Files
twotalesanimation 6b15bae62a
Deploy / deploy (push) Successful in 2m50s
Bulk Thumbnails upload
2026-08-06 12:59:28 +02:00

933 lines
37 KiB
TypeScript

"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,
} 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";
import type { ThumbnailPreviewItem } from "@/app/api/batch-upload/thumbnails/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<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);
};
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);
};
// ── 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);
reset();
}}
>
<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>
);
})()}
</>
)}
</div>
);
}
// ── 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>
);
}