- Add Gitea Actions workflow for automated build & deploy - Add K8s manifests: webui, travel-svc, medicine-svc, sandbox-svc - Update kustomization for localhost:5000 registry - Add ingress for gooseek.ru and api.gooseek.ru - Learning cabinet with onboarding, courses, sandbox integration - Medicine service with symptom analysis and doctor matching - Travel service with itinerary planning - Server setup scripts (NVIDIA/CUDA, K3s, Gitea runner) Made-with: Cursor
95 lines
2.6 KiB
TypeScript
95 lines
2.6 KiB
TypeScript
'use client';
|
|
|
|
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
|
|
|
export type ThemeMode = 'light' | 'dim';
|
|
|
|
const THEME_STORAGE_KEY = 'gooseek_theme';
|
|
|
|
function readStoredTheme(): ThemeMode | null {
|
|
try {
|
|
const v = localStorage.getItem(THEME_STORAGE_KEY);
|
|
if (v === 'light' || v === 'dim') return v;
|
|
return null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function prefersDark(): boolean {
|
|
if (typeof window === 'undefined') return false;
|
|
return !!window.matchMedia?.('(prefers-color-scheme: dark)')?.matches;
|
|
}
|
|
|
|
function applyTheme(theme: ThemeMode): void {
|
|
if (typeof document === 'undefined') return;
|
|
const root = document.documentElement;
|
|
if (theme === 'dim') root.classList.add('theme-dim');
|
|
else root.classList.remove('theme-dim');
|
|
try {
|
|
localStorage.setItem(THEME_STORAGE_KEY, theme);
|
|
} catch {
|
|
// ignore storage failures (private mode, quota, etc.)
|
|
}
|
|
}
|
|
|
|
function resolveInitialTheme(): ThemeMode {
|
|
if (typeof document !== 'undefined' && document.documentElement.classList.contains('theme-dim')) {
|
|
return 'dim';
|
|
}
|
|
const stored = typeof window !== 'undefined' ? readStoredTheme() : null;
|
|
if (stored) return stored;
|
|
return prefersDark() ? 'dim' : 'light';
|
|
}
|
|
|
|
type ThemeContextValue = {
|
|
theme: ThemeMode;
|
|
setTheme: (theme: ThemeMode) => void;
|
|
toggleTheme: () => void;
|
|
};
|
|
|
|
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
|
|
|
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
|
const [theme, setThemeState] = useState<ThemeMode>('light');
|
|
|
|
useEffect(() => {
|
|
const initial = resolveInitialTheme();
|
|
setThemeState(initial);
|
|
applyTheme(initial);
|
|
}, []);
|
|
|
|
const setTheme = useCallback((next: ThemeMode) => {
|
|
setThemeState(next);
|
|
applyTheme(next);
|
|
}, []);
|
|
|
|
const toggleTheme = useCallback(() => {
|
|
setTheme(theme === 'light' ? 'dim' : 'light');
|
|
}, [theme, setTheme]);
|
|
|
|
useEffect(() => {
|
|
const onStorage = (e: StorageEvent) => {
|
|
if (e.key !== THEME_STORAGE_KEY) return;
|
|
const v = e.newValue;
|
|
if (v !== 'light' && v !== 'dim') return;
|
|
setThemeState(v);
|
|
applyTheme(v);
|
|
};
|
|
window.addEventListener('storage', onStorage);
|
|
return () => window.removeEventListener('storage', onStorage);
|
|
}, []);
|
|
|
|
const value = useMemo(() => ({ theme, setTheme, toggleTheme }), [theme, setTheme, toggleTheme]);
|
|
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
|
}
|
|
|
|
export function useTheme(): ThemeContextValue {
|
|
const ctx = useContext(ThemeContext);
|
|
if (!ctx) {
|
|
throw new Error('useTheme must be used within ThemeProvider');
|
|
}
|
|
return ctx;
|
|
}
|
|
|