Some checks failed
Build & Release APK / build (push) Failing after 13s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
233 lines
9.6 KiB
JavaScript
233 lines
9.6 KiB
JavaScript
// --- 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 = '<option value="">All tags</option>';
|
||
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 = '<p class="empty">Select a contact, or add a new one.</p>';
|
||
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();
|