feat: add public user profiles

This commit is contained in:
Vakanaut
2026-07-12 16:21:53 +03:00
parent 69931e81a8
commit 103e3ca209
18 changed files with 286 additions and 13 deletions

View File

@@ -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",

View File

@@ -1,7 +1,7 @@
{
"name": "calendar-run-frontend",
"private": true,
"version": "0.8.0",
"version": "0.8.1",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -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 }),
});
}

View File

@@ -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";

View File

@@ -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
View 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;
}

View File

@@ -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: [

View File

@@ -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">

View 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>
);
}