578 lines
22 KiB
TypeScript
578 lines
22 KiB
TypeScript
"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,
|
||
} from "lucide-react";
|
||
import type { ShotWithDetails } from "@/types";
|
||
import { ShotSettingsTab } from "@/components/shots/ShotSettingsTab";
|
||
import { FootageViewer } from "@/components/shots/FootageViewer";
|
||
import { duplicateShot, internallyApproveShot, shareWithClient, unshareFromClient, unapproveShot } 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 [activeTab, setActiveTab] = useState<"tasks" | "reviews" | "footage" | "settings">("tasks");
|
||
|
||
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 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.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 */}
|
||
{shot.shotApprovalStatus === "INTERNALLY_APPROVED" && (
|
||
<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 && shot.shotApprovalStatus === "INTERNALLY_APPROVED" && !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>
|
||
</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">
|
||
“{latestApproval.notes}”
|
||
</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>
|
||
</div>
|
||
);
|
||
}
|