🌿 Collaborative wiki on ATProto lichen.wiki
atproto
14
fork

Configure Feed

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

Wire user reader theming

juprodh e53e3937 bc8b6770

+135 -13
+16 -2
src/lib/access.ts
··· 5 5 getRequest, 6 6 getWiki, 7 7 } from "../server/db/queries/index.ts"; 8 + import { resolveUserTheme, type UserTheme } from "../views/theme/index.ts"; 8 9 import { ForbiddenError, NotFoundError } from "./errors.ts"; 9 10 import { type Locale, resolveLocale } from "./i18n/index.ts"; 10 11 ··· 60 61 did: string | null; 61 62 access: AccessLevel; 62 63 locale: Locale; 64 + userTheme: UserTheme; 63 65 /** true only when access is "none" and the user has a pending access request */ 64 66 hasPendingRequest: boolean; 65 67 } ··· 114 116 ): Promise<RequestContext> { 115 117 const session = await getSessionFromRequest(request); 116 118 const did = session?.did ?? null; 119 + const cookieHeader = request.headers.get("cookie"); 117 120 const locale = resolveLocale( 118 - request.headers.get("cookie"), 121 + cookieHeader, 119 122 request.headers.get("accept-language"), 120 123 ); 124 + const userTheme = resolveUserTheme(cookieHeader); 121 125 122 126 if (!wikiSlug) { 123 127 return { ··· 126 130 did, 127 131 access: "none", 128 132 locale, 133 + userTheme, 129 134 hasPendingRequest: false, 130 135 }; 131 136 } ··· 138 143 did, 139 144 access: "none", 140 145 locale, 146 + userTheme, 141 147 hasPendingRequest: false, 142 148 }; 143 149 } ··· 146 152 const access = getAccessLevel(wiki, did, role); 147 153 const hasPendingRequest = 148 154 access === "none" && did ? getRequest(wiki.slug, did) !== null : false; 149 - return { session, wiki, did, access, locale, hasPendingRequest }; 155 + return { 156 + session, 157 + wiki, 158 + did, 159 + access, 160 + locale, 161 + userTheme, 162 + hasPendingRequest, 163 + }; 150 164 }
+4
src/lib/i18n/en.ts
··· 4 4 nav: { 5 5 login: "Log in", 6 6 logout: "Log out", 7 + theme: "Theme", 8 + themeLight: "Light", 9 + themeDark: "Dark", 10 + themeSystem: "System", 7 11 }, 8 12 login: { 9 13 heading: "Log in to Lichen",
+4
src/lib/i18n/fr.ts
··· 4 4 nav: { 5 5 login: "Connexion", 6 6 logout: "Déconnexion", 7 + theme: "Thème", 8 + themeLight: "Clair", 9 + themeDark: "Sombre", 10 + themeSystem: "Système", 7 11 }, 8 12 login: { 9 13 heading: "Connexion à Lichen",
+4
src/lib/i18n/index.ts
··· 8 8 nav: { 9 9 login: string; 10 10 logout: string; 11 + theme: string; 12 + themeLight: string; 13 + themeDark: string; 14 + themeSystem: string; 11 15 }; 12 16 login: { 13 17 heading: string;
+2
src/server/app.ts
··· 13 13 import { noteRoutes } from "./routes/note.ts"; 14 14 import { profileRoutes } from "./routes/profile.ts"; 15 15 import { searchRoutes } from "./routes/search.ts"; 16 + import { themeRoutes } from "./routes/theme.ts"; 16 17 import { wikiRoutes } from "./routes/wiki.ts"; 17 18 18 19 // Dev and prod auth must not coexist: the dev-session fallback trusts a `did=` ··· 43 44 .use(blobRoutes) 44 45 .use(bookmarkRoutes) 45 46 .use(localeRoutes) 47 + .use(themeRoutes) 46 48 .use(profileRoutes) 47 49 .use(homeRoute) 48 50 .use(exploreRoutes)
+1
src/server/routes/explore.ts
··· 29 29 explorePage(wikis, total, languages, { 30 30 session: ctx.session, 31 31 locale: ctx.locale, 32 + userTheme: ctx.userTheme, 32 33 sort, 33 34 page, 34 35 }),
+1
src/server/routes/home.ts
··· 20 20 homePage(wikis, languages, { 21 21 session: ctx.session, 22 22 locale: ctx.locale, 23 + userTheme: ctx.userTheme, 23 24 }), 24 25 { edgeCacheSeconds: ctx.session ? 0 : 60 }, 25 26 );
+4
src/server/routes/note.ts
··· 35 35 scripts: EDITOR_SCRIPTS, 36 36 stylesheets: KATEX_STYLESHEETS, 37 37 locale: ctx.locale, 38 + userTheme: ctx.userTheme, 38 39 }), 39 40 ); 40 41 }) ··· 56 57 scripts: EDITOR_SCRIPTS, 57 58 stylesheets: KATEX_STYLESHEETS, 58 59 locale: ctx.locale, 60 + userTheme: ctx.userTheme, 59 61 error: err.message, 60 62 titleValue: fields.title, 61 63 contentValue: fields.content, ··· 85 87 scripts: EDITOR_SCRIPTS, 86 88 stylesheets: KATEX_STYLESHEETS, 87 89 locale: ctx.locale, 90 + userTheme: ctx.userTheme, 88 91 }, 89 92 ), 90 93 ); ··· 158 161 sidebarNotes, 159 162 currentNoteSlug: params.noteSlug, 160 163 locale: ctx.locale, 164 + userTheme: ctx.userTheme, 161 165 accessLevel: ctx.access, 162 166 bookmarkHtml: bmHtml, 163 167 shareHtml,
+1
src/server/routes/profile.ts
··· 39 39 profilePage(profile, ownedWikis, collaboratingWikis, bookmarks, { 40 40 session: ctx.session, 41 41 locale: ctx.locale, 42 + userTheme: ctx.userTheme, 42 43 }), 43 44 ); 44 45 })
+24
src/server/routes/theme.ts
··· 1 + import { Elysia } from "elysia"; 2 + import { USER_THEMES, type UserTheme } from "../../views/theme/index.ts"; 3 + 4 + export const themeRoutes = new Elysia().post( 5 + "/set-theme", 6 + async ({ request }) => { 7 + const formData = await request.formData(); 8 + const theme = formData.get("theme") as string | null; 9 + 10 + if (!theme || !USER_THEMES.includes(theme as UserTheme)) { 11 + return new Response("Invalid theme", { status: 400 }); 12 + } 13 + 14 + const referer = request.headers.get("referer") || "/"; 15 + 16 + return new Response(null, { 17 + status: 302, 18 + headers: { 19 + Location: referer, 20 + "Set-Cookie": `theme=${theme}; Path=/; SameSite=Lax; Max-Age=31536000`, 21 + }, 22 + }); 23 + }, 24 + );
+12 -1
src/server/routes/wiki.ts
··· 50 50 .get("/new", async ({ request }) => { 51 51 const ctx = await resolveRequestContext(request); 52 52 return htmlResponse( 53 - newWikiPage({ session: ctx.session, locale: ctx.locale }), 53 + newWikiPage({ 54 + session: ctx.session, 55 + locale: ctx.locale, 56 + userTheme: ctx.userTheme, 57 + }), 54 58 ); 55 59 }) 56 60 .post("/new", async ({ request }) => { ··· 72 76 newWikiPage({ 73 77 session: ctx.session, 74 78 locale: ctx.locale, 79 + userTheme: ctx.userTheme, 75 80 error, 76 81 nameValue: name, 77 82 languageValue: language, ··· 122 127 { 123 128 session: ctx.session, 124 129 locale: ctx.locale, 130 + userTheme: ctx.userTheme, 125 131 accessLevel: ctx.access, 126 132 wikiDid: ctx.wiki.did, 127 133 wikiDescription: ctx.wiki.description, ··· 157 163 { 158 164 session: ctx.session, 159 165 locale: ctx.locale, 166 + userTheme: ctx.userTheme, 160 167 accessLevel: ctx.access, 161 168 wikiDid: ctx.wiki.did, 162 169 wikiDescription: description, ··· 189 196 { 190 197 session: ctx.session, 191 198 locale: ctx.locale, 199 + userTheme: ctx.userTheme, 192 200 accessLevel: ctx.access, 193 201 wikiDid: ctx.wiki.did, 194 202 wikiDescription: ctx.wiki.description, ··· 219 227 accessDeniedPage(ctx.wiki.name, params.wikiSlug, { 220 228 session: ctx.session, 221 229 locale: ctx.locale, 230 + userTheme: ctx.userTheme, 222 231 hasPendingRequest: ctx.hasPendingRequest, 223 232 }), 224 233 403, ··· 258 267 sidebarNotes, 259 268 currentNoteSlug: "home", 260 269 locale: ctx.locale, 270 + userTheme: ctx.userTheme, 261 271 accessLevel: ctx.access, 262 272 bookmarkHtml: bmHtml, 263 273 shareHtml, ··· 278 288 session: ctx.session, 279 289 sidebarNotes, 280 290 locale: ctx.locale, 291 + userTheme: ctx.userTheme, 281 292 accessLevel: ctx.access, 282 293 bookmarkHtml: bmHtml, 283 294 },
+1
src/views/icons.ts
··· 7 7 logout: `<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>`, 8 8 check: `<svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>`, 9 9 download: `<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>`, 10 + theme: `<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path d="M12 3a9 9 0 1 0 9 9c0-.46-.04-.92-.1-1.36a5.39 5.39 0 0 1-4.4 2.26 5.4 5.4 0 0 1-3.14-9.8c-.44-.06-.9-.1-1.36-.1z"/></svg>`, 10 11 } as const;
+22 -4
src/views/layout.ts
··· 10 10 sidebarButtonClass, 11 11 sidebarLinkClass, 12 12 THEME, 13 - themeStyleAttr, 14 - themes, 13 + themeRootStyle, 14 + USER_THEMES, 15 + type UserTheme, 15 16 } from "./theme/index.ts"; 16 17 17 18 const LOCALE_LABELS: Record<string, string> = { ··· 29 30 currentNoteSlug?: string; 30 31 pageTitle?: string; 31 32 locale?: Locale; 33 + userTheme?: UserTheme; 32 34 accessLevel?: AccessLevel; 33 35 bookmarkHtml?: string; 34 36 shareHtml?: string; ··· 134 136 .join("\n"); 135 137 136 138 const locale = options?.locale ?? "en"; 139 + const userTheme = options?.userTheme ?? "system"; 137 140 const msg = t(locale); 138 141 139 142 const session = options?.session; ··· 156 159 </div> 157 160 </div>`; 158 161 162 + const themeItems = USER_THEMES.map( 163 + (t) => 164 + `<a href="#" class="block px-3 py-2 text-sm ${t === userTheme ? `font-semibold ${THEME.accentText}` : THEME.textSecondary} ${THEME.accentSoft} rounded" onclick="event.preventDefault();fetch('/set-theme',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:'theme=${t}'}).then(()=>location.reload())">${msg.nav[`theme${t.charAt(0).toUpperCase()}${t.slice(1)}` as "themeLight" | "themeDark" | "themeSystem"]}</a>`, 165 + ).join("\n"); 166 + const themePicker = `<div class="relative" id="theme-picker"> 167 + <button type="button" onclick="document.getElementById('theme-menu').classList.toggle('hidden')" class="${navBtnClass}" aria-label="${msg.nav.theme}" title="${msg.nav.theme}"> 168 + ${ICONS.theme} 169 + </button> 170 + <div id="theme-menu" class="${dropdownClass}"> 171 + ${themeItems} 172 + </div> 173 + </div>`; 174 + 159 175 const profileOrLogin = session 160 176 ? `<div class="relative" id="profile-picker"> 161 177 <button type="button" onclick="document.getElementById('profile-menu').classList.toggle('hidden')" class="${navBtnClass}" aria-label="${msg.profile.myWikis}"> ··· 194 210 : "" 195 211 } 196 212 <link rel="stylesheet" href="/public/dist.css"> 213 + <style>${themeRootStyle(userTheme)}</style> 197 214 ${extraStyles} 198 215 <script src="/public/htmx.min.js" defer></script> 199 216 ${extraScripts} 200 217 </head> 201 - <body style="${themeStyleAttr(themes.light)}" class="${THEME.bg} ${THEME.text} min-h-screen flex flex-col"> 218 + <body class="${THEME.bg} ${THEME.text} min-h-screen flex flex-col"> 202 219 <nav class="shrink-0 sticky top-0 z-20 ${THEME.bgSurface} border-b ${THEME.border}"> 203 220 <div class="max-w-6xl mx-auto px-6 py-3 flex items-center justify-between gap-4"> 204 221 <div class="flex items-center gap-2 min-w-0"> ··· 218 235 </div> 219 236 <div class="flex items-center gap-2 shrink-0"> 220 237 ${profileOrLogin} 238 + ${themePicker} 221 239 ${localePicker} 222 240 </div> 223 241 </div> ··· 244 262 </dialog> 245 263 <script> 246 264 document.addEventListener('click', (e) => { 247 - for (const [pickerId, menuId] of [['locale-picker', 'locale-menu'], ['profile-picker', 'profile-menu'], ['export-picker', 'export-menu']]) { 265 + for (const [pickerId, menuId] of [['locale-picker', 'locale-menu'], ['theme-picker', 'theme-menu'], ['profile-picker', 'profile-menu'], ['export-picker', 'export-menu']]) { 248 266 const el = document.getElementById(pickerId); 249 267 if (el && !el.contains(e.target)) document.getElementById(menuId)?.classList.add('hidden'); 250 268 }
+16 -4
src/views/theme/apply.ts
··· 1 - import type { Theme } from "./themes.ts"; 1 + import type { UserTheme } from "./resolve.ts"; 2 + import { type Theme, themes } from "./themes.ts"; 2 3 3 4 const kebab = (s: string): string => 4 5 s.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`); 5 6 6 - export function themeStyleAttr(theme: Theme): string { 7 + function themeVars(theme: Theme): string { 7 8 return Object.entries(theme) 8 - .map(([k, v]) => `--${kebab(k)}: ${v}`) 9 - .join("; "); 9 + .map(([k, v]) => `--${kebab(k)}: ${v};`) 10 + .join(" "); 11 + } 12 + 13 + /** 14 + * Returns the contents of a <style> block that sets CSS theme vars on <body>. 15 + * For "system", emits both light defaults and a prefers-color-scheme: dark 16 + * override so the browser resolves the active theme natively (no FOUC, no JS). 17 + */ 18 + export function themeRootStyle(userTheme: UserTheme): string { 19 + if (userTheme === "light") return `body { ${themeVars(themes.light)} }`; 20 + if (userTheme === "dark") return `body { ${themeVars(themes.dark)} }`; 21 + return `body { ${themeVars(themes.light)} } @media (prefers-color-scheme: dark) { body { ${themeVars(themes.dark)} } }`; 10 22 }
+2 -2
src/views/theme/index.ts
··· 1 - export { themeStyleAttr } from "./apply.ts"; 2 - export { themes } from "./themes.ts"; 1 + export { themeRootStyle } from "./apply.ts"; 2 + export { resolveUserTheme, USER_THEMES, type UserTheme } from "./resolve.ts"; 3 3 export { 4 4 dangerButtonClass, 5 5 dangerSmallButtonClass,
+15
src/views/theme/resolve.ts
··· 1 + export const USER_THEMES = ["light", "dark", "system"] as const; 2 + export type UserTheme = (typeof USER_THEMES)[number]; 3 + 4 + export function resolveUserTheme( 5 + cookieHeader: string | null | undefined, 6 + ): UserTheme { 7 + if (!cookieHeader) return "system"; 8 + const match = cookieHeader.match(/(?:^|;\s*)theme=([^;]+)/); 9 + if (!match?.[1]) return "system"; 10 + const val = match[1].trim(); 11 + if ((USER_THEMES as readonly string[]).includes(val)) { 12 + return val as UserTheme; 13 + } 14 + return "system"; 15 + }
+1
tests/lib/import-export/export.test.ts
··· 65 65 did: TEST_DID, 66 66 access: "none" as const, 67 67 locale: "en" as const, 68 + userTheme: "system" as const, 68 69 hasPendingRequest: false, 69 70 }; 70 71 }
+1
tests/lib/import-export/import.test.ts
··· 70 70 did: TEST_DID, 71 71 access: "none", 72 72 locale: "en" as const, 73 + userTheme: "system" as const, 73 74 hasPendingRequest: false, 74 75 ...overrides, 75 76 };
+1
tests/lib/orchestrators/membership.test.ts
··· 67 67 did: ADMIN_DID, 68 68 access: "admin", 69 69 locale: "en", 70 + userTheme: "system", 70 71 hasPendingRequest: false, 71 72 ...overrides, 72 73 };
+1
tests/lib/orchestrators/note.test.ts
··· 60 60 did: WIKI_DID, 61 61 access: "admin", 62 62 locale: "en", 63 + userTheme: "system", 63 64 hasPendingRequest: false, 64 65 ...overrides, 65 66 };
+2
tests/lib/orchestrators/wiki.test.ts
··· 70 70 did: TEST_DID, 71 71 access: "none", 72 72 locale: "en", 73 + userTheme: "system", 73 74 hasPendingRequest: false, 74 75 ...overrides, 75 76 }; ··· 85 86 did: TEST_DID, 86 87 access: "admin", 87 88 locale: "en", 89 + userTheme: "system", 88 90 hasPendingRequest: false, 89 91 ...overrides, 90 92 };