snapshot
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
plans/
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
# dependencies (bun install)
|
# dependencies (bun install)
|
||||||
node_modules
|
node_modules
|
||||||
|
|
||||||
|
# local sqlite database
|
||||||
|
data/
|
||||||
|
|
||||||
# output
|
# output
|
||||||
out
|
out
|
||||||
dist
|
dist
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.545.0",
|
"lucide-react": "^0.545.0",
|
||||||
|
"marked": "^18.0.0",
|
||||||
"react": "^19",
|
"react": "^19",
|
||||||
"react-dom": "^19",
|
"react-dom": "^19",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
@@ -140,6 +141,8 @@
|
|||||||
|
|
||||||
"lucide-react": ["lucide-react@0.545.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-7r1/yUuflQDSt4f1bpn5ZAocyIxcTyVyBBChSVtBKn5M+392cPmI5YJMWOJKk/HUWGm5wg83chlAZtCcGbEZtw=="],
|
"lucide-react": ["lucide-react@0.545.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-7r1/yUuflQDSt4f1bpn5ZAocyIxcTyVyBBChSVtBKn5M+392cPmI5YJMWOJKk/HUWGm5wg83chlAZtCcGbEZtw=="],
|
||||||
|
|
||||||
|
"marked": ["marked@18.0.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-2e7Qiv/HJSXj8rDEpgTvGKsP8yYtI9xXHKDnrftrmnrJPaFNM7VRb2YCzWaX4BP1iCJ/XPduzDJZMFoqTCcIMA=="],
|
||||||
|
|
||||||
"react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="],
|
"react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="],
|
||||||
|
|
||||||
"react-dom": ["react-dom@19.2.5", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="],
|
"react-dom": ["react-dom@19.2.5", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="],
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.545.0",
|
"lucide-react": "^0.545.0",
|
||||||
|
"marked": "^18.0.0",
|
||||||
"react": "^19",
|
"react": "^19",
|
||||||
"react-dom": "^19",
|
"react-dom": "^19",
|
||||||
"tailwind-merge": "^3.3.1"
|
"tailwind-merge": "^3.3.1"
|
||||||
|
|||||||
@@ -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 { NotesApp } from "@/components/NotesApp";
|
||||||
import { APITester } from "./APITester";
|
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
import logo from "./logo.svg";
|
|
||||||
import reactLogo from "./react.svg";
|
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto p-8 text-center relative z-10">
|
<div className="relative z-10 w-full">
|
||||||
<div className="flex justify-center items-center gap-8 mb-8">
|
<NotesApp />
|
||||||
<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>
|
</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 {
|
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 charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="icon" type="image/svg+xml" href="./logo.svg" />
|
<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>
|
<script type="module" src="./frontend.tsx" async></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
+95
-20
@@ -1,39 +1,114 @@
|
|||||||
import { serve } from "bun";
|
import { serve } from "bun";
|
||||||
import index from "./index.html";
|
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({
|
const server = serve({
|
||||||
routes: {
|
routes: {
|
||||||
// Serve index.html for all unmatched routes.
|
"/api/snippets": {
|
||||||
"/*": index,
|
|
||||||
|
|
||||||
"/api/hello": {
|
|
||||||
async GET(req) {
|
async GET(req) {
|
||||||
return Response.json({
|
const url = new URL(req.url);
|
||||||
message: "Hello, world!",
|
const q = url.searchParams.get("q");
|
||||||
method: "GET",
|
const sort = url.searchParams.get("sort");
|
||||||
});
|
return Response.json(listSnippets(q, sort));
|
||||||
},
|
},
|
||||||
async PUT(req) {
|
async POST(req) {
|
||||||
return Response.json({
|
const body = await readJson(req);
|
||||||
message: "Hello, world!",
|
if (!body || typeof body !== "object") {
|
||||||
method: "PUT",
|
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 => {
|
"/api/snippets/:id": {
|
||||||
const name = req.params.name;
|
async GET(req) {
|
||||||
return Response.json({
|
const id = Number(req.params.id);
|
||||||
message: `Hello, ${name}!`,
|
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" && {
|
development: process.env.NODE_ENV !== "production" && {
|
||||||
// Enable browser hot reloading in development
|
|
||||||
hmr: true,
|
hmr: true,
|
||||||
|
|
||||||
// Echo console logs from the browser to the server
|
|
||||||
console: true,
|
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