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>
105 lines
3.0 KiB
TypeScript
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;
|
|
});
|
|
}
|