From 3bdc8cc64677a581475edc9d4a88ead25d732d99 Mon Sep 17 00:00:00 2001 From: Stefan ATZLESBERGER Date: Thu, 16 Apr 2026 14:54:12 +0200 Subject: [PATCH] snapshot --- src/components/NotesApp.tsx | 4 +- src/components/SnippetCard.tsx | 149 +++++++-------- src/components/TagTreeDialog.tsx | 304 +++++++++++++++++-------------- src/components/ui/card.tsx | 2 +- src/components/ui/dialog.tsx | 2 +- src/index.css | 28 +++ 6 files changed, 272 insertions(+), 217 deletions(-) diff --git a/src/components/NotesApp.tsx b/src/components/NotesApp.tsx index 3b3415e..3ca8984 100644 --- a/src/components/NotesApp.tsx +++ b/src/components/NotesApp.tsx @@ -127,7 +127,7 @@ export function NotesApp() { id="search" value={query} onChange={e => setQuery(e.target.value)} - placeholder="Ohne Prefix: Titel, Snippet-Tags & Tag-Baum · tag:x · title:y · content:z" + placeholder="Freitext: Inhalt & Tags · tag:x · title:y · content:z" autoComplete="off" className="h-9" /> @@ -183,7 +183,7 @@ export function NotesApp() { {error ?

{error}

: null} {loading ?

Lade Snippets …

: null} -
+
{!loading && snippets.length === 0 ? (

Keine Snippets. Mit „Neu“ starten.

) : null} diff --git a/src/components/SnippetCard.tsx b/src/components/SnippetCard.tsx index 50f956e..c44ed94 100644 --- a/src/components/SnippetCard.tsx +++ b/src/components/SnippetCard.tsx @@ -1,4 +1,5 @@ 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"; @@ -9,11 +10,6 @@ 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(",") @@ -44,8 +40,8 @@ function TagPill({ tagKey, label, hex }: { tagKey: string; label: string; hex: s stringFieldForForm(snippet.body)); - const [title, setTitle] = useState(() => stringFieldForForm(snippet.title)); + const [body, setBody] = useState(() => snippet.body ?? ""); const [tagsStr, setTagsStr] = useState(() => (snippet.tags ?? []).join(", ")); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); @@ -141,23 +133,18 @@ export function SnippetCard({ snippet, treeColorByName, treeDisplayByName, onRep setDirty(true); }; - const viewHtml = useMemo( - () => marked.parse(stringFieldForForm(snippet.body)) as string, - [snippet.body], - ); + 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(stringFieldForForm(snippet.body)); - setTitle(stringFieldForForm(snippet.title)); + setBody(snippet.body ?? ""); setTagsStr((snippet.tags ?? []).join(", ")); - }, [dialogOpen, snippet.id, snippet.updated_at, snippet.body, snippet.title, snippet.tags]); + }, [dialogOpen, snippet.id, snippet.updated_at, snippet.body, snippet.tags]); const hydrateFromSnippet = () => { - setBody(stringFieldForForm(snippet.body)); - setTitle(stringFieldForForm(snippet.title)); + setBody(snippet.body ?? ""); setTagsStr((snippet.tags ?? []).join(", ")); setShowPreview(false); setDirty(false); @@ -188,7 +175,7 @@ export function SnippetCard({ snippet, treeColorByName, treeDisplayByName, onRep method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - title: title.trim() === "" ? null : title.trim(), + title: null, body, tags: tagsFromString(tagsStr), }), @@ -230,13 +217,13 @@ export function SnippetCard({ snippet, treeColorByName, treeDisplayByName, onRep return ( <> - - + +
{ if (e.key === "Enter" || e.key === " ") { e.preventDefault(); @@ -252,62 +239,64 @@ export function SnippetCard({ snippet, treeColorByName, treeDisplayByName, onRep
- -
-
- {snippet.title?.trim() ? ( - {snippet.title} - ) : ( - (Kein Titel) - )} -
-
- {snippet.tags.length ? ( - snippet.tags.map((t, i) => ( - - )) - ) : ( - Keine Tags - )} -
+ +
+ {snippet.tags.length ? ( + snippet.tags.map((t, i) => ( + + )) + ) : ( + Keine Tags + )}
-
-
Geändert: {formatWhen(snippet.updated_at)}
-
- - -
- {error && !dialogOpen ?

{error}

: null} +
+ + {formatWhen(snippet.updated_at)} + + +
+ {error && !dialogOpen ? ( +

{error}

+ ) : null} - - - Snippet bearbeiten - Titel, Tags und Markdown. Vorschau optional einblenden. + + + Snippet bearbeiten + Tags und Markdown — Vorschau optional. -
- { - markDirty(); - setTitle(e.target.value); - }} - placeholder="Optionaler Titel" - className="shrink-0" - /> +
{error}

: null}
- -
- -
diff --git a/src/components/TagTreeDialog.tsx b/src/components/TagTreeDialog.tsx index 2d29a96..b2a4590 100644 --- a/src/components/TagTreeDialog.tsx +++ b/src/components/TagTreeDialog.tsx @@ -1,5 +1,5 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; -import { FolderTree, Loader2, Plus, Trash2 } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState, type MouseEvent } from "react"; +import { FolderTree, Loader2, Pencil, Plus, Trash2 } from "lucide-react"; import { formatTreeTagDisplay } from "@/lib/treeTagDisplay"; import { cn } from "@/lib/utils"; import type { TagTreeNode } from "@/types/tagTree"; @@ -42,6 +42,7 @@ export function TagTreeDialog({ open, onOpenChange, onChanged }: Props) { const [nodes, setNodes] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [formOpen, setFormOpen] = useState(false); const [editingId, setEditingId] = useState(null); const [name, setName] = useState(""); const [parentIdStr, setParentIdStr] = useState("__root__"); @@ -68,9 +69,21 @@ export function TagTreeDialog({ open, onOpenChange, onChanged }: Props) { 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 resetForm = () => { + const resetFormFields = () => { setEditingId(null); setName(""); setParentIdStr("__root__"); @@ -78,17 +91,27 @@ export function TagTreeDialog({ open, onOpenChange, onChanged }: Props) { 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) => { - resetForm(); + setFormOpen(true); + resetFormFields(); setParentIdStr(parentId == null ? "__root__" : String(parentId)); + setError(null); }; const save = async () => { @@ -120,7 +143,7 @@ export function TagTreeDialog({ open, onOpenChange, onChanged }: Props) { } } await load(); - resetForm(); + exitFormMode(); onChanged?.(); } catch (e) { setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen"); @@ -129,7 +152,7 @@ export function TagTreeDialog({ open, onOpenChange, onChanged }: Props) { } }; - const remove = async (id: number, e?: React.MouseEvent) => { + const remove = async (id: number, e?: MouseEvent) => { e?.stopPropagation(); if (!window.confirm("Diesen Knoten und alle Unterknoten wirklich löschen?")) return; setBusy(true); @@ -140,7 +163,7 @@ export function TagTreeDialog({ open, onOpenChange, onChanged }: Props) { const err = (await res.json().catch(() => null)) as { error?: string } | null; throw new Error(err?.error ?? res.statusText); } - if (editingId === id) resetForm(); + if (editingId === id) exitFormMode(); await load(); onChanged?.(); } catch (e) { @@ -176,7 +199,7 @@ export function TagTreeDialog({ open, onOpenChange, onChanged }: Props) { return ( - +
@@ -186,16 +209,24 @@ export function TagTreeDialog({ open, onOpenChange, onChanged }: Props) { Tag-Baum Strukturierte Tags für Suche und Farben.{" "} - Kurzname steht in Snippets; optionaler Prefix nur für die - Anzeige (z. B. Projekt: sp-migrator). + Kurzname in Snippets; optionaler Prefix nur für die Anzeige.
-
- {/* Baum */} -
+
+
+ {!formOpen && error ? ( +
+ {error} +
+ ) : null}
Hierarchie @@ -235,52 +266,55 @@ export function TagTreeDialog({ open, onOpenChange, onChanged }: Props) {
) : ( -
    +
      {flatOrdered.map(({ node: n, depth }) => { - const isActive = editingId === n.id; + const isActive = formOpen && editingId === n.id; return (
    • - -
      +
      +
      +
      - {/* Formular */} -
      -
      -

      - {editingId == null ? "Knoten anlegen" : "Knoten bearbeiten"} -

      - {editingId != null ? ( -

      ID {editingId}

      - ) : null} -
      - -
      - {error ? ( -

      - {error} + {formOpen ? ( +

      +
      +

      + {editingId == null ? "Knoten anlegen" : "Knoten bearbeiten"}

      - ) : null} + {editingId != null ? ( +

      ID {editingId}

      + ) : null} +
      -
      -
      - - setName(e.target.value)} - className="font-mono text-sm" - autoComplete="off" - placeholder="sp-migrator" - /> -
      +
      + {error ? ( +

      + {error} +

      + ) : null} -
      - - setLabelPrefix(e.target.value)} - placeholder="Projekt:" - className="font-mono text-sm" - autoComplete="off" - /> -
      - Vorschau - {previewLabel ?? "—"} -
      -
      - -
      - - -
      - -
      - -
      +
      +
      + setColor(e.target.value)} - placeholder="#3b82f6" - className="min-w-0 flex-1 font-mono text-sm" + id="tt-name" + value={name} + onChange={e => setName(e.target.value)} + className="font-mono text-sm" + autoComplete="off" + placeholder="sp-migrator" + /> +
      + +
      + + setLabelPrefix(e.target.value)} + placeholder="Projekt:" + className="font-mono text-sm" autoComplete="off" /> - setColor(e.target.value)} - aria-label="Farbe wählen" - /> +
      + Vorschau + {previewLabel ?? "—"} +
      +
      + +
      + + +
      + +
      + +
      + setColor(e.target.value)} + placeholder="#3b82f6" + className="min-w-0 flex-1 font-mono text-sm" + autoComplete="off" + /> + setColor(e.target.value)} + aria-label="Farbe wählen" + /> +
      -
      + ) : null}
      - - -
      - + + + ) : ( + - -
      + )}
diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx index 6b44ad4..33345f4 100644 --- a/src/components/ui/card.tsx +++ b/src/components/ui/card.tsx @@ -49,7 +49,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) { function CardFooter({ className, ...props }: React.ComponentProps<"div">) { return ( -
+
); } diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index c70f2da..d286cb7 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -46,7 +46,7 @@ const DialogContent = React.forwardRef< ref={ref} data-slot="dialog-content" className={cn( - "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", + "fixed left-1/2 top-1/2 z-50 flex min-h-0 w-[calc(100vw-1.5rem)] max-w-lg -translate-x-1/2 -translate-y-1/2 flex-col gap-4 overflow-hidden rounded-lg border bg-background p-6 shadow-lg duration-200 max-h-[min(92dvh,calc(100dvh-2rem))]", "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95", className, )} diff --git a/src/index.css b/src/index.css index 9ad5b78..e057e30 100644 --- a/src/index.css +++ b/src/index.css @@ -158,4 +158,32 @@ body::before { max-width: 100%; height: auto; } + + /* Snippet-Karten: etwas dichter, Fokus auf Fließtext */ + .snippet-md-card-wrap .snippet-md { + font-size: 0.8125rem; + 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) { + margin-block: 0.375rem; + padding: 0.5rem; + } }