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:
99
frontend/react/shared/api-client.js
Normal file
99
frontend/react/shared/api-client.js
Normal file
@@ -0,0 +1,99 @@
|
||||
import { clearAuthState, fetchWithAuth } from '../../auth-session.js';
|
||||
|
||||
const DEFAULT_API_ERROR = 'Не удалось выполнить запрос';
|
||||
const UNAUTHORIZED_ERROR = 'Требуется повторный вход в систему';
|
||||
|
||||
export class ApiRequestError extends Error {
|
||||
constructor(message, { data = null, status = 0 } = {}) {
|
||||
super(message);
|
||||
this.name = 'ApiRequestError';
|
||||
this.data = data;
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export async function requestJson(
|
||||
endpoint,
|
||||
{ errorMessage = DEFAULT_API_ERROR, headers: initialHeaders, json, ...options } = {},
|
||||
{
|
||||
fetchImpl = fetchWithAuth,
|
||||
onUnauthorized = () => window.location.replace('/')
|
||||
} = {}
|
||||
) {
|
||||
if (typeof endpoint !== 'string' || !endpoint.startsWith('/api/')) {
|
||||
throw new ApiRequestError('Разрешены только same-origin запросы к API');
|
||||
}
|
||||
|
||||
const headers = new Headers(initialHeaders || {});
|
||||
headers.set('Accept', 'application/json');
|
||||
|
||||
let body = options.body;
|
||||
if (json !== undefined) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
body = JSON.stringify(json);
|
||||
}
|
||||
|
||||
const response = await fetchImpl(endpoint, {
|
||||
...options,
|
||||
body,
|
||||
headers
|
||||
});
|
||||
const data = await readResponseData(response);
|
||||
|
||||
if (response.status === 401) {
|
||||
clearAuthState();
|
||||
onUnauthorized();
|
||||
throw new ApiRequestError(UNAUTHORIZED_ERROR, { data, status: response.status });
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiRequestError(errorMessage, { data, status: response.status });
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export const apiClient = Object.freeze({
|
||||
delete: (endpoint, options = {}, dependencies) => requestJson(
|
||||
endpoint,
|
||||
{ ...options, method: 'DELETE' },
|
||||
dependencies
|
||||
),
|
||||
get: (endpoint, options = {}, dependencies) => requestJson(
|
||||
endpoint,
|
||||
{ ...options, method: 'GET' },
|
||||
dependencies
|
||||
),
|
||||
post: (endpoint, json, options = {}, dependencies) => requestJson(
|
||||
endpoint,
|
||||
{ ...options, json, method: 'POST' },
|
||||
dependencies
|
||||
),
|
||||
put: (endpoint, json, options = {}, dependencies) => requestJson(
|
||||
endpoint,
|
||||
{ ...options, json, method: 'PUT' },
|
||||
dependencies
|
||||
)
|
||||
});
|
||||
|
||||
async function readResponseData(response) {
|
||||
if (response.status === 204) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('Content-Type') || '';
|
||||
if (!contentType.includes('application/json')) {
|
||||
return text;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
28
frontend/react/shared/auth-client.js
Normal file
28
frontend/react/shared/auth-client.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import { logoutSession, restoreSession } from '../../auth-session.js';
|
||||
import { resolveSessionState, SESSION_PHASES } from './session-policy.js';
|
||||
|
||||
const SESSION_RESTORE_ERROR = 'Не удалось восстановить сессию';
|
||||
|
||||
export async function loadAuthorizedSession(
|
||||
allowedRoles = [],
|
||||
{ restoreSessionImpl = restoreSession } = {}
|
||||
) {
|
||||
try {
|
||||
const session = await restoreSessionImpl();
|
||||
return resolveSessionState(session, allowedRoles);
|
||||
} catch (error) {
|
||||
return {
|
||||
message: SESSION_RESTORE_ERROR,
|
||||
phase: SESSION_PHASES.error,
|
||||
session: null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function logoutAndRedirect({
|
||||
logoutSessionImpl = logoutSession,
|
||||
replaceLocation = path => window.location.replace(path)
|
||||
} = {}) {
|
||||
await logoutSessionImpl();
|
||||
replaceLocation('/');
|
||||
}
|
||||
53
frontend/react/shared/dates.js
Normal file
53
frontend/react/shared/dates.js
Normal file
@@ -0,0 +1,53 @@
|
||||
export const WEEK_LENGTH_DAYS = 7;
|
||||
|
||||
export function startOfWeek(date) {
|
||||
const copy = new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
const day = copy.getDay() || 7;
|
||||
copy.setDate(copy.getDate() - day + 1);
|
||||
return copy;
|
||||
}
|
||||
|
||||
export function parseIsoDate(value) {
|
||||
const [year, month, day] = String(value || '').split('-').map(Number);
|
||||
return year && month && day ? new Date(year, month - 1, day) : new Date();
|
||||
}
|
||||
|
||||
export function addDays(date, days) {
|
||||
const copy = new Date(date);
|
||||
copy.setDate(copy.getDate() + days);
|
||||
return copy;
|
||||
}
|
||||
|
||||
export function toIsoDate(date) {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function formatDate(date) {
|
||||
return date.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
}
|
||||
|
||||
export function formatIsoDate(value) {
|
||||
const [year, month, day] = String(value || '').split('-');
|
||||
return year && month && day ? `${day}.${month}.${year}` : '—';
|
||||
}
|
||||
|
||||
export function dayName(date) {
|
||||
const value = date.toLocaleDateString('ru-RU', { weekday: 'long' });
|
||||
return value.charAt(0).toUpperCase() + value.slice(1);
|
||||
}
|
||||
|
||||
export function trimTime(value) {
|
||||
return String(value || '').slice(0, 5);
|
||||
}
|
||||
|
||||
export function weekDates(weekStart) {
|
||||
return Array.from({ length: WEEK_LENGTH_DAYS }, (_, index) => addDays(weekStart, index));
|
||||
}
|
||||
|
||||
export function weekEndIso(weekStart) {
|
||||
return toIsoDate(addDays(weekStart, WEEK_LENGTH_DAYS - 1));
|
||||
}
|
||||
|
||||
export function formatWeekLabel(weekStart) {
|
||||
return `${formatDate(weekStart)} - ${formatDate(addDays(weekStart, WEEK_LENGTH_DAYS - 1))}`;
|
||||
}
|
||||
19
frontend/react/shared/runtime-entry.jsx
Normal file
19
frontend/react/shared/runtime-entry.jsx
Normal file
@@ -0,0 +1,19 @@
|
||||
export {
|
||||
Component,
|
||||
Fragment,
|
||||
StrictMode,
|
||||
createElement,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react';
|
||||
export { createRoot } from 'react-dom/client';
|
||||
|
||||
export { apiClient, ApiRequestError, requestJson } from './api-client.js';
|
||||
export { loadAuthorizedSession, logoutAndRedirect } from './auth-client.js';
|
||||
export { resolveSessionState, SESSION_PHASES } from './session-policy.js';
|
||||
export { useAuthorizedSession } from './use-authorized-session.js';
|
||||
export { AppErrorBoundary } from './ui/AppErrorBoundary.jsx';
|
||||
export { AsyncState } from './ui/AsyncState.jsx';
|
||||
export { FormAlert } from './ui/FormAlert.jsx';
|
||||
38
frontend/react/shared/session-policy.js
Normal file
38
frontend/react/shared/session-policy.js
Normal file
@@ -0,0 +1,38 @@
|
||||
export const SESSION_PHASES = Object.freeze({
|
||||
error: 'error',
|
||||
loading: 'loading',
|
||||
ready: 'ready',
|
||||
unauthorized: 'unauthorized'
|
||||
});
|
||||
|
||||
export function resolveSessionState(session, allowedRoles = []) {
|
||||
if (!session?.role) {
|
||||
return {
|
||||
message: 'Требуется повторный вход в систему',
|
||||
phase: SESSION_PHASES.unauthorized,
|
||||
session: null
|
||||
};
|
||||
}
|
||||
|
||||
if (allowedRoles.length > 0 && !allowedRoles.includes(session.role)) {
|
||||
return {
|
||||
message: 'У вас нет доступа к этому разделу',
|
||||
phase: SESSION_PHASES.unauthorized,
|
||||
session: null
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
message: '',
|
||||
phase: SESSION_PHASES.ready,
|
||||
session
|
||||
};
|
||||
}
|
||||
|
||||
export function loadingSessionState() {
|
||||
return {
|
||||
message: 'Проверяем сессию…',
|
||||
phase: SESSION_PHASES.loading,
|
||||
session: null
|
||||
};
|
||||
}
|
||||
22
frontend/react/shared/testing/render-shared.jsx
Normal file
22
frontend/react/shared/testing/render-shared.jsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import { AppErrorBoundary } from '../ui/AppErrorBoundary.jsx';
|
||||
import { AsyncState } from '../ui/AsyncState.jsx';
|
||||
import { FormAlert } from '../ui/FormAlert.jsx';
|
||||
|
||||
export function renderAsyncState(props) {
|
||||
return renderToStaticMarkup(<AsyncState {...props} />);
|
||||
}
|
||||
|
||||
export function renderFormAlert(props) {
|
||||
return renderToStaticMarkup(<FormAlert {...props} />);
|
||||
}
|
||||
|
||||
export function renderErrorBoundaryContent() {
|
||||
return renderToStaticMarkup(
|
||||
<AppErrorBoundary>
|
||||
<p>Рабочее содержимое</p>
|
||||
</AppErrorBoundary>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
35
frontend/react/shared/use-authorized-session.js
Normal file
35
frontend/react/shared/use-authorized-session.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { loadAuthorizedSession } from './auth-client.js';
|
||||
import { loadingSessionState, SESSION_PHASES } from './session-policy.js';
|
||||
|
||||
export function useAuthorizedSession(allowedRoles = [], { redirectOnUnauthorized = true } = {}) {
|
||||
const roleKey = useMemo(
|
||||
() => [...allowedRoles].sort().join('|'),
|
||||
[allowedRoles]
|
||||
);
|
||||
const normalizedRoles = useMemo(
|
||||
() => roleKey ? roleKey.split('|') : [],
|
||||
[roleKey]
|
||||
);
|
||||
const [state, setState] = useState(loadingSessionState);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setState(loadingSessionState());
|
||||
|
||||
void loadAuthorizedSession(normalizedRoles).then(result => {
|
||||
if (!active) return;
|
||||
setState(result);
|
||||
if (redirectOnUnauthorized && result.phase === SESSION_PHASES.unauthorized) {
|
||||
window.location.replace('/');
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [normalizedRoles, redirectOnUnauthorized]);
|
||||
|
||||
return state;
|
||||
}
|
||||
Reference in New Issue
Block a user