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 settings page with light/dark theme preference form

+206
+2
apps/web/src/routes/index.ts
··· 10 10 import { createAuthRoutes } from "./auth.js"; 11 11 import { createModActionRoute } from "./mod.js"; 12 12 import { createAdminRoutes } from "./admin.js"; 13 + import { createSettingsRoutes } from "./settings.js"; 13 14 import { createNotFoundRoute } from "./not-found.js"; 14 15 15 16 const config = loadConfig(); ··· 24 25 .route("/", createAuthRoutes(config.appviewUrl)) 25 26 .route("/", createModActionRoute(config.appviewUrl)) 26 27 .route("/", createAdminRoutes(config.appviewUrl)) 28 + .route("/", createSettingsRoutes(config.appviewUrl)) 27 29 .route("/", createNotFoundRoute(config.appviewUrl));
+204
apps/web/src/routes/settings.tsx
··· 1 + import { Hono } from "hono"; 2 + import { BaseLayout } from "../layouts/base.js"; 3 + import { getSession } from "../lib/session.js"; 4 + import { resolveUserThemePreference, FALLBACK_THEME } from "../lib/theme-resolution.js"; 5 + import type { WebAppEnv } from "../lib/theme-resolution.js"; 6 + import { isProgrammingError } from "../lib/errors.js"; 7 + import { logger } from "../lib/logger.js"; 8 + 9 + type ThemeSummary = { uri: string; name: string; colorScheme: string }; 10 + type Policy = { 11 + availableThemes: { uri: string }[]; 12 + allowUserChoice: boolean; 13 + defaultLightThemeUri: string | null; 14 + defaultDarkThemeUri: string | null; 15 + }; 16 + 17 + export function createSettingsRoutes(appviewUrl: string) { 18 + const app = new Hono<WebAppEnv>(); 19 + 20 + // ── GET /settings ────────────────────────────────────────────────────────── 21 + app.get("/settings", async (c) => { 22 + const resolvedTheme = c.get("theme") ?? FALLBACK_THEME; 23 + const cookieHeader = c.req.header("cookie"); 24 + const auth = await getSession(appviewUrl, cookieHeader); 25 + if (!auth.authenticated) return c.redirect("/login"); 26 + 27 + const saved = c.req.query("saved") === "1"; 28 + const errorParam = c.req.query("error"); 29 + 30 + // Fetch theme policy 31 + let policy: Policy | null = null; 32 + try { 33 + const policyRes = await fetch(`${appviewUrl}/api/theme-policy`); 34 + if (policyRes.ok) { 35 + policy = (await policyRes.json()) as Policy; 36 + } 37 + } catch (error) { 38 + if (isProgrammingError(error)) throw error; 39 + logger.error("Failed to fetch theme policy for settings page", { 40 + operation: "GET /settings", 41 + error: error instanceof Error ? error.message : String(error), 42 + }); 43 + } 44 + 45 + if (!policy) { 46 + return c.html( 47 + <BaseLayout title="Settings — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 48 + <main class="settings-page"> 49 + <h1>Settings</h1> 50 + <p class="settings-banner settings-banner--error"> 51 + Theme settings are temporarily unavailable. Please try again later. 52 + </p> 53 + </main> 54 + </BaseLayout> 55 + ); 56 + } 57 + 58 + // Fetch available themes list (already filtered by policy server-side in AppView) 59 + let allThemes: ThemeSummary[] = []; 60 + try { 61 + const themesRes = await fetch(`${appviewUrl}/api/themes`); 62 + if (themesRes.ok) { 63 + const data = (await themesRes.json()) as { themes: ThemeSummary[] }; 64 + allThemes = data.themes ?? []; 65 + } 66 + } catch (err) { 67 + if (isProgrammingError(err)) throw err; 68 + logger.warn("Failed to fetch themes list for settings page", { 69 + operation: "GET /settings", 70 + error: err instanceof Error ? err.message : String(err), 71 + }); 72 + } 73 + 74 + const lightThemes = allThemes.filter((t) => t.colorScheme === "light"); 75 + const darkThemes = allThemes.filter((t) => t.colorScheme === "dark"); 76 + 77 + // Pre-select current preference cookie (Phase 1), falling back to forum default 78 + const currentLightUri = 79 + resolveUserThemePreference(cookieHeader, "light", allThemes, policy.allowUserChoice) ?? 80 + policy.defaultLightThemeUri; 81 + const currentDarkUri = 82 + resolveUserThemePreference(cookieHeader, "dark", allThemes, policy.allowUserChoice) ?? 83 + policy.defaultDarkThemeUri; 84 + 85 + return c.html( 86 + <BaseLayout title="Settings — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 87 + <main class="settings-page"> 88 + <h1>Settings</h1> 89 + {saved && ( 90 + <p class="settings-banner settings-banner--success">Preferences saved.</p> 91 + )} 92 + {errorParam && ( 93 + <p class="settings-banner settings-banner--error"> 94 + {decodeURIComponent(errorParam)} 95 + </p> 96 + )} 97 + <section> 98 + <h2>Appearance</h2> 99 + {policy.allowUserChoice ? ( 100 + <form method="post" action="/settings/appearance" class="settings-form"> 101 + <div class="settings-form__field"> 102 + <label for="lightThemeUri">Light theme</label> 103 + <select id="lightThemeUri" name="lightThemeUri"> 104 + {lightThemes.map((t) => ( 105 + <option value={t.uri} selected={t.uri === currentLightUri}> 106 + {t.name} 107 + </option> 108 + ))} 109 + </select> 110 + </div> 111 + <div class="settings-form__field"> 112 + <label for="darkThemeUri">Dark theme</label> 113 + <select id="darkThemeUri" name="darkThemeUri"> 114 + {darkThemes.map((t) => ( 115 + <option value={t.uri} selected={t.uri === currentDarkUri}> 116 + {t.name} 117 + </option> 118 + ))} 119 + </select> 120 + </div> 121 + <div id="theme-preview"></div> 122 + <button type="submit" class="settings-form__submit"> 123 + Save preferences 124 + </button> 125 + </form> 126 + ) : ( 127 + <p class="settings-banner"> 128 + Theme selection is managed by the forum administrator. 129 + </p> 130 + )} 131 + </section> 132 + </main> 133 + </BaseLayout> 134 + ); 135 + }); 136 + 137 + // ── POST /settings/appearance ─────────────────────────────────────────────── 138 + app.post("/settings/appearance", async (c) => { 139 + const cookieHeader = c.req.header("cookie"); 140 + const auth = await getSession(appviewUrl, cookieHeader); 141 + if (!auth.authenticated) return c.redirect("/login"); 142 + 143 + let body: Record<string, string | File>; 144 + try { 145 + body = await c.req.parseBody(); 146 + } catch (err) { 147 + if (isProgrammingError(err)) throw err; 148 + return c.redirect("/settings?error=invalid", 302); 149 + } 150 + 151 + const lightThemeUri = 152 + typeof body.lightThemeUri === "string" ? body.lightThemeUri.trim() : ""; 153 + const darkThemeUri = 154 + typeof body.darkThemeUri === "string" ? body.darkThemeUri.trim() : ""; 155 + if (!lightThemeUri || !darkThemeUri) { 156 + return c.redirect("/settings?error=invalid", 302); 157 + } 158 + 159 + // Fetch FRESH policy (bypass cache) so recently-removed themes can't be saved 160 + let policy: { availableThemes: Array<{ uri: string }>; allowUserChoice: boolean } | null = 161 + null; 162 + try { 163 + const policyRes = await fetch(`${appviewUrl}/api/theme-policy`); 164 + if (policyRes.ok) { 165 + policy = (await policyRes.json()) as { availableThemes: Array<{ uri: string }>; allowUserChoice: boolean }; 166 + } 167 + } catch (err) { 168 + if (isProgrammingError(err)) throw err; 169 + logger.error("Failed to fetch theme policy during preference save", { 170 + operation: "POST /settings/appearance", 171 + error: err instanceof Error ? err.message : String(err), 172 + }); 173 + } 174 + 175 + if (!policy) { 176 + return c.redirect("/settings?error=unavailable", 302); 177 + } 178 + if (!policy.allowUserChoice) { 179 + return c.redirect("/settings?error=not-allowed", 302); 180 + } 181 + 182 + const availableUris = policy.availableThemes.map((t: { uri: string }) => t.uri); 183 + if (!availableUris.includes(lightThemeUri) || !availableUris.includes(darkThemeUri)) { 184 + return c.redirect("/settings?error=invalid-theme", 302); 185 + } 186 + 187 + // Set preference cookies (1 year). 188 + // AT URIs (at://did:plc:.../rkey) are valid cookie values per RFC 6265 — 189 + // colons and slashes are permitted; no encoding needed. 190 + const headers = new Headers(); 191 + headers.append( 192 + "set-cookie", 193 + `atbb-light-theme=${lightThemeUri}; Path=/; Max-Age=31536000; SameSite=Lax` 194 + ); 195 + headers.append( 196 + "set-cookie", 197 + `atbb-dark-theme=${darkThemeUri}; Path=/; Max-Age=31536000; SameSite=Lax` 198 + ); 199 + headers.set("location", "/settings?saved=1"); 200 + return new Response(null, { status: 302, headers }); 201 + }); 202 + 203 + return app; 204 + }