@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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<HTMLInputElement>(null);
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
// ── 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([]);
|
||||
@@ -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<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(
|
||||
@@ -474,6 +556,187 @@ export function BatchUploadClient({ projects }: BatchUploadClientProps) {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user