Skip to content

Commit fd6302d

Browse files
committed
Add gallery to initiatives
1 parent 7da566d commit fd6302d

9 files changed

Lines changed: 289 additions & 1 deletion

public/data/initiatives.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1223,7 +1223,7 @@
12231223
"title": "Renovation of RO Filter System at Saivapragasa Ladies College, Vavuniya | Filter of Hope #6",
12241224
"related_sdgs": [6],
12251225
"related_sdg_targets": ["6.1"],
1226-
"image": "/images/foh-ro-filter-renovation-vavuniya.jpg",
1226+
"image": ["/images/foh-ro-filter-renovation-vavuniya.jpg", "/images/foh-ro-filter-renovation-vavuniya-2.jpg", "/images/foh-ro-filter-renovation-vavuniya-3.jpg", "/images/foh-ro-filter-renovation-vavuniya-4.jpg", "/images/foh-ro-filter-renovation-vavuniya-5.jpg"],
12271227
"slug": "foh-ro-filter-renovation-vavuniya",
12281228
"content": "IEEE Sri Lanka Section SIGHT renovated an existing RO filter system at Saivapragasa Ladies College, Vavuniya. The work included replacing the membrane (Vontron) and installing a 200GPD pump, restoring the system’s full functionality.\n\nWith the renovation completed, students and the surrounding community now have continued access to safe and clean drinking water, strengthening health and daily well-being. This effort highlights the group’s dedication to maintaining and sustaining essential clean water solutions.",
12291229
"date": "2025-09-05",
438 KB
Loading
359 KB
Loading
196 KB
Loading
829 KB
Loading
-669 KB
Loading

src/components/ImageGallery.tsx

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { motion } from 'framer-motion';
2+
import React, { useState } from 'react';
3+
import ImageModal from './ImageModal';
4+
5+
interface ImageGalleryProps {
6+
images: string[];
7+
alt: string;
8+
title: string;
9+
}
10+
11+
const ImageGallery: React.FC<ImageGalleryProps> = ({ images, alt, title }) => {
12+
const [isModalOpen, setIsModalOpen] = useState(false);
13+
const [currentImageIndex, setCurrentImageIndex] = useState(0);
14+
15+
// Don't render if no images or only one image (handled by main image)
16+
if (!images || images.length <= 1) {
17+
return null;
18+
}
19+
20+
const openModal = (index: number) => {
21+
setCurrentImageIndex(index);
22+
setIsModalOpen(true);
23+
};
24+
25+
const closeModal = () => {
26+
setIsModalOpen(false);
27+
};
28+
29+
const goToPrevious = () => {
30+
setCurrentImageIndex((prev) => (prev === 0 ? images.length - 1 : prev - 1));
31+
};
32+
33+
const goToNext = () => {
34+
setCurrentImageIndex((prev) => (prev === images.length - 1 ? 0 : prev + 1));
35+
};
36+
37+
const goToIndex = (index: number) => {
38+
setCurrentImageIndex(index);
39+
};
40+
41+
return (
42+
<>
43+
<div className="bg-white rounded-lg shadow-sm p-6">
44+
<h3 className="text-xl font-semibold text-gray-900 mb-4">Gallery</h3>
45+
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
46+
{images.map((image, index) => (
47+
<motion.button
48+
key={index}
49+
whileHover={{ scale: 1.05 }}
50+
whileTap={{ scale: 0.95 }}
51+
transition={{ duration: 0.2 }}
52+
onClick={() => openModal(index)}
53+
className="relative aspect-square rounded-lg overflow-hidden group focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
54+
>
55+
<img
56+
src={image}
57+
alt={`${alt} - Image ${index + 1}`}
58+
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
59+
onError={(e) => {
60+
const target = e.target as HTMLImageElement;
61+
const container = target.parentElement!;
62+
container.innerHTML = `
63+
<div class="w-full h-full bg-gray-200 flex items-center justify-center">
64+
<div class="text-gray-500 text-xs text-center p-2">
65+
<div class="mb-1">📷</div>
66+
Image ${index + 1}
67+
</div>
68+
</div>
69+
`;
70+
}}
71+
/>
72+
{/* Overlay with hover effect */}
73+
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-20 transition-opacity duration-300 flex items-center justify-center">
74+
<div className="opacity-0 group-hover:opacity-100 transition-opacity duration-300">
75+
<div className="w-8 h-8 bg-white bg-opacity-90 rounded-full flex items-center justify-center">
76+
<svg className="w-4 h-4 text-gray-800" fill="none" stroke="currentColor" viewBox="0 0 24 24">
77+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v6m3-3H7" />
78+
</svg>
79+
</div>
80+
</div>
81+
</div>
82+
{/* Image number indicator */}
83+
<div className="absolute top-2 left-2 bg-black bg-opacity-70 text-white text-xs px-2 py-1 rounded">
84+
{index + 1}
85+
</div>
86+
</motion.button>
87+
))}
88+
</div>
89+
<p className="text-sm text-gray-500 mt-3">
90+
Click on any image to view in full size • {images.length} photos
91+
</p>
92+
</div>
93+
94+
<ImageModal
95+
isOpen={isModalOpen}
96+
onClose={closeModal}
97+
images={images}
98+
currentIndex={currentImageIndex}
99+
onPrevious={goToPrevious}
100+
onNext={goToNext}
101+
onGoToIndex={goToIndex}
102+
alt={alt}
103+
/>
104+
</>
105+
);
106+
};
107+
108+
export default ImageGallery;

src/components/ImageModal.tsx

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import { AnimatePresence, motion } from 'framer-motion';
2+
import { ChevronLeft, ChevronRight, X } from 'lucide-react';
3+
import React, { useEffect } from 'react';
4+
5+
interface ImageModalProps {
6+
isOpen: boolean;
7+
onClose: () => void;
8+
images: string[];
9+
currentIndex: number;
10+
onPrevious: () => void;
11+
onNext: () => void;
12+
onGoToIndex: (index: number) => void;
13+
alt: string;
14+
}
15+
16+
const ImageModal: React.FC<ImageModalProps> = ({
17+
isOpen,
18+
onClose,
19+
images,
20+
currentIndex,
21+
onPrevious,
22+
onNext,
23+
onGoToIndex,
24+
alt
25+
}) => {
26+
// Handle escape key
27+
useEffect(() => {
28+
const handleEscape = (e: KeyboardEvent) => {
29+
if (e.key === 'Escape') {
30+
onClose();
31+
}
32+
};
33+
34+
if (isOpen) {
35+
document.addEventListener('keydown', handleEscape);
36+
document.body.style.overflow = 'hidden';
37+
}
38+
39+
return () => {
40+
document.removeEventListener('keydown', handleEscape);
41+
document.body.style.overflow = '';
42+
};
43+
}, [isOpen, onClose]);
44+
45+
// Handle arrow keys
46+
useEffect(() => {
47+
const handleArrowKeys = (e: KeyboardEvent) => {
48+
if (e.key === 'ArrowLeft') {
49+
onPrevious();
50+
} else if (e.key === 'ArrowRight') {
51+
onNext();
52+
}
53+
};
54+
55+
if (isOpen) {
56+
document.addEventListener('keydown', handleArrowKeys);
57+
}
58+
59+
return () => {
60+
document.removeEventListener('keydown', handleArrowKeys);
61+
};
62+
}, [isOpen, onPrevious, onNext]);
63+
64+
if (!isOpen || images.length === 0) return null;
65+
66+
return (
67+
<AnimatePresence>
68+
<motion.div
69+
initial={{ opacity: 0 }}
70+
animate={{ opacity: 1 }}
71+
exit={{ opacity: 0 }}
72+
transition={{ duration: 0.3 }}
73+
className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-90"
74+
onClick={onClose}
75+
>
76+
{/* Close button */}
77+
<button
78+
onClick={onClose}
79+
className="absolute top-4 right-4 z-60 p-2 bg-black bg-opacity-50 text-white rounded-full hover:bg-opacity-70 transition-colors"
80+
>
81+
<X className="h-6 w-6" />
82+
</button>
83+
84+
{/* Image counter */}
85+
{images.length > 1 && (
86+
<div className="absolute top-4 left-4 z-60 px-3 py-1 bg-black bg-opacity-50 text-white text-sm rounded-full">
87+
{currentIndex + 1} of {images.length}
88+
</div>
89+
)}
90+
91+
{/* Previous button */}
92+
{images.length > 1 && (
93+
<button
94+
onClick={(e) => {
95+
e.stopPropagation();
96+
onPrevious();
97+
}}
98+
className="absolute left-4 top-1/2 transform -translate-y-1/2 z-60 p-3 bg-black bg-opacity-50 text-white rounded-full hover:bg-opacity-70 transition-colors"
99+
>
100+
<ChevronLeft className="h-6 w-6" />
101+
</button>
102+
)}
103+
104+
{/* Next button */}
105+
{images.length > 1 && (
106+
<button
107+
onClick={(e) => {
108+
e.stopPropagation();
109+
onNext();
110+
}}
111+
className="absolute right-4 top-1/2 transform -translate-y-1/2 z-60 p-3 bg-black bg-opacity-50 text-white rounded-full hover:bg-opacity-70 transition-colors"
112+
>
113+
<ChevronRight className="h-6 w-6" />
114+
</button>
115+
)}
116+
117+
{/* Image */}
118+
<motion.div
119+
initial={{ scale: 0.8 }}
120+
animate={{ scale: 1 }}
121+
exit={{ scale: 0.8 }}
122+
transition={{ duration: 0.3 }}
123+
className="max-w-4xl max-h-[90vh] mx-4"
124+
onClick={(e) => e.stopPropagation()}
125+
>
126+
<img
127+
src={images[currentIndex]}
128+
alt={`${alt} - Image ${currentIndex + 1}`}
129+
className="w-full h-full object-contain rounded-lg"
130+
onError={(e) => {
131+
const target = e.target as HTMLImageElement;
132+
target.src = '/images/fallback-image.jpg'; // You can add a fallback image
133+
}}
134+
/>
135+
</motion.div>
136+
137+
{/* Thumbnail navigation for multiple images */}
138+
{images.length > 1 && (
139+
<div className="absolute bottom-4 left-1/2 transform -translate-x-1/2 z-60">
140+
<div className="flex space-x-2 bg-black bg-opacity-50 rounded-lg p-2">
141+
{images.map((image, index) => (
142+
<button
143+
key={index}
144+
onClick={(e) => {
145+
e.stopPropagation();
146+
onGoToIndex(index);
147+
}}
148+
className={`w-12 h-12 rounded overflow-hidden transition-opacity ${
149+
index === currentIndex ? 'opacity-100 ring-2 ring-white' : 'opacity-60 hover:opacity-80'
150+
}`}
151+
>
152+
<img
153+
src={image}
154+
alt={`Thumbnail ${index + 1}`}
155+
className="w-full h-full object-cover"
156+
/>
157+
</button>
158+
))}
159+
</div>
160+
</div>
161+
)}
162+
</motion.div>
163+
</AnimatePresence>
164+
);
165+
};
166+
167+
export default ImageModal;

src/components/InitiativeDetail.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { ArrowLeft, Building, Calendar, DollarSign, ExternalLink, MapPin, Users
33
import React, { useEffect, useState } from 'react';
44
import { Link, useNavigate, useParams } from 'react-router-dom';
55
import type { Initiative, OrganizingUnit, Partner } from '../types';
6+
import ImageGallery from './ImageGallery';
67
import InitiativeCard from './InitiativeCard';
78
import SDGBadge from './SDGBadge';
89

@@ -325,6 +326,18 @@ const InitiativeDetail: React.FC = () => {
325326
</div>
326327
)}
327328

329+
{/* Image Gallery */}
330+
{(() => {
331+
const images = Array.isArray(initiative.image) ? initiative.image : (initiative.image ? [initiative.image] : []);
332+
return (
333+
<ImageGallery
334+
images={images}
335+
alt={initiative.title}
336+
title={initiative.title}
337+
/>
338+
);
339+
})()}
340+
328341
{(initiative.link || initiative.vtools_event_url) && (
329342
<div className="bg-white rounded-lg shadow-sm p-6">
330343
<h3 className="text-xl font-semibold text-gray-900 mb-4">Resources</h3>

0 commit comments

Comments
 (0)