diff --git a/api/handlers/media/mediaHandler.go b/api/handlers/media/mediaHandler.go index 93f4a28..9e61bfb 100644 --- a/api/handlers/media/mediaHandler.go +++ b/api/handlers/media/mediaHandler.go @@ -70,9 +70,23 @@ type countsResponse struct { } type listResponse struct { - Items []mediaItemResponse `json:"items"` - NextCursor *string `json:"next_cursor"` - Counts countsResponse `json:"counts"` + Items []mediaItemResponse `json:"items"` + Total int `json:"total"` + 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 { @@ -173,7 +187,6 @@ func parseListQuery(r *http.Request) (domainImage.ListQuery, error) { query := domainImage.NewListQuery() query.Search = values.Get("q") query.Hash = strings.ToLower(values.Get("hash")) - query.Cursor = values.Get("cursor") if values.Has("filter") { query.Filter = domainImage.Filter(values.Get("filter")) } @@ -186,13 +199,19 @@ func parseListQuery(r *http.Request) (domainImage.ListQuery, error) { if values.Has("sort") { query.Sort = domainImage.Sort(values.Get("sort")) } - if values.Has("limit") { - limit, err := strconv.Atoi(values.Get("limit")) - if err != nil { - return domainImage.ListQuery{}, domainImage.ErrInvalidQuery - } - query.Limit = limit + page, err := intQueryParam(values, "page", domainImage.DefaultListPage) + if err != nil { + return domainImage.ListQuery{}, domainImage.ErrInvalidQuery } + 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 } @@ -219,11 +238,11 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) { } response := listResponse{ 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}, } - if page.NextCursor != "" { - response.NextCursor = &page.NextCursor - } writeJSON(w, http.StatusOK, response) } diff --git a/docs/api.md b/docs/api.md index 79b2a53..d49c549 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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. 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 -original plus its generated variants), `variants` (`with` or `without` generated variant files) and -`limit`. Every item carries `variant_count` and `variant_size` (the index rows of the image and -their bytes, 0 when none); `counts` stays library-wide. Any other `variants` or `sort` value is 400. +`q`, `hash`, `filter` (`all`, `unused` or `duplicates`), `sort` (`newest`, `oldest`, `name`, +`largest` or `total_size`, the original plus its generated variants), `variants` (`with` or +`without` generated variant files), `page` (a whole number from 1, default 1) and `size` (5, 25, 50 +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 Owners and admins manage named variants of the stored images. These routes are session only (no API diff --git a/docs/design/README.md b/docs/design/README.md index b5e2193..924d19b 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -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 | | B7 Consistency sweep | Done for the roles listed | | 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 | ## 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 | | New article | 5 | | Editor | 41 | -| Media | 43 | +| Media | 52 | | Media picker | 13 | | Integration | 8 | | Activity | 9 | @@ -89,7 +89,7 @@ Back-office total 341. Auth board 50 (Log in 9, Register 6, Pending 6, Verify 6, ## Defaults chosen - 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%). -- 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 link button is disabled when an image is selected. - The tooltip text is "Insert link (Ctrl+K)" and "Insert link (⌘K)" on macOS. diff --git a/docs/design/change-list.md b/docs/design/change-list.md index da5adf5..cb6e90c 100644 --- a/docs/design/change-list.md +++ b/docs/design/change-list.md @@ -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 | | 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 | -| 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 | | 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 | diff --git a/docs/design/routes.md b/docs/design/routes.md index c01c913..0cba3ca 100644 --- a/docs/design/routes.md +++ b/docs/design/routes.md @@ -5,16 +5,22 @@ | /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/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} | -| /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) | | /integration | Integration | none | none | | /integration/api-keys | API keys | none | GET/POST /v1/api-key, DELETE /v1/api-key/{id} | -## Pagination in the URL (Articles) -`/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. -**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. +## Pagination in the URL (Articles, Media) +`/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. +**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 `page` and `size`, allowed sizes 5, 25, 50, 100, default 25 (as implemented). diff --git a/docs/design/screens/articles.md b/docs/design/screens/articles.md index ac35fae..1cf2776 100644 --- a/docs/design/screens/articles.md +++ b/docs/design/screens/articles.md @@ -1,6 +1,6 @@ # 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. @@ -52,7 +52,7 @@ Rows update when the server responds, not optimistically. Search, tab and sort a ### 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. diff --git a/docs/design/screens/media.md b/docs/design/screens/media.md index 2548497..67339d6 100644 --- a/docs/design/screens/media.md +++ b/docs/design/screens/media.md @@ -1,6 +1,6 @@ # 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"). @@ -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. -### 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. @@ -18,7 +18,7 @@ New route for verified users, in the sidebar between Articles and Users with the | # | 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. | | | 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. | | @@ -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." | | 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. | | +| 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) diff --git a/docs/design/source/IntegrationPage.dc.html b/docs/design/source/IntegrationPage.dc.html index 047206d..65949d3 100644 --- a/docs/design/source/IntegrationPage.dc.html +++ b/docs/design/source/IntegrationPage.dc.html @@ -179,7 +179,7 @@ class Component extends DCLogic { const j = o => JSON.stringify(o, null, 2) const G = { 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-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' }) }), @@ -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 })] 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] } })), - 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 } })) } }), 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 })) } diff --git a/docs/design/source/MediaFrame.dc.html b/docs/design/source/MediaFrame.dc.html index 078f5a3..8c9e7a1 100644 --- a/docs/design/source/MediaFrame.dc.html +++ b/docs/design/source/MediaFrame.dc.html @@ -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 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 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())) parts.push(tb) 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.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 (view === 'grid') parts.push(SelectAll(), Grid()); else parts.push(m ? Cards() : Table()) + parts.push(...Pager()) } return parts } diff --git a/docs/design/source/RenewCMS Screens Back-office.dc.html b/docs/design/source/RenewCMS Screens Back-office.dc.html index d611f05..6fa1375 100644 --- a/docs/design/source/RenewCMS Screens Back-office.dc.html +++ b/docs/design/source/RenewCMS Screens Back-office.dc.html @@ -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({ 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: 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".' }), @@ -269,7 +269,16 @@ class Component extends DCLogic { 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: [ - 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({ 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.' }), diff --git a/docs/design/states.md b/docs/design/states.md index a9a3a7d..206ab8a 100644 --- a/docs/design/states.md +++ b/docs/design/states.md @@ -33,8 +33,8 @@ Glossary pass 2, changes made: activity log lines "Removed N expired invites" an ## 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. -## Articles 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. +## Articles and Media pagination +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 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). diff --git a/features/editor_image_upload.qa.md b/features/editor_image_upload.qa.md index 473645d..92db2be 100644 --- a/features/editor_image_upload.qa.md +++ b/features/editor_image_upload.qa.md @@ -4,8 +4,8 @@ Mock `XMLHttpRequest` (a fake with `open`, `setRequestHeader`, `send`, `abort`, firing `upload.progress`, `load`, `error`) for `POST /v1/media/`. Mock useApi's `request` for `GET /v1/media/` (list and `?hash=`), reusing the mock from `api_client_global_error_handling.qa.md`. Stub `hashFile` where a hash is needed, and add one test with `crypto.subtle` deleted. Mount EditorPage inside DashboardShell at `/article/12/edit` with the `editor_core.qa.md` fixture article, GET already resolved 200. Assert URLs by parsing the query, not by string equality. -1. **Toolbar button, empty library** - `GET /v1/media/?limit=24` resolves `{items: [], next_cursor: null, counts: {all: 0, unused: 0, duplicates: 0}}`; click "Image"; assert the dialog "Add an image", both tabs, the Upload tab selected, the drop zone copy, footer "Cancel" only, no XHR. -2. **Drop opens Upload with the file** - dispatch `drop` with `before.png` (image/png, 3.9 MB); assert the Upload tab, a `?hash=` request, no `?limit=24` list request, then the crop step after an empty lookup, and no XHR. +1. **Toolbar button, empty library** - `GET /v1/media/?page=1&size=25` resolves `{items: [], total: 0, page: 1, size: 25, counts: {all: 0, unused: 0, duplicates: 0}}`; click "Image"; assert the dialog "Add an image", both tabs, the Upload tab selected, the drop zone copy, footer "Cancel" only, no XHR. +2. **Drop opens Upload with the file** - dispatch `drop` with `before.png` (image/png, 3.9 MB); assert the Upload tab, a `?hash=...&page=1&size=100` request, no `?page=1&size=25` list request, then the crop step after an empty lookup, and no XHR. 3. **Dragging over shows "Drop to upload"**. 4. **Rejected, Examples-driven** - `plan.pdf`, `disguised.png`, `north-wall.tif`; assert no `?hash=` request and no XHR, plus the exact title, text, confirm and cancel. 5. **"Choose another file" returns to idle**, with the tabs back. diff --git a/features/editor_media_picker.feature b/features/editor_media_picker.feature index a5f84bd..368803e 100644 --- a/features/editor_media_picker.feature +++ b/features/editor_media_picker.feature @@ -17,13 +17,16 @@ Feature: The editor's Library tab, picking an image from the media library a bottom sheet on mobile And the footer reads "Cancel", and on the Library tab also a primary "Insert image" that is disabled until an image is selected - And the Library tab calls GET /v1/media/ through useApi with limit=24, with q only while a - search is set and cursor only when loading more (no filter and no sort parameter), and the - Upload tab is specified in features/editor_image_upload.feature - And the response is { items, next_cursor, counts }, newest first; sizes read "8 KB" and + And the Library tab calls GET /v1/media/?q=Q&page=N&size=25 through useApi, with q only while a + search is set and page 1, then 2, 3, ... when loading more (no filter and no sort parameter), + and the Upload tab is specified in features/editor_image_upload.feature + And the response is { items, total, page, size, counts }, newest first; "Load more" shows while + the loaded count is below the response total (the matching count, not counts.all); sizes read "8 KB" and "3.9 MB", dimensions read "2400 × 1600" (an SVG reads "Vector"), dates are absolute And the 12-image fixture, ids 1 to 12, is the one in features/media_browse.feature, and the - picker mocks use next_cursor "c24" and counts { all: 48, unused: 2, duplicates: 2 } + picker mocks use total 48 and counts { all: 48, unused: 2, duplicates: 2 } + And this slice is amended by features/media_pagination_api.feature: cursor and limit are gone, + LIBRARY_PAGE_LIMIT 24 became the page size 25, and a mock with items equal to its total is the last page And an image is "in this article" when an image node with its id as data-image-id is in the editor's current content, unsaved insertions included, when the dialog opens; in the examples below images 1 (north-wall.jpg) and 4 (logo-renew.svg) are in the article @@ -42,13 +45,13 @@ Feature: The editor's Library tab, picking an image from the media library Rule: Which tab opens Scenario: The library has images, so Library opens - Given GET /v1/media/?limit=24 resolves 200 with the 12-image fixture + Given GET /v1/media/?page=1&size=25 resolves 200 with the 12-image fixture When the user activates the toolbar's "Image" button Then the Library tab is selected and the footer reads "Cancel" and a disabled "Insert image" And nothing is drawn in the grid for the first 150 ms, then 8 skeleton tiles show until the response arrives Scenario: The library is empty, so Upload opens - Given GET /v1/media/?limit=24 resolves 200 with no item and no search set + Given GET /v1/media/?page=1&size=25 resolves 200 with no item and no search set When the dialog opens Then the Upload tab is selected, in its idle state When the user selects the Library tab @@ -56,9 +59,9 @@ Feature: The editor's Library tab, picking an image from the media library Scenario: A dropped file opens Upload and sends no list request When the user drops "before.png" onto the editor - Then the Upload tab is selected and no GET /v1/media/?limit=24 is sent + Then the Upload tab is selected and no GET /v1/media/?page=1&size=25 is sent When the user selects the Library tab - Then GET /v1/media/?limit=24 is sent + Then GET /v1/media/?page=1&size=25 is sent Rule: Browsing @@ -75,7 +78,7 @@ Feature: The editor's Library tab, picking an image from the media library Then the line reads "Newest first" Scenario: Load more is hidden on the last page - Given the response has next_cursor null + Given the loaded items equal the response total (for example total 12 with the 12-image fixture) Then "Load more" is not shown Scenario Outline: A tile's accessible name @@ -167,16 +170,16 @@ Feature: The editor's Library tab, picking an image from the media library Scenario: Typing searches after a debounce Given the Library tab shows the 12-image fixture When the user types "wall" - Then after a 120 ms debounce GET /v1/media/ is sent with q=wall and limit=24 and no cursor + Then after a 120 ms debounce GET /v1/media/ is sent with q=wall, page=1 and size=25 And skeleton tiles show after 150 ms, and any selection is cleared Scenario: The x in the field clears the search Given the search field holds "wall" with results shown When the user activates the x in the field - Then the field is empty and GET /v1/media/ is sent with limit=24 and no q + Then the field is empty and GET /v1/media/ is sent with page=1 and size=25 and no q Scenario: No results - Given GET /v1/media/?q=wall&limit=24 resolves 200 with no item + Given GET /v1/media/?q=wall&page=1&size=25 resolves 200 with no item Then the search icon shows with the title `No images match "wall"`, the text "Check the spelling, or search for a shorter part of the filename." and a "Clear search" button And the search field still shows "wall" above it, and "Insert image" is disabled When the user activates "Clear search" @@ -185,23 +188,23 @@ Feature: The editor's Library tab, picking an image from the media library Rule: Load more Scenario: Loading more shows progress and appends - Given the first page resolved with 12 images, next_cursor "c24" + Given the first page resolved with 12 images and total 48 When the user activates "Load more" - Then GET /v1/media/ is sent with limit=24 and cursor=c24 + Then GET /v1/media/ is sent with page=2 and size=25 And the button reads "Loading…" with a loader-circle and is disabled, and one row of skeleton tiles shows at the end And focus stays on the button When the request resolves 200 with 12 more images Then the new tiles are appended after the first 12, and focus moves to the first new tile - Scenario: The cursor and the search combine - Given the search "wall" is active and the page has next_cursor "c24" + Scenario: The next page and the search combine + Given the search "wall" is active and the response total is above the loaded count When the user activates "Load more" - Then GET /v1/media/ is sent with q=wall, limit=24 and cursor=c24 + Then GET /v1/media/ is sent with q=wall, page=2 and size=25 Rule: Empty and failed Scenario: Empty library - Given the Library tab is selected and GET /v1/media/?limit=24 resolved with no item + Given the Library tab is selected and GET /v1/media/?page=1&size=25 resolved with no item Then the images icon shows with "Your library is empty", "Images you upload appear here, ready to use in any article." and a primary "Upload an image" And "Insert image" is disabled When the user activates "Upload an image" diff --git a/features/editor_media_picker.qa.md b/features/editor_media_picker.qa.md index ef18449..4983bcf 100644 --- a/features/editor_media_picker.qa.md +++ b/features/editor_media_picker.qa.md @@ -2,29 +2,29 @@ ## Automated (Vitest + Vue Test Utils, `task test:web`) -Mock `request` for `GET /v1/media/` by parsed query. Use the 12-image fixture from `media_browse.feature` as `items`, with `next_cursor: "c24"` and `counts: {all: 48, unused: 2, duplicates: 2}`. Mount EditorPage at `/article/12/edit` with a body containing `` and ``. 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 `` and ``. Use fake timers for the 150 ms and 120 ms steps. 1. **Opens on Library with images** - first response has items; assert Library selected and skeleton tiles only after 150 ms. 2. **Opens on Upload when empty** - empty first response; assert Upload selected; then click Library and assert the empty panel. -3. **Drop sends no list request** - drop a file; assert no `?limit=24` request until Library is clicked. +3. **Drop sends no list request** - drop a file; assert no `?page=1&size=25` request until Library is clicked. 4. **Nothing selected** - assert the line, 12 tiles in order, the "In this article" tag on exactly ids 1 and 4, "Load more", "Insert image" disabled. 5. **Active search hides the count** - the line reads "Newest first". -6. **Last page** - `next_cursor: null` hides "Load more". +6. **Last page** - a total equal to the loaded count (for example 12) hides "Load more". 7. **Accessible names** - the three Examples. 8. **Selection** - click, Enter and Space each select; check, outline, "Insert image" enabled. 9. **Panel copy** - kitchen-after.webp panel, placeholder and help text exact; "Load more" hidden; SVG reads "Vector · 8 KB". 10. **Single selection resets alt**; reactivating keeps it selected. 11. **Insert** - alt "A kitchen with new oak worktops and white tiles" gives `insertImage("/static/uploadedImages/2.webp", alt, 2)`, SaveStatus "Unsaved changes", the toast, the dialog closed, no non-GET request. Empty alt gives `""`. 12. **Cancel, x and Escape** change nothing. -13. **Search** - type "wall"; at 119 ms no request, at 120 ms the request has `q=wall&limit=24` and no cursor; the selection is cleared. The field's x and "Clear search" send a request with no `q`. +13. **Search** - type "wall"; at 119 ms no request, at 120 ms the request has `q=wall&page=1&size=25`; the selection is cleared. The field's x and "Clear search" send a request with no `q`. 14. **No results** and **Empty** copy; "Upload an image" switches to the Upload tab. -15. **Load more** - request has `cursor=c24`; button "Loading…" and disabled; skeleton row of 4 (3 below md); focus on the button, then on the first new tile after resolve; combined `q` and `cursor`. +15. **Load more** - request has `page=2&size=25`; button "Loading…" and disabled; skeleton row of 4 (3 below md); focus on the button, then on the first new tile after resolve; combined `q` and `page=2`. 16. **Failed to load, Examples 500 and network** - alert copy; "Try again" repeats the request; success replaces the alert. ## Visual: `task visual:check -- media-picker` Add a setup in `web/visual/states.ts` for each of the 13 states in `docs/design/screens/media-picker.md`, from the board's script (`RenewCMS Screens Back-office.dc.html` lines 239 to 253). Every capture must be within 10 % at 1440×900 and 390×844, light and dark. The sample data to reuse: -- Library mock: the 12-image fixture, `counts.all` 48, `next_cursor` non-null; images 1 and 4 in the article. The dialog is opened on the editor "How we price a bathroom renovation". +- Library mock: the 12-image fixture, `counts.all` 48, `total` 48; images 1 and 4 in the article. The dialog is opened on the editor "How we price a bathroom renovation". - "Upload tab": Upload selected, idle drop zone. - "Upload tab: already in your library": a chosen file named `north-wall.jpg`; the hash lookup returns id 1 (12 Sep 2026, Marta Alves); the thumbnail is 72 × 54. - "Library: loading": skeleton tiles. @@ -46,9 +46,9 @@ Manual checks the screenshots can't cover: - [ ] Focus order is tabs, search, tiles, "Load more", footer. Enter and Space select a tile. Focus returns to the toolbar's image button when the dialog closes. - [ ] Hover on a tile and on "Load more", and the focus ring. - [ ] Motion: the dialog follows the standard 240 ms timing. -- [ ] A real library of more than 24 images through `task dev`: "Load more" and the cursor. +- [ ] A real library of more than 25 images through `task dev`: "Load more" and the page numbers. - [ ] A screen reader reads the tile names and the alt text help. ## Manual verification -Real `GET /v1/media/` with `q` and `cursor` against `task dev` with a live SQLite file. +Real `GET /v1/media/` with `q`, `page` and `size` against `task dev` with a live SQLite file. diff --git a/features/integration.qa.md b/features/integration.qa.md index 40867d9..7c17024 100644 --- a/features/integration.qa.md +++ b/features/integration.qa.md @@ -21,7 +21,7 @@ Catalogue spec (pure data, no mount, imports web/src/data/apiCatalogue.ts): - article: id, title, body, body_format, is_online, created_at, updated_at; - media item: id, filename, url, size, content_type, width, height, sha256, uploaded_by, created_at, used_in, duplicate; - - media list: items, next_cursor, counts (all, unused, duplicates); + - media list: items, total, page, size, counts (all, unused, duplicates), no next_cursor; - merge: articles_updated, merged; - usage: id, title, published; - user list item: id, kind, username, display_name, email, role, status, created_at; diff --git a/features/media_browse.feature b/features/media_browse.feature index 7f081aa..7d2aa9f 100644 --- a/features/media_browse.feature +++ b/features/media_browse.feature @@ -20,8 +20,12 @@ Feature: Media page, part 1: browse and manage And useMedia is a composable over useApi. It exposes the list state (items, counts, loading, error), the request functions for list, usage, rename and delete, and the copy helper, and it maps ApiError and NetworkError to the states below - And MediaPage calls GET /v1/media/ with the query built from the URL, always with limit=100, - and every request carries the jwt cookie through useApi + And MediaPage calls GET /v1/media/ with the query built from the URL, always with an explicit + page and size (page=1&size=25 by default, or the stored size), and every request carries + the jwt cookie through useApi + And this slice is amended by features/media_pagination_api.feature and + features/media_pagination_screen.feature: limit=100 and next_cursor are gone, the body is + { items, total, page, size, counts }, and the list is paginated And search, tab, sort and view live in the URL query string so the view can be linked and survives a reload: ?q= (search text), ?filter= ("unused" or "duplicates"; absent means All), ?sort= ("oldest", "name" or "largest"; absent means Newest), ?view= ("list"; absent @@ -90,10 +94,10 @@ Feature: Media page, part 1: browse and manage | 10 | bathroom-tiles.png | 860 KB | 1800 × 1200 | PNG | 0 | Marta Alves | 4 Aug 2026 | alpha | | 11 | lime-plaster.jpg | 2.1 MB | 3200 × 2133 | JPEG | 2 | Inès Duarte | 19 Jul 2026 | | | 12 | floorboards.jpg | 2.4 MB | 3600 × 2400 | JPEG | 4 | Inès Duarte | 14 Jun 2026 | duplicate | - with counts { all: 12, unused: 2, duplicates: 2 }, and next_cursor null + with counts { all: 12, unused: 2, duplicates: 2 }, total 12, page 1, size 25 Scenario: Default, many images - Given GET /v1/media/?filter=all&sort=newest&limit=100 resolves 200 with the 12-image fixture + Given GET /v1/media/?filter=all&sort=newest&page=1&size=25 resolves 200 with the 12-image fixture When the user navigates to /media Then the breadcrumb reads "Media" (mobile title "Media") And the tabs read "All 12", "Unused 2", "Duplicates 2", with "All" selected, and the @@ -144,7 +148,7 @@ Feature: Media page, part 1: browse and manage Scenario Outline: The query string is the source of truth for search, tab, sort and view Given GET /v1/media/ is mocked When the user opens /media - Then GET /v1/media/ is sent with "" and limit=100 + Then GET /v1/media/ is sent with "" and page=1&size=25 And the control shows "" Examples: @@ -161,7 +165,7 @@ Feature: Media page, part 1: browse and manage Scenario: Changing a control writes the query string and reloads the list Given the Default state has loaded When the user types "wall" in the search field - Then after a 120 ms debounce the URL gains ?q=wall and GET /v1/media/?q=wall&filter=all&sort=newest&limit=100 is sent + Then after a 120 ms debounce the URL gains ?q=wall and GET /v1/media/?q=wall&filter=all&sort=newest&page=1&size=25 is sent When the user selects the "Unused" tab Then the URL reads ?q=wall&filter=unused and the list is requested again When the user picks "Largest" in the sort menu @@ -187,7 +191,7 @@ Feature: Media page, part 1: browse and manage Scenario: No results, search and filter Given the Default state has loaded - When the user selects "Unused" and types "wall" and GET /v1/media/?q=wall&filter=unused&sort=newest&limit=100 resolves 200 with zero items + When the user selects "Unused" and types "wall" and GET /v1/media/?q=wall&filter=unused&sort=newest&page=1&size=25 resolves 200 with zero items Then a panel replaces the images with the search icon, title `No images match "wall"`, text "Nothing matches in Unused. Check the spelling, or clear the filters." and an outline "Clear filters" button When the user activates "Clear filters" Then the search field is emptied, the tab returns to "All", ?q and ?filter leave the URL, and the Default cards reappear diff --git a/features/media_browse.qa.md b/features/media_browse.qa.md index 48e7a41..b9b41e4 100644 --- a/features/media_browse.qa.md +++ b/features/media_browse.qa.md @@ -147,5 +147,6 @@ Manual checks screenshots cannot cover: - **Toast placement and stacking** on a live app. - **Counts after a delete** against a live server: the background refresh gives the true Unused and Duplicates counts. -- **A library above 100 images**: B1 requests `limit=100` and ignores `next_cursor`, so only the - first 100 show. +- **A library above 25 images**: amended by `features/media_pagination_screen.feature`. B1 used to + request `limit=100` and ignore `next_cursor`; the page now requests `page=1&size=25` (or the stored + size) and shows a paginator, so every image is reachable. diff --git a/features/media_library.qa.md b/features/media_library.qa.md index 475eef2..bbd7670 100644 --- a/features/media_library.qa.md +++ b/features/media_library.qa.md @@ -21,7 +21,7 @@ Package: `test` (black-box). New file `test/media_library_http_test.go`. Fake ty ## HTTP checks 10. Auth table: each method+path from the outline with no cookie -> 401, stale cookie -> 401 with `apperr.UnauthorizedErrorMessage`, unverified cookie -> 403 with `apperr.VerificationRequiredErrorMessage`. Assert no fake method beyond the middleware's user lookup was called. -11. List: decode `{items, next_cursor, counts}`. Assert each row of the sort, filter, `q` and `hash` tables, the combined query, the paging walk (limit 2 across three pages ending in `next_cursor == null`), the empty library (`items` is `[]` in the raw JSON, not `null`), SVG `width`/`height` are JSON `null` (assert on the raw body), and `counts` is unchanged by `q`/`filter`. Assert every 400 query row, body equals `apperr.MediaQueryErrorMessage + "\n"`. +11. List (amended by `features/media_pagination_api.feature`): decode `{items, total, page, size, counts}`, with no `next_cursor`. Assert each row of the sort, filter, `q` and `hash` tables, the combined query, the paging walk (`size=5` across three pages with `page=1..3`, each reporting the same `total`), the empty library (`items` is `[]` in the raw JSON, not `null`), SVG `width`/`height` are JSON `null` (assert on the raw body), and `counts` is unchanged by `q`/`filter`. Assert every 400 query row, body equals `apperr.MediaQueryErrorMessage + "\n"`. 12. Upload: for each allowed type assert 201, the item fields, `sha256` equals the test's own hash, `uploaded_by`, the url regex, and one repo entry with `UploadedByID == 1`. Upload identical bytes twice: both 201, second has `duplicate: true`, list `counts.duplicates == 1`. Assert filename rules (no extension gets `.png`, path stripped). Assert 413/415/400 statuses and bodies, that `Create` is never called on rejection, boundary 10485760 vs 10485761, a missing `file` part -> 400, and an armed `Create` failure -> 500 `"Internal server error\n"`. 13. Rename: assert 200 with the new filename, the same `url`, the article body unchanged, and every invalid row -> 400 with `"Enter a filename.\n"` for the filename rows; 404 for 9999; 400 with `apperr.IdUint32ErrorMessage` for `abc`. Assert the repository filename is unchanged after each 400. 14. Delete: in-use image -> 204 with an empty body, article bodies byte-identical, `deleteCalls == [1]`; 404, 400, 500 rows. The "file already gone" case is a persistence behaviour and is a manual item. diff --git a/features/media_page_size_memory.feature b/features/media_page_size_memory.feature new file mode 100644 index 0000000..e2d6726 --- /dev/null +++ b/features/media_page_size_memory.feature @@ -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 "