- Add Stalwart API client library (lib/stalwart/client.ts) - Add server-side proxy routes for auth, crypto, password, principal, probe - Add account security Zustand store with full state management - Add Security settings tab with password change, display name, TOTP 2FA, app passwords, and encryption-at-rest controls - Add stalwartFeaturesEnabled config flag (opt-out via STALWART_FEATURES=false) - Add i18n translations for all 8 locales (en, de, es, fr, it, ja, nl, pt) - Add tests for Stalwart client (24 tests) and security store (29 tests)
105 lines
3.0 KiB
TypeScript
105 lines
3.0 KiB
TypeScript
import { create } from 'zustand';
|
|
import { persist } from 'zustand/middleware';
|
|
|
|
type Theme = 'light' | 'dark' | 'system';
|
|
|
|
interface ThemeState {
|
|
theme: Theme;
|
|
resolvedTheme: 'light' | 'dark';
|
|
hydrated: boolean;
|
|
setTheme: (theme: Theme) => void;
|
|
toggleTheme: () => void;
|
|
initializeTheme: () => void;
|
|
}
|
|
|
|
const getSystemTheme = (): 'light' | 'dark' => {
|
|
if (typeof window === 'undefined') return 'light';
|
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
};
|
|
|
|
const applyTheme = (theme: 'light' | 'dark') => {
|
|
if (typeof document === 'undefined') return;
|
|
|
|
const root = document.documentElement;
|
|
if (theme === 'dark') {
|
|
root.classList.remove('light');
|
|
root.classList.add('dark');
|
|
} else {
|
|
root.classList.remove('dark');
|
|
root.classList.add('light');
|
|
}
|
|
|
|
// Also update color-scheme for native elements (scrollbars, form controls)
|
|
root.style.colorScheme = theme;
|
|
|
|
localStorage.setItem('theme-applied', theme);
|
|
};
|
|
|
|
let mediaQueryCleanup: (() => void) | null = null;
|
|
|
|
export const useThemeStore = create<ThemeState>()(
|
|
persist(
|
|
(set, get) => ({
|
|
theme: 'system',
|
|
resolvedTheme: 'light',
|
|
hydrated: false,
|
|
|
|
setTheme: (theme) => {
|
|
const resolvedTheme = theme === 'system' ? getSystemTheme() : theme;
|
|
applyTheme(resolvedTheme);
|
|
set({ theme, resolvedTheme });
|
|
},
|
|
|
|
toggleTheme: () => {
|
|
const { theme } = get();
|
|
const nextTheme: Theme =
|
|
theme === 'light' ? 'dark' :
|
|
theme === 'dark' ? 'system' : 'light';
|
|
get().setTheme(nextTheme);
|
|
},
|
|
|
|
initializeTheme: () => {
|
|
const { theme } = get();
|
|
const resolvedTheme = theme === 'system' ? getSystemTheme() : theme;
|
|
applyTheme(resolvedTheme);
|
|
set({ resolvedTheme, hydrated: true });
|
|
|
|
// Clean up previous listener if any
|
|
if (mediaQueryCleanup) {
|
|
mediaQueryCleanup();
|
|
mediaQueryCleanup = null;
|
|
}
|
|
|
|
if (typeof window !== 'undefined') {
|
|
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
|
const handleChange = () => {
|
|
const { theme } = get();
|
|
if (theme === 'system') {
|
|
const newResolvedTheme = getSystemTheme();
|
|
applyTheme(newResolvedTheme);
|
|
set({ resolvedTheme: newResolvedTheme });
|
|
}
|
|
};
|
|
|
|
mediaQuery.addEventListener('change', handleChange);
|
|
mediaQueryCleanup = () => mediaQuery.removeEventListener('change', handleChange);
|
|
}
|
|
},
|
|
}),
|
|
{
|
|
name: 'theme-storage',
|
|
partialize: (state) => ({ theme: state.theme }),
|
|
onRehydrateStorage: () => {
|
|
return (state) => {
|
|
if (state) {
|
|
// Re-apply theme immediately after rehydration
|
|
const resolvedTheme = state.theme === 'system' ? getSystemTheme() : state.theme;
|
|
applyTheme(resolvedTheme);
|
|
state.resolvedTheme = resolvedTheme;
|
|
state.hydrated = true;
|
|
}
|
|
};
|
|
},
|
|
}
|
|
)
|
|
); |