Files
spark/public/app.js
T
2026-06-10 00:40:26 -04:00

349 lines
9.9 KiB
JavaScript

// --- 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 }),
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}`),
};
let me = null;
let ideas = [];
let selectedId = null;
// --- 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);
}
}
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 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);
}
// --- Rendering: idea list ---
function renderList() {
const list = $("#ideas-list");
$("#idea-count").textContent = ideas.length;
if (!ideas.length) {
list.innerHTML =
'<div class="empty" style="padding:30px 10px">No ideas yet. Add your first one!</div>';
return;
}
list.innerHTML = ideas
.map(
(idea) => `
<div class="idea-item ${idea.id === selectedId ? "active" : ""}" data-id="${idea.id}">
<h3>${escapeHtml(idea.title)}</h3>
<div class="meta">
<span class="pill">${idea.note_count} note${
idea.note_count === 1 ? "" : "s"
}</span>
<span 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)))
);
}
// --- Rendering: detail + privacy + notes ---
function renderDetail() {
const panel = $("#detail-panel");
const idea = ideas.find((i) => i.id === selectedId);
if (!idea) {
panel.innerHTML =
'<div class="empty"><span class="big">👈</span>Select an idea to see its notes, or create one to get started.</div>';
return;
}
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>`
: ""
}
<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>
<form class="note-composer" id="note-form">
<textarea id="note-body" rows="2" placeholder="Add a note… (Cmd/Ctrl+Enter to post)" required></textarea>
<div class="row"><button type="submit" class="btn btn-primary btn-sm">💬 Post note</button></div>
</form>
`;
$("#del-idea").addEventListener("click", () => onDeleteIdea(idea.id));
$("#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) => {
e.preventDefault();
onAddNote(idea.id);
});
$("#note-body").addEventListener("keydown", (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault();
onAddNote(idea.id);
}
});
loadNotes(idea.id);
}
// --- Actions ---
async function refresh() {
try {
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;
renderList();
renderDetail();
}
async function loadNotes(id) {
try {
const notes = await ideasApi.notes(id);
if (selectedId === id)
renderNotesInto($("#notes-container"), notes, onDeleteNote);
} catch (e) {
toast(e.message, true);
}
}
async function onCreateIdea(e) {
e.preventDefault();
const title = $("#idea-title").value.trim();
const description = $("#idea-desc").value.trim();
if (!title) return;
try {
const idea = await ideasApi.create(title, description);
$("#idea-form").reset();
await refresh();
selectIdea(idea.id);
toast("Idea added ✨");
} catch (err) {
toast(err.message, true);
}
}
async function onDeleteIdea(id) {
if (!confirm("Delete this idea and all its notes?")) return;
try {
await ideasApi.remove(id);
if (selectedId === id) selectedId = null;
await refresh();
toast("Idea deleted");
} catch (err) {
toast(err.message, true);
}
}
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 ideasApi.addNote(id, body);
input.value = "";
await Promise.all([loadNotes(id), refreshListOnly()]);
} catch (err) {
toast(err.message, true);
}
}
async function onDeleteNote(noteId) {
try {
await ideasApi.deleteNote(noteId);
if (selectedId) await Promise.all([loadNotes(selectedId), refreshListOnly()]);
toast("Note deleted");
} catch (err) {
toast(err.message, true);
}
}
// 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();