Files
vfxreview/components/shots/ShotCard.tsx
T
twotalesanimation caaa333f02
Deploy / deploy (push) Failing after 19m11s
feat(pipeline): render-derived shot thumbnails, and bulk shot delete
Two independent features landing together:

Generated shot thumbnails — a shot can now carry a thumbnail promoted
from a render export (Shot.generatedThumbnailUrl/generatedThumbnailFrom)
alongside the existing brief-supplied thumbnailUrl, which always takes
display precedence (lib/shot-thumbnail.ts) so a promoted frame can only
ever fill a gap, never overwrite the brief. Shows with
pipelineConfig.autoShotThumbnail promote automatically on every
finished export (lib/render-pipeline/preview.ts); promoteExportThumbnail/
clearGeneratedThumbnail (actions/shots.ts) are the manual equivalent for
everyone else. Worker-side seek logic for picking the promoted frame is
covered by RenderWorker/VFXReviewWorker.Tests/ThumbnailSeekTests.cs.

Bulk shot delete — the shot-status page's selection bar gets a "Bulk
Actions" dropdown (alongside the existing Change Due Date) with Delete
Shots. bulkDeleteShots() (actions/shots.ts) deletes one at a time rather
than in a single statement: exports_shotId_fkey is ON DELETE RESTRICT
by design, so a shot with renders or deliveries on record is protected
and gets skipped and reported by name rather than aborting the whole
selection or being silently destroyed along with its history.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 17:06:55 +02:00

273 lines
11 KiB
TypeScript

"use client";
import Link from "next/link";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import { getInitials, formatRelativeDate } from "@/lib/utils";
import {
MoreHorizontal,
Film,
MessageSquare,
Clock,
CheckCircle2,
AlertCircle,
ArrowUpRight,
Copy,
ShieldCheck,
Share2,
Eye,
} from "lucide-react";
import type { ShotWithDetails } from "@/types";
import { duplicateShot } from "@/actions/shots";
import { useToast } from "@/components/ui/use-toast";
import { shotThumbnail } from "@/lib/shot-thumbnail";
interface ShotCardProps {
shot: ShotWithDetails;
projectId: string;
compact?: boolean;
canManage?: boolean;
}
const STATUS_CONFIG: Record<
string,
{ label: string; color: string; icon: React.ElementType }
> = {
WAITING: { label: "Waiting", color: "bg-zinc-500/10 text-zinc-400 border-zinc-500/20", icon: Clock },
IN_PROGRESS: { label: "In Progress", color: "bg-blue-500/10 text-blue-400 border-blue-500/20", icon: Film },
INTERNAL_REVIEW: { label: "Internal Review", color: "bg-purple-500/10 text-purple-400 border-purple-500/20", icon: AlertCircle },
READY_FOR_CLIENT: { label: "Ready for Client", color: "bg-sky-500/10 text-sky-400 border-sky-500/20", icon: ShieldCheck },
CLIENT_REVIEW: { label: "Client Review", color: "bg-indigo-500/10 text-indigo-400 border-indigo-500/20", icon: Eye },
REVISIONS: { label: "Revisions", color: "bg-orange-500/10 text-orange-400 border-orange-500/20", icon: AlertCircle },
COMPLETE: { label: "Complete", color: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20", icon: CheckCircle2 },
};
const APPROVAL_BADGE: Record<string, { label: string; color: string }> = {
PENDING: { label: "Pending Internal Approval", color: "bg-zinc-500/10 text-zinc-400 border-zinc-500/20" },
INTERNALLY_APPROVED: { label: "Internally Approved", color: "bg-sky-500/10 text-sky-400 border-sky-500/20" },
CLIENT_APPROVED: { label: "Client Approved", color: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20" },
};
const PRIORITY_DOT: Record<string, string> = {
LOW: "bg-zinc-500",
NORMAL: "bg-blue-500",
HIGH: "bg-amber-500",
URGENT: "bg-red-500",
};
export function ShotCard({ shot, projectId, compact = false, canManage = false }: ShotCardProps) {
const router = useRouter();
const { toast } = useToast();
const [isDuplicating, setIsDuplicating] = useState(false);
const statusCfg = STATUS_CONFIG[shot.status] ?? STATUS_CONFIG.WAITING;
const StatusIcon = statusCfg.icon;
const latestVersion = shot.versions?.[0];
const openComments = shot.versions
?.reduce((sum, v) => sum + (v._count?.comments ?? 0), 0) ?? 0;
const handleDuplicate = async () => {
setIsDuplicating(true);
try {
const { shot: newShot } = await duplicateShot(shot.id);
toast({ title: "Shot duplicated", description: `Created ${newShot.shotCode}` });
router.push(`/projects/${projectId}/shots/${newShot.id}`);
} catch (e) {
toast({ title: "Duplicate failed", description: e instanceof Error ? e.message : undefined, variant: "destructive" });
} finally {
setIsDuplicating(false);
}
};
const approvalCfg = APPROVAL_BADGE[shot.shotApprovalStatus] ?? APPROVAL_BADGE.PENDING;
if (compact) {
return (
<div className="flex items-center gap-3 px-3 py-2.5 rounded-lg border border-border hover:border-border/80 transition-colors group">
<div
className={cn("w-2 h-2 rounded-full shrink-0", PRIORITY_DOT[shot.priority] ?? "bg-zinc-500")}
title={shot.priority}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-mono text-xs text-muted-foreground">{shot.shotCode}</span>
{shot.sequence && (
<span className="text-xs text-muted-foreground/60">{shot.sequence}</span>
)}
</div>
{shot.description && (
<p className="text-sm truncate text-foreground/80 mt-0.5">{shot.description}</p>
)}
</div>
<span className={cn("text-xs px-1.5 py-0.5 rounded border", statusCfg.color)}>
{statusCfg.label}
</span>
<Link href={`/projects/${projectId}/shots/${shot.id}`}>
<Button variant="ghost" size="icon-sm" className="opacity-0 group-hover:opacity-100 transition-opacity">
<ArrowUpRight className="h-3.5 w-3.5" />
</Button>
</Link>
</div>
);
}
return (
<Card className="group hover:border-border/80 transition-colors">
<CardHeader className="pb-2">
{/* Thumbnail with cinema scope aspect ratio (2.39:1) */}
<div className="mb-3 -mx-6 -mt-6 overflow-hidden rounded-t-lg">
<div className="relative w-full aspect-[2.39]">
{shotThumbnail(shot) || latestVersion?.thumbnailUrl || latestVersion?.posterUrl ? (
<Image
src={shotThumbnail(shot) || latestVersion?.thumbnailUrl || latestVersion?.posterUrl || ""}
alt={shot.shotCode}
fill
className="object-cover"
/>
) : (
<div className="w-full h-full bg-gradient-to-br from-zinc-800 to-zinc-900 flex items-center justify-center">
<div className="text-center">
<Film className="h-12 w-12 text-zinc-600 mx-auto mb-2" />
<p className="font-mono text-lg font-semibold text-zinc-400">{shot.shotCode}</p>
</div>
</div>
)}
</div>
</div>
{/* Header info */}
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2">
<div
className={cn("w-2 h-2 rounded-full mt-0.5", PRIORITY_DOT[shot.priority] ?? "bg-zinc-500")}
title={`Priority: ${shot.priority}`}
/>
<div>
<span className="font-mono text-xs text-muted-foreground">{shot.shotCode}</span>
{shot.sequence && (
<span className="text-xs text-muted-foreground/50 ml-2">{shot.sequence}</span>
)}
</div>
</div>
<div className="flex items-center gap-2">
<span className={cn("text-xs px-2 py-0.5 rounded-full border", statusCfg.color)}>
<StatusIcon className="h-3 w-3 inline mr-1" />
{statusCfg.label}
</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon-sm" className="opacity-100 group-hover:opacity-100 transition-opacity text-muted-foreground">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/projects/${projectId}/shots/${shot.id}`}>
View shot
</Link>
</DropdownMenuItem>
{canManage && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={handleDuplicate}
disabled={isDuplicating}
className="gap-2"
>
<Copy className="h-3.5 w-3.5" />
{isDuplicating ? "Duplicating…" : "Duplicate shot"}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{shot.description && (
<p className="text-sm text-foreground/80 line-clamp-2 mt-1">{shot.description}</p>
)}
</CardHeader>
<CardContent className="pb-2">
{/* Stats row */}
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Film className="h-3.5 w-3.5" />
{shot.versions?.length ?? 0} version{(shot.versions?.length ?? 0) !== 1 ? "s" : ""}
</span>
{openComments > 0 && (
<span className="flex items-center gap-1 text-amber-400">
<MessageSquare className="h-3.5 w-3.5" />
{openComments} open
</span>
)}
{shot.dueDate && (
<span className="flex items-center gap-1 ml-auto">
<Clock className="h-3.5 w-3.5" />
{formatRelativeDate(shot.dueDate)}
</span>
)}
</div>
{/* Approval + sharing badges */}
<div className="flex flex-wrap gap-1.5 mt-2">
<span className={cn("text-[10px] px-1.5 py-0.5 rounded border", approvalCfg.color)}>
{approvalCfg.label}
</span>
{shot.shotApprovalStatus === "INTERNALLY_APPROVED" && (
<span className={cn(
"text-[10px] px-1.5 py-0.5 rounded border flex items-center gap-1",
shot.sharedWithClient
? "bg-indigo-500/10 text-indigo-400 border-indigo-500/20"
: "bg-zinc-500/10 text-zinc-500 border-zinc-600/20"
)}>
<Share2 className="h-2.5 w-2.5" />
{shot.sharedWithClient ? "Shared With Client" : "Not Shared"}
</span>
)}
</div>
</CardContent>
<CardFooter className="pt-2 flex items-center justify-between">
{/* Artist avatar */}
{shot.artist ? (
<div className="flex items-center gap-1.5">
<Avatar className="h-5 w-5">
{shot.artist.image && <AvatarImage src={shot.artist.image} />}
<AvatarFallback className="text-[9px]">
{getInitials(shot.artist.name)}
</AvatarFallback>
</Avatar>
<span className="text-xs text-muted-foreground">
{shot.artist.name ?? shot.artist.email}
</span>
</div>
) : (
<span className="text-xs text-muted-foreground">Unassigned</span>
)}
<Button variant="ghost" size="sm" asChild className="text-xs h-7">
<Link href={`/projects/${projectId}/shots/${shot.id}`}>
VIEW
</Link>
</Button>
</CardFooter>
</Card>
);
}