snapshot
This commit is contained in:
+16
-11
@@ -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}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user