This commit is contained in:
Stefan ATZLESBERGER
2026-04-16 14:54:12 +02:00
parent 5cbf43a2db
commit 3bdc8cc646
6 changed files with 272 additions and 217 deletions
+2 -2
View File
@@ -127,7 +127,7 @@ export function NotesApp() {
id="search" id="search"
value={query} value={query}
onChange={e => setQuery(e.target.value)} 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" autoComplete="off"
className="h-9" className="h-9"
/> />
@@ -183,7 +183,7 @@ export function NotesApp() {
{error ? <p className="text-sm text-destructive">{error}</p> : null} {error ? <p className="text-sm text-destructive">{error}</p> : null}
{loading ? <p className="text-sm text-muted-foreground">Lade Snippets …</p> : null} {loading ? <p className="text-sm text-muted-foreground">Lade Snippets …</p> : null}
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-2 sm:gap-2.5">
{!loading && snippets.length === 0 ? ( {!loading && snippets.length === 0 ? (
<p className="text-center text-sm text-muted-foreground">Keine Snippets. Mit „Neu“ starten.</p> <p className="text-center text-sm text-muted-foreground">Keine Snippets. Mit „Neu“ starten.</p>
) : null} ) : null}
+56 -67
View File
@@ -1,4 +1,5 @@
import { marked } from "marked"; import { marked } from "marked";
import { Pencil, Trash2 } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import type { Snippet } from "@/types/snippet"; import type { Snippet } from "@/types/snippet";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -9,11 +10,6 @@ import { Textarea } from "@/components/ui/textarea";
marked.setOptions({ gfm: true }); 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[] { function tagsFromString(s: string): string[] {
return s return s
.split(",") .split(",")
@@ -44,8 +40,8 @@ function TagPill({ tagKey, label, hex }: { tagKey: string; label: string; hex: s
<span <span
className={ className={
has has
? "inline-flex max-w-full items-center rounded-full border px-2 py-0.5 text-xs font-medium" ? "inline-flex max-w-full items-center rounded-full border-2 px-1.5 py-px text-[11px] font-medium leading-tight"
: "inline-flex max-w-full items-center rounded-full border border-border bg-muted/50 px-2 py-0.5 text-xs text-muted-foreground" : "inline-flex max-w-full items-center rounded-full border-2 border-border bg-muted/40 px-1.5 py-px text-[11px] leading-tight text-muted-foreground"
} }
style={ style={
has has
@@ -64,7 +60,7 @@ function TagPill({ tagKey, label, hex }: { tagKey: string; label: string; hex: s
function formatWhen(iso: string): string { function formatWhen(iso: string): string {
try { try {
return new Date(iso).toLocaleString(); return new Date(iso).toLocaleString(undefined, { dateStyle: "short", timeStyle: "short" });
} catch { } catch {
return iso; return iso;
} }
@@ -79,16 +75,12 @@ type Props = {
}; };
export function SnippetCard({ snippet, treeColorByName, treeDisplayByName, onReplace, onRemove }: Props) { export function SnippetCard({ snippet, treeColorByName, treeDisplayByName, onReplace, onRemove }: Props) {
const pristineEmpty = const pristineEmpty = !snippet.body.trim() && (snippet.tags?.length ?? 0) === 0;
!stringFieldForForm(snippet.body) &&
(snippet.title == null || snippet.title.trim() === "") &&
(snippet.tags?.length ?? 0) === 0;
const [dialogOpen, setDialogOpen] = useState(pristineEmpty); const [dialogOpen, setDialogOpen] = useState(pristineEmpty);
const [showPreview, setShowPreview] = useState(false); const [showPreview, setShowPreview] = useState(false);
const [dirty, setDirty] = useState(false); const [dirty, setDirty] = useState(false);
const [body, setBody] = useState(() => stringFieldForForm(snippet.body)); const [body, setBody] = useState(() => snippet.body ?? "");
const [title, setTitle] = useState(() => stringFieldForForm(snippet.title));
const [tagsStr, setTagsStr] = useState(() => (snippet.tags ?? []).join(", ")); const [tagsStr, setTagsStr] = useState(() => (snippet.tags ?? []).join(", "));
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -141,23 +133,18 @@ export function SnippetCard({ snippet, treeColorByName, treeDisplayByName, onRep
setDirty(true); setDirty(true);
}; };
const viewHtml = useMemo( const viewHtml = useMemo(() => marked.parse(snippet.body ?? "") as string, [snippet.body]);
() => marked.parse(stringFieldForForm(snippet.body)) as string,
[snippet.body],
);
const previewHtml = useMemo(() => marked.parse(body) as string, [body]); const previewHtml = useMemo(() => marked.parse(body) as string, [body]);
useEffect(() => { useEffect(() => {
if (dialogOpen) return; if (dialogOpen) return;
setBody(stringFieldForForm(snippet.body)); setBody(snippet.body ?? "");
setTitle(stringFieldForForm(snippet.title));
setTagsStr((snippet.tags ?? []).join(", ")); 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 = () => { const hydrateFromSnippet = () => {
setBody(stringFieldForForm(snippet.body)); setBody(snippet.body ?? "");
setTitle(stringFieldForForm(snippet.title));
setTagsStr((snippet.tags ?? []).join(", ")); setTagsStr((snippet.tags ?? []).join(", "));
setShowPreview(false); setShowPreview(false);
setDirty(false); setDirty(false);
@@ -188,7 +175,7 @@ export function SnippetCard({ snippet, treeColorByName, treeDisplayByName, onRep
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
title: title.trim() === "" ? null : title.trim(), title: null,
body, body,
tags: tagsFromString(tagsStr), tags: tagsFromString(tagsStr),
}), }),
@@ -230,13 +217,13 @@ export function SnippetCard({ snippet, treeColorByName, treeDisplayByName, onRep
return ( return (
<> <>
<Card className="gap-0 py-3 text-left shadow-sm"> <Card className="gap-0 border-border/80 py-0 text-left shadow-md ring-1 ring-black/5 dark:ring-white/10">
<CardContent className="px-4"> <CardContent className="px-3 pb-1 pt-2">
<div <div
role="button" role="button"
tabIndex={0} tabIndex={0}
title="Zum Bearbeiten klicken" 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" 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 => { onKeyDown={e => {
if (e.key === "Enter" || e.key === " ") { if (e.key === "Enter" || e.key === " ") {
e.preventDefault(); e.preventDefault();
@@ -252,16 +239,8 @@ export function SnippetCard({ snippet, treeColorByName, treeDisplayByName, onRep
</div> </div>
</div> </div>
</CardContent> </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"> <CardFooter className="flex flex-row items-center gap-2 border-t border-border/80 px-3 py-1">
<div className="grid w-full gap-1 text-sm sm:max-w-xl"> <div className="flex min-h-7 min-w-0 flex-1 flex-wrap items-center gap-1">
<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="flex min-h-[1.5rem] flex-wrap items-center gap-1.5">
{snippet.tags.length ? ( {snippet.tags.length ? (
snippet.tags.map((t, i) => ( snippet.tags.map((t, i) => (
<TagPill <TagPill
@@ -272,42 +251,52 @@ export function SnippetCard({ snippet, treeColorByName, treeDisplayByName, onRep
/> />
)) ))
) : ( ) : (
<span className="text-muted-foreground">Keine Tags</span> <span className="text-[10px] text-muted-foreground">Keine Tags</span>
)} )}
</div> </div>
</div> <div className="flex h-7 shrink-0 items-center gap-1.5">
<div className="flex w-full flex-col gap-2 sm:w-auto sm:items-end"> <span className="whitespace-nowrap text-[10px] leading-none tabular-nums text-muted-foreground/90">
<div className="text-xs text-muted-foreground">Geändert: {formatWhen(snippet.updated_at)}</div> {formatWhen(snippet.updated_at)}
<div className="flex flex-wrap gap-2"> </span>
<Button type="button" variant="secondary" disabled={busy} onClick={openDialog}> <Button
Bearbeiten 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>
<Button type="button" variant="destructive" disabled={busy} onClick={() => void deleteRemote(true)}> <Button
Löschen 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> </Button>
</div> </div>
{error && !dialogOpen ? <p className="text-sm text-destructive">{error}</p> : null} {error && !dialogOpen ? (
</div> <p className="basis-full truncate text-center text-[10px] text-destructive sm:text-start">{error}</p>
) : null}
</CardFooter> </CardFooter>
</Card> </Card>
<Dialog open={dialogOpen} onOpenChange={handleOpenChange}> <Dialog open={dialogOpen} onOpenChange={handleOpenChange}>
<DialogContent className="gap-0 p-0"> <DialogContent className="flex h-[min(88dvh,820px)] max-h-[92vh] w-[calc(100vw-1.5rem)] max-w-3xl gap-0 p-0">
<DialogHeader className="shrink-0 space-y-1.5 border-b px-6 pb-4 pt-6 pr-14 text-center sm:text-start"> <DialogHeader className="shrink-0 space-y-1 border-b px-5 pb-3 pt-5 pr-12 text-start sm:px-6 sm:pr-14">
<DialogTitle>Snippet bearbeiten</DialogTitle> <DialogTitle className="text-base">Snippet bearbeiten</DialogTitle>
<DialogDescription>Titel, Tags und Markdown. Vorschau optional einblenden.</DialogDescription> <DialogDescription className="text-xs">Tags und Markdown — Vorschau optional.</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-hidden px-6 py-4"> <div className="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden px-5 py-3 sm:px-6">
<Input
value={title}
onChange={e => {
markDirty();
setTitle(e.target.value);
}}
placeholder="Optionaler Titel"
className="shrink-0"
/>
<div className="relative shrink-0"> <div className="relative shrink-0">
<Input <Input
value={tagsStr} value={tagsStr}
@@ -402,15 +391,15 @@ export function SnippetCard({ snippet, treeColorByName, treeDisplayByName, onRep
{error ? <p className="shrink-0 text-sm text-destructive">{error}</p> : null} {error ? <p className="shrink-0 text-sm text-destructive">{error}</p> : null}
</div> </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"> <DialogFooter className="shrink-0 flex-row flex-wrap items-center justify-between gap-2 border-t px-5 py-3 sm:px-6">
<Button type="button" variant="outline" size="sm" className="h-9" onClick={() => setShowPreview(v => !v)}> <Button type="button" variant="outline" size="sm" className="h-8 text-xs" onClick={() => setShowPreview(v => !v)}>
{showPreview ? "Vorschau ausblenden" : "Vorschau"} {showPreview ? "Vorschau aus" : "Vorschau"}
</Button> </Button>
<div className="flex flex-wrap items-center justify-end gap-2"> <div className="flex flex-wrap items-center justify-end gap-2">
<Button type="button" variant="secondary" disabled={busy} onClick={requestDismiss}> <Button type="button" variant="secondary" size="sm" className="h-8" disabled={busy} onClick={requestDismiss}>
Abbrechen Abbrechen
</Button> </Button>
<Button type="button" disabled={busy} onClick={() => void save()}> <Button type="button" size="sm" className="h-8" disabled={busy} onClick={() => void save()}>
Speichern Speichern
</Button> </Button>
</div> </div>
+87 -49
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState, type MouseEvent } from "react";
import { FolderTree, Loader2, Plus, Trash2 } from "lucide-react"; import { FolderTree, Loader2, Pencil, Plus, Trash2 } from "lucide-react";
import { formatTreeTagDisplay } from "@/lib/treeTagDisplay"; import { formatTreeTagDisplay } from "@/lib/treeTagDisplay";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { TagTreeNode } from "@/types/tagTree"; import type { TagTreeNode } from "@/types/tagTree";
@@ -42,6 +42,7 @@ export function TagTreeDialog({ open, onOpenChange, onChanged }: Props) {
const [nodes, setNodes] = useState<TagTreeNode[]>([]); const [nodes, setNodes] = useState<TagTreeNode[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [formOpen, setFormOpen] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null); const [editingId, setEditingId] = useState<number | null>(null);
const [name, setName] = useState(""); const [name, setName] = useState("");
const [parentIdStr, setParentIdStr] = useState<string>("__root__"); const [parentIdStr, setParentIdStr] = useState<string>("__root__");
@@ -68,9 +69,21 @@ export function TagTreeDialog({ open, onOpenChange, onChanged }: Props) {
void load(); void load();
}, [open, 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 flatOrdered = useMemo(() => orderedWithDepth(nodes), [nodes]);
const resetForm = () => { const resetFormFields = () => {
setEditingId(null); setEditingId(null);
setName(""); setName("");
setParentIdStr("__root__"); setParentIdStr("__root__");
@@ -78,17 +91,27 @@ export function TagTreeDialog({ open, onOpenChange, onChanged }: Props) {
setLabelPrefix(""); setLabelPrefix("");
}; };
const exitFormMode = () => {
setFormOpen(false);
resetFormFields();
setError(null);
};
const startEdit = (n: TagTreeNode) => { const startEdit = (n: TagTreeNode) => {
setFormOpen(true);
setEditingId(n.id); setEditingId(n.id);
setName(n.name); setName(n.name);
setParentIdStr(n.parent_id == null ? "__root__" : String(n.parent_id)); setParentIdStr(n.parent_id == null ? "__root__" : String(n.parent_id));
setColor(n.color ?? ""); setColor(n.color ?? "");
setLabelPrefix(n.label_prefix ?? ""); setLabelPrefix(n.label_prefix ?? "");
setError(null);
}; };
const startNewUnder = (parentId: number | null) => { const startNewUnder = (parentId: number | null) => {
resetForm(); setFormOpen(true);
resetFormFields();
setParentIdStr(parentId == null ? "__root__" : String(parentId)); setParentIdStr(parentId == null ? "__root__" : String(parentId));
setError(null);
}; };
const save = async () => { const save = async () => {
@@ -120,7 +143,7 @@ export function TagTreeDialog({ open, onOpenChange, onChanged }: Props) {
} }
} }
await load(); await load();
resetForm(); exitFormMode();
onChanged?.(); onChanged?.();
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen"); 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(); e?.stopPropagation();
if (!window.confirm("Diesen Knoten und alle Unterknoten wirklich löschen?")) return; if (!window.confirm("Diesen Knoten und alle Unterknoten wirklich löschen?")) return;
setBusy(true); setBusy(true);
@@ -140,7 +163,7 @@ export function TagTreeDialog({ open, onOpenChange, onChanged }: Props) {
const err = (await res.json().catch(() => null)) as { error?: string } | null; const err = (await res.json().catch(() => null)) as { error?: string } | null;
throw new Error(err?.error ?? res.statusText); throw new Error(err?.error ?? res.statusText);
} }
if (editingId === id) resetForm(); if (editingId === id) exitFormMode();
await load(); await load();
onChanged?.(); onChanged?.();
} catch (e) { } catch (e) {
@@ -176,7 +199,7 @@ export function TagTreeDialog({ open, onOpenChange, onChanged }: Props) {
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[90vh] w-[calc(100vw-1.5rem)] max-w-3xl flex-col gap-0 overflow-hidden p-0 sm:max-w-3xl"> <DialogContent className="left-0 top-0 flex h-dvh max-h-dvh min-h-0 w-full max-w-none translate-x-0 translate-y-0 flex-col gap-0 overflow-hidden rounded-none p-0 sm:max-w-none">
<DialogHeader className="shrink-0 space-y-2 border-b bg-muted/30 px-5 py-4 text-left sm:px-6"> <DialogHeader className="shrink-0 space-y-2 border-b bg-muted/30 px-5 py-4 text-left sm:px-6">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-lg border bg-background shadow-sm"> <div className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-lg border bg-background shadow-sm">
@@ -186,16 +209,24 @@ export function TagTreeDialog({ open, onOpenChange, onChanged }: Props) {
<DialogTitle className="text-lg">Tag-Baum</DialogTitle> <DialogTitle className="text-lg">Tag-Baum</DialogTitle>
<DialogDescription className="text-pretty text-xs leading-relaxed sm:text-sm"> <DialogDescription className="text-pretty text-xs leading-relaxed sm:text-sm">
Strukturierte Tags für Suche und Farben.{" "} Strukturierte Tags für Suche und Farben.{" "}
<span className="text-foreground/90">Kurzname</span> steht in Snippets; optionaler Prefix nur für die <span className="text-foreground/90">Kurzname</span> in Snippets; optionaler Prefix nur für die Anzeige.
Anzeige (z.&nbsp;B. <span className="font-mono text-foreground">Projekt: sp-migrator</span>).
</DialogDescription> </DialogDescription>
</div> </div>
</div> </div>
</DialogHeader> </DialogHeader>
<div className="flex min-h-0 flex-1 flex-col lg:flex-row lg:divide-x"> <div className={cn("flex min-h-0 flex-1 flex-col lg:flex-row lg:divide-x", formOpen ? "" : "lg:flex-1")}>
{/* Baum */} <div
<div className="flex min-h-[min(220px,40vh)] flex-1 flex-col border-b bg-muted/10 lg:min-h-0 lg:max-w-[min(100%,420px)] lg:border-b-0 lg:bg-muted/5"> className={cn(
"flex min-h-[min(200px,38vh)] flex-col border-b bg-muted/10 lg:min-h-0 lg:border-b-0 lg:bg-muted/5",
formOpen ? "lg:max-w-[min(100%,420px)]" : "lg:flex-1",
)}
>
{!formOpen && error ? (
<div className="border-b border-destructive/30 bg-destructive/10 px-4 py-2 text-sm text-destructive sm:px-5">
{error}
</div>
) : null}
<div className="flex shrink-0 items-center justify-between gap-2 border-b px-4 py-2.5 sm:px-5"> <div className="flex shrink-0 items-center justify-between gap-2 border-b px-4 py-2.5 sm:px-5">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Hierarchie</span> <span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Hierarchie</span>
@@ -235,52 +266,55 @@ export function TagTreeDialog({ open, onOpenChange, onChanged }: Props) {
</Button> </Button>
</div> </div>
) : ( ) : (
<ul role="list"> <ul className="space-y-0.5" role="list">
{flatOrdered.map(({ node: n, depth }) => { {flatOrdered.map(({ node: n, depth }) => {
const isActive = editingId === n.id; const isActive = formOpen && editingId === n.id;
return ( return (
<li key={n.id} role="none"> <li key={n.id} role="none">
<div <div
className={cn( className={cn(
"group flex items-stretch gap-1 rounded-lg border border-transparent transition-colors", "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/60 hover:bg-background/80", isActive ? "border-border bg-background shadow-sm" : "hover:border-border/50 hover:bg-muted/40",
)} )}
> >
<button <div
type="button" className="flex min-w-0 flex-1 items-center gap-2 py-1 pr-1"
disabled={busy} style={{ paddingLeft: `${8 + depth * 12}px` }}
onClick={() => startEdit(n)}
className={cn(
"flex min-w-0 flex-1 items-center gap-2 rounded-l-lg py-2 pl-2 pr-1 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring",
isActive && "cursor-default",
)}
style={{ paddingLeft: `${10 + depth * 14}px` }}
> >
<span <span
className="size-2 shrink-0 rounded-full ring-1 ring-border ring-offset-1 ring-offset-background" className="size-1.5 shrink-0 rounded-full ring-1 ring-border"
style={{ style={{
backgroundColor: n.color ?? "hsl(var(--muted-foreground) / 0.25)", backgroundColor: n.color ?? "hsl(var(--muted-foreground) / 0.3)",
}} }}
title={n.color ? `Farbe ${n.color}` : "Keine Farbe"} title={n.color ? `Farbe ${n.color}` : "Keine Farbe"}
aria-hidden aria-hidden
/> />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<span className="truncate text-sm font-medium leading-tight"> <div className="truncate text-xs font-medium leading-snug">
{n.label_prefix} {formatTreeTagDisplay(n.name, n.label_prefix)}
</span>
&nbsp;
<span className="truncate font-mono text-sm text-muted-foreground">
{n.name}
</span>
</div> </div>
</button> <div className="truncate font-mono text-[10px] leading-tight text-muted-foreground">{n.name}</div>
<div className="flex shrink-0 justify-center gap-0.5 py-1 pr-1"> </div>
</div>
<div className="flex shrink-0 items-center gap-0 pr-0.5">
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
className="size-8 text-muted-foreground hover:text-foreground" className="size-7 text-muted-foreground hover:text-foreground"
title="Unterknoten anlegen" title="Bearbeiten"
disabled={busy}
onClick={() => startEdit(n)}
>
<Pencil className="size-3.5" aria-hidden />
<span className="sr-only">Bearbeiten</span>
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="size-7 text-muted-foreground hover:text-foreground"
title="Unterknoten"
disabled={busy} disabled={busy}
onClick={e => { onClick={e => {
e.stopPropagation(); e.stopPropagation();
@@ -294,7 +328,7 @@ export function TagTreeDialog({ open, onOpenChange, onChanged }: Props) {
type="button" type="button"
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
className="size-8 text-muted-foreground hover:text-destructive" className="size-7 text-muted-foreground hover:text-destructive"
title="Löschen" title="Löschen"
disabled={busy} disabled={busy}
onClick={e => void remove(n.id, e)} onClick={e => void remove(n.id, e)}
@@ -312,7 +346,7 @@ export function TagTreeDialog({ open, onOpenChange, onChanged }: Props) {
</div> </div>
</div> </div>
{/* Formular */} {formOpen ? (
<div className="flex w-full min-w-0 flex-1 flex-col overflow-y-auto bg-background lg:max-w-md"> <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"> <div className="border-b px-4 py-2.5 sm:px-5">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground"> <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
@@ -405,20 +439,24 @@ export function TagTreeDialog({ open, onOpenChange, onChanged }: Props) {
</div> </div>
</div> </div>
</div> </div>
) : null}
</div> </div>
<DialogFooter className="shrink-0 flex-col gap-2 border-t bg-muted/20 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-5"> <DialogFooter className="shrink-0 flex-row flex-wrap items-center justify-start gap-2 border-t bg-muted/20 px-4 py-3 sm:px-5">
<Button type="button" variant="ghost" size="sm" className="h-9 w-full sm:w-auto" disabled={busy} onClick={resetForm}> {formOpen ? (
Eingaben zurücksetzen <>
</Button> <Button type="button" variant="outline" className="h-9" disabled={busy} onClick={() => exitFormMode()}>
<div className="flex w-full gap-2 sm:w-auto sm:justify-end">
<Button type="button" variant="outline" className="h-9 flex-1 sm:flex-none" disabled={busy} onClick={() => onOpenChange(false)}>
Schließen Schließen
</Button> </Button>
<Button type="button" className="h-9 flex-1 sm:min-w-[120px]" disabled={busy || !name.trim()} onClick={() => void save()}> <Button type="button" className="h-9" disabled={busy || !name.trim()} onClick={() => void save()}>
Speichern Speichern
</Button> </Button>
</div> </>
) : (
<Button type="button" variant="outline" className="h-9" disabled={busy} onClick={() => onOpenChange(false)}>
Schließen
</Button>
)}
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
+1 -1
View File
@@ -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} />
); );
} }
+1 -1
View File
@@ -46,7 +46,7 @@ const DialogContent = React.forwardRef<
ref={ref} ref={ref}
data-slot="dialog-content" data-slot="dialog-content"
className={cn( 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", "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, className,
)} )}
+28
View File
@@ -158,4 +158,32 @@ body::before {
max-width: 100%; max-width: 100%;
height: auto; 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;
}
} }