@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.
@@ -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,69 @@ 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 and width
165
+ img.style.maxHeight = availableHeight.value
166
+
167
+ // Adjust image size based on screen size
168
+ if (windowWidth.value <= 768) {
169
+ img.style.maxWidth = '95vw'
170
+ img.style.maxHeight = `calc(${windowHeight.value * 0.7 / 16}rem)`
171
+ }
172
+ else {
173
+ img.style.maxWidth = sidePanel.value ? 'calc(100vw - 17rem)' : '94vw'
174
+ }
175
+ })
113
176
  })
114
- }
177
+ }, 50)
115
178
 
179
+ // Update all layout measurements
180
+ const updateLayout = useDebounceFn(() => {
181
+ calculateImageSize()
182
+ }, 50)
183
+
184
+ // Modal controls
116
185
  function setModal(value: boolean) {
117
186
  if (value === true) {
118
187
  if (props.onOpen) props.onOpen()
119
188
  document.body.style.overflow = 'hidden' // Prevent scrolling when gallery is open
189
+
120
190
  if (!import.meta.env.SSR) {
121
191
  useEventListener(document, 'keydown', handleKeyboardInput)
122
192
  useEventListener(document, 'keyup', handleKeyboardRelease)
123
193
  }
194
+
124
195
  // Auto-hide controls after 3 seconds on mobile
125
- if (window.innerWidth < 1024) {
196
+ if (windowWidth.value < 1024) {
126
197
  controlsTimeout = window.setTimeout(() => {
127
198
  showControls.value = false
128
199
  }, 3000)
@@ -131,11 +202,13 @@ function setModal(value: boolean) {
131
202
  else {
132
203
  if (props.onClose) props.onClose()
133
204
  document.body.style.overflow = '' // Restore scrolling
205
+
134
206
  // Exit fullscreen if active
135
207
  if (isFullscreen.value) {
136
208
  exitFullscreen()
137
209
  isFullscreen.value = false
138
210
  }
211
+
139
212
  // Clear timeout if modal is closed
140
213
  if (controlsTimeout) {
141
214
  clearTimeout(controlsTimeout)
@@ -147,6 +220,7 @@ function setModal(value: boolean) {
147
220
  // Don't reset info panel state when opening/closing
148
221
  }
149
222
 
223
+ // Open gallery with debounce to prevent accidental double-clicks
150
224
  const openGalleryImage = useDebounceFn((index: number | undefined) => {
151
225
  if (index === undefined) {
152
226
  modelValue.value = 0
@@ -155,8 +229,14 @@ const openGalleryImage = useDebounceFn((index: number | undefined) => {
155
229
  modelValue.value = Number.parseInt(index.toString())
156
230
  }
157
231
  setModal(true)
158
- }, 50) // Debounce to prevent accidental double-opens
159
232
 
233
+ // Update layout after opening
234
+ nextTick(() => {
235
+ updateLayout()
236
+ })
237
+ }, 50)
238
+
239
+ // Navigation functions
160
240
  function goNextImage() {
161
241
  direction.value = 'next'
162
242
  if (modelValue.value < props.images.length - 1) {
@@ -174,34 +254,18 @@ function goPrevImage() {
174
254
  modelValue.value--
175
255
  }
176
256
  else {
177
- modelValue.value
178
- = props.images.length - 1 > 0 ? props.images.length - 1 : 0
257
+ modelValue.value = props.images.length - 1 > 0 ? props.images.length - 1 : 0
179
258
  }
180
259
  resetControlsTimer()
181
260
  }
182
261
 
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
-
262
+ // UI control functions
199
263
  function resetControlsTimer() {
200
264
  // Show controls when user interacts
201
265
  showControls.value = true
202
266
 
203
267
  // Only set timer on mobile
204
- if (window.innerWidth < 1024) {
268
+ if (windowWidth.value < 1024) {
205
269
  if (controlsTimeout) {
206
270
  clearTimeout(controlsTimeout)
207
271
  }
@@ -213,7 +277,7 @@ function resetControlsTimer() {
213
277
 
214
278
  function toggleControls() {
215
279
  showControls.value = !showControls.value
216
- if (showControls.value && window.innerWidth < 1024) {
280
+ if (showControls.value && windowWidth.value < 1024) {
217
281
  resetControlsTimer()
218
282
  }
219
283
  }
@@ -222,28 +286,32 @@ function toggleInfoPanel() {
222
286
  infoPanel.value = !infoPanel.value
223
287
  resetControlsTimer()
224
288
 
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
- }
289
+ // Update layout after panel toggle
290
+ nextTick(() => {
291
+ updateLayout()
292
+ })
293
+ }
294
+
295
+ function toggleSidePanel() {
296
+ sidePanel.value = !sidePanel.value
297
+ resetControlsTimer()
298
+
299
+ // Update layout after panel toggle
300
+ nextTick(() => {
301
+ updateLayout()
302
+ })
235
303
  }
236
304
 
237
305
  function toggleFullscreen() {
238
306
  if (!isFullscreen.value) {
239
- if (galleryContainerRef.value) {
307
+ if (galleryRef.value) {
240
308
  enterFullscreen()
241
309
  .then(() => {
242
310
  isFullscreen.value = true
243
311
  // Give browser time to adjust fullscreen before updating sizing
244
312
  if (fullscreenResizeTimeout) clearTimeout(fullscreenResizeTimeout)
245
313
  fullscreenResizeTimeout = window.setTimeout(() => {
246
- updateImageSizes()
314
+ updateLayout()
247
315
  }, 50)
248
316
  })
249
317
  .catch(() => {})
@@ -253,6 +321,10 @@ function toggleFullscreen() {
253
321
  exitFullscreen()
254
322
  .then(() => {
255
323
  isFullscreen.value = false
324
+ if (fullscreenResizeTimeout) clearTimeout(fullscreenResizeTimeout)
325
+ fullscreenResizeTimeout = window.setTimeout(() => {
326
+ updateLayout()
327
+ }, 50)
256
328
  })
257
329
  .catch(() => {})
258
330
  }
@@ -269,7 +341,7 @@ const touchStart = useDebounceFn((event: TouchEvent) => {
269
341
 
270
342
  // Check if the touch started on an interactive element
271
343
  if (targetElement.closest('button, a, input, textarea, select')) {
272
- return // Don't handle swipe if interacting with an interactive element
344
+ return // Don't handle swipe if interacting with controls
273
345
  }
274
346
 
275
347
  start.x = touch.screenX
@@ -283,7 +355,7 @@ const touchEnd = useDebounceFn((event: TouchEvent) => {
283
355
 
284
356
  // Check if the touch ended on an interactive element
285
357
  if (targetElement.closest('button, a, input, textarea, select')) {
286
- return // Don't handle swipe if interacting with an interactive element
358
+ return // Don't handle swipe if interacting with controls
287
359
  }
288
360
 
289
361
  const end = { x: touch.screenX, y: touch.screenY }
@@ -300,16 +372,15 @@ const touchEnd = useDebounceFn((event: TouchEvent) => {
300
372
  // Add a threshold to prevent accidental swipes
301
373
  if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 50) {
302
374
  if (diffX > 0) {
303
- direction.value = 'next'
304
375
  goNextImage()
305
376
  }
306
377
  else {
307
- direction.value = 'prev'
308
378
  goPrevImage()
309
379
  }
310
380
  }
311
381
  }, 50)
312
382
 
383
+ // Border color function
313
384
  function getBorderColor(i: any) {
314
385
  if (props.borderColor !== undefined) {
315
386
  return props.borderColor(i)
@@ -317,8 +388,7 @@ function getBorderColor(i: any) {
317
388
  return ''
318
389
  }
319
390
 
320
- const isKeyPressed = ref<boolean>(false)
321
-
391
+ // Keyboard handlers
322
392
  function handleKeyboardInput(event: KeyboardEvent) {
323
393
  if (!isGalleryOpen.value) return
324
394
  if (isKeyPressed.value) return
@@ -330,12 +400,10 @@ function handleKeyboardInput(event: KeyboardEvent) {
330
400
  break
331
401
  case 'ArrowRight':
332
402
  isKeyPressed.value = true
333
- direction.value = 'next'
334
403
  goNextImage()
335
404
  break
336
405
  case 'ArrowLeft':
337
406
  isKeyPressed.value = true
338
- direction.value = 'prev'
339
407
  goPrevImage()
340
408
  break
341
409
  case 'f':
@@ -366,76 +434,55 @@ const handleBackdropClick = useDebounceFn((event: MouseEvent) => {
366
434
  }
367
435
  }, 200)
368
436
 
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)
437
+ // Watch for image changes, fullscreen, or panel visibility changes
438
+ watch(
439
+ [
440
+ currentImage,
441
+ isFullscreen,
442
+ infoPanel,
443
+ sidePanel,
444
+ windowWidth,
445
+ windowHeight,
446
+ galleryWidth,
447
+ galleryHeight,
448
+ topControlsHeight,
449
+ infoPanelHeight,
450
+ ],
451
+ () => {
452
+ updateLayout()
453
+ },
454
+ )
402
455
 
456
+ // Lifecycle hooks
403
457
  onMounted(() => {
404
458
  eventBus.on(`${props.id}GalleryImage`, openGalleryImage)
405
459
  eventBus.on(`${props.id}Gallery`, openGalleryImage)
406
460
  eventBus.on(`${props.id}GalleryClose`, closeGallery)
407
461
 
408
- // Store reference to the gallery container
409
- galleryContainerRef.value = document.querySelector('.gallery-container')
462
+ // Initialize layout
463
+ nextTick(() => {
464
+ updateLayout()
465
+ })
410
466
 
411
- // Initialize info height once mounted (only if info panel is shown)
412
- if (infoPanel.value) {
413
- updateInfoHeight()
467
+ // Set up observers for dynamic resizing
468
+ if (topControlsRef.value) {
469
+ useResizeObserver(topControlsRef.value, updateLayout)
414
470
  }
415
471
 
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
- })
472
+ if (infoPanelRef.value) {
473
+ useResizeObserver(infoPanelRef.value, updateLayout)
424
474
  }
425
475
 
426
- // Listen for window resize events
427
- useEventListener(window, 'resize', handleWindowResize)
476
+ if (sidePanelRef.value) {
477
+ useResizeObserver(sidePanelRef.value, updateLayout)
478
+ }
428
479
 
429
- // Listen for fullscreen changes to update image sizes
480
+ // Listen for fullscreen changes
430
481
  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
- }
482
+ isFullscreen.value = !!document.fullscreenElement
483
+ nextTick(() => {
484
+ updateLayout()
485
+ })
439
486
  })
440
487
  })
441
488
 
@@ -476,286 +523,264 @@ onUnmounted(() => {
476
523
  >
477
524
  <div
478
525
  v-if="isGalleryOpen"
479
- class="fixed bg-fv-neutral-900 text-white inset-0 max-w-[100vw] overflow-hidden gallery-container"
526
+ ref="galleryRef"
527
+ class="fixed bg-fv-neutral-900 text-white inset-0 max-w-[100vw] max-h-[100vh] overflow-hidden gallery-container"
480
528
  style="z-index: 37"
481
529
  role="dialog"
482
530
  aria-modal="true"
483
531
  @click="handleBackdropClick"
484
532
  >
533
+ <!-- Top Controls Bar - Fixed at top -->
534
+ <transition
535
+ enter-active-class="transition-opacity duration-300"
536
+ enter-from-class="opacity-0"
537
+ enter-to-class="opacity-100"
538
+ leave-active-class="transition-opacity duration-300"
539
+ leave-from-class="opacity-100"
540
+ leave-to-class="opacity-0"
541
+ >
542
+ <div
543
+ v-if="showControls"
544
+ ref="topControlsRef"
545
+ 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"
546
+ >
547
+ <!-- Title and Counter -->
548
+ <div class="flex items-center space-x-2">
549
+ <span v-if="title" class="font-medium text-lg">{{ title }}</span>
550
+ <span class="text-sm opacity-80">{{ currentIndex }} / {{ imageCount }}</span>
551
+ </div>
552
+
553
+ <!-- Control Buttons -->
554
+ <div class="flex items-center space-x-2">
555
+ <button
556
+ class="btn p-1.5 rounded-full bg-fv-neutral-800/70 hover:bg-fv-neutral-700/90 transition-transform transform hover:scale-110"
557
+ :class="{ 'bg-fv-primary-500/70': infoPanel }"
558
+ :title="infoPanel ? 'Hide info' : 'Show info'"
559
+ @click="toggleInfoPanel"
560
+ >
561
+ <InformationCircleIcon class="w-5 h-5" />
562
+ </button>
563
+
564
+ <button
565
+ class="btn p-1.5 rounded-full bg-fv-neutral-800/70 hover:bg-fv-neutral-700/90 transition-transform transform hover:scale-110"
566
+ :title="sidePanel ? 'Hide thumbnails' : 'Show thumbnails'"
567
+ @click="toggleSidePanel"
568
+ >
569
+ <ChevronDoubleRightIcon v-if="sidePanel" class="w-5 h-5" />
570
+ <ChevronDoubleLeftIcon v-else class="w-5 h-5" />
571
+ </button>
572
+
573
+ <button
574
+ class="btn p-1.5 rounded-full bg-fv-neutral-800/70 hover:bg-fv-neutral-700/90 transition-transform transform hover:scale-110"
575
+ aria-label="Close gallery"
576
+ @click="setModal(false)"
577
+ >
578
+ <component :is="closeIcon" class="w-5 h-5" />
579
+ </button>
580
+ </div>
581
+ </div>
582
+ </transition>
583
+
584
+ <!-- Main Gallery Content - Flexbox layout -->
485
585
  <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
586
+ ref="galleryContentRef"
587
+ class="w-full h-full flex flex-col lg:flex-row"
588
+ style="margin-top: var(--controls-height, 0px)"
489
589
  >
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 -->
590
+ <!-- Main Image Area - Fills available space -->
591
+ <div
592
+ class="relative flex-1 h-full flex items-center justify-center"
593
+ :class="{ 'lg:pr-64': sidePanel, 'lg:max-w-[calc(100%-16rem)]': sidePanel }"
594
+ style="max-width: 100%;"
595
+ >
596
+ <!-- Image Navigation Controls - Left -->
597
+ <transition
598
+ enter-active-class="transition-opacity duration-300"
599
+ enter-from-class="opacity-0"
600
+ enter-to-class="opacity-100"
601
+ leave-active-class="transition-opacity duration-300"
602
+ leave-from-class="opacity-100"
603
+ leave-to-class="opacity-0"
604
+ >
494
605
  <div
495
- class="flex h-full relative flex-grow items-center justify-center gap-2 z-[1]"
496
- @touchstart="touchStart"
497
- @touchend="touchEnd"
606
+ v-if="showControls && images.length > 1"
607
+ class="absolute left-0 z-40 h-full flex items-center px-2 md:px-4"
498
608
  >
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"
609
+ <button
610
+ 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"
611
+ aria-label="Previous image"
612
+ @click="goPrevImage()"
507
613
  >
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>
614
+ <ChevronLeftIcon class="w-6 h-6 md:w-8 md:h-8" />
615
+ </button>
616
+ </div>
617
+ </transition>
521
618
 
522
- <!-- Main Image Container -->
619
+ <!-- Image Display Container -->
620
+ <div
621
+ ref="imageContainerRef"
622
+ class="image-container flex-grow flex items-center justify-center"
623
+ :class="{ 'has-info': infoPanel }"
624
+ @touchstart="touchStart"
625
+ @touchend="touchEnd"
626
+ >
627
+ <transition
628
+ :name="direction === 'next' ? 'slide-next' : 'slide-prev'"
629
+ mode="out-in"
630
+ >
523
631
  <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;"
632
+ :key="`image-display-${modelValue}`"
633
+ class="image-display relative w-full h-full flex flex-col items-center justify-center"
526
634
  >
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]"
635
+ <!-- Actual Image/Video Content -->
636
+ <template v-if="videoComponent && isVideo(images[modelValue])">
637
+ <ClientOnly>
638
+ <component
639
+ :is="videoComponent"
640
+ :src="isVideo(images[modelValue])"
641
+ class="shadow max-w-full h-auto object-contain video-component"
642
+ :style="{ maxHeight: availableHeight }"
643
+ />
644
+ </ClientOnly>
645
+ </template>
646
+ <template v-else>
647
+ <img
648
+ v-if="modelValueSrc && imageComponent === 'img'"
649
+ class="shadow max-w-full h-auto object-contain"
650
+ :style="{ maxHeight: availableHeight }"
651
+ :src="modelValueSrc"
652
+ :alt="`Gallery image ${modelValue + 1}`"
534
653
  >
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>
654
+ <component
655
+ :is="imageComponent"
656
+ v-else-if="modelValueSrc && imageComponent"
657
+ :image="modelValueSrc.image"
658
+ :variant="modelValueSrc.variant"
659
+ :alt="modelValueSrc.alt"
660
+ class="shadow max-w-full h-auto object-contain"
661
+ :style="{ maxHeight: availableHeight }"
662
+ :likes="modelValueSrc.likes"
663
+ :show-likes="modelValueSrc.showLikes"
664
+ :is-author="modelValueSrc.isAuthor"
665
+ :user-uuid="modelValueSrc.userUUID"
666
+ />
667
+ </template>
604
668
  </div>
669
+ </transition>
670
+ </div>
605
671
 
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"
672
+ <!-- Image Navigation Controls - Right -->
673
+ <transition
674
+ enter-active-class="transition-opacity duration-300"
675
+ enter-from-class="opacity-0"
676
+ enter-to-class="opacity-100"
677
+ leave-active-class="transition-opacity duration-300"
678
+ leave-from-class="opacity-100"
679
+ leave-to-class="opacity-0"
680
+ >
681
+ <div
682
+ v-if="showControls && images.length > 1"
683
+ class="absolute right-0 z-40 h-full flex items-center px-2 md:px-4"
684
+ :class="{ 'lg:mr-64': sidePanel }"
685
+ >
686
+ <button
687
+ 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"
688
+ aria-label="Next image"
689
+ @click="goNextImage()"
614
690
  >
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>
691
+ <ChevronRightIcon class="w-6 h-6 md:w-8 md:h-8" />
692
+ </button>
628
693
  </div>
629
- </div>
694
+ </transition>
630
695
 
631
- <!-- Side Panel for Thumbnails -->
696
+ <!-- Info Panel Below Image -->
632
697
  <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"
698
+ enter-active-class="transition-all duration-300 ease-out"
699
+ enter-from-class="opacity-0 transform translate-y-4"
700
+ enter-to-class="opacity-100 transform translate-y-0"
701
+ leave-active-class="transition-all duration-300 ease-in"
702
+ leave-from-class="opacity-100 transform translate-y-0"
703
+ leave-to-class="opacity-0 transform translate-y-4"
639
704
  >
640
705
  <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;"
706
+ v-if="infoPanel && images[modelValue]"
707
+ ref="infoPanelRef"
708
+ 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
709
  >
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>
710
+ <slot :value="images[modelValue]" />
695
711
  </div>
696
712
  </transition>
697
713
  </div>
698
714
 
699
- <!-- Top Controls -->
715
+ <!-- Side Thumbnails Panel -->
700
716
  <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"
717
+ enter-active-class="transform transition ease-in-out duration-300"
718
+ enter-from-class="translate-x-full"
719
+ enter-to-class="translate-x-0"
720
+ leave-active-class="transform transition ease-in-out duration-300"
721
+ leave-from-class="translate-x-0"
722
+ leave-to-class="translate-x-full"
707
723
  >
708
724
  <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"
725
+ v-if="sidePanel"
726
+ ref="sidePanelRef"
727
+ 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 cool-scroll"
728
+ :style="{ 'padding-top': `${topControlsHeight}px` }"
711
729
  >
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>
730
+ <!-- Paging Controls if needed -->
731
+ <div v-if="paging" class="flex items-center justify-center pt-2">
732
+ <DefaultPaging :id="id" :items="paging" />
716
733
  </div>
717
734
 
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)"
735
+ <!-- Thumbnail Grid -->
736
+ <div class="grid grid-cols-2 gap-2 p-2">
737
+ <div
738
+ v-for="i in images.length"
739
+ :key="`bg_${id}_${i}`"
740
+ class="group relative"
751
741
  >
752
- <component :is="closeIcon" class="w-5 h-5" />
753
- </button>
742
+ <div
743
+ class="absolute inset-0 rounded-lg transition-colors duration-300 group-hover:bg-fv-neutral-700/40"
744
+ :class="{ 'bg-fv-primary-500/40': i - 1 === modelValue }"
745
+ />
746
+ <img
747
+ v-if="imageComponent === 'img'"
748
+ :class="`h-auto max-w-full rounded-lg cursor-pointer shadow transition-all duration-300 group-hover:brightness-110 ${getBorderColor(
749
+ images[i - 1],
750
+ )}`"
751
+ :style="{
752
+ filter:
753
+ i - 1 === modelValue ? 'brightness(1)' : 'brightness(0.7)',
754
+ }"
755
+ :src="getThumbnailUrl(images[i - 1])"
756
+ :alt="`Thumbnail ${i}`"
757
+ @click="$eventBus.emit(`${id}GalleryImage`, i - 1)"
758
+ >
759
+ <component
760
+ :is="imageComponent"
761
+ v-else
762
+ :image="getThumbnailUrl(images[i - 1]).image"
763
+ :variant="getThumbnailUrl(images[i - 1]).variant"
764
+ :alt="getThumbnailUrl(images[i - 1]).alt"
765
+ :class="`h-auto max-w-full rounded-lg cursor-pointer shadow transition-all duration-300 group-hover:brightness-110 ${getBorderColor(
766
+ images[i - 1],
767
+ )}`"
768
+ :style="{
769
+ filter:
770
+ i - 1 === modelValue ? 'brightness(1)' : 'brightness(0.7)',
771
+ }"
772
+ :likes="getThumbnailUrl(images[i - 1]).likes"
773
+ :show-likes="getThumbnailUrl(images[i - 1]).showLikes"
774
+ :is-author="getThumbnailUrl(images[i - 1]).isAuthor"
775
+ :user-uuid="getThumbnailUrl(images[i - 1]).userUUID"
776
+ @click="$eventBus.emit(`${id}GalleryImage`, i - 1)"
777
+ />
778
+ </div>
754
779
  </div>
755
780
  </div>
756
781
  </transition>
757
782
 
758
- <!-- Mobile Thumbnail Preview -->
783
+ <!-- Mobile Thumbnail Preview (bottom of screen on mobile) -->
759
784
  <transition
760
785
  enter-active-class="transition-transform duration-300 ease-out"
761
786
  enter-from-class="translate-y-full"
@@ -766,7 +791,8 @@ onUnmounted(() => {
766
791
  >
767
792
  <div
768
793
  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]"
794
+ 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"
795
+ :class="{ 'pb-20': infoPanel }"
770
796
  >
771
797
  <div class="overflow-x-auto flex space-x-2 pb-1 px-1">
772
798
  <div
@@ -804,7 +830,7 @@ onUnmounted(() => {
804
830
  </div>
805
831
  </transition>
806
832
 
807
- <!-- Thumbnail Grid/Mason/Custom Layouts -->
833
+ <!-- Thumbnail Grid/Mason/Custom Layouts for non-opened gallery -->
808
834
  <div v-if="mode === 'grid' || mode === 'mason' || mode === 'custom'" class="gallery-grid">
809
835
  <div
810
836
  :class="{
@@ -898,13 +924,41 @@ onUnmounted(() => {
898
924
  </template>
899
925
 
900
926
  <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;
927
+ /* Ensure controls stay fixed at top */
928
+ .controls-bar {
929
+ height: auto;
930
+ }
931
+
932
+ /* Layout container for main image and info panel */
933
+ .image-container {
934
+ position: relative;
935
+ display: flex;
936
+ flex-direction: column;
937
+ justify-content: center;
938
+ align-items: center;
939
+ height: 100%;
940
+ width: 100%;
941
+ }
942
+
943
+ /* Side panel positioning */
944
+ .side-panel {
945
+ height: 100vh;
946
+ overflow-y: auto;
947
+ overflow-x: hidden;
948
+ }
949
+
950
+ /* Info panel styling */
951
+ .info-panel {
952
+ width: 100%;
953
+ border-top-left-radius: 0.5rem;
954
+ border-top-right-radius: 0.5rem;
955
+ }
956
+
957
+ /* Image sizing in different contexts */
958
+ .image-display img,
959
+ .image-display .video-component {
960
+ transition: max-height 0.3s ease-out, max-width 0.3s ease-out;
961
+ object-fit: contain;
908
962
  }
909
963
 
910
964
  /* Transition styles for next (right) navigation */
@@ -918,8 +972,8 @@ onUnmounted(() => {
918
972
 
919
973
  .slide-next-enter-from {
920
974
  opacity: 0;
921
- transform: translateX(100%);
922
- filter: blur(10px);
975
+ transform: translateX(30px);
976
+ filter: blur(8px);
923
977
  }
924
978
 
925
979
  .slide-next-enter-to {
@@ -936,8 +990,8 @@ onUnmounted(() => {
936
990
 
937
991
  .slide-next-leave-to {
938
992
  opacity: 0;
939
- transform: translateX(-100%);
940
- filter: blur(10px);
993
+ transform: translateX(-30px);
994
+ filter: blur(8px);
941
995
  }
942
996
 
943
997
  /* Transition styles for prev (left) navigation */
@@ -951,8 +1005,8 @@ onUnmounted(() => {
951
1005
 
952
1006
  .slide-prev-enter-from {
953
1007
  opacity: 0;
954
- transform: translateX(-100%);
955
- filter: blur(10px);
1008
+ transform: translateX(-30px);
1009
+ filter: blur(8px);
956
1010
  }
957
1011
 
958
1012
  .slide-prev-enter-to {
@@ -969,11 +1023,11 @@ onUnmounted(() => {
969
1023
 
970
1024
  .slide-prev-leave-to {
971
1025
  opacity: 0;
972
- transform: translateX(100%);
973
- filter: blur(10px);
1026
+ transform: translateX(30px);
1027
+ filter: blur(8px);
974
1028
  }
975
1029
 
976
- /* Modern grids */
1030
+ /* Grid layouts for thumbnails */
977
1031
  .gallery-grid {
978
1032
  min-height: 200px;
979
1033
  }