@fy-/fws-vue 2.2.45 → 2.2.47

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.
@@ -1,10 +1,10 @@
1
1
  <script setup lang="ts">
2
2
  import type { Component } from 'vue'
3
3
  import type { APIPaging } from '../../composables/rest'
4
- import { Dialog, DialogPanel, TransitionRoot } from '@headlessui/vue'
5
4
  import {
6
5
  ArrowLeftCircleIcon,
7
6
  ArrowRightCircleIcon,
7
+ ArrowsPointingOutIcon, // For fullscreen button
8
8
  ChevronDoubleLeftIcon,
9
9
  ChevronDoubleRightIcon,
10
10
  XCircleIcon,
@@ -16,6 +16,20 @@ import DefaultPaging from './DefaultPaging.vue'
16
16
  const isGalleryOpen = ref<boolean>(false)
17
17
  const eventBus = useEventBus()
18
18
  const sidePanel = ref<boolean>(true)
19
+ const isImageLoading = ref<boolean>(false)
20
+ const galleryRef = ref<HTMLElement | null>(null)
21
+ const isFullscreen = ref<boolean>(false)
22
+
23
+ // For mobile image manipulation
24
+ const scale = ref<number>(1)
25
+ const startDistance = ref<number>(0)
26
+ const isPinching = ref<boolean>(false)
27
+ const panPosition = reactive({ x: 0, y: 0 })
28
+ const startPanPosition = reactive({ x: 0, y: 0 })
29
+ const isPanning = ref<boolean>(false)
30
+ const lastTapTime = ref<number>(0)
31
+ const touchCenter = reactive({ x: 0.5, y: 0.5 }) // Default to center
32
+ const showMobileThumbnails = ref<boolean>(false)
19
33
 
20
34
  const props = withDefaults(
21
35
  defineProps<{
@@ -65,13 +79,89 @@ const modelValue = computed({
65
79
  })
66
80
 
67
81
  const direction = ref<'next' | 'prev'>('next')
82
+ const imageCounter = computed(() => `${modelValue.value + 1} / ${props.images.length}`)
83
+ let focusableElements: HTMLElement[] = []
84
+
85
+ // Trap focus within gallery for accessibility
86
+ function getFocusableElements(element: HTMLElement): HTMLElement[] {
87
+ return Array.from(
88
+ element.querySelectorAll(
89
+ 'a[href], button, input, textarea, select, details, [tabindex]:not([tabindex="-1"])',
90
+ ),
91
+ ).filter(
92
+ el => !el.hasAttribute('disabled') && !el.getAttribute('aria-hidden'),
93
+ ) as HTMLElement[]
94
+ }
95
+
96
+ function handleTabKey(event: KeyboardEvent) {
97
+ if (!isGalleryOpen.value || event.key !== 'Tab' || focusableElements.length <= 1) return
98
+
99
+ const firstElement = focusableElements[0]
100
+ const lastElement = focusableElements[focusableElements.length - 1]
101
+
102
+ // Shift + Tab on first element should focus the last element
103
+ if (event.shiftKey && document.activeElement === firstElement) {
104
+ event.preventDefault()
105
+ lastElement.focus()
106
+ }
107
+ // Tab on last element should focus the first element
108
+ else if (!event.shiftKey && document.activeElement === lastElement) {
109
+ event.preventDefault()
110
+ firstElement.focus()
111
+ }
112
+ }
113
+
114
+ // Toggle fullscreen mode
115
+ function toggleFullscreen() {
116
+ isFullscreen.value = !isFullscreen.value
117
+
118
+ // Reset zoom and pan when toggling fullscreen
119
+ scale.value = 1
120
+ panPosition.x = 0
121
+ panPosition.y = 0
122
+ }
68
123
 
69
124
  function setModal(value: boolean) {
70
125
  if (value === true) {
71
126
  if (props.onOpen) props.onOpen()
127
+ document.body.style.overflow = 'hidden' // Prevent scrolling when gallery is open
128
+ if (!import.meta.env.SSR) {
129
+ document.addEventListener('keydown', handleKeyboardInput)
130
+ document.addEventListener('keyup', handleKeyboardRelease)
131
+ document.addEventListener('keydown', handleTabKey)
132
+ }
133
+
134
+ // Initialize focus trap after gallery opens
135
+ setTimeout(() => {
136
+ if (galleryRef.value) {
137
+ focusableElements = getFocusableElements(galleryRef.value)
138
+ // Focus the close button if present
139
+ const closeButton = galleryRef.value.querySelector('button[aria-label="Close gallery"]') as HTMLElement
140
+ if (closeButton) {
141
+ closeButton.focus()
142
+ }
143
+ else if (focusableElements.length > 0) {
144
+ focusableElements[0].focus()
145
+ }
146
+ }
147
+ }, 100)
72
148
  }
73
149
  else {
74
150
  if (props.onClose) props.onClose()
151
+ document.body.style.overflow = '' // Restore scrolling
152
+
153
+ // Reset zoom, pan, fullscreen and thumbnail states when closing
154
+ scale.value = 1
155
+ panPosition.x = 0
156
+ panPosition.y = 0
157
+ isFullscreen.value = false
158
+ showMobileThumbnails.value = false
159
+
160
+ if (!import.meta.env.SSR) {
161
+ document.removeEventListener('keydown', handleKeyboardInput)
162
+ document.removeEventListener('keyup', handleKeyboardRelease)
163
+ document.removeEventListener('keydown', handleTabKey)
164
+ }
75
165
  }
76
166
  isGalleryOpen.value = value
77
167
  }
@@ -87,7 +177,13 @@ function openGalleryImage(index: number | undefined) {
87
177
  }
88
178
 
89
179
  function goNextImage() {
180
+ // Reset zoom and pan when navigating
181
+ scale.value = 1
182
+ panPosition.x = 0
183
+ panPosition.y = 0
184
+
90
185
  direction.value = 'next'
186
+ isImageLoading.value = true
91
187
  if (modelValue.value < props.images.length - 1) {
92
188
  modelValue.value++
93
189
  }
@@ -97,7 +193,13 @@ function goNextImage() {
97
193
  }
98
194
 
99
195
  function goPrevImage() {
196
+ // Reset zoom and pan when navigating
197
+ scale.value = 1
198
+ panPosition.x = 0
199
+ panPosition.y = 0
200
+
100
201
  direction.value = 'prev'
202
+ isImageLoading.value = true
101
203
  if (modelValue.value > 0) {
102
204
  modelValue.value--
103
205
  }
@@ -115,41 +217,159 @@ const modelValueSrc = computed(() => {
115
217
 
116
218
  const start = reactive({ x: 0, y: 0 })
117
219
 
220
+ // Calculate the distance between two touch points
221
+ function getDistance(touch1: Touch, touch2: Touch): number {
222
+ const dx = touch1.clientX - touch2.clientX
223
+ const dy = touch1.clientY - touch2.clientY
224
+ return Math.sqrt(dx * dx + dy * dy)
225
+ }
226
+
227
+ // Calculate the center point between two touches
228
+ function getTouchCenter(touch1: Touch, touch2: Touch, element: HTMLElement) {
229
+ const rect = element.getBoundingClientRect()
230
+ const centerX = (touch1.clientX + touch2.clientX) / 2
231
+ const centerY = (touch1.clientY + touch2.clientY) / 2
232
+
233
+ return {
234
+ x: (centerX - rect.left) / rect.width,
235
+ y: (centerY - rect.top) / rect.height,
236
+ }
237
+ }
238
+
118
239
  function touchStart(event: TouchEvent) {
119
- const touch = event.touches[0]
120
- const targetElement = touch.target as HTMLElement
240
+ const now = Date.now()
241
+ const container = event.currentTarget as HTMLElement
242
+
243
+ // Handle double tap detection for zoom
244
+ if (now - lastTapTime.value < 300 && event.touches.length === 1) {
245
+ if (scale.value !== 1) {
246
+ // Reset zoom and pan on double tap if already zoomed
247
+ scale.value = 1
248
+ panPosition.x = 0
249
+ panPosition.y = 0
250
+ }
251
+ else {
252
+ // Zoom to a moderate level on double tap
253
+ scale.value = 2.5
254
+ // Center zoom on tap location
255
+ const touch = event.touches[0]
256
+ const rect = container.getBoundingClientRect()
257
+ touchCenter.x = (touch.clientX - rect.left) / rect.width
258
+ touchCenter.y = (touch.clientY - rect.top) / rect.height
259
+ }
260
+ event.preventDefault()
261
+ return
262
+ }
263
+
264
+ lastTapTime.value = now
265
+
266
+ // Detect if we're starting to pinch zoom
267
+ if (event.touches.length === 2) {
268
+ isPinching.value = true
269
+ startDistance.value = getDistance(event.touches[0], event.touches[1])
270
+ const center = getTouchCenter(event.touches[0], event.touches[1], container)
271
+ touchCenter.x = center.x
272
+ touchCenter.y = center.y
273
+ event.preventDefault()
274
+ return
275
+ }
276
+
277
+ // If we're already zoomed in, enable panning with one finger
278
+ if (scale.value > 1 && event.touches.length === 1) {
279
+ isPanning.value = true
280
+ startPanPosition.x = panPosition.x
281
+ startPanPosition.y = panPosition.y
282
+ start.x = event.touches[0].clientX
283
+ start.y = event.touches[0].clientY
284
+ event.preventDefault()
285
+ return
286
+ }
287
+
288
+ // Normal swipe behavior if not zoomed
289
+ if (scale.value <= 1 && event.touches.length === 1) {
290
+ const touch = event.touches[0]
291
+ const targetElement = touch.target as HTMLElement
292
+
293
+ // Check if the touch started on an interactive element
294
+ if (targetElement.closest('button, a, input, textarea, select')) {
295
+ return // Don't handle swipe if interacting with an interactive element
296
+ }
297
+
298
+ start.x = touch.screenX
299
+ start.y = touch.screenY
300
+ }
301
+ }
302
+
303
+ function touchMove(event: TouchEvent) {
304
+ // Handle pinch zoom
305
+ if (isPinching.value && event.touches.length === 2) {
306
+ const currentDistance = getDistance(event.touches[0], event.touches[1])
307
+ const newScale = (currentDistance / startDistance.value) * scale.value
121
308
 
122
- // Check if the touch started on an interactive element
123
- if (targetElement.closest('button, a, input, textarea, select')) {
124
- return // Don't handle swipe if interacting with an interactive element
309
+ // Limit zoom range
310
+ scale.value = Math.min(Math.max(0.5, newScale), 5)
311
+ event.preventDefault()
312
+ return
125
313
  }
126
314
 
127
- start.x = touch.screenX
128
- start.y = touch.screenY
315
+ // Handle panning when zoomed in
316
+ if (isPanning.value && event.touches.length === 1) {
317
+ const deltaX = event.touches[0].clientX - start.x
318
+ const deltaY = event.touches[0].clientY - start.y
319
+
320
+ // Calculate how much the pan should be constrained based on zoom level
321
+ const constraint = Math.max(0, scale.value - 1) * 150
322
+
323
+ panPosition.x = Math.min(Math.max(startPanPosition.x + deltaX, -constraint), constraint)
324
+ panPosition.y = Math.min(Math.max(startPanPosition.y + deltaY, -constraint), constraint)
325
+
326
+ event.preventDefault()
327
+ }
129
328
  }
329
+
130
330
  function touchEnd(event: TouchEvent) {
131
- const touch = event.changedTouches[0]
132
- const targetElement = touch.target as HTMLElement
331
+ // Reset pinch zoom state
332
+ if (isPinching.value) {
333
+ isPinching.value = false
334
+ return
335
+ }
133
336
 
134
- // Check if the touch ended on an interactive element
135
- if (targetElement.closest('button, a, input, textarea, select')) {
136
- return // Don't handle swipe if interacting with an interactive element
337
+ // Reset panning state
338
+ if (isPanning.value) {
339
+ isPanning.value = false
340
+ return
137
341
  }
138
342
 
139
- const end = { x: touch.screenX, y: touch.screenY }
343
+ // If we're zoomed in, don't handle swiping between images
344
+ if (scale.value > 1) {
345
+ return
346
+ }
140
347
 
141
- const diffX = start.x - end.x
142
- const diffY = start.y - end.y
348
+ // Normal swipe behavior if not zoomed
349
+ if (event.changedTouches.length === 1) {
350
+ const touch = event.changedTouches[0]
351
+ const targetElement = touch.target as HTMLElement
143
352
 
144
- // Add a threshold to prevent accidental swipes
145
- if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 50) {
146
- if (diffX > 0) {
147
- direction.value = 'next'
148
- goNextImage()
353
+ // Check if the touch ended on an interactive element
354
+ if (targetElement.closest('button, a, input, textarea, select')) {
355
+ return // Don't handle swipe if interacting with an interactive element
149
356
  }
150
- else {
151
- direction.value = 'prev'
152
- goPrevImage()
357
+
358
+ const end = { x: touch.screenX, y: touch.screenY }
359
+
360
+ const diffX = start.x - end.x
361
+ const diffY = start.y - end.y
362
+
363
+ // Add a threshold to prevent accidental swipes
364
+ if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 50) {
365
+ if (diffX > 0) {
366
+ direction.value = 'next'
367
+ goNextImage()
368
+ }
369
+ else {
370
+ direction.value = 'prev'
371
+ goPrevImage()
372
+ }
153
373
  }
154
374
  }
155
375
  }
@@ -164,8 +384,31 @@ function getBorderColor(i: any) {
164
384
  const isKeyPressed = ref<boolean>(false)
165
385
 
166
386
  function handleKeyboardInput(event: KeyboardEvent) {
387
+ if (!isGalleryOpen.value) return
167
388
  if (isKeyPressed.value) return
389
+
168
390
  switch (event.key) {
391
+ case 'Escape':
392
+ if (scale.value > 1) {
393
+ // If zoomed in, reset zoom first
394
+ scale.value = 1
395
+ panPosition.x = 0
396
+ panPosition.y = 0
397
+ }
398
+ else if (showMobileThumbnails.value) {
399
+ // If mobile thumbnails are shown, hide them first
400
+ showMobileThumbnails.value = false
401
+ }
402
+ else if (isFullscreen.value) {
403
+ // If in fullscreen, exit fullscreen first
404
+ isFullscreen.value = false
405
+ }
406
+ else {
407
+ // Otherwise close the gallery
408
+ event.preventDefault()
409
+ setModal(false)
410
+ }
411
+ break
169
412
  case 'ArrowRight':
170
413
  isKeyPressed.value = true
171
414
  direction.value = 'next'
@@ -176,6 +419,9 @@ function handleKeyboardInput(event: KeyboardEvent) {
176
419
  direction.value = 'prev'
177
420
  goPrevImage()
178
421
  break
422
+ case 'f':
423
+ toggleFullscreen()
424
+ break
179
425
  default:
180
426
  break
181
427
  }
@@ -187,92 +433,161 @@ function handleKeyboardRelease(event: KeyboardEvent) {
187
433
  }
188
434
  }
189
435
 
436
+ function onImageLoad() {
437
+ isImageLoading.value = false
438
+ }
439
+
190
440
  function closeGallery() {
191
441
  setModal(false)
192
442
  }
193
443
 
444
+ // Click outside gallery content to close
445
+ function handleBackdropClick(event: MouseEvent) {
446
+ if (event.target === event.currentTarget) {
447
+ setModal(false)
448
+ }
449
+ }
450
+
451
+ // Toggle mobile thumbnails panel
452
+ function toggleMobileThumbnails() {
453
+ showMobileThumbnails.value = !showMobileThumbnails.value
454
+ }
455
+
456
+ // Computed transform style for zoom and pan
457
+ const imageTransform = computed(() => {
458
+ return `scale(${scale.value}) translate(${panPosition.x / scale.value}px, ${panPosition.y / scale.value}px)`
459
+ })
460
+
461
+ // Transform origin for zoom based on touch position
462
+ const imageTransformOrigin = computed(() => {
463
+ return `${touchCenter.x * 100}% ${touchCenter.y * 100}%`
464
+ })
465
+
194
466
  onMounted(() => {
195
467
  eventBus.on(`${props.id}GalleryImage`, openGalleryImage)
196
468
  eventBus.on(`${props.id}Gallery`, openGalleryImage)
197
469
  eventBus.on(`${props.id}GalleryClose`, closeGallery)
198
- if (window !== undefined && !import.meta.env.SSR) {
199
- window.addEventListener('keydown', handleKeyboardInput)
200
- window.addEventListener('keyup', handleKeyboardRelease)
201
- }
202
470
  })
203
471
 
204
472
  onUnmounted(() => {
205
473
  eventBus.off(`${props.id}Gallery`, openGalleryImage)
206
474
  eventBus.off(`${props.id}GalleryImage`, openGalleryImage)
207
475
  eventBus.off(`${props.id}GalleryClose`, closeGallery)
208
- if (window !== undefined && !import.meta.env.SSR) {
209
- window.removeEventListener('keydown', handleKeyboardInput)
210
- window.removeEventListener('keyup', handleKeyboardRelease)
476
+ if (!import.meta.env.SSR) {
477
+ document.removeEventListener('keydown', handleKeyboardInput)
478
+ document.removeEventListener('keyup', handleKeyboardRelease)
479
+ document.removeEventListener('keydown', handleTabKey)
480
+ document.body.style.overflow = '' // Ensure body scrolling is restored
211
481
  }
212
482
  })
213
483
  </script>
214
484
 
215
485
  <template>
216
486
  <div>
217
- <TransitionRoot
218
- :show="isGalleryOpen"
219
- as="template"
220
- enter="duration-300 ease-out"
221
- enter-from="opacity-0"
222
- enter-to="opacity-100"
223
- leave="duration-200 ease-in"
224
- leave-from="opacity-100"
225
- leave-to="opacity-0"
487
+ <transition
488
+ enter-active-class="duration-300 ease-out"
489
+ enter-from-class="opacity-0"
490
+ enter-to-class="opacity-100"
491
+ leave-active-class="duration-200 ease-in"
492
+ leave-from-class="opacity-100"
493
+ leave-to-class="opacity-0"
226
494
  >
227
- <Dialog
228
- :open="isGalleryOpen"
229
- class="fixed bg-fv-neutral-900 text-white inset-0 max-w-[100vw] overflow-y-auto overflow-x-hidden"
495
+ <div
496
+ v-if="isGalleryOpen"
497
+ ref="galleryRef"
498
+ class="fixed bg-gradient-to-b from-fv-neutral-950 to-fv-neutral-900 text-white inset-0 max-w-[100vw] overflow-hidden"
499
+ :class="{ 'fullscreen-mode': isFullscreen }"
230
500
  style="z-index: 37"
231
- @close="setModal"
501
+ role="dialog"
502
+ aria-modal="true"
503
+ @click="handleBackdropClick"
232
504
  >
233
- <DialogPanel
234
- class="relative w-full max-w-full flex flex-col justify-center items-center"
505
+ <div
506
+ class="relative w-full max-w-full flex flex-col justify-center items-center h-full"
235
507
  style="z-index: 38"
508
+ @click.stop
236
509
  >
237
- <div class="flex flex-grow gap-4 w-full max-w-full">
238
- <div class="flex-grow h-[100vh] flex items-center relative">
239
- <button
240
- class="btn w-9 h-9 rounded-full absolute top-4 left-2"
241
- style="z-index: 39"
242
- @click="setModal(false)"
243
- >
244
- <component :is="closeIcon" class="w-8 h-8" />
245
- </button>
510
+ <div class="flex flex-grow gap-4 w-full max-w-full h-full">
511
+ <div class="flex-grow h-full flex items-center relative">
512
+ <!-- Top control bar -->
513
+ <div class="absolute top-0 left-0 right-0 flex justify-between items-center p-4 bg-gradient-to-b from-black/50 to-transparent z-[40]">
514
+ <div class="flex items-center gap-2">
515
+ <button
516
+ aria-label="Close gallery"
517
+ class="rounded-full p-2 bg-black/40 backdrop-blur-sm hover:bg-black/60 transition-colors"
518
+ @click="setModal(false)"
519
+ >
520
+ <component :is="closeIcon" class="w-6 h-6" />
521
+ </button>
522
+
523
+ <!-- Mobile thumbnails toggle button -->
524
+ <button
525
+ v-if="images.length > 1"
526
+ aria-label="Toggle thumbnails"
527
+ class="sm:hidden rounded-full p-2 bg-black/40 backdrop-blur-sm hover:bg-black/60 transition-colors"
528
+ @click="toggleMobileThumbnails"
529
+ >
530
+ <svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" viewBox="0 0 20 20" fill="currentColor">
531
+ <path d="M5 3a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2V5a2 2 0 00-2-2H5zM5 11a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2v-2a2 2 0 00-2-2H5zM11 5a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V5zM11 13a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
532
+ </svg>
533
+ </button>
534
+
535
+ <!-- Fullscreen toggle button -->
536
+ <button
537
+ aria-label="Toggle fullscreen"
538
+ class="rounded-full p-2 bg-black/40 backdrop-blur-sm hover:bg-black/60 transition-colors"
539
+ @click="toggleFullscreen"
540
+ >
541
+ <ArrowsPointingOutIcon class="w-5 h-5" />
542
+ </button>
543
+ </div>
544
+
545
+ <div class="px-3 py-1 rounded-full bg-black/40 backdrop-blur-sm text-white text-sm">
546
+ {{ imageCounter }}
547
+ </div>
548
+ </div>
246
549
 
247
550
  <div
248
- class="flex h-[100vh] relative flex-grow items-center justify-center gap-2 z-[1]"
551
+ class="flex h-full relative flex-grow items-center justify-center gap-2 z-[1]"
249
552
  >
553
+ <!-- Left nav button (larger touch area on mobile) -->
250
554
  <div
251
- class="hidden lg:relative z-[2] lg:flex w-10 flex-shrink-0 items-center justify-center flex-0"
555
+ class="absolute left-0 top-0 bottom-0 w-1/3 flex items-center justify-start pl-4 lg:pl-10 z-[2]"
556
+ @click="scale <= 1 && goPrevImage()"
252
557
  >
253
558
  <button
254
559
  v-if="images.length > 1"
255
- class="btn p-1 rounded-full"
256
- @click="goPrevImage()"
560
+ aria-label="Previous image"
561
+ class="rounded-full p-2 bg-black/40 backdrop-blur-sm hover:bg-black/60 transition-colors hidden sm:block"
562
+ @click.stop
257
563
  >
258
- <ArrowLeftCircleIcon class="w-8 h-8" />
564
+ <ArrowLeftCircleIcon class="w-6 h-6" />
259
565
  </button>
260
566
  </div>
567
+
261
568
  <div
262
569
  class="flex-1 flex flex-col z-[2] items-center justify-center max-w-full lg:max-w-[calc(100vw - 256px)] relative"
263
570
  >
571
+ <!-- Loading indicator -->
572
+ <div
573
+ v-if="isImageLoading"
574
+ class="absolute inset-0 flex items-center justify-center z-10 bg-fv-neutral-900/30 backdrop-blur-sm"
575
+ >
576
+ <div class="w-10 h-10 border-t-2 border-white rounded-full animate-spin" />
577
+ </div>
578
+
264
579
  <transition
265
580
  :name="direction === 'next' ? 'slide-next' : 'slide-prev'"
266
581
  mode="out-in"
267
582
  >
268
583
  <div
269
- v-if="true"
270
584
  :key="`image-display-${modelValue}`"
271
585
  class="flex-1 w-full max-w-full flex flex-col items-center justify-center absolute inset-0 z-[2]"
272
586
  >
273
587
  <div
274
- class="flex-1 w-full max-w-full flex items-center justify-center"
588
+ class="flex-1 w-full max-w-full flex items-center justify-center overflow-hidden"
275
589
  @touchstart="touchStart"
590
+ @touchmove="touchMove"
276
591
  @touchend="touchEnd"
277
592
  >
278
593
  <template
@@ -282,120 +597,255 @@ onUnmounted(() => {
282
597
  <component
283
598
  :is="videoComponent"
284
599
  :src="isVideo(images[modelValue])"
285
- class="shadow max-w-full h-auto object-contain max-h-[85vh]"
600
+ class="shadow-2xl max-w-full h-auto object-contain max-h-[85vh]"
286
601
  />
287
602
  </ClientOnly>
288
603
  </template>
289
604
  <template v-else>
290
- <img
291
- v-if="modelValueSrc && imageComponent === 'img'"
292
- class="shadow max-w-full h-auto object-contain max-h-[85vh]"
293
- :src="modelValueSrc"
294
- >
295
- <component
296
- :is="imageComponent"
297
- v-else-if="modelValueSrc && imageComponent"
298
- :image="modelValueSrc.image"
299
- :variant="modelValueSrc.variant"
300
- :alt="modelValueSrc.alt"
301
- class="shadow max-w-full h-auto object-contain max-h-[85vh]"
302
- />
605
+ <div class="image-container">
606
+ <img
607
+ v-if="modelValueSrc && imageComponent === 'img'"
608
+ class="shadow-2xl max-w-full h-auto object-contain max-h-[80vh] transition-transform duration-100 rounded-md"
609
+ :class="{ 'cursor-grab': scale > 1, 'cursor-zoom-in': scale === 1 }"
610
+ :style="{
611
+ transform: imageTransform,
612
+ transformOrigin: imageTransformOrigin,
613
+ touchAction: scale > 1 ? 'none' : 'auto',
614
+ }"
615
+ :src="modelValueSrc"
616
+ :alt="`Gallery image ${modelValue + 1}`"
617
+ @load="onImageLoad"
618
+ >
619
+ <component
620
+ :is="imageComponent"
621
+ v-else-if="modelValueSrc && imageComponent"
622
+ :image="modelValueSrc.image"
623
+ :variant="modelValueSrc.variant"
624
+ :alt="modelValueSrc.alt"
625
+ class="shadow-2xl max-w-full h-auto object-contain max-h-[80vh] transition-transform duration-100 rounded-md"
626
+ :style="{
627
+ transform: imageTransform,
628
+ transformOrigin: imageTransformOrigin,
629
+ touchAction: scale > 1 ? 'none' : 'auto',
630
+ }"
631
+ @load="onImageLoad"
632
+ />
633
+ </div>
303
634
  </template>
304
635
  </div>
636
+
637
+ <!-- Mobile zoom hint -->
638
+ <div
639
+ v-if="scale === 1 && !isImageLoading"
640
+ class="text-xs text-white/60 p-2 sm:hidden"
641
+ >
642
+ Double-tap to zoom
643
+ </div>
644
+
305
645
  <div
306
- class="flex-0 py-2 flex items-center justify-center max-w-full w-full relative !z-[3]"
646
+ class="flex-0 py-4 flex items-center justify-center max-w-full w-full relative !z-[3]"
307
647
  >
308
648
  <slot :value="images[modelValue]" />
309
649
  </div>
310
650
  </div>
311
651
  </transition>
312
652
  </div>
653
+
654
+ <!-- Right nav button (larger touch area on mobile) -->
313
655
  <div
314
- class="hidden lg:flex w-10 flex-shrink-0 items-center justify-center"
656
+ class="absolute right-0 top-0 bottom-0 w-1/3 flex items-center justify-end pr-4 lg:pr-10 z-[2]"
657
+ @click="scale <= 1 && goNextImage()"
315
658
  >
316
- <button
317
- class="btn w-9 h-9 rounded-full hidden lg:block absolute top-4"
318
- :class="{
319
- '-right-4': sidePanel,
320
- 'right-2': !sidePanel,
321
- }"
322
- style="z-index: 39"
323
- @click="() => (sidePanel = !sidePanel)"
324
- >
325
- <ChevronDoubleRightIcon v-if="sidePanel" class="w-7 h-7" />
326
- <ChevronDoubleLeftIcon v-else class="w-7 h-7" />
327
- </button>
328
659
  <button
329
660
  v-if="images.length > 1"
330
- class="btn p-1 rounded-full"
331
- @click="goNextImage()"
661
+ aria-label="Next image"
662
+ class="rounded-full p-2 bg-black/40 backdrop-blur-sm hover:bg-black/60 transition-colors hidden sm:block"
663
+ @click.stop
332
664
  >
333
- <ArrowRightCircleIcon class="w-8 h-8" />
665
+ <ArrowRightCircleIcon class="w-6 h-6" />
334
666
  </button>
335
667
  </div>
668
+
669
+ <!-- Sidebar toggle button (desktop) -->
670
+ <button
671
+ class="absolute top-4 right-4 rounded-full p-2 bg-black/40 backdrop-blur-sm hover:bg-black/60 transition-colors hidden lg:block"
672
+ style="z-index: 39"
673
+ :aria-label="sidePanel ? 'Hide thumbnails' : 'Show thumbnails'"
674
+ @click="() => (sidePanel = !sidePanel)"
675
+ >
676
+ <ChevronDoubleRightIcon v-if="sidePanel" class="w-5 h-5" />
677
+ <ChevronDoubleLeftIcon v-else class="w-5 h-5" />
678
+ </button>
336
679
  </div>
337
680
  </div>
338
681
 
339
- <TransitionRoot
340
- :show="sidePanel"
341
- as="div"
342
- enter="transform transition ease-in-out duration-300"
343
- enter-from="translate-x-full"
344
- enter-to="translate-x-0"
345
- leave="transform transition ease-in-out duration-300"
346
- leave-from="translate-x-0"
347
- leave-to="translate-x-full"
348
- class="hidden lg:block flex-shrink-0 w-64 bg-fv-neutral-800 h-[100vh] max-h-[100vh] overflow-y-auto"
682
+ <!-- Sidebar with thumbnails (desktop) -->
683
+ <transition
684
+ enter-active-class="transform transition ease-in-out duration-300"
685
+ enter-from-class="translate-x-full"
686
+ enter-to-class="translate-x-0"
687
+ leave-active-class="transform transition ease-in-out duration-300"
688
+ leave-from-class="translate-x-0"
689
+ leave-to-class="translate-x-full"
349
690
  >
350
- <!-- Side panel content -->
351
- <div v-if="paging" class="flex items-center justify-center">
352
- <DefaultPaging :id="id" :items="paging" />
691
+ <div
692
+ v-if="sidePanel"
693
+ class="hidden lg:block flex-shrink-0 w-64 bg-fv-neutral-800/80 backdrop-blur-sm h-full max-h-full overflow-y-auto border-l border-fv-neutral-700/50"
694
+ >
695
+ <!-- Sidebar header -->
696
+ <div class="p-4 border-b border-fv-neutral-700/50">
697
+ <h3 class="text-lg font-medium">
698
+ Gallery
699
+ </h3>
700
+ </div>
701
+
702
+ <!-- Paging controls if provided -->
703
+ <div v-if="paging" class="flex items-center justify-center p-2 border-b border-fv-neutral-700/50">
704
+ <DefaultPaging :id="id" :items="paging" />
705
+ </div>
706
+
707
+ <!-- Thumbnails grid -->
708
+ <div class="grid grid-cols-2 gap-2 p-4">
709
+ <div
710
+ v-for="i in images.length"
711
+ :key="`bg_${id}_${i}`"
712
+ class="relative group"
713
+ >
714
+ <!-- Active image indicator -->
715
+ <div
716
+ v-if="i - 1 === modelValue"
717
+ class="absolute inset-0 border-2 border-white rounded-lg z-10"
718
+ />
719
+
720
+ <!-- Thumbnail -->
721
+ <div
722
+ class="overflow-hidden rounded-lg transition-all duration-200"
723
+ :class="{ 'ring-2 ring-white': i - 1 === modelValue, 'opacity-60 group-hover:opacity-100': i - 1 !== modelValue }"
724
+ >
725
+ <img
726
+ v-if="imageComponent === 'img'"
727
+ class="h-auto max-w-full rounded-lg cursor-pointer shadow transform transition duration-300 group-hover:scale-105 group-hover:brightness-110"
728
+ :src="getThumbnailUrl(images[i - 1])"
729
+ :alt="`Thumbnail ${i}`"
730
+ @click="$eventBus.emit(`${id}GalleryImage`, i - 1)"
731
+ >
732
+ <component
733
+ :is="imageComponent"
734
+ v-else
735
+ :image="getThumbnailUrl(images[i - 1]).image"
736
+ :variant="getThumbnailUrl(images[i - 1]).variant"
737
+ :alt="getThumbnailUrl(images[i - 1]).alt"
738
+ :class="`h-auto max-w-full rounded-lg cursor-pointer shadow transform transition duration-300 group-hover:scale-105 group-hover:brightness-110 ${getBorderColor(images[i - 1])}`"
739
+ @click="$eventBus.emit(`${id}GalleryImage`, i - 1)"
740
+ />
741
+ </div>
742
+ </div>
743
+ </div>
353
744
  </div>
354
- <div class="grid grid-cols-2 gap-2 p-2">
355
- <div
356
- v-for="i in images.length"
357
- :key="`bg_${id}_${i}`"
358
- class="hover:!brightness-100"
359
- :style="{
360
- filter:
361
- i - 1 === modelValue ? 'brightness(1)' : 'brightness(0.5)',
362
- }"
745
+ </transition>
746
+ </div>
747
+
748
+ <!-- Mobile navigation controls (bottom bar) -->
749
+ <div
750
+ v-if="!showMobileThumbnails"
751
+ class="fixed bottom-0 left-0 right-0 sm:hidden flex justify-between items-center p-4 bg-gradient-to-t from-black/80 to-transparent"
752
+ style="z-index: 40"
753
+ >
754
+ <button
755
+ v-if="images.length > 1 && scale <= 1"
756
+ aria-label="Previous image"
757
+ class="rounded-full p-3 bg-black/50 backdrop-blur-sm active:bg-black/70"
758
+ @click="goPrevImage"
759
+ >
760
+ <ArrowLeftCircleIcon class="w-6 h-6" />
761
+ </button>
762
+
763
+ <!-- Reset zoom button when zoomed -->
764
+ <button
765
+ v-if="scale > 1"
766
+ aria-label="Reset zoom"
767
+ class="rounded-full p-2 bg-black/50 backdrop-blur-sm active:bg-black/70"
768
+ @click="scale = 1; panPosition.x = 0; panPosition.y = 0"
769
+ >
770
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
771
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5v-4m0 4h-4m4 0l-5-5" />
772
+ </svg>
773
+ </button>
774
+
775
+ <button
776
+ v-if="images.length > 1 && scale <= 1"
777
+ aria-label="Next image"
778
+ class="rounded-full p-3 bg-black/50 backdrop-blur-sm active:bg-black/70"
779
+ @click="goNextImage"
780
+ >
781
+ <ArrowRightCircleIcon class="w-6 h-6" />
782
+ </button>
783
+ </div>
784
+
785
+ <!-- Mobile thumbnails panel -->
786
+ <transition
787
+ enter-active-class="transform transition ease-in-out duration-300"
788
+ enter-from-class="translate-y-full"
789
+ enter-to-class="translate-y-0"
790
+ leave-active-class="transform transition ease-in-out duration-300"
791
+ leave-from-class="translate-y-0"
792
+ leave-to-class="translate-y-full"
793
+ >
794
+ <div
795
+ v-if="showMobileThumbnails"
796
+ class="fixed bottom-0 left-0 right-0 sm:hidden bg-black/80 backdrop-blur-md pt-4 pb-6 border-t border-fv-neutral-700/50"
797
+ style="z-index: 50"
798
+ >
799
+ <div class="relative">
800
+ <!-- Close thumbnails button -->
801
+ <button
802
+ aria-label="Close thumbnails"
803
+ class="absolute top-0 right-4 rounded-full p-1 bg-black/40 hover:bg-black/60"
804
+ @click="showMobileThumbnails = false"
363
805
  >
364
- <img
365
- v-if="imageComponent === 'img'"
366
- :class="`h-auto max-w-full rounded-lg cursor-pointer shadow ${getBorderColor(
367
- images[i - 1],
368
- )}`"
369
- :src="getThumbnailUrl(images[i - 1])"
370
- @click="$eventBus.emit(`${id}GalleryImage`, i - 1)"
806
+ <XCircleIcon class="w-5 h-5" />
807
+ </button>
808
+
809
+ <p class="text-center text-sm mb-2 font-medium">
810
+ Thumbnails
811
+ </p>
812
+
813
+ <!-- Horizontal scrollable thumbnails -->
814
+ <div class="flex overflow-x-auto px-4 pb-2 space-x-2 hide-scrollbar">
815
+ <div
816
+ v-for="i in images.length"
817
+ :key="`mt_${id}_${i}`"
818
+ class="flex-shrink-0 w-16 h-16 relative"
819
+ :class="{ 'opacity-100': i - 1 === modelValue, 'opacity-50': i - 1 !== modelValue }"
371
820
  >
372
- <component
373
- :is="imageComponent"
374
- v-else
375
- :image="getThumbnailUrl(images[i - 1]).image"
376
- :variant="getThumbnailUrl(images[i - 1]).variant"
377
- :alt="getThumbnailUrl(images[i - 1]).alt"
378
- :class="`h-auto max-w-full rounded-lg cursor-pointer shadow ${getBorderColor(
379
- images[i - 1],
380
- )}`"
381
- @click="$eventBus.emit(`${id}GalleryImage`, i - 1)"
382
- />
821
+ <div
822
+ v-if="i - 1 === modelValue"
823
+ class="absolute inset-0 border-2 border-white rounded-lg z-10"
824
+ />
825
+ <img
826
+ class="h-full w-full object-cover rounded-lg"
827
+ :src="getThumbnailUrl(images[i - 1])"
828
+ :alt="`Thumbnail ${i}`"
829
+ @click="$eventBus.emit(`${id}GalleryImage`, i - 1); showMobileThumbnails = false"
830
+ >
831
+ </div>
383
832
  </div>
384
833
  </div>
385
- </TransitionRoot>
386
- </div>
387
- </DialogPanel>
388
- </Dialog>
389
- </TransitionRoot>
834
+ </div>
835
+ </transition>
836
+ </div>
837
+ </div>
838
+ </transition>
390
839
 
840
+ <!-- Grid view mode -->
391
841
  <div v-if="mode === 'grid' || mode === 'mason' || mode === 'custom'" class="min-h-[600px]">
392
842
  <div
393
843
  :class="{
394
- ' grid grid-cols-2 md:grid-cols-4 xl:grid-cols-6 gap-4 items-start':
844
+ 'grid grid-cols-2 md:grid-cols-4 xl:grid-cols-6 gap-4 items-start':
395
845
  mode === 'mason',
396
- ' grid grid-cols-2 md:grid-cols-4 xl:grid-cols-6 gap-4 items-center':
846
+ 'grid grid-cols-2 md:grid-cols-4 xl:grid-cols-6 gap-4 items-center':
397
847
  mode === 'grid',
398
- ' custom-grid': mode === 'custom',
848
+ 'custom-grid': mode === 'custom',
399
849
  }"
400
850
  >
401
851
  <slot name="thumbnail" />
@@ -410,11 +860,21 @@ onUnmounted(() => {
410
860
  </div>
411
861
 
412
862
  <template v-for="j in gridHeight" :key="`gi_${id}_${i + j}`">
413
- <div>
863
+ <div class="group overflow-hidden rounded-lg relative">
864
+ <!-- Visual feedback for the tap action on mobile -->
865
+ <div class="absolute inset-0 bg-black/30 opacity-0 group-hover:opacity-100 group-active:opacity-100 transition-opacity duration-300 flex items-center justify-center z-[1]">
866
+ <div class="bg-black/60 rounded-full p-2 transform scale-0 group-hover:scale-100 group-active:scale-100 transition-transform duration-300">
867
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
868
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v3m0 0v3m0-3h3m-3 0H7" />
869
+ </svg>
870
+ </div>
871
+ </div>
872
+
414
873
  <img
415
874
  v-if="i + j - 2 < images.length && imageComponent === 'img'"
416
- class="h-auto max-w-full rounded-lg cursor-pointer"
875
+ class="h-auto max-w-full rounded-lg cursor-pointer transform transition-transform duration-300 group-hover:scale-105 group-active:scale-102"
417
876
  :src="getThumbnailUrl(images[i + j - 2])"
877
+ :alt="`Gallery image ${i + j - 1}`"
418
878
  @click="$eventBus.emit(`${id}GalleryImage`, i + j - 2)"
419
879
  >
420
880
  <component
@@ -423,9 +883,7 @@ onUnmounted(() => {
423
883
  :image="getThumbnailUrl(images[i + j - 2]).image"
424
884
  :variant="getThumbnailUrl(images[i + j - 2]).variant"
425
885
  :alt="getThumbnailUrl(images[i + j - 2]).alt"
426
- :class="`h-auto max-w-full rounded-lg cursor-pointer ${getBorderColor(
427
- images[i + j - 2],
428
- )}`"
886
+ :class="`h-auto max-w-full rounded-lg cursor-pointer transform transition-transform duration-300 group-hover:scale-105 group-active:scale-102 ${getBorderColor(images[i + j - 2])}`"
429
887
  :likes="getThumbnailUrl(images[i + j - 2]).likes"
430
888
  :show-likes="getThumbnailUrl(images[i + j - 2]).showLikes"
431
889
  :is-author="getThumbnailUrl(images[i + j - 2]).isAuthor"
@@ -436,14 +894,25 @@ onUnmounted(() => {
436
894
  </template>
437
895
  </div>
438
896
  </template>
439
- <div v-else class="relative">
440
- <div v-if="ranking" class="img-gallery-ranking">
897
+ <div v-else class="relative group overflow-hidden rounded-lg">
898
+ <div v-if="ranking" class="img-gallery-ranking z-10">
441
899
  {{ i }}
442
900
  </div>
901
+
902
+ <!-- Visual overlay on hover with touch feedback -->
903
+ <div class="absolute inset-0 bg-black/30 opacity-0 group-hover:opacity-100 group-active:opacity-100 transition-opacity duration-300 flex items-center justify-center z-[1]">
904
+ <div class="bg-black/60 rounded-full p-2 transform scale-0 group-hover:scale-100 group-active:scale-100 transition-transform duration-300">
905
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
906
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v3m0 0v3m0-3h3m-3 0H7" />
907
+ </svg>
908
+ </div>
909
+ </div>
910
+
443
911
  <img
444
912
  v-if="imageComponent === 'img'"
445
- class="h-auto max-w-full rounded-lg cursor-pointer"
913
+ class="h-auto max-w-full rounded-lg cursor-pointer transform transition-transform duration-300 group-hover:scale-105 group-active:scale-102"
446
914
  :src="getThumbnailUrl(images[i - 1])"
915
+ :alt="`Gallery image ${i}`"
447
916
  @click="$eventBus.emit(`${id}GalleryImage`, i - 1)"
448
917
  >
449
918
  <component
@@ -452,9 +921,7 @@ onUnmounted(() => {
452
921
  :image="getThumbnailUrl(images[i - 1]).image"
453
922
  :variant="getThumbnailUrl(images[i - 1]).variant"
454
923
  :alt="getThumbnailUrl(images[i - 1]).alt"
455
- :class="`h-auto max-w-full rounded-lg cursor-pointer ${getBorderColor(
456
- images[i - 1],
457
- )}`"
924
+ :class="`h-auto max-w-full rounded-lg cursor-pointer transform transition-transform duration-300 group-hover:scale-105 group-active:scale-102 ${getBorderColor(images[i - 1])}`"
458
925
  :likes="getThumbnailUrl(images[i - 1]).likes"
459
926
  :show-likes="getThumbnailUrl(images[i - 1]).showLikes"
460
927
  :is-author="getThumbnailUrl(images[i - 1]).isAuthor"
@@ -465,11 +932,16 @@ onUnmounted(() => {
465
932
  </template>
466
933
  </div>
467
934
  </div>
935
+
936
+ <!-- Button mode -->
468
937
  <button
469
938
  v-if="mode === 'button'"
470
- :class="`btn ${buttonType ? buttonType : 'primary'} defaults`"
939
+ :class="`btn ${buttonType ? buttonType : 'primary'} defaults flex items-center gap-2`"
471
940
  @click="openGalleryImage(0)"
472
941
  >
942
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
943
+ <path fill-rule="evenodd" d="M4 3a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V5a2 2 0 00-2-2H4zm12 12H4l4-8 3 6 2-4 3 6z" clip-rule="evenodd" />
944
+ </svg>
473
945
  {{ buttonText ? buttonText : $t("open_gallery_cta") }}
474
946
  </button>
475
947
  </div>
@@ -480,9 +952,9 @@ onUnmounted(() => {
480
952
  .slide-next-enter-active,
481
953
  .slide-next-leave-active {
482
954
  transition:
483
- opacity 0.15s,
484
- transform 0.15s,
485
- filter 0.15s;
955
+ opacity 0.3s,
956
+ transform 0.3s,
957
+ filter 0.3s;
486
958
  }
487
959
 
488
960
  .slide-next-enter-from {
@@ -513,9 +985,9 @@ onUnmounted(() => {
513
985
  .slide-prev-enter-active,
514
986
  .slide-prev-leave-active {
515
987
  transition:
516
- opacity 0.15s,
517
- transform 0.15s,
518
- filter 0.15s;
988
+ opacity 0.3s,
989
+ transform 0.3s,
990
+ filter 0.3s;
519
991
  }
520
992
 
521
993
  .slide-prev-enter-from {
@@ -542,18 +1014,74 @@ onUnmounted(() => {
542
1014
  filter: blur(10px);
543
1015
  }
544
1016
 
545
- /* Ensure the images are positioned correctly to prevent overlap */
546
- .relative-container {
547
- position: relative;
548
- width: 100%;
549
- height: 100%;
1017
+ /* Custom animation for loading spinner */
1018
+ @keyframes spin {
1019
+ from { transform: rotate(0deg); }
1020
+ to { transform: rotate(360deg); }
550
1021
  }
551
1022
 
552
- .relative-container > div {
1023
+ .animate-spin {
1024
+ animation: spin 1s linear infinite;
1025
+ }
1026
+
1027
+ /* Improved gallery ranking */
1028
+ .img-gallery-ranking {
553
1029
  position: absolute;
554
- top: 0;
555
- left: 0;
1030
+ top: 0.5rem;
1031
+ left: 0.5rem;
1032
+ width: 1.5rem;
1033
+ height: 1.5rem;
1034
+ display: flex;
1035
+ align-items: center;
1036
+ justify-content: center;
1037
+ background-color: rgba(0, 0, 0, 0.7);
1038
+ color: white;
1039
+ border-radius: 9999px;
1040
+ font-size: 0.75rem;
1041
+ font-weight: 600;
1042
+ z-index: 5;
1043
+ }
1044
+
1045
+ /* Image container */
1046
+ .image-container {
1047
+ display: flex;
1048
+ align-items: center;
1049
+ justify-content: center;
556
1050
  width: 100%;
557
1051
  height: 100%;
1052
+ user-select: none;
1053
+ touch-action: none;
1054
+ }
1055
+
1056
+ /* Hide scrollbar for mobile thumbnails */
1057
+ .hide-scrollbar {
1058
+ -ms-overflow-style: none; /* IE and Edge */
1059
+ scrollbar-width: none; /* Firefox */
1060
+ }
1061
+
1062
+ .hide-scrollbar::-webkit-scrollbar {
1063
+ display: none; /* Chrome, Safari, Opera */
1064
+ }
1065
+
1066
+ /* Active touch feedback */
1067
+ .group-active\:opacity-100 {
1068
+ @apply group-active:opacity-100;
1069
+ }
1070
+
1071
+ .group-active\:scale-100 {
1072
+ @apply group-active:scale-100;
1073
+ }
1074
+
1075
+ .group-active\:scale-102 {
1076
+ @apply group-active:scale-[1.02];
1077
+ }
1078
+
1079
+ /* Fullscreen mode */
1080
+ .fullscreen-mode {
1081
+ position: fixed;
1082
+ inset: 0;
1083
+ width: 100vw;
1084
+ height: 100vh;
1085
+ z-index: 99;
558
1086
  }
559
1087
  </style>