@fy-/fws-vue 2.3.13 → 2.3.15

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,1073 @@
1
+ <script setup lang="ts">
2
+ import type { Component } from 'vue'
3
+ import type { APIPaging } from '../../composables/rest'
4
+ import {
5
+ ChevronDoubleLeftIcon,
6
+ ChevronDoubleRightIcon,
7
+ ChevronLeftIcon,
8
+ ChevronRightIcon,
9
+ InformationCircleIcon,
10
+ XMarkIcon,
11
+ } from '@heroicons/vue/24/solid'
12
+ import { useDebounceFn, useEventListener, useFullscreen, useResizeObserver } from '@vueuse/core'
13
+ import { computed, h, nextTick, onMounted, onUnmounted, reactive, ref, shallowRef, watch } from 'vue'
14
+ import { useEventBus } from '../../composables/event-bus'
15
+ import DefaultPaging from './DefaultPaging.vue'
16
+
17
+ const isGalleryOpen = ref<boolean>(false)
18
+ const eventBus = useEventBus()
19
+ const sidePanel = ref<boolean>(true)
20
+ const showControls = ref<boolean>(true)
21
+ const isFullscreen = ref<boolean>(false)
22
+ const infoPanel = ref<boolean>(true) // Show info panel by default
23
+ const touchStartTime = ref<number>(0)
24
+ const infoHeight = ref<number>(0)
25
+ const galleryContainerRef = shallowRef<HTMLElement | null>(null)
26
+
27
+ // Use VueUse's useFullscreen for better fullscreen handling
28
+ const { isFullscreen: isElementFullscreen, enter: enterFullscreen, exit: exitFullscreen } = useFullscreen(galleryContainerRef)
29
+
30
+ // Track when fullscreen changes externally (like Escape key)
31
+ watch(isElementFullscreen, (newValue) => {
32
+ isFullscreen.value = newValue
33
+ if (newValue) {
34
+ // Force update of image size when entering fullscreen
35
+ nextTick(() => {
36
+ updateImageSizes()
37
+ })
38
+ }
39
+ })
40
+
41
+ const props = withDefaults(
42
+ defineProps<{
43
+ id: string
44
+ images: Array<any>
45
+ title?: string
46
+ getImageUrl?: Function
47
+ getThumbnailUrl?: Function
48
+ onOpen?: Function
49
+ onClose?: Function
50
+ closeIcon?: object
51
+ gridHeight?: number
52
+ mode: 'mason' | 'grid' | 'button' | 'hidden' | 'custom'
53
+ paging?: APIPaging | undefined
54
+ buttonText?: string
55
+ buttonType?: string
56
+ modelValue: number
57
+ borderColor?: Function
58
+ imageLoader: string
59
+ videoComponent?: Component | string
60
+ imageComponent?: Component | string
61
+ isVideo?: Function
62
+ ranking?: boolean
63
+ }>(),
64
+ {
65
+ modelValue: 0,
66
+ imageComponent: 'img',
67
+ mode: 'grid',
68
+ gridHeight: 4,
69
+ closeIcon: () => h(XMarkIcon),
70
+ images: () => [],
71
+ isVideo: () => false,
72
+ getImageUrl: (image: any) => image.image_url,
73
+ getThumbnailUrl: (image: any) => `${image.image_url}?s=250x250&m=autocrop`,
74
+ paging: undefined,
75
+ borderColor: undefined,
76
+ ranking: false,
77
+ },
78
+ )
79
+
80
+ const emit = defineEmits(['update:modelValue'])
81
+ const modelValue = computed({
82
+ get: () => props.modelValue,
83
+ set: (i) => {
84
+ emit('update:modelValue', i)
85
+ },
86
+ })
87
+
88
+ const direction = ref<'next' | 'prev'>('next')
89
+
90
+ let controlsTimeout: number | null = null
91
+ let fullscreenResizeTimeout: number | null = null
92
+
93
+ // Used to maintain consistent image sizes
94
+ function updateImageSizes() {
95
+ nextTick(() => {
96
+ const imageContainers = document.querySelectorAll('.image-container img, .image-container .video-component') as NodeListOf<HTMLElement>
97
+ if (imageContainers && imageContainers.length > 0) {
98
+ imageContainers.forEach((img) => {
99
+ // Force a reflow to ensure correct sizing
100
+ img.style.maxHeight = ''
101
+ // Force browser to recalculate styles
102
+ void img.offsetHeight
103
+
104
+ // Calculate the correct height based on fullscreen and info panel state
105
+ const topHeight = 4 // rem
106
+ const infoHeight = infoPanel.value ? Number.parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--info-height') || '0px') / 16 : 0
107
+ const viewHeight = isFullscreen.value ? 90 : 80
108
+
109
+ // Set explicit height with important to override any other styles
110
+ img.style.maxHeight = `calc(${viewHeight}vh - ${infoHeight}rem - ${topHeight}rem) !important`
111
+ })
112
+ }
113
+ })
114
+ }
115
+
116
+ function setModal(value: boolean) {
117
+ if (value === true) {
118
+ if (props.onOpen) props.onOpen()
119
+ document.body.style.overflow = 'hidden' // Prevent scrolling when gallery is open
120
+ if (!import.meta.env.SSR) {
121
+ useEventListener(document, 'keydown', handleKeyboardInput)
122
+ useEventListener(document, 'keyup', handleKeyboardRelease)
123
+ }
124
+ // Auto-hide controls after 3 seconds on mobile
125
+ if (window.innerWidth < 1024) {
126
+ controlsTimeout = window.setTimeout(() => {
127
+ showControls.value = false
128
+ }, 3000)
129
+ }
130
+ }
131
+ else {
132
+ if (props.onClose) props.onClose()
133
+ document.body.style.overflow = '' // Restore scrolling
134
+ // Exit fullscreen if active
135
+ if (isFullscreen.value) {
136
+ exitFullscreen()
137
+ isFullscreen.value = false
138
+ }
139
+ // Clear timeout if modal is closed
140
+ if (controlsTimeout) {
141
+ clearTimeout(controlsTimeout)
142
+ controlsTimeout = null
143
+ }
144
+ }
145
+ isGalleryOpen.value = value
146
+ showControls.value = true
147
+ // Don't reset info panel state when opening/closing
148
+ }
149
+
150
+ const openGalleryImage = useDebounceFn((index: number | undefined) => {
151
+ if (index === undefined) {
152
+ modelValue.value = 0
153
+ }
154
+ else {
155
+ modelValue.value = Number.parseInt(index.toString())
156
+ }
157
+ setModal(true)
158
+ }, 50) // Debounce to prevent accidental double-opens
159
+
160
+ function goNextImage() {
161
+ direction.value = 'next'
162
+ if (modelValue.value < props.images.length - 1) {
163
+ modelValue.value++
164
+ }
165
+ else {
166
+ modelValue.value = 0
167
+ }
168
+ resetControlsTimer()
169
+ }
170
+
171
+ function goPrevImage() {
172
+ direction.value = 'prev'
173
+ if (modelValue.value > 0) {
174
+ modelValue.value--
175
+ }
176
+ else {
177
+ modelValue.value
178
+ = props.images.length - 1 > 0 ? props.images.length - 1 : 0
179
+ }
180
+ resetControlsTimer()
181
+ }
182
+
183
+ const modelValueSrc = computed(() => {
184
+ if (props.images.length === 0) return false
185
+ if (props.images[modelValue.value] === undefined) return false
186
+ return props.getImageUrl(props.images[modelValue.value])
187
+ })
188
+
189
+ const currentImage = computed(() => {
190
+ if (props.images.length === 0) return null
191
+ return props.images[modelValue.value]
192
+ })
193
+
194
+ const imageCount = computed(() => props.images.length)
195
+ const currentIndex = computed(() => modelValue.value + 1)
196
+
197
+ const start = reactive({ x: 0, y: 0 })
198
+
199
+ function resetControlsTimer() {
200
+ // Show controls when user interacts
201
+ showControls.value = true
202
+
203
+ // Only set timer on mobile
204
+ if (window.innerWidth < 1024) {
205
+ if (controlsTimeout) {
206
+ clearTimeout(controlsTimeout)
207
+ }
208
+ controlsTimeout = window.setTimeout(() => {
209
+ showControls.value = false
210
+ }, 3000)
211
+ }
212
+ }
213
+
214
+ function toggleControls() {
215
+ showControls.value = !showControls.value
216
+ if (showControls.value && window.innerWidth < 1024) {
217
+ resetControlsTimer()
218
+ }
219
+ }
220
+
221
+ function toggleInfoPanel() {
222
+ infoPanel.value = !infoPanel.value
223
+ resetControlsTimer()
224
+
225
+ // Update the info height after panel toggle
226
+ if (infoPanel.value) {
227
+ nextTick(() => {
228
+ updateInfoHeight()
229
+ })
230
+ }
231
+ else {
232
+ // Reset when hiding
233
+ document.documentElement.style.setProperty('--info-height', '0px')
234
+ }
235
+ }
236
+
237
+ function toggleFullscreen() {
238
+ if (!isFullscreen.value) {
239
+ if (galleryContainerRef.value) {
240
+ enterFullscreen()
241
+ .then(() => {
242
+ isFullscreen.value = true
243
+ // Give browser time to adjust fullscreen before updating sizing
244
+ if (fullscreenResizeTimeout) clearTimeout(fullscreenResizeTimeout)
245
+ fullscreenResizeTimeout = window.setTimeout(() => {
246
+ updateImageSizes()
247
+ }, 50)
248
+ })
249
+ .catch(() => {})
250
+ }
251
+ }
252
+ else {
253
+ exitFullscreen()
254
+ .then(() => {
255
+ isFullscreen.value = false
256
+ })
257
+ .catch(() => {})
258
+ }
259
+ resetControlsTimer()
260
+ }
261
+
262
+ // Touch handling with debounce to prevent multiple rapid changes
263
+ const touchStart = useDebounceFn((event: TouchEvent) => {
264
+ const touch = event.touches[0]
265
+ const targetElement = touch.target as HTMLElement
266
+
267
+ // Store start time for tap detection
268
+ touchStartTime.value = Date.now()
269
+
270
+ // Check if the touch started on an interactive element
271
+ if (targetElement.closest('button, a, input, textarea, select')) {
272
+ return // Don't handle swipe if interacting with an interactive element
273
+ }
274
+
275
+ start.x = touch.screenX
276
+ start.y = touch.screenY
277
+ }, 50)
278
+
279
+ const touchEnd = useDebounceFn((event: TouchEvent) => {
280
+ const touch = event.changedTouches[0]
281
+ const targetElement = touch.target as HTMLElement
282
+ const touchDuration = Date.now() - touchStartTime.value
283
+
284
+ // Check if the touch ended on an interactive element
285
+ if (targetElement.closest('button, a, input, textarea, select')) {
286
+ return // Don't handle swipe if interacting with an interactive element
287
+ }
288
+
289
+ const end = { x: touch.screenX, y: touch.screenY }
290
+
291
+ const diffX = start.x - end.x
292
+ const diffY = start.y - end.y
293
+
294
+ // Detect tap (quick touch with minimal movement)
295
+ if (Math.abs(diffX) < 10 && Math.abs(diffY) < 10 && touchDuration < 300) {
296
+ toggleControls()
297
+ return
298
+ }
299
+
300
+ // Add a threshold to prevent accidental swipes
301
+ if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 50) {
302
+ if (diffX > 0) {
303
+ direction.value = 'next'
304
+ goNextImage()
305
+ }
306
+ else {
307
+ direction.value = 'prev'
308
+ goPrevImage()
309
+ }
310
+ }
311
+ }, 50)
312
+
313
+ function getBorderColor(i: any) {
314
+ if (props.borderColor !== undefined) {
315
+ return props.borderColor(i)
316
+ }
317
+ return ''
318
+ }
319
+
320
+ const isKeyPressed = ref<boolean>(false)
321
+
322
+ function handleKeyboardInput(event: KeyboardEvent) {
323
+ if (!isGalleryOpen.value) return
324
+ if (isKeyPressed.value) return
325
+
326
+ switch (event.key) {
327
+ case 'Escape':
328
+ event.preventDefault()
329
+ setModal(false)
330
+ break
331
+ case 'ArrowRight':
332
+ isKeyPressed.value = true
333
+ direction.value = 'next'
334
+ goNextImage()
335
+ break
336
+ case 'ArrowLeft':
337
+ isKeyPressed.value = true
338
+ direction.value = 'prev'
339
+ goPrevImage()
340
+ break
341
+ case 'f':
342
+ toggleFullscreen()
343
+ break
344
+ case 'i':
345
+ toggleInfoPanel()
346
+ break
347
+ default:
348
+ break
349
+ }
350
+ }
351
+
352
+ function handleKeyboardRelease(event: KeyboardEvent) {
353
+ if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') {
354
+ isKeyPressed.value = false
355
+ }
356
+ }
357
+
358
+ function closeGallery() {
359
+ setModal(false)
360
+ }
361
+
362
+ // Click outside gallery content to close - with debounce to prevent accidental closes
363
+ const handleBackdropClick = useDebounceFn((event: MouseEvent) => {
364
+ if (event.target === event.currentTarget) {
365
+ setModal(false)
366
+ }
367
+ }, 200)
368
+
369
+ // Watch for both image changes and fullscreen mode changes
370
+ watch([currentImage, isFullscreen], () => {
371
+ // Update the info height when image changes or fullscreen state changes
372
+ if (infoPanel.value) {
373
+ nextTick(() => {
374
+ updateInfoHeight()
375
+ // Fix image sizing issue when navigating in fullscreen
376
+ if (isFullscreen.value) {
377
+ updateImageSizes()
378
+ }
379
+ })
380
+ }
381
+ })
382
+
383
+ // Update CSS variable with info panel height
384
+ function updateInfoHeight() {
385
+ nextTick(() => {
386
+ const infoElement = document.querySelector('.info-panel-slot') as HTMLElement
387
+ if (infoElement) {
388
+ const height = infoElement.offsetHeight
389
+ infoHeight.value = height
390
+ document.documentElement.style.setProperty('--info-height', `${height}px`)
391
+ }
392
+ })
393
+ }
394
+
395
+ // Debounced window resize handler to avoid performance issues
396
+ const handleWindowResize = useDebounceFn(() => {
397
+ if (isGalleryOpen.value) {
398
+ updateImageSizes()
399
+ updateInfoHeight()
400
+ }
401
+ }, 100)
402
+
403
+ onMounted(() => {
404
+ eventBus.on(`${props.id}GalleryImage`, openGalleryImage)
405
+ eventBus.on(`${props.id}Gallery`, openGalleryImage)
406
+ eventBus.on(`${props.id}GalleryClose`, closeGallery)
407
+
408
+ // Store reference to the gallery container
409
+ galleryContainerRef.value = document.querySelector('.gallery-container')
410
+
411
+ // Initialize info height once mounted (only if info panel is shown)
412
+ if (infoPanel.value) {
413
+ updateInfoHeight()
414
+ }
415
+
416
+ // Use vueUse's useResizeObserver instead of native ResizeObserver
417
+ const infoElement = document.querySelector('.info-panel-slot')
418
+ if (infoElement) {
419
+ useResizeObserver(infoElement as HTMLElement, () => {
420
+ if (infoPanel.value) {
421
+ updateInfoHeight()
422
+ }
423
+ })
424
+ }
425
+
426
+ // Listen for window resize events
427
+ useEventListener(window, 'resize', handleWindowResize)
428
+
429
+ // Listen for fullscreen changes to update image sizes
430
+ useEventListener(document, 'fullscreenchange', () => {
431
+ if (document.fullscreenElement) {
432
+ // This handles the case of using F11 or browser fullscreen controls
433
+ isFullscreen.value = true
434
+ updateImageSizes()
435
+ }
436
+ else {
437
+ isFullscreen.value = false
438
+ }
439
+ })
440
+ })
441
+
442
+ onUnmounted(() => {
443
+ eventBus.off(`${props.id}Gallery`, openGalleryImage)
444
+ eventBus.off(`${props.id}GalleryImage`, openGalleryImage)
445
+ eventBus.off(`${props.id}GalleryClose`, closeGallery)
446
+
447
+ if (!import.meta.env.SSR) {
448
+ document.body.style.overflow = '' // Ensure body scrolling is restored
449
+ }
450
+
451
+ // Clear any remaining timeouts
452
+ if (controlsTimeout) {
453
+ clearTimeout(controlsTimeout)
454
+ }
455
+
456
+ if (fullscreenResizeTimeout) {
457
+ clearTimeout(fullscreenResizeTimeout)
458
+ }
459
+
460
+ // Ensure we exit fullscreen mode on unmount
461
+ if (isFullscreen.value) {
462
+ exitFullscreen().catch(() => {})
463
+ }
464
+ })
465
+ </script>
466
+
467
+ <template>
468
+ <div>
469
+ <transition
470
+ enter-active-class="duration-300 ease-out"
471
+ enter-from-class="opacity-0"
472
+ enter-to-class="opacity-100"
473
+ leave-active-class="duration-200 ease-in"
474
+ leave-from-class="opacity-100"
475
+ leave-to-class="opacity-0"
476
+ >
477
+ <div
478
+ v-if="isGalleryOpen"
479
+ class="fixed bg-fv-neutral-900 text-white inset-0 max-w-[100vw] overflow-hidden gallery-container"
480
+ style="z-index: 37"
481
+ role="dialog"
482
+ aria-modal="true"
483
+ @click="handleBackdropClick"
484
+ >
485
+ <div
486
+ class="relative w-full h-full max-w-full flex flex-col justify-center items-center"
487
+ style="z-index: 38"
488
+ @click.stop
489
+ >
490
+ <!-- Main Content Area -->
491
+ <div class="flex flex-grow gap-4 w-full h-full max-w-full">
492
+ <div class="flex-grow h-full flex items-center relative">
493
+ <!-- Image Display Area -->
494
+ <div
495
+ class="flex h-full relative flex-grow items-center justify-center gap-2 z-[1]"
496
+ @touchstart="touchStart"
497
+ @touchend="touchEnd"
498
+ >
499
+ <!-- Image Navigation - Left -->
500
+ <transition
501
+ enter-active-class="transition-opacity duration-300"
502
+ enter-from-class="opacity-0"
503
+ enter-to-class="opacity-100"
504
+ leave-active-class="transition-opacity duration-300"
505
+ leave-from-class="opacity-100"
506
+ leave-to-class="opacity-0"
507
+ >
508
+ <div
509
+ v-if="showControls && images.length > 1"
510
+ class="absolute left-0 z-[40] h-full flex items-center px-2 md:px-4"
511
+ >
512
+ <button
513
+ class="btn bg-fv-neutral-800/70 hover:bg-fv-neutral-700/90 backdrop-blur-sm p-2 rounded-full transition-transform transform hover:scale-110"
514
+ aria-label="Previous image"
515
+ @click="goPrevImage()"
516
+ >
517
+ <ChevronLeftIcon class="w-6 h-6 md:w-8 md:h-8" />
518
+ </button>
519
+ </div>
520
+ </transition>
521
+
522
+ <!-- Main Image Container -->
523
+ <div
524
+ class="flex-1 flex flex-col z-[2] items-center justify-center max-w-full lg:max-w-[calc(100vw - 256px)] relative"
525
+ style="padding-top: 1rem;"
526
+ >
527
+ <transition
528
+ :name="direction === 'next' ? 'slide-next' : 'slide-prev'"
529
+ mode="out-in"
530
+ >
531
+ <div
532
+ :key="`image-display-${modelValue}`"
533
+ class="flex-1 w-full max-w-full flex flex-col items-center justify-center absolute inset-0 z-[2]"
534
+ >
535
+ <div
536
+ class="flex-1 w-full max-w-full flex items-center justify-center image-container"
537
+ >
538
+ <template
539
+ v-if="videoComponent && isVideo(images[modelValue])"
540
+ >
541
+ <ClientOnly>
542
+ <component
543
+ :is="videoComponent"
544
+ :src="isVideo(images[modelValue])"
545
+ class="shadow max-w-full h-auto object-contain video-component"
546
+ :style="{
547
+ maxHeight: isFullscreen
548
+ ? infoPanel
549
+ ? 'calc(90vh - var(--info-height, 0px) - 4rem)'
550
+ : 'calc(90vh - 4rem)'
551
+ : infoPanel
552
+ ? 'calc(80vh - var(--info-height, 0px) - 4rem)'
553
+ : 'calc(80vh - 4rem)',
554
+ }"
555
+ />
556
+ </ClientOnly>
557
+ </template>
558
+ <template v-else>
559
+ <img
560
+ v-if="modelValueSrc && imageComponent === 'img'"
561
+ class="shadow max-w-full h-auto object-contain"
562
+ :style="{
563
+ maxHeight: isFullscreen
564
+ ? infoPanel
565
+ ? 'calc(90vh - var(--info-height, 0px) - 4rem)'
566
+ : 'calc(90vh - 4rem)'
567
+ : infoPanel
568
+ ? 'calc(80vh - var(--info-height, 0px) - 4rem)'
569
+ : 'calc(80vh - 4rem)',
570
+ }"
571
+ :src="modelValueSrc"
572
+ :alt="`Gallery image ${modelValue + 1}`"
573
+ >
574
+ <component
575
+ :is="imageComponent"
576
+ v-else-if="modelValueSrc && imageComponent"
577
+ :image="modelValueSrc.image"
578
+ :variant="modelValueSrc.variant"
579
+ :alt="modelValueSrc.alt"
580
+ class="shadow max-w-full h-auto object-contain"
581
+ :style="{
582
+ maxHeight: isFullscreen
583
+ ? infoPanel
584
+ ? 'calc(90vh - var(--info-height, 0px) - 4rem)'
585
+ : 'calc(90vh - 4rem)'
586
+ : infoPanel
587
+ ? 'calc(80vh - var(--info-height, 0px) - 4rem)'
588
+ : 'calc(80vh - 4rem)',
589
+ }"
590
+ />
591
+ </template>
592
+ </div>
593
+
594
+ <!-- Image Slot Content -->
595
+ <div
596
+ v-if="infoPanel"
597
+ class="info-panel-slot flex-0 px-4 py-3 backdrop-blur-md bg-fv-neutral-900/70 rounded-t-lg flex items-center justify-center max-w-full w-full !z-[45] transition-all"
598
+ @transitionend="updateInfoHeight"
599
+ >
600
+ <slot :value="images[modelValue]" />
601
+ </div>
602
+ </div>
603
+ </transition>
604
+ </div>
605
+
606
+ <!-- Image Navigation - Right -->
607
+ <transition
608
+ enter-active-class="transition-opacity duration-300"
609
+ enter-from-class="opacity-0"
610
+ enter-to-class="opacity-100"
611
+ leave-active-class="transition-opacity duration-300"
612
+ leave-from-class="opacity-100"
613
+ leave-to-class="opacity-0"
614
+ >
615
+ <div
616
+ v-if="showControls && images.length > 1"
617
+ class="absolute right-0 z-[40] h-full flex items-center px-2 md:px-4"
618
+ >
619
+ <button
620
+ class="btn bg-fv-neutral-800/70 hover:bg-fv-neutral-700/90 backdrop-blur-sm p-2 rounded-full transition-transform transform hover:scale-110"
621
+ aria-label="Next image"
622
+ @click="goNextImage()"
623
+ >
624
+ <ChevronRightIcon class="w-6 h-6 md:w-8 md:h-8" />
625
+ </button>
626
+ </div>
627
+ </transition>
628
+ </div>
629
+ </div>
630
+
631
+ <!-- Side Panel for Thumbnails -->
632
+ <transition
633
+ enter-active-class="transform transition ease-in-out duration-300"
634
+ enter-from-class="translate-x-full"
635
+ enter-to-class="translate-x-0"
636
+ leave-active-class="transform transition ease-in-out duration-300"
637
+ leave-from-class="translate-x-0"
638
+ leave-to-class="translate-x-full"
639
+ >
640
+ <div
641
+ v-if="sidePanel"
642
+ class="hidden lg:block flex-shrink-0 w-64 bg-fv-neutral-800/90 backdrop-blur-md h-full max-h-full overflow-y-auto"
643
+ style="padding-top: 4rem;"
644
+ >
645
+ <!-- Paging Controls -->
646
+ <div v-if="paging" class="flex items-center justify-center pt-2">
647
+ <DefaultPaging :id="id" :items="paging" />
648
+ </div>
649
+
650
+ <!-- Thumbnail Grid -->
651
+ <div class="grid grid-cols-2 gap-2 p-2">
652
+ <div
653
+ v-for="i in images.length"
654
+ :key="`bg_${id}_${i}`"
655
+ class="group relative"
656
+ >
657
+ <div
658
+ class="absolute inset-0 rounded-lg transition-colors duration-300 group-hover:bg-fv-neutral-700/40"
659
+ :class="{ 'bg-fv-primary-500/40': i - 1 === modelValue }"
660
+ />
661
+ <img
662
+ v-if="imageComponent === 'img'"
663
+ :class="`h-auto max-w-full rounded-lg cursor-pointer shadow transition-all duration-300 group-hover:brightness-110 ${getBorderColor(
664
+ images[i - 1],
665
+ )}`"
666
+ :style="{
667
+ filter:
668
+ i - 1 === modelValue ? 'brightness(1)' : 'brightness(0.7)',
669
+ }"
670
+ :src="getThumbnailUrl(images[i - 1])"
671
+ :alt="`Thumbnail ${i}`"
672
+ @click="$eventBus.emit(`${id}GalleryImage`, i - 1)"
673
+ >
674
+ <component
675
+ :is="imageComponent"
676
+ v-else
677
+ :image="getThumbnailUrl(images[i - 1]).image"
678
+ :variant="getThumbnailUrl(images[i - 1]).variant"
679
+ :alt="getThumbnailUrl(images[i - 1]).alt"
680
+ :class="`h-auto max-w-full rounded-lg cursor-pointer shadow transition-all duration-300 group-hover:brightness-110 ${getBorderColor(
681
+ images[i - 1],
682
+ )}`"
683
+ :style="{
684
+ filter:
685
+ i - 1 === modelValue ? 'brightness(1)' : 'brightness(0.7)',
686
+ }"
687
+ :likes="getThumbnailUrl(images[i - 1]).likes"
688
+ :show-likes="getThumbnailUrl(images[i - 1]).showLikes"
689
+ :is-author="getThumbnailUrl(images[i - 1]).isAuthor"
690
+ :user-uuid="getThumbnailUrl(images[i - 1]).userUUID"
691
+ @click="$eventBus.emit(`${id}GalleryImage`, i - 1)"
692
+ />
693
+ </div>
694
+ </div>
695
+ </div>
696
+ </transition>
697
+ </div>
698
+
699
+ <!-- Top Controls -->
700
+ <transition
701
+ enter-active-class="transition-opacity duration-300"
702
+ enter-from-class="opacity-0"
703
+ enter-to-class="opacity-100"
704
+ leave-active-class="transition-opacity duration-300"
705
+ leave-from-class="opacity-100"
706
+ leave-to-class="opacity-0"
707
+ >
708
+ <div
709
+ v-if="showControls"
710
+ class="fixed top-0 left-0 right-0 px-4 py-3 flex justify-between items-center bg-gradient-to-b from-fv-neutral-900/90 to-transparent backdrop-blur-sm z-[50] transition-opacity h-16"
711
+ >
712
+ <!-- Title and Counter -->
713
+ <div class="flex items-center space-x-2">
714
+ <span v-if="title" class="font-medium text-lg">{{ title }}</span>
715
+ <span class="text-sm opacity-80">{{ currentIndex }} / {{ imageCount }}</span>
716
+ </div>
717
+
718
+ <!-- Control Buttons -->
719
+ <div class="flex items-center space-x-2">
720
+ <button
721
+ class="btn p-1.5 rounded-full bg-fv-neutral-800/70 hover:bg-fv-neutral-700/90 transition-transform transform hover:scale-110"
722
+ :class="{ 'bg-fv-primary-500/70': infoPanel }"
723
+ :title="infoPanel ? 'Hide info' : 'Show info'"
724
+ @click="toggleInfoPanel"
725
+ >
726
+ <InformationCircleIcon class="w-5 h-5" />
727
+ </button>
728
+
729
+ <button
730
+ class="btn p-1.5 rounded-full lg:hidden bg-fv-neutral-800/70 hover:bg-fv-neutral-700/90 transition-transform transform hover:scale-110"
731
+ :title="sidePanel ? 'Hide thumbnails' : 'Show thumbnails'"
732
+ @click="() => (sidePanel = !sidePanel)"
733
+ >
734
+ <ChevronDoubleRightIcon v-if="sidePanel" class="w-5 h-5" />
735
+ <ChevronDoubleLeftIcon v-else class="w-5 h-5" />
736
+ </button>
737
+
738
+ <button
739
+ class="btn p-1.5 rounded-full hidden lg:block bg-fv-neutral-800/70 hover:bg-fv-neutral-700/90 transition-transform transform hover:scale-110"
740
+ :title="sidePanel ? 'Hide thumbnails' : 'Show thumbnails'"
741
+ @click="() => (sidePanel = !sidePanel)"
742
+ >
743
+ <ChevronDoubleRightIcon v-if="sidePanel" class="w-5 h-5" />
744
+ <ChevronDoubleLeftIcon v-else class="w-5 h-5" />
745
+ </button>
746
+
747
+ <button
748
+ class="btn p-1.5 rounded-full bg-fv-neutral-800/70 hover:bg-fv-neutral-700/90 transition-transform transform hover:scale-110"
749
+ aria-label="Close gallery"
750
+ @click="setModal(false)"
751
+ >
752
+ <component :is="closeIcon" class="w-5 h-5" />
753
+ </button>
754
+ </div>
755
+ </div>
756
+ </transition>
757
+
758
+ <!-- Mobile Thumbnail Preview -->
759
+ <transition
760
+ enter-active-class="transition-transform duration-300 ease-out"
761
+ enter-from-class="translate-y-full"
762
+ enter-to-class="translate-y-0"
763
+ leave-active-class="transition-transform duration-300 ease-in"
764
+ leave-from-class="translate-y-0"
765
+ leave-to-class="translate-y-full"
766
+ >
767
+ <div
768
+ v-if="showControls && images.length > 1 && !sidePanel"
769
+ class="absolute bottom-0 left-0 right-0 p-2 lg:hidden bg-gradient-to-t from-fv-neutral-900/90 to-transparent backdrop-blur-sm z-[50]"
770
+ >
771
+ <div class="overflow-x-auto flex space-x-2 pb-1 px-1">
772
+ <div
773
+ v-for="(image, idx) in images"
774
+ :key="`mobile_thumb_${id}_${idx}`"
775
+ class="flex-shrink-0 w-16 h-16 rounded-lg relative cursor-pointer"
776
+ :class="{ 'ring-2 ring-fv-primary-500 ring-offset-1 ring-offset-fv-neutral-900': idx === modelValue }"
777
+ @click="$eventBus.emit(`${id}GalleryImage`, idx)"
778
+ >
779
+ <img
780
+ v-if="imageComponent === 'img'"
781
+ class="w-full h-full object-cover rounded-lg transition duration-200"
782
+ :style="{
783
+ filter: idx === modelValue ? 'brightness(1)' : 'brightness(0.7)',
784
+ }"
785
+ :src="getThumbnailUrl(image)"
786
+ :alt="`Thumbnail ${idx + 1}`"
787
+ >
788
+ <component
789
+ :is="imageComponent"
790
+ v-else
791
+ :image="getThumbnailUrl(image).image"
792
+ :variant="getThumbnailUrl(image).variant"
793
+ :alt="getThumbnailUrl(image).alt"
794
+ class="w-full h-full object-cover rounded-lg transition duration-200"
795
+ :style="{
796
+ filter: idx === modelValue ? 'brightness(1)' : 'brightness(0.7)',
797
+ }"
798
+ />
799
+ </div>
800
+ </div>
801
+ </div>
802
+ </transition>
803
+ </div>
804
+ </div>
805
+ </transition>
806
+
807
+ <!-- Thumbnail Grid/Mason/Custom Layouts -->
808
+ <div v-if="mode === 'grid' || mode === 'mason' || mode === 'custom'" class="gallery-grid">
809
+ <div
810
+ :class="{
811
+ 'masonry-grid': mode === 'mason',
812
+ 'standard-grid': mode === 'grid',
813
+ 'custom-grid': mode === 'custom',
814
+ }"
815
+ >
816
+ <slot name="thumbnail" />
817
+ <template v-for="i in images.length" :key="`g_${id}_${i}`">
818
+ <template v-if="mode === 'mason'">
819
+ <div
820
+ v-if="i + (1 % gridHeight) === 0"
821
+ class="masonry-column relative"
822
+ >
823
+ <div v-if="ranking" class="img-gallery-ranking">
824
+ {{ i }}
825
+ </div>
826
+
827
+ <template v-for="j in gridHeight" :key="`gi_${id}_${i + j}`">
828
+ <div class="masonry-item">
829
+ <img
830
+ v-if="i + j - 2 < images.length && imageComponent === 'img'"
831
+ class="h-auto max-w-full w-full rounded-lg cursor-pointer shadow-md hover:shadow-xl transition-all duration-300 hover:brightness-110 hover:scale-[1.02]"
832
+ :src="getThumbnailUrl(images[i + j - 2])"
833
+ :alt="`Gallery image ${i + j - 1}`"
834
+ @click="$eventBus.emit(`${id}GalleryImage`, i + j - 2)"
835
+ >
836
+ <component
837
+ :is="imageComponent"
838
+ v-else-if="i + j - 2 < images.length"
839
+ :image="getThumbnailUrl(images[i + j - 2]).image"
840
+ :variant="getThumbnailUrl(images[i + j - 2]).variant"
841
+ :alt="getThumbnailUrl(images[i + j - 2]).alt"
842
+ :class="`h-auto max-w-full w-full rounded-lg cursor-pointer shadow-md hover:shadow-xl transition-all duration-300 hover:brightness-110 hover:scale-[1.02] ${getBorderColor(
843
+ images[i + j - 2],
844
+ )}`"
845
+ :likes="getThumbnailUrl(images[i + j - 2]).likes"
846
+ :show-likes="getThumbnailUrl(images[i + j - 2]).showLikes"
847
+ :is-author="getThumbnailUrl(images[i + j - 2]).isAuthor"
848
+ :user-uuid="getThumbnailUrl(images[i + j - 2]).userUUID"
849
+ @click="$eventBus.emit(`${id}GalleryImage`, i + j - 2)"
850
+ />
851
+ </div>
852
+ </template>
853
+ </div>
854
+ </template>
855
+ <div v-else class="grid-item relative group">
856
+ <div v-if="ranking" class="img-gallery-ranking">
857
+ {{ i }}
858
+ </div>
859
+ <div class="overflow-hidden rounded-lg">
860
+ <img
861
+ v-if="imageComponent === 'img'"
862
+ class="h-auto max-w-full w-full rounded-lg cursor-pointer shadow-md transition-all duration-300 group-hover:brightness-110 group-hover:scale-[1.03]"
863
+ :src="getThumbnailUrl(images[i - 1])"
864
+ :alt="`Gallery image ${i}`"
865
+ @click="$eventBus.emit(`${id}GalleryImage`, i - 1)"
866
+ >
867
+ <component
868
+ :is="imageComponent"
869
+ v-else-if="imageComponent"
870
+ :image="getThumbnailUrl(images[i - 1]).image"
871
+ :variant="getThumbnailUrl(images[i - 1]).variant"
872
+ :alt="getThumbnailUrl(images[i - 1]).alt"
873
+ :class="`h-auto max-w-full w-full rounded-lg cursor-pointer shadow-md transition-all duration-300 group-hover:brightness-110 group-hover:scale-[1.03] ${getBorderColor(
874
+ images[i - 1],
875
+ )}`"
876
+ :likes="getThumbnailUrl(images[i - 1]).likes"
877
+ :show-likes="getThumbnailUrl(images[i - 1]).showLikes"
878
+ :is-author="getThumbnailUrl(images[i - 1]).isAuthor"
879
+ :user-uuid="getThumbnailUrl(images[i - 1]).userUUID"
880
+ @click="$eventBus.emit(`${id}GalleryImage`, i - 1)"
881
+ />
882
+ </div>
883
+ </div>
884
+ </template>
885
+ </div>
886
+ </div>
887
+
888
+ <!-- Button Mode -->
889
+ <button
890
+ v-if="mode === 'button'"
891
+ :class="`btn ${buttonType ? buttonType : 'primary'} defaults relative overflow-hidden group`"
892
+ @click="openGalleryImage(0)"
893
+ >
894
+ <span class="relative z-10">{{ buttonText ? buttonText : $t("open_gallery_cta") }}</span>
895
+ <span class="absolute inset-0 bg-white/10 transform -translate-x-full group-hover:translate-x-0 transition-transform duration-300" />
896
+ </button>
897
+ </div>
898
+ </template>
899
+
900
+ <style scoped>
901
+ /* Fix for top controls to ensure they're always visible */
902
+ .fixed.top-0 {
903
+ position: fixed !important;
904
+ top: 0 !important;
905
+ left: 0 !important;
906
+ right: 0 !important;
907
+ z-index: 51 !important;
908
+ }
909
+
910
+ /* Transition styles for next (right) navigation */
911
+ .slide-next-enter-active,
912
+ .slide-next-leave-active {
913
+ transition:
914
+ opacity 0.15s,
915
+ transform 0.15s,
916
+ filter 0.15s;
917
+ }
918
+
919
+ .slide-next-enter-from {
920
+ opacity: 0;
921
+ transform: translateX(100%);
922
+ filter: blur(10px);
923
+ }
924
+
925
+ .slide-next-enter-to {
926
+ opacity: 1;
927
+ transform: translateX(0);
928
+ filter: blur(0);
929
+ }
930
+
931
+ .slide-next-leave-from {
932
+ opacity: 1;
933
+ transform: translateX(0);
934
+ filter: blur(0);
935
+ }
936
+
937
+ .slide-next-leave-to {
938
+ opacity: 0;
939
+ transform: translateX(-100%);
940
+ filter: blur(10px);
941
+ }
942
+
943
+ /* Transition styles for prev (left) navigation */
944
+ .slide-prev-enter-active,
945
+ .slide-prev-leave-active {
946
+ transition:
947
+ opacity 0.15s,
948
+ transform 0.15s,
949
+ filter 0.15s;
950
+ }
951
+
952
+ .slide-prev-enter-from {
953
+ opacity: 0;
954
+ transform: translateX(-100%);
955
+ filter: blur(10px);
956
+ }
957
+
958
+ .slide-prev-enter-to {
959
+ opacity: 1;
960
+ transform: translateX(0);
961
+ filter: blur(0);
962
+ }
963
+
964
+ .slide-prev-leave-from {
965
+ opacity: 1;
966
+ transform: translateX(0);
967
+ filter: blur(0);
968
+ }
969
+
970
+ .slide-prev-leave-to {
971
+ opacity: 0;
972
+ transform: translateX(100%);
973
+ filter: blur(10px);
974
+ }
975
+
976
+ /* Modern grids */
977
+ .gallery-grid {
978
+ min-height: 200px;
979
+ }
980
+
981
+ .standard-grid {
982
+ display: grid;
983
+ grid-template-columns: repeat(1, 1fr);
984
+ gap: 0.75rem;
985
+ }
986
+
987
+ @media (min-width: 480px) {
988
+ .standard-grid {
989
+ grid-template-columns: repeat(2, 1fr);
990
+ }
991
+ }
992
+
993
+ @media (min-width: 768px) {
994
+ .standard-grid {
995
+ grid-template-columns: repeat(3, 1fr);
996
+ gap: 1rem;
997
+ }
998
+ }
999
+
1000
+ @media (min-width: 1024px) {
1001
+ .standard-grid {
1002
+ grid-template-columns: repeat(4, 1fr);
1003
+ }
1004
+ }
1005
+
1006
+ @media (min-width: 1280px) {
1007
+ .standard-grid {
1008
+ grid-template-columns: repeat(5, 1fr);
1009
+ }
1010
+ }
1011
+
1012
+ @media (min-width: 1536px) {
1013
+ .standard-grid {
1014
+ grid-template-columns: repeat(6, 1fr);
1015
+ }
1016
+ }
1017
+
1018
+ .masonry-grid {
1019
+ display: grid;
1020
+ grid-template-columns: repeat(1, 1fr);
1021
+ gap: 0.75rem;
1022
+ }
1023
+
1024
+ @media (min-width: 480px) {
1025
+ .masonry-grid {
1026
+ grid-template-columns: repeat(2, 1fr);
1027
+ }
1028
+ }
1029
+
1030
+ @media (min-width: 768px) {
1031
+ .masonry-grid {
1032
+ grid-template-columns: repeat(3, 1fr);
1033
+ gap: 1rem;
1034
+ }
1035
+ }
1036
+
1037
+ @media (min-width: 1024px) {
1038
+ .masonry-grid {
1039
+ grid-template-columns: repeat(4, 1fr);
1040
+ }
1041
+ }
1042
+
1043
+ .masonry-column {
1044
+ display: grid;
1045
+ gap: 0.75rem;
1046
+ }
1047
+
1048
+ .masonry-item {
1049
+ break-inside: avoid;
1050
+ margin-bottom: 0.75rem;
1051
+ }
1052
+
1053
+ .grid-item {
1054
+ break-inside: avoid;
1055
+ margin-bottom: 0.75rem;
1056
+ }
1057
+
1058
+ .img-gallery-ranking {
1059
+ position: absolute;
1060
+ top: 0.5rem;
1061
+ left: 0.5rem;
1062
+ background-color: rgba(0, 0, 0, 0.6);
1063
+ color: white;
1064
+ width: 1.5rem;
1065
+ height: 1.5rem;
1066
+ display: flex;
1067
+ align-items: center;
1068
+ justify-content: center;
1069
+ border-radius: 9999px;
1070
+ font-size: 0.75rem;
1071
+ z-index: 10;
1072
+ }
1073
+ </style>