1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-07-22 03:05:32 +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

202
frontend/src/App.jsx Normal file
View file

@ -0,0 +1,202 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { fetchArchives, fetchEntries, searchEntries, fetchRuns, fetchTags } from './api'
import Topbar from './components/Topbar'
import CaptureDialog from './components/CaptureDialog'
import EntriesView from './components/EntriesView'
import RunsView from './components/RunsView'
import AdminView from './components/AdminView'
import TagsView from './components/TagsView'
import ContextRail from './components/ContextRail'
export default function App() {
const [archives, setArchives] = useState([])
const [archiveId, setArchiveId] = useState(null)
const [entries, setEntries] = useState([])
const [selectedEntryUid, setSelectedEntryUid] = useState(null)
const [selectedEntry, setSelectedEntry] = useState(null)
const [tagFilter, setTagFilter] = useState(null)
const [view, setView] = useState('archive')
const [searchQuery, setSearchQuery] = useState('')
const [resultCount, setResultCount] = useState('')
const [searchBusy, setSearchBusy] = useState(false)
const [runs, setRuns] = useState([])
const [tagNodes, setTagNodes] = useState([])
const [captureDialogOpen, setCaptureDialogOpen] = useState(false)
const loadEntries = useCallback(async (aid, q, tag) => {
if (!aid) return
setSearchBusy(true)
try {
let results
if (q || tag) {
results = await searchEntries(aid, q, tag)
} else {
results = await fetchEntries(aid)
}
setEntries(results)
setResultCount(results.length === 0 ? 'No results' : `${results.length} result${results.length === 1 ? '' : 's'}`)
} catch {
setEntries([])
setResultCount('Search failed. Try again.')
} finally {
setSearchBusy(false)
}
}, [])
// Mount: load archives, pick first, then parallel load
useEffect(() => {
fetchArchives().then(list => {
setArchives(list)
if (list.length > 0) {
const first = list[0].id
setArchiveId(first)
}
})
}, [])
// Archive change: parallel load entries + runs + tags
useEffect(() => {
if (!archiveId) return
setTagFilter(null)
setSelectedEntry(null)
setSelectedEntryUid(null)
Promise.all([
loadEntries(archiveId, '', null),
fetchRuns(archiveId).then(setRuns),
fetchTags(archiveId).then(setTagNodes),
])
}, [archiveId]) // intentionally not including loadEntries to avoid re-running on its recreation
// Debounced search
useEffect(() => {
if (archiveId === null) return
const timer = setTimeout(() => {
loadEntries(archiveId, searchQuery, tagFilter)
}, 300)
return () => clearTimeout(timer)
}, [searchQuery, archiveId]) // tagFilter handled separately below
// Tag filter change: switch to archive view, reload
useEffect(() => {
if (archiveId === null) return
setView('archive')
loadEntries(archiveId, searchQuery, tagFilter)
}, [tagFilter, archiveId]) // intentional: searchQuery excluded to avoid double-fire
const handleArchiveChange = useCallback((id) => {
setArchiveId(id)
}, [])
const handleViewChange = useCallback((name) => {
setView(name)
if (name === 'tags' && archiveId) {
fetchTags(archiveId).then(setTagNodes)
}
}, [archiveId])
const selectEntry = useCallback((entry) => {
setSelectedEntryUid(entry ? entry.entry_uid : null)
setSelectedEntry(entry)
}, [])
const handleTagFilterSet = useCallback((fullPath) => {
setTagFilter(fullPath)
}, [])
const handleClearTagFilter = useCallback(() => {
setTagFilter(null)
}, [])
const handleTagsRefresh = useCallback(() => {
if (archiveId) fetchTags(archiveId).then(setTagNodes)
}, [archiveId])
const handleCaptureClick = useCallback(() => {
setCaptureDialogOpen(true)
}, [])
const handleCaptureClose = useCallback(() => {
setCaptureDialogOpen(false)
}, [])
const handleCaptured = useCallback(() => {
if (!archiveId) return
Promise.all([
loadEntries(archiveId, searchQuery, tagFilter),
fetchRuns(archiveId).then(setRuns),
])
}, [archiveId, searchQuery, tagFilter, loadEntries])
return (
<>
<Topbar
archives={archives}
archiveId={archiveId}
onArchiveChange={handleArchiveChange}
view={view}
onViewChange={handleViewChange}
onCaptureClick={handleCaptureClick}
/>
<main className="app-shell">
<div className="workspace">
{view === 'archive' && (
<div className="search-row">
<input
className="search-input"
type="search"
aria-label="Search archive"
aria-busy={searchBusy}
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
/>
<div className="result-count">
{resultCount}
{tagFilter && (
<button className="tag-filter-badge" onClick={handleClearTagFilter}>
× {tagFilter}
</button>
)}
</div>
</div>
)}
{view === 'archive' && (
<EntriesView
entries={entries}
selectedEntryUid={selectedEntryUid}
onSelectEntry={selectEntry}
tagFilter={tagFilter}
onClearTagFilter={handleClearTagFilter}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
resultCount={resultCount}
searchBusy={searchBusy}
/>
)}
{view === 'runs' && <RunsView runs={runs} />}
{view === 'admin' && <AdminView archives={archives} />}
{view === 'tags' && (
<TagsView
tagNodes={tagNodes}
tagFilter={tagFilter}
onTagFilterSet={handleTagFilterSet}
onViewChange={handleViewChange}
/>
)}
</div>
<ContextRail
archiveId={archiveId}
selectedEntry={selectedEntry}
onTagFilterSet={handleTagFilterSet}
tagNodes={tagNodes}
onTagsRefresh={handleTagsRefresh}
/>
</main>
<CaptureDialog
open={captureDialogOpen}
archiveId={archiveId}
onClose={handleCaptureClose}
onCaptured={handleCaptured}
/>
</>
)
}

70
frontend/src/api.js Normal file
View file

@ -0,0 +1,70 @@
async function getJson(url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}`);
}
return response.json();
}
export async function fetchArchives() {
return getJson("/api/archives");
}
export async function fetchEntries(archiveId) {
return getJson(`/api/archives/${archiveId}/entries`);
}
export async function searchEntries(archiveId, q, tag) {
const params = new URLSearchParams();
if (q) params.set("q", q);
if (tag) params.set("tag", tag);
return getJson(`/api/archives/${archiveId}/entries/search?${params}`);
}
export async function fetchEntryDetail(archiveId, entryUid) {
return getJson(`/api/archives/${archiveId}/entries/${entryUid}`);
}
export async function fetchEntryTags(archiveId, entryUid) {
return getJson(`/api/archives/${archiveId}/entries/${entryUid}/tags`);
}
export async function assignTag(archiveId, entryUid, tagPath) {
const resp = await fetch(
`/api/archives/${archiveId}/entries/${entryUid}/tags`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tag_path: tagPath }),
}
);
if (!resp.ok) throw new Error(`Failed to add tag (${resp.status})`);
}
export async function removeTag(archiveId, entryUid, tagUid) {
const resp = await fetch(
`/api/archives/${archiveId}/entries/${entryUid}/tags/${tagUid}`,
{ method: "DELETE" }
);
if (!resp.ok) throw new Error(`Remove failed (${resp.status})`);
}
export async function fetchRuns(archiveId) {
return getJson(`/api/archives/${archiveId}/runs`);
}
export async function fetchTags(archiveId) {
return getJson(`/api/archives/${archiveId}/tags`);
}
export async function submitCapture(archiveId, locator) {
const res = await fetch(`/api/archives/${archiveId}/captures`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ locator }),
});
if (!res.ok) {
const msg = await res.text();
throw new Error(msg || `HTTP ${res.status}`);
}
}

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>
)
}

8
frontend/src/main.jsx Normal file
View file

@ -0,0 +1,8 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './styles.css'
import App from './App'
createRoot(document.getElementById('root')).render(
<StrictMode><App /></StrictMode>
)

642
frontend/src/styles.css Normal file
View file

@ -0,0 +1,642 @@
:root {
color-scheme: light;
--ink: #20251f;
--muted: #666a61;
--paper: #f5f0e7;
--paper-2: #e9e1d2;
--paper-3: #fffaf0;
--line: #d2c6b5;
--line-soft: #e5dccd;
--accent: #8d3f30;
--accent-2: #b78342;
--link: #245f72;
--top: #141d18;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background: var(--paper);
color: var(--ink);
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
button,
input,
select {
font: inherit;
}
.topbar {
height: 56px;
display: grid;
grid-template-columns: auto minmax(180px, 250px) 1fr auto;
align-items: center;
gap: 18px;
padding: 0 18px;
background: var(--top);
color: #f7eedf;
border-bottom: 3px solid var(--accent);
}
.brand {
font-family: Georgia, "Times New Roman", serif;
font-size: 28px;
line-height: 1;
}
.archive-switcher {
width: 100%;
min-width: 0;
border: 1px solid rgba(247, 238, 223, 0.35);
background: #202b25;
color: #f7eedf;
padding: 7px 9px;
}
.nav {
display: flex;
gap: 16px;
justify-content: flex-end;
min-width: 0;
}
.nav-link {
border: 0;
background: transparent;
color: #d7cdbf;
cursor: pointer;
padding: 8px 0;
}
.nav-link.is-active {
color: #fffaf0;
border-bottom: 1px solid #fffaf0;
}
.capture-button {
border: 0;
background: #f7eedf;
color: var(--top);
padding: 9px 12px;
cursor: pointer;
}
.app-shell {
height: calc(100vh - 56px);
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
}
.workspace {
min-width: 0;
overflow: auto;
}
.search-row {
min-height: 76px;
display: grid;
grid-template-columns: minmax(0, 1fr) max-content;
gap: 18px;
align-items: center;
padding: 14px 16px;
background: var(--paper-2);
border-bottom: 1px solid var(--line);
}
.search-input {
width: 100%;
height: 46px;
border: 2px solid var(--ink);
background: var(--paper-3);
color: var(--ink);
padding: 0 14px;
font-size: 16px;
outline-offset: 3px;
}
.result-count {
min-width: 76px;
color: var(--muted);
font-size: 13px;
text-align: right;
}
.view {
display: none;
}
.view.is-active {
display: block;
}
.entry-table {
width: 100%;
font-size: 13px;
overflow: auto;
}
.entry-header-row {
display: flex;
align-items: stretch;
position: sticky;
top: 0;
z-index: 1;
background: #ded5c7;
border-bottom: 1px solid #cec4b5;
color: #5d625e;
font-size: 11px;
text-transform: uppercase;
}
.entry-header-row > div {
padding: 10px;
flex-shrink: 0;
}
#entries-body > div {
display: flex;
align-items: baseline;
cursor: default;
border-bottom: 1px solid var(--line-soft);
}
#entries-body > div > div {
padding: 10px;
flex-shrink: 0;
overflow: hidden;
}
#entries-body > div:nth-child(even) {
background: #f2ede5;
}
#entries-body > div:nth-child(odd) {
background: var(--paper-3);
}
#entries-body > div.is-selected {
background: #eee2d2;
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.col-added {
width: 168px;
}
.col-title {
flex: 1 1 0;
min-width: 0;
overflow: hidden;
}
.col-type {
width: 120px;
}
.col-size {
width: 100px;
}
.col-url {
flex: 0 0 30%;
min-width: 0;
overflow: hidden;
}
/* Runs table (still a <table>) */
#runs-view .entry-table {
border-collapse: collapse;
table-layout: auto;
}
#runs-view .entry-table th {
position: sticky;
top: 0;
z-index: 1;
text-align: left;
padding: 10px;
background: #ded5c7;
color: #5d625e;
border-bottom: 1px solid #cec4b5;
font-size: 11px;
text-transform: uppercase;
}
#runs-view .entry-table td {
padding: 10px;
border-bottom: 1px solid var(--line-soft);
vertical-align: top;
}
#runs-view .entry-table tr:nth-child(even) td { background: #f2ede5; }
#runs-view .entry-table tr:nth-child(odd) td { background: var(--paper-3); }
.entry-title {
color: var(--link);
font-weight: 750;
}
.source-icon {
display: inline-flex;
align-items: center;
width: 1em;
height: 1em;
margin-right: 0.35em;
vertical-align: middle;
flex-shrink: 0;
}
.source-icon svg {
width: 100%;
height: 100%;
}
.url-cell {
color: #555b55;
word-break: break-all;
}
.type-pill {
display: inline-block;
padding: 2px 6px;
background: #d8e3df;
color: #275a5f;
border: 1px solid #bfd0ca;
}
.context-rail {
border-left: 1px solid var(--line);
background: #efe7dc;
padding: 18px;
overflow: auto;
}
.rail-title {
color: var(--accent);
font-weight: 800;
margin-bottom: 10px;
text-transform: uppercase;
font-size: 12px;
letter-spacing: 0;
}
.rail-body {
color: var(--muted);
font-size: 14px;
line-height: 1.6;
}
.rail-body strong {
color: var(--ink);
}
.rail-item {
padding: 10px 0;
border-bottom: 1px solid var(--line);
}
.admin-view {
padding: 22px;
}
.admin-view h1 {
margin: 0 0 16px;
font-size: 18px;
}
.admin-list {
display: grid;
gap: 10px;
max-width: 780px;
}
.admin-archive {
border: 1px solid var(--line);
background: var(--paper-3);
padding: 12px;
}
.muted {
color: var(--muted);
}
@media (max-width: 900px) {
.topbar {
grid-template-columns: 1fr auto;
height: auto;
min-height: 56px;
padding: 12px;
}
.archive-switcher,
.nav {
grid-column: 1 / -1;
}
.nav {
justify-content: flex-start;
overflow-x: auto;
}
.app-shell {
height: auto;
grid-template-columns: 1fr;
}
.search-row {
grid-template-columns: 1fr;
}
.result-count {
text-align: left;
}
.context-rail {
border-left: 0;
border-top: 1px solid var(--line);
}
.entry-table {
min-width: 860px;
}
}
.rail-entry-title {
display: block;
font-size: 15px;
font-weight: 700;
color: var(--ink);
margin-bottom: 12px;
line-height: 1.4;
}
.rail-section {
margin-bottom: 18px;
}
.rail-section-heading {
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--accent);
margin-bottom: 6px;
}
.rail-label {
font-weight: 600;
color: var(--ink);
}
.rail-url-link {
color: var(--accent);
word-break: break-all;
font-size: 13px;
}
.artifact-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.artifact-link {
display: block;
padding: 6px 8px;
background: var(--paper-3);
border: 1px solid var(--line);
color: var(--accent);
text-decoration: none;
font-size: 13px;
border-radius: 3px;
}
.artifact-link:hover {
background: var(--line);
text-decoration: underline;
}
/* Search loading state */
.search-input[aria-busy="true"] {
cursor: progress;
}
/* Tag tree */
.tag-tree {
padding: 12px;
}
.tag-tree-list {
list-style: none;
margin: 0;
padding: 0;
}
.tag-children {
padding-left: 16px;
}
.tag-node-btn {
border: 0;
background: transparent;
color: var(--link);
cursor: pointer;
padding: 3px 0;
text-align: left;
font-size: 13px;
display: block;
width: 100%;
}
.tag-node-btn:hover {
text-decoration: underline;
}
.tag-node-btn.is-active {
font-weight: 600;
color: var(--ink);
}
/* Tag pills in context rail */
.entry-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 8px 0;
}
.tag-pill {
display: inline-flex;
align-items: center;
gap: 4px;
background: var(--paper-2);
border: 1px solid var(--line);
border-radius: 3px;
font-size: 12px;
padding: 2px 6px;
color: var(--ink);
}
.remove-tag {
border: 0;
background: none;
cursor: pointer;
color: var(--muted);
padding: 0;
font-size: 14px;
line-height: 1;
}
.remove-tag:hover {
color: var(--accent);
}
/* Assign tag form */
.assign-tag-form {
display: flex;
gap: 6px;
padding: 6px 0 8px;
border-top: 1px solid var(--line-soft);
margin-top: 4px;
}
.assign-tag-input {
flex: 1;
min-width: 0;
border: 1px solid var(--line);
background: var(--paper-3);
color: var(--ink);
padding: 4px 7px;
font-size: 12px;
}
.assign-tag-btn {
border: 1px solid var(--line);
background: var(--paper-2);
color: var(--ink);
cursor: pointer;
padding: 4px 10px;
font-size: 12px;
white-space: nowrap;
}
.assign-tag-btn:hover {
background: var(--paper);
}
/* Tag filter badge in search row */
.tag-filter-badge {
display: inline-flex;
align-items: center;
gap: 4px;
background: var(--accent);
color: #fff;
border: 0;
border-radius: 3px;
font-size: 12px;
padding: 2px 8px;
cursor: pointer;
margin-left: 8px;
}
.tag-filter-badge:hover {
opacity: 0.85;
}
/* Capture dialog */
.capture-dialog {
border: 2px solid var(--ink);
background: var(--paper);
padding: 0;
min-width: 360px;
max-width: 520px;
box-shadow: 4px 4px 0 var(--ink);
}
.capture-dialog::backdrop {
background: rgba(0, 0, 0, 0.45);
}
.capture-dialog-inner {
padding: 28px;
}
.capture-dialog-title {
margin: 0 0 16px;
font-size: 18px;
font-family: Georgia, "Times New Roman", serif;
}
.capture-label {
display: block;
font-size: 13px;
font-weight: 600;
margin-bottom: 6px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.capture-input {
width: 100%;
height: 42px;
border: 2px solid var(--ink);
background: var(--paper-3);
color: var(--ink);
padding: 0 12px;
font-size: 15px;
margin-bottom: 10px;
outline-offset: 3px;
}
.capture-error {
color: var(--accent);
font-size: 13px;
margin-bottom: 10px;
min-height: 18px;
}
.capture-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 16px;
}
.capture-cancel {
border: 1px solid var(--line);
background: none;
color: var(--ink);
padding: 8px 18px;
cursor: pointer;
}
.capture-cancel:hover {
background: var(--paper-2);
}
.capture-submit {
border: 0;
background: var(--ink);
color: var(--paper);
padding: 8px 20px;
cursor: pointer;
font-weight: 600;
}
.capture-submit:hover {
opacity: 0.85;
}
.capture-submit:disabled {
opacity: 0.45;
cursor: default;
}

40
frontend/src/utils.js Normal file
View file

@ -0,0 +1,40 @@
export function formatBytes(bytes) {
if (!bytes) return "0 B";
const units = ["B", "KB", "MB", "GB"];
let size = bytes;
let unit = 0;
while (size >= 1024 && unit < units.length - 1) {
size /= 1024;
unit += 1;
}
return `${size.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`;
}
export function valueText(value) {
return value ?? "";
}
export function formatTimestamp(value) {
if (!value) return "";
const d = new Date(value);
if (isNaN(d)) return value;
const pad = (n) => String(n).padStart(2, "0");
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;
}
export const SOURCE_ICONS = {
youtube: `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path fill="#FF0000" d="M23.5 6.2a3 3 0 0 0-2.1-2.1C19.5 3.6 12 3.6 12 3.6s-7.5 0-9.4.5A3 3 0 0 0 .5 6.2C0 8.1 0 12 0 12s0 3.9.5 5.8a3 3 0 0 0 2.1 2.1c1.9.5 9.4.5 9.4.5s7.5 0 9.4-.5a3 3 0 0 0 2.1-2.1C24 15.9 24 12 24 12s0-3.9-.5-5.8z"/><polygon fill="#fff" points="9.6,15.6 15.8,12 9.6,8.4"/></svg>`,
x: `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M18.2 2h3.3l-7.2 8.2L23 22h-6.6l-5.2-6.8L5 22H1.7l7.7-8.8L1 2h6.8l4.7 6.2zm-1.1 18h1.8L6.9 3.9H5z"/></svg>`,
instagram: `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="ig" x1="0%" y1="100%" x2="100%" y2="0%"><stop offset="0%" stop-color="#f09433"/><stop offset="25%" stop-color="#e6683c"/><stop offset="50%" stop-color="#dc2743"/><stop offset="75%" stop-color="#cc2366"/><stop offset="100%" stop-color="#bc1888"/></linearGradient></defs><rect width="24" height="24" rx="5" fill="url(#ig)"/><rect x="2.5" y="2.5" width="19" height="19" rx="4" fill="none" stroke="#fff" stroke-width="1.5"/><circle cx="12" cy="12" r="4" fill="none" stroke="#fff" stroke-width="1.5"/><circle cx="17.5" cy="6.5" r="1" fill="#fff"/></svg>`,
facebook: `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><rect width="24" height="24" rx="4" fill="#1877F2"/><path fill="#fff" d="M16 8h-2c-.6 0-1 .4-1 1v2h3l-.4 3H13v8h-3v-8H8v-3h2V9a4 4 0 0 1 4-4h2v3z"/></svg>`,
tiktok: `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M19.6 5.4a4.8 4.8 0 0 1-4.8-4.8h-3v14.7a2 2 0 1 1-2-2l.1-3a5 5 0 1 0 4.9 5V8.4a8 8 0 0 0 4.8 1.6V6.7a4.8 4.8 0 0 1-0-.2l0 0 .1.9z" fill="#000"/><path d="M18.6 4.4a4.8 4.8 0 0 1-4.8-4.8h-3v14.7a2 2 0 1 1-2-2l.1-3a5 5 0 1 0 4.9 5V7.4a8 8 0 0 0 4.8 1.6V5.7" fill="none" stroke="#69C9D0" stroke-width="1.5"/></svg>`,
reddit: `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><circle cx="12" cy="12" r="12" fill="#FF4500"/><path fill="#fff" d="M20 12a2 2 0 0 0-2-2 2 2 0 0 0-1.3.5c-1.3-.9-3-.9-4.6-.9l.8-3.7 2.6.5a1.4 1.4 0 1 0 1.4-1.3 1.4 1.4 0 0 0-1.3.8l-2.9-.5a.3.3 0 0 0-.4.3l-.9 4.1c-1.6 0-3.1.1-4.3.9A2 2 0 0 0 4 12a2 2 0 0 0 1 1.7 3.3 3.3 0 0 0 0 .5c0 2.6 3 4.8 6.8 4.8s6.8-2.1 6.8-4.8a3.3 3.3 0 0 0 0-.5A2 2 0 0 0 20 12zm-13.6 1a1.1 1.1 0 1 1 1.1 1.1A1.1 1.1 0 0 1 6.4 13zm6.2 3.1a3.5 3.5 0 0 1-2.3.7 3.5 3.5 0 0 1-2.3-.7.3.3 0 0 1 .4-.4 3 3 0 0 0 1.9.5 3 3 0 0 0 1.9-.5.3.3 0 0 1 .4.4zm-.3-2a1.1 1.1 0 1 1 1.1-1.1A1.1 1.1 0 0 1 12.3 14.1z"/></svg>`,
snapchat: `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><rect width="24" height="24" rx="4" fill="#FFFC00"/><path d="M12 3.5c-2.4 0-4.4 2-4.4 4.4v1.3l-.8.1c-.3 0-.5.2-.5.5s.3.5.6.6c.3.1.5.3.5.8 0 .3-.1.5-.3.7-.6.8-1.5 1.3-2.2 1.4-.1.7.7 1 1.7 1.2.1.6.3.8.8.8.6 0 1.1.4 2.7.4 1.5 0 2.1-.4 2.7-.4.5 0 .7-.2.8-.8 1-.2 1.8-.5 1.7-1.2-.7-.1-1.6-.6-2.2-1.4-.2-.2-.3-.4-.3-.7 0-.5.2-.7.5-.8.3-.1.6-.3.6-.6s-.2-.5-.5-.5l-.8-.1V7.9C16.4 5.5 14.4 3.5 12 3.5z"/></svg>`,
local: `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path fill="none" stroke="#666" stroke-width="1.5" stroke-linejoin="round" d="M4 4h7l2 2h7v14H4z"/></svg>`,
web: `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><circle cx="12" cy="12" r="10" fill="none" stroke="#555" stroke-width="1.5"/><ellipse cx="12" cy="12" rx="4" ry="10" fill="none" stroke="#555" stroke-width="1.5"/><line x1="2" y1="9" x2="22" y2="9" stroke="#555" stroke-width="1.5"/><line x1="2" y1="15" x2="22" y2="15" stroke="#555" stroke-width="1.5"/></svg>`,
other: `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><circle cx="12" cy="12" r="10" fill="none" stroke="#999" stroke-width="1.5"/><text x="12" y="16" text-anchor="middle" font-size="12" fill="#999">?</text></svg>`,
};
export function sourceIconSvg(kind) {
return SOURCE_ICONS[kind] ?? SOURCE_ICONS.other;
}