Supervisor feature
Deploy / deploy (push) Successful in 3m5s

This commit is contained in:
twotalesanimation
2026-07-11 15:06:12 +02:00
parent 6fc818a3db
commit e24fd8eda0
19 changed files with 3175 additions and 2 deletions
+98
View File
@@ -0,0 +1,98 @@
"use client";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
const schema = z.object({
name: z.string().min(1, "Name required").max(100),
description: z.string().max(300).optional(),
});
type FormData = z.infer<typeof schema>;
interface Props {
shootDayId: string;
open: boolean;
onOpenChange: (open: boolean) => void;
onCreated: (setup: { id: string; name: string }) => void;
}
export function NewSetupDialog({ shootDayId, open, onOpenChange, onCreated }: Props) {
const [loading, setLoading] = useState(false);
const { register, handleSubmit, reset, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: { name: "" },
});
async function onSubmit(data: FormData) {
setLoading(true);
try {
const res = await fetch(`/api/shoot-log/days/${shootDayId}/setups`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error("Failed to create setup");
const { setup } = await res.json();
onCreated(setup);
reset();
onOpenChange(false);
} finally {
setLoading(false);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-sm bg-zinc-900 border-zinc-800">
<DialogHeader>
<DialogTitle className="text-white">New Setup</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-1.5">
<Label className="text-zinc-400 text-xs">Setup Name *</Label>
<Input
{...register("name")}
placeholder="A, B, or descriptive name"
autoFocus
className="bg-zinc-800 border-zinc-700 text-white"
/>
{errors.name && <p className="text-red-400 text-xs">{errors.name.message}</p>}
</div>
<div className="space-y-1.5">
<Label className="text-zinc-400 text-xs">Description (optional)</Label>
<Input
{...register("description")}
placeholder="Camera position, location..."
className="bg-zinc-800 border-zinc-700 text-white"
/>
</div>
<DialogFooter className="gap-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>
Cancel
</Button>
<Button type="submit" disabled={loading}>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : "Create Setup"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
+136
View File
@@ -0,0 +1,136 @@
"use client";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { format } from "date-fns";
import { CalendarIcon, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
const schema = z.object({
date: z.string().min(1, "Date is required"),
unit: z.string().max(20).default("A"),
label: z.string().max(120).optional(),
notes: z.string().optional(),
});
type FormData = z.infer<typeof schema>;
interface Props {
projectId: string;
open: boolean;
onOpenChange: (open: boolean) => void;
onCreate: (day: { id: string; date: string; unit: string; label: string | null }) => void;
}
export function NewShootDayDialog({ projectId, open, onOpenChange, onCreate }: Props) {
const [loading, setLoading] = useState(false);
const {
register,
handleSubmit,
reset,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
date: format(new Date(), "yyyy-MM-dd"),
unit: "A",
},
});
async function onSubmit(data: FormData) {
setLoading(true);
try {
const res = await fetch("/api/shoot-log/days", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ projectId, ...data }),
});
if (!res.ok) throw new Error("Failed to create shoot day");
const { day } = await res.json();
onCreate(day);
reset({ date: format(new Date(), "yyyy-MM-dd"), unit: "A" });
onOpenChange(false);
} finally {
setLoading(false);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md bg-zinc-900 border-zinc-800">
<DialogHeader>
<DialogTitle className="text-white">New Shoot Day</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-zinc-400 text-xs">Date *</Label>
<Input
type="date"
{...register("date")}
className="bg-zinc-800 border-zinc-700 text-white"
/>
{errors.date && (
<p className="text-red-400 text-xs">{errors.date.message}</p>
)}
</div>
<div className="space-y-1.5">
<Label className="text-zinc-400 text-xs">Unit</Label>
<Input
{...register("unit")}
placeholder="A"
className="bg-zinc-800 border-zinc-700 text-white"
/>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-zinc-400 text-xs">Label (optional)</Label>
<Input
{...register("label")}
placeholder="e.g. Ext. Warehouse — Night"
className="bg-zinc-800 border-zinc-700 text-white"
/>
</div>
<div className="space-y-1.5">
<Label className="text-zinc-400 text-xs">Notes (optional)</Label>
<Input
{...register("notes")}
placeholder="Any notes about this shoot day..."
className="bg-zinc-800 border-zinc-700 text-white"
/>
</div>
<DialogFooter className="gap-2">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={loading}
>
Cancel
</Button>
<Button type="submit" disabled={loading}>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : "Create Day"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
+104
View File
@@ -0,0 +1,104 @@
"use client";
import { cn } from "@/lib/utils";
import type { FullTake } from "./TakeEditorPanel";
// Fields to compare (label → key)
const COMPARE_FIELDS: { label: string; key: keyof FullTake; format?: (v: unknown) => string }[] = [
{ label: "Scene", key: "scene" },
{ label: "Shot", key: "shotLabel" },
{ label: "Camera", key: "cameraLetter" },
{ label: "Clip Name", key: "clipName" },
{ label: "Camera Model", key: "cameraModel" },
{ label: "Resolution", key: "resolution" },
{ label: "Codec", key: "codec" },
{ label: "FPS", key: "fps" },
{ label: "Shutter", key: "shutter" },
{ label: "ISO", key: "iso" },
{ label: "White Balance", key: "whiteBalance" },
{ label: "Colour Space", key: "colourSpace" },
{ label: "Lens Set", key: "lensSet" },
{ label: "Lens", key: "lens" },
{ label: "T Stop", key: "tStop" },
{ label: "Filters", key: "filters" },
{ label: "Anamorphic", key: "isAnamorphic", format: (v) => (v ? "Yes" : "No") },
{ label: "Weather", key: "weather" },
{ label: "Sun Direction", key: "sunDirection" },
];
function display(value: unknown, format?: (v: unknown) => string): string {
if (value === null || value === undefined || value === "") return "—";
if (format) return format(value);
return String(value);
}
function isChanged(a: unknown, b: unknown): boolean {
if (a === null || a === undefined) a = "";
if (b === null || b === undefined) b = "";
return String(a) !== String(b);
}
interface Props {
current: FullTake;
previous: FullTake;
}
export function PreviousTakePanel({ current, previous }: Props) {
const changedFields = COMPARE_FIELDS.filter(({ key, format }) =>
isChanged(display(current[key], format), display(previous[key], format))
);
const unchangedFields = COMPARE_FIELDS.filter(({ key, format }) =>
!isChanged(display(current[key], format), display(previous[key], format))
);
return (
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40 overflow-hidden text-xs">
<div className="grid grid-cols-[1fr_1fr_1fr] border-b border-zinc-800">
<div className="px-3 py-2 text-zinc-600 font-semibold uppercase tracking-wider text-[10px]">Field</div>
<div className="px-3 py-2 text-zinc-600 font-semibold uppercase tracking-wider text-[10px]">
T{previous.takeNumber} (prev)
</div>
<div className="px-3 py-2 text-zinc-600 font-semibold uppercase tracking-wider text-[10px]">
T{current.takeNumber} (current)
</div>
</div>
{/* Changed fields — highlighted */}
{changedFields.map(({ label, key, format }) => (
<div
key={String(key)}
className="grid grid-cols-[1fr_1fr_1fr] border-b border-zinc-800 bg-amber-500/5"
>
<div className="px-3 py-2 text-amber-400/70 font-medium">{label}</div>
<div className="px-3 py-2 text-zinc-400 line-through">
{display(previous[key], format)}
</div>
<div className="px-3 py-2 text-amber-300 font-semibold">
{display(current[key], format)}
</div>
</div>
))}
{/* Unchanged fields */}
{unchangedFields.map(({ label, key, format }) => {
const val = display(current[key], format);
if (val === "—") return null;
return (
<div
key={String(key)}
className="grid grid-cols-[1fr_1fr_1fr] border-b border-zinc-800/50 opacity-50"
>
<div className="px-3 py-1.5 text-zinc-500">{label}</div>
<div className="px-3 py-1.5 text-zinc-400">{val}</div>
<div className="px-3 py-1.5 text-zinc-400">{val}</div>
</div>
);
})}
{changedFields.length === 0 && (
<div className="px-3 py-3 text-zinc-600 italic">No changes from previous take.</div>
)}
</div>
);
}
+233
View File
@@ -0,0 +1,233 @@
"use client";
import { useState, useCallback, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Clapperboard, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { TakeListPanel } from "./TakeListPanel";
import { TakeEditorPanel, type FullTake } from "./TakeEditorPanel";
import { NewShootDayDialog } from "./NewShootDayDialog";
import { NewSetupDialog } from "./NewSetupDialog";
// ─── Types ────────────────────────────────────────────────────────────────────
interface Project {
id: string;
name: string;
code: string;
}
// ─── Component ────────────────────────────────────────────────────────────────
interface Props {
projects: Project[];
}
export function ShootLogClient({ projects }: Props) {
const [projectId, setProjectId] = useState<string>(projects[0]?.id ?? "");
const [selectedTakeId, setSelectedTakeId] = useState<string | null>(null);
const [selectedSetupId, setSelectedSetupId] = useState<string | null>(null);
// Dialog state
const [newDayOpen, setNewDayOpen] = useState(false);
const [newSetupDayId, setNewSetupDayId] = useState<string | null>(null);
// Refresh token to force TakeListPanel to refetch
const [refreshToken, setRefreshToken] = useState(0);
const refresh = useCallback(() => setRefreshToken((t) => t + 1), []);
// ── Fetch selected take ────────────────────────────────────────────────────
const { data: takeData, isLoading: takeLoading } = useQuery<{ take: FullTake }>({
queryKey: ["take", selectedTakeId],
queryFn: async () => {
const res = await fetch(`/api/shoot-log/takes/${selectedTakeId}`);
if (!res.ok) throw new Error("Failed to load take");
return res.json();
},
enabled: !!selectedTakeId,
staleTime: 0,
});
const take = takeData?.take ?? null;
// ── Fetch previous take (same setup, takeNumber - 1) ──────────────────────
const prevTakeId = take?.setup.takes.find(
(t) => t.takeNumber === (take?.takeNumber ?? 0) - 1
)?.id;
const { data: prevTakeData } = useQuery<{ take: FullTake }>({
queryKey: ["take", prevTakeId],
queryFn: async () => {
const res = await fetch(`/api/shoot-log/takes/${prevTakeId}`);
if (!res.ok) throw new Error("Failed to load previous take");
return res.json();
},
enabled: !!prevTakeId,
staleTime: 60_000,
});
const previousTake = prevTakeData?.take ?? null;
// ── Navigation ─────────────────────────────────────────────────────────────
const allTakesInSetup = take?.setup.takes ?? [];
const currentIdx = allTakesInSetup.findIndex((t) => t.id === selectedTakeId);
const prevTakeNav = currentIdx > 0 ? allTakesInSetup[currentIdx - 1] : null;
const nextTakeNav = currentIdx < allTakesInSetup.length - 1 ? allTakesInSetup[currentIdx + 1] : null;
// ── Actions ────────────────────────────────────────────────────────────────
const createTake = useCallback(
async (setupId: string, initialData?: object) => {
const res = await fetch(`/api/shoot-log/setups/${setupId}/takes`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(initialData ?? {}),
});
if (!res.ok) throw new Error("Failed to create take");
const { take } = await res.json();
refresh();
setSelectedTakeId(take.id);
setSelectedSetupId(setupId);
},
[refresh]
);
const handleDuplicate = useCallback(async () => {
if (!take) return;
// Build initial data from current take (omit id, notes, attachments)
const { id, takeNumber, attachments, setup, createdById, createdAt, updatedAt, ...rest } = take as FullTake & Record<string, unknown>;
void id; void takeNumber; void attachments; void setup; void createdById; void createdAt; void updatedAt;
// Increment clip name
const clipName = incrementClipName(take.clipName);
const initial = { ...rest, clipName, supervisorNotes: null, continuityNotes: null, vfxRequirements: null };
await createTake(take.setupId, initial);
}, [take, createTake]);
const handleDelete = useCallback(async () => {
if (!selectedTakeId) return;
if (!confirm("Delete this take? This cannot be undone.")) return;
await fetch(`/api/shoot-log/takes/${selectedTakeId}`, { method: "DELETE" });
setSelectedTakeId(null);
refresh();
}, [selectedTakeId, refresh]);
// ── Render ─────────────────────────────────────────────────────────────────
if (!projectId) {
return (
<div className="flex flex-col items-center justify-center h-full gap-4 text-zinc-500">
<Clapperboard className="h-12 w-12 text-zinc-700" />
<p>No active projects found.</p>
</div>
);
}
return (
<>
{/* Project selector (top bar) */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-zinc-800 bg-zinc-900/60 shrink-0">
<Clapperboard className="h-4 w-4 text-amber-400 shrink-0" />
<span className="text-xs font-semibold text-zinc-400 uppercase tracking-wider">Shot Log</span>
<div className="ml-2">
<select
value={projectId}
onChange={(e) => {
setProjectId(e.target.value);
setSelectedTakeId(null);
setSelectedSetupId(null);
setRefreshToken((t) => t + 1);
}}
className="bg-zinc-800 border border-zinc-700 rounded-lg text-sm text-white px-3 py-1.5 focus:outline-none focus:border-amber-600"
>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.code} {p.name}
</option>
))}
</select>
</div>
{/* Keyboard hint */}
<div className="ml-auto hidden lg:flex items-center gap-3 text-[10px] text-zinc-600">
<span> navigate</span>
<span>D duplicate</span>
<span>N new take</span>
</div>
</div>
{/* Split layout */}
<div className="flex flex-1 overflow-hidden min-h-0">
{/* Left panel */}
<div className="w-72 xl:w-80 shrink-0 border-r border-zinc-800 overflow-hidden flex flex-col">
<TakeListPanel
projectId={projectId}
selectedTakeId={selectedTakeId}
refreshToken={refreshToken}
onSelectTake={(takeId, setupId) => {
setSelectedTakeId(takeId);
setSelectedSetupId(setupId);
}}
onNewDay={() => setNewDayOpen(true)}
onNewSetup={(dayId) => setNewSetupDayId(dayId)}
onNewTake={(setupId) => createTake(setupId)}
/>
</div>
{/* Right panel */}
<div className="flex-1 overflow-hidden flex flex-col">
{takeLoading ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-6 w-6 animate-spin text-zinc-500" />
</div>
) : take ? (
<TakeEditorPanel
key={take.id}
take={take}
previousTake={previousTake}
onPrev={prevTakeNav ? () => setSelectedTakeId(prevTakeNav.id) : null}
onNext={nextTakeNav ? () => setSelectedTakeId(nextTakeNav.id) : null}
onDuplicate={handleDuplicate}
onNewTake={() => createTake(take.setupId)}
onDelete={handleDelete}
onAttachmentsChange={refresh}
/>
) : (
<div className="flex flex-col items-center justify-center h-full gap-3 text-zinc-600">
<Clapperboard className="h-10 w-10 text-zinc-700" />
<p className="text-sm">Select a take or create a new shoot day</p>
</div>
)}
</div>
</div>
{/* Dialogs */}
<NewShootDayDialog
projectId={projectId}
open={newDayOpen}
onOpenChange={setNewDayOpen}
onCreate={refresh}
/>
{newSetupDayId && (
<NewSetupDialog
shootDayId={newSetupDayId}
open={!!newSetupDayId}
onOpenChange={(o) => { if (!o) setNewSetupDayId(null); }}
onCreated={() => {
setNewSetupDayId(null);
refresh();
}}
/>
)}
</>
);
}
/** Increment trailing number in clip name: A001_C002 → A001_C003 */
function incrementClipName(clipName: string | null | undefined): string | null {
if (!clipName) return null;
const match = clipName.match(/^(.*?)(\d+)$/);
if (!match) return clipName;
const [, prefix, numStr] = match;
const next = String(Number(numStr) + 1).padStart(numStr.length, "0");
return `${prefix}${next}`;
}
+532
View File
@@ -0,0 +1,532 @@
"use client";
import { useEffect, useRef, useState, useCallback } from "react";
import {
ChevronLeft, ChevronRight, Copy, Plus, Check, Loader2,
Trash2, AlertCircle, ChevronDown, ChevronUp,
} 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;
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>
);
}
// ─── 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>
{/* 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>
);
}
+258
View File
@@ -0,0 +1,258 @@
"use client";
import { useRef, useState, useCallback } from "react";
import { Upload, X, Loader2, ImageIcon, ZoomIn } from "lucide-react";
import { cn } from "@/lib/utils";
import type { TakeAttachmentData } from "./TakeEditorPanel";
// ─── Lightbox ─────────────────────────────────────────────────────────────────
function Lightbox({
attachments,
index,
onClose,
}: {
attachments: TakeAttachmentData[];
index: number;
onClose: () => void;
}) {
const [current, setCurrent] = useState(index);
const att = attachments[current];
return (
<div
className="fixed inset-0 z-50 bg-black/90 flex flex-col items-center justify-center"
onClick={onClose}
>
<button
className="absolute top-4 right-4 text-white/70 hover:text-white p-2"
onClick={onClose}
>
<X className="h-6 w-6" />
</button>
{/* Image */}
<div
className="max-w-[90vw] max-h-[80vh] overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={att.fileUrl}
alt={att.fileName}
className="max-w-full max-h-[80vh] object-contain rounded-lg"
/>
</div>
{/* Caption */}
{att.caption && (
<p className="mt-3 text-sm text-zinc-300 max-w-lg text-center">{att.caption}</p>
)}
<p className="mt-1 text-xs text-zinc-500">{att.fileName}</p>
{/* Prev / Next */}
{attachments.length > 1 && (
<div className="flex gap-4 mt-4" onClick={(e) => e.stopPropagation()}>
<button
disabled={current === 0}
onClick={() => setCurrent((c) => c - 1)}
className="px-4 py-2 rounded bg-zinc-800 text-zinc-300 disabled:opacity-30 hover:bg-zinc-700 text-sm"
>
Prev
</button>
<span className="text-zinc-500 text-sm self-center">
{current + 1} / {attachments.length}
</span>
<button
disabled={current === attachments.length - 1}
onClick={() => setCurrent((c) => c + 1)}
className="px-4 py-2 rounded bg-zinc-800 text-zinc-300 disabled:opacity-30 hover:bg-zinc-700 text-sm"
>
Next
</button>
</div>
)}
</div>
);
}
// ─── Main component ───────────────────────────────────────────────────────────
interface Props {
takeId: string;
attachments: TakeAttachmentData[];
onChange: (updated: TakeAttachmentData[]) => void;
}
export function TakeImageGallery({ takeId, attachments, onChange }: Props) {
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
const [dragOver, setDragOver] = useState(false);
const imageAttachments = attachments.filter((a) => a.fileType === "IMAGE");
const uploadFile = useCallback(
async (file: File) => {
const formData = new FormData();
formData.append("file", file);
formData.append("type", "image");
const res = await fetch("/api/upload", { method: "POST", body: formData });
if (!res.ok) throw new Error("Upload failed");
const { url, key } = await res.json();
// Register attachment in DB
const attachRes = await fetch(`/api/shoot-log/takes/${takeId}/attachments`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
fileUrl: url,
fileKey: key ?? "",
fileName: file.name,
fileSize: file.size,
fileType: "IMAGE",
category: "MISCELLANEOUS",
}),
});
if (!attachRes.ok) throw new Error("Failed to save attachment");
const { attachment } = await attachRes.json();
return attachment as TakeAttachmentData;
},
[takeId]
);
const handleFiles = useCallback(
async (files: FileList | File[]) => {
const imageFiles = Array.from(files).filter((f) => f.type.startsWith("image/"));
if (!imageFiles.length) return;
setUploading(true);
try {
const results = await Promise.all(imageFiles.map(uploadFile));
onChange([...attachments, ...results]);
} finally {
setUploading(false);
}
},
[attachments, onChange, uploadFile]
);
const handleDelete = useCallback(
async (id: string) => {
const res = await fetch(
`/api/shoot-log/takes/${takeId}/attachments/${id}`,
{ method: "DELETE" }
);
if (res.ok) {
onChange(attachments.filter((a) => a.id !== id));
}
},
[attachments, onChange, takeId]
);
const onDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
handleFiles(e.dataTransfer.files);
},
[handleFiles]
);
return (
<div className="space-y-3">
{/* Upload zone */}
<div
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={onDrop}
className={cn(
"border-2 border-dashed rounded-lg p-4 text-center transition-colors cursor-pointer",
dragOver
? "border-amber-500 bg-amber-500/5"
: "border-zinc-700 hover:border-zinc-600"
)}
onClick={() => fileInputRef.current?.click()}
>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
capture="environment"
className="hidden"
onChange={(e) => e.target.files && handleFiles(e.target.files)}
/>
{uploading ? (
<div className="flex items-center justify-center gap-2 text-zinc-400">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm">Uploading...</span>
</div>
) : (
<div className="flex items-center justify-center gap-2 text-zinc-500">
<Upload className="h-4 w-4" />
<span className="text-sm">
Drop images here, tap to upload, or use camera
</span>
</div>
)}
</div>
{/* Thumbnail grid */}
{imageAttachments.length > 0 && (
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-2">
{imageAttachments.map((att, i) => (
<div key={att.id} className="relative group aspect-square">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={att.fileUrl}
alt={att.caption ?? att.fileName}
className="w-full h-full object-cover rounded-lg bg-zinc-800 cursor-pointer"
onClick={() => setLightboxIndex(i)}
/>
{/* Overlay */}
<div className="absolute inset-0 rounded-lg bg-black/0 group-hover:bg-black/40 transition-colors flex items-center justify-center">
<div className="opacity-0 group-hover:opacity-100 flex gap-1 transition-opacity">
<button
onClick={() => setLightboxIndex(i)}
className="p-1.5 rounded bg-black/60 text-white hover:bg-black/80"
>
<ZoomIn className="h-3.5 w-3.5" />
</button>
<button
onClick={() => handleDelete(att.id)}
className="p-1.5 rounded bg-black/60 text-red-400 hover:bg-black/80"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
</div>
{/* Caption badge */}
{att.caption && (
<div className="absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/60 rounded-b-lg text-[9px] text-zinc-300 truncate">
{att.caption}
</div>
)}
</div>
))}
</div>
)}
{imageAttachments.length === 0 && !uploading && (
<div className="flex items-center gap-2 text-zinc-600 text-xs py-2">
<ImageIcon className="h-4 w-4" />
No images yet
</div>
)}
{/* Lightbox */}
{lightboxIndex !== null && (
<Lightbox
attachments={imageAttachments}
index={lightboxIndex}
onClose={() => setLightboxIndex(null)}
/>
)}
</div>
);
}
+319
View File
@@ -0,0 +1,319 @@
"use client";
import { useState, useCallback, useRef, useEffect } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { format } from "date-fns";
import { Plus, ChevronDown, ChevronRight, Image, MessageSquare, Loader2, Clapperboard } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { TakeQuality } from "@prisma/client";
// ─── Types ───────────────────────────────────────────────────────────────────
export interface TakeSummary {
id: string;
takeNumber: number;
clipName: string | null;
quality: TakeQuality;
supervisorNotes: string | null;
continuityNotes: string | null;
vfxRequirements: string | null;
_count: { attachments: number };
}
export interface SetupWithTakes {
id: string;
name: string;
description: string | null;
sortOrder: number;
takes: TakeSummary[];
}
export interface ShootDayWithSetups {
id: string;
date: string;
unit: string;
label: string | null;
setups: SetupWithTakes[];
}
// ─── Quality helpers ──────────────────────────────────────────────────────────
const QUALITY_DOT: Record<TakeQuality, string> = {
HERO: "bg-green-400",
GOOD: "bg-blue-400",
PRINT: "bg-amber-400",
NO_GOOD: "bg-red-500",
FALSE_START: "bg-zinc-500",
};
const QUALITY_LABEL: Record<TakeQuality, string> = {
HERO: "Hero",
GOOD: "Good",
PRINT: "Print",
NO_GOOD: "NG",
FALSE_START: "FS",
};
// ─── Component ───────────────────────────────────────────────────────────────
interface Props {
projectId: string;
selectedTakeId: string | null;
onSelectTake: (takeId: string, setupId: string) => void;
onNewDay: () => void;
onNewSetup: (dayId: string) => void;
onNewTake: (setupId: string) => void;
refreshToken?: number;
}
export function TakeListPanel({
projectId,
selectedTakeId,
onSelectTake,
onNewDay,
onNewSetup,
onNewTake,
refreshToken,
}: Props) {
const [expandedDays, setExpandedDays] = useState<Set<string>>(new Set());
const [expandedSetups, setExpandedSetups] = useState<Set<string>>(new Set());
const autoExpandedRef = useRef(false);
const { data, isLoading } = useQuery<{ days: ShootDayWithSetups[] }>({
queryKey: ["shoot-log-days", projectId, refreshToken],
queryFn: async () => {
const res = await fetch(`/api/shoot-log/days?projectId=${projectId}`);
if (!res.ok) throw new Error("Failed to load days");
return res.json();
},
enabled: !!projectId,
staleTime: 30_000,
});
const days = data?.days ?? [];
const toggleDay = useCallback((id: string) => {
setExpandedDays((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}, []);
const toggleSetup = useCallback((id: string) => {
setExpandedSetups((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}, []);
// Auto-expand the first day/setup on first load
useEffect(() => {
if (!autoExpandedRef.current && days.length > 0) {
autoExpandedRef.current = true;
const firstDay = days[0];
setExpandedDays(new Set([firstDay.id]));
if (firstDay.setups.length > 0) {
setExpandedSetups(new Set([firstDay.setups[0].id]));
}
}
}, [days]);
const hasNotes = (take: TakeSummary) =>
!!(take.supervisorNotes || take.continuityNotes || take.vfxRequirements);
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="px-3 py-3 border-b border-zinc-800 flex items-center justify-between gap-2 shrink-0">
<span className="text-xs font-semibold text-zinc-400 uppercase tracking-wider">
Shoot Days
</span>
<Button size="icon-sm" variant="ghost" onClick={onNewDay} title="New Shoot Day">
<Plus className="h-4 w-4" />
</Button>
</div>
{/* List */}
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<div className="flex justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-zinc-500" />
</div>
) : days.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 px-4 text-center gap-3">
<Clapperboard className="h-8 w-8 text-zinc-600" />
<p className="text-sm text-zinc-500">No shoot days yet.</p>
<Button size="sm" variant="outline" onClick={onNewDay}>
<Plus className="h-3 w-3 mr-1" />
New Shoot Day
</Button>
</div>
) : (
<div className="py-2">
{days.map((day) => {
const dayOpen = expandedDays.has(day.id);
const takeCount = day.setups.reduce((s, set) => s + set.takes.length, 0);
return (
<div key={day.id}>
{/* Day row */}
<button
onClick={() => toggleDay(day.id)}
className="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-zinc-800/60 transition-colors group"
>
{dayOpen ? (
<ChevronDown className="h-3.5 w-3.5 text-zinc-500 shrink-0" />
) : (
<ChevronRight className="h-3.5 w-3.5 text-zinc-500 shrink-0" />
)}
<div className="flex-1 min-w-0">
<div className="text-xs font-semibold text-zinc-200 truncate">
{format(new Date(day.date), "EEE d MMM yyyy")}
{day.unit && day.unit !== "A" && (
<span className="ml-1 text-zinc-500">· Unit {day.unit}</span>
)}
</div>
{day.label && (
<div className="text-[11px] text-zinc-500 truncate">{day.label}</div>
)}
</div>
<span className="text-[10px] text-zinc-600 shrink-0">{takeCount}t</span>
<Button
size="icon-sm"
variant="ghost"
className="h-6 w-6 opacity-0 group-hover:opacity-100 shrink-0"
onClick={(e) => {
e.stopPropagation();
onNewSetup(day.id);
}}
title="New Setup"
>
<Plus className="h-3 w-3" />
</Button>
</button>
{/* Setups */}
{dayOpen && (
<div>
{day.setups.length === 0 ? (
<div className="pl-8 pr-3 py-1">
<button
onClick={() => onNewSetup(day.id)}
className="text-[11px] text-zinc-600 hover:text-zinc-400 transition-colors"
>
+ Add setup
</button>
</div>
) : (
day.setups.map((setup) => {
const setupOpen = expandedSetups.has(setup.id);
return (
<div key={setup.id}>
{/* Setup row */}
<button
onClick={() => toggleSetup(setup.id)}
className="w-full flex items-center gap-2 pl-6 pr-3 py-1.5 text-left hover:bg-zinc-800/40 transition-colors group"
>
{setupOpen ? (
<ChevronDown className="h-3 w-3 text-zinc-600 shrink-0" />
) : (
<ChevronRight className="h-3 w-3 text-zinc-600 shrink-0" />
)}
<span className="flex-1 text-xs font-medium text-zinc-300 truncate">
Setup {setup.name}
</span>
<span className="text-[10px] text-zinc-600 shrink-0">
{setup.takes.length}t
</span>
<Button
size="icon-sm"
variant="ghost"
className="h-5 w-5 opacity-0 group-hover:opacity-100 shrink-0"
onClick={(e) => {
e.stopPropagation();
onNewTake(setup.id);
}}
title="New Take"
>
<Plus className="h-3 w-3" />
</Button>
</button>
{/* Takes */}
{setupOpen && (
<div>
{setup.takes.map((take) => (
<button
key={take.id}
onClick={() => onSelectTake(take.id, setup.id)}
className={cn(
"w-full flex items-center gap-2 pl-10 pr-3 py-2 text-left transition-colors",
selectedTakeId === take.id
? "bg-amber-500/10 text-amber-300"
: "hover:bg-zinc-800/40 text-zinc-300"
)}
>
{/* Quality dot */}
<span
className={cn(
"h-2 w-2 rounded-full shrink-0",
QUALITY_DOT[take.quality]
)}
/>
{/* Take number */}
<span className="text-[11px] font-mono text-zinc-500 w-5 shrink-0">
T{take.takeNumber}
</span>
{/* Clip name */}
<span
className={cn(
"flex-1 text-xs truncate",
selectedTakeId === take.id
? "text-amber-200"
: "text-zinc-300"
)}
>
{take.clipName ?? (
<span className="text-zinc-600 italic">No clip name</span>
)}
</span>
{/* Indicators */}
<div className="flex items-center gap-1 shrink-0">
{hasNotes(take) && (
<MessageSquare className="h-3 w-3 text-zinc-500" />
)}
{take._count.attachments > 0 && (
<span className="flex items-center gap-0.5 text-[10px] text-zinc-500">
<Image className="h-3 w-3" />
{take._count.attachments}
</span>
)}
</div>
</button>
))}
{/* Add take button */}
<button
onClick={() => onNewTake(setup.id)}
className="w-full pl-10 pr-3 py-1.5 text-left text-[11px] text-zinc-600 hover:text-zinc-400 transition-colors"
>
+ New take
</button>
</div>
)}
</div>
);
})
)}
</div>
)}
</div>
);
})}
</div>
)}
</div>
</div>
);
}