Skip to content

Commit

Permalink
Merge pull request #3 from riipandi/feat/dark-mode
Browse files Browse the repository at this point in the history
Feat: Dark Mode
  • Loading branch information
riipandi authored Sep 18, 2024
2 parents 5fbdfc0 + 49d4c13 commit 00df2a8
Show file tree
Hide file tree
Showing 15 changed files with 584 additions and 278 deletions.
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

0 comments on commit 00df2a8

Please sign in to comment.