1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-07-22 03:05:32 +02:00

Compare commits

...

2 commits

Author SHA1 Message Date
4311e85f95
ui: comprehensive UI polish pass
- LoginPage/SetupPage: centered card layout with display font, styled fields and submit button
- Topbar: fix duplicate Settings nav item; add styled user-menu with username + logout button
- RunsView: format ISO timestamps to readable dates, add colored status badges (completed/failed/running)
- AdminView: view-tabs system, styled admin-table, admin-input, status-badge (active/disabled), btn-primary
- SettingsView: replace all inline styles with form-section/form-field/field-input/btn-primary/btn-danger
- CollectionsView: restructure create form with proper field labels and btn-primary
- TagsView: add Tags section heading with separator
- ContextRail: show visibility as human-readable label not raw number; fix assign-error class
- styles.css: add auth-loading, view-tabs, form utilities, btn variants, status badges,
  run-status pills, token-banner/row, checkbox-row, coll-create-form, tag-tree-header CSS
2026-06-28 22:14:08 +02:00
3ccfcce87b
feat(ui): add Storybook design system refinements 2026-06-28 21:01:50 +02:00
32 changed files with 3567 additions and 1097 deletions

7
.gitignore vendored
View file

@ -23,3 +23,10 @@
!frontend/** !frontend/**
frontend/node_modules/ frontend/node_modules/
frontend/node_modules/** frontend/node_modules/**
frontend/result
frontend/result/**
frontend/dist/
frontend/dist/**
.DS_Store
frontend/storybook-static/
frontend/storybook-static/**

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -4,8 +4,8 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Archivr</title> <title>Archivr</title>
<script type="module" crossorigin src="/assets/index-C6h0jcH3.js"></script> <script type="module" crossorigin src="/assets/index-CWb0sQjJ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D40QFUPh.css"> <link rel="stylesheet" crossorigin href="/assets/index-MrdP6h9x.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View file

@ -0,0 +1,13 @@
/** @type { import('@storybook/react-vite').StorybookConfig } */
const config = {
stories: ['../src/**/*.stories.{js,jsx,ts,tsx}'],
addons: [
'@storybook/addon-essentials',
'@storybook/addon-interactions',
],
framework: {
name: '@storybook/react-vite',
options: {},
},
};
export default config;

View file

@ -0,0 +1,5 @@
import '../src/styles.css';
export const parameters = {
layout: 'fullscreen',
};

File diff suppressed because it is too large Load diff

View file

@ -6,14 +6,24 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview",
"storybook": "storybook dev -p 6006",
"storybook:build": "storybook build"
}, },
"dependencies": { "dependencies": {
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1"
}, },
"devDependencies": { "devDependencies": {
"@storybook/addon-essentials": "^7.6.20",
"@storybook/addon-interactions": "^7.6.20",
"@storybook/blocks": "^7.6.20",
"@storybook/preview-api": "^7.6.20",
"@storybook/react": "^7.6.20",
"@storybook/react-vite": "^7.6.20",
"@storybook/test": "^7.6.20",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"storybook": "^7.6.20",
"vite": "^5.4.11" "vite": "^5.4.11"
} }
} }

View file

@ -172,23 +172,32 @@ export default function App() {
<main className="app-shell"> <main className="app-shell">
<div className="workspace"> <div className="workspace">
{view === 'archive' && ( {view === 'archive' && (
<div className="search-row"> <div className="toolbar">
<input <div className="search-field">
className="search-input" <span className="ico" aria-hidden="true">
type="search" <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
aria-label="Search archive" <circle cx="11" cy="11" r="7"/><line x1="16.5" y1="16.5" x2="21" y2="21"/>
aria-busy={searchBusy} </svg>
value={searchQuery} </span>
onChange={e => setSearchQuery(e.target.value)} <input
/> className="search-input"
<div className="result-count"> type="search"
{resultCount} aria-label="Search archive"
aria-busy={searchBusy}
placeholder="Search titles, URLs, types, tags…"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
/>
<span className="kbd">K</span>
</div>
<span className="result-count">
{resultCount && <><b>{resultCount.split(' ')[0]}</b>{' '}{resultCount.split(' ').slice(1).join(' ')}</>}
{tagFilter && ( {tagFilter && (
<button className="tag-filter-badge" onClick={handleClearTagFilter}> <button className="tag-filter-badge" onClick={handleClearTagFilter}>
× {tagFilter} × {tagFilter}
</button> </button>
)} )}
</div> </span>
</div> </div>
)} )}
{view === 'archive' && ( {view === 'archive' && (

View file

@ -114,13 +114,13 @@ export default function AdminView({ archives }) {
<section id="admin-view" className="view admin-view is-active"> <section id="admin-view" className="view admin-view is-active">
<h1>Admin</h1> <h1>Admin</h1>
<div className="admin-tabs"> <div className="view-tabs">
<button className={`admin-tab${tab === 'users' ? ' is-active' : ''}`} onClick={() => setTab('users')}>Users</button> <button className={`view-tab${tab === 'users' ? ' is-active' : ''}`} onClick={() => setTab('users')}>Users</button>
<button className={`admin-tab${tab === 'roles' ? ' is-active' : ''}`} onClick={() => setTab('roles')}>Roles</button> <button className={`view-tab${tab === 'roles' ? ' is-active' : ''}`} onClick={() => setTab('roles')}>Roles</button>
<button className={`admin-tab${tab === 'archives' ? ' is-active' : ''}`} onClick={() => setTab('archives')}>Archives</button> <button className={`view-tab${tab === 'archives' ? ' is-active' : ''}`} onClick={() => setTab('archives')}>Archives</button>
</div> </div>
{error && <div className="capture-error">{error}</div>} {error && <div className="form-msg form-msg--err">{error}</div>}
{tab === 'users' && ( {tab === 'users' && (
<div className="admin-section"> <div className="admin-section">
@ -156,9 +156,9 @@ export default function AdminView({ archives }) {
value={newPassword} onChange={e => setNewPassword(e.target.value)} required /> value={newPassword} onChange={e => setNewPassword(e.target.value)} required />
<input className="admin-input" type="email" placeholder="Email (optional)" <input className="admin-input" type="email" placeholder="Email (optional)"
value={newEmail} onChange={e => setNewEmail(e.target.value)} /> value={newEmail} onChange={e => setNewEmail(e.target.value)} />
{createError && <div className="capture-error">{createError}</div>} {createError && <div className="form-msg form-msg--err">{createError}</div>}
<button className="capture-submit" type="submit" disabled={creating}> <button className="btn-primary" type="submit" disabled={creating}>
{creating ? 'Creating' : 'Create User'} {creating ? 'Creating\u2026' : 'Create User'}
</button> </button>
</form> </form>
</div> </div>
@ -192,9 +192,9 @@ export default function AdminView({ archives }) {
onChange={e => setNewRoleSlug(e.target.value)} required /> onChange={e => setNewRoleSlug(e.target.value)} required />
<input className="admin-input" placeholder="Display Name (e.g. Moderator)" <input className="admin-input" placeholder="Display Name (e.g. Moderator)"
value={newRoleName} onChange={e => setNewRoleName(e.target.value)} required /> value={newRoleName} onChange={e => setNewRoleName(e.target.value)} required />
{roleCreateError && <div className="capture-error">{roleCreateError}</div>} {roleCreateError && <div className="form-msg form-msg--err">{roleCreateError}</div>}
<button className="capture-submit" type="submit" disabled={creatingRole}> <button className="btn-primary" type="submit" disabled={creatingRole}>
{creatingRole ? 'Creating' : 'Create Role'} {creatingRole ? 'Creating\u2026' : 'Create Role'}
</button> </button>
</form> </form>
</div> </div>

View file

@ -0,0 +1,41 @@
import { useState } from 'react';
import CaptureDialog from './CaptureDialog';
export default {
component: CaptureDialog,
tags: ['autodocs'],
};
function CaptureDialogWrapper(args) {
const [open, setOpen] = useState(args.open);
return (
<div>
<button onClick={() => setOpen(true)} style={{ padding: '8px 16px', marginBottom: '16px' }}>
Open Capture Dialog
</button>
<CaptureDialog
{...args}
open={open}
onClose={() => setOpen(false)}
/>
</div>
);
}
export const Default = {
render: (args) => <CaptureDialogWrapper {...args} />,
args: {
open: false,
archiveId: 'archive_1',
onCaptured: () => {},
},
};
export const Open = {
args: {
open: true,
archiveId: 'archive_1',
onCaptured: () => {},
},
};

View file

@ -192,7 +192,7 @@ export default function CollectionsView({ archiveId }) {
{collections.map(c => ( {collections.map(c => (
<button <button
key={c.collection_uid} key={c.collection_uid}
className={`coll-row${selectedUid === c.collection_uid ? ' is-active' : ''}`} className={`coll-sidebar-row${selectedUid === c.collection_uid ? ' is-active' : ''}`}
onClick={() => setSelectedUid(c.collection_uid)} onClick={() => setSelectedUid(c.collection_uid)}
> >
<span className="coll-row-name">{c.name}</span> <span className="coll-row-name">{c.name}</span>
@ -324,40 +324,43 @@ export default function CollectionsView({ archiveId }) {
</div> </div>
{/* Create form */} {/* Create form */}
<details className="coll-create-details" style={{ marginTop: '1.5rem' }}> <details className="coll-create-details">
<summary style={{ cursor: 'pointer', fontWeight: 600 }}>Create collection</summary> <summary>+ Create collection</summary>
<form onSubmit={handleCreate} style={{ marginTop: '0.75rem', display: 'flex', flexDirection: 'column', gap: '0.5rem', maxWidth: 400 }}> <form className="coll-create-form" onSubmit={handleCreate}>
<label> <div className="form-field">
Name <label className="form-label" htmlFor="coll-name">Name</label>
<input <input
className="capture-input" className="capture-input"
id="coll-name"
type="text" type="text"
value={newName} value={newName}
onChange={e => { setNewName(e.target.value); if (!newSlug) setNewSlug(e.target.value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')) }} onChange={e => { setNewName(e.target.value); if (!newSlug) setNewSlug(e.target.value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')) }}
placeholder="My Collection" placeholder="My Collection"
required required
/> />
</label> </div>
<label> <div className="form-field">
Slug <label className="form-label" htmlFor="coll-slug">Slug</label>
<input <input
className="capture-input" className="capture-input"
id="coll-slug"
type="text" type="text"
value={newSlug} value={newSlug}
onChange={e => setNewSlug(e.target.value)} onChange={e => setNewSlug(e.target.value)}
placeholder="my-collection" placeholder="my-collection"
required required
/> />
</label> </div>
<label> <div className="form-field">
Default visibility <label className="form-label" htmlFor="coll-vis">Default visibility</label>
<select className="capture-input" style={{ height: 42 }} value={newVis} onChange={e => setNewVis(Number(e.target.value))}> <select className="capture-input" id="coll-vis" style={{ height: 42 }}
value={newVis} onChange={e => setNewVis(Number(e.target.value))}>
{VIS_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)} {VIS_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select> </select>
</label> </div>
{createError && <div className="collections-error">{createError}</div>} {createError && <div className="collections-error">{createError}</div>}
<button className="capture-submit" type="submit" disabled={creating} style={{ alignSelf: 'flex-start', padding: '8px 20px' }}> <button className="btn-primary" type="submit" disabled={creating}>
{creating ? 'Creating…' : 'Create'} {creating ? 'Creating\u2026' : 'Create collection'}
</button> </button>
</form> </form>
</details> </details>

View file

@ -1,6 +1,14 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { fetchEntryDetail, fetchEntryTags, assignTag, removeTag, listEntryCollections } from '../api' import { fetchEntryDetail, fetchEntryTags, assignTag, removeTag, listEntryCollections } from '../api'
import { formatTimestamp, formatBytes, valueText } from '../utils' import { formatTimestamp, formatBytes, valueText, sourceIconSvg } from '../utils'
const VIS_LABEL = { 0: 'Private', 1: 'Public', 2: 'Users only', 3: 'Public' }
const ExternalIcon = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M7 17 17 7M9 7h8v8"/>
</svg>
)
export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet, tagNodes, onTagsRefresh }) { export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet, tagNodes, onTagsRefresh }) {
const [detail, setDetail] = useState(null) const [detail, setDetail] = useState(null)
@ -53,54 +61,60 @@ export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet,
const updated = await fetchEntryTags(archiveId, selectedEntry.entry_uid) const updated = await fetchEntryTags(archiveId, selectedEntry.entry_uid)
setTags(updated) setTags(updated)
onTagsRefresh() onTagsRefresh()
} catch (e) { } catch {
// silently ignore for now; could add error state // silently ignore
} }
} }
const metaRows = detail ? [
['Added', formatTimestamp(detail.summary.archived_at)],
['Source', detail.summary.source_kind],
['Type', detail.summary.entity_kind],
['Visibility', VIS_LABEL[detail.summary.visibility] ?? detail.summary.visibility],
['Root', detail.structured_root_relpath],
] : []
return ( return (
<aside className="context-rail"> <aside className="context-rail">
<div className="rail-title">Context</div> <div className="rail-eyebrow">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"> {!selectedEntry ? (
{detail.summary.original_url && ( <p className="tags-empty">Select an entry.</p>
<div className="rail-item"> ) : !detail ? (
<span className="rail-label">Original URL</span>:{' '} <p className="tags-empty">Loading</p>
<a ) : (
href={detail.summary.original_url} <>
target="_blank" <h2 className="rail-title">
rel="noopener noreferrer" {valueText(detail.summary.title) || valueText(detail.summary.entry_uid)}
className="rail-url-link" </h2>
>
{detail.summary.original_url} {detail.summary.original_url && (
</a> <a
</div> className="url-tile"
)} href={detail.summary.original_url}
{[ target="_blank"
['Added', formatTimestamp(detail.summary.archived_at)], rel="noopener noreferrer"
['Source', detail.summary.source_kind], >
['Type', detail.summary.entity_kind], <span className="ico" dangerouslySetInnerHTML={{ __html: sourceIconSvg(detail.summary.source_kind) }} />
['Visibility', detail.summary.visibility], <span className="u-text">{detail.summary.original_url}</span>
['Structured root', detail.structured_root_relpath], <span className="ext"><ExternalIcon /></span>
].map(([label, value]) => ( </a>
<div key={label} className="rail-item"> )}
<span className="rail-label">{label}</span>: {valueText(value)}
<div className="meta-list">
{metaRows.filter(([, v]) => v != null && v !== '').map(([label, value]) => (
<div key={label} className="meta-item">
<span className="meta-k">{label}</span>
<span className={`meta-v${label === 'Root' ? ' mono' : ''}`}>{valueText(value)}</span>
</div> </div>
))} ))}
</div> </div>
{detail.artifacts.length > 0 ? ( {detail.artifacts.length > 0 && (
<div className="rail-section"> <div className="rail-section">
<div className="rail-section-heading">Artifacts ({detail.artifacts.length})</div> <div className="rail-section-heading">
Artifacts <span className="num">{detail.artifacts.length}</span>
</div>
<ul className="artifact-list"> <ul className="artifact-list">
{detail.artifacts.map((artifact, index) => ( {detail.artifacts.map((artifact, index) => (
<li key={index}> <li key={index}>
@ -110,64 +124,65 @@ export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet,
rel="noopener noreferrer" rel="noopener noreferrer"
className="artifact-link" className="artifact-link"
> >
{artifact.artifact_role.replace(/_/g, ' ')} <span className="artifact-name">{artifact.artifact_role.replace(/_/g, ' ')}</span>
{artifact.byte_size != null ? ` (${formatBytes(artifact.byte_size)})` : ''} <span className="artifact-size">
{artifact.byte_size != null ? formatBytes(artifact.byte_size) : '—'}
</span>
</a> </a>
</li> </li>
))} ))}
</ul> </ul>
</div> </div>
) : (
<div className="rail-item muted">No artifacts.</div>
)} )}
</div> </>
)} )}
{selectedEntry && ( {selectedEntry && (
<> <>
<div className="entry-tags"> <div className="rail-section">
<div className="rail-section-heading">Tags</div>
{tags.length === 0 ? ( {tags.length === 0 ? (
<span className="muted">No tags.</span> <p className="tags-empty">No tags yet.</p>
) : ( ) : (
tags.map(tag => ( <div className="tags-wrap">
<span key={tag.tag_uid} className="tag-pill" title={tag.full_path}> {tags.map(tag => (
{tag.name} <span key={tag.tag_uid} className="tag-pill" title={tag.full_path}>
<button {tag.name}
className="remove-tag" <button
title={`Remove tag ${tag.full_path}`} className="remove"
onClick={() => handleRemoveTag(tag.tag_uid)} title={`Remove tag ${tag.full_path}`}
> onClick={() => handleRemoveTag(tag.tag_uid)}
× >×</button>
</button> </span>
</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>
)} )}
{assignError && (
<p className="form-msg form-msg--err" style={{ margin: '0 0 8px' }}>{assignError}</p>
)}
<div className="tag-input-wrap">
<span className="hash">/</span>
<input
className="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="tag-add-btn" onClick={handleAssignTag}>Add</button>
</div>
</div> </div>
{selectedEntry && entryCollections.length > 0 && (
{entryCollections.length > 0 && (
<div className="rail-section"> <div className="rail-section">
<div className="rail-section-heading">Collections</div> <div className="rail-section-heading">Collections</div>
{entryCollections.map(c => ( {entryCollections.map(c => (
<div key={c.collection_uid} className="rail-item"> <div key={c.collection_uid} className="coll-row">
<span className="rail-label">{c.collection_uid}</span>:{' '} <span className="coll-name">{c.collection_uid}</span>
<span className="muted"> <span className="vis-badge">
{{ 0: 'Private', 1: 'Public', 2: 'Users only', 3: 'Public' }[c.visibility_bits] ?? `bits:${c.visibility_bits}`} {VIS_LABEL[c.visibility_bits] ?? `bits:${c.visibility_bits}`}
</span> </span>
</div> </div>
))} ))}

View file

@ -0,0 +1,66 @@
import EntryRow from './EntryRow';
export default {
component: EntryRow,
tags: ['autodocs'],
};
const sampleEntry = {
entry_uid: 'entry_123',
title: 'A Great Article About Web Design',
archived_at: '2026-06-27T10:30:00Z',
entity_kind: 'page',
source_kind: 'web',
total_artifact_bytes: 2048576,
original_url: 'https://example.com/article',
has_favicon: false,
};
const videoEntry = {
entry_uid: 'entry_456',
title: 'React Performance Tips',
archived_at: '2026-06-26T14:15:00Z',
entity_kind: 'video',
source_kind: 'youtube',
total_artifact_bytes: 104857600,
original_url: 'https://youtube.com/watch?v=xyz',
has_favicon: false,
};
export const Default = {
args: {
entry: sampleEntry,
archiveId: 'archive_1',
isSelected: false,
onSelect: () => {},
},
decorators: [
(Story) => (
<div style={{
display: 'grid',
gridTemplateColumns: '178px 38% 130px 110px 34%',
gap: '10px',
padding: '10px',
background: 'var(--paper-3)',
}}>
<Story />
</div>
),
],
};
export const Selected = {
args: {
...Default.args,
isSelected: true,
},
decorators: Default.decorators,
};
export const Video = {
args: {
...Default.args,
entry: videoEntry,
},
decorators: Default.decorators,
};

View file

@ -23,32 +23,41 @@ export default function LoginPage({ onLogin }) {
return ( return (
<div className="login-page"> <div className="login-page">
<h1>Archivr</h1> <div className="login-card">
<form onSubmit={handleSubmit}> <h1 className="login-brand">Archivr</h1>
<label> <p className="login-tagline">Sign in to your archive</p>
Username <form onSubmit={handleSubmit}>
<input <div className="login-field">
type="text" <label className="login-label" htmlFor="login-username">Username</label>
value={username} <input
onChange={e => setUsername(e.target.value)} className="login-input"
autoFocus id="login-username"
required type="text"
/> value={username}
</label> onChange={e => setUsername(e.target.value)}
<label> autoFocus
Password required
<input autoComplete="username"
type="password" />
value={password} </div>
onChange={e => setPassword(e.target.value)} <div className="login-field">
required <label className="login-label" htmlFor="login-password">Password</label>
/> <input
</label> className="login-input"
{error && <p className="error">{error}</p>} id="login-password"
<button type="submit" disabled={loading}> type="password"
{loading ? 'Logging in\u2026' : 'Log in'} value={password}
</button> onChange={e => setPassword(e.target.value)}
</form> required
autoComplete="current-password"
/>
</div>
{error && <p className="login-error">{error}</p>}
<button className="login-submit" type="submit" disabled={loading}>
{loading ? 'Signing in\u2026' : 'Sign in'}
</button>
</form>
</div>
</div> </div>
); );
} }

View file

@ -1,24 +1,50 @@
function fmtDate(iso) {
if (!iso) return '—';
try {
return new Date(iso).toLocaleString(undefined, {
year: 'numeric', month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit',
});
} catch {
return iso;
}
}
function StatusBadge({ status }) {
const cls = status === 'completed' ? 'run-status--completed'
: status === 'failed' ? 'run-status--failed'
: status === 'running' ? 'run-status--running'
: '';
return <span className={`run-status ${cls}`}>{status || '—'}</span>;
}
export default function RunsView({ runs }) { export default function RunsView({ runs }) {
return ( return (
<section id="runs-view" className="view is-active"> <section id="runs-view" className="view is-active">
<table className="entry-table"> <table className="entry-table">
<thead> <thead>
<tr> <tr>
<th>Started</th><th>Status</th><th>Requested</th><th>Completed</th><th>Failed</th> <th>Started</th>
<th>Status</th>
<th>Requested</th>
<th>Completed</th>
<th>Failed</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{runs.map((run, i) => ( {runs.length === 0 ? (
<tr><td colSpan={5} style={{ color: 'var(--muted)', padding: '24px 16px', textAlign: 'center' }}>No runs yet.</td></tr>
) : runs.map((run, i) => (
<tr key={i}> <tr key={i}>
<td>{run.started_at ?? ''}</td> <td>{fmtDate(run.started_at)}</td>
<td>{run.status ?? ''}</td> <td><StatusBadge status={run.status} /></td>
<td>{run.requested_count ?? ''}</td> <td>{run.requested_count ?? ''}</td>
<td>{run.completed_count ?? ''}</td> <td>{run.completed_count ?? ''}</td>
<td>{run.failed_count ?? ''}</td> <td>{run.failed_count ?? ''}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</section> </section>
) );
} }

View file

@ -7,23 +7,24 @@ import {
} from '../api.js' } from '../api.js'
const ROLE_ADMIN = 4 const ROLE_ADMIN = 4
const ROLE_OWNER = 8
export default function SettingsView() { export default function SettingsView() {
const { currentUser, setCurrentUser } = useContext(AuthContext) ?? {} const { currentUser, setCurrentUser } = useContext(AuthContext) ?? {}
const isAdmin = currentUser && ((currentUser.role_bits & ROLE_ADMIN) !== 0) const isAdmin = currentUser && ((currentUser.role_bits & ROLE_ADMIN) !== 0)
const [tab, setTab] = useState('profile') const [tab, setTab] = useState('profile')
const tabs = ['profile', 'tokens', ...(isAdmin ? ['instance'] : [])]
const tabLabels = { profile: 'Profile', tokens: 'API Tokens', instance: 'Instance' }
return ( return (
<section className="admin-view"> <section className="admin-view">
<h1>Settings</h1> <h1>Settings</h1>
<div className="admin-tabs" style={{ display: 'flex', gap: 12, marginBottom: 20 }}> <div className="view-tabs">
{['profile', 'tokens', ...(isAdmin ? ['instance'] : [])].map(t => ( {tabs.map(t => (
<button key={t} <button key={t}
className={`nav-link${tab === t ? ' is-active' : ''}`} className={`view-tab${tab === t ? ' is-active' : ''}`}
style={{ borderBottom: tab === t ? '2px solid var(--accent)' : undefined, color: 'var(--ink)' }}
onClick={() => setTab(t)}> onClick={() => setTab(t)}>
{t === 'profile' ? 'Profile' : t === 'tokens' ? 'API Tokens' : 'Instance'} {tabLabels[t]}
</button> </button>
))} ))}
</div> </div>
@ -78,34 +79,43 @@ function ProfileTab({ currentUser, setCurrentUser }) {
} }
return ( return (
<div style={{ maxWidth: 480 }}> <div style={{ maxWidth: 440 }}>
<div className="admin-section"> <div className="form-section">
<h2 style={{ fontSize: 15, marginBottom: 12 }}>Display Name</h2> <h2>Display Name</h2>
<form className="admin-form" onSubmit={handleSaveProfile}> <form onSubmit={handleSaveProfile}>
<input className="admin-input" placeholder={currentUser?.username ?? ''} <div className="form-field">
value={displayName} onChange={e => setDisplayName(e.target.value)} /> <label className="form-label" htmlFor="display-name">Name shown in the UI</label>
{saveMsg && ( <input className="field-input" id="display-name"
<div className={saveMsg.ok ? 'muted' : 'capture-error'}>{saveMsg.text}</div> placeholder={currentUser?.username ?? ''}
)} value={displayName} onChange={e => setDisplayName(e.target.value)} />
<button className="capture-submit" type="submit" disabled={saving}> </div>
{saveMsg && <div className={`form-msg form-msg--${saveMsg.ok ? 'ok' : 'err'}`}>{saveMsg.text}</div>}
<button className="btn-primary" type="submit" disabled={saving}>
{saving ? 'Saving\u2026' : 'Save'} {saving ? 'Saving\u2026' : 'Save'}
</button> </button>
</form> </form>
</div> </div>
<div className="admin-section" style={{ marginTop: 28 }}> <div className="form-section">
<h2 style={{ fontSize: 15, marginBottom: 12 }}>Change Password</h2> <h2>Change Password</h2>
<form className="admin-form" onSubmit={handleChangePassword}> <form onSubmit={handleChangePassword}>
<input className="admin-input" type="password" placeholder="Current password" <div className="form-field">
value={curPw} onChange={e => setCurPw(e.target.value)} required /> <label className="form-label" htmlFor="cur-pw">Current password</label>
<input className="admin-input" type="password" placeholder="New password" <input className="field-input" id="cur-pw" type="password"
value={newPw} onChange={e => setNewPw(e.target.value)} required /> value={curPw} onChange={e => setCurPw(e.target.value)} required />
<input className="admin-input" type="password" placeholder="Confirm new password" </div>
value={confirmPw} onChange={e => setConfirmPw(e.target.value)} required /> <div className="form-field">
{pwMsg && ( <label className="form-label" htmlFor="new-pw">New password</label>
<div className={pwMsg.ok ? 'muted' : 'capture-error'}>{pwMsg.text}</div> <input className="field-input" id="new-pw" type="password"
)} value={newPw} onChange={e => setNewPw(e.target.value)} required />
<button className="capture-submit" type="submit" disabled={pwSaving}> </div>
<div className="form-field">
<label className="form-label" htmlFor="confirm-pw">Confirm new password</label>
<input className="field-input" id="confirm-pw" type="password"
value={confirmPw} onChange={e => setConfirmPw(e.target.value)} required />
</div>
{pwMsg && <div className={`form-msg form-msg--${pwMsg.ok ? 'ok' : 'err'}`}>{pwMsg.text}</div>}
<button className="btn-primary" type="submit" disabled={pwSaving}>
{pwSaving ? 'Changing\u2026' : 'Change Password'} {pwSaving ? 'Changing\u2026' : 'Change Password'}
</button> </button>
</form> </form>
@ -120,7 +130,7 @@ function TokensTab() {
const [error, setError] = useState(null) const [error, setError] = useState(null)
const [newName, setNewName] = useState('') const [newName, setNewName] = useState('')
const [creating, setCreating] = useState(false) const [creating, setCreating] = useState(false)
const [newToken, setNewToken] = useState(null) // { raw_token, name } const [newToken, setNewToken] = useState(null)
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
setLoading(true); setError(null) setLoading(true); setError(null)
@ -157,48 +167,47 @@ function TokensTab() {
} }
return ( return (
<div style={{ maxWidth: 640 }}> <div style={{ maxWidth: 600 }}>
<h2 style={{ fontSize: 15, marginBottom: 12 }}>API Tokens</h2> <div className="form-section">
{newToken && ( <h2>API Tokens</h2>
<div style={{ background: '#e8f5e9', border: '1px solid #a5d6a7', padding: '12px 14px', marginBottom: 16, fontSize: 13 }}> {newToken && (
<strong>Token created.</strong> Copy it now \u2014 it will not be shown again.<br /> <div className="token-banner">
<code style={{ wordBreak: 'break-all', display: 'block', marginTop: 6, padding: '6px 8px', background: '#f5f5f5', border: '1px solid #ddd' }}> <strong>Token created.</strong> Copy it now it won't be shown again.
{newToken.raw_token} <code>{newToken.raw_token}</code>
</code> <button className="token-dismiss" onClick={() => setNewToken(null)}>Dismiss</button>
<button style={{ marginTop: 8, fontSize: 12, border: '1px solid #aaa', background: 'none', cursor: 'pointer' }} </div>
onClick={() => setNewToken(null)}>Dismiss</button> )}
</div> <form className="token-create-row" onSubmit={handleCreate}>
)} <input className="field-input field-input--flex" placeholder="Token name"
<form className="admin-form" onSubmit={handleCreate} style={{ display: 'flex', gap: 8, marginBottom: 16 }}> value={newName} onChange={e => setNewName(e.target.value)} required />
<input className="admin-input" placeholder="Token name" value={newName} <button className="btn-primary" type="submit" disabled={creating}>
onChange={e => setNewName(e.target.value)} style={{ flex: 1 }} required /> {creating ? 'Creating\u2026' : 'Create token'}
<button className="capture-submit" type="submit" disabled={creating}> </button>
{creating ? 'Creating\u2026' : 'Create'} </form>
</button> {error && <div className="form-msg form-msg--err">{error}</div>}
</form> {loading ? (
{error && <div className="capture-error">{error}</div>} <div className="muted">Loading\u2026</div>
{loading ? <div className="muted">Loading\u2026</div> : ( ) : (
<div className="admin-list"> <div>
{tokens.length === 0 && <div className="muted">No tokens yet.</div>} {tokens.length === 0 && <div className="muted">No tokens yet.</div>}
{tokens.map(tok => ( {tokens.map(tok => (
<div key={tok.token_uid} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', <div key={tok.token_uid} className="token-row">
border: '1px solid var(--line)', background: 'var(--paper-3)', padding: '10px 12px' }}> <div className="token-row-info">
<div> <strong>{tok.name}</strong>
<strong style={{ fontSize: 14 }}>{tok.name}</strong> <div className="muted">
<div className="muted" style={{ fontSize: 12, marginTop: 2 }}> Created {tok.created_at.slice(0, 10)}
Created {tok.created_at.slice(0, 10)} {tok.last_used_at && ` \u00b7 Last used ${tok.last_used_at.slice(0, 10)}`}
{tok.last_used_at && ` \u00b7 Last used ${tok.last_used_at.slice(0, 10)}`} </div>
</div> </div>
<button className="btn-danger" style={{ fontSize: 12, padding: '4px 10px' }}
onClick={() => handleDelete(tok.token_uid)}>
Revoke
</button>
</div> </div>
<button onClick={() => handleDelete(tok.token_uid)} ))}
style={{ border: '1px solid var(--line)', background: 'none', cursor: 'pointer', </div>
color: 'var(--accent)', fontSize: 12, padding: '4px 10px' }}> )}
Revoke </div>
</button>
</div>
))}
</div>
)}
</div> </div>
) )
} }
@ -232,40 +241,40 @@ function InstanceTab() {
} }
if (loading) return <div className="muted">Loading\u2026</div> if (loading) return <div className="muted">Loading\u2026</div>
if (error) return <div className="capture-error">{error}</div> if (error) return <div className="form-msg form-msg--err">{error}</div>
if (!settings) return null if (!settings) return null
return ( return (
<div style={{ maxWidth: 480 }}> <div style={{ maxWidth: 440 }}>
<h2 style={{ fontSize: 15, marginBottom: 12 }}>Instance Settings</h2> <div className="form-section">
<form onSubmit={handleSave}> <h2>Instance Settings</h2>
<div className="admin-section"> <form onSubmit={handleSave}>
{[ {[
['public_index_enabled', 'Public index (unauthenticated browsing)'], ['public_index_enabled', 'Public index (unauthenticated browsing)'],
['public_entry_content_enabled', 'Public entry content'], ['public_entry_content_enabled', 'Public entry content'],
['open_registration_enabled', 'Open registration'], ['open_registration_enabled', 'Open registration'],
].map(([key, label]) => ( ].map(([key, label]) => (
<label key={key} style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12, cursor: 'pointer' }}> <label key={key} className="checkbox-row">
<input type="checkbox" checked={!!settings[key]} <input type="checkbox" checked={!!settings[key]}
onChange={e => setSettings(s => ({ ...s, [key]: e.target.checked }))} /> onChange={e => setSettings(s => ({ ...s, [key]: e.target.checked }))} />
{label} {label}
</label> </label>
))} ))}
<div style={{ marginBottom: 12 }}> <div className="form-field" style={{ marginTop: 4 }}>
<label style={{ display: 'block', fontWeight: 600, marginBottom: 4, fontSize: 13 }}>Default entry visibility</label> <label className="form-label">Default entry visibility</label>
<select className="admin-input" value={settings.default_entry_visibility} <select className="field-input" value={settings.default_entry_visibility}
onChange={e => setSettings(s => ({ ...s, default_entry_visibility: Number(e.target.value) }))}> onChange={e => setSettings(s => ({ ...s, default_entry_visibility: Number(e.target.value) }))}>
<option value={0}>Private (0)</option> <option value={0}>Private</option>
<option value={2}>Unlisted (2)</option> <option value={2}>Unlisted</option>
<option value={3}>Public (3)</option> <option value={3}>Public</option>
</select> </select>
</div> </div>
</div> {saveMsg && <div className={`form-msg form-msg--${saveMsg.ok ? 'ok' : 'err'}`}>{saveMsg.text}</div>}
{saveMsg && <div className={saveMsg.ok ? 'muted' : 'capture-error'}>{saveMsg.text}</div>} <button className="btn-primary" type="submit" disabled={saving}>
<button className="capture-submit" type="submit" disabled={saving}> {saving ? 'Saving\u2026' : 'Save Settings'}
{saving ? 'Saving\u2026' : 'Save Settings'} </button>
</button> </form>
</form> </div>
</div> </div>
) )
} }

View file

@ -32,42 +32,53 @@ export default function SetupPage({ onComplete }) {
return ( return (
<div className="setup-page"> <div className="setup-page">
<h1>Welcome to Archivr</h1> <div className="setup-card">
<p>Create your owner account to get started.</p> <h1 className="setup-brand">Archivr</h1>
<form onSubmit={handleSubmit}> <p className="setup-tagline">Create your owner account to get started.</p>
<label> <form onSubmit={handleSubmit}>
Username <div className="setup-field">
<input <label className="setup-label" htmlFor="setup-username">Username</label>
type="text" <input
value={username} className="setup-input"
onChange={e => setUsername(e.target.value)} id="setup-username"
autoFocus type="text"
required value={username}
/> onChange={e => setUsername(e.target.value)}
</label> autoFocus
<label> required
Password autoComplete="username"
<input />
type="password" </div>
value={password} <div className="setup-field">
onChange={e => setPassword(e.target.value)} <label className="setup-label" htmlFor="setup-password">Password</label>
required <input
/> className="setup-input"
</label> id="setup-password"
<label> type="password"
Confirm password value={password}
<input onChange={e => setPassword(e.target.value)}
type="password" required
value={confirm} autoComplete="new-password"
onChange={e => setConfirm(e.target.value)} />
required </div>
/> <div className="setup-field">
</label> <label className="setup-label" htmlFor="setup-confirm">Confirm password</label>
{error && <p className="error">{error}</p>} <input
<button type="submit" disabled={loading}> className="setup-input"
{loading ? 'Creating account\u2026' : 'Create account'} id="setup-confirm"
</button> type="password"
</form> value={confirm}
onChange={e => setConfirm(e.target.value)}
required
autoComplete="new-password"
/>
</div>
{error && <p className="setup-error">{error}</p>}
<button className="setup-submit" type="submit" disabled={loading}>
{loading ? 'Creating account\u2026' : 'Create account'}
</button>
</form>
</div>
</div> </div>
); );
} }

View file

@ -31,8 +31,14 @@ export default function TagsView({ tagNodes, tagFilter, onTagFilterSet, onViewCh
return ( return (
<section id="tags-view" className="view is-active"> <section id="tags-view" className="view is-active">
<div className="tag-tree"> <div className="tag-tree">
<div className="tag-tree-header">
<span className="tag-tree-title">Tags</span>
{tagFilter && (
<span className="tag-tree-active">Filtering: {tagFilter}</span>
)}
</div>
{tagNodes.length === 0 ? ( {tagNodes.length === 0 ? (
<div>No tags yet.</div> <p className="muted" style={{ padding: '8px 0' }}>No tags yet.</p>
) : ( ) : (
<ul className="tag-tree-list"> <ul className="tag-tree-list">
{tagNodes.map(node => ( {tagNodes.map(node => (

View file

@ -16,10 +16,12 @@ export default function Topbar({ archives, archiveId, onArchiveChange, view, onV
return ( return (
<header className="topbar"> <header className="topbar">
<div className="brand">Archivr</div> <div className="brand">Archivr</div>
<select className="archive-switcher" aria-label="Select archive" <div className="switcher">
value={archiveId ?? ''} onChange={e => onArchiveChange(e.target.value)}> <select aria-label="Select archive"
{archives.map(a => <option key={a.id} value={a.id}>{a.label}</option>)} value={archiveId ?? ''} onChange={e => onArchiveChange(e.target.value)}>
</select> {archives.map(a => <option key={a.id} value={a.id}>{a.label}</option>)}
</select>
</div>
<nav className="nav" aria-label="Primary"> <nav className="nav" aria-label="Primary">
{['archive', 'runs', 'admin', 'tags', 'collections', 'settings'].map(name => ( {['archive', 'runs', 'admin', 'tags', 'collections', 'settings'].map(name => (
<button key={name} className={`nav-link${view === name ? ' is-active' : ''}`} <button key={name} className={`nav-link${view === name ? ' is-active' : ''}`}
@ -31,9 +33,8 @@ export default function Topbar({ archives, archiveId, onArchiveChange, view, onV
<button className="capture-button" onClick={onCaptureClick}>+ Capture</button> <button className="capture-button" onClick={onCaptureClick}>+ Capture</button>
{currentUser && ( {currentUser && (
<div className="user-menu"> <div className="user-menu">
<span className="username">{currentUser.display_name || currentUser.username}</span> <span className="user-name">{currentUser.display_name || currentUser.username}</span>
<button className="nav-link" onClick={() => onViewChange('settings')} style={{ color: '#d7cdbf', fontSize: 13 }}>Settings</button> <button className="logout-btn" onClick={handleLogout} disabled={loggingOut}>
<button onClick={handleLogout} disabled={loggingOut} className="logout-btn">
{loggingOut ? 'Logging out\u2026' : 'Log out'} {loggingOut ? 'Logging out\u2026' : 'Log out'}
</button> </button>
</div> </div>

View file

@ -0,0 +1,36 @@
import Topbar from './Topbar';
export default {
component: Topbar,
tags: ['autodocs'],
};
const defaultArchives = [
{ id: '1', label: 'Main Archive' },
{ id: '2', label: 'Research' },
{ id: '3', label: 'Screenshots' },
];
export const Default = {
args: {
archives: defaultArchives,
archiveId: '1',
onArchiveChange: () => {},
view: 'archive',
onViewChange: () => {},
onCaptureClick: () => {},
},
};
export const WithUser = {
args: {
...Default.args,
},
decorators: [
(Story) => (
<div style={{ background: 'var(--paper)' }}>
<Story />
</div>
),
],
};

View file

@ -0,0 +1,102 @@
export default {
title: 'UI Patterns',
tags: ['autodocs'],
};
export const Buttons = () => (
<div style={{ padding: '20px', display: 'flex', gap: '16px', flexWrap: 'wrap' }}>
<button className="capture-button">+ Capture</button>
<button className="nav-link">Nav Link</button>
<button className="nav-link is-active">Nav Link (Active)</button>
<button className="assign-tag-btn">Add Tag</button>
<button className="assign-tag-btn" style={{ pointerEvents: 'none', opacity: 0.6 }}>Disabled</button>
</div>
);
export const FormInputs = () => (
<div style={{ padding: '20px', maxWidth: '400px', display: 'flex', flexDirection: 'column', gap: '16px' }}>
<div>
<label style={{ display: 'block', marginBottom: '6px', color: 'var(--muted)', fontSize: '12px' }}>Search Input</label>
<input className="search-input" type="search" placeholder="Search archive..." />
</div>
<div>
<label style={{ display: 'block', marginBottom: '6px', color: 'var(--muted)', fontSize: '12px' }}>Capture Input</label>
<input className="capture-input" type="text" placeholder="tweet:1234567890 or https://..." />
</div>
<div>
<label style={{ display: 'block', marginBottom: '6px', color: 'var(--muted)', fontSize: '12px' }}>Tag Input</label>
<input className="assign-tag-input" type="text" placeholder="/science/cs" />
</div>
<div>
<label style={{ display: 'block', marginBottom: '6px', color: 'var(--muted)', fontSize: '12px' }}>Archive Switcher</label>
<select className="archive-switcher" style={{ width: '100%' }}>
<option>Main Archive</option>
<option>Research</option>
<option>Screenshots</option>
</select>
</div>
</div>
);
export const Pills = () => (
<div style={{ padding: '20px', display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<span className="type-pill">page</span>
<span className="type-pill">video</span>
<span className="type-pill">tweet_thread</span>
<span className="type-pill">file</span>
</div>
);
export const TagPills = () => (
<div style={{ padding: '20px', display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<span className="tag-pill">
science
<button className="remove-tag">×</button>
</span>
<span className="tag-pill">
computer-science
<button className="remove-tag">×</button>
</span>
<span className="tag-pill">
learning
<button className="remove-tag">×</button>
</span>
</div>
);
export const ColorPalette = () => (
<div style={{ padding: '20px', display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '16px' }}>
<div>
<div style={{ height: '80px', background: 'var(--ink)', marginBottom: '8px', borderRadius: '4px' }} />
<strong>Ink</strong> (#20251f)
</div>
<div>
<div style={{ height: '80px', background: 'var(--paper)', border: '1px solid var(--line)', marginBottom: '8px', borderRadius: '4px' }} />
<strong>Paper</strong> (#f5f0e7)
</div>
<div>
<div style={{ height: '80px', background: 'var(--accent)', marginBottom: '8px', borderRadius: '4px' }} />
<strong>Accent</strong> (#8d3f30)
</div>
<div>
<div style={{ height: '80px', background: 'var(--accent-2)', marginBottom: '8px', borderRadius: '4px' }} />
<strong>Accent 2</strong> (#b78342)
</div>
<div>
<div style={{ height: '80px', background: 'var(--link)', marginBottom: '8px', borderRadius: '4px' }} />
<strong>Link</strong> (#245f72)
</div>
<div>
<div style={{ height: '80px', background: 'var(--top)', marginBottom: '8px', borderRadius: '4px' }} />
<strong>Top</strong> (#141d18)
</div>
<div>
<div style={{ height: '80px', background: 'var(--muted)', marginBottom: '8px', borderRadius: '4px' }} />
<strong>Muted</strong> (#666a61)
</div>
<div>
<div style={{ height: '80px', background: 'var(--line)', marginBottom: '8px', borderRadius: '4px' }} />
<strong>Line</strong> (#d2c6b5)
</div>
</div>
);

File diff suppressed because it is too large Load diff

View file

@ -5,7 +5,7 @@ export default defineConfig({
plugins: [react()], plugins: [react()],
build: { build: {
outDir: "../crates/archivr-server/static", outDir: "../crates/archivr-server/static",
emptyOutDir: false, emptyOutDir: true,
assetsDir: "assets", assetsDir: "assets",
}, },
}); });