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:
dipatrik10
2026-09-28 23:15:04 +03:00
parent c6083b35e0
commit 97bf3cc50a
167 changed files with 31283 additions and 19033 deletions

View File

@@ -0,0 +1,186 @@
import { useEffect, useRef, useState } from 'react';
export function ConfirmDialog({
title = 'Подтвердите действие',
message = '',
confirmLabel = 'Подтвердить',
cancelLabel = 'Отмена',
danger = false,
onConfirm,
onClose
}) {
const confirmRef = useRef(null);
useEffect(() => {
confirmRef.current?.focus();
}, []);
useEffect(() => {
const handleKeydown = event => {
if (event.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleKeydown);
return () => {
document.removeEventListener('keydown', handleKeydown);
};
}, [onClose]);
return (
<div
className="project-dialog-backdrop"
onClick={event => {
if (event.target === event.currentTarget) onClose();
}}
>
<section
className="project-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="project-dialog-title"
aria-describedby="project-dialog-message"
>
<header>
<h2 id="project-dialog-title">{title}</h2>
<button
type="button"
className="btn btn-icon-md btn-ghost"
aria-label="Закрыть"
onClick={onClose}
>
×
</button>
</header>
<p id="project-dialog-message">{message}</p>
<footer>
{cancelLabel && (
<button type="button" className="btn btn-md btn-secondary" onClick={onClose}>
{cancelLabel}
</button>
)}
<button
ref={confirmRef}
type="button"
className={`btn btn-md ${danger ? 'btn-danger' : 'btn-primary'}`}
onClick={onConfirm}
>
{confirmLabel}
</button>
</footer>
</section>
</div>
);
}
export function AlertDialog({ title, message, confirmLabel = 'Понятно', onClose }) {
return (
<ConfirmDialog
cancelLabel={null}
confirmLabel={confirmLabel}
message={message}
title={title}
onClose={onClose}
onConfirm={onClose}
/>
);
}
// Реплика legacy promptAction: диалог с обязательным текстовым полем.
// onConfirm получает trimmed-значение; пустое обязательное поле показывает
// ошибку внутри диалога, как в admin/js/dialog.js.
export function PromptDialog({
title = 'Подтвердите действие',
message = '',
confirmLabel = 'Подтвердить',
cancelLabel = 'Отмена',
danger = false,
input = {},
onConfirm,
onClose
}) {
const [value, setValue] = useState('');
const [error, setError] = useState('');
const inputRef = useRef(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
useEffect(() => {
const handleKeydown = event => {
if (event.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleKeydown);
return () => {
document.removeEventListener('keydown', handleKeydown);
};
}, [onClose]);
function confirm() {
const trimmed = value.trim();
if (input.required && !trimmed) {
setError(input.requiredMessage || 'Заполните поле');
inputRef.current?.focus();
return;
}
onConfirm(trimmed);
}
return (
<div
className="project-dialog-backdrop"
onClick={event => {
if (event.target === event.currentTarget) onClose();
}}
>
<section
className="project-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="project-dialog-title"
aria-describedby="project-dialog-message"
>
<header>
<h2 id="project-dialog-title">{title}</h2>
<button
type="button"
className="btn btn-icon-md btn-ghost"
aria-label="Закрыть"
onClick={onClose}
>
×
</button>
</header>
<p id="project-dialog-message">{message}</p>
<label className="project-dialog-field">
<span>{input.label || 'Комментарий'}</span>
<textarea
ref={inputRef}
rows="4"
maxLength={Number(input.maxlength || 2000)}
placeholder={input.placeholder || ''}
value={value}
onChange={event => {
setValue(event.target.value);
setError('');
}}
/>
</label>
<div className="project-dialog-error" role="alert">{error}</div>
<footer>
{cancelLabel && (
<button type="button" className="btn btn-md btn-secondary" onClick={onClose}>
{cancelLabel}
</button>
)}
<button
type="button"
className={`btn btn-md ${danger ? 'btn-danger' : 'btn-primary'}`}
onClick={confirm}
>
{confirmLabel}
</button>
</footer>
</section>
</div>
);
}