mirror of
https://github.com/thegeneralist01/archivr
synced 2026-07-21 18:55:36 +02:00
Compare commits
2 commits
46ad816c4c
...
4311e85f95
| Author | SHA1 | Date | |
|---|---|---|---|
| 4311e85f95 | |||
| 3ccfcce87b |
32 changed files with 3567 additions and 1097 deletions
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -23,3 +23,10 @@
|
|||
!frontend/**
|
||||
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
40
crates/archivr-server/static/assets/index-CWb0sQjJ.js
Normal file
40
crates/archivr-server/static/assets/index-CWb0sQjJ.js
Normal file
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
1
crates/archivr-server/static/assets/index-MrdP6h9x.css
Normal file
1
crates/archivr-server/static/assets/index-MrdP6h9x.css
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -4,8 +4,8 @@
|
|||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Archivr</title>
|
||||
<script type="module" crossorigin src="/assets/index-C6h0jcH3.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D40QFUPh.css">
|
||||
<script type="module" crossorigin src="/assets/index-CWb0sQjJ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-MrdP6h9x.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
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": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"storybook:build": "storybook build"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"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",
|
||||
"storybook": "^7.6.20",
|
||||
"vite": "^5.4.11"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -172,23 +172,32 @@ export default function App() {
|
|||
<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}
|
||||
<div className="toolbar">
|
||||
<div className="search-field">
|
||||
<span className="ico" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<circle cx="11" cy="11" r="7"/><line x1="16.5" y1="16.5" x2="21" y2="21"/>
|
||||
</svg>
|
||||
</span>
|
||||
<input
|
||||
className="search-input"
|
||||
type="search"
|
||||
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 && (
|
||||
<button className="tag-filter-badge" onClick={handleClearTagFilter}>
|
||||
× {tagFilter}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{view === 'archive' && (
|
||||
|
|
|
|||
|
|
@ -114,13 +114,13 @@ export default function AdminView({ archives }) {
|
|||
<section id="admin-view" className="view admin-view is-active">
|
||||
<h1>Admin</h1>
|
||||
|
||||
<div className="admin-tabs">
|
||||
<button className={`admin-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={`admin-tab${tab === 'archives' ? ' is-active' : ''}`} onClick={() => setTab('archives')}>Archives</button>
|
||||
<div className="view-tabs">
|
||||
<button className={`view-tab${tab === 'users' ? ' is-active' : ''}`} onClick={() => setTab('users')}>Users</button>
|
||||
<button className={`view-tab${tab === 'roles' ? ' is-active' : ''}`} onClick={() => setTab('roles')}>Roles</button>
|
||||
<button className={`view-tab${tab === 'archives' ? ' is-active' : ''}`} onClick={() => setTab('archives')}>Archives</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="capture-error">{error}</div>}
|
||||
{error && <div className="form-msg form-msg--err">{error}</div>}
|
||||
|
||||
{tab === 'users' && (
|
||||
<div className="admin-section">
|
||||
|
|
@ -156,9 +156,9 @@ export default function AdminView({ archives }) {
|
|||
value={newPassword} onChange={e => setNewPassword(e.target.value)} required />
|
||||
<input className="admin-input" type="email" placeholder="Email (optional)"
|
||||
value={newEmail} onChange={e => setNewEmail(e.target.value)} />
|
||||
{createError && <div className="capture-error">{createError}</div>}
|
||||
<button className="capture-submit" type="submit" disabled={creating}>
|
||||
{creating ? 'Creating…' : 'Create User'}
|
||||
{createError && <div className="form-msg form-msg--err">{createError}</div>}
|
||||
<button className="btn-primary" type="submit" disabled={creating}>
|
||||
{creating ? 'Creating\u2026' : 'Create User'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -192,9 +192,9 @@ export default function AdminView({ archives }) {
|
|||
onChange={e => setNewRoleSlug(e.target.value)} required />
|
||||
<input className="admin-input" placeholder="Display Name (e.g. Moderator)"
|
||||
value={newRoleName} onChange={e => setNewRoleName(e.target.value)} required />
|
||||
{roleCreateError && <div className="capture-error">{roleCreateError}</div>}
|
||||
<button className="capture-submit" type="submit" disabled={creatingRole}>
|
||||
{creatingRole ? 'Creating…' : 'Create Role'}
|
||||
{roleCreateError && <div className="form-msg form-msg--err">{roleCreateError}</div>}
|
||||
<button className="btn-primary" type="submit" disabled={creatingRole}>
|
||||
{creatingRole ? 'Creating\u2026' : 'Create Role'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
|
|
|||
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 => (
|
||||
<button
|
||||
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)}
|
||||
>
|
||||
<span className="coll-row-name">{c.name}</span>
|
||||
|
|
@ -324,40 +324,43 @@ export default function CollectionsView({ archiveId }) {
|
|||
</div>
|
||||
|
||||
{/* Create form */}
|
||||
<details className="coll-create-details" style={{ marginTop: '1.5rem' }}>
|
||||
<summary style={{ cursor: 'pointer', fontWeight: 600 }}>Create collection</summary>
|
||||
<form onSubmit={handleCreate} style={{ marginTop: '0.75rem', display: 'flex', flexDirection: 'column', gap: '0.5rem', maxWidth: 400 }}>
|
||||
<label>
|
||||
Name
|
||||
<details className="coll-create-details">
|
||||
<summary>+ Create collection</summary>
|
||||
<form className="coll-create-form" onSubmit={handleCreate}>
|
||||
<div className="form-field">
|
||||
<label className="form-label" htmlFor="coll-name">Name</label>
|
||||
<input
|
||||
className="capture-input"
|
||||
id="coll-name"
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={e => { setNewName(e.target.value); if (!newSlug) setNewSlug(e.target.value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')) }}
|
||||
placeholder="My Collection"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Slug
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label className="form-label" htmlFor="coll-slug">Slug</label>
|
||||
<input
|
||||
className="capture-input"
|
||||
id="coll-slug"
|
||||
type="text"
|
||||
value={newSlug}
|
||||
onChange={e => setNewSlug(e.target.value)}
|
||||
placeholder="my-collection"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Default visibility
|
||||
<select className="capture-input" style={{ height: 42 }} value={newVis} onChange={e => setNewVis(Number(e.target.value))}>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label className="form-label" htmlFor="coll-vis">Default visibility</label>
|
||||
<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>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{createError && <div className="collections-error">{createError}</div>}
|
||||
<button className="capture-submit" type="submit" disabled={creating} style={{ alignSelf: 'flex-start', padding: '8px 20px' }}>
|
||||
{creating ? 'Creating…' : 'Create'}
|
||||
<button className="btn-primary" type="submit" disabled={creating}>
|
||||
{creating ? 'Creating\u2026' : 'Create collection'}
|
||||
</button>
|
||||
</form>
|
||||
</details>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,14 @@
|
|||
import { useState, useEffect, useRef } from 'react'
|
||||
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 }) {
|
||||
const [detail, setDetail] = useState(null)
|
||||
|
|
@ -53,54 +61,60 @@ export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet,
|
|||
const updated = await fetchEntryTags(archiveId, selectedEntry.entry_uid)
|
||||
setTags(updated)
|
||||
onTagsRefresh()
|
||||
} catch (e) {
|
||||
// silently ignore for now; could add error state
|
||||
} catch {
|
||||
// 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 (
|
||||
<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-eyebrow">Context</div>
|
||||
|
||||
<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)}
|
||||
{!selectedEntry ? (
|
||||
<p className="tags-empty">Select an entry.</p>
|
||||
) : !detail ? (
|
||||
<p className="tags-empty">Loading…</p>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="rail-title">
|
||||
{valueText(detail.summary.title) || valueText(detail.summary.entry_uid)}
|
||||
</h2>
|
||||
|
||||
{detail.summary.original_url && (
|
||||
<a
|
||||
className="url-tile"
|
||||
href={detail.summary.original_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span className="ico" dangerouslySetInnerHTML={{ __html: sourceIconSvg(detail.summary.source_kind) }} />
|
||||
<span className="u-text">{detail.summary.original_url}</span>
|
||||
<span className="ext"><ExternalIcon /></span>
|
||||
</a>
|
||||
)}
|
||||
|
||||
<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>
|
||||
|
||||
{detail.artifacts.length > 0 ? (
|
||||
{detail.artifacts.length > 0 && (
|
||||
<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">
|
||||
{detail.artifacts.map((artifact, index) => (
|
||||
<li key={index}>
|
||||
|
|
@ -110,64 +124,65 @@ export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet,
|
|||
rel="noopener noreferrer"
|
||||
className="artifact-link"
|
||||
>
|
||||
{artifact.artifact_role.replace(/_/g, ' ')}
|
||||
{artifact.byte_size != null ? ` (${formatBytes(artifact.byte_size)})` : ''}
|
||||
<span className="artifact-name">{artifact.artifact_role.replace(/_/g, ' ')}</span>
|
||||
<span className="artifact-size">
|
||||
{artifact.byte_size != null ? formatBytes(artifact.byte_size) : '—'}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rail-item muted">No artifacts.</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedEntry && (
|
||||
<>
|
||||
<div className="entry-tags">
|
||||
<div className="rail-section">
|
||||
<div className="rail-section-heading">Tags</div>
|
||||
{tags.length === 0 ? (
|
||||
<span className="muted">No tags.</span>
|
||||
<p className="tags-empty">No tags yet.</p>
|
||||
) : (
|
||||
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 className="tags-wrap">
|
||||
{tags.map(tag => (
|
||||
<span key={tag.tag_uid} className="tag-pill" title={tag.full_path}>
|
||||
{tag.name}
|
||||
<button
|
||||
className="remove"
|
||||
title={`Remove tag ${tag.full_path}`}
|
||||
onClick={() => handleRemoveTag(tag.tag_uid)}
|
||||
>×</button>
|
||||
</span>
|
||||
))}
|
||||
</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>
|
||||
{selectedEntry && entryCollections.length > 0 && (
|
||||
|
||||
{entryCollections.length > 0 && (
|
||||
<div className="rail-section">
|
||||
<div className="rail-section-heading">Collections</div>
|
||||
{entryCollections.map(c => (
|
||||
<div key={c.collection_uid} className="rail-item">
|
||||
<span className="rail-label">{c.collection_uid}</span>:{' '}
|
||||
<span className="muted">
|
||||
{{ 0: 'Private', 1: 'Public', 2: 'Users only', 3: 'Public' }[c.visibility_bits] ?? `bits:${c.visibility_bits}`}
|
||||
<div key={c.collection_uid} className="coll-row">
|
||||
<span className="coll-name">{c.collection_uid}</span>
|
||||
<span className="vis-badge">
|
||||
{VIS_LABEL[c.visibility_bits] ?? `bits:${c.visibility_bits}`}
|
||||
</span>
|
||||
</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,
|
||||
};
|
||||
|
|
@ -23,32 +23,41 @@ export default function LoginPage({ onLogin }) {
|
|||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<h1>Archivr</h1>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<label>
|
||||
Username
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{error && <p className="error">{error}</p>}
|
||||
<button type="submit" disabled={loading}>
|
||||
{loading ? 'Logging in\u2026' : 'Log in'}
|
||||
</button>
|
||||
</form>
|
||||
<div className="login-card">
|
||||
<h1 className="login-brand">Archivr</h1>
|
||||
<p className="login-tagline">Sign in to your archive</p>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="login-field">
|
||||
<label className="login-label" htmlFor="login-username">Username</label>
|
||||
<input
|
||||
className="login-input"
|
||||
id="login-username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
autoFocus
|
||||
required
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
<div className="login-field">
|
||||
<label className="login-label" htmlFor="login-password">Password</label>
|
||||
<input
|
||||
className="login-input"
|
||||
id="login-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
||||
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>
|
||||
<th>Started</th>
|
||||
<th>Status</th>
|
||||
<th>Requested</th>
|
||||
<th>Completed</th>
|
||||
<th>Failed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<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}>
|
||||
<td>{run.started_at ?? ''}</td>
|
||||
<td>{run.status ?? ''}</td>
|
||||
<td>{run.requested_count ?? ''}</td>
|
||||
<td>{run.completed_count ?? ''}</td>
|
||||
<td>{run.failed_count ?? ''}</td>
|
||||
<td>{fmtDate(run.started_at)}</td>
|
||||
<td><StatusBadge status={run.status} /></td>
|
||||
<td>{run.requested_count ?? '—'}</td>
|
||||
<td>{run.completed_count ?? '—'}</td>
|
||||
<td>{run.failed_count ?? '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,23 +7,24 @@ import {
|
|||
} from '../api.js'
|
||||
|
||||
const ROLE_ADMIN = 4
|
||||
const ROLE_OWNER = 8
|
||||
|
||||
export default function SettingsView() {
|
||||
const { currentUser, setCurrentUser } = useContext(AuthContext) ?? {}
|
||||
const isAdmin = currentUser && ((currentUser.role_bits & ROLE_ADMIN) !== 0)
|
||||
const [tab, setTab] = useState('profile')
|
||||
|
||||
const tabs = ['profile', 'tokens', ...(isAdmin ? ['instance'] : [])]
|
||||
const tabLabels = { profile: 'Profile', tokens: 'API Tokens', instance: 'Instance' }
|
||||
|
||||
return (
|
||||
<section className="admin-view">
|
||||
<h1>Settings</h1>
|
||||
<div className="admin-tabs" style={{ display: 'flex', gap: 12, marginBottom: 20 }}>
|
||||
{['profile', 'tokens', ...(isAdmin ? ['instance'] : [])].map(t => (
|
||||
<div className="view-tabs">
|
||||
{tabs.map(t => (
|
||||
<button key={t}
|
||||
className={`nav-link${tab === t ? ' is-active' : ''}`}
|
||||
style={{ borderBottom: tab === t ? '2px solid var(--accent)' : undefined, color: 'var(--ink)' }}
|
||||
className={`view-tab${tab === t ? ' is-active' : ''}`}
|
||||
onClick={() => setTab(t)}>
|
||||
{t === 'profile' ? 'Profile' : t === 'tokens' ? 'API Tokens' : 'Instance'}
|
||||
{tabLabels[t]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -78,34 +79,43 @@ function ProfileTab({ currentUser, setCurrentUser }) {
|
|||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 480 }}>
|
||||
<div className="admin-section">
|
||||
<h2 style={{ fontSize: 15, marginBottom: 12 }}>Display Name</h2>
|
||||
<form className="admin-form" onSubmit={handleSaveProfile}>
|
||||
<input className="admin-input" placeholder={currentUser?.username ?? ''}
|
||||
value={displayName} onChange={e => setDisplayName(e.target.value)} />
|
||||
{saveMsg && (
|
||||
<div className={saveMsg.ok ? 'muted' : 'capture-error'}>{saveMsg.text}</div>
|
||||
)}
|
||||
<button className="capture-submit" type="submit" disabled={saving}>
|
||||
<div style={{ maxWidth: 440 }}>
|
||||
<div className="form-section">
|
||||
<h2>Display Name</h2>
|
||||
<form onSubmit={handleSaveProfile}>
|
||||
<div className="form-field">
|
||||
<label className="form-label" htmlFor="display-name">Name shown in the UI</label>
|
||||
<input className="field-input" id="display-name"
|
||||
placeholder={currentUser?.username ?? ''}
|
||||
value={displayName} onChange={e => setDisplayName(e.target.value)} />
|
||||
</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'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="admin-section" style={{ marginTop: 28 }}>
|
||||
<h2 style={{ fontSize: 15, marginBottom: 12 }}>Change Password</h2>
|
||||
<form className="admin-form" onSubmit={handleChangePassword}>
|
||||
<input className="admin-input" type="password" placeholder="Current password"
|
||||
value={curPw} onChange={e => setCurPw(e.target.value)} required />
|
||||
<input className="admin-input" type="password" placeholder="New password"
|
||||
value={newPw} onChange={e => setNewPw(e.target.value)} required />
|
||||
<input className="admin-input" type="password" placeholder="Confirm new password"
|
||||
value={confirmPw} onChange={e => setConfirmPw(e.target.value)} required />
|
||||
{pwMsg && (
|
||||
<div className={pwMsg.ok ? 'muted' : 'capture-error'}>{pwMsg.text}</div>
|
||||
)}
|
||||
<button className="capture-submit" type="submit" disabled={pwSaving}>
|
||||
<div className="form-section">
|
||||
<h2>Change Password</h2>
|
||||
<form onSubmit={handleChangePassword}>
|
||||
<div className="form-field">
|
||||
<label className="form-label" htmlFor="cur-pw">Current password</label>
|
||||
<input className="field-input" id="cur-pw" type="password"
|
||||
value={curPw} onChange={e => setCurPw(e.target.value)} required />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label className="form-label" htmlFor="new-pw">New password</label>
|
||||
<input className="field-input" id="new-pw" type="password"
|
||||
value={newPw} onChange={e => setNewPw(e.target.value)} required />
|
||||
</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'}
|
||||
</button>
|
||||
</form>
|
||||
|
|
@ -120,7 +130,7 @@ function TokensTab() {
|
|||
const [error, setError] = useState(null)
|
||||
const [newName, setNewName] = useState('')
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [newToken, setNewToken] = useState(null) // { raw_token, name }
|
||||
const [newToken, setNewToken] = useState(null)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true); setError(null)
|
||||
|
|
@ -157,48 +167,47 @@ function TokensTab() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 640 }}>
|
||||
<h2 style={{ fontSize: 15, marginBottom: 12 }}>API Tokens</h2>
|
||||
{newToken && (
|
||||
<div style={{ background: '#e8f5e9', border: '1px solid #a5d6a7', padding: '12px 14px', marginBottom: 16, fontSize: 13 }}>
|
||||
<strong>Token created.</strong> Copy it now \u2014 it will not be shown again.<br />
|
||||
<code style={{ wordBreak: 'break-all', display: 'block', marginTop: 6, padding: '6px 8px', background: '#f5f5f5', border: '1px solid #ddd' }}>
|
||||
{newToken.raw_token}
|
||||
</code>
|
||||
<button style={{ marginTop: 8, fontSize: 12, border: '1px solid #aaa', background: 'none', cursor: 'pointer' }}
|
||||
onClick={() => setNewToken(null)}>Dismiss</button>
|
||||
</div>
|
||||
)}
|
||||
<form className="admin-form" onSubmit={handleCreate} style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
|
||||
<input className="admin-input" placeholder="Token name" value={newName}
|
||||
onChange={e => setNewName(e.target.value)} style={{ flex: 1 }} required />
|
||||
<button className="capture-submit" type="submit" disabled={creating}>
|
||||
{creating ? 'Creating\u2026' : 'Create'}
|
||||
</button>
|
||||
</form>
|
||||
{error && <div className="capture-error">{error}</div>}
|
||||
{loading ? <div className="muted">Loading\u2026</div> : (
|
||||
<div className="admin-list">
|
||||
{tokens.length === 0 && <div className="muted">No tokens yet.</div>}
|
||||
{tokens.map(tok => (
|
||||
<div key={tok.token_uid} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
border: '1px solid var(--line)', background: 'var(--paper-3)', padding: '10px 12px' }}>
|
||||
<div>
|
||||
<strong style={{ fontSize: 14 }}>{tok.name}</strong>
|
||||
<div className="muted" style={{ fontSize: 12, marginTop: 2 }}>
|
||||
Created {tok.created_at.slice(0, 10)}
|
||||
{tok.last_used_at && ` \u00b7 Last used ${tok.last_used_at.slice(0, 10)}`}
|
||||
<div style={{ maxWidth: 600 }}>
|
||||
<div className="form-section">
|
||||
<h2>API Tokens</h2>
|
||||
{newToken && (
|
||||
<div className="token-banner">
|
||||
<strong>Token created.</strong> Copy it now — it won't be shown again.
|
||||
<code>{newToken.raw_token}</code>
|
||||
<button className="token-dismiss" onClick={() => setNewToken(null)}>Dismiss</button>
|
||||
</div>
|
||||
)}
|
||||
<form className="token-create-row" onSubmit={handleCreate}>
|
||||
<input className="field-input field-input--flex" placeholder="Token name"
|
||||
value={newName} onChange={e => setNewName(e.target.value)} required />
|
||||
<button className="btn-primary" type="submit" disabled={creating}>
|
||||
{creating ? 'Creating\u2026' : 'Create token'}
|
||||
</button>
|
||||
</form>
|
||||
{error && <div className="form-msg form-msg--err">{error}</div>}
|
||||
{loading ? (
|
||||
<div className="muted">Loading\u2026</div>
|
||||
) : (
|
||||
<div>
|
||||
{tokens.length === 0 && <div className="muted">No tokens yet.</div>}
|
||||
{tokens.map(tok => (
|
||||
<div key={tok.token_uid} className="token-row">
|
||||
<div className="token-row-info">
|
||||
<strong>{tok.name}</strong>
|
||||
<div className="muted">
|
||||
Created {tok.created_at.slice(0, 10)}
|
||||
{tok.last_used_at && ` \u00b7 Last used ${tok.last_used_at.slice(0, 10)}`}
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn-danger" style={{ fontSize: 12, padding: '4px 10px' }}
|
||||
onClick={() => handleDelete(tok.token_uid)}>
|
||||
Revoke
|
||||
</button>
|
||||
</div>
|
||||
<button onClick={() => handleDelete(tok.token_uid)}
|
||||
style={{ border: '1px solid var(--line)', background: 'none', cursor: 'pointer',
|
||||
color: 'var(--accent)', fontSize: 12, padding: '4px 10px' }}>
|
||||
Revoke
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -232,40 +241,40 @@ function InstanceTab() {
|
|||
}
|
||||
|
||||
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
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 480 }}>
|
||||
<h2 style={{ fontSize: 15, marginBottom: 12 }}>Instance Settings</h2>
|
||||
<form onSubmit={handleSave}>
|
||||
<div className="admin-section">
|
||||
<div style={{ maxWidth: 440 }}>
|
||||
<div className="form-section">
|
||||
<h2>Instance Settings</h2>
|
||||
<form onSubmit={handleSave}>
|
||||
{[
|
||||
['public_index_enabled', 'Public index (unauthenticated browsing)'],
|
||||
['public_entry_content_enabled', 'Public entry content'],
|
||||
['open_registration_enabled', 'Open registration'],
|
||||
].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]}
|
||||
onChange={e => setSettings(s => ({ ...s, [key]: e.target.checked }))} />
|
||||
{label}
|
||||
</label>
|
||||
))}
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={{ display: 'block', fontWeight: 600, marginBottom: 4, fontSize: 13 }}>Default entry visibility</label>
|
||||
<select className="admin-input" value={settings.default_entry_visibility}
|
||||
<div className="form-field" style={{ marginTop: 4 }}>
|
||||
<label className="form-label">Default entry visibility</label>
|
||||
<select className="field-input" value={settings.default_entry_visibility}
|
||||
onChange={e => setSettings(s => ({ ...s, default_entry_visibility: Number(e.target.value) }))}>
|
||||
<option value={0}>Private (0)</option>
|
||||
<option value={2}>Unlisted (2)</option>
|
||||
<option value={3}>Public (3)</option>
|
||||
<option value={0}>Private</option>
|
||||
<option value={2}>Unlisted</option>
|
||||
<option value={3}>Public</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{saveMsg && <div className={saveMsg.ok ? 'muted' : 'capture-error'}>{saveMsg.text}</div>}
|
||||
<button className="capture-submit" type="submit" disabled={saving}>
|
||||
{saving ? 'Saving\u2026' : 'Save Settings'}
|
||||
</button>
|
||||
</form>
|
||||
{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 Settings'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,42 +32,53 @@ export default function SetupPage({ onComplete }) {
|
|||
|
||||
return (
|
||||
<div className="setup-page">
|
||||
<h1>Welcome to Archivr</h1>
|
||||
<p>Create your owner account to get started.</p>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<label>
|
||||
Username
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Confirm password
|
||||
<input
|
||||
type="password"
|
||||
value={confirm}
|
||||
onChange={e => setConfirm(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{error && <p className="error">{error}</p>}
|
||||
<button type="submit" disabled={loading}>
|
||||
{loading ? 'Creating account\u2026' : 'Create account'}
|
||||
</button>
|
||||
</form>
|
||||
<div className="setup-card">
|
||||
<h1 className="setup-brand">Archivr</h1>
|
||||
<p className="setup-tagline">Create your owner account to get started.</p>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="setup-field">
|
||||
<label className="setup-label" htmlFor="setup-username">Username</label>
|
||||
<input
|
||||
className="setup-input"
|
||||
id="setup-username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
autoFocus
|
||||
required
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
<div className="setup-field">
|
||||
<label className="setup-label" htmlFor="setup-password">Password</label>
|
||||
<input
|
||||
className="setup-input"
|
||||
id="setup-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="setup-field">
|
||||
<label className="setup-label" htmlFor="setup-confirm">Confirm password</label>
|
||||
<input
|
||||
className="setup-input"
|
||||
id="setup-confirm"
|
||||
type="password"
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,8 +31,14 @@ export default function TagsView({ tagNodes, tagFilter, onTagFilterSet, onViewCh
|
|||
return (
|
||||
<section id="tags-view" className="view is-active">
|
||||
<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 ? (
|
||||
<div>No tags yet.</div>
|
||||
<p className="muted" style={{ padding: '8px 0' }}>No tags yet.</p>
|
||||
) : (
|
||||
<ul className="tag-tree-list">
|
||||
{tagNodes.map(node => (
|
||||
|
|
|
|||
|
|
@ -16,10 +16,12 @@ export default function Topbar({ archives, archiveId, onArchiveChange, view, onV
|
|||
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>
|
||||
<div className="switcher">
|
||||
<select 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>
|
||||
</div>
|
||||
<nav className="nav" aria-label="Primary">
|
||||
{['archive', 'runs', 'admin', 'tags', 'collections', 'settings'].map(name => (
|
||||
<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>
|
||||
{currentUser && (
|
||||
<div className="user-menu">
|
||||
<span className="username">{currentUser.display_name || currentUser.username}</span>
|
||||
<button className="nav-link" onClick={() => onViewChange('settings')} style={{ color: '#d7cdbf', fontSize: 13 }}>Settings</button>
|
||||
<button onClick={handleLogout} disabled={loggingOut} className="logout-btn">
|
||||
<span className="user-name">{currentUser.display_name || currentUser.username}</span>
|
||||
<button className="logout-btn" onClick={handleLogout} disabled={loggingOut}>
|
||||
{loggingOut ? 'Logging out\u2026' : 'Log out'}
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
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()],
|
||||
build: {
|
||||
outDir: "../crates/archivr-server/static",
|
||||
emptyOutDir: false,
|
||||
emptyOutDir: true,
|
||||
assetsDir: "assets",
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue