This commit is contained in:
Stefan ATZLESBERGER
2026-04-16 14:30:36 +02:00
parent d1ca72450f
commit 5cbf43a2db
2 changed files with 250 additions and 112 deletions
+15 -2
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { Tags } from "lucide-react";
import { formatTreeTagDisplay } from "@/lib/treeTagDisplay"; import { formatTreeTagDisplay } from "@/lib/treeTagDisplay";
import type { Snippet } from "@/types/snippet"; import type { Snippet } from "@/types/snippet";
import type { TagTreeNode } from "@/types/tagTree"; import type { TagTreeNode } from "@/types/tagTree";
@@ -149,8 +150,20 @@ export function NotesApp() {
</Select> </Select>
</div> </div>
<div className="flex flex-wrap gap-2 sm:shrink-0"> <div className="flex flex-wrap gap-2 sm:shrink-0">
<Button type="button" variant="outline" className="h-9 w-full sm:w-auto" onClick={() => setTagTreeOpen(true)}> <Button
Tag-Baum type="button"
variant="outline"
className="h-9 w-full gap-2 sm:w-auto"
onClick={() => setTagTreeOpen(true)}
title="Tag-Hierarchie und Anzeige-Prefixe verwalten"
>
<Tags className="size-4 shrink-0 opacity-70" aria-hidden />
<span>Tag-Baum</span>
{tagTreeNodes.length > 0 ? (
<span className="rounded-full bg-muted px-1.5 py-0 text-[10px] font-medium tabular-nums text-muted-foreground">
{tagTreeNodes.length}
</span>
) : null}
</Button> </Button>
<Button type="button" className="h-9 w-full sm:w-auto" onClick={() => void createNew()}> <Button type="button" className="h-9 w-full sm:w-auto" onClick={() => void createNew()}>
Neu Neu
+190 -65
View File
@@ -1,5 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { FolderTree, Loader2, Plus, Trash2 } from "lucide-react";
import { formatTreeTagDisplay } from "@/lib/treeTagDisplay"; import { formatTreeTagDisplay } from "@/lib/treeTagDisplay";
import { cn } from "@/lib/utils";
import type { TagTreeNode } from "@/types/tagTree"; import type { TagTreeNode } from "@/types/tagTree";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
@@ -84,6 +86,11 @@ export function TagTreeDialog({ open, onOpenChange, onChanged }: Props) {
setLabelPrefix(n.label_prefix ?? ""); setLabelPrefix(n.label_prefix ?? "");
}; };
const startNewUnder = (parentId: number | null) => {
resetForm();
setParentIdStr(parentId == null ? "__root__" : String(parentId));
};
const save = async () => { const save = async () => {
setBusy(true); setBusy(true);
setError(null); setError(null);
@@ -122,8 +129,9 @@ export function TagTreeDialog({ open, onOpenChange, onChanged }: Props) {
} }
}; };
const remove = async (id: number) => { const remove = async (id: number, e?: React.MouseEvent) => {
if (!window.confirm("Knoten inkl. Unterknoten löschen?")) return; e?.stopPropagation();
if (!window.confirm("Diesen Knoten und alle Unterknoten wirklich löschen?")) return;
setBusy(true); setBusy(true);
setError(null); setError(null);
try { try {
@@ -164,70 +172,183 @@ export function TagTreeDialog({ open, onOpenChange, onChanged }: Props) {
return nodes.filter(n => !blocked.has(n.id)); return nodes.filter(n => !blocked.has(n.id));
}, [nodes, editingId]); }, [nodes, editingId]);
const previewLabel = name.trim() ? formatTreeTagDisplay(name.trim(), labelPrefix) : null;
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[85vh] gap-0 overflow-hidden p-0 sm:max-w-lg"> <DialogContent className="flex max-h-[90vh] w-[calc(100vw-1.5rem)] max-w-3xl flex-col gap-0 overflow-hidden p-0 sm:max-w-3xl">
<DialogHeader className="shrink-0 space-y-1 border-b px-6 py-4"> <DialogHeader className="shrink-0 space-y-2 border-b bg-muted/30 px-5 py-4 text-left sm:px-6">
<DialogTitle>Tag-Baum</DialogTitle> <div className="flex items-start gap-3">
<DialogDescription> <div className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-lg border bg-background shadow-sm">
Hierarchie für zusammengehörige Tags. Optionaler Anzeige-Prefix (z.&nbsp;B.{" "} <FolderTree className="size-4 text-muted-foreground" aria-hidden />
<code className="text-xs">Projekt:</code>) ergibt die Darstellung{" "} </div>
<code className="text-xs">Projekt: sp-migrator</code> bei Name{" "} <div className="min-w-0 flex-1 space-y-1">
<code className="text-xs">sp-migrator</code>. Im Snippet und in der Suche gilt weiter der kurze Name. <DialogTitle className="text-lg">Tag-Baum</DialogTitle>
<DialogDescription className="text-pretty text-xs leading-relaxed sm:text-sm">
Strukturierte Tags für Suche und Farben.{" "}
<span className="text-foreground/90">Kurzname</span> steht in Snippets; optionaler Prefix nur für die
Anzeige (z.&nbsp;B. <span className="font-mono text-foreground">Projekt: sp-migrator</span>).
</DialogDescription> </DialogDescription>
</div>
</div>
</DialogHeader> </DialogHeader>
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-auto px-6 py-4"> <div className="flex min-h-0 flex-1 flex-col lg:flex-row lg:divide-x">
{loading ? <p className="text-sm text-muted-foreground">Lade …</p> : null} {/* Baum */}
{error ? <p className="text-sm text-destructive">{error}</p> : null} <div className="flex min-h-[min(220px,40vh)] flex-1 flex-col border-b bg-muted/10 lg:min-h-0 lg:max-w-[min(100%,420px)] lg:border-b-0 lg:bg-muted/5">
<div className="flex shrink-0 items-center justify-between gap-2 border-b px-4 py-2.5 sm:px-5">
<div className="flex items-center gap-2">
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Hierarchie</span>
{!loading ? (
<span className="rounded-full bg-muted px-2 py-0.5 font-mono text-[10px] text-muted-foreground tabular-nums">
{nodes.length}
</span>
) : null}
</div>
<Button
type="button"
variant="secondary"
size="sm"
className="h-8 gap-1.5 px-2.5 text-xs"
disabled={busy}
onClick={() => startNewUnder(null)}
>
<Plus className="size-3.5" aria-hidden />
Neuer Knoten
</Button>
</div>
<div className="space-y-1 rounded-md border bg-muted/20 p-2 text-sm"> <div className="min-h-0 flex-1 overflow-y-auto px-2 py-2 sm:px-3">
{flatOrdered.length === 0 && !loading ? ( {loading ? (
<p className="px-2 py-3 text-muted-foreground">Noch keine Knoten.</p> <div className="flex flex-col items-center justify-center gap-2 py-12 text-muted-foreground">
<Loader2 className="size-7 animate-spin opacity-70" aria-hidden />
<span className="text-sm">Lade Tag-Baum …</span>
</div>
) : flatOrdered.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-3 px-4 py-10 text-center">
<p className="max-w-xs text-sm text-muted-foreground">
Noch keine Knoten. Lege den ersten an — er erscheint in der Vorschau und in der Tag-Suche.
</p>
<Button type="button" size="sm" className="gap-1.5" onClick={() => startNewUnder(null)}>
<Plus className="size-3.5" aria-hidden />
Ersten Knoten anlegen
</Button>
</div>
) : ( ) : (
flatOrdered.map(({ node: n, depth }) => ( <ul role="list">
{flatOrdered.map(({ node: n, depth }) => {
const isActive = editingId === n.id;
return (
<li key={n.id} role="none">
<div <div
key={n.id} className={cn(
className="flex items-center gap-2 rounded px-2 py-1 hover:bg-muted/60" "group flex items-stretch gap-1 rounded-lg border border-transparent transition-colors",
style={{ paddingLeft: `${8 + depth * 14}px` }} isActive ? "border-border bg-background shadow-sm" : "hover:border-border/60 hover:bg-background/80",
)}
>
<button
type="button"
disabled={busy}
onClick={() => startEdit(n)}
className={cn(
"flex min-w-0 flex-1 items-center gap-2 rounded-l-lg py-2 pl-2 pr-1 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring",
isActive && "cursor-default",
)}
style={{ paddingLeft: `${10 + depth * 14}px` }}
> >
<span <span
className="min-w-0 flex-1 truncate font-mono text-xs" className="size-2 shrink-0 rounded-full ring-1 ring-border ring-offset-1 ring-offset-background"
title={`${n.name}${n.label_prefix ? ` · Prefix: ${n.label_prefix}` : ""}`} style={{
style={ backgroundColor: n.color ?? "hsl(var(--muted-foreground) / 0.25)",
n.color }}
? { borderLeft: `3px solid ${n.color}`, paddingLeft: 6 } title={n.color ? `Farbe ${n.color}` : "Keine Farbe"}
: { paddingLeft: 9 } aria-hidden
} />
> <div className="min-w-0 flex-1">
{formatTreeTagDisplay(n.name, n.label_prefix)} <span className="truncate text-sm font-medium leading-tight">
{n.label_prefix}
</span> </span>
<Button type="button" size="sm" variant="secondary" className="h-7 shrink-0 px-2 text-xs" onClick={() => startEdit(n)}> &nbsp;
Bearbeiten <span className="truncate font-mono text-sm text-muted-foreground">
{n.name}
</span>
</div>
</button>
<div className="flex shrink-0 justify-center gap-0.5 py-1 pr-1">
<Button
type="button"
variant="ghost"
size="icon-sm"
className="size-8 text-muted-foreground hover:text-foreground"
title="Unterknoten anlegen"
disabled={busy}
onClick={e => {
e.stopPropagation();
startNewUnder(n.id);
}}
>
<Plus className="size-3.5" aria-hidden />
<span className="sr-only">Unterknoten anlegen</span>
</Button> </Button>
<Button <Button
type="button" type="button"
size="sm"
variant="ghost" variant="ghost"
className="h-7 shrink-0 px-2 text-xs text-destructive hover:text-destructive" size="icon-sm"
onClick={() => void remove(n.id)} className="size-8 text-muted-foreground hover:text-destructive"
title="Löschen"
disabled={busy} disabled={busy}
onClick={e => void remove(n.id, e)}
> >
Löschen <Trash2 className="size-3.5" aria-hidden />
<span className="sr-only">Löschen</span>
</Button> </Button>
</div> </div>
)) </div>
</li>
);
})}
</ul>
)} )}
</div> </div>
<div className="space-y-3 border-t pt-3">
<p className="text-xs font-medium text-muted-foreground">{editingId == null ? "Neuer Knoten" : `Knoten #${editingId} bearbeiten`}</p>
<div className="space-y-1.5">
<Label htmlFor="tt-name">Name (Referenz im Snippet / Intellisense)</Label>
<Input id="tt-name" value={name} onChange={e => setName(e.target.value)} className="font-mono text-sm" autoComplete="off" />
</div> </div>
<div className="space-y-1.5">
<Label htmlFor="tt-prefix">Anzeige-Prefix (optional, z.&nbsp;B. Projekt:)</Label> {/* Formular */}
<div className="flex w-full min-w-0 flex-1 flex-col overflow-y-auto bg-background lg:max-w-md">
<div className="border-b px-4 py-2.5 sm:px-5">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{editingId == null ? "Knoten anlegen" : "Knoten bearbeiten"}
</p>
{editingId != null ? (
<p className="mt-0.5 font-mono text-[11px] text-muted-foreground">ID {editingId}</p>
) : null}
</div>
<div className="flex flex-1 flex-col gap-5 px-4 py-4 sm:px-5">
{error ? (
<p className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</p>
) : null}
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="tt-name" className="text-xs text-muted-foreground">
Kurzname (Snippet, Suche, Intellisense)
</Label>
<Input
id="tt-name"
value={name}
onChange={e => setName(e.target.value)}
className="font-mono text-sm"
autoComplete="off"
placeholder="sp-migrator"
/>
</div>
<div className="space-y-2">
<Label htmlFor="tt-prefix" className="text-xs text-muted-foreground">
Anzeige-Prefix (optional)
</Label>
<Input <Input
id="tt-prefix" id="tt-prefix"
value={labelPrefix} value={labelPrefix}
@@ -236,21 +357,20 @@ export function TagTreeDialog({ open, onOpenChange, onChanged }: Props) {
className="font-mono text-sm" className="font-mono text-sm"
autoComplete="off" autoComplete="off"
/> />
<p className="text-xs text-muted-foreground"> <div className="rounded-md border bg-muted/30 px-3 py-2 text-xs">
Vorschau:{" "} <span className="text-muted-foreground">Vorschau </span>
<span className="font-mono text-foreground"> <span className="font-medium text-foreground">{previewLabel ?? "—"}</span>
{name.trim() ? formatTreeTagDisplay(name.trim(), labelPrefix) : "—"}
</span>
</p>
</div> </div>
<div className="space-y-1.5"> </div>
<Label>Parent</Label>
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">Übergeordnet</Label>
<Select value={parentIdStr} onValueChange={setParentIdStr}> <Select value={parentIdStr} onValueChange={setParentIdStr}>
<SelectTrigger className="h-9"> <SelectTrigger className="h-9 w-full font-mono text-xs">
<SelectValue placeholder="Ohne Parent (Wurzel)" /> <SelectValue placeholder="Wurzel" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="__root__">(Wurzel)</SelectItem> <SelectItem value="__root__">(Wurzel — kein Parent)</SelectItem>
{parentOptions.map(n => ( {parentOptions.map(n => (
<SelectItem key={n.id} value={String(n.id)}> <SelectItem key={n.id} value={String(n.id)}>
{formatTreeTagDisplay(n.name, n.label_prefix)} {formatTreeTagDisplay(n.name, n.label_prefix)}
@@ -259,20 +379,23 @@ export function TagTreeDialog({ open, onOpenChange, onChanged }: Props) {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="space-y-1.5">
<Label htmlFor="tt-color">Farbe (#rrggbb, optional)</Label> <div className="space-y-2">
<Label htmlFor="tt-color" className="text-xs text-muted-foreground">
Farbe (optional, #rrggbb)
</Label>
<div className="flex gap-2"> <div className="flex gap-2">
<Input <Input
id="tt-color" id="tt-color"
value={color} value={color}
onChange={e => setColor(e.target.value)} onChange={e => setColor(e.target.value)}
placeholder="#3b82f6" placeholder="#3b82f6"
className="font-mono text-sm" className="min-w-0 flex-1 font-mono text-sm"
autoComplete="off" autoComplete="off"
/> />
<Input <Input
type="color" type="color"
className="h-9 w-12 shrink-0 cursor-pointer p-1" className="h-9 w-11 shrink-0 cursor-pointer rounded-md border p-1"
value={/^#[0-9A-Fa-f]{6}$/.test(color) ? color : "#64748b"} value={/^#[0-9A-Fa-f]{6}$/.test(color) ? color : "#64748b"}
onChange={e => setColor(e.target.value)} onChange={e => setColor(e.target.value)}
aria-label="Farbe wählen" aria-label="Farbe wählen"
@@ -281,16 +404,18 @@ export function TagTreeDialog({ open, onOpenChange, onChanged }: Props) {
</div> </div>
</div> </div>
</div> </div>
</div>
</div>
<DialogFooter className="shrink-0 flex-row flex-wrap gap-2 border-t px-6 py-4"> <DialogFooter className="shrink-0 flex-col gap-2 border-t bg-muted/20 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-5">
<Button type="button" variant="outline" disabled={busy} onClick={resetForm}> <Button type="button" variant="ghost" size="sm" className="h-9 w-full sm:w-auto" disabled={busy} onClick={resetForm}>
Formular leeren Eingaben zurücksetzen
</Button> </Button>
<div className="flex flex-1 flex-wrap justify-end gap-2"> <div className="flex w-full gap-2 sm:w-auto sm:justify-end">
<Button type="button" variant="secondary" disabled={busy} onClick={() => onOpenChange(false)}> <Button type="button" variant="outline" className="h-9 flex-1 sm:flex-none" disabled={busy} onClick={() => onOpenChange(false)}>
Schließen Schließen
</Button> </Button>
<Button type="button" disabled={busy || !name.trim()} onClick={() => void save()}> <Button type="button" className="h-9 flex-1 sm:min-w-[120px]" disabled={busy || !name.trim()} onClick={() => void save()}>
Speichern Speichern
</Button> </Button>
</div> </div>