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
- 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
313 lines
9.3 KiB
TypeScript
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>
|
|
);
|
|
}
|