1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-07-22 11:15:41 +02:00

feat(ui): rewrite frontend in React with Vite

- Scaffold Vite+React project in frontend/ (bun, react 18, @vitejs/plugin-react)
- vite.config.js outputs to crates/archivr-server/static/ directly
- src/utils.js: formatBytes, valueText, formatTimestamp, SOURCE_ICONS, sourceIconSvg
- src/api.js: typed fetch wrappers for all API endpoints
- App.jsx: full state management, archive switching, debounced search,
  tag filter, view routing, capture dialog orchestration
- components/Topbar.jsx: archive switcher, nav, capture button
- components/CaptureDialog.jsx: native <dialog> ref with showModal/close,
  Escape key support, locator validation
- components/EntriesView.jsx + EntryRow.jsx: flex table with source icons,
  type pills, keyboard selection
- components/ContextRail.jsx: parallel detail+tags fetch, stale-race guard
  via selectSeqRef, inline tag assign/remove
- components/RunsView.jsx: runs kept as <table> (matches original)
- components/AdminView.jsx: mounted archives list
- components/TagsView.jsx: recursive tag tree with active state
- Remove old app.js; styles.css moved to frontend/src/ (Vite bundles it)
This commit is contained in:
TheGeneralist 2026-06-24 12:24:34 +02:00
parent b895d6331c
commit 4458f17b13
Signed by: thegeneralist01
SSH key fingerprint: SHA256:pp9qddbCNmVNoSjevdvQvM5z0DHN7LTa8qBMbcMq/R4
22 changed files with 1035 additions and 591 deletions

View file

@ -0,0 +1,15 @@
export default function AdminView({ archives }) {
return (
<section id="admin-view" className="view admin-view is-active">
<h1>Mounted Archives</h1>
<div className="admin-list">
{archives.map(archive => (
<div key={archive.id} className="admin-archive">
<strong>{archive.label}</strong>
<div className="muted">{archive.archive_path}</div>
</div>
))}
</div>
</section>
)
}

View file

@ -0,0 +1,65 @@
import { useRef, useEffect, useState } from 'react'
import { submitCapture } from '../api'
export default function CaptureDialog({ open, archiveId, onClose, onCaptured }) {
const dialogRef = useRef(null)
const [locator, setLocator] = useState('')
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
useEffect(() => {
const dialog = dialogRef.current
if (!dialog) return
const handleClose = () => onClose()
dialog.addEventListener('close', handleClose)
return () => dialog.removeEventListener('close', handleClose)
}, [onClose])
useEffect(() => {
const dialog = dialogRef.current
if (!dialog) return
if (open) {
setLocator('')
setError(null)
if (!dialog.open) dialog.showModal()
} else {
if (dialog.open) dialog.close()
}
}, [open])
async function handleSubmit() {
if (!locator.trim()) { setError('Enter a locator.'); return }
setBusy(true)
setError(null)
try {
await submitCapture(archiveId, locator.trim())
dialogRef.current?.close()
onCaptured()
} catch (e) {
setError(e.message)
} finally {
setBusy(false)
}
}
return (
<dialog ref={dialogRef} className="capture-dialog">
<div className="capture-dialog-inner">
<h2 className="capture-dialog-title">Capture</h2>
<label htmlFor="capture-locator" className="capture-label">Locator</label>
<input id="capture-locator" className="capture-input" type="text"
placeholder="tweet:1234567890 or https://..."
value={locator} onChange={e => setLocator(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleSubmit() }}
autoComplete="off" />
{error && <div className="capture-error">{error}</div>}
<div className="capture-actions">
<button type="button" className="capture-cancel" onClick={() => dialogRef.current?.close()}>Cancel</button>
<button type="button" className="capture-submit" onClick={handleSubmit} disabled={busy}>
{busy ? 'Capturing\u2026' : 'Capture'}
</button>
</div>
</div>
</dialog>
)
}

View file

@ -0,0 +1,163 @@
import { useState, useEffect, useRef } from 'react'
import { fetchEntryDetail, fetchEntryTags, assignTag, removeTag } from '../api'
import { formatTimestamp, formatBytes, valueText } from '../utils'
export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet, tagNodes, onTagsRefresh }) {
const [detail, setDetail] = useState(null)
const [tags, setTags] = useState([])
const [assignInput, setAssignInput] = useState('')
const [assignError, setAssignError] = useState('')
const selectSeqRef = useRef(0)
useEffect(() => {
if (!selectedEntry || !archiveId) {
setDetail(null)
setTags([])
return
}
const seq = ++selectSeqRef.current
setDetail(null)
setTags([])
Promise.all([
fetchEntryDetail(archiveId, selectedEntry.entry_uid),
fetchEntryTags(archiveId, selectedEntry.entry_uid),
]).then(([det, tgs]) => {
if (seq !== selectSeqRef.current) return
setDetail(det)
setTags(tgs)
}).catch(() => {})
}, [selectedEntry, archiveId])
async function handleAssignTag() {
const path = assignInput.trim()
if (!path || !selectedEntry) return
try {
await assignTag(archiveId, selectedEntry.entry_uid, path)
setAssignInput('')
setAssignError('')
const updated = await fetchEntryTags(archiveId, selectedEntry.entry_uid)
setTags(updated)
onTagsRefresh()
} catch (e) {
setAssignError(e.message)
}
}
async function handleRemoveTag(tagUid) {
try {
await removeTag(archiveId, selectedEntry.entry_uid, tagUid)
const updated = await fetchEntryTags(archiveId, selectedEntry.entry_uid)
setTags(updated)
onTagsRefresh()
} catch (e) {
// silently ignore for now; could add error state
}
}
return (
<aside className="context-rail">
<div className="rail-title">Context</div>
{!selectedEntry ? (
<div className="rail-body">Select an entry.</div>
) : !detail ? (
<div className="rail-body">Loading</div>
) : (
<div className="rail-body">
<strong className="rail-entry-title">
{valueText(detail.summary.title) || valueText(detail.summary.entry_uid)}
</strong>
<div className="rail-section">
{detail.summary.original_url && (
<div className="rail-item">
<span className="rail-label">Original URL</span>:{' '}
<a
href={detail.summary.original_url}
target="_blank"
rel="noopener noreferrer"
className="rail-url-link"
>
{detail.summary.original_url}
</a>
</div>
)}
{[
['Added', formatTimestamp(detail.summary.archived_at)],
['Source', detail.summary.source_kind],
['Type', detail.summary.entity_kind],
['Visibility', detail.summary.visibility],
['Structured root', detail.structured_root_relpath],
].map(([label, value]) => (
<div key={label} className="rail-item">
<span className="rail-label">{label}</span>: {valueText(value)}
</div>
))}
</div>
{detail.artifacts.length > 0 ? (
<div className="rail-section">
<div className="rail-section-heading">Artifacts ({detail.artifacts.length})</div>
<ul className="artifact-list">
{detail.artifacts.map((artifact, index) => (
<li key={index}>
<a
href={`/api/archives/${archiveId}/entries/${detail.summary.entry_uid}/artifacts/${index}`}
target="_blank"
rel="noopener noreferrer"
className="artifact-link"
>
{artifact.artifact_role.replace(/_/g, ' ')}
{artifact.byte_size != null ? ` (${formatBytes(artifact.byte_size)})` : ''}
</a>
</li>
))}
</ul>
</div>
) : (
<div className="rail-item muted">No artifacts.</div>
)}
</div>
)}
{selectedEntry && (
<>
<div className="entry-tags">
{tags.length === 0 ? (
<span className="muted">No tags.</span>
) : (
tags.map(tag => (
<span key={tag.tag_uid} className="tag-pill" title={tag.full_path}>
{tag.name}
<button
className="remove-tag"
title={`Remove tag ${tag.full_path}`}
onClick={() => handleRemoveTag(tag.tag_uid)}
>
×
</button>
</span>
))
)}
</div>
<div className="assign-tag-form">
<input
className="assign-tag-input"
type="text"
placeholder="/science/cs"
autoComplete="off"
value={assignInput}
onChange={e => setAssignInput(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleAssignTag() }}
/>
<button className="assign-tag-btn" onClick={handleAssignTag}>Add tag</button>
{assignError && (
<div className="muted" style={{ fontSize: '0.85em', color: 'var(--accent)' }}>
{assignError}
</div>
)}
</div>
</>
)}
</aside>
)
}

View file

@ -0,0 +1,27 @@
import EntryRow from './EntryRow';
export default function EntriesView({ entries, selectedEntryUid, onSelectEntry }) {
return (
<section id="archive-view" className="view is-active">
<div className="entry-table">
<div className="entry-header-row">
<div className="col-added">Added</div>
<div className="col-title">Title</div>
<div className="col-type">Type</div>
<div className="col-size">Size</div>
<div className="col-url">Original URL</div>
</div>
<div id="entries-body">
{entries.map(entry => (
<EntryRow
key={entry.entry_uid}
entry={entry}
isSelected={entry.entry_uid === selectedEntryUid}
onSelect={() => onSelectEntry(entry)}
/>
))}
</div>
</div>
</section>
);
}

View file

@ -0,0 +1,24 @@
import { formatTimestamp, formatBytes, valueText, sourceIconSvg } from '../utils';
export default function EntryRow({ entry, isSelected, onSelect }) {
return (
<div
className={isSelected ? 'is-selected' : undefined}
tabIndex={0}
data-entry-uid={entry.entry_uid}
onClick={onSelect}
onKeyDown={e => { if (e.key === 'Enter') onSelect(); }}
>
<div className="col-added">{formatTimestamp(entry.archived_at)}</div>
<div className="col-title">
<span className="source-icon" dangerouslySetInnerHTML={{ __html: sourceIconSvg(entry.source_kind) }} />
<span className="entry-title">{valueText(entry.title) || valueText(entry.entry_uid)}</span>
</div>
<div className="col-type">
<span className="type-pill">{valueText(entry.entity_kind)}</span>
</div>
<div className="col-size">{formatBytes(entry.total_artifact_bytes)}</div>
<div className="url-cell col-url">{valueText(entry.original_url)}</div>
</div>
);
}

View file

@ -0,0 +1,24 @@
export default function RunsView({ runs }) {
return (
<section id="runs-view" className="view is-active">
<table className="entry-table">
<thead>
<tr>
<th>Started</th><th>Status</th><th>Requested</th><th>Completed</th><th>Failed</th>
</tr>
</thead>
<tbody>
{runs.map((run, i) => (
<tr key={i}>
<td>{run.started_at ?? ''}</td>
<td>{run.status ?? ''}</td>
<td>{run.requested_count ?? ''}</td>
<td>{run.completed_count ?? ''}</td>
<td>{run.failed_count ?? ''}</td>
</tr>
))}
</tbody>
</table>
</section>
)
}

View file

@ -0,0 +1,47 @@
function TagNode({ node, tagFilter, onTagFilterSet, onViewChange }) {
const isActive = tagFilter === node.tag.full_path
function handleClick() {
const next = isActive ? null : node.tag.full_path
onTagFilterSet(next)
onViewChange('archive')
}
return (
<li>
<button
className={`tag-node-btn${isActive ? ' is-active' : ''}`}
title={node.tag.full_path}
onClick={handleClick}>
{node.tag.name}
</button>
{node.children?.length > 0 && (
<div className="tag-children">
<ul className="tag-tree-list">
{node.children.map(child => (
<TagNode key={child.tag.tag_uid} node={child}
tagFilter={tagFilter} onTagFilterSet={onTagFilterSet} onViewChange={onViewChange} />
))}
</ul>
</div>
)}
</li>
)
}
export default function TagsView({ tagNodes, tagFilter, onTagFilterSet, onViewChange }) {
return (
<section id="tags-view" className="view is-active">
<div className="tag-tree">
{tagNodes.length === 0 ? (
<div>No tags yet.</div>
) : (
<ul className="tag-tree-list">
{tagNodes.map(node => (
<TagNode key={node.tag.tag_uid} node={node}
tagFilter={tagFilter} onTagFilterSet={onTagFilterSet} onViewChange={onViewChange} />
))}
</ul>
)}
</div>
</section>
)
}

View file

@ -0,0 +1,20 @@
export default function Topbar({ archives, archiveId, onArchiveChange, view, onViewChange, onCaptureClick }) {
return (
<header className="topbar">
<div className="brand">Archivr</div>
<select className="archive-switcher" aria-label="Select archive"
value={archiveId ?? ''} onChange={e => onArchiveChange(e.target.value)}>
{archives.map(a => <option key={a.id} value={a.id}>{a.label}</option>)}
</select>
<nav className="nav" aria-label="Primary">
{['archive', 'runs', 'admin', 'tags'].map(name => (
<button key={name} className={`nav-link${view === name ? ' is-active' : ''}`}
onClick={() => onViewChange(name)}>
{name.charAt(0).toUpperCase() + name.slice(1)}
</button>
))}
</nav>
<button className="capture-button" onClick={onCaptureClick}>+ Capture</button>
</header>
)
}