@fy-/fws-vue 2.3.48 → 2.3.50

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,4 +1,3 @@
1
- # This file has been completely rewritten and optimized
2
1
  <script setup lang="ts">
3
2
  import type { Component } from 'vue'
4
3
  import type { APIPaging } from '../../composables/rest'
@@ -129,29 +128,46 @@ const currentImage = computed(() => {
129
128
  const imageCount = computed(() => props.images.length)
130
129
  const currentIndex = computed(() => modelValue.value + 1)
131
130
 
132
- // Simple image sizing that relies on flexbox layout to handle the info panel
131
+ /**
132
+ * Dynamically update the size of the displayed image/video
133
+ * so it fits inside the viewport, taking into account:
134
+ * - Top controls
135
+ * - Info panel (if open)
136
+ * - Side panel (if open)
137
+ * - A little padding
138
+ */
133
139
  const updateImageSizes = useDebounceFn(() => {
134
- // Get the main image
135
- const mainImage = document.querySelector('.image-display img') as HTMLImageElement
136
- if (!mainImage) return
140
+ // Only adjust if gallery is open
141
+ if (!isGalleryOpen.value) return
142
+
143
+ // Find the container and the main media element (img or video)
144
+ const container = document.querySelector('.image-display') as HTMLDivElement
145
+ if (!container) return
146
+ const mainMedia = container.querySelector('img, video') as HTMLElement | null
147
+ if (!mainMedia) return
137
148
 
138
149
  // Standard padding
139
150
  const padding = 24
140
151
 
141
- // Handle width constraints
152
+ // Side panel width if visible
142
153
  const sidebarWidth = sidePanel.value ? 256 : 0
154
+
155
+ // Calculate available width
143
156
  const availableWidth = windowWidth.value - sidebarWidth - padding * 2
144
157
 
145
- // Set width constraints
146
- mainImage.style.maxWidth = windowWidth.value <= 768
147
- ? '90vw'
148
- : `${availableWidth}px`
158
+ // Set max width
159
+ mainMedia.style.maxWidth
160
+ = windowWidth.value <= 768 ? '90vw' : `${availableWidth}px`
149
161
 
150
- // Let height be auto to preserve aspect ratio and fit in flexbox container
151
- mainImage.style.height = 'auto'
162
+ // Preserve aspect ratio; let height be auto
163
+ mainMedia.style.height = 'auto'
152
164
 
153
- // Set a reasonable max-height if needed
154
- mainImage.style.maxHeight = '75vh'
165
+ // Deduct top controls height + info panel height (if open) + padding
166
+ const topSpace = topControlsHeight.value || 0
167
+ const infoSpace = infoPanel.value ? (infoPanelHeight.value || 0) : 0
168
+ const availableHeight = windowHeight.value - topSpace - infoSpace - padding * 2
169
+
170
+ mainMedia.style.maxHeight = `${availableHeight}px`
155
171
  }, 50)
156
172
 
157
173
  // Modal controls
@@ -164,8 +180,6 @@ function setModal(value: boolean) {
164
180
  useEventListener(document, 'keydown', handleKeyboardInput)
165
181
  useEventListener(document, 'keyup', handleKeyboardRelease)
166
182
  }
167
-
168
- // No longer auto-hide controls on mobile
169
183
  }
170
184
  else {
171
185
  if (props.onClose) props.onClose()
@@ -185,7 +199,6 @@ function setModal(value: boolean) {
185
199
  }
186
200
  isGalleryOpen.value = value
187
201
  showControls.value = true
188
- // Don't reset info panel state when opening/closing
189
202
  }
190
203
 
191
204
  // Open gallery with debounce to prevent accidental double-clicks
@@ -207,7 +220,6 @@ const openGalleryImage = useDebounceFn((index: number | undefined) => {
207
220
  // Navigation functions
208
221
  function goNextImage() {
209
222
  direction.value = 'next'
210
-
211
223
  if (modelValue.value < props.images.length - 1) {
212
224
  modelValue.value++
213
225
  }
@@ -216,7 +228,6 @@ function goNextImage() {
216
228
  }
217
229
  resetControlsTimer()
218
230
 
219
- // Force image sizing update after navigation
220
231
  nextTick(() => {
221
232
  updateImageSizes()
222
233
  })
@@ -224,7 +235,6 @@ function goNextImage() {
224
235
 
225
236
  function goPrevImage() {
226
237
  direction.value = 'prev'
227
-
228
238
  if (modelValue.value > 0) {
229
239
  modelValue.value--
230
240
  }
@@ -233,7 +243,6 @@ function goPrevImage() {
233
243
  }
234
244
  resetControlsTimer()
235
245
 
236
- // Force image sizing update after navigation
237
246
  nextTick(() => {
238
247
  updateImageSizes()
239
248
  })
@@ -245,11 +254,6 @@ function resetControlsTimer() {
245
254
  showControls.value = true
246
255
  }
247
256
 
248
- // eslint-disable-next-line unused-imports/no-unused-vars
249
- function toggleControls() {
250
- showControls.value = !showControls.value
251
- }
252
-
253
257
  function toggleInfoPanel() {
254
258
  infoPanel.value = !infoPanel.value
255
259
  resetControlsTimer()
@@ -257,10 +261,8 @@ function toggleInfoPanel() {
257
261
  // Update layout immediately AND after nextTick to ensure DOM updates
258
262
  updateImageSizes()
259
263
 
260
- // Schedule multiple updates to handle any transition effects
261
264
  nextTick(() => {
262
265
  updateImageSizes()
263
-
264
266
  // Additional delayed updates to catch transitions
265
267
  setTimeout(() => updateImageSizes(), 50)
266
268
  setTimeout(() => updateImageSizes(), 300)
@@ -271,7 +273,6 @@ function toggleSidePanel() {
271
273
  sidePanel.value = !sidePanel.value
272
274
  resetControlsTimer()
273
275
 
274
- // Update layout after panel toggle
275
276
  nextTick(() => {
276
277
  updateImageSizes()
277
278
  })
@@ -283,7 +284,6 @@ function toggleFullscreen() {
283
284
  enterFullscreen()
284
285
  .then(() => {
285
286
  isFullscreen.value = true
286
- // Give browser time to adjust fullscreen before updating sizing
287
287
  if (fullscreenResizeTimeout) clearTimeout(fullscreenResizeTimeout)
288
288
  fullscreenResizeTimeout = window.setTimeout(() => {
289
289
  updateImageSizes()
@@ -311,12 +311,11 @@ const touchStart = useDebounceFn((event: TouchEvent) => {
311
311
  const touch = event.touches[0]
312
312
  const targetElement = touch.target as HTMLElement
313
313
 
314
- // Store start time for tap detection
315
314
  touchStartTime.value = Date.now()
316
315
 
317
- // Check if the touch started on an interactive element
316
+ // Ignore swipes if starting on an interactive element
318
317
  if (targetElement.closest('button, a, input, textarea, select')) {
319
- return // Don't handle swipe if interacting with controls
318
+ return
320
319
  }
321
320
 
322
321
  start.x = touch.screenX
@@ -328,9 +327,9 @@ const touchEnd = useDebounceFn((event: TouchEvent) => {
328
327
  const targetElement = touch.target as HTMLElement
329
328
  const touchDuration = Date.now() - touchStartTime.value
330
329
 
331
- // Check if the touch ended on an interactive element
330
+ // Ignore swipes if ending on an interactive element
332
331
  if (targetElement.closest('button, a, input, textarea, select')) {
333
- return // Don't handle swipe if interacting with controls
332
+ return
334
333
  }
335
334
 
336
335
  const end = { x: touch.screenX, y: touch.screenY }
@@ -338,12 +337,12 @@ const touchEnd = useDebounceFn((event: TouchEvent) => {
338
337
  const diffX = start.x - end.x
339
338
  const diffY = start.y - end.y
340
339
 
341
- // For taps, we don't toggle controls anymore - they always stay visible
340
+ // If it's a quick tap (not a swipe), do nothing
342
341
  if (Math.abs(diffX) < 10 && Math.abs(diffY) < 10 && touchDuration < 300) {
343
342
  return
344
343
  }
345
344
 
346
- // Add a threshold to prevent accidental swipes
345
+ // Left/right swipe
347
346
  if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 50) {
348
347
  if (diffX > 0) {
349
348
  goNextImage()
@@ -401,14 +400,14 @@ function closeGallery() {
401
400
  setModal(false)
402
401
  }
403
402
 
404
- // Click outside gallery content to close - with debounce to prevent accidental closes
403
+ // Click outside gallery content to close - with debounce
405
404
  const handleBackdropClick = useDebounceFn((event: MouseEvent) => {
406
405
  if (event.target === event.currentTarget) {
407
406
  setModal(false)
408
407
  }
409
408
  }, 200)
410
409
 
411
- // Watch for image changes, fullscreen, or panel visibility changes
410
+ // Watch for changes that affect sizing
412
411
  watch(
413
412
  [
414
413
  currentImage,
@@ -462,7 +461,7 @@ onUnmounted(() => {
462
461
  eventBus.off(`${props.id}GalleryClose`, closeGallery)
463
462
 
464
463
  if (!import.meta.env.SSR) {
465
- document.body.style.overflow = '' // Ensure body scrolling is restored
464
+ document.body.style.overflow = '' // Restore scrolling
466
465
  }
467
466
 
468
467
  // Clear any remaining timeouts
@@ -500,7 +499,7 @@ onUnmounted(() => {
500
499
  aria-modal="true"
501
500
  @click="handleBackdropClick"
502
501
  >
503
- <!-- Top Controls Bar - Fixed at top -->
502
+ <!-- Top Controls Bar -->
504
503
  <transition
505
504
  enter-active-class="transition-opacity duration-300"
506
505
  enter-from-class="opacity-0"
@@ -551,19 +550,19 @@ onUnmounted(() => {
551
550
  </div>
552
551
  </transition>
553
552
 
554
- <!-- Main Gallery Content - Flexbox layout -->
553
+ <!-- Main Gallery Content -->
555
554
  <div
556
555
  ref="galleryContentRef"
557
556
  class="w-full h-full flex flex-col lg:flex-row"
558
557
  >
559
- <!-- Main Image Area with flex column layout -->
558
+ <!-- Main Image Area -->
560
559
  <div
561
560
  class="relative flex-1 h-full flex flex-col"
562
561
  :style="{ paddingTop: `${topControlsHeight}px` }"
563
562
  :class="{ 'lg:pr-64': sidePanel, 'lg:max-w-[calc(100%-16rem)]': sidePanel }"
564
563
  style="max-width: 100%;"
565
564
  >
566
- <!-- Image Navigation Controls - Left -->
565
+ <!-- Left Navigation (Previous) -->
567
566
  <transition
568
567
  enter-active-class="transition-opacity duration-300"
569
568
  enter-from-class="opacity-0"
@@ -586,7 +585,7 @@ onUnmounted(() => {
586
585
  </div>
587
586
  </transition>
588
587
 
589
- <!-- Image Container - flex-grow to fill available space -->
588
+ <!-- Image/Video Container -->
590
589
  <div
591
590
  ref="imageContainerRef"
592
591
  class="flex-grow flex items-center justify-center"
@@ -603,17 +602,19 @@ onUnmounted(() => {
603
602
  :key="`image-display-${modelValue}`"
604
603
  class="image-display relative w-full h-full flex flex-col items-center justify-center"
605
604
  >
606
- <!-- Actual Image/Video Content -->
605
+ <!-- If video -->
607
606
  <template v-if="videoComponent && isVideo(images[modelValue])">
608
607
  <ClientOnly>
609
608
  <component
610
609
  :is="videoComponent"
611
610
  :src="isVideo(images[modelValue])"
612
611
  class="shadow max-w-full h-auto object-contain video-component"
613
- @load="updateImageSizes"
612
+ @loadedmetadata="updateImageSizes"
613
+ @loadeddata="updateImageSizes"
614
614
  />
615
615
  </ClientOnly>
616
616
  </template>
617
+ <!-- Otherwise, image -->
617
618
  <template v-else>
618
619
  <img
619
620
  v-if="modelValueSrc && imageComponent === 'img'"
@@ -635,7 +636,7 @@ onUnmounted(() => {
635
636
  </transition>
636
637
  </div>
637
638
 
638
- <!-- Image Navigation Controls - Right -->
639
+ <!-- Right Navigation (Next) -->
639
640
  <transition
640
641
  enter-active-class="transition-opacity duration-300"
641
642
  enter-from-class="opacity-0"
@@ -659,7 +660,7 @@ onUnmounted(() => {
659
660
  </div>
660
661
  </transition>
661
662
 
662
- <!-- Info Panel directly in flex column flow -->
663
+ <!-- Info Panel -->
663
664
  <transition
664
665
  enter-active-class="transition-all duration-300 ease-out"
665
666
  enter-from-class="opacity-0 transform translate-y-4"
@@ -678,7 +679,7 @@ onUnmounted(() => {
678
679
  </transition>
679
680
  </div>
680
681
 
681
- <!-- Side Thumbnails Panel -->
682
+ <!-- Side Thumbnails Panel (Desktop) -->
682
683
  <transition
683
684
  enter-active-class="transform transition ease-in-out duration-300"
684
685
  enter-from-class="translate-x-full"
@@ -693,12 +694,12 @@ onUnmounted(() => {
693
694
  class="side-panel hidden lg:block absolute right-0 top-0 bottom-0 w-64 overflow-y-auto z-40 cool-scroll"
694
695
  :style="{ paddingTop: `${topControlsHeight + 8}px` }"
695
696
  >
696
- <!-- Paging Controls if needed -->
697
+ <!-- Paging Controls -->
697
698
  <div v-if="paging" class="flex items-center justify-center pt-2">
698
699
  <DefaultPaging :id="id" :items="paging" />
699
700
  </div>
700
701
 
701
- <!-- Thumbnail Grid -->
702
+ <!-- Thumbnails -->
702
703
  <div class="grid grid-cols-2 gap-2 p-2">
703
704
  <div
704
705
  v-for="i in images.length"
@@ -746,7 +747,7 @@ onUnmounted(() => {
746
747
  </div>
747
748
  </transition>
748
749
 
749
- <!-- Mobile Thumbnail Preview (bottom of screen on mobile) -->
750
+ <!-- Mobile Thumbnail Row (Bottom) -->
750
751
  <transition
751
752
  enter-active-class="transition-transform duration-300 ease-out"
752
753
  enter-from-class="translate-y-full"
@@ -799,7 +800,7 @@ onUnmounted(() => {
799
800
  </div>
800
801
  </transition>
801
802
 
802
- <!-- Thumbnail Grid/Mason/Custom Layouts for non-opened gallery -->
803
+ <!-- Thumbnail Grid/Masonry/Custom Layouts if gallery is not open -->
803
804
  <div v-if="mode === 'grid' || mode === 'mason' || mode === 'custom'" class="gallery-grid">
804
805
  <div
805
806
  :class="{
@@ -809,8 +810,11 @@ onUnmounted(() => {
809
810
  }"
810
811
  >
811
812
  <slot name="thumbnail" />
813
+
814
+ <!-- Iterate images -->
812
815
  <template v-for="i in images.length" :key="`g_${id}_${i}`">
813
816
  <template v-if="mode === 'mason'">
817
+ <!-- Example naive "masonry" approach -->
814
818
  <div
815
819
  v-if="i + (1 % gridHeight) === 0"
816
820
  class="masonry-column relative"
@@ -818,7 +822,6 @@ onUnmounted(() => {
818
822
  <div v-if="ranking" class="img-gallery-ranking">
819
823
  {{ i }}
820
824
  </div>
821
-
822
825
  <template v-for="j in gridHeight" :key="`gi_${id}_${i + j}`">
823
826
  <div class="masonry-item">
824
827
  <img
@@ -893,12 +896,10 @@ onUnmounted(() => {
893
896
  </template>
894
897
 
895
898
  <style scoped>
896
- /* Ensure controls stay fixed at top */
897
899
  .controls-bar {
898
900
  height: auto;
899
901
  }
900
902
 
901
- /* Layout container for main image and info panel */
902
903
  .image-container {
903
904
  position: relative;
904
905
  display: flex;
@@ -909,87 +910,74 @@ onUnmounted(() => {
909
910
  width: 100%;
910
911
  }
911
912
 
912
- /* Side panel positioning */
913
913
  .side-panel {
914
914
  height: 100vh;
915
915
  overflow-y: auto;
916
916
  overflow-x: hidden;
917
917
  }
918
918
 
919
- /* Info panel styling */
920
919
  .info-panel {
921
920
  width: 100%;
922
921
  border-top-left-radius: 0.5rem;
923
922
  border-top-right-radius: 0.5rem;
924
923
  }
925
924
 
926
- /* Transition styles for next (right) navigation */
925
+ /* Transitions for next/prev images */
927
926
  .slide-next-enter-active,
928
- .slide-next-leave-active {
927
+ .slide-next-leave-active,
928
+ .slide-prev-enter-active,
929
+ .slide-prev-leave-active {
929
930
  transition:
930
931
  opacity 0.15s,
931
932
  transform 0.15s,
932
933
  filter 0.15s;
933
934
  }
934
935
 
936
+ /* Next (slide from right) */
935
937
  .slide-next-enter-from {
936
938
  opacity: 0;
937
939
  transform: translateX(30px);
938
940
  filter: blur(8px);
939
941
  }
940
-
941
942
  .slide-next-enter-to {
942
943
  opacity: 1;
943
944
  transform: translateX(0);
944
945
  filter: blur(0);
945
946
  }
946
-
947
947
  .slide-next-leave-from {
948
948
  opacity: 1;
949
949
  transform: translateX(0);
950
950
  filter: blur(0);
951
951
  }
952
-
953
952
  .slide-next-leave-to {
954
953
  opacity: 0;
955
954
  transform: translateX(-30px);
956
955
  filter: blur(8px);
957
956
  }
958
957
 
959
- /* Transition styles for prev (left) navigation */
960
- .slide-prev-enter-active,
961
- .slide-prev-leave-active {
962
- transition:
963
- opacity 0.15s,
964
- transform 0.15s,
965
- filter 0.15s;
966
- }
967
-
958
+ /* Prev (slide from left) */
968
959
  .slide-prev-enter-from {
969
960
  opacity: 0;
970
961
  transform: translateX(-30px);
971
962
  filter: blur(8px);
972
963
  }
973
-
974
964
  .slide-prev-enter-to {
975
965
  opacity: 1;
976
966
  transform: translateX(0);
977
967
  filter: blur(0);
978
968
  }
979
-
980
969
  .slide-prev-leave-from {
981
970
  opacity: 1;
982
971
  transform: translateX(0);
983
972
  filter: blur(0);
984
973
  }
985
-
986
974
  .slide-prev-leave-to {
987
975
  opacity: 0;
988
976
  transform: translateX(30px);
989
977
  filter: blur(8px);
990
978
  }
991
979
 
992
- /* Grid layouts for thumbnails */
980
+ /* Grid layouts */
993
981
  .gallery-grid {
994
982
  min-height: 200px;
995
983
  }
@@ -999,32 +987,27 @@ onUnmounted(() => {
999
987
  grid-template-columns: repeat(1, 1fr);
1000
988
  gap: 0.75rem;
1001
989
  }
1002
-
1003
990
  @media (min-width: 480px) {
1004
991
  .standard-grid {
1005
992
  grid-template-columns: repeat(2, 1fr);
1006
993
  }
1007
994
  }
1008
-
1009
995
  @media (min-width: 768px) {
1010
996
  .standard-grid {
1011
997
  grid-template-columns: repeat(3, 1fr);
1012
998
  gap: 1rem;
1013
999
  }
1014
1000
  }
1015
-
1016
1001
  @media (min-width: 1024px) {
1017
1002
  .standard-grid {
1018
1003
  grid-template-columns: repeat(4, 1fr);
1019
1004
  }
1020
1005
  }
1021
-
1022
1006
  @media (min-width: 1280px) {
1023
1007
  .standard-grid {
1024
1008
  grid-template-columns: repeat(5, 1fr);
1025
1009
  }
1026
1010
  }
1027
-
1028
1011
  @media (min-width: 1536px) {
1029
1012
  .standard-grid {
1030
1013
  grid-template-columns: repeat(6, 1fr);
@@ -1036,20 +1019,17 @@ onUnmounted(() => {
1036
1019
  grid-template-columns: repeat(1, 1fr);
1037
1020
  gap: 0.75rem;
1038
1021
  }
1039
-
1040
1022
  @media (min-width: 480px) {
1041
1023
  .masonry-grid {
1042
1024
  grid-template-columns: repeat(2, 1fr);
1043
1025
  }
1044
1026
  }
1045
-
1046
1027
  @media (min-width: 768px) {
1047
1028
  .masonry-grid {
1048
1029
  grid-template-columns: repeat(3, 1fr);
1049
1030
  gap: 1rem;
1050
1031
  }
1051
1032
  }
1052
-
1053
1033
  @media (min-width: 1024px) {
1054
1034
  .masonry-grid {
1055
1035
  grid-template-columns: repeat(4, 1fr);
@@ -1087,13 +1067,12 @@ onUnmounted(() => {
1087
1067
  z-index: 10;
1088
1068
  }
1089
1069
 
1090
- /* Special class to hide scrollbars on mobile */
1070
+ /* Hide scrollbars on mobile */
1091
1071
  .no-scrollbar {
1092
1072
  -ms-overflow-style: none; /* IE and Edge */
1093
1073
  scrollbar-width: none; /* Firefox */
1094
1074
  }
1095
-
1096
1075
  .no-scrollbar::-webkit-scrollbar {
1097
- display: none; /* Chrome, Safari and Opera */
1076
+ display: none; /* Chrome, Safari, and Opera */
1098
1077
  }
1099
1078
  </style>
@@ -30,11 +30,19 @@ export interface APIResult {
30
30
  // Cache for URL parsing to avoid repeated parsing of the same URL
31
31
  const urlParseCache = new Map<string, string>()
32
32
 
33
+ // Global request hash cache shared across all instances
34
+ const globalHashCache = new Map<string, number>()
35
+
36
+ // Track in-flight requests to avoid duplicates
37
+ const inFlightRequests = new Map<number, Promise<any>>()
38
+
39
+ // Detect if we're in SSR mode once and cache the result
40
+ let isSSRMode: boolean | null = null
41
+
33
42
  // Memoized function to extract URL pathname and search for hashing
34
43
  function getUrlForHash(url: string): string {
35
- if (urlParseCache.has(url)) {
36
- return urlParseCache.get(url)!
37
- }
44
+ const cached = urlParseCache.get(url)
45
+ if (cached) return cached
38
46
 
39
47
  let urlForHash: string
40
48
  try {
@@ -49,20 +57,34 @@ function getUrlForHash(url: string): string {
49
57
  return urlForHash
50
58
  }
51
59
 
52
- // Detect if we're in SSR mode once and cache the result
53
- let isSSRMode: boolean | null = null
60
+ // Check SSR mode with caching
54
61
  function checkSSRMode(): boolean {
55
62
  if (isSSRMode === null) {
56
63
  isSSRMode = getMode() === 'ssr'
57
64
  }
65
+
58
66
  return isSSRMode
59
67
  }
60
68
 
61
- // Optimized JSON.stringify for params
69
+ // Fast JSON.stringify for params
62
70
  function stringifyParams(params?: RestParams): string {
63
71
  return params ? JSON.stringify(params) : ''
64
72
  }
65
73
 
74
+ // Compute request hash with global caching
75
+ function computeRequestHash(url: string, method: RestMethod, params?: RestParams): number {
76
+ const cacheKey = `${url}|${method}|${stringifyParams(params)}`
77
+
78
+ const cached = globalHashCache.get(cacheKey)
79
+ if (cached !== undefined) return cached
80
+
81
+ const urlForHash = getUrlForHash(url)
82
+ const hash = stringHash(urlForHash + method + stringifyParams(params))
83
+
84
+ globalHashCache.set(cacheKey, hash)
85
+ return hash
86
+ }
87
+
66
88
  export function useRest(): <ResultType extends APIResult>(
67
89
  url: string,
68
90
  method: RestMethod,
@@ -71,23 +93,8 @@ params?: RestParams,
71
93
  const serverRouter = useServerRouter()
72
94
  const eventBus = useEventBus()
73
95
 
74
- // Cache for request hash computations
75
- const hashCache = new Map<string, number>()
76
-
77
- // Function to compute and cache request hash
78
- function computeRequestHash(url: string, method: RestMethod, params?: RestParams): number {
79
- const cacheKey = `${url}|${method}|${stringifyParams(params)}`
80
-
81
- if (hashCache.has(cacheKey)) {
82
- return hashCache.get(cacheKey)!
83
- }
84
-
85
- const urlForHash = getUrlForHash(url)
86
- const hash = stringHash(urlForHash + method + stringifyParams(params))
87
-
88
- hashCache.set(cacheKey, hash)
89
- return hash
90
- }
96
+ // Pre-check for server rendering state
97
+ const isSSR = isServerRendered()
91
98
 
92
99
  // Handle API error response consistently
93
100
  function handleErrorResult<ResultType extends APIResult>(result: ResultType): Promise<ResultType> {
@@ -104,7 +111,7 @@ params?: RestParams,
104
111
  const requestHash = computeRequestHash(url, method, params)
105
112
 
106
113
  // Check for server-rendered results first
107
- if (isServerRendered()) {
114
+ if (isSSR) {
108
115
  const hasResult = serverRouter.getResult(requestHash)
109
116
  if (hasResult !== undefined) {
110
117
  const result = hasResult as ResultType
@@ -118,35 +125,55 @@ params?: RestParams,
118
125
  }
119
126
  }
120
127
 
121
- try {
122
- const restResult: ResultType = await rest(url, method, params)
128
+ // Check if this exact request is already in-flight
129
+ const existingRequest = inFlightRequests.get(requestHash)
130
+ if (existingRequest) {
131
+ // Return the existing promise for the in-flight request
132
+ return existingRequest
133
+ }
123
134
 
124
- // Store result in server router if in SSR mode
125
- if (checkSSRMode()) {
126
- // Use structuredClone if available for better performance than JSON.parse/stringify
127
- const resultCopy = typeof structuredClone !== 'undefined'
128
- ? structuredClone(restResult)
129
- : JSON.parse(JSON.stringify(restResult))
135
+ // Create the actual request function
136
+ const performRequest = async (): Promise<ResultType> => {
137
+ try {
138
+ const restResult: ResultType = await rest(url, method, params)
130
139
 
131
- serverRouter.addResult(requestHash, resultCopy)
132
- }
140
+ // Store result in server router if in SSR mode
141
+ if (checkSSRMode()) {
142
+ // Use structuredClone if available for better performance than JSON.parse/stringify
143
+ const resultCopy = typeof structuredClone !== 'undefined'
144
+ ? structuredClone(restResult)
145
+ : JSON.parse(JSON.stringify(restResult))
133
146
 
134
- if (restResult.result === 'error') {
135
- return handleErrorResult(restResult)
136
- }
147
+ serverRouter.addResult(requestHash, resultCopy)
148
+ }
137
149
 
138
- return Promise.resolve(restResult)
139
- }
140
- catch (error) {
141
- const restError = error as ResultType
150
+ if (restResult.result === 'error') {
151
+ return handleErrorResult(restResult)
152
+ }
142
153
 
143
- if (checkSSRMode()) {
144
- serverRouter.addResult(requestHash, restError)
154
+ return Promise.resolve(restResult)
145
155
  }
156
+ catch (error) {
157
+ const restError = error as ResultType
158
+
159
+ if (checkSSRMode()) {
160
+ serverRouter.addResult(requestHash, restError)
161
+ }
146
162
 
147
- eventBus.emit('main-loading', false)
148
- eventBus.emit('rest-error', restError)
149
- return Promise.resolve(restError)
163
+ eventBus.emit('main-loading', false)
164
+ eventBus.emit('rest-error', restError)
165
+ return Promise.resolve(restError)
166
+ }
167
+ finally {
168
+ // Always remove from in-flight requests when done
169
+ inFlightRequests.delete(requestHash)
170
+ }
150
171
  }
172
+
173
+ // Track this request as in-flight
174
+ const requestPromise = performRequest()
175
+ inFlightRequests.set(requestHash, requestPromise)
176
+
177
+ return requestPromise
151
178
  }
152
179
  }
@@ -11,86 +11,112 @@ import ru from 'timeago.js/esm/lang/ru'
11
11
  import zh_CN from 'timeago.js/esm/lang/zh_CN'
12
12
  import { useTranslation } from './translations'
13
13
 
14
- // Register common locales
15
- register('fr', fr)
16
- register('fr_FR', fr)
17
- register('es', es)
18
- register('es_ES', es)
19
- register('de', de)
20
- register('de_DE', de)
21
- register('it', it)
22
- register('it_IT', it)
23
- register('nl', nl)
24
- register('nl_NL', nl)
25
- register('ru', ru)
26
- register('ru_RU', ru)
27
- register('ja', ja)
28
- register('ja_JP', ja)
29
- register('zh_CN', zh_CN)
14
+ // Register common locales - do this once at module initialization
15
+ const localeMap = {
16
+ fr,
17
+ fr_FR: fr,
18
+ es,
19
+ es_ES: es,
20
+ de,
21
+ de_DE: de,
22
+ it,
23
+ it_IT: it,
24
+ nl,
25
+ nl_NL: nl,
26
+ ru,
27
+ ru_RU: ru,
28
+ ja,
29
+ ja_JP: ja,
30
+ zh_CN,
31
+ }
32
+
33
+ // Register all locales at once
34
+ Object.entries(localeMap).forEach(([locale, func]) => register(locale, func))
30
35
 
31
36
  // Cache common constants and patterns
32
37
  const k = 1024
33
38
  const byteSizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
39
+
40
+ // Precompute log(k) for formatBytes
41
+ const logK = Math.log(k)
42
+
43
+ // Define date format options once
34
44
  const dateFormatOptions = {
35
- date: {
45
+ date: Object.freeze({
36
46
  year: 'numeric',
37
47
  month: 'long',
38
48
  day: 'numeric',
39
- },
40
- datetime: {
49
+ }),
50
+ datetime: Object.freeze({
41
51
  year: 'numeric',
42
52
  month: 'long',
43
53
  day: 'numeric',
44
54
  hour: 'numeric',
45
55
  minute: 'numeric',
46
56
  second: 'numeric',
47
- },
57
+ }),
48
58
  }
49
59
 
50
60
  // Color cache for contrast calculations to avoid repeated calculations
51
61
  const colorContrastCache = new Map<string, string>()
52
62
 
63
+ // Locale transform cache to avoid repeated replacements
64
+ const localeTransformCache = new Map<string, string>()
65
+
66
+ // Default colors for contrast errors
67
+ const DEFAULT_DARK_COLOR = '#000000'
68
+ const DEFAULT_LIGHT_COLOR = '#FFFFFF'
69
+
53
70
  function cropText(str: string, ml = 100, end = '...') {
54
- if (!str) return str
71
+ if (!str) {
72
+ return str
73
+ }
55
74
  if (str.length > ml) {
56
75
  return `${str.slice(0, ml)}${end}`
57
76
  }
77
+
58
78
  return str
59
79
  }
60
80
 
61
81
  function getContrastingTextColor(backgroundColor: string) {
62
82
  // Return from cache if available
63
- if (colorContrastCache.has(backgroundColor)) {
64
- return colorContrastCache.get(backgroundColor)!
83
+ const cached = colorContrastCache.get(backgroundColor)
84
+ if (cached) {
85
+ return cached
65
86
  }
66
87
 
67
88
  // Input validation
68
89
  if (!backgroundColor || backgroundColor.length !== 7 || backgroundColor[0] !== '#') {
69
- return '#000000'
90
+ return DEFAULT_DARK_COLOR
70
91
  }
71
92
 
72
93
  try {
94
+ // Parse hex color components more efficiently
73
95
  const r = Number.parseInt(backgroundColor.substring(1, 3), 16)
74
96
  const g = Number.parseInt(backgroundColor.substring(3, 5), 16)
75
97
  const b = Number.parseInt(backgroundColor.substring(5, 7), 16)
76
98
 
99
+ // Calculate relative luminance (standard formula for text contrast)
77
100
  const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
78
- const result = luminance > 0.5 ? '#000000' : '#FFFFFF'
101
+ const result = luminance > 0.5 ? DEFAULT_DARK_COLOR : DEFAULT_LIGHT_COLOR
79
102
 
80
103
  // Cache the result
81
104
  colorContrastCache.set(backgroundColor, result)
82
105
  return result
83
106
  }
84
107
  catch {
85
- return '#000000'
108
+ return DEFAULT_DARK_COLOR
86
109
  }
87
110
  }
88
111
 
89
112
  function formatBytes(bytes: number, decimals = 2) {
90
- if (!+bytes) return '0 Bytes'
113
+ if (!+bytes) {
114
+ return '0 Bytes'
115
+ }
91
116
 
92
117
  const dm = decimals < 0 ? 0 : decimals
93
- const i = Math.floor(Math.log(bytes) / Math.log(k))
118
+ // Use precomputed logK for better performance
119
+ const i = Math.floor(Math.log(bytes) / logK)
94
120
 
95
121
  return `${Number.parseFloat((bytes / k ** i).toFixed(dm))} ${byteSizes[i]}`
96
122
  }
@@ -103,7 +129,7 @@ function parseDateInput(dt: Date | string | number): number {
103
129
 
104
130
  if (typeof dt === 'string') {
105
131
  const parsed = Date.parse(dt)
106
- return Number.isNaN(parsed) ? Number.parseInt(dt) : parsed
132
+ return Number.isNaN(parsed) ? Number.parseInt(dt, 10) : parsed
107
133
  }
108
134
 
109
135
  return dt
@@ -137,14 +163,17 @@ function formatTimeago(dt: Date | string | number) {
137
163
  const timestamp = parseDateInput(dt)
138
164
  const dateObj = new Date(timestamp)
139
165
 
140
- // Get browser locale and format it for timeago.js
166
+ // Get browser locale and lookup in cache
141
167
  const fullLocale = getLocale()
142
168
 
143
- // Convert locale format (e.g., fr-FR to fr_FR)
144
- const localeWithUnderscore = fullLocale.replace('-', '_')
169
+ // Use cached locale transformation if available
170
+ let localeWithUnderscore = localeTransformCache.get(fullLocale)
171
+ if (!localeWithUnderscore) {
172
+ localeWithUnderscore = fullLocale.replace('-', '_')
173
+ localeTransformCache.set(fullLocale, localeWithUnderscore)
174
+ }
145
175
 
146
176
  // Use the locale directly - the registration above ensures support
147
- // timeago.js will fall back to en_US if no matching locale is found
148
177
  return formatDateTimeago(dateObj, localeWithUnderscore)
149
178
  }
150
179
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fy-/fws-vue",
3
- "version": "2.3.48",
3
+ "version": "2.3.50",
4
4
  "author": "Florian 'Fy' Gasquez <m@fy.to>",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/fy-to/FWJS#readme",