Files
netfelix-audio-fix/src/features/pipeline/ReviewColumn.tsx
Felix Förtsch be6593094e
All checks were successful
Build and Push Docker Image / build (push) Successful in 2m45s
review: lazy-load groups with infinite scroll, nest seasons
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>
2026-04-15 12:13:28 +02:00

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>
);
}