v2
This commit is contained in:
@@ -15,7 +15,16 @@
|
|||||||
"Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:3000/api/ideas/1/notes)",
|
"Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:3000/api/ideas/1/notes)",
|
||||||
"Bash(kill %1)",
|
"Bash(kill %1)",
|
||||||
"Bash(lsof -ti:3000)",
|
"Bash(lsof -ti:3000)",
|
||||||
"Bash(xargs kill)"
|
"Bash(xargs kill)",
|
||||||
|
"Bash(bunx tsc *)",
|
||||||
|
"Bash(lsof -i:3000 -P -n)",
|
||||||
|
"Bash(kill 35084)",
|
||||||
|
"Bash(curl -s http://localhost:3000/api/auth/providers)",
|
||||||
|
"Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:3000/api/me)",
|
||||||
|
"Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:3000/api/ideas)",
|
||||||
|
"Bash(curl -s http://localhost:3000/api/explore)",
|
||||||
|
"Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:3000/auth/github)",
|
||||||
|
"Bash(bun *)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Copy to ".env" and fill in. Bun auto-loads .env.
|
||||||
|
# You only need to configure the provider(s) you want — the login screen
|
||||||
|
# shows just the ones with both an ID and secret set.
|
||||||
|
|
||||||
|
# Public base URL of this app (used to build OAuth redirect URIs).
|
||||||
|
# Must match what you register in the OAuth app settings.
|
||||||
|
BASE_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# --- GitHub OAuth ---
|
||||||
|
# Create one at: https://github.com/settings/developers -> "New OAuth App"
|
||||||
|
# Homepage URL: http://localhost:3000
|
||||||
|
# Authorization callback URL: http://localhost:3000/auth/github/callback
|
||||||
|
GITHUB_CLIENT_ID=
|
||||||
|
GITHUB_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# --- Google OAuth ---
|
||||||
|
# Create credentials at: https://console.cloud.google.com/apis/credentials
|
||||||
|
# Application type: Web application
|
||||||
|
# Authorized redirect URI: http://localhost:3000/auth/google/callback
|
||||||
|
GOOGLE_CLIENT_ID=
|
||||||
|
GOOGLE_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Optional: change the port (defaults to 3000)
|
||||||
|
# PORT=3000
|
||||||
@@ -2,3 +2,4 @@ node_modules/
|
|||||||
*.db
|
*.db
|
||||||
*.db-shm
|
*.db-shm
|
||||||
*.db-wal
|
*.db-wal
|
||||||
|
.env
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
# ✨ Spark Slop
|
# ✨ Spark Slop
|
||||||
|
|
||||||
A small, fast web app for capturing **project ideas** and growing them with
|
A small, fast web app for capturing **project ideas** and growing them with
|
||||||
**timestamped notes** — like a comment thread for each idea.
|
**timestamped notes** — like a comment thread for each idea. Ideas are
|
||||||
|
**private to your account** by default, with opt-in sharing.
|
||||||
|
|
||||||
Built with **Bun**, **TypeScript**, and **SQLite** (`bun:sqlite`). No external
|
Built with **Bun**, **TypeScript**, and **SQLite** (`bun:sqlite`). No external
|
||||||
runtime dependencies — the whole backend is the Bun standard library.
|
runtime dependencies — the whole backend is the Bun standard library.
|
||||||
@@ -10,8 +11,46 @@ runtime dependencies — the whole backend is the Bun standard library.
|
|||||||
|
|
||||||
- **Bun.serve** — HTTP server + static file serving
|
- **Bun.serve** — HTTP server + static file serving
|
||||||
- **bun:sqlite** — embedded database (single `spark_slop.db` file, WAL mode)
|
- **bun:sqlite** — embedded database (single `spark_slop.db` file, WAL mode)
|
||||||
|
- **OAuth** sign-in (GitHub and/or Google) with cookie sessions — no passwords stored
|
||||||
- **Vanilla TS/JS frontend** — no build step, no framework
|
- **Vanilla TS/JS frontend** — no build step, no framework
|
||||||
- A landing page (`/`) and the app (`/app`)
|
|
||||||
|
## Auth & privacy
|
||||||
|
|
||||||
|
- **Sign in with GitHub or Google.** Both are optional — the login screen only
|
||||||
|
shows providers you've configured. Sessions are HttpOnly cookies (30 days);
|
||||||
|
no passwords are ever stored.
|
||||||
|
- **Private by default.** Every idea belongs to its owner and is invisible to
|
||||||
|
everyone else.
|
||||||
|
- **Per-idea public toggle.** Flip an idea to public to list it on the Explore
|
||||||
|
feed (`/explore`), browsable by anyone — including logged-out visitors.
|
||||||
|
- **Shareable read-only links.** Generate a secret link (`/share/<token>`) that
|
||||||
|
lets anyone view one idea and its notes without an account. Works
|
||||||
|
independently of public/private status, and can be revoked anytime.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. Install dev types (already done if you cloned with `bun.lock`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun install
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Configure at least one OAuth provider. Copy the example env file and fill in
|
||||||
|
the credentials:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
**GitHub:** create an OAuth app at <https://github.com/settings/developers>
|
||||||
|
with callback URL `http://localhost:3000/auth/github/callback`.
|
||||||
|
|
||||||
|
**Google:** create OAuth credentials at
|
||||||
|
<https://console.cloud.google.com/apis/credentials> (Web application) with
|
||||||
|
authorized redirect URI `http://localhost:3000/auth/google/callback`.
|
||||||
|
|
||||||
|
Bun auto-loads `.env`. If you change `BASE_URL` (e.g. for production), update
|
||||||
|
the callback/redirect URLs in the provider settings to match.
|
||||||
|
|
||||||
## Run it
|
## Run it
|
||||||
|
|
||||||
@@ -28,29 +67,66 @@ PORT=8080 bun run start
|
|||||||
```
|
```
|
||||||
|
|
||||||
The SQLite database file (`spark_slop.db`) is created automatically on first run.
|
The SQLite database file (`spark_slop.db`) is created automatically on first run.
|
||||||
|
(If you ran an older version without auth, the schema self-migrates to add the
|
||||||
|
new columns — existing ideas will have no owner; delete `spark_slop.db` for a
|
||||||
|
clean slate.)
|
||||||
|
|
||||||
## Project layout
|
## Project layout
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
db.ts # SQLite schema + typed query functions
|
db.ts # SQLite schema, migration guard, ownership-scoped queries
|
||||||
server.ts # Bun.serve: JSON API + static files
|
auth.ts # OAuth (GitHub/Google) flows, sessions, cookies
|
||||||
|
server.ts # Bun.serve: auth routes + JSON API + static files
|
||||||
public/
|
public/
|
||||||
index.html # landing page
|
index.html # landing page
|
||||||
app.html # the app (idea list + notes)
|
app.html # the app (auth-gated: idea list, notes, privacy controls)
|
||||||
app.js # frontend logic (API client, rendering)
|
app.js # app logic (login gate, ideas, notes, visibility, sharing)
|
||||||
|
explore.html # public Explore feed
|
||||||
|
explore.js
|
||||||
|
share.html # read-only viewer (shared links + public ideas)
|
||||||
|
share.js
|
||||||
|
common.js # shared helpers (API client, time formatting, rendering)
|
||||||
styles.css # modern dark UI
|
styles.css # modern dark UI
|
||||||
```
|
```
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
|
### Public (no auth)
|
||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
| ------ | ------------------------- | -------------------------- |
|
| ------ | ------------------------- | ---------------------------------------- |
|
||||||
| GET | `/api/ideas` | List ideas (with note counts) |
|
| GET | `/api/auth/providers` | Which OAuth providers are configured |
|
||||||
|
| GET | `/api/me` | Current user (401 if signed out) |
|
||||||
|
| GET | `/api/explore` | List all public ideas |
|
||||||
|
| GET | `/api/public/ideas/:id` | A public idea + its notes (read-only) |
|
||||||
|
| GET | `/api/shared/:token` | An idea + notes via secret share token |
|
||||||
|
|
||||||
|
### Auth
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
| ------ | -------------------------- | --------------------------------- |
|
||||||
|
| GET | `/auth/github` | Start GitHub OAuth |
|
||||||
|
| GET | `/auth/github/callback` | GitHub redirect target |
|
||||||
|
| GET | `/auth/google` | Start Google OAuth |
|
||||||
|
| GET | `/auth/google/callback` | Google redirect target |
|
||||||
|
| GET | `/auth/logout` | Clear session and sign out |
|
||||||
|
|
||||||
|
### Authenticated (owner-scoped)
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
| ------ | ----------------------------- | ------------------------------------ |
|
||||||
|
| GET | `/api/ideas` | List your ideas (with note counts) |
|
||||||
| POST | `/api/ideas` | Create an idea `{title, description}` |
|
| POST | `/api/ideas` | Create an idea `{title, description}` |
|
||||||
| GET | `/api/ideas/:id` | Get one idea |
|
| GET | `/api/ideas/:id` | Get one of your ideas |
|
||||||
| PUT | `/api/ideas/:id` | Update an idea |
|
| PUT | `/api/ideas/:id` | Update an idea |
|
||||||
| DELETE | `/api/ideas/:id` | Delete an idea (cascades notes) |
|
| DELETE | `/api/ideas/:id` | Delete an idea (cascades notes) |
|
||||||
|
| PUT | `/api/ideas/:id/visibility` | Set `{visibility: "public"\|"private"}` |
|
||||||
|
| POST | `/api/ideas/:id/share` | Create/rotate a share link |
|
||||||
|
| DELETE | `/api/ideas/:id/share` | Revoke the share link |
|
||||||
| GET | `/api/ideas/:id/notes` | List notes for an idea |
|
| GET | `/api/ideas/:id/notes` | List notes for an idea |
|
||||||
| POST | `/api/ideas/:id/notes` | Add a note `{body}` |
|
| POST | `/api/ideas/:id/notes` | Add a note `{body}` |
|
||||||
| DELETE | `/api/notes/:id` | Delete a note |
|
| DELETE | `/api/notes/:id` | Delete a note |
|
||||||
|
|
||||||
|
All authenticated routes return `401` when signed out and `404` for ideas/notes
|
||||||
|
you don't own — ownership is enforced in the SQL, not just the route layer.
|
||||||
|
|||||||
+21
-5
@@ -18,11 +18,29 @@
|
|||||||
<span class="spark">✨</span>
|
<span class="spark">✨</span>
|
||||||
<span>Spark<span class="gradient-text">Slop</span></span>
|
<span>Spark<span class="gradient-text">Slop</span></span>
|
||||||
</a>
|
</a>
|
||||||
<a href="/" class="btn btn-ghost btn-sm">← Home</a>
|
<div class="nav-right" id="nav-right">
|
||||||
|
<a href="/explore" class="btn btn-ghost btn-sm">🌐 Explore</a>
|
||||||
|
<!-- user chip injected here when signed in -->
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<main class="app-main">
|
<!-- Login gate (shown when not authenticated) -->
|
||||||
|
<div class="login-gate" id="login-gate" hidden>
|
||||||
|
<div class="login-card">
|
||||||
|
<span class="spark big">✨</span>
|
||||||
|
<h1>Sign in to Spark Slop</h1>
|
||||||
|
<p>Your ideas are private to your account. Sign in to continue.</p>
|
||||||
|
<div class="provider-buttons" id="provider-buttons"></div>
|
||||||
|
<p class="login-error" id="login-error" hidden></p>
|
||||||
|
<p class="login-note" id="login-note" hidden>
|
||||||
|
No login providers are configured yet. See the README to set up
|
||||||
|
GitHub or Google OAuth.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main class="app-main" id="app-main" hidden>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="app-header">
|
<div class="app-header">
|
||||||
<div>
|
<div>
|
||||||
@@ -32,7 +50,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="layout">
|
<div class="layout">
|
||||||
<!-- Left: new idea + list -->
|
|
||||||
<div>
|
<div>
|
||||||
<div class="panel" style="margin-bottom: 20px">
|
<div class="panel" style="margin-bottom: 20px">
|
||||||
<h2>New idea</h2>
|
<h2>New idea</h2>
|
||||||
@@ -67,7 +84,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right: detail + notes -->
|
|
||||||
<div class="panel" id="detail-panel">
|
<div class="panel" id="detail-panel">
|
||||||
<div class="empty" id="detail-empty">
|
<div class="empty" id="detail-empty">
|
||||||
<span class="big">👈</span>
|
<span class="big">👈</span>
|
||||||
@@ -79,7 +95,7 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
|
<script src="/common.js"></script>
|
||||||
<script src="/app.js"></script>
|
<script src="/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+196
-110
@@ -1,84 +1,99 @@
|
|||||||
// --- Tiny API client ---
|
// --- API endpoints (relies on window.api from common.js) ---
|
||||||
const api = {
|
const ideasApi = {
|
||||||
async req(method, path, body) {
|
list: () => api.req("GET", "/api/ideas"),
|
||||||
const res = await fetch(path, {
|
create: (title, description) =>
|
||||||
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 }),
|
api.req("POST", "/api/ideas", { title, description }),
|
||||||
deleteIdea: (id) => api.req("DELETE", `/api/ideas/${id}`),
|
remove: (id) => api.req("DELETE", `/api/ideas/${id}`),
|
||||||
|
setVisibility: (id, visibility) =>
|
||||||
|
api.req("PUT", `/api/ideas/${id}/visibility`, { visibility }),
|
||||||
|
enableShare: (id) => api.req("POST", `/api/ideas/${id}/share`),
|
||||||
|
disableShare: (id) => api.req("DELETE", `/api/ideas/${id}/share`),
|
||||||
notes: (id) => api.req("GET", `/api/ideas/${id}/notes`),
|
notes: (id) => api.req("GET", `/api/ideas/${id}/notes`),
|
||||||
addNote: (id, body) => api.req("POST", `/api/ideas/${id}/notes`, { body }),
|
addNote: (id, body) => api.req("POST", `/api/ideas/${id}/notes`, { body }),
|
||||||
deleteNote: (id) => api.req("DELETE", `/api/notes/${id}`),
|
deleteNote: (id) => api.req("DELETE", `/api/notes/${id}`),
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- State ---
|
let me = null;
|
||||||
let ideas = [];
|
let ideas = [];
|
||||||
let selectedId = null;
|
let selectedId = null;
|
||||||
|
|
||||||
// --- Helpers ---
|
// --- Auth bootstrap ---
|
||||||
const $ = (sel) => document.querySelector(sel);
|
async function init() {
|
||||||
|
$("#idea-form").addEventListener("submit", onCreateIdea);
|
||||||
function escapeHtml(s) {
|
try {
|
||||||
return String(s)
|
me = await api.req("GET", "/api/me");
|
||||||
.replace(/&/g, "&")
|
showApp();
|
||||||
.replace(/</g, "<")
|
await refresh();
|
||||||
.replace(/>/g, ">")
|
} catch (e) {
|
||||||
.replace(/"/g, """);
|
if (e.status === 401) showLogin();
|
||||||
|
else toast(e.message, true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SQLite stores UTC datetimes as "YYYY-MM-DD HH:MM:SS"; parse as UTC.
|
async function showLogin() {
|
||||||
function parseUTC(s) {
|
$("#app-main").hidden = true;
|
||||||
return new Date(s.replace(" ", "T") + "Z");
|
$("#login-gate").hidden = false;
|
||||||
}
|
|
||||||
|
|
||||||
function relativeTime(s) {
|
const params = new URLSearchParams(location.search);
|
||||||
const then = parseUTC(s);
|
if (params.get("auth_error")) {
|
||||||
const diff = (Date.now() - then.getTime()) / 1000;
|
const el = $("#login-error");
|
||||||
if (diff < 45) return "just now";
|
el.hidden = false;
|
||||||
if (diff < 90) return "a minute ago";
|
el.textContent =
|
||||||
if (diff < 3600) return `${Math.round(diff / 60)} min ago`;
|
params.get("auth_error") === "state"
|
||||||
if (diff < 7200) return "an hour ago";
|
? "Sign-in session expired or was tampered with. Please try again."
|
||||||
if (diff < 86400) return `${Math.round(diff / 3600)} hours ago`;
|
: "Could not complete sign-in. Please try again.";
|
||||||
if (diff < 172800) return "yesterday";
|
}
|
||||||
if (diff < 604800) return `${Math.round(diff / 86400)} days ago`;
|
|
||||||
return then.toLocaleDateString(undefined, {
|
try {
|
||||||
month: "short",
|
const { providers } = await api.req("GET", "/api/auth/providers");
|
||||||
day: "numeric",
|
const box = $("#provider-buttons");
|
||||||
year: "numeric",
|
if (!providers.length) {
|
||||||
|
$("#login-note").hidden = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
box.innerHTML = providers.map(providerButton).join("");
|
||||||
|
box.querySelectorAll(".provider-btn").forEach((btn) => {
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
location.href = `/auth/${btn.dataset.provider}`;
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
function fullTime(s) {
|
|
||||||
return parseUTC(s).toLocaleString(undefined, {
|
|
||||||
dateStyle: "medium",
|
|
||||||
timeStyle: "short",
|
|
||||||
});
|
});
|
||||||
|
} catch (e) {
|
||||||
|
toast(e.message, true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let toastTimer;
|
function showApp() {
|
||||||
function toast(msg, isError = false) {
|
$("#login-gate").hidden = true;
|
||||||
const el = $("#toast");
|
$("#app-main").hidden = false;
|
||||||
el.textContent = msg;
|
renderUserChip();
|
||||||
el.classList.toggle("error", isError);
|
|
||||||
el.classList.add("show");
|
|
||||||
clearTimeout(toastTimer);
|
|
||||||
toastTimer = setTimeout(() => el.classList.remove("show"), 2600);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Rendering ---
|
function renderUserChip() {
|
||||||
|
const nav = $("#nav-right");
|
||||||
|
// Remove any existing chip first.
|
||||||
|
nav.querySelector(".user-chip")?.remove();
|
||||||
|
const avatar = me.avatar_url
|
||||||
|
? `<img src="${escapeHtml(me.avatar_url)}" alt="" />`
|
||||||
|
: `<span class="avatar-fallback">${escapeHtml(
|
||||||
|
(me.name || "?")[0].toUpperCase()
|
||||||
|
)}</span>`;
|
||||||
|
const chip = document.createElement("div");
|
||||||
|
chip.className = "user-chip";
|
||||||
|
chip.innerHTML = `${avatar}<span>${escapeHtml(
|
||||||
|
me.name
|
||||||
|
)}</span><span class="logout" title="Sign out">⎋</span>`;
|
||||||
|
chip.querySelector(".logout").addEventListener("click", () => {
|
||||||
|
location.href = "/auth/logout";
|
||||||
|
});
|
||||||
|
nav.appendChild(chip);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Rendering: idea list ---
|
||||||
function renderList() {
|
function renderList() {
|
||||||
const list = $("#ideas-list");
|
const list = $("#ideas-list");
|
||||||
$("#idea-count").textContent = ideas.length;
|
$("#idea-count").textContent = ideas.length;
|
||||||
|
|
||||||
if (ideas.length === 0) {
|
if (!ideas.length) {
|
||||||
list.innerHTML =
|
list.innerHTML =
|
||||||
'<div class="empty" style="padding:30px 10px">No ideas yet. Add your first one!</div>';
|
'<div class="empty" style="padding:30px 10px">No ideas yet. Add your first one!</div>';
|
||||||
return;
|
return;
|
||||||
@@ -90,19 +105,27 @@ function renderList() {
|
|||||||
<div class="idea-item ${idea.id === selectedId ? "active" : ""}" data-id="${idea.id}">
|
<div class="idea-item ${idea.id === selectedId ? "active" : ""}" data-id="${idea.id}">
|
||||||
<h3>${escapeHtml(idea.title)}</h3>
|
<h3>${escapeHtml(idea.title)}</h3>
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
<span class="pill">${idea.note_count} note${idea.note_count === 1 ? "" : "s"}</span>
|
<span class="pill">${idea.note_count} note${
|
||||||
|
idea.note_count === 1 ? "" : "s"
|
||||||
|
}</span>
|
||||||
|
<span class="vis-badge ${idea.visibility}">${
|
||||||
|
idea.visibility === "public" ? "🌐 public" : "🔒 private"
|
||||||
|
}</span>
|
||||||
<span>updated ${relativeTime(idea.updated_at)}</span>
|
<span>updated ${relativeTime(idea.updated_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>`
|
</div>`
|
||||||
)
|
)
|
||||||
.join("");
|
.join("");
|
||||||
|
|
||||||
list.querySelectorAll(".idea-item").forEach((el) => {
|
list
|
||||||
el.addEventListener("click", () => selectIdea(Number(el.dataset.id)));
|
.querySelectorAll(".idea-item")
|
||||||
});
|
.forEach((el) =>
|
||||||
|
el.addEventListener("click", () => selectIdea(Number(el.dataset.id)))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderDetail() {
|
// --- Rendering: detail + privacy + notes ---
|
||||||
|
function renderDetail() {
|
||||||
const panel = $("#detail-panel");
|
const panel = $("#detail-panel");
|
||||||
const idea = ideas.find((i) => i.id === selectedId);
|
const idea = ideas.find((i) => i.id === selectedId);
|
||||||
|
|
||||||
@@ -112,13 +135,57 @@ async function renderDetail() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const shareUrl = idea.share_token
|
||||||
|
? `${location.origin}/share/${idea.share_token}`
|
||||||
|
: "";
|
||||||
|
|
||||||
panel.innerHTML = `
|
panel.innerHTML = `
|
||||||
<div class="detail-head">
|
<div class="detail-head">
|
||||||
<h2>${escapeHtml(idea.title)}</h2>
|
<h2>${escapeHtml(idea.title)}</h2>
|
||||||
<button class="btn btn-danger btn-sm" id="del-idea">Delete idea</button>
|
<button class="btn btn-danger btn-sm" id="del-idea">Delete idea</button>
|
||||||
</div>
|
</div>
|
||||||
${idea.description ? `<p class="detail-desc">${escapeHtml(idea.description)}</p>` : ""}
|
${
|
||||||
|
idea.description
|
||||||
|
? `<p class="detail-desc">${escapeHtml(idea.description)}</p>`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
<div class="detail-meta">Created ${fullTime(idea.created_at)}</div>
|
<div class="detail-meta">Created ${fullTime(idea.created_at)}</div>
|
||||||
|
|
||||||
|
<div class="privacy-bar">
|
||||||
|
<span class="vis-badge ${idea.visibility}">${
|
||||||
|
idea.visibility === "public" ? "🌐 Public" : "🔒 Private"
|
||||||
|
}</span>
|
||||||
|
<label class="switch">
|
||||||
|
<input type="checkbox" id="vis-toggle" ${
|
||||||
|
idea.visibility === "public" ? "checked" : ""
|
||||||
|
} />
|
||||||
|
<span class="track"></span>
|
||||||
|
<span>List on public Explore feed</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="share-box">
|
||||||
|
<div class="share-head">
|
||||||
|
<h4>🔗 Shareable read-only link</h4>
|
||||||
|
${
|
||||||
|
idea.share_token
|
||||||
|
? `<button class="btn btn-danger btn-sm" id="share-disable">Disable</button>`
|
||||||
|
: `<button class="btn btn-sm" id="share-enable">Create link</button>`
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
${
|
||||||
|
idea.share_token
|
||||||
|
? `<div class="share-row">
|
||||||
|
<input type="text" id="share-url" value="${escapeHtml(
|
||||||
|
shareUrl
|
||||||
|
)}" readonly />
|
||||||
|
<button class="btn btn-sm" id="share-copy">Copy</button>
|
||||||
|
</div>
|
||||||
|
<div class="share-hint">Anyone with this link can view the idea and its notes (read-only) — no account needed.</div>`
|
||||||
|
: `<div class="share-hint">Generate a secret link to share this idea read-only, even while it stays private.</div>`
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="divider"></div>
|
<div class="divider"></div>
|
||||||
<h2 style="font-size:1.05rem;margin-bottom:4px">Notes</h2>
|
<h2 style="font-size:1.05rem;margin-bottom:4px">Notes</h2>
|
||||||
<div id="notes-container"><div class="empty" style="padding:20px">Loading…</div></div>
|
<div id="notes-container"><div class="empty" style="padding:20px">Loading…</div></div>
|
||||||
@@ -129,6 +196,16 @@ async function renderDetail() {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
$("#del-idea").addEventListener("click", () => onDeleteIdea(idea.id));
|
$("#del-idea").addEventListener("click", () => onDeleteIdea(idea.id));
|
||||||
|
$("#vis-toggle").addEventListener("change", (e) =>
|
||||||
|
onToggleVisibility(idea.id, e.target.checked)
|
||||||
|
);
|
||||||
|
|
||||||
|
$("#share-enable")?.addEventListener("click", () => onEnableShare(idea.id));
|
||||||
|
$("#share-disable")?.addEventListener("click", () => onDisableShare(idea.id));
|
||||||
|
$("#share-copy")?.addEventListener("click", async () => {
|
||||||
|
await navigator.clipboard.writeText(shareUrl).catch(() => {});
|
||||||
|
toast("Link copied to clipboard 🔗");
|
||||||
|
});
|
||||||
|
|
||||||
const form = $("#note-form");
|
const form = $("#note-form");
|
||||||
form.addEventListener("submit", (e) => {
|
form.addEventListener("submit", (e) => {
|
||||||
@@ -145,45 +222,20 @@ async function renderDetail() {
|
|||||||
loadNotes(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 ---
|
// --- Actions ---
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
try {
|
try {
|
||||||
ideas = await api.ideas();
|
ideas = await ideasApi.list();
|
||||||
renderList();
|
renderList();
|
||||||
renderDetail();
|
renderDetail();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast(e.message, true);
|
toast(e.message, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
async function refreshListOnly() {
|
||||||
|
ideas = await ideasApi.list();
|
||||||
|
renderList();
|
||||||
|
}
|
||||||
|
|
||||||
function selectIdea(id) {
|
function selectIdea(id) {
|
||||||
selectedId = id;
|
selectedId = id;
|
||||||
@@ -193,8 +245,9 @@ function selectIdea(id) {
|
|||||||
|
|
||||||
async function loadNotes(id) {
|
async function loadNotes(id) {
|
||||||
try {
|
try {
|
||||||
const notes = await api.notes(id);
|
const notes = await ideasApi.notes(id);
|
||||||
if (selectedId === id) renderNotes(notes);
|
if (selectedId === id)
|
||||||
|
renderNotesInto($("#notes-container"), notes, onDeleteNote);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast(e.message, true);
|
toast(e.message, true);
|
||||||
}
|
}
|
||||||
@@ -206,7 +259,7 @@ async function onCreateIdea(e) {
|
|||||||
const description = $("#idea-desc").value.trim();
|
const description = $("#idea-desc").value.trim();
|
||||||
if (!title) return;
|
if (!title) return;
|
||||||
try {
|
try {
|
||||||
const idea = await api.createIdea(title, description);
|
const idea = await ideasApi.create(title, description);
|
||||||
$("#idea-form").reset();
|
$("#idea-form").reset();
|
||||||
await refresh();
|
await refresh();
|
||||||
selectIdea(idea.id);
|
selectIdea(idea.id);
|
||||||
@@ -219,7 +272,7 @@ async function onCreateIdea(e) {
|
|||||||
async function onDeleteIdea(id) {
|
async function onDeleteIdea(id) {
|
||||||
if (!confirm("Delete this idea and all its notes?")) return;
|
if (!confirm("Delete this idea and all its notes?")) return;
|
||||||
try {
|
try {
|
||||||
await api.deleteIdea(id);
|
await ideasApi.remove(id);
|
||||||
if (selectedId === id) selectedId = null;
|
if (selectedId === id) selectedId = null;
|
||||||
await refresh();
|
await refresh();
|
||||||
toast("Idea deleted");
|
toast("Idea deleted");
|
||||||
@@ -228,28 +281,57 @@ async function onDeleteIdea(id) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onToggleVisibility(id, makePublic) {
|
||||||
|
try {
|
||||||
|
const updated = await ideasApi.setVisibility(id, makePublic ? "public" : "private");
|
||||||
|
mergeIdea(updated);
|
||||||
|
renderList();
|
||||||
|
renderDetail();
|
||||||
|
toast(makePublic ? "Now public on Explore 🌐" : "Set to private 🔒");
|
||||||
|
} catch (err) {
|
||||||
|
toast(err.message, true);
|
||||||
|
renderDetail();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onEnableShare(id) {
|
||||||
|
try {
|
||||||
|
const updated = await ideasApi.enableShare(id);
|
||||||
|
mergeIdea(updated);
|
||||||
|
renderDetail();
|
||||||
|
toast("Share link created 🔗");
|
||||||
|
} catch (err) {
|
||||||
|
toast(err.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDisableShare(id) {
|
||||||
|
try {
|
||||||
|
const updated = await ideasApi.disableShare(id);
|
||||||
|
mergeIdea(updated);
|
||||||
|
renderDetail();
|
||||||
|
toast("Share link disabled");
|
||||||
|
} catch (err) {
|
||||||
|
toast(err.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function onAddNote(id) {
|
async function onAddNote(id) {
|
||||||
const input = $("#note-body");
|
const input = $("#note-body");
|
||||||
const body = input.value.trim();
|
const body = input.value.trim();
|
||||||
if (!body) return;
|
if (!body) return;
|
||||||
try {
|
try {
|
||||||
await api.addNote(id, body);
|
await ideasApi.addNote(id, body);
|
||||||
input.value = "";
|
input.value = "";
|
||||||
// Refresh notes immediately, plus list for the updated count/order.
|
|
||||||
await Promise.all([loadNotes(id), refreshListOnly()]);
|
await Promise.all([loadNotes(id), refreshListOnly()]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast(err.message, true);
|
toast(err.message, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshListOnly() {
|
|
||||||
ideas = await api.ideas();
|
|
||||||
renderList();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onDeleteNote(noteId) {
|
async function onDeleteNote(noteId) {
|
||||||
try {
|
try {
|
||||||
await api.deleteNote(noteId);
|
await ideasApi.deleteNote(noteId);
|
||||||
if (selectedId) await Promise.all([loadNotes(selectedId), refreshListOnly()]);
|
if (selectedId) await Promise.all([loadNotes(selectedId), refreshListOnly()]);
|
||||||
toast("Note deleted");
|
toast("Note deleted");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -257,6 +339,10 @@ async function onDeleteNote(noteId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Init ---
|
// Replace an idea in local state with a fresh copy (preserving note_count).
|
||||||
$("#idea-form").addEventListener("submit", onCreateIdea);
|
function mergeIdea(updated) {
|
||||||
refresh();
|
const idx = ideas.findIndex((i) => i.id === updated.id);
|
||||||
|
if (idx !== -1) ideas[idx] = { ...ideas[idx], ...updated };
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
// Shared helpers used by app.js, explore.js, and share.js.
|
||||||
|
|
||||||
|
window.$ = (sel, root = document) => root.querySelector(sel);
|
||||||
|
|
||||||
|
window.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) {
|
||||||
|
const e = new Error((data && data.error) || "Request failed");
|
||||||
|
e.status = res.status;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
window.escapeHtml = (s) =>
|
||||||
|
String(s)
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
|
||||||
|
// SQLite stores UTC datetimes as "YYYY-MM-DD HH:MM:SS"; parse as UTC.
|
||||||
|
window.parseUTC = (s) => new Date(s.replace(" ", "T") + "Z");
|
||||||
|
|
||||||
|
window.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",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
window.fullTime = (s) =>
|
||||||
|
parseUTC(s).toLocaleString(undefined, {
|
||||||
|
dateStyle: "medium",
|
||||||
|
timeStyle: "short",
|
||||||
|
});
|
||||||
|
|
||||||
|
let toastTimer;
|
||||||
|
window.toast = (msg, isError = false) => {
|
||||||
|
const el = $("#toast");
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = msg;
|
||||||
|
el.classList.toggle("error", isError);
|
||||||
|
el.classList.add("show");
|
||||||
|
clearTimeout(toastTimer);
|
||||||
|
toastTimer = setTimeout(() => el.classList.remove("show"), 2600);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render a list of notes into a container element (read-only unless onDelete given).
|
||||||
|
window.renderNotesInto = (container, notes, onDelete) => {
|
||||||
|
if (!container) return;
|
||||||
|
if (!notes.length) {
|
||||||
|
container.innerHTML =
|
||||||
|
'<div class="empty" style="padding:24px">No notes yet.</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>
|
||||||
|
${onDelete ? `<span class="del" data-note="${n.id}">delete</span>` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
if (onDelete) {
|
||||||
|
container.querySelectorAll(".del").forEach((el) =>
|
||||||
|
el.addEventListener("click", () => onDelete(Number(el.dataset.note)))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Provider sign-in buttons (used by login gate).
|
||||||
|
window.providerButton = (p) => {
|
||||||
|
const icons = {
|
||||||
|
github:
|
||||||
|
'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 .5C5.7.5.5 5.7.5 12c0 5.1 3.3 9.4 7.9 10.9.6.1.8-.3.8-.6v-2c-3.2.7-3.9-1.4-3.9-1.4-.5-1.3-1.3-1.7-1.3-1.7-1.1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.7 1.3 3.4 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.3 1.2a11.5 11.5 0 0 1 6 0C17.3 4.7 18.3 5 18.3 5c.6 1.6.2 2.8.1 3.1.8.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.3c0 .3.2.7.8.6 4.6-1.5 7.9-5.8 7.9-10.9C23.5 5.7 18.3.5 12 .5z"/></svg>',
|
||||||
|
google:
|
||||||
|
'<svg viewBox="0 0 24 24"><path fill="#4285F4" d="M22.5 12.2c0-.8-.1-1.4-.2-2.1H12v3.9h6c-.1 1-.8 2.5-2.2 3.5v2.9h3.5c2.1-1.9 3.2-4.7 3.2-8.1z"/><path fill="#34A853" d="M12 23c2.9 0 5.4-1 7.2-2.6l-3.5-2.9c-1 .7-2.2 1.1-3.7 1.1-2.8 0-5.2-1.9-6.1-4.5H2.3v2.9C4.1 20.6 7.8 23 12 23z"/><path fill="#FBBC05" d="M5.9 14.1c-.2-.7-.4-1.4-.4-2.1s.1-1.4.4-2.1V7H2.3C1.5 8.5 1 10.2 1 12s.5 3.5 1.3 5l3.6-2.9z"/><path fill="#EA4335" d="M12 5.4c1.6 0 2.9.5 4 1.6l3-3C17.4 2.1 14.9 1 12 1 7.8 1 4.1 3.4 2.3 7l3.6 2.9C6.8 7.3 9.2 5.4 12 5.4z"/></svg>',
|
||||||
|
};
|
||||||
|
return `<button class="provider-btn" data-provider="${p.id}">${
|
||||||
|
icons[p.id] || ""
|
||||||
|
}<span>Continue with ${escapeHtml(p.label)}</span></button>`;
|
||||||
|
};
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Explore — Spark Slop</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="/app" class="btn btn-primary btn-sm">Open the app →</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="app-main">
|
||||||
|
<div class="container">
|
||||||
|
<div class="app-header">
|
||||||
|
<div>
|
||||||
|
<h1>🌐 Explore public ideas</h1>
|
||||||
|
<p>Ideas people have chosen to share with the world.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="explore-grid" class="features" style="padding-bottom: 60px">
|
||||||
|
<div class="empty">Loading…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div class="toast" id="toast"></div>
|
||||||
|
<script src="/common.js"></script>
|
||||||
|
<script src="/explore.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
async function load() {
|
||||||
|
const grid = $("#explore-grid");
|
||||||
|
try {
|
||||||
|
const ideas = await api.req("GET", "/api/explore");
|
||||||
|
if (!ideas.length) {
|
||||||
|
grid.innerHTML =
|
||||||
|
'<div class="empty"><span class="big">🌱</span>No public ideas yet. Be the first to share one!</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
grid.innerHTML = ideas
|
||||||
|
.map(
|
||||||
|
(idea) => `
|
||||||
|
<a class="feature-card" href="/share?idea=${idea.id}" style="cursor:pointer">
|
||||||
|
<h3>${escapeHtml(idea.title)}</h3>
|
||||||
|
<p>${
|
||||||
|
idea.description
|
||||||
|
? escapeHtml(idea.description)
|
||||||
|
: "<em>No description</em>"
|
||||||
|
}</p>
|
||||||
|
<div class="meta" style="margin-top:14px;font-size:0.8rem;color:var(--muted);display:flex;gap:10px">
|
||||||
|
<span class="pill">${idea.note_count} note${
|
||||||
|
idea.note_count === 1 ? "" : "s"
|
||||||
|
}</span>
|
||||||
|
<span>by ${escapeHtml(idea.author_name || "someone")}</span>
|
||||||
|
<span>· ${relativeTime(idea.updated_at)}</span>
|
||||||
|
</div>
|
||||||
|
</a>`
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
} catch (e) {
|
||||||
|
grid.innerHTML = `<div class="empty">Could not load ideas: ${escapeHtml(
|
||||||
|
e.message
|
||||||
|
)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
load();
|
||||||
+11
-7
@@ -22,7 +22,10 @@
|
|||||||
<span class="spark">✨</span>
|
<span class="spark">✨</span>
|
||||||
<span>Spark<span class="gradient-text">Slop</span></span>
|
<span>Spark<span class="gradient-text">Slop</span></span>
|
||||||
</a>
|
</a>
|
||||||
<a href="/app" class="btn btn-primary btn-sm">Open the app →</a>
|
<div class="nav-right">
|
||||||
|
<a href="/explore" class="btn btn-ghost btn-sm">🌐 Explore</a>
|
||||||
|
<a href="/app" class="btn btn-primary btn-sm">Sign in →</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@@ -39,8 +42,8 @@
|
|||||||
comment thread for your own brain.
|
comment thread for your own brain.
|
||||||
</p>
|
</p>
|
||||||
<div class="hero-actions">
|
<div class="hero-actions">
|
||||||
<a href="/app" class="btn btn-primary">Start capturing ideas</a>
|
<a href="/app" class="btn btn-primary">Sign in & start capturing</a>
|
||||||
<a href="#features" class="btn btn-ghost">See how it works</a>
|
<a href="/explore" class="btn btn-ghost">🌐 Explore public ideas</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -64,11 +67,12 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-card">
|
<div class="feature-card">
|
||||||
<span class="icon">⚡</span>
|
<span class="icon">🔒</span>
|
||||||
<h3>Fast & local</h3>
|
<h3>Private by default</h3>
|
||||||
<p>
|
<p>
|
||||||
Powered by Bun and SQLite. Your data lives in a single file on your
|
Sign in with GitHub or Google. Every idea is yours alone — until you
|
||||||
machine — snappy, private, and yours.
|
choose to share it with a secret read-only link or list it on the
|
||||||
|
public Explore feed.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Shared idea — Spark Slop</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="/app" class="btn btn-ghost btn-sm">Open the app →</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="app-main">
|
||||||
|
<div class="container" style="max-width: 760px">
|
||||||
|
<div id="share-root">
|
||||||
|
<div class="empty">Loading…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div class="toast" id="toast"></div>
|
||||||
|
<script src="/common.js"></script>
|
||||||
|
<script src="/share.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
// Universal read-only viewer:
|
||||||
|
// /share/:token -> fetch /api/shared/:token (secret link)
|
||||||
|
// /share?idea=:id -> fetch /api/public/ideas/:id (public Explore item)
|
||||||
|
function resolveEndpoint() {
|
||||||
|
const parts = location.pathname.split("/").filter(Boolean); // ["share", token?]
|
||||||
|
if (parts.length >= 2) return `/api/shared/${encodeURIComponent(parts[1])}`;
|
||||||
|
const id = new URLSearchParams(location.search).get("idea");
|
||||||
|
if (id) return `/api/public/ideas/${encodeURIComponent(id)}`;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const root = $("#share-root");
|
||||||
|
const endpoint = resolveEndpoint();
|
||||||
|
if (!endpoint) {
|
||||||
|
root.innerHTML =
|
||||||
|
'<div class="empty"><span class="big">🤔</span>No idea specified.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const idea = await api.req("GET", endpoint);
|
||||||
|
const isPublic = idea.visibility === "public";
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="read-only-banner">
|
||||||
|
👁️ You're viewing a read-only ${
|
||||||
|
isPublic ? "public" : "shared"
|
||||||
|
} idea${idea.author_name ? ` by <strong>${escapeHtml(idea.author_name)}</strong>` : ""}.
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="detail-head">
|
||||||
|
<h2>${escapeHtml(idea.title)}</h2>
|
||||||
|
<span class="vis-badge ${idea.visibility}">${
|
||||||
|
isPublic ? "🌐 Public" : "🔒 Shared"
|
||||||
|
}</span>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
renderNotesInto($("#notes-container"), idea.notes || []);
|
||||||
|
} catch (e) {
|
||||||
|
root.innerHTML = `<div class="empty"><span class="big">🔒</span>${
|
||||||
|
e.status === 404
|
||||||
|
? "This idea doesn't exist, is private, or the link was disabled."
|
||||||
|
: escapeHtml(e.message)
|
||||||
|
}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
load();
|
||||||
@@ -424,6 +424,224 @@ textarea:focus {
|
|||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- Nav right / user chip ---------- */
|
||||||
|
.nav-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.user-chip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
padding: 5px 6px 5px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--surface);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
.user-chip img,
|
||||||
|
.user-chip .avatar-fallback {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
.user-chip .avatar-fallback {
|
||||||
|
background: var(--grad);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.user-chip .logout {
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 6px;
|
||||||
|
}
|
||||||
|
.user-chip .logout:hover {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Login gate ---------- */
|
||||||
|
.login-gate {
|
||||||
|
min-height: calc(100vh - 68px);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
}
|
||||||
|
.login-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 44px 38px;
|
||||||
|
max-width: 420px;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.login-card .spark.big {
|
||||||
|
font-size: 2.6rem;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.login-card h1 {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
.login-card p {
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 26px;
|
||||||
|
}
|
||||||
|
.provider-buttons {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.provider-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 13px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.98rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s ease, transform 0.12s ease;
|
||||||
|
}
|
||||||
|
.provider-btn:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.provider-btn svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
.login-error {
|
||||||
|
color: var(--danger);
|
||||||
|
margin-top: 18px !important;
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.login-note {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-top: 18px !important;
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Privacy controls ---------- */
|
||||||
|
.privacy-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
background: var(--bg-soft);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
.vis-badge {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
.vis-badge.private {
|
||||||
|
background: rgba(139, 147, 167, 0.18);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.vis-badge.public {
|
||||||
|
background: rgba(0, 212, 255, 0.16);
|
||||||
|
color: var(--accent-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toggle switch */
|
||||||
|
.switch {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.switch input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.switch .track {
|
||||||
|
width: 40px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
position: relative;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
.switch .track::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--muted);
|
||||||
|
transition: transform 0.2s ease, background 0.2s ease;
|
||||||
|
}
|
||||||
|
.switch input:checked + .track {
|
||||||
|
background: rgba(0, 212, 255, 0.25);
|
||||||
|
border-color: var(--accent-2);
|
||||||
|
}
|
||||||
|
.switch input:checked + .track::after {
|
||||||
|
transform: translateX(18px);
|
||||||
|
background: var(--accent-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-box {
|
||||||
|
margin-top: 4px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
background: var(--bg-soft);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
.share-box .share-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.share-box h4 {
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
.share-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.share-row input {
|
||||||
|
flex: 1;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
.share-hint {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-only-banner {
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
.toast {
|
.toast {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 24px;
|
bottom: 24px;
|
||||||
|
|||||||
+272
@@ -0,0 +1,272 @@
|
|||||||
|
import {
|
||||||
|
upsertUser,
|
||||||
|
createSession,
|
||||||
|
getSessionUser,
|
||||||
|
deleteSession,
|
||||||
|
type User,
|
||||||
|
} from "./db";
|
||||||
|
|
||||||
|
export const BASE_URL = process.env.BASE_URL ?? "http://localhost:3000";
|
||||||
|
const SESSION_COOKIE = "spark_session";
|
||||||
|
const STATE_COOKIE = "spark_oauth_state";
|
||||||
|
const SESSION_DAYS = 30;
|
||||||
|
|
||||||
|
interface ProviderConfig {
|
||||||
|
id: "github" | "google";
|
||||||
|
label: string;
|
||||||
|
clientId?: string;
|
||||||
|
clientSecret?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const providers: ProviderConfig[] = [
|
||||||
|
{
|
||||||
|
id: "github",
|
||||||
|
label: "GitHub",
|
||||||
|
clientId: process.env.GITHUB_CLIENT_ID,
|
||||||
|
clientSecret: process.env.GITHUB_CLIENT_SECRET,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "google",
|
||||||
|
label: "Google",
|
||||||
|
clientId: process.env.GOOGLE_CLIENT_ID,
|
||||||
|
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function configuredProviders() {
|
||||||
|
return providers
|
||||||
|
.filter((p) => p.clientId && p.clientSecret)
|
||||||
|
.map((p) => ({ id: p.id, label: p.label }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function provider(id: string): ProviderConfig | undefined {
|
||||||
|
return providers.find(
|
||||||
|
(p) => p.id === id && p.clientId && p.clientSecret
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- token / cookie helpers ---
|
||||||
|
|
||||||
|
function randomToken(bytes = 32): string {
|
||||||
|
const arr = new Uint8Array(bytes);
|
||||||
|
crypto.getRandomValues(arr);
|
||||||
|
return Array.from(arr, (b) => b.toString(16).padStart(2, "0")).join("");
|
||||||
|
}
|
||||||
|
export { randomToken };
|
||||||
|
|
||||||
|
export function parseCookies(req: Request): Record<string, string> {
|
||||||
|
const header = req.headers.get("cookie");
|
||||||
|
if (!header) return {};
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
for (const part of header.split(";")) {
|
||||||
|
const idx = part.indexOf("=");
|
||||||
|
if (idx === -1) continue;
|
||||||
|
out[part.slice(0, idx).trim()] = decodeURIComponent(
|
||||||
|
part.slice(idx + 1).trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cookie(
|
||||||
|
name: string,
|
||||||
|
value: string,
|
||||||
|
maxAgeSeconds: number
|
||||||
|
): string {
|
||||||
|
const secure = BASE_URL.startsWith("https") ? " Secure;" : "";
|
||||||
|
return `${name}=${encodeURIComponent(
|
||||||
|
value
|
||||||
|
)}; Path=/; HttpOnly; SameSite=Lax;${secure} Max-Age=${maxAgeSeconds}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function currentUser(req: Request): User | null {
|
||||||
|
const token = parseCookies(req)[SESSION_COOKIE];
|
||||||
|
if (!token) return null;
|
||||||
|
return getSessionUser(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- OAuth flow ---
|
||||||
|
|
||||||
|
function redirect(location: string, headers: Record<string, string> = {}) {
|
||||||
|
return new Response(null, { status: 302, headers: { Location: location, ...headers } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: send the user to the provider's consent screen.
|
||||||
|
export function startOAuth(providerId: string): Response {
|
||||||
|
const p = provider(providerId);
|
||||||
|
if (!p) return new Response("Unknown or unconfigured provider", { status: 404 });
|
||||||
|
|
||||||
|
const state = randomToken(16);
|
||||||
|
const redirectUri = `${BASE_URL}/auth/${p.id}/callback`;
|
||||||
|
let url: string;
|
||||||
|
|
||||||
|
if (p.id === "github") {
|
||||||
|
const q = new URLSearchParams({
|
||||||
|
client_id: p.clientId!,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
scope: "read:user user:email",
|
||||||
|
state,
|
||||||
|
});
|
||||||
|
url = `https://github.com/login/oauth/authorize?${q}`;
|
||||||
|
} else {
|
||||||
|
const q = new URLSearchParams({
|
||||||
|
client_id: p.clientId!,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
response_type: "code",
|
||||||
|
scope: "openid email profile",
|
||||||
|
state,
|
||||||
|
access_type: "online",
|
||||||
|
prompt: "select_account",
|
||||||
|
});
|
||||||
|
url = `https://accounts.google.com/o/oauth2/v2/auth?${q}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bind the state to the chosen provider so we can validate the callback.
|
||||||
|
return redirect(url, {
|
||||||
|
"Set-Cookie": cookie(STATE_COOKIE, `${p.id}:${state}`, 600),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: handle the provider's redirect back to us.
|
||||||
|
export async function handleCallback(
|
||||||
|
providerId: string,
|
||||||
|
req: Request
|
||||||
|
): Promise<Response> {
|
||||||
|
const p = provider(providerId);
|
||||||
|
if (!p) return new Response("Unknown provider", { status: 404 });
|
||||||
|
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const code = url.searchParams.get("code");
|
||||||
|
const state = url.searchParams.get("state");
|
||||||
|
const expected = parseCookies(req)[STATE_COOKIE];
|
||||||
|
|
||||||
|
if (!code || !state || expected !== `${p.id}:${state}`) {
|
||||||
|
return redirect("/app?auth_error=state");
|
||||||
|
}
|
||||||
|
|
||||||
|
const redirectUri = `${BASE_URL}/auth/${p.id}/callback`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const profile =
|
||||||
|
p.id === "github"
|
||||||
|
? await githubProfile(p, code, redirectUri)
|
||||||
|
: await googleProfile(p, code, redirectUri);
|
||||||
|
|
||||||
|
const user = upsertUser(profile);
|
||||||
|
const token = randomToken(32);
|
||||||
|
const expires = new Date(Date.now() + SESSION_DAYS * 86400_000);
|
||||||
|
createSession(token, user.id, isoSqlite(expires));
|
||||||
|
|
||||||
|
// Clear state cookie, set session cookie.
|
||||||
|
return redirect("/app", {
|
||||||
|
"Set-Cookie": cookie(SESSION_COOKIE, token, SESSION_DAYS * 86400),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error("OAuth callback failed:", e);
|
||||||
|
return redirect("/app?auth_error=exchange");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logout(req: Request): Response {
|
||||||
|
const token = parseCookies(req)[SESSION_COOKIE];
|
||||||
|
if (token) deleteSession(token);
|
||||||
|
return redirect("/", {
|
||||||
|
"Set-Cookie": cookie(SESSION_COOKIE, "", 0),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- provider-specific profile fetching ---
|
||||||
|
|
||||||
|
async function githubProfile(p: ProviderConfig, code: string, redirectUri: string) {
|
||||||
|
const tokenRes = await fetch("https://github.com/login/oauth/access_token", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
client_id: p.clientId,
|
||||||
|
client_secret: p.clientSecret,
|
||||||
|
code,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const tokenJson = (await tokenRes.json()) as { access_token?: string };
|
||||||
|
const accessToken = tokenJson.access_token;
|
||||||
|
if (!accessToken) throw new Error("No access token from GitHub");
|
||||||
|
|
||||||
|
const auth = { Authorization: `Bearer ${accessToken}`, "User-Agent": "spark-slop" };
|
||||||
|
const userRes = await fetch("https://api.github.com/user", { headers: auth });
|
||||||
|
const gh = (await userRes.json()) as {
|
||||||
|
id: number;
|
||||||
|
login: string;
|
||||||
|
name?: string;
|
||||||
|
avatar_url?: string;
|
||||||
|
email?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// GitHub may not expose a public email; fetch the primary verified one.
|
||||||
|
let email = gh.email ?? null;
|
||||||
|
if (!email) {
|
||||||
|
const emailRes = await fetch("https://api.github.com/user/emails", {
|
||||||
|
headers: auth,
|
||||||
|
});
|
||||||
|
if (emailRes.ok) {
|
||||||
|
const emails = (await emailRes.json()) as {
|
||||||
|
email: string;
|
||||||
|
primary: boolean;
|
||||||
|
verified: boolean;
|
||||||
|
}[];
|
||||||
|
email =
|
||||||
|
emails.find((e) => e.primary && e.verified)?.email ??
|
||||||
|
emails.find((e) => e.verified)?.email ??
|
||||||
|
emails[0]?.email ??
|
||||||
|
null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider: "github",
|
||||||
|
provider_id: String(gh.id),
|
||||||
|
email,
|
||||||
|
name: gh.name || gh.login,
|
||||||
|
avatar_url: gh.avatar_url ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function googleProfile(p: ProviderConfig, code: string, redirectUri: string) {
|
||||||
|
const tokenRes = await fetch("https://oauth2.googleapis.com/token", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
client_id: p.clientId!,
|
||||||
|
client_secret: p.clientSecret!,
|
||||||
|
code,
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const tokenJson = (await tokenRes.json()) as { access_token?: string };
|
||||||
|
if (!tokenJson.access_token) throw new Error("No access token from Google");
|
||||||
|
|
||||||
|
const userRes = await fetch(
|
||||||
|
"https://www.googleapis.com/oauth2/v2/userinfo",
|
||||||
|
{ headers: { Authorization: `Bearer ${tokenJson.access_token}` } }
|
||||||
|
);
|
||||||
|
const g = (await userRes.json()) as {
|
||||||
|
id: string;
|
||||||
|
email?: string;
|
||||||
|
name?: string;
|
||||||
|
picture?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider: "google",
|
||||||
|
provider_id: g.id,
|
||||||
|
email: g.email ?? null,
|
||||||
|
name: g.name || g.email || "Google user",
|
||||||
|
avatar_url: g.picture ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format a Date as SQLite's "YYYY-MM-DD HH:MM:SS" in UTC.
|
||||||
|
function isoSqlite(d: Date): string {
|
||||||
|
return d.toISOString().slice(0, 19).replace("T", " ");
|
||||||
|
}
|
||||||
@@ -6,13 +6,40 @@ export const db = new Database("spark_slop.db", { create: true });
|
|||||||
db.exec("PRAGMA journal_mode = WAL;");
|
db.exec("PRAGMA journal_mode = WAL;");
|
||||||
db.exec("PRAGMA foreign_keys = ON;");
|
db.exec("PRAGMA foreign_keys = ON;");
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
provider_id TEXT NOT NULL,
|
||||||
|
email TEXT,
|
||||||
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
avatar_url TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
UNIQUE (provider, provider_id)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
token TEXT PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS ideas (
|
CREATE TABLE IF NOT EXISTS ideas (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER,
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
description TEXT NOT NULL DEFAULT '',
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
visibility TEXT NOT NULL DEFAULT 'private',
|
||||||
|
share_token TEXT UNIQUE,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
@@ -27,14 +54,55 @@ db.exec(`
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_notes_idea_id ON notes(idea_id);`);
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_notes_idea_id ON notes(idea_id);`);
|
||||||
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_ideas_user_id ON ideas(user_id);`);
|
||||||
|
|
||||||
|
// --- Lightweight migration: add columns missing from older databases ---
|
||||||
|
function columns(table: string): Set<string> {
|
||||||
|
const rows = db.query(`PRAGMA table_info(${table})`).all() as {
|
||||||
|
name: string;
|
||||||
|
}[];
|
||||||
|
return new Set(rows.map((r) => r.name));
|
||||||
|
}
|
||||||
|
const ideaCols = columns("ideas");
|
||||||
|
if (!ideaCols.has("user_id"))
|
||||||
|
db.exec(`ALTER TABLE ideas ADD COLUMN user_id INTEGER`);
|
||||||
|
if (!ideaCols.has("visibility"))
|
||||||
|
db.exec(
|
||||||
|
`ALTER TABLE ideas ADD COLUMN visibility TEXT NOT NULL DEFAULT 'private'`
|
||||||
|
);
|
||||||
|
if (!ideaCols.has("share_token"))
|
||||||
|
db.exec(`ALTER TABLE ideas ADD COLUMN share_token TEXT`);
|
||||||
|
|
||||||
|
export type Visibility = "private" | "public";
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: number;
|
||||||
|
provider: string;
|
||||||
|
provider_id: string;
|
||||||
|
email: string | null;
|
||||||
|
name: string;
|
||||||
|
avatar_url: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Session {
|
||||||
|
token: string;
|
||||||
|
user_id: number;
|
||||||
|
created_at: string;
|
||||||
|
expires_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Idea {
|
export interface Idea {
|
||||||
id: number;
|
id: number;
|
||||||
|
user_id: number;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
visibility: Visibility;
|
||||||
|
share_token: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
note_count?: number;
|
note_count?: number;
|
||||||
|
author_name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Note {
|
export interface Note {
|
||||||
@@ -44,35 +112,140 @@ export interface Note {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Idea queries ---
|
// --- User queries ---
|
||||||
|
|
||||||
export function listIdeas(): Idea[] {
|
export function upsertUser(u: {
|
||||||
|
provider: string;
|
||||||
|
provider_id: string;
|
||||||
|
email: string | null;
|
||||||
|
name: string;
|
||||||
|
avatar_url: string | null;
|
||||||
|
}): User {
|
||||||
|
const existing = db
|
||||||
|
.query(`SELECT * FROM users WHERE provider = ? AND provider_id = ?`)
|
||||||
|
.get(u.provider, u.provider_id) as User | null;
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return db
|
||||||
|
.query(
|
||||||
|
`UPDATE users SET email = ?, name = ?, avatar_url = ?
|
||||||
|
WHERE id = ? RETURNING *`
|
||||||
|
)
|
||||||
|
.get(u.email, u.name, u.avatar_url, existing.id) as User;
|
||||||
|
}
|
||||||
|
return db
|
||||||
|
.query(
|
||||||
|
`INSERT INTO users (provider, provider_id, email, name, avatar_url)
|
||||||
|
VALUES (?, ?, ?, ?, ?) RETURNING *`
|
||||||
|
)
|
||||||
|
.get(u.provider, u.provider_id, u.email, u.name, u.avatar_url) as User;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Session queries ---
|
||||||
|
|
||||||
|
export function createSession(
|
||||||
|
token: string,
|
||||||
|
userId: number,
|
||||||
|
expiresAt: string
|
||||||
|
): void {
|
||||||
|
db.query(
|
||||||
|
`INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)`
|
||||||
|
).run(token, userId, expiresAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSessionUser(token: string): User | null {
|
||||||
|
return (
|
||||||
|
(db
|
||||||
|
.query(
|
||||||
|
`SELECT u.* FROM sessions s
|
||||||
|
JOIN users u ON u.id = s.user_id
|
||||||
|
WHERE s.token = ? AND s.expires_at > datetime('now')`
|
||||||
|
)
|
||||||
|
.get(token) as User) ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteSession(token: string): void {
|
||||||
|
db.query(`DELETE FROM sessions WHERE token = ?`).run(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Idea queries (ownership-scoped) ---
|
||||||
|
|
||||||
|
export function listIdeas(userId: number): Idea[] {
|
||||||
return db
|
return db
|
||||||
.query(
|
.query(
|
||||||
`SELECT i.*, COUNT(n.id) AS note_count
|
`SELECT i.*, COUNT(n.id) AS note_count
|
||||||
FROM ideas i
|
FROM ideas i
|
||||||
LEFT JOIN notes n ON n.idea_id = i.id
|
LEFT JOIN notes n ON n.idea_id = i.id
|
||||||
|
WHERE i.user_id = ?
|
||||||
GROUP BY i.id
|
GROUP BY i.id
|
||||||
ORDER BY i.updated_at DESC`
|
ORDER BY i.updated_at DESC`
|
||||||
)
|
)
|
||||||
|
.all(userId) as Idea[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOwnedIdea(id: number, userId: number): Idea | null {
|
||||||
|
return (
|
||||||
|
(db
|
||||||
|
.query(`SELECT * FROM ideas WHERE id = ? AND user_id = ?`)
|
||||||
|
.get(id, userId) as Idea) ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPublicIdeaById(id: number): Idea | null {
|
||||||
|
return (
|
||||||
|
(db
|
||||||
|
.query(
|
||||||
|
`SELECT i.*, u.name AS author_name
|
||||||
|
FROM ideas i JOIN users u ON u.id = i.user_id
|
||||||
|
WHERE i.id = ? AND i.visibility = 'public'`
|
||||||
|
)
|
||||||
|
.get(id) as Idea) ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getIdeaByShareToken(token: string): Idea | null {
|
||||||
|
return (
|
||||||
|
(db
|
||||||
|
.query(
|
||||||
|
`SELECT i.*, u.name AS author_name
|
||||||
|
FROM ideas i JOIN users u ON u.id = i.user_id
|
||||||
|
WHERE i.share_token = ?`
|
||||||
|
)
|
||||||
|
.get(token) as Idea) ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listPublicIdeas(): Idea[] {
|
||||||
|
return db
|
||||||
|
.query(
|
||||||
|
`SELECT i.*, u.name AS author_name, COUNT(n.id) AS note_count
|
||||||
|
FROM ideas i
|
||||||
|
JOIN users u ON u.id = i.user_id
|
||||||
|
LEFT JOIN notes n ON n.idea_id = i.id
|
||||||
|
WHERE i.visibility = 'public'
|
||||||
|
GROUP BY i.id
|
||||||
|
ORDER BY i.updated_at DESC
|
||||||
|
LIMIT 200`
|
||||||
|
)
|
||||||
.all() as Idea[];
|
.all() as Idea[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getIdea(id: number): Idea | null {
|
export function createIdea(
|
||||||
return (db.query(`SELECT * FROM ideas WHERE id = ?`).get(id) as Idea) ?? null;
|
userId: number,
|
||||||
}
|
title: string,
|
||||||
|
description: string
|
||||||
export function createIdea(title: string, description: string): Idea {
|
): Idea {
|
||||||
const row = db
|
return db
|
||||||
.query(
|
.query(
|
||||||
`INSERT INTO ideas (title, description) VALUES (?, ?) RETURNING *`
|
`INSERT INTO ideas (user_id, title, description) VALUES (?, ?, ?) RETURNING *`
|
||||||
)
|
)
|
||||||
.get(title, description) as Idea;
|
.get(userId, title, description) as Idea;
|
||||||
return row;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateIdea(
|
export function updateIdea(
|
||||||
id: number,
|
id: number,
|
||||||
|
userId: number,
|
||||||
title: string,
|
title: string,
|
||||||
description: string
|
description: string
|
||||||
): Idea | null {
|
): Idea | null {
|
||||||
@@ -81,23 +254,56 @@ export function updateIdea(
|
|||||||
.query(
|
.query(
|
||||||
`UPDATE ideas
|
`UPDATE ideas
|
||||||
SET title = ?, description = ?, updated_at = datetime('now')
|
SET title = ?, description = ?, updated_at = datetime('now')
|
||||||
WHERE id = ?
|
WHERE id = ? AND user_id = ?
|
||||||
RETURNING *`
|
RETURNING *`
|
||||||
)
|
)
|
||||||
.get(title, description, id) as Idea) ?? null
|
.get(title, description, id, userId) as Idea) ?? null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteIdea(id: number): boolean {
|
export function setVisibility(
|
||||||
const res = db.query(`DELETE FROM ideas WHERE id = ?`).run(id);
|
id: number,
|
||||||
return res.changes > 0;
|
userId: number,
|
||||||
|
visibility: Visibility
|
||||||
|
): Idea | null {
|
||||||
|
return (
|
||||||
|
(db
|
||||||
|
.query(
|
||||||
|
`UPDATE ideas SET visibility = ?, updated_at = datetime('now')
|
||||||
|
WHERE id = ? AND user_id = ? RETURNING *`
|
||||||
|
)
|
||||||
|
.get(visibility, id, userId) as Idea) ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setShareToken(
|
||||||
|
id: number,
|
||||||
|
userId: number,
|
||||||
|
token: string | null
|
||||||
|
): Idea | null {
|
||||||
|
return (
|
||||||
|
(db
|
||||||
|
.query(
|
||||||
|
`UPDATE ideas SET share_token = ? WHERE id = ? AND user_id = ? RETURNING *`
|
||||||
|
)
|
||||||
|
.get(token, id, userId) as Idea) ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteIdea(id: number, userId: number): boolean {
|
||||||
|
return (
|
||||||
|
db.query(`DELETE FROM ideas WHERE id = ? AND user_id = ?`).run(id, userId)
|
||||||
|
.changes > 0
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Note queries ---
|
// --- Note queries ---
|
||||||
|
|
||||||
export function listNotes(ideaId: number): Note[] {
|
export function listNotes(ideaId: number): Note[] {
|
||||||
return db
|
return db
|
||||||
.query(`SELECT * FROM notes WHERE idea_id = ? ORDER BY created_at ASC, id ASC`)
|
.query(
|
||||||
|
`SELECT * FROM notes WHERE idea_id = ? ORDER BY created_at ASC, id ASC`
|
||||||
|
)
|
||||||
.all(ideaId) as Note[];
|
.all(ideaId) as Note[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,14 +311,21 @@ export function createNote(ideaId: number, body: string): Note {
|
|||||||
const note = db
|
const note = db
|
||||||
.query(`INSERT INTO notes (idea_id, body) VALUES (?, ?) RETURNING *`)
|
.query(`INSERT INTO notes (idea_id, body) VALUES (?, ?) RETURNING *`)
|
||||||
.get(ideaId, body) as Note;
|
.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(
|
db.query(`UPDATE ideas SET updated_at = datetime('now') WHERE id = ?`).run(
|
||||||
ideaId
|
ideaId
|
||||||
);
|
);
|
||||||
return note;
|
return note;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteNote(id: number): boolean {
|
// Returns the idea_id of the deleted note, or null if it didn't exist /
|
||||||
const res = db.query(`DELETE FROM notes WHERE id = ?`).run(id);
|
// wasn't owned by the user (ownership enforced via the join).
|
||||||
return res.changes > 0;
|
export function deleteNoteOwned(noteId: number, userId: number): boolean {
|
||||||
|
return (
|
||||||
|
db
|
||||||
|
.query(
|
||||||
|
`DELETE FROM notes
|
||||||
|
WHERE id = ? AND idea_id IN (SELECT id FROM ideas WHERE user_id = ?)`
|
||||||
|
)
|
||||||
|
.run(noteId, userId).changes > 0
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+137
-19
@@ -1,13 +1,27 @@
|
|||||||
import {
|
import {
|
||||||
listIdeas,
|
listIdeas,
|
||||||
getIdea,
|
getOwnedIdea,
|
||||||
|
getPublicIdeaById,
|
||||||
|
getIdeaByShareToken,
|
||||||
|
listPublicIdeas,
|
||||||
createIdea,
|
createIdea,
|
||||||
updateIdea,
|
updateIdea,
|
||||||
|
setVisibility,
|
||||||
|
setShareToken,
|
||||||
deleteIdea,
|
deleteIdea,
|
||||||
listNotes,
|
listNotes,
|
||||||
createNote,
|
createNote,
|
||||||
deleteNote,
|
deleteNoteOwned,
|
||||||
|
type User,
|
||||||
} from "./db";
|
} from "./db";
|
||||||
|
import {
|
||||||
|
currentUser,
|
||||||
|
configuredProviders,
|
||||||
|
startOAuth,
|
||||||
|
handleCallback,
|
||||||
|
logout,
|
||||||
|
randomToken,
|
||||||
|
} from "./auth";
|
||||||
|
|
||||||
const PORT = Number(process.env.PORT ?? 3000);
|
const PORT = Number(process.env.PORT ?? 3000);
|
||||||
const PUBLIC_DIR = new URL("../public/", import.meta.url).pathname;
|
const PUBLIC_DIR = new URL("../public/", import.meta.url).pathname;
|
||||||
@@ -18,11 +32,9 @@ function json(data: unknown, status = 200): Response {
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function err(message: string, status = 400): Response {
|
function err(message: string, status = 400): Response {
|
||||||
return json({ error: message }, status);
|
return json({ error: message }, status);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readJson(req: Request): Promise<any> {
|
async function readJson(req: Request): Promise<any> {
|
||||||
try {
|
try {
|
||||||
return await req.json();
|
return await req.json();
|
||||||
@@ -31,14 +43,30 @@ async function readJson(req: Request): Promise<any> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Expose only safe, owner-facing fields.
|
||||||
|
function publicView(idea: any, includeNotes = false) {
|
||||||
|
const base = {
|
||||||
|
id: idea.id,
|
||||||
|
title: idea.title,
|
||||||
|
description: idea.description,
|
||||||
|
visibility: idea.visibility,
|
||||||
|
author_name: idea.author_name ?? null,
|
||||||
|
created_at: idea.created_at,
|
||||||
|
updated_at: idea.updated_at,
|
||||||
|
note_count: idea.note_count,
|
||||||
|
};
|
||||||
|
return includeNotes ? { ...base, notes: listNotes(idea.id) } : base;
|
||||||
|
}
|
||||||
|
|
||||||
async function serveStatic(pathname: string): Promise<Response> {
|
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);
|
let rel = pathname === "/" ? "index.html" : pathname.slice(1);
|
||||||
if (rel === "app") rel = "app.html";
|
if (rel === "app") rel = "app.html";
|
||||||
|
if (rel === "explore") rel = "explore.html";
|
||||||
|
// /share/:token and /share?idea= -> the read-only viewer page.
|
||||||
|
if (rel === "share" || rel.startsWith("share/")) rel = "share.html";
|
||||||
|
|
||||||
let file = Bun.file(PUBLIC_DIR + rel);
|
let file = Bun.file(PUBLIC_DIR + rel);
|
||||||
if (!(await file.exists())) {
|
if (!(await file.exists())) {
|
||||||
// SPA-ish fallback for unknown non-asset routes.
|
|
||||||
if (!rel.includes(".")) file = Bun.file(PUBLIC_DIR + "index.html");
|
if (!rel.includes(".")) file = Bun.file(PUBLIC_DIR + "index.html");
|
||||||
}
|
}
|
||||||
if (!(await file.exists())) return new Response("Not found", { status: 404 });
|
if (!(await file.exists())) return new Response("Not found", { status: 404 });
|
||||||
@@ -51,7 +79,8 @@ const server = Bun.serve({
|
|||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
const { pathname } = url;
|
const { pathname } = url;
|
||||||
|
|
||||||
// ---------- API ----------
|
if (pathname.startsWith("/auth/")) return handleAuthRoutes(req, pathname);
|
||||||
|
|
||||||
if (pathname.startsWith("/api/")) {
|
if (pathname.startsWith("/api/")) {
|
||||||
try {
|
try {
|
||||||
return await handleApi(req, pathname);
|
return await handleApi(req, pathname);
|
||||||
@@ -61,23 +90,76 @@ const server = Bun.serve({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Static ----------
|
|
||||||
return serveStatic(pathname);
|
return serveStatic(pathname);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function handleAuthRoutes(req: Request, pathname: string): Promise<Response> | Response {
|
||||||
|
let m = pathname.match(/^\/auth\/(github|google)$/);
|
||||||
|
if (m) return startOAuth(m[1]!);
|
||||||
|
|
||||||
|
m = pathname.match(/^\/auth\/(github|google)\/callback$/);
|
||||||
|
if (m) return handleCallback(m[1]!, req);
|
||||||
|
|
||||||
|
if (pathname === "/auth/logout") return logout(req);
|
||||||
|
|
||||||
|
return new Response("Not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
async function handleApi(req: Request, pathname: string): Promise<Response> {
|
async function handleApi(req: Request, pathname: string): Promise<Response> {
|
||||||
const method = req.method;
|
const method = req.method;
|
||||||
|
|
||||||
|
// ---- Public, no auth required ----
|
||||||
|
if (pathname === "/api/auth/providers" && method === "GET") {
|
||||||
|
return json({ providers: configuredProviders() });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname === "/api/me" && method === "GET") {
|
||||||
|
const user = currentUser(req);
|
||||||
|
if (!user) return err("Not authenticated", 401);
|
||||||
|
return json(safeUser(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname === "/api/explore" && method === "GET") {
|
||||||
|
return json(listPublicIdeas().map((i) => publicView(i)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public idea detail (read-only) — visibility must be 'public'.
|
||||||
|
let m = pathname.match(/^\/api\/public\/ideas\/(\d+)$/);
|
||||||
|
if (m && method === "GET") {
|
||||||
|
const idea = getPublicIdeaById(Number(m[1]));
|
||||||
|
return idea ? json(publicView(idea, true)) : err("Not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared read-only view by secret token.
|
||||||
|
m = pathname.match(/^\/api\/shared\/([a-f0-9]+)$/);
|
||||||
|
if (m && method === "GET") {
|
||||||
|
const idea = getIdeaByShareToken(m[1]!);
|
||||||
|
return idea ? json(publicView(idea, true)) : err("Not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Everything below requires authentication ----
|
||||||
|
const user = currentUser(req);
|
||||||
|
if (!user) return err("Not authenticated", 401);
|
||||||
|
|
||||||
|
return handleAuthedApi(req, pathname, method, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAuthedApi(
|
||||||
|
req: Request,
|
||||||
|
pathname: string,
|
||||||
|
method: string,
|
||||||
|
user: User
|
||||||
|
): Promise<Response> {
|
||||||
// /api/ideas
|
// /api/ideas
|
||||||
if (pathname === "/api/ideas") {
|
if (pathname === "/api/ideas") {
|
||||||
if (method === "GET") return json(listIdeas());
|
if (method === "GET") return json(listIdeas(user.id));
|
||||||
if (method === "POST") {
|
if (method === "POST") {
|
||||||
const { title, description } = await readJson(req);
|
const { title, description } = await readJson(req);
|
||||||
if (!title || typeof title !== "string" || !title.trim())
|
if (!title || typeof title !== "string" || !title.trim())
|
||||||
return err("Title is required");
|
return err("Title is required");
|
||||||
return json(
|
return json(
|
||||||
createIdea(title.trim(), (description ?? "").toString().trim()),
|
createIdea(user.id, title.trim(), (description ?? "").toString().trim()),
|
||||||
201
|
201
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -89,7 +171,7 @@ async function handleApi(req: Request, pathname: string): Promise<Response> {
|
|||||||
if (m) {
|
if (m) {
|
||||||
const id = Number(m[1]);
|
const id = Number(m[1]);
|
||||||
if (method === "GET") {
|
if (method === "GET") {
|
||||||
const idea = getIdea(id);
|
const idea = getOwnedIdea(id, user.id);
|
||||||
return idea ? json(idea) : err("Idea not found", 404);
|
return idea ? json(idea) : err("Idea not found", 404);
|
||||||
}
|
}
|
||||||
if (method === "PUT") {
|
if (method === "PUT") {
|
||||||
@@ -97,13 +179,41 @@ async function handleApi(req: Request, pathname: string): Promise<Response> {
|
|||||||
if (!title || !title.trim()) return err("Title is required");
|
if (!title || !title.trim()) return err("Title is required");
|
||||||
const updated = updateIdea(
|
const updated = updateIdea(
|
||||||
id,
|
id,
|
||||||
|
user.id,
|
||||||
title.trim(),
|
title.trim(),
|
||||||
(description ?? "").toString().trim()
|
(description ?? "").toString().trim()
|
||||||
);
|
);
|
||||||
return updated ? json(updated) : err("Idea not found", 404);
|
return updated ? json(updated) : err("Idea not found", 404);
|
||||||
}
|
}
|
||||||
if (method === "DELETE") {
|
if (method === "DELETE") {
|
||||||
return deleteIdea(id) ? json({ ok: true }) : err("Idea not found", 404);
|
return deleteIdea(id, user.id)
|
||||||
|
? json({ ok: true })
|
||||||
|
: err("Idea not found", 404);
|
||||||
|
}
|
||||||
|
return err("Method not allowed", 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
// /api/ideas/:id/visibility { visibility: 'public' | 'private' }
|
||||||
|
m = pathname.match(/^\/api\/ideas\/(\d+)\/visibility$/);
|
||||||
|
if (m && method === "PUT") {
|
||||||
|
const { visibility } = await readJson(req);
|
||||||
|
if (visibility !== "public" && visibility !== "private")
|
||||||
|
return err("visibility must be 'public' or 'private'");
|
||||||
|
const updated = setVisibility(Number(m[1]), user.id, visibility);
|
||||||
|
return updated ? json(updated) : err("Idea not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// /api/ideas/:id/share POST = enable/rotate, DELETE = disable
|
||||||
|
m = pathname.match(/^\/api\/ideas\/(\d+)\/share$/);
|
||||||
|
if (m) {
|
||||||
|
const id = Number(m[1]);
|
||||||
|
if (method === "POST") {
|
||||||
|
const updated = setShareToken(id, user.id, randomToken(16));
|
||||||
|
return updated ? json(updated) : err("Idea not found", 404);
|
||||||
|
}
|
||||||
|
if (method === "DELETE") {
|
||||||
|
const updated = setShareToken(id, user.id, null);
|
||||||
|
return updated ? json(updated) : err("Idea not found", 404);
|
||||||
}
|
}
|
||||||
return err("Method not allowed", 405);
|
return err("Method not allowed", 405);
|
||||||
}
|
}
|
||||||
@@ -112,7 +222,7 @@ async function handleApi(req: Request, pathname: string): Promise<Response> {
|
|||||||
m = pathname.match(/^\/api\/ideas\/(\d+)\/notes$/);
|
m = pathname.match(/^\/api\/ideas\/(\d+)\/notes$/);
|
||||||
if (m) {
|
if (m) {
|
||||||
const ideaId = Number(m[1]);
|
const ideaId = Number(m[1]);
|
||||||
if (!getIdea(ideaId)) return err("Idea not found", 404);
|
if (!getOwnedIdea(ideaId, user.id)) return err("Idea not found", 404);
|
||||||
if (method === "GET") return json(listNotes(ideaId));
|
if (method === "GET") return json(listNotes(ideaId));
|
||||||
if (method === "POST") {
|
if (method === "POST") {
|
||||||
const { body } = await readJson(req);
|
const { body } = await readJson(req);
|
||||||
@@ -124,15 +234,23 @@ async function handleApi(req: Request, pathname: string): Promise<Response> {
|
|||||||
|
|
||||||
// /api/notes/:id
|
// /api/notes/:id
|
||||||
m = pathname.match(/^\/api\/notes\/(\d+)$/);
|
m = pathname.match(/^\/api\/notes\/(\d+)$/);
|
||||||
if (m) {
|
if (m && method === "DELETE") {
|
||||||
const id = Number(m[1]);
|
return deleteNoteOwned(Number(m[1]), user.id)
|
||||||
if (method === "DELETE") {
|
? json({ ok: true })
|
||||||
return deleteNote(id) ? json({ ok: true }) : err("Note not found", 404);
|
: err("Note not found", 404);
|
||||||
}
|
|
||||||
return err("Method not allowed", 405);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return err("Not found", 404);
|
return err("Not found", 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function safeUser(u: User) {
|
||||||
|
return {
|
||||||
|
id: u.id,
|
||||||
|
name: u.name,
|
||||||
|
email: u.email,
|
||||||
|
avatar_url: u.avatar_url,
|
||||||
|
provider: u.provider,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`✨ Spark Slop running at http://localhost:${server.port}`);
|
console.log(`✨ Spark Slop running at http://localhost:${server.port}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user