feat: pagination in the SPA for the Sites and Authors lists

This commit is contained in:
Florian Sylvain committed 2026-10-19 04:00:47 +02:00
1 parent 500ad59458
commit 340a0ad461
34 files changed
+2833 -896

No files matched your search

+247
View File
@@ -0,0 +1,247 @@
import { computed, onBeforeUnmount, ref, watch, type Ref } from 'vue'
import { useRoute, useRouter, type LocationQuery } from 'vue-router'
import { useDelayedSkeleton } from '@/composables/useDelayedSkeleton'
import { pageCountFor } from '@/lib/pagination'
import { sameRouteQuery } from '@/lib/pageQuery'
const SEARCH_DEBOUNCE_MS = 120
export type UrlMode = 'push' | 'replace'
export interface PagedQuery {
q: string
page: number
size: number
}
export type PagedNotice<Q> = { kind: 'range' } | { kind: 'error'; query: Q; mode: UrlMode }
export type PagedOutcome =
| {
kind: 'loaded'
total: number
count: number
page?: number
redirected?: boolean
apply?: () => void
}
| { kind: 'failed'; notice: boolean; settled?: boolean; resetDesired?: boolean }
| { kind: 'stale' }
interface PagedContext<Q> {
loaded: boolean
shown: Q
}
interface PagedQueryOptions<Q extends PagedQuery> {
path: string
initial: Q
request: (query: Q, context: PagedContext<Q>) => Promise<PagedOutcome>
parseRoute: (query: LocationQuery, ownReplace: boolean) => Q
toRoute: (query: Q, current: LocationQuery) => Record<string, string>
same: (left: Q, right: Q) => boolean
sameRoute?: (current: LocationQuery, target: Record<string, string>) => boolean
enabled?: () => boolean
isLoading?: () => boolean
pendingPageFor?: (query: Q, loaded: boolean) => number | null
initialLoading?: boolean
onRouteQuery?: (query: Q) => void
onLoaded?: () => void
}
export function usePagedQuery<Q extends PagedQuery>(options: PagedQueryOptions<Q>) {
const route = useRoute()
const router = useRouter()
const skeleton = useDelayedSkeleton()
const sameRoute = options.sameRoute ?? sameRouteQuery
const total = ref(0)
const shown = ref(options.initial) as Ref<Q>
const desired = ref(options.initial) as Ref<Q>
const hasLoaded = ref(false)
const loadedOnce = ref(false)
const loading = ref(options.initialLoading ?? false)
const pendingPage = ref<number | null>(null)
const notice = ref<PagedNotice<Q> | null>(null)
let token = 0
let syncedTarget: Record<string, string> | null = null
const pageCount = computed(() => pageCountFor(total.value, shown.value.size))
const skeletonActive = computed(() => loading.value && skeleton.visible.value)
async function syncUrl(query: Q, mode: UrlMode) {
if (route.path !== options.path) {
return
}
const target = options.toRoute(query, route.query)
if (sameRoute(route.query, target)) {
return
}
syncedTarget = target
await router[mode]({ query: target })
}
async function load(query: Q, mode: UrlMode, successNotice: PagedNotice<Q> | null = null) {
const current = ++token
desired.value = query
loading.value = true
pendingPage.value = options.pendingPageFor
? options.pendingPageFor(query, loadedOnce.value)
: loadedOnce.value
? query.page
: null
skeleton.start()
const outcome = await options.request(query, {
loaded: loadedOnce.value,
shown: shown.value,
})
if (current !== token || outcome.kind === 'stale') {
return
}
if (outcome.kind === 'failed') {
finish()
if (outcome.settled) {
hasLoaded.value = true
}
if (outcome.resetDesired) {
desired.value = shown.value
}
if (outcome.notice) {
notice.value = { kind: 'error', query, mode }
}
return
}
const lastPage = pageCountFor(outcome.total, query.size)
if (
successNotice === null &&
!outcome.redirected &&
outcome.count === 0 &&
outcome.total > 0 &&
query.page > lastPage
) {
await load({ ...query, page: lastPage }, 'replace', { kind: 'range' })
return
}
const effective = {
...query,
page: outcome.page ?? (outcome.total === 0 ? 1 : query.page),
}
outcome.apply?.()
total.value = outcome.total
shown.value = effective
desired.value = effective
hasLoaded.value = true
loadedOnce.value = true
notice.value = successNotice ?? (outcome.redirected ? { kind: 'range' } : null)
finish()
await syncUrl(effective, outcome.redirected ? 'replace' : mode)
options.onLoaded?.()
}
function finish() {
loading.value = false
pendingPage.value = null
skeleton.stop()
}
function changePage(page: number) {
return load({ ...desired.value, page }, 'push')
}
function changeSize(size: number) {
return load({ ...desired.value, size, page: 1 }, 'push')
}
function retryNotice() {
const current = notice.value as PagedNotice<Q> | null
if (current?.kind === 'error') {
return load(current.query, current.mode)
}
}
watch(
() => route.query,
() => {
if (route.path !== options.path || options.enabled?.() === false) {
return
}
const ownReplace = syncedTarget !== null && sameRoute(route.query, syncedTarget)
syncedTarget = null
const next = options.parseRoute(route.query, ownReplace)
if (hasLoaded.value && options.same(next, shown.value)) {
syncUrl(shown.value, 'replace')
return
}
const busy = options.isLoading ? options.isLoading() : loading.value
if (options.same(next, desired.value) && busy) {
return
}
options.onRouteQuery?.(next)
load(next, 'replace')
},
)
onBeforeUnmount(() => {
token++
})
return {
total,
shown,
desired,
hasLoaded,
loadedOnce,
loading,
pendingPage,
notice,
pageCount,
showSkeleton: skeleton.visible,
skeletonActive,
load,
syncUrl,
changePage,
changeSize,
retryNotice,
}
}
export function useSearchInput(
initial: string,
current: () => string,
commit: (query: string) => unknown,
) {
const searchInput = ref(initial)
let timer: ReturnType<typeof setTimeout> | null = null
function cancel() {
if (timer !== null) {
clearTimeout(timer)
timer = null
}
}
function changeSearch(value: string) {
const q = value.trim()
if (q !== current()) {
commit(q)
}
}
function clearSearch() {
cancel()
searchInput.value = ''
changeSearch('')
}
watch(searchInput, (value) => {
cancel()
timer = setTimeout(() => {
timer = null
changeSearch(value)
}, SEARCH_DEBOUNCE_MS)
})
onBeforeUnmount(cancel)
return { searchInput, changeSearch, clearSearch }
}