Files
runners-calendar/frontend/src/pages/RacesPage.tsx
Anton f7b611bbbe
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
fix(frontend): prevent calendar loading layout shift
2026-04-27 14:02:20 +03:00

352 lines
12 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 { 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>
);
}