171 lines
5.4 KiB
TypeScript
171 lines
5.4 KiB
TypeScript
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>
|
||
);
|
||
}
|