// --- tiny helpers --------------------------------------------------------- const $ = (sel) => document.querySelector(sel); const el = (tag, props = {}, ...kids) => { const n = Object.assign(document.createElement(tag), props); for (const k of kids) n.append(k?.nodeType ? k : document.createTextNode(k ?? "")); return n; }; const api = async (path, opts) => { const res = await fetch("/api" + path, { headers: { "Content-Type": "application/json" }, ...opts, }); if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error(body.error || res.statusText); } return res.status === 204 ? null : res.json(); }; function toast(msg) { let t = $(".toast"); if (!t) { t = el("div", { className: "toast" }); document.body.append(t); } t.textContent = msg; t.classList.add("show"); clearTimeout(t._timer); t._timer = setTimeout(() => t.classList.remove("show"), 2200); } function relTime(iso) { if (!iso) return "never"; const d = new Date(iso), now = new Date(); const days = Math.floor((now - d) / 86400000); if (days <= 0) return "today"; if (days === 1) return "yesterday"; if (days < 30) return days + "d ago"; if (days < 365) return Math.floor(days / 30) + "mo ago"; return Math.floor(days / 365) + "y ago"; } const fmtDate = (iso) => (iso ? new Date(iso).toLocaleDateString() : ""); // --- state ---------------------------------------------------------------- let state = { q: "", tag: "", sort: "name", selectedId: null }; // --- list ----------------------------------------------------------------- async function refresh() { const [stats, list] = await Promise.all([ api("/stats"), api(`/people?q=${encodeURIComponent(state.q)}&tag=${encodeURIComponent(state.tag)}&sort=${state.sort}`), ]); $("#stats").textContent = `${stats.people} contacts · ${stats.interactions} interactions · ${stats.relationships} links`; renderList(list); } function renderList(list) { const ul = $("#list"); ul.innerHTML = ""; if (!list.length) { ul.append(el("li", { className: "hint" }, "No matches.")); return; } for (const p of list) { const meta = el("div", { className: "meta" }, el("span", {}, `📇 ${relTime(p.last_contacted)}`), p.location ? el("span", {}, `📍 ${p.location}`) : "", p.interaction_count ? el("span", {}, `${p.interaction_count}×`) : "", ); (p.tags || []).forEach((t) => meta.append(el("span", { className: "tagchip" }, t))); const li = el("li", { className: p.id === state.selectedId ? "active" : "" }, el("span", { className: "name" }, p.full_name), meta, ); li.onclick = () => selectPerson(p.id); ul.append(li); } } async function loadTags() { const tags = await api("/tags"); const sel = $("#tag"); sel.innerHTML = ''; tags.forEach((t) => sel.append(el("option", { value: t.tag }, `${t.tag} (${t.count})`))); sel.value = state.tag; } // --- detail --------------------------------------------------------------- async function selectPerson(id) { state.selectedId = id; document.querySelectorAll(".list li").forEach((li) => li.classList.remove("active")); const p = await api(`/people/${id}`); renderDetail(p); refresh(); } function field(label, input) { return el("div", { className: "row" }, el("label", {}, label), el("div", { className: "field" }, input)); } function renderDetail(p) { const d = $("#detail"); d.innerHTML = ""; // --- editable info block --- const nameI = el("input", { value: p.full_name }); const emailI = el("input", { value: p.email || "", type: "email" }); const phoneI = el("input", { value: p.phone || "", type: "tel", placeholder: "+61…" }); const locationI = el("input", { value: p.location || "", placeholder: "City, country" }); const tagsI = el("input", { value: (p.tags || []).join(", ") }); const notesI = el("textarea", { value: p.notes || "" }); const save = el("button", { className: "primary" }, "Save"); save.onclick = async () => { try { await api(`/people/${p.id}`, { method: "PATCH", body: JSON.stringify({ full_name: nameI.value, email: emailI.value, phone: phoneI.value, location: locationI.value, tags: tagsI.value, notes: notesI.value }), }); toast("Saved"); await loadTags(); selectPerson(p.id); } catch (e) { toast(e.message); } }; const del = el("button", { className: "danger" }, "Delete"); del.onclick = async () => { if (!confirm(`Delete ${p.full_name}? This removes their interactions too.`)) return; try { await api(`/people/${p.id}`, { method: "DELETE" }); toast("Deleted"); state.selectedId = null; d.innerHTML = '
Select a contact, or add a new one.
'; loadTags(); refresh(); } catch (e) { toast(e.message); } }; const info = el("section", { className: "block" }, el("h2", {}, p.full_name), el("p", { className: "sub" }, `Last contacted ${relTime(p.last_contacted || (p.interactions[0]?.occurred_at))}`), field("Name", nameI), field("Email", emailI), field("Phone", phoneI), field("Location", locationI), field("Tags", tagsI), field("Notes", notesI), el("div", { className: "actions" }, save, del), ); d.append(info); // --- relationships --- if (p.relationships?.length) { const rl = el("ul", { className: "timeline" }); p.relationships.forEach((r) => { const link = el("a", { href: "#", style: "color:var(--accent)" }, r.other_name); link.onclick = (e) => { e.preventDefault(); selectPerson(r.other_id); }; rl.append(el("li", {}, el("span", { className: "badge" }, r.type), " ", link, r.notes ? ` — ${r.notes}` : "")); }); d.append(el("section", { className: "block" }, el("h3", {}, "Relationships"), rl)); } // --- interactions --- const typeI = el("select", {}, ...["call", "message", "email", "meetup", "note", "other"].map((t) => el("option", { value: t }, t))); const whenI = el("input", { type: "date", value: new Date().toISOString().slice(0, 10) }); const summaryI = el("input", { placeholder: "What happened? (short summary)" }); const inotesI = el("textarea", { placeholder: "Details (optional)" }); const logBtn = el("button", { className: "primary" }, "Log interaction"); logBtn.onclick = async () => { if (!summaryI.value.trim() && !inotesI.value.trim()) return toast("Add a summary first"); try { await api(`/people/${p.id}/interactions`, { method: "POST", body: JSON.stringify({ type: typeI.value, occurred_at: whenI.value, summary: summaryI.value, notes: inotesI.value }), }); toast("Logged"); selectPerson(p.id); } catch (e) { toast(e.message); } }; const timeline = el("ul", { className: "timeline" }); if (!p.interactions.length) timeline.append(el("li", { className: "hint" }, "No interactions logged yet.")); p.interactions.forEach((i) => { const delI = el("button", { className: "ghost", style: "float:right;color:var(--muted)" }, "✕"); delI.onclick = async () => { if (!confirm("Delete this interaction?")) return; await api(`/interactions/${i.id}`, { method: "DELETE" }); toast("Removed"); selectPerson(p.id); }; timeline.append(el("li", {}, delI, el("div", { className: "when" }, `${fmtDate(i.occurred_at)} · `, el("span", { className: "badge" }, i.type || "note")), el("div", {}, i.summary || ""), i.notes ? el("div", { className: "hint" }, i.notes) : "", )); }); d.append(el("section", { className: "block" }, el("h3", {}, "Log an interaction"), el("div", { className: "inline-form" }, field("Type", typeI), field("Date", whenI), field("Summary", summaryI), field("Notes", inotesI), logBtn), )); d.append(el("section", { className: "block" }, el("h3", {}, "History"), timeline)); } // --- add contact ---------------------------------------------------------- function showAddForm() { state.selectedId = null; document.querySelectorAll(".list li").forEach((li) => li.classList.remove("active")); const d = $("#detail"); d.innerHTML = ""; const nameI = el("input", { placeholder: "Full name *" }); const emailI = el("input", { type: "email", placeholder: "email@example.com" }); const phoneI = el("input", { type: "tel", placeholder: "+61…" }); const locationI = el("input", { placeholder: "City, country" }); const tagsI = el("input", { placeholder: "comma, separated, tags" }); const notesI = el("textarea", { placeholder: "Notes" }); const create = el("button", { className: "primary" }, "Create contact"); create.onclick = async () => { if (!nameI.value.trim()) return toast("Name is required"); try { const p = await api("/people", { method: "POST", body: JSON.stringify({ full_name: nameI.value, email: emailI.value, phone: phoneI.value, location: locationI.value, tags: tagsI.value, notes: notesI.value }), }); toast("Created"); await loadTags(); selectPerson(p.id); } catch (e) { toast(e.message); } }; d.append(el("section", { className: "block" }, el("h3", {}, "New contact"), field("Name", nameI), field("Email", emailI), field("Phone", phoneI), field("Location", locationI), field("Tags", tagsI), field("Notes", notesI), el("div", { className: "actions" }, create), )); } // --- wire up -------------------------------------------------------------- let searchTimer; $("#search").oninput = (e) => { state.q = e.target.value; clearTimeout(searchTimer); searchTimer = setTimeout(refresh, 200); }; $("#tag").onchange = (e) => { state.tag = e.target.value; refresh(); }; $("#sort").onchange = (e) => { state.sort = e.target.value; refresh(); }; $("#add-btn").onclick = showAddForm; loadTags(); refresh();