@fy-/fws-vue 2.4.2 → 2.4.4

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.
@@ -18,13 +18,20 @@ import {
18
18
  useWindowSize,
19
19
  } from '@vueuse/core'
20
20
  import { computed, h, nextTick, onMounted, onUnmounted, reactive, ref, shallowRef, watch } from 'vue'
21
+ import { useRoute } from 'vue-router'
21
22
  import { useEventBus } from '../../composables/event-bus'
23
+ import { useServerRouter } from '../../stores/serverRouter'
22
24
  import { ClientOnly } from '../ssr/ClientOnly'
23
25
  import DefaultPaging from './DefaultPaging.vue'
24
26
 
25
27
  // Core state
26
28
  const isGalleryOpen = ref<boolean>(false)
27
29
  const eventBus = useEventBus()
30
+ const route = useRoute()
31
+ const history = useServerRouter()
32
+ // Cross-page navigation: set while a prev/next page change is in flight
33
+ const pendingPageFlip = ref<'next' | 'prev' | null>(null)
34
+ let pendingPageFlipTimeout: number | null = null
28
35
  const sidePanel = ref<boolean>(true)
29
36
  const showControls = ref<boolean>(true)
30
37
  const isFullscreen = ref<boolean>(false)
@@ -322,6 +329,13 @@ function setModal(value: boolean) {
322
329
  clearTimeout(controlsTimeout)
323
330
  controlsTimeout = null
324
331
  }
332
+
333
+ // Abort any in-flight cross-page navigation
334
+ pendingPageFlip.value = null
335
+ if (pendingPageFlipTimeout) {
336
+ clearTimeout(pendingPageFlipTimeout)
337
+ pendingPageFlipTimeout = null
338
+ }
325
339
  }
326
340
  isGalleryOpen.value = value
327
341
  showControls.value = true
@@ -343,12 +357,60 @@ const openGalleryImage = useDebounceFn((index: number | undefined) => {
343
357
  })
344
358
  }, 50)
345
359
 
360
+ // Cross-page navigation: push the new page into the route query, exactly like
361
+ // DefaultPaging does. The consumer's paging watchers react to the route change
362
+ // and refetch, replacing `images` — the watcher below then lands the index.
363
+ function goToGalleryPage(page: number) {
364
+ const newQuery = { ...route.query }
365
+ newQuery.page = page.toString()
366
+ history.push({
367
+ path: history.currentRoute.path,
368
+ query: newQuery,
369
+ })
370
+ }
371
+
372
+ function startPageFlip(dir: 'next' | 'prev', page: number) {
373
+ pendingPageFlip.value = dir
374
+ // Safety: if the refetch never lands (network error), unlock navigation
375
+ if (pendingPageFlipTimeout) clearTimeout(pendingPageFlipTimeout)
376
+ pendingPageFlipTimeout = window.setTimeout(() => {
377
+ pendingPageFlip.value = null
378
+ }, 5000)
379
+ goToGalleryPage(page)
380
+ }
381
+
382
+ // When the images array is replaced while a page flip is pending, land on the
383
+ // first image (next) or last image (prev) of the new page. Default pre-flush
384
+ // watcher: runs before render, so the old index never flashes.
385
+ watch(
386
+ () => props.images,
387
+ (newImages) => {
388
+ if (!pendingPageFlip.value) return
389
+ if (newImages && newImages.length > 0) {
390
+ modelValue.value = pendingPageFlip.value === 'next' ? 0 : newImages.length - 1
391
+ }
392
+ pendingPageFlip.value = null
393
+ if (pendingPageFlipTimeout) {
394
+ clearTimeout(pendingPageFlipTimeout)
395
+ pendingPageFlipTimeout = null
396
+ }
397
+ nextTick(() => {
398
+ updateImageSizes()
399
+ })
400
+ },
401
+ )
402
+
346
403
  // Navigation functions
347
404
  function goNextImage() {
348
405
  direction.value = 'next'
406
+ if (pendingPageFlip.value) return // page change in flight — ignore extra input
349
407
  if (modelValue.value < props.images.length - 1) {
350
408
  modelValue.value++
351
409
  }
410
+ else if (props.paging && props.paging.page_no < props.paging.page_max) {
411
+ // Last image of the page → go to the first image of the next page
412
+ startPageFlip('next', props.paging.page_no + 1)
413
+ }
352
414
  else {
353
415
  modelValue.value = 0
354
416
  }
@@ -361,9 +423,14 @@ function goNextImage() {
361
423
 
362
424
  function goPrevImage() {
363
425
  direction.value = 'prev'
426
+ if (pendingPageFlip.value) return // page change in flight — ignore extra input
364
427
  if (modelValue.value > 0) {
365
428
  modelValue.value--
366
429
  }
430
+ else if (props.paging && props.paging.page_no > 1) {
431
+ // First image of the page → go to the last image of the previous page
432
+ startPageFlip('prev', props.paging.page_no - 1)
433
+ }
367
434
  else {
368
435
  modelValue.value = props.images.length - 1 > 0 ? props.images.length - 1 : 0
369
436
  }
@@ -599,6 +666,10 @@ onUnmounted(() => {
599
666
  clearTimeout(fullscreenResizeTimeout)
600
667
  }
601
668
 
669
+ if (pendingPageFlipTimeout) {
670
+ clearTimeout(pendingPageFlipTimeout)
671
+ }
672
+
602
673
  // Ensure we exit fullscreen mode on unmount
603
674
  if (isFullscreen.value) {
604
675
  exitFullscreen().catch(() => {})
@@ -698,7 +769,7 @@ onUnmounted(() => {
698
769
  leave-to-class="opacity-0"
699
770
  >
700
771
  <div
701
- v-if="showControls && images.length > 1"
772
+ v-if="showControls && (images.length > 1 || (paging && paging.page_max > 1))"
702
773
  class="absolute left-0 z-40 h-full flex items-center px-2 md:px-4"
703
774
  >
704
775
  <button
@@ -772,7 +843,7 @@ onUnmounted(() => {
772
843
  leave-to-class="opacity-0"
773
844
  >
774
845
  <div
775
- v-if="showControls && images.length > 1"
846
+ v-if="showControls && (images.length > 1 || (paging && paging.page_max > 1))"
776
847
  class="absolute right-0 z-40 h-full flex items-center px-2 md:px-4"
777
848
  :class="{ 'lg:mr-64': sidePanel }"
778
849
  >
package/index.ts CHANGED
@@ -1,8 +1,10 @@
1
+ import type { TFunction } from 'i18next'
1
2
  import type { Emitter } from 'mitt'
2
3
  import type { App, Plugin } from 'vue'
3
4
  import type { Events } from './composables/event-bus'
4
5
  import i18next from 'i18next'
5
6
  import mitt from 'mitt'
7
+ import { shallowRef } from 'vue'
6
8
  import CmsArticleBoxed from './components/fws/CmsArticleBoxed.vue'
7
9
  import CmsArticleSingle from './components/fws/CmsArticleSingle.vue'
8
10
  import DataTable from './components/fws/DataTable.vue'
@@ -70,15 +72,40 @@ function createFWS(): Plugin {
70
72
  // @ts-expect-error: Emitter<Events> is not assignable to Emitter<Events>
71
73
  const eventBus: Emitter<Events> = mitt<Events>()
72
74
 
75
+ // i18next's `t` is NOT reactive. If a component renders a key during a window
76
+ // where the resource isn't loaded yet (cold deep-link, client-nav before a late
77
+ // locale fetch, hydration race) it paints the RAW token and Vue never re-renders
78
+ // — nothing reactive changed, so the miss sticks. Wrap `t` so it depends on a
79
+ // tick bumped by i18next's own events: any raw key then self-heals the instant
80
+ // resources land, and `changeLanguage` live-updates every string. Client-only:
81
+ // on the server strings are already resolved by the `await i18nextPromise` before
82
+ // render, and `install()` runs per-request there — adding listeners to the global
83
+ // i18next singleton each request would leak. So SSR keeps the plain `t`.
84
+ let translate: TFunction = i18next.t
85
+ if (typeof window !== 'undefined') {
86
+ const tick = shallowRef(0)
87
+ const bump = (): void => {
88
+ tick.value++
89
+ }
90
+ i18next.on('loaded', bump)
91
+ i18next.on('added', bump)
92
+ i18next.on('languageChanged', bump)
93
+ const rawT = i18next.t as unknown as (...a: unknown[]) => unknown
94
+ translate = ((...args: Parameters<typeof i18next.t>) => {
95
+ void tick.value
96
+ return rawT(...args)
97
+ }) as unknown as TFunction
98
+ }
99
+
73
100
  return {
74
101
  install(app: App) {
75
102
  if (app.config.globalProperties) {
76
103
  app.config.globalProperties.$eventBus = eventBus
77
104
  app.provide('fwsVueEventBus', eventBus)
78
105
 
79
- // i18next
80
- app.config.globalProperties.$t = i18next.t
81
- app.provide('fwsVueTranslate', i18next.t)
106
+ // i18next (reactive wrapper — see note above)
107
+ app.config.globalProperties.$t = translate
108
+ app.provide('fwsVueTranslate', translate)
82
109
 
83
110
  // Templating
84
111
  app.config.globalProperties.$cropText = cropText
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fy-/fws-vue",
3
- "version": "2.4.2",
3
+ "version": "2.4.4",
4
4
  "author": "Florian 'Fy' Gasquez <m@fy.to>",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/fy-to/FWJS#readme",