2 Commits

8 changed files with 38 additions and 36 deletions

View File

@@ -262,6 +262,7 @@ export function TicketAssignmentsCard({
{videoId ? ( {videoId ? (
<div className={'mt-4 border-t pt-4'}> <div className={'mt-4 border-t pt-4'}>
<TicketDetectionPreview <TicketDetectionPreview
key={`${videoId}:${selectedAssignment.id}`}
ticketId={ticketId} ticketId={ticketId}
videoId={videoId} videoId={videoId}
assignmentId={selectedAssignment.id} assignmentId={selectedAssignment.id}

View File

@@ -24,6 +24,8 @@ interface TicketDetectionPreviewProps {
onDetectionCountChange?: (count: number | undefined) => void; onDetectionCountChange?: (count: number | undefined) => void;
} }
const DETECTION_PAGE_SIZE = 10;
function DetectionMetadataBar({ function DetectionMetadataBar({
items, items,
}: { }: {
@@ -102,6 +104,8 @@ export function TicketDetectionPreview({
const [currentIndex, setCurrentIndex] = useState(0); const [currentIndex, setCurrentIndex] = useState(0);
const [isDiscardDialogOpen, setIsDiscardDialogOpen] = useState(false); const [isDiscardDialogOpen, setIsDiscardDialogOpen] = useState(false);
const isUnassignedPreview = assignmentStatus === 'unassigned'; const isUnassignedPreview = assignmentStatus === 'unassigned';
const pageStart =
Math.floor(currentIndex / DETECTION_PAGE_SIZE) * DETECTION_PAGE_SIZE;
useEffect(() => { useEffect(() => {
setCurrentIndex(0); setCurrentIndex(0);
@@ -109,23 +113,26 @@ export function TicketDetectionPreview({
const queryParams = useMemo( const queryParams = useMemo(
() => ({ () => ({
skip: currentIndex, skip: pageStart,
limit: 1, limit: DETECTION_PAGE_SIZE,
assignment_id: isUnassignedPreview ? undefined : assignmentId, assignment_id: isUnassignedPreview ? undefined : assignmentId,
assignment_status: isUnassignedPreview assignment_status: isUnassignedPreview
? ('unassigned' as const) ? ('unassigned' as const)
: undefined, : undefined,
sort: 'timestamp_asc', sort: 'timestamp_asc',
}), }),
[assignmentId, currentIndex, isUnassignedPreview], [assignmentId, isUnassignedPreview, pageStart],
); );
const detectionsQuery = useTicketDetectionsQuery( const detectionsQuery = useTicketDetectionsQuery(
videoId ?? undefined, videoId ?? undefined,
queryParams, queryParams,
{ keepPreviousPage: true },
); );
const detectionResult = detectionsQuery.data; const detectionResult = detectionsQuery.data;
const activeDetection = detectionResult?.items[0]; const activeDetection = detectionsQuery.isPlaceholderData
? undefined
: detectionResult?.items[currentIndex - pageStart];
const confirmedDetectionCount = detectionResult?.total; const confirmedDetectionCount = detectionResult?.total;
const detectionCount = confirmedDetectionCount ?? 0; const detectionCount = confirmedDetectionCount ?? 0;
const activeVisual = getDefectVisual( const activeVisual = getDefectVisual(
@@ -156,12 +163,15 @@ export function TicketDetectionPreview({
}, [confirmedDetectionCount, currentIndex]); }, [confirmedDetectionCount, currentIndex]);
useEffect(() => { useEffect(() => {
onDetectionCountChange?.(confirmedDetectionCount); onDetectionCountChange?.(detectionCount);
}, [confirmedDetectionCount, onDetectionCountChange]); }, [detectionCount, onDetectionCountChange]);
const isNavigationDisabled = const isNavigationDisabled =
detectionCount === 0 || detectionsQuery.isLoading; detectionCount === 0 ||
const isInitialLoading = detectionsQuery.isLoading; detectionsQuery.isLoading ||
detectionsQuery.isPlaceholderData;
const isInitialLoading =
detectionsQuery.isLoading || detectionsQuery.isPlaceholderData;
const isError = detectionsQuery.isError; const isError = detectionsQuery.isError;
const retry = () => void detectionsQuery.refetch(); const retry = () => void detectionsQuery.refetch();
@@ -221,7 +231,7 @@ export function TicketDetectionPreview({
</div> </div>
</div> </div>
{isInitialLoading && !activeDetection ? ( {isInitialLoading ? (
<TicketDetectionPreviewSkeleton /> <TicketDetectionPreviewSkeleton />
) : isError ? ( ) : isError ? (
<div className="rounded-lg border border-dashed border-destructive/40 bg-destructive/5 px-4 py-8 text-center"> <div className="rounded-lg border border-dashed border-destructive/40 bg-destructive/5 px-4 py-8 text-center">

View File

@@ -22,6 +22,7 @@ export function TicketDetectionPreviewCard({
<CardContent className="p-5"> <CardContent className="p-5">
<div className="min-w-0"> <div className="min-w-0">
<TicketDetectionPreview <TicketDetectionPreview
key={`${videoId}:${assignmentId}`}
ticketId={ticketId} ticketId={ticketId}
videoId={videoId} videoId={videoId}
assignmentId={assignmentId} assignmentId={assignmentId}

View File

@@ -41,12 +41,13 @@ export function TicketUnassignedIssuesCard({
results. results.
</CardDescription> </CardDescription>
</div> </div>
<Badge variant={'default'}>{detectionCount ?? 0} Unassigned</Badge> <Badge variant={'default'}>{detectionCount ?? 0} Unassigned</Badge>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className={'p-5'}> <CardContent className={'p-5'}>
<div className={'min-w-0'}> <div className={'min-w-0'}>
<TicketDetectionPreview <TicketDetectionPreview
key={`${videoId}:unassigned`}
ticketId={ticketId} ticketId={ticketId}
videoId={videoId} videoId={videoId}
assignmentStatus={'unassigned'} assignmentStatus={'unassigned'}

View File

@@ -176,13 +176,9 @@ export function IssueReviewAction({
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<Button <Button
type="button" type="button"
variant="outline" variant="default"
disabled={isPending} disabled={isPending}
onClick={() => void submitDecision('approve')} onClick={() => void submitDecision('approve')}
className={cn(
decision === 'approve' &&
'border-emerald-500 bg-emerald-500/10 text-emerald-700 hover:bg-emerald-500/15 dark:text-emerald-300',
)}
> >
{isPending && decision === 'approve' ? ( {isPending && decision === 'approve' ? (
<Loader2 className="animate-spin" /> <Loader2 className="animate-spin" />
@@ -193,13 +189,9 @@ export function IssueReviewAction({
</Button> </Button>
<Button <Button
type="button" type="button"
variant="outline" variant="destructive"
disabled={isPending} disabled={isPending}
onClick={() => void submitDecision('reject')} onClick={() => void submitDecision('reject')}
className={cn(
decision === 'reject' &&
'border-destructive bg-destructive/10 text-destructive hover:bg-destructive/15',
)}
> >
{isPending && decision === 'reject' ? ( {isPending && decision === 'reject' ? (
<Loader2 className="animate-spin" /> <Loader2 className="animate-spin" />

View File

@@ -2,6 +2,7 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { import {
keepPreviousData,
useInfiniteQuery, useInfiniteQuery,
useMutation, useMutation,
useQuery, useQuery,
@@ -28,6 +29,11 @@ import { extensionRequestKeys } from '../../extension-requests/queries/extension
const TICKET_TABLE_REFRESH_MS = 5 * 60 * 1000; const TICKET_TABLE_REFRESH_MS = 5 * 60 * 1000;
interface TicketDetectionsQueryOptions {
enabled?: boolean;
keepPreviousPage?: boolean;
}
interface UseTicketsQueryParams { interface UseTicketsQueryParams {
skip: number; skip: number;
limit: number; limit: number;
@@ -113,8 +119,9 @@ export function useTicketAssignmentsQuery(
export function useTicketDetectionsQuery( export function useTicketDetectionsQuery(
videoId: string | undefined, videoId: string | undefined,
params: VideoDetectionsParams, params: VideoDetectionsParams,
enabled = true, options: TicketDetectionsQueryOptions = {},
) { ) {
const { enabled = true, keepPreviousPage = false } = options;
const queryParams = useMemo( const queryParams = useMemo(
() => ({ () => ({
skip: params.skip ?? 0, skip: params.skip ?? 0,
@@ -143,6 +150,7 @@ export function useTicketDetectionsQuery(
queryFn: () => queryFn: () =>
videoService.getVideoDetections(videoId as string, queryParams), videoService.getVideoDetections(videoId as string, queryParams),
enabled: Boolean(enabled && videoId), enabled: Boolean(enabled && videoId),
placeholderData: keepPreviousPage ? keepPreviousData : undefined,
staleTime: Infinity, staleTime: Infinity,
}); });
} }

View File

@@ -16,7 +16,7 @@ import { getPinnedColumnStyles } from './columnStyles';
const TableHeader = <TData, _>({ table }: { table: Table<TData> }) => { const TableHeader = <TData, _>({ table }: { table: Table<TData> }) => {
return ( return (
<ShadTableHeader className="sticky top-0 z-20 bg-muted/70 backdrop-blur supports-backdrop-filter:bg-muted/60"> <ShadTableHeader className="sticky top-0 z-20 bg-muted">
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}> <TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => { {headerGroup.headers.map((header) => {
@@ -39,7 +39,7 @@ const TableHeader = <TData, _>({ table }: { table: Table<TData> }) => {
canSort ? header.column.getToggleSortingHandler() : undefined canSort ? header.column.getToggleSortingHandler() : undefined
} }
className={cn( className={cn(
'h-11 max-w-0 overflow-hidden border-b border-border bg-muted/70 px-4 text-sm font-semibold text-foreground transition-colors', 'h-11 max-w-0 overflow-hidden border-b border-border bg-muted px-4 text-sm font-semibold text-foreground transition-colors',
canSort && 'cursor-pointer select-none hover:bg-muted', canSort && 'cursor-pointer select-none hover:bg-muted',
sortDirection && 'bg-muted', sortDirection && 'bg-muted',
)} )}

View File

@@ -41,11 +41,7 @@ export default function AnnotatedDetectionImage({
] ]
: []; : [];
const aspectRatio = const aspectRatio = aspectRatioOverride ?? '16 / 9';
aspectRatioOverride ??
(videoWidth > 0 && videoHeight > 0
? `${videoWidth} / ${videoHeight}`
: '16 / 9');
const annotatedMedia = objectUrl ? ( const annotatedMedia = objectUrl ? (
<> <>
@@ -75,14 +71,7 @@ export default function AnnotatedDetectionImage({
className="group relative flex w-full cursor-zoom-in items-center justify-center overflow-hidden rounded-lg border bg-black focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" className="group relative flex w-full cursor-zoom-in items-center justify-center overflow-hidden rounded-lg border bg-black focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
style={{ aspectRatio }} style={{ aspectRatio }}
> >
<Image {annotatedMedia}
src={objectUrl}
alt={`${detection.detection.display_name} detection`}
fill
unoptimized
sizes="(min-width: 1280px) 18rem, (min-width: 640px) 50vw, 100vw"
className="object-contain"
/>
<span className="pointer-events-none absolute inset-x-0 bottom-0 z-[3] bg-gradient-to-t from-black/70 to-transparent px-3 pb-2 pt-8 text-center text-xs font-medium text-white opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100"> <span className="pointer-events-none absolute inset-x-0 bottom-0 z-[3] bg-gradient-to-t from-black/70 to-transparent px-3 pb-2 pt-8 text-center text-xs font-medium text-white opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100">
Click to view annotated image Click to view annotated image
</span> </span>