Use zustand stores for language and theme (#724)

This commit is contained in:
shenlan 2025-12-02 15:17:24 +08:00 committed by GitHub
parent 164ac259e5
commit 337ebf27bc
5 changed files with 193 additions and 196 deletions

View File

@ -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>

View 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]
}

View File

@ -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 }
}

View File

@ -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

View File

@ -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">