Kin: personal relationships app — server (cadence/due/migrations) + Expo Android app
Some checks failed
Build & Release APK / build (push) Failing after 13s
Some checks failed
Build & Release APK / build (push) Failing after 13s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
232
server/public/app.js
Normal file
232
server/public/app.js
Normal file
@@ -0,0 +1,232 @@
|
||||
// --- 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();
|
||||
34
server/public/index.html
Normal file
34
server/public/index.html
Normal file
@@ -0,0 +1,34 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Personal CRM</title>
|
||||
<link rel="stylesheet" href="/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Personal CRM</h1>
|
||||
<div class="stats" id="stats"></div>
|
||||
<div class="controls">
|
||||
<input id="search" type="search" placeholder="Search name or email…" autocomplete="off" />
|
||||
<select id="tag"><option value="">All tags</option></select>
|
||||
<select id="sort">
|
||||
<option value="name">Sort: Name</option>
|
||||
<option value="recent">Sort: Recently contacted</option>
|
||||
<option value="stale">Sort: Out of touch</option>
|
||||
</select>
|
||||
<button id="add-btn" class="primary">+ Contact</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<ul id="list" class="list"></ul>
|
||||
<section id="detail" class="detail">
|
||||
<p class="empty">Select a contact, or add a new one.</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
102
server/public/styles.css
Normal file
102
server/public/styles.css
Normal file
@@ -0,0 +1,102 @@
|
||||
:root {
|
||||
--bg: #0f1115;
|
||||
--panel: #171a21;
|
||||
--panel-2: #1e222b;
|
||||
--border: #2a2f3a;
|
||||
--text: #e6e8ec;
|
||||
--muted: #8b93a1;
|
||||
--accent: #4f8cff;
|
||||
--accent-2: #2d6ae0;
|
||||
--danger: #e5484d;
|
||||
--radius: 10px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
header {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px 16px;
|
||||
background: var(--panel);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
}
|
||||
header h1 { font-size: 18px; margin: 0; }
|
||||
.stats { color: var(--muted); font-size: 13px; }
|
||||
.controls { margin-left: auto; display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
|
||||
input, select, textarea, button {
|
||||
font: inherit;
|
||||
color: var(--text);
|
||||
background: var(--panel-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
input:focus, select:focus, textarea:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
|
||||
button { cursor: pointer; }
|
||||
button.primary { background: var(--accent); border-color: var(--accent-2); color: #fff; font-weight: 600; }
|
||||
button.primary:hover { background: var(--accent-2); }
|
||||
button.ghost { background: transparent; }
|
||||
button.danger { color: var(--danger); border-color: var(--danger); background: transparent; }
|
||||
|
||||
main { display: grid; grid-template-columns: 340px 1fr; min-height: calc(100vh - 66px); }
|
||||
|
||||
.list { list-style: none; margin: 0; padding: 8px; border-right: 1px solid var(--border); overflow-y: auto; }
|
||||
.list li {
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
.list li:hover { background: var(--panel); }
|
||||
.list li.active { background: var(--panel-2); outline: 1px solid var(--accent); }
|
||||
.list .name { font-weight: 600; }
|
||||
.list .meta { font-size: 12px; color: var(--muted); display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.tagchip { font-size: 11px; padding: 1px 7px; border-radius: 999px; background: #23324d; color: #9dbcff; }
|
||||
|
||||
.detail { padding: 24px; overflow-y: auto; }
|
||||
.detail .empty { color: var(--muted); }
|
||||
.detail h2 { margin: 0 0 4px; }
|
||||
.detail .sub { color: var(--muted); margin: 0 0 16px; }
|
||||
.detail section.block { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; margin-bottom: 16px; }
|
||||
.detail section.block h3 { margin: 0 0 12px; font-size: 13px; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); }
|
||||
|
||||
.row { display: flex; gap: 10px; margin-bottom: 10px; }
|
||||
.row label { flex: 0 0 110px; color: var(--muted); padding-top: 8px; }
|
||||
.row .field { flex: 1; }
|
||||
.field input, .field textarea, .field select { width: 100%; }
|
||||
textarea { resize: vertical; min-height: 60px; }
|
||||
|
||||
.timeline { list-style: none; margin: 0; padding: 0; }
|
||||
.timeline li { padding: 10px 0; border-top: 1px solid var(--border); }
|
||||
.timeline li:first-child { border-top: none; }
|
||||
.timeline .when { font-size: 12px; color: var(--muted); }
|
||||
.badge { font-size: 11px; padding: 1px 8px; border-radius: 999px; background: var(--panel-2); border: 1px solid var(--border); }
|
||||
|
||||
.actions { display: flex; gap: 8px; margin-top: 8px; }
|
||||
.inline-form { display: grid; gap: 8px; }
|
||||
.hint { color: var(--muted); font-size: 12px; }
|
||||
.toast {
|
||||
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
|
||||
background: var(--panel-2); border: 1px solid var(--border); padding: 10px 16px;
|
||||
border-radius: 8px; opacity: 0; transition: opacity .2s; pointer-events: none;
|
||||
}
|
||||
.toast.show { opacity: 1; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
main { grid-template-columns: 1fr; }
|
||||
.list { max-height: 40vh; }
|
||||
}
|
||||
Reference in New Issue
Block a user