admin: descriptive commit messages + dev-mode sync fallback

Server side: writeData() now takes a message, and each action in the
POST handler computes its own — e.g. "admin: add idea "Title"", "admin:
clear note on slug". For setTodos (which ships the whole blob), diff
old vs new per slug to produce messages like "neighbor-sum-avoidance:
add T6" or "mark T1 done, add T5".

Client side: syncFromServer() falls back to the bundled admin-data.json
when /api/admin is unreachable, so the admin panel reflects committed
state under `astro dev` (which doesn't run Netlify Functions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Neeldhara Misra 2026-04-19 18:26:27 +05:30
parent 0d0365cf6d
commit e7dd3ed8eb
2 changed files with 95 additions and 21 deletions

View file

@ -52,10 +52,60 @@ async function readData() {
return { ...DEFAULT_DATA }; return { ...DEFAULT_DATA };
} }
async function writeData(data: any) { async function writeData(data: any, message = "admin: update admin data") {
const { _sha, ...cleanData } = data; const { _sha, ...cleanData } = data;
const content = JSON.stringify(cleanData, null, 2) + "\n"; 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<string, TodoLike[]> = {},
newTodos: Record<string, TodoLike[]> = {}
): 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 --- // --- Source file helpers ---
@ -151,22 +201,30 @@ export default async (request: Request, context: Context) => {
if (request.method === "POST") { if (request.method === "POST") {
const body = await request.json(); const body = await request.json();
const data = await readData(); const data = await readData();
let message = "admin: update admin data";
if (body.action === "setStatus") { if (body.action === "setStatus") {
data.statusOverrides[body.slug] = body.status; data.statusOverrides[body.slug] = body.status;
message = `admin: override ${body.slug} status to ${body.status}`;
} else if (body.action === "clearStatus") { } else if (body.action === "clearStatus") {
delete data.statusOverrides[body.slug]; delete data.statusOverrides[body.slug];
message = `admin: clear status override on ${body.slug}`;
} else if (body.action === "commitStatus") { } else if (body.action === "commitStatus") {
const success = await updateInteractivesSource(body.slug, body.status); const success = await updateInteractivesSource(body.slug, body.status);
if (success) { if (success) {
delete data.statusOverrides[body.slug]; 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: true, committed: true });
} }
return json({ ok: false, error: "Failed to update source file" }, 500); return json({ ok: false, error: "Failed to update source file" }, 500);
} else if (body.action === "setNote") { } else if (body.action === "setNote") {
if (body.note?.trim()) { data.notes[body.slug] = body.note; } if (body.note?.trim()) {
else { delete data.notes[body.slug]; } 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") { } else if (body.action === "addIdea") {
const idea = body.idea; const idea = body.idea;
data.ideas.push({ data.ideas.push({
@ -174,29 +232,37 @@ export default async (request: Request, context: Context) => {
id: idea.id || crypto.randomUUID(), id: idea.id || crypto.randomUUID(),
createdAt: idea.createdAt || new Date().toISOString().split("T")[0], 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") { } 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); data.ideas = data.ideas.filter((i: any) => i.id !== body.id);
message = `admin: remove idea${removed?.title ? ` "${removed.title}"` : ""}`;
} else if (body.action === "updateIdea") { } else if (body.action === "updateIdea") {
const existing = data.ideas.find((i: any) => i.id === body.id);
data.ideas = data.ideas.map((i: any) => data.ideas = data.ideas.map((i: any) =>
i.id === body.id ? { ...i, ...body.updates } : i i.id === body.id ? { ...i, ...body.updates } : i
); );
message = `admin: update idea${existing?.title ? ` "${existing.title}"` : ""}`;
} else if (body.action === "deleteInteractive") { } else if (body.action === "deleteInteractive") {
const success = await deleteInteractiveFromSource(body.slug); const success = await deleteInteractiveFromSource(body.slug);
delete data.statusOverrides[body.slug]; delete data.statusOverrides[body.slug];
delete data.notes[body.slug]; delete data.notes[body.slug];
delete data.todos?.[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 }); return json({ ok: success, deleted: success });
} else if (body.action === "setTodos") { } else if (body.action === "setTodos") {
const desc = describeTodoChanges(data.todos, body.todos);
data.todos = body.todos; data.todos = body.todos;
if (desc) message = `admin: ${desc}`;
} else if (body.action === "saveAll") { } else if (body.action === "saveAll") {
if (body.statusOverrides) data.statusOverrides = body.statusOverrides; if (body.statusOverrides) data.statusOverrides = body.statusOverrides;
if (body.notes) data.notes = body.notes; if (body.notes) data.notes = body.notes;
if (body.ideas) data.ideas = body.ideas; if (body.ideas) data.ideas = body.ideas;
if (body.todos) data.todos = body.todos; if (body.todos) data.todos = body.todos;
message = "admin: bulk sync (saveAll)";
} }
await writeData(data); await writeData(data, message);
return json({ ok: true }); return json({ ok: true });
} }

View file

@ -1,4 +1,5 @@
import type { InteractiveStatus } from "./interactives"; import type { InteractiveStatus } from "./interactives";
import staticAdminData from "@/data/admin-data.json";
const ADMIN_KEY = "interactives_admin"; const ADMIN_KEY = "interactives_admin";
const NOTES_KEY = "interactives_notes"; const NOTES_KEY = "interactives_notes";
@ -264,10 +265,7 @@ export function getAllTodoCounts(): Record<string, { total: number; done: number
// --- Sync: load server data into localStorage --- // --- Sync: load server data into localStorage ---
export async function syncFromServer(): Promise<void> { function applyServerData(data: any) {
try {
const data = await apiCall("GET");
if (data.error) return; // auth failed
if (data.statusOverrides !== undefined) { if (data.statusOverrides !== undefined) {
localStorage.setItem(STATUS_KEY, JSON.stringify(data.statusOverrides)); localStorage.setItem(STATUS_KEY, JSON.stringify(data.statusOverrides));
} }
@ -280,7 +278,17 @@ export async function syncFromServer(): Promise<void> {
if (data.todos !== undefined) { if (data.todos !== undefined) {
localStorage.setItem(TODOS_KEY, JSON.stringify(data.todos)); localStorage.setItem(TODOS_KEY, JSON.stringify(data.todos));
} }
}
export async function syncFromServer(): Promise<void> {
try {
const data = await apiCall("GET");
if (data.error) throw new Error("auth failed");
applyServerData(data);
} catch { } 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);
} }
} }