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:
212
frontend/react/login/LoginApp.jsx
Normal file
212
frontend/react/login/LoginApp.jsx
Normal file
@@ -0,0 +1,212 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { clearAuthState, storeAuthState } from '../../auth-session.js';
|
||||
import { initializeTelemetry } from '../../telemetry.js';
|
||||
import {
|
||||
LOGIN_ERROR_MESSAGES,
|
||||
requestLogin,
|
||||
validateLogin
|
||||
} from './login-service.js';
|
||||
import { FormAlert } from '../shared/ui/FormAlert.jsx';
|
||||
|
||||
void initializeTelemetry();
|
||||
|
||||
const EMPTY_CREDENTIALS = Object.freeze({ password: '', username: '' });
|
||||
|
||||
function BrandIcon() {
|
||||
return (
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<rect width="40" height="40" rx="12" fill="url(#logo-gradient)" />
|
||||
<path d="M12 20L18 26L28 14" stroke="white" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<defs>
|
||||
<linearGradient id="logo-gradient" x1="0" y1="0" x2="40" y2="40">
|
||||
<stop stopColor="#6366f1" />
|
||||
<stop offset="1" stopColor="#8b5cf6" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function UserIcon() {
|
||||
return (
|
||||
<svg className="input-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function LockIcon() {
|
||||
return (
|
||||
<svg className="input-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function EyeIcon() {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function Spinner() {
|
||||
return (
|
||||
<svg className="spinner" width="20" height="20" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" fill="none" strokeDasharray="31.4 31.4" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function LoginApp() {
|
||||
const [credentials, setCredentials] = useState(EMPTY_CREDENTIALS);
|
||||
const [errors, setErrors] = useState({});
|
||||
const [alert, setAlert] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [passwordVisible, setPasswordVisible] = useState(false);
|
||||
const [rippleVisible, setRippleVisible] = useState(false);
|
||||
const redirectTimer = useRef(null);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (redirectTimer.current) {
|
||||
window.clearTimeout(redirectTimer.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateField = event => {
|
||||
const { name, value } = event.target;
|
||||
setCredentials(current => ({ ...current, [name]: value }));
|
||||
setErrors(current => ({ ...current, [name]: undefined }));
|
||||
};
|
||||
|
||||
const submit = async event => {
|
||||
event.preventDefault();
|
||||
const nextErrors = validateLogin(credentials);
|
||||
setErrors(nextErrors);
|
||||
setAlert(null);
|
||||
|
||||
if (Object.keys(nextErrors).length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await requestLogin(credentials);
|
||||
if (!result.ok) {
|
||||
setAlert({ message: result.message, type: 'error' });
|
||||
return;
|
||||
}
|
||||
|
||||
clearAuthState();
|
||||
const session = storeAuthState(result.data);
|
||||
if (!session) {
|
||||
setAlert({ message: LOGIN_ERROR_MESSAGES.invalidResponse, type: 'error' });
|
||||
return;
|
||||
}
|
||||
|
||||
setAlert({ message: 'Вход выполнен успешно!', type: 'success' });
|
||||
redirectTimer.current = window.setTimeout(() => {
|
||||
window.location.assign(session.redirect || '/');
|
||||
}, 400);
|
||||
} catch (error) {
|
||||
setAlert({ message: LOGIN_ERROR_MESSAGES.connection, type: 'error' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const showRipple = () => {
|
||||
setRippleVisible(false);
|
||||
window.requestAnimationFrame(() => setRippleVisible(true));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="background" aria-hidden="true">
|
||||
<div className="shape shape-1" />
|
||||
<div className="shape shape-2" />
|
||||
<div className="shape shape-3" />
|
||||
</div>
|
||||
|
||||
<main className="login-container">
|
||||
<div className="login-card">
|
||||
<header className="login-header">
|
||||
<div className="logo"><BrandIcon /></div>
|
||||
<h1>Добро пожаловать</h1>
|
||||
<p className="subtitle">Войдите в свой аккаунт</p>
|
||||
</header>
|
||||
|
||||
<form id="login-form" noValidate onSubmit={submit}>
|
||||
<div className={`form-group stagger-1${errors.username ? ' has-error shake' : ''}`}>
|
||||
<label htmlFor="username">Имя пользователя</label>
|
||||
<div className="input-wrapper">
|
||||
<UserIcon />
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
name="username"
|
||||
value={credentials.username}
|
||||
onChange={updateField}
|
||||
placeholder="Введите имя пользователя"
|
||||
autoComplete="username"
|
||||
aria-invalid={Boolean(errors.username)}
|
||||
aria-describedby="username-error"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<span className="error-message" id="username-error">{errors.username || ''}</span>
|
||||
</div>
|
||||
|
||||
<div className={`form-group stagger-2${errors.password ? ' has-error shake' : ''}`}>
|
||||
<label htmlFor="password">Пароль</label>
|
||||
<div className="input-wrapper">
|
||||
<LockIcon />
|
||||
<input
|
||||
type={passwordVisible ? 'text' : 'password'}
|
||||
id="password"
|
||||
name="password"
|
||||
value={credentials.password}
|
||||
onChange={updateField}
|
||||
placeholder="Введите пароль"
|
||||
autoComplete="current-password"
|
||||
aria-invalid={Boolean(errors.password)}
|
||||
aria-describedby="password-error"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-icon-md btn-secondary toggle-password"
|
||||
id="toggle-password"
|
||||
aria-label={passwordVisible ? 'Скрыть пароль' : 'Показать пароль'}
|
||||
aria-pressed={passwordVisible}
|
||||
onClick={() => setPasswordVisible(visible => !visible)}
|
||||
>
|
||||
<EyeIcon />
|
||||
</button>
|
||||
</div>
|
||||
<span className="error-message" id="password-error">{errors.password || ''}</span>
|
||||
</div>
|
||||
|
||||
<FormAlert alert={alert} className="stagger-3" id="form-alert" />
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-lg btn-primary btn-submit stagger-4"
|
||||
id="btn-submit"
|
||||
disabled={loading}
|
||||
onPointerDown={showRipple}
|
||||
>
|
||||
{loading ? <Spinner /> : <span className="btn-text">Войти</span>}
|
||||
{rippleVisible && <span className="ripple" onAnimationEnd={() => setRippleVisible(false)} />}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user