@@ -0,0 +1,23 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { BatchUploadClient } from "@/components/batch-upload/BatchUploadClient";
|
||||||
|
|
||||||
|
export const metadata = { title: "Batch Upload — VFX Review" };
|
||||||
|
|
||||||
|
export default async function BatchUploadPage() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) redirect("/login");
|
||||||
|
|
||||||
|
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
|
||||||
|
redirect("/dashboard");
|
||||||
|
}
|
||||||
|
|
||||||
|
const projects = await db.project.findMany({
|
||||||
|
where: { status: { in: ["ACTIVE", "ON_HOLD"] } },
|
||||||
|
select: { id: true, name: true, code: true },
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return <BatchUploadClient projects={projects} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import {
|
||||||
|
parseFilename,
|
||||||
|
stripVersionFromTitle,
|
||||||
|
findMatchingShotCode,
|
||||||
|
} from "@/lib/batch-upload-parser";
|
||||||
|
|
||||||
|
export type PreviewItemStatus =
|
||||||
|
| "new-version" // task found, same version prefix → just add a version
|
||||||
|
| "rename-and-upload" // task found but different version → rename task + add version
|
||||||
|
| "create-task" // no matching task → use/rename fallback "Comp" task or create new
|
||||||
|
| "update-highres" // .mov file → replace the shot's high-res file
|
||||||
|
| "no-shot" // shot code not found in this project
|
||||||
|
| "unsupported"; // file extension is not .mp4 or .mov
|
||||||
|
|
||||||
|
export interface PreviewItem {
|
||||||
|
fileName: string;
|
||||||
|
status: PreviewItemStatus;
|
||||||
|
shotId: string | null;
|
||||||
|
shotCode: string | null;
|
||||||
|
/** For .mp4: ID of the directly matched task, if found */
|
||||||
|
taskId: string | null;
|
||||||
|
/** For .mp4: current title of the matched / fallback task */
|
||||||
|
currentTaskTitle: string | null;
|
||||||
|
/** For .mp4: desired task title after upload (filename without extension) */
|
||||||
|
newTaskTitle: string;
|
||||||
|
/** For .mov: filename of the existing high-res file that will be replaced */
|
||||||
|
currentHighResFilename: string | null;
|
||||||
|
/** For create-task: ID of a "Comp" task to rename & use */
|
||||||
|
fallbackTaskId: string | null;
|
||||||
|
fallbackTaskTitle: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/batch-upload/preview
|
||||||
|
* Body: { projectId: string; fileNames: string[] }
|
||||||
|
*
|
||||||
|
* Returns a preview of what action will be taken for each file, without
|
||||||
|
* actually uploading anything.
|
||||||
|
*/
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await req.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { projectId, fileNames } = body as {
|
||||||
|
projectId?: string;
|
||||||
|
fileNames?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!projectId || !Array.isArray(fileNames) || fileNames.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "projectId and fileNames[] are required" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load all shots for this project with their tasks
|
||||||
|
const shots = await db.shot.findMany({
|
||||||
|
where: { projectId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
shotCode: true,
|
||||||
|
highResFilename: true,
|
||||||
|
tasks: {
|
||||||
|
select: { id: true, title: true, type: true },
|
||||||
|
orderBy: { sortOrder: "asc" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const shotCodes = shots.map((s) => s.shotCode);
|
||||||
|
|
||||||
|
const items: PreviewItem[] = fileNames.map((fileName: string) => {
|
||||||
|
const parsed = parseFilename(fileName);
|
||||||
|
|
||||||
|
if (parsed.type === "other") {
|
||||||
|
return {
|
||||||
|
fileName,
|
||||||
|
status: "unsupported" as const,
|
||||||
|
shotId: null,
|
||||||
|
shotCode: null,
|
||||||
|
taskId: null,
|
||||||
|
currentTaskTitle: null,
|
||||||
|
newTaskTitle: "",
|
||||||
|
currentHighResFilename: null,
|
||||||
|
fallbackTaskId: null,
|
||||||
|
fallbackTaskTitle: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the shot whose code is a prefix of this file's task name base
|
||||||
|
const matchedCode = findMatchingShotCode(parsed.taskNameBase, shotCodes);
|
||||||
|
const matchedShot = matchedCode
|
||||||
|
? shots.find((s) => s.shotCode === matchedCode) ?? null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!matchedShot) {
|
||||||
|
return {
|
||||||
|
fileName,
|
||||||
|
status: "no-shot" as const,
|
||||||
|
shotId: null,
|
||||||
|
shotCode: null,
|
||||||
|
taskId: null,
|
||||||
|
currentTaskTitle: null,
|
||||||
|
newTaskTitle: "",
|
||||||
|
currentHighResFilename: null,
|
||||||
|
fallbackTaskId: null,
|
||||||
|
fallbackTaskTitle: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── .mov → update-highres ─────────────────────────────────────────────
|
||||||
|
if (parsed.type === "mov") {
|
||||||
|
return {
|
||||||
|
fileName,
|
||||||
|
status: "update-highres" as const,
|
||||||
|
shotId: matchedShot.id,
|
||||||
|
shotCode: matchedShot.shotCode,
|
||||||
|
taskId: null,
|
||||||
|
currentTaskTitle: null,
|
||||||
|
newTaskTitle: "",
|
||||||
|
currentHighResFilename: matchedShot.highResFilename ?? null,
|
||||||
|
fallbackTaskId: null,
|
||||||
|
fallbackTaskTitle: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── .mp4 → find matching task ─────────────────────────────────────────
|
||||||
|
const desiredTitle = parsed.taskNameWithVersion; // full name without ext
|
||||||
|
|
||||||
|
// Match by stripping version from existing task titles
|
||||||
|
const matchedTask = matchedShot.tasks.find(
|
||||||
|
(t) => stripVersionFromTitle(t.title) === parsed.taskNameBase
|
||||||
|
);
|
||||||
|
|
||||||
|
if (matchedTask) {
|
||||||
|
const sameTitle = matchedTask.title === desiredTitle;
|
||||||
|
return {
|
||||||
|
fileName,
|
||||||
|
status: (sameTitle ? "new-version" : "rename-and-upload") as PreviewItemStatus,
|
||||||
|
shotId: matchedShot.id,
|
||||||
|
shotCode: matchedShot.shotCode,
|
||||||
|
taskId: matchedTask.id,
|
||||||
|
currentTaskTitle: matchedTask.title,
|
||||||
|
newTaskTitle: desiredTitle,
|
||||||
|
currentHighResFilename: null,
|
||||||
|
fallbackTaskId: null,
|
||||||
|
fallbackTaskTitle: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// No direct match – look for a generic "Comp" or "comp" task, or any COMP type
|
||||||
|
const compTask = matchedShot.tasks.find(
|
||||||
|
(t) =>
|
||||||
|
t.title.toLowerCase() === "comp" ||
|
||||||
|
t.type === "COMP"
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
fileName,
|
||||||
|
status: "create-task" as const,
|
||||||
|
shotId: matchedShot.id,
|
||||||
|
shotCode: matchedShot.shotCode,
|
||||||
|
taskId: null,
|
||||||
|
currentTaskTitle: null,
|
||||||
|
newTaskTitle: desiredTitle,
|
||||||
|
currentHighResFilename: null,
|
||||||
|
fallbackTaskId: compTask?.id ?? null,
|
||||||
|
fallbackTaskTitle: compTask?.title ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ items });
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { uploadFile, uploadToHetzner, deleteFromHetzner } from "@/lib/storage";
|
||||||
|
import { recalcShotStatus } from "@/lib/shot-status";
|
||||||
|
|
||||||
|
export const maxDuration = 120;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/batch-upload/upload
|
||||||
|
*
|
||||||
|
* Handles a single file upload from the batch upload flow.
|
||||||
|
*
|
||||||
|
* FormData fields:
|
||||||
|
* file – the binary file
|
||||||
|
* action – "new-version" | "rename-and-upload" | "create-task" | "update-highres"
|
||||||
|
* shotId – shot DB id (required for all actions)
|
||||||
|
* projectId – project DB id
|
||||||
|
* taskId – required for "new-version" and "rename-and-upload"
|
||||||
|
* fallbackTaskId – optional: the "Comp" task id to rename for "create-task"
|
||||||
|
* newTaskTitle – desired task title (filename without extension)
|
||||||
|
*/
|
||||||
|
export async function POST(
|
||||||
|
req: NextRequest
|
||||||
|
): Promise<NextResponse> {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await req.formData();
|
||||||
|
const file = formData.get("file") as File | null;
|
||||||
|
const action = formData.get("action") as string | null;
|
||||||
|
const shotId = formData.get("shotId") as string | null;
|
||||||
|
const projectId = formData.get("projectId") as string | null;
|
||||||
|
const taskId = formData.get("taskId") as string | null;
|
||||||
|
const fallbackTaskId = formData.get("fallbackTaskId") as string | null;
|
||||||
|
const newTaskTitle = (formData.get("newTaskTitle") as string | null) ?? "";
|
||||||
|
|
||||||
|
if (!file || !action || !shotId || !projectId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Missing required fields: file, action, shotId, projectId" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
|
||||||
|
// ── High-res (.mov) ───────────────────────────────────────────────────────
|
||||||
|
if (action === "update-highres") {
|
||||||
|
const shot = await db.shot.findUnique({
|
||||||
|
where: { id: shotId },
|
||||||
|
select: { id: true, highResKey: true },
|
||||||
|
});
|
||||||
|
if (!shot) {
|
||||||
|
return NextResponse.json({ error: "Shot not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the previous high-res file if one exists
|
||||||
|
if (shot.highResKey) {
|
||||||
|
await deleteFromHetzner(shot.highResKey).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { key } = await uploadToHetzner(buffer, file.name, file.type, "highres");
|
||||||
|
|
||||||
|
await db.shot.update({
|
||||||
|
where: { id: shotId },
|
||||||
|
data: { highResKey: key, highResFilename: file.name },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
action: "update-highres",
|
||||||
|
fileName: file.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Version upload (.mp4) ─────────────────────────────────────────────────
|
||||||
|
let resolvedTaskId: string;
|
||||||
|
|
||||||
|
if (action === "new-version") {
|
||||||
|
if (!taskId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "taskId is required for new-version action" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
resolvedTaskId = taskId;
|
||||||
|
} else if (action === "rename-and-upload") {
|
||||||
|
if (!taskId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "taskId is required for rename-and-upload action" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Rename the task to reflect the new version number
|
||||||
|
await db.task.update({
|
||||||
|
where: { id: taskId },
|
||||||
|
data: { title: newTaskTitle },
|
||||||
|
});
|
||||||
|
resolvedTaskId = taskId;
|
||||||
|
} else if (action === "create-task") {
|
||||||
|
if (fallbackTaskId) {
|
||||||
|
// Rename the existing "Comp" (or similar) task and reuse it
|
||||||
|
await db.task.update({
|
||||||
|
where: { id: fallbackTaskId },
|
||||||
|
data: { title: newTaskTitle },
|
||||||
|
});
|
||||||
|
resolvedTaskId = fallbackTaskId;
|
||||||
|
} else {
|
||||||
|
// Create a brand-new task under the shot
|
||||||
|
const lastTask = await db.task.findFirst({
|
||||||
|
where: { shotId },
|
||||||
|
orderBy: { sortOrder: "desc" },
|
||||||
|
select: { sortOrder: true },
|
||||||
|
});
|
||||||
|
const task = await db.task.create({
|
||||||
|
data: {
|
||||||
|
title: newTaskTitle,
|
||||||
|
type: "COMP",
|
||||||
|
shotId,
|
||||||
|
projectId,
|
||||||
|
createdById: session.user.id,
|
||||||
|
sortOrder: (lastTask?.sortOrder ?? -1) + 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
resolvedTaskId = task.id;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Unknown action: ${action}` },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload the video to the configured storage backend
|
||||||
|
const result = await uploadFile(buffer, file.name, file.type, "videos");
|
||||||
|
|
||||||
|
// Mark all existing versions for this task as no longer latest
|
||||||
|
await db.version.updateMany({
|
||||||
|
where: { taskId: resolvedTaskId },
|
||||||
|
data: { isLatest: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Determine next sequential version number
|
||||||
|
const lastVersion = await db.version.findFirst({
|
||||||
|
where: { taskId: resolvedTaskId },
|
||||||
|
orderBy: { versionNumber: "desc" },
|
||||||
|
select: { versionNumber: true },
|
||||||
|
});
|
||||||
|
const versionNumber = (lastVersion?.versionNumber ?? 0) + 1;
|
||||||
|
|
||||||
|
// Create the version record
|
||||||
|
const version = await db.version.create({
|
||||||
|
data: {
|
||||||
|
versionNumber,
|
||||||
|
taskId: resolvedTaskId,
|
||||||
|
artistId: session.user.id,
|
||||||
|
fileUrl: result.url,
|
||||||
|
fileName: file.name,
|
||||||
|
fileSize: BigInt(file.size),
|
||||||
|
mimeType: file.type,
|
||||||
|
isLatest: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Move the task to INTERNAL_REVIEW
|
||||||
|
await db.task.update({
|
||||||
|
where: { id: resolvedTaskId },
|
||||||
|
data: { status: "INTERNAL_REVIEW" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Recalculate the parent shot's status
|
||||||
|
await recalcShotStatus(shotId).catch(() => {});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
action,
|
||||||
|
versionId: version.id,
|
||||||
|
versionNumber,
|
||||||
|
taskId: resolvedTaskId,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
CalendarRange,
|
CalendarRange,
|
||||||
BarChart2,
|
BarChart2,
|
||||||
ListVideo,
|
ListVideo,
|
||||||
|
CloudUpload,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
@@ -30,6 +31,7 @@ const navItems = [
|
|||||||
{ href: '/playlist', label: 'Playlist', icon: ListVideo, hideForClient: true },
|
{ href: '/playlist', label: 'Playlist', icon: ListVideo, hideForClient: true },
|
||||||
{ href: '/tasks', label: 'My Tasks', icon: ListTodo, hideForClient: true },
|
{ href: '/tasks', label: 'My Tasks', icon: ListTodo, hideForClient: true },
|
||||||
{ href: '/schedule', label: 'Schedule', icon: CalendarRange, adminOnly: true },
|
{ href: '/schedule', label: 'Schedule', icon: CalendarRange, adminOnly: true },
|
||||||
|
{ href: '/batch-upload', label: 'Batch Upload', icon: CloudUpload, adminOnly: true },
|
||||||
{ href: '/clients', label: 'Clients', icon: Users, adminOnly: true },
|
{ href: '/clients', label: 'Clients', icon: Users, adminOnly: true },
|
||||||
{ href: '/users', label: 'Users', icon: UserCog, adminOnly: true, adminStrictOnly: true },
|
{ href: '/users', label: 'Users', icon: UserCog, adminOnly: true, adminStrictOnly: true },
|
||||||
{ href: '/settings', label: 'Settings', icon: Settings },
|
{ href: '/settings', label: 'Settings', icon: Settings },
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* Utilities for parsing VFX filenames into shot / task metadata.
|
||||||
|
*
|
||||||
|
* Expected naming convention (any separator count is fine):
|
||||||
|
* {SHOW}_{EP}_{SCENE}_{CUT}_{TASK}_{ARTIST}_v{NNN}.{ext}
|
||||||
|
* e.g. UNG_106_035_010_cmp_TT_v002.mp4
|
||||||
|
*
|
||||||
|
* The shot code is matched against the database – the parser itself
|
||||||
|
* does NOT hard-code how many underscore segments belong to the shot.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ParsedFilename {
|
||||||
|
originalName: string;
|
||||||
|
/** Filename without the extension */
|
||||||
|
nameWithoutExt: string;
|
||||||
|
/** Lowercased extension without the leading dot (e.g. "mp4", "mov") */
|
||||||
|
ext: string;
|
||||||
|
/**
|
||||||
|
* Task name base – the full name without the version suffix.
|
||||||
|
* e.g. "UNG_106_035_010_cmp_TT"
|
||||||
|
*/
|
||||||
|
taskNameBase: string;
|
||||||
|
/**
|
||||||
|
* Full task title including the version suffix (no extension).
|
||||||
|
* This is what the task should be named after upload.
|
||||||
|
* e.g. "UNG_106_035_010_cmp_TT_v002"
|
||||||
|
*/
|
||||||
|
taskNameWithVersion: string;
|
||||||
|
/**
|
||||||
|
* Version string extracted from the filename, e.g. "v002".
|
||||||
|
* Empty string if no version pattern was found.
|
||||||
|
*/
|
||||||
|
version: string;
|
||||||
|
type: "mp4" | "mov" | "other";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a VFX filename into its constituent parts.
|
||||||
|
*/
|
||||||
|
export function parseFilename(filename: string): ParsedFilename {
|
||||||
|
const lastDot = filename.lastIndexOf(".");
|
||||||
|
const ext = lastDot >= 0 ? filename.slice(lastDot + 1).toLowerCase() : "";
|
||||||
|
const nameWithoutExt = lastDot >= 0 ? filename.slice(0, lastDot) : filename;
|
||||||
|
|
||||||
|
// Version pattern: _v followed by one or more digits at the end (case insensitive)
|
||||||
|
const versionMatch = nameWithoutExt.match(/_v(\d+)$/i);
|
||||||
|
const version = versionMatch ? `v${versionMatch[1]}` : "";
|
||||||
|
const taskNameBase = versionMatch
|
||||||
|
? nameWithoutExt.slice(0, nameWithoutExt.length - versionMatch[0].length)
|
||||||
|
: nameWithoutExt;
|
||||||
|
|
||||||
|
const type: "mp4" | "mov" | "other" =
|
||||||
|
ext === "mp4" ? "mp4" : ext === "mov" ? "mov" : "other";
|
||||||
|
|
||||||
|
return {
|
||||||
|
originalName: filename,
|
||||||
|
nameWithoutExt,
|
||||||
|
ext,
|
||||||
|
taskNameBase,
|
||||||
|
taskNameWithVersion: nameWithoutExt,
|
||||||
|
version,
|
||||||
|
type,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip a trailing version suffix (_v###) from a task title.
|
||||||
|
* e.g. "UNG_106_035_010_cmp_TT_v001" → "UNG_106_035_010_cmp_TT"
|
||||||
|
*/
|
||||||
|
export function stripVersionFromTitle(title: string): string {
|
||||||
|
return title.replace(/_v\d+$/i, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a taskNameBase and a list of shot codes, return the shot code that
|
||||||
|
* is a prefix of the task name base (shot code + "_" separator).
|
||||||
|
*
|
||||||
|
* e.g. taskNameBase "UNG_106_035_010_cmp_TT" matches shot "UNG_106_035_010"
|
||||||
|
*/
|
||||||
|
export function findMatchingShotCode(
|
||||||
|
taskNameBase: string,
|
||||||
|
shotCodes: string[]
|
||||||
|
): string | null {
|
||||||
|
// Sort by length descending so longer (more specific) codes are matched first
|
||||||
|
const sorted = [...shotCodes].sort((a, b) => b.length - a.length);
|
||||||
|
for (const code of sorted) {
|
||||||
|
if (
|
||||||
|
taskNameBase === code ||
|
||||||
|
taskNameBase.toLowerCase().startsWith(code.toLowerCase() + "_")
|
||||||
|
) {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user