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:
parent
c5926b4213
commit
9b182053f1
5 changed files with 6546 additions and 151 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -28,3 +28,6 @@ Thumbs.db
|
||||||
bun.lockb
|
bun.lockb
|
||||||
yarn.lock
|
yarn.lock
|
||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
|
|
||||||
|
# Local Netlify folder
|
||||||
|
.netlify
|
||||||
|
|
|
||||||
|
|
@ -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
6460
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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" },
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue