@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.
- package/components/ui/DefaultDropdown.vue +15 -3
- package/components/ui/DefaultGallery.vue +695 -167
- package/components/ui/DefaultModal.vue +137 -35
- package/package.json +1 -1
|
@@ -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
|
|
120
|
-
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
|
+
}
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
128
|
-
|
|
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
|
-
|
|
132
|
-
|
|
331
|
+
// Reset pinch zoom state
|
|
332
|
+
if (isPinching.value) {
|
|
333
|
+
isPinching.value = false
|
|
334
|
+
return
|
|
335
|
+
}
|
|
133
336
|
|
|
134
|
-
//
|
|
135
|
-
if (
|
|
136
|
-
|
|
337
|
+
// Reset panning state
|
|
338
|
+
if (isPanning.value) {
|
|
339
|
+
isPanning.value = false
|
|
340
|
+
return
|
|
137
341
|
}
|
|
138
342
|
|
|
139
|
-
|
|
343
|
+
// If we're zoomed in, don't handle swiping between images
|
|
344
|
+
if (scale.value > 1) {
|
|
345
|
+
return
|
|
346
|
+
}
|
|
140
347
|
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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 (
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
<
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
enter="
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
leave="
|
|
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
|
-
<
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
501
|
+
role="dialog"
|
|
502
|
+
aria-modal="true"
|
|
503
|
+
@click="handleBackdropClick"
|
|
232
504
|
>
|
|
233
|
-
<
|
|
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-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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-
|
|
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="
|
|
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
|
-
|
|
256
|
-
|
|
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-
|
|
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
|
-
<
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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-
|
|
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="
|
|
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
|
-
|
|
331
|
-
|
|
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-
|
|
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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
enter="
|
|
343
|
-
enter-
|
|
344
|
-
|
|
345
|
-
leave="
|
|
346
|
-
leave-
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
<
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
</
|
|
386
|
-
</
|
|
387
|
-
</
|
|
388
|
-
</
|
|
389
|
-
</
|
|
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
|
-
'
|
|
844
|
+
'grid grid-cols-2 md:grid-cols-4 xl:grid-cols-6 gap-4 items-start':
|
|
395
845
|
mode === 'mason',
|
|
396
|
-
'
|
|
846
|
+
'grid grid-cols-2 md:grid-cols-4 xl:grid-cols-6 gap-4 items-center':
|
|
397
847
|
mode === 'grid',
|
|
398
|
-
'
|
|
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.
|
|
484
|
-
transform 0.
|
|
485
|
-
filter 0.
|
|
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.
|
|
517
|
-
transform 0.
|
|
518
|
-
filter 0.
|
|
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
|
-
/*
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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
|
-
.
|
|
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>
|