diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx index 803cc53..a4aab3f 100644 --- a/app/(dashboard)/layout.tsx +++ b/app/(dashboard)/layout.tsx @@ -12,11 +12,15 @@ export default async function DashboardLayout({ if (!session?.user) redirect("/login"); return ( -
- -
-
-
+
+
+ +
+
+
+
+
+
{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 = { + 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 `
+
+ ${ + shot.thumbnailUrl + ? `${esc(shot.shotCode)}` + : `
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)}
` : ""} +
+
`; +} + +// ─── 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 `
+ ${ + g.label + ? `
+
${esc(g.label)}
+
${esc(project.name)}  ·  ${g.shots.length} shot${g.shots.length !== 1 ? "s" : ""}
+
` + : "" + } +
+ ${g.shots.map((s) => shotCardHtml(s, opts)).join("\n ")} +
+
`; + }) + .join("\n"); + + return ` + + + + ${esc(project.code)} — Storyboard + + + + +
+ ${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 `
+
+ ${ + shot.thumbnailUrl + ? `${esc(shot.shotCode)}` + : `
No image
` + } +
${esc(project.code)}
+
${idx + 1} / ${shots.length}
+
+
+
+ ${esc(shot.shotCode)} + ${shot.isKeyShot ? `★ Key Shot` : ""} + ${opts.showVersion ? `${esc(shot.shotVersion)}` : ""} +
+ ${metaParts.length ? `
${metaParts.join(" · ")}
` : ""} + ${opts.showDesc && shot.description ? `
${esc(shot.description)}
` : ""} + ${opts.showNotes && shot.notes ? `
${esc(shot.notes)}
` : ""} +
+
`; + }) + .join("\n"); + + return ` + + + + ${esc(project.code)} — Storyboard (Fullscreen) + + + + +
+ ${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(); + 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" /> ) : ( -
+
)} @@ -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 */} -