/** * ThemeProvider с поддержкой скинов. * Управляет data-skin / data-theme на и runtime CSS для пользовательских скинов. */ import { createContext, useContext, useEffect, useLayoutEffect, useState, useCallback, useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; import type { Skin, DensityPreset } from './tokens'; import { BUILT_IN_SKINS, DEFAULT_SKIN_ID, getBuiltInSkinById, isBuiltInSkin } from './index'; import { generateSkinCss } from './tokens'; import { useAuth } from '@/hooks/useAuth'; import { apiRequest } from '@/lib/queryClient'; const THEME_STORAGE_KEY = 'theme'; const SKIN_STORAGE_KEY = 'skin'; export type Theme = 'light' | 'dark'; /** Тема 2.0: структурные варианты активного скина, разрешённые с дефолтами. */ export interface ResolvedSkinComponents { control: { variant: 'outline' | 'filled' | 'underline'; radius: 'sm' | 'md' | 'none' }; label: { position: 'top' | 'inline'; casing: 'none' | 'uppercase'; weight: 'regular' | 'medium' }; form: { columns: 1 | 2 }; dialog: { mobile: 'sheet' | 'modal'; backdrop: 'dim' | 'blur' }; table: { dividers: 'rows' | 'grid' | 'none'; header: 'muted' | 'plain' }; button: { radius: 'sm' | 'md' | 'pill' }; motion: { enabled: boolean }; density: { preset: DensityPreset }; layout: { taskDetail: 'compact' | 'vertical' | 'pyrus' }; } /** Дефолты соответствуют текущему виду интерфейса (до Темы 2.0). */ // label.position: 'inline' — текущий вид карточки задачи (label и значение в одной строке); // 'top' задаётся скином явно (например, скин pyrus) или пресетом представления. export const DEFAULT_SKIN_COMPONENTS: ResolvedSkinComponents = { control: { variant: 'outline', radius: 'md' }, label: { position: 'inline', casing: 'none', weight: 'medium' }, form: { columns: 2 }, dialog: { mobile: 'modal', backdrop: 'dim' }, table: { dividers: 'rows', header: 'muted' }, button: { radius: 'md' }, motion: { enabled: true }, density: { preset: 'comfortable' }, layout: { taskDetail: 'compact' }, }; interface ThemeContextType { theme: Theme; setTheme: (theme: Theme) => void; toggleTheme: () => void; skinId: string; setSkinId: (skinId: string) => void; availableSkins: Skin[]; skinComponents: ResolvedSkinComponents; } const ThemeContext = createContext(undefined); function getSystemTheme(): Theme { if (typeof window === 'undefined') return 'light'; return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; } function getInitialTheme(): Theme { if (typeof window === 'undefined') return 'light'; const stored = localStorage.getItem(THEME_STORAGE_KEY); if (stored === 'light' || stored === 'dark') return stored; return getSystemTheme(); } function getStoredSkinId(): string | null { if (typeof window === 'undefined') return null; return localStorage.getItem(SKIN_STORAGE_KEY); } /** Вставляет или обновляет runtime CSS для пользовательского скина. */ function applyRuntimeSkinCss(skinId: string, theme: Theme, skinTokens?: Skin['tokens']) { if (!skinTokens || !skinTokens[theme]) { const style = document.getElementById('skin-runtime'); if (style) style.textContent = ''; return; } // Единый генератор: flatten токенов + переменные Темы 2.0 + motion-правило const css = generateSkinCss({ id: skinId, name: skinId, tokens: skinTokens }, theme); let style = document.getElementById('skin-runtime'); if (!style) { style = document.createElement('style'); style.id = 'skin-runtime'; document.head.appendChild(style); } style.textContent = css; } interface ThemeProviderProps { children: React.ReactNode; } export function ThemeProvider({ children }: ThemeProviderProps) { const { user } = useAuth(); const [theme, setThemeState] = useState(getInitialTheme); const [skinId, setSkinIdState] = useState(DEFAULT_SKIN_ID); const [customSkinsMap, setCustomSkinsMap] = useState>({}); const organizationSkinId = user?.organization?.activeSkinId; // Загружаем пользовательские скины организации. const { data: customSkinsData } = useQuery({ queryKey: ['/api/organization/skins'], queryFn: async () => { const res = await apiRequest('GET', '/api/organization/skins'); return res.json() as Promise<{ success: boolean; skins: Array<{ id: string; dbId?: number; name: string; description?: string; isEditable: boolean; isBuiltIn: boolean }> }>; }, enabled: !!user, staleTime: 5 * 60 * 1000, }); // Загружаем детали текущего активного пользовательского скина, если он не встроенный. const isCurrentCustom = Boolean(skinId) && !isBuiltInSkin(skinId); const { data: currentSkinData } = useQuery({ queryKey: ['/api/organization/skins', skinId], queryFn: async () => { const res = await apiRequest('GET', `/api/organization/skins/${encodeURIComponent(skinId)}`); return res.json() as Promise<{ success: boolean; skin: Skin }>; }, enabled: !!user && isCurrentCustom, staleTime: 5 * 60 * 1000, }); useEffect(() => { if (!customSkinsData?.skins) return; const map: Record = {}; for (const item of customSkinsData.skins) { if (item.isBuiltIn) continue; map[item.id] = { id: item.id, name: item.name, description: item.description, isEditable: item.isEditable, tokens: {} as Skin['tokens'], // tokens подгружаются отдельно при выборе }; } setCustomSkinsMap(map); }, [customSkinsData]); useEffect(() => { if (currentSkinData?.skin) { setCustomSkinsMap((prev) => ({ ...prev, [currentSkinData.skin.id]: currentSkinData.skin })); } }, [currentSkinData]); // Инициализация skinId: организационный > localStorage > default. useEffect(() => { if (typeof window === 'undefined') return; const allSkinIds = new Set([...BUILT_IN_SKINS.map((s) => s.id), ...Object.keys(customSkinsMap)]); const stored = getStoredSkinId(); if (stored && allSkinIds.has(stored)) { setSkinIdState(stored); } else if (organizationSkinId && allSkinIds.has(organizationSkinId)) { setSkinIdState(organizationSkinId); } else { setSkinIdState(DEFAULT_SKIN_ID); } }, [organizationSkinId, customSkinsData]); const setTheme = useCallback((newTheme: Theme) => { setThemeState(newTheme); if (typeof window !== 'undefined') { localStorage.setItem(THEME_STORAGE_KEY, newTheme); } }, []); const setSkinId = useCallback((newSkinId: string) => { setSkinIdState(newSkinId); if (typeof window !== 'undefined') { localStorage.setItem(SKIN_STORAGE_KEY, newSkinId); } }, []); const toggleTheme = useCallback(() => { setTheme(theme === 'light' ? 'dark' : 'light'); }, [theme, setTheme]); useLayoutEffect(() => { const root = document.documentElement; root.setAttribute('data-skin', skinId); root.setAttribute('data-theme', theme); root.classList.remove('light', 'dark'); root.classList.add(theme); // Для встроенных скинов CSS уже есть в generated-skins.css. // Для пользовательских — генерируем runtime. const customSkin = customSkinsMap[skinId]; applyRuntimeSkinCss(skinId, theme, customSkin?.tokens); }, [theme, skinId, customSkinsMap]); // Слушаем системную тему, если нет явного сохранённого выбора. useEffect(() => { if (typeof window === 'undefined') return; const media = window.matchMedia('(prefers-color-scheme: dark)'); const handler = (e: MediaQueryListEvent) => { if (!localStorage.getItem(THEME_STORAGE_KEY)) { setThemeState(e.matches ? 'dark' : 'light'); } }; media.addEventListener('change', handler); return () => media.removeEventListener('change', handler); }, []); const availableSkins = useMemo(() => { const custom = Object.values(customSkinsMap); return [...BUILT_IN_SKINS, ...custom]; }, [customSkinsMap]); // Тема 2.0: структурные варианты активного скина (components + density.preset) с дефолтами. const skinComponents = useMemo(() => { const skin = isBuiltInSkin(skinId) ? getBuiltInSkinById(skinId) : customSkinsMap[skinId]; const themeTokens = skin?.tokens?.[theme] ?? skin?.tokens?.light; const c = themeTokens?.components; if (!c && !themeTokens?.density) return DEFAULT_SKIN_COMPONENTS; return { control: { variant: c?.control?.variant ?? 'outline', radius: c?.control?.radius ?? 'md' }, label: { position: c?.label?.position ?? 'inline', casing: c?.label?.casing ?? 'none', weight: c?.label?.weight ?? 'medium', }, form: { columns: c?.form?.columns ?? 2 }, dialog: { mobile: c?.dialog?.mobile ?? 'modal', backdrop: c?.dialog?.backdrop ?? 'dim' }, table: { dividers: c?.table?.dividers ?? 'rows', header: c?.table?.header ?? 'muted' }, button: { radius: c?.button?.radius ?? 'md' }, motion: { enabled: c?.motion?.enabled ?? true }, density: { preset: themeTokens?.density?.preset ?? 'comfortable' }, layout: { taskDetail: c?.layout?.taskDetail ?? 'compact' }, }; }, [skinId, theme, customSkinsMap]); const value: ThemeContextType = { theme, setTheme, toggleTheme, skinId, setSkinId, availableSkins, skinComponents, }; return {children}; } export function useTheme() { const context = useContext(ThemeContext); if (!context) { throw new Error('useTheme must be used within a ThemeProvider'); } return context; } /** Удобный хук только для текущего скина. */ export function useSkin() { const { skinId, setSkinId, availableSkins } = useTheme(); const skin = availableSkins.find((s) => s.id === skinId) ?? getBuiltInSkinById(DEFAULT_SKIN_ID)!; return { skinId, skin, setSkinId, availableSkins }; } /** Тема 2.0: структурные варианты компонентов активного скина (с дефолтами). */ export function useSkinComponents(): ResolvedSkinComponents { return useTheme().skinComponents; }