Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions src/components/ThemeProvider.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof themeModeSchema>
type ResolvedTheme = z.infer<typeof resolvedThemeSchema>

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)')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we have a subscription to theme change on the client? e.g. if i switch from dark to light mode on the client in my browser or OS settings, does this update the theme here as well?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it's the setupPreferredListener function setting it up. While on auto if you change the theme for example on your OS setting it immediately updates on tanstack.com

.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)')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we call this again? when does the above throw?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is just in case accessing localStorage fails

.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<ThemeContextProps | undefined>(undefined)

type ThemeProviderProps = {
children: ReactNode
}
export function ThemeProvider({ children }: ThemeProviderProps) {
const [themeMode, setThemeMode] = useState<ThemeMode>(getStoredThemeMode)

useEffect(() => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we really need that effect? cant we just execute it whenever setTheme is called?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can move updateThemeClass inside setTheme but I'd keep the useEffect to un/subscribe to the system listener

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 (
<ThemeContext.Provider
value={{ themeMode, resolvedTheme, setTheme, toggleMode }}
>
<ScriptOnce children={themeDetectorScript} />
{children}
</ThemeContext.Provider>
)
}

export const useTheme = () => {
const context = React.useContext(ThemeContext)
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider')
}
return context
}
120 changes: 10 additions & 110 deletions src/components/ThemeToggle.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof themeModeSchema>
type PrefersMode = z.infer<typeof prefersModeSchema>

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<ThemeStore>((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 <body> 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<HTMLDivElement, MouseEvent>
Expand All @@ -102,40 +16,26 @@ export function ThemeToggle() {
return (
<div
onClick={handleToggleMode}
className={twMerge(
`w-12 h-6 bg-gray-500/10 dark:bg-gray-800 rounded-full flex items-center justify-between cursor-pointer relative transition-all`
)}
className={`w-12 h-6 bg-gray-500/10 dark:bg-gray-800 rounded-full flex items-center justify-between cursor-pointer relative transition-all`}
>
<div className="flex-1 flex items-center justify-between px-1.5">
<FaSun
className={twMerge(
`text-sm transition-opacity`,
mode !== 'auto' ? 'opacity-50' : 'opacity-0'
)}
className={`text-sm transition-opacity auto:opacity-0 opacity-50`}
/>
<FaMoon
className={twMerge(
`text-sm transition-opacity`,
mode !== 'auto' ? 'opacity-50' : 'opacity-0'
)}
className={`text-sm transition-opacity auto:opacity-0 opacity-50`}
/>
<span
className={twMerge(
`uppercase select-none font-black text-[.6rem] absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 transition-opacity`,
mode === 'auto' ? 'opacity-30 hover:opacity-50' : 'opacity-0'
)}
className={`uppercase select-none font-black text-[.6rem] absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 transition-opacity auto:opacity-30 auto:hover:opacity-50 opacity-0`}
>
Auto
</span>
</div>
<div
className="absolute w-6 h-6 rounded-full shadow-md shadow-black/20 bg-white dark:bg-gray-400 transition-all duration-300 ease-in-out"
style={{
left: mode === 'auto' ? '50%' : mode === 'light' ? '100%' : '0%',
transform: `translateX(${
mode === 'auto' ? '-50%' : mode === 'light' ? '-100%' : '0'
}) scale(${mode === 'auto' ? 0 : 0.8})`,
}}
className="absolute w-6 h-6 rounded-full shadow-md shadow-black/20 bg-white dark:bg-gray-400 transition-all duration-300 ease-in-out
auto:left-1/2 auto:-translate-x-1/2 auto:scale-0 auto:opacity-0
left-0 translate-x-full scale-75
dark:left-0 dark:translate-x-0 dark:scale-75"
/>
</div>
)
Expand Down
38 changes: 9 additions & 29 deletions src/routes/__root.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import * as React from 'react'
import * as ReactDom from 'react-dom'
import {
Outlet,
ScriptOnce,
createRootRouteWithContext,
redirect,
useMatches,
Expand All @@ -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
Expand Down Expand Up @@ -127,11 +125,6 @@ export const Route = createRootRouteWithContext<{
}
},
staleTime: Infinity,
loader: async () => {
return {
themeCookie: await getThemeCookie(),
}
},
errorComponent: (props) => {
return (
<RootDocument>
Expand Down Expand Up @@ -159,23 +152,18 @@ function RootComponent() {

return (
<ClerkProvider publishableKey={PUBLISHABLE_KEY}>
<SearchProvider>
<RootDocument>
<Outlet />
</RootDocument>
</SearchProvider>
<ThemeProvider>
<SearchProvider>
<RootDocument>
<Outlet />
</RootDocument>
</SearchProvider>
</ThemeProvider>
</ClerkProvider>
)
}

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({
Expand All @@ -200,17 +188,9 @@ function RootDocument({ children }: { children: React.ReactNode }) {

const showDevtools = canShowLoading && isRouterPage

const themeClass = themeCookie === 'dark' ? 'dark' : ''

return (
<html lang="en" className={themeClass}>
<html lang="en" suppressHydrationWarning>
<head>
{/* 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' ? (
<ScriptOnce
children={`window.matchMedia('(prefers-color-scheme: dark)').matches ? document.documentElement.classList.add('dark') : null`}
/>
) : null}
<HeadContent />
{matches.find((d) => d.staticData?.baseParent) ? (
<base target="_parent" />
Expand Down
2 changes: 1 addition & 1 deletion src/routes/_libraries/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
9 changes: 8 additions & 1 deletion tailwind.config.cjs
Original file line number Diff line number Diff line change
@@ -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: {
Expand Down
Loading