Compare commits
10
Commits
614b8a9a66
...
e4bdd7c9bb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4bdd7c9bb | ||
|
|
a8cc169816 | ||
|
|
24b37821dc | ||
|
|
e5f77debc2 | ||
|
|
c2b20a9f10 | ||
|
|
14ed445328 | ||
|
|
3bdc8cc646 | ||
|
|
5cbf43a2db | ||
|
|
d1ca72450f | ||
|
|
f111274262 |
@@ -12,6 +12,7 @@
|
|||||||
"bun-plugin-tailwind": "^0.1.2",
|
"bun-plugin-tailwind": "^0.1.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"highlight.js": "^11.11.1",
|
||||||
"lucide-react": "^0.545.0",
|
"lucide-react": "^0.545.0",
|
||||||
"marked": "^18.0.0",
|
"marked": "^18.0.0",
|
||||||
"react": "^19",
|
"react": "^19",
|
||||||
@@ -144,6 +145,8 @@
|
|||||||
|
|
||||||
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
|
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
|
||||||
|
|
||||||
|
"highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="],
|
||||||
|
|
||||||
"lucide-react": ["lucide-react@0.545.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-7r1/yUuflQDSt4f1bpn5ZAocyIxcTyVyBBChSVtBKn5M+392cPmI5YJMWOJKk/HUWGm5wg83chlAZtCcGbEZtw=="],
|
"lucide-react": ["lucide-react@0.545.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-7r1/yUuflQDSt4f1bpn5ZAocyIxcTyVyBBChSVtBKn5M+392cPmI5YJMWOJKk/HUWGm5wg83chlAZtCcGbEZtw=="],
|
||||||
|
|
||||||
"marked": ["marked@18.0.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-2e7Qiv/HJSXj8rDEpgTvGKsP8yYtI9xXHKDnrftrmnrJPaFNM7VRb2YCzWaX4BP1iCJ/XPduzDJZMFoqTCcIMA=="],
|
"marked": ["marked@18.0.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-2e7Qiv/HJSXj8rDEpgTvGKsP8yYtI9xXHKDnrftrmnrJPaFNM7VRb2YCzWaX4BP1iCJ/XPduzDJZMFoqTCcIMA=="],
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"bun-plugin-tailwind": "^0.1.2",
|
"bun-plugin-tailwind": "^0.1.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"highlight.js": "^11.11.1",
|
||||||
"lucide-react": "^0.545.0",
|
"lucide-react": "^0.545.0",
|
||||||
"marked": "^18.0.0",
|
"marked": "^18.0.0",
|
||||||
"react": "^19",
|
"react": "^19",
|
||||||
|
|||||||
+2
-1
@@ -1,4 +1,5 @@
|
|||||||
import { NotesApp } from "@/components/NotesApp";
|
import { NotesApp } from "@/features/notes/NotesApp";
|
||||||
|
import "highlight.js/styles/github.css";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -1,135 +0,0 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import type { Snippet } from "@/types/snippet";
|
|
||||||
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";
|
|
||||||
|
|
||||||
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"];
|
|
||||||
|
|
||||||
export function NotesApp() {
|
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
const [debouncedQuery, setDebouncedQuery] = useState("");
|
|
||||||
const [sort, setSort] = useState<SortValue>("updated_desc");
|
|
||||||
const [snippets, setSnippets] = useState<Snippet[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const t = window.setTimeout(() => setDebouncedQuery(query), 300);
|
|
||||||
return () => window.clearTimeout(t);
|
|
||||||
}, [query]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (debouncedQuery.trim()) params.set("q", debouncedQuery.trim());
|
|
||||||
params.set("sort", sort);
|
|
||||||
const res = await fetch(`/api/snippets?${params.toString()}`);
|
|
||||||
if (!res.ok) throw new Error(await res.text());
|
|
||||||
const data = (await res.json()) as Snippet[];
|
|
||||||
if (!cancelled) setSnippets(data);
|
|
||||||
} catch (e) {
|
|
||||||
if (!cancelled) setError(e instanceof Error ? e.message : "Laden fehlgeschlagen");
|
|
||||||
} finally {
|
|
||||||
if (!cancelled) setLoading(false);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [debouncedQuery, sort]);
|
|
||||||
|
|
||||||
const replaceSnippet = useCallback((s: Snippet) => {
|
|
||||||
setSnippets(prev => prev.map(x => (x.id === s.id ? s : x)));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const removeSnippet = useCallback((id: number) => {
|
|
||||||
setSnippets(prev => prev.filter(x => x.id !== id));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const createNew = async () => {
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/snippets", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ title: null, body: "", tags: [] }),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const err = (await res.json().catch(() => null)) as { error?: string } | null;
|
|
||||||
throw new Error(err?.error ?? res.statusText);
|
|
||||||
}
|
|
||||||
const created = (await res.json()) as Snippet;
|
|
||||||
setSnippets(prev => [created, ...prev]);
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : "Neues Snippet fehlgeschlagen");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-4 px-4 py-4 sm:px-5">
|
|
||||||
<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="Ohne Prefix: Titel & 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 sm:shrink-0">
|
|
||||||
<Button type="button" className="h-9 w-full sm:w-auto" onClick={() => void createNew()}>
|
|
||||||
Neu
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
|
||||||
{loading ? <p className="text-sm text-muted-foreground">Lade Snippets …</p> : null}
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
{!loading && snippets.length === 0 ? (
|
|
||||||
<p className="text-center text-sm text-muted-foreground">Keine Snippets. Mit „Neu“ starten.</p>
|
|
||||||
) : null}
|
|
||||||
{snippets.map(s => (
|
|
||||||
<SnippetCard key={s.id} snippet={s} onReplace={replaceSnippet} onRemove={removeSnippet} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,263 +0,0 @@
|
|||||||
import { marked } from "marked";
|
|
||||||
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 { 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 stringFieldForForm(v: string | null | undefined): string {
|
|
||||||
if (v == null) return "";
|
|
||||||
return typeof v === "string" ? v : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function tagsFromString(s: string): string[] {
|
|
||||||
return s
|
|
||||||
.split(",")
|
|
||||||
.map(t => t.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatWhen(iso: string): string {
|
|
||||||
try {
|
|
||||||
return new Date(iso).toLocaleString();
|
|
||||||
} catch {
|
|
||||||
return iso;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
snippet: Snippet;
|
|
||||||
onReplace: (s: Snippet) => void;
|
|
||||||
onRemove: (id: number) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const mdBodyClassBase =
|
|
||||||
"snippet-md text-sm leading-relaxed [&_a]:text-primary [&_a]:underline [&_blockquote]:border-l-2 [&_blockquote]:border-muted-foreground/40 [&_blockquote]:pl-3 [&_blockquote]:italic [&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_h1]:mb-2 [&_h1]:text-xl [&_h1]:font-semibold [&_h2]:mb-2 [&_h2]:text-lg [&_h2]:font-semibold [&_h3]:mb-1 [&_h3]:text-base [&_h3]:font-semibold [&_li]:ml-4 [&_ol]:list-decimal [&_pre]:overflow-x-auto [&_pre]:rounded-md [&_pre]:bg-muted [&_pre]:p-3 [&_ul]:list-disc";
|
|
||||||
|
|
||||||
const mdBodyClassCard = `${mdBodyClassBase} max-h-[min(55vh,420px)] overflow-auto`;
|
|
||||||
const mdBodyClassDialogPreview = `${mdBodyClassBase} min-h-0 flex-1 overflow-auto`;
|
|
||||||
|
|
||||||
export function SnippetCard({ snippet, onReplace, onRemove }: Props) {
|
|
||||||
const pristineEmpty =
|
|
||||||
!stringFieldForForm(snippet.body) &&
|
|
||||||
(snippet.title == null || snippet.title.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(() => stringFieldForForm(snippet.body));
|
|
||||||
const [title, setTitle] = useState(() => stringFieldForForm(snippet.title));
|
|
||||||
const [tagsStr, setTagsStr] = useState(() => (snippet.tags ?? []).join(", "));
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const viewHtml = useMemo(
|
|
||||||
() => marked.parse(stringFieldForForm(snippet.body)) as string,
|
|
||||||
[snippet.body],
|
|
||||||
);
|
|
||||||
|
|
||||||
const previewHtml = useMemo(() => marked.parse(body) as string, [body]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (dialogOpen) return;
|
|
||||||
setBody(stringFieldForForm(snippet.body));
|
|
||||||
setTitle(stringFieldForForm(snippet.title));
|
|
||||||
setTagsStr((snippet.tags ?? []).join(", "));
|
|
||||||
}, [dialogOpen, snippet.id, snippet.updated_at, snippet.body, snippet.title, snippet.tags]);
|
|
||||||
|
|
||||||
const hydrateFromSnippet = () => {
|
|
||||||
setBody(stringFieldForForm(snippet.body));
|
|
||||||
setTitle(stringFieldForForm(snippet.title));
|
|
||||||
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: title.trim() === "" ? null : title.trim(),
|
|
||||||
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 py-3 text-left shadow-sm">
|
|
||||||
<CardContent className="px-4 pb-2 pt-1">
|
|
||||||
<div
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
title="Zum Bearbeiten klicken"
|
|
||||||
className="rounded-sm px-0.5 py-2 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={`${mdBodyClassCard}`}
|
|
||||||
dangerouslySetInnerHTML={{ __html: viewHtml }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
<CardFooter className="flex flex-col gap-2 border-t px-4 pb-3 pt-2 sm:flex-row sm:items-end sm:justify-between">
|
|
||||||
<div className="grid w-full gap-1 text-sm sm:max-w-xl">
|
|
||||||
<div>
|
|
||||||
{snippet.title?.trim() ? (
|
|
||||||
<span className="font-medium text-foreground">{snippet.title}</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-sm font-normal italic tracking-wide text-muted-foreground">(Kein Titel)</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="text-muted-foreground">{snippet.tags.length ? snippet.tags.join(", ") : "Keine Tags"}</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:items-end">
|
|
||||||
<div className="text-xs text-muted-foreground">Geändert: {formatWhen(snippet.updated_at)}</div>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<Button type="button" variant="secondary" disabled={busy} onClick={openDialog}>
|
|
||||||
Bearbeiten
|
|
||||||
</Button>
|
|
||||||
<Button type="button" variant="destructive" disabled={busy} onClick={() => void deleteRemote(true)}>
|
|
||||||
Löschen
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{error && !dialogOpen ? <p className="text-sm text-destructive">{error}</p> : null}
|
|
||||||
</div>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Dialog open={dialogOpen} onOpenChange={handleOpenChange}>
|
|
||||||
<DialogContent className="gap-0 p-0">
|
|
||||||
<DialogHeader className="shrink-0 space-y-1.5 border-b px-6 pb-4 pt-6 pr-14 text-center sm:text-start">
|
|
||||||
<DialogTitle>Snippet bearbeiten</DialogTitle>
|
|
||||||
<DialogDescription>Titel, Tags und Markdown. Vorschau optional einblenden.</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-hidden px-6 py-4">
|
|
||||||
<Input
|
|
||||||
value={title}
|
|
||||||
onChange={e => {
|
|
||||||
markDirty();
|
|
||||||
setTitle(e.target.value);
|
|
||||||
}}
|
|
||||||
placeholder="Optionaler Titel"
|
|
||||||
className="shrink-0"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
value={tagsStr}
|
|
||||||
onChange={e => {
|
|
||||||
markDirty();
|
|
||||||
setTagsStr(e.target.value);
|
|
||||||
}}
|
|
||||||
placeholder="Tags, kommagetrennt"
|
|
||||||
className="shrink-0"
|
|
||||||
/>
|
|
||||||
<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={`${mdBodyClassDialogPreview} 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-3 border-t px-6 py-4 sm:flex-row sm:justify-between">
|
|
||||||
<Button type="button" variant="outline" size="sm" className="h-9" onClick={() => setShowPreview(v => !v)}>
|
|
||||||
{showPreview ? "Vorschau ausblenden" : "Vorschau"}
|
|
||||||
</Button>
|
|
||||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
|
||||||
<Button type="button" variant="secondary" disabled={busy} onClick={requestDismiss}>
|
|
||||||
Abbrechen
|
|
||||||
</Button>
|
|
||||||
<Button type="button" disabled={busy} onClick={() => void save()}>
|
|
||||||
Speichern
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -49,7 +49,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
|
|
||||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
<div data-slot="card-footer" className={cn("flex items-center px-6 [.border-t]:pt-6", className)} {...props} />
|
<div data-slot="card-footer" className={cn("flex items-center px-6 [.border-t]:pt-2", className)} {...props} />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/** Styled native `<input type="date">` (Kalender-Popup bleibt browserabhängig). */
|
||||||
|
const DateInput = React.forwardRef<HTMLInputElement, Omit<React.ComponentProps<"input">, "type">>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
type="date"
|
||||||
|
data-slot="date-input"
|
||||||
|
className={cn(
|
||||||
|
"date-field",
|
||||||
|
"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 font-mono text-sm shadow-xs transition-[color,box-shadow] outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
"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}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
DateInput.displayName = "DateInput";
|
||||||
|
|
||||||
|
export { DateInput };
|
||||||
@@ -4,6 +4,14 @@ import * as React from "react";
|
|||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
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>) {
|
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||||
}
|
}
|
||||||
@@ -36,20 +44,19 @@ const DialogOverlay = React.forwardRef<
|
|||||||
));
|
));
|
||||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
const DialogContent = React.forwardRef<
|
export type DialogContentProps = React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
variant?: "default" | "fullscreen";
|
||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
};
|
||||||
>(({ className, children, ...props }, ref) => (
|
|
||||||
|
const DialogContent = React.forwardRef<React.ElementRef<typeof DialogPrimitive.Content>, DialogContentProps>(
|
||||||
|
({ className, children, variant = "default", ...props }, ref) => (
|
||||||
<DialogPortal>
|
<DialogPortal>
|
||||||
<DialogOverlay />
|
<DialogOverlay />
|
||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
data-slot="dialog-content"
|
data-slot="dialog-content"
|
||||||
className={cn(
|
data-variant={variant}
|
||||||
"fixed left-2 right-2 top-1/2 z-50 flex h-[calc(100dvh-2rem)] max-h-[calc(100dvh-2rem)] w-auto max-w-none -translate-y-1/2 flex-col gap-4 overflow-hidden rounded-lg border bg-background p-6 shadow-lg duration-200",
|
className={cn(variant === "fullscreen" ? dialogContentFullscreen : dialogContentDefault, className)}
|
||||||
"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,
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -62,7 +69,8 @@ const DialogContent = React.forwardRef<
|
|||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
</DialogPrimitive.Content>
|
</DialogPrimitive.Content>
|
||||||
</DialogPortal>
|
</DialogPortal>
|
||||||
));
|
),
|
||||||
|
);
|
||||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
|||||||
data-slot="input"
|
data-slot="input"
|
||||||
className={cn(
|
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",
|
"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",
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,17 +2,21 @@ import * as React from "react";
|
|||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
const Textarea = React.forwardRef<HTMLTextAreaElement, React.ComponentProps<"textarea">>(
|
||||||
return (
|
({ className, ...props }, ref) => (
|
||||||
<textarea
|
<textarea
|
||||||
|
ref={ref}
|
||||||
data-slot="textarea"
|
data-slot="textarea"
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
),
|
||||||
}
|
);
|
||||||
|
Textarea.displayName = "Textarea";
|
||||||
|
|
||||||
export { Textarea };
|
export { Textarea };
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { formatTreeTagDisplay } from "@/lib/treeTagDisplay";
|
||||||
|
import type { Snippet } from "@/types/snippet";
|
||||||
|
import type { TagTreeNode } from "@/types/tagTree";
|
||||||
|
import { SnippetCard } from "@/features/snippets/SnippetCard";
|
||||||
|
import { TagTreeDialog } from "@/features/tag-tree/TagTreeDialog";
|
||||||
|
import { NotesToolbar } from "@/features/notes/NotesToolbar";
|
||||||
|
import type { SortValue } from "@/features/notes/sortOptions";
|
||||||
|
|
||||||
|
export function NotesApp() {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [debouncedQuery, setDebouncedQuery] = useState("");
|
||||||
|
const [sort, setSort] = useState<SortValue>("updated_desc");
|
||||||
|
const [snippets, setSnippets] = useState<Snippet[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [tagTreeOpen, setTagTreeOpen] = useState(false);
|
||||||
|
const [tagTreeNodes, setTagTreeNodes] = useState<TagTreeNode[]>([]);
|
||||||
|
const [snippetListVersion, setSnippetListVersion] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const t = window.setTimeout(() => setDebouncedQuery(query), 300);
|
||||||
|
return () => window.clearTimeout(t);
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
void (async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (debouncedQuery.trim()) params.set("q", debouncedQuery.trim());
|
||||||
|
params.set("sort", sort);
|
||||||
|
const res = await fetch(`/api/snippets?${params.toString()}`);
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
const data = (await res.json()) as Snippet[];
|
||||||
|
if (!cancelled) setSnippets(data);
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) setError(e instanceof Error ? e.message : "Laden fehlgeschlagen");
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [debouncedQuery, sort, snippetListVersion]);
|
||||||
|
|
||||||
|
const fetchTagTree = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/tag-tree");
|
||||||
|
if (!res.ok) return;
|
||||||
|
setTagTreeNodes((await res.json()) as TagTreeNode[]);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void fetchTagTree();
|
||||||
|
}, [fetchTagTree]);
|
||||||
|
|
||||||
|
const treeColorByName = useMemo(() => {
|
||||||
|
const m: Record<string, string | null> = {};
|
||||||
|
for (const n of tagTreeNodes) {
|
||||||
|
m[n.name] = n.color;
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}, [tagTreeNodes]);
|
||||||
|
|
||||||
|
const treeDisplayByName = useMemo(() => {
|
||||||
|
const m: Record<string, string> = {};
|
||||||
|
for (const n of tagTreeNodes) {
|
||||||
|
m[n.name] = formatTreeTagDisplay(n.name, n.label_prefix ?? null);
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}, [tagTreeNodes]);
|
||||||
|
|
||||||
|
const replaceSnippet = useCallback((s: Snippet) => {
|
||||||
|
setSnippets(prev => prev.map(x => (x.id === s.id ? s : x)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const removeSnippet = useCallback((id: number) => {
|
||||||
|
setSnippets(prev => prev.filter(x => x.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { snippetColumnLeft, snippetColumnRight } = useMemo(() => {
|
||||||
|
const left: Snippet[] = [];
|
||||||
|
const right: Snippet[] = [];
|
||||||
|
snippets.forEach((s, i) => {
|
||||||
|
if (i % 2 === 0) left.push(s);
|
||||||
|
else right.push(s);
|
||||||
|
});
|
||||||
|
return { snippetColumnLeft: left, snippetColumnRight: right };
|
||||||
|
}, [snippets]);
|
||||||
|
|
||||||
|
const createNew = async () => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/snippets", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ title: null, body: "", tags: [] }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = (await res.json().catch(() => null)) as { error?: string } | null;
|
||||||
|
throw new Error(err?.error ?? res.statusText);
|
||||||
|
}
|
||||||
|
const created = (await res.json()) as Snippet;
|
||||||
|
setSnippets(prev => [created, ...prev]);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Neues Snippet fehlgeschlagen");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex w-full max-w-6xl flex-col gap-4 px-4 py-4 sm:px-5 xl:max-w-7xl min-[1800px]:max-w-none min-[1800px]:px-6 min-[2200px]:px-10">
|
||||||
|
<NotesToolbar
|
||||||
|
query={query}
|
||||||
|
onQueryChange={setQuery}
|
||||||
|
sort={sort}
|
||||||
|
onSortChange={setSort}
|
||||||
|
tagTreeCount={tagTreeNodes.length}
|
||||||
|
onOpenTagTree={() => setTagTreeOpen(true)}
|
||||||
|
onCreateNew={() => void createNew()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TagTreeDialog
|
||||||
|
open={tagTreeOpen}
|
||||||
|
onOpenChange={setTagTreeOpen}
|
||||||
|
onChanged={() => {
|
||||||
|
void fetchTagTree();
|
||||||
|
setSnippetListVersion(v => v + 1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||||
|
{loading ? <p className="text-sm text-muted-foreground">Lade Snippets …</p> : null}
|
||||||
|
|
||||||
|
{!loading && snippets.length === 0 ? (
|
||||||
|
<p className="text-center text-sm text-muted-foreground">Keine Snippets. Mit „Neu“ starten.</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{snippets.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-2 sm:gap-2.5 min-[1800px]:hidden">
|
||||||
|
{snippets.map(s => (
|
||||||
|
<SnippetCard
|
||||||
|
key={s.id}
|
||||||
|
snippet={s}
|
||||||
|
treeColorByName={treeColorByName}
|
||||||
|
treeDisplayByName={treeDisplayByName}
|
||||||
|
onReplace={replaceSnippet}
|
||||||
|
onRemove={removeSnippet}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="hidden min-[1800px]:flex min-[1800px]:flex-row min-[1800px]:items-start min-[1800px]:gap-x-8">
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-4">
|
||||||
|
{snippetColumnLeft.map(s => (
|
||||||
|
<SnippetCard
|
||||||
|
key={s.id}
|
||||||
|
snippet={s}
|
||||||
|
treeColorByName={treeColorByName}
|
||||||
|
treeDisplayByName={treeDisplayByName}
|
||||||
|
onReplace={replaceSnippet}
|
||||||
|
onRemove={removeSnippet}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-4">
|
||||||
|
{snippetColumnRight.map(s => (
|
||||||
|
<SnippetCard
|
||||||
|
key={s.id}
|
||||||
|
snippet={s}
|
||||||
|
treeColorByName={treeColorByName}
|
||||||
|
treeDisplayByName={treeDisplayByName}
|
||||||
|
onReplace={replaceSnippet}
|
||||||
|
onRemove={removeSnippet}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,10 @@
|
|||||||
|
export const SORT_OPTIONS = [
|
||||||
|
{ value: "updated_desc", label: "Zuletzt geändert (neu zuerst)" },
|
||||||
|
{ value: "updated_asc", label: "Zuletzt geändert (alt zuerst)" },
|
||||||
|
{ value: "deadline_asc", label: "Deadline (früheste zuerst)" },
|
||||||
|
{ value: "deadline_desc", label: "Deadline (späteste 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,285 @@
|
|||||||
|
import { Pencil, Trash2 } from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import type { MouseEvent } from "react";
|
||||||
|
import type { Snippet } from "@/types/snippet";
|
||||||
|
import { deadlineToDateInput } from "@/lib/snippetDeadline";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardFooter } from "@/components/ui/card";
|
||||||
|
import { SnippetDeadlineBadge } from "@/features/snippets/components/SnippetDeadlineBadge";
|
||||||
|
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 { toggleMarkdownTaskAt } from "@/features/snippets/lib/markdownTasks";
|
||||||
|
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 && !snippet.deadline_at;
|
||||||
|
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(pristineEmpty);
|
||||||
|
const [dirty, setDirty] = useState(false);
|
||||||
|
const [body, setBody] = useState(() => snippet.body ?? "");
|
||||||
|
const [tagsStr, setTagsStr] = useState(() => (snippet.tags ?? []).join(", "));
|
||||||
|
const [deadlineDate, setDeadlineDate] = useState(() => deadlineToDateInput(snippet.deadline_at));
|
||||||
|
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(", "));
|
||||||
|
setDeadlineDate(deadlineToDateInput(snippet.deadline_at));
|
||||||
|
}, [dialogOpen, snippet.id, snippet.updated_at, snippet.body, snippet.tags, snippet.deadline_at]);
|
||||||
|
|
||||||
|
const hydrateFromSnippet = () => {
|
||||||
|
setBody(snippet.body ?? "");
|
||||||
|
setTagsStr((snippet.tags ?? []).join(", "));
|
||||||
|
setDeadlineDate(deadlineToDateInput(snippet.deadline_at));
|
||||||
|
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),
|
||||||
|
deadline_at: deadlineDate.trim() === "" ? null : deadlineDate.trim(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMarkdownMouseDown = useCallback(
|
||||||
|
(e: MouseEvent<HTMLDivElement>) => {
|
||||||
|
const t = e.target;
|
||||||
|
if (!(t instanceof HTMLInputElement)) return;
|
||||||
|
if (!t.classList.contains("snippet-task-cb") || t.type !== "checkbox") return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const idx = Number(t.getAttribute("data-snippet-task-idx"));
|
||||||
|
if (!Number.isFinite(idx) || busy) return;
|
||||||
|
const newBody = toggleMarkdownTaskAt(snippet.body, idx);
|
||||||
|
if (newBody === null) return;
|
||||||
|
void (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: newBody }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = (await res.json().catch(() => null)) as { error?: string } | null;
|
||||||
|
throw new Error(err?.error ?? res.statusText);
|
||||||
|
}
|
||||||
|
onReplace((await res.json()) as Snippet);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Aufgabe konnte nicht gespeichert werden");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
},
|
||||||
|
[snippet.body, snippet.id, busy, onReplace],
|
||||||
|
);
|
||||||
|
|
||||||
|
async function clearDeadlineRemote() {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/snippets/${snippet.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ deadline_at: null }),
|
||||||
|
});
|
||||||
|
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);
|
||||||
|
setDeadlineDate("");
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Deadline konnte nicht entfernt werden");
|
||||||
|
} 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="space-y-1.5 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 snippet-md-viewport overflow-hidden rounded-sm">
|
||||||
|
<div
|
||||||
|
className="snippet-md max-h-full overflow-x-auto overflow-y-auto pr-0.5"
|
||||||
|
dangerouslySetInnerHTML={{ __html: viewHtml }}
|
||||||
|
onMouseDown={handleMarkdownMouseDown}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="flex flex-row flex-wrap items-center gap-2 border-t border-border/80 px-3 py-1">
|
||||||
|
<div className="snippet-tag-cloud flex min-h-7 min-w-0 flex-1 flex-wrap items-center gap-1.5">
|
||||||
|
{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">
|
||||||
|
{snippet.deadline_at ? (
|
||||||
|
<SnippetDeadlineBadge
|
||||||
|
deadlineAt={snippet.deadline_at}
|
||||||
|
busy={busy}
|
||||||
|
onMarkDone={() => void clearDeadlineRemote()}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<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);
|
||||||
|
}}
|
||||||
|
deadlineDate={deadlineDate}
|
||||||
|
onDeadlineChange={v => {
|
||||||
|
markDirty();
|
||||||
|
setDeadlineDate(v);
|
||||||
|
}}
|
||||||
|
busy={busy}
|
||||||
|
error={error}
|
||||||
|
onSave={() => void save()}
|
||||||
|
onDismiss={requestDismiss}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { Check } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { deadlineBadgeClass, deadlineTone, formatDeadlineLabel } from "@/lib/snippetDeadline";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
deadlineAt: string;
|
||||||
|
busy?: boolean;
|
||||||
|
onMarkDone?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SnippetDeadlineBadge({ deadlineAt, busy, onMarkDone }: Props) {
|
||||||
|
const tone = deadlineTone(deadlineAt);
|
||||||
|
return (
|
||||||
|
<div className="contents">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-[10px] font-medium tabular-nums",
|
||||||
|
deadlineBadgeClass(tone),
|
||||||
|
)}
|
||||||
|
title="Deadline"
|
||||||
|
>
|
||||||
|
{formatDeadlineLabel(deadlineAt)}
|
||||||
|
</span>
|
||||||
|
{onMarkDone ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="size-7 text-muted-foreground hover:text-foreground"
|
||||||
|
disabled={busy}
|
||||||
|
title="Erledigt — Deadline entfernen"
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
onMarkDone();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check className="size-3.5" aria-hidden />
|
||||||
|
<span className="sr-only">Deadline entfernen</span>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import type { MouseEvent } 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 { DateInput } from "@/components/ui/date-input";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
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 { toggleMarkdownTaskAt } from "@/features/snippets/lib/markdownTasks";
|
||||||
|
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;
|
||||||
|
deadlineDate: string;
|
||||||
|
onDeadlineChange: (v: string) => void;
|
||||||
|
busy: boolean;
|
||||||
|
error: string | null;
|
||||||
|
onSave: () => void;
|
||||||
|
onDismiss: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SnippetEditDialog({
|
||||||
|
snippet,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
treeColorByName,
|
||||||
|
treeDisplayByName,
|
||||||
|
body,
|
||||||
|
tagsStr,
|
||||||
|
onBodyChange,
|
||||||
|
onTagsStrChange,
|
||||||
|
deadlineDate,
|
||||||
|
onDeadlineChange,
|
||||||
|
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]);
|
||||||
|
|
||||||
|
const handlePreviewMouseDown = useCallback(
|
||||||
|
(e: MouseEvent<HTMLDivElement>) => {
|
||||||
|
const t = e.target;
|
||||||
|
if (!(t instanceof HTMLInputElement)) return;
|
||||||
|
if (!t.classList.contains("snippet-task-cb") || t.type !== "checkbox") return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const idx = Number(t.getAttribute("data-snippet-task-idx"));
|
||||||
|
if (!Number.isFinite(idx) || busy) return;
|
||||||
|
const next = toggleMarkdownTaskAt(body, idx);
|
||||||
|
if (next !== null) onBodyChange(next);
|
||||||
|
},
|
||||||
|
[body, busy, onBodyChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
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="snippet-tag-cloud 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 }}
|
||||||
|
onMouseDown={handlePreviewMouseDown}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 flex-wrap items-end gap-2">
|
||||||
|
<div className="min-w-[11rem] flex-1 space-y-1">
|
||||||
|
<Label htmlFor="snippet-deadline" className="text-xs text-muted-foreground">
|
||||||
|
Deadline (optional)
|
||||||
|
</Label>
|
||||||
|
<DateInput id="snippet-deadline" value={deadlineDate} onChange={e => onDeadlineChange(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-9 shrink-0"
|
||||||
|
disabled={!deadlineDate}
|
||||||
|
onClick={() => onDeadlineChange("")}
|
||||||
|
>
|
||||||
|
Deadline entfernen
|
||||||
|
</Button>
|
||||||
|
</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,45 @@
|
|||||||
|
import { Hash } from "lucide-react";
|
||||||
|
import type { CSSProperties } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
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={cn(
|
||||||
|
"tag-pill inline-flex max-w-full items-center gap-1 rounded-lg border px-2 py-0.5 text-[11px] font-medium leading-tight shadow-sm ring-1 ring-black/[0.04] transition-[box-shadow,transform,background-color] duration-150 dark:ring-white/[0.06]",
|
||||||
|
has
|
||||||
|
? "shadow-[0_1px_2px_rgba(0,0,0,0.06)] dark:shadow-[0_1px_2px_rgba(0,0,0,0.35)]"
|
||||||
|
: "border-border/70 bg-gradient-to-b from-muted/90 to-muted/45 text-muted-foreground dark:from-muted/50 dark:to-muted/25",
|
||||||
|
)}
|
||||||
|
style={
|
||||||
|
has
|
||||||
|
? ({
|
||||||
|
borderColor: `${hex}cc`,
|
||||||
|
background: `linear-gradient(165deg, color-mix(in oklch, ${hex} 28%, transparent) 0%, color-mix(in oklch, ${hex} 12%, transparent) 100%)`,
|
||||||
|
boxShadow: `0 1px 2px color-mix(in oklch, ${hex} 35%, transparent), inset 0 1px 0 color-mix(in oklch, white 22%, transparent)`,
|
||||||
|
color: `color-mix(in oklch, ${hex} 12%, var(--foreground))`,
|
||||||
|
} as CSSProperties)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
title={tagKey === label ? tagKey : `${label} (${tagKey})`}
|
||||||
|
>
|
||||||
|
{has ? (
|
||||||
|
<span
|
||||||
|
className="size-1.5 shrink-0 rounded-full ring-1 ring-black/10 dark:ring-white/15"
|
||||||
|
style={{ backgroundColor: hex! }}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Hash className="size-2.5 shrink-0 opacity-55" aria-hidden />
|
||||||
|
)}
|
||||||
|
<span className="min-w-0 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,31 @@
|
|||||||
|
/**
|
||||||
|
* GFM-Tasklisten (`- [ ]` / `- [x]`) in Dokumentreihenfolge erkennen und umschalten.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** GFM task lines: `- [ ]`, `* [x]`, `1. [ ]`; optional indent; text after `]` optional. */
|
||||||
|
const TASK_LINE =
|
||||||
|
/^(\s*)([-*+]|\d+\.)\s+\[([ xX])\]\s*(.*)$/;
|
||||||
|
|
||||||
|
export function listMarkdownTaskLineIndices(body: string): number[] {
|
||||||
|
const lines = body.split(/\r?\n/);
|
||||||
|
const out: number[] = [];
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
if (TASK_LINE.test(lines[i]!)) out.push(i);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kippt die n-te Task-Zeile im Dokument (`[ ]` ↔ `[x]`). `null` bei ungültigem Index. */
|
||||||
|
export function toggleMarkdownTaskAt(body: string, taskIndex: number): string | null {
|
||||||
|
const lines = body.split(/\r?\n/);
|
||||||
|
const taskLineIndices = listMarkdownTaskLineIndices(body);
|
||||||
|
if (taskIndex < 0 || taskIndex >= taskLineIndices.length) return null;
|
||||||
|
const li = taskLineIndices[taskIndex]!;
|
||||||
|
const line = lines[li]!;
|
||||||
|
const m = line.match(TASK_LINE);
|
||||||
|
if (!m) return null;
|
||||||
|
const checked = m[3] === "x" || m[3] === "X";
|
||||||
|
const next = checked ? " " : "x";
|
||||||
|
lines[li] = line.replace(/\[[ xX]\]/, `[${next}]`);
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import hljs from "highlight.js/lib/common";
|
||||||
|
import { marked } from "marked";
|
||||||
|
|
||||||
|
marked.setOptions({ gfm: true, pedantic: false });
|
||||||
|
|
||||||
|
/** Pro `parseSnippetMarkdown`-Aufruf: Checkbox-Indizes von 0 … */
|
||||||
|
const taskCheckboxSeq = { n: 0 };
|
||||||
|
|
||||||
|
/** Markdown-Sprachkennungen → highlight.js-Name */
|
||||||
|
const LANG_ALIASES: Record<string, string> = {
|
||||||
|
ts: "typescript",
|
||||||
|
tsx: "typescript",
|
||||||
|
js: "javascript",
|
||||||
|
mjs: "javascript",
|
||||||
|
cjs: "javascript",
|
||||||
|
sh: "bash",
|
||||||
|
zsh: "bash",
|
||||||
|
yml: "yaml",
|
||||||
|
md: "markdown",
|
||||||
|
rs: "rust",
|
||||||
|
py: "python",
|
||||||
|
rb: "ruby",
|
||||||
|
kt: "kotlin",
|
||||||
|
kts: "kotlin",
|
||||||
|
text: "plaintext",
|
||||||
|
txt: "plaintext",
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveLang(raw: string | undefined): string {
|
||||||
|
const t = (raw ?? "").trim().toLowerCase();
|
||||||
|
if (!t) return "plaintext";
|
||||||
|
return LANG_ALIASES[t] ?? t;
|
||||||
|
}
|
||||||
|
|
||||||
|
function langClassSegment(lang: string): string {
|
||||||
|
return lang.replace(/[^a-z0-9_-]/gi, "-") || "plaintext";
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
let markedExtensionsConfigured = false;
|
||||||
|
|
||||||
|
function ensureMarkedExtensions(): void {
|
||||||
|
if (markedExtensionsConfigured) return;
|
||||||
|
markedExtensionsConfigured = true;
|
||||||
|
marked.use({
|
||||||
|
renderer: {
|
||||||
|
code({ text, lang }) {
|
||||||
|
const key = resolveLang(lang);
|
||||||
|
const use = hljs.getLanguage(key) ? key : "plaintext";
|
||||||
|
try {
|
||||||
|
const highlighted = hljs.highlight(text, { language: use, ignoreIllegals: true }).value;
|
||||||
|
return `<pre class="snippet-pre"><code class="hljs language-${langClassSegment(use)}">${highlighted}</code></pre>\n`;
|
||||||
|
} catch {
|
||||||
|
return `<pre class="snippet-pre"><code class="hljs language-plaintext">${escapeHtml(text)}</code></pre>\n`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
checkbox({ checked }) {
|
||||||
|
const idx = taskCheckboxSeq.n++;
|
||||||
|
const chk = checked ? " checked" : "";
|
||||||
|
return `<input type="checkbox" class="snippet-task-cb"${chk} data-snippet-task-idx="${idx}" aria-label="Aufgabe umschalten" /> `;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureMarkedExtensions();
|
||||||
|
|
||||||
|
export function parseSnippetMarkdown(md: string): string {
|
||||||
|
taskCheckboxSeq.n = 0;
|
||||||
|
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]);
|
||||||
|
}
|
||||||
+376
@@ -10,6 +10,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
/* src: url('./assets/fonts/Inter-Variable.ttf') format('truetype'); */
|
||||||
|
src: url('./assets/fonts/Inter-var.woff2') format('woff2');
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/* cool Bun background animation 😎 */
|
/* cool Bun background animation 😎 */
|
||||||
body::before {
|
body::before {
|
||||||
content: "";
|
content: "";
|
||||||
@@ -49,3 +59,369 @@ body::before {
|
|||||||
animation: none !important;
|
animation: none !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Markdown rendering (marked HTML) */
|
||||||
|
@layer components {
|
||||||
|
.snippet-md {
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-family: 'Inter';
|
||||||
|
}
|
||||||
|
|
||||||
|
.snippet-md :where(p) {
|
||||||
|
margin-block: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snippet-md :where(a) {
|
||||||
|
color: var(--primary);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snippet-md :where(a):hover {
|
||||||
|
text-decoration-thickness: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snippet-md :where(blockquote) {
|
||||||
|
margin-block: 0.5rem;
|
||||||
|
border-left: 2px solid var(--border);
|
||||||
|
padding-left: 0.75rem;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.snippet-md :where(code:not(.hljs)) {
|
||||||
|
border-radius: var(--radius-sm, 0.25rem);
|
||||||
|
background-color: var(--muted);
|
||||||
|
padding: 0.125rem 0.25rem;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snippet-md :where(pre:not(.snippet-pre)) {
|
||||||
|
margin-block: 0.5rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
border-radius: var(--radius-md, 0.375rem);
|
||||||
|
background-color: var(--muted);
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snippet-md :where(pre:not(.snippet-pre) code) {
|
||||||
|
background-color: transparent;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Highlight.js Codeblöcke */
|
||||||
|
.snippet-md :where(pre.snippet-pre) {
|
||||||
|
margin-block: 0.5rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
border-radius: var(--radius-md, 0.375rem);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background-color: color-mix(in oklch, var(--muted) 88%, var(--background));
|
||||||
|
padding: 0.65rem 0.75rem;
|
||||||
|
box-shadow: inset 0 1px 0 color-mix(in oklch, white 55%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .snippet-md :where(pre.snippet-pre) {
|
||||||
|
box-shadow: inset 0 1px 0 color-mix(in oklch, white 8%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.snippet-md :where(pre.snippet-pre code.hljs) {
|
||||||
|
display: block;
|
||||||
|
width: max-content;
|
||||||
|
min-width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
tab-size: 2;
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snippet-tag-cloud {
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
border: 1px solid color-mix(in oklch, var(--border) 85%, transparent);
|
||||||
|
background: linear-gradient(
|
||||||
|
165deg,
|
||||||
|
color-mix(in oklch, var(--muted) 55%, transparent) 0%,
|
||||||
|
color-mix(in oklch, var(--muted) 22%, transparent) 100%
|
||||||
|
);
|
||||||
|
padding: 0.4rem 0.5rem;
|
||||||
|
box-shadow: inset 0 1px 0 color-mix(in oklch, white 40%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .snippet-tag-cloud {
|
||||||
|
box-shadow: inset 0 1px 0 color-mix(in oklch, white 6%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.snippet-md :where(h1) {
|
||||||
|
margin-block: 0 0.5rem;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snippet-md :where(h2) {
|
||||||
|
margin-block: 0.75rem 0.5rem;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snippet-md :where(h3) {
|
||||||
|
margin-block: 0.5rem 0.25rem;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Listen: outside + Einzug, damit Verschachtelung sichtbar ist */
|
||||||
|
.snippet-md :where(ul, ol) {
|
||||||
|
margin-block: 0.5rem;
|
||||||
|
list-style-position: outside;
|
||||||
|
padding-inline-start: 1.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snippet-md :where(ul) {
|
||||||
|
list-style-type: disc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snippet-md :where(ol) {
|
||||||
|
list-style-type: decimal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snippet-md :where(li) {
|
||||||
|
margin-block: 0.2rem;
|
||||||
|
padding-inline-start: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snippet-md :where(li > ul, li > ol) {
|
||||||
|
margin-block: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snippet-md input.snippet-task-cb {
|
||||||
|
margin-inline-end: 0.35rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
accent-color: var(--primary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Karteninhalt: begrenzte Höhe, langer Text scrollt */
|
||||||
|
.snippet-md-viewport {
|
||||||
|
max-height: min(52vh, 28rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.snippet-md :where(table) {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snippet-md :where(th, td) {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snippet-md :where(hr) {
|
||||||
|
margin-block: 1rem;
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.snippet-md :where(img) {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Snippet-Karten: etwas dichter, Fokus auf Fließtext */
|
||||||
|
.snippet-md-card-wrap .snippet-md {
|
||||||
|
font-size: 0.76rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snippet-md-card-wrap .snippet-md :where(p) {
|
||||||
|
margin-block: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snippet-md-card-wrap .snippet-md :where(p:first-child) {
|
||||||
|
margin-block-start: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snippet-md-card-wrap .snippet-md :where(p:last-child) {
|
||||||
|
margin-block-end: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snippet-md-card-wrap .snippet-md :where(h1, h2, h3) {
|
||||||
|
margin-block-start: 0.5rem;
|
||||||
|
margin-block-end: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snippet-md-card-wrap .snippet-md :where(pre:not(.snippet-pre)) {
|
||||||
|
margin-block: 0.375rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snippet-md-card-wrap .snippet-md :where(pre.snippet-pre) {
|
||||||
|
margin-block: 0.375rem;
|
||||||
|
padding: 0.45rem 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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 max-h-[min(50vh,26rem)] 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Native <input type="date"> — Feld, WebKit-Segmente & Kalender-Icon.
|
||||||
|
* Das aufklappende Kalender-Popup gestaltet weiterhin der Browser.
|
||||||
|
*/
|
||||||
|
.date-field {
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .date-field {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-field::-webkit-datetime-edit,
|
||||||
|
.date-field::-webkit-datetime-edit-fields-wrapper {
|
||||||
|
padding: 0.05rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-field::-webkit-datetime-edit-text {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
padding: 0 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-field::-webkit-datetime-edit-day-field,
|
||||||
|
.date-field::-webkit-datetime-edit-month-field,
|
||||||
|
.date-field::-webkit-datetime-edit-year-field {
|
||||||
|
padding: 0.1rem 0.2rem;
|
||||||
|
border-radius: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-field:focus::-webkit-datetime-edit-day-field,
|
||||||
|
.date-field:focus::-webkit-datetime-edit-month-field,
|
||||||
|
.date-field:focus::-webkit-datetime-edit-year-field,
|
||||||
|
.date-field:focus-visible::-webkit-datetime-edit-day-field,
|
||||||
|
.date-field:focus-visible::-webkit-datetime-edit-month-field,
|
||||||
|
.date-field:focus-visible::-webkit-datetime-edit-year-field {
|
||||||
|
background-color: color-mix(in oklch, var(--accent) 45%, transparent);
|
||||||
|
color: var(--accent-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-field::-webkit-calendar-picker-indicator {
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.65;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
padding: 0.15rem 0.35rem;
|
||||||
|
margin-inline-start: 0.125rem;
|
||||||
|
transition: opacity 0.12s ease, background-color 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-field:hover::-webkit-calendar-picker-indicator {
|
||||||
|
opacity: 1;
|
||||||
|
background-color: color-mix(in oklch, var(--muted) 75%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-field:focus::-webkit-calendar-picker-indicator,
|
||||||
|
.date-field:focus-visible::-webkit-calendar-picker-indicator {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+4
-98
@@ -1,109 +1,15 @@
|
|||||||
import { serve } from "bun";
|
import { serve } from "bun";
|
||||||
import index from "./index.html";
|
import index from "./index.html";
|
||||||
import { getDb } from "./server/db";
|
import { getDb } from "./server/db";
|
||||||
import { createSnippet, deleteSnippet, getSnippetById, listSnippets, updateSnippet } from "./server/snippets";
|
import { snippetRoutes } from "./server/routes/snippetsHandlers";
|
||||||
|
import { tagTreeRoutes } from "./server/routes/tagTreeHandlers";
|
||||||
|
|
||||||
getDb();
|
getDb();
|
||||||
|
|
||||||
async function readJson(req: Request): Promise<unknown> {
|
|
||||||
try {
|
|
||||||
return await req.json();
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const server = serve({
|
const server = serve({
|
||||||
routes: {
|
routes: {
|
||||||
"/api/snippets": {
|
...snippetRoutes,
|
||||||
async GET(req) {
|
...tagTreeRoutes,
|
||||||
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/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 });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
"/*": index,
|
"/*": index,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/** Kalendertag lokal als `YYYY-MM-DD` (für Vergleich mit DB). */
|
||||||
|
export function localDateKey(d: Date): string {
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(d.getDate()).padStart(2, "0");
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** UI-Wert für `<input type="date" />` aus API-Wert. */
|
||||||
|
export function deadlineToDateInput(deadlineAt: string | null | undefined): string {
|
||||||
|
if (!deadlineAt) return "";
|
||||||
|
const s = deadlineAt.trim();
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s;
|
||||||
|
const t = new Date(s);
|
||||||
|
if (Number.isNaN(t.getTime())) return "";
|
||||||
|
return localDateKey(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DeadlineTone = "none" | "upcoming" | "today" | "overdue";
|
||||||
|
|
||||||
|
/** Blau = künftig (nicht heute), Orange = heute fällig, Rot = überschritten. */
|
||||||
|
export function deadlineTone(deadlineAt: string | null | undefined): DeadlineTone {
|
||||||
|
if (!deadlineAt) return "none";
|
||||||
|
const key = deadlineToDateInput(deadlineAt);
|
||||||
|
if (!key) return "none";
|
||||||
|
const today = localDateKey(new Date());
|
||||||
|
if (key < today) return "overdue";
|
||||||
|
if (key === today) return "today";
|
||||||
|
return "upcoming";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deadlineBadgeClass(tone: DeadlineTone): string {
|
||||||
|
switch (tone) {
|
||||||
|
case "upcoming":
|
||||||
|
return "text-blue-500/70 text-blue-800 dark:text-blue-200";
|
||||||
|
case "today":
|
||||||
|
return "text-orange-500/80 text-orange-900 dark:text-orange-100";
|
||||||
|
case "overdue":
|
||||||
|
return "text-destructive/80 text-destructive";
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDeadlineLabel(deadlineAt: string): string {
|
||||||
|
const key = deadlineToDateInput(deadlineAt);
|
||||||
|
if (!key) return deadlineAt;
|
||||||
|
try {
|
||||||
|
const [y, m, d] = key.split("-").map(Number);
|
||||||
|
const dt = new Date(y, m - 1, d);
|
||||||
|
return dt.toLocaleDateString(undefined, { dateStyle: "medium" });
|
||||||
|
} catch {
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/** Darstellung: optionaler Prefix + Leerzeichen + kanonischer Name (z. B. Projekt: sp-migrator). */
|
||||||
|
export function formatTreeTagDisplay(name: string, labelPrefix: string | null | undefined): string {
|
||||||
|
const p = labelPrefix?.trim();
|
||||||
|
if (!p) return name;
|
||||||
|
return `${p} ${name}`.trim();
|
||||||
|
}
|
||||||
+2
-27
@@ -2,34 +2,9 @@ import { Database } from "bun:sqlite";
|
|||||||
import { mkdirSync } from "fs";
|
import { mkdirSync } from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
let dbInstance: Database | null = null;
|
import { migrate } from "./db/migrate";
|
||||||
|
|
||||||
function migrate(database: Database) {
|
let dbInstance: Database | null = null;
|
||||||
database.run(`
|
|
||||||
CREATE TABLE IF NOT EXISTS snippets (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
title TEXT,
|
|
||||||
body TEXT NOT NULL DEFAULT '',
|
|
||||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
updated_at TEXT NOT NULL
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
database.run(`
|
|
||||||
CREATE TABLE IF NOT EXISTS tags (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
name TEXT NOT NULL UNIQUE COLLATE NOCASE
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
database.run(`
|
|
||||||
CREATE TABLE IF NOT EXISTS snippet_tags (
|
|
||||||
snippet_id INTEGER NOT NULL REFERENCES snippets(id) ON DELETE CASCADE,
|
|
||||||
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
|
||||||
PRIMARY KEY (snippet_id, tag_id)
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
database.run(`CREATE INDEX IF NOT EXISTS idx_snippets_updated ON snippets(updated_at);`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDb(): Database {
|
export function getDb(): Database {
|
||||||
if (dbInstance) return dbInstance;
|
if (dbInstance) return dbInstance;
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import type { Database } from "bun:sqlite";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einmaliges Kernschema — so, als würde eine leere DB zum ersten Mal angelegt.
|
||||||
|
* `CREATE TABLE IF NOT EXISTS` ändert bestehende Tabellen nicht; fehlende Spalten
|
||||||
|
* werden danach idempotent per `ensureColumn` nachgezogen (bestehende Zeilen bleiben).
|
||||||
|
*/
|
||||||
|
|
||||||
|
function assertSqlIdent(name: string, label: string): void {
|
||||||
|
if (!/^[a-z][a-z0-9_]*$/i.test(name)) {
|
||||||
|
throw new Error(`${label} ist kein gültiger SQL-Identifier: ${name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function tableHasColumn(database: Database, table: string, column: string): boolean {
|
||||||
|
assertSqlIdent(table, "Tabellenname");
|
||||||
|
assertSqlIdent(column, "Spaltenname");
|
||||||
|
const cols = database.query(`PRAGMA table_info(${table})`).all() as { name: string }[];
|
||||||
|
return cols.some(c => c.name === column);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fügt eine Spalte nur hinzu, wenn sie noch fehlt (keine Exceptions als Steuerung). */
|
||||||
|
function ensureColumn(database: Database, table: string, column: string, sqlType: string): void {
|
||||||
|
if (tableHasColumn(database, table, column)) return;
|
||||||
|
assertSqlIdent(table, "Tabellenname");
|
||||||
|
assertSqlIdent(column, "Spaltenname");
|
||||||
|
database.run(`ALTER TABLE ${table} ADD COLUMN ${column} ${sqlType}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function migrate(database: Database): void {
|
||||||
|
database.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS snippets (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
title TEXT,
|
||||||
|
body TEXT NOT NULL DEFAULT '',
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
deadline_at TEXT
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
database.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS tags (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL UNIQUE COLLATE NOCASE
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
database.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS snippet_tags (
|
||||||
|
snippet_id INTEGER NOT NULL REFERENCES snippets(id) ON DELETE CASCADE,
|
||||||
|
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (snippet_id, tag_id)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
database.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS tag_tree_nodes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||||
|
parent_id INTEGER REFERENCES tag_tree_nodes(id) ON DELETE CASCADE,
|
||||||
|
color TEXT,
|
||||||
|
label_prefix TEXT
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
/* Bestehende Installationen: Tabellen schon da, einzelne Spalten fehlen → ALTER nur bei Bedarf */
|
||||||
|
// ensureColumn(database, "snippets", "deadline_at", "TEXT");
|
||||||
|
// ensureColumn(database, "tag_tree_nodes", "label_prefix", "TEXT");
|
||||||
|
|
||||||
|
database.run(`CREATE INDEX IF NOT EXISTS idx_tag_tree_parent ON tag_tree_nodes(parent_id);`);
|
||||||
|
database.run(`CREATE INDEX IF NOT EXISTS idx_snippets_updated ON snippets(updated_at);`);
|
||||||
|
database.run(`CREATE INDEX IF NOT EXISTS idx_snippets_deadline ON snippets(deadline_at);`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export async function readJson(req: Request): Promise<unknown> {
|
||||||
|
try {
|
||||||
|
return await req.json();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { readJson } from "@/server/http/readJson";
|
||||||
|
import {
|
||||||
|
createSnippet,
|
||||||
|
deleteSnippet,
|
||||||
|
getSnippetById,
|
||||||
|
listSnippets,
|
||||||
|
normalizeDeadlineAtInput,
|
||||||
|
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") : [];
|
||||||
|
let deadline_at: string | null | undefined;
|
||||||
|
if ("deadline_at" in o) {
|
||||||
|
try {
|
||||||
|
deadline_at = normalizeDeadlineAtInput(o.deadline_at);
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : "deadline_at ungültig";
|
||||||
|
return Response.json({ error: msg }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const snippet = createSnippet({
|
||||||
|
title:
|
||||||
|
o.title === null ? null : typeof o.title === "string" ? o.title : undefined,
|
||||||
|
body: typeof o.body === "string" ? o.body : "",
|
||||||
|
tags,
|
||||||
|
deadline_at,
|
||||||
|
});
|
||||||
|
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;
|
||||||
|
deadline_at?: string | null;
|
||||||
|
} = {};
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
if ("deadline_at" in o) {
|
||||||
|
try {
|
||||||
|
patch.deadline_at = normalizeDeadlineAtInput(o.deadline_at);
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : "deadline_at ungültig";
|
||||||
|
return Response.json({ error: msg }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
+113
-27
@@ -1,5 +1,15 @@
|
|||||||
import type { Database } from "bun:sqlite";
|
import type { Database } from "bun:sqlite";
|
||||||
import { getDb } from "./db";
|
import { getDb } from "./db";
|
||||||
|
import {
|
||||||
|
colorsForTagNames,
|
||||||
|
decodeSnippetTagDisplay,
|
||||||
|
encodeTreeTagStorage,
|
||||||
|
escapeLike,
|
||||||
|
expandExactTreeTagNames,
|
||||||
|
matchingTreeTagNamesForLikePattern,
|
||||||
|
treeStorageRefsForDisplayNames,
|
||||||
|
treeTagDisplayForCanonicalName,
|
||||||
|
} from "./tagTree";
|
||||||
|
|
||||||
export type SnippetRow = {
|
export type SnippetRow = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -8,21 +18,33 @@ export type SnippetRow = {
|
|||||||
sort_order: number;
|
sort_order: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
deadline_at: string | null;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
|
/** Anzeige-Label pro Tag (Prefix + Name), gleiche Reihenfolge wie `tags`. */
|
||||||
|
tag_display: string[];
|
||||||
|
/** Farben aus dem Tag-Baum (Schlüssel = Tag-String wie am Snippet, Wert = #rrggbb oder null). */
|
||||||
|
tag_colors: Record<string, string | null>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function escapeLike(raw: string): string {
|
/** PATCH/POST: `undefined` = nicht ändern, `null` = entfernen. */
|
||||||
return raw.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_");
|
export function normalizeDeadlineAtInput(raw: unknown): string | null | undefined {
|
||||||
|
if (raw === undefined) return undefined;
|
||||||
|
if (raw === null) return null;
|
||||||
|
if (typeof raw !== "string") throw new Error("deadline_at muss ein String (YYYY-MM-DD) sein");
|
||||||
|
const t = raw.trim();
|
||||||
|
if (t === "") return null;
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(t)) throw new Error("deadline_at muss YYYY-MM-DD sein");
|
||||||
|
return t;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `tag:x`, `title:y`, `content:z` (case-insensitive); remainder = default (title OR tag names, LIKE). All parts AND. */
|
/** `tag:x`, `title:y`, `content:z` (case-insensitive); remainder = default (title OR tag names, LIKE). All parts AND. */
|
||||||
function parseSearchQuery(raw: string): {
|
function parseSearchQuery(raw: string): {
|
||||||
defaultText: string | null;
|
defaultText: string | null;
|
||||||
tagTerms: string[];
|
tagTermsRaw: string[];
|
||||||
titleTerms: string[];
|
titleTerms: string[];
|
||||||
contentTerms: string[];
|
contentTerms: string[];
|
||||||
} {
|
} {
|
||||||
const tagTerms: string[] = [];
|
const tagTermsRaw: string[] = [];
|
||||||
const titleTerms: string[] = [];
|
const titleTerms: string[] = [];
|
||||||
const contentTerms: string[] = [];
|
const contentTerms: string[] = [];
|
||||||
const re = /(?:^|\s)(tag|title|content):(\S+)/gi;
|
const re = /(?:^|\s)(tag|title|content):(\S+)/gi;
|
||||||
@@ -33,7 +55,7 @@ function parseSearchQuery(raw: string): {
|
|||||||
const val = m[2];
|
const val = m[2];
|
||||||
if (!val) continue;
|
if (!val) continue;
|
||||||
const escaped = escapeLike(val);
|
const escaped = escapeLike(val);
|
||||||
if (kind === "tag") tagTerms.push(escaped);
|
if (kind === "tag") tagTermsRaw.push(val);
|
||||||
else if (kind === "title") titleTerms.push(escaped);
|
else if (kind === "title") titleTerms.push(escaped);
|
||||||
else if (kind === "content") contentTerms.push(escaped);
|
else if (kind === "content") contentTerms.push(escaped);
|
||||||
spans.push([m.index, m.index + m[0].length]);
|
spans.push([m.index, m.index + m[0].length]);
|
||||||
@@ -44,7 +66,7 @@ function parseSearchQuery(raw: string): {
|
|||||||
}
|
}
|
||||||
const defaultTrim = rest.replace(/\s+/g, " ").trim();
|
const defaultTrim = rest.replace(/\s+/g, " ").trim();
|
||||||
const defaultText = defaultTrim === "" ? null : escapeLike(defaultTrim);
|
const defaultText = defaultTrim === "" ? null : escapeLike(defaultTrim);
|
||||||
return { defaultText, tagTerms, titleTerms, contentTerms };
|
return { defaultText, tagTermsRaw, titleTerms, contentTerms };
|
||||||
}
|
}
|
||||||
|
|
||||||
function nowIso(): string {
|
function nowIso(): string {
|
||||||
@@ -59,6 +81,10 @@ function orderClause(sort: string): string {
|
|||||||
return "ORDER BY s.title COLLATE NOCASE ASC NULLS LAST, s.updated_at DESC";
|
return "ORDER BY s.title COLLATE NOCASE ASC NULLS LAST, s.updated_at DESC";
|
||||||
case "title_desc":
|
case "title_desc":
|
||||||
return "ORDER BY s.title COLLATE NOCASE DESC NULLS LAST, s.updated_at DESC";
|
return "ORDER BY s.title COLLATE NOCASE DESC NULLS LAST, s.updated_at DESC";
|
||||||
|
case "deadline_asc":
|
||||||
|
return "ORDER BY s.deadline_at ASC NULLS LAST, s.updated_at DESC";
|
||||||
|
case "deadline_desc":
|
||||||
|
return "ORDER BY s.deadline_at DESC NULLS LAST, s.updated_at DESC";
|
||||||
case "manual":
|
case "manual":
|
||||||
return "ORDER BY s.sort_order ASC, s.updated_at DESC";
|
return "ORDER BY s.sort_order ASC, s.updated_at DESC";
|
||||||
case "updated_desc":
|
case "updated_desc":
|
||||||
@@ -89,6 +115,7 @@ function loadTagsForSnippetIds(database: Database, ids: number[]): Map<number, s
|
|||||||
}
|
}
|
||||||
|
|
||||||
function rowToPublic(
|
function rowToPublic(
|
||||||
|
database: Database,
|
||||||
row: {
|
row: {
|
||||||
id: number;
|
id: number;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
@@ -96,9 +123,17 @@ function rowToPublic(
|
|||||||
sort_order: number;
|
sort_order: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
deadline_at: string | null;
|
||||||
},
|
},
|
||||||
tags: string[],
|
tagsRaw: string[],
|
||||||
): SnippetRow {
|
): SnippetRow {
|
||||||
|
const tags = tagsRaw.map(t => decodeSnippetTagDisplay(database, t));
|
||||||
|
const tag_display = tags.map(t => treeTagDisplayForCanonicalName(database, t));
|
||||||
|
const cmap = colorsForTagNames(database, tags);
|
||||||
|
const tag_colors: Record<string, string | null> = {};
|
||||||
|
for (const t of tags) {
|
||||||
|
tag_colors[t] = cmap.get(t) ?? null;
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
title: row.title,
|
title: row.title,
|
||||||
@@ -106,7 +141,10 @@ function rowToPublic(
|
|||||||
sort_order: row.sort_order,
|
sort_order: row.sort_order,
|
||||||
created_at: row.created_at,
|
created_at: row.created_at,
|
||||||
updated_at: row.updated_at,
|
updated_at: row.updated_at,
|
||||||
|
deadline_at: row.deadline_at,
|
||||||
tags,
|
tags,
|
||||||
|
tag_display,
|
||||||
|
tag_colors,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,12 +160,13 @@ export function listSnippets(q: string | null, sort: string | null): SnippetRow[
|
|||||||
sort_order: number;
|
sort_order: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
deadline_at: string | null;
|
||||||
}[];
|
}[];
|
||||||
|
|
||||||
if (trimmed === "") {
|
if (trimmed === "") {
|
||||||
rows = database
|
rows = database
|
||||||
.query(
|
.query(
|
||||||
`SELECT s.id, s.title, s.body, s.sort_order, s.created_at, s.updated_at
|
`SELECT s.id, s.title, s.body, s.sort_order, s.created_at, s.updated_at, s.deadline_at
|
||||||
FROM snippets s
|
FROM snippets s
|
||||||
${order}`,
|
${order}`,
|
||||||
)
|
)
|
||||||
@@ -138,25 +177,64 @@ export function listSnippets(q: string | null, sort: string | null): SnippetRow[
|
|||||||
const params: string[] = [];
|
const params: string[] = [];
|
||||||
|
|
||||||
if (parsed.defaultText !== null) {
|
if (parsed.defaultText !== null) {
|
||||||
conditions.push(`(
|
const treeTagNames = matchingTreeTagNamesForLikePattern(database, parsed.defaultText);
|
||||||
(s.title IS NOT NULL AND s.title LIKE '%' || ? || '%' ESCAPE '\\')
|
const parts: string[] = [
|
||||||
OR EXISTS (
|
`(s.title IS NOT NULL AND s.title LIKE '%' || ? || '%' ESCAPE '\\')`,
|
||||||
|
`EXISTS (
|
||||||
SELECT 1 FROM snippet_tags st
|
SELECT 1 FROM snippet_tags st
|
||||||
JOIN tags t ON t.id = st.tag_id
|
JOIN tags t ON t.id = st.tag_id
|
||||||
WHERE st.snippet_id = s.id
|
WHERE st.snippet_id = s.id
|
||||||
AND t.name LIKE '%' || ? || '%' ESCAPE '\\'
|
AND t.name LIKE '%' || ? || '%' ESCAPE '\\'
|
||||||
)
|
)`,
|
||||||
)`);
|
];
|
||||||
params.push(parsed.defaultText, parsed.defaultText);
|
params.push(parsed.defaultText, parsed.defaultText);
|
||||||
|
if (treeTagNames.length > 0) {
|
||||||
|
const lower = treeTagNames.map(n => n.toLowerCase());
|
||||||
|
const phLower = lower.map(() => "?").join(", ");
|
||||||
|
const refs = treeStorageRefsForDisplayNames(database, treeTagNames);
|
||||||
|
const sub: string[] = [`lower(t.name) IN (${phLower})`];
|
||||||
|
params.push(...lower);
|
||||||
|
if (refs.length > 0) {
|
||||||
|
const phRefs = refs.map(() => "?").join(", ");
|
||||||
|
sub.push(`t.name IN (${phRefs})`);
|
||||||
|
params.push(...refs);
|
||||||
|
}
|
||||||
|
parts.push(`EXISTS (
|
||||||
|
SELECT 1 FROM snippet_tags st
|
||||||
|
JOIN tags t ON t.id = st.tag_id
|
||||||
|
WHERE st.snippet_id = s.id
|
||||||
|
AND (${sub.join(" OR ")})
|
||||||
|
)`);
|
||||||
|
}
|
||||||
|
conditions.push(`(${parts.join(" OR ")})`);
|
||||||
}
|
}
|
||||||
for (const term of parsed.tagTerms) {
|
for (const raw of parsed.tagTermsRaw) {
|
||||||
conditions.push(`EXISTS (
|
const expanded = expandExactTreeTagNames(database, raw);
|
||||||
|
if (expanded !== null && expanded.length > 0) {
|
||||||
|
const lower = expanded.map(n => n.toLowerCase());
|
||||||
|
const refs = treeStorageRefsForDisplayNames(database, expanded);
|
||||||
|
const sub: string[] = [`lower(t.name) IN (${lower.map(() => "?").join(", ")})`];
|
||||||
|
params.push(...lower);
|
||||||
|
if (refs.length > 0) {
|
||||||
|
sub.push(`t.name IN (${refs.map(() => "?").join(", ")})`);
|
||||||
|
params.push(...refs);
|
||||||
|
}
|
||||||
|
conditions.push(`EXISTS (
|
||||||
|
SELECT 1 FROM snippet_tags st
|
||||||
|
JOIN tags t ON t.id = st.tag_id
|
||||||
|
WHERE st.snippet_id = s.id
|
||||||
|
AND (${sub.join(" OR ")})
|
||||||
|
)`);
|
||||||
|
} else {
|
||||||
|
const term = escapeLike(raw.trim());
|
||||||
|
conditions.push(`EXISTS (
|
||||||
SELECT 1 FROM snippet_tags st
|
SELECT 1 FROM snippet_tags st
|
||||||
JOIN tags t ON t.id = st.tag_id
|
JOIN tags t ON t.id = st.tag_id
|
||||||
WHERE st.snippet_id = s.id
|
WHERE st.snippet_id = s.id
|
||||||
AND t.name LIKE '%' || ? || '%' ESCAPE '\\'
|
AND t.name LIKE '%' || ? || '%' ESCAPE '\\'
|
||||||
)`);
|
)`);
|
||||||
params.push(term);
|
params.push(term);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for (const term of parsed.titleTerms) {
|
for (const term of parsed.titleTerms) {
|
||||||
conditions.push(`(s.title IS NOT NULL AND s.title LIKE '%' || ? || '%' ESCAPE '\\')`);
|
conditions.push(`(s.title IS NOT NULL AND s.title LIKE '%' || ? || '%' ESCAPE '\\')`);
|
||||||
@@ -170,7 +248,7 @@ export function listSnippets(q: string | null, sort: string | null): SnippetRow[
|
|||||||
if (conditions.length === 0) {
|
if (conditions.length === 0) {
|
||||||
rows = database
|
rows = database
|
||||||
.query(
|
.query(
|
||||||
`SELECT s.id, s.title, s.body, s.sort_order, s.created_at, s.updated_at
|
`SELECT s.id, s.title, s.body, s.sort_order, s.created_at, s.updated_at, s.deadline_at
|
||||||
FROM snippets s
|
FROM snippets s
|
||||||
${order}`,
|
${order}`,
|
||||||
)
|
)
|
||||||
@@ -179,7 +257,7 @@ export function listSnippets(q: string | null, sort: string | null): SnippetRow[
|
|||||||
const where = conditions.join(" AND ");
|
const where = conditions.join(" AND ");
|
||||||
rows = database
|
rows = database
|
||||||
.query(
|
.query(
|
||||||
`SELECT s.id, s.title, s.body, s.sort_order, s.created_at, s.updated_at
|
`SELECT s.id, s.title, s.body, s.sort_order, s.created_at, s.updated_at, s.deadline_at
|
||||||
FROM snippets s
|
FROM snippets s
|
||||||
WHERE ${where}
|
WHERE ${where}
|
||||||
${order}`,
|
${order}`,
|
||||||
@@ -190,14 +268,14 @@ export function listSnippets(q: string | null, sort: string | null): SnippetRow[
|
|||||||
|
|
||||||
const ids = rows.map(r => r.id);
|
const ids = rows.map(r => r.id);
|
||||||
const tagMap = loadTagsForSnippetIds(database, ids);
|
const tagMap = loadTagsForSnippetIds(database, ids);
|
||||||
return rows.map(r => rowToPublic(r, tagMap.get(r.id) ?? []));
|
return rows.map(r => rowToPublic(database, r, tagMap.get(r.id) ?? []));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSnippetById(id: number): SnippetRow | null {
|
export function getSnippetById(id: number): SnippetRow | null {
|
||||||
const database = getDb();
|
const database = getDb();
|
||||||
const row = database
|
const row = database
|
||||||
.query(
|
.query(
|
||||||
`SELECT id, title, body, sort_order, created_at, updated_at FROM snippets WHERE id = ?`,
|
`SELECT id, title, body, sort_order, created_at, updated_at, deadline_at FROM snippets WHERE id = ?`,
|
||||||
)
|
)
|
||||||
.get(id) as
|
.get(id) as
|
||||||
| {
|
| {
|
||||||
@@ -207,11 +285,12 @@ export function getSnippetById(id: number): SnippetRow | null {
|
|||||||
sort_order: number;
|
sort_order: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
deadline_at: string | null;
|
||||||
}
|
}
|
||||||
| undefined;
|
| undefined;
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
const tagMap = loadTagsForSnippetIds(database, [row.id]);
|
const tagMap = loadTagsForSnippetIds(database, [row.id]);
|
||||||
return rowToPublic(row, tagMap.get(row.id) ?? []);
|
return rowToPublic(database, row, tagMap.get(row.id) ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getOrCreateTagId(database: Database, name: string): number | null {
|
function getOrCreateTagId(database: Database, name: string): number | null {
|
||||||
@@ -230,7 +309,8 @@ function replaceSnippetTags(database: Database, snippetId: number, tagNames: str
|
|||||||
if (unique.length === 0) return;
|
if (unique.length === 0) return;
|
||||||
const insertLink = database.prepare(`INSERT OR IGNORE INTO snippet_tags (snippet_id, tag_id) VALUES (?, ?)`);
|
const insertLink = database.prepare(`INSERT OR IGNORE INTO snippet_tags (snippet_id, tag_id) VALUES (?, ?)`);
|
||||||
for (const name of unique) {
|
for (const name of unique) {
|
||||||
const tagId = getOrCreateTagId(database, name);
|
const storage = encodeTreeTagStorage(database, name);
|
||||||
|
const tagId = getOrCreateTagId(database, storage);
|
||||||
if (tagId !== null) insertLink.run(snippetId, tagId);
|
if (tagId !== null) insertLink.run(snippetId, tagId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -240,6 +320,7 @@ export function createSnippet(input: {
|
|||||||
body?: string;
|
body?: string;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
sort_order?: number;
|
sort_order?: number;
|
||||||
|
deadline_at?: string | null;
|
||||||
}): SnippetRow {
|
}): SnippetRow {
|
||||||
const database = getDb();
|
const database = getDb();
|
||||||
const t = nowIso();
|
const t = nowIso();
|
||||||
@@ -251,10 +332,12 @@ export function createSnippet(input: {
|
|||||||
: String(input.title).trim();
|
: String(input.title).trim();
|
||||||
const body = input.body ?? "";
|
const body = input.body ?? "";
|
||||||
const sortOrder = typeof input.sort_order === "number" ? input.sort_order : Date.now();
|
const sortOrder = typeof input.sort_order === "number" ? input.sort_order : Date.now();
|
||||||
|
const deadlineAt =
|
||||||
|
input.deadline_at === undefined ? null : input.deadline_at === null ? null : String(input.deadline_at).trim() || null;
|
||||||
|
|
||||||
database.run(
|
database.run(
|
||||||
`INSERT INTO snippets (title, body, sort_order, created_at, updated_at) VALUES (?, ?, ?, ?, ?)`,
|
`INSERT INTO snippets (title, body, sort_order, created_at, updated_at, deadline_at) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
[title, body, sortOrder, t, t],
|
[title, body, sortOrder, t, t, deadlineAt],
|
||||||
);
|
);
|
||||||
const idRow = database.query(`SELECT last_insert_rowid() AS id`).get() as { id: number };
|
const idRow = database.query(`SELECT last_insert_rowid() AS id`).get() as { id: number };
|
||||||
const newId = Number(idRow.id);
|
const newId = Number(idRow.id);
|
||||||
@@ -269,6 +352,7 @@ export function updateSnippet(
|
|||||||
body?: string;
|
body?: string;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
sort_order?: number;
|
sort_order?: number;
|
||||||
|
deadline_at?: string | null;
|
||||||
},
|
},
|
||||||
): SnippetRow | null {
|
): SnippetRow | null {
|
||||||
const database = getDb();
|
const database = getDb();
|
||||||
@@ -276,11 +360,12 @@ export function updateSnippet(
|
|||||||
if (!existing) return null;
|
if (!existing) return null;
|
||||||
|
|
||||||
const current = database
|
const current = database
|
||||||
.query(`SELECT title, body, sort_order FROM snippets WHERE id = ?`)
|
.query(`SELECT title, body, sort_order, deadline_at FROM snippets WHERE id = ?`)
|
||||||
.get(id) as {
|
.get(id) as {
|
||||||
title: string | null;
|
title: string | null;
|
||||||
body: string;
|
body: string;
|
||||||
sort_order: number;
|
sort_order: number;
|
||||||
|
deadline_at: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const title =
|
const title =
|
||||||
@@ -291,11 +376,12 @@ export function updateSnippet(
|
|||||||
: String(input.title).trim();
|
: String(input.title).trim();
|
||||||
const body = input.body === undefined ? current.body : String(input.body);
|
const body = input.body === undefined ? current.body : String(input.body);
|
||||||
const sort_order = input.sort_order === undefined ? current.sort_order : input.sort_order;
|
const sort_order = input.sort_order === undefined ? current.sort_order : input.sort_order;
|
||||||
|
const deadline_at = input.deadline_at === undefined ? current.deadline_at : input.deadline_at;
|
||||||
const t = nowIso();
|
const t = nowIso();
|
||||||
|
|
||||||
database.run(
|
database.run(
|
||||||
`UPDATE snippets SET title = ?, body = ?, sort_order = ?, updated_at = ? WHERE id = ?`,
|
`UPDATE snippets SET title = ?, body = ?, sort_order = ?, deadline_at = ?, updated_at = ? WHERE id = ?`,
|
||||||
[title, body, sort_order, t, id],
|
[title, body, sort_order, deadline_at, t, id],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (input.tags !== undefined) {
|
if (input.tags !== undefined) {
|
||||||
|
|||||||
@@ -0,0 +1,343 @@
|
|||||||
|
import type { Database } from "bun:sqlite";
|
||||||
|
import { formatTreeTagDisplay } from "@/lib/treeTagDisplay";
|
||||||
|
import { getDb } from "./db";
|
||||||
|
import type { TagTreeNode, TagTreeSuggestion } from "@/types/tagTree";
|
||||||
|
|
||||||
|
export { formatTreeTagDisplay } from "@/lib/treeTagDisplay";
|
||||||
|
|
||||||
|
/** Bekannte Baum-Tags werden als `#[tag:<nodeIdHex>]` in `tags.name` gespeichert (kein Padding, Kleinbuchstaben). */
|
||||||
|
const TAG_REF_RE = /^#\[tag:([0-9a-f]+)\]$/i;
|
||||||
|
|
||||||
|
function escapeLike(raw: string): string {
|
||||||
|
return raw.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_");
|
||||||
|
}
|
||||||
|
|
||||||
|
type NodeRow = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
parent_id: number | null;
|
||||||
|
color: string | null;
|
||||||
|
label_prefix: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function treeTagDisplayForCanonicalName(database: Database, canonicalName: string): string {
|
||||||
|
const row = database
|
||||||
|
.query(`SELECT label_prefix FROM tag_tree_nodes WHERE name = ? COLLATE NOCASE`)
|
||||||
|
.get(canonicalName) as { label_prefix: string | null } | undefined;
|
||||||
|
return formatTreeTagDisplay(canonicalName, row?.label_prefix ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadNodeRows(database: Database): NodeRow[] {
|
||||||
|
return database
|
||||||
|
.query(`SELECT id, name, parent_id, color, label_prefix FROM tag_tree_nodes ORDER BY name COLLATE NOCASE ASC`)
|
||||||
|
.all() as NodeRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildChildrenMap(rows: NodeRow[]): Map<number | null, number[]> {
|
||||||
|
const m = new Map<number | null, number[]>();
|
||||||
|
for (const r of rows) {
|
||||||
|
const p = r.parent_id;
|
||||||
|
const list = m.get(p);
|
||||||
|
if (list) list.push(r.id);
|
||||||
|
else m.set(p, [r.id]);
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectDescendantIds(startId: number, childrenByParent: Map<number | null, number[]>): number[] {
|
||||||
|
const out: number[] = [];
|
||||||
|
const stack = [...(childrenByParent.get(startId) ?? [])];
|
||||||
|
while (stack.length) {
|
||||||
|
const id = stack.pop()!;
|
||||||
|
out.push(id);
|
||||||
|
const kids = childrenByParent.get(id);
|
||||||
|
if (kids) for (const k of kids) stack.push(k);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeTreeTagRef(database: Database, stored: string): string | null {
|
||||||
|
const m = stored.trim().match(TAG_REF_RE);
|
||||||
|
if (!m) return null;
|
||||||
|
const id = Number.parseInt(m[1], 16);
|
||||||
|
if (!Number.isFinite(id) || id < 1) return null;
|
||||||
|
const row = database.query(`SELECT name FROM tag_tree_nodes WHERE id = ?`).get(id) as { name: string } | undefined;
|
||||||
|
return row?.name ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Anzeige-Label für einen in der DB gespeicherten Tag-Namen (refs → Baumname). */
|
||||||
|
export function decodeSnippetTagDisplay(database: Database, stored: string): string {
|
||||||
|
return decodeTreeTagRef(database, stored) ?? stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Speicherform: bekannte Baum-Namen → `#[tag:hexId]`, sonst unverändert; gültige Refs werden normalisiert. */
|
||||||
|
export function encodeTreeTagStorage(database: Database, input: string): string {
|
||||||
|
const t = input.trim();
|
||||||
|
if (!t) return t;
|
||||||
|
const ref = t.match(TAG_REF_RE);
|
||||||
|
if (ref) {
|
||||||
|
const id = Number.parseInt(ref[1], 16);
|
||||||
|
if (!Number.isFinite(id) || id < 1) return t;
|
||||||
|
const row = database.query(`SELECT id FROM tag_tree_nodes WHERE id = ?`).get(id) as { id: number } | undefined;
|
||||||
|
if (row) return `#[tag:${row.id.toString(16)}]`;
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
const row2 = database.query(`SELECT id FROM tag_tree_nodes WHERE name = ? COLLATE NOCASE`).get(t) as
|
||||||
|
| { id: number }
|
||||||
|
| undefined;
|
||||||
|
if (row2) return `#[tag:${row2.id.toString(16)}]`;
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kanonische Ref-Strings für Baumknoten mit diesen Anzeigenamen (für Suche auf encodierten Snippet-Tags). */
|
||||||
|
export function treeStorageRefsForDisplayNames(database: Database, displayNames: string[]): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const n of displayNames) {
|
||||||
|
const row = database.query(`SELECT id FROM tag_tree_nodes WHERE name = ? COLLATE NOCASE`).get(n.trim()) as
|
||||||
|
| { id: number }
|
||||||
|
| undefined;
|
||||||
|
if (!row) continue;
|
||||||
|
const s = `#[tag:${row.id.toString(16)}]`;
|
||||||
|
if (!seen.has(s)) {
|
||||||
|
seen.add(s);
|
||||||
|
out.push(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vorschläge nur aus dem Tag-Baum; `name` wird eingefügt, `display` für die Liste. */
|
||||||
|
export function suggestTagTreeItems(database: Database, qRaw: string, limit = 48): TagTreeSuggestion[] {
|
||||||
|
const q = qRaw.trim().toLowerCase();
|
||||||
|
const out: TagTreeSuggestion[] = [];
|
||||||
|
for (const n of loadNodeRows(database)) {
|
||||||
|
const display = formatTreeTagDisplay(n.name, n.label_prefix);
|
||||||
|
if (q === "") {
|
||||||
|
out.push({ name: n.name, display });
|
||||||
|
} else if (
|
||||||
|
n.name.toLowerCase().includes(q) ||
|
||||||
|
display.toLowerCase().includes(q) ||
|
||||||
|
(n.label_prefix?.toLowerCase().includes(q) ?? false)
|
||||||
|
) {
|
||||||
|
out.push({ name: n.name, display });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.sort((a, b) => a.display.localeCompare(b.display, undefined, { sensitivity: "base" })).slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wenn ein Knoten mit exakt diesem Namen existiert: Namen dieses Knotens plus aller Nachfahren (Snippet-Tags sind freitext, gleich Knotenname). */
|
||||||
|
export function expandExactTreeTagNames(database: Database, rawName: string): string[] | null {
|
||||||
|
const trimmed = rawName.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
const fromRef = decodeTreeTagRef(database, trimmed);
|
||||||
|
const lookupName = fromRef ?? trimmed;
|
||||||
|
const row = database
|
||||||
|
.query(`SELECT id FROM tag_tree_nodes WHERE name = ? COLLATE NOCASE`)
|
||||||
|
.get(lookupName) as { id: number } | undefined;
|
||||||
|
if (!row) return null;
|
||||||
|
const rows = loadNodeRows(database);
|
||||||
|
const byId = new Map(rows.map(r => [r.id, r]));
|
||||||
|
const children = buildChildrenMap(rows);
|
||||||
|
const ids = [row.id, ...collectDescendantIds(row.id, children)];
|
||||||
|
const names = [...new Set(ids.map(id => byId.get(id)?.name).filter((n): n is string => Boolean(n)))];
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Für Freitextsuche: alle Knotennamen, die LIKE matchen, plus Namen aller ihrer Nachfahren. */
|
||||||
|
export function matchingTreeTagNamesForLikePattern(database: Database, likePatternEscaped: string): string[] {
|
||||||
|
const rows = loadNodeRows(database);
|
||||||
|
if (rows.length === 0) return [];
|
||||||
|
const matched = database
|
||||||
|
.query(
|
||||||
|
`SELECT id FROM tag_tree_nodes
|
||||||
|
WHERE name LIKE '%' || ? || '%' ESCAPE '\\'
|
||||||
|
OR IFNULL(label_prefix, '') LIKE '%' || ? || '%' ESCAPE '\\'`,
|
||||||
|
)
|
||||||
|
.all(likePatternEscaped, likePatternEscaped) as { id: number }[];
|
||||||
|
if (matched.length === 0) return [];
|
||||||
|
const children = buildChildrenMap(rows);
|
||||||
|
const byId = new Map(rows.map(r => [r.id, r]));
|
||||||
|
const nameSet = new Set<string>();
|
||||||
|
for (const { id } of matched) {
|
||||||
|
nameSet.add(byId.get(id)!.name);
|
||||||
|
for (const did of collectDescendantIds(id, children)) {
|
||||||
|
const n = byId.get(did)?.name;
|
||||||
|
if (n) nameSet.add(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...nameSet];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listTagTreeNodes(): TagTreeNode[] {
|
||||||
|
const database = getDb();
|
||||||
|
return loadNodeRows(database).map(r => ({
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
label_prefix: r.label_prefix,
|
||||||
|
parent_id: r.parent_id,
|
||||||
|
color: r.color,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const HEX_COLOR = /^#[0-9A-Fa-f]{6}$/;
|
||||||
|
|
||||||
|
function normalizeColor(v: unknown): string | null {
|
||||||
|
if (v === null || v === undefined) return null;
|
||||||
|
if (typeof v !== "string") return null;
|
||||||
|
const s = v.trim();
|
||||||
|
if (s === "") return null;
|
||||||
|
if (!HEX_COLOR.test(s)) return null;
|
||||||
|
return s.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLabelPrefix(v: unknown): string | null {
|
||||||
|
if (v === null || v === undefined) return null;
|
||||||
|
if (typeof v !== "string") return null;
|
||||||
|
const s = v.trim();
|
||||||
|
return s === "" ? null : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wouldCreateCycle(database: Database, nodeId: number, newParentId: number | null): boolean {
|
||||||
|
if (newParentId === null) return false;
|
||||||
|
let cur: number | null = newParentId;
|
||||||
|
const seen = new Set<number>();
|
||||||
|
while (cur !== null) {
|
||||||
|
if (cur === nodeId) return true;
|
||||||
|
if (seen.has(cur)) return true;
|
||||||
|
seen.add(cur);
|
||||||
|
const row = database.query(`SELECT parent_id FROM tag_tree_nodes WHERE id = ?`).get(cur) as
|
||||||
|
| { parent_id: number | null }
|
||||||
|
| undefined;
|
||||||
|
cur = row?.parent_id ?? null;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTagTreeNode(input: {
|
||||||
|
name: string;
|
||||||
|
parent_id?: number | null;
|
||||||
|
color?: string | null;
|
||||||
|
label_prefix?: string | null;
|
||||||
|
}): TagTreeNode {
|
||||||
|
const database = getDb();
|
||||||
|
const name = String(input.name).trim();
|
||||||
|
if (!name) {
|
||||||
|
throw new Error("Name fehlt");
|
||||||
|
}
|
||||||
|
const parent_id =
|
||||||
|
input.parent_id === undefined || input.parent_id === null
|
||||||
|
? null
|
||||||
|
: Number(input.parent_id);
|
||||||
|
if (parent_id !== null && !Number.isFinite(parent_id)) {
|
||||||
|
throw new Error("Ungültige parent_id");
|
||||||
|
}
|
||||||
|
if (parent_id !== null) {
|
||||||
|
const p = database.query(`SELECT id FROM tag_tree_nodes WHERE id = ?`).get(parent_id) as { id: number } | undefined;
|
||||||
|
if (!p) throw new Error("Parent nicht gefunden");
|
||||||
|
}
|
||||||
|
const color = normalizeColor(input.color);
|
||||||
|
const label_prefix = normalizeLabelPrefix(input.label_prefix);
|
||||||
|
database.run(`INSERT INTO tag_tree_nodes (name, parent_id, color, label_prefix) VALUES (?, ?, ?, ?)`, [
|
||||||
|
name,
|
||||||
|
parent_id,
|
||||||
|
color,
|
||||||
|
label_prefix,
|
||||||
|
]);
|
||||||
|
const idRow = database.query(`SELECT last_insert_rowid() AS id`).get() as { id: number };
|
||||||
|
const id = Number(idRow.id);
|
||||||
|
const row = database
|
||||||
|
.query(`SELECT id, name, parent_id, color, label_prefix FROM tag_tree_nodes WHERE id = ?`)
|
||||||
|
.get(id) as NodeRow;
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
label_prefix: row.label_prefix,
|
||||||
|
parent_id: row.parent_id,
|
||||||
|
color: row.color,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateTagTreeNode(
|
||||||
|
id: number,
|
||||||
|
input: { name?: string; parent_id?: number | null; color?: string | null; label_prefix?: string | null },
|
||||||
|
): TagTreeNode | null {
|
||||||
|
const database = getDb();
|
||||||
|
const existing = database
|
||||||
|
.query(`SELECT id, name, parent_id, color, label_prefix FROM tag_tree_nodes WHERE id = ?`)
|
||||||
|
.get(id) as NodeRow | undefined;
|
||||||
|
if (!existing) return null;
|
||||||
|
|
||||||
|
let name = existing.name;
|
||||||
|
if (input.name !== undefined) {
|
||||||
|
const n = String(input.name).trim();
|
||||||
|
if (!n) throw new Error("Name fehlt");
|
||||||
|
name = n;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parent_id = existing.parent_id;
|
||||||
|
if ("parent_id" in input) {
|
||||||
|
parent_id = input.parent_id === null || input.parent_id === undefined ? null : Number(input.parent_id);
|
||||||
|
if (parent_id !== null && !Number.isFinite(parent_id)) {
|
||||||
|
throw new Error("Ungültige parent_id");
|
||||||
|
}
|
||||||
|
if (parent_id !== null) {
|
||||||
|
const p = database.query(`SELECT id FROM tag_tree_nodes WHERE id = ?`).get(parent_id) as { id: number } | undefined;
|
||||||
|
if (!p) throw new Error("Parent nicht gefunden");
|
||||||
|
if (parent_id === id) throw new Error("Knoten kann nicht eigener Parent sein");
|
||||||
|
}
|
||||||
|
if (wouldCreateCycle(database, id, parent_id)) {
|
||||||
|
throw new Error("Zyklus im Baum nicht erlaubt");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let color = existing.color;
|
||||||
|
if ("color" in input) {
|
||||||
|
color = normalizeColor(input.color);
|
||||||
|
}
|
||||||
|
|
||||||
|
let label_prefix = existing.label_prefix;
|
||||||
|
if ("label_prefix" in input) {
|
||||||
|
label_prefix = normalizeLabelPrefix(input.label_prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
database.run(`UPDATE tag_tree_nodes SET name = ?, parent_id = ?, color = ?, label_prefix = ? WHERE id = ?`, [
|
||||||
|
name,
|
||||||
|
parent_id,
|
||||||
|
color,
|
||||||
|
label_prefix,
|
||||||
|
id,
|
||||||
|
]);
|
||||||
|
const row = database.query(`SELECT id, name, parent_id, color, label_prefix FROM tag_tree_nodes WHERE id = ?`).get(id) as NodeRow;
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
label_prefix: row.label_prefix,
|
||||||
|
parent_id: row.parent_id,
|
||||||
|
color: row.color,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteTagTreeNode(id: number): boolean {
|
||||||
|
const database = getDb();
|
||||||
|
const res = database.run(`DELETE FROM tag_tree_nodes WHERE id = ?`, [id]);
|
||||||
|
return res.changes > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function colorsForTagNames(database: Database, tagNames: string[]): Map<string, string | null> {
|
||||||
|
const out = new Map<string, string | null>();
|
||||||
|
if (tagNames.length === 0) return out;
|
||||||
|
const unique = [...new Set(tagNames.map(t => t.trim()).filter(Boolean))];
|
||||||
|
const lower = unique.map(s => s.toLowerCase());
|
||||||
|
const placeholders = lower.map(() => "?").join(", ");
|
||||||
|
const rows = database
|
||||||
|
.query(`SELECT name, color FROM tag_tree_nodes WHERE lower(name) IN (${placeholders})`)
|
||||||
|
.all(...lower) as { name: string; color: string | null }[];
|
||||||
|
for (const t of tagNames) {
|
||||||
|
const row = rows.find(r => r.name.toLowerCase() === t.toLowerCase());
|
||||||
|
if (row) out.set(t, row.color);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { escapeLike };
|
||||||
@@ -5,5 +5,10 @@ export type Snippet = {
|
|||||||
sort_order: number;
|
sort_order: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
/** Kalendertag `YYYY-MM-DD` oder `null` = keine Deadline / erledigt. */
|
||||||
|
deadline_at: string | null;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
|
/** Anzeige pro Tag (Prefix + Name für Baum-Tags), gleiche Reihenfolge wie `tags`. */
|
||||||
|
tag_display: string[];
|
||||||
|
tag_colors: Record<string, string | null>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export type TagTreeNode = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
/** Optional z. B. "Projekt:" → Darstellung "Projekt: sp-migrator", Referenz / Intellisense bleibt `name`. */
|
||||||
|
label_prefix: string | null;
|
||||||
|
parent_id: number | null;
|
||||||
|
color: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TagTreeSuggestion = {
|
||||||
|
name: string;
|
||||||
|
display: string;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user