forked from admin/runners-calendar
Merge pull request 'feat: add public user profiles' (#41) from feature/public-profiles into main
Reviewed-on: admin/runners-calendar#41
This commit is contained in:
1
backend/migrations/005_public_profiles.sql
Normal file
1
backend/migrations/005_public_profiles.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS is_profile_public BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
4
backend/package-lock.json
generated
4
backend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "calendar-run-backend",
|
||||
"version": "1.5.0",
|
||||
"version": "1.5.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "calendar-run-backend",
|
||||
"version": "1.5.0",
|
||||
"version": "1.5.1",
|
||||
"dependencies": {
|
||||
"argon2": "^0.44.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "calendar-run-backend",
|
||||
"version": "1.5.0",
|
||||
"version": "1.5.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
|
||||
@@ -7,6 +7,7 @@ import { loadAuth, requireCsrf } from "./authMiddleware";
|
||||
import authRouter from "./routes/auth";
|
||||
import healthRouter from "./routes/health";
|
||||
import racesRouter from "./routes/races";
|
||||
import usersRouter from "./routes/users";
|
||||
|
||||
const TURNSTILE_ORIGIN = "https://challenges.cloudflare.com";
|
||||
|
||||
@@ -54,6 +55,7 @@ export function createApp(): express.Express {
|
||||
|
||||
app.use("/api", healthRouter);
|
||||
app.use("/api", authRouter);
|
||||
app.use("/api", usersRouter);
|
||||
app.use("/api", racesRouter);
|
||||
|
||||
app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
|
||||
|
||||
@@ -10,6 +10,7 @@ declare global {
|
||||
id: string;
|
||||
email: string;
|
||||
emailVerifiedAt: string | null;
|
||||
isProfilePublic: boolean;
|
||||
};
|
||||
csrfTokenHash: string;
|
||||
sessionToken: string;
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface AuthUser {
|
||||
id: string;
|
||||
email: string;
|
||||
emailVerifiedAt: string | null;
|
||||
isProfilePublic: boolean;
|
||||
}
|
||||
|
||||
interface UserRow {
|
||||
@@ -25,6 +26,7 @@ interface UserRow {
|
||||
email: string;
|
||||
password_hash: string;
|
||||
email_verified_at: Date | string | null;
|
||||
is_profile_public: boolean;
|
||||
}
|
||||
|
||||
interface SessionRow {
|
||||
@@ -35,6 +37,7 @@ interface SessionRow {
|
||||
expires_at: Date | string;
|
||||
email: string;
|
||||
email_verified_at: Date | string | null;
|
||||
is_profile_public: boolean;
|
||||
}
|
||||
|
||||
interface SessionListRow {
|
||||
@@ -65,6 +68,7 @@ function userFromRow(row: UserRow): AuthUser {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
emailVerifiedAt: toIso(row.email_verified_at),
|
||||
isProfilePublic: row.is_profile_public,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -81,7 +85,7 @@ function isUniqueViolation(error: unknown): boolean {
|
||||
export async function findUserByEmail(email: string): Promise<UserRow | null> {
|
||||
const normalized = normalizeEmail(email);
|
||||
const { rows } = await pool.query<UserRow>(
|
||||
"SELECT id, email, password_hash, email_verified_at FROM users WHERE LOWER(BTRIM(email)) = $1",
|
||||
"SELECT id, email, password_hash, email_verified_at, is_profile_public FROM users WHERE LOWER(BTRIM(email)) = $1",
|
||||
[normalized],
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
@@ -142,7 +146,7 @@ export async function registerUser(email: string, password: string): Promise<voi
|
||||
({ rows } = await client.query<UserRow>(
|
||||
`INSERT INTO users (email, password_hash)
|
||||
VALUES ($1, $2)
|
||||
RETURNING id, email, password_hash, email_verified_at`,
|
||||
RETURNING id, email, password_hash, email_verified_at, is_profile_public`,
|
||||
[normalized, passwordHash],
|
||||
));
|
||||
} catch (error) {
|
||||
@@ -180,7 +184,8 @@ export async function createSession(userId: string): Promise<{ sessionToken: str
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, user_id, token_hash, csrf_token_hash, expires_at,
|
||||
(SELECT email FROM users WHERE id = $1) AS email,
|
||||
(SELECT email_verified_at FROM users WHERE id = $1) AS email_verified_at`,
|
||||
(SELECT email_verified_at FROM users WHERE id = $1) AS email_verified_at,
|
||||
(SELECT is_profile_public FROM users WHERE id = $1) AS is_profile_public`,
|
||||
[userId, sha256Hex(sessionToken), sha256Hex(csrfToken), expiresAt],
|
||||
);
|
||||
const row = rows[0];
|
||||
@@ -192,6 +197,7 @@ export async function createSession(userId: string): Promise<{ sessionToken: str
|
||||
id: row.user_id,
|
||||
email: row.email,
|
||||
emailVerifiedAt: toIso(row.email_verified_at),
|
||||
isProfilePublic: row.is_profile_public,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -306,7 +312,7 @@ export async function rotateCsrf(sessionToken: string): Promise<string | null> {
|
||||
export async function getSession(sessionToken: string): Promise<{ user: AuthUser; csrfTokenHash: string } | null> {
|
||||
const tokenHash = sha256Hex(sessionToken);
|
||||
const { rows } = await pool.query<SessionRow>(
|
||||
`SELECT s.id, s.user_id, s.token_hash, s.csrf_token_hash, s.expires_at, u.email, u.email_verified_at
|
||||
`SELECT s.id, s.user_id, s.token_hash, s.csrf_token_hash, s.expires_at, u.email, u.email_verified_at, u.is_profile_public
|
||||
FROM sessions s
|
||||
JOIN users u ON u.id = s.user_id
|
||||
WHERE s.token_hash = $1 AND s.revoked_at IS NULL AND s.expires_at > NOW()`,
|
||||
@@ -325,11 +331,19 @@ export async function getSession(sessionToken: string): Promise<{ user: AuthUser
|
||||
id: row.user_id,
|
||||
email: row.email,
|
||||
emailVerifiedAt: toIso(row.email_verified_at),
|
||||
isProfilePublic: row.is_profile_public,
|
||||
},
|
||||
csrfTokenHash: row.csrf_token_hash,
|
||||
};
|
||||
}
|
||||
|
||||
export async function setProfileVisibility(userId: string, isProfilePublic: boolean): Promise<void> {
|
||||
await pool.query(
|
||||
"UPDATE users SET is_profile_public = $2, updated_at = NOW() WHERE id = $1",
|
||||
[userId, isProfilePublic],
|
||||
);
|
||||
}
|
||||
|
||||
export function csrfMatches(hash: string, token: string): boolean {
|
||||
return timingSafeEqualHex(hash, sha256Hex(token));
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
revokeSessionById,
|
||||
rotateCsrf,
|
||||
revokeSession,
|
||||
setProfileVisibility,
|
||||
verifyEmailToken,
|
||||
} from "../authService";
|
||||
import { clearSessionCookie, requireAuth, setSessionCookie } from "../authMiddleware";
|
||||
@@ -79,6 +80,7 @@ const passwordChangeSchema = z.object({
|
||||
}).strict();
|
||||
|
||||
const sessionIdSchema = z.string().uuid();
|
||||
const profileSchema = z.object({ isProfilePublic: z.boolean() }).strict();
|
||||
|
||||
function validationError(res: Response): void {
|
||||
res.status(400).json({ error: "validation_error", details: ["Invalid request body"] });
|
||||
@@ -147,6 +149,20 @@ router.get("/auth/me", async (req: Request, res: Response, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.patch("/auth/profile", requireAuth, async (req: Request, res: Response, next) => {
|
||||
const parsed = profileSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
validationError(res);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await setProfileVisibility(req.auth!.user.id, parsed.data.isProfilePublic);
|
||||
res.json({ isProfilePublic: parsed.data.isProfilePublic });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/auth/sessions", requireAuth, async (req: Request, res: Response, next) => {
|
||||
try {
|
||||
res.json({ sessions: await listActiveSessions(req.auth!.user.id, req.auth!.sessionToken) });
|
||||
|
||||
53
backend/src/routes/users.ts
Normal file
53
backend/src/routes/users.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Request, Response, Router } from "express";
|
||||
import { z } from "zod";
|
||||
import { pool } from "../db";
|
||||
|
||||
const router: Router = Router();
|
||||
|
||||
interface PublicRaceRow {
|
||||
race_date: string | Date;
|
||||
title: string;
|
||||
distance_km: string;
|
||||
status: "planned" | "registered" | "completed" | null;
|
||||
cover_image_url: string | null;
|
||||
}
|
||||
|
||||
function raceDate(value: string | Date): string {
|
||||
return typeof value === "string" ? value.slice(0, 10) : value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
router.get("/users/:id/races", async (req: Request, res: Response) => {
|
||||
const parsed = z.string().uuid().safeParse(req.params.id);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: "validation_error", details: ["id: Must be a UUID"] });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const profile = await pool.query(
|
||||
"SELECT id FROM users WHERE id = $1 AND is_profile_public = TRUE",
|
||||
[parsed.data],
|
||||
);
|
||||
if (profile.rowCount === 0) {
|
||||
res.status(404).json({ error: "not_found", details: ["Profile not found"] });
|
||||
return;
|
||||
}
|
||||
const { rows } = await pool.query<PublicRaceRow>(
|
||||
`SELECT race_date, title, distance_km, status, cover_image_url
|
||||
FROM races WHERE owner_user_id = $1 ORDER BY race_date ASC`,
|
||||
[parsed.data],
|
||||
);
|
||||
res.json(rows.map((row) => ({
|
||||
date: raceDate(row.race_date),
|
||||
title: row.title,
|
||||
distanceKm: Number(row.distance_km),
|
||||
status: row.status,
|
||||
coverImageUrl: row.cover_image_url,
|
||||
})));
|
||||
} catch (error) {
|
||||
console.error("[GET /users/:id/races]", error);
|
||||
res.status(503).json({ error: "database_unavailable" });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -173,6 +173,48 @@ test("GET /api/races requires authentication", async () => {
|
||||
assert.equal(res.body.error, "unauthorized");
|
||||
});
|
||||
|
||||
test("public profile exposes only its owner's calendar after they enable it", async () => {
|
||||
const { agent } = await authAgent();
|
||||
const user = await agent.get("/api/auth/me").expect(200);
|
||||
const userId = user.body.user.id as string;
|
||||
await agent
|
||||
.post("/api/races")
|
||||
.set("X-CSRF-Token", user.body.csrfToken as string)
|
||||
.send({
|
||||
slug: "2026-08-01-public-race",
|
||||
date: "2026-08-01",
|
||||
title: "Public Race",
|
||||
distanceKm: 10,
|
||||
notes: "Private note",
|
||||
bibNumber: "123",
|
||||
finishTime: "00:40:00",
|
||||
})
|
||||
.expect(201);
|
||||
|
||||
await request(app).get(`/api/users/${userId}/races`).expect(404);
|
||||
await agent
|
||||
.patch("/api/auth/profile")
|
||||
.set("X-CSRF-Token", user.body.csrfToken as string)
|
||||
.send({ isProfilePublic: true })
|
||||
.expect(200);
|
||||
|
||||
const publicRaces = await request(app).get(`/api/users/${userId}/races`).expect(200);
|
||||
assert.deepEqual(publicRaces.body, [{
|
||||
date: "2026-08-01",
|
||||
title: "Public Race",
|
||||
distanceKm: 10,
|
||||
status: null,
|
||||
coverImageUrl: null,
|
||||
}]);
|
||||
|
||||
await agent
|
||||
.patch("/api/auth/profile")
|
||||
.set("X-CSRF-Token", user.body.csrfToken as string)
|
||||
.send({ isProfilePublic: false })
|
||||
.expect(200);
|
||||
await request(app).get(`/api/users/${userId}/races`).expect(404);
|
||||
});
|
||||
|
||||
test("login uses generic response for missing user and wrong password", async () => {
|
||||
const password = "correct horse battery staple";
|
||||
await createVerifiedUser("generic@example.com", password);
|
||||
|
||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "calendar-run-frontend",
|
||||
"version": "0.8.0",
|
||||
"version": "0.8.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "calendar-run-frontend",
|
||||
"version": "0.8.0",
|
||||
"version": "0.8.1",
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "calendar-run-frontend",
|
||||
"private": true,
|
||||
"version": "0.8.0",
|
||||
"version": "0.8.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -87,3 +87,10 @@ export async function revokeSession(sessionId: string): Promise<void> {
|
||||
export async function revokeOtherSessions(): Promise<void> {
|
||||
await requestJson<void>("/auth/sessions/revoke-others", { method: "POST" });
|
||||
}
|
||||
|
||||
export async function updateProfileVisibility(isProfilePublic: boolean): Promise<void> {
|
||||
await requestJson<void>("/auth/profile", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ isProfilePublic }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
export type { AuthSession, AuthUser, CreateRacePayload, Race, RacesQuery, RaceStatus, UpdateRacePayload } from "./types";
|
||||
export type { AuthSession, AuthUser, CreateRacePayload, PublicRace, Race, RacesQuery, RaceStatus, UpdateRacePayload } from "./types";
|
||||
export { ApiError, getApiErrorMessage } from "./errors";
|
||||
export type { BackendMetaResponse } from "./health";
|
||||
export { getBackendMeta } from "./health";
|
||||
export { getRaceById, getRaces, createRace, updateRace, deleteRace } from "./races";
|
||||
export { getPublicRaces } from "./users";
|
||||
export {
|
||||
forgotPassword,
|
||||
changePassword,
|
||||
@@ -15,5 +16,6 @@ export {
|
||||
resetPassword,
|
||||
revokeOtherSessions,
|
||||
revokeSession,
|
||||
updateProfileVisibility,
|
||||
verifyEmail,
|
||||
} from "./auth";
|
||||
|
||||
@@ -48,6 +48,15 @@ export interface AuthUser {
|
||||
id: string;
|
||||
email: string;
|
||||
emailVerifiedAt: string | null;
|
||||
isProfilePublic: boolean;
|
||||
}
|
||||
|
||||
export interface PublicRace {
|
||||
date: string;
|
||||
title: string;
|
||||
distanceKm: number;
|
||||
status: RaceStatus | null;
|
||||
coverImageUrl: string | null;
|
||||
}
|
||||
|
||||
export interface AuthSession {
|
||||
|
||||
22
frontend/src/api/users.ts
Normal file
22
frontend/src/api/users.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { ApiError } from "./errors";
|
||||
import { requestJson } from "./http";
|
||||
import type { PublicRace } from "./types";
|
||||
|
||||
function isPublicRace(value: unknown): value is PublicRace {
|
||||
const race = value as Partial<PublicRace>;
|
||||
return (
|
||||
typeof race?.date === "string" &&
|
||||
typeof race?.title === "string" &&
|
||||
typeof race?.distanceKm === "number" &&
|
||||
(race?.status === null || race?.status === "planned" || race?.status === "registered" || race?.status === "completed") &&
|
||||
(race?.coverImageUrl === null || typeof race?.coverImageUrl === "string")
|
||||
);
|
||||
}
|
||||
|
||||
export async function getPublicRaces(userId: string, init?: RequestInit): Promise<PublicRace[]> {
|
||||
const response = await requestJson<unknown[]>(`/users/${userId}/races`, init);
|
||||
if (!Array.isArray(response) || !response.every(isPublicRace)) {
|
||||
throw new ApiError({ code: "unknown_error", message: "Некорректный формат данных от API." });
|
||||
}
|
||||
return response;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { RaceDetailsPage } from "../pages/RaceDetailsPage";
|
||||
import { RaceFormPage } from "../pages/RaceFormPage";
|
||||
import { RaceDayPage } from "../pages/RaceDayPage";
|
||||
import { AccountPage } from "../pages/AccountPage";
|
||||
import { PublicProfilePage } from "../pages/PublicProfilePage";
|
||||
import { ForgotPasswordPage, LoginPage, RegisterPage, ResetPasswordPage, VerifyEmailPage } from "../pages/AuthPages";
|
||||
import { RequireAuth } from "./auth/RequireAuth";
|
||||
|
||||
@@ -19,6 +20,7 @@ export const appRouter = createBrowserRouter([
|
||||
{ path: "verify-email", element: <VerifyEmailPage /> },
|
||||
{ path: "forgot-password", element: <ForgotPasswordPage /> },
|
||||
{ path: "reset-password", element: <ResetPasswordPage /> },
|
||||
{ path: "users/:userId", element: <PublicProfilePage /> },
|
||||
{
|
||||
element: <RequireAuth />,
|
||||
children: [
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { ApiError, changePassword, getSessions, revokeOtherSessions, revokeSession } from "../api";
|
||||
import { ApiError, changePassword, getSessions, revokeOtherSessions, revokeSession, updateProfileVisibility } from "../api";
|
||||
import type { AuthSession } from "../api";
|
||||
import { useAuth } from "../app/auth/AuthContext";
|
||||
|
||||
@@ -8,7 +8,7 @@ function formatDate(value: string): string {
|
||||
}
|
||||
|
||||
export function AccountPage(): JSX.Element {
|
||||
const { user, logout } = useAuth();
|
||||
const { user, logout, refresh } = useAuth();
|
||||
const [sessions, setSessions] = useState<AuthSession[]>([]);
|
||||
const [sessionsError, setSessionsError] = useState("");
|
||||
const [sessionsLoading, setSessionsLoading] = useState(true);
|
||||
@@ -18,6 +18,8 @@ export function AccountPage(): JSX.Element {
|
||||
const [passwordError, setPasswordError] = useState("");
|
||||
const [passwordSuccess, setPasswordSuccess] = useState("");
|
||||
const [passwordSaving, setPasswordSaving] = useState(false);
|
||||
const [profileError, setProfileError] = useState("");
|
||||
const [profileSaving, setProfileSaving] = useState(false);
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
setSessionsLoading(true);
|
||||
@@ -81,11 +83,45 @@ export function AccountPage(): JSX.Element {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleProfileVisibilityChange(isProfilePublic: boolean): Promise<void> {
|
||||
setProfileSaving(true);
|
||||
setProfileError("");
|
||||
try {
|
||||
await updateProfileVisibility(isProfilePublic);
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
setProfileError(error instanceof ApiError ? error.message : "Не удалось обновить видимость страницы.");
|
||||
} finally {
|
||||
setProfileSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page page--auth">
|
||||
<h1 className="page__title">Аккаунт</h1>
|
||||
<p className="page__subtitle">{user?.email} — email подтверждён.</p>
|
||||
|
||||
<section className="auth-form" aria-labelledby="profile-title">
|
||||
<h2 className="auth-form__title" id="profile-title">Публичная страница</h2>
|
||||
<label className="auth-form__field">
|
||||
<span className="auth-form__label">Ссылка на страницу</span>
|
||||
<input className="auth-form__input" readOnly value={`${window.location.origin}/users/${user?.id ?? ""}`} />
|
||||
</label>
|
||||
<label className="auth-form__field">
|
||||
<span>
|
||||
<input
|
||||
checked={user?.isProfilePublic ?? false}
|
||||
disabled={profileSaving}
|
||||
type="checkbox"
|
||||
onChange={(event) => void handleProfileVisibilityChange(event.target.checked)}
|
||||
/>{" "}
|
||||
Открыть страницу всем
|
||||
</span>
|
||||
<span className="auth-form__label">Открытая страница показывает только календарь стартов, без личных заметок и результатов.</span>
|
||||
</label>
|
||||
{profileError ? <p className="page__subtitle page__subtitle--error" role="alert">{profileError}</p> : null}
|
||||
</section>
|
||||
|
||||
<form className="auth-form" onSubmit={(event) => void handlePasswordSubmit(event)}>
|
||||
<h2 className="auth-form__title">Сменить пароль</h2>
|
||||
<label className="auth-form__field">
|
||||
|
||||
66
frontend/src/pages/PublicProfilePage.tsx
Normal file
66
frontend/src/pages/PublicProfilePage.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { ApiError, getPublicRaces } from "../api";
|
||||
import type { PublicRace } from "../api";
|
||||
import { formatDistance, formatRaceDate, getRaceStatusClassName, getRaceStatusLabel } from "../lib";
|
||||
|
||||
export function PublicProfilePage(): JSX.Element {
|
||||
const { userId } = useParams<{ userId: string }>();
|
||||
const [races, setRaces] = useState<PublicRace[]>([]);
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const ac = new AbortController();
|
||||
if (!userId) {
|
||||
setError("Страница пользователя не найдена.");
|
||||
setLoading(false);
|
||||
return () => ac.abort();
|
||||
}
|
||||
void getPublicRaces(userId, { signal: ac.signal })
|
||||
.then((items) => {
|
||||
if (!ac.signal.aborted) {
|
||||
setRaces(items);
|
||||
}
|
||||
})
|
||||
.catch((cause) => {
|
||||
if (!ac.signal.aborted) {
|
||||
setError(cause instanceof ApiError ? cause.message : "Не удалось загрузить страницу пользователя.");
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!ac.signal.aborted) {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
return () => ac.abort();
|
||||
}, [userId]);
|
||||
|
||||
return (
|
||||
<section className="page page--race-day">
|
||||
<section className="race-day-hero">
|
||||
<p className="race-day-hero__eyebrow">Публичная страница</p>
|
||||
<h1 className="page__title">Календарь стартов</h1>
|
||||
<p className="page__subtitle">Опубликованные старты пользователя.</p>
|
||||
</section>
|
||||
{loading ? <p className="page__subtitle" aria-busy="true">Загружаем…</p> : null}
|
||||
{error ? <p className="page__subtitle page__subtitle--error" role="alert">{error}</p> : null}
|
||||
{!loading && !error && races.length === 0 ? <p className="page__subtitle">Пока нет опубликованных стартов.</p> : null}
|
||||
{!loading && !error && races.length > 0 ? (
|
||||
<ul className="race-day__list">
|
||||
{races.map((race, index) => (
|
||||
<li className="race-day__item" key={`${race.date}-${race.title}-${index}`}>
|
||||
<div className="race-day__body">
|
||||
<span className="race-day__title">{race.title}</span>
|
||||
<span className="race-day__meta">
|
||||
{formatRaceDate(race.date)} · {formatDistance(race.distanceKm)} ·{" "}
|
||||
<span className={getRaceStatusClassName(race.status, race.date)}>{getRaceStatusLabel(race.status, race.date)}</span>
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user