Files
vfxreview/components/storyboard/StoryboardGenerator.tsx
twotalesanimation e3bdd59087
Deploy / deploy (push) Successful in 2m47s
fix: add useEffect back to StoryboardGenerator imports
2026-07-22 12:42:28 +02:00

804 lines
30 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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} &nbsp;·&nbsp; {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&nbsp;{shot.episode}</span>}
<span>Scene&nbsp;{shot.scene}</span>
{shot.shotGroup && <span>{shot.shotGroup.name}</span>}
{opts.includeFrameRange && frameCount != null && (
<span>
{shot.frameStart}{shot.frameEnd} &nbsp;({frameCount}&nbsp;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>
);
}