Files
iistwin/client/src/themes/ThemeProvider.tsx

273 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* ThemeProvider с поддержкой скинов.
* Управляет data-skin / data-theme на <html> и 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<ThemeContextType | undefined>(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<Theme>(getInitialTheme);
const [skinId, setSkinIdState] = useState<string>(DEFAULT_SKIN_ID);
const [customSkinsMap, setCustomSkinsMap] = useState<Record<string, Skin>>({});
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<string, Skin> = {};
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<ResolvedSkinComponents>(() => {
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 <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
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;
}