diff --git a/src/app.html b/src/app.html index 77a5ff5..8f524c6 100644 --- a/src/app.html +++ b/src/app.html @@ -1,5 +1,5 @@ - + diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 92e3282..2b064d0 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -3,6 +3,8 @@ import { csrf } from '$lib/server/csrf'; import { DB } from '$lib/server/drizzle'; import { runSeeds } from '$lib/server/drizzle/seeds'; import { JWT } from '$lib/server/jwt'; +import type { ThemeModeType } from '$lib/theme-mode'; +import type { Handle } from '@sveltejs/kit'; import { sequence } from '@sveltejs/kit/hooks'; import { migrate } from 'drizzle-orm/mysql2/migrator'; import { handleSession } from 'svelte-kit-cookie-session'; @@ -18,6 +20,21 @@ if (AUTO_MIGRATE === 'true') { await runSeeds(); +const handleThemeHook = (async ({ resolve, event }) => { + // Take the theme from the query parameters or from the cookies + const newTheme = event.url.searchParams.get('themeMode') as ThemeModeType; + const cookieTheme = event.cookies.get('themeMode') as ThemeModeType; + + const theme: ThemeModeType | null = newTheme || cookieTheme; + if (theme) { + return await resolve(event, { + transformPageChunk: ({ html }) => html.replace(/theme-base=""/g, `theme-base="${theme}"`) + }); + } + + return await resolve(event); +}) satisfies Handle; + export const handle = sequence( csrf(['/oauth2/token', '/oauth2/introspect', '/oauth2/device_authorization']), handleSession({ @@ -25,5 +42,6 @@ export const handle = sequence( cookie: { secure: SESSION_SECURE === 'true' } - }) + }), + handleThemeHook ); diff --git a/src/lib/components/ThemeButton.svelte b/src/lib/components/ThemeButton.svelte index 6f9eb42..79d22f5 100644 --- a/src/lib/components/ThemeButton.svelte +++ b/src/lib/components/ThemeButton.svelte @@ -1,16 +1,24 @@ - + {$t(`common.theme.${nextMode}`)} diff --git a/src/lib/components/admin/AdminHeader.svelte b/src/lib/components/admin/AdminHeader.svelte index 38142cd..4d774b1 100644 --- a/src/lib/components/admin/AdminHeader.svelte +++ b/src/lib/components/admin/AdminHeader.svelte @@ -26,6 +26,7 @@ & .aside { display: flex; + align-items: center; gap: 1rem; } } diff --git a/src/lib/theme-mode.ts b/src/lib/theme-mode.ts index d631501..45e8cb3 100644 --- a/src/lib/theme-mode.ts +++ b/src/lib/theme-mode.ts @@ -6,24 +6,46 @@ export type ThemeModeType = 'light' | 'dark'; export const themeMode = writable('light', (set) => { if (!browser) return; - const storageMode = window?.localStorage.getItem('inThemeMode') as ThemeModeType; - const uaTheme = window?.matchMedia?.('(prefers-color-scheme: dark)'); - const uaMode: ThemeModeType = uaTheme?.matches ? 'dark' : 'light'; - set(storageMode || uaMode || 'light'); - - const uaThemeCallback = () => set(uaTheme?.matches ? 'dark' : 'light'); - uaTheme?.addEventListener('change', uaThemeCallback); - return () => uaTheme?.removeEventListener('change', uaThemeCallback); + set((document.documentElement.getAttribute('theme-base') as ThemeModeType) || 'light'); }); -export const useThemeMode = () => - onMount(() => - themeMode.subscribe((value) => document.documentElement.setAttribute('theme-base', value)) - ); +/** + * Send a theme to the server so that it can be saved as a cookie. + * @param theme - theme to send to server + */ +export const forwardThemeToServer = async (theme: ThemeModeType) => + fetch(`/?/setTheme&themeMode=${theme}`, { + method: 'POST', + body: new FormData(), + credentials: 'include', + headers: { + 'x-sveltekit-action': 'true' + } + }); -export const setThemeMode = (mode: ThemeModeType) => { - if (!browser) return; - themeMode.set(mode); - window.localStorage.setItem('inThemeMode', mode); -}; +export const forwardInitialTheme = () => + onMount(() => { + // If a cookie-set theme is not present, set the theme to current user agent theme. + const hasServerTheme = !!document.documentElement.getAttribute('theme-base'); + const uaTheme = window?.matchMedia?.('(prefers-color-scheme: dark)'); + const uaMode: ThemeModeType = uaTheme?.matches ? 'dark' : 'light'; + const uaSetter = () => { + themeMode.set(uaTheme?.matches ? 'dark' : 'light'); + }; + + if (!hasServerTheme) { + themeMode.set(uaMode); + } + + const unsubscribeForward = themeMode.subscribe(async (theme) => { + document.documentElement.setAttribute('theme-base', theme); + await forwardThemeToServer(theme); + }); + + uaTheme?.addEventListener?.('change', uaSetter); + return () => { + unsubscribeForward(); + uaTheme?.removeEventListener('change', uaSetter); + }; + }); diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index c3bace0..6d1fdb8 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,5 +1,8 @@ diff --git a/src/routes/+layout.ts b/src/routes/+layout.ts index 98d9ef4..82a3d48 100644 --- a/src/routes/+layout.ts +++ b/src/routes/+layout.ts @@ -1,16 +1,10 @@ -import { browser } from '$app/environment'; import { loadTranslations } from '$lib/i18n'; -import { themeMode } from '$lib/theme-mode'; export const load = async ({ url }) => { const { pathname } = url; const initLocale = 'en'; // get from cookie, user session, ... - if (browser) { - themeMode.subscribe((value) => document.documentElement.setAttribute('theme-base', value)); - } - await loadTranslations(initLocale, pathname); return {}; diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index 336c438..7490567 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -1,3 +1,20 @@ import { redirect } from '@sveltejs/kit'; +export const actions = { + setTheme: async ({ url, cookies }) => { + // Set a new theme in the cookies according to themeMode query parameter + const themeMode = url.searchParams.get('themeMode'); + if (themeMode) { + cookies.set('themeMode', themeMode, { + maxAge: 60 * 60 * 24 * 365, + httpOnly: true, + sameSite: 'lax', + path: '/' + }); + } + + return redirect(303, '/account'); + } +}; + export const load = () => redirect(302, '/account'); diff --git a/src/routes/login/+page.server.ts b/src/routes/login/+page.server.ts index d453d3c..c6f05a9 100644 --- a/src/routes/login/+page.server.ts +++ b/src/routes/login/+page.server.ts @@ -109,8 +109,16 @@ export const actions = { } as Actions; export const load = async ({ locals, url }) => { - if (locals.session.data?.user) { - return redirect(301, url.searchParams.get('redirectTo') || '/'); + if (url.searchParams.has('redirectTo')) { + // Check that the redirect URL is a local path + if (!url.searchParams.get('redirectTo')?.startsWith('/')) { + return redirect(301, '/login'); + } + + // Redirect if already logged in + if (locals.session.data?.user) { + return redirect(301, url.searchParams.get('redirectTo') || '/'); + } } // Activation routine