diff --git a/backend/migrations/006_profile_usernames_and_visibility.sql b/backend/migrations/006_profile_usernames_and_visibility.sql new file mode 100644 index 0000000..225158b --- /dev/null +++ b/backend/migrations/006_profile_usernames_and_visibility.sql @@ -0,0 +1,12 @@ +ALTER TABLE users ADD COLUMN IF NOT EXISTS profile_username TEXT; +ALTER TABLE users ADD COLUMN IF NOT EXISTS is_future_races_public BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE users ADD COLUMN IF NOT EXISTS is_completed_races_public BOOLEAN NOT NULL DEFAULT FALSE; + +UPDATE users +SET is_future_races_public = is_profile_public, + is_completed_races_public = is_profile_public +WHERE is_profile_public = TRUE; + +CREATE UNIQUE INDEX IF NOT EXISTS users_profile_username_normalized_key + ON users (LOWER(profile_username)) + WHERE profile_username IS NOT NULL; diff --git a/backend/package-lock.json b/backend/package-lock.json index 9af7107..df262cc 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "calendar-run-backend", - "version": "1.5.1", + "version": "1.5.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "calendar-run-backend", - "version": "1.5.1", + "version": "1.5.2", "dependencies": { "argon2": "^0.44.0", "cookie-parser": "^1.4.7", diff --git a/backend/package.json b/backend/package.json index bc0c656..911ad6d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "calendar-run-backend", - "version": "1.5.1", + "version": "1.5.2", "private": true, "scripts": { "build": "tsc", diff --git a/backend/src/authMiddleware.ts b/backend/src/authMiddleware.ts index 6c2c206..60b1fed 100644 --- a/backend/src/authMiddleware.ts +++ b/backend/src/authMiddleware.ts @@ -10,7 +10,9 @@ declare global { id: string; email: string; emailVerifiedAt: string | null; - isProfilePublic: boolean; + profileUsername: string | null; + isFutureRacesPublic: boolean; + isCompletedRacesPublic: boolean; }; csrfTokenHash: string; sessionToken: string; diff --git a/backend/src/authService.ts b/backend/src/authService.ts index b7368ee..a9882ef 100644 --- a/backend/src/authService.ts +++ b/backend/src/authService.ts @@ -18,7 +18,9 @@ export interface AuthUser { id: string; email: string; emailVerifiedAt: string | null; - isProfilePublic: boolean; + profileUsername: string | null; + isFutureRacesPublic: boolean; + isCompletedRacesPublic: boolean; } interface UserRow { @@ -26,7 +28,9 @@ interface UserRow { email: string; password_hash: string; email_verified_at: Date | string | null; - is_profile_public: boolean; + profile_username: string | null; + is_future_races_public: boolean; + is_completed_races_public: boolean; } interface SessionRow { @@ -37,7 +41,9 @@ interface SessionRow { expires_at: Date | string; email: string; email_verified_at: Date | string | null; - is_profile_public: boolean; + profile_username: string | null; + is_future_races_public: boolean; + is_completed_races_public: boolean; } interface SessionListRow { @@ -68,7 +74,9 @@ function userFromRow(row: UserRow): AuthUser { id: row.id, email: row.email, emailVerifiedAt: toIso(row.email_verified_at), - isProfilePublic: row.is_profile_public, + profileUsername: row.profile_username, + isFutureRacesPublic: row.is_future_races_public, + isCompletedRacesPublic: row.is_completed_races_public, }; } @@ -85,7 +93,9 @@ function isUniqueViolation(error: unknown): boolean { export async function findUserByEmail(email: string): Promise { const normalized = normalizeEmail(email); const { rows } = await pool.query( - "SELECT id, email, password_hash, email_verified_at, is_profile_public FROM users WHERE LOWER(BTRIM(email)) = $1", + `SELECT id, email, password_hash, email_verified_at, profile_username, + is_future_races_public, is_completed_races_public + FROM users WHERE LOWER(BTRIM(email)) = $1`, [normalized], ); return rows[0] ?? null; @@ -146,7 +156,8 @@ export async function registerUser(email: string, password: string): Promise( `INSERT INTO users (email, password_hash) VALUES ($1, $2) - RETURNING id, email, password_hash, email_verified_at, is_profile_public`, + RETURNING id, email, password_hash, email_verified_at, profile_username, + is_future_races_public, is_completed_races_public`, [normalized, passwordHash], )); } catch (error) { @@ -185,7 +196,9 @@ export async function createSession(userId: string): Promise<{ sessionToken: str 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 is_profile_public FROM users WHERE id = $1) AS is_profile_public`, + (SELECT profile_username FROM users WHERE id = $1) AS profile_username, + (SELECT is_future_races_public FROM users WHERE id = $1) AS is_future_races_public, + (SELECT is_completed_races_public FROM users WHERE id = $1) AS is_completed_races_public`, [userId, sha256Hex(sessionToken), sha256Hex(csrfToken), expiresAt], ); const row = rows[0]; @@ -197,7 +210,9 @@ 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, + profileUsername: row.profile_username, + isFutureRacesPublic: row.is_future_races_public, + isCompletedRacesPublic: row.is_completed_races_public, }, }; } @@ -312,7 +327,8 @@ export async function rotateCsrf(sessionToken: string): Promise { export async function getSession(sessionToken: string): Promise<{ user: AuthUser; csrfTokenHash: string } | null> { const tokenHash = sha256Hex(sessionToken); const { rows } = await pool.query( - `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 + `SELECT s.id, s.user_id, s.token_hash, s.csrf_token_hash, s.expires_at, u.email, u.email_verified_at, + u.profile_username, u.is_future_races_public, u.is_completed_races_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()`, @@ -331,16 +347,38 @@ 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, + profileUsername: row.profile_username, + isFutureRacesPublic: row.is_future_races_public, + isCompletedRacesPublic: row.is_completed_races_public, }, csrfTokenHash: row.csrf_token_hash, }; } -export async function setProfileVisibility(userId: string, isProfilePublic: boolean): Promise { +export async function updateProfile( + userId: string, + profile: { + profileUsername?: string | null; + isFutureRacesPublic?: boolean; + isCompletedRacesPublic?: boolean; + }, +): Promise { await pool.query( - "UPDATE users SET is_profile_public = $2, updated_at = NOW() WHERE id = $1", - [userId, isProfilePublic], + `UPDATE users SET + profile_username = CASE WHEN $2 THEN $3 ELSE profile_username END, + is_future_races_public = CASE WHEN $4 THEN $5 ELSE is_future_races_public END, + is_completed_races_public = CASE WHEN $6 THEN $7 ELSE is_completed_races_public END, + updated_at = NOW() + WHERE id = $1`, + [ + userId, + profile.profileUsername !== undefined, + profile.profileUsername, + profile.isFutureRacesPublic !== undefined, + profile.isFutureRacesPublic, + profile.isCompletedRacesPublic !== undefined, + profile.isCompletedRacesPublic, + ], ); } diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 409e532..47925ef 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -13,7 +13,7 @@ import { revokeSessionById, rotateCsrf, revokeSession, - setProfileVisibility, + updateProfile, verifyEmailToken, } from "../authService"; import { clearSessionCookie, requireAuth, setSessionCookie } from "../authMiddleware"; @@ -80,7 +80,12 @@ const passwordChangeSchema = z.object({ }).strict(); const sessionIdSchema = z.string().uuid(); -const profileSchema = z.object({ isProfilePublic: z.boolean() }).strict(); +const profileUsernameSchema = z.string().trim().toLowerCase().min(3).max(30).regex(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/); +const profileSchema = z.object({ + profileUsername: profileUsernameSchema.nullable().optional(), + isFutureRacesPublic: z.boolean().optional(), + isCompletedRacesPublic: z.boolean().optional(), +}).strict().refine((profile) => Object.keys(profile).length > 0); function validationError(res: Response): void { res.status(400).json({ error: "validation_error", details: ["Invalid request body"] }); @@ -156,9 +161,13 @@ router.patch("/auth/profile", requireAuth, async (req: Request, res: Response, n return; } try { - await setProfileVisibility(req.auth!.user.id, parsed.data.isProfilePublic); - res.json({ isProfilePublic: parsed.data.isProfilePublic }); + await updateProfile(req.auth!.user.id, parsed.data); + res.json({ ok: true }); } catch (error) { + if (typeof error === "object" && error !== null && (error as { code?: string }).code === "23505") { + res.status(409).json({ error: "username_taken", details: ["Username is already in use"] }); + return; + } next(error); } }); diff --git a/backend/src/routes/users.ts b/backend/src/routes/users.ts index 86aeb69..f8bb2f1 100644 --- a/backend/src/routes/users.ts +++ b/backend/src/routes/users.ts @@ -10,22 +10,27 @@ interface PublicRaceRow { distance_km: string; status: "planned" | "registered" | "completed" | null; cover_image_url: string | null; + finish_time: string | null; + finish_place: 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); +router.get("/users/:username/races", async (req: Request, res: Response) => { + const parsed = z.string().trim().toLowerCase().min(3).max(30).regex(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/).safeParse(req.params.username); if (!parsed.success) { - res.status(400).json({ error: "validation_error", details: ["id: Must be a UUID"] }); + res.status(400).json({ error: "validation_error", details: ["username: Invalid username"] }); return; } try { const profile = await pool.query( - "SELECT id FROM users WHERE id = $1 AND is_profile_public = TRUE", + `SELECT id, is_future_races_public, is_completed_races_public + FROM users + WHERE LOWER(profile_username) = $1 + AND (is_future_races_public = TRUE OR is_completed_races_public = TRUE)`, [parsed.data], ); if (profile.rowCount === 0) { @@ -33,9 +38,12 @@ router.get("/users/:id/races", async (req: Request, res: Response) => { return; } const { rows } = await pool.query( - `SELECT race_date, title, distance_km, status, cover_image_url - FROM races WHERE owner_user_id = $1 ORDER BY race_date ASC`, - [parsed.data], + `SELECT race_date, title, distance_km, status, cover_image_url, finish_time, finish_place + FROM races + WHERE owner_user_id = $1 + AND ((status = 'completed' AND $2) OR (status IS DISTINCT FROM 'completed' AND $3)) + ORDER BY race_date ASC`, + [profile.rows[0].id, profile.rows[0].is_completed_races_public, profile.rows[0].is_future_races_public], ); res.json(rows.map((row) => ({ date: raceDate(row.race_date), @@ -43,9 +51,11 @@ router.get("/users/:id/races", async (req: Request, res: Response) => { distanceKm: Number(row.distance_km), status: row.status, coverImageUrl: row.cover_image_url, + finishTime: row.status === "completed" ? row.finish_time : null, + finishPlace: row.status === "completed" ? row.finish_place : null, }))); } catch (error) { - console.error("[GET /users/:id/races]", error); + console.error("[GET /users/:username/races]", error); res.status(503).json({ error: "database_unavailable" }); } }); diff --git a/backend/test/app.test.ts b/backend/test/app.test.ts index c80531e..efaa9c0 100644 --- a/backend/test/app.test.ts +++ b/backend/test/app.test.ts @@ -173,46 +173,67 @@ 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 () => { +test("public profile uses its username and separate visibility settings", 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", + slug: "2026-08-01-planned-race", date: "2026-08-01", - title: "Public Race", + title: "Planned Race", distanceKm: 10, - notes: "Private note", - bibNumber: "123", + }) + .expect(201); + await agent + .post("/api/races") + .set("X-CSRF-Token", user.body.csrfToken as string) + .send({ + slug: "2026-07-01-completed-race", + date: "2026-07-01", + title: "Completed Race", + distanceKm: 10, + status: "completed", finishTime: "00:40:00", + finishPlace: "12", + notes: "Private note", }) .expect(201); - await request(app).get(`/api/users/${userId}/races`).expect(404); + await request(app).get("/api/users/public-runner/races").expect(404); await agent .patch("/api/auth/profile") .set("X-CSRF-Token", user.body.csrfToken as string) - .send({ isProfilePublic: true }) + .send({ profileUsername: "public-runner", isFutureRacesPublic: true, isCompletedRacesPublic: false }) .expect(200); - const publicRaces = await request(app).get(`/api/users/${userId}/races`).expect(200); - assert.deepEqual(publicRaces.body, [{ + const futureRaces = await request(app).get("/api/users/public-runner/races").expect(200); + assert.deepEqual(futureRaces.body, [{ date: "2026-08-01", - title: "Public Race", + title: "Planned Race", distanceKm: 10, status: null, coverImageUrl: null, + finishTime: null, + finishPlace: null, }]); await agent .patch("/api/auth/profile") .set("X-CSRF-Token", user.body.csrfToken as string) - .send({ isProfilePublic: false }) + .send({ isFutureRacesPublic: false, isCompletedRacesPublic: true }) .expect(200); - await request(app).get(`/api/users/${userId}/races`).expect(404); + const completedRaces = await request(app).get("/api/users/public-runner/races").expect(200); + assert.deepEqual(completedRaces.body, [{ + date: "2026-07-01", + title: "Completed Race", + distanceKm: 10, + status: "completed", + coverImageUrl: null, + finishTime: "00:40:00", + finishPlace: "12", + }]); }); test("login uses generic response for missing user and wrong password", async () => { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f0898e6..8ec30d6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "calendar-run-frontend", - "version": "0.8.1", + "version": "0.8.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "calendar-run-frontend", - "version": "0.8.1", + "version": "0.8.2", "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/frontend/package.json b/frontend/package.json index f38703e..b0bad8c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "calendar-run-frontend", "private": true, - "version": "0.8.1", + "version": "0.8.2", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 4ed1a1e..c89e050 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -88,9 +88,13 @@ export async function revokeOtherSessions(): Promise { await requestJson("/auth/sessions/revoke-others", { method: "POST" }); } -export async function updateProfileVisibility(isProfilePublic: boolean): Promise { +export async function updateProfile(payload: { + profileUsername?: string | null; + isFutureRacesPublic?: boolean; + isCompletedRacesPublic?: boolean; +}): Promise { await requestJson("/auth/profile", { method: "PATCH", - body: JSON.stringify({ isProfilePublic }), + body: JSON.stringify(payload), }); } diff --git a/frontend/src/api/errors.ts b/frontend/src/api/errors.ts index bac287f..74b5200 100644 --- a/frontend/src/api/errors.ts +++ b/frontend/src/api/errors.ts @@ -3,6 +3,7 @@ export type ApiErrorCode = | "not_found" | "database_unavailable" | "conflict" + | "username_taken" | "unauthorized" | "email_not_verified" | "csrf_error" @@ -42,6 +43,7 @@ function normalizeApiCode(value: string | undefined): ApiErrorCode { value === "not_found" || value === "database_unavailable" || value === "conflict" || + value === "username_taken" || value === "unauthorized" || value === "email_not_verified" || value === "csrf_error" || @@ -110,6 +112,8 @@ export function getApiErrorMessage(code: ApiErrorCode): string { return "Сервис временно недоступен. Попробуйте позже."; case "conflict": return "Запись с таким идентификатором уже существует."; + case "username_taken": + return "Это имя пользователя уже занято."; case "unauthorized": return "Нужно войти в аккаунт."; case "email_not_verified": diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 7cd0674..36a9912 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -16,6 +16,6 @@ export { resetPassword, revokeOtherSessions, revokeSession, - updateProfileVisibility, + updateProfile, verifyEmail, } from "./auth"; diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 03c34bb..1b0a23a 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -48,7 +48,9 @@ export interface AuthUser { id: string; email: string; emailVerifiedAt: string | null; - isProfilePublic: boolean; + profileUsername: string | null; + isFutureRacesPublic: boolean; + isCompletedRacesPublic: boolean; } export interface PublicRace { @@ -57,6 +59,8 @@ export interface PublicRace { distanceKm: number; status: RaceStatus | null; coverImageUrl: string | null; + finishTime: string | null; + finishPlace: string | null; } export interface AuthSession { diff --git a/frontend/src/api/users.ts b/frontend/src/api/users.ts index c4a042e..d01e964 100644 --- a/frontend/src/api/users.ts +++ b/frontend/src/api/users.ts @@ -9,12 +9,14 @@ function isPublicRace(value: unknown): value is PublicRace { 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") + (race?.coverImageUrl === null || typeof race?.coverImageUrl === "string") && + (race?.finishTime === null || typeof race?.finishTime === "string") && + (race?.finishPlace === null || typeof race?.finishPlace === "string") ); } -export async function getPublicRaces(userId: string, init?: RequestInit): Promise { - const response = await requestJson(`/users/${userId}/races`, init); +export async function getPublicRaces(username: string, init?: RequestInit): Promise { + const response = await requestJson(`/users/${encodeURIComponent(username)}/races`, init); if (!Array.isArray(response) || !response.every(isPublicRace)) { throw new ApiError({ code: "unknown_error", message: "Некорректный формат данных от API." }); } diff --git a/frontend/src/app/router.tsx b/frontend/src/app/router.tsx index d625de6..da41770 100644 --- a/frontend/src/app/router.tsx +++ b/frontend/src/app/router.tsx @@ -20,7 +20,7 @@ export const appRouter = createBrowserRouter([ { path: "verify-email", element: }, { path: "forgot-password", element: }, { path: "reset-password", element: }, - { path: "users/:userId", element: }, + { path: "users/:username", element: }, { element: , children: [ diff --git a/frontend/src/pages/AccountPage.tsx b/frontend/src/pages/AccountPage.tsx index 7bb3156..8144005 100644 --- a/frontend/src/pages/AccountPage.tsx +++ b/frontend/src/pages/AccountPage.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useState } from "react"; -import { ApiError, changePassword, getSessions, revokeOtherSessions, revokeSession, updateProfileVisibility } from "../api"; +import { ApiError, changePassword, getSessions, revokeOtherSessions, revokeSession, updateProfile } from "../api"; import type { AuthSession } from "../api"; import { useAuth } from "../app/auth/AuthContext"; @@ -19,7 +19,11 @@ export function AccountPage(): JSX.Element { const [passwordSuccess, setPasswordSuccess] = useState(""); const [passwordSaving, setPasswordSaving] = useState(false); const [profileError, setProfileError] = useState(""); + const [profileSuccess, setProfileSuccess] = useState(""); const [profileSaving, setProfileSaving] = useState(false); + const [profileUsername, setProfileUsername] = useState(user?.profileUsername ?? ""); + const [futureRacesPublic, setFutureRacesPublic] = useState(user?.isFutureRacesPublic ?? false); + const [completedRacesPublic, setCompletedRacesPublic] = useState(user?.isCompletedRacesPublic ?? false); const loadSessions = useCallback(async () => { setSessionsLoading(true); @@ -37,6 +41,12 @@ export function AccountPage(): JSX.Element { void loadSessions(); }, [loadSessions]); + useEffect(() => { + setProfileUsername(user?.profileUsername ?? ""); + setFutureRacesPublic(user?.isFutureRacesPublic ?? false); + setCompletedRacesPublic(user?.isCompletedRacesPublic ?? false); + }, [user?.profileUsername, user?.isFutureRacesPublic, user?.isCompletedRacesPublic]); + async function handlePasswordSubmit(event: React.FormEvent): Promise { event.preventDefault(); setPasswordError(""); @@ -83,12 +93,19 @@ export function AccountPage(): JSX.Element { } } - async function handleProfileVisibilityChange(isProfilePublic: boolean): Promise { + async function handleProfileSubmit(event: React.FormEvent): Promise { + event.preventDefault(); setProfileSaving(true); setProfileError(""); + setProfileSuccess(""); try { - await updateProfileVisibility(isProfilePublic); + await updateProfile({ + profileUsername: profileUsername.trim(), + isFutureRacesPublic: futureRacesPublic, + isCompletedRacesPublic: completedRacesPublic, + }); await refresh(); + setProfileSuccess("Настройки личной страницы сохранены."); } catch (error) { setProfileError(error instanceof ApiError ? error.message : "Не удалось обновить видимость страницы."); } finally { @@ -96,31 +113,74 @@ export function AccountPage(): JSX.Element { } } + async function copyProfileUrl(): Promise { + if (!user?.profileUsername) { + return; + } + setProfileError(""); + try { + await navigator.clipboard.writeText(`${window.location.origin}/users/${user.profileUsername}`); + setProfileSuccess("Адрес скопирован."); + } catch { + setProfileError("Не удалось скопировать адрес."); + } + } + + const profileUrl = user?.profileUsername ? `${window.location.origin}/users/${user.profileUsername}` : ""; + return (

Аккаунт

{user?.email} — email подтверждён.

-
+
void handleProfileSubmit(event)}>

Публичная страница

+ +
+ Адрес личной страницы +
+ + +
+
+

Открытая страница не показывает личные заметки и номера участников.

{profileError ?

{profileError}

: null} -
+ {profileSuccess ?

{profileSuccess}

: null} + +
void handlePasswordSubmit(event)}>

Сменить пароль

diff --git a/frontend/src/pages/PublicProfilePage.tsx b/frontend/src/pages/PublicProfilePage.tsx index 1029a72..16f4813 100644 --- a/frontend/src/pages/PublicProfilePage.tsx +++ b/frontend/src/pages/PublicProfilePage.tsx @@ -5,19 +5,19 @@ import type { PublicRace } from "../api"; import { formatDistance, formatRaceDate, getRaceStatusClassName, getRaceStatusLabel } from "../lib"; export function PublicProfilePage(): JSX.Element { - const { userId } = useParams<{ userId: string }>(); + const { username } = useParams<{ username: string }>(); const [races, setRaces] = useState([]); const [error, setError] = useState(""); const [loading, setLoading] = useState(true); useEffect(() => { const ac = new AbortController(); - if (!userId) { + if (!username) { setError("Страница пользователя не найдена."); setLoading(false); return () => ac.abort(); } - void getPublicRaces(userId, { signal: ac.signal }) + void getPublicRaces(username, { signal: ac.signal }) .then((items) => { if (!ac.signal.aborted) { setRaces(items); @@ -34,7 +34,7 @@ export function PublicProfilePage(): JSX.Element { } }); return () => ac.abort(); - }, [userId]); + }, [username]); return (
@@ -55,6 +55,8 @@ export function PublicProfilePage(): JSX.Element { {formatRaceDate(race.date)} · {formatDistance(race.distanceKm)} ·{" "} {getRaceStatusLabel(race.status, race.date)} + {race.finishTime ? ` · ${race.finishTime}` : ""} + {race.finishPlace ? ` · ${race.finishPlace}` : ""} diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 4d9eccd..abf1b28 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -206,6 +206,15 @@ a { font-size: var(--font-size-caption); } +.account__profile-link { + display: flex; + gap: var(--space-2); +} + +.account__profile-link .auth-form__input { + min-width: 0; +} + .dashboard-grid { margin-top: var(--space-6); display: grid;