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() {
+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
+8 -6
View File
@@ -284,9 +284,9 @@ describe('Response samples use the real domain JSON keys', () => {
expect(parsed(find('PATCH /v1/article/{id}/unpublish')).is_online).toBe(false)
})
it('uses items, next_cursor and counts for the media list', () => {
it('uses items, total, page, size and counts for the media list, with no next_cursor', () => {
const list = parsed(find('GET /v1/media/'))
expect(keysOf(list)).toEqual(['counts', 'items', 'next_cursor'])
expect(keysOf(list)).toEqual(['counts', 'items', 'page', 'size', 'total'])
expect(keysOf(list.counts)).toEqual(['all', 'duplicates', 'unused'])
expectOnlyKeys(list.items[0], MEDIA_ITEM_KEYS)
})
@@ -418,18 +418,20 @@ describe('Request bodies and query parameters', () => {
expect(Array.isArray(JSON.parse(list.response))).toBe(false)
})
it('lists q, hash, cursor, filter, sort and limit on the media list only', () => {
it('lists q, hash, filter, sort, variants, page and size on the media list only', () => {
expect(find('GET /v1/media/').query.map((param) => param.name)).toEqual([
'q',
'hash',
'cursor',
'filter',
'sort',
'limit',
'variants',
'page',
'size',
])
expect(find('GET /v1/media/').query.find((param) => param.name === 'limit')!.type).toBe(
expect(find('GET /v1/media/').query.find((param) => param.name === 'size')!.type).toBe(
'number',
)
expect(find('GET /v1/media/').errors.map((error) => error.status)).toContain(400)
expect(find('POST /v1/media/').query).toEqual([])
expect(find('GET /v1/settings').query).toEqual([])
})
+28 -11
View File
@@ -95,10 +95,24 @@ const FORMAT_QUERY: ApiField = {
rule: 'markdown, html or json. Default markdown.',
}
const PAGE_QUERY: ApiField = {
name: 'page',
type: 'number',
required: false,
rule: 'A whole number from 1. Default 1.',
}
const SIZE_QUERY: ApiField = {
name: 'size',
type: 'number',
required: false,
rule: '5, 25, 50 or 100. Default 25.',
}
const ARTICLE_LIST_QUERY: ApiField[] = [
FORMAT_QUERY,
{ name: 'page', type: 'number', required: false, rule: 'A whole number from 1. Default 1.' },
{ name: 'size', type: 'number', required: false, rule: '5, 25, 50 or 100. Default 25.' },
PAGE_QUERY,
SIZE_QUERY,
{
name: 'q',
type: 'string',
@@ -122,12 +136,6 @@ const MEDIA_QUERY: ApiField[] = [
required: false,
rule: 'SHA-256 of the content, lowercased.',
},
{
name: 'cursor',
type: 'string',
required: false,
rule: 'The next_cursor of the previous page.',
},
{
name: 'filter',
type: 'string',
@@ -138,9 +146,16 @@ const MEDIA_QUERY: ApiField[] = [
name: 'sort',
type: 'string',
required: false,
rule: 'newest, oldest, name or largest. Default newest.',
rule: 'newest, oldest, name, largest or total_size. Default newest.',
},
{ name: 'limit', type: 'number', required: false, rule: '1 to 100. Default 24.' },
{
name: 'variants',
type: 'string',
required: false,
rule: 'with or without. Default both.',
},
PAGE_QUERY,
SIZE_QUERY,
]
const json = (value: unknown) => JSON.stringify(value, null, 2)
@@ -423,7 +438,9 @@ export const API_GROUPS: ApiGroup[] = [
verified([e(400, 'The media query is invalid.'), e(401, UNAUTHORIZED)]),
json({
items: [mediaItem()],
next_cursor: null,
total: 1,
page: 1,
size: 25,
counts: { all: 1, unused: 0, duplicates: 0 },
}),
{ query: MEDIA_QUERY },
+3 -4
View File
@@ -1603,11 +1603,10 @@ export const messages = {
'champ:direction, avec le champ title, status, created_at ou updated_at et la direction asc ou desc. Par défaut updated_at:desc.',
'Search text.': 'Texte recherché.',
'SHA-256 of the content, lowercased.': 'SHA-256 du contenu, en minuscules.',
'The next_cursor of the previous page.': 'Le next_cursor de la page précédente.',
'all, unused or duplicates. Default all.': 'all, unused ou duplicates. Par défaut all.',
'newest, oldest, name or largest. Default newest.':
'newest, oldest, name ou largest. Par défaut newest.',
'1 to 100. Default 24.': 'De 1 à 100. Par défaut 24.',
'newest, oldest, name, largest or total_size. Default newest.':
'newest, oldest, name, largest ou total_size. Par défaut newest.',
'with or without. Default both.': 'with ou without. Par défaut les deux.',
"Read, write and publish articles. Titles can't be changed after creation.":
'Lire, écrire et publier des articles. Les titres ne peuvent pas être modifiés après la création.',
"3 to 200 characters. Can't be changed later.":
+13 -3
View File
@@ -136,7 +136,9 @@ describe('Switch language without losing work', () => {
}
return {
items,
next_cursor: null,
total: items.length,
page: 1,
size: 25,
counts: { all: items.length, unused: items.length, duplicates: 0 },
}
})
@@ -518,13 +520,21 @@ describe('Hardener localization outcomes', () => {
duplicate: false,
},
],
next_cursor: null,
total: 1,
page: 1,
size: 25,
counts: { all: 1, unused: 1, duplicates: 0 },
})
const listing = await request()
request.mockImplementation(async (path: string) =>
path.includes('?hash=')
? { items: [], next_cursor: null, counts: { all: 0, unused: 0, duplicates: 0 } }
? {
items: [],
total: 0,
page: 1,
size: 25,
counts: { all: 0, unused: 0, duplicates: 0 },
}
: listing,
)
await mountRoute('/media')
+76 -9
View File
@@ -1,5 +1,19 @@
import { describe, expect, it } from 'vitest'
import { DEFAULT_CONTROLS, parseMediaControls, toRequestQuery, toRouteQuery } from '../mediaQuery'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
DEFAULT_CONTROLS,
parseMediaControls,
toHashRequestQuery,
toRequestQuery,
toRouteQuery,
} from '../mediaQuery'
beforeEach(() => {
localStorage.clear()
})
afterEach(() => {
localStorage.clear()
})
describe('parseMediaControls', () => {
it('uses the defaults for an empty query', () => {
@@ -21,6 +35,34 @@ describe('parseMediaControls', () => {
expect(parseMediaControls(query)).toEqual({ ...DEFAULT_CONTROLS, ...expected })
})
it.each([
[{ page: '7' }, { page: 7 }],
[{ size: '50' }, { size: 50 }],
[
{ page: '3', size: '5' },
{ page: 3, size: 5 },
],
[{ page: '0' }, {}],
[{ page: '-2' }, {}],
[{ page: '1.5' }, {}],
[{ page: 'abc' }, {}],
[{ size: '10' }, {}],
[{ size: '0' }, {}],
[{ size: '200' }, {}],
[{ size: '' }, {}],
])('maps the paging of %j', (query, expected) => {
expect(parseMediaControls(query)).toEqual({ ...DEFAULT_CONTROLS, ...expected })
})
it('uses the stored size only when the URL has no size', () => {
localStorage.setItem('renewcms-page-size-media', '5')
expect(parseMediaControls({}).size).toBe(5)
expect(parseMediaControls({ size: '100' }).size).toBe(100)
expect(parseMediaControls({ size: '10' }).size).toBe(25)
expect(parseMediaControls({}, false).size).toBe(25)
})
it('takes the first value of a repeated parameter and trims the search', () => {
expect(parseMediaControls({ q: [' wall ', 'x'] }).q).toBe('wall')
})
@@ -33,20 +75,45 @@ describe('toRouteQuery', () => {
it('keeps only the non-default values', () => {
expect(
toRouteQuery({ q: 'wall', filter: 'unused', sort: 'largest', view: 'list' }),
toRouteQuery({
...DEFAULT_CONTROLS,
q: 'wall',
filter: 'unused',
sort: 'largest',
view: 'list',
}),
).toEqual({ q: 'wall', filter: 'unused', sort: 'largest', view: 'list' })
})
it('keeps the page and the size when they are not the defaults', () => {
expect(toRouteQuery({ ...DEFAULT_CONTROLS, page: 7, size: 50 })).toEqual({
page: '7',
size: '50',
})
})
it('drops the page and the size on the Duplicates tab', () => {
expect(
toRouteQuery({ ...DEFAULT_CONTROLS, filter: 'duplicates', page: 4, size: 5 }),
).toEqual({ filter: 'duplicates' })
})
})
describe('toRequestQuery', () => {
it.each([
[{}, 'filter=all&sort=newest&limit=100'],
[{ filter: 'unused' }, 'filter=unused&sort=newest&limit=100'],
[{ filter: 'duplicates' }, 'filter=duplicates&sort=newest&limit=100'],
[{ sort: 'largest' }, 'filter=all&sort=largest&limit=100'],
[{ q: 'wall' }, 'q=wall&filter=all&sort=newest&limit=100'],
[{ q: 'a b&c', filter: 'unused' }, 'q=a%20b%26c&filter=unused&sort=newest&limit=100'],
[{}, 'filter=all&sort=newest&page=1&size=25'],
[{ filter: 'unused' }, 'filter=unused&sort=newest&page=1&size=25'],
[{ filter: 'duplicates' }, 'filter=duplicates&sort=newest&page=1&size=25'],
[{ sort: 'largest' }, 'filter=all&sort=largest&page=1&size=25'],
[{ q: 'wall' }, 'q=wall&filter=all&sort=newest&page=1&size=25'],
[{ q: 'a b&c', filter: 'unused' }, 'q=a%20b%26c&filter=unused&sort=newest&page=1&size=25'],
])('builds the request for %j', (overrides, expected) => {
expect(toRequestQuery({ ...DEFAULT_CONTROLS, ...overrides })).toBe(expected)
})
})
describe('toHashRequestQuery', () => {
it('asks for every copy in one page', () => {
expect(toHashRequestQuery('abc')).toBe('hash=abc&page=1&size=100')
})
})
+56 -3
View File
@@ -1,4 +1,5 @@
import type { LocationQuery } from 'vue-router'
import { MEDIA_PAGE_SIZE_KEY, resolvePageSize } from '@/lib/pageSizeMemory'
export type MediaFilter = 'all' | 'unused' | 'duplicates'
export type MediaSort = 'newest' | 'oldest' | 'name' | 'largest' | 'total_size'
@@ -11,9 +12,15 @@ export interface MediaControls {
sort: MediaSort
variants: MediaVariantsFilter
view: MediaView
page: number
size: number
}
export const MEDIA_PAGE_LIMIT = 100
export const MEDIA_PAGE_SIZES = [5, 25, 50, 100]
export const DEFAULT_MEDIA_PAGE_SIZE = 25
export const MEDIA_FULL_SET_PAGE_SIZE = 100
const FIRST_PAGE = 1
const POSITIVE_INTEGER = /^[1-9]\d*$/
export const DEFAULT_CONTROLS: MediaControls = {
q: '',
@@ -21,6 +28,8 @@ export const DEFAULT_CONTROLS: MediaControls = {
sort: 'newest',
variants: 'all',
view: 'grid',
page: FIRST_PAGE,
size: DEFAULT_MEDIA_PAGE_SIZE,
}
const FILTERS: MediaFilter[] = ['all', 'unused', 'duplicates']
@@ -36,18 +45,57 @@ function pick<T extends string>(allowed: T[], value: string, fallback: T) {
return (allowed as string[]).includes(value) ? (value as T) : fallback
}
export function parseMediaControls(query: LocationQuery): MediaControls {
function parsePage(value: string) {
return POSITIVE_INTEGER.test(value) ? Number(value) : FIRST_PAGE
}
function parseSize(value: LocationQuery[string] | undefined, useStoredSize: boolean) {
const first = Array.isArray(value) ? value[0] : value
const raw = first === null ? '' : first
if (raw === undefined && !useStoredSize) {
return DEFAULT_MEDIA_PAGE_SIZE
}
return resolvePageSize(raw, MEDIA_PAGE_SIZE_KEY, MEDIA_PAGE_SIZES, DEFAULT_MEDIA_PAGE_SIZE)
}
export function parseMediaControls(query: LocationQuery, useStoredSize = true): MediaControls {
return {
q: firstValue(query.q).trim(),
filter: pick(FILTERS, firstValue(query.filter), DEFAULT_CONTROLS.filter),
sort: pick(SORTS, firstValue(query.sort), DEFAULT_CONTROLS.sort),
variants: pick(VARIANTS_FILTERS, firstValue(query.variants), DEFAULT_CONTROLS.variants),
view: pick(VIEWS, firstValue(query.view), DEFAULT_CONTROLS.view),
page: parsePage(firstValue(query.page)),
size: parseSize(query.size, useStoredSize),
}
}
export function isFullSet(controls: MediaControls) {
return controls.filter === 'duplicates'
}
export function sameMediaControls(left: MediaControls, right: MediaControls) {
const samePaging =
(isFullSet(left) && isFullSet(right)) ||
(left.page === right.page && left.size === right.size)
return (
left.q === right.q &&
left.filter === right.filter &&
left.sort === right.sort &&
left.variants === right.variants &&
left.view === right.view &&
samePaging
)
}
export function toRouteQuery(controls: MediaControls) {
const query: Record<string, string> = {}
if (!isFullSet(controls) && controls.page !== DEFAULT_CONTROLS.page) {
query.page = String(controls.page)
}
if (!isFullSet(controls) && controls.size !== DEFAULT_CONTROLS.size) {
query.size = String(controls.size)
}
if (controls.q) {
query.q = controls.q
}
@@ -72,7 +120,12 @@ export function toRequestQuery(controls: MediaControls) {
`filter=${controls.filter}`,
`sort=${controls.sort}`,
...(controls.variants === 'all' ? [] : [`variants=${controls.variants}`]),
`limit=${MEDIA_PAGE_LIMIT}`,
`page=${controls.page}`,
`size=${controls.size}`,
]
return parts.join('&')
}
export function toHashRequestQuery(hash: string) {
return `hash=${hash}&page=${FIRST_PAGE}&size=${MEDIA_FULL_SET_PAGE_SIZE}`
}
+1
View File
@@ -1,5 +1,6 @@
export const ARTICLES_PAGE_SIZE_KEY = 'renewcms-page-size-articles'
export const ACTIVITY_PAGE_SIZE_KEY = 'renewcms-page-size-activity'
export const MEDIA_PAGE_SIZE_KEY = 'renewcms-page-size-media'
export function readStoredPageSize(key: string, validSizes: number[]) {
try {
+4 -2
View File
@@ -256,7 +256,9 @@ describe('Nuxt UI shell foundation and router guards', () => {
const getMe = vi.fn().mockResolvedValue({ verified: true })
const request = vi.fn().mockResolvedValue({
items: [],
next_cursor: null,
total: 0,
page: 1,
size: 25,
counts: { all: 0, unused: 0, duplicates: 0 },
})
vi.mocked(useApi).mockReturnValue({ getMe, request })
@@ -268,7 +270,7 @@ describe('Nuxt UI shell foundation and router guards', () => {
expect(app.find('nav[aria-label="breadcrumb"]').text()).toBe('Media')
expect(app.find('a[href="/media"][aria-current="page"]').exists()).toBe(true)
expect(request).toHaveBeenCalledWith('/v1/media/?filter=all&sort=newest&limit=100')
expect(request).toHaveBeenCalledWith('/v1/media/?filter=all&sort=newest&page=1&size=25')
})
it('An unverified session on /media is sent to /register/pending', async () => {
+180 -30
View File
@@ -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>
@@ -187,12 +187,18 @@ afterEach(async () => {
await router.push('/login')
})
const EMPTY_LIBRARY = { items: [], next_cursor: null, counts: { all: 0, unused: 0, duplicates: 0 } }
const EMPTY_LIBRARY = {
items: [],
total: 0,
page: 1,
size: 25,
counts: { all: 0, unused: 0, duplicates: 0 },
}
function articleHandlers(extra: Record<string, () => Response | Promise<Response>> = {}) {
return {
'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()),
'GET /v1/media/?limit=24': () => mockResponse(200, EMPTY_LIBRARY),
'GET /v1/media/?page=1&size=25': () => mockResponse(200, EMPTY_LIBRARY),
...extra,
}
}
@@ -217,7 +223,8 @@ describe('Dropping a valid file onto the editor opens the uploader on the crop s
it('looks the hash up, skips the list, and uploads to /v1/media/ from the crop step', async () => {
const wrapper = await mountEditor(
articleHandlers({
[`GET /v1/media/?hash=${'a'.repeat(64)}`]: () => mockResponse(200, EMPTY_LIBRARY),
[`GET /v1/media/?hash=${'a'.repeat(64)}&page=1&size=100`]: () =>
mockResponse(200, EMPTY_LIBRARY),
}),
)
@@ -231,7 +238,7 @@ describe('Dropping a valid file onto the editor opens the uploader on the crop s
expect(document.body.textContent).toContain('Crop and resize')
expect(vi.mocked(global.fetch).mock.calls.map(([url]) => url)).not.toContain(
'/v1/media/?limit=24',
'/v1/media/?page=1&size=25',
)
findButton('Upload image')?.click()
@@ -38,7 +38,7 @@ function libraryPage() {
const items = fixture().map((item) =>
item.id === 2 ? { ...item, url: '/static/uploadedImages/2.webp' } : item,
)
return { items, next_cursor: 'c24', counts: { all: 48, unused: 2, duplicates: 2 } }
return { items, total: 48, page: 1, size: 25, counts: { all: 48, unused: 2, duplicates: 2 } }
}
let toastAdd: ReturnType<typeof vi.fn>
@@ -60,7 +60,7 @@ async function mountEditor(body = BODY_WITH_TWO_IMAGES) {
if (url === '/v1/article/12?format=html') {
return mockResponse(200, articleFixture(body))
}
if (url === '/v1/media/?limit=24') {
if (url === '/v1/media/?page=1&size=25') {
return mockResponse(200, libraryPage())
}
throw new Error(`Unexpected fetch call: ${url}`)
@@ -79,7 +79,13 @@ function libraryPage() {
4: '/static/uploadedImages/4.svg',
}
const items = fixture().map((item) => ({ ...item, url: urls[item.id] ?? item.url }))
return { items, next_cursor: null, counts: { all: 12, unused: 2, duplicates: 2 } }
return {
items,
total: items.length,
page: 1,
size: 25,
counts: { all: 12, unused: 2, duplicates: 2 },
}
}
let toastAdd: ReturnType<typeof vi.fn>
@@ -113,7 +119,7 @@ async function mountEditor(options: EditorOptions = {}) {
if (url === '/v1/article/12?format=html') {
return reply(200, article(options.body ?? '<p>Intro</p>'))
}
if (url === '/v1/media/?limit=24') {
if (url === '/v1/media/?page=1&size=25') {
return reply(200, libraryPage())
}
if (url === '/v1/image-variants') {
@@ -234,14 +234,15 @@ describe('Group scrolled, one card expanded', () => {
expect(published.text()).toContain('No request body.')
})
it('shows the six media query parameters on the media list', async () => {
it('shows the seven media query parameters on the media list', async () => {
const wrapper = await mountIntegration()
const list = await expand(wrapper, 'GET /v1/media/')
for (const name of ['q', 'hash', 'cursor', 'filter', 'sort', 'limit']) {
for (const name of ['q', 'hash', 'filter', 'sort', 'variants', 'page', 'size']) {
expect(list.text()).toContain(name)
}
expect(list.text()).toContain('1 to 100. Default 24.')
expect(list.text()).toContain('5, 25, 50 or 100. Default 25.')
expect(list.text()).not.toContain('next_cursor')
})
it('states the multipart field on the upload and the owner-only 403 and rate limit on invites', async () => {
@@ -79,7 +79,7 @@ beforeEach(() => {
afterEach(unmountPage)
describe('The Duplicates tab', () => {
it('is enabled, writes the URL, requests filter=duplicates with limit=100 and shows sort and view', async () => {
it('is enabled, writes the URL, requests filter=duplicates at size 100 and shows sort and view', async () => {
await mountPage({ list: duplicatesList() })
expect(document.body.querySelector('[aria-label="Sort"]')).not.toBeNull()
@@ -90,7 +90,7 @@ describe('The Duplicates tab', () => {
await settle()
expect(router.currentRoute.value.query).toEqual({ filter: 'duplicates' })
expect(lastListPath()).toBe('/v1/media/?filter=duplicates&sort=newest&limit=100')
expect(lastListPath()).toBe('/v1/media/?filter=duplicates&sort=newest&page=1&size=100')
expect(tab('Duplicates').getAttribute('aria-selected')).toBe('true')
expect(tabs().map(textOf)).toEqual(['All 12', 'Unused 2', 'Duplicates 2'])
expect(document.body.querySelector('[data-testid="duplicates-count"]')?.textContent).toBe(
@@ -123,7 +123,9 @@ describe('The Duplicates tab', () => {
vi.advanceTimersByTime(120)
await flushPromises()
expect(lastListPath()).toBe('/v1/media/?q=floor&filter=duplicates&sort=newest&limit=100')
expect(lastListPath()).toBe(
'/v1/media/?q=floor&filter=duplicates&sort=newest&page=1&size=100',
)
})
})
@@ -0,0 +1,256 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import router from '@/router'
import { listCalls, mountPage, press, settle, tab, textOf, unmountPage } from './mediaTestKit'
import { pagedHandler, parsePaged, library } from './mediaPagingKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return { ...actual, useToast: () => ({ add: vi.fn(), remove: vi.fn(), update: vi.fn() }) }
})
const KEY = 'renewcms-page-size-media'
function calls() {
return listCalls().map(([requested]) => parsePaged(requested as string))
}
function lastCall() {
return calls().at(-1)
}
function fullPath() {
return router.currentRoute.value.fullPath
}
function selectText() {
return textOf(document.body.querySelector('[aria-label="Rows per page"]'))
}
async function open(path = '/media') {
await mountPage({ list: pagedHandler() }, path)
}
async function reopen(path = '/media') {
await unmountPage()
await open(path)
}
async function choose(label: string) {
document.body.querySelector<HTMLElement>('[aria-label="Rows per page"]')?.click()
await flushPromises()
const option = Array.from(document.body.querySelectorAll('[role="option"]')).find(
(element) => element.textContent?.trim() === label,
)
;(option as HTMLElement).click()
await settle()
}
beforeAll(() => {
Element.prototype.scrollIntoView = vi.fn()
})
beforeEach(() => {
localStorage.clear()
})
afterEach(async () => {
await unmountPage()
})
describe('Choosing a size stores it', () => {
it.each([
['5 per page', '5', '/media?size=5'],
['25 per page', '25', '/media'],
['50 per page', '50', '/media?size=50'],
['100 per page', '100', '/media?size=100'],
])('%s', async (label, stored, url) => {
await open()
await choose(label)
expect(localStorage.getItem(KEY)).toBe(stored)
expect(fullPath()).toBe(url)
})
})
describe('Restoring the stored size', () => {
it('restores the stored size when /media is opened without a size', async () => {
localStorage.setItem(KEY, '5')
await open()
expect(calls()).toMatchObject([{ page: 1, size: 5 }])
expect(selectText()).toBe('5 per page')
expect(fullPath()).toBe('/media?size=5')
})
it('keeps the size after leaving the page and coming back', async () => {
await open()
await choose('5 per page')
await unmountPage()
await router.push('/')
await open()
expect(lastCall()).toMatchObject({ size: 5 })
expect(selectText()).toBe('5 per page')
})
it('keeps the size across a full page refresh', async () => {
await open()
await choose('5 per page')
await reopen('/media?size=5')
expect(lastCall()).toMatchObject({ size: 5 })
expect(selectText()).toBe('5 per page')
await reopen('/media')
expect(lastCall()).toMatchObject({ size: 5 })
expect(selectText()).toBe('5 per page')
})
it('keeps the stored size across paging, search, tabs, sort and view', async () => {
localStorage.setItem(KEY, '50')
await open('/media?size=50')
document.body.querySelector<HTMLButtonElement>('button[aria-label="Next page"]')?.click()
await settle()
tab('Unused').focus()
press(tab('Unused'), 'Enter')
await settle()
await router.replace({ query: { size: '50', sort: 'largest' } })
await settle()
await router.replace({ query: { size: '50', view: 'list' } })
await settle()
await router.replace({ query: { size: '50', q: 'image' } })
await settle()
expect(calls().length).toBeGreaterThan(4)
expect(calls().every((call) => call.size === 50)).toBe(true)
expect(localStorage.getItem(KEY)).toBe('50')
})
it('uses the stored size when only a page is given', async () => {
localStorage.setItem(KEY, '5')
await open('/media?page=3')
expect(lastCall()).toMatchObject({ page: 3, size: 5 })
expect(fullPath()).toBe('/media?page=3&size=5')
})
})
describe('Replacing the stored size', () => {
it('replaces the stored size when another one is chosen', async () => {
localStorage.setItem(KEY, '5')
await open()
await choose('100 per page')
expect(localStorage.getItem(KEY)).toBe('100')
await reopen()
expect(lastCall()).toMatchObject({ size: 100 })
})
it('remembers a return to 25', async () => {
localStorage.setItem(KEY, '5')
await open()
await choose('25 per page')
expect(localStorage.getItem(KEY)).toBe('25')
await reopen()
expect(lastCall()).toMatchObject({ size: 25 })
expect(fullPath()).toBe('/media')
})
})
describe('URL precedence', () => {
it('lets an explicit size win without storing it', async () => {
localStorage.setItem(KEY, '5')
await open('/media?size=100')
expect(lastCall()).toMatchObject({ page: 1, size: 100 })
expect(selectText()).toBe('100 per page')
expect(localStorage.getItem(KEY)).toBe('5')
await reopen()
expect(lastCall()).toMatchObject({ size: 5 })
})
it('ignores the stored size for an invalid size in the URL', async () => {
localStorage.setItem(KEY, '5')
await open('/media?size=10')
expect(calls()).toMatchObject([{ page: 1, size: 25 }])
expect(fullPath()).toBe('/media')
expect(localStorage.getItem(KEY)).toBe('5')
})
})
describe('An invalid stored value falls back to 25', () => {
it.each(['10', 'abc', '0', '-5', '25.5', ''])('stored %j', async (stored) => {
localStorage.setItem(KEY, stored)
await open()
expect(calls()).toMatchObject([{ page: 1, size: 25 }])
expect(selectText()).toBe('25 per page')
expect(fullPath()).toBe('/media')
})
})
describe('Unavailable storage never breaks the page', () => {
async function expectWorking() {
await open()
expect(calls()).toMatchObject([{ page: 1, size: 25 }])
expect(document.body.querySelector('[role="alert"]')).toBeNull()
await choose('50 per page')
expect(lastCall()).toMatchObject({ page: 1, size: 50 })
expect(fullPath()).toBe('/media?size=50')
}
it('throws on getItem', async () => {
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new Error('denied')
})
await expectWorking()
})
it('throws on setItem (quota)', async () => {
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('quota')
})
await expectWorking()
})
it('is undefined', async () => {
vi.stubGlobal('localStorage', undefined)
await expectWorking()
})
})
describe('The three lists keep separate sizes', () => {
it('reads only the media key on /media', async () => {
localStorage.setItem('renewcms-page-size-articles', '50')
localStorage.setItem('renewcms-page-size-activity', '100')
localStorage.setItem(KEY, '5')
await open()
expect(lastCall()).toMatchObject({ size: 5 })
})
})
describe('The Duplicates tab ignores the stored size', () => {
it('loads at size 100, keeps the stored value and sends it again on All', async () => {
localStorage.setItem(KEY, '5')
await mountPage({ list: pagedHandler(library(30, true)) }, '/media?filter=duplicates')
expect(calls().every((call) => call.size === 100)).toBe(true)
expect(localStorage.getItem(KEY)).toBe('5')
tab('All').focus()
press(tab('All'), 'Enter')
await settle()
expect(lastCall()).toMatchObject({ page: 1, size: 5, filter: 'all' })
expect(localStorage.getItem(KEY)).toBe('5')
})
})
@@ -0,0 +1,515 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import router from '@/router'
import { ApiError } from '@/composables/useApi'
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
import {
bulkBar,
callsTo,
cards,
checkboxFor,
deferred,
findButton,
listCalls,
mountPage,
press,
selectAllCheckbox,
settle,
tab,
textOf,
unmountPage,
} from './mediaTestKit'
import { library, pagedBody, pagedHandler, parsePaged, PAGED_COUNTS } from './mediaPagingKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return { ...actual, useToast: () => ({ add: vi.fn(), remove: vi.fn(), update: vi.fn() }) }
})
const FIRST_PAGE_REQUEST = '/v1/media/?filter=all&sort=newest&page=1&size=25'
function summary() {
return textOf(document.body.querySelector('[data-testid="paginator-summary"]'))
}
function control(label: string) {
return document.body.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`)
}
function paginatorNav() {
return document.body.querySelector('nav[aria-label="Pagination"]')
}
function paginatorButtons() {
return Array.from(paginatorNav()?.querySelectorAll<HTMLButtonElement>('button') ?? [])
}
function pageButtons() {
return paginatorButtons().filter((button) => /^\d+$/.test(textOf(button)))
}
function sizeTrigger() {
return document.body.querySelector<HTMLElement>('[aria-label="Rows per page"]')
}
function path() {
return router.currentRoute.value.fullPath
}
function alertText() {
return textOf(document.body.querySelector('[role="alert"], [role="status"]:not(.sr-only)'))
}
function listPaths() {
return listCalls().map(([requested]) => requested as string)
}
async function click(element: HTMLElement | null | undefined) {
element?.click()
await flushPromises()
}
async function choose(label: string) {
sizeTrigger()?.click()
await flushPromises()
const option = Array.from(document.body.querySelectorAll('[role="option"]')).find(
(element) => element.textContent?.trim() === label,
)
;(option as HTMLElement).click()
await settle()
}
beforeAll(() => {
Element.prototype.scrollIntoView = vi.fn()
})
beforeEach(() => {
localStorage.clear()
})
afterEach(async () => {
await unmountPage()
})
describe('44 First page', () => {
it('requests page 1 of 25, shows the range and disables First and Previous', async () => {
await mountPage({ list: pagedHandler() })
expect(listPaths()).toEqual([FIRST_PAGE_REQUEST])
expect(summary()).toBe('Showing 1–25 of 312')
expect(control('First page')?.disabled).toBe(true)
expect(control('Previous page')?.disabled).toBe(true)
expect(control('Next page')?.disabled).toBe(false)
expect(control('Last page')?.disabled).toBe(false)
expect(pageButtons()[0]?.getAttribute('aria-current')).toBe('page')
expect(path()).toBe('/media')
expect(cards()).toHaveLength(25)
})
})
describe('45 Middle page', () => {
it('marks page 7 as current with ellipses and every control enabled', async () => {
await mountPage({ list: pagedHandler() }, '/media?page=7')
expect(summary()).toBe('Showing 151–175 of 312')
const current = pageButtons().filter((button) => button.getAttribute('aria-current'))
expect(current.map(textOf)).toEqual(['7'])
expect(current[0]?.className).toContain('bg-(--rc-sand)')
expect(paginatorNav()?.textContent).toContain('…')
expect(paginatorButtons().every((button) => !button.disabled)).toBe(true)
})
})
describe('46 Last page', () => {
it('shows 12 images, the last range and disables Next and Last', async () => {
await mountPage({ list: pagedHandler() }, '/media?page=13')
expect(cards()).toHaveLength(12)
expect(summary()).toBe('Showing 301–312 of 312')
expect(control('Next page')?.disabled).toBe(true)
expect(control('Last page')?.disabled).toBe(true)
})
})
describe('Single page', () => {
it('keeps the summary and the page-size select and hides the paginator', async () => {
await mountPage({ list: pagedHandler(library(12), { all: 12, unused: 2, duplicates: 2 }) })
expect(summary()).toBe('Showing 1–12 of 12')
expect(sizeTrigger()).not.toBeNull()
expect(paginatorNav()).toBeNull()
})
})
describe('47 List view', () => {
it('puts the paginator under the table on desktop', async () => {
await mountPage({ list: pagedHandler() }, '/media?view=list&page=2')
const table = document.body.querySelector('[data-testid="media-row"]')!
const bar = document.body.querySelector('[data-testid="paginator"]')!
expect(table.compareDocumentPosition(bar) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
expect(pageButtons().length).toBeGreaterThan(2)
})
it('shows Previous, "Page 2 of 13" and Next under the card list on mobile', async () => {
await mountPage({ list: pagedHandler() }, '/media?view=list&page=2', MOBILE_WIDTH)
expect(paginatorNav()?.textContent).toContain('Page 2 of 13')
expect(pageButtons()).toHaveLength(0)
expect(paginatorButtons()).toHaveLength(2)
})
})
describe('48 Loading next page', () => {
it('shows the skeleton, disables the controls and moves the URL only on success', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const all = library()
const next = deferred<unknown>()
await mountPage({
list: (requested) =>
parsePaged(requested).page === 2 ? next.promise : pagedBody(all, requested),
})
await click(control('Next page'))
await vi.advanceTimersByTimeAsync(150)
expect(document.body.querySelector('[aria-busy="true"]')).not.toBeNull()
expect(summary()).toBe('Loading page 2…')
expect(paginatorButtons().every((button) => button.disabled)).toBe(true)
expect(path()).toBe('/media')
next.resolve(pagedBody(all, '/v1/media/?filter=all&sort=newest&page=2&size=25'))
await vi.advanceTimersByTimeAsync(0)
await flushPromises()
expect(summary()).toBe('Showing 26–50 of 312')
expect(path()).toBe('/media?page=2')
})
})
describe.each([
['a 500 response', () => Promise.reject(new ApiError(500))],
['a request that never reaches the server', () => Promise.reject(new TypeError('offline'))],
])('49 Error loading a page keeps the previous images (%s)', (_name, failure) => {
it('keeps the images and the URL, shows the notice and Retry loads the page', async () => {
const all = library()
let failing = false
await mountPage(
{
list: (requested) =>
failing && parsePaged(requested).page === 7
? failure()
: pagedBody(all, requested),
},
'/media?page=6',
)
const before = cards().map((card) => card.getAttribute('data-media-id'))
failing = true
await click(control('Next page'))
await settle()
expect(cards().map((card) => card.getAttribute('data-media-id'))).toEqual(before)
expect(path()).toBe('/media?page=6')
expect(alertText()).toContain("Couldn't load page 7. Showing page 6.")
expect(control('Next page')?.disabled).toBe(false)
failing = false
await click(findButton('Retry'))
await settle()
expect(summary()).toBe('Showing 151–175 of 312')
expect(path()).toBe('/media?page=7')
expect(alertText()).not.toContain("Couldn't load page")
})
})
describe('50 Page out of range', () => {
it('replaces the URL with the last page and shows the notice', async () => {
const push = vi.spyOn(router, 'push')
const replace = vi.spyOn(router, 'replace')
await mountPage({ list: pagedHandler() }, '/media?page=99')
expect(listPaths().map((requested) => parsePaged(requested).page)).toEqual([99, 13])
expect(replace).toHaveBeenCalledWith({ query: { page: '13' } })
expect(push).toHaveBeenCalledTimes(1)
expect(path()).toBe('/media?page=13')
expect(alertText()).toContain("That page doesn't exist. Showing the last page.")
expect(summary()).toBe('Showing 301–312 of 312')
})
it('shows the empty library state with no redirect when the total is 0', async () => {
await mountPage(
{ list: pagedHandler([], { all: 0, unused: 0, duplicates: 0 }) },
'/media?page=5',
)
expect(listPaths()).toHaveLength(1)
expect(document.body.textContent).toContain('No images yet')
expect(path()).toBe('/media')
})
it('shows the no results state after a search with total 0', async () => {
await mountPage({ list: pagedHandler() }, '/media?q=zzzz&page=5')
expect(document.body.textContent).toContain('No images match "zzzz"')
expect(path()).toBe('/media?q=zzzz')
})
})
describe('Controls reset to page 1', () => {
async function expectResetTo(path1: string, act: () => Promise<void> | void) {
await mountPage({ list: pagedHandler() }, '/media?page=7')
const before = listPaths().length
await act()
await settle()
const added = listPaths().slice(before)
expect(added.length).toBeGreaterThan(0)
expect(parsePaged(added.at(-1)!).page).toBe(1)
expect(path()).toBe(path1)
expect(textOf(document.body)).toContain('All 312')
}
it('on a search, once the debounce elapsed', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
await mountPage({ list: pagedHandler() }, '/media?page=7')
const input = document.body.querySelector<HTMLInputElement>(
'input[placeholder="Search by filename"]',
)!
input.value = 'image'
input.dispatchEvent(new Event('input', { bubbles: true }))
await vi.advanceTimersByTimeAsync(119)
expect(listPaths()).toHaveLength(1)
await vi.advanceTimersByTimeAsync(1)
await flushPromises()
expect(parsePaged(listPaths().at(-1)!).page).toBe(1)
expect(path()).toBe('/media?q=image')
expect(textOf(document.body)).toContain('All 312')
})
it('on a tab change', () =>
expectResetTo('/media?filter=unused', () => {
tab('Unused').focus()
press(tab('Unused'), 'Enter')
}))
it('on a sort change', () =>
expectResetTo('/media?sort=oldest', async () => {
await router.replace({ query: { sort: 'oldest' } })
}))
it('on a view change', () =>
expectResetTo('/media?view=list', () => {
document.body.querySelector<HTMLElement>('[aria-label="List view"]')?.click()
}))
it('on a variants filter change', () =>
expectResetTo('/media?variants=with', () => {
Array.from(document.body.querySelectorAll<HTMLElement>('button'))
.find((button) => textOf(button) === 'With variants')
?.click()
}))
})
describe('51 Page size menu', () => {
it.each([
['5 per page', 5, '/media?size=5'],
['25 per page', 25, '/media'],
['50 per page', 50, '/media?size=50'],
['100 per page', 100, '/media?size=100'],
])('choosing %s requests page 1 of %i and ends on %s', async (label, size, url) => {
await mountPage({ list: pagedHandler() }, '/media?page=3')
await choose(label)
expect(parsePaged(listPaths().at(-1)!)).toMatchObject({ page: 1, size })
expect(path()).toBe(url)
})
})
describe('Invalid URL values fall back silently', () => {
it.each(['size=10&page=abc', 'page=0', 'page=-2', 'page=1.5', 'size=0', 'size=200'])(
'/media?%s requests page 1 of 25 and replaces the URL',
async (query) => {
await mountPage({ list: pagedHandler() }, `/media?${query}`)
expect(listPaths()).toEqual([FIRST_PAGE_REQUEST])
expect(path()).toBe('/media')
expect(alertText()).toBe('')
},
)
})
describe('Pagination is kept across the other controls', () => {
it('keeps size, sort and filter on Next', async () => {
await mountPage(
{ list: pagedHandler(library(), PAGED_COUNTS) },
'/media?size=50&sort=largest&filter=unused',
)
await click(control('Next page'))
expect(listPaths().at(-1)).toBe('/v1/media/?filter=unused&sort=largest&page=2&size=50')
expect(path()).toBe('/media?page=2&size=50&filter=unused&sort=largest')
})
})
describe('52 Focus-visible and keyboard', () => {
it('gives every control the primary outline with a 2 px offset', async () => {
await mountPage({ list: pagedHandler() }, '/media?page=7')
for (const element of [...paginatorButtons(), sizeTrigger()]) {
expect(element?.className).toContain('focus-visible:outline-2')
expect(element?.className).toContain('focus-visible:outline-offset-2')
expect(element?.className).toContain('focus-visible:outline-primary')
}
})
it('opens the page-size list on ArrowDown and closes it on Escape', async () => {
await mountPage({ list: pagedHandler() })
const trigger = sizeTrigger()!
trigger.focus()
trigger.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }),
)
await flushPromises()
expect(document.body.querySelector('[role="listbox"]')).not.toBeNull()
;(document.activeElement ?? document.body).dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }),
)
await flushPromises()
expect(document.body.querySelector('[role="listbox"]')).toBeNull()
})
})
describe('Select all covers the current page only', () => {
it('selects the 25 images of page 2 and clears on the next page', async () => {
await mountPage({ list: pagedHandler() }, '/media?page=2')
expect(textOf(document.body)).toContain('Select all 25 images')
selectAllCheckbox().click()
await flushPromises()
expect(textOf(bulkBar())).toContain('25 selected')
await click(control('Next page'))
await settle()
expect(bulkBar()).toBeNull()
expect(checkboxFor('image-51.jpg').getAttribute('aria-checked')).toBe('false')
})
})
describe('The Duplicates tab', () => {
it('loads the whole set at size 100, groups it, shows no paginator and drops page and size', async () => {
const all = library(130, true)
await mountPage(
{ list: pagedHandler(all, { all: 312, unused: 2, duplicates: 65 }) },
'/media?filter=duplicates&page=4&size=5',
)
expect(listPaths()).toEqual([
'/v1/media/?filter=duplicates&sort=newest&page=1&size=100',
'/v1/media/?filter=duplicates&sort=newest&page=2&size=100',
])
expect(document.body.querySelectorAll('[data-testid="duplicate-group"]')).toHaveLength(65)
expect(paginatorNav()).toBeNull()
expect(document.body.querySelector('[data-testid="paginator-summary"]')).toBeNull()
expect(sizeTrigger()).toBeNull()
expect(path()).toBe('/media?filter=duplicates')
})
it('returns to the paginated list when All is selected', async () => {
await mountPage({ list: pagedHandler(library(130, true)) }, '/media?filter=duplicates')
tab('All').focus()
press(tab('All'), 'Enter')
await settle()
expect(listPaths().at(-1)).toBe(FIRST_PAGE_REQUEST)
expect(paginatorNav()).not.toBeNull()
})
})
describe('Deleting the last image of the last page', () => {
it('steps back one page, replaces the URL and refreshes the counts', async () => {
const all = library(26)
let removed = false
await mountPage(
{
list: (requested) => {
const live = removed ? all.slice(0, 25) : all
const counts = { all: live.length, unused: 2, duplicates: 2 }
return pagedBody(live, requested, counts)
},
remove: () => {
removed = true
},
},
'/media?page=2',
)
expect(cards()).toHaveLength(1)
cards()[0]!.click()
await settle()
findButton('Delete')!.click()
await settle()
const confirm = Array.from(document.body.querySelectorAll('button')).find(
(button) => textOf(button) === 'Delete image',
)
confirm?.click()
await settle()
expect(summary()).toBe('Showing 1–25 of 25')
expect(path()).toBe('/media')
expect(textOf(document.body)).toContain('All 25')
})
})
describe('A background refresh keeps the current page', () => {
it('requests page 7 again after a delete, with no skeleton and the same URL', async () => {
const all = library()
await mountPage({ list: pagedHandler(all), remove: () => undefined }, '/media?page=7')
const before = listPaths().length
cards()[0]!.click()
await settle()
findButton('Delete')!.click()
await settle()
Array.from(document.body.querySelectorAll('button'))
.find((button) => textOf(button) === 'Delete image')
?.click()
await settle()
const added = listPaths().slice(before)
expect(added).toEqual(['/v1/media/?filter=all&sort=newest&page=7&size=25'])
expect(document.body.querySelector('[data-testid="media-skeleton"]')).toBeNull()
expect(path()).toBe('/media?page=7')
})
})
describe('Hash lookups do not touch the page', () => {
it('sends hash, page=1 and size=100 and leaves the URL and the paginator alone', async () => {
await mountPage(
{
list: pagedHandler(library(60, true)),
hash: () => pagedBody(library(3, true), '/v1/media/?page=1&size=100'),
},
'/media?page=2',
)
cards()[0]!.click()
await settle()
expect(callsTo('GET', /\?hash=hash-\d+&page=1&size=100$/)).toHaveLength(1)
expect(path()).toBe('/media?page=2')
expect(summary()).toBe('Showing 26–50 of 60')
})
})
+20 -3
View File
@@ -261,16 +261,33 @@ describe('The selection follows the query', () => {
expect(bulkBar()).toBeNull()
})
it('clears when the sort changes and stays when the view changes', async () => {
it('clears when the view changes', async () => {
await mountPage()
await select('north-wall.jpg', 'kitchen-after.webp')
await router.replace({ query: { view: 'list' } })
await settle()
expect(textOf(bulkBar())).toContain('2 selected')
await router.replace({ query: { view: 'list', sort: 'oldest' } })
expect(bulkBar()).toBeNull()
})
it('clears when the sort changes', async () => {
await mountPage()
await select('north-wall.jpg', 'kitchen-after.webp')
await router.replace({ query: { sort: 'oldest' } })
await settle()
expect(bulkBar()).toBeNull()
})
it('clears when the page changes', async () => {
await mountPage()
await select('north-wall.jpg')
await router.push({ query: { page: '2' } })
await settle()
expect(bulkBar()).toBeNull()
})
+4 -2
View File
@@ -63,7 +63,9 @@ function hashLookups() {
function hashHandlerFor(name: string, existing = [5, 6, 12]): Handlers['hash'] {
return (path) =>
path === `/v1/media/?hash=${hexOf(name)}` ? listBody(fixture(existing)) : listBody([])
path === `/v1/media/?hash=${hexOf(name)}&page=1&size=100`
? listBody(fixture(existing))
: listBody([])
}
function headerButton(label: string) {
@@ -424,7 +426,7 @@ describe('A row whose content already exists', () => {
await mountWithDuplicate()
expect(hashLookups().map(([path]) => path)).toEqual([
`/v1/media/?hash=${hexOf('floorboards-2.jpg')}`,
`/v1/media/?hash=${hexOf('floorboards-2.jpg')}&page=1&size=100`,
])
expect(FakeXhr.instances).toHaveLength(0)
const row = uploadRows()[0]!
@@ -165,8 +165,8 @@ describe('The variants filter', () => {
})
it.each([
['With variants', '?variants=with', 'filter=all&sort=newest&variants=with&limit=100'],
['Without', '?variants=without', 'filter=all&sort=newest&variants=without&limit=100'],
['With variants', '?variants=with', 'filter=all&sort=newest&variants=with&page=1&size=25'],
['Without', '?variants=without', 'filter=all&sort=newest&variants=without&page=1&size=25'],
])('Choosing %s writes the URL and requests the list', async (label, search, request) => {
await mountPage()
@@ -202,7 +202,7 @@ describe('The variants filter', () => {
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/media')
expect(lastListPath()).toBe('/v1/media/?filter=all&sort=newest&limit=100')
expect(lastListPath()).toBe('/v1/media/?filter=all&sort=newest&page=1&size=25')
})
it.each([
@@ -223,7 +223,7 @@ describe('The variants filter', () => {
])('The query string is the source of truth: %s', async (search, request, selected, url) => {
await mountPage({}, `/media${search}`)
expect(lastListPath()).toBe(`/v1/media/?${request}&limit=100`)
expect(lastListPath()).toBe(`/v1/media/?${request}&page=1&size=25`)
expect(segment(selected).getAttribute('aria-pressed')).toBe('true')
expect(router.currentRoute.value.fullPath).toBe(url)
})
@@ -294,7 +294,7 @@ describe('Sorting by total size', () => {
await flushPromises()
expect(router.currentRoute.value.query).toEqual({ sort: 'total_size' })
expect(lastListPath()).toBe('/v1/media/?filter=all&sort=total_size&limit=100')
expect(lastListPath()).toBe('/v1/media/?filter=all&sort=total_size&page=1&size=25')
expect(textOf(document.body.querySelector('[aria-label="Sort"]'))).toBe(
'Largest total size (original + variants)',
)
@@ -321,7 +321,7 @@ describe('Sorting by total size', () => {
await mountPage({}, '/media?sort=total')
expect(router.currentRoute.value.fullPath).toBe('/media')
expect(lastListPath()).toBe('/v1/media/?filter=all&sort=newest&limit=100')
expect(lastListPath()).toBe('/v1/media/?filter=all&sort=newest&page=1&size=25')
})
})
+18 -15
View File
@@ -226,7 +226,7 @@ function fixture(ids?: number[]): MediaItem[] {
}
function listBody(items: MediaItem[], counts = { all: 12, unused: 2, duplicates: 2 }) {
return { items, next_cursor: null, counts }
return { items, total: items.length, page: 1, size: 25, counts }
}
function listWithout(id: number) {
@@ -440,7 +440,7 @@ describe('Default, many images', () => {
)
expect(document.body.querySelector('h1')?.textContent?.trim()).toBe('Media')
expect(listCalls().map(([path]) => path)).toEqual([
'/v1/media/?filter=all&sort=newest&limit=100',
'/v1/media/?filter=all&sort=newest&page=1&size=25',
])
expect(tabs().map(textOf)).toEqual(['All 12', 'Unused 2', 'Duplicates 2'])
expect(tab('All').getAttribute('aria-selected')).toBe('true')
@@ -565,7 +565,8 @@ describe('List view', () => {
expect(router.currentRoute.value.query).toEqual({ view: 'list' })
expect(document.body.querySelectorAll('[data-testid="media-row"]')).toHaveLength(12)
expect(listCalls()).toHaveLength(1)
expect(listCalls()).toHaveLength(2)
expect(lastListPath()).toBe('/v1/media/?filter=all&sort=newest&page=1&size=25')
activate(ariaLabel('Grid view'))
await flushPromises()
@@ -603,14 +604,14 @@ describe('List view', () => {
describe('The query string is the source of truth', () => {
it.each([
['?filter=unused', 'filter=unused&sort=newest&limit=100'],
['?sort=largest', 'filter=all&sort=largest&limit=100'],
['?sort=name', 'filter=all&sort=name&limit=100'],
['?sort=oldest', 'filter=all&sort=oldest&limit=100'],
['?q=wall', 'q=wall&filter=all&sort=newest&limit=100'],
['?view=list', 'filter=all&sort=newest&limit=100'],
['?filter=bogus&sort=zzz', 'filter=all&sort=newest&limit=100'],
['?filter=duplicates', 'filter=duplicates&sort=newest&limit=100'],
['?filter=unused', 'filter=unused&sort=newest&page=1&size=25'],
['?sort=largest', 'filter=all&sort=largest&page=1&size=25'],
['?sort=name', 'filter=all&sort=name&page=1&size=25'],
['?sort=oldest', 'filter=all&sort=oldest&page=1&size=25'],
['?q=wall', 'q=wall&filter=all&sort=newest&page=1&size=25'],
['?view=list', 'filter=all&sort=newest&page=1&size=25'],
['?filter=bogus&sort=zzz', 'filter=all&sort=newest&page=1&size=25'],
['?filter=duplicates', 'filter=duplicates&sort=newest&page=1&size=100'],
])('opening /media%s requests %s', async (url, request) => {
await mountPage({}, `/media${url}`)
@@ -657,7 +658,7 @@ describe('The query string is the source of truth', () => {
vi.advanceTimersByTime(1)
await flushPromises()
expect(router.currentRoute.value.query).toEqual({ q: 'wall' })
expect(lastListPath()).toBe('/v1/media/?q=wall&filter=all&sort=newest&limit=100')
expect(lastListPath()).toBe('/v1/media/?q=wall&filter=all&sort=newest&page=1&size=25')
tab('Unused').focus()
press(tab('Unused'), 'Enter')
@@ -712,7 +713,7 @@ describe('The Duplicates tab', () => {
expect(tab('Duplicates').getAttribute('aria-selected')).toBe('true')
expect(router.currentRoute.value.query).toEqual({ filter: 'duplicates' })
expect(lastListPath()).toBe('/v1/media/?filter=duplicates&sort=newest&limit=100')
expect(lastListPath()).toBe('/v1/media/?filter=duplicates&sort=newest&page=1&size=100')
})
})
@@ -813,7 +814,7 @@ describe('Empty library and no results', () => {
vi.advanceTimersByTime(120)
await flushPromises()
expect(lastListPath()).toBe('/v1/media/?q=wall&filter=unused&sort=newest&limit=100')
expect(lastListPath()).toBe('/v1/media/?q=wall&filter=unused&sort=newest&page=1&size=25')
expect(document.body.textContent).toContain('No images match "wall"')
expect(document.body.textContent).toContain(
'Nothing matches in Unused. Check the spelling, or clear the filters.',
@@ -998,7 +999,9 @@ describe('Detail panel', () => {
await clickCard('floorboards.jpg')
expect(requestMock.mock.calls.some(([path]) => path === '/v1/media/?hash=0e')).toBe(true)
expect(
requestMock.mock.calls.some(([path]) => path === '/v1/media/?hash=0e&page=1&size=100'),
).toBe(true)
const alert = panel()!.querySelector('[data-testid="duplicate-alert"]')!
expect(textOf(alert)).toContain('Identical to 2 other images')
expect(textOf(alert)).toContain(
+54
View File
@@ -0,0 +1,54 @@
import type { MediaItem } from '@/composables/useMedia'
import { fixture } from './mediaTestKit'
export const LIBRARY_SIZE = 312
export const PAGED_COUNTS = { all: LIBRARY_SIZE, unused: 2, duplicates: 2 }
export interface PagedParams {
page: number
size: number
filter: string
sort: string
q: string
}
export function library(count = LIBRARY_SIZE, duplicates = false): MediaItem[] {
const template = fixture()[0]!
return Array.from({ length: count }, (_, index) => ({
...template,
id: index + 1,
filename: `image-${index + 1}.jpg`,
url: `/media/image-${index + 1}.jpg`,
sha256: duplicates ? `hash-${Math.floor(index / 2)}` : `unique-${index}`,
duplicate: duplicates,
}))
}
export function parsePaged(path: string): PagedParams {
const query = new URL(path, 'http://localhost').searchParams
return {
page: Number(query.get('page')),
size: Number(query.get('size')),
filter: query.get('filter') ?? '',
sort: query.get('sort') ?? '',
q: query.get('q') ?? '',
}
}
export function pagedBody(all: MediaItem[], path: string, counts = PAGED_COUNTS) {
const { page, size, filter, q } = parsePaged(path)
const matching = all
.filter((item) => filter !== 'duplicates' || item.duplicate)
.filter((item) => !q || item.filename.includes(q))
return {
items: matching.slice((page - 1) * size, page * size),
total: matching.length,
page,
size,
counts,
}
}
export function pagedHandler(all: MediaItem[] = library(), counts = PAGED_COUNTS) {
return (path: string) => pagedBody(all, path, counts)
}
+12 -2
View File
@@ -248,8 +248,18 @@ export function fixture(ids?: number[]): MediaItem[] {
)
}
export function listBody(items: MediaItem[], counts = FULL_COUNTS) {
return { items, next_cursor: null, counts }
export function listBody(
items: MediaItem[],
counts = FULL_COUNTS,
paging: { total?: number; page?: number; size?: number } = {},
) {
return {
items,
total: paging.total ?? items.length,
page: paging.page ?? 1,
size: paging.size ?? 25,
counts,
}
}
export function deferred<T>() {
+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,
}