@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.
- 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 +130 -46
- 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,7 +1,8 @@
|
|
|
1
1
|
<script lang="ts" setup>
|
|
2
2
|
import type { NavLink } from '../../types'
|
|
3
3
|
import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/vue/24/solid'
|
|
4
|
-
import { useStorage } from '@vueuse/core'
|
|
4
|
+
import { useDebounceFn, useStorage } from '@vueuse/core'
|
|
5
|
+
import { computed, shallowRef } from 'vue'
|
|
5
6
|
import { useRoute } from 'vue-router'
|
|
6
7
|
|
|
7
8
|
const props = withDefaults(
|
|
@@ -15,21 +16,48 @@ const props = withDefaults(
|
|
|
15
16
|
baseUrl: '/',
|
|
16
17
|
},
|
|
17
18
|
)
|
|
19
|
+
|
|
18
20
|
const route = useRoute()
|
|
21
|
+
|
|
22
|
+
// Cache storage key to avoid string concatenation
|
|
23
|
+
const storageKey = computed(() => `isOpenSidebar-${props.id}`)
|
|
24
|
+
|
|
25
|
+
// Create a cache for active links to avoid recalculation
|
|
26
|
+
const activeLinkCache = shallowRef(new Map<string, string>())
|
|
27
|
+
|
|
28
|
+
// Optimized link active check with memoization
|
|
19
29
|
function isLinkActive(link: NavLink) {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
30
|
+
const linkTo = link.to
|
|
31
|
+
|
|
32
|
+
// Return from cache if available
|
|
33
|
+
if (activeLinkCache.value.has(linkTo)) {
|
|
34
|
+
return activeLinkCache.value.get(linkTo)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let result = ''
|
|
38
|
+
if (linkTo !== props.baseUrl) {
|
|
39
|
+
if (route.path === linkTo || route.path.includes(linkTo)) {
|
|
40
|
+
result = 'fvside-active'
|
|
23
41
|
}
|
|
24
42
|
}
|
|
25
43
|
else {
|
|
26
|
-
if (route.path ===
|
|
27
|
-
|
|
44
|
+
if (route.path === linkTo) {
|
|
45
|
+
result = 'fvside-active'
|
|
28
46
|
}
|
|
29
47
|
}
|
|
30
|
-
|
|
48
|
+
|
|
49
|
+
// Cache the result
|
|
50
|
+
activeLinkCache.value.set(linkTo, result)
|
|
51
|
+
return result
|
|
31
52
|
}
|
|
32
|
-
|
|
53
|
+
|
|
54
|
+
// Debounce storage updates to prevent rapid toggling
|
|
55
|
+
const isOpen = useStorage(storageKey.value, true)
|
|
56
|
+
|
|
57
|
+
// Debounced toggle function
|
|
58
|
+
const toggleSidebar = useDebounceFn(() => {
|
|
59
|
+
isOpen.value = !isOpen.value
|
|
60
|
+
}, 150)
|
|
33
61
|
</script>
|
|
34
62
|
|
|
35
63
|
<template>
|
|
@@ -38,7 +66,7 @@ const isOpen = useStorage(`isOpenSidebar-${props.id}`, true)
|
|
|
38
66
|
<button
|
|
39
67
|
class="btn neutral defaults"
|
|
40
68
|
aria-controls="side-nav"
|
|
41
|
-
@click="
|
|
69
|
+
@click="toggleSidebar"
|
|
42
70
|
>
|
|
43
71
|
<ArrowLeftIcon v-if="isOpen" />
|
|
44
72
|
<ArrowRightIcon v-else />
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import {
|
|
2
|
+
import { useDebounceFn, useVModel } from '@vueuse/core'
|
|
3
|
+
import { computed, onMounted, shallowRef, watch } from 'vue'
|
|
3
4
|
import { useEventBus } from '../../composables/event-bus'
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -41,23 +42,41 @@ const props = withDefaults(
|
|
|
41
42
|
)
|
|
42
43
|
|
|
43
44
|
/**
|
|
44
|
-
* Refs & Data
|
|
45
|
+
* Refs & Data - using shallowRef for DOM elements for better performance
|
|
45
46
|
*/
|
|
46
|
-
const textInput =
|
|
47
|
-
const isMaxReached =
|
|
48
|
-
const inputContainer =
|
|
47
|
+
const textInput = shallowRef<HTMLElement>()
|
|
48
|
+
const isMaxReached = shallowRef(false)
|
|
49
|
+
const inputContainer = shallowRef<HTMLElement>()
|
|
49
50
|
|
|
50
51
|
const emit = defineEmits(['update:modelValue'])
|
|
51
52
|
|
|
52
53
|
/**
|
|
53
|
-
*
|
|
54
|
+
* Use VueUse's useVModel for more efficient two-way binding
|
|
54
55
|
*/
|
|
55
|
-
const model =
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
56
|
+
const model = useVModel(props, 'modelValue', emit)
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Cache regex patterns to avoid creating them on each input
|
|
60
|
+
*/
|
|
61
|
+
const getSeparatorRegex = (() => {
|
|
62
|
+
let cachedRegex: RegExp | null = null
|
|
63
|
+
let cachedGlobalRegex: RegExp | null = null
|
|
64
|
+
|
|
65
|
+
return (isGlobal = false) => {
|
|
66
|
+
if (isGlobal) {
|
|
67
|
+
if (!cachedGlobalRegex) {
|
|
68
|
+
cachedGlobalRegex = new RegExp(props.separators.join('|'), 'g')
|
|
69
|
+
}
|
|
70
|
+
return cachedGlobalRegex
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
if (!cachedRegex) {
|
|
74
|
+
cachedRegex = new RegExp(props.separators.join('|'))
|
|
75
|
+
}
|
|
76
|
+
return cachedRegex
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
})()
|
|
61
80
|
|
|
62
81
|
/**
|
|
63
82
|
* Compute aria-describedby IDs if help or error exist
|
|
@@ -101,9 +120,11 @@ onMounted(() => {
|
|
|
101
120
|
const eventBus = useEventBus()
|
|
102
121
|
|
|
103
122
|
/**
|
|
104
|
-
* Copy the tags to clipboard
|
|
123
|
+
* Copy the tags to clipboard with debounce to prevent multiple executions
|
|
105
124
|
*/
|
|
106
|
-
|
|
125
|
+
const copyText = useDebounceFn(async () => {
|
|
126
|
+
if (!model.value.length) return
|
|
127
|
+
|
|
107
128
|
const text = model.value.join(', ')
|
|
108
129
|
|
|
109
130
|
try {
|
|
@@ -122,35 +143,33 @@ async function copyText() {
|
|
|
122
143
|
time: 2500,
|
|
123
144
|
})
|
|
124
145
|
}
|
|
125
|
-
}
|
|
146
|
+
}, 300)
|
|
126
147
|
|
|
127
148
|
/**
|
|
128
149
|
* On each character input, check if user typed a separator
|
|
129
150
|
*/
|
|
130
|
-
|
|
151
|
+
const handleInput = useDebounceFn((event: Event) => {
|
|
131
152
|
const inputEvent = event as InputEvent
|
|
132
153
|
if (!inputEvent.data) return
|
|
133
|
-
|
|
134
|
-
if (
|
|
154
|
+
|
|
155
|
+
if (getSeparatorRegex().test(inputEvent.data)) {
|
|
135
156
|
addTag()
|
|
136
157
|
}
|
|
137
|
-
}
|
|
158
|
+
}, 50)
|
|
138
159
|
|
|
139
160
|
/**
|
|
140
|
-
* Add a tag by splitting on the separator
|
|
161
|
+
* Add a tag by splitting on the separator - optimized with fewer operations
|
|
141
162
|
*/
|
|
142
163
|
function addTag() {
|
|
143
164
|
if (!textInput.value || isMaxReached.value) return
|
|
144
165
|
|
|
145
|
-
const separatorsRegex = new RegExp(props.separators.join('|'))
|
|
146
166
|
const textContent = textInput.value.textContent?.trim()
|
|
147
|
-
|
|
148
167
|
if (!textContent) return
|
|
149
168
|
|
|
150
169
|
const newTags = textContent
|
|
151
|
-
.split(
|
|
152
|
-
.map(
|
|
153
|
-
.filter(
|
|
170
|
+
.split(getSeparatorRegex())
|
|
171
|
+
.map(tag => tag.trim())
|
|
172
|
+
.filter(tag => tag.length > 0)
|
|
154
173
|
|
|
155
174
|
// Remove duplicates if noDuplicates is enabled
|
|
156
175
|
const filteredTags = props.noDuplicates
|
|
@@ -160,28 +179,41 @@ function addTag() {
|
|
|
160
179
|
// If maxTags is set, ensure adding tags doesn't exceed the limit
|
|
161
180
|
if (props.maxTags && props.maxTags > 0) {
|
|
162
181
|
const slotsAvailable = props.maxTags - model.value.length
|
|
163
|
-
|
|
182
|
+
if (slotsAvailable <= 0) {
|
|
183
|
+
// If no slots are available, clear input and return
|
|
184
|
+
textInput.value.textContent = ''
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
if (filteredTags.length > slotsAvailable) {
|
|
188
|
+
filteredTags.splice(slotsAvailable)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (filteredTags.length) {
|
|
193
|
+
model.value = [...model.value, ...filteredTags]
|
|
164
194
|
}
|
|
165
195
|
|
|
166
|
-
model.value = [...model.value, ...filteredTags]
|
|
167
196
|
textInput.value.textContent = ''
|
|
168
197
|
}
|
|
169
198
|
|
|
170
199
|
/**
|
|
171
200
|
* Remove a tag by index
|
|
172
201
|
*/
|
|
173
|
-
|
|
202
|
+
const removeTag = useDebounceFn((index: number) => {
|
|
203
|
+
if (index < 0 || index >= model.value.length) return
|
|
204
|
+
|
|
174
205
|
const newTags = [...model.value]
|
|
175
206
|
newTags.splice(index, 1)
|
|
176
207
|
model.value = newTags
|
|
177
208
|
focusInput()
|
|
178
|
-
}
|
|
209
|
+
}, 50)
|
|
179
210
|
|
|
180
211
|
/**
|
|
181
212
|
* Handle backspace/delete on an empty input
|
|
182
213
|
*/
|
|
183
214
|
function removeLastTag() {
|
|
184
215
|
if (!textInput.value) return
|
|
216
|
+
|
|
185
217
|
if (textInput.value.textContent === '') {
|
|
186
218
|
// If input is empty, remove the last tag
|
|
187
219
|
if (model.value.length > 0) {
|
|
@@ -201,16 +233,20 @@ function removeLastTag() {
|
|
|
201
233
|
|
|
202
234
|
/**
|
|
203
235
|
* Place the cursor at the end of the contenteditable text
|
|
236
|
+
* Using requestAnimationFrame for better performance
|
|
204
237
|
*/
|
|
205
238
|
function placeCursorToEnd() {
|
|
206
239
|
if (!textInput.value) return
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
240
|
+
|
|
241
|
+
requestAnimationFrame(() => {
|
|
242
|
+
const range = document.createRange()
|
|
243
|
+
const sel = window.getSelection()
|
|
244
|
+
range.selectNodeContents(textInput.value!)
|
|
245
|
+
range.collapse(false)
|
|
246
|
+
if (!sel) return
|
|
247
|
+
sel.removeAllRanges()
|
|
248
|
+
sel.addRange(range)
|
|
249
|
+
})
|
|
214
250
|
}
|
|
215
251
|
|
|
216
252
|
/**
|
|
@@ -224,32 +260,37 @@ function focusInput() {
|
|
|
224
260
|
}
|
|
225
261
|
|
|
226
262
|
/**
|
|
227
|
-
* Handle pasting text
|
|
263
|
+
* Handle pasting text with debounce
|
|
228
264
|
*/
|
|
229
|
-
|
|
265
|
+
const handlePaste = useDebounceFn((e: ClipboardEvent) => {
|
|
230
266
|
if (!textInput.value || isMaxReached.value) return
|
|
231
267
|
|
|
232
268
|
const clipboardData = e.clipboardData ?? (window as any).clipboardData
|
|
233
269
|
if (!clipboardData) return
|
|
234
270
|
|
|
235
271
|
const text = clipboardData.getData('text')
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
272
|
+
if (!text.trim()) return
|
|
273
|
+
|
|
274
|
+
const pasteText = text.replace(getSeparatorRegex(true), ',')
|
|
275
|
+
|
|
276
|
+
// Set the text content directly, rather than appending
|
|
277
|
+
textInput.value.textContent = pasteText
|
|
239
278
|
e.preventDefault()
|
|
240
279
|
addTag()
|
|
241
|
-
}
|
|
280
|
+
}, 50)
|
|
242
281
|
|
|
243
282
|
/**
|
|
244
|
-
* Handle keyboard navigation between tags
|
|
283
|
+
* Handle keyboard navigation between tags - optimized with element lookup caching
|
|
245
284
|
*/
|
|
246
285
|
function handleKeyNavigation(e: KeyboardEvent, index: number) {
|
|
286
|
+
if (!inputContainer.value) return
|
|
287
|
+
|
|
247
288
|
if (e.key === 'ArrowLeft' && index > 0) {
|
|
248
|
-
const prevTag = inputContainer.value
|
|
289
|
+
const prevTag = inputContainer.value.querySelector(`[data-index="${index - 1}"] button`) as HTMLElement
|
|
249
290
|
if (prevTag) prevTag.focus()
|
|
250
291
|
}
|
|
251
292
|
else if (e.key === 'ArrowRight' && index < model.value.length - 1) {
|
|
252
|
-
const nextTag = inputContainer.value
|
|
293
|
+
const nextTag = inputContainer.value.querySelector(`[data-index="${index + 1}"] button`) as HTMLElement
|
|
253
294
|
if (nextTag) nextTag.focus()
|
|
254
295
|
}
|
|
255
296
|
}
|
package/composables/seo.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Ref } from 'vue'
|
|
2
2
|
import { getLocale, getPrefix, getURL } from '@fy-/fws-js'
|
|
3
3
|
import { useHead, useSeoMeta } from '@unhead/vue'
|
|
4
|
+
import { computed } from 'vue'
|
|
4
5
|
|
|
5
6
|
export interface LazyHead {
|
|
6
7
|
name?: string
|
|
@@ -26,10 +27,53 @@ export interface LazyHead {
|
|
|
26
27
|
twitterCreator?: string
|
|
27
28
|
}
|
|
28
29
|
|
|
30
|
+
// Helper function to process image URLs
|
|
31
|
+
function processImageUrl(image: string | undefined, imageType: string | undefined): string | undefined {
|
|
32
|
+
if (!image) return undefined
|
|
33
|
+
|
|
34
|
+
if (image.includes('?vars=')) {
|
|
35
|
+
if (imageType) {
|
|
36
|
+
return image.replace(
|
|
37
|
+
'?vars=',
|
|
38
|
+
`.${imageType.replace('image/', '')}?vars=`,
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
return image.replace('?vars=', '.png?vars=')
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return image
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Helper function to normalize image type
|
|
49
|
+
function normalizeImageType(imageType: string | undefined): 'image/jpeg' | 'image/gif' | 'image/png' | false {
|
|
50
|
+
if (!imageType) return false
|
|
51
|
+
|
|
52
|
+
const type = imageType.includes('image/') ? imageType : `image/${imageType}`
|
|
53
|
+
if (type === 'image/jpeg' || type === 'image/gif' || type === 'image/png') {
|
|
54
|
+
return type as 'image/jpeg' | 'image/gif' | 'image/png'
|
|
55
|
+
}
|
|
56
|
+
return 'image/png'
|
|
57
|
+
}
|
|
58
|
+
|
|
29
59
|
// eslint-disable-next-line unused-imports/no-unused-vars
|
|
30
60
|
export function useSeo(seoData: Ref<LazyHead>, initial: boolean = false) {
|
|
31
61
|
const currentLocale = getLocale()
|
|
32
|
-
|
|
62
|
+
|
|
63
|
+
// Cache the URL components
|
|
64
|
+
const urlBase = computed(() => ({
|
|
65
|
+
scheme: getURL().Scheme,
|
|
66
|
+
host: getURL().Host,
|
|
67
|
+
path: getURL().Path,
|
|
68
|
+
canonical: getURL().Canonical,
|
|
69
|
+
prefix: getPrefix(),
|
|
70
|
+
}))
|
|
71
|
+
|
|
72
|
+
// Memoize common values
|
|
73
|
+
const localeForOg = computed(() => currentLocale?.replace('-', '_'))
|
|
74
|
+
const imageUrl = computed(() => processImageUrl(seoData.value.image, seoData.value.imageType))
|
|
75
|
+
const imageType = computed(() => normalizeImageType(seoData.value.imageType))
|
|
76
|
+
const imageAlt = computed(() => seoData.value.image ? seoData.value.title : undefined)
|
|
33
77
|
|
|
34
78
|
useHead({
|
|
35
79
|
meta: () => {
|
|
@@ -64,9 +108,9 @@ export function useSeo(seoData: Ref<LazyHead>, initial: boolean = false) {
|
|
|
64
108
|
links.push({
|
|
65
109
|
rel: 'alternate',
|
|
66
110
|
hreflang: locale,
|
|
67
|
-
href: `${
|
|
68
|
-
|
|
69
|
-
}/l/${locale}${
|
|
111
|
+
href: `${urlBase.value.scheme}://${
|
|
112
|
+
urlBase.value.host
|
|
113
|
+
}/l/${locale}${urlBase.value.path.replace(urlBase.value.prefix, '')}`,
|
|
70
114
|
key: `alternate-${locale}`,
|
|
71
115
|
})
|
|
72
116
|
}
|
|
@@ -88,12 +132,8 @@ export function useSeo(seoData: Ref<LazyHead>, initial: boolean = false) {
|
|
|
88
132
|
})
|
|
89
133
|
|
|
90
134
|
useSeoMeta({
|
|
91
|
-
ogUrl: () =>
|
|
92
|
-
ogLocale: () =>
|
|
93
|
-
if (currentLocale) {
|
|
94
|
-
return currentLocale.replace('-', '_')
|
|
95
|
-
}
|
|
96
|
-
},
|
|
135
|
+
ogUrl: () => urlBase.value.canonical,
|
|
136
|
+
ogLocale: () => localeForOg.value,
|
|
97
137
|
robots:
|
|
98
138
|
'index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1',
|
|
99
139
|
title: () => seoData.value.title || '',
|
|
@@ -103,97 +143,22 @@ export function useSeo(seoData: Ref<LazyHead>, initial: boolean = false) {
|
|
|
103
143
|
ogSiteName: () => seoData.value.name,
|
|
104
144
|
twitterTitle: () => seoData.value.title,
|
|
105
145
|
twitterDescription: () => seoData.value.description,
|
|
106
|
-
ogImageAlt: () =>
|
|
107
|
-
if (seoData.value.image) {
|
|
108
|
-
return seoData.value.title
|
|
109
|
-
}
|
|
110
|
-
return undefined
|
|
111
|
-
},
|
|
146
|
+
ogImageAlt: () => imageAlt.value,
|
|
112
147
|
// @ts-expect-error: Type 'string' is not assignable to type 'undefined'.
|
|
113
148
|
ogType: () => (seoData.value.type ? seoData.value.type : 'website'),
|
|
114
149
|
twitterCreator: () => seoData.value.twitterCreator,
|
|
115
150
|
twitterSite: () => seoData.value.twitterCreator,
|
|
116
|
-
twitterImageAlt: () =>
|
|
117
|
-
if (seoData.value.image) {
|
|
118
|
-
return seoData.value.title
|
|
119
|
-
}
|
|
120
|
-
return undefined
|
|
121
|
-
},
|
|
151
|
+
twitterImageAlt: () => imageAlt.value,
|
|
122
152
|
description: () => seoData.value.description,
|
|
123
153
|
keywords: () => seoData.value.keywords,
|
|
124
154
|
articlePublishedTime: () => seoData.value.published,
|
|
125
155
|
articleModifiedTime: () => seoData.value.modified,
|
|
126
|
-
ogImageSecureUrl: () =>
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
return seoData.value.image.replace(
|
|
131
|
-
'?vars=',
|
|
132
|
-
`.${seoData.value.imageType.replace('image/', '')}?vars=`,
|
|
133
|
-
)
|
|
134
|
-
}
|
|
135
|
-
else {
|
|
136
|
-
return seoData.value.image.replace('?vars=', '.png?vars=')
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
return seoData.value.image
|
|
140
|
-
}
|
|
141
|
-
},
|
|
142
|
-
ogImageUrl: () => {
|
|
143
|
-
if (seoData.value.image) {
|
|
144
|
-
if (seoData.value.image.includes('?vars=')) {
|
|
145
|
-
if (seoData.value.imageType) {
|
|
146
|
-
return seoData.value.image.replace(
|
|
147
|
-
'?vars=',
|
|
148
|
-
`.${seoData.value.imageType.replace('image/', '')}?vars=`,
|
|
149
|
-
)
|
|
150
|
-
}
|
|
151
|
-
else {
|
|
152
|
-
return seoData.value.image.replace('?vars=', '.png?vars=')
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
return seoData.value.image
|
|
156
|
-
}
|
|
157
|
-
},
|
|
158
|
-
ogImageType: () => {
|
|
159
|
-
if (seoData.value.imageType) {
|
|
160
|
-
const type = seoData.value.imageType.includes('image/')
|
|
161
|
-
? seoData.value.imageType
|
|
162
|
-
: `image/${seoData.value.imageType}`
|
|
163
|
-
if (type === 'image/jpeg' || type === 'image/gif' || type === 'image/png') {
|
|
164
|
-
return type
|
|
165
|
-
}
|
|
166
|
-
return 'image/png'
|
|
167
|
-
}
|
|
168
|
-
return undefined
|
|
169
|
-
},
|
|
170
|
-
twitterImageUrl: () => {
|
|
171
|
-
if (seoData.value.image) {
|
|
172
|
-
if (seoData.value.image.includes('?vars=')) {
|
|
173
|
-
if (seoData.value.imageType) {
|
|
174
|
-
return seoData.value.image.replace(
|
|
175
|
-
'?vars=',
|
|
176
|
-
`.${seoData.value.imageType.replace('image/', '')}?vars=`,
|
|
177
|
-
)
|
|
178
|
-
}
|
|
179
|
-
else {
|
|
180
|
-
return seoData.value.image.replace('?vars=', '.png?vars=')
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
return seoData.value.image
|
|
184
|
-
}
|
|
185
|
-
},
|
|
156
|
+
ogImageSecureUrl: () => imageUrl.value,
|
|
157
|
+
ogImageUrl: () => imageUrl.value,
|
|
158
|
+
ogImageType: () => imageType.value,
|
|
159
|
+
twitterImageUrl: () => imageUrl.value,
|
|
186
160
|
twitterImageType() {
|
|
187
|
-
|
|
188
|
-
const type = seoData.value.imageType.includes('image/')
|
|
189
|
-
? seoData.value.imageType
|
|
190
|
-
: `image/${seoData.value.imageType}`
|
|
191
|
-
if (type === 'image/jpeg' || type === 'image/gif' || type === 'image/png') {
|
|
192
|
-
return type
|
|
193
|
-
}
|
|
194
|
-
return 'image/png'
|
|
195
|
-
}
|
|
196
|
-
return undefined
|
|
161
|
+
return imageType.value
|
|
197
162
|
},
|
|
198
163
|
})
|
|
199
164
|
}
|
|
@@ -2,86 +2,116 @@ import { getLocale } from '@fy-/fws-js'
|
|
|
2
2
|
import { format as formatDateTimeago } from 'timeago.js'
|
|
3
3
|
import { useTranslation } from './translations'
|
|
4
4
|
|
|
5
|
+
// Cache common constants and patterns
|
|
6
|
+
const k = 1024
|
|
7
|
+
const byteSizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
|
|
8
|
+
const dateFormatOptions = {
|
|
9
|
+
date: {
|
|
10
|
+
year: 'numeric',
|
|
11
|
+
month: 'long',
|
|
12
|
+
day: 'numeric',
|
|
13
|
+
},
|
|
14
|
+
datetime: {
|
|
15
|
+
year: 'numeric',
|
|
16
|
+
month: 'long',
|
|
17
|
+
day: 'numeric',
|
|
18
|
+
hour: 'numeric',
|
|
19
|
+
minute: 'numeric',
|
|
20
|
+
second: 'numeric',
|
|
21
|
+
},
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Color cache for contrast calculations to avoid repeated calculations
|
|
25
|
+
const colorContrastCache = new Map<string, string>()
|
|
26
|
+
|
|
5
27
|
function cropText(str: string, ml = 100, end = '...') {
|
|
28
|
+
if (!str) return str
|
|
6
29
|
if (str.length > ml) {
|
|
7
30
|
return `${str.slice(0, ml)}${end}`
|
|
8
31
|
}
|
|
9
32
|
return str
|
|
10
33
|
}
|
|
34
|
+
|
|
11
35
|
function getContrastingTextColor(backgroundColor: string) {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
36
|
+
// Return from cache if available
|
|
37
|
+
if (colorContrastCache.has(backgroundColor)) {
|
|
38
|
+
return colorContrastCache.get(backgroundColor)!
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Input validation
|
|
42
|
+
if (!backgroundColor || backgroundColor.length !== 7 || backgroundColor[0] !== '#') {
|
|
43
|
+
return '#000000'
|
|
44
|
+
}
|
|
15
45
|
|
|
16
|
-
|
|
46
|
+
try {
|
|
47
|
+
const r = Number.parseInt(backgroundColor.substring(1, 3), 16)
|
|
48
|
+
const g = Number.parseInt(backgroundColor.substring(3, 5), 16)
|
|
49
|
+
const b = Number.parseInt(backgroundColor.substring(5, 7), 16)
|
|
17
50
|
|
|
18
|
-
|
|
51
|
+
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
|
|
52
|
+
const result = luminance > 0.5 ? '#000000' : '#FFFFFF'
|
|
53
|
+
|
|
54
|
+
// Cache the result
|
|
55
|
+
colorContrastCache.set(backgroundColor, result)
|
|
56
|
+
return result
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return '#000000'
|
|
60
|
+
}
|
|
19
61
|
}
|
|
62
|
+
|
|
20
63
|
function formatBytes(bytes: number, decimals = 2) {
|
|
21
64
|
if (!+bytes) return '0 Bytes'
|
|
22
65
|
|
|
23
|
-
const k = 1024
|
|
24
66
|
const dm = decimals < 0 ? 0 : decimals
|
|
25
|
-
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
|
|
26
|
-
|
|
27
67
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
|
28
68
|
|
|
29
|
-
return `${Number.parseFloat((bytes / k ** i).toFixed(dm))} ${
|
|
69
|
+
return `${Number.parseFloat((bytes / k ** i).toFixed(dm))} ${byteSizes[i]}`
|
|
30
70
|
}
|
|
31
71
|
|
|
32
|
-
|
|
33
|
-
|
|
72
|
+
// Helper to parse date inputs consistently
|
|
73
|
+
function parseDateInput(dt: Date | string | number): number {
|
|
74
|
+
if (dt instanceof Date) {
|
|
75
|
+
return dt.getTime()
|
|
76
|
+
}
|
|
77
|
+
|
|
34
78
|
if (typeof dt === 'string') {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
_dt = Number.parseInt(dt)
|
|
38
|
-
}
|
|
79
|
+
const parsed = Date.parse(dt)
|
|
80
|
+
return Number.isNaN(parsed) ? Number.parseInt(dt) : parsed
|
|
39
81
|
}
|
|
40
82
|
|
|
83
|
+
return dt
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function formatDate(dt: Date | string | number) {
|
|
87
|
+
const timestamp = parseDateInput(dt)
|
|
41
88
|
const translate = useTranslation()
|
|
89
|
+
|
|
42
90
|
return translate('global_datetime', {
|
|
43
|
-
val: new Date(
|
|
91
|
+
val: new Date(timestamp),
|
|
44
92
|
formatParams: {
|
|
45
|
-
val:
|
|
46
|
-
year: 'numeric',
|
|
47
|
-
month: 'long',
|
|
48
|
-
day: 'numeric',
|
|
49
|
-
},
|
|
93
|
+
val: dateFormatOptions.date,
|
|
50
94
|
},
|
|
51
95
|
})
|
|
52
96
|
}
|
|
97
|
+
|
|
53
98
|
function formatDatetime(dt: Date | string | number) {
|
|
54
|
-
|
|
55
|
-
if (typeof dt === 'string') {
|
|
56
|
-
_dt = Date.parse(dt)
|
|
57
|
-
if (Number.isNaN(_dt)) {
|
|
58
|
-
_dt = Number.parseInt(dt)
|
|
59
|
-
}
|
|
60
|
-
}
|
|
99
|
+
const timestamp = parseDateInput(dt)
|
|
61
100
|
const translate = useTranslation()
|
|
101
|
+
|
|
62
102
|
return translate('global_datetime', {
|
|
63
|
-
val: new Date(
|
|
103
|
+
val: new Date(timestamp),
|
|
64
104
|
formatParams: {
|
|
65
|
-
val:
|
|
66
|
-
year: 'numeric',
|
|
67
|
-
month: 'long',
|
|
68
|
-
day: 'numeric',
|
|
69
|
-
hour: 'numeric',
|
|
70
|
-
minute: 'numeric',
|
|
71
|
-
second: 'numeric',
|
|
72
|
-
},
|
|
105
|
+
val: dateFormatOptions.datetime,
|
|
73
106
|
},
|
|
74
107
|
})
|
|
75
108
|
}
|
|
109
|
+
|
|
76
110
|
function formatTimeago(dt: Date | string | number) {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
_dt = Number.parseInt(dt)
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
return formatDateTimeago(new Date(_dt), getLocale().replace('_', '-'))
|
|
111
|
+
const timestamp = parseDateInput(dt)
|
|
112
|
+
const locale = getLocale().replace('_', '-')
|
|
113
|
+
|
|
114
|
+
return formatDateTimeago(new Date(timestamp), locale)
|
|
85
115
|
}
|
|
86
116
|
|
|
87
117
|
export {
|