snapshot
This commit is contained in:
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
};
|
||||
Reference in New Issue
Block a user