snapshot
This commit is contained in:
@@ -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
|
||||||
|
|||||||
+235
-110
@@ -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,133 +172,250 @@ 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. 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>
|
<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. B. <span className="font-mono text-foreground">Projekt: sp-migrator</span>).
|
||||||
|
</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="space-y-1 rounded-md border bg-muted/20 p-2 text-sm">
|
<div className="flex items-center gap-2">
|
||||||
{flatOrdered.length === 0 && !loading ? (
|
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Hierarchie</span>
|
||||||
<p className="px-2 py-3 text-muted-foreground">Noch keine Knoten.</p>
|
{!loading ? (
|
||||||
) : (
|
<span className="rounded-full bg-muted px-2 py-0.5 font-mono text-[10px] text-muted-foreground tabular-nums">
|
||||||
flatOrdered.map(({ node: n, depth }) => (
|
{nodes.length}
|
||||||
<div
|
|
||||||
key={n.id}
|
|
||||||
className="flex items-center gap-2 rounded px-2 py-1 hover:bg-muted/60"
|
|
||||||
style={{ paddingLeft: `${8 + depth * 14}px` }}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="min-w-0 flex-1 truncate font-mono text-xs"
|
|
||||||
title={`${n.name}${n.label_prefix ? ` · Prefix: ${n.label_prefix}` : ""}`}
|
|
||||||
style={
|
|
||||||
n.color
|
|
||||||
? { borderLeft: `3px solid ${n.color}`, paddingLeft: 6 }
|
|
||||||
: { paddingLeft: 9 }
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{formatTreeTagDisplay(n.name, n.label_prefix)}
|
|
||||||
</span>
|
</span>
|
||||||
<Button type="button" size="sm" variant="secondary" className="h-7 shrink-0 px-2 text-xs" onClick={() => startEdit(n)}>
|
) : null}
|
||||||
Bearbeiten
|
</div>
|
||||||
</Button>
|
<Button
|
||||||
<Button
|
type="button"
|
||||||
type="button"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
className="h-8 gap-1.5 px-2.5 text-xs"
|
||||||
className="h-7 shrink-0 px-2 text-xs text-destructive hover:text-destructive"
|
disabled={busy}
|
||||||
onClick={() => void remove(n.id)}
|
onClick={() => startNewUnder(null)}
|
||||||
disabled={busy}
|
>
|
||||||
>
|
<Plus className="size-3.5" aria-hidden />
|
||||||
Löschen
|
Neuer Knoten
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto px-2 py-2 sm:px-3">
|
||||||
|
{loading ? (
|
||||||
|
<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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
))
|
) : (
|
||||||
)}
|
<ul role="list">
|
||||||
|
{flatOrdered.map(({ node: n, depth }) => {
|
||||||
|
const isActive = editingId === n.id;
|
||||||
|
return (
|
||||||
|
<li key={n.id} role="none">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"group flex items-stretch gap-1 rounded-lg border border-transparent transition-colors",
|
||||||
|
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
|
||||||
|
className="size-2 shrink-0 rounded-full ring-1 ring-border ring-offset-1 ring-offset-background"
|
||||||
|
style={{
|
||||||
|
backgroundColor: n.color ?? "hsl(var(--muted-foreground) / 0.25)",
|
||||||
|
}}
|
||||||
|
title={n.color ? `Farbe ${n.color}` : "Keine Farbe"}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<span className="truncate text-sm font-medium leading-tight">
|
||||||
|
{n.label_prefix}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<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
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="size-8 text-muted-foreground hover:text-destructive"
|
||||||
|
title="Löschen"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={e => void remove(n.id, e)}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5" aria-hidden />
|
||||||
|
<span className="sr-only">Löschen</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3 border-t pt-3">
|
{/* Formular */}
|
||||||
<p className="text-xs font-medium text-muted-foreground">{editingId == null ? "Neuer Knoten" : `Knoten #${editingId} bearbeiten`}</p>
|
<div className="flex w-full min-w-0 flex-1 flex-col overflow-y-auto bg-background lg:max-w-md">
|
||||||
<div className="space-y-1.5">
|
<div className="border-b px-4 py-2.5 sm:px-5">
|
||||||
<Label htmlFor="tt-name">Name (Referenz im Snippet / Intellisense)</Label>
|
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
<Input id="tt-name" value={name} onChange={e => setName(e.target.value)} className="font-mono text-sm" autoComplete="off" />
|
{editingId == null ? "Knoten anlegen" : "Knoten bearbeiten"}
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="tt-prefix">Anzeige-Prefix (optional, z. B. Projekt:)</Label>
|
|
||||||
<Input
|
|
||||||
id="tt-prefix"
|
|
||||||
value={labelPrefix}
|
|
||||||
onChange={e => setLabelPrefix(e.target.value)}
|
|
||||||
placeholder="Projekt:"
|
|
||||||
className="font-mono text-sm"
|
|
||||||
autoComplete="off"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Vorschau:{" "}
|
|
||||||
<span className="font-mono text-foreground">
|
|
||||||
{name.trim() ? formatTreeTagDisplay(name.trim(), labelPrefix) : "—"}
|
|
||||||
</span>
|
|
||||||
</p>
|
</p>
|
||||||
|
{editingId != null ? (
|
||||||
|
<p className="mt-0.5 font-mono text-[11px] text-muted-foreground">ID {editingId}</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label>Parent</Label>
|
<div className="flex flex-1 flex-col gap-5 px-4 py-4 sm:px-5">
|
||||||
<Select value={parentIdStr} onValueChange={setParentIdStr}>
|
{error ? (
|
||||||
<SelectTrigger className="h-9">
|
<p className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||||
<SelectValue placeholder="Ohne Parent (Wurzel)" />
|
{error}
|
||||||
</SelectTrigger>
|
</p>
|
||||||
<SelectContent>
|
) : null}
|
||||||
<SelectItem value="__root__">(Wurzel)</SelectItem>
|
|
||||||
{parentOptions.map(n => (
|
<div className="space-y-4">
|
||||||
<SelectItem key={n.id} value={String(n.id)}>
|
<div className="space-y-2">
|
||||||
{formatTreeTagDisplay(n.name, n.label_prefix)}
|
<Label htmlFor="tt-name" className="text-xs text-muted-foreground">
|
||||||
</SelectItem>
|
Kurzname (Snippet, Suche, Intellisense)
|
||||||
))}
|
</Label>
|
||||||
</SelectContent>
|
<Input
|
||||||
</Select>
|
id="tt-name"
|
||||||
</div>
|
value={name}
|
||||||
<div className="space-y-1.5">
|
onChange={e => setName(e.target.value)}
|
||||||
<Label htmlFor="tt-color">Farbe (#rrggbb, optional)</Label>
|
className="font-mono text-sm"
|
||||||
<div className="flex gap-2">
|
autoComplete="off"
|
||||||
<Input
|
placeholder="sp-migrator"
|
||||||
id="tt-color"
|
/>
|
||||||
value={color}
|
</div>
|
||||||
onChange={e => setColor(e.target.value)}
|
|
||||||
placeholder="#3b82f6"
|
<div className="space-y-2">
|
||||||
className="font-mono text-sm"
|
<Label htmlFor="tt-prefix" className="text-xs text-muted-foreground">
|
||||||
autoComplete="off"
|
Anzeige-Prefix (optional)
|
||||||
/>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
type="color"
|
id="tt-prefix"
|
||||||
className="h-9 w-12 shrink-0 cursor-pointer p-1"
|
value={labelPrefix}
|
||||||
value={/^#[0-9A-Fa-f]{6}$/.test(color) ? color : "#64748b"}
|
onChange={e => setLabelPrefix(e.target.value)}
|
||||||
onChange={e => setColor(e.target.value)}
|
placeholder="Projekt:"
|
||||||
aria-label="Farbe wählen"
|
className="font-mono text-sm"
|
||||||
/>
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<div className="rounded-md border bg-muted/30 px-3 py-2 text-xs">
|
||||||
|
<span className="text-muted-foreground">Vorschau </span>
|
||||||
|
<span className="font-medium text-foreground">{previewLabel ?? "—"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs text-muted-foreground">Übergeordnet</Label>
|
||||||
|
<Select value={parentIdStr} onValueChange={setParentIdStr}>
|
||||||
|
<SelectTrigger className="h-9 w-full font-mono text-xs">
|
||||||
|
<SelectValue placeholder="Wurzel" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__root__">(Wurzel — kein Parent)</SelectItem>
|
||||||
|
{parentOptions.map(n => (
|
||||||
|
<SelectItem key={n.id} value={String(n.id)}>
|
||||||
|
{formatTreeTagDisplay(n.name, n.label_prefix)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="tt-color" className="text-xs text-muted-foreground">
|
||||||
|
Farbe (optional, #rrggbb)
|
||||||
|
</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="tt-color"
|
||||||
|
value={color}
|
||||||
|
onChange={e => setColor(e.target.value)}
|
||||||
|
placeholder="#3b82f6"
|
||||||
|
className="min-w-0 flex-1 font-mono text-sm"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="color"
|
||||||
|
className="h-9 w-11 shrink-0 cursor-pointer rounded-md border p-1"
|
||||||
|
value={/^#[0-9A-Fa-f]{6}$/.test(color) ? color : "#64748b"}
|
||||||
|
onChange={e => setColor(e.target.value)}
|
||||||
|
aria-label="Farbe wählen"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</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>
|
||||||
|
|||||||
Reference in New Issue
Block a user