804 lines
30 KiB
TypeScript
804 lines
30 KiB
TypeScript
"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<string, string> = {
|
||
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 (
|
||
<div
|
||
className={cn(
|
||
"sb-avoid-break rounded-sm overflow-hidden border border-zinc-700 bg-zinc-900",
|
||
"print:border-zinc-300 print:bg-white print:rounded-none"
|
||
)}
|
||
>
|
||
{/* Thumbnail */}
|
||
<div className="relative w-full aspect-[2.39] bg-zinc-950 print:bg-zinc-100">
|
||
{shot.thumbnailUrl ? (
|
||
// eslint-disable-next-line @next/next/no-img-element
|
||
<img
|
||
src={shot.thumbnailUrl}
|
||
alt={shot.shotCode}
|
||
loading="lazy"
|
||
className="w-full h-full object-cover"
|
||
/>
|
||
) : (
|
||
<div className="flex items-center justify-center w-full h-full sb-no-thumb">
|
||
<Film className="h-6 w-6 text-zinc-700 print:text-zinc-400" />
|
||
</div>
|
||
)}
|
||
{shot.isKeyShot && (
|
||
<div className="absolute top-1 right-1 bg-amber-500/90 rounded-sm p-0.5">
|
||
<Star className="h-2.5 w-2.5 text-white" fill="white" />
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Info */}
|
||
<div className="p-2 space-y-0.5">
|
||
<div className="flex items-baseline justify-between gap-1">
|
||
<span className="font-mono text-[10px] font-bold text-white print:text-black leading-tight truncate">
|
||
{shot.shotCode}
|
||
</span>
|
||
{opts.includeVersion && (
|
||
<span className="font-mono text-[9px] text-zinc-500 print:text-zinc-400 shrink-0">
|
||
{shot.shotVersion}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{opts.includeFrameRange && frameCount != null && (
|
||
<div className="text-[9px] text-zinc-500 print:text-zinc-500">
|
||
{shot.frameStart}–{shot.frameEnd} ({frameCount}fr)
|
||
</div>
|
||
)}
|
||
|
||
{opts.includeDescription && shot.description && (
|
||
<p className="text-[10px] text-zinc-400 print:text-zinc-700 leading-tight line-clamp-3">
|
||
{shot.description}
|
||
</p>
|
||
)}
|
||
|
||
{opts.includeNotes && shot.notes && (
|
||
<p className="text-[10px] text-zinc-500 print:text-zinc-500 italic leading-tight line-clamp-2">
|
||
{shot.notes}
|
||
</p>
|
||
)}
|
||
|
||
{opts.includeStatus && (
|
||
<div className="text-[9px] text-zinc-600 print:text-zinc-400 uppercase tracking-wide">
|
||
{STATUS_LABEL[shot.status] ?? shot.status}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Standard layout ──────────────────────────────────────────────────────────
|
||
|
||
function StandardLayout({
|
||
groups,
|
||
opts,
|
||
projectName,
|
||
pageBreakBetweenGroups,
|
||
}: {
|
||
groups: ShotGroup[];
|
||
opts: DisplayOptions;
|
||
projectName: string;
|
||
pageBreakBetweenGroups: boolean;
|
||
}) {
|
||
const colClass: Record<number, string> = {
|
||
2: "grid-cols-2",
|
||
3: "grid-cols-3",
|
||
4: "grid-cols-4",
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-8 print:space-y-6">
|
||
{groups.map((group, gi) => (
|
||
<div
|
||
key={group.label || "all"}
|
||
className={cn(pageBreakBetweenGroups && gi > 0 && "sb-force-break print:mt-0")}
|
||
>
|
||
{/* Group header */}
|
||
{group.label && (
|
||
<div className="sb-avoid-break mb-4 print:mb-3">
|
||
<div className="flex items-center gap-3">
|
||
<div className="flex-1 h-px bg-zinc-700 print:bg-zinc-300 sb-group-header-line" />
|
||
<span className="text-xs font-bold uppercase tracking-widest text-zinc-400 print:text-zinc-600 px-2">
|
||
{group.label}
|
||
</span>
|
||
<div className="flex-1 h-px bg-zinc-700 print:bg-zinc-300" />
|
||
</div>
|
||
<p className="text-[10px] text-zinc-600 print:text-zinc-400 mt-1 text-center">
|
||
{projectName} · {group.shots.length} shot{group.shots.length !== 1 ? "s" : ""}
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* Shot grid */}
|
||
<div className={cn("grid gap-3 print:gap-2", colClass[opts.columns] ?? "grid-cols-3")}>
|
||
{group.shots.map((shot) => (
|
||
<ShotCard key={shot.id} shot={shot} opts={opts} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Fullscreen layout ────────────────────────────────────────────────────────
|
||
|
||
function FullscreenLayout({
|
||
groups,
|
||
opts,
|
||
projectName,
|
||
}: {
|
||
groups: ShotGroup[];
|
||
opts: DisplayOptions;
|
||
projectName: string;
|
||
}) {
|
||
const allShots = groups.flatMap((g) => g.shots);
|
||
|
||
return (
|
||
<div>
|
||
{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 (
|
||
<div
|
||
key={shot.id}
|
||
className={cn(
|
||
"relative flex flex-col overflow-hidden",
|
||
"bg-zinc-950 print:bg-black",
|
||
"min-h-[70vh] print:h-screen",
|
||
!isLast && "sb-page-break mb-2 print:mb-0"
|
||
)}
|
||
>
|
||
{/* Full-bleed image */}
|
||
<div className="relative flex-1 bg-zinc-900 print:bg-black overflow-hidden sb-fs-image-area">
|
||
{shot.thumbnailUrl ? (
|
||
// eslint-disable-next-line @next/next/no-img-element
|
||
<img
|
||
src={shot.thumbnailUrl}
|
||
alt={shot.shotCode}
|
||
loading="lazy"
|
||
className="w-full h-full object-contain"
|
||
/>
|
||
) : (
|
||
<div className="flex items-center justify-center h-full min-h-[40vh]">
|
||
<div className="text-center space-y-3">
|
||
<Film className="h-16 w-16 text-zinc-800 mx-auto" />
|
||
<p className="text-zinc-700 text-sm">No thumbnail</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Project watermark — top left */}
|
||
<div className="absolute top-4 left-4 text-[10px] font-mono text-white/30 print:text-white/40 tracking-widest uppercase">
|
||
{projectName}
|
||
</div>
|
||
|
||
{/* Shot number — top right */}
|
||
<div className="absolute top-4 right-4 text-[10px] font-mono text-white/30 print:text-white/40 tracking-widest">
|
||
{idx + 1} / {allShots.length}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Info bar */}
|
||
<div className="bg-zinc-900 print:bg-zinc-950 border-t border-zinc-800 print:border-zinc-700 px-8 py-5 print:py-4 shrink-0 sb-fs-infobar">
|
||
<div className="flex items-start gap-8">
|
||
<div className="space-y-1 min-w-0 flex-1">
|
||
<div className="flex items-center gap-3 flex-wrap">
|
||
<span className="font-mono text-2xl print:text-xl font-bold text-white tracking-tight">
|
||
{shot.shotCode}
|
||
</span>
|
||
{shot.isKeyShot && (
|
||
<span className="flex items-center gap-1 text-amber-400 text-xs font-semibold">
|
||
<Star className="h-3 w-3" fill="currentColor" /> Key Shot
|
||
</span>
|
||
)}
|
||
{opts.includeVersion && (
|
||
<span className="font-mono text-sm text-zinc-500 print:text-zinc-400">
|
||
{shot.shotVersion}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex items-center gap-4 text-xs text-zinc-500 print:text-zinc-400 flex-wrap">
|
||
{shot.episode && <span>Ep {shot.episode}</span>}
|
||
<span>Scene {shot.scene}</span>
|
||
{shot.shotGroup && <span>{shot.shotGroup.name}</span>}
|
||
{opts.includeFrameRange && frameCount != null && (
|
||
<span>
|
||
{shot.frameStart}–{shot.frameEnd} ({frameCount} fr)
|
||
</span>
|
||
)}
|
||
{opts.includeStatus && (
|
||
<span className="uppercase tracking-wide">
|
||
{STATUS_LABEL[shot.status] ?? shot.status}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{opts.includeDescription && shot.description && (
|
||
<p className="text-sm text-zinc-300 print:text-zinc-200 mt-2 leading-relaxed max-w-3xl">
|
||
{shot.description}
|
||
</p>
|
||
)}
|
||
|
||
{opts.includeNotes && shot.notes && (
|
||
<p className="text-xs text-zinc-500 print:text-zinc-400 italic mt-1 max-w-3xl">
|
||
{shot.notes}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Toggle button ─────────────────────────────────────────────────────────────
|
||
|
||
function ToggleBtn({
|
||
active,
|
||
onClick,
|
||
children,
|
||
className,
|
||
}: {
|
||
active: boolean;
|
||
onClick: () => void;
|
||
children: React.ReactNode;
|
||
className?: string;
|
||
}) {
|
||
return (
|
||
<button
|
||
onClick={onClick}
|
||
className={cn(
|
||
"flex items-center gap-2 rounded-lg px-3 py-2 text-xs font-medium transition-all border",
|
||
active
|
||
? "bg-amber-500/15 border-amber-500/40 text-amber-400"
|
||
: "bg-zinc-800/60 border-zinc-700 text-zinc-400 hover:text-zinc-200 hover:border-zinc-600",
|
||
className
|
||
)}
|
||
>
|
||
{children}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
// ─── Collapsible section ───────────────────────────────────────────────────────
|
||
|
||
function Section({
|
||
title,
|
||
children,
|
||
defaultOpen = true,
|
||
}: {
|
||
title: string;
|
||
children: React.ReactNode;
|
||
defaultOpen?: boolean;
|
||
}) {
|
||
const [open, setOpen] = useState(defaultOpen);
|
||
return (
|
||
<div className="border-b border-zinc-800 pb-4">
|
||
<button
|
||
onClick={() => setOpen((o) => !o)}
|
||
className="flex items-center justify-between w-full py-2 text-xs font-bold uppercase tracking-widest text-zinc-500 hover:text-zinc-300 transition-colors"
|
||
>
|
||
{title}
|
||
{open ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
||
</button>
|
||
{open && <div className="pt-2 space-y-2">{children}</div>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Checkbox row ─────────────────────────────────────────────────────────────
|
||
|
||
function CheckRow({
|
||
label,
|
||
checked,
|
||
onChange,
|
||
}: {
|
||
label: string;
|
||
checked: boolean;
|
||
onChange: (v: boolean) => void;
|
||
}) {
|
||
return (
|
||
<label className="flex items-center gap-2 cursor-pointer group">
|
||
<input
|
||
type="checkbox"
|
||
checked={checked}
|
||
onChange={(e) => onChange(e.target.checked)}
|
||
className="accent-amber-500 h-3.5 w-3.5"
|
||
/>
|
||
<span className="text-xs text-zinc-400 group-hover:text-zinc-200 transition-colors">
|
||
{label}
|
||
</span>
|
||
</label>
|
||
);
|
||
}
|
||
|
||
// ─── Main component ───────────────────────────────────────────────────────────
|
||
|
||
interface Props {
|
||
projects: Project[];
|
||
}
|
||
|
||
export function StoryboardGenerator({ projects }: Props) {
|
||
const [projectId, setProjectId] = useState(projects[0]?.id ?? "");
|
||
const [layout, setLayout] = useState<Layout>("standard");
|
||
const [columns, setColumns] = useState(3);
|
||
const [groupBy, setGroupBy] = useState<GroupBy>("episode");
|
||
const [pageBreakBetweenGroups, setPageBreakBetweenGroups] = useState(true);
|
||
const [showOnlyWithThumbnails, setShowOnlyWithThumbnails] = useState(false);
|
||
|
||
// Filter state — sets of episode/group keys; empty = all
|
||
const [selectedEpisodes, setSelectedEpisodes] = useState<Set<string>>(new Set());
|
||
const [selectedGroups, setSelectedGroups] = useState<Set<string>>(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<string, string>();
|
||
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<string, Shot[]>();
|
||
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 (
|
||
<div className="flex h-full overflow-hidden bg-zinc-950">
|
||
{/* ── Controls sidebar ── */}
|
||
<div className="no-print w-64 shrink-0 border-r border-zinc-800 overflow-y-auto flex flex-col bg-zinc-900">
|
||
{/* Header */}
|
||
<div className="px-4 pt-4 pb-3 border-b border-zinc-800 shrink-0">
|
||
<h1 className="text-sm font-bold text-white flex items-center gap-2">
|
||
<LayoutGrid className="h-4 w-4 text-amber-400" />
|
||
Storyboard Generator
|
||
</h1>
|
||
<p className="text-[11px] text-zinc-500 mt-0.5">
|
||
{filteredShots.length} shot{filteredShots.length !== 1 ? "s" : ""} selected
|
||
</p>
|
||
</div>
|
||
|
||
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-5">
|
||
{/* Project */}
|
||
<Section title="Project">
|
||
<select
|
||
value={projectId}
|
||
onChange={(e) => setProjectId(e.target.value)}
|
||
className="w-full bg-zinc-800 border border-zinc-700 rounded-lg text-xs text-white px-3 py-2 focus:outline-none focus:border-amber-600"
|
||
>
|
||
{projects.map((p) => (
|
||
<option key={p.id} value={p.id}>
|
||
{p.code} — {p.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</Section>
|
||
|
||
{/* Layout */}
|
||
<Section title="Layout">
|
||
<div className="flex gap-2">
|
||
<ToggleBtn active={layout === "standard"} onClick={() => setLayout("standard")} className="flex-1 justify-center">
|
||
<LayoutGrid className="h-3.5 w-3.5" />
|
||
Standard
|
||
</ToggleBtn>
|
||
<ToggleBtn active={layout === "fullscreen"} onClick={() => setLayout("fullscreen")} className="flex-1 justify-center">
|
||
<Maximize2 className="h-3.5 w-3.5" />
|
||
Fullscreen
|
||
</ToggleBtn>
|
||
</div>
|
||
|
||
{layout === "standard" && (
|
||
<div className="space-y-1.5 pt-1">
|
||
<Label className="text-[10px] text-zinc-500 uppercase tracking-wider">
|
||
Columns
|
||
</Label>
|
||
<div className="flex gap-1.5">
|
||
{([2, 3, 4] as const).map((c) => (
|
||
<button
|
||
key={c}
|
||
onClick={() => setColumns(c)}
|
||
className={cn(
|
||
"flex-1 py-1.5 rounded text-xs font-semibold border transition-all",
|
||
columns === c
|
||
? "bg-amber-500/15 border-amber-500/40 text-amber-400"
|
||
: "bg-zinc-800 border-zinc-700 text-zinc-500 hover:text-zinc-300"
|
||
)}
|
||
>
|
||
{c}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</Section>
|
||
|
||
{/* Group by */}
|
||
<Section title="Group By">
|
||
{(["episode", "scene", "group", "none"] as GroupBy[]).map((g) => (
|
||
<label key={g} className="flex items-center gap-2 cursor-pointer">
|
||
<input
|
||
type="radio"
|
||
name="groupBy"
|
||
value={g}
|
||
checked={groupBy === g}
|
||
onChange={() => setGroupBy(g)}
|
||
className="accent-amber-500"
|
||
/>
|
||
<span className="text-xs text-zinc-400 capitalize">
|
||
{g === "none" ? "No grouping" : g === "group" ? "Shot Group" : g.charAt(0).toUpperCase() + g.slice(1)}
|
||
</span>
|
||
</label>
|
||
))}
|
||
{groupBy !== "none" && (
|
||
<CheckRow
|
||
label="Page break between groups"
|
||
checked={pageBreakBetweenGroups}
|
||
onChange={setPageBreakBetweenGroups}
|
||
/>
|
||
)}
|
||
</Section>
|
||
|
||
{/* Filter by episode */}
|
||
{episodes.length > 0 && (
|
||
<Section title="Episodes" defaultOpen={false}>
|
||
<div className="flex gap-2 mb-2">
|
||
<button
|
||
onClick={() => setSelectedEpisodes(new Set())}
|
||
className="text-[10px] text-amber-400 hover:text-amber-300"
|
||
>
|
||
All
|
||
</button>
|
||
<button
|
||
onClick={() => setSelectedEpisodes(new Set(episodes))}
|
||
className="text-[10px] text-zinc-500 hover:text-zinc-300"
|
||
>
|
||
None
|
||
</button>
|
||
</div>
|
||
<div className="space-y-1.5 max-h-40 overflow-y-auto">
|
||
{episodes.map((ep) => (
|
||
<label key={ep} className="flex items-center gap-2 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={selectedEpisodes.size === 0 || selectedEpisodes.has(ep)}
|
||
onChange={() => toggleEpisode(ep)}
|
||
className="accent-amber-500 h-3.5 w-3.5"
|
||
/>
|
||
<span className="text-xs text-zinc-400 font-mono">{ep}</span>
|
||
</label>
|
||
))}
|
||
</div>
|
||
</Section>
|
||
)}
|
||
|
||
{/* Filter by shot group */}
|
||
{shotGroups.length > 0 && (
|
||
<Section title="Shot Groups" defaultOpen={false}>
|
||
<div className="flex gap-2 mb-2">
|
||
<button
|
||
onClick={() => setSelectedGroups(new Set())}
|
||
className="text-[10px] text-amber-400 hover:text-amber-300"
|
||
>
|
||
All
|
||
</button>
|
||
<button
|
||
onClick={() => setSelectedGroups(new Set(shotGroups.map((g) => g.id)))}
|
||
className="text-[10px] text-zinc-500 hover:text-zinc-300"
|
||
>
|
||
None
|
||
</button>
|
||
</div>
|
||
<div className="space-y-1.5 max-h-40 overflow-y-auto">
|
||
{shotGroups.map((g) => (
|
||
<label key={g.id} className="flex items-center gap-2 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={selectedGroups.size === 0 || selectedGroups.has(g.id)}
|
||
onChange={() => toggleGroup(g.id)}
|
||
className="accent-amber-500 h-3.5 w-3.5"
|
||
/>
|
||
<span className="text-xs text-zinc-400">{g.name}</span>
|
||
</label>
|
||
))}
|
||
</div>
|
||
</Section>
|
||
)}
|
||
|
||
{/* Content options */}
|
||
<Section title="Show Fields">
|
||
<CheckRow label="Description" checked={includeDescription} onChange={setIncludeDescription} />
|
||
<CheckRow label="Notes" checked={includeNotes} onChange={setIncludeNotes} />
|
||
<CheckRow label="Frame range" checked={includeFrameRange} onChange={setIncludeFrameRange} />
|
||
<CheckRow label="Version" checked={includeVersion} onChange={setIncludeVersion} />
|
||
<CheckRow label="Status" checked={includeStatus} onChange={setIncludeStatus} />
|
||
<CheckRow
|
||
label="Only shots with thumbnails"
|
||
checked={showOnlyWithThumbnails}
|
||
onChange={setShowOnlyWithThumbnails}
|
||
/>
|
||
</Section>
|
||
</div>
|
||
|
||
{/* Generate button */}
|
||
<div className="shrink-0 p-4 border-t border-zinc-800 space-y-2">
|
||
{layout === "fullscreen" && (
|
||
<p className="text-[10px] text-zinc-600 text-center leading-tight">
|
||
Tip: choose Landscape in the print dialog
|
||
</p>
|
||
)}
|
||
{filteredShots.length > 0 ? (
|
||
<a href={pdfUrl} target="_blank" rel="noopener noreferrer" className="block">
|
||
<Button className="w-full bg-amber-600 hover:bg-amber-500 text-white font-semibold">
|
||
<Printer className="h-4 w-4 mr-2" />
|
||
Generate PDF
|
||
</Button>
|
||
</a>
|
||
) : (
|
||
<Button className="w-full bg-amber-600/40 text-white/40 font-semibold" disabled>
|
||
<Printer className="h-4 w-4 mr-2" />
|
||
Generate PDF
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Preview area ── */}
|
||
<div className="flex-1 overflow-y-auto bg-zinc-950">
|
||
{/* Preview toolbar */}
|
||
<div className="no-print sticky top-0 z-10 flex items-center gap-3 px-6 py-3 bg-zinc-900/80 backdrop-blur border-b border-zinc-800">
|
||
<span className="text-xs text-zinc-500">Preview</span>
|
||
<div className="flex-1" />
|
||
<span className="text-[11px] text-zinc-600">
|
||
{layout === "standard" ? `${columns} cols` : "1 per page"} ·{" "}
|
||
{filteredShots.length} shots ·{" "}
|
||
{layout === "fullscreen" ? "Landscape PDF" : "Portrait PDF"}
|
||
</span>
|
||
</div>
|
||
|
||
{/* Storyboard content */}
|
||
<div className={cn("print-area p-6 print:p-0", layout === "standard" ? "sb-standard-print" : "sb-fullscreen-print")}>
|
||
{isLoading ? (
|
||
<div className="flex items-center justify-center py-24 no-print">
|
||
<Loader2 className="h-8 w-8 animate-spin text-zinc-600" />
|
||
</div>
|
||
) : error ? (
|
||
<div className="flex items-center justify-center py-24 no-print text-red-400 text-sm">
|
||
Failed to load shots
|
||
</div>
|
||
) : filteredShots.length === 0 ? (
|
||
<div className="flex flex-col items-center justify-center py-24 no-print gap-3 text-zinc-600">
|
||
<Film className="h-12 w-12 text-zinc-800" />
|
||
<p className="text-sm">No shots to display</p>
|
||
{showOnlyWithThumbnails && (
|
||
<p className="text-xs text-zinc-700">Try unchecking "Only shots with thumbnails"</p>
|
||
)}
|
||
</div>
|
||
) : layout === "standard" ? (
|
||
<StandardLayout
|
||
groups={groupedShots}
|
||
opts={opts}
|
||
projectName={project?.name ?? ""}
|
||
pageBreakBetweenGroups={pageBreakBetweenGroups}
|
||
/>
|
||
) : (
|
||
<FullscreenLayout
|
||
groups={groupedShots}
|
||
opts={opts}
|
||
projectName={project?.name ?? ""}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|