next-site: site-wide settings + full-field admin editing, fix Worker-vs-static-asset routing for /index.json

This commit is contained in:
Ally Founder 2026-08-15 12:40:10 -04:00
parent 3bab720f8f
commit 3b587f298c
6 changed files with 179 additions and 45 deletions

View file

@ -27,3 +27,13 @@ CREATE TABLE plugin_channels (
updated_at TEXT NOT NULL, updated_at TEXT NOT NULL,
PRIMARY KEY (plugin_id, channel) 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
);

View file

@ -1,4 +1,4 @@
import { setChannel, upsertOverride } from '../db' import { setChannel, setSiteSettings, upsertOverride } from '../db'
import { checkAuth } from './auth' import { checkAuth } from './auth'
import type { Env } from '../env' import type { Env } from '../env'
import type { BaseIndex } from '../publicIndex' import type { BaseIndex } from '../publicIndex'
@ -24,6 +24,19 @@ export async function handleAdminApi(
if (!checkAuth(request, env)) return json({ error: 'unauthorized' }, 401) 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( const overrideMatch = url.pathname.match(
/^\/api\/admin\/overrides\/([\w.-]+)$/, /^\/api\/admin\/overrides\/([\w.-]+)$/,
) )

View file

@ -160,6 +160,43 @@ export async function upsertOverride(
.run() .run()
} }
export interface SiteSettings {
name?: string
description?: string
}
export async function getSiteSettings(env: Env): Promise<SiteSettings | null> {
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<SiteSettings>,
): Promise<void> {
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( export async function setChannel(
env: Env, env: Env,
id: string, id: string,

View file

@ -1,44 +1,50 @@
import { handleAdminApi } from './admin/routes' import { handleAdminApi } from './admin/routes'
import { getAllChannels, getAllOverrides } from './db' import { getAllChannels, getAllOverrides, getSiteSettings } from './db'
import { renderAdminPage } from './pages/admin' import { renderAdminPage } from './pages/admin'
import { renderBrowsePage } from './pages/browse' import { renderBrowsePage } from './pages/browse'
import { renderDetailPage } from './pages/detail' import { renderDetailPage } from './pages/detail'
import { buildPublicIndex } from './publicIndex' import { buildPublicIndex } from './publicIndex'
import type { Override } from './db'
import type { Env } from './env' import type { Env } from './env'
import type { BaseIndex } from './publicIndex' import type { BaseIndex } from './publicIndex'
async function loadMergedIndex(
request: Request,
env: Env,
): Promise<{
merged: BaseIndex
overrides: Record<string, Override>
baseRes: Response
}> {
const baseRes = await env.ASSETS.fetch(new URL('/index.json', request.url))
const base = await baseRes.json<BaseIndex>()
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 { export default {
async fetch(request: Request, env: Env): Promise<Response> { async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url) const url = new URL(request.url)
if (url.pathname === '/index.json') { if (url.pathname === '/index.json') {
const baseRes = await env.ASSETS.fetch( const { baseRes, merged } = await loadMergedIndex(request, env)
new URL('/index.json', request.url),
)
if (!baseRes.ok) return baseRes if (!baseRes.ok) return baseRes
const base = await baseRes.json<BaseIndex>()
const [overrides, channels] = await Promise.all([
getAllOverrides(env),
getAllChannels(env),
])
const merged = buildPublicIndex(base, overrides, channels)
return new Response(JSON.stringify(merged), { return new Response(JSON.stringify(merged), {
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
}) })
} }
if (url.pathname === '/') { if (url.pathname === '/') {
const baseRes = await env.ASSETS.fetch( const { merged, overrides } = await loadMergedIndex(request, env)
new URL('/index.json', request.url),
)
const base = await baseRes.json<BaseIndex>()
const [overrides, channels] = await Promise.all([
getAllOverrides(env),
getAllChannels(env),
])
const merged = buildPublicIndex(base, overrides, channels)
return new Response(renderBrowsePage(merged, overrides), { return new Response(renderBrowsePage(merged, overrides), {
headers: { 'content-type': 'text/html; charset=utf-8' }, headers: { 'content-type': 'text/html; charset=utf-8' },
@ -48,15 +54,7 @@ export default {
const detailMatch = url.pathname.match(/^\/plugins\/([\w.-]+)$/) const detailMatch = url.pathname.match(/^\/plugins\/([\w.-]+)$/)
if (detailMatch) { if (detailMatch) {
const id = detailMatch[1] const id = detailMatch[1]
const baseRes = await env.ASSETS.fetch( const { merged, overrides } = await loadMergedIndex(request, env)
new URL('/index.json', request.url),
)
const base = await baseRes.json<BaseIndex>()
const [overrides, channels] = await Promise.all([
getAllOverrides(env),
getAllChannels(env),
])
const merged = buildPublicIndex(base, overrides, channels)
const plugin = merged.plugins[id] const plugin = merged.plugins[id]
if (!plugin) return new Response('Not found', { status: 404 }) if (!plugin) return new Response('Not found', { status: 404 })

View file

@ -1,17 +1,25 @@
import { htmlShell, renderHeader } from '../theme' 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 * 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 * 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. * 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 { export function renderAdminPage(): string {
const body = ` const body = `
${renderHeader({ brandName: 'Admin' })} ${renderHeader({ brandName: 'Admin' })}
<div class="wrap" style="padding-top:22px;"> <div class="wrap" style="padding-top:22px;padding-bottom:60px;">
<div id="gate"> <div id="gate">
<div class="page-sub">Admin token</div> <div class="page-sub">Admin token</div>
<input id="token" type="password" style="background:#15161c;border:1px solid rgba(255,255,255,0.12);color:#e8e9ed;padding:8px 10px;border-radius:6px;width:280px;"> <input id="token" type="password" style="${INPUT_STYLE}max-width:280px;">
<button id="unlock" class="btn" style="border:none;cursor:pointer;margin-left:8px;">Unlock</button> <button id="unlock" class="btn" style="border:none;cursor:pointer;margin-left:8px;">Unlock</button>
</div> </div>
<div id="panel" style="display:none;"></div> <div id="panel" style="display:none;"></div>
@ -21,40 +29,100 @@ export function renderAdminPage(): string {
const panel = document.getElementById('panel') const panel = document.getElementById('panel')
let token = '' 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, '&quot;')}"\`
const inner = multiline ? (value ?? '') : ''
return \`
<div style="margin-bottom:8px;">
<div style="font-size:10.5px;color:rgba(255,255,255,0.4);margin-bottom:3px;">\${label}</div>
<\${tag} data-id="\${id}" data-field="\${key}" style="\${'${INPUT_STYLE}'}"\${valueAttr}>\${inner}</\${closeTag}>
</div>\`
}
async function loadSiteSettings() {
const res = await fetch('/index.json')
const data = await res.json()
document.getElementById('site').innerHTML = \`
<div class="card" style="margin-bottom:20px;">
<div class="card-name">Site settings</div>
\${fieldRow('site', 'name', 'Site name', data.name)}
\${fieldRow('site', 'description', 'Site description', data.description, true)}
<button id="save-site" class="btn" style="border:none;cursor:pointer;">Save site settings</button>
</div>\`
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() { async function loadPlugins() {
const res = await fetch('/index.json') const res = await fetch('/index.json')
const data = await res.json() const data = await res.json()
panel.innerHTML = Object.entries(data.plugins).map(([id, p]) => \` const pluginsEl = document.getElementById('plugins')
<div class="card" style="margin-bottom:12px;"> pluginsEl.innerHTML = Object.entries(data.plugins).map(([id, p]) => \`
<div class="card" style="margin-bottom:16px;">
<div class="card-name">\${p.name} <span class="card-meta">(\${id})</span></div> <div class="card-name">\${p.name} <span class="card-meta">(\${id})</span></div>
<div style="margin-top:8px;display:flex;gap:8px;flex-wrap:wrap;"> <div style="margin-top:12px;">
<input placeholder="tagline" data-id="\${id}" data-field="tagline" style="background:#15161c;border:1px solid rgba(255,255,255,0.12);color:#e8e9ed;padding:6px 8px;border-radius:6px;flex:1;"> \${TEXT_FIELDS.map(([key, label]) => fieldRow(id, key, label, p[key])).join('')}
<label style="font-size:11px;display:flex;align-items:center;gap:4px;"><input type="checkbox" data-id="\${id}" data-field="hidden"> hidden</label> \${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)}
<label style="font-size:11px;display:flex;align-items:center;gap:4px;margin-bottom:10px;">
<input type="checkbox" data-id="\${id}" data-field="hidden"> hidden
</label>
<button data-id="\${id}" class="btn save" style="border:none;cursor:pointer;">Save</button> <button data-id="\${id}" class="btn save" style="border:none;cursor:pointer;">Save</button>
</div> </div>
<div style="margin-top:8px;display:flex;gap:8px;"> <div style="margin-top:14px;padding-top:14px;border-top:1px solid rgba(255,255,255,0.08);display:flex;gap:8px;">
<input placeholder="version" data-id="\${id}" data-channel="stable" style="background:#15161c;border:1px solid rgba(255,255,255,0.12);color:#e8e9ed;padding:6px 8px;border-radius:6px;width:100px;"> <input placeholder="version" data-id="\${id}" data-channel="stable" style="\${'${INPUT_STYLE}'}width:100px;flex:none;">
<button data-id="\${id}" data-channel="stable" class="btn promote" style="border:none;cursor:pointer;">Promote to stable</button> <button data-id="\${id}" data-channel="stable" class="btn promote" style="border:none;cursor:pointer;">Promote to stable</button>
</div> </div>
</div> </div>
\`).join('') \`).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 id = btn.dataset.id
const tagline = panel.querySelector(\`[data-id="\${id}"][data-field="tagline"]\`).value const patch = {}
const hidden = panel.querySelector(\`[data-id="\${id}"][data-field="hidden"]\`).checked 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}\`, { await fetch(\`/api/admin/overrides/\${id}\`, {
method: 'PUT', method: 'PUT',
headers: { 'content-type': 'application/json', authorization: \`Bearer \${token}\` }, headers: { 'content-type': 'application/json', authorization: \`Bearer \${token}\` },
body: JSON.stringify({ tagline, hidden }), body: JSON.stringify(patch),
}) })
alert('Saved ' + id) 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 id = btn.dataset.id
const channel = btn.dataset.channel 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}\`, { const res = await fetch(\`/api/admin/channels/\${id}/\${channel}\`, {
method: 'PUT', method: 'PUT',
headers: { 'content-type': 'application/json', authorization: \`Bearer \${token}\` }, headers: { 'content-type': 'application/json', authorization: \`Bearer \${token}\` },
@ -69,6 +137,8 @@ export function renderAdminPage(): string {
token = document.getElementById('token').value token = document.getElementById('token').value
gate.style.display = 'none' gate.style.display = 'none'
panel.style.display = 'block' panel.style.display = 'block'
panel.innerHTML = '<div id="site"></div><div id="plugins"></div>'
loadSiteSettings()
loadPlugins() loadPlugins()
}) })
</script>` </script>`

View file

@ -6,6 +6,12 @@ main = "worker/src/index.ts"
[assets] [assets]
directory = "build/dist" directory = "build/dist"
binding = "ASSETS" 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]] [[d1_databases]]
binding = "DB" binding = "DB"