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

+2 -2
View File
@@ -4,8 +4,8 @@
Mock `XMLHttpRequest` (a fake with `open`, `setRequestHeader`, `send`, `abort`, firing `upload.progress`, `load`, `error`) for `POST /v1/media/`. Mock useApi's `request` for `GET /v1/media/` (list and `?hash=`), reusing the mock from `api_client_global_error_handling.qa.md`. Stub `hashFile` where a hash is needed, and add one test with `crypto.subtle` deleted. Mount EditorPage inside DashboardShell at `/article/12/edit` with the `editor_core.qa.md` fixture article, GET already resolved 200. Assert URLs by parsing the query, not by string equality.
1. **Toolbar button, empty library** - `GET /v1/media/?limit=24` resolves `{items: [], next_cursor: null, counts: {all: 0, unused: 0, duplicates: 0}}`; click "Image"; assert the dialog "Add an image", both tabs, the Upload tab selected, the drop zone copy, footer "Cancel" only, no XHR.
2. **Drop opens Upload with the file** - dispatch `drop` with `before.png` (image/png, 3.9 MB); assert the Upload tab, a `?hash=` request, no `?limit=24` list request, then the crop step after an empty lookup, and no XHR.
1. **Toolbar button, empty library** - `GET /v1/media/?page=1&size=25` resolves `{items: [], total: 0, page: 1, size: 25, counts: {all: 0, unused: 0, duplicates: 0}}`; click "Image"; assert the dialog "Add an image", both tabs, the Upload tab selected, the drop zone copy, footer "Cancel" only, no XHR.
2. **Drop opens Upload with the file** - dispatch `drop` with `before.png` (image/png, 3.9 MB); assert the Upload tab, a `?hash=...&page=1&size=100` request, no `?page=1&size=25` list request, then the crop step after an empty lookup, and no XHR.
3. **Dragging over shows "Drop to upload"**.
4. **Rejected, Examples-driven** - `plan.pdf`, `disguised.png`, `north-wall.tif`; assert no `?hash=` request and no XHR, plus the exact title, text, confirm and cancel.
5. **"Choose another file" returns to idle**, with the tabs back.
+22 -19
View File
@@ -17,13 +17,16 @@ Feature: The editor's Library tab, picking an image from the media library
a bottom sheet on mobile
And the footer reads "Cancel", and on the Library tab also a primary "Insert image" that is
disabled until an image is selected
And the Library tab calls GET /v1/media/ through useApi with limit=24, with q only while a
search is set and cursor only when loading more (no filter and no sort parameter), and the
Upload tab is specified in features/editor_image_upload.feature
And the response is { items, next_cursor, counts }, newest first; sizes read "8 KB" and
And the Library tab calls GET /v1/media/?q=Q&page=N&size=25 through useApi, with q only while a
search is set and page 1, then 2, 3, ... when loading more (no filter and no sort parameter),
and the Upload tab is specified in features/editor_image_upload.feature
And the response is { items, total, page, size, counts }, newest first; "Load more" shows while
the loaded count is below the response total (the matching count, not counts.all); sizes read "8 KB" and
"3.9 MB", dimensions read "2400 × 1600" (an SVG reads "Vector"), dates are absolute
And the 12-image fixture, ids 1 to 12, is the one in features/media_browse.feature, and the
picker mocks use next_cursor "c24" and counts { all: 48, unused: 2, duplicates: 2 }
picker mocks use total 48 and counts { all: 48, unused: 2, duplicates: 2 }
And this slice is amended by features/media_pagination_api.feature: cursor and limit are gone,
LIBRARY_PAGE_LIMIT 24 became the page size 25, and a mock with items equal to its total is the last page
And an image is "in this article" when an image node with its id as data-image-id is in
the editor's current content, unsaved insertions included, when the dialog opens;
in the examples below images 1 (north-wall.jpg) and 4 (logo-renew.svg) are in the article
@@ -42,13 +45,13 @@ Feature: The editor's Library tab, picking an image from the media library
Rule: Which tab opens
Scenario: The library has images, so Library opens
Given GET /v1/media/?limit=24 resolves 200 with the 12-image fixture
Given GET /v1/media/?page=1&size=25 resolves 200 with the 12-image fixture
When the user activates the toolbar's "Image" button
Then the Library tab is selected and the footer reads "Cancel" and a disabled "Insert image"
And nothing is drawn in the grid for the first 150 ms, then 8 skeleton tiles show until the response arrives
Scenario: The library is empty, so Upload opens
Given GET /v1/media/?limit=24 resolves 200 with no item and no search set
Given GET /v1/media/?page=1&size=25 resolves 200 with no item and no search set
When the dialog opens
Then the Upload tab is selected, in its idle state
When the user selects the Library tab
@@ -56,9 +59,9 @@ Feature: The editor's Library tab, picking an image from the media library
Scenario: A dropped file opens Upload and sends no list request
When the user drops "before.png" onto the editor
Then the Upload tab is selected and no GET /v1/media/?limit=24 is sent
Then the Upload tab is selected and no GET /v1/media/?page=1&size=25 is sent
When the user selects the Library tab
Then GET /v1/media/?limit=24 is sent
Then GET /v1/media/?page=1&size=25 is sent
Rule: Browsing
@@ -75,7 +78,7 @@ Feature: The editor's Library tab, picking an image from the media library
Then the line reads "Newest first"
Scenario: Load more is hidden on the last page
Given the response has next_cursor null
Given the loaded items equal the response total (for example total 12 with the 12-image fixture)
Then "Load more" is not shown
Scenario Outline: A tile's accessible name
@@ -167,16 +170,16 @@ Feature: The editor's Library tab, picking an image from the media library
Scenario: Typing searches after a debounce
Given the Library tab shows the 12-image fixture
When the user types "wall"
Then after a 120 ms debounce GET /v1/media/ is sent with q=wall and limit=24 and no cursor
Then after a 120 ms debounce GET /v1/media/ is sent with q=wall, page=1 and size=25
And skeleton tiles show after 150 ms, and any selection is cleared
Scenario: The x in the field clears the search
Given the search field holds "wall" with results shown
When the user activates the x in the field
Then the field is empty and GET /v1/media/ is sent with limit=24 and no q
Then the field is empty and GET /v1/media/ is sent with page=1 and size=25 and no q
Scenario: No results
Given GET /v1/media/?q=wall&limit=24 resolves 200 with no item
Given GET /v1/media/?q=wall&page=1&size=25 resolves 200 with no item
Then the search icon shows with the title `No images match "wall"`, the text "Check the spelling, or search for a shorter part of the filename." and a "Clear search" button
And the search field still shows "wall" above it, and "Insert image" is disabled
When the user activates "Clear search"
@@ -185,23 +188,23 @@ Feature: The editor's Library tab, picking an image from the media library
Rule: Load more
Scenario: Loading more shows progress and appends
Given the first page resolved with 12 images, next_cursor "c24"
Given the first page resolved with 12 images and total 48
When the user activates "Load more"
Then GET /v1/media/ is sent with limit=24 and cursor=c24
Then GET /v1/media/ is sent with page=2 and size=25
And the button reads "Loading…" with a loader-circle and is disabled, and one row of skeleton tiles shows at the end
And focus stays on the button
When the request resolves 200 with 12 more images
Then the new tiles are appended after the first 12, and focus moves to the first new tile
Scenario: The cursor and the search combine
Given the search "wall" is active and the page has next_cursor "c24"
Scenario: The next page and the search combine
Given the search "wall" is active and the response total is above the loaded count
When the user activates "Load more"
Then GET /v1/media/ is sent with q=wall, limit=24 and cursor=c24
Then GET /v1/media/ is sent with q=wall, page=2 and size=25
Rule: Empty and failed
Scenario: Empty library
Given the Library tab is selected and GET /v1/media/?limit=24 resolved with no item
Given the Library tab is selected and GET /v1/media/?page=1&size=25 resolved with no item
Then the images icon shows with "Your library is empty", "Images you upload appear here, ready to use in any article." and a primary "Upload an image"
And "Insert image" is disabled
When the user activates "Upload an image"
+8 -8
View File
@@ -2,29 +2,29 @@
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mock `request` for `GET /v1/media/` by parsed query. Use the 12-image fixture from `media_browse.feature` as `items`, with `next_cursor: "c24"` and `counts: {all: 48, unused: 2, duplicates: 2}`. Mount EditorPage at `/article/12/edit` with a body containing `<img data-image-id="1">` and `<img data-image-id="4">`. Use fake timers for the 150 ms and 120 ms steps.
Mock `request` for `GET /v1/media/` by parsed query. Use the 12-image fixture from `media_browse.feature` as `items`, with `total: 48`, `page`, `size: 25` and `counts: {all: 48, unused: 2, duplicates: 2}` (amended by `features/media_pagination_api.feature`: `next_cursor`, `cursor` and `limit` are gone). Mount EditorPage at `/article/12/edit` with a body containing `<img data-image-id="1">` and `<img data-image-id="4">`. Use fake timers for the 150 ms and 120 ms steps.
1. **Opens on Library with images** - first response has items; assert Library selected and skeleton tiles only after 150 ms.
2. **Opens on Upload when empty** - empty first response; assert Upload selected; then click Library and assert the empty panel.
3. **Drop sends no list request** - drop a file; assert no `?limit=24` request until Library is clicked.
3. **Drop sends no list request** - drop a file; assert no `?page=1&size=25` request until Library is clicked.
4. **Nothing selected** - assert the line, 12 tiles in order, the "In this article" tag on exactly ids 1 and 4, "Load more", "Insert image" disabled.
5. **Active search hides the count** - the line reads "Newest first".
6. **Last page** - `next_cursor: null` hides "Load more".
6. **Last page** - a total equal to the loaded count (for example 12) hides "Load more".
7. **Accessible names** - the three Examples.
8. **Selection** - click, Enter and Space each select; check, outline, "Insert image" enabled.
9. **Panel copy** - kitchen-after.webp panel, placeholder and help text exact; "Load more" hidden; SVG reads "Vector · 8 KB".
10. **Single selection resets alt**; reactivating keeps it selected.
11. **Insert** - alt "A kitchen with new oak worktops and white tiles" gives `insertImage("/static/uploadedImages/2.webp", alt, 2)`, SaveStatus "Unsaved changes", the toast, the dialog closed, no non-GET request. Empty alt gives `""`.
12. **Cancel, x and Escape** change nothing.
13. **Search** - type "wall"; at 119 ms no request, at 120 ms the request has `q=wall&limit=24` and no cursor; the selection is cleared. The field's x and "Clear search" send a request with no `q`.
13. **Search** - type "wall"; at 119 ms no request, at 120 ms the request has `q=wall&page=1&size=25`; the selection is cleared. The field's x and "Clear search" send a request with no `q`.
14. **No results** and **Empty** copy; "Upload an image" switches to the Upload tab.
15. **Load more** - request has `cursor=c24`; button "Loading…" and disabled; skeleton row of 4 (3 below md); focus on the button, then on the first new tile after resolve; combined `q` and `cursor`.
15. **Load more** - request has `page=2&size=25`; button "Loading…" and disabled; skeleton row of 4 (3 below md); focus on the button, then on the first new tile after resolve; combined `q` and `page=2`.
16. **Failed to load, Examples 500 and network** - alert copy; "Try again" repeats the request; success replaces the alert.
## Visual: `task visual:check -- media-picker`
Add a setup in `web/visual/states.ts` for each of the 13 states in `docs/design/screens/media-picker.md`, from the board's script (`RenewCMS Screens Back-office.dc.html` lines 239 to 253). Every capture must be within 10 % at 1440×900 and 390×844, light and dark. The sample data to reuse:
- Library mock: the 12-image fixture, `counts.all` 48, `next_cursor` non-null; images 1 and 4 in the article. The dialog is opened on the editor "How we price a bathroom renovation".
- Library mock: the 12-image fixture, `counts.all` 48, `total` 48; images 1 and 4 in the article. The dialog is opened on the editor "How we price a bathroom renovation".
- "Upload tab": Upload selected, idle drop zone.
- "Upload tab: already in your library": a chosen file named `north-wall.jpg`; the hash lookup returns id 1 (12 Sep 2026, Marta Alves); the thumbnail is 72 × 54.
- "Library: loading": skeleton tiles.
@@ -46,9 +46,9 @@ Manual checks the screenshots can't cover:
- [ ] Focus order is tabs, search, tiles, "Load more", footer. Enter and Space select a tile. Focus returns to the toolbar's image button when the dialog closes.
- [ ] Hover on a tile and on "Load more", and the focus ring.
- [ ] Motion: the dialog follows the standard 240 ms timing.
- [ ] A real library of more than 24 images through `task dev`: "Load more" and the cursor.
- [ ] A real library of more than 25 images through `task dev`: "Load more" and the page numbers.
- [ ] A screen reader reads the tile names and the alt text help.
## Manual verification
Real `GET /v1/media/` with `q` and `cursor` against `task dev` with a live SQLite file.
Real `GET /v1/media/` with `q`, `page` and `size` against `task dev` with a live SQLite file.
+1 -1
View File
@@ -21,7 +21,7 @@ Catalogue spec (pure data, no mount, imports web/src/data/apiCatalogue.ts):
- article: id, title, body, body_format, is_online, created_at, updated_at;
- media item: id, filename, url, size, content_type, width, height, sha256, uploaded_by,
created_at, used_in, duplicate;
- media list: items, next_cursor, counts (all, unused, duplicates);
- media list: items, total, page, size, counts (all, unused, duplicates), no next_cursor;
- merge: articles_updated, merged;
- usage: id, title, published;
- user list item: id, kind, username, display_name, email, role, status, created_at;
+11 -7
View File
@@ -20,8 +20,12 @@ Feature: Media page, part 1: browse and manage
And useMedia is a composable over useApi. It exposes the list state (items, counts, loading,
error), the request functions for list, usage, rename and delete, and the copy helper, and
it maps ApiError and NetworkError to the states below
And MediaPage calls GET /v1/media/ with the query built from the URL, always with limit=100,
and every request carries the jwt cookie through useApi
And MediaPage calls GET /v1/media/ with the query built from the URL, always with an explicit
page and size (page=1&size=25 by default, or the stored size), and every request carries
the jwt cookie through useApi
And this slice is amended by features/media_pagination_api.feature and
features/media_pagination_screen.feature: limit=100 and next_cursor are gone, the body is
{ items, total, page, size, counts }, and the list is paginated
And search, tab, sort and view live in the URL query string so the view can be linked and
survives a reload: ?q= (search text), ?filter= ("unused" or "duplicates"; absent means
All), ?sort= ("oldest", "name" or "largest"; absent means Newest), ?view= ("list"; absent
@@ -90,10 +94,10 @@ Feature: Media page, part 1: browse and manage
| 10 | bathroom-tiles.png | 860 KB | 1800 × 1200 | PNG | 0 | Marta Alves | 4 Aug 2026 | alpha |
| 11 | lime-plaster.jpg | 2.1 MB | 3200 × 2133 | JPEG | 2 | Inès Duarte | 19 Jul 2026 | |
| 12 | floorboards.jpg | 2.4 MB | 3600 × 2400 | JPEG | 4 | Inès Duarte | 14 Jun 2026 | duplicate |
with counts { all: 12, unused: 2, duplicates: 2 }, and next_cursor null
with counts { all: 12, unused: 2, duplicates: 2 }, total 12, page 1, size 25
Scenario: Default, many images
Given GET /v1/media/?filter=all&sort=newest&limit=100 resolves 200 with the 12-image fixture
Given GET /v1/media/?filter=all&sort=newest&page=1&size=25 resolves 200 with the 12-image fixture
When the user navigates to /media
Then the breadcrumb reads "Media" (mobile title "Media")
And the tabs read "All 12", "Unused 2", "Duplicates 2", with "All" selected, and the
@@ -144,7 +148,7 @@ Feature: Media page, part 1: browse and manage
Scenario Outline: The query string is the source of truth for search, tab, sort and view
Given GET /v1/media/ is mocked
When the user opens /media<url>
Then GET /v1/media/ is sent with "<request>" and limit=100
Then GET /v1/media/ is sent with "<request>" and page=1&size=25
And the control shows "<control>"
Examples:
@@ -161,7 +165,7 @@ Feature: Media page, part 1: browse and manage
Scenario: Changing a control writes the query string and reloads the list
Given the Default state has loaded
When the user types "wall" in the search field
Then after a 120 ms debounce the URL gains ?q=wall and GET /v1/media/?q=wall&filter=all&sort=newest&limit=100 is sent
Then after a 120 ms debounce the URL gains ?q=wall and GET /v1/media/?q=wall&filter=all&sort=newest&page=1&size=25 is sent
When the user selects the "Unused" tab
Then the URL reads ?q=wall&filter=unused and the list is requested again
When the user picks "Largest" in the sort menu
@@ -187,7 +191,7 @@ Feature: Media page, part 1: browse and manage
Scenario: No results, search and filter
Given the Default state has loaded
When the user selects "Unused" and types "wall" and GET /v1/media/?q=wall&filter=unused&sort=newest&limit=100 resolves 200 with zero items
When the user selects "Unused" and types "wall" and GET /v1/media/?q=wall&filter=unused&sort=newest&page=1&size=25 resolves 200 with zero items
Then a panel replaces the images with the search icon, title `No images match "wall"`, text "Nothing matches in Unused. Check the spelling, or clear the filters." and an outline "Clear filters" button
When the user activates "Clear filters"
Then the search field is emptied, the tab returns to "All", ?q and ?filter leave the URL, and the Default cards reappear
+3 -2
View File
@@ -147,5 +147,6 @@ Manual checks screenshots cannot cover:
- **Toast placement and stacking** on a live app.
- **Counts after a delete** against a live server: the background refresh gives the true Unused
and Duplicates counts.
- **A library above 100 images**: B1 requests `limit=100` and ignores `next_cursor`, so only the
first 100 show.
- **A library above 25 images**: amended by `features/media_pagination_screen.feature`. B1 used to
request `limit=100` and ignore `next_cursor`; the page now requests `page=1&size=25` (or the stored
size) and shows a paginator, so every image is reachable.
+1 -1
View File
@@ -21,7 +21,7 @@ Package: `test` (black-box). New file `test/media_library_http_test.go`. Fake ty
## HTTP checks
10. Auth table: each method+path from the outline with no cookie -> 401, stale cookie -> 401 with `apperr.UnauthorizedErrorMessage`, unverified cookie -> 403 with `apperr.VerificationRequiredErrorMessage`. Assert no fake method beyond the middleware's user lookup was called.
11. List: decode `{items, next_cursor, counts}`. Assert each row of the sort, filter, `q` and `hash` tables, the combined query, the paging walk (limit 2 across three pages ending in `next_cursor == null`), the empty library (`items` is `[]` in the raw JSON, not `null`), SVG `width`/`height` are JSON `null` (assert on the raw body), and `counts` is unchanged by `q`/`filter`. Assert every 400 query row, body equals `apperr.MediaQueryErrorMessage + "\n"`.
11. List (amended by `features/media_pagination_api.feature`): decode `{items, total, page, size, counts}`, with no `next_cursor`. Assert each row of the sort, filter, `q` and `hash` tables, the combined query, the paging walk (`size=5` across three pages with `page=1..3`, each reporting the same `total`), the empty library (`items` is `[]` in the raw JSON, not `null`), SVG `width`/`height` are JSON `null` (assert on the raw body), and `counts` is unchanged by `q`/`filter`. Assert every 400 query row, body equals `apperr.MediaQueryErrorMessage + "\n"`.
12. Upload: for each allowed type assert 201, the item fields, `sha256` equals the test's own hash, `uploaded_by`, the url regex, and one repo entry with `UploadedByID == 1`. Upload identical bytes twice: both 201, second has `duplicate: true`, list `counts.duplicates == 1`. Assert filename rules (no extension gets `.png`, path stripped). Assert 413/415/400 statuses and bodies, that `Create` is never called on rejection, boundary 10485760 vs 10485761, a missing `file` part -> 400, and an armed `Create` failure -> 500 `"Internal server error\n"`.
13. Rename: assert 200 with the new filename, the same `url`, the article body unchanged, and every invalid row -> 400 with `"Enter a filename.\n"` for the filename rows; 404 for 9999; 400 with `apperr.IdUint32ErrorMessage` for `abc`. Assert the repository filename is unchanged after each 400.
14. Delete: in-use image -> 204 with an empty body, article bodies byte-identical, `deleteCalls == [1]`; 404, 400, 500 rows. The "file already gone" case is a persistence behaviour and is a manual item.
+128
View File
@@ -0,0 +1,128 @@
Feature: Media page remembers the chosen page size
As a verified RenewCMS user
I want the page size I choose on the Media page to be remembered
So that I do not have to pick it again each time I come back or refresh
Background:
Given this slice follows features/articles_page_size_memory.feature and extends features/media_pagination_screen.feature; the URL keeps ?size= as in that spec and no visual or API change is made
And the size options stay "5 per page", "25 per page", "50 per page", "100 per page" and the default stays 25
And the chosen size is stored in localStorage under the key renewcms-page-size-media as the bare number (for example 5), one key per paginated list; Articles uses renewcms-page-size-articles and Activity renewcms-page-size-activity, and the three do not affect each other
And a MEDIA_PAGE_SIZE_KEY constant joins ARTICLES_PAGE_SIZE_KEY and ACTIVITY_PAGE_SIZE_KEY in web/src/lib/pageSizeMemory.ts and the existing readStoredPageSize, storePageSize and resolvePageSize helpers are reused
And only choosing a size in the page-size select writes the stored value; loading a URL never writes it
And an explicit valid ?size= in the URL is authoritative for that load and does not change the stored value
And an invalid ?size= in the URL falls back to 25, never to the stored value, and is replaced in the URL (features/media_pagination_screen.feature)
And a stored value that is not 5, 25, 50 or 100 is treated as absent
And localStorage that is unavailable or throws on read or write never raises an error or breaks the page: reads act as absent and writes are ignored
And logout does not clear the stored size
And a size change still resets page to 1 and drops page from the URL
And the Duplicates tab ignores the stored size (it always loads with size=100) and never writes it
And the picker and hash lookups ignore the stored size (fixed size=25 and size=100)
And the requests are GET /v1/media/?filter=F&sort=S&page=P&size=Z as in features/media_pagination_api.feature
Scenario Outline: Choosing a size stores it
Given nothing is stored and the user is on /media
When the user chooses "<label>" in the page-size select
Then localStorage renewcms-page-size-media is "<stored>"
And on success the URL is <url>
Examples:
| label | stored | url |
| 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 |
Scenario: The stored size is restored when the page is opened without a size
Given localStorage renewcms-page-size-media is "5"
When the user navigates to /media
Then GET ...&page=1&size=5 is sent
And the page-size select reads "5 per page"
And on success the URL is replaced (no history entry) by /media?size=5
Scenario: The size survives leaving the page and coming back
Given the user chose "5 per page" on /media
When the user navigates to /, then back to /media
Then GET ...&size=5 is sent and the select reads "5 per page"
Scenario: The size survives a full page refresh
Given the user chose "5 per page" on /media
When the page is reloaded on /media?size=5, or on /media with the URL typed in without a size
Then GET ...&size=5 is sent and the select reads "5 per page"
Scenario: The stored size is kept across paging, search, tabs, sort and view
Given localStorage renewcms-page-size-media is "50" and the user is on /media?size=50
When the user activates Next, switches to the "Unused" tab, sorts by "Largest", switches to list view or searches "wall"
Then every request carries size=50 and the stored value stays "50"
Scenario: Changing the size again replaces the stored one
Given localStorage renewcms-page-size-media is "5"
When the user chooses "100 per page"
Then localStorage renewcms-page-size-media is "100"
And the next visit to /media without a size sends size=100
Scenario: Returning to 25 is remembered too
Given localStorage renewcms-page-size-media is "5"
When the user chooses "25 per page"
Then localStorage renewcms-page-size-media is "25"
And the next visit to /media sends size=25 and the URL stays /media
Scenario: An explicit size in the URL wins and is not stored
Given localStorage renewcms-page-size-media is "5"
When the user navigates to /media?size=100
Then GET ...&size=100 is sent and the select reads "100 per page"
And localStorage renewcms-page-size-media is still "5"
And a later visit to /media sends size=5
Scenario: An explicit page with no size uses the stored size
Given localStorage renewcms-page-size-media is "5"
When the user navigates to /media?page=3
Then GET ...&page=3&size=5 is sent and on success the URL is replaced by /media?page=3&size=5
Scenario Outline: An invalid stored value falls back to 25
Given localStorage renewcms-page-size-media is "<stored>"
When the user navigates to /media
Then GET ...&page=1&size=25 is sent, the select reads "25 per page" and the URL is /media
Examples:
| stored |
| 10 |
| abc |
| 0 |
| -5 |
| 25.5 |
| |
Scenario: An invalid size in the URL ignores the stored one
Given localStorage renewcms-page-size-media is "5"
When the user navigates to /media?size=10
Then GET ... is sent with size=25 and the URL is replaced by /media
And localStorage renewcms-page-size-media is still "5"
Scenario Outline: Unavailable storage never breaks the page
Given localStorage <failure>
When the user navigates to /media
Then GET ...&page=1&size=25 is sent, the page renders and no error is shown
When the user chooses "50 per page"
Then GET ...&page=1&size=50 is sent and the URL is /media?size=50
Examples:
| failure |
| throws on getItem |
| throws on setItem (quota) |
| is undefined |
Scenario: Logging out keeps the stored size
Given localStorage renewcms-page-size-media is "5"
When the user logs out and logs in again and opens /media
Then GET ...&size=5 is sent
Scenario: The three lists keep separate sizes
Given localStorage has renewcms-page-size-articles "50", renewcms-page-size-activity "100" and renewcms-page-size-media "5"
When the user opens /article, /activity and /media in turn without a size
Then they send size=50, size=100 and size=5 respectively
Scenario: The Duplicates tab ignores the stored size
Given localStorage renewcms-page-size-media is "5"
When the user opens /media?filter=duplicates
Then the requests carry size=100, the stored value stays "5", and returning to the "All" tab sends size=5
+28
View File
@@ -0,0 +1,28 @@
# QA: Media page size memory (SPA)
## Automated (Vitest + Vue Test Utils, `task test:web`)
Add `web/src/views/__tests__/MediaPageSizeMemory.spec.ts`, mirroring `ArticlesPageSizeMemory.spec.ts` and `ActivityPageSizeMemory.spec.ts`: stub `localStorage` per test (a `Storage` fake, plus variants whose `getItem` or `setItem` throws, and `undefined`), use the `/v1/media/` mock of `mediaTestKit.ts` that honours `page` and `size`, and a real router. Extend `web/src/lib/__tests__/pageSizeMemory.spec.ts` for `MEDIA_PAGE_SIZE_KEY`.
1. Choose a size table (5, 25, 50, 100) with nothing stored: stored value per the table and URLs `/media?size=5`, `/media`, `/media?size=50`, `/media?size=100`.
2. Stored 5, open `/media`: request `size=5`, select reads "5 per page", URL replaced (not pushed) by `/media?size=5`.
3. Leave and come back (navigate to `/` then `/media`): `size=5`.
4. Reload on `/media?size=5` and on `/media` with a fresh mount: `size=5`.
5. Stored 50: Next, "Unused" tab, "Largest" sort, list view and search "wall" each carry `size=50`; the stored value stays "50".
6. Choose 100 after 5: stored "100", next visit `size=100`. Choose 25 after 5: stored "25", URL stays `/media`.
7. Explicit `?size=100` with stored 5: request `size=100`, stored still "5", later visit `size=5`.
8. `?page=3` with stored 5: request `page=3&size=5`, URL replaced by `/media?page=3&size=5`.
9. Invalid stored table (`10`, `abc`, `0`, `-5`, `25.5`, empty): request `size=25`, URL `/media`.
10. Invalid URL `?size=10` with stored 5: request `size=25`, URL `/media`, stored still "5".
11. Unavailable storage table (throws on getItem, throws on setItem, undefined): page renders, no error, `size=25`, then choosing 50 sends `size=50` and URL `/media?size=50`.
12. Logout and login keeps the key (clear the auth store only; storage untouched), then `/media` sends `size=5`.
13. Separate keys: articles 50, activity 100, media 5 -> the three views send 50, 100 and 5.
14. Duplicates tab: stored 5 -> requests carry `size=100`, stored unchanged, returning to All sends `size=5`.
15. Unit test of the lib: `readStoredPageSize`, `storePageSize` and `resolvePageSize` with the media key and the valid sizes 5/25/50/100.
No Visual part: no visual or design change.
## Manual verification
- In a real browser (`task dev`): choose "5 per page", refresh, close and reopen the tab, confirm the select and the URL; in DevTools confirm only `renewcms-page-size-media` changed.
- Block storage (private mode or disabled site data) and confirm the page still works.
+148
View File
@@ -0,0 +1,148 @@
Feature: Paginated media listing
As an API consumer, the RenewCMS media page or the editor picker
I want GET /v1/media/ to answer one page of images with the total and the library-wide counts
So that large libraries load quickly and every consumer pages the same way
Background:
Given GET /v1/media/ is mounted inside the jwtauth.Verifier + Authenticator group of api/router.go, unchanged
And BREAKING CHANGE: the body is {"items": [...], "total": N, "page": P, "size": S, "counts": {"all": A, "unused": U, "duplicates": D}} in snake_case; next_cursor is removed and no X-Total-Count header is sent
And each item is the existing media item (id, filename, url, size, content_type, width, height, sha256, uploaded_by, created_at, used_in, duplicate, variant_count, variant_size) and items is [] rather than null
And paging follows the Activity and Articles convention: page is an integer >= 1 (default 1), size is one of 5, 25, 50, 100 (default 25)
And q, hash, filter (all, unused, duplicates), sort (newest, oldest, name, largest, total_size) and variants (with, without) keep their current meaning and validation; the default order is newest
And total is the number of images matching q, hash, filter and variants, before paging; page and size echo the values applied
And counts stays library-wide: it ignores q, hash, filter, variants and paging
And ordering is applied to the whole matching list before slicing, with id as the final tie-break, so pages never overlap or skip (newest and total_size are made deterministic; oldest, name and largest already tie on id)
And a page beyond the last answers 200 with items [] and the real total
And any invalid page, size, filter, sort, variants or hash answers 400 with the plain-text body "The media query is invalid." (apperr.MediaQueryErrorMessage); page and size are validated with validate tags through validator.Validate.Struct
And cursor and limit are no longer parameters: they are ignored, never an error, and have no effect on paging
And ListMediaUseCase reaches data only through domain interfaces; the domain ListQuery carries Page and Size instead of Cursor and Limit; the handler never touches a repository
And the SPA sends explicit page and size on every list call
Scenario: A bare request answers the envelope with the defaults
Given the library holds 30 images
When a verified client sends GET /v1/media/
Then the status is 200
And the body has page 1, size 25, total 30, 25 items and counts {"all": 30, "unused": U, "duplicates": D}
And the body has no next_cursor key
Scenario Outline: Defaults when only one parameter is given
Given the library holds 60 images
When a verified client sends GET /v1/media/?<query>
Then the body has page <page>, size <size>, total 60 and <count> items
Examples:
| query | page | size | count |
| page=2 | 2 | 25 | 25 |
| page=3 | 3 | 25 | 10 |
| size=5 | 1 | 5 | 5 |
| size=50 | 1 | 50 | 50 |
| size=100 | 1 | 100 | 60 |
Scenario: Pages partition the list with no overlap
Given the library holds 12 images
When a client requests ?page=1&size=5, ?page=2&size=5 and ?page=3&size=5
Then the pages hold 5, 5 and 2 items, total is 12 on each, and no id repeats
Scenario: Newest first with the id tie-break
Given images id 1 uploaded 2026-09-01, id 2 and id 3 uploaded 2026-09-03
When a client sends GET /v1/media/?size=5
Then the item ids are 3, 2, 1
Scenario Outline: Sort orders the whole library before paging
Given 12 images with distinct names, sizes and dates
When a client requests ?sort=<sort>&size=5 for pages 1, 2 and 3
Then the pages hold the whole ordered list in <order> with no repeat
Examples:
| sort | order |
| newest | created_at descending |
| oldest | created_at ascending |
| name | filename stem, case-insensitive ascending |
| largest | size descending |
| total_size | original size plus generated variant bytes, descending |
Scenario: Filter and variants narrow total and items but not the counts
Given 40 images of which 6 are unused and 3 hold generated variants, and the library counts are {"all": 40, "unused": 6, "duplicates": 2}
When a client sends GET /v1/media/?filter=unused&size=5
Then total is 6, the page holds 5 items and counts is {"all": 40, "unused": 6, "duplicates": 2}
When a client sends GET /v1/media/?variants=with
Then total is 3 and counts is unchanged
Scenario: Search combines with paging
Given 7 images whose filename contains "wall" and 20 others
When a client sends GET /v1/media/?q=wall&size=5&page=2
Then total is 7, the page holds 2 items and counts.all is 27
Scenario: Hash lookup returns every copy in one page
Given 3 images share the sha256 H
When a client sends GET /v1/media/?hash=H&page=1&size=100
Then total is 3 and items has the 3 copies
Scenario: Hash lookup with no paging uses the defaults
Given 3 images share the sha256 H
When a client sends GET /v1/media/?hash=H
Then the body has page 1, size 25, total 3 and 3 items
Scenario: A page beyond the last is empty with the real total
Given the library holds 12 images
When a client sends GET /v1/media/?page=99&size=5
Then the status is 200, items is [], total is 12, page is 99, size is 5 and counts.all is 12
Scenario: An empty library
Given the library is empty
When a client sends GET /v1/media/
Then the status is 200, items is [], total is 0 and counts is {"all": 0, "unused": 0, "duplicates": 0}
Scenario: counts ignores every filter and the page
Given the library holds 30 images
When a client sends GET /v1/media/?q=zzzz&page=3&size=5
Then items is [], total is 0 and counts.all is 30
Scenario Outline: cursor and limit are ignored
Given the library holds 30 images
When a client sends GET /v1/media/?<query>
Then the status is 200 and the body has page 1, size 25 and 25 items
Examples:
| query |
| cursor=abc |
| limit=5 |
| limit=0 |
| limit=abc |
| cursor=b2Zmc2V0OjU |
Scenario Outline: Invalid parameters answer 400
When a client sends GET /v1/media/?<query>
Then the status is 400
And the body is "The media query is invalid."
And the repository was not called
Examples:
| query |
| page=0 |
| page=-1 |
| page=abc |
| page= |
| page=1.5 |
| size=0 |
| size=10 |
| size=200 |
| size=abc |
| size= |
| page=0&size=5 |
| page=1&size=1000 |
| filter=archived |
| sort=biggest |
| variants= |
| variants=other |
| hash=notahash |
Scenario: Repository failure answers 500
Given the repository fails on its next call
When a client sends GET /v1/media/
Then the status is 500 and the body is "Internal server error"
Scenario: An unauthenticated caller is refused as before
When a client sends GET /v1/media/?page=1&size=5 without a valid jwt cookie
Then the response is the unchanged 401 of the auth group and the use case is not called
+36
View File
@@ -0,0 +1,36 @@
# QA: Paginated media listing (API)
## HTTP-level (Go, `test/`, `task test`)
Build `media.NewHandler(...)` over the real `ListMediaUseCase` and in-memory fakes of `image.Repository`, `article.Repository`, `user.Repository` and `image.VariantFileRepository` (extend the existing media fakes, keeping type names unique across files). Mount on a fresh `chi.NewRouter()` mirroring the media router in `api/router.go` (GET / inside `jwtauth.Verifier` + `jwtauth.Authenticator`, test `jwtauth.New("HS256", ...)`, valid `jwt` cookie with a `user_id` claim of a verified user). Call `validator.InitValidator()`. No live server, no SQLite file.
Helper: decode into {items []map, total, page, size int, counts {all, unused, duplicates}}; also assert the raw JSON contains `"items":[` (never null) and does not contain `next_cursor`.
1. Bare request: 30 images -> object, page 1, size 25, total 30, 25 items, no `next_cursor`.
2. Defaults table: page=2, page=3, size=5, size=50, size=100 on 60 images.
3. Partition: 12 images, pages 1-3 of size 5 -> 5, 5, 2; 12 distinct ids.
4. Order: equal-timestamp case -> ids 3,2,1. Sort table (newest, oldest, name, largest, total_size): 12 images, three pages, concatenation equals the full ordered list, no repeat. Ties break on id.
5. Filter/variants/q: unused -> total 6, counts unchanged; variants=with -> total 3; q=wall&size=5&page=2 -> total 7, 2 items, counts.all 27.
6. Hash: hash=H&page=1&size=100 -> 3 copies; hash=H alone -> page 1, size 25, 3 items.
7. Out of range: page=99&size=5 -> 200, raw `"items":[]`, total 12.
8. Empty library -> items [], total 0, counts 0/0/0.
9. counts library-wide: q=zzzz&page=3&size=5 -> items [], total 0, counts.all 30.
10. cursor/limit ignored: rows `cursor=abc`, `limit=5`, `limit=0`, `limit=abc`, a valid-looking old cursor -> 200, page 1, size 25, 25 items.
11. Invalid table (all rows): each -> 400, body exactly "The media query is invalid.", repository call counters stay 0.
12. Repository failure -> 500 "Internal server error".
13. Missing or invalid jwt cookie -> the auth group's 401, use case counter 0.
14. Use-case tests: `ListMediaUseCase.List` returns items, total, page, size and counts; slices after sorting; propagates a repository error (`errors.Is` on a sentinel); `Describe` is unchanged; `ListQuery.Validate` rejects page < 1 and sizes outside 5/25/50/100 with `ErrInvalidQuery`. `image.ListQuery.Cursor` and `.Limit`, `MediaPage.NextCursor`, `encodeCursor` and `decodeCursor` no longer exist.
15. Existing tests that read `next_cursor`, send `limit` or `cursor`, or call `NewListQuery` (media tests in `test/`, `features/media_library.qa.md`, `features/media_browse.qa.md`) are amended to the envelope; list them in the diff.
## Docs (verified by reading the diff)
- `docs/api.md` lines 99-103: replace `cursor` and `limit` with `page` (default 1) and `size` (5, 25, 50, 100; default 25); state the envelope (`items`, `total`, `page`, `size`, `counts`), that `counts` stays library-wide, that invalid values answer 400 "The media query is invalid.", and a "Breaking change" note: `next_cursor` is gone, `cursor` and `limit` are ignored.
- `docs/api/*.md`: grep for `/v1/media/` and `cursor`; update the media listing section if one exists.
- `web/src/data/apiCatalogue.ts` `med-list`: add `page`, `size`, `q`, `filter`, `sort`, `variants`, `hash` query entries, the 400 error, and a paged example (`total`, `page`, `size`, no `next_cursor`). Remove the `next_cursor` rule (line ~129, with its French entry in `web/src/i18n/messages.ts` line ~1606) if it is only used by media. Integration specs asserting the catalogue are amended.
- Flag for the user (read-only to agents): `docs/design/screens/integration.md` and `docs/design/source/IntegrationPage.dc.html` (med-list with no params), `docs/design/screens/media.md`, `routes.md`, `change-list.md` item 13b, `README.md` line 92 and `screens/media-picker.md`: all still say "array body" and "X-Total-Count".
- A release note under `docs/releases/` is the maintainer's call: flag it.
## Manual verification
- Real SQLite file via `task dev`: seed 60 images, check page=2&size=25, ordering by each sort and counts; the same on MySQL/PostgreSQL if available (no live DB in tests).
- An authenticated curl returns the envelope with no `next_cursor`.
+185
View File
@@ -0,0 +1,185 @@
Feature: Media page pagination
As a verified owner or admin with many images
I want the Media library split into pages with a page-size menu and shareable URLs
So that I can browse a large library quickly and come back to the same page
Background:
Given /media is the verified Media route (features/media_browse.feature), per docs/design/routes.md "Pagination in the URL (Articles, Media)" and docs/design/screens/media.md states 44-52
And the Paginator and PageSizeSelect are the existing components used by Articles (AppPaginator.vue); no new component is created
And the page requests GET /v1/media/?q=Q&filter=F&sort=S&variants=V&page=P&size=Z and reads {items, total, page, size, counts} (features/media_pagination_api.feature); q and variants are sent only when set; page and size are always sent explicitly; the tab counts come from counts and do not change while searching
And page and size live in the URL: ?page=&size=, next to the existing q, filter, sort, variants and view; the defaults (page 1, size 25) are omitted from the URL
And an invalid or missing size falls back to the stored size (features/media_page_size_memory.feature) or 25, and an invalid page (not an integer >= 1) falls back to 1, both silently replaced in the URL with history.replace and never shown as an error
And a change to search, tab, sort, view, the variants filter or the page size resets page to 1 (and drops it from the URL); search keeps its 120 ms debounce
And the URL changes when a response succeeds, not when the request is sent
And the summary reads "Showing {from}–{to} of {total}" with locale-formatted numbers, from total (the filtered count), under the grid or list
And the Paginator has First, Previous, page numbers with ellipses marking skipped ranges, Next, Last, and the page-size select "5 per page", "25 per page", "50 per page", "100 per page" (it opens upward)
And the current page uses --rc-sand fill, --rc-on-sand text and a --ui-primary border and has aria-current="page"; disabled controls are at 40 % opacity; buttons are 32 px (44 px on mobile)
And on mobile the paginator is Previous, "Page N of M" and Next
And the paginator is hidden when total fits on one page; the summary and the page-size select stay
And the paginator sits under the grid in grid view and under the table (md and up) or card list (below md) in list view; its exact placement is taken from the board frame docs/design/source/MediaFrame.dc.html, not from components.md prose
And keyboard: Tab reaches the page-size select, first, previous, each page, next, last; Enter or Space activates; ArrowDown opens the page-size list, Enter selects, Esc closes; every control shows a 2 px --ui-primary focus outline with a 2 px offset
And the Duplicates tab (?filter=duplicates) is not paginated: it loads the whole duplicate set with size=100, requesting page 1, 2, ... until the loaded count reaches total, groups it by sha256 as today, shows no paginator, summary or page-size select, and drops page and size from the URL
And "Select all" covers the images on the current page only and reads "{n} images" for that page; changing page, size, search, tab, sort or view clears the selection
And the silent background refresh after a delete, upload or merge reloads the current page with no skeleton; when the current page has become empty and is past the last page, the list steps back to the last page and replaces the URL
And the first load and a change of search, tab or sort keep the existing skeleton after 150 ms and the "Couldn't load your images" alert (media.md states 4 and 42); the page-change error below applies only when images are already shown
And the fixture below is total 312, size 25 (13 pages) unless stated, with the board's own sample data
Scenario: 44 First page
Given GET /v1/media/?filter=all&sort=newest&page=1&size=25 resolves 200 with 25 items, total 312 and counts {all 312, unused 2, duplicates 2}
When the user navigates to /media
Then the summary reads "Showing 1–25 of 312"
And First and Previous are disabled, page 1 is current, Next and Last are enabled
And the URL has no page or size
Scenario: 45 Middle page
Given GET ...&page=7&size=25 resolves 200 with 25 items and total 312
When the user navigates to /media?page=7
Then the summary reads "Showing 151–175 of 312"
And page 7 is current with --rc-sand / --rc-on-sand and aria-current="page"
And ellipses mark the skipped ranges and every control is enabled
Scenario: 46 Last page
Given GET ...&page=13&size=25 resolves 200 with 12 items and total 312
When the user navigates to /media?page=13
Then the summary reads "Showing 301–312 of 312"
And Next and Last are disabled
Scenario: Single page
Given GET /v1/media/ resolves 200 with 12 items, total 12 and counts {all 12, unused 2, duplicates 2}
When the user navigates to /media
Then the summary reads "Showing 1–12 of 12" and the page-size select stays
And the paginator (First ... Last) is not rendered
Scenario: 47 List view, desktop and mobile
Given page 2 of 13 is shown with ?view=list
Then at md width or more the paginator sits under the table with the page numbers
And below md it sits under the card list as Previous, "Page 2 of 13", Next with 44 px controls and no page numbers
And in grid view on mobile the paginator is the same Previous, "Page 2 of 13", Next
Scenario: 48 Loading next page
Given the first page has loaded and GET ...&page=2 has not resolved
When the user activates Next
Then skeleton cards keep the grid height and the list is aria-busy="true"
And every paginator control is disabled and the summary reads "Loading page 2…"
When the request resolves 200
Then the images and summary of page 2 appear and the URL becomes /media?page=2
Scenario Outline: 49 Error loading a page keeps the previous images
Given page 6 is shown
When the user activates Next and GET ...&page=7 <failure>
Then the page-6 images stay, the URL stays /media?page=6 and the controls are re-enabled
And a notice reads "Couldn't load page 7. Showing page 6." with a "Retry" action
When the user activates "Retry"
Then GET ...&page=7 is sent again and, on 200, page 7 replaces the images, the URL becomes ?page=7 and the notice goes
Examples:
| failure |
| resolves with a 500 response |
| never reaches the server |
Scenario: 50 Page out of range redirects to the last page with a notice
Given GET ...&page=99 resolves 200 with items [] and total 312
When the user navigates to /media?page=99
Then the URL is replaced (no history entry) by /media?page=13 and GET ...&page=13 is sent
And the notice reads "That page doesn't exist. Showing the last page."
And the last-page state (46) is shown
Scenario: Out of range with total 0 shows the empty state, no redirect
Given GET ...&page=5 resolves 200 with items [] and total 0 and counts.all 0
When the user navigates to /media?page=5
Then the "No images yet" empty state shows and the URL is /media
Scenario: Out of range after a search with total 0 shows no results
Given GET ...&q=zzzz&page=5 resolves 200 with items [], total 0 and counts.all 312
When the user navigates to /media?q=zzzz&page=5
Then the no-results state (media.md state 6) shows and the URL is /media?q=zzzz
Scenario Outline: Search, tab, sort, view and variants changes reset to page 1
Given the user is on /media?page=7
When the user <action> and the request resolves
Then GET ... carries page=1
And on success the URL has no page
And the tab counts are unchanged by a search
Examples:
| action |
| types "wall" in the search field and the debounce elapses |
| selects the "Unused" tab |
| picks "Largest" in the sort menu |
| activates the list icon in the grid toggle |
| applies the "With variants" filter |
Scenario Outline: 51 Page size menu
Given the user is on /media?page=3
When the user opens the page-size select (it opens upward) and chooses "<label>"
Then GET ...&page=1&size=<size> is sent and, on success, the URL is <url>
Examples:
| label | size | url |
| 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 |
Scenario Outline: Invalid URL values fall back silently
When the user navigates to /media?<query>
Then GET ... is sent with page=1 and size=25 and the URL is replaced by /media with no history entry
And no error or notice is shown
Examples:
| query |
| size=10&page=abc |
| page=0 |
| page=-2 |
| page=1.5 |
| size=0 |
| size=200 |
Scenario: Pagination is kept across the other controls
Given the URL is /media?size=50&sort=largest&filter=unused
When the user activates Next
Then every request carries size=50, sort=largest and filter=unused and the URL keeps them
Scenario: 52 Focus-visible
When the user tabs to Next
Then Next shows a 2 px --ui-primary outline with a 2 px offset
And the same ring shows on every paginator control and on the page-size select
Scenario: Keyboard, the page-size list
When the user focuses the page-size select and presses ArrowDown
Then the list opens; Enter selects the highlighted size; Esc closes it and returns focus to the select
Scenario: Select all covers the current page only
Given page 2 of 13 is shown with 25 images
Then the "Select all" row reads "Select all" and "25 images"
When the user activates "Select all"
Then 25 images are selected and the bulk bar announces "25 selected"
When the user activates Next and page 3 loads
Then no image is selected and the bulk bar is gone
Scenario: The Duplicates tab loads the whole set and shows no paginator
Given 130 images are duplicates, GET ...&filter=duplicates&sort=newest&page=1&size=100 resolves 200 with 100 items and total 130
And GET ...&filter=duplicates&page=2&size=100 resolves 200 with the remaining 30 items
When the user navigates to /media?filter=duplicates&page=4&size=5
Then page 1 and page 2 are requested with size=100, page 2 only because 100 < 130
And the groups are built from the 130 images, with no paginator, summary or page-size select
And the URL is replaced by /media?filter=duplicates
Scenario: Leaving the Duplicates tab returns to the paginated list
Given the Duplicates tab is shown
When the user selects the "All" tab
Then GET ...&filter=all&page=1&size=25 is sent and the paginator is shown
Scenario: Deleting the last image of the last page
Given the user is on the last page with a single image and deletes it (204)
Then the list reloads the previous page in the background and the URL is replaced by it
And the tab counts are recomputed from the new response
Scenario: A background refresh keeps the current page
Given page 7 is shown and the user renames, uploads or merges
Then the list is requested again in the background for page 7 with no skeleton
Scenario: Hash lookups do not touch the page
When the detail panel or the upload duplicate check looks up copies of an image
Then GET /v1/media/?hash=H&page=1&size=100 is sent, the URL does not change and the paginator is unaffected
+52
View File
@@ -0,0 +1,52 @@
# QA: Media page pagination (SPA)
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mock `/v1/media/` the way `web/src/views/__tests__/mediaTestKit.ts` does, extended so the mock honours `page` and `size`, builds the envelope `{items, total, page, size, counts}` and has no `next_cursor`. Use the 312-image fixture (25 per page) from the board's data in `docs/design/source/MediaFrame.dc.html`. Mount `MediaView` with a real router and Pinia. Reuse the helpers of `ArticlesPagination.spec.ts`.
One test per state:
1. 44 First page: request `?filter=all&sort=newest&page=1&size=25`; "Showing 1–25 of 312"; First/Previous disabled; URL without page or size.
2. 45 Middle page: `/media?page=7` -> "Showing 151–175 of 312"; `aria-current="page"` on 7; ellipses.
3. 46 Last page: `/media?page=13` -> 12 items, "Showing 301–312 of 312"; Next/Last disabled.
4. Single page: total 12 -> summary, page-size select, no First..Last.
5. 47 List view: `?view=list&page=2` -> paginator under the table (md) and the card list (below md, the "Page 2 of 13" form).
6. 48 Loading next page: pending page 2 -> skeleton cards, `aria-busy`, controls disabled, "Loading page 2…"; resolve -> URL `/media?page=2`.
7. 49 Error (500, network): previous images stay, URL unchanged, notice "Couldn't load page 7. Showing page 6." with Retry; Retry succeeds -> `?page=7`, notice gone.
8. 50 Out of range: `?page=99` with items [] and total 312 -> URL replaced by `?page=13` (assert `router.replace`, history length unchanged), notice text exact, last-page state.
9. Total 0 out of range: empty state, URL `/media`; with `q` -> no-results state.
10. Reset to page 1: table of search (120 ms debounce with fake timers on the timer only, never the system date), tab, sort, view, variants filter -> request carries `page=1`, URL has no page.
11. 51 Page-size menu table: 5, 25, 50, 100 -> request `page=1&size=N`; URLs per the table.
12. Invalid URL table (`size=10&page=abc`, `page=0`, `page=-2`, `page=1.5`, `size=0`, `size=200`): request page=1 size=25, URL replaced, no notice or alert.
13. Controls kept: Next with `size=50&sort=largest&filter=unused` keeps every param.
14. 52 Focus and keyboard: tab order select, first, previous, pages, next, last; ArrowDown opens, Enter selects, Esc closes and refocuses.
15. Select all: "25 images", 25 selected, page change clears the selection and the bulk bar.
16. Duplicates tab: with 130 duplicates, assert requests page=1 and page=2 at size=100, groups built from all 130, no paginator, URL drops page and size; selecting All returns to the paginated request.
17. Last-image delete: steps back one page and replaces the URL; counts refreshed.
18. Background refresh after rename/upload/merge: same page, no skeleton.
19. Hash lookups send `hash=H&page=1&size=100` (`findCopies`, `useMediaUpload`, `ImageUploader`).
20. Amended existing specs: every spec reading `next_cursor`, `limit=100`, `MEDIA_PAGE_LIMIT` or the old `/v1/media/?` mocks is migrated to the envelope and page/size requests; list them in the diff.
## Visual (`task visual:check -- media`)
Hard gate: every capture within 10 % at 1440×900 and 390×844, light and dark. Add `web/visual/states.ts` setups using the board's own sample data (312 images, 25 per page; filenames, sizes, uploaders and dates taken from the frame's script in `docs/design/source/MediaFrame.dc.html`; counts all 312 / unused 2 / duplicates 2):
| State | Setup |
| --- | --- |
| 44 Pagination: first page | `/media`, page 1 of 13 |
| 45 middle page | `/media?page=7` |
| 46 last page | `/media?page=13`, 12 items |
| 47 list view | `/media?view=list&page=2` |
| 48 loading next page | page 2 pending |
| 49 error loading a page | page 7 fails, page 6 shown |
| 50 page out of range | `/media?page=99` replaced by `?page=13` with the notice |
| 51 page-size menu | select open upward |
| 52 focus-visible | Next focused |
If a frame renders a relative date or greeting, mark that state `skip: clock-sensitive` and write no date-frozen test. The paginator's placement under the grid or list is checked against the board frame's markup, not `components.md` prose.
## Manual verification
- Keyboard: tab order and focus ring on every paginator control; ArrowDown/Enter/Esc in the page-size list; grid arrow-key navigation still works after a page change; focus is sensible after Next.
- Hover and motion on the paginator and skeleton.
- A real library of 60+ images through `task dev`: paging, size change, page past the end replaced with the notice, Duplicates tab loading beyond 100 duplicates.
- Screen reader: the summary and "Loading page N…" are announced; selection announcements after a page change.
@@ -10,13 +10,16 @@ Feature: Media page, part 2: upload, selection and duplicates
DashboardShell, per docs/design/routes.md and docs/design/screens/media.md
And this slice is SPA only: the API from slice A is used unchanged, and no Go code, use case
or repository changes
And this slice is amended by features/media_pagination_screen.feature: "Select all" covers the
images of the current page only, the Duplicates tab loads the whole set at size=100 page after
page until the loaded count reaches total, and hash lookups send page=1&size=100
And this slice supersedes these B1 statements: the Upload button, the mobile pinned button and
"Upload an image" are enabled; the Duplicates tab is enabled and ?filter=duplicates is a
valid value; "Review duplicates" is enabled; Space on a card toggles selection; the checkbox,
the "Select all" text and the partial state exist; the B2 states no longer carry
skip 'pending' in web/visual/states.ts
And the calls used are POST /v1/media/ (multipart field "file", sent with XMLHttpRequest so
that upload progress is reported, with the jwt cookie), GET /v1/media/?hash=<sha256 hex>,
that upload progress is reported, with the jwt cookie), GET /v1/media/?hash=<sha256 hex>&page=1&size=100,
DELETE /v1/media/{id}, POST /v1/media/merge { keep_id, merge_ids } and the list call from
B1 with filter=duplicates; every other /v1 call goes through useApi
And 401 and 403 on any of these calls, including the XHR upload, send the user to
@@ -130,7 +133,7 @@ Feature: Media page, part 2: upload, selection and duplicates
Scenario: A row whose content already exists
Given the user chooses "floorboards-2.jpg" (2.4 MB, valid)
When the SHA-256 of the file is computed
Then GET /v1/media/?hash=<that hex> is sent before any POST
Then GET /v1/media/?hash=<that hex>&page=1&size=100 is sent before any POST
And when it resolves 200 with the image "floorboards.jpg" (id 12, uploaded 14 Jun 2026 by Inès Duarte)
Then the row shows "Already in your library", the text `Same as "floorboards.jpg", uploaded 14 Jun 2026 by Inès Duarte.`, and the buttons "Use existing" and "Upload anyway"
And nothing is sent with POST /v1/media/ for it, and its concurrency slot is released
@@ -146,7 +149,7 @@ Feature: Media page, part 2: upload, selection and duplicates
And the image "floorboards.jpg" is selected, the bulk bar reads "1 selected", and the page announces "floorboards.jpg selected, 1 selected"
Scenario: Use existing when the image is not in the loaded list
Given the existing image is not in the loaded list because of the search, the tab or the 100 limit
Given the existing image is not in the loaded list because of the search, the tab or the current page
When the user activates "Use existing"
Then the search and the tab reset to All and the image is selected
And if it is still not loaded the row is dropped without selecting
@@ -275,7 +278,7 @@ Feature: Media page, part 2: upload, selection and duplicates
When 3 images are selected
Then that checkbox shows the partial state
When the user activates it while it is partial
Then all 12 loaded images are selected and it shows checked
Then all 12 images of the current page are selected and it shows checked (never the other pages)
When the user activates it while it is checked
Then the selection is emptied
And in list view at md width or more the table header checkbox does the same; the mobile card list has no "Select all" row and is selected through its 44 px row checkboxes
@@ -358,13 +361,13 @@ Feature: Media page, part 2: upload, selection and duplicates
Scenario: The Duplicates tab is enabled
Given the Default state has loaded
When the user selects the "Duplicates 2" tab
Then the URL gains ?filter=duplicates and the list is requested with filter=duplicates and limit=100
Then the URL gains ?filter=duplicates and the list is requested with filter=duplicates at size=100, page 1, 2, ... until the loaded count reaches total
And the tab is selected, and "Duplicates" carries the info-tinted count of groups
And opening /media?filter=duplicates lands on the same view, and "Review duplicates" in the detail panel of "floorboards.jpg" is enabled and selects this tab
And search applies, the sort menu and the view toggle show as on the other tabs, sort is sent with the request, and the view toggle has no effect on the groups view while the grid or list preference stays in the URL
Scenario: Duplicates, groups
Given GET /v1/media/?filter=duplicates&sort=newest&limit=100 resolves 200 with ids 5, 6, 7, 9 and 12
Given GET /v1/media/?filter=duplicates&sort=newest&page=1&size=100 resolves 200 with ids 5, 6, 7, 9 and 12
When the user opens the Duplicates tab
Then the images are grouped by sha256 into 2 bordered cards stacked with a 16 px gap, the group with the oldest suggested copy first
And group 1 has the header "Group 1" over "3 identical images · 2.4 MB each · 3600 × 2400", and group 2 has "Group 2" over "2 identical images · 3.1 MB each · 3600 × 2400"
@@ -13,7 +13,7 @@ Mount MediaPage in DashboardShell with a verified session and a test router at /
3. **Entry points**: Upload (desktop header), the mobile pinned button and "Upload an image" each click the hidden `input[type=file]` (multiple, exact accept list).
4. **Drop overlay**: dragenter with Files shows the copy exactly; dragleave hides; drop starts uploads; a drag without Files shows nothing; mobile pinned button hidden while shown.
5. **Queue**: 6 valid files give at most 2 XHRs open at once; order kept; header "Uploading 6 images" and "N of 6 done" counts settled rows; "Uploading 1 image" singular; progress 1.1 of 2.6 MB reads "Uploading 1.1 of 2.6 MB" with a 42 % bar; 201 gives "Added to your library"; a silent list reload follows; files added while open are appended.
6. **Duplicate check**: GET `?hash=` precedes POST; a match gives the Duplicate row with exact text (oldest match named); slot released; "Use existing" drops the row and selects the id (bulk bar "1 selected", live region text), including the reset-to-All path; "Upload anyway" sends POST; crypto undefined and lookup failure skip straight to POST.
6. **Duplicate check**: GET `?hash=...&page=1&size=100` precedes POST; a match gives the Duplicate row with exact text (oldest match named); slot released; "Use existing" drops the row and selects the id (bulk bar "1 selected", live region text), including the reset-to-All path; "Upload anyway" sends POST; crypto undefined and lookup failure skip straight to POST.
7. **Server statuses**: 201, 413, 415, 400, 500, network error: row copy and icon exact; "Try again" re-sends only that file with no new GET `?hash=`.
8. **Cancel**: row x aborts that XHR and removes the row, the next file starts; "Cancel all" aborts all and removes waiting rows; panel closes when empty.
9. **Summary**: Examples-driven summary lines; "Upload finished" / "{n} images" and "Dismiss"; "Nothing was uploaded" with "3 files were rejected" and with "2 files need attention", no summary; live-region text; Dismiss removes every row.
@@ -22,7 +22,7 @@ Mount MediaPage in DashboardShell with a verified session and a test router at /
12. **Selection**: checkbox visibility (hover, focus, any selection, always on mobile); tick and Space toggle; Enter opens the panel and ticking does not; live-region texts for select, unselect and "Selection cleared"; the checkbox is not a tab stop; select-all states (unchecked, partial, checked) in the grid row and the table header (no row in the mobile card list); Escape closes the panel first and clears the selection next; Clear; selection cleared on q/tab/sort change, kept on view change.
13. **Bulk bar**: copy, buttons, desktop and mobile placement classes, replaces the mobile pinned button.
14. **Bulk delete**: dialog copy exact (title, description with the summed count, rows "Used in 2", "Used in 3", "Unused"), singular form; no usage request; in progress (one DELETE per id, buttons disabled, not dismissible); done (cards leave, selection empty, focus target, toast id and text, silent reload); partly failed (two removed, failed one stays selected, bar "1 selected", toast with Retry and no auto-dismiss, Retry re-sends only the failed DELETE); several failures title; 404 counted as deleted; all failed gives the inline alert.
15. **Duplicates tab**: enabled, URL and request (`filter=duplicates`, limit=100), deep link, "Review duplicates" enabled and switching tab; sort and view toggle shown (sort sent with the request, toggle keeps the preference in the URL); grouping by sha256, group order, headers (including the SVG/unknown uploader case), column order, "Suggested" tag and footer, "Uploaded 14 Jun 2026 by Inès Duarte", "Used in 4 articles" opens the panel; suggested-copy Examples; empty state and "Back to all images"; badge absent at 0.
15. **Duplicates tab**: enabled, URL and request (`filter=duplicates`, size=100, page 1, 2, ... until total is reached; amended by `features/media_pagination_screen.feature`), deep link, "Review duplicates" enabled and switching tab; sort and view toggle shown (sort sent with the request, toggle keeps the preference in the URL); grouping by sha256, group order, headers (including the SVG/unknown uploader case), column order, "Suggested" tag and footer, "Uploaded 14 Jun 2026 by Inès Duarte", "Used in 4 articles" opens the panel; suggested-copy Examples; empty state and "Back to all images"; badge absent at 0.
16. **Merge**: dialog copy exact for floorboards.jpg and another kept copy, singular form; POST body `{ keep_id: 12, merge_ids: [5, 6] }`; in progress not dismissible; 200 gives group removed, toast id "media-merged-12" and text, live-region text, silent reload, "No duplicates" when last; 500 and network give the alert; 409 gives the alert and a reload; 404 gives the info toast and closes.
17. **Global errors**: 401 and 403 from XHR upload, merge, delete and hash lookup call the same redirect handler as useApi; no local copy.
18. **B1 regression**: media_browse specs updated for the superseded statements and the rest still passes.