@@ -12,11 +12,15 @@ export default async function DashboardLayout({
|
||||
if (!session?.user) redirect("/login");
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-zinc-950 overflow-hidden">
|
||||
<Sidebar />
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<Header />
|
||||
<main className="flex-1 overflow-auto">
|
||||
<div className="flex h-screen bg-zinc-950 overflow-hidden print:block print:h-auto print:overflow-visible">
|
||||
<div className="print:hidden">
|
||||
<Sidebar />
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col overflow-hidden print:block print:overflow-visible">
|
||||
<div className="print:hidden">
|
||||
<Header />
|
||||
</div>
|
||||
<main className="flex-1 overflow-auto print:overflow-visible">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -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, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
const STATUS_LABELS: 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",
|
||||
};
|
||||
|
||||
// ─── 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 `<div class="shot-card">
|
||||
<div class="thumb-wrap">
|
||||
${
|
||||
shot.thumbnailUrl
|
||||
? `<img src="${esc(shot.thumbnailUrl)}" alt="${esc(shot.shotCode)}" loading="eager" />`
|
||||
: `<div class="no-thumb">No image</div>`
|
||||
}
|
||||
${shot.isKeyShot ? `<span class="key-badge">KEY</span>` : ""}
|
||||
</div>
|
||||
<div class="shot-info">
|
||||
<div class="code-row">
|
||||
<span class="shot-code">${esc(shot.shotCode)}</span>
|
||||
${opts.showVersion ? `<span class="version">${esc(shot.shotVersion)}</span>` : ""}
|
||||
</div>
|
||||
${opts.showFrameRange && fc != null ? `<div class="frame-range">${shot.frameStart}–${shot.frameEnd} (${fc}fr)</div>` : ""}
|
||||
${opts.showDesc && shot.description ? `<div class="description">${esc(shot.description)}</div>` : ""}
|
||||
${opts.showNotes && shot.notes ? `<div class="notes">${esc(shot.notes)}</div>` : ""}
|
||||
${opts.showStatus ? `<div class="status-label">${esc(STATUS_LABELS[shot.status] ?? shot.status)}</div>` : ""}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ─── 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 `<div class="group-section${breakClass}">
|
||||
${
|
||||
g.label
|
||||
? `<div class="group-header">
|
||||
<div class="group-title">${esc(g.label)}</div>
|
||||
<div class="group-meta">${esc(project.name)} · ${g.shots.length} shot${g.shots.length !== 1 ? "s" : ""}</div>
|
||||
</div>`
|
||||
: ""
|
||||
}
|
||||
<div class="grid cols-${columns}">
|
||||
${g.shots.map((s) => shotCardHtml(s, opts)).join("\n ")}
|
||||
</div>
|
||||
</div>`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>${esc(project.code)} — Storyboard</title>
|
||||
<style>
|
||||
@page { size: A4 portrait; margin: 14mm 12mm 12mm 12mm; }
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
|
||||
background: #fff;
|
||||
color: #111;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* ── Print bar (hidden on print) ── */
|
||||
.print-bar {
|
||||
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
|
||||
background: #18181b;
|
||||
color: #fff;
|
||||
padding: 10px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,.4);
|
||||
}
|
||||
.print-bar-info h1 { font-size: 14px; font-weight: 600; }
|
||||
.print-bar-info p { font-size: 11px; color: #a1a1aa; margin-top: 1px; }
|
||||
.print-btn {
|
||||
background: #f59e0b; color: #000; border: none;
|
||||
padding: 8px 22px; border-radius: 6px;
|
||||
font-size: 13px; font-weight: 700; cursor: pointer;
|
||||
white-space: nowrap; flex-shrink: 0;
|
||||
}
|
||||
.print-btn:hover { background: #d97706; }
|
||||
@media print { .print-bar { display: none !important; } }
|
||||
|
||||
/* ── Content offset ── */
|
||||
.content { padding-top: 56px; }
|
||||
@media print { .content { padding-top: 0; } }
|
||||
|
||||
/* ── Group ── */
|
||||
.group-break { break-before: page; page-break-before: always; }
|
||||
.group-header { margin-bottom: 8px; padding-bottom: 6px; border-bottom: 1.5px solid #d1d5db; }
|
||||
.group-title { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: .09em; color: #374151; }
|
||||
.group-meta { font-size: 9px; color: #9ca3af; margin-top: 2px; }
|
||||
.group-section { margin-bottom: 16px; }
|
||||
|
||||
/* ── Grid ── */
|
||||
.grid { display: grid; gap: 8px; margin-bottom: 12px; }
|
||||
.cols-2 { grid-template-columns: repeat(2, 1fr); }
|
||||
.cols-3 { grid-template-columns: repeat(3, 1fr); }
|
||||
.cols-4 { grid-template-columns: repeat(4, 1fr); }
|
||||
|
||||
/* ── Shot card ── */
|
||||
.shot-card {
|
||||
break-inside: avoid; page-break-inside: avoid;
|
||||
border: 1px solid #e5e7eb; border-radius: 2px; overflow: hidden;
|
||||
background: #fff;
|
||||
}
|
||||
.thumb-wrap {
|
||||
position: relative; width: 100%;
|
||||
aspect-ratio: 2.39 / 1; background: #f3f4f6; overflow: hidden;
|
||||
}
|
||||
.thumb-wrap img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.no-thumb {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
height: 100%; color: #d1d5db; font-size: 9px;
|
||||
}
|
||||
.key-badge {
|
||||
position: absolute; top: 3px; right: 3px;
|
||||
background: #f59e0b; color: #fff;
|
||||
font-size: 7px; font-weight: 700; padding: 1px 4px; border-radius: 2px;
|
||||
}
|
||||
.shot-info { padding: 4px 6px 5px; }
|
||||
.code-row { display: flex; align-items: baseline; justify-content: space-between; gap: 4px; }
|
||||
.shot-code { font-family: 'SFMono-Regular', Consolas, monospace; font-size: 9.5px; font-weight: 700; color: #111; }
|
||||
.version { font-family: monospace; font-size: 8px; color: #9ca3af; }
|
||||
.frame-range { font-size: 8px; color: #6b7280; margin-top: 1px; }
|
||||
.description { font-size: 9px; color: #374151; line-height: 1.35; margin-top: 3px; }
|
||||
.notes { font-size: 8px; color: #6b7280; font-style: italic; line-height: 1.3; margin-top: 2px; }
|
||||
.status-label { font-size: 7.5px; color: #9ca3af; text-transform: uppercase; letter-spacing: .05em; margin-top: 2px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="print-bar">
|
||||
<div class="print-bar-info">
|
||||
<h1>${esc(project.name)} — Storyboard</h1>
|
||||
<p>${totalShots} shot${totalShots !== 1 ? "s" : ""} · ${columns} columns${groups[0]?.label ? ` · ${groups.length} group${groups.length !== 1 ? "s" : ""}` : ""}</p>
|
||||
</div>
|
||||
<button class="print-btn" onclick="window.print()">🖨 Print / Save as PDF</button>
|
||||
</div>
|
||||
<div class="content">
|
||||
${groupsHtml}
|
||||
</div>
|
||||
<script>
|
||||
var imgs = Array.from(document.querySelectorAll('img'));
|
||||
var done = 0;
|
||||
function check() { if (++done >= imgs.length) setTimeout(function(){ window.print(); }, 200); }
|
||||
if (!imgs.length) { setTimeout(function(){ window.print(); }, 200); }
|
||||
else imgs.forEach(function(img) { if (img.complete) check(); else { img.onload = check; img.onerror = check; } });
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
// ─── 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 `<div class="shot-page${isLast ? " last-page" : ""}">
|
||||
<div class="image-area">
|
||||
${
|
||||
shot.thumbnailUrl
|
||||
? `<img src="${esc(shot.thumbnailUrl)}" alt="${esc(shot.shotCode)}" loading="eager" />`
|
||||
: `<div class="no-image">No image</div>`
|
||||
}
|
||||
<div class="watermark">${esc(project.code)}</div>
|
||||
<div class="counter">${idx + 1} / ${shots.length}</div>
|
||||
</div>
|
||||
<div class="info-bar">
|
||||
<div class="shot-header">
|
||||
<span class="shot-code-fs">${esc(shot.shotCode)}</span>
|
||||
${shot.isKeyShot ? `<span class="key-label">★ Key Shot</span>` : ""}
|
||||
${opts.showVersion ? `<span class="version-fs">${esc(shot.shotVersion)}</span>` : ""}
|
||||
</div>
|
||||
${metaParts.length ? `<div class="meta-row">${metaParts.join(" · ")}</div>` : ""}
|
||||
${opts.showDesc && shot.description ? `<div class="desc-fs">${esc(shot.description)}</div>` : ""}
|
||||
${opts.showNotes && shot.notes ? `<div class="notes-fs">${esc(shot.notes)}</div>` : ""}
|
||||
</div>
|
||||
</div>`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>${esc(project.code)} — Storyboard (Fullscreen)</title>
|
||||
<style>
|
||||
@page { size: A4 landscape; margin: 0; }
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { background: #000; color: #fff; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif; }
|
||||
|
||||
/* ── Print bar ── */
|
||||
.print-bar {
|
||||
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
|
||||
background: #18181b; color: #fff;
|
||||
padding: 10px 20px;
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,.6);
|
||||
}
|
||||
.print-bar-info h1 { font-size: 14px; font-weight: 600; }
|
||||
.print-bar-info p { font-size: 11px; color: #a1a1aa; margin-top: 1px; }
|
||||
.print-btn {
|
||||
background: #f59e0b; color: #000; border: none;
|
||||
padding: 8px 22px; border-radius: 6px;
|
||||
font-size: 13px; font-weight: 700; cursor: pointer; white-space: nowrap;
|
||||
}
|
||||
.print-btn:hover { background: #d97706; }
|
||||
@media print { .print-bar { display: none !important; } }
|
||||
|
||||
/* ── Shot page ── */
|
||||
.shot-page {
|
||||
display: flex; flex-direction: column;
|
||||
height: 100vh;
|
||||
break-after: page; page-break-after: always;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
}
|
||||
.shot-page.last-page { break-after: auto; page-break-after: auto; }
|
||||
@media print {
|
||||
.shot-page { margin-top: 0; }
|
||||
/* Offset for print bar gone on print — first page may have extra space in preview */
|
||||
}
|
||||
|
||||
/* ── Image area ── */
|
||||
.image-area {
|
||||
flex: 1; overflow: hidden; background: #000; position: relative;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
min-height: 0;
|
||||
}
|
||||
.image-area img { width: 100%; height: 100%; object-fit: contain; display: block; }
|
||||
.no-image { color: #333; font-size: 13px; }
|
||||
.watermark {
|
||||
position: absolute; top: 12px; left: 16px;
|
||||
font-family: monospace; font-size: 9px; font-weight: 500;
|
||||
color: rgba(255,255,255,.2); letter-spacing: .15em; text-transform: uppercase;
|
||||
}
|
||||
.counter {
|
||||
position: absolute; top: 12px; right: 16px;
|
||||
font-family: monospace; font-size: 9px;
|
||||
color: rgba(255,255,255,.2);
|
||||
}
|
||||
|
||||
/* ── Info bar ── */
|
||||
.info-bar {
|
||||
flex-shrink: 0;
|
||||
background: #111; border-top: 1px solid #2a2a2a;
|
||||
padding: 10px 24px;
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
}
|
||||
.shot-header { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
|
||||
.shot-code-fs { font-family: monospace; font-size: 20px; font-weight: 700; color: #fff; letter-spacing: -.01em; }
|
||||
.key-label { font-size: 10px; color: #f59e0b; font-weight: 600; }
|
||||
.version-fs { font-family: monospace; font-size: 11px; color: #6b7280; }
|
||||
.meta-row { font-size: 10px; color: #6b7280; margin-top: 1px; }
|
||||
.desc-fs { font-size: 12px; color: #d1d5db; line-height: 1.5; margin-top: 4px; }
|
||||
.notes-fs { font-size: 10px; color: #6b7280; font-style: italic; margin-top: 2px; }
|
||||
|
||||
/* ── Preview offset ── */
|
||||
.pages-wrap { padding-top: 52px; }
|
||||
@media print { .pages-wrap { padding-top: 0; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="print-bar">
|
||||
<div class="print-bar-info">
|
||||
<h1>${esc(project.name)} — Storyboard</h1>
|
||||
<p>${shots.length} shot${shots.length !== 1 ? "s" : ""} · Fullscreen · A4 Landscape</p>
|
||||
</div>
|
||||
<button class="print-btn" onclick="window.print()">🖨 Print / Save as PDF</button>
|
||||
</div>
|
||||
<div class="pages-wrap">
|
||||
${pages}
|
||||
</div>
|
||||
<script>
|
||||
var imgs = Array.from(document.querySelectorAll('img'));
|
||||
var done = 0;
|
||||
function check() { if (++done >= imgs.length) setTimeout(function(){ window.print(); }, 200); }
|
||||
if (!imgs.length) { setTimeout(function(){ window.print(); }, 200); }
|
||||
else imgs.forEach(function(img) { if (img.complete) check(); else { img.onload = check; img.onerror = check; } });
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
// ─── 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<string, Shot[]>();
|
||||
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" },
|
||||
});
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center w-full h-full">
|
||||
<div className="flex items-center justify-center w-full h-full sb-no-thumb">
|
||||
<Film className="h-6 w-6 text-zinc-700 print:text-zinc-400" />
|
||||
</div>
|
||||
)}
|
||||
@@ -182,7 +182,7 @@ function StandardLayout({
|
||||
{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" />
|
||||
<div className="flex-1 h-px bg-zinc-700 print:bg-zinc-300 sb-group-header-line" />
|
||||
<span className="text-xs font-bold uppercase tracking-widest text-zinc-400 print:text-zinc-600 px-2">
|
||||
{group.label}
|
||||
</span>
|
||||
@@ -239,7 +239,7 @@ function FullscreenLayout({
|
||||
)}
|
||||
>
|
||||
{/* Full-bleed image */}
|
||||
<div className="relative flex-1 bg-zinc-900 print:bg-black overflow-hidden">
|
||||
<div className="relative flex-1 bg-zinc-900 print:bg-black overflow-hidden sb-fs-image-area">
|
||||
{shot.thumbnailUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
@@ -269,7 +269,7 @@ function FullscreenLayout({
|
||||
</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="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 sb-fs-infobar">
|
||||
<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">
|
||||
@@ -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 */}
|
||||
<style dangerouslySetInnerHTML={{ __html: printStyles }} />
|
||||
|
||||
<div className="flex h-full overflow-hidden bg-zinc-950">
|
||||
<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 */}
|
||||
@@ -740,19 +730,24 @@ export function StoryboardGenerator({ projects }: Props) {
|
||||
|
||||
{/* Generate button */}
|
||||
<div className="shrink-0 p-4 border-t border-zinc-800 space-y-2">
|
||||
{layout === "fullscreen" && (
|
||||
{layout === "fullscreen" && (
|
||||
<p className="text-[10px] text-zinc-600 text-center leading-tight">
|
||||
Tip: choose Landscape in the print dialog for best results
|
||||
Tip: choose Landscape in the print dialog
|
||||
</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>
|
||||
{filteredShots.length > 0 ? (
|
||||
<a href={pdfUrl} target="_blank" rel="noopener noreferrer" className="block">
|
||||
<Button className="w-full bg-amber-600 hover:bg-amber-500 text-white font-semibold">
|
||||
<Printer className="h-4 w-4 mr-2" />
|
||||
Generate PDF
|
||||
</Button>
|
||||
</a>
|
||||
) : (
|
||||
<Button className="w-full bg-amber-600/40 text-white/40 font-semibold" disabled>
|
||||
<Printer className="h-4 w-4 mr-2" />
|
||||
Generate PDF
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -770,7 +765,7 @@ export function StoryboardGenerator({ projects }: Props) {
|
||||
</div>
|
||||
|
||||
{/* Storyboard content */}
|
||||
<div className="print-area p-6 print:p-0">
|
||||
<div className={cn("print-area p-6 print:p-0", layout === "standard" ? "sb-standard-print" : "sb-fullscreen-print")}>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-24 no-print">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-zinc-600" />
|
||||
@@ -801,9 +796,8 @@ export function StoryboardGenerator({ projects }: Props) {
|
||||
projectName={project?.name ?? ""}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user