diff --git a/src/App.tsx b/src/App.tsx index 27d519a..d073d32 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { NotesApp } from "@/components/NotesApp"; +import { NotesApp } from "@/features/notes/NotesApp"; import "./index.css"; export function App() { diff --git a/src/components/SnippetCard.tsx b/src/components/SnippetCard.tsx deleted file mode 100644 index c44ed94..0000000 --- a/src/components/SnippetCard.tsx +++ /dev/null @@ -1,411 +0,0 @@ -import { marked } from "marked"; -import { Pencil, Trash2 } from "lucide-react"; -import { useEffect, useMemo, useRef, useState } from "react"; -import type { Snippet } from "@/types/snippet"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardFooter } from "@/components/ui/card"; -import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { Textarea } from "@/components/ui/textarea"; - -marked.setOptions({ gfm: true }); - -function tagsFromString(s: string): string[] { - return s - .split(",") - .map(t => t.trim()) - .filter(Boolean); -} - -/** Letztes Segment nach dem letzten Komma (laufender Tag-Token). */ -function splitTagsField(s: string): { committed: string; token: string } { - const lastComma = s.lastIndexOf(","); - if (lastComma === -1) { - return { committed: "", token: s.trimStart() }; - } - return { committed: s.slice(0, lastComma + 1), token: s.slice(lastComma + 1).trimStart() }; -} - -function colorForTagName( - name: string, - treeColorByName: Record, - snippetColors: Record, -): string | null { - return treeColorByName[name] ?? snippetColors[name] ?? null; -} - -function TagPill({ tagKey, label, hex }: { tagKey: string; label: string; hex: string | null }) { - const has = Boolean(hex && /^#[0-9A-Fa-f]{6}$/i.test(hex)); - return ( - - {label} - - ); -} - -function formatWhen(iso: string): string { - try { - return new Date(iso).toLocaleString(undefined, { dateStyle: "short", timeStyle: "short" }); - } catch { - return iso; - } -} - -type Props = { - snippet: Snippet; - treeColorByName: Record; - treeDisplayByName: Record; - onReplace: (s: Snippet) => void; - onRemove: (id: number) => void; -}; - -export function SnippetCard({ snippet, treeColorByName, treeDisplayByName, onReplace, onRemove }: Props) { - const pristineEmpty = !snippet.body.trim() && (snippet.tags?.length ?? 0) === 0; - - const [dialogOpen, setDialogOpen] = useState(pristineEmpty); - const [showPreview, setShowPreview] = useState(false); - const [dirty, setDirty] = useState(false); - const [body, setBody] = useState(() => snippet.body ?? ""); - const [tagsStr, setTagsStr] = useState(() => (snippet.tags ?? []).join(", ")); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - const [tagFieldFocused, setTagFieldFocused] = useState(false); - const [tagSuggest, setTagSuggest] = useState<{ name: string; display: string }[]>([]); - const [tagSuggestOpen, setTagSuggestOpen] = useState(false); - const [tagSuggestIndex, setTagSuggestIndex] = useState(0); - const tagSuggestReq = useRef(0); - - useEffect(() => { - if (!dialogOpen || !tagFieldFocused) { - setTagSuggestOpen(false); - return; - } - const { token } = splitTagsField(tagsStr); - const id = ++tagSuggestReq.current; - const t = window.setTimeout(() => { - void (async () => { - try { - const res = await fetch(`/api/tag-suggestions?q=${encodeURIComponent(token)}`); - if (tagSuggestReq.current !== id) return; - if (!res.ok) return; - const data = (await res.json()) as { items?: { name: string; display: string }[] }; - const items = Array.isArray(data.items) ? data.items : []; - setTagSuggest(items); - setTagSuggestOpen(items.length > 0); - setTagSuggestIndex(0); - } catch { - if (tagSuggestReq.current !== id) return; - setTagSuggest([]); - setTagSuggestOpen(false); - } - })(); - }, 160); - return () => window.clearTimeout(t); - }, [dialogOpen, tagFieldFocused, tagsStr]); - - useEffect(() => { - if (!dialogOpen) { - setTagFieldFocused(false); - setTagSuggest([]); - setTagSuggestOpen(false); - } - }, [dialogOpen]); - - const applyTagSuggestion = (label: string) => { - const { committed } = splitTagsField(tagsStr); - setTagsStr(`${committed}${label}, `); - setTagSuggestOpen(false); - setDirty(true); - }; - - const viewHtml = useMemo(() => marked.parse(snippet.body ?? "") as string, [snippet.body]); - - const previewHtml = useMemo(() => marked.parse(body) as string, [body]); - - useEffect(() => { - if (dialogOpen) return; - setBody(snippet.body ?? ""); - setTagsStr((snippet.tags ?? []).join(", ")); - }, [dialogOpen, snippet.id, snippet.updated_at, snippet.body, snippet.tags]); - - const hydrateFromSnippet = () => { - setBody(snippet.body ?? ""); - setTagsStr((snippet.tags ?? []).join(", ")); - setShowPreview(false); - setDirty(false); - setError(null); - }; - - const openDialog = () => { - hydrateFromSnippet(); - setDialogOpen(true); - }; - - /** Dialog schließen; bei neuem leeren Snippet ohne Änderungen wieder entfernen. */ - const requestDismiss = () => { - setDialogOpen(false); - if (pristineEmpty && !dirty) void deleteRemote(false); - }; - - const handleOpenChange = (open: boolean) => { - if (open) openDialog(); - else requestDismiss(); - }; - - const save = async () => { - setBusy(true); - setError(null); - try { - const res = await fetch(`/api/snippets/${snippet.id}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - title: null, - body, - tags: tagsFromString(tagsStr), - }), - }); - if (!res.ok) { - const err = (await res.json().catch(() => null)) as { error?: string } | null; - throw new Error(err?.error ?? res.statusText); - } - const updated = (await res.json()) as Snippet; - onReplace(updated); - setDirty(false); - setDialogOpen(false); - } catch (e) { - setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen"); - } finally { - setBusy(false); - } - }; - - async function deleteRemote(askConfirm: boolean) { - if (askConfirm && !window.confirm("Snippet wirklich löschen?")) return; - setBusy(true); - setError(null); - try { - const res = await fetch(`/api/snippets/${snippet.id}`, { method: "DELETE" }); - if (!res.ok && res.status !== 204) { - const err = (await res.json().catch(() => null)) as { error?: string } | null; - throw new Error(err?.error ?? res.statusText); - } - onRemove(snippet.id); - } catch (e) { - setError(e instanceof Error ? e.message : "Löschen fehlgeschlagen"); - } finally { - setBusy(false); - } - } - - const markDirty = () => setDirty(true); - - return ( - <> - - -
{ - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - openDialog(); - } - }} - > -
-
-
-
- - -
- {snippet.tags.length ? ( - snippet.tags.map((t, i) => ( - - )) - ) : ( - Keine Tags - )} -
-
- - {formatWhen(snippet.updated_at)} - - - -
- {error && !dialogOpen ? ( -

{error}

- ) : null} -
- - - - - - Snippet bearbeiten - Tags und Markdown — Vorschau optional. - - -
-
- { - markDirty(); - setTagsStr(e.target.value); - }} - onFocus={() => setTagFieldFocused(true)} - onBlur={() => { - window.setTimeout(() => setTagFieldFocused(false), 140); - window.setTimeout(() => setTagSuggestOpen(false), 160); - }} - onKeyDown={e => { - if (!tagSuggestOpen || tagSuggest.length === 0) return; - if (e.key === "ArrowDown") { - e.preventDefault(); - setTagSuggestIndex(i => Math.min(tagSuggest.length - 1, i + 1)); - } else if (e.key === "ArrowUp") { - e.preventDefault(); - setTagSuggestIndex(i => Math.max(0, i - 1)); - } else if (e.key === "Enter") { - e.preventDefault(); - const pick = tagSuggest[tagSuggestIndex]; - if (pick) applyTagSuggestion(pick.name); - } else if (e.key === "Escape") { - e.preventDefault(); - setTagSuggestOpen(false); - } - }} - placeholder="Tags, kommagetrennt — Vorschläge mit Pfeiltasten / Enter" - autoComplete="off" - className="h-9" - /> - {tagSuggestOpen ? ( -
    - {tagSuggest.map((item, i) => ( -
  • - -
  • - ))} -
- ) : null} -
-
- {tagsFromString(tagsStr).map((t, i) => ( - - ))} -
-
-