273 lines
11 KiB
TypeScript
273 lines
11 KiB
TypeScript
/**
|
||
* 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;
|
||
}
|