WIP! A BB-style forum, on the ATmosphere!
We're still working... we'll be back soon when we have something to show off!
node
typescript
hono
htmx
atproto
1// pattern: Mixed (unavoidable)
2// Reason: Hono route file — JSX components, pure helpers (slugifyName, isHttpsUrl,
3// sanitizeTokenValue), and route handlers (I/O) must coexist per this project's
4// one-file-per-route-group convention (see other admin-*.tsx route files).
5import { Hono } from "hono";
6import { BaseLayout } from "../layouts/base.js";
7import { PageHeader, EmptyState } from "../components/index.js";
8import {
9 getSessionWithPermissions,
10 canManageThemes,
11} from "../lib/session.js";
12import { isProgrammingError } from "../lib/errors.js";
13import { logger } from "../lib/logger.js";
14import { tokensToCss } from "../lib/theme.js";
15import { FALLBACK_THEME, type WebAppEnv } from "../lib/theme-resolution.js";
16import neobrutalLight from "../styles/presets/neobrutal-light.json" with { type: "json" };
17import neobrutalDark from "../styles/presets/neobrutal-dark.json" with { type: "json" };
18import cleanLight from "../styles/presets/clean-light.json" with { type: "json" };
19import cleanDark from "../styles/presets/clean-dark.json" with { type: "json" };
20import classicBb from "../styles/presets/classic-bb.json" with { type: "json" };
21
22// ─── Types ─────────────────────────────────────────────────────────────────
23
24interface AdminThemeEntry {
25 id: string;
26 uri: string;
27 name: string;
28 colorScheme: string;
29 tokens: Record<string, string>;
30 cssOverrides: string | null;
31 fontUrls: string[] | null;
32 createdAt: string;
33 indexedAt: string;
34}
35
36interface ThemePolicy {
37 defaultLightThemeUri: string | null;
38 defaultDarkThemeUri: string | null;
39 allowUserChoice: boolean;
40 availableThemes: Array<{ uri: string; cid?: string }>;
41}
42
43// Preset token maps — used by POST /admin/themes to seed tokens on creation
44const THEME_PRESETS: Record<string, Record<string, string>> = {
45 "neobrutal-light": neobrutalLight as Record<string, string>,
46 "neobrutal-dark": neobrutalDark as Record<string, string>,
47 "clean-light": cleanLight as Record<string, string>,
48 "clean-dark": cleanDark as Record<string, string>,
49 "classic-bb": classicBb as Record<string, string>,
50 "blank": {},
51};
52
53// ─── Token Group Constants ──────────────────────────────────────────────────
54
55export const COLOR_TOKENS = [
56 "color-bg", "color-surface", "color-text", "color-text-muted",
57 "color-primary", "color-primary-hover", "color-secondary", "color-border",
58 "color-shadow", "color-success", "color-warning", "color-danger",
59 "color-code-bg", "color-code-text",
60] as const;
61
62export const TYPOGRAPHY_TOKENS = [
63 "font-body", "font-heading", "font-mono",
64 "font-size-base", "font-size-sm", "font-size-xs", "font-size-lg",
65 "font-size-xl", "font-size-2xl",
66 "font-weight-normal", "font-weight-bold",
67 "line-height-body", "line-height-heading",
68] as const;
69
70export const SPACING_TOKENS = [
71 "space-xs", "space-sm", "space-md", "space-lg", "space-xl",
72 "radius", "border-width", "shadow-offset", "content-width",
73] as const;
74
75export const COMPONENT_TOKENS = [
76 "button-radius", "button-shadow",
77 "card-radius", "card-shadow",
78 "btn-press-hover", "btn-press-active",
79 "input-radius", "input-border",
80 "nav-height",
81] as const;
82
83export const ALL_KNOWN_TOKENS: readonly string[] = [
84 ...COLOR_TOKENS, ...TYPOGRAPHY_TOKENS, ...SPACING_TOKENS, ...COMPONENT_TOKENS,
85];
86
87// ─── Helpers ────────────────────────────────────────────────────────────────
88
89/**
90 * Extracts the error message from an AppView error response.
91 * Falls back to the provided default if JSON parsing fails.
92 */
93async function extractAppviewError(res: Response, fallback: string): Promise<string> {
94 try {
95 const data = (await res.json()) as { error?: string };
96 return data.error ?? fallback;
97 } catch (error) {
98 logger.error("Failed to parse AppView error response body", {
99 operation: "extractAppviewError",
100 status: res.status,
101 error: error instanceof Error ? error.message : String(error),
102 });
103 return fallback;
104 }
105}
106
107/** Drop token values that could break the CSS style block. */
108function sanitizeTokenValue(value: unknown): string | null {
109 if (typeof value !== "string") return null;
110 if (value.includes("<") || value.includes(";") || value.includes("}")) return null;
111 return value;
112}
113
114/** Produce a URL-safe filename slug from a theme name, with a fallback. */
115function slugifyName(name: string): string {
116 return name
117 .toLowerCase()
118 .replace(/[^a-z0-9]+/g, "-")
119 .replace(/^-+|-+$/g, "") || "theme";
120}
121
122/** Returns true only for absolute HTTPS URLs. */
123function isHttpsUrl(url: unknown): boolean {
124 if (typeof url !== "string") return false;
125 try {
126 return new URL(url).protocol === "https:";
127 } catch {
128 return false;
129 }
130}
131
132// ─── JSX Components ─────────────────────────────────────────────────────────
133
134function ColorTokenInput({ name, value }: { name: string; value: string }) {
135 const safeValue =
136 !value.startsWith("var(") && !value.includes(";") && !value.includes("<")
137 ? value
138 : "#cccccc";
139 return (
140 <div class="token-input token-input--color">
141 <label for={`token-${name}`}>{name}</label>
142 <div class="token-input__controls">
143 <input
144 type="color"
145 value={safeValue}
146 aria-label={`${name} color picker`}
147 oninput="this.nextElementSibling.value=this.value;this.nextElementSibling.dispatchEvent(new Event('change',{bubbles:true}))"
148 />
149 <input
150 type="text"
151 id={`token-${name}`}
152 name={name}
153 value={safeValue}
154 oninput="if(/^#[0-9a-fA-F]{6}$/.test(this.value))this.previousElementSibling.value=this.value"
155 />
156 </div>
157 </div>
158 );
159}
160
161function TextTokenInput({ name, value }: { name: string; value: string }) {
162 return (
163 <div class="token-input">
164 <label for={`token-${name}`}>{name}</label>
165 <input type="text" id={`token-${name}`} name={name} value={value} />
166 </div>
167 );
168}
169
170function TokenFieldset({
171 legend,
172 tokens,
173 effectiveTokens,
174 isColor,
175}: {
176 legend: string;
177 tokens: readonly string[];
178 effectiveTokens: Record<string, string>;
179 isColor: boolean;
180}) {
181 return (
182 <fieldset class="token-group">
183 <legend>{legend}</legend>
184 {tokens.map((name) =>
185 isColor ? (
186 <ColorTokenInput name={name} value={effectiveTokens[name] ?? ""} />
187 ) : (
188 <TextTokenInput name={name} value={effectiveTokens[name] ?? ""} />
189 )
190 )}
191 </fieldset>
192 );
193}
194
195function ThemePreviewContent({ tokens }: { tokens: Record<string, string> }) {
196 const css = tokensToCss(tokens);
197 return (
198 <>
199 <style>{`.preview-pane-inner{${css}}`}</style>
200 <div class="preview-pane-inner">
201 <div
202 style="background:var(--color-surface);border-bottom:var(--border-width) solid var(--color-border);padding:var(--space-sm) var(--space-md);display:flex;align-items:center;font-family:var(--font-heading);font-weight:var(--font-weight-bold);font-size:var(--font-size-lg);color:var(--color-text);"
203 role="navigation"
204 aria-label="Preview navigation"
205 >
206 atBB Forum Preview
207 </div>
208 <div style="padding:var(--space-md);">
209 <div
210 style="background:var(--color-surface);border:var(--border-width) solid var(--color-border);border-radius:var(--card-radius);box-shadow:var(--card-shadow);padding:var(--space-md);margin-bottom:var(--space-md);"
211 >
212 <h2
213 style="font-family:var(--font-heading);font-size:var(--font-size-xl);font-weight:var(--font-weight-bold);line-height:var(--line-height-heading);color:var(--color-text);margin:0 0 var(--space-sm) 0;"
214 >
215 Sample Thread Title
216 </h2>
217 <p style="font-family:var(--font-body);font-size:var(--font-size-base);line-height:var(--line-height-body);color:var(--color-text);margin:0 0 var(--space-md) 0;">
218 Body text showing font, color, and spacing at work.{" "}
219 <a href="#" style="color:var(--color-primary);">A sample link</a>
220 </p>
221 <pre
222 style="font-family:var(--font-mono);font-size:var(--font-size-sm);background:var(--color-code-bg);color:var(--color-code-text);padding:var(--space-sm) var(--space-md);border-radius:var(--radius);margin:0 0 var(--space-md) 0;overflow-x:auto;"
223 >
224 {`const greeting = "hello forum";`}
225 </pre>
226 <input
227 type="text"
228 placeholder="Reply…"
229 style="font-family:var(--font-body);font-size:var(--font-size-base);border:var(--input-border);border-radius:var(--input-radius);padding:var(--space-sm) var(--space-md);width:100%;box-sizing:border-box;background:var(--color-bg);color:var(--color-text);margin-bottom:var(--space-sm);"
230 />
231 <div style="display:flex;gap:var(--space-sm);flex-wrap:wrap;">
232 <button
233 type="button"
234 style="background:var(--color-primary);color:var(--color-surface);border:var(--border-width) solid var(--color-border);border-radius:var(--button-radius);box-shadow:var(--button-shadow);font-family:var(--font-body);font-weight:var(--font-weight-bold);padding:var(--space-sm) var(--space-md);cursor:pointer;"
235 >
236 Post Reply
237 </button>
238 <button
239 type="button"
240 style="background:var(--color-surface);color:var(--color-text);border:var(--border-width) solid var(--color-border);border-radius:var(--button-radius);box-shadow:var(--button-shadow);font-family:var(--font-body);font-weight:var(--font-weight-bold);padding:var(--space-sm) var(--space-md);cursor:pointer;"
241 >
242 Cancel
243 </button>
244 <span
245 style="display:inline-block;background:var(--color-success);color:var(--color-surface);border:var(--border-width) solid var(--color-border);padding:0 var(--space-sm);font-size:var(--font-size-sm);"
246 >
247 success
248 </span>
249 <span
250 style="display:inline-block;background:var(--color-warning);color:var(--color-text);border:var(--border-width) solid var(--color-border);padding:0 var(--space-sm);font-size:var(--font-size-sm);"
251 >
252 warning
253 </span>
254 <span
255 style="display:inline-block;background:var(--color-danger);color:var(--color-surface);border:var(--border-width) solid var(--color-border);padding:0 var(--space-sm);font-size:var(--font-size-sm);"
256 >
257 danger
258 </span>
259 </div>
260 </div>
261 </div>
262 </div>
263 </>
264 );
265}
266
267
268// ─── Route Factory ──────────────────────────────────────────────────────────
269
270export function createAdminThemeRoutes(appviewUrl: string) {
271 const app = new Hono<WebAppEnv>();
272
273 // ── GET /admin/themes ──────────────────────────────────────────────────────
274
275 app.get("/admin/themes", async (c) => {
276 const resolvedTheme = c.get("theme") ?? FALLBACK_THEME;
277 const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie"));
278
279 if (!auth.authenticated) {
280 return c.redirect("/login");
281 }
282
283 if (!canManageThemes(auth)) {
284 return c.html(
285 <BaseLayout title="Access Denied — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}>
286 <PageHeader title="Themes" />
287 <p>You don't have permission to manage themes.</p>
288 </BaseLayout>,
289 403
290 );
291 }
292
293 const cookie = c.req.header("cookie") ?? "";
294 const errorMsg = c.req.query("error") ?? null;
295
296 let adminThemes: AdminThemeEntry[] = [];
297 let policy: ThemePolicy | null = null;
298
299 try {
300 const [themesRes, policyRes] = await Promise.all([
301 fetch(`${appviewUrl}/api/admin/themes`, { headers: { Cookie: cookie } }),
302 fetch(`${appviewUrl}/api/theme-policy`, { headers: { Cookie: cookie } }),
303 ]);
304
305 if (themesRes.ok) {
306 try {
307 const data = (await themesRes.json()) as { themes: AdminThemeEntry[] };
308 adminThemes = data.themes;
309 } catch {
310 logger.error("Failed to parse admin themes response", {
311 operation: "GET /admin/themes",
312 status: themesRes.status,
313 });
314 }
315 } else {
316 logger.error("Failed to fetch admin themes list", {
317 operation: "GET /admin/themes",
318 status: themesRes.status,
319 });
320 }
321
322 if (policyRes.ok) {
323 try {
324 policy = (await policyRes.json()) as ThemePolicy;
325 } catch {
326 logger.error("Failed to parse theme policy response", {
327 operation: "GET /admin/themes",
328 status: policyRes.status,
329 });
330 }
331 } else if (policyRes.status !== 404) {
332 logger.error("Failed to fetch theme policy", {
333 operation: "GET /admin/themes",
334 status: policyRes.status,
335 });
336 }
337 // 404 = no policy yet — render page with empty policy (not an error)
338 } catch (error) {
339 if (isProgrammingError(error)) throw error;
340 logger.error("Network error fetching themes data", {
341 operation: "GET /admin/themes",
342 error: error instanceof Error ? error.message : String(error),
343 });
344 }
345
346 const availableUris = new Set((policy?.availableThemes ?? []).map((t) => t.uri));
347 const lightThemes = adminThemes.filter((t) => t.colorScheme === "light");
348 const darkThemes = adminThemes.filter((t) => t.colorScheme === "dark");
349
350 return c.html(
351 <BaseLayout title="Themes — atBB Admin" auth={auth} resolvedTheme={resolvedTheme}>
352 <PageHeader title="Themes" />
353
354 {errorMsg && <div class="structure-error-banner">{errorMsg}</div>}
355
356 {adminThemes.length === 0 ? (
357 <EmptyState message="No themes yet. Create one below." />
358 ) : (
359 <div class="structure-list">
360 {adminThemes.map((theme) => {
361 const themeRkey = theme.uri.split("/").pop() ?? theme.id;
362 const dialogId = `confirm-delete-theme-${themeRkey}`;
363 const swatchTokens = [
364 "color-bg",
365 "color-surface",
366 "color-primary",
367 "color-secondary",
368 "color-border",
369 ] as const;
370
371 return (
372 <div class="structure-item">
373 <div class="structure-item__header">
374 <div class="structure-item__title">
375 <label>
376 <input
377 type="checkbox"
378 form="policy-form"
379 name="availableThemes"
380 value={theme.uri}
381 checked={availableUris.has(theme.uri)}
382 />
383 {" "}
384 {theme.name}
385 </label>
386 <span class={`badge badge--${theme.colorScheme}`}>
387 {theme.colorScheme}
388 </span>
389 </div>
390
391 <div class="theme-swatches" aria-hidden="true">
392 {swatchTokens.map((token) => {
393 const value = theme.tokens[token] ?? "#cccccc";
394 const safe =
395 !value.startsWith("var(") &&
396 !value.includes(";") &&
397 !value.includes("<");
398 return (
399 <span
400 class="theme-swatch"
401 style={safe ? `background:${value}` : "background:#cccccc"}
402 title={token}
403 />
404 );
405 })}
406 </div>
407
408 <div class="structure-item__actions">
409 <a href={`/admin/themes/${themeRkey}`} class="btn btn-secondary btn-sm">
410 Edit
411 </a>
412
413 <a href={`/admin/themes/${themeRkey}/export`} class="btn btn-secondary btn-sm">
414 Export
415 </a>
416
417 <form
418 method="post"
419 action={`/admin/themes/${themeRkey}/duplicate`}
420 style="display:inline"
421 >
422 <button type="submit" class="btn btn-secondary btn-sm">
423 Duplicate
424 </button>
425 </form>
426
427 <button
428 type="button"
429 class="btn btn-danger btn-sm"
430 onclick={`document.getElementById('${dialogId}').showModal()`}
431 >
432 Delete
433 </button>
434 </div>
435 </div>
436
437 <dialog id={dialogId} class="structure-confirm-dialog">
438 <p>
439 Delete theme "{theme.name}"? This cannot be undone.
440 </p>
441 <form
442 method="post"
443 action={`/admin/themes/${themeRkey}/delete`}
444 class="dialog-actions"
445 >
446 <button type="submit" class="btn btn-danger">
447 Delete
448 </button>
449 <button
450 type="button"
451 class="btn btn-secondary"
452 onclick={`document.getElementById('${dialogId}').close()`}
453 >
454 Cancel
455 </button>
456 </form>
457 </dialog>
458 </div>
459 );
460 })}
461 </div>
462 )}
463
464 {/* Policy form — availability checkboxes on cards associate via form="policy-form" */}
465 <section class="admin-section">
466 <h2>Theme Policy</h2>
467 <form id="policy-form" method="post" action="/admin/theme-policy">
468 <div class="form-group">
469 <label for="defaultLightThemeUri">Default Light Theme</label>
470 <select id="defaultLightThemeUri" name="defaultLightThemeUri">
471 <option value="">— none —</option>
472 {lightThemes.map((t) => (
473 <option
474 value={t.uri}
475 selected={policy?.defaultLightThemeUri === t.uri}
476 >
477 {t.name}
478 </option>
479 ))}
480 </select>
481 </div>
482
483 <div class="form-group">
484 <label for="defaultDarkThemeUri">Default Dark Theme</label>
485 <select id="defaultDarkThemeUri" name="defaultDarkThemeUri">
486 <option value="">— none —</option>
487 {darkThemes.map((t) => (
488 <option
489 value={t.uri}
490 selected={policy?.defaultDarkThemeUri === t.uri}
491 >
492 {t.name}
493 </option>
494 ))}
495 </select>
496 </div>
497
498 <div class="form-group">
499 <label>
500 <input
501 type="checkbox"
502 name="allowUserChoice"
503 checked={policy?.allowUserChoice ?? true}
504 />
505 {" "}Allow users to choose their own theme
506 </label>
507 </div>
508
509 <p class="form-hint">
510 Check themes above to make them available to users.
511 </p>
512 <button type="submit" class="btn btn-primary">
513 Save Policy
514 </button>
515 </form>
516 </section>
517
518 {/* Create new theme */}
519 <details class="structure-add-form">
520 <summary class="structure-add-form__trigger">+ Create New Theme</summary>
521 <form
522 method="post"
523 action="/admin/themes"
524 class="structure-edit-form__body"
525 >
526 <div class="form-group">
527 <label for="new-theme-name">Name</label>
528 <input
529 id="new-theme-name"
530 type="text"
531 name="name"
532 required
533 placeholder="My Custom Theme"
534 />
535 </div>
536 <div class="form-group">
537 <label for="new-theme-scheme">Color Scheme</label>
538 <select id="new-theme-scheme" name="colorScheme">
539 <option value="light">Light</option>
540 <option value="dark">Dark</option>
541 </select>
542 </div>
543 <div class="form-group">
544 <label for="new-theme-preset">Start from Preset</label>
545 <select id="new-theme-preset" name="preset">
546 <option value="neobrutal-light">Neobrutal Light</option>
547 <option value="neobrutal-dark">Neobrutal Dark</option>
548 <option value="blank">Blank</option>
549 </select>
550 </div>
551 <button type="submit" class="btn btn-primary">
552 Create Theme
553 </button>
554 </form>
555 </details>
556
557 {/* Import theme from JSON */}
558 <details class="structure-add-form">
559 <summary class="structure-add-form__trigger">↑ Import Theme from JSON</summary>
560 <form
561 method="post"
562 action="/admin/themes/import"
563 enctype="multipart/form-data"
564 class="structure-edit-form__body"
565 >
566 <div class="form-group">
567 <label for="import-theme-file">Theme JSON file</label>
568 <input
569 id="import-theme-file"
570 type="file"
571 name="themeFile"
572 accept=".json"
573 required
574 />
575 <p class="form-hint">
576 Imports name, colorScheme, tokens, and fontUrls.
577 CSS overrides and unknown token keys are ignored.
578 </p>
579 </div>
580 <button type="submit" class="btn btn-primary">
581 Import Theme
582 </button>
583 </form>
584 </details>
585 </BaseLayout>
586 );
587 });
588
589 // ── GET /admin/themes/:rkey/export ────────────────────────────────────────
590 // Distinct from /:rkey — the 4-segment path cannot match the 3-segment /:rkey route.
591
592 app.get("/admin/themes/:rkey/export", async (c) => {
593 const resolvedTheme = c.get("theme") ?? FALLBACK_THEME;
594 const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie"));
595 if (!auth.authenticated) return c.redirect("/login");
596 if (!canManageThemes(auth)) {
597 return c.html(
598 <BaseLayout title="Access Denied — atBB Admin" auth={auth} resolvedTheme={resolvedTheme}>
599 <PageHeader title="Access Denied" />
600 <p>You don't have permission to manage themes.</p>
601 </BaseLayout>,
602 403
603 );
604 }
605
606 const themeRkey = c.req.param("rkey");
607
608 let theme: AdminThemeEntry | null = null;
609 try {
610 const res = await fetch(`${appviewUrl}/api/themes/${themeRkey}`);
611 if (res.status === 404) {
612 return c.html(
613 <BaseLayout title="Theme Not Found — atBB Admin" auth={auth} resolvedTheme={resolvedTheme}>
614 <PageHeader title="Theme Not Found" />
615 <p>This theme does not exist.</p>
616 <a href="/admin/themes" class="btn btn-secondary">← Back to themes</a>
617 </BaseLayout>,
618 404
619 );
620 }
621 if (res.ok) {
622 try {
623 theme = (await res.json()) as AdminThemeEntry;
624 } catch (error) {
625 if (isProgrammingError(error)) throw error;
626 logger.error("Failed to parse theme response for export", {
627 operation: "GET /admin/themes/:rkey/export",
628 themeRkey,
629 error: error instanceof Error ? error.message : String(error),
630 });
631 }
632 } else {
633 logger.error("AppView returned error loading theme for export", {
634 operation: "GET /admin/themes/:rkey/export",
635 themeRkey,
636 status: res.status,
637 });
638 }
639 } catch (error) {
640 if (isProgrammingError(error)) throw error;
641 logger.error("Network error loading theme for export", {
642 operation: "GET /admin/themes/:rkey/export",
643 themeRkey,
644 error: error instanceof Error ? error.message : String(error),
645 });
646 }
647
648 if (!theme) {
649 return c.html(
650 <BaseLayout title="Export Failed — atBB Admin" auth={auth} resolvedTheme={resolvedTheme}>
651 <PageHeader title="Export Failed" />
652 <p>Unable to load theme data. Please try again.</p>
653 <a href="/admin/themes" class="btn btn-secondary">← Back to themes</a>
654 </BaseLayout>,
655 500
656 );
657 }
658
659 // cssOverrides excluded from export — it contains raw CSS that may reference
660 // external resources and is tied to this forum's sanitization config.
661 const exportData = {
662 name: theme.name,
663 colorScheme: theme.colorScheme,
664 tokens: theme.tokens,
665 fontUrls: theme.fontUrls ?? [],
666 };
667
668 const filename = `${slugifyName(theme.name)}-${slugifyName(theme.colorScheme)}.json`;
669 c.header("Content-Type", "application/json");
670 c.header("Content-Disposition", `attachment; filename="${filename}"`);
671 return c.body(JSON.stringify(exportData, null, 2), 200);
672 });
673
674 // ── GET /admin/themes/:rkey ────────────────────────────────────────────────
675
676 app.get("/admin/themes/:rkey", async (c) => {
677 const resolvedTheme = c.get("theme") ?? FALLBACK_THEME;
678 const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie"));
679 if (!auth.authenticated) return c.redirect("/login");
680 if (!canManageThemes(auth)) {
681 return c.html(
682 <BaseLayout title="Access Denied — atBB Admin" auth={auth} resolvedTheme={resolvedTheme}>
683 <PageHeader title="Access Denied" />
684 <p>You don't have permission to manage themes.</p>
685 </BaseLayout>,
686 403
687 );
688 }
689
690 const themeRkey = c.req.param("rkey");
691 const presetParam = c.req.query("preset") ?? null;
692 const successMsg = c.req.query("success") === "1" ? "Theme saved successfully." : null;
693 const errorMsg = c.req.query("error") ?? null;
694
695 // Fetch theme from AppView
696 let theme: AdminThemeEntry | null = null;
697 try {
698 const res = await fetch(`${appviewUrl}/api/themes/${themeRkey}`);
699 if (res.status === 404) {
700 return c.html(
701 <BaseLayout title="Theme Not Found — atBB Admin" auth={auth} resolvedTheme={resolvedTheme}>
702 <PageHeader title="Theme Not Found" />
703 <p>This theme does not exist.</p>
704 <a href="/admin/themes" class="btn btn-secondary">← Back to themes</a>
705 </BaseLayout>,
706 404
707 );
708 }
709 if (res.ok) {
710 try {
711 theme = (await res.json()) as AdminThemeEntry;
712 } catch {
713 logger.error("Failed to parse theme response", {
714 operation: "GET /admin/themes/:rkey",
715 themeRkey,
716 });
717 }
718 } else {
719 logger.error("AppView returned error loading theme", {
720 operation: "GET /admin/themes/:rkey",
721 themeRkey,
722 status: res.status,
723 });
724 }
725 } catch (error) {
726 if (isProgrammingError(error)) throw error;
727 logger.error("Network error loading theme", {
728 operation: "GET /admin/themes/:rkey",
729 themeRkey,
730 error: error instanceof Error ? error.message : String(error),
731 });
732 }
733
734 if (!theme) {
735 return c.html(
736 <BaseLayout title="Theme Unavailable — atBB Admin" auth={auth} resolvedTheme={resolvedTheme}>
737 <PageHeader title="Theme Unavailable" />
738 <p>Unable to load theme data. Please try again.</p>
739 <a href="/admin/themes" class="btn btn-secondary">← Back to themes</a>
740 </BaseLayout>,
741 500
742 );
743 }
744
745 // If ?preset is set, override DB tokens with preset tokens
746 const presetTokens = presetParam ? (THEME_PRESETS[presetParam] ?? null) : null;
747 const effectiveTokens: Record<string, string> = presetTokens
748 ? { ...theme.tokens, ...presetTokens }
749 : { ...theme.tokens };
750
751 const fontUrlsText = (theme.fontUrls ?? []).join("\n");
752
753 return c.html(
754 <BaseLayout title={`Edit Theme: ${theme.name} — atBB Admin`} auth={auth} resolvedTheme={resolvedTheme}>
755 <PageHeader title={`Edit Theme: ${theme.name}`} />
756
757 {successMsg && <div class="structure-success-banner">{successMsg}</div>}
758 {errorMsg && <div class="structure-error-banner">{errorMsg}</div>}
759
760 <a href="/admin/themes" class="btn btn-secondary btn-sm" style="margin-bottom: var(--space-md); display: inline-block;">
761 ← Back to themes
762 </a>
763
764 {/* Metadata + tokens form */}
765 <form
766 id="editor-form"
767 method="post"
768 action={`/admin/themes/${themeRkey}/save`}
769 class="theme-editor"
770 >
771 {/* Metadata */}
772 <fieldset class="token-group">
773 <legend>Theme Metadata</legend>
774 <div class="token-input">
775 <label for="theme-name">Name</label>
776 <input type="text" id="theme-name" name="name" value={theme.name} required />
777 </div>
778 <div class="token-input">
779 <label for="theme-scheme">Color Scheme</label>
780 <select id="theme-scheme" name="colorScheme">
781 <option value="light" selected={theme.colorScheme === "light" ? true : undefined}>Light</option>
782 <option value="dark" selected={theme.colorScheme === "dark" ? true : undefined}>Dark</option>
783 </select>
784 </div>
785 <div class="token-input">
786 <label for="theme-font-urls">Font URLs (one per line)</label>
787 <textarea id="theme-font-urls" name="fontUrls" rows={3} placeholder="https://fonts.googleapis.com/css2?family=...">
788 {fontUrlsText}
789 </textarea>
790 </div>
791 </fieldset>
792
793 {/* Token editor + live preview layout */}
794 <div class="theme-editor__layout">
795 {/* Left: token controls */}
796 <div
797 class="theme-editor__controls"
798 hx-post={`/admin/themes/${themeRkey}/preview`}
799 hx-trigger="input delay:500ms"
800 hx-target="#preview-pane"
801 hx-include="#editor-form"
802 >
803 <TokenFieldset
804 legend="Colors"
805 tokens={COLOR_TOKENS}
806 effectiveTokens={effectiveTokens}
807 isColor={true}
808 />
809 <TokenFieldset
810 legend="Typography"
811 tokens={TYPOGRAPHY_TOKENS}
812 effectiveTokens={effectiveTokens}
813 isColor={false}
814 />
815 <TokenFieldset
816 legend="Spacing & Layout"
817 tokens={SPACING_TOKENS}
818 effectiveTokens={effectiveTokens}
819 isColor={false}
820 />
821 <TokenFieldset
822 legend="Components"
823 tokens={COMPONENT_TOKENS}
824 effectiveTokens={effectiveTokens}
825 isColor={false}
826 />
827
828 {/* CSS overrides */}
829 <fieldset class="token-group">
830 <legend>CSS Overrides</legend>
831 <div class="token-input">
832 <label for="css-overrides">Custom CSS</label>
833 <textarea
834 id="css-overrides"
835 name="cssOverrides"
836 rows={6}
837 aria-describedby="css-overrides-hint"
838 placeholder="/* Structural overrides beyond what design tokens allow */"
839 >
840 {theme.cssOverrides ?? ""}
841 </textarea>
842 <p id="css-overrides-hint" class="form-hint">
843 Raw CSS for structural changes. Dangerous constructs (external
844 URLs, @import, expression()) are stripped automatically on save.
845 </p>
846 </div>
847 </fieldset>
848 </div>
849
850 {/* Right: live preview */}
851 <div class="theme-editor__preview">
852 <h3>Live Preview</h3>
853 <div id="preview-pane" class="preview-pane">
854 <ThemePreviewContent tokens={effectiveTokens} />
855 </div>
856 </div>
857 </div>
858
859 {/* Actions */}
860 <div class="theme-editor__actions">
861 <button type="submit" class="btn btn-primary">Save Theme</button>
862
863 <button
864 type="button"
865 class="btn btn-secondary"
866 onclick="document.getElementById('reset-dialog').showModal()"
867 >
868 Reset to Preset
869 </button>
870 </div>
871 </form>
872
873 {/* Reset to preset dialog */}
874 <dialog id="reset-dialog" class="structure-confirm-dialog">
875 <form method="post" action={`/admin/themes/${themeRkey}/reset-to-preset`}>
876 <p>Reset all token values to a built-in preset? Your unsaved changes will be lost.</p>
877 <div class="form-group">
878 <label for="reset-preset-select">Reset to preset:</label>
879 <select id="reset-preset-select" name="preset">
880 <option value="neobrutal-light">Neobrutal Light</option>
881 <option value="neobrutal-dark">Neobrutal Dark</option>
882 <option value="blank">Blank (empty tokens)</option>
883 </select>
884 </div>
885 <div class="dialog-actions">
886 <button type="submit" class="btn btn-danger">Reset</button>
887 <button
888 type="button"
889 class="btn btn-secondary"
890 onclick="document.getElementById('reset-dialog').close()"
891 >
892 Cancel
893 </button>
894 </div>
895 </form>
896 </dialog>
897 </BaseLayout>
898 );
899 });
900
901 // ── POST /admin/themes ────────────────────────────────────────────────────
902
903 app.post("/admin/themes", async (c) => {
904 const resolvedTheme = c.get("theme") ?? FALLBACK_THEME;
905 const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie"));
906 if (!auth.authenticated) return c.redirect("/login");
907 if (!canManageThemes(auth)) {
908 return c.html(<BaseLayout title="Access Denied" auth={auth} resolvedTheme={resolvedTheme}><p>Access denied.</p></BaseLayout>, 403);
909 }
910
911 const cookie = c.req.header("cookie") ?? "";
912
913 let body: Record<string, string | File>;
914 try {
915 body = await c.req.parseBody();
916 } catch (error) {
917 if (isProgrammingError(error)) throw error;
918 return c.redirect(
919 `/admin/themes?error=${encodeURIComponent("Invalid form submission.")}`,
920 302
921 );
922 }
923
924 const name = typeof body.name === "string" ? body.name.trim() : "";
925 const colorScheme = typeof body.colorScheme === "string" ? body.colorScheme : "light";
926 const preset = typeof body.preset === "string" ? body.preset : "blank";
927
928 if (!name) {
929 return c.redirect(
930 `/admin/themes?error=${encodeURIComponent("Theme name is required.")}`,
931 302
932 );
933 }
934
935 const tokens = THEME_PRESETS[preset] ?? {};
936
937 let apiRes: Response;
938 try {
939 apiRes = await fetch(`${appviewUrl}/api/admin/themes`, {
940 method: "POST",
941 headers: { "Content-Type": "application/json", Cookie: cookie },
942 body: JSON.stringify({ name, colorScheme, tokens }),
943 });
944 } catch (error) {
945 if (isProgrammingError(error)) throw error;
946 logger.error("Network error creating theme", {
947 operation: "POST /admin/themes",
948 error: error instanceof Error ? error.message : String(error),
949 });
950 return c.redirect(
951 `/admin/themes?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`,
952 302
953 );
954 }
955
956 if (!apiRes.ok) {
957 const msg = await extractAppviewError(apiRes, "Failed to create theme. Please try again.");
958 return c.redirect(
959 `/admin/themes?error=${encodeURIComponent(msg)}`,
960 302
961 );
962 }
963
964 return c.redirect("/admin/themes", 302);
965 });
966
967 // ── POST /admin/themes/import ─────────────────────────────────────────────
968 // File upload: reads the JSON file, validates structure, strips unknown tokens
969 // and cssOverrides, then delegates to POST /api/admin/themes.
970
971 app.post("/admin/themes/import", async (c) => {
972 const resolvedTheme = c.get("theme") ?? FALLBACK_THEME;
973 const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie"));
974 if (!auth.authenticated) return c.redirect("/login");
975 if (!canManageThemes(auth)) {
976 return c.html(
977 <BaseLayout title="Access Denied" auth={auth} resolvedTheme={resolvedTheme}>
978 <p>Access denied.</p>
979 </BaseLayout>,
980 403
981 );
982 }
983
984 const cookie = c.req.header("cookie") ?? "";
985
986 let rawBody: Record<string, string | File>;
987 try {
988 rawBody = await c.req.parseBody();
989 } catch (error) {
990 if (isProgrammingError(error)) throw error;
991 logger.error("Failed to parse import form body", {
992 operation: "POST /admin/themes/import",
993 error: error instanceof Error ? error.message : String(error),
994 });
995 return c.redirect(
996 `/admin/themes?error=${encodeURIComponent("Invalid form submission.")}`,
997 302
998 );
999 }
1000
1001 const uploaded = rawBody.themeFile;
1002 if (!uploaded || typeof uploaded === "string" || uploaded.size === 0) {
1003 return c.redirect(
1004 `/admin/themes?error=${encodeURIComponent("Please select a JSON file to import.")}`,
1005 302
1006 );
1007 }
1008
1009 const MAX_IMPORT_BYTES = 100 * 1024; // 100 KB
1010 if (uploaded.size > MAX_IMPORT_BYTES) {
1011 return c.redirect(
1012 `/admin/themes?error=${encodeURIComponent("Import failed: file exceeds the 100 KB size limit.")}`,
1013 302
1014 );
1015 }
1016
1017 // Read the file text and parse as JSON — two separate try blocks so encoding
1018 // failures and JSON syntax errors produce distinct log entries.
1019 let text: string;
1020 try {
1021 text = await uploaded.text();
1022 } catch (error) {
1023 if (isProgrammingError(error)) throw error;
1024 logger.error("Failed to read uploaded theme file", {
1025 operation: "POST /admin/themes/import",
1026 error: error instanceof Error ? error.message : String(error),
1027 });
1028 return c.redirect(
1029 `/admin/themes?error=${encodeURIComponent("Import failed: could not read the uploaded file.")}`,
1030 302
1031 );
1032 }
1033
1034 let parsed: unknown;
1035 try {
1036 parsed = JSON.parse(text);
1037 } catch {
1038 return c.redirect(
1039 `/admin/themes?error=${encodeURIComponent("Import failed: file is not valid JSON.")}`,
1040 302
1041 );
1042 }
1043
1044 if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
1045 return c.redirect(
1046 `/admin/themes?error=${encodeURIComponent("Import failed: file must be a JSON object.")}`,
1047 302
1048 );
1049 }
1050
1051 const obj = parsed as Record<string, unknown>;
1052
1053 // Validate required: name
1054 const name = typeof obj.name === "string" ? obj.name.trim() : "";
1055 if (!name) {
1056 return c.redirect(
1057 `/admin/themes?error=${encodeURIComponent('Import failed: missing required field "name".')}`,
1058 302
1059 );
1060 }
1061
1062 // Validate required: colorScheme
1063 if (obj.colorScheme !== "light" && obj.colorScheme !== "dark") {
1064 return c.redirect(
1065 `/admin/themes?error=${encodeURIComponent(
1066 'Import failed: colorScheme must be "light" or "dark".'
1067 )}`,
1068 302
1069 );
1070 }
1071 const colorScheme = obj.colorScheme;
1072
1073 // Validate required: tokens (must be a plain object)
1074 if (typeof obj.tokens !== "object" || obj.tokens === null || Array.isArray(obj.tokens)) {
1075 return c.redirect(
1076 `/admin/themes?error=${encodeURIComponent('Import failed: missing required field "tokens".')}`,
1077 302
1078 );
1079 }
1080
1081 // Strip unknown token keys — only recognized tokens pass through
1082 const rawTokens = obj.tokens as Record<string, unknown>;
1083 const tokens: Record<string, string> = {};
1084 for (const key of ALL_KNOWN_TOKENS) {
1085 const val = rawTokens[key];
1086 if (typeof val === "string") {
1087 tokens[key] = val;
1088 }
1089 }
1090
1091 // Validate fontUrls — each must be an HTTPS URL
1092 let fontUrls: string[] | undefined;
1093 if (obj.fontUrls !== undefined) {
1094 if (!Array.isArray(obj.fontUrls)) {
1095 return c.redirect(
1096 `/admin/themes?error=${encodeURIComponent("Import failed: fontUrls must be an array.")}`,
1097 302
1098 );
1099 }
1100 for (const url of obj.fontUrls) {
1101 if (!isHttpsUrl(url)) {
1102 return c.redirect(
1103 `/admin/themes?error=${encodeURIComponent(
1104 `Import failed: font URL must be HTTPS: ${String(url)}`
1105 )}`,
1106 302
1107 );
1108 }
1109 }
1110 // Safe: every element has passed isHttpsUrl(), which verifies typeof === "string"
1111 fontUrls = obj.fontUrls as string[];
1112 }
1113
1114 // cssOverrides is silently dropped — keeps the shared JSON schema portable
1115 // (no forum-specific CSS bleeding in) and avoids importing structural overrides
1116 // that may not make sense in the target forum's layout.
1117
1118 let apiRes: Response;
1119 try {
1120 apiRes = await fetch(`${appviewUrl}/api/admin/themes`, {
1121 method: "POST",
1122 headers: { "Content-Type": "application/json", Cookie: cookie },
1123 body: JSON.stringify({
1124 name,
1125 colorScheme,
1126 tokens,
1127 ...(fontUrls !== undefined && { fontUrls }),
1128 }),
1129 });
1130 } catch (error) {
1131 if (isProgrammingError(error)) throw error;
1132 logger.error("Network error importing theme", {
1133 operation: "POST /admin/themes/import",
1134 error: error instanceof Error ? error.message : String(error),
1135 });
1136 return c.redirect(
1137 `/admin/themes?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`,
1138 302
1139 );
1140 }
1141
1142 if (!apiRes.ok) {
1143 const msg = await extractAppviewError(apiRes, "Failed to import theme. Please try again.");
1144 return c.redirect(`/admin/themes?error=${encodeURIComponent(msg)}`, 302);
1145 }
1146
1147 return c.redirect("/admin/themes", 302);
1148 });
1149
1150 // ── POST /admin/themes/:rkey/duplicate ────────────────────────────────────
1151
1152 app.post("/admin/themes/:rkey/duplicate", async (c) => {
1153 const resolvedTheme = c.get("theme") ?? FALLBACK_THEME;
1154 const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie"));
1155 if (!auth.authenticated) return c.redirect("/login");
1156 if (!canManageThemes(auth)) {
1157 return c.html(<BaseLayout title="Access Denied" auth={auth} resolvedTheme={resolvedTheme}><p>Access denied.</p></BaseLayout>, 403);
1158 }
1159
1160 const cookie = c.req.header("cookie") ?? "";
1161 const themeRkey = c.req.param("rkey");
1162
1163 let apiRes: Response;
1164 try {
1165 apiRes = await fetch(`${appviewUrl}/api/admin/themes/${themeRkey}/duplicate`, {
1166 method: "POST",
1167 headers: { Cookie: cookie },
1168 });
1169 } catch (error) {
1170 if (isProgrammingError(error)) throw error;
1171 logger.error("Network error duplicating theme", {
1172 operation: "POST /admin/themes/:rkey/duplicate",
1173 themeRkey,
1174 error: error instanceof Error ? error.message : String(error),
1175 });
1176 return c.redirect(
1177 `/admin/themes?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`,
1178 302
1179 );
1180 }
1181
1182 if (!apiRes.ok) {
1183 const msg = await extractAppviewError(apiRes, "Failed to duplicate theme. Please try again.");
1184 return c.redirect(`/admin/themes?error=${encodeURIComponent(msg)}`, 302);
1185 }
1186
1187 return c.redirect("/admin/themes", 302);
1188 });
1189
1190 // ── POST /admin/themes/:rkey/delete ──────────────────────────────────────
1191
1192 app.post("/admin/themes/:rkey/delete", async (c) => {
1193 const resolvedTheme = c.get("theme") ?? FALLBACK_THEME;
1194 const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie"));
1195 if (!auth.authenticated) return c.redirect("/login");
1196 if (!canManageThemes(auth)) {
1197 return c.html(<BaseLayout title="Access Denied" auth={auth} resolvedTheme={resolvedTheme}><p>Access denied.</p></BaseLayout>, 403);
1198 }
1199
1200 const cookie = c.req.header("cookie") ?? "";
1201 const themeRkey = c.req.param("rkey");
1202
1203 let apiRes: Response;
1204 try {
1205 apiRes = await fetch(`${appviewUrl}/api/admin/themes/${themeRkey}`, {
1206 method: "DELETE",
1207 headers: { Cookie: cookie },
1208 });
1209 } catch (error) {
1210 if (isProgrammingError(error)) throw error;
1211 logger.error("Network error deleting theme", {
1212 operation: "POST /admin/themes/:rkey/delete",
1213 themeRkey,
1214 error: error instanceof Error ? error.message : String(error),
1215 });
1216 return c.redirect(
1217 `/admin/themes?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`,
1218 302
1219 );
1220 }
1221
1222 if (!apiRes.ok) {
1223 if (apiRes.status === 409) {
1224 return c.redirect(
1225 `/admin/themes?error=${encodeURIComponent("Cannot delete a theme that is currently set as a default.")}`,
1226 302
1227 );
1228 }
1229 const msg = await extractAppviewError(apiRes, "Failed to delete theme. Please try again.");
1230 return c.redirect(`/admin/themes?error=${encodeURIComponent(msg)}`, 302);
1231 }
1232
1233 return c.redirect("/admin/themes", 302);
1234 });
1235
1236 // ── POST /admin/theme-policy ──────────────────────────────────────────────
1237
1238 app.post("/admin/theme-policy", async (c) => {
1239 const resolvedTheme = c.get("theme") ?? FALLBACK_THEME;
1240 const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie"));
1241 if (!auth.authenticated) return c.redirect("/login");
1242 if (!canManageThemes(auth)) {
1243 return c.html(<BaseLayout title="Access Denied" auth={auth} resolvedTheme={resolvedTheme}><p>Access denied.</p></BaseLayout>, 403);
1244 }
1245
1246 const cookie = c.req.header("cookie") ?? "";
1247
1248 let rawBody: Record<string, string | string[] | File | File[]>;
1249 try {
1250 rawBody = await c.req.parseBody({ all: true });
1251 } catch (error) {
1252 if (isProgrammingError(error)) throw error;
1253 return c.redirect(
1254 `/admin/themes?error=${encodeURIComponent("Invalid form submission.")}`,
1255 302
1256 );
1257 }
1258
1259 const defaultLightThemeUri =
1260 typeof rawBody.defaultLightThemeUri === "string" ? rawBody.defaultLightThemeUri : "";
1261 const defaultDarkThemeUri =
1262 typeof rawBody.defaultDarkThemeUri === "string" ? rawBody.defaultDarkThemeUri : "";
1263 // Checkbox: present with value "on" when checked, absent when unchecked
1264 const allowUserChoice = rawBody.allowUserChoice === "on";
1265
1266 // availableThemes may be a single string, an array, or absent
1267 const rawAvailable = rawBody.availableThemes;
1268 const availableThemes =
1269 rawAvailable === undefined
1270 ? []
1271 : Array.isArray(rawAvailable)
1272 ? rawAvailable.filter((v): v is string => typeof v === "string")
1273 : typeof rawAvailable === "string"
1274 ? [rawAvailable]
1275 : [];
1276
1277 let apiRes: Response;
1278 try {
1279 apiRes = await fetch(`${appviewUrl}/api/admin/theme-policy`, {
1280 method: "PUT",
1281 headers: { "Content-Type": "application/json", Cookie: cookie },
1282 body: JSON.stringify({
1283 defaultLightThemeUri,
1284 defaultDarkThemeUri,
1285 allowUserChoice,
1286 availableThemes: availableThemes.map((uri) => ({ uri })),
1287 }),
1288 });
1289 } catch (error) {
1290 if (isProgrammingError(error)) throw error;
1291 logger.error("Network error updating theme policy", {
1292 operation: "POST /admin/theme-policy",
1293 error: error instanceof Error ? error.message : String(error),
1294 });
1295 return c.redirect(
1296 `/admin/themes?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`,
1297 302
1298 );
1299 }
1300
1301 if (!apiRes.ok) {
1302 const msg = await extractAppviewError(apiRes, "Failed to update theme policy. Please try again.");
1303 return c.redirect(`/admin/themes?error=${encodeURIComponent(msg)}`, 302);
1304 }
1305
1306 return c.redirect("/admin/themes", 302);
1307 });
1308
1309 // ── POST /admin/themes/:rkey/preview ─────────────────────────────────────
1310
1311 app.post("/admin/themes/:rkey/preview", async (c) => {
1312 const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie"));
1313 if (!auth.authenticated) return c.redirect("/login");
1314 if (!canManageThemes(auth)) {
1315 return c.html("", 403);
1316 }
1317
1318 let rawBody: Record<string, string | File>;
1319 try {
1320 rawBody = await c.req.parseBody();
1321 } catch (error) {
1322 if (isProgrammingError(error)) throw error;
1323 // Return empty preview on parse error — don't break the HTMX swap
1324 return c.html(<ThemePreviewContent tokens={{}} />);
1325 }
1326
1327 // Only accept known token names — ignore metadata fields like name/colorScheme
1328 const tokens: Record<string, string> = {};
1329 for (const tokenName of ALL_KNOWN_TOKENS) {
1330 const raw = rawBody[tokenName];
1331 if (typeof raw !== "string") continue;
1332 const safe = sanitizeTokenValue(raw);
1333 if (safe !== null) {
1334 tokens[tokenName] = safe;
1335 }
1336 }
1337
1338 return c.html(<ThemePreviewContent tokens={tokens} />);
1339 });
1340
1341 // ── POST /admin/themes/:rkey/save ─────────────────────────────────────────
1342
1343 app.post("/admin/themes/:rkey/save", async (c) => {
1344 const resolvedTheme = c.get("theme") ?? FALLBACK_THEME;
1345 const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie"));
1346 if (!auth.authenticated) return c.redirect("/login");
1347 if (!canManageThemes(auth)) {
1348 return c.html(
1349 <BaseLayout title="Access Denied" auth={auth} resolvedTheme={resolvedTheme}>
1350 <p>Access denied.</p>
1351 </BaseLayout>,
1352 403
1353 );
1354 }
1355
1356 const themeRkey = c.req.param("rkey");
1357 const cookie = c.req.header("cookie") ?? "";
1358
1359 let rawBody: Record<string, string | File>;
1360 try {
1361 rawBody = await c.req.parseBody();
1362 } catch (error) {
1363 if (isProgrammingError(error)) throw error;
1364 return c.redirect(
1365 `/admin/themes/${themeRkey}?error=${encodeURIComponent("Invalid form submission.")}`,
1366 302
1367 );
1368 }
1369
1370 const name = typeof rawBody.name === "string" ? rawBody.name.trim() : "";
1371 if (!name) {
1372 return c.redirect(
1373 `/admin/themes/${themeRkey}?error=${encodeURIComponent("Theme name is required.")}`,
1374 302
1375 );
1376 }
1377 const colorScheme = typeof rawBody.colorScheme === "string" ? rawBody.colorScheme : "light";
1378 const fontUrlsRaw = typeof rawBody.fontUrls === "string" ? rawBody.fontUrls : "";
1379 const fontUrls = fontUrlsRaw
1380 .split("\n")
1381 .map((u) => u.trim())
1382 .filter(Boolean);
1383 const cssOverrides =
1384 typeof rawBody.cssOverrides === "string" ? rawBody.cssOverrides : undefined;
1385
1386 // Extract token values from form fields
1387 const tokens: Record<string, string> = {};
1388 for (const tokenName of ALL_KNOWN_TOKENS) {
1389 const raw = rawBody[tokenName];
1390 if (typeof raw !== "string") continue;
1391 const safe = sanitizeTokenValue(raw.trim());
1392 if (safe !== null && safe !== "") {
1393 tokens[tokenName] = safe;
1394 }
1395 }
1396
1397 let apiRes: Response;
1398 try {
1399 apiRes = await fetch(`${appviewUrl}/api/admin/themes/${themeRkey}`, {
1400 method: "PUT",
1401 headers: { "Content-Type": "application/json", Cookie: cookie },
1402 body: JSON.stringify({ name, colorScheme, tokens, fontUrls, cssOverrides }),
1403 });
1404 } catch (error) {
1405 if (isProgrammingError(error)) throw error;
1406 logger.error("Network error saving theme", {
1407 operation: "POST /admin/themes/:rkey/save",
1408 themeRkey,
1409 error: error instanceof Error ? error.message : String(error),
1410 });
1411 return c.redirect(
1412 `/admin/themes/${themeRkey}?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`,
1413 302
1414 );
1415 }
1416
1417 if (!apiRes.ok) {
1418 const msg = await extractAppviewError(apiRes, "Failed to save theme. Please try again.");
1419 return c.redirect(
1420 `/admin/themes/${themeRkey}?error=${encodeURIComponent(msg)}`,
1421 302
1422 );
1423 }
1424
1425 return c.redirect(`/admin/themes/${themeRkey}?success=1`, 302);
1426 });
1427
1428 // ── POST /admin/themes/:rkey/reset-to-preset ──────────────────────────────
1429
1430 app.post("/admin/themes/:rkey/reset-to-preset", async (c) => {
1431 const resolvedTheme = c.get("theme") ?? FALLBACK_THEME;
1432 const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie"));
1433 if (!auth.authenticated) return c.redirect("/login");
1434 if (!canManageThemes(auth)) {
1435 return c.html(
1436 <BaseLayout title="Access Denied" auth={auth} resolvedTheme={resolvedTheme}>
1437 <p>Access denied.</p>
1438 </BaseLayout>,
1439 403
1440 );
1441 }
1442
1443 const themeRkey = c.req.param("rkey");
1444
1445 let body: Record<string, string | File>;
1446 try {
1447 body = await c.req.parseBody();
1448 } catch (error) {
1449 if (isProgrammingError(error)) throw error;
1450 return c.redirect(
1451 `/admin/themes/${themeRkey}?error=${encodeURIComponent("Invalid form submission.")}`,
1452 302
1453 );
1454 }
1455
1456 const preset = typeof body.preset === "string" ? body.preset : "";
1457 if (!(preset in THEME_PRESETS)) {
1458 return c.redirect(
1459 `/admin/themes/${themeRkey}?error=${encodeURIComponent("Invalid preset name.")}`,
1460 302
1461 );
1462 }
1463
1464 return c.redirect(`/admin/themes/${themeRkey}?preset=${encodeURIComponent(preset)}`, 302);
1465 });
1466
1467 return app;
1468}
1469