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:
28
frontend/react/shared/ui/AppErrorBoundary.jsx
Normal file
28
frontend/react/shared/ui/AppErrorBoundary.jsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Component } from 'react';
|
||||
|
||||
import { AsyncState } from './AsyncState.jsx';
|
||||
|
||||
export class AppErrorBoundary extends Component {
|
||||
state = { failed: false };
|
||||
|
||||
static getDerivedStateFromError() {
|
||||
return { failed: true };
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.failed) {
|
||||
return (
|
||||
<main className="ui-fatal-state">
|
||||
<AsyncState
|
||||
kind="error"
|
||||
title="Интерфейс временно недоступен"
|
||||
message="Обновите страницу, чтобы восстановить работу"
|
||||
onRetry={() => window.location.reload()}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
175
frontend/react/shared/ui/AsyncCombobox.jsx
Normal file
175
frontend/react/shared/ui/AsyncCombobox.jsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import { useEffect, useId, useRef, useState } from 'react';
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 320;
|
||||
|
||||
export function AsyncCombobox({
|
||||
allowClear = true,
|
||||
ariaLabel = 'Поисковый список',
|
||||
emptyLabel = 'Ничего не найдено',
|
||||
load,
|
||||
onSelect,
|
||||
optionDescription = () => '',
|
||||
optionLabel = item => item.name,
|
||||
optionValue = item => item.id,
|
||||
placeholder = 'Начните вводить название',
|
||||
selectedLabel = '',
|
||||
selectedValue = ''
|
||||
}) {
|
||||
const rootRef = useRef(null);
|
||||
const inputRef = useRef(null);
|
||||
const debounceTimer = useRef(null);
|
||||
const requestNumber = useRef(0);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const [items, setItems] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const listId = useId();
|
||||
|
||||
useEffect(() => () => {
|
||||
window.clearTimeout(debounceTimer.current);
|
||||
requestNumber.current += 1;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
const handlePointerDown = event => {
|
||||
if (rootRef.current && !rootRef.current.contains(event.target)) {
|
||||
close();
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handlePointerDown);
|
||||
return () => document.removeEventListener('mousedown', handlePointerDown);
|
||||
});
|
||||
|
||||
async function search(nextQuery) {
|
||||
const currentRequest = requestNumber.current + 1;
|
||||
requestNumber.current = currentRequest;
|
||||
setOpen(true);
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await load(String(nextQuery || '').trim(), selectedValue || '');
|
||||
if (requestNumber.current !== currentRequest) return;
|
||||
setItems(Array.isArray(result) ? result : []);
|
||||
setLoading(false);
|
||||
} catch {
|
||||
if (requestNumber.current !== currentRequest) return;
|
||||
setError('Не удалось выполнить поиск');
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
window.clearTimeout(debounceTimer.current);
|
||||
requestNumber.current += 1;
|
||||
setOpen(false);
|
||||
setQuery('');
|
||||
setItems([]);
|
||||
setError('');
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
function choose(item) {
|
||||
onSelect(item);
|
||||
close();
|
||||
}
|
||||
|
||||
function clear() {
|
||||
onSelect(null);
|
||||
setQuery('');
|
||||
inputRef.current?.focus();
|
||||
void search('');
|
||||
}
|
||||
|
||||
function handleFocus() {
|
||||
setQuery(selectedLabel);
|
||||
void search(selectedLabel);
|
||||
}
|
||||
|
||||
function handleInput(event) {
|
||||
const value = event.target.value;
|
||||
setQuery(value);
|
||||
window.clearTimeout(debounceTimer.current);
|
||||
debounceTimer.current = window.setTimeout(() => {
|
||||
void search(value);
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
function handleInputKeydown(event) {
|
||||
if (event.key === 'Escape') {
|
||||
close();
|
||||
} else if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
rootRef.current?.querySelector('[role="option"]')?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function handleMenuKeydown(event) {
|
||||
const options = Array.from(rootRef.current?.querySelectorAll('[role="option"]') || []);
|
||||
const current = options.indexOf(document.activeElement);
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
close();
|
||||
inputRef.current?.focus();
|
||||
} else if (event.key === 'ArrowDown' && current >= 0) {
|
||||
event.preventDefault();
|
||||
options[Math.min(current + 1, options.length - 1)]?.focus();
|
||||
} else if (event.key === 'ArrowUp' && current >= 0) {
|
||||
event.preventDefault();
|
||||
(current === 0 ? inputRef.current : options[current - 1])?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`async-combobox${open ? ' is-open' : ''}`} ref={rootRef}>
|
||||
<div className="async-combobox-control">
|
||||
<input
|
||||
type="search"
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
aria-expanded={open}
|
||||
aria-controls={listId}
|
||||
aria-label={ariaLabel}
|
||||
autoComplete="off"
|
||||
placeholder={placeholder}
|
||||
value={open ? query : selectedLabel}
|
||||
onChange={handleInput}
|
||||
onFocus={handleFocus}
|
||||
onKeyDown={handleInputKeydown}
|
||||
ref={inputRef}
|
||||
/>
|
||||
{allowClear && (
|
||||
<button type="button" className="async-combobox-clear" aria-label="Очистить выбор" onClick={clear}>×</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="async-combobox-menu" role="listbox" id={listId} hidden={!open} onKeyDown={handleMenuKeydown}>
|
||||
{open && (
|
||||
loading ? (
|
||||
<div className="async-combobox-state"><span className="state-spinner" aria-hidden="true" />Поиск...</div>
|
||||
) : error ? (
|
||||
<div className="async-combobox-state is-error">{error}</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="async-combobox-state">{emptyLabel}</div>
|
||||
) : items.map(item => {
|
||||
const value = String(optionValue(item));
|
||||
const description = optionDescription(item);
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
role="option"
|
||||
className="async-combobox-option"
|
||||
aria-selected={String(selectedValue) === value}
|
||||
onClick={() => choose(item)}
|
||||
>
|
||||
<strong>{optionLabel(item)}</strong>
|
||||
{description ? <small>{description}</small> : null}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
frontend/react/shared/ui/AsyncState.jsx
Normal file
38
frontend/react/shared/ui/AsyncState.jsx
Normal file
@@ -0,0 +1,38 @@
|
||||
const DEFAULT_COPY = Object.freeze({
|
||||
empty: {
|
||||
message: 'Здесь пока нет данных',
|
||||
title: 'Пусто'
|
||||
},
|
||||
error: {
|
||||
message: 'Попробуйте повторить запрос',
|
||||
title: 'Не удалось загрузить данные'
|
||||
},
|
||||
loading: {
|
||||
message: 'Это займёт несколько секунд',
|
||||
title: 'Загружаем данные'
|
||||
}
|
||||
});
|
||||
|
||||
export function AsyncState({ kind = 'loading', message, onRetry, title }) {
|
||||
const copy = DEFAULT_COPY[kind] || DEFAULT_COPY.loading;
|
||||
const isError = kind === 'error';
|
||||
|
||||
return (
|
||||
<section
|
||||
className={`ui-state ui-state--${kind}`}
|
||||
role={isError ? 'alert' : 'status'}
|
||||
aria-live={isError ? 'assertive' : 'polite'}
|
||||
>
|
||||
<span className="ui-state__signal" aria-hidden="true" />
|
||||
<div className="ui-state__content">
|
||||
<h2 className="ui-state__title">{title || copy.title}</h2>
|
||||
<p className="ui-state__message">{message || copy.message}</p>
|
||||
</div>
|
||||
{isError && onRetry && (
|
||||
<button type="button" className="btn btn-md btn-secondary" onClick={onRetry}>
|
||||
Повторить
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
186
frontend/react/shared/ui/ConfirmDialog.jsx
Normal file
186
frontend/react/shared/ui/ConfirmDialog.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
frontend/react/shared/ui/FormAlert.jsx
Normal file
10
frontend/react/shared/ui/FormAlert.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
export function FormAlert({ alert, className = '', id }) {
|
||||
const tone = alert?.type === 'success' ? 'success' : 'error';
|
||||
const classes = ['form-alert', className, alert ? tone : ''].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<div className={classes} id={id} role="alert" aria-live="polite">
|
||||
{alert?.message || ''}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
57
frontend/react/shared/ui/Pagination.jsx
Normal file
57
frontend/react/shared/ui/Pagination.jsx
Normal file
@@ -0,0 +1,57 @@
|
||||
const PAGE_SIZES = [25, 50, 100];
|
||||
|
||||
export function Pagination({
|
||||
id,
|
||||
page = 0,
|
||||
size = 25,
|
||||
totalItems = 0,
|
||||
totalPages = 0,
|
||||
label = 'записей',
|
||||
onPage,
|
||||
onSize
|
||||
}) {
|
||||
const total = Number(totalItems || 0);
|
||||
const pages = Math.max(Number(totalPages || 0), total ? 1 : 0);
|
||||
const current = Number(page || 0);
|
||||
const pageSize = Number(size || 25);
|
||||
const start = total ? current * pageSize + 1 : 0;
|
||||
const end = Math.min((current + 1) * pageSize, total);
|
||||
|
||||
return (
|
||||
<div className="pagination" id={id}>
|
||||
<div className="pagination-summary" aria-live="polite">
|
||||
Показано {start}–{end} из {total} {label}
|
||||
</div>
|
||||
<div className="pagination-actions">
|
||||
<label className="pagination-size">
|
||||
<span>На странице</span>
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={event => onSize?.(Number(event.target.value))}
|
||||
>
|
||||
{PAGE_SIZES.map(value => (
|
||||
<option key={value} value={value}>{value}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-secondary"
|
||||
disabled={current <= 0}
|
||||
onClick={() => onPage?.(current - 1)}
|
||||
>
|
||||
Назад
|
||||
</button>
|
||||
<span className="pagination-page">{pages ? current + 1 : 0} / {pages}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-secondary"
|
||||
disabled={current >= pages - 1}
|
||||
onClick={() => onPage?.(current + 1)}
|
||||
>
|
||||
Далее
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
93
frontend/react/shared/ui/ScheduleOverview.jsx
Normal file
93
frontend/react/shared/ui/ScheduleOverview.jsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
buildScheduleOverviewModel,
|
||||
formatLongDate,
|
||||
lessonMeta,
|
||||
lessonStart,
|
||||
pluralLessons,
|
||||
relativeDate,
|
||||
trimTime
|
||||
} from '../../../schedule-overview.js';
|
||||
|
||||
export function ScheduleOverview({ lessons, now = new Date(), onGoToday, role = 'student' }) {
|
||||
const { todayIso, todayLessons, nextLesson } = buildScheduleOverviewModel(lessons, now);
|
||||
|
||||
return (
|
||||
<section className="schedule-overview" aria-live="polite">
|
||||
<header className="schedule-overview-head">
|
||||
<div>
|
||||
<span className="schedule-overview-kicker">Быстрый обзор</span>
|
||||
<h2>{formatLongDate(now)}</h2>
|
||||
</div>
|
||||
<button className="btn btn-md btn-secondary" type="button" onClick={onGoToday}>
|
||||
Открыть текущую неделю
|
||||
</button>
|
||||
</header>
|
||||
<div className="schedule-overview-grid">
|
||||
<article className="schedule-overview-card is-today">
|
||||
<div className="schedule-overview-card-head">
|
||||
<div>
|
||||
<span>Сегодня</span>
|
||||
<h3>{todayLessons.length ? pluralLessons(todayLessons.length) : 'Свободный день'}</h3>
|
||||
</div>
|
||||
<span className="schedule-overview-count">{todayLessons.length}</span>
|
||||
</div>
|
||||
<div className="schedule-overview-list">
|
||||
{todayLessons.length > 0
|
||||
? todayLessons.map((lesson, index) => (
|
||||
<CompactLesson
|
||||
key={`${lesson.date}-${trimTime(lesson.startTime)}-${index}`}
|
||||
lesson={lesson}
|
||||
role={role}
|
||||
/>
|
||||
))
|
||||
: <p className="schedule-overview-empty">На сегодня занятий нет.</p>}
|
||||
</div>
|
||||
</article>
|
||||
<article className="schedule-overview-card is-next">
|
||||
<div className="schedule-overview-card-head">
|
||||
<div>
|
||||
<span>Следующая пара</span>
|
||||
<h3>{nextLesson ? relativeDate(nextLesson.date, todayIso) : 'Пока ничего не запланировано'}</h3>
|
||||
</div>
|
||||
{nextLesson && (
|
||||
<span className="schedule-overview-badge">
|
||||
{lessonStart(nextLesson) <= now ? 'Идёт сейчас' : trimTime(nextLesson.startTime)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{nextLesson
|
||||
? <NextLessonCard lesson={nextLesson} role={role} />
|
||||
: <p className="schedule-overview-empty">В ближайшие 14 дней занятий нет.</p>}
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function CompactLesson({ lesson, role }) {
|
||||
return (
|
||||
<div className="schedule-overview-lesson">
|
||||
<time>{trimTime(lesson.startTime)}</time>
|
||||
<div>
|
||||
<strong>{lesson.subjectName || 'Занятие'}</strong>
|
||||
<small>{lessonMeta(lesson, role).join(' · ') || 'Подробности уточняются'}</small>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NextLessonCard({ lesson, role }) {
|
||||
return (
|
||||
<div className="schedule-overview-next">
|
||||
<div className="schedule-overview-next-time">
|
||||
<strong>{trimTime(lesson.startTime)}</strong>
|
||||
<span>до {trimTime(lesson.endTime)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<h4>{lesson.subjectName || 'Занятие'}</h4>
|
||||
<p>{lessonMeta(lesson, role).join(' · ') || 'Подробности уточняются'}</p>
|
||||
{lesson.lessonTypeName && <span className="schedule-overview-type">{lesson.lessonTypeName}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user