"use client"; import { useState, useMemo, useCallback, useEffect } from "react"; import { useQuery } from "@tanstack/react-query"; import { LayoutGrid, Maximize2, Printer, Loader2, Film, ChevronDown, ChevronRight, Star, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { cn } from "@/lib/utils"; // ─── Types ──────────────────────────────────────────────────────────────────── interface Project { id: string; name: string; code: string; projectType: string; } interface Shot { id: string; shotCode: string; scene: string; episode: string | null; shotNumber: number; sequence: string | null; description: string | null; notes: string | null; status: string; thumbnailUrl: string | null; frameStart: number | null; frameEnd: number | null; fps: number; isKeyShot: boolean; shotVersion: string; shotGroup: { id: string; name: string } | null; } type Layout = "standard" | "fullscreen"; type GroupBy = "episode" | "scene" | "group" | "none"; interface ShotGroup { label: string; shots: Shot[]; } interface DisplayOptions { includeDescription: boolean; includeNotes: boolean; includeFrameRange: boolean; includeStatus: boolean; includeVersion: boolean; layout: Layout; columns: number; } // ─── Status config ──────────────────────────────────────────────────────────── const STATUS_LABEL: Record = { WAITING: "Waiting", IN_PROGRESS: "In Progress", INTERNAL_REVIEW: "Internal Review", READY_FOR_CLIENT: "Ready for Client", CLIENT_REVIEW: "Client Review", REVISIONS: "Revisions", COMPLETE: "Complete", }; // ─── Standard layout shot card ───────────────────────────────────────────────── function ShotCard({ shot, opts }: { shot: Shot; opts: DisplayOptions }) { const frameCount = shot.frameStart != null && shot.frameEnd != null ? shot.frameEnd - shot.frameStart + 1 : null; return (
{/* Thumbnail */}
{shot.thumbnailUrl ? ( // eslint-disable-next-line @next/next/no-img-element {shot.shotCode} ) : (
)} {shot.isKeyShot && (
)}
{/* Info */}
{shot.shotCode} {opts.includeVersion && ( {shot.shotVersion} )}
{opts.includeFrameRange && frameCount != null && (
{shot.frameStart}–{shot.frameEnd} ({frameCount}fr)
)} {opts.includeDescription && shot.description && (

{shot.description}

)} {opts.includeNotes && shot.notes && (

{shot.notes}

)} {opts.includeStatus && (
{STATUS_LABEL[shot.status] ?? shot.status}
)}
); } // ─── Standard layout ────────────────────────────────────────────────────────── function StandardLayout({ groups, opts, projectName, pageBreakBetweenGroups, }: { groups: ShotGroup[]; opts: DisplayOptions; projectName: string; pageBreakBetweenGroups: boolean; }) { const colClass: Record = { 2: "grid-cols-2", 3: "grid-cols-3", 4: "grid-cols-4", }; return (
{groups.map((group, gi) => (
0 && "sb-force-break print:mt-0")} > {/* Group header */} {group.label && (
{group.label}

{projectName}  ·  {group.shots.length} shot{group.shots.length !== 1 ? "s" : ""}

)} {/* Shot grid */}
{group.shots.map((shot) => ( ))}
))}
); } // ─── Fullscreen layout ──────────────────────────────────────────────────────── function FullscreenLayout({ groups, opts, projectName, }: { groups: ShotGroup[]; opts: DisplayOptions; projectName: string; }) { const allShots = groups.flatMap((g) => g.shots); return (
{allShots.map((shot, idx) => { const frameCount = shot.frameStart != null && shot.frameEnd != null ? shot.frameEnd - shot.frameStart + 1 : null; const isLast = idx === allShots.length - 1; return (
{/* Full-bleed image */}
{shot.thumbnailUrl ? ( // eslint-disable-next-line @next/next/no-img-element {shot.shotCode} ) : (

No thumbnail

)} {/* Project watermark — top left */}
{projectName}
{/* Shot number — top right */}
{idx + 1} / {allShots.length}
{/* Info bar */}
{shot.shotCode} {shot.isKeyShot && ( Key Shot )} {opts.includeVersion && ( {shot.shotVersion} )}
{shot.episode && Ep {shot.episode}} Scene {shot.scene} {shot.shotGroup && {shot.shotGroup.name}} {opts.includeFrameRange && frameCount != null && ( {shot.frameStart}–{shot.frameEnd}  ({frameCount} fr) )} {opts.includeStatus && ( {STATUS_LABEL[shot.status] ?? shot.status} )}
{opts.includeDescription && shot.description && (

{shot.description}

)} {opts.includeNotes && shot.notes && (

{shot.notes}

)}
); })}
); } // ─── Toggle button ───────────────────────────────────────────────────────────── function ToggleBtn({ active, onClick, children, className, }: { active: boolean; onClick: () => void; children: React.ReactNode; className?: string; }) { return ( ); } // ─── Collapsible section ─────────────────────────────────────────────────────── function Section({ title, children, defaultOpen = true, }: { title: string; children: React.ReactNode; defaultOpen?: boolean; }) { const [open, setOpen] = useState(defaultOpen); return (
{open &&
{children}
}
); } // ─── Checkbox row ───────────────────────────────────────────────────────────── function CheckRow({ label, checked, onChange, }: { label: string; checked: boolean; onChange: (v: boolean) => void; }) { return ( ); } // ─── Main component ─────────────────────────────────────────────────────────── interface Props { projects: Project[]; } export function StoryboardGenerator({ projects }: Props) { const [projectId, setProjectId] = useState(projects[0]?.id ?? ""); const [layout, setLayout] = useState("standard"); const [columns, setColumns] = useState(3); const [groupBy, setGroupBy] = useState("episode"); const [pageBreakBetweenGroups, setPageBreakBetweenGroups] = useState(true); const [showOnlyWithThumbnails, setShowOnlyWithThumbnails] = useState(false); // Filter state — sets of episode/group keys; empty = all const [selectedEpisodes, setSelectedEpisodes] = useState>(new Set()); const [selectedGroups, setSelectedGroups] = useState>(new Set()); // Display options const [includeDescription, setIncludeDescription] = useState(true); const [includeNotes, setIncludeNotes] = useState(false); const [includeFrameRange, setIncludeFrameRange] = useState(true); const [includeStatus, setIncludeStatus] = useState(false); const [includeVersion, setIncludeVersion] = useState(false); // ── Fetch shots ───────────────────────────────────────────────────────────── const { data, isLoading, error } = useQuery<{ shots: Shot[] }>({ queryKey: ["storyboard-shots", projectId], queryFn: async () => { const res = await fetch(`/api/storyboard/shots?projectId=${projectId}`); if (!res.ok) throw new Error("Failed to load shots"); return res.json(); }, enabled: !!projectId, staleTime: 60_000, }); const allShots = data?.shots ?? []; // ── Derived filter options ───────────────────────────────────────────────── const episodes = useMemo( () => [...new Set(allShots.map((s) => s.episode).filter(Boolean) as string[])].sort(), [allShots] ); const shotGroups = useMemo(() => { const seen = new Map(); allShots.forEach((s) => { if (s.shotGroup) seen.set(s.shotGroup.id, s.shotGroup.name); }); return [...seen.entries()].map(([id, name]) => ({ id, name })); }, [allShots]); // Reset filters when project changes useEffect(() => { setSelectedEpisodes(new Set()); setSelectedGroups(new Set()); }, [projectId]); // ── Filtered shots ───────────────────────────────────────────────────────── const filteredShots = useMemo(() => { return allShots.filter((shot) => { if (showOnlyWithThumbnails && !shot.thumbnailUrl) return false; if (selectedEpisodes.size > 0) { const ep = shot.episode ?? ""; if (!selectedEpisodes.has(ep)) return false; } if (selectedGroups.size > 0) { const gid = shot.shotGroup?.id ?? ""; if (!selectedGroups.has(gid)) return false; } return true; }); }, [allShots, selectedEpisodes, selectedGroups, showOnlyWithThumbnails]); // ── Group filtered shots ──────────────────────────────────────────────────── const groupedShots = useMemo((): ShotGroup[] => { if (groupBy === "none") return [{ label: "", shots: filteredShots }]; const map = new Map(); for (const shot of filteredShots) { let key = ""; if (groupBy === "episode") key = shot.episode ? `Episode ${shot.episode}` : "No Episode"; else if (groupBy === "scene") key = `Scene ${shot.scene}`; else if (groupBy === "group") key = shot.shotGroup?.name ?? "No Group"; if (!map.has(key)) map.set(key, []); map.get(key)!.push(shot); } return [...map.entries()].map(([label, shots]) => ({ label, shots })); }, [filteredShots, groupBy]); const project = projects.find((p) => p.id === projectId); const opts: DisplayOptions = { includeDescription, includeNotes, includeFrameRange, includeStatus, includeVersion, layout, columns, }; // ── Toggle helpers ───────────────────────────────────────────────────────── const toggleEpisode = useCallback((ep: string) => { setSelectedEpisodes((prev) => { const next = new Set(prev); next.has(ep) ? next.delete(ep) : next.add(ep); return next; }); }, []); const toggleGroup = useCallback((id: string) => { setSelectedGroups((prev) => { const next = new Set(prev); next.has(id) ? next.delete(id) : next.add(id); return next; }); }, []); // ── Print styles ──────────────────────────────────────────────────────────── const printStyles = useMemo(() => { const isFullscreen = layout === "fullscreen"; return ` @media print { .no-print { display: none !important; } .print-area { display: block !important; } body { background: ${isFullscreen ? "#000" : "#fff"} !important; margin: 0; } @page { size: ${isFullscreen ? "A4 landscape" : "A4 portrait"}; margin: ${isFullscreen ? "0" : "12mm 12mm 10mm 12mm"}; } .sb-page-break { break-after: page; page-break-after: always; } .sb-avoid-break { break-inside: avoid; page-break-inside: avoid; } .sb-force-break { break-before: page; page-break-before: always; } .print\\:bg-white { background: #fff !important; } .print\\:bg-black { background: #000 !important; } .print\\:text-black { color: #000 !important; } .print\\:text-zinc-700 { color: #3f3f46 !important; } .print\\:border-zinc-300 { border-color: #d4d4d8 !important; } .print\\:h-screen { height: 100vh !important; } .print\\:mb-0 { margin-bottom: 0 !important; } .print\\:mt-0 { margin-top: 0 !important; } .print\\:space-y-6 > * + * { margin-top: 1.5rem !important; } .print\\:gap-2 { gap: 0.5rem !important; } .print\\:py-4 { padding-top: 1rem !important; padding-bottom: 1rem !important; } } `; }, [layout]); // ─── Render ────────────────────────────────────────────────────────────────── return ( <> {/* Print styles injected dynamically */}