Storyboard Feature
Deploy / deploy (push) Successful in 2m53s

This commit is contained in:
twotalesanimation
2026-07-22 12:19:19 +02:00
parent c968b8a46a
commit 77a3161f0c
6 changed files with 906 additions and 4 deletions
+22
View File
@@ -0,0 +1,22 @@
import { auth } from "@/auth";
import { redirect } from "next/navigation";
import { db } from "@/lib/db";
import { StoryboardGenerator } from "@/components/storyboard/StoryboardGenerator";
export const metadata = { title: "Storyboard Generator" };
export default async function StoryboardPage() {
const session = await auth();
if (!session?.user) redirect("/login");
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
redirect("/dashboard");
}
const projects = await db.project.findMany({
where: { status: { in: ["ACTIVE", "ON_HOLD"] } },
select: { id: true, name: true, code: true, projectType: true },
orderBy: { name: "asc" },
});
return <StoryboardGenerator projects={projects} />;
}
+44
View File
@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
// GET /api/storyboard/shots?projectId=xxx
export async function GET(req: NextRequest) {
const session = await auth();
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const projectId = req.nextUrl.searchParams.get("projectId");
if (!projectId) return NextResponse.json({ error: "projectId required" }, { status: 400 });
const shots = await db.shot.findMany({
where: { projectId },
select: {
id: true,
shotCode: true,
scene: true,
episode: true,
shotNumber: true,
sequence: true,
description: true,
notes: true,
status: true,
thumbnailUrl: true,
frameStart: true,
frameEnd: true,
fps: true,
isKeyShot: true,
shotVersion: true,
shotGroup: { select: { id: true, name: true } },
},
orderBy: [
{ episode: "asc" },
{ scene: "asc" },
{ shotNumber: "asc" },
],
});
return NextResponse.json({ shots });
}
+2
View File
@@ -20,6 +20,7 @@ import {
ListVideo,
CloudUpload,
Clapperboard,
LayoutGrid,
} from 'lucide-react';
import { useState } from 'react';
import { useSession } from 'next-auth/react';
@@ -32,6 +33,7 @@ const navItems = [
{ href: '/playlist', label: 'Playlist', icon: ListVideo, hideForClient: true },
{ href: '/tasks', label: 'My Tasks', icon: ListTodo, hideForClient: true },
{ href: '/shoot-log', label: 'Shot Log', icon: Clapperboard, supervisorOnly: true },
{ href: '/storyboard', label: 'Storyboard', icon: LayoutGrid, supervisorOnly: true },
{ href: '/schedule', label: 'Schedule', icon: CalendarRange, adminOnly: true },
{ href: '/batch-upload', label: 'Batch Upload', icon: CloudUpload, adminOnly: true },
{ href: '/clients', label: 'Clients', icon: Users, adminOnly: true },
+20 -4
View File
@@ -7,6 +7,7 @@ import { TakeListPanel } from "./TakeListPanel";
import { TakeEditorPanel, type FullTake } from "./TakeEditorPanel";
import { NewShootDayDialog } from "./NewShootDayDialog";
import { NewSetupDialog } from "./NewSetupDialog";
import { cn } from "@/lib/utils";
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -28,6 +29,8 @@ export function ShootLogClient({ projects }: Props) {
const [projectId, setProjectId] = useState<string>(projects[0]?.id ?? "");
const [selectedTakeId, setSelectedTakeId] = useState<string | null>(null);
const [selectedSetupId, setSelectedSetupId] = useState<string | null>(null);
// Controls which panel is visible on mobile (< md)
const [mobileView, setMobileView] = useState<"list" | "editor">("list");
// Which day to auto-expand in the left panel after creation
const [expandDayId, setExpandDayId] = useState<string | null>(null);
@@ -92,6 +95,7 @@ export function ShootLogClient({ projects }: Props) {
invalidateDays();
setSelectedTakeId(newTake.id);
setSelectedSetupId(setupId);
setMobileView("editor");
},
[invalidateDays]
);
@@ -122,6 +126,7 @@ export function ShootLogClient({ projects }: Props) {
setExpandDayId(day.id);
setSelectedTakeId(newTake.id);
setSelectedSetupId(setup.id);
setMobileView("editor");
} catch {
// Day was created but setup/take failed — still refresh
invalidateDays();
@@ -158,6 +163,7 @@ export function ShootLogClient({ projects }: Props) {
if (!confirm("Delete this take? This cannot be undone.")) return;
await fetch(`/api/shoot-log/takes/${selectedTakeId}`, { method: "DELETE" });
setSelectedTakeId(null);
setMobileView("list");
invalidateDays();
}, [selectedTakeId, invalidateDays]);
@@ -186,6 +192,7 @@ export function ShootLogClient({ projects }: Props) {
setSelectedTakeId(null);
setSelectedSetupId(null);
setExpandDayId(null);
setMobileView("list");
}}
className="bg-zinc-800 border border-zinc-700 rounded-lg text-sm text-white px-3 py-1.5 focus:outline-none focus:border-amber-600"
>
@@ -207,8 +214,12 @@ export function ShootLogClient({ projects }: Props) {
{/* Split layout */}
<div className="flex flex-1 overflow-hidden min-h-0">
{/* Left panel */}
<div className="w-72 xl:w-80 shrink-0 border-r border-zinc-800 overflow-hidden flex flex-col">
{/* Left panel — full width on mobile, fixed sidebar on md+ */}
<div className={cn(
"shrink-0 border-r border-zinc-800 overflow-hidden flex-col",
"w-full md:w-72 xl:w-80",
mobileView === "editor" ? "hidden md:flex" : "flex"
)}>
<TakeListPanel
projectId={projectId}
selectedTakeId={selectedTakeId}
@@ -216,6 +227,7 @@ export function ShootLogClient({ projects }: Props) {
onSelectTake={(takeId, setupId) => {
setSelectedTakeId(takeId);
setSelectedSetupId(setupId);
setMobileView("editor");
}}
onNewDay={() => setNewDayOpen(true)}
onNewSetup={(dayId) => setNewSetupDayId(dayId)}
@@ -223,8 +235,11 @@ export function ShootLogClient({ projects }: Props) {
/>
</div>
{/* Right panel */}
<div className="flex-1 overflow-hidden flex flex-col">
{/* Right panel — hidden on mobile when list is showing */}
<div className={cn(
"flex-1 overflow-hidden flex-col",
mobileView === "list" ? "hidden md:flex" : "flex"
)}>
{takeLoading ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-6 w-6 animate-spin text-zinc-500" />
@@ -240,6 +255,7 @@ export function ShootLogClient({ projects }: Props) {
onNewTake={() => createTake(take.setupId)}
onDelete={handleDelete}
onAttachmentsChange={invalidateDays}
onBack={() => setMobileView("list")}
/>
) : (
<div className="flex flex-col items-center justify-center h-full gap-3 text-zinc-600">
+9
View File
@@ -498,6 +498,7 @@ interface Props {
onNewTake: () => void;
onDelete: () => void;
onAttachmentsChange: () => void;
onBack?: (() => void) | null;
}
export function TakeEditorPanel({
@@ -509,6 +510,7 @@ export function TakeEditorPanel({
onNewTake,
onDelete,
onAttachmentsChange,
onBack,
}: Props) {
const { status, queue } = useAutosave(take.id);
@@ -550,6 +552,13 @@ export function TakeEditorPanel({
<div className="flex flex-col h-full overflow-hidden">
{/* ── Nav bar ── */}
<div className="flex items-center gap-2 px-4 py-3 border-b border-zinc-800 shrink-0 flex-wrap">
{/* Back to list — mobile only */}
{onBack && (
<Button size="sm" variant="ghost" className="md:hidden -ml-2 text-zinc-400 shrink-0" onClick={onBack}>
<ChevronLeft className="h-4 w-4" />
List
</Button>
)}
<div className="flex items-center gap-1">
<Button size="icon-sm" variant="ghost" disabled={!onPrev} onClick={onPrev ?? undefined} title="Previous take (←)">
<ChevronLeft className="h-4 w-4" />
@@ -0,0 +1,809 @@
"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">
<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" />
<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">
{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">
<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;
});
}, []);
// ── 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 */}
<style dangerouslySetInnerHTML={{ __html: printStyles }} />
<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 for best results
</p>
)}
<Button
className="w-full bg-amber-600 hover:bg-amber-500 text-white font-semibold"
onClick={() => window.print()}
disabled={filteredShots.length === 0}
>
<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="print-area p-6 print:p-0">
{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>
</>
);
}