Files
gerbeur/src/pages/UserPublicProfile.tsx
khannurien c8c7b05c25
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 41s
v3: fixed inconsistent page titles
2026-06-28 06:51:42 +00:00

1724 lines
55 KiB
TypeScript

import React, {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { Link, useNavigate, useParams } from "react-router";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { API_URL, DEFAULT_PAGE_SIZE, VALIDATION } from "../config/api.ts";
import type {
Dump,
InviteTreeEntry,
PaginatedData,
PublicUser,
Role,
} from "../model.ts";
import {
deserializeAuthResponse,
deserializeDump,
deserializeInviteTreeEntry,
deserializePublicUser,
hydrateDump,
hydratePlaylist,
parseAPIResponse,
type RawDump,
type RawInviteTreeEntry,
type RawPublicUser,
type UpdateUserRequest,
} from "../model.ts";
import { Avatar } from "../components/Avatar.tsx";
import { DumpCard } from "../components/DumpCard.tsx";
import { PlaylistCard } from "../components/PlaylistCard.tsx";
import { NewPlaylistForm } from "../components/NewPlaylistForm.tsx";
import { PageShell } from "../components/PageShell.tsx";
import { PageError } from "../components/PageError.tsx";
import { useAuth } from "../hooks/useAuth.ts";
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
import { useTheme } from "../hooks/useTheme.ts";
import { useDefaultFeedTab } from "../hooks/useDefaultFeedTab.ts";
import { useWS } from "../hooks/useWS.ts";
import { useDumpListSync } from "../hooks/useDumpListSync.ts";
import { useFading } from "../hooks/useFading.ts";
import { usePlaylistListSync } from "../hooks/usePlaylistListSync.ts";
import { usePositionAwareSync } from "../hooks/usePositionAwareSync.ts";
import type { Playlist, RawPlaylist } from "../model.ts";
import { deserializePlaylist } from "../model.ts";
import { useFeedCache } from "../hooks/useFeedCache.ts";
import { DumpCreateModal } from "../components/DumpCreateModal.tsx";
import { FollowUserButton } from "../components/FollowButton.tsx";
import { ErrorCard } from "../components/ErrorCard.tsx";
import { friendlyFetchError } from "../utils/apiError.ts";
import { can } from "../utils/permissions.ts";
import { TextEditor } from "../components/TextEditor.tsx";
import { Markdown } from "../components/Markdown.tsx";
import { ChangePasswordModal } from "../components/ChangePasswordModal.tsx";
import { TabBar } from "../components/TabBar.tsx";
import { useTabParam } from "../hooks/useTabParam.ts";
import { Controller } from "react-hook-form";
import {
expectOk,
FormError,
FormProvider,
SubmitButton,
useApiForm,
} from "../components/form/index.ts";
function InviteButton() {
const { authFetch } = useAuth();
const [inviteUrl, setInviteUrl] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [error, setError] = useState<string | null>(null);
async function generate() {
try {
const res = await authFetch(`${API_URL}/api/invites`, { method: "POST" });
const body = await res.json();
if (body.success) {
const url =
`${globalThis.location.origin}/register?token=${body.data.token}`;
setInviteUrl(url);
} else {
setError(t`Failed to generate invite`);
}
} catch {
setError(t`Failed to generate invite`);
}
}
async function copy() {
if (!inviteUrl) return;
await navigator.clipboard.writeText(inviteUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
if (inviteUrl) {
return (
<div className="invite-result">
<span className="invite-url">{inviteUrl}</span>
<button type="button" className="invite-copy-btn" onClick={copy}>
{copied ? t`Copied!` : t`Copy`}
</button>
</div>
);
}
return (
<div className="invite-generate">
<button type="button" className="invite-btn" onClick={generate}>
<Trans>+ Invite someone</Trans>
</button>
{error && (
<ErrorCard title={t`Failed to generate invite`} message={error} />
)}
</div>
);
}
interface PaginatedList<T> {
items: T[];
hasMore: boolean;
page: number;
loadingMore: boolean;
}
function initialList<T>(items: T[], hasMore: boolean): PaginatedList<T> {
return { items, hasMore, page: 1, loadingMore: false };
}
type ProfileState =
| { status: "loading" }
| { status: "error"; error: string }
| {
status: "loaded";
user: PublicUser;
dumps: PaginatedList<Dump>;
votes: PaginatedList<Dump>;
playlists: PaginatedList<Playlist>;
};
type FollowedState =
| null
| { status: "loading" }
| { status: "error"; error: string }
| {
status: "loaded";
users: PaginatedList<PublicUser>;
playlists: PaginatedList<Playlist>;
};
type InviteTreeState =
| null
| { status: "loading" }
| { status: "error"; error: string }
| { status: "loaded"; entries: InviteTreeEntry[] };
type InviteTreeNode = InviteTreeEntry & { children: InviteTreeNode[] };
const PROFILE_TABS = [
"dumps",
"playlists",
"followed",
"invitees",
"settings",
"manage",
] as const;
type ProfileTab = (typeof PROFILE_TABS)[number];
function buildInviteTree(
entries: InviteTreeEntry[],
parentId: string,
): InviteTreeNode[] {
return entries
.filter((e) => e.invitedById === parentId)
.map((e) => ({ ...e, children: buildInviteTree(entries, e.id) }));
}
export function UserPublicProfile() {
const { username } = useParams();
const navigate = useNavigate();
const { user: me, authFetch, login, logout, token } = useAuth();
const { style, colorScheme, setStyle, setColorScheme } = useTheme();
const [defaultFeedTab, setDefaultFeedTab] = useDefaultFeedTab();
const {
voteCounts,
myVotes,
lastVoteEvent,
lastDumpEvent,
lastPlaylistEvent,
lastUserEvent,
castVote,
removeVote,
} = useWS();
const { cached: cachedDumps, saveState: saveDumps } = useFeedCache<Dump>(
`feed:profile-dumps:${username ?? ""}`,
hydrateDump,
);
const { cached: cachedVotes, saveState: saveVotes } = useFeedCache<Dump>(
`feed:profile-votes:${username ?? ""}`,
hydrateDump,
);
const { cached: cachedPlaylists, saveState: savePlaylists } = useFeedCache<
Playlist
>(
`feed:profile-playlists:${username ?? ""}`,
hydratePlaylist,
);
const [state, setState] = useState<ProfileState>({ status: "loading" });
useDocumentTitle(
state.status === "loaded"
? state.user.username
: state.status === "error"
? null
: undefined,
);
const profileUserId = state.status === "loaded" ? state.user.id : null;
const isOwnProfile = me?.id === profileUserId;
// Active tab is driven by the `/~/<tab>` URL path (linkable / refresh-safe).
// `settings` is own-profile only; `manage` requires the user:manage permission
// on someone else's profile. Invalid tabs fall back to "dumps".
const canManageUsers = can(me, "user:manage");
const availableTabs: ProfileTab[] = PROFILE_TABS.filter((t) => {
if (t === "settings") return isOwnProfile;
if (t === "manage") return !isOwnProfile && canManageUsers;
return true;
});
const [tab, setTab] = useTabParam<ProfileTab>(availableTabs, "dumps");
const setDumps = useCallback((fn: (prev: Dump[]) => Dump[]) => {
setState((s) =>
s.status !== "loaded"
? s
: { ...s, dumps: { ...s.dumps, items: fn(s.dumps.items) } }
);
}, []);
const dumpItems = state.status === "loaded" ? state.dumps.items : [];
usePositionAwareSync(
dumpItems,
setDumps,
lastDumpEvent,
(d) => d.isPrivate,
(d) => !d.isPrivate && d.userId === profileUserId,
);
useDumpListSync(setDumps, {
ownerId: profileUserId ?? undefined,
isOwner: isOwnProfile,
skipReinsert: true,
});
// Dump IDs removed due to vote withdrawal — must not be re-inserted on
// a future dump_updated event (that would only be for private→public transitions).
const withdrawnVoteIdsRef = useRef<Set<string>>(new Set());
const setVotes = useCallback((fn: (prev: Dump[]) => Dump[]) => {
setState((s) =>
s.status !== "loaded"
? s
: { ...s, votes: { ...s.votes, items: fn(s.votes.items) } }
);
}, []);
const voteItems = state.status === "loaded" ? state.votes.items : [];
usePositionAwareSync(
voteItems,
setVotes,
lastDumpEvent,
(d) => d.isPrivate,
(d) => !d.isPrivate && !withdrawnVoteIdsRef.current.has(d.id),
);
useDumpListSync(setVotes, { skipReinsert: true });
const setPlaylists = useCallback((fn: (prev: Playlist[]) => Playlist[]) => {
setState((s) =>
s.status !== "loaded"
? s
: { ...s, playlists: { ...s.playlists, items: fn(s.playlists.items) } }
);
}, []);
const playlistItems = state.status === "loaded" ? state.playlists.items : [];
const lastPlaylistItem = lastPlaylistEvent?.type === "updated"
? (lastPlaylistEvent.playlist ?? null)
: null;
usePositionAwareSync(
playlistItems,
setPlaylists,
lastPlaylistItem,
(p) => !p.isPublic,
(p) => p.isPublic && p.userId === profileUserId,
);
usePlaylistListSync(setPlaylists, {
isOwner: isOwnProfile,
ownerId: profileUserId ?? undefined,
skipReinsert: true,
});
// Update profile user when they edit their own profile
useEffect(() => {
if (!lastUserEvent) return;
const { user } = lastUserEvent;
// Subscribing to the WS-pushed user-update stream — setState here
// mirrors external state into local state, no synchronous alternative.
// eslint-disable-next-line react-hooks/set-state-in-effect
setState((s) => {
if (s.status !== "loaded" || s.user.id !== user.id) return s;
return { ...s, user };
});
}, [lastUserEvent]);
const [uploading, setUploading] = useState(false);
const [avatarError, setAvatarError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [descEditing, setDescEditing] = useState(false);
const [emailEditing, setEmailEditing] = useState(false);
const prevMyVotesRef = useRef<Set<string> | null>(null);
const [changePasswordOpen, setChangePasswordOpen] = useState(false);
const [followedState, setFollowedState] = useState<FollowedState>(null);
const [inviteTreeState, setInviteTreeState] = useState<InviteTreeState>(null);
const inviteTreeNodes = useMemo(() => {
if (!profileUserId || inviteTreeState?.status !== "loaded") return [];
return buildInviteTree(inviteTreeState.entries, profileUserId);
}, [inviteTreeState, profileUserId]);
const [prevUsername, setPrevUsername] = useState(username);
if (prevUsername !== username) {
setPrevUsername(username);
setState({ status: "loading" });
setFollowedState(null);
setInviteTreeState(null);
}
// Refs can't be written during render — reset in an effect instead, which
// still runs (and clears the ref) before the vote-diffing effect below.
useEffect(() => {
prevMyVotesRef.current = null;
}, [username]);
useEffect(() => {
if (!username) return;
const controller = new AbortController();
const allCached = cachedDumps && cachedVotes && cachedPlaylists;
if (allCached) {
// Only fetch the user object (lightweight, always fresh)
fetch(`${API_URL}/api/users/${username}`, { signal: controller.signal })
.then((r) => r.json())
.then((body) => {
if (!body.success) throw new Error("User not found");
const profileUser = deserializePublicUser(body.data);
setState({
status: "loaded",
user: profileUser,
dumps: {
items: cachedDumps.items,
hasMore: cachedDumps.hasMore,
page: cachedDumps.page,
loadingMore: false,
},
votes: {
items: cachedVotes.items,
hasMore: cachedVotes.hasMore,
page: cachedVotes.page,
loadingMore: false,
},
playlists: {
items: cachedPlaylists.items,
hasMore: cachedPlaylists.hasMore,
page: cachedPlaylists.page,
loadingMore: false,
},
});
})
.catch((err) => {
if (err.name === "AbortError") return;
setState({ status: "error", error: friendlyFetchError(err) });
});
return () => controller.abort();
}
(async () => {
try {
const authHeaders: HeadersInit = token
? { Authorization: `Bearer ${token}` }
: {};
const [userRes, dumpsRes, votesRes, playlistsRes] = await Promise.all([
fetch(`${API_URL}/api/users/${username}`, {
signal: controller.signal,
}),
fetch(
`${API_URL}/api/users/${username}/dumps?page=1&limit=${DEFAULT_PAGE_SIZE}`,
{ headers: authHeaders, signal: controller.signal },
),
fetch(
`${API_URL}/api/users/${username}/votes?page=1&limit=${DEFAULT_PAGE_SIZE}`,
{ headers: authHeaders, signal: controller.signal },
),
fetch(
`${API_URL}/api/users/${username}/playlists?page=1&limit=${DEFAULT_PAGE_SIZE}`,
{ headers: authHeaders, signal: controller.signal },
),
]);
if (!userRes.ok) {
throw new Error(
userRes.status === 404
? "User not found"
: `HTTP ${userRes.status}`,
);
}
const [userBody, dumpsBody, votesBody, playlistsBody] = await Promise
.all([
userRes.json(),
dumpsRes.json(),
votesRes.json(),
playlistsRes.json(),
]);
const votesData: PaginatedData<RawDump> = votesBody.success
? votesBody.data
: { items: [], total: 0, hasMore: false };
const playlistsData: PaginatedData<RawPlaylist> = playlistsBody.success
? playlistsBody.data
: { items: [], total: 0, hasMore: false };
const dumpsData: PaginatedData<RawDump> = dumpsBody.success
? dumpsBody.data
: { items: [], total: 0, hasMore: false };
const profileUser = deserializePublicUser(userBody.data);
const voteItems = votesData.items.map(deserializeDump);
setState({
status: "loaded",
user: profileUser,
dumps: initialList(
dumpsData.items.map(deserializeDump),
dumpsData.hasMore,
),
votes: initialList(voteItems, votesData.hasMore),
playlists: initialList(
playlistsData.items.map(deserializePlaylist),
playlistsData.hasMore,
),
});
} catch (err) {
if ((err as Error).name === "AbortError") return;
setState({
status: "error",
error: friendlyFetchError(err),
});
}
})();
return () => controller.abort();
}, [username, cachedDumps, cachedVotes, cachedPlaylists, token]);
// Own profile: prepend dumps newly voted by the user to the preview list
useEffect(() => {
if (!profileUserId || me?.id !== profileUserId) return;
if (prevMyVotesRef.current === null) {
prevMyVotesRef.current = new Set(myVotes);
return;
}
const prev = prevMyVotesRef.current;
setState((s) => {
if (s.status !== "loaded") return s;
const voteIds = new Set(s.votes.items.map((d) => d.id));
const toAdd = s.dumps.items.filter((d) =>
myVotes.has(d.id) && !prev.has(d.id) && !voteIds.has(d.id)
);
if (toAdd.length === 0) return s;
return {
...s,
votes: { ...s.votes, items: [...toAdd, ...s.votes.items] },
};
});
prevMyVotesRef.current = new Set(myVotes);
}, [myVotes, me, profileUserId]);
// Real-time upvoted list sync via WS vote events
useEffect(() => {
if (!lastVoteEvent || !profileUserId) return;
const { dumpId, voterId, action } = lastVoteEvent;
if (voterId !== profileUserId) return;
if (action === "remove") {
// Keep dump in state.votes.items as a ghost — UpvotedDumpList drives
// its own votedIds + fading state and will animate the removal.
withdrawnVoteIdsRef.current.add(dumpId);
} else {
withdrawnVoteIdsRef.current.delete(dumpId);
fetch(`${API_URL}/api/dumps/${dumpId}`)
.then((r) => r.json())
.then((body) => {
if (!body.success) return;
const dump = deserializeDump(body.data);
setState((s) => {
if (s.status !== "loaded") return s;
const idx = s.votes.items.findIndex((d) => d.id === dumpId);
if (idx !== -1) {
// Ghost re-voted: update in-place.
const items = [...s.votes.items];
items[idx] = dump;
return { ...s, votes: { ...s.votes, items } };
}
// First-time vote: prepend.
return {
...s,
votes: { ...s.votes, items: [dump, ...s.votes.items] },
};
});
})
.catch(() => {});
}
}, [lastVoteEvent, profileUserId]);
// Save scroll position + loaded state to sessionStorage on scroll
useEffect(() => {
if (state.status !== "loaded") return;
let timer: ReturnType<typeof setTimeout>;
const onScroll = () => {
clearTimeout(timer);
timer = setTimeout(() => {
if (state.status !== "loaded") return;
const y = globalThis.scrollY;
saveDumps(state.dumps.items, state.dumps.page, state.dumps.hasMore, y);
saveVotes(state.votes.items, state.votes.page, state.votes.hasMore, y);
savePlaylists(
state.playlists.items,
state.playlists.page,
state.playlists.hasMore,
y,
);
}, 100);
};
globalThis.addEventListener("scroll", onScroll, { passive: true });
return () => {
globalThis.removeEventListener("scroll", onScroll);
clearTimeout(timer);
};
}, [state, saveDumps, saveVotes, savePlaylists]);
// Keep the playlists cache current whenever the list changes (e.g. via WS),
// so a page refresh restores the up-to-date list rather than a stale snapshot.
const playlistFeed = state.status === "loaded" ? state.playlists : null;
useEffect(() => {
if (!playlistFeed) return;
savePlaylists(
playlistFeed.items,
playlistFeed.page,
playlistFeed.hasMore,
globalThis.scrollY,
);
}, [playlistFeed, savePlaylists]);
// Lazy-load followed users + playlists when the Followed tab is first opened
useEffect(() => {
if (
tab !== "followed" || followedState !== null || state.status !== "loaded"
) {
return;
}
queueMicrotask(() => setFollowedState({ status: "loading" }));
const controller = new AbortController();
Promise.all([
fetch(
`${API_URL}/api/users/${username}/followed-users?page=1&limit=${DEFAULT_PAGE_SIZE}`,
{ signal: controller.signal },
),
fetch(
`${API_URL}/api/users/${username}/followed-playlists?page=1&limit=${DEFAULT_PAGE_SIZE}`,
{ signal: controller.signal },
),
])
.then(([ur, pr]) => Promise.all([ur.json(), pr.json()]))
.then(([ub, pb]) => {
const usersData = ub.success
? ub.data as PaginatedData<RawPublicUser>
: { items: [], hasMore: false };
const playlistsData = pb.success
? pb.data as PaginatedData<RawPlaylist>
: { items: [], hasMore: false };
setFollowedState({
status: "loaded",
users: initialList(
usersData.items.map(deserializePublicUser),
usersData.hasMore,
),
playlists: initialList(
playlistsData.items.map(deserializePlaylist),
playlistsData.hasMore,
),
});
})
.catch((err) => {
if (err.name === "AbortError") return;
setFollowedState({ status: "error", error: friendlyFetchError(err) });
});
return () => controller.abort();
// followedState intentionally omitted: it's a read-once guard, not a trigger.
// Including it would abort the in-flight fetch when state changes to "loading".
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tab, state.status, username]);
// Lazy-load invite tree when the Invitees tab is first opened
useEffect(() => {
if (
tab !== "invitees" || inviteTreeState !== null ||
state.status !== "loaded"
) {
return;
}
queueMicrotask(() => setInviteTreeState({ status: "loading" }));
const controller = new AbortController();
fetch(
`${API_URL}/api/users/${username}/invitees`,
{ signal: controller.signal },
)
.then((r) => r.json())
.then((body) => {
const entries: InviteTreeEntry[] = body.success
? (body.data as RawInviteTreeEntry[]).map(deserializeInviteTreeEntry)
: [];
setInviteTreeState({ status: "loaded", entries });
})
.catch((err) => {
if (err.name === "AbortError") return;
setInviteTreeState({ status: "error", error: friendlyFetchError(err) });
});
return () => controller.abort();
// inviteTreeState intentionally omitted — same read-once guard pattern.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tab, state.status, username]);
// Restore scroll position after cache restoration
const scrollRestored = useRef(false);
useLayoutEffect(() => {
if (cachedDumps?.scrollY == null || scrollRestored.current) return;
if (state.status === "loaded") {
globalThis.scrollTo(0, cachedDumps.scrollY);
scrollRestored.current = true;
}
}, [state.status, cachedDumps]);
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || state.status !== "loaded") return;
setAvatarError(null);
setUploading(true);
try {
const formData = new FormData();
formData.append("file", file);
const res = await authFetch(`${API_URL}/api/avatars/me`, {
method: "POST",
body: formData,
});
const body = parseAPIResponse<RawPublicUser>(await res.json());
if (!body.success) {
setAvatarError(body.error.message);
return;
}
const storedRaw = localStorage.getItem("authResponse");
if (storedRaw) {
login({
...deserializeAuthResponse(JSON.parse(storedRaw)),
user: deserializePublicUser(body.data),
});
}
setState((prev) =>
prev.status === "loaded"
? { ...prev, user: deserializePublicUser(body.data) }
: prev
);
} catch {
setAvatarError(t`Upload failed`);
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
};
const handleEmailSaved = (email: string) => {
const storedRaw = localStorage.getItem("authResponse");
if (storedRaw) {
const prev = deserializeAuthResponse(JSON.parse(storedRaw));
login({ ...prev, user: { ...prev.user, email } });
}
setEmailEditing(false);
};
const handleDescSaved = (description: string | undefined) => {
setState((s) =>
s.status === "loaded"
? { ...s, user: { ...s.user, description } }
: s
);
setDescEditing(false);
};
const handleRoleSaved = (role: Role) => {
setState((s) =>
s.status === "loaded" ? { ...s, user: { ...s.user, role } } : s
);
};
if (state.status === "loading") {
return (
<PageShell>
<p className="page-loading">
<Trans>Loading profile</Trans>
</p>
</PageShell>
);
}
if (state.status === "error") {
return (
<PageError
message={state.error}
actions={
<>
<button
className="btn-border"
type="button"
onClick={() => navigate("/")}
>
<Trans> Back</Trans>
</button>
{me && (
<button className="btn-border" type="button" onClick={logout}>
<Trans>Log out</Trans>
</button>
)}
</>
}
/>
);
}
const { user: profileUser, dumps, votes, playlists } = state;
return (
<PageShell>
<div className="profile-header">
<div className="profile-avatar-wrapper">
<Avatar
userId={profileUser.id}
username={profileUser.username}
hasAvatar={!!profileUser.avatarMime}
size={128}
version={profileUser.updatedAt?.getTime()}
/>
{isOwnProfile && (
<label className="avatar-change-overlay" title={t`Change avatar`}>
{uploading ? "…" : "✎"}
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
onChange={handleAvatarUpload}
disabled={uploading}
style={{ display: "none" }}
/>
</label>
)}
</div>
<div className="profile-info">
<div className="profile-info-scroll">
<h1 className="profile-username">{profileUser.username}</h1>
{profileUser.invitedByUsername
? (
<p className="profile-invited-by">
<Trans>invited by</Trans>{" "}
<Link
to={`/users/${profileUser.invitedByUsername}`}
className="profile-invited-by-link"
>
@{profileUser.invitedByUsername}
</Link>
</p>
)
: (
<p className="profile-invited-by profile-invited-by--founding">
O.G.
</p>
)}
{isOwnProfile && (
emailEditing
? (
<EmailEditor
initialEmail={me?.email ?? ""}
onCancel={() => setEmailEditing(false)}
onSaved={handleEmailSaved}
/>
)
: (
<p
className="profile-email-display"
onClick={() => setEmailEditing(true)}
title="Edit email"
>
{me?.email ?? t`Add email…`}
<span className="profile-description-edit-btn" aria-hidden>
</span>
</p>
)
)}
{avatarError && (
<ErrorCard
title={t`Failed to update avatar`}
message={avatarError}
/>
)}
{!isOwnProfile && (
<FollowUserButton
targetUserId={profileUser.id}
targetUsername={profileUser.username}
/>
)}
{isOwnProfile && (
<div className="profile-own-actions">
<InviteButton />
<button type="button" className="btn-border" onClick={logout}>
<Trans>Log out</Trans>
</button>
</div>
)}
</div>
</div>
</div>
{(profileUser.description || isOwnProfile) && (
<div className="profile-description">
{descEditing
? (
<DescriptionEditor
initialDescription={profileUser.description ?? ""}
onCancel={() => setDescEditing(false)}
onSaved={handleDescSaved}
/>
)
: (
<div
className={`profile-description-view${
isOwnProfile ? " profile-description-view--editable" : ""
}`}
onClick={isOwnProfile
? () => setDescEditing(true)
: undefined}
>
{profileUser.description
? (
<Markdown className="profile-description-text">
{profileUser.description}
</Markdown>
)
: (
<div className="profile-description-empty">
<Trans>Who am I?</Trans>
</div>
)}
{isOwnProfile && (
<span className="profile-description-edit-btn" aria-hidden>
</span>
)}
</div>
)}
</div>
)}
<TabBar
tabs={[
{ key: "dumps", label: <Trans>Dumps</Trans> },
{ key: "playlists", label: <Trans>Playlists</Trans> },
{ key: "followed", label: <Trans>Followed</Trans> },
{ key: "invitees", label: <Trans>Invitees</Trans> },
...(isOwnProfile
? [{ key: "settings" as const, label: <Trans>Settings</Trans> }]
: []),
...(!isOwnProfile && canManageUsers
? [{ key: "manage" as const, label: <Trans>Manage</Trans> }]
: []),
]}
activeTab={tab}
onChange={(key) => setTab(key)}
className="profile-tabs-scroller"
innerClassName="profile-tabs"
/>
{tab === "dumps" && (
<div className="profile-columns">
<DumpList
title={t`Dumps (${dumps.items.length}${dumps.hasMore ? "+" : ""})`}
dumps={dumps.items}
voteCounts={voteCounts}
myVotes={myVotes}
canVote={!!me}
castVote={castVote}
removeVote={removeVote}
isOwnProfile={isOwnProfile}
viewAllHref={`/users/${profileUser.username}/dumps`}
/>
<UpvotedDumpList
title={t`Upvoted (${votes.items.length}${
votes.hasMore ? "+" : ""
})`}
dumps={votes.items}
profileUserId={profileUserId}
isOwnProfile={isOwnProfile}
voteCounts={voteCounts}
myVotes={myVotes}
canVote={!!me}
castVote={castVote}
removeVote={removeVote}
viewAllHref={`/users/${profileUser.username}/upvoted`}
/>
</div>
)}
{tab === "playlists" && (
<section className="profile-section" id="playlists">
<div className="profile-section-header">
<h2 className="profile-section-title">
<Trans>
Playlists ({playlists.items.length}
{playlists.hasMore ? "+" : ""})
</Trans>
</h2>
{isOwnProfile && (
<NewPlaylistForm
onCreated={(p) =>
setState((s) => {
if (s.status !== "loaded") return s;
if (
s.playlists.items.some((pl) =>
pl.id === p.id
)
) {
return s;
}
return {
...s,
playlists: {
...s.playlists,
items: [p, ...s.playlists.items],
},
};
})}
/>
)}
</div>
{playlists.items.length === 0
? (
<p className="empty-state">
<Trans>No playlists yet.</Trans>
</p>
)
: (
<ul className="dump-feed">
{playlists.items.map((p) => (
<PlaylistCard
key={p.id}
playlist={p}
isOwner={isOwnProfile}
/>
))}
</ul>
)}
{playlists.items.length > 0 && (
<Link
to={`/users/${profileUser.username}/playlists`}
className="profile-view-all"
>
<Trans>View all </Trans>
</Link>
)}
</section>
)}
{tab === "followed" && (
<div className="profile-columns">
<section className="profile-section">
<h2 className="profile-section-title">
<Trans>Following</Trans>
</h2>
{followedState === null || followedState.status === "loading"
? (
<p className="index-status">
<Trans>Loading</Trans>
</p>
)
: followedState.status === "error"
? (
<ErrorCard
title={t`Failed to load`}
message={followedState.error}
/>
)
: followedState.users.items.length === 0
? (
<p className="empty-state">
<Trans>Not following anyone yet.</Trans>
</p>
)
: (
<>
<ul className="followed-user-list">
{followedState.users.items.map((u) => (
<FollowedUserCard key={u.id} user={u} />
))}
</ul>
{followedState.users.hasMore && (
<Link
to={`/users/${profileUser.username}/following`}
className="profile-view-all"
>
<Trans>View all </Trans>
</Link>
)}
</>
)}
</section>
<section className="profile-section">
<h2 className="profile-section-title">
<Trans>Followed playlists</Trans>
</h2>
{followedState === null || followedState.status === "loading"
? (
<p className="index-status">
<Trans>Loading</Trans>
</p>
)
: followedState.status === "error"
? (
<ErrorCard
title={t`Failed to load`}
message={followedState.error}
/>
)
: followedState.playlists.items.length === 0
? (
<p className="empty-state">
<Trans>No followed playlists yet.</Trans>
</p>
)
: (
<>
<ul className="dump-feed">
{followedState.playlists.items.map((p) => (
<PlaylistCard key={p.id} playlist={p} />
))}
</ul>
{followedState.playlists.hasMore && (
<Link
to={`/users/${profileUser.username}/playlists`}
className="profile-view-all"
>
<Trans>View all </Trans>
</Link>
)}
</>
)}
</section>
</div>
)}
{tab === "invitees" && (
<section className="profile-section">
<h2 className="profile-section-title">
<Trans>Invitees</Trans>
</h2>
{inviteTreeState === null || inviteTreeState.status === "loading"
? (
<p className="index-status">
<Trans>Loading</Trans>
</p>
)
: inviteTreeState.status === "error"
? (
<ErrorCard
title={t`Failed to load`}
message={inviteTreeState.error}
/>
)
: inviteTreeState.entries.length === 0
? (
<p className="empty-state invite-tree-empty">
<Trans>No invitees yet.</Trans>
</p>
)
: <InviteTreeList nodes={inviteTreeNodes} />}
</section>
)}
{changePasswordOpen && (
<ChangePasswordModal onClose={() => setChangePasswordOpen(false)} />
)}
{tab === "settings" && isOwnProfile && (
<>
<section className="profile-section">
<h2 className="profile-section-title">
<Trans>Account</Trans>
</h2>
<div className="profile-appearance-grid">
<div className="profile-appearance-row">
<span className="profile-appearance-label">
<Trans>Password</Trans>
</span>
<button
type="button"
className="btn btn--ghost"
onClick={() => setChangePasswordOpen(true)}
>
<Trans>Change password</Trans>
</button>
</div>
</div>
</section>
<section className="profile-section">
<h2 className="profile-section-title">
<Trans>Appearance</Trans>
</h2>
<div className="profile-appearance-grid">
<div className="profile-appearance-row">
<span className="profile-appearance-label">
<Trans>Style</Trans>
</span>
<div className="visibility-toggle">
<button
type="button"
className={style === "smooth" ? "active" : ""}
onClick={() => setStyle("smooth")}
>
Smooth
</button>
<button
type="button"
className={style === "brutalist" ? "active" : ""}
onClick={() => setStyle("brutalist")}
>
Brutalist
</button>
<button
type="button"
className={style === "geocities" ? "active" : ""}
onClick={() => setStyle("geocities")}
>
GeoCities
</button>
<button
type="button"
className={style === "nyt" ? "active" : ""}
onClick={() => setStyle("nyt")}
>
Times
</button>
</div>
</div>
<div className="profile-appearance-row">
<span className="profile-appearance-label">
<Trans>Color scheme</Trans>
</span>
<div className="visibility-toggle">
<button
type="button"
className={colorScheme === "auto" ? "active" : ""}
onClick={() => setColorScheme("auto")}
>
<Trans>Auto</Trans>
</button>
<button
type="button"
className={colorScheme === "light" ? "active" : ""}
onClick={() => setColorScheme("light")}
>
<Trans>Light</Trans>
</button>
<button
type="button"
className={colorScheme === "dark" ? "active" : ""}
onClick={() => setColorScheme("dark")}
>
<Trans>Dark</Trans>
</button>
</div>
</div>
</div>
</section>
<section className="profile-section">
<h2 className="profile-section-title">
<Trans>Feeds</Trans>
</h2>
<div className="profile-appearance-grid">
<div className="profile-appearance-row">
<span className="profile-appearance-label">
<Trans>Default tab</Trans>
</span>
<div className="visibility-toggle">
<button
type="button"
className={defaultFeedTab === "hot" ? "active" : ""}
onClick={() => setDefaultFeedTab("hot")}
>
<Trans>Hot</Trans>
</button>
<button
type="button"
className={defaultFeedTab === "new" ? "active" : ""}
onClick={() => setDefaultFeedTab("new")}
>
<Trans>New</Trans>
</button>
<button
type="button"
className={defaultFeedTab === "journal" ? "active" : ""}
onClick={() => setDefaultFeedTab("journal")}
>
<Trans>Journal</Trans>
</button>
<button
type="button"
className={defaultFeedTab === "followed" ? "active" : ""}
onClick={() => setDefaultFeedTab("followed")}
>
<Trans>Followed</Trans>
</button>
</div>
</div>
</div>
</section>
</>
)}
{tab === "manage" && !isOwnProfile && canManageUsers && (
<section className="profile-section">
<h2 className="profile-section-title">
<Trans>User management</Trans>
</h2>
<div className="profile-appearance-grid">
<RoleSwitcher
userId={profileUser.id}
currentRole={profileUser.role}
onSaved={handleRoleSaved}
/>
</div>
</section>
)}
</PageShell>
);
}
// ── Plain dump list ──────────────────────────────────────────────────────────
function DumpList(
{
title,
dumps,
voteCounts,
myVotes,
canVote,
castVote,
removeVote,
isOwnProfile,
viewAllHref,
}: {
title: string;
dumps: Dump[];
voteCounts: Record<string, number>;
myVotes: Set<string>;
canVote: boolean;
castVote: (id: string) => void;
removeVote: (id: string) => void;
isOwnProfile?: boolean;
viewAllHref: string;
},
) {
const [createModalOpen, setCreateModalOpen] = useState(false);
return (
<section className="profile-section">
<div className="profile-section-header">
<h2 className="profile-section-title">{title}</h2>
{isOwnProfile && (
<button
type="button"
className="new-item-toggle"
onClick={() => setCreateModalOpen(true)}
>
+<span className="btn-new-label">
<Trans>New dump</Trans>
</span>
</button>
)}
</div>
{createModalOpen && (
<DumpCreateModal onClose={() => setCreateModalOpen(false)} />
)}
{dumps.length === 0
? (
<p className="empty-state">
<Trans>Nothing here yet.</Trans>
</p>
)
: (
<ul className="dump-feed">
{dumps.map((dump) => (
<DumpCard
key={dump.id}
dump={dump}
voteCount={voteCounts[dump.id] ?? dump.voteCount}
voted={myVotes.has(dump.id)}
canVote={canVote}
castVote={castVote}
removeVote={removeVote}
isOwner={isOwnProfile}
/>
))}
</ul>
)}
{dumps.length > 0 && (
<Link to={viewAllHref} className="profile-view-all">
<Trans>View all </Trans>
</Link>
)}
</section>
);
}
// ── Upvoted list: fades items out when votes are removed ─────────────────────
function UpvotedDumpList(
{
title,
dumps,
profileUserId,
isOwnProfile,
voteCounts,
myVotes,
canVote,
castVote,
removeVote,
viewAllHref,
}: {
title: string;
dumps: Dump[];
profileUserId: string | null;
isOwnProfile: boolean;
voteCounts: Record<string, number>;
myVotes: Set<string>;
canVote: boolean;
castVote: (id: string) => void;
removeVote: (id: string) => void;
viewAllHref: string;
},
) {
const { myVotes: wsMyVotes, lastVoteEvent } = useWS();
const { fading, startFading, cancelFading } = useFading();
// votedIds is managed locally so setVotedIds + startFading/cancelFading can
// be called in the same effect body — guaranteeing a single render where the
// dump is always in visibleDumps (with or without fading class). This prevents
// the DOM node from being unmounted/remounted, which would break CSS transitions.
const [votedIds, setVotedIds] = useState(() =>
new Set(dumps.map((d) => d.id))
);
const prevMyVotesRef = useRef<Set<string> | null>(null);
// Own profile: sync votedIds with myVotes; start/cancel fading in same batch.
// setVotedIds and startFading/cancelFading must be called together synchronously
// in the same effect to guarantee a single render where the DOM node isn't
// unmounted — converting to render-phase isn't possible because startFading/
// cancelFading are themselves setState calls that can't run during render.
useEffect(() => {
if (!profileUserId || !isOwnProfile) return;
if (prevMyVotesRef.current === null) {
// setVotedIds + prevMyVotesRef must be co-located to stay consistent.
setVotedIds(new Set(wsMyVotes));
prevMyVotesRef.current = new Set(wsMyVotes);
return;
}
const prev = prevMyVotesRef.current;
setVotedIds(new Set(wsMyVotes));
for (const id of prev) if (!wsMyVotes.has(id)) startFading(id);
for (const id of wsMyVotes) if (!prev.has(id)) cancelFading(id);
prevMyVotesRef.current = new Set(wsMyVotes);
}, [wsMyVotes, isOwnProfile, profileUserId, startFading, cancelFading]);
// Non-own profile: sync votedIds with WS vote events for the profile user.
// Same constraint as above: setVotedIds and startFading/cancelFading must
// fire together so the DOM node stays mounted throughout the transition.
useEffect(() => {
if (!lastVoteEvent || !profileUserId || isOwnProfile) return;
const { dumpId, voterId, action } = lastVoteEvent;
if (voterId !== profileUserId) return;
if (action === "remove") {
// setVotedIds and startFading must fire together to avoid a render with
// stale votedIds between the two updates.
// eslint-disable-next-line react-hooks/set-state-in-effect
setVotedIds((prev) => {
const n = new Set(prev);
n.delete(dumpId);
return n;
});
startFading(dumpId);
} else {
setVotedIds((prev) => new Set([...prev, dumpId]));
cancelFading(dumpId);
}
}, [lastVoteEvent, profileUserId, isOwnProfile, startFading, cancelFading]);
const visibleDumps = dumps.filter((d) =>
votedIds.has(d.id) || d.id in fading
);
return (
<section className="profile-section">
<div className="profile-section-header">
<h2 className="profile-section-title">{title}</h2>
</div>
{visibleDumps.length === 0
? (
<p className="empty-state">
<Trans>Nothing here yet.</Trans>
</p>
)
: (
<ul className="dump-feed">
{visibleDumps.map((dump) => {
const phase = fading[dump.id];
const extraCls = phase === "cooldown"
? "dump-card--fading"
: phase === "dismissing"
? "dump-card--dismissing"
: undefined;
return (
<DumpCard
key={dump.id}
dump={dump}
voteCount={voteCounts[dump.id] ?? dump.voteCount}
voted={myVotes.has(dump.id)}
canVote={canVote}
castVote={castVote}
removeVote={removeVote}
isOwner={isOwnProfile}
className={extraCls}
/>
);
})}
</ul>
)}
{visibleDumps.length > 0 && (
<Link to={viewAllHref} className="profile-view-all">
<Trans>View all </Trans>
</Link>
)}
</section>
);
}
// ── Invite tree ──────────────────────────────────────────────────────────────
function InviteTreeList({ nodes }: { nodes: InviteTreeNode[] }) {
return (
<ul className="invite-tree">
{nodes.map((node) => (
<li key={node.id} className="invite-tree-node">
<Link
to={`/users/${node.username}`}
className="invite-tree-user"
>
<Avatar
userId={node.id}
username={node.username}
hasAvatar={!!node.avatarMime}
size={24}
/>
@{node.username}
</Link>
{node.children.length > 0 && <InviteTreeList nodes={node.children} />}
</li>
))}
</ul>
);
}
// ── Followed user card ───────────────────────────────────────────────────────
function FollowedUserCard({ user }: { user: PublicUser }) {
return (
<li className="followed-user-card">
<Link to={`/users/${user.username}`} className="followed-user-card-link">
<Avatar
userId={user.id}
username={user.username}
hasAvatar={!!user.avatarMime}
size={36}
version={user.updatedAt?.getTime()}
/>
<span className="followed-user-card-name">@{user.username}</span>
</Link>
<FollowUserButton
targetUserId={user.id}
targetUsername={user.username}
/>
</li>
);
}
const ROLE_OPTIONS: { value: Role; label: React.ReactNode }[] = [
{ value: "user", label: <Trans>User</Trans> },
{ value: "moderator", label: <Trans>Moderator</Trans> },
{ value: "admin", label: <Trans>Admin</Trans> },
];
interface RoleSwitcherProps {
userId: string;
currentRole: Role;
onSaved: (role: Role) => void;
}
// Segmented role control (matches the dump-visibility toggle style), gated
// behind the user:manage permission by its caller.
function RoleSwitcher({ userId, currentRole, onSaved }: RoleSwitcherProps) {
const { authFetch } = useAuth();
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
async function changeRole(role: Role) {
if (role === currentRole || saving) return;
setSaving(true);
setError(null);
try {
const res = await authFetch(`${API_URL}/api/users/${userId}/role`, {
method: "PATCH",
body: JSON.stringify({ role }),
});
const body = parseAPIResponse<RawPublicUser>(await res.json());
if (!body.success) {
setError(body.error.message);
return;
}
onSaved(deserializePublicUser(body.data).role);
} catch (err) {
setError(friendlyFetchError(err));
} finally {
setSaving(false);
}
}
return (
<>
<div className="profile-appearance-row">
<span className="profile-appearance-label">
<Trans>Role</Trans>
</span>
<div className="visibility-toggle">
{ROLE_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
className={currentRole === opt.value ? "active" : ""}
disabled={saving}
onClick={() => changeRole(opt.value)}
>
{opt.label}
</button>
))}
</div>
</div>
{error && <ErrorCard title={t`Failed to update role`} message={error} />}
</>
);
}
interface EmailEditorProps {
initialEmail: string;
onCancel: () => void;
onSaved: (email: string) => void;
}
function EmailEditor({ initialEmail, onCancel, onSaved }: EmailEditorProps) {
const { authFetch } = useAuth();
const form = useApiForm<{ email: string }>({
defaultValues: { email: initialEmail },
});
const email = form.watch("email");
const submitting = form.formState.isSubmitting;
const onSubmit = form.submit(async ({ email }) => {
await expectOk<RawPublicUser>(
await authFetch(`${API_URL}/api/users/me`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
{ email: email.trim() } satisfies UpdateUserRequest,
),
}),
);
onSaved(email.trim());
});
return (
<FormProvider {...form}>
<form className="profile-email-editor" onSubmit={onSubmit}>
<input
type="email"
className="profile-email-input"
{...form.register("email")}
onKeyDown={(e) => {
if (e.key === "Escape") onCancel();
}}
disabled={submitting}
autoFocus
/>
<div className="profile-email-actions">
<SubmitButton
className="profile-email-btn profile-email-btn--save"
pendingLabel={<Trans>Saving</Trans>}
disabled={!email.trim()}
>
<Trans>Save</Trans>
</SubmitButton>
<button
type="button"
className="profile-email-btn profile-email-btn--cancel"
onClick={onCancel}
disabled={submitting}
>
<Trans>Cancel</Trans>
</button>
</div>
<FormError title={t`Failed to save`} />
</form>
</FormProvider>
);
}
interface DescriptionEditorProps {
initialDescription: string;
onCancel: () => void;
onSaved: (description: string | undefined) => void;
}
function DescriptionEditor(
{ initialDescription, onCancel, onSaved }: DescriptionEditorProps,
) {
const { authFetch } = useAuth();
const form = useApiForm<{ description: string }>({
defaultValues: { description: initialDescription },
});
const description = form.watch("description");
const submitting = form.formState.isSubmitting;
const onSubmit = form.submit(async ({ description }) => {
const trimmed = description.trim() || undefined;
await expectOk<RawPublicUser>(
await authFetch(`${API_URL}/api/users/me`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
{ description: trimmed } satisfies UpdateUserRequest,
),
}),
);
onSaved(trimmed);
});
return (
<FormProvider {...form}>
<form className="profile-description-editor" onSubmit={onSubmit}>
<Controller
name="description"
control={form.control}
render={({ field }) => (
<TextEditor
className="comment-reply-textarea"
value={field.value}
onChange={field.onChange}
onSubmit={() => void onSubmit()}
placeholder={t`Who am I?`}
autoResize
maxLength={VALIDATION.USER_DESCRIPTION_MAX}
/>
)}
/>
<div className="profile-description-actions">
<SubmitButton
pendingLabel={<Trans>Saving</Trans>}
disabled={description.length > VALIDATION.USER_DESCRIPTION_MAX}
>
<Trans>Save</Trans>
</SubmitButton>
<button
type="button"
className="form-cancel"
onClick={onCancel}
disabled={submitting}
>
<Trans>Cancel</Trans>
</button>
<FormError title={t`Failed to save`} />
</div>
</form>
</FormProvider>
);
}