Files
ma7engine/core/browser/shell/index.tsx
T
maenekoandClaude Opus 5 18bd05bcbb ma7engine: ядро, шаблон панели, скаффолдер и доктор
Первый коммит существующего состояния. Ядро кладётся под git ради того,
чтобы половины разъезжались в продуктовые репозитории через git subtree,
а не копированием: subtree делает настоящий merge, хранит историю и умеет
отдавать правки обратно.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LeBkZLiQrvdECf5nfgiMz
2026-08-26 20:39:37 +03:00

302 lines
16 KiB
TypeScript

// Оболочка панели: логин, тема, 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<Session>;
// Свой хром вокруг вкладки. Слот получает активную вкладку в 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<Session>(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<Session>('/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 (
<div className="app">
<button
className="theme-toggle"
aria-label={theme === 'dark' ? 'Светлая тема' : 'Тёмная тема'}
onClick={() => setTheme(t => (t === 'dark' ? 'light' : 'dark'))}
>
{theme === 'dark' ? <IcoSun /> : <IcoMoon />}
</button>
{token && slot('headerExtra')}
{!token ? (
<div className="login-screen">
<div className="login-card">
<span className="logo-name">{APP_NAME}</span>
<p className="login-sub">Войдите, чтобы продолжить</p>
<input
className="field"
placeholder="Логин"
value={loginUser}
onChange={e => setLoginUser(e.target.value)}
autoFocus
/>
<input
className="field"
type="password"
placeholder="Пароль"
value={loginPass}
onChange={e => setLoginPass(e.target.value)}
onKeyDown={e => e.key === 'Enter' && login()}
/>
<button className="btn btn--primary btn--full" onClick={login}>
Войти
</button>
{loginError && <p className="login-error">{loginError}</p>}
</div>
</div>
) : (
<>
{/* Top bar — видим только на мобильном */}
<header className="top-bar">
<button
className="hamburger-btn"
aria-label="Открыть меню"
onClick={() => setDrawerOpen(true)}
>
<IcoMenu />
</button>
<span className="top-bar-title">{tab?.label ?? APP_NAME}</span>
</header>
{/* Затемнение под drawer */}
<div
className={`drawer-scrim${drawerOpen ? ' drawer-scrim--visible' : ''}`}
onClick={() => setDrawerOpen(false)}
/>
<div className="layout">
<aside
className={`sidebar${drawerOpen ? ' sidebar--open' : ''}`}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
<div className="sidebar-logo">
<span className="logo-name">{APP_NAME}</span>
{APP_VERSION && <span className="sidebar-version">{APP_VERSION}</span>}
</div>
<hr className="drawer-divider" />
{groups.map(g => (
<div key={g.category} className="nav-group">
{/* Вкладки без category собраны в одну безымянную группу —
заголовка у неё нет, меню выглядит плоским списком. */}
{g.category && <div className="nav-category">{g.category}</div>}
{g.tabs.map(t => (
<div
key={t.id}
className={`nav-item${t.id === tab?.id ? ' nav-item--active' : ''}`}
onClick={() => { setActiveTab(t.id); setDrawerOpen(false); }}
>
<t.Icon /> {t.label}
</div>
))}
</div>
))}
<div className="sidebar-spacer" />
<button className="btn btn--danger" onClick={logout}>
<IcoLogout /> Выйти
</button>
</aside>
<main className="main">
<h2 className="page-title">{tab?.label ?? APP_NAME}</h2>
{slot('aboveTab')}
{tab
? <tab.Page token={token} showMsg={showMsg}
username={session.user ?? ''} userId={session.userId ?? ''}
role={session.role} permissions={session.permissions}
availablePermissions={ALL_TAB_PERMISSIONS}
onNavigate={setActiveTab} />
: <p className="placeholder">
{TABS.length === 0
? <>Вкладок пока нет — добавь папку в <code>src/tabs/</code></>
: 'Ни одна вкладка не доступна твоим правам'}
</p>}
</main>
</div>
</>
)}
{msg && <div className="snack">{msg}</div>}
</div>
);
}