@fy-/fws-vue 2.3.13 → 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,44 +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
- 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
- }
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
+ })
113
168
  })
114
- }
169
+ }, 50)
170
+
171
+ // Update all layout measurements
172
+ const updateLayout = useDebounceFn(() => {
173
+ calculateImageSize()
174
+ }, 50)
115
175
 
176
+ // Modal controls
116
177
  function setModal(value: boolean) {
117
178
  if (value === true) {
118
179
  if (props.onOpen) props.onOpen()
119
180
  document.body.style.overflow = 'hidden' // Prevent scrolling when gallery is open
181
+
120
182
  if (!import.meta.env.SSR) {
121
183
  useEventListener(document, 'keydown', handleKeyboardInput)
122
184
  useEventListener(document, 'keyup', handleKeyboardRelease)
123
185
  }
186
+
124
187
  // Auto-hide controls after 3 seconds on mobile
125
- if (window.innerWidth < 1024) {
188
+ if (windowWidth.value < 1024) {
126
189
  controlsTimeout = window.setTimeout(() => {
127
190
  showControls.value = false
128
191
  }, 3000)
@@ -131,11 +194,13 @@ function setModal(value: boolean) {
131
194
  else {
132
195
  if (props.onClose) props.onClose()
133
196
  document.body.style.overflow = '' // Restore scrolling
197
+
134
198
  // Exit fullscreen if active
135
199
  if (isFullscreen.value) {
136
200
  exitFullscreen()
137
201
  isFullscreen.value = false
138
202
  }
203
+
139
204
  // Clear timeout if modal is closed
140
205
  if (controlsTimeout) {
141
206
  clearTimeout(controlsTimeout)
@@ -147,6 +212,7 @@ function setModal(value: boolean) {
147
212
  // Don't reset info panel state when opening/closing
148
213
  }
149
214
 
215
+ // Open gallery with debounce to prevent accidental double-clicks
150
216
  const openGalleryImage = useDebounceFn((index: number | undefined) => {
151
217
  if (index === undefined) {
152
218
  modelValue.value = 0
@@ -155,8 +221,14 @@ const openGalleryImage = useDebounceFn((index: number | undefined) => {
155
221
  modelValue.value = Number.parseInt(index.toString())
156
222
  }
157
223
  setModal(true)
158
- }, 50) // Debounce to prevent accidental double-opens
159
224
 
225
+ // Update layout after opening
226
+ nextTick(() => {
227
+ updateLayout()
228
+ })
229
+ }, 50)
230
+
231
+ // Navigation functions
160
232
  function goNextImage() {
161
233
  direction.value = 'next'
162
234
  if (modelValue.value < props.images.length - 1) {
@@ -174,34 +246,18 @@ function goPrevImage() {
174
246
  modelValue.value--
175
247
  }
176
248
  else {
177
- modelValue.value
178
- = props.images.length - 1 > 0 ? props.images.length - 1 : 0
249
+ modelValue.value = props.images.length - 1 > 0 ? props.images.length - 1 : 0
179
250
  }
180
251
  resetControlsTimer()
181
252
  }
182
253
 
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
-
254
+ // UI control functions
199
255
  function resetControlsTimer() {
200
256
  // Show controls when user interacts
201
257
  showControls.value = true
202
258
 
203
259
  // Only set timer on mobile
204
- if (window.innerWidth < 1024) {
260
+ if (windowWidth.value < 1024) {
205
261
  if (controlsTimeout) {
206
262
  clearTimeout(controlsTimeout)
207
263
  }
@@ -213,7 +269,7 @@ function resetControlsTimer() {
213
269
 
214
270
  function toggleControls() {
215
271
  showControls.value = !showControls.value
216
- if (showControls.value && window.innerWidth < 1024) {
272
+ if (showControls.value && windowWidth.value < 1024) {
217
273
  resetControlsTimer()
218
274
  }
219
275
  }
@@ -222,28 +278,32 @@ function toggleInfoPanel() {
222
278
  infoPanel.value = !infoPanel.value
223
279
  resetControlsTimer()
224
280
 
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
- }
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
+ })
235
295
  }
236
296
 
237
297
  function toggleFullscreen() {
238
298
  if (!isFullscreen.value) {
239
- if (galleryContainerRef.value) {
299
+ if (galleryRef.value) {
240
300
  enterFullscreen()
241
301
  .then(() => {
242
302
  isFullscreen.value = true
243
303
  // Give browser time to adjust fullscreen before updating sizing
244
304
  if (fullscreenResizeTimeout) clearTimeout(fullscreenResizeTimeout)
245
305
  fullscreenResizeTimeout = window.setTimeout(() => {
246
- updateImageSizes()
306
+ updateLayout()
247
307
  }, 50)
248
308
  })
249
309
  .catch(() => {})
@@ -253,6 +313,10 @@ function toggleFullscreen() {
253
313
  exitFullscreen()
254
314
  .then(() => {
255
315
  isFullscreen.value = false
316
+ if (fullscreenResizeTimeout) clearTimeout(fullscreenResizeTimeout)
317
+ fullscreenResizeTimeout = window.setTimeout(() => {
318
+ updateLayout()
319
+ }, 50)
256
320
  })
257
321
  .catch(() => {})
258
322
  }
@@ -269,7 +333,7 @@ const touchStart = useDebounceFn((event: TouchEvent) => {
269
333
 
270
334
  // Check if the touch started on an interactive element
271
335
  if (targetElement.closest('button, a, input, textarea, select')) {
272
- return // Don't handle swipe if interacting with an interactive element
336
+ return // Don't handle swipe if interacting with controls
273
337
  }
274
338
 
275
339
  start.x = touch.screenX
@@ -283,7 +347,7 @@ const touchEnd = useDebounceFn((event: TouchEvent) => {
283
347
 
284
348
  // Check if the touch ended on an interactive element
285
349
  if (targetElement.closest('button, a, input, textarea, select')) {
286
- return // Don't handle swipe if interacting with an interactive element
350
+ return // Don't handle swipe if interacting with controls
287
351
  }
288
352
 
289
353
  const end = { x: touch.screenX, y: touch.screenY }
@@ -300,16 +364,15 @@ const touchEnd = useDebounceFn((event: TouchEvent) => {
300
364
  // Add a threshold to prevent accidental swipes
301
365
  if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 50) {
302
366
  if (diffX > 0) {
303
- direction.value = 'next'
304
367
  goNextImage()
305
368
  }
306
369
  else {
307
- direction.value = 'prev'
308
370
  goPrevImage()
309
371
  }
310
372
  }
311
373
  }, 50)
312
374
 
375
+ // Border color function
313
376
  function getBorderColor(i: any) {
314
377
  if (props.borderColor !== undefined) {
315
378
  return props.borderColor(i)
@@ -317,8 +380,7 @@ function getBorderColor(i: any) {
317
380
  return ''
318
381
  }
319
382
 
320
- const isKeyPressed = ref<boolean>(false)
321
-
383
+ // Keyboard handlers
322
384
  function handleKeyboardInput(event: KeyboardEvent) {
323
385
  if (!isGalleryOpen.value) return
324
386
  if (isKeyPressed.value) return
@@ -330,12 +392,10 @@ function handleKeyboardInput(event: KeyboardEvent) {
330
392
  break
331
393
  case 'ArrowRight':
332
394
  isKeyPressed.value = true
333
- direction.value = 'next'
334
395
  goNextImage()
335
396
  break
336
397
  case 'ArrowLeft':
337
398
  isKeyPressed.value = true
338
- direction.value = 'prev'
339
399
  goPrevImage()
340
400
  break
341
401
  case 'f':
@@ -366,76 +426,55 @@ const handleBackdropClick = useDebounceFn((event: MouseEvent) => {
366
426
  }
367
427
  }, 200)
368
428
 
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)
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
+ )
402
447
 
448
+ // Lifecycle hooks
403
449
  onMounted(() => {
404
450
  eventBus.on(`${props.id}GalleryImage`, openGalleryImage)
405
451
  eventBus.on(`${props.id}Gallery`, openGalleryImage)
406
452
  eventBus.on(`${props.id}GalleryClose`, closeGallery)
407
453
 
408
- // Store reference to the gallery container
409
- galleryContainerRef.value = document.querySelector('.gallery-container')
454
+ // Initialize layout
455
+ nextTick(() => {
456
+ updateLayout()
457
+ })
410
458
 
411
- // Initialize info height once mounted (only if info panel is shown)
412
- if (infoPanel.value) {
413
- updateInfoHeight()
459
+ // Set up observers for dynamic resizing
460
+ if (topControlsRef.value) {
461
+ useResizeObserver(topControlsRef.value, updateLayout)
414
462
  }
415
463
 
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
- })
464
+ if (infoPanelRef.value) {
465
+ useResizeObserver(infoPanelRef.value, updateLayout)
424
466
  }
425
467
 
426
- // Listen for window resize events
427
- useEventListener(window, 'resize', handleWindowResize)
468
+ if (sidePanelRef.value) {
469
+ useResizeObserver(sidePanelRef.value, updateLayout)
470
+ }
428
471
 
429
- // Listen for fullscreen changes to update image sizes
472
+ // Listen for fullscreen changes
430
473
  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
- }
474
+ isFullscreen.value = !!document.fullscreenElement
475
+ nextTick(() => {
476
+ updateLayout()
477
+ })
439
478
  })
440
479
  })
441
480
 
@@ -476,286 +515,262 @@ onUnmounted(() => {
476
515
  >
477
516
  <div
478
517
  v-if="isGalleryOpen"
479
- 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"
480
520
  style="z-index: 37"
481
521
  role="dialog"
482
522
  aria-modal="true"
483
523
  @click="handleBackdropClick"
484
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 -->
485
577
  <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
578
+ ref="galleryContentRef"
579
+ class="w-full h-full flex flex-col lg:flex-row"
580
+ style="margin-top: var(--controls-height, 0px)"
489
581
  >
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 -->
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
+ >
494
596
  <div
495
- class="flex h-full relative flex-grow items-center justify-center gap-2 z-[1]"
496
- @touchstart="touchStart"
497
- @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"
498
599
  >
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"
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()"
507
604
  >
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>
605
+ <ChevronLeftIcon class="w-6 h-6 md:w-8 md:h-8" />
606
+ </button>
607
+ </div>
608
+ </transition>
521
609
 
522
- <!-- 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
+ >
523
622
  <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;"
623
+ :key="`image-display-${modelValue}`"
624
+ class="image-display relative w-full h-full flex flex-col items-center justify-center"
526
625
  >
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]"
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}`"
534
644
  >
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>
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>
604
659
  </div>
660
+ </transition>
661
+ </div>
605
662
 
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"
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()"
614
680
  >
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>
681
+ <ChevronRightIcon class="w-6 h-6 md:w-8 md:h-8" />
682
+ </button>
628
683
  </div>
629
- </div>
684
+ </transition>
630
685
 
631
- <!-- Side Panel for Thumbnails -->
686
+ <!-- Info Panel Below Image -->
632
687
  <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"
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"
639
694
  >
640
695
  <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;"
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"
644
699
  >
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>
700
+ <slot :value="images[modelValue]" />
695
701
  </div>
696
702
  </transition>
697
703
  </div>
698
704
 
699
- <!-- Top Controls -->
705
+ <!-- Side Thumbnails Panel -->
700
706
  <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
+ 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"
707
713
  >
708
714
  <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"
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` }"
711
719
  >
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>
720
+ <!-- Paging Controls if needed -->
721
+ <div v-if="paging" class="flex items-center justify-center pt-2">
722
+ <DefaultPaging :id="id" :items="paging" />
716
723
  </div>
717
724
 
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)"
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"
751
731
  >
752
- <component :is="closeIcon" class="w-5 h-5" />
753
- </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>
754
769
  </div>
755
770
  </div>
756
771
  </transition>
757
772
 
758
- <!-- Mobile Thumbnail Preview -->
773
+ <!-- Mobile Thumbnail Preview (bottom of screen on mobile) -->
759
774
  <transition
760
775
  enter-active-class="transition-transform duration-300 ease-out"
761
776
  enter-from-class="translate-y-full"
@@ -766,7 +781,8 @@ onUnmounted(() => {
766
781
  >
767
782
  <div
768
783
  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]"
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 }"
770
786
  >
771
787
  <div class="overflow-x-auto flex space-x-2 pb-1 px-1">
772
788
  <div
@@ -804,7 +820,7 @@ onUnmounted(() => {
804
820
  </div>
805
821
  </transition>
806
822
 
807
- <!-- Thumbnail Grid/Mason/Custom Layouts -->
823
+ <!-- Thumbnail Grid/Mason/Custom Layouts for non-opened gallery -->
808
824
  <div v-if="mode === 'grid' || mode === 'mason' || mode === 'custom'" class="gallery-grid">
809
825
  <div
810
826
  :class="{
@@ -898,13 +914,41 @@ onUnmounted(() => {
898
914
  </template>
899
915
 
900
916
  <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;
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;
908
952
  }
909
953
 
910
954
  /* Transition styles for next (right) navigation */
@@ -918,8 +962,8 @@ onUnmounted(() => {
918
962
 
919
963
  .slide-next-enter-from {
920
964
  opacity: 0;
921
- transform: translateX(100%);
922
- filter: blur(10px);
965
+ transform: translateX(30px);
966
+ filter: blur(8px);
923
967
  }
924
968
 
925
969
  .slide-next-enter-to {
@@ -936,8 +980,8 @@ onUnmounted(() => {
936
980
 
937
981
  .slide-next-leave-to {
938
982
  opacity: 0;
939
- transform: translateX(-100%);
940
- filter: blur(10px);
983
+ transform: translateX(-30px);
984
+ filter: blur(8px);
941
985
  }
942
986
 
943
987
  /* Transition styles for prev (left) navigation */
@@ -951,8 +995,8 @@ onUnmounted(() => {
951
995
 
952
996
  .slide-prev-enter-from {
953
997
  opacity: 0;
954
- transform: translateX(-100%);
955
- filter: blur(10px);
998
+ transform: translateX(-30px);
999
+ filter: blur(8px);
956
1000
  }
957
1001
 
958
1002
  .slide-prev-enter-to {
@@ -969,11 +1013,11 @@ onUnmounted(() => {
969
1013
 
970
1014
  .slide-prev-leave-to {
971
1015
  opacity: 0;
972
- transform: translateX(100%);
973
- filter: blur(10px);
1016
+ transform: translateX(30px);
1017
+ filter: blur(8px);
974
1018
  }
975
1019
 
976
- /* Modern grids */
1020
+ /* Grid layouts for thumbnails */
977
1021
  .gallery-grid {
978
1022
  min-height: 200px;
979
1023
  }