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:
parent
46ad816c4c
commit
3ccfcce87b
15 changed files with 2839 additions and 666 deletions
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -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/**
|
||||||
|
|
|
||||||
13
frontend/.storybook/main.js
Normal file
13
frontend/.storybook/main.js
Normal 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;
|
||||||
5
frontend/.storybook/preview.js
Normal file
5
frontend/.storybook/preview.js
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import '../src/styles.css';
|
||||||
|
|
||||||
|
export const parameters = {
|
||||||
|
layout: 'fullscreen',
|
||||||
|
};
|
||||||
1892
frontend/bun.lock
1892
frontend/bun.lock
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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' && (
|
||||||
|
|
|
||||||
41
frontend/src/components/CaptureDialog.stories.jsx
Normal file
41
frontend/src/components/CaptureDialog.stories.jsx
Normal 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: () => {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
66
frontend/src/components/EntryRow.stories.jsx
Normal file
66
frontend/src/components/EntryRow.stories.jsx
Normal 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,
|
||||||
|
};
|
||||||
|
|
@ -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' : ''}`}
|
||||||
|
|
|
||||||
36
frontend/src/components/Topbar.stories.jsx
Normal file
36
frontend/src/components/Topbar.stories.jsx
Normal 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>
|
||||||
|
),
|
||||||
|
],
|
||||||
|
};
|
||||||
102
frontend/src/components/UIPatterns.stories.jsx
Normal file
102
frontend/src/components/UIPatterns.stories.jsx
Normal 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
|
|
@ -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",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue