Skip to content

Commit 95f3ed7

Browse files
committed
feat(components): add image gallery components
- Add ImageGallery component for thumbnail display (4-column grid) - Add ImageGalleryModal component for full-screen image viewing - Features: * Click thumbnails or project title to open gallery * Navigate between images with arrow buttons and keyboard * Display project title and image counter in modal * Close with ESC key or click outside * Responsive design with proper z-index layering
1 parent b8ee917 commit 95f3ed7

2 files changed

Lines changed: 192 additions & 0 deletions

File tree

src/components/ImageGallery.astro

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
interface Props {
3+
images: string[];
4+
title: string;
5+
projectId: string;
6+
}
7+
8+
const { images, title, projectId } = Astro.props;
9+
---
10+
11+
<!-- Only render thumbnails here, modal will be at page level -->
12+
{images && images.length > 0 && (
13+
<div class="mb-4">
14+
<div class="grid grid-cols-4 gap-2">
15+
{images.slice(0, 4).map((image, index) => (
16+
<button
17+
type="button"
18+
class="relative overflow-hidden rounded-lg border-2 border-transparent hover:border-primary-500 transition-all duration-200 focus:outline-none focus:border-primary-500 transform hover:scale-105 aspect-square"
19+
onclick={`openImageGallery('${projectId}', ${index})`}
20+
>
21+
<img
22+
src={image}
23+
alt={`${title} screenshot ${index + 1}`}
24+
class="w-full h-full object-cover"
25+
/>
26+
{index === 3 && images.length > 4 && (
27+
<div class="absolute inset-0 bg-black/70 flex items-center justify-center backdrop-blur-sm">
28+
<span class="text-white text-lg font-bold">+{images.length - 4}</span>
29+
</div>
30+
)}
31+
</button>
32+
))}
33+
</div>
34+
</div>
35+
)}
36+
37+
<script define:vars={{ images, projectId }}>
38+
// Store gallery data globally
39+
if (typeof window !== 'undefined') {
40+
window.galleryData = window.galleryData || {};
41+
window.galleryData[projectId] = {
42+
images: images || [],
43+
currentIndex: 0
44+
};
45+
}
46+
</script>
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
---
2+
// This component renders all the modals at the page level (outside cards)
3+
interface Props {
4+
projects: Array<{
5+
id: string;
6+
title: string;
7+
images?: string[];
8+
}>;
9+
}
10+
11+
const { projects } = Astro.props;
12+
---
13+
14+
<!-- Render modals for all projects at page level -->
15+
{projects.filter(p => p.images && p.images.length > 0).map((project) => (
16+
<div
17+
id={`gallery-modal-${project.id}`}
18+
class="fixed inset-0 bg-black/95 z-[9999] hidden items-center justify-center p-4 backdrop-blur-sm"
19+
onclick={`closeImageGallery('${project.id}')`}
20+
>
21+
<div class="relative max-w-7xl w-full h-full flex items-center justify-center">
22+
<!-- Project Title -->
23+
<div class="absolute top-4 left-4 z-10 bg-black/50 text-white px-4 py-2 rounded-lg backdrop-blur-sm">
24+
<h3 class="font-semibold text-lg">{project.title}</h3>
25+
</div>
26+
27+
<!-- Close Button -->
28+
<button
29+
type="button"
30+
class="absolute top-4 right-4 text-white hover:text-gray-300 z-10 bg-black/30 rounded-full p-2 backdrop-blur-sm transition-colors"
31+
onclick={`event.stopPropagation(); closeImageGallery('${project.id}')`}
32+
>
33+
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
34+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
35+
</svg>
36+
</button>
37+
38+
<!-- Navigation Buttons -->
39+
{project.images && project.images.length > 1 && (
40+
<>
41+
<button
42+
type="button"
43+
class="absolute left-4 top-1/2 transform -translate-y-1/2 text-white hover:text-gray-300 z-10 bg-black/30 rounded-full p-3 backdrop-blur-sm transition-colors hover:bg-black/50"
44+
onclick={`event.stopPropagation(); navigateGallery('${project.id}', -1)`}
45+
>
46+
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
47+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M15 19l-7-7 7-7"></path>
48+
</svg>
49+
</button>
50+
51+
<button
52+
type="button"
53+
class="absolute right-4 top-1/2 transform -translate-y-1/2 text-white hover:text-gray-300 z-10 bg-black/30 rounded-full p-3 backdrop-blur-sm transition-colors hover:bg-black/50"
54+
onclick={`event.stopPropagation(); navigateGallery('${project.id}', 1)`}
55+
>
56+
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
57+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M9 5l7 7-7 7"></path>
58+
</svg>
59+
</button>
60+
</>
61+
)}
62+
63+
<!-- Image Container -->
64+
<div class="relative flex items-center justify-center max-w-full max-h-full" onclick="event.stopPropagation()">
65+
<img
66+
id={`gallery-image-${project.id}`}
67+
src=""
68+
alt=""
69+
class="max-w-full max-h-[90vh] object-contain rounded-lg shadow-2xl"
70+
/>
71+
72+
<!-- Image Counter -->
73+
{project.images && project.images.length > 1 && (
74+
<div class="absolute bottom-6 left-1/2 transform -translate-x-1/2 bg-black/70 text-white px-4 py-2 rounded-full text-sm backdrop-blur-sm">
75+
<span id={`gallery-counter-${project.id}`}>1 / {project.images.length}</span>
76+
</div>
77+
)}
78+
</div>
79+
</div>
80+
</div>
81+
))}
82+
83+
<script>
84+
// Global gallery functions
85+
window.openImageGallery = function(id, index = 0) {
86+
const modal = document.getElementById(`gallery-modal-${id}`);
87+
const image = document.getElementById(`gallery-image-${id}`);
88+
const counter = document.getElementById(`gallery-counter-${id}`);
89+
90+
if (modal && image && window.galleryData && window.galleryData[id]) {
91+
window.galleryData[id].currentIndex = index;
92+
const currentImage = window.galleryData[id].images[index];
93+
94+
image.src = currentImage;
95+
image.alt = `Gallery image ${index + 1}`;
96+
97+
if (counter) {
98+
counter.textContent = `${index + 1} / ${window.galleryData[id].images.length}`;
99+
}
100+
101+
modal.classList.remove('hidden');
102+
modal.classList.add('flex');
103+
document.body.style.overflow = 'hidden';
104+
}
105+
};
106+
107+
window.closeImageGallery = function(id) {
108+
const modal = document.getElementById(`gallery-modal-${id}`);
109+
if (modal) {
110+
modal.classList.add('hidden');
111+
modal.classList.remove('flex');
112+
document.body.style.overflow = 'auto';
113+
}
114+
};
115+
116+
window.navigateGallery = function(id, direction) {
117+
if (!window.galleryData || !window.galleryData[id]) return;
118+
119+
const data = window.galleryData[id];
120+
const newIndex = (data.currentIndex + direction + data.images.length) % data.images.length;
121+
122+
window.openImageGallery(id, newIndex);
123+
};
124+
125+
// Keyboard navigation
126+
document.addEventListener('keydown', function(e) {
127+
const openModal = document.querySelector('[id^="gallery-modal-"]:not(.hidden)');
128+
if (!openModal) return;
129+
130+
const modalId = openModal.id.replace('gallery-modal-', '');
131+
132+
switch(e.key) {
133+
case 'Escape':
134+
window.closeImageGallery(modalId);
135+
break;
136+
case 'ArrowLeft':
137+
e.preventDefault();
138+
window.navigateGallery(modalId, -1);
139+
break;
140+
case 'ArrowRight':
141+
e.preventDefault();
142+
window.navigateGallery(modalId, 1);
143+
break;
144+
}
145+
});
146+
</script>

0 commit comments

Comments
 (0)