From 6cf48a9a9e6d662a9947daced1dadb04838c7849 Mon Sep 17 00:00:00 2001 From: Nicholas Keller Date: Tue, 9 Jun 2026 23:48:27 -0400 Subject: [PATCH] v1 --- .claude/settings.local.json | 21 ++ .gitignore | 4 + README.md | 56 +++++ bun.lock | 19 ++ package.json | 13 ++ public/app.html | 85 +++++++ public/app.js | 262 +++++++++++++++++++++ public/index.html | 83 +++++++ public/styles.css | 445 ++++++++++++++++++++++++++++++++++++ src/db.ts | 118 ++++++++++ src/server.ts | 138 +++++++++++ tsconfig.json | 12 + 12 files changed, 1256 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 .gitignore create mode 100644 README.md create mode 100644 bun.lock create mode 100644 package.json create mode 100644 public/app.html create mode 100644 public/app.js create mode 100644 public/index.html create mode 100644 public/styles.css create mode 100644 src/db.ts create mode 100644 src/server.ts create mode 100644 tsconfig.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..eb10111 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,21 @@ +{ + "permissions": { + "allow": [ + "Bash(bun --version)", + "Bash(bun add *)", + "Bash(PORT=3000 bun *)", + "Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:3000/)", + "Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:3000/app)", + "Bash(curl -s -X POST http://localhost:3000/api/ideas -H \"Content-Type: application/json\" -d '{\"title\":\"AI recipe planner\",\"description\":\"Plan meals from pantry items\"}')", + "Bash(curl -s http://localhost:3000/api/ideas)", + "Bash(curl -s -X POST http://localhost:3000/api/ideas/1/notes -H \"Content-Type: application/json\" -d '{\"body\":\"Could use the Claude API for parsing receipts\"}')", + "Bash(curl -s -o /dev/null -w \"%{http_code} %{content_type}\" http://localhost:3000/styles.css)", + "Bash(curl -s -o /dev/null -w \"%{http_code} %{content_type}\" http://localhost:3000/app.js)", + "Bash(curl -s -X DELETE http://localhost:3000/api/ideas/1)", + "Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:3000/api/ideas/1/notes)", + "Bash(kill %1)", + "Bash(lsof -ti:3000)", + "Bash(xargs kill)" + ] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..39bb136 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +*.db +*.db-shm +*.db-wal diff --git a/README.md b/README.md new file mode 100644 index 0000000..2364c5c --- /dev/null +++ b/README.md @@ -0,0 +1,56 @@ +# ✨ Spark Slop + +A small, fast web app for capturing **project ideas** and growing them with +**timestamped notes** — like a comment thread for each idea. + +Built with **Bun**, **TypeScript**, and **SQLite** (`bun:sqlite`). No external +runtime dependencies — the whole backend is the Bun standard library. + +## Stack + +- **Bun.serve** — HTTP server + static file serving +- **bun:sqlite** — embedded database (single `spark_slop.db` file, WAL mode) +- **Vanilla TS/JS frontend** — no build step, no framework +- A landing page (`/`) and the app (`/app`) + +## Run it + +```bash +bun run dev # auto-reload on file changes +# or +bun run start # plain run +``` + +Then open **http://localhost:3000**. Set `PORT` to change the port: + +```bash +PORT=8080 bun run start +``` + +The SQLite database file (`spark_slop.db`) is created automatically on first run. + +## Project layout + +``` +src/ + db.ts # SQLite schema + typed query functions + server.ts # Bun.serve: JSON API + static files +public/ + index.html # landing page + app.html # the app (idea list + notes) + app.js # frontend logic (API client, rendering) + styles.css # modern dark UI +``` + +## API + +| Method | Path | Description | +| ------ | ------------------------- | -------------------------- | +| GET | `/api/ideas` | List ideas (with note counts) | +| POST | `/api/ideas` | Create an idea `{title, description}` | +| GET | `/api/ideas/:id` | Get one idea | +| PUT | `/api/ideas/:id` | Update an idea | +| DELETE | `/api/ideas/:id` | Delete an idea (cascades notes) | +| GET | `/api/ideas/:id/notes` | List notes for an idea | +| POST | `/api/ideas/:id/notes` | Add a note `{body}` | +| DELETE | `/api/notes/:id` | Delete a note | diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..2a4bc6d --- /dev/null +++ b/bun.lock @@ -0,0 +1,19 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "spark-slop", + "devDependencies": { + "bun-types": "^1.3.14", + }, + }, + }, + "packages": { + "@types/node": ["@types/node@25.9.2", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw=="], + + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..10f8510 --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "name": "spark-slop", + "version": "1.0.0", + "description": "Store project ideas with timestamped notes", + "type": "module", + "scripts": { + "dev": "bun --watch src/server.ts", + "start": "bun src/server.ts" + }, + "devDependencies": { + "bun-types": "^1.3.14" + } +} diff --git a/public/app.html b/public/app.html new file mode 100644 index 0000000..6a32b2e --- /dev/null +++ b/public/app.html @@ -0,0 +1,85 @@ + + + + + + Spark Slop — Your ideas + + + + + + + +
+
+
+
+

Your ideas

+

Capture a spark, then grow it with timestamped notes.

+
+
+ +
+ +
+
+

New idea

+
+
+ + +
+
+ + +
+ +
+
+ +
+

All ideas (0)

+
+
+
+ + +
+
+ 👈 + Select an idea to see its notes, or create one to get started. +
+
+
+
+
+ +
+ + + + diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..ffabc7f --- /dev/null +++ b/public/app.js @@ -0,0 +1,262 @@ +// --- Tiny API client --- +const api = { + async req(method, path, body) { + const res = await fetch(path, { + method, + headers: body ? { "Content-Type": "application/json" } : undefined, + body: body ? JSON.stringify(body) : undefined, + }); + const data = res.status === 204 ? null : await res.json().catch(() => null); + if (!res.ok) throw new Error((data && data.error) || "Request failed"); + return data; + }, + ideas: () => api.req("GET", "/api/ideas"), + createIdea: (title, description) => + api.req("POST", "/api/ideas", { title, description }), + deleteIdea: (id) => api.req("DELETE", `/api/ideas/${id}`), + notes: (id) => api.req("GET", `/api/ideas/${id}/notes`), + addNote: (id, body) => api.req("POST", `/api/ideas/${id}/notes`, { body }), + deleteNote: (id) => api.req("DELETE", `/api/notes/${id}`), +}; + +// --- State --- +let ideas = []; +let selectedId = null; + +// --- Helpers --- +const $ = (sel) => document.querySelector(sel); + +function escapeHtml(s) { + return String(s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +// SQLite stores UTC datetimes as "YYYY-MM-DD HH:MM:SS"; parse as UTC. +function parseUTC(s) { + return new Date(s.replace(" ", "T") + "Z"); +} + +function relativeTime(s) { + const then = parseUTC(s); + const diff = (Date.now() - then.getTime()) / 1000; + if (diff < 45) return "just now"; + if (diff < 90) return "a minute ago"; + if (diff < 3600) return `${Math.round(diff / 60)} min ago`; + if (diff < 7200) return "an hour ago"; + if (diff < 86400) return `${Math.round(diff / 3600)} hours ago`; + if (diff < 172800) return "yesterday"; + if (diff < 604800) return `${Math.round(diff / 86400)} days ago`; + return then.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + }); +} + +function fullTime(s) { + return parseUTC(s).toLocaleString(undefined, { + dateStyle: "medium", + timeStyle: "short", + }); +} + +let toastTimer; +function toast(msg, isError = false) { + const el = $("#toast"); + el.textContent = msg; + el.classList.toggle("error", isError); + el.classList.add("show"); + clearTimeout(toastTimer); + toastTimer = setTimeout(() => el.classList.remove("show"), 2600); +} + +// --- Rendering --- +function renderList() { + const list = $("#ideas-list"); + $("#idea-count").textContent = ideas.length; + + if (ideas.length === 0) { + list.innerHTML = + '
No ideas yet. Add your first one!
'; + return; + } + + list.innerHTML = ideas + .map( + (idea) => ` +
+

${escapeHtml(idea.title)}

+
+ ${idea.note_count} note${idea.note_count === 1 ? "" : "s"} + updated ${relativeTime(idea.updated_at)} +
+
` + ) + .join(""); + + list.querySelectorAll(".idea-item").forEach((el) => { + el.addEventListener("click", () => selectIdea(Number(el.dataset.id))); + }); +} + +async function renderDetail() { + const panel = $("#detail-panel"); + const idea = ideas.find((i) => i.id === selectedId); + + if (!idea) { + panel.innerHTML = + '
👈Select an idea to see its notes, or create one to get started.
'; + return; + } + + panel.innerHTML = ` +
+

${escapeHtml(idea.title)}

+ +
+ ${idea.description ? `

${escapeHtml(idea.description)}

` : ""} +
Created ${fullTime(idea.created_at)}
+
+

Notes

+
Loading…
+
+ +
+
+ `; + + $("#del-idea").addEventListener("click", () => onDeleteIdea(idea.id)); + + const form = $("#note-form"); + form.addEventListener("submit", (e) => { + e.preventDefault(); + onAddNote(idea.id); + }); + $("#note-body").addEventListener("keydown", (e) => { + if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { + e.preventDefault(); + onAddNote(idea.id); + } + }); + + loadNotes(idea.id); +} + +function renderNotes(notes) { + const container = $("#notes-container"); + if (!container) return; + if (notes.length === 0) { + container.innerHTML = + '
No notes yet — add the first one below.
'; + return; + } + container.innerHTML = notes + .map( + (n) => ` +
+
📝
+
+
${escapeHtml(n.body)}
+
+ ${relativeTime(n.created_at)} + delete +
+
+
` + ) + .join(""); + + container.querySelectorAll(".del").forEach((el) => { + el.addEventListener("click", () => onDeleteNote(Number(el.dataset.note))); + }); +} + +// --- Actions --- +async function refresh() { + try { + ideas = await api.ideas(); + renderList(); + renderDetail(); + } catch (e) { + toast(e.message, true); + } +} + +function selectIdea(id) { + selectedId = id; + renderList(); + renderDetail(); +} + +async function loadNotes(id) { + try { + const notes = await api.notes(id); + if (selectedId === id) renderNotes(notes); + } catch (e) { + toast(e.message, true); + } +} + +async function onCreateIdea(e) { + e.preventDefault(); + const title = $("#idea-title").value.trim(); + const description = $("#idea-desc").value.trim(); + if (!title) return; + try { + const idea = await api.createIdea(title, description); + $("#idea-form").reset(); + await refresh(); + selectIdea(idea.id); + toast("Idea added ✨"); + } catch (err) { + toast(err.message, true); + } +} + +async function onDeleteIdea(id) { + if (!confirm("Delete this idea and all its notes?")) return; + try { + await api.deleteIdea(id); + if (selectedId === id) selectedId = null; + await refresh(); + toast("Idea deleted"); + } catch (err) { + toast(err.message, true); + } +} + +async function onAddNote(id) { + const input = $("#note-body"); + const body = input.value.trim(); + if (!body) return; + try { + await api.addNote(id, body); + input.value = ""; + // Refresh notes immediately, plus list for the updated count/order. + await Promise.all([loadNotes(id), refreshListOnly()]); + } catch (err) { + toast(err.message, true); + } +} + +async function refreshListOnly() { + ideas = await api.ideas(); + renderList(); +} + +async function onDeleteNote(noteId) { + try { + await api.deleteNote(noteId); + if (selectedId) await Promise.all([loadNotes(selectedId), refreshListOnly()]); + toast("Note deleted"); + } catch (err) { + toast(err.message, true); + } +} + +// --- Init --- +$("#idea-form").addEventListener("submit", onCreateIdea); +refresh(); diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..21981bd --- /dev/null +++ b/public/index.html @@ -0,0 +1,83 @@ + + + + + + Spark Slop — Capture every project idea + + + + + + + + +
+
+ SQLite · Bun · TypeScript +

+ Every idea deserves a
+ place to grow. +

+

+ Spark Slop is a tiny, fast workspace for your project ideas. Jot the + spark, then leave timestamped notes as the thought evolves — like a + comment thread for your own brain. +

+ +
+
+ +
+
+
+ 💡 +

Capture the spark

+

+ Drop a title and a quick description the moment inspiration hits. + No friction, no fields you don't need. +

+
+
+ 💬 +

Notes with timestamps

+

+ Add running notes like comments. Each one is stamped with the + moment you wrote it, so you can watch an idea mature over time. +

+
+
+ +

Fast & local

+

+ Powered by Bun and SQLite. Your data lives in a single file on your + machine — snappy, private, and yours. +

+
+
+
+ + + + diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..94a1219 --- /dev/null +++ b/public/styles.css @@ -0,0 +1,445 @@ +:root { + --bg: #0b0d12; + --bg-soft: #12151d; + --surface: #161a24; + --surface-2: #1d2230; + --border: #262c3a; + --text: #e8ebf2; + --muted: #8b93a7; + --accent: #7c5cff; + --accent-2: #00d4ff; + --danger: #ff5470; + --radius: 14px; + --shadow: 0 10px 40px -12px rgba(0, 0, 0, 0.6); + --grad: linear-gradient(135deg, var(--accent), var(--accent-2)); +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + scroll-behavior: smooth; +} + +body { + font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, + sans-serif; + background: var(--bg); + color: var(--text); + line-height: 1.6; + -webkit-font-smoothing: antialiased; +} + +a { + color: inherit; + text-decoration: none; +} + +.container { + width: 100%; + max-width: 1100px; + margin: 0 auto; + padding: 0 24px; +} + +/* ---------- Buttons ---------- */ +.btn { + display: inline-flex; + align-items: center; + gap: 8px; + border: 1px solid var(--border); + background: var(--surface-2); + color: var(--text); + padding: 10px 18px; + border-radius: 10px; + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; + transition: transform 0.12s ease, border-color 0.2s ease, background 0.2s ease; +} +.btn:hover { + transform: translateY(-1px); + border-color: var(--accent); +} +.btn-primary { + background: var(--grad); + border: none; + color: #fff; + box-shadow: 0 8px 24px -8px rgba(124, 92, 255, 0.6); +} +.btn-ghost { + background: transparent; +} +.btn-danger { + color: var(--danger); + border-color: transparent; + background: transparent; + padding: 6px 10px; +} +.btn-danger:hover { + border-color: var(--danger); +} +.btn-sm { + padding: 6px 12px; + font-size: 0.85rem; +} + +/* ---------- Nav ---------- */ +.nav { + position: sticky; + top: 0; + z-index: 50; + backdrop-filter: blur(12px); + background: rgba(11, 13, 18, 0.7); + border-bottom: 1px solid var(--border); +} +.nav-inner { + display: flex; + align-items: center; + justify-content: space-between; + height: 68px; +} +.logo { + display: flex; + align-items: center; + gap: 10px; + font-weight: 800; + font-size: 1.2rem; + letter-spacing: -0.02em; +} +.logo .spark { + font-size: 1.4rem; +} +.gradient-text { + background: var(--grad); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; +} + +/* ---------- Landing ---------- */ +.hero { + position: relative; + text-align: center; + padding: 120px 0 100px; + overflow: hidden; +} +.hero::before { + content: ""; + position: absolute; + top: -200px; + left: 50%; + transform: translateX(-50%); + width: 700px; + height: 700px; + background: radial-gradient( + circle, + rgba(124, 92, 255, 0.25), + transparent 60% + ); + filter: blur(40px); + z-index: -1; +} +.badge { + display: inline-block; + padding: 6px 14px; + border: 1px solid var(--border); + border-radius: 999px; + font-size: 0.82rem; + color: var(--muted); + margin-bottom: 28px; + background: var(--surface); +} +.hero h1 { + font-size: clamp(2.4rem, 6vw, 4.2rem); + line-height: 1.05; + letter-spacing: -0.03em; + font-weight: 800; + margin-bottom: 22px; +} +.hero p.sub { + font-size: 1.2rem; + color: var(--muted); + max-width: 620px; + margin: 0 auto 38px; +} +.hero-actions { + display: flex; + gap: 14px; + justify-content: center; + flex-wrap: wrap; +} + +.features { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 22px; + padding: 40px 0 120px; +} +.feature-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 28px; + transition: transform 0.18s ease, border-color 0.2s ease; +} +.feature-card:hover { + transform: translateY(-4px); + border-color: var(--accent); +} +.feature-card .icon { + font-size: 1.8rem; + margin-bottom: 14px; + display: block; +} +.feature-card h3 { + font-size: 1.15rem; + margin-bottom: 8px; +} +.feature-card p { + color: var(--muted); + font-size: 0.95rem; +} + +footer { + border-top: 1px solid var(--border); + padding: 30px 0; + color: var(--muted); + font-size: 0.9rem; + text-align: center; +} + +/* ---------- App ---------- */ +.app-main { + padding: 40px 0 80px; +} +.app-header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 20px; + margin-bottom: 32px; + flex-wrap: wrap; +} +.app-header h1 { + font-size: 2rem; + letter-spacing: -0.02em; +} +.app-header p { + color: var(--muted); +} + +.layout { + display: grid; + grid-template-columns: 380px 1fr; + gap: 28px; + align-items: start; +} +@media (max-width: 860px) { + .layout { + grid-template-columns: 1fr; + } +} + +.panel { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 22px; +} +.panel h2 { + font-size: 1.05rem; + margin-bottom: 16px; +} + +/* Forms */ +.field { + margin-bottom: 14px; +} +.field label { + display: block; + font-size: 0.85rem; + color: var(--muted); + margin-bottom: 6px; +} +input[type="text"], +textarea { + width: 100%; + background: var(--bg-soft); + border: 1px solid var(--border); + border-radius: 10px; + color: var(--text); + padding: 11px 13px; + font-size: 0.95rem; + font-family: inherit; + resize: vertical; + transition: border-color 0.2s ease; +} +input[type="text"]:focus, +textarea:focus { + outline: none; + border-color: var(--accent); +} + +/* Idea list */ +.ideas-list { + display: flex; + flex-direction: column; + gap: 12px; + max-height: 70vh; + overflow-y: auto; +} +.idea-item { + border: 1px solid var(--border); + background: var(--bg-soft); + border-radius: 12px; + padding: 14px 16px; + cursor: pointer; + transition: border-color 0.18s ease, transform 0.12s ease; +} +.idea-item:hover { + border-color: var(--accent); + transform: translateX(2px); +} +.idea-item.active { + border-color: var(--accent); + background: var(--surface-2); +} +.idea-item h3 { + font-size: 1rem; + margin-bottom: 4px; +} +.idea-item .meta { + font-size: 0.78rem; + color: var(--muted); + display: flex; + gap: 10px; +} +.pill { + background: rgba(124, 92, 255, 0.15); + color: #b9a8ff; + padding: 1px 8px; + border-radius: 999px; + font-weight: 600; +} + +/* Detail */ +.detail-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 8px; +} +.detail-head h2 { + font-size: 1.5rem; + margin: 0; +} +.detail-desc { + color: var(--muted); + margin-bottom: 4px; + white-space: pre-wrap; +} +.detail-meta { + font-size: 0.8rem; + color: var(--muted); + margin-bottom: 24px; +} + +.divider { + height: 1px; + background: var(--border); + margin: 18px 0; +} + +/* Notes / comments */ +.note { + display: flex; + gap: 12px; + padding: 14px 0; + border-bottom: 1px solid var(--border); +} +.note:last-child { + border-bottom: none; +} +.note .avatar { + width: 34px; + height: 34px; + border-radius: 50%; + background: var(--grad); + flex-shrink: 0; + display: grid; + place-items: center; + font-size: 0.9rem; +} +.note-body { + flex: 1; + min-width: 0; +} +.note-body .text { + white-space: pre-wrap; + word-wrap: break-word; +} +.note-time { + font-size: 0.75rem; + color: var(--muted); + margin-top: 4px; + display: flex; + align-items: center; + gap: 8px; +} +.note-time .del { + color: var(--danger); + cursor: pointer; + opacity: 0; + transition: opacity 0.15s ease; +} +.note:hover .note-time .del { + opacity: 1; +} + +.note-composer { + margin-top: 18px; + display: flex; + flex-direction: column; + gap: 10px; +} +.note-composer .row { + display: flex; + justify-content: flex-end; +} + +/* Empty / states */ +.empty { + text-align: center; + color: var(--muted); + padding: 60px 20px; +} +.empty .big { + font-size: 2.5rem; + display: block; + margin-bottom: 12px; +} + +.toast { + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%) translateY(100px); + background: var(--surface-2); + border: 1px solid var(--border); + padding: 12px 20px; + border-radius: 10px; + box-shadow: var(--shadow); + transition: transform 0.25s ease; + z-index: 100; +} +.toast.show { + transform: translateX(-50%) translateY(0); +} +.toast.error { + border-color: var(--danger); +} diff --git a/src/db.ts b/src/db.ts new file mode 100644 index 0000000..300d946 --- /dev/null +++ b/src/db.ts @@ -0,0 +1,118 @@ +import { Database } from "bun:sqlite"; + +export const db = new Database("spark_slop.db", { create: true }); + +// Use WAL for better concurrent read/write performance. +db.exec("PRAGMA journal_mode = WAL;"); +db.exec("PRAGMA foreign_keys = ON;"); + +db.exec(` + CREATE TABLE IF NOT EXISTS ideas ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); +`); + +db.exec(` + CREATE TABLE IF NOT EXISTS notes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + idea_id INTEGER NOT NULL, + body TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (idea_id) REFERENCES ideas(id) ON DELETE CASCADE + ); +`); + +db.exec(`CREATE INDEX IF NOT EXISTS idx_notes_idea_id ON notes(idea_id);`); + +export interface Idea { + id: number; + title: string; + description: string; + created_at: string; + updated_at: string; + note_count?: number; +} + +export interface Note { + id: number; + idea_id: number; + body: string; + created_at: string; +} + +// --- Idea queries --- + +export function listIdeas(): Idea[] { + return db + .query( + `SELECT i.*, COUNT(n.id) AS note_count + FROM ideas i + LEFT JOIN notes n ON n.idea_id = i.id + GROUP BY i.id + ORDER BY i.updated_at DESC` + ) + .all() as Idea[]; +} + +export function getIdea(id: number): Idea | null { + return (db.query(`SELECT * FROM ideas WHERE id = ?`).get(id) as Idea) ?? null; +} + +export function createIdea(title: string, description: string): Idea { + const row = db + .query( + `INSERT INTO ideas (title, description) VALUES (?, ?) RETURNING *` + ) + .get(title, description) as Idea; + return row; +} + +export function updateIdea( + id: number, + title: string, + description: string +): Idea | null { + return ( + (db + .query( + `UPDATE ideas + SET title = ?, description = ?, updated_at = datetime('now') + WHERE id = ? + RETURNING *` + ) + .get(title, description, id) as Idea) ?? null + ); +} + +export function deleteIdea(id: number): boolean { + const res = db.query(`DELETE FROM ideas WHERE id = ?`).run(id); + return res.changes > 0; +} + +// --- Note queries --- + +export function listNotes(ideaId: number): Note[] { + return db + .query(`SELECT * FROM notes WHERE idea_id = ? ORDER BY created_at ASC, id ASC`) + .all(ideaId) as Note[]; +} + +export function createNote(ideaId: number, body: string): Note { + const note = db + .query(`INSERT INTO notes (idea_id, body) VALUES (?, ?) RETURNING *`) + .get(ideaId, body) as Note; + // Touch the parent idea so it sorts to the top. + db.query(`UPDATE ideas SET updated_at = datetime('now') WHERE id = ?`).run( + ideaId + ); + return note; +} + +export function deleteNote(id: number): boolean { + const res = db.query(`DELETE FROM notes WHERE id = ?`).run(id); + return res.changes > 0; +} diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..1bd8866 --- /dev/null +++ b/src/server.ts @@ -0,0 +1,138 @@ +import { + listIdeas, + getIdea, + createIdea, + updateIdea, + deleteIdea, + listNotes, + createNote, + deleteNote, +} from "./db"; + +const PORT = Number(process.env.PORT ?? 3000); +const PUBLIC_DIR = new URL("../public/", import.meta.url).pathname; + +function json(data: unknown, status = 200): Response { + return new Response(JSON.stringify(data), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +function err(message: string, status = 400): Response { + return json({ error: message }, status); +} + +async function readJson(req: Request): Promise { + try { + return await req.json(); + } catch { + return {}; + } +} + +async function serveStatic(pathname: string): Promise { + // Map "/" -> index.html, "/app" -> app.html, otherwise the literal file. + let rel = pathname === "/" ? "index.html" : pathname.slice(1); + if (rel === "app") rel = "app.html"; + + let file = Bun.file(PUBLIC_DIR + rel); + if (!(await file.exists())) { + // SPA-ish fallback for unknown non-asset routes. + if (!rel.includes(".")) file = Bun.file(PUBLIC_DIR + "index.html"); + } + if (!(await file.exists())) return new Response("Not found", { status: 404 }); + return new Response(file); +} + +const server = Bun.serve({ + port: PORT, + async fetch(req) { + const url = new URL(req.url); + const { pathname } = url; + + // ---------- API ---------- + if (pathname.startsWith("/api/")) { + try { + return await handleApi(req, pathname); + } catch (e) { + console.error(e); + return err("Internal server error", 500); + } + } + + // ---------- Static ---------- + return serveStatic(pathname); + }, +}); + +async function handleApi(req: Request, pathname: string): Promise { + const method = req.method; + + // /api/ideas + if (pathname === "/api/ideas") { + if (method === "GET") return json(listIdeas()); + if (method === "POST") { + const { title, description } = await readJson(req); + if (!title || typeof title !== "string" || !title.trim()) + return err("Title is required"); + return json( + createIdea(title.trim(), (description ?? "").toString().trim()), + 201 + ); + } + return err("Method not allowed", 405); + } + + // /api/ideas/:id + let m = pathname.match(/^\/api\/ideas\/(\d+)$/); + if (m) { + const id = Number(m[1]); + if (method === "GET") { + const idea = getIdea(id); + return idea ? json(idea) : err("Idea not found", 404); + } + if (method === "PUT") { + const { title, description } = await readJson(req); + if (!title || !title.trim()) return err("Title is required"); + const updated = updateIdea( + id, + title.trim(), + (description ?? "").toString().trim() + ); + return updated ? json(updated) : err("Idea not found", 404); + } + if (method === "DELETE") { + return deleteIdea(id) ? json({ ok: true }) : err("Idea not found", 404); + } + return err("Method not allowed", 405); + } + + // /api/ideas/:id/notes + m = pathname.match(/^\/api\/ideas\/(\d+)\/notes$/); + if (m) { + const ideaId = Number(m[1]); + if (!getIdea(ideaId)) return err("Idea not found", 404); + if (method === "GET") return json(listNotes(ideaId)); + if (method === "POST") { + const { body } = await readJson(req); + if (!body || !body.toString().trim()) return err("Note body is required"); + return json(createNote(ideaId, body.toString().trim()), 201); + } + return err("Method not allowed", 405); + } + + // /api/notes/:id + m = pathname.match(/^\/api\/notes\/(\d+)$/); + if (m) { + const id = Number(m[1]); + if (method === "DELETE") { + return deleteNote(id) ? json({ ok: true }) : err("Note not found", 404); + } + return err("Method not allowed", 405); + } + + return err("Not found", 404); +} + +console.log(`✨ Spark Slop running at http://localhost:${server.port}`); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..f381c00 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "lib": ["ESNext", "DOM"], + "module": "ESNext", + "target": "ESNext", + "moduleResolution": "bundler", + "types": ["bun-types"], + "strict": true, + "skipLibCheck": true, + "noEmit": true + } +}