snapshot
This commit is contained in:
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { NotesApp } from "@/components/NotesApp";
|
||||
import { NotesApp } from "@/features/notes/NotesApp";
|
||||
import "./index.css";
|
||||
|
||||
export function App() {
|
||||
|
||||
@@ -1,411 +0,0 @@
|
||||
import { marked } from "marked";
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { Snippet } from "@/types/snippet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardFooter } from "@/components/ui/card";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
marked.setOptions({ gfm: true });
|
||||
|
||||
function tagsFromString(s: string): string[] {
|
||||
return s
|
||||
.split(",")
|
||||
.map(t => t.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/** Letztes Segment nach dem letzten Komma (laufender Tag-Token). */
|
||||
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() };
|
||||
}
|
||||
|
||||
function colorForTagName(
|
||||
name: string,
|
||||
treeColorByName: Record<string, string | null>,
|
||||
snippetColors: Record<string, string | null>,
|
||||
): string | null {
|
||||
return treeColorByName[name] ?? snippetColors[name] ?? null;
|
||||
}
|
||||
|
||||
function TagPill({ tagKey, label, hex }: { tagKey: string; label: string; hex: string | null }) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
function formatWhen(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleString(undefined, { dateStyle: "short", timeStyle: "short" });
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
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 [showPreview, setShowPreview] = useState(false);
|
||||
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 [tagFieldFocused, setTagFieldFocused] = useState(false);
|
||||
const [tagSuggest, setTagSuggest] = useState<{ name: string; display: string }[]>([]);
|
||||
const [tagSuggestOpen, setTagSuggestOpen] = useState(false);
|
||||
const [tagSuggestIndex, setTagSuggestIndex] = useState(0);
|
||||
const tagSuggestReq = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dialogOpen || !tagFieldFocused) {
|
||||
setTagSuggestOpen(false);
|
||||
return;
|
||||
}
|
||||
const { token } = splitTagsField(tagsStr);
|
||||
const id = ++tagSuggestReq.current;
|
||||
const t = window.setTimeout(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/tag-suggestions?q=${encodeURIComponent(token)}`);
|
||||
if (tagSuggestReq.current !== id) return;
|
||||
if (!res.ok) return;
|
||||
const data = (await res.json()) as { items?: { name: string; display: string }[] };
|
||||
const items = Array.isArray(data.items) ? data.items : [];
|
||||
setTagSuggest(items);
|
||||
setTagSuggestOpen(items.length > 0);
|
||||
setTagSuggestIndex(0);
|
||||
} catch {
|
||||
if (tagSuggestReq.current !== id) return;
|
||||
setTagSuggest([]);
|
||||
setTagSuggestOpen(false);
|
||||
}
|
||||
})();
|
||||
}, 160);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [dialogOpen, tagFieldFocused, tagsStr]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dialogOpen) {
|
||||
setTagFieldFocused(false);
|
||||
setTagSuggest([]);
|
||||
setTagSuggestOpen(false);
|
||||
}
|
||||
}, [dialogOpen]);
|
||||
|
||||
const applyTagSuggestion = (label: string) => {
|
||||
const { committed } = splitTagsField(tagsStr);
|
||||
setTagsStr(`${committed}${label}, `);
|
||||
setTagSuggestOpen(false);
|
||||
setDirty(true);
|
||||
};
|
||||
|
||||
const viewHtml = useMemo(() => marked.parse(snippet.body ?? "") as string, [snippet.body]);
|
||||
|
||||
const previewHtml = useMemo(() => marked.parse(body) as string, [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(", "));
|
||||
setShowPreview(false);
|
||||
setDirty(false);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const openDialog = () => {
|
||||
hydrateFromSnippet();
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
/** Dialog schließen; bei neuem leeren Snippet ohne Änderungen wieder entfernen. */
|
||||
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>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="flex h-[min(88dvh,820px)] max-h-[92vh] w-[calc(100vw-1.5rem)] max-w-3xl gap-0 p-0">
|
||||
<DialogHeader className="shrink-0 space-y-1 border-b px-5 pb-3 pt-5 pr-12 text-start sm:px-6 sm:pr-14">
|
||||
<DialogTitle className="text-base">Snippet bearbeiten</DialogTitle>
|
||||
<DialogDescription className="text-xs">Tags und Markdown — Vorschau optional.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden px-5 py-3 sm:px-6">
|
||||
<div className="relative shrink-0">
|
||||
<Input
|
||||
value={tagsStr}
|
||||
onChange={e => {
|
||||
markDirty();
|
||||
setTagsStr(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();
|
||||
setTagSuggestIndex(i => Math.min(tagSuggest.length - 1, i + 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setTagSuggestIndex(i => Math.max(0, i - 1));
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const pick = tagSuggest[tagSuggestIndex];
|
||||
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"
|
||||
/>
|
||||
{tagSuggestOpen ? (
|
||||
<ul
|
||||
role="listbox"
|
||||
className="absolute z-50 mt-1 max-h-40 w-full overflow-auto rounded-md border bg-popover py-1 text-sm shadow-md"
|
||||
>
|
||||
{tagSuggest.map((item, i) => (
|
||||
<li key={`${item.name}-${i}`} role="option" aria-selected={i === tagSuggestIndex}>
|
||||
<button
|
||||
type="button"
|
||||
className={`flex w-full cursor-pointer flex-col gap-0.5 px-3 py-1.5 text-left hover:bg-accent ${
|
||||
i === tagSuggestIndex ? "bg-accent" : ""
|
||||
}`}
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => applyTagSuggestion(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>
|
||||
) : null}
|
||||
</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
|
||||
? "flex min-h-0 flex-1 flex-col gap-3 overflow-hidden lg:flex-row"
|
||||
: "flex min-h-0 flex-1 flex-col overflow-hidden"
|
||||
}
|
||||
>
|
||||
<Textarea
|
||||
value={body}
|
||||
onChange={e => {
|
||||
markDirty();
|
||||
setBody(e.target.value);
|
||||
}}
|
||||
className="min-h-0 w-full flex-1 resize-none font-mono text-sm lg:min-h-0"
|
||||
placeholder="Markdown …"
|
||||
/>
|
||||
{showPreview ? (
|
||||
<div
|
||||
className="snippet-md min-h-0 flex-1 overflow-auto rounded-md border bg-muted/30 p-3 lg:w-1/2"
|
||||
dangerouslySetInnerHTML={{ __html: previewHtml }}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{error ? <p className="shrink-0 text-sm text-destructive">{error}</p> : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="shrink-0 flex-row flex-wrap items-center justify-between gap-2 border-t px-5 py-3 sm:px-6">
|
||||
<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={requestDismiss}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="button" size="sm" className="h-8" disabled={busy} onClick={() => void save()}>
|
||||
Speichern
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,464 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type MouseEvent } from "react";
|
||||
import { FolderTree, Loader2, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import { formatTreeTagDisplay } from "@/lib/treeTagDisplay";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { TagTreeNode } from "@/types/tagTree";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onChanged?: () => void;
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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 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);
|
||||
}
|
||||
};
|
||||
|
||||
const parentOptions = 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]);
|
||||
|
||||
const previewLabel = name.trim() ? formatTreeTagDisplay(name.trim(), labelPrefix) : null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="left-0 top-0 flex h-dvh max-h-dvh min-h-0 w-full max-w-none translate-x-0 translate-y-0 flex-col gap-0 overflow-hidden rounded-none p-0 sm:max-w-none">
|
||||
<DialogHeader className="shrink-0 space-y-2 border-b bg-muted/30 px-5 py-4 text-left sm:px-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-lg border bg-background shadow-sm">
|
||||
<FolderTree className="size-4 text-muted-foreground" aria-hidden />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<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>
|
||||
|
||||
<div className={cn("flex min-h-0 flex-1 flex-col lg:flex-row lg:divide-x", formOpen ? "" : "lg:flex-1")}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-[min(200px,38vh)] flex-col border-b bg-muted/10 lg:min-h-0 lg:border-b-0 lg:bg-muted/5",
|
||||
formOpen ? "lg:max-w-[min(100%,420px)]" : "lg:flex-1",
|
||||
)}
|
||||
>
|
||||
{!formOpen && error ? (
|
||||
<div className="border-b border-destructive/30 bg-destructive/10 px-4 py-2 text-sm text-destructive sm:px-5">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
<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="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>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-0.5" role="list">
|
||||
{flatOrdered.map(({ node: n, depth }) => {
|
||||
const isActive = formOpen && editingId === n.id;
|
||||
return (
|
||||
<li key={n.id} 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={() => startEdit(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();
|
||||
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-7 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>
|
||||
|
||||
{formOpen ? (
|
||||
<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
|
||||
id="tt-prefix"
|
||||
value={labelPrefix}
|
||||
onChange={e => setLabelPrefix(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={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>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="shrink-0 flex-row flex-wrap items-center justify-start gap-2 border-t bg-muted/20 px-4 py-3 sm:px-5">
|
||||
{formOpen ? (
|
||||
<>
|
||||
<Button type="button" variant="outline" className="h-9" disabled={busy} onClick={() => exitFormMode()}>
|
||||
Schließen
|
||||
</Button>
|
||||
<Button type="button" className="h-9" disabled={busy || !name.trim()} onClick={() => void save()}>
|
||||
Speichern
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button type="button" variant="outline" className="h-9" disabled={busy} onClick={() => onOpenChange(false)}>
|
||||
Schließen
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,14 @@ import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Centered modal (default shadcn). */
|
||||
const dialogContentDefault =
|
||||
"fixed left-1/2 top-1/2 z-50 flex min-h-0 w-[calc(100vw-1.5rem)] max-w-lg -translate-x-1/2 -translate-y-1/2 flex-col gap-4 overflow-hidden rounded-lg border bg-background p-6 shadow-lg duration-200 max-h-[min(92dvh,calc(100dvh-2rem))] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95";
|
||||
|
||||
/** Full viewport; overrides centered positioning from the default recipe. */
|
||||
const dialogContentFullscreen =
|
||||
"fixed inset-0 z-50 flex min-h-0 w-full flex-col gap-0 overflow-hidden rounded-none border bg-background p-0 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95";
|
||||
|
||||
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
}
|
||||
@@ -36,20 +44,19 @@ const DialogOverlay = React.forwardRef<
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
export type DialogContentProps = React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||
variant?: "default" | "fullscreen";
|
||||
};
|
||||
|
||||
const DialogContent = React.forwardRef<React.ElementRef<typeof DialogPrimitive.Content>, DialogContentProps>(
|
||||
({ className, children, variant = "default", ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed left-1/2 top-1/2 z-50 flex min-h-0 w-[calc(100vw-1.5rem)] max-w-lg -translate-x-1/2 -translate-y-1/2 flex-col gap-4 overflow-hidden rounded-lg border bg-background p-6 shadow-lg duration-200 max-h-[min(92dvh,calc(100dvh-2rem))]",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
||||
className,
|
||||
)}
|
||||
data-variant={variant}
|
||||
className={cn(variant === "fullscreen" ? dialogContentFullscreen : dialogContentDefault, className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
@@ -62,7 +69,8 @@ const DialogContent = React.forwardRef<
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
),
|
||||
);
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
|
||||
@@ -9,7 +9,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"focus:border-ring focus:ring-[3px] focus:ring-ring/50 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -2,17 +2,21 @@ import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, React.ComponentProps<"textarea">>(
|
||||
({ className, ...props }, ref) => (
|
||||
<textarea
|
||||
ref={ref}
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input min-h-16 w-full min-w-0 rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm field-sizing-content",
|
||||
"focus:border-ring focus:ring-[3px] focus:ring-ring/50 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
),
|
||||
);
|
||||
Textarea.displayName = "Textarea";
|
||||
|
||||
export { Textarea };
|
||||
@@ -1,23 +1,11 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Tags } from "lucide-react";
|
||||
import { formatTreeTagDisplay } from "@/lib/treeTagDisplay";
|
||||
import type { Snippet } from "@/types/snippet";
|
||||
import type { TagTreeNode } from "@/types/tagTree";
|
||||
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 { SnippetCard } from "@/components/SnippetCard";
|
||||
import { TagTreeDialog } from "@/components/TagTreeDialog";
|
||||
|
||||
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;
|
||||
|
||||
type SortValue = (typeof SORT_OPTIONS)[number]["value"];
|
||||
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("");
|
||||
@@ -118,58 +106,15 @@ export function NotesApp() {
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-4 px-4 py-4 sm:px-5">
|
||||
<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 => setQuery(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 => setSort(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={() => 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 type="button" className="h-9 w-full sm:w-auto" onClick={() => void createNew()}>
|
||||
Neu
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<NotesToolbar
|
||||
query={query}
|
||||
onQueryChange={setQuery}
|
||||
sort={sort}
|
||||
onSortChange={setSort}
|
||||
tagTreeCount={tagTreeNodes.length}
|
||||
onOpenTagTree={() => setTagTreeOpen(true)}
|
||||
onCreateNew={() => void createNew()}
|
||||
/>
|
||||
|
||||
<TagTreeDialog
|
||||
open={tagTreeOpen}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"];
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
+106
@@ -186,4 +186,110 @@ body::before {
|
||||
margin-block: 0.375rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
/* App dialogs: shared layout (Tailwind here, short class names in TSX) */
|
||||
.dialog-snippet-header {
|
||||
@apply shrink-0 space-y-1 border-b px-5 pb-3 pt-5 pr-12 text-start sm:px-6 sm:pr-14;
|
||||
}
|
||||
|
||||
.dialog-snippet-body {
|
||||
/* no overflow-hidden: would clip focus ring (box-shadow) on inputs */
|
||||
@apply flex min-h-0 flex-1 flex-col gap-2 px-5 py-3 sm:px-6;
|
||||
}
|
||||
|
||||
.dialog-snippet-footer {
|
||||
@apply shrink-0 flex-row flex-wrap items-center justify-between gap-2 border-t px-5 py-3 sm:px-6;
|
||||
}
|
||||
|
||||
.dialog-snippet-editor {
|
||||
@apply flex min-h-0 flex-1 flex-col;
|
||||
}
|
||||
|
||||
.dialog-snippet-editor-with-preview {
|
||||
@apply flex min-h-0 flex-1 flex-col gap-3 lg:flex-row;
|
||||
}
|
||||
|
||||
.dialog-snippet-preview {
|
||||
@apply min-h-0 flex-1 overflow-auto rounded-md border bg-muted/30 p-3 lg:w-1/2;
|
||||
}
|
||||
|
||||
.dialog-snippet-textarea {
|
||||
@apply min-h-0 w-full flex-1 resize-none font-mono text-sm lg:min-h-0;
|
||||
}
|
||||
|
||||
.tag-suggest-dropdown {
|
||||
@apply absolute z-50 mt-1 max-h-40 w-full overflow-auto rounded-md border bg-popover py-1 text-sm shadow-md;
|
||||
}
|
||||
|
||||
.tag-suggest-option {
|
||||
@apply flex w-full cursor-pointer flex-col gap-0.5 px-3 py-1.5 text-left hover:bg-accent;
|
||||
}
|
||||
|
||||
.tag-suggest-option-active {
|
||||
@apply bg-accent;
|
||||
}
|
||||
|
||||
.tag-tree-count-chip {
|
||||
@apply rounded-full bg-muted px-2 py-0.5 font-mono text-[10px] text-muted-foreground tabular-nums;
|
||||
}
|
||||
|
||||
.tag-tree-node-list {
|
||||
@apply space-y-0.5;
|
||||
}
|
||||
|
||||
.tag-tree-dialog-header {
|
||||
@apply shrink-0 space-y-2 border-b bg-muted/30 px-5 py-4 text-left sm:px-6;
|
||||
}
|
||||
|
||||
.tag-tree-dialog-hero {
|
||||
@apply flex items-start gap-3;
|
||||
}
|
||||
|
||||
.tag-tree-dialog-icon {
|
||||
@apply mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-lg border bg-background shadow-sm;
|
||||
}
|
||||
|
||||
.tag-tree-dialog-hero-text {
|
||||
@apply min-w-0 flex-1 space-y-1;
|
||||
}
|
||||
|
||||
.tag-tree-dialog-footer {
|
||||
@apply shrink-0 flex-row flex-wrap items-center justify-start gap-2 border-t bg-muted/20 px-4 py-3 sm:px-5;
|
||||
}
|
||||
|
||||
.tag-tree-main {
|
||||
@apply flex min-h-0 flex-1 flex-col lg:flex-row lg:divide-x;
|
||||
}
|
||||
|
||||
.tag-tree-hierarchy {
|
||||
@apply flex min-h-[min(200px,38vh)] flex-col border-b bg-muted/10 lg:min-h-0 lg:border-b-0 lg:bg-muted/5;
|
||||
}
|
||||
|
||||
.tag-tree-hierarchy--with-form {
|
||||
@apply lg:max-w-[min(100%,420px)];
|
||||
}
|
||||
|
||||
.tag-tree-hierarchy--solo {
|
||||
@apply lg:flex-1;
|
||||
}
|
||||
|
||||
.tag-tree-inline-error {
|
||||
@apply border-b border-destructive/30 bg-destructive/10 px-4 py-2 text-sm text-destructive sm:px-5;
|
||||
}
|
||||
|
||||
.tag-tree-toolbar {
|
||||
@apply flex shrink-0 items-center justify-between gap-2 border-b px-4 py-2.5 sm:px-5;
|
||||
}
|
||||
|
||||
.tag-tree-scroll {
|
||||
@apply min-h-0 flex-1 overflow-y-auto px-2 py-2 sm:px-3;
|
||||
}
|
||||
|
||||
.tag-tree-empty {
|
||||
@apply flex flex-col items-center justify-center gap-3 px-4 py-10 text-center;
|
||||
}
|
||||
|
||||
.tag-tree-loading {
|
||||
@apply flex flex-col items-center justify-center gap-2 py-12 text-muted-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
+4
-206
@@ -1,217 +1,15 @@
|
||||
import { serve } from "bun";
|
||||
import index from "./index.html";
|
||||
import { getDb } from "./server/db";
|
||||
import { createSnippet, deleteSnippet, getSnippetById, listSnippets, updateSnippet } from "./server/snippets";
|
||||
import {
|
||||
createTagTreeNode,
|
||||
deleteTagTreeNode,
|
||||
listTagTreeNodes,
|
||||
suggestTagTreeItems,
|
||||
updateTagTreeNode,
|
||||
} from "./server/tagTree";
|
||||
import { snippetRoutes } from "./server/routes/snippetsHandlers";
|
||||
import { tagTreeRoutes } from "./server/routes/tagTreeHandlers";
|
||||
|
||||
getDb();
|
||||
|
||||
async function readJson(req: Request): Promise<unknown> {
|
||||
try {
|
||||
return await req.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const server = serve({
|
||||
routes: {
|
||||
"/api/snippets": {
|
||||
async GET(req) {
|
||||
const url = new URL(req.url);
|
||||
const q = url.searchParams.get("q");
|
||||
const sort = url.searchParams.get("sort");
|
||||
return Response.json(listSnippets(q, sort));
|
||||
},
|
||||
async POST(req) {
|
||||
const body = await readJson(req);
|
||||
if (!body || typeof body !== "object") {
|
||||
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
const o = body as Record<string, unknown>;
|
||||
const tags = Array.isArray(o.tags)
|
||||
? o.tags.filter((t): t is string => typeof t === "string")
|
||||
: [];
|
||||
const snippet = createSnippet({
|
||||
title:
|
||||
o.title === null
|
||||
? null
|
||||
: typeof o.title === "string"
|
||||
? o.title
|
||||
: undefined,
|
||||
body: typeof o.body === "string" ? o.body : "",
|
||||
tags,
|
||||
});
|
||||
return Response.json(snippet, { status: 201 });
|
||||
},
|
||||
},
|
||||
|
||||
"/api/tag-suggestions": {
|
||||
async GET(req) {
|
||||
const url = new URL(req.url);
|
||||
const q = url.searchParams.get("q") ?? "";
|
||||
const database = getDb();
|
||||
return Response.json({ items: suggestTagTreeItems(database, q) });
|
||||
},
|
||||
},
|
||||
|
||||
"/api/tag-tree": {
|
||||
async GET() {
|
||||
return Response.json(listTagTreeNodes());
|
||||
},
|
||||
async POST(req) {
|
||||
const body = await readJson(req);
|
||||
if (!body || typeof body !== "object") {
|
||||
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
const o = body as Record<string, unknown>;
|
||||
try {
|
||||
const node = createTagTreeNode({
|
||||
name: typeof o.name === "string" ? o.name : "",
|
||||
parent_id:
|
||||
o.parent_id === null || o.parent_id === undefined
|
||||
? null
|
||||
: typeof o.parent_id === "number"
|
||||
? o.parent_id
|
||||
: Number(o.parent_id),
|
||||
color: o.color === null || o.color === undefined ? null : String(o.color),
|
||||
label_prefix:
|
||||
o.label_prefix === null || o.label_prefix === undefined
|
||||
? null
|
||||
: typeof o.label_prefix === "string"
|
||||
? o.label_prefix
|
||||
: String(o.label_prefix),
|
||||
});
|
||||
return Response.json(node, { status: 201 });
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : "Fehler";
|
||||
return Response.json({ error: msg }, { status: 400 });
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
"/api/tag-tree/:id": {
|
||||
async PATCH(req) {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isFinite(id)) {
|
||||
return Response.json({ error: "Invalid id" }, { status: 400 });
|
||||
}
|
||||
const body = await readJson(req);
|
||||
if (!body || typeof body !== "object") {
|
||||
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
const o = body as Record<string, unknown>;
|
||||
const patch: {
|
||||
name?: string;
|
||||
parent_id?: number | null;
|
||||
color?: string | null;
|
||||
label_prefix?: string | null;
|
||||
} = {};
|
||||
if ("name" in o && typeof o.name === "string") patch.name = o.name;
|
||||
if ("parent_id" in o) {
|
||||
patch.parent_id =
|
||||
o.parent_id === null ? null : typeof o.parent_id === "number" ? o.parent_id : Number(o.parent_id);
|
||||
}
|
||||
if ("color" in o) {
|
||||
patch.color = o.color === null || o.color === undefined ? null : String(o.color);
|
||||
}
|
||||
if ("label_prefix" in o) {
|
||||
patch.label_prefix =
|
||||
o.label_prefix === null || o.label_prefix === undefined
|
||||
? null
|
||||
: typeof o.label_prefix === "string"
|
||||
? o.label_prefix
|
||||
: String(o.label_prefix);
|
||||
}
|
||||
try {
|
||||
const node = updateTagTreeNode(id, patch);
|
||||
if (!node) {
|
||||
return Response.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
return Response.json(node);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : "Fehler";
|
||||
return Response.json({ error: msg }, { status: 400 });
|
||||
}
|
||||
},
|
||||
async DELETE(req) {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isFinite(id)) {
|
||||
return Response.json({ error: "Invalid id" }, { status: 400 });
|
||||
}
|
||||
const ok = deleteTagTreeNode(id);
|
||||
if (!ok) {
|
||||
return Response.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
return new Response(null, { status: 204 });
|
||||
},
|
||||
},
|
||||
|
||||
"/api/snippets/:id": {
|
||||
async GET(req) {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isFinite(id)) {
|
||||
return Response.json({ error: "Invalid id" }, { status: 400 });
|
||||
}
|
||||
const snippet = getSnippetById(id);
|
||||
if (!snippet) {
|
||||
return Response.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
return Response.json(snippet);
|
||||
},
|
||||
async PATCH(req) {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isFinite(id)) {
|
||||
return Response.json({ error: "Invalid id" }, { status: 400 });
|
||||
}
|
||||
const body = await readJson(req);
|
||||
if (!body || typeof body !== "object") {
|
||||
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
const o = body as Record<string, unknown>;
|
||||
const patch: {
|
||||
title?: string | null;
|
||||
body?: string;
|
||||
tags?: string[];
|
||||
sort_order?: number;
|
||||
} = {};
|
||||
if ("title" in o) {
|
||||
patch.title = o.title === null ? null : String(o.title);
|
||||
}
|
||||
if ("body" in o && typeof o.body === "string") {
|
||||
patch.body = o.body;
|
||||
}
|
||||
if ("tags" in o && Array.isArray(o.tags)) {
|
||||
patch.tags = o.tags.filter((t): t is string => typeof t === "string");
|
||||
}
|
||||
if ("sort_order" in o && typeof o.sort_order === "number" && Number.isFinite(o.sort_order)) {
|
||||
patch.sort_order = o.sort_order;
|
||||
}
|
||||
const snippet = updateSnippet(id, patch);
|
||||
if (!snippet) {
|
||||
return Response.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
return Response.json(snippet);
|
||||
},
|
||||
async DELETE(req) {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isFinite(id)) {
|
||||
return Response.json({ error: "Invalid id" }, { status: 400 });
|
||||
}
|
||||
const ok = deleteSnippet(id);
|
||||
if (!ok) {
|
||||
return Response.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
return new Response(null, { status: 204 });
|
||||
},
|
||||
},
|
||||
|
||||
...snippetRoutes,
|
||||
...tagTreeRoutes,
|
||||
"/*": index,
|
||||
},
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export async function readJson(req: Request): Promise<unknown> {
|
||||
try {
|
||||
return await req.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { readJson } from "@/server/http/readJson";
|
||||
import { createSnippet, deleteSnippet, getSnippetById, listSnippets, updateSnippet } from "@/server/snippets";
|
||||
|
||||
export const snippetRoutes = {
|
||||
"/api/snippets": {
|
||||
async GET(req: Request) {
|
||||
const url = new URL(req.url);
|
||||
const q = url.searchParams.get("q");
|
||||
const sort = url.searchParams.get("sort");
|
||||
return Response.json(listSnippets(q, sort));
|
||||
},
|
||||
async POST(req: Request) {
|
||||
const body = await readJson(req);
|
||||
if (!body || typeof body !== "object") {
|
||||
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
const o = body as Record<string, unknown>;
|
||||
const tags = Array.isArray(o.tags) ? o.tags.filter((t): t is string => typeof t === "string") : [];
|
||||
const snippet = createSnippet({
|
||||
title:
|
||||
o.title === null ? null : typeof o.title === "string" ? o.title : undefined,
|
||||
body: typeof o.body === "string" ? o.body : "",
|
||||
tags,
|
||||
});
|
||||
return Response.json(snippet, { status: 201 });
|
||||
},
|
||||
},
|
||||
|
||||
"/api/snippets/:id": {
|
||||
async GET(req: Request) {
|
||||
const id = Number((req as Request & { params: { id: string } }).params.id);
|
||||
if (!Number.isFinite(id)) {
|
||||
return Response.json({ error: "Invalid id" }, { status: 400 });
|
||||
}
|
||||
const snippet = getSnippetById(id);
|
||||
if (!snippet) {
|
||||
return Response.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
return Response.json(snippet);
|
||||
},
|
||||
async PATCH(req: Request) {
|
||||
const id = Number((req as Request & { params: { id: string } }).params.id);
|
||||
if (!Number.isFinite(id)) {
|
||||
return Response.json({ error: "Invalid id" }, { status: 400 });
|
||||
}
|
||||
const body = await readJson(req);
|
||||
if (!body || typeof body !== "object") {
|
||||
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
const o = body as Record<string, unknown>;
|
||||
const patch: {
|
||||
title?: string | null;
|
||||
body?: string;
|
||||
tags?: string[];
|
||||
sort_order?: number;
|
||||
} = {};
|
||||
if ("title" in o) {
|
||||
patch.title = o.title === null ? null : String(o.title);
|
||||
}
|
||||
if ("body" in o && typeof o.body === "string") {
|
||||
patch.body = o.body;
|
||||
}
|
||||
if ("tags" in o && Array.isArray(o.tags)) {
|
||||
patch.tags = o.tags.filter((t): t is string => typeof t === "string");
|
||||
}
|
||||
if ("sort_order" in o && typeof o.sort_order === "number" && Number.isFinite(o.sort_order)) {
|
||||
patch.sort_order = o.sort_order;
|
||||
}
|
||||
const snippet = updateSnippet(id, patch);
|
||||
if (!snippet) {
|
||||
return Response.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
return Response.json(snippet);
|
||||
},
|
||||
async DELETE(req: Request) {
|
||||
const id = Number((req as Request & { params: { id: string } }).params.id);
|
||||
if (!Number.isFinite(id)) {
|
||||
return Response.json({ error: "Invalid id" }, { status: 400 });
|
||||
}
|
||||
const ok = deleteSnippet(id);
|
||||
if (!ok) {
|
||||
return Response.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
return new Response(null, { status: 204 });
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
import { getDb } from "@/server/db";
|
||||
import { readJson } from "@/server/http/readJson";
|
||||
import {
|
||||
createTagTreeNode,
|
||||
deleteTagTreeNode,
|
||||
listTagTreeNodes,
|
||||
suggestTagTreeItems,
|
||||
updateTagTreeNode,
|
||||
} from "@/server/tagTree";
|
||||
|
||||
export const tagTreeRoutes = {
|
||||
"/api/tag-suggestions": {
|
||||
async GET(req: Request) {
|
||||
const url = new URL(req.url);
|
||||
const q = url.searchParams.get("q") ?? "";
|
||||
const database = getDb();
|
||||
return Response.json({ items: suggestTagTreeItems(database, q) });
|
||||
},
|
||||
},
|
||||
|
||||
"/api/tag-tree": {
|
||||
async GET() {
|
||||
return Response.json(listTagTreeNodes());
|
||||
},
|
||||
async POST(req: Request) {
|
||||
const body = await readJson(req);
|
||||
if (!body || typeof body !== "object") {
|
||||
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
const o = body as Record<string, unknown>;
|
||||
try {
|
||||
const node = createTagTreeNode({
|
||||
name: typeof o.name === "string" ? o.name : "",
|
||||
parent_id:
|
||||
o.parent_id === null || o.parent_id === undefined
|
||||
? null
|
||||
: typeof o.parent_id === "number"
|
||||
? o.parent_id
|
||||
: Number(o.parent_id),
|
||||
color: o.color === null || o.color === undefined ? null : String(o.color),
|
||||
label_prefix:
|
||||
o.label_prefix === null || o.label_prefix === undefined
|
||||
? null
|
||||
: typeof o.label_prefix === "string"
|
||||
? o.label_prefix
|
||||
: String(o.label_prefix),
|
||||
});
|
||||
return Response.json(node, { status: 201 });
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : "Fehler";
|
||||
return Response.json({ error: msg }, { status: 400 });
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
"/api/tag-tree/:id": {
|
||||
async PATCH(req: Request) {
|
||||
const id = Number((req as Request & { params: { id: string } }).params.id);
|
||||
if (!Number.isFinite(id)) {
|
||||
return Response.json({ error: "Invalid id" }, { status: 400 });
|
||||
}
|
||||
const body = await readJson(req);
|
||||
if (!body || typeof body !== "object") {
|
||||
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
const o = body as Record<string, unknown>;
|
||||
const patch: {
|
||||
name?: string;
|
||||
parent_id?: number | null;
|
||||
color?: string | null;
|
||||
label_prefix?: string | null;
|
||||
} = {};
|
||||
if ("name" in o && typeof o.name === "string") patch.name = o.name;
|
||||
if ("parent_id" in o) {
|
||||
patch.parent_id =
|
||||
o.parent_id === null ? null : typeof o.parent_id === "number" ? o.parent_id : Number(o.parent_id);
|
||||
}
|
||||
if ("color" in o) {
|
||||
patch.color = o.color === null || o.color === undefined ? null : String(o.color);
|
||||
}
|
||||
if ("label_prefix" in o) {
|
||||
patch.label_prefix =
|
||||
o.label_prefix === null || o.label_prefix === undefined
|
||||
? null
|
||||
: typeof o.label_prefix === "string"
|
||||
? o.label_prefix
|
||||
: String(o.label_prefix);
|
||||
}
|
||||
try {
|
||||
const node = updateTagTreeNode(id, patch);
|
||||
if (!node) {
|
||||
return Response.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
return Response.json(node);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : "Fehler";
|
||||
return Response.json({ error: msg }, { status: 400 });
|
||||
}
|
||||
},
|
||||
async DELETE(req: Request) {
|
||||
const id = Number((req as Request & { params: { id: string } }).params.id);
|
||||
if (!Number.isFinite(id)) {
|
||||
return Response.json({ error: "Invalid id" }, { status: 400 });
|
||||
}
|
||||
const ok = deleteTagTreeNode(id);
|
||||
if (!ok) {
|
||||
return Response.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
return new Response(null, { status: 204 });
|
||||
},
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user