diff --git a/app/(dashboard)/storyboard/page.tsx b/app/(dashboard)/storyboard/page.tsx new file mode 100644 index 0000000..191d12e --- /dev/null +++ b/app/(dashboard)/storyboard/page.tsx @@ -0,0 +1,22 @@ +import { auth } from "@/auth"; +import { redirect } from "next/navigation"; +import { db } from "@/lib/db"; +import { StoryboardGenerator } from "@/components/storyboard/StoryboardGenerator"; + +export const metadata = { title: "Storyboard Generator" }; + +export default async function StoryboardPage() { + const session = await auth(); + if (!session?.user) redirect("/login"); + if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) { + redirect("/dashboard"); + } + + const projects = await db.project.findMany({ + where: { status: { in: ["ACTIVE", "ON_HOLD"] } }, + select: { id: true, name: true, code: true, projectType: true }, + orderBy: { name: "asc" }, + }); + + return ; +} diff --git a/app/api/storyboard/shots/route.ts b/app/api/storyboard/shots/route.ts new file mode 100644 index 0000000..f3927d0 --- /dev/null +++ b/app/api/storyboard/shots/route.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/auth"; +import { db } from "@/lib/db"; + +// GET /api/storyboard/shots?projectId=xxx +export async function GET(req: NextRequest) { + const session = await auth(); + if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const projectId = req.nextUrl.searchParams.get("projectId"); + if (!projectId) return NextResponse.json({ error: "projectId required" }, { status: 400 }); + + const shots = await db.shot.findMany({ + where: { projectId }, + select: { + id: true, + shotCode: true, + scene: true, + episode: true, + shotNumber: true, + sequence: 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" }, + ], + }); + + return NextResponse.json({ shots }); +} diff --git a/components/layout/Sidebar.tsx b/components/layout/Sidebar.tsx index c6323d5..78b4735 100644 --- a/components/layout/Sidebar.tsx +++ b/components/layout/Sidebar.tsx @@ -20,6 +20,7 @@ import { ListVideo, CloudUpload, Clapperboard, + LayoutGrid, } from 'lucide-react'; import { useState } from 'react'; import { useSession } from 'next-auth/react'; @@ -32,6 +33,7 @@ const navItems = [ { href: '/playlist', label: 'Playlist', icon: ListVideo, hideForClient: true }, { href: '/tasks', label: 'My Tasks', icon: ListTodo, hideForClient: true }, { href: '/shoot-log', label: 'Shot Log', icon: Clapperboard, supervisorOnly: true }, + { href: '/storyboard', label: 'Storyboard', icon: LayoutGrid, supervisorOnly: true }, { href: '/schedule', label: 'Schedule', icon: CalendarRange, adminOnly: true }, { href: '/batch-upload', label: 'Batch Upload', icon: CloudUpload, adminOnly: true }, { href: '/clients', label: 'Clients', icon: Users, adminOnly: true }, diff --git a/components/shoot-log/ShootLogClient.tsx b/components/shoot-log/ShootLogClient.tsx index 66661c4..317f468 100644 --- a/components/shoot-log/ShootLogClient.tsx +++ b/components/shoot-log/ShootLogClient.tsx @@ -7,6 +7,7 @@ import { TakeListPanel } from "./TakeListPanel"; import { TakeEditorPanel, type FullTake } from "./TakeEditorPanel"; import { NewShootDayDialog } from "./NewShootDayDialog"; import { NewSetupDialog } from "./NewSetupDialog"; +import { cn } from "@/lib/utils"; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -28,6 +29,8 @@ export function ShootLogClient({ projects }: Props) { const [projectId, setProjectId] = useState(projects[0]?.id ?? ""); const [selectedTakeId, setSelectedTakeId] = useState(null); const [selectedSetupId, setSelectedSetupId] = useState(null); + // Controls which panel is visible on mobile (< md) + const [mobileView, setMobileView] = useState<"list" | "editor">("list"); // Which day to auto-expand in the left panel after creation const [expandDayId, setExpandDayId] = useState(null); @@ -92,6 +95,7 @@ export function ShootLogClient({ projects }: Props) { invalidateDays(); setSelectedTakeId(newTake.id); setSelectedSetupId(setupId); + setMobileView("editor"); }, [invalidateDays] ); @@ -122,6 +126,7 @@ export function ShootLogClient({ projects }: Props) { setExpandDayId(day.id); setSelectedTakeId(newTake.id); setSelectedSetupId(setup.id); + setMobileView("editor"); } catch { // Day was created but setup/take failed — still refresh invalidateDays(); @@ -158,6 +163,7 @@ export function ShootLogClient({ projects }: Props) { if (!confirm("Delete this take? This cannot be undone.")) return; await fetch(`/api/shoot-log/takes/${selectedTakeId}`, { method: "DELETE" }); setSelectedTakeId(null); + setMobileView("list"); invalidateDays(); }, [selectedTakeId, invalidateDays]); @@ -186,6 +192,7 @@ export function ShootLogClient({ projects }: Props) { setSelectedTakeId(null); setSelectedSetupId(null); setExpandDayId(null); + setMobileView("list"); }} className="bg-zinc-800 border border-zinc-700 rounded-lg text-sm text-white px-3 py-1.5 focus:outline-none focus:border-amber-600" > @@ -207,8 +214,12 @@ export function ShootLogClient({ projects }: Props) { {/* Split layout */}
- {/* Left panel */} -
+ {/* Left panel — full width on mobile, fixed sidebar on md+ */} +
{ setSelectedTakeId(takeId); setSelectedSetupId(setupId); + setMobileView("editor"); }} onNewDay={() => setNewDayOpen(true)} onNewSetup={(dayId) => setNewSetupDayId(dayId)} @@ -223,8 +235,11 @@ export function ShootLogClient({ projects }: Props) { />
- {/* Right panel */} -
+ {/* Right panel — hidden on mobile when list is showing */} +
{takeLoading ? (
@@ -240,6 +255,7 @@ export function ShootLogClient({ projects }: Props) { onNewTake={() => createTake(take.setupId)} onDelete={handleDelete} onAttachmentsChange={invalidateDays} + onBack={() => setMobileView("list")} /> ) : (
diff --git a/components/shoot-log/TakeEditorPanel.tsx b/components/shoot-log/TakeEditorPanel.tsx index 98ec018..9ba456d 100644 --- a/components/shoot-log/TakeEditorPanel.tsx +++ b/components/shoot-log/TakeEditorPanel.tsx @@ -498,6 +498,7 @@ interface Props { onNewTake: () => void; onDelete: () => void; onAttachmentsChange: () => void; + onBack?: (() => void) | null; } export function TakeEditorPanel({ @@ -509,6 +510,7 @@ export function TakeEditorPanel({ onNewTake, onDelete, onAttachmentsChange, + onBack, }: Props) { const { status, queue } = useAutosave(take.id); @@ -550,6 +552,13 @@ export function TakeEditorPanel({
{/* ── Nav bar ── */}
+ {/* Back to list — mobile only */} + {onBack && ( + + )}
+ ); +} + +// ─── Collapsible section ─────────────────────────────────────────────────────── + +function Section({ + title, + children, + defaultOpen = true, +}: { + title: string; + children: React.ReactNode; + defaultOpen?: boolean; +}) { + const [open, setOpen] = useState(defaultOpen); + return ( +
+ + {open &&
{children}
} +
+ ); +} + +// ─── Checkbox row ───────────────────────────────────────────────────────────── + +function CheckRow({ + label, + checked, + onChange, +}: { + label: string; + checked: boolean; + onChange: (v: boolean) => void; +}) { + return ( + + ); +} + +// ─── Main component ─────────────────────────────────────────────────────────── + +interface Props { + projects: Project[]; +} + +export function StoryboardGenerator({ projects }: Props) { + const [projectId, setProjectId] = useState(projects[0]?.id ?? ""); + const [layout, setLayout] = useState("standard"); + const [columns, setColumns] = useState(3); + const [groupBy, setGroupBy] = useState("episode"); + const [pageBreakBetweenGroups, setPageBreakBetweenGroups] = useState(true); + const [showOnlyWithThumbnails, setShowOnlyWithThumbnails] = useState(false); + + // Filter state — sets of episode/group keys; empty = all + const [selectedEpisodes, setSelectedEpisodes] = useState>(new Set()); + const [selectedGroups, setSelectedGroups] = useState>(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(); + 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(); + 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 */} +