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

+86 -5
View File
@@ -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])
})
+22 -15
View File
@@ -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,
+79 -12
View File
@@ -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,
+4 -1
View File
@@ -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