v2: global player, infinite scroll, image picker, threaded comments

This commit is contained in:
khannurien
2026-03-21 13:55:22 +00:00
parent be426eb150
commit 7c098e7c4c
48 changed files with 4346 additions and 711 deletions

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { Link, useLocation } from "react-router";
import { Avatar } from "../components/Avatar.tsx";
@@ -7,15 +7,24 @@ import { AppHeader } from "../components/AppHeader.tsx";
import { API_URL } from "../config/api.ts";
import { deserializeDump, type Dump } from "../model.ts";
import { deserializeDump, type Dump, type PaginatedData, type RawDump } from "../model.ts";
import { useFeedCache } from "../hooks/useFeedCache.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { useWS } from "../hooks/useWS.ts";
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
const PAGE_SIZE = 20;
// After JSON roundtrip, createdAt is a string — re-parse it
const hydrateDump = (raw: Dump): Dump =>
deserializeDump(raw as unknown as RawDump);
type DumpsState =
| { status: "loading" }
| { status: "error"; error: string }
| { status: "loaded"; dumps: Dump[] };
| { status: "loaded"; dumps: Dump[]; hasMore: boolean; page: number; loadingMore: boolean };
type SortMode = "new" | "hot";
@@ -29,7 +38,7 @@ export function Index() {
const justDeletedId = (location.state as { deletedDumpId?: string } | null)
?.deletedDumpId;
const { user } = useAuth();
const { user, token } = useAuth();
const {
onlineUsers,
voteCounts,
@@ -40,20 +49,31 @@ export function Index() {
removeVote,
} = useWS();
const [dumpsState, setDumpsState] = useState<DumpsState>({
status: "loading",
});
const { cached, saveState } = useFeedCache<Dump>(`feed:index:${user?.id ?? "guest"}`, hydrateDump);
const [dumpsState, setDumpsState] = useState<DumpsState>(() =>
cached
? { status: "loaded", dumps: cached.items, hasMore: cached.hasMore, page: cached.page, loadingMore: false }
: { status: "loading" }
);
const [sort, setSort] = useState<SortMode>("hot");
useEffect(() => {
if (cached) return; // restored from cache, skip fetch
(async () => {
try {
const res = await fetch(`${API_URL}/api/dumps/`);
const res = await fetch(`${API_URL}/api/dumps/?page=1&limit=${PAGE_SIZE}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await res.json();
const { items, hasMore } = body.data as PaginatedData<RawDump>;
setDumpsState({
status: "loaded",
dumps: body.data.map(deserializeDump),
dumps: items.map(deserializeDump),
hasMore,
page: 1,
loadingMore: false,
});
} catch (err) {
setDumpsState({
@@ -62,11 +82,82 @@ export function Index() {
});
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const loadMore = useCallback(() => {
if (
dumpsState.status !== "loaded" ||
!dumpsState.hasMore ||
dumpsState.loadingMore
) return;
const nextPage = dumpsState.page + 1;
setDumpsState((s) =>
s.status === "loaded" ? { ...s, loadingMore: true } : s
);
fetch(`${API_URL}/api/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>;
setDumpsState((s) =>
s.status === "loaded"
? {
...s,
dumps: [...s.dumps, ...items.map(deserializeDump)],
hasMore,
page: nextPage,
loadingMore: false,
}
: s
);
})
.catch(() =>
setDumpsState((s) =>
s.status === "loaded" ? { ...s, loadingMore: false } : s
)
);
}, [dumpsState, token]);
const sentinelRef = useInfiniteScroll(
loadMore,
dumpsState.status === "loaded" && dumpsState.hasMore && !dumpsState.loadingMore,
);
// Save scroll position + loaded state to sessionStorage on scroll
useEffect(() => {
if (dumpsState.status !== "loaded") return;
let timer: ReturnType<typeof setTimeout>;
const onScroll = () => {
clearTimeout(timer);
timer = setTimeout(() => {
if (dumpsState.status === "loaded") {
saveState(dumpsState.dumps, dumpsState.page, dumpsState.hasMore, window.scrollY);
}
}, 100);
};
window.addEventListener("scroll", onScroll, { passive: true });
return () => { window.removeEventListener("scroll", onScroll); clearTimeout(timer); };
}, [dumpsState, saveState]);
// Restore scroll position after cache restoration
const scrollRestored = useRef(false);
useLayoutEffect(() => {
if (cached?.scrollY == null || scrollRestored.current) return;
if (dumpsState.status === "loaded") {
window.scrollTo(0, cached.scrollY);
scrollRestored.current = true;
}
// cached is stable (read once), safe to omit
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dumpsState.status]);
const loading = dumpsState.status === "loading";
const error = dumpsState.status === "error" ? dumpsState.error : null;
const dumps = dumpsState.status === "loaded" ? dumpsState.dumps : [];
const loadingMore = dumpsState.status === "loaded" && dumpsState.loadingMore;
const restIds = new Set(dumps.map((d) => d.id));
const combined = [...recentDumps.filter((d) => !restIds.has(d.id)), ...dumps]
.filter((d) => !deletedDumpIds.has(d.id) && d.id !== justDeletedId);
@@ -141,22 +232,24 @@ export function Index() {
)}
{!loading && !error && combined.length > 0 && (
<>
<ul className="dump-feed">
{sortedDumps.map((dump) => (
<DumpCard
key={dump.id}
dump={dump}
voteCount={voteCounts[dump.id] ?? dump.voteCount}
voted={myVotes.has(dump.id)}
canVote={!!user}
castVote={castVote}
removeVote={removeVote}
/>
))}
</ul>
</>
<ul className="dump-feed">
{sortedDumps.map((dump) => (
<DumpCard
key={dump.id}
dump={dump}
voteCount={voteCounts[dump.id] ?? dump.voteCount}
voted={myVotes.has(dump.id)}
canVote={!!user}
castVote={castVote}
removeVote={removeVote}
isOwner={user?.id === dump.userId}
/>
))}
</ul>
)}
<div ref={sentinelRef} />
{loadingMore && <p className="feed-loading-more">Loading more</p>}
</div>
);
}