v3: added journal view to the index, code organization pass

This commit is contained in:
khannurien
2026-03-25 21:07:17 +00:00
parent c293f3e706
commit 0cb5a798c7
16 changed files with 1025 additions and 420 deletions

View File

@@ -6,10 +6,9 @@ import {
useRef,
useState,
} from "react";
import { Link, useLocation } from "react-router";
import { Link, useLocation, useNavigate } 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";
@@ -20,10 +19,8 @@ import {
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 { useScrollSave } from "../hooks/useScrollSave.ts";
@@ -32,6 +29,12 @@ import { useWS } from "../hooks/useWS.ts";
import { useDumpListSync } from "../hooks/useDumpListSync.ts";
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
import { HotFeed } from "./index/HotFeed.tsx";
import { NewFeed } from "./index/NewFeed.tsx";
import { JournalFeed } from "./index/JournalFeed.tsx";
import { FollowedFeed } from "./index/FollowedFeed.tsx";
import type { MainFeedProps } from "./index/types.ts";
type DumpsState =
| { status: "loading" }
| { status: "error"; error: string }
@@ -43,82 +46,13 @@ type DumpsState =
loadingMore: boolean;
};
type FeedTab = "hot" | "new" | "followed";
type FollowedSection = "users" | "playlists";
type FeedTab = "hot" | "new" | "journal" | "followed";
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 ────────────────────────────────────────────────────────────────────
const VALID_TABS = new Set<string>(["hot", "new", "journal", "followed"]);
export function Index() {
const location = useLocation();
const navigate = useNavigate();
const justDeletedId = (location.state as { deletedDumpId?: string } | null)
?.deletedDumpId;
@@ -133,7 +67,8 @@ export function Index() {
removeVote,
} = useWS();
// Main feed
// ── Main feed state ──
const { cached, saveState } = useFeedCache<Dump>(
`feed:index:${user?.id ?? "guest"}`,
hydrateDump,
@@ -151,56 +86,12 @@ export function Index() {
);
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 rawTab = new URLSearchParams(location.search).get("tab") ?? "hot";
const tab: FeedTab = VALID_TABS.has(rawTab) ? rawTab as FeedTab : "hot";
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]);
function setTab(t: FeedTab) {
navigate(`/?tab=${t}`, { replace: true });
}
// ── Main feed fetch ──
@@ -229,10 +120,7 @@ export function Index() {
});
} catch (err) {
if ((err as Error).name === "AbortError") return;
setDumpsState({
status: "error",
error: friendlyFetchError(err),
});
setDumpsState({ status: "error", error: friendlyFetchError(err) });
}
})();
return () => {
@@ -241,93 +129,18 @@ export function Index() {
};
}, [cached, token]);
// ── Followed feeds fetch (lazy, on first tab open) ──
// ── WS sync for main feed ──
useEffect(() => {
if (tab !== "followed" || !user || !token) return;
const setDumpsItems = useCallback(
(fn: (prev: Dump[]) => Dump[]) =>
setDumpsState((s) =>
s.status !== "loaded" ? s : { ...s, dumps: fn(s.dumps) }
),
[],
);
useDumpListSync(setDumpsItems);
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 ──
// ── Load more ──
const loadMore = useCallback(() => {
if (
@@ -363,85 +176,7 @@ export function Index() {
);
}, [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 ──
// ── Scroll save / restore ──
const sentinelRef = useInfiniteScroll(
loadMore,
@@ -457,34 +192,6 @@ export function Index() {
}, [dumpsState, saveState]),
);
useScrollSave(
followedUsersDumps.status === "loaded",
useCallback((y) => {
if (followedUsersDumps.status !== "loaded") return;
saveFollowedUsers(
followedUsersDumps.dumps,
followedUsersDumps.page,
followedUsersDumps.hasMore,
y,
);
}, [followedUsersDumps, saveFollowedUsers]),
);
useScrollSave(
followedPlaylistsDumps.status === "loaded",
useCallback((y) => {
if (followedPlaylistsDumps.status !== "loaded") return;
saveFollowedPlaylists(
followedPlaylistsDumps.dumps,
followedPlaylistsDumps.page,
followedPlaylistsDumps.hasMore,
y,
);
}, [followedPlaylistsDumps, saveFollowedPlaylists]),
);
// ── Scroll restoration ──
const scrollRestored = useRef(false);
useLayoutEffect(() => {
if (cached?.scrollY == null || scrollRestored.current) return;
@@ -498,19 +205,36 @@ export function Index() {
const loading = dumpsState.status === "loading";
const error = dumpsState.status === "error" ? dumpsState.error : null;
const dumps = dumpsState.status === "loaded" ? dumpsState.dumps : [];
const dumps = useMemo(
() => dumpsState.status === "loaded" ? dumpsState.dumps : [],
[dumpsState],
);
const loadingMore = dumpsState.status === "loaded" && dumpsState.loadingMore;
const hasMore = dumpsState.status === "loaded" ? dumpsState.hasMore : false;
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),
const combined = useMemo(
() =>
[...recentDumps.filter((d) => !restIds.has(d.id)), ...dumps].filter(
(d) => !deletedDumpIds.has(d.id) && d.id !== justDeletedId,
),
[recentDumps, restIds, dumps, deletedDumpIds, justDeletedId],
);
const mainFeedProps: MainFeedProps = {
dumps: combined,
loading,
error,
hasMore,
loadingMore,
sentinelRef,
voteCounts,
myVotes,
user,
castVote,
removeVote,
};
// ── Render ──
const presenceRow = (
@@ -550,6 +274,13 @@ export function Index() {
>
New
</button>
<button
type="button"
className={`feed-sort-btn${tab === "journal" ? " active" : ""}`}
onClick={() => setTab("journal")}
>
Journal
</button>
{user && (
<button
type="button"
@@ -574,96 +305,23 @@ export function Index() {
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 === "hot" && <HotFeed {...mainFeedProps} />}
{tab === "new" && <NewFeed {...mainFeedProps} />}
{tab === "journal" && <JournalFeed {...mainFeedProps} />}
{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>
<FollowedFeed
voteCounts={voteCounts}
myVotes={myVotes}
user={user}
castVote={castVote}
removeVote={removeVote}
deletedDumpIds={deletedDumpIds}
/>
)}
</div>
);