"use client"; import { useEffect, useRef, useState, useCallback } from "react"; import { useQuery } from "@tanstack/react-query"; import { ChevronLeft, ChevronRight, Copy, Plus, Check, Loader2, Trash2, AlertCircle, ChevronDown, ChevronUp, Link2, X, BookImage, } from "lucide-react"; import Image from "next/image"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { cn } from "@/lib/utils"; import { TakeImageGallery } from "./TakeImageGallery"; import { PreviousTakePanel } from "./PreviousTakePanel"; import type { TakeQuality } from "@prisma/client"; // ─── Types ──────────────────────────────────────────────────────────────────── export interface TakeAttachmentData { id: string; fileUrl: string; fileName: string; fileType: string; category: string; caption: string | null; sortOrder: number; } export interface FullTake { id: string; setupId: string; takeNumber: number; pipelineShotId: string | null; scene: string | null; shotLabel: string | null; unitLabel: string | null; cameraLetter: string | null; clipName: string | null; roll: string | null; cameraModel: string | null; resolution: string | null; codec: string | null; fps: number | null; shutter: string | null; iso: number | null; whiteBalance: number | null; colourSpace: string | null; lensSet: string | null; lens: string | null; tStop: string | null; filters: string | null; isAnamorphic: boolean; hasHdri: boolean; hasChromeBall: boolean; hasGreyBall: boolean; hasMacbeth: boolean; hasCleanPlate: boolean; hasSurvey: boolean; hasLidar: boolean; hasWitnessCamera: boolean; hasLensGrid: boolean; hasTexturePhotos: boolean; hasPhotogrammetry: boolean; weather: string | null; sunDirection: string | null; artificialLights: string | null; supervisorNotes: string | null; continuityNotes: string | null; vfxRequirements: string | null; quality: TakeQuality; attachments: TakeAttachmentData[]; setup: { takes: { id: string; takeNumber: number }[]; shootDay: { id: string; date: string; unit: string; label: string | null; projectId: string }; }; } // ─── Quality config ─────────────────────────────────────────────────────────── const QUALITIES: { value: TakeQuality; label: string; color: string; active: string }[] = [ { value: "FALSE_START", label: "False Start", color: "border-zinc-700 text-zinc-500", active: "bg-zinc-700 text-zinc-200 border-zinc-600" }, { value: "NO_GOOD", label: "No Good", color: "border-red-900/60 text-red-500", active: "bg-red-900/60 text-red-200 border-red-700" }, { value: "PRINT", label: "Print", color: "border-amber-800/60 text-amber-500", active: "bg-amber-800/60 text-amber-200 border-amber-600" }, { value: "GOOD", label: "Good", color: "border-blue-800/60 text-blue-400", active: "bg-blue-900/60 text-blue-200 border-blue-600" }, { value: "HERO", label: "Hero ★", color: "border-green-800/60 text-green-500", active: "bg-green-900/60 text-green-200 border-green-600" }, ]; const TRACKING_ITEMS: { key: keyof FullTake; label: string }[] = [ { key: "hasHdri", label: "HDRI" }, { key: "hasChromeBall", label: "Chrome Ball" }, { key: "hasGreyBall", label: "Grey Ball" }, { key: "hasMacbeth", label: "Macbeth" }, { key: "hasCleanPlate", label: "Clean Plate" }, { key: "hasSurvey", label: "Survey" }, { key: "hasLidar", label: "LiDAR" }, { key: "hasWitnessCamera", label: "Witness Cam" }, { key: "hasLensGrid", label: "Lens Grid" }, { key: "hasTexturePhotos", label: "Texture Photos" }, { key: "hasPhotogrammetry", label: "Photogrammetry" }, ]; // ─── Autosave hook ──────────────────────────────────────────────────────────── type SaveStatus = "clean" | "dirty" | "saving" | "saved" | "error"; function useAutosave(takeId: string) { const [status, setStatus] = useState("clean"); const timerRef = useRef | null>(null); const pendingRef = useRef>({}); const flush = useCallback(async () => { if (Object.keys(pendingRef.current).length === 0) return; const patch = { ...pendingRef.current }; pendingRef.current = {}; setStatus("saving"); try { const res = await fetch(`/api/shoot-log/takes/${takeId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(patch), }); if (!res.ok) throw new Error("save failed"); setStatus("saved"); setTimeout(() => setStatus("clean"), 2000); } catch { setStatus("error"); } }, [takeId]); const queue = useCallback( (patch: Record) => { pendingRef.current = { ...pendingRef.current, ...patch }; setStatus("dirty"); if (timerRef.current) clearTimeout(timerRef.current); timerRef.current = setTimeout(flush, 800); }, [flush] ); // Flush immediately when takeId changes useEffect(() => { return () => { if (timerRef.current) clearTimeout(timerRef.current); }; }, [takeId]); return { status, queue, flush }; } // ─── Field components ───────────────────────────────────────────────────────── function Field({ label, children, className, }: { label: string; children: React.ReactNode; className?: string; }) { return (
{children}
); } function TF({ value, onChange, placeholder, type = "text", className, }: { value: string; onChange: (v: string) => void; placeholder?: string; type?: string; className?: string; }) { return ( onChange(e.target.value)} placeholder={placeholder} className={cn("h-9 bg-zinc-900 border-zinc-700 text-sm", className)} /> ); } function SectionHeader({ title }: { title: string }) { return (
{title}
); } // ─── Linked shot panel ──────────────────────────────────────────────────────── interface LinkedShot { id: string; shotCode: string; scene: string | null; episode: string | null; thumbnailUrl: string | null; references: { id: string; fileUrl: string; fileName: string; label: string | null }[]; } interface LightboxItem { url: string; label: string; } function ShotLightbox({ items, index, onClose, }: { items: LightboxItem[]; index: number; onClose: () => void; }) { const [current, setCurrent] = useState(index); const item = items[current]; // Keyboard navigation useEffect(() => { function onKey(e: KeyboardEvent) { if (e.key === "Escape") onClose(); if (e.key === "ArrowLeft") setCurrent((c) => Math.max(0, c - 1)); if (e.key === "ArrowRight") setCurrent((c) => Math.min(items.length - 1, c + 1)); } window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [items.length, onClose]); return (
e.stopPropagation()} > {/* eslint-disable-next-line @next/next/no-img-element */} {item.label}

{item.label}

{items.length > 1 && (
e.stopPropagation()}> {current + 1} / {items.length}
)}
); } function LinkedShotPanel({ shotId }: { shotId: string }) { const [lightboxIndex, setLightboxIndex] = useState(null); const { data, isLoading } = useQuery<{ shot: LinkedShot }>({ queryKey: ["linked-shot", shotId], queryFn: async () => { const res = await fetch(`/api/shoot-log/shots/${shotId}`); if (!res.ok) throw new Error("Failed"); return res.json(); }, staleTime: 60_000, enabled: !!shotId, }); if (isLoading) { return (
Loading shot data…
); } const shot = data?.shot; if (!shot) return null; const hasContent = shot.thumbnailUrl || shot.references.length > 0; if (!hasContent) return null; // Build a flat list of lightbox items: thumbnail first, then references const lightboxItems: LightboxItem[] = [ ...(shot.thumbnailUrl ? [{ url: shot.thumbnailUrl, label: `${shot.shotCode} — Thumbnail` }] : []), ...shot.references.map((ref) => ({ url: ref.fileUrl, label: ref.label ?? ref.fileName, })), ]; // Index offsets: thumbnail is 0 (if present), refs start at offset const thumbLightboxIndex = shot.thumbnailUrl ? 0 : null; const refLightboxOffset = shot.thumbnailUrl ? 1 : 0; return ( <>
Shot Reference — {shot.shotCode}
{shot.thumbnailUrl && (
setLightboxIndex(thumbLightboxIndex!)} > {shot.shotCode}
)} {shot.references.length > 0 && (
{shot.references.map((ref, i) => (
setLightboxIndex(refLightboxOffset + i)} > {ref.label
{ref.label && (
{ref.label}
)}
))}
)}
{lightboxIndex !== null && ( setLightboxIndex(null)} /> )} ); } // ─── Shot selector ──────────────────────────────────────────────────────────── interface ShotOption { id: string; shotCode: string; scene: string | null; episode: string | null; } function ShotSelector({ projectId, value, onChange, }: { projectId: string; value: string | null; onChange: (id: string | null) => void; }) { const [search, setSearch] = useState(""); const { data, isLoading } = useQuery<{ shots: ShotOption[] }>({ queryKey: ["project-shots", projectId], queryFn: async () => { const res = await fetch(`/api/shoot-log/shots?projectId=${projectId}`); if (!res.ok) throw new Error("Failed"); return res.json(); }, staleTime: 60_000, enabled: !!projectId, }); const shots = data?.shots ?? []; const filtered = search ? shots.filter( (s) => s.shotCode.toLowerCase().includes(search.toLowerCase()) || (s.scene ?? "").toLowerCase().includes(search.toLowerCase()) || (s.episode ?? "").toLowerCase().includes(search.toLowerCase()) ) : shots; const selected = shots.find((s) => s.id === value); return (
setSearch(e.target.value)} className="h-8 text-xs bg-zinc-900 border-zinc-700" />
{value && ( )}
{selected && (
Linked to {selected.shotCode} {selected.episode ? ` · Ep ${selected.episode}` : ""}
)}
); } // ─── Main component ─────────────────────────────────────────────────────────── interface Props { take: FullTake; previousTake: FullTake | null; onPrev: (() => void) | null; onNext: (() => void) | null; onDuplicate: () => void; onNewTake: () => void; onDelete: () => void; onAttachmentsChange: () => void; } export function TakeEditorPanel({ take, previousTake, onPrev, onNext, onDuplicate, onNewTake, onDelete, onAttachmentsChange, }: Props) { const { status, queue } = useAutosave(take.id); // Local field state — initialised from take prop, synced when take.id changes const [fields, setFields] = useState(take); const prevIdRef = useRef(take.id); useEffect(() => { if (take.id !== prevIdRef.current) { setFields(take); prevIdRef.current = take.id; } }, [take]); const [showPrevPanel, setShowPrevPanel] = useState(false); function set(key: K, value: FullTake[K]) { setFields((f) => ({ ...f, [key]: value })); queue({ [key]: value }); } const str = (v: string | null) => v ?? ""; const num = (v: number | null) => (v !== null && v !== undefined ? String(v) : ""); // Keyboard shortcuts useEffect(() => { function handleKey(e: KeyboardEvent) { if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; if (e.key === "ArrowLeft" && onPrev) onPrev(); if (e.key === "ArrowRight" && onNext) onNext(); if (e.key === "d" && !e.metaKey && !e.ctrlKey) onDuplicate(); if (e.key === "n" && !e.metaKey && !e.ctrlKey) onNewTake(); } window.addEventListener("keydown", handleKey); return () => window.removeEventListener("keydown", handleKey); }, [onPrev, onNext, onDuplicate, onNewTake]); return (
{/* ── Nav bar ── */}

Setup {take.setup.shootDay.unit} · Take {take.takeNumber}

{new Date(take.setup.shootDay.date).toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short", year: "numeric", })} {take.setup.shootDay.label ? ` · ${take.setup.shootDay.label}` : ""}

{/* Save status */}
{status === "saving" && } {status === "saved" && } {status === "dirty" && } {status === "error" && }
{/* ── Quality bar ── */}
{QUALITIES.map((q) => ( ))}
{/* ── Scrollable form ── */}
{/* General */}
set("scene", v || null)} placeholder="e.g. 12" /> set("shotLabel", v || null)} placeholder="e.g. 12A" /> set("unitLabel", v || null)} placeholder="e.g. A" /> {}} placeholder="—" className="opacity-60 cursor-default" />
set("pipelineShotId", id)} /> {fields.pipelineShotId && ( )} {/* Camera */}
set("cameraLetter", v || null)} placeholder="A" /> set("clipName", v || null)} placeholder="A001_C001" /> set("roll", v || null)} placeholder="A001" /> set("cameraModel", v || null)} placeholder="Alexa Mini LF" /> set("resolution", v || null)} placeholder="4.5K" /> set("codec", v || null)} placeholder="ARRIRAW" /> set("fps", v ? Number(v) : null)} placeholder="24" /> set("shutter", v || null)} placeholder="180°" /> set("iso", v ? Number(v) : null)} placeholder="800" /> set("whiteBalance", v ? Number(v) : null)} placeholder="5600" /> set("colourSpace", v || null)} placeholder="LogC3 AWG3" />
{/* Lens */}
set("lensSet", v || null)} placeholder="Cooke S4" /> set("lens", v || null)} placeholder="50mm" /> set("tStop", v || null)} placeholder="T2.8" /> set("filters", v || null)} placeholder="IRND 0.6" />
{/* Tracking */}
{TRACKING_ITEMS.map(({ key, label }) => { const checked = fields[key] as boolean; return ( ); })}
{/* Environment */}
set("weather", v || null)} placeholder="Overcast" /> set("sunDirection", v || null)} placeholder="NW high" />