1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-10-09 12:55:00 +02:00

feat: reorder child entries with a configurable role permission

- Persist sibling order for child entries (archived_entries.position):
  backfilled to the previous archived_at order, new children appended
  (initial playlist order kept; sync appends after user order).
- PUT /api/archives/:id/entries/:uid/children/order takes the full child
  UID list in one IMMEDIATE transaction (401 guest, 403 role not allowed,
  404 unknown or invisible parent, 400 unless exactly the current children).
- Reorder permission is a role mask in the auth instance settings
  (reorder_children_role_bits, default Admin|Owner). Only the Owner can
  change it, built-in or custom roles, never Guest. /api/auth/me and login
  expose can_reorder_children. Settings PATCH is now one IMMEDIATE txn.
- Main page: drag handle for mouse/trackpad on viewports >640px, up/down
  arrows otherwise (pure CSS, arrows are the fallback), Alt+Up/Down
  everywhere; optimistic with revert. Settings > Instance > Permissions
  for the Owner (read-only for admins).
- Fix: renaming a child entry now updates its row immediately.
- Remove Storybook (deps, config, stories, docs).
This commit is contained in:
TheGeneralist 2026-10-04 18:54:04 +02:00
parent 8fc6754e28
commit 699eb7f62d
27 changed files with 1300 additions and 2477 deletions

View file

@ -1,13 +0,0 @@
/** @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

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

File diff suppressed because it is too large Load diff

View file

@ -6,24 +6,14 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"storybook": "storybook dev -p 6006",
"storybook:build": "storybook build"
"preview": "vite preview"
},
"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"
}
}

View file

@ -123,6 +123,9 @@ export default function App() {
const [collections, setCollections] = useState([])
const [entries, setEntries] = useState([])
const [deletedUids, setDeletedUids] = useState(() => new Set())
// Renames made in this session, keyed by entry_uid. Child rows live in each
// EntryRow's private state (not `entries`), so they read titles through this.
const [renamedTitles, setRenamedTitles] = useState(() => new Map())
const [selectedEntryUid, setSelectedEntryUid] = useState(() => parseLocation().entry)
const [selectedEntry, setSelectedEntry] = useState(null)
const [selectedUids, setSelectedUids] = useState(() => {
@ -427,6 +430,9 @@ export default function App() {
}, [tagFilter]);
const handleEntryTitleChange = useCallback((entryUid, newTitle) => {
setRenamedTitles(prev => new Map(prev).set(entryUid, newTitle))
const cached = entryCacheRef.current.get(entryUid)
if (cached) entryCacheRef.current.set(entryUid, { ...cached, title: newTitle })
setEntries(prev => prev.map(e =>
e.entry_uid === entryUid ? { ...e, title: newTitle } : e
))
@ -637,6 +643,10 @@ export default function App() {
setToasts(prev => prev.filter(t => t.id !== id))
}, [])
const handleChildReorderError = useCallback((message) => {
handleToast(message, null, 'error', 'Reorder failed')
}, [handleToast])
const handleIgnoreUblock = useCallback(() => {
sessionStorage.setItem('ublockWarningIgnored', 'true')
setUblockWarningIgnored(true)
@ -752,7 +762,10 @@ export default function App() {
archiveId={archiveId}
pendingCaptures={pendingCaptures}
deletedUids={deletedUids}
renamedTitles={renamedTitles}
isPublicSession={!currentUser}
canReorder={!!currentUser?.can_reorder_children}
onChildReorderError={handleChildReorderError}
/>
)}
{view === 'runs' && <RunsView runs={runs} />}

View file

@ -77,6 +77,26 @@ export async function fetchEntryChildren(archiveId, entryUid) {
return getJson(`/api/archives/${archiveId}/entries/${entryUid}/children`);
}
// Persists a new sibling order for a parent's direct children. `childUids`
// must be exactly the parent's current children; the server answers 400
// when the set is stale (e.g. a sync added a video) — err.status carries it.
export async function reorderEntryChildren(archiveId, parentEntryUid, childUids) {
const res = await fetch(
`/api/archives/${archiveId}/entries/${parentEntryUid}/children/order`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ child_uids: childUids }),
}
);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
const err = new Error(body.error || `HTTP ${res.status}`);
err.status = res.status;
throw err;
}
}
// Fetch multiple artifact JSON payloads for an entry in parallel.
// Returns a Promise<Array> preserving index order.
export function fetchEntryArtifacts(archiveId, entryUid, indices) {
@ -359,7 +379,7 @@ export async function updateInstanceSettings(patch) {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
});
if (!res.ok) throw new Error(await res.text());
if (!res.ok) { const b = await res.json().catch(() => ({})); throw new Error(b.error || `HTTP ${res.status}`); }
}
// ── Admin helpers ─────────────────────────────────────────────────────────────

View file

@ -1,41 +0,0 @@
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

@ -2,7 +2,7 @@ import SkeletonEntryRow from './SkeletonEntryRow';
import EntryRow from './EntryRow';
export default function EntriesView({ entries, selectedUids, onRowClick, archiveId, pendingCaptures = [], deletedUids, isPublicSession }) {
export default function EntriesView({ entries, selectedUids, onRowClick, archiveId, pendingCaptures = [], deletedUids, renamedTitles, isPublicSession, canReorder = false, onChildReorderError }) {
return (
<section id="archive-view" className="view is-active">
<div className="entry-table">
@ -29,7 +29,10 @@ export default function EntriesView({ entries, selectedUids, onRowClick, archive
onRowClick={onRowClick}
selectedUids={selectedUids}
deletedUids={deletedUids}
renamedTitles={renamedTitles}
isPublicSession={isPublicSession}
canReorder={canReorder}
onReorderError={onChildReorderError}
/>
))}
</div>

View file

@ -1,15 +1,23 @@
import { useState } from 'react';
import { useState, useRef, useEffect } from 'react';
import { formatTimestamp, formatBytes, valueText, sourceIconSvg } from '../utils';
import { fetchEntryChildren } from '../api';
import { fetchEntryChildren, reorderEntryChildren } from '../api';
function ChildRow({ entry, index, onRowClick, selectedUids }) {
function ChildRow({
entry, index, onRowClick, selectedUids,
isFirst, isLast, reorderDisabled, onMove, canReorder,
isDragging, dropEdge, onHandleDragStart, onHandleDragEnd, onRowDragOver, onRowDrop,
}) {
const isSelected = (selectedUids?.size === 1) && selectedUids.has(entry.entry_uid);
const isMultiSelected = (selectedUids?.size >= 2) && selectedUids.has(entry.entry_uid);
const label = valueText(entry.title) || valueText(entry.entry_uid);
const cls = ['child-entry-row',
index % 2 === 0 ? 'child-entry-row--light' : 'child-entry-row--dark',
isSelected && 'is-selected',
isMultiSelected && 'is-multi-selected',
isDragging && 'is-dragging',
dropEdge === 'before' && 'is-drop-before',
dropEdge === 'after' && 'is-drop-after',
].filter(Boolean).join(' ');
return (
@ -19,15 +27,54 @@ function ChildRow({ entry, index, onRowClick, selectedUids }) {
data-entry-uid={entry.entry_uid}
onMouseDown={e => { if (e.shiftKey) e.preventDefault(); }}
onClick={e => onRowClick(entry, e)}
onKeyDown={e => { if (e.key === 'Enter') onRowClick(entry, e); }}
onDragOver={canReorder ? onRowDragOver : undefined}
onDrop={canReorder ? onRowDrop : undefined}
aria-keyshortcuts={canReorder ? 'Alt+ArrowUp Alt+ArrowDown' : undefined}
onKeyDown={e => {
if (canReorder && e.altKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
e.preventDefault();
if (!reorderDisabled) onMove(e.key === 'ArrowUp' ? -1 : 1);
return;
}
if (e.key === 'Enter') onRowClick(entry, e);
}}
>
<div className="col-check" aria-hidden="true" />
<div className="col-added">{formatTimestamp(entry.archived_at)}</div>
<div className="col-title">
{canReorder && (
<span className="child-reorder-controls">
<span
className="child-drag-handle"
draggable={!reorderDisabled}
title="Drag to reorder (or Alt+↑/↓)"
aria-hidden="true"
onClick={e => e.stopPropagation()}
onDragStart={onHandleDragStart}
onDragEnd={onHandleDragEnd}
>⋮⋮</span>
<button
type="button"
className="child-move-btn"
aria-label={`Move ${label} up`}
disabled={reorderDisabled || isFirst}
onClick={e => { e.stopPropagation(); onMove(-1); }}
onKeyDown={e => e.stopPropagation()}
>↑</button>
<button
type="button"
className="child-move-btn"
aria-label={`Move ${label} down`}
disabled={reorderDisabled || isLast}
onClick={e => { e.stopPropagation(); onMove(1); }}
onKeyDown={e => e.stopPropagation()}
>↓</button>
</span>
)}
<span className="source-icon">
<span dangerouslySetInnerHTML={{ __html: sourceIconSvg(entry.source_kind) }} />
</span>
<span className="entry-title">{valueText(entry.title) || valueText(entry.entry_uid)}</span>
<span className="entry-title">{label}</span>
</div>
<div className="col-type">
<span className="type-pill">{valueText(entry.entity_kind)}</span>
@ -40,11 +87,16 @@ function ChildRow({ entry, index, onRowClick, selectedUids }) {
);
}
export default function EntryRow({ entry, archiveId, rowIndex, isSelected, isMultiSelected, onRowClick, selectedUids, deletedUids, isPublicSession }) {
export default function EntryRow({ entry, archiveId, rowIndex, isSelected, isMultiSelected, onRowClick, selectedUids, deletedUids, renamedTitles, isPublicSession, canReorder = false, onReorderError }) {
const [favFailed, setFavFailed] = useState(false);
const [expanded, setExpanded] = useState(false);
const [children, setChildren] = useState(null);
const [childrenLoading, setChildrenLoading] = useState(false);
const [reorderSaving, setReorderSaving] = useState(false);
const [dragUid, setDragUid] = useState(null);
const [dropTarget, setDropTarget] = useState(null); // { uid, after: bool }
const childListRef = useRef(null);
const focusUidRef = useRef(null);
const showFavicon =
entry.source_kind === 'web' &&
@ -94,6 +146,83 @@ export default function EntryRow({ entry, archiveId, rowIndex, isSelected, isMul
}
}
// Deleted children are hidden locally and already gone server-side, so the
// filtered list is exactly the set the server expects. Renames are applied on
// top of the fetched list, since that list is only fetched once per expansion.
const visibleChildren = (children ?? [])
.filter(c => !deletedUids?.has(c.entry_uid))
.map(c => (renamedTitles?.has(c.entry_uid) ? { ...c, title: renamedTitles.get(c.entry_uid) } : c));
async function commitChildOrder(next) {
const prev = children;
setChildren(next); // optimistic
setReorderSaving(true);
try {
await reorderEntryChildren(archiveId, entry.entry_uid, next.map(c => c.entry_uid));
} catch (err) {
setChildren(prev); // revert
onReorderError?.(err.message);
if (err.status === 400) { // stale set: resync with the server
try { setChildren(await fetchEntryChildren(archiveId, entry.entry_uid)); } catch (_) { /* keep reverted list */ }
}
} finally {
setReorderSaving(false);
}
}
function moveChild(fromIdx, toIdx) {
if (!canReorder || reorderSaving || fromIdx === toIdx || toIdx < 0 || toIdx >= visibleChildren.length) return;
const next = visibleChildren.slice();
const [moved] = next.splice(fromIdx, 1);
next.splice(toIdx, 0, moved);
commitChildOrder(next);
}
function handleChildMove(idx, delta) {
focusUidRef.current = visibleChildren[idx]?.entry_uid ?? null;
moveChild(idx, idx + delta);
}
// Keep keyboard focus on the moved row (React re-inserts keyed nodes,
// which can drop focus in some browsers).
useEffect(() => {
const uid = focusUidRef.current;
if (!uid || !childListRef.current) return;
focusUidRef.current = null;
const row = childListRef.current.querySelector(`[data-entry-uid="${CSS.escape(uid)}"]`);
if (row && !row.contains(document.activeElement)) row.focus();
}, [children]);
function handleDragStart(uid, e) {
e.stopPropagation();
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', uid); // Firefox requires data to start a drag
const rowEl = e.currentTarget.closest('.child-entry-row');
if (rowEl) e.dataTransfer.setDragImage(rowEl, 16, rowEl.offsetHeight / 2);
setDragUid(uid);
}
function handleDragEnd() { setDragUid(null); setDropTarget(null); }
function handleRowDragOver(uid, e) {
if (!dragUid) return; // foreign drags (files, other parents) are not droppable
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const rect = e.currentTarget.getBoundingClientRect();
const after = e.clientY > rect.top + rect.height / 2;
if (dropTarget?.uid !== uid || dropTarget?.after !== after) setDropTarget({ uid, after });
}
function handleRowDrop(uid, e) {
if (!dragUid) return;
e.preventDefault();
const fromIdx = visibleChildren.findIndex(c => c.entry_uid === dragUid);
const targetIdx = visibleChildren.findIndex(c => c.entry_uid === uid);
const after = dropTarget?.uid === uid ? dropTarget.after : false;
handleDragEnd();
if (fromIdx === -1 || targetIdx === -1) return;
let toIdx = targetIdx + (after ? 1 : 0);
if (fromIdx < toIdx) toIdx -= 1;
moveChild(fromIdx, toIdx);
}
const outerClass = [
'entry-row-outer',
rowIndex % 2 === 0 ? 'entry-row-outer--light' : 'entry-row-outer--dark',
@ -154,18 +283,32 @@ export default function EntryRow({ entry, archiveId, rowIndex, isSelected, isMul
{expanded && (
<>
{childrenLoading && <div className="child-entries-loading">Loading…</div>}
<div className="child-entries" aria-label={`${entry.child_count} child entries`}>
{children && children
.filter(c => !deletedUids?.has(c.entry_uid))
.map((child, idx) => (
<ChildRow
key={child.entry_uid}
entry={child}
index={idx}
onRowClick={onRowClick}
selectedUids={selectedUids}
/>
))}
<div
className="child-entries"
ref={childListRef}
aria-busy={reorderSaving}
aria-label={`${entry.child_count} child entries`}
>
{visibleChildren.map((child, idx) => (
<ChildRow
key={child.entry_uid}
entry={child}
index={idx}
onRowClick={onRowClick}
selectedUids={selectedUids}
isFirst={idx === 0}
isLast={idx === visibleChildren.length - 1}
reorderDisabled={reorderSaving}
canReorder={canReorder}
onMove={delta => handleChildMove(idx, delta)}
isDragging={dragUid === child.entry_uid}
dropEdge={dropTarget?.uid === child.entry_uid && dragUid !== child.entry_uid ? (dropTarget.after ? 'after' : 'before') : null}
onHandleDragStart={e => handleDragStart(child.entry_uid, e)}
onHandleDragEnd={handleDragEnd}
onRowDragOver={e => handleRowDragOver(child.entry_uid, e)}
onRowDrop={e => handleRowDrop(child.entry_uid, e)}
/>
))}
</div>
</>
)}

View file

@ -1,66 +0,0 @@
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

@ -6,13 +6,16 @@ import {
getInstanceSettings, updateInstanceSettings,
scanOrphanBlobs, deleteOrphanBlobs,
listCookieRules, createCookieRule, updateCookieRule, deleteCookieRule,
listRoles, fetchMe,
} from '../api.js'
const ROLE_ADMIN = 4
const ROLE_OWNER = 8
export default function SettingsView({ tab, onTabChange, archiveId }) {
const { currentUser, setCurrentUser } = useContext(AuthContext) ?? {}
const isAdmin = currentUser && ((currentUser.role_bits & ROLE_ADMIN) !== 0)
const isOwner = !!currentUser && (currentUser.role_bits & ROLE_OWNER) !== 0
const tabs = ['profile', 'tokens', ...(isAdmin ? ['instance', 'cookies', 'extensions', 'storage'] : [])]
const tabLabels = { profile: 'Profile', tokens: 'API Tokens', instance: 'Instance', cookies: 'Cookies', extensions: 'Extensions', storage: 'Storage' }
@ -32,7 +35,7 @@ export default function SettingsView({ tab, onTabChange, archiveId }) {
{tab === 'profile' && <ProfileTab currentUser={currentUser} setCurrentUser={setCurrentUser} />}
{tab === 'tokens' && <TokensTab />}
{tab === 'instance' && isAdmin && <InstanceTab />}
{tab === 'instance' && isAdmin && <InstanceTab isOwner={isOwner} setCurrentUser={setCurrentUser} />}
{tab === 'cookies' && isAdmin && <CookiesTab />}
{tab === 'extensions' && isAdmin && <ExtensionsTab />}
{tab === 'storage' && isAdmin && <StorageTab archiveId={archiveId} />}
@ -239,26 +242,56 @@ function TokensTab() {
)
}
function InstanceTab() {
function InstanceTab({ isOwner, setCurrentUser }) {
const [settings, setSettings] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [saving, setSaving] = useState(false)
const [saveMsg, setSaveMsg] = useState(null)
const [roles, setRoles] = useState([])
const [reorderBits, setReorderBits] = useState(12)
const [permSaving, setPermSaving] = useState(false)
const [permMsg, setPermMsg] = useState(null)
useEffect(() => {
(async () => {
try { setSettings(await getInstanceSettings()) }
try {
const [s, r] = await Promise.all([getInstanceSettings(), listRoles()])
setSettings(s)
setRoles(r.filter(role => role.bit_position > 0))
setReorderBits(s.reorder_children_role_bits ?? 12)
}
catch (e) { setError(e.message) }
finally { setLoading(false) }
})()
}, [])
function toggleRole(bit, checked) {
setReorderBits(b => checked ? (b | bit) >>> 0 : (b & ~bit) >>> 0)
}
async function handleSavePermissions(e) {
e.preventDefault()
setPermSaving(true); setPermMsg(null)
try {
await updateInstanceSettings({ reorder_children_role_bits: reorderBits })
setSettings(s => ({ ...s, reorder_children_role_bits: reorderBits }))
const me = await fetchMe()
if (me) setCurrentUser?.(me) // owner's own can_reorder_children may change
setPermMsg({ ok: true, text: 'Saved.' })
} catch (err) {
setPermMsg({ ok: false, text: err.message })
} finally {
setPermSaving(false)
}
}
async function handleSave(e) {
e.preventDefault()
setSaving(true); setSaveMsg(null)
try {
await updateInstanceSettings(settings)
const { reorder_children_role_bits: _mask, ...rest } = settings
await updateInstanceSettings(rest)
setSaveMsg({ ok: true, text: 'Saved.' })
} catch (err) {
setSaveMsg({ ok: false, text: err.message })
@ -302,6 +335,31 @@ function InstanceTab() {
</button>
</form>
</div>
<div className="form-section">
<h2>Permissions</h2>
<form onSubmit={handleSavePermissions}>
<label className="form-label">Reorder child entries</label>
{roles.map(role => {
const bit = (1 << role.bit_position) >>> 0
return (
<label key={role.role_uid} className="checkbox-row">
<input type="checkbox" disabled={!isOwner || permSaving}
checked={(reorderBits & bit) !== 0}
onChange={e => toggleRole(bit, e.target.checked)} />
{role.name}{!role.is_builtin && <span className="muted"> (custom)</span>}
</label>
)
})}
<p className="form-hint">Roles are cumulative: every signed-in account also has User, and owners also have Admin. Checking User lets every signed-in account reorder.</p>
{!isOwner && <p className="form-hint">Only the owner can change this.</p>}
{permMsg && <div className={`form-msg form-msg--${permMsg.ok ? 'ok' : 'err'}`}>{permMsg.text}</div>}
{isOwner && (
<button className="btn-primary" type="submit" disabled={permSaving}>
{permSaving ? 'Saving\u2026' : 'Save Permissions'}
</button>
)}
</form>
</div>
</div>
)
}

View file

@ -1,31 +0,0 @@
import SkeletonEntryRow from './SkeletonEntryRow';
export default {
component: SkeletonEntryRow,
tags: ['autodocs'],
parameters: { layout: 'padded' },
};
function PendingRows({ locators }) {
return (
<div className="entry-table">
<div id="entries-body">
{locators.map(locator => (
<SkeletonEntryRow key={locator} locator={locator} />
))}
</div>
</div>
);
}
export const Examples = {
render: () => (
<PendingRows
locators={[
'https://example.com/articles/a-small-and-useful-page',
'https://www.youtube.com/watch?v=abc123&list=PL1234567890',
'tweet:1891234567890123456',
]}
/>
),
};

View file

@ -1,167 +0,0 @@
import { useState, useEffect } from 'react';
import TagsView from './TagsView';
// Installs a per-story fetch stub that intercepts /api/ tag mutations and
// returns realistic Tag shapes so renameTag/moveTag/createTag don't throw.
// Installed in useEffect so it never leaks into other stories and won't
// double-wrap on re-renders.
function ApiStub({ children }) {
useEffect(() => {
const realFetch = window.fetch.bind(window);
window.fetch = (url, opts) => {
if (typeof url === 'string' && url.startsWith('/api/')) {
const method = (opts?.method ?? 'GET').toUpperCase();
if (method === 'DELETE') {
return Promise.resolve(new Response(null, { status: 204 }));
}
// Parse request body to construct a realistic Tag shape.
// renameTag sends { name }, moveTag/createTag send path info.
// full_path must be present or callers throw on updated.full_path.
let body = {};
try { body = JSON.parse(opts?.body ?? '{}'); } catch { /* ignore */ }
const slug = (body.name ?? body.path ?? 'stub')
.trim().replace(/\s+/g, '-').replace(/[^a-zA-Z0-9-]/g, '').replace(/^-+|-+$/g, '') || 'stub';
const stubTag = {
tag_uid: 'stub-uid',
name: slug.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()),
slug,
full_path: `/${slug}`,
};
return Promise.resolve(
new Response(JSON.stringify(stubTag), {
status: method === 'POST' ? 201 : 200,
headers: { 'Content-Type': 'application/json' },
})
);
}
return realFetch(url, opts);
};
return () => { window.fetch = realFetch; };
}, []);
return children;
}
function withApiStub(Story) {
return <ApiStub><Story /></ApiStub>;
}
export default {
component: TagsView,
tags: ['autodocs'],
parameters: { layout: 'padded' },
decorators: [withApiStub],
};
// ── Shared fixtures ───────────────────────────────────────────────────────
const noop = () => {};
function tag(tag_uid, name, slug, full_path, entry_count = 0, children = [], subtree_count = null) {
return { tag: { tag_uid, name, slug, full_path }, entry_count, subtree_count: subtree_count ?? entry_count, children };
}
const sampleTree = [
tag('t1', 'Science', 'science', '/science', 12, [
tag('t2', 'Computer Science', 'computer-science', '/science/computer-science', 7, [
tag('t3', 'Algorithms', 'algorithms', '/science/computer-science/algorithms', 3),
tag('t4', 'Compilers', 'compilers', '/science/computer-science/compilers', 1),
], 11),
tag('t5', 'Physics', 'physics', '/science/physics', 4),
], 23),
tag('t6', 'History', 'history', '/history', 5, [
tag('t7', 'Ancient', 'ancient', '/history/ancient', 2),
], 7),
tag('t8', 'Reading List', 'reading-list', '/reading-list', 0),
];
// Wrapper that wires local state so onTagsRefresh/onTagRenamed callbacks
// keep the tree consistent within a story session.
function TagsViewSandbox({ initialNodes = sampleTree, tagFilter = null, humanizeTags = false }) {
const [nodes, setNodes] = useState(initialNodes);
const [filter, setFilter] = useState(tagFilter);
function handleTagRenamed(oldPath, newPath) {
if (filter === oldPath) setFilter(newPath);
else if (filter?.startsWith(oldPath + '/')) setFilter(newPath + filter.slice(oldPath.length));
}
function handleTagDeleted(deletedPath) {
if (filter === deletedPath || filter?.startsWith(deletedPath + '/')) setFilter(null);
}
// onTagsRefresh is a no-op here: in a real app it re-fetches; the stub
// tag returned from fetch won't match our fixture tree, so the tree stays
// as-is after mutations. That is acceptable for visual QA purposes.
return (
<div style={{ maxWidth: 340, fontFamily: 'Helvetica Neue, sans-serif' }}>
<TagsView
archiveId="demo"
tagNodes={nodes}
tagFilter={filter}
onTagFilterSet={setFilter}
onViewChange={noop}
onTagRenamed={handleTagRenamed}
onTagDeleted={handleTagDeleted}
onTagsRefresh={noop}
humanizeTags={humanizeTags}
/>
</div>
);
}
// ── Stories ───────────────────────────────────────────────────────────────
/** Default view with a nested tag tree. All interactions are exercisable:
* click + New / Move to open the picker modal; click a tag to filter;
* double-click or pencil to rename; × to delete.
* API mutations are stubbed — the tree won't re-fetch after saves,
* but no network errors will occur. */
export const Default = {
render: () => <TagsViewSandbox />,
};
/** Empty archive — "No tags yet." shown; + New still works. */
export const Empty = {
render: () => <TagsViewSandbox initialNodes={[]} />,
};
/** Humanize mode: slugs displayed as Title Case names. */
export const HumanizedNames = {
render: () => <TagsViewSandbox humanizeTags />,
};
/** Active tag filter — header shows the current filter path. */
export const WithActiveFilter = {
render: () => (
<TagsViewSandbox tagFilter="/science/computer-science" />
),
};
/** Flat list with no nesting. */
export const FlatList = {
render: () => (
<TagsViewSandbox
initialNodes={[
tag('a1', 'Books', 'books', '/books', 8),
tag('a2', 'Films', 'films', '/films', 3),
tag('a3', 'Music', 'music', '/music', 0),
tag('a4', 'Podcasts', 'podcasts', '/podcasts', 14),
]}
/>
),
};
/** Tags with large entry counts — verifies count badge layout. */
export const HighCounts = {
render: () => (
<TagsViewSandbox
initialNodes={[
tag('h1', 'All', 'all', '/all', 9999, [
tag('h2', 'Starred', 'starred', '/all/starred', 432),
tag('h3', 'Archive', 'archive', '/all/archive', 1204),
]),
]}
/>
),
};

View file

@ -1,36 +0,0 @@
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

@ -1,102 +0,0 @@
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>
);

View file

@ -3062,6 +3062,56 @@ body.has-audio-bar { padding-bottom: 56px; }
.child-entry-row .col-added { padding-left: 22px; }
.child-entry-row > div:last-child { padding-right: 22px; }
/* Child reorder: drag handle or move buttons inside .col-title, never both.
The arrows are the default (touch, no pointer, phone-sized ≤640px viewports,
and any browser that can't evaluate the query below). The one media query
switches a mouse/trackpad on a wider viewport to the drag handle, so the two
cases are exact complements without range syntax. Alt+↑/↓ on a focused row
works everywhere. */
.child-reorder-controls {
display: inline-flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
transition: opacity 0.15s;
}
.child-drag-handle {
display: none;
cursor: grab;
padding: 0 3px;
font-size: 0.8em;
letter-spacing: -2px;
user-select: none;
color: var(--muted);
}
.child-drag-handle:active { cursor: grabbing; }
.child-move-btn {
width: 28px;
height: 28px;
padding: 0;
border: none;
background: none;
color: inherit;
font-size: 0.9em;
line-height: 1;
cursor: pointer;
}
.child-move-btn:disabled { opacity: 0.3; cursor: default; }
.child-move-btn:focus { outline: none; }
.child-move-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; border-radius: 2px; }
@media (hover: hover) and (pointer: fine) and (min-width: 641px) {
/* Dimmed until the row is hovered/focused; there is hover to reveal it. */
.child-reorder-controls { gap: 1px; opacity: 0.3; }
.child-entry-row:hover .child-reorder-controls,
.child-entry-row:focus-within .child-reorder-controls { opacity: 1; }
.child-drag-handle { display: inline; }
.child-move-btn { display: none; }
}
.child-entry-row.is-dragging { opacity: 0.4; }
.child-entry-row.is-drop-before { box-shadow: inset 0 2px 0 var(--accent); }
.child-entry-row.is-drop-after { box-shadow: inset 0 -2px 0 var(--accent); }
.child-entries[aria-busy="true"] .child-entry-row { cursor: progress; }
/* Expand chevron button */
.entry-expand-btn {
display: inline-flex;