@@ -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} · {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 {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;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ── 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user