Files
vfxreview/components/shots/ShotCard.tsx
T
twotalesanimation 05475a6c19
Deploy / deploy (push) Successful in 2m28s
added duplicate shot
2026-05-20 18:20:45 +02:00

241 lines
9.0 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,
} from "lucide-react";
import type { ShotWithDetails } from "@/types";
import { duplicateShot } from "@/actions/shots";
import { useToast } from "@/components/ui/use-toast";
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 },
IN_REVIEW: { label: "In Review", color: "bg-purple-500/10 text-purple-400 border-purple-500/20", icon: AlertCircle },
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 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);
}
};
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]">
{shot.thumbnailUrl || latestVersion?.thumbnailUrl || latestVersion?.posterUrl ? (
<Image
src={shot.thumbnailUrl || 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>
</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>
);
}