Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: Dark Mode #3

Merged
merged 10 commits into from
Sep 18, 2024
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
30 changes: 30 additions & 0 deletions app/components/theme.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Theme, type ThemeType, useTheme } from '#/context/providers/theme-provider'
import { clx } from '#/utils/ui-helper'

export default function ThemeSwitcher() {
const [theme, setTheme] = useTheme()

const handleThemeChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
setTheme(event.target.value as ThemeType)
}

return (
<div className="relative">
<select
value={theme || Theme.SYSTEM}
onChange={handleThemeChange}
className={clx(
'w-full appearance-none rounded-lg border px-3 py-1.5 pr-8 text-sm focus:outline-none',
'border-gray-300 bg-white text-gray-900 focus:border-primary-500',
'dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:focus:border-primary-400'
)}
>
{Object.entries(Theme).map(([key, value]) => (
<option key={value} value={value}>
{key.charAt(0) + key.slice(1).toLowerCase()}
</option>
))}
</select>
</div>
)
}
123 changes: 123 additions & 0 deletions app/context/providers/theme-provider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { useFetcher, useRevalidator } from '@remix-run/react'
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'
import type { Dispatch, SetStateAction } from 'react'
import type { EnumValues } from '#/utils/common'

const Theme = {
DARK: 'dark',
LIGHT: 'light',
SYSTEM: 'system',
} as const

type ThemeType = EnumValues<typeof Theme>

type ThemeContextType = [ThemeType | null, Dispatch<SetStateAction<ThemeType | null>>]

const ThemeContext = createContext<ThemeContextType | undefined>(undefined)

const prefersLightMQ = '(prefers-color-scheme: light)'

const getTheme = (): ThemeType => {
if (typeof window === 'undefined') return Theme.SYSTEM
return window.matchMedia(prefersLightMQ).matches ? Theme.LIGHT : Theme.DARK
}

interface ThemeProviderProps {
children: React.ReactNode
specifiedTheme?: ThemeType
}

function useThemeEffect(theme: ThemeType | null) {
const { revalidate } = useRevalidator()
const persistTheme = useFetcher()

const setThemeClass = useCallback((newTheme: ThemeType) => {
document.documentElement.classList.remove(Theme.LIGHT, Theme.DARK)
document.documentElement.classList.add(newTheme)
}, [])

// biome-ignore lint/correctness/useExhaustiveDependencies: render once
useEffect(() => {
if (theme) {
persistTheme.submit({ theme }, { action: 'set-theme', method: 'POST' })
const resolvedTheme = theme === Theme.SYSTEM ? getTheme() : theme
setThemeClass(resolvedTheme)
}
}, [theme])

useEffect(() => {
const mediaQuery = window.matchMedia(prefersLightMQ)
const handleChange = () => {
if (theme === Theme.SYSTEM) {
setThemeClass(getTheme())
}
revalidate()
}
mediaQuery.addEventListener('change', handleChange)
return () => mediaQuery.removeEventListener('change', handleChange)
}, [theme, revalidate, setThemeClass])
}

function ThemeProvider({ children, specifiedTheme }: ThemeProviderProps) {
const [theme, setTheme] = useState<ThemeType | null>(() => {
if (specifiedTheme && Object.values(Theme).includes(specifiedTheme)) {
return specifiedTheme
}
return null
})

const mountRun = useRef(false)

useEffect(() => {
if (!mountRun.current) {
mountRun.current = true
setTheme(specifiedTheme ?? getTheme())
}
}, [specifiedTheme])

useThemeEffect(theme)

return <ThemeContext.Provider value={[theme, setTheme]}>{children}</ThemeContext.Provider>
}

function NonFlashOfWrongThemeEls({ ssrTheme }: { ssrTheme: boolean }) {
const [theme] = useTheme()
const resolvedTheme = theme === Theme.SYSTEM ? getTheme() : theme
const colorScheme =
resolvedTheme === Theme.LIGHT ? 'light' : resolvedTheme === Theme.DARK ? 'dark' : 'light dark'

const setThemeScript = `
(function() {
const theme = ${JSON.stringify(resolvedTheme)};
const cl = document.documentElement.classList;
cl.remove('light', 'dark');
cl.add(theme);
})();
`

return (
<>
<meta name="color-scheme" content={ssrTheme ? 'light' : colorScheme} />
{!ssrTheme && (
<script
// biome-ignore lint/security/noDangerouslySetInnerHtml: Inline script is necessary for theme initialization
dangerouslySetInnerHTML={{ __html: setThemeScript }}
/>
)}
</>
)
}

function useTheme(): ThemeContextType {
const context = useContext(ThemeContext)
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider')
}
return context
}

function isTheme(value: unknown): value is ThemeType {
return typeof value === 'string' && Object.values(Theme).includes(value as ThemeType)
}

export { Theme, type ThemeType, isTheme, NonFlashOfWrongThemeEls, ThemeProvider, useTheme }
33 changes: 26 additions & 7 deletions app/root.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,29 @@
import type { LinksFunction, LoaderFunction, MetaDescriptor, MetaFunction } from '@remix-run/node'
import { Links, Meta, Outlet, Scripts, json, useRouteError } from '@remix-run/react'
import { Links, Meta, Outlet, Scripts, json, useLoaderData, useRouteError } from '@remix-run/react'
import { ScrollRestoration, isRouteErrorResponse } from '@remix-run/react'
import { type PropsWithChildren, useEffect } from 'react'
import { useEffect } from 'react'

import InternalError from '#/components/errors/internal-error'
import NotFound from '#/components/errors/not-found'
import { useNonce } from '#/context/providers/nonce-provider'
import {
NonFlashOfWrongThemeEls,
ThemeProvider,
useTheme,
} from '#/context/providers/theme-provider'
import { getThemeSession } from '#/utils/theme.server'
import { clx } from '#/utils/ui-helper'

import styles from './styles.css?url'

export const loader: LoaderFunction = async ({ request, context }) => {
const themeSession = await getThemeSession(request)

return json({
// Dynamic Canonical URL: https://sergiodxa.com/tutorials/add-dynamic-canonical-url-to-remix-routes
meta: [{ tagName: 'link', rel: 'canonical', href: request.url }] satisfies MetaDescriptor[],
nonce: context.nonce as string,
theme: themeSession.getTheme(),
baseUrl: process.env.APP_BASE_URL,
})
}
Expand All @@ -41,7 +50,9 @@ export const meta: MetaFunction<typeof loader> = ({ data }) => {
]
}

export function Layout({ children }: PropsWithChildren) {
function App() {
const data = useLoaderData<typeof loader>()
const [theme] = useTheme()
const nonce = useNonce()

useEffect(() => {
Expand Down Expand Up @@ -77,18 +88,21 @@ export function Layout({ children }: PropsWithChildren) {
}, [])

return (
<html lang="en">
<html lang="en" className={clx(theme)}>
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<NonFlashOfWrongThemeEls ssrTheme={Boolean(data.theme)} />
<Meta />
<Links />
</head>
<body className={clx(import.meta.env.DEV && 'debug-breakpoints')} suppressHydrationWarning>
<a href="#main" className="skiplink">
Skip to main content
</a>
<div id="main">{children}</div>
<div id="main">
<Outlet />
</div>
<ScrollRestoration nonce={nonce} />
<Scripts nonce={nonce} />
</body>
Expand Down Expand Up @@ -127,6 +141,11 @@ export function ErrorBoundary() {
)
}

export default function App() {
return <Outlet />
export default function AppWithProviders() {
const data = useLoaderData<typeof loader>()
return (
<ThemeProvider specifiedTheme={data.theme}>
<App />
</ThemeProvider>
)
}
8 changes: 4 additions & 4 deletions app/routes/_index.tsx → app/routes/_home+/_index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ export default function IndexPage() {

if (!data || data.isDefault) {
return (
<div className="mx-auto flex size-full min-h-screen flex-col items-center justify-center px-4 text-center sm:px-6 lg:px-8">
<>
<h1 className="block font-bold text-7xl text-gray-800 sm:text-7xl dark:text-white">
Welcome to Remix
</h1>
Expand All @@ -115,15 +115,15 @@ export default function IndexPage() {
</Link>
</Button>
</div>
</div>
</>
)
}

// Destructure the data object.
const { domain, sites, currentSite } = data

return (
<div className="mx-auto flex size-full min-h-screen flex-col items-center justify-center px-4 text-center sm:px-6 lg:px-8">
<>
<h1 className="block font-bold text-7xl text-gray-800 sm:text-7xl dark:text-white">
Welcome to Remix
</h1>
Expand Down Expand Up @@ -151,6 +151,6 @@ export default function IndexPage() {
</Button>
))}
</div>
</div>
</>
)
}
20 changes: 20 additions & 0 deletions app/routes/_home+/_layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { LoaderFunction } from '@remix-run/node'
import { Outlet, json } from '@remix-run/react'
import ThemeSwitcher from '#/components/theme'

export const loader: LoaderFunction = async (_ctx) => {
return json({})
}

export default function HomeLayout() {
return (
<div className="bg-white dark:bg-slate-950">
<div className="absolute top-4 right-4">
<ThemeSwitcher />
</div>
<main className="mx-auto flex size-full min-h-screen flex-col items-center justify-center px-4 text-center sm:px-6 lg:px-8">
<Outlet />
</main>
</div>
)
}
30 changes: 30 additions & 0 deletions app/routes/set-theme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/*!
* Portions of this file are based on code from `mattstobbs/remix-dark-mode`.
* Credits to Matt Stobbs: https://github.com/mattstobbs/remix-dark-mode
*/

import { type ActionFunctionArgs, json, redirect } from '@remix-run/node'
import { isTheme } from '#/context/providers/theme-provider'
import { getThemeSession } from '#/utils/theme.server'

export const action = async ({ request }: ActionFunctionArgs) => {
const themeSession = await getThemeSession(request)
const requestText = await request.text()
const form = new URLSearchParams(requestText)
const theme = form.get('theme')

if (!isTheme(theme)) {
return json({
success: false,
message: `theme value of ${theme} is not a valid theme`,
})
}

themeSession.setTheme(theme)

const cookiesValue = await themeSession.commit()

return json({ success: true }, { headers: { 'Set-Cookie': cookiesValue } })
}

export const loader = () => redirect('/', { status: 404 })
2 changes: 1 addition & 1 deletion app/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
@layer base {
html,
body {
@apply size-full min-h-screen antialiased scroll-smooth dark:bg-gray-950;
@apply size-full min-h-screen antialiased scroll-smooth bg-white dark:bg-gray-950;
}
}

Expand Down
2 changes: 2 additions & 0 deletions app/utils/common.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import pico from 'picocolors'
import type { LogLevel } from './env.server'

export type EnumValues<Type> = Type[keyof Type]

/**
* Determines if the current environment is a browser.
* @returns `true` if the current environment is a browser, `false` otherwise.
Expand Down
25 changes: 25 additions & 0 deletions app/utils/env.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/

import 'dotenv/config'
import type { CookieOptions } from '@remix-run/node'
import * as v from 'valibot'
import { logger } from './common'

Expand Down Expand Up @@ -41,6 +42,30 @@ const EnvSchema = v.object({
SMTP_EMAIL_FROM: v.optional(v.string(), 'Remix Mailer <[email protected]>'),
})

export const GlobalCookiesOptions: Omit<CookieOptions, 'name' | 'expires'> = {
path: '/',
sameSite: 'lax',
httpOnly: true,
secure: process.env.NODE_ENV !== 'development',
secrets: process.env.NODE_ENV !== 'development' ? [process.env.APP_SECRET_KEY] : [],
encode: (val) => {
try {
return atob(val) // Decode the Base64 cookie value
} catch (error) {
logger.error('Failed to encode cookie:', error)
return val // Return original value if encoding fails
}
},
decode: (val) => {
try {
return btoa(val) // Encode the cookie value to Base64
} catch (error) {
logger.error('Failed to decode cookie:', error)
return val // Return original value if decoding fails
}
},
}

export type LogLevel = v.InferInput<typeof LogLevelSchema>

declare global {
Expand Down
Loading