@fy-/fws-vue 2.3.21 → 2.3.23

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.
@@ -46,8 +46,6 @@ const { width: windowWidth, height: windowHeight } = useWindowSize()
46
46
  const { height: topControlsHeight } = useElementSize(topControlsRef)
47
47
  const { height: infoPanelHeight } = useElementSize(infoPanelRef)
48
48
 
49
- // We no longer need derived measurements as we use CSS variables instead
50
-
51
49
  // Use VueUse's useFullscreen for better fullscreen handling
52
50
  const { isFullscreen: isElementFullscreen, enter: enterFullscreen, exit: exitFullscreen } = useFullscreen(galleryRef)
53
51
 
@@ -131,33 +129,27 @@ const currentImage = computed(() => {
131
129
  const imageCount = computed(() => props.images.length)
132
130
  const currentIndex = computed(() => modelValue.value + 1)
133
131
 
134
- // Update CSS variables for layout consistency
135
- function updateInfoHeight() {
136
- if (!infoPanelRef.value) return
137
-
138
- const height = infoPanelRef.value.offsetHeight || 0
139
- document.documentElement.style.setProperty('--info-height', `${height}px`)
140
- }
141
-
142
- function updateControlsHeight() {
143
- if (!topControlsRef.value) return
144
-
145
- const height = topControlsRef.value.offsetHeight || 0
146
- document.documentElement.style.setProperty('--controls-height', `${height}px`)
147
- }
148
-
149
- // CSS variable-based image sizing
132
+ // Simple but effective image sizing function
150
133
  const updateImageSizes = useDebounceFn(() => {
151
- // Update CSS variables first
152
- updateInfoHeight()
153
- updateControlsHeight()
154
-
155
- // Set sidebar width variable
156
- const sidebarWidthValue = sidePanel.value ? '16rem' : '0px'
157
- document.documentElement.style.setProperty('--sidebar-width', sidebarWidthValue)
158
-
159
- // No need to manipulate image elements directly - CSS variables will handle it
160
- }, 10)
134
+ // Only target main image for sizing
135
+ const mainImage = document.querySelector('.image-display img') as HTMLImageElement
136
+ if (!mainImage) return
137
+
138
+ // Simple, direct calculation of available space
139
+ const topHeight = topControlsRef.value?.offsetHeight || 0
140
+ const infoHeight = infoPanel.value && infoPanelRef.value ? infoPanelRef.value.offsetHeight : 0
141
+ const availableHeight = windowHeight.value - topHeight - infoHeight - 32
142
+
143
+ // Apply size directly to fill available space
144
+ mainImage.style.maxHeight = `${availableHeight}px`
145
+ mainImage.style.width = 'auto'
146
+
147
+ // Handle width constraints
148
+ const sidebarWidth = sidePanel.value ? 256 : 0
149
+ mainImage.style.maxWidth = windowWidth.value <= 768
150
+ ? '85vw'
151
+ : `${windowWidth.value - sidebarWidth - 48}px`
152
+ }, 50)
161
153
 
162
154
  // Modal controls
163
155
  function setModal(value: boolean) {
@@ -170,12 +162,7 @@ function setModal(value: boolean) {
170
162
  useEventListener(document, 'keyup', handleKeyboardRelease)
171
163
  }
172
164
 
173
- // Auto-hide controls after 3 seconds on mobile
174
- if (windowWidth.value < 1024) {
175
- controlsTimeout = window.setTimeout(() => {
176
- showControls.value = false
177
- }, 3000)
178
- }
165
+ // No longer auto-hide controls on mobile
179
166
  }
180
167
  else {
181
168
  if (props.onClose) props.onClose()
@@ -225,6 +212,11 @@ function goNextImage() {
225
212
  modelValue.value = 0
226
213
  }
227
214
  resetControlsTimer()
215
+
216
+ // Force image sizing update after navigation
217
+ nextTick(() => {
218
+ updateImageSizes()
219
+ })
228
220
  }
229
221
 
230
222
  function goPrevImage() {
@@ -237,29 +229,22 @@ function goPrevImage() {
237
229
  modelValue.value = props.images.length - 1 > 0 ? props.images.length - 1 : 0
238
230
  }
239
231
  resetControlsTimer()
232
+
233
+ // Force image sizing update after navigation
234
+ nextTick(() => {
235
+ updateImageSizes()
236
+ })
240
237
  }
241
238
 
242
239
  // UI control functions
243
240
  function resetControlsTimer() {
244
- // Show controls when user interacts
241
+ // Always show controls - no auto-hide
245
242
  showControls.value = true
246
-
247
- // Only set timer on mobile
248
- if (windowWidth.value < 1024) {
249
- if (controlsTimeout) {
250
- clearTimeout(controlsTimeout)
251
- }
252
- controlsTimeout = window.setTimeout(() => {
253
- showControls.value = false
254
- }, 3000)
255
- }
256
243
  }
257
244
 
245
+ // eslint-disable-next-line unused-imports/no-unused-vars
258
246
  function toggleControls() {
259
247
  showControls.value = !showControls.value
260
- if (showControls.value && windowWidth.value < 1024) {
261
- resetControlsTimer()
262
- }
263
248
  }
264
249
 
265
250
  function toggleInfoPanel() {
@@ -343,9 +328,8 @@ const touchEnd = useDebounceFn((event: TouchEvent) => {
343
328
  const diffX = start.x - end.x
344
329
  const diffY = start.y - end.y
345
330
 
346
- // Detect tap (quick touch with minimal movement)
331
+ // For taps, we don't toggle controls anymore - they always stay visible
347
332
  if (Math.abs(diffX) < 10 && Math.abs(diffY) < 10 && touchDuration < 300) {
348
- toggleControls()
349
333
  return
350
334
  }
351
335
 
@@ -440,9 +424,6 @@ onMounted(() => {
440
424
  eventBus.on(`${props.id}Gallery`, openGalleryImage)
441
425
  eventBus.on(`${props.id}GalleryClose`, closeGallery)
442
426
 
443
- // Initialize CSS variables
444
- updateImageSizes()
445
-
446
427
  // Set up observers for dynamic resizing
447
428
  if (topControlsRef.value) {
448
429
  useResizeObserver(topControlsRef.value, updateImageSizes)
@@ -567,7 +548,8 @@ onUnmounted(() => {
567
548
  >
568
549
  <!-- Main Image Area - Fills available space -->
569
550
  <div
570
- class="relative flex-1 h-full flex items-center justify-center pt-[var(--controls-height)]"
551
+ class="relative flex-1 h-full flex items-center justify-center"
552
+ :style="{ paddingTop: `${topControlsHeight}px` }"
571
553
  :class="{ 'lg:pr-64': sidePanel, 'lg:max-w-[calc(100%-16rem)]': sidePanel }"
572
554
  style="max-width: 100%;"
573
555
  >
@@ -617,14 +599,15 @@ onUnmounted(() => {
617
599
  <component
618
600
  :is="videoComponent"
619
601
  :src="isVideo(images[modelValue])"
620
- class="shadow max-w-full video-component gallery-image"
602
+ class="shadow max-w-full h-auto object-contain video-component"
603
+ @load="updateImageSizes"
621
604
  />
622
605
  </ClientOnly>
623
606
  </template>
624
607
  <template v-else>
625
608
  <img
626
609
  v-if="modelValueSrc && imageComponent === 'img'"
627
- class="shadow max-w-full gallery-image"
610
+ class="shadow max-w-full h-auto object-contain"
628
611
  :src="modelValueSrc"
629
612
  :alt="`Gallery image ${modelValue + 1}`"
630
613
  @load="updateImageSizes"
@@ -632,11 +615,10 @@ onUnmounted(() => {
632
615
  <component
633
616
  :is="imageComponent"
634
617
  v-else-if="modelValueSrc && imageComponent"
635
-
636
- class="shadow max-w-full gallery-image"
637
618
  :image="modelValueSrc.image"
638
619
  :variant="modelValueSrc.variant"
639
620
  :alt="modelValueSrc.alt"
621
+ class="shadow max-w-full h-auto object-contain"
640
622
  />
641
623
  </template>
642
624
  </div>
@@ -680,7 +662,6 @@ onUnmounted(() => {
680
662
  v-if="infoPanel && images[modelValue]"
681
663
  ref="infoPanelRef"
682
664
  class="info-panel absolute bottom-0 left-0 right-0 px-4 py-3 backdrop-blur-md bg-fv-neutral-900/70 z-45"
683
- @transitionend="updateImageSizes"
684
665
  >
685
666
  <slot :value="images[modelValue]" />
686
667
  </div>
@@ -699,7 +680,8 @@ onUnmounted(() => {
699
680
  <div
700
681
  v-if="sidePanel"
701
682
  ref="sidePanelRef"
702
- class="side-panel hidden lg:block absolute right-0 top-0 bottom-0 w-64 backdrop-blur-md overflow-y-auto z-40 cool-scroll pt-[calc(var(--controls-height)+8px)]"
683
+ 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"
684
+ :style="{ paddingTop: `${topControlsHeight + 8}px` }"
703
685
  >
704
686
  <!-- Paging Controls if needed -->
705
687
  <div v-if="paging" class="flex items-center justify-center pt-2">
@@ -901,13 +883,6 @@ onUnmounted(() => {
901
883
  </template>
902
884
 
903
885
  <style scoped>
904
- /* CSS variables for dimensions */
905
- :root {
906
- --controls-height: 0px;
907
- --info-height: 0px;
908
- --sidebar-width: 16rem;
909
- }
910
-
911
886
  /* Ensure controls stay fixed at top */
912
887
  .controls-bar {
913
888
  height: auto;
@@ -938,26 +913,6 @@ onUnmounted(() => {
938
913
  border-top-right-radius: 0.5rem;
939
914
  }
940
915
 
941
- /* Image sizing in different contexts - simplified approach like old component */
942
- .gallery-image {
943
- height: auto;
944
- object-fit: contain;
945
- max-width: 92vw;
946
- max-height: calc(80vh - var(--controls-height) - var(--info-height, 0px));
947
- transition: max-height 0.3s ease-out, max-width 0.3s ease-out;
948
- }
949
-
950
- @media (min-width: 1024px) {
951
- .gallery-image {
952
- max-width: calc(92vw - var(--sidebar-width) - 48px);
953
- }
954
- }
955
-
956
- /* Fullscreen mode sizing */
957
- :is(.gallery-container[style*="fullscreen"]) .gallery-image {
958
- max-height: calc(92vh - var(--controls-height) - var(--info-height, 0px));
959
- }
960
-
961
916
  /* Transition styles for next (right) navigation */
962
917
  .slide-next-enter-active,
963
918
  .slide-next-leave-active {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fy-/fws-vue",
3
- "version": "2.3.21",
3
+ "version": "2.3.23",
4
4
  "author": "Florian 'Fy' Gasquez <m@fy.to>",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/fy-to/FWJS#readme",
@@ -1,1073 +0,0 @@
1
- <script setup lang="ts">
2
- import type { Component } from 'vue'
3
- import type { APIPaging } from '../../composables/rest'
4
- import {
5
- ChevronDoubleLeftIcon,
6
- ChevronDoubleRightIcon,
7
- ChevronLeftIcon,
8
- ChevronRightIcon,
9
- InformationCircleIcon,
10
- XMarkIcon,
11
- } from '@heroicons/vue/24/solid'
12
- import { useDebounceFn, useEventListener, useFullscreen, useResizeObserver } from '@vueuse/core'
13
- import { computed, h, nextTick, onMounted, onUnmounted, reactive, ref, shallowRef, watch } from 'vue'
14
- import { useEventBus } from '../../composables/event-bus'
15
- import DefaultPaging from './DefaultPaging.vue'
16
-
17
- const isGalleryOpen = ref<boolean>(false)
18
- const eventBus = useEventBus()
19
- const sidePanel = ref<boolean>(true)
20
- const showControls = ref<boolean>(true)
21
- const isFullscreen = ref<boolean>(false)
22
- 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)
26
-
27
- // Use VueUse's useFullscreen for better fullscreen handling
28
- const { isFullscreen: isElementFullscreen, enter: enterFullscreen, exit: exitFullscreen } = useFullscreen(galleryContainerRef)
29
-
30
- // Track when fullscreen changes externally (like Escape key)
31
- watch(isElementFullscreen, (newValue) => {
32
- isFullscreen.value = newValue
33
- if (newValue) {
34
- // Force update of image size when entering fullscreen
35
- nextTick(() => {
36
- updateImageSizes()
37
- })
38
- }
39
- })
40
-
41
- const props = withDefaults(
42
- defineProps<{
43
- id: string
44
- images: Array<any>
45
- title?: string
46
- getImageUrl?: Function
47
- getThumbnailUrl?: Function
48
- onOpen?: Function
49
- onClose?: Function
50
- closeIcon?: object
51
- gridHeight?: number
52
- mode: 'mason' | 'grid' | 'button' | 'hidden' | 'custom'
53
- paging?: APIPaging | undefined
54
- buttonText?: string
55
- buttonType?: string
56
- modelValue: number
57
- borderColor?: Function
58
- imageLoader: string
59
- videoComponent?: Component | string
60
- imageComponent?: Component | string
61
- isVideo?: Function
62
- ranking?: boolean
63
- }>(),
64
- {
65
- modelValue: 0,
66
- imageComponent: 'img',
67
- mode: 'grid',
68
- gridHeight: 4,
69
- closeIcon: () => h(XMarkIcon),
70
- images: () => [],
71
- isVideo: () => false,
72
- getImageUrl: (image: any) => image.image_url,
73
- getThumbnailUrl: (image: any) => `${image.image_url}?s=250x250&m=autocrop`,
74
- paging: undefined,
75
- borderColor: undefined,
76
- ranking: false,
77
- },
78
- )
79
-
80
- const emit = defineEmits(['update:modelValue'])
81
- const modelValue = computed({
82
- get: () => props.modelValue,
83
- set: (i) => {
84
- emit('update:modelValue', i)
85
- },
86
- })
87
-
88
- const direction = ref<'next' | 'prev'>('next')
89
-
90
- let controlsTimeout: number | null = null
91
- let fullscreenResizeTimeout: number | null = null
92
-
93
- // Used to maintain consistent image sizes
94
- function updateImageSizes() {
95
- 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
- }
113
- })
114
- }
115
-
116
- function setModal(value: boolean) {
117
- if (value === true) {
118
- if (props.onOpen) props.onOpen()
119
- document.body.style.overflow = 'hidden' // Prevent scrolling when gallery is open
120
- if (!import.meta.env.SSR) {
121
- useEventListener(document, 'keydown', handleKeyboardInput)
122
- useEventListener(document, 'keyup', handleKeyboardRelease)
123
- }
124
- // Auto-hide controls after 3 seconds on mobile
125
- if (window.innerWidth < 1024) {
126
- controlsTimeout = window.setTimeout(() => {
127
- showControls.value = false
128
- }, 3000)
129
- }
130
- }
131
- else {
132
- if (props.onClose) props.onClose()
133
- document.body.style.overflow = '' // Restore scrolling
134
- // Exit fullscreen if active
135
- if (isFullscreen.value) {
136
- exitFullscreen()
137
- isFullscreen.value = false
138
- }
139
- // Clear timeout if modal is closed
140
- if (controlsTimeout) {
141
- clearTimeout(controlsTimeout)
142
- controlsTimeout = null
143
- }
144
- }
145
- isGalleryOpen.value = value
146
- showControls.value = true
147
- // Don't reset info panel state when opening/closing
148
- }
149
-
150
- const openGalleryImage = useDebounceFn((index: number | undefined) => {
151
- if (index === undefined) {
152
- modelValue.value = 0
153
- }
154
- else {
155
- modelValue.value = Number.parseInt(index.toString())
156
- }
157
- setModal(true)
158
- }, 50) // Debounce to prevent accidental double-opens
159
-
160
- function goNextImage() {
161
- direction.value = 'next'
162
- if (modelValue.value < props.images.length - 1) {
163
- modelValue.value++
164
- }
165
- else {
166
- modelValue.value = 0
167
- }
168
- resetControlsTimer()
169
- }
170
-
171
- function goPrevImage() {
172
- direction.value = 'prev'
173
- if (modelValue.value > 0) {
174
- modelValue.value--
175
- }
176
- else {
177
- modelValue.value
178
- = props.images.length - 1 > 0 ? props.images.length - 1 : 0
179
- }
180
- resetControlsTimer()
181
- }
182
-
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
-
199
- function resetControlsTimer() {
200
- // Show controls when user interacts
201
- showControls.value = true
202
-
203
- // Only set timer on mobile
204
- if (window.innerWidth < 1024) {
205
- if (controlsTimeout) {
206
- clearTimeout(controlsTimeout)
207
- }
208
- controlsTimeout = window.setTimeout(() => {
209
- showControls.value = false
210
- }, 3000)
211
- }
212
- }
213
-
214
- function toggleControls() {
215
- showControls.value = !showControls.value
216
- if (showControls.value && window.innerWidth < 1024) {
217
- resetControlsTimer()
218
- }
219
- }
220
-
221
- function toggleInfoPanel() {
222
- infoPanel.value = !infoPanel.value
223
- resetControlsTimer()
224
-
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
- }
235
- }
236
-
237
- function toggleFullscreen() {
238
- if (!isFullscreen.value) {
239
- if (galleryContainerRef.value) {
240
- enterFullscreen()
241
- .then(() => {
242
- isFullscreen.value = true
243
- // Give browser time to adjust fullscreen before updating sizing
244
- if (fullscreenResizeTimeout) clearTimeout(fullscreenResizeTimeout)
245
- fullscreenResizeTimeout = window.setTimeout(() => {
246
- updateImageSizes()
247
- }, 50)
248
- })
249
- .catch(() => {})
250
- }
251
- }
252
- else {
253
- exitFullscreen()
254
- .then(() => {
255
- isFullscreen.value = false
256
- })
257
- .catch(() => {})
258
- }
259
- resetControlsTimer()
260
- }
261
-
262
- // Touch handling with debounce to prevent multiple rapid changes
263
- const touchStart = useDebounceFn((event: TouchEvent) => {
264
- const touch = event.touches[0]
265
- const targetElement = touch.target as HTMLElement
266
-
267
- // Store start time for tap detection
268
- touchStartTime.value = Date.now()
269
-
270
- // Check if the touch started on an interactive element
271
- if (targetElement.closest('button, a, input, textarea, select')) {
272
- return // Don't handle swipe if interacting with an interactive element
273
- }
274
-
275
- start.x = touch.screenX
276
- start.y = touch.screenY
277
- }, 50)
278
-
279
- const touchEnd = useDebounceFn((event: TouchEvent) => {
280
- const touch = event.changedTouches[0]
281
- const targetElement = touch.target as HTMLElement
282
- const touchDuration = Date.now() - touchStartTime.value
283
-
284
- // Check if the touch ended on an interactive element
285
- if (targetElement.closest('button, a, input, textarea, select')) {
286
- return // Don't handle swipe if interacting with an interactive element
287
- }
288
-
289
- const end = { x: touch.screenX, y: touch.screenY }
290
-
291
- const diffX = start.x - end.x
292
- const diffY = start.y - end.y
293
-
294
- // Detect tap (quick touch with minimal movement)
295
- if (Math.abs(diffX) < 10 && Math.abs(diffY) < 10 && touchDuration < 300) {
296
- toggleControls()
297
- return
298
- }
299
-
300
- // Add a threshold to prevent accidental swipes
301
- if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 50) {
302
- if (diffX > 0) {
303
- direction.value = 'next'
304
- goNextImage()
305
- }
306
- else {
307
- direction.value = 'prev'
308
- goPrevImage()
309
- }
310
- }
311
- }, 50)
312
-
313
- function getBorderColor(i: any) {
314
- if (props.borderColor !== undefined) {
315
- return props.borderColor(i)
316
- }
317
- return ''
318
- }
319
-
320
- const isKeyPressed = ref<boolean>(false)
321
-
322
- function handleKeyboardInput(event: KeyboardEvent) {
323
- if (!isGalleryOpen.value) return
324
- if (isKeyPressed.value) return
325
-
326
- switch (event.key) {
327
- case 'Escape':
328
- event.preventDefault()
329
- setModal(false)
330
- break
331
- case 'ArrowRight':
332
- isKeyPressed.value = true
333
- direction.value = 'next'
334
- goNextImage()
335
- break
336
- case 'ArrowLeft':
337
- isKeyPressed.value = true
338
- direction.value = 'prev'
339
- goPrevImage()
340
- break
341
- case 'f':
342
- toggleFullscreen()
343
- break
344
- case 'i':
345
- toggleInfoPanel()
346
- break
347
- default:
348
- break
349
- }
350
- }
351
-
352
- function handleKeyboardRelease(event: KeyboardEvent) {
353
- if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') {
354
- isKeyPressed.value = false
355
- }
356
- }
357
-
358
- function closeGallery() {
359
- setModal(false)
360
- }
361
-
362
- // Click outside gallery content to close - with debounce to prevent accidental closes
363
- const handleBackdropClick = useDebounceFn((event: MouseEvent) => {
364
- if (event.target === event.currentTarget) {
365
- setModal(false)
366
- }
367
- }, 200)
368
-
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)
402
-
403
- onMounted(() => {
404
- eventBus.on(`${props.id}GalleryImage`, openGalleryImage)
405
- eventBus.on(`${props.id}Gallery`, openGalleryImage)
406
- eventBus.on(`${props.id}GalleryClose`, closeGallery)
407
-
408
- // Store reference to the gallery container
409
- galleryContainerRef.value = document.querySelector('.gallery-container')
410
-
411
- // Initialize info height once mounted (only if info panel is shown)
412
- if (infoPanel.value) {
413
- updateInfoHeight()
414
- }
415
-
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
- })
424
- }
425
-
426
- // Listen for window resize events
427
- useEventListener(window, 'resize', handleWindowResize)
428
-
429
- // Listen for fullscreen changes to update image sizes
430
- 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
- }
439
- })
440
- })
441
-
442
- onUnmounted(() => {
443
- eventBus.off(`${props.id}Gallery`, openGalleryImage)
444
- eventBus.off(`${props.id}GalleryImage`, openGalleryImage)
445
- eventBus.off(`${props.id}GalleryClose`, closeGallery)
446
-
447
- if (!import.meta.env.SSR) {
448
- document.body.style.overflow = '' // Ensure body scrolling is restored
449
- }
450
-
451
- // Clear any remaining timeouts
452
- if (controlsTimeout) {
453
- clearTimeout(controlsTimeout)
454
- }
455
-
456
- if (fullscreenResizeTimeout) {
457
- clearTimeout(fullscreenResizeTimeout)
458
- }
459
-
460
- // Ensure we exit fullscreen mode on unmount
461
- if (isFullscreen.value) {
462
- exitFullscreen().catch(() => {})
463
- }
464
- })
465
- </script>
466
-
467
- <template>
468
- <div>
469
- <transition
470
- enter-active-class="duration-300 ease-out"
471
- enter-from-class="opacity-0"
472
- enter-to-class="opacity-100"
473
- leave-active-class="duration-200 ease-in"
474
- leave-from-class="opacity-100"
475
- leave-to-class="opacity-0"
476
- >
477
- <div
478
- v-if="isGalleryOpen"
479
- class="fixed bg-fv-neutral-900 text-white inset-0 max-w-[100vw] overflow-hidden gallery-container"
480
- style="z-index: 37"
481
- role="dialog"
482
- aria-modal="true"
483
- @click="handleBackdropClick"
484
- >
485
- <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
489
- >
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 -->
494
- <div
495
- class="flex h-full relative flex-grow items-center justify-center gap-2 z-[1]"
496
- @touchstart="touchStart"
497
- @touchend="touchEnd"
498
- >
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"
507
- >
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>
521
-
522
- <!-- Main Image Container -->
523
- <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;"
526
- >
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]"
534
- >
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>
604
- </div>
605
-
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"
614
- >
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>
628
- </div>
629
- </div>
630
-
631
- <!-- Side Panel for Thumbnails -->
632
- <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"
639
- >
640
- <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;"
644
- >
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>
695
- </div>
696
- </transition>
697
- </div>
698
-
699
- <!-- Top Controls -->
700
- <transition
701
- enter-active-class="transition-opacity duration-300"
702
- enter-from-class="opacity-0"
703
- enter-to-class="opacity-100"
704
- leave-active-class="transition-opacity duration-300"
705
- leave-from-class="opacity-100"
706
- leave-to-class="opacity-0"
707
- >
708
- <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"
711
- >
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>
716
- </div>
717
-
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)"
751
- >
752
- <component :is="closeIcon" class="w-5 h-5" />
753
- </button>
754
- </div>
755
- </div>
756
- </transition>
757
-
758
- <!-- Mobile Thumbnail Preview -->
759
- <transition
760
- enter-active-class="transition-transform duration-300 ease-out"
761
- enter-from-class="translate-y-full"
762
- enter-to-class="translate-y-0"
763
- leave-active-class="transition-transform duration-300 ease-in"
764
- leave-from-class="translate-y-0"
765
- leave-to-class="translate-y-full"
766
- >
767
- <div
768
- 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]"
770
- >
771
- <div class="overflow-x-auto flex space-x-2 pb-1 px-1">
772
- <div
773
- v-for="(image, idx) in images"
774
- :key="`mobile_thumb_${id}_${idx}`"
775
- class="flex-shrink-0 w-16 h-16 rounded-lg relative cursor-pointer"
776
- :class="{ 'ring-2 ring-fv-primary-500 ring-offset-1 ring-offset-fv-neutral-900': idx === modelValue }"
777
- @click="$eventBus.emit(`${id}GalleryImage`, idx)"
778
- >
779
- <img
780
- v-if="imageComponent === 'img'"
781
- class="w-full h-full object-cover rounded-lg transition duration-200"
782
- :style="{
783
- filter: idx === modelValue ? 'brightness(1)' : 'brightness(0.7)',
784
- }"
785
- :src="getThumbnailUrl(image)"
786
- :alt="`Thumbnail ${idx + 1}`"
787
- >
788
- <component
789
- :is="imageComponent"
790
- v-else
791
- :image="getThumbnailUrl(image).image"
792
- :variant="getThumbnailUrl(image).variant"
793
- :alt="getThumbnailUrl(image).alt"
794
- class="w-full h-full object-cover rounded-lg transition duration-200"
795
- :style="{
796
- filter: idx === modelValue ? 'brightness(1)' : 'brightness(0.7)',
797
- }"
798
- />
799
- </div>
800
- </div>
801
- </div>
802
- </transition>
803
- </div>
804
- </div>
805
- </transition>
806
-
807
- <!-- Thumbnail Grid/Mason/Custom Layouts -->
808
- <div v-if="mode === 'grid' || mode === 'mason' || mode === 'custom'" class="gallery-grid">
809
- <div
810
- :class="{
811
- 'masonry-grid': mode === 'mason',
812
- 'standard-grid': mode === 'grid',
813
- 'custom-grid': mode === 'custom',
814
- }"
815
- >
816
- <slot name="thumbnail" />
817
- <template v-for="i in images.length" :key="`g_${id}_${i}`">
818
- <template v-if="mode === 'mason'">
819
- <div
820
- v-if="i + (1 % gridHeight) === 0"
821
- class="masonry-column relative"
822
- >
823
- <div v-if="ranking" class="img-gallery-ranking">
824
- {{ i }}
825
- </div>
826
-
827
- <template v-for="j in gridHeight" :key="`gi_${id}_${i + j}`">
828
- <div class="masonry-item">
829
- <img
830
- v-if="i + j - 2 < images.length && imageComponent === 'img'"
831
- 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]"
832
- :src="getThumbnailUrl(images[i + j - 2])"
833
- :alt="`Gallery image ${i + j - 1}`"
834
- @click="$eventBus.emit(`${id}GalleryImage`, i + j - 2)"
835
- >
836
- <component
837
- :is="imageComponent"
838
- v-else-if="i + j - 2 < images.length"
839
- :image="getThumbnailUrl(images[i + j - 2]).image"
840
- :variant="getThumbnailUrl(images[i + j - 2]).variant"
841
- :alt="getThumbnailUrl(images[i + j - 2]).alt"
842
- :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(
843
- images[i + j - 2],
844
- )}`"
845
- :likes="getThumbnailUrl(images[i + j - 2]).likes"
846
- :show-likes="getThumbnailUrl(images[i + j - 2]).showLikes"
847
- :is-author="getThumbnailUrl(images[i + j - 2]).isAuthor"
848
- :user-uuid="getThumbnailUrl(images[i + j - 2]).userUUID"
849
- @click="$eventBus.emit(`${id}GalleryImage`, i + j - 2)"
850
- />
851
- </div>
852
- </template>
853
- </div>
854
- </template>
855
- <div v-else class="grid-item relative group">
856
- <div v-if="ranking" class="img-gallery-ranking">
857
- {{ i }}
858
- </div>
859
- <div class="overflow-hidden rounded-lg">
860
- <img
861
- v-if="imageComponent === 'img'"
862
- 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]"
863
- :src="getThumbnailUrl(images[i - 1])"
864
- :alt="`Gallery image ${i}`"
865
- @click="$eventBus.emit(`${id}GalleryImage`, i - 1)"
866
- >
867
- <component
868
- :is="imageComponent"
869
- v-else-if="imageComponent"
870
- :image="getThumbnailUrl(images[i - 1]).image"
871
- :variant="getThumbnailUrl(images[i - 1]).variant"
872
- :alt="getThumbnailUrl(images[i - 1]).alt"
873
- :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(
874
- images[i - 1],
875
- )}`"
876
- :likes="getThumbnailUrl(images[i - 1]).likes"
877
- :show-likes="getThumbnailUrl(images[i - 1]).showLikes"
878
- :is-author="getThumbnailUrl(images[i - 1]).isAuthor"
879
- :user-uuid="getThumbnailUrl(images[i - 1]).userUUID"
880
- @click="$eventBus.emit(`${id}GalleryImage`, i - 1)"
881
- />
882
- </div>
883
- </div>
884
- </template>
885
- </div>
886
- </div>
887
-
888
- <!-- Button Mode -->
889
- <button
890
- v-if="mode === 'button'"
891
- :class="`btn ${buttonType ? buttonType : 'primary'} defaults relative overflow-hidden group`"
892
- @click="openGalleryImage(0)"
893
- >
894
- <span class="relative z-10">{{ buttonText ? buttonText : $t("open_gallery_cta") }}</span>
895
- <span class="absolute inset-0 bg-white/10 transform -translate-x-full group-hover:translate-x-0 transition-transform duration-300" />
896
- </button>
897
- </div>
898
- </template>
899
-
900
- <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;
908
- }
909
-
910
- /* Transition styles for next (right) navigation */
911
- .slide-next-enter-active,
912
- .slide-next-leave-active {
913
- transition:
914
- opacity 0.15s,
915
- transform 0.15s,
916
- filter 0.15s;
917
- }
918
-
919
- .slide-next-enter-from {
920
- opacity: 0;
921
- transform: translateX(100%);
922
- filter: blur(10px);
923
- }
924
-
925
- .slide-next-enter-to {
926
- opacity: 1;
927
- transform: translateX(0);
928
- filter: blur(0);
929
- }
930
-
931
- .slide-next-leave-from {
932
- opacity: 1;
933
- transform: translateX(0);
934
- filter: blur(0);
935
- }
936
-
937
- .slide-next-leave-to {
938
- opacity: 0;
939
- transform: translateX(-100%);
940
- filter: blur(10px);
941
- }
942
-
943
- /* Transition styles for prev (left) navigation */
944
- .slide-prev-enter-active,
945
- .slide-prev-leave-active {
946
- transition:
947
- opacity 0.15s,
948
- transform 0.15s,
949
- filter 0.15s;
950
- }
951
-
952
- .slide-prev-enter-from {
953
- opacity: 0;
954
- transform: translateX(-100%);
955
- filter: blur(10px);
956
- }
957
-
958
- .slide-prev-enter-to {
959
- opacity: 1;
960
- transform: translateX(0);
961
- filter: blur(0);
962
- }
963
-
964
- .slide-prev-leave-from {
965
- opacity: 1;
966
- transform: translateX(0);
967
- filter: blur(0);
968
- }
969
-
970
- .slide-prev-leave-to {
971
- opacity: 0;
972
- transform: translateX(100%);
973
- filter: blur(10px);
974
- }
975
-
976
- /* Modern grids */
977
- .gallery-grid {
978
- min-height: 200px;
979
- }
980
-
981
- .standard-grid {
982
- display: grid;
983
- grid-template-columns: repeat(1, 1fr);
984
- gap: 0.75rem;
985
- }
986
-
987
- @media (min-width: 480px) {
988
- .standard-grid {
989
- grid-template-columns: repeat(2, 1fr);
990
- }
991
- }
992
-
993
- @media (min-width: 768px) {
994
- .standard-grid {
995
- grid-template-columns: repeat(3, 1fr);
996
- gap: 1rem;
997
- }
998
- }
999
-
1000
- @media (min-width: 1024px) {
1001
- .standard-grid {
1002
- grid-template-columns: repeat(4, 1fr);
1003
- }
1004
- }
1005
-
1006
- @media (min-width: 1280px) {
1007
- .standard-grid {
1008
- grid-template-columns: repeat(5, 1fr);
1009
- }
1010
- }
1011
-
1012
- @media (min-width: 1536px) {
1013
- .standard-grid {
1014
- grid-template-columns: repeat(6, 1fr);
1015
- }
1016
- }
1017
-
1018
- .masonry-grid {
1019
- display: grid;
1020
- grid-template-columns: repeat(1, 1fr);
1021
- gap: 0.75rem;
1022
- }
1023
-
1024
- @media (min-width: 480px) {
1025
- .masonry-grid {
1026
- grid-template-columns: repeat(2, 1fr);
1027
- }
1028
- }
1029
-
1030
- @media (min-width: 768px) {
1031
- .masonry-grid {
1032
- grid-template-columns: repeat(3, 1fr);
1033
- gap: 1rem;
1034
- }
1035
- }
1036
-
1037
- @media (min-width: 1024px) {
1038
- .masonry-grid {
1039
- grid-template-columns: repeat(4, 1fr);
1040
- }
1041
- }
1042
-
1043
- .masonry-column {
1044
- display: grid;
1045
- gap: 0.75rem;
1046
- }
1047
-
1048
- .masonry-item {
1049
- break-inside: avoid;
1050
- margin-bottom: 0.75rem;
1051
- }
1052
-
1053
- .grid-item {
1054
- break-inside: avoid;
1055
- margin-bottom: 0.75rem;
1056
- }
1057
-
1058
- .img-gallery-ranking {
1059
- position: absolute;
1060
- top: 0.5rem;
1061
- left: 0.5rem;
1062
- background-color: rgba(0, 0, 0, 0.6);
1063
- color: white;
1064
- width: 1.5rem;
1065
- height: 1.5rem;
1066
- display: flex;
1067
- align-items: center;
1068
- justify-content: center;
1069
- border-radius: 9999px;
1070
- font-size: 0.75rem;
1071
- z-index: 10;
1072
- }
1073
- </style>