Files
boocode/apps/server/src/routes/settings.ts
indifferentketchup fc4fbb0b7e feat: futuristic theme ladder + stacked landing banner
Add three opt-in dark themes (BooCode+, BooCode Classic, BooCode
Override) plus an in-place Ember polish, on a class-scoped effects
engine: matrix rain, a neon grid field, and frosted glass, all gated
by a localStorage "Animated background" toggle and prefers-reduced-
motion. Extend the server theme_id whitelist so the new ids persist,
and replace the Home landing wordmark with the stacked mascot +
wordmark banner.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 14:16:59 +00:00

105 lines
3.0 KiB
TypeScript

import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import type { Sql } from '../db.js';
export async function getSetting<T = unknown>(
sql: Sql,
key: string
): Promise<T | null> {
const rows = await sql<{ value: T }[]>`SELECT value FROM settings WHERE key = ${key}`;
return rows[0]?.value ?? null;
}
export async function setSetting(
sql: Sql,
key: string,
value: unknown
): Promise<void> {
await sql`
INSERT INTO settings (key, value)
VALUES (${key}, ${sql.json(value as never)})
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
`;
}
// themes-v1: whitelist of the preset theme ids. Kept in sync with
// docs/themes_v1.md §1 and apps/web/src/lib/theme.ts THEMES.
// (+ 'ember' — the BooCode 2.0 signature, now the default.)
const THEME_IDS = [
'obsidian',
'gunmetal',
'espresso',
'volcanic-brown',
'copper',
'gold',
'oxblood',
'crimson',
'elderflower',
'plum',
'steel-pink',
'fuchsia-noir',
'matrix',
'sage',
'ivory',
'chalk',
'cobalt',
'midnight-sapphire',
'ember',
// futuristic ladder (opt-in) — kept in sync with apps/web/src/lib/theme.ts THEMES
'boocode-plus',
'boocode-classic',
'boocode-override',
] as const;
const THEME_MODES = ['dark', 'light', 'system'] as const;
// PATCH body is still a free-form key/value bag for everything except the
// two theme keys, which carry strict per-key validation. Anything outside
// THEME_IDS / THEME_MODES on those keys is rejected with 400.
function validateThemeKeys(body: Record<string, unknown>): string | null {
if ('theme_id' in body) {
const v = body.theme_id;
if (typeof v !== 'string' || !(THEME_IDS as readonly string[]).includes(v)) {
return `theme_id must be one of: ${THEME_IDS.join(', ')}`;
}
}
if ('theme_mode' in body) {
const v = body.theme_mode;
if (typeof v !== 'string' || !(THEME_MODES as readonly string[]).includes(v)) {
return `theme_mode must be one of: ${THEME_MODES.join(', ')}`;
}
}
return null;
}
const PatchBody = z.record(z.string(), z.unknown());
export function registerSettingsRoutes(app: FastifyInstance, sql: Sql): void {
app.get('/api/settings', async () => {
const rows = await sql<{ key: string; value: unknown }[]>`SELECT key, value FROM settings`;
const out: Record<string, unknown> = {};
for (const r of rows) out[r.key] = r.value;
return out;
});
app.patch('/api/settings', async (req, reply) => {
const parsed = PatchBody.safeParse(req.body);
if (!parsed.success) {
reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() };
}
const themeError = validateThemeKeys(parsed.data);
if (themeError) {
reply.code(400);
return { error: themeError };
}
for (const [k, v] of Object.entries(parsed.data)) {
await setSetting(sql, k, v);
}
const rows = await sql<{ key: string; value: unknown }[]>`SELECT key, value FROM settings`;
const out: Record<string, unknown> = {};
for (const r of rows) out[r.key] = r.value;
return out;
});
}