diff --git a/src/components/ThemeProvider.tsx b/src/components/ThemeProvider.tsx new file mode 100644 index 000000000..30f12024b --- /dev/null +++ b/src/components/ThemeProvider.tsx @@ -0,0 +1,141 @@ +import { ScriptOnce } from '@tanstack/react-router' +import { clientOnly, createIsomorphicFn } from '@tanstack/react-start' +import * as React from 'react' +import { createContext, ReactNode, useEffect, useState } from 'react' +import { z } from 'zod' + +const themeModeSchema = z.enum(['light', 'dark', 'auto']) +const resolvedThemeSchema = z.enum(['light', 'dark']) +const themeKey = 'theme' + +type ThemeMode = z.infer +type ResolvedTheme = z.infer + +const getStoredThemeMode = createIsomorphicFn() + .server((): ThemeMode => 'auto') + .client((): ThemeMode => { + try { + const storedTheme = localStorage.getItem(themeKey) + return themeModeSchema.parse(storedTheme) + } catch { + return 'auto' + } + }) + +const setStoredThemeMode = clientOnly((theme: ThemeMode) => { + try { + const parsedTheme = themeModeSchema.parse(theme) + localStorage.setItem(themeKey, parsedTheme) + } catch {} +}) + +const getSystemTheme = createIsomorphicFn() + .server((): ResolvedTheme => 'light') + .client((): ResolvedTheme => { + return window.matchMedia('(prefers-color-scheme: dark)').matches + ? 'dark' + : 'light' + }) + +const updateThemeClass = clientOnly((themeMode: ThemeMode) => { + const root = document.documentElement + root.classList.remove('light', 'dark', 'auto') + const newTheme = themeMode === 'auto' ? getSystemTheme() : themeMode + root.classList.add(newTheme) + + if (themeMode === 'auto') { + root.classList.add('auto') + } +}) + +const setupPreferredListener = clientOnly(() => { + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)') + const handler = () => updateThemeClass('auto') + mediaQuery.addEventListener('change', handler) + return () => mediaQuery.removeEventListener('change', handler) +}) + +const getNextTheme = clientOnly((current: ThemeMode): ThemeMode => { + const themes: ThemeMode[] = + getSystemTheme() === 'dark' + ? ['auto', 'light', 'dark'] + : ['auto', 'dark', 'light'] + return themes[(themes.indexOf(current) + 1) % themes.length] +}) + +const themeDetectorScript = (function () { + function themeFn() { + try { + const storedTheme = localStorage.getItem('theme') || 'auto' + const validTheme = ['light', 'dark', 'auto'].includes(storedTheme) + ? storedTheme + : 'auto' + + if (validTheme === 'auto') { + const autoTheme = window.matchMedia('(prefers-color-scheme: dark)') + .matches + ? 'dark' + : 'light' + document.documentElement.classList.add(autoTheme, 'auto') + } else { + document.documentElement.classList.add(validTheme) + } + } catch (e) { + const autoTheme = window.matchMedia('(prefers-color-scheme: dark)') + .matches + ? 'dark' + : 'light' + document.documentElement.classList.add(autoTheme, 'auto') + } + } + return `(${themeFn.toString()})();` +})() + +type ThemeContextProps = { + themeMode: ThemeMode + resolvedTheme: ResolvedTheme + setTheme: (theme: ThemeMode) => void + toggleMode: () => void +} +const ThemeContext = createContext(undefined) + +type ThemeProviderProps = { + children: ReactNode +} +export function ThemeProvider({ children }: ThemeProviderProps) { + const [themeMode, setThemeMode] = useState(getStoredThemeMode) + + useEffect(() => { + if (themeMode !== 'auto') return + return setupPreferredListener() + }, [themeMode]) + + const resolvedTheme = themeMode === 'auto' ? getSystemTheme() : themeMode + + const setTheme = (newTheme: ThemeMode) => { + setThemeMode(newTheme) + setStoredThemeMode(newTheme) + updateThemeClass(newTheme) + } + + const toggleMode = () => { + setTheme(getNextTheme(themeMode)) + } + + return ( + + + {children} + + ) +} + +export const useTheme = () => { + const context = React.useContext(ThemeContext) + if (!context) { + throw new Error('useTheme must be used within a ThemeProvider') + } + return context +} diff --git a/src/components/ThemeToggle.tsx b/src/components/ThemeToggle.tsx index 22a480da3..20c6e5c60 100644 --- a/src/components/ThemeToggle.tsx +++ b/src/components/ThemeToggle.tsx @@ -1,95 +1,9 @@ -import { createServerFn } from '@tanstack/react-start' -import { getCookie, setCookie } from '@tanstack/react-start/server' import * as React from 'react' import { FaMoon, FaSun } from 'react-icons/fa' -import { twMerge } from 'tailwind-merge' - -import { z } from 'zod' -import { create } from 'zustand' - -const themeModeSchema = z.enum(['light', 'dark', 'auto']) -const prefersModeSchema = z.enum(['light', 'dark']) - -type ThemeMode = z.infer -type PrefersMode = z.infer - -interface ThemeStore { - mode: ThemeMode - prefers: PrefersMode - toggleMode: () => void - setPrefers: (prefers: PrefersMode) => void -} - -const updateThemeCookie = createServerFn({ method: 'POST' }) - .validator(themeModeSchema) - .handler((ctx) => { - setCookie('theme', ctx.data, { - httpOnly: false, - sameSite: 'lax', - secure: process.env.NODE_ENV === 'production', - path: '/', - maxAge: 60 * 60 * 24 * 365 * 10, - }) - }) - -export const getThemeCookie = createServerFn().handler(() => { - return ( - themeModeSchema.catch('auto').parse(getCookie('theme') ?? 'null') || 'auto' - ) -}) - -export const useThemeStore = create((set, get) => ({ - mode: 'auto', - prefers: (() => { - if (typeof document !== 'undefined') { - return window.matchMedia('(prefers-color-scheme: dark)').matches - ? 'dark' - : 'light' - } - - return 'light' - })(), - toggleMode: () => - set((s) => { - const newMode = - s.mode === 'auto' ? 'light' : s.mode === 'light' ? 'dark' : 'auto' - - updateThemeClass(newMode, s.prefers) - updateThemeCookie({ - data: newMode, - }) - - return { - mode: newMode, - } - }), - setPrefers: (prefers) => { - set({ prefers }) - updateThemeClass(get().mode, prefers) - }, -})) - -if (typeof document !== 'undefined') { - window - .matchMedia('(prefers-color-scheme: dark)') - .addEventListener('change', (event) => { - if (useThemeStore.getState().mode === 'auto') { - } - useThemeStore.getState().setPrefers(event.matches ? 'dark' : 'light') - }) -} - -// Helper to update class -function updateThemeClass(mode: ThemeMode, prefers: PrefersMode) { - document.documentElement.classList.remove('dark') - if (mode === 'dark' || (mode === 'auto' && prefers === 'dark')) { - document.documentElement.classList.add('dark') - } -} +import { useTheme } from './ThemeProvider' export function ThemeToggle() { - const mode = useThemeStore((s) => s.mode) - const toggleMode = useThemeStore((s) => s.toggleMode) + const { toggleMode } = useTheme() const handleToggleMode = ( e: React.MouseEvent @@ -102,40 +16,26 @@ export function ThemeToggle() { return (
Auto
) diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index ff4664b87..4b7d23596 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -1,8 +1,6 @@ import * as React from 'react' -import * as ReactDom from 'react-dom' import { Outlet, - ScriptOnce, createRootRouteWithContext, redirect, useMatches, @@ -20,11 +18,11 @@ import { TanStackRouterDevtoolsInProd } from '@tanstack/react-router-devtools' import { NotFound } from '~/components/NotFound' import { CgSpinner } from 'react-icons/cg' import { DefaultCatchBoundary } from '~/components/DefaultCatchBoundary' -import { getThemeCookie, useThemeStore } from '~/components/ThemeToggle' import { GamScripts } from '~/components/Gam' import { BackgroundAnimation } from '~/components/BackgroundAnimation' import { SearchProvider } from '~/contexts/SearchContext' import { SearchModal } from '~/components/SearchModal' +import { ThemeProvider } from '~/components/ThemeProvider' export const Route = createRootRouteWithContext<{ queryClient: QueryClient @@ -127,11 +125,6 @@ export const Route = createRootRouteWithContext<{ } }, staleTime: Infinity, - loader: async () => { - return { - themeCookie: await getThemeCookie(), - } - }, errorComponent: (props) => { return ( @@ -159,23 +152,18 @@ function RootComponent() { return ( - - - - - + + + + + + + ) } function RootDocument({ children }: { children: React.ReactNode }) { - const { themeCookie } = Route.useLoaderData() - - React.useEffect(() => { - useThemeStore.setState({ mode: themeCookie }) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - const matches = useMatches() const isLoading = useRouterState({ @@ -200,17 +188,9 @@ function RootDocument({ children }: { children: React.ReactNode }) { const showDevtools = canShowLoading && isRouterPage - const themeClass = themeCookie === 'dark' ? 'dark' : '' - return ( - + - {/* If the theme is set to auto, inject a tiny script to set the proper class on html based on the user preference */} - {themeCookie === 'auto' ? ( - - ) : null} {matches.find((d) => d.staticData?.baseParent) ? ( diff --git a/src/routes/_libraries/route.tsx b/src/routes/_libraries/route.tsx index b096e6781..f7bbd42c8 100644 --- a/src/routes/_libraries/route.tsx +++ b/src/routes/_libraries/route.tsx @@ -18,7 +18,7 @@ import { import { getSponsorsForSponsorPack } from '~/server/sponsors' import { libraries } from '~/libraries' import { Scarf } from '~/components/Scarf' -import { ThemeToggle, useThemeStore } from '~/components/ThemeToggle' +import { ThemeToggle } from '~/components/ThemeToggle' import { TbBrandBluesky, TbBrandTwitter } from 'react-icons/tb' import { BiSolidCheckShield } from 'react-icons/bi' import { SearchButton } from '~/components/SearchButton' diff --git a/tailwind.config.cjs b/tailwind.config.cjs index 821368431..d8250fa23 100644 --- a/tailwind.config.cjs +++ b/tailwind.config.cjs @@ -1,7 +1,14 @@ /** @type {import('tailwindcss').Config} */ module.exports = { content: ['./src/**/*.{js,ts,jsx,tsx}'], - plugins: [require('@tailwindcss/typography')], + plugins: [ + require('@tailwindcss/typography'), + function ({ addVariant }) { + addVariant('light', '&:is(.light *)') + addVariant('dark', '&:is(.dark *)') + addVariant('auto', '&:is(.auto *)') + }, + ], darkMode: 'class', theme: { extend: {