Админка, настройки, логин, кабинеты преподавателя и студента полностью переведены на React 19 (модель strangler, docs/REACT_MIGRATION.md). - 15 вкладок админки: lazy-загрузка, общий error boundary, сервисный слой - Удалены legacy-views и переходный слой LegacyTabView (45 файлов) - Сборка esbuild (frontend/scripts/build-react.mjs) с бюджетами gzip на чанки - CSP-хэши, кэширование: chunk'и immutable, CSS/HTML no-cache (react-cache.conf) - Фиксы: lazy-импорт именованных экспортов (краш после авторизации), растяжение #admin-root/#settings-root на всю ширину, возврат subjects/lessonTypes из справочников (краш просмотра расписаний), отказ от заведомо широкого поиска расписания без выбранной цели, центрирование карточки логина - Тесты: 237/237 (npm run check), регрессионные на lazy-вкладки и ширину - Документация: REACT_MIGRATION.md, REACT_MIGRATION_RESULT.md, FRONTEND.md
210 lines
9.3 KiB
JavaScript
210 lines
9.3 KiB
JavaScript
import { useEffect, useState } from 'react';
|
|
|
|
import { SETTINGS_ROLES } from '../../admin/js/role-capabilities.js';
|
|
import { logoutAndRedirect } from '../shared/auth-client.js';
|
|
import { SESSION_PHASES } from '../shared/session-policy.js';
|
|
import { useAuthorizedSession } from '../shared/use-authorized-session.js';
|
|
import { AsyncState } from '../shared/ui/AsyncState.jsx';
|
|
import { DatabaseTab } from './DatabaseTab.jsx';
|
|
import { EduFormsTab } from './EduFormsTab.jsx';
|
|
import { GeneralTab } from './GeneralTab.jsx';
|
|
import { TimeSlotsTab } from './TimeSlotsTab.jsx';
|
|
import {
|
|
defaultSettingsTabForRole,
|
|
settingsTabsForRole
|
|
} from './settings-service.js';
|
|
|
|
const THEME_STORAGE_KEY = 'theme';
|
|
const SIDEBAR_STORAGE_KEY = 'sidebar-collapsed';
|
|
|
|
const TAB_ICONS = Object.freeze({
|
|
database: (
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
|
|
strokeLinecap="round" strokeLinejoin="round">
|
|
<ellipse cx="12" cy="5" rx="9" ry="3" />
|
|
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3" />
|
|
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5" />
|
|
</svg>
|
|
),
|
|
'edu-forms': (
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
|
|
strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
|
|
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
|
|
<line x1="9" y1="7" x2="17" y2="7" />
|
|
<line x1="9" y1="11" x2="15" y2="11" />
|
|
</svg>
|
|
),
|
|
general: (
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
|
|
strokeLinecap="round" strokeLinejoin="round">
|
|
<circle cx="12" cy="12" r="3" />
|
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
|
</svg>
|
|
),
|
|
'time-slots': (
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
|
|
strokeLinecap="round" strokeLinejoin="round">
|
|
<circle cx="12" cy="12" r="9" />
|
|
<polyline points="12 7 12 12 16 14" />
|
|
<path d="M4 4l3 3" />
|
|
<path d="M20 4l-3 3" />
|
|
</svg>
|
|
)
|
|
});
|
|
|
|
export function SettingsApp() {
|
|
const sessionState = useAuthorizedSession(SETTINGS_ROLES);
|
|
|
|
useEffect(() => {
|
|
document.documentElement.dataset.theme = localStorage.getItem(THEME_STORAGE_KEY) || 'light';
|
|
}, []);
|
|
|
|
if (sessionState.phase === SESSION_PHASES.loading) {
|
|
return <SettingsShell title="Настройки"><AsyncState kind="loading" title="Настройки" message="Проверяем сессию…" /></SettingsShell>;
|
|
}
|
|
|
|
if (sessionState.phase !== SESSION_PHASES.ready) {
|
|
return <SettingsShell title="Настройки"><AsyncState kind="error" title="Раздел недоступен" message={sessionState.message} /></SettingsShell>;
|
|
}
|
|
|
|
return <SettingsWorkspace role={sessionState.session.role} />;
|
|
}
|
|
|
|
function SettingsShell({ title, sidebar, children }) {
|
|
const [collapsed, setCollapsed] = useState(() => (
|
|
typeof window !== 'undefined'
|
|
&& window.innerWidth > 768
|
|
&& localStorage.getItem(SIDEBAR_STORAGE_KEY) === 'true'
|
|
));
|
|
|
|
function toggleSidebar() {
|
|
setCollapsed(current => {
|
|
const next = !current;
|
|
localStorage.setItem(SIDEBAR_STORAGE_KEY, next);
|
|
return next;
|
|
});
|
|
}
|
|
|
|
function toggleTheme() {
|
|
const next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
|
|
document.documentElement.dataset.theme = next;
|
|
localStorage.setItem(THEME_STORAGE_KEY, next);
|
|
}
|
|
|
|
function handleLogout(event) {
|
|
event.preventDefault();
|
|
void logoutAndRedirect();
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<aside className={`sidebar${collapsed ? ' collapsed' : ''}`}>
|
|
<div className="sidebar-header">
|
|
<div className="logo">
|
|
<svg width="32" height="32" viewBox="0 0 40 40" fill="none">
|
|
<rect width="40" height="40" rx="12" fill="url(#lg)" />
|
|
<path d="M12 20L18 26L28 14" stroke="#fff" strokeWidth="3" strokeLinecap="round"
|
|
strokeLinejoin="round" />
|
|
<defs>
|
|
<linearGradient id="lg" x1="0" y1="0" x2="40" y2="40">
|
|
<stop stopColor="#6366f1" />
|
|
<stop offset="1" stopColor="#8b5cf6" />
|
|
</linearGradient>
|
|
</defs>
|
|
</svg>
|
|
<span>Настройки</span>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="btn btn-icon-sm btn-ghost sidebar-close-btn"
|
|
aria-label="Скрыть панель"
|
|
onClick={toggleSidebar}
|
|
>
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
|
|
strokeLinecap="round" strokeLinejoin="round">
|
|
<polyline points="15 18 9 12 15 6" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
{sidebar}
|
|
<div className="sidebar-footer">
|
|
<a href="/admin/" className="btn-back">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
|
|
strokeLinecap="round" strokeLinejoin="round">
|
|
<line x1="19" y1="12" x2="5" y2="12" />
|
|
<polyline points="12 19 5 12 12 5" />
|
|
</svg>
|
|
<span>Назад в панель</span>
|
|
</a>
|
|
<a href="/" className="btn-back" onClick={handleLogout}>
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
|
|
strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
|
<polyline points="16 17 21 12 16 7" />
|
|
<line x1="21" y1="12" x2="9" y2="12" />
|
|
</svg>
|
|
<span>Выйти</span>
|
|
</a>
|
|
</div>
|
|
</aside>
|
|
|
|
<div className="sidebar-overlay" />
|
|
|
|
<main className={`main${collapsed ? ' sidebar-collapsed' : ''}`}>
|
|
<header className="topbar">
|
|
<h1>{title}</h1>
|
|
<button
|
|
type="button"
|
|
className="btn btn-icon-md btn-secondary theme-toggle"
|
|
aria-label="Переключить тему"
|
|
onClick={toggleTheme}
|
|
>
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
|
|
strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
<path d="M21 12.79A9 9 0 1 1 11.21 3a7 7 0 0 0 9.79 9.79z" />
|
|
</svg>
|
|
</button>
|
|
</header>
|
|
<section className="content">
|
|
{children}
|
|
</section>
|
|
</main>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function SettingsWorkspace({ role }) {
|
|
const tabs = settingsTabsForRole(role);
|
|
const [activeTab, setActiveTab] = useState(() => defaultSettingsTabForRole(role) || tabs[0]?.id);
|
|
const active = tabs.find(tab => tab.id === activeTab) || tabs[0];
|
|
|
|
const sidebar = (
|
|
<nav className="sidebar-nav">
|
|
{tabs.map(tab => (
|
|
<a
|
|
key={tab.id}
|
|
href="#"
|
|
className={`nav-item${tab.id === active?.id ? ' active' : ''}`}
|
|
onClick={event => {
|
|
event.preventDefault();
|
|
setActiveTab(tab.id);
|
|
}}
|
|
>
|
|
{TAB_ICONS[tab.id]}
|
|
<span>{tab.title}</span>
|
|
</a>
|
|
))}
|
|
</nav>
|
|
);
|
|
|
|
return (
|
|
<SettingsShell sidebar={sidebar} title={active ? active.title : 'Настройки'}>
|
|
{active?.id === 'general' && <GeneralTab />}
|
|
{active?.id === 'time-slots' && <TimeSlotsTab />}
|
|
{active?.id === 'edu-forms' && <EduFormsTab />}
|
|
{active?.id === 'database' && <DatabaseTab />}
|
|
</SettingsShell>
|
|
);
|
|
}
|