feat: complete p0 hardening sprint
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
This commit is contained in:
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "calendar-run-frontend",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "calendar-run-frontend",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "calendar-run-frontend",
|
||||
"private": true,
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
import { requestJson } from "./http";
|
||||
|
||||
export type HealthResponse = {
|
||||
status: string;
|
||||
version: string;
|
||||
};
|
||||
|
||||
export type BackendMetaResponse = {
|
||||
version: string;
|
||||
};
|
||||
|
||||
export async function getHealth(init?: RequestInit): Promise<HealthResponse> {
|
||||
return requestJson<HealthResponse>("/health", init);
|
||||
}
|
||||
|
||||
/** Версия бэкенда для футера (отдельный путь от /health — меньше ложных блокировок). */
|
||||
export async function getBackendMeta(init?: RequestInit): Promise<BackendMetaResponse> {
|
||||
return requestJson<BackendMetaResponse>("/meta", init);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export type { AuthUser, CreateRacePayload, Race, RacesQuery, RaceStatus, UpdateRacePayload } from "./types";
|
||||
export { ApiError, getApiErrorMessage } from "./errors";
|
||||
export type { BackendMetaResponse, HealthResponse } from "./health";
|
||||
export { getBackendMeta, getHealth } from "./health";
|
||||
export type { BackendMetaResponse } from "./health";
|
||||
export { getBackendMeta } from "./health";
|
||||
export { getRaceById, getRaces, createRace, updateRace, deleteRace } from "./races";
|
||||
export {
|
||||
forgotPassword,
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { buildMonthCells, toYmd, WEEKDAY_LABELS_SHORT_RU } from "../lib";
|
||||
|
||||
const MONTH_NAMES_RU = [
|
||||
"Январь",
|
||||
"Февраль",
|
||||
"Март",
|
||||
"Апрель",
|
||||
"Май",
|
||||
"Июнь",
|
||||
"Июль",
|
||||
"Август",
|
||||
"Сентябрь",
|
||||
"Октябрь",
|
||||
"Ноябрь",
|
||||
"Декабрь",
|
||||
];
|
||||
|
||||
interface DatePickerFieldProps {
|
||||
value: string;
|
||||
name: string;
|
||||
required?: boolean;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
function parseYmd(value: string): { year: number; monthIndex: number; day: number } | null {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const year = Number(value.slice(0, 4));
|
||||
const monthIndex = Number(value.slice(5, 7)) - 1;
|
||||
const day = Number(value.slice(8, 10));
|
||||
|
||||
if (!Number.isInteger(year) || !Number.isInteger(monthIndex) || !Number.isInteger(day)) {
|
||||
return null;
|
||||
}
|
||||
if (monthIndex < 0 || monthIndex > 11) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { year, monthIndex, day };
|
||||
}
|
||||
|
||||
function getInitialVisibleMonth(value: string): { year: number; monthIndex: number } {
|
||||
const parsed = parseYmd(value);
|
||||
if (parsed) {
|
||||
return { year: parsed.year, monthIndex: parsed.monthIndex };
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
return { year: now.getFullYear(), monthIndex: now.getMonth() };
|
||||
}
|
||||
|
||||
export function DatePickerField(props: DatePickerFieldProps): JSX.Element {
|
||||
const { value, name, required, onChange } = props;
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [visibleMonth, setVisibleMonth] = useState(() => getInitialVisibleMonth(value));
|
||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const parsed = parseYmd(value);
|
||||
if (!parsed) {
|
||||
return;
|
||||
}
|
||||
setVisibleMonth({ year: parsed.year, monthIndex: parsed.monthIndex });
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
function handlePointerDown(event: MouseEvent): void {
|
||||
if (rootRef.current?.contains(event.target as Node)) {
|
||||
return;
|
||||
}
|
||||
setIsOpen(false);
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent): void {
|
||||
if (event.key === "Escape") {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("mousedown", handlePointerDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handlePointerDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const selected = parseYmd(value);
|
||||
const todayYmd = toYmd(new Date().getFullYear(), new Date().getMonth(), new Date().getDate());
|
||||
const cells = useMemo(
|
||||
() => buildMonthCells(visibleMonth.year, visibleMonth.monthIndex),
|
||||
[visibleMonth],
|
||||
);
|
||||
const monthTitle = `${MONTH_NAMES_RU[visibleMonth.monthIndex]} ${visibleMonth.year}`;
|
||||
|
||||
function shiftMonth(delta: number): void {
|
||||
setVisibleMonth((prev) => {
|
||||
const next = new Date(Date.UTC(prev.year, prev.monthIndex + delta, 1));
|
||||
return { year: next.getUTCFullYear(), monthIndex: next.getUTCMonth() };
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="date-picker" ref={rootRef}>
|
||||
<div className="date-picker__control">
|
||||
<input
|
||||
className="race-form__input date-picker__input"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
name={name}
|
||||
value={value}
|
||||
onChange={(event) => {
|
||||
onChange(event.target.value);
|
||||
}}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
placeholder="2026-05-03"
|
||||
autoComplete="off"
|
||||
required={required}
|
||||
/>
|
||||
<button
|
||||
className="date-picker__toggle"
|
||||
type="button"
|
||||
aria-label="Открыть календарь"
|
||||
aria-expanded={isOpen}
|
||||
onClick={() => setIsOpen((prev) => !prev)}
|
||||
>
|
||||
▾
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isOpen ? (
|
||||
<div className="date-picker__popover" role="dialog" aria-label="Выбор даты">
|
||||
<div className="date-picker__header">
|
||||
<button
|
||||
className="date-picker__nav"
|
||||
type="button"
|
||||
aria-label="Предыдущий месяц"
|
||||
onClick={() => shiftMonth(-1)}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<p className="date-picker__title">{monthTitle}</p>
|
||||
<button
|
||||
className="date-picker__nav"
|
||||
type="button"
|
||||
aria-label="Следующий месяц"
|
||||
onClick={() => shiftMonth(1)}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
<div className="date-picker__weekdays" aria-hidden>
|
||||
{WEEKDAY_LABELS_SHORT_RU.map((weekday) => (
|
||||
<span key={weekday} className="date-picker__weekday">
|
||||
{weekday}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="date-picker__cells">
|
||||
{cells.map((day, idx) => {
|
||||
if (day === null) {
|
||||
return <span key={`empty-${idx}`} className="date-picker__cell date-picker__cell--empty" />;
|
||||
}
|
||||
|
||||
const ymd = toYmd(visibleMonth.year, visibleMonth.monthIndex, day);
|
||||
const isSelected =
|
||||
selected?.year === visibleMonth.year &&
|
||||
selected.monthIndex === visibleMonth.monthIndex &&
|
||||
selected.day === day;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={ymd}
|
||||
className={`date-picker__day${isSelected ? " date-picker__day--selected" : ""}${ymd === todayYmd ? " date-picker__day--today" : ""}`}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(ymd);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
{day}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
|
||||
function pad2(n: number): string {
|
||||
return String(n).padStart(2, "0");
|
||||
}
|
||||
|
||||
function parseToParts(value: string): { h: number | null; m: number | null; s: number | null } {
|
||||
const t = value.trim();
|
||||
if (!t) {
|
||||
return { h: null, m: null, s: null };
|
||||
}
|
||||
const parts = t.split(":").map((p) => p.trim());
|
||||
if (parts.length === 2) {
|
||||
const h = Number(parts[0]);
|
||||
const m = Number(parts[1]);
|
||||
if (Number.isInteger(h) && Number.isInteger(m) && h >= 0 && h <= 23 && m >= 0 && m <= 59) {
|
||||
return { h, m, s: 0 };
|
||||
}
|
||||
}
|
||||
if (parts.length >= 3) {
|
||||
const h = Number(parts[0]);
|
||||
const m = Number(parts[1]);
|
||||
const s = Number(parts[2]);
|
||||
if (
|
||||
Number.isInteger(h) &&
|
||||
Number.isInteger(m) &&
|
||||
Number.isInteger(s) &&
|
||||
h >= 0 &&
|
||||
h <= 23 &&
|
||||
m >= 0 &&
|
||||
m <= 59 &&
|
||||
s >= 0 &&
|
||||
s <= 59
|
||||
) {
|
||||
return { h, m, s };
|
||||
}
|
||||
}
|
||||
return { h: null, m: null, s: null };
|
||||
}
|
||||
|
||||
function partsToString(h: number | null, m: number | null, s: number | null): string {
|
||||
if (h === null || m === null || s === null) {
|
||||
return "";
|
||||
}
|
||||
return `${pad2(h)}:${pad2(m)}:${pad2(s)}`;
|
||||
}
|
||||
|
||||
const HOURS = Array.from({ length: 24 }, (_, i) => i);
|
||||
const MIN_SEC = Array.from({ length: 60 }, (_, i) => i);
|
||||
|
||||
interface StartTimeSelectsProps {
|
||||
value: string;
|
||||
onChange: (next: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function StartTimeSelects(props: StartTimeSelectsProps): JSX.Element {
|
||||
const { value, onChange, disabled } = props;
|
||||
const { h, m, s } = useMemo(() => parseToParts(value), [value]);
|
||||
|
||||
const emit = useCallback(
|
||||
(nextH: number | null, nextM: number | null, nextS: number | null) => {
|
||||
onChange(partsToString(nextH, nextM, nextS));
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const hourVal = h === null ? "" : String(h);
|
||||
const minVal = m === null ? "" : String(m);
|
||||
const secVal = s === null ? "" : String(s);
|
||||
|
||||
return (
|
||||
<div className="race-form__time-picker">
|
||||
<label className="race-form__time-picker__unit">
|
||||
<span className="race-form__time-picker__label">Часы</span>
|
||||
<select
|
||||
className="race-form__input race-form__time-picker__select"
|
||||
aria-label="Часы"
|
||||
disabled={disabled}
|
||||
value={hourVal}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
if (v === "") {
|
||||
emit(null, null, null);
|
||||
return;
|
||||
}
|
||||
const nh = Number(v);
|
||||
emit(nh, m ?? 0, s ?? 0);
|
||||
}}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{HOURS.map((n) => (
|
||||
<option key={n} value={String(n)}>
|
||||
{pad2(n)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<span className="race-form__time-picker__sep" aria-hidden>
|
||||
:
|
||||
</span>
|
||||
<label className="race-form__time-picker__unit">
|
||||
<span className="race-form__time-picker__label">Минуты</span>
|
||||
<select
|
||||
className="race-form__input race-form__time-picker__select"
|
||||
aria-label="Минуты"
|
||||
disabled={disabled}
|
||||
value={minVal}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
if (v === "") {
|
||||
emit(null, null, null);
|
||||
return;
|
||||
}
|
||||
const nm = Number(v);
|
||||
emit(h ?? 0, nm, s ?? 0);
|
||||
}}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{MIN_SEC.map((n) => (
|
||||
<option key={n} value={String(n)}>
|
||||
{pad2(n)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<span className="race-form__time-picker__sep" aria-hidden>
|
||||
:
|
||||
</span>
|
||||
<label className="race-form__time-picker__unit">
|
||||
<span className="race-form__time-picker__label">Секунды</span>
|
||||
<select
|
||||
className="race-form__input race-form__time-picker__select"
|
||||
aria-label="Секунды"
|
||||
disabled={disabled}
|
||||
value={secVal}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
if (v === "") {
|
||||
emit(null, null, null);
|
||||
return;
|
||||
}
|
||||
const ns = Number(v);
|
||||
emit(h ?? 0, m ?? 0, ns);
|
||||
}}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{MIN_SEC.map((n) => (
|
||||
<option key={n} value={String(n)}>
|
||||
{pad2(n)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,2 @@
|
||||
export { DatePickerField } from "./DatePickerField";
|
||||
export { PaceTrendChart } from "./PaceTrendChart";
|
||||
export { RacesCalendar } from "./RacesCalendar";
|
||||
export { StartTimeSelects } from "./StartTimeSelects";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export {
|
||||
formatDistance,
|
||||
formatRaceDate,
|
||||
findLatestPersonalRecord,
|
||||
getPaceLabel,
|
||||
getRaceCountdownLabel,
|
||||
getRaceStatusClassName,
|
||||
|
||||
@@ -24,18 +24,29 @@ export function parseFinishTimeToSeconds(value: string | null): number | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts = value.split(":").map((part) => Number(part));
|
||||
if (parts.some((part) => Number.isNaN(part) || part < 0)) {
|
||||
const parts = value.split(":");
|
||||
if (parts.some((part) => !/^\d+$/.test(part))) {
|
||||
return null;
|
||||
}
|
||||
const values = parts.map(Number);
|
||||
|
||||
if (parts.length === 2) {
|
||||
const [minutes, seconds] = parts;
|
||||
if (values.length === 1) {
|
||||
return values[0]! <= 59 ? values[0]! : null;
|
||||
}
|
||||
|
||||
if (values.length === 2) {
|
||||
const [minutes, seconds] = values;
|
||||
if (minutes! > 59 || seconds! > 59) {
|
||||
return null;
|
||||
}
|
||||
return minutes * 60 + seconds;
|
||||
}
|
||||
|
||||
if (parts.length === 3) {
|
||||
const [hours, minutes, seconds] = parts;
|
||||
if (values.length === 3) {
|
||||
const [hours, minutes, seconds] = values;
|
||||
if (minutes! > 59 || seconds! > 59) {
|
||||
return null;
|
||||
}
|
||||
return hours * 3600 + minutes * 60 + seconds;
|
||||
}
|
||||
|
||||
@@ -62,6 +73,37 @@ export function sortByDateDesc(races: Race[]): Race[] {
|
||||
return [...races].sort((left, right) => parseRaceDate(right.date).getTime() - parseRaceDate(left.date).getTime());
|
||||
}
|
||||
|
||||
/** Последний результат, который улучшил уже существовавший личный рекорд на сопоставимой дистанции. */
|
||||
export function findLatestPersonalRecord(races: Race[]): Race | null {
|
||||
const bestTimes: Array<{ distanceKm: number; seconds: number }> = [];
|
||||
let latestRecord: Race | null = null;
|
||||
|
||||
for (const race of sortByDateAsc(races)) {
|
||||
if (race.status !== "completed") {
|
||||
continue;
|
||||
}
|
||||
const seconds = parseFinishTimeToSeconds(race.finishTime);
|
||||
if (seconds === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// ponytail: personal calendars are small; introduce a distance index only if profiling requires it.
|
||||
const comparable = bestTimes.filter((best) => isCloseDistance(best.distanceKm, race.distanceKm));
|
||||
if (comparable.length > 0 && seconds < Math.min(...comparable.map((best) => best.seconds))) {
|
||||
latestRecord = race;
|
||||
}
|
||||
if (comparable.length === 0) {
|
||||
bestTimes.push({ distanceKm: race.distanceKm, seconds });
|
||||
} else {
|
||||
comparable.forEach((best) => {
|
||||
best.seconds = Math.min(best.seconds, seconds);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return latestRecord;
|
||||
}
|
||||
|
||||
export function splitRacesByDate(races: Race[], now: Date = new Date()): { upcoming: Race[]; past: Race[] } {
|
||||
const today = new Date(now);
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
@@ -11,13 +11,6 @@ interface RaceVisual {
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface OfficialRaceVisual {
|
||||
keywords: string[];
|
||||
imageSrc: string;
|
||||
imageFit?: RaceVisualFit;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const FALLBACK_VISUALS: Record<RaceVisualVariant, RaceVisual> = {
|
||||
short: {
|
||||
variant: "short",
|
||||
@@ -56,109 +49,8 @@ const FALLBACK_VISUALS: Record<RaceVisualVariant, RaceVisual> = {
|
||||
},
|
||||
};
|
||||
|
||||
const OFFICIAL_VISUALS: OfficialRaceVisual[] = [
|
||||
{
|
||||
keywords: ["забег апрель"],
|
||||
imageSrc: "https://aprilrun5km.runc.run/uploads/page_card_photos/AprilRun_photo_1.jpg",
|
||||
label: "Забег Апрель",
|
||||
},
|
||||
{
|
||||
keywords: ["быстрый пес"],
|
||||
imageSrc: "https://fastdogxc.runc.run/uploads/page_card_photos/Dog_spring_2026-5.jpg",
|
||||
label: "Кросс",
|
||||
},
|
||||
{
|
||||
keywords: ["лисья гора"],
|
||||
imageSrc: "https://foxhillxc.runc.run/uploads/page_card_photos/Fox_Spring_2026-0.jpg",
|
||||
label: "Кросс",
|
||||
},
|
||||
{
|
||||
keywords: ["казанский марафон"],
|
||||
imageSrc: "https://static.tildacdn.com/tild3961-6436-4462-b738-356665613039/Frame_2131327895.png",
|
||||
imageFit: "contain",
|
||||
label: "Казанский марафон",
|
||||
},
|
||||
{
|
||||
keywords: ["мышкинский полумарафон", "по шести холмам"],
|
||||
imageSrc: "https://static.tildacdn.com/tild6133-6137-4865-b166-623532313531/photo.jpg",
|
||||
label: "Золотое кольцо",
|
||||
},
|
||||
{
|
||||
keywords: ["забег.рф", "забег рф"],
|
||||
imageSrc: "https://xn--80acghh.xn--p1ai/zabeg.jpg",
|
||||
label: "ЗаБег.РФ",
|
||||
},
|
||||
{
|
||||
keywords: ["переславский марафон", "александровские версты"],
|
||||
imageSrc: "https://static.tildacdn.com/tild6432-3338-4533-b262-633339353335/photo_1.jpg",
|
||||
label: "Золотое кольцо",
|
||||
},
|
||||
{
|
||||
keywords: ["красочный забег"],
|
||||
imageSrc: "https://colorrun5km.runc.run/uploads/page_card_photos/ColorRun2026-1.jpg",
|
||||
label: "Красочный забег",
|
||||
},
|
||||
{
|
||||
keywords: ["здорово кострома", "здорово, кострома"],
|
||||
imageSrc: "https://static.tildacdn.com/tild6139-3539-4661-b232-386230336431/kostroma.jpg",
|
||||
label: "Золотое кольцо",
|
||||
},
|
||||
{
|
||||
keywords: ["ночной забег москва"],
|
||||
imageSrc: "https://nightrun10km.runc.run/uploads/page_card_photos/NightRun_2026-9.jpg",
|
||||
label: "Ночной забег",
|
||||
},
|
||||
{
|
||||
keywords: ["белые ночи"],
|
||||
imageSrc: "https://wnmarathon.runc.run/uploads/page_card_photos/WN_photo_01.jpg",
|
||||
label: "Белые ночи",
|
||||
},
|
||||
{
|
||||
keywords: ["сергиевым путем", "сергиевым путём"],
|
||||
imageSrc: "https://static.tildacdn.com/tild6236-3466-4239-b666-393061326338/serg.jpg",
|
||||
label: "Золотое кольцо",
|
||||
},
|
||||
{
|
||||
keywords: ["ночной забег нижний новгород"],
|
||||
imageSrc: "https://rrweb.russiarunning.com/-x740/generalimages/0531a1b8-3876-4620-8961-2fa374e474e5.png",
|
||||
imageFit: "contain",
|
||||
label: "Ночной забег",
|
||||
},
|
||||
{
|
||||
keywords: ["suvorov extreme"],
|
||||
imageSrc: "https://goldenultra.ru/grut/files/photos/100.jpg",
|
||||
label: "Трейл",
|
||||
},
|
||||
{
|
||||
keywords: ["рыбинский полумарафон", "великий хлебный путь"],
|
||||
imageSrc: "https://static.tildacdn.com/tild6130-3230-4332-b932-366166366633/photo.jpg",
|
||||
label: "Золотое кольцо",
|
||||
},
|
||||
{
|
||||
keywords: ["ярославский полумарафон", "золотое кольцо"],
|
||||
imageSrc: "https://static.tildacdn.com/tild6331-6333-4635-b635-376262373361/photo.jpg",
|
||||
label: "Золотое кольцо",
|
||||
},
|
||||
{
|
||||
keywords: ["моя столица"],
|
||||
imageSrc: "https://static.tildacdn.com/tild3263-3036-4639-b830-653365663832/-min.jpg",
|
||||
imageFit: "contain",
|
||||
label: "Моя столица",
|
||||
},
|
||||
];
|
||||
|
||||
function normalizeTitle(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replaceAll("ё", "е")
|
||||
.replace(/[«»|]/g, " ")
|
||||
.replace(/[^\p{L}\p{N}.&]+/gu, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function getFallbackRaceVisual(race: Race): RaceVisual {
|
||||
const title = normalizeTitle(race.title);
|
||||
const title = race.title.toLowerCase();
|
||||
|
||||
if (title.includes("ночной")) {
|
||||
return FALLBACK_VISUALS.night;
|
||||
@@ -196,20 +88,5 @@ export function getRaceVisual(race: Race): RaceVisual {
|
||||
};
|
||||
}
|
||||
|
||||
const title = normalizeTitle(race.title);
|
||||
const official = OFFICIAL_VISUALS.find((visual) =>
|
||||
visual.keywords.some((keyword) => title.includes(normalizeTitle(keyword))),
|
||||
);
|
||||
|
||||
if (!official) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return {
|
||||
...fallback,
|
||||
imageSrc: official.imageSrc,
|
||||
fallbackSrc: fallback.imageSrc,
|
||||
imageFit: official.imageFit ?? fallback.imageFit,
|
||||
label: official.label,
|
||||
};
|
||||
return fallback;
|
||||
}
|
||||
|
||||
@@ -120,6 +120,7 @@ export function RegisterPage(): JSX.Element {
|
||||
const [turnstileToken, setTurnstileToken] = useState("");
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleToken = useCallback((token: string) => setTurnstileToken(token), []);
|
||||
|
||||
@@ -127,11 +128,14 @@ export function RegisterPage(): JSX.Element {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setMessage(null);
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await register({ email, password, turnstileToken });
|
||||
setMessage("Проверьте почту: мы отправили ссылку для подтверждения email.");
|
||||
} catch (err) {
|
||||
setError(errorMessage(err, "Не удалось зарегистрироваться."));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -150,7 +154,9 @@ export function RegisterPage(): JSX.Element {
|
||||
<TurnstileField onToken={handleToken} />
|
||||
{message ? <p className="page__subtitle">{message}</p> : null}
|
||||
{error ? <p className="page__subtitle page__subtitle--error">{error}</p> : null}
|
||||
<button className="btn btn--primary" type="submit" disabled={!turnstileToken}>Создать аккаунт</button>
|
||||
<button className="btn btn--primary" type="submit" disabled={!turnstileToken || isSubmitting}>
|
||||
{isSubmitting ? "Создаём аккаунт…" : "Создать аккаунт"}
|
||||
</button>
|
||||
</form>
|
||||
<p className="auth-form__links">
|
||||
<Link className="page-link" to="/login">Уже есть аккаунт</Link>
|
||||
@@ -165,30 +171,72 @@ export function VerifyEmailPage(): JSX.Element {
|
||||
const [email, setEmail] = useState(user?.email ?? "");
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isVerifying, setIsVerifying] = useState(false);
|
||||
const [isVerified, setIsVerified] = useState(false);
|
||||
const [isResending, setIsResending] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const token = params.get("token");
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
void verifyEmail(token)
|
||||
.then(async () => {
|
||||
await refresh();
|
||||
setMessage("Email подтверждён. Теперь можно пользоваться календарём.");
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err, "Ссылка недействительна.")));
|
||||
let isMounted = true;
|
||||
setIsVerifying(true);
|
||||
setError(null);
|
||||
void (async () => {
|
||||
try {
|
||||
await verifyEmail(token);
|
||||
try {
|
||||
await refresh();
|
||||
} catch {
|
||||
// Подтверждение уже выполнено; обновление сессии не должно скрывать успех.
|
||||
}
|
||||
if (isMounted) {
|
||||
setIsVerified(true);
|
||||
}
|
||||
} catch (err) {
|
||||
if (isMounted) {
|
||||
setError(errorMessage(err, "Ссылка недействительна."));
|
||||
}
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setIsVerifying(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [params, refresh]);
|
||||
|
||||
const resend = async () => {
|
||||
setError(null);
|
||||
await resendVerification(email);
|
||||
setMessage("Если email зарегистрирован и ещё не подтверждён, письмо отправлено.");
|
||||
setMessage(null);
|
||||
setIsResending(true);
|
||||
try {
|
||||
await resendVerification(email);
|
||||
setMessage("Если email зарегистрирован и ещё не подтверждён, письмо отправлено.");
|
||||
} catch (err) {
|
||||
setError(errorMessage(err, "Не удалось отправить письмо повторно."));
|
||||
} finally {
|
||||
setIsResending(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isVerified) {
|
||||
return (
|
||||
<section className="page page--auth">
|
||||
<h1 className="page__title">Email подтверждён</h1>
|
||||
<p className="page__subtitle">Теперь можно пользоваться календарём.</p>
|
||||
<p className="auth-form__links"><Link className="page-link" to="/">Перейти к календарю</Link></p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page page--auth">
|
||||
<h1 className="page__title">Подтверждение email</h1>
|
||||
<p className="page__subtitle">Для доступа к календарю подтвердите email по ссылке из письма.</p>
|
||||
<p className="page__subtitle">{isVerifying ? "Проверяем ссылку…" : "Для доступа к календарю подтвердите email по ссылке из письма."}</p>
|
||||
<form className="auth-form" onSubmit={(e) => { e.preventDefault(); void resend(); }}>
|
||||
<label className="auth-form__field">
|
||||
<span className="auth-form__label">Email</span>
|
||||
@@ -196,7 +244,9 @@ export function VerifyEmailPage(): JSX.Element {
|
||||
</label>
|
||||
{message ? <p className="page__subtitle">{message}</p> : null}
|
||||
{error ? <p className="page__subtitle page__subtitle--error">{error}</p> : null}
|
||||
<button className="btn btn--primary" type="submit">Отправить письмо ещё раз</button>
|
||||
<button className="btn btn--primary" type="submit" disabled={isVerifying || isResending}>
|
||||
{isResending ? "Отправляем…" : "Отправить письмо ещё раз"}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
@@ -205,17 +255,35 @@ export function VerifyEmailPage(): JSX.Element {
|
||||
export function ForgotPasswordPage(): JSX.Element {
|
||||
const [email, setEmail] = useState("");
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setMessage(null);
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await forgotPassword(email);
|
||||
setMessage("Если email зарегистрирован, ссылка отправлена.");
|
||||
} catch (err) {
|
||||
setError(errorMessage(err, "Не удалось отправить ссылку."));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="page page--auth">
|
||||
<h1 className="page__title">Сброс пароля</h1>
|
||||
<form className="auth-form" onSubmit={async (e) => { e.preventDefault(); await forgotPassword(email); setMessage("Если email зарегистрирован, ссылка отправлена."); }}>
|
||||
<form className="auth-form" onSubmit={handleSubmit}>
|
||||
<label className="auth-form__field">
|
||||
<span className="auth-form__label">Email</span>
|
||||
<input className="auth-form__input" type="email" value={email} onChange={(event) => setEmail(event.target.value)} required />
|
||||
</label>
|
||||
{message ? <p className="page__subtitle">{message}</p> : null}
|
||||
<button className="btn btn--primary" type="submit">Отправить ссылку</button>
|
||||
{error ? <p className="page__subtitle page__subtitle--error" role="alert">{error}</p> : null}
|
||||
<button className="btn btn--primary" type="submit" disabled={isSubmitting}>{isSubmitting ? "Отправляем…" : "Отправить ссылку"}</button>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
@@ -226,27 +294,34 @@ export function ResetPasswordPage(): JSX.Element {
|
||||
const [password, setPassword] = useState("");
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setMessage(null);
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await resetPassword(params.get("token") ?? "", password);
|
||||
setMessage("Пароль обновлён. Теперь войдите заново.");
|
||||
} catch (err) {
|
||||
setError(errorMessage(err, "Не удалось обновить пароль."));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="page page--auth">
|
||||
<h1 className="page__title">Новый пароль</h1>
|
||||
<form className="auth-form" onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
const token = params.get("token") ?? "";
|
||||
try {
|
||||
await resetPassword(token, password);
|
||||
setMessage("Пароль обновлён. Теперь войдите заново.");
|
||||
} catch (err) {
|
||||
setError(errorMessage(err, "Не удалось обновить пароль."));
|
||||
}
|
||||
}}>
|
||||
<form className="auth-form" onSubmit={handleSubmit}>
|
||||
<label className="auth-form__field">
|
||||
<span className="auth-form__label">Пароль</span>
|
||||
<input className="auth-form__input" type="password" minLength={15} value={password} onChange={(event) => setPassword(event.target.value)} required />
|
||||
</label>
|
||||
{message ? <p className="page__subtitle">{message}</p> : null}
|
||||
{error ? <p className="page__subtitle page__subtitle--error">{error}</p> : null}
|
||||
<button className="btn btn--primary" type="submit">Сохранить пароль</button>
|
||||
{error ? <p className="page__subtitle page__subtitle--error" role="alert">{error}</p> : null}
|
||||
<button className="btn btn--primary" type="submit" disabled={isSubmitting}>{isSubmitting ? "Сохраняем…" : "Сохранить пароль"}</button>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { PaceTrendChart } from "../components";
|
||||
import {
|
||||
formatDistance,
|
||||
formatRaceDate,
|
||||
findLatestPersonalRecord,
|
||||
getRaceCountdownLabel,
|
||||
getRaceVisual,
|
||||
getPaceLabel,
|
||||
@@ -76,10 +77,7 @@ export function DashboardPage(): JSX.Element {
|
||||
const nextRace = upcoming[0] ?? null;
|
||||
const lastResult = past.find((race) => race.status === "completed") ?? null;
|
||||
|
||||
const lastPersonalRecord =
|
||||
past.find(
|
||||
(race) => race.status === "completed" && parseFinishTimeToSeconds(race.finishTime) !== null,
|
||||
) ?? null;
|
||||
const lastPersonalRecord = findLatestPersonalRecord(races);
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
const seasonRaces = races.filter((race) => parseRaceDate(race.date).getFullYear() === currentYear);
|
||||
@@ -259,10 +257,8 @@ export function DashboardPage(): JSX.Element {
|
||||
)}
|
||||
</article>
|
||||
|
||||
<article
|
||||
className={`dashboard-card dashboard-card--accent-lime${dashboardMetrics.lastPersonalRecord ? " dashboard-card--linked" : ""}`}
|
||||
>
|
||||
{dashboardMetrics.lastPersonalRecord ? (
|
||||
{dashboardMetrics.lastPersonalRecord ? (
|
||||
<article className="dashboard-card dashboard-card--accent-lime dashboard-card--linked">
|
||||
<Link
|
||||
className="dashboard-card__link-surface"
|
||||
to={`/races/${dashboardMetrics.lastPersonalRecord.id}`}
|
||||
@@ -278,13 +274,8 @@ export function DashboardPage(): JSX.Element {
|
||||
</p>
|
||||
<p className="dashboard-card__hint">{formatRaceDate(dashboardMetrics.lastPersonalRecord.date)}</p>
|
||||
</Link>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="dashboard-card__title">Последний личный рекорд</h2>
|
||||
<p className="dashboard-card__empty">Нет завершённых стартов с финишным временем.</p>
|
||||
</>
|
||||
)}
|
||||
</article>
|
||||
</article>
|
||||
) : null}
|
||||
|
||||
<article className="dashboard-card dashboard-card--season dashboard-card--accent-violet">
|
||||
<h2 className="dashboard-card__title">Сезон</h2>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import { ApiError, createRace, getRaceById, updateRace } from "../api";
|
||||
import type { CreateRacePayload, Race, RaceStatus, UpdateRacePayload } from "../api";
|
||||
import { DatePickerField, StartTimeSelects } from "../components";
|
||||
import { isRaceDateInPast, parseFinishTimeToSeconds } from "../lib";
|
||||
import type { CreateRacePayload, Race, RaceStatus } from "../api";
|
||||
import { isRaceDateInPast } from "../lib";
|
||||
|
||||
function slugify(text: string): string {
|
||||
return text
|
||||
@@ -36,8 +35,13 @@ interface FormData {
|
||||
clusterSchedule: string;
|
||||
bibPickup: string;
|
||||
bibNumber: string;
|
||||
finishTime: string;
|
||||
finishPlace: string;
|
||||
finishPlaceTotal: string;
|
||||
originalFinishPlace: string;
|
||||
isFinishPlaceEdited: boolean;
|
||||
finishHours: string;
|
||||
finishMinutes: string;
|
||||
finishSeconds: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
@@ -52,8 +56,13 @@ const EMPTY_FORM: FormData = {
|
||||
clusterSchedule: "",
|
||||
bibPickup: "",
|
||||
bibNumber: "",
|
||||
finishTime: "",
|
||||
finishPlace: "",
|
||||
finishPlaceTotal: "",
|
||||
originalFinishPlace: "",
|
||||
isFinishPlaceEdited: false,
|
||||
finishHours: "",
|
||||
finishMinutes: "",
|
||||
finishSeconds: "",
|
||||
notes: "",
|
||||
};
|
||||
|
||||
@@ -70,17 +79,88 @@ function raceToFormData(race: Race): FormData {
|
||||
clusterSchedule: race.clusterSchedule ?? "",
|
||||
bibPickup: race.bibPickup ?? "",
|
||||
bibNumber: race.bibNumber ?? "",
|
||||
finishTime: race.finishTime ?? "",
|
||||
finishPlace: race.finishPlace ?? "",
|
||||
...finishTimeToFormData(race.finishTime),
|
||||
...finishPlaceToFormData(race.finishPlace),
|
||||
notes: race.notes ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function finishTimeToFormData(
|
||||
value: string | null,
|
||||
): Pick<FormData, "finishHours" | "finishMinutes" | "finishSeconds"> {
|
||||
const parts = value?.trim().split(":") ?? [];
|
||||
if (parts.length === 3) {
|
||||
return { finishHours: parts[0], finishMinutes: parts[1], finishSeconds: parts[2] };
|
||||
}
|
||||
if (parts.length === 2) {
|
||||
return { finishHours: "", finishMinutes: parts[0], finishSeconds: parts[1] };
|
||||
}
|
||||
return { finishHours: "", finishMinutes: "", finishSeconds: parts[0] ?? "" };
|
||||
}
|
||||
|
||||
function finishPlaceToFormData(
|
||||
value: string | null,
|
||||
): Pick<FormData, "finishPlace" | "finishPlaceTotal" | "originalFinishPlace" | "isFinishPlaceEdited"> {
|
||||
const originalFinishPlace = value ?? "";
|
||||
const [finishPlace = "", finishPlaceTotal = ""] = originalFinishPlace.split("/", 2);
|
||||
return { finishPlace, finishPlaceTotal, originalFinishPlace, isFinishPlaceEdited: false };
|
||||
}
|
||||
|
||||
function emptyToNull(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
return trimmed === "" ? null : trimmed;
|
||||
}
|
||||
|
||||
function parseNonNegativeInteger(value: string): number | null {
|
||||
const trimmed = value.trim();
|
||||
if (!/^\d+$/.test(trimmed)) {
|
||||
return null;
|
||||
}
|
||||
return Number(trimmed);
|
||||
}
|
||||
|
||||
function getFinishTime(form: FormData): { value: string | null; error: string | null } {
|
||||
const hasValue = [form.finishHours, form.finishMinutes, form.finishSeconds].some((value) => value.trim() !== "");
|
||||
if (!hasValue) {
|
||||
return { value: null, error: null };
|
||||
}
|
||||
|
||||
const hours = form.finishHours.trim() === "" ? null : parseNonNegativeInteger(form.finishHours);
|
||||
const minutes = form.finishMinutes.trim() === "" ? 0 : parseNonNegativeInteger(form.finishMinutes);
|
||||
const seconds = form.finishSeconds.trim() === "" ? 0 : parseNonNegativeInteger(form.finishSeconds);
|
||||
if (hours === null && form.finishHours.trim() !== "") {
|
||||
return { value: null, error: "Часы должны быть целым числом не меньше 0." };
|
||||
}
|
||||
if (minutes === null || seconds === null || minutes > 59 || seconds > 59) {
|
||||
return { value: null, error: "Минуты и секунды должны быть целыми числами от 0 до 59." };
|
||||
}
|
||||
|
||||
const pad = (value: number) => String(value).padStart(2, "0");
|
||||
if (hours !== null) {
|
||||
return { value: `${hours}:${pad(minutes)}:${pad(seconds)}`, error: null };
|
||||
}
|
||||
if (form.finishMinutes.trim() !== "") {
|
||||
return { value: `${pad(minutes)}:${pad(seconds)}`, error: null };
|
||||
}
|
||||
return { value: pad(seconds), error: null };
|
||||
}
|
||||
|
||||
function getFinishPlace(form: FormData): { value: string | null; error: string | null } {
|
||||
if (!form.isFinishPlaceEdited) {
|
||||
return { value: emptyToNull(form.originalFinishPlace), error: null };
|
||||
}
|
||||
|
||||
if (form.finishPlace.trim() === "" && form.finishPlaceTotal.trim() === "") {
|
||||
return { value: null, error: null };
|
||||
}
|
||||
const place = parseNonNegativeInteger(form.finishPlace);
|
||||
const total = form.finishPlaceTotal.trim() === "" ? null : parseNonNegativeInteger(form.finishPlaceTotal);
|
||||
if (place === null || place < 1 || (total !== null && (total < 1 || place > total))) {
|
||||
return { value: null, error: "Место должно быть не меньше 1 и не больше числа участников." };
|
||||
}
|
||||
return { value: total === null ? String(place) : `${place}/${total}`, error: null };
|
||||
}
|
||||
|
||||
function validateForm(form: FormData): string[] {
|
||||
const errors: string[] = [];
|
||||
if (!form.date.trim()) {
|
||||
@@ -93,6 +173,14 @@ function validateForm(form: FormData): string[] {
|
||||
if (Number.isNaN(km) || km <= 0) {
|
||||
errors.push("Дистанция должна быть положительным числом.");
|
||||
}
|
||||
const finishTime = getFinishTime(form);
|
||||
const finishPlace = getFinishPlace(form);
|
||||
if (finishTime.error) {
|
||||
errors.push(finishTime.error);
|
||||
}
|
||||
if (finishPlace.error) {
|
||||
errors.push(finishPlace.error);
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
@@ -167,12 +255,6 @@ export function RaceFormPage(): JSX.Element {
|
||||
const { name, value } = event.target;
|
||||
setForm((prev) => {
|
||||
const next = { ...prev, [name]: value };
|
||||
if (name === "finishTime") {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed !== "" && parseFinishTimeToSeconds(trimmed) !== null) {
|
||||
return { ...next, status: "completed" };
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
@@ -193,56 +275,38 @@ export function RaceFormPage(): JSX.Element {
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
const finishTrimmed = form.finishTime.trim();
|
||||
const hasParsedFinish =
|
||||
finishTrimmed !== "" && parseFinishTimeToSeconds(finishTrimmed) !== null;
|
||||
const finishTime = getFinishTime(form);
|
||||
const finishPlace = getFinishPlace(form);
|
||||
let statusValue: RaceStatus | null =
|
||||
form.status === "planned" || form.status === "registered" || form.status === "completed"
|
||||
? form.status
|
||||
: null;
|
||||
if (hasParsedFinish) {
|
||||
if (finishTime.value) {
|
||||
statusValue = "completed";
|
||||
}
|
||||
|
||||
if (isEditMode && raceId) {
|
||||
const payload: UpdateRacePayload = {
|
||||
date: form.date.trim(),
|
||||
title: form.title.trim(),
|
||||
distanceKm: parseFloat(form.distanceKm),
|
||||
status: statusValue,
|
||||
officialUrl: emptyToNull(form.officialUrl),
|
||||
coverImageUrl: emptyToNull(form.coverImageUrl),
|
||||
startTime: emptyToNull(form.startTime),
|
||||
clusterSchedule: emptyToNull(form.clusterSchedule),
|
||||
bibPickup: emptyToNull(form.bibPickup),
|
||||
bibNumber: emptyToNull(form.bibNumber),
|
||||
finishTime: emptyToNull(form.finishTime),
|
||||
finishPlace: emptyToNull(form.finishPlace),
|
||||
notes: emptyToNull(form.notes),
|
||||
};
|
||||
const payload: Omit<CreateRacePayload, "slug"> = {
|
||||
date: form.date.trim(),
|
||||
title: form.title.trim(),
|
||||
distanceKm: parseFloat(form.distanceKm),
|
||||
status: statusValue,
|
||||
officialUrl: emptyToNull(form.officialUrl),
|
||||
coverImageUrl: emptyToNull(form.coverImageUrl),
|
||||
startTime: emptyToNull(form.startTime),
|
||||
clusterSchedule: emptyToNull(form.clusterSchedule),
|
||||
bibPickup: emptyToNull(form.bibPickup),
|
||||
bibNumber: emptyToNull(form.bibNumber),
|
||||
finishTime: finishTime.value,
|
||||
finishPlace: finishPlace.value,
|
||||
notes: emptyToNull(form.notes),
|
||||
};
|
||||
|
||||
if (isEditMode && raceId) {
|
||||
await updateRace(raceId, payload);
|
||||
navigate(`/races/${raceId}`);
|
||||
} else {
|
||||
const slug = generateId(form.date.trim(), form.title.trim());
|
||||
const payload: CreateRacePayload = {
|
||||
slug,
|
||||
date: form.date.trim(),
|
||||
title: form.title.trim(),
|
||||
distanceKm: parseFloat(form.distanceKm),
|
||||
status: statusValue,
|
||||
officialUrl: emptyToNull(form.officialUrl),
|
||||
coverImageUrl: emptyToNull(form.coverImageUrl),
|
||||
startTime: emptyToNull(form.startTime),
|
||||
clusterSchedule: emptyToNull(form.clusterSchedule),
|
||||
bibPickup: emptyToNull(form.bibPickup),
|
||||
bibNumber: emptyToNull(form.bibNumber),
|
||||
finishTime: emptyToNull(form.finishTime),
|
||||
finishPlace: emptyToNull(form.finishPlace),
|
||||
notes: emptyToNull(form.notes),
|
||||
};
|
||||
|
||||
const created = await createRace(payload);
|
||||
const created = await createRace({ ...payload, slug });
|
||||
navigate(`/races/${created.id}`);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -291,17 +355,17 @@ export function RaceFormPage(): JSX.Element {
|
||||
<fieldset className="race-form__group">
|
||||
<legend className="race-form__legend">Основная информация</legend>
|
||||
|
||||
<div className="race-form__field">
|
||||
<label className="race-form__field">
|
||||
<span className="race-form__label">Дата *</span>
|
||||
<DatePickerField
|
||||
<input
|
||||
className="race-form__input"
|
||||
type="date"
|
||||
name="date"
|
||||
value={form.date}
|
||||
onChange={(next) => {
|
||||
setForm((prev) => ({ ...prev, date: next }));
|
||||
}}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="race-form__field">
|
||||
<span className="race-form__label">Название *</span>
|
||||
@@ -317,7 +381,6 @@ export function RaceFormPage(): JSX.Element {
|
||||
</label>
|
||||
|
||||
<label className="race-form__field">
|
||||
<span className="race-form__label">Дистанция, км *</span>
|
||||
<input
|
||||
className="race-form__input"
|
||||
type="number"
|
||||
@@ -376,15 +439,17 @@ export function RaceFormPage(): JSX.Element {
|
||||
</label>
|
||||
|
||||
{hideOrgScheduleFields ? null : (
|
||||
<div className="race-form__field">
|
||||
<label className="race-form__field">
|
||||
<span className="race-form__label">Время старта</span>
|
||||
<StartTimeSelects
|
||||
<input
|
||||
className="race-form__input"
|
||||
type="time"
|
||||
step="1"
|
||||
name="startTime"
|
||||
value={form.startTime}
|
||||
onChange={(next) => {
|
||||
setForm((prev) => ({ ...prev, startTime: next }));
|
||||
}}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{hideOrgScheduleFields ? null : (
|
||||
@@ -430,29 +495,37 @@ export function RaceFormPage(): JSX.Element {
|
||||
<fieldset className="race-form__group">
|
||||
<legend className="race-form__legend">Результаты</legend>
|
||||
|
||||
<label className="race-form__field">
|
||||
<div className="race-form__field">
|
||||
<span className="race-form__label">Финишное время</span>
|
||||
<input
|
||||
className="race-form__input"
|
||||
type="text"
|
||||
name="finishTime"
|
||||
value={form.finishTime}
|
||||
onChange={handleChange}
|
||||
placeholder="1:45:30"
|
||||
/>
|
||||
</label>
|
||||
<div className="race-form__result-fields">
|
||||
<label className="race-form__result-field">
|
||||
<span className="race-form__result-field-label">Часы</span>
|
||||
<input className="race-form__input" type="number" name="finishHours" value={form.finishHours} onChange={handleChange} min="0" step="1" inputMode="numeric" />
|
||||
</label>
|
||||
<label className="race-form__result-field">
|
||||
<span className="race-form__result-field-label">Минуты</span>
|
||||
<input className="race-form__input" type="number" name="finishMinutes" value={form.finishMinutes} onChange={handleChange} min="0" max="59" step="1" inputMode="numeric" />
|
||||
</label>
|
||||
<label className="race-form__result-field">
|
||||
<span className="race-form__result-field-label">Секунды</span>
|
||||
<input className="race-form__input" type="number" name="finishSeconds" value={form.finishSeconds} onChange={handleChange} min="0" max="59" step="1" inputMode="numeric" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="race-form__field">
|
||||
<div className="race-form__field">
|
||||
<span className="race-form__label">Место на финише</span>
|
||||
<input
|
||||
className="race-form__input"
|
||||
type="text"
|
||||
name="finishPlace"
|
||||
value={form.finishPlace}
|
||||
onChange={handleChange}
|
||||
placeholder="12/340"
|
||||
/>
|
||||
</label>
|
||||
<div className="race-form__result-fields">
|
||||
<label className="race-form__result-field">
|
||||
<span className="race-form__result-field-label">Место</span>
|
||||
<input className="race-form__input" type="number" name="finishPlace" value={form.finishPlace} onChange={(event) => setForm((prev) => ({ ...prev, finishPlace: event.target.value, isFinishPlaceEdited: true }))} min="1" step="1" inputMode="numeric" />
|
||||
</label>
|
||||
<label className="race-form__result-field">
|
||||
<span className="race-form__result-field-label">Всего участников</span>
|
||||
<input className="race-form__input" type="number" name="finishPlaceTotal" value={form.finishPlaceTotal} onChange={(event) => setForm((prev) => ({ ...prev, finishPlaceTotal: event.target.value, isFinishPlaceEdited: true }))} min="1" step="1" inputMode="numeric" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -825,167 +825,24 @@ a {
|
||||
min-height: 5rem;
|
||||
}
|
||||
|
||||
.race-form__time-picker {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
.race-form__result-fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr));
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.race-form__time-picker__unit {
|
||||
.race-form__result-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.race-form__time-picker__label {
|
||||
.race-form__result-field-label {
|
||||
font-size: var(--font-size-caption);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.race-form__time-picker__select {
|
||||
min-width: 4.25rem;
|
||||
}
|
||||
|
||||
.race-form__time-picker__sep {
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 600;
|
||||
padding-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.date-picker {
|
||||
position: relative;
|
||||
width: min(100%, 22rem);
|
||||
}
|
||||
|
||||
.date-picker__control {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.date-picker__input {
|
||||
min-width: 0;
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
.date-picker__toggle {
|
||||
width: 3rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-left: none;
|
||||
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
|
||||
background: var(--color-bg-deep);
|
||||
color: #ffffff;
|
||||
font: inherit;
|
||||
font-weight: 900;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.date-picker__toggle:hover,
|
||||
.date-picker__toggle:focus-visible {
|
||||
background: var(--color-accent-strong);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.date-picker__popover {
|
||||
position: absolute;
|
||||
top: calc(100% + var(--space-2));
|
||||
left: 0;
|
||||
z-index: 12;
|
||||
width: min(20rem, calc(100vw - var(--space-8)));
|
||||
padding: var(--space-4);
|
||||
border: 1px solid rgba(17, 104, 216, 0.2);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
box-shadow: var(--shadow-card-lift);
|
||||
}
|
||||
|
||||
.date-picker__header {
|
||||
display: grid;
|
||||
grid-template-columns: 2.25rem minmax(0, 1fr) 2.25rem;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.date-picker__title {
|
||||
margin: 0;
|
||||
color: var(--color-bg-deep);
|
||||
font-weight: 900;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.date-picker__nav {
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(17, 104, 216, 0.08);
|
||||
color: var(--color-accent);
|
||||
font: inherit;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 900;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.date-picker__nav:hover,
|
||||
.date-picker__nav:focus-visible {
|
||||
background: var(--color-lime);
|
||||
color: #071927;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.date-picker__weekdays,
|
||||
.date-picker__cells {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.date-picker__weekdays {
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.date-picker__weekday {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.date-picker__cell,
|
||||
.date-picker__day {
|
||||
min-height: 2.25rem;
|
||||
}
|
||||
|
||||
.date-picker__day {
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
font-size: var(--font-size-caption);
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.date-picker__day:hover,
|
||||
.date-picker__day:focus-visible {
|
||||
background: #e8f1ff;
|
||||
border-color: rgba(17, 104, 216, 0.2);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.date-picker__day--today {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.date-picker__day--selected {
|
||||
background: var(--color-lime);
|
||||
color: #071927;
|
||||
}
|
||||
|
||||
.race-form__actions {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{"root":["./src/main.tsx","./src/api/index.ts","./src/app/router.tsx","./src/app/layouts/applayout.tsx","./src/components/index.ts","./src/features/index.ts","./src/lib/index.ts","./src/pages/dashboardpage.tsx","./src/pages/racespage.tsx"],"version":"5.9.3"}
|
||||
@@ -7,7 +7,9 @@
|
||||
"moduleResolution": "Bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"types": ["node"],
|
||||
"strict": true
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"tsBuildInfoFile": "./node_modules/.cache/tsconfig.node.tsbuildinfo"
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
2
frontend/vite.config.d.ts
vendored
2
frontend/vite.config.d.ts
vendored
@@ -1,2 +0,0 @@
|
||||
declare const _default: import("vite").UserConfig;
|
||||
export default _default;
|
||||
@@ -1,5 +0,0 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
export default defineConfig({
|
||||
plugins: [react()]
|
||||
});
|
||||
Reference in New Issue
Block a user