From cb60ce45874e6a027a68d8e5c0d08ee7a5ee0533 Mon Sep 17 00:00:00 2001 From: T4RuN05 Date: Sat, 30 May 2026 19:25:20 +0530 Subject: [PATCH] Enhance product video upload logic, stabilize navigation, and revamp gallery modal --- src/app/admin/products/create/page.jsx | 224 ++++++++++++++- .../edit/[slug]/EditProductClient.jsx | 264 ++++++++++++++++-- src/app/components/products/ProductCard.jsx | 64 ++++- .../components/products/ProductDetails.jsx | 194 ++++++++----- src/lib/api.js | 4 +- 5 files changed, 653 insertions(+), 97 deletions(-) diff --git a/src/app/admin/products/create/page.jsx b/src/app/admin/products/create/page.jsx index 3ead615..1782ae8 100644 --- a/src/app/admin/products/create/page.jsx +++ b/src/app/admin/products/create/page.jsx @@ -2,7 +2,7 @@ import { useState, useEffect, useRef } from "react"; import { useRouter } from "next/navigation"; -import { FiUpload, FiX } from "react-icons/fi"; +import { FiUpload, FiX, FiVideo } from "react-icons/fi"; import { FaChevronUp, FaChevronDown } from "react-icons/fa"; import { useAuth } from "@/context/AuthContext"; import MinimalEditor from "@/app/components/admin/MinimalEditor"; @@ -99,7 +99,51 @@ export default function CreateProductPage() { faq: "", }); const [uploading, setUploading] = useState(false); + const [video, setVideo] = useState(null); + const [uploadingVideo, setUploadingVideo] = useState(false); + const [videoProgress, setVideoProgress] = useState(0); const thumbnailsRef = useRef(null); + const videoXhrRef = useRef(null); + + // ================= PREVENT REFRESH ================= + useEffect(() => { + const handleBeforeUnload = (e) => { + if (uploading || uploadingVideo) { + e.preventDefault(); + e.returnValue = "Are you sure you want to leave? Upload is in progress."; + return e.returnValue; + } + }; + + const handleGlobalClick = (e) => { + if (uploading || uploadingVideo) { + const target = e.target.closest("a"); + if (target && target.href && !target.href.startsWith("javascript:") && target.target !== "_blank") { + if (!window.confirm("An upload is in progress. Are you sure you want to leave?")) { + e.preventDefault(); + e.stopPropagation(); + } + } + } + }; + + window.addEventListener("beforeunload", handleBeforeUnload); + document.addEventListener("click", handleGlobalClick, { capture: true }); + + return () => { + window.removeEventListener("beforeunload", handleBeforeUnload); + document.removeEventListener("click", handleGlobalClick, { capture: true }); + }; + }, [uploading, uploadingVideo]); + + // ================= ABORT ON UNMOUNT ================= + useEffect(() => { + return () => { + if (videoXhrRef.current) { + videoXhrRef.current.abort(); + } + }; + }, []); const scrollThumbs = (direction) => { if (!thumbnailsRef.current) return; @@ -220,6 +264,116 @@ export default function CreateProductPage() { })); }; + /* ========================= + VIDEO UPLOAD HANDLING + ========================== */ + + const handleVideoUpload = async (e) => { + const file = e.target.files?.[0]; + if (!file) return; + + if (!file.type.startsWith("video/")) { + toast.error("Please select a video file"); + return; + } + + if (file.size > 50 * 1024 * 1024) { + toast.error("Video must be under 50MB"); + return; + } + + setUploadingVideo(true); + setVideoProgress(0); + const toastId = toast.loading("Preparing upload...", { duration: Infinity }); + + try { + // Step 1: Get signed credentials from backend + const sigRes = await fetch( + `${process.env.NEXT_PUBLIC_API_URL}/api/products/video-signature`, + { credentials: "include" } + ); + + if (!sigRes.ok) { + toast.error("Failed to authorize upload", { id: toastId }); + setUploadingVideo(false); + return; + } + + const { signature, timestamp, api_key, cloud_name, folder } = await sigRes.json(); + + // Step 2: Upload directly to Cloudinary + const formData = new FormData(); + formData.append("file", file); + formData.append("api_key", api_key); + formData.append("timestamp", timestamp); + formData.append("signature", signature); + formData.append("folder", folder); + formData.append("resource_type", "video"); + + toast.loading("Uploading video... 0%", { id: toastId, duration: Infinity }); + + const xhr = new XMLHttpRequest(); + videoXhrRef.current = xhr; + xhr.open("POST", `https://api.cloudinary.com/v1_1/${cloud_name}/video/upload`); + + xhr.upload.onprogress = (event) => { + if (event.lengthComputable) { + const pct = Math.round((event.loaded / event.total) * 100); + setVideoProgress(pct); + toast.loading(`Uploading video... ${pct}%`, { id: toastId, duration: Infinity }); + } + }; + + xhr.onload = () => { + videoXhrRef.current = null; + try { + const data = JSON.parse(xhr.responseText); + if (xhr.status >= 200 && xhr.status < 300) { + setVideo({ + url: data.secure_url, + public_id: data.public_id, + }); + toast.success("Video uploaded successfully", { id: toastId, duration: 4000 }); + } else { + toast.error(data.error?.message || "Video upload failed", { id: toastId, duration: 4000 }); + } + } catch { + toast.error("Video upload failed", { id: toastId, duration: 4000 }); + } finally { + setUploadingVideo(false); + setVideoProgress(0); + } + }; + + xhr.onerror = () => { + videoXhrRef.current = null; + toast.error("Video upload failed — check your connection", { id: toastId, duration: 4000 }); + setUploadingVideo(false); + setVideoProgress(0); + }; + + xhr.onabort = () => { + videoXhrRef.current = null; + toast.dismiss(toastId); + setUploadingVideo(false); + setVideoProgress(0); + }; + + xhr.send(formData); + } catch (err) { + toast.error("Video upload failed", { id: toastId, duration: 4000 }); + setUploadingVideo(false); + setVideoProgress(0); + } + + e.target.value = ""; + }; + + const handleRemoveVideo = () => { + setVideo(null); + toast.success("Video removed"); + }; + /* ========================= SUBMIT ========================== */ @@ -266,6 +420,7 @@ export default function CreateProductPage() { description: form.description, faq: form.faq, images: images, + video: video || { url: null, public_id: null }, }), }, ); @@ -376,6 +531,69 @@ ${uploading ? "opacity-60 pointer-events-none" : ""} )} + + {/* VIDEO UPLOAD SECTION */} +
+
+

+ + Product Video +

+ Max 50MB • All formats +
+ + {video?.url ? ( +
+
+ ) : ( +
{/* FORM FIELDS */} @@ -484,8 +702,8 @@ ${uploading ? "opacity-60 pointer-events-none" : ""}
diff --git a/src/app/admin/products/edit/[slug]/EditProductClient.jsx b/src/app/admin/products/edit/[slug]/EditProductClient.jsx index be5b82b..11bbe40 100644 --- a/src/app/admin/products/edit/[slug]/EditProductClient.jsx +++ b/src/app/admin/products/edit/[slug]/EditProductClient.jsx @@ -4,7 +4,7 @@ import { useEffect, useState, useRef } from "react"; import { useParams, useRouter } from "next/navigation"; import { useAuth } from "@/context/AuthContext"; import MinimalEditor from "@/app/components/admin/MinimalEditor"; -import { FiUpload, FiX } from "react-icons/fi"; +import { FiUpload, FiX, FiVideo } from "react-icons/fi"; import { FaChevronUp, FaChevronDown } from "react-icons/fa"; import EditProductSkeleton from "@/app/components/admin/EditProductSkeleton"; import toast from "react-hot-toast"; @@ -96,6 +96,11 @@ export default function EditProductPage() { const [replyMap, setReplyMap] = useState({}); const [sendingMailId, setSendingMailId] = useState(null); const [updating, setUpdating] = useState(false); + const [video, setVideo] = useState(null); + const [uploading, setUploading] = useState(false); + const [uploadingVideo, setUploadingVideo] = useState(false); + const [videoProgress, setVideoProgress] = useState(0); + const videoXhrRef = useRef(null); const [form, setForm] = useState({ title: "", @@ -110,6 +115,46 @@ export default function EditProductPage() { }); const sensors = useSensors(useSensor(PointerSensor)); + // ================= PREVENT REFRESH ================= + useEffect(() => { + const handleBeforeUnload = (e) => { + if (uploading || uploadingVideo || updating) { + e.preventDefault(); + e.returnValue = "Are you sure you want to leave? Upload is in progress."; + return e.returnValue; + } + }; + + const handleGlobalClick = (e) => { + if (uploading || uploadingVideo || updating) { + const target = e.target.closest("a"); + if (target && target.href && !target.href.startsWith("javascript:") && target.target !== "_blank") { + if (!window.confirm("An upload or update is in progress. Are you sure you want to leave?")) { + e.preventDefault(); + e.stopPropagation(); + } + } + } + }; + + window.addEventListener("beforeunload", handleBeforeUnload); + document.addEventListener("click", handleGlobalClick, { capture: true }); + + return () => { + window.removeEventListener("beforeunload", handleBeforeUnload); + document.removeEventListener("click", handleGlobalClick, { capture: true }); + }; + }, [uploading, uploadingVideo, updating]); + + // ================= ABORT ON UNMOUNT ================= + useEffect(() => { + return () => { + if (videoXhrRef.current) { + videoXhrRef.current.abort(); + } + }; + }, []); + // ================= FETCH PRODUCT ================= useEffect(() => { const fetchProduct = async () => { @@ -139,6 +184,7 @@ export default function EditProductPage() { setImages(data.images || []); setActiveImage(data.images?.[0]?.url || null); + setVideo(data.video?.url ? data.video : null); setTimeout(() => setLoading(false), 200); }; @@ -183,25 +229,32 @@ export default function EditProductPage() { formData.append("images", file); } - const res = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/api/products/upload`, - { - method: "POST", - credentials: "include", - body: formData, - }, - ); + try { + setUploading(true); + const res = await fetch( + `${process.env.NEXT_PUBLIC_API_URL}/api/products/upload`, + { + method: "POST", + credentials: "include", + body: formData, + }, + ); - const data = await res.json(); - if (!res.ok) { - router.push("/admin/products"); - return; - } + const data = await res.json(); + if (!res.ok) { + router.push("/admin/products"); + return; + } - setImages((prev) => [...prev, ...data]); + setImages((prev) => [...prev, ...data]); - if (!activeImage && data.length > 0) { - setActiveImage(data[0].url); + if (!activeImage && data.length > 0) { + setActiveImage(data[0].url); + } + } catch (err) { + toast.error("Image upload failed"); + } finally { + setUploading(false); } }; @@ -288,6 +341,114 @@ export default function EditProductPage() { setForm({ ...form, [activeTab]: value }); }; + // ================= VIDEO UPLOAD (Direct to Cloudinary) ================= + const handleVideoUpload = async (e) => { + const file = e.target.files?.[0]; + if (!file) return; + + // Client-side validation + if (!file.type.startsWith("video/")) { + toast.error("Please select a video file"); + return; + } + + if (file.size > 50 * 1024 * 1024) { + toast.error("Video must be under 50MB"); + return; + } + + setUploadingVideo(true); + setVideoProgress(0); + const toastId = toast.loading("Preparing upload...", { duration: Infinity }); + + try { + // Step 1: Get signed credentials from backend + const sigRes = await fetch( + `${process.env.NEXT_PUBLIC_API_URL}/api/products/video-signature`, + { credentials: "include" } + ); + + if (!sigRes.ok) { + toast.error("Failed to authorize upload", { id: toastId }); + setUploadingVideo(false); + return; + } + + const { signature, timestamp, api_key, cloud_name, folder } = await sigRes.json(); + + // Step 2: Upload directly to Cloudinary + const formData = new FormData(); + formData.append("file", file); + formData.append("api_key", api_key); + formData.append("timestamp", timestamp); + formData.append("signature", signature); + formData.append("folder", folder); + formData.append("resource_type", "video"); + + toast.loading("Uploading video... 0%", { id: toastId, duration: Infinity }); + + const xhr = new XMLHttpRequest(); + videoXhrRef.current = xhr; + xhr.open("POST", `https://api.cloudinary.com/v1_1/${cloud_name}/video/upload`); + + xhr.upload.onprogress = (event) => { + if (event.lengthComputable) { + const pct = Math.round((event.loaded / event.total) * 100); + setVideoProgress(pct); + toast.loading(`Uploading video... ${pct}%`, { id: toastId, duration: Infinity }); + } + }; + + xhr.onload = () => { + videoXhrRef.current = null; + try { + const data = JSON.parse(xhr.responseText); + if (xhr.status >= 200 && xhr.status < 300) { + setVideo({ + url: data.secure_url, + public_id: data.public_id, + }); + toast.success("Video uploaded successfully", { id: toastId, duration: 4000 }); + } else { + toast.error(data.error?.message || "Video upload failed", { id: toastId, duration: 4000 }); + } + } catch { + toast.error("Video upload failed", { id: toastId, duration: 4000 }); + } finally { + setUploadingVideo(false); + setVideoProgress(0); + } + }; + + xhr.onerror = () => { + videoXhrRef.current = null; + toast.error("Video upload failed — check your connection", { id: toastId, duration: 4000 }); + setUploadingVideo(false); + setVideoProgress(0); + }; + + xhr.onabort = () => { + videoXhrRef.current = null; + toast.dismiss(toastId); + setUploadingVideo(false); + setVideoProgress(0); + }; + + xhr.send(formData); + } catch (err) { + toast.error("Video upload failed", { id: toastId, duration: 4000 }); + setUploadingVideo(false); + setVideoProgress(0); + } + + e.target.value = ""; + }; + + const handleRemoveVideo = () => { + setVideo(null); + toast.success("Video removed"); + }; + const handleUpdate = async () => { try { setUpdating(true); @@ -311,6 +472,7 @@ export default function EditProductPage() { description: form.description, faq: form.faq, images, + video: video || { url: null, public_id: null }, }), }, ); @@ -427,6 +589,70 @@ export default function EditProductPage() {
+ {/* VIDEO SECTION */} +
+
+

+ + Product Video +

+ Max 50MB • All formats supported +
+ + {video?.url ? ( +
+
+ ) : ( +