dorfteich/apps/web/src/labels/LabelManager.tsx
Claude Fable 5 83fa23bbf9
All checks were successful
CI / Lint, typecheck, test (push) Successful in 3m35s
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 3m44s
CD / Deploy to Test (push) Successful in 15s
CD / Smoke tests against Test (push) Successful in 1m17s
CD / Promote to Int (push) Successful in 11s
CI / Auth e2e pack (push) Successful in 5m36s
CI / Import/export fidelity gate (push) Successful in 46s
Polish round 2: content footer, dismissable menus, manual versions, substring search, icon actions in settings (M10 follow-up)
- content footer: the collab status is an icon (wifi/off/refresh, localized
  tooltip + visually-hidden text, class/data-status hooks kept for e2e) on
  the left, the legal links right-aligned; read mode drops the editor
  frame and its inner padding, edit mode keeps it
- menus (page overflow, user, notifications bell, pond switcher) close on
  outside click and Escape via a shared useDismissable hook; the bell got
  its missing tooltip
- side panels (labels, history) stack vertically in one column
- edit mode gains a Save-version icon (prompt for the name, POST
  /pages/:id/versions); the history panel lists contributors by display
  name — more than three collapse to two plus an expandable ellipsis
  (PageVersionView.contributors resolved server-side, deleted users drop
  out)
- search finds partial words via a LIKE fallback next to the tsquery
  (FTS matches still rank first; regression-pinned in the db pack), and
  the recent-searches list has a clear button
- pond owners create labels directly in the label picker (plus a
  permanent link to the full manager); add/remove/delete buttons across
  the pond settings (members, access rules, labels, files) and the
  watch/unwatch toggles in pond/user settings are icon buttons now —
  class hooks and accessible names unchanged for the e2e packs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-12 07:13:34 +02:00

313 lines
9.3 KiB
TypeScript

import type { LabelTreeNode, LabelView } from '@dorfteich/shared';
import { collectSubtreeIds } from '@dorfteich/shared';
import { Plus, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ApiError } from '../lib/api';
import { useLabelMutations, usePondLabels } from './use-pond-labels';
/** Turns an ApiError code into a translated message; other errors are generic. */
function useErrorText(): (error: unknown) => string {
const { t } = useTranslation('errors');
return (error) => {
if (error instanceof ApiError) {
return t(error.body.code, {
defaultValue: error.body.message,
...(error.body.details ?? {}),
});
}
return t('internal_error');
};
}
/**
* Pond-settings label management (issue #44): a tree of the pond's labels with
* inline create, rename, recolour, move (via a parent picker), and delete.
* Every operation is a native button/input/select, so the tree is fully
* keyboard-operable. Access is enforced by the api — this UI is only shown to
* users who may modify the pond.
*/
export function LabelManager({ pondId }: { pondId: string }): React.JSX.Element {
const { t } = useTranslation('labels');
const { tree, flat, isLoading } = usePondLabels(pondId);
const mutations = useLabelMutations(pondId);
const errorText = useErrorText();
const [error, setError] = useState<string | null>(null);
const [newRoot, setNewRoot] = useState('');
const run = async (action: () => Promise<void>): Promise<void> => {
setError(null);
try {
await action();
} catch (err) {
setError(errorText(err));
}
};
async function addRoot(): Promise<void> {
const name = newRoot.trim();
if (!name) return;
await run(async () => {
await mutations.create(name, null);
setNewRoot('');
});
}
return (
<section className="label-manager" aria-label={t('settings.title')}>
<p className="label-manager__description">{t('settings.description')}</p>
<form
className="label-manager__new-root"
onSubmit={(event) => {
event.preventDefault();
void addRoot();
}}
>
<input
type="text"
value={newRoot}
onChange={(event) => setNewRoot(event.target.value)}
placeholder={t('settings.newRootPlaceholder')}
aria-label={t('settings.newRootPlaceholder')}
/>
<button
type="submit"
className="icon-button"
disabled={!newRoot.trim()}
aria-label={t('settings.add')}
title={t('settings.add')}
>
<Plus aria-hidden />
</button>
</form>
{error && (
<p className="label-manager__error" role="alert">
{error}
</p>
)}
{isLoading ? null : tree.length === 0 ? (
<p className="label-manager__empty">{t('settings.empty')}</p>
) : (
<ul className="label-tree" role="tree">
{tree.map((node) => (
<LabelNode
key={node.id}
node={node}
flat={flat}
mutations={mutations}
onError={setError}
errorText={errorText}
/>
))}
</ul>
)}
</section>
);
}
type Mutations = ReturnType<typeof useLabelMutations>;
function LabelNode({
node,
flat,
mutations,
onError,
errorText,
}: {
node: LabelTreeNode;
flat: LabelView[];
mutations: Mutations;
onError: (message: string | null) => void;
errorText: (error: unknown) => string;
}): React.JSX.Element {
const { t } = useTranslation('labels');
const [renaming, setRenaming] = useState(false);
const [name, setName] = useState(node.name);
const [addingChild, setAddingChild] = useState(false);
const [childName, setChildName] = useState('');
const run = async (action: () => Promise<void>): Promise<void> => {
onError(null);
try {
await action();
} catch (err) {
onError(errorText(err));
}
};
// A label cannot move under itself or one of its descendants.
const subtree = collectSubtreeIds(flat, node.id);
const moveTargets = flat.filter((l) => !subtree.has(l.id));
async function submitRename(): Promise<void> {
const next = name.trim();
if (!next || next === node.name) {
setRenaming(false);
setName(node.name);
return;
}
await run(async () => {
await mutations.rename(node.id, next);
setRenaming(false);
});
}
async function addChild(): Promise<void> {
const value = childName.trim();
if (!value) return;
await run(async () => {
await mutations.create(value, node.id);
setChildName('');
setAddingChild(false);
});
}
async function remove(): Promise<void> {
if (!window.confirm(t('settings.deleteConfirm'))) return;
await run(async () => {
try {
await mutations.remove(node.id, false);
} catch (err) {
// The api refuses to drop a label with assigned pages unless forced;
// confirm the detach, then retry with force.
if (err instanceof ApiError && err.body.code === 'label_has_pages') {
const count = err.body.details?.count?.[0] ?? '?';
if (window.confirm(t('settings.detachConfirm', { count }))) {
await mutations.remove(node.id, true);
}
return;
}
throw err;
}
});
}
return (
<li className="label-tree__item" role="treeitem">
<div className="label-node">
<span className="label-node__swatch" style={{ backgroundColor: node.color }} aria-hidden />
{renaming ? (
<input
type="text"
className="label-node__name-input"
value={name}
autoFocus
aria-label={t('settings.nameLabel')}
onChange={(event) => setName(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') void submitRename();
if (event.key === 'Escape') {
setRenaming(false);
setName(node.name);
}
}}
onBlur={() => void submitRename()}
/>
) : (
<span className="label-node__name">{node.name}</span>
)}
<div className="label-node__actions">
<input
type="color"
className="label-node__color"
value={node.color}
aria-label={t('settings.colorLabel')}
onChange={(event) => void run(() => mutations.recolor(node.id, event.target.value))}
/>
<button
type="button"
className="linklike label-node__rename"
onClick={() => (renaming ? void submitRename() : setRenaming(true))}
>
{renaming ? t('settings.save') : t('settings.rename')}
</button>
<label className="label-node__move">
<span className="visually-hidden">{t('settings.moveTo')}</span>
<select
value={node.parentId ?? ''}
aria-label={t('settings.moveTo')}
onChange={(event) =>
void run(() => mutations.move(node.id, event.target.value || null))
}
>
<option value="">{t('settings.root')}</option>
{moveTargets.map((target) => (
<option key={target.id} value={target.id}>
{target.name}
</option>
))}
</select>
</label>
<button
type="button"
className="linklike label-node__add-child"
onClick={() => setAddingChild((v) => !v)}
>
{t('settings.addChild')}
</button>
<button
type="button"
className="icon-button label-node__delete"
aria-label={t('settings.delete')}
title={t('settings.delete')}
onClick={() => void remove()}
>
<Trash2 aria-hidden />
</button>
</div>
</div>
{addingChild && (
<form
className="label-tree__child-form"
onSubmit={(event) => {
event.preventDefault();
void addChild();
}}
>
<input
type="text"
value={childName}
autoFocus
placeholder={t('settings.addChild')}
aria-label={t('settings.addChild')}
onChange={(event) => setChildName(event.target.value)}
/>
<button
type="submit"
className="icon-button"
disabled={!childName.trim()}
aria-label={t('settings.add')}
title={t('settings.add')}
>
<Plus aria-hidden />
</button>
<button type="button" className="linklike" onClick={() => setAddingChild(false)}>
{t('settings.cancel')}
</button>
</form>
)}
{node.children.length > 0 && (
<ul className="label-tree" role="group">
{node.children.map((child) => (
<LabelNode
key={child.id}
node={child}
flat={flat}
mutations={mutations}
onError={onError}
errorText={errorText}
/>
))}
</ul>
)}
</li>
);
}