-
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.
Merge pull request #3 from riipandi/feat/dark-mode
Feat: Dark Mode
- Loading branch information
Showing
15 changed files
with
584 additions
and
278 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,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> | ||
) | ||
} |
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,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 } |
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,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 }) |
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 |
---|---|---|
|
@@ -4,6 +4,7 @@ | |
*/ | ||
|
||
import 'dotenv/config' | ||
import type { CookieOptions } from '@remix-run/node' | ||
import * as v from 'valibot' | ||
import { logger } from './common' | ||
|
||
|
@@ -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 { | ||
|
Oops, something went wrong.