Admin — Schlagwörter: Complete Page Overhaul

Full redesign of the tag admin two-panel page to support infinite hierarchy depth. The admin area already has a three-column structure: EntityNav (navy sidebar) → Tags list panelEdit panel. This spec redesigns panels 2 and 3 to handle a real tree, a tree-aware parent picker, ancestry breadcrumb, children preview, tag merge flow, and a destructive delete guard.

Route: /admin/tags Route: /admin/tags/[id] New endpoint: POST /api/tags/{id}/merge Breakpoints: 320 / 768 / 1024 / 1440 Changes: TagsListPanel.svelte · [id]/+page.svelte

1 — Desktop: Full Three-Column Layout (lg, ≥1024px)

The admin shell already has three columns: EntityNav (navy, 120px at lg+) → entity list panel → edit panel. For tags, the list panel becomes a collapsible tree browser. The edit panel gains breadcrumb, children preview, merge zone, and a guarded delete zone. Nothing about the EntityNav changes.

Desktop lg (1280px) — child tag "Deutschland" selected (depth 2)
Dokumente Personen Admin
M
Admin
5
Benutzer
3
Gruppen
142
Schlagwörter
System
Schlagwörter
Orte (38)
Europa (22)
Deutschland (11)
Frankreich (5)
Österreich (6)
Asien (8)
Personen (54)
Ereignisse (19)
Hochzeiten(7)
Geburten(5)
Reisen(7)
Urkunden(12)
Schlagwort bearbeiten — Deutschland
Name
⚠ Namensänderungen wirken sich auf alle verknüpften Dokumente aus.
Deutschland
Übergeordnetes Schlagwort
Europa
Orte › Europa
×
Farbe
Farbe von Orte (amber) — wird vererbt
Untergeordnete Schlagwörter
Berlin(3) München(4) Hamburg(2) Bayern(2) … und 2 weitere →
Zusammenführen
Alle Dokumente und untergeordnete Schlagwörter auf ein anderes Schlagwort übertragen und dieses danach löschen.
⇒ Mit anderem Schlagwort zusammenführen …
Löschen
11 Dokumente verknüpft · 6 Untergeordnete Schlagwörter
Nur dieses Schlagwort löschen
Untergeordnete werden zu Europa verschoben
Gesamten Teilbaum löschen
Löscht auch 6 untergeordnete Schlagwörter
Gib Deutschland zur Bestätigung ein:
Deutschland
Löschen
Abbrechen Speichern
Three columns left-to-right: EntityNav (unchanged, 120px navy sidebar) → Tree panel (240px, replaces flat list) → Edit panel (flex-1, ~860px at 1280px). The EntityNav and its flyout behaviour on tablet are not touched by this spec.

2 — Tablet: md breakpoint (768px)

At tablet width, EntityNav shrinks to 48px icon-only strip (existing behaviour, unchanged). The tree panel and edit panel share the remaining 720px. The tree panel collapses to 32px when not needed, giving the edit panel more room.

768px — tree + edit side by side
M
Schlagwörter
Orte(38)
Europa(22)
Deutschland(11)
Frankreich(5)
Asien(8)
Personen(54)
Schlagwort bearbeiten — Deutschland
Name
Deutschland
Übergeordnetes Schlagwort
Europa
Orte › Europa
×
AbbrechenSpeichern
Tablet: EntityNav shrinks to icon-only strip (26px spec / 48px real). The tree panel and edit panel still sit side by side. Tree panel can be collapsed to give the edit panel more space.
768px — tree collapsed (more edit space)
M
Schlagwörter
Schlagwort bearbeiten — Deutschland
Name
⚠ Namensänderungen wirken sich auf alle verknüpften Dokumente aus.
Deutschland
Übergeordnetes Schlagwort
Europa
Orte › Europa
×
Farbe
Farbe von Orte (amber) — wird vererbt
Untergeordnete (6)
Berlin(3)München(4)… und 4 weitere →
AbbrechenSpeichern
Collapsed tree: 32px real wide. Click the arrow to re-expand. Edit panel gets the freed space (~690px at 768px with EntityNav 48px + collapsed tree 32px).

3 — Tree Panel: Node States & Depth

Detailed view of each node type. True depth indentation: 12px per level real (7px at spec scale). The panel width increases from 200px → 240px to accommodate depth-3+ labels without truncation. On tablet this still fits: 48px EntityNav + 240px tree + remaining edit = ✓ at 768px.

Root — expanded, active child
Orte(38)
Europa(22)
Deutschland(11)
Frankreich(5)
Asien(8)
Active: border-l-2 border-primary bg-primary/[0.09]. Depth 2 indent: pl-[28px] real.
Deep hierarchy — depth 3–4
Orte(38)
Europa(22)
Deutschland(11)
Bayern(4)
Sachsen(2)
Leipzig(1)
Depth-4 indent: pl-[52px] real. Label always truncate. 240px panel provides ~134px for label at depth 4.
Color dots — root only
Natur(6)
Ereignisse(19)
Sonstiges(3)
Familie(9)
Color dot only on root tags with a color set. Root without color: no dot, muted label color. Hover: bg-muted.
Collapsed panel handle (32px real)
Schlagwörter
32px wide. Click expands panel. State saved per tag in localStorage.
A
Each chevron is a real <button aria-expanded> with aria-controls pointing to the child list. Keyboard navigation: expands, collapses, ↑↓ move between visible nodes. The row link is a separate <a> for navigation — the chevron and the label/name are different interactive targets.

4 — Edit Panel: Root Tag vs. Child Tag

Root tags show the color picker. Child tags show an inherited-color indicator and a full ancestry breadcrumb. Both show the children preview section, merge zone, and delete guard.

Root tag "Orte" — color picker visible, no breadcrumb trail
Schlagwort bearbeiten — Orte
Name
⚠ Namensänderungen wirken sich auf alle verknüpften Dokumente aus.
Orte
Übergeordnetes Schlagwort
Kein übergeordnetes Schlagwort
×
Farbe
×
Untergeordnete Schlagwörter (5)
Europa(22) Asien(8) Amerika(5) Afrika(2) Australien(1)
Zusammenführen
Alle Dokumente und untergeordnete Schlagwörter übertragen.
⇒ Zusammenführen …
Löschen
38 Dok. · 5 Untergeordnete
Löschen
AbbrechenSpeichern
Root: single breadcrumb segment (no links). Color picker visible (no parent). When a parent is selected, the color picker slides out and the inherited-color indicator slides in.
Child tag "Deutschland" (depth 2) — full delete guard expanded
Schlagwort bearbeiten — Deutschland
Name
⚠ Namensänderungen wirken sich auf alle verknüpften Dokumente aus.
Deutschland
Übergeordnetes Schlagwort
Europa
Orte › Europa
×
Farbe
Farbe von Orte (amber) — wird vererbt
Untergeordnete Schlagwörter
Berlin(3)München(4)Hamburg(2)Bayern(2)… und 2 weitere →
Zusammenführen
Alle Dokumente und untergeordnete Schlagwörter auf ein anderes Schlagwort übertragen.
⇒ Zusammenführen …
Löschen
11 Dokumente verknüpft · 6 Untergeordnete Schlagwörter
Nur dieses Schlagwort löschen
Untergeordnete werden zu Europa verschoben
Gesamten Teilbaum löschen
Löscht auch 6 untergeordnete Schlagwörter unwiderruflich
Gib Deutschland zur Bestätigung ein:
Deutschland
Löschen
AbbrechenSpeichern
Delete guard: radio option selected + name confirmed → delete button enabled. The name input only appears after a radio is chosen. This prevents someone from typing before reading.

5 — Tree-Aware Parent Picker

Replaces the flat <select> at [id]/+page.svelte:123. Each result shows the tag name plus its full ancestry path as a subtitle. Tags that are self or descendants are excluded server-side — not shown as disabled, simply not in results.

Picker open — query "eur"
Übergeordnetes Schlagwort
eur
×
Europa
Orte › Europa
Mitteleuropa
Orte › Europa › Mitteleuropa
Südeuropa
Orte › Europa › Südeuropa
Path resolved client-side from the tags array already in layout server state — no extra API call. Color dot matches the root ancestor's color. Keyboard: ↑↓ navigate, Enter selects, Escape closes.
Value selected — clear affordance + color inheritance note
Übergeordnetes Schlagwort
Europa
Orte › Europa
×
Farbe amber von Orte wird automatisch vererbt.
When a parent with a color is selected, a helper text explains color inheritance. The color picker card hides. Clicking × restores the empty state and re-shows the color picker.
B
Accessibility: role="combobox", aria-expanded, aria-autocomplete="list", aria-activedescendant. On open, focus stays on input. Backspace clears selected value before triggering search.
C
Self and descendants excluded by the server. The server already has cycle detection in validateNoAncestorCycle() — the picker simply calls GET /api/tags?query=… and server omits invalid parents. No client-side exclusion list needed.

6 — Tag Merge: Two-Step Modal

Triggered from the amber merge zone in the edit panel. Step 1: pick target and see a live preview of what moves. Step 2: confirm with a visual from/to summary. After merge, redirect to the surviving tag.

Step 1 of 2 — Pick target, live preview
Preview card updates live as target changes. "Weiter →" disabled until a valid target is selected. Target cannot be self, descendant, or a tag that would create a cycle.
Step 2 of 2 — Confirm summary
Confirm button amber — not red. Data is not lost, it moves to the target. After merge: redirect to surviving tag ("BRD") with a banner-ok success banner in the edit panel.
D
New backend endpoint: POST /api/tags/{id}/merge body: {"targetId": "uuid"}. Atomically: (1) reassign all document_tags rows, (2) reparent children to targetId, (3) delete source. Requires ADMIN_TAG permission. Returns surviving tag. DomainException on self-merge or target-is-descendant.
E
Modal: role="dialog" aria-modal="true" aria-labelledby focus trap. Escape cancels and returns focus to the merge button. On error (e.g. duplicate name), modal stays open and shows banner-err above the summary — no silent redirect.

7 — Mobile (375px): EntityNav hidden, full-screen tree → edit

On mobile the EntityNav is hidden md:flex — completely gone (existing behaviour). The tag section shows either the tree list or the edit form full-screen, same as today. The tree list gets the same depth-aware nodes; the edit form gets breadcrumb and the new zones.

375px — Tree list (full screen)
9:41●●●
M
Alle Schlagwörter
Orte(38)
Europa(22)
Deutschland(11)
Frankreich(5)
Asien(8)
Personen(54)
Ereignisse(19)
Hochzeiten(7)
Mobile: EntityNav hidden. Tree list is full-width. No collapse button on mobile. Tapping a node navigates to the full-screen edit.
375px — Edit form (full screen)
9:41●●●
‹ Schlagwörter Deutschland
Name
Deutschland
Übergeordnetes Schlagwort
Europa
Orte › Europa
×
Untergeordnete (6)
Berlin(3)München(4)… und 4 weitere →
Zusammenführen
⇒ Zusammenführen …
Löschen
11 Dok. · 6 Untergeordnete
Löschen
AbbrechenSpeichern
Mobile edit: back link "‹ Schlagwörter" returns to tree list. All touch targets min 44×44px real. Merge modal opens full-screen on mobile.

Implementation Reference

Exact Tailwind classes, real pixel values, and file locations for every changed element. Green rows are new. Rows without colour are updates to existing elements.

Element Tailwind Classes Real px / value Notes / file
EntityNav (unchanged) md:w-12 lg:w-30 flex-shrink-0 bg-brand-navy 48px tablet / 120px desktop No changes. EntityNav.svelte
Tree panel — expanded w-60 flex-shrink-0 bg-surface border-r border-line flex flex-col overflow-hidden 240px (was 200px) Widen +40px to fit depth-3+ labels. TagsListPanel.svelte
Tree panel — collapsed w-8 flex-shrink-0 bg-surface border-r border-line flex flex-col items-center cursor-pointer 32px Click handle to re-expand. Per-tag state in localStorage.
Tree node row flex items-center h-9 pr-2 cursor-pointer border-l-2 border-transparent hover:bg-muted transition-colors height: 36px Row is split: chevron button + link are siblings, not nested
Tree node — active border-l-primary bg-primary/[0.09] 2px left border, 9% navy bg aria-current="page" on the <a>
Depth indent (per level) d0: pl-[4px] d1: pl-[16px] d2: pl-[28px] d3: pl-[40px] d4+: pl-[52px] 12px per level Computed from TagTreeNodeDTO depth. Labels: truncate
Chevron button w-4 h-9 flex items-center justify-center text-ink-3 hover:text-ink flex-shrink-0 transition-transform 16×36px (full row height) aria-expanded + aria-controls="{id}-children". SVG icon, not text.
Color dot (root tags) w-2 h-2 rounded-full flex-shrink-0 mr-1.5 + inline style="background-color: var(--c-tag-{color})" 8×8px Root tags with a color only. Child tags: no dot in tree.
Doc count suffix text-[11px] font-normal text-ink-3 ml-1 flex-shrink-0 11px From TagTreeNodeDTO.documentCount. Format: (12)
Ancestry breadcrumb flex items-center flex-wrap gap-1.5 mb-3 px-3 py-2 bg-muted rounded-sm border border-line text-xs 12px, 8px/12px padding Root: single non-linked span. aria-label="Schlagwort-Pfad"
Breadcrumb link text-primary font-semibold hover:underline underline-offset-2 focus-visible:ring-1 focus-visible:ring-focus-ring rounded-sm Real <a href="/admin/tags/{id}">
Parent picker (replaces <select>) w-full border border-line rounded-sm bg-muted px-3 py-2.5 text-sm text-ink cursor-pointer focus-visible:ring-2 focus-visible:ring-focus-ring outline-none height: ~40px; 14px font Custom combobox. role="combobox" aria-expanded aria-autocomplete="list"
Picker dropdown option flex items-start gap-2 px-3 py-2.5 hover:bg-muted cursor-pointer min-height: 44px (two-line) Path subtitle: text-[11px] text-ink-3 mt-0.5
Inherited color indicator flex items-center gap-2 mt-2 px-3 py-2 bg-muted border border-line rounded-sm text-xs text-ink-2 12px font Only on child tags. Replaces color picker when parent is set.
Children chip inline-flex items-center gap-1.5 px-2.5 py-1 bg-muted border border-line rounded-full text-[11px] font-medium text-ink-2 hover:bg-canvas transition-colors 11px, pill shape Wrapped in <a href="/admin/tags/{id}">
Merge zone border border-amber-200 bg-amber-50 rounded-sm p-4 mb-4 16px padding Amber = recoverable. Data moves to target, nothing is lost.
Merge button inline-flex items-center gap-2 px-4 py-2.5 bg-amber-800 text-amber-50 rounded-sm text-xs font-bold uppercase tracking-widest hover:opacity-80 min-h-[44px] min 44px height
Merge modal backdrop fixed inset-0 bg-ink/40 flex items-center justify-center z-50 40% overlay role="dialog" aria-modal="true". Focus trap. Escape cancels.
Modal confirm button (Step 2) px-5 py-2.5 bg-amber-800 text-amber-50 rounded-sm text-xs font-bold uppercase tracking-widest min-h-[44px] Amber On success: redirect to surviving tag + banner-ok
Delete impact summary bg-red-50 border border-red-200 rounded-sm px-3 py-2 mb-3 text-xs text-red-800 12px Counts from tree data already in page state — no extra API fetch needed
Delete radio option flex items-start gap-3 p-3 border border-red-200 bg-white rounded-sm cursor-pointer hover:bg-red-50 mb-2 min-h-[44px] min 44px Custom radio. Real <input type="radio"> visually hidden + styled indicator.
Delete name input hidden until radio selected, then w-full border border-red-200 rounded-sm bg-white px-3 py-2 text-sm focus:ring-1 focus:ring-red-400 outline-none Input appears only after a radio is chosen. Prevents pre-filling before reading the warning.
Delete confirm button bg-danger text-danger-fg rounded-sm px-4 py-2.5 text-xs font-bold uppercase tracking-widest hover:opacity-80 disabled:opacity-40 disabled:cursor-not-allowed min-h-[44px] min 44px. --c-danger: #c0392b Enabled only when radio is selected AND name input matches exactly
NEW: POST /api/tags/{id}/merge Body: {"targetId": "uuid"} Returns: surviving Tag Permission: ADMIN_TAG. Atomic transaction. DomainException on self-merge or descendant target.