1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-07-21 18:55:36 +02:00

feat(ui): add Storybook design system refinements

This commit is contained in:
TheGeneralist 2026-06-28 20:52:18 +02:00
parent 46ad816c4c
commit 3ccfcce87b
Signed by: thegeneralist01
SSH key fingerprint: SHA256:pp9qddbCNmVNoSjevdvQvM5z0DHN7LTa8qBMbcMq/R4
15 changed files with 2839 additions and 666 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/**

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

@ -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>

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', 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 style={{ color: 'var(--accent)', fontSize: 13, 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

@ -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' : ''}`}

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",
}, },
}); });