feat(media): pagination
This commit is contained in:
1 parent
04dc151ed7
commit
63c1e7076e
65 files changed
+2960
-365
No files matched your search
+180
-30
@@ -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>
|
||||
|
||||
|
||||
Reference in new issue
Block a user