All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 41s
1724 lines
55 KiB
TypeScript
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>
|
|
);
|
|
}
|