From c0d3a96ff664e875f9c5ad087b4fbdbb211226c2 Mon Sep 17 00:00:00 2001 From: jona159 Date: Mon, 8 Jun 2026 07:12:04 +0200 Subject: [PATCH 01/38] feat(wip): add theming --- .../landing/header/theme-toggle.tsx | 63 +++++++++ app/components/map/topbar.tsx | 2 + app/components/theme-select.tsx | 105 +++++++++++++++ app/lib/theme.ts | 10 ++ app/root.tsx | 69 +++++++++- app/services/theme-service.server.ts | 17 +++ app/styles/app.css | 120 ++++++++++++------ app/styles/tailwind.css | 4 +- 8 files changed, 345 insertions(+), 45 deletions(-) create mode 100644 app/components/landing/header/theme-toggle.tsx create mode 100644 app/components/theme-select.tsx create mode 100644 app/lib/theme.ts create mode 100644 app/services/theme-service.server.ts diff --git a/app/components/landing/header/theme-toggle.tsx b/app/components/landing/header/theme-toggle.tsx new file mode 100644 index 00000000..56ef19e0 --- /dev/null +++ b/app/components/landing/header/theme-toggle.tsx @@ -0,0 +1,63 @@ +import { SunMoon } from 'lucide-react' +import { useFetcher } from 'react-router' +import { Button } from '~/components/ui/button' +import type { ThemePreference } from '~/lib/theme' +import { useRootRouteLoaderData } from '~/root' + +function applyThemePreference(preference: ThemePreference) { + const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches + + const resolvedTheme = + preference === 'dark' || (preference === 'system' && prefersDark) + ? 'dark' + : 'light' + + const root = document.documentElement + + root.classList.remove('light', 'dark') + root.classList.add(resolvedTheme) + root.style.colorScheme = resolvedTheme +} + +function getCurrentResolvedTheme(): 'light' | 'dark' { + return document.documentElement.classList.contains('dark') ? 'dark' : 'light' +} + +export default function ThemeToggle() { + const { themePreference } = useRootRouteLoaderData() + const fetcher = useFetcher() + + const toggleTheme = () => { + const currentTheme = getCurrentResolvedTheme() + + const nextThemePreference: ThemePreference = + currentTheme === 'dark' ? 'light' : 'dark' + + applyThemePreference(nextThemePreference) + + void fetcher.submit( + { 'set-theme': nextThemePreference }, + { method: 'post', action: '/' }, + ) + } + + return ( +
+ + +
+ {themePreference === 'system' + ? 'System theme' + : `${themePreference[0].toUpperCase()}${themePreference.slice(1)} theme`} +
+
+ ) +} diff --git a/app/components/map/topbar.tsx b/app/components/map/topbar.tsx index eef4f49c..00b53de4 100644 --- a/app/components/map/topbar.tsx +++ b/app/components/map/topbar.tsx @@ -4,6 +4,7 @@ import NavBar from '../header/nav-bar' import Info from '../header/info' import LanguageSelector from '../landing/header/language-selector' import { TooltipProvider } from '../ui/tooltip' +import ThemeToggle from '../landing/header/theme-toggle' interface MapHeaderProps { devices: any @@ -34,6 +35,7 @@ export default function MapHeader({
+
diff --git a/app/components/theme-select.tsx b/app/components/theme-select.tsx new file mode 100644 index 00000000..78e5ed4d --- /dev/null +++ b/app/components/theme-select.tsx @@ -0,0 +1,105 @@ +import { useEffect } from 'react' +import { useFetcher, useRouteLoaderData } from 'react-router' +import invariant from 'tiny-invariant' +import { ThemePreferenceSchema, type ThemePreference } from '~/lib/theme' +import type { loader as rootLoader } from '~/root' + +function parseThemePreference(value: unknown): ThemePreference | null { + const result = ThemePreferenceSchema.safeParse(value) + return result.success ? result.data : null +} + +function resolveThemePreference(preference: ThemePreference): 'light' | 'dark' { + if (preference === 'dark') return 'dark' + if (preference === 'light') return 'light' + + return window.matchMedia('(prefers-color-scheme: dark)').matches + ? 'dark' + : 'light' +} + +function applyThemePreference(preference: ThemePreference) { + const theme = resolveThemePreference(preference) + const root = document.documentElement + + root.classList.remove('light', 'dark') + root.classList.add(theme) + root.style.colorScheme = theme +} + +function useRootData() { + const rootData = useRouteLoaderData('root') + invariant(rootData, 'root loader data should be available') + return rootData +} + +export function ThemeSelect() { + const { themePreference: serverThemePreference } = useRootData() + const fetcher = useFetcher<{ ok: boolean }>() + + const optimisticThemePreference = parseThemePreference( + fetcher.formData?.get('set-theme'), + ) + + const themePreference = optimisticThemePreference ?? serverThemePreference + const isSaving = fetcher.state !== 'idle' + + useEffect(() => { + applyThemePreference(themePreference) + + if (themePreference !== 'system') return + + const media = window.matchMedia('(prefers-color-scheme: dark)') + const listener = () => applyThemePreference('system') + + media.addEventListener('change', listener) + + return () => { + media.removeEventListener('change', listener) + } + }, [themePreference]) + + return ( + + + + + + + + ) +} diff --git a/app/lib/theme.ts b/app/lib/theme.ts new file mode 100644 index 00000000..e7f89504 --- /dev/null +++ b/app/lib/theme.ts @@ -0,0 +1,10 @@ +import { z } from 'zod' + +export const ThemePreferenceSchema = z.enum(['light', 'dark', 'system']) + +export type ThemePreference = z.infer +export type ResolvedTheme = 'light' | 'dark' + +export function getServerTheme(preference: ThemePreference): ResolvedTheme { + return preference === 'dark' ? 'dark' : 'light' +} diff --git a/app/root.tsx b/app/root.tsx index 7e128f2a..bfd51591 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -23,6 +23,15 @@ import { getLocale, i18nCookie, i18nextMiddleware } from './middleware/i18next' import { tosUiMiddleware } from './middleware/tos-ui.server' import { prometheusMetricsMiddleware } from './middleware/metrics.server' import { getUser } from './services/session-service.server' +import { + getServerTheme, + ThemePreference, + ThemePreferenceSchema, +} from './lib/theme' +import { + getThemePreference, + themeCookie, +} from './services/theme-service.server' export const middleware: Route.MiddlewareFunction[] = [ prometheusMetricsMiddleware, @@ -86,13 +95,40 @@ export const meta: MetaFunction = () => [ }, ] +function PreventFlashOnWrongTheme({ + themePreference, +}: { + themePreference: ThemePreference +}) { + const script = ` +(() => { + const preference = ${JSON.stringify(themePreference)}; + const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; + const theme = preference === 'dark' || (preference === 'system' && prefersDark) + ? 'dark' + : 'light'; + + const root = document.documentElement; + root.classList.remove('light', 'dark'); + root.classList.add(theme); + root.style.colorScheme = theme; +})(); +` + + return