// Оболочка панели: логин, тема, drawer/сайдбар, снэкбар и переключение вкладок. // Про конкретные вкладки не знает ничего — их приносит реестр из конфига. // // Всё продуктовое вынесено в PanelConfig: имя и версия, ключи localStorage, // адреса эндпоинтов, способ загрузки сессии и слоты для своего хрома. Панель // без ролей (сессия отдаёт permissions: ['*']) и панель с правами — один и тот // же код, отличаются только конфигом. import { useState, useEffect, useRef, useCallback, type ReactNode } from 'react'; import axios from 'axios'; import './styles.css'; import { IcoMenu, IcoLogout, IcoSun, IcoMoon } from '../icons'; import { setUnauthorizedHandler } from '../http'; import { EMPTY_SESSION, type Session } from '../auth'; import type { BuiltTabs, TabDef } from '../tabs'; // Контекст, который оболочка отдаёт слотам — чтобы свой хром мог ходить в сеть // и показывать сообщения теми же средствами, что и вкладки. export interface SlotContext { token: string; showMsg: (text: string) => void; session: Session; tab: TabDef | null; } export interface PanelConfig { // Реестр вкладок: результат buildTabs(...) из приложения. Передаётся сюда, // а не импортируется, потому что import.meta.glob обязан остаться в // приложении — см. комментарий в tabs/index.ts. tabs: BuiltTabs; branding: { name: string; version?: string }; // Ключи localStorage. Разводятся по панелям, чтобы две панели на одном // домене не затирали друг другу токен и тему. storageKeys?: { token?: string; theme?: string }; endpoints?: { login?: string; logout?: string }; // Как узнать, жив ли токен, и получить роль с правами. По умолчанию — // GET /api/session. Панель без ролей отдаёт отсюда permissions: ['*']. loadSession?: (token: string) => Promise; // Свой хром вокруг вкладки. Слот получает активную вкладку в ctx.tab и // может вернуть null там, где рисоваться не должен. slots?: { aboveTab?: (ctx: SlotContext) => ReactNode; headerExtra?: (ctx: SlotContext) => ReactNode; }; } export default function Panel({ config }: { config: PanelConfig }) { const { TABS, ALL_TAB_PERMISSIONS, visibleTabsByCategory } = config.tabs; const TOKEN_KEY = config.storageKeys?.token ?? 'admin_token'; const THEME_KEY = config.storageKeys?.theme ?? 'admin_theme'; const LOGIN_URL = config.endpoints?.login ?? '/login'; const LOGOUT_URL = config.endpoints?.logout ?? '/logout'; const APP_NAME = config.branding.name; const APP_VERSION = config.branding.version; const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY) ?? ''); const [loginUser, setLoginUser] = useState(''); const [loginPass, setLoginPass] = useState(''); const [loginError, setLoginError] = useState(''); const [msg, setMsg] = useState(''); const [drawerOpen, setDrawerOpen] = useState(false); const [activeTab, setActiveTab] = useState(''); const [session, setSession] = useState(EMPTY_SESSION); const [theme, setTheme] = useState<'light' | 'dark'>(() => localStorage.getItem(THEME_KEY) === 'dark' ? 'dark' : 'light'); const touchStartX = useRef(0); const touchCurrentX = useRef(0); const showMsg = useCallback((text: string) => { setMsg(text); setTimeout(() => setMsg(''), 3000); }, []); // Проверяет, жив ли токен, и заодно тянет роль/права — по ним фильтруется // меню. Данные конкретных вкладок грузят сами вкладки. const loadSession = config.loadSession; const startSession = useCallback(async (tok: string) => { const data = loadSession ? await loadSession(tok) : (await axios.get('/api/session', { headers: { Authorization: `Bearer ${tok}` } })).data; setSession(data); return data; }, [loadSession]); const login = useCallback(async () => { try { const { data: auth } = await axios.post(LOGIN_URL, { user: loginUser, pass: loginPass }); const tok = auth.token as string; await startSession(tok); localStorage.setItem(TOKEN_KEY, tok); setLoginError(''); setToken(tok); } catch (e) { // Все три причины отказа пишем под форму, а не в снэкбар: тот живёт // 3 секунды и уезжает, а человек в этот момент смотрит на поля и // перенабирает пароль. Особенно важно для 429 — там верный пароль // тоже не пустит, и без текста это выглядит как «пароль не тот». if (axios.isAxiosError(e) && e.response?.status === 401) { setLoginError('Неверный логин или пароль'); } else if (axios.isAxiosError(e) && e.response?.status === 429) { setLoginError(e.response.data?.error ?? 'Слишком много попыток'); } else { setLoginError('Сервер недоступен'); } } }, [loginUser, loginPass, startSession, showMsg, LOGIN_URL, TOKEN_KEY]); const logout = useCallback(async () => { try { await axios.post(LOGOUT_URL, null, { headers: { Authorization: `Bearer ${token}` } }); } catch { /* токен уже мог истечь */ } localStorage.removeItem(TOKEN_KEY); setToken(''); setSession(EMPTY_SESSION); setActiveTab(''); setDrawerOpen(false); }, [token, LOGOUT_URL, TOKEN_KEY]); // Любой 401 из apiFetch вкладки означает, что токен протух или отозван — // разлогиниваем сразу, а не показываем ошибку в снэкбаре и ждём, пока // пользователь сам догадается перезайти. useEffect(() => { setUnauthorizedHandler(() => { localStorage.removeItem(TOKEN_KEY); setToken(''); setSession(EMPTY_SESSION); setActiveTab(''); }); return () => setUnauthorizedHandler(null); }, [TOKEN_KEY]); const handleTouchStart = useCallback((e: React.TouchEvent) => { touchStartX.current = e.touches[0].clientX; touchCurrentX.current = e.touches[0].clientX; }, []); const handleTouchMove = useCallback((e: React.TouchEvent) => { touchCurrentX.current = e.touches[0].clientX; }, []); const handleTouchEnd = useCallback(() => { if (touchStartX.current - touchCurrentX.current > 40) setDrawerOpen(false); }, []); // Восстановить сессию из сохранённого токена useEffect(() => { const saved = localStorage.getItem(TOKEN_KEY); if (saved) { startSession(saved).catch(() => { localStorage.removeItem(TOKEN_KEY); setToken(''); }); } }, [startSession, TOKEN_KEY]); // Применять и сохранять тему useEffect(() => { document.documentElement.setAttribute('data-theme', theme); localStorage.setItem(THEME_KEY, theme); }, [theme, THEME_KEY]); // Блокировать прокрутку фона когда drawer открыт useEffect(() => { document.body.style.overflow = drawerOpen ? 'hidden' : ''; return () => { document.body.style.overflow = ''; }; }, [drawerOpen]); // Закрывать drawer по Escape useEffect(() => { if (!drawerOpen) return; const h = (e: KeyboardEvent) => { if (e.key === 'Escape') setDrawerOpen(false); }; document.addEventListener('keydown', h); return () => document.removeEventListener('keydown', h); }, [drawerOpen]); // Меню строится из вкладок, доступных правам текущей учётки, сгруппированных // по категориям. Вкладок (или прав на них) может не быть вовсе — тогда // вместо контента показываем заглушку. const groups = visibleTabsByCategory(session.permissions); const visible = groups.flatMap(g => g.tabs); const tab = visible.find(t => t.id === activeTab) ?? visible[0] ?? null; // Слот рисуется, если он задан в конфиге. Погасить его на конкретной вкладке // решает сам слот: активная вкладка лежит в ctx.tab. const slotCtx: SlotContext = { token, showMsg, session, tab }; const slot = (name: 'aboveTab' | 'headerExtra') => config.slots?.[name]?.(slotCtx) ?? null; return (
{token && slot('headerExtra')} {!token ? (
{APP_NAME}

Войдите, чтобы продолжить

setLoginUser(e.target.value)} autoFocus /> setLoginPass(e.target.value)} onKeyDown={e => e.key === 'Enter' && login()} /> {loginError &&

{loginError}

}
) : ( <> {/* Top bar — видим только на мобильном */}
{tab?.label ?? APP_NAME}
{/* Затемнение под drawer */}
setDrawerOpen(false)} />

{tab?.label ?? APP_NAME}

{slot('aboveTab')} {tab ? :

{TABS.length === 0 ? <>Вкладок пока нет — добавь папку в src/tabs/ : 'Ни одна вкладка не доступна твоим правам'}

}
)} {msg &&
{msg}
}
); }