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.

refactor(web): extract theme admin handlers into admin-themes.tsx (ATB-59)

Malpercio cc63906a 8b8cdf9b

+600
+600
apps/web/src/routes/admin-themes.tsx
··· 1 + import { Hono } from "hono"; 2 + import { BaseLayout } from "../layouts/base.js"; 3 + import { PageHeader, EmptyState } from "../components/index.js"; 4 + import { 5 + getSessionWithPermissions, 6 + canManageThemes, 7 + } from "../lib/session.js"; 8 + import { isProgrammingError } from "../lib/errors.js"; 9 + import { logger } from "../lib/logger.js"; 10 + import neobrutalLight from "../styles/presets/neobrutal-light.json"; 11 + import neobrutalDark from "../styles/presets/neobrutal-dark.json"; 12 + 13 + // ─── Types ───────────────────────────────────────────────────────────────── 14 + 15 + interface AdminThemeEntry { 16 + id: string; 17 + uri: string; 18 + name: string; 19 + colorScheme: string; 20 + tokens: Record<string, string>; 21 + cssOverrides: string | null; 22 + fontUrls: string[] | null; 23 + createdAt: string; 24 + indexedAt: string; 25 + } 26 + 27 + interface ThemePolicy { 28 + defaultLightThemeUri: string | null; 29 + defaultDarkThemeUri: string | null; 30 + allowUserChoice: boolean; 31 + availableThemes: Array<{ uri: string; cid: string }>; 32 + } 33 + 34 + // Preset token maps — used by POST /admin/themes to seed tokens on creation 35 + const THEME_PRESETS: Record<string, Record<string, string>> = { 36 + "neobrutal-light": neobrutalLight as Record<string, string>, 37 + "neobrutal-dark": neobrutalDark as Record<string, string>, 38 + "blank": {}, 39 + }; 40 + 41 + // ─── Token Group Constants ────────────────────────────────────────────────── 42 + 43 + export const COLOR_TOKENS = [ 44 + "color-bg", "color-surface", "color-text", "color-text-muted", 45 + "color-primary", "color-primary-hover", "color-secondary", "color-border", 46 + "color-shadow", "color-success", "color-warning", "color-danger", 47 + "color-code-bg", "color-code-text", 48 + ] as const; 49 + 50 + export const TYPOGRAPHY_TOKENS = [ 51 + "font-body", "font-heading", "font-mono", 52 + "font-size-base", "font-size-sm", "font-size-xs", "font-size-lg", 53 + "font-size-xl", "font-size-2xl", 54 + "font-weight-normal", "font-weight-bold", 55 + "line-height-body", "line-height-heading", 56 + ] as const; 57 + 58 + export const SPACING_TOKENS = [ 59 + "space-xs", "space-sm", "space-md", "space-lg", "space-xl", 60 + "radius", "border-width", "shadow-offset", "content-width", 61 + ] as const; 62 + 63 + export const COMPONENT_TOKENS = [ 64 + "button-radius", "button-shadow", 65 + "card-radius", "card-shadow", 66 + "btn-press-hover", "btn-press-active", 67 + "input-radius", "input-border", 68 + "nav-height", 69 + ] as const; 70 + 71 + export const ALL_KNOWN_TOKENS: readonly string[] = [ 72 + ...COLOR_TOKENS, ...TYPOGRAPHY_TOKENS, ...SPACING_TOKENS, ...COMPONENT_TOKENS, 73 + ]; 74 + 75 + // ─── Helpers ──────────────────────────────────────────────────────────────── 76 + 77 + /** 78 + * Extracts the error message from an AppView error response. 79 + * Falls back to the provided default if JSON parsing fails. 80 + */ 81 + async function extractAppviewError(res: Response, fallback: string): Promise<string> { 82 + try { 83 + const data = (await res.json()) as { error?: string }; 84 + return data.error ?? fallback; 85 + } catch { 86 + return fallback; 87 + } 88 + } 89 + 90 + // ─── Route Factory ────────────────────────────────────────────────────────── 91 + 92 + export function createAdminThemeRoutes(appviewUrl: string) { 93 + const app = new Hono(); 94 + 95 + // ── GET /admin/themes ────────────────────────────────────────────────────── 96 + 97 + app.get("/admin/themes", async (c) => { 98 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 99 + 100 + if (!auth.authenticated) { 101 + return c.redirect("/login"); 102 + } 103 + 104 + if (!canManageThemes(auth)) { 105 + return c.html( 106 + <BaseLayout title="Access Denied — atBB Forum" auth={auth}> 107 + <PageHeader title="Themes" /> 108 + <p>You don&apos;t have permission to manage themes.</p> 109 + </BaseLayout>, 110 + 403 111 + ); 112 + } 113 + 114 + const cookie = c.req.header("cookie") ?? ""; 115 + const errorMsg = c.req.query("error") ?? null; 116 + 117 + let adminThemes: AdminThemeEntry[] = []; 118 + let policy: ThemePolicy | null = null; 119 + 120 + try { 121 + const [themesRes, policyRes] = await Promise.all([ 122 + fetch(`${appviewUrl}/api/admin/themes`, { headers: { Cookie: cookie } }), 123 + fetch(`${appviewUrl}/api/theme-policy`, { headers: { Cookie: cookie } }), 124 + ]); 125 + 126 + if (themesRes.ok) { 127 + try { 128 + const data = (await themesRes.json()) as { themes: AdminThemeEntry[] }; 129 + adminThemes = data.themes; 130 + } catch { 131 + logger.error("Failed to parse admin themes response", { 132 + operation: "GET /admin/themes", 133 + status: themesRes.status, 134 + }); 135 + } 136 + } else { 137 + logger.error("Failed to fetch admin themes list", { 138 + operation: "GET /admin/themes", 139 + status: themesRes.status, 140 + }); 141 + } 142 + 143 + if (policyRes.ok) { 144 + try { 145 + policy = (await policyRes.json()) as ThemePolicy; 146 + } catch { 147 + logger.error("Failed to parse theme policy response", { 148 + operation: "GET /admin/themes", 149 + status: policyRes.status, 150 + }); 151 + } 152 + } else if (policyRes.status !== 404) { 153 + logger.error("Failed to fetch theme policy", { 154 + operation: "GET /admin/themes", 155 + status: policyRes.status, 156 + }); 157 + } 158 + // 404 = no policy yet — render page with empty policy (not an error) 159 + } catch (error) { 160 + if (isProgrammingError(error)) throw error; 161 + logger.error("Network error fetching themes data", { 162 + operation: "GET /admin/themes", 163 + error: error instanceof Error ? error.message : String(error), 164 + }); 165 + } 166 + 167 + const availableUris = new Set((policy?.availableThemes ?? []).map((t) => t.uri)); 168 + const lightThemes = adminThemes.filter((t) => t.colorScheme === "light"); 169 + const darkThemes = adminThemes.filter((t) => t.colorScheme === "dark"); 170 + 171 + return c.html( 172 + <BaseLayout title="Themes — atBB Admin" auth={auth}> 173 + <PageHeader title="Themes" /> 174 + 175 + {errorMsg && <div class="structure-error-banner">{errorMsg}</div>} 176 + 177 + {adminThemes.length === 0 ? ( 178 + <EmptyState message="No themes yet. Create one below." /> 179 + ) : ( 180 + <div class="structure-list"> 181 + {adminThemes.map((theme) => { 182 + const themeRkey = theme.uri.split("/").pop() ?? theme.id; 183 + const dialogId = `confirm-delete-theme-${themeRkey}`; 184 + const swatchTokens = [ 185 + "color-bg", 186 + "color-surface", 187 + "color-primary", 188 + "color-secondary", 189 + "color-border", 190 + ] as const; 191 + 192 + return ( 193 + <div class="structure-item"> 194 + <div class="structure-item__header"> 195 + <div class="structure-item__title"> 196 + <label> 197 + <input 198 + type="checkbox" 199 + form="policy-form" 200 + name="availableThemes" 201 + value={theme.uri} 202 + checked={availableUris.has(theme.uri)} 203 + /> 204 + {" "} 205 + {theme.name} 206 + </label> 207 + <span class={`badge badge--${theme.colorScheme}`}> 208 + {theme.colorScheme} 209 + </span> 210 + </div> 211 + 212 + <div class="theme-swatches" aria-hidden="true"> 213 + {swatchTokens.map((token) => { 214 + const value = theme.tokens[token] ?? "#cccccc"; 215 + const safe = 216 + !value.startsWith("var(") && 217 + !value.includes(";") && 218 + !value.includes("<"); 219 + return ( 220 + <span 221 + class="theme-swatch" 222 + style={safe ? `background:${value}` : "background:#cccccc"} 223 + title={token} 224 + /> 225 + ); 226 + })} 227 + </div> 228 + 229 + <div class="structure-item__actions"> 230 + <span class="btn btn-secondary btn-sm" aria-disabled="true"> 231 + Edit 232 + </span> 233 + 234 + <form 235 + method="post" 236 + action={`/admin/themes/${themeRkey}/duplicate`} 237 + style="display:inline" 238 + > 239 + <button type="submit" class="btn btn-secondary btn-sm"> 240 + Duplicate 241 + </button> 242 + </form> 243 + 244 + <button 245 + type="button" 246 + class="btn btn-danger btn-sm" 247 + onclick={`document.getElementById('${dialogId}').showModal()`} 248 + > 249 + Delete 250 + </button> 251 + </div> 252 + </div> 253 + 254 + <dialog id={dialogId} class="structure-confirm-dialog"> 255 + <p> 256 + Delete theme &quot;{theme.name}&quot;? This cannot be undone. 257 + </p> 258 + <form 259 + method="post" 260 + action={`/admin/themes/${themeRkey}/delete`} 261 + class="dialog-actions" 262 + > 263 + <button type="submit" class="btn btn-danger"> 264 + Delete 265 + </button> 266 + <button 267 + type="button" 268 + class="btn btn-secondary" 269 + onclick={`document.getElementById('${dialogId}').close()`} 270 + > 271 + Cancel 272 + </button> 273 + </form> 274 + </dialog> 275 + </div> 276 + ); 277 + })} 278 + </div> 279 + )} 280 + 281 + {/* Policy form — availability checkboxes on cards associate via form="policy-form" */} 282 + <section class="admin-section"> 283 + <h2>Theme Policy</h2> 284 + <form id="policy-form" method="post" action="/admin/theme-policy"> 285 + <div class="form-group"> 286 + <label for="defaultLightThemeUri">Default Light Theme</label> 287 + <select id="defaultLightThemeUri" name="defaultLightThemeUri"> 288 + <option value="">— none —</option> 289 + {lightThemes.map((t) => ( 290 + <option 291 + value={t.uri} 292 + selected={policy?.defaultLightThemeUri === t.uri} 293 + > 294 + {t.name} 295 + </option> 296 + ))} 297 + </select> 298 + </div> 299 + 300 + <div class="form-group"> 301 + <label for="defaultDarkThemeUri">Default Dark Theme</label> 302 + <select id="defaultDarkThemeUri" name="defaultDarkThemeUri"> 303 + <option value="">— none —</option> 304 + {darkThemes.map((t) => ( 305 + <option 306 + value={t.uri} 307 + selected={policy?.defaultDarkThemeUri === t.uri} 308 + > 309 + {t.name} 310 + </option> 311 + ))} 312 + </select> 313 + </div> 314 + 315 + <div class="form-group"> 316 + <label> 317 + <input 318 + type="checkbox" 319 + name="allowUserChoice" 320 + checked={policy?.allowUserChoice ?? true} 321 + /> 322 + {" "}Allow users to choose their own theme 323 + </label> 324 + </div> 325 + 326 + <p class="form-hint"> 327 + Check themes above to make them available to users. 328 + </p> 329 + <button type="submit" class="btn btn-primary"> 330 + Save Policy 331 + </button> 332 + </form> 333 + </section> 334 + 335 + {/* Create new theme */} 336 + <details class="structure-add-form"> 337 + <summary class="structure-add-form__trigger">+ Create New Theme</summary> 338 + <form 339 + method="post" 340 + action="/admin/themes" 341 + class="structure-edit-form__body" 342 + > 343 + <div class="form-group"> 344 + <label for="new-theme-name">Name</label> 345 + <input 346 + id="new-theme-name" 347 + type="text" 348 + name="name" 349 + required 350 + placeholder="My Custom Theme" 351 + /> 352 + </div> 353 + <div class="form-group"> 354 + <label for="new-theme-scheme">Color Scheme</label> 355 + <select id="new-theme-scheme" name="colorScheme"> 356 + <option value="light">Light</option> 357 + <option value="dark">Dark</option> 358 + </select> 359 + </div> 360 + <div class="form-group"> 361 + <label for="new-theme-preset">Start from Preset</label> 362 + <select id="new-theme-preset" name="preset"> 363 + <option value="neobrutal-light">Neobrutal Light</option> 364 + <option value="neobrutal-dark">Neobrutal Dark</option> 365 + <option value="blank">Blank</option> 366 + </select> 367 + </div> 368 + <button type="submit" class="btn btn-primary"> 369 + Create Theme 370 + </button> 371 + </form> 372 + </details> 373 + </BaseLayout> 374 + ); 375 + }); 376 + 377 + // ── POST /admin/themes ──────────────────────────────────────────────────── 378 + 379 + app.post("/admin/themes", async (c) => { 380 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 381 + if (!auth.authenticated) return c.redirect("/login"); 382 + if (!canManageThemes(auth)) { 383 + return c.html(<BaseLayout title="Access Denied" auth={auth}><p>Access denied.</p></BaseLayout>, 403); 384 + } 385 + 386 + const cookie = c.req.header("cookie") ?? ""; 387 + 388 + let body: Record<string, string | File>; 389 + try { 390 + body = await c.req.parseBody(); 391 + } catch (error) { 392 + if (isProgrammingError(error)) throw error; 393 + return c.redirect( 394 + `/admin/themes?error=${encodeURIComponent("Invalid form submission.")}`, 395 + 302 396 + ); 397 + } 398 + 399 + const name = typeof body.name === "string" ? body.name.trim() : ""; 400 + const colorScheme = typeof body.colorScheme === "string" ? body.colorScheme : "light"; 401 + const preset = typeof body.preset === "string" ? body.preset : "blank"; 402 + 403 + if (!name) { 404 + return c.redirect( 405 + `/admin/themes?error=${encodeURIComponent("Theme name is required.")}`, 406 + 302 407 + ); 408 + } 409 + 410 + const tokens = THEME_PRESETS[preset] ?? {}; 411 + 412 + let apiRes: Response; 413 + try { 414 + apiRes = await fetch(`${appviewUrl}/api/admin/themes`, { 415 + method: "POST", 416 + headers: { "Content-Type": "application/json", Cookie: cookie }, 417 + body: JSON.stringify({ name, colorScheme, tokens }), 418 + }); 419 + } catch (error) { 420 + if (isProgrammingError(error)) throw error; 421 + logger.error("Network error creating theme", { 422 + operation: "POST /admin/themes", 423 + error: error instanceof Error ? error.message : String(error), 424 + }); 425 + return c.redirect( 426 + `/admin/themes?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`, 427 + 302 428 + ); 429 + } 430 + 431 + if (!apiRes.ok) { 432 + const msg = await extractAppviewError(apiRes, "Failed to create theme. Please try again."); 433 + return c.redirect( 434 + `/admin/themes?error=${encodeURIComponent(msg)}`, 435 + 302 436 + ); 437 + } 438 + 439 + return c.redirect("/admin/themes", 302); 440 + }); 441 + 442 + // ── POST /admin/themes/:rkey/duplicate ──────────────────────────────────── 443 + 444 + app.post("/admin/themes/:rkey/duplicate", async (c) => { 445 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 446 + if (!auth.authenticated) return c.redirect("/login"); 447 + if (!canManageThemes(auth)) { 448 + return c.html(<BaseLayout title="Access Denied" auth={auth}><p>Access denied.</p></BaseLayout>, 403); 449 + } 450 + 451 + const cookie = c.req.header("cookie") ?? ""; 452 + const themeRkey = c.req.param("rkey"); 453 + 454 + let apiRes: Response; 455 + try { 456 + apiRes = await fetch(`${appviewUrl}/api/admin/themes/${themeRkey}/duplicate`, { 457 + method: "POST", 458 + headers: { Cookie: cookie }, 459 + }); 460 + } catch (error) { 461 + if (isProgrammingError(error)) throw error; 462 + logger.error("Network error duplicating theme", { 463 + operation: "POST /admin/themes/:rkey/duplicate", 464 + themeRkey, 465 + error: error instanceof Error ? error.message : String(error), 466 + }); 467 + return c.redirect( 468 + `/admin/themes?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`, 469 + 302 470 + ); 471 + } 472 + 473 + if (!apiRes.ok) { 474 + const msg = await extractAppviewError(apiRes, "Failed to duplicate theme. Please try again."); 475 + return c.redirect(`/admin/themes?error=${encodeURIComponent(msg)}`, 302); 476 + } 477 + 478 + return c.redirect("/admin/themes", 302); 479 + }); 480 + 481 + // ── POST /admin/themes/:rkey/delete ────────────────────────────────────── 482 + 483 + app.post("/admin/themes/:rkey/delete", async (c) => { 484 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 485 + if (!auth.authenticated) return c.redirect("/login"); 486 + if (!canManageThemes(auth)) { 487 + return c.html(<BaseLayout title="Access Denied" auth={auth}><p>Access denied.</p></BaseLayout>, 403); 488 + } 489 + 490 + const cookie = c.req.header("cookie") ?? ""; 491 + const themeRkey = c.req.param("rkey"); 492 + 493 + let apiRes: Response; 494 + try { 495 + apiRes = await fetch(`${appviewUrl}/api/admin/themes/${themeRkey}`, { 496 + method: "DELETE", 497 + headers: { Cookie: cookie }, 498 + }); 499 + } catch (error) { 500 + if (isProgrammingError(error)) throw error; 501 + logger.error("Network error deleting theme", { 502 + operation: "POST /admin/themes/:rkey/delete", 503 + themeRkey, 504 + error: error instanceof Error ? error.message : String(error), 505 + }); 506 + return c.redirect( 507 + `/admin/themes?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`, 508 + 302 509 + ); 510 + } 511 + 512 + if (!apiRes.ok) { 513 + if (apiRes.status === 409) { 514 + return c.redirect( 515 + `/admin/themes?error=${encodeURIComponent("Cannot delete a theme that is currently set as a default.")}`, 516 + 302 517 + ); 518 + } 519 + const msg = await extractAppviewError(apiRes, "Failed to delete theme. Please try again."); 520 + return c.redirect(`/admin/themes?error=${encodeURIComponent(msg)}`, 302); 521 + } 522 + 523 + return c.redirect("/admin/themes", 302); 524 + }); 525 + 526 + // ── POST /admin/theme-policy ────────────────────────────────────────────── 527 + 528 + app.post("/admin/theme-policy", async (c) => { 529 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 530 + if (!auth.authenticated) return c.redirect("/login"); 531 + if (!canManageThemes(auth)) { 532 + return c.html(<BaseLayout title="Access Denied" auth={auth}><p>Access denied.</p></BaseLayout>, 403); 533 + } 534 + 535 + const cookie = c.req.header("cookie") ?? ""; 536 + 537 + let rawBody: Record<string, string | string[] | File | File[]>; 538 + try { 539 + rawBody = await c.req.parseBody({ all: true }); 540 + } catch (error) { 541 + if (isProgrammingError(error)) throw error; 542 + return c.redirect( 543 + `/admin/themes?error=${encodeURIComponent("Invalid form submission.")}`, 544 + 302 545 + ); 546 + } 547 + 548 + const defaultLightThemeUri = 549 + typeof rawBody.defaultLightThemeUri === "string" ? rawBody.defaultLightThemeUri : ""; 550 + const defaultDarkThemeUri = 551 + typeof rawBody.defaultDarkThemeUri === "string" ? rawBody.defaultDarkThemeUri : ""; 552 + // Checkbox: present with value "on" when checked, absent when unchecked 553 + const allowUserChoice = rawBody.allowUserChoice === "on"; 554 + 555 + // availableThemes may be a single string, an array, or absent 556 + const rawAvailable = rawBody.availableThemes; 557 + const availableThemes = 558 + rawAvailable === undefined 559 + ? [] 560 + : Array.isArray(rawAvailable) 561 + ? rawAvailable.filter((v): v is string => typeof v === "string") 562 + : typeof rawAvailable === "string" 563 + ? [rawAvailable] 564 + : []; 565 + 566 + let apiRes: Response; 567 + try { 568 + apiRes = await fetch(`${appviewUrl}/api/admin/theme-policy`, { 569 + method: "PUT", 570 + headers: { "Content-Type": "application/json", Cookie: cookie }, 571 + body: JSON.stringify({ 572 + defaultLightThemeUri, 573 + defaultDarkThemeUri, 574 + allowUserChoice, 575 + availableThemes: availableThemes.map((uri) => ({ uri })), 576 + }), 577 + }); 578 + } catch (error) { 579 + if (isProgrammingError(error)) throw error; 580 + logger.error("Network error updating theme policy", { 581 + operation: "POST /admin/theme-policy", 582 + error: error instanceof Error ? error.message : String(error), 583 + }); 584 + return c.redirect( 585 + `/admin/themes?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`, 586 + 302 587 + ); 588 + } 589 + 590 + if (!apiRes.ok) { 591 + const msg = await extractAppviewError(apiRes, "Failed to update theme policy. Please try again."); 592 + return c.redirect(`/admin/themes?error=${encodeURIComponent(msg)}`, 302); 593 + } 594 + 595 + return c.redirect("/admin/themes", 302); 596 + }); 597 + 598 + return app; 599 + } 600 +