320 lines
13 KiB
TypeScript
320 lines
13 KiB
TypeScript
"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>
|
|
);
|
|
}
|