"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, ImageIcon, Film, } from "lucide-react"; import { cn } from "@/lib/utils"; import { useToast } from "@/components/ui/use-toast"; import { PLATE_EXTS, plateContentType } from "@/lib/batch-upload-parser"; import type { PreviewItem, PreviewItemStatus } from "@/app/api/batch-upload/preview/route"; import type { ThumbnailPreviewItem } from "@/app/api/batch-upload/thumbnails/route"; import type { PlatePreviewItem } from "@/app/api/batch-upload/plates/route"; interface Project { id: string; name: string; code: string; } type UploadStatus = "pending" | "uploading" | "success" | "error"; interface UploadState { status: UploadStatus; error?: string; } /** Footage plates are large, so they also carry per-file upload progress. */ interface PlateUploadState extends UploadState { /** 0–1, only meaningful while status is "uploading" */ progress?: number; } 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); // ── Thumbnail upload state ───────────────────────────────────────────────── const thumbInputRef = useRef(null); const [thumbFiles, setThumbFiles] = useState([]); const [thumbIsDragging, setThumbIsDragging] = useState(false); const [thumbPreview, setThumbPreview] = useState(null); const [thumbLoadingPreview, setThumbLoadingPreview] = useState(false); const [thumbUploadStates, setThumbUploadStates] = useState>({}); const [thumbIsUploading, setThumbIsUploading] = useState(false); const [thumbUploadComplete, setThumbUploadComplete] = useState(false); const THUMB_EXTS = new Set(["jpg", "jpeg", "png", "webp", "tiff", "tif", "avif"]); const acceptThumb = (f: File) => THUMB_EXTS.has(f.name.split(".").pop()?.toLowerCase() ?? ""); const resetThumbs = () => { setThumbFiles([]); setThumbPreview(null); setThumbUploadStates({}); setThumbUploadComplete(false); }; // ── Footage plate upload state ───────────────────────────────────────────── const plateInputRef = useRef(null); const [plateFiles, setPlateFiles] = useState([]); const [plateIsDragging, setPlateIsDragging] = useState(false); const [platePreview, setPlatePreview] = useState(null); const [plateLoadingPreview, setPlateLoadingPreview] = useState(false); const [plateUploadStates, setPlateUploadStates] = useState>({}); const [plateIsUploading, setPlateIsUploading] = useState(false); const [plateUploadComplete, setPlateUploadComplete] = useState(false); const acceptPlate = (f: File) => PLATE_EXTS.has(f.name.split(".").pop()?.toLowerCase() ?? ""); const resetPlates = () => { setPlateFiles([]); setPlatePreview(null); setPlateUploadStates({}); setPlateUploadComplete(false); }; 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); }; // ── Thumbnail handlers ───────────────────────────────────────────────────── const handleThumbDrop = useCallback((e: React.DragEvent) => { e.preventDefault(); setThumbIsDragging(false); const dropped = Array.from(e.dataTransfer.files).filter(acceptThumb); if (dropped.length > 0) { setThumbFiles(dropped); setThumbPreview(null); setThumbUploadStates({}); setThumbUploadComplete(false); } else toast({ title: "No image files", description: "Only JPG, PNG, WebP, TIFF images are accepted." }); }, [toast]); // eslint-disable-line react-hooks/exhaustive-deps const handleThumbInput = (e: React.ChangeEvent) => { const selected = Array.from(e.target.files ?? []).filter(acceptThumb); if (selected.length > 0) { setThumbFiles(selected); setThumbPreview(null); setThumbUploadStates({}); setThumbUploadComplete(false); } e.target.value = ""; }; const fetchThumbPreview = async () => { if (!projectId || thumbFiles.length === 0) return; setThumbLoadingPreview(true); try { const res = await fetch("/api/batch-upload/thumbnails", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ projectId, fileNames: thumbFiles.map((f) => f.name) }), }); if (!res.ok) throw new Error("Preview failed"); const data = await res.json(); setThumbPreview(data.items); const states: Record = {}; for (const item of data.items as ThumbnailPreviewItem[]) states[item.fileName] = { status: "pending" }; setThumbUploadStates(states); } catch { toast({ title: "Preview failed", variant: "destructive" }); } finally { setThumbLoadingPreview(false); } }; const startThumbUpload = async () => { if (!thumbPreview || !projectId) return; setThumbIsUploading(true); const matched = thumbPreview.filter((i) => i.status === "match"); for (const item of matched) { const file = thumbFiles.find((f) => f.name === item.fileName); if (!file) continue; setThumbUploadStates((prev) => ({ ...prev, [item.fileName]: { status: "uploading" } })); try { const fd = new FormData(); fd.append("file", file); fd.append("shotId", item.shotId!); fd.append("projectId", projectId); const res = await fetch("/api/batch-upload/thumbnails/upload", { method: "POST", body: fd }); if (!res.ok) { const d = await res.json().catch(() => ({ error: "Upload failed" })); throw new Error(d.error ?? "Upload failed"); } setThumbUploadStates((prev) => ({ ...prev, [item.fileName]: { status: "success" } })); } catch (err) { setThumbUploadStates((prev) => ({ ...prev, [item.fileName]: { status: "error", error: err instanceof Error ? err.message : "Upload failed" } })); } } setThumbIsUploading(false); setThumbUploadComplete(true); }; // ── Footage plate handlers ───────────────────────────────────────────────── const handlePlateDrop = useCallback((e: React.DragEvent) => { e.preventDefault(); setPlateIsDragging(false); const dropped = Array.from(e.dataTransfer.files).filter(acceptPlate); if (dropped.length > 0) { setPlateFiles(dropped); setPlatePreview(null); setPlateUploadStates({}); setPlateUploadComplete(false); } else { toast({ title: "No supported files", description: "Only .mov, .mp4, .mxf, .avi, .mkv and .webm files are accepted.", }); } }, [toast]); // eslint-disable-line react-hooks/exhaustive-deps const handlePlateInput = (e: React.ChangeEvent) => { const selected = Array.from(e.target.files ?? []).filter(acceptPlate); if (selected.length > 0) { setPlateFiles(selected); setPlatePreview(null); setPlateUploadStates({}); setPlateUploadComplete(false); } e.target.value = ""; }; const fetchPlatePreview = async () => { if (!projectId || plateFiles.length === 0) return; setPlateLoadingPreview(true); try { const res = await fetch("/api/batch-upload/plates", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ projectId, fileNames: plateFiles.map((f) => f.name) }), }); if (!res.ok) throw new Error("Preview failed"); const data = await res.json(); setPlatePreview(data.items); const states: Record = {}; for (const item of data.items as PlatePreviewItem[]) states[item.fileName] = { status: "pending" }; setPlateUploadStates(states); } catch { toast({ title: "Preview failed", variant: "destructive" }); } finally { setPlateLoadingPreview(false); } }; const startPlateUpload = async () => { if (!platePreview || !projectId) return; setPlateIsUploading(true); const matched = platePreview.filter((i) => i.status === "match"); for (const item of matched) { const file = plateFiles.find((f) => f.name === item.fileName); if (!file) continue; setPlateUploadStates((prev) => ({ ...prev, [item.fileName]: { status: "uploading", progress: 0 }, })); try { // Step 1 — presigned PUT URL. Plates are multi-GB, so the bytes go // straight to Hetzner and never touch this server or the proxy. const presignRes = await fetch("/api/upload/presign", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ fileName: file.name, contentType: plateContentType(file.name), folder: "videos", }), }); 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 directly to storage, tracking progress via XHR. await putWithProgress(presignedUrl, file, (fraction) => { setPlateUploadStates((prev) => ({ ...prev, [item.fileName]: { status: "uploading", progress: fraction }, })); }); // Step 3 — commit the plate record against the matched shot. const commitRes = await fetch("/api/batch-upload/plates/upload", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ projectId, shotId: item.shotId, key, fileName: file.name, fileSize: file.size, label: item.label, }), }); if (!commitRes.ok) { const d = await commitRes.json().catch(() => ({ error: "Failed to save plate" })); throw new Error((d as { error?: string }).error ?? "Failed to save plate"); } setPlateUploadStates((prev) => ({ ...prev, [item.fileName]: { status: "success" } })); } catch (err) { setPlateUploadStates((prev) => ({ ...prev, [item.fileName]: { status: "error", error: err instanceof Error ? err.message : "Upload failed", }, })); } } setPlateIsUploading(false); setPlateUploadComplete(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 )}

)} {/* ── Thumbnail bulk upload ─────────────────────────────────────────── */} {projectId && ( <>

Bulk Thumbnail Upload

Drop images here to assign thumbnails to existing shots. Files are matched by filename (without extension) to shot codes.

{/* Thumb drop zone */} {!thumbUploadComplete && (
{ e.preventDefault(); setThumbIsDragging(true); }} onDragLeave={() => setThumbIsDragging(false)} onDrop={handleThumbDrop} onClick={() => thumbInputRef.current?.click()} className={cn( "border-2 border-dashed rounded-xl p-10 text-center cursor-pointer transition-colors select-none", thumbIsDragging ? "border-blue-400 bg-blue-400/5" : "border-zinc-700 hover:border-zinc-500 hover:bg-zinc-800/30" )} >

Drop thumbnail images here

JPG, PNG, WebP, TIFF · or click to browse

{thumbFiles.length > 0 && (

{thumbFiles.length} image{thumbFiles.length !== 1 ? "s" : ""} selected

)}
{thumbFiles.length > 0 && !thumbPreview && (
)}
)} {/* Thumb preview table */} {thumbPreview && !thumbUploadComplete && (() => { const matched = thumbPreview.filter((i) => i.status === "match"); const unmatched = thumbPreview.filter((i) => i.status === "no-match"); const thumbSuccess = Object.values(thumbUploadStates).filter((s) => s.status === "success").length; const thumbErrors = Object.values(thumbUploadStates).filter((s) => s.status === "error").length; const thumbDone = thumbSuccess + thumbErrors; const thumbProgress = matched.length > 0 ? (thumbDone / matched.length) * 100 : 0; return ( Thumbnail preview —{" "} {matched.length} matched · {unmatched.length} unmatched
{thumbPreview.map((item) => { const state = thumbUploadStates[item.fileName]; return ( ); })}
File Matched Shot Status
{item.fileName}
{item.shotCode ? ( {item.shotCode} ) : ( no match )} {item.status === "no-match" ? ( skip ) : !state || state.status === "pending" ? ( set thumbnail ) : state.status === "uploading" ? ( uploading ) : state.status === "success" ? ( done ) : ( error )}
{thumbIsUploading && (
Uploading {Math.min(thumbDone + 1, matched.length)} of {matched.length}… {Math.round(thumbProgress)}%
)}

{matched.length} thumbnail{matched.length !== 1 ? "s" : ""} will be assigned {unmatched.length > 0 && · {unmatched.length} skipped (no matching shot)}

); })()} {/* Thumb done summary */} {thumbUploadComplete && (() => { const thumbSuccess = Object.values(thumbUploadStates).filter((s) => s.status === "success").length; const thumbErrors = Object.values(thumbUploadStates).filter((s) => s.status === "error").length; return (

Thumbnails uploaded

{thumbSuccess} assigned{thumbErrors > 0 && · {thumbErrors} failed}

); })()} {/* ── Footage plate bulk upload ───────────────────────────────── */}

Bulk Footage Plate Upload

Drop plate movies here to add them to a shot's Footage tab. The shot is taken from the start of the filename and the remainder becomes the plate label —{" "} DH2_VFX_058_120_REF.mov{" "} becomes the plate REF on shot{" "} DH2_VFX_058_120.

{/* Plate drop zone */} {!plateUploadComplete && (
{ e.preventDefault(); setPlateIsDragging(true); }} onDragLeave={() => setPlateIsDragging(false)} onDrop={handlePlateDrop} onClick={() => plateInputRef.current?.click()} className={cn( "border-2 border-dashed rounded-xl p-10 text-center cursor-pointer transition-colors select-none", plateIsDragging ? "border-emerald-400 bg-emerald-400/5" : "border-zinc-700 hover:border-zinc-500 hover:bg-zinc-800/30" )} >

Drop footage plates here

MOV, MP4, MXF, AVI, MKV, WebM · or click to browse

{plateFiles.length > 0 && (

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

)}
{plateFiles.length > 0 && !platePreview && (
)}
)} {/* Plate preview table */} {platePreview && !plateUploadComplete && (() => { const matched = platePreview.filter((i) => i.status === "match"); const skippedPlates = platePreview.filter((i) => i.status !== "match"); const dupes = platePreview.filter((i) => i.status === "duplicate"); const plateSuccess = Object.values(plateUploadStates).filter((s) => s.status === "success").length; const plateErrors = Object.values(plateUploadStates).filter((s) => s.status === "error").length; const plateDone = plateSuccess + plateErrors; const plateProgress = matched.length > 0 ? (plateDone / matched.length) * 100 : 0; return ( Footage plate preview —{" "} {matched.length} matched · {skippedPlates.length} skipped
{platePreview.map((item) => { const state = plateUploadStates[item.fileName]; return ( ); })}
File Matched Shot Plate label Status
{item.fileName}
{item.shotCode ? ( {item.shotCode} {item.existingPlateCount > 0 && ( ({item.existingPlateCount} plate{item.existingPlateCount !== 1 ? "s" : ""}) )} ) : ( {item.status === "unsupported" ? "unsupported file" : "no match"} )} {item.status === "match" ? ( {item.label} ) : ( — )} {item.status === "duplicate" ? ( already added ) : item.status !== "match" ? ( skip ) : !state || state.status === "pending" ? ( add plate ) : state.status === "uploading" ? ( {Math.round((state.progress ?? 0) * 100)}% ) : state.status === "success" ? ( done ) : ( error )}
{plateIsUploading && (
Uploading {Math.min(plateDone + 1, matched.length)} of {matched.length}… {Math.round(plateProgress)}%
)}

{matched.length} plate{matched.length !== 1 ? "s" : ""} will be added {skippedPlates.length > 0 && ( {" "}· {skippedPlates.length} skipped {dupes.length > 0 && ` (${dupes.length} already uploaded)`} )}

); })()} {/* Plate done summary */} {plateUploadComplete && (() => { const plateSuccess = Object.values(plateUploadStates).filter((s) => s.status === "success").length; const plateErrors = Object.values(plateUploadStates).filter((s) => s.status === "error").length; return (

Footage plates uploaded

{plateSuccess} added{plateErrors > 0 && · {plateErrors} failed}

); })()} )}
); } // ── Helpers ──────────────────────────────────────────────────────────────────── /** * PUT a file to a presigned storage URL, reporting upload progress. * * `fetch` gives no upload progress, and footage plates are big enough that a * silent multi-minute wait per file is unusable — hence XHR. */ function putWithProgress( url: string, file: File, onProgress: (fraction: number) => void ): Promise { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open("PUT", url); xhr.setRequestHeader("Content-Type", plateContentType(file.name)); xhr.upload.addEventListener("progress", (e) => { if (e.lengthComputable) onProgress(e.loaded / e.total); }); xhr.addEventListener("load", () => { if (xhr.status >= 200 && xhr.status < 300) resolve(); else if (xhr.status === 403) reject(new Error("Storage upload failed — check Hetzner CORS / bucket policy")); else reject(new Error(`Storage upload failed (HTTP ${xhr.status})`)); }); xhr.addEventListener("error", () => reject(new Error("Network error"))); xhr.addEventListener("abort", () => reject(new Error("Upload aborted"))); xhr.send(file); }); } // ── 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} ); }