@@ -0,0 +1,617 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Upload,
|
||||
FileVideo,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Loader2,
|
||||
ChevronRight,
|
||||
ArrowRight,
|
||||
RotateCcw,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import type { PreviewItem, PreviewItemStatus } from "@/app/api/batch-upload/preview/route";
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
type UploadStatus = "pending" | "uploading" | "success" | "error";
|
||||
|
||||
interface UploadState {
|
||||
status: UploadStatus;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface BatchUploadClientProps {
|
||||
projects: Project[];
|
||||
}
|
||||
|
||||
export function BatchUploadClient({ projects }: BatchUploadClientProps) {
|
||||
const { toast } = useToast();
|
||||
const [projectId, setProjectId] = useState<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);
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
const reset = () => {
|
||||
setFiles([]);
|
||||
setPreview(null);
|
||||
setUploadStates({});
|
||||
setUploadComplete(false);
|
||||
};
|
||||
|
||||
const acceptFile = (f: File) =>
|
||||
f.name.toLowerCase().endsWith(".mp4") || f.name.toLowerCase().endsWith(".mov");
|
||||
|
||||
// ── Drag & Drop ────────────────────────────────────────────────────────────
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback(() => {
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
const dropped = Array.from(e.dataTransfer.files).filter(acceptFile);
|
||||
if (dropped.length > 0) {
|
||||
setFiles(dropped);
|
||||
setPreview(null);
|
||||
setUploadStates({});
|
||||
setUploadComplete(false);
|
||||
} else {
|
||||
toast({ title: "No supported files", description: "Only .mp4 and .mov files are accepted." });
|
||||
}
|
||||
}, [toast]);
|
||||
|
||||
const handleFileInput = (e: React.ChangeEvent<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 {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
fd.append("action", item.status);
|
||||
fd.append("shotId", item.shotId!);
|
||||
fd.append("projectId", projectId);
|
||||
if (item.taskId) fd.append("taskId", item.taskId);
|
||||
if (item.fallbackTaskId) fd.append("fallbackTaskId", item.fallbackTaskId);
|
||||
if (item.newTaskTitle) fd.append("newTaskTitle", item.newTaskTitle);
|
||||
|
||||
const res = await fetch("/api/batch-upload/upload", {
|
||||
method: "POST",
|
||||
body: fd,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({ error: "Upload failed" }));
|
||||
throw new Error(data.error ?? "Upload failed");
|
||||
}
|
||||
|
||||
setUploadStates((prev) => ({
|
||||
...prev,
|
||||
[item.fileName]: { status: "success" },
|
||||
}));
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "Upload failed";
|
||||
setUploadStates((prev) => ({
|
||||
...prev,
|
||||
[item.fileName]: { status: "error", error: message },
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
setIsUploading(false);
|
||||
setUploadComplete(true);
|
||||
};
|
||||
|
||||
// ── Derived counts ─────────────────────────────────────────────────────────
|
||||
|
||||
const uploadable = preview?.filter(
|
||||
(i) => i.status !== "no-shot" && i.status !== "unsupported"
|
||||
) ?? [];
|
||||
const skipped = preview?.filter(
|
||||
(i) => i.status === "no-shot" || i.status === "unsupported"
|
||||
) ?? [];
|
||||
const successCount = Object.values(uploadStates).filter((s) => s.status === "success").length;
|
||||
const errorCount = Object.values(uploadStates).filter((s) => s.status === "error").length;
|
||||
const doneCount = successCount + errorCount;
|
||||
const progress = uploadable.length > 0 ? (doneCount / uploadable.length) * 100 : 0;
|
||||
|
||||
// ── Render ─────────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<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>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user