@fy-/fws-vue 2.2.46 → 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.
- package/components/ui/DefaultGallery.vue +631 -133
- package/components/ui/DefaultModal.vue +45 -6
- package/package.json +1 -1
|
@@ -4,6 +4,7 @@ import type { APIPaging } from '../../composables/rest'
|
|
|
4
4
|
import {
|
|
5
5
|
ArrowLeftCircleIcon,
|
|
6
6
|
ArrowRightCircleIcon,
|
|
7
|
+
ArrowsPointingOutIcon, // For fullscreen button
|
|
7
8
|
ChevronDoubleLeftIcon,
|
|
8
9
|
ChevronDoubleRightIcon,
|
|
9
10
|
XCircleIcon,
|
|
@@ -15,6 +16,20 @@ import DefaultPaging from './DefaultPaging.vue'
|
|
|
15
16
|
const isGalleryOpen = ref<boolean>(false)
|
|
16
17
|
const eventBus = useEventBus()
|
|
17
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)
|
|
18
33
|
|
|
19
34
|
const props = withDefaults(
|
|
20
35
|
defineProps<{
|
|
@@ -64,6 +79,47 @@ const modelValue = computed({
|
|
|
64
79
|
})
|
|
65
80
|
|
|
66
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
|
+
}
|
|
67
123
|
|
|
68
124
|
function setModal(value: boolean) {
|
|
69
125
|
if (value === true) {
|
|
@@ -72,14 +128,39 @@ function setModal(value: boolean) {
|
|
|
72
128
|
if (!import.meta.env.SSR) {
|
|
73
129
|
document.addEventListener('keydown', handleKeyboardInput)
|
|
74
130
|
document.addEventListener('keyup', handleKeyboardRelease)
|
|
131
|
+
document.addEventListener('keydown', handleTabKey)
|
|
75
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)
|
|
76
148
|
}
|
|
77
149
|
else {
|
|
78
150
|
if (props.onClose) props.onClose()
|
|
79
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
|
+
|
|
80
160
|
if (!import.meta.env.SSR) {
|
|
81
161
|
document.removeEventListener('keydown', handleKeyboardInput)
|
|
82
162
|
document.removeEventListener('keyup', handleKeyboardRelease)
|
|
163
|
+
document.removeEventListener('keydown', handleTabKey)
|
|
83
164
|
}
|
|
84
165
|
}
|
|
85
166
|
isGalleryOpen.value = value
|
|
@@ -96,7 +177,13 @@ function openGalleryImage(index: number | undefined) {
|
|
|
96
177
|
}
|
|
97
178
|
|
|
98
179
|
function goNextImage() {
|
|
180
|
+
// Reset zoom and pan when navigating
|
|
181
|
+
scale.value = 1
|
|
182
|
+
panPosition.x = 0
|
|
183
|
+
panPosition.y = 0
|
|
184
|
+
|
|
99
185
|
direction.value = 'next'
|
|
186
|
+
isImageLoading.value = true
|
|
100
187
|
if (modelValue.value < props.images.length - 1) {
|
|
101
188
|
modelValue.value++
|
|
102
189
|
}
|
|
@@ -106,7 +193,13 @@ function goNextImage() {
|
|
|
106
193
|
}
|
|
107
194
|
|
|
108
195
|
function goPrevImage() {
|
|
196
|
+
// Reset zoom and pan when navigating
|
|
197
|
+
scale.value = 1
|
|
198
|
+
panPosition.x = 0
|
|
199
|
+
panPosition.y = 0
|
|
200
|
+
|
|
109
201
|
direction.value = 'prev'
|
|
202
|
+
isImageLoading.value = true
|
|
110
203
|
if (modelValue.value > 0) {
|
|
111
204
|
modelValue.value--
|
|
112
205
|
}
|
|
@@ -124,41 +217,159 @@ const modelValueSrc = computed(() => {
|
|
|
124
217
|
|
|
125
218
|
const start = reactive({ x: 0, y: 0 })
|
|
126
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
|
+
|
|
127
239
|
function touchStart(event: TouchEvent) {
|
|
128
|
-
const
|
|
129
|
-
const
|
|
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
|
+
}
|
|
130
276
|
|
|
131
|
-
//
|
|
132
|
-
if (
|
|
133
|
-
|
|
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
|
|
134
286
|
}
|
|
135
287
|
|
|
136
|
-
|
|
137
|
-
|
|
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
|
+
}
|
|
138
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
|
|
308
|
+
|
|
309
|
+
// Limit zoom range
|
|
310
|
+
scale.value = Math.min(Math.max(0.5, newScale), 5)
|
|
311
|
+
event.preventDefault()
|
|
312
|
+
return
|
|
313
|
+
}
|
|
314
|
+
|
|
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
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
139
330
|
function touchEnd(event: TouchEvent) {
|
|
140
|
-
|
|
141
|
-
|
|
331
|
+
// Reset pinch zoom state
|
|
332
|
+
if (isPinching.value) {
|
|
333
|
+
isPinching.value = false
|
|
334
|
+
return
|
|
335
|
+
}
|
|
142
336
|
|
|
143
|
-
//
|
|
144
|
-
if (
|
|
145
|
-
|
|
337
|
+
// Reset panning state
|
|
338
|
+
if (isPanning.value) {
|
|
339
|
+
isPanning.value = false
|
|
340
|
+
return
|
|
146
341
|
}
|
|
147
342
|
|
|
148
|
-
|
|
343
|
+
// If we're zoomed in, don't handle swiping between images
|
|
344
|
+
if (scale.value > 1) {
|
|
345
|
+
return
|
|
346
|
+
}
|
|
149
347
|
|
|
150
|
-
|
|
151
|
-
|
|
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
|
|
152
352
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
direction.value = 'next'
|
|
157
|
-
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
|
|
158
356
|
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
+
}
|
|
162
373
|
}
|
|
163
374
|
}
|
|
164
375
|
}
|
|
@@ -178,8 +389,25 @@ function handleKeyboardInput(event: KeyboardEvent) {
|
|
|
178
389
|
|
|
179
390
|
switch (event.key) {
|
|
180
391
|
case 'Escape':
|
|
181
|
-
|
|
182
|
-
|
|
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
|
+
}
|
|
183
411
|
break
|
|
184
412
|
case 'ArrowRight':
|
|
185
413
|
isKeyPressed.value = true
|
|
@@ -191,6 +419,9 @@ function handleKeyboardInput(event: KeyboardEvent) {
|
|
|
191
419
|
direction.value = 'prev'
|
|
192
420
|
goPrevImage()
|
|
193
421
|
break
|
|
422
|
+
case 'f':
|
|
423
|
+
toggleFullscreen()
|
|
424
|
+
break
|
|
194
425
|
default:
|
|
195
426
|
break
|
|
196
427
|
}
|
|
@@ -202,6 +433,10 @@ function handleKeyboardRelease(event: KeyboardEvent) {
|
|
|
202
433
|
}
|
|
203
434
|
}
|
|
204
435
|
|
|
436
|
+
function onImageLoad() {
|
|
437
|
+
isImageLoading.value = false
|
|
438
|
+
}
|
|
439
|
+
|
|
205
440
|
function closeGallery() {
|
|
206
441
|
setModal(false)
|
|
207
442
|
}
|
|
@@ -213,6 +448,21 @@ function handleBackdropClick(event: MouseEvent) {
|
|
|
213
448
|
}
|
|
214
449
|
}
|
|
215
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
|
+
|
|
216
466
|
onMounted(() => {
|
|
217
467
|
eventBus.on(`${props.id}GalleryImage`, openGalleryImage)
|
|
218
468
|
eventBus.on(`${props.id}Gallery`, openGalleryImage)
|
|
@@ -226,6 +476,7 @@ onUnmounted(() => {
|
|
|
226
476
|
if (!import.meta.env.SSR) {
|
|
227
477
|
document.removeEventListener('keydown', handleKeyboardInput)
|
|
228
478
|
document.removeEventListener('keyup', handleKeyboardRelease)
|
|
479
|
+
document.removeEventListener('keydown', handleTabKey)
|
|
229
480
|
document.body.style.overflow = '' // Ensure body scrolling is restored
|
|
230
481
|
}
|
|
231
482
|
})
|
|
@@ -243,58 +494,100 @@ onUnmounted(() => {
|
|
|
243
494
|
>
|
|
244
495
|
<div
|
|
245
496
|
v-if="isGalleryOpen"
|
|
246
|
-
|
|
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 }"
|
|
247
500
|
style="z-index: 37"
|
|
248
501
|
role="dialog"
|
|
249
502
|
aria-modal="true"
|
|
250
503
|
@click="handleBackdropClick"
|
|
251
504
|
>
|
|
252
505
|
<div
|
|
253
|
-
class="relative w-full max-w-full flex flex-col justify-center items-center"
|
|
506
|
+
class="relative w-full max-w-full flex flex-col justify-center items-center h-full"
|
|
254
507
|
style="z-index: 38"
|
|
255
508
|
@click.stop
|
|
256
509
|
>
|
|
257
|
-
<div class="flex flex-grow gap-4 w-full max-w-full">
|
|
258
|
-
<div class="flex-grow h-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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>
|
|
267
549
|
|
|
268
550
|
<div
|
|
269
|
-
class="flex h-
|
|
551
|
+
class="flex h-full relative flex-grow items-center justify-center gap-2 z-[1]"
|
|
270
552
|
>
|
|
553
|
+
<!-- Left nav button (larger touch area on mobile) -->
|
|
271
554
|
<div
|
|
272
|
-
class="
|
|
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()"
|
|
273
557
|
>
|
|
274
558
|
<button
|
|
275
559
|
v-if="images.length > 1"
|
|
276
|
-
class="btn p-1 rounded-full"
|
|
277
560
|
aria-label="Previous image"
|
|
278
|
-
|
|
561
|
+
class="rounded-full p-2 bg-black/40 backdrop-blur-sm hover:bg-black/60 transition-colors hidden sm:block"
|
|
562
|
+
@click.stop
|
|
279
563
|
>
|
|
280
|
-
<ArrowLeftCircleIcon class="w-
|
|
564
|
+
<ArrowLeftCircleIcon class="w-6 h-6" />
|
|
281
565
|
</button>
|
|
282
566
|
</div>
|
|
567
|
+
|
|
283
568
|
<div
|
|
284
569
|
class="flex-1 flex flex-col z-[2] items-center justify-center max-w-full lg:max-w-[calc(100vw - 256px)] relative"
|
|
285
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
|
+
|
|
286
579
|
<transition
|
|
287
580
|
:name="direction === 'next' ? 'slide-next' : 'slide-prev'"
|
|
288
581
|
mode="out-in"
|
|
289
582
|
>
|
|
290
583
|
<div
|
|
291
|
-
v-if="true"
|
|
292
584
|
:key="`image-display-${modelValue}`"
|
|
293
585
|
class="flex-1 w-full max-w-full flex flex-col items-center justify-center absolute inset-0 z-[2]"
|
|
294
586
|
>
|
|
295
587
|
<div
|
|
296
|
-
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"
|
|
297
589
|
@touchstart="touchStart"
|
|
590
|
+
@touchmove="touchMove"
|
|
298
591
|
@touchend="touchEnd"
|
|
299
592
|
>
|
|
300
593
|
<template
|
|
@@ -304,63 +597,89 @@ onUnmounted(() => {
|
|
|
304
597
|
<component
|
|
305
598
|
:is="videoComponent"
|
|
306
599
|
:src="isVideo(images[modelValue])"
|
|
307
|
-
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]"
|
|
308
601
|
/>
|
|
309
602
|
</ClientOnly>
|
|
310
603
|
</template>
|
|
311
604
|
<template v-else>
|
|
312
|
-
<
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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>
|
|
326
634
|
</template>
|
|
327
635
|
</div>
|
|
636
|
+
|
|
637
|
+
<!-- Mobile zoom hint -->
|
|
328
638
|
<div
|
|
329
|
-
|
|
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
|
+
|
|
645
|
+
<div
|
|
646
|
+
class="flex-0 py-4 flex items-center justify-center max-w-full w-full relative !z-[3]"
|
|
330
647
|
>
|
|
331
648
|
<slot :value="images[modelValue]" />
|
|
332
649
|
</div>
|
|
333
650
|
</div>
|
|
334
651
|
</transition>
|
|
335
652
|
</div>
|
|
653
|
+
|
|
654
|
+
<!-- Right nav button (larger touch area on mobile) -->
|
|
336
655
|
<div
|
|
337
|
-
class="
|
|
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()"
|
|
338
658
|
>
|
|
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
659
|
<button
|
|
353
660
|
v-if="images.length > 1"
|
|
354
|
-
class="btn p-1 rounded-full"
|
|
355
661
|
aria-label="Next image"
|
|
356
|
-
|
|
662
|
+
class="rounded-full p-2 bg-black/40 backdrop-blur-sm hover:bg-black/60 transition-colors hidden sm:block"
|
|
663
|
+
@click.stop
|
|
357
664
|
>
|
|
358
|
-
<ArrowRightCircleIcon class="w-
|
|
665
|
+
<ArrowRightCircleIcon class="w-6 h-6" />
|
|
359
666
|
</button>
|
|
360
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>
|
|
361
679
|
</div>
|
|
362
680
|
</div>
|
|
363
681
|
|
|
682
|
+
<!-- Sidebar with thumbnails (desktop) -->
|
|
364
683
|
<transition
|
|
365
684
|
enter-active-class="transform transition ease-in-out duration-300"
|
|
366
685
|
enter-from-class="translate-x-full"
|
|
@@ -371,59 +690,162 @@ onUnmounted(() => {
|
|
|
371
690
|
>
|
|
372
691
|
<div
|
|
373
692
|
v-if="sidePanel"
|
|
374
|
-
class="hidden lg:block flex-shrink-0 w-64 bg-fv-neutral-800 h-
|
|
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"
|
|
375
694
|
>
|
|
376
|
-
<!--
|
|
377
|
-
<div
|
|
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">
|
|
378
704
|
<DefaultPaging :id="id" :items="paging" />
|
|
379
705
|
</div>
|
|
380
|
-
|
|
706
|
+
|
|
707
|
+
<!-- Thumbnails grid -->
|
|
708
|
+
<div class="grid grid-cols-2 gap-2 p-4">
|
|
381
709
|
<div
|
|
382
710
|
v-for="i in images.length"
|
|
383
711
|
:key="`bg_${id}_${i}`"
|
|
384
|
-
class="
|
|
385
|
-
:style="{
|
|
386
|
-
filter:
|
|
387
|
-
i - 1 === modelValue ? 'brightness(1)' : 'brightness(0.5)',
|
|
388
|
-
}"
|
|
712
|
+
class="relative group"
|
|
389
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>
|
|
744
|
+
</div>
|
|
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"
|
|
805
|
+
>
|
|
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 }"
|
|
820
|
+
>
|
|
821
|
+
<div
|
|
822
|
+
v-if="i - 1 === modelValue"
|
|
823
|
+
class="absolute inset-0 border-2 border-white rounded-lg z-10"
|
|
824
|
+
/>
|
|
390
825
|
<img
|
|
391
|
-
|
|
392
|
-
:class="`h-auto max-w-full rounded-lg cursor-pointer shadow ${getBorderColor(
|
|
393
|
-
images[i - 1],
|
|
394
|
-
)}`"
|
|
826
|
+
class="h-full w-full object-cover rounded-lg"
|
|
395
827
|
:src="getThumbnailUrl(images[i - 1])"
|
|
396
828
|
:alt="`Thumbnail ${i}`"
|
|
397
|
-
@click="$eventBus.emit(`${id}GalleryImage`, i - 1)"
|
|
829
|
+
@click="$eventBus.emit(`${id}GalleryImage`, i - 1); showMobileThumbnails = false"
|
|
398
830
|
>
|
|
399
|
-
<component
|
|
400
|
-
:is="imageComponent"
|
|
401
|
-
v-else
|
|
402
|
-
:image="getThumbnailUrl(images[i - 1]).image"
|
|
403
|
-
:variant="getThumbnailUrl(images[i - 1]).variant"
|
|
404
|
-
:alt="getThumbnailUrl(images[i - 1]).alt"
|
|
405
|
-
:class="`h-auto max-w-full rounded-lg cursor-pointer shadow ${getBorderColor(
|
|
406
|
-
images[i - 1],
|
|
407
|
-
)}`"
|
|
408
|
-
@click="$eventBus.emit(`${id}GalleryImage`, i - 1)"
|
|
409
|
-
/>
|
|
410
831
|
</div>
|
|
411
832
|
</div>
|
|
412
833
|
</div>
|
|
413
|
-
</
|
|
414
|
-
</
|
|
834
|
+
</div>
|
|
835
|
+
</transition>
|
|
415
836
|
</div>
|
|
416
837
|
</div>
|
|
417
838
|
</transition>
|
|
418
839
|
|
|
840
|
+
<!-- Grid view mode -->
|
|
419
841
|
<div v-if="mode === 'grid' || mode === 'mason' || mode === 'custom'" class="min-h-[600px]">
|
|
420
842
|
<div
|
|
421
843
|
:class="{
|
|
422
|
-
'
|
|
844
|
+
'grid grid-cols-2 md:grid-cols-4 xl:grid-cols-6 gap-4 items-start':
|
|
423
845
|
mode === 'mason',
|
|
424
|
-
'
|
|
846
|
+
'grid grid-cols-2 md:grid-cols-4 xl:grid-cols-6 gap-4 items-center':
|
|
425
847
|
mode === 'grid',
|
|
426
|
-
'
|
|
848
|
+
'custom-grid': mode === 'custom',
|
|
427
849
|
}"
|
|
428
850
|
>
|
|
429
851
|
<slot name="thumbnail" />
|
|
@@ -438,10 +860,19 @@ onUnmounted(() => {
|
|
|
438
860
|
</div>
|
|
439
861
|
|
|
440
862
|
<template v-for="j in gridHeight" :key="`gi_${id}_${i + j}`">
|
|
441
|
-
<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
|
+
|
|
442
873
|
<img
|
|
443
874
|
v-if="i + j - 2 < images.length && imageComponent === 'img'"
|
|
444
|
-
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"
|
|
445
876
|
:src="getThumbnailUrl(images[i + j - 2])"
|
|
446
877
|
:alt="`Gallery image ${i + j - 1}`"
|
|
447
878
|
@click="$eventBus.emit(`${id}GalleryImage`, i + j - 2)"
|
|
@@ -452,9 +883,7 @@ onUnmounted(() => {
|
|
|
452
883
|
:image="getThumbnailUrl(images[i + j - 2]).image"
|
|
453
884
|
:variant="getThumbnailUrl(images[i + j - 2]).variant"
|
|
454
885
|
:alt="getThumbnailUrl(images[i + j - 2]).alt"
|
|
455
|
-
:class="`h-auto max-w-full rounded-lg cursor-pointer ${getBorderColor(
|
|
456
|
-
images[i + j - 2],
|
|
457
|
-
)}`"
|
|
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])}`"
|
|
458
887
|
:likes="getThumbnailUrl(images[i + j - 2]).likes"
|
|
459
888
|
:show-likes="getThumbnailUrl(images[i + j - 2]).showLikes"
|
|
460
889
|
:is-author="getThumbnailUrl(images[i + j - 2]).isAuthor"
|
|
@@ -465,13 +894,23 @@ onUnmounted(() => {
|
|
|
465
894
|
</template>
|
|
466
895
|
</div>
|
|
467
896
|
</template>
|
|
468
|
-
<div v-else class="relative">
|
|
469
|
-
<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">
|
|
470
899
|
{{ i }}
|
|
471
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
|
+
|
|
472
911
|
<img
|
|
473
912
|
v-if="imageComponent === 'img'"
|
|
474
|
-
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"
|
|
475
914
|
:src="getThumbnailUrl(images[i - 1])"
|
|
476
915
|
:alt="`Gallery image ${i}`"
|
|
477
916
|
@click="$eventBus.emit(`${id}GalleryImage`, i - 1)"
|
|
@@ -482,9 +921,7 @@ onUnmounted(() => {
|
|
|
482
921
|
:image="getThumbnailUrl(images[i - 1]).image"
|
|
483
922
|
:variant="getThumbnailUrl(images[i - 1]).variant"
|
|
484
923
|
:alt="getThumbnailUrl(images[i - 1]).alt"
|
|
485
|
-
:class="`h-auto max-w-full rounded-lg cursor-pointer ${getBorderColor(
|
|
486
|
-
images[i - 1],
|
|
487
|
-
)}`"
|
|
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])}`"
|
|
488
925
|
:likes="getThumbnailUrl(images[i - 1]).likes"
|
|
489
926
|
:show-likes="getThumbnailUrl(images[i - 1]).showLikes"
|
|
490
927
|
:is-author="getThumbnailUrl(images[i - 1]).isAuthor"
|
|
@@ -495,11 +932,16 @@ onUnmounted(() => {
|
|
|
495
932
|
</template>
|
|
496
933
|
</div>
|
|
497
934
|
</div>
|
|
935
|
+
|
|
936
|
+
<!-- Button mode -->
|
|
498
937
|
<button
|
|
499
938
|
v-if="mode === 'button'"
|
|
500
|
-
:class="`btn ${buttonType ? buttonType : 'primary'} defaults`"
|
|
939
|
+
:class="`btn ${buttonType ? buttonType : 'primary'} defaults flex items-center gap-2`"
|
|
501
940
|
@click="openGalleryImage(0)"
|
|
502
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>
|
|
503
945
|
{{ buttonText ? buttonText : $t("open_gallery_cta") }}
|
|
504
946
|
</button>
|
|
505
947
|
</div>
|
|
@@ -510,9 +952,9 @@ onUnmounted(() => {
|
|
|
510
952
|
.slide-next-enter-active,
|
|
511
953
|
.slide-next-leave-active {
|
|
512
954
|
transition:
|
|
513
|
-
opacity 0.
|
|
514
|
-
transform 0.
|
|
515
|
-
filter 0.
|
|
955
|
+
opacity 0.3s,
|
|
956
|
+
transform 0.3s,
|
|
957
|
+
filter 0.3s;
|
|
516
958
|
}
|
|
517
959
|
|
|
518
960
|
.slide-next-enter-from {
|
|
@@ -543,9 +985,9 @@ onUnmounted(() => {
|
|
|
543
985
|
.slide-prev-enter-active,
|
|
544
986
|
.slide-prev-leave-active {
|
|
545
987
|
transition:
|
|
546
|
-
opacity 0.
|
|
547
|
-
transform 0.
|
|
548
|
-
filter 0.
|
|
988
|
+
opacity 0.3s,
|
|
989
|
+
transform 0.3s,
|
|
990
|
+
filter 0.3s;
|
|
549
991
|
}
|
|
550
992
|
|
|
551
993
|
.slide-prev-enter-from {
|
|
@@ -572,18 +1014,74 @@ onUnmounted(() => {
|
|
|
572
1014
|
filter: blur(10px);
|
|
573
1015
|
}
|
|
574
1016
|
|
|
575
|
-
/*
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
1017
|
+
/* Custom animation for loading spinner */
|
|
1018
|
+
@keyframes spin {
|
|
1019
|
+
from { transform: rotate(0deg); }
|
|
1020
|
+
to { transform: rotate(360deg); }
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
.animate-spin {
|
|
1024
|
+
animation: spin 1s linear infinite;
|
|
580
1025
|
}
|
|
581
1026
|
|
|
582
|
-
|
|
1027
|
+
/* Improved gallery ranking */
|
|
1028
|
+
.img-gallery-ranking {
|
|
583
1029
|
position: absolute;
|
|
584
|
-
top: 0;
|
|
585
|
-
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;
|
|
586
1050
|
width: 100%;
|
|
587
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;
|
|
588
1086
|
}
|
|
589
1087
|
</style>
|
|
@@ -3,6 +3,9 @@ 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
|
+
// Static counter for managing z-index across all modals
|
|
7
|
+
let modalCounter = 0
|
|
8
|
+
|
|
6
9
|
const props = withDefaults(
|
|
7
10
|
defineProps<{
|
|
8
11
|
id: string
|
|
@@ -27,6 +30,10 @@ const modalRef = ref<HTMLElement | null>(null)
|
|
|
27
30
|
let previouslyFocusedElement: HTMLElement | null = null
|
|
28
31
|
let focusableElements: HTMLElement[] = []
|
|
29
32
|
|
|
33
|
+
// Dynamic z-index to ensure the most recently opened modal is on top
|
|
34
|
+
const baseZIndex = 100 // Use a higher base z-index to avoid conflicts
|
|
35
|
+
const zIndex = ref<number>(baseZIndex)
|
|
36
|
+
|
|
30
37
|
// Trap focus within modal for accessibility
|
|
31
38
|
function getFocusableElements(element: HTMLElement): HTMLElement[] {
|
|
32
39
|
return Array.from(
|
|
@@ -39,8 +46,16 @@ function getFocusableElements(element: HTMLElement): HTMLElement[] {
|
|
|
39
46
|
}
|
|
40
47
|
|
|
41
48
|
function handleKeyDown(event: KeyboardEvent) {
|
|
49
|
+
// Only handle events for the top-most modal
|
|
42
50
|
if (!isOpen.value) return
|
|
43
51
|
|
|
52
|
+
// Get all active modals
|
|
53
|
+
const activeModals = document.querySelectorAll('[data-modal-active="true"]')
|
|
54
|
+
// If this is not the top-most modal, don't handle keyboard events
|
|
55
|
+
if (activeModals.length > 0 && modalRef.value !== activeModals[activeModals.length - 1]) {
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
|
|
44
59
|
// Close on escape
|
|
45
60
|
if (event.key === 'Escape') {
|
|
46
61
|
event.preventDefault()
|
|
@@ -67,12 +82,28 @@ function setModal(value: boolean) {
|
|
|
67
82
|
if (value === true) {
|
|
68
83
|
if (props.onOpen) props.onOpen()
|
|
69
84
|
previouslyFocusedElement = document.activeElement as HTMLElement
|
|
70
|
-
|
|
85
|
+
|
|
86
|
+
// Set this modal's z-index higher than any existing modal
|
|
87
|
+
modalCounter += 3 // Increase by 3 to handle backdrop, container and content
|
|
88
|
+
zIndex.value = baseZIndex + modalCounter
|
|
89
|
+
|
|
90
|
+
// Only manage body overflow for the first opened modal
|
|
91
|
+
const activeModals = document.querySelectorAll('[data-modal-active="true"]')
|
|
92
|
+
if (activeModals.length === 0) {
|
|
93
|
+
document.body.style.overflow = 'hidden' // Prevent scrolling when modal is open
|
|
94
|
+
}
|
|
95
|
+
|
|
71
96
|
document.addEventListener('keydown', handleKeyDown)
|
|
72
97
|
}
|
|
73
98
|
if (value === false) {
|
|
74
99
|
if (props.onClose) props.onClose()
|
|
75
|
-
|
|
100
|
+
|
|
101
|
+
// Only restore body overflow if this is the last open modal
|
|
102
|
+
const activeModals = document.querySelectorAll('[data-modal-active="true"]')
|
|
103
|
+
if (activeModals.length <= 1) {
|
|
104
|
+
document.body.style.overflow = '' // Restore scrolling
|
|
105
|
+
}
|
|
106
|
+
|
|
76
107
|
document.removeEventListener('keydown', handleKeyDown)
|
|
77
108
|
if (previouslyFocusedElement) {
|
|
78
109
|
previouslyFocusedElement.focus()
|
|
@@ -111,7 +142,14 @@ onMounted(() => {
|
|
|
111
142
|
onUnmounted(() => {
|
|
112
143
|
eventBus.off(`${props.id}Modal`, setModal)
|
|
113
144
|
document.removeEventListener('keydown', handleKeyDown)
|
|
114
|
-
|
|
145
|
+
|
|
146
|
+
// Only restore body overflow if this modal was open when unmounted
|
|
147
|
+
if (isOpen.value) {
|
|
148
|
+
const activeModals = document.querySelectorAll('[data-modal-active="true"]')
|
|
149
|
+
if (activeModals.length <= 1) {
|
|
150
|
+
document.body.style.overflow = '' // Restore scrolling
|
|
151
|
+
}
|
|
152
|
+
}
|
|
115
153
|
})
|
|
116
154
|
|
|
117
155
|
// Click outside to close
|
|
@@ -135,22 +173,23 @@ function handleBackdropClick(event: MouseEvent) {
|
|
|
135
173
|
<div
|
|
136
174
|
v-if="isOpen"
|
|
137
175
|
class="fixed inset-0 overflow-y-auto"
|
|
138
|
-
style="
|
|
176
|
+
:style="{ zIndex }"
|
|
139
177
|
role="dialog"
|
|
140
178
|
:aria-labelledby="title ? `${props.id}-title` : undefined"
|
|
141
179
|
aria-modal="true"
|
|
180
|
+
data-modal-active="true"
|
|
142
181
|
>
|
|
143
182
|
<!-- Backdrop with click to close functionality -->
|
|
144
183
|
<div
|
|
145
184
|
class="flex absolute backdrop-blur-[8px] inset-0 flex-col items-center justify-center min-h-screen text-fv-neutral-800 dark:text-fv-neutral-300 bg-fv-neutral-900/[.20] dark:bg-fv-neutral-50/[.20]"
|
|
146
|
-
style="
|
|
185
|
+
:style="{ zIndex: zIndex + 1 }"
|
|
147
186
|
@click="handleBackdropClick"
|
|
148
187
|
>
|
|
149
188
|
<!-- Modal panel -->
|
|
150
189
|
<div
|
|
151
190
|
ref="modalRef"
|
|
152
191
|
:class="`relative ${mSize} max-w-6xl max-h-full ${ofy} bg-white rounded-lg shadow dark:bg-fv-neutral-900`"
|
|
153
|
-
style="
|
|
192
|
+
:style="{ zIndex: zIndex + 2 }"
|
|
154
193
|
tabindex="-1"
|
|
155
194
|
@click.stop
|
|
156
195
|
>
|