feat: завершить миграцию фронтенда на React (этапы 1-5)
Админка, настройки, логин, кабинеты преподавателя и студента полностью переведены на 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
This commit is contained in:
458
frontend/react/admin/AdminApp.jsx
Normal file
458
frontend/react/admin/AdminApp.jsx
Normal file
@@ -0,0 +1,458 @@
|
||||
import { Suspense, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { ADMIN_APP_ROLES, SETTINGS_ROLES } from '../../admin/js/role-capabilities.js';
|
||||
import { applyRippleEffect, closeAllDropdownsOnOutsideClick } from '../../admin/js/utils.js';
|
||||
import { initAllCustomDropdowns, startDropdownAutoObserver } from '../../admin/js/dropdown.js';
|
||||
import { confirmAbandonDirtyChanges } from '../../admin/js/dirty-state.js';
|
||||
import { trackUxEvent } from '../../telemetry.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 { REACT_TABS } from './react-tabs.js';
|
||||
import {
|
||||
ADMIN_ROUTES,
|
||||
allowedAdminTabs,
|
||||
badgeVisibility,
|
||||
defaultAdminTabForRole,
|
||||
fetchPendingRequestsCount,
|
||||
logoTitleForRole,
|
||||
navBadgeLabel,
|
||||
navSectionsForRole,
|
||||
requestCountLabel,
|
||||
tabFromHash
|
||||
} from './admin-service.js';
|
||||
|
||||
const THEME_STORAGE_KEY = 'theme';
|
||||
const SIDEBAR_STORAGE_KEY = 'sidebar-collapsed';
|
||||
|
||||
const ICONS = Object.freeze({
|
||||
dashboard: (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="7" height="9" />
|
||||
<rect x="14" y="3" width="7" height="5" />
|
||||
<rect x="14" y="12" width="7" height="9" />
|
||||
<rect x="3" y="16" width="7" height="5" />
|
||||
</svg>
|
||||
),
|
||||
'teacher-requests': (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
|
||||
<rect x="8" y="2" width="8" height="4" rx="1" />
|
||||
<path d="M8 11h8" />
|
||||
<path d="M8 16h5" />
|
||||
</svg>
|
||||
),
|
||||
'department-workspace': (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 21h18" />
|
||||
<path d="M5 21V7l7-4 7 4v14" />
|
||||
<path d="M9 21v-6h6v6" />
|
||||
<path d="M9 9h.01" />
|
||||
<path d="M15 9h.01" />
|
||||
</svg>
|
||||
),
|
||||
schedule: (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
||||
<line x1="16" y1="2" x2="16" y2="6" />
|
||||
<line x1="8" y1="2" x2="8" y2="6" />
|
||||
<line x1="3" y1="10" x2="21" y2="10" />
|
||||
</svg>
|
||||
),
|
||||
'schedule-versions': (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 7h12a4 4 0 0 1 4 4v7" />
|
||||
<path d="M16 3l4 4-4 4" />
|
||||
<path d="M20 17H8a4 4 0 0 1-4-4V6" />
|
||||
<path d="M8 21l-4-4 4-4" />
|
||||
</svg>
|
||||
),
|
||||
'schedule-quality': (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 19V9" />
|
||||
<path d="M10 19V5" />
|
||||
<path d="M16 19v-7" />
|
||||
<path d="M22 19V3" />
|
||||
<path d="M2 19h20" />
|
||||
</svg>
|
||||
),
|
||||
'academic-calendar': (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 3h18v18H3z" />
|
||||
<path d="M3 9h18" />
|
||||
<path d="M9 3v18" />
|
||||
<path d="M15 3v18" />
|
||||
<path d="M3 15h18" />
|
||||
</svg>
|
||||
),
|
||||
'auditorium-workload': (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||
<line x1="3" y1="9" x2="21" y2="9" />
|
||||
<line x1="9" y1="21" x2="9" y2="9" />
|
||||
</svg>
|
||||
),
|
||||
'schedule-view': (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
),
|
||||
'teacher-absences': (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 3a4 4 0 1 0 0 8 4 4 0 0 0 0-8z" />
|
||||
<path d="M5 21v-2a7 7 0 0 1 14 0v2" />
|
||||
<path d="M18 8l3 3" />
|
||||
<path d="M21 8l-3 3" />
|
||||
</svg>
|
||||
),
|
||||
classrooms: (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 3h18v18H3zM9 3v18M15 3v18M3 9h18M3 15h18" />
|
||||
</svg>
|
||||
),
|
||||
groups: (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" />
|
||||
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" />
|
||||
</svg>
|
||||
),
|
||||
'university-structure': (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
|
||||
<rect x="8" y="2" width="8" height="4" rx="1" ry="1" />
|
||||
</svg>
|
||||
),
|
||||
subjects: (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 20h9" />
|
||||
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
|
||||
</svg>
|
||||
),
|
||||
users: (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</svg>
|
||||
)
|
||||
});
|
||||
|
||||
export function AdminApp() {
|
||||
const sessionState = useAuthorizedSession(ADMIN_APP_ROLES);
|
||||
|
||||
if (sessionState.phase === SESSION_PHASES.loading) {
|
||||
return (
|
||||
<AdminFrame>
|
||||
<AsyncState kind="loading" title="Админ-панель" message="Проверяем сессию…" />
|
||||
</AdminFrame>
|
||||
);
|
||||
}
|
||||
|
||||
if (sessionState.phase !== SESSION_PHASES.ready) {
|
||||
return (
|
||||
<AdminFrame>
|
||||
<AsyncState kind="error" title="Раздел недоступен" message={sessionState.message} />
|
||||
</AdminFrame>
|
||||
);
|
||||
}
|
||||
|
||||
return <AdminWorkspace role={sessionState.session.role} />;
|
||||
}
|
||||
|
||||
function AdminFrame({ children }) {
|
||||
return (
|
||||
<main className="main">
|
||||
<header className="topbar">
|
||||
<div className="topbar-title-group">
|
||||
<h1>Админ-панель</h1>
|
||||
</div>
|
||||
</header>
|
||||
<section className="content">
|
||||
{children}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminWorkspace({ role }) {
|
||||
const allowedTabs = allowedAdminTabs(role);
|
||||
const [activeTab, setActiveTab] = useState(() => (
|
||||
typeof window === 'undefined'
|
||||
? defaultAdminTabForRole(role)
|
||||
: tabFromHash(window.location.hash, role)
|
||||
));
|
||||
const activeTabRef = useRef(activeTab);
|
||||
const [collapsed, setCollapsed] = useState(() => (
|
||||
typeof window !== 'undefined'
|
||||
&& window.innerWidth > 768
|
||||
&& localStorage.getItem(SIDEBAR_STORAGE_KEY) === 'true'
|
||||
));
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [pendingCount, setPendingCount] = useState(null);
|
||||
const settingsRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
applyRippleEffect();
|
||||
closeAllDropdownsOnOutsideClick();
|
||||
initAllCustomDropdowns(document.body);
|
||||
startDropdownAutoObserver();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.theme = localStorage.getItem(THEME_STORAGE_KEY) || 'light';
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function handleHashChange() {
|
||||
void requestTabSwitch(tabFromHash(window.location.hash, role));
|
||||
}
|
||||
window.addEventListener('hashchange', handleHashChange);
|
||||
return () => {
|
||||
window.removeEventListener('hashchange', handleHashChange);
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event) {
|
||||
if (settingsRef.current && !settingsRef.current.contains(event.target)) {
|
||||
setSettingsOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (role !== 'ADMIN') return;
|
||||
let active = true;
|
||||
|
||||
async function refresh(countOverride = null) {
|
||||
try {
|
||||
const count = countOverride === null
|
||||
? await fetchPendingRequestsCount()
|
||||
: countOverride;
|
||||
if (active) setPendingCount(count);
|
||||
} catch {
|
||||
if (active) setPendingCount(null);
|
||||
console.warn('Не удалось загрузить счётчик заявок');
|
||||
}
|
||||
}
|
||||
|
||||
void refresh();
|
||||
function handleRequestsChanged(event) {
|
||||
void refresh(Number.isFinite(event.detail?.count) ? event.detail.count : null);
|
||||
}
|
||||
document.addEventListener('teacherRequestsChanged', handleRequestsChanged);
|
||||
return () => {
|
||||
active = false;
|
||||
document.removeEventListener('teacherRequestsChanged', handleRequestsChanged);
|
||||
};
|
||||
}, [role]);
|
||||
|
||||
async function requestTabSwitch(tab) {
|
||||
if (tab === activeTabRef.current || !allowedTabs.has(tab)) return;
|
||||
if (activeTabRef.current && !(await confirmAbandonDirtyChanges())) return;
|
||||
|
||||
activeTabRef.current = tab;
|
||||
setActiveTab(tab);
|
||||
trackUxEvent('раздел_открыт', { tab, role });
|
||||
if (window.location.hash !== `#${tab}`) {
|
||||
window.location.hash = tab;
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
const sections = navSectionsForRole(role);
|
||||
const title = activeTab ? ADMIN_ROUTES[activeTab].title : 'Админ-панель';
|
||||
const badge = pendingCount === null ? null : badgeVisibility(pendingCount);
|
||||
|
||||
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>{logoTitleForRole(role)}</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>
|
||||
<nav className="sidebar-nav">
|
||||
{sections.map(section => (
|
||||
<div key={section.title || 'main'}>
|
||||
{section.title && (
|
||||
<>
|
||||
<div className="sidebar-section-divider" />
|
||||
<div className="sidebar-section-title">{section.title}</div>
|
||||
</>
|
||||
)}
|
||||
{section.items.map(item => (
|
||||
<a
|
||||
key={item.id}
|
||||
href={`#${item.id}`}
|
||||
className={`nav-item${item.id === activeTab ? ' active' : ''}`}
|
||||
data-tab={item.id}
|
||||
onClick={event => {
|
||||
event.preventDefault();
|
||||
void requestTabSwitch(item.id);
|
||||
}}
|
||||
>
|
||||
{ICONS[item.id]}
|
||||
<span>{item.label}</span>
|
||||
{item.id === 'teacher-requests' && badge && !badge.hidden && (
|
||||
<span
|
||||
className="nav-badge"
|
||||
id="teacher-requests-nav-badge"
|
||||
aria-label={badge.ariaLabel}
|
||||
>
|
||||
{navBadgeLabel(pendingCount)}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
<div className="sidebar-footer">
|
||||
<div className={`settings-dropdown${settingsOpen ? ' open' : ''}`} ref={settingsRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-settings"
|
||||
onClick={event => {
|
||||
event.stopPropagation();
|
||||
setSettingsOpen(current => !current);
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
<span>Настройки</span>
|
||||
<svg className="settings-chevron" width="16" height="16" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="settings-menu">
|
||||
{SETTINGS_ROLES.includes(role) && (
|
||||
<a href="/admin/settings/" className="settings-menu-item">
|
||||
<svg width="18" height="18" 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>
|
||||
Общие настройки
|
||||
</a>
|
||||
)}
|
||||
<div className="settings-menu-divider" />
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-md btn-danger-subtle btn-logout settings-menu-item settings-menu-item--danger"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
<svg width="18" height="18" 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>
|
||||
Выйти
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="sidebar-overlay" />
|
||||
|
||||
<main className={`main${collapsed ? ' sidebar-collapsed' : ''}`}>
|
||||
<header className="topbar">
|
||||
<div className="topbar-title-group">
|
||||
<h1 id="page-title">{title}</h1>
|
||||
{badge && !badge.hidden && (
|
||||
<button
|
||||
type="button"
|
||||
className="topbar-request-badge"
|
||||
id="teacher-requests-title-badge"
|
||||
aria-label={badge.ariaLabel}
|
||||
onClick={() => void requestTabSwitch('teacher-requests')}
|
||||
>
|
||||
{requestCountLabel(pendingCount)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<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" id="app-content">
|
||||
{activeTab && <TabContent tab={activeTab} />}
|
||||
</section>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TabContent({ tab }) {
|
||||
const reactTab = REACT_TABS[tab];
|
||||
const TabComponent = reactTab?.Component;
|
||||
if (!TabComponent) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Suspense fallback={<div className="tab-loading"><span className="state-spinner" aria-hidden="true" />Загрузка...</div>}>
|
||||
<TabComponent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user