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

+51 -93
View File
@@ -2,7 +2,7 @@
import { t } from '@/i18n'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { useRoute, useRouter, type LocationQuery } from 'vue-router'
import { useRoute, type LocationQuery } from 'vue-router'
import { useEventListener, useMediaQuery, useOnline } from '@vueuse/core'
import { useLocalizedToast } from '@/i18n/presentation'
import AppPaginator from '@/components/AppPaginator.vue'
@@ -34,6 +34,7 @@ import {
} from '@/composables/useMedia'
import { useMediaSelection } from '@/composables/useMediaSelection'
import { useMediaUpload } from '@/composables/useMediaUpload'
import { usePagedQuery, type PagedOutcome, type UrlMode } from '@/composables/usePagedQuery'
import type { PurgeResult } from '@/composables/useMediaPurge'
import { toastsAboveBulkBar } from '@/composables/useToastOffset'
import { groupDuplicates, type DuplicateGroup } from '@/lib/mediaDuplicates'
@@ -45,7 +46,6 @@ import {
toRouteQuery,
type MediaControls,
} from '@/lib/mediaQuery'
import { sameRouteQuery } from '@/lib/articleListQuery'
import { MEDIA_PAGE_SIZE_KEY, storePageSize } from '@/lib/pageSizeMemory'
import { pageCountFor } from '@/lib/pagination'
import { purgedToast } from '@/lib/mediaPurge'
@@ -78,11 +78,7 @@ const MERGE_CONFLICT_COPY =
const OFFLINE_COPY =
"You're offline. You can look through the images that are loaded, but uploading, renaming and deleting need a connection."
type UrlMode = 'push' | 'replace'
type Notice = { kind: 'range' } | { kind: 'error'; query: MediaControls; mode: UrlMode }
const route = useRoute()
const router = useRouter()
const toast = useLocalizedToast()
const session = useSessionStore()
const media = useMedia()
@@ -91,15 +87,55 @@ const upload = useMediaUpload({ onAdded: refreshInBackground })
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const online = useOnline()
const shown = ref<MediaControls>(parseMediaControls(route.query))
const desired = ref<MediaControls>(shown.value)
function differsOnlyByPage(query: MediaControls, current: MediaControls) {
return sameMediaControls({ ...query, page: current.page }, current)
}
function sameOrderedQuery(current: LocationQuery, target: Record<string, string>) {
const flatten = (query: LocationQuery) =>
Object.entries(query).map(([key, value]) => [key, Array.isArray(value) ? value[0] : value])
return JSON.stringify(flatten(current)) === JSON.stringify(Object.entries(target))
}
async function requestPage(
query: MediaControls,
context: { loaded: boolean; shown: MediaControls },
): Promise<PagedOutcome> {
const keepOnError = context.loaded && differsOnlyByPage(query, context.shown)
const outcome = await media.load(query, { keepOnError })
if (outcome.kind === 'stale') {
return { kind: 'stale' }
}
if (outcome.kind === 'failed') {
const showNotice = keepOnError && !outcome.handled
return { kind: 'failed', settled: true, notice: showNotice, resetDesired: showNotice }
}
return {
kind: 'loaded',
total: media.total.value,
count: media.items.value.length,
page: outcome.page,
redirected: outcome.redirected,
apply: selection.clear,
}
}
const paged = usePagedQuery<MediaControls>({
path: MEDIA_PATH,
initial: parseMediaControls(route.query),
request: requestPage,
parseRoute: (query, ownReplace) => parseMediaControls(query, !ownReplace),
toRoute: (query) => toRouteQuery(query),
same: sameMediaControls,
sameRoute: sameOrderedQuery,
isLoading: () => media.status.value === 'loading',
pendingPageFor: (query, loaded) => (loaded && !isFullSet(query) ? query.page : null),
onLoaded: selectPending,
})
const { shown, desired, hasLoaded, loadedOnce, pendingPage, notice, load, syncUrl, changePage } =
paged
const controls = computed(() => shown.value)
const offline = computed(() => !online.value)
const hasLoaded = ref(false)
const loadedOnce = ref(false)
const pendingPage = ref<number | null>(null)
const notice = ref<Notice | null>(null)
const heading = ref<HTMLElement | null>(null)
const fileInput = ref<HTMLInputElement | null>(null)
const activeId = ref<number | null>(null)
@@ -123,8 +159,6 @@ let bulkDeletedTotal = 0
let bulkFailureRevision = 0
let bulkFailureToastId = BULK_FAILED_TOAST_ID
let failureToastShown = false
let requestToken = 0
let syncedTarget: Record<string, string> | null = null
const activeItem = computed(
() => media.items.value.find((item) => item.id === activeId.value) ?? null,
@@ -224,56 +258,6 @@ watch(showBulkBar, (visible) => (toastsAboveBulkBar.value = visible), {
})
onBeforeUnmount(() => (toastsAboveBulkBar.value = false))
function sameOrderedQuery(current: LocationQuery, target: Record<string, string>) {
const flatten = (query: LocationQuery) =>
Object.entries(query).map(([key, value]) => [key, Array.isArray(value) ? value[0] : value])
return JSON.stringify(flatten(current)) === JSON.stringify(Object.entries(target))
}
async function syncUrl(query: MediaControls, mode: UrlMode) {
if (route.path !== MEDIA_PATH) {
return
}
const target = toRouteQuery(query)
if (sameOrderedQuery(route.query, target)) {
return
}
syncedTarget = target
await router[mode]({ query: target })
}
function differsOnlyByPage(query: MediaControls, current: MediaControls) {
return sameMediaControls({ ...query, page: current.page }, current)
}
async function load(query: MediaControls, mode: UrlMode) {
const token = ++requestToken
desired.value = query
pendingPage.value = loadedOnce.value && !isFullSet(query) ? query.page : null
const keepOnError = loadedOnce.value && differsOnlyByPage(query, shown.value)
const outcome = await media.load(query, { keepOnError })
if (token !== requestToken || outcome.kind === 'stale') {
return
}
pendingPage.value = null
hasLoaded.value = true
if (outcome.kind === 'failed') {
if (keepOnError && !outcome.handled) {
desired.value = shown.value
notice.value = { kind: 'error', query, mode }
}
return
}
loadedOnce.value = true
const effective = { ...query, page: outcome.page }
selection.clear()
shown.value = effective
desired.value = effective
notice.value = outcome.redirected ? { kind: 'range' } : null
await syncUrl(effective, outcome.redirected ? 'replace' : mode)
selectPending()
}
function updateControls(patch: Partial<MediaControls>, mode: UrlMode = 'replace') {
const changed = Object.entries(patch).some(
([key, value]) => desired.value[key as keyof MediaControls] !== value,
@@ -283,19 +267,13 @@ function updateControls(patch: Partial<MediaControls>, mode: UrlMode = 'replace'
}
}
function changePage(page: number) {
load({ ...desired.value, page }, 'push')
}
function changeSize(size: number) {
storePageSize(MEDIA_PAGE_SIZE_KEY, size)
load({ ...desired.value, size, page: 1 }, 'push')
paged.changeSize(size)
}
function retryNotice() {
if (notice.value?.kind === 'error') {
load(notice.value.query, notice.value.mode)
}
paged.retryNotice()
}
function clearFilters() {
@@ -312,26 +290,6 @@ watch(activeItem, (item) => {
}
})
watch(
() => route.query,
() => {
if (route.path !== MEDIA_PATH) {
return
}
const ownReplace = syncedTarget !== null && sameRouteQuery(route.query, syncedTarget)
syncedTarget = null
const next = parseMediaControls(route.query, !ownReplace)
if (hasLoaded.value && sameMediaControls(next, shown.value)) {
syncUrl(shown.value, 'replace')
return
}
if (sameMediaControls(next, desired.value) && media.status.value === 'loading') {
return
}
load(next, 'replace')
},
)
load(shown.value, 'replace')
watch(