diff --git a/dashboard/src/components/theme/ThemeProvider.tsx b/dashboard/src/components/theme/ThemeProvider.tsx index e6b31c7..f2e1beb 100644 --- a/dashboard/src/components/theme/ThemeProvider.tsx +++ b/dashboard/src/components/theme/ThemeProvider.tsx @@ -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 = { - 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(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(initialPreference) - const [theme, setTheme] = useState( - 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( () => ({ 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 {children} diff --git a/dashboard/src/components/theme/store.ts b/dashboard/src/components/theme/store.ts new file mode 100644 index 0000000..14d344c --- /dev/null +++ b/dashboard/src/components/theme/store.ts @@ -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 = { + 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((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] +} diff --git a/dashboard/src/i18n/LanguageProvider.tsx b/dashboard/src/i18n/LanguageProvider.tsx index 3c58d87..a8d2da4 100644 --- a/dashboard/src/i18n/LanguageProvider.tsx +++ b/dashboard/src/i18n/LanguageProvider.tsx @@ -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({ - language: 'zh', - setLanguage: () => {}, -}) - -export function LanguageProvider({ children }: { children: React.ReactNode }) { - const [language, setLanguage] = useState(() => { - 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((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 ( - - {children} - - ) + return children } export function useLanguage() { - return useContext(LanguageContext) + const language = useLanguageStore((state) => state.language) + const setLanguage = useLanguageStore((state) => state.setLanguage) + return { language, setLanguage } } diff --git a/dashboard/src/modules/extensions/builtin/user-center/account/ThemePreferenceCard.tsx b/dashboard/src/modules/extensions/builtin/user-center/account/ThemePreferenceCard.tsx index d145a86..a02348c 100644 --- a/dashboard/src/modules/extensions/builtin/user-center/account/ThemePreferenceCard.tsx +++ b/dashboard/src/modules/extensions/builtin/user-center/account/ThemePreferenceCard.tsx @@ -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 diff --git a/dashboard/src/modules/extensions/builtin/user-center/routes/theme.tsx b/dashboard/src/modules/extensions/builtin/user-center/routes/theme.tsx index cf6737d..d663a05 100644 --- a/dashboard/src/modules/extensions/builtin/user-center/routes/theme.tsx +++ b/dashboard/src/modules/extensions/builtin/user-center/routes/theme.tsx @@ -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 - shadows: Record - } -} - -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 (