Files
vfxreview/components/batch-upload/BatchUploadClient.tsx
twotalesanimation 2dee1bf6fc
Deploy / deploy (push) Successful in 6m39s
feat(batch-upload): bulk footage plate upload matched by filename
Adds a third section to the Batch Upload page that attaches plate movies to
shots derived from the filename: DH2_VFX_058_120_REF.mov becomes the plate
"REF" on shot DH2_VFX_058_120. Shot matching reuses findMatchingShotCode, so
it behaves like the rest of the page (case-insensitive, longest code wins,
underscore-boundary only) and the filename remainder becomes the label.

Bytes go straight to Hetzner via the existing presign endpoint since plates
are multi-GB, with XHR for per-file progress. Files whose shot already has a
plate of the same name are skipped so re-running a batch does not stack
duplicates.

Also resets the thumbnail and plate previews when the project changes — they
are resolved against a specific project, but only the version-upload flow was
being cleared.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 12:15:29 +02:00

1343 lines
57 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<string>("");
const [files, setFiles] = useState<File[]>([]);
const [isDragging, setIsDragging] = useState(false);
const [isLoadingPreview, setIsLoadingPreview] = useState(false);
const [preview, setPreview] = useState<PreviewItem[] | null>(null);
const [uploadStates, setUploadStates] = useState<Record<string, UploadState>>({});
const [isUploading, setIsUploading] = useState(false);
const [uploadComplete, setUploadComplete] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// ── Thumbnail upload state ─────────────────────────────────────────────────
const thumbInputRef = useRef<HTMLInputElement>(null);
const [thumbFiles, setThumbFiles] = useState<File[]>([]);
const [thumbIsDragging, setThumbIsDragging] = useState(false);
const [thumbPreview, setThumbPreview] = useState<ThumbnailPreviewItem[] | null>(null);
const [thumbLoadingPreview, setThumbLoadingPreview] = useState(false);
const [thumbUploadStates, setThumbUploadStates] = useState<Record<string, UploadState>>({});
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<HTMLInputElement>(null);
const [plateFiles, setPlateFiles] = useState<File[]>([]);
const [plateIsDragging, setPlateIsDragging] = useState(false);
const [platePreview, setPlatePreview] = useState<PlatePreviewItem[] | null>(null);
const [plateLoadingPreview, setPlateLoadingPreview] = useState(false);
const [plateUploadStates, setPlateUploadStates] = useState<Record<string, PlateUploadState>>({});
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<HTMLInputElement>) => {
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<string, UploadState> = {};
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 <bucket> \
// --cors-configuration '{"CORSRules":[{"AllowedOrigins":["*"],
// "AllowedMethods":["PUT"],"AllowedHeaders":["*"]}]}' \
// --endpoint-url <hetzner_endpoint>
// 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<HTMLInputElement>) => {
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<string, UploadState> = {};
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<HTMLInputElement>) => {
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<string, PlateUploadState> = {};
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 (
<div className="max-w-5xl mx-auto px-6 py-8 space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-semibold text-white">Batch Upload</h1>
<p className="text-zinc-400 text-sm mt-1">
Drop <span className="text-zinc-300 font-mono">.mp4</span> files to upload new versions to tasks, and{" "}
<span className="text-zinc-300 font-mono">.mov</span> files to replace a shot's high-res deliverable.
Files are matched to shots and tasks by filename.
</p>
</div>
{/* Project selector */}
<Card>
<CardContent className="pt-5 pb-5">
<label className="text-sm font-medium text-zinc-300 block mb-2">Project</label>
<Select
value={projectId}
onValueChange={(v) => {
setProjectId(v);
// Every preview is resolved against a specific project, so none
// of them survive a project switch.
reset();
resetThumbs();
resetPlates();
}}
>
<SelectTrigger className="w-80 bg-zinc-800 border-zinc-700">
<SelectValue placeholder="Select a project…" />
</SelectTrigger>
<SelectContent>
{projects.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name}{" "}
<span className="text-zinc-500 font-mono text-xs ml-1">({p.code})</span>
</SelectItem>
))}
</SelectContent>
</Select>
</CardContent>
</Card>
{/* Drop zone */}
{projectId && !uploadComplete && (
<Card>
<CardContent className="pt-5">
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => 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"
)}
>
<Upload className="h-10 w-10 text-zinc-500 mx-auto mb-3" />
<p className="text-zinc-300 font-medium">
Drop <span className="font-mono">.mp4</span> /{" "}
<span className="font-mono">.mov</span> files here
</p>
<p className="text-zinc-500 text-sm mt-1">or click to browse</p>
{files.length > 0 && (
<p className="text-amber-400 text-sm mt-3 font-medium">
{files.length} file{files.length !== 1 ? "s" : ""} selected
</p>
)}
<input
ref={fileInputRef}
type="file"
multiple
accept=".mp4,.mov,video/mp4,video/quicktime"
className="hidden"
onChange={handleFileInput}
/>
</div>
{files.length > 0 && !preview && (
<div className="mt-4 flex items-center gap-3">
<Button onClick={fetchPreview} disabled={isLoadingPreview}>
{isLoadingPreview ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Loading preview…
</>
) : (
<>
Preview upload
<ChevronRight className="h-4 w-4 ml-2" />
</>
)}
</Button>
<Button variant="ghost" onClick={reset} disabled={isLoadingPreview}>
Clear
</Button>
</div>
)}
</CardContent>
</Card>
)}
{/* Preview table */}
{preview && !uploadComplete && (
<Card>
<CardHeader className="pb-0">
<CardTitle className="text-base font-medium text-zinc-200">
Upload preview —{" "}
<span className="text-zinc-400 font-normal">
{preview.length} file{preview.length !== 1 ? "s" : ""}
</span>
</CardTitle>
</CardHeader>
<CardContent className="p-0 mt-4">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800">
<th className="text-left text-xs text-zinc-500 font-normal px-6 py-2.5">
File
</th>
<th className="text-left text-xs text-zinc-500 font-normal px-6 py-2.5">
Shot
</th>
<th className="text-left text-xs text-zinc-500 font-normal px-6 py-2.5">
Action
</th>
<th className="text-left text-xs text-zinc-500 font-normal px-4 py-2.5 w-28">
Status
</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-800/60">
{preview.map((item) => (
<PreviewRow
key={item.fileName}
item={item}
uploadState={uploadStates[item.fileName]}
/>
))}
</tbody>
</table>
</div>
{/* Progress bar while uploading */}
{isUploading && (
<div className="px-6 py-3 border-t border-zinc-800">
<div className="flex justify-between text-xs text-zinc-400 mb-1.5">
<span>
Uploading {Math.min(doneCount + 1, uploadable.length)} of{" "}
{uploadable.length}…
</span>
<span>{Math.round(progress)}%</span>
</div>
<Progress value={progress} className="h-1.5" />
</div>
)}
{/* Footer actions */}
<div className="px-6 py-4 border-t border-zinc-800 flex items-center justify-between gap-4">
<p className="text-zinc-500 text-xs">
{uploadable.length} file{uploadable.length !== 1 ? "s" : ""} will be
uploaded
{skipped.length > 0 && (
<span className="text-zinc-600">
{" "}
· {skipped.length} skipped (not matched or unsupported)
</span>
)}
</p>
<div className="flex gap-3 shrink-0">
<Button
variant="ghost"
onClick={reset}
disabled={isUploading}
size="sm"
>
Change files
</Button>
<Button
onClick={startUpload}
disabled={isUploading || uploadable.length === 0}
size="sm"
>
{isUploading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Uploading…
</>
) : (
`Upload ${uploadable.length} file${uploadable.length !== 1 ? "s" : ""}`
)}
</Button>
</div>
</div>
</CardContent>
</Card>
)}
{/* Done summary */}
{uploadComplete && (
<Card>
<CardContent className="pt-8 pb-8 flex flex-col items-center gap-4 text-center">
<CheckCircle2 className="h-12 w-12 text-green-500" />
<div>
<p className="text-white font-semibold text-lg">Upload complete</p>
<p className="text-zinc-400 text-sm mt-1">
{successCount} succeeded
{errorCount > 0 && (
<span className="text-red-400"> · {errorCount} failed</span>
)}
</p>
</div>
<Button variant="outline" onClick={reset} className="mt-2">
<RotateCcw className="h-4 w-4 mr-2" />
Upload more files
</Button>
</CardContent>
</Card>
)}
{/* ── Thumbnail bulk upload ─────────────────────────────────────────── */}
{projectId && (
<>
<div className="border-t border-zinc-800 pt-2">
<h2 className="text-base font-semibold text-white">Bulk Thumbnail Upload</h2>
<p className="text-zinc-400 text-sm mt-1">
Drop images here to assign thumbnails to existing shots. Files are matched by filename (without extension) to shot codes.
</p>
</div>
{/* Thumb drop zone */}
{!thumbUploadComplete && (
<Card>
<CardContent className="pt-5">
<div
onDragOver={(e) => { 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"
)}
>
<ImageIcon className="h-9 w-9 text-zinc-500 mx-auto mb-3" />
<p className="text-zinc-300 font-medium">Drop thumbnail images here</p>
<p className="text-zinc-500 text-sm mt-1">JPG, PNG, WebP, TIFF · or click to browse</p>
{thumbFiles.length > 0 && (
<p className="text-blue-400 text-sm mt-3 font-medium">
{thumbFiles.length} image{thumbFiles.length !== 1 ? "s" : ""} selected
</p>
)}
<input
ref={thumbInputRef}
type="file"
multiple
accept="image/*"
className="hidden"
onChange={handleThumbInput}
/>
</div>
{thumbFiles.length > 0 && !thumbPreview && (
<div className="mt-4 flex items-center gap-3">
<Button onClick={fetchThumbPreview} disabled={thumbLoadingPreview}>
{thumbLoadingPreview ? (
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />Loading preview…</>
) : (
<>Preview<ChevronRight className="h-4 w-4 ml-2" /></>
)}
</Button>
<Button variant="ghost" onClick={resetThumbs} disabled={thumbLoadingPreview}>Clear</Button>
</div>
)}
</CardContent>
</Card>
)}
{/* 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 (
<Card>
<CardHeader className="pb-0">
<CardTitle className="text-base font-medium text-zinc-200">
Thumbnail preview —{" "}
<span className="text-zinc-400 font-normal">
{matched.length} matched · {unmatched.length} unmatched
</span>
</CardTitle>
</CardHeader>
<CardContent className="p-0 mt-4">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800">
<th className="text-left text-xs text-zinc-500 font-normal px-6 py-2.5">File</th>
<th className="text-left text-xs text-zinc-500 font-normal px-6 py-2.5">Matched Shot</th>
<th className="text-left text-xs text-zinc-500 font-normal px-4 py-2.5 w-28">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-800/60">
{thumbPreview.map((item) => {
const state = thumbUploadStates[item.fileName];
return (
<tr key={item.fileName} className="hover:bg-zinc-800/30 transition-colors">
<td className="px-6 py-3">
<div className="flex items-center gap-2">
<ImageIcon className="h-4 w-4 text-zinc-500 shrink-0" />
<span className="text-zinc-200 font-mono text-xs truncate max-w-[240px]">{item.fileName}</span>
</div>
</td>
<td className="px-6 py-3">
{item.shotCode ? (
<span className="font-mono text-xs text-zinc-300">{item.shotCode}</span>
) : (
<span className="text-xs text-zinc-600 italic">no match</span>
)}
</td>
<td className="px-4 py-3">
{item.status === "no-match" ? (
<span className="flex items-center gap-1 text-xs text-zinc-500"><XCircle className="h-3.5 w-3.5" />skip</span>
) : !state || state.status === "pending" ? (
<span className="flex items-center gap-1 text-xs text-blue-400"><ArrowRight className="h-3.5 w-3.5" />set thumbnail</span>
) : state.status === "uploading" ? (
<span className="flex items-center gap-1 text-xs text-amber-400"><Loader2 className="h-3.5 w-3.5 animate-spin" />uploading</span>
) : state.status === "success" ? (
<span className="flex items-center gap-1 text-xs text-emerald-400"><CheckCircle2 className="h-3.5 w-3.5" />done</span>
) : (
<span className="flex items-center gap-1 text-xs text-red-400" title={state.error}><XCircle className="h-3.5 w-3.5" />error</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{thumbIsUploading && (
<div className="px-6 py-3 border-t border-zinc-800">
<div className="flex justify-between text-xs text-zinc-400 mb-1.5">
<span>Uploading {Math.min(thumbDone + 1, matched.length)} of {matched.length}…</span>
<span>{Math.round(thumbProgress)}%</span>
</div>
<Progress value={thumbProgress} className="h-1.5" />
</div>
)}
<div className="px-6 py-4 border-t border-zinc-800 flex items-center justify-between gap-4">
<p className="text-zinc-500 text-xs">
{matched.length} thumbnail{matched.length !== 1 ? "s" : ""} will be assigned
{unmatched.length > 0 && <span className="text-zinc-600"> · {unmatched.length} skipped (no matching shot)</span>}
</p>
<div className="flex gap-3 shrink-0">
<Button variant="ghost" size="sm" onClick={resetThumbs} disabled={thumbIsUploading}>Change files</Button>
<Button size="sm" onClick={startThumbUpload} disabled={thumbIsUploading || matched.length === 0}>
{thumbIsUploading ? (
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />Uploading…</>
) : (
`Upload ${matched.length} thumbnail${matched.length !== 1 ? "s" : ""}`
)}
</Button>
</div>
</div>
</CardContent>
</Card>
);
})()}
{/* 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 (
<Card>
<CardContent className="pt-8 pb-8 flex flex-col items-center gap-4 text-center">
<CheckCircle2 className="h-12 w-12 text-green-500" />
<div>
<p className="text-white font-semibold text-lg">Thumbnails uploaded</p>
<p className="text-zinc-400 text-sm mt-1">
{thumbSuccess} assigned{thumbErrors > 0 && <span className="text-red-400"> · {thumbErrors} failed</span>}
</p>
</div>
<Button variant="outline" onClick={resetThumbs} className="mt-2">
<RotateCcw className="h-4 w-4 mr-2" />Upload more thumbnails
</Button>
</CardContent>
</Card>
);
})()}
{/* ── Footage plate bulk upload ───────────────────────────────── */}
<div className="border-t border-zinc-800 pt-2">
<h2 className="text-base font-semibold text-white">Bulk Footage Plate Upload</h2>
<p className="text-zinc-400 text-sm mt-1">
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 —{" "}
<span className="text-zinc-300 font-mono text-xs">DH2_VFX_058_120_REF.mov</span>{" "}
becomes the plate <span className="text-zinc-300">REF</span> on shot{" "}
<span className="text-zinc-300 font-mono text-xs">DH2_VFX_058_120</span>.
</p>
</div>
{/* Plate drop zone */}
{!plateUploadComplete && (
<Card>
<CardContent className="pt-5">
<div
onDragOver={(e) => { 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"
)}
>
<Film className="h-9 w-9 text-zinc-500 mx-auto mb-3" />
<p className="text-zinc-300 font-medium">Drop footage plates here</p>
<p className="text-zinc-500 text-sm mt-1">
MOV, MP4, MXF, AVI, MKV, WebM · or click to browse
</p>
{plateFiles.length > 0 && (
<p className="text-emerald-400 text-sm mt-3 font-medium">
{plateFiles.length} file{plateFiles.length !== 1 ? "s" : ""} selected
</p>
)}
<input
ref={plateInputRef}
type="file"
multiple
accept=".mov,.mp4,.m4v,.mxf,.avi,.mkv,.webm,video/*"
className="hidden"
onChange={handlePlateInput}
/>
</div>
{plateFiles.length > 0 && !platePreview && (
<div className="mt-4 flex items-center gap-3">
<Button onClick={fetchPlatePreview} disabled={plateLoadingPreview}>
{plateLoadingPreview ? (
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />Loading preview…</>
) : (
<>Preview<ChevronRight className="h-4 w-4 ml-2" /></>
)}
</Button>
<Button variant="ghost" onClick={resetPlates} disabled={plateLoadingPreview}>Clear</Button>
</div>
)}
</CardContent>
</Card>
)}
{/* 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 (
<Card>
<CardHeader className="pb-0">
<CardTitle className="text-base font-medium text-zinc-200">
Footage plate preview —{" "}
<span className="text-zinc-400 font-normal">
{matched.length} matched · {skippedPlates.length} skipped
</span>
</CardTitle>
</CardHeader>
<CardContent className="p-0 mt-4">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800">
<th className="text-left text-xs text-zinc-500 font-normal px-6 py-2.5">File</th>
<th className="text-left text-xs text-zinc-500 font-normal px-6 py-2.5">Matched Shot</th>
<th className="text-left text-xs text-zinc-500 font-normal px-6 py-2.5">Plate label</th>
<th className="text-left text-xs text-zinc-500 font-normal px-4 py-2.5 w-32">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-800/60">
{platePreview.map((item) => {
const state = plateUploadStates[item.fileName];
return (
<tr key={item.fileName} className="hover:bg-zinc-800/30 transition-colors">
<td className="px-6 py-3">
<div className="flex items-center gap-2">
<FileVideo className="h-4 w-4 text-zinc-500 shrink-0" />
<span className="text-zinc-200 font-mono text-xs truncate max-w-[240px]">{item.fileName}</span>
</div>
</td>
<td className="px-6 py-3">
{item.shotCode ? (
<span className="font-mono text-xs text-zinc-300">
{item.shotCode}
{item.existingPlateCount > 0 && (
<span className="text-zinc-600 ml-1.5 font-sans">
({item.existingPlateCount} plate{item.existingPlateCount !== 1 ? "s" : ""})
</span>
)}
</span>
) : (
<span className="text-xs text-zinc-600 italic">
{item.status === "unsupported" ? "unsupported file" : "no match"}
</span>
)}
</td>
<td className="px-6 py-3">
{item.status === "match" ? (
<span className="text-zinc-300 text-xs">{item.label}</span>
) : (
<span className="text-zinc-700 text-xs">—</span>
)}
</td>
<td className="px-4 py-3">
{item.status === "duplicate" ? (
<span className="flex items-center gap-1 text-xs text-zinc-500" title="This shot already has a plate with this filename">
<XCircle className="h-3.5 w-3.5" />already added
</span>
) : item.status !== "match" ? (
<span className="flex items-center gap-1 text-xs text-zinc-500"><XCircle className="h-3.5 w-3.5" />skip</span>
) : !state || state.status === "pending" ? (
<span className="flex items-center gap-1 text-xs text-emerald-400"><ArrowRight className="h-3.5 w-3.5" />add plate</span>
) : state.status === "uploading" ? (
<span className="flex items-center gap-1 text-xs text-amber-400">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{Math.round((state.progress ?? 0) * 100)}%
</span>
) : state.status === "success" ? (
<span className="flex items-center gap-1 text-xs text-emerald-400"><CheckCircle2 className="h-3.5 w-3.5" />done</span>
) : (
<span className="flex items-center gap-1 text-xs text-red-400" title={state.error}><XCircle className="h-3.5 w-3.5" />error</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{plateIsUploading && (
<div className="px-6 py-3 border-t border-zinc-800">
<div className="flex justify-between text-xs text-zinc-400 mb-1.5">
<span>Uploading {Math.min(plateDone + 1, matched.length)} of {matched.length}…</span>
<span>{Math.round(plateProgress)}%</span>
</div>
<Progress value={plateProgress} className="h-1.5" />
</div>
)}
<div className="px-6 py-4 border-t border-zinc-800 flex items-center justify-between gap-4">
<p className="text-zinc-500 text-xs">
{matched.length} plate{matched.length !== 1 ? "s" : ""} will be added
{skippedPlates.length > 0 && (
<span className="text-zinc-600">
{" "}· {skippedPlates.length} skipped
{dupes.length > 0 && ` (${dupes.length} already uploaded)`}
</span>
)}
</p>
<div className="flex gap-3 shrink-0">
<Button variant="ghost" size="sm" onClick={resetPlates} disabled={plateIsUploading}>Change files</Button>
<Button size="sm" onClick={startPlateUpload} disabled={plateIsUploading || matched.length === 0}>
{plateIsUploading ? (
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />Uploading…</>
) : (
`Upload ${matched.length} plate${matched.length !== 1 ? "s" : ""}`
)}
</Button>
</div>
</div>
</CardContent>
</Card>
);
})()}
{/* 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 (
<Card>
<CardContent className="pt-8 pb-8 flex flex-col items-center gap-4 text-center">
<CheckCircle2 className="h-12 w-12 text-green-500" />
<div>
<p className="text-white font-semibold text-lg">Footage plates uploaded</p>
<p className="text-zinc-400 text-sm mt-1">
{plateSuccess} added{plateErrors > 0 && <span className="text-red-400"> · {plateErrors} failed</span>}
</p>
</div>
<Button variant="outline" onClick={resetPlates} className="mt-2">
<RotateCcw className="h-4 w-4 mr-2" />Upload more plates
</Button>
</CardContent>
</Card>
);
})()}
</>
)}
</div>
);
}
// ── 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<void> {
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 (
<tr className="hover:bg-zinc-800/30 transition-colors">
{/* File */}
<td className="px-6 py-3">
<div className="flex items-center gap-2">
<FileVideo className="h-4 w-4 text-zinc-500 shrink-0" />
<span className="text-zinc-200 font-mono text-xs truncate max-w-[260px]">
{item.fileName}
</span>
<span
className={cn(
"inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-mono shrink-0",
ext === "mp4"
? "border-blue-500/40 text-blue-400"
: "border-purple-500/40 text-purple-400"
)}
>
.{ext}
</span>
</div>
</td>
{/* Shot */}
<td className="px-6 py-3">
{item.shotCode ? (
<span className="text-zinc-300 font-mono text-xs">{item.shotCode}</span>
) : (
<span className="text-red-400 text-xs">Not found</span>
)}
</td>
{/* Action */}
<td className="px-6 py-3">
<ActionCell item={item} />
</td>
{/* Upload status */}
<td className="px-4 py-3">
<StatusCell status={item.status} uploadState={uploadState} />
</td>
</tr>
);
}
function ActionCell({ item }: { item: PreviewItem }) {
switch (item.status) {
case "new-version":
return (
<div className="space-y-1">
<ActionBadge color="green">New version</ActionBadge>
<p className="text-zinc-400 text-[11px] font-mono">{item.currentTaskTitle}</p>
</div>
);
case "rename-and-upload":
return (
<div className="space-y-1">
<ActionBadge color="amber">Rename task + upload</ActionBadge>
<div className="flex items-center gap-1 text-[11px] font-mono text-zinc-500 flex-wrap">
<span className="line-through text-zinc-600">{item.currentTaskTitle}</span>
<ArrowRight className="h-3 w-3 shrink-0" />
<span className="text-zinc-300">{item.newTaskTitle}</span>
</div>
</div>
);
case "create-task":
return (
<div className="space-y-1">
{item.fallbackTaskId ? (
<>
<ActionBadge color="blue">Rename Comp task + upload</ActionBadge>
<div className="flex items-center gap-1 text-[11px] font-mono text-zinc-500 flex-wrap">
<span className="line-through text-zinc-600">{item.fallbackTaskTitle}</span>
<ArrowRight className="h-3 w-3 shrink-0" />
<span className="text-zinc-300">{item.newTaskTitle}</span>
</div>
</>
) : (
<>
<ActionBadge color="blue">Create new task</ActionBadge>
<p className="text-zinc-300 text-[11px] font-mono">{item.newTaskTitle}</p>
</>
)}
</div>
);
case "update-highres":
return (
<div className="space-y-1">
<ActionBadge color="purple">Replace high-res</ActionBadge>
{item.currentHighResFilename && (
<p className="text-zinc-500 text-[11px]">
Replaces: <span className="font-mono">{item.currentHighResFilename}</span>
</p>
)}
</div>
);
case "no-shot":
return <ActionBadge color="red">Shot not found</ActionBadge>;
case "unsupported":
return <ActionBadge color="zinc">Unsupported file type</ActionBadge>;
default:
return null;
}
}
function StatusCell({
status,
uploadState,
}: {
status: PreviewItemStatus;
uploadState: UploadState | undefined;
}) {
if (status === "no-shot" || status === "unsupported") {
return <span className="text-zinc-600 text-xs">Skipped</span>;
}
if (!uploadState || uploadState.status === "pending") {
return <span className="text-zinc-500 text-xs">Pending</span>;
}
if (uploadState.status === "uploading") {
return (
<span className="flex items-center gap-1 text-amber-400 text-xs">
<Loader2 className="h-3 w-3 animate-spin" />
Uploading
</span>
);
}
if (uploadState.status === "success") {
return (
<span className="flex items-center gap-1 text-green-400 text-xs">
<CheckCircle2 className="h-3 w-3" />
Done
</span>
);
}
if (uploadState.status === "error") {
return (
<span
className="flex items-center gap-1 text-red-400 text-xs"
title={uploadState.error}
>
<XCircle className="h-3 w-3" />
Error
</span>
);
}
return null;
}
function ActionBadge({
children,
color,
}: {
children: React.ReactNode;
color: "green" | "amber" | "blue" | "purple" | "red" | "zinc";
}) {
const map: Record<string, string> = {
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 (
<span
className={cn(
"inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-medium",
map[color]
)}
>
{children}
</span>
);
}