-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
10 changed files
with
292 additions
and
13 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
import { Theme, useTheme } from '#/context/providers/theme-provider' | ||
|
||
export default function ThemeSwitcher() { | ||
const [, setTheme] = useTheme() | ||
return ( | ||
<div className="flex flex-wrap justify-center gap-4 rounded-2xl p-1"> | ||
<button | ||
type="button" | ||
className="rounded-xl border-2 border-gray-900 bg-white px-4 py-1 text-gray-900 text-sm" | ||
onClick={() => setTheme(Theme.LIGHT)} | ||
> | ||
Light | ||
</button> | ||
<button | ||
type="button" | ||
className="rounded-xl border-2 border-gray-50 bg-gray-900 px-4 py-1 text-gray-50 text-sm" | ||
onClick={() => setTheme(Theme.DARK)} | ||
> | ||
Dark | ||
</button> | ||
</div> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,137 @@ | ||
/*! | ||
* 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 { useFetcher } from '@remix-run/react' | ||
import { createContext, useContext, useEffect, useRef, useState } from 'react' | ||
import type { Dispatch, ReactNode, SetStateAction } from 'react' | ||
|
||
/** | ||
* Enum representing available themes | ||
*/ | ||
enum Theme { | ||
DARK = 'dark', | ||
LIGHT = 'light', | ||
} | ||
|
||
const themes: Theme[] = Object.values(Theme) | ||
|
||
type ThemeContextType = [Theme | null, Dispatch<SetStateAction<Theme | null>>] | ||
|
||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined) | ||
|
||
const prefersLightMQ = '(prefers-color-scheme: light)' | ||
|
||
/** | ||
* Get the preferred theme based on system preferences | ||
*/ | ||
const getPreferredTheme = (): Theme => | ||
window.matchMedia(prefersLightMQ).matches ? Theme.LIGHT : Theme.DARK | ||
|
||
/** | ||
* ThemeProvider component to manage theme state | ||
*/ | ||
function ThemeProvider({ | ||
children, | ||
specifiedTheme, | ||
}: { | ||
children: ReactNode | ||
specifiedTheme: Theme | null | ||
}) { | ||
const [theme, setTheme] = useState<Theme | null>(() => { | ||
if (specifiedTheme && themes.includes(specifiedTheme)) { | ||
return specifiedTheme | ||
} | ||
return typeof window === 'object' ? getPreferredTheme() : null | ||
}) | ||
|
||
const persistTheme = useFetcher() | ||
const mountRun = useRef(false) | ||
|
||
useEffect(() => { | ||
if (!mountRun.current) { | ||
mountRun.current = true | ||
return | ||
} | ||
if (theme) { | ||
persistTheme.submit({ theme }, { action: 'set-theme', method: 'post' }) | ||
} | ||
}, [theme, persistTheme]) | ||
|
||
useEffect(() => { | ||
const mediaQuery = window.matchMedia(prefersLightMQ) | ||
const handleChange = () => setTheme(mediaQuery.matches ? Theme.LIGHT : Theme.DARK) | ||
mediaQuery.addEventListener('change', handleChange) | ||
return () => mediaQuery.removeEventListener('change', handleChange) | ||
}, []) | ||
|
||
return <ThemeContext.Provider value={[theme, setTheme]}>{children}</ThemeContext.Provider> | ||
} | ||
|
||
// Client-side theme detection and application | ||
const clientThemeCode = ` | ||
;(() => { | ||
const theme = window.matchMedia(${JSON.stringify(prefersLightMQ)}).matches ? 'light' : 'dark'; | ||
const cl = document.documentElement.classList; | ||
const themeAlreadyApplied = cl.contains('light') || cl.contains('dark'); | ||
if (themeAlreadyApplied) { | ||
// this script shouldn't exist if the theme is already applied! | ||
console.warn("Hi there, could you let Matt know you're seeing this message? Thanks!"); | ||
} else { | ||
cl.add(theme); | ||
} | ||
const meta = document.querySelector('meta[name=color-scheme]'); | ||
if (meta) { | ||
if (theme === 'dark') { | ||
meta.content = 'dark light'; | ||
} else if (theme === 'light') { | ||
meta.content = 'light dark'; | ||
} | ||
} else { | ||
console.warn("Hey, could you let Matt know you're seeing this message? Thanks!"); | ||
} | ||
})(); | ||
` | ||
|
||
/** | ||
* Component to prevent flash of wrong theme | ||
*/ | ||
function NonFlashOfWrongThemeEls({ ssrTheme }: { ssrTheme: boolean }) { | ||
const [theme] = useTheme() | ||
|
||
return ( | ||
<> | ||
<meta name="color-scheme" content={theme === Theme.LIGHT ? 'light dark' : 'dark light'} /> | ||
{!ssrTheme && ( | ||
<script | ||
// biome-ignore lint/security/noDangerouslySetInnerHtml: required for clientThemeCode | ||
dangerouslySetInnerHTML={{ __html: clientThemeCode }} | ||
/> | ||
)} | ||
</> | ||
) | ||
} | ||
|
||
/** | ||
* Hook to access the current theme and theme setter | ||
*/ | ||
function useTheme(): ThemeContextType { | ||
const context = useContext(ThemeContext) | ||
if (context === undefined) { | ||
throw new Error('useTheme must be used within a ThemeProvider') | ||
} | ||
return context | ||
} | ||
|
||
/** | ||
* Type guard to check if a value is a valid Theme | ||
*/ | ||
function isTheme(value: unknown): value is Theme { | ||
return typeof value === 'string' && themes.includes(value as Theme) | ||
} | ||
|
||
export { isTheme, NonFlashOfWrongThemeEls, Theme, ThemeProvider, useTheme } |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,28 @@ | ||
/*! | ||
* 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) | ||
|
||
return json({ success: true }, { headers: { 'Set-Cookie': await themeSession.commit() } }) | ||
} | ||
|
||
export const loader = () => redirect('/', { status: 404 }) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,50 @@ | ||
/*! | ||
* 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 SessionIdStorageStrategy, createCookieSessionStorage } from '@remix-run/node' | ||
import { Theme, isTheme } from '#/context/providers/theme-provider' | ||
|
||
/** | ||
* Determines if the application is running in development mode. | ||
*/ | ||
const isDevelopment = process.env.NODE_ENV === 'development' | ||
|
||
/** | ||
* Configuration options for the theme cookie. | ||
*/ | ||
const cookiesOptions: Omit<SessionIdStorageStrategy['cookie'], 'name'> = { | ||
path: '/', | ||
sameSite: 'lax', | ||
secure: !isDevelopment, | ||
secrets: isDevelopment ? [] : [process.env.APP_SECRET_KEY], | ||
expires: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days from now | ||
httpOnly: true, | ||
} | ||
|
||
/** | ||
* Creates a session storage for managing theme preferences. | ||
*/ | ||
const themeStorage = createCookieSessionStorage({ | ||
cookie: { name: 'remix_theme', ...cookiesOptions }, | ||
}) | ||
|
||
/** | ||
* Retrieves and manages the theme session for a given request. | ||
* @param request - The incoming request object. | ||
* @returns An object with methods to get, set, and commit the theme. | ||
*/ | ||
async function getThemeSession(request: Request) { | ||
const session = await themeStorage.getSession(request.headers.get('Cookie')) | ||
return { | ||
getTheme: () => { | ||
const themeValue = session.get('theme') | ||
return isTheme(themeValue) ? themeValue : Theme.DARK | ||
}, | ||
setTheme: (theme: Theme) => session.set('theme', theme), | ||
commit: () => themeStorage.commitSession(session, cookiesOptions), | ||
} | ||
} | ||
|
||
export { getThemeSession } |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.