@aws505/sheetsite 1.0.2 → 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +185 -40
- package/dist/components/index.js +804 -57
- package/dist/components/index.js.map +1 -1
- package/dist/components/index.mjs +789 -57
- package/dist/components/index.mjs.map +1 -1
- package/dist/index.js +175 -59
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +165 -59
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/components/index.ts +12 -0
- package/src/components/sections/BeforeAfter.tsx +345 -0
- package/src/components/sections/FAQ.tsx +3 -3
- package/src/components/sections/Gallery.tsx +104 -4
- package/src/components/sections/Menu.tsx +312 -0
- package/src/components/sections/Services.tsx +3 -3
- package/src/components/sections/Testimonials.tsx +1 -1
- package/src/components/sections/TrustBadges.tsx +283 -0
- package/src/components/ui/AnimatedSection.tsx +136 -0
package/package.json
CHANGED
package/src/components/index.ts
CHANGED
|
@@ -31,6 +31,15 @@ export type { HoursProps } from './sections/Hours';
|
|
|
31
31
|
export { Gallery } from './sections/Gallery';
|
|
32
32
|
export type { GalleryProps } from './sections/Gallery';
|
|
33
33
|
|
|
34
|
+
export { Menu } from './sections/Menu';
|
|
35
|
+
export type { MenuProps } from './sections/Menu';
|
|
36
|
+
|
|
37
|
+
export { TrustBadges } from './sections/TrustBadges';
|
|
38
|
+
export type { TrustBadgesProps, TrustBadge } from './sections/TrustBadges';
|
|
39
|
+
|
|
40
|
+
export { BeforeAfter } from './sections/BeforeAfter';
|
|
41
|
+
export type { BeforeAfterProps, BeforeAfterItem } from './sections/BeforeAfter';
|
|
42
|
+
|
|
34
43
|
// UI Components
|
|
35
44
|
export { Button, ButtonLink } from './ui/Button';
|
|
36
45
|
export type { ButtonProps, ButtonLinkProps } from './ui/Button';
|
|
@@ -39,3 +48,6 @@ export { Card, CardHeader, CardBody, CardFooter, CardImage } from './ui/Card';
|
|
|
39
48
|
export type { CardProps, CardHeaderProps, CardImageProps } from './ui/Card';
|
|
40
49
|
|
|
41
50
|
export * from './ui/Icons';
|
|
51
|
+
|
|
52
|
+
export { AnimatedSection, StaggerContainer } from './ui/AnimatedSection';
|
|
53
|
+
export type { AnimatedSectionProps, StaggerContainerProps } from './ui/AnimatedSection';
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Before/After Gallery Section Component
|
|
3
|
+
*
|
|
4
|
+
* Displays before and after images with an interactive slider.
|
|
5
|
+
* Perfect for tailors, home services, beauty services, etc.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
'use client';
|
|
9
|
+
|
|
10
|
+
import React, { useState, useRef, useCallback } from 'react';
|
|
11
|
+
|
|
12
|
+
export interface BeforeAfterItem {
|
|
13
|
+
id?: string;
|
|
14
|
+
beforeImageUrl: string;
|
|
15
|
+
afterImageUrl: string;
|
|
16
|
+
beforeAlt?: string;
|
|
17
|
+
afterAlt?: string;
|
|
18
|
+
title?: string;
|
|
19
|
+
description?: string;
|
|
20
|
+
category?: string;
|
|
21
|
+
featured?: boolean;
|
|
22
|
+
sortOrder?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface BeforeAfterProps {
|
|
26
|
+
items: BeforeAfterItem[];
|
|
27
|
+
title?: string;
|
|
28
|
+
subtitle?: string;
|
|
29
|
+
columns?: 1 | 2 | 3;
|
|
30
|
+
variant?: 'slider' | 'side-by-side' | 'stacked';
|
|
31
|
+
showCategories?: boolean;
|
|
32
|
+
className?: string;
|
|
33
|
+
id?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Before/After gallery section component.
|
|
38
|
+
*/
|
|
39
|
+
export function BeforeAfter({
|
|
40
|
+
items,
|
|
41
|
+
title = 'Our Work',
|
|
42
|
+
subtitle = 'See the transformation',
|
|
43
|
+
columns = 2,
|
|
44
|
+
variant = 'slider',
|
|
45
|
+
showCategories = true,
|
|
46
|
+
className = '',
|
|
47
|
+
id = 'portfolio',
|
|
48
|
+
}: BeforeAfterProps) {
|
|
49
|
+
// Get unique categories
|
|
50
|
+
const categories = showCategories
|
|
51
|
+
? [...new Set(items.filter(item => item.category).map(item => item.category!))]
|
|
52
|
+
: [];
|
|
53
|
+
|
|
54
|
+
const [activeCategory, setActiveCategory] = useState<string | null>(null);
|
|
55
|
+
|
|
56
|
+
// Filter items by category
|
|
57
|
+
const displayedItems = activeCategory
|
|
58
|
+
? items.filter(item => item.category === activeCategory)
|
|
59
|
+
: items;
|
|
60
|
+
|
|
61
|
+
// Sort by sortOrder
|
|
62
|
+
const sortedItems = [...displayedItems].sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0));
|
|
63
|
+
|
|
64
|
+
const gridCols = {
|
|
65
|
+
1: 'max-w-2xl mx-auto',
|
|
66
|
+
2: 'md:grid-cols-2',
|
|
67
|
+
3: 'md:grid-cols-2 lg:grid-cols-3',
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<section id={id} className={`py-16 scroll-mt-20 ${className}`}>
|
|
72
|
+
<div className="container mx-auto px-4">
|
|
73
|
+
{/* Header */}
|
|
74
|
+
<div className="text-center mb-12">
|
|
75
|
+
<h2 className="text-3xl md:text-4xl font-bold text-gray-900 mb-4">{title}</h2>
|
|
76
|
+
{subtitle && (
|
|
77
|
+
<p className="text-lg text-gray-600 max-w-2xl mx-auto">{subtitle}</p>
|
|
78
|
+
)}
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
{/* Category Tabs */}
|
|
82
|
+
{showCategories && categories.length > 1 && (
|
|
83
|
+
<div className="flex flex-wrap justify-center gap-2 mb-8">
|
|
84
|
+
<button
|
|
85
|
+
onClick={() => setActiveCategory(null)}
|
|
86
|
+
className={`px-4 py-2 rounded-full text-sm font-medium transition-colors ${
|
|
87
|
+
activeCategory === null
|
|
88
|
+
? 'bg-primary-600 text-white'
|
|
89
|
+
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
|
90
|
+
}`}
|
|
91
|
+
>
|
|
92
|
+
All
|
|
93
|
+
</button>
|
|
94
|
+
{categories.map((category) => (
|
|
95
|
+
<button
|
|
96
|
+
key={category}
|
|
97
|
+
onClick={() => setActiveCategory(category)}
|
|
98
|
+
className={`px-4 py-2 rounded-full text-sm font-medium transition-colors ${
|
|
99
|
+
activeCategory === category
|
|
100
|
+
? 'bg-primary-600 text-white'
|
|
101
|
+
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
|
102
|
+
}`}
|
|
103
|
+
>
|
|
104
|
+
{category}
|
|
105
|
+
</button>
|
|
106
|
+
))}
|
|
107
|
+
</div>
|
|
108
|
+
)}
|
|
109
|
+
|
|
110
|
+
{/* Items Grid */}
|
|
111
|
+
<div className={`grid gap-8 ${gridCols[columns]}`}>
|
|
112
|
+
{sortedItems.map((item) => (
|
|
113
|
+
<BeforeAfterCard
|
|
114
|
+
key={item.id || `${item.beforeImageUrl}-${item.afterImageUrl}`}
|
|
115
|
+
item={item}
|
|
116
|
+
variant={variant}
|
|
117
|
+
/>
|
|
118
|
+
))}
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
</section>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Before/After card component.
|
|
127
|
+
*/
|
|
128
|
+
function BeforeAfterCard({
|
|
129
|
+
item,
|
|
130
|
+
variant,
|
|
131
|
+
}: {
|
|
132
|
+
item: BeforeAfterItem;
|
|
133
|
+
variant: BeforeAfterProps['variant'];
|
|
134
|
+
}) {
|
|
135
|
+
if (variant === 'side-by-side') {
|
|
136
|
+
return <SideBySideCard item={item} />;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (variant === 'stacked') {
|
|
140
|
+
return <StackedCard item={item} />;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Default: slider variant
|
|
144
|
+
return <SliderCard item={item} />;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Slider card with draggable comparison.
|
|
149
|
+
*/
|
|
150
|
+
function SliderCard({ item }: { item: BeforeAfterItem }) {
|
|
151
|
+
const [sliderPosition, setSliderPosition] = useState(50);
|
|
152
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
153
|
+
const isDragging = useRef(false);
|
|
154
|
+
|
|
155
|
+
const handleMove = useCallback((clientX: number) => {
|
|
156
|
+
if (!containerRef.current) return;
|
|
157
|
+
const rect = containerRef.current.getBoundingClientRect();
|
|
158
|
+
const x = clientX - rect.left;
|
|
159
|
+
const percentage = Math.max(0, Math.min(100, (x / rect.width) * 100));
|
|
160
|
+
setSliderPosition(percentage);
|
|
161
|
+
}, []);
|
|
162
|
+
|
|
163
|
+
const handleMouseDown = () => {
|
|
164
|
+
isDragging.current = true;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const handleMouseUp = () => {
|
|
168
|
+
isDragging.current = false;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const handleMouseMove = (e: React.MouseEvent) => {
|
|
172
|
+
if (isDragging.current) {
|
|
173
|
+
handleMove(e.clientX);
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const handleTouchMove = (e: React.TouchEvent) => {
|
|
178
|
+
handleMove(e.touches[0].clientX);
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
<div className="bg-white rounded-lg shadow-md overflow-hidden">
|
|
183
|
+
<div
|
|
184
|
+
ref={containerRef}
|
|
185
|
+
className="relative aspect-[4/3] cursor-ew-resize select-none overflow-hidden"
|
|
186
|
+
onMouseDown={handleMouseDown}
|
|
187
|
+
onMouseUp={handleMouseUp}
|
|
188
|
+
onMouseLeave={handleMouseUp}
|
|
189
|
+
onMouseMove={handleMouseMove}
|
|
190
|
+
onTouchMove={handleTouchMove}
|
|
191
|
+
>
|
|
192
|
+
{/* After image (full) */}
|
|
193
|
+
<img
|
|
194
|
+
src={item.afterImageUrl}
|
|
195
|
+
alt={item.afterAlt || 'After'}
|
|
196
|
+
className="absolute inset-0 w-full h-full object-cover"
|
|
197
|
+
/>
|
|
198
|
+
|
|
199
|
+
{/* Before image (clipped) */}
|
|
200
|
+
<div
|
|
201
|
+
className="absolute inset-0 overflow-hidden"
|
|
202
|
+
style={{ width: `${sliderPosition}%` }}
|
|
203
|
+
>
|
|
204
|
+
<img
|
|
205
|
+
src={item.beforeImageUrl}
|
|
206
|
+
alt={item.beforeAlt || 'Before'}
|
|
207
|
+
className="absolute inset-0 w-full h-full object-cover"
|
|
208
|
+
style={{ width: containerRef.current?.offsetWidth || '100%' }}
|
|
209
|
+
/>
|
|
210
|
+
</div>
|
|
211
|
+
|
|
212
|
+
{/* Slider handle */}
|
|
213
|
+
<div
|
|
214
|
+
className="absolute top-0 bottom-0 w-1 bg-white shadow-lg cursor-ew-resize"
|
|
215
|
+
style={{ left: `${sliderPosition}%`, transform: 'translateX(-50%)' }}
|
|
216
|
+
>
|
|
217
|
+
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-10 h-10 bg-white rounded-full shadow-lg flex items-center justify-center">
|
|
218
|
+
<svg className="w-6 h-6 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
219
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l4-4 4 4m0 6l-4 4-4-4" />
|
|
220
|
+
</svg>
|
|
221
|
+
</div>
|
|
222
|
+
</div>
|
|
223
|
+
|
|
224
|
+
{/* Labels */}
|
|
225
|
+
<div className="absolute bottom-4 left-4 px-2 py-1 bg-black/60 text-white text-xs rounded">
|
|
226
|
+
Before
|
|
227
|
+
</div>
|
|
228
|
+
<div className="absolute bottom-4 right-4 px-2 py-1 bg-black/60 text-white text-xs rounded">
|
|
229
|
+
After
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
232
|
+
|
|
233
|
+
{/* Content */}
|
|
234
|
+
{(item.title || item.description) && (
|
|
235
|
+
<div className="p-4">
|
|
236
|
+
{item.title && (
|
|
237
|
+
<h3 className="text-lg font-semibold text-gray-900 mb-1">{item.title}</h3>
|
|
238
|
+
)}
|
|
239
|
+
{item.description && (
|
|
240
|
+
<p className="text-gray-600 text-sm">{item.description}</p>
|
|
241
|
+
)}
|
|
242
|
+
</div>
|
|
243
|
+
)}
|
|
244
|
+
</div>
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Side by side card.
|
|
250
|
+
*/
|
|
251
|
+
function SideBySideCard({ item }: { item: BeforeAfterItem }) {
|
|
252
|
+
return (
|
|
253
|
+
<div className="bg-white rounded-lg shadow-md overflow-hidden">
|
|
254
|
+
<div className="grid grid-cols-2">
|
|
255
|
+
<div className="relative aspect-square">
|
|
256
|
+
<img
|
|
257
|
+
src={item.beforeImageUrl}
|
|
258
|
+
alt={item.beforeAlt || 'Before'}
|
|
259
|
+
className="w-full h-full object-cover"
|
|
260
|
+
/>
|
|
261
|
+
<span className="absolute bottom-2 left-2 px-2 py-1 bg-black/60 text-white text-xs rounded">
|
|
262
|
+
Before
|
|
263
|
+
</span>
|
|
264
|
+
</div>
|
|
265
|
+
<div className="relative aspect-square">
|
|
266
|
+
<img
|
|
267
|
+
src={item.afterImageUrl}
|
|
268
|
+
alt={item.afterAlt || 'After'}
|
|
269
|
+
className="w-full h-full object-cover"
|
|
270
|
+
/>
|
|
271
|
+
<span className="absolute bottom-2 right-2 px-2 py-1 bg-black/60 text-white text-xs rounded">
|
|
272
|
+
After
|
|
273
|
+
</span>
|
|
274
|
+
</div>
|
|
275
|
+
</div>
|
|
276
|
+
|
|
277
|
+
{/* Content */}
|
|
278
|
+
{(item.title || item.description) && (
|
|
279
|
+
<div className="p-4">
|
|
280
|
+
{item.title && (
|
|
281
|
+
<h3 className="text-lg font-semibold text-gray-900 mb-1">{item.title}</h3>
|
|
282
|
+
)}
|
|
283
|
+
{item.description && (
|
|
284
|
+
<p className="text-gray-600 text-sm">{item.description}</p>
|
|
285
|
+
)}
|
|
286
|
+
</div>
|
|
287
|
+
)}
|
|
288
|
+
</div>
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Stacked card with hover reveal.
|
|
294
|
+
*/
|
|
295
|
+
function StackedCard({ item }: { item: BeforeAfterItem }) {
|
|
296
|
+
const [showAfter, setShowAfter] = useState(false);
|
|
297
|
+
|
|
298
|
+
return (
|
|
299
|
+
<div className="bg-white rounded-lg shadow-md overflow-hidden">
|
|
300
|
+
<div
|
|
301
|
+
className="relative aspect-[4/3] cursor-pointer"
|
|
302
|
+
onMouseEnter={() => setShowAfter(true)}
|
|
303
|
+
onMouseLeave={() => setShowAfter(false)}
|
|
304
|
+
onClick={() => setShowAfter(!showAfter)}
|
|
305
|
+
>
|
|
306
|
+
{/* Before image */}
|
|
307
|
+
<img
|
|
308
|
+
src={item.beforeImageUrl}
|
|
309
|
+
alt={item.beforeAlt || 'Before'}
|
|
310
|
+
className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-300 ${
|
|
311
|
+
showAfter ? 'opacity-0' : 'opacity-100'
|
|
312
|
+
}`}
|
|
313
|
+
/>
|
|
314
|
+
|
|
315
|
+
{/* After image */}
|
|
316
|
+
<img
|
|
317
|
+
src={item.afterImageUrl}
|
|
318
|
+
alt={item.afterAlt || 'After'}
|
|
319
|
+
className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-300 ${
|
|
320
|
+
showAfter ? 'opacity-100' : 'opacity-0'
|
|
321
|
+
}`}
|
|
322
|
+
/>
|
|
323
|
+
|
|
324
|
+
{/* Label */}
|
|
325
|
+
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 px-3 py-1 bg-black/60 text-white text-sm rounded">
|
|
326
|
+
{showAfter ? 'After' : 'Before'} (hover to toggle)
|
|
327
|
+
</div>
|
|
328
|
+
</div>
|
|
329
|
+
|
|
330
|
+
{/* Content */}
|
|
331
|
+
{(item.title || item.description) && (
|
|
332
|
+
<div className="p-4">
|
|
333
|
+
{item.title && (
|
|
334
|
+
<h3 className="text-lg font-semibold text-gray-900 mb-1">{item.title}</h3>
|
|
335
|
+
)}
|
|
336
|
+
{item.description && (
|
|
337
|
+
<p className="text-gray-600 text-sm">{item.description}</p>
|
|
338
|
+
)}
|
|
339
|
+
</div>
|
|
340
|
+
)}
|
|
341
|
+
</div>
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export default BeforeAfter;
|
|
@@ -53,7 +53,7 @@ export function FAQ({
|
|
|
53
53
|
|
|
54
54
|
if (variant === 'cards') {
|
|
55
55
|
return (
|
|
56
|
-
<section id={id} className={`py-16 ${className}`}>
|
|
56
|
+
<section id={id} className={`py-16 scroll-mt-20 ${className}`}>
|
|
57
57
|
<div className="container mx-auto px-4">
|
|
58
58
|
<SectionHeader title={title} subtitle={subtitle} />
|
|
59
59
|
<div className="grid md:grid-cols-2 gap-6 max-w-4xl mx-auto">
|
|
@@ -68,7 +68,7 @@ export function FAQ({
|
|
|
68
68
|
|
|
69
69
|
if (variant === 'simple') {
|
|
70
70
|
return (
|
|
71
|
-
<section id={id} className={`py-16 ${className}`}>
|
|
71
|
+
<section id={id} className={`py-16 scroll-mt-20 ${className}`}>
|
|
72
72
|
<div className="container mx-auto px-4">
|
|
73
73
|
<SectionHeader title={title} subtitle={subtitle} />
|
|
74
74
|
<div className="max-w-3xl mx-auto space-y-8">
|
|
@@ -83,7 +83,7 @@ export function FAQ({
|
|
|
83
83
|
|
|
84
84
|
// Default: accordion variant
|
|
85
85
|
return (
|
|
86
|
-
<section id={id} className={`py-16 bg-gray-50 ${className}`}>
|
|
86
|
+
<section id={id} className={`py-16 scroll-mt-20 bg-gray-50 ${className}`}>
|
|
87
87
|
<div className="container mx-auto px-4">
|
|
88
88
|
<SectionHeader title={title} subtitle={subtitle} />
|
|
89
89
|
<div className="max-w-3xl mx-auto">
|
|
@@ -16,8 +16,10 @@ export interface GalleryProps {
|
|
|
16
16
|
columns?: 2 | 3 | 4;
|
|
17
17
|
variant?: 'grid' | 'masonry' | 'carousel';
|
|
18
18
|
showCaptions?: boolean;
|
|
19
|
+
enableLightbox?: boolean;
|
|
19
20
|
limit?: number;
|
|
20
21
|
className?: string;
|
|
22
|
+
id?: string;
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
/**
|
|
@@ -30,11 +32,14 @@ export function Gallery({
|
|
|
30
32
|
columns = 3,
|
|
31
33
|
variant = 'grid',
|
|
32
34
|
showCaptions = true,
|
|
35
|
+
enableLightbox = true,
|
|
33
36
|
limit,
|
|
34
37
|
className = '',
|
|
38
|
+
id = 'gallery',
|
|
35
39
|
}: GalleryProps) {
|
|
36
40
|
const displayedItems = limit ? items.slice(0, limit) : items;
|
|
37
41
|
const [failedImages, setFailedImages] = useState<Set<string>>(new Set());
|
|
42
|
+
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
|
38
43
|
|
|
39
44
|
const handleImageError = (id: string) => {
|
|
40
45
|
setFailedImages((prev) => new Set(prev).add(id));
|
|
@@ -46,8 +51,28 @@ export function Gallery({
|
|
|
46
51
|
4: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-4',
|
|
47
52
|
};
|
|
48
53
|
|
|
54
|
+
const openLightbox = (index: number) => {
|
|
55
|
+
if (enableLightbox) {
|
|
56
|
+
setLightboxIndex(index);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const closeLightbox = () => setLightboxIndex(null);
|
|
61
|
+
|
|
62
|
+
const goToPrevious = () => {
|
|
63
|
+
if (lightboxIndex !== null) {
|
|
64
|
+
setLightboxIndex(lightboxIndex === 0 ? displayedItems.length - 1 : lightboxIndex - 1);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const goToNext = () => {
|
|
69
|
+
if (lightboxIndex !== null) {
|
|
70
|
+
setLightboxIndex(lightboxIndex === displayedItems.length - 1 ? 0 : lightboxIndex + 1);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
49
74
|
return (
|
|
50
|
-
<section className={`py-16 ${className}`}>
|
|
75
|
+
<section id={id} className={`py-16 scroll-mt-20 ${className}`}>
|
|
51
76
|
<div className="container mx-auto px-4">
|
|
52
77
|
{/* Header */}
|
|
53
78
|
{(title || subtitle) && (
|
|
@@ -63,14 +88,18 @@ export function Gallery({
|
|
|
63
88
|
|
|
64
89
|
{/* Gallery Grid */}
|
|
65
90
|
<div className={`grid gap-4 ${gridCols[columns]}`}>
|
|
66
|
-
{displayedItems.map((item) => {
|
|
91
|
+
{displayedItems.map((item, index) => {
|
|
67
92
|
const itemId = item.id || item.imageUrl;
|
|
68
93
|
const hasFailed = failedImages.has(itemId);
|
|
69
94
|
|
|
70
95
|
return (
|
|
71
96
|
<div
|
|
72
97
|
key={itemId}
|
|
73
|
-
className=
|
|
98
|
+
className={`group relative aspect-square overflow-hidden rounded-lg bg-gray-100 ${enableLightbox ? 'cursor-pointer' : ''}`}
|
|
99
|
+
onClick={() => openLightbox(index)}
|
|
100
|
+
role={enableLightbox ? 'button' : undefined}
|
|
101
|
+
tabIndex={enableLightbox ? 0 : undefined}
|
|
102
|
+
onKeyDown={enableLightbox ? (e) => e.key === 'Enter' && openLightbox(index) : undefined}
|
|
74
103
|
>
|
|
75
104
|
{hasFailed ? (
|
|
76
105
|
<div className="absolute inset-0 flex items-center justify-center text-gray-400">
|
|
@@ -85,8 +114,16 @@ export function Gallery({
|
|
|
85
114
|
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
|
86
115
|
onError={() => handleImageError(itemId)}
|
|
87
116
|
/>
|
|
117
|
+
{/* Hover overlay with zoom icon */}
|
|
118
|
+
{enableLightbox && (
|
|
119
|
+
<div className="absolute inset-0 bg-black/30 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-center justify-center">
|
|
120
|
+
<svg className="w-10 h-10 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
121
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v3m0 0v3m0-3h3m-3 0H7" />
|
|
122
|
+
</svg>
|
|
123
|
+
</div>
|
|
124
|
+
)}
|
|
88
125
|
{/* Caption overlay */}
|
|
89
|
-
{showCaptions && item.caption && (
|
|
126
|
+
{showCaptions && item.caption && !enableLightbox && (
|
|
90
127
|
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
|
91
128
|
<div className="absolute bottom-0 left-0 right-0 p-4">
|
|
92
129
|
<p className="text-white text-sm">{item.caption}</p>
|
|
@@ -100,6 +137,69 @@ export function Gallery({
|
|
|
100
137
|
})}
|
|
101
138
|
</div>
|
|
102
139
|
</div>
|
|
140
|
+
|
|
141
|
+
{/* Lightbox Modal */}
|
|
142
|
+
{enableLightbox && lightboxIndex !== null && (
|
|
143
|
+
<div
|
|
144
|
+
className="fixed inset-0 z-50 bg-black/90 flex items-center justify-center"
|
|
145
|
+
onClick={closeLightbox}
|
|
146
|
+
>
|
|
147
|
+
{/* Close button */}
|
|
148
|
+
<button
|
|
149
|
+
onClick={closeLightbox}
|
|
150
|
+
className="absolute top-4 right-4 text-white hover:text-gray-300 transition-colors z-10"
|
|
151
|
+
aria-label="Close lightbox"
|
|
152
|
+
>
|
|
153
|
+
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
154
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
155
|
+
</svg>
|
|
156
|
+
</button>
|
|
157
|
+
|
|
158
|
+
{/* Previous button */}
|
|
159
|
+
<button
|
|
160
|
+
onClick={(e) => { e.stopPropagation(); goToPrevious(); }}
|
|
161
|
+
className="absolute left-4 top-1/2 -translate-y-1/2 text-white hover:text-gray-300 transition-colors z-10"
|
|
162
|
+
aria-label="Previous image"
|
|
163
|
+
>
|
|
164
|
+
<svg className="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
165
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
|
166
|
+
</svg>
|
|
167
|
+
</button>
|
|
168
|
+
|
|
169
|
+
{/* Next button */}
|
|
170
|
+
<button
|
|
171
|
+
onClick={(e) => { e.stopPropagation(); goToNext(); }}
|
|
172
|
+
className="absolute right-4 top-1/2 -translate-y-1/2 text-white hover:text-gray-300 transition-colors z-10"
|
|
173
|
+
aria-label="Next image"
|
|
174
|
+
>
|
|
175
|
+
<svg className="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
176
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
|
177
|
+
</svg>
|
|
178
|
+
</button>
|
|
179
|
+
|
|
180
|
+
{/* Image container */}
|
|
181
|
+
<div
|
|
182
|
+
className="max-w-5xl max-h-[85vh] mx-4"
|
|
183
|
+
onClick={(e) => e.stopPropagation()}
|
|
184
|
+
>
|
|
185
|
+
<img
|
|
186
|
+
src={displayedItems[lightboxIndex].imageUrl}
|
|
187
|
+
alt={displayedItems[lightboxIndex].alt || ''}
|
|
188
|
+
className="max-w-full max-h-[85vh] object-contain"
|
|
189
|
+
/>
|
|
190
|
+
{showCaptions && displayedItems[lightboxIndex].caption && (
|
|
191
|
+
<p className="text-white text-center mt-4 text-lg">
|
|
192
|
+
{displayedItems[lightboxIndex].caption}
|
|
193
|
+
</p>
|
|
194
|
+
)}
|
|
195
|
+
</div>
|
|
196
|
+
|
|
197
|
+
{/* Image counter */}
|
|
198
|
+
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 text-white text-sm">
|
|
199
|
+
{lightboxIndex + 1} / {displayedItems.length}
|
|
200
|
+
</div>
|
|
201
|
+
</div>
|
|
202
|
+
)}
|
|
103
203
|
</section>
|
|
104
204
|
);
|
|
105
205
|
}
|