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

+32 -13
View File
@@ -70,9 +70,23 @@ type countsResponse struct {
} }
type listResponse struct { type listResponse struct {
Items []mediaItemResponse `json:"items"` Items []mediaItemResponse `json:"items"`
NextCursor *string `json:"next_cursor"` Total int `json:"total"`
Counts countsResponse `json:"counts"` Page int `json:"page"`
Size int `json:"size"`
Counts countsResponse `json:"counts"`
}
type pagingQuery struct {
Page int `validate:"gte=1"`
Size int `validate:"oneof=5 25 50 100"`
}
func intQueryParam(values url.Values, name string, fallback int) (int, error) {
if !values.Has(name) {
return fallback, nil
}
return strconv.Atoi(values.Get(name))
} }
type usageResponse struct { type usageResponse struct {
@@ -173,7 +187,6 @@ func parseListQuery(r *http.Request) (domainImage.ListQuery, error) {
query := domainImage.NewListQuery() query := domainImage.NewListQuery()
query.Search = values.Get("q") query.Search = values.Get("q")
query.Hash = strings.ToLower(values.Get("hash")) query.Hash = strings.ToLower(values.Get("hash"))
query.Cursor = values.Get("cursor")
if values.Has("filter") { if values.Has("filter") {
query.Filter = domainImage.Filter(values.Get("filter")) query.Filter = domainImage.Filter(values.Get("filter"))
} }
@@ -186,13 +199,19 @@ func parseListQuery(r *http.Request) (domainImage.ListQuery, error) {
if values.Has("sort") { if values.Has("sort") {
query.Sort = domainImage.Sort(values.Get("sort")) query.Sort = domainImage.Sort(values.Get("sort"))
} }
if values.Has("limit") { page, err := intQueryParam(values, "page", domainImage.DefaultListPage)
limit, err := strconv.Atoi(values.Get("limit")) if err != nil {
if err != nil { return domainImage.ListQuery{}, domainImage.ErrInvalidQuery
return domainImage.ListQuery{}, domainImage.ErrInvalidQuery
}
query.Limit = limit
} }
size, err := intQueryParam(values, "size", domainImage.DefaultListSize)
if err != nil {
return domainImage.ListQuery{}, domainImage.ErrInvalidQuery
}
if validator.Validate.Struct(pagingQuery{Page: page, Size: size}) != nil {
return domainImage.ListQuery{}, domainImage.ErrInvalidQuery
}
query.Page = page
query.Size = size
return query, nil return query, nil
} }
@@ -219,11 +238,11 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
} }
response := listResponse{ response := listResponse{
Items: items, Items: items,
Total: page.Total,
Page: page.Page,
Size: page.Size,
Counts: countsResponse{All: page.Counts.All, Unused: page.Counts.Unused, Duplicates: page.Counts.Duplicates}, Counts: countsResponse{All: page.Counts.All, Unused: page.Counts.Unused, Duplicates: page.Counts.Duplicates},
} }
if page.NextCursor != "" {
response.NextCursor = &page.NextCursor
}
writeJSON(w, http.StatusOK, response) writeJSON(w, http.StatusOK, response)
} }
+14 -4
View File
@@ -97,10 +97,20 @@ try again."; the text of the error never reaches the event, the API or the CSV.
codes, article bodies, file bytes, storage paths and asset urls are never recorded. codes, article bodies, file bytes, storage paths and asset urls are never recorded.
Article routes accept `?format=markdown|html|json` (default `markdown`). `GET /v1/media/` accepts Article routes accept `?format=markdown|html|json` (default `markdown`). `GET /v1/media/` accepts
`q`, `hash`, `cursor`, `filter`, `sort` (`newest`, `oldest`, `name`, `largest` or `total_size`, the `q`, `hash`, `filter` (`all`, `unused` or `duplicates`), `sort` (`newest`, `oldest`, `name`,
original plus its generated variants), `variants` (`with` or `without` generated variant files) and `largest` or `total_size`, the original plus its generated variants), `variants` (`with` or
`limit`. Every item carries `variant_count` and `variant_size` (the index rows of the image and `without` generated variant files), `page` (a whole number from 1, default 1) and `size` (5, 25, 50
their bytes, 0 when none); `counts` stays library-wide. Any other `variants` or `sort` value is 400. or 100, default 25). The body is `{ "items": [...], "total", "page", "size", "counts" }`: `total` is
the number of images matching `q`, `hash`, `filter` and `variants` before paging, `page` and `size`
echo the values applied, and `counts` (`all`, `unused`, `duplicates`) stays library-wide, ignoring
every filter and the paging. Ordering is applied to the whole matching list before slicing, with the
image id as the final tie-break, so pages never overlap or skip. A page past the last answers 200
with `items: []` and the real `total`. Any invalid `page`, `size`, `filter`, `sort`, `variants` or
`hash` is 400 "The media query is invalid.". Every item carries `variant_count` and `variant_size`
(the index rows of the image and their bytes, 0 when none).
Breaking change: `next_cursor` is gone and no `X-Total-Count` header is sent. `cursor` and `limit`
are no longer parameters; they are ignored, never an error, and have no effect on paging.
## Image variants ## Image variants
Owners and admins manage named variants of the stored images. These routes are session only (no API Owners and admins manage named variants of the stored images. These routes are session only (no API
+3 -3
View File
@@ -27,7 +27,7 @@ routes.md · components.md · states.md · foundations.md · change-list.md · a
| B6 Contrast and states matrix | Done for the pairs listed; matrix drawn | | B6 Contrast and states matrix | Done for the pairs listed; matrix drawn |
| B7 Consistency sweep | Done for the roles listed | | B7 Consistency sweep | Done for the roles listed |
| B8 Copy glossary | Done by search; not every line re-read | | B8 Copy glossary | Done by search; not every line re-read |
| B9 Media and picker | Badge added; filter, sort and size not folded in | | B9 Media and picker | Badge added; Media pagination drawn (9 states); filter, sort and size not folded in |
| B10 "proposed" labels | Only article pagination and JSON export | | B10 "proposed" labels | Only article pagination and JSON export |
## State counts (Back-office board: 341) ## State counts (Back-office board: 341)
@@ -39,7 +39,7 @@ routes.md · components.md · states.md · foundations.md · change-list.md · a
| Articles: pagination | 11 | | Articles: pagination | 11 |
| New article | 5 | | New article | 5 |
| Editor | 41 | | Editor | 41 |
| Media | 43 | | Media | 52 |
| Media picker | 13 | | Media picker | 13 |
| Integration | 8 | | Integration | 8 |
| Activity | 9 | | Activity | 9 |
@@ -89,7 +89,7 @@ Back-office total 341. Auth board 50 (Log in 9, Register 6, Pending 6, Verify 6,
## Defaults chosen ## Defaults chosen
- Slide-over rule: only forms with a live preview and record details; on mobile always a 92% bottom sheet. - Slide-over rule: only forms with a live preview and record details; on mobile always a 92% bottom sheet.
- Hover, active, disabled and focus tokens for the matrix (components not specified before): primary hover `--ui-text`, active `--ui-text-toned`; destructive hover opacity 0.85 and active 0.75; disabled 40% (rows 55%). - Hover, active, disabled and focus tokens for the matrix (components not specified before): primary hover `--ui-text`, active `--ui-text-toned`; destructive hover opacity 0.85 and active 0.75; disabled 40% (rows 55%).
- Article pagination stays "proposed" until the backend returns page, size and total. - Article pagination: API shape (array body, total in `X-Total-Count`) is a design assumption to confirm with the specifier.
- The Remove action in the link popover has no confirmation; undo restores the link. - The Remove action in the link popover has no confirmation; undo restores the link.
- The link button is disabled when an image is selected. - The link button is disabled when an image is selected.
- The tooltip text is "Insert link (Ctrl+K)" and "Insert link (⌘K)" on macOS. - The tooltip text is "Insert link (Ctrl+K)" and "Insert link (⌘K)" on macOS.
+2 -1
View File
@@ -16,7 +16,8 @@ Ordered by implementation dependency: tokens and shell first, then shared compon
| 10 | New article | Title only. Body removed from the modal; the editor opens straight away | Yes | Maybe: confirm `POST /v1/article/` accepts an empty `body` (it is "required" today); otherwise the frontend sends an empty string | NewArticleModal | Yes if empty body is accepted | | 10 | New article | Title only. Body removed from the modal; the editor opens straight away | Yes | Maybe: confirm `POST /v1/article/` accepts an empty `body` (it is "required" today); otherwise the frontend sends an empty string | NewArticleModal | Yes if empty body is accepted |
| 11 | Editor | Remove the 10,000 character limit, counter, limit and over-limit states; neutral count | Yes | Yes: remove the body length validation on `POST /v1/article/` and `PATCH /v1/article/{id}` | EditorStatusBar | No | | 11 | Editor | Remove the 10,000 character limit, counter, limit and over-limit states; neutral count | Yes | Yes: remove the body length validation on `POST /v1/article/` and `PATCH /v1/article/{id}` | EditorStatusBar | No |
| 12 | Editor | Insert-link button (default, hover, active, disabled), tooltip, LinkModal, link popover and touch sheet, rendered link, toolbar scrolling at 390 px | Yes | No (editor only) | LinkModal, LinkPopover, ToolbarButton | Yes | | 12 | Editor | Insert-link button (default, hover, active, disabled), tooltip, LinkModal, link popover and touch sheet, rendered link, toolbar scrolling at 390 px | Yes | No (editor only) | LinkModal, LinkPopover, ToolbarButton | Yes |
| 13 | Articles | Pagination with `page` and `size` (5, 25, 50, 100; default 25), URL state, loading, error, out of range | Yes | **Yes (proposed):** `GET /v1/article/` returns everything today. It needs `page`, `size` and `total` in the response | Paginator, PageSizeSelect | No | | 13 | Articles | Pagination with `page` and `size` (5, 25, 50, 100; default 25), URL state, loading, error, out of range | Yes | **Yes:** `page`, `size` on `GET /v1/article/` (same convention and 400 as Activity); total in `X-Total-Count`, array body unchanged; Integration reference updated | Paginator, PageSizeSelect | No |
| 13b | Media | Pagination under the grid and list, same Paginator and PageSizeSelect as Articles; `page` and `size` in the URL; saved size preference | Yes (9 states in the Media group) | **Yes:** `page`, `size` on `GET /v1/media/` (replaces `cursor` and `limit`); total in `X-Total-Count`, array body unchanged; Integration reference | None (reuses Paginator, PageSizeSelect) | No |
| 14 | Activity | Failed-action and security cards as toggles, synced chip and segment | Yes | No | FilterCardToggle | Yes | | 14 | Activity | Failed-action and security cards as toggles, synced chip and segment | Yes | No | FilterCardToggle | Yes |
| 15 | Activity | Export menu and dialog, CSV: current view (active filters) and all activity (no filters, optional `from`/`to`). Preparing, started, failed, nothing to export, 403 | Yes | No: `GET /v1/activity/export` is implemented (CSV, synchronous, owner and admin, same filters as the list) | ExportMenu, ExportDialog | Yes | | 15 | Activity | Export menu and dialog, CSV: current view (active filters) and all activity (no filters, optional `from`/`to`). Preparing, started, failed, nothing to export, 403 | Yes | No: `GET /v1/activity/export` is implemented (CSV, synchronous, owner and admin, same filters as the list) | ExportMenu, ExportDialog | Yes |
| 16 | Activity | JSON export (format control CSV or JSON) | Yes, one state, labelled proposed | **Yes (proposed):** `format=json` on `GET /v1/activity/export` | Segmented format control | No | | 16 | Activity | JSON export (format control CSV or JSON) | Yes, one state, labelled proposed | **Yes (proposed):** `format=json` on `GET /v1/activity/export` | Segmented format control | No |
+11 -5
View File
@@ -5,16 +5,22 @@
| /settings | Settings (Version, Media card, Media cleanup, Retention) | none | GET/PATCH /v1/media/purge-settings, GET/PATCH /v1/settings | | /settings | Settings (Version, Media card, Media cleanup, Retention) | none | GET/PATCH /v1/media/purge-settings, GET/PATCH /v1/settings |
| /settings/media/variants | Image variants (admin) | none | GET/PATCH/DELETE /v1/image-variants, POST /v1/image-variants/{name}/purge | | /settings/media/variants | Image variants (admin) | none | GET/PATCH/DELETE /v1/image-variants, POST /v1/image-variants/{name}/purge |
| /settings/media/cache | Cache (admin) | none | GET /v1/image-variants/cache, POST /v1/image-variants/cache/purge {scope: all|unused|variant|image} | | /settings/media/cache | Cache (admin) | none | GET /v1/image-variants/cache, POST /v1/image-variants/cache/purge {scope: all|unused|variant|image} |
| /media | Media library | filter, sort | GET /v1/media?variants=with|without&sort=total_size | | /media | Media library | page, size, q, filter, sort, view | GET /v1/media/?page=&size=&q=&filter=&variants=&sort= (page, size; total in the `X-Total-Count` header) |
| /media (detail) | Variants tab | none | GET /v1/media/{id}/variants, POST and DELETE /v1/media/{id}/variants/{name} | | /media (detail) | Variants tab | none | GET /v1/media/{id}/variants, POST and DELETE /v1/media/{id}/variants/{name} |
| /articles | Articles list | page, size, tab, q | GET /v1/article/?page=&size=&q=&status= (proposed: needs backend page, size and total in the response) | | /articles | Articles list | page, size, tab, q | GET /v1/article/?page=&size=&q=&status= (page, size; total in the `X-Total-Count` header; array body unchanged) |
| /activity | Activity | q, actor, category, outcome, security, range, from, to, page, size | GET /v1/activity (page, size), GET /v1/activity/export (shipped, CSV) | | /activity | Activity | q, actor, category, outcome, security, range, from, to, page, size | GET /v1/activity (page, size), GET /v1/activity/export (shipped, CSV) |
| /integration | Integration | none | none | | /integration | Integration | none | none |
| /integration/api-keys | API keys | none | GET/POST /v1/api-key, DELETE /v1/api-key/{id} | | /integration/api-keys | API keys | none | GET/POST /v1/api-key, DELETE /v1/api-key/{id} |
## Pagination in the URL (Articles) ## Pagination in the URL (Articles, Media)
`/articles?page=2&size=50`. Parameter names follow the repo (activity already uses `page` and `size`). Allowed sizes 5, 25, 50, 100. Defaults `page=1`, `size=25`, omitted from the URL when default. Changing search, tab, filter or size resets `page` to 1. `page` above the last page redirects (replace) to the last page and shows "That page doesn't exist. Showing the last page." An invalid `size` falls back to 25. `/articles?page=2&size=50` and `/media?page=7&size=50`. Parameter names follow the repo (activity already uses `page` and `size`). Allowed sizes 5, 25, 50, 100. Defaults `page=1`, `size=25`, omitted from the URL when default. Changing search, tab, filter or size resets `page` to 1. `page` above the last page redirects (replace) to the last page and shows "That page doesn't exist. Showing the last page." An invalid `size` falls back to 25.
**Proposed, needs backend:** `GET /v1/article/` currently returns every article. It must accept `page` and `size` and return `total` (and the page) in the response. **API:** `GET /v1/article/` accepts `page` (default 1) and `size` (5, 25, 50, 100; default 25), as `GET /v1/activity` does. The body stays a JSON array so existing consumers keep working; the total comes back in `X-Total-Count` (the response shape is to be confirmed with the specifier). Anonymous callers only see online articles, and the total counts only those. Invalid `page` or `size` answers 400, the same as Activity.
**Media API:** `GET /v1/media/` takes the same `page` and `size`, with the same 400 and the total in `X-Total-Count`; the array body stays. The earlier `cursor` and `limit` parameters are replaced by them.
**Page size memory:** the size chosen on Articles, Activity or Media is remembered for the user and used when the URL has no `size`; an explicit `size` in the URL wins.
**SPA:** never shows that 400. A `size` outside 5, 25, 50, 100 or a `page` that is not a positive integer is replaced in the URL by the default (`history.replace`), silently. A `page` past the last page is replaced by the last page and the notice "That page doesn't exist. Showing the last page." appears.
## Activity pagination ## Activity pagination
`page` and `size`, allowed sizes 5, 25, 50, 100, default 25 (as implemented). `page` and `size`, allowed sizes 5, 25, 50, 100, default 25 (as implemented).
+2 -2
View File
@@ -1,6 +1,6 @@
# Articles # Articles
Route: `/article`, `/articles?page=2&size=50`. API: GET /v1/article/ · PATCH …/publish · PATCH …/unpublish · DELETE /v1/article/{id} · GET /v1/article/?page=&size=&q=&status= (proposed: needs backend page, size, total in the response). Route: `/article`, `/articles?page=2&size=50`. API: GET /v1/article/ · PATCH …/publish · PATCH …/unpublish · DELETE /v1/article/{id} · GET /v1/article/?page=&size=&q=&status= (page, size; total in the X-Total-Count header; invalid values are normalised in the URL, never shown as an error).
Rows update when the server responds, not optimistically. Search, tab and sort are stored in the query string. Rows update when the server responds, not optimistically. Search, tab and sort are stored in the query string.
@@ -52,7 +52,7 @@ Rows update when the server responds, not optimistically. Search, tab and sort a
### Articles: pagination (group "Articles: pagination", 11 states) ### Articles: pagination (group "Articles: pagination", 11 states)
Route: `/articles?page=2&size=50`. API: GET /v1/article/?page=&size=&q=&status= (proposed: needs backend page, size, total in the response). Route: `/articles?page=2&size=50`. API: GET /v1/article/?page=&size=&q=&status= (page, size; total in the X-Total-Count header; invalid values are normalised in the URL, never shown as an error).
Page and page size live in the URL as page and size. Defaults (page 1, size 25) are omitted. Allowed sizes 5, 25, 50, 100. Search, tab or sort changes reset to page 1. Rows equal the page size; the frames draw at most 10 rows. The paginator hides when everything fits on one page; the summary stays. Mobile shows Previous, "Page N of M" and Next. Keyboard: Tab reaches page size, first, previous, each page, next, last; Enter or Space activates; the current page has aria-current="page"; ArrowDown opens the page-size list, Enter selects, Esc closes. Page and page size live in the URL as page and size. Defaults (page 1, size 25) are omitted. Allowed sizes 5, 25, 50, 100. Search, tab or sort changes reset to page 1. Rows equal the page size; the frames draw at most 10 rows. The paginator hides when everything fits on one page; the summary stays. Mobile shows Previous, "Page N of M" and Next. Keyboard: Tab reaches page size, first, previous, each page, next, last; Enter or Space activates; the current page has aria-current="page"; ArrowDown opens the page-size list, Enter selects, Esc closes.
+12 -3
View File
@@ -1,6 +1,6 @@
# Media # Media
Route: `/media`, `/media (detail panel)`, `/media?filter=duplicates`. API: GET /v1/media/ · POST /v1/media/ · PATCH /v1/media/{id} · DELETE /v1/media/{id} · POST /v1/media/merge · GET /v1/media/{id}/usage · GET /v1/media?variants=with|without&sort=total_size · GET /v1/media/{id}/variants · POST /v1/media/{id}/variants/{name} · DELETE /v1/media/{id}/variants/{name} · owner and admin only. Route: `/media`, `/media?page=7&size=50`, `/media (detail panel)`, `/media?filter=duplicates`. API: GET /v1/media/?page=&size=&q=&filter= (total in `X-Total-Count`) · POST /v1/media/ · PATCH /v1/media/{id} · DELETE /v1/media/{id} · POST /v1/media/merge · GET /v1/media/{id}/usage · GET /v1/media?variants=with|without&sort=total_size · GET /v1/media/{id}/variants · POST /v1/media/{id}/variants/{name} · DELETE /v1/media/{id}/variants/{name} · owner and admin only.
New route for verified users, in the sidebar between Articles and Users with the images icon. Images belong to the library, not to an article: one image can be used in many articles, and deleting an article never deletes images. Uploads from this page are not cropped or resized, and the file is stored as chosen. "Used in" is read from article bodies each time the list loads. Duplicates are images with identical content, whatever their filenames. Alt text is not stored on the image; it is set when inserting into an article. Search, filter, sort and view are stored in the query string. Keyboard: grid items are buttons in one tab stop each; Enter opens the detail panel, Space toggles selection, and Escape closes the panel or clears the selection. Selection changes are announced ("north-wall.jpg selected, 1 selected"). New route for verified users, in the sidebar between Articles and Users with the images icon. Images belong to the library, not to an article: one image can be used in many articles, and deleting an article never deletes images. Uploads from this page are not cropped or resized, and the file is stored as chosen. "Used in" is read from article bodies each time the list loads. Duplicates are images with identical content, whatever their filenames. Alt text is not stored on the image; it is set when inserting into an article. Search, filter, sort and view are stored in the query string. Keyboard: grid items are buttons in one tab stop each; Enter opens the detail panel, Space toggles selection, and Escape closes the panel or clears the selection. Selection changes are announced ("north-wall.jpg selected, 1 selected").
@@ -10,7 +10,7 @@ Visual reference: `RenewCMS Screens Back-office.dc.html`, groups "Media", "Media
Every state below is drawn in light and dark (theme switch on the board), at 1440×900 and 390×844 (both frames sit side by side). Board: `RenewCMS Screens Back-office.dc.html`. Copy is exact; where a cell is empty the state reuses the fixed copy. Every state below is drawn in light and dark (theme switch on the board), at 1440×900 and 390×844 (both frames sit side by side). Board: `RenewCMS Screens Back-office.dc.html`. Copy is exact; where a cell is empty the state reuses the fixed copy.
### Media (group "Media", 43 states) ### Media (group "Media", 52 states)
Route: `/media`. API: GET /v1/media/ · POST /v1/media/ · PATCH /v1/media/{id} · DELETE /v1/media/{id} · POST /v1/media/merge · GET /v1/media/{id}/usage. Route: `/media`. API: GET /v1/media/ · POST /v1/media/ · PATCH /v1/media/{id} · DELETE /v1/media/{id} · POST /v1/media/merge · GET /v1/media/{id}/usage.
@@ -18,7 +18,7 @@ New route for verified users, in the sidebar between Articles and Users with the
| # | State | Trigger | Copy | | # | State | Trigger | Copy |
|---|---|---|---| |---|---|---|---|
| 1 | Grid, many images | Default view. 12 images, newest first. Each item is a button labelled like "north-wall.jpg, 3.9 MB, used in 2 articles". Badges: "Unused" (warm), "Duplicate" (info), "Used in N" (outline). The Duplicates tab carries a count of groups only while duplicates exist. | | | 1 | Grid, many images | Default view. 12 images, newest first. Each item is a button labelled like "north-wall.jpg, 3.9 MB, used in 2 articles". Badges: "Unused" (warm), "Duplicate" (info), "Used in N" (outline). The Duplicates tab carries a count of groups only while duplicates exist. Below the grid: "Showing 1–12 of 12" and the page-size select (same paginator as Articles). | |
| 2 | Grid, few images | Three images. Cards keep their size instead of stretching. Checkerboard shows behind the SVG. | | | 2 | Grid, few images | Three images. Cards keep their size instead of stretching. Checkerboard shows behind the SVG. | |
| 3 | List view | The list icon in the toggle. A table from md up (Filename, Dimensions, Size, Type, Used in, Uploaded by, Uploaded) and a card list below md. The "Used in" count opens the article list in the detail panel. | | | 3 | List view | The list icon in the toggle. A table from md up (Filename, Dimensions, Size, Type, Used in, Uploaded by, Uploaded) and a card list below md. The "Used in" count opens the article list in the detail panel. | |
| 4 | Loading | The first load, a retry, or a change of search or filter. Skeleton cards appear after 150 ms. | | | 4 | Loading | The first load, a retry, or a change of search or filter. Skeleton cards appear after 150 ms. | |
@@ -61,6 +61,15 @@ New route for verified users, in the sidebar between Articles and Users with the
| 41 | Merge: done | 200: the group leaves the list. When it was the last one, "No duplicates" shows. | "Copies merged" / "2 copies merged. 5 articles updated." | | 41 | Merge: done | 200: the group leaves the list. When it was the last one, "No duplicates" shows. | "Copies merged" / "2 copies merged. 5 articles updated." |
| 42 | List failed to load | A 500 or network failure on the list request. The alert replaces the images; Try again repeats the request. | | | 42 | List failed to load | A 500 or network failure on the list request. The alert replaces the images; Try again repeats the request. | |
| 43 | Offline | navigator.onLine is false. Loaded images stay browsable. Upload, Rename, Delete and Merge are disabled, and the banner says why. | | | 43 | Offline | navigator.onLine is false. Loaded images stay browsable. Upload, Rename, Delete and Merge are disabled, and the banner says why. | |
| 44 | Pagination: first page | /media. Previous and First are disabled. Summary "Showing 1–25 of 312". The paginator sits under the grid or list and uses the Articles controls: 5, 25, 50 or 100 per page, default 25; page and size live in the URL as page and size (/media?page=7&size=50); search, tab, sort, view and size changes reset to page 1. API: GET /v1/media/?page=&size=&q=&filter= with the total in X-Total-Count. | |
| 45 | Pagination: middle page | /media?page=7. Current page uses --rc-sand and --rc-on-sand; ellipses mark skipped ranges. | |
| 46 | Pagination: last page | /media?page=13. Next and Last are disabled. Summary "Showing 301–312 of 312". | |
| 47 | Pagination: list view | Same paginator under the table. On mobile: Previous, "Page 2 of 13", Next, with the 44 px targets. | |
| 48 | Pagination: loading next page | Next was pressed. Skeleton cards keep the grid height, controls are disabled, summary reads "Loading page 7…". | |
| 49 | Pagination: error loading a page | 500 or network failure on page change. Previous images stay; Retry sits in the notice. | "Couldn't load page 7. Showing page 6." |
| 50 | Pagination: page out of range | /media?page=99 is replaced with ?page=13 and this notice is shown. | "That page doesn't exist. Showing the last page." |
| 51 | Pagination: page size menu | The select opens upward. 5, 25, 50 or 100; the chosen size is saved as size and the page resets to 1. | |
| 52 | Pagination: focus-visible | Keyboard focus on Next: 2 px --ui-primary outline, 2 px offset. Same ring on every paginator control. | |
### 3. Media library (group "Media library", 6 states) ### 3. Media library (group "Media library", 6 states)
+2 -2
View File
@@ -179,7 +179,7 @@ class Component extends DCLogic {
const j = o => JSON.stringify(o, null, 2) const j = o => JSON.stringify(o, null, 2)
const G = { const G = {
articles: { title: 'Articles', desc: 'Read, write and publish articles. Titles can\'t be changed after creation.', eps: [ articles: { title: 'Articles', desc: 'Read, write and publish articles. Titles can\'t be changed after creation.', eps: [
A('art-list', 'GET', '/v1/article/', 'List articles. Anonymous callers get online articles only.', 'Public', 1, [e401], [], { pub: 1, r: j([{ id: 12, title: 'How we price a bathroom renovation', status: 'online' }]) }), A('art-list', 'GET', '/v1/article/', 'List articles, one page at a time. Anonymous callers get online articles only, and the total counts only those. The total is in the X-Total-Count response header; the body stays a JSON array.', 'Public', 1, [[400, 'Invalid page or size.'], e401], [S('page', 'integer', 'No', '1 or higher. Default 1. A page past the end returns an empty array.'), S('size', 'integer', 'No', 'One of 5, 25, 50 or 100. Default 25.')], { qp: 1, pub: 1, r: j([{ id: 12, title: 'How we price a bathroom renovation', status: 'online' }]) }),
A('art-get', 'GET', '/v1/article/{id}', 'Get one article. Drafts answer 404 to anonymous callers.', 'Public', 1, [e401, e404], [], { pub: 1, r: j({ id: 12, title: 'How we price a bathroom renovation', status: 'online', body: 'A bathroom renovation is priced by room size, plumbing changes and finish level.', body_format: 'markdown' }) }), A('art-get', 'GET', '/v1/article/{id}', 'Get one article. Drafts answer 404 to anonymous callers.', 'Public', 1, [e401, e404], [], { pub: 1, r: j({ id: 12, title: 'How we price a bathroom renovation', status: 'online', body: 'A bathroom renovation is priced by room size, plumbing changes and finish level.', body_format: 'markdown' }) }),
A('art-create', 'POST', '/v1/article/', 'Create a draft article.', 'Verified user', 1, [[400, mm], e401], [S('title', 'string', 'Yes', '3 to 200 characters. Can\'t be changed later.'), S('body', 'string', 'No', 'Optional. Any length.')], { body: { title: 'Choosing tiles for a small bathroom' }, r: j({ id: 13, status: 'draft' }) }), A('art-create', 'POST', '/v1/article/', 'Create a draft article.', 'Verified user', 1, [[400, mm], e401], [S('title', 'string', 'Yes', '3 to 200 characters. Can\'t be changed later.'), S('body', 'string', 'No', 'Optional. Any length.')], { body: { title: 'Choosing tiles for a small bathroom' }, r: j({ id: 13, status: 'draft' }) }),
A('art-update', 'PATCH', '/v1/article/{id}', 'Update the body of an article.', 'Verified user', 1, [[400, mm], e401, e404], [S('body', 'string', 'Yes', 'Any length.')], { body: { body: 'Updated text.' }, r: j({ id: 12, status: 'draft' }) }), A('art-update', 'PATCH', '/v1/article/{id}', 'Update the body of an article.', 'Verified user', 1, [[400, mm], e401, e404], [S('body', 'string', 'Yes', 'Any length.')], { body: { body: 'Updated text.' }, r: j({ id: 12, status: 'draft' }) }),
@@ -232,7 +232,7 @@ class Component extends DCLogic {
const chips = [chip(e.acc, e.acc === 'Owner only' ? { bg: 'var(--ui-bg-elevated)', fg: 'var(--ui-text-highlighted)', fw: 500 } : {}), chip('API key OK', { icon: 'check', bg: 'var(--rc-success-soft)', fg: 'var(--ui-success)', bd: 'transparent', fw: 500 })] const chips = [chip(e.acc, e.acc === 'Owner only' ? { bg: 'var(--ui-bg-elevated)', fg: 'var(--ui-text-highlighted)', fw: 500 } : {}), chip('API key OK', { icon: 'check', bg: 'var(--rc-success-soft)', fg: 'var(--ui-success)', bd: 'transparent', fw: 500 })]
return { anchor: 'ep-' + e.id, open, m: e.m, p: e.p, d: e.d, mBg: MB[e.m][0], mFg: MB[e.m][1], mBd: MB[e.m][2], chev: open ? 'chevron-up' : 'chevron-down', rowBg: open ? 'var(--ui-bg-muted)' : 'transparent', label: e.m + ' ' + e.p + '. ' + e.d, chips, return { anchor: 'ep-' + e.id, open, m: e.m, p: e.p, d: e.d, mBg: MB[e.m][0], mFg: MB[e.m][1], mBd: MB[e.m][2], chev: open ? 'chevron-up' : 'chevron-down', rowBg: open ? 'var(--ui-bg-muted)' : 'transparent', label: e.m + ' ' + e.p + '. ' + e.d, chips,
toggle: () => this.setState(s => ({ open: { ...s.open, [e.id]: !s.open[e.id] } })), toggle: () => this.setState(s => ({ open: { ...s.open, [e.id]: !s.open[e.id] } })),
bodyTitle: e.multi ? 'Request body · multipart/form-data, 1 file field' : e.body ? 'Request body · JSON' : 'Request body', hasFields: e.fields.length > 0, noFields: e.fields.length === 0, fields: e.fields, bodyTitle: e.qp ? 'Query parameters' : e.multi ? 'Request body · multipart/form-data, 1 file field' : e.body ? 'Request body · JSON' : 'Request body', hasFields: e.fields.length > 0, noFields: e.fields.length === 0, fields: e.fields,
tabs: ['fetch', 'curl', 'Response'].map(t => { const k = t === 'Response' ? 'response' : t, on = (k === tab) || (t === tab); return { t, on, bd: on ? 'var(--ui-primary)' : 'transparent', fg: on ? 'var(--ui-text-highlighted)' : 'var(--ui-text-toned)', pick: () => this.setState(s => ({ tab: { ...s.tab, [e.id]: k } })) } }), tabs: ['fetch', 'curl', 'Response'].map(t => { const k = t === 'Response' ? 'response' : t, on = (k === tab) || (t === tab); return { t, on, bd: on ? 'var(--ui-primary)' : 'transparent', fg: on ? 'var(--ui-text-highlighted)' : 'var(--ui-text-toned)', pick: () => this.setState(s => ({ tab: { ...s.tab, [e.id]: k } })) } }),
snips: [{ k: 'fetch', label: 'fetch', code: fe }, { k: 'curl', label: 'curl', code: curl }, { k: 'response', label: 'Response', code: e.r }], snips: [{ k: 'fetch', label: 'fetch', code: fe }, { k: 'curl', label: 'curl', code: curl }, { k: 'response', label: 'Response', code: e.r }],
errs: e.errs.map(([c, cap]) => ({ c, cap })) } errs: e.errs.map(([c, cap]) => ({ c, cap })) }
+32 -1
View File
@@ -142,6 +142,36 @@ class Component extends DCLogic {
const Header = () => D('display:flex;align-items:flex-end;gap:16px;flex-wrap:wrap', D('flex:1;min-width:200px;display:flex;flex-direction:column;gap:2px', S('font:600 20px/28px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', 'Media'), S('color:var(--ui-text-muted)', 'Every image you\'ve uploaded, ready to use in any article.')), !m && (s.limited ? B('Try again in 0:42', { k: 'primary', icon: 'timer', dis: true }) : B('Upload', { k: 'primary', icon: 'upload', dis: s.offline }))) const Header = () => D('display:flex;align-items:flex-end;gap:16px;flex-wrap:wrap', D('flex:1;min-width:200px;display:flex;flex-direction:column;gap:2px', S('font:600 20px/28px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', 'Media'), S('color:var(--ui-text-muted)', 'Every image you\'ve uploaded, ready to use in any article.')), !m && (s.limited ? B('Try again in 0:42', { k: 'primary', icon: 'timer', dis: true }) : B('Upload', { k: 'primary', icon: 'upload', dis: s.offline })))
const Pager = () => {
const g = { page: 1, per: 25, total: imgs.length, ...(s.pg || {}) }
g.pages = g.pages || Math.max(1, Math.ceil(g.total / g.per))
const busy = !!g.loading, ring = k => g.focus === k ? 'outline:2px solid var(--ui-primary);outline-offset:2px;' : ''
const fmt = n => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
const from = g.total ? (g.page - 1) * g.per + 1 : 0, to = Math.min(g.page * g.per, g.total)
const back = busy || g.page === 1 ? 0.4 : 1, fwd = busy || g.page === g.pages ? 0.4 : 1
const PB = (ic, op, rg, label) => S('width:' + bh + 'px;height:' + bh + 'px;display:flex;align-items:center;justify-content:center;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted);opacity:' + op + ';' + (rg || ''), I(ic))
const set = new Set([1, g.pages, g.page - 1, g.page, g.page + 1].filter(n => n >= 1 && n <= g.pages))
const arr = g.pages <= 7 ? Array.from({ length: g.pages }, (_, i) => i + 1) : [...set].sort((a, b) => a - b)
const nums = []
arr.forEach((n, i) => {
if (i && n - arr[i - 1] > 1) nums.push(S('min-width:' + bh + 'px;height:' + bh + 'px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)', '…'))
const cur = n === g.page
nums.push(S('min-width:' + bh + 'px;height:' + bh + 'px;padding:0 6px;display:flex;align-items:center;justify-content:center;border:1px solid ' + (cur ? 'var(--ui-primary)' : 'var(--ui-border-accented)') + ';background:' + (cur ? 'var(--rc-sand)' : 'var(--ui-bg)') + ';color:' + (cur ? 'var(--rc-on-sand)' : 'var(--ui-text-highlighted)') + ';font-weight:' + (cur ? 600 : 400), String(n)))
})
const Size = () => D('position:relative', S('height:' + bh + 'px;padding:0 8px 0 12px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted);' + ring('size'), g.per + ' per page', I('chevron-down', 14)),
g.sizeOpen && D('position:absolute;' + (m ? 'right' : 'left') + ':0;bottom:calc(100% + 4px);z-index:40;width:160px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:4px', ...[5, 25, 50, 100].map(n => D('height:' + bh + 'px;padding:0 8px;display:flex;align-items:center;background:' + (n === g.per ? 'var(--rc-sand)' : 'transparent') + ';color:' + (n === g.per ? 'var(--rc-on-sand)' : 'var(--ui-text-highlighted)') + ';font-weight:' + (n === g.per ? 600 : 400), n + ' per page'))))
const sum = S('color:var(--ui-text-muted)', busy ? 'Loading page ' + g.loading + '…' : 'Showing ' + from + '–' + to + ' of ' + fmt(g.total))
const out = []
if (g.err) out.push(Alert('error', 'circle-x', g.err, 'Check your connection and try again.', 'Retry'))
if (g.note) out.push(Alert('info', 'info', g.note))
if (m) {
out.push(D('display:flex;flex-direction:column;gap:12px;padding:4px 0 16px', D('display:flex;align-items:center;gap:12px;flex-wrap:wrap;justify-content:space-between', sum, Size()),
g.pages > 1 && D('display:flex;align-items:center;gap:8px', S('flex:1;height:44px;display:flex;align-items:center;justify-content:center;gap:6px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted);font-weight:500;opacity:' + back + ';' + ring('prev'), I('chevron-left'), 'Previous'), S('padding:0 4px;color:var(--ui-text-highlighted);font-weight:500;white-space:nowrap', 'Page ' + g.page + ' of ' + g.pages), S('flex:1;height:44px;display:flex;align-items:center;justify-content:center;gap:6px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted);font-weight:500;opacity:' + fwd + ';' + ring('next'), 'Next', I('chevron-right')))))
} else {
out.push(D('display:flex;align-items:center;gap:16px', D('flex:1;display:flex;align-items:center;gap:16px', sum, Size()), g.pages > 1 && D('display:flex;align-items:center;gap:4px;justify-content:flex-end', PB('chevrons-left', back), PB('chevron-left', back, ring('prev')), ...nums, PB('chevron-right', fwd, ring('next')), PB('chevrons-right', fwd))))
}
return out
}
const pageBody = () => { const pageBody = () => {
const parts = [] const parts = []
if (s.empty === 'first') { parts.push(Empty('images', 'No images yet', 'Upload your first image. You can use it in any article.', 'Upload an image', true), S('align-self:center;color:var(--ui-text-muted);font-size:12px', 'PNG, JPEG, WEBP, SVG · max 10 MB each')); return parts } if (s.empty === 'first') { parts.push(Empty('images', 'No images yet', 'Upload your first image. You can use it in any article.', 'Upload an image', true), S('align-self:center;color:var(--ui-text-muted);font-size:12px', 'PNG, JPEG, WEBP, SVG · max 10 MB each')); return parts }
@@ -153,12 +183,13 @@ class Component extends DCLogic {
: D('display:flex;align-items:flex-end;gap:12px;border-bottom:1px solid var(--ui-border)', TabRow(), S('display:flex;padding-bottom:6px;gap:8px', Search(), Sort(), pb, Toggle())) : D('display:flex;align-items:flex-end;gap:12px;border-bottom:1px solid var(--ui-border)', TabRow(), S('display:flex;padding-bottom:6px;gap:8px', Search(), Sort(), pb, Toggle()))
parts.push(tb) parts.push(tb)
if (UP) parts.push(UpPanel()) if (UP) parts.push(UpPanel())
if (s.loading) parts.push(Skel()) if (s.loading) { parts.push(Skel()); if (s.pg) parts.push(...Pager()) }
else if (s.error) { } else if (s.error) { }
else if (s.empty === 'search') parts.push(Empty('search', 'No images match "wall"', 'Nothing matches in Unused. Check the spelling, or clear the filters.', 'Clear filters', false)) else if (s.empty === 'search') parts.push(Empty('search', 'No images match "wall"', 'Nothing matches in Unused. Check the spelling, or clear the filters.', 'Clear filters', false))
else if (tab === 'dups') parts.push(s.dupEmpty ? Empty('copy', 'No duplicates', 'Every image in your library is different.', 'Back to all images', false) : Groups()) else if (tab === 'dups') parts.push(s.dupEmpty ? Empty('copy', 'No duplicates', 'Every image in your library is different.', 'Back to all images', false) : Groups())
else { else {
if (view === 'grid') parts.push(SelectAll(), Grid()); else parts.push(m ? Cards() : Table()) if (view === 'grid') parts.push(SelectAll(), Grid()); else parts.push(m ? Cards() : Table())
parts.push(...Pager())
} }
return parts return parts
} }
@@ -205,7 +205,7 @@ class Component extends DCLogic {
withM({ ...L({ modal: del(false, 'Couldn\'t delete the article, so it\'s still there. Try again in a moment.') }), name: 'Delete: failed', trigger: '500, a partial failure where the article is kept. The error shows inside the dialog.' }), withM({ ...L({ modal: del(false, 'Couldn\'t delete the article, so it\'s still there. Try again in a moment.') }), name: 'Delete: failed', trigger: '500, a partial failure where the article is kept. The error shows inside the dialog.' }),
withM({ ...L({ list: { counts: [5, 4, 1], rows: rows().filter((r, i) => i !== 3) }, toasts: [T('success', 'circle-check', 'Article deleted', '"Winter checklist for timber windows" and its images are gone.')] }), name: 'Deleted', trigger: '204: the row is removed and focus moves to the next row. This is also where a delete from the editor ends up.' }) withM({ ...L({ list: { counts: [5, 4, 1], rows: rows().filter((r, i) => i !== 3) }, toasts: [T('success', 'circle-check', 'Article deleted', '"Winter checklist for timber windows" and its images are gone.')] }), name: 'Deleted', trigger: '204: the row is removed and focus moves to the next row. This is also where a delete from the editor ends up.' })
] }, ] },
{ label: 'Articles: pagination', title: 'Articles: pagination', route: '/articles?page=2&size=50', api: 'GET /v1/article/?page=&size=&q=&status= (proposed: needs backend page, size, total in the response)', note: 'Page and page size live in the URL as page and size. Defaults (page 1, size 25) are omitted. Allowed sizes 5, 25, 50, 100. Search, tab or sort changes reset to page 1. Rows equal the page size; the frames draw at most 10 rows. The paginator hides when everything fits on one page; the summary stays. Mobile shows Previous, "Page N of M" and Next. Keyboard: Tab reaches page size, first, previous, each page, next, last; Enter or Space activates; the current page has aria-current="page"; ArrowDown opens the page-size list, Enter selects, Esc closes.', states: [ { label: 'Articles: pagination', title: 'Articles: pagination', route: '/articles?page=2&size=50', api: 'GET /v1/article/?page=&size=&q=&status= (page, size; total in the X-Total-Count header; invalid values are normalised in the URL, never shown as an error)', note: 'Page and page size live in the URL as page and size. Defaults (page 1, size 25) are omitted. Allowed sizes 5, 25, 50, 100. Search, tab or sort changes reset to page 1. Rows equal the page size; the frames draw at most 10 rows. The paginator hides when everything fits on one page; the summary stays. Mobile shows Previous, "Page N of M" and Next. Keyboard: Tab reaches page size, first, previous, each page, next, last; Enter or Space activates; the current page has aria-current="page"; ArrowDown opens the page-size list, Enter selects, Esc closes.', states: [
withM({ ...PG({ page: 1 }), name: 'First page', trigger: '/articles. Previous and First are disabled. Summary "Showing 1–25 of 312".' }), withM({ ...PG({ page: 1 }), name: 'First page', trigger: '/articles. Previous and First are disabled. Summary "Showing 1–25 of 312".' }),
withM({ ...PG({ page: 7 }), name: 'Middle page', trigger: '/articles?page=7. Current page uses --rc-sand and --rc-on-sand; ellipses mark skipped ranges.' }), withM({ ...PG({ page: 7 }), name: 'Middle page', trigger: '/articles?page=7. Current page uses --rc-sand and --rc-on-sand; ellipses mark skipped ranges.' }),
withM({ ...PG({ page: 13 }), name: 'Last page', trigger: '/articles?page=13. Next and Last are disabled. Summary "Showing 301–312 of 312".' }), withM({ ...PG({ page: 13 }), name: 'Last page', trigger: '/articles?page=13. Next and Last are disabled. Summary "Showing 301–312 of 312".' }),
@@ -269,7 +269,16 @@ class Component extends DCLogic {
E({ notfound: true }, { name: 'Not found (404)', trigger: 'Unknown id, or the article was deleted elsewhere.' }) E({ notfound: true }, { name: 'Not found (404)', trigger: 'Unknown id, or the article was deleted elsewhere.' })
] }, ] },
{ label: 'Media', title: 'Media', route: '/media', api: 'GET /v1/media/ · POST /v1/media/ · PATCH /v1/media/{id} · DELETE /v1/media/{id} · POST /v1/media/merge · GET /v1/media/{id}/usage', note: 'New route for verified users, in the sidebar between Articles and Users with the images icon. Images belong to the library, not to an article: one image can be used in many articles, and deleting an article never deletes images. Uploads from this page are not cropped or resized, and the file is stored as chosen. "Used in" is read from article bodies each time the list loads. Duplicates are images with identical content, whatever their filenames. Alt text is not stored on the image; it is set when inserting into an article. Search, filter, sort and view are stored in the query string. Keyboard: grid items are buttons in one tab stop each; Enter opens the detail panel, Space toggles selection, and Escape closes the panel or clears the selection. Selection changes are announced ("north-wall.jpg selected, 1 selected").', states: [ { label: 'Media', title: 'Media', route: '/media', api: 'GET /v1/media/ · POST /v1/media/ · PATCH /v1/media/{id} · DELETE /v1/media/{id} · POST /v1/media/merge · GET /v1/media/{id}/usage', note: 'New route for verified users, in the sidebar between Articles and Users with the images icon. Images belong to the library, not to an article: one image can be used in many articles, and deleting an article never deletes images. Uploads from this page are not cropped or resized, and the file is stored as chosen. "Used in" is read from article bodies each time the list loads. Duplicates are images with identical content, whatever their filenames. Alt text is not stored on the image; it is set when inserting into an article. Search, filter, sort and view are stored in the query string. Keyboard: grid items are buttons in one tab stop each; Enter opens the detail panel, Space toggles selection, and Escape closes the panel or clears the selection. Selection changes are announced ("north-wall.jpg selected, 1 selected").', states: [
Mp({ name: 'Grid, many images', trigger: 'Default view. 12 images, newest first. Each item is a button labelled like "north-wall.jpg, 3.9 MB, used in 2 articles". Badges: "Unused" (warm), "Duplicate" (info), "Used in N" (outline). The Duplicates tab carries a count of groups only while duplicates exist.' }), Mp({ name: 'Grid, many images', trigger: 'Default view. 12 images, newest first. Each item is a button labelled like "north-wall.jpg, 3.9 MB, used in 2 articles". Badges: "Unused" (warm), "Duplicate" (info), "Used in N" (outline). The Duplicates tab carries a count of groups only while duplicates exist. Below the grid: "Showing 1–12 of 12" and the page-size select (same paginator as Articles).' }),
Mp({ pg: { page: 1, total: 312 }, name: 'Pagination: first page', trigger: '/media. Previous and First are disabled. Summary "Showing 1–25 of 312". The paginator sits under the grid or list and uses the Articles controls: 5, 25, 50 or 100 per page, default 25; page and size live in the URL as page and size (/media?page=7&size=50); search, tab, sort, view and size changes reset to page 1. API: GET /v1/media/?page=&size=&q=&filter= with the total in X-Total-Count.' }),
Mp({ pg: { page: 7, total: 312 }, name: 'Pagination: middle page', trigger: '/media?page=7. Current page uses --rc-sand and --rc-on-sand; ellipses mark skipped ranges.' }),
Mp({ pg: { page: 13, total: 312 }, name: 'Pagination: last page', trigger: '/media?page=13. Next and Last are disabled. Summary "Showing 301–312 of 312".' }),
Mp({ view: 'list', pg: { page: 2, total: 312 }, name: 'Pagination: list view', trigger: 'Same paginator under the table. On mobile: Previous, "Page 2 of 13", Next, with the 44 px targets.' }),
Mp({ loading: true, pg: { page: 6, total: 312, loading: 7 }, name: 'Pagination: loading next page', trigger: 'Next was pressed. Skeleton cards keep the grid height, controls are disabled, summary reads "Loading page 7…".' }),
Mp({ pg: { page: 6, total: 312, err: 'Couldn\'t load page 7. Showing page 6.' }, name: 'Pagination: error loading a page', trigger: '500 or network failure on page change. Previous images stay; Retry sits in the notice.' }),
Mp({ pg: { page: 13, total: 312, note: 'That page doesn\'t exist. Showing the last page.' }, name: 'Pagination: page out of range', trigger: '/media?page=99 is replaced with ?page=13 and this notice is shown.' }),
Mp({ pg: { page: 1, total: 312, sizeOpen: true }, name: 'Pagination: page size menu', trigger: 'The select opens upward. 5, 25, 50 or 100; the chosen size is saved as size and the page resets to 1.' }),
Mp({ pg: { page: 7, total: 312, focus: 'next' }, name: 'Pagination: focus-visible', trigger: 'Keyboard focus on Next: 2 px --ui-primary outline, 2 px offset. Same ring on every paginator control.' }),
Mp({ ids: [1, 4, 8], name: 'Grid, few images', trigger: 'Three images. Cards keep their size instead of stretching. Checkerboard shows behind the SVG.' }), Mp({ ids: [1, 4, 8], name: 'Grid, few images', trigger: 'Three images. Cards keep their size instead of stretching. Checkerboard shows behind the SVG.' }),
Mp({ view: 'list', name: 'List view', trigger: 'The list icon in the toggle. A table from md up (Filename, Dimensions, Size, Type, Used in, Uploaded by, Uploaded) and a card list below md. The "Used in" count opens the article list in the detail panel.' }), Mp({ view: 'list', name: 'List view', trigger: 'The list icon in the toggle. A table from md up (Filename, Dimensions, Size, Type, Used in, Uploaded by, Uploaded) and a card list below md. The "Used in" count opens the article list in the detail panel.' }),
Mp({ loading: true, name: 'Loading', trigger: 'The first load, a retry, or a change of search or filter. Skeleton cards appear after 150 ms.' }), Mp({ loading: true, name: 'Loading', trigger: 'The first load, a retry, or a change of search or filter. Skeleton cards appear after 150 ms.' }),
+2 -2
View File
@@ -33,8 +33,8 @@ Glossary pass 2, changes made: activity log lines "Removed N expired invites" an
## Public image 400 bodies ## Public image 400 bodies
`{ "status": 400, "error": "unknown_variant", "message": "Unknown variant", "detail": "hero" }` and `{ "status": 400, "error": "unsupported_parameter", "message": "Unsupported parameter", "detail": "width" }`. The original is not served as a fallback. `{ "status": 400, "error": "unknown_variant", "message": "Unknown variant", "detail": "hero" }` and `{ "status": 400, "error": "unsupported_parameter", "message": "Unsupported parameter", "detail": "width" }`. The original is not served as a fallback.
## Articles pagination ## Articles and Media pagination
Loading next page keeps row height, controls disabled, summary "Loading page 7…". Error "Couldn't load page 7. Showing page 6." with Retry. Out of range "That page doesn't exist. Showing the last page." Page size menu offers 5, 25, 50, 100. Same states and copy on Articles and Media (Media draws them under the grid or list). Loading next page keeps row height, controls disabled, summary "Loading page 7…". Error "Couldn't load page 7. Showing page 6." with Retry. Out of range "That page doesn't exist. Showing the last page." Page size menu offers 5, 25, 50, 100. Invalid `page` or `size` in the URL (size=30, page=abc, page=0) is replaced by the default without a notice; the API 400 never reaches the user.
## Editor ## Editor
Counter without limit ("1,204 words · 7,318 characters"; at 120,000 characters "19,845 words · 120,000 characters"), "Saving…", "Saved", "Couldn't save. Your text is kept. Retry". Link states and the toolbar overflow are drawn in the Editor group (11 states). Counter without limit ("1,204 words · 7,318 characters"; at 120,000 characters "19,845 words · 120,000 characters"), "Saving…", "Saved", "Couldn't save. Your text is kept. Retry". Link states and the toolbar overflow are drawn in the Editor group (11 states).
+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. 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. 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=` request, no `?limit=24` list request, then the crop step after an empty lookup, and 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"**. 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. 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. 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 a bottom sheet on mobile
And the footer reads "Cancel", and on the Library tab also a primary "Insert image" that is And the footer reads "Cancel", and on the Library tab also a primary "Insert image" that is
disabled until an image is selected disabled until an image is selected
And the Library tab calls GET /v1/media/ through useApi with limit=24, with q only while a 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 cursor only when loading more (no filter and no sort parameter), and the search is set and page 1, then 2, 3, ... when loading more (no filter and no sort parameter),
Upload tab is specified in features/editor_image_upload.feature 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 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 "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 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 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; 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 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 Rule: Which tab opens
Scenario: The library has images, so Library 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 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" 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 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 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 When the dialog opens
Then the Upload tab is selected, in its idle state Then the Upload tab is selected, in its idle state
When the user selects the Library tab 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 Scenario: A dropped file opens Upload and sends no list request
When the user drops "before.png" onto the editor 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 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 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" Then the line reads "Newest first"
Scenario: Load more is hidden on the last page 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 Then "Load more" is not shown
Scenario Outline: A tile's accessible name 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 Scenario: Typing searches after a debounce
Given the Library tab shows the 12-image fixture Given the Library tab shows the 12-image fixture
When the user types "wall" 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 And skeleton tiles show after 150 ms, and any selection is cleared
Scenario: The x in the field clears the search Scenario: The x in the field clears the search
Given the search field holds "wall" with results shown Given the search field holds "wall" with results shown
When the user activates the x in the field 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 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 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 And the search field still shows "wall" above it, and "Insert image" is disabled
When the user activates "Clear search" 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 Rule: Load more
Scenario: Loading more shows progress and appends 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" 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 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 And focus stays on the button
When the request resolves 200 with 12 more images 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 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 Scenario: The next page and the search combine
Given the search "wall" is active and the page has next_cursor "c24" Given the search "wall" is active and the response total is above the loaded count
When the user activates "Load more" 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 Rule: Empty and failed
Scenario: Empty library 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" 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 And "Insert image" is disabled
When the user activates "Upload an image" When the user activates "Upload an image"
+8 -8
View File
@@ -2,29 +2,29 @@
## Automated (Vitest + Vue Test Utils, `task test:web`) ## 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. 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. 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. 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". 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. 7. **Accessible names** - the three Examples.
8. **Selection** - click, Enter and Space each select; check, outline, "Insert image" enabled. 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". 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. 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 `""`. 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. 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. 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. 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` ## 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: 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": 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. - "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. - "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. - [ ] 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. - [ ] Hover on a tile and on "Load more", and the focus ring.
- [ ] Motion: the dialog follows the standard 240 ms timing. - [ ] 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. - [ ] A screen reader reads the tile names and the alt text help.
## Manual verification ## 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; - 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, - media item: id, filename, url, size, content_type, width, height, sha256, uploaded_by,
created_at, used_in, duplicate; 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; - merge: articles_updated, merged;
- usage: id, title, published; - usage: id, title, published;
- user list item: id, kind, username, display_name, email, role, status, created_at; - 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, 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 error), the request functions for list, usage, rename and delete, and the copy helper, and
it maps ApiError and NetworkError to the states below 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 MediaPage calls GET /v1/media/ with the query built from the URL, always with an explicit
and every request carries the jwt cookie through useApi 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 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 survives a reload: ?q= (search text), ?filter= ("unused" or "duplicates"; absent means
All), ?sort= ("oldest", "name" or "largest"; absent means Newest), ?view= ("list"; absent 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 | | 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 | | | 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 | | 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 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 When the user navigates to /media
Then the breadcrumb reads "Media" (mobile title "Media") Then the breadcrumb reads "Media" (mobile title "Media")
And the tabs read "All 12", "Unused 2", "Duplicates 2", with "All" selected, and the 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 Scenario Outline: The query string is the source of truth for search, tab, sort and view
Given GET /v1/media/ is mocked Given GET /v1/media/ is mocked
When the user opens /media<url> 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>" And the control shows "<control>"
Examples: 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 Scenario: Changing a control writes the query string and reloads the list
Given the Default state has loaded Given the Default state has loaded
When the user types "wall" in the search field 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 When the user selects the "Unused" tab
Then the URL reads ?q=wall&filter=unused and the list is requested again Then the URL reads ?q=wall&filter=unused and the list is requested again
When the user picks "Largest" in the sort menu 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 Scenario: No results, search and filter
Given the Default state has loaded 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 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" 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 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. - **Toast placement and stacking** on a live app.
- **Counts after a delete** against a live server: the background refresh gives the true Unused - **Counts after a delete** against a live server: the background refresh gives the true Unused
and Duplicates counts. and Duplicates counts.
- **A library above 100 images**: B1 requests `limit=100` and ignores `next_cursor`, so only the - **A library above 25 images**: amended by `features/media_pagination_screen.feature`. B1 used to
first 100 show. 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 ## 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. 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"`. 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. 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. 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 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 And this slice is SPA only: the API from slice A is used unchanged, and no Go code, use case
or repository changes 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 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 "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, 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 the "Select all" text and the partial state exist; the B2 states no longer carry
skip 'pending' in web/visual/states.ts skip 'pending' in web/visual/states.ts
And the calls used are POST /v1/media/ (multipart field "file", sent with XMLHttpRequest so 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 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 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 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 Scenario: A row whose content already exists
Given the user chooses "floorboards-2.jpg" (2.4 MB, valid) Given the user chooses "floorboards-2.jpg" (2.4 MB, valid)
When the SHA-256 of the file is computed 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) 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" 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 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" 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 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" When the user activates "Use existing"
Then the search and the tab reset to All and the image is selected 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 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 When 3 images are selected
Then that checkbox shows the partial state Then that checkbox shows the partial state
When the user activates it while it is partial 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 When the user activates it while it is checked
Then the selection is emptied 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 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 Scenario: The Duplicates tab is enabled
Given the Default state has loaded Given the Default state has loaded
When the user selects the "Duplicates 2" tab 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 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 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 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 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 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 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" 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). 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. 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. 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=`. 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. 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. 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. 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. 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. 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. 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. 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. 18. **B1 regression**: media_browse specs updated for the superseded statements and the rest still passes.
+11 -6
View File
@@ -26,8 +26,8 @@ const MaxImageSize int64 = 10485760
const ( const (
MinFilenameRunes = 1 MinFilenameRunes = 1
MaxFilenameRunes = 100 MaxFilenameRunes = 100
DefaultListLimit = 24 DefaultListPage = 1
MaxListLimit = 100 DefaultListSize = 25
) )
var allowedContentTypes = map[string]bool{ var allowedContentTypes = map[string]bool{
@@ -128,12 +128,12 @@ type ListQuery struct {
Filter Filter Filter Filter
Variants VariantsFilter Variants VariantsFilter
Sort Sort Sort Sort
Limit int Page int
Cursor string Size int
} }
func NewListQuery() ListQuery { func NewListQuery() ListQuery {
return ListQuery{Filter: FilterAll, Sort: SortNewest, Limit: DefaultListLimit} return ListQuery{Filter: FilterAll, Sort: SortNewest, Page: DefaultListPage, Size: DefaultListSize}
} }
func (q ListQuery) Validate() error { func (q ListQuery) Validate() error {
@@ -152,7 +152,12 @@ func (q ListQuery) Validate() error {
default: default:
return ErrInvalidQuery return ErrInvalidQuery
} }
if q.Limit < 1 || q.Limit > MaxListLimit { if q.Page < 1 {
return ErrInvalidQuery
}
switch q.Size {
case 5, 25, 50, 100:
default:
return ErrInvalidQuery return ErrInvalidQuery
} }
if q.Hash != "" && !sha256Pattern.MatchString(q.Hash) { if q.Hash != "" && !sha256Pattern.MatchString(q.Hash) {
+9 -37
View File
@@ -4,13 +4,9 @@ import (
"RenewCMS/internal/domain/article" "RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/image" "RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/user" "RenewCMS/internal/domain/user"
"encoding/base64"
"strconv"
"strings" "strings"
) )
const cursorPrefix = "offset:"
type MediaItem struct { type MediaItem struct {
Image image.Image Image image.Image
UploadedBy string UploadedBy string
@@ -28,9 +24,11 @@ type MediaCounts struct {
} }
type MediaPage struct { type MediaPage struct {
Items []MediaItem Items []MediaItem
NextCursor string Total int
Counts MediaCounts Page int
Size int
Counts MediaCounts
} }
type ListMediaUseCase struct { type ListMediaUseCase struct {
@@ -48,10 +46,6 @@ func (l *ListMediaUseCase) List(query image.ListQuery) (MediaPage, error) {
if err := query.Validate(); err != nil { if err := query.Validate(); err != nil {
return MediaPage{}, err return MediaPage{}, err
} }
offset, err := decodeCursor(query.Cursor)
if err != nil {
return MediaPage{}, err
}
usage, err := l.usageCounter() usage, err := l.usageCounter()
if err != nil { if err != nil {
@@ -83,8 +77,9 @@ func (l *ListMediaUseCase) List(query image.ListQuery) (MediaPage, error) {
} }
} }
pageEnd := min(offset+query.Limit, len(matching)) offset := (query.Page - 1) * query.Size
pageStart := min(offset, pageEnd) pageStart := min(offset, len(matching))
pageEnd := min(pageStart+query.Size, len(matching))
names := newUploaderNames(l.userRepository) names := newUploaderNames(l.userRepository)
items := []MediaItem{} items := []MediaItem{}
for _, img := range matching[pageStart:pageEnd] { for _, img := range matching[pageStart:pageEnd] {
@@ -103,11 +98,7 @@ func (l *ListMediaUseCase) List(query image.ListQuery) (MediaPage, error) {
}) })
} }
page := MediaPage{Items: items, Counts: counts} return MediaPage{Items: items, Total: len(matching), Page: query.Page, Size: query.Size, Counts: counts}, nil
if pageEnd < len(matching) {
page.NextCursor = encodeCursor(pageEnd)
}
return page, nil
} }
func (l *ListMediaUseCase) Describe(img image.Image) (MediaItem, error) { func (l *ListMediaUseCase) Describe(img image.Image) (MediaItem, error) {
@@ -217,22 +208,3 @@ func matchesFilter(filter image.Filter, usedIn int, duplicate bool) bool {
return true return true
} }
} }
func encodeCursor(offset int) string {
return base64.RawURLEncoding.EncodeToString([]byte(cursorPrefix + strconv.Itoa(offset)))
}
func decodeCursor(cursor string) (int, error) {
if cursor == "" {
return 0, nil
}
raw, err := base64.RawURLEncoding.DecodeString(cursor)
if err != nil || !strings.HasPrefix(string(raw), cursorPrefix) {
return 0, image.ErrInvalidQuery
}
offset, err := strconv.Atoi(strings.TrimPrefix(string(raw), cursorPrefix))
if err != nil || offset < 0 {
return 0, image.ErrInvalidQuery
}
return offset, nil
}
+67
View File
@@ -0,0 +1,67 @@
package test
import (
"testing"
"time"
"RenewCMS/internal/domain/image"
)
func sortedIDs(order image.Sort, imgs []image.Image) []uint32 {
image.SortImages(imgs, order)
ids := make([]uint32, len(imgs))
for i, img := range imgs {
ids[i] = img.ID
}
return ids
}
func equalIDs(a, b []uint32) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func TestImageSortTieBreaks(t *testing.T) {
now := time.Now()
build := func() []image.Image {
return []image.Image{
{ID: 2, Filename: "a.png", Size: 10, CreatedAt: now},
{ID: 1, Filename: "a.png", Size: 10, CreatedAt: now},
{ID: 3, Filename: "b.png", Size: 20, CreatedAt: now.Add(time.Hour)},
}
}
cases := []struct {
order image.Sort
want []uint32
}{
{image.SortOldest, []uint32{1, 2, 3}},
{image.SortNewest, []uint32{3, 2, 1}},
{image.SortName, []uint32{1, 2, 3}},
{image.SortLargest, []uint32{3, 1, 2}},
}
for _, c := range cases {
if got := sortedIDs(c.order, build()); !equalIDs(got, c.want) {
t.Errorf("%s: got %v want %v", c.order, got, c.want)
}
}
}
func TestImageSortByTotalSizeTieBreak(t *testing.T) {
imgs := []image.Image{
{ID: 2, Path: "/u/x.png", Size: 10},
{ID: 1, Path: "/u/y.png", Size: 10},
{ID: 3, Path: "/u/z.png", Size: 11},
}
image.SortImagesByTotalSize(imgs, nil)
got := []uint32{imgs[0].ID, imgs[1].ID, imgs[2].ID}
if !equalIDs(got, []uint32{3, 1, 2}) {
t.Fatalf("got %v", got)
}
}
+2
View File
@@ -75,6 +75,7 @@ type mockImageRepo struct {
images map[uint32]imageDomain.Image images map[uint32]imageDomain.Image
nextID uint32 nextID uint32
fail failSwitch fail failSwitch
listCalls int
failDeleteFor map[uint32]error failDeleteFor map[uint32]error
deleteCalls []uint32 deleteCalls []uint32
createCalls int createCalls int
@@ -169,6 +170,7 @@ func (m *mockImageRepo) Delete(id uint32) error {
} }
func (m *mockImageRepo) List(query imageDomain.SearchQuery) ([]imageDomain.Image, error) { func (m *mockImageRepo) List(query imageDomain.SearchQuery) ([]imageDomain.Image, error) {
m.listCalls++
if err := m.fail.trigger("List"); err != nil { if err := m.fail.trigger("List"); err != nil {
return nil, err return nil, err
} }
-35
View File
@@ -3,9 +3,6 @@ package test
import ( import (
domainArticle "RenewCMS/internal/domain/article" domainArticle "RenewCMS/internal/domain/article"
"RenewCMS/internal/infrastructure/useCases" "RenewCMS/internal/infrastructure/useCases"
"encoding/base64"
"net/http"
"net/url"
"reflect" "reflect"
"testing" "testing"
) )
@@ -20,38 +17,6 @@ func (r *mediaOrderRecordingArticleRepo) UpdateBody(id uint32, body string) (dom
return r.mockArticleRepo.UpdateBody(id, body) return r.mockArticleRepo.UpdateBody(id, body)
} }
func TestMediaCursorBoundaries(t *testing.T) {
seed := func(env *mediaEnv) {
env.seedImage(1, "a.jpg", 10, "image/jpeg", 3, 2, hashA, 1)
env.seedImage(2, "b.jpg", 20, "image/jpeg", 3, 2, hashB, 1)
}
encode := func(raw string) string {
return url.QueryEscape(base64.RawURLEncoding.EncodeToString([]byte(raw)))
}
t.Run("A cursor at offset zero is accepted and starts from the first item", func(t *testing.T) {
env := newMediaEnv(t)
seed(env)
rec := env.send(t, http.MethodGet, "/v1/media/?cursor="+encode("offset:0"), "", mediaVerifiedUserId)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rec.Code)
}
})
t.Run("A negative cursor offset is refused", func(t *testing.T) {
env := newMediaEnv(t)
seed(env)
rec := env.send(t, http.MethodGet, "/v1/media/?cursor="+encode("offset:-1"), "", mediaVerifiedUserId)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d", rec.Code)
}
})
}
func TestMediaMergeRewritesArticlesInIdOrder(t *testing.T) { func TestMediaMergeRewritesArticlesInIdOrder(t *testing.T) {
env := newMediaEnv(t) env := newMediaEnv(t)
env.seedImage(2, "floorboards.jpg", 20, "image/jpeg", 3, 2, hashB, 1) env.seedImage(2, "floorboards.jpg", 20, "image/jpeg", 3, 2, hashB, 1)
+3 -1
View File
@@ -32,6 +32,7 @@ const (
type mediaEnv struct { type mediaEnv struct {
articles *mockArticleRepo articles *mockArticleRepo
images *mockImageRepo images *mockImageRepo
variants *mockVariantFileRepo
storage *mockImageStorage storage *mockImageStorage
users *mockUserRepo users *mockUserRepo
purgeSettings *mockPurgeSettingsRepo purgeSettings *mockPurgeSettingsRepo
@@ -48,6 +49,7 @@ func newMediaEnv(t *testing.T) *mediaEnv {
env := &mediaEnv{ env := &mediaEnv{
articles: newMockArticleRepo(), articles: newMockArticleRepo(),
images: newMockImageRepo(), images: newMockImageRepo(),
variants: newMockVariantFileRepo(),
storage: newMockImageStorage(), storage: newMockImageStorage(),
users: newMockUserRepo(), users: newMockUserRepo(),
token: jwtauth.New("HS256", []byte("test-secret"), nil), token: jwtauth.New("HS256", []byte("test-secret"), nil),
@@ -72,7 +74,7 @@ func newMediaEnv(t *testing.T) *mediaEnv {
GetUserUseCase: useCases.NewGetUserUseCase(env.users), GetUserUseCase: useCases.NewGetUserUseCase(env.users),
CreateImageUseCase: useCases.NewCreateImageUseCase(env.images, env.storage, discardRecorder), CreateImageUseCase: useCases.NewCreateImageUseCase(env.images, env.storage, discardRecorder),
GenerateVariantsOnUploadUseCase: newInertGenerateOnUploadUseCase(), GenerateVariantsOnUploadUseCase: newInertGenerateOnUploadUseCase(),
ListMediaUseCase: useCases.NewListMediaUseCase(env.images, env.articles, env.users, newMockVariantFileRepo()), ListMediaUseCase: useCases.NewListMediaUseCase(env.images, env.articles, env.users, env.variants),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(env.images, discardRecorder), RenameMediaUseCase: useCases.NewRenameMediaUseCase(env.images, discardRecorder),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(env.images, env.storage, newMockVariantFileRepo(), discardRecorder), DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(env.images, env.storage, newMockVariantFileRepo(), discardRecorder),
MergeMediaUseCase: merge, MergeMediaUseCase: merge,
+21 -25
View File
@@ -7,7 +7,6 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"net/http" "net/http"
"net/url"
"os/exec" "os/exec"
"reflect" "reflect"
"regexp" "regexp"
@@ -35,9 +34,11 @@ func seedListingLibrary(env *mediaEnv) {
} }
type listBody struct { type listBody struct {
Items []map[string]any `json:"items"` Items []map[string]any `json:"items"`
NextCursor *string `json:"next_cursor"` Total int `json:"total"`
Counts map[string]int `json:"counts"` Page int `json:"page"`
Size int `json:"size"`
Counts map[string]int `json:"counts"`
} }
func (e *mediaEnv) list(t *testing.T, query string) (listBody, string) { func (e *mediaEnv) list(t *testing.T, query string) (listBody, string) {
@@ -155,11 +156,11 @@ func TestMediaListing(t *testing.T) {
if !strings.Contains(raw, `"width":null`) || !strings.Contains(raw, `"height":null`) { if !strings.Contains(raw, `"width":null`) || !strings.Contains(raw, `"height":null`) {
t.Errorf("expected null width and height in the raw body, got %s", raw) t.Errorf("expected null width and height in the raw body, got %s", raw)
} }
if got.NextCursor != nil { if strings.Contains(raw, "next_cursor") {
t.Errorf("expected next_cursor null, got %v", *got.NextCursor) t.Errorf("expected no next_cursor in the raw body")
} }
if !strings.Contains(raw, `"next_cursor":null`) { if got.Total != 5 || got.Page != 1 || got.Size != 25 {
t.Errorf("expected next_cursor null in the raw body") t.Errorf("unexpected paging %d %d %d", got.Total, got.Page, got.Size)
} }
if !reflect.DeepEqual(got.Counts, map[string]int{"all": 5, "unused": 3, "duplicates": 1}) { if !reflect.DeepEqual(got.Counts, map[string]int{"all": 5, "unused": 3, "duplicates": 1}) {
t.Errorf("unexpected counts %v", got.Counts) t.Errorf("unexpected counts %v", got.Counts)
@@ -293,31 +294,26 @@ func TestMediaListing(t *testing.T) {
} }
}) })
t.Run("limit and cursor page through the list", func(t *testing.T) { t.Run("page and size page through the list", func(t *testing.T) {
env := newMediaEnv(t) env := newMediaEnv(t)
seedListingLibrary(env) for id := uint32(1); id <= 12; id++ {
env.seedImage(id, "image.jpg", 10, "image/jpeg", 3, 2, hashA, 1)
first, _ := env.list(t, "?limit=2")
expectIds(t, idsOf(first.Items), []uint32{5, 4})
if first.NextCursor == nil || *first.NextCursor == "" {
t.Fatalf("expected a next_cursor on page 1")
} }
second, _ := env.list(t, "?limit=2&cursor="+url.QueryEscape(*first.NextCursor)) first, _ := env.list(t, "?size=5&page=1")
expectIds(t, idsOf(second.Items), []uint32{3, 2}) second, _ := env.list(t, "?size=5&page=2")
if second.NextCursor == nil || *second.NextCursor == "" { third, _ := env.list(t, "?size=5&page=3")
t.Fatalf("expected a next_cursor on page 2")
}
third, _ := env.list(t, "?limit=2&cursor="+url.QueryEscape(*second.NextCursor)) expectIds(t, idsOf(first.Items), []uint32{12, 11, 10, 9, 8})
expectIds(t, idsOf(third.Items), []uint32{1}) expectIds(t, idsOf(second.Items), []uint32{7, 6, 5, 4, 3})
if third.NextCursor != nil { expectIds(t, idsOf(third.Items), []uint32{2, 1})
t.Errorf("expected next_cursor null on the last page, got %v", *third.NextCursor) if third.Total != 12 || third.Page != 3 || third.Size != 5 {
t.Errorf("unexpected paging %d %d %d", third.Total, third.Page, third.Size)
} }
}) })
t.Run("A malformed query answers 400", func(t *testing.T) { t.Run("A malformed query answers 400", func(t *testing.T) {
for _, query := range []string{"filter=bogus", "sort=bogus", "limit=0", "limit=101", "limit=abc", "cursor=%%%", "cursor=bogus", "hash=notahash"} { for _, query := range []string{"filter=bogus", "sort=bogus", "page=0", "page=abc", "size=10", "size=200", "size=", "hash=notahash"} {
t.Run(query, func(t *testing.T) { t.Run(query, func(t *testing.T) {
env := newMediaEnv(t) env := newMediaEnv(t)
seedListingLibrary(env) seedListingLibrary(env)
+349
View File
@@ -0,0 +1,349 @@
package test
import (
"RenewCMS/api/pkg/apperr"
domainImage "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/useCases"
"errors"
"fmt"
"net/http"
"net/url"
"reflect"
"strings"
"testing"
"time"
)
func seedBulkImages(env *mediaEnv, count int) {
for id := uint32(1); id <= uint32(count); id++ {
env.seedImage(id, fmt.Sprintf("image-%02d.jpg", id), int64(id)*10, "image/jpeg", 3, 2, hashA, 1)
}
}
func TestMediaPaginationEnvelope(t *testing.T) {
t.Run("A bare request answers the envelope with the defaults", func(t *testing.T) {
env := newMediaEnv(t)
seedBulkImages(env, 30)
got, raw := env.list(t, "")
if got.Page != 1 || got.Size != 25 || got.Total != 30 || len(got.Items) != 25 {
t.Errorf("unexpected envelope %d %d %d %d", got.Page, got.Size, got.Total, len(got.Items))
}
if got.Counts["all"] != 30 {
t.Errorf("unexpected counts %v", got.Counts)
}
if strings.Contains(raw, "next_cursor") {
t.Errorf("unexpected next_cursor in %s", raw)
}
})
t.Run("Defaults when only one parameter is given", func(t *testing.T) {
examples := []struct {
query string
page int
size int
count int
}{
{"?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},
}
for _, example := range examples {
t.Run(example.query, func(t *testing.T) {
env := newMediaEnv(t)
seedBulkImages(env, 60)
got, _ := env.list(t, example.query)
if got.Page != example.page || got.Size != example.size || got.Total != 60 || len(got.Items) != example.count {
t.Errorf("unexpected envelope %d %d %d %d", got.Page, got.Size, got.Total, len(got.Items))
}
})
}
})
t.Run("Pages partition the list with no overlap", func(t *testing.T) {
env := newMediaEnv(t)
seedBulkImages(env, 12)
seen := map[uint32]bool{}
for page, want := range map[int]int{1: 5, 2: 5, 3: 2} {
got, _ := env.list(t, fmt.Sprintf("?page=%d&size=5", page))
if len(got.Items) != want || got.Total != 12 {
t.Errorf("page %d holds %d of %d", page, len(got.Items), got.Total)
}
for _, id := range idsOf(got.Items) {
if seen[id] {
t.Errorf("id %d repeats", id)
}
seen[id] = true
}
}
if len(seen) != 12 {
t.Errorf("expected 12 distinct ids, got %d", len(seen))
}
})
t.Run("Newest first with the id tie-break", func(t *testing.T) {
env := newMediaEnv(t)
for id := uint32(1); id <= 3; id++ {
env.seedImage(id, "a.jpg", 10, "image/jpeg", 3, 2, hashA, 1)
}
sameDay := time.Date(2026, time.September, 3, 0, 0, 0, 0, time.UTC)
for _, id := range []uint32{2, 3} {
img := env.images.images[id]
img.CreatedAt = sameDay
env.images.images[id] = img
}
first := env.images.images[1]
first.CreatedAt = sameDay.AddDate(0, 0, -2)
env.images.images[1] = first
got, _ := env.list(t, "?size=5")
expectIds(t, idsOf(got.Items), []uint32{3, 2, 1})
})
t.Run("Sort orders the whole library before paging", func(t *testing.T) {
names := []string{"mango", "Apple", "cherry", "banana", "Zucchini", "date", "fig", "Grape", "kiwi", "lemon", "olive", "pear"}
sizes := []int64{50, 120, 10, 90, 30, 110, 70, 20, 100, 60, 40, 80}
examples := map[string][]uint32{
"newest": {12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1},
"oldest": {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12},
"largest": {2, 6, 9, 4, 12, 7, 10, 1, 11, 5, 8, 3},
"name": {2, 4, 3, 6, 7, 8, 9, 10, 1, 11, 12, 5},
"total_size": {3, 8, 2, 6, 9, 4, 12, 7, 10, 1, 11, 5},
}
for sortName, want := range examples {
t.Run(sortName, func(t *testing.T) {
env := newMediaEnv(t)
for index, name := range names {
env.seedImage(uint32(index+1), name+".jpg", sizes[index], "image/jpeg", 3, 2, hashA, 1)
}
for id, bytes := range map[uint32]int64{3: 500, 8: 200} {
_, _ = env.variants.Save(domainImage.VariantFile{VariantName: "hero", SourceName: domainImage.StoredName(env.images.images[id].Path), CachedName: fmt.Sprintf("c%d", id), ContentType: "image/jpeg", Size: bytes, Fingerprint: "f"})
}
var all []uint32
for page := 1; page <= 3; page++ {
got, _ := env.list(t, fmt.Sprintf("?sort=%s&size=5&page=%d", sortName, page))
all = append(all, idsOf(got.Items)...)
}
expectIds(t, all, want)
})
}
})
t.Run("Filter and search narrow total and items but not the counts", func(t *testing.T) {
env := newMediaEnv(t)
seedBulkImages(env, 40)
for id := uint32(1); id <= 34; id++ {
env.seedArticle(id, "Article", true, env.urlOf(id))
}
unused, _ := env.list(t, "?filter=unused&size=5")
search, _ := env.list(t, "?q=image-1&size=5&page=2")
if unused.Total != 6 || len(unused.Items) != 5 || unused.Counts["all"] != 40 || unused.Counts["unused"] != 6 {
t.Errorf("unexpected unused page %d %d %v", unused.Total, len(unused.Items), unused.Counts)
}
if search.Total != 10 || len(search.Items) != 5 || search.Counts["all"] != 40 {
t.Errorf("unexpected search page %d %d %v", search.Total, len(search.Items), search.Counts)
}
})
t.Run("Hash lookup returns every copy in one page", func(t *testing.T) {
env := newMediaEnv(t)
seedListingLibrary(env)
env.seedImage(6, "floorboards-2.jpg", 10, "image/jpeg", 3, 2, hashB, 1)
explicit, _ := env.list(t, "?hash="+hashB+"&page=1&size=100")
bare, _ := env.list(t, "?hash="+hashB)
if explicit.Total != 3 || len(explicit.Items) != 3 {
t.Errorf("unexpected explicit lookup %d %d", explicit.Total, len(explicit.Items))
}
if bare.Page != 1 || bare.Size != 25 || bare.Total != 3 || len(bare.Items) != 3 {
t.Errorf("unexpected bare lookup %d %d %d %d", bare.Page, bare.Size, bare.Total, len(bare.Items))
}
})
t.Run("A page beyond the last is empty with the real total", func(t *testing.T) {
env := newMediaEnv(t)
seedBulkImages(env, 12)
got, raw := env.list(t, "?page=99&size=5")
if len(got.Items) != 0 || got.Total != 12 || got.Page != 99 || got.Size != 5 || got.Counts["all"] != 12 {
t.Errorf("unexpected envelope %+v", got)
}
if !strings.Contains(raw, `"items":[]`) {
t.Errorf("expected an empty array in %s", raw)
}
})
t.Run("An empty library", func(t *testing.T) {
env := newMediaEnv(t)
got, raw := env.list(t, "")
if len(got.Items) != 0 || got.Total != 0 || !reflect.DeepEqual(got.Counts, map[string]int{"all": 0, "unused": 0, "duplicates": 0}) {
t.Errorf("unexpected envelope %+v", got)
}
if !strings.Contains(raw, `"items":[]`) {
t.Errorf("expected an empty array in %s", raw)
}
})
t.Run("counts ignores every filter and the page", func(t *testing.T) {
env := newMediaEnv(t)
seedBulkImages(env, 30)
got, _ := env.list(t, "?q=zzzz&page=3&size=5")
if len(got.Items) != 0 || got.Total != 0 || got.Counts["all"] != 30 {
t.Errorf("unexpected envelope %+v", got)
}
})
t.Run("cursor and limit are ignored", func(t *testing.T) {
for _, query := range []string{"cursor=abc", "limit=5", "limit=0", "limit=abc", "cursor=b2Zmc2V0OjU"} {
t.Run(query, func(t *testing.T) {
env := newMediaEnv(t)
seedBulkImages(env, 30)
got, _ := env.list(t, "?"+query)
bare, _ := env.list(t, "")
if got.Page != 1 || got.Size != 25 || len(got.Items) != 25 {
t.Errorf("unexpected envelope %d %d %d", got.Page, got.Size, len(got.Items))
}
expectIds(t, idsOf(got.Items), idsOf(bare.Items))
})
}
})
t.Run("Invalid parameters answer 400", func(t *testing.T) {
queries := []string{"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"}
for _, query := range queries {
t.Run(query, func(t *testing.T) {
env := newMediaEnv(t)
seedBulkImages(env, 3)
rec := env.send(t, http.MethodGet, "/v1/media/?"+url.PathEscape(query), "", mediaVerifiedUserId)
if rec.Code != http.StatusBadRequest || rec.Body.String() != apperr.MediaQueryErrorMessage+"\n" {
t.Errorf("unexpected response %d %q", rec.Code, rec.Body.String())
}
if env.images.listCalls != 0 {
t.Errorf("expected the repository not to be called")
}
})
}
})
t.Run("Repository failure answers 500", func(t *testing.T) {
env := newMediaEnv(t)
env.images.fail.failNext("List")
rec := env.send(t, http.MethodGet, "/v1/media/", "", mediaVerifiedUserId)
if rec.Code != http.StatusInternalServerError || rec.Body.String() != "Internal server error\n" {
t.Errorf("unexpected response %d %q", rec.Code, rec.Body.String())
}
})
t.Run("An unauthenticated caller is refused as before", func(t *testing.T) {
env := newMediaEnv(t)
rec := env.send(t, http.MethodGet, "/v1/media/?page=1&size=5", "", 0)
if rec.Code != http.StatusUnauthorized || env.images.listCalls != 0 {
t.Errorf("unexpected response %d with %d calls", rec.Code, env.images.listCalls)
}
})
}
func TestListMediaUseCasePaging(t *testing.T) {
build := func() (*mockImageRepo, *mockVariantFileRepo, *useCases.ListMediaUseCase) {
images := newMockImageRepo()
variants := newMockVariantFileRepo()
return images, variants, useCases.NewListMediaUseCase(images, newMockArticleRepo(), newMockUserRepo(), variants)
}
seed := func(images *mockImageRepo, count int) {
for id := uint32(1); id <= uint32(count); id++ {
images.images[id] = domainImage.Image{ID: id, Path: fmt.Sprintf("/static/uploadedImages/img-%d.jpg", id), Filename: "a.jpg", Size: int64(id), CreatedAt: mockImageEpoch.Add(time.Duration(id) * time.Minute)}
}
}
t.Run("List returns the items, total, page, size and counts", func(t *testing.T) {
images, _, useCase := build()
seed(images, 12)
query := domainImage.NewListQuery()
query.Page, query.Size = 3, 5
page, err := useCase.List(query)
if err != nil || len(page.Items) != 2 || page.Total != 12 || page.Page != 3 || page.Size != 5 || page.Counts.All != 12 {
t.Errorf("unexpected page %+v err %v", page, err)
}
})
t.Run("The variants filter and total_size sort apply before slicing", func(t *testing.T) {
images, variants, useCase := build()
seed(images, 12)
for _, id := range []uint32{3, 7, 9} {
_, _ = variants.Save(domainImage.VariantFile{VariantName: "hero", SourceName: domainImage.StoredName(images.images[id].Path), CachedName: fmt.Sprintf("c%d", id), ContentType: "image/jpeg", Size: int64(id) * 1000, Fingerprint: "f"})
}
with := domainImage.NewListQuery()
with.Variants, with.Sort, with.Size = domainImage.VariantsWith, domainImage.SortTotalSize, 5
page, err := useCase.List(with)
if err != nil || page.Total != 3 || len(page.Items) != 3 || page.Items[0].Image.ID != 9 || page.Counts.All != 12 {
t.Errorf("unexpected page %+v err %v", page, err)
}
all := domainImage.NewListQuery()
all.Sort, all.Size = domainImage.SortTotalSize, 5
first, _ := useCase.List(all)
if first.Items[0].Image.ID != 9 || first.Items[1].Image.ID != 7 || first.Items[2].Image.ID != 3 {
t.Errorf("unexpected total_size order %v", first.Items)
}
})
t.Run("A repository error is propagated", func(t *testing.T) {
images, _, useCase := build()
images.fail.failNextWith("List", errRepositoryFailure)
_, err := useCase.List(domainImage.NewListQuery())
if !errors.Is(err, errRepositoryFailure) {
t.Errorf("err = %v", err)
}
})
t.Run("ListQuery.Validate rejects a bad page or size", func(t *testing.T) {
for _, mutate := range []func(*domainImage.ListQuery){
func(q *domainImage.ListQuery) { q.Page = 0 },
func(q *domainImage.ListQuery) { q.Size = 0 },
func(q *domainImage.ListQuery) { q.Size = 10 },
func(q *domainImage.ListQuery) { q.Size = 101 },
} {
query := domainImage.NewListQuery()
mutate(&query)
if err := query.Validate(); !errors.Is(err, domainImage.ErrInvalidQuery) {
t.Errorf("err = %v for %+v", err, query)
}
}
for _, size := range []int{5, 25, 50, 100} {
query := domainImage.NewListQuery()
query.Size = size
if err := query.Validate(); err != nil {
t.Errorf("size %d: err = %v", size, err)
}
}
})
}
+5 -7
View File
@@ -5,7 +5,6 @@ import (
"RenewCMS/internal/domain/article" "RenewCMS/internal/domain/article"
domain "RenewCMS/internal/domain/image" domain "RenewCMS/internal/domain/image"
"net/http" "net/http"
"net/url"
"slices" "slices"
"strings" "strings"
"testing" "testing"
@@ -184,16 +183,15 @@ func TestMediaListVariantsFilterAndSort(t *testing.T) {
} }
}) })
t.Run("variants=with and sort=total_size page through the list", func(t *testing.T) { t.Run("variants=with and sort=total_size answer the ordered page with its total", func(t *testing.T) {
env := newVariantEnv(t) env := newVariantEnv(t)
env.seedLibrary() env.seedLibrary()
first, firstBody := env.listIds("?variants=with&sort=total_size&limit=2") ids, body := env.listIds("?variants=with&sort=total_size&size=5&page=1")
cursor, _ := firstBody["next_cursor"].(string) beyond, _ := env.listIds("?variants=with&sort=total_size&size=5&page=2")
second, secondBody := env.listIds("?variants=with&sort=total_size&limit=2&cursor=" + url.QueryEscape(cursor))
if !slices.Equal(first, []float64{1, 4}) || cursor == "" || !slices.Equal(second, []float64{2}) || secondBody["next_cursor"] != nil { if !slices.Equal(ids, []float64{1, 4, 2}) || body["total"] != float64(3) || len(beyond) != 0 {
t.Errorf("first = %v (%q), second = %v (%v)", first, cursor, second, secondBody["next_cursor"]) t.Errorf("ids = %v, total = %v, beyond = %v", ids, body["total"], beyond)
} }
}) })
+5 -2
View File
@@ -19,6 +19,7 @@ import {
sizeRejectionText, sizeRejectionText,
validateImageFile, validateImageFile,
} from '@/lib/imageUpload' } from '@/lib/imageUpload'
import { toHashRequestQuery } from '@/lib/mediaQuery'
import { libraryMatchText } from '@/lib/mediaFormat' import { libraryMatchText } from '@/lib/mediaFormat'
import { insertedUrl } from '@/lib/variants/variantOptions' import { insertedUrl } from '@/lib/variants/variantOptions'
import { UNDERLINE_TAB_TRIGGER } from '@/lib/tabStyles' import { UNDERLINE_TAB_TRIGGER } from '@/lib/tabStyles'
@@ -349,7 +350,9 @@ async function findExisting(file: File): Promise<MediaItem | null | 'handled'> {
return null return null
} }
try { 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 return oldestOf(response.items) ?? null
} catch (error) { } catch (error) {
if (error instanceof ApiError && classifyMediaError(error) === 'handled') { if (error instanceof ApiError && classifyMediaError(error) === 'handled') {
@@ -796,7 +799,7 @@ onUnmounted(() => {
:status="library.status.value" :status="library.status.value"
:show-skeleton="library.showSkeleton.value" :show-skeleton="library.showSkeleton.value"
:loading-more="library.loadingMore.value" :loading-more="library.loadingMore.value"
:has-more="library.nextCursor.value !== null" :has-more="library.hasMore.value"
:total="library.total.value" :total="library.total.value"
:query="library.query.value" :query="library.query.value"
:first-new-id="library.firstNewId.value" :first-new-id="library.firstNewId.value"
@@ -115,11 +115,11 @@ describe('The library has images, so Library opens', () => {
expect(skeletonTiles()).toHaveLength(9) 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() await openLibrary()
expect(listCalls()).toHaveLength(1) 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', () => { 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) }) await openLibrary({ list: () => pageBody(fixture(), null) })
expect(findButton('Load more')).toBeUndefined() expect(findButton('Load more')).toBeUndefined()
@@ -373,7 +373,7 @@ describe('Insert', () => {
}) })
describe('Search', () => { 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() await openWithFakeTimers()
tile('kitchen-after.webp').click() tile('kitchen-after.webp').click()
await vi.advanceTimersByTimeAsync(0) await vi.advanceTimersByTimeAsync(0)
@@ -388,8 +388,8 @@ describe('Search', () => {
expect(listCalls()).toHaveLength(2) expect(listCalls()).toHaveLength(2)
expect(listCalls()[1]!.get('q')).toBe('wall') expect(listCalls()[1]!.get('q')).toBe('wall')
expect(listCalls()[1]!.get('limit')).toBe('24') expect(listCalls()[1]!.get('size')).toBe('25')
expect(listCalls()[1]!.has('cursor')).toBe(false) expect(listCalls()[1]!.get('page')).toBe('1')
expect(findButton('Insert image')?.disabled).toBe(true) expect(findButton('Insert image')?.disabled).toBe(true)
}) })
@@ -419,7 +419,7 @@ describe('Search', () => {
expect(searchField().value).toBe('') expect(searchField().value).toBe('')
const last = listCalls().at(-1)! const last = listCalls().at(-1)!
expect(last.has('q')).toBe(false) 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 () => { 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 () => { it('shows progress, appends the new tiles and moves focus to the first one', async () => {
const more = deferred<unknown>() const more = deferred<unknown>()
await openWithFakeTimers({ await openWithFakeTimers({
list: (query) => (query.get('cursor') ? more.promise : pageBody()), list: (query) => (query.get('page') === '2' ? more.promise : pageBody()),
}) })
const button = findButton('Load more')! const button = findButton('Load more')!
button.focus() button.focus()
@@ -456,8 +456,8 @@ describe('Load more', () => {
await vi.advanceTimersByTimeAsync(0) await vi.advanceTimersByTimeAsync(0)
const last = listCalls().at(-1)! const last = listCalls().at(-1)!
expect(last.get('cursor')).toBe('c24') expect(last.get('page')).toBe('2')
expect(last.get('limit')).toBe('24') expect(last.get('size')).toBe('25')
const loading = findButton('Loading…')! const loading = findButton('Loading…')!
expect(loading.getAttribute('aria-disabled')).toBe('true') expect(loading.getAttribute('aria-disabled')).toBe('true')
expect(document.activeElement).toBe(loading) expect(document.activeElement).toBe(loading)
@@ -475,7 +475,10 @@ describe('Load more', () => {
it('draws a row of 3 skeleton tiles on mobile', async () => { it('draws a row of 3 skeleton tiles on mobile', async () => {
await openWithFakeTimers( await openWithFakeTimers(
{ list: (query) => (query.get('cursor') ? new Promise(() => undefined) : pageBody()) }, {
list: (query) =>
query.get('page') === '2' ? new Promise(() => undefined) : pageBody(),
},
MOBILE_WIDTH, MOBILE_WIDTH,
) )
@@ -485,9 +488,9 @@ describe('Load more', () => {
expect(skeletonTiles()).toHaveLength(3) expect(skeletonTiles()).toHaveLength(3)
}) })
it('combines the cursor with the search', async () => { it('combines the next page with the search', async () => {
await openLibrary({ 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') await searchFor('wall')
@@ -495,8 +498,8 @@ describe('Load more', () => {
const last = listCalls().at(-1)! const last = listCalls().at(-1)!
expect(last.get('q')).toBe('wall') expect(last.get('q')).toBe('wall')
expect(last.get('limit')).toBe('24') expect(last.get('size')).toBe('25')
expect(last.get('cursor')).toBe('c24') expect(last.get('page')).toBe('2')
}) })
}) })
+3 -6
View File
@@ -11,14 +11,11 @@ import { fixture, textOf } from '@/views/__tests__/mediaTestKit'
export { textOf } 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 const PICKER_COUNTS = { all: 48, unused: 2, duplicates: 2 }
export function pageBody( export function pageBody(items: MediaItem[] = fixture(), total: number | null = FIRST_PAGE_TOTAL) {
items: MediaItem[] = fixture(), return { items, total: total ?? items.length, page: 1, size: 25, counts: PICKER_COUNTS }
next_cursor: string | null = FIRST_PAGE_CURSOR,
) {
return { items, next_cursor, counts: PICKER_COUNTS }
} }
export function secondPage() { 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 { return {
items, items,
next_cursor: null, total: paging.total ?? items.length,
page: paging.page ?? 1,
size: 25,
counts: { all: items.length, unused: 1, duplicates: 0 }, counts: { all: items.length, unused: 1, duplicates: 0 },
} }
} }
@@ -57,20 +59,99 @@ afterEach(() => {
}) })
describe('useMedia list state', () => { 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)])) request.mockResolvedValue(listBody([item(1), item(2)]))
const media = useMedia() const media = useMedia()
await media.load({ ...DEFAULT_CONTROLS, q: 'wall', filter: 'unused' }) await media.load({ ...DEFAULT_CONTROLS, q: 'wall', filter: 'unused' })
expect(request).toHaveBeenCalledWith( 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.items.value.map((entry) => entry.id)).toEqual([1, 2])
expect(media.counts.value).toEqual({ all: 2, unused: 1, duplicates: 0 }) expect(media.counts.value).toEqual({ all: 2, unused: 1, duplicates: 0 })
expect(media.status.value).toBe('ready') 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 () => { it('shows the skeleton only after 150 ms of loading', async () => {
const pending = deferred<unknown>() const pending = deferred<unknown>()
request.mockReturnValue(pending.promise) request.mockReturnValue(pending.promise)
@@ -184,7 +265,7 @@ describe('useMedia requests', () => {
const others = await media.findCopies(item(12, { sha256: 'abc123' })) 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]) 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 { tryOnBeforeUnmount } from '@vueuse/core'
import { useApi } from '@/composables/useApi' import { useApi } from '@/composables/useApi'
import { import {
@@ -9,21 +9,22 @@ import {
} from '@/composables/useMedia' } from '@/composables/useMedia'
import { useSeenImagesStore } from '@/stores/seenImages' 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' export type LibraryStatus = 'idle' | 'loading' | 'ready' | 'error' | 'handled'
interface LibraryResponse { interface LibraryResponse {
items: MediaItem[] items: MediaItem[]
next_cursor: string | null total: number
counts: MediaCounts counts: MediaCounts
} }
function requestQuery(query: string, cursor: string | null) { function requestQuery(query: string, page: number) {
return [ return [
...(query ? [`q=${encodeURIComponent(query)}`] : []), ...(query ? [`q=${encodeURIComponent(query)}`] : []),
`limit=${LIBRARY_PAGE_LIMIT}`, `page=${page}`,
...(cursor ? [`cursor=${encodeURIComponent(cursor)}`] : []), `size=${LIBRARY_PAGE_SIZE}`,
].join('&') ].join('&')
} }
@@ -33,7 +34,8 @@ export function useLibraryList() {
const items = ref<MediaItem[]>([]) const items = ref<MediaItem[]>([])
const total = ref(0) const total = ref(0)
const nextCursor = ref<string | null>(null) const matching = ref(0)
const loadedPages = ref(0)
const status = ref<LibraryStatus>('idle') const status = ref<LibraryStatus>('idle')
const query = ref('') const query = ref('')
const showSkeleton = ref(false) const showSkeleton = ref(false)
@@ -41,6 +43,7 @@ export function useLibraryList() {
const firstNewId = ref<number | null>(null) const firstNewId = ref<number | null>(null)
let skeletonTimer: ReturnType<typeof setTimeout> | null = null let skeletonTimer: ReturnType<typeof setTimeout> | null = null
let latest = 0 let latest = 0
const hasMore = computed(() => items.value.length < matching.value)
function clearSkeletonTimer() { function clearSkeletonTimer() {
if (skeletonTimer !== null) { if (skeletonTimer !== null) {
@@ -66,7 +69,8 @@ export function useLibraryList() {
latest += 1 latest += 1
items.value = [] items.value = []
total.value = 0 total.value = 0
nextCursor.value = null matching.value = 0
loadedPages.value = 0
status.value = 'idle' status.value = 'idle'
query.value = '' query.value = ''
loadingMore.value = false loadingMore.value = false
@@ -79,21 +83,23 @@ export function useLibraryList() {
const ticket = latest const ticket = latest
query.value = nextQuery query.value = nextQuery
items.value = [] items.value = []
nextCursor.value = null matching.value = 0
loadedPages.value = 0
loadingMore.value = false loadingMore.value = false
firstNewId.value = null firstNewId.value = null
status.value = 'loading' status.value = 'loading'
startSkeletonTimer() startSkeletonTimer()
try { try {
const response = await request<LibraryResponse>( const response = await request<LibraryResponse>(
`/v1/media/?${requestQuery(nextQuery, null)}`, `/v1/media/?${requestQuery(nextQuery, FIRST_PAGE)}`,
) )
if (ticket !== latest) { if (ticket !== latest) {
return return
} }
items.value = response.items items.value = response.items
total.value = response.counts.all total.value = response.counts.all
nextCursor.value = response.next_cursor matching.value = response.total
loadedPages.value = FIRST_PAGE
seen.remember(response.items) seen.remember(response.items)
status.value = 'ready' status.value = 'ready'
} catch (error) { } catch (error) {
@@ -109,7 +115,7 @@ export function useLibraryList() {
} }
async function loadMore() { async function loadMore() {
if (nextCursor.value === null || loadingMore.value || status.value !== 'ready') { if (!hasMore.value || loadingMore.value || status.value !== 'ready') {
return return
} }
const ticket = latest const ticket = latest
@@ -117,7 +123,7 @@ export function useLibraryList() {
startSkeletonTimer() startSkeletonTimer()
try { try {
const response = await request<LibraryResponse>( const response = await request<LibraryResponse>(
`/v1/media/?${requestQuery(query.value, nextCursor.value)}`, `/v1/media/?${requestQuery(query.value, loadedPages.value + 1)}`,
) )
if (ticket !== latest) { if (ticket !== latest) {
return return
@@ -125,7 +131,8 @@ export function useLibraryList() {
const known = new Set(items.value.map((item) => item.id)) const known = new Set(items.value.map((item) => item.id))
const added = response.items.filter((item) => !known.has(item.id)) const added = response.items.filter((item) => !known.has(item.id))
items.value = [...items.value, ...added] items.value = [...items.value, ...added]
nextCursor.value = response.next_cursor matching.value = response.total
loadedPages.value += 1
firstNewId.value = added[0]?.id ?? null firstNewId.value = added[0]?.id ?? null
seen.remember(added) seen.remember(added)
} catch { } catch {
@@ -156,7 +163,7 @@ export function useLibraryList() {
return { return {
items, items,
total, total,
nextCursor, hasMore,
status, status,
query, query,
showSkeleton, showSkeleton,
+79 -12
View File
@@ -1,7 +1,14 @@
import { ref } from 'vue' import { ref } from 'vue'
import { tryOnBeforeUnmount } from '@vueuse/core' import { tryOnBeforeUnmount } from '@vueuse/core'
import { ApiError, useApi } from '@/composables/useApi' 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' import { bumpMediaRevision } from '@/lib/mediaRevision'
export interface MediaItem { export interface MediaItem {
@@ -44,12 +51,24 @@ export interface BulkDeleteResult {
handled: boolean handled: boolean
} }
interface MediaListResponse { export interface MediaListResponse {
items: MediaItem[] items: MediaItem[]
next_cursor: string | null total: number
page: number
size: number
counts: MediaCounts 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 MediaLoadStatus = 'loading' | 'ready' | 'error' | 'handled'
export type MediaFailure = 'handled' | 'not-found' | 'failed' export type MediaFailure = 'handled' | 'not-found' | 'failed'
@@ -68,10 +87,19 @@ export function classifyMediaError(error: unknown): MediaFailure {
return 'failed' 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() { export function useMedia() {
const { request } = useApi() const { request } = useApi()
const items = ref<MediaItem[]>([]) const items = ref<MediaItem[]>([])
const total = ref(0)
const counts = ref<MediaCounts>({ all: 0, unused: 0, duplicates: 0 }) const counts = ref<MediaCounts>({ all: 0, unused: 0, duplicates: 0 })
const status = ref<MediaLoadStatus>('loading') const status = ref<MediaLoadStatus>('loading')
const showSkeleton = ref(false) const showSkeleton = ref(false)
@@ -94,7 +122,39 @@ export function useMedia() {
}, MEDIA_SKELETON_DELAY_MS) }, 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 const silent = options.silent === true
if (!silent) { if (!silent) {
latestLoad += 1 latestLoad += 1
@@ -102,20 +162,24 @@ export function useMedia() {
} }
const ticket = latestLoad const ticket = latestLoad
try { try {
const response = await request<MediaListResponse>( const { response, page, redirected } = await requestList(controls)
`/v1/media/?${toRequestQuery(controls)}`,
)
if (ticket !== latestLoad) { if (ticket !== latestLoad) {
return return { kind: 'stale' }
} }
items.value = response.items items.value = response.items
total.value = response.total
counts.value = response.counts counts.value = response.counts
status.value = 'ready' status.value = 'ready'
return { kind: 'loaded', page, redirected }
} catch (error) { } catch (error) {
if (silent || ticket !== latestLoad) { if (ticket !== latestLoad) {
return 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 { } finally {
if (!silent && ticket === latestLoad) { if (!silent && ticket === latestLoad) {
clearSkeletonTimer() clearSkeletonTimer()
@@ -129,7 +193,9 @@ export function useMedia() {
} }
async function findCopies(item: MediaItem) { 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) return response.items.filter((copy) => copy.id !== item.id)
} }
@@ -204,6 +270,7 @@ export function useMedia() {
return { return {
items, items,
total,
counts, counts,
status, status,
showSkeleton, showSkeleton,
+4 -1
View File
@@ -2,6 +2,7 @@ import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { redirectToLogin, redirectUnverified, useApi } from '@/composables/useApi' import { redirectToLogin, redirectUnverified, useApi } from '@/composables/useApi'
import { classifyMediaError, type MediaItem } from '@/composables/useMedia' import { classifyMediaError, type MediaItem } from '@/composables/useMedia'
import { useRetryAfter } from '@/composables/useRetryAfter' import { useRetryAfter } from '@/composables/useRetryAfter'
import { toHashRequestQuery } from '@/lib/mediaQuery'
import { hashFile } from '@/lib/fileHash' import { hashFile } from '@/lib/fileHash'
import { extensionMatchesType, validateImageFile } from '@/lib/imageUpload' import { extensionMatchesType, validateImageFile } from '@/lib/imageUpload'
import { describeUpload, parseRetryAfter } from '@/lib/mediaUploadRows' import { describeUpload, parseRetryAfter } from '@/lib/mediaUploadRows'
@@ -135,7 +136,9 @@ export function useMediaUpload(options: UploadOptions = {}) {
return null return null
} }
try { 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 return oldestOf(response.items) ?? null
} catch (error) { } catch (error) {
return classifyMediaError(error) === 'handled' ? 'halted' : null 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) 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/')) 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']) expect(keysOf(list.counts)).toEqual(['all', 'duplicates', 'unused'])
expectOnlyKeys(list.items[0], MEDIA_ITEM_KEYS) 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) 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([ expect(find('GET /v1/media/').query.map((param) => param.name)).toEqual([
'q', 'q',
'hash', 'hash',
'cursor',
'filter', 'filter',
'sort', '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', 'number',
) )
expect(find('GET /v1/media/').errors.map((error) => error.status)).toContain(400)
expect(find('POST /v1/media/').query).toEqual([]) expect(find('POST /v1/media/').query).toEqual([])
expect(find('GET /v1/settings').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.', 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[] = [ const ARTICLE_LIST_QUERY: ApiField[] = [
FORMAT_QUERY, FORMAT_QUERY,
{ name: 'page', type: 'number', required: false, rule: 'A whole number from 1. Default 1.' }, PAGE_QUERY,
{ name: 'size', type: 'number', required: false, rule: '5, 25, 50 or 100. Default 25.' }, SIZE_QUERY,
{ {
name: 'q', name: 'q',
type: 'string', type: 'string',
@@ -122,12 +136,6 @@ const MEDIA_QUERY: ApiField[] = [
required: false, required: false,
rule: 'SHA-256 of the content, lowercased.', rule: 'SHA-256 of the content, lowercased.',
}, },
{
name: 'cursor',
type: 'string',
required: false,
rule: 'The next_cursor of the previous page.',
},
{ {
name: 'filter', name: 'filter',
type: 'string', type: 'string',
@@ -138,9 +146,16 @@ const MEDIA_QUERY: ApiField[] = [
name: 'sort', name: 'sort',
type: 'string', type: 'string',
required: false, 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) 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)]), verified([e(400, 'The media query is invalid.'), e(401, UNAUTHORIZED)]),
json({ json({
items: [mediaItem()], items: [mediaItem()],
next_cursor: null, total: 1,
page: 1,
size: 25,
counts: { all: 1, unused: 0, duplicates: 0 }, counts: { all: 1, unused: 0, duplicates: 0 },
}), }),
{ query: MEDIA_QUERY }, { 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.', '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é.', 'Search text.': 'Texte recherché.',
'SHA-256 of the content, lowercased.': 'SHA-256 du contenu, en minuscules.', '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.', 'all, unused or duplicates. Default all.': 'all, unused ou duplicates. Par défaut all.',
'newest, oldest, name or largest. Default newest.': 'newest, oldest, name, largest or total_size. Default newest.':
'newest, oldest, name ou largest. Par défaut newest.', 'newest, oldest, name, largest ou total_size. Par défaut newest.',
'1 to 100. Default 24.': 'De 1 à 100. Par défaut 24.', '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.": "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.', '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.": "3 to 200 characters. Can't be changed later.":
+13 -3
View File
@@ -136,7 +136,9 @@ describe('Switch language without losing work', () => {
} }
return { return {
items, items,
next_cursor: null, total: items.length,
page: 1,
size: 25,
counts: { all: items.length, unused: items.length, duplicates: 0 }, counts: { all: items.length, unused: items.length, duplicates: 0 },
} }
}) })
@@ -518,13 +520,21 @@ describe('Hardener localization outcomes', () => {
duplicate: false, duplicate: false,
}, },
], ],
next_cursor: null, total: 1,
page: 1,
size: 25,
counts: { all: 1, unused: 1, duplicates: 0 }, counts: { all: 1, unused: 1, duplicates: 0 },
}) })
const listing = await request() const listing = await request()
request.mockImplementation(async (path: string) => request.mockImplementation(async (path: string) =>
path.includes('?hash=') 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, : listing,
) )
await mountRoute('/media') await mountRoute('/media')
+76 -9
View File
@@ -1,5 +1,19 @@
import { describe, expect, it } from 'vitest' import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { DEFAULT_CONTROLS, parseMediaControls, toRequestQuery, toRouteQuery } from '../mediaQuery' import {
DEFAULT_CONTROLS,
parseMediaControls,
toHashRequestQuery,
toRequestQuery,
toRouteQuery,
} from '../mediaQuery'
beforeEach(() => {
localStorage.clear()
})
afterEach(() => {
localStorage.clear()
})
describe('parseMediaControls', () => { describe('parseMediaControls', () => {
it('uses the defaults for an empty query', () => { it('uses the defaults for an empty query', () => {
@@ -21,6 +35,34 @@ describe('parseMediaControls', () => {
expect(parseMediaControls(query)).toEqual({ ...DEFAULT_CONTROLS, ...expected }) 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', () => { it('takes the first value of a repeated parameter and trims the search', () => {
expect(parseMediaControls({ q: [' wall ', 'x'] }).q).toBe('wall') expect(parseMediaControls({ q: [' wall ', 'x'] }).q).toBe('wall')
}) })
@@ -33,20 +75,45 @@ describe('toRouteQuery', () => {
it('keeps only the non-default values', () => { it('keeps only the non-default values', () => {
expect( 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' }) ).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', () => { describe('toRequestQuery', () => {
it.each([ it.each([
[{}, 'filter=all&sort=newest&limit=100'], [{}, 'filter=all&sort=newest&page=1&size=25'],
[{ filter: 'unused' }, 'filter=unused&sort=newest&limit=100'], [{ filter: 'unused' }, 'filter=unused&sort=newest&page=1&size=25'],
[{ filter: 'duplicates' }, 'filter=duplicates&sort=newest&limit=100'], [{ filter: 'duplicates' }, 'filter=duplicates&sort=newest&page=1&size=25'],
[{ sort: 'largest' }, 'filter=all&sort=largest&limit=100'], [{ sort: 'largest' }, 'filter=all&sort=largest&page=1&size=25'],
[{ q: 'wall' }, 'q=wall&filter=all&sort=newest&limit=100'], [{ 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&limit=100'], [{ 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) => { ])('builds the request for %j', (overrides, expected) => {
expect(toRequestQuery({ ...DEFAULT_CONTROLS, ...overrides })).toBe(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 type { LocationQuery } from 'vue-router'
import { MEDIA_PAGE_SIZE_KEY, resolvePageSize } from '@/lib/pageSizeMemory'
export type MediaFilter = 'all' | 'unused' | 'duplicates' export type MediaFilter = 'all' | 'unused' | 'duplicates'
export type MediaSort = 'newest' | 'oldest' | 'name' | 'largest' | 'total_size' export type MediaSort = 'newest' | 'oldest' | 'name' | 'largest' | 'total_size'
@@ -11,9 +12,15 @@ export interface MediaControls {
sort: MediaSort sort: MediaSort
variants: MediaVariantsFilter variants: MediaVariantsFilter
view: MediaView 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 = { export const DEFAULT_CONTROLS: MediaControls = {
q: '', q: '',
@@ -21,6 +28,8 @@ export const DEFAULT_CONTROLS: MediaControls = {
sort: 'newest', sort: 'newest',
variants: 'all', variants: 'all',
view: 'grid', view: 'grid',
page: FIRST_PAGE,
size: DEFAULT_MEDIA_PAGE_SIZE,
} }
const FILTERS: MediaFilter[] = ['all', 'unused', 'duplicates'] 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 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 { return {
q: firstValue(query.q).trim(), q: firstValue(query.q).trim(),
filter: pick(FILTERS, firstValue(query.filter), DEFAULT_CONTROLS.filter), filter: pick(FILTERS, firstValue(query.filter), DEFAULT_CONTROLS.filter),
sort: pick(SORTS, firstValue(query.sort), DEFAULT_CONTROLS.sort), sort: pick(SORTS, firstValue(query.sort), DEFAULT_CONTROLS.sort),
variants: pick(VARIANTS_FILTERS, firstValue(query.variants), DEFAULT_CONTROLS.variants), variants: pick(VARIANTS_FILTERS, firstValue(query.variants), DEFAULT_CONTROLS.variants),
view: pick(VIEWS, firstValue(query.view), DEFAULT_CONTROLS.view), 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) { export function toRouteQuery(controls: MediaControls) {
const query: Record<string, string> = {} 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) { if (controls.q) {
query.q = controls.q query.q = controls.q
} }
@@ -72,7 +120,12 @@ export function toRequestQuery(controls: MediaControls) {
`filter=${controls.filter}`, `filter=${controls.filter}`,
`sort=${controls.sort}`, `sort=${controls.sort}`,
...(controls.variants === 'all' ? [] : [`variants=${controls.variants}`]), ...(controls.variants === 'all' ? [] : [`variants=${controls.variants}`]),
`limit=${MEDIA_PAGE_LIMIT}`, `page=${controls.page}`,
`size=${controls.size}`,
] ]
return parts.join('&') 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 ARTICLES_PAGE_SIZE_KEY = 'renewcms-page-size-articles'
export const ACTIVITY_PAGE_SIZE_KEY = 'renewcms-page-size-activity' 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[]) { export function readStoredPageSize(key: string, validSizes: number[]) {
try { 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 getMe = vi.fn().mockResolvedValue({ verified: true })
const request = vi.fn().mockResolvedValue({ const request = vi.fn().mockResolvedValue({
items: [], items: [],
next_cursor: null, total: 0,
page: 1,
size: 25,
counts: { all: 0, unused: 0, duplicates: 0 }, counts: { all: 0, unused: 0, duplicates: 0 },
}) })
vi.mocked(useApi).mockReturnValue({ getMe, request }) 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('nav[aria-label="breadcrumb"]').text()).toBe('Media')
expect(app.find('a[href="/media"][aria-current="page"]').exists()).toBe(true) 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 () => { 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 { t } from '@/i18n'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue' 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 { useEventListener, useMediaQuery, useOnline } from '@vueuse/core'
import { useLocalizedToast } from '@/i18n/presentation' import { useLocalizedToast } from '@/i18n/presentation'
import AppPaginator from '@/components/AppPaginator.vue'
import BulkDeleteMediaDialog from '@/components/BulkDeleteMediaDialog.vue' import BulkDeleteMediaDialog from '@/components/BulkDeleteMediaDialog.vue'
import DashboardShell from '@/components/DashboardShell.vue' import DashboardShell from '@/components/DashboardShell.vue'
import DeleteMediaDialog from '@/components/DeleteMediaDialog.vue' import DeleteMediaDialog from '@/components/DeleteMediaDialog.vue'
@@ -38,16 +39,21 @@ import { toastsAboveBulkBar } from '@/composables/useToastOffset'
import { groupDuplicates, type DuplicateGroup } from '@/lib/mediaDuplicates' import { groupDuplicates, type DuplicateGroup } from '@/lib/mediaDuplicates'
import { joinNames } from '@/lib/mediaFormat' import { joinNames } from '@/lib/mediaFormat'
import { import {
isFullSet,
parseMediaControls, parseMediaControls,
toRequestQuery, sameMediaControls,
toRouteQuery, toRouteQuery,
type MediaControls, type MediaControls,
} from '@/lib/mediaQuery' } 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 { purgedToast } from '@/lib/mediaPurge'
import { RATE_LIMITED_TEXT, RATE_LIMITED_TITLE, UPLOAD_ACCEPT } from '@/lib/mediaUploadRows' import { RATE_LIMITED_TEXT, RATE_LIMITED_TITLE, UPLOAD_ACCEPT } from '@/lib/mediaUploadRows'
import { useSessionStore } from '@/stores/session' import { useSessionStore } from '@/stores/session'
const DESKTOP_QUERY = '(min-width: 48rem)' const DESKTOP_QUERY = '(min-width: 48rem)'
const MEDIA_PATH = '/media'
const TOAST_DURATION_MS = 5000 const TOAST_DURATION_MS = 5000
const PERSISTENT_TOAST = 0 const PERSISTENT_TOAST = 0
const BULK_FAILED_TOAST_ID = 'media-bulk-delete-failed' const BULK_FAILED_TOAST_ID = 'media-bulk-delete-failed'
@@ -72,6 +78,9 @@ const MERGE_CONFLICT_COPY =
const OFFLINE_COPY = const OFFLINE_COPY =
"You're offline. You can look through the images that are loaded, but uploading, renaming and deleting need a connection." "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 route = useRoute()
const router = useRouter() const router = useRouter()
const toast = useLocalizedToast() const toast = useLocalizedToast()
@@ -82,11 +91,15 @@ const upload = useMediaUpload({ onAdded: refreshInBackground })
const isDesktop = useMediaQuery(DESKTOP_QUERY) const isDesktop = useMediaQuery(DESKTOP_QUERY)
const online = useOnline() const online = useOnline()
const controls = computed(() => parseMediaControls(route.query)) const shown = ref<MediaControls>(parseMediaControls(route.query))
const requestQuery = computed(() => toRequestQuery(controls.value)) const desired = ref<MediaControls>(shown.value)
const controls = computed(() => shown.value)
const offline = computed(() => !online.value) const offline = computed(() => !online.value)
const hasLoaded = ref(false) 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 heading = ref<HTMLElement | null>(null)
const fileInput = ref<HTMLInputElement | null>(null) const fileInput = ref<HTMLInputElement | null>(null)
const activeId = ref<number | null>(null) const activeId = ref<number | null>(null)
@@ -110,6 +123,8 @@ let bulkDeletedTotal = 0
let bulkFailureRevision = 0 let bulkFailureRevision = 0
let bulkFailureToastId = BULK_FAILED_TOAST_ID let bulkFailureToastId = BULK_FAILED_TOAST_ID
let failureToastShown = false let failureToastShown = false
let requestToken = 0
let syncedTarget: Record<string, string> | null = null
const activeItem = computed( const activeItem = computed(
() => media.items.value.find((item) => item.id === activeId.value) ?? null, () => 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 counts = computed(() => (hasLoaded.value ? media.counts.value : null))
const loadFailed = computed(() => media.status.value === 'error') const loadFailed = computed(() => media.status.value === 'error')
const loading = computed(() => media.status.value === 'loading') 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 isDuplicates = computed(() => controls.value.filter === 'duplicates')
const groups = computed(() => (isDuplicates.value ? groupDuplicates(media.items.value) : [])) const groups = computed(() => (isDuplicates.value ? groupDuplicates(media.items.value) : []))
const emptyLibrary = computed( const emptyLibrary = computed(
@@ -133,8 +150,24 @@ const nothingToShow = computed(
() => () =>
ready.value && ready.value &&
!emptyLibrary.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(() => { const emptyVariant = computed(() => {
if (isDuplicates.value && controls.value.q === '') { if (isDuplicates.value && controls.value.q === '') {
return 'no-duplicates' return 'no-duplicates'
@@ -191,21 +224,78 @@ watch(showBulkBar, (visible) => (toastsAboveBulkBar.value = visible), {
}) })
onBeforeUnmount(() => (toastsAboveBulkBar.value = false)) onBeforeUnmount(() => (toastsAboveBulkBar.value = false))
function canonicalizeUrl() { function sameOrderedQuery(current: LocationQuery, target: Record<string, string>) {
const canonical = toRouteQuery(controls.value) const flatten = (query: LocationQuery) =>
const current = Object.fromEntries( Object.entries(query).map(([key, value]) => [key, Array.isArray(value) ? value[0] : value])
Object.entries(route.query).map(([key, value]) => [ return JSON.stringify(flatten(current)) === JSON.stringify(Object.entries(target))
key, }
Array.isArray(value) ? (value[0] ?? '') : (value ?? ''),
]), 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)) { if (changed) {
router.replace({ query: canonical }) load({ ...desired.value, page: 1, ...patch }, mode)
} }
} }
function updateControls(patch: Partial<MediaControls>) { function changePage(page: number) {
router.replace({ query: toRouteQuery({ ...controls.value, ...patch }) }) 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() { function clearFilters() {
@@ -216,8 +306,6 @@ function announce(source: () => string) {
announcementSource.value = source announcementSource.value = source
} }
canonicalizeUrl()
watch(activeItem, (item) => { watch(activeItem, (item) => {
if (item) { if (item) {
closingItem.value = item closingItem.value = item
@@ -225,16 +313,27 @@ watch(activeItem, (item) => {
}) })
watch( watch(
requestQuery, () => route.query,
async () => { () => {
selection.clear() if (route.path !== MEDIA_PATH) {
await media.load(controls.value) return
hasLoaded.value = true }
selectPending() 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( watch(
() => upload.header.value, () => upload.header.value,
(next, previous) => { (next, previous) => {
@@ -249,8 +348,18 @@ watch(
}, },
) )
function refreshInBackground() { async function refreshInBackground() {
media.load(controls.value, { silent: true }) 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) { function focusCard(id: number) {
@@ -910,7 +1019,7 @@ async function mergeAction() {
icon="i-lucide-rotate-cw" 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" 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' }" :ui="{ leadingIcon: 'size-3.5' }"
@click="media.load(controls)" @click="load(desired, 'replace')"
> >
{{ t('Try again') }} {{ t('Try again') }}
</UButton> </UButton>
@@ -918,7 +1027,7 @@ async function mergeAction() {
</UAlert> </UAlert>
<MediaToolbar <MediaToolbar
:controls="controls" :controls="desired"
:counts="counts" :counts="counts"
:show-purge="showPurge" :show-purge="showPurge"
:purge-disabled="offline" :purge-disabled="offline"
@@ -998,6 +1107,47 @@ async function mergeAction() {
/> />
</template> </template>
</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> </template>
</div> </div>
@@ -187,12 +187,18 @@ afterEach(async () => {
await router.push('/login') 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>> = {}) { function articleHandlers(extra: Record<string, () => Response | Promise<Response>> = {}) {
return { return {
'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()), '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, ...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 () => { it('looks the hash up, skips the list, and uploads to /v1/media/ from the crop step', async () => {
const wrapper = await mountEditor( const wrapper = await mountEditor(
articleHandlers({ 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(document.body.textContent).toContain('Crop and resize')
expect(vi.mocked(global.fetch).mock.calls.map(([url]) => url)).not.toContain( 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() findButton('Upload image')?.click()
@@ -38,7 +38,7 @@ function libraryPage() {
const items = fixture().map((item) => const items = fixture().map((item) =>
item.id === 2 ? { ...item, url: '/static/uploadedImages/2.webp' } : 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> 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') { if (url === '/v1/article/12?format=html') {
return mockResponse(200, articleFixture(body)) return mockResponse(200, articleFixture(body))
} }
if (url === '/v1/media/?limit=24') { if (url === '/v1/media/?page=1&size=25') {
return mockResponse(200, libraryPage()) return mockResponse(200, libraryPage())
} }
throw new Error(`Unexpected fetch call: ${url}`) throw new Error(`Unexpected fetch call: ${url}`)
@@ -79,7 +79,13 @@ function libraryPage() {
4: '/static/uploadedImages/4.svg', 4: '/static/uploadedImages/4.svg',
} }
const items = fixture().map((item) => ({ ...item, url: urls[item.id] ?? item.url })) 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> let toastAdd: ReturnType<typeof vi.fn>
@@ -113,7 +119,7 @@ async function mountEditor(options: EditorOptions = {}) {
if (url === '/v1/article/12?format=html') { if (url === '/v1/article/12?format=html') {
return reply(200, article(options.body ?? '<p>Intro</p>')) 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()) return reply(200, libraryPage())
} }
if (url === '/v1/image-variants') { if (url === '/v1/image-variants') {
@@ -234,14 +234,15 @@ describe('Group scrolled, one card expanded', () => {
expect(published.text()).toContain('No request body.') 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 wrapper = await mountIntegration()
const list = await expand(wrapper, 'GET /v1/media/') 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(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 () => { 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) afterEach(unmountPage)
describe('The Duplicates tab', () => { 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() }) await mountPage({ list: duplicatesList() })
expect(document.body.querySelector('[aria-label="Sort"]')).not.toBeNull() expect(document.body.querySelector('[aria-label="Sort"]')).not.toBeNull()
@@ -90,7 +90,7 @@ describe('The Duplicates tab', () => {
await settle() await settle()
expect(router.currentRoute.value.query).toEqual({ filter: 'duplicates' }) 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(tab('Duplicates').getAttribute('aria-selected')).toBe('true')
expect(tabs().map(textOf)).toEqual(['All 12', 'Unused 2', 'Duplicates 2']) expect(tabs().map(textOf)).toEqual(['All 12', 'Unused 2', 'Duplicates 2'])
expect(document.body.querySelector('[data-testid="duplicates-count"]')?.textContent).toBe( expect(document.body.querySelector('[data-testid="duplicates-count"]')?.textContent).toBe(
@@ -123,7 +123,9 @@ describe('The Duplicates tab', () => {
vi.advanceTimersByTime(120) vi.advanceTimersByTime(120)
await flushPromises() 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() 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 mountPage()
await select('north-wall.jpg', 'kitchen-after.webp') await select('north-wall.jpg', 'kitchen-after.webp')
await router.replace({ query: { view: 'list' } }) await router.replace({ query: { view: 'list' } })
await settle() 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() 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() expect(bulkBar()).toBeNull()
}) })
+4 -2
View File
@@ -63,7 +63,9 @@ function hashLookups() {
function hashHandlerFor(name: string, existing = [5, 6, 12]): Handlers['hash'] { function hashHandlerFor(name: string, existing = [5, 6, 12]): Handlers['hash'] {
return (path) => 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) { function headerButton(label: string) {
@@ -424,7 +426,7 @@ describe('A row whose content already exists', () => {
await mountWithDuplicate() await mountWithDuplicate()
expect(hashLookups().map(([path]) => path)).toEqual([ 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) expect(FakeXhr.instances).toHaveLength(0)
const row = uploadRows()[0]! const row = uploadRows()[0]!
@@ -165,8 +165,8 @@ describe('The variants filter', () => {
}) })
it.each([ it.each([
['With variants', '?variants=with', 'filter=all&sort=newest&variants=with&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&limit=100'], ['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) => { ])('Choosing %s writes the URL and requests the list', async (label, search, request) => {
await mountPage() await mountPage()
@@ -202,7 +202,7 @@ describe('The variants filter', () => {
await flushPromises() await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/media') 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([ it.each([
@@ -223,7 +223,7 @@ describe('The variants filter', () => {
])('The query string is the source of truth: %s', async (search, request, selected, url) => { ])('The query string is the source of truth: %s', async (search, request, selected, url) => {
await mountPage({}, `/media${search}`) 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(segment(selected).getAttribute('aria-pressed')).toBe('true')
expect(router.currentRoute.value.fullPath).toBe(url) expect(router.currentRoute.value.fullPath).toBe(url)
}) })
@@ -294,7 +294,7 @@ describe('Sorting by total size', () => {
await flushPromises() await flushPromises()
expect(router.currentRoute.value.query).toEqual({ sort: 'total_size' }) 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( expect(textOf(document.body.querySelector('[aria-label="Sort"]'))).toBe(
'Largest total size (original + variants)', 'Largest total size (original + variants)',
) )
@@ -321,7 +321,7 @@ describe('Sorting by total size', () => {
await mountPage({}, '/media?sort=total') await mountPage({}, '/media?sort=total')
expect(router.currentRoute.value.fullPath).toBe('/media') 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 }) { 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) { function listWithout(id: number) {
@@ -440,7 +440,7 @@ describe('Default, many images', () => {
) )
expect(document.body.querySelector('h1')?.textContent?.trim()).toBe('Media') expect(document.body.querySelector('h1')?.textContent?.trim()).toBe('Media')
expect(listCalls().map(([path]) => path)).toEqual([ 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(tabs().map(textOf)).toEqual(['All 12', 'Unused 2', 'Duplicates 2'])
expect(tab('All').getAttribute('aria-selected')).toBe('true') expect(tab('All').getAttribute('aria-selected')).toBe('true')
@@ -565,7 +565,8 @@ describe('List view', () => {
expect(router.currentRoute.value.query).toEqual({ view: 'list' }) expect(router.currentRoute.value.query).toEqual({ view: 'list' })
expect(document.body.querySelectorAll('[data-testid="media-row"]')).toHaveLength(12) 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')) activate(ariaLabel('Grid view'))
await flushPromises() await flushPromises()
@@ -603,14 +604,14 @@ describe('List view', () => {
describe('The query string is the source of truth', () => { describe('The query string is the source of truth', () => {
it.each([ it.each([
['?filter=unused', 'filter=unused&sort=newest&limit=100'], ['?filter=unused', 'filter=unused&sort=newest&page=1&size=25'],
['?sort=largest', 'filter=all&sort=largest&limit=100'], ['?sort=largest', 'filter=all&sort=largest&page=1&size=25'],
['?sort=name', 'filter=all&sort=name&limit=100'], ['?sort=name', 'filter=all&sort=name&page=1&size=25'],
['?sort=oldest', 'filter=all&sort=oldest&limit=100'], ['?sort=oldest', 'filter=all&sort=oldest&page=1&size=25'],
['?q=wall', 'q=wall&filter=all&sort=newest&limit=100'], ['?q=wall', 'q=wall&filter=all&sort=newest&page=1&size=25'],
['?view=list', 'filter=all&sort=newest&limit=100'], ['?view=list', 'filter=all&sort=newest&page=1&size=25'],
['?filter=bogus&sort=zzz', 'filter=all&sort=newest&limit=100'], ['?filter=bogus&sort=zzz', 'filter=all&sort=newest&page=1&size=25'],
['?filter=duplicates', 'filter=duplicates&sort=newest&limit=100'], ['?filter=duplicates', 'filter=duplicates&sort=newest&page=1&size=100'],
])('opening /media%s requests %s', async (url, request) => { ])('opening /media%s requests %s', async (url, request) => {
await mountPage({}, `/media${url}`) await mountPage({}, `/media${url}`)
@@ -657,7 +658,7 @@ describe('The query string is the source of truth', () => {
vi.advanceTimersByTime(1) vi.advanceTimersByTime(1)
await flushPromises() await flushPromises()
expect(router.currentRoute.value.query).toEqual({ q: 'wall' }) 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() tab('Unused').focus()
press(tab('Unused'), 'Enter') press(tab('Unused'), 'Enter')
@@ -712,7 +713,7 @@ describe('The Duplicates tab', () => {
expect(tab('Duplicates').getAttribute('aria-selected')).toBe('true') expect(tab('Duplicates').getAttribute('aria-selected')).toBe('true')
expect(router.currentRoute.value.query).toEqual({ filter: 'duplicates' }) 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) vi.advanceTimersByTime(120)
await flushPromises() 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('No images match "wall"')
expect(document.body.textContent).toContain( expect(document.body.textContent).toContain(
'Nothing matches in Unused. Check the spelling, or clear the filters.', 'Nothing matches in Unused. Check the spelling, or clear the filters.',
@@ -998,7 +999,9 @@ describe('Detail panel', () => {
await clickCard('floorboards.jpg') 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"]')! const alert = panel()!.querySelector('[data-testid="duplicate-alert"]')!
expect(textOf(alert)).toContain('Identical to 2 other images') expect(textOf(alert)).toContain('Identical to 2 other images')
expect(textOf(alert)).toContain( 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) { export function listBody(
return { items, next_cursor: null, counts } 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>() { export function deferred<T>() {
+233 -2
View File
@@ -1254,11 +1254,15 @@ interface MediaSample {
createdAt: string createdAt: string
duplicate: boolean duplicate: boolean
tone: number tone: number
variantCount: number
variantSize: number
} }
const mediaSamples: MediaSample[] = [ const mediaSamples: MediaSample[] = [
{ {
id: 1, id: 1,
variantCount: 4,
variantSize: 266240,
filename: 'north-wall.jpg', filename: 'north-wall.jpg',
size: 4089446, size: 4089446,
width: 4032, width: 4032,
@@ -1272,6 +1276,8 @@ const mediaSamples: MediaSample[] = [
}, },
{ {
id: 2, id: 2,
variantCount: 5,
variantSize: 419840,
filename: 'kitchen-after.webp', filename: 'kitchen-after.webp',
size: 1258291, size: 1258291,
width: 2400, width: 2400,
@@ -1285,6 +1291,8 @@ const mediaSamples: MediaSample[] = [
}, },
{ {
id: 3, id: 3,
variantCount: 5,
variantSize: 404480,
filename: 'kitchen-before.webp', filename: 'kitchen-before.webp',
size: 1153434, size: 1153434,
width: 2400, width: 2400,
@@ -1298,6 +1306,8 @@ const mediaSamples: MediaSample[] = [
}, },
{ {
id: 4, id: 4,
variantCount: 0,
variantSize: 0,
filename: 'logo-renew.svg', filename: 'logo-renew.svg',
size: 8192, size: 8192,
width: null, width: null,
@@ -1311,6 +1321,8 @@ const mediaSamples: MediaSample[] = [
}, },
{ {
id: 5, id: 5,
variantCount: 3,
variantSize: 245760,
filename: 'floorboards-copy.jpg', filename: 'floorboards-copy.jpg',
size: 2516582, size: 2516582,
width: 3600, width: 3600,
@@ -1324,6 +1336,8 @@ const mediaSamples: MediaSample[] = [
}, },
{ {
id: 6, id: 6,
variantCount: 0,
variantSize: 0,
filename: 'IMG_2208.jpg', filename: 'IMG_2208.jpg',
size: 2516582, size: 2516582,
width: 3600, width: 3600,
@@ -1337,6 +1351,8 @@ const mediaSamples: MediaSample[] = [
}, },
{ {
id: 7, id: 7,
variantCount: 2,
variantSize: 153600,
filename: 'IMG_4471.jpg', filename: 'IMG_4471.jpg',
size: 3250585, size: 3250585,
width: 3600, width: 3600,
@@ -1350,6 +1366,8 @@ const mediaSamples: MediaSample[] = [
}, },
{ {
id: 8, id: 8,
variantCount: 0,
variantSize: 0,
filename: 'window-frame.jpg', filename: 'window-frame.jpg',
size: 1887437, size: 1887437,
width: 3000, width: 3000,
@@ -1363,6 +1381,8 @@ const mediaSamples: MediaSample[] = [
}, },
{ {
id: 9, id: 9,
variantCount: 1,
variantSize: 61440,
filename: 'stone-wall-detail.jpg', filename: 'stone-wall-detail.jpg',
size: 3250585, size: 3250585,
width: 3600, width: 3600,
@@ -1376,6 +1396,8 @@ const mediaSamples: MediaSample[] = [
}, },
{ {
id: 10, id: 10,
variantCount: 3,
variantSize: 194560,
filename: 'bathroom-tiles.png', filename: 'bathroom-tiles.png',
size: 880640, size: 880640,
width: 1800, width: 1800,
@@ -1389,6 +1411,8 @@ const mediaSamples: MediaSample[] = [
}, },
{ {
id: 11, id: 11,
variantCount: 4,
variantSize: 286720,
filename: 'lime-plaster.jpg', filename: 'lime-plaster.jpg',
size: 2202009, size: 2202009,
width: 3200, width: 3200,
@@ -1402,6 +1426,8 @@ const mediaSamples: MediaSample[] = [
}, },
{ {
id: 12, id: 12,
variantCount: 5,
variantSize: 440320,
filename: 'floorboards.jpg', filename: 'floorboards.jpg',
size: 2516582, size: 2516582,
width: 3600, width: 3600,
@@ -1454,6 +1480,8 @@ function mediaItem(sample: MediaSample, filename = sample.filename) {
created_at: sample.createdAt, created_at: sample.createdAt,
used_in: sample.usedIn, used_in: sample.usedIn,
duplicate: sample.duplicate, 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) { 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> { function mediaApi(overrides: Record<string, ApiMock> = {}): Record<string, ApiMock> {
@@ -2015,6 +2082,59 @@ const mediaStates: Record<string, VisualState> = {
mediaDuplicatesPath, mediaDuplicatesPath,
), ),
'List failed to load': mediaState(mediaApi({ 'GET /v1/media/': { status: 500 } })), '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), Offline: mediaState(mediaApi(), goOffline),
} }
@@ -2931,6 +3051,23 @@ const mediaPurgeStates: Record<string, VisualState> = {
openMediaPurgeDialog, openMediaPurgeDialog,
mediaDuplicatesPath, 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( 'Duplicates tab with the button': mediaState(
mediaDuplicatesApi(), mediaDuplicatesApi(),
undefined, 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>> = { export const visualStates: Record<string, Record<string, VisualState>> = {
activity: activityStates, activity: activityStates,
profile: { profile: {
@@ -5644,6 +5873,8 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
...withVariantExceptions(variantsMediaStates), ...withVariantExceptions(variantsMediaStates),
...withVariantExceptions(variantsEditorStates), ...withVariantExceptions(variantsEditorStates),
}, },
media: { ...mediaStates, ...mediaPurgeStates }, media: withDetailTabsException(
withVariantsRowException({ ...mediaStates, ...mediaPurgeStates }),
),
'media-picker': pickerStates, 'media-picker': pickerStates,
} }