Use zustand stores for language and theme (#724)
This commit is contained in:
parent
164ac259e5
commit
337ebf27bc
@ -1,17 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createContext, useContext, useEffect, useMemo } from 'react'
|
||||
|
||||
import { darkTheme } from './dark'
|
||||
import { lightTheme } from './light'
|
||||
import type { ThemeDefinition, ThemeName, ThemePreference, ThemeTokens } from './types'
|
||||
|
||||
const STORAGE_KEY = 'xcontrol:theme-preference'
|
||||
|
||||
const themeRegistry: Record<ThemeName, ThemeDefinition> = {
|
||||
light: lightTheme,
|
||||
dark: darkTheme,
|
||||
}
|
||||
import { getThemeDefinition, useThemeStore } from './store'
|
||||
import type { ThemeName, ThemePreference, ThemeTokens } from './types'
|
||||
|
||||
interface ThemeContextValue {
|
||||
theme: ThemeName
|
||||
@ -25,11 +17,12 @@ interface ThemeContextValue {
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined)
|
||||
|
||||
function applyTheme(definition: ThemeDefinition) {
|
||||
function applyTheme(theme: ThemeName) {
|
||||
if (typeof document === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
const definition = getThemeDefinition(theme)
|
||||
const root = document.documentElement
|
||||
root.dataset.theme = definition.name
|
||||
root.style.setProperty('color-scheme', definition.colorScheme)
|
||||
@ -55,108 +48,57 @@ function applyTheme(definition: ThemeDefinition) {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSystemTheme(): ThemeName {
|
||||
if (typeof window === 'undefined') {
|
||||
return 'light'
|
||||
}
|
||||
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
export interface ThemeProviderProps {
|
||||
children: React.ReactNode
|
||||
initialPreference?: ThemePreference
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children, initialPreference = 'system' }: ThemeProviderProps) {
|
||||
const [preference, setPreferenceState] = useState<ThemePreference>(initialPreference)
|
||||
const [theme, setTheme] = useState<ThemeName>(
|
||||
initialPreference === 'system' ? resolveSystemTheme() : initialPreference,
|
||||
)
|
||||
const hasLoadedPreferenceRef = useRef(false)
|
||||
const theme = useThemeStore((state) => state.theme)
|
||||
const preference = useThemeStore((state) => state.preference)
|
||||
const tokens = useThemeStore((state) => state.tokens)
|
||||
const colorScheme = useThemeStore((state) => state.colorScheme)
|
||||
const availableThemes = useThemeStore((state) => state.availableThemes)
|
||||
const setPreference = useThemeStore((state) => state.setPreference)
|
||||
const toggleTheme = useThemeStore((state) => state.toggleTheme)
|
||||
const hydrate = useThemeStore((state) => state.hydrate)
|
||||
const setSystemTheme = useThemeStore((state) => state.setSystemTheme)
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
const storedPreference = window.localStorage.getItem(STORAGE_KEY) as ThemePreference | null
|
||||
if (storedPreference === 'light' || storedPreference === 'dark' || storedPreference === 'system') {
|
||||
setPreferenceState(storedPreference)
|
||||
setTheme(storedPreference === 'system' ? resolveSystemTheme() : storedPreference)
|
||||
hasLoadedPreferenceRef.current = true
|
||||
return
|
||||
}
|
||||
|
||||
if (!hasLoadedPreferenceRef.current && initialPreference === 'system') {
|
||||
setTheme(resolveSystemTheme())
|
||||
setPreferenceState('system')
|
||||
}
|
||||
hasLoadedPreferenceRef.current = true
|
||||
}, [initialPreference])
|
||||
hydrate(initialPreference)
|
||||
}, [hydrate, initialPreference])
|
||||
|
||||
useEffect(() => {
|
||||
if (preference === 'system') {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.removeItem(STORAGE_KEY)
|
||||
setTheme(resolveSystemTheme())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem(STORAGE_KEY, preference)
|
||||
}
|
||||
setTheme(preference)
|
||||
}, [preference])
|
||||
|
||||
useEffect(() => {
|
||||
applyTheme(themeRegistry[theme])
|
||||
applyTheme(theme)
|
||||
}, [theme])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const handleChange = (event: MediaQueryListEvent) => {
|
||||
setTheme((current) => {
|
||||
if (preference === 'system') {
|
||||
return event.matches ? 'dark' : 'light'
|
||||
}
|
||||
return current
|
||||
})
|
||||
setSystemTheme(event.matches ? 'dark' : 'light')
|
||||
}
|
||||
handleChange(mediaQuery as unknown as MediaQueryListEvent)
|
||||
mediaQuery.addEventListener('change', handleChange)
|
||||
return () => {
|
||||
mediaQuery.removeEventListener('change', handleChange)
|
||||
}
|
||||
}, [preference])
|
||||
|
||||
const setPreference = useCallback((nextPreference: ThemePreference) => {
|
||||
setPreferenceState(nextPreference)
|
||||
}, [])
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
setPreferenceState((current) => {
|
||||
if (current === 'system') {
|
||||
return theme === 'light' ? 'dark' : 'light'
|
||||
}
|
||||
return current === 'light' ? 'dark' : 'light'
|
||||
})
|
||||
}, [theme])
|
||||
}, [setSystemTheme])
|
||||
|
||||
const value = useMemo<ThemeContextValue>(
|
||||
() => ({
|
||||
theme,
|
||||
preference,
|
||||
tokens: themeRegistry[theme].tokens,
|
||||
colorScheme: themeRegistry[theme].colorScheme,
|
||||
availableThemes: Object.keys(themeRegistry) as ThemeName[],
|
||||
tokens,
|
||||
colorScheme,
|
||||
availableThemes,
|
||||
setPreference,
|
||||
toggleTheme,
|
||||
}),
|
||||
[preference, setPreference, theme, toggleTheme],
|
||||
[availableThemes, colorScheme, preference, setPreference, theme, tokens, toggleTheme],
|
||||
)
|
||||
|
||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
|
||||
|
||||
104
dashboard/src/components/theme/store.ts
Normal file
104
dashboard/src/components/theme/store.ts
Normal file
@ -0,0 +1,104 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
import { darkTheme } from './dark'
|
||||
import { lightTheme } from './light'
|
||||
import type { ThemeDefinition, ThemeName, ThemePreference, ThemeTokens } from './types'
|
||||
|
||||
const STORAGE_KEY = 'xcontrol:theme-preference'
|
||||
|
||||
const themeRegistry: Record<ThemeName, ThemeDefinition> = {
|
||||
light: lightTheme,
|
||||
dark: darkTheme,
|
||||
}
|
||||
|
||||
const availableThemes = Object.keys(themeRegistry) as ThemeName[]
|
||||
|
||||
function resolveSystemTheme(): ThemeName {
|
||||
if (typeof window === 'undefined') {
|
||||
return 'light'
|
||||
}
|
||||
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
function readStoredPreference(defaultPreference: ThemePreference): ThemePreference {
|
||||
if (typeof window === 'undefined') {
|
||||
return defaultPreference
|
||||
}
|
||||
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY) as ThemePreference | null
|
||||
if (stored === 'light' || stored === 'dark' || stored === 'system') {
|
||||
return stored
|
||||
}
|
||||
|
||||
return defaultPreference
|
||||
}
|
||||
|
||||
function persistPreference(preference: ThemePreference) {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
if (preference === 'system') {
|
||||
window.localStorage.removeItem(STORAGE_KEY)
|
||||
return
|
||||
}
|
||||
|
||||
window.localStorage.setItem(STORAGE_KEY, preference)
|
||||
}
|
||||
|
||||
function buildThemeState(preference: ThemePreference, theme: ThemeName): ThemeState {
|
||||
const definition = themeRegistry[theme]
|
||||
return {
|
||||
preference,
|
||||
theme,
|
||||
tokens: definition.tokens,
|
||||
colorScheme: definition.colorScheme,
|
||||
availableThemes,
|
||||
}
|
||||
}
|
||||
|
||||
export type ThemeState = {
|
||||
preference: ThemePreference
|
||||
theme: ThemeName
|
||||
tokens: ThemeTokens
|
||||
colorScheme: 'light' | 'dark'
|
||||
availableThemes: ThemeName[]
|
||||
}
|
||||
|
||||
export type ThemeActions = {
|
||||
hydrate: (initialPreference: ThemePreference) => void
|
||||
setPreference: (preference: ThemePreference) => void
|
||||
toggleTheme: () => void
|
||||
setSystemTheme: (theme: ThemeName) => void
|
||||
}
|
||||
|
||||
export const useThemeStore = create<ThemeState & ThemeActions>((set, get) => ({
|
||||
...buildThemeState('system', resolveSystemTheme()),
|
||||
hydrate: (initialPreference) => {
|
||||
const preference = readStoredPreference(initialPreference)
|
||||
const resolvedTheme = preference === 'system' ? resolveSystemTheme() : preference
|
||||
set(buildThemeState(preference, resolvedTheme))
|
||||
},
|
||||
setPreference: (preference) => {
|
||||
const resolvedTheme = preference === 'system' ? resolveSystemTheme() : preference
|
||||
persistPreference(preference)
|
||||
set(buildThemeState(preference, resolvedTheme))
|
||||
},
|
||||
toggleTheme: () => {
|
||||
const nextTheme = get().theme === 'light' ? 'dark' : 'light'
|
||||
get().setPreference(nextTheme)
|
||||
},
|
||||
setSystemTheme: (theme) => {
|
||||
set((state) => {
|
||||
if (state.preference !== 'system') {
|
||||
return state
|
||||
}
|
||||
return buildThemeState('system', theme)
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
export function getThemeDefinition(theme: ThemeName): ThemeDefinition {
|
||||
return themeRegistry[theme]
|
||||
}
|
||||
@ -1,67 +1,87 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext, useEffect, useState } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { create } from 'zustand'
|
||||
|
||||
export type Language = 'en' | 'zh'
|
||||
|
||||
type LanguageContextType = {
|
||||
type LanguageState = {
|
||||
language: Language
|
||||
setLanguage: (lang: Language) => void
|
||||
hydrateLanguage: () => void
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'cloudnative-suite.language'
|
||||
|
||||
const LanguageContext = createContext<LanguageContextType>({
|
||||
language: 'zh',
|
||||
setLanguage: () => {},
|
||||
})
|
||||
|
||||
export function LanguageProvider({ children }: { children: React.ReactNode }) {
|
||||
const [language, setLanguage] = useState<Language>(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY)
|
||||
if (stored === 'en' || stored === 'zh') {
|
||||
return stored
|
||||
}
|
||||
|
||||
const [primaryLocale] = window.navigator.languages && window.navigator.languages.length
|
||||
? window.navigator.languages
|
||||
: [window.navigator.language]
|
||||
if (typeof primaryLocale === 'string') {
|
||||
const normalized = primaryLocale.toLowerCase()
|
||||
if (normalized.startsWith('en')) {
|
||||
return 'en'
|
||||
}
|
||||
if (normalized.startsWith('zh')) {
|
||||
return 'zh'
|
||||
}
|
||||
}
|
||||
}
|
||||
function detectPreferredLanguage(): Language {
|
||||
if (typeof window === 'undefined') {
|
||||
return 'zh'
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY)
|
||||
if (stored === 'en' || stored === 'zh') {
|
||||
return stored
|
||||
}
|
||||
|
||||
const [primaryLocale] = window.navigator.languages?.length
|
||||
? window.navigator.languages
|
||||
: [window.navigator.language]
|
||||
|
||||
if (typeof primaryLocale === 'string') {
|
||||
const normalized = primaryLocale.toLowerCase()
|
||||
if (normalized.startsWith('en')) {
|
||||
return 'en'
|
||||
}
|
||||
if (normalized.startsWith('zh')) {
|
||||
return 'zh'
|
||||
}
|
||||
}
|
||||
|
||||
return 'zh'
|
||||
}
|
||||
|
||||
function syncDocumentLanguage(language: Language) {
|
||||
if (typeof document === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
document.documentElement.lang = language
|
||||
document.documentElement.dataset.language = language
|
||||
}
|
||||
|
||||
export const useLanguageStore = create<LanguageState>((set) => ({
|
||||
language: detectPreferredLanguage(),
|
||||
setLanguage: (language) => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem(STORAGE_KEY, language)
|
||||
}
|
||||
}, [language])
|
||||
syncDocumentLanguage(language)
|
||||
set({ language })
|
||||
},
|
||||
hydrateLanguage: () => {
|
||||
const preferred = detectPreferredLanguage()
|
||||
syncDocumentLanguage(preferred)
|
||||
set({ language: preferred })
|
||||
},
|
||||
}))
|
||||
|
||||
export function LanguageProvider({ children }: { children: React.ReactNode }) {
|
||||
const language = useLanguageStore((state) => state.language)
|
||||
const hydrateLanguage = useLanguageStore((state) => state.hydrateLanguage)
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') {
|
||||
return
|
||||
}
|
||||
hydrateLanguage()
|
||||
}, [hydrateLanguage])
|
||||
|
||||
document.documentElement.lang = language
|
||||
document.documentElement.dataset.language = language
|
||||
useEffect(() => {
|
||||
syncDocumentLanguage(language)
|
||||
}, [language])
|
||||
|
||||
return (
|
||||
<LanguageContext.Provider value={{ language, setLanguage }}>
|
||||
{children}
|
||||
</LanguageContext.Provider>
|
||||
)
|
||||
return children
|
||||
}
|
||||
|
||||
export function useLanguage() {
|
||||
return useContext(LanguageContext)
|
||||
const language = useLanguageStore((state) => state.language)
|
||||
const setLanguage = useLanguageStore((state) => state.setLanguage)
|
||||
return { language, setLanguage }
|
||||
}
|
||||
|
||||
@ -3,8 +3,8 @@
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import Card from '../components/Card'
|
||||
import { darkTheme, lightTheme, useTheme } from '../routes/theme'
|
||||
import type { ThemeDefinition, ThemeName, ThemePreference } from '../routes/theme'
|
||||
import { darkTheme, lightTheme, useTheme } from '@components/theme'
|
||||
import type { ThemeDefinition, ThemeName, ThemePreference } from '@components/theme'
|
||||
|
||||
const THEME_OPTIONS: Array<{
|
||||
value: ThemePreference
|
||||
|
||||
@ -1,74 +1,5 @@
|
||||
import ThemePreferenceCard from '../account/ThemePreferenceCard'
|
||||
|
||||
export type ThemeName = 'light' | 'dark'
|
||||
export type ThemePreference = 'system' | 'light' | 'dark'
|
||||
|
||||
export type ThemeDefinition = {
|
||||
name: ThemeName
|
||||
tokens: {
|
||||
colors: Record<string, string>
|
||||
shadows: Record<string, string>
|
||||
}
|
||||
}
|
||||
|
||||
export type ThemeContextValue = {
|
||||
preference: ThemePreference | null
|
||||
theme: ThemeName
|
||||
setPreference: (preference: ThemePreference) => void
|
||||
}
|
||||
|
||||
// Stub theme definitions for now
|
||||
export const lightTheme: ThemeDefinition = {
|
||||
name: 'light',
|
||||
tokens: {
|
||||
colors: {
|
||||
background: '#ffffff',
|
||||
text: '#1f2937',
|
||||
'text-subtle': '#6b7280',
|
||||
surface: '#f9fafb',
|
||||
'surface-elevated': '#ffffff',
|
||||
'surface-border': '#e5e7eb',
|
||||
'surface-muted': '#f3f4f6',
|
||||
primary: '#3b82f6',
|
||||
'primary-foreground': '#ffffff',
|
||||
'primary-muted': '#dbeafe',
|
||||
},
|
||||
shadows: {
|
||||
sm: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const darkTheme: ThemeDefinition = {
|
||||
name: 'dark',
|
||||
tokens: {
|
||||
colors: {
|
||||
background: '#111827',
|
||||
text: '#f9fafb',
|
||||
'text-subtle': '#9ca3af',
|
||||
surface: '#1f2937',
|
||||
'surface-elevated': '#374151',
|
||||
'surface-border': '#4b5563',
|
||||
'surface-muted': '#1f2937',
|
||||
primary: '#60a5fa',
|
||||
'primary-foreground': '#1f2937',
|
||||
'primary-muted': '#1e3a8a',
|
||||
},
|
||||
shadows: {
|
||||
sm: '0 1px 2px 0 rgba(0, 0, 0, 0.3)',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Stub useTheme hook
|
||||
export function useTheme(): ThemeContextValue {
|
||||
return {
|
||||
preference: 'system',
|
||||
theme: 'light',
|
||||
setPreference: () => {},
|
||||
}
|
||||
}
|
||||
|
||||
export default function UserCenterThemeRoute() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user