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

feat(web): GET /admin/themes/:rkey token editor page + fix Edit button (ATB-59)

+380 -2
+380 -2
apps/web/src/routes/admin-themes.tsx
··· 7 7 } from "../lib/session.js"; 8 8 import { isProgrammingError } from "../lib/errors.js"; 9 9 import { logger } from "../lib/logger.js"; 10 + import { tokensToCss } from "../lib/theme.js"; 10 11 import neobrutalLight from "../styles/presets/neobrutal-light.json"; 11 12 import neobrutalDark from "../styles/presets/neobrutal-dark.json"; 12 13 ··· 87 88 } 88 89 } 89 90 91 + /** Drop token values that could break the CSS style block. */ 92 + function sanitizeTokenValue(value: unknown): string | null { 93 + if (typeof value !== "string") return null; 94 + if (value.includes("<") || value.includes(";") || value.includes("</")) return null; 95 + return value; 96 + } 97 + 98 + // ─── JSX Components ───────────────────────────────────────────────────────── 99 + 100 + function ColorTokenInput({ name, value }: { name: string; value: string }) { 101 + const safeValue = 102 + !value.startsWith("var(") && !value.includes(";") && !value.includes("<") 103 + ? value 104 + : "#cccccc"; 105 + return ( 106 + <div class="token-input token-input--color"> 107 + <label for={`token-${name}`}>{name}</label> 108 + <div class="token-input__controls"> 109 + <input 110 + type="color" 111 + value={safeValue} 112 + aria-label={`${name} color picker`} 113 + oninput="this.nextElementSibling.value=this.value;this.nextElementSibling.dispatchEvent(new Event('change',{bubbles:true}))" 114 + /> 115 + <input 116 + type="text" 117 + id={`token-${name}`} 118 + name={name} 119 + value={safeValue} 120 + oninput="if(/^#[0-9a-fA-F]{6}$/.test(this.value))this.previousElementSibling.value=this.value" 121 + /> 122 + </div> 123 + </div> 124 + ); 125 + } 126 + 127 + function TextTokenInput({ name, value }: { name: string; value: string }) { 128 + return ( 129 + <div class="token-input"> 130 + <label for={`token-${name}`}>{name}</label> 131 + <input type="text" id={`token-${name}`} name={name} value={value} /> 132 + </div> 133 + ); 134 + } 135 + 136 + function TokenFieldset({ 137 + legend, 138 + tokens, 139 + effectiveTokens, 140 + isColor, 141 + }: { 142 + legend: string; 143 + tokens: readonly string[]; 144 + effectiveTokens: Record<string, string>; 145 + isColor: boolean; 146 + }) { 147 + return ( 148 + <fieldset class="token-group"> 149 + <legend>{legend}</legend> 150 + {tokens.map((name) => 151 + isColor ? ( 152 + <ColorTokenInput name={name} value={effectiveTokens[name] ?? ""} /> 153 + ) : ( 154 + <TextTokenInput name={name} value={effectiveTokens[name] ?? ""} /> 155 + ) 156 + )} 157 + </fieldset> 158 + ); 159 + } 160 + 161 + function ThemePreviewContent({ tokens }: { tokens: Record<string, string> }) { 162 + const css = tokensToCss(tokens); 163 + return ( 164 + <> 165 + <style>{`.preview-pane-inner{${css}}`}</style> 166 + <div class="preview-pane-inner"> 167 + <div 168 + 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);" 169 + role="navigation" 170 + aria-label="Preview navigation" 171 + > 172 + atBB Forum Preview 173 + </div> 174 + <div style="padding:var(--space-md);"> 175 + <div 176 + 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);" 177 + > 178 + <h2 179 + 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;" 180 + > 181 + Sample Thread Title 182 + </h2> 183 + <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;"> 184 + Body text showing font, color, and spacing at work.{" "} 185 + <a href="#" style="color:var(--color-primary);">A sample link</a> 186 + </p> 187 + <pre 188 + 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;" 189 + > 190 + {`const greeting = "hello forum";`} 191 + </pre> 192 + <input 193 + type="text" 194 + placeholder="Reply…" 195 + 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);" 196 + /> 197 + <div style="display:flex;gap:var(--space-sm);flex-wrap:wrap;"> 198 + <button 199 + type="button" 200 + 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;" 201 + > 202 + Post Reply 203 + </button> 204 + <button 205 + type="button" 206 + 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;" 207 + > 208 + Cancel 209 + </button> 210 + <span 211 + 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);" 212 + > 213 + success 214 + </span> 215 + <span 216 + 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);" 217 + > 218 + warning 219 + </span> 220 + <span 221 + 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);" 222 + > 223 + danger 224 + </span> 225 + </div> 226 + </div> 227 + </div> 228 + </div> 229 + </> 230 + ); 231 + } 232 + 233 + // Suppress unused warning — sanitizeTokenValue will be used in Task 6 (preview endpoint) 234 + void sanitizeTokenValue; 235 + 90 236 // ─── Route Factory ────────────────────────────────────────────────────────── 91 237 92 238 export function createAdminThemeRoutes(appviewUrl: string) { ··· 227 373 </div> 228 374 229 375 <div class="structure-item__actions"> 230 - <span class="btn btn-secondary btn-sm" aria-disabled="true"> 376 + <a href={`/admin/themes/${themeRkey}`} class="btn btn-secondary btn-sm"> 231 377 Edit 232 - </span> 378 + </a> 233 379 234 380 <form 235 381 method="post" ··· 370 516 </button> 371 517 </form> 372 518 </details> 519 + </BaseLayout> 520 + ); 521 + }); 522 + 523 + // ── GET /admin/themes/:rkey ──────────────────────────────────────────────── 524 + 525 + app.get("/admin/themes/:rkey", async (c) => { 526 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 527 + if (!auth.authenticated) return c.redirect("/login"); 528 + if (!canManageThemes(auth)) { 529 + return c.html( 530 + <BaseLayout title="Access Denied — atBB Admin" auth={auth}> 531 + <PageHeader title="Access Denied" /> 532 + <p>You don&apos;t have permission to manage themes.</p> 533 + </BaseLayout>, 534 + 403 535 + ); 536 + } 537 + 538 + const themeRkey = c.req.param("rkey"); 539 + const cookie = c.req.header("cookie") ?? ""; 540 + const presetParam = c.req.query("preset") ?? null; 541 + const successMsg = c.req.query("success") === "1" ? "Theme saved successfully." : null; 542 + const errorMsg = c.req.query("error") ?? null; 543 + 544 + // Fetch theme from AppView 545 + let theme: AdminThemeEntry | null = null; 546 + try { 547 + const res = await fetch(`${appviewUrl}/api/admin/themes/${themeRkey}`, { 548 + headers: { Cookie: cookie }, 549 + }); 550 + if (res.status === 404) { 551 + return c.html( 552 + <BaseLayout title="Theme Not Found — atBB Admin" auth={auth}> 553 + <PageHeader title="Theme Not Found" /> 554 + <p>This theme does not exist.</p> 555 + <a href="/admin/themes" class="btn btn-secondary">← Back to themes</a> 556 + </BaseLayout>, 557 + 404 558 + ); 559 + } 560 + if (res.ok) { 561 + try { 562 + theme = (await res.json()) as AdminThemeEntry; 563 + } catch { 564 + logger.error("Failed to parse theme response", { 565 + operation: "GET /admin/themes/:rkey", 566 + themeRkey, 567 + }); 568 + } 569 + } else { 570 + logger.error("AppView returned error loading theme", { 571 + operation: "GET /admin/themes/:rkey", 572 + themeRkey, 573 + status: res.status, 574 + }); 575 + } 576 + } catch (error) { 577 + if (isProgrammingError(error)) throw error; 578 + logger.error("Network error loading theme", { 579 + operation: "GET /admin/themes/:rkey", 580 + themeRkey, 581 + error: error instanceof Error ? error.message : String(error), 582 + }); 583 + } 584 + 585 + if (!theme) { 586 + return c.html( 587 + <BaseLayout title="Theme Unavailable — atBB Admin" auth={auth}> 588 + <PageHeader title="Theme Unavailable" /> 589 + <p>Unable to load theme data. Please try again.</p> 590 + <a href="/admin/themes" class="btn btn-secondary">← Back to themes</a> 591 + </BaseLayout>, 592 + 500 593 + ); 594 + } 595 + 596 + // If ?preset is set, override DB tokens with preset tokens 597 + const presetTokens = presetParam ? (THEME_PRESETS[presetParam] ?? null) : null; 598 + const effectiveTokens: Record<string, string> = presetTokens 599 + ? { ...theme.tokens, ...presetTokens } 600 + : { ...theme.tokens }; 601 + 602 + const fontUrlsText = (theme.fontUrls ?? []).join("\n"); 603 + 604 + return c.html( 605 + <BaseLayout title={`Edit Theme: ${theme.name} — atBB Admin`} auth={auth}> 606 + <PageHeader title={`Edit Theme: ${theme.name}`} /> 607 + 608 + {successMsg && <div class="structure-success-banner">{successMsg}</div>} 609 + {errorMsg && <div class="structure-error-banner">{errorMsg}</div>} 610 + 611 + <a href="/admin/themes" class="btn btn-secondary btn-sm" style="margin-bottom: var(--space-md); display: inline-block;"> 612 + ← Back to themes 613 + </a> 614 + 615 + {/* Metadata + tokens form */} 616 + <form 617 + id="editor-form" 618 + method="post" 619 + action={`/admin/themes/${themeRkey}/save`} 620 + class="theme-editor" 621 + > 622 + {/* Metadata */} 623 + <fieldset class="token-group"> 624 + <legend>Theme Metadata</legend> 625 + <div class="token-input"> 626 + <label for="theme-name">Name</label> 627 + <input type="text" id="theme-name" name="name" value={theme.name} required /> 628 + </div> 629 + <div class="token-input"> 630 + <label for="theme-scheme">Color Scheme</label> 631 + <select id="theme-scheme" name="colorScheme"> 632 + <option value="light" selected={theme.colorScheme === "light" ? true : undefined}>Light</option> 633 + <option value="dark" selected={theme.colorScheme === "dark" ? true : undefined}>Dark</option> 634 + </select> 635 + </div> 636 + <div class="token-input"> 637 + <label for="theme-font-urls">Font URLs (one per line)</label> 638 + <textarea id="theme-font-urls" name="fontUrls" rows={3} placeholder="https://fonts.googleapis.com/css2?family=..."> 639 + {fontUrlsText} 640 + </textarea> 641 + </div> 642 + </fieldset> 643 + 644 + {/* Token editor + live preview layout */} 645 + <div class="theme-editor__layout"> 646 + {/* Left: token controls */} 647 + <div 648 + class="theme-editor__controls" 649 + hx-post={`/admin/themes/${themeRkey}/preview`} 650 + hx-trigger="input delay:500ms" 651 + hx-target="#preview-pane" 652 + hx-include="#editor-form" 653 + > 654 + <TokenFieldset 655 + legend="Colors" 656 + tokens={COLOR_TOKENS} 657 + effectiveTokens={effectiveTokens} 658 + isColor={true} 659 + /> 660 + <TokenFieldset 661 + legend="Typography" 662 + tokens={TYPOGRAPHY_TOKENS} 663 + effectiveTokens={effectiveTokens} 664 + isColor={false} 665 + /> 666 + <TokenFieldset 667 + legend="Spacing & Layout" 668 + tokens={SPACING_TOKENS} 669 + effectiveTokens={effectiveTokens} 670 + isColor={false} 671 + /> 672 + <TokenFieldset 673 + legend="Components" 674 + tokens={COMPONENT_TOKENS} 675 + effectiveTokens={effectiveTokens} 676 + isColor={false} 677 + /> 678 + 679 + {/* CSS overrides — disabled until ATB-62 */} 680 + <fieldset class="token-group"> 681 + <legend>CSS Overrides</legend> 682 + <div class="token-input"> 683 + <label for="css-overrides"> 684 + Custom CSS{" "} 685 + <span class="form-hint">(disabled — CSS sanitization not yet implemented)</span> 686 + </label> 687 + <textarea 688 + id="css-overrides" 689 + name="css-overrides" 690 + rows={6} 691 + disabled 692 + aria-describedby="css-overrides-hint" 693 + placeholder="/* Will be enabled in ATB-62 */" 694 + > 695 + {theme.cssOverrides ?? ""} 696 + </textarea> 697 + <p id="css-overrides-hint" class="form-hint"> 698 + Raw CSS overrides will be available after CSS sanitization is implemented (ATB-62). 699 + </p> 700 + </div> 701 + </fieldset> 702 + </div> 703 + 704 + {/* Right: live preview */} 705 + <div class="theme-editor__preview"> 706 + <h3>Live Preview</h3> 707 + <div id="preview-pane" class="preview-pane"> 708 + <ThemePreviewContent tokens={effectiveTokens} /> 709 + </div> 710 + </div> 711 + </div> 712 + 713 + {/* Actions */} 714 + <div class="theme-editor__actions"> 715 + <button type="submit" class="btn btn-primary">Save Theme</button> 716 + 717 + <button 718 + type="button" 719 + class="btn btn-secondary" 720 + onclick="document.getElementById('reset-dialog').showModal()" 721 + > 722 + Reset to Preset 723 + </button> 724 + </div> 725 + </form> 726 + 727 + {/* Reset to preset dialog */} 728 + <dialog id="reset-dialog" class="structure-confirm-dialog"> 729 + <form method="post" action={`/admin/themes/${themeRkey}/reset-to-preset`}> 730 + <p>Reset all token values to a built-in preset? Your unsaved changes will be lost.</p> 731 + <div class="form-group"> 732 + <label for="reset-preset-select">Reset to preset:</label> 733 + <select id="reset-preset-select" name="preset"> 734 + <option value="neobrutal-light">Neobrutal Light</option> 735 + <option value="neobrutal-dark">Neobrutal Dark</option> 736 + <option value="blank">Blank (empty tokens)</option> 737 + </select> 738 + </div> 739 + <div class="dialog-actions"> 740 + <button type="submit" class="btn btn-danger">Reset</button> 741 + <button 742 + type="button" 743 + class="btn btn-secondary" 744 + onclick="document.getElementById('reset-dialog').close()" 745 + > 746 + Cancel 747 + </button> 748 + </div> 749 + </form> 750 + </dialog> 373 751 </BaseLayout> 374 752 ); 375 753 });