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 ( +
+ Loading shot data… +
+ ); + } + + const shot = data?.shot; + if (!shot) return null; + + const hasContent = shot.thumbnailUrl || shot.references.length > 0; + if (!hasContent) return null; + + return ( +
+
+ + Shot Reference — {shot.shotCode} +
+ + {shot.thumbnailUrl && ( +
+ {shot.shotCode} +
+ )} + + {shot.references.length > 0 && ( +
+ {shot.references.map((ref) => ( +
+ {ref.label + {ref.label && ( +
+ {ref.label} +
+ )} +
+ ))} +
+ )} +
+ ); +} + // ─── Shot selector ──────────────────────────────────────────────────────────── interface ShotOption { @@ -440,6 +521,9 @@ export function TakeEditorPanel({ value={fields.pipelineShotId} onChange={(id) => set("pipelineShotId", id)} /> + {fields.pipelineShotId && ( + + )} {/* Camera */} diff --git a/components/shots/ShotSettingsTab.tsx b/components/shots/ShotSettingsTab.tsx index fa1082b..3493d8e 100644 --- a/components/shots/ShotSettingsTab.tsx +++ b/components/shots/ShotSettingsTab.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useRef } from "react"; +import { useState, useRef, useEffect, useCallback } from "react"; import { useRouter } from "next/navigation"; import Image from "next/image"; import { useForm } from "react-hook-form"; @@ -20,7 +20,143 @@ import { } from "@/components/ui/select"; import { updateShot, deleteShot } from "@/actions/shots"; import { useToast } from "@/components/ui/use-toast"; -import { Upload, X, Film, ImageIcon, Trash2 } from "lucide-react"; +import { Upload, X, Film, ImageIcon, Trash2, BookImage, Loader2 } from "lucide-react"; + +// ─── Shot References ────────────────────────────────────────────────────────── + +interface ShotRef { + id: string; + fileUrl: string; + fileName: string; + label: string | null; + sortOrder: number; +} + +function ShotReferencesSection({ shotId }: { shotId: string }) { + const { toast } = useToast(); + const [refs, setRefs] = useState([]); + const [loading, setLoading] = useState(true); + const [uploading, setUploading] = useState(false); + const fileInputRef = useRef(null); + + const fetchRefs = useCallback(async () => { + try { + const res = await fetch(`/api/shots/${shotId}/references`); + if (res.ok) { + const data = await res.json(); + setRefs(data.references); + } + } finally { + setLoading(false); + } + }, [shotId]); + + useEffect(() => { fetchRefs(); }, [fetchRefs]); + + const handleUpload = async (e: React.ChangeEvent) => { + const files = Array.from(e.target.files ?? []); + if (!files.length) return; + setUploading(true); + try { + for (const file of files) { + const fd = new FormData(); + fd.append("file", file); + const res = await fetch(`/api/shots/${shotId}/references`, { method: "POST", body: fd }); + if (!res.ok) throw new Error(await res.text()); + const { reference } = await res.json(); + setRefs((prev) => [...prev, reference]); + } + } catch (err) { + toast({ title: "Upload failed", description: err instanceof Error ? err.message : undefined, variant: "destructive" }); + } finally { + setUploading(false); + if (fileInputRef.current) fileInputRef.current.value = ""; + } + }; + + const handleDelete = async (refId: string) => { + try { + const res = await fetch(`/api/shots/${shotId}/references?refId=${refId}`, { method: "DELETE" }); + if (!res.ok) throw new Error("Delete failed"); + setRefs((prev) => prev.filter((r) => r.id !== refId)); + } catch { + toast({ title: "Failed to delete reference", variant: "destructive" }); + } + }; + + return ( +
+
+ + References +
+ + + {loading ? ( +
+ Loading… +
+ ) : ( + <> + {refs.length > 0 && ( +
+ {refs.map((ref) => ( +
+ {ref.label + {ref.label && ( +
+ {ref.label} +
+ )} + +
+ ))} +
+ )} + +
+ + +
+ + )} +
+ ); +} + +// ─── Settings form ──────────────────────────────────────────────────────────── const settingsSchema = z.object({ shotCode: z.string().min(1, "Required").max(120).regex(/^[A-Z0-9_]+$/i, "Alphanumeric and underscores only"), @@ -318,6 +454,9 @@ export function ShotSettingsTab({ shot, artists, onSaved }: ShotSettingsTabProps /> + {/* References */} + + diff --git a/prisma/migrations/20260712000000_add_shot_references/migration.sql b/prisma/migrations/20260712000000_add_shot_references/migration.sql new file mode 100644 index 0000000..df61d08 --- /dev/null +++ b/prisma/migrations/20260712000000_add_shot_references/migration.sql @@ -0,0 +1,17 @@ +-- CreateTable +CREATE TABLE "shot_references" ( + "id" TEXT NOT NULL, + "shotId" TEXT NOT NULL, + "label" TEXT, + "fileUrl" TEXT NOT NULL, + "fileKey" TEXT NOT NULL DEFAULT '', + "fileName" TEXT NOT NULL DEFAULT '', + "fileSize" BIGINT, + "sortOrder" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "shot_references_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "shot_references" ADD CONSTRAINT "shot_references_shotId_fkey" FOREIGN KEY ("shotId") REFERENCES "shots"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ab710a4..838dec8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -357,6 +357,7 @@ model Shot { versions Version[] tasks Task[] footagePlates FootagePlate[] + references ShotReference[] loggedTakes Take[] @relation("TakeToShot") @@unique([projectId, shotCode]) @@ -379,6 +380,22 @@ model FootagePlate { @@map("footage_plates") } +model ShotReference { + id String @id @default(cuid()) + shotId String + label String? + fileUrl String + fileKey String @default("") + fileName String @default("") + fileSize BigInt? + sortOrder Int @default(0) + createdAt DateTime @default(now()) + + shot Shot @relation(fields: [shotId], references: [id], onDelete: Cascade) + + @@map("shot_references") +} + model ShotGroup { id String @id @default(cuid()) name String