Files
gerbeur/src/pages/Index.tsx
2026-03-23 15:55:45 +00:00

705 lines
20 KiB
TypeScript

import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
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, DEFAULT_PAGE_SIZE } from "../config/api.ts";
import {
deserializeDump,
type Dump,
hydrateDump,
type PaginatedData,
type RawDump,
type User,
} from "../model.ts";
import { ErrorCard } from "../components/ErrorCard.tsx";
import { friendlyFetchError } from "../utils/apiError.ts";
import { useFeedCache } from "../hooks/useFeedCache.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { useWS } from "../hooks/useWS.ts";
import { useDumpListSync } from "../hooks/useDumpListSync.ts";
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
type DumpsState =
| { status: "loading" }
| { status: "error"; error: string }
| {
status: "loaded";
dumps: Dump[];
hasMore: boolean;
page: number;
loadingMore: boolean;
};
type FeedTab = "hot" | "new" | "followed";
type FollowedSection = "users" | "playlists";
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);
}
// ── FollowedSubFeed ──────────────────────────────────────────────────────────
interface FollowedSubFeedProps {
state: DumpsState;
voteCounts: Record<string, number>;
myVotes: Set<string>;
user: User | null;
castVote: (id: string) => void;
removeVote: (id: string) => void;
deletedDumpIds: Set<string>;
emptyMessage: string;
onLoadMore: () => void;
}
function FollowedSubFeed({
state,
voteCounts,
myVotes,
user,
castVote,
removeVote,
deletedDumpIds,
emptyMessage,
onLoadMore,
}: FollowedSubFeedProps) {
const hasMore = state.status === "loaded" && state.hasMore &&
!state.loadingMore;
const sentinelRef = useInfiniteScroll(onLoadMore, hasMore);
if (state.status === "loading") {
return <p className="index-status">Loading</p>;
}
if (state.status === "error") {
return <ErrorCard title="Failed to load" message={state.error} />;
}
const visible = state.dumps.filter((d) => !deletedDumpIds.has(d.id));
if (visible.length === 0) {
return <p className="index-status">{emptyMessage}</p>;
}
return (
<>
<ul className="dump-feed">
{visible.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} />
{state.loadingMore && <p className="feed-loading-more">Loading more</p>}
</>
);
}
// ── Index ────────────────────────────────────────────────────────────────────
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();
// Main feed
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 mainFetchDone = useRef(false);
// Followed feeds
const { cached: cachedFollowedUsers, saveState: saveFollowedUsers } =
useFeedCache<Dump>(
`feed:followed-users:${user?.id ?? "guest"}`,
hydrateDump,
);
const { cached: cachedFollowedPlaylists, saveState: saveFollowedPlaylists } =
useFeedCache<Dump>(
`feed:followed-playlists:${user?.id ?? "guest"}`,
hydrateDump,
);
const [followedUsersDumps, setFollowedUsersDumps] = useState<DumpsState>({
status: "loading",
});
const [followedPlaylistsDumps, setFollowedPlaylistsDumps] = useState<
DumpsState
>({ status: "loading" });
const setFollowedUsersDumpsItems = useCallback(
(fn: (prev: Dump[]) => Dump[]) =>
setFollowedUsersDumps((s) =>
s.status !== "loaded" ? s : { ...s, dumps: fn(s.dumps) }
),
[],
);
useDumpListSync(setFollowedUsersDumpsItems);
const setFollowedPlaylistsDumpsItems = useCallback(
(fn: (prev: Dump[]) => Dump[]) =>
setFollowedPlaylistsDumps((s) =>
s.status !== "loaded" ? s : { ...s, dumps: fn(s.dumps) }
),
[],
);
useDumpListSync(setFollowedPlaylistsDumpsItems);
const [tab, setTab] = useState<FeedTab>("hot");
const [followedSection, setFollowedSection] = useState<FollowedSection>(
"users",
);
// When the logo is clicked it navigates to / with state { tab: "hot" }, producing
// a new location.key even if already on /. React to that to reset the active tab.
useEffect(() => {
const st = location.state as { tab?: string } | null;
if (st?.tab === "hot" || st?.tab === "new" || st?.tab === "followed") {
setTab(st.tab as FeedTab);
}
}, [location]);
// ── Main feed fetch ──
useEffect(() => {
if (mainFetchDone.current || cached) return;
mainFetchDone.current = true;
const controller = new AbortController();
(async () => {
try {
const res = await fetch(
`${API_URL}/api/dumps/?page=1&limit=${DEFAULT_PAGE_SIZE}`,
{
signal: controller.signal,
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: items.map(deserializeDump),
hasMore,
page: 1,
loadingMore: false,
});
} catch (err) {
if ((err as Error).name === "AbortError") return;
setDumpsState({
status: "error",
error: friendlyFetchError(err),
});
}
})();
return () => {
mainFetchDone.current = false;
controller.abort();
};
}, [cached, token]);
// ── Followed feeds fetch (lazy, on first tab open) ──
useEffect(() => {
if (tab !== "followed" || !user || !token) return;
if (followedUsersDumps.status === "loading") {
if (cachedFollowedUsers) {
setFollowedUsersDumps({
status: "loaded",
dumps: cachedFollowedUsers.items,
hasMore: cachedFollowedUsers.hasMore,
page: cachedFollowedUsers.page,
loadingMore: false,
});
} else {
fetch(
`${API_URL}/api/follows/feed/users?page=1&limit=${DEFAULT_PAGE_SIZE}`,
{
headers: { Authorization: `Bearer ${token}` },
},
)
.then((r) => r.json())
.then((body) => {
const { items, hasMore } = body.data as PaginatedData<RawDump>;
setFollowedUsersDumps({
status: "loaded",
dumps: items.map(deserializeDump),
hasMore,
page: 1,
loadingMore: false,
});
})
.catch((err) =>
setFollowedUsersDumps({
status: "error",
error: friendlyFetchError(err),
})
);
}
}
if (followedPlaylistsDumps.status === "loading") {
if (cachedFollowedPlaylists) {
setFollowedPlaylistsDumps({
status: "loaded",
dumps: cachedFollowedPlaylists.items,
hasMore: cachedFollowedPlaylists.hasMore,
page: cachedFollowedPlaylists.page,
loadingMore: false,
});
} else {
fetch(
`${API_URL}/api/follows/feed/playlists?page=1&limit=${DEFAULT_PAGE_SIZE}`,
{
headers: { Authorization: `Bearer ${token}` },
},
)
.then((r) => r.json())
.then((body) => {
const { items, hasMore } = body.data as PaginatedData<RawDump>;
setFollowedPlaylistsDumps({
status: "loaded",
dumps: items.map(deserializeDump),
hasMore,
page: 1,
loadingMore: false,
});
})
.catch((err) =>
setFollowedPlaylistsDumps({
status: "error",
error: friendlyFetchError(err),
})
);
}
}
}, [
tab,
user,
token,
cachedFollowedUsers,
cachedFollowedPlaylists,
followedUsersDumps.status,
followedPlaylistsDumps.status,
]);
// ── Load-more callbacks ──
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=${DEFAULT_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 loadMoreFollowedUsers = useCallback(() => {
if (
followedUsersDumps.status !== "loaded" ||
!followedUsersDumps.hasMore ||
followedUsersDumps.loadingMore ||
!token
) return;
const nextPage = followedUsersDumps.page + 1;
setFollowedUsersDumps((s) =>
s.status === "loaded" ? { ...s, loadingMore: true } : s
);
fetch(
`${API_URL}/api/follows/feed/users?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`,
{
headers: { Authorization: `Bearer ${token}` },
},
)
.then((r) => r.json())
.then((body) => {
const { items, hasMore } = body.data as PaginatedData<RawDump>;
setFollowedUsersDumps((s) =>
s.status === "loaded"
? {
...s,
dumps: [...s.dumps, ...items.map(deserializeDump)],
hasMore,
page: nextPage,
loadingMore: false,
}
: s
);
})
.catch(() =>
setFollowedUsersDumps((s) =>
s.status === "loaded" ? { ...s, loadingMore: false } : s
)
);
}, [followedUsersDumps, token]);
const loadMoreFollowedPlaylists = useCallback(() => {
if (
followedPlaylistsDumps.status !== "loaded" ||
!followedPlaylistsDumps.hasMore ||
followedPlaylistsDumps.loadingMore ||
!token
) return;
const nextPage = followedPlaylistsDumps.page + 1;
setFollowedPlaylistsDumps((s) =>
s.status === "loaded" ? { ...s, loadingMore: true } : s
);
fetch(
`${API_URL}/api/follows/feed/playlists?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`,
{
headers: { Authorization: `Bearer ${token}` },
},
)
.then((r) => r.json())
.then((body) => {
const { items, hasMore } = body.data as PaginatedData<RawDump>;
setFollowedPlaylistsDumps((s) =>
s.status === "loaded"
? {
...s,
dumps: [...s.dumps, ...items.map(deserializeDump)],
hasMore,
page: nextPage,
loadingMore: false,
}
: s
);
})
.catch(() =>
setFollowedPlaylistsDumps((s) =>
s.status === "loaded" ? { ...s, loadingMore: false } : s
)
);
}, [followedPlaylistsDumps, token]);
// ── Scroll save effects ──
const sentinelRef = useInfiniteScroll(
loadMore,
dumpsState.status === "loaded" && dumpsState.hasMore &&
!dumpsState.loadingMore,
);
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,
globalThis.scrollY,
);
}
}, 100);
};
globalThis.addEventListener("scroll", onScroll, { passive: true });
return () => {
globalThis.removeEventListener("scroll", onScroll);
clearTimeout(timer);
};
}, [dumpsState, saveState]);
useEffect(() => {
if (followedUsersDumps.status !== "loaded") return;
let timer: ReturnType<typeof setTimeout>;
const onScroll = () => {
clearTimeout(timer);
timer = setTimeout(() => {
if (followedUsersDumps.status === "loaded") {
saveFollowedUsers(
followedUsersDumps.dumps,
followedUsersDumps.page,
followedUsersDumps.hasMore,
globalThis.scrollY,
);
}
}, 100);
};
globalThis.addEventListener("scroll", onScroll, { passive: true });
return () => {
globalThis.removeEventListener("scroll", onScroll);
clearTimeout(timer);
};
}, [followedUsersDumps, saveFollowedUsers]);
useEffect(() => {
if (followedPlaylistsDumps.status !== "loaded") return;
let timer: ReturnType<typeof setTimeout>;
const onScroll = () => {
clearTimeout(timer);
timer = setTimeout(() => {
if (followedPlaylistsDumps.status === "loaded") {
saveFollowedPlaylists(
followedPlaylistsDumps.dumps,
followedPlaylistsDumps.page,
followedPlaylistsDumps.hasMore,
globalThis.scrollY,
);
}
}, 100);
};
globalThis.addEventListener("scroll", onScroll, { passive: true });
return () => {
globalThis.removeEventListener("scroll", onScroll);
clearTimeout(timer);
};
}, [followedPlaylistsDumps, saveFollowedPlaylists]);
// ── Scroll restoration ──
const scrollRestored = useRef(false);
useLayoutEffect(() => {
if (cached?.scrollY == null || scrollRestored.current) return;
if (dumpsState.status === "loaded") {
globalThis.scrollTo(0, cached.scrollY);
scrollRestored.current = true;
}
}, [dumpsState.status, cached]);
// ── Derived values ──
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 = useMemo(() => new Set(dumps.map((d) => d.id)), [dumps]);
const combined = [...recentDumps.filter((d) => !restIds.has(d.id)), ...dumps]
.filter((d) => !deletedDumpIds.has(d.id) && d.id !== justDeletedId);
const sortedDumps = [...combined].sort(
tab === "new"
? (a, b) => b.createdAt.getTime() - a.createdAt.getTime()
: (a, b) => hotScore(b) - hotScore(a),
);
// ── Render ──
const presenceRow = (
<div className="index-presence">
{onlineUsers.map((u) => (
<Link
key={u.userId}
to={`/users/${u.username}`}
title={u.username}
className="index-presence-avatar"
>
<Avatar
userId={u.userId}
username={u.username}
hasAvatar={u.hasAvatar}
size={32}
version={u.avatarVersion}
/>
</Link>
))}
</div>
);
const tabBar = (
<div className="feed-sort">
<button
type="button"
className={`feed-sort-btn${tab === "hot" ? " active" : ""}`}
onClick={() => setTab("hot")}
>
Hot
</button>
<button
type="button"
className={`feed-sort-btn${tab === "new" ? " active" : ""}`}
onClick={() => setTab("new")}
>
New
</button>
{user && (
<button
type="button"
className={`feed-sort-btn${tab === "followed" ? " active" : ""}`}
onClick={() => setTab("followed")}
>
Followed
</button>
)}
</div>
);
return (
<div className="index-page">
<AppHeader
centerSlot={
<div className="header-center-slot">
{presenceRow}
{tabBar}
</div>
}
disableNew={dumpsState.status === "error"}
/>
{/* Shown only on narrow viewports */}
<div className="index-below-header">
{tabBar}
{presenceRow}
</div>
{/* Hot / New feed */}
{tab !== "followed" && (
<>
{loading && <p className="index-status">Loading</p>}
{error && <ErrorCard title="Failed to load" message={error} />}
{!loading && !error && combined.length === 0 && (
<p className="index-status">No dumps yet. Be the first!</p>
)}
{!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}
isOwner={user?.id === dump.userId}
/>
))}
</ul>
)}
<div ref={sentinelRef} />
{loadingMore && <p className="feed-loading-more">Loading more</p>}
</>
)}
{/* Followed feed */}
{tab === "followed" && user && (
<div className="followed-feed">
<div className="feed-sort followed-sub-nav">
<button
type="button"
className={`feed-sort-btn${
followedSection === "users" ? " active" : ""
}`}
onClick={() => setFollowedSection("users")}
>
From people
</button>
<button
type="button"
className={`feed-sort-btn${
followedSection === "playlists" ? " active" : ""
}`}
onClick={() => setFollowedSection("playlists")}
>
From playlists
</button>
</div>
{followedSection === "users" && (
<FollowedSubFeed
state={followedUsersDumps}
voteCounts={voteCounts}
myVotes={myVotes}
user={user}
castVote={castVote}
removeVote={removeVote}
deletedDumpIds={deletedDumpIds}
emptyMessage="Follow some users to see their dumps here."
onLoadMore={loadMoreFollowedUsers}
/>
)}
{followedSection === "playlists" && (
<FollowedSubFeed
state={followedPlaylistsDumps}
voteCounts={voteCounts}
myVotes={myVotes}
user={user}
castVote={castVote}
removeVote={removeVote}
deletedDumpIds={deletedDumpIds}
emptyMessage="Follow some public playlists to see their dumps here."
onLoadMore={loadMoreFollowedPlaylists}
/>
)}
</div>
)}
</div>
);
}