@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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import {
|
|
2
|
+
import { useDebounceFn } from '@vueuse/core'
|
|
3
|
+
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
|
3
4
|
import { useEventBus } from '../../composables/event-bus'
|
|
4
5
|
|
|
5
6
|
const props = withDefaults(
|
|
@@ -13,18 +14,26 @@ const props = withDefaults(
|
|
|
13
14
|
id: '',
|
|
14
15
|
},
|
|
15
16
|
)
|
|
17
|
+
|
|
16
18
|
const eventBus = useEventBus()
|
|
17
19
|
const loading = ref<boolean>(false)
|
|
18
|
-
|
|
20
|
+
|
|
21
|
+
// Compute event name once to avoid string concatenation on each event
|
|
22
|
+
const eventName = computed(() => props.id ? `${props.id}-loading` : 'loading')
|
|
23
|
+
|
|
24
|
+
// Debounce the loading state change to prevent rapid toggles
|
|
25
|
+
const setLoading = useDebounceFn((value: boolean) => {
|
|
19
26
|
loading.value = value
|
|
20
|
-
}
|
|
27
|
+
}, 50)
|
|
28
|
+
|
|
29
|
+
// Setup event listeners with computed event name
|
|
21
30
|
onMounted(() => {
|
|
22
|
-
|
|
23
|
-
else eventBus.on('loading', setLoading)
|
|
31
|
+
eventBus.on(eventName.value, setLoading)
|
|
24
32
|
})
|
|
33
|
+
|
|
34
|
+
// Proper cleanup of event listeners
|
|
25
35
|
onUnmounted(() => {
|
|
26
|
-
|
|
27
|
-
else eventBus.off('loading', setLoading)
|
|
36
|
+
eventBus.off(eventName.value, setLoading)
|
|
28
37
|
})
|
|
29
38
|
</script>
|
|
30
39
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { XCircleIcon } from '@heroicons/vue/24/solid'
|
|
3
|
-
import {
|
|
3
|
+
import { useDebounceFn, useEventListener } from '@vueuse/core'
|
|
4
|
+
import { h, nextTick, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue'
|
|
4
5
|
import { useEventBus } from '../../composables/event-bus'
|
|
5
6
|
|
|
6
7
|
// Use a shared global registry in the window to track all modals across instances
|
|
@@ -83,23 +84,32 @@ let focusableElements: HTMLElement[] = []
|
|
|
83
84
|
const baseZIndex = 40 // Starting z-index value
|
|
84
85
|
const zIndex = ref<number>(baseZIndex)
|
|
85
86
|
|
|
86
|
-
//
|
|
87
|
+
// Cache the modal ID to avoid repeated string concatenation
|
|
88
|
+
const modalId = shallowRef(`${props.id}Modal`)
|
|
89
|
+
const modalUniqueId = shallowRef('')
|
|
90
|
+
|
|
91
|
+
// Trap focus within modal for accessibility - memoize selector for better performance
|
|
92
|
+
const focusableSelector = 'a[href], button, input, textarea, select, details, [tabindex]:not([tabindex="-1"])'
|
|
93
|
+
|
|
87
94
|
function getFocusableElements(element: HTMLElement): HTMLElement[] {
|
|
88
95
|
return Array.from(
|
|
89
|
-
element.querySelectorAll(
|
|
90
|
-
'a[href], button, input, textarea, select, details, [tabindex]:not([tabindex="-1"])',
|
|
91
|
-
),
|
|
96
|
+
element.querySelectorAll(focusableSelector),
|
|
92
97
|
).filter(
|
|
93
98
|
el => !el.hasAttribute('disabled') && !el.getAttribute('aria-hidden'),
|
|
94
99
|
) as HTMLElement[]
|
|
95
100
|
}
|
|
96
101
|
|
|
102
|
+
// Use VueUse's useEventListener for better event handling
|
|
103
|
+
function setupKeydownListener() {
|
|
104
|
+
useEventListener(document, 'keydown', handleKeyDown)
|
|
105
|
+
}
|
|
106
|
+
|
|
97
107
|
function handleKeyDown(event: KeyboardEvent) {
|
|
98
108
|
// Only handle events for the top-most modal
|
|
99
109
|
if (!isOpen.value) return
|
|
100
110
|
|
|
101
111
|
// Check if this modal is the top-most one
|
|
102
|
-
const isTopMost = isTopMostModal(
|
|
112
|
+
const isTopMost = isTopMostModal(modalUniqueId.value)
|
|
103
113
|
if (!isTopMost) {
|
|
104
114
|
return
|
|
105
115
|
}
|
|
@@ -107,7 +117,8 @@ function handleKeyDown(event: KeyboardEvent) {
|
|
|
107
117
|
// Close on escape
|
|
108
118
|
if (event.key === 'Escape') {
|
|
109
119
|
event.preventDefault()
|
|
110
|
-
|
|
120
|
+
// Use direct state to avoid the use-before-define issue
|
|
121
|
+
isOpen.value = false
|
|
111
122
|
return
|
|
112
123
|
}
|
|
113
124
|
|
|
@@ -141,7 +152,7 @@ function isTopMostModal(id: string): boolean {
|
|
|
141
152
|
return highestEntry[0] === id
|
|
142
153
|
}
|
|
143
154
|
|
|
144
|
-
|
|
155
|
+
const setModal = useDebounceFn((value: boolean) => {
|
|
145
156
|
if (value === true) {
|
|
146
157
|
if (props.onOpen) props.onOpen()
|
|
147
158
|
previouslyFocusedElement = document.activeElement as HTMLElement
|
|
@@ -149,8 +160,9 @@ function setModal(value: boolean) {
|
|
|
149
160
|
// Get the next z-index from the global registry
|
|
150
161
|
const newZIndex = modalRegistry.getNextZIndex()
|
|
151
162
|
|
|
152
|
-
// Register this modal in the global registry with a unique ID
|
|
163
|
+
// Register this modal in the global registry with a unique ID
|
|
153
164
|
const uniqueId = `${props.id}-${Date.now()}`
|
|
165
|
+
modalUniqueId.value = uniqueId
|
|
154
166
|
modalRegistry.modals.set(uniqueId, newZIndex)
|
|
155
167
|
|
|
156
168
|
// Store the unique ID as a data attribute for future reference
|
|
@@ -164,27 +176,22 @@ function setModal(value: boolean) {
|
|
|
164
176
|
// Set this modal's z-index
|
|
165
177
|
zIndex.value = newZIndex
|
|
166
178
|
|
|
167
|
-
|
|
179
|
+
setupKeydownListener()
|
|
168
180
|
}
|
|
169
181
|
if (value === false) {
|
|
170
182
|
if (props.onClose) props.onClose()
|
|
171
183
|
|
|
172
184
|
// Find and remove this modal from the registry
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
const uniqueId = modalElement.getAttribute('data-modal-unique-id')
|
|
176
|
-
if (uniqueId) {
|
|
177
|
-
modalRegistry.modals.delete(uniqueId)
|
|
178
|
-
}
|
|
185
|
+
if (modalUniqueId.value) {
|
|
186
|
+
modalRegistry.modals.delete(modalUniqueId.value)
|
|
179
187
|
}
|
|
180
188
|
|
|
181
|
-
document.removeEventListener('keydown', handleKeyDown)
|
|
182
189
|
if (previouslyFocusedElement) {
|
|
183
190
|
previouslyFocusedElement.focus()
|
|
184
191
|
}
|
|
185
192
|
}
|
|
186
193
|
isOpen.value = value
|
|
187
|
-
}
|
|
194
|
+
}, 50)
|
|
188
195
|
|
|
189
196
|
// After modal is opened, set focus and collect focusable elements
|
|
190
197
|
watch(isOpen, async (newVal) => {
|
|
@@ -193,49 +200,44 @@ watch(isOpen, async (newVal) => {
|
|
|
193
200
|
if (modalRef.value) {
|
|
194
201
|
focusableElements = getFocusableElements(modalRef.value)
|
|
195
202
|
|
|
196
|
-
// Focus the
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
closeButton
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
focusableElements
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
203
|
+
// Focus the close button or first focusable element
|
|
204
|
+
requestAnimationFrame(() => {
|
|
205
|
+
const closeButton = modalRef.value?.querySelector('button[aria-label="Close modal"]') as HTMLElement
|
|
206
|
+
if (closeButton) {
|
|
207
|
+
closeButton.focus()
|
|
208
|
+
}
|
|
209
|
+
else if (focusableElements.length > 0) {
|
|
210
|
+
focusableElements[0].focus()
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
// If no focusable elements, focus the modal itself
|
|
214
|
+
modalRef.value?.focus()
|
|
215
|
+
}
|
|
216
|
+
})
|
|
208
217
|
}
|
|
209
218
|
}
|
|
210
219
|
})
|
|
211
220
|
|
|
212
221
|
onMounted(() => {
|
|
213
|
-
eventBus.on(
|
|
222
|
+
eventBus.on(modalId.value, setModal)
|
|
214
223
|
})
|
|
215
224
|
|
|
216
225
|
onUnmounted(() => {
|
|
217
|
-
eventBus.off(
|
|
218
|
-
document.removeEventListener('keydown', handleKeyDown)
|
|
226
|
+
eventBus.off(modalId.value, setModal)
|
|
219
227
|
|
|
220
228
|
// Clean up the modal registry if this modal was open when unmounted
|
|
221
|
-
if (isOpen.value) {
|
|
222
|
-
|
|
223
|
-
if (modalElement) {
|
|
224
|
-
const uniqueId = modalElement.getAttribute('data-modal-unique-id')
|
|
225
|
-
if (uniqueId) {
|
|
226
|
-
modalRegistry.modals.delete(uniqueId)
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
+
if (isOpen.value && modalUniqueId.value) {
|
|
230
|
+
modalRegistry.modals.delete(modalUniqueId.value)
|
|
229
231
|
}
|
|
230
232
|
})
|
|
231
233
|
|
|
232
|
-
// Click outside to close
|
|
233
|
-
|
|
234
|
+
// Click outside to close - use debounce to prevent accidental double-clicks
|
|
235
|
+
const handleBackdropClick = useDebounceFn((event: MouseEvent) => {
|
|
234
236
|
// Close only if clicking the backdrop, not the modal content
|
|
235
237
|
if (event.target === event.currentTarget) {
|
|
236
238
|
setModal(false)
|
|
237
239
|
}
|
|
238
|
-
}
|
|
240
|
+
}, 200)
|
|
239
241
|
</script>
|
|
240
242
|
|
|
241
243
|
<template>
|
|
@@ -7,7 +7,8 @@ import {
|
|
|
7
7
|
SparklesIcon,
|
|
8
8
|
XMarkIcon,
|
|
9
9
|
} from '@heroicons/vue/24/solid'
|
|
10
|
-
import {
|
|
10
|
+
import { useDebounceFn, useRafFn } from '@vueuse/core'
|
|
11
|
+
import { computed, onMounted, onUnmounted, ref, shallowRef } from 'vue'
|
|
11
12
|
import { useEventBus } from '../../composables/event-bus'
|
|
12
13
|
import ScaleTransition from './transitions/ScaleTransition.vue'
|
|
13
14
|
|
|
@@ -30,7 +31,7 @@ interface NotifProps {
|
|
|
30
31
|
const eventBus = useEventBus()
|
|
31
32
|
|
|
32
33
|
/** Current displayed notification */
|
|
33
|
-
const currentNotif =
|
|
34
|
+
const currentNotif = shallowRef<NotifProps | null>(null)
|
|
34
35
|
|
|
35
36
|
/** Progress percentage (0 to 100) for the notification's life */
|
|
36
37
|
const progress = ref(0)
|
|
@@ -40,7 +41,7 @@ const isPaused = ref(false)
|
|
|
40
41
|
|
|
41
42
|
/** References to setTimeout / setInterval so we can clear them properly */
|
|
42
43
|
let hideTimeout: ReturnType<typeof setTimeout> | null = null
|
|
43
|
-
let
|
|
44
|
+
let rafStop: Function | null = null
|
|
44
45
|
|
|
45
46
|
/**
|
|
46
47
|
* Primary logic when a 'SendNotif' event is called.
|
|
@@ -48,7 +49,7 @@ let progressInterval: ReturnType<typeof setInterval> | null = null
|
|
|
48
49
|
* - Sets up the new notification
|
|
49
50
|
* - Starts a progress bar
|
|
50
51
|
*/
|
|
51
|
-
|
|
52
|
+
const onCall = useDebounceFn((data: NotifProps) => {
|
|
52
53
|
// If there's an existing notification, remove it first
|
|
53
54
|
hideNotif()
|
|
54
55
|
|
|
@@ -68,19 +69,30 @@ function onCall(data: NotifProps) {
|
|
|
68
69
|
// (A) Hide the notification after the specified time
|
|
69
70
|
hideTimeout = setTimeout(() => hideNotif(), data.time)
|
|
70
71
|
|
|
71
|
-
// (B)
|
|
72
|
+
// (B) Use requestAnimationFrame for smoother animation
|
|
72
73
|
progress.value = 0
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
74
|
+
const startTime = performance.now()
|
|
75
|
+
const duration = Number(data.time || 5000)
|
|
76
|
+
|
|
77
|
+
const { pause } = useRafFn((timestamp) => {
|
|
78
|
+
if (isPaused.value) return
|
|
79
|
+
|
|
80
|
+
const elapsed = timestamp.timestamp - startTime
|
|
81
|
+
const newProgress = Math.min(100, (elapsed / duration) * 100)
|
|
82
|
+
progress.value = newProgress
|
|
83
|
+
|
|
84
|
+
if (newProgress >= 100) {
|
|
85
|
+
hideNotif()
|
|
81
86
|
}
|
|
82
|
-
}
|
|
83
|
-
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
rafStop = pause
|
|
90
|
+
|
|
91
|
+
// Pause if initially paused
|
|
92
|
+
if (isPaused.value) {
|
|
93
|
+
pause()
|
|
94
|
+
}
|
|
95
|
+
}, 50)
|
|
84
96
|
|
|
85
97
|
/**
|
|
86
98
|
* Clears everything related to the current notification
|
|
@@ -94,9 +106,10 @@ function hideNotif() {
|
|
|
94
106
|
clearTimeout(hideTimeout)
|
|
95
107
|
hideTimeout = null
|
|
96
108
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
109
|
+
|
|
110
|
+
if (rafStop) {
|
|
111
|
+
rafStop()
|
|
112
|
+
rafStop = null
|
|
100
113
|
}
|
|
101
114
|
}
|
|
102
115
|
|
|
@@ -126,96 +139,83 @@ function resumeTimer() {
|
|
|
126
139
|
}
|
|
127
140
|
|
|
128
141
|
/** Execute CTA action if provided */
|
|
129
|
-
|
|
142
|
+
const handleCtaClick = useDebounceFn(() => {
|
|
130
143
|
if (currentNotif.value?.ctaAction) {
|
|
131
144
|
currentNotif.value.ctaAction()
|
|
132
145
|
}
|
|
133
|
-
}
|
|
146
|
+
}, 300)
|
|
134
147
|
|
|
135
|
-
/** Get ARIA label based on notification type */
|
|
148
|
+
/** Get ARIA label based on notification type - moved to computed for caching */
|
|
136
149
|
const ariaDescribedBy = computed(() => {
|
|
137
150
|
if (!currentNotif.value) return ''
|
|
138
151
|
return `notif-${currentNotif.value.type || 'info'}`
|
|
139
152
|
})
|
|
140
153
|
|
|
154
|
+
// Color computation mapping - reduces repeated switch statements
|
|
155
|
+
const typeColorMap = {
|
|
156
|
+
success: {
|
|
157
|
+
bg: 'bg-green-50 dark:bg-green-900/20',
|
|
158
|
+
border: 'border-green-300 dark:border-green-700',
|
|
159
|
+
text: 'text-green-800 dark:text-green-200',
|
|
160
|
+
icon: 'text-green-500 dark:text-green-400',
|
|
161
|
+
progress: 'bg-green-500 dark:bg-green-400',
|
|
162
|
+
},
|
|
163
|
+
warning: {
|
|
164
|
+
bg: 'bg-amber-50 dark:bg-amber-900/20',
|
|
165
|
+
border: 'border-amber-300 dark:border-amber-700',
|
|
166
|
+
text: 'text-amber-800 dark:text-amber-200',
|
|
167
|
+
icon: 'text-amber-500 dark:text-amber-400',
|
|
168
|
+
progress: 'bg-amber-500 dark:bg-amber-400',
|
|
169
|
+
},
|
|
170
|
+
secret: {
|
|
171
|
+
bg: 'bg-fuchsia-50 dark:bg-fuchsia-900/20',
|
|
172
|
+
border: 'border-fuchsia-300 dark:border-fuchsia-700',
|
|
173
|
+
text: 'text-fuchsia-800 dark:text-fuchsia-200',
|
|
174
|
+
icon: 'text-fuchsia-500 dark:text-fuchsia-400',
|
|
175
|
+
progress: 'bg-fuchsia-500 dark:bg-fuchsia-400',
|
|
176
|
+
},
|
|
177
|
+
info: {
|
|
178
|
+
bg: 'bg-blue-50 dark:bg-blue-900/20',
|
|
179
|
+
border: 'border-blue-300 dark:border-blue-700',
|
|
180
|
+
text: 'text-blue-800 dark:text-blue-200',
|
|
181
|
+
icon: 'text-blue-500 dark:text-blue-400',
|
|
182
|
+
progress: 'bg-blue-500 dark:bg-blue-400',
|
|
183
|
+
},
|
|
184
|
+
}
|
|
185
|
+
|
|
141
186
|
/** Get background color based on notification type */
|
|
142
187
|
const bgColor = computed(() => {
|
|
143
188
|
if (!currentNotif.value) return ''
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
case 'success':
|
|
147
|
-
return 'bg-green-50 dark:bg-green-900/20'
|
|
148
|
-
case 'warning':
|
|
149
|
-
return 'bg-amber-50 dark:bg-amber-900/20'
|
|
150
|
-
case 'secret':
|
|
151
|
-
return 'bg-fuchsia-50 dark:bg-fuchsia-900/20'
|
|
152
|
-
default: // info
|
|
153
|
-
return 'bg-blue-50 dark:bg-blue-900/20'
|
|
154
|
-
}
|
|
189
|
+
const type = currentNotif.value.type || 'info'
|
|
190
|
+
return typeColorMap[type].bg
|
|
155
191
|
})
|
|
156
192
|
|
|
157
193
|
/** Get border color based on notification type */
|
|
158
194
|
const borderColor = computed(() => {
|
|
159
195
|
if (!currentNotif.value) return ''
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
case 'success':
|
|
163
|
-
return 'border-green-300 dark:border-green-700'
|
|
164
|
-
case 'warning':
|
|
165
|
-
return 'border-amber-300 dark:border-amber-700'
|
|
166
|
-
case 'secret':
|
|
167
|
-
return 'border-fuchsia-300 dark:border-fuchsia-700'
|
|
168
|
-
default: // info
|
|
169
|
-
return 'border-blue-300 dark:border-blue-700'
|
|
170
|
-
}
|
|
196
|
+
const type = currentNotif.value.type || 'info'
|
|
197
|
+
return typeColorMap[type].border
|
|
171
198
|
})
|
|
172
199
|
|
|
173
200
|
/** Get text color based on notification type */
|
|
174
201
|
const textColor = computed(() => {
|
|
175
202
|
if (!currentNotif.value) return ''
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
case 'success':
|
|
179
|
-
return 'text-green-800 dark:text-green-200'
|
|
180
|
-
case 'warning':
|
|
181
|
-
return 'text-amber-800 dark:text-amber-200'
|
|
182
|
-
case 'secret':
|
|
183
|
-
return 'text-fuchsia-800 dark:text-fuchsia-200'
|
|
184
|
-
default: // info
|
|
185
|
-
return 'text-blue-800 dark:text-blue-200'
|
|
186
|
-
}
|
|
203
|
+
const type = currentNotif.value.type || 'info'
|
|
204
|
+
return typeColorMap[type].text
|
|
187
205
|
})
|
|
188
206
|
|
|
189
207
|
/** Get icon color based on notification type */
|
|
190
208
|
const iconColor = computed(() => {
|
|
191
209
|
if (!currentNotif.value) return ''
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
case 'success':
|
|
195
|
-
return 'text-green-500 dark:text-green-400'
|
|
196
|
-
case 'warning':
|
|
197
|
-
return 'text-amber-500 dark:text-amber-400'
|
|
198
|
-
case 'secret':
|
|
199
|
-
return 'text-fuchsia-500 dark:text-fuchsia-400'
|
|
200
|
-
default: // info
|
|
201
|
-
return 'text-blue-500 dark:text-blue-400'
|
|
202
|
-
}
|
|
210
|
+
const type = currentNotif.value.type || 'info'
|
|
211
|
+
return typeColorMap[type].icon
|
|
203
212
|
})
|
|
204
213
|
|
|
205
214
|
/** Get progress bar color based on notification type */
|
|
206
215
|
const progressColor = computed(() => {
|
|
207
216
|
if (!currentNotif.value) return ''
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
case 'success':
|
|
211
|
-
return 'bg-green-500 dark:bg-green-400'
|
|
212
|
-
case 'warning':
|
|
213
|
-
return 'bg-amber-500 dark:bg-amber-400'
|
|
214
|
-
case 'secret':
|
|
215
|
-
return 'bg-fuchsia-500 dark:bg-fuchsia-400'
|
|
216
|
-
default: // info
|
|
217
|
-
return 'bg-blue-500 dark:bg-blue-400'
|
|
218
|
-
}
|
|
217
|
+
const type = currentNotif.value.type || 'info'
|
|
218
|
+
return typeColorMap[type].progress
|
|
219
219
|
})
|
|
220
220
|
|
|
221
221
|
/**
|
|
@@ -230,6 +230,7 @@ onMounted(() => {
|
|
|
230
230
|
*/
|
|
231
231
|
onUnmounted(() => {
|
|
232
232
|
eventBus.off('SendNotif', onCall)
|
|
233
|
+
hideNotif()
|
|
233
234
|
})
|
|
234
235
|
</script>
|
|
235
236
|
|
|
@@ -5,7 +5,8 @@ import type { APIPaging } from '../../composables/rest'
|
|
|
5
5
|
import { getURL, hasFW } from '@fy-/fws-js'
|
|
6
6
|
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/vue/24/solid'
|
|
7
7
|
import { useServerHead } from '@unhead/vue'
|
|
8
|
-
import {
|
|
8
|
+
import { useDebounceFn } from '@vueuse/core'
|
|
9
|
+
import { computed, onMounted, shallowRef, watch } from 'vue'
|
|
9
10
|
import { useRoute } from 'vue-router'
|
|
10
11
|
import { useEventBus } from '../../composables/event-bus'
|
|
11
12
|
import { useServerRouter } from '../../stores/serverRouter'
|
|
@@ -27,120 +28,150 @@ const route = useRoute()
|
|
|
27
28
|
const eventBus = useEventBus()
|
|
28
29
|
const history = useServerRouter()
|
|
29
30
|
|
|
31
|
+
// Using shallowRef for non-reactive values
|
|
32
|
+
const isMounted = shallowRef(false)
|
|
33
|
+
const pageWatcher = shallowRef<WatchStopHandle>()
|
|
34
|
+
|
|
35
|
+
// Memoize the hash string to avoid repeated computation
|
|
36
|
+
const hashString = computed(() => props.hash !== '' ? `#${props.hash}` : undefined)
|
|
37
|
+
|
|
38
|
+
// Check if a page is valid to navigate to
|
|
30
39
|
function isNewPage(page: number) {
|
|
31
40
|
return (
|
|
32
41
|
page >= 1 && page <= props.items.page_max && page !== props.items.page_no
|
|
33
42
|
)
|
|
34
43
|
}
|
|
35
44
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
function next() {
|
|
45
|
+
// Debounced navigation functions to prevent rapid clicks
|
|
46
|
+
const next = useDebounceFn(() => {
|
|
39
47
|
const page = props.items.page_no + 1
|
|
40
48
|
|
|
41
49
|
if (!isNewPage(page)) return
|
|
50
|
+
|
|
42
51
|
const newQuery = { ...route.query }
|
|
43
52
|
newQuery.page = page.toString()
|
|
53
|
+
|
|
44
54
|
history.push({
|
|
45
55
|
path: history.currentRoute.path,
|
|
46
56
|
query: newQuery,
|
|
47
|
-
hash:
|
|
57
|
+
hash: hashString.value,
|
|
48
58
|
})
|
|
49
|
-
}
|
|
59
|
+
}, 300)
|
|
50
60
|
|
|
51
|
-
|
|
61
|
+
const prev = useDebounceFn(() => {
|
|
52
62
|
const page = props.items.page_no - 1
|
|
63
|
+
|
|
53
64
|
if (!isNewPage(page)) return
|
|
65
|
+
|
|
54
66
|
const newQuery = { ...route.query }
|
|
55
67
|
newQuery.page = page.toString()
|
|
68
|
+
|
|
56
69
|
history.push({
|
|
57
70
|
path: history.currentRoute.path,
|
|
58
71
|
query: newQuery,
|
|
59
|
-
hash:
|
|
72
|
+
hash: hashString.value,
|
|
60
73
|
})
|
|
61
|
-
}
|
|
74
|
+
}, 300)
|
|
62
75
|
|
|
76
|
+
// Extract route generation to a reusable function to reduce duplicated code
|
|
63
77
|
function page(page: number): RouteLocationRaw {
|
|
78
|
+
if (!isNewPage(page)) {
|
|
79
|
+
// Return current route if the page is not valid
|
|
80
|
+
return {
|
|
81
|
+
path: history.currentRoute.path,
|
|
82
|
+
query: route.query,
|
|
83
|
+
hash: hashString.value,
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
64
87
|
const newQuery = { ...route.query }
|
|
65
88
|
newQuery.page = page.toString()
|
|
89
|
+
|
|
66
90
|
return {
|
|
67
91
|
path: history.currentRoute.path,
|
|
68
92
|
query: newQuery,
|
|
69
|
-
hash:
|
|
93
|
+
hash: hashString.value,
|
|
70
94
|
}
|
|
71
95
|
}
|
|
72
|
-
|
|
96
|
+
|
|
97
|
+
// Watch for route changes to trigger page change events
|
|
73
98
|
pageWatcher.value = watch(
|
|
74
99
|
() => route.query.page,
|
|
75
100
|
(v, oldValue) => {
|
|
101
|
+
// Skip if component is not mounted or value hasn't changed
|
|
76
102
|
if (v === oldValue || !isMounted.value) return
|
|
103
|
+
|
|
104
|
+
// Emit page change event with fallback to page 1
|
|
77
105
|
eventBus.emit(`${props.id}GoToPage`, v || 1)
|
|
78
106
|
},
|
|
79
107
|
)
|
|
80
108
|
|
|
81
|
-
// Compute pagination links for
|
|
109
|
+
// Compute pagination links for SEO head tags with performance optimizations
|
|
82
110
|
const paginationLinks = computed(() => {
|
|
111
|
+
// Early exit if basic conditions aren't met
|
|
112
|
+
if (!hasFW() || props.items.page_max <= 1) {
|
|
113
|
+
return []
|
|
114
|
+
}
|
|
115
|
+
|
|
83
116
|
const result: any[] = []
|
|
84
117
|
const page_max = Number(props.items.page_max)
|
|
118
|
+
const url = getURL()
|
|
85
119
|
|
|
86
|
-
|
|
87
|
-
let prev
|
|
120
|
+
if (!url) return result
|
|
88
121
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
const canonicalUrl = new URL(url.Canonical)
|
|
122
|
+
try {
|
|
123
|
+
// Parse the canonical URL once
|
|
124
|
+
const canonicalUrl = new URL(url.Canonical)
|
|
125
|
+
const baseUrl = `${url.Scheme}://${url.Host}${url.Path}`
|
|
94
126
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
// Remove the existing 'page' parameter to avoid duplicates
|
|
101
|
-
const page = Number(currentQuery.page)
|
|
102
|
-
|
|
103
|
-
delete currentQuery.page
|
|
127
|
+
// Build query params object
|
|
128
|
+
const currentQuery: Record<string, string> = {}
|
|
129
|
+
canonicalUrl.searchParams.forEach((value, key) => {
|
|
130
|
+
currentQuery[key] = value
|
|
131
|
+
})
|
|
104
132
|
|
|
105
|
-
|
|
133
|
+
// Get current page and create hash part once
|
|
134
|
+
const page = Number(currentQuery.page) || 1
|
|
135
|
+
const hashPart = props.hash !== '' ? `#${props.hash}` : ''
|
|
106
136
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
const nextQueryString = new URLSearchParams(nextQuery).toString()
|
|
110
|
-
next = `${baseUrl}?${nextQueryString}${hashPart}`
|
|
111
|
-
}
|
|
137
|
+
// Remove page from query params to avoid duplicates
|
|
138
|
+
delete currentQuery.page
|
|
112
139
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
140
|
+
// Add next link if applicable
|
|
141
|
+
if (page + 1 <= page_max) {
|
|
142
|
+
const nextQuery = { ...currentQuery, page: (page + 1).toString() }
|
|
143
|
+
const nextQueryString = new URLSearchParams(nextQuery).toString()
|
|
144
|
+
result.push({
|
|
145
|
+
rel: 'next',
|
|
146
|
+
href: `${baseUrl}?${nextQueryString}${hashPart}`,
|
|
147
|
+
key: `paging-next${props.id}`,
|
|
148
|
+
hid: `paging-next${props.id}`,
|
|
149
|
+
id: `paging-next${props.id}`,
|
|
150
|
+
})
|
|
118
151
|
}
|
|
119
|
-
}
|
|
120
152
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
153
|
+
// Add prev link if applicable
|
|
154
|
+
if (page - 1 >= 1) {
|
|
155
|
+
const prevQuery = { ...currentQuery, page: (page - 1).toString() }
|
|
156
|
+
const prevQueryString = new URLSearchParams(prevQuery).toString()
|
|
157
|
+
result.push({
|
|
158
|
+
rel: 'prev',
|
|
159
|
+
href: `${baseUrl}?${prevQueryString}${hashPart}`,
|
|
160
|
+
key: `paging-prev${props.id}`,
|
|
161
|
+
hid: `paging-prev${props.id}`,
|
|
162
|
+
id: `paging-prev${props.id}`,
|
|
163
|
+
})
|
|
164
|
+
}
|
|
129
165
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
href: prev,
|
|
134
|
-
key: `paging-prev${props.id}`,
|
|
135
|
-
hid: `paging-prev${props.id}`,
|
|
136
|
-
id: `paging-prev${props.id}`,
|
|
137
|
-
})
|
|
166
|
+
catch (e) {
|
|
167
|
+
// Silently fail if URL parsing fails
|
|
168
|
+
console.error('Error generating pagination links:', e)
|
|
138
169
|
}
|
|
139
170
|
|
|
140
171
|
return result
|
|
141
172
|
})
|
|
142
173
|
|
|
143
|
-
//
|
|
174
|
+
// Set head tags using useServerHead
|
|
144
175
|
useServerHead({
|
|
145
176
|
link: paginationLinks.value,
|
|
146
177
|
})
|