mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-10-15 19:45:42 -04:00
fix(web): load more titles in Explore rows and show status in Discover
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
6 files changed
+176
-49
No files matched your search
@@ -11,6 +11,7 @@ interface TitleGridItem {
|
||||
firstAirDate?: string | null;
|
||||
voteAverage?: number | null;
|
||||
userStatus?: "in_watchlist" | "watching" | "caught_up" | "completed" | null;
|
||||
episodeProgress?: { watched: number; total: number } | null;
|
||||
}
|
||||
|
||||
export function TitleGridSectionSkeleton() {
|
||||
@@ -49,6 +50,7 @@ export function TitleGrid({ items }: { items: TitleGridItem[] }) {
|
||||
releaseDate={t.releaseDate ?? t.firstAirDate}
|
||||
voteAverage={t.voteAverage}
|
||||
userStatus={t.userStatus}
|
||||
episodeProgress={t.episodeProgress}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -97,7 +97,25 @@ export function DiscoverSection() {
|
||||
isFetchingNextPage,
|
||||
});
|
||||
|
||||
const items = useMemo(() => data?.pages.flatMap((p) => p.items) ?? [], [data?.pages]);
|
||||
const items = useMemo(() => {
|
||||
const pages = data?.pages ?? [];
|
||||
const statuses = Object.assign({}, ...pages.map((p) => p.userStatuses)) as Record<
|
||||
string,
|
||||
"in_watchlist" | "watching" | "caught_up" | "completed"
|
||||
>;
|
||||
const progress = Object.assign({}, ...pages.map((p) => p.episodeProgress)) as Record<
|
||||
string,
|
||||
{ watched: number; total: number }
|
||||
>;
|
||||
return pages
|
||||
.flatMap((p) => p.items)
|
||||
.map((item) =>
|
||||
Object.assign({}, item, {
|
||||
userStatus: statuses[item.id] ?? null,
|
||||
episodeProgress: progress[item.id] ?? null,
|
||||
}),
|
||||
);
|
||||
}, [data?.pages]);
|
||||
|
||||
const genres = genreData?.genres ?? [];
|
||||
const providers = providerData?.platforms ?? [];
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useMemo, useRef, useState } from "react";
|
||||
import { TitleCard, TitleCardSkeleton } from "@/components/title-card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { hasReachedHorizontalEnd } from "@/hooks/use-infinite-scroll";
|
||||
import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
|
||||
import { orpc } from "@/lib/orpc/client";
|
||||
|
||||
interface Genre {
|
||||
@@ -92,6 +92,14 @@ export function FilterableTitleRow({
|
||||
[discoverData?.pages],
|
||||
);
|
||||
|
||||
const sentinelRef = useInfiniteScroll({
|
||||
fetchNextPage,
|
||||
hasNextPage: selectedGenre !== null && hasNextPage,
|
||||
isFetchingNextPage,
|
||||
rootRef: scrollRef,
|
||||
rootMargin: "0px 400px 0px 0px",
|
||||
});
|
||||
|
||||
const isLoading = selectedGenre !== null && isPending;
|
||||
const items = selectedGenre === null ? defaultItems : discoverItems;
|
||||
const userStatuses = selectedGenre === null ? initialStatuses : discoverStatuses;
|
||||
@@ -155,21 +163,6 @@ export function FilterableTitleRow({
|
||||
hideScrollbar
|
||||
className="-mx-6 sm:-mx-2"
|
||||
scrollRef={scrollRef}
|
||||
onScrollEnd={() => {
|
||||
const viewport = scrollRef.current;
|
||||
|
||||
if (
|
||||
selectedGenre === null ||
|
||||
!viewport ||
|
||||
!hasNextPage ||
|
||||
isFetchingNextPage ||
|
||||
!hasReachedHorizontalEnd(viewport)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetchNextPage();
|
||||
}}
|
||||
>
|
||||
<div className="flex gap-4 px-6 py-2 sm:px-2">
|
||||
{items.map((item: TitleRowItem, i: number) => (
|
||||
@@ -197,6 +190,7 @@ export function FilterableTitleRow({
|
||||
<div className="border-primary size-5 animate-spin rounded-full border-2 border-t-transparent" />
|
||||
</div>
|
||||
)}
|
||||
<div ref={sentinelRef} className="w-px shrink-0" aria-hidden />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
|
||||
@@ -2,7 +2,9 @@ import { useRef } from "react";
|
||||
|
||||
import { TitleCard } from "@/components/title-card";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { hasReachedHorizontalEnd } from "@/hooks/use-infinite-scroll";
|
||||
import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
interface TitleRowItem {
|
||||
id: string;
|
||||
@@ -37,6 +39,13 @@ export function TitleRow({
|
||||
isFetchingNextPage = false,
|
||||
}: TitleRowProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const sentinelRef = useInfiniteScroll({
|
||||
fetchNextPage: onEndReached ?? noop,
|
||||
hasNextPage: !!onEndReached && hasNextPage,
|
||||
isFetchingNextPage,
|
||||
rootRef: scrollRef,
|
||||
rootMargin: "0px 400px 0px 0px",
|
||||
});
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
@@ -46,27 +55,7 @@ export function TitleRow({
|
||||
{icon}
|
||||
<h2 className="font-display text-xl tracking-tight text-balance">{heading}</h2>
|
||||
</div>
|
||||
<ScrollArea
|
||||
scrollFade
|
||||
hideScrollbar
|
||||
className="-mx-6 sm:-mx-2"
|
||||
scrollRef={scrollRef}
|
||||
onScrollEnd={() => {
|
||||
const viewport = scrollRef.current;
|
||||
|
||||
if (
|
||||
!viewport ||
|
||||
!onEndReached ||
|
||||
!hasNextPage ||
|
||||
isFetchingNextPage ||
|
||||
!hasReachedHorizontalEnd(viewport)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
onEndReached();
|
||||
}}
|
||||
>
|
||||
<ScrollArea scrollFade hideScrollbar className="-mx-6 sm:-mx-2" scrollRef={scrollRef}>
|
||||
<div className="flex gap-4 px-6 py-2 sm:px-2">
|
||||
{items.map((item, i) => (
|
||||
<div key={item.id} className="w-[140px] shrink-0 sm:w-[160px]">
|
||||
@@ -93,6 +82,7 @@ export function TitleRow({
|
||||
<div className="border-primary size-5 animate-spin rounded-full border-2 border-t-transparent" />
|
||||
</div>
|
||||
)}
|
||||
<div ref={sentinelRef} className="w-px shrink-0" aria-hidden />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { act, useRef } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { useInfiniteScroll } from "./use-infinite-scroll";
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function HorizontalRow({
|
||||
fetchNextPage,
|
||||
hasNextPage = true,
|
||||
isFetchingNextPage = false,
|
||||
rootMargin = "0px",
|
||||
}: {
|
||||
fetchNextPage: () => void;
|
||||
hasNextPage?: boolean;
|
||||
isFetchingNextPage?: boolean;
|
||||
rootMargin?: string;
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const sentinelRef = useInfiniteScroll({
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
rootRef: scrollRef,
|
||||
rootMargin,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
data-testid="scroller"
|
||||
style={{ width: 200, height: 50, overflowX: "auto", overflowY: "hidden" }}
|
||||
>
|
||||
<div style={{ display: "flex", width: 2000, height: 40 }}>
|
||||
<div style={{ flex: "1 1 auto" }} />
|
||||
<div ref={sentinelRef} style={{ width: 1, flexShrink: 0 }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let root: Root | null = null;
|
||||
let container: HTMLDivElement | null = null;
|
||||
|
||||
function mount(ui: React.ReactElement) {
|
||||
// The host stays inside the document viewport, so only the scroll container clips the sentinel.
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
act(() => {
|
||||
root?.render(ui);
|
||||
});
|
||||
return container;
|
||||
}
|
||||
|
||||
function nextFrames(count = 3) {
|
||||
return act(async () => {
|
||||
for (let i = 0; i < count; i++) {
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root?.unmount();
|
||||
});
|
||||
container?.remove();
|
||||
root = null;
|
||||
container = null;
|
||||
});
|
||||
|
||||
describe("useInfiniteScroll with rootRef", () => {
|
||||
test("does not fetch while the sentinel is scrolled out of the container", async () => {
|
||||
const fetchNextPage = vi.fn<() => void>();
|
||||
mount(<HorizontalRow fetchNextPage={fetchNextPage} />);
|
||||
|
||||
await nextFrames();
|
||||
|
||||
expect(fetchNextPage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("fetches once the container is scrolled to the sentinel", async () => {
|
||||
const fetchNextPage = vi.fn<() => void>();
|
||||
const host = mount(<HorizontalRow fetchNextPage={fetchNextPage} />);
|
||||
const scroller = host.querySelector<HTMLElement>("[data-testid=scroller]");
|
||||
expect(scroller).not.toBeNull();
|
||||
|
||||
await nextFrames();
|
||||
expect(fetchNextPage).not.toHaveBeenCalled();
|
||||
|
||||
scroller!.scrollLeft = scroller!.scrollWidth;
|
||||
|
||||
await vi.waitFor(() => expect(fetchNextPage).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
test("applies rootMargin relative to the scroll container, not the viewport", async () => {
|
||||
const fetchNextPage = vi.fn<() => void>();
|
||||
const host = mount(
|
||||
<HorizontalRow fetchNextPage={fetchNextPage} rootMargin="0px 100px 0px 0px" />,
|
||||
);
|
||||
const scroller = host.querySelector<HTMLElement>("[data-testid=scroller]");
|
||||
expect(scroller).not.toBeNull();
|
||||
|
||||
await nextFrames();
|
||||
expect(fetchNextPage).not.toHaveBeenCalled();
|
||||
|
||||
// The container shows x = 1750..1950 and the sentinel sits at x = 1999: still clipped,
|
||||
// but within the 100px margin. Only an observer rooted at the container fires here.
|
||||
scroller!.scrollLeft = 1750;
|
||||
|
||||
await vi.waitFor(() => expect(fetchNextPage).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
test("does not fetch when there is no next page", async () => {
|
||||
const fetchNextPage = vi.fn<() => void>();
|
||||
const host = mount(<HorizontalRow fetchNextPage={fetchNextPage} hasNextPage={false} />);
|
||||
const scroller = host.querySelector<HTMLElement>("[data-testid=scroller]");
|
||||
|
||||
scroller!.scrollLeft = scroller!.scrollWidth;
|
||||
await nextFrames();
|
||||
|
||||
expect(fetchNextPage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,21 +1,18 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export function hasReachedHorizontalEnd(element: HTMLElement, threshold = 24) {
|
||||
if (element.scrollWidth <= element.clientWidth) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return element.scrollLeft + element.clientWidth >= element.scrollWidth - threshold;
|
||||
}
|
||||
|
||||
export function useInfiniteScroll({
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
rootRef,
|
||||
rootMargin = "200px",
|
||||
}: {
|
||||
fetchNextPage: () => void;
|
||||
hasNextPage: boolean;
|
||||
isFetchingNextPage: boolean;
|
||||
/** Scroll container to observe within. Defaults to the document viewport. */
|
||||
rootRef?: React.RefObject<HTMLElement | null>;
|
||||
rootMargin?: string;
|
||||
}) {
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -29,12 +26,12 @@ export function useInfiniteScroll({
|
||||
fetchNextPage();
|
||||
}
|
||||
},
|
||||
{ rootMargin: "200px" },
|
||||
{ root: rootRef?.current ?? null, rootMargin },
|
||||
);
|
||||
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage, rootRef, rootMargin]);
|
||||
|
||||
return sentinelRef;
|
||||
}
|
||||
Reference in new issue
Block a user