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
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 1469 lines 56 kB view raw
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&apos;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 &quot;{theme.name}&quot;? 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&apos;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&apos;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