feat(media): pagination
This commit is contained in:
1 parent
04dc151ed7
commit
63c1e7076e
65 files changed
+2960
-365
No files matched your search
@@ -26,10 +26,12 @@ function item(id: number, overrides: Partial<MediaItem> = {}): MediaItem {
|
||||
}
|
||||
}
|
||||
|
||||
function listBody(items: MediaItem[]) {
|
||||
function listBody(items: MediaItem[], paging: { total?: number; page?: number } = {}) {
|
||||
return {
|
||||
items,
|
||||
next_cursor: null,
|
||||
total: paging.total ?? items.length,
|
||||
page: paging.page ?? 1,
|
||||
size: 25,
|
||||
counts: { all: items.length, unused: 1, duplicates: 0 },
|
||||
}
|
||||
}
|
||||
@@ -57,20 +59,99 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('useMedia list state', () => {
|
||||
it('requests the list with the controls and limit=100 and stores items and counts', async () => {
|
||||
it('requests the list with the controls, page and size and stores items and counts', async () => {
|
||||
request.mockResolvedValue(listBody([item(1), item(2)]))
|
||||
const media = useMedia()
|
||||
|
||||
await media.load({ ...DEFAULT_CONTROLS, q: 'wall', filter: 'unused' })
|
||||
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
'/v1/media/?q=wall&filter=unused&sort=newest&limit=100',
|
||||
'/v1/media/?q=wall&filter=unused&sort=newest&page=1&size=25',
|
||||
)
|
||||
expect(media.items.value.map((entry) => entry.id)).toEqual([1, 2])
|
||||
expect(media.counts.value).toEqual({ all: 2, unused: 1, duplicates: 0 })
|
||||
expect(media.status.value).toBe('ready')
|
||||
})
|
||||
|
||||
it('sends the page and the size it was given and stores the total', async () => {
|
||||
request.mockResolvedValue(listBody([item(1)], { total: 312, page: 7 }))
|
||||
const media = useMedia()
|
||||
|
||||
const outcome = await media.load({ ...DEFAULT_CONTROLS, page: 7, size: 50 })
|
||||
|
||||
expect(request).toHaveBeenCalledWith('/v1/media/?filter=all&sort=newest&page=7&size=50')
|
||||
expect(media.total.value).toBe(312)
|
||||
expect(outcome).toEqual({ kind: 'loaded', page: 7, redirected: false })
|
||||
})
|
||||
|
||||
it('asks for the last page when the page is past the end and keeps the old items until then', async () => {
|
||||
request.mockResolvedValueOnce(listBody([], { total: 312, page: 99 }))
|
||||
request.mockResolvedValueOnce(listBody([item(1)], { total: 312, page: 13 }))
|
||||
const media = useMedia()
|
||||
|
||||
const outcome = await media.load({ ...DEFAULT_CONTROLS, page: 99 })
|
||||
|
||||
expect(request).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/v1/media/?filter=all&sort=newest&page=13&size=25',
|
||||
)
|
||||
expect(outcome).toEqual({ kind: 'loaded', page: 13, redirected: true })
|
||||
expect(media.items.value).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps page 1 and no redirect when the total is zero', async () => {
|
||||
request.mockResolvedValueOnce(listBody([], { total: 0, page: 5 }))
|
||||
const media = useMedia()
|
||||
|
||||
const outcome = await media.load({ ...DEFAULT_CONTROLS, page: 5 })
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(1)
|
||||
expect(outcome).toEqual({ kind: 'loaded', page: 1, redirected: false })
|
||||
})
|
||||
|
||||
it('loads the whole duplicate set at size 100 page after page', async () => {
|
||||
const first = Array.from({ length: 100 }, (_, index) => item(index + 1))
|
||||
const second = Array.from({ length: 30 }, (_, index) => item(index + 101))
|
||||
request.mockResolvedValueOnce(listBody(first, { total: 130 }))
|
||||
request.mockResolvedValueOnce(listBody(second, { total: 130, page: 2 }))
|
||||
const media = useMedia()
|
||||
|
||||
await media.load({ ...DEFAULT_CONTROLS, filter: 'duplicates', page: 4, size: 5 })
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(2)
|
||||
expect(request).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/v1/media/?filter=duplicates&sort=newest&page=1&size=100',
|
||||
)
|
||||
expect(request).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/v1/media/?filter=duplicates&sort=newest&page=2&size=100',
|
||||
)
|
||||
expect(media.items.value).toHaveLength(130)
|
||||
})
|
||||
|
||||
it('requests no second duplicate page when the first one holds everything', async () => {
|
||||
request.mockResolvedValueOnce(listBody([item(1), item(2)]))
|
||||
const media = useMedia()
|
||||
|
||||
await media.load({ ...DEFAULT_CONTROLS, filter: 'duplicates' })
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps the loaded items and the ready status when a page change fails with keepOnError', async () => {
|
||||
request.mockResolvedValueOnce(listBody([item(1)], { total: 60 }))
|
||||
const media = useMedia()
|
||||
await media.load(DEFAULT_CONTROLS)
|
||||
|
||||
request.mockRejectedValueOnce(new ApiError(500))
|
||||
const outcome = await media.load({ ...DEFAULT_CONTROLS, page: 2 }, { keepOnError: true })
|
||||
|
||||
expect(outcome).toEqual({ kind: 'failed', handled: false })
|
||||
expect(media.status.value).toBe('ready')
|
||||
expect(media.items.value).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('shows the skeleton only after 150 ms of loading', async () => {
|
||||
const pending = deferred<unknown>()
|
||||
request.mockReturnValue(pending.promise)
|
||||
@@ -184,7 +265,7 @@ describe('useMedia requests', () => {
|
||||
|
||||
const others = await media.findCopies(item(12, { sha256: 'abc123' }))
|
||||
|
||||
expect(request).toHaveBeenCalledWith('/v1/media/?hash=abc123')
|
||||
expect(request).toHaveBeenCalledWith('/v1/media/?hash=abc123&page=1&size=100')
|
||||
expect(others.map((entry) => entry.id)).toEqual([5, 6])
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { tryOnBeforeUnmount } from '@vueuse/core'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import {
|
||||
@@ -9,21 +9,22 @@ import {
|
||||
} from '@/composables/useMedia'
|
||||
import { useSeenImagesStore } from '@/stores/seenImages'
|
||||
|
||||
export const LIBRARY_PAGE_LIMIT = 24
|
||||
export const LIBRARY_PAGE_SIZE = 25
|
||||
const FIRST_PAGE = 1
|
||||
|
||||
export type LibraryStatus = 'idle' | 'loading' | 'ready' | 'error' | 'handled'
|
||||
|
||||
interface LibraryResponse {
|
||||
items: MediaItem[]
|
||||
next_cursor: string | null
|
||||
total: number
|
||||
counts: MediaCounts
|
||||
}
|
||||
|
||||
function requestQuery(query: string, cursor: string | null) {
|
||||
function requestQuery(query: string, page: number) {
|
||||
return [
|
||||
...(query ? [`q=${encodeURIComponent(query)}`] : []),
|
||||
`limit=${LIBRARY_PAGE_LIMIT}`,
|
||||
...(cursor ? [`cursor=${encodeURIComponent(cursor)}`] : []),
|
||||
`page=${page}`,
|
||||
`size=${LIBRARY_PAGE_SIZE}`,
|
||||
].join('&')
|
||||
}
|
||||
|
||||
@@ -33,7 +34,8 @@ export function useLibraryList() {
|
||||
|
||||
const items = ref<MediaItem[]>([])
|
||||
const total = ref(0)
|
||||
const nextCursor = ref<string | null>(null)
|
||||
const matching = ref(0)
|
||||
const loadedPages = ref(0)
|
||||
const status = ref<LibraryStatus>('idle')
|
||||
const query = ref('')
|
||||
const showSkeleton = ref(false)
|
||||
@@ -41,6 +43,7 @@ export function useLibraryList() {
|
||||
const firstNewId = ref<number | null>(null)
|
||||
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let latest = 0
|
||||
const hasMore = computed(() => items.value.length < matching.value)
|
||||
|
||||
function clearSkeletonTimer() {
|
||||
if (skeletonTimer !== null) {
|
||||
@@ -66,7 +69,8 @@ export function useLibraryList() {
|
||||
latest += 1
|
||||
items.value = []
|
||||
total.value = 0
|
||||
nextCursor.value = null
|
||||
matching.value = 0
|
||||
loadedPages.value = 0
|
||||
status.value = 'idle'
|
||||
query.value = ''
|
||||
loadingMore.value = false
|
||||
@@ -79,21 +83,23 @@ export function useLibraryList() {
|
||||
const ticket = latest
|
||||
query.value = nextQuery
|
||||
items.value = []
|
||||
nextCursor.value = null
|
||||
matching.value = 0
|
||||
loadedPages.value = 0
|
||||
loadingMore.value = false
|
||||
firstNewId.value = null
|
||||
status.value = 'loading'
|
||||
startSkeletonTimer()
|
||||
try {
|
||||
const response = await request<LibraryResponse>(
|
||||
`/v1/media/?${requestQuery(nextQuery, null)}`,
|
||||
`/v1/media/?${requestQuery(nextQuery, FIRST_PAGE)}`,
|
||||
)
|
||||
if (ticket !== latest) {
|
||||
return
|
||||
}
|
||||
items.value = response.items
|
||||
total.value = response.counts.all
|
||||
nextCursor.value = response.next_cursor
|
||||
matching.value = response.total
|
||||
loadedPages.value = FIRST_PAGE
|
||||
seen.remember(response.items)
|
||||
status.value = 'ready'
|
||||
} catch (error) {
|
||||
@@ -109,7 +115,7 @@ export function useLibraryList() {
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (nextCursor.value === null || loadingMore.value || status.value !== 'ready') {
|
||||
if (!hasMore.value || loadingMore.value || status.value !== 'ready') {
|
||||
return
|
||||
}
|
||||
const ticket = latest
|
||||
@@ -117,7 +123,7 @@ export function useLibraryList() {
|
||||
startSkeletonTimer()
|
||||
try {
|
||||
const response = await request<LibraryResponse>(
|
||||
`/v1/media/?${requestQuery(query.value, nextCursor.value)}`,
|
||||
`/v1/media/?${requestQuery(query.value, loadedPages.value + 1)}`,
|
||||
)
|
||||
if (ticket !== latest) {
|
||||
return
|
||||
@@ -125,7 +131,8 @@ export function useLibraryList() {
|
||||
const known = new Set(items.value.map((item) => item.id))
|
||||
const added = response.items.filter((item) => !known.has(item.id))
|
||||
items.value = [...items.value, ...added]
|
||||
nextCursor.value = response.next_cursor
|
||||
matching.value = response.total
|
||||
loadedPages.value += 1
|
||||
firstNewId.value = added[0]?.id ?? null
|
||||
seen.remember(added)
|
||||
} catch {
|
||||
@@ -156,7 +163,7 @@ export function useLibraryList() {
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
nextCursor,
|
||||
hasMore,
|
||||
status,
|
||||
query,
|
||||
showSkeleton,
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { ref } from 'vue'
|
||||
import { tryOnBeforeUnmount } from '@vueuse/core'
|
||||
import { ApiError, useApi } from '@/composables/useApi'
|
||||
import { toRequestQuery, type MediaControls } from '@/lib/mediaQuery'
|
||||
import {
|
||||
MEDIA_FULL_SET_PAGE_SIZE,
|
||||
isFullSet,
|
||||
toHashRequestQuery,
|
||||
toRequestQuery,
|
||||
type MediaControls,
|
||||
} from '@/lib/mediaQuery'
|
||||
import { pageCountFor } from '@/lib/pagination'
|
||||
import { bumpMediaRevision } from '@/lib/mediaRevision'
|
||||
|
||||
export interface MediaItem {
|
||||
@@ -44,12 +51,24 @@ export interface BulkDeleteResult {
|
||||
handled: boolean
|
||||
}
|
||||
|
||||
interface MediaListResponse {
|
||||
export interface MediaListResponse {
|
||||
items: MediaItem[]
|
||||
next_cursor: string | null
|
||||
total: number
|
||||
page: number
|
||||
size: number
|
||||
counts: MediaCounts
|
||||
}
|
||||
|
||||
export type MediaLoadOutcome =
|
||||
| { kind: 'loaded'; page: number; redirected: boolean }
|
||||
| { kind: 'failed'; handled: boolean }
|
||||
| { kind: 'stale' }
|
||||
|
||||
interface MediaLoadOptions {
|
||||
silent?: boolean
|
||||
keepOnError?: boolean
|
||||
}
|
||||
|
||||
export type MediaLoadStatus = 'loading' | 'ready' | 'error' | 'handled'
|
||||
export type MediaFailure = 'handled' | 'not-found' | 'failed'
|
||||
|
||||
@@ -68,10 +87,19 @@ export function classifyMediaError(error: unknown): MediaFailure {
|
||||
return 'failed'
|
||||
}
|
||||
|
||||
function isPastLastPage(response: MediaListResponse, page: number, size: number) {
|
||||
return (
|
||||
response.items.length === 0 &&
|
||||
response.total > 0 &&
|
||||
page > pageCountFor(response.total, size)
|
||||
)
|
||||
}
|
||||
|
||||
export function useMedia() {
|
||||
const { request } = useApi()
|
||||
|
||||
const items = ref<MediaItem[]>([])
|
||||
const total = ref(0)
|
||||
const counts = ref<MediaCounts>({ all: 0, unused: 0, duplicates: 0 })
|
||||
const status = ref<MediaLoadStatus>('loading')
|
||||
const showSkeleton = ref(false)
|
||||
@@ -94,7 +122,39 @@ export function useMedia() {
|
||||
}, MEDIA_SKELETON_DELAY_MS)
|
||||
}
|
||||
|
||||
async function load(controls: MediaControls, options: { silent?: boolean } = {}) {
|
||||
function requestPage(controls: MediaControls) {
|
||||
return request<MediaListResponse>(`/v1/media/?${toRequestQuery(controls)}`)
|
||||
}
|
||||
|
||||
async function requestFullSet(controls: MediaControls) {
|
||||
const query = { ...controls, page: 1, size: MEDIA_FULL_SET_PAGE_SIZE }
|
||||
const first = await requestPage(query)
|
||||
const loaded = [...first.items]
|
||||
let latest = first
|
||||
while (loaded.length < first.total && latest.items.length > 0) {
|
||||
query.page += 1
|
||||
latest = await requestPage(query)
|
||||
loaded.push(...latest.items)
|
||||
}
|
||||
return { ...latest, items: loaded, total: loaded.length, page: 1 }
|
||||
}
|
||||
|
||||
async function requestList(controls: MediaControls) {
|
||||
if (isFullSet(controls)) {
|
||||
return { response: await requestFullSet(controls), page: 1, redirected: false }
|
||||
}
|
||||
const response = await requestPage(controls)
|
||||
if (!isPastLastPage(response, controls.page, controls.size)) {
|
||||
return { response, page: response.total === 0 ? 1 : controls.page, redirected: false }
|
||||
}
|
||||
const page = pageCountFor(response.total, controls.size)
|
||||
return { response: await requestPage({ ...controls, page }), page, redirected: true }
|
||||
}
|
||||
|
||||
async function load(
|
||||
controls: MediaControls,
|
||||
options: MediaLoadOptions = {},
|
||||
): Promise<MediaLoadOutcome> {
|
||||
const silent = options.silent === true
|
||||
if (!silent) {
|
||||
latestLoad += 1
|
||||
@@ -102,20 +162,24 @@ export function useMedia() {
|
||||
}
|
||||
const ticket = latestLoad
|
||||
try {
|
||||
const response = await request<MediaListResponse>(
|
||||
`/v1/media/?${toRequestQuery(controls)}`,
|
||||
)
|
||||
const { response, page, redirected } = await requestList(controls)
|
||||
if (ticket !== latestLoad) {
|
||||
return
|
||||
return { kind: 'stale' }
|
||||
}
|
||||
items.value = response.items
|
||||
total.value = response.total
|
||||
counts.value = response.counts
|
||||
status.value = 'ready'
|
||||
return { kind: 'loaded', page, redirected }
|
||||
} catch (error) {
|
||||
if (silent || ticket !== latestLoad) {
|
||||
return
|
||||
if (ticket !== latestLoad) {
|
||||
return { kind: 'stale' }
|
||||
}
|
||||
status.value = classifyMediaError(error) === 'handled' ? 'handled' : 'error'
|
||||
const handled = classifyMediaError(error) === 'handled'
|
||||
if (!silent) {
|
||||
status.value = handled ? 'handled' : options.keepOnError ? 'ready' : 'error'
|
||||
}
|
||||
return { kind: 'failed', handled }
|
||||
} finally {
|
||||
if (!silent && ticket === latestLoad) {
|
||||
clearSkeletonTimer()
|
||||
@@ -129,7 +193,9 @@ export function useMedia() {
|
||||
}
|
||||
|
||||
async function findCopies(item: MediaItem) {
|
||||
const response = await request<MediaListResponse>(`/v1/media/?hash=${item.sha256}`)
|
||||
const response = await request<MediaListResponse>(
|
||||
`/v1/media/?${toHashRequestQuery(item.sha256)}`,
|
||||
)
|
||||
return response.items.filter((copy) => copy.id !== item.id)
|
||||
}
|
||||
|
||||
@@ -204,6 +270,7 @@ export function useMedia() {
|
||||
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
counts,
|
||||
status,
|
||||
showSkeleton,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { redirectToLogin, redirectUnverified, useApi } from '@/composables/useApi'
|
||||
import { classifyMediaError, type MediaItem } from '@/composables/useMedia'
|
||||
import { useRetryAfter } from '@/composables/useRetryAfter'
|
||||
import { toHashRequestQuery } from '@/lib/mediaQuery'
|
||||
import { hashFile } from '@/lib/fileHash'
|
||||
import { extensionMatchesType, validateImageFile } from '@/lib/imageUpload'
|
||||
import { describeUpload, parseRetryAfter } from '@/lib/mediaUploadRows'
|
||||
@@ -135,7 +136,9 @@ export function useMediaUpload(options: UploadOptions = {}) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const response = await request<{ items: MediaItem[] }>(`/v1/media/?hash=${hash}`)
|
||||
const response = await request<{ items: MediaItem[] }>(
|
||||
`/v1/media/?${toHashRequestQuery(hash)}`,
|
||||
)
|
||||
return oldestOf(response.items) ?? null
|
||||
} catch (error) {
|
||||
return classifyMediaError(error) === 'handled' ? 'halted' : null
|
||||
|
||||
Reference in new issue
Block a user