This commit is contained in:
Stefan ATZLESBERGER
2026-04-16 15:44:10 +02:00
parent 3bdc8cc646
commit 14ed445328
30 changed files with 1562 additions and 1167 deletions
+148
View File
@@ -0,0 +1,148 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { formatTreeTagDisplay } from "@/lib/treeTagDisplay";
import type { Snippet } from "@/types/snippet";
import type { TagTreeNode } from "@/types/tagTree";
import { SnippetCard } from "@/features/snippets/SnippetCard";
import { TagTreeDialog } from "@/features/tag-tree/TagTreeDialog";
import { NotesToolbar } from "@/features/notes/NotesToolbar";
import type { SortValue } from "@/features/notes/sortOptions";
export function NotesApp() {
const [query, setQuery] = useState("");
const [debouncedQuery, setDebouncedQuery] = useState("");
const [sort, setSort] = useState<SortValue>("updated_desc");
const [snippets, setSnippets] = useState<Snippet[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [tagTreeOpen, setTagTreeOpen] = useState(false);
const [tagTreeNodes, setTagTreeNodes] = useState<TagTreeNode[]>([]);
const [snippetListVersion, setSnippetListVersion] = useState(0);
useEffect(() => {
const t = window.setTimeout(() => setDebouncedQuery(query), 300);
return () => window.clearTimeout(t);
}, [query]);
useEffect(() => {
let cancelled = false;
void (async () => {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams();
if (debouncedQuery.trim()) params.set("q", debouncedQuery.trim());
params.set("sort", sort);
const res = await fetch(`/api/snippets?${params.toString()}`);
if (!res.ok) throw new Error(await res.text());
const data = (await res.json()) as Snippet[];
if (!cancelled) setSnippets(data);
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : "Laden fehlgeschlagen");
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [debouncedQuery, sort, snippetListVersion]);
const fetchTagTree = useCallback(async () => {
try {
const res = await fetch("/api/tag-tree");
if (!res.ok) return;
setTagTreeNodes((await res.json()) as TagTreeNode[]);
} catch {
/* ignore */
}
}, []);
useEffect(() => {
void fetchTagTree();
}, [fetchTagTree]);
const treeColorByName = useMemo(() => {
const m: Record<string, string | null> = {};
for (const n of tagTreeNodes) {
m[n.name] = n.color;
}
return m;
}, [tagTreeNodes]);
const treeDisplayByName = useMemo(() => {
const m: Record<string, string> = {};
for (const n of tagTreeNodes) {
m[n.name] = formatTreeTagDisplay(n.name, n.label_prefix ?? null);
}
return m;
}, [tagTreeNodes]);
const replaceSnippet = useCallback((s: Snippet) => {
setSnippets(prev => prev.map(x => (x.id === s.id ? s : x)));
}, []);
const removeSnippet = useCallback((id: number) => {
setSnippets(prev => prev.filter(x => x.id !== id));
}, []);
const createNew = async () => {
setError(null);
try {
const res = await fetch("/api/snippets", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: null, body: "", tags: [] }),
});
if (!res.ok) {
const err = (await res.json().catch(() => null)) as { error?: string } | null;
throw new Error(err?.error ?? res.statusText);
}
const created = (await res.json()) as Snippet;
setSnippets(prev => [created, ...prev]);
} catch (e) {
setError(e instanceof Error ? e.message : "Neues Snippet fehlgeschlagen");
}
};
return (
<div className="mx-auto flex w-full max-w-6xl flex-col gap-4 px-4 py-4 sm:px-5">
<NotesToolbar
query={query}
onQueryChange={setQuery}
sort={sort}
onSortChange={setSort}
tagTreeCount={tagTreeNodes.length}
onOpenTagTree={() => setTagTreeOpen(true)}
onCreateNew={() => void createNew()}
/>
<TagTreeDialog
open={tagTreeOpen}
onOpenChange={setTagTreeOpen}
onChanged={() => {
void fetchTagTree();
setSnippetListVersion(v => v + 1);
}}
/>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
{loading ? <p className="text-sm text-muted-foreground">Lade Snippets …</p> : null}
<div className="flex flex-col gap-2 sm:gap-2.5">
{!loading && snippets.length === 0 ? (
<p className="text-center text-sm text-muted-foreground">Keine Snippets. Mit „Neu“ starten.</p>
) : null}
{snippets.map(s => (
<SnippetCard
key={s.id}
snippet={s}
treeColorByName={treeColorByName}
treeDisplayByName={treeDisplayByName}
onReplace={replaceSnippet}
onRemove={removeSnippet}
/>
))}
</div>
</div>
);
}
+81
View File
@@ -0,0 +1,81 @@
import { Tags } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { SORT_OPTIONS, type SortValue } from "@/features/notes/sortOptions";
type Props = {
query: string;
onQueryChange: (v: string) => void;
sort: SortValue;
onSortChange: (v: SortValue) => void;
tagTreeCount: number;
onOpenTagTree: () => void;
onCreateNew: () => void;
};
export function NotesToolbar({
query,
onQueryChange,
sort,
onSortChange,
tagTreeCount,
onOpenTagTree,
onCreateNew,
}: Props) {
return (
<header className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-end sm:gap-3">
<div className="min-w-0 flex-1 space-y-1.5">
<Label htmlFor="search" className="text-xs text-muted-foreground">
Suche
</Label>
<Input
id="search"
value={query}
onChange={e => onQueryChange(e.target.value)}
placeholder="Freitext: Inhalt & Tags · tag:x · title:y · content:z"
autoComplete="off"
className="h-9"
/>
</div>
<div className="w-full min-w-[min(100%,220px)] space-y-1.5 sm:w-72">
<Label htmlFor="sort" className="text-xs text-muted-foreground">
Sortierung
</Label>
<Select value={sort} onValueChange={v => onSortChange(v as SortValue)}>
<SelectTrigger id="sort" className="h-9 w-full">
<SelectValue placeholder="Sortierung" />
</SelectTrigger>
<SelectContent>
{SORT_OPTIONS.map(o => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-wrap gap-2 sm:shrink-0">
<Button
type="button"
variant="outline"
className="h-9 w-full gap-2 sm:w-auto"
onClick={onOpenTagTree}
title="Tag-Hierarchie und Anzeige-Prefixe verwalten"
>
<Tags className="size-4 shrink-0 opacity-70" aria-hidden />
<span>Tag-Baum</span>
{tagTreeCount > 0 ? (
<span className="rounded-full bg-muted px-1.5 py-0 text-[10px] font-medium tabular-nums text-muted-foreground">
{tagTreeCount}
</span>
) : null}
</Button>
<Button type="button" className="h-9 w-full sm:w-auto" onClick={onCreateNew}>
Neu
</Button>
</div>
</header>
);
}
+8
View File
@@ -0,0 +1,8 @@
export const SORT_OPTIONS = [
{ value: "updated_desc", label: "Zuletzt geändert (neu zuerst)" },
{ value: "updated_asc", label: "Zuletzt geändert (alt zuerst)" },
{ value: "title_asc", label: "Titel A–Z" },
{ value: "title_desc", label: "Titel Z–A" },
] as const;
export type SortValue = (typeof SORT_OPTIONS)[number]["value"];
+202
View File
@@ -0,0 +1,202 @@
import { Pencil, Trash2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import type { Snippet } from "@/types/snippet";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardFooter } from "@/components/ui/card";
import { SnippetEditDialog } from "@/features/snippets/components/SnippetEditDialog";
import { TagPill } from "@/features/snippets/components/TagPill";
import { colorForTagName, tagsFromString } from "@/features/snippets/lib/tagFieldUtils";
import { formatWhen } from "@/features/snippets/lib/formatWhen";
import { parseSnippetMarkdown } from "@/features/snippets/lib/parseSnippetMarkdown";
type Props = {
snippet: Snippet;
treeColorByName: Record<string, string | null>;
treeDisplayByName: Record<string, string>;
onReplace: (s: Snippet) => void;
onRemove: (id: number) => void;
};
export function SnippetCard({ snippet, treeColorByName, treeDisplayByName, onReplace, onRemove }: Props) {
const pristineEmpty = !snippet.body.trim() && (snippet.tags?.length ?? 0) === 0;
const [dialogOpen, setDialogOpen] = useState(pristineEmpty);
const [dirty, setDirty] = useState(false);
const [body, setBody] = useState(() => snippet.body ?? "");
const [tagsStr, setTagsStr] = useState(() => (snippet.tags ?? []).join(", "));
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const viewHtml = useMemo(() => parseSnippetMarkdown(snippet.body ?? ""), [snippet.body]);
useEffect(() => {
if (dialogOpen) return;
setBody(snippet.body ?? "");
setTagsStr((snippet.tags ?? []).join(", "));
}, [dialogOpen, snippet.id, snippet.updated_at, snippet.body, snippet.tags]);
const hydrateFromSnippet = () => {
setBody(snippet.body ?? "");
setTagsStr((snippet.tags ?? []).join(", "));
setDirty(false);
setError(null);
};
const openDialog = () => {
hydrateFromSnippet();
setDialogOpen(true);
};
const requestDismiss = () => {
setDialogOpen(false);
if (pristineEmpty && !dirty) void deleteRemote(false);
};
const handleOpenChange = (open: boolean) => {
if (open) openDialog();
else requestDismiss();
};
const save = async () => {
setBusy(true);
setError(null);
try {
const res = await fetch(`/api/snippets/${snippet.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: null,
body,
tags: tagsFromString(tagsStr),
}),
});
if (!res.ok) {
const err = (await res.json().catch(() => null)) as { error?: string } | null;
throw new Error(err?.error ?? res.statusText);
}
const updated = (await res.json()) as Snippet;
onReplace(updated);
setDirty(false);
setDialogOpen(false);
} catch (e) {
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen");
} finally {
setBusy(false);
}
};
async function deleteRemote(askConfirm: boolean) {
if (askConfirm && !window.confirm("Snippet wirklich löschen?")) return;
setBusy(true);
setError(null);
try {
const res = await fetch(`/api/snippets/${snippet.id}`, { method: "DELETE" });
if (!res.ok && res.status !== 204) {
const err = (await res.json().catch(() => null)) as { error?: string } | null;
throw new Error(err?.error ?? res.statusText);
}
onRemove(snippet.id);
} catch (e) {
setError(e instanceof Error ? e.message : "Löschen fehlgeschlagen");
} finally {
setBusy(false);
}
}
const markDirty = () => setDirty(true);
return (
<>
<Card className="gap-0 border-border/80 py-0 text-left shadow-md ring-1 ring-black/5 dark:ring-white/10">
<CardContent className="px-3 pb-1 pt-2">
<div
role="button"
tabIndex={0}
title="Zum Bearbeiten klicken"
className="-mx-0.5 rounded-sm px-0.5 py-0.5 outline-none ring-offset-background focus-visible:ring-2 focus-visible:ring-ring"
onKeyDown={e => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
openDialog();
}
}}
>
<div className="snippet-md-card-wrap">
<div className="snippet-md overflow-auto" dangerouslySetInnerHTML={{ __html: viewHtml }} />
</div>
</div>
</CardContent>
<CardFooter className="flex flex-row items-center gap-2 border-t border-border/80 px-3 py-1">
<div className="flex min-h-7 min-w-0 flex-1 flex-wrap items-center gap-1">
{snippet.tags.length ? (
snippet.tags.map((t, i) => (
<TagPill
key={t}
tagKey={t}
label={snippet.tag_display?.[i] ?? treeDisplayByName[t] ?? t}
hex={colorForTagName(t, treeColorByName, snippet.tag_colors ?? {})}
/>
))
) : (
<span className="text-[10px] text-muted-foreground">Keine Tags</span>
)}
</div>
<div className="flex h-7 shrink-0 items-center gap-1.5">
<span className="whitespace-nowrap text-[10px] leading-none tabular-nums text-muted-foreground/90">
{formatWhen(snippet.updated_at)}
</span>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="size-7 text-muted-foreground hover:text-foreground"
disabled={busy}
title="Bearbeiten"
onClick={openDialog}
>
<Pencil className="size-3.5" aria-hidden />
<span className="sr-only">Bearbeiten</span>
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="size-7 text-muted-foreground hover:text-destructive"
disabled={busy}
title="Löschen"
onClick={() => void deleteRemote(true)}
>
<Trash2 className="size-3.5" aria-hidden />
<span className="sr-only">Löschen</span>
</Button>
</div>
{error && !dialogOpen ? (
<p className="basis-full truncate text-center text-[10px] text-destructive sm:text-start">{error}</p>
) : null}
</CardFooter>
</Card>
<SnippetEditDialog
snippet={snippet}
open={dialogOpen}
onOpenChange={handleOpenChange}
treeColorByName={treeColorByName}
treeDisplayByName={treeDisplayByName}
body={body}
tagsStr={tagsStr}
onBodyChange={v => {
markDirty();
setBody(v);
}}
onTagsStrChange={v => {
markDirty();
setTagsStr(v);
}}
busy={busy}
error={error}
onSave={() => void save()}
onDismiss={requestDismiss}
/>
</>
);
}
@@ -0,0 +1,155 @@
import { useEffect, useMemo, useRef, useState } from "react";
import type { Snippet } from "@/types/snippet";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { useTagSuggestions } from "@/features/snippets/hooks/useTagSuggestions";
import { TagPill } from "@/features/snippets/components/TagPill";
import { TagSuggestDropdown } from "@/features/snippets/components/TagSuggestDropdown";
import { colorForTagName, splitTagsField, tagsFromString } from "@/features/snippets/lib/tagFieldUtils";
import { parseSnippetMarkdown } from "@/features/snippets/lib/parseSnippetMarkdown";
type Props = {
snippet: Snippet;
open: boolean;
onOpenChange: (open: boolean) => void;
treeColorByName: Record<string, string | null>;
treeDisplayByName: Record<string, string>;
body: string;
tagsStr: string;
onBodyChange: (v: string) => void;
onTagsStrChange: (v: string) => void;
busy: boolean;
error: string | null;
onSave: () => void;
onDismiss: () => void;
};
export function SnippetEditDialog({
snippet,
open,
onOpenChange,
treeColorByName,
treeDisplayByName,
body,
tagsStr,
onBodyChange,
onTagsStrChange,
busy,
error,
onSave,
onDismiss,
}: Props) {
const bodyRef = useRef<HTMLTextAreaElement>(null);
const [showPreview, setShowPreview] = useState(false);
const [tagFieldFocused, setTagFieldFocused] = useState(false);
const { items: tagSuggest, open: tagSuggestOpen, setOpen: setTagSuggestOpen, activeIndex, setActiveIndex } =
useTagSuggestions(open, tagFieldFocused, tagsStr);
const previewHtml = useMemo(() => parseSnippetMarkdown(body), [body]);
useEffect(() => {
if (!open) setShowPreview(false);
}, [open]);
const applyTagSuggestion = (label: string) => {
const { committed } = splitTagsField(tagsStr);
onTagsStrChange(`${committed}${label}, `);
setTagSuggestOpen(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
variant="fullscreen"
onOpenAutoFocus={e => {
e.preventDefault();
requestAnimationFrame(() => bodyRef.current?.focus());
}}
>
<DialogHeader className="dialog-snippet-header">
<DialogTitle className="text-base">Snippet bearbeiten</DialogTitle>
<DialogDescription className="text-xs">Tags und Markdown — Vorschau optional.</DialogDescription>
</DialogHeader>
<div className="dialog-snippet-body">
<div className="relative shrink-0">
<Input
value={tagsStr}
onChange={e => onTagsStrChange(e.target.value)}
onFocus={() => setTagFieldFocused(true)}
onBlur={() => {
window.setTimeout(() => setTagFieldFocused(false), 140);
window.setTimeout(() => setTagSuggestOpen(false), 160);
}}
onKeyDown={e => {
if (!tagSuggestOpen || tagSuggest.length === 0) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setActiveIndex(i => Math.min(tagSuggest.length - 1, i + 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActiveIndex(i => Math.max(0, i - 1));
} else if (e.key === "Enter") {
e.preventDefault();
const pick = tagSuggest[activeIndex];
if (pick) applyTagSuggestion(pick.name);
} else if (e.key === "Escape") {
e.preventDefault();
setTagSuggestOpen(false);
}
}}
placeholder="Tags, kommagetrennt — Vorschläge mit Pfeiltasten / Enter"
autoComplete="off"
className="h-9"
/>
<TagSuggestDropdown
open={tagSuggestOpen}
items={tagSuggest}
activeIndex={activeIndex}
onPick={name => applyTagSuggestion(name)}
/>
</div>
<div className="flex min-h-[1.5rem] flex-wrap gap-1.5">
{tagsFromString(tagsStr).map((t, i) => (
<TagPill
key={`${i}-${t}`}
tagKey={t}
label={treeDisplayByName[t] ?? t}
hex={colorForTagName(t, treeColorByName, snippet.tag_colors ?? {})}
/>
))}
</div>
<div className={showPreview ? "dialog-snippet-editor-with-preview" : "dialog-snippet-editor"}>
<Textarea
ref={bodyRef}
value={body}
onChange={e => onBodyChange(e.target.value)}
className="dialog-snippet-textarea"
placeholder="Markdown …"
/>
{showPreview ? (
<div className="dialog-snippet-preview snippet-md" dangerouslySetInnerHTML={{ __html: previewHtml }} />
) : null}
</div>
{error ? <p className="shrink-0 text-sm text-destructive">{error}</p> : null}
</div>
<DialogFooter className="dialog-snippet-footer">
<Button type="button" variant="outline" size="sm" className="h-8 text-xs" onClick={() => setShowPreview(v => !v)}>
{showPreview ? "Vorschau aus" : "Vorschau"}
</Button>
<div className="flex flex-wrap items-center justify-end gap-2">
<Button type="button" variant="secondary" size="sm" className="h-8" disabled={busy} onClick={onDismiss}>
Abbrechen
</Button>
<Button type="button" size="sm" className="h-8" disabled={busy} onClick={onSave}>
Speichern
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,29 @@
type Props = {
tagKey: string;
label: string;
hex: string | null;
};
export function TagPill({ tagKey, label, hex }: Props) {
const has = Boolean(hex && /^#[0-9A-Fa-f]{6}$/i.test(hex));
return (
<span
className={
has
? "inline-flex max-w-full items-center rounded-full border-2 px-1.5 py-px text-[11px] font-medium leading-tight"
: "inline-flex max-w-full items-center rounded-full border-2 border-border bg-muted/40 px-1.5 py-px text-[11px] leading-tight text-muted-foreground"
}
style={
has
? {
borderColor: hex!,
backgroundColor: `${hex}33`,
}
: undefined
}
title={tagKey === label ? tagKey : `${label} (${tagKey})`}
>
<span className="truncate">{label}</span>
</span>
);
}
@@ -0,0 +1,32 @@
import { cn } from "@/lib/utils";
import type { TagSuggestionItem } from "@/features/snippets/hooks/useTagSuggestions";
type Props = {
open: boolean;
items: TagSuggestionItem[];
activeIndex: number;
onPick: (name: string) => void;
};
export function TagSuggestDropdown({ open, items, activeIndex, onPick }: Props) {
if (!open) return null;
return (
<ul role="listbox" className="tag-suggest-dropdown">
{items.map((item, i) => (
<li key={`${item.name}-${i}`} role="option" aria-selected={i === activeIndex}>
<button
type="button"
className={cn("tag-suggest-option", i === activeIndex && "tag-suggest-option-active")}
onMouseDown={e => e.preventDefault()}
onClick={() => onPick(item.name)}
>
<span className="text-xs">{item.display}</span>
{item.display !== item.name ? (
<span className="font-mono text-[10px] text-muted-foreground">{item.name}</span>
) : null}
</button>
</li>
))}
</ul>
);
}
@@ -0,0 +1,48 @@
import { useEffect, useRef, useState } from "react";
import { splitTagsField } from "@/features/snippets/lib/tagFieldUtils";
export type TagSuggestionItem = { name: string; display: string };
export function useTagSuggestions(dialogOpen: boolean, tagFieldFocused: boolean, tagsStr: string) {
const [items, setItems] = useState<TagSuggestionItem[]>([]);
const [open, setOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(0);
const reqId = useRef(0);
useEffect(() => {
if (!dialogOpen || !tagFieldFocused) {
setOpen(false);
return;
}
const { token } = splitTagsField(tagsStr);
const id = ++reqId.current;
const t = window.setTimeout(() => {
void (async () => {
try {
const res = await fetch(`/api/tag-suggestions?q=${encodeURIComponent(token)}`);
if (reqId.current !== id) return;
if (!res.ok) return;
const data = (await res.json()) as { items?: TagSuggestionItem[] };
const next = Array.isArray(data.items) ? data.items : [];
setItems(next);
setOpen(next.length > 0);
setActiveIndex(0);
} catch {
if (reqId.current !== id) return;
setItems([]);
setOpen(false);
}
})();
}, 160);
return () => window.clearTimeout(t);
}, [dialogOpen, tagFieldFocused, tagsStr]);
useEffect(() => {
if (!dialogOpen) {
setItems([]);
setOpen(false);
}
}, [dialogOpen]);
return { items, open, setOpen, activeIndex, setActiveIndex };
}
+7
View File
@@ -0,0 +1,7 @@
export function formatWhen(iso: string): string {
try {
return new Date(iso).toLocaleString(undefined, { dateStyle: "short", timeStyle: "short" });
} catch {
return iso;
}
}
@@ -0,0 +1,7 @@
import { marked } from "marked";
marked.setOptions({ gfm: true });
export function parseSnippetMarkdown(md: string): string {
return marked.parse(md) as string;
}
@@ -0,0 +1,23 @@
export function tagsFromString(s: string): string[] {
return s
.split(",")
.map(t => t.trim())
.filter(Boolean);
}
/** Letztes Segment nach dem letzten Komma (laufender Tag-Token). */
export function splitTagsField(s: string): { committed: string; token: string } {
const lastComma = s.lastIndexOf(",");
if (lastComma === -1) {
return { committed: "", token: s.trimStart() };
}
return { committed: s.slice(0, lastComma + 1), token: s.slice(lastComma + 1).trimStart() };
}
export function colorForTagName(
name: string,
treeColorByName: Record<string, string | null>,
snippetColors: Record<string, string | null>,
): string | null {
return treeColorByName[name] ?? snippetColors[name] ?? null;
}
+202
View File
@@ -0,0 +1,202 @@
import { useCallback, useEffect, useMemo, useState, type MouseEvent } from "react";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import type { TagTreeNode } from "@/types/tagTree";
import { orderedWithDepth } from "@/features/tag-tree/orderTagTree";
import { TagTreeDialogFooter } from "@/features/tag-tree/TagTreeDialogFooter";
import { TagTreeDialogHeader } from "@/features/tag-tree/TagTreeDialogHeader";
import { TagTreeHierarchyPanel } from "@/features/tag-tree/TagTreeHierarchyPanel";
import { TagTreeNodeForm } from "@/features/tag-tree/TagTreeNodeForm";
import { useTagTreeParentOptions } from "@/features/tag-tree/useTagTreeParentOptions";
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
onChanged?: () => void;
};
export function TagTreeDialog({ open, onOpenChange, onChanged }: Props) {
const [nodes, setNodes] = useState<TagTreeNode[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [formOpen, setFormOpen] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [name, setName] = useState("");
const [parentIdStr, setParentIdStr] = useState<string>("__root__");
const [color, setColor] = useState("");
const [labelPrefix, setLabelPrefix] = useState("");
const [busy, setBusy] = useState(false);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch("/api/tag-tree");
if (!res.ok) throw new Error(await res.text());
setNodes((await res.json()) as TagTreeNode[]);
} catch (e) {
setError(e instanceof Error ? e.message : "Laden fehlgeschlagen");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (!open) return;
void load();
}, [open, load]);
useEffect(() => {
if (!open) {
setFormOpen(false);
setEditingId(null);
setName("");
setParentIdStr("__root__");
setColor("");
setLabelPrefix("");
setError(null);
}
}, [open]);
const flatOrdered = useMemo(() => orderedWithDepth(nodes), [nodes]);
const parentOptions = useTagTreeParentOptions(nodes, editingId);
const resetFormFields = () => {
setEditingId(null);
setName("");
setParentIdStr("__root__");
setColor("");
setLabelPrefix("");
};
const exitFormMode = () => {
setFormOpen(false);
resetFormFields();
setError(null);
};
const startEdit = (n: TagTreeNode) => {
setFormOpen(true);
setEditingId(n.id);
setName(n.name);
setParentIdStr(n.parent_id == null ? "__root__" : String(n.parent_id));
setColor(n.color ?? "");
setLabelPrefix(n.label_prefix ?? "");
setError(null);
};
const startNewUnder = (parentId: number | null) => {
setFormOpen(true);
resetFormFields();
setParentIdStr(parentId == null ? "__root__" : String(parentId));
setError(null);
};
const save = async () => {
setBusy(true);
setError(null);
try {
const parent_id = parentIdStr === "__root__" ? null : Number(parentIdStr);
const colorNorm = color.trim() === "" ? null : color.trim();
const labelPrefixNorm = labelPrefix.trim() === "" ? null : labelPrefix.trim();
if (editingId == null) {
const res = await fetch("/api/tag-tree", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, parent_id, color: colorNorm, label_prefix: labelPrefixNorm }),
});
if (!res.ok) {
const err = (await res.json().catch(() => null)) as { error?: string } | null;
throw new Error(err?.error ?? res.statusText);
}
} else {
const res = await fetch(`/api/tag-tree/${editingId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, parent_id, color: colorNorm, label_prefix: labelPrefixNorm }),
});
if (!res.ok) {
const err = (await res.json().catch(() => null)) as { error?: string } | null;
throw new Error(err?.error ?? res.statusText);
}
}
await load();
exitFormMode();
onChanged?.();
} catch (e) {
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen");
} finally {
setBusy(false);
}
};
const remove = async (id: number, e?: MouseEvent) => {
e?.stopPropagation();
if (!window.confirm("Diesen Knoten und alle Unterknoten wirklich löschen?")) return;
setBusy(true);
setError(null);
try {
const res = await fetch(`/api/tag-tree/${id}`, { method: "DELETE" });
if (!res.ok && res.status !== 204) {
const err = (await res.json().catch(() => null)) as { error?: string } | null;
throw new Error(err?.error ?? res.statusText);
}
if (editingId === id) exitFormMode();
await load();
onChanged?.();
} catch (e) {
setError(e instanceof Error ? e.message : "Löschen fehlgeschlagen");
} finally {
setBusy(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent variant="fullscreen">
<TagTreeDialogHeader />
<div className={cn("tag-tree-main", !formOpen && "lg:flex-1")}>
<TagTreeHierarchyPanel
formOpen={formOpen}
loading={loading}
busy={busy}
error={error}
nodes={nodes}
flatOrdered={flatOrdered}
editingId={editingId}
onNewRoot={() => startNewUnder(null)}
onEdit={startEdit}
onAddChild={parentId => startNewUnder(parentId)}
onDelete={(id, ev) => void remove(id, ev)}
/>
{formOpen ? (
<TagTreeNodeForm
editingId={editingId}
name={name}
onNameChange={setName}
labelPrefix={labelPrefix}
onLabelPrefixChange={setLabelPrefix}
parentIdStr={parentIdStr}
onParentIdStrChange={setParentIdStr}
color={color}
onColorChange={setColor}
parentOptions={parentOptions}
error={error}
/>
) : null}
</div>
<TagTreeDialogFooter
formOpen={formOpen}
busy={busy}
canSave={Boolean(name.trim())}
onExitForm={exitFormMode}
onSave={() => void save()}
onCloseDialog={() => onOpenChange(false)}
/>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,32 @@
import { Button } from "@/components/ui/button";
import { DialogFooter } from "@/components/ui/dialog";
type Props = {
formOpen: boolean;
busy: boolean;
canSave: boolean;
onExitForm: () => void;
onSave: () => void;
onCloseDialog: () => void;
};
export function TagTreeDialogFooter({ formOpen, busy, canSave, onExitForm, onSave, onCloseDialog }: Props) {
return (
<DialogFooter className="tag-tree-dialog-footer">
{formOpen ? (
<>
<Button type="button" variant="outline" className="h-9" disabled={busy} onClick={onExitForm}>
Schließen
</Button>
<Button type="button" className="h-9" disabled={busy || !canSave} onClick={onSave}>
Speichern
</Button>
</>
) : (
<Button type="button" variant="outline" className="h-9" disabled={busy} onClick={onCloseDialog}>
Schließen
</Button>
)}
</DialogFooter>
);
}
@@ -0,0 +1,21 @@
import { FolderTree } from "lucide-react";
import { DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
export function TagTreeDialogHeader() {
return (
<DialogHeader className="tag-tree-dialog-header">
<div className="tag-tree-dialog-hero">
<div className="tag-tree-dialog-icon">
<FolderTree className="size-4 text-muted-foreground" aria-hidden />
</div>
<div className="tag-tree-dialog-hero-text">
<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> in Snippets; optionaler Prefix nur für die Anzeige.
</DialogDescription>
</div>
</div>
</DialogHeader>
);
}
@@ -0,0 +1,93 @@
import { Loader2, Plus } from "lucide-react";
import type { MouseEvent } from "react";
import { cn } from "@/lib/utils";
import type { TagTreeNode } from "@/types/tagTree";
import { Button } from "@/components/ui/button";
import { TagTreeNodeRow } from "@/features/tag-tree/TagTreeNodeRow";
type FlatRow = { node: TagTreeNode; depth: number };
type Props = {
formOpen: boolean;
loading: boolean;
busy: boolean;
error: string | null;
nodes: TagTreeNode[];
flatOrdered: FlatRow[];
editingId: number | null;
onNewRoot: () => void;
onEdit: (n: TagTreeNode) => void;
onAddChild: (parentId: number) => void;
onDelete: (id: number, e?: MouseEvent) => void;
};
export function TagTreeHierarchyPanel({
formOpen,
loading,
busy,
error,
nodes,
flatOrdered,
editingId,
onNewRoot,
onEdit,
onAddChild,
onDelete,
}: Props) {
return (
<div className={cn("tag-tree-hierarchy", formOpen ? "tag-tree-hierarchy--with-form" : "tag-tree-hierarchy--solo")}>
{!formOpen && error ? <div className="tag-tree-inline-error">{error}</div> : null}
<div className="tag-tree-toolbar">
<div className="flex items-center gap-2">
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Hierarchie</span>
{!loading ? <span className="tag-tree-count-chip">{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={onNewRoot}
>
<Plus className="size-3.5" aria-hidden />
Neuer Knoten
</Button>
</div>
<div className="tag-tree-scroll">
{loading ? (
<div className="tag-tree-loading">
<Loader2 className="size-7 animate-spin opacity-70" aria-hidden />
<span className="text-sm">Lade Tag-Baum …</span>
</div>
) : flatOrdered.length === 0 ? (
<div className="tag-tree-empty">
<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={onNewRoot}>
<Plus className="size-3.5" aria-hidden />
Ersten Knoten anlegen
</Button>
</div>
) : (
<ul className="tag-tree-node-list" role="list">
{flatOrdered.map(({ node: n, depth }) => (
<TagTreeNodeRow
key={n.id}
node={n}
depth={depth}
isActive={formOpen && editingId === n.id}
busy={busy}
onEdit={onEdit}
onAddChild={onAddChild}
onDelete={onDelete}
/>
))}
</ul>
)}
</div>
</div>
);
}
+128
View File
@@ -0,0 +1,128 @@
import { formatTreeTagDisplay } from "@/lib/treeTagDisplay";
import type { TagTreeNode } from "@/types/tagTree";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
type Props = {
editingId: number | null;
name: string;
onNameChange: (v: string) => void;
labelPrefix: string;
onLabelPrefixChange: (v: string) => void;
parentIdStr: string;
onParentIdStrChange: (v: string) => void;
color: string;
onColorChange: (v: string) => void;
parentOptions: TagTreeNode[];
error: string | null;
};
export function TagTreeNodeForm({
editingId,
name,
onNameChange,
labelPrefix,
onLabelPrefixChange,
parentIdStr,
onParentIdStrChange,
color,
onColorChange,
parentOptions,
error,
}: Props) {
const previewLabel = name.trim() ? formatTreeTagDisplay(name.trim(), labelPrefix) : null;
return (
<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 => onNameChange(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
id="tt-prefix"
value={labelPrefix}
onChange={e => onLabelPrefixChange(e.target.value)}
placeholder="Projekt:"
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={onParentIdStrChange}>
<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 => onColorChange(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 => onColorChange(e.target.value)}
aria-label="Farbe wählen"
/>
</div>
</div>
</div>
</div>
</div>
);
}
+85
View File
@@ -0,0 +1,85 @@
import { Pencil, Plus, Trash2 } from "lucide-react";
import type { MouseEvent } from "react";
import { formatTreeTagDisplay } from "@/lib/treeTagDisplay";
import { cn } from "@/lib/utils";
import type { TagTreeNode } from "@/types/tagTree";
import { Button } from "@/components/ui/button";
type Props = {
node: TagTreeNode;
depth: number;
isActive: boolean;
busy: boolean;
onEdit: (n: TagTreeNode) => void;
onAddChild: (parentId: number) => void;
onDelete: (id: number, e?: MouseEvent) => void;
};
export function TagTreeNodeRow({ node: n, depth, isActive, busy, onEdit, onAddChild, onDelete }: Props) {
return (
<li role="none">
<div
className={cn(
"group flex items-center gap-0.5 rounded-md border border-transparent py-0.5 transition-colors",
isActive ? "border-border bg-background shadow-sm" : "hover:border-border/50 hover:bg-muted/40",
)}
>
<div className="flex min-w-0 flex-1 items-center gap-2 py-1 pr-1" style={{ paddingLeft: `${8 + depth * 12}px` }}>
<span
className="size-1.5 shrink-0 rounded-full ring-1 ring-border"
style={{
backgroundColor: n.color ?? "hsl(var(--muted-foreground) / 0.3)",
}}
title={n.color ? `Farbe ${n.color}` : "Keine Farbe"}
aria-hidden
/>
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-medium leading-snug">{formatTreeTagDisplay(n.name, n.label_prefix)}</div>
<div className="truncate font-mono text-[10px] leading-tight text-muted-foreground">{n.name}</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-0 pr-0.5">
<Button
type="button"
variant="ghost"
size="icon-sm"
className="size-7 text-muted-foreground hover:text-foreground"
title="Bearbeiten"
disabled={busy}
onClick={() => onEdit(n)}
>
<Pencil className="size-3.5" aria-hidden />
<span className="sr-only">Bearbeiten</span>
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="size-7 text-muted-foreground hover:text-foreground"
title="Unterknoten"
disabled={busy}
onClick={e => {
e.stopPropagation();
onAddChild(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-7 text-muted-foreground hover:text-destructive"
title="Löschen"
disabled={busy}
onClick={e => void onDelete(n.id, e)}
>
<Trash2 className="size-3.5" aria-hidden />
<span className="sr-only">Löschen</span>
</Button>
</div>
</div>
</li>
);
}
+24
View File
@@ -0,0 +1,24 @@
import type { TagTreeNode } from "@/types/tagTree";
export function orderedWithDepth(nodes: TagTreeNode[]): { node: TagTreeNode; depth: number }[] {
const byParent = new Map<number | null, TagTreeNode[]>();
for (const n of nodes) {
const p = n.parent_id;
const list = byParent.get(p);
if (list) list.push(n);
else byParent.set(p, [n]);
}
for (const list of byParent.values()) {
list.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }));
}
const walk = (parentId: number | null, depth: number): { node: TagTreeNode; depth: number }[] => {
const kids = byParent.get(parentId) ?? [];
const out: { node: TagTreeNode; depth: number }[] = [];
for (const k of kids) {
out.push({ node: k, depth });
out.push(...walk(k.id, depth + 1));
}
return out;
};
return walk(null, 0);
}
@@ -0,0 +1,26 @@
import { useMemo } from "react";
import type { TagTreeNode } from "@/types/tagTree";
export function useTagTreeParentOptions(nodes: TagTreeNode[], editingId: number | null): TagTreeNode[] {
return useMemo(() => {
const blocked = new Set<number>();
if (editingId != null) {
blocked.add(editingId);
const children = new Map<number | null, number[]>();
for (const n of nodes) {
const p = n.parent_id;
const list = children.get(p);
if (list) list.push(n.id);
else children.set(p, [n.id]);
}
const stack = [...(children.get(editingId) ?? [])];
while (stack.length) {
const id = stack.pop()!;
blocked.add(id);
const kids = children.get(id);
if (kids) for (const k of kids) stack.push(k);
}
}
return nodes.filter(n => !blocked.has(n.id));
}, [nodes, editingId]);
}