import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { Link, useLocation } from "react-router"; import { Avatar } from "../components/Avatar.tsx"; import { DumpCard } from "../components/DumpCard.tsx"; import { AppHeader } from "../components/AppHeader.tsx"; import { API_URL } from "../config/api.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[]; hasMore: boolean; page: number; loadingMore: boolean }; type SortMode = "new" | "hot"; function hotScore(dump: Dump): number { const ageHours = (Date.now() - dump.createdAt.getTime()) / 3_600_000; return (dump.voteCount + 1) / Math.pow(ageHours + 2, 1.5); } export function Index() { const location = useLocation(); const justDeletedId = (location.state as { deletedDumpId?: string } | null) ?.deletedDumpId; const { user, token } = useAuth(); const { onlineUsers, voteCounts, myVotes, recentDumps, deletedDumpIds, castVote, removeVote, } = useWS(); const { cached, saveState } = useFeedCache(`feed:index:${user?.id ?? "guest"}`, hydrateDump); const [dumpsState, setDumpsState] = useState(() => cached ? { status: "loaded", dumps: cached.items, hasMore: cached.hasMore, page: cached.page, loadingMore: false } : { status: "loading" } ); const [sort, setSort] = useState("hot"); useEffect(() => { if (cached) return; // restored from cache, skip fetch (async () => { try { 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; setDumpsState({ status: "loaded", dumps: items.map(deserializeDump), hasMore, page: 1, loadingMore: false, }); } catch (err) { setDumpsState({ status: "error", error: err instanceof Error ? err.message : "Failed to load", }); } })(); // 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; 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; 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); const sortedDumps = [...combined].sort( sort === "hot" ? (a, b) => hotScore(b) - hotScore(a) : (a, b) => b.createdAt.getTime() - a.createdAt.getTime(), ); const presenceRow = (
{onlineUsers.map((u) => ( ))}
); const sortButtons = !loading && !error && combined.length > 0 && (
); return (
{presenceRow} {sortButtons}
} /> {/* Shown only on narrow viewports */}
{sortButtons} {presenceRow}
{loading &&

Loading…

} {error &&

{error}

} {!loading && !error && combined.length === 0 && (

No dumps yet. Be the first!

)} {!loading && !error && combined.length > 0 && (
    {sortedDumps.map((dump) => ( ))}
)}
{loadingMore &&

Loading more…

}
); }