snapshot
This commit is contained in:
@@ -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 ? <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">
|
||||
<div className="flex flex-col gap-2 sm:gap-2.5">
|
||||
{!loading && snippets.length === 0 ? (
|
||||
<p className="text-center text-sm text-muted-foreground">Keine Snippets. Mit „Neu“ starten.</p>
|
||||
) : null}
|
||||
|
||||
@@ -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
|
||||
<span
|
||||
className={
|
||||
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 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 px-1.5 py-px text-[11px] font-medium leading-tight"
|
||||
: "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={
|
||||
has
|
||||
@@ -64,7 +60,7 @@ function TagPill({ tagKey, label, hex }: { tagKey: string; label: string; hex: s
|
||||
|
||||
function formatWhen(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleString();
|
||||
return new Date(iso).toLocaleString(undefined, { dateStyle: "short", timeStyle: "short" });
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
@@ -79,16 +75,12 @@ type Props = {
|
||||
};
|
||||
|
||||
export function SnippetCard({ snippet, treeColorByName, treeDisplayByName, onReplace, onRemove }: Props) {
|
||||
const pristineEmpty =
|
||||
!stringFieldForForm(snippet.body) &&
|
||||
(snippet.title == null || snippet.title.trim() === "") &&
|
||||
(snippet.tags?.length ?? 0) === 0;
|
||||
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(() => 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<string | null>(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 (
|
||||
<>
|
||||
<Card className="gap-0 py-3 text-left shadow-sm">
|
||||
<CardContent className="px-4">
|
||||
<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-3 pb-1 pt-2">
|
||||
<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"
|
||||
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();
|
||||
@@ -252,16 +239,8 @@ export function SnippetCard({ snippet, treeColorByName, treeDisplayByName, onRep
|
||||
</div>
|
||||
</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="flex min-h-[1.5rem] flex-wrap items-center gap-1.5">
|
||||
<CardFooter className="flex flex-row items-center gap-2 border-t border-border/80 px-3 py-1">
|
||||
<div className="flex min-h-7 min-w-0 flex-1 flex-wrap items-center gap-1">
|
||||
{snippet.tags.length ? (
|
||||
snippet.tags.map((t, i) => (
|
||||
<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 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
|
||||
<div className="flex h-7 shrink-0 items-center gap-1.5">
|
||||
<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="destructive" disabled={busy} onClick={() => void deleteRemote(true)}>
|
||||
Löschen
|
||||
<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="text-sm text-destructive">{error}</p> : null}
|
||||
</div>
|
||||
{error && !dialogOpen ? (
|
||||
<p className="basis-full truncate text-center text-[10px] text-destructive sm:text-start">{error}</p>
|
||||
) : null}
|
||||
</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>
|
||||
<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 border-b px-5 pb-3 pt-5 pr-12 text-start sm:px-6 sm:pr-14">
|
||||
<DialogTitle className="text-base">Snippet bearbeiten</DialogTitle>
|
||||
<DialogDescription className="text-xs">Tags und Markdown — Vorschau optional.</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"
|
||||
/>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden px-5 py-3 sm:px-6">
|
||||
<div className="relative shrink-0">
|
||||
<Input
|
||||
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}
|
||||
</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"}
|
||||
<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-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" disabled={busy} onClick={requestDismiss}>
|
||||
<Button type="button" variant="secondary" size="sm" className="h-8" disabled={busy} onClick={requestDismiss}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="button" disabled={busy} onClick={() => void save()}>
|
||||
<Button type="button" size="sm" className="h-8" disabled={busy} onClick={() => void save()}>
|
||||
Speichern
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -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<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__");
|
||||
@@ -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 (
|
||||
<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">
|
||||
<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">
|
||||
@@ -186,16 +209,24 @@ export function TagTreeDialog({ open, onOpenChange, onChanged }: Props) {
|
||||
<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> steht in Snippets; optionaler Prefix nur für die
|
||||
Anzeige (z. B. <span className="font-mono text-foreground">Projekt: sp-migrator</span>).
|
||||
<span className="text-foreground/90">Kurzname</span> in Snippets; optionaler Prefix nur für die Anzeige.
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col lg:flex-row lg:divide-x">
|
||||
{/* Baum */}
|
||||
<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">
|
||||
<div className={cn("flex min-h-0 flex-1 flex-col lg:flex-row lg:divide-x", formOpen ? "" : "lg:flex-1")}>
|
||||
<div
|
||||
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 items-center gap-2">
|
||||
<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>
|
||||
</div>
|
||||
) : (
|
||||
<ul role="list">
|
||||
<ul className="space-y-0.5" role="list">
|
||||
{flatOrdered.map(({ node: n, depth }) => {
|
||||
const isActive = editingId === n.id;
|
||||
const isActive = formOpen && editingId === n.id;
|
||||
return (
|
||||
<li key={n.id} role="none">
|
||||
<div
|
||||
className={cn(
|
||||
"group flex items-stretch gap-1 rounded-lg border border-transparent transition-colors",
|
||||
isActive ? "border-border bg-background shadow-sm" : "hover:border-border/60 hover:bg-background/80",
|
||||
"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",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
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` }}
|
||||
<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-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={{
|
||||
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"}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="truncate text-sm font-medium leading-tight">
|
||||
{n.label_prefix}
|
||||
</span>
|
||||
|
||||
<span className="truncate font-mono text-sm text-muted-foreground">
|
||||
{n.name}
|
||||
</span>
|
||||
<div className="truncate text-xs font-medium leading-snug">
|
||||
{formatTreeTagDisplay(n.name, n.label_prefix)}
|
||||
</div>
|
||||
</button>
|
||||
<div className="flex shrink-0 justify-center gap-0.5 py-1 pr-1">
|
||||
<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-8 text-muted-foreground hover:text-foreground"
|
||||
title="Unterknoten anlegen"
|
||||
className="size-7 text-muted-foreground hover:text-foreground"
|
||||
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}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
@@ -294,7 +328,7 @@ export function TagTreeDialog({ open, onOpenChange, onChanged }: Props) {
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="size-8 text-muted-foreground hover:text-destructive"
|
||||
className="size-7 text-muted-foreground hover:text-destructive"
|
||||
title="Löschen"
|
||||
disabled={busy}
|
||||
onClick={e => void remove(n.id, e)}
|
||||
@@ -312,7 +346,7 @@ export function TagTreeDialog({ open, onOpenChange, onChanged }: Props) {
|
||||
</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="border-b px-4 py-2.5 sm:px-5">
|
||||
<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>
|
||||
) : null}
|
||||
</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">
|
||||
<Button type="button" variant="ghost" size="sm" className="h-9 w-full sm:w-auto" disabled={busy} onClick={resetForm}>
|
||||
Eingaben zurücksetzen
|
||||
</Button>
|
||||
<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)}>
|
||||
<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">
|
||||
{formOpen ? (
|
||||
<>
|
||||
<Button type="button" variant="outline" className="h-9" disabled={busy} onClick={() => exitFormMode()}>
|
||||
Schließen
|
||||
</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
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Button type="button" variant="outline" className="h-9" disabled={busy} onClick={() => onOpenChange(false)}>
|
||||
Schließen
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -49,7 +49,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
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} />
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user