caaa333f02
Deploy / deploy (push) Failing after 19m11s
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>
273 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|