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

@@ -0,0 +1,365 @@
import { useCallback, useEffect, useState } from "react";
import { DumpCard } from "../../components/DumpCard.tsx";
import { ErrorCard } from "../../components/ErrorCard.tsx";
import { API_URL, DEFAULT_PAGE_SIZE } from "../../config/api.ts";
import { useAuth } from "../../hooks/useAuth.ts";
import { useDumpListSync } from "../../hooks/useDumpListSync.ts";
import { useFeedCache } from "../../hooks/useFeedCache.ts";
import { useInfiniteScroll } from "../../hooks/useInfiniteScroll.ts";
import { useScrollSave } from "../../hooks/useScrollSave.ts";
import {
deserializeDump,
type Dump,
hydrateDump,
type PaginatedData,
type RawDump,
type User,
} from "../../model.ts";
import { friendlyFetchError } from "../../utils/apiError.ts";
type FeedState =
| { status: "loading" }
| { status: "error"; error: string }
| {
status: "loaded";
dumps: Dump[];
hasMore: boolean;
page: number;
loadingMore: boolean;
};
type FollowedSection = "users" | "playlists";
interface FollowedFeedProps {
voteCounts: Record<string, number>;
myVotes: Set<string>;
user: User;
castVote: (id: string) => void;
removeVote: (id: string) => void;
deletedDumpIds: Set<string>;
}
// ── FollowedSubFeed ───────────────────────────────────────────────────────────
interface FollowedSubFeedProps {
state: FeedState;
voteCounts: Record<string, number>;
myVotes: Set<string>;
user: User;
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 enabled = state.status === "loaded" && state.hasMore &&
!state.loadingMore;
const sentinelRef = useInfiniteScroll(onLoadMore, enabled);
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={true}
castVote={castVote}
removeVote={removeVote}
isOwner={user.id === dump.userId}
/>
))}
</ul>
<div ref={sentinelRef} />
{state.loadingMore && <p className="feed-loading-more">Loading more</p>}
</>
);
}
// ── FollowedFeed ──────────────────────────────────────────────────────────────
export function FollowedFeed({
voteCounts,
myVotes,
user,
castVote,
removeVote,
deletedDumpIds,
}: FollowedFeedProps) {
const { token } = useAuth();
const { cached: cachedUsers, saveState: saveUsers } = useFeedCache<Dump>(
`feed:followed-users:${user.id}`,
hydrateDump,
);
const { cached: cachedPlaylists, saveState: savePlaylists } = useFeedCache<
Dump
>(
`feed:followed-playlists:${user.id}`,
hydrateDump,
);
const [usersState, setUsersState] = useState<FeedState>(() =>
cachedUsers
? {
status: "loaded",
dumps: cachedUsers.items,
hasMore: cachedUsers.hasMore,
page: cachedUsers.page,
loadingMore: false,
}
: { status: "loading" }
);
const [playlistsState, setPlaylistsState] = useState<FeedState>(() =>
cachedPlaylists
? {
status: "loaded",
dumps: cachedPlaylists.items,
hasMore: cachedPlaylists.hasMore,
page: cachedPlaylists.page,
loadingMore: false,
}
: { status: "loading" }
);
const [section, setSection] = useState<FollowedSection>("users");
// WS sync
const setUsersItems = useCallback(
(fn: (prev: Dump[]) => Dump[]) =>
setUsersState((s) =>
s.status !== "loaded" ? s : { ...s, dumps: fn(s.dumps) }
),
[],
);
useDumpListSync(setUsersItems);
const setPlaylistsItems = useCallback(
(fn: (prev: Dump[]) => Dump[]) =>
setPlaylistsState((s) =>
s.status !== "loaded" ? s : { ...s, dumps: fn(s.dumps) }
),
[],
);
useDumpListSync(setPlaylistsItems);
// Fetch on mount if not cached
useEffect(() => {
if (!token) return;
if (usersState.status === "loading") {
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>;
setUsersState({
status: "loaded",
dumps: items.map(deserializeDump),
hasMore,
page: 1,
loadingMore: false,
});
})
.catch((err) =>
setUsersState({ status: "error", error: friendlyFetchError(err) })
);
}
if (playlistsState.status === "loading") {
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>;
setPlaylistsState({
status: "loaded",
dumps: items.map(deserializeDump),
hasMore,
page: 1,
loadingMore: false,
});
})
.catch((err) =>
setPlaylistsState({
status: "error",
error: friendlyFetchError(err),
})
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
// Scroll save
useScrollSave(
usersState.status === "loaded",
useCallback((y) => {
if (usersState.status !== "loaded") return;
saveUsers(usersState.dumps, usersState.page, usersState.hasMore, y);
}, [usersState, saveUsers]),
);
useScrollSave(
playlistsState.status === "loaded",
useCallback((y) => {
if (playlistsState.status !== "loaded") return;
savePlaylists(
playlistsState.dumps,
playlistsState.page,
playlistsState.hasMore,
y,
);
}, [playlistsState, savePlaylists]),
);
// Load-more callbacks
const loadMoreUsers = useCallback(() => {
if (
usersState.status !== "loaded" || !usersState.hasMore ||
usersState.loadingMore || !token
) return;
const nextPage = usersState.page + 1;
setUsersState((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>;
setUsersState((s) =>
s.status === "loaded"
? {
...s,
dumps: [...s.dumps, ...items.map(deserializeDump)],
hasMore,
page: nextPage,
loadingMore: false,
}
: s
);
})
.catch(() =>
setUsersState((s) =>
s.status === "loaded" ? { ...s, loadingMore: false } : s
)
);
}, [usersState, token]);
const loadMorePlaylists = useCallback(() => {
if (
playlistsState.status !== "loaded" || !playlistsState.hasMore ||
playlistsState.loadingMore || !token
) return;
const nextPage = playlistsState.page + 1;
setPlaylistsState((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>;
setPlaylistsState((s) =>
s.status === "loaded"
? {
...s,
dumps: [...s.dumps, ...items.map(deserializeDump)],
hasMore,
page: nextPage,
loadingMore: false,
}
: s
);
})
.catch(() =>
setPlaylistsState((s) =>
s.status === "loaded" ? { ...s, loadingMore: false } : s
)
);
}, [playlistsState, token]);
return (
<div className="followed-feed">
<div className="feed-sort followed-sub-nav">
<button
type="button"
className={`feed-sort-btn${section === "users" ? " active" : ""}`}
onClick={() => setSection("users")}
>
From people
</button>
<button
type="button"
className={`feed-sort-btn${
section === "playlists" ? " active" : ""
}`}
onClick={() => setSection("playlists")}
>
From playlists
</button>
</div>
{section === "users" && (
<FollowedSubFeed
state={usersState}
voteCounts={voteCounts}
myVotes={myVotes}
user={user}
castVote={castVote}
removeVote={removeVote}
deletedDumpIds={deletedDumpIds}
emptyMessage="Follow some users to see their dumps here."
onLoadMore={loadMoreUsers}
/>
)}
{section === "playlists" && (
<FollowedSubFeed
state={playlistsState}
voteCounts={voteCounts}
myVotes={myVotes}
user={user}
castVote={castVote}
removeVote={removeVote}
deletedDumpIds={deletedDumpIds}
emptyMessage="Follow some public playlists to see their dumps here."
onLoadMore={loadMorePlaylists}
/>
)}
</div>
);
}