From 3b587f298c8ecb615b7b5139fe316c7deafcf39b Mon Sep 17 00:00:00 2001 From: Ally Founder Date: Sat, 15 Aug 2026 12:40:10 -0400 Subject: [PATCH] next-site: site-wide settings + full-field admin editing, fix Worker-vs-static-asset routing for /index.json --- worker/schema.sql | 10 ++++ worker/src/admin/routes.ts | 15 +++++- worker/src/db.ts | 37 ++++++++++++++ worker/src/index.ts | 56 ++++++++++----------- worker/src/pages/admin.ts | 100 +++++++++++++++++++++++++++++++------ wrangler.toml | 6 +++ 6 files changed, 179 insertions(+), 45 deletions(-) diff --git a/worker/schema.sql b/worker/schema.sql index f2c3d3b..f94970a 100644 --- a/worker/schema.sql +++ b/worker/schema.sql @@ -27,3 +27,13 @@ CREATE TABLE plugin_channels ( updated_at TEXT NOT NULL, PRIMARY KEY (plugin_id, channel) ); + +-- Single-row table (id always 'site') for site-wide admin-editable metadata: name/description +-- shown in the browse page hero and used as the default OG tags, overriding repo.config.json's +-- static values without needing a redeploy. +CREATE TABLE IF NOT EXISTS site_settings ( + id TEXT PRIMARY KEY, + name TEXT, + description TEXT, + updated_at TEXT NOT NULL +); diff --git a/worker/src/admin/routes.ts b/worker/src/admin/routes.ts index f013dfc..570d3d2 100644 --- a/worker/src/admin/routes.ts +++ b/worker/src/admin/routes.ts @@ -1,4 +1,4 @@ -import { setChannel, upsertOverride } from '../db' +import { setChannel, setSiteSettings, upsertOverride } from '../db' import { checkAuth } from './auth' import type { Env } from '../env' import type { BaseIndex } from '../publicIndex' @@ -24,6 +24,19 @@ export async function handleAdminApi( if (!checkAuth(request, env)) return json({ error: 'unauthorized' }, 401) + if (url.pathname === '/api/admin/site' && request.method === 'PUT') { + const body = await request + .json<{ name?: string; description?: string }>() + .catch(() => null) + if (!body) return json({ error: 'invalid body' }, 400) + + await setSiteSettings(env, { + name: body.name, + description: body.description, + }) + return json({ ok: true }) + } + const overrideMatch = url.pathname.match( /^\/api\/admin\/overrides\/([\w.-]+)$/, ) diff --git a/worker/src/db.ts b/worker/src/db.ts index 138b61a..b221ebf 100644 --- a/worker/src/db.ts +++ b/worker/src/db.ts @@ -160,6 +160,43 @@ export async function upsertOverride( .run() } +export interface SiteSettings { + name?: string + description?: string +} + +export async function getSiteSettings(env: Env): Promise { + const row = await env.DB.prepare( + 'SELECT name, description FROM site_settings WHERE id = ?1', + ) + .bind('site') + .first<{ name: string | null; description: string | null }>() + if (!row) return null + return { + name: row.name ?? undefined, + description: row.description ?? undefined, + } +} + +export async function setSiteSettings( + env: Env, + patch: Partial, +): Promise { + const existing = await getSiteSettings(env) + const merged: SiteSettings = { ...existing, ...patch } + + await env.DB.prepare( + `INSERT INTO site_settings (id, name, description, updated_at) VALUES ('site', ?1, ?2, ?3) + ON CONFLICT(id) DO UPDATE SET name = excluded.name, description = excluded.description, updated_at = excluded.updated_at`, + ) + .bind( + merged.name ?? null, + merged.description ?? null, + new Date().toISOString(), + ) + .run() +} + export async function setChannel( env: Env, id: string, diff --git a/worker/src/index.ts b/worker/src/index.ts index 35b1b03..d5d8b0e 100644 --- a/worker/src/index.ts +++ b/worker/src/index.ts @@ -1,44 +1,50 @@ import { handleAdminApi } from './admin/routes' -import { getAllChannels, getAllOverrides } from './db' +import { getAllChannels, getAllOverrides, getSiteSettings } from './db' import { renderAdminPage } from './pages/admin' import { renderBrowsePage } from './pages/browse' import { renderDetailPage } from './pages/detail' import { buildPublicIndex } from './publicIndex' +import type { Override } from './db' import type { Env } from './env' import type { BaseIndex } from './publicIndex' +async function loadMergedIndex( + request: Request, + env: Env, +): Promise<{ + merged: BaseIndex + overrides: Record + baseRes: Response +}> { + const baseRes = await env.ASSETS.fetch(new URL('/index.json', request.url)) + const base = await baseRes.json() + const [overrides, channels, siteSettings] = await Promise.all([ + getAllOverrides(env), + getAllChannels(env), + getSiteSettings(env), + ]) + const merged = buildPublicIndex(base, overrides, channels) + if (siteSettings?.name) merged.name = siteSettings.name + if (siteSettings?.description) merged.description = siteSettings.description + + return { merged, overrides, baseRes } +} + export default { async fetch(request: Request, env: Env): Promise { const url = new URL(request.url) if (url.pathname === '/index.json') { - const baseRes = await env.ASSETS.fetch( - new URL('/index.json', request.url), - ) + const { baseRes, merged } = await loadMergedIndex(request, env) if (!baseRes.ok) return baseRes - const base = await baseRes.json() - const [overrides, channels] = await Promise.all([ - getAllOverrides(env), - getAllChannels(env), - ]) - const merged = buildPublicIndex(base, overrides, channels) - return new Response(JSON.stringify(merged), { headers: { 'content-type': 'application/json' }, }) } if (url.pathname === '/') { - const baseRes = await env.ASSETS.fetch( - new URL('/index.json', request.url), - ) - const base = await baseRes.json() - const [overrides, channels] = await Promise.all([ - getAllOverrides(env), - getAllChannels(env), - ]) - const merged = buildPublicIndex(base, overrides, channels) + const { merged, overrides } = await loadMergedIndex(request, env) return new Response(renderBrowsePage(merged, overrides), { headers: { 'content-type': 'text/html; charset=utf-8' }, @@ -48,15 +54,7 @@ export default { const detailMatch = url.pathname.match(/^\/plugins\/([\w.-]+)$/) if (detailMatch) { const id = detailMatch[1] - const baseRes = await env.ASSETS.fetch( - new URL('/index.json', request.url), - ) - const base = await baseRes.json() - const [overrides, channels] = await Promise.all([ - getAllOverrides(env), - getAllChannels(env), - ]) - const merged = buildPublicIndex(base, overrides, channels) + const { merged, overrides } = await loadMergedIndex(request, env) const plugin = merged.plugins[id] if (!plugin) return new Response('Not found', { status: 404 }) diff --git a/worker/src/pages/admin.ts b/worker/src/pages/admin.ts index 33c0fc4..6fcb796 100644 --- a/worker/src/pages/admin.ts +++ b/worker/src/pages/admin.ts @@ -1,17 +1,25 @@ import { htmlShell, renderHeader } from '../theme' +const INPUT_STYLE = + 'background:#15161c;border:1px solid rgba(255,255,255,0.12);color:#e8e9ed;padding:7px 9px;border-radius:6px;width:100%;font-family:inherit;font-size:12.5px;' + /** * Single-page admin UI: a token field gates everything else client-side (the real gate is * server-side, in handleAdminApi - this is just UX, not security). Once a token is entered, it * fetches /index.json for the plugin list and lets the admin PUT overrides/channels directly. + * + * Exposes every field the backend already supports (db.ts's Override type) - not just tagline + * and hidden. `features`/`commands` are array-shaped, edited as raw JSON textareas rather than + * a bespoke list editor for each - functional and complete over polished, matching "edit + * anything" as the actual requirement here. */ export function renderAdminPage(): string { const body = ` ${renderHeader({ brandName: 'Admin' })} -
+
Admin token
- +
@@ -21,40 +29,100 @@ export function renderAdminPage(): string { const panel = document.getElementById('panel') let token = '' + const TEXT_FIELDS = [ + ['name', 'Name'], ['description', 'Description'], ['tagline', 'Tagline'], + ['note', 'Note'], ['category', 'Category'], ['accent', 'Accent (hex)'], + ['status', 'Status'], ['howItWorks', 'How it works'], ['limitations', 'Limitations'], + ] + + function fieldRow(id, key, label, value, multiline) { + const tag = multiline ? 'textarea rows="2"' : 'input' + const closeTag = multiline ? 'textarea' : 'input' + const valueAttr = multiline ? '' : \` value="\${(value ?? '').replace(/"/g, '"')}"\` + const inner = multiline ? (value ?? '') : '' + return \` +
+
\${label}
+ <\${tag} data-id="\${id}" data-field="\${key}" style="\${'${INPUT_STYLE}'}"\${valueAttr}>\${inner} +
\` + } + + async function loadSiteSettings() { + const res = await fetch('/index.json') + const data = await res.json() + document.getElementById('site').innerHTML = \` +
+
Site settings
+ \${fieldRow('site', 'name', 'Site name', data.name)} + \${fieldRow('site', 'description', 'Site description', data.description, true)} + +
\` + + document.getElementById('save-site').addEventListener('click', async () => { + const name = document.querySelector('[data-id="site"][data-field="name"]').value + const description = document.querySelector('[data-id="site"][data-field="description"]').value + await fetch('/api/admin/site', { + method: 'PUT', + headers: { 'content-type': 'application/json', authorization: \`Bearer \${token}\` }, + body: JSON.stringify({ name, description }), + }) + alert('Saved site settings') + }) + } + async function loadPlugins() { const res = await fetch('/index.json') const data = await res.json() - panel.innerHTML = Object.entries(data.plugins).map(([id, p]) => \` -
+ const pluginsEl = document.getElementById('plugins') + pluginsEl.innerHTML = Object.entries(data.plugins).map(([id, p]) => \` +
\${p.name} (\${id})
-
- - +
+ \${TEXT_FIELDS.map(([key, label]) => fieldRow(id, key, label, p[key])).join('')} + \${fieldRow(id, 'features', 'Features (JSON array of strings)', p.features ? JSON.stringify(p.features) : '', true)} + \${fieldRow(id, 'commands', 'Commands (JSON array of {cmd, desc})', p.commands ? JSON.stringify(p.commands) : '', true)} +
-
- +
+
\`).join('') - panel.querySelectorAll('.save').forEach(btn => btn.addEventListener('click', async () => { + pluginsEl.querySelectorAll('.save').forEach(btn => btn.addEventListener('click', async () => { const id = btn.dataset.id - const tagline = panel.querySelector(\`[data-id="\${id}"][data-field="tagline"]\`).value - const hidden = panel.querySelector(\`[data-id="\${id}"][data-field="hidden"]\`).checked + const patch = {} + for (const [key] of TEXT_FIELDS) { + patch[key] = pluginsEl.querySelector(\`[data-id="\${id}"][data-field="\${key}"]\`).value + } + patch.hidden = pluginsEl.querySelector(\`[data-id="\${id}"][data-field="hidden"]\`).checked + + const featuresRaw = pluginsEl.querySelector(\`[data-id="\${id}"][data-field="features"]\`).value + const commandsRaw = pluginsEl.querySelector(\`[data-id="\${id}"][data-field="commands"]\`).value + try { + if (featuresRaw) patch.features = JSON.parse(featuresRaw) + if (commandsRaw) patch.commands = JSON.parse(commandsRaw) + } catch { + alert('Features/commands must be valid JSON') + return + } + await fetch(\`/api/admin/overrides/\${id}\`, { method: 'PUT', headers: { 'content-type': 'application/json', authorization: \`Bearer \${token}\` }, - body: JSON.stringify({ tagline, hidden }), + body: JSON.stringify(patch), }) alert('Saved ' + id) })) - panel.querySelectorAll('.promote').forEach(btn => btn.addEventListener('click', async () => { + pluginsEl.querySelectorAll('.promote').forEach(btn => btn.addEventListener('click', async () => { const id = btn.dataset.id const channel = btn.dataset.channel - const version = panel.querySelector(\`[data-id="\${id}"][data-channel="\${channel}"]\`).value + const version = pluginsEl.querySelector(\`[data-id="\${id}"][data-channel="\${channel}"]\`).value const res = await fetch(\`/api/admin/channels/\${id}/\${channel}\`, { method: 'PUT', headers: { 'content-type': 'application/json', authorization: \`Bearer \${token}\` }, @@ -69,6 +137,8 @@ export function renderAdminPage(): string { token = document.getElementById('token').value gate.style.display = 'none' panel.style.display = 'block' + panel.innerHTML = '
' + loadSiteSettings() loadPlugins() }) ` diff --git a/wrangler.toml b/wrangler.toml index 40db8bf..de7e10e 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -6,6 +6,12 @@ main = "worker/src/index.ts" [assets] directory = "build/dist" binding = "ASSETS" +# Without this, Cloudflare serves a matching static file (e.g. /index.json, which is a real file +# Gradle writes to build/dist) BEFORE ever invoking the Worker - meaning /index.json's dynamic D1 +# merge (hidden filtering, stable-channel-as-latest, site-settings overrides) silently never ran +# for that exact path. Worker always runs first now; its own fallback (env.ASSETS.fetch) still +# serves genuine static files (the plugin .zip artifacts) for anything it doesn't handle itself. +run_worker_first = true [[d1_databases]] binding = "DB"