feat: customize public profile visibility
Some checks failed
CI / build-and-test (pull_request) Has been cancelled

This commit is contained in:
Vakanaut
2026-07-12 16:57:43 +03:00
parent 103e3ca209
commit d4c72115d8
19 changed files with 245 additions and 68 deletions

View File

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

View File

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

View File

@@ -88,9 +88,13 @@ export async function revokeOtherSessions(): Promise<void> {
await requestJson<void>("/auth/sessions/revoke-others", { method: "POST" });
}
export async function updateProfileVisibility(isProfilePublic: boolean): Promise<void> {
export async function updateProfile(payload: {
profileUsername?: string | null;
isFutureRacesPublic?: boolean;
isCompletedRacesPublic?: boolean;
}): Promise<void> {
await requestJson<void>("/auth/profile", {
method: "PATCH",
body: JSON.stringify({ isProfilePublic }),
body: JSON.stringify(payload),
});
}

View File

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

View File

@@ -16,6 +16,6 @@ export {
resetPassword,
revokeOtherSessions,
revokeSession,
updateProfileVisibility,
updateProfile,
verifyEmail,
} from "./auth";

View File

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

View File

@@ -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<PublicRace[]> {
const response = await requestJson<unknown[]>(`/users/${userId}/races`, init);
export async function getPublicRaces(username: string, init?: RequestInit): Promise<PublicRace[]> {
const response = await requestJson<unknown[]>(`/users/${encodeURIComponent(username)}/races`, init);
if (!Array.isArray(response) || !response.every(isPublicRace)) {
throw new ApiError({ code: "unknown_error", message: "Некорректный формат данных от API." });
}

View File

@@ -20,7 +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 /> },
{ path: "users/:username", element: <PublicProfilePage /> },
{
element: <RequireAuth />,
children: [

View File

@@ -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<HTMLFormElement>): Promise<void> {
event.preventDefault();
setPasswordError("");
@@ -83,12 +93,19 @@ export function AccountPage(): JSX.Element {
}
}
async function handleProfileVisibilityChange(isProfilePublic: boolean): Promise<void> {
async function handleProfileSubmit(event: React.FormEvent<HTMLFormElement>): Promise<void> {
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<void> {
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 (
<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">
<form className="auth-form" aria-labelledby="profile-title" onSubmit={(event) => void handleProfileSubmit(event)}>
<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 ?? ""}`} />
<span className="auth-form__label">Имя пользователя</span>
<input
className="auth-form__input"
required
minLength={3}
maxLength={30}
pattern="[a-z0-9](?:[a-z0-9-]*[a-z0-9])?"
value={profileUsername}
onChange={(event) => setProfileUsername(event.target.value.toLowerCase())}
/>
</label>
<label className="auth-form__field">
<span>
<input
checked={user?.isProfilePublic ?? false}
checked={futureRacesPublic}
disabled={profileSaving}
type="checkbox"
onChange={(event) => void handleProfileVisibilityChange(event.target.checked)}
onChange={(event) => setFutureRacesPublic(event.target.checked)}
/>{" "}
Открыть страницу всем
Видимость будущих стартов
</span>
<span className="auth-form__label">Открытая страница показывает только календарь стартов, без личных заметок и результатов.</span>
</label>
<label className="auth-form__field">
<span>
<input
checked={completedRacesPublic}
disabled={profileSaving}
type="checkbox"
onChange={(event) => setCompletedRacesPublic(event.target.checked)}
/>{" "}
Видимость завершённых стартов и результатов
</span>
</label>
<div className="auth-form__field">
<span className="auth-form__label">Адрес личной страницы</span>
<div className="account__profile-link">
<input className="auth-form__input" readOnly value={profileUrl} placeholder="Сначала задайте имя пользователя" />
<button className="btn btn--secondary" disabled={!profileUrl} type="button" onClick={() => void copyProfileUrl()}>Скопировать</button>
</div>
</div>
<p className="page__subtitle">Открытая страница не показывает личные заметки и номера участников.</p>
{profileError ? <p className="page__subtitle page__subtitle--error" role="alert">{profileError}</p> : null}
</section>
{profileSuccess ? <p className="account__success" role="status">{profileSuccess}</p> : null}
<button className="btn" disabled={profileSaving} type="submit">{profileSaving ? "Сохраняем…" : "Сохранить настройки"}</button>
</form>
<form className="auth-form" onSubmit={(event) => void handlePasswordSubmit(event)}>
<h2 className="auth-form__title">Сменить пароль</h2>

View File

@@ -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<PublicRace[]>([]);
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 (
<section className="page page--race-day">
@@ -55,6 +55,8 @@ export function PublicProfilePage(): JSX.Element {
<span className="race-day__meta">
{formatRaceDate(race.date)} · {formatDistance(race.distanceKm)} ·{" "}
<span className={getRaceStatusClassName(race.status, race.date)}>{getRaceStatusLabel(race.status, race.date)}</span>
{race.finishTime ? ` · ${race.finishTime}` : ""}
{race.finishPlace ? ` · ${race.finishPlace}` : ""}
</span>
</div>
</li>

View File

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