Files
bun-notes/src/features/notes/NotesApp.tsx
T
2026-04-16 16:44:06 +02:00

190 lines
6.4 KiB
TypeScript

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>
);
}