Files
runners-calendar/frontend/src/pages/RaceDayPage.tsx
Anton 0da7454033
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
feat(frontend): redesign race dashboard
2026-04-22 11:47:37 +03:00

171 lines
5.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState } from "react";
import { Link, useParams } from "react-router-dom";
import type { Race } from "../api";
import { ApiError, getRaces } from "../api";
import {
formatDistance,
formatRaceDate,
getRaceStatusClassName,
getRaceStatusLabel,
getRaceVisual,
sortByDateAsc,
} from "../lib";
function getErrorMessage(error: unknown): string {
if (error instanceof ApiError) {
return error.message;
}
return "Не удалось загрузить старты.";
}
export function RaceDayPage(): JSX.Element {
const { ymd } = useParams<{ ymd: string }>();
const [races, setRaces] = useState<Race[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const validYmd = ymd && /^\d{4}-\d{2}-\d{2}$/.test(ymd) ? ymd : null;
const year = validYmd ? parseInt(validYmd.slice(0, 4), 10) : NaN;
useEffect(() => {
if (!validYmd || Number.isNaN(year)) {
setIsLoading(false);
setRaces([]);
return;
}
const ac = new AbortController();
let mounted = true;
async function load(): Promise<void> {
setIsLoading(true);
try {
const items = await getRaces({ year }, { signal: ac.signal });
if (!mounted || ac.signal.aborted) {
return;
}
const forDay = items.filter((r) => r.date.slice(0, 10) === validYmd);
setRaces(sortByDateAsc(forDay));
setErrorMessage(null);
} catch (e) {
if (ac.signal.aborted || !mounted) {
return;
}
setErrorMessage(getErrorMessage(e));
setRaces([]);
} finally {
if (mounted && !ac.signal.aborted) {
setIsLoading(false);
}
}
}
void load();
return () => {
mounted = false;
ac.abort();
};
}, [validYmd, year]);
const heading = useMemo(() => {
if (!validYmd) {
return "Дата не указана";
}
return formatRaceDate(validYmd);
}, [validYmd]);
if (!validYmd) {
return (
<section className="page page--race-day">
<div className="race-day-hero">
<p className="race-day-hero__eyebrow">Страница дня</p>
<h1 className="page__title">Некорректная дата</h1>
<Link className="page-link" to="/races">
Вернуться к календарю стартов
</Link>
</div>
</section>
);
}
return (
<section className="page page--race-day">
<section className="race-day-hero" aria-label="Старты дня">
<Link className="page-link" to="/races">
Календарь стартов
</Link>
<p className="race-day-hero__eyebrow">Старты дня</p>
<h1 className="page__title">{heading}</h1>
<p className="page__subtitle">
{isLoading
? "Загружаем расписание..."
: races.length > 0
? `Запланировано стартов: ${races.length}`
: "Проверьте расписание или добавьте старт на эту дату."}
</p>
</section>
{errorMessage ? (
<p className="page__subtitle page__subtitle--error" role="alert">
{errorMessage}
</p>
) : null}
{isLoading ? (
<p className="page__subtitle" aria-busy="true">
Загружаем
</p>
) : null}
{!isLoading && !errorMessage && races.length === 0 ? (
<p className="page__subtitle">На эту дату стартов нет.</p>
) : null}
{!isLoading && races.length > 0 ? (
<ul className="race-day__list">
{races.map((race) => {
const visual = getRaceVisual(race);
return (
<li key={race.id} className="race-day__item">
<Link className="race-day__link" to={`/races/${race.id}`}>
<img
className={`race-day__image${
visual.imageFit === "contain" ? " race-day__image--contain" : ""
}`}
src={visual.imageSrc}
alt=""
loading="lazy"
referrerPolicy="no-referrer"
onError={(event) => {
event.currentTarget.onerror = null;
event.currentTarget.classList.remove("race-day__image--contain");
event.currentTarget.src = visual.fallbackSrc;
}}
/>
<span className="race-day__body">
<span className="race-day__kicker">{visual.label}</span>
<span className="race-day__title">{race.title}</span>
<span className="race-day__meta">
{formatDistance(race.distanceKm)} ·{" "}
<span className={getRaceStatusClassName(race.status, race.date)}>
{getRaceStatusLabel(race.status, race.date)}
</span>
</span>
</span>
</Link>
</li>
);
})}
</ul>
) : null}
<div className="race-day__actions">
<Link className="btn btn--primary" to={`/races/new?date=${validYmd}`}>
Добавить
</Link>
</div>
</section>
);
}