@fy-/fws-vue 2.3.63 → 2.3.65
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 +74 -53
- package/components/ui/DefaultInput.vue +1 -1
- package/components/ui/DefaultModal.vue +41 -29
- package/components/ui/DefaultPaging.vue +6 -3
- package/composables/rest.ts +28 -13
- package/composables/seo.ts +59 -36
- package/composables/ssr.ts +10 -4
- package/package.json +1 -1
- package/stores/serverRouter.ts +12 -6
- package/stores/user.ts +44 -33
|
@@ -27,23 +27,44 @@ const baseUrl = computed(() => {
|
|
|
27
27
|
})
|
|
28
28
|
|
|
29
29
|
// Memoize breadcrumb schema format to avoid recalculation
|
|
30
|
-
const breadcrumbsSchemaFormat = computed(() =>
|
|
31
|
-
|
|
30
|
+
const breadcrumbsSchemaFormat = computed(() => {
|
|
31
|
+
const navLength = props.nav.length
|
|
32
|
+
|
|
33
|
+
return props.nav.map((item, index) => {
|
|
34
|
+
const isLastItem = index === navLength - 1
|
|
35
|
+
|
|
36
|
+
// According to Google's guidelines, the last item should not have a URL
|
|
37
|
+
if (!item.to || isLastItem) {
|
|
38
|
+
return {
|
|
39
|
+
'@type': 'ListItem',
|
|
40
|
+
'position': index + 1,
|
|
41
|
+
'name': item.name,
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Ensure proper URL construction
|
|
46
|
+
let itemUrl = item.to
|
|
47
|
+
|
|
48
|
+
// Handle relative URLs
|
|
49
|
+
if (itemUrl.startsWith('/')) {
|
|
50
|
+
itemUrl = `${baseUrl.value.scheme}://${baseUrl.value.host}${itemUrl}`
|
|
51
|
+
}
|
|
52
|
+
else if (!itemUrl.startsWith('http')) {
|
|
53
|
+
// Handle paths without leading slash
|
|
54
|
+
itemUrl = `${baseUrl.value.scheme}://${baseUrl.value.host}/${itemUrl}`
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Clean up any double slashes (except after protocol)
|
|
58
|
+
itemUrl = itemUrl.replace(/([^:]\/)\/+/g, '$1')
|
|
59
|
+
|
|
32
60
|
return {
|
|
61
|
+
'@type': 'ListItem',
|
|
33
62
|
'position': index + 1,
|
|
34
63
|
'name': item.name,
|
|
35
|
-
'
|
|
64
|
+
'item': itemUrl,
|
|
36
65
|
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
const fullUrl = `${baseUrl.value.host}${item.to}`.replace(/\/\//g, '/')
|
|
40
|
-
return {
|
|
41
|
-
'position': index + 1,
|
|
42
|
-
'name': item.name,
|
|
43
|
-
'item': `${baseUrl.value.scheme}://${fullUrl}`,
|
|
44
|
-
'@type': 'ListItem',
|
|
45
|
-
}
|
|
46
|
-
}))
|
|
66
|
+
})
|
|
67
|
+
})
|
|
47
68
|
|
|
48
69
|
// Cache breadcrumb ID to avoid string operations on every render
|
|
49
70
|
const breadcrumbId = computed(() => {
|
|
@@ -52,11 +73,19 @@ const breadcrumbId = computed(() => {
|
|
|
52
73
|
return stringHash(chain)
|
|
53
74
|
})
|
|
54
75
|
|
|
76
|
+
// Class strings extracted for reusability and mobile optimization
|
|
77
|
+
const linkClasses = 'text-xs font-medium text-fv-neutral-700 hover:text-fv-neutral-900 dark:text-fv-neutral-200 dark:hover:text-white transition-colors duration-200'
|
|
78
|
+
const textClasses = 'text-xs font-medium text-fv-neutral-500 dark:text-fv-neutral-200'
|
|
79
|
+
const chevronClasses = 'w-4 h-4 md:w-5 md:h-5 text-fv-neutral-400 inline-block mx-0.5 md:mx-1.5'
|
|
80
|
+
const homeIconClasses = 'w-3.5 h-3.5 md:w-4 md:h-4 mr-1.5 md:mr-2 inline-block'
|
|
81
|
+
|
|
55
82
|
// Only run schema.org setup if we have breadcrumbs
|
|
56
|
-
if (props.nav && props.nav.length) {
|
|
83
|
+
if (props.nav && props.nav.length > 0) {
|
|
57
84
|
useSchemaOrg([
|
|
58
85
|
defineBreadcrumb({
|
|
59
86
|
'@id': `#${breadcrumbId.value}`,
|
|
87
|
+
'@context': 'https://schema.org',
|
|
88
|
+
'@type': 'BreadcrumbList',
|
|
60
89
|
'itemListElement': breadcrumbsSchemaFormat,
|
|
61
90
|
}),
|
|
62
91
|
])
|
|
@@ -64,44 +93,36 @@ if (props.nav && props.nav.length) {
|
|
|
64
93
|
</script>
|
|
65
94
|
|
|
66
95
|
<template>
|
|
67
|
-
<
|
|
68
|
-
<
|
|
69
|
-
<
|
|
70
|
-
<
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
? 'w-4 h-4 mr-2 inline-block'
|
|
75
|
-
: 'w-5 h-5 text-fv-neutral-400 inline-block mx-0.5 md:mx-1.5'
|
|
76
|
-
"
|
|
77
|
-
/>
|
|
78
|
-
|
|
79
|
-
<router-link
|
|
80
|
-
v-if="item.to"
|
|
81
|
-
:to="item.to"
|
|
82
|
-
:class="
|
|
83
|
-
index === 0
|
|
84
|
-
? 'text-xs font-medium text-fv-neutral-700 hover:text-fv-neutral-900 dark:text-fv-neutral-200 dark:hover:text-white'
|
|
85
|
-
: 'text-xs font-medium text-fv-neutral-700 hover:text-fv-neutral-900 dark:text-fv-neutral-200 dark:hover:text-white'
|
|
86
|
-
"
|
|
87
|
-
>
|
|
88
|
-
<HomeIcon
|
|
89
|
-
v-if="showHome && index === 0"
|
|
90
|
-
:class="
|
|
91
|
-
index === 0
|
|
92
|
-
? 'w-4 h-4 mr-2 inline-block'
|
|
93
|
-
: 'w-4 h-4 text-fv-neutral-400 inline-block mx-0.5 md:mx-1.5'
|
|
94
|
-
"
|
|
96
|
+
<nav aria-label="Breadcrumb">
|
|
97
|
+
<ol class="inline-flex items-center flex-wrap gap-y-1">
|
|
98
|
+
<template v-for="(item, index) in nav" :key="`bc_${index.toString()}`">
|
|
99
|
+
<li class="inline-flex items-center">
|
|
100
|
+
<ChevronRightIcon
|
|
101
|
+
v-if="index !== 0"
|
|
102
|
+
:class="chevronClasses"
|
|
95
103
|
/>
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
104
|
+
|
|
105
|
+
<router-link
|
|
106
|
+
v-if="item.to && index !== nav.length - 1"
|
|
107
|
+
:to="item.to"
|
|
108
|
+
:class="linkClasses"
|
|
109
|
+
:aria-current="index === nav.length - 1 ? 'page' : undefined"
|
|
110
|
+
>
|
|
111
|
+
<HomeIcon
|
|
112
|
+
v-if="showHome && index === 0"
|
|
113
|
+
:class="homeIconClasses"
|
|
114
|
+
/>
|
|
115
|
+
<span>{{ item.name }}</span>
|
|
116
|
+
</router-link>
|
|
117
|
+
<span
|
|
118
|
+
v-else
|
|
119
|
+
:class="textClasses"
|
|
120
|
+
:aria-current="index === nav.length - 1 ? 'page' : undefined"
|
|
121
|
+
>
|
|
122
|
+
{{ item.name }}
|
|
123
|
+
</span>
|
|
124
|
+
</li>
|
|
125
|
+
</template>
|
|
126
|
+
</ol>
|
|
127
|
+
</nav>
|
|
107
128
|
</template>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { XCircleIcon } from '@heroicons/vue/24/solid'
|
|
3
3
|
import { useDebounceFn, useEventListener } from '@vueuse/core'
|
|
4
|
-
import { h, nextTick, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue'
|
|
4
|
+
import { computed, h, nextTick, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue'
|
|
5
5
|
import { useEventBus } from '../../composables/event-bus'
|
|
6
6
|
|
|
7
7
|
// Use a shared global registry in the window to track all modals across instances
|
|
@@ -75,7 +75,7 @@ const props = withDefaults(
|
|
|
75
75
|
const eventBus = useEventBus()
|
|
76
76
|
|
|
77
77
|
const isOpen = ref<boolean>(false)
|
|
78
|
-
const modalRef =
|
|
78
|
+
const modalRef = shallowRef<HTMLElement | null>(null)
|
|
79
79
|
let previouslyFocusedElement: HTMLElement | null = null
|
|
80
80
|
let focusableElements: HTMLElement[] = []
|
|
81
81
|
|
|
@@ -91,6 +91,14 @@ const modalUniqueId = shallowRef('')
|
|
|
91
91
|
// Trap focus within modal for accessibility - memoize selector for better performance
|
|
92
92
|
const focusableSelector = 'a[href], button, input, textarea, select, details, [tabindex]:not([tabindex="-1"])'
|
|
93
93
|
|
|
94
|
+
// Mobile-optimized backdrop classes
|
|
95
|
+
const backdropClasses = 'flex fixed backdrop-blur-[8px] inset-0 flex-col items-center py-4 md:py-8 px-2 md:px-4 overflow-y-auto text-fv-neutral-800 dark:text-fv-neutral-300 bg-fv-neutral-900/[.20] dark:bg-fv-neutral-50/[.20]'
|
|
96
|
+
|
|
97
|
+
// Modal panel classes with mobile optimization
|
|
98
|
+
const modalPanelClasses = computed(() => {
|
|
99
|
+
return `relative ${props.mSize} max-w-[calc(100vw-1rem)] md:max-w-6xl max-h-[85vh] my-auto px-0 box-border bg-white rounded-lg shadow-xl dark:bg-fv-neutral-900 flex flex-col`
|
|
100
|
+
})
|
|
101
|
+
|
|
94
102
|
function getFocusableElements(element: HTMLElement): HTMLElement[] {
|
|
95
103
|
return Array.from(
|
|
96
104
|
element.querySelectorAll(focusableSelector),
|
|
@@ -99,12 +107,11 @@ function getFocusableElements(element: HTMLElement): HTMLElement[] {
|
|
|
99
107
|
) as HTMLElement[]
|
|
100
108
|
}
|
|
101
109
|
|
|
102
|
-
//
|
|
103
|
-
|
|
104
|
-
useEventListener(document, 'keydown', handleKeyDown)
|
|
105
|
-
}
|
|
110
|
+
// Forward declare setModal to avoid use-before-define
|
|
111
|
+
let setModal: any
|
|
106
112
|
|
|
107
|
-
|
|
113
|
+
// Memoize keydown handler for better performance
|
|
114
|
+
const handleKeyDown = useDebounceFn((event: KeyboardEvent) => {
|
|
108
115
|
// Only handle events for the top-most modal
|
|
109
116
|
if (!isOpen.value) return
|
|
110
117
|
|
|
@@ -117,25 +124,28 @@ function handleKeyDown(event: KeyboardEvent) {
|
|
|
117
124
|
// Close on escape
|
|
118
125
|
if (event.key === 'Escape') {
|
|
119
126
|
event.preventDefault()
|
|
120
|
-
|
|
121
|
-
isOpen.value = false
|
|
127
|
+
setModal(false)
|
|
122
128
|
return
|
|
123
129
|
}
|
|
124
130
|
|
|
125
|
-
// Handle tab trapping
|
|
131
|
+
// Handle tab trapping only if we have focusable elements
|
|
126
132
|
if (event.key === 'Tab' && focusableElements.length > 0) {
|
|
133
|
+
const firstElement = focusableElements[0]
|
|
134
|
+
const lastElement = focusableElements[focusableElements.length - 1]
|
|
135
|
+
const activeElement = document.activeElement
|
|
136
|
+
|
|
127
137
|
// If shift + tab on first element, focus last element
|
|
128
|
-
if (event.shiftKey &&
|
|
138
|
+
if (event.shiftKey && activeElement === firstElement) {
|
|
129
139
|
event.preventDefault()
|
|
130
|
-
|
|
140
|
+
lastElement.focus()
|
|
131
141
|
}
|
|
132
142
|
// If tab on last element, focus first element
|
|
133
|
-
else if (!event.shiftKey &&
|
|
143
|
+
else if (!event.shiftKey && activeElement === lastElement) {
|
|
134
144
|
event.preventDefault()
|
|
135
|
-
|
|
145
|
+
firstElement.focus()
|
|
136
146
|
}
|
|
137
147
|
}
|
|
138
|
-
}
|
|
148
|
+
}, 10)
|
|
139
149
|
|
|
140
150
|
// Check if this modal is the top-most (highest z-index)
|
|
141
151
|
function isTopMostModal(id: string): boolean {
|
|
@@ -152,7 +162,7 @@ function isTopMostModal(id: string): boolean {
|
|
|
152
162
|
return highestEntry[0] === id
|
|
153
163
|
}
|
|
154
164
|
|
|
155
|
-
|
|
165
|
+
setModal = useDebounceFn((value: boolean) => {
|
|
156
166
|
if (value === true) {
|
|
157
167
|
if (props.onOpen) props.onOpen()
|
|
158
168
|
previouslyFocusedElement = document.activeElement as HTMLElement
|
|
@@ -176,7 +186,8 @@ const setModal = useDebounceFn((value: boolean) => {
|
|
|
176
186
|
// Set this modal's z-index
|
|
177
187
|
zIndex.value = newZIndex
|
|
178
188
|
|
|
179
|
-
|
|
189
|
+
// Set up keyboard listener directly
|
|
190
|
+
useEventListener(document, 'keydown', handleKeyDown)
|
|
180
191
|
}
|
|
181
192
|
if (value === false) {
|
|
182
193
|
if (props.onClose) props.onClose()
|
|
@@ -197,12 +208,13 @@ const setModal = useDebounceFn((value: boolean) => {
|
|
|
197
208
|
watch(isOpen, async (newVal) => {
|
|
198
209
|
if (newVal) {
|
|
199
210
|
await nextTick()
|
|
200
|
-
|
|
201
|
-
|
|
211
|
+
const modalEl = modalRef.value
|
|
212
|
+
if (modalEl) {
|
|
213
|
+
focusableElements = getFocusableElements(modalEl)
|
|
202
214
|
|
|
203
215
|
// Focus the close button or first focusable element
|
|
204
216
|
requestAnimationFrame(() => {
|
|
205
|
-
const closeButton =
|
|
217
|
+
const closeButton = modalEl.querySelector('button[aria-label="Close modal"]') as HTMLElement
|
|
206
218
|
if (closeButton) {
|
|
207
219
|
closeButton.focus()
|
|
208
220
|
}
|
|
@@ -211,7 +223,7 @@ watch(isOpen, async (newVal) => {
|
|
|
211
223
|
}
|
|
212
224
|
else {
|
|
213
225
|
// If no focusable elements, focus the modal itself
|
|
214
|
-
|
|
226
|
+
modalEl.focus()
|
|
215
227
|
}
|
|
216
228
|
})
|
|
217
229
|
}
|
|
@@ -243,10 +255,10 @@ const handleBackdropClick = useDebounceFn((event: MouseEvent) => {
|
|
|
243
255
|
<template>
|
|
244
256
|
<ClientOnly>
|
|
245
257
|
<transition
|
|
246
|
-
enter-active-class="duration-
|
|
258
|
+
enter-active-class="duration-150 ease-out"
|
|
247
259
|
enter-from-class="opacity-0"
|
|
248
260
|
enter-to-class="opacity-100"
|
|
249
|
-
leave-active-class="duration-
|
|
261
|
+
leave-active-class="duration-100 ease-in"
|
|
250
262
|
leave-from-class="opacity-100"
|
|
251
263
|
leave-to-class="opacity-0"
|
|
252
264
|
>
|
|
@@ -262,14 +274,14 @@ const handleBackdropClick = useDebounceFn((event: MouseEvent) => {
|
|
|
262
274
|
>
|
|
263
275
|
<!-- Backdrop with click to close functionality -->
|
|
264
276
|
<div
|
|
265
|
-
class="
|
|
277
|
+
:class="backdropClasses"
|
|
266
278
|
:style="{ zIndex }"
|
|
267
279
|
@click="handleBackdropClick"
|
|
268
280
|
>
|
|
269
281
|
<!-- Modal panel -->
|
|
270
282
|
<div
|
|
271
283
|
ref="modalRef"
|
|
272
|
-
:class="
|
|
284
|
+
:class="modalPanelClasses"
|
|
273
285
|
:style="{ zIndex }"
|
|
274
286
|
tabindex="-1"
|
|
275
287
|
@click.stop
|
|
@@ -277,17 +289,17 @@ const handleBackdropClick = useDebounceFn((event: MouseEvent) => {
|
|
|
277
289
|
<!-- Header with title if provided -->
|
|
278
290
|
<div
|
|
279
291
|
v-if="title"
|
|
280
|
-
class="flex items-center justify-between p-2 w-full border-b rounded-t dark:border-fv-neutral-700"
|
|
292
|
+
class="flex items-center justify-between p-2 md:p-3 w-full border-b rounded-t dark:border-fv-neutral-700"
|
|
281
293
|
>
|
|
282
294
|
<slot name="before" />
|
|
283
295
|
<h2
|
|
284
296
|
v-if="title"
|
|
285
297
|
:id="`${props.id}-title`"
|
|
286
|
-
class="text-xl font-semibold text-fv-neutral-900 dark:text-white"
|
|
298
|
+
class="text-lg md:text-xl font-semibold text-fv-neutral-900 dark:text-white"
|
|
287
299
|
v-html="title"
|
|
288
300
|
/>
|
|
289
301
|
<button
|
|
290
|
-
class="text-fv-neutral-400 bg-transparent hover:bg-fv-neutral-200 hover:text-fv-neutral-900 rounded-lg text-sm w-8 h-8 ml-auto inline-flex justify-center items-center dark:hover:bg-fv-neutral-600 dark:hover:text-white"
|
|
302
|
+
class="text-fv-neutral-400 bg-transparent hover:bg-fv-neutral-200 hover:text-fv-neutral-900 rounded-lg text-sm w-8 h-8 ml-auto inline-flex justify-center items-center dark:hover:bg-fv-neutral-600 dark:hover:text-white transition-colors duration-200"
|
|
291
303
|
aria-label="Close modal"
|
|
292
304
|
@click="setModal(false)"
|
|
293
305
|
>
|
|
@@ -295,7 +307,7 @@ const handleBackdropClick = useDebounceFn((event: MouseEvent) => {
|
|
|
295
307
|
</button>
|
|
296
308
|
</div>
|
|
297
309
|
<!-- Content area -->
|
|
298
|
-
<div :class="`p-2 space-y-3 flex-grow ${ofy}`">
|
|
310
|
+
<div :class="`p-2 md:p-4 space-y-3 flex-grow ${ofy}`">
|
|
299
311
|
<slot />
|
|
300
312
|
</div>
|
|
301
313
|
</div>
|
|
@@ -282,12 +282,13 @@ onMounted(() => {
|
|
|
282
282
|
</div>
|
|
283
283
|
<input
|
|
284
284
|
v-else
|
|
285
|
+
:id="`jump-input-${id}`"
|
|
285
286
|
v-model="jumpPageValue"
|
|
286
287
|
type="number"
|
|
287
288
|
:min="1"
|
|
288
289
|
:max="items.page_max"
|
|
289
290
|
:class="`pagination-jump-input ${inputWidthClass}`"
|
|
290
|
-
placeholder=""
|
|
291
|
+
placeholder="#"
|
|
291
292
|
@keydown="handleJumpInputKeydown"
|
|
292
293
|
@blur="showJumpInput = false; jumpPageValue = ''"
|
|
293
294
|
>
|
|
@@ -336,12 +337,13 @@ onMounted(() => {
|
|
|
336
337
|
</div>
|
|
337
338
|
<input
|
|
338
339
|
v-else
|
|
340
|
+
:id="`jump-input-${id}`"
|
|
339
341
|
v-model="jumpPageValue"
|
|
340
342
|
type="number"
|
|
341
343
|
:min="1"
|
|
342
344
|
:max="items.page_max"
|
|
343
345
|
:class="`pagination-jump-input ${inputWidthClass}`"
|
|
344
|
-
placeholder=""
|
|
346
|
+
placeholder="#"
|
|
345
347
|
@keydown="handleJumpInputKeydown"
|
|
346
348
|
@blur="showJumpInput = false; jumpPageValue = ''"
|
|
347
349
|
>
|
|
@@ -366,12 +368,13 @@ onMounted(() => {
|
|
|
366
368
|
</div>
|
|
367
369
|
<input
|
|
368
370
|
v-else
|
|
371
|
+
:id="`jump-input-${id}`"
|
|
369
372
|
v-model="jumpPageValue"
|
|
370
373
|
type="number"
|
|
371
374
|
:min="1"
|
|
372
375
|
:max="items.page_max"
|
|
373
376
|
:class="`pagination-jump-input ${inputWidthClass}`"
|
|
374
|
-
placeholder=""
|
|
377
|
+
placeholder="#"
|
|
375
378
|
@keydown="handleJumpInputKeydown"
|
|
376
379
|
@blur="showJumpInput = false; jumpPageValue = ''"
|
|
377
380
|
>
|
package/composables/rest.ts
CHANGED
|
@@ -27,15 +27,19 @@ export interface APIResult {
|
|
|
27
27
|
status?: number
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
//
|
|
30
|
+
// Use WeakMap for caches to allow garbage collection of unused entries
|
|
31
31
|
const urlParseCache = new Map<string, string>()
|
|
32
32
|
|
|
33
|
-
// Global request hash cache
|
|
33
|
+
// Global request hash cache with size limit to prevent memory leaks
|
|
34
34
|
const globalHashCache = new Map<string, number>()
|
|
35
|
+
const MAX_HASH_CACHE_SIZE = 1000
|
|
35
36
|
|
|
36
37
|
// Track in-flight requests to avoid duplicates
|
|
37
38
|
const inFlightRequests = new Map<number, Promise<any>>()
|
|
38
39
|
|
|
40
|
+
// Reusable TextEncoder instance
|
|
41
|
+
const textEncoder = new TextEncoder()
|
|
42
|
+
|
|
39
43
|
// Detect if we're in SSR mode once and cache the result
|
|
40
44
|
let isSSRMode: boolean | null = null
|
|
41
45
|
|
|
@@ -73,7 +77,7 @@ function stringifyParams(params?: RestParams): string {
|
|
|
73
77
|
return params ? JSON.stringify(params) : ''
|
|
74
78
|
}
|
|
75
79
|
|
|
76
|
-
// Compute request hash with global caching
|
|
80
|
+
// Compute request hash with global caching and size limit
|
|
77
81
|
function computeRequestHash(url: string, method: RestMethod, params?: RestParams): number {
|
|
78
82
|
const cacheKey = `${url}|${method}|${stringifyParams(params)}`
|
|
79
83
|
|
|
@@ -83,17 +87,25 @@ function computeRequestHash(url: string, method: RestMethod, params?: RestParams
|
|
|
83
87
|
const urlForHash = getUrlForHash(url)
|
|
84
88
|
const hash = stringHash(urlForHash + method + stringifyParams(params))
|
|
85
89
|
|
|
90
|
+
// Implement LRU-like cache eviction when size limit is reached
|
|
91
|
+
if (globalHashCache.size >= MAX_HASH_CACHE_SIZE) {
|
|
92
|
+
// Delete the first (oldest) entry
|
|
93
|
+
const firstKey = globalHashCache.keys().next().value
|
|
94
|
+
if (firstKey !== undefined) {
|
|
95
|
+
globalHashCache.delete(firstKey)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
86
99
|
globalHashCache.set(cacheKey, hash)
|
|
87
100
|
return hash
|
|
88
101
|
}
|
|
89
102
|
|
|
90
|
-
function str2ab(str) {
|
|
91
|
-
|
|
92
|
-
return encoder.encode(str)
|
|
103
|
+
function str2ab(str: string): Uint8Array {
|
|
104
|
+
return textEncoder.encode(str)
|
|
93
105
|
}
|
|
94
106
|
|
|
95
|
-
// Create HMAC signature
|
|
96
|
-
async function createHMACSignature(secret, data) {
|
|
107
|
+
// Create HMAC signature with proper typing
|
|
108
|
+
async function createHMACSignature(secret: string, data: string): Promise<string> {
|
|
97
109
|
const key = await crypto.subtle.importKey(
|
|
98
110
|
'raw',
|
|
99
111
|
str2ab(secret),
|
|
@@ -122,10 +134,13 @@ params?: RestParams,
|
|
|
122
134
|
// Pre-check for server rendering state
|
|
123
135
|
const isSSR = isServerRendered()
|
|
124
136
|
|
|
125
|
-
// Handle API error response consistently
|
|
137
|
+
// Handle API error response consistently - memoize emitter functions
|
|
138
|
+
const emitMainLoading = (value: boolean) => eventBus.emit('main-loading', value)
|
|
139
|
+
const emitRestError = (result: any) => eventBus.emit('rest-error', result)
|
|
140
|
+
|
|
126
141
|
function handleErrorResult<ResultType extends APIResult>(result: ResultType): Promise<ResultType> {
|
|
127
|
-
|
|
128
|
-
|
|
142
|
+
emitMainLoading(false)
|
|
143
|
+
emitRestError(result)
|
|
129
144
|
return Promise.reject(result)
|
|
130
145
|
}
|
|
131
146
|
|
|
@@ -197,8 +212,8 @@ params?: RestParams,
|
|
|
197
212
|
serverRouter.addResult(requestHash, restError)
|
|
198
213
|
}
|
|
199
214
|
|
|
200
|
-
|
|
201
|
-
|
|
215
|
+
emitMainLoading(false)
|
|
216
|
+
emitRestError(restError)
|
|
202
217
|
return Promise.resolve(restError)
|
|
203
218
|
}
|
|
204
219
|
finally {
|
package/composables/seo.ts
CHANGED
|
@@ -27,22 +27,29 @@ export interface LazyHead {
|
|
|
27
27
|
twitterCreator?: string
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
//
|
|
30
|
+
// Cache for processed image URLs
|
|
31
|
+
const processedImageUrlCache = new Map<string, string>()
|
|
32
|
+
|
|
33
|
+
// Helper function to process image URLs with caching
|
|
31
34
|
function processImageUrl(image: string | undefined, imageType: string | undefined): string | undefined {
|
|
32
35
|
if (!image) return undefined
|
|
33
36
|
|
|
37
|
+
// Create cache key
|
|
38
|
+
const cacheKey = `${image}|${imageType || ''}`
|
|
39
|
+
const cached = processedImageUrlCache.get(cacheKey)
|
|
40
|
+
if (cached) return cached
|
|
41
|
+
|
|
42
|
+
let result: string
|
|
34
43
|
if (image.includes('?vars=')) {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
41
|
-
else {
|
|
42
|
-
return image.replace('?vars=', '.png?vars=')
|
|
43
|
-
}
|
|
44
|
+
const extension = imageType ? imageType.replace('image/', '') : 'png'
|
|
45
|
+
result = image.replace('?vars=', `.${extension}?vars=`)
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
result = image
|
|
44
49
|
}
|
|
45
|
-
|
|
50
|
+
|
|
51
|
+
processedImageUrlCache.set(cacheKey, result)
|
|
52
|
+
return result
|
|
46
53
|
}
|
|
47
54
|
|
|
48
55
|
// Helper function to normalize image type
|
|
@@ -56,6 +63,11 @@ function normalizeImageType(imageType: string | undefined): 'image/jpeg' | 'imag
|
|
|
56
63
|
return 'image/png'
|
|
57
64
|
}
|
|
58
65
|
|
|
66
|
+
// Precomputed alternate locale URL template
|
|
67
|
+
function ALTERNATE_LOCALE_TEMPLATE(scheme: string, host: string, locale: string, path: string) {
|
|
68
|
+
return `${scheme}://${host}/l/${locale}${path}`
|
|
69
|
+
}
|
|
70
|
+
|
|
59
71
|
// eslint-disable-next-line unused-imports/no-unused-vars
|
|
60
72
|
export function useSeo(seoData: Ref<LazyHead>, initial: boolean = false) {
|
|
61
73
|
const currentLocale = getLocale()
|
|
@@ -106,18 +118,26 @@ export function useSeo(seoData: Ref<LazyHead>, initial: boolean = false) {
|
|
|
106
118
|
})
|
|
107
119
|
*/
|
|
108
120
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
121
|
+
// Optimize alternate locale generation
|
|
122
|
+
if (seoData.value.alternateLocales?.length) {
|
|
123
|
+
const pathWithoutPrefix = urlBase.value.path.replace(urlBase.value.prefix, '')
|
|
124
|
+
|
|
125
|
+
for (const locale of seoData.value.alternateLocales) {
|
|
126
|
+
if (locale !== currentLocale) {
|
|
127
|
+
links.push({
|
|
128
|
+
rel: 'alternate',
|
|
129
|
+
hreflang: locale,
|
|
130
|
+
href: ALTERNATE_LOCALE_TEMPLATE(
|
|
131
|
+
urlBase.value.scheme,
|
|
132
|
+
urlBase.value.host,
|
|
133
|
+
locale,
|
|
134
|
+
pathWithoutPrefix,
|
|
135
|
+
),
|
|
136
|
+
key: `alternate-${locale}`,
|
|
137
|
+
})
|
|
138
|
+
}
|
|
119
139
|
}
|
|
120
|
-
}
|
|
140
|
+
}
|
|
121
141
|
|
|
122
142
|
/*
|
|
123
143
|
if (seoData.value.image) {
|
|
@@ -134,25 +154,30 @@ export function useSeo(seoData: Ref<LazyHead>, initial: boolean = false) {
|
|
|
134
154
|
},
|
|
135
155
|
})
|
|
136
156
|
|
|
157
|
+
// Create memoized getters for frequently accessed values
|
|
158
|
+
const seoTitle = computed(() => seoData.value.title)
|
|
159
|
+
const seoDescription = computed(() => seoData.value.description)
|
|
160
|
+
const seoType = computed(() => seoData.value.type || 'website')
|
|
161
|
+
const twitterCreator = computed(() => seoData.value.twitterCreator)
|
|
162
|
+
|
|
137
163
|
useSeoMeta({
|
|
138
164
|
ogUrl: () => urlBase.value.canonical,
|
|
139
165
|
ogLocale: () => localeForOg.value,
|
|
140
|
-
robots:
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
ogDescription: () => seoData.value.description,
|
|
166
|
+
robots: 'index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1',
|
|
167
|
+
title: () => seoTitle.value || '',
|
|
168
|
+
ogTitle: () => seoTitle.value,
|
|
169
|
+
ogDescription: () => seoDescription.value,
|
|
145
170
|
twitterCard: 'summary_large_image',
|
|
146
171
|
ogSiteName: () => seoData.value.name,
|
|
147
|
-
twitterTitle: () =>
|
|
148
|
-
twitterDescription: () =>
|
|
172
|
+
twitterTitle: () => seoTitle.value,
|
|
173
|
+
twitterDescription: () => seoDescription.value,
|
|
149
174
|
ogImageAlt: () => imageAlt.value,
|
|
150
175
|
// @ts-expect-error: Type 'string' is not assignable to type 'undefined'.
|
|
151
|
-
ogType: () =>
|
|
152
|
-
twitterCreator: () =>
|
|
153
|
-
twitterSite: () =>
|
|
176
|
+
ogType: () => seoType.value,
|
|
177
|
+
twitterCreator: () => twitterCreator.value,
|
|
178
|
+
twitterSite: () => twitterCreator.value,
|
|
154
179
|
twitterImageAlt: () => imageAlt.value,
|
|
155
|
-
description: () =>
|
|
180
|
+
description: () => seoDescription.value,
|
|
156
181
|
keywords: () => seoData.value.keywords,
|
|
157
182
|
articlePublishedTime: () => seoData.value.published,
|
|
158
183
|
articleModifiedTime: () => seoData.value.modified,
|
|
@@ -160,8 +185,6 @@ export function useSeo(seoData: Ref<LazyHead>, initial: boolean = false) {
|
|
|
160
185
|
ogImageUrl: () => imageUrl.value,
|
|
161
186
|
ogImageType: () => imageType.value,
|
|
162
187
|
twitterImageUrl: () => imageUrl.value,
|
|
163
|
-
twitterImageType()
|
|
164
|
-
return imageType.value
|
|
165
|
-
},
|
|
188
|
+
twitterImageType: () => imageType.value,
|
|
166
189
|
})
|
|
167
190
|
}
|
package/composables/ssr.ts
CHANGED
|
@@ -22,10 +22,15 @@ export interface SSRResult {
|
|
|
22
22
|
redirect?: string
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
// Cache SSR state to avoid repeated checks
|
|
26
|
+
let cachedSSRState: boolean | null = null
|
|
27
|
+
|
|
25
28
|
export function isServerRendered() {
|
|
29
|
+
if (cachedSSRState !== null) return cachedSSRState
|
|
30
|
+
|
|
26
31
|
const state = getInitialState()
|
|
27
|
-
|
|
28
|
-
return
|
|
32
|
+
cachedSSRState = !!(state && state.isSSR)
|
|
33
|
+
return cachedSSRState
|
|
29
34
|
}
|
|
30
35
|
|
|
31
36
|
export function initVueClient(router: Router, pinia: Pinia) {
|
|
@@ -41,8 +46,9 @@ export async function initVueServer(
|
|
|
41
46
|
callback: Function,
|
|
42
47
|
options: { url?: string } = {},
|
|
43
48
|
) {
|
|
44
|
-
|
|
45
|
-
|
|
49
|
+
// Cache URL object to avoid multiple calls
|
|
50
|
+
const urlObj = getURL()
|
|
51
|
+
const url = options.url || `${getPath()}${urlObj.Query ? `?${urlObj.Query}` : ''}`
|
|
46
52
|
const { app, router, head, pinia } = await createApp(true)
|
|
47
53
|
const serverRouter = useServerRouter(pinia)
|
|
48
54
|
serverRouter._setRouter(router)
|
package/package.json
CHANGED
package/stores/serverRouter.ts
CHANGED
|
@@ -5,7 +5,7 @@ export interface ServerRouterState {
|
|
|
5
5
|
_router: any | null
|
|
6
6
|
status: number
|
|
7
7
|
redirect?: string
|
|
8
|
-
results:
|
|
8
|
+
results: Map<number, any>
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
export const useServerRouter = defineStore('routerStore', {
|
|
@@ -15,7 +15,7 @@ export const useServerRouter = defineStore('routerStore', {
|
|
|
15
15
|
_router: null,
|
|
16
16
|
status: 200,
|
|
17
17
|
redirect: undefined,
|
|
18
|
-
results:
|
|
18
|
+
results: new Map(),
|
|
19
19
|
}) as ServerRouterState,
|
|
20
20
|
getters: {
|
|
21
21
|
currentRoute: state => state._router?.currentRoute,
|
|
@@ -48,16 +48,22 @@ export const useServerRouter = defineStore('routerStore', {
|
|
|
48
48
|
this._router?.go(1)
|
|
49
49
|
},
|
|
50
50
|
addResult(id: number, result: any) {
|
|
51
|
-
|
|
51
|
+
// Limit results cache size to prevent memory leaks
|
|
52
|
+
if (this.results.size > 100) {
|
|
53
|
+
// Remove oldest entries (first 10)
|
|
54
|
+
const keysToDelete = Array.from(this.results.keys()).slice(0, 10)
|
|
55
|
+
keysToDelete.forEach(key => this.results.delete(key))
|
|
56
|
+
}
|
|
57
|
+
this.results.set(id, result)
|
|
52
58
|
},
|
|
53
59
|
hasResult(id: number) {
|
|
54
|
-
return this.results
|
|
60
|
+
return this.results.has(id)
|
|
55
61
|
},
|
|
56
62
|
getResult(id: number) {
|
|
57
|
-
return this.results
|
|
63
|
+
return this.results.get(id)
|
|
58
64
|
},
|
|
59
65
|
removeResult(id: number) {
|
|
60
|
-
|
|
66
|
+
this.results.delete(id)
|
|
61
67
|
},
|
|
62
68
|
},
|
|
63
69
|
})
|
package/stores/user.ts
CHANGED
|
@@ -3,7 +3,6 @@ import type { RouteLocation } from 'vue-router'
|
|
|
3
3
|
import type { APIResult } from '../composables/rest'
|
|
4
4
|
import { rest } from '@fy-/fws-js'
|
|
5
5
|
import { defineStore } from 'pinia'
|
|
6
|
-
import { computed, shallowRef } from 'vue'
|
|
7
6
|
import { useServerRouter } from './serverRouter'
|
|
8
7
|
|
|
9
8
|
export interface UserStore {
|
|
@@ -15,6 +14,10 @@ let refreshPromise: Promise<void> | null = null
|
|
|
15
14
|
const refreshDebounceTime = 200 // 200ms
|
|
16
15
|
let lastRefreshTime = 0
|
|
17
16
|
|
|
17
|
+
// Cache for API endpoints
|
|
18
|
+
const USER_GET_ENDPOINT = 'User:get'
|
|
19
|
+
const USER_LOGOUT_ENDPOINT = 'User:logout'
|
|
20
|
+
|
|
18
21
|
export const useUserStore = defineStore('userStore', {
|
|
19
22
|
state: (): UserStore => ({
|
|
20
23
|
user: null,
|
|
@@ -34,7 +37,7 @@ export const useUserStore = defineStore('userStore', {
|
|
|
34
37
|
|
|
35
38
|
lastRefreshTime = now
|
|
36
39
|
refreshPromise = new Promise((resolve) => {
|
|
37
|
-
rest(
|
|
40
|
+
rest(USER_GET_ENDPOINT, 'GET')
|
|
38
41
|
.then((user: APIResult) => {
|
|
39
42
|
if (user.result === 'success') {
|
|
40
43
|
this.setUser(user.data)
|
|
@@ -60,7 +63,7 @@ export const useUserStore = defineStore('userStore', {
|
|
|
60
63
|
|
|
61
64
|
async logout() {
|
|
62
65
|
try {
|
|
63
|
-
const user: APIResult = await rest(
|
|
66
|
+
const user: APIResult = await rest(USER_LOGOUT_ENDPOINT, 'POST')
|
|
64
67
|
// In all cases, we set the user to null
|
|
65
68
|
this.setUser(null)
|
|
66
69
|
return user.result === 'success'
|
|
@@ -79,23 +82,24 @@ export const useUserStore = defineStore('userStore', {
|
|
|
79
82
|
|
|
80
83
|
// Shared implementation for route checking to avoid code duplication
|
|
81
84
|
function createUserChecker(path: string, redirectLink: boolean) {
|
|
82
|
-
//
|
|
83
|
-
const router =
|
|
85
|
+
// Get router once instead of creating shallowRef
|
|
86
|
+
const router = useServerRouter()
|
|
87
|
+
|
|
88
|
+
// Pre-build redirect URL template
|
|
89
|
+
const redirectUrl = redirectLink ? `${path}?return_to=` : path
|
|
84
90
|
|
|
85
91
|
return (route: RouteLocation, isAuthenticated: boolean) => {
|
|
86
|
-
|
|
92
|
+
// Early return for most common case
|
|
93
|
+
if (!route.meta?.reqLogin || isAuthenticated) return false
|
|
87
94
|
|
|
88
|
-
if (
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
router.value.push(`${path}?return_to=${route.path}`)
|
|
95
|
-
}
|
|
96
|
-
return true
|
|
95
|
+
if (redirectLink) {
|
|
96
|
+
router.status = 307
|
|
97
|
+
router.push(`${redirectUrl}${route.path}`)
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
router.push(path)
|
|
97
101
|
}
|
|
98
|
-
return
|
|
102
|
+
return true
|
|
99
103
|
}
|
|
100
104
|
}
|
|
101
105
|
|
|
@@ -105,17 +109,16 @@ export async function useUserCheckAsyncSimple(
|
|
|
105
109
|
) {
|
|
106
110
|
const userStore = useUserStore()
|
|
107
111
|
await userStore.refreshUser()
|
|
108
|
-
const isAuth = computed(() => userStore.isAuth)
|
|
109
112
|
const router = useServerRouter()
|
|
110
113
|
const checkUser = createUserChecker(path, redirectLink)
|
|
111
114
|
|
|
112
115
|
// Check current route immediately
|
|
113
|
-
checkUser(router.currentRoute, isAuth
|
|
116
|
+
checkUser(router.currentRoute, userStore.isAuth)
|
|
114
117
|
|
|
115
|
-
// Setup route guard
|
|
118
|
+
// Setup route guard - use arrow function to always get current auth state
|
|
116
119
|
router._router.beforeEach((to: any) => {
|
|
117
120
|
if (to.fullPath !== path) {
|
|
118
|
-
checkUser(to, isAuth
|
|
121
|
+
checkUser(to, userStore.isAuth)
|
|
119
122
|
}
|
|
120
123
|
})
|
|
121
124
|
}
|
|
@@ -123,46 +126,54 @@ export async function useUserCheckAsyncSimple(
|
|
|
123
126
|
export async function useUserCheckAsync(path = '/login', redirectLink = false) {
|
|
124
127
|
const userStore = useUserStore()
|
|
125
128
|
await userStore.refreshUser()
|
|
126
|
-
const isAuth = computed(() => userStore.isAuth)
|
|
127
129
|
const router = useServerRouter()
|
|
128
130
|
const checkUser = createUserChecker(path, redirectLink)
|
|
129
131
|
|
|
130
132
|
// Check current route immediately
|
|
131
|
-
checkUser(router.currentRoute, isAuth
|
|
132
|
-
|
|
133
|
-
// Setup route guards
|
|
134
|
-
|
|
135
|
-
|
|
133
|
+
checkUser(router.currentRoute, userStore.isAuth)
|
|
134
|
+
|
|
135
|
+
// Setup route guards - throttle afterEach refresh
|
|
136
|
+
let afterEachTimeout: NodeJS.Timeout | null = null
|
|
137
|
+
router._router.afterEach(() => {
|
|
138
|
+
if (afterEachTimeout) clearTimeout(afterEachTimeout)
|
|
139
|
+
afterEachTimeout = setTimeout(() => {
|
|
140
|
+
userStore.refreshUser()
|
|
141
|
+
afterEachTimeout = null
|
|
142
|
+
}, 100)
|
|
136
143
|
})
|
|
137
144
|
|
|
138
145
|
router._router.beforeEach((to: any) => {
|
|
139
146
|
if (to.fullPath !== path) {
|
|
140
|
-
checkUser(to, isAuth
|
|
147
|
+
checkUser(to, userStore.isAuth)
|
|
141
148
|
}
|
|
142
149
|
})
|
|
143
150
|
}
|
|
144
151
|
|
|
145
152
|
export function useUserCheck(path = '/login', redirectLink = false) {
|
|
146
153
|
const userStore = useUserStore()
|
|
147
|
-
const isAuth = computed(() => userStore.isAuth)
|
|
148
154
|
const router = useServerRouter()
|
|
149
155
|
const checkUser = createUserChecker(path, redirectLink)
|
|
150
156
|
|
|
151
157
|
// Check current route after refresh
|
|
152
158
|
userStore.refreshUser().then(() => {
|
|
153
159
|
if (router.currentRoute) {
|
|
154
|
-
checkUser(router.currentRoute, isAuth
|
|
160
|
+
checkUser(router.currentRoute, userStore.isAuth)
|
|
155
161
|
}
|
|
156
162
|
})
|
|
157
163
|
|
|
158
|
-
// Setup route guards
|
|
159
|
-
|
|
160
|
-
|
|
164
|
+
// Setup route guards - throttle afterEach refresh
|
|
165
|
+
let afterEachTimeout: NodeJS.Timeout | null = null
|
|
166
|
+
router._router.afterEach(() => {
|
|
167
|
+
if (afterEachTimeout) clearTimeout(afterEachTimeout)
|
|
168
|
+
afterEachTimeout = setTimeout(() => {
|
|
169
|
+
userStore.refreshUser()
|
|
170
|
+
afterEachTimeout = null
|
|
171
|
+
}, 100)
|
|
161
172
|
})
|
|
162
173
|
|
|
163
174
|
router._router.beforeEach((to: any) => {
|
|
164
175
|
if (to.fullPath !== path) {
|
|
165
|
-
checkUser(to, isAuth
|
|
176
|
+
checkUser(to, userStore.isAuth)
|
|
166
177
|
}
|
|
167
178
|
})
|
|
168
179
|
}
|