diff --git a/src/lib/i18n/en.ts b/src/lib/i18n/en.ts index 98bd867..f911652 100644 --- a/src/lib/i18n/en.ts +++ b/src/lib/i18n/en.ts @@ -138,6 +138,14 @@ export const en: Messages = { descriptionLabel: "Description", save: "Save changes", detailsSaved: "Wiki details saved.", + theme: "Theme", + themeModeLabel: "Theme behavior", + themeReader: "Reader's choice", + themeReaderHint: "Visitors see the wiki in their preferred theme.", + themeEnforce: "Enforce a theme", + themeEnforceHint: "All visitors see the wiki in the theme you pick below.", + themePresetLabel: "Preset", + themeSaved: "Theme saved.", dangerZone: "Delete this wiki", deleteWikiDescription: "This will permanently delete the wiki and all its notes. This action cannot be undone.", @@ -166,5 +174,7 @@ export const en: Messages = { noMarkdownFiles: "No markdown files found in zip.", importFailed: "Import failed: {error}", requestFailed: "Something went wrong. Please try again.", + invalidThemeMode: "Invalid theme mode.", + invalidTheme: "Invalid theme.", }, }; diff --git a/src/lib/i18n/fr.ts b/src/lib/i18n/fr.ts index ad89b0b..1b2b7f2 100644 --- a/src/lib/i18n/fr.ts +++ b/src/lib/i18n/fr.ts @@ -140,6 +140,15 @@ export const fr: PartialMessages = { descriptionLabel: "Description", save: "Enregistrer les modifications", detailsSaved: "Détails du wiki enregistrés.", + theme: "Thème", + themeModeLabel: "Comportement du thème", + themeReader: "Choix du lecteur", + themeReaderHint: "Les visiteurs voient le wiki dans leur thème préféré.", + themeEnforce: "Imposer un thème", + themeEnforceHint: + "Tous les visiteurs voient le wiki dans le thème choisi ci-dessous.", + themePresetLabel: "Préréglage", + themeSaved: "Thème enregistré.", dangerZone: "Supprimer ce wiki", deleteWikiDescription: "Cela supprimera définitivement le wiki et toutes ses notes. Cette action est irréversible.", @@ -168,5 +177,7 @@ export const fr: PartialMessages = { noMarkdownFiles: "Aucun fichier markdown trouvé dans le zip.", importFailed: "Échec de l'importation : {error}", requestFailed: "Une erreur est survenue. Veuillez réessayer.", + invalidThemeMode: "Mode de thème invalide.", + invalidTheme: "Thème invalide.", }, }; diff --git a/src/lib/i18n/index.ts b/src/lib/i18n/index.ts index f3b7ffe..1899412 100644 --- a/src/lib/i18n/index.ts +++ b/src/lib/i18n/index.ts @@ -138,6 +138,14 @@ export interface Messages { descriptionLabel: string; save: string; detailsSaved: string; + theme: string; + themeModeLabel: string; + themeReader: string; + themeReaderHint: string; + themeEnforce: string; + themeEnforceHint: string; + themePresetLabel: string; + themeSaved: string; dangerZone: string; deleteWikiDescription: string; deleteWikiOwnerOnly: string; @@ -164,6 +172,8 @@ export interface Messages { noMarkdownFiles: string; importFailed: string; requestFailed: string; + invalidThemeMode: string; + invalidTheme: string; }; } diff --git a/src/lib/orchestrators/wiki.ts b/src/lib/orchestrators/wiki.ts index 285ece4..3c98471 100644 --- a/src/lib/orchestrators/wiki.ts +++ b/src/lib/orchestrators/wiki.ts @@ -12,9 +12,11 @@ import { deleteWikiByAtUri, getWiki, listMembers, + setWikiTheme, upsertMembership, upsertWiki, } from "../../server/db/queries/index.ts"; +import { themes } from "../../views/theme/themes.ts"; import type { RequestContext, WikiRequestContext } from "../access.ts"; import { parseAtUri } from "../at-uri.ts"; import { COLLECTIONS } from "../constants.ts"; @@ -252,6 +254,27 @@ export async function editWikiAction( ); } +/** + * Update a wiki's theme settings. Admin only (route-gated). + * DB-only for now; PDS persistence lands in commit 7. + */ +export function setWikiThemeAction( + ctx: WikiRequestContext, + fields: { themeMode: string; theme: string }, + msg: Messages, +): void { + if (!ctx.did) throw new ForbiddenError(); + + if (fields.themeMode !== "reader" && fields.themeMode !== "enforce") { + throw new ValidationError(msg.error.invalidThemeMode); + } + if (!(fields.theme in themes)) { + throw new ValidationError(msg.error.invalidTheme); + } + + setWikiTheme(ctx.wiki.slug, fields.themeMode, fields.theme); +} + /** * Delete a wiki. Owner only. * PDS cleanup (wiki record + membership records) → DB cascade delete. diff --git a/src/server/db/queries/index.ts b/src/server/db/queries/index.ts index 88157fd..bde731f 100644 --- a/src/server/db/queries/index.ts +++ b/src/server/db/queries/index.ts @@ -53,5 +53,6 @@ export { listCollaboratingWikis, listOwnedWikis, listPublicWikisPaginated, + setWikiTheme, upsertWiki, } from "./wiki.ts"; diff --git a/src/server/db/queries/wiki.ts b/src/server/db/queries/wiki.ts index 40e81f0..9a6f06d 100644 --- a/src/server/db/queries/wiki.ts +++ b/src/server/db/queries/wiki.ts @@ -141,6 +141,20 @@ export function upsertWiki( ); } +export function setWikiTheme( + slug: string, + themeMode: "reader" | "enforce", + theme: string, +): void { + const db = getDb(); + db.run( + `UPDATE wikis + SET theme_mode = ?, theme = ?, updated_at = datetime('now') + WHERE slug = ?`, + [themeMode, theme, slug], + ); +} + export function deleteWikiByAtUri(atUri: string): void { const db = getDb(); const wiki = db diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index 0a7624a..5b215b8 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -13,12 +13,28 @@ export function initSchema(db: Database): void { visibility TEXT NOT NULL DEFAULT 'public' CHECK (visibility IN ('public', 'private')), language TEXT NOT NULL DEFAULT 'en', description TEXT NOT NULL DEFAULT '', + theme_mode TEXT NOT NULL DEFAULT 'reader' CHECK (theme_mode IN ('reader', 'enforce')), + theme TEXT NOT NULL DEFAULT 'light', at_uri TEXT NOT NULL UNIQUE, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ) `); + // Migrations for existing DBs that predate the theme columns. + const wikiCols = db.query("PRAGMA table_info(wikis)").all() as { + name: string; + }[]; + const hasCol = (name: string) => wikiCols.some((c) => c.name === name); + if (!hasCol("theme_mode")) { + db.run( + "ALTER TABLE wikis ADD COLUMN theme_mode TEXT NOT NULL DEFAULT 'reader'", + ); + } + if (!hasCol("theme")) { + db.run("ALTER TABLE wikis ADD COLUMN theme TEXT NOT NULL DEFAULT 'light'"); + } + db.run(` CREATE TABLE IF NOT EXISTS notes ( slug TEXT NOT NULL, diff --git a/src/server/db/types.ts b/src/server/db/types.ts index 93d0051..264c88e 100644 --- a/src/server/db/types.ts +++ b/src/server/db/types.ts @@ -5,6 +5,8 @@ export interface WikiRow { visibility: string; language: string; description: string; + theme_mode: string; + theme: string; at_uri: string; created_at: string; updated_at: string; diff --git a/src/server/routes/note.ts b/src/server/routes/note.ts index 3013bad..9304e53 100644 --- a/src/server/routes/note.ts +++ b/src/server/routes/note.ts @@ -167,6 +167,8 @@ export const noteRoutes = new Elysia({ prefix: "/wiki" }) shareHtml, ogTitle: `${data.note.title} - ${ctx.wiki.name}`, ogUrl: canonicalUrl, + wikiThemeMode: ctx.wiki.theme_mode, + wikiTheme: ctx.wiki.theme, }, ctx.wiki.language, ), diff --git a/src/server/routes/wiki.ts b/src/server/routes/wiki.ts index 15c87ef..bb33050 100644 --- a/src/server/routes/wiki.ts +++ b/src/server/routes/wiki.ts @@ -20,6 +20,7 @@ import { createWikiAction, deleteWikiAction, editWikiAction, + setWikiThemeAction, } from "../../lib/orchestrators/wiki.ts"; import { resolveProfiles } from "../../lib/profile.ts"; import { htmlResponse } from "../../lib/response.ts"; @@ -131,7 +132,10 @@ export const wikiRoutes = new Elysia({ prefix: "/wiki" }) accessLevel: ctx.access, wikiDid: ctx.wiki.did, wikiDescription: ctx.wiki.description, + wikiThemeMode: ctx.wiki.theme_mode, + wikiTheme: ctx.wiki.theme, detailsSaved: query["saved"] === "1", + themeSaved: query["themeSaved"] === "1", }, ), ); @@ -167,6 +171,50 @@ export const wikiRoutes = new Elysia({ prefix: "/wiki" }) accessLevel: ctx.access, wikiDid: ctx.wiki.did, wikiDescription: description, + wikiThemeMode: ctx.wiki.theme_mode, + wikiTheme: ctx.wiki.theme, + error: err.message, + }, + ), + 400, + ); + } + throw err; + } + }) + .post("/:wikiSlug/-/theme", async ({ params, request }) => { + const ctx = await resolveWikiContext(request, params.wikiSlug, "admin"); + const msg = t(ctx.locale); + const formData = await request.formData(); + const themeMode = (formData.get("theme_mode") as string | null) ?? "reader"; + const theme = (formData.get("theme") as string | null) ?? "light"; + + try { + setWikiThemeAction(ctx, { themeMode, theme }, msg); + return redirect(`/wiki/${params.wikiSlug}/-/settings?themeSaved=1`); + } catch (err) { + if (err instanceof ValidationError) { + const isOwner = ctx.did === ctx.wiki.did; + const { members, requests, profiles } = await loadSettingsData( + params.wikiSlug, + ); + return htmlResponse( + settingsPage( + ctx.wiki.name, + params.wikiSlug, + isOwner, + members, + requests, + profiles, + { + session: ctx.session, + locale: ctx.locale, + userTheme: ctx.userTheme, + accessLevel: ctx.access, + wikiDid: ctx.wiki.did, + wikiDescription: ctx.wiki.description, + wikiThemeMode: ctx.wiki.theme_mode, + wikiTheme: ctx.wiki.theme, error: err.message, }, ), @@ -200,6 +248,8 @@ export const wikiRoutes = new Elysia({ prefix: "/wiki" }) accessLevel: ctx.access, wikiDid: ctx.wiki.did, wikiDescription: ctx.wiki.description, + wikiThemeMode: ctx.wiki.theme_mode, + wikiTheme: ctx.wiki.theme, error: "Wiki name does not match.", }, ), @@ -273,6 +323,8 @@ export const wikiRoutes = new Elysia({ prefix: "/wiki" }) shareHtml, ogTitle: `${homeData.note.title} - ${ctx.wiki.name}`, ogUrl: canonicalUrl, + wikiThemeMode: ctx.wiki.theme_mode, + wikiTheme: ctx.wiki.theme, }, ctx.wiki.language, ), @@ -291,6 +343,8 @@ export const wikiRoutes = new Elysia({ prefix: "/wiki" }) userTheme: ctx.userTheme, accessLevel: ctx.access, bookmarkHtml: bmHtml, + wikiThemeMode: ctx.wiki.theme_mode, + wikiTheme: ctx.wiki.theme, }, ctx.wiki.language, ), diff --git a/src/views/note.ts b/src/views/note.ts index e6c671c..2ff670d 100644 --- a/src/views/note.ts +++ b/src/views/note.ts @@ -1,27 +1,32 @@ import { type LayoutOptions, layout } from "./layout.ts"; +import { wrapWikiContent } from "./theme/index.ts"; export function notePage( wikiName: string, wikiSlug: string, noteTitle: string, renderedHtml: string, - options?: LayoutOptions, + options?: LayoutOptions & { wikiThemeMode?: string; wikiTheme?: string }, wikiLanguage?: string, ): string { const langAttr = wikiLanguage ? ` lang="${wikiLanguage}"` : ""; - return layout( - noteTitle, + const content = wrapWikiContent( `
${renderedHtml}
`, { - ...options, - wikiName, - wikiSlug, - pageTitle: noteTitle, - enableSearchShortcut: true, + wikiThemeMode: + options?.wikiThemeMode === "enforce" ? "enforce" : "reader", + wikiTheme: options?.wikiTheme === "dark" ? "dark" : "light", }, ); + return layout(noteTitle, content, { + ...options, + wikiName, + wikiSlug, + pageTitle: noteTitle, + enableSearchShortcut: true, + }); } diff --git a/src/views/settings.ts b/src/views/settings.ts index 3389e6d..3c0cbac 100644 --- a/src/views/settings.ts +++ b/src/views/settings.ts @@ -12,12 +12,16 @@ import { successBanner, THEME, } from "./theme/index.ts"; +import { themes } from "./theme/themes.ts"; interface SettingsPageOptions extends LayoutOptions { wikiDid: string; wikiDescription: string; + wikiThemeMode: string; + wikiTheme: string; error?: string; detailsSaved?: boolean; + themeSaved?: boolean; } function renderIdentity(did: string, profile: ProfileInfo | undefined): string { @@ -251,6 +255,67 @@ function renderDangerZone( `; } +function renderThemeSection( + wikiSlug: string, + wikiThemeMode: string, + wikiTheme: string, + themeSaved: boolean, + locale: string, +): string { + const msg = t(locale as "en" | "fr"); + const isEnforce = wikiThemeMode === "enforce"; + const themeLabels: Record = { + light: msg.nav.themeLight, + dark: msg.nav.themeDark, + }; + const themeOptions = (Object.keys(themes) as (keyof typeof themes)[]) + .map( + (name) => + ``, + ) + .join(""); + + const savedBanner = themeSaved ? successBanner(msg.settings.themeSaved) : ""; + + return `
+

${msg.settings.theme}

+ ${savedBanner} +
+
+ ${msg.settings.themeModeLabel} + + +
+
+ + +
+
+ +
+
+
`; +} + function renderDetailsSection( wikiSlug: string, wikiName: string, @@ -302,6 +367,13 @@ export function settingsPage( options.detailsSaved ?? false, locale, ); + const themeHtml = renderThemeSection( + wikiSlug, + options.wikiThemeMode, + options.wikiTheme, + options.themeSaved ?? false, + locale, + ); const membersHtml = renderMembersSection( wikiSlug, members, @@ -317,7 +389,7 @@ export function settingsPage( return layout( `${msg.settings.heading} — ${wikiName}`, - `${backLink}

${msg.settings.heading}

${errorHtml}${detailsHtml}${membersHtml}${dangerHtml}`, + `${backLink}

${msg.settings.heading}

${errorHtml}${detailsHtml}${themeHtml}${membersHtml}${dangerHtml}`, { ...options, wikiName, wikiSlug }, ); } diff --git a/src/views/theme/apply.ts b/src/views/theme/apply.ts index cca785b..fe366e6 100644 --- a/src/views/theme/apply.ts +++ b/src/views/theme/apply.ts @@ -1,4 +1,9 @@ -import type { UserTheme } from "./resolve.ts"; +import { + resolveTheme, + type ThemeName, + type UserTheme, + type WikiThemeMode, +} from "./resolve.ts"; import { type Theme, themes } from "./themes.ts"; const kebab = (s: string): string => @@ -20,3 +25,23 @@ export function themeRootStyle(userTheme: UserTheme): string { if (userTheme === "dark") return `body { ${themeVars(themes.dark)} }`; return `body { ${themeVars(themes.light)} } @media (prefers-color-scheme: dark) { body { ${themeVars(themes.dark)} } }`; } + +function themeStyleAttr(theme: Theme): string { + return Object.entries(theme) + .map(([k, v]) => `--${kebab(k)}: ${v}`) + .join("; "); +} + +/** + * Wraps wiki-content HTML in a div that overrides the chrome theme when the + * wiki enforces its own. In reader mode the content inherits via CSS cascade + * and no wrapper is emitted. + */ +export function wrapWikiContent( + html: string, + args: { wikiThemeMode?: WikiThemeMode; wikiTheme?: ThemeName } = {}, +): string { + const theme = resolveTheme({ scope: "wikiContent", ...args }); + if (!theme) return html; + return `
${html}
`; +} diff --git a/src/views/theme/index.ts b/src/views/theme/index.ts index 24a40fa..1b664f6 100644 --- a/src/views/theme/index.ts +++ b/src/views/theme/index.ts @@ -1,4 +1,4 @@ -export { themeRootStyle } from "./apply.ts"; +export { themeRootStyle, wrapWikiContent } from "./apply.ts"; export { resolveUserTheme, USER_THEMES, type UserTheme } from "./resolve.ts"; export { dangerButtonClass, diff --git a/src/views/theme/resolve.ts b/src/views/theme/resolve.ts index 0f726d2..069dcf1 100644 --- a/src/views/theme/resolve.ts +++ b/src/views/theme/resolve.ts @@ -1,6 +1,43 @@ +import { type Theme, themes } from "./themes.ts"; + export const USER_THEMES = ["light", "dark", "system"] as const; export type UserTheme = (typeof USER_THEMES)[number]; +/** + * How a wiki applies its theme: + * - "reader": fall through to the visitor's chosen theme (chrome theme) + * - "enforce": override with the wiki's own theme regardless of visitor preference + */ +export type WikiThemeMode = "reader" | "enforce"; + +/** Named theme keys present in `themes` (light/dark presets). */ +export type ThemeName = keyof typeof themes; + +type ThemeScope = "chrome" | "wikiContent"; + +type ResolveThemeArgs = { + scope: ThemeScope; + wikiThemeMode?: WikiThemeMode; + wikiTheme?: ThemeName; +}; + +/** + * Returns the palette an element should apply via inline style, or null when + * the element should inherit from its cascading parent. + * + * Chrome scope is always handled by the body