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" }, }); }