@fy-/fws-vue 2.3.11 → 2.3.12

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.
@@ -1,6 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { XCircleIcon } from '@heroicons/vue/24/solid'
3
- import { h, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
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
- // Trap focus within modal for accessibility
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(props.id)
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
- setModal(false)
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
- function setModal(value: boolean) {
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 (combines component id with instance 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
- document.addEventListener('keydown', handleKeyDown)
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
- const modalElement = document.querySelector(`[data-modal-id="${props.id}"]`) as HTMLElement
174
- if (modalElement) {
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 first focusable element or the close button if available
197
- const closeButton = modalRef.value.querySelector('button[aria-label="Close modal"]') as HTMLElement
198
- if (closeButton) {
199
- closeButton.focus()
200
- }
201
- else if (focusableElements.length > 0) {
202
- focusableElements[0].focus()
203
- }
204
- else {
205
- // If no focusable elements, focus the modal itself
206
- modalRef.value.focus()
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(`${props.id}Modal`, setModal)
222
+ eventBus.on(modalId.value, setModal)
214
223
  })
215
224
 
216
225
  onUnmounted(() => {
217
- eventBus.off(`${props.id}Modal`, setModal)
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
- const modalElement = document.querySelector(`[data-modal-id="${props.id}"]`) as HTMLElement
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
- function handleBackdropClick(event: MouseEvent) {
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 { computed, onMounted, onUnmounted, ref } from 'vue'
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 = ref<NotifProps | null>(null)
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 progressInterval: ReturnType<typeof setInterval> | null = null
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
- function onCall(data: NotifProps) {
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) Animate the progress bar from 0 to 100% within that time
72
+ // (B) Use requestAnimationFrame for smoother animation
72
73
  progress.value = 0
73
- progressInterval = setInterval(() => {
74
- if (currentNotif.value && data.time && !isPaused.value) {
75
- // update progress based on a 100ms tick
76
- progress.value += (100 / (data.time / 100))
77
- // if progress hits or exceeds 100, hide
78
- if (progress.value >= 100) {
79
- hideNotif()
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
- }, 100)
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
- if (progressInterval) {
98
- clearInterval(progressInterval)
99
- progressInterval = null
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
- function handleCtaClick() {
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
- switch (currentNotif.value.type) {
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
- switch (currentNotif.value.type) {
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
- switch (currentNotif.value.type) {
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
- switch (currentNotif.value.type) {
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
- switch (currentNotif.value.type) {
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 { computed, onMounted, ref, watch } from 'vue'
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
- const pageWatcher = ref<WatchStopHandle>()
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: props.hash !== '' ? `#${props.hash}` : undefined,
57
+ hash: hashString.value,
48
58
  })
49
- }
59
+ }, 300)
50
60
 
51
- function prev() {
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: props.hash !== '' ? `#${props.hash}` : undefined,
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: props.hash !== '' ? `#${props.hash}` : undefined,
93
+ hash: hashString.value,
70
94
  }
71
95
  }
72
- const isMounted = ref(false)
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 useHead
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
- let next
87
- let prev
120
+ if (!url) return result
88
121
 
89
- if (hasFW()) {
90
- const url = getURL()
91
- if (url) {
92
- // Parse the canonical URL to get the base URL and current query parameters
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
- const baseUrl = `${url.Scheme}://${url.Host}${url.Path}`
96
- const currentQuery: Record<string, string> = {}
97
- canonicalUrl.searchParams.forEach((value, key) => {
98
- currentQuery[key] = value
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
- const hashPart = props.hash !== '' ? `#${props.hash}` : ''
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
- if (page + 1 <= page_max) {
108
- const nextQuery = { ...currentQuery, page: (page + 1).toString() }
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
- if (page - 1 >= 1) {
114
- const prevQuery = { ...currentQuery, page: (page - 1).toString() }
115
- const prevQueryString = new URLSearchParams(prevQuery).toString()
116
- prev = `${baseUrl}?${prevQueryString}${hashPart}`
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
- if (next) {
122
- result.push({
123
- rel: 'next',
124
- href: next,
125
- key: `paging-next${props.id}`,
126
- hid: `paging-next${props.id}`,
127
- id: `paging-next${props.id}`,
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
- if (prev) {
131
- result.push({
132
- rel: 'prev',
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
- // Use useHead outside of any reactive context to ensure it runs during SSR
174
+ // Set head tags using useServerHead
144
175
  useServerHead({
145
176
  link: paginationLinks.value,
146
177
  })