"use client"; import { useState, useMemo, useCallback } 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; }); }, []); // ── PDF URL (opens in new tab → auto-triggers browser print dialog) ───────────────────── const pdfUrl = useMemo(() => { const params = new URLSearchParams({ projectId, layout, columns: String(columns), groupBy, desc: includeDescription ? "1" : "0", notes: includeNotes ? "1" : "0", frameRange: includeFrameRange ? "1" : "0", status: includeStatus ? "1" : "0", version: includeVersion ? "1" : "0", pageBreaks: pageBreakBetweenGroups ? "1" : "0", onlyThumbs: showOnlyWithThumbnails ? "1" : "0", }); if (selectedEpisodes.size > 0) params.set("episodes", [...selectedEpisodes].join(",")); if (selectedGroups.size > 0) params.set("groups", [...selectedGroups].join(",")); return `/api/storyboard/pdf?${params}`; }, [ projectId, layout, columns, groupBy, includeDescription, includeNotes, includeFrameRange, includeStatus, includeVersion, pageBreakBetweenGroups, showOnlyWithThumbnails, selectedEpisodes, selectedGroups, ]); // ─── Render ────────────────────────────────────────────────────────────────── return (
{/* ── Controls sidebar ── */}
{/* Header */}

Storyboard Generator

{filteredShots.length} shot{filteredShots.length !== 1 ? "s" : ""} selected

{/* Project */}
{/* Layout */}
setLayout("standard")} className="flex-1 justify-center"> Standard setLayout("fullscreen")} className="flex-1 justify-center"> Fullscreen
{layout === "standard" && (
{([2, 3, 4] as const).map((c) => ( ))}
)}
{/* Group by */}
{(["episode", "scene", "group", "none"] as GroupBy[]).map((g) => ( ))} {groupBy !== "none" && ( )}
{/* Filter by episode */} {episodes.length > 0 && (
{episodes.map((ep) => ( ))}
)} {/* Filter by shot group */} {shotGroups.length > 0 && (
{shotGroups.map((g) => ( ))}
)} {/* Content options */}
{/* Generate button */}
{layout === "fullscreen" && (

Tip: choose Landscape in the print dialog

)} {filteredShots.length > 0 ? ( ) : ( )}
{/* ── Preview area ── */}
{/* Preview toolbar */}
Preview
{layout === "standard" ? `${columns} cols` : "1 per page"} ·{" "} {filteredShots.length} shots ·{" "} {layout === "fullscreen" ? "Landscape PDF" : "Portrait PDF"}
{/* Storyboard content */}
{isLoading ? (
) : error ? (
Failed to load shots
) : filteredShots.length === 0 ? (

No shots to display

{showOnlyWithThumbnails && (

Try unchecking "Only shots with thumbnails"

)}
) : layout === "standard" ? ( ) : ( )}
); }