"use client"; import { useState, useEffect } from "react"; import { motion, AnimatePresence } from "framer-motion"; import * as faceapi from "face-api.js"; import { Card, CardBody, Button, Input, Image, addToast, Spinner, Divider, Progress, Chip, } from "@heroui/react"; import { getFirestore, collection, addDoc, serverTimestamp, vector, } from "firebase/firestore"; import { getAuth, onAuthStateChanged, User } from "firebase/auth"; import firebaseApp from "@/config/firebase"; import { fontCursive, fontSans } from "@/config/fonts"; const db = getFirestore(firebaseApp()); const auth = getAuth(firebaseApp()); // --- CLOUDINARY CONFIGURATION --- const CLOUDINARY_CLOUD_NAME = process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME; const CLOUDINARY_UPLOAD_PRESET = process.env.NEXT_PUBLIC_CLOUDINARY_UPLOAD_PRESET; const CLOUDINARY_URL = `https://api.cloudinary.com/v1_1/${CLOUDINARY_CLOUD_NAME}/image/upload`; // --------------------------------- interface UploadJob { file: File; status: "pending" | "processing" | "success" | "error"; error?: string; progress: number; } export default function BulkUploadPage() { const [user, setUser] = useState(null); const [authLoading, setAuthLoading] = useState(true); const [uploaderName, setUploaderName] = useState(""); const [jobs, setJobs] = useState([]); const [isProcessing, setIsProcessing] = useState(false); const [modelsLoaded, setModelsLoaded] = useState(false); const [captcha, setCaptcha] = useState(""); const ADMIN_EMAIL = process.env.NEXT_PUBLIC_ADMIN_EMAIL; useEffect(() => { const unsubscribeAuth = onAuthStateChanged(auth, (currentUser) => { setUser(currentUser); setAuthLoading(false); }); const loadModels = async () => { const MODEL_URL = "/models"; try { await Promise.all([ faceapi.nets.ssdMobilenetv1.loadFromUri(MODEL_URL), faceapi.nets.faceLandmark68Net.loadFromUri(MODEL_URL), faceapi.nets.faceRecognitionNet.loadFromUri(MODEL_URL), ]); setModelsLoaded(true); console.log("FaceAPI models loaded for Bulk Upload"); } catch (error) { console.error("Error loading FaceAPI models:", error); addToast({ title: "Model Error", description: "Could not load AI models.", color: "danger" }); } }; loadModels(); return () => unsubscribeAuth(); }, []); const resizeImage = (file: File): Promise => { return new Promise((resolve) => { const reader = new FileReader(); reader.readAsDataURL(file); reader.onload = (event) => { const img = new window.Image(); img.src = event.target?.result as string; img.onload = () => { const canvas = document.createElement("canvas"); const MAX_WIDTH = 1200; const MAX_HEIGHT = 1200; let width = img.width; let height = img.height; if (width > height) { if (width > MAX_WIDTH) { height *= MAX_WIDTH / width; width = MAX_WIDTH; } } else { if (height > MAX_HEIGHT) { width *= MAX_HEIGHT / height; height = MAX_HEIGHT; } } canvas.width = width; canvas.height = height; const ctx = canvas.getContext("2d"); ctx?.drawImage(img, 0, 0, width, height); canvas.toBlob((blob) => { if (blob) resolve(blob); }, "image/jpeg", 0.7); }; }; }); }; const detectFaces = async (file: File) => { return new Promise((resolve, reject) => { const imageUrl = URL.createObjectURL(file); const img = document.createElement("img"); img.src = imageUrl; img.crossOrigin = "anonymous"; img.onload = async () => { try { const detections = await faceapi .detectAllFaces(img) .withFaceLandmarks() .withFaceDescriptors(); const serializedDetections = detections.map((d) => ({ detection: { score: d.detection.score, box: { x: d.detection.box.x, y: d.detection.box.y, width: d.detection.box.width, height: d.detection.box.height, }, }, landmarks: d.landmarks.positions.map((p) => ({ x: p.x, y: p.y })), descriptor: Array.from(d.descriptor), })); resolve(serializedDetections); } catch (error) { reject(error); } finally { URL.revokeObjectURL(imageUrl); } }; img.onerror = () => reject("Image load error"); }); }; const processJob = async (index: number) => { const job = jobs[index]; setJobs(prev => prev.map((j, i) => i === index ? { ...j, status: "processing", progress: 10 } : j)); try { // 1. Resize const resizedBlob = await resizeImage(job.file); setJobs(prev => prev.map((j, i) => i === index ? { ...j, progress: 30 } : j)); // 2. Detect Faces const faceData = await detectFaces(job.file); setJobs(prev => prev.map((j, i) => i === index ? { ...j, progress: 50 } : j)); // 3. Upload to Cloudinary const formData = new FormData(); formData.append("file", resizedBlob, "upload.jpg"); formData.append("upload_preset", CLOUDINARY_UPLOAD_PRESET!); const res = await fetch(CLOUDINARY_URL, { method: "POST", body: formData }); const cloudFile = await res.json(); if (!cloudFile.secure_url) throw new Error("Cloudinary upload failed"); setJobs(prev => prev.map((j, i) => i === index ? { ...j, progress: 80 } : j)); // 4. Save to Firestore await addDoc(collection(db, "guestbook"), { name: uploaderName, imageUrl: cloudFile.secure_url, createdAt: serverTimestamp(), }); if (faceData.length > 0) { const facePromises = faceData.map((face) => { return addDoc(collection(db, "facedata"), { photoId: cloudFile.secure_url, imageUrl: cloudFile.secure_url, name: uploaderName, createdAt: serverTimestamp(), embedding: vector(face.descriptor), descriptor: face.descriptor, metadata: { detectionScore: face.detection.score }, }); }); await Promise.all(facePromises); } setJobs(prev => prev.map((j, i) => i === index ? { ...j, status: "success", progress: 100 } : j)); } catch (error: any) { console.error(`Error processing ${job.file.name}:`, error); setJobs(prev => prev.map((j, i) => i === index ? { ...j, status: "error", error: error.message } : j)); } }; const handleStartBulkUpload = async (e: React.FormEvent) => { e.preventDefault(); if (!uploaderName || jobs.length === 0 || isProcessing) return; if (captcha.trim() !== "10") { addToast({ title: "Security Check", description: "Answer correctly!", color: "warning" }); return; } setIsProcessing(true); for (let i = 0; i < jobs.length; i++) { if (jobs[i].status !== "success") { await processJob(i); } } setIsProcessing(false); addToast({ title: "Bulk Upload Complete", color: "success" }); }; const handleFileChange = (e: React.ChangeEvent) => { if (e.target.files) { const newFiles = Array.from(e.target.files); const newJobs: UploadJob[] = newFiles.map(file => ({ file, status: "pending", progress: 0 })); setJobs(prev => [...prev, ...newJobs]); } }; if (authLoading) return
; if (!user || user.email !== ADMIN_EMAIL) { return (

Access Restricted

This tool is for administrators only.

); } const stats = { total: jobs.length, success: jobs.filter(j => j.status === "success").length, error: jobs.filter(j => j.status === "error").length, processing: jobs.filter(j => j.status === "processing").length, pending: jobs.filter(j => j.status === "pending").length, }; return (

Bulk Upload

Add multiple memories to the guestbook at once.

setUploaderName(e.target.value)} />

Bot Protection

setCaptcha(e.target.value)} />
{jobs.length > 0 && (

Total Progress

{Math.round(((stats.success + stats.error) / stats.total) * 100)} %

Queue Status

{stats.success} Done {stats.error} Failed {stats.processing + stats.pending} Remaining
{jobs.map((job, i) => (
preview

{job.file.name}

{job.status === "processing" && ( {job.progress < 30 ? "Resizing..." : job.progress < 50 ? "Scanning..." : "Uploading..."} )}
{job.progress}%
{job.status === "pending" && Pending} {job.status === "processing" && } {job.status === "success" && Success} {job.status === "error" && Failed}
))}
)}
); }