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:
parent
8fc6754e28
commit
699eb7f62d
27 changed files with 1300 additions and 2477 deletions
|
|
@ -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;
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import '../src/styles.css';
|
||||
|
||||
export const parameters = {
|
||||
layout: 'fullscreen',
|
||||
};
|
||||
1884
frontend/bun.lock
1884
frontend/bun.lock
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} />}
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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: () => {},
|
||||
},
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
|
@ -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),
|
||||
]),
|
||||
]}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
|
@ -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>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue