This commit is contained in:
Stefan ATZLESBERGER
2026-04-16 13:11:09 +02:00
parent dfbcd20ab6
commit a6c4178e0c
3 changed files with 213 additions and 74 deletions
+16 -11
View File
@@ -80,23 +80,27 @@ export function NotesApp() {
};
return (
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6 p-6">
<header className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div className="grid w-full gap-2 sm:max-w-md">
<Label htmlFor="search">Suche</Label>
<div className="mx-auto flex w-full max-w-6xl flex-col gap-4 px-4 py-4 sm:px-5">
<header className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-end sm:gap-3">
<div className="min-w-0 flex-1 space-y-1.5">
<Label htmlFor="search" className="text-xs text-muted-foreground">
Suche
</Label>
<Input
id="search"
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Titel, Tags oder Notiztext …"
placeholder="Ohne Prefix: Titel & Tags · tag:x · title:y · content:z"
autoComplete="off"
className="h-9"
/>
</div>
<div className="flex w-full flex-col gap-2 sm:w-auto sm:items-end">
<div className="grid w-full gap-2 sm:w-[280px]">
<Label>Sortierung</Label>
<div className="w-full min-w-[min(100%,220px)] space-y-1.5 sm:w-72">
<Label htmlFor="sort" className="text-xs text-muted-foreground">
Sortierung
</Label>
<Select value={sort} onValueChange={v => setSort(v as SortValue)}>
<SelectTrigger>
<SelectTrigger id="sort" className="h-9 w-full">
<SelectValue placeholder="Sortierung" />
</SelectTrigger>
<SelectContent>
@@ -108,7 +112,8 @@ export function NotesApp() {
</SelectContent>
</Select>
</div>
<Button type="button" className="w-full sm:w-auto" onClick={() => void createNew()}>
<div className="flex sm:shrink-0">
<Button type="button" className="h-9 w-full sm:w-auto" onClick={() => void createNew()}>
Neu
</Button>
</div>
@@ -117,7 +122,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-6">
<div className="flex flex-col gap-4">
{!loading && snippets.length === 0 ? (
<p className="text-center text-sm text-muted-foreground">Keine Snippets. Mit „Neu“ starten.</p>
) : null}
+78 -22
View File
@@ -1,5 +1,5 @@
import { marked } from "marked";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import type { Snippet } from "@/types/snippet";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardFooter } from "@/components/ui/card";
@@ -8,6 +8,12 @@ import { Textarea } from "@/components/ui/textarea";
marked.setOptions({ gfm: true });
/** API `null` / fehlender Wert → leerer String, damit kontrollierte Inputs nie `null` anzeigen. */
function stringFieldForForm(v: string | null | undefined): string {
if (v == null) return "";
return typeof v === "string" ? v : "";
}
function tagsFromString(s: string): string[] {
return s
.split(",")
@@ -30,23 +36,36 @@ type Props = {
};
export function SnippetCard({ snippet, onReplace, onRemove }: Props) {
const pristineEmpty = !snippet.body && !snippet.title && snippet.tags.length === 0;
const pristineEmpty =
!stringFieldForForm(snippet.body) && (snippet.title == null || snippet.title.trim() === "") && (snippet.tags?.length ?? 0) === 0;
const [editing, setEditing] = useState(pristineEmpty);
const [showPreview, setShowPreview] = useState(false);
const [dirty, setDirty] = useState(false);
const [body, setBody] = useState(snippet.body);
const [title, setTitle] = useState(snippet.title ?? "");
const [tagsStr, setTagsStr] = useState(snippet.tags.join(", "));
const [body, setBody] = useState(() => stringFieldForForm(snippet.body));
const [title, setTitle] = useState(() => stringFieldForForm(snippet.title));
const [tagsStr, setTagsStr] = useState(() => (snippet.tags ?? []).join(", "));
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const html = useMemo(() => marked.parse(editing ? body : snippet.body) as string, [editing, body, snippet.body]);
const html = useMemo(
() => marked.parse(editing ? body : stringFieldForForm(snippet.body)) as string,
[editing, body, snippet.body],
);
useEffect(() => {
if (editing) return;
setBody(stringFieldForForm(snippet.body));
setTitle(stringFieldForForm(snippet.title));
setTagsStr((snippet.tags ?? []).join(", "));
}, [editing, snippet.id, snippet.updated_at, snippet.body, snippet.title, snippet.tags]);
const startEdit = () => {
setEditing(true);
setShowPreview(false);
setDirty(false);
setBody(snippet.body);
setTitle(snippet.title ?? "");
setTagsStr(snippet.tags.join(", "));
setBody(stringFieldForForm(snippet.body));
setTitle(stringFieldForForm(snippet.title));
setTagsStr((snippet.tags ?? []).join(", "));
setError(null);
};
@@ -103,45 +122,76 @@ export function SnippetCard({ snippet, onReplace, onRemove }: Props) {
}
setEditing(false);
setDirty(false);
setBody(snippet.body);
setTitle(snippet.title ?? "");
setTagsStr(snippet.tags.join(", "));
setBody(stringFieldForForm(snippet.body));
setTitle(stringFieldForForm(snippet.title));
setTagsStr((snippet.tags ?? []).join(", "));
setError(null);
};
const markDirty = () => setDirty(true);
const mdBodyClass =
"snippet-md max-h-[min(55vh,420px)] overflow-auto text-sm leading-relaxed [&_a]:text-primary [&_a]:underline [&_blockquote]:border-l-2 [&_blockquote]:border-muted-foreground/40 [&_blockquote]:pl-3 [&_blockquote]:italic [&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_h1]:mb-2 [&_h1]:text-xl [&_h1]:font-semibold [&_h2]:mb-2 [&_h2]:text-lg [&_h2]:font-semibold [&_h3]:mb-1 [&_h3]:text-base [&_h3]:font-semibold [&_li]:ml-4 [&_ol]:list-decimal [&_pre]:overflow-x-auto [&_pre]:rounded-md [&_pre]:bg-muted [&_pre]:p-3 [&_ul]:list-disc";
return (
<Card className="text-left">
<CardContent className="pt-6">
<div className="min-h-[180px] rounded-lg border bg-muted/30 p-4">
<Card className="gap-3 py-4 text-left shadow-sm">
<CardContent className="px-4 pb-0 pt-0">
<div className="min-h-[140px] rounded-md border bg-muted/30 p-2">
{editing ? (
<div className="grid gap-4 lg:grid-cols-2">
<div className="space-y-2">
<div className="flex flex-wrap items-center justify-end gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="h-8"
onClick={() => setShowPreview(v => !v)}
>
{showPreview ? "Vorschau ausblenden" : "Vorschau"}
</Button>
</div>
<div className={showPreview ? "grid gap-3 lg:grid-cols-2" : "grid gap-3"}>
<Textarea
value={body}
onChange={e => {
markDirty();
setBody(e.target.value);
}}
className="min-h-[220px] resize-y font-mono text-sm"
className="min-h-[200px] resize-y font-mono text-sm"
placeholder="Markdown …"
/>
{showPreview ? (
<div
className="snippet-md max-h-[420px] min-h-[120px] overflow-auto rounded-md border bg-background p-3 text-sm leading-relaxed [&_a]:text-primary [&_a]:underline [&_blockquote]:border-l-2 [&_blockquote]:border-muted-foreground/40 [&_blockquote]:pl-3 [&_blockquote]:italic [&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_h1]:mb-2 [&_h1]:text-xl [&_h1]:font-semibold [&_h2]:mb-2 [&_h2]:text-lg [&_h2]:font-semibold [&_h3]:mb-1 [&_h3]:text-base [&_h3]:font-semibold [&_li]:ml-4 [&_ol]:list-decimal [&_pre]:overflow-x-auto [&_pre]:rounded-md [&_pre]:bg-muted [&_pre]:p-3 [&_ul]:list-disc"
className={`${mdBodyClass} min-h-[120px] rounded-md border bg-background p-2`}
dangerouslySetInnerHTML={{ __html: html }}
/>
) : null}
</div>
</div>
) : (
<div
className="snippet-md max-h-[420px] overflow-auto text-sm leading-relaxed [&_a]:text-primary [&_a]:underline [&_blockquote]:border-l-2 [&_blockquote]:border-muted-foreground/40 [&_blockquote]:pl-3 [&_blockquote]:italic [&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_h1]:mb-2 [&_h1]:text-xl [&_h1]:font-semibold [&_h2]:mb-2 [&_h2]:text-lg [&_h2]:font-semibold [&_h3]:mb-1 [&_h3]:text-base [&_h3]:font-semibold [&_li]:ml-4 [&_ol]:list-decimal [&_pre]:overflow-x-auto [&_pre]:rounded-md [&_pre]:bg-muted [&_pre]:p-3 [&_ul]:list-disc"
role="button"
tabIndex={0}
title="Zum Bearbeiten klicken"
className={`${mdBodyClass} cursor-pointer rounded-md p-2 outline-none ring-offset-background hover:bg-muted/40 focus-visible:ring-2 focus-visible:ring-ring`}
dangerouslySetInnerHTML={{ __html: html }}
onClick={e => {
if ((e.target as HTMLElement).closest("a")) return;
startEdit();
}}
onKeyDown={e => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
startEdit();
}
}}
/>
)}
</div>
{error ? <p className="mt-2 text-sm text-destructive">{error}</p> : null}
</CardContent>
<CardFooter className="flex flex-col gap-4 border-t pt-6 sm:flex-row sm:items-end sm:justify-between">
<div className="grid w-full gap-3 sm:max-w-xl">
<CardFooter className="flex flex-col gap-3 border-t px-4 pb-3 pt-3 sm:flex-row sm:items-end sm:justify-between">
<div className="grid w-full gap-2 sm:max-w-xl">
{editing ? (
<>
<Input
@@ -163,7 +213,13 @@ export function SnippetCard({ snippet, onReplace, onRemove }: Props) {
</>
) : (
<div className="space-y-1 text-sm">
<div className="font-medium">{snippet.title?.trim() ? snippet.title : <span className="text-muted-foreground">Ohne Titel</span>}</div>
<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="text-muted-foreground">{snippet.tags.length ? snippet.tags.join(", ") : "Keine Tags"}</div>
</div>
)}
+89 -11
View File
@@ -15,6 +15,38 @@ function escapeLike(raw: string): string {
return raw.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_");
}
/** `tag:x`, `title:y`, `content:z` (case-insensitive); remainder = default (title OR tag names, LIKE). All parts AND. */
function parseSearchQuery(raw: string): {
defaultText: string | null;
tagTerms: string[];
titleTerms: string[];
contentTerms: string[];
} {
const tagTerms: string[] = [];
const titleTerms: string[] = [];
const contentTerms: string[] = [];
const re = /(?:^|\s)(tag|title|content):(\S+)/gi;
const spans: [number, number][] = [];
let m: RegExpExecArray | null;
while ((m = re.exec(raw)) !== null) {
const kind = m[1].toLowerCase();
const val = m[2];
if (!val) continue;
const escaped = escapeLike(val);
if (kind === "tag") tagTerms.push(escaped);
else if (kind === "title") titleTerms.push(escaped);
else if (kind === "content") contentTerms.push(escaped);
spans.push([m.index, m.index + m[0].length]);
}
let rest = raw;
for (const [start, end] of [...spans].sort((a, b) => b[0] - a[0])) {
rest = `${rest.slice(0, start)} ${rest.slice(end)}`;
}
const defaultTrim = rest.replace(/\s+/g, " ").trim();
const defaultText = defaultTrim === "" ? null : escapeLike(defaultTrim);
return { defaultText, tagTerms, titleTerms, contentTerms };
}
function nowIso(): string {
return new Date().toISOString();
}
@@ -101,22 +133,59 @@ export function listSnippets(q: string | null, sort: string | null): SnippetRow[
)
.all() as typeof rows;
} else {
const term = escapeLike(trimmed);
rows = database
.query(
`SELECT s.id, s.title, s.body, s.sort_order, s.created_at, s.updated_at
FROM snippets s
WHERE s.title LIKE '%' || ?1 || '%' ESCAPE '\\'
OR s.body LIKE '%' || ?1 || '%' ESCAPE '\\'
const parsed = parseSearchQuery(trimmed);
const conditions: string[] = [];
const params: string[] = [];
if (parsed.defaultText !== null) {
conditions.push(`(
(s.title IS NOT NULL AND s.title LIKE '%' || ? || '%' ESCAPE '\\')
OR EXISTS (
SELECT 1 FROM snippet_tags st
JOIN tags t ON t.id = st.tag_id
WHERE st.snippet_id = s.id
AND t.name LIKE '%' || ?1 || '%' ESCAPE '\\'
AND t.name LIKE '%' || ? || '%' ESCAPE '\\'
)
)`);
params.push(parsed.defaultText, parsed.defaultText);
}
for (const term of parsed.tagTerms) {
conditions.push(`EXISTS (
SELECT 1 FROM snippet_tags st
JOIN tags t ON t.id = st.tag_id
WHERE st.snippet_id = s.id
AND t.name LIKE '%' || ? || '%' ESCAPE '\\'
)`);
params.push(term);
}
for (const term of parsed.titleTerms) {
conditions.push(`(s.title IS NOT NULL AND s.title LIKE '%' || ? || '%' ESCAPE '\\')`);
params.push(term);
}
for (const term of parsed.contentTerms) {
conditions.push(`(s.body LIKE '%' || ? || '%' ESCAPE '\\')`);
params.push(term);
}
if (conditions.length === 0) {
rows = database
.query(
`SELECT s.id, s.title, s.body, s.sort_order, s.created_at, s.updated_at
FROM snippets s
${order}`,
)
.all(term) as typeof rows;
.all() as typeof rows;
} else {
const where = conditions.join(" AND ");
rows = database
.query(
`SELECT s.id, s.title, s.body, s.sort_order, s.created_at, s.updated_at
FROM snippets s
WHERE ${where}
${order}`,
)
.all(...params) as typeof rows;
}
}
const ids = rows.map(r => r.id);
@@ -174,7 +243,12 @@ export function createSnippet(input: {
}): SnippetRow {
const database = getDb();
const t = nowIso();
const title = input.title === undefined || input.title === "" ? null : String(input.title);
const title =
input.title === undefined || input.title === null
? null
: String(input.title).trim() === ""
? null
: String(input.title).trim();
const body = input.body ?? "";
const sortOrder = typeof input.sort_order === "number" ? input.sort_order : Date.now();
@@ -210,7 +284,11 @@ export function updateSnippet(
};
const title =
input.title === undefined ? current.title : input.title === "" || input.title === null ? null : String(input.title);
input.title === undefined
? current.title
: input.title === null || String(input.title).trim() === ""
? null
: String(input.title).trim();
const body = input.body === undefined ? current.body : String(input.body);
const sort_order = input.sort_order === undefined ? current.sort_order : input.sort_order;
const t = nowIso();