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(null); const [newRoot, setNewRoot] = useState(''); const run = async (action: () => Promise): Promise => { setError(null); try { await action(); } catch (err) { setError(errorText(err)); } }; async function addRoot(): Promise { const name = newRoot.trim(); if (!name) return; await run(async () => { await mutations.create(name, null); setNewRoot(''); }); } return (

{t('settings.description')}

{ event.preventDefault(); void addRoot(); }} > setNewRoot(event.target.value)} placeholder={t('settings.newRootPlaceholder')} aria-label={t('settings.newRootPlaceholder')} />
{error && (

{error}

)} {isLoading ? null : tree.length === 0 ? (

{t('settings.empty')}

) : (
    {tree.map((node) => ( ))}
)}
); } type Mutations = ReturnType; 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): Promise => { 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 { 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 { const value = childName.trim(); if (!value) return; await run(async () => { await mutations.create(value, node.id); setChildName(''); setAddingChild(false); }); } async function remove(): Promise { 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 (
  • {renaming ? ( setName(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter') void submitRename(); if (event.key === 'Escape') { setRenaming(false); setName(node.name); } }} onBlur={() => void submitRename()} /> ) : ( {node.name} )}
    void run(() => mutations.recolor(node.id, event.target.value))} />
    {addingChild && (
    { event.preventDefault(); void addChild(); }} > setChildName(event.target.value)} />
    )} {node.children.length > 0 && (
      {node.children.map((child) => ( ))}
    )}
  • ); }