676 lines
19 KiB
TypeScript
676 lines
19 KiB
TypeScript
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,
|
|
type User,
|
|
} 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 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 <p className="index-status index-status--error">{state.error}</p>;
|
|
}
|
|
|
|
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 [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;
|
|
(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<RawDump>;
|
|
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",
|
|
});
|
|
}
|
|
})();
|
|
}, [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=${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: err instanceof Error ? err.message : "Failed to load",
|
|
})
|
|
);
|
|
}
|
|
}
|
|
|
|
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=${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: err instanceof Error ? err.message : "Failed to load",
|
|
})
|
|
);
|
|
}
|
|
}
|
|
}, [
|
|
tab,
|
|
user?.id,
|
|
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=${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=${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=${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 = 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(
|
|
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}
|
|
/>
|
|
</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>
|
|
}
|
|
/>
|
|
|
|
{/* 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 && <p className="index-status index-status--error">{error}</p>}
|
|
|
|
{!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>
|
|
);
|
|
}
|