@fy-/fws-vue-core 3.0.4 → 3.0.5

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.
Files changed (121) hide show
  1. package/package.json +6 -8
  2. package/src/components/fws/CmsArticleBoxed.vue +247 -0
  3. package/src/components/fws/CmsArticleSingle.vue +201 -0
  4. package/src/components/fws/DataTable.vue +659 -0
  5. package/src/components/fws/FilterData.vue +423 -0
  6. package/src/components/fws/UserData.vue +220 -0
  7. package/src/components/fws/UserFlow.vue +955 -0
  8. package/src/components/fws/UserOAuth2.vue +521 -0
  9. package/src/components/fws/UserProfile.vue +615 -0
  10. package/src/components/fws/UserProfileStrict.vue +233 -0
  11. package/src/components/ssr/ClientOnly.ts +10 -0
  12. package/src/components/ui/DefaultBreadcrumb.vue +99 -0
  13. package/src/components/ui/DefaultConfirm.vue +178 -0
  14. package/src/components/ui/DefaultConfirmWithInput.vue +217 -0
  15. package/src/components/ui/DefaultDropdown.vue +104 -0
  16. package/src/components/ui/DefaultDropdownLink.vue +94 -0
  17. package/src/components/ui/DefaultGallery.vue +1056 -0
  18. package/src/components/ui/DefaultInput.vue +768 -0
  19. package/src/components/ui/DefaultLoader.vue +125 -0
  20. package/src/components/ui/DefaultModal.vue +350 -0
  21. package/src/components/ui/DefaultNotif.vue +332 -0
  22. package/src/components/ui/DefaultPaging.vue +395 -0
  23. package/src/components/ui/DefaultSidebar.vue +267 -0
  24. package/src/components/ui/DefaultTagInput.vue +415 -0
  25. package/src/components/ui/transitions/CollapseTransition.vue +19 -0
  26. package/src/components/ui/transitions/ExpandTransition.vue +19 -0
  27. package/src/components/ui/transitions/FadeTransition.vue +17 -0
  28. package/src/components/ui/transitions/ScaleTransition.vue +21 -0
  29. package/src/components/ui/transitions/SlideTransition.vue +32 -0
  30. package/src/composables/event-bus.ts +15 -0
  31. package/src/composables/rest.ts +165 -0
  32. package/src/composables/seo.ts +142 -0
  33. package/src/composables/ssr.ts +103 -0
  34. package/src/composables/templating.ts +133 -0
  35. package/src/composables/translations.ts +45 -0
  36. package/src/env.d.ts +10 -0
  37. package/{dist/src/index.d.ts → src/index.ts} +71 -45
  38. package/src/plugin.ts +42 -0
  39. package/src/safelist.html +11 -0
  40. package/src/stores/serverRouter.ts +62 -0
  41. package/src/stores/user.ts +118 -0
  42. package/src/types.ts +58 -0
  43. package/dist/index.css +0 -2
  44. package/dist/index.js +0 -5767
  45. package/dist/src/components/fws/CmsArticleBoxed.vue.d.ts +0 -32
  46. package/dist/src/components/fws/CmsArticleBoxed.vue.d.ts.map +0 -1
  47. package/dist/src/components/fws/CmsArticleSingle.vue.d.ts +0 -29
  48. package/dist/src/components/fws/CmsArticleSingle.vue.d.ts.map +0 -1
  49. package/dist/src/components/fws/DataTable.vue.d.ts +0 -52
  50. package/dist/src/components/fws/DataTable.vue.d.ts.map +0 -1
  51. package/dist/src/components/fws/FilterData.vue.d.ts +0 -15
  52. package/dist/src/components/fws/FilterData.vue.d.ts.map +0 -1
  53. package/dist/src/components/fws/UserData.vue.d.ts +0 -8
  54. package/dist/src/components/fws/UserData.vue.d.ts.map +0 -1
  55. package/dist/src/components/fws/UserFlow.vue.d.ts +0 -116
  56. package/dist/src/components/fws/UserFlow.vue.d.ts.map +0 -1
  57. package/dist/src/components/fws/UserOAuth2.vue.d.ts +0 -17
  58. package/dist/src/components/fws/UserOAuth2.vue.d.ts.map +0 -1
  59. package/dist/src/components/fws/UserProfile.vue.d.ts +0 -40
  60. package/dist/src/components/fws/UserProfile.vue.d.ts.map +0 -1
  61. package/dist/src/components/fws/UserProfileStrict.vue.d.ts +0 -12
  62. package/dist/src/components/fws/UserProfileStrict.vue.d.ts.map +0 -1
  63. package/dist/src/components/ssr/ClientOnly.d.ts +0 -4
  64. package/dist/src/components/ssr/ClientOnly.d.ts.map +0 -1
  65. package/dist/src/components/ui/DefaultBreadcrumb.vue.d.ts +0 -11
  66. package/dist/src/components/ui/DefaultBreadcrumb.vue.d.ts.map +0 -1
  67. package/dist/src/components/ui/DefaultConfirm.vue.d.ts +0 -81
  68. package/dist/src/components/ui/DefaultConfirm.vue.d.ts.map +0 -1
  69. package/dist/src/components/ui/DefaultConfirmWithInput.vue.d.ts +0 -81
  70. package/dist/src/components/ui/DefaultConfirmWithInput.vue.d.ts.map +0 -1
  71. package/dist/src/components/ui/DefaultDropdown.vue.d.ts +0 -35
  72. package/dist/src/components/ui/DefaultDropdown.vue.d.ts.map +0 -1
  73. package/dist/src/components/ui/DefaultDropdownLink.vue.d.ts +0 -23
  74. package/dist/src/components/ui/DefaultDropdownLink.vue.d.ts.map +0 -1
  75. package/dist/src/components/ui/DefaultGallery.vue.d.ts +0 -114
  76. package/dist/src/components/ui/DefaultGallery.vue.d.ts.map +0 -1
  77. package/dist/src/components/ui/DefaultInput.vue.d.ts +0 -61
  78. package/dist/src/components/ui/DefaultInput.vue.d.ts.map +0 -1
  79. package/dist/src/components/ui/DefaultLoader.vue.d.ts +0 -12
  80. package/dist/src/components/ui/DefaultLoader.vue.d.ts.map +0 -1
  81. package/dist/src/components/ui/DefaultModal.vue.d.ts +0 -36
  82. package/dist/src/components/ui/DefaultModal.vue.d.ts.map +0 -1
  83. package/dist/src/components/ui/DefaultNotif.vue.d.ts +0 -3
  84. package/dist/src/components/ui/DefaultNotif.vue.d.ts.map +0 -1
  85. package/dist/src/components/ui/DefaultPaging.vue.d.ts +0 -13
  86. package/dist/src/components/ui/DefaultPaging.vue.d.ts.map +0 -1
  87. package/dist/src/components/ui/DefaultSidebar.vue.d.ts +0 -29
  88. package/dist/src/components/ui/DefaultSidebar.vue.d.ts.map +0 -1
  89. package/dist/src/components/ui/DefaultTagInput.vue.d.ts +0 -34
  90. package/dist/src/components/ui/DefaultTagInput.vue.d.ts.map +0 -1
  91. package/dist/src/components/ui/transitions/CollapseTransition.vue.d.ts +0 -18
  92. package/dist/src/components/ui/transitions/CollapseTransition.vue.d.ts.map +0 -1
  93. package/dist/src/components/ui/transitions/ExpandTransition.vue.d.ts +0 -18
  94. package/dist/src/components/ui/transitions/ExpandTransition.vue.d.ts.map +0 -1
  95. package/dist/src/components/ui/transitions/FadeTransition.vue.d.ts +0 -18
  96. package/dist/src/components/ui/transitions/FadeTransition.vue.d.ts.map +0 -1
  97. package/dist/src/components/ui/transitions/ScaleTransition.vue.d.ts +0 -18
  98. package/dist/src/components/ui/transitions/ScaleTransition.vue.d.ts.map +0 -1
  99. package/dist/src/components/ui/transitions/SlideTransition.vue.d.ts +0 -21
  100. package/dist/src/components/ui/transitions/SlideTransition.vue.d.ts.map +0 -1
  101. package/dist/src/composables/event-bus.d.ts +0 -8
  102. package/dist/src/composables/event-bus.d.ts.map +0 -1
  103. package/dist/src/composables/rest.d.ts +0 -24
  104. package/dist/src/composables/rest.d.ts.map +0 -1
  105. package/dist/src/composables/seo.d.ts +0 -26
  106. package/dist/src/composables/seo.d.ts.map +0 -1
  107. package/dist/src/composables/ssr.d.ts +0 -24
  108. package/dist/src/composables/ssr.d.ts.map +0 -1
  109. package/dist/src/composables/templating.d.ts +0 -7
  110. package/dist/src/composables/templating.d.ts.map +0 -1
  111. package/dist/src/composables/translations.d.ts +0 -8
  112. package/dist/src/composables/translations.d.ts.map +0 -1
  113. package/dist/src/index.d.ts.map +0 -1
  114. package/dist/src/plugin.d.ts +0 -3
  115. package/dist/src/plugin.d.ts.map +0 -1
  116. package/dist/src/stores/serverRouter.d.ts +0 -34
  117. package/dist/src/stores/serverRouter.d.ts.map +0 -1
  118. package/dist/src/stores/user.d.ts +0 -139
  119. package/dist/src/stores/user.d.ts.map +0 -1
  120. package/dist/src/types.d.ts +0 -48
  121. package/dist/src/types.d.ts.map +0 -1
@@ -0,0 +1,1056 @@
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 {
13
+ useDebounceFn,
14
+ useElementSize,
15
+ useEventListener,
16
+ useFullscreen,
17
+ useResizeObserver,
18
+ useWindowSize,
19
+ } from '@vueuse/core'
20
+ import { computed, h, nextTick, onMounted, onUnmounted, reactive, ref, shallowRef, watch } from 'vue'
21
+ import { useEventBus } from '../../composables/event-bus'
22
+ import { ClientOnly } from '../ssr/ClientOnly'
23
+ import DefaultPaging from './DefaultPaging.vue'
24
+
25
+ // Core state
26
+ const isGalleryOpen = ref(false)
27
+ const eventBus = useEventBus()
28
+ const sidePanel = ref(true)
29
+ const showControls = ref(true)
30
+ const isFullscreen = ref(false)
31
+ const infoPanel = ref(true)
32
+ const direction = ref<'next' | 'prev'>('next')
33
+
34
+ // DOM refs
35
+ const galleryRef = shallowRef<HTMLElement | null>(null)
36
+ const galleryContentRef = shallowRef<HTMLElement | null>(null)
37
+ const imageContainerRef = shallowRef<HTMLElement | null>(null)
38
+ const infoPanelRef = shallowRef<HTMLElement | null>(null)
39
+ const sidePanelRef = shallowRef<HTMLElement | null>(null)
40
+ const topControlsRef = shallowRef<HTMLElement | null>(null)
41
+
42
+ // Sizing via VueUse
43
+ const { width: galleryWidth, height: galleryHeight } = useElementSize(galleryRef)
44
+ const { width: windowWidth, height: windowHeight } = useWindowSize()
45
+ const { height: topControlsHeight } = useElementSize(topControlsRef)
46
+ const { height: infoPanelHeight } = useElementSize(infoPanelRef)
47
+ const { isFullscreen: isElementFullscreen, enter: enterFullscreen, exit: exitFullscreen } = useFullscreen(galleryRef)
48
+
49
+ watch(isElementFullscreen, (v) => { isFullscreen.value = v })
50
+
51
+ // Touch state
52
+ const touchStartTime = ref(0)
53
+ const start = reactive({ x: 0, y: 0 })
54
+ const isKeyPressed = ref(false)
55
+ let controlsTimeout: number | null = null
56
+ let fullscreenResizeTimeout: number | null = null
57
+
58
+ const props = withDefaults(
59
+ defineProps<{
60
+ id: string
61
+ images: Array<any>
62
+ title?: string
63
+ getImageUrl?: Function
64
+ getThumbnailUrl?: Function
65
+ onOpen?: Function
66
+ onClose?: Function
67
+ closeIcon?: object
68
+ gridHeight?: number
69
+ mode: 'mason' | 'grid' | 'button' | 'hidden' | 'custom'
70
+ paging?: APIPaging | undefined
71
+ buttonText?: string
72
+ buttonType?: string
73
+ modelValue: number
74
+ borderColor?: Function
75
+ imageLoader: string
76
+ videoComponent?: Component | string
77
+ imageComponent?: Component | string
78
+ isVideo?: Function
79
+ ranking?: boolean
80
+ editEnabled?: boolean
81
+ editMode?: boolean
82
+ selectedItems?: Set<string>
83
+ onBulkAction?: Function
84
+ getItemId?: Function
85
+ editButtonText?: string
86
+ cancelButtonText?: string
87
+ bulkActionText?: string
88
+ selectAllText?: string
89
+ clearSelectionText?: string
90
+ selectedCountText?: string
91
+ saveOrderText?: string
92
+ reorderButtonText?: string
93
+ onSaveOrder?: Function
94
+ showSaveOrder?: boolean
95
+ getItemPosition?: Function
96
+ onPositionChange?: Function
97
+ }>(),
98
+ {
99
+ modelValue: 0,
100
+ imageComponent: 'img',
101
+ mode: 'grid',
102
+ gridHeight: 4,
103
+ closeIcon: () => h(XMarkIcon),
104
+ images: () => [],
105
+ isVideo: () => false,
106
+ getImageUrl: (image: any) => image.image_url,
107
+ getThumbnailUrl: (image: any) => `${image.image_url}?s=250x250&m=autocrop`,
108
+ paging: undefined,
109
+ borderColor: undefined,
110
+ ranking: false,
111
+ editEnabled: false,
112
+ editMode: false,
113
+ selectedItems: () => new Set(),
114
+ onBulkAction: undefined,
115
+ getItemId: (item: any, index: number) => item.id || item.UUID || index.toString(),
116
+ editButtonText: 'Edit',
117
+ cancelButtonText: 'Cancel',
118
+ bulkActionText: 'Delete Selected',
119
+ selectAllText: 'Select All',
120
+ clearSelectionText: 'Clear',
121
+ selectedCountText: 'selected',
122
+ saveOrderText: 'Save Order',
123
+ reorderButtonText: 'Reorder',
124
+ onSaveOrder: undefined,
125
+ showSaveOrder: false,
126
+ getItemPosition: (item: any, index: number) => item.UserIndex ?? index,
127
+ onPositionChange: undefined,
128
+ },
129
+ )
130
+
131
+ const emit = defineEmits(['update:modelValue', 'update:editMode', 'update:selectedItems'])
132
+
133
+ const modelValue = computed({
134
+ get: () => props.modelValue,
135
+ set: (i) => { emit('update:modelValue', i) },
136
+ })
137
+
138
+ // Edit mode
139
+ const localEditMode = computed({
140
+ get: () => props.editMode,
141
+ set: value => emit('update:editMode', value),
142
+ })
143
+
144
+ const localReorderMode = ref(false)
145
+
146
+ function toggleReorderMode() {
147
+ localReorderMode.value = !localReorderMode.value
148
+ if (localReorderMode.value) {
149
+ localEditMode.value = false
150
+ localSelectedItems.value = new Set()
151
+ }
152
+ }
153
+
154
+ function exitReorderMode() { localReorderMode.value = false }
155
+
156
+ const localSelectedItems = computed({
157
+ get: () => props.selectedItems,
158
+ set: value => emit('update:selectedItems', value),
159
+ })
160
+
161
+ function toggleEditMode() {
162
+ localEditMode.value = !localEditMode.value
163
+ if (localEditMode.value) localReorderMode.value = false
164
+ if (!localEditMode.value) localSelectedItems.value = new Set()
165
+ }
166
+
167
+ function toggleItemSelection(item: any, index: number) {
168
+ const id = props.getItemId(item, index)
169
+ const s = new Set(localSelectedItems.value)
170
+ s.has(id) ? s.delete(id) : s.add(id)
171
+ localSelectedItems.value = s
172
+ }
173
+
174
+ function selectAll() {
175
+ const s = new Set<string>()
176
+ props.images.forEach((item, i) => s.add(props.getItemId(item, i)))
177
+ localSelectedItems.value = s
178
+ }
179
+
180
+ function clearSelection() { localSelectedItems.value = new Set() }
181
+
182
+ function handleBulkAction() {
183
+ if (props.onBulkAction && localSelectedItems.value.size > 0) {
184
+ props.onBulkAction(Array.from(localSelectedItems.value))
185
+ }
186
+ }
187
+
188
+ function isItemSelected(item: any, index: number): boolean {
189
+ return localSelectedItems.value.has(props.getItemId(item, index))
190
+ }
191
+
192
+ function handleThumbnailClick(item: any, index: number) {
193
+ if (props.editMode) toggleItemSelection(item, index)
194
+ else eventBus.emit(`${props.id}GalleryImage`, index)
195
+ }
196
+
197
+ // Computed
198
+ const modelValueSrc = computed(() => {
199
+ if (!props.images.length || !props.images[modelValue.value]) return false
200
+ return props.getImageUrl(props.images[modelValue.value])
201
+ })
202
+
203
+ const currentImage = computed(() => props.images.length ? props.images[modelValue.value] : null)
204
+ const imageCount = computed(() => props.images.length)
205
+ const currentIndex = computed(() => modelValue.value + 1)
206
+
207
+ // Dynamic image sizing
208
+ const updateImageSizes = useDebounceFn(() => {
209
+ if (!isGalleryOpen.value) return
210
+ const container = document.querySelector('.fv-gallery__display') as HTMLDivElement
211
+ if (!container) return
212
+ const media = container.querySelector('img, video') as HTMLElement | null
213
+ if (!media) return
214
+
215
+ const padding = 24
216
+ const sidebarW = sidePanel.value ? 256 : 0
217
+ const availW = windowWidth.value - sidebarW - padding * 2
218
+ media.style.maxWidth = windowWidth.value <= 768 ? '90vw' : `${availW}px`
219
+ media.style.height = 'auto'
220
+
221
+ const topH = topControlsHeight.value || 0
222
+ const infoH = infoPanel.value ? (infoPanelHeight.value || 0) : 0
223
+ media.style.maxHeight = `${windowHeight.value - topH - infoH - padding * 2}px`
224
+ }, 50)
225
+
226
+ // Modal
227
+ function setModal(value: boolean) {
228
+ if (value) {
229
+ props.onOpen?.()
230
+ document.body.style.overflow = 'hidden'
231
+ if (!import.meta.env.SSR) {
232
+ useEventListener(document, 'keydown', handleKeyboardInput)
233
+ useEventListener(document, 'keyup', handleKeyboardRelease)
234
+ }
235
+ } else {
236
+ props.onClose?.()
237
+ document.body.style.overflow = ''
238
+ if (isFullscreen.value) { exitFullscreen(); isFullscreen.value = false }
239
+ if (controlsTimeout) { clearTimeout(controlsTimeout); controlsTimeout = null }
240
+ }
241
+ isGalleryOpen.value = value
242
+ showControls.value = true
243
+ }
244
+
245
+ const openGalleryImage = useDebounceFn((index: number | undefined) => {
246
+ modelValue.value = index !== undefined ? Number.parseInt(index.toString()) : 0
247
+ setModal(true)
248
+ nextTick(() => updateImageSizes())
249
+ }, 50)
250
+
251
+ function goNextImage() {
252
+ direction.value = 'next'
253
+ modelValue.value = modelValue.value < props.images.length - 1 ? modelValue.value + 1 : 0
254
+ resetControlsTimer()
255
+ nextTick(() => updateImageSizes())
256
+ }
257
+
258
+ function goPrevImage() {
259
+ direction.value = 'prev'
260
+ modelValue.value = modelValue.value > 0 ? modelValue.value - 1 : Math.max(0, props.images.length - 1)
261
+ resetControlsTimer()
262
+ nextTick(() => updateImageSizes())
263
+ }
264
+
265
+ function resetControlsTimer() { showControls.value = true }
266
+
267
+ function toggleInfoPanel() {
268
+ infoPanel.value = !infoPanel.value
269
+ resetControlsTimer()
270
+ updateImageSizes()
271
+ nextTick(() => { updateImageSizes(); setTimeout(() => updateImageSizes(), 300) })
272
+ }
273
+
274
+ function toggleSidePanel() {
275
+ sidePanel.value = !sidePanel.value
276
+ resetControlsTimer()
277
+ nextTick(() => updateImageSizes())
278
+ }
279
+
280
+ function toggleFullscreen() {
281
+ const action = isFullscreen.value ? exitFullscreen : enterFullscreen
282
+ if (!isFullscreen.value && galleryRef.value) {
283
+ action().then(() => {
284
+ isFullscreen.value = !isFullscreen.value
285
+ if (fullscreenResizeTimeout) clearTimeout(fullscreenResizeTimeout)
286
+ fullscreenResizeTimeout = window.setTimeout(() => updateImageSizes(), 50)
287
+ }).catch(() => {})
288
+ } else {
289
+ action().then(() => {
290
+ isFullscreen.value = false
291
+ if (fullscreenResizeTimeout) clearTimeout(fullscreenResizeTimeout)
292
+ fullscreenResizeTimeout = window.setTimeout(() => updateImageSizes(), 50)
293
+ }).catch(() => {})
294
+ }
295
+ resetControlsTimer()
296
+ }
297
+
298
+ // Touch
299
+ const touchStart = useDebounceFn((e: TouchEvent) => {
300
+ const t = e.touches[0]
301
+ if ((t.target as HTMLElement).closest('button, a, input, textarea, select')) return
302
+ touchStartTime.value = Date.now()
303
+ start.x = t.screenX
304
+ start.y = t.screenY
305
+ }, 50)
306
+
307
+ const touchEnd = useDebounceFn((e: TouchEvent) => {
308
+ const t = e.changedTouches[0]
309
+ if ((t.target as HTMLElement).closest('button, a, input, textarea, select')) return
310
+ const dx = start.x - t.screenX
311
+ const dy = start.y - t.screenY
312
+ const dur = Date.now() - touchStartTime.value
313
+ if (Math.abs(dx) < 10 && Math.abs(dy) < 10 && dur < 300) return
314
+ if (Math.abs(dx) > Math.abs(dy) && Math.abs(dx) > 50) {
315
+ dx > 0 ? goNextImage() : goPrevImage()
316
+ }
317
+ }, 50)
318
+
319
+ function getBorderColor(i: any) { return props.borderColor?.(i) || '' }
320
+
321
+ // Keyboard
322
+ function handleKeyboardInput(e: KeyboardEvent) {
323
+ if (!isGalleryOpen.value || isKeyPressed.value) return
324
+ switch (e.key) {
325
+ case 'Escape': e.preventDefault(); setModal(false); break
326
+ case 'ArrowRight': isKeyPressed.value = true; goNextImage(); break
327
+ case 'ArrowLeft': isKeyPressed.value = true; goPrevImage(); break
328
+ case 'f': toggleFullscreen(); break
329
+ case 'i': toggleInfoPanel(); break
330
+ }
331
+ }
332
+
333
+ function handleKeyboardRelease(e: KeyboardEvent) {
334
+ if (['ArrowRight', 'ArrowLeft'].includes(e.key)) isKeyPressed.value = false
335
+ }
336
+
337
+ function closeGallery() { setModal(false) }
338
+
339
+ const handleBackdropClick = useDebounceFn((e: MouseEvent) => {
340
+ if (e.target === e.currentTarget) setModal(false)
341
+ }, 200)
342
+
343
+ // Watchers
344
+ watch(
345
+ [currentImage, isFullscreen, infoPanel, sidePanel, windowWidth, windowHeight, galleryWidth, galleryHeight, topControlsHeight, infoPanelHeight, modelValue],
346
+ () => updateImageSizes(),
347
+ )
348
+
349
+ // Lifecycle
350
+ onMounted(() => {
351
+ eventBus.on(`${props.id}GalleryImage`, openGalleryImage)
352
+ eventBus.on(`${props.id}Gallery`, openGalleryImage)
353
+ eventBus.on(`${props.id}GalleryClose`, closeGallery)
354
+ if (topControlsRef.value) useResizeObserver(topControlsRef.value, updateImageSizes)
355
+ if (infoPanelRef.value) useResizeObserver(infoPanelRef.value, updateImageSizes)
356
+ if (sidePanelRef.value) useResizeObserver(sidePanelRef.value, updateImageSizes)
357
+ useEventListener(document, 'fullscreenchange', () => {
358
+ isFullscreen.value = !!document.fullscreenElement
359
+ nextTick(() => updateImageSizes())
360
+ })
361
+ })
362
+
363
+ onUnmounted(() => {
364
+ eventBus.off(`${props.id}Gallery`, openGalleryImage)
365
+ eventBus.off(`${props.id}GalleryImage`, openGalleryImage)
366
+ eventBus.off(`${props.id}GalleryClose`, closeGallery)
367
+ if (!import.meta.env.SSR) document.body.style.overflow = ''
368
+ if (controlsTimeout) clearTimeout(controlsTimeout)
369
+ if (fullscreenResizeTimeout) clearTimeout(fullscreenResizeTimeout)
370
+ if (isFullscreen.value) exitFullscreen().catch(() => {})
371
+ })
372
+ </script>
373
+
374
+ <template>
375
+ <div>
376
+ <!-- ═══ LIGHTBOX VIEWER ═══ -->
377
+ <Transition
378
+ enter-active-class="fv-gallery-enter-active"
379
+ enter-from-class="fv-gallery-enter-from"
380
+ leave-active-class="fv-gallery-leave-active"
381
+ leave-to-class="fv-gallery-leave-to"
382
+ >
383
+ <div
384
+ v-if="isGalleryOpen"
385
+ ref="galleryRef"
386
+ class="fv-gallery"
387
+ role="dialog"
388
+ aria-modal="true"
389
+ @click="handleBackdropClick"
390
+ >
391
+ <!-- Top Controls — glass bar -->
392
+ <Transition
393
+ enter-active-class="fv-gallery__fade-enter"
394
+ enter-from-class="fv-gallery__fade-from"
395
+ leave-active-class="fv-gallery__fade-leave"
396
+ leave-to-class="fv-gallery__fade-from"
397
+ >
398
+ <div v-if="showControls" ref="topControlsRef" class="fv-gallery__topbar">
399
+ <div class="fv-gallery__topbar-left">
400
+ <span v-if="title" class="fv-gallery__title">{{ title }}</span>
401
+ <span class="fv-gallery__counter">{{ currentIndex }} / {{ imageCount }}</span>
402
+ </div>
403
+ <div class="fv-gallery__topbar-right">
404
+ <button
405
+ :class="['fv-gallery__ctrl-btn', { 'fv-gallery__ctrl-btn--active': infoPanel }]"
406
+ :title="infoPanel ? 'Hide info' : 'Show info'"
407
+ @click="toggleInfoPanel"
408
+ >
409
+ <InformationCircleIcon class="w-5 h-5" />
410
+ </button>
411
+ <button
412
+ class="fv-gallery__ctrl-btn"
413
+ :title="sidePanel ? 'Hide thumbnails' : 'Show thumbnails'"
414
+ @click="toggleSidePanel"
415
+ >
416
+ <ChevronDoubleRightIcon v-if="sidePanel" class="w-5 h-5" />
417
+ <ChevronDoubleLeftIcon v-else class="w-5 h-5" />
418
+ </button>
419
+ <button class="fv-gallery__ctrl-btn" aria-label="Close gallery" @click="setModal(false)">
420
+ <component :is="closeIcon" class="w-5 h-5" />
421
+ </button>
422
+ </div>
423
+ </div>
424
+ </Transition>
425
+
426
+ <!-- Main Content -->
427
+ <div ref="galleryContentRef" class="fv-gallery__content">
428
+ <!-- Image Area -->
429
+ <div
430
+ class="fv-gallery__main"
431
+ :style="{ paddingTop: `${topControlsHeight}px` }"
432
+ :class="{ 'fv-gallery__main--with-sidebar': sidePanel }"
433
+ >
434
+ <!-- Prev Arrow -->
435
+ <Transition enter-active-class="fv-gallery__fade-enter" enter-from-class="fv-gallery__fade-from" leave-active-class="fv-gallery__fade-leave" leave-to-class="fv-gallery__fade-from">
436
+ <div v-if="showControls && images.length > 1" class="fv-gallery__nav fv-gallery__nav--prev">
437
+ <button class="fv-gallery__nav-btn" aria-label="Previous image" @click="goPrevImage()">
438
+ <ChevronLeftIcon class="w-6 h-6 md:w-8 md:h-8" />
439
+ </button>
440
+ </div>
441
+ </Transition>
442
+
443
+ <!-- Image/Video -->
444
+ <div ref="imageContainerRef" class="fv-gallery__viewport" @touchstart="touchStart" @touchend="touchEnd">
445
+ <Transition :name="direction === 'next' ? 'slide-next' : 'slide-prev'" mode="out-in" @before-enter="updateImageSizes" @after-leave="updateImageSizes">
446
+ <div :key="`img-${modelValue}`" class="fv-gallery__display">
447
+ <template v-if="videoComponent && isVideo(images[modelValue])">
448
+ <ClientOnly>
449
+ <component :is="videoComponent" :src="isVideo(images[modelValue])" class="fv-gallery__media" @loadedmetadata="updateImageSizes" @loadeddata="updateImageSizes" />
450
+ </ClientOnly>
451
+ </template>
452
+ <template v-else>
453
+ <img v-if="modelValueSrc && imageComponent === 'img'" class="fv-gallery__media" :src="modelValueSrc" :alt="`Gallery image ${modelValue + 1}`" @load="updateImageSizes">
454
+ <component :is="imageComponent" v-else-if="modelValueSrc && imageComponent" :image="modelValueSrc.image" :variant="modelValueSrc.variant" :alt="modelValueSrc.alt" class="fv-gallery__media" />
455
+ </template>
456
+ </div>
457
+ </Transition>
458
+ </div>
459
+
460
+ <!-- Next Arrow -->
461
+ <Transition enter-active-class="fv-gallery__fade-enter" enter-from-class="fv-gallery__fade-from" leave-active-class="fv-gallery__fade-leave" leave-to-class="fv-gallery__fade-from">
462
+ <div v-if="showControls && images.length > 1" class="fv-gallery__nav fv-gallery__nav--next" :class="{ 'fv-gallery__nav--sidebar-offset': sidePanel }">
463
+ <button class="fv-gallery__nav-btn" aria-label="Next image" @click="goNextImage()">
464
+ <ChevronRightIcon class="w-6 h-6 md:w-8 md:h-8" />
465
+ </button>
466
+ </div>
467
+ </Transition>
468
+
469
+ <!-- Info Panel -->
470
+ <Transition enter-active-class="fv-gallery__slide-up-enter" enter-from-class="fv-gallery__slide-up-from" leave-active-class="fv-gallery__slide-up-leave" leave-to-class="fv-gallery__slide-up-from">
471
+ <div v-if="infoPanel && images[modelValue]" ref="infoPanelRef" class="fv-gallery__info">
472
+ <slot :value="images[modelValue]" />
473
+ </div>
474
+ </Transition>
475
+ </div>
476
+
477
+ <!-- Side Thumbnails (Desktop) -->
478
+ <Transition enter-active-class="fv-gallery__slide-right-enter" enter-from-class="fv-gallery__slide-right-from" leave-active-class="fv-gallery__slide-right-leave" leave-to-class="fv-gallery__slide-right-from">
479
+ <div v-if="sidePanel" ref="sidePanelRef" class="fv-gallery__sidebar" :style="{ paddingTop: `${topControlsHeight + 8}px` }">
480
+ <div v-if="paging" class="fv-gallery__sidebar-paging">
481
+ <DefaultPaging :id="id" :items="paging" />
482
+ </div>
483
+ <div class="fv-gallery__sidebar-grid">
484
+ <div v-for="i in images.length" :key="`bg_${id}_${i}`" class="fv-gallery__thumb-wrap">
485
+ <div class="fv-gallery__thumb-highlight" :class="{ 'fv-gallery__thumb-highlight--active': i - 1 === modelValue }" />
486
+ <img
487
+ v-if="imageComponent === 'img'"
488
+ :class="['fv-gallery__thumb', getBorderColor(images[i - 1])]"
489
+ :style="{ filter: i - 1 === modelValue ? 'brightness(1)' : 'brightness(0.6)' }"
490
+ :src="getThumbnailUrl(images[i - 1])"
491
+ :alt="`Thumbnail ${i}`"
492
+ @click="$eventBus.emit(`${id}GalleryImage`, i - 1)"
493
+ >
494
+ <component
495
+ :is="imageComponent"
496
+ v-else
497
+ :image="getThumbnailUrl(images[i - 1]).image"
498
+ :variant="getThumbnailUrl(images[i - 1]).variant"
499
+ :alt="getThumbnailUrl(images[i - 1]).alt"
500
+ :class="['fv-gallery__thumb', getBorderColor(images[i - 1])]"
501
+ :style="{ filter: i - 1 === modelValue ? 'brightness(1)' : 'brightness(0.6)' }"
502
+ :likes="getThumbnailUrl(images[i - 1]).likes"
503
+ :show-likes="getThumbnailUrl(images[i - 1]).showLikes"
504
+ :is-author="getThumbnailUrl(images[i - 1]).isAuthor"
505
+ :user-uuid="getThumbnailUrl(images[i - 1]).userUUID"
506
+ @click="$eventBus.emit(`${id}GalleryImage`, i - 1)"
507
+ />
508
+ </div>
509
+ </div>
510
+ </div>
511
+ </Transition>
512
+
513
+ <!-- Mobile Thumbnails (Bottom) -->
514
+ <Transition enter-active-class="fv-gallery__slide-up-enter" enter-from-class="fv-gallery__slide-up-from" leave-active-class="fv-gallery__slide-up-leave" leave-to-class="fv-gallery__slide-up-from">
515
+ <div v-if="showControls && images.length > 1 && !sidePanel" class="fv-gallery__mobile-thumbs" @touchstart.stop @touchmove.stop @touchend.stop>
516
+ <div class="fv-gallery__mobile-thumbs-scroll">
517
+ <div
518
+ v-for="(image, idx) in images"
519
+ :key="`mt_${id}_${idx}`"
520
+ :class="['fv-gallery__mobile-thumb', { 'fv-gallery__mobile-thumb--active': idx === modelValue }]"
521
+ @click="$eventBus.emit(`${id}GalleryImage`, idx)"
522
+ >
523
+ <img v-if="imageComponent === 'img'" class="fv-gallery__mobile-thumb-img" :style="{ filter: idx === modelValue ? 'brightness(1)' : 'brightness(0.6)' }" :src="getThumbnailUrl(image)" :alt="`Thumbnail ${idx + 1}`">
524
+ <component :is="imageComponent" v-else :image="getThumbnailUrl(image).image" :variant="getThumbnailUrl(image).variant" :alt="getThumbnailUrl(image).alt" class="fv-gallery__mobile-thumb-img" :style="{ filter: idx === modelValue ? 'brightness(1)' : 'brightness(0.6)' }" />
525
+ </div>
526
+ </div>
527
+ </div>
528
+ </Transition>
529
+ </div>
530
+ </div>
531
+ </Transition>
532
+
533
+ <!-- ═══ EDIT MODE CONTROLS ═══ -->
534
+ <div v-if="editEnabled && images.length > 0 && (mode === 'grid' || mode === 'mason' || mode === 'custom')">
535
+ <div v-if="!localEditMode && !localReorderMode" class="fv-gallery__edit-toggle">
536
+ <button class="fv-gallery__edit-btn" @click="toggleEditMode">{{ editButtonText }}</button>
537
+ <button v-if="showSaveOrder" class="fv-gallery__edit-btn fv-gallery__edit-btn--primary" @click="toggleReorderMode">{{ reorderButtonText }}</button>
538
+ </div>
539
+
540
+ <div v-if="localEditMode" class="fv-gallery__bulk-bar">
541
+ <div class="fv-gallery__bulk-left">
542
+ <span class="fv-gallery__bulk-count">{{ localSelectedItems.size }} {{ selectedCountText }}</span>
543
+ <button class="fv-gallery__bulk-action" @click="selectAll">{{ selectAllText }}</button>
544
+ <button class="fv-gallery__bulk-action" @click="clearSelection">{{ clearSelectionText }}</button>
545
+ </div>
546
+ <div class="fv-gallery__bulk-right">
547
+ <button class="fv-gallery__bulk-action fv-gallery__bulk-action--cancel" @click="toggleEditMode">{{ cancelButtonText }}</button>
548
+ <button class="fv-gallery__bulk-action fv-gallery__bulk-action--danger" :disabled="localSelectedItems.size === 0" @click="handleBulkAction">{{ bulkActionText }}</button>
549
+ </div>
550
+ </div>
551
+
552
+ <div v-if="localReorderMode" class="fv-gallery__reorder-bar">
553
+ <span class="fv-gallery__reorder-label">Set position numbers (lower = earlier)</span>
554
+ <div class="fv-gallery__bulk-right">
555
+ <button class="fv-gallery__bulk-action fv-gallery__bulk-action--cancel" @click="exitReorderMode">{{ cancelButtonText }}</button>
556
+ <button v-if="onSaveOrder" class="fv-gallery__bulk-action fv-gallery__bulk-action--primary" @click="onSaveOrder()">{{ saveOrderText }}</button>
557
+ </div>
558
+ </div>
559
+ </div>
560
+
561
+ <!-- ═══ GRID / MASON / CUSTOM ═══ -->
562
+ <div v-if="mode === 'grid' || mode === 'mason' || mode === 'custom'" class="fv-gallery-grid">
563
+ <div :class="{ 'fv-masonry': mode === 'mason', 'fv-standard-grid': mode === 'grid', 'fv-custom-grid': mode === 'custom' }">
564
+ <slot name="thumbnail" />
565
+ <template v-for="i in images.length" :key="`g_${id}_${i}`">
566
+ <template v-if="mode === 'mason'">
567
+ <div v-if="i + (1 % gridHeight) === 0" class="fv-masonry__col">
568
+ <div v-if="ranking" class="fv-gallery__ranking">{{ i }}</div>
569
+ <template v-for="j in gridHeight" :key="`gi_${id}_${i + j}`">
570
+ <div v-if="i + j - 2 < images.length" class="fv-masonry__item" :class="{ 'fv-gallery__item--selected': localEditMode && isItemSelected(images[i + j - 2], i + j - 2) }">
571
+ <div v-if="localEditMode" class="fv-gallery__checkbox-overlay">
572
+ <div :class="['fv-gallery__checkbox', { 'fv-gallery__checkbox--checked': isItemSelected(images[i + j - 2], i + j - 2) }]">
573
+ <svg v-if="isItemSelected(images[i + j - 2], i + j - 2)" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" /></svg>
574
+ </div>
575
+ </div>
576
+ <div v-if="localEditMode && isItemSelected(images[i + j - 2], i + j - 2)" class="fv-gallery__select-overlay" />
577
+ <img v-if="imageComponent === 'img'" class="fv-gallery__grid-img" :src="getThumbnailUrl(images[i + j - 2])" :alt="`Gallery image ${i + j - 1}`" @click="handleThumbnailClick(images[i + j - 2], i + j - 2)">
578
+ <component :is="imageComponent" v-else-if="i + j - 2 < images.length" :image="getThumbnailUrl(images[i + j - 2]).image" :variant="getThumbnailUrl(images[i + j - 2]).variant" :alt="getThumbnailUrl(images[i + j - 2]).alt" :class="['fv-gallery__grid-img', getBorderColor(images[i + j - 2])]" :likes="getThumbnailUrl(images[i + j - 2]).likes" :show-likes="getThumbnailUrl(images[i + j - 2]).showLikes" :is-author="getThumbnailUrl(images[i + j - 2]).isAuthor" :user-uuid="getThumbnailUrl(images[i + j - 2]).userUUID" @click="handleThumbnailClick(images[i + j - 2], i + j - 2)" />
579
+ </div>
580
+ </template>
581
+ </div>
582
+ </template>
583
+ <div v-else class="fv-grid__item group">
584
+ <div v-if="ranking" class="fv-gallery__ranking">{{ i }}</div>
585
+ <div class="fv-grid__item-inner" :class="{ 'fv-gallery__item--selected': localEditMode && isItemSelected(images[i - 1], i - 1) }">
586
+ <div v-if="localEditMode" class="fv-gallery__checkbox-overlay">
587
+ <div :class="['fv-gallery__checkbox', { 'fv-gallery__checkbox--checked': isItemSelected(images[i - 1], i - 1) }]">
588
+ <svg v-if="isItemSelected(images[i - 1], i - 1)" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" /></svg>
589
+ </div>
590
+ </div>
591
+ <div v-if="localReorderMode && onPositionChange" class="fv-gallery__position-input">
592
+ <input type="number" min="-1" :value="getItemPosition(images[i - 1], i - 1)" class="fv-gallery__position-field" @blur="(e: Event) => onPositionChange ? onPositionChange(images[i - 1], parseInt((e.target as HTMLInputElement).value)) : () => {}" @click.stop @keydown.enter="(e: Event) => { (e.target as HTMLInputElement).blur() }">
593
+ </div>
594
+ <div v-if="localEditMode && isItemSelected(images[i - 1], i - 1)" class="fv-gallery__select-overlay" />
595
+ <img v-if="imageComponent === 'img'" class="fv-gallery__grid-img" :src="getThumbnailUrl(images[i - 1])" :alt="`Gallery image ${i}`" @click="handleThumbnailClick(images[i - 1], i - 1)">
596
+ <component :is="imageComponent" v-else-if="imageComponent" :image="getThumbnailUrl(images[i - 1]).image" :variant="getThumbnailUrl(images[i - 1]).variant" :alt="getThumbnailUrl(images[i - 1]).alt" :class="['fv-gallery__grid-img', getBorderColor(images[i - 1])]" :likes="getThumbnailUrl(images[i - 1]).likes" :show-likes="getThumbnailUrl(images[i - 1]).showLikes" :is-author="getThumbnailUrl(images[i - 1]).isAuthor" :user-uuid="getThumbnailUrl(images[i - 1]).userUUID" @click="handleThumbnailClick(images[i - 1], i - 1)" />
597
+ </div>
598
+ </div>
599
+ </template>
600
+ </div>
601
+ </div>
602
+
603
+ <!-- Bottom edit bars (duplicated for scroll convenience) -->
604
+ <div v-if="editEnabled && localEditMode && images.length > 0 && (mode === 'grid' || mode === 'mason' || mode === 'custom')" class="fv-gallery__bulk-bar" style="margin-top: 0.75rem;">
605
+ <div class="fv-gallery__bulk-left">
606
+ <span class="fv-gallery__bulk-count">{{ localSelectedItems.size }} {{ selectedCountText }}</span>
607
+ <button class="fv-gallery__bulk-action" @click="selectAll">{{ selectAllText }}</button>
608
+ <button class="fv-gallery__bulk-action" @click="clearSelection">{{ clearSelectionText }}</button>
609
+ </div>
610
+ <div class="fv-gallery__bulk-right">
611
+ <button class="fv-gallery__bulk-action fv-gallery__bulk-action--cancel" @click="toggleEditMode">{{ cancelButtonText }}</button>
612
+ <button class="fv-gallery__bulk-action fv-gallery__bulk-action--danger" :disabled="localSelectedItems.size === 0" @click="handleBulkAction">{{ bulkActionText }}</button>
613
+ </div>
614
+ </div>
615
+ <div v-if="editEnabled && localReorderMode && images.length > 0 && (mode === 'grid' || mode === 'mason' || mode === 'custom')" class="fv-gallery__reorder-bar" style="margin-top: 0.75rem;">
616
+ <span class="fv-gallery__reorder-label">Set position numbers (lower = earlier)</span>
617
+ <div class="fv-gallery__bulk-right">
618
+ <button class="fv-gallery__bulk-action fv-gallery__bulk-action--cancel" @click="exitReorderMode">{{ cancelButtonText }}</button>
619
+ <button v-if="onSaveOrder" class="fv-gallery__bulk-action fv-gallery__bulk-action--primary" @click="onSaveOrder()">{{ saveOrderText }}</button>
620
+ </div>
621
+ </div>
622
+
623
+ <!-- Button Mode -->
624
+ <button v-if="mode === 'button'" :class="`btn ${buttonType || 'primary'} defaults`" @click="openGalleryImage(0)">
625
+ {{ buttonText || $t("open_gallery_cta") || 'Open Gallery' }}
626
+ </button>
627
+ </div>
628
+ </template>
629
+
630
+ <style scoped>
631
+ /* ═══ Lightbox — Linear near-opaque dark + glass controls ═══ */
632
+ .fv-gallery {
633
+ position: fixed;
634
+ inset: 0;
635
+ z-index: 37;
636
+ max-width: 100vw;
637
+ max-height: 100vh;
638
+ overflow: hidden;
639
+ background: #0a0a0a;
640
+ color: white;
641
+ }
642
+
643
+ /* Top controls — glass bar */
644
+ .fv-gallery__topbar {
645
+ position: fixed;
646
+ top: 0;
647
+ left: 0;
648
+ right: 0;
649
+ display: flex;
650
+ justify-content: space-between;
651
+ align-items: center;
652
+ padding: 0.5rem 1rem;
653
+ z-index: 50;
654
+ background: rgba(10, 10, 10, 0.85);
655
+ backdrop-filter: blur(12px) saturate(1.2);
656
+ -webkit-backdrop-filter: blur(12px) saturate(1.2);
657
+ border-bottom: 1px solid rgba(255, 255, 255, 0.06);
658
+ }
659
+ .fv-gallery__topbar-left { display: flex; align-items: center; gap: 0.5rem; }
660
+ .fv-gallery__topbar-right { display: flex; align-items: center; gap: 0.375rem; }
661
+ .fv-gallery__title { font-weight: 500; font-size: 1rem; }
662
+ .fv-gallery__counter { font-size: 0.8125rem; opacity: 0.7; }
663
+
664
+ /* Apple-style circular glass control buttons */
665
+ .fv-gallery__ctrl-btn {
666
+ display: inline-flex;
667
+ align-items: center;
668
+ justify-content: center;
669
+ width: 2.25rem;
670
+ height: 2.25rem;
671
+ border-radius: 50%;
672
+ border: none;
673
+ cursor: pointer;
674
+ color: rgba(255, 255, 255, 0.8);
675
+ background: rgba(255, 255, 255, 0.08);
676
+ transition: background-color 150ms, color 150ms, transform 150ms;
677
+ }
678
+ .fv-gallery__ctrl-btn:hover {
679
+ background: rgba(255, 255, 255, 0.15);
680
+ color: white;
681
+ transform: scale(1.08);
682
+ }
683
+ .fv-gallery__ctrl-btn--active {
684
+ background: rgba(124, 58, 237, 0.4);
685
+ color: white;
686
+ }
687
+ .fv-gallery__ctrl-btn:focus-visible {
688
+ outline: 2px solid rgba(124, 58, 237, 0.6);
689
+ outline-offset: 2px;
690
+ }
691
+
692
+ /* Content layout */
693
+ .fv-gallery__content { width: 100%; height: 100%; display: flex; flex-direction: column; }
694
+ @media (min-width: 1024px) { .fv-gallery__content { flex-direction: row; } }
695
+
696
+ .fv-gallery__main {
697
+ position: relative;
698
+ flex: 1;
699
+ height: 100%;
700
+ display: flex;
701
+ flex-direction: column;
702
+ max-width: 100%;
703
+ }
704
+ .fv-gallery__main--with-sidebar {
705
+ padding-right: 0;
706
+ }
707
+ @media (min-width: 1024px) {
708
+ .fv-gallery__main--with-sidebar {
709
+ padding-right: 16rem;
710
+ max-width: calc(100% - 16rem);
711
+ }
712
+ }
713
+
714
+ /* Navigation arrows — Apple circular glass */
715
+ .fv-gallery__nav {
716
+ position: absolute;
717
+ z-index: 40;
718
+ height: 100%;
719
+ display: flex;
720
+ align-items: center;
721
+ padding: 0 0.5rem;
722
+ }
723
+ @media (min-width: 768px) { .fv-gallery__nav { padding: 0 1rem; } }
724
+ .fv-gallery__nav--prev { left: 0; }
725
+ .fv-gallery__nav--next { right: 0; }
726
+ @media (min-width: 1024px) {
727
+ .fv-gallery__nav--sidebar-offset { right: 16rem; }
728
+ }
729
+
730
+ .fv-gallery__nav-btn {
731
+ display: inline-flex;
732
+ align-items: center;
733
+ justify-content: center;
734
+ padding: 0.75rem;
735
+ border-radius: 50%;
736
+ border: none;
737
+ cursor: pointer;
738
+ color: rgba(255, 255, 255, 0.8);
739
+ background: rgba(255, 255, 255, 0.08);
740
+ backdrop-filter: blur(8px);
741
+ -webkit-backdrop-filter: blur(8px);
742
+ transition: background-color 150ms, transform 150ms;
743
+ }
744
+ .fv-gallery__nav-btn:hover {
745
+ background: rgba(255, 255, 255, 0.15);
746
+ transform: scale(1.1);
747
+ }
748
+ .fv-gallery__nav-btn:focus-visible {
749
+ outline: 2px solid rgba(124, 58, 237, 0.6);
750
+ outline-offset: 2px;
751
+ }
752
+
753
+ /* Viewport */
754
+ .fv-gallery__viewport {
755
+ flex-grow: 1;
756
+ display: flex;
757
+ align-items: center;
758
+ justify-content: center;
759
+ }
760
+ .fv-gallery__display {
761
+ position: relative;
762
+ width: 100%;
763
+ height: 100%;
764
+ display: flex;
765
+ flex-direction: column;
766
+ align-items: center;
767
+ justify-content: center;
768
+ }
769
+ .fv-gallery__media {
770
+ max-width: 100%;
771
+ height: auto;
772
+ object-fit: contain;
773
+ border-radius: 4px;
774
+ }
775
+
776
+ /* Info panel — glass bottom bar */
777
+ .fv-gallery__info {
778
+ width: 100%;
779
+ padding: 0.5rem 1rem;
780
+ background: rgba(10, 10, 10, 0.85);
781
+ backdrop-filter: blur(12px) saturate(1.2);
782
+ -webkit-backdrop-filter: blur(12px) saturate(1.2);
783
+ border-top: 1px solid rgba(255, 255, 255, 0.06);
784
+ }
785
+
786
+ /* Side panel — glass sidebar with luminance border */
787
+ .fv-gallery__sidebar {
788
+ display: none;
789
+ position: absolute;
790
+ right: 0;
791
+ top: 0;
792
+ bottom: 0;
793
+ width: 16rem;
794
+ overflow-y: auto;
795
+ overflow-x: hidden;
796
+ z-index: 40;
797
+ background: rgba(10, 10, 10, 0.7);
798
+ backdrop-filter: blur(16px) saturate(1.2);
799
+ -webkit-backdrop-filter: blur(16px) saturate(1.2);
800
+ border-left: 1px solid rgba(255, 255, 255, 0.06);
801
+ }
802
+ @media (min-width: 1024px) { .fv-gallery__sidebar { display: block; } }
803
+
804
+ .fv-gallery__sidebar-paging { display: flex; align-items: center; justify-content: center; padding-top: 0.5rem; }
805
+ .fv-gallery__sidebar-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.5rem; padding: 0.5rem; }
806
+
807
+ .fv-gallery__thumb-wrap { position: relative; }
808
+ .fv-gallery__thumb-highlight {
809
+ position: absolute;
810
+ inset: 0;
811
+ border-radius: 0.5rem;
812
+ transition: background-color 200ms;
813
+ }
814
+ .fv-gallery__thumb-wrap:hover .fv-gallery__thumb-highlight { background: rgba(255, 255, 255, 0.08); }
815
+ .fv-gallery__thumb-highlight--active { background: rgba(124, 58, 237, 0.3); }
816
+
817
+ .fv-gallery__thumb {
818
+ width: 100%;
819
+ height: auto;
820
+ border-radius: 0.5rem;
821
+ cursor: pointer;
822
+ transition: filter 200ms, transform 200ms;
823
+ }
824
+ .fv-gallery__thumb:hover { transform: scale(1.02); }
825
+
826
+ /* Mobile thumbnails */
827
+ .fv-gallery__mobile-thumbs {
828
+ position: absolute;
829
+ bottom: 0;
830
+ left: 0;
831
+ right: 0;
832
+ padding: 0.25rem;
833
+ z-index: 45;
834
+ background: linear-gradient(to top, rgba(10, 10, 10, 0.9), transparent);
835
+ backdrop-filter: blur(8px);
836
+ -webkit-backdrop-filter: blur(8px);
837
+ }
838
+ @media (min-width: 1024px) { .fv-gallery__mobile-thumbs { display: none; } }
839
+
840
+ .fv-gallery__mobile-thumbs-scroll {
841
+ display: flex;
842
+ gap: 0.5rem;
843
+ overflow-x: auto;
844
+ padding: 0.25rem;
845
+ scrollbar-width: none;
846
+ }
847
+ .fv-gallery__mobile-thumbs-scroll::-webkit-scrollbar { display: none; }
848
+
849
+ .fv-gallery__mobile-thumb {
850
+ flex-shrink: 0;
851
+ width: 4rem;
852
+ height: 4rem;
853
+ border-radius: 0.5rem;
854
+ overflow: hidden;
855
+ cursor: pointer;
856
+ }
857
+ .fv-gallery__mobile-thumb--active {
858
+ box-shadow: 0 0 0 2px var(--color-fv-primary-500, #8b5cf6), 0 0 0 3px rgba(10, 10, 10, 0.9);
859
+ }
860
+ .fv-gallery__mobile-thumb-img {
861
+ width: 100%;
862
+ height: 100%;
863
+ object-fit: cover;
864
+ border-radius: 0.5rem;
865
+ transition: filter 200ms;
866
+ }
867
+
868
+ /* ═══ Transitions ═══ */
869
+ .fv-gallery-enter-active { transition: opacity 250ms cubic-bezier(0.16, 1, 0.3, 1); }
870
+ .fv-gallery-leave-active { transition: opacity 150ms ease-in; }
871
+ .fv-gallery-enter-from, .fv-gallery-leave-to { opacity: 0; }
872
+
873
+ .fv-gallery__fade-enter { transition: opacity 200ms; }
874
+ .fv-gallery__fade-leave { transition: opacity 200ms; }
875
+ .fv-gallery__fade-from { opacity: 0; }
876
+
877
+ .fv-gallery__slide-up-enter { transition: all 250ms ease-out; }
878
+ .fv-gallery__slide-up-leave { transition: all 200ms ease-in; }
879
+ .fv-gallery__slide-up-from { opacity: 0; transform: translateY(1rem); }
880
+
881
+ .fv-gallery__slide-right-enter { transition: transform 250ms ease-in-out; }
882
+ .fv-gallery__slide-right-leave { transition: transform 250ms ease-in-out; }
883
+ .fv-gallery__slide-right-from { transform: translateX(100%); }
884
+
885
+ /* Image slide transitions */
886
+ .slide-next-enter-active, .slide-next-leave-active,
887
+ .slide-prev-enter-active, .slide-prev-leave-active {
888
+ transition: opacity 150ms cubic-bezier(0.4, 0, 0.2, 1), transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
889
+ will-change: transform, opacity;
890
+ }
891
+ .slide-next-enter-from { opacity: 0; transform: translateX(30px); }
892
+ .slide-next-leave-to { opacity: 0; transform: translateX(-30px); }
893
+ .slide-prev-enter-from { opacity: 0; transform: translateX(-30px); }
894
+ .slide-prev-leave-to { opacity: 0; transform: translateX(30px); }
895
+
896
+ /* ═══ Grid Layouts ═══ */
897
+ .fv-gallery-grid { min-height: 200px; }
898
+
899
+ .fv-standard-grid { display: grid; grid-template-columns: repeat(1, 1fr); gap: 0.75rem; }
900
+ @media (min-width: 480px) { .fv-standard-grid { grid-template-columns: repeat(2, 1fr); } }
901
+ @media (min-width: 768px) { .fv-standard-grid { grid-template-columns: repeat(3, 1fr); gap: 1rem; } }
902
+ @media (min-width: 1024px) { .fv-standard-grid { grid-template-columns: repeat(4, 1fr); } }
903
+ @media (min-width: 1280px) { .fv-standard-grid { grid-template-columns: repeat(5, 1fr); } }
904
+ @media (min-width: 1536px) { .fv-standard-grid { grid-template-columns: repeat(6, 1fr); } }
905
+
906
+ .fv-masonry { display: grid; grid-template-columns: repeat(1, 1fr); gap: 0.75rem; }
907
+ @media (min-width: 480px) { .fv-masonry { grid-template-columns: repeat(2, 1fr); } }
908
+ @media (min-width: 768px) { .fv-masonry { grid-template-columns: repeat(3, 1fr); gap: 1rem; } }
909
+ @media (min-width: 1024px) { .fv-masonry { grid-template-columns: repeat(4, 1fr); } }
910
+
911
+ .fv-masonry__col { display: grid; gap: 0.75rem; }
912
+ .fv-masonry__item { break-inside: avoid; margin-bottom: 0.75rem; position: relative; }
913
+ .fv-grid__item { break-inside: avoid; margin-bottom: 0.75rem; position: relative; }
914
+ .fv-grid__item-inner { overflow: hidden; border-radius: 0.5rem; position: relative; }
915
+
916
+ .fv-gallery__grid-img {
917
+ width: 100%;
918
+ height: auto;
919
+ border-radius: 0.5rem;
920
+ cursor: pointer;
921
+ transition: transform 250ms, filter 250ms;
922
+ }
923
+ .fv-grid__item:hover .fv-gallery__grid-img,
924
+ .fv-masonry__item:hover .fv-gallery__grid-img {
925
+ transform: scale(1.02);
926
+ filter: brightness(1.05);
927
+ }
928
+
929
+ /* Edit mode overlays */
930
+ .fv-gallery__item--selected { box-shadow: 0 0 0 3px #3b82f6; border-radius: 0.5rem; }
931
+ .fv-gallery__checkbox-overlay { position: absolute; top: 0.5rem; left: 0.5rem; z-index: 20; }
932
+ .fv-gallery__checkbox {
933
+ width: 1.5rem;
934
+ height: 1.5rem;
935
+ border-radius: 0.25rem;
936
+ border: 2px solid #9ca3af;
937
+ background: rgba(255, 255, 255, 0.9);
938
+ display: flex;
939
+ align-items: center;
940
+ justify-content: center;
941
+ cursor: pointer;
942
+ }
943
+ :is(.dark) .fv-gallery__checkbox { background: rgba(0, 0, 0, 0.9); }
944
+ .fv-gallery__checkbox--checked { background: #3b82f6; border-color: #3b82f6; color: white; }
945
+ .fv-gallery__select-overlay { position: absolute; inset: 0; background: rgba(59, 130, 246, 0.15); pointer-events: none; z-index: 10; border-radius: 0.5rem; }
946
+
947
+ .fv-gallery__position-input { position: absolute; top: 0.5rem; right: 0.5rem; z-index: 20; }
948
+ .fv-gallery__position-field {
949
+ width: 5rem;
950
+ height: 2.25rem;
951
+ font-size: 0.8125rem;
952
+ text-align: center;
953
+ border-radius: 0.375rem;
954
+ border: 2px solid #60a5fa;
955
+ background: rgba(255, 255, 255, 0.95);
956
+ font-weight: 600;
957
+ color: #171717;
958
+ }
959
+ :is(.dark) .fv-gallery__position-field { background: rgba(0, 0, 0, 0.95); color: white; }
960
+
961
+ .fv-gallery__ranking {
962
+ position: absolute;
963
+ top: 0.5rem;
964
+ left: 0.5rem;
965
+ width: 1.5rem;
966
+ height: 1.5rem;
967
+ display: flex;
968
+ align-items: center;
969
+ justify-content: center;
970
+ border-radius: 50%;
971
+ font-size: 0.75rem;
972
+ z-index: 10;
973
+ background: rgba(0, 0, 0, 0.6);
974
+ color: white;
975
+ }
976
+
977
+ /* ═══ Edit/Bulk/Reorder Bars ═══ */
978
+ .fv-gallery__edit-toggle { display: flex; justify-content: flex-end; margin-bottom: 0.75rem; gap: 0.5rem; }
979
+ .fv-gallery__edit-btn {
980
+ padding: 0.5rem 1rem;
981
+ border-radius: 0.5rem;
982
+ font-size: 0.8125rem;
983
+ font-weight: 500;
984
+ border: none;
985
+ cursor: pointer;
986
+ transition: background-color 150ms;
987
+ background: #e5e5e5;
988
+ color: #404040;
989
+ }
990
+ :is(.dark) .fv-gallery__edit-btn { background: rgba(255, 255, 255, 0.08); color: #d4d4d4; }
991
+ .fv-gallery__edit-btn:hover { background: #d4d4d4; }
992
+ :is(.dark) .fv-gallery__edit-btn:hover { background: rgba(255, 255, 255, 0.12); }
993
+ .fv-gallery__edit-btn--primary { background: var(--color-fv-primary-600, #7c3aed); color: white; }
994
+ .fv-gallery__edit-btn--primary:hover { background: var(--color-fv-primary-700, #6d28d9); }
995
+
996
+ .fv-gallery__bulk-bar {
997
+ display: flex;
998
+ flex-wrap: wrap;
999
+ align-items: center;
1000
+ justify-content: space-between;
1001
+ gap: 0.5rem;
1002
+ padding: 0.75rem;
1003
+ margin-bottom: 0.75rem;
1004
+ border-radius: 0.5rem;
1005
+ background: #f5f5f5;
1006
+ border: 1px solid rgba(0, 0, 0, 0.06);
1007
+ }
1008
+ :is(.dark) .fv-gallery__bulk-bar { background: rgba(255, 255, 255, 0.04); border-color: rgba(255, 255, 255, 0.06); }
1009
+
1010
+ .fv-gallery__bulk-left, .fv-gallery__bulk-right { display: flex; align-items: center; gap: 0.5rem; }
1011
+ .fv-gallery__bulk-count { font-size: 0.8125rem; font-weight: 500; }
1012
+
1013
+ .fv-gallery__bulk-action {
1014
+ padding: 0.25rem 0.75rem;
1015
+ font-size: 0.8125rem;
1016
+ border-radius: 0.375rem;
1017
+ border: none;
1018
+ cursor: pointer;
1019
+ transition: background-color 150ms;
1020
+ background: #e5e5e5;
1021
+ color: #404040;
1022
+ }
1023
+ :is(.dark) .fv-gallery__bulk-action { background: rgba(255, 255, 255, 0.08); color: #d4d4d4; }
1024
+ .fv-gallery__bulk-action:hover { background: #d4d4d4; }
1025
+ .fv-gallery__bulk-action--cancel { background: #525252; color: white; }
1026
+ .fv-gallery__bulk-action--cancel:hover { background: #404040; }
1027
+ .fv-gallery__bulk-action--danger { background: #cf2d56; color: white; }
1028
+ .fv-gallery__bulk-action--danger:hover { background: #b82750; }
1029
+ .fv-gallery__bulk-action--danger:disabled { opacity: 0.5; cursor: not-allowed; }
1030
+ .fv-gallery__bulk-action--primary { background: var(--color-fv-primary-600, #7c3aed); color: white; }
1031
+ .fv-gallery__bulk-action--primary:hover { background: var(--color-fv-primary-700, #6d28d9); }
1032
+
1033
+ .fv-gallery__reorder-bar {
1034
+ display: flex;
1035
+ flex-wrap: wrap;
1036
+ align-items: center;
1037
+ justify-content: space-between;
1038
+ gap: 0.5rem;
1039
+ padding: 0.75rem;
1040
+ margin-bottom: 0.75rem;
1041
+ border-radius: 0.5rem;
1042
+ background: rgba(59, 130, 246, 0.06);
1043
+ border: 1px solid rgba(59, 130, 246, 0.2);
1044
+ }
1045
+ :is(.dark) .fv-gallery__reorder-bar { background: rgba(59, 130, 246, 0.08); border-color: rgba(59, 130, 246, 0.2); }
1046
+ .fv-gallery__reorder-label { font-size: 0.8125rem; font-weight: 500; color: #1e40af; }
1047
+ :is(.dark) .fv-gallery__reorder-label { color: #93c5fd; }
1048
+
1049
+ @media (prefers-reduced-motion: reduce) {
1050
+ .fv-gallery-enter-active, .fv-gallery-leave-active { transition: opacity 100ms; }
1051
+ .slide-next-enter-active, .slide-next-leave-active,
1052
+ .slide-prev-enter-active, .slide-prev-leave-active { transition: opacity 100ms; }
1053
+ .slide-next-enter-from, .slide-next-leave-to,
1054
+ .slide-prev-enter-from, .slide-prev-leave-to { transform: none; }
1055
+ }
1056
+ </style>