352 lines
12 KiB
TypeScript
352 lines
12 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from "react";
|
||
import { Link } from "react-router-dom";
|
||
import type { Race, RacesQuery } from "../api";
|
||
import { ApiError, getRaces } from "../api";
|
||
import { RacesCalendar } from "../components/RacesCalendar";
|
||
import {
|
||
formatDistance,
|
||
formatRaceDate,
|
||
getRaceVisual,
|
||
getRaceStatusClassName,
|
||
getRaceStatusLabel,
|
||
parseRaceDate,
|
||
sortByDateAsc,
|
||
sortByDateDesc,
|
||
} from "../lib";
|
||
|
||
const MONTH_OPTIONS: { value: string; label: string }[] = [
|
||
{ value: "", label: "Все месяцы" },
|
||
{ value: "1", label: "Январь" },
|
||
{ value: "2", label: "Февраль" },
|
||
{ value: "3", label: "Март" },
|
||
{ value: "4", label: "Апрель" },
|
||
{ value: "5", label: "Май" },
|
||
{ value: "6", label: "Июнь" },
|
||
{ value: "7", label: "Июль" },
|
||
{ value: "8", label: "Август" },
|
||
{ value: "9", label: "Сентябрь" },
|
||
{ value: "10", label: "Октябрь" },
|
||
{ value: "11", label: "Ноябрь" },
|
||
{ value: "12", label: "Декабрь" },
|
||
];
|
||
|
||
const VIEW_STORAGE_KEY = "races-view-mode";
|
||
|
||
type ViewMode = "list" | "calendar";
|
||
|
||
function yearSelectOptions(): number[] {
|
||
const current = new Date().getFullYear();
|
||
const start = current - 2;
|
||
const end = current + 4;
|
||
const years: number[] = [];
|
||
for (let y = start; y <= end; y += 1) {
|
||
years.push(y);
|
||
}
|
||
return years;
|
||
}
|
||
|
||
function getErrorMessage(error: unknown): string {
|
||
if (error instanceof ApiError) {
|
||
return error.message;
|
||
}
|
||
return "Не удалось загрузить календарь стартов.";
|
||
}
|
||
|
||
function readInitialViewMode(): ViewMode {
|
||
try {
|
||
const v = sessionStorage.getItem(VIEW_STORAGE_KEY);
|
||
return v === "calendar" ? "calendar" : "list";
|
||
} catch {
|
||
return "list";
|
||
}
|
||
}
|
||
|
||
function RaceList(props: { title: string; races: Race[] }): JSX.Element {
|
||
const { title, races } = props;
|
||
|
||
return (
|
||
<section className="race-list" aria-label={title}>
|
||
<h2 className="race-list__title">{title}</h2>
|
||
{races.length > 0 ? (
|
||
<ul className="race-list__items">
|
||
{races.map((race) => {
|
||
const visual = getRaceVisual(race);
|
||
const parsedDate = parseRaceDate(race.date);
|
||
const day = parsedDate.toLocaleDateString("ru-RU", { day: "2-digit" });
|
||
const month = parsedDate.toLocaleDateString("ru-RU", { month: "short" });
|
||
|
||
return (
|
||
<li key={race.id} className={`race-card race-card--action race-card--poster race-card--${visual.variant}`}>
|
||
<Link
|
||
className="race-card__link-surface"
|
||
to={`/races/${race.id}`}
|
||
aria-label={`Старт: ${race.title}`}
|
||
>
|
||
<div className="race-card__image-wrap">
|
||
<img
|
||
className={`race-card__image${
|
||
visual.imageFit === "contain" ? " race-card__image--contain" : ""
|
||
}`}
|
||
src={visual.imageSrc}
|
||
alt=""
|
||
loading="lazy"
|
||
referrerPolicy="no-referrer"
|
||
onError={(event) => {
|
||
event.currentTarget.onerror = null;
|
||
event.currentTarget.classList.remove("race-card__image--contain");
|
||
event.currentTarget.src = visual.fallbackSrc;
|
||
}}
|
||
/>
|
||
<span className="race-card__date-badge">
|
||
<span>{day}</span>
|
||
<span>{month}</span>
|
||
</span>
|
||
</div>
|
||
<div className="race-card__content">
|
||
<div className="race-card__main">
|
||
<p className="race-card__kicker">{visual.label}</p>
|
||
<p className="race-card__title">
|
||
<span className="race-card__title-text">{race.title}</span>
|
||
</p>
|
||
<p className="race-card__meta">
|
||
{formatRaceDate(race.date)} · {formatDistance(race.distanceKm)}
|
||
</p>
|
||
</div>
|
||
<div className="race-card__footer">
|
||
<span className={getRaceStatusClassName(race.status, race.date)}>
|
||
{getRaceStatusLabel(race.status, race.date)}
|
||
</span>
|
||
<span className="race-card__cta">Открыть</span>
|
||
</div>
|
||
</div>
|
||
</Link>
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
) : (
|
||
<p className="race-list__empty">Пока нет данных в этом разделе.</p>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
export function RacesPage(): JSX.Element {
|
||
const [races, setRaces] = useState<Race[]>([]);
|
||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||
const [yearFilter, setYearFilter] = useState<string>("");
|
||
const [monthFilter, setMonthFilter] = useState<string>("");
|
||
const [viewMode, setViewMode] = useState<ViewMode>(() => readInitialViewMode());
|
||
|
||
const setViewModePersist = useCallback((mode: ViewMode) => {
|
||
setViewMode(mode);
|
||
try {
|
||
sessionStorage.setItem(VIEW_STORAGE_KEY, mode);
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}, []);
|
||
|
||
const handleViewList = useCallback(() => {
|
||
setViewModePersist("list");
|
||
}, [setViewModePersist]);
|
||
|
||
const handleViewCalendar = useCallback(() => {
|
||
setViewModePersist("calendar");
|
||
setYearFilter((prev) => (prev === "" ? String(new Date().getFullYear()) : prev));
|
||
}, [setViewModePersist]);
|
||
|
||
const listQuery = useMemo((): RacesQuery | undefined => {
|
||
if (viewMode === "calendar") {
|
||
const y = yearFilter !== "" ? parseInt(yearFilter, 10) : new Date().getFullYear();
|
||
if (!Number.isNaN(y)) {
|
||
return { year: y };
|
||
}
|
||
return undefined;
|
||
}
|
||
const q: RacesQuery = {};
|
||
if (yearFilter !== "") {
|
||
const y = parseInt(yearFilter, 10);
|
||
if (!Number.isNaN(y)) {
|
||
q.year = y;
|
||
}
|
||
}
|
||
if (monthFilter !== "") {
|
||
const m = parseInt(monthFilter, 10);
|
||
if (!Number.isNaN(m)) {
|
||
q.month = m;
|
||
}
|
||
}
|
||
return Object.keys(q).length > 0 ? q : undefined;
|
||
}, [viewMode, yearFilter, monthFilter]);
|
||
|
||
const displayYear = useMemo(() => {
|
||
if (yearFilter !== "") {
|
||
const y = parseInt(yearFilter, 10);
|
||
return Number.isNaN(y) ? new Date().getFullYear() : y;
|
||
}
|
||
return new Date().getFullYear();
|
||
}, [yearFilter]);
|
||
|
||
useEffect(() => {
|
||
const ac = new AbortController();
|
||
let isMounted = true;
|
||
|
||
async function loadRaces(): Promise<void> {
|
||
setIsLoading(true);
|
||
try {
|
||
const items = await getRaces(listQuery, { signal: ac.signal });
|
||
if (!isMounted || ac.signal.aborted) {
|
||
return;
|
||
}
|
||
setRaces(items);
|
||
setErrorMessage(null);
|
||
} catch (error) {
|
||
if (ac.signal.aborted || !isMounted) {
|
||
return;
|
||
}
|
||
setErrorMessage(getErrorMessage(error));
|
||
} finally {
|
||
if (isMounted && !ac.signal.aborted) {
|
||
setIsLoading(false);
|
||
}
|
||
}
|
||
}
|
||
|
||
void loadRaces();
|
||
return () => {
|
||
isMounted = false;
|
||
ac.abort();
|
||
};
|
||
}, [listQuery]);
|
||
|
||
const { upcoming, completed } = useMemo(
|
||
() => ({
|
||
upcoming: sortByDateAsc(races.filter((race) => race.status !== "completed")),
|
||
completed: sortByDateDesc(races.filter((race) => race.status === "completed")),
|
||
}),
|
||
[races],
|
||
);
|
||
const statusMessage = useMemo(() => {
|
||
if (errorMessage && !isLoading) {
|
||
return errorMessage;
|
||
}
|
||
if (isLoading) {
|
||
return "Загружаем данные...";
|
||
}
|
||
if (viewMode === "calendar" && monthFilter === "") {
|
||
return "Выберите месяц, чтобы увидеть его крупным планом.";
|
||
}
|
||
return "";
|
||
}, [errorMessage, isLoading, monthFilter, viewMode]);
|
||
const statusClassName = [
|
||
"races-status__message",
|
||
!statusMessage ? "races-status__message--empty" : "",
|
||
errorMessage && !isLoading ? "races-status__message--error" : "",
|
||
]
|
||
.filter(Boolean)
|
||
.join(" ");
|
||
|
||
if (errorMessage && races.length === 0 && !isLoading) {
|
||
return (
|
||
<section className="page page--races" role="alert">
|
||
<h1 className="page__title">Календарь стартов</h1>
|
||
<p className="page__subtitle page__subtitle--error">{errorMessage}</p>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<section className="page page--races">
|
||
<section className="races-hero" aria-label="Календарь стартов">
|
||
<div className="races-hero__content">
|
||
<p className="races-hero__eyebrow">Сезонная афиша</p>
|
||
<h1 className="page__title">Календарь стартов</h1>
|
||
<p className="page__subtitle">Будущие и прошедшие старты в одном месте.</p>
|
||
<div className="races-view-toggle" role="group" aria-label="Вид отображения">
|
||
<button
|
||
type="button"
|
||
className={`races-view-toggle__btn${viewMode === "list" ? " races-view-toggle__btn--active" : ""}`}
|
||
onClick={handleViewList}
|
||
>
|
||
Список
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={`races-view-toggle__btn${viewMode === "calendar" ? " races-view-toggle__btn--active" : ""}`}
|
||
onClick={handleViewCalendar}
|
||
>
|
||
Календарь
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="races-hero__filters">
|
||
<div className="races-filter" role="search" aria-label="Фильтр по дате">
|
||
<label className="races-filter__field">
|
||
<span className="races-filter__label">Год</span>
|
||
<select
|
||
className="races-filter__select"
|
||
value={viewMode === "list" ? yearFilter : yearFilter || String(displayYear)}
|
||
onChange={(event) => {
|
||
setYearFilter(event.target.value);
|
||
}}
|
||
>
|
||
{viewMode === "list" ? <option value="">Все года</option> : null}
|
||
{yearSelectOptions().map((y) => (
|
||
<option key={y} value={String(y)}>
|
||
{y}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label className="races-filter__field">
|
||
<span className="races-filter__label">Месяц</span>
|
||
<select
|
||
className="races-filter__select"
|
||
value={monthFilter}
|
||
onChange={(event) => {
|
||
setMonthFilter(event.target.value);
|
||
}}
|
||
>
|
||
{MONTH_OPTIONS.map((opt) => (
|
||
<option key={opt.value || "all"} value={opt.value}>
|
||
{opt.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<div className="races-status" aria-live="polite">
|
||
<p
|
||
className={statusClassName}
|
||
role={errorMessage && !isLoading ? "alert" : undefined}
|
||
aria-busy={isLoading || undefined}
|
||
aria-hidden={!statusMessage || undefined}
|
||
>
|
||
{statusMessage || "\u00a0"}
|
||
</p>
|
||
</div>
|
||
|
||
{viewMode === "list" ? (
|
||
<div className="race-lists">
|
||
<RaceList title="Будущие" races={upcoming} />
|
||
<RaceList title="Завершенные" races={completed} />
|
||
</div>
|
||
) : (
|
||
<div className="races-cal-wrap">
|
||
<RacesCalendar
|
||
displayYear={displayYear}
|
||
monthFilter={monthFilter}
|
||
races={races}
|
||
onMonthFilterChange={setMonthFilter}
|
||
/>
|
||
</div>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|