Some checks failed
CI / build-and-test (pull_request) Has been cancelled
- CORS_ORIGIN: несколько origin через запятую; комментарии в .env.example - Версия бэкенда: APP_VERSION, безопасное чтение package.json, футер при пустой версии - Сообщения API: unknown_error и ответы 401/403/404 без JSON; отладочный лог при !ok - Статус «внесите результат» для прошедшей даты + блок на карточке старта и стили
259 lines
9.1 KiB
TypeScript
259 lines
9.1 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from "react";
|
||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||
import { ApiError, deleteRace, getRaceById } from "../api";
|
||
import {
|
||
formatDistance,
|
||
formatRaceDate,
|
||
getPaceLabel,
|
||
getRaceStatusClassName,
|
||
getRaceStatusLabel,
|
||
raceNeedsResultEntry,
|
||
} from "../lib";
|
||
import type { Race } from "../api";
|
||
|
||
function getErrorMessage(error: unknown): string {
|
||
if (error instanceof ApiError) {
|
||
return error.message;
|
||
}
|
||
return "Не удалось загрузить карточку старта.";
|
||
}
|
||
|
||
function DetailItem(props: { label: string; value: string | null | undefined }): JSX.Element | null {
|
||
const text = props.value?.trim();
|
||
if (!text) {
|
||
return null;
|
||
}
|
||
return (
|
||
<div className="race-details-meta__item">
|
||
<dt className="race-details-meta__key">{props.label}</dt>
|
||
<dd className="race-details-meta__value">{text}</dd>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function DetailLink(props: { label: string; url: string | null | undefined }): JSX.Element | null {
|
||
const href = props.url?.trim();
|
||
if (!href) {
|
||
return null;
|
||
}
|
||
return (
|
||
<div className="race-details-meta__item">
|
||
<dt className="race-details-meta__key">{props.label}</dt>
|
||
<dd className="race-details-meta__value">
|
||
<a href={href} target="_blank" rel="noopener noreferrer" className="race-details-meta__link">
|
||
{href}
|
||
</a>
|
||
</dd>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function RaceDetailsPage(): JSX.Element {
|
||
const { raceId } = useParams<{ raceId: string }>();
|
||
const navigate = useNavigate();
|
||
const [race, setRace] = useState<Race | null>(null);
|
||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||
const [isDeleting, setIsDeleting] = useState<boolean>(false);
|
||
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean>(false);
|
||
|
||
useEffect(() => {
|
||
const ac = new AbortController();
|
||
let isMounted = true;
|
||
|
||
async function loadRace(): Promise<void> {
|
||
if (!raceId) {
|
||
setErrorMessage("Не найден идентификатор старта.");
|
||
setIsLoading(false);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const item = await getRaceById(raceId, { signal: ac.signal });
|
||
if (!isMounted || ac.signal.aborted) {
|
||
return;
|
||
}
|
||
setRace(item);
|
||
setErrorMessage(null);
|
||
} catch (error) {
|
||
if (ac.signal.aborted || !isMounted) {
|
||
return;
|
||
}
|
||
setErrorMessage(getErrorMessage(error));
|
||
} finally {
|
||
if (isMounted && !ac.signal.aborted) {
|
||
setIsLoading(false);
|
||
}
|
||
}
|
||
}
|
||
|
||
void loadRace();
|
||
return () => {
|
||
isMounted = false;
|
||
ac.abort();
|
||
};
|
||
}, [raceId]);
|
||
|
||
const paceLabel = useMemo(() => {
|
||
if (!race || race.status !== "completed") {
|
||
return null;
|
||
}
|
||
return getPaceLabel(race.finishTime, race.distanceKm);
|
||
}, [race]);
|
||
|
||
const handleDelete = useCallback(async () => {
|
||
if (!raceId) {
|
||
return;
|
||
}
|
||
setIsDeleting(true);
|
||
try {
|
||
await deleteRace(raceId);
|
||
navigate("/races", { replace: true });
|
||
} catch (error) {
|
||
setErrorMessage(error instanceof ApiError ? error.message : "Не удалось удалить старт.");
|
||
setShowDeleteConfirm(false);
|
||
} finally {
|
||
setIsDeleting(false);
|
||
}
|
||
}, [raceId, navigate]);
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<section className="page page--race-details" aria-busy="true">
|
||
<h1 className="page__title">Карточка старта</h1>
|
||
<p className="page__subtitle">Загружаем данные старта...</p>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
if (errorMessage || !race) {
|
||
return (
|
||
<section className="page page--race-details" role="alert">
|
||
<h1 className="page__title">Карточка старта</h1>
|
||
<p className="page__subtitle page__subtitle--error">{errorMessage ?? "Старт не найден."}</p>
|
||
<Link className="page-link" to="/races">
|
||
Вернуться к списку стартов
|
||
</Link>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
const isCompleted = race.status === "completed";
|
||
|
||
return (
|
||
<section className="page page--race-details">
|
||
<div className="race-details-header">
|
||
<div className="race-details-header__main">
|
||
<h1 className="page__title">{race.title}</h1>
|
||
<p className="page__subtitle">
|
||
{formatRaceDate(race.date)} · {formatDistance(race.distanceKm)}
|
||
</p>
|
||
</div>
|
||
<span className={getRaceStatusClassName(race.status, race.date)}>{getRaceStatusLabel(race.status, race.date)}</span>
|
||
</div>
|
||
|
||
{raceNeedsResultEntry(race) ? (
|
||
<p className="race-details-past-hint" role="status">
|
||
Дата старта уже прошла —{" "}
|
||
<Link className="race-details-past-hint__link" to={`/races/${race.id}/edit`}>
|
||
внесите результат или обновите статус
|
||
</Link>
|
||
.
|
||
</p>
|
||
) : null}
|
||
|
||
<div className="race-details-actions">
|
||
<Link className="btn btn--primary" to={`/races/${race.id}/edit`}>
|
||
Редактировать
|
||
</Link>
|
||
<button
|
||
className="btn btn--danger"
|
||
type="button"
|
||
onClick={() => setShowDeleteConfirm(true)}
|
||
>
|
||
Удалить
|
||
</button>
|
||
</div>
|
||
|
||
{showDeleteConfirm ? (
|
||
<div className="confirm-banner" role="alertdialog" aria-label="Подтверждение удаления">
|
||
<p className="confirm-banner__text">Удалить «{race.title}»? Это действие необратимо.</p>
|
||
<div className="confirm-banner__actions">
|
||
<button
|
||
className="btn btn--danger"
|
||
type="button"
|
||
disabled={isDeleting}
|
||
onClick={handleDelete}
|
||
>
|
||
{isDeleting ? "Удаляем…" : "Да, удалить"}
|
||
</button>
|
||
<button
|
||
className="btn btn--secondary"
|
||
type="button"
|
||
disabled={isDeleting}
|
||
onClick={() => setShowDeleteConfirm(false)}
|
||
>
|
||
Отмена
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="race-details-grid">
|
||
<article className="race-details-card">
|
||
<h2 className="race-details-card__title">Основная информация</h2>
|
||
<dl className="race-details-meta">
|
||
<div className="race-details-meta__item">
|
||
<dt className="race-details-meta__key">Дата</dt>
|
||
<dd className="race-details-meta__value">{formatRaceDate(race.date)}</dd>
|
||
</div>
|
||
<div className="race-details-meta__item">
|
||
<dt className="race-details-meta__key">Дистанция</dt>
|
||
<dd className="race-details-meta__value">{formatDistance(race.distanceKm)}</dd>
|
||
</div>
|
||
<div className="race-details-meta__item">
|
||
<dt className="race-details-meta__key">Статус</dt>
|
||
<dd className="race-details-meta__value">{getRaceStatusLabel(race.status, race.date)}</dd>
|
||
</div>
|
||
<DetailLink label="Сайт организатора" url={race.officialUrl} />
|
||
<DetailItem label="Время старта" value={race.startTime} />
|
||
<DetailItem label="Расписание кластеров" value={race.clusterSchedule} />
|
||
<DetailItem label="Выдача номеров" value={race.bibPickup} />
|
||
<DetailItem label="Стартовый номер" value={race.bibNumber} />
|
||
</dl>
|
||
</article>
|
||
|
||
<article className="race-details-card">
|
||
<h2 className="race-details-card__title">Результаты</h2>
|
||
{isCompleted ? (
|
||
<dl className="race-details-meta">
|
||
<div className="race-details-meta__item">
|
||
<dt className="race-details-meta__key">Время</dt>
|
||
<dd className="race-details-meta__value">{race.finishTime ?? "время не указано"}</dd>
|
||
</div>
|
||
<div className="race-details-meta__item">
|
||
<dt className="race-details-meta__key">Темп</dt>
|
||
<dd className="race-details-meta__value">{paceLabel ?? "не удалось вычислить"}</dd>
|
||
</div>
|
||
<DetailItem label="Место" value={race.finishPlace} />
|
||
</dl>
|
||
) : (
|
||
<p className="race-details-card__empty">
|
||
Метрики появятся после завершения старта и ввода результата.
|
||
</p>
|
||
)}
|
||
</article>
|
||
</div>
|
||
|
||
<article className="race-details-card race-details-card--notes">
|
||
<h2 className="race-details-card__title">Заметки</h2>
|
||
<p className="race-details-card__notes">{race.notes?.trim() ? race.notes : "Заметок пока нет."}</p>
|
||
</article>
|
||
|
||
<Link className="page-link" to="/races">
|
||
Назад к календарю стартов
|
||
</Link>
|
||
</section>
|
||
);
|
||
}
|