v3: follows, notifications, invite-only registration, unread markers

This commit is contained in:
khannurien
2026-03-21 18:42:47 +00:00
parent 7c098e7c4c
commit 608c6bc6a8
55 changed files with 4743 additions and 884 deletions

260
src/pages/UserDumps.tsx Normal file
View File

@@ -0,0 +1,260 @@
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { Link, useParams } from "react-router";
import { API_URL } from "../config/api.ts";
import type { Dump, PaginatedData, PublicUser, RawDump } from "../model.ts";
import { deserializeDump, deserializePublicUser } from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { useWS } from "../hooks/useWS.ts";
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
import { useFeedCache } from "../hooks/useFeedCache.ts";
import { Avatar } from "../components/Avatar.tsx";
import { DumpCard } from "../components/DumpCard.tsx";
import { DumpCreateModal } from "../components/DumpCreateModal.tsx";
import { PageShell } from "../components/PageShell.tsx";
import { PageError } from "../components/PageError.tsx";
const PAGE_SIZE = 20;
const hydrateDump = (raw: Dump): Dump =>
deserializeDump(raw as unknown as RawDump);
type State =
| { status: "loading" }
| { status: "error"; error: string }
| {
status: "loaded";
profileUser: PublicUser;
dumps: Dump[];
hasMore: boolean;
page: number;
loadingMore: boolean;
};
export function UserDumps() {
const { username } = useParams();
const { user: me, token } = useAuth();
const { voteCounts, myVotes, castVote, removeVote } = useWS();
const { cached, saveState } = useFeedCache<Dump>(
`feed:user-dumps-full:${username ?? ""}`,
hydrateDump,
);
const [state, setState] = useState<State>({ status: "loading" });
const [createModalOpen, setCreateModalOpen] = useState(false);
useEffect(() => {
if (!username) return;
setState({ status: "loading" });
if (cached) {
fetch(`${API_URL}/api/users/${username}`)
.then((r) => r.json())
.then((body) => {
if (!body.success) throw new Error("User not found");
setState({
status: "loaded",
profileUser: deserializePublicUser(body.data),
dumps: cached.items,
hasMore: cached.hasMore,
page: cached.page,
loadingMore: false,
});
})
.catch((err) =>
setState({
status: "error",
error: err instanceof Error ? err.message : "Failed to load",
})
);
return;
}
const authHeaders: HeadersInit = token
? { Authorization: `Bearer ${token}` }
: {};
Promise.all([
fetch(`${API_URL}/api/users/${username}`),
fetch(
`${API_URL}/api/users/${username}/dumps?page=1&limit=${PAGE_SIZE}`,
{ headers: authHeaders },
),
])
.then(([userRes, dumpsRes]) =>
Promise.all([userRes.json(), dumpsRes.json()])
)
.then(([userBody, dumpsBody]) => {
if (!userBody.success) throw new Error("User not found");
const { items, hasMore } = dumpsBody.success
? dumpsBody.data as PaginatedData<RawDump>
: { items: [], hasMore: false };
setState({
status: "loaded",
profileUser: deserializePublicUser(userBody.data),
dumps: items.map(deserializeDump),
hasMore,
page: 1,
loadingMore: false,
});
})
.catch((err) =>
setState({
status: "error",
error: err instanceof Error ? err.message : "Failed to load",
})
);
}, [username]);
const loadMore = useCallback(() => {
if (
state.status !== "loaded" || !state.hasMore || state.loadingMore ||
!username
) return;
const nextPage = state.page + 1;
setState((s) => s.status === "loaded" ? { ...s, loadingMore: true } : s);
fetch(
`${API_URL}/api/users/${username}/dumps?page=${nextPage}&limit=${PAGE_SIZE}`,
{ headers: token ? { Authorization: `Bearer ${token}` } : {} },
)
.then((r) => r.json())
.then((body) => {
const { items, hasMore } = body.data as PaginatedData<RawDump>;
setState((s) =>
s.status === "loaded"
? {
...s,
dumps: [...s.dumps, ...items.map(deserializeDump)],
hasMore,
page: nextPage,
loadingMore: false,
}
: s
);
})
.catch(() =>
setState((s) =>
s.status === "loaded" ? { ...s, loadingMore: false } : s
)
);
}, [state, username, token]);
const sentinelRef = useInfiniteScroll(
loadMore,
state.status === "loaded" && state.hasMore && !state.loadingMore,
);
useEffect(() => {
if (state.status !== "loaded") return;
let timer: ReturnType<typeof setTimeout>;
const onScroll = () => {
clearTimeout(timer);
timer = setTimeout(() => {
if (state.status !== "loaded") return;
saveState(state.dumps, state.page, state.hasMore, globalThis.scrollY);
}, 100);
};
globalThis.addEventListener("scroll", onScroll, { passive: true });
return () => {
globalThis.removeEventListener("scroll", onScroll);
clearTimeout(timer);
};
}, [state, saveState]);
const scrollRestored = useRef(false);
useLayoutEffect(() => {
if (cached?.scrollY == null || scrollRestored.current) return;
if (state.status === "loaded") {
globalThis.scrollTo(0, cached.scrollY);
scrollRestored.current = true;
}
}, [state.status, cached]);
if (state.status === "loading") {
return (
<PageShell>
<p className="page-loading">Loading</p>
</PageShell>
);
}
if (state.status === "error") {
return (
<PageError
message={state.error}
actions={
<Link to={`/users/${username}`} className="logout-btn">
Back to profile
</Link>
}
/>
);
}
const { profileUser, dumps, hasMore, loadingMore } = state;
const isOwnProfile = me?.username === profileUser.username;
return (
<PageShell>
<div className="profile-subpage-header">
<Link
to={`/users/${username}`}
className="profile-subpage-back"
>
{profileUser.username}
</Link>
<div className="profile-subpage-title-row">
<Avatar
userId={profileUser.id}
username={profileUser.username}
hasAvatar={!!profileUser.avatarMime}
size={36}
/>
<h1 className="profile-subpage-title">Dumps</h1>
{isOwnProfile && (
<button
type="button"
className="new-playlist-toggle"
onClick={() => setCreateModalOpen(true)}
>
+ New dump
</button>
)}
</div>
</div>
{createModalOpen && (
<DumpCreateModal onClose={() => setCreateModalOpen(false)} />
)}
{dumps.length === 0
? <p className="empty-state">Nothing here yet.</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={!!me}
castVote={castVote}
removeVote={removeVote}
isOwner={isOwnProfile}
/>
))}
</ul>
)}
<div ref={sentinelRef} />
{loadingMore && <p className="feed-loading-more">Loading more</p>}
{!hasMore && dumps.length > 0 && (
<p className="index-status">All {dumps.length} dumps loaded.</p>
)}
</PageShell>
);
}