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
+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);
}