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

+5 -2
View File
@@ -19,6 +19,7 @@ import {
sizeRejectionText,
validateImageFile,
} from '@/lib/imageUpload'
import { toHashRequestQuery } from '@/lib/mediaQuery'
import { libraryMatchText } from '@/lib/mediaFormat'
import { insertedUrl } from '@/lib/variants/variantOptions'
import { UNDERLINE_TAB_TRIGGER } from '@/lib/tabStyles'
@@ -349,7 +350,9 @@ async function findExisting(file: File): Promise<MediaItem | null | 'handled'> {
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) {
if (error instanceof ApiError && classifyMediaError(error) === 'handled') {
@@ -796,7 +799,7 @@ onUnmounted(() => {
:status="library.status.value"
:show-skeleton="library.showSkeleton.value"
:loading-more="library.loadingMore.value"
:has-more="library.nextCursor.value !== null"
:has-more="library.hasMore.value"
:total="library.total.value"
:query="library.query.value"
:first-new-id="library.firstNewId.value"
@@ -115,11 +115,11 @@ describe('The library has images, so Library opens', () => {
expect(skeletonTiles()).toHaveLength(9)
})
it('asks for 24 images and nothing else', async () => {
it('asks for the first page of 25 images and nothing else', async () => {
await openLibrary()
expect(listCalls()).toHaveLength(1)
expect(listCalls()[0]!.toString()).toBe('limit=24')
expect(listCalls()[0]!.toString()).toBe('page=1&size=25')
})
})
@@ -197,7 +197,7 @@ describe('A search is active, so the count is not shown', () => {
})
describe('Load more is hidden on the last page', () => {
it('does not show "Load more" without a cursor', async () => {
it('does not show "Load more" once every matching image is loaded', async () => {
await openLibrary({ list: () => pageBody(fixture(), null) })
expect(findButton('Load more')).toBeUndefined()
@@ -373,7 +373,7 @@ describe('Insert', () => {
})
describe('Search', () => {
it('searches after a 120 ms debounce, with no cursor, and clears the selection', async () => {
it('searches after a 120 ms debounce, on page 1, and clears the selection', async () => {
await openWithFakeTimers()
tile('kitchen-after.webp').click()
await vi.advanceTimersByTimeAsync(0)
@@ -388,8 +388,8 @@ describe('Search', () => {
expect(listCalls()).toHaveLength(2)
expect(listCalls()[1]!.get('q')).toBe('wall')
expect(listCalls()[1]!.get('limit')).toBe('24')
expect(listCalls()[1]!.has('cursor')).toBe(false)
expect(listCalls()[1]!.get('size')).toBe('25')
expect(listCalls()[1]!.get('page')).toBe('1')
expect(findButton('Insert image')?.disabled).toBe(true)
})
@@ -419,7 +419,7 @@ describe('Search', () => {
expect(searchField().value).toBe('')
const last = listCalls().at(-1)!
expect(last.has('q')).toBe(false)
expect(last.get('limit')).toBe('24')
expect(last.get('size')).toBe('25')
})
it('shows the no results panel and "Clear search" empties the field', async () => {
@@ -447,7 +447,7 @@ describe('Load more', () => {
it('shows progress, appends the new tiles and moves focus to the first one', async () => {
const more = deferred<unknown>()
await openWithFakeTimers({
list: (query) => (query.get('cursor') ? more.promise : pageBody()),
list: (query) => (query.get('page') === '2' ? more.promise : pageBody()),
})
const button = findButton('Load more')!
button.focus()
@@ -456,8 +456,8 @@ describe('Load more', () => {
await vi.advanceTimersByTimeAsync(0)
const last = listCalls().at(-1)!
expect(last.get('cursor')).toBe('c24')
expect(last.get('limit')).toBe('24')
expect(last.get('page')).toBe('2')
expect(last.get('size')).toBe('25')
const loading = findButton('Loading…')!
expect(loading.getAttribute('aria-disabled')).toBe('true')
expect(document.activeElement).toBe(loading)
@@ -475,7 +475,10 @@ describe('Load more', () => {
it('draws a row of 3 skeleton tiles on mobile', async () => {
await openWithFakeTimers(
{ list: (query) => (query.get('cursor') ? new Promise(() => undefined) : pageBody()) },
{
list: (query) =>
query.get('page') === '2' ? new Promise(() => undefined) : pageBody(),
},
MOBILE_WIDTH,
)
@@ -485,9 +488,9 @@ describe('Load more', () => {
expect(skeletonTiles()).toHaveLength(3)
})
it('combines the cursor with the search', async () => {
it('combines the next page with the search', async () => {
await openLibrary({
list: (query) => pageBody(query.get('q') ? fixture([1, 2]) : fixture(), 'c24'),
list: (query) => pageBody(query.get('q') ? fixture([1, 2]) : fixture(), 48),
})
await searchFor('wall')
@@ -495,8 +498,8 @@ describe('Load more', () => {
const last = listCalls().at(-1)!
expect(last.get('q')).toBe('wall')
expect(last.get('limit')).toBe('24')
expect(last.get('cursor')).toBe('c24')
expect(last.get('size')).toBe('25')
expect(last.get('page')).toBe('2')
})
})
+3 -6
View File
@@ -11,14 +11,11 @@ import { fixture, textOf } from '@/views/__tests__/mediaTestKit'
export { textOf }
export const FIRST_PAGE_CURSOR = 'c24'
export const FIRST_PAGE_TOTAL = 48
export const PICKER_COUNTS = { all: 48, unused: 2, duplicates: 2 }
export function pageBody(
items: MediaItem[] = fixture(),
next_cursor: string | null = FIRST_PAGE_CURSOR,
) {
return { items, next_cursor, counts: PICKER_COUNTS }
export function pageBody(items: MediaItem[] = fixture(), total: number | null = FIRST_PAGE_TOTAL) {
return { items, total: total ?? items.length, page: 1, size: 25, counts: PICKER_COUNTS }
}
export function secondPage() {