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

+233 -2
View File
@@ -1254,11 +1254,15 @@ interface MediaSample {
createdAt: string
duplicate: boolean
tone: number
variantCount: number
variantSize: number
}
const mediaSamples: MediaSample[] = [
{
id: 1,
variantCount: 4,
variantSize: 266240,
filename: 'north-wall.jpg',
size: 4089446,
width: 4032,
@@ -1272,6 +1276,8 @@ const mediaSamples: MediaSample[] = [
},
{
id: 2,
variantCount: 5,
variantSize: 419840,
filename: 'kitchen-after.webp',
size: 1258291,
width: 2400,
@@ -1285,6 +1291,8 @@ const mediaSamples: MediaSample[] = [
},
{
id: 3,
variantCount: 5,
variantSize: 404480,
filename: 'kitchen-before.webp',
size: 1153434,
width: 2400,
@@ -1298,6 +1306,8 @@ const mediaSamples: MediaSample[] = [
},
{
id: 4,
variantCount: 0,
variantSize: 0,
filename: 'logo-renew.svg',
size: 8192,
width: null,
@@ -1311,6 +1321,8 @@ const mediaSamples: MediaSample[] = [
},
{
id: 5,
variantCount: 3,
variantSize: 245760,
filename: 'floorboards-copy.jpg',
size: 2516582,
width: 3600,
@@ -1324,6 +1336,8 @@ const mediaSamples: MediaSample[] = [
},
{
id: 6,
variantCount: 0,
variantSize: 0,
filename: 'IMG_2208.jpg',
size: 2516582,
width: 3600,
@@ -1337,6 +1351,8 @@ const mediaSamples: MediaSample[] = [
},
{
id: 7,
variantCount: 2,
variantSize: 153600,
filename: 'IMG_4471.jpg',
size: 3250585,
width: 3600,
@@ -1350,6 +1366,8 @@ const mediaSamples: MediaSample[] = [
},
{
id: 8,
variantCount: 0,
variantSize: 0,
filename: 'window-frame.jpg',
size: 1887437,
width: 3000,
@@ -1363,6 +1381,8 @@ const mediaSamples: MediaSample[] = [
},
{
id: 9,
variantCount: 1,
variantSize: 61440,
filename: 'stone-wall-detail.jpg',
size: 3250585,
width: 3600,
@@ -1376,6 +1396,8 @@ const mediaSamples: MediaSample[] = [
},
{
id: 10,
variantCount: 3,
variantSize: 194560,
filename: 'bathroom-tiles.png',
size: 880640,
width: 1800,
@@ -1389,6 +1411,8 @@ const mediaSamples: MediaSample[] = [
},
{
id: 11,
variantCount: 4,
variantSize: 286720,
filename: 'lime-plaster.jpg',
size: 2202009,
width: 3200,
@@ -1402,6 +1426,8 @@ const mediaSamples: MediaSample[] = [
},
{
id: 12,
variantCount: 5,
variantSize: 440320,
filename: 'floorboards.jpg',
size: 2516582,
width: 3600,
@@ -1454,6 +1480,8 @@ function mediaItem(sample: MediaSample, filename = sample.filename) {
created_at: sample.createdAt,
used_in: sample.usedIn,
duplicate: sample.duplicate,
variant_count: sample.variantCount,
variant_size: sample.variantSize,
}
}
@@ -1464,7 +1492,46 @@ function mediaItems(ids?: number[], without: number[] = []) {
}
function mediaList(items: unknown[], counts = mediaFullCounts) {
return { items, next_cursor: null, counts }
return { items, total: items.length, page: 1, size: 25, counts }
}
const mediaPagedTotal = 312
const mediaPagedCounts = { all: mediaPagedTotal, unused: 2, duplicates: 2 }
const mediaPastLastPage = 99
const mediaFailingPage = '7'
function mediaPagedApi() {
return mediaApi({
'GET /v1/media/': {
status: 200,
bodyFor: (url) => {
const page = Number(url.searchParams.get('page'))
return {
items: page === mediaPastLastPage ? [] : mediaItems(),
total: mediaPagedTotal,
page,
size: Number(url.searchParams.get('size')),
counts: mediaPagedCounts,
}
},
},
})
}
function isMediaPage(url: URL, wanted: string) {
return url.pathname === '/v1/media/' && url.searchParams.get('page') === wanted
}
async function scrollEverythingToTop(page: Page) {
await page.evaluate(() => {
for (const element of document.querySelectorAll('*')) {
element.scrollTop = 0
}
})
}
async function pressNextPage(page: Page) {
await page.getByRole('button', { name: 'Next page' }).click()
}
function mediaApi(overrides: Record<string, ApiMock> = {}): Record<string, ApiMock> {
@@ -2015,6 +2082,59 @@ const mediaStates: Record<string, VisualState> = {
mediaDuplicatesPath,
),
'List failed to load': mediaState(mediaApi({ 'GET /v1/media/': { status: 500 } })),
'Pagination: first page': mediaState(mediaPagedApi()),
'Pagination: middle page': mediaState(mediaPagedApi(), undefined, '/media?page=7'),
'Pagination: last page': mediaState(mediaPagedApi(), undefined, '/media?page=13'),
'Pagination: list view': mediaState(mediaPagedApi(), undefined, '/media?view=list&page=2'),
'Pagination: loading next page': mediaState(
mediaPagedApi(),
async (page) => {
await page.route(
(url) => isMediaPage(url, mediaFailingPage),
() => undefined,
)
await pressNextPage(page)
await page.getByTestId('media-skeleton').waitFor()
await scrollEverythingToTop(page)
},
'/media?page=6',
),
'Pagination: error loading a page': mediaState(
mediaPagedApi(),
async (page) => {
await page.route(
(url) => isMediaPage(url, mediaFailingPage),
(route) => route.fulfill({ status: 500 }),
)
await pressNextPage(page)
await page.getByText("Couldn't load page 7. Showing page 6.").waitFor()
await scrollEverythingToTop(page)
},
'/media?page=6',
),
'Pagination: page out of range': mediaState(
mediaPagedApi(),
async (page) => {
await page.getByText("That page doesn't exist. Showing the last page.").waitFor()
await scrollEverythingToTop(page)
},
`/media?page=${mediaPastLastPage}`,
),
'Pagination: page size menu': mediaState(mediaPagedApi(), async (page) => {
await page.locator('[aria-label="Rows per page"]').click()
await page.getByRole('listbox').waitFor()
await scrollEverythingToTop(page)
}),
'Pagination: focus-visible': mediaState(
mediaPagedApi(),
async (page) => {
await page.getByRole('button', { name: 'Next page' }).focus()
await page.keyboard.press('Shift+Tab')
await page.keyboard.press('Tab')
await scrollEverythingToTop(page)
},
'/media?page=7',
),
Offline: mediaState(mediaApi(), goOffline),
}
@@ -2931,6 +3051,23 @@ const mediaPurgeStates: Record<string, VisualState> = {
openMediaPurgeDialog,
mediaDuplicatesPath,
),
'Purge duplicates: confirm': mediaState(
mediaDuplicatesApi(undefined, {
[purgePreviewPath]: { status: 200, body: purgePreviewBody() },
}),
openMediaPurgeDialog,
mediaDuplicatesPath,
),
'Duplicates: no purge button (member)': mediaState(
mediaDuplicatesApi(undefined, {
'GET /v1/auth/me': {
status: 200,
body: { ...(mediaSession.body as object), role: 'member' },
},
}),
undefined,
mediaDuplicatesPath,
),
'Duplicates tab with the button': mediaState(
mediaDuplicatesApi(),
undefined,
@@ -4255,6 +4392,98 @@ function withVariantExceptions(states: Record<string, VisualState>) {
)
}
const variantsRowMobileCeilings: Record<string, number> = {
'Grid, many images': 33,
'Grid, few images': 26,
'List view': 19,
Loading: 30,
'Hover, focus and keyboard': 28,
'Selection and bulk bar': 31,
'Bulk delete: confirm': 23,
'Bulk delete: in progress': 23,
'Bulk delete: partly failed': 24,
'Bulk delete: done': 30,
'Detail panel: in use': 21,
'Detail panel: unused': 20,
'Copy URL': 21,
'Rename: dialog': 15,
'Rename: saving': 19,
'Rename: validation': 15,
'Rename: failed': 14,
'Rename: done': 20,
'Delete: image in use': 29,
'Delete: in progress': 29,
'Delete: done': 32,
'Image already gone (404)': 31,
'Upload: several files, mixed': 16,
'Upload: summary': 15,
'Upload: rejected files (413, 400)': 17,
'Upload: too many requests (429)': 29,
'Duplicates: groups': 29,
'Merge: confirm': 13,
'Merge: in progress': 13,
'Merge: done': 29,
Offline: 32,
'Pagination: first page': 33,
'Pagination: middle page': 33,
'Pagination: last page': 33,
'Pagination: list view': 19,
'Pagination: loading next page': 30,
'Pagination: error loading a page': 33,
'Pagination: page out of range': 33,
'Pagination: page size menu': 33,
'Pagination: focus-visible': 33,
'Purge duplicates: confirm': 24,
'Duplicates: no purge button (member)': 30,
'Duplicates tab with the button': 29,
'Duplicates tab: offline': 29,
}
function withVariantsRowException(
states: Record<string, VisualState>,
): Record<string, VisualState> {
return Object.fromEntries(
Object.entries(states).map(([name, state]) => {
const mobile = variantsRowMobileCeilings[name]
if (mobile === undefined) {
return [name, state]
}
return [
name,
{
...state,
exception: {
maxPercent: { desktop: MEDIA_DESKTOP_BUDGET, mobile },
reason: 'variants filter row is not drawn on the Media board (docs/design README: not folded into Media)',
approvedBy: 'user (Florian Sylvain), 2026-10-13',
},
},
]
}),
)
}
const MEDIA_DESKTOP_BUDGET = 10
function withDetailTabsException(states: Record<string, VisualState>): Record<string, VisualState> {
const name = 'Detail panel: duplicate'
const state = states[name]
if (!state) {
return states
}
return {
...states,
[name]: {
...state,
exception: {
maxPercent: { desktop: 11, mobile: 30 },
reason: 'Details | Variants tab row not drawn on the Media board (drawn on the Image detail board)',
approvedBy: 'user (Florian Sylvain), 2026-10-13',
},
},
}
}
export const visualStates: Record<string, Record<string, VisualState>> = {
activity: activityStates,
profile: {
@@ -5644,6 +5873,8 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
...withVariantExceptions(variantsMediaStates),
...withVariantExceptions(variantsEditorStates),
},
media: { ...mediaStates, ...mediaPurgeStates },
media: withDetailTabsException(
withVariantsRowException({ ...mediaStates, ...mediaPurgeStates }),
),
'media-picker': pickerStates,
}