"use client"; import { useState, useCallback, useRef } from "react"; import { Button } from "@/components/ui/button"; import { Progress } from "@/components/ui/progress"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Upload, FileVideo, CheckCircle2, XCircle, Loader2, ChevronRight, ArrowRight, RotateCcw, } from "lucide-react"; import { cn } from "@/lib/utils"; import { useToast } from "@/components/ui/use-toast"; import type { PreviewItem, PreviewItemStatus } from "@/app/api/batch-upload/preview/route"; interface Project { id: string; name: string; code: string; } type UploadStatus = "pending" | "uploading" | "success" | "error"; interface UploadState { status: UploadStatus; error?: string; } interface BatchUploadClientProps { projects: Project[]; } export function BatchUploadClient({ projects }: BatchUploadClientProps) { const { toast } = useToast(); const [projectId, setProjectId] = useState(""); const [files, setFiles] = useState([]); const [isDragging, setIsDragging] = useState(false); const [isLoadingPreview, setIsLoadingPreview] = useState(false); const [preview, setPreview] = useState(null); const [uploadStates, setUploadStates] = useState>({}); const [isUploading, setIsUploading] = useState(false); const [uploadComplete, setUploadComplete] = useState(false); const fileInputRef = useRef(null); // ── Helpers ──────────────────────────────────────────────────────────────── const reset = () => { setFiles([]); setPreview(null); setUploadStates({}); setUploadComplete(false); }; const acceptFile = (f: File) => f.name.toLowerCase().endsWith(".mp4") || f.name.toLowerCase().endsWith(".mov"); // ── Drag & Drop ──────────────────────────────────────────────────────────── const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); setIsDragging(true); }, []); const handleDragLeave = useCallback(() => { setIsDragging(false); }, []); const handleDrop = useCallback((e: React.DragEvent) => { e.preventDefault(); setIsDragging(false); const dropped = Array.from(e.dataTransfer.files).filter(acceptFile); if (dropped.length > 0) { setFiles(dropped); setPreview(null); setUploadStates({}); setUploadComplete(false); } else { toast({ title: "No supported files", description: "Only .mp4 and .mov files are accepted." }); } }, [toast]); const handleFileInput = (e: React.ChangeEvent) => { const selected = Array.from(e.target.files ?? []).filter(acceptFile); if (selected.length > 0) { setFiles(selected); setPreview(null); setUploadStates({}); setUploadComplete(false); } // Reset so the same file can be re-selected e.target.value = ""; }; // ── Preview ──────────────────────────────────────────────────────────────── const fetchPreview = async () => { if (!projectId || files.length === 0) return; setIsLoadingPreview(true); try { const res = await fetch("/api/batch-upload/preview", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ projectId, fileNames: files.map((f) => f.name) }), }); if (!res.ok) throw new Error("Preview request failed"); const data = await res.json(); setPreview(data.items); // Initialise upload states const states: Record = {}; for (const item of data.items as PreviewItem[]) { states[item.fileName] = { status: "pending" }; } setUploadStates(states); } catch { toast({ title: "Preview failed", description: "Could not load the upload preview. Please try again.", variant: "destructive", }); } finally { setIsLoadingPreview(false); } }; // ── Upload ───────────────────────────────────────────────────────────────── const startUpload = async () => { if (!preview || !projectId) return; setIsUploading(true); const uploadable = preview.filter( (item) => item.status !== "no-shot" && item.status !== "unsupported" ); for (const item of uploadable) { const file = files.find((f) => f.name === item.fileName); if (!file) continue; setUploadStates((prev) => ({ ...prev, [item.fileName]: { status: "uploading" }, })); try { if (item.status === "update-highres") { // MOV high-res files: upload directly from the browser to Hetzner // via a presigned PUT URL so the file never passes through Nginx, // avoiding HTTP 413 (Request Entity Too Large) errors. // // Prerequisite: the Hetzner bucket must have a CORS policy that // allows PUT from this origin. Add it once via the Hetzner console // or AWS CLI: aws s3api put-bucket-cors --bucket \ // --cors-configuration '{"CORSRules":[{"AllowedOrigins":["*"], // "AllowedMethods":["PUT"],"AllowedHeaders":["*"]}]}' \ // --endpoint-url // Step 1 — obtain a presigned PUT URL + server-generated storage key const presignRes = await fetch("/api/batch-upload/presign", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ fileName: file.name }), }); if (!presignRes.ok) { const d = await presignRes.json().catch(() => ({ error: "Failed to get upload URL" })); throw new Error((d as { error?: string }).error ?? "Failed to get upload URL"); } const { presignedUrl, key } = await presignRes.json() as { presignedUrl: string; key: string }; // Step 2 — PUT the file directly to Hetzner (no proxy in the path) const putRes = await fetch(presignedUrl, { method: "PUT", body: file, headers: { "Content-Type": file.type || "video/quicktime" }, }); if (!putRes.ok) { throw new Error( putRes.status === 403 ? "Storage upload failed — check Hetzner CORS / bucket policy" : `Storage upload failed (HTTP ${putRes.status})` ); } // Step 3 — commit: record the uploaded key in the DB const fd = new FormData(); fd.append("action", "update-highres"); fd.append("shotId", item.shotId!); fd.append("projectId", projectId); fd.append("key", key); fd.append("fileName", file.name); const commitRes = await fetch("/api/batch-upload/upload", { method: "POST", body: fd }); if (!commitRes.ok) { const d = await commitRes.json().catch(() => ({ error: "Failed to save" })); throw new Error((d as { error?: string }).error ?? "Failed to save"); } } else { // MP4 version uploads: existing fetch-based flow const fd = new FormData(); fd.append("file", file); fd.append("action", item.status); fd.append("shotId", item.shotId!); fd.append("projectId", projectId); if (item.taskId) fd.append("taskId", item.taskId); if (item.fallbackTaskId) fd.append("fallbackTaskId", item.fallbackTaskId); if (item.newTaskTitle) fd.append("newTaskTitle", item.newTaskTitle); const res = await fetch("/api/batch-upload/upload", { method: "POST", body: fd, }); if (!res.ok) { const data = await res.json().catch(() => ({ error: "Upload failed" })); throw new Error(data.error ?? "Upload failed"); } } setUploadStates((prev) => ({ ...prev, [item.fileName]: { status: "success" }, })); } catch (err: unknown) { const message = err instanceof Error ? err.message : "Upload failed"; setUploadStates((prev) => ({ ...prev, [item.fileName]: { status: "error", error: message }, })); } } setIsUploading(false); setUploadComplete(true); }; // ── Derived counts ───────────────────────────────────────────────────────── const uploadable = preview?.filter( (i) => i.status !== "no-shot" && i.status !== "unsupported" ) ?? []; const skipped = preview?.filter( (i) => i.status === "no-shot" || i.status === "unsupported" ) ?? []; const successCount = Object.values(uploadStates).filter((s) => s.status === "success").length; const errorCount = Object.values(uploadStates).filter((s) => s.status === "error").length; const doneCount = successCount + errorCount; const progress = uploadable.length > 0 ? (doneCount / uploadable.length) * 100 : 0; // ── Render ───────────────────────────────────────────────────────────────── return (
{/* Header */}

Batch Upload

Drop .mp4 files to upload new versions to tasks, and{" "} .mov files to replace a shot's high-res deliverable. Files are matched to shots and tasks by filename.

{/* Project selector */} {/* Drop zone */} {projectId && !uploadComplete && (
fileInputRef.current?.click()} className={cn( "border-2 border-dashed rounded-xl p-12 text-center cursor-pointer transition-colors select-none", isDragging ? "border-amber-400 bg-amber-400/5" : "border-zinc-700 hover:border-zinc-500 hover:bg-zinc-800/30" )} >

Drop .mp4 /{" "} .mov files here

or click to browse

{files.length > 0 && (

{files.length} file{files.length !== 1 ? "s" : ""} selected

)}
{files.length > 0 && !preview && (
)}
)} {/* Preview table */} {preview && !uploadComplete && ( Upload preview —{" "} {preview.length} file{preview.length !== 1 ? "s" : ""}
{preview.map((item) => ( ))}
File Shot Action Status
{/* Progress bar while uploading */} {isUploading && (
Uploading {Math.min(doneCount + 1, uploadable.length)} of{" "} {uploadable.length}… {Math.round(progress)}%
)} {/* Footer actions */}

{uploadable.length} file{uploadable.length !== 1 ? "s" : ""} will be uploaded {skipped.length > 0 && ( {" "} · {skipped.length} skipped (not matched or unsupported) )}

)} {/* Done summary */} {uploadComplete && (

Upload complete

{successCount} succeeded {errorCount > 0 && ( · {errorCount} failed )}

)}
); } // ── Sub-components ───────────────────────────────────────────────────────────── function PreviewRow({ item, uploadState, }: { item: PreviewItem; uploadState: UploadState | undefined; }) { const ext = item.fileName.split(".").pop()?.toLowerCase() ?? ""; return ( {/* File */}
{item.fileName} .{ext}
{/* Shot */} {item.shotCode ? ( {item.shotCode} ) : ( Not found )} {/* Action */} {/* Upload status */} ); } function ActionCell({ item }: { item: PreviewItem }) { switch (item.status) { case "new-version": return (
New version

{item.currentTaskTitle}

); case "rename-and-upload": return (
Rename task + upload
{item.currentTaskTitle} {item.newTaskTitle}
); case "create-task": return (
{item.fallbackTaskId ? ( <> Rename Comp task + upload
{item.fallbackTaskTitle} {item.newTaskTitle}
) : ( <> Create new task

{item.newTaskTitle}

)}
); case "update-highres": return (
Replace high-res {item.currentHighResFilename && (

Replaces: {item.currentHighResFilename}

)}
); case "no-shot": return Shot not found; case "unsupported": return Unsupported file type; default: return null; } } function StatusCell({ status, uploadState, }: { status: PreviewItemStatus; uploadState: UploadState | undefined; }) { if (status === "no-shot" || status === "unsupported") { return Skipped; } if (!uploadState || uploadState.status === "pending") { return Pending; } if (uploadState.status === "uploading") { return ( Uploading ); } if (uploadState.status === "success") { return ( Done ); } if (uploadState.status === "error") { return ( Error ); } return null; } function ActionBadge({ children, color, }: { children: React.ReactNode; color: "green" | "amber" | "blue" | "purple" | "red" | "zinc"; }) { const map: Record = { green: "bg-green-500/10 text-green-400 border-green-500/30", amber: "bg-amber-500/10 text-amber-400 border-amber-500/30", blue: "bg-blue-500/10 text-blue-400 border-blue-500/30", purple: "bg-purple-500/10 text-purple-400 border-purple-500/30", red: "bg-red-500/10 text-red-400 border-red-500/30", zinc: "bg-zinc-500/10 text-zinc-400 border-zinc-500/30", }; return ( {children} ); }