feat(media): pagination

This commit is contained in:
Florian Sylvain committed 2026-10-13 19:54:59 +02:00
1 parent 04dc151ed7
commit 63c1e7076e
65 files changed
+2960 -365

No files matched your search

+180 -30
View File
@@ -2,9 +2,10 @@
import { t } from '@/i18n'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useRoute, useRouter, type LocationQuery } from 'vue-router'
import { useEventListener, useMediaQuery, useOnline } from '@vueuse/core'
import { useLocalizedToast } from '@/i18n/presentation'
import AppPaginator from '@/components/AppPaginator.vue'
import BulkDeleteMediaDialog from '@/components/BulkDeleteMediaDialog.vue'
import DashboardShell from '@/components/DashboardShell.vue'
import DeleteMediaDialog from '@/components/DeleteMediaDialog.vue'
@@ -38,16 +39,21 @@ import { toastsAboveBulkBar } from '@/composables/useToastOffset'
import { groupDuplicates, type DuplicateGroup } from '@/lib/mediaDuplicates'
import { joinNames } from '@/lib/mediaFormat'
import {
isFullSet,
parseMediaControls,
toRequestQuery,
sameMediaControls,
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'
import { RATE_LIMITED_TEXT, RATE_LIMITED_TITLE, UPLOAD_ACCEPT } from '@/lib/mediaUploadRows'
import { useSessionStore } from '@/stores/session'
const DESKTOP_QUERY = '(min-width: 48rem)'
const MEDIA_PATH = '/media'
const TOAST_DURATION_MS = 5000
const PERSISTENT_TOAST = 0
const BULK_FAILED_TOAST_ID = 'media-bulk-delete-failed'
@@ -72,6 +78,9 @@ 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()
@@ -82,11 +91,15 @@ const upload = useMediaUpload({ onAdded: refreshInBackground })
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const online = useOnline()
const controls = computed(() => parseMediaControls(route.query))
const requestQuery = computed(() => toRequestQuery(controls.value))
const shown = ref<MediaControls>(parseMediaControls(route.query))
const desired = ref<MediaControls>(shown.value)
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)
@@ -110,6 +123,8 @@ 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,
@@ -119,7 +134,9 @@ const panelItem = computed(() => activeItem.value ?? closingItem.value)
const counts = computed(() => (hasLoaded.value ? media.counts.value : null))
const loadFailed = computed(() => media.status.value === 'error')
const loading = computed(() => media.status.value === 'loading')
const ready = computed(() => media.status.value === 'ready')
const ready = computed(
() => media.status.value === 'ready' || (media.status.value === 'loading' && loadedOnce.value),
)
const isDuplicates = computed(() => controls.value.filter === 'duplicates')
const groups = computed(() => (isDuplicates.value ? groupDuplicates(media.items.value) : []))
const emptyLibrary = computed(
@@ -133,8 +150,24 @@ const nothingToShow = computed(
() =>
ready.value &&
!emptyLibrary.value &&
(isDuplicates.value ? groups.value.length === 0 : media.items.value.length === 0),
(isDuplicates.value
? groups.value.length === 0
: media.items.value.length === 0 && media.total.value === 0),
)
const showPaginator = computed(
() => ready.value && !isDuplicates.value && !emptyLibrary.value && media.total.value > 0,
)
const pageCount = computed(() => pageCountFor(media.total.value, shown.value.size))
const noticeText = computed(() => {
const current = notice.value
if (!current) {
return ''
}
if (current.kind === 'range') {
return t("That page doesn't exist. Showing the last page.")
}
return t("Couldn't load page {0}. Showing page {1}.", [current.query.page, shown.value.page])
})
const emptyVariant = computed(() => {
if (isDuplicates.value && controls.value.q === '') {
return 'no-duplicates'
@@ -191,21 +224,78 @@ watch(showBulkBar, (visible) => (toastsAboveBulkBar.value = visible), {
})
onBeforeUnmount(() => (toastsAboveBulkBar.value = false))
function canonicalizeUrl() {
const canonical = toRouteQuery(controls.value)
const current = Object.fromEntries(
Object.entries(route.query).map(([key, value]) => [
key,
Array.isArray(value) ? (value[0] ?? '') : (value ?? ''),
]),
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,
)
if (JSON.stringify(canonical) !== JSON.stringify(current)) {
router.replace({ query: canonical })
if (changed) {
load({ ...desired.value, page: 1, ...patch }, mode)
}
}
function updateControls(patch: Partial<MediaControls>) {
router.replace({ query: toRouteQuery({ ...controls.value, ...patch }) })
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')
}
function retryNotice() {
if (notice.value?.kind === 'error') {
load(notice.value.query, notice.value.mode)
}
}
function clearFilters() {
@@ -216,8 +306,6 @@ function announce(source: () => string) {
announcementSource.value = source
}
canonicalizeUrl()
watch(activeItem, (item) => {
if (item) {
closingItem.value = item
@@ -225,16 +313,27 @@ watch(activeItem, (item) => {
})
watch(
requestQuery,
async () => {
selection.clear()
await media.load(controls.value)
hasLoaded.value = true
selectPending()
() => 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')
},
{ immediate: true },
)
load(shown.value, 'replace')
watch(
() => upload.header.value,
(next, previous) => {
@@ -249,8 +348,18 @@ watch(
},
)
function refreshInBackground() {
media.load(controls.value, { silent: true })
async function refreshInBackground() {
const outcome = await media.load(shown.value, { silent: true })
if (outcome.kind !== 'loaded') {
return
}
hasLoaded.value = true
loadedOnce.value = true
if (outcome.page !== shown.value.page) {
shown.value = { ...shown.value, page: outcome.page }
desired.value = shown.value
await syncUrl(shown.value, 'replace')
}
}
function focusCard(id: number) {
@@ -910,7 +1019,7 @@ async function mergeAction() {
icon="i-lucide-rotate-cw"
class="h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-7"
:ui="{ leadingIcon: 'size-3.5' }"
@click="media.load(controls)"
@click="load(desired, 'replace')"
>
{{ t('Try again') }}
</UButton>
@@ -918,7 +1027,7 @@ async function mergeAction() {
</UAlert>
<MediaToolbar
:controls="controls"
:controls="desired"
:counts="counts"
:show-purge="showPurge"
:purge-disabled="offline"
@@ -998,6 +1107,47 @@ async function mergeAction() {
/>
</template>
</template>
<UAlert
v-if="notice && !isDuplicates"
:role="notice.kind === 'error' ? 'alert' : 'status'"
:color="notice.kind === 'error' ? 'error' : 'info'"
variant="soft"
orientation="vertical"
:close="false"
:icon="notice.kind === 'error' ? 'i-lucide-circle-x' : 'i-lucide-info'"
:title="noticeText"
:description="
notice.kind === 'error'
? t('Check your connection and try again.')
: undefined
"
:ui="{ actions: 'mt-2' }"
>
<template v-if="notice.kind === 'error'" #actions>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-rotate-cw"
class="h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-7"
:ui="{ leadingIcon: 'size-3.5' }"
@click="retryNotice"
>
{{ t('Retry') }}
</UButton>
</template>
</UAlert>
<AppPaginator
v-if="showPaginator"
:page="shown.page"
:page-count="pageCount"
:total="media.total.value"
:size="shown.size"
:loading-page="pendingPage"
@update:page="changePage"
@update:size="changeSize"
/>
</template>
</div>