mirror of
https://github.com/thegeneralist01/archivr
synced 2026-07-21 18:55:36 +02:00
feat(tags): revamp tags tab (#29)
feat(tags): revamp tags tab — tooltips, entry counts, Create/Move flows, Esc handling (#29)
This commit is contained in:
parent
a4de506495
commit
289037235c
11 changed files with 2942 additions and 847 deletions
|
|
@ -111,6 +111,26 @@ export async function deleteTag(archiveId, tagUid) {
|
|||
if (!res.ok) throw new Error(await res.text());
|
||||
}
|
||||
|
||||
export async function createTag(archiveId, path) {
|
||||
const res = await fetch(`/api/archives/${archiveId}/tags`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function moveTag(archiveId, tagUid, parentUid) {
|
||||
const res = await fetch(`/api/archives/${archiveId}/tags/${tagUid}/move`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ parent_uid: parentUid ?? null }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchRuns(archiveId) {
|
||||
return getJson(`/api/archives/${archiveId}/runs`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,156 @@
|
|||
import { useState, useRef } from 'react';
|
||||
import { renameTag, deleteTag } from '../api';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { renameTag, deleteTag, createTag, moveTag } from '../api';
|
||||
|
||||
function TagNode({ node, archiveId, tagFilter, onTagFilterSet, onViewChange, onTagRenamed, onTagDeleted, onTagsRefresh, humanizeTags }) {
|
||||
// ── TagPickerNode ─────────────────────────────────────────────────────────
|
||||
// A node inside the destination picker modal.
|
||||
function TagPickerNode({ node, onPick }) {
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
className="tag-picker-node-btn"
|
||||
title={node.tag.full_path}
|
||||
onClick={() => onPick(node.tag)}
|
||||
>
|
||||
{node.tag.slug}
|
||||
</button>
|
||||
{node.children?.length > 0 && (
|
||||
<div className="tag-children">
|
||||
<ul className="tag-tree-list">
|
||||
{node.children.map(child => (
|
||||
<TagPickerNode key={child.tag.tag_uid} node={child} onPick={onPick} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
// ── TagPickerModal ────────────────────────────────────────────────────────
|
||||
// Shared modal for "create under" and "move under" destination selection.
|
||||
// `excludeUid` hides that tag and all its descendants (used for move).
|
||||
// `onPick(tag | null)` — null means "make root / no parent".
|
||||
function TagPickerModal({ title, tagNodes, excludeUid, onPick, onCancel }) {
|
||||
useEffect(() => {
|
||||
function onKey(e) { if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); onCancel(); } }
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => document.removeEventListener('keydown', onKey);
|
||||
}, [onCancel]);
|
||||
function filterTree(nodes) {
|
||||
return nodes
|
||||
.filter(n => n.tag.tag_uid !== excludeUid)
|
||||
.map(n => ({ ...n, children: filterTree(n.children) }));
|
||||
}
|
||||
const visibleNodes = excludeUid ? filterTree(tagNodes) : tagNodes;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="tag-picker-backdrop"
|
||||
onClick={e => { if (e.target === e.currentTarget) onCancel(); }}
|
||||
>
|
||||
<div className="tag-picker-modal" role="dialog" aria-modal="true">
|
||||
<div className="tag-picker-header">
|
||||
<span className="tag-picker-title">{title}</span>
|
||||
<button
|
||||
className="tag-picker-close"
|
||||
onClick={onCancel}
|
||||
title="Cancel"
|
||||
aria-label="Cancel"
|
||||
>×</button>
|
||||
</div>
|
||||
<div className="tag-picker-body">
|
||||
<button
|
||||
className="tag-picker-root-btn"
|
||||
onClick={() => onPick(null)}
|
||||
title="Place at root level (no parent)"
|
||||
>
|
||||
↑ Root tag (no parent)
|
||||
</button>
|
||||
{visibleNodes.length > 0 ? (
|
||||
<ul className="tag-tree-list tag-picker-tree">
|
||||
{visibleNodes.map(node => (
|
||||
<TagPickerNode key={node.tag.tag_uid} node={node} onPick={onPick} />
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="tag-picker-empty">No other tags available.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── CreateInput ───────────────────────────────────────────────────────────
|
||||
// Inline text input that appears in the tag tree when creating a new tag.
|
||||
// Matches the rename-input UX: Enter saves, Escape cancels, blur saves.
|
||||
function CreateInput({ parentPath, archiveId, onDone, onCancel }) {
|
||||
const [draft, setDraft] = useState('');
|
||||
const cancelRef = useRef(false);
|
||||
|
||||
async function submit() {
|
||||
const name = draft.trim();
|
||||
if (!name) { onCancel(); return; }
|
||||
const path = parentPath ? `${parentPath}/${name}` : `/${name}`;
|
||||
try {
|
||||
await createTag(archiveId, path);
|
||||
onDone();
|
||||
} catch (err) {
|
||||
alert(err.message || 'Create failed');
|
||||
onCancel();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
className="tag-rename-input"
|
||||
autoFocus
|
||||
placeholder="tag name"
|
||||
value={draft}
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') e.currentTarget.blur();
|
||||
if (e.key === 'Escape') { cancelRef.current = true; e.currentTarget.blur(); }
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (cancelRef.current) { cancelRef.current = false; onCancel(); }
|
||||
else { submit(); }
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ── TagNode ───────────────────────────────────────────────────────────────
|
||||
function TagNode({
|
||||
node,
|
||||
archiveId,
|
||||
tagFilter,
|
||||
onTagFilterSet,
|
||||
onViewChange,
|
||||
onTagRenamed,
|
||||
onTagDeleted,
|
||||
onTagsRefresh,
|
||||
humanizeTags,
|
||||
// Move source-selection mode: clicking a tag selects it as the thing to move.
|
||||
moveSelectMode,
|
||||
onMoveSourceSelect,
|
||||
// Pending create: the tag_uid of the parent that should render an inline input,
|
||||
// or '__root__' for the root list (handled in TagsView, not here).
|
||||
pendingCreateParentUid,
|
||||
onCreateDone,
|
||||
onCreateCancel,
|
||||
}) {
|
||||
const isActive = tagFilter === node.tag.full_path;
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState('');
|
||||
const cancelRef = useRef(false);
|
||||
|
||||
function handleFilterClick() {
|
||||
function handleClick() {
|
||||
if (editing) return;
|
||||
if (moveSelectMode) {
|
||||
onMoveSourceSelect(node);
|
||||
return;
|
||||
}
|
||||
const next = isActive ? null : node.tag.full_path;
|
||||
onTagFilterSet(next);
|
||||
onViewChange('archive');
|
||||
|
|
@ -16,16 +158,14 @@ function TagNode({ node, archiveId, tagFilter, onTagFilterSet, onViewChange, onT
|
|||
|
||||
function startEdit(e) {
|
||||
e.stopPropagation();
|
||||
if (moveSelectMode) return;
|
||||
setDraft(node.tag.slug);
|
||||
setEditing(true);
|
||||
}
|
||||
|
||||
async function handleRenameSave() {
|
||||
const value = draft.trim();
|
||||
if (!value || value === node.tag.slug) {
|
||||
setEditing(false);
|
||||
return;
|
||||
}
|
||||
if (!value || value === node.tag.slug) { setEditing(false); return; }
|
||||
try {
|
||||
const updated = await renameTag(archiveId, node.tag.tag_uid, value);
|
||||
onTagRenamed(node.tag.full_path, updated.full_path);
|
||||
|
|
@ -52,11 +192,18 @@ function TagNode({ node, archiveId, tagFilter, onTagFilterSet, onViewChange, onT
|
|||
}
|
||||
}
|
||||
|
||||
const childProps = { archiveId, tagFilter, onTagFilterSet, onViewChange, onTagRenamed, onTagDeleted, onTagsRefresh, humanizeTags };
|
||||
const childProps = {
|
||||
archiveId, tagFilter, onTagFilterSet, onViewChange, onTagRenamed, onTagDeleted,
|
||||
onTagsRefresh, humanizeTags, moveSelectMode, onMoveSourceSelect,
|
||||
pendingCreateParentUid, onCreateDone, onCreateCancel,
|
||||
};
|
||||
|
||||
const showCreateInput = pendingCreateParentUid === node.tag.tag_uid;
|
||||
const hasChildren = node.children?.length > 0;
|
||||
|
||||
return (
|
||||
<li>
|
||||
<div className="tag-node-row">
|
||||
<div className={`tag-node-row${moveSelectMode ? ' tag-node-row--move-select' : ''}`}>
|
||||
{editing ? (
|
||||
<input
|
||||
className="tag-rename-input"
|
||||
|
|
@ -68,50 +215,66 @@ function TagNode({ node, archiveId, tagFilter, onTagFilterSet, onViewChange, onT
|
|||
if (e.key === 'Escape') { cancelRef.current = true; e.currentTarget.blur(); }
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (cancelRef.current) {
|
||||
cancelRef.current = false;
|
||||
setEditing(false);
|
||||
} else {
|
||||
handleRenameSave();
|
||||
}
|
||||
if (cancelRef.current) { cancelRef.current = false; setEditing(false); }
|
||||
else { handleRenameSave(); }
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
className={`tag-node-btn${isActive ? ' is-active' : ''}`}
|
||||
title={node.tag.full_path}
|
||||
onClick={handleFilterClick}
|
||||
onDoubleClick={startEdit}
|
||||
className={`tag-node-btn${isActive ? ' is-active' : ''}${moveSelectMode ? ' tag-node-btn--move-select' : ''}`}
|
||||
title={moveSelectMode ? `Select "${node.tag.full_path}" to move` : node.tag.full_path}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={moveSelectMode ? undefined : startEdit}
|
||||
>
|
||||
{humanizeTags ? node.tag.name : node.tag.slug}
|
||||
<svg
|
||||
className="edit-icon"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
onClick={e => { e.stopPropagation(); startEdit(e); }}
|
||||
>
|
||||
<path d="M11.5 2.5a1.5 1.5 0 0 1 2 2L5 13l-3 1 1-3 8.5-8.5z"/>
|
||||
</svg>
|
||||
<span className="tag-node-label">{humanizeTags ? node.tag.name : node.tag.slug}</span>
|
||||
<span className="tag-node-count">
|
||||
{node.children.length === 0
|
||||
? `(${node.entry_count})`
|
||||
: `(${node.entry_count}) (${node.subtree_count} Total)`}
|
||||
</span>
|
||||
{!moveSelectMode && (
|
||||
<svg
|
||||
className="edit-icon"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
title="Rename tag"
|
||||
onClick={e => { e.stopPropagation(); startEdit(e); }}
|
||||
>
|
||||
<path d="M11.5 2.5a1.5 1.5 0 0 1 2 2L5 13l-3 1 1-3 8.5-8.5z"/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="remove tag-node-delete"
|
||||
title={`Delete tag ${node.tag.full_path}`}
|
||||
onClick={handleDelete}
|
||||
aria-label={`Delete tag ${node.tag.full_path}`}
|
||||
>×</button>
|
||||
{!editing && !moveSelectMode && (
|
||||
<button
|
||||
className="remove tag-node-delete"
|
||||
title={`Delete "${node.tag.full_path}"`}
|
||||
onClick={handleDelete}
|
||||
aria-label={`Delete "${node.tag.full_path}"`}
|
||||
>×</button>
|
||||
)}
|
||||
</div>
|
||||
{node.children?.length > 0 && (
|
||||
{(hasChildren || showCreateInput) && (
|
||||
<div className="tag-children">
|
||||
<ul className="tag-tree-list">
|
||||
{node.children.map(child => (
|
||||
<TagNode key={child.tag.tag_uid} node={child} {...childProps} />
|
||||
))}
|
||||
{showCreateInput && (
|
||||
<li>
|
||||
<CreateInput
|
||||
parentPath={node.tag.full_path}
|
||||
archiveId={archiveId}
|
||||
onDone={onCreateDone}
|
||||
onCancel={onCreateCancel}
|
||||
/>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -119,35 +282,162 @@ function TagNode({ node, archiveId, tagFilter, onTagFilterSet, onViewChange, onT
|
|||
);
|
||||
}
|
||||
|
||||
export default function TagsView({ archiveId, tagNodes, tagFilter, onTagFilterSet, onViewChange, onTagRenamed, onTagDeleted, onTagsRefresh, humanizeTags }) {
|
||||
// ── TagsView ──────────────────────────────────────────────────────────────
|
||||
export default function TagsView({
|
||||
archiveId, tagNodes, tagFilter, onTagFilterSet, onViewChange,
|
||||
onTagRenamed, onTagDeleted, onTagsRefresh, humanizeTags,
|
||||
}) {
|
||||
// ── Move state ────────────────────────────────────────────────────────
|
||||
// moveStep: null | 'select-source' | 'select-dest'
|
||||
const [moveStep, setMoveStep] = useState(null);
|
||||
const [moveSourceNode, setMoveSourceNode] = useState(null);
|
||||
|
||||
function startMoveMode() { setMoveStep('select-source'); setMoveSourceNode(null); }
|
||||
function cancelMove() { setMoveStep(null); setMoveSourceNode(null); }
|
||||
// Esc while in source-selection mode cancels the move.
|
||||
useEffect(() => {
|
||||
if (moveStep !== 'select-source') return;
|
||||
function onKey(e) { if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); cancelMove(); } }
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => document.removeEventListener('keydown', onKey);
|
||||
}, [moveStep]);
|
||||
|
||||
function handleMoveSourceSelect(node) {
|
||||
setMoveSourceNode(node);
|
||||
setMoveStep('select-dest');
|
||||
}
|
||||
|
||||
async function handleMoveDest(destTag) {
|
||||
// destTag: Tag object | null (null = make root)
|
||||
const sourceOldPath = moveSourceNode.tag.full_path;
|
||||
const sourceUid = moveSourceNode.tag.tag_uid;
|
||||
const destUid = destTag?.tag_uid ?? null;
|
||||
cancelMove();
|
||||
try {
|
||||
const updated = await moveTag(archiveId, sourceUid, destUid);
|
||||
// Keep the active tag filter consistent when the moved tag/subtree was active.
|
||||
onTagRenamed(sourceOldPath, updated.full_path);
|
||||
onTagsRefresh();
|
||||
} catch (err) {
|
||||
alert(err.message || 'Move failed');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Create state ──────────────────────────────────────────────────────
|
||||
// createStep: null | 'select-parent' | 'input'
|
||||
const [createStep, setCreateStep] = useState(null);
|
||||
const [createParentTag, setCreateParentTag] = useState(undefined);
|
||||
|
||||
function startCreateMode() { setCreateStep('select-parent'); setCreateParentTag(undefined); }
|
||||
function cancelCreate() { setCreateStep(null); setCreateParentTag(undefined); }
|
||||
|
||||
function handleCreateParentPick(tag) {
|
||||
// tag: Tag | null (null = root)
|
||||
setCreateParentTag(tag ?? null);
|
||||
setCreateStep('input');
|
||||
}
|
||||
|
||||
function handleCreateDone() {
|
||||
setCreateStep(null);
|
||||
setCreateParentTag(undefined);
|
||||
onTagsRefresh();
|
||||
}
|
||||
|
||||
// ── Derived ───────────────────────────────────────────────────────────
|
||||
const moveSelectMode = moveStep === 'select-source';
|
||||
|
||||
// The tag_uid of the parent that should render a create input inside its
|
||||
// children list. '__root__' means the root-level list handles it.
|
||||
const pendingCreateParentUid = createStep === 'input'
|
||||
? (createParentTag ? createParentTag.tag_uid : '__root__')
|
||||
: undefined;
|
||||
|
||||
const nodeProps = {
|
||||
archiveId, tagFilter, onTagFilterSet, onViewChange, onTagRenamed, onTagDeleted,
|
||||
onTagsRefresh, humanizeTags, moveSelectMode, onMoveSourceSelect: handleMoveSourceSelect,
|
||||
pendingCreateParentUid, onCreateDone: handleCreateDone, onCreateCancel: cancelCreate,
|
||||
};
|
||||
|
||||
const showRootCreateInput = pendingCreateParentUid === '__root__';
|
||||
|
||||
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>
|
||||
{moveSelectMode ? (
|
||||
<>
|
||||
<span className="tag-tree-title tag-tree-title--move">Select a tag to move</span>
|
||||
<button
|
||||
className="tag-tree-action-btn tag-tree-action-btn--cancel"
|
||||
onClick={cancelMove}
|
||||
>Cancel</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="tag-tree-title">Tags</span>
|
||||
{tagFilter && (
|
||||
<span className="tag-tree-active" title={tagFilter}>Filtering: {tagFilter}</span>
|
||||
)}
|
||||
<div className="tag-tree-actions">
|
||||
<button
|
||||
className="tag-tree-action-btn"
|
||||
onClick={startCreateMode}
|
||||
title="Create a new tag"
|
||||
disabled={!!createStep || !!moveStep}
|
||||
>+ New</button>
|
||||
<button
|
||||
className="tag-tree-action-btn"
|
||||
onClick={startMoveMode}
|
||||
title="Move a tag to a different parent"
|
||||
disabled={!!createStep || !!moveStep || tagNodes.length === 0}
|
||||
>Move</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{tagNodes.length === 0 ? (
|
||||
|
||||
{tagNodes.length === 0 && !showRootCreateInput ? (
|
||||
<p className="muted" style={{ padding: '8px 0' }}>No tags yet.</p>
|
||||
) : (
|
||||
<ul className="tag-tree-list">
|
||||
{tagNodes.map(node => (
|
||||
<TagNode key={node.tag.tag_uid} node={node}
|
||||
archiveId={archiveId}
|
||||
tagFilter={tagFilter}
|
||||
onTagFilterSet={onTagFilterSet}
|
||||
onViewChange={onViewChange}
|
||||
onTagRenamed={onTagRenamed}
|
||||
onTagDeleted={onTagDeleted}
|
||||
onTagsRefresh={onTagsRefresh}
|
||||
humanizeTags={humanizeTags}
|
||||
/>
|
||||
<TagNode key={node.tag.tag_uid} node={node} {...nodeProps} />
|
||||
))}
|
||||
{showRootCreateInput && (
|
||||
<li>
|
||||
<CreateInput
|
||||
parentPath={null}
|
||||
archiveId={archiveId}
|
||||
onDone={handleCreateDone}
|
||||
onCancel={cancelCreate}
|
||||
/>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create: pick parent modal */}
|
||||
{createStep === 'select-parent' && (
|
||||
<TagPickerModal
|
||||
title="Create tag under…"
|
||||
tagNodes={tagNodes}
|
||||
excludeUid={null}
|
||||
onPick={handleCreateParentPick}
|
||||
onCancel={cancelCreate}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Move: pick destination modal (shown after source is selected) */}
|
||||
{moveStep === 'select-dest' && moveSourceNode && (
|
||||
<TagPickerModal
|
||||
title={`Move "${moveSourceNode.tag.slug}" under…`}
|
||||
tagNodes={tagNodes}
|
||||
excludeUid={moveSourceNode.tag.tag_uid}
|
||||
onPick={handleMoveDest}
|
||||
onCancel={cancelMove}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
167
frontend/src/components/TagsView.stories.jsx
Normal file
167
frontend/src/components/TagsView.stories.jsx
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
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),
|
||||
]),
|
||||
]}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
|
@ -1279,6 +1279,135 @@ select {
|
|||
outline: none;
|
||||
}
|
||||
|
||||
/* entry count badge next to tag name */
|
||||
.tag-node-label { flex-shrink: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.tag-node-count {
|
||||
font-size: 11px;
|
||||
color: var(--muted-2);
|
||||
font-weight: 400;
|
||||
flex-shrink: 0;
|
||||
margin-left: 3px;
|
||||
}
|
||||
|
||||
/* header action buttons (+ New, Move) */
|
||||
.tag-tree-header { align-items: center; }
|
||||
.tag-tree-actions { margin-left: auto; display: flex; gap: 4px; }
|
||||
.tag-tree-action-btn {
|
||||
font-size: 11px;
|
||||
padding: 2px 7px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
line-height: 1.5;
|
||||
white-space: nowrap;
|
||||
transition: background 0.1s, color 0.1s;
|
||||
}
|
||||
.tag-tree-action-btn:hover:not(:disabled) { background: var(--paper-2); color: var(--ink); }
|
||||
.tag-tree-action-btn:disabled { opacity: 0.4; cursor: default; }
|
||||
.tag-tree-action-btn--cancel { border-color: var(--accent); color: var(--accent); }
|
||||
.tag-tree-action-btn--cancel:hover { background: color-mix(in srgb, var(--accent) 8%, transparent); }
|
||||
.tag-tree-title--move { font-style: italic; color: var(--accent-2); }
|
||||
|
||||
/* move source-select mode: nodes act as clickable targets */
|
||||
.tag-node-row--move-select .tag-node-btn { cursor: crosshair; color: var(--ink); }
|
||||
.tag-node-row--move-select .tag-node-btn:hover {
|
||||
background: color-mix(in srgb, var(--link) 10%, transparent);
|
||||
text-decoration: none;
|
||||
border-radius: var(--r);
|
||||
}
|
||||
|
||||
/* ── Tag picker modal (create/move destination) ──────────────────────────── */
|
||||
.tag-picker-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 200;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
.tag-picker-modal {
|
||||
background: var(--paper);
|
||||
border-radius: var(--r3);
|
||||
box-shadow: 0 16px 56px rgba(0, 0, 0, 0.24);
|
||||
width: 300px;
|
||||
max-width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 60vh;
|
||||
}
|
||||
.tag-picker-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
flex-shrink: 0;
|
||||
gap: 8px;
|
||||
}
|
||||
.tag-picker-title {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tag-picker-close {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--muted-2);
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tag-picker-close:hover { background: var(--paper-2); color: var(--ink); }
|
||||
.tag-picker-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 8px 12px 12px;
|
||||
}
|
||||
.tag-picker-root-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 5px 8px;
|
||||
margin-bottom: 6px;
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: var(--r);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tag-picker-root-btn:hover { background: var(--paper-2); color: var(--ink); border-color: var(--muted-2); }
|
||||
.tag-picker-tree { border-top: 1px solid var(--line-soft); padding-top: 6px; margin-top: 2px; }
|
||||
.tag-picker-node-btn {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--link);
|
||||
cursor: pointer;
|
||||
padding: 3px 6px;
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
display: block;
|
||||
width: 100%;
|
||||
border-radius: var(--r);
|
||||
}
|
||||
.tag-picker-node-btn:hover { background: var(--paper-2); text-decoration: none; }
|
||||
.tag-picker-empty { font-size: 13px; color: var(--muted-2); margin: 6px 0 0; }
|
||||
|
||||
/* ── Collections page (sidebar layout) ──────────────────────────────────── */
|
||||
.collections-view {
|
||||
padding: 24px;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue