next-site: site-wide settings + full-field admin editing, fix Worker-vs-static-asset routing for /index.json
This commit is contained in:
parent
3bab720f8f
commit
3b587f298c
6 changed files with 179 additions and 45 deletions
|
|
@ -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
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -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.-]+)$/,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 })
|
||||||
|
|
|
||||||
|
|
@ -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, '"')}"\`
|
||||||
|
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>`
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue