This commit is contained in:
Stefan ATZLESBERGER
2026-04-16 12:55:18 +02:00
parent 97dc4d043a
commit dfbcd20ab6
14 changed files with 723 additions and 116 deletions
-64
View File
@@ -1,64 +0,0 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { useRef, type FormEvent } from "react";
export function APITester() {
const responseInputRef = useRef<HTMLTextAreaElement>(null);
const testEndpoint = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
try {
const form = e.currentTarget;
const formData = new FormData(form);
const endpoint = formData.get("endpoint") as string;
const url = new URL(endpoint, location.href);
const method = formData.get("method") as string;
const res = await fetch(url, { method });
const data = await res.json();
responseInputRef.current!.value = JSON.stringify(data, null, 2);
} catch (error) {
responseInputRef.current!.value = String(error);
}
};
return (
<div className="flex flex-col gap-6">
<form onSubmit={testEndpoint} className="flex items-center gap-2">
<Label htmlFor="method" className="sr-only">
Method
</Label>
<Select name="method" defaultValue="GET">
<SelectTrigger className="w-[100px]" id="method">
<SelectValue placeholder="Method" />
</SelectTrigger>
<SelectContent align="start">
<SelectItem value="GET">GET</SelectItem>
<SelectItem value="PUT">PUT</SelectItem>
</SelectContent>
</Select>
<Label htmlFor="endpoint" className="sr-only">
Endpoint
</Label>
<Input id="endpoint" type="text" name="endpoint" defaultValue="/api/hello" placeholder="/api/hello" />
<Button type="submit" variant="secondary">
Send
</Button>
</form>
<Label htmlFor="response" className="sr-only">
Response
</Label>
<Textarea
ref={responseInputRef}
id="response"
readOnly
placeholder="Response will appear here..."
className="min-h-[140px] font-mono resize-y"
/>
</div>
);
}
+3 -30
View File
@@ -1,37 +1,10 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { APITester } from "./APITester";
import { NotesApp } from "@/components/NotesApp";
import "./index.css";
import logo from "./logo.svg";
import reactLogo from "./react.svg";
export function App() {
return (
<div className="container mx-auto p-8 text-center relative z-10">
<div className="flex justify-center items-center gap-8 mb-8">
<img
src={logo}
alt="Bun Logo"
className="h-36 p-6 transition-all duration-300 hover:drop-shadow-[0_0_2em_#646cffaa] scale-120"
/>
<img
src={reactLogo}
alt="React Logo"
className="h-36 p-6 transition-all duration-300 hover:drop-shadow-[0_0_2em_#61dafbaa] [animation:spin_20s_linear_infinite]"
/>
</div>
<Card>
<CardHeader className="gap-4">
<CardTitle className="text-3xl font-bold">Bun + React</CardTitle>
<CardDescription>
Edit <code className="rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono">src/App.tsx</code> and save to
test HMR
</CardDescription>
</CardHeader>
<CardContent>
<APITester />
</CardContent>
</Card>
<div className="relative z-10 w-full">
<NotesApp />
</div>
);
}
+130
View File
@@ -0,0 +1,130 @@
import { useCallback, useEffect, useState } from "react";
import type { Snippet } from "@/types/snippet";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { SnippetCard } from "@/components/SnippetCard";
const SORT_OPTIONS = [
{ value: "updated_desc", label: "Zuletzt geändert (neu zuerst)" },
{ value: "updated_asc", label: "Zuletzt geändert (alt zuerst)" },
{ value: "title_asc", label: "Titel A–Z" },
{ value: "title_desc", label: "Titel Z–A" },
] as const;
type SortValue = (typeof SORT_OPTIONS)[number]["value"];
export function NotesApp() {
const [query, setQuery] = useState("");
const [debouncedQuery, setDebouncedQuery] = useState("");
const [sort, setSort] = useState<SortValue>("updated_desc");
const [snippets, setSnippets] = useState<Snippet[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const t = window.setTimeout(() => setDebouncedQuery(query), 300);
return () => window.clearTimeout(t);
}, [query]);
useEffect(() => {
let cancelled = false;
(async () => {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams();
if (debouncedQuery.trim()) params.set("q", debouncedQuery.trim());
params.set("sort", sort);
const res = await fetch(`/api/snippets?${params.toString()}`);
if (!res.ok) throw new Error(await res.text());
const data = (await res.json()) as Snippet[];
if (!cancelled) setSnippets(data);
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : "Laden fehlgeschlagen");
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [debouncedQuery, sort]);
const replaceSnippet = useCallback((s: Snippet) => {
setSnippets(prev => prev.map(x => (x.id === s.id ? s : x)));
}, []);
const removeSnippet = useCallback((id: number) => {
setSnippets(prev => prev.filter(x => x.id !== id));
}, []);
const createNew = async () => {
setError(null);
try {
const res = await fetch("/api/snippets", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: null, body: "", tags: [] }),
});
if (!res.ok) {
const err = (await res.json().catch(() => null)) as { error?: string } | null;
throw new Error(err?.error ?? res.statusText);
}
const created = (await res.json()) as Snippet;
setSnippets(prev => [created, ...prev]);
} catch (e) {
setError(e instanceof Error ? e.message : "Neues Snippet fehlgeschlagen");
}
};
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>
<Input
id="search"
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Titel, Tags oder Notiztext …"
autoComplete="off"
/>
</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>
<Select value={sort} onValueChange={v => setSort(v as SortValue)}>
<SelectTrigger>
<SelectValue placeholder="Sortierung" />
</SelectTrigger>
<SelectContent>
{SORT_OPTIONS.map(o => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button type="button" className="w-full sm:w-auto" onClick={() => void createNew()}>
Neu
</Button>
</div>
</header>
{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">
{!loading && snippets.length === 0 ? (
<p className="text-center text-sm text-muted-foreground">Keine Snippets. Mit „Neu“ starten.</p>
) : null}
{snippets.map(s => (
<SnippetCard key={s.id} snippet={s} onReplace={replaceSnippet} onRemove={removeSnippet} />
))}
</div>
</div>
);
}
+198
View File
@@ -0,0 +1,198 @@
import { marked } from "marked";
import { useMemo, useState } from "react";
import type { Snippet } from "@/types/snippet";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardFooter } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
marked.setOptions({ gfm: true });
function tagsFromString(s: string): string[] {
return s
.split(",")
.map(t => t.trim())
.filter(Boolean);
}
function formatWhen(iso: string): string {
try {
return new Date(iso).toLocaleString();
} catch {
return iso;
}
}
type Props = {
snippet: Snippet;
onReplace: (s: Snippet) => void;
onRemove: (id: number) => void;
};
export function SnippetCard({ snippet, onReplace, onRemove }: Props) {
const pristineEmpty = !snippet.body && !snippet.title && snippet.tags.length === 0;
const [editing, setEditing] = useState(pristineEmpty);
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 [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 startEdit = () => {
setEditing(true);
setDirty(false);
setBody(snippet.body);
setTitle(snippet.title ?? "");
setTagsStr(snippet.tags.join(", "));
setError(null);
};
const save = async () => {
setBusy(true);
setError(null);
try {
const res = await fetch(`/api/snippets/${snippet.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: title.trim() === "" ? null : title.trim(),
body,
tags: tagsFromString(tagsStr),
}),
});
if (!res.ok) {
const err = (await res.json().catch(() => null)) as { error?: string } | null;
throw new Error(err?.error ?? res.statusText);
}
const updated = (await res.json()) as Snippet;
onReplace(updated);
setEditing(false);
setDirty(false);
} catch (e) {
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen");
} finally {
setBusy(false);
}
};
const deleteRemote = async (askConfirm: boolean) => {
if (askConfirm && !window.confirm("Snippet wirklich löschen?")) return;
setBusy(true);
setError(null);
try {
const res = await fetch(`/api/snippets/${snippet.id}`, { method: "DELETE" });
if (!res.ok && res.status !== 204) {
const err = (await res.json().catch(() => null)) as { error?: string } | null;
throw new Error(err?.error ?? res.statusText);
}
onRemove(snippet.id);
} catch (e) {
setError(e instanceof Error ? e.message : "Löschen fehlgeschlagen");
} finally {
setBusy(false);
}
};
const cancel = async () => {
if (pristineEmpty && !dirty) {
await deleteRemote(false);
return;
}
setEditing(false);
setDirty(false);
setBody(snippet.body);
setTitle(snippet.title ?? "");
setTagsStr(snippet.tags.join(", "));
setError(null);
};
const markDirty = () => setDirty(true);
return (
<Card className="text-left">
<CardContent className="pt-6">
<div className="min-h-[180px] rounded-lg border bg-muted/30 p-4">
{editing ? (
<div className="grid gap-4 lg:grid-cols-2">
<Textarea
value={body}
onChange={e => {
markDirty();
setBody(e.target.value);
}}
className="min-h-[220px] resize-y font-mono text-sm"
placeholder="Markdown …"
/>
<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"
dangerouslySetInnerHTML={{ __html: html }}
/>
</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"
dangerouslySetInnerHTML={{ __html: html }}
/>
)}
</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">
{editing ? (
<>
<Input
value={title}
onChange={e => {
markDirty();
setTitle(e.target.value);
}}
placeholder="Optionaler Titel"
/>
<Input
value={tagsStr}
onChange={e => {
markDirty();
setTagsStr(e.target.value);
}}
placeholder="Tags, kommagetrennt"
/>
</>
) : (
<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 className="text-muted-foreground">{snippet.tags.length ? snippet.tags.join(", ") : "Keine Tags"}</div>
</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">
{editing ? (
<>
<Button type="button" variant="secondary" disabled={busy} onClick={() => void cancel()}>
Abbrechen
</Button>
<Button type="button" disabled={busy} onClick={() => void save()}>
Speichern
</Button>
</>
) : (
<>
<Button type="button" variant="secondary" disabled={busy} onClick={startEdit}>
Bearbeiten
</Button>
<Button type="button" variant="destructive" disabled={busy} onClick={() => void deleteRemote(true)}>
Löschen
</Button>
</>
)}
</div>
</div>
</CardFooter>
</Card>
);
}
+1 -1
View File
@@ -6,7 +6,7 @@
}
body {
@apply grid place-items-center min-w-[320px] min-h-screen relative m-0 bg-background text-foreground;
@apply min-w-[320px] min-h-screen relative m-0 bg-background text-foreground;
}
}
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="./logo.svg" />
<title>Bun + React</title>
<title>Notes</title>
<script type="module" src="./frontend.tsx" async></script>
</head>
<body>
+95 -20
View File
@@ -1,39 +1,114 @@
import { serve } from "bun";
import index from "./index.html";
import { getDb } from "./server/db";
import { createSnippet, deleteSnippet, getSnippetById, listSnippets, updateSnippet } from "./server/snippets";
getDb();
async function readJson(req: Request): Promise<unknown> {
try {
return await req.json();
} catch {
return null;
}
}
const server = serve({
routes: {
// Serve index.html for all unmatched routes.
"/*": index,
"/api/hello": {
"/api/snippets": {
async GET(req) {
return Response.json({
message: "Hello, world!",
method: "GET",
});
const url = new URL(req.url);
const q = url.searchParams.get("q");
const sort = url.searchParams.get("sort");
return Response.json(listSnippets(q, sort));
},
async PUT(req) {
return Response.json({
message: "Hello, world!",
method: "PUT",
async POST(req) {
const body = await readJson(req);
if (!body || typeof body !== "object") {
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
}
const o = body as Record<string, unknown>;
const tags = Array.isArray(o.tags)
? o.tags.filter((t): t is string => typeof t === "string")
: [];
const snippet = createSnippet({
title:
o.title === null
? null
: typeof o.title === "string"
? o.title
: undefined,
body: typeof o.body === "string" ? o.body : "",
tags,
});
return Response.json(snippet, { status: 201 });
},
},
"/api/hello/:name": async req => {
const name = req.params.name;
return Response.json({
message: `Hello, ${name}!`,
});
"/api/snippets/:id": {
async GET(req) {
const id = Number(req.params.id);
if (!Number.isFinite(id)) {
return Response.json({ error: "Invalid id" }, { status: 400 });
}
const snippet = getSnippetById(id);
if (!snippet) {
return Response.json({ error: "Not found" }, { status: 404 });
}
return Response.json(snippet);
},
async PATCH(req) {
const id = Number(req.params.id);
if (!Number.isFinite(id)) {
return Response.json({ error: "Invalid id" }, { status: 400 });
}
const body = await readJson(req);
if (!body || typeof body !== "object") {
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
}
const o = body as Record<string, unknown>;
const patch: {
title?: string | null;
body?: string;
tags?: string[];
sort_order?: number;
} = {};
if ("title" in o) {
patch.title = o.title === null ? null : String(o.title);
}
if ("body" in o && typeof o.body === "string") {
patch.body = o.body;
}
if ("tags" in o && Array.isArray(o.tags)) {
patch.tags = o.tags.filter((t): t is string => typeof t === "string");
}
if ("sort_order" in o && typeof o.sort_order === "number" && Number.isFinite(o.sort_order)) {
patch.sort_order = o.sort_order;
}
const snippet = updateSnippet(id, patch);
if (!snippet) {
return Response.json({ error: "Not found" }, { status: 404 });
}
return Response.json(snippet);
},
async DELETE(req) {
const id = Number(req.params.id);
if (!Number.isFinite(id)) {
return Response.json({ error: "Invalid id" }, { status: 400 });
}
const ok = deleteSnippet(id);
if (!ok) {
return Response.json({ error: "Not found" }, { status: 404 });
}
return new Response(null, { status: 204 });
},
},
"/*": index,
},
development: process.env.NODE_ENV !== "production" && {
// Enable browser hot reloading in development
hmr: true,
// Echo console logs from the browser to the server
console: true,
},
});
+44
View File
@@ -0,0 +1,44 @@
import { Database } from "bun:sqlite";
import { mkdirSync } from "fs";
import path from "path";
let dbInstance: Database | null = null;
function migrate(database: Database) {
database.run(`
CREATE TABLE IF NOT EXISTS snippets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT,
body TEXT NOT NULL DEFAULT '',
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
`);
database.run(`
CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE COLLATE NOCASE
);
`);
database.run(`
CREATE TABLE IF NOT EXISTS snippet_tags (
snippet_id INTEGER NOT NULL REFERENCES snippets(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (snippet_id, tag_id)
);
`);
database.run(`CREATE INDEX IF NOT EXISTS idx_snippets_updated ON snippets(updated_at);`);
}
export function getDb(): Database {
if (dbInstance) return dbInstance;
const dir = path.join(process.cwd(), "data");
mkdirSync(dir, { recursive: true });
const file = path.join(dir, "notes.sqlite");
dbInstance = new Database(file);
dbInstance.run("PRAGMA journal_mode = WAL;");
dbInstance.run("PRAGMA foreign_keys = ON;");
migrate(dbInstance);
return dbInstance;
}
+234
View File
@@ -0,0 +1,234 @@
import type { Database } from "bun:sqlite";
import { getDb } from "./db";
export type SnippetRow = {
id: number;
title: string | null;
body: string;
sort_order: number;
created_at: string;
updated_at: string;
tags: string[];
};
function escapeLike(raw: string): string {
return raw.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_");
}
function nowIso(): string {
return new Date().toISOString();
}
function orderClause(sort: string): string {
switch (sort) {
case "updated_asc":
return "ORDER BY s.updated_at ASC, s.id ASC";
case "title_asc":
return "ORDER BY s.title COLLATE NOCASE ASC NULLS LAST, s.updated_at DESC";
case "title_desc":
return "ORDER BY s.title COLLATE NOCASE DESC NULLS LAST, s.updated_at DESC";
case "manual":
return "ORDER BY s.sort_order ASC, s.updated_at DESC";
case "updated_desc":
default:
return "ORDER BY s.updated_at DESC, s.id DESC";
}
}
function loadTagsForSnippetIds(database: Database, ids: number[]): Map<number, string[]> {
const map = new Map<number, string[]>();
if (ids.length === 0) return map;
const placeholders = ids.map(() => "?").join(", ");
const rows = database
.query(
`SELECT st.snippet_id, t.name
FROM snippet_tags st
JOIN tags t ON t.id = st.tag_id
WHERE st.snippet_id IN (${placeholders})
ORDER BY t.name COLLATE NOCASE ASC`,
)
.all(...ids) as { snippet_id: number; name: string }[];
for (const id of ids) map.set(id, []);
for (const row of rows) {
const list = map.get(row.snippet_id);
if (list) list.push(row.name);
}
return map;
}
function rowToPublic(
row: {
id: number;
title: string | null;
body: string;
sort_order: number;
created_at: string;
updated_at: string;
},
tags: string[],
): SnippetRow {
return {
id: row.id,
title: row.title,
body: row.body,
sort_order: row.sort_order,
created_at: row.created_at,
updated_at: row.updated_at,
tags,
};
}
export function listSnippets(q: string | null, sort: string | null): SnippetRow[] {
const database = getDb();
const order = orderClause(sort ?? "updated_desc");
const trimmed = (q ?? "").trim();
let rows: {
id: number;
title: string | null;
body: string;
sort_order: number;
created_at: string;
updated_at: string;
}[];
if (trimmed === "") {
rows = database
.query(
`SELECT s.id, s.title, s.body, s.sort_order, s.created_at, s.updated_at
FROM snippets s
${order}`,
)
.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 '\\'
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 '\\'
)
${order}`,
)
.all(term) as typeof rows;
}
const ids = rows.map(r => r.id);
const tagMap = loadTagsForSnippetIds(database, ids);
return rows.map(r => rowToPublic(r, tagMap.get(r.id) ?? []));
}
export function getSnippetById(id: number): SnippetRow | null {
const database = getDb();
const row = database
.query(
`SELECT id, title, body, sort_order, created_at, updated_at FROM snippets WHERE id = ?`,
)
.get(id) as
| {
id: number;
title: string | null;
body: string;
sort_order: number;
created_at: string;
updated_at: string;
}
| undefined;
if (!row) return null;
const tagMap = loadTagsForSnippetIds(database, [row.id]);
return rowToPublic(row, tagMap.get(row.id) ?? []);
}
function getOrCreateTagId(database: Database, name: string): number | null {
const trimmed = name.trim();
if (!trimmed) return null;
database.run(`INSERT OR IGNORE INTO tags (name) VALUES (?)`, [trimmed]);
const row = database.query(`SELECT id FROM tags WHERE name = ? COLLATE NOCASE`).get(trimmed) as
| { id: number }
| undefined;
return row?.id ?? null;
}
function replaceSnippetTags(database: Database, snippetId: number, tagNames: string[]) {
database.run(`DELETE FROM snippet_tags WHERE snippet_id = ?`, [snippetId]);
const unique = [...new Set(tagNames.map(t => t.trim()).filter(Boolean))];
if (unique.length === 0) return;
const insertLink = database.prepare(`INSERT OR IGNORE INTO snippet_tags (snippet_id, tag_id) VALUES (?, ?)`);
for (const name of unique) {
const tagId = getOrCreateTagId(database, name);
if (tagId !== null) insertLink.run(snippetId, tagId);
}
}
export function createSnippet(input: {
title?: string | null;
body?: string;
tags?: string[];
sort_order?: number;
}): SnippetRow {
const database = getDb();
const t = nowIso();
const title = input.title === undefined || input.title === "" ? null : String(input.title);
const body = input.body ?? "";
const sortOrder = typeof input.sort_order === "number" ? input.sort_order : Date.now();
database.run(
`INSERT INTO snippets (title, body, sort_order, created_at, updated_at) VALUES (?, ?, ?, ?, ?)`,
[title, body, sortOrder, t, t],
);
const idRow = database.query(`SELECT last_insert_rowid() AS id`).get() as { id: number };
const newId = Number(idRow.id);
replaceSnippetTags(database, newId, input.tags ?? []);
return getSnippetById(newId)!;
}
export function updateSnippet(
id: number,
input: {
title?: string | null;
body?: string;
tags?: string[];
sort_order?: number;
},
): SnippetRow | null {
const database = getDb();
const existing = database.query(`SELECT id FROM snippets WHERE id = ?`).get(id) as { id: number } | undefined;
if (!existing) return null;
const current = database
.query(`SELECT title, body, sort_order FROM snippets WHERE id = ?`)
.get(id) as {
title: string | null;
body: string;
sort_order: number;
};
const title =
input.title === undefined ? current.title : input.title === "" || input.title === null ? null : String(input.title);
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();
database.run(
`UPDATE snippets SET title = ?, body = ?, sort_order = ?, updated_at = ? WHERE id = ?`,
[title, body, sort_order, t, id],
);
if (input.tags !== undefined) {
replaceSnippetTags(database, id, input.tags);
}
return getSnippetById(id);
}
export function deleteSnippet(id: number): boolean {
const database = getDb();
const res = database.run(`DELETE FROM snippets WHERE id = ?`, [id]);
return res.changes > 0;
}
+9
View File
@@ -0,0 +1,9 @@
export type Snippet = {
id: number;
title: string | null;
body: string;
sort_order: number;
created_at: string;
updated_at: string;
tags: string[];
};