v3: added multiple stylesheets, improved user profiles
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user