diff --git a/app/api/shoot-log/shots/[shotId]/route.ts b/app/api/shoot-log/shots/[shotId]/route.ts new file mode 100644 index 0000000..d29746b --- /dev/null +++ b/app/api/shoot-log/shots/[shotId]/route.ts @@ -0,0 +1,45 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/auth"; +import { db } from "@/lib/db"; + +function requireRole(role: string) { + return ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(role); +} + +// GET /api/shoot-log/shots/[shotId] +// Returns a shot's thumbnail + references for display in the shoot-log +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ shotId: string }> } +) { + const session = await auth(); + if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + if (!requireRole(session.user.role)) + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + + const { shotId } = await params; + + const shot = await db.shot.findUnique({ + where: { id: shotId }, + select: { + id: true, + shotCode: true, + scene: true, + episode: true, + thumbnailUrl: true, + references: { + orderBy: { sortOrder: "asc" }, + select: { + id: true, + fileUrl: true, + fileName: true, + label: true, + sortOrder: true, + }, + }, + }, + }); + + if (!shot) return NextResponse.json({ error: "Not found" }, { status: 404 }); + return NextResponse.json({ shot }); +} diff --git a/app/api/shots/[shotId]/references/route.ts b/app/api/shots/[shotId]/references/route.ts new file mode 100644 index 0000000..708d99f --- /dev/null +++ b/app/api/shots/[shotId]/references/route.ts @@ -0,0 +1,108 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { uploadFile } from "@/lib/storage"; + +function requireAuth(role: string) { + return ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(role); +} + +// GET /api/shots/[shotId]/references +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ shotId: string }> } +) { + const session = await auth(); + if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const { shotId } = await params; + + const references = await db.shotReference.findMany({ + where: { shotId }, + orderBy: { sortOrder: "asc" }, + }); + + return NextResponse.json({ + references: references.map((r) => ({ + ...r, + fileSize: r.fileSize != null ? Number(r.fileSize) : null, + })), + }); +} + +// POST /api/shots/[shotId]/references (multipart upload) +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ shotId: string }> } +) { + const session = await auth(); + if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + if (!requireAuth(session.user.role)) + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + + const { shotId } = await params; + + const shot = await db.shot.findUnique({ where: { id: shotId }, select: { id: true } }); + if (!shot) return NextResponse.json({ error: "Shot not found" }, { status: 404 }); + + const formData = await req.formData(); + const file = formData.get("file") as File | null; + const label = (formData.get("label") as string) || null; + + if (!file) return NextResponse.json({ error: "No file provided" }, { status: 400 }); + if (!file.type.startsWith("image/")) + return NextResponse.json({ error: "Only image files accepted" }, { status: 400 }); + + const maxSize = 50 * 1024 * 1024; // 50 MB + if (file.size > maxSize) + return NextResponse.json({ error: "File too large (max 50 MB)" }, { status: 413 }); + + const buffer = Buffer.from(await file.arrayBuffer()); + const uploaded = await uploadFile(buffer, file.name, file.type, "image"); + + const maxOrder = await db.shotReference.aggregate({ + where: { shotId }, + _max: { sortOrder: true }, + }); + const sortOrder = (maxOrder._max.sortOrder ?? -1) + 1; + + const reference = await db.shotReference.create({ + data: { + shotId, + label, + fileUrl: uploaded.url, + fileKey: uploaded.key, + fileName: file.name, + fileSize: BigInt(file.size), + sortOrder, + }, + }); + + return NextResponse.json({ + reference: { ...reference, fileSize: Number(reference.fileSize) }, + }); +} + +// DELETE /api/shots/[shotId]/references/[refId] handled via query param +// DELETE /api/shots/[shotId]/references?refId=xxx +export async function DELETE( + req: NextRequest, + { params }: { params: Promise<{ shotId: string }> } +) { + const session = await auth(); + if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + if (!requireAuth(session.user.role)) + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + + const { shotId } = await params; + const { searchParams } = new URL(req.url); + const refId = searchParams.get("refId"); + + if (!refId) return NextResponse.json({ error: "refId required" }, { status: 400 }); + + const ref = await db.shotReference.findFirst({ where: { id: refId, shotId } }); + if (!ref) return NextResponse.json({ error: "Not found" }, { status: 404 }); + + await db.shotReference.delete({ where: { id: refId } }); + return NextResponse.json({ ok: true }); +} diff --git a/components/shoot-log/TakeEditorPanel.tsx b/components/shoot-log/TakeEditorPanel.tsx index 1374b27..e28076d 100644 --- a/components/shoot-log/TakeEditorPanel.tsx +++ b/components/shoot-log/TakeEditorPanel.tsx @@ -4,8 +4,9 @@ import { useEffect, useRef, useState, useCallback } from "react"; import { useQuery } from "@tanstack/react-query"; import { ChevronLeft, ChevronRight, Copy, Plus, Check, Loader2, - Trash2, AlertCircle, ChevronDown, ChevronUp, Link2, X, + Trash2, AlertCircle, ChevronDown, ChevronUp, Link2, X, BookImage, } from "lucide-react"; +import Image from "next/image"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; @@ -201,6 +202,86 @@ function SectionHeader({ title }: { title: string }) { ); } +// ─── Linked shot panel ──────────────────────────────────────────────────────── + +interface LinkedShot { + id: string; + shotCode: string; + scene: string | null; + episode: string | null; + thumbnailUrl: string | null; + references: { id: string; fileUrl: string; fileName: string; label: string | null }[]; +} + +function LinkedShotPanel({ shotId }: { shotId: string }) { + const { data, isLoading } = useQuery<{ shot: LinkedShot }>({ + queryKey: ["linked-shot", shotId], + queryFn: async () => { + const res = await fetch(`/api/shoot-log/shots/${shotId}`); + if (!res.ok) throw new Error("Failed"); + return res.json(); + }, + staleTime: 60_000, + enabled: !!shotId, + }); + + if (isLoading) { + return ( +