@fy-/fws-vue 2.2.50 → 2.2.52

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.
@@ -39,14 +39,23 @@ function showConfirm(data: ConfirmModalData) {
39
39
  title.value = data.title
40
40
  desc.value = data.desc
41
41
  onConfirm.value = data.onConfirm
42
- isOpen.value = true
42
+
43
+ // Emit event first to ensure it's registered before opening the modal
43
44
  eventBus.emit('confirmModal', true)
44
- nextTick(() => {
45
- previouslyFocusedElement = document.activeElement as HTMLElement
46
- if (modalRef.value) {
47
- modalRef.value.focus()
48
- }
49
- })
45
+
46
+ // Force this to happen at the end of the event loop
47
+ // to ensure it happens after any other modal operations
48
+ setTimeout(() => {
49
+ isOpen.value = true
50
+ eventBus.emit('confirmModal', true)
51
+
52
+ nextTick(() => {
53
+ previouslyFocusedElement = document.activeElement as HTMLElement
54
+ if (modalRef.value) {
55
+ modalRef.value.focus()
56
+ }
57
+ })
58
+ }, 0)
50
59
  }
51
60
 
52
61
  onMounted(() => {
@@ -2,19 +2,24 @@
2
2
  import type { Component } from 'vue'
3
3
  import type { APIPaging } from '../../composables/rest'
4
4
  import {
5
- ArrowLeftCircleIcon,
6
- ArrowRightCircleIcon,
7
5
  ChevronDoubleLeftIcon,
8
6
  ChevronDoubleRightIcon,
9
- XCircleIcon,
7
+ ChevronLeftIcon,
8
+ ChevronRightIcon,
9
+ InformationCircleIcon,
10
+ XMarkIcon,
10
11
  } from '@heroicons/vue/24/solid'
11
- import { computed, h, onMounted, onUnmounted, reactive, ref } from 'vue'
12
+ import { computed, h, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
12
13
  import { useEventBus } from '../../composables/event-bus'
13
14
  import DefaultPaging from './DefaultPaging.vue'
14
15
 
15
16
  const isGalleryOpen = ref<boolean>(false)
16
17
  const eventBus = useEventBus()
17
18
  const sidePanel = ref<boolean>(true)
19
+ const showControls = ref<boolean>(true)
20
+ const isFullscreen = ref<boolean>(false)
21
+ const infoPanel = ref<boolean>(false)
22
+ const touchStartTime = ref<number>(0)
18
23
 
19
24
  const props = withDefaults(
20
25
  defineProps<{
@@ -44,7 +49,7 @@ const props = withDefaults(
44
49
  imageComponent: 'img',
45
50
  mode: 'grid',
46
51
  gridHeight: 4,
47
- closeIcon: () => h(XCircleIcon),
52
+ closeIcon: () => h(XMarkIcon),
48
53
  images: () => [],
49
54
  isVideo: () => false,
50
55
  getImageUrl: (image: any) => image.image_url,
@@ -65,6 +70,8 @@ const modelValue = computed({
65
70
 
66
71
  const direction = ref<'next' | 'prev'>('next')
67
72
 
73
+ let controlsTimeout: number | null = null
74
+
68
75
  function setModal(value: boolean) {
69
76
  if (value === true) {
70
77
  if (props.onOpen) props.onOpen()
@@ -73,6 +80,12 @@ function setModal(value: boolean) {
73
80
  document.addEventListener('keydown', handleKeyboardInput)
74
81
  document.addEventListener('keyup', handleKeyboardRelease)
75
82
  }
83
+ // Auto-hide controls after 3 seconds on mobile
84
+ if (window.innerWidth < 1024) {
85
+ controlsTimeout = window.setTimeout(() => {
86
+ showControls.value = false
87
+ }, 3000)
88
+ }
76
89
  }
77
90
  else {
78
91
  if (props.onClose) props.onClose()
@@ -81,8 +94,20 @@ function setModal(value: boolean) {
81
94
  document.removeEventListener('keydown', handleKeyboardInput)
82
95
  document.removeEventListener('keyup', handleKeyboardRelease)
83
96
  }
97
+ // Clear timeout if modal is closed
98
+ if (controlsTimeout) {
99
+ clearTimeout(controlsTimeout)
100
+ controlsTimeout = null
101
+ }
102
+ // Exit fullscreen if active
103
+ if (isFullscreen.value && document.exitFullscreen) {
104
+ document.exitFullscreen().catch(() => {})
105
+ isFullscreen.value = false
106
+ }
84
107
  }
85
108
  isGalleryOpen.value = value
109
+ showControls.value = true
110
+ infoPanel.value = false
86
111
  }
87
112
 
88
113
  function openGalleryImage(index: number | undefined) {
@@ -103,6 +128,7 @@ function goNextImage() {
103
128
  else {
104
129
  modelValue.value = 0
105
130
  }
131
+ resetControlsTimer()
106
132
  }
107
133
 
108
134
  function goPrevImage() {
@@ -114,6 +140,7 @@ function goPrevImage() {
114
140
  modelValue.value
115
141
  = props.images.length - 1 > 0 ? props.images.length - 1 : 0
116
142
  }
143
+ resetControlsTimer()
117
144
  }
118
145
 
119
146
  const modelValueSrc = computed(() => {
@@ -122,12 +149,69 @@ const modelValueSrc = computed(() => {
122
149
  return props.getImageUrl(props.images[modelValue.value])
123
150
  })
124
151
 
152
+ const currentImage = computed(() => {
153
+ if (props.images.length === 0) return null
154
+ return props.images[modelValue.value]
155
+ })
156
+
157
+ const imageCount = computed(() => props.images.length)
158
+ const currentIndex = computed(() => modelValue.value + 1)
159
+
125
160
  const start = reactive({ x: 0, y: 0 })
126
161
 
162
+ function resetControlsTimer() {
163
+ // Show controls when user interacts
164
+ showControls.value = true
165
+
166
+ // Only set timer on mobile
167
+ if (window.innerWidth < 1024) {
168
+ if (controlsTimeout) {
169
+ clearTimeout(controlsTimeout)
170
+ }
171
+ controlsTimeout = window.setTimeout(() => {
172
+ showControls.value = false
173
+ }, 3000)
174
+ }
175
+ }
176
+
177
+ function toggleControls() {
178
+ showControls.value = !showControls.value
179
+ if (showControls.value && window.innerWidth < 1024) {
180
+ resetControlsTimer()
181
+ }
182
+ }
183
+
184
+ function toggleInfoPanel() {
185
+ infoPanel.value = !infoPanel.value
186
+ resetControlsTimer()
187
+ }
188
+
189
+ function toggleFullscreen() {
190
+ if (!isFullscreen.value) {
191
+ const element = document.querySelector('.gallery-container') as HTMLElement
192
+ if (element && element.requestFullscreen) {
193
+ element.requestFullscreen().then(() => {
194
+ isFullscreen.value = true
195
+ }).catch(() => {})
196
+ }
197
+ }
198
+ else {
199
+ if (document.exitFullscreen) {
200
+ document.exitFullscreen().then(() => {
201
+ isFullscreen.value = false
202
+ }).catch(() => {})
203
+ }
204
+ }
205
+ resetControlsTimer()
206
+ }
207
+
127
208
  function touchStart(event: TouchEvent) {
128
209
  const touch = event.touches[0]
129
210
  const targetElement = touch.target as HTMLElement
130
211
 
212
+ // Store start time for tap detection
213
+ touchStartTime.value = Date.now()
214
+
131
215
  // Check if the touch started on an interactive element
132
216
  if (targetElement.closest('button, a, input, textarea, select')) {
133
217
  return // Don't handle swipe if interacting with an interactive element
@@ -136,9 +220,11 @@ function touchStart(event: TouchEvent) {
136
220
  start.x = touch.screenX
137
221
  start.y = touch.screenY
138
222
  }
223
+
139
224
  function touchEnd(event: TouchEvent) {
140
225
  const touch = event.changedTouches[0]
141
226
  const targetElement = touch.target as HTMLElement
227
+ const touchDuration = Date.now() - touchStartTime.value
142
228
 
143
229
  // Check if the touch ended on an interactive element
144
230
  if (targetElement.closest('button, a, input, textarea, select')) {
@@ -150,6 +236,12 @@ function touchEnd(event: TouchEvent) {
150
236
  const diffX = start.x - end.x
151
237
  const diffY = start.y - end.y
152
238
 
239
+ // Detect tap (quick touch with minimal movement)
240
+ if (Math.abs(diffX) < 10 && Math.abs(diffY) < 10 && touchDuration < 300) {
241
+ toggleControls()
242
+ return
243
+ }
244
+
153
245
  // Add a threshold to prevent accidental swipes
154
246
  if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 50) {
155
247
  if (diffX > 0) {
@@ -191,6 +283,12 @@ function handleKeyboardInput(event: KeyboardEvent) {
191
283
  direction.value = 'prev'
192
284
  goPrevImage()
193
285
  break
286
+ case 'f':
287
+ toggleFullscreen()
288
+ break
289
+ case 'i':
290
+ toggleInfoPanel()
291
+ break
194
292
  default:
195
293
  break
196
294
  }
@@ -213,6 +311,11 @@ function handleBackdropClick(event: MouseEvent) {
213
311
  }
214
312
  }
215
313
 
314
+ watch(currentImage, () => {
315
+ // Reset info panel when image changes
316
+ infoPanel.value = false
317
+ })
318
+
216
319
  onMounted(() => {
217
320
  eventBus.on(`${props.id}GalleryImage`, openGalleryImage)
218
321
  eventBus.on(`${props.id}Gallery`, openGalleryImage)
@@ -228,6 +331,10 @@ onUnmounted(() => {
228
331
  document.removeEventListener('keyup', handleKeyboardRelease)
229
332
  document.body.style.overflow = '' // Ensure body scrolling is restored
230
333
  }
334
+ // Clear any remaining timeouts
335
+ if (controlsTimeout) {
336
+ clearTimeout(controlsTimeout)
337
+ }
231
338
  })
232
339
  </script>
233
340
 
@@ -243,43 +350,50 @@ onUnmounted(() => {
243
350
  >
244
351
  <div
245
352
  v-if="isGalleryOpen"
246
- class="fixed bg-fv-neutral-900 text-white inset-0 max-w-[100vw] overflow-y-auto overflow-x-hidden"
353
+ class="fixed bg-fv-neutral-900 text-white inset-0 max-w-[100vw] overflow-hidden gallery-container"
247
354
  style="z-index: 37"
248
355
  role="dialog"
249
356
  aria-modal="true"
250
357
  @click="handleBackdropClick"
251
358
  >
252
359
  <div
253
- class="relative w-full max-w-full flex flex-col justify-center items-center"
360
+ class="relative w-full h-full max-w-full flex flex-col justify-center items-center"
254
361
  style="z-index: 38"
255
362
  @click.stop
256
363
  >
257
- <div class="flex flex-grow gap-4 w-full max-w-full">
258
- <div class="flex-grow h-[100vh] flex items-center relative">
259
- <button
260
- class="btn w-9 h-9 rounded-full absolute top-4 left-2"
261
- style="z-index: 39"
262
- aria-label="Close gallery"
263
- @click="setModal(false)"
264
- >
265
- <component :is="closeIcon" class="w-8 h-8" />
266
- </button>
267
-
364
+ <!-- Main Content Area -->
365
+ <div class="flex flex-grow gap-4 w-full h-full max-w-full">
366
+ <div class="flex-grow h-full flex items-center relative">
367
+ <!-- Image Display Area -->
268
368
  <div
269
- class="flex h-[100vh] relative flex-grow items-center justify-center gap-2 z-[1]"
369
+ class="flex h-full relative flex-grow items-center justify-center gap-2 z-[1]"
370
+ @touchstart="touchStart"
371
+ @touchend="touchEnd"
270
372
  >
271
- <div
272
- class="hidden lg:relative z-[2] lg:flex w-10 flex-shrink-0 items-center justify-center flex-0"
373
+ <!-- Image Navigation - Left -->
374
+ <transition
375
+ enter-active-class="transition-opacity duration-300"
376
+ enter-from-class="opacity-0"
377
+ enter-to-class="opacity-100"
378
+ leave-active-class="transition-opacity duration-300"
379
+ leave-from-class="opacity-100"
380
+ leave-to-class="opacity-0"
273
381
  >
274
- <button
275
- v-if="images.length > 1"
276
- class="btn p-1 rounded-full"
277
- aria-label="Previous image"
278
- @click="goPrevImage()"
382
+ <div
383
+ v-if="showControls && images.length > 1"
384
+ class="absolute left-0 z-[40] h-full flex items-center px-2 md:px-4"
279
385
  >
280
- <ArrowLeftCircleIcon class="w-8 h-8" />
281
- </button>
282
- </div>
386
+ <button
387
+ 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"
388
+ aria-label="Previous image"
389
+ @click="goPrevImage()"
390
+ >
391
+ <ChevronLeftIcon class="w-6 h-6 md:w-8 md:h-8" />
392
+ </button>
393
+ </div>
394
+ </transition>
395
+
396
+ <!-- Main Image Container -->
283
397
  <div
284
398
  class="flex-1 flex flex-col z-[2] items-center justify-center max-w-full lg:max-w-[calc(100vw - 256px)] relative"
285
399
  >
@@ -288,14 +402,11 @@ onUnmounted(() => {
288
402
  mode="out-in"
289
403
  >
290
404
  <div
291
- v-if="true"
292
405
  :key="`image-display-${modelValue}`"
293
406
  class="flex-1 w-full max-w-full flex flex-col items-center justify-center absolute inset-0 z-[2]"
294
407
  >
295
408
  <div
296
409
  class="flex-1 w-full max-w-full flex items-center justify-center"
297
- @touchstart="touchStart"
298
- @touchend="touchEnd"
299
410
  >
300
411
  <template
301
412
  v-if="videoComponent && isVideo(images[modelValue])"
@@ -325,42 +436,44 @@ onUnmounted(() => {
325
436
  />
326
437
  </template>
327
438
  </div>
439
+
440
+ <!-- Image Slot Content -->
328
441
  <div
329
- class="flex-0 py-2 flex items-center justify-center max-w-full w-full relative !z-[3]"
442
+ v-if="infoPanel"
443
+ class="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"
330
444
  >
331
445
  <slot :value="images[modelValue]" />
332
446
  </div>
333
447
  </div>
334
448
  </transition>
335
449
  </div>
336
- <div
337
- class="hidden lg:flex w-10 flex-shrink-0 items-center justify-center"
450
+
451
+ <!-- Image Navigation - Right -->
452
+ <transition
453
+ enter-active-class="transition-opacity duration-300"
454
+ enter-from-class="opacity-0"
455
+ enter-to-class="opacity-100"
456
+ leave-active-class="transition-opacity duration-300"
457
+ leave-from-class="opacity-100"
458
+ leave-to-class="opacity-0"
338
459
  >
339
- <button
340
- class="btn w-9 h-9 rounded-full hidden lg:block absolute top-4"
341
- :class="{
342
- '-right-4': sidePanel,
343
- 'right-2': !sidePanel,
344
- }"
345
- style="z-index: 39"
346
- :aria-label="sidePanel ? 'Hide thumbnails' : 'Show thumbnails'"
347
- @click="() => (sidePanel = !sidePanel)"
348
- >
349
- <ChevronDoubleRightIcon v-if="sidePanel" class="w-7 h-7" />
350
- <ChevronDoubleLeftIcon v-else class="w-7 h-7" />
351
- </button>
352
- <button
353
- v-if="images.length > 1"
354
- class="btn p-1 rounded-full"
355
- aria-label="Next image"
356
- @click="goNextImage()"
460
+ <div
461
+ v-if="showControls && images.length > 1"
462
+ class="absolute right-0 z-[40] h-full flex items-center px-2 md:px-4"
357
463
  >
358
- <ArrowRightCircleIcon class="w-8 h-8" />
359
- </button>
360
- </div>
464
+ <button
465
+ 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"
466
+ aria-label="Next image"
467
+ @click="goNextImage()"
468
+ >
469
+ <ChevronRightIcon class="w-6 h-6 md:w-8 md:h-8" />
470
+ </button>
471
+ </div>
472
+ </transition>
361
473
  </div>
362
474
  </div>
363
475
 
476
+ <!-- Side Panel for Thumbnails -->
364
477
  <transition
365
478
  enter-active-class="transform transition ease-in-out duration-300"
366
479
  enter-from-class="translate-x-full"
@@ -371,27 +484,33 @@ onUnmounted(() => {
371
484
  >
372
485
  <div
373
486
  v-if="sidePanel"
374
- class="hidden lg:block flex-shrink-0 w-64 bg-fv-neutral-800 h-[100vh] max-h-[100vh] overflow-y-auto"
487
+ 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"
375
488
  >
376
- <!-- Side panel content -->
377
- <div v-if="paging" class="flex items-center justify-center">
489
+ <!-- Paging Controls -->
490
+ <div v-if="paging" class="flex items-center justify-center pt-2">
378
491
  <DefaultPaging :id="id" :items="paging" />
379
492
  </div>
493
+
494
+ <!-- Thumbnail Grid -->
380
495
  <div class="grid grid-cols-2 gap-2 p-2">
381
496
  <div
382
497
  v-for="i in images.length"
383
498
  :key="`bg_${id}_${i}`"
384
- class="hover:!brightness-100"
385
- :style="{
386
- filter:
387
- i - 1 === modelValue ? 'brightness(1)' : 'brightness(0.5)',
388
- }"
499
+ class="group relative"
389
500
  >
501
+ <div
502
+ class="absolute inset-0 rounded-lg transition-colors duration-300 group-hover:bg-fv-neutral-700/40"
503
+ :class="{ 'bg-fv-primary-500/40': i - 1 === modelValue }"
504
+ />
390
505
  <img
391
506
  v-if="imageComponent === 'img'"
392
- :class="`h-auto max-w-full rounded-lg cursor-pointer shadow ${getBorderColor(
507
+ :class="`h-auto max-w-full rounded-lg cursor-pointer shadow transition-all duration-300 group-hover:brightness-110 ${getBorderColor(
393
508
  images[i - 1],
394
509
  )}`"
510
+ :style="{
511
+ filter:
512
+ i - 1 === modelValue ? 'brightness(1)' : 'brightness(0.7)',
513
+ }"
395
514
  :src="getThumbnailUrl(images[i - 1])"
396
515
  :alt="`Thumbnail ${i}`"
397
516
  @click="$eventBus.emit(`${id}GalleryImage`, i - 1)"
@@ -402,9 +521,17 @@ onUnmounted(() => {
402
521
  :image="getThumbnailUrl(images[i - 1]).image"
403
522
  :variant="getThumbnailUrl(images[i - 1]).variant"
404
523
  :alt="getThumbnailUrl(images[i - 1]).alt"
405
- :class="`h-auto max-w-full rounded-lg cursor-pointer shadow ${getBorderColor(
524
+ :class="`h-auto max-w-full rounded-lg cursor-pointer shadow transition-all duration-300 group-hover:brightness-110 ${getBorderColor(
406
525
  images[i - 1],
407
526
  )}`"
527
+ :style="{
528
+ filter:
529
+ i - 1 === modelValue ? 'brightness(1)' : 'brightness(0.7)',
530
+ }"
531
+ :likes="getThumbnailUrl(images[i - 1]).likes"
532
+ :show-likes="getThumbnailUrl(images[i - 1]).showLikes"
533
+ :is-author="getThumbnailUrl(images[i - 1]).isAuthor"
534
+ :user-uuid="getThumbnailUrl(images[i - 1]).userUUID"
408
535
  @click="$eventBus.emit(`${id}GalleryImage`, i - 1)"
409
536
  />
410
537
  </div>
@@ -412,18 +539,121 @@ onUnmounted(() => {
412
539
  </div>
413
540
  </transition>
414
541
  </div>
542
+
543
+ <!-- Top Controls -->
544
+ <transition
545
+ enter-active-class="transition-opacity duration-300"
546
+ enter-from-class="opacity-0"
547
+ enter-to-class="opacity-100"
548
+ leave-active-class="transition-opacity duration-300"
549
+ leave-from-class="opacity-100"
550
+ leave-to-class="opacity-0"
551
+ >
552
+ <div
553
+ v-if="showControls"
554
+ 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"
555
+ >
556
+ <!-- Title and Counter -->
557
+ <div class="flex items-center space-x-2">
558
+ <span v-if="title" class="font-medium text-lg">{{ title }}</span>
559
+ <span class="text-sm opacity-80">{{ currentIndex }} / {{ imageCount }}</span>
560
+ </div>
561
+
562
+ <!-- Control Buttons -->
563
+ <div class="flex items-center space-x-2">
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="Toggle info"
567
+ @click="toggleInfoPanel"
568
+ >
569
+ <InformationCircleIcon class="w-5 h-5" />
570
+ </button>
571
+
572
+ <button
573
+ 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"
574
+ :title="sidePanel ? 'Hide thumbnails' : 'Show thumbnails'"
575
+ @click="() => (sidePanel = !sidePanel)"
576
+ >
577
+ <ChevronDoubleRightIcon v-if="sidePanel" class="w-5 h-5" />
578
+ <ChevronDoubleLeftIcon v-else class="w-5 h-5" />
579
+ </button>
580
+
581
+ <button
582
+ 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"
583
+ :title="sidePanel ? 'Hide thumbnails' : 'Show thumbnails'"
584
+ @click="() => (sidePanel = !sidePanel)"
585
+ >
586
+ <ChevronDoubleRightIcon v-if="sidePanel" class="w-5 h-5" />
587
+ <ChevronDoubleLeftIcon v-else class="w-5 h-5" />
588
+ </button>
589
+
590
+ <button
591
+ class="btn p-1.5 rounded-full bg-fv-neutral-800/70 hover:bg-fv-neutral-700/90 transition-transform transform hover:scale-110"
592
+ aria-label="Close gallery"
593
+ @click="setModal(false)"
594
+ >
595
+ <component :is="closeIcon" class="w-5 h-5" />
596
+ </button>
597
+ </div>
598
+ </div>
599
+ </transition>
600
+
601
+ <!-- Mobile Thumbnail Preview -->
602
+ <transition
603
+ enter-active-class="transition-transform duration-300 ease-out"
604
+ enter-from-class="translate-y-full"
605
+ enter-to-class="translate-y-0"
606
+ leave-active-class="transition-transform duration-300 ease-in"
607
+ leave-from-class="translate-y-0"
608
+ leave-to-class="translate-y-full"
609
+ >
610
+ <div
611
+ v-if="showControls && images.length > 1 && !sidePanel"
612
+ 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]"
613
+ >
614
+ <div class="overflow-x-auto flex space-x-2 pb-1 px-1">
615
+ <div
616
+ v-for="(image, idx) in images"
617
+ :key="`mobile_thumb_${id}_${idx}`"
618
+ class="flex-shrink-0 w-16 h-16 rounded-lg relative cursor-pointer"
619
+ :class="{ 'ring-2 ring-fv-primary-500 ring-offset-1 ring-offset-fv-neutral-900': idx === modelValue }"
620
+ @click="$eventBus.emit(`${id}GalleryImage`, idx)"
621
+ >
622
+ <img
623
+ v-if="imageComponent === 'img'"
624
+ class="w-full h-full object-cover rounded-lg transition duration-200"
625
+ :style="{
626
+ filter: idx === modelValue ? 'brightness(1)' : 'brightness(0.7)',
627
+ }"
628
+ :src="getThumbnailUrl(image)"
629
+ :alt="`Thumbnail ${idx + 1}`"
630
+ >
631
+ <component
632
+ :is="imageComponent"
633
+ v-else
634
+ :image="getThumbnailUrl(image).image"
635
+ :variant="getThumbnailUrl(image).variant"
636
+ :alt="getThumbnailUrl(image).alt"
637
+ class="w-full h-full object-cover rounded-lg transition duration-200"
638
+ :style="{
639
+ filter: idx === modelValue ? 'brightness(1)' : 'brightness(0.7)',
640
+ }"
641
+ />
642
+ </div>
643
+ </div>
644
+ </div>
645
+ </transition>
415
646
  </div>
416
647
  </div>
417
648
  </transition>
418
649
 
419
- <div v-if="mode === 'grid' || mode === 'mason' || mode === 'custom'" class="min-h-[600px]">
650
+ <!-- Thumbnail Grid/Mason/Custom Layouts -->
651
+ <div v-if="mode === 'grid' || mode === 'mason' || mode === 'custom'" class="gallery-grid">
420
652
  <div
421
653
  :class="{
422
- ' grid grid-cols-2 md:grid-cols-4 xl:grid-cols-6 gap-4 items-start':
423
- mode === 'mason',
424
- ' grid grid-cols-2 md:grid-cols-4 xl:grid-cols-6 gap-4 items-center':
425
- mode === 'grid',
426
- ' custom-grid': mode === 'custom',
654
+ 'masonry-grid': mode === 'mason',
655
+ 'standard-grid': mode === 'grid',
656
+ 'custom-grid': mode === 'custom',
427
657
  }"
428
658
  >
429
659
  <slot name="thumbnail" />
@@ -431,17 +661,17 @@ onUnmounted(() => {
431
661
  <template v-if="mode === 'mason'">
432
662
  <div
433
663
  v-if="i + (1 % gridHeight) === 0"
434
- class="grid gap-4 items-start relative"
664
+ class="masonry-column relative"
435
665
  >
436
666
  <div v-if="ranking" class="img-gallery-ranking">
437
667
  {{ i }}
438
668
  </div>
439
669
 
440
670
  <template v-for="j in gridHeight" :key="`gi_${id}_${i + j}`">
441
- <div>
671
+ <div class="masonry-item">
442
672
  <img
443
673
  v-if="i + j - 2 < images.length && imageComponent === 'img'"
444
- class="h-auto max-w-full rounded-lg cursor-pointer"
674
+ class="h-auto max-w-full w-full rounded-lg cursor-pointer shadow-md hover:shadow-xl transition-all duration-300 hover:brightness-110 hover:scale-[1.02]"
445
675
  :src="getThumbnailUrl(images[i + j - 2])"
446
676
  :alt="`Gallery image ${i + j - 1}`"
447
677
  @click="$eventBus.emit(`${id}GalleryImage`, i + j - 2)"
@@ -452,7 +682,7 @@ onUnmounted(() => {
452
682
  :image="getThumbnailUrl(images[i + j - 2]).image"
453
683
  :variant="getThumbnailUrl(images[i + j - 2]).variant"
454
684
  :alt="getThumbnailUrl(images[i + j - 2]).alt"
455
- :class="`h-auto max-w-full rounded-lg cursor-pointer ${getBorderColor(
685
+ :class="`h-auto max-w-full w-full rounded-lg cursor-pointer shadow-md hover:shadow-xl transition-all duration-300 hover:brightness-110 hover:scale-[1.02] ${getBorderColor(
456
686
  images[i + j - 2],
457
687
  )}`"
458
688
  :likes="getThumbnailUrl(images[i + j - 2]).likes"
@@ -465,42 +695,47 @@ onUnmounted(() => {
465
695
  </template>
466
696
  </div>
467
697
  </template>
468
- <div v-else class="relative">
698
+ <div v-else class="grid-item relative group">
469
699
  <div v-if="ranking" class="img-gallery-ranking">
470
700
  {{ i }}
471
701
  </div>
472
- <img
473
- v-if="imageComponent === 'img'"
474
- class="h-auto max-w-full rounded-lg cursor-pointer"
475
- :src="getThumbnailUrl(images[i - 1])"
476
- :alt="`Gallery image ${i}`"
477
- @click="$eventBus.emit(`${id}GalleryImage`, i - 1)"
478
- >
479
- <component
480
- :is="imageComponent"
481
- v-else-if="imageComponent"
482
- :image="getThumbnailUrl(images[i - 1]).image"
483
- :variant="getThumbnailUrl(images[i - 1]).variant"
484
- :alt="getThumbnailUrl(images[i - 1]).alt"
485
- :class="`h-auto max-w-full rounded-lg cursor-pointer ${getBorderColor(
486
- images[i - 1],
487
- )}`"
488
- :likes="getThumbnailUrl(images[i - 1]).likes"
489
- :show-likes="getThumbnailUrl(images[i - 1]).showLikes"
490
- :is-author="getThumbnailUrl(images[i - 1]).isAuthor"
491
- :user-uuid="getThumbnailUrl(images[i - 1]).userUUID"
492
- @click="$eventBus.emit(`${id}GalleryImage`, i - 1)"
493
- />
702
+ <div class="overflow-hidden rounded-lg">
703
+ <img
704
+ v-if="imageComponent === 'img'"
705
+ class="h-auto max-w-full w-full rounded-lg cursor-pointer shadow-md transition-all duration-300 group-hover:brightness-110 group-hover:scale-[1.03]"
706
+ :src="getThumbnailUrl(images[i - 1])"
707
+ :alt="`Gallery image ${i}`"
708
+ @click="$eventBus.emit(`${id}GalleryImage`, i - 1)"
709
+ >
710
+ <component
711
+ :is="imageComponent"
712
+ v-else-if="imageComponent"
713
+ :image="getThumbnailUrl(images[i - 1]).image"
714
+ :variant="getThumbnailUrl(images[i - 1]).variant"
715
+ :alt="getThumbnailUrl(images[i - 1]).alt"
716
+ :class="`h-auto max-w-full w-full rounded-lg cursor-pointer shadow-md transition-all duration-300 group-hover:brightness-110 group-hover:scale-[1.03] ${getBorderColor(
717
+ images[i - 1],
718
+ )}`"
719
+ :likes="getThumbnailUrl(images[i - 1]).likes"
720
+ :show-likes="getThumbnailUrl(images[i - 1]).showLikes"
721
+ :is-author="getThumbnailUrl(images[i - 1]).isAuthor"
722
+ :user-uuid="getThumbnailUrl(images[i - 1]).userUUID"
723
+ @click="$eventBus.emit(`${id}GalleryImage`, i - 1)"
724
+ />
725
+ </div>
494
726
  </div>
495
727
  </template>
496
728
  </div>
497
729
  </div>
730
+
731
+ <!-- Button Mode -->
498
732
  <button
499
733
  v-if="mode === 'button'"
500
- :class="`btn ${buttonType ? buttonType : 'primary'} defaults`"
734
+ :class="`btn ${buttonType ? buttonType : 'primary'} defaults relative overflow-hidden group`"
501
735
  @click="openGalleryImage(0)"
502
736
  >
503
- {{ buttonText ? buttonText : $t("open_gallery_cta") }}
737
+ <span class="relative z-10">{{ buttonText ? buttonText : $t("open_gallery_cta") }}</span>
738
+ <span class="absolute inset-0 bg-white/10 transform -translate-x-full group-hover:translate-x-0 transition-transform duration-300" />
504
739
  </button>
505
740
  </div>
506
741
  </template>
@@ -572,18 +807,101 @@ onUnmounted(() => {
572
807
  filter: blur(10px);
573
808
  }
574
809
 
575
- /* Ensure the images are positioned correctly to prevent overlap */
576
- .relative-container {
577
- position: relative;
578
- width: 100%;
579
- height: 100%;
810
+ /* Modern grids */
811
+ .gallery-grid {
812
+ min-height: 200px;
813
+ }
814
+
815
+ .standard-grid {
816
+ display: grid;
817
+ grid-template-columns: repeat(1, 1fr);
818
+ gap: 0.75rem;
819
+ }
820
+
821
+ @media (min-width: 480px) {
822
+ .standard-grid {
823
+ grid-template-columns: repeat(2, 1fr);
824
+ }
825
+ }
826
+
827
+ @media (min-width: 768px) {
828
+ .standard-grid {
829
+ grid-template-columns: repeat(3, 1fr);
830
+ gap: 1rem;
831
+ }
832
+ }
833
+
834
+ @media (min-width: 1024px) {
835
+ .standard-grid {
836
+ grid-template-columns: repeat(4, 1fr);
837
+ }
838
+ }
839
+
840
+ @media (min-width: 1280px) {
841
+ .standard-grid {
842
+ grid-template-columns: repeat(5, 1fr);
843
+ }
844
+ }
845
+
846
+ @media (min-width: 1536px) {
847
+ .standard-grid {
848
+ grid-template-columns: repeat(6, 1fr);
849
+ }
850
+ }
851
+
852
+ .masonry-grid {
853
+ display: grid;
854
+ grid-template-columns: repeat(1, 1fr);
855
+ gap: 0.75rem;
856
+ }
857
+
858
+ @media (min-width: 480px) {
859
+ .masonry-grid {
860
+ grid-template-columns: repeat(2, 1fr);
861
+ }
862
+ }
863
+
864
+ @media (min-width: 768px) {
865
+ .masonry-grid {
866
+ grid-template-columns: repeat(3, 1fr);
867
+ gap: 1rem;
868
+ }
869
+ }
870
+
871
+ @media (min-width: 1024px) {
872
+ .masonry-grid {
873
+ grid-template-columns: repeat(4, 1fr);
874
+ }
875
+ }
876
+
877
+ .masonry-column {
878
+ display: grid;
879
+ gap: 0.75rem;
880
+ }
881
+
882
+ .masonry-item {
883
+ break-inside: avoid;
884
+ margin-bottom: 0.75rem;
885
+ }
886
+
887
+ .grid-item {
888
+ break-inside: avoid;
889
+ margin-bottom: 0.75rem;
580
890
  }
581
891
 
582
- .relative-container > div {
892
+ .img-gallery-ranking {
583
893
  position: absolute;
584
- top: 0;
585
- left: 0;
586
- width: 100%;
587
- height: 100%;
894
+ top: 0.5rem;
895
+ left: 0.5rem;
896
+ background-color: rgba(0, 0, 0, 0.6);
897
+ color: white;
898
+ width: 1.5rem;
899
+ height: 1.5rem;
900
+ display: flex;
901
+ align-items: center;
902
+ justify-content: center;
903
+ border-radius: 9999px;
904
+ font-size: 0.75rem;
905
+ z-index: 10;
588
906
  }
589
907
  </style>
@@ -3,8 +3,56 @@ import { XCircleIcon } from '@heroicons/vue/24/solid'
3
3
  import { h, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
4
4
  import { useEventBus } from '../../composables/event-bus'
5
5
 
6
- // Global registry to track all open modals and their z-indexes
7
- const openModals: Map<string, number> = new Map()
6
+ // Use a shared global registry in the window to track all modals across instances
7
+ // This ensures proper z-index stacking even when modals are in different components
8
+ if (typeof window !== 'undefined') {
9
+ // @ts-expect-error: TS doesn't know about the global registry
10
+ window.__FWS_MODAL_REGISTRY = window.__FWS_MODAL_REGISTRY || {
11
+ modals: new Map<string, number>(),
12
+ getNextZIndex() {
13
+ const baseZIndex = 40
14
+ const maxZIndex = 59
15
+
16
+ // If no modals, start at base
17
+ if (this.modals.size === 0) {
18
+ return baseZIndex
19
+ }
20
+
21
+ // Find highest z-index
22
+ const values = Array.from(this.modals.values())
23
+ // @ts-expect-error: TS doesn't know that values are numbers
24
+ const highestZIndex = Math.max(...values)
25
+
26
+ // Calculate next z-index
27
+ const nextZIndex = highestZIndex + 1
28
+
29
+ // If we're approaching the upper limit, reset all
30
+ if (nextZIndex >= maxZIndex) {
31
+ this.resetAllZIndexes()
32
+ return baseZIndex
33
+ }
34
+
35
+ return nextZIndex
36
+ },
37
+ resetAllZIndexes() {
38
+ // Sort by current z-index
39
+ const entries = Array.from(this.modals.entries())
40
+ // @ts-expect-error: TS doesn't know that entries are tuples
41
+ entries.sort((a, b) => a[1] - b[1])
42
+
43
+ // Reassign starting from base
44
+ let newIndex = 40
45
+ // @ts-expect-error: TS doesn't know that entries are tuples
46
+ entries.forEach(([id, _]) => {
47
+ this.modals.set(id, newIndex)
48
+ newIndex++
49
+ })
50
+ },
51
+ }
52
+ }
53
+
54
+ // @ts-expect-error: TS doesn't know about the global registry
55
+ const modalRegistry = typeof window !== 'undefined' ? window.__FWS_MODAL_REGISTRY : { modals: new Map() }
8
56
 
9
57
  const props = withDefaults(
10
58
  defineProps<{
@@ -80,15 +128,16 @@ function handleKeyDown(event: KeyboardEvent) {
80
128
 
81
129
  // Check if this modal is the top-most (highest z-index)
82
130
  function isTopMostModal(id: string): boolean {
83
- if (openModals.size === 0) return false
131
+ if (modalRegistry.modals.size === 0) return false
84
132
 
85
133
  // Find the modal with the highest z-index
86
- const entries = Array.from(openModals.entries())
134
+ const entries = Array.from(modalRegistry.modals.entries())
87
135
  const highestEntry = entries.reduce((prev, current) =>
136
+ // @ts-expect-error: TS doesn't know that entries are tuples
88
137
  current[1] > prev[1] ? current : prev,
89
138
  )
90
139
 
91
- // Return true if this modal has the highest z-index
140
+ // @ts-expect-error: TS doesn't know that entries are tuples
92
141
  return highestEntry[0] === id
93
142
  }
94
143
 
@@ -97,17 +146,26 @@ function setModal(value: boolean) {
97
146
  if (props.onOpen) props.onOpen()
98
147
  previouslyFocusedElement = document.activeElement as HTMLElement
99
148
 
100
- // Calculate the new z-index for this modal
101
- const highestZIndex = calculateHighestZIndex()
149
+ // Get the next z-index from the global registry
150
+ const newZIndex = modalRegistry.getNextZIndex()
151
+
152
+ // Register this modal in the global registry with a unique ID (combines component id with instance id)
153
+ const uniqueId = `${props.id}-${Date.now()}`
154
+ modalRegistry.modals.set(uniqueId, newZIndex)
102
155
 
103
- // Register this modal in the global registry
104
- openModals.set(props.id, highestZIndex)
156
+ // Store the unique ID as a data attribute for future reference
157
+ nextTick(() => {
158
+ const modalElement = document.querySelector(`[data-modal-id="${props.id}"]`) as HTMLElement
159
+ if (modalElement) {
160
+ modalElement.setAttribute('data-modal-unique-id', uniqueId)
161
+ }
162
+ })
105
163
 
106
164
  // Set this modal's z-index
107
- zIndex.value = highestZIndex
165
+ zIndex.value = newZIndex
108
166
 
109
167
  // Only manage body overflow for the first opened modal
110
- if (openModals.size === 1) {
168
+ if (modalRegistry.modals.size === 1) {
111
169
  document.body.style.overflow = 'hidden' // Prevent scrolling when modal is open
112
170
  }
113
171
 
@@ -116,11 +174,17 @@ function setModal(value: boolean) {
116
174
  if (value === false) {
117
175
  if (props.onClose) props.onClose()
118
176
 
119
- // Remove this modal from the registry
120
- openModals.delete(props.id)
177
+ // Find and remove this modal from the registry
178
+ const modalElement = document.querySelector(`[data-modal-id="${props.id}"]`) as HTMLElement
179
+ if (modalElement) {
180
+ const uniqueId = modalElement.getAttribute('data-modal-unique-id')
181
+ if (uniqueId) {
182
+ modalRegistry.modals.delete(uniqueId)
183
+ }
184
+ }
121
185
 
122
186
  // Only restore body overflow if this is the last open modal
123
- if (openModals.size === 0) {
187
+ if (modalRegistry.modals.size === 0) {
124
188
  document.body.style.overflow = '' // Restore scrolling
125
189
  }
126
190
 
@@ -132,47 +196,7 @@ function setModal(value: boolean) {
132
196
  isOpen.value = value
133
197
  }
134
198
 
135
- // Calculate the highest z-index for a new modal
136
- function calculateHighestZIndex(): number {
137
- // Start with the base z-index
138
- let newZIndex = baseZIndex
139
-
140
- // Find the highest z-index currently in use
141
- if (openModals.size > 0) {
142
- const values = Array.from(openModals.values())
143
- newZIndex = Math.max(...values) + 1
144
- }
145
-
146
- // Ensure we stay within range (40-59)
147
- if (newZIndex >= 59) {
148
- // If we're approaching the upper limit, reset all z-indexes
149
- resetAllModalZIndexes()
150
- return baseZIndex + 1
151
- }
152
-
153
- return newZIndex
154
- }
155
-
156
- // Reset all modal z-indexes when we approach the upper limit
157
- function resetAllModalZIndexes() {
158
- // Sort modals by their current z-index to maintain relative ordering
159
- const entries = Array.from(openModals.entries())
160
- entries.sort((a, b) => a[1] - b[1])
161
-
162
- // Reassign z-indexes starting from baseZIndex
163
- let newIndex = baseZIndex
164
- entries.forEach(([id, _]) => {
165
- openModals.set(id, newIndex)
166
-
167
- // Find the modal element and update its z-index
168
- const modalElement = document.querySelector(`[data-modal-id="${id}"]`) as HTMLElement
169
- if (modalElement) {
170
- modalElement.style.zIndex = newIndex.toString()
171
- }
172
-
173
- newIndex++
174
- })
175
- }
199
+ // These functions have been moved to the global registry object
176
200
 
177
201
  // After modal is opened, set focus and collect focusable elements
178
202
  watch(isOpen, async (newVal) => {
@@ -205,10 +229,18 @@ onUnmounted(() => {
205
229
  eventBus.off(`${props.id}Modal`, setModal)
206
230
  document.removeEventListener('keydown', handleKeyDown)
207
231
 
208
- // Only restore body overflow if this modal was open when unmounted
232
+ // Clean up the modal registry if this modal was open when unmounted
209
233
  if (isOpen.value) {
210
- const activeModals = document.querySelectorAll('[data-modal-active="true"]')
211
- if (activeModals.length <= 1) {
234
+ const modalElement = document.querySelector(`[data-modal-id="${props.id}"]`) as HTMLElement
235
+ if (modalElement) {
236
+ const uniqueId = modalElement.getAttribute('data-modal-unique-id')
237
+ if (uniqueId) {
238
+ modalRegistry.modals.delete(uniqueId)
239
+ }
240
+ }
241
+
242
+ // Only restore body overflow if this is the last open modal
243
+ if (modalRegistry.modals.size === 0) {
212
244
  document.body.style.overflow = '' // Restore scrolling
213
245
  }
214
246
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fy-/fws-vue",
3
- "version": "2.2.50",
3
+ "version": "2.2.52",
4
4
  "author": "Florian 'Fy' Gasquez <m@fy.to>",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/fy-to/FWJS#readme",