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,
|
||||
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 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.-]+)$/,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -160,6 +160,43 @@ export async function upsertOverride(
|
|||
.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(
|
||||
env: Env,
|
||||
id: string,
|
||||
|
|
|
|||
|
|
@ -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<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 {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
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<BaseIndex>()
|
||||
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<BaseIndex>()
|
||||
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<BaseIndex>()
|
||||
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 })
|
||||
|
|
|
|||
|
|
@ -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' })}
|
||||
<div class="wrap" style="padding-top:22px;">
|
||||
<div class="wrap" style="padding-top:22px;padding-bottom:60px;">
|
||||
<div id="gate">
|
||||
<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>
|
||||
</div>
|
||||
<div id="panel" style="display:none;"></div>
|
||||
|
|
@ -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 \`
|
||||
<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() {
|
||||
const res = await fetch('/index.json')
|
||||
const data = await res.json()
|
||||
panel.innerHTML = Object.entries(data.plugins).map(([id, p]) => \`
|
||||
<div class="card" style="margin-bottom:12px;">
|
||||
const pluginsEl = document.getElementById('plugins')
|
||||
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 style="margin-top:8px;display:flex;gap:8px;flex-wrap:wrap;">
|
||||
<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;">
|
||||
<label style="font-size:11px;display:flex;align-items:center;gap:4px;"><input type="checkbox" data-id="\${id}" data-field="hidden"> hidden</label>
|
||||
<div style="margin-top:12px;">
|
||||
\${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)}
|
||||
<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>
|
||||
</div>
|
||||
<div style="margin-top:8px;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;">
|
||||
<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="\${'${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>
|
||||
</div>
|
||||
</div>
|
||||
\`).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 = '<div id="site"></div><div id="plugins"></div>'
|
||||
loadSiteSettings()
|
||||
loadPlugins()
|
||||
})
|
||||
</script>`
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue