v1
This commit is contained in:
@@ -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)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
@@ -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 |
|
||||||
@@ -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=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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, ">")
|
||||||
|
.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 =
|
||||||
|
'<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();
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
@@ -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}`);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["ESNext", "DOM"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"target": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"types": ["bun-types"],
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user