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
+51
-93
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user