@fy-/fws-vue 2.3.11 → 2.3.13
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/DefaultBreadcrumb.vue +31 -11
- package/components/ui/DefaultConfirm.vue +6 -6
- package/components/ui/DefaultDateSelection.vue +7 -1
- package/components/ui/DefaultDropdown.vue +15 -23
- package/components/ui/DefaultDropdownLink.vue +19 -11
- package/components/ui/DefaultGallery.vue +158 -48
- package/components/ui/DefaultInput.vue +15 -9
- package/components/ui/DefaultLoader.vue +16 -7
- package/components/ui/DefaultModal.vue +46 -44
- package/components/ui/DefaultNotif.vue +77 -76
- package/components/ui/DefaultPaging.vue +88 -57
- package/components/ui/DefaultSidebar.vue +37 -9
- package/components/ui/DefaultTagInput.vue +86 -45
- package/composables/seo.ts +57 -92
- package/composables/templating.ts +75 -45
- package/composables/translations.ts +18 -2
- package/package.json +1 -1
- package/stores/user.ts +83 -59
|
@@ -4,6 +4,7 @@ import { getURL, stringHash } from '@fy-/fws-js'
|
|
|
4
4
|
import { ChevronRightIcon, HomeIcon } from '@heroicons/vue/24/solid'
|
|
5
5
|
import { defineBreadcrumb } from '@unhead/schema-org'
|
|
6
6
|
import { useSchemaOrg } from '@unhead/schema-org/vue'
|
|
7
|
+
import { computed } from 'vue'
|
|
7
8
|
|
|
8
9
|
const props = withDefaults(
|
|
9
10
|
defineProps<{
|
|
@@ -16,27 +17,46 @@ const props = withDefaults(
|
|
|
16
17
|
},
|
|
17
18
|
)
|
|
18
19
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
// Memoize URL computation
|
|
21
|
+
const baseUrl = computed(() => {
|
|
22
|
+
const url = getURL()
|
|
23
|
+
return {
|
|
24
|
+
host: url.Host,
|
|
25
|
+
scheme: url.Scheme,
|
|
26
|
+
}
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
// Memoize breadcrumb schema format to avoid recalculation
|
|
30
|
+
const breadcrumbsSchemaFormat = computed(() => props.nav.map((item, index) => {
|
|
31
|
+
if (!item.to) {
|
|
32
|
+
return {
|
|
33
|
+
'position': index + 1,
|
|
34
|
+
'name': item.name,
|
|
35
|
+
'@type': 'ListItem',
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const fullUrl = `${baseUrl.value.host}${item.to}`.replace(/\/\//g, '/')
|
|
23
40
|
return {
|
|
24
41
|
'position': index + 1,
|
|
25
42
|
'name': item.name,
|
|
26
|
-
'item':
|
|
27
|
-
? `${getURL().Scheme}://${fullUrl}`
|
|
28
|
-
: undefined,
|
|
43
|
+
'item': `${baseUrl.value.scheme}://${fullUrl}`,
|
|
29
44
|
'@type': 'ListItem',
|
|
30
45
|
}
|
|
31
|
-
})
|
|
32
|
-
|
|
46
|
+
}))
|
|
47
|
+
|
|
48
|
+
// Cache breadcrumb ID to avoid string operations on every render
|
|
49
|
+
const breadcrumbId = computed(() => {
|
|
50
|
+
if (!props.nav.length) return ''
|
|
33
51
|
const chain = props.nav.map(item => item.name).join(' > ')
|
|
34
52
|
return stringHash(chain)
|
|
35
|
-
}
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
// Only run schema.org setup if we have breadcrumbs
|
|
36
56
|
if (props.nav && props.nav.length) {
|
|
37
57
|
useSchemaOrg([
|
|
38
58
|
defineBreadcrumb({
|
|
39
|
-
'@id': `#${
|
|
59
|
+
'@id': `#${breadcrumbId.value}`,
|
|
40
60
|
'itemListElement': breadcrumbsSchemaFormat,
|
|
41
61
|
}),
|
|
42
62
|
])
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
+
import { useDebounceFn } from '@vueuse/core'
|
|
2
3
|
import { nextTick, onMounted, onUnmounted, ref } from 'vue'
|
|
3
4
|
import { useEventBus } from '../../composables/event-bus'
|
|
4
5
|
import DefaultModal from './DefaultModal.vue'
|
|
@@ -17,12 +18,12 @@ interface ConfirmModalData {
|
|
|
17
18
|
onConfirm: Function
|
|
18
19
|
}
|
|
19
20
|
|
|
20
|
-
|
|
21
|
+
const _onConfirm = useDebounceFn(async () => {
|
|
21
22
|
if (onConfirm.value) {
|
|
22
23
|
await onConfirm.value()
|
|
23
24
|
}
|
|
24
25
|
resetConfirm()
|
|
25
|
-
}
|
|
26
|
+
}, 300)
|
|
26
27
|
|
|
27
28
|
function resetConfirm() {
|
|
28
29
|
title.value = null
|
|
@@ -43,9 +44,8 @@ function showConfirm(data: ConfirmModalData) {
|
|
|
43
44
|
// Emit event first to ensure it's registered before opening the modal
|
|
44
45
|
eventBus.emit('confirmModal', true)
|
|
45
46
|
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
setTimeout(() => {
|
|
47
|
+
// Use requestAnimationFrame instead of setTimeout for better performance
|
|
48
|
+
requestAnimationFrame(() => {
|
|
49
49
|
isOpen.value = true
|
|
50
50
|
eventBus.emit('confirmModal', true)
|
|
51
51
|
|
|
@@ -57,7 +57,7 @@ function showConfirm(data: ConfirmModalData) {
|
|
|
57
57
|
catch {
|
|
58
58
|
}
|
|
59
59
|
})
|
|
60
|
-
}
|
|
60
|
+
})
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
onMounted(() => {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<script lang="ts" setup>
|
|
2
|
+
import { useDebounceFn } from '@vueuse/core'
|
|
2
3
|
import { computed } from 'vue'
|
|
3
4
|
import { DefaultInput } from '../..'
|
|
4
5
|
|
|
@@ -22,10 +23,15 @@ const props = withDefaults(
|
|
|
22
23
|
|
|
23
24
|
const emit = defineEmits(['update:modelValue'])
|
|
24
25
|
|
|
26
|
+
// Use debounced emitter to reduce update frequency
|
|
27
|
+
const emitUpdate = useDebounceFn((value: DateInterval) => {
|
|
28
|
+
emit('update:modelValue', value)
|
|
29
|
+
}, 150)
|
|
30
|
+
|
|
25
31
|
const model = computed({
|
|
26
32
|
get: () => props.modelValue,
|
|
27
33
|
set: (items) => {
|
|
28
|
-
|
|
34
|
+
emitUpdate(items)
|
|
29
35
|
},
|
|
30
36
|
})
|
|
31
37
|
</script>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import {
|
|
2
|
+
import { onClickOutside, useEventListener } from '@vueuse/core'
|
|
3
|
+
import { onMounted, shallowRef } from 'vue'
|
|
3
4
|
import ScaleTransition from './transitions/ScaleTransition.vue'
|
|
4
5
|
|
|
5
6
|
const props = defineProps<{
|
|
@@ -16,31 +17,22 @@ const props = defineProps<{
|
|
|
16
17
|
closeDropdown: () => void
|
|
17
18
|
}>()
|
|
18
19
|
|
|
19
|
-
const dropdownRef =
|
|
20
|
-
|
|
21
|
-
// Custom implementation of click-outside functionality
|
|
22
|
-
function handleClickOutsideElement(event: MouseEvent) {
|
|
23
|
-
if (props.preventClickOutside) return
|
|
24
|
-
|
|
25
|
-
if (props.show && dropdownRef.value && !dropdownRef.value.contains(event.target as Node)) {
|
|
26
|
-
props.handleClickOutside()
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function handleCloseOnEscape(event: KeyboardEvent) {
|
|
31
|
-
if (['Escape', 'Esc'].includes(event.key)) {
|
|
32
|
-
props.closeDropdown()
|
|
33
|
-
}
|
|
34
|
-
}
|
|
20
|
+
const dropdownRef = shallowRef<HTMLElement | null>(null)
|
|
35
21
|
|
|
22
|
+
// Use VueUse's onClickOutside for more efficient click handling
|
|
36
23
|
onMounted(() => {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
24
|
+
onClickOutside(dropdownRef, (_event) => {
|
|
25
|
+
if (!props.preventClickOutside && props.show) {
|
|
26
|
+
props.handleClickOutside()
|
|
27
|
+
}
|
|
28
|
+
})
|
|
40
29
|
|
|
41
|
-
|
|
42
|
-
document
|
|
43
|
-
|
|
30
|
+
// Use VueUse's useEventListener for cleaner event management
|
|
31
|
+
useEventListener(document, 'keydown', (event: KeyboardEvent) => {
|
|
32
|
+
if (['Escape', 'Esc'].includes(event.key) && props.show) {
|
|
33
|
+
props.closeDropdown()
|
|
34
|
+
}
|
|
35
|
+
})
|
|
44
36
|
})
|
|
45
37
|
</script>
|
|
46
38
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
+
import { useDebounceFn } from '@vueuse/core'
|
|
2
3
|
import { computed } from 'vue'
|
|
3
4
|
|
|
4
5
|
const props = defineProps<{
|
|
@@ -7,21 +8,28 @@ const props = defineProps<{
|
|
|
7
8
|
color?: string
|
|
8
9
|
}>()
|
|
9
10
|
|
|
11
|
+
// Fixed class strings to avoid string recreation on each render
|
|
10
12
|
const baseClasses = `w-full px-4 py-3 flex items-center border-b opacity-60
|
|
11
|
-
dark:opacity-70 outline-none text-sm
|
|
13
|
+
dark:opacity-70 outline-none text-sm border-fv-neutral-200 dark:border-fv-neutral-600
|
|
12
14
|
transition-all duration-200`
|
|
13
15
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
else {
|
|
19
|
-
return `text-black dark:text-white active:bg-fv-neutral-100 dark:hover:bg-fv-neutral-600
|
|
20
|
-
dark:focus:bg-fv-neutral-600 hover:bg-fv-neutral-50`
|
|
21
|
-
}
|
|
22
|
-
})
|
|
16
|
+
// Cache color classes to avoid recomputation
|
|
17
|
+
const dangerClasses = 'text-red-500 dark:hover:text-red-50 hover:bg-red-50 active:bg-red-100 dark:hover:bg-red-900'
|
|
18
|
+
const defaultClasses = `text-black dark:text-white active:bg-fv-neutral-100 dark:hover:bg-fv-neutral-600
|
|
19
|
+
dark:focus:bg-fv-neutral-600 hover:bg-fv-neutral-50`
|
|
23
20
|
|
|
21
|
+
// Memoize color classes
|
|
22
|
+
const colorClasses = computed(() =>
|
|
23
|
+
props.color === 'danger' ? dangerClasses : defaultClasses,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
// Memoize final classes to avoid string concatenation on each render
|
|
24
27
|
const classes = computed(() => `${baseClasses} ${colorClasses.value}`)
|
|
28
|
+
|
|
29
|
+
// Debounce click handler to prevent rapid clicks
|
|
30
|
+
const debouncedClick = props.handleClick
|
|
31
|
+
? useDebounceFn(props.handleClick, 150)
|
|
32
|
+
: undefined
|
|
25
33
|
</script>
|
|
26
34
|
|
|
27
35
|
<template>
|
|
@@ -30,7 +38,7 @@ const classes = computed(() => `${baseClasses} ${colorClasses.value}`)
|
|
|
30
38
|
:class="classes"
|
|
31
39
|
role="menuitem"
|
|
32
40
|
type="button"
|
|
33
|
-
@click.prevent="props.handleClick"
|
|
41
|
+
@click.prevent="debouncedClick || props.handleClick"
|
|
34
42
|
>
|
|
35
43
|
<slot />
|
|
36
44
|
</button>
|
|
@@ -9,7 +9,8 @@ import {
|
|
|
9
9
|
InformationCircleIcon,
|
|
10
10
|
XMarkIcon,
|
|
11
11
|
} from '@heroicons/vue/24/solid'
|
|
12
|
-
import {
|
|
12
|
+
import { useDebounceFn, useEventListener, useFullscreen, useResizeObserver } from '@vueuse/core'
|
|
13
|
+
import { computed, h, nextTick, onMounted, onUnmounted, reactive, ref, shallowRef, watch } from 'vue'
|
|
13
14
|
import { useEventBus } from '../../composables/event-bus'
|
|
14
15
|
import DefaultPaging from './DefaultPaging.vue'
|
|
15
16
|
|
|
@@ -21,6 +22,21 @@ const isFullscreen = ref<boolean>(false)
|
|
|
21
22
|
const infoPanel = ref<boolean>(true) // Show info panel by default
|
|
22
23
|
const touchStartTime = ref<number>(0)
|
|
23
24
|
const infoHeight = ref<number>(0)
|
|
25
|
+
const galleryContainerRef = shallowRef<HTMLElement | null>(null)
|
|
26
|
+
|
|
27
|
+
// Use VueUse's useFullscreen for better fullscreen handling
|
|
28
|
+
const { isFullscreen: isElementFullscreen, enter: enterFullscreen, exit: exitFullscreen } = useFullscreen(galleryContainerRef)
|
|
29
|
+
|
|
30
|
+
// Track when fullscreen changes externally (like Escape key)
|
|
31
|
+
watch(isElementFullscreen, (newValue) => {
|
|
32
|
+
isFullscreen.value = newValue
|
|
33
|
+
if (newValue) {
|
|
34
|
+
// Force update of image size when entering fullscreen
|
|
35
|
+
nextTick(() => {
|
|
36
|
+
updateImageSizes()
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
})
|
|
24
40
|
|
|
25
41
|
const props = withDefaults(
|
|
26
42
|
defineProps<{
|
|
@@ -72,14 +88,38 @@ const modelValue = computed({
|
|
|
72
88
|
const direction = ref<'next' | 'prev'>('next')
|
|
73
89
|
|
|
74
90
|
let controlsTimeout: number | null = null
|
|
91
|
+
let fullscreenResizeTimeout: number | null = null
|
|
92
|
+
|
|
93
|
+
// Used to maintain consistent image sizes
|
|
94
|
+
function updateImageSizes() {
|
|
95
|
+
nextTick(() => {
|
|
96
|
+
const imageContainers = document.querySelectorAll('.image-container img, .image-container .video-component') as NodeListOf<HTMLElement>
|
|
97
|
+
if (imageContainers && imageContainers.length > 0) {
|
|
98
|
+
imageContainers.forEach((img) => {
|
|
99
|
+
// Force a reflow to ensure correct sizing
|
|
100
|
+
img.style.maxHeight = ''
|
|
101
|
+
// Force browser to recalculate styles
|
|
102
|
+
void img.offsetHeight
|
|
103
|
+
|
|
104
|
+
// Calculate the correct height based on fullscreen and info panel state
|
|
105
|
+
const topHeight = 4 // rem
|
|
106
|
+
const infoHeight = infoPanel.value ? Number.parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--info-height') || '0px') / 16 : 0
|
|
107
|
+
const viewHeight = isFullscreen.value ? 90 : 80
|
|
108
|
+
|
|
109
|
+
// Set explicit height with important to override any other styles
|
|
110
|
+
img.style.maxHeight = `calc(${viewHeight}vh - ${infoHeight}rem - ${topHeight}rem) !important`
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
}
|
|
75
115
|
|
|
76
116
|
function setModal(value: boolean) {
|
|
77
117
|
if (value === true) {
|
|
78
118
|
if (props.onOpen) props.onOpen()
|
|
79
119
|
document.body.style.overflow = 'hidden' // Prevent scrolling when gallery is open
|
|
80
120
|
if (!import.meta.env.SSR) {
|
|
81
|
-
document
|
|
82
|
-
document
|
|
121
|
+
useEventListener(document, 'keydown', handleKeyboardInput)
|
|
122
|
+
useEventListener(document, 'keyup', handleKeyboardRelease)
|
|
83
123
|
}
|
|
84
124
|
// Auto-hide controls after 3 seconds on mobile
|
|
85
125
|
if (window.innerWidth < 1024) {
|
|
@@ -91,27 +131,23 @@ function setModal(value: boolean) {
|
|
|
91
131
|
else {
|
|
92
132
|
if (props.onClose) props.onClose()
|
|
93
133
|
document.body.style.overflow = '' // Restore scrolling
|
|
94
|
-
if
|
|
95
|
-
|
|
96
|
-
|
|
134
|
+
// Exit fullscreen if active
|
|
135
|
+
if (isFullscreen.value) {
|
|
136
|
+
exitFullscreen()
|
|
137
|
+
isFullscreen.value = false
|
|
97
138
|
}
|
|
98
139
|
// Clear timeout if modal is closed
|
|
99
140
|
if (controlsTimeout) {
|
|
100
141
|
clearTimeout(controlsTimeout)
|
|
101
142
|
controlsTimeout = null
|
|
102
143
|
}
|
|
103
|
-
// Exit fullscreen if active
|
|
104
|
-
if (isFullscreen.value && document.exitFullscreen) {
|
|
105
|
-
document.exitFullscreen().catch(() => {})
|
|
106
|
-
isFullscreen.value = false
|
|
107
|
-
}
|
|
108
144
|
}
|
|
109
145
|
isGalleryOpen.value = value
|
|
110
146
|
showControls.value = true
|
|
111
147
|
// Don't reset info panel state when opening/closing
|
|
112
148
|
}
|
|
113
149
|
|
|
114
|
-
|
|
150
|
+
const openGalleryImage = useDebounceFn((index: number | undefined) => {
|
|
115
151
|
if (index === undefined) {
|
|
116
152
|
modelValue.value = 0
|
|
117
153
|
}
|
|
@@ -119,7 +155,7 @@ function openGalleryImage(index: number | undefined) {
|
|
|
119
155
|
modelValue.value = Number.parseInt(index.toString())
|
|
120
156
|
}
|
|
121
157
|
setModal(true)
|
|
122
|
-
}
|
|
158
|
+
}, 50) // Debounce to prevent accidental double-opens
|
|
123
159
|
|
|
124
160
|
function goNextImage() {
|
|
125
161
|
direction.value = 'next'
|
|
@@ -200,24 +236,31 @@ function toggleInfoPanel() {
|
|
|
200
236
|
|
|
201
237
|
function toggleFullscreen() {
|
|
202
238
|
if (!isFullscreen.value) {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
239
|
+
if (galleryContainerRef.value) {
|
|
240
|
+
enterFullscreen()
|
|
241
|
+
.then(() => {
|
|
242
|
+
isFullscreen.value = true
|
|
243
|
+
// Give browser time to adjust fullscreen before updating sizing
|
|
244
|
+
if (fullscreenResizeTimeout) clearTimeout(fullscreenResizeTimeout)
|
|
245
|
+
fullscreenResizeTimeout = window.setTimeout(() => {
|
|
246
|
+
updateImageSizes()
|
|
247
|
+
}, 50)
|
|
248
|
+
})
|
|
249
|
+
.catch(() => {})
|
|
208
250
|
}
|
|
209
251
|
}
|
|
210
252
|
else {
|
|
211
|
-
|
|
212
|
-
|
|
253
|
+
exitFullscreen()
|
|
254
|
+
.then(() => {
|
|
213
255
|
isFullscreen.value = false
|
|
214
|
-
})
|
|
215
|
-
|
|
256
|
+
})
|
|
257
|
+
.catch(() => {})
|
|
216
258
|
}
|
|
217
259
|
resetControlsTimer()
|
|
218
260
|
}
|
|
219
261
|
|
|
220
|
-
|
|
262
|
+
// Touch handling with debounce to prevent multiple rapid changes
|
|
263
|
+
const touchStart = useDebounceFn((event: TouchEvent) => {
|
|
221
264
|
const touch = event.touches[0]
|
|
222
265
|
const targetElement = touch.target as HTMLElement
|
|
223
266
|
|
|
@@ -231,9 +274,9 @@ function touchStart(event: TouchEvent) {
|
|
|
231
274
|
|
|
232
275
|
start.x = touch.screenX
|
|
233
276
|
start.y = touch.screenY
|
|
234
|
-
}
|
|
277
|
+
}, 50)
|
|
235
278
|
|
|
236
|
-
|
|
279
|
+
const touchEnd = useDebounceFn((event: TouchEvent) => {
|
|
237
280
|
const touch = event.changedTouches[0]
|
|
238
281
|
const targetElement = touch.target as HTMLElement
|
|
239
282
|
const touchDuration = Date.now() - touchStartTime.value
|
|
@@ -265,7 +308,7 @@ function touchEnd(event: TouchEvent) {
|
|
|
265
308
|
goPrevImage()
|
|
266
309
|
}
|
|
267
310
|
}
|
|
268
|
-
}
|
|
311
|
+
}, 50)
|
|
269
312
|
|
|
270
313
|
function getBorderColor(i: any) {
|
|
271
314
|
if (props.borderColor !== undefined) {
|
|
@@ -316,18 +359,23 @@ function closeGallery() {
|
|
|
316
359
|
setModal(false)
|
|
317
360
|
}
|
|
318
361
|
|
|
319
|
-
// Click outside gallery content to close
|
|
320
|
-
|
|
362
|
+
// Click outside gallery content to close - with debounce to prevent accidental closes
|
|
363
|
+
const handleBackdropClick = useDebounceFn((event: MouseEvent) => {
|
|
321
364
|
if (event.target === event.currentTarget) {
|
|
322
365
|
setModal(false)
|
|
323
366
|
}
|
|
324
|
-
}
|
|
367
|
+
}, 200)
|
|
325
368
|
|
|
326
|
-
|
|
327
|
-
|
|
369
|
+
// Watch for both image changes and fullscreen mode changes
|
|
370
|
+
watch([currentImage, isFullscreen], () => {
|
|
371
|
+
// Update the info height when image changes or fullscreen state changes
|
|
328
372
|
if (infoPanel.value) {
|
|
329
373
|
nextTick(() => {
|
|
330
374
|
updateInfoHeight()
|
|
375
|
+
// Fix image sizing issue when navigating in fullscreen
|
|
376
|
+
if (isFullscreen.value) {
|
|
377
|
+
updateImageSizes()
|
|
378
|
+
}
|
|
331
379
|
})
|
|
332
380
|
}
|
|
333
381
|
})
|
|
@@ -344,42 +392,75 @@ function updateInfoHeight() {
|
|
|
344
392
|
})
|
|
345
393
|
}
|
|
346
394
|
|
|
395
|
+
// Debounced window resize handler to avoid performance issues
|
|
396
|
+
const handleWindowResize = useDebounceFn(() => {
|
|
397
|
+
if (isGalleryOpen.value) {
|
|
398
|
+
updateImageSizes()
|
|
399
|
+
updateInfoHeight()
|
|
400
|
+
}
|
|
401
|
+
}, 100)
|
|
402
|
+
|
|
347
403
|
onMounted(() => {
|
|
348
404
|
eventBus.on(`${props.id}GalleryImage`, openGalleryImage)
|
|
349
405
|
eventBus.on(`${props.id}Gallery`, openGalleryImage)
|
|
350
406
|
eventBus.on(`${props.id}GalleryClose`, closeGallery)
|
|
351
407
|
|
|
408
|
+
// Store reference to the gallery container
|
|
409
|
+
galleryContainerRef.value = document.querySelector('.gallery-container')
|
|
410
|
+
|
|
352
411
|
// Initialize info height once mounted (only if info panel is shown)
|
|
353
412
|
if (infoPanel.value) {
|
|
354
413
|
updateInfoHeight()
|
|
355
414
|
}
|
|
356
415
|
|
|
357
|
-
//
|
|
358
|
-
const resizeObserver = new ResizeObserver(() => {
|
|
359
|
-
if (infoPanel.value) {
|
|
360
|
-
updateInfoHeight()
|
|
361
|
-
}
|
|
362
|
-
})
|
|
363
|
-
|
|
416
|
+
// Use vueUse's useResizeObserver instead of native ResizeObserver
|
|
364
417
|
const infoElement = document.querySelector('.info-panel-slot')
|
|
365
418
|
if (infoElement) {
|
|
366
|
-
|
|
419
|
+
useResizeObserver(infoElement as HTMLElement, () => {
|
|
420
|
+
if (infoPanel.value) {
|
|
421
|
+
updateInfoHeight()
|
|
422
|
+
}
|
|
423
|
+
})
|
|
367
424
|
}
|
|
425
|
+
|
|
426
|
+
// Listen for window resize events
|
|
427
|
+
useEventListener(window, 'resize', handleWindowResize)
|
|
428
|
+
|
|
429
|
+
// Listen for fullscreen changes to update image sizes
|
|
430
|
+
useEventListener(document, 'fullscreenchange', () => {
|
|
431
|
+
if (document.fullscreenElement) {
|
|
432
|
+
// This handles the case of using F11 or browser fullscreen controls
|
|
433
|
+
isFullscreen.value = true
|
|
434
|
+
updateImageSizes()
|
|
435
|
+
}
|
|
436
|
+
else {
|
|
437
|
+
isFullscreen.value = false
|
|
438
|
+
}
|
|
439
|
+
})
|
|
368
440
|
})
|
|
369
441
|
|
|
370
442
|
onUnmounted(() => {
|
|
371
443
|
eventBus.off(`${props.id}Gallery`, openGalleryImage)
|
|
372
444
|
eventBus.off(`${props.id}GalleryImage`, openGalleryImage)
|
|
373
445
|
eventBus.off(`${props.id}GalleryClose`, closeGallery)
|
|
446
|
+
|
|
374
447
|
if (!import.meta.env.SSR) {
|
|
375
|
-
document.removeEventListener('keydown', handleKeyboardInput)
|
|
376
|
-
document.removeEventListener('keyup', handleKeyboardRelease)
|
|
377
448
|
document.body.style.overflow = '' // Ensure body scrolling is restored
|
|
378
449
|
}
|
|
450
|
+
|
|
379
451
|
// Clear any remaining timeouts
|
|
380
452
|
if (controlsTimeout) {
|
|
381
453
|
clearTimeout(controlsTimeout)
|
|
382
454
|
}
|
|
455
|
+
|
|
456
|
+
if (fullscreenResizeTimeout) {
|
|
457
|
+
clearTimeout(fullscreenResizeTimeout)
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Ensure we exit fullscreen mode on unmount
|
|
461
|
+
if (isFullscreen.value) {
|
|
462
|
+
exitFullscreen().catch(() => {})
|
|
463
|
+
}
|
|
383
464
|
})
|
|
384
465
|
</script>
|
|
385
466
|
|
|
@@ -441,6 +522,7 @@ onUnmounted(() => {
|
|
|
441
522
|
<!-- Main Image Container -->
|
|
442
523
|
<div
|
|
443
524
|
class="flex-1 flex flex-col z-[2] items-center justify-center max-w-full lg:max-w-[calc(100vw - 256px)] relative"
|
|
525
|
+
style="padding-top: 1rem;"
|
|
444
526
|
>
|
|
445
527
|
<transition
|
|
446
528
|
:name="direction === 'next' ? 'slide-next' : 'slide-prev'"
|
|
@@ -451,7 +533,7 @@ onUnmounted(() => {
|
|
|
451
533
|
class="flex-1 w-full max-w-full flex flex-col items-center justify-center absolute inset-0 z-[2]"
|
|
452
534
|
>
|
|
453
535
|
<div
|
|
454
|
-
class="flex-1 w-full max-w-full flex items-center justify-center"
|
|
536
|
+
class="flex-1 w-full max-w-full flex items-center justify-center image-container"
|
|
455
537
|
>
|
|
456
538
|
<template
|
|
457
539
|
v-if="videoComponent && isVideo(images[modelValue])"
|
|
@@ -460,9 +542,15 @@ onUnmounted(() => {
|
|
|
460
542
|
<component
|
|
461
543
|
:is="videoComponent"
|
|
462
544
|
:src="isVideo(images[modelValue])"
|
|
463
|
-
class="shadow max-w-full h-auto object-contain"
|
|
545
|
+
class="shadow max-w-full h-auto object-contain video-component"
|
|
464
546
|
:style="{
|
|
465
|
-
maxHeight:
|
|
547
|
+
maxHeight: isFullscreen
|
|
548
|
+
? infoPanel
|
|
549
|
+
? 'calc(90vh - var(--info-height, 0px) - 4rem)'
|
|
550
|
+
: 'calc(90vh - 4rem)'
|
|
551
|
+
: infoPanel
|
|
552
|
+
? 'calc(80vh - var(--info-height, 0px) - 4rem)'
|
|
553
|
+
: 'calc(80vh - 4rem)',
|
|
466
554
|
}"
|
|
467
555
|
/>
|
|
468
556
|
</ClientOnly>
|
|
@@ -472,7 +560,13 @@ onUnmounted(() => {
|
|
|
472
560
|
v-if="modelValueSrc && imageComponent === 'img'"
|
|
473
561
|
class="shadow max-w-full h-auto object-contain"
|
|
474
562
|
:style="{
|
|
475
|
-
maxHeight:
|
|
563
|
+
maxHeight: isFullscreen
|
|
564
|
+
? infoPanel
|
|
565
|
+
? 'calc(90vh - var(--info-height, 0px) - 4rem)'
|
|
566
|
+
: 'calc(90vh - 4rem)'
|
|
567
|
+
: infoPanel
|
|
568
|
+
? 'calc(80vh - var(--info-height, 0px) - 4rem)'
|
|
569
|
+
: 'calc(80vh - 4rem)',
|
|
476
570
|
}"
|
|
477
571
|
:src="modelValueSrc"
|
|
478
572
|
:alt="`Gallery image ${modelValue + 1}`"
|
|
@@ -485,7 +579,13 @@ onUnmounted(() => {
|
|
|
485
579
|
:alt="modelValueSrc.alt"
|
|
486
580
|
class="shadow max-w-full h-auto object-contain"
|
|
487
581
|
:style="{
|
|
488
|
-
maxHeight:
|
|
582
|
+
maxHeight: isFullscreen
|
|
583
|
+
? infoPanel
|
|
584
|
+
? 'calc(90vh - var(--info-height, 0px) - 4rem)'
|
|
585
|
+
: 'calc(90vh - 4rem)'
|
|
586
|
+
: infoPanel
|
|
587
|
+
? 'calc(80vh - var(--info-height, 0px) - 4rem)'
|
|
588
|
+
: 'calc(80vh - 4rem)',
|
|
489
589
|
}"
|
|
490
590
|
/>
|
|
491
591
|
</template>
|
|
@@ -539,7 +639,8 @@ onUnmounted(() => {
|
|
|
539
639
|
>
|
|
540
640
|
<div
|
|
541
641
|
v-if="sidePanel"
|
|
542
|
-
class="hidden lg:block flex-shrink-0 w-64 bg-fv-neutral-800/90 backdrop-blur-md h-full max-h-full overflow-y-auto
|
|
642
|
+
class="hidden lg:block flex-shrink-0 w-64 bg-fv-neutral-800/90 backdrop-blur-md h-full max-h-full overflow-y-auto"
|
|
643
|
+
style="padding-top: 4rem;"
|
|
543
644
|
>
|
|
544
645
|
<!-- Paging Controls -->
|
|
545
646
|
<div v-if="paging" class="flex items-center justify-center pt-2">
|
|
@@ -606,7 +707,7 @@ onUnmounted(() => {
|
|
|
606
707
|
>
|
|
607
708
|
<div
|
|
608
709
|
v-if="showControls"
|
|
609
|
-
class="
|
|
710
|
+
class="fixed top-0 left-0 right-0 px-4 py-3 flex justify-between items-center bg-gradient-to-b from-fv-neutral-900/90 to-transparent backdrop-blur-sm z-[50] transition-opacity h-16"
|
|
610
711
|
>
|
|
611
712
|
<!-- Title and Counter -->
|
|
612
713
|
<div class="flex items-center space-x-2">
|
|
@@ -797,6 +898,15 @@ onUnmounted(() => {
|
|
|
797
898
|
</template>
|
|
798
899
|
|
|
799
900
|
<style scoped>
|
|
901
|
+
/* Fix for top controls to ensure they're always visible */
|
|
902
|
+
.fixed.top-0 {
|
|
903
|
+
position: fixed !important;
|
|
904
|
+
top: 0 !important;
|
|
905
|
+
left: 0 !important;
|
|
906
|
+
right: 0 !important;
|
|
907
|
+
z-index: 51 !important;
|
|
908
|
+
}
|
|
909
|
+
|
|
800
910
|
/* Transition styles for next (right) navigation */
|
|
801
911
|
.slide-next-enter-active,
|
|
802
912
|
.slide-next-leave-active {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import type { ErrorObject } from '@vuelidate/core'
|
|
3
|
-
import {
|
|
3
|
+
import { useDebounceFn } from '@vueuse/core'
|
|
4
|
+
import { computed, shallowRef, toRef } from 'vue'
|
|
4
5
|
import { useTranslation } from '../../composables/translations'
|
|
5
6
|
import DefaultTagInput from './DefaultTagInput.vue'
|
|
6
7
|
|
|
@@ -53,11 +54,13 @@ const props = withDefaults(
|
|
|
53
54
|
)
|
|
54
55
|
|
|
55
56
|
const translate = useTranslation()
|
|
56
|
-
|
|
57
|
+
// Use shallowRef for DOM elements to reduce reactivity overhead
|
|
58
|
+
const inputRef = shallowRef<HTMLInputElement>()
|
|
57
59
|
|
|
58
60
|
const errorProps = toRef(props, 'error')
|
|
59
61
|
const errorVuelidateProps = toRef(props, 'errorVuelidate')
|
|
60
62
|
|
|
63
|
+
// Memoized error calculation
|
|
61
64
|
const checkErrors = computed(() => {
|
|
62
65
|
if (errorProps.value) return errorProps.value
|
|
63
66
|
if (errorVuelidateProps.value && errorVuelidateProps.value.length > 0) {
|
|
@@ -84,20 +87,23 @@ const emit = defineEmits([
|
|
|
84
87
|
'blur',
|
|
85
88
|
])
|
|
86
89
|
|
|
87
|
-
|
|
90
|
+
// Debounced event handlers to reduce CPU usage from rapid events
|
|
91
|
+
const handleFocus = useDebounceFn(() => {
|
|
88
92
|
emit('focus', props.id)
|
|
89
|
-
}
|
|
90
|
-
|
|
93
|
+
}, 50)
|
|
94
|
+
|
|
95
|
+
const handleBlur = useDebounceFn(() => {
|
|
91
96
|
emit('blur', props.id)
|
|
92
|
-
}
|
|
97
|
+
}, 50)
|
|
93
98
|
|
|
94
|
-
// Copy input value to clipboard
|
|
95
|
-
|
|
99
|
+
// Copy input value to clipboard with debounce
|
|
100
|
+
const copyToClipboard = useDebounceFn(() => {
|
|
96
101
|
if (props.modelValue) {
|
|
97
102
|
navigator.clipboard.writeText(props.modelValue.toString())
|
|
98
103
|
}
|
|
99
|
-
}
|
|
104
|
+
}, 200)
|
|
100
105
|
|
|
106
|
+
// Optimized computed properties with explicit types
|
|
101
107
|
const model = computed<modelValueType>({
|
|
102
108
|
get: () => props.modelValue,
|
|
103
109
|
set: (value) => {
|