v3: added multiple stylesheets, improved user profiles

This commit is contained in:
khannurien
2026-04-06 15:36:04 +00:00
parent a69788c15b
commit 3b6980a8fc
24 changed files with 2182 additions and 714 deletions

View File

@@ -1,6 +1,6 @@
import { useState } from "react";
import { t } from "@lingui/core/macro";
import { Plural, Trans } from "@lingui/react/macro";
import { Trans } from "@lingui/react/macro";
import { Link, useParams } from "react-router";
import { useAuth } from "../hooks/useAuth.ts";
@@ -70,33 +70,9 @@ export function UserDumps() {
const { profileUser, items: dumps, hasMore, loadingMore } = state;
return (
<PageShell>
<ProfileSubpageHeader
username={username!}
profileUser={profileUser}
title={t`Dumps`}
actions={isOwnProfile && (
<button
type="button"
className="new-playlist-toggle"
onClick={() => setCreateModalOpen(true)}
>
<Trans>+ New dump</Trans>
</button>
)}
/>
{createModalOpen && (
<DumpCreateModal onClose={() => setCreateModalOpen(false)} />
)}
{dumps.length === 0
? (
<p className="empty-state">
<Trans>Nothing here yet.</Trans>
</p>
)
: (
<PageShell
feed={dumps.length > 0 && (
<>
<ul className="dump-feed">
{dumps.map((dump) => (
<DumpCard
@@ -111,21 +87,40 @@ export function UserDumps() {
/>
))}
</ul>
)}
<div ref={sentinelRef} />
{loadingMore && (
<p className="feed-loading-more">
<Trans>Loading more</Trans>
</p>
{hasMore && <div ref={sentinelRef} />}
{loadingMore && (
<p className="feed-loading-more">
<Trans>Loading more</Trans>
</p>
)}
{!hasMore && (
<p className="feed-end">
<Trans>You've reached the end.</Trans>
</p>
)}
</>
)}
{!hasMore && dumps.length > 0 && (
<p className="index-status">
<Trans>
All <Plural value={dumps.length} one="# dump" other="# dumps" />
{" "}
loaded.
</Trans>
>
<ProfileSubpageHeader
username={username!}
profileUser={profileUser}
title={t`Dumps`}
actions={isOwnProfile && (
<button
type="button"
className="new-playlist-toggle"
onClick={() => setCreateModalOpen(true)}
>
<Trans>+ New dump</Trans>
</button>
)}
/>
{createModalOpen && (
<DumpCreateModal onClose={() => setCreateModalOpen(false)} />
)}
{dumps.length === 0 && (
<p className="empty-state">
<Trans>Nothing here yet.</Trans>
</p>
)}
</PageShell>

View File

@@ -411,12 +411,17 @@ export function UserPlaylists() {
))}
</ul>
)}
<div ref={createdSentinelRef} />
{created.hasMore && <div ref={createdSentinelRef} />}
{created.loadingMore && (
<p className="feed-loading-more">
<Trans>Loading more</Trans>
</p>
)}
{!created.hasMore && created.items.length > 0 && (
<p className="feed-end">
<Trans>You've reached the end.</Trans>
</p>
)}
</section>
<section className="profile-section">
@@ -441,12 +446,17 @@ export function UserPlaylists() {
))}
</ul>
)}
<div ref={followedSentinelRef} />
{followed.hasMore && <div ref={followedSentinelRef} />}
{followed.loadingMore && (
<p className="feed-loading-more">
<Trans>Loading more…</Trans>
</p>
)}
{!followed.hasMore && followed.items.length > 0 && (
<p className="feed-end">
<Trans>You've reached the end.</Trans>
</p>
)}
</section>
{confirmDeleteId && (

View File

@@ -2,6 +2,7 @@ import React, {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
@@ -10,15 +11,22 @@ 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, PaginatedData, PublicUser } from "../model.ts";
import type {
Dump,
InviteTreeEntry,
PaginatedData,
PublicUser,
} from "../model.ts";
import {
deserializeAuthResponse,
deserializeDump,
deserializeInviteTreeEntry,
deserializePublicUser,
hydrateDump,
hydratePlaylist,
parseAPIResponse,
type RawDump,
type RawInviteTreeEntry,
type RawPublicUser,
type UpdateUserRequest,
} from "../model.ts";
@@ -29,6 +37,7 @@ 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 { useTheme } from "../hooks/useTheme.ts";
import { useWS } from "../hooks/useWS.ts";
import { useDumpListSync } from "../hooks/useDumpListSync.ts";
import { useFading } from "../hooks/useFading.ts";
@@ -118,10 +127,38 @@ type ProfileState =
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[] };
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 {
voteCounts,
myVotes,
@@ -244,11 +281,25 @@ export function UserPublicProfile() {
const [emailError, setEmailError] = useState<string | null>(null);
const prevMyVotesRef = useRef<Set<string> | null>(null);
const [tab, setTab] = useState<
"dumps" | "playlists" | "followed" | "invitees" | "settings"
>("dumps");
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" });
prevMyVotesRef.current = null;
setTab("dumps");
setFollowedState(null);
setInviteTreeState(null);
}
useEffect(() => {
@@ -468,6 +519,85 @@ export function UserPublicProfile() {
);
}, [playlistFeed, savePlaylists]);
// Lazy-load followed users + playlists when the Followed tab is first opened
useEffect(() => {
if (
tab !== "followed" || followedState !== null || state.status !== "loaded"
) {
return;
}
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;
}
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(() => {
@@ -826,80 +956,312 @@ export function UserPublicProfile() {
</div>
)}
<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 className="profile-tabs feed-sort">
<button
type="button"
className={`feed-sort-btn${tab === "dumps" ? " active" : ""}`}
onClick={() => setTab("dumps")}
>
<Trans>Dumps</Trans>
</button>
<button
type="button"
className={`feed-sort-btn${tab === "playlists" ? " active" : ""}`}
onClick={() => setTab("playlists")}
>
<Trans>Playlists</Trans>
</button>
<button
type="button"
className={`feed-sort-btn${tab === "followed" ? " active" : ""}`}
onClick={() => setTab("followed")}
>
<Trans>Followed</Trans>
</button>
<button
type="button"
className={`feed-sort-btn${tab === "invitees" ? " active" : ""}`}
onClick={() => setTab("invitees")}
>
<Trans>Invitees</Trans>
</button>
{isOwnProfile && (
<button
type="button"
className={`feed-sort-btn${tab === "settings" ? " active" : ""}`}
onClick={() => setTab("settings")}
>
<Trans>Settings</Trans>
</button>
)}
</div>
<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],
},
};
})}
/>
)}
{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>
{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>
)}
{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>
)}
{playlists.items.length > 0 && (
<Link
to={`/users/${profileUser.username}/playlists`}
className="profile-view-all"
>
<Trans>View all </Trans>
</Link>
)}
</section>
</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>
)}
{tab === "settings" && isOwnProfile && (
<>
<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>
</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>
</>
)}
</PageShell>
);
}
@@ -1109,3 +1471,52 @@ function UpvotedDumpList(
</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>
);
}

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Link, useParams } from "react-router";
import { t } from "@lingui/core/macro";
import { Plural, Trans } from "@lingui/react/macro";
import { Trans } from "@lingui/react/macro";
import { API_URL } from "../config/api.ts";
import type { Dump } from "../model.ts";
@@ -145,20 +145,9 @@ export function UserUpvoted() {
);
return (
<PageShell>
<ProfileSubpageHeader
username={username!}
profileUser={profileUser}
title={t`Upvoted`}
/>
{visibleDumps.length === 0
? (
<p className="empty-state">
<Trans>Nothing here yet.</Trans>
</p>
)
: (
<PageShell
feed={visibleDumps.length > 0 && (
<>
<ul className="dump-feed">
{visibleDumps.map((dump) => {
const phase = fading[dump.id];
@@ -182,25 +171,28 @@ export function UserUpvoted() {
);
})}
</ul>
)}
<div ref={sentinelRef} />
{loadingMore && (
<p className="feed-loading-more">
<Trans>Loading more</Trans>
</p>
{hasMore && <div ref={sentinelRef} />}
{loadingMore && (
<p className="feed-loading-more">
<Trans>Loading more</Trans>
</p>
)}
{!hasMore && (
<p className="feed-end">
<Trans>You've reached the end.</Trans>
</p>
)}
</>
)}
{!hasMore && visibleDumps.length > 0 && (
<p className="index-status">
<Trans>
All{" "}
<Plural
value={votes.length}
one="# upvoted dump"
other="# upvoted dumps"
/>{" "}
loaded.
</Trans>
>
<ProfileSubpageHeader
username={username!}
profileUser={profileUser}
title={t`Upvoted`}
/>
{visibleDumps.length === 0 && (
<p className="empty-state">
<Trans>Nothing here yet.</Trans>
</p>
)}
</PageShell>

View File

@@ -103,12 +103,17 @@ function FollowedSubFeed({
/>
))}
</ul>
<div ref={sentinelRef} />
{state.hasMore && <div ref={sentinelRef} />}
{state.loadingMore && (
<p className="feed-loading-more">
<Trans>Loading more</Trans>
</p>
)}
{!state.hasMore && state.dumps.length > 0 && (
<p className="feed-end">
<Trans>You've reached the end.</Trans>
</p>
)}
</>
);
}