@fy-/fws-vue 2.3.12 → 2.3.14

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.
@@ -9,35 +9,78 @@ import {
9
9
  InformationCircleIcon,
10
10
  XMarkIcon,
11
11
  } from '@heroicons/vue/24/solid'
12
- import { useDebounceFn, useEventListener, useFullscreen, useResizeObserver } from '@vueuse/core'
12
+ import {
13
+ useDebounceFn,
14
+ useElementSize,
15
+ useEventListener,
16
+ useFullscreen,
17
+ useResizeObserver,
18
+ useWindowSize,
19
+ } from '@vueuse/core'
13
20
  import { computed, h, nextTick, onMounted, onUnmounted, reactive, ref, shallowRef, watch } from 'vue'
14
21
  import { useEventBus } from '../../composables/event-bus'
22
+ import { ClientOnly } from '../ssr/ClientOnly'
15
23
  import DefaultPaging from './DefaultPaging.vue'
16
24
 
25
+ // Core state
17
26
  const isGalleryOpen = ref<boolean>(false)
18
27
  const eventBus = useEventBus()
19
28
  const sidePanel = ref<boolean>(true)
20
29
  const showControls = ref<boolean>(true)
21
30
  const isFullscreen = ref<boolean>(false)
22
31
  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)
32
+ const direction = ref<'next' | 'prev'>('next')
33
+
34
+ // Refs to track DOM elements and their sizes
35
+ const galleryRef = shallowRef<HTMLElement | null>(null)
36
+ const galleryContentRef = shallowRef<HTMLElement | null>(null)
37
+ const imageContainerRef = shallowRef<HTMLElement | null>(null)
38
+ const infoPanelRef = shallowRef<HTMLElement | null>(null)
39
+ const sidePanelRef = shallowRef<HTMLElement | null>(null)
40
+ const topControlsRef = shallowRef<HTMLElement | null>(null)
41
+
42
+ // Use VueUse's useElementSize for reliable sizing
43
+ const { width: galleryWidth, height: galleryHeight } = useElementSize(galleryRef)
44
+ const { width: windowWidth, height: windowHeight } = useWindowSize()
45
+ const { height: topControlsHeight } = useElementSize(topControlsRef)
46
+ const { height: infoPanelHeight } = useElementSize(infoPanelRef)
47
+
48
+ // Derived measurements
49
+ const availableHeight = computed(() => {
50
+ let height = isFullscreen.value
51
+ ? windowHeight.value * 0.95 // 95% of viewport in fullscreen
52
+ : windowHeight.value * 0.85 // 85% of viewport in normal mode
53
+
54
+ // Subtract top controls
55
+ height -= topControlsHeight.value
56
+
57
+ // Subtract info panel if visible
58
+ if (infoPanel.value && infoPanelHeight.value > 0) {
59
+ height -= infoPanelHeight.value
60
+ }
61
+
62
+ // Convert to rem for consistent sizing
63
+ return `${height / 16}rem`
64
+ })
26
65
 
27
66
  // Use VueUse's useFullscreen for better fullscreen handling
28
- const { isFullscreen: isElementFullscreen, enter: enterFullscreen, exit: exitFullscreen } = useFullscreen(galleryContainerRef)
67
+ const { isFullscreen: isElementFullscreen, enter: enterFullscreen, exit: exitFullscreen } = useFullscreen(galleryRef)
29
68
 
30
69
  // Track when fullscreen changes externally (like Escape key)
31
70
  watch(isElementFullscreen, (newValue) => {
32
71
  isFullscreen.value = newValue
33
- if (newValue) {
34
- // Force update of image size when entering fullscreen
35
- nextTick(() => {
36
- updateImageSizes()
37
- })
38
- }
39
72
  })
40
73
 
74
+ // Touch handling state
75
+ const touchStartTime = ref<number>(0)
76
+ const start = reactive({ x: 0, y: 0 })
77
+ const isKeyPressed = ref<boolean>(false)
78
+
79
+ // Timers for automatic control hiding
80
+ let controlsTimeout: number | null = null
81
+ let fullscreenResizeTimeout: number | null = null
82
+
83
+ // Props definition with defaults
41
84
  const props = withDefaults(
42
85
  defineProps<{
43
86
  id: string
@@ -77,7 +120,10 @@ const props = withDefaults(
77
120
  },
78
121
  )
79
122
 
123
+ // Emits
80
124
  const emit = defineEmits(['update:modelValue'])
125
+
126
+ // Two-way binding for model value
81
127
  const modelValue = computed({
82
128
  get: () => props.modelValue,
83
129
  set: (i) => {
@@ -85,40 +131,61 @@ const modelValue = computed({
85
131
  },
86
132
  })
87
133
 
88
- const direction = ref<'next' | 'prev'>('next')
134
+ // Computed values
135
+ const modelValueSrc = computed(() => {
136
+ if (props.images.length === 0) return false
137
+ if (props.images[modelValue.value] === undefined) return false
138
+ return props.getImageUrl(props.images[modelValue.value])
139
+ })
89
140
 
90
- let controlsTimeout: number | null = null
91
- let fullscreenResizeTimeout: number | null = null
141
+ const currentImage = computed(() => {
142
+ if (props.images.length === 0) return null
143
+ return props.images[modelValue.value]
144
+ })
145
+
146
+ const imageCount = computed(() => props.images.length)
147
+ const currentIndex = computed(() => modelValue.value + 1)
148
+
149
+ // Image size and positioning
150
+ const calculateImageSize = useDebounceFn(() => {
151
+ if (!imageContainerRef.value) return
92
152
 
93
- // Used to maintain consistent image sizes
94
- function updateImageSizes() {
95
153
  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
- if (img.style.maxHeight) {
101
- const currentMaxHeight = img.style.maxHeight
102
- img.style.maxHeight = ''
103
- // Force browser to recalculate styles
104
- void img.offsetHeight
105
- img.style.maxHeight = currentMaxHeight
106
- }
107
- })
108
- }
154
+ const imageElements = imageContainerRef.value?.querySelectorAll('.image-display img, .image-display .video-component') as NodeListOf<HTMLElement>
155
+
156
+ if (!imageElements || imageElements.length === 0) return
157
+
158
+ imageElements.forEach((img) => {
159
+ // Reset to ensure proper recalculation
160
+ img.style.maxHeight = ''
161
+ // Force browser to recalculate styles
162
+ void img.offsetHeight
163
+
164
+ // Set proper height
165
+ img.style.maxHeight = availableHeight.value
166
+ img.style.maxWidth = sidePanel.value ? 'calc(100vw - 16rem)' : '100vw'
167
+ })
109
168
  })
110
- }
169
+ }, 50)
111
170
 
171
+ // Update all layout measurements
172
+ const updateLayout = useDebounceFn(() => {
173
+ calculateImageSize()
174
+ }, 50)
175
+
176
+ // Modal controls
112
177
  function setModal(value: boolean) {
113
178
  if (value === true) {
114
179
  if (props.onOpen) props.onOpen()
115
180
  document.body.style.overflow = 'hidden' // Prevent scrolling when gallery is open
181
+
116
182
  if (!import.meta.env.SSR) {
117
183
  useEventListener(document, 'keydown', handleKeyboardInput)
118
184
  useEventListener(document, 'keyup', handleKeyboardRelease)
119
185
  }
186
+
120
187
  // Auto-hide controls after 3 seconds on mobile
121
- if (window.innerWidth < 1024) {
188
+ if (windowWidth.value < 1024) {
122
189
  controlsTimeout = window.setTimeout(() => {
123
190
  showControls.value = false
124
191
  }, 3000)
@@ -127,11 +194,13 @@ function setModal(value: boolean) {
127
194
  else {
128
195
  if (props.onClose) props.onClose()
129
196
  document.body.style.overflow = '' // Restore scrolling
197
+
130
198
  // Exit fullscreen if active
131
199
  if (isFullscreen.value) {
132
200
  exitFullscreen()
133
201
  isFullscreen.value = false
134
202
  }
203
+
135
204
  // Clear timeout if modal is closed
136
205
  if (controlsTimeout) {
137
206
  clearTimeout(controlsTimeout)
@@ -143,6 +212,7 @@ function setModal(value: boolean) {
143
212
  // Don't reset info panel state when opening/closing
144
213
  }
145
214
 
215
+ // Open gallery with debounce to prevent accidental double-clicks
146
216
  const openGalleryImage = useDebounceFn((index: number | undefined) => {
147
217
  if (index === undefined) {
148
218
  modelValue.value = 0
@@ -151,8 +221,14 @@ const openGalleryImage = useDebounceFn((index: number | undefined) => {
151
221
  modelValue.value = Number.parseInt(index.toString())
152
222
  }
153
223
  setModal(true)
154
- }, 50) // Debounce to prevent accidental double-opens
155
224
 
225
+ // Update layout after opening
226
+ nextTick(() => {
227
+ updateLayout()
228
+ })
229
+ }, 50)
230
+
231
+ // Navigation functions
156
232
  function goNextImage() {
157
233
  direction.value = 'next'
158
234
  if (modelValue.value < props.images.length - 1) {
@@ -170,34 +246,18 @@ function goPrevImage() {
170
246
  modelValue.value--
171
247
  }
172
248
  else {
173
- modelValue.value
174
- = props.images.length - 1 > 0 ? props.images.length - 1 : 0
249
+ modelValue.value = props.images.length - 1 > 0 ? props.images.length - 1 : 0
175
250
  }
176
251
  resetControlsTimer()
177
252
  }
178
253
 
179
- const modelValueSrc = computed(() => {
180
- if (props.images.length === 0) return false
181
- if (props.images[modelValue.value] === undefined) return false
182
- return props.getImageUrl(props.images[modelValue.value])
183
- })
184
-
185
- const currentImage = computed(() => {
186
- if (props.images.length === 0) return null
187
- return props.images[modelValue.value]
188
- })
189
-
190
- const imageCount = computed(() => props.images.length)
191
- const currentIndex = computed(() => modelValue.value + 1)
192
-
193
- const start = reactive({ x: 0, y: 0 })
194
-
254
+ // UI control functions
195
255
  function resetControlsTimer() {
196
256
  // Show controls when user interacts
197
257
  showControls.value = true
198
258
 
199
259
  // Only set timer on mobile
200
- if (window.innerWidth < 1024) {
260
+ if (windowWidth.value < 1024) {
201
261
  if (controlsTimeout) {
202
262
  clearTimeout(controlsTimeout)
203
263
  }
@@ -209,7 +269,7 @@ function resetControlsTimer() {
209
269
 
210
270
  function toggleControls() {
211
271
  showControls.value = !showControls.value
212
- if (showControls.value && window.innerWidth < 1024) {
272
+ if (showControls.value && windowWidth.value < 1024) {
213
273
  resetControlsTimer()
214
274
  }
215
275
  }
@@ -218,28 +278,32 @@ function toggleInfoPanel() {
218
278
  infoPanel.value = !infoPanel.value
219
279
  resetControlsTimer()
220
280
 
221
- // Update the info height after panel toggle
222
- if (infoPanel.value) {
223
- nextTick(() => {
224
- updateInfoHeight()
225
- })
226
- }
227
- else {
228
- // Reset when hiding
229
- document.documentElement.style.setProperty('--info-height', '0px')
230
- }
281
+ // Update layout after panel toggle
282
+ nextTick(() => {
283
+ updateLayout()
284
+ })
285
+ }
286
+
287
+ function toggleSidePanel() {
288
+ sidePanel.value = !sidePanel.value
289
+ resetControlsTimer()
290
+
291
+ // Update layout after panel toggle
292
+ nextTick(() => {
293
+ updateLayout()
294
+ })
231
295
  }
232
296
 
233
297
  function toggleFullscreen() {
234
298
  if (!isFullscreen.value) {
235
- if (galleryContainerRef.value) {
299
+ if (galleryRef.value) {
236
300
  enterFullscreen()
237
301
  .then(() => {
238
302
  isFullscreen.value = true
239
303
  // Give browser time to adjust fullscreen before updating sizing
240
304
  if (fullscreenResizeTimeout) clearTimeout(fullscreenResizeTimeout)
241
305
  fullscreenResizeTimeout = window.setTimeout(() => {
242
- updateImageSizes()
306
+ updateLayout()
243
307
  }, 50)
244
308
  })
245
309
  .catch(() => {})
@@ -249,6 +313,10 @@ function toggleFullscreen() {
249
313
  exitFullscreen()
250
314
  .then(() => {
251
315
  isFullscreen.value = false
316
+ if (fullscreenResizeTimeout) clearTimeout(fullscreenResizeTimeout)
317
+ fullscreenResizeTimeout = window.setTimeout(() => {
318
+ updateLayout()
319
+ }, 50)
252
320
  })
253
321
  .catch(() => {})
254
322
  }
@@ -265,7 +333,7 @@ const touchStart = useDebounceFn((event: TouchEvent) => {
265
333
 
266
334
  // Check if the touch started on an interactive element
267
335
  if (targetElement.closest('button, a, input, textarea, select')) {
268
- return // Don't handle swipe if interacting with an interactive element
336
+ return // Don't handle swipe if interacting with controls
269
337
  }
270
338
 
271
339
  start.x = touch.screenX
@@ -279,7 +347,7 @@ const touchEnd = useDebounceFn((event: TouchEvent) => {
279
347
 
280
348
  // Check if the touch ended on an interactive element
281
349
  if (targetElement.closest('button, a, input, textarea, select')) {
282
- return // Don't handle swipe if interacting with an interactive element
350
+ return // Don't handle swipe if interacting with controls
283
351
  }
284
352
 
285
353
  const end = { x: touch.screenX, y: touch.screenY }
@@ -296,16 +364,15 @@ const touchEnd = useDebounceFn((event: TouchEvent) => {
296
364
  // Add a threshold to prevent accidental swipes
297
365
  if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 50) {
298
366
  if (diffX > 0) {
299
- direction.value = 'next'
300
367
  goNextImage()
301
368
  }
302
369
  else {
303
- direction.value = 'prev'
304
370
  goPrevImage()
305
371
  }
306
372
  }
307
373
  }, 50)
308
374
 
375
+ // Border color function
309
376
  function getBorderColor(i: any) {
310
377
  if (props.borderColor !== undefined) {
311
378
  return props.borderColor(i)
@@ -313,8 +380,7 @@ function getBorderColor(i: any) {
313
380
  return ''
314
381
  }
315
382
 
316
- const isKeyPressed = ref<boolean>(false)
317
-
383
+ // Keyboard handlers
318
384
  function handleKeyboardInput(event: KeyboardEvent) {
319
385
  if (!isGalleryOpen.value) return
320
386
  if (isKeyPressed.value) return
@@ -326,12 +392,10 @@ function handleKeyboardInput(event: KeyboardEvent) {
326
392
  break
327
393
  case 'ArrowRight':
328
394
  isKeyPressed.value = true
329
- direction.value = 'next'
330
395
  goNextImage()
331
396
  break
332
397
  case 'ArrowLeft':
333
398
  isKeyPressed.value = true
334
- direction.value = 'prev'
335
399
  goPrevImage()
336
400
  break
337
401
  case 'f':
@@ -362,65 +426,55 @@ const handleBackdropClick = useDebounceFn((event: MouseEvent) => {
362
426
  }
363
427
  }, 200)
364
428
 
365
- // Watch for both image changes and fullscreen mode changes
366
- watch([currentImage, isFullscreen], () => {
367
- // Update the info height when image changes or fullscreen state changes
368
- if (infoPanel.value) {
369
- nextTick(() => {
370
- updateInfoHeight()
371
- // Fix image sizing issue when navigating in fullscreen
372
- if (isFullscreen.value) {
373
- updateImageSizes()
374
- }
375
- })
376
- }
377
- })
378
-
379
- // Update CSS variable with info panel height
380
- function updateInfoHeight() {
381
- nextTick(() => {
382
- const infoElement = document.querySelector('.info-panel-slot') as HTMLElement
383
- if (infoElement) {
384
- const height = infoElement.offsetHeight
385
- infoHeight.value = height
386
- document.documentElement.style.setProperty('--info-height', `${height}px`)
387
- }
388
- })
389
- }
429
+ // Watch for image changes, fullscreen, or panel visibility changes
430
+ watch(
431
+ [
432
+ currentImage,
433
+ isFullscreen,
434
+ infoPanel,
435
+ sidePanel,
436
+ windowWidth,
437
+ windowHeight,
438
+ galleryWidth,
439
+ galleryHeight,
440
+ topControlsHeight,
441
+ infoPanelHeight,
442
+ ],
443
+ () => {
444
+ updateLayout()
445
+ },
446
+ )
390
447
 
448
+ // Lifecycle hooks
391
449
  onMounted(() => {
392
450
  eventBus.on(`${props.id}GalleryImage`, openGalleryImage)
393
451
  eventBus.on(`${props.id}Gallery`, openGalleryImage)
394
452
  eventBus.on(`${props.id}GalleryClose`, closeGallery)
395
453
 
396
- // Store reference to the gallery container
397
- galleryContainerRef.value = document.querySelector('.gallery-container')
454
+ // Initialize layout
455
+ nextTick(() => {
456
+ updateLayout()
457
+ })
458
+
459
+ // Set up observers for dynamic resizing
460
+ if (topControlsRef.value) {
461
+ useResizeObserver(topControlsRef.value, updateLayout)
462
+ }
398
463
 
399
- // Initialize info height once mounted (only if info panel is shown)
400
- if (infoPanel.value) {
401
- updateInfoHeight()
464
+ if (infoPanelRef.value) {
465
+ useResizeObserver(infoPanelRef.value, updateLayout)
402
466
  }
403
467
 
404
- // Use vueUse's useResizeObserver instead of native ResizeObserver
405
- const infoElement = document.querySelector('.info-panel-slot')
406
- if (infoElement) {
407
- useResizeObserver(infoElement as HTMLElement, () => {
408
- if (infoPanel.value) {
409
- updateInfoHeight()
410
- }
411
- })
468
+ if (sidePanelRef.value) {
469
+ useResizeObserver(sidePanelRef.value, updateLayout)
412
470
  }
413
471
 
414
- // Listen for fullscreen changes to update image sizes
472
+ // Listen for fullscreen changes
415
473
  useEventListener(document, 'fullscreenchange', () => {
416
- if (document.fullscreenElement) {
417
- // This handles the case of using F11 or browser fullscreen controls
418
- isFullscreen.value = true
419
- updateImageSizes()
420
- }
421
- else {
422
- isFullscreen.value = false
423
- }
474
+ isFullscreen.value = !!document.fullscreenElement
475
+ nextTick(() => {
476
+ updateLayout()
477
+ })
424
478
  })
425
479
  })
426
480
 
@@ -461,284 +515,262 @@ onUnmounted(() => {
461
515
  >
462
516
  <div
463
517
  v-if="isGalleryOpen"
464
- class="fixed bg-fv-neutral-900 text-white inset-0 max-w-[100vw] overflow-hidden gallery-container"
518
+ ref="galleryRef"
519
+ class="fixed bg-fv-neutral-900 text-white inset-0 max-w-[100vw] max-h-[100vh] overflow-hidden gallery-container"
465
520
  style="z-index: 37"
466
521
  role="dialog"
467
522
  aria-modal="true"
468
523
  @click="handleBackdropClick"
469
524
  >
525
+ <!-- Top Controls Bar - Fixed at top -->
526
+ <transition
527
+ enter-active-class="transition-opacity duration-300"
528
+ enter-from-class="opacity-0"
529
+ enter-to-class="opacity-100"
530
+ leave-active-class="transition-opacity duration-300"
531
+ leave-from-class="opacity-100"
532
+ leave-to-class="opacity-0"
533
+ >
534
+ <div
535
+ v-if="showControls"
536
+ ref="topControlsRef"
537
+ class="fixed top-0 left-0 right-0 px-4 py-2 flex justify-between items-center bg-fv-neutral-900/90 backdrop-blur-sm z-50 controls-bar"
538
+ >
539
+ <!-- Title and Counter -->
540
+ <div class="flex items-center space-x-2">
541
+ <span v-if="title" class="font-medium text-lg">{{ title }}</span>
542
+ <span class="text-sm opacity-80">{{ currentIndex }} / {{ imageCount }}</span>
543
+ </div>
544
+
545
+ <!-- Control Buttons -->
546
+ <div class="flex items-center space-x-2">
547
+ <button
548
+ class="btn p-1.5 rounded-full bg-fv-neutral-800/70 hover:bg-fv-neutral-700/90 transition-transform transform hover:scale-110"
549
+ :class="{ 'bg-fv-primary-500/70': infoPanel }"
550
+ :title="infoPanel ? 'Hide info' : 'Show info'"
551
+ @click="toggleInfoPanel"
552
+ >
553
+ <InformationCircleIcon class="w-5 h-5" />
554
+ </button>
555
+
556
+ <button
557
+ class="btn p-1.5 rounded-full bg-fv-neutral-800/70 hover:bg-fv-neutral-700/90 transition-transform transform hover:scale-110"
558
+ :title="sidePanel ? 'Hide thumbnails' : 'Show thumbnails'"
559
+ @click="toggleSidePanel"
560
+ >
561
+ <ChevronDoubleRightIcon v-if="sidePanel" class="w-5 h-5" />
562
+ <ChevronDoubleLeftIcon v-else class="w-5 h-5" />
563
+ </button>
564
+
565
+ <button
566
+ class="btn p-1.5 rounded-full bg-fv-neutral-800/70 hover:bg-fv-neutral-700/90 transition-transform transform hover:scale-110"
567
+ aria-label="Close gallery"
568
+ @click="setModal(false)"
569
+ >
570
+ <component :is="closeIcon" class="w-5 h-5" />
571
+ </button>
572
+ </div>
573
+ </div>
574
+ </transition>
575
+
576
+ <!-- Main Gallery Content - Flexbox layout -->
470
577
  <div
471
- class="relative w-full h-full max-w-full flex flex-col justify-center items-center"
472
- style="z-index: 38"
473
- @click.stop
578
+ ref="galleryContentRef"
579
+ class="w-full h-full flex flex-col lg:flex-row"
580
+ style="margin-top: var(--controls-height, 0px)"
474
581
  >
475
- <!-- Main Content Area -->
476
- <div class="flex flex-grow gap-4 w-full h-full max-w-full">
477
- <div class="flex-grow h-full flex items-center relative">
478
- <!-- Image Display Area -->
582
+ <!-- Main Image Area - Fills available space -->
583
+ <div
584
+ class="relative flex-1 h-full flex items-center justify-center"
585
+ :class="{ 'lg:pr-64': sidePanel }"
586
+ >
587
+ <!-- Image Navigation Controls - Left -->
588
+ <transition
589
+ enter-active-class="transition-opacity duration-300"
590
+ enter-from-class="opacity-0"
591
+ enter-to-class="opacity-100"
592
+ leave-active-class="transition-opacity duration-300"
593
+ leave-from-class="opacity-100"
594
+ leave-to-class="opacity-0"
595
+ >
479
596
  <div
480
- class="flex h-full relative flex-grow items-center justify-center gap-2 z-[1]"
481
- @touchstart="touchStart"
482
- @touchend="touchEnd"
597
+ v-if="showControls && images.length > 1"
598
+ class="absolute left-0 z-40 h-full flex items-center px-2 md:px-4"
483
599
  >
484
- <!-- Image Navigation - Left -->
485
- <transition
486
- enter-active-class="transition-opacity duration-300"
487
- enter-from-class="opacity-0"
488
- enter-to-class="opacity-100"
489
- leave-active-class="transition-opacity duration-300"
490
- leave-from-class="opacity-100"
491
- leave-to-class="opacity-0"
600
+ <button
601
+ 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"
602
+ aria-label="Previous image"
603
+ @click="goPrevImage()"
492
604
  >
493
- <div
494
- v-if="showControls && images.length > 1"
495
- class="absolute left-0 z-[40] h-full flex items-center px-2 md:px-4"
496
- >
497
- <button
498
- 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"
499
- aria-label="Previous image"
500
- @click="goPrevImage()"
501
- >
502
- <ChevronLeftIcon class="w-6 h-6 md:w-8 md:h-8" />
503
- </button>
504
- </div>
505
- </transition>
605
+ <ChevronLeftIcon class="w-6 h-6 md:w-8 md:h-8" />
606
+ </button>
607
+ </div>
608
+ </transition>
506
609
 
507
- <!-- Main Image Container -->
610
+ <!-- Image Display Container -->
611
+ <div
612
+ ref="imageContainerRef"
613
+ class="image-container flex-grow flex items-center justify-center"
614
+ :class="{ 'has-info': infoPanel }"
615
+ @touchstart="touchStart"
616
+ @touchend="touchEnd"
617
+ >
618
+ <transition
619
+ :name="direction === 'next' ? 'slide-next' : 'slide-prev'"
620
+ mode="out-in"
621
+ >
508
622
  <div
509
- class="flex-1 flex flex-col z-[2] items-center justify-center max-w-full lg:max-w-[calc(100vw - 256px)] relative"
623
+ :key="`image-display-${modelValue}`"
624
+ class="image-display relative w-full h-full flex flex-col items-center justify-center"
510
625
  >
511
- <transition
512
- :name="direction === 'next' ? 'slide-next' : 'slide-prev'"
513
- mode="out-in"
514
- >
515
- <div
516
- :key="`image-display-${modelValue}`"
517
- class="flex-1 w-full max-w-full flex flex-col items-center justify-center absolute inset-0 z-[2]"
626
+ <!-- Actual Image/Video Content -->
627
+ <template v-if="videoComponent && isVideo(images[modelValue])">
628
+ <ClientOnly>
629
+ <component
630
+ :is="videoComponent"
631
+ :src="isVideo(images[modelValue])"
632
+ class="shadow max-w-full h-auto object-contain video-component"
633
+ :style="{ maxHeight: availableHeight }"
634
+ />
635
+ </ClientOnly>
636
+ </template>
637
+ <template v-else>
638
+ <img
639
+ v-if="modelValueSrc && imageComponent === 'img'"
640
+ class="shadow max-w-full h-auto object-contain"
641
+ :style="{ maxHeight: availableHeight }"
642
+ :src="modelValueSrc"
643
+ :alt="`Gallery image ${modelValue + 1}`"
518
644
  >
519
- <div
520
- class="flex-1 w-full max-w-full flex items-center justify-center image-container"
521
- >
522
- <template
523
- v-if="videoComponent && isVideo(images[modelValue])"
524
- >
525
- <ClientOnly>
526
- <component
527
- :is="videoComponent"
528
- :src="isVideo(images[modelValue])"
529
- class="shadow max-w-full h-auto object-contain video-component"
530
- :style="{
531
- maxHeight: isFullscreen
532
- ? infoPanel
533
- ? 'calc(90vh - var(--info-height, 0px) - 4rem)'
534
- : 'calc(90vh - 4rem)'
535
- : infoPanel
536
- ? 'calc(80vh - var(--info-height, 0px) - 4rem)'
537
- : 'calc(80vh - 4rem)',
538
- }"
539
- />
540
- </ClientOnly>
541
- </template>
542
- <template v-else>
543
- <img
544
- v-if="modelValueSrc && imageComponent === 'img'"
545
- class="shadow max-w-full h-auto object-contain"
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
- :src="modelValueSrc"
556
- :alt="`Gallery image ${modelValue + 1}`"
557
- >
558
- <component
559
- :is="imageComponent"
560
- v-else-if="modelValueSrc && imageComponent"
561
- :image="modelValueSrc.image"
562
- :variant="modelValueSrc.variant"
563
- :alt="modelValueSrc.alt"
564
- class="shadow max-w-full h-auto object-contain"
565
- :style="{
566
- maxHeight: isFullscreen
567
- ? infoPanel
568
- ? 'calc(90vh - var(--info-height, 0px) - 4rem)'
569
- : 'calc(90vh - 4rem)'
570
- : infoPanel
571
- ? 'calc(80vh - var(--info-height, 0px) - 4rem)'
572
- : 'calc(80vh - 4rem)',
573
- }"
574
- />
575
- </template>
576
- </div>
577
-
578
- <!-- Image Slot Content -->
579
- <div
580
- v-if="infoPanel"
581
- 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"
582
- @transitionend="updateInfoHeight"
583
- >
584
- <slot :value="images[modelValue]" />
585
- </div>
586
- </div>
587
- </transition>
645
+ <component
646
+ :is="imageComponent"
647
+ v-else-if="modelValueSrc && imageComponent"
648
+ :image="modelValueSrc.image"
649
+ :variant="modelValueSrc.variant"
650
+ :alt="modelValueSrc.alt"
651
+ class="shadow max-w-full h-auto object-contain"
652
+ :style="{ maxHeight: availableHeight }"
653
+ :likes="modelValueSrc.likes"
654
+ :show-likes="modelValueSrc.showLikes"
655
+ :is-author="modelValueSrc.isAuthor"
656
+ :user-uuid="modelValueSrc.userUUID"
657
+ />
658
+ </template>
588
659
  </div>
660
+ </transition>
661
+ </div>
589
662
 
590
- <!-- Image Navigation - Right -->
591
- <transition
592
- enter-active-class="transition-opacity duration-300"
593
- enter-from-class="opacity-0"
594
- enter-to-class="opacity-100"
595
- leave-active-class="transition-opacity duration-300"
596
- leave-from-class="opacity-100"
597
- leave-to-class="opacity-0"
663
+ <!-- Image Navigation Controls - Right -->
664
+ <transition
665
+ enter-active-class="transition-opacity duration-300"
666
+ enter-from-class="opacity-0"
667
+ enter-to-class="opacity-100"
668
+ leave-active-class="transition-opacity duration-300"
669
+ leave-from-class="opacity-100"
670
+ leave-to-class="opacity-0"
671
+ >
672
+ <div
673
+ v-if="showControls && images.length > 1"
674
+ class="absolute right-0 z-40 h-full flex items-center px-2 md:px-4"
675
+ >
676
+ <button
677
+ 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"
678
+ aria-label="Next image"
679
+ @click="goNextImage()"
598
680
  >
599
- <div
600
- v-if="showControls && images.length > 1"
601
- class="absolute right-0 z-[40] h-full flex items-center px-2 md:px-4"
602
- >
603
- <button
604
- 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"
605
- aria-label="Next image"
606
- @click="goNextImage()"
607
- >
608
- <ChevronRightIcon class="w-6 h-6 md:w-8 md:h-8" />
609
- </button>
610
- </div>
611
- </transition>
681
+ <ChevronRightIcon class="w-6 h-6 md:w-8 md:h-8" />
682
+ </button>
612
683
  </div>
613
- </div>
684
+ </transition>
614
685
 
615
- <!-- Side Panel for Thumbnails -->
686
+ <!-- Info Panel Below Image -->
616
687
  <transition
617
- enter-active-class="transform transition ease-in-out duration-300"
618
- enter-from-class="translate-x-full"
619
- enter-to-class="translate-x-0"
620
- leave-active-class="transform transition ease-in-out duration-300"
621
- leave-from-class="translate-x-0"
622
- leave-to-class="translate-x-full"
688
+ enter-active-class="transition-all duration-300 ease-out"
689
+ enter-from-class="opacity-0 transform translate-y-4"
690
+ enter-to-class="opacity-100 transform translate-y-0"
691
+ leave-active-class="transition-all duration-300 ease-in"
692
+ leave-from-class="opacity-100 transform translate-y-0"
693
+ leave-to-class="opacity-0 transform translate-y-4"
623
694
  >
624
695
  <div
625
- v-if="sidePanel"
626
- 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 pt-16"
696
+ v-if="infoPanel && images[modelValue]"
697
+ ref="infoPanelRef"
698
+ class="info-panel absolute bottom-0 left-0 right-0 px-4 py-3 backdrop-blur-md bg-fv-neutral-900/70 z-45"
627
699
  >
628
- <!-- Paging Controls -->
629
- <div v-if="paging" class="flex items-center justify-center pt-2">
630
- <DefaultPaging :id="id" :items="paging" />
631
- </div>
632
-
633
- <!-- Thumbnail Grid -->
634
- <div class="grid grid-cols-2 gap-2 p-2">
635
- <div
636
- v-for="i in images.length"
637
- :key="`bg_${id}_${i}`"
638
- class="group relative"
639
- >
640
- <div
641
- class="absolute inset-0 rounded-lg transition-colors duration-300 group-hover:bg-fv-neutral-700/40"
642
- :class="{ 'bg-fv-primary-500/40': i - 1 === modelValue }"
643
- />
644
- <img
645
- v-if="imageComponent === 'img'"
646
- :class="`h-auto max-w-full rounded-lg cursor-pointer shadow transition-all duration-300 group-hover:brightness-110 ${getBorderColor(
647
- images[i - 1],
648
- )}`"
649
- :style="{
650
- filter:
651
- i - 1 === modelValue ? 'brightness(1)' : 'brightness(0.7)',
652
- }"
653
- :src="getThumbnailUrl(images[i - 1])"
654
- :alt="`Thumbnail ${i}`"
655
- @click="$eventBus.emit(`${id}GalleryImage`, i - 1)"
656
- >
657
- <component
658
- :is="imageComponent"
659
- v-else
660
- :image="getThumbnailUrl(images[i - 1]).image"
661
- :variant="getThumbnailUrl(images[i - 1]).variant"
662
- :alt="getThumbnailUrl(images[i - 1]).alt"
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
- :likes="getThumbnailUrl(images[i - 1]).likes"
671
- :show-likes="getThumbnailUrl(images[i - 1]).showLikes"
672
- :is-author="getThumbnailUrl(images[i - 1]).isAuthor"
673
- :user-uuid="getThumbnailUrl(images[i - 1]).userUUID"
674
- @click="$eventBus.emit(`${id}GalleryImage`, i - 1)"
675
- />
676
- </div>
677
- </div>
700
+ <slot :value="images[modelValue]" />
678
701
  </div>
679
702
  </transition>
680
703
  </div>
681
704
 
682
- <!-- Top Controls -->
705
+ <!-- Side Thumbnails Panel -->
683
706
  <transition
684
- enter-active-class="transition-opacity duration-300"
685
- enter-from-class="opacity-0"
686
- enter-to-class="opacity-100"
687
- leave-active-class="transition-opacity duration-300"
688
- leave-from-class="opacity-100"
689
- leave-to-class="opacity-0"
707
+ enter-active-class="transform transition ease-in-out duration-300"
708
+ enter-from-class="translate-x-full"
709
+ enter-to-class="translate-x-0"
710
+ leave-active-class="transform transition ease-in-out duration-300"
711
+ leave-from-class="translate-x-0"
712
+ leave-to-class="translate-x-full"
690
713
  >
691
714
  <div
692
- v-if="showControls"
693
- class="absolute 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"
715
+ v-if="sidePanel"
716
+ ref="sidePanelRef"
717
+ class="side-panel hidden lg:block absolute right-0 top-0 bottom-0 w-64 bg-fv-neutral-800/90 backdrop-blur-md overflow-y-auto z-40"
718
+ :style="{ 'padding-top': `${topControlsHeight}px` }"
694
719
  >
695
- <!-- Title and Counter -->
696
- <div class="flex items-center space-x-2">
697
- <span v-if="title" class="font-medium text-lg">{{ title }}</span>
698
- <span class="text-sm opacity-80">{{ currentIndex }} / {{ imageCount }}</span>
720
+ <!-- Paging Controls if needed -->
721
+ <div v-if="paging" class="flex items-center justify-center pt-2">
722
+ <DefaultPaging :id="id" :items="paging" />
699
723
  </div>
700
724
 
701
- <!-- Control Buttons -->
702
- <div class="flex items-center space-x-2">
703
- <button
704
- class="btn p-1.5 rounded-full bg-fv-neutral-800/70 hover:bg-fv-neutral-700/90 transition-transform transform hover:scale-110"
705
- :class="{ 'bg-fv-primary-500/70': infoPanel }"
706
- :title="infoPanel ? 'Hide info' : 'Show info'"
707
- @click="toggleInfoPanel"
708
- >
709
- <InformationCircleIcon class="w-5 h-5" />
710
- </button>
711
-
712
- <button
713
- 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"
714
- :title="sidePanel ? 'Hide thumbnails' : 'Show thumbnails'"
715
- @click="() => (sidePanel = !sidePanel)"
716
- >
717
- <ChevronDoubleRightIcon v-if="sidePanel" class="w-5 h-5" />
718
- <ChevronDoubleLeftIcon v-else class="w-5 h-5" />
719
- </button>
720
-
721
- <button
722
- 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"
723
- :title="sidePanel ? 'Hide thumbnails' : 'Show thumbnails'"
724
- @click="() => (sidePanel = !sidePanel)"
725
- >
726
- <ChevronDoubleRightIcon v-if="sidePanel" class="w-5 h-5" />
727
- <ChevronDoubleLeftIcon v-else class="w-5 h-5" />
728
- </button>
729
-
730
- <button
731
- class="btn p-1.5 rounded-full bg-fv-neutral-800/70 hover:bg-fv-neutral-700/90 transition-transform transform hover:scale-110"
732
- aria-label="Close gallery"
733
- @click="setModal(false)"
725
+ <!-- Thumbnail Grid -->
726
+ <div class="grid grid-cols-2 gap-2 p-2">
727
+ <div
728
+ v-for="i in images.length"
729
+ :key="`bg_${id}_${i}`"
730
+ class="group relative"
734
731
  >
735
- <component :is="closeIcon" class="w-5 h-5" />
736
- </button>
732
+ <div
733
+ class="absolute inset-0 rounded-lg transition-colors duration-300 group-hover:bg-fv-neutral-700/40"
734
+ :class="{ 'bg-fv-primary-500/40': i - 1 === modelValue }"
735
+ />
736
+ <img
737
+ v-if="imageComponent === 'img'"
738
+ :class="`h-auto max-w-full rounded-lg cursor-pointer shadow transition-all duration-300 group-hover:brightness-110 ${getBorderColor(
739
+ images[i - 1],
740
+ )}`"
741
+ :style="{
742
+ filter:
743
+ i - 1 === modelValue ? 'brightness(1)' : 'brightness(0.7)',
744
+ }"
745
+ :src="getThumbnailUrl(images[i - 1])"
746
+ :alt="`Thumbnail ${i}`"
747
+ @click="$eventBus.emit(`${id}GalleryImage`, i - 1)"
748
+ >
749
+ <component
750
+ :is="imageComponent"
751
+ v-else
752
+ :image="getThumbnailUrl(images[i - 1]).image"
753
+ :variant="getThumbnailUrl(images[i - 1]).variant"
754
+ :alt="getThumbnailUrl(images[i - 1]).alt"
755
+ :class="`h-auto max-w-full rounded-lg cursor-pointer shadow transition-all duration-300 group-hover:brightness-110 ${getBorderColor(
756
+ images[i - 1],
757
+ )}`"
758
+ :style="{
759
+ filter:
760
+ i - 1 === modelValue ? 'brightness(1)' : 'brightness(0.7)',
761
+ }"
762
+ :likes="getThumbnailUrl(images[i - 1]).likes"
763
+ :show-likes="getThumbnailUrl(images[i - 1]).showLikes"
764
+ :is-author="getThumbnailUrl(images[i - 1]).isAuthor"
765
+ :user-uuid="getThumbnailUrl(images[i - 1]).userUUID"
766
+ @click="$eventBus.emit(`${id}GalleryImage`, i - 1)"
767
+ />
768
+ </div>
737
769
  </div>
738
770
  </div>
739
771
  </transition>
740
772
 
741
- <!-- Mobile Thumbnail Preview -->
773
+ <!-- Mobile Thumbnail Preview (bottom of screen on mobile) -->
742
774
  <transition
743
775
  enter-active-class="transition-transform duration-300 ease-out"
744
776
  enter-from-class="translate-y-full"
@@ -749,7 +781,8 @@ onUnmounted(() => {
749
781
  >
750
782
  <div
751
783
  v-if="showControls && images.length > 1 && !sidePanel"
752
- 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]"
784
+ 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-45"
785
+ :class="{ 'pb-20': infoPanel }"
753
786
  >
754
787
  <div class="overflow-x-auto flex space-x-2 pb-1 px-1">
755
788
  <div
@@ -787,7 +820,7 @@ onUnmounted(() => {
787
820
  </div>
788
821
  </transition>
789
822
 
790
- <!-- Thumbnail Grid/Mason/Custom Layouts -->
823
+ <!-- Thumbnail Grid/Mason/Custom Layouts for non-opened gallery -->
791
824
  <div v-if="mode === 'grid' || mode === 'mason' || mode === 'custom'" class="gallery-grid">
792
825
  <div
793
826
  :class="{
@@ -881,6 +914,43 @@ onUnmounted(() => {
881
914
  </template>
882
915
 
883
916
  <style scoped>
917
+ /* Ensure controls stay fixed at top */
918
+ .controls-bar {
919
+ height: auto;
920
+ }
921
+
922
+ /* Layout container for main image and info panel */
923
+ .image-container {
924
+ position: relative;
925
+ display: flex;
926
+ flex-direction: column;
927
+ justify-content: center;
928
+ align-items: center;
929
+ height: 100%;
930
+ width: 100%;
931
+ }
932
+
933
+ /* Side panel positioning */
934
+ .side-panel {
935
+ height: 100vh;
936
+ overflow-y: auto;
937
+ overflow-x: hidden;
938
+ }
939
+
940
+ /* Info panel styling */
941
+ .info-panel {
942
+ width: 100%;
943
+ border-top-left-radius: 0.5rem;
944
+ border-top-right-radius: 0.5rem;
945
+ }
946
+
947
+ /* Image sizing in different contexts */
948
+ .image-display img,
949
+ .image-display .video-component {
950
+ transition: max-height 0.3s ease-out, max-width 0.3s ease-out;
951
+ object-fit: contain;
952
+ }
953
+
884
954
  /* Transition styles for next (right) navigation */
885
955
  .slide-next-enter-active,
886
956
  .slide-next-leave-active {
@@ -892,8 +962,8 @@ onUnmounted(() => {
892
962
 
893
963
  .slide-next-enter-from {
894
964
  opacity: 0;
895
- transform: translateX(100%);
896
- filter: blur(10px);
965
+ transform: translateX(30px);
966
+ filter: blur(8px);
897
967
  }
898
968
 
899
969
  .slide-next-enter-to {
@@ -910,8 +980,8 @@ onUnmounted(() => {
910
980
 
911
981
  .slide-next-leave-to {
912
982
  opacity: 0;
913
- transform: translateX(-100%);
914
- filter: blur(10px);
983
+ transform: translateX(-30px);
984
+ filter: blur(8px);
915
985
  }
916
986
 
917
987
  /* Transition styles for prev (left) navigation */
@@ -925,8 +995,8 @@ onUnmounted(() => {
925
995
 
926
996
  .slide-prev-enter-from {
927
997
  opacity: 0;
928
- transform: translateX(-100%);
929
- filter: blur(10px);
998
+ transform: translateX(-30px);
999
+ filter: blur(8px);
930
1000
  }
931
1001
 
932
1002
  .slide-prev-enter-to {
@@ -943,11 +1013,11 @@ onUnmounted(() => {
943
1013
 
944
1014
  .slide-prev-leave-to {
945
1015
  opacity: 0;
946
- transform: translateX(100%);
947
- filter: blur(10px);
1016
+ transform: translateX(30px);
1017
+ filter: blur(8px);
948
1018
  }
949
1019
 
950
- /* Modern grids */
1020
+ /* Grid layouts for thumbnails */
951
1021
  .gallery-grid {
952
1022
  min-height: 200px;
953
1023
  }