review: lazy-load groups with infinite scroll, nest seasons
Build and Push Docker Image / build (push) Successful in 2m45s
Build and Push Docker Image / build (push) Successful in 2m45s
Client changes paired with the earlier /groups endpoint: - Types: drop review[]/reviewTotal from PipelineData, add ReviewGroup and ReviewGroupsResponse. - PipelinePage: parallel-fetch /pipeline and /groups?offset=0&limit=25. - ReviewColumn: IntersectionObserver on a sentinel div fetches the next page when it scrolls into view. No more "Showing first N of M" banner — the column loads lazily until hasMore is false. - SeriesCard: when a series has pending work in >1 season, render collapsible season sub-groups each with an "Approve season" button wired to POST /season/:key/:season/approve-all. Rename the series button from "Approve all" to "Approve series" for clarity. v2026.04.15.3 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,28 +1,57 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { api } from "~/shared/lib/api";
|
||||
import type { PipelineReviewItem } from "~/shared/lib/types";
|
||||
import type { ReviewGroup, ReviewGroupsResponse } from "~/shared/lib/types";
|
||||
import { ColumnShell } from "./ColumnShell";
|
||||
import { PipelineCard } from "./PipelineCard";
|
||||
import { SeriesCard } from "./SeriesCard";
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
interface ReviewColumnProps {
|
||||
items: PipelineReviewItem[];
|
||||
total: number;
|
||||
initialResponse: ReviewGroupsResponse;
|
||||
totalItems: number;
|
||||
jellyfinUrl: string;
|
||||
onMutate: () => void;
|
||||
}
|
||||
|
||||
interface SeriesGroup {
|
||||
name: string;
|
||||
key: string;
|
||||
jellyfinId: string | null;
|
||||
episodes: PipelineReviewItem[];
|
||||
}
|
||||
export function ReviewColumn({ initialResponse, totalItems, jellyfinUrl, onMutate }: ReviewColumnProps) {
|
||||
const [groups, setGroups] = useState<ReviewGroup[]>(initialResponse.groups);
|
||||
const [hasMore, setHasMore] = useState(initialResponse.hasMore);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
export function ReviewColumn({ items, total, jellyfinUrl, onMutate }: ReviewColumnProps) {
|
||||
const truncated = total > items.length;
|
||||
// Reset when the parent refetches page 0 (after approve/skip actions).
|
||||
useEffect(() => {
|
||||
setGroups(initialResponse.groups);
|
||||
setHasMore(initialResponse.hasMore);
|
||||
}, [initialResponse]);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (loadingMore || !hasMore) return;
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
const res = await api.get<ReviewGroupsResponse>(`/api/review/groups?offset=${groups.length}&limit=${PAGE_SIZE}`);
|
||||
setGroups((prev) => [...prev, ...res.groups]);
|
||||
setHasMore(res.hasMore);
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [groups.length, hasMore, loadingMore]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasMore || !sentinelRef.current) return;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting) loadMore();
|
||||
},
|
||||
{ rootMargin: "200px" },
|
||||
);
|
||||
observer.observe(sentinelRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, [hasMore, loadMore]);
|
||||
|
||||
const skipAll = async () => {
|
||||
if (!confirm(`Skip all ${total} pending items? They won't be processed unless you unskip them.`)) return;
|
||||
if (!confirm(`Skip all ${totalItems} pending items? They won't be processed unless you unskip them.`)) return;
|
||||
await api.post("/api/review/skip-all");
|
||||
onMutate();
|
||||
};
|
||||
@@ -47,89 +76,62 @@ export function ReviewColumn({ items, total, jellyfinUrl, onMutate }: ReviewColu
|
||||
onMutate();
|
||||
};
|
||||
|
||||
// Group by series (movies are standalone)
|
||||
const movies = items.filter((i) => i.type === "Movie");
|
||||
const seriesMap = new Map<string, SeriesGroup>();
|
||||
|
||||
for (const item of items.filter((i) => i.type === "Episode")) {
|
||||
const key = item.series_jellyfin_id ?? item.series_name ?? String(item.item_id);
|
||||
if (!seriesMap.has(key)) {
|
||||
seriesMap.set(key, { name: item.series_name ?? "", key, jellyfinId: item.series_jellyfin_id, episodes: [] });
|
||||
}
|
||||
seriesMap.get(key)!.episodes.push(item);
|
||||
}
|
||||
|
||||
// Interleave movies and series, sorted by confidence (high first)
|
||||
const allItems = [
|
||||
...movies.map((m) => ({ type: "movie" as const, item: m, sortKey: m.confidence === "high" ? 0 : 1 })),
|
||||
...[...seriesMap.values()].map((s) => ({
|
||||
type: "series" as const,
|
||||
item: s,
|
||||
sortKey: s.episodes.every((e) => e.confidence === "high") ? 0 : 1,
|
||||
})),
|
||||
].sort((a, b) => a.sortKey - b.sortKey);
|
||||
|
||||
// Flatten each visible entry to its list of item_ids. "Approve up to here"
|
||||
// on index i approves everything in the union of idsByEntry[0..i-1] — one
|
||||
// id for a movie, N ids for a series (one per episode).
|
||||
const idsByEntry: number[][] = allItems.map((entry) =>
|
||||
entry.type === "movie" ? [entry.item.item_id] : entry.item.episodes.map((e) => e.item_id),
|
||||
// Compute ids per visible group for "Approve above"
|
||||
const idsByGroup: number[][] = groups.map((g) =>
|
||||
g.kind === "movie" ? [g.item.item_id] : g.seasons.flatMap((s) => s.episodes.map((ep) => ep.item_id)),
|
||||
);
|
||||
const priorIds = (index: number): number[] => idsByEntry.slice(0, index).flat();
|
||||
const priorIds = (index: number): number[] => idsByGroup.slice(0, index).flat();
|
||||
|
||||
const actions =
|
||||
totalItems > 0
|
||||
? [
|
||||
{ label: "Auto Review", onClick: autoApprove, primary: true },
|
||||
{ label: "Skip all", onClick: skipAll },
|
||||
]
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<ColumnShell
|
||||
title="Review"
|
||||
count={truncated ? `${items.length} of ${total}` : total}
|
||||
actions={
|
||||
total > 0
|
||||
? [
|
||||
{ label: "Auto Review", onClick: autoApprove, primary: true },
|
||||
{ label: "Skip all", onClick: skipAll },
|
||||
]
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<ColumnShell title="Review" count={totalItems} actions={actions}>
|
||||
<div className="space-y-2">
|
||||
{allItems.map((entry, index) => {
|
||||
// The button approves everything visually above this card. First
|
||||
// card has nothing before it → undefined suppresses the affordance.
|
||||
{groups.map((group, index) => {
|
||||
const prior = index > 0 ? priorIds(index) : null;
|
||||
const onApproveUpToHere = prior && prior.length > 0 ? () => approveBatch(prior) : undefined;
|
||||
if (entry.type === "movie") {
|
||||
if (group.kind === "movie") {
|
||||
return (
|
||||
<PipelineCard
|
||||
key={entry.item.id}
|
||||
item={entry.item}
|
||||
key={group.item.id}
|
||||
item={group.item}
|
||||
jellyfinUrl={jellyfinUrl}
|
||||
onToggleStream={async (streamId, action) => {
|
||||
await api.patch(`/api/review/${entry.item.item_id}/stream/${streamId}`, { action });
|
||||
await api.patch(`/api/review/${group.item.item_id}/stream/${streamId}`, { action });
|
||||
onMutate();
|
||||
}}
|
||||
onApprove={() => approveItem(entry.item.item_id)}
|
||||
onSkip={() => skipItem(entry.item.item_id)}
|
||||
onApprove={() => approveItem(group.item.item_id)}
|
||||
onSkip={() => skipItem(group.item.item_id)}
|
||||
onApproveUpToHere={onApproveUpToHere}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<SeriesCard
|
||||
key={entry.item.key}
|
||||
seriesKey={entry.item.key}
|
||||
seriesName={entry.item.name}
|
||||
key={group.seriesKey}
|
||||
seriesKey={group.seriesKey}
|
||||
seriesName={group.seriesName}
|
||||
jellyfinUrl={jellyfinUrl}
|
||||
seriesJellyfinId={entry.item.jellyfinId}
|
||||
episodes={entry.item.episodes}
|
||||
seriesJellyfinId={group.seriesJellyfinId}
|
||||
seasons={group.seasons}
|
||||
episodeCount={group.episodeCount}
|
||||
originalLanguage={group.originalLanguage}
|
||||
onMutate={onMutate}
|
||||
onApproveUpToHere={onApproveUpToHere}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{allItems.length === 0 && <p className="text-sm text-gray-400 text-center py-8">No items to review</p>}
|
||||
{truncated && (
|
||||
<p className="text-xs text-gray-400 text-center py-3 border-t mt-2">
|
||||
Showing first {items.length} of {total}. Approve some to see the rest.
|
||||
</p>
|
||||
{groups.length === 0 && <p className="text-sm text-gray-400 text-center py-8">No items to review</p>}
|
||||
{hasMore && (
|
||||
<div ref={sentinelRef} className="py-4 text-center text-xs text-gray-400">
|
||||
{loadingMore ? "Loading more…" : ""}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ColumnShell>
|
||||
|
||||
Reference in New Issue
Block a user