diff --git a/netlify/functions/admin.mts b/netlify/functions/admin.mts index cc2312c..5c8c372 100644 --- a/netlify/functions/admin.mts +++ b/netlify/functions/admin.mts @@ -52,10 +52,60 @@ async function readData() { return { ...DEFAULT_DATA }; } -async function writeData(data: any) { +async function writeData(data: any, message = "admin: update admin data") { const { _sha, ...cleanData } = data; const content = JSON.stringify(cleanData, null, 2) + "\n"; - await githubPutFile("src/data/admin-data.json", content, "admin: update admin data", _sha); + await githubPutFile("src/data/admin-data.json", content, message, _sha); +} + +type TodoLike = { id: string; refId?: string; done?: boolean; remark?: string }; + +/** Describe what changed between two whole-todos snapshots. */ +function describeTodoChanges( + oldTodos: Record = {}, + newTodos: Record = {} +): string | null { + const perSlug: string[] = []; + const slugs = new Set([...Object.keys(oldTodos), ...Object.keys(newTodos)]); + for (const slug of slugs) { + const oldList = oldTodos[slug] || []; + const newList = newTodos[slug] || []; + const oldById = new Map(oldList.map((t) => [t.id, t])); + const newById = new Map(newList.map((t) => [t.id, t])); + + const added: TodoLike[] = []; + const removed: TodoLike[] = []; + const markedDone: TodoLike[] = []; + const reopened: TodoLike[] = []; + const remarkChanged: TodoLike[] = []; + + for (const [id, t] of newById) { + if (!oldById.has(id)) added.push(t); + } + for (const [id, t] of oldById) { + if (!newById.has(id)) removed.push(t); + } + for (const [id, newT] of newById) { + const oldT = oldById.get(id); + if (!oldT) continue; + if ((oldT.done ?? false) !== (newT.done ?? false)) { + (newT.done ? markedDone : reopened).push(newT); + } else if ((oldT.remark || "") !== (newT.remark || "")) { + remarkChanged.push(newT); + } + } + + const refs = (list: TodoLike[]) => + list.map((t) => t.refId || t.id.slice(0, 8)).join(", "); + const parts: string[] = []; + if (added.length) parts.push(`add ${refs(added)}`); + if (removed.length) parts.push(`remove ${refs(removed)}`); + if (markedDone.length) parts.push(`mark ${refs(markedDone)} done`); + if (reopened.length) parts.push(`reopen ${refs(reopened)}`); + if (remarkChanged.length) parts.push(`remark on ${refs(remarkChanged)}`); + if (parts.length) perSlug.push(`${slug}: ${parts.join(", ")}`); + } + return perSlug.length ? perSlug.join("; ") : null; } // --- Source file helpers --- @@ -151,22 +201,30 @@ export default async (request: Request, context: Context) => { if (request.method === "POST") { const body = await request.json(); const data = await readData(); + let message = "admin: update admin data"; if (body.action === "setStatus") { data.statusOverrides[body.slug] = body.status; + message = `admin: override ${body.slug} status to ${body.status}`; } else if (body.action === "clearStatus") { delete data.statusOverrides[body.slug]; + message = `admin: clear status override on ${body.slug}`; } else if (body.action === "commitStatus") { const success = await updateInteractivesSource(body.slug, body.status); if (success) { delete data.statusOverrides[body.slug]; - await writeData(data); + await writeData(data, `admin: clear override after committing ${body.slug} status`); return json({ ok: true, committed: true }); } return json({ ok: false, error: "Failed to update source file" }, 500); } else if (body.action === "setNote") { - if (body.note?.trim()) { data.notes[body.slug] = body.note; } - else { delete data.notes[body.slug]; } + if (body.note?.trim()) { + data.notes[body.slug] = body.note; + message = `admin: update note on ${body.slug}`; + } else { + delete data.notes[body.slug]; + message = `admin: clear note on ${body.slug}`; + } } else if (body.action === "addIdea") { const idea = body.idea; data.ideas.push({ @@ -174,29 +232,37 @@ export default async (request: Request, context: Context) => { id: idea.id || crypto.randomUUID(), createdAt: idea.createdAt || new Date().toISOString().split("T")[0], }); + message = `admin: add idea "${idea.title || idea.id?.slice(0, 8) || ""}"`.trim(); } else if (body.action === "removeIdea") { + const removed = data.ideas.find((i: any) => i.id === body.id); data.ideas = data.ideas.filter((i: any) => i.id !== body.id); + message = `admin: remove idea${removed?.title ? ` "${removed.title}"` : ""}`; } else if (body.action === "updateIdea") { + const existing = data.ideas.find((i: any) => i.id === body.id); data.ideas = data.ideas.map((i: any) => i.id === body.id ? { ...i, ...body.updates } : i ); + message = `admin: update idea${existing?.title ? ` "${existing.title}"` : ""}`; } else if (body.action === "deleteInteractive") { const success = await deleteInteractiveFromSource(body.slug); delete data.statusOverrides[body.slug]; delete data.notes[body.slug]; delete data.todos?.[body.slug]; - await writeData(data); + await writeData(data, `admin: clean up admin data for deleted ${body.slug}`); return json({ ok: success, deleted: success }); } else if (body.action === "setTodos") { + const desc = describeTodoChanges(data.todos, body.todos); data.todos = body.todos; + if (desc) message = `admin: ${desc}`; } else if (body.action === "saveAll") { if (body.statusOverrides) data.statusOverrides = body.statusOverrides; if (body.notes) data.notes = body.notes; if (body.ideas) data.ideas = body.ideas; if (body.todos) data.todos = body.todos; + message = "admin: bulk sync (saveAll)"; } - await writeData(data); + await writeData(data, message); return json({ ok: true }); } diff --git a/src/lib/admin.ts b/src/lib/admin.ts index e89a300..2cc9b34 100644 --- a/src/lib/admin.ts +++ b/src/lib/admin.ts @@ -1,4 +1,5 @@ import type { InteractiveStatus } from "./interactives"; +import staticAdminData from "@/data/admin-data.json"; const ADMIN_KEY = "interactives_admin"; const NOTES_KEY = "interactives_notes"; @@ -264,23 +265,30 @@ export function getAllTodoCounts(): Record { try { const data = await apiCall("GET"); - if (data.error) return; // auth failed - if (data.statusOverrides !== undefined) { - localStorage.setItem(STATUS_KEY, JSON.stringify(data.statusOverrides)); - } - if (data.notes !== undefined) { - localStorage.setItem(NOTES_KEY, JSON.stringify(data.notes)); - } - if (data.ideas !== undefined) { - localStorage.setItem(IDEAS_KEY, JSON.stringify(data.ideas)); - } - if (data.todos !== undefined) { - localStorage.setItem(TODOS_KEY, JSON.stringify(data.todos)); - } + if (data.error) throw new Error("auth failed"); + applyServerData(data); } catch { - // Offline or server unavailable — use localStorage as fallback + // API unreachable (e.g. `astro dev` without netlify functions) — fall back + // to the version of admin-data.json bundled at build time so the admin UI + // still reflects the committed state locally. + applyServerData(staticAdminData); } }