632 lines
23 KiB
TypeScript
632 lines
23 KiB
TypeScript
"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,
|
|
} from "lucide-react";
|
|
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<SaveStatus>("clean");
|
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const pendingRef = useRef<Record<string, unknown>>({});
|
|
|
|
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<string, unknown>) => {
|
|
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 (
|
|
<div className={cn("flex flex-col gap-1", className)}>
|
|
<label className="text-[10px] font-semibold uppercase tracking-wider text-zinc-500">
|
|
{label}
|
|
</label>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TF({
|
|
value,
|
|
onChange,
|
|
placeholder,
|
|
type = "text",
|
|
className,
|
|
}: {
|
|
value: string;
|
|
onChange: (v: string) => void;
|
|
placeholder?: string;
|
|
type?: string;
|
|
className?: string;
|
|
}) {
|
|
return (
|
|
<Input
|
|
type={type}
|
|
value={value}
|
|
onChange={(e) => 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 (
|
|
<div className="flex items-center gap-3 py-1">
|
|
<span className="text-[10px] font-bold uppercase tracking-widest text-zinc-500">{title}</span>
|
|
<div className="flex-1 h-px bg-zinc-800" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── 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 (
|
|
<div className="flex flex-col gap-1.5">
|
|
<Input
|
|
placeholder="Filter shots…"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className="h-8 text-xs bg-zinc-900 border-zinc-700"
|
|
/>
|
|
<div className="relative flex items-center gap-2">
|
|
<select
|
|
value={value ?? ""}
|
|
onChange={(e) => onChange(e.target.value || null)}
|
|
disabled={isLoading}
|
|
className="flex-1 h-9 rounded-lg border border-zinc-700 bg-zinc-900 text-sm text-white px-3 pr-8 focus:outline-none focus:border-amber-600 disabled:opacity-50"
|
|
>
|
|
<option value="">— Not linked —</option>
|
|
{filtered.map((s) => (
|
|
<option key={s.id} value={s.id}>
|
|
{s.shotCode}
|
|
{s.episode ? ` · Ep ${s.episode}` : ""}
|
|
{s.scene ? ` (sc. ${s.scene})` : ""}
|
|
</option>
|
|
))}
|
|
</select>
|
|
{value && (
|
|
<button
|
|
onClick={() => onChange(null)}
|
|
className="shrink-0 p-1.5 rounded-md text-zinc-500 hover:text-red-400 hover:bg-zinc-800 transition-colors"
|
|
title="Unlink shot"
|
|
>
|
|
<X className="h-3.5 w-3.5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
{selected && (
|
|
<div className="flex items-center gap-1.5 text-[11px] text-amber-400">
|
|
<Link2 className="h-3 w-3 shrink-0" />
|
|
<span>
|
|
Linked to <strong>{selected.shotCode}</strong>
|
|
{selected.episode ? ` · Ep ${selected.episode}` : ""}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── 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<FullTake>(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<K extends keyof FullTake>(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 (
|
|
<div className="flex flex-col h-full overflow-hidden">
|
|
{/* ── Nav bar ── */}
|
|
<div className="flex items-center gap-2 px-4 py-3 border-b border-zinc-800 shrink-0 flex-wrap">
|
|
<div className="flex items-center gap-1">
|
|
<Button size="icon-sm" variant="ghost" disabled={!onPrev} onClick={onPrev ?? undefined} title="Previous take (←)">
|
|
<ChevronLeft className="h-4 w-4" />
|
|
</Button>
|
|
<Button size="icon-sm" variant="ghost" disabled={!onNext} onClick={onNext ?? undefined} title="Next take (→)">
|
|
<ChevronRight className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="flex-1 min-w-0">
|
|
<h2 className="text-sm font-semibold text-white">
|
|
Setup {take.setup.shootDay.unit} · Take {take.takeNumber}
|
|
</h2>
|
|
<p className="text-[11px] text-zinc-500">
|
|
{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}` : ""}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Save status */}
|
|
<div className="shrink-0">
|
|
{status === "saving" && <Loader2 className="h-4 w-4 animate-spin text-zinc-500" />}
|
|
{status === "saved" && <Check className="h-4 w-4 text-green-500" />}
|
|
{status === "dirty" && <span className="h-2 w-2 rounded-full bg-amber-400 inline-block" />}
|
|
{status === "error" && <AlertCircle className="h-4 w-4 text-red-500" />}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1 shrink-0">
|
|
<Button size="sm" variant="secondary" onClick={onDuplicate} title="Duplicate take (D)">
|
|
<Copy className="h-3.5 w-3.5" />
|
|
Dup
|
|
</Button>
|
|
<Button size="sm" variant="secondary" onClick={onNewTake} title="New take (N)">
|
|
<Plus className="h-3.5 w-3.5" />
|
|
Take
|
|
</Button>
|
|
<Button size="icon-sm" variant="destructive" onClick={onDelete} title="Delete take">
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Quality bar ── */}
|
|
<div className="flex items-center gap-2 px-4 py-3 border-b border-zinc-800 shrink-0 flex-wrap">
|
|
{QUALITIES.map((q) => (
|
|
<button
|
|
key={q.value}
|
|
onClick={() => set("quality", q.value)}
|
|
className={cn(
|
|
"px-4 py-2 rounded-lg border text-xs font-semibold transition-all min-h-[36px]",
|
|
fields.quality === q.value ? q.active : q.color
|
|
)}
|
|
>
|
|
{q.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* ── Scrollable form ── */}
|
|
<div className="flex-1 overflow-y-auto">
|
|
<div className="px-4 py-4 space-y-5 max-w-4xl">
|
|
|
|
{/* General */}
|
|
<SectionHeader title="General" />
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
|
|
<Field label="Scene">
|
|
<TF value={str(fields.scene)} onChange={(v) => set("scene", v || null)} placeholder="e.g. 12" />
|
|
</Field>
|
|
<Field label="Shot">
|
|
<TF value={str(fields.shotLabel)} onChange={(v) => set("shotLabel", v || null)} placeholder="e.g. 12A" />
|
|
</Field>
|
|
<Field label="Unit">
|
|
<TF value={str(fields.unitLabel)} onChange={(v) => set("unitLabel", v || null)} placeholder="e.g. A" />
|
|
</Field>
|
|
<Field label="Take #">
|
|
<TF value={String(fields.takeNumber)} onChange={() => {}} placeholder="—" className="opacity-60 cursor-default" />
|
|
</Field>
|
|
</div>
|
|
<Field label="Pipeline Shot" className="max-w-sm">
|
|
<ShotSelector
|
|
projectId={take.setup.shootDay.projectId}
|
|
value={fields.pipelineShotId}
|
|
onChange={(id) => set("pipelineShotId", id)}
|
|
/>
|
|
</Field>
|
|
|
|
{/* Camera */}
|
|
<SectionHeader title="Camera" />
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
|
|
<Field label="Camera Letter">
|
|
<TF value={str(fields.cameraLetter)} onChange={(v) => set("cameraLetter", v || null)} placeholder="A" />
|
|
</Field>
|
|
<Field label="Clip Name" className="sm:col-span-2">
|
|
<TF value={str(fields.clipName)} onChange={(v) => set("clipName", v || null)} placeholder="A001_C001" />
|
|
</Field>
|
|
<Field label="Roll">
|
|
<TF value={str(fields.roll)} onChange={(v) => set("roll", v || null)} placeholder="A001" />
|
|
</Field>
|
|
<Field label="Camera Model" className="sm:col-span-2">
|
|
<TF value={str(fields.cameraModel)} onChange={(v) => set("cameraModel", v || null)} placeholder="Alexa Mini LF" />
|
|
</Field>
|
|
<Field label="Resolution">
|
|
<TF value={str(fields.resolution)} onChange={(v) => set("resolution", v || null)} placeholder="4.5K" />
|
|
</Field>
|
|
<Field label="Codec">
|
|
<TF value={str(fields.codec)} onChange={(v) => set("codec", v || null)} placeholder="ARRIRAW" />
|
|
</Field>
|
|
<Field label="FPS">
|
|
<TF
|
|
type="number"
|
|
value={num(fields.fps)}
|
|
onChange={(v) => set("fps", v ? Number(v) : null)}
|
|
placeholder="24"
|
|
/>
|
|
</Field>
|
|
<Field label="Shutter">
|
|
<TF value={str(fields.shutter)} onChange={(v) => set("shutter", v || null)} placeholder="180°" />
|
|
</Field>
|
|
<Field label="ISO">
|
|
<TF
|
|
type="number"
|
|
value={num(fields.iso)}
|
|
onChange={(v) => set("iso", v ? Number(v) : null)}
|
|
placeholder="800"
|
|
/>
|
|
</Field>
|
|
<Field label="White Balance">
|
|
<TF
|
|
type="number"
|
|
value={num(fields.whiteBalance)}
|
|
onChange={(v) => set("whiteBalance", v ? Number(v) : null)}
|
|
placeholder="5600"
|
|
/>
|
|
</Field>
|
|
<Field label="Colour Space" className="sm:col-span-2">
|
|
<TF value={str(fields.colourSpace)} onChange={(v) => set("colourSpace", v || null)} placeholder="LogC3 AWG3" />
|
|
</Field>
|
|
</div>
|
|
|
|
{/* Lens */}
|
|
<SectionHeader title="Lens" />
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
|
|
<Field label="Lens Set" className="sm:col-span-2">
|
|
<TF value={str(fields.lensSet)} onChange={(v) => set("lensSet", v || null)} placeholder="Cooke S4" />
|
|
</Field>
|
|
<Field label="Lens">
|
|
<TF value={str(fields.lens)} onChange={(v) => set("lens", v || null)} placeholder="50mm" />
|
|
</Field>
|
|
<Field label="T Stop">
|
|
<TF value={str(fields.tStop)} onChange={(v) => set("tStop", v || null)} placeholder="T2.8" />
|
|
</Field>
|
|
<Field label="Filters" className="sm:col-span-2">
|
|
<TF value={str(fields.filters)} onChange={(v) => set("filters", v || null)} placeholder="IRND 0.6" />
|
|
</Field>
|
|
<Field label="Anamorphic">
|
|
<button
|
|
onClick={() => set("isAnamorphic", !fields.isAnamorphic)}
|
|
className={cn(
|
|
"h-9 px-3 rounded-lg border text-xs font-medium transition-colors text-left",
|
|
fields.isAnamorphic
|
|
? "bg-amber-500/20 border-amber-600 text-amber-300"
|
|
: "bg-zinc-900 border-zinc-700 text-zinc-500"
|
|
)}
|
|
>
|
|
{fields.isAnamorphic ? "Yes" : "No"}
|
|
</button>
|
|
</Field>
|
|
</div>
|
|
|
|
{/* Tracking */}
|
|
<SectionHeader title="Tracking" />
|
|
<div className="flex flex-wrap gap-2">
|
|
{TRACKING_ITEMS.map(({ key, label }) => {
|
|
const checked = fields[key] as boolean;
|
|
return (
|
|
<button
|
|
key={key}
|
|
onClick={() => set(key as keyof FullTake, !checked as never)}
|
|
className={cn(
|
|
"px-3 py-2 rounded-lg border text-xs font-medium transition-all min-h-[36px]",
|
|
checked
|
|
? "bg-amber-500/15 border-amber-600/60 text-amber-300"
|
|
: "bg-zinc-900 border-zinc-700 text-zinc-500 hover:border-zinc-600 hover:text-zinc-400"
|
|
)}
|
|
>
|
|
{label}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Environment */}
|
|
<SectionHeader title="Environment" />
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
|
<Field label="Weather" className="sm:col-span-1">
|
|
<TF value={str(fields.weather)} onChange={(v) => set("weather", v || null)} placeholder="Overcast" />
|
|
</Field>
|
|
<Field label="Sun Direction">
|
|
<TF value={str(fields.sunDirection)} onChange={(v) => set("sunDirection", v || null)} placeholder="NW high" />
|
|
</Field>
|
|
<Field label="Artificial Lights" className="sm:col-span-3">
|
|
<Textarea
|
|
value={str(fields.artificialLights)}
|
|
onChange={(e) => set("artificialLights", e.target.value || null)}
|
|
placeholder="2x HMI 12K softbox, practical tungsten..."
|
|
className="bg-zinc-900 border-zinc-700 text-sm min-h-[72px] resize-none"
|
|
/>
|
|
</Field>
|
|
</div>
|
|
|
|
{/* Supervisor */}
|
|
<SectionHeader title="Supervisor" />
|
|
<div className="grid gap-3">
|
|
<Field label="Notes">
|
|
<Textarea
|
|
value={str(fields.supervisorNotes)}
|
|
onChange={(e) => set("supervisorNotes", e.target.value || null)}
|
|
placeholder="General notes on this take..."
|
|
className="bg-zinc-900 border-zinc-700 text-sm min-h-[80px] resize-none"
|
|
/>
|
|
</Field>
|
|
<Field label="Continuity">
|
|
<Textarea
|
|
value={str(fields.continuityNotes)}
|
|
onChange={(e) => set("continuityNotes", e.target.value || null)}
|
|
placeholder="Continuity concerns..."
|
|
className="bg-zinc-900 border-zinc-700 text-sm min-h-[72px] resize-none"
|
|
/>
|
|
</Field>
|
|
<Field label="VFX Requirements">
|
|
<Textarea
|
|
value={str(fields.vfxRequirements)}
|
|
onChange={(e) => set("vfxRequirements", e.target.value || null)}
|
|
placeholder="Screen replacement, wire removal, creature interaction..."
|
|
className="bg-zinc-900 border-zinc-700 text-sm min-h-[72px] resize-none"
|
|
/>
|
|
</Field>
|
|
</div>
|
|
|
|
{/* Images */}
|
|
<SectionHeader title="Reference Images" />
|
|
<TakeImageGallery
|
|
takeId={take.id}
|
|
attachments={fields.attachments}
|
|
onChange={(attachments) => {
|
|
setFields((f) => ({ ...f, attachments }));
|
|
onAttachmentsChange();
|
|
}}
|
|
/>
|
|
|
|
{/* Previous Take comparison */}
|
|
{previousTake && (
|
|
<div>
|
|
<button
|
|
onClick={() => setShowPrevPanel((p) => !p)}
|
|
className="flex items-center gap-2 text-xs text-zinc-500 hover:text-zinc-300 transition-colors mb-2"
|
|
>
|
|
{showPrevPanel ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
|
|
Compare with Take {previousTake.takeNumber}
|
|
</button>
|
|
{showPrevPanel && (
|
|
<PreviousTakePanel current={fields} previous={previousTake} />
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Bottom padding */}
|
|
<div className="h-8" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|