From 6b15bae62ad350db6e0ec5b7a45d50b6e7a526cd Mon Sep 17 00:00:00 2001 From: twotalesanimation <80506065+twotalesanimation@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:59:28 +0200 Subject: [PATCH] Bulk Thumbnails upload --- app/api/batch-upload/thumbnails/route.ts | 73 +++++ .../batch-upload/thumbnails/upload/route.ts | 41 +++ components/batch-upload/BatchUploadClient.tsx | 265 +++++++++++++++++- 3 files changed, 378 insertions(+), 1 deletion(-) create mode 100644 app/api/batch-upload/thumbnails/route.ts create mode 100644 app/api/batch-upload/thumbnails/upload/route.ts diff --git a/app/api/batch-upload/thumbnails/route.ts b/app/api/batch-upload/thumbnails/route.ts new file mode 100644 index 0000000..c359ce3 --- /dev/null +++ b/app/api/batch-upload/thumbnails/route.ts @@ -0,0 +1,73 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/auth"; +import { db } from "@/lib/db"; + +const IMAGE_EXTS = new Set(["jpg", "jpeg", "png", "webp", "tiff", "tif", "avif"]); + +export interface ThumbnailPreviewItem { + fileName: string; + stemName: string; + shotCode: string | null; + shotId: string | null; + status: "match" | "no-match"; + currentThumbnailUrl: string | null; +} + +function stem(fileName: string): string { + const dot = fileName.lastIndexOf("."); + return (dot > 0 ? fileName.slice(0, dot) : fileName).toLowerCase(); +} + +/** + * POST /api/batch-upload/thumbnails + * Body: { projectId: string; fileNames: string[] } + */ +export async function POST(req: NextRequest) { + const session = await auth(); + if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + let body: { projectId?: string; fileNames?: string[] }; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const { projectId, fileNames } = body; + if (!projectId || !Array.isArray(fileNames)) { + return NextResponse.json({ error: "projectId and fileNames are required" }, { status: 400 }); + } + + // Only image files + const imageFiles = fileNames.filter((f) => { + const ext = f.split(".").pop()?.toLowerCase() ?? ""; + return IMAGE_EXTS.has(ext); + }); + + const shots = await db.shot.findMany({ + where: { projectId }, + select: { id: true, shotCode: true, thumbnailUrl: true }, + }); + + // Build a lowercase map for case-insensitive matching + const shotMap = new Map(shots.map((s) => [s.shotCode.toLowerCase(), s])); + + const items: ThumbnailPreviewItem[] = fileNames.map((fileName) => { + const ext = fileName.split(".").pop()?.toLowerCase() ?? ""; + if (!IMAGE_EXTS.has(ext)) { + return { fileName, stemName: stem(fileName), shotCode: null, shotId: null, status: "no-match" as const, currentThumbnailUrl: null }; + } + const s = stem(fileName); + const shot = shotMap.get(s) ?? null; + return { + fileName, + stemName: s, + shotCode: shot?.shotCode ?? null, + shotId: shot?.id ?? null, + status: shot ? "match" : "no-match", + currentThumbnailUrl: shot?.thumbnailUrl ?? null, + }; + }); + + return NextResponse.json({ items }); +} diff --git a/app/api/batch-upload/thumbnails/upload/route.ts b/app/api/batch-upload/thumbnails/upload/route.ts new file mode 100644 index 0000000..7ed7c68 --- /dev/null +++ b/app/api/batch-upload/thumbnails/upload/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { uploadToHetzner } from "@/lib/storage"; +import { revalidatePath } from "next/cache"; + +/** + * POST /api/batch-upload/thumbnails/upload + * FormData: { projectId, shotId, file (image) } + */ +export async function POST(req: NextRequest) { + const session = await auth(); + if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const formData = await req.formData(); + const file = formData.get("file") as File | null; + const shotId = formData.get("shotId") as string | null; + const projectId = formData.get("projectId") as string | null; + + if (!file || !shotId || !projectId) { + return NextResponse.json({ error: "file, shotId and projectId are required" }, { status: 400 }); + } + if (!file.type.startsWith("image/")) { + return NextResponse.json({ error: "File must be an image" }, { status: 400 }); + } + + const shot = await db.shot.findFirst({ where: { id: shotId, projectId }, select: { id: true } }); + if (!shot) return NextResponse.json({ error: "Shot not found" }, { status: 404 }); + + const buffer = Buffer.from(await file.arrayBuffer()); + const { key } = await uploadToHetzner(buffer, file.name, file.type, "image"); + const thumbnailUrl = `/api/files/${key}`; + + await db.shot.update({ where: { id: shotId }, data: { thumbnailUrl } }); + revalidatePath(`/projects/${projectId}`); + + return NextResponse.json({ success: true, thumbnailUrl }); +} diff --git a/components/batch-upload/BatchUploadClient.tsx b/components/batch-upload/BatchUploadClient.tsx index 8799278..a73304e 100644 --- a/components/batch-upload/BatchUploadClient.tsx +++ b/components/batch-upload/BatchUploadClient.tsx @@ -20,10 +20,12 @@ import { 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; @@ -54,7 +56,25 @@ export function BatchUploadClient({ projects }: BatchUploadClientProps) { const [uploadComplete, setUploadComplete] = useState(false); const fileInputRef = useRef(null); - // ── Helpers ──────────────────────────────────────────────────────────────── + // ── Thumbnail upload state ───────────────────────────────────────────────── + const thumbInputRef = useRef(null); + const [thumbFiles, setThumbFiles] = useState([]); + const [thumbIsDragging, setThumbIsDragging] = useState(false); + const [thumbPreview, setThumbPreview] = useState(null); + const [thumbLoadingPreview, setThumbLoadingPreview] = useState(false); + const [thumbUploadStates, setThumbUploadStates] = useState>({}); + const [thumbIsUploading, setThumbIsUploading] = useState(false); + const [thumbUploadComplete, setThumbUploadComplete] = useState(false); + + const THUMB_EXTS = new Set(["jpg", "jpeg", "png", "webp", "tiff", "tif", "avif"]); + const acceptThumb = (f: File) => THUMB_EXTS.has(f.name.split(".").pop()?.toLowerCase() ?? ""); + + const resetThumbs = () => { + setThumbFiles([]); + setThumbPreview(null); + setThumbUploadStates({}); + setThumbUploadComplete(false); + }; const reset = () => { setFiles([]); @@ -242,6 +262,68 @@ export function BatchUploadClient({ projects }: BatchUploadClientProps) { setUploadComplete(true); }; + // ── Thumbnail handlers ───────────────────────────────────────────────────── + + const handleThumbDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + setThumbIsDragging(false); + const dropped = Array.from(e.dataTransfer.files).filter(acceptThumb); + if (dropped.length > 0) { setThumbFiles(dropped); setThumbPreview(null); setThumbUploadStates({}); setThumbUploadComplete(false); } + else toast({ title: "No image files", description: "Only JPG, PNG, WebP, TIFF images are accepted." }); + }, [toast]); // eslint-disable-line react-hooks/exhaustive-deps + + const handleThumbInput = (e: React.ChangeEvent) => { + const selected = Array.from(e.target.files ?? []).filter(acceptThumb); + if (selected.length > 0) { setThumbFiles(selected); setThumbPreview(null); setThumbUploadStates({}); setThumbUploadComplete(false); } + e.target.value = ""; + }; + + const fetchThumbPreview = async () => { + if (!projectId || thumbFiles.length === 0) return; + setThumbLoadingPreview(true); + try { + const res = await fetch("/api/batch-upload/thumbnails", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ projectId, fileNames: thumbFiles.map((f) => f.name) }), + }); + if (!res.ok) throw new Error("Preview failed"); + const data = await res.json(); + setThumbPreview(data.items); + const states: Record = {}; + for (const item of data.items as ThumbnailPreviewItem[]) states[item.fileName] = { status: "pending" }; + setThumbUploadStates(states); + } catch { + toast({ title: "Preview failed", variant: "destructive" }); + } finally { + setThumbLoadingPreview(false); + } + }; + + const startThumbUpload = async () => { + if (!thumbPreview || !projectId) return; + setThumbIsUploading(true); + const matched = thumbPreview.filter((i) => i.status === "match"); + for (const item of matched) { + const file = thumbFiles.find((f) => f.name === item.fileName); + if (!file) continue; + setThumbUploadStates((prev) => ({ ...prev, [item.fileName]: { status: "uploading" } })); + try { + const fd = new FormData(); + fd.append("file", file); + fd.append("shotId", item.shotId!); + fd.append("projectId", projectId); + const res = await fetch("/api/batch-upload/thumbnails/upload", { method: "POST", body: fd }); + if (!res.ok) { const d = await res.json().catch(() => ({ error: "Upload failed" })); throw new Error(d.error ?? "Upload failed"); } + setThumbUploadStates((prev) => ({ ...prev, [item.fileName]: { status: "success" } })); + } catch (err) { + setThumbUploadStates((prev) => ({ ...prev, [item.fileName]: { status: "error", error: err instanceof Error ? err.message : "Upload failed" } })); + } + } + setThumbIsUploading(false); + setThumbUploadComplete(true); + }; + // ── Derived counts ───────────────────────────────────────────────────────── const uploadable = preview?.filter( @@ -474,6 +556,187 @@ export function BatchUploadClient({ projects }: BatchUploadClientProps) { )} + + {/* ── Thumbnail bulk upload ─────────────────────────────────────────── */} + {projectId && ( + <> +
+

Bulk Thumbnail Upload

+

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

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

Drop thumbnail images here

+

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

+ {thumbFiles.length > 0 && ( +

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

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

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

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

Thumbnails uploaded

+

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

+
+ +
+
+ ); + })()} + + )} ); }