@thisiscrowd/crowdbox 1.0.0

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.
@@ -0,0 +1,43 @@
1
+ export class EventEmitter {
2
+ constructor() {
3
+ this._events = Object.create(null);
4
+ }
5
+
6
+ on(event, listener) {
7
+ if (!this._events[event]) this._events[event] = [];
8
+ this._events[event].push(listener);
9
+ return this;
10
+ }
11
+
12
+ once(event, listener) {
13
+ const wrapper = (...args) => {
14
+ listener(...args);
15
+ this.off(event, wrapper);
16
+ };
17
+ wrapper._original = listener;
18
+ return this.on(event, wrapper);
19
+ }
20
+
21
+ off(event, listener) {
22
+ if (!this._events[event]) return this;
23
+ this._events[event] = this._events[event].filter(
24
+ (l) => l !== listener && l._original !== listener
25
+ );
26
+ return this;
27
+ }
28
+
29
+ emit(event, ...args) {
30
+ if (!this._events[event]) return false;
31
+ [...this._events[event]].forEach((l) => l(...args));
32
+ return true;
33
+ }
34
+
35
+ removeAllListeners(event) {
36
+ if (event) {
37
+ delete this._events[event];
38
+ } else {
39
+ this._events = Object.create(null);
40
+ }
41
+ return this;
42
+ }
43
+ }
@@ -0,0 +1,2 @@
1
+ export { Crowdbox, LightboxGallery } from './Crowdbox.js';
2
+ export { Crowdbox as default } from './Crowdbox.js';
@@ -0,0 +1,26 @@
1
+ // Central registry for media adapters and plugins so the host app
2
+ // can swap or extend them without forking the library.
3
+ const adapters = new Map();
4
+ const plugins = new Map();
5
+
6
+ export const Registry = {
7
+ registerAdapter(type, adapter) {
8
+ adapters.set(type, adapter);
9
+ },
10
+
11
+ getAdapter(type) {
12
+ return adapters.get(type) ?? null;
13
+ },
14
+
15
+ registerPlugin(name, plugin) {
16
+ plugins.set(name, plugin);
17
+ },
18
+
19
+ getPlugin(name) {
20
+ return plugins.get(name) ?? null;
21
+ },
22
+
23
+ getPlugins() {
24
+ return [...plugins.values()];
25
+ },
26
+ };
@@ -0,0 +1,115 @@
1
+ export function createElement(tag, attrs = {}, ...children) {
2
+ const el = document.createElement(tag);
3
+ for (const [k, v] of Object.entries(attrs)) {
4
+ if (k === 'class') el.className = v;
5
+ else if (k.startsWith('data-')) el.dataset[k.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = v;
6
+ else if (k === 'aria' || k.startsWith('aria-')) el.setAttribute(k, v);
7
+ else el[k] = v;
8
+ }
9
+ children.flat().forEach((child) => {
10
+ if (child == null) return;
11
+ el.appendChild(typeof child === 'string' ? document.createTextNode(child) : child);
12
+ });
13
+ return el;
14
+ }
15
+
16
+ export function mergeDeep(target, ...sources) {
17
+ for (const src of sources) {
18
+ for (const key of Object.keys(src ?? {})) {
19
+ if (src[key] && typeof src[key] === 'object' && !Array.isArray(src[key])) {
20
+ if (!target[key] || typeof target[key] !== 'object') target[key] = {};
21
+ mergeDeep(target[key], src[key]);
22
+ } else {
23
+ target[key] = src[key];
24
+ }
25
+ }
26
+ }
27
+ return target;
28
+ }
29
+
30
+ export function getYouTubeId(url) {
31
+ const m = url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/|shorts\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/);
32
+ return m ? m[1] : null;
33
+ }
34
+
35
+ export function getVimeoId(url) {
36
+ const m = url.match(/vimeo\.com\/(?:video\/)?(\d+)/);
37
+ return m ? m[1] : null;
38
+ }
39
+
40
+ export function isVideoUrl(url) {
41
+ return /\.(mp4|webm|ogg|mov)(\?.*)?$/i.test(url);
42
+ }
43
+
44
+ export function isYouTubeUrl(url) {
45
+ return /youtube\.com|youtu\.be/.test(url);
46
+ }
47
+
48
+ export function isVimeoUrl(url) {
49
+ return /vimeo\.com/.test(url);
50
+ }
51
+
52
+ export function detectMediaType(src) {
53
+ if (!src) return 'unknown';
54
+ if (isYouTubeUrl(src)) return 'youtube';
55
+ if (isVimeoUrl(src)) return 'vimeo';
56
+ if (isVideoUrl(src)) return 'video';
57
+ if (/\.(jpg|jpeg|png|gif|webp|avif|svg)(\?.*)?$/i.test(src)) return 'image';
58
+ return 'iframe';
59
+ }
60
+
61
+ export function trapFocus(container) {
62
+ const focusable = container.querySelectorAll(
63
+ 'a[href],button:not([disabled]),textarea,input,select,[tabindex]:not([tabindex="-1"])'
64
+ );
65
+ const first = focusable[0];
66
+ const last = focusable[focusable.length - 1];
67
+
68
+ function handler(e) {
69
+ if (e.key !== 'Tab') return;
70
+ if (e.shiftKey) {
71
+ if (document.activeElement === first) { e.preventDefault(); last?.focus(); }
72
+ } else {
73
+ if (document.activeElement === last) { e.preventDefault(); first?.focus(); }
74
+ }
75
+ }
76
+ container.addEventListener('keydown', handler);
77
+ return () => container.removeEventListener('keydown', handler);
78
+ }
79
+
80
+ export function requestFullscreen(el) {
81
+ return (el.requestFullscreen || el.webkitRequestFullscreen || el.mozRequestFullScreen)?.call(el);
82
+ }
83
+
84
+ export function exitFullscreen() {
85
+ return (document.exitFullscreen || document.webkitExitFullscreen || document.mozCancelFullScreen)?.call(document);
86
+ }
87
+
88
+ export function isFullscreen() {
89
+ return !!(document.fullscreenElement || document.webkitFullscreenElement || document.mozFullScreenElement);
90
+ }
91
+
92
+ export function clamp(value, min, max) {
93
+ return Math.min(Math.max(value, min), max);
94
+ }
95
+
96
+ export function noop() {}
97
+
98
+ export function debounce(fn, ms) {
99
+ let t;
100
+ return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
101
+ }
102
+
103
+ export function isTouchDevice() {
104
+ return 'ontouchstart' in window || navigator.maxTouchPoints > 0;
105
+ }
106
+
107
+ export function supportsPassive() {
108
+ let ok = false;
109
+ try {
110
+ window.addEventListener('test', null, Object.defineProperty({}, 'passive', { get() { ok = true; } }));
111
+ } catch (_) {}
112
+ return ok;
113
+ }
114
+
115
+ export const passiveOpt = supportsPassive() ? { passive: true } : false;
package/src/index.js ADDED
@@ -0,0 +1,61 @@
1
+ // Core
2
+ export { Crowdbox, LightboxGallery } from './core/Crowdbox.js';
3
+ export { EventEmitter } from './core/EventEmitter.js';
4
+ export { Registry } from './core/Registry.js';
5
+
6
+ // Adapters
7
+ import { ImageAdapter } from './adapters/image.js';
8
+ import { VideoAdapter } from './adapters/video.js';
9
+ import { YouTubeAdapter } from './adapters/youtube.js';
10
+ import { VimeoAdapter } from './adapters/vimeo.js';
11
+ import { IframeAdapter } from './adapters/iframe.js';
12
+
13
+ // Plugins
14
+ import { ZoomPlugin } from './plugins/zoom/zoom.js';
15
+ import { ThumbsPlugin } from './plugins/thumbs/thumbs.js';
16
+ import { FullscreenPlugin } from './plugins/fullscreen/fullscreen.js';
17
+ import { DownloadPlugin } from './plugins/download/download.js';
18
+ import { SharePlugin } from './plugins/share/share.js';
19
+
20
+ export { ImageAdapter, VideoAdapter, YouTubeAdapter, VimeoAdapter, IframeAdapter };
21
+ export { ZoomPlugin, ThumbsPlugin, FullscreenPlugin, DownloadPlugin, SharePlugin };
22
+
23
+ // Auto-register everything so the library works out of the box
24
+ import { Registry as Reg } from './core/Registry.js';
25
+
26
+ Reg.registerAdapter('image', ImageAdapter);
27
+ Reg.registerAdapter('video', VideoAdapter);
28
+ Reg.registerAdapter('youtube', YouTubeAdapter);
29
+ Reg.registerAdapter('vimeo', VimeoAdapter);
30
+ Reg.registerAdapter('iframe', IframeAdapter);
31
+
32
+ Reg.registerPlugin('zoom', ZoomPlugin);
33
+ Reg.registerPlugin('thumbnails', ThumbsPlugin);
34
+ Reg.registerPlugin('fullscreen', FullscreenPlugin);
35
+ Reg.registerPlugin('download', DownloadPlugin);
36
+ Reg.registerPlugin('share', SharePlugin);
37
+
38
+ // ─── Convenience factory ─────────────────────────────────────────────────────
39
+ import { Crowdbox } from './core/Crowdbox.js';
40
+
41
+ /**
42
+ * Create a Crowdbox instance and return it.
43
+ * This is the primary public API for vanilla / script-tag usage.
44
+ */
45
+ export function createGallery(options = {}) {
46
+ return new Crowdbox(options);
47
+ }
48
+
49
+ // Auto-init from [data-lgx-init] attribute
50
+ if (typeof document !== 'undefined') {
51
+ document.addEventListener('DOMContentLoaded', () => {
52
+ document.querySelectorAll('[data-lgx-init]').forEach((root) => {
53
+ try {
54
+ const opts = JSON.parse(root.dataset.lgxInit || '{}');
55
+ new Crowdbox({ selector: `#${root.id} [data-lgx]`, ...opts });
56
+ } catch (_) {}
57
+ });
58
+ });
59
+ }
60
+
61
+ export default Crowdbox;
@@ -0,0 +1,50 @@
1
+ export class DownloadPlugin {
2
+ static pluginName = 'download';
3
+
4
+ constructor(gallery) {
5
+ this.gallery = gallery;
6
+ this._btn = null;
7
+ }
8
+
9
+ init(gallery) {
10
+ this.gallery = gallery;
11
+ }
12
+
13
+ buildToolbar(toolbar) {
14
+ if (!this.gallery.opts.download) return;
15
+
16
+ this._btn = document.createElement('button');
17
+ this._btn.type = 'button';
18
+ this._btn.className = 'lgx-btn lgx-btn--download';
19
+ this._btn.setAttribute('aria-label', this.gallery.opts.i18n.download);
20
+ this._btn.title = this.gallery.opts.i18n.download;
21
+ this._btn.innerHTML = this.gallery._svgIcon('download').outerHTML;
22
+ this._btn.addEventListener('click', () => this._download());
23
+
24
+ const close = toolbar.querySelector('.lgx-btn--close');
25
+ toolbar.insertBefore(this._btn, close);
26
+ }
27
+
28
+ afterSlide(index, item) {
29
+ if (!this._btn) return;
30
+ // Hide download for video embeds where we can't trigger a download
31
+ const canDownload = ['image', 'video'].includes(item.type);
32
+ this._btn.style.display = canDownload ? '' : 'none';
33
+ }
34
+
35
+ _download() {
36
+ const item = this.gallery._items[this.gallery._index];
37
+ if (!item) return;
38
+ const url = item.download || item.src;
39
+ const a = document.createElement('a');
40
+ a.href = url;
41
+ a.download = item.downloadName || url.split('/').pop() || 'download';
42
+ a.style.display = 'none';
43
+ document.body.appendChild(a);
44
+ a.click();
45
+ document.body.removeChild(a);
46
+ this.gallery.emit('download', { item });
47
+ }
48
+
49
+ destroy() {}
50
+ }
@@ -0,0 +1,65 @@
1
+ import { requestFullscreen, exitFullscreen, isFullscreen } from '../../core/utils.js';
2
+
3
+ export class FullscreenPlugin {
4
+ static pluginName = 'fullscreen';
5
+
6
+ constructor(gallery) {
7
+ this.gallery = gallery;
8
+ this._btn = null;
9
+ this._onChange = null;
10
+ }
11
+
12
+ init(gallery) {
13
+ this.gallery = gallery;
14
+ }
15
+
16
+ buildToolbar(toolbar) {
17
+ if (!this.gallery.opts.fullscreen) return;
18
+
19
+ this._btn = document.createElement('button');
20
+ this._btn.type = 'button';
21
+ this._btn.className = 'lgx-btn lgx-btn--fullscreen';
22
+ this._btn.setAttribute('aria-label', this.gallery.opts.i18n.fullscreen);
23
+ this._btn.title = this.gallery.opts.i18n.fullscreen;
24
+ this._btn.innerHTML = this.gallery._svgIcon('fullscreen').outerHTML;
25
+ this._btn.addEventListener('click', () => this.toggle());
26
+
27
+ const close = toolbar.querySelector('.lgx-btn--close');
28
+ toolbar.insertBefore(this._btn, close);
29
+
30
+ this._onChange = () => this._updateBtn();
31
+ document.addEventListener('fullscreenchange', this._onChange);
32
+ document.addEventListener('webkitfullscreenchange', this._onChange);
33
+ }
34
+
35
+ toggle() {
36
+ if (isFullscreen()) {
37
+ exitFullscreen();
38
+ } else {
39
+ requestFullscreen(this.gallery._dom.modal);
40
+ }
41
+ }
42
+
43
+ toggleFullscreen() {
44
+ this.toggle();
45
+ }
46
+
47
+ _updateBtn() {
48
+ if (!this._btn) return;
49
+ const full = isFullscreen();
50
+ this._btn.setAttribute('aria-pressed', full ? 'true' : 'false');
51
+ this.gallery._dom.modal?.classList.toggle('lgx-modal--fullscreen', full);
52
+ }
53
+
54
+ afterClose() {
55
+ if (isFullscreen()) exitFullscreen();
56
+ if (this._onChange) {
57
+ document.removeEventListener('fullscreenchange', this._onChange);
58
+ document.removeEventListener('webkitfullscreenchange', this._onChange);
59
+ }
60
+ }
61
+
62
+ destroy() {
63
+ this.afterClose();
64
+ }
65
+ }
@@ -0,0 +1,63 @@
1
+ export class SharePlugin {
2
+ static pluginName = 'share';
3
+
4
+ constructor(gallery) {
5
+ this.gallery = gallery;
6
+ this._btn = null;
7
+ }
8
+
9
+ init(gallery) {
10
+ this.gallery = gallery;
11
+ }
12
+
13
+ buildToolbar(toolbar) {
14
+ if (!this.gallery.opts.share) return;
15
+
16
+ this._btn = document.createElement('button');
17
+ this._btn.type = 'button';
18
+ this._btn.className = 'lgx-btn lgx-btn--share';
19
+ this._btn.setAttribute('aria-label', this.gallery.opts.i18n.share);
20
+ this._btn.title = this.gallery.opts.i18n.share;
21
+ this._btn.innerHTML = this.gallery._svgIcon('share').outerHTML;
22
+ this._btn.addEventListener('click', () => this._share());
23
+
24
+ const close = toolbar.querySelector('.lgx-btn--close');
25
+ toolbar.insertBefore(this._btn, close);
26
+ }
27
+
28
+ async _share() {
29
+ const item = this.gallery._items[this.gallery._index];
30
+ if (!item) return;
31
+
32
+ const shareData = {
33
+ title: item.caption || document.title,
34
+ url: item.shareUrl || item.src,
35
+ };
36
+
37
+ // Native Web Share API (mobile)
38
+ if (navigator.share) {
39
+ try {
40
+ await navigator.share(shareData);
41
+ this.gallery.emit('share', { item, method: 'native' });
42
+ return;
43
+ } catch (_) {}
44
+ }
45
+
46
+ // Fallback: copy URL to clipboard
47
+ if (navigator.clipboard) {
48
+ await navigator.clipboard.writeText(shareData.url);
49
+ this._showToast('Link copied!');
50
+ this.gallery.emit('share', { item, method: 'clipboard' });
51
+ }
52
+ }
53
+
54
+ _showToast(msg) {
55
+ const toast = document.createElement('div');
56
+ toast.className = 'lgx-toast';
57
+ toast.textContent = msg;
58
+ this.gallery._dom.modal?.appendChild(toast);
59
+ setTimeout(() => toast.remove(), 2500);
60
+ }
61
+
62
+ destroy() {}
63
+ }
@@ -0,0 +1,108 @@
1
+ export class ThumbsPlugin {
2
+ static pluginName = 'thumbnails';
3
+
4
+ constructor(gallery) {
5
+ this.gallery = gallery;
6
+ this._strip = null;
7
+ this._thumbEls = [];
8
+ this._observer = null;
9
+ }
10
+
11
+ init(gallery) {
12
+ this.gallery = gallery;
13
+ }
14
+
15
+ afterBuildDOM() {
16
+ if (!this.gallery.opts.thumbnails) return;
17
+ if (this.gallery._items.length < 2) return;
18
+
19
+ this._buildStrip();
20
+ }
21
+
22
+ afterSlide(index) {
23
+ this._activate(index);
24
+ }
25
+
26
+ _buildStrip() {
27
+ const gallery = this.gallery;
28
+ const strip = document.createElement('div');
29
+ strip.className = 'lgx-thumbs';
30
+ strip.setAttribute('role', 'tablist');
31
+ strip.setAttribute('aria-label', 'Gallery thumbnails');
32
+
33
+ this._thumbEls = gallery._items.map((item, i) => {
34
+ const thumb = document.createElement('button');
35
+ thumb.type = 'button';
36
+ thumb.className = 'lgx-thumb';
37
+ thumb.setAttribute('role', 'tab');
38
+ thumb.setAttribute('aria-label', `Slide ${i + 1}${item.caption ? ': ' + item.caption : ''}`);
39
+ thumb.setAttribute('aria-selected', i === gallery._index ? 'true' : 'false');
40
+ thumb.dataset.lgxThumbIndex = i;
41
+
42
+ const adapter = gallery._resolveAdapter(item);
43
+ const thumbSrc = adapter?.getThumbnail(item);
44
+
45
+ if (thumbSrc) {
46
+ const img = document.createElement('img');
47
+ img.alt = '';
48
+ img.loading = 'lazy';
49
+
50
+ // IntersectionObserver lazy load
51
+ if ('IntersectionObserver' in window) {
52
+ img.dataset.src = thumbSrc;
53
+ this._observe(img);
54
+ } else {
55
+ img.src = thumbSrc;
56
+ }
57
+
58
+ thumb.appendChild(img);
59
+ } else {
60
+ // Video/iframe fallback: show index number
61
+ thumb.appendChild(document.createTextNode(i + 1));
62
+ thumb.classList.add('lgx-thumb--no-image');
63
+ }
64
+
65
+ thumb.addEventListener('click', () => gallery.goTo(i));
66
+ return thumb;
67
+ });
68
+
69
+ this._thumbEls.forEach((t) => strip.appendChild(t));
70
+ gallery._dom.container.appendChild(strip);
71
+ gallery._dom.thumbStrip = strip;
72
+ this._strip = strip;
73
+
74
+ this._activate(gallery._index);
75
+ }
76
+
77
+ _activate(index) {
78
+ this._thumbEls.forEach((t, i) => {
79
+ const active = i === index;
80
+ t.classList.toggle('lgx-thumb--active', active);
81
+ t.setAttribute('aria-selected', active ? 'true' : 'false');
82
+ });
83
+
84
+ // Scroll active thumb into view
85
+ const activeThumb = this._thumbEls[index];
86
+ if (activeThumb && this._strip) {
87
+ activeThumb.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
88
+ }
89
+ }
90
+
91
+ _observe(img) {
92
+ if (!this._observer) {
93
+ this._observer = new IntersectionObserver((entries) => {
94
+ entries.forEach((e) => {
95
+ if (e.isIntersecting) {
96
+ e.target.src = e.target.dataset.src;
97
+ this._observer.unobserve(e.target);
98
+ }
99
+ });
100
+ }, { rootMargin: '100px' });
101
+ }
102
+ this._observer.observe(img);
103
+ }
104
+
105
+ destroy() {
106
+ this._observer?.disconnect();
107
+ }
108
+ }
@@ -0,0 +1,51 @@
1
+ export class ZoomPlugin {
2
+ static pluginName = 'zoom';
3
+
4
+ constructor(gallery) {
5
+ this.gallery = gallery;
6
+ this._btnIn = null;
7
+ this._btnOut = null;
8
+ }
9
+
10
+ init(gallery) {
11
+ this.gallery = gallery;
12
+ }
13
+
14
+ buildToolbar(toolbar) {
15
+ if (!this.gallery.opts.zoom) return;
16
+
17
+ this._btnIn = this._makeBtn('zoomin', this.gallery.opts.i18n.zoomIn, () => this.gallery.zoomIn());
18
+ this._btnOut = this._makeBtn('zoomout', this.gallery.opts.i18n.zoomOut, () => this.gallery.zoomOut());
19
+
20
+ // Insert before close button
21
+ const close = toolbar.querySelector('.lgx-btn--close');
22
+ toolbar.insertBefore(this._btnOut, close);
23
+ toolbar.insertBefore(this._btnIn, this._btnOut);
24
+ }
25
+
26
+ afterSlide(index, item) {
27
+ const isImage = item.type === 'image';
28
+ if (this._btnIn) this._btnIn.style.display = isImage ? '' : 'none';
29
+ if (this._btnOut) this._btnOut.style.display = isImage ? '' : 'none';
30
+ }
31
+
32
+ afterZoom(zoom) {
33
+ const min = this.gallery.opts.zoomMin;
34
+ const max = this.gallery.opts.zoomMax;
35
+ if (this._btnIn) this._btnIn.disabled = zoom >= max;
36
+ if (this._btnOut) this._btnOut.disabled = zoom <= min;
37
+ }
38
+
39
+ _makeBtn(iconName, label, onClick) {
40
+ const btn = document.createElement('button');
41
+ btn.type = 'button';
42
+ btn.className = `lgx-btn lgx-btn--${iconName}`;
43
+ btn.setAttribute('aria-label', label);
44
+ btn.title = label;
45
+ btn.innerHTML = this.gallery._svgIcon(iconName).outerHTML;
46
+ btn.addEventListener('click', onClick);
47
+ return btn;
48
+ }
49
+
50
+ destroy() {}
51
+ }
@@ -0,0 +1,75 @@
1
+ import { useState, useCallback } from 'react';
2
+ import { Lightbox } from './Lightbox.jsx';
3
+
4
+ /**
5
+ * A ready-to-use responsive grid that opens a Lightbox on click.
6
+ *
7
+ * Props:
8
+ * items GalleryItem[]
9
+ * columns number (default 3)
10
+ * gap number in px (default 8)
11
+ * lightboxOptions CrowdboxOptions
12
+ * className string
13
+ */
14
+ export function GalleryGrid({ items = [], columns = 3, gap = 8, lightboxOptions = {}, className = '' }) {
15
+ const [lightboxOpen, setLightboxOpen] = useState(false);
16
+ const [activeIndex, setActiveIndex] = useState(0);
17
+
18
+ const handleClick = useCallback((index) => {
19
+ setActiveIndex(index);
20
+ setLightboxOpen(true);
21
+ }, []);
22
+
23
+ const gridStyle = {
24
+ display: 'grid',
25
+ gridTemplateColumns: `repeat(${columns}, 1fr)`,
26
+ gap: `${gap}px`,
27
+ };
28
+
29
+ return (
30
+ <>
31
+ <div className={`lgx-grid ${className}`} style={gridStyle}>
32
+ {items.map((item, i) => {
33
+ const thumb = item.thumb || (item.type === 'image' ? item.src : null);
34
+ return (
35
+ <button
36
+ key={i}
37
+ type="button"
38
+ className="lgx-grid__item"
39
+ onClick={() => handleClick(i)}
40
+ aria-label={item.caption || `Open item ${i + 1}`}
41
+ style={{
42
+ padding: 0,
43
+ border: 'none',
44
+ background: '#111',
45
+ cursor: 'pointer',
46
+ overflow: 'hidden',
47
+ borderRadius: 4,
48
+ aspectRatio: '1',
49
+ }}
50
+ >
51
+ {thumb ? (
52
+ <img
53
+ src={thumb}
54
+ alt={item.alt || item.caption || ''}
55
+ loading="lazy"
56
+ style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
57
+ />
58
+ ) : (
59
+ <span style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', color: '#fff', fontSize: 24 }}>▶</span>
60
+ )}
61
+ </button>
62
+ );
63
+ })}
64
+ </div>
65
+
66
+ <Lightbox
67
+ items={items}
68
+ open={lightboxOpen}
69
+ startIndex={activeIndex}
70
+ onClose={() => setLightboxOpen(false)}
71
+ {...lightboxOptions}
72
+ />
73
+ </>
74
+ );
75
+ }