v2
This commit is contained in:
+21
-5
@@ -18,11 +18,29 @@
|
||||
<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 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>
|
||||
</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="app-header">
|
||||
<div>
|
||||
@@ -32,7 +50,6 @@
|
||||
</div>
|
||||
|
||||
<div class="layout">
|
||||
<!-- Left: new idea + list -->
|
||||
<div>
|
||||
<div class="panel" style="margin-bottom: 20px">
|
||||
<h2>New idea</h2>
|
||||
@@ -67,7 +84,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: detail + notes -->
|
||||
<div class="panel" id="detail-panel">
|
||||
<div class="empty" id="detail-empty">
|
||||
<span class="big">👈</span>
|
||||
@@ -79,7 +95,7 @@
|
||||
</main>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script src="/common.js"></script>
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+198
-112
@@ -1,84 +1,99 @@
|
||||
// --- 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 endpoints (relies on window.api from common.js) ---
|
||||
const ideasApi = {
|
||||
list: () => api.req("GET", "/api/ideas"),
|
||||
create: (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`),
|
||||
addNote: (id, body) => api.req("POST", `/api/ideas/${id}/notes`, { body }),
|
||||
deleteNote: (id) => api.req("DELETE", `/api/notes/${id}`),
|
||||
};
|
||||
|
||||
// --- State ---
|
||||
let me = null;
|
||||
let ideas = [];
|
||||
let selectedId = null;
|
||||
|
||||
// --- Helpers ---
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
// --- Auth bootstrap ---
|
||||
async function init() {
|
||||
$("#idea-form").addEventListener("submit", onCreateIdea);
|
||||
try {
|
||||
me = await api.req("GET", "/api/me");
|
||||
showApp();
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
if (e.status === 401) showLogin();
|
||||
else toast(e.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
// SQLite stores UTC datetimes as "YYYY-MM-DD HH:MM:SS"; parse as UTC.
|
||||
function parseUTC(s) {
|
||||
return new Date(s.replace(" ", "T") + "Z");
|
||||
async function showLogin() {
|
||||
$("#app-main").hidden = true;
|
||||
$("#login-gate").hidden = false;
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
if (params.get("auth_error")) {
|
||||
const el = $("#login-error");
|
||||
el.hidden = false;
|
||||
el.textContent =
|
||||
params.get("auth_error") === "state"
|
||||
? "Sign-in session expired or was tampered with. Please try again."
|
||||
: "Could not complete sign-in. Please try again.";
|
||||
}
|
||||
|
||||
try {
|
||||
const { providers } = await api.req("GET", "/api/auth/providers");
|
||||
const box = $("#provider-buttons");
|
||||
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}`;
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
toast(e.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
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 showApp() {
|
||||
$("#login-gate").hidden = true;
|
||||
$("#app-main").hidden = false;
|
||||
renderUserChip();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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 ---
|
||||
// --- Rendering: idea list ---
|
||||
function renderList() {
|
||||
const list = $("#ideas-list");
|
||||
$("#idea-count").textContent = ideas.length;
|
||||
|
||||
if (ideas.length === 0) {
|
||||
if (!ideas.length) {
|
||||
list.innerHTML =
|
||||
'<div class="empty" style="padding:30px 10px">No ideas yet. Add your first one!</div>';
|
||||
return;
|
||||
@@ -90,19 +105,27 @@ function renderList() {
|
||||
<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 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>
|
||||
</div>
|
||||
</div>`
|
||||
)
|
||||
.join("");
|
||||
|
||||
list.querySelectorAll(".idea-item").forEach((el) => {
|
||||
el.addEventListener("click", () => selectIdea(Number(el.dataset.id)));
|
||||
});
|
||||
list
|
||||
.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 idea = ideas.find((i) => i.id === selectedId);
|
||||
|
||||
@@ -112,13 +135,57 @@ async function renderDetail() {
|
||||
return;
|
||||
}
|
||||
|
||||
const shareUrl = idea.share_token
|
||||
? `${location.origin}/share/${idea.share_token}`
|
||||
: "";
|
||||
|
||||
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>` : ""}
|
||||
${
|
||||
idea.description
|
||||
? `<p class="detail-desc">${escapeHtml(idea.description)}</p>`
|
||||
: ""
|
||||
}
|
||||
<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>
|
||||
<h2 style="font-size:1.05rem;margin-bottom:4px">Notes</h2>
|
||||
<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));
|
||||
$("#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");
|
||||
form.addEventListener("submit", (e) => {
|
||||
@@ -145,45 +222,20 @@ async function renderDetail() {
|
||||
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();
|
||||
ideas = await ideasApi.list();
|
||||
renderList();
|
||||
renderDetail();
|
||||
} catch (e) {
|
||||
toast(e.message, true);
|
||||
}
|
||||
}
|
||||
async function refreshListOnly() {
|
||||
ideas = await ideasApi.list();
|
||||
renderList();
|
||||
}
|
||||
|
||||
function selectIdea(id) {
|
||||
selectedId = id;
|
||||
@@ -193,8 +245,9 @@ function selectIdea(id) {
|
||||
|
||||
async function loadNotes(id) {
|
||||
try {
|
||||
const notes = await api.notes(id);
|
||||
if (selectedId === id) renderNotes(notes);
|
||||
const notes = await ideasApi.notes(id);
|
||||
if (selectedId === id)
|
||||
renderNotesInto($("#notes-container"), notes, onDeleteNote);
|
||||
} catch (e) {
|
||||
toast(e.message, true);
|
||||
}
|
||||
@@ -206,7 +259,7 @@ async function onCreateIdea(e) {
|
||||
const description = $("#idea-desc").value.trim();
|
||||
if (!title) return;
|
||||
try {
|
||||
const idea = await api.createIdea(title, description);
|
||||
const idea = await ideasApi.create(title, description);
|
||||
$("#idea-form").reset();
|
||||
await refresh();
|
||||
selectIdea(idea.id);
|
||||
@@ -219,7 +272,7 @@ async function onCreateIdea(e) {
|
||||
async function onDeleteIdea(id) {
|
||||
if (!confirm("Delete this idea and all its notes?")) return;
|
||||
try {
|
||||
await api.deleteIdea(id);
|
||||
await ideasApi.remove(id);
|
||||
if (selectedId === id) selectedId = null;
|
||||
await refresh();
|
||||
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) {
|
||||
const input = $("#note-body");
|
||||
const body = input.value.trim();
|
||||
if (!body) return;
|
||||
try {
|
||||
await api.addNote(id, body);
|
||||
await ideasApi.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);
|
||||
await ideasApi.deleteNote(noteId);
|
||||
if (selectedId) await Promise.all([loadNotes(selectedId), refreshListOnly()]);
|
||||
toast("Note deleted");
|
||||
} catch (err) {
|
||||
@@ -257,6 +339,10 @@ async function onDeleteNote(noteId) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Init ---
|
||||
$("#idea-form").addEventListener("submit", onCreateIdea);
|
||||
refresh();
|
||||
// Replace an idea in local state with a fresh copy (preserving note_count).
|
||||
function mergeIdea(updated) {
|
||||
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>Spark<span class="gradient-text">Slop</span></span>
|
||||
</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>
|
||||
</nav>
|
||||
|
||||
@@ -39,8 +42,8 @@
|
||||
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>
|
||||
<a href="/app" class="btn btn-primary">Sign in & start capturing</a>
|
||||
<a href="/explore" class="btn btn-ghost">🌐 Explore public ideas</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -64,11 +67,12 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<span class="icon">⚡</span>
|
||||
<h3>Fast & local</h3>
|
||||
<span class="icon">🔒</span>
|
||||
<h3>Private by default</h3>
|
||||
<p>
|
||||
Powered by Bun and SQLite. Your data lives in a single file on your
|
||||
machine — snappy, private, and yours.
|
||||
Sign in with GitHub or Google. Every idea is yours alone — until you
|
||||
choose to share it with a secret read-only link or list it on the
|
||||
public Explore feed.
|
||||
</p>
|
||||
</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;
|
||||
}
|
||||
|
||||
/* ---------- 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 {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
|
||||
Reference in New Issue
Block a user