From 527401777bf610e1af7405eafad5724a1ffc336a Mon Sep 17 00:00:00 2001 From: Ansh Date: Sun, 30 Nov 2025 22:20:55 +0530 Subject: [PATCH] added image preview + zoom in out + edit architect profile --- package-lock.json | 15 ++ package.json | 1 + src/components/ImageViewer.tsx | 128 +++++++++++++++ src/pages/categories/ArchitectDetail.tsx | 190 ++++++++++++++++++----- 4 files changed, 299 insertions(+), 35 deletions(-) create mode 100644 src/components/ImageViewer.tsx diff --git a/package-lock.json b/package-lock.json index d348fc19..bb551077 100644 --- a/package-lock.json +++ b/package-lock.json @@ -57,6 +57,7 @@ "react-i18next": "^15.4.1", "react-resizable-panels": "^2.1.3", "react-router-dom": "^6.26.2", + "react-zoom-pan-pinch": "^3.7.0", "recharts": "^2.12.7", "sonner": "^2.0.7", "tailwind-merge": "^2.5.2", @@ -8266,6 +8267,20 @@ "react-dom": ">=16.6.0" } }, + "node_modules/react-zoom-pan-pinch": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/react-zoom-pan-pinch/-/react-zoom-pan-pinch-3.7.0.tgz", + "integrity": "sha512-UmReVZ0TxlKzxSbYiAj+LeGRW8s8LraAFTXRAxzMYnNRgGPsxCudwZKVkjvGmjtx7SW/hZamt69NUmGf4xrkXA==", + "license": "MIT", + "engines": { + "node": ">=8", + "npm": ">=5" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", diff --git a/package.json b/package.json index 0b9a4d31..d0b88643 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "react-i18next": "^15.4.1", "react-resizable-panels": "^2.1.3", "react-router-dom": "^6.26.2", + "react-zoom-pan-pinch": "^3.7.0", "recharts": "^2.12.7", "sonner": "^2.0.7", "tailwind-merge": "^2.5.2", diff --git a/src/components/ImageViewer.tsx b/src/components/ImageViewer.tsx new file mode 100644 index 00000000..f55033f0 --- /dev/null +++ b/src/components/ImageViewer.tsx @@ -0,0 +1,128 @@ +import { motion } from "framer-motion"; +import { useEffect, useState } from "react"; +import { X } from "lucide-react"; +import { TransformWrapper, TransformComponent } from "react-zoom-pan-pinch"; + +interface ImageViewerProps { + photos: string[]; + currentIndex: number; + setCurrentIndex: React.Dispatch>; + onClose: () => void; + canDelete: boolean; // still kept for future use + onDelete: (index: number) => void; // still kept for future use +} + +export default function ImageViewer({ + photos, + currentIndex, + setCurrentIndex, + onClose, +}: ImageViewerProps) { + + const [scale, setScale] = useState(1); + + useEffect(() => { + document.body.style.overflow = "hidden"; + return () => { + document.body.style.overflow = "auto"; + }; + }, []); + + useEffect(() => { + const keyHandler = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + + if (scale === 1 && e.key === "ArrowRight" && currentIndex < photos.length - 1) { + setCurrentIndex(currentIndex + 1); + } + + if (scale === 1 && e.key === "ArrowLeft" && currentIndex > 0) { + setCurrentIndex(currentIndex - 1); + } + }; + + window.addEventListener("keydown", keyHandler); + return () => window.removeEventListener("keydown", keyHandler); + }, [scale, currentIndex, photos.length, setCurrentIndex, onClose]); + + + return ( + { + if (e.target === e.currentTarget) onClose(); + }} + > + + {/* Close Button Only */} + + + + {/* Left Arrow */} + {currentIndex > 0 && scale === 1 && ( + + )} + + {/* Zoom Wrapper */} + setScale(instance.state.scale)} + > + + + e.stopPropagation()} + onWheel={(e) => e.stopPropagation()} + onMouseDown={(e) => e.preventDefault()} + onContextMenu={(e) => e.preventDefault()} + initial={{ scale: 0.9 }} + animate={{ scale: 1 }} + /> + + + + + + {/* Right Arrow */} + {currentIndex < photos.length - 1 && scale === 1 && ( + + )} + + + ); +} diff --git a/src/pages/categories/ArchitectDetail.tsx b/src/pages/categories/ArchitectDetail.tsx index 7c4771d4..523f7e9b 100644 --- a/src/pages/categories/ArchitectDetail.tsx +++ b/src/pages/categories/ArchitectDetail.tsx @@ -5,6 +5,8 @@ import Navbar from "@/components/Navbar"; import Footer from "@/components/Footer"; import { Loader2, Upload, X } from "lucide-react"; import { motion } from "framer-motion"; +import ImageViewer from "@/components/ImageViewer"; + export default function ArchitectDetail() { const { id } = useParams<{ id: string }>(); @@ -32,6 +34,45 @@ export default function ArchitectDetail() { const [message, setMessage] = useState(""); const [date, setDate] = useState(""); + // image viewer state + const [selectedIndex, setSelectedIndex] = useState(null); + // delete logic + const handleDeleteImage = async (index: number) => { + if (!photos[index] || !id) return; + + const confirmDelete = confirm("Are you sure you want to delete this photo?"); + if (!confirmDelete) return; + + const fileUrl = photos[index]; + const fileName = fileUrl.split(`/architect_${id}/`)[1]; + if (!fileName) return; + + const filePath = `architect_${id}/${fileName}`; + + const { error } = await supabase.storage.from("Architects").remove([filePath]); + if (error) { + alert("Failed to delete image."); + return; + } + + // update photo list + const updatedPhotos = photos.filter((_, i) => i !== index); + setPhotos(updatedPhotos); + + // adjust viewer index safely + if (updatedPhotos.length === 0) { + setSelectedIndex(null); + } else if (index >= updatedPhotos.length) { + setSelectedIndex(updatedPhotos.length - 1); + } else { + setSelectedIndex(index); + } + + alert("Image deleted successfully."); + }; + + + // Get current logged-in user useEffect(() => { const getUser = async () => { @@ -85,30 +126,32 @@ export default function ArchitectDetail() { const fetchPhotos = async () => { if (!id) return; setPhotoError(null); + try { const folderName = `architect_${id}`; const { data, error } = await supabase.storage .from("Architects") .list(folderName, { limit: 100 }); + if (error) { console.error("Error listing files:", error); - setPhotoError("Failed to load photos. Please refresh the page."); setPhotos([]); return; } - const urls = - data?.map((file) => { - const { data: publicData } = supabase.storage - .from("Architects") - .getPublicUrl(`${folderName}/${file.name}`); - return publicData?.publicUrl || ""; - }) || []; + // Generate fresh, non-cached URLs + const urls = data.map((file) => { + const { data: publicUrl } = supabase.storage + .from("Architects") + .getPublicUrl(`${folderName}/${file.name}`); - setPhotos(urls.filter(Boolean)); + // prevent browser and CDN caching old files + return `${publicUrl.publicUrl}?v=${Date.now()}`; + }); + + setPhotos(urls); } catch (err) { - console.error("Error fetching photos:", err); - setPhotoError("Failed to load photos. Please refresh the page."); + console.error(err); setPhotos([]); } }; @@ -118,36 +161,80 @@ export default function ArchitectDetail() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [id]); - // Handle photo upload - const handleUpload = async (e: React.ChangeEvent) => { - if (!canUpload) { - alert("You can only upload photos to your own profile."); +const handleUpload = async (e: React.ChangeEvent) => { + if (!canUpload) return alert("You can only upload to your own profile."); + + try { + const file = e.target.files?.[0]; + if (!file || !id) return; + + setUploading(true); + const folderName = `architect_${id}`; + const fileName = `${Date.now()}_${file.name}`; + const filePath = `${folderName}/${fileName}`; + + const { error: uploadError } = await supabase.storage + .from("Architects") + .upload(filePath, file); + + if (uploadError) { + alert("Upload failed."); return; } + + await fetchPhotos(); // refresh gallery + } finally { + setUploading(false); + } +}; + + + + // Handle photo upload + const handleProfilePhotoUpdate = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file || !id) return; + + setUploading(true); + try { - setPhotoError(null); - const file = e.target.files?.[0]; - if (!file || !id) return; - setUploading(true); - const folderName = `architect_${id}`; - const fileName = `${Date.now()}_${file.name}`; - const filePath = `${folderName}/${fileName}`; + const fileExt = file.name.split(".").pop(); + const fileName = `profile_${Date.now()}.${fileExt}`; + const filePath = `architect_${id}/${fileName}`; + const { error: uploadError } = await supabase.storage .from("Architects") - .upload(filePath, file); + .upload(filePath, file, { upsert: true }); + if (uploadError) { - console.error("Upload error:", uploadError); - setPhotoError("Failed to upload photo. Please try again."); + alert("Error updating profile photo"); + return; + } + + const { data: publicUrl } = supabase.storage + .from("Architects") + .getPublicUrl(filePath); + + if (!publicUrl?.publicUrl) return; + + const { error: dbError } = await supabase + .from("architects") + .update({ image_url: publicUrl.publicUrl }) + .eq("id", Number(id)); + + if (dbError) { + alert("Failed to update profile photo in database."); return; } - await fetchPhotos(); + + setArchitect((prev: any) => ({ ...prev, image_url: publicUrl.publicUrl })); + alert("Profile photo updated successfully!"); + } catch (err) { - console.error("Error uploading photo:", err); - setPhotoError("Failed to upload photo. Please try again."); + console.error(err); + alert("Unexpected error updating profile photo."); } finally { setUploading(false); - // reset file input value if desired (no ref here) - // e.currentTarget.value = ""; } }; @@ -249,11 +336,32 @@ export default function ArchitectDetail() { {/* Profile */}
- {architect.name} +
+ {architect.name} + + {canUpload && ( + <> + + + + + )} +

{architect.name}

{architect.specialization}

@@ -384,11 +492,23 @@ export default function ArchitectDetail() { alt={`Work ${i + 1}`} className="rounded-xl shadow-md object-cover w-full h-48" whileHover={{ scale: 1.05 }} + onClick={() => setSelectedIndex(i)} /> ))}
)}
+ {selectedIndex !== null && ( + setSelectedIndex(null)} + canDelete={canUpload} // only owner can delete + onDelete={handleDeleteImage} // delete handler + /> + )} +