feat: pagination in the SPA for the Sites and Authors lists
This commit is contained in:
1 parent
500ad59458
commit
340a0ad461
34 files changed
+2833
-896
No files matched your search
@@ -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 }
|
||||
}
|
||||
Reference in new issue
Block a user