Админка, настройки, логин, кабинеты преподавателя и студента полностью переведены на 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
187 lines
6.1 KiB
JavaScript
187 lines
6.1 KiB
JavaScript
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>
|
||
);
|
||
}
|