1/** 2 * Product Media Gallery Alpine.js Component 3 * 4 * Manages product image gallery with: 5 * - Main image carousel with thumbnails 6 * - Lightbox/modal for full-size viewing 7 * - Keyboard navigation 8 * - Responsive mobile/desktop layouts 9 * 10 * @returns {Object} Alpine component definition 11 */ 12function productMedia() { 13 return { 14 // State 15 currentIndex: 0, 16 showLightbox: false, 17 swiperReady: false, 18 // Tracks the first (eager) gallery image's own load state, independent of 19 // Swiper init, so the skeleton clears as soon as the image paints rather 20 // than waiting on deferred Alpine/Swiper bootstrapping. 21 heroLoaded: false, 22 totalImages: 0, 23 mainSwiper: null, 24 lightboxSwiper: null, 25 thumbnailSwiper: null, 26 optionRelationMap: {}, 27 28 /** 29 * Initialize component 30 */ 31 init() { 32 // Count slides only from main swiper (before Swiper creates loop duplicates) 33 this.totalImages = this.$refs.mainSwiper ? this.$refs.mainSwiper.querySelectorAll('.swiper-slide').length : 0; 34 35 // The hero image is eager-loaded and often finishes (or is served from 36 // cache) before Alpine attaches its @load listener. Catch that case here 37 // so the skeleton doesn't get stuck covering an already-painted image. 38 if (this.$refs.heroImage && this.$refs.heroImage.complete && this.$refs.heroImage.naturalWidth > 0) { 39 this.heroLoaded = true; 40 } 41 42 this.buildOptionRelationMap(); 43 44 this.$nextTick(() => { 45 this.initializeSwipers(); 46 this.setupKeyboardNav(); 47 this.setupOptionRelationListener(); 48 // Mark swiper as ready to hide skeleton 49 this.swiperReady = true; 50 }); 51 }, 52 53 /** 54 * Initialize all Swiper instances 55 */ 56 initializeSwipers() { 57 // Main image swiper (desktop and mobile) 58 if (this.$refs.mainSwiper) { 59 const mainSwiperEl = this.$refs.mainSwiper; 60 const prevBtn = mainSwiperEl.querySelector('.main-swiper-button-prev'); 61 const nextBtn = mainSwiperEl.querySelector('.main-swiper-button-next'); 62 const paginationEl = mainSwiperEl.closest('.main-image-container').querySelector('.main-swiper-pagination'); 63 const slideCount = mainSwiperEl.querySelectorAll('.swiper-slide').length; 64 65 this.mainSwiper = new Swiper(mainSwiperEl, { 66 slidesPerView: 1, 67 spaceBetween: 0, 68 loop: slideCount > 1, 69 preloadImages: true, 70 updateOnImagesReady: true, 71 navigation: { 72 nextEl: nextBtn, 73 prevEl: prevBtn, 74 }, 75 pagination: { 76 el: paginationEl, 77 clickable: true, 78 dynamicBullets: true, 79 }, 80 on: { 81 slideChange: (swiper) => { 82 this.currentIndex = swiper.realIndex; 83 // Sync thumbnail swiper to keep active thumbnail visible 84 this.scrollThumbnailIntoView(swiper.realIndex); 85 // Sync lightbox if open (check index to prevent infinite loop) 86 if (this.showLightbox && this.lightboxSwiper && this.lightboxSwiper.realIndex !== swiper.realIndex) { 87 if (this.lightboxSwiper.params.loop) { 88 this.lightboxSwiper.slideToLoop(swiper.realIndex); 89 } else { 90 this.lightboxSwiper.slideTo(swiper.realIndex); 91 } 92 } 93 } 94 } 95 }); 96 } 97 98 // Thumbnail swiper (desktop only) 99 if (this.$refs.thumbSwiper && this.totalImages > 1) { 100 this.thumbnailSwiper = new Swiper(this.$refs.thumbSwiper, { 101 slidesPerView: 5, 102 spaceBetween: 15, 103 watchSlidesProgress: true, 104 centeredSlides: false, 105 slideToClickedSlide: true, 106 breakpoints: { 107 1024: { 108 spaceBetween: 20, 109 } 110 } 111 }); 112 } 113
114 // Note: Lightbox swiper is initialized lazily in initLightboxSwiper() 115 // to ensure it's visible when Swiper calculates dimensions for loop mode 116 }, 117 118 /** 119 * Initialize lightbox swiper (called lazily when lightbox first opens) 120 * Must be called when lightbox is visible for loop mode to work correctly 121 * @param {number} initialIndex - The slide index to start at 122 */ 123 initLightboxSwiper(initialIndex = 0) { 124 if (this.lightboxSwiper || !this.$refs.lightboxSwiper) return; 125 126 const lightboxSwiperEl = this.$refs.lightboxSwiper; 127 const lightboxPrevBtn = lightboxSwiperEl.querySelector('.lightbox-swiper-button-prev'); 128 const lightboxNextBtn = lightboxSwiperEl.querySelector('.lightbox-swiper-button-next'); 129 const lightboxPaginationEl = lightboxSwiperEl.querySelector('.lightbox-swiper-pagination'); 130 131 this.lightboxSwiper = new Swiper(lightboxSwiperEl, { 132 slidesPerView: 1, 133 spaceBetween: 30, 134 loop: this.totalImages > 1, 135 navigation: { 136 nextEl: lightboxNextBtn, 137 prevEl: lightboxPrevBtn, 138 }, 139 pagination: { 140 el: lightboxPaginationEl, 141 clickable: true, 142 type: 'fraction', 143 }, 144 keyboard: { 145 enabled: true, 146 }, 147 on: { 148 slideChange: (swiper) => { 149 this.currentIndex = swiper.realIndex; 150 // Sync main swiper (check index to prevent infinite loop) 151 if (this.mainSwiper && this.mainSwiper.realIndex !== swiper.realIndex) { 152 if (this.mainSwiper.params.loop) { 153 this.mainSwiper.slideToLoop(swiper.realIndex); 154 } else { 155 this.mainSwiper.slideTo(swiper.realIndex); 156 } 157 } 158 // Sync thumbnail swiper 159 this.scrollThumbnailIntoView(swiper.realIndex); 160 } 161 } 162 }); 163 164 // Navigate to the correct slide after initialization (works better with loop mode) 165 if (initialIndex > 0) { 166 this.lightboxSwiper.slideToLoop(initialIndex, 0); 167 } 168 }, 169 170 /** 171 * Go to specific slide 172 * @param {number} index - Slide index to navigate to 173 */ 174 goToSlide(index) { 175 this.currentIndex = index; 176 if (this.mainSwiper) { 177 // Use slideToLoop for loop-enabled swipers to handle index correctly 178 if (this.mainSwiper.params.loop) { 179 this.mainSwiper.slideToLoop(index); 180 } else { 181 this.mainSwiper.slideTo(index); 182 } 183 } 184 if (this.showLightbox && this.lightboxSwiper) { 185 if (this.lightboxSwiper.params.loop) { 186 this.lightboxSwiper.slideToLoop(index); 187 } else { 188 this.lightboxSwiper.slideTo(index); 189 } 190 } 191 }, 192 193 /** 194 * Open lightbox at current or specific index 195 * @param {number|null} index - Optional index to open at 196 */ 197 openLightbox(index = null) { 198 if (index !== null) { 199 this.currentIndex = index; 200 } 201 202 this.showLightbox = true; 203 document.body.style.overflow = 'hidden'; 204 205 // Hide sticky header via global UI store 206 if (window.Alpine && Alpine.store('ui')) { 207 Alpine.store('ui').enterFullscreen(); 208 } 209 210 // Initialize or navigate lightbox swiper 211 this.$nextTick(() => { 212 if (this.lightboxSwiper) { 213 // Swiper already exists, just navigate to current slide 214 if (this.lightboxSwiper.params.loop) { 215 this.lightboxSwiper.slideToLoop(this.currentIndex, 0); 216 } else { 217 this.lightboxSwiper.slideTo(this.currentIndex, 0); 218 } 219 } else { 220 // First time opening - initialize with current index 221 this.initLightboxSwiper(this.currentIndex); 222 } 223 }); 224 }, 225 226 /** 227 * Close lightbox 228 */ 229 closeLightbox() { 230 this.showLightbox = false;
231 document.body.style.overflow = ''; 232 233 // Show sticky header again 234 if (window.Alpine && Alpine.store('ui')) { 235 Alpine.store('ui').exitFullscreen(); 236 } 237 }, 238 239 /** 240 * Setup keyboard navigation 241 */ 242 setupKeyboardNav() { 243 document.addEventListener('keydown', (e) => { 244 if (this.showLightbox) { 245 if (e.key === 'Escape') { 246 this.closeLightbox(); 247 } 248 // Arrow keys are handled by Swiper's keyboard option 249 } 250 }); 251 }, 252 253 /** 254 * Handle thumbnail click 255 * @param {number} index - Thumbnail index clicked 256 */ 257 selectThumbnail(index) { 258 this.goToSlide(index); 259 }, 260 261 /** 262 * Handle thumbnail hover (desktop) 263 * @param {number} index - Thumbnail index hovered 264 */ 265 hoverThumbnail(index) { 266 // Only on desktop 267 if (window.innerWidth >= 768) { 268 this.goToSlide(index); 269 } 270 }, 271 272 /** 273 * Scroll thumbnail swiper to ensure active thumbnail is visible 274 * @param {number} index - Active thumbnail index 275 */ 276 scrollThumbnailIntoView(index) { 277 if (!this.thumbnailSwiper) return; 278 279 const slidesPerView = this.thumbnailSwiper.params.slidesPerView; 280 const totalSlides = this.thumbnailSwiper.slides.length; 281 282 // Calculate the ideal position to center the active thumbnail 283 // or at least ensure it's visible 284 let targetIndex = index; 285 286 // Try to center the thumbnail, but respect bounds 287 const halfVisible = Math.floor(slidesPerView / 2); 288 targetIndex = Math.max(0, index - halfVisible); 289 290 // Don't scroll past the end 291 const maxIndex = Math.max(0, totalSlides - slidesPerView); 292 targetIndex = Math.min(targetIndex, maxIndex); 293 294 this.thumbnailSwiper.slideTo(targetIndex); 295 }, 296 297 /** 298 * Get image counter text 299 * @returns {string} e.g., "1 / 5" 300 */ 301 get imageCounter() { 302 return `${this.currentIndex + 1} / ${this.totalImages}`; 303 }, 304 305 /** 306 * Build a map of option title -> slide index from data-option-relation attributes 307 */ 308 buildOptionRelationMap() { 309 this.optionRelationMap = {}; 310 if (!this.$refs.mainSwiper) return; 311 312 var slides = this.$refs.mainSwiper.querySelectorAll('.swiper-slide'); 313 slides.forEach(function(slide, index) { 314 var relation = slide.getAttribute('data-option-relation'); 315 if (relation) { 316 this.optionRelationMap[relation.toLowerCase()] = index; 317 } 318 }.bind(this)); 319 }, 320 321 /** 322 * Listen for option-focus events dispatched by custom option selects 323 */ 324 setupOptionRelationListener() { 325 if (Object.keys(this.optionRelationMap).length === 0) return; 326 327 document.addEventListener('option-focus', function(e) { 328 var title = e.detail && e.detail.title; 329 if (!title) return; 330 331 var index = this.optionRelationMap[title.toLowerCase()]; 332 if (index !== undefined) { 333 this.goToSlide(index); 334 } 335 }.bind(this)); 336 }, 337 338 /** 339 * Cleanup when component is destroyed 340 */ 341 destroy() { 342 if (this.mainSwiper) this.mainSwiper.destroy(); 343 if (this.thumbnailSwiper) this.thumbnailSwiper.destroy(); 344 if (this.lightboxSwiper) this.lightboxSwiper.destroy(); 345 document.body.style.overflow = ''; 346 } 347 }; 348} 349 350// Make globally available for Alpine 351window.productMedia = productMedia;
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.