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: add HTMX theme preview endpoint and wire up select elements

+81 -3
+81 -3
apps/web/src/routes/settings.tsx
··· 1 1 import { Hono } from "hono"; 2 2 import { BaseLayout } from "../layouts/base.js"; 3 3 import { getSession } from "../lib/session.js"; 4 - import { resolveUserThemePreference, FALLBACK_THEME } from "../lib/theme-resolution.js"; 4 + import { 5 + resolveUserThemePreference, 6 + FALLBACK_THEME, 7 + parseRkeyFromUri, 8 + } from "../lib/theme-resolution.js"; 5 9 import type { WebAppEnv } from "../lib/theme-resolution.js"; 6 10 import { isProgrammingError } from "../lib/errors.js"; 7 11 import { logger } from "../lib/logger.js"; ··· 110 114 <form method="post" action="/settings/appearance" class="settings-form"> 111 115 <div class="settings-form__field"> 112 116 <label for="lightThemeUri">Light theme</label> 113 - <select id="lightThemeUri" name="lightThemeUri"> 117 + <select 118 + id="lightThemeUri" 119 + name="lightThemeUri" 120 + hx-get="/settings/preview" 121 + hx-trigger="change" 122 + hx-target="#theme-preview" 123 + hx-swap="outerHTML" 124 + hx-include="this"> 114 125 {lightThemes.map((t) => ( 115 126 <option value={t.uri} selected={t.uri === currentLightUri}> 116 127 {t.name} ··· 120 131 </div> 121 132 <div class="settings-form__field"> 122 133 <label for="darkThemeUri">Dark theme</label> 123 - <select id="darkThemeUri" name="darkThemeUri"> 134 + <select 135 + id="darkThemeUri" 136 + name="darkThemeUri" 137 + hx-get="/settings/preview" 138 + hx-trigger="change" 139 + hx-target="#theme-preview" 140 + hx-swap="outerHTML" 141 + hx-include="this"> 124 142 {darkThemes.map((t) => ( 125 143 <option value={t.uri} selected={t.uri === currentDarkUri}> 126 144 {t.name} ··· 208 226 ); 209 227 headers.set("location", "/settings?saved=1"); 210 228 return new Response(null, { status: 302, headers }); 229 + }); 230 + 231 + // ── ThemeSwatchPreview component ──────────────────────────────────────────── 232 + function ThemeSwatchPreview({ 233 + name, 234 + tokens, 235 + }: { 236 + name: string; 237 + tokens: Record<string, string>; 238 + }) { 239 + const swatchTokens = [ 240 + "color-bg", 241 + "color-surface", 242 + "color-primary", 243 + "color-text", 244 + "color-border", 245 + ] as const; 246 + return ( 247 + <div id="theme-preview" class="theme-preview"> 248 + <span class="theme-preview__name">{name}</span> 249 + <div class="theme-preview__swatches"> 250 + {swatchTokens.map((token) => { 251 + const color = tokens[token]; 252 + if (!color) return null; 253 + return ( 254 + <span 255 + class="theme-preview__swatch" 256 + style={`background:${color}`} 257 + title={token} 258 + /> 259 + ); 260 + })} 261 + </div> 262 + </div> 263 + ); 264 + } 265 + 266 + // ── GET /settings/preview ──────────────────────────────────────────────────── 267 + app.get("/settings/preview", async (c) => { 268 + const emptyFragment = <div id="theme-preview"></div>; 269 + const themeUri = c.req.query("lightThemeUri") ?? c.req.query("darkThemeUri"); 270 + if (!themeUri) return c.html(emptyFragment); 271 + 272 + const rkey = parseRkeyFromUri(themeUri); 273 + if (!rkey || !/^[a-z0-9-]+$/i.test(rkey)) return c.html(emptyFragment); 274 + 275 + try { 276 + const res = await fetch(`${appviewUrl}/api/themes/${rkey}`); 277 + if (!res.ok) return c.html(emptyFragment); 278 + const theme = (await res.json()) as { 279 + name: string; 280 + tokens: Record<string, string>; 281 + }; 282 + return c.html( 283 + <ThemeSwatchPreview name={theme.name ?? ""} tokens={theme.tokens ?? {}} /> 284 + ); 285 + } catch (err) { 286 + if (isProgrammingError(err)) throw err; 287 + return c.html(emptyFragment); 288 + } 211 289 }); 212 290 213 291 return app;