263 lines
7.2 KiB
JavaScript
263 lines
7.2 KiB
JavaScript
// --- Tiny API client ---
|
|
const api = {
|
|
async req(method, path, body) {
|
|
const res = await fetch(path, {
|
|
method,
|
|
headers: body ? { "Content-Type": "application/json" } : undefined,
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
const data = res.status === 204 ? null : await res.json().catch(() => null);
|
|
if (!res.ok) throw new Error((data && data.error) || "Request failed");
|
|
return data;
|
|
},
|
|
ideas: () => api.req("GET", "/api/ideas"),
|
|
createIdea: (title, description) =>
|
|
api.req("POST", "/api/ideas", { title, description }),
|
|
deleteIdea: (id) => api.req("DELETE", `/api/ideas/${id}`),
|
|
notes: (id) => api.req("GET", `/api/ideas/${id}/notes`),
|
|
addNote: (id, body) => api.req("POST", `/api/ideas/${id}/notes`, { body }),
|
|
deleteNote: (id) => api.req("DELETE", `/api/notes/${id}`),
|
|
};
|
|
|
|
// --- State ---
|
|
let ideas = [];
|
|
let selectedId = null;
|
|
|
|
// --- Helpers ---
|
|
const $ = (sel) => document.querySelector(sel);
|
|
|
|
function escapeHtml(s) {
|
|
return String(s)
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """);
|
|
}
|
|
|
|
// SQLite stores UTC datetimes as "YYYY-MM-DD HH:MM:SS"; parse as UTC.
|
|
function parseUTC(s) {
|
|
return new Date(s.replace(" ", "T") + "Z");
|
|
}
|
|
|
|
function relativeTime(s) {
|
|
const then = parseUTC(s);
|
|
const diff = (Date.now() - then.getTime()) / 1000;
|
|
if (diff < 45) return "just now";
|
|
if (diff < 90) return "a minute ago";
|
|
if (diff < 3600) return `${Math.round(diff / 60)} min ago`;
|
|
if (diff < 7200) return "an hour ago";
|
|
if (diff < 86400) return `${Math.round(diff / 3600)} hours ago`;
|
|
if (diff < 172800) return "yesterday";
|
|
if (diff < 604800) return `${Math.round(diff / 86400)} days ago`;
|
|
return then.toLocaleDateString(undefined, {
|
|
month: "short",
|
|
day: "numeric",
|
|
year: "numeric",
|
|
});
|
|
}
|
|
|
|
function fullTime(s) {
|
|
return parseUTC(s).toLocaleString(undefined, {
|
|
dateStyle: "medium",
|
|
timeStyle: "short",
|
|
});
|
|
}
|
|
|
|
let toastTimer;
|
|
function toast(msg, isError = false) {
|
|
const el = $("#toast");
|
|
el.textContent = msg;
|
|
el.classList.toggle("error", isError);
|
|
el.classList.add("show");
|
|
clearTimeout(toastTimer);
|
|
toastTimer = setTimeout(() => el.classList.remove("show"), 2600);
|
|
}
|
|
|
|
// --- Rendering ---
|
|
function renderList() {
|
|
const list = $("#ideas-list");
|
|
$("#idea-count").textContent = ideas.length;
|
|
|
|
if (ideas.length === 0) {
|
|
list.innerHTML =
|
|
'<div class="empty" style="padding:30px 10px">No ideas yet. Add your first one!</div>';
|
|
return;
|
|
}
|
|
|
|
list.innerHTML = ideas
|
|
.map(
|
|
(idea) => `
|
|
<div class="idea-item ${idea.id === selectedId ? "active" : ""}" data-id="${idea.id}">
|
|
<h3>${escapeHtml(idea.title)}</h3>
|
|
<div class="meta">
|
|
<span class="pill">${idea.note_count} note${idea.note_count === 1 ? "" : "s"}</span>
|
|
<span>updated ${relativeTime(idea.updated_at)}</span>
|
|
</div>
|
|
</div>`
|
|
)
|
|
.join("");
|
|
|
|
list.querySelectorAll(".idea-item").forEach((el) => {
|
|
el.addEventListener("click", () => selectIdea(Number(el.dataset.id)));
|
|
});
|
|
}
|
|
|
|
async function renderDetail() {
|
|
const panel = $("#detail-panel");
|
|
const idea = ideas.find((i) => i.id === selectedId);
|
|
|
|
if (!idea) {
|
|
panel.innerHTML =
|
|
'<div class="empty"><span class="big">👈</span>Select an idea to see its notes, or create one to get started.</div>';
|
|
return;
|
|
}
|
|
|
|
panel.innerHTML = `
|
|
<div class="detail-head">
|
|
<h2>${escapeHtml(idea.title)}</h2>
|
|
<button class="btn btn-danger btn-sm" id="del-idea">Delete idea</button>
|
|
</div>
|
|
${idea.description ? `<p class="detail-desc">${escapeHtml(idea.description)}</p>` : ""}
|
|
<div class="detail-meta">Created ${fullTime(idea.created_at)}</div>
|
|
<div class="divider"></div>
|
|
<h2 style="font-size:1.05rem;margin-bottom:4px">Notes</h2>
|
|
<div id="notes-container"><div class="empty" style="padding:20px">Loading…</div></div>
|
|
<form class="note-composer" id="note-form">
|
|
<textarea id="note-body" rows="2" placeholder="Add a note… (Cmd/Ctrl+Enter to post)" required></textarea>
|
|
<div class="row"><button type="submit" class="btn btn-primary btn-sm">💬 Post note</button></div>
|
|
</form>
|
|
`;
|
|
|
|
$("#del-idea").addEventListener("click", () => onDeleteIdea(idea.id));
|
|
|
|
const form = $("#note-form");
|
|
form.addEventListener("submit", (e) => {
|
|
e.preventDefault();
|
|
onAddNote(idea.id);
|
|
});
|
|
$("#note-body").addEventListener("keydown", (e) => {
|
|
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
|
e.preventDefault();
|
|
onAddNote(idea.id);
|
|
}
|
|
});
|
|
|
|
loadNotes(idea.id);
|
|
}
|
|
|
|
function renderNotes(notes) {
|
|
const container = $("#notes-container");
|
|
if (!container) return;
|
|
if (notes.length === 0) {
|
|
container.innerHTML =
|
|
'<div class="empty" style="padding:24px">No notes yet — add the first one below.</div>';
|
|
return;
|
|
}
|
|
container.innerHTML = notes
|
|
.map(
|
|
(n) => `
|
|
<div class="note">
|
|
<div class="avatar">📝</div>
|
|
<div class="note-body">
|
|
<div class="text">${escapeHtml(n.body)}</div>
|
|
<div class="note-time">
|
|
<span title="${escapeHtml(fullTime(n.created_at))}">${relativeTime(n.created_at)}</span>
|
|
<span class="del" data-note="${n.id}">delete</span>
|
|
</div>
|
|
</div>
|
|
</div>`
|
|
)
|
|
.join("");
|
|
|
|
container.querySelectorAll(".del").forEach((el) => {
|
|
el.addEventListener("click", () => onDeleteNote(Number(el.dataset.note)));
|
|
});
|
|
}
|
|
|
|
// --- Actions ---
|
|
async function refresh() {
|
|
try {
|
|
ideas = await api.ideas();
|
|
renderList();
|
|
renderDetail();
|
|
} catch (e) {
|
|
toast(e.message, true);
|
|
}
|
|
}
|
|
|
|
function selectIdea(id) {
|
|
selectedId = id;
|
|
renderList();
|
|
renderDetail();
|
|
}
|
|
|
|
async function loadNotes(id) {
|
|
try {
|
|
const notes = await api.notes(id);
|
|
if (selectedId === id) renderNotes(notes);
|
|
} catch (e) {
|
|
toast(e.message, true);
|
|
}
|
|
}
|
|
|
|
async function onCreateIdea(e) {
|
|
e.preventDefault();
|
|
const title = $("#idea-title").value.trim();
|
|
const description = $("#idea-desc").value.trim();
|
|
if (!title) return;
|
|
try {
|
|
const idea = await api.createIdea(title, description);
|
|
$("#idea-form").reset();
|
|
await refresh();
|
|
selectIdea(idea.id);
|
|
toast("Idea added ✨");
|
|
} catch (err) {
|
|
toast(err.message, true);
|
|
}
|
|
}
|
|
|
|
async function onDeleteIdea(id) {
|
|
if (!confirm("Delete this idea and all its notes?")) return;
|
|
try {
|
|
await api.deleteIdea(id);
|
|
if (selectedId === id) selectedId = null;
|
|
await refresh();
|
|
toast("Idea deleted");
|
|
} catch (err) {
|
|
toast(err.message, true);
|
|
}
|
|
}
|
|
|
|
async function onAddNote(id) {
|
|
const input = $("#note-body");
|
|
const body = input.value.trim();
|
|
if (!body) return;
|
|
try {
|
|
await api.addNote(id, body);
|
|
input.value = "";
|
|
// Refresh notes immediately, plus list for the updated count/order.
|
|
await Promise.all([loadNotes(id), refreshListOnly()]);
|
|
} catch (err) {
|
|
toast(err.message, true);
|
|
}
|
|
}
|
|
|
|
async function refreshListOnly() {
|
|
ideas = await api.ideas();
|
|
renderList();
|
|
}
|
|
|
|
async function onDeleteNote(noteId) {
|
|
try {
|
|
await api.deleteNote(noteId);
|
|
if (selectedId) await Promise.all([loadNotes(selectedId), refreshListOnly()]);
|
|
toast("Note deleted");
|
|
} catch (err) {
|
|
toast(err.message, true);
|
|
}
|
|
}
|
|
|
|
// --- Init ---
|
|
$("#idea-form").addEventListener("submit", onCreateIdea);
|
|
refresh();
|