Files
vfxreview/app/(dashboard)/projects/[id]/shots/[shotId]/page.tsx
T
twotalesanimation 75d5149bea
Deploy / deploy (push) Successful in 3m12s
versioning added
2026-06-25 22:19:22 +02:00

677 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useEffect } from "react";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { TaskList } from "@/components/tasks/TaskList";
import { Separator } from "@/components/ui/separator";
import { getInitials, cn, formatRelativeDate } from "@/lib/utils";
import {
Film,
ArrowLeft,
Clock,
AlertCircle,
CheckCircle2,
Settings,
ListTodo,
Video,
Copy,
ShieldCheck,
Share2,
Eye,
EyeOff,
MessageSquare,
ExternalLink,
XCircle,
FileVideo,
Pencil,
Check,
} from "lucide-react";
import { Input } from "@/components/ui/input";
import type { ShotWithDetails } from "@/types";
import { ShotSettingsTab } from "@/components/shots/ShotSettingsTab";
import { FootageViewer } from "@/components/shots/FootageViewer";
import { HighResUploadDialog } from "@/components/shots/HighResUploadDialog";
import { duplicateShot, internallyApproveShot, shareWithClient, unshareFromClient, unapproveShot, updateShotVersion } from "@/actions/shots";
const STATUS_CONFIG: Record<
string,
{ label: string; className: string; Icon: React.ElementType }
> = {
WAITING: { label: "Waiting", className: "bg-zinc-500/10 text-zinc-400 border-zinc-500/20", Icon: Clock },
IN_PROGRESS: { label: "In Progress", className: "bg-blue-500/10 text-blue-400 border-blue-500/20", Icon: Film },
INTERNAL_REVIEW: { label: "Internal Review", className: "bg-purple-500/10 text-purple-400 border-purple-500/20", Icon: AlertCircle },
READY_FOR_CLIENT: { label: "Ready for Client", className: "bg-sky-500/10 text-sky-400 border-sky-500/20", Icon: ShieldCheck },
CLIENT_REVIEW: { label: "Client Review", className: "bg-indigo-500/10 text-indigo-400 border-indigo-500/20", Icon: Eye },
REVISIONS: { label: "Revisions", className: "bg-orange-500/10 text-orange-400 border-orange-500/20", Icon: AlertCircle },
COMPLETE: { label: "Complete", className: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20", Icon: CheckCircle2 },
};
const APPROVAL_STYLES: Record<string, string> = {
PENDING_REVIEW: "bg-amber-500/10 text-amber-400 border-amber-500/20",
APPROVED: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20",
REJECTED: "bg-red-500/10 text-red-400 border-red-500/20",
NEEDS_CHANGES: "bg-orange-500/10 text-orange-400 border-orange-500/20",
};
const APPROVAL_ICONS: Record<string, React.ElementType> = {
PENDING_REVIEW: Clock,
APPROVED: CheckCircle2,
REJECTED: XCircle,
NEEDS_CHANGES: AlertCircle,
};
const PRIORITY_CONFIG: Record<string, { label: string; dot: string }> = {
LOW: { label: "Low", dot: "bg-zinc-400" },
NORMAL: { label: "Normal", dot: "bg-blue-400" },
HIGH: { label: "High", dot: "bg-amber-400" },
CRITICAL: { label: "Critical", dot: "bg-red-500" },
};
import { useToast } from "@/components/ui/use-toast";
export default function ShotDetailPage() {
const params = useParams<{ id: string; shotId: string }>();
const router = useRouter();
const { toast } = useToast();
const [shot, setShot] = useState<ShotWithDetails | null>(null);
const [projectName, setProjectName] = useState<string>("");
const [loading, setLoading] = useState(true);
const [canApprove, setCanApprove] = useState(false);
const [canInternallyApprove, setCanInternallyApprove] = useState(false);
const [tasks, setTasks] = useState<any[]>([]);
const [artists, setArtists] = useState<any[]>([]);
const [canManage, setCanManage] = useState(false);
const [isDuplicating, setIsDuplicating] = useState(false);
const [isActioning, setIsActioning] = useState(false);
const [highResDialogOpen, setHighResDialogOpen] = useState(false);
const [activeTab, setActiveTab] = useState<"tasks" | "reviews" | "footage" | "settings">("tasks");
const [editingVersion, setEditingVersion] = useState(false);
const [versionInput, setVersionInput] = useState("");
const [savingVersion, setSavingVersion] = useState(false);
const fetchShot = async () => {
try {
const res = await fetch(`/api/shots/${params.shotId}?projectId=${params.id}`);
if (res.status === 404) {
router.push("/projects");
return;
}
const data = await res.json();
setShot(data.shot);
setProjectName(data.projectName ?? "");
setCanApprove(data.canApprove ?? false);
setCanInternallyApprove(data.canInternallyApprove ?? false);
setTasks(data.tasks ?? []);
setArtists(data.artists ?? []);
setCanManage(data.canApprove ?? false);
} catch {
router.push("/projects");
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchShot();
}, [params.shotId]);
const handleInternalApprove = async () => {
if (!shot) return;
setIsActioning(true);
try {
await internallyApproveShot(shot.id);
toast({ title: "Shot internally approved", description: "Status: Ready for Client" });
fetchShot();
} catch (e) {
toast({ title: "Failed", description: e instanceof Error ? e.message : undefined, variant: "destructive" });
} finally {
setIsActioning(false);
}
};
const handleShareWithClient = async () => {
if (!shot) return;
setIsActioning(true);
try {
await shareWithClient(shot.id);
toast({ title: "Shared with client", description: "Status: Client Review" });
fetchShot();
} catch (e) {
toast({ title: "Failed", description: e instanceof Error ? e.message : undefined, variant: "destructive" });
} finally {
setIsActioning(false);
}
};
const handleUnshare = async () => {
if (!shot) return;
setIsActioning(true);
try {
await unshareFromClient(shot.id);
toast({ title: "Removed from client review", description: "Status: Ready for Client" });
fetchShot();
} catch (e) {
toast({ title: "Failed", description: e instanceof Error ? e.message : undefined, variant: "destructive" });
} finally {
setIsActioning(false);
}
};
const handleUnapproveShot = async () => {
if (!shot) return;
if (!confirm("Undo client approval? The shot will return to Internally Approved.")) return;
setIsActioning(true);
try {
await unapproveShot(shot.id);
toast({ title: "Client approval undone", description: "Status: Ready for Client" });
fetchShot();
} catch (e) {
toast({ title: "Failed", description: e instanceof Error ? e.message : undefined, variant: "destructive" });
} finally {
setIsActioning(false);
}
};
const handleSaveVersion = async () => {
if (!shot) return;
const trimmed = versionInput.trim().toLowerCase();
if (!/^v\d{3}$/.test(trimmed)) {
toast({ title: "Invalid format", description: "Version must be v### (e.g. v001)", variant: "destructive" });
return;
}
setSavingVersion(true);
try {
await updateShotVersion(shot.id, trimmed);
toast({ title: `Version set to ${trimmed}` });
setEditingVersion(false);
fetchShot();
} catch (e) {
toast({ title: "Failed", description: e instanceof Error ? e.message : undefined, variant: "destructive" });
} finally {
setSavingVersion(false);
}
};
const handleDuplicate = async () => {
if (!shot) return;
setIsDuplicating(true);
try {
const { shot: newShot } = await duplicateShot(shot.id);
toast({ title: "Shot duplicated", description: `Created ${newShot.shotCode}` });
router.push(`/projects/${params.id}/shots/${newShot.id}`);
} catch (e) {
toast({ title: "Duplicate failed", description: e instanceof Error ? e.message : undefined, variant: "destructive" });
} finally {
setIsDuplicating(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<Film className="h-6 w-6 animate-pulse text-muted-foreground" />
</div>
);
}
if (!shot) return null;
const statusCfg = STATUS_CONFIG[shot.status] ?? STATUS_CONFIG.WAITING;
const priorityCfg = PRIORITY_CONFIG[shot.priority] ?? PRIORITY_CONFIG.NORMAL;
const { Icon: StatusIcon } = statusCfg;
return (
<div className="p-6 space-y-6 max-w-[1400px] mx-auto">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Link href="/projects" className="hover:text-foreground transition-colors">
Projects
</Link>
<span>/</span>
<Link href={`/projects/${params.id}`} className="hover:text-foreground transition-colors">
{projectName}
</Link>
<span>/</span>
<span className="text-foreground font-mono">{shot.shotCode}</span>
</div>
{/* Header */}
<div className="flex items-start gap-6">
{/* Thumbnail cinema scope 2.39:1 */}
<Button variant="ghost" size="icon" asChild className="-ml-2 h-8 w-8">
<Link href={`/projects/${params.id}`}>
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
{shot.thumbnailUrl && (
<div className="relative flex-shrink-0 w-72 aspect-[2.39] rounded-lg overflow-hidden border border-border">
<Image
src={shot.thumbnailUrl}
alt={shot.shotCode}
fill
className="object-cover"
/>
</div>
)}
<div className="space-y-2">
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold font-mono">{shot.shotCode}</h1>
{/* Shot version badge — click to edit for managers */}
{canManage ? (
editingVersion ? (
<div className="flex items-center gap-1.5">
<Input
autoFocus
value={versionInput}
onChange={(e) => setVersionInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleSaveVersion();
if (e.key === "Escape") setEditingVersion(false);
}}
className="h-7 w-20 font-mono text-xs px-2"
placeholder="v001"
maxLength={4}
/>
<button
onClick={handleSaveVersion}
disabled={savingVersion}
className="text-emerald-400 hover:text-emerald-300 disabled:opacity-50"
title="Save version"
>
<Check className="h-4 w-4" />
</button>
<button
onClick={() => setEditingVersion(false)}
className="text-zinc-500 hover:text-zinc-300"
title="Cancel"
>
<XCircle className="h-4 w-4" />
</button>
</div>
) : (
<button
onClick={() => { setVersionInput(shot.shotVersion ?? "v001"); setEditingVersion(true); }}
className="group flex items-center gap-1 font-mono text-sm px-2 py-0.5 rounded border border-zinc-700 bg-zinc-800/60 text-zinc-300 hover:border-amber-500/50 hover:text-amber-400 transition-colors"
title="Click to edit version"
>
{shot.shotVersion ?? "v001"}
<Pencil className="h-3 w-3 opacity-0 group-hover:opacity-100 transition-opacity" />
</button>
)
) : (
<span className="font-mono text-sm px-2 py-0.5 rounded border border-zinc-700 bg-zinc-800/60 text-zinc-300">
{shot.shotVersion ?? "v001"}
</span>
)}
{shot.sequence && (
<span className="text-sm text-muted-foreground">Seq: {shot.sequence}</span>
)}
</div>
{shot.description && (
<p className="text-muted-foreground ">{shot.description}</p>
)}
<div className="flex items-center gap-3 flex-wrap">
<Badge className={statusCfg.className} variant="outline">
<StatusIcon className="h-3 w-3 mr-1" />
{statusCfg.label}
</Badge>
{/* Approval status badges */}
{shot.shotApprovalStatus === "PENDING" && (
<Badge variant="outline" className="bg-zinc-500/10 text-zinc-400 border-zinc-500/20 text-[11px]">
Pending Internal Approval
</Badge>
)}
{shot.shotApprovalStatus === "INTERNALLY_APPROVED" && (
<Badge variant="outline" className="bg-sky-500/10 text-sky-400 border-sky-500/20 text-[11px]">
<ShieldCheck className="h-3 w-3 mr-1" />
Internally Approved
</Badge>
)}
{shot.shotApprovalStatus === "CLIENT_APPROVED" && (
<Badge variant="outline" className="bg-emerald-500/10 text-emerald-400 border-emerald-500/20 text-[11px]">
<CheckCircle2 className="h-3 w-3 mr-1" />
Client Approved
</Badge>
)}
{/* Client sharing badge */}
{["INTERNALLY_APPROVED", "CLIENT_APPROVED"].includes(shot.shotApprovalStatus) && (
<Badge variant="outline" className={cn(
"text-[11px]",
shot.sharedWithClient
? "bg-indigo-500/10 text-indigo-400 border-indigo-500/20"
: "bg-zinc-500/10 text-zinc-500 border-zinc-600/20"
)}>
{shot.sharedWithClient ? "Shared With Client" : "Not Shared"}
</Badge>
)}
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<span
className={`h-2 w-2 rounded-full ${priorityCfg.dot}`}
/>
{priorityCfg.label} Priority
</div>
<span className="text-sm text-muted-foreground">{shot.fps} fps</span>
{shot.artist && (
<div className="flex items-center gap-1.5">
<Avatar className="h-5 w-5">
<AvatarImage src={shot.artist.image ?? undefined} />
<AvatarFallback className="text-[10px] bg-primary/10 text-primary">
{getInitials(shot.artist.name ?? shot.artist.email)}
</AvatarFallback>
</Avatar>
<span className="text-sm text-muted-foreground">
{shot.artist.name ?? shot.artist.email}
</span>
</div>
)}
</div>
</div>
{/* EDL / Pull metadata */}
{(shot.exrOutput || shot.sourceClip) && (
<div className="mt-3 rounded-lg border border-zinc-800 bg-zinc-900/60 px-4 py-3 space-y-2">
{shot.exrOutput && (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-medium text-zinc-500 uppercase tracking-wider w-24 shrink-0">EXR Output</span>
<span className="font-mono text-xs text-zinc-200 flex-1 break-all">{shot.exrOutput}</span>
<button
className="text-zinc-500 hover:text-zinc-300 transition-colors shrink-0"
title="Copy EXR output name"
onClick={() => navigator.clipboard.writeText(shot.exrOutput ?? "")}
>
<Copy className="h-3.5 w-3.5" />
</button>
</div>
)}
{shot.sourceClip && (
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-zinc-500 uppercase tracking-wider w-24 shrink-0">Source Clip</span>
<span className="font-mono text-xs text-zinc-400">{shot.sourceClip}</span>
</div>
)}
{(shot.timecodeStart || shot.timecodeEnd) && (
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-zinc-500 uppercase tracking-wider w-24 shrink-0">Timecode</span>
<span className="font-mono text-xs text-zinc-400">
{shot.timecodeStart ?? ""}
{shot.timecodeStart && shot.timecodeEnd && " → "}
{shot.timecodeEnd ?? ""}
{shot.clipDuration && <span className="ml-2 text-zinc-600">({shot.clipDuration})</span>}
</span>
</div>
)}
</div>
)}
{canManage && (
<div className="ml-auto shrink-0 flex items-center gap-2">
{/* Internal approval action */}
{canInternallyApprove && shot.shotApprovalStatus === "PENDING" && (
<Button
variant="outline"
size="sm"
onClick={handleInternalApprove}
disabled={isActioning}
className="gap-2 border-sky-500/40 text-sky-400 hover:bg-sky-500/10"
>
<ShieldCheck className="h-3.5 w-3.5" />
Approve Internally
</Button>
)}
{/* Share / Unshare with client */}
{canInternallyApprove && ["INTERNALLY_APPROVED", "CLIENT_APPROVED"].includes(shot.shotApprovalStatus) && !shot.sharedWithClient && (
<Button
variant="outline"
size="sm"
onClick={handleShareWithClient}
disabled={isActioning}
className="gap-2 border-indigo-500/40 text-indigo-400 hover:bg-indigo-500/10"
>
<Share2 className="h-3.5 w-3.5" />
Share With Client
</Button>
)}
{canInternallyApprove && shot.sharedWithClient && (
<Button
variant="outline"
size="sm"
onClick={handleUnshare}
disabled={isActioning}
className="gap-2 border-zinc-500/40 text-zinc-400 hover:bg-zinc-500/10"
>
<EyeOff className="h-3.5 w-3.5" />
Remove From Client Review
</Button>
)}
{/* Undo client approval */}
{canInternallyApprove && shot.shotApprovalStatus === "CLIENT_APPROVED" && (
<Button
variant="outline"
size="sm"
onClick={handleUnapproveShot}
disabled={isActioning}
className="gap-2 border-amber-500/40 text-amber-400 hover:bg-amber-500/10"
>
<AlertCircle className="h-3.5 w-3.5" />
Undo Client Approval
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={handleDuplicate}
disabled={isDuplicating}
className="gap-2"
>
<Copy className="h-3.5 w-3.5" />
{isDuplicating ? "Duplicating…" : "Duplicate Shot"}
</Button> <Button
variant="outline"
size="sm"
onClick={() => setHighResDialogOpen(true)}
className={cn(
"gap-2",
shot.highResFilename
? "border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10"
: "border-zinc-600 text-zinc-400 hover:bg-zinc-700/50"
)}
>
<FileVideo className="h-3.5 w-3.5" />
{shot.highResFilename ? "High Res ✓" : "Upload High Res"}
</Button> </div>
)}
</div>
<Separator />
{/* Tabs */}
<div>
<div className="flex border-b border-border mb-5">
<button
onClick={() => setActiveTab("tasks")}
className={cn(
"flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px",
activeTab === "tasks"
? "border-amber-500 text-amber-400"
: "border-transparent text-zinc-500 hover:text-zinc-300"
)}
>
<ListTodo className="h-4 w-4" />
Tasks
</button>
<button
onClick={() => setActiveTab("reviews")}
className={cn(
"flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px",
activeTab === "reviews"
? "border-amber-500 text-amber-400"
: "border-transparent text-zinc-500 hover:text-zinc-300"
)}
>
<MessageSquare className="h-4 w-4" />
Reviews
</button>
<button
onClick={() => setActiveTab("footage")}
className={cn(
"flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px",
activeTab === "footage"
? "border-amber-500 text-amber-400"
: "border-transparent text-zinc-500 hover:text-zinc-300"
)}
>
<Video className="h-4 w-4" />
Footage
</button>
{canManage && (
<button
onClick={() => setActiveTab("settings")}
className={cn(
"flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px",
activeTab === "settings"
? "border-amber-500 text-amber-400"
: "border-transparent text-zinc-500 hover:text-zinc-300"
)}
>
<Settings className="h-4 w-4" />
Settings
</button>
)}
</div>
{activeTab === "tasks" && (
<TaskList
tasks={tasks}
projectId={params.id}
shotId={shot.id}
artists={artists}
canManage={canManage}
onTaskCreated={fetchShot}
/>
)}
{activeTab === "reviews" && (
<div className="space-y-3">
{tasks.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 gap-2 text-muted-foreground">
<MessageSquare className="h-8 w-8 opacity-30" />
<p className="text-sm">No tasks yet reviews will appear here once tasks are created.</p>
</div>
) : (
tasks.map((task: any) => {
const latestVersion = task.versions?.[0];
const latestApproval = latestVersion?.approvals?.[0];
const ApprovalIcon = latestVersion ? (APPROVAL_ICONS[latestVersion.approvalStatus] ?? Clock) : Clock;
const approvalStyle = latestVersion ? (APPROVAL_STYLES[latestVersion.approvalStatus] ?? "") : "";
return (
<div key={task.id} className="rounded-lg border border-border bg-card p-4 space-y-3">
{/* Task header */}
<div className="flex items-center gap-3 flex-wrap">
<p className="text-sm font-medium flex-1 min-w-0 truncate">{task.title}</p>
{latestVersion ? (
<>
<Badge
variant="outline"
className={cn("text-xs gap-1", approvalStyle)}
>
<ApprovalIcon className="h-3 w-3" />
{latestVersion.approvalStatus.replace(/_/g, " ")}
</Badge>
<Link
href={`/review/${latestVersion.id}`}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors font-mono"
>
v{String(latestVersion.versionNumber).padStart(3, "0")}
<ExternalLink className="h-3 w-3" />
</Link>
</>
) : (
<span className="text-xs text-muted-foreground">No versions uploaded</span>
)}
</div>
{/* Latest approval */}
{latestVersion && (
latestApproval ? (
<div className="flex items-start gap-3">
<Avatar className="h-7 w-7 shrink-0">
<AvatarImage src={latestApproval.user.image ?? undefined} />
<AvatarFallback className="text-[10px] bg-primary/10 text-primary">
{getInitials(latestApproval.user.name ?? "?")}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0 space-y-0.5">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium">
{latestApproval.user.name ?? "Reviewer"}
</span>
<span className="text-xs text-muted-foreground">
{formatRelativeDate(latestApproval.createdAt)}
</span>
</div>
{latestApproval.notes ? (
<p className="text-sm text-muted-foreground italic leading-relaxed">
&ldquo;{latestApproval.notes}&rdquo;
</p>
) : (
<p className="text-xs text-muted-foreground/60">No comment left</p>
)}
</div>
</div>
) : (
<p className="text-xs text-muted-foreground pl-1">Awaiting review</p>
)
)}
</div>
);
})
)}
</div>
)}
{activeTab === "footage" && (
<FootageViewer
shot={shot}
canManage={canManage}
onSaved={fetchShot}
/>
)}
{activeTab === "settings" && canManage && (
<ShotSettingsTab shot={shot} artists={artists} onSaved={fetchShot} />
)}
</div>
<HighResUploadDialog
shotId={shot.id}
shotCode={shot.shotCode}
currentFilename={shot.highResFilename ?? null}
open={highResDialogOpen}
onClose={() => setHighResDialogOpen(false)}
onSuccess={fetchShot}
/>
</div>
);
}