From 3e3a8f7a26cd6fe62e5621479de34e3a7f738979 Mon Sep 17 00:00:00 2001 From: twotalesanimation <80506065+twotalesanimation@users.noreply.github.com> Date: Thu, 21 May 2026 19:16:03 +0200 Subject: [PATCH] multiple plates --- actions/shots.ts | 87 +++++ app/api/shots/[shotId]/route.ts | 5 + components/shots/FootageViewer.tsx | 299 +++++++++++++----- components/shots/ShotSettingsTab.tsx | 177 +---------- .../migration.sql | 32 ++ prisma/schema.prisma | 17 + types/index.ts | 10 + 7 files changed, 377 insertions(+), 250 deletions(-) create mode 100644 prisma/migrations/20260521100000_add_footage_plates/migration.sql diff --git a/actions/shots.ts b/actions/shots.ts index a71bc13..c8ebb24 100644 --- a/actions/shots.ts +++ b/actions/shots.ts @@ -392,3 +392,90 @@ export async function getShotsByProject(projectId: string) { orderBy: [{ sequence: "asc" }, { shotCode: "asc" }], }); } + +// ─── Footage Plates ──────────────────────────────────────────────────────────── + +const addFootagePlateSchema = z.object({ + shotId: z.string().min(1), + fileUrl: z.string().url(), + fileKey: z.string().default(""), + fileName: z.string().default(""), + fileSize: z.number().int().positive().optional(), + label: z.string().max(100).default(""), +}); + +export async function addFootagePlate(raw: unknown) { + const session = await auth(); + if (!session?.user) throw new Error("Unauthorized"); + + const data = addFootagePlateSchema.parse(raw); + + // Determine next sortOrder + const last = await db.footagePlate.findFirst({ + where: { shotId: data.shotId }, + orderBy: { sortOrder: "desc" }, + select: { sortOrder: true }, + }); + + const plate = await db.footagePlate.create({ + data: { + shotId: data.shotId, + fileUrl: data.fileUrl, + fileKey: data.fileKey, + fileName: data.fileName, + fileSize: data.fileSize != null ? BigInt(data.fileSize) : null, + label: data.label, + sortOrder: (last?.sortOrder ?? -1) + 1, + }, + }); + + const shot = await db.shot.findUnique({ where: { id: data.shotId }, select: { projectId: true } }); + if (shot) { + revalidatePath(`/projects/${shot.projectId}`); + revalidatePath(`/projects/${shot.projectId}/shots/${data.shotId}`); + } + + return { success: true, plate: { ...plate, fileSize: plate.fileSize?.toString() ?? null } }; +} + +export async function removeFootagePlate(plateId: string) { + const session = await auth(); + if (!session?.user) throw new Error("Unauthorized"); + + const role = session.user.role as string; + if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(role)) throw new Error("Insufficient permissions"); + + const plate = await db.footagePlate.findUnique({ + where: { id: plateId }, + include: { shot: { select: { projectId: true } } }, + }); + if (!plate) throw new Error("Plate not found"); + + await db.footagePlate.delete({ where: { id: plateId } }); + + revalidatePath(`/projects/${plate.shot.projectId}`); + revalidatePath(`/projects/${plate.shot.projectId}/shots/${plate.shotId}`); + + return { success: true }; +} + +export async function renameFootagePlate(plateId: string, label: string) { + const session = await auth(); + if (!session?.user) throw new Error("Unauthorized"); + + const role = session.user.role as string; + if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(role)) throw new Error("Insufficient permissions"); + + const trimmed = label.trim().slice(0, 100); + + const plate = await db.footagePlate.update({ + where: { id: plateId }, + data: { label: trimmed }, + include: { shot: { select: { projectId: true } } }, + }); + + revalidatePath(`/projects/${plate.shot.projectId}`); + revalidatePath(`/projects/${plate.shot.projectId}/shots/${plate.shotId}`); + + return { success: true }; +} diff --git a/app/api/shots/[shotId]/route.ts b/app/api/shots/[shotId]/route.ts index 0aa7d37..dcc7df8 100644 --- a/app/api/shots/[shotId]/route.ts +++ b/app/api/shots/[shotId]/route.ts @@ -17,6 +17,7 @@ export async function GET( where: { id: shotId }, include: { artist: { select: { id: true, name: true, email: true, image: true } }, + footagePlates: { orderBy: { sortOrder: "asc" } }, versions: { orderBy: { versionNumber: "desc" }, include: { @@ -68,6 +69,10 @@ export async function GET( // Serialize BigInt fields (fileSize) so JSON.stringify doesn't throw const shotSerialized = { ...shot, + footagePlates: shot.footagePlates.map((p) => ({ + ...p, + fileSize: p.fileSize != null ? p.fileSize.toString() : null, + })), versions: shot.versions.map((v) => ({ ...v, fileSize: v.fileSize != null ? v.fileSize.toString() : null, diff --git a/components/shots/FootageViewer.tsx b/components/shots/FootageViewer.tsx index d6adb42..7af4a80 100644 --- a/components/shots/FootageViewer.tsx +++ b/components/shots/FootageViewer.tsx @@ -1,17 +1,29 @@ -"use client"; +"use client"; import { useRef, useState } from "react"; -import { Video, Upload, X } from "lucide-react"; +import { Video, Upload, X, Plus, Pencil, Trash2, Film } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { updateShot } from "@/actions/shots"; +import { Input } from "@/components/ui/input"; +import { addFootagePlate, removeFootagePlate, renameFootagePlate } from "@/actions/shots"; import { useToast } from "@/components/ui/use-toast"; +import { cn } from "@/lib/utils"; + +interface Plate { + id: string; + label: string; + fileUrl: string; + fileKey: string; + fileName: string; + fileSize: bigint | string | null; + sortOrder: number; + createdAt: Date; +} interface FootageViewerProps { shot: { id: string; shotCode: string; - originalFootageUrl: string | null; - originalFootageKey: string | null; + footagePlates: Plate[]; }; canManage: boolean; onSaved?: () => void; @@ -57,36 +69,62 @@ function uploadViaXhr( }); } +function formatSize(size: bigint | string | null): string { + if (size == null) return ""; + const n = typeof size === "string" ? parseInt(size, 10) : Number(size); + if (isNaN(n)) return ""; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`; + return `${(n / 1024 / 1024).toFixed(1)} MB`; +} + export function FootageViewer({ shot, canManage, onSaved }: FootageViewerProps) { const { toast } = useToast(); - const videoRef = useRef(null); const fileInputRef = useRef(null); - const [footageFile, setFootageFile] = useState(null); + + const [plates, setPlates] = useState(shot.footagePlates ?? []); + const [selectedId, setSelectedId] = useState( + (shot.footagePlates ?? [])[0]?.id ?? null + ); + const [pendingFile, setPendingFile] = useState(null); + const [pendingLabel, setPendingLabel] = useState(""); const [uploading, setUploading] = useState(false); const [progress, setProgress] = useState(0); - const [currentUrl, setCurrentUrl] = useState(shot.originalFootageUrl ?? null); + const [editingId, setEditingId] = useState(null); + const [editLabel, setEditLabel] = useState(""); + const [deletingId, setDeletingId] = useState(null); + const selectedPlate = plates.find((p) => p.id === selectedId) ?? null; + + // ── upload new plate ────────────────────────────────────────────────────── const handleFileChange = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; - setFootageFile(file); + setPendingFile(file); + setPendingLabel(""); e.target.value = ""; }; const handleUpload = async () => { - if (!footageFile) return; + if (!pendingFile) return; setUploading(true); setProgress(0); try { - const { url, key } = await uploadViaXhr(footageFile, setProgress); - await updateShot({ + const { url, key } = await uploadViaXhr(pendingFile, setProgress); + const result = await addFootagePlate({ shotId: shot.id, - originalFootageUrl: url, - originalFootageKey: key || undefined, + fileUrl: url, + fileKey: key, + fileName: pendingFile.name, + fileSize: pendingFile.size, + label: pendingLabel.trim() || `Plate ${plates.length + 1}`, }); - setCurrentUrl(url); - setFootageFile(null); - toast({ title: "Footage uploaded" }); + const newPlate = result.plate as Plate; + const updated = [...plates, newPlate]; + setPlates(updated); + setSelectedId(newPlate.id); + setPendingFile(null); + setPendingLabel(""); + toast({ title: "Footage plate added" }); onSaved?.(); } catch (e) { toast({ @@ -100,62 +138,76 @@ export function FootageViewer({ shot, canManage, onSaved }: FootageViewerProps) } }; - const handleRemove = async () => { + const handleCancelPending = () => { + setPendingFile(null); + setPendingLabel(""); + }; + + // ── remove plate ────────────────────────────────────────────────────────── + const handleRemove = async (plateId: string) => { + setDeletingId(plateId); try { - await updateShot({ shotId: shot.id, originalFootageUrl: null, originalFootageKey: null }); - setCurrentUrl(null); - setFootageFile(null); - toast({ title: "Footage removed" }); + await removeFootagePlate(plateId); + const updated = plates.filter((p) => p.id !== plateId); + setPlates(updated); + if (selectedId === plateId) setSelectedId(updated[0]?.id ?? null); + toast({ title: "Plate removed" }); onSaved?.(); } catch (e) { toast({ - title: "Failed to remove footage", + title: "Failed to remove plate", description: e instanceof Error ? e.message : undefined, variant: "destructive", }); + } finally { + setDeletingId(null); + } + }; + + // ── rename plate ────────────────────────────────────────────────────────── + const startEdit = (plate: Plate) => { + setEditingId(plate.id); + setEditLabel(plate.label); + }; + + const commitEdit = async (plateId: string) => { + try { + await renameFootagePlate(plateId, editLabel); + setPlates((prev) => + prev.map((p) => (p.id === plateId ? { ...p, label: editLabel.trim() } : p)) + ); + onSaved?.(); + } catch (e) { + toast({ + title: "Failed to rename plate", + description: e instanceof Error ? e.message : undefined, + variant: "destructive", + }); + } finally { + setEditingId(null); } }; return (
-
-

-

- {canManage && currentUrl && ( -
- - -
+ {/* Header */} +

+

+ {/* Video player */} - {currentUrl && ( + {selectedPlate ? (
+ ) : ( + !pendingFile && ( +
canManage && fileInputRef.current?.click()} + > + +

No footage plates yet.

+ {canManage &&

Click to add a video file

} +
+ ) )} - {/* Empty state */} - {!currentUrl && !footageFile && ( -
canManage && fileInputRef.current?.click()} - > -