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