Skip to content

Commit ae9c005

Browse files
snomiaoclaude
andcommitted
feat: implement light/dark mode theme switching
- Add ThemeProvider with system preference detection - Create ThemeSwitcher component with sun/moon icons - Update header styling to support theme switching - Configure Tailwind CSS for dark mode support - Refactor theme configurations for reusability - Update global styles for proper theme transitions 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent 5f0dd6d commit ae9c005

File tree

8 files changed

+440
-36
lines changed

8 files changed

+440
-36
lines changed

components/Header/Header.tsx

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ import { FaDiscord, FaGithub } from 'react-icons/fa'
88
import logoBluePng from '@/src/assets/images/logo_blue.png'
99
import { useFromUrlParam } from '../common/HOC/useFromUrl'
1010
import LanguageSwitcher from '../common/LanguageSwitcher'
11+
import ThemeSwitcher from '../common/ThemeSwitcher'
1112
import ProfileDropdown from './ProfileDropdown'
13+
import { themeConfig } from '@/utils/themeConfig'
1214

1315
interface HeaderProps {
1416
isLoggedIn?: boolean
@@ -32,9 +34,8 @@ const Header: React.FC<HeaderProps> = ({ isLoggedIn, title }) => {
3234
return (
3335
<Navbar
3436
fluid
35-
className="mx-auto p-8"
37+
className={`mx-auto p-8 ${themeConfig.header.background}`}
3638
style={{
37-
backgroundColor: 'rgb(17 24 39)',
3839
paddingLeft: 0,
3940
paddingRight: 0,
4041
}}
@@ -47,17 +48,23 @@ const Header: React.FC<HeaderProps> = ({ isLoggedIn, title }) => {
4748
height={36}
4849
className="w-6 h-6 mr-3 sm:w-9 sm:h-9 rounded-lg"
4950
/>
50-
<span className="self-center text-xl font-semibold text-white whitespace-nowrap">
51+
<span
52+
className={`self-center text-xl font-semibold whitespace-nowrap ${themeConfig.header.text}`}
53+
>
5154
{t('Comfy Registry')}
5255
</span>
5356
</Link>
54-
<div className="flex items-center gap-2 bg-gray-900 md:order-2">
57+
<div
58+
className={`flex items-center gap-2 md:order-2 ${themeConfig.header.background}`}
59+
>
5560
{isLoggedIn ? (
5661
<ProfileDropdown />
5762
) : (
5863
<>
5964
<Button onClick={handleLogIn} color="dark" size="xs">
60-
<span className="text-white text-xs md:text-base">
65+
<span
66+
className={`text-xs md:text-base ${themeConfig.header.text}`}
67+
>
6168
{t('Login')}
6269
</span>
6370
</Button>
@@ -78,7 +85,9 @@ const Header: React.FC<HeaderProps> = ({ isLoggedIn, title }) => {
7885
color="blue"
7986
size="xs"
8087
>
81-
<span className="text-white text-xs md:text-base">
88+
<span
89+
className={`text-xs md:text-base ${themeConfig.header.text}`}
90+
>
8291
{t('Documentation')}
8392
</span>
8493
</Button>
@@ -90,6 +99,7 @@ const Header: React.FC<HeaderProps> = ({ isLoggedIn, title }) => {
9099
size="xs"
91100
/>
92101

102+
<ThemeSwitcher />
93103
{/* place in the most-right to reduce ... when switching language */}
94104
<LanguageSwitcher />
95105
</div>
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import React from 'react'
2+
import { Dropdown, DropdownItem } from 'flowbite-react'
3+
import { useTheme, Theme } from '@/src/hooks/useTheme'
4+
import { useNextTranslation } from '@/src/hooks/i18n'
5+
import { HiSun, HiMoon, HiDesktopComputer } from 'react-icons/hi'
6+
7+
const ThemeIcon = ({
8+
theme,
9+
actualTheme,
10+
}: {
11+
theme: Theme
12+
actualTheme: 'light' | 'dark'
13+
}) => {
14+
if (theme === 'auto') {
15+
return <HiDesktopComputer className="w-4 h-4" />
16+
}
17+
if (theme === 'light') {
18+
return <HiSun className="w-4 h-4" />
19+
}
20+
return <HiMoon className="w-4 h-4" />
21+
}
22+
23+
export default function ThemeSwitcher() {
24+
const { theme, actualTheme, setTheme } = useTheme()
25+
const { t } = useNextTranslation()
26+
27+
const themeOptions: {
28+
value: Theme
29+
label: string
30+
icon: React.ReactNode
31+
}[] = [
32+
{
33+
value: 'auto',
34+
label: t('Auto'),
35+
icon: <HiDesktopComputer className="w-4 h-4" />,
36+
},
37+
{
38+
value: 'light',
39+
label: t('Light'),
40+
icon: <HiSun className="w-4 h-4" />,
41+
},
42+
{
43+
value: 'dark',
44+
label: t('Dark'),
45+
icon: <HiMoon className="w-4 h-4" />,
46+
},
47+
]
48+
49+
const currentOption = themeOptions.find((option) => option.value === theme)
50+
51+
return (
52+
<Dropdown
53+
label=""
54+
renderTrigger={() => (
55+
<button
56+
type="button"
57+
className="inline-flex items-center justify-center p-2 w-8 h-8 text-sm text-gray-500 bg-white border border-gray-300 rounded-lg hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-200 dark:text-gray-400 dark:bg-gray-800 dark:border-gray-600 dark:hover:bg-gray-700 dark:focus:ring-gray-600"
58+
aria-label="Toggle theme"
59+
>
60+
<ThemeIcon theme={theme} actualTheme={actualTheme} />
61+
</button>
62+
)}
63+
color="gray"
64+
size="xs"
65+
dismissOnClick
66+
>
67+
{themeOptions.map((option) => (
68+
<DropdownItem
69+
key={option.value}
70+
onClick={() => setTheme(option.value)}
71+
className={`flex items-center gap-2 ${theme === option.value ? 'font-bold' : ''}`}
72+
>
73+
{option.icon}
74+
{option.label}
75+
</DropdownItem>
76+
))}
77+
</Dropdown>
78+
)
79+
}

pages/_app.tsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type { AppProps } from 'next/app'
88
import { useEffect } from 'react'
99
import FlowBiteThemeProvider from '../components/flowbite-theme'
1010
import Layout from '../components/layout'
11+
import { ThemeProvider } from '@/src/hooks/useTheme'
1112
import '../styles/globals.css'
1213
import { AxiosRequestConfig, AxiosResponse } from 'axios'
1314
import { request } from 'http'
@@ -114,11 +115,13 @@ function MyApp({ Component, pageProps }: AppProps) {
114115

115116
return (
116117
<QueryClientProvider client={queryClient}>
117-
<FlowBiteThemeProvider>
118-
<Layout>
119-
<Component {...pageProps} />
120-
</Layout>
121-
</FlowBiteThemeProvider>
118+
<ThemeProvider>
119+
<FlowBiteThemeProvider>
120+
<Layout>
121+
<Component {...pageProps} />
122+
</Layout>
123+
</FlowBiteThemeProvider>
124+
</ThemeProvider>
122125
</QueryClientProvider>
123126
)
124127
}

src/hooks/useTheme.tsx

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import React, {
2+
createContext,
3+
useContext,
4+
useEffect,
5+
useState,
6+
useCallback,
7+
} from 'react'
8+
9+
export type Theme = 'light' | 'dark' | 'auto'
10+
11+
interface ThemeContextType {
12+
theme: Theme
13+
actualTheme: 'light' | 'dark'
14+
setTheme: (theme: Theme) => void
15+
}
16+
17+
const ThemeContext = createContext<ThemeContextType | undefined>(undefined)
18+
19+
const THEME_STORAGE_KEY = 'comfy-registry-theme'
20+
21+
export function ThemeProvider({ children }: { children: React.ReactNode }) {
22+
const [theme, setThemeState] = useState<Theme>('auto')
23+
const [actualTheme, setActualTheme] = useState<'light' | 'dark'>('dark')
24+
25+
// Get system preference
26+
const getSystemTheme = (): 'light' | 'dark' => {
27+
if (typeof window === 'undefined') return 'dark'
28+
return window.matchMedia('(prefers-color-scheme: dark)').matches
29+
? 'dark'
30+
: 'light'
31+
}
32+
33+
// Calculate actual theme based on setting
34+
const calculateActualTheme = (themeValue: Theme): 'light' | 'dark' => {
35+
if (themeValue === 'auto') {
36+
return getSystemTheme()
37+
}
38+
return themeValue
39+
}
40+
41+
// Load theme from localStorage on mount
42+
useEffect(() => {
43+
const savedTheme = localStorage.getItem(THEME_STORAGE_KEY) as Theme
44+
const initialTheme = savedTheme || 'auto'
45+
setThemeState(initialTheme)
46+
setActualTheme(calculateActualTheme(initialTheme))
47+
}, [calculateActualTheme])
48+
49+
// Listen to system theme changes
50+
useEffect(() => {
51+
if (typeof window === 'undefined') return
52+
53+
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
54+
const handleChange = () => {
55+
if (theme === 'auto') {
56+
setActualTheme(getSystemTheme())
57+
}
58+
}
59+
60+
mediaQuery.addEventListener('change', handleChange)
61+
return () => mediaQuery.removeEventListener('change', handleChange)
62+
}, [theme])
63+
64+
// Update document class and actual theme when theme changes
65+
useEffect(() => {
66+
const newActualTheme = calculateActualTheme(theme)
67+
setActualTheme(newActualTheme)
68+
69+
if (typeof document !== 'undefined') {
70+
document.documentElement.classList.remove('light', 'dark')
71+
document.documentElement.classList.add(newActualTheme)
72+
}
73+
}, [theme])
74+
75+
const setTheme = (newTheme: Theme) => {
76+
setThemeState(newTheme)
77+
localStorage.setItem(THEME_STORAGE_KEY, newTheme)
78+
}
79+
80+
return (
81+
<ThemeContext.Provider value={{ theme, actualTheme, setTheme }}>
82+
{children}
83+
</ThemeContext.Provider>
84+
)
85+
}
86+
87+
export function useTheme() {
88+
const context = useContext(ThemeContext)
89+
if (context === undefined) {
90+
throw new Error('useTheme must be used within a ThemeProvider')
91+
}
92+
return context
93+
}

styles/globals.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
@tailwind utilities;
66

77
body {
8-
background-color: rgb(17 24 39);
8+
@apply bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white;
99
}
1010
.placeholder-white::placeholder {
1111
color: white !important;

tailwind.config.cjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/** @type {import('tailwindcss').Config} */
22
module.exports = {
33
// important: true,
4+
darkMode: 'class',
45
content: [
56
'./pages/**/*.{js,ts,jsx,tsx}',
67
'./components/**/*.{js,ts,jsx,tsx}',

0 commit comments

Comments
 (0)