Switch to Netlify adapter with GitHub API persistence

- Swap @astrojs/node for @astrojs/netlify adapter
- Admin API reads/writes admin-data.json via GitHub API in production
- Status changes (commitStatus) and deletes commit directly to repo
- Env vars: ADMIN_PASSWORD, GITHUB_TOKEN
- Local dev falls back to filesystem (no env vars needed)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Neeldhara Misra 2026-04-17 03:22:18 +05:30
parent c5926b4213
commit 9b182053f1
5 changed files with 6546 additions and 151 deletions

3
.gitignore vendored
View file

@ -28,3 +28,6 @@ Thumbs.db
bun.lockb bun.lockb
yarn.lock yarn.lock
pnpm-lock.yaml pnpm-lock.yaml
# Local Netlify folder
.netlify

View file

@ -5,14 +5,14 @@ import sitemap from "@astrojs/sitemap";
import react from "@astrojs/react"; import react from "@astrojs/react";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
import node from "@astrojs/node"; import netlify from "@astrojs/netlify";
// https://astro.build/config // https://astro.build/config
export default defineConfig({ export default defineConfig({
site: "https://interactives.neeldhara.com", site: "https://interactives.neeldhara.com",
integrations: [mdx(), sitemap(), react()], integrations: [mdx(), sitemap(), react()],
output: "static", output: "static",
adapter: node({ mode: "standalone" }), adapter: netlify(),
fonts: [ fonts: [
{ {

6460
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -17,7 +17,7 @@
"node": ">=22.12.0" "node": ">=22.12.0"
}, },
"dependencies": { "dependencies": {
"@astrojs/node": "^10.0.4", "@astrojs/netlify": "^7.0.7",
"@astrojs/react": "^5.0.2", "@astrojs/react": "^5.0.2",
"@astrojs/rss": "^4.0.18", "@astrojs/rss": "^4.0.18",
"@fontsource-variable/geist": "^5.2.8", "@fontsource-variable/geist": "^5.2.8",
@ -30,7 +30,7 @@
"@types/katex": "^0.16.8", "@types/katex": "^0.16.8",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"@types/three": "^0.183.1", "@types/three": "^0.183.1",
"astro": "^6.1.2", "astro": "^6.1.7",
"canvas-confetti": "^1.9.4", "canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",

View file

@ -5,54 +5,94 @@ import { ADMIN_PASSWORD } from "@/lib/admin";
export const prerender = false; export const prerender = false;
// Use env var with fallback to hardcoded value
const SERVER_ADMIN_PASSWORD = import.meta.env.ADMIN_PASSWORD || ADMIN_PASSWORD;
// GitHub API for committing changes (used in production on Netlify)
const GITHUB_TOKEN = import.meta.env.GITHUB_TOKEN || "";
const GITHUB_REPO = "nm-static/interactives";
const GITHUB_BRANCH = "main";
const DATA_PATH = resolve("src/data/admin-data.json"); const DATA_PATH = resolve("src/data/admin-data.json");
const INTERACTIVES_PATH = resolve("src/lib/interactives.ts"); const INTERACTIVES_PATH = resolve("src/lib/interactives.ts");
function readData() { const DEFAULT_DATA = { statusOverrides: {}, notes: {}, ideas: [], todos: {} };
async function readData() {
if (isNetlify()) {
const file = await githubGetFile("src/data/admin-data.json");
if (file) return { ...DEFAULT_DATA, ...JSON.parse(file.content), _sha: file.sha };
return { ...DEFAULT_DATA };
}
try { try {
return JSON.parse(readFileSync(DATA_PATH, "utf-8")); return JSON.parse(readFileSync(DATA_PATH, "utf-8"));
} catch { } catch {
return { statusOverrides: {}, notes: {}, ideas: [], todos: {} }; return { ...DEFAULT_DATA };
} }
} }
function writeData(data: any) { async function writeData(data: any) {
writeFileSync(DATA_PATH, JSON.stringify(data, null, 2) + "\n"); const { _sha, ...cleanData } = data;
const content = JSON.stringify(cleanData, null, 2) + "\n";
if (isNetlify()) {
await githubPutFile(
"src/data/admin-data.json",
content,
"admin: update admin data",
_sha
);
} else {
writeFileSync(DATA_PATH, content);
}
} }
function updateInteractivesSource(slug: string, newStatus: string): boolean { function applyStatusChange(source: string, slug: string, newStatus: string): string | null {
const slugPattern = `slug: "${slug}"`;
const slugIndex = source.indexOf(slugPattern);
if (slugIndex === -1) return null;
const closingIndex = source.indexOf("\n },", slugIndex);
if (closingIndex === -1) return null;
const block = source.substring(slugIndex, closingIndex);
const statusMatch = block.match(/status: "(\w+)" as const,/);
if (!statusMatch) return null;
const oldStatusStr = `status: "${statusMatch[1]}" as const,`;
const newStatusStr = `status: "${newStatus}" as const,`;
const statusOffset = block.indexOf(oldStatusStr);
if (statusOffset === -1) return null;
const absOffset = slugIndex + statusOffset;
return source.substring(0, absOffset) + newStatusStr + source.substring(absOffset + oldStatusStr.length);
}
function applyDelete(source: string, slug: string): string | null {
const slugPattern = `slug: "${slug}"`;
const slugIndex = source.indexOf(slugPattern);
if (slugIndex === -1) return null;
let braceStart = source.lastIndexOf(" {", slugIndex);
if (braceStart === -1) return null;
const closingPattern = "\n },";
let braceEnd = source.indexOf(closingPattern, slugIndex);
if (braceEnd === -1) return null;
braceEnd += closingPattern.length;
const beforeBrace = source.substring(0, braceStart);
const lastNewline = beforeBrace.lastIndexOf("\n");
const lineBefore = beforeBrace.substring(lastNewline + 1).trim();
const deleteFrom = lineBefore.startsWith("//") ? lastNewline + 1 : braceStart;
return source.substring(0, deleteFrom) + source.substring(braceEnd).replace(/\n{3,}/g, "\n\n");
}
async function updateInteractivesSource(slug: string, newStatus: string): Promise<boolean> {
try { try {
let source = readFileSync(INTERACTIVES_PATH, "utf-8"); if (isNetlify()) {
const file = await githubGetFile("src/lib/interactives.ts");
// Find the entry for this slug and update its status. if (!file) return false;
// Pattern: slug: "the-slug", ... status: "old" as const, const updated = applyStatusChange(file.content, slug, newStatus);
// We search for the slug, then find the next `status:` line within that block. if (!updated) return false;
const slugPattern = `slug: "${slug}"`; return await githubPutFile("src/lib/interactives.ts", updated, `admin: set ${slug} to ${newStatus}`, file.sha);
const slugIndex = source.indexOf(slugPattern); }
if (slugIndex === -1) return false; const source = readFileSync(INTERACTIVES_PATH, "utf-8");
const updated = applyStatusChange(source, slug, newStatus);
// Find the closing `},` of this entry (next occurrence after the slug) if (!updated) return false;
const closingIndex = source.indexOf("\n },", slugIndex); writeFileSync(INTERACTIVES_PATH, updated);
if (closingIndex === -1) return false;
// Find the status line within this block
const block = source.substring(slugIndex, closingIndex);
const statusMatch = block.match(/status: "(\w+)" as const,/);
if (!statusMatch) return false;
const oldStatusStr = `status: "${statusMatch[1]}" as const,`;
const newStatusStr = `status: "${newStatus}" as const,`;
const blockStart = slugIndex;
const statusOffset = block.indexOf(oldStatusStr);
if (statusOffset === -1) return false;
const absOffset = blockStart + statusOffset;
source =
source.substring(0, absOffset) +
newStatusStr +
source.substring(absOffset + oldStatusStr.length);
writeFileSync(INTERACTIVES_PATH, source);
return true; return true;
} catch (e) { } catch (e) {
console.error("Failed to update interactives.ts:", e); console.error("Failed to update interactives.ts:", e);
@ -60,33 +100,19 @@ function updateInteractivesSource(slug: string, newStatus: string): boolean {
} }
} }
function deleteInteractiveFromSource(slug: string): boolean { async function deleteInteractiveFromSource(slug: string): Promise<boolean> {
try { try {
let source = readFileSync(INTERACTIVES_PATH, "utf-8"); if (isNetlify()) {
const slugPattern = `slug: "${slug}"`; const file = await githubGetFile("src/lib/interactives.ts");
const slugIndex = source.indexOf(slugPattern); if (!file) return false;
if (slugIndex === -1) return false; const updated = applyDelete(file.content, slug);
if (!updated) return false;
// Find the opening `{` before the slug return await githubPutFile("src/lib/interactives.ts", updated, `admin: delete ${slug}`, file.sha);
let braceStart = source.lastIndexOf(" {", slugIndex); }
if (braceStart === -1) return false; const source = readFileSync(INTERACTIVES_PATH, "utf-8");
const updated = applyDelete(source, slug);
// Find the closing `},` after the slug if (!updated) return false;
const closingPattern = "\n },"; writeFileSync(INTERACTIVES_PATH, updated);
let braceEnd = source.indexOf(closingPattern, slugIndex);
if (braceEnd === -1) return false;
braceEnd += closingPattern.length;
// Also remove any comment line directly before the entry
const beforeBrace = source.substring(0, braceStart);
const lastNewline = beforeBrace.lastIndexOf("\n");
const lineBefore = beforeBrace.substring(lastNewline + 1).trim();
const deleteFrom = lineBefore.startsWith("//") ? lastNewline + 1 : braceStart;
source = source.substring(0, deleteFrom) + source.substring(braceEnd);
// Clean up double blank lines
source = source.replace(/\n{3,}/g, "\n\n");
writeFileSync(INTERACTIVES_PATH, source);
return true; return true;
} catch (e) { } catch (e) {
console.error("Failed to delete from interactives.ts:", e); console.error("Failed to delete from interactives.ts:", e);
@ -96,43 +122,80 @@ function deleteInteractiveFromSource(slug: string): boolean {
function checkAuth(request: Request): boolean { function checkAuth(request: Request): boolean {
const auth = request.headers.get("x-admin-password"); const auth = request.headers.get("x-admin-password");
return auth === ADMIN_PASSWORD; return auth === SERVER_ADMIN_PASSWORD;
}
// --- GitHub API helpers for production persistence ---
async function githubGetFile(path: string): Promise<{ content: string; sha: string } | null> {
if (!GITHUB_TOKEN) return null;
try {
const res = await fetch(
`https://api.github.com/repos/${GITHUB_REPO}/contents/${path}?ref=${GITHUB_BRANCH}`,
{ headers: { Authorization: `Bearer ${GITHUB_TOKEN}`, Accept: "application/vnd.github.v3+json" } }
);
if (!res.ok) return null;
const data = await res.json();
return { content: Buffer.from(data.content, "base64").toString("utf-8"), sha: data.sha };
} catch { return null; }
}
async function githubPutFile(path: string, content: string, message: string, sha?: string): Promise<boolean> {
if (!GITHUB_TOKEN) return false;
try {
const body: any = {
message,
content: Buffer.from(content).toString("base64"),
branch: GITHUB_BRANCH,
};
if (sha) body.sha = sha;
const res = await fetch(
`https://api.github.com/repos/${GITHUB_REPO}/contents/${path}`,
{
method: "PUT",
headers: { Authorization: `Bearer ${GITHUB_TOKEN}`, Accept: "application/vnd.github.v3+json", "Content-Type": "application/json" },
body: JSON.stringify(body),
}
);
return res.ok;
} catch { return false; }
}
// Check if we're running on Netlify (serverless) vs local dev
function isNetlify(): boolean {
return !!GITHUB_TOKEN;
} }
export const GET: APIRoute = async ({ request }) => { export const GET: APIRoute = async ({ request }) => {
if (!checkAuth(request)) { if (!checkAuth(request)) {
return new Response(JSON.stringify({ error: "Unauthorized" }), { return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 });
status: 401,
});
} }
return new Response(JSON.stringify(readData()), { const data = await readData();
const { _sha, ...cleanData } = data;
return new Response(JSON.stringify(cleanData), {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
}); });
}; };
export const POST: APIRoute = async ({ request }) => { export const POST: APIRoute = async ({ request }) => {
if (!checkAuth(request)) { if (!checkAuth(request)) {
return new Response(JSON.stringify({ error: "Unauthorized" }), { return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 });
status: 401,
});
} }
const body = await request.json(); const body = await request.json();
const data = readData(); const data = await readData();
if (body.action === "setStatus") { if (body.action === "setStatus") {
data.statusOverrides[body.slug] = body.status; data.statusOverrides[body.slug] = body.status;
} else if (body.action === "clearStatus") { } else if (body.action === "clearStatus") {
delete data.statusOverrides[body.slug]; delete data.statusOverrides[body.slug];
} else if (body.action === "commitStatus") { } else if (body.action === "commitStatus") {
// Write status change directly into interactives.ts const success = await updateInteractivesSource(body.slug, body.status);
const success = updateInteractivesSource(body.slug, body.status);
if (success) { if (success) {
// Clear the override since it's now in the source
delete data.statusOverrides[body.slug]; delete data.statusOverrides[body.slug];
writeData(data); await writeData(data);
return new Response( return new Response(
JSON.stringify({ ok: true, committed: true, data }), JSON.stringify({ ok: true, committed: true }),
{ headers: { "Content-Type": "application/json" } } { headers: { "Content-Type": "application/json" } }
); );
} else { } else {
@ -161,14 +224,13 @@ export const POST: APIRoute = async ({ request }) => {
i.id === body.id ? { ...i, ...body.updates } : i i.id === body.id ? { ...i, ...body.updates } : i
); );
} else if (body.action === "deleteInteractive") { } else if (body.action === "deleteInteractive") {
const success = deleteInteractiveFromSource(body.slug); const success = await deleteInteractiveFromSource(body.slug);
// Also clean up admin data for this 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];
writeData(data); await writeData(data);
return new Response( return new Response(
JSON.stringify({ ok: success, deleted: success, data }), JSON.stringify({ ok: success, deleted: success }),
{ headers: { "Content-Type": "application/json" } } { headers: { "Content-Type": "application/json" } }
); );
} else if (body.action === "setTodos") { } else if (body.action === "setTodos") {
@ -180,8 +242,8 @@ export const POST: APIRoute = async ({ request }) => {
if (body.todos) data.todos = body.todos; if (body.todos) data.todos = body.todos;
} }
writeData(data); await writeData(data);
return new Response(JSON.stringify({ ok: true, data }), { return new Response(JSON.stringify({ ok: true }), {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
}); });
}; };