+ = {
+ WAITING: "Waiting",
+ IN_PROGRESS: "In Progress",
+ INTERNAL_REVIEW: "Internal Review",
+ READY_FOR_CLIENT: "Ready for Client",
+ CLIENT_REVIEW: "Client Review",
+ REVISIONS: "Revisions",
+ COMPLETE: "Complete",
+};
+
+// ─── Types ────────────────────────────────────────────────────────────────────
+
+type Shot = {
+ id: string;
+ shotCode: string;
+ scene: string;
+ episode: string | null;
+ shotNumber: number;
+ 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 Opts = {
+ showDesc: boolean;
+ showNotes: boolean;
+ showFrameRange: boolean;
+ showStatus: boolean;
+ showVersion: boolean;
+};
+
+type Group = { label: string; shots: Shot[] };
+
+// ─── Shot card HTML (standard layout) ────────────────────────────────────────
+
+function shotCardHtml(shot: Shot, opts: Opts): string {
+ const fc =
+ shot.frameStart != null && shot.frameEnd != null
+ ? shot.frameEnd - shot.frameStart + 1
+ : null;
+
+ return `${esc(project.code)} — Storyboard
+
+
+
+
+ ${esc(project.code)} — Storyboard (Fullscreen)
+
+
+
+
+ ();
+ for (const shot of shots) {
+ 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);
+ }
+ map.forEach((sh, label) => groups.push({ label, shots: sh }));
+ }
+
+ const html =
+ layout === "fullscreen"
+ ? buildFullscreenHtml(project, groups.flatMap((g) => g.shots), opts)
+ : buildStandardHtml(project, groups, opts, columns, pageBreaks);
+
+ return new NextResponse(html, {
+ headers: { "Content-Type": "text/html; charset=utf-8" },
+ });
+}
diff --git a/components/storyboard/StoryboardGenerator.tsx b/components/storyboard/StoryboardGenerator.tsx
index ec082d6..6f19aee 100644
--- a/components/storyboard/StoryboardGenerator.tsx
+++ b/components/storyboard/StoryboardGenerator.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState, useMemo, useCallback, useEffect } from "react";
+import { useState, useMemo, useCallback } from "react";
import { useQuery } from "@tanstack/react-query";
import {
LayoutGrid,
@@ -100,7 +100,7 @@ function ShotCard({ shot, opts }: { shot: Shot; opts: DisplayOptions }) {
className="w-full h-full object-cover"
/>
) : (
-
+
+
+
+
{children}
diff --git a/app/api/storyboard/pdf/route.ts b/app/api/storyboard/pdf/route.ts
new file mode 100644
index 0000000..199393c
--- /dev/null
+++ b/app/api/storyboard/pdf/route.ts
@@ -0,0 +1,448 @@
+import { NextRequest, NextResponse } from "next/server";
+import { auth } from "@/auth";
+import { db } from "@/lib/db";
+
+// ─── Helpers ──────────────────────────────────────────────────────────────────
+
+function esc(s: string | null | undefined): string {
+ if (!s) return "";
+ return s
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+}
+
+const STATUS_LABELS: Record
+
+
+
+
`;
+}
+
+// ─── Standard layout HTML ─────────────────────────────────────────────────────
+
+function buildStandardHtml(
+ project: { name: string; code: string },
+ groups: Group[],
+ opts: Opts,
+ columns: number,
+ pageBreaks: boolean
+): string {
+ const totalShots = groups.reduce((n, g) => n + g.shots.length, 0);
+
+ const groupsHtml = groups
+ .map((g, i) => {
+ const breakClass = pageBreaks && i > 0 ? " group-break" : "";
+ return `
+ ${
+ shot.thumbnailUrl
+ ? `
`
+ : `
+ No image
`
+ }
+ ${shot.isKeyShot ? `KEY` : ""}
+
+
+
+ ${esc(shot.shotCode)}
+ ${opts.showVersion ? `${esc(shot.shotVersion)}` : ""}
+
+ ${opts.showFrameRange && fc != null ? `${shot.frameStart}–${shot.frameEnd} (${fc}fr)
` : ""}
+ ${opts.showDesc && shot.description ? `${esc(shot.description)}
` : ""}
+ ${opts.showNotes && shot.notes ? `${esc(shot.notes)}
` : ""}
+ ${opts.showStatus ? `${esc(STATUS_LABELS[shot.status] ?? shot.status)}
` : ""}
+
+ ${
+ g.label
+ ? `
`;
+ })
+ .join("\n");
+
+ return `
+
+
+
+
+
`
+ : ""
+ }
+ ${esc(g.label)}
+
+
+ ${g.shots.map((s) => shotCardHtml(s, opts)).join("\n ")}
+
+
+ ${groupsHtml}
+
+
+
+`;
+}
+
+// ─── Fullscreen layout HTML ────────────────────────────────────────────────────
+
+function buildFullscreenHtml(
+ project: { name: string; code: string },
+ shots: Shot[],
+ opts: Opts
+): string {
+ const pages = shots
+ .map((shot, idx) => {
+ const fc =
+ shot.frameStart != null && shot.frameEnd != null
+ ? shot.frameEnd - shot.frameStart + 1
+ : null;
+ const isLast = idx === shots.length - 1;
+
+ const metaParts: string[] = [];
+ if (shot.episode) metaParts.push(`Ep ${esc(shot.episode)}`);
+ metaParts.push(`Scene ${esc(shot.scene)}`);
+ if (shot.shotGroup) metaParts.push(esc(shot.shotGroup.name));
+ if (opts.showFrameRange && fc != null) metaParts.push(`${shot.frameStart}–${shot.frameEnd} (${fc} fr)`);
+ if (opts.showStatus) metaParts.push(esc(STATUS_LABELS[shot.status] ?? shot.status));
+
+ return `
+
`;
+ })
+ .join("\n");
+
+ return `
+
+
+
+
+ ${
+ shot.thumbnailUrl
+ ? `
`
+ : `
+
+No image
`
+ }
+ ${esc(project.code)}
+ ${idx + 1} / ${shots.length}
+
+ ${pages}
+
+
+
+`;
+}
+
+// ─── Route handler ────────────────────────────────────────────────────────────
+
+export async function GET(req: NextRequest) {
+ const session = await auth();
+ if (!session?.user) return new NextResponse("Unauthorized", { status: 401 });
+ if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
+ return new NextResponse("Forbidden", { status: 403 });
+ }
+
+ const p = req.nextUrl.searchParams;
+ const projectId = p.get("projectId");
+ if (!projectId) return new NextResponse("projectId required", { status: 400 });
+
+ const layout = p.get("layout") === "fullscreen" ? "fullscreen" : "standard";
+ const columns = Math.min(4, Math.max(2, parseInt(p.get("columns") ?? "3", 10)));
+ const groupBy = (p.get("groupBy") ?? "episode") as "episode" | "scene" | "group" | "none";
+ const epFilter = p.get("episodes")?.split(",").filter(Boolean) ?? [];
+ const grpFilter = p.get("groups")?.split(",").filter(Boolean) ?? [];
+
+ const opts: Opts = {
+ showDesc: p.get("desc") !== "0",
+ showNotes: p.get("notes") === "1",
+ showFrameRange: p.get("frameRange") !== "0",
+ showStatus: p.get("status") === "1",
+ showVersion: p.get("version") === "1",
+ };
+ const pageBreaks = p.get("pageBreaks") !== "0";
+ const onlyThumbs = p.get("onlyThumbs") === "1";
+
+ const project = await db.project.findUnique({
+ where: { id: projectId },
+ select: { name: true, code: true },
+ });
+ if (!project) return new NextResponse("Project not found", { status: 404 });
+
+ let shots = await db.shot.findMany({
+ where: { projectId },
+ select: {
+ id: true, shotCode: true, scene: true, episode: true, shotNumber: 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" }],
+ });
+
+ if (onlyThumbs) shots = shots.filter((s) => !!s.thumbnailUrl);
+ if (epFilter.length) shots = shots.filter((s) => epFilter.includes(s.episode ?? ""));
+ if (grpFilter.length) shots = shots.filter((s) => grpFilter.includes(s.shotGroup?.id ?? ""));
+
+ // Group shots
+ const groups: Group[] = [];
+ if (groupBy === "none") {
+ groups.push({ label: "", shots });
+ } else {
+ const map = new Map
+
)}
@@ -182,7 +182,7 @@ function StandardLayout({
{group.label && (
-
+
{group.label}
@@ -239,7 +239,7 @@ function FullscreenLayout({
)}
>
{/* Full-bleed image */}
-
+
{shot.thumbnailUrl ? (
// eslint-disable-next-line @next/next/no-img-element
{/* Info bar */}
-
+
@@ -527,43 +527,33 @@ export function StoryboardGenerator({ projects }: Props) {
});
}, []);
- // ── 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]);
+ // ── 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 (
- <>
- {/* Print styles injected dynamically */}
-
-
-
+
);
}
{/* ── Controls sidebar ── */}
{/* Storyboard content */}
-
{/* Header */}
@@ -740,19 +730,24 @@ export function StoryboardGenerator({ projects }: Props) {
{/* Generate button */}
@@ -770,7 +765,7 @@ export function StoryboardGenerator({ projects }: Props) {
- {layout === "fullscreen" && (
+ {layout === "fullscreen" && (
- Tip: choose Landscape in the print dialog for best results + Tip: choose Landscape in the print dialog
)} - + {filteredShots.length > 0 ? ( + + + + ) : ( + + )}
+
- >
+
{isLoading ? (
@@ -801,9 +796,8 @@ export function StoryboardGenerator({ projects }: Props) {
projectName={project?.name ?? ""}
/>
)}
-