All checks were successful
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>
140 lines
4.5 KiB
TypeScript
140 lines
4.5 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { api } from "~/shared/lib/api";
|
|
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 {
|
|
initialResponse: ReviewGroupsResponse;
|
|
totalItems: number;
|
|
jellyfinUrl: string;
|
|
onMutate: () => void;
|
|
}
|
|
|
|
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);
|
|
|
|
// 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 ${totalItems} pending items? They won't be processed unless you unskip them.`)) return;
|
|
await api.post("/api/review/skip-all");
|
|
onMutate();
|
|
};
|
|
|
|
const autoApprove = async () => {
|
|
const res = await api.post<{ ok: boolean; count: number }>("/api/review/auto-approve");
|
|
onMutate();
|
|
if (res.count === 0) alert("No high-confidence items to auto-approve.");
|
|
};
|
|
|
|
const approveItem = async (itemId: number) => {
|
|
await api.post(`/api/review/${itemId}/approve`);
|
|
onMutate();
|
|
};
|
|
const skipItem = async (itemId: number) => {
|
|
await api.post(`/api/review/${itemId}/skip`);
|
|
onMutate();
|
|
};
|
|
const approveBatch = async (itemIds: number[]) => {
|
|
if (itemIds.length === 0) return;
|
|
await api.post<{ ok: boolean; count: number }>("/api/review/approve-batch", { itemIds });
|
|
onMutate();
|
|
};
|
|
|
|
// 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[] => 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={totalItems} actions={actions}>
|
|
<div className="space-y-2">
|
|
{groups.map((group, index) => {
|
|
const prior = index > 0 ? priorIds(index) : null;
|
|
const onApproveUpToHere = prior && prior.length > 0 ? () => approveBatch(prior) : undefined;
|
|
if (group.kind === "movie") {
|
|
return (
|
|
<PipelineCard
|
|
key={group.item.id}
|
|
item={group.item}
|
|
jellyfinUrl={jellyfinUrl}
|
|
onToggleStream={async (streamId, action) => {
|
|
await api.patch(`/api/review/${group.item.item_id}/stream/${streamId}`, { action });
|
|
onMutate();
|
|
}}
|
|
onApprove={() => approveItem(group.item.item_id)}
|
|
onSkip={() => skipItem(group.item.item_id)}
|
|
onApproveUpToHere={onApproveUpToHere}
|
|
/>
|
|
);
|
|
}
|
|
return (
|
|
<SeriesCard
|
|
key={group.seriesKey}
|
|
seriesKey={group.seriesKey}
|
|
seriesName={group.seriesName}
|
|
jellyfinUrl={jellyfinUrl}
|
|
seriesJellyfinId={group.seriesJellyfinId}
|
|
seasons={group.seasons}
|
|
episodeCount={group.episodeCount}
|
|
originalLanguage={group.originalLanguage}
|
|
onMutate={onMutate}
|
|
onApproveUpToHere={onApproveUpToHere}
|
|
/>
|
|
);
|
|
})}
|
|
{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>
|
|
);
|
|
}
|