diff --git a/src/lib/access.ts b/src/lib/access.ts index f0753c6..6115bfd 100644 --- a/src/lib/access.ts +++ b/src/lib/access.ts @@ -5,6 +5,7 @@ import { getRequest, getWiki, } from "../server/db/queries/index.ts"; +import { resolveUserTheme, type UserTheme } from "../views/theme/index.ts"; import { ForbiddenError, NotFoundError } from "./errors.ts"; import { type Locale, resolveLocale } from "./i18n/index.ts"; @@ -60,6 +61,7 @@ export interface RequestContext { did: string | null; access: AccessLevel; locale: Locale; + userTheme: UserTheme; /** true only when access is "none" and the user has a pending access request */ hasPendingRequest: boolean; } @@ -114,10 +116,12 @@ export async function resolveRequestContext( ): Promise { const session = await getSessionFromRequest(request); const did = session?.did ?? null; + const cookieHeader = request.headers.get("cookie"); const locale = resolveLocale( - request.headers.get("cookie"), + cookieHeader, request.headers.get("accept-language"), ); + const userTheme = resolveUserTheme(cookieHeader); if (!wikiSlug) { return { @@ -126,6 +130,7 @@ export async function resolveRequestContext( did, access: "none", locale, + userTheme, hasPendingRequest: false, }; } @@ -138,6 +143,7 @@ export async function resolveRequestContext( did, access: "none", locale, + userTheme, hasPendingRequest: false, }; } @@ -146,5 +152,13 @@ export async function resolveRequestContext( const access = getAccessLevel(wiki, did, role); const hasPendingRequest = access === "none" && did ? getRequest(wiki.slug, did) !== null : false; - return { session, wiki, did, access, locale, hasPendingRequest }; + return { + session, + wiki, + did, + access, + locale, + userTheme, + hasPendingRequest, + }; } diff --git a/src/lib/i18n/en.ts b/src/lib/i18n/en.ts index a7fb48e..98bd867 100644 --- a/src/lib/i18n/en.ts +++ b/src/lib/i18n/en.ts @@ -4,6 +4,10 @@ export const en: Messages = { nav: { login: "Log in", logout: "Log out", + theme: "Theme", + themeLight: "Light", + themeDark: "Dark", + themeSystem: "System", }, login: { heading: "Log in to Lichen", diff --git a/src/lib/i18n/fr.ts b/src/lib/i18n/fr.ts index 0533f1a..ad89b0b 100644 --- a/src/lib/i18n/fr.ts +++ b/src/lib/i18n/fr.ts @@ -4,6 +4,10 @@ export const fr: PartialMessages = { nav: { login: "Connexion", logout: "Déconnexion", + theme: "Thème", + themeLight: "Clair", + themeDark: "Sombre", + themeSystem: "Système", }, login: { heading: "Connexion à Lichen", diff --git a/src/lib/i18n/index.ts b/src/lib/i18n/index.ts index 460642a..f3b7ffe 100644 --- a/src/lib/i18n/index.ts +++ b/src/lib/i18n/index.ts @@ -8,6 +8,10 @@ export interface Messages { nav: { login: string; logout: string; + theme: string; + themeLight: string; + themeDark: string; + themeSystem: string; }; login: { heading: string; diff --git a/src/server/app.ts b/src/server/app.ts index 62e5571..9823b84 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -13,6 +13,7 @@ import { membershipRoutes } from "./routes/membership.ts"; import { noteRoutes } from "./routes/note.ts"; import { profileRoutes } from "./routes/profile.ts"; import { searchRoutes } from "./routes/search.ts"; +import { themeRoutes } from "./routes/theme.ts"; import { wikiRoutes } from "./routes/wiki.ts"; // Dev and prod auth must not coexist: the dev-session fallback trusts a `did=` @@ -43,6 +44,7 @@ export function buildApp() { .use(blobRoutes) .use(bookmarkRoutes) .use(localeRoutes) + .use(themeRoutes) .use(profileRoutes) .use(homeRoute) .use(exploreRoutes) diff --git a/src/server/routes/explore.ts b/src/server/routes/explore.ts index 6cb43a3..0eb3ffd 100644 --- a/src/server/routes/explore.ts +++ b/src/server/routes/explore.ts @@ -29,6 +29,7 @@ export const exploreRoutes = new Elysia() explorePage(wikis, total, languages, { session: ctx.session, locale: ctx.locale, + userTheme: ctx.userTheme, sort, page, }), diff --git a/src/server/routes/home.ts b/src/server/routes/home.ts index ee174ce..447a50d 100644 --- a/src/server/routes/home.ts +++ b/src/server/routes/home.ts @@ -20,6 +20,7 @@ export const homeRoute = new Elysia().get("/", async ({ request }) => { homePage(wikis, languages, { session: ctx.session, locale: ctx.locale, + userTheme: ctx.userTheme, }), { edgeCacheSeconds: ctx.session ? 0 : 60 }, ); diff --git a/src/server/routes/note.ts b/src/server/routes/note.ts index a651a9f..3013bad 100644 --- a/src/server/routes/note.ts +++ b/src/server/routes/note.ts @@ -35,6 +35,7 @@ export const noteRoutes = new Elysia({ prefix: "/wiki" }) scripts: EDITOR_SCRIPTS, stylesheets: KATEX_STYLESHEETS, locale: ctx.locale, + userTheme: ctx.userTheme, }), ); }) @@ -56,6 +57,7 @@ export const noteRoutes = new Elysia({ prefix: "/wiki" }) scripts: EDITOR_SCRIPTS, stylesheets: KATEX_STYLESHEETS, locale: ctx.locale, + userTheme: ctx.userTheme, error: err.message, titleValue: fields.title, contentValue: fields.content, @@ -85,6 +87,7 @@ export const noteRoutes = new Elysia({ prefix: "/wiki" }) scripts: EDITOR_SCRIPTS, stylesheets: KATEX_STYLESHEETS, locale: ctx.locale, + userTheme: ctx.userTheme, }, ), ); @@ -158,6 +161,7 @@ export const noteRoutes = new Elysia({ prefix: "/wiki" }) sidebarNotes, currentNoteSlug: params.noteSlug, locale: ctx.locale, + userTheme: ctx.userTheme, accessLevel: ctx.access, bookmarkHtml: bmHtml, shareHtml, diff --git a/src/server/routes/profile.ts b/src/server/routes/profile.ts index 6bc02c5..f7a38e8 100644 --- a/src/server/routes/profile.ts +++ b/src/server/routes/profile.ts @@ -39,6 +39,7 @@ export const profileRoutes = new Elysia() profilePage(profile, ownedWikis, collaboratingWikis, bookmarks, { session: ctx.session, locale: ctx.locale, + userTheme: ctx.userTheme, }), ); }) diff --git a/src/server/routes/theme.ts b/src/server/routes/theme.ts new file mode 100644 index 0000000..f682416 --- /dev/null +++ b/src/server/routes/theme.ts @@ -0,0 +1,24 @@ +import { Elysia } from "elysia"; +import { USER_THEMES, type UserTheme } from "../../views/theme/index.ts"; + +export const themeRoutes = new Elysia().post( + "/set-theme", + async ({ request }) => { + const formData = await request.formData(); + const theme = formData.get("theme") as string | null; + + if (!theme || !USER_THEMES.includes(theme as UserTheme)) { + return new Response("Invalid theme", { status: 400 }); + } + + const referer = request.headers.get("referer") || "/"; + + return new Response(null, { + status: 302, + headers: { + Location: referer, + "Set-Cookie": `theme=${theme}; Path=/; SameSite=Lax; Max-Age=31536000`, + }, + }); + }, +); diff --git a/src/server/routes/wiki.ts b/src/server/routes/wiki.ts index f613b01..15c87ef 100644 --- a/src/server/routes/wiki.ts +++ b/src/server/routes/wiki.ts @@ -50,7 +50,11 @@ export const wikiRoutes = new Elysia({ prefix: "/wiki" }) .get("/new", async ({ request }) => { const ctx = await resolveRequestContext(request); return htmlResponse( - newWikiPage({ session: ctx.session, locale: ctx.locale }), + newWikiPage({ + session: ctx.session, + locale: ctx.locale, + userTheme: ctx.userTheme, + }), ); }) .post("/new", async ({ request }) => { @@ -72,6 +76,7 @@ export const wikiRoutes = new Elysia({ prefix: "/wiki" }) newWikiPage({ session: ctx.session, locale: ctx.locale, + userTheme: ctx.userTheme, error, nameValue: name, languageValue: language, @@ -122,6 +127,7 @@ export const wikiRoutes = new Elysia({ prefix: "/wiki" }) { session: ctx.session, locale: ctx.locale, + userTheme: ctx.userTheme, accessLevel: ctx.access, wikiDid: ctx.wiki.did, wikiDescription: ctx.wiki.description, @@ -157,6 +163,7 @@ export const wikiRoutes = new Elysia({ prefix: "/wiki" }) { session: ctx.session, locale: ctx.locale, + userTheme: ctx.userTheme, accessLevel: ctx.access, wikiDid: ctx.wiki.did, wikiDescription: description, @@ -189,6 +196,7 @@ export const wikiRoutes = new Elysia({ prefix: "/wiki" }) { session: ctx.session, locale: ctx.locale, + userTheme: ctx.userTheme, accessLevel: ctx.access, wikiDid: ctx.wiki.did, wikiDescription: ctx.wiki.description, @@ -219,6 +227,7 @@ export const wikiRoutes = new Elysia({ prefix: "/wiki" }) accessDeniedPage(ctx.wiki.name, params.wikiSlug, { session: ctx.session, locale: ctx.locale, + userTheme: ctx.userTheme, hasPendingRequest: ctx.hasPendingRequest, }), 403, @@ -258,6 +267,7 @@ export const wikiRoutes = new Elysia({ prefix: "/wiki" }) sidebarNotes, currentNoteSlug: "home", locale: ctx.locale, + userTheme: ctx.userTheme, accessLevel: ctx.access, bookmarkHtml: bmHtml, shareHtml, @@ -278,6 +288,7 @@ export const wikiRoutes = new Elysia({ prefix: "/wiki" }) session: ctx.session, sidebarNotes, locale: ctx.locale, + userTheme: ctx.userTheme, accessLevel: ctx.access, bookmarkHtml: bmHtml, }, diff --git a/src/views/icons.ts b/src/views/icons.ts index 33bfb40..29a69bf 100644 --- a/src/views/icons.ts +++ b/src/views/icons.ts @@ -7,4 +7,5 @@ export const ICONS = { logout: ``, check: ``, download: ``, + theme: ``, } as const; diff --git a/src/views/layout.ts b/src/views/layout.ts index 3b001e1..2f0d745 100644 --- a/src/views/layout.ts +++ b/src/views/layout.ts @@ -10,8 +10,9 @@ import { sidebarButtonClass, sidebarLinkClass, THEME, - themeStyleAttr, - themes, + themeRootStyle, + USER_THEMES, + type UserTheme, } from "./theme/index.ts"; const LOCALE_LABELS: Record = { @@ -29,6 +30,7 @@ export interface LayoutOptions { currentNoteSlug?: string; pageTitle?: string; locale?: Locale; + userTheme?: UserTheme; accessLevel?: AccessLevel; bookmarkHtml?: string; shareHtml?: string; @@ -134,6 +136,7 @@ export function layout( .join("\n"); const locale = options?.locale ?? "en"; + const userTheme = options?.userTheme ?? "system"; const msg = t(locale); const session = options?.session; @@ -156,6 +159,19 @@ export function layout( `; + const themeItems = USER_THEMES.map( + (t) => + `${msg.nav[`theme${t.charAt(0).toUpperCase()}${t.slice(1)}` as "themeLight" | "themeDark" | "themeSystem"]}`, + ).join("\n"); + const themePicker = `
+ +
+ ${themeItems} +
+
`; + const profileOrLogin = session ? `