292 lines
8.5 KiB
TypeScript
292 lines
8.5 KiB
TypeScript
import { useCallback, useMemo, useRef, useState } from "react";
|
||
import { Link, useNavigate } from "react-router-dom";
|
||
import type { Race } from "../api";
|
||
import { buildMonthCells, groupRacesByYmd, isRaceDateInPast, toYmd, WEEKDAY_LABELS_SHORT_RU } from "../lib";
|
||
|
||
const MONTH_NAMES_RU_SHORT = [
|
||
"янв.",
|
||
"февр.",
|
||
"мар.",
|
||
"апр.",
|
||
"май",
|
||
"июн.",
|
||
"июл.",
|
||
"авг.",
|
||
"сент.",
|
||
"окт.",
|
||
"нояб.",
|
||
"дек.",
|
||
];
|
||
|
||
const POPOVER_LEAVE_MS = 140;
|
||
|
||
function toLocalYmd(date: Date): string {
|
||
const y = date.getFullYear();
|
||
const m = String(date.getMonth() + 1).padStart(2, "0");
|
||
const d = String(date.getDate()).padStart(2, "0");
|
||
return `${y}-${m}-${d}`;
|
||
}
|
||
|
||
interface RacesCalendarProps {
|
||
displayYear: number;
|
||
monthFilter: string;
|
||
races: Race[];
|
||
onMonthFilterChange: (value: string) => void;
|
||
}
|
||
|
||
function DayPopover(props: {
|
||
ymd: string;
|
||
races: Race[];
|
||
onCancelClose: () => void;
|
||
onScheduleClose: () => void;
|
||
}): JSX.Element {
|
||
const { ymd, races, onCancelClose, onScheduleClose } = props;
|
||
const hasRaces = races.length > 0;
|
||
|
||
return (
|
||
<div
|
||
className="races-cal__popover"
|
||
role="tooltip"
|
||
onMouseEnter={onCancelClose}
|
||
onMouseLeave={onScheduleClose}
|
||
>
|
||
{hasRaces ? (
|
||
<ul className="races-cal__popover-list">
|
||
{races.map((r) => (
|
||
<li key={r.id} className="races-cal__popover-item">
|
||
<Link className="races-cal__popover-link" to={`/races/${r.id}`} onClick={onCancelClose}>
|
||
{r.title}
|
||
</Link>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
) : (
|
||
<p className="races-cal__popover-empty">Стартов нет</p>
|
||
)}
|
||
<Link
|
||
className="btn btn--secondary races-cal__popover-add"
|
||
to={`/races/new?date=${ymd}`}
|
||
onClick={onCancelClose}
|
||
>
|
||
Добавить
|
||
</Link>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function CalendarMonthBlock(props: {
|
||
year: number;
|
||
monthIndex: number;
|
||
racesByYmd: Map<string, Race[]>;
|
||
compact: boolean;
|
||
navigate: ReturnType<typeof useNavigate>;
|
||
openYmd: string | null;
|
||
setOpenYmd: (v: string | null) => void;
|
||
scheduleClose: () => void;
|
||
cancelClose: () => void;
|
||
onMonthSelect?: (monthIndex: number) => void;
|
||
todayYmd: string;
|
||
}): JSX.Element {
|
||
const {
|
||
year,
|
||
monthIndex,
|
||
racesByYmd,
|
||
compact,
|
||
navigate,
|
||
openYmd,
|
||
setOpenYmd,
|
||
scheduleClose,
|
||
cancelClose,
|
||
onMonthSelect,
|
||
todayYmd,
|
||
} = props;
|
||
const cells = useMemo(() => buildMonthCells(year, monthIndex), [year, monthIndex]);
|
||
const title = `${MONTH_NAMES_RU_SHORT[monthIndex]} ${year}`;
|
||
|
||
const blockClass = compact ? "races-cal__month races-cal__month--compact" : "races-cal__month";
|
||
|
||
return (
|
||
<div className={blockClass}>
|
||
<h3 className="races-cal__month-title">
|
||
{onMonthSelect ? (
|
||
<button
|
||
type="button"
|
||
className="races-cal__month-title-button"
|
||
onClick={() => {
|
||
onMonthSelect(monthIndex);
|
||
}}
|
||
>
|
||
{title}
|
||
</button>
|
||
) : (
|
||
title
|
||
)}
|
||
</h3>
|
||
<div className="races-cal__weekdays" aria-hidden>
|
||
{WEEKDAY_LABELS_SHORT_RU.map((d) => (
|
||
<span key={d} className="races-cal__weekday">
|
||
{d}
|
||
</span>
|
||
))}
|
||
</div>
|
||
<div className="races-cal__cells">
|
||
{cells.map((day, idx) => {
|
||
if (day === null) {
|
||
return <div key={`e-${idx}`} className="races-cal__cell races-cal__cell--empty" />;
|
||
}
|
||
const ymd = toYmd(year, monthIndex, day);
|
||
const dayRaces = racesByYmd.get(ymd) ?? [];
|
||
const hasRaces = dayRaces.length > 0;
|
||
const isOpen = openYmd === ymd;
|
||
const isPast = isRaceDateInPast(ymd);
|
||
const isToday = ymd === todayYmd;
|
||
const cellClassName = [
|
||
"races-cal__cell",
|
||
hasRaces ? "races-cal__cell--has-race" : "",
|
||
isOpen ? "races-cal__cell--open" : "",
|
||
isPast ? "races-cal__cell--past" : "",
|
||
isToday ? "races-cal__cell--today" : "",
|
||
]
|
||
.filter(Boolean)
|
||
.join(" ");
|
||
|
||
return (
|
||
<div
|
||
key={ymd}
|
||
className={cellClassName}
|
||
onMouseEnter={() => {
|
||
cancelClose();
|
||
setOpenYmd(hasRaces ? ymd : null);
|
||
}}
|
||
onMouseLeave={scheduleClose}
|
||
>
|
||
<button
|
||
type="button"
|
||
className="races-cal__day-btn"
|
||
onClick={() => {
|
||
navigate(`/races/day/${ymd}`);
|
||
}}
|
||
onFocus={() => {
|
||
cancelClose();
|
||
setOpenYmd(hasRaces ? ymd : null);
|
||
}}
|
||
onBlur={(e) => {
|
||
const next = e.relatedTarget as Node | null;
|
||
if (next && e.currentTarget.closest(".races-cal__cell")?.contains(next)) {
|
||
return;
|
||
}
|
||
scheduleClose();
|
||
}}
|
||
>
|
||
{day}
|
||
</button>
|
||
{isOpen && hasRaces ? (
|
||
<DayPopover
|
||
ymd={ymd}
|
||
races={dayRaces}
|
||
onCancelClose={cancelClose}
|
||
onScheduleClose={scheduleClose}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function RacesCalendar(props: RacesCalendarProps): JSX.Element {
|
||
const { displayYear, monthFilter, races, onMonthFilterChange } = props;
|
||
const navigate = useNavigate();
|
||
const [openYmd, setOpenYmd] = useState<string | null>(null);
|
||
const leaveTimerRef = useRef<number | null>(null);
|
||
|
||
const cancelClose = useCallback(() => {
|
||
if (leaveTimerRef.current !== null) {
|
||
window.clearTimeout(leaveTimerRef.current);
|
||
leaveTimerRef.current = null;
|
||
}
|
||
}, []);
|
||
|
||
const scheduleClose = useCallback(() => {
|
||
cancelClose();
|
||
leaveTimerRef.current = window.setTimeout(() => {
|
||
setOpenYmd(null);
|
||
leaveTimerRef.current = null;
|
||
}, POPOVER_LEAVE_MS);
|
||
}, [cancelClose]);
|
||
|
||
const racesByYmd = useMemo(() => groupRacesByYmd(races), [races]);
|
||
const todayYmd = useMemo(() => toLocalYmd(new Date()), []);
|
||
|
||
const focusedMonthIndex = monthFilter === "" ? null : parseInt(monthFilter, 10) - 1;
|
||
|
||
return (
|
||
<div className="races-cal">
|
||
<p className="races-cal__hint">Наведите на дату с забегом — краткая информация. Клик — страница дня.</p>
|
||
{focusedMonthIndex === null || Number.isNaN(focusedMonthIndex) ? (
|
||
<div className="races-cal__year">
|
||
{MONTH_NAMES_RU_SHORT.map((_, mi) => (
|
||
<CalendarMonthBlock
|
||
key={mi}
|
||
year={displayYear}
|
||
monthIndex={mi}
|
||
racesByYmd={racesByYmd}
|
||
compact
|
||
navigate={navigate}
|
||
openYmd={openYmd}
|
||
setOpenYmd={setOpenYmd}
|
||
scheduleClose={scheduleClose}
|
||
cancelClose={cancelClose}
|
||
onMonthSelect={(mi) => {
|
||
onMonthFilterChange(String(mi + 1));
|
||
setOpenYmd(null);
|
||
}}
|
||
todayYmd={todayYmd}
|
||
/>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="races-cal__month-focus">
|
||
<nav className="races-cal__month-nav" aria-label="Месяцы года">
|
||
{MONTH_NAMES_RU_SHORT.map((label, mi) => (
|
||
<button
|
||
key={label}
|
||
type="button"
|
||
className={`races-cal__month-nav-item${mi === focusedMonthIndex ? " races-cal__month-nav-item--active" : ""}`}
|
||
onClick={() => {
|
||
onMonthFilterChange(String(mi + 1));
|
||
}}
|
||
>
|
||
{label}
|
||
</button>
|
||
))}
|
||
<button
|
||
type="button"
|
||
className="races-cal__month-nav-item races-cal__month-nav-item--all"
|
||
onClick={() => {
|
||
onMonthFilterChange("");
|
||
}}
|
||
>
|
||
Весь год
|
||
</button>
|
||
</nav>
|
||
<CalendarMonthBlock
|
||
year={displayYear}
|
||
monthIndex={focusedMonthIndex}
|
||
racesByYmd={racesByYmd}
|
||
compact={false}
|
||
navigate={navigate}
|
||
openYmd={openYmd}
|
||
setOpenYmd={setOpenYmd}
|
||
scheduleClose={scheduleClose}
|
||
cancelClose={cancelClose}
|
||
todayYmd={todayYmd}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|