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 = { 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 { loaded: boolean shown: Q } interface PagedQueryOptions { path: string initial: Q request: (query: Q, context: PagedContext) => Promise parseRoute: (query: LocationQuery, ownReplace: boolean) => Q toRoute: (query: Q, current: LocationQuery) => Record same: (left: Q, right: Q) => boolean sameRoute?: (current: LocationQuery, target: Record) => boolean enabled?: () => boolean isLoading?: () => boolean pendingPageFor?: (query: Q, loaded: boolean) => number | null initialLoading?: boolean onRouteQuery?: (query: Q) => void onLoaded?: () => void } export function usePagedQuery(options: PagedQueryOptions) { 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 const desired = ref(options.initial) as Ref const hasLoaded = ref(false) const loadedOnce = ref(false) const loading = ref(options.initialLoading ?? false) const pendingPage = ref(null) const notice = ref | null>(null) let token = 0 let syncedTarget: Record | 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 | 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 | 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 | 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 } }