This commit is contained in:
Nicholas Keller
2026-06-09 23:48:27 -04:00
commit 6cf48a9a9e
12 changed files with 1256 additions and 0 deletions
+21
View File
@@ -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)"
]
}
}
+4
View File
@@ -0,0 +1,4 @@
node_modules/
*.db
*.db-shm
*.db-wal
+56
View File
@@ -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 |
+19
View File
@@ -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=="],
}
}
+13
View File
@@ -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"
}
}
+85
View File
@@ -0,0 +1,85 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Spark Slop — Your ideas</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<nav class="nav">
<div class="container nav-inner">
<a href="/" class="logo">
<span class="spark"></span>
<span>Spark<span class="gradient-text">Slop</span></span>
</a>
<a href="/" class="btn btn-ghost btn-sm">← Home</a>
</div>
</nav>
<main class="app-main">
<div class="container">
<div class="app-header">
<div>
<h1>Your ideas</h1>
<p>Capture a spark, then grow it with timestamped notes.</p>
</div>
</div>
<div class="layout">
<!-- Left: new idea + list -->
<div>
<div class="panel" style="margin-bottom: 20px">
<h2>New idea</h2>
<form id="idea-form">
<div class="field">
<label for="idea-title">Title</label>
<input
type="text"
id="idea-title"
placeholder="e.g. AI recipe planner"
autocomplete="off"
required
/>
</div>
<div class="field">
<label for="idea-desc">Description (optional)</label>
<textarea
id="idea-desc"
rows="3"
placeholder="What's the idea?"
></textarea>
</div>
<button type="submit" class="btn btn-primary" style="width: 100%">
✨ Add idea
</button>
</form>
</div>
<div class="panel">
<h2>All ideas (<span id="idea-count">0</span>)</h2>
<div class="ideas-list" id="ideas-list"></div>
</div>
</div>
<!-- Right: detail + notes -->
<div class="panel" id="detail-panel">
<div class="empty" id="detail-empty">
<span class="big">👈</span>
Select an idea to see its notes, or create one to get started.
</div>
</div>
</div>
</div>
</main>
<div class="toast" id="toast"></div>
<script src="/app.js"></script>
</body>
</html>
+262
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
// 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 =
'<div class="empty" style="padding:30px 10px">No ideas yet. Add your first one!</div>';
return;
}
list.innerHTML = ideas
.map(
(idea) => `
<div class="idea-item ${idea.id === selectedId ? "active" : ""}" data-id="${idea.id}">
<h3>${escapeHtml(idea.title)}</h3>
<div class="meta">
<span class="pill">${idea.note_count} note${idea.note_count === 1 ? "" : "s"}</span>
<span>updated ${relativeTime(idea.updated_at)}</span>
</div>
</div>`
)
.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 =
'<div class="empty"><span class="big">👈</span>Select an idea to see its notes, or create one to get started.</div>';
return;
}
panel.innerHTML = `
<div class="detail-head">
<h2>${escapeHtml(idea.title)}</h2>
<button class="btn btn-danger btn-sm" id="del-idea">Delete idea</button>
</div>
${idea.description ? `<p class="detail-desc">${escapeHtml(idea.description)}</p>` : ""}
<div class="detail-meta">Created ${fullTime(idea.created_at)}</div>
<div class="divider"></div>
<h2 style="font-size:1.05rem;margin-bottom:4px">Notes</h2>
<div id="notes-container"><div class="empty" style="padding:20px">Loading…</div></div>
<form class="note-composer" id="note-form">
<textarea id="note-body" rows="2" placeholder="Add a note… (Cmd/Ctrl+Enter to post)" required></textarea>
<div class="row"><button type="submit" class="btn btn-primary btn-sm">💬 Post note</button></div>
</form>
`;
$("#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 =
'<div class="empty" style="padding:24px">No notes yet — add the first one below.</div>';
return;
}
container.innerHTML = notes
.map(
(n) => `
<div class="note">
<div class="avatar">📝</div>
<div class="note-body">
<div class="text">${escapeHtml(n.body)}</div>
<div class="note-time">
<span title="${escapeHtml(fullTime(n.created_at))}">${relativeTime(n.created_at)}</span>
<span class="del" data-note="${n.id}">delete</span>
</div>
</div>
</div>`
)
.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();
+83
View File
@@ -0,0 +1,83 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Spark Slop — Capture every project idea</title>
<meta
name="description"
content="A clean home for your project ideas and the running notes that grow them."
/>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<nav class="nav">
<div class="container nav-inner">
<a href="/" class="logo">
<span class="spark"></span>
<span>Spark<span class="gradient-text">Slop</span></span>
</a>
<a href="/app" class="btn btn-primary btn-sm">Open the app →</a>
</div>
</nav>
<header class="hero">
<div class="container">
<span class="badge">SQLite · Bun · TypeScript</span>
<h1>
Every idea deserves a<br />
<span class="gradient-text">place to grow.</span>
</h1>
<p class="sub">
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.
</p>
<div class="hero-actions">
<a href="/app" class="btn btn-primary">Start capturing ideas</a>
<a href="#features" class="btn btn-ghost">See how it works</a>
</div>
</div>
</header>
<section id="features" class="container">
<div class="features">
<div class="feature-card">
<span class="icon">💡</span>
<h3>Capture the spark</h3>
<p>
Drop a title and a quick description the moment inspiration hits.
No friction, no fields you don't need.
</p>
</div>
<div class="feature-card">
<span class="icon">💬</span>
<h3>Notes with timestamps</h3>
<p>
Add running notes like comments. Each one is stamped with the
moment you wrote it, so you can watch an idea mature over time.
</p>
</div>
<div class="feature-card">
<span class="icon"></span>
<h3>Fast & local</h3>
<p>
Powered by Bun and SQLite. Your data lives in a single file on your
machine — snappy, private, and yours.
</p>
</div>
</div>
</section>
<footer>
<div class="container">
Built with Bun + SQLite + TypeScript · ✨ Spark Slop
</div>
</footer>
</body>
</html>
+445
View File
@@ -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);
}
+118
View File
@@ -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;
}
+138
View File
@@ -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<any> {
try {
return await req.json();
} catch {
return {};
}
}
async function serveStatic(pathname: string): Promise<Response> {
// 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<Response> {
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}`);
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"lib": ["ESNext", "DOM"],
"module": "ESNext",
"target": "ESNext",
"moduleResolution": "bundler",
"types": ["bun-types"],
"strict": true,
"skipLibCheck": true,
"noEmit": true
}
}