Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d03a27f31 | ||
|
|
63c1e7076e | ||
|
|
04dc151ed7 | ||
|
|
a1cdfce529 | ||
|
|
c41e6dbca5 | ||
|
|
9d9453c1b3 |
No files matched your search
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+14
-4
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
+11
-5
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 })) }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.' }),
|
||||
|
||||
@@ -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).
|
||||
|
||||
+4
-4
@@ -21,7 +21,7 @@ Requirements: Docker with Compose v2. [task](https://taskfile.dev) is optional f
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
`docker-compose.yml` uses `image: ${RENEWCMS_IMAGE:-git.floriansylvain.fr/fsylvain/renewcms:beta}`. The default tag is `beta` because `latest` does not exist until the first stable release (the default changes to `latest` then). Set `RENEWCMS_IMAGE` in your shell or in a `.env` file next to the compose file to pin another tag, for example `RENEWCMS_IMAGE=git.floriansylvain.fr/fsylvain/renewcms:v3.2.0-beta`.
|
||||
`docker-compose.yml` uses `image: ${RENEWCMS_IMAGE:-git.floriansylvain.fr/fsylvain/renewcms:beta}`. The default tag is `beta` because `latest` does not exist until the first stable release (the default changes to `latest` then). Set `RENEWCMS_IMAGE` in your shell or in a `.env` file next to the compose file to pin another tag, for example `RENEWCMS_IMAGE=git.floriansylvain.fr/fsylvain/renewcms:v3.2.1-beta`.
|
||||
|
||||
To build from the local sources instead, see [Building from source](#building-from-source).
|
||||
|
||||
@@ -50,7 +50,7 @@ docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
|
||||
| Release | Tags pushed |
|
||||
|---|---|
|
||||
| `vX.Y.Z` (stable) | `vX.Y.Z`, `X.Y.Z`, `X.Y`, `latest` |
|
||||
| `vX.Y.Z-<pre>` (for example `v3.2.0-beta`, `v3.2.0-rc.1`) | `vX.Y.Z-<pre>`, `X.Y.Z-<pre>`, and the moving tag `<pre-name>` (`beta`, `rc`) |
|
||||
| `vX.Y.Z-<pre>` (for example `v3.2.1-beta`, `v3.2.1-rc.1`) | `vX.Y.Z-<pre>`, `X.Y.Z-<pre>`, and the moving tag `<pre-name>` (`beta`, `rc`) |
|
||||
|
||||
A pre-release never moves `latest`. Images are published for `linux/amd64` and `linux/arm64`. `renewcms --version` in the image prints the exact release tag. Images are not signed; the Ed25519 key signs the release binaries only.
|
||||
|
||||
@@ -198,7 +198,7 @@ Publishing is a manual, maintainer-run task, like `task release`. There is no CI
|
||||
1. Tag the release and keep the work tree clean:
|
||||
|
||||
```sh
|
||||
git tag v3.2.0-beta
|
||||
git tag v3.2.1-beta
|
||||
```
|
||||
|
||||
2. Log in once to the registry. The task never reads or stores credentials:
|
||||
@@ -226,7 +226,7 @@ Publishing is a manual, maintainer-run task, like `task release`. There is no CI
|
||||
|
||||
```sh
|
||||
task docker:publish -- --dry-run
|
||||
task docker:publish VERSION=v3.2.0-rc.1 -- --dry-run
|
||||
task docker:publish VERSION=v3.2.1-rc.1 -- --dry-run
|
||||
```
|
||||
|
||||
The dry run builds for the host platform only, loads the image into the local Docker, pushes nothing and needs no login. It prints the tags a real run would push. A `VERSION` override is accepted only with `--dry-run`, and a dirty tree is allowed there. Remove the loaded images afterwards with `docker rmi`.
|
||||
|
||||
@@ -38,3 +38,4 @@ Each body lives in `docs/releases/<tag>.md`. Publish with `tea releases create -
|
||||
| `v3.0.0-beta` | Activity log, more databases and S3 storage |
|
||||
| `v3.1.0-beta` | Image variants |
|
||||
| `v3.2.0-beta` | Article lists, editor links and activity export |
|
||||
| `v3.2.1-beta` | Paged media library and remembered page sizes |
|
||||
@@ -0,0 +1,31 @@
|
||||
This release pages the media library, remembers your page size on the article, activity and media lists, and previews image variants on a real sample photo. The media list API changes shape, so read the upgrading notes if you call it.
|
||||
|
||||
## New
|
||||
|
||||
**Media library**
|
||||
|
||||
- The media library is now paged, with a page size of 5, 25, 50 or 100. The image picker in the editor uses the same pages.
|
||||
|
||||
**Image variants**
|
||||
|
||||
- Creating a variant shows a preview rendered from a real sample image, so the size estimate and the before and after comparison match what visitors will get.
|
||||
|
||||
## Improvements and fixes
|
||||
|
||||
- The article, activity and media lists keep their page and page size in the address, so a reload or a shared link opens the same view.
|
||||
- The Integration page is now fully translated into French.
|
||||
|
||||
## API
|
||||
|
||||
- Changed: `GET /v1/media/` answers `{ items, total, page, size, counts }` instead of `{ items, next_cursor, counts }`. It accepts `page` (default `1`) and `size` (`5`, `25`, `50` or `100`, default `25`). An invalid value answers `400`.
|
||||
- Changed: `GET /v1/media/` no longer accepts `cursor` or `limit`.
|
||||
|
||||
## Under the hood
|
||||
|
||||
- Replace the cursor and limit of the media list query with a page and a size in the image domain and the list use case.
|
||||
- Add specifications, design checks and Go and Vitest suites for the paged media screens.
|
||||
|
||||
## Upgrading
|
||||
|
||||
- Replace the binary, or pull the new image. There is no schema change and no new environment variable.
|
||||
- Any script or site that reads `GET /v1/media/` must now read `total` and loop over `page` instead of following `next_cursor`.
|
||||
@@ -0,0 +1,115 @@
|
||||
Feature: Articles list remembers the chosen page size
|
||||
|
||||
As a verified RenewCMS user
|
||||
I want the page size I choose on the Articles list to be remembered
|
||||
So that I do not have to pick it again each time I come back or refresh
|
||||
|
||||
Background:
|
||||
Given this slice amends features/articles_pagination_screen.feature; the URL keeps ?size= as in that spec and no visual or API change is made (docs/design/ is untouched)
|
||||
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-articles as the bare number (for example 5), one key per paginated list; only the Articles list uses it today
|
||||
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/articles_pagination_screen.feature scenario 10b)
|
||||
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 requests are GET /v1/article/?format=html&page=P&size=S&q=Q&status=T as in features/articles_pagination_api.feature
|
||||
|
||||
Scenario Outline: Choosing a size stores it
|
||||
Given nothing is stored and the user is on /article
|
||||
When the user chooses "<label>" in the page-size select
|
||||
Then localStorage renewcms-page-size-articles is "<stored>"
|
||||
And on success the URL is <url>
|
||||
|
||||
Examples:
|
||||
| label | stored | url |
|
||||
| 5 per page | 5 | /article?size=5 |
|
||||
| 25 per page | 25 | /article |
|
||||
| 50 per page | 50 | /article?size=50 |
|
||||
| 100 per page | 100 | /article?size=100 |
|
||||
|
||||
Scenario: The stored size is restored when the list is opened without a size
|
||||
Given localStorage renewcms-page-size-articles is "5"
|
||||
When the user navigates to /article
|
||||
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 /article?size=5
|
||||
|
||||
Scenario: The size survives leaving the list and coming back
|
||||
Given the user chose "5 per page" on /article
|
||||
When the user navigates to /, then back to /article
|
||||
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 /article
|
||||
When the page is reloaded on /article?size=5, or on /article 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 and sort
|
||||
Given localStorage renewcms-page-size-articles is "50" and the user is on /article?size=50
|
||||
When the user activates Next, switches to the "Drafts" tab, sorts by Title or searches "plaster"
|
||||
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-articles is "5"
|
||||
When the user chooses "100 per page"
|
||||
Then localStorage renewcms-page-size-articles is "100"
|
||||
And the next visit to /article without a size sends size=100
|
||||
|
||||
Scenario: Returning to 25 is remembered too
|
||||
Given localStorage renewcms-page-size-articles is "5"
|
||||
When the user chooses "25 per page"
|
||||
Then localStorage renewcms-page-size-articles is "25"
|
||||
And the next visit to /article sends size=25 and the URL stays /article
|
||||
|
||||
Scenario: An explicit size in the URL wins and is not stored
|
||||
Given localStorage renewcms-page-size-articles is "5"
|
||||
When the user navigates to /article?size=100
|
||||
Then GET ...&size=100 is sent and the select reads "100 per page"
|
||||
And localStorage renewcms-page-size-articles is still "5"
|
||||
And a later visit to /article sends size=5
|
||||
|
||||
Scenario: An explicit page with no size uses the stored size
|
||||
Given localStorage renewcms-page-size-articles is "5"
|
||||
When the user navigates to /article?page=3
|
||||
Then GET ...&page=3&size=5 is sent and on success the URL is replaced by /article?page=3&size=5
|
||||
|
||||
Scenario Outline: An invalid stored value falls back to 25
|
||||
Given localStorage renewcms-page-size-articles is "<stored>"
|
||||
When the user navigates to /article
|
||||
Then GET ...&page=1&size=25 is sent, the select reads "25 per page" and the URL is /article
|
||||
|
||||
Examples:
|
||||
| stored |
|
||||
| 10 |
|
||||
| abc |
|
||||
| 0 |
|
||||
| -5 |
|
||||
| 25.5 |
|
||||
| |
|
||||
|
||||
Scenario: An invalid size in the URL ignores the stored one
|
||||
Given localStorage renewcms-page-size-articles is "5"
|
||||
When the user navigates to /article?size=10
|
||||
Then GET ... is sent with size=25 and the URL is replaced by /article
|
||||
And localStorage renewcms-page-size-articles is still "5"
|
||||
|
||||
Scenario Outline: Unavailable storage never breaks the list
|
||||
Given localStorage <failure>
|
||||
When the user navigates to /article
|
||||
Then GET ...&page=1&size=25 is sent, the list 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 /article?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-articles is "5"
|
||||
When the user logs out and logs in again and opens /article
|
||||
Then GET ...&size=5 is sent
|
||||
@@ -0,0 +1,33 @@
|
||||
# QA: Articles page-size memory (SPA)
|
||||
|
||||
No Go or HTTP test applies: nothing outside web/ changes.
|
||||
|
||||
## Automated (Vitest, task test:web)
|
||||
|
||||
Extend or sit beside web/src/views/__tests__/ArticlesPagination.spec.ts and reuse articleEnvelope.ts for the mocked GET /v1/article/ envelope (total 312, size from the query). localStorage.clear() in beforeEach; fresh router per test. Assert on the mocked request query string, the select label, the router URL and localStorage.
|
||||
|
||||
1. Choosing 5 / 25 / 50 / 100 writes "5" / "25" / "50" / "100" under renewcms-page-size-articles; URLs /article?size=5, /article, ?size=50, ?size=100.
|
||||
2. Stored "5", open /article: request carries size=5 and page=1, select reads "5 per page", URL replaced (router.replace, no history entry added) with ?size=5.
|
||||
3. Choose 5, navigate to another route and back (mount again): size=5 requested.
|
||||
4. Choose 5, remount the app on /article and on /article?size=5 (simulated refresh): size=5 requested.
|
||||
5. Stored "50", on ?size=50: Next, Drafts tab, sort Title, search "plaster" (fake timers for the 120 ms debounce, not the system date): every request has size=50, stored value unchanged.
|
||||
6. Stored "5", choose 100: stored "100"; remount sends size=100.
|
||||
7. Stored "5", choose 25: stored "25"; remount sends size=25, URL /article.
|
||||
8. Stored "5", open /article?size=100: size=100 requested, stored still "5"; remount on /article sends size=5.
|
||||
9. Stored "5", open /article?page=3: request page=3&size=5, URL replaced by ?page=3&size=5.
|
||||
10. Invalid stored values 10, abc, 0, -5, 25.5, "": size=25 requested, select "25 per page", URL /article.
|
||||
11. Stored "5", open /article?size=10: size=25 requested, URL /article, stored still "5".
|
||||
12. Storage failures (Storage.prototype.getItem throwing; setItem throwing; window.localStorage undefined): no exception, list renders, size=25 on load, choosing 50 requests size=50 and URL ?size=50.
|
||||
13. Logout (session store reset) leaves the stored value; the next /article sends size=5.
|
||||
14. Unit tests for any read/write helper added next to web/src/lib/pagination.ts: valid values accepted, invalid rejected, errors swallowed.
|
||||
|
||||
## Visual (task visual:check -- articles)
|
||||
|
||||
No visual change is intended. Run the full articles group as a regression and require every capture within 10 %. Setups use the board's own sample data and must not depend on a stored size. Clear localStorage before each capture so a leftover value cannot change the default 25 frame. No state is clock-sensitive for this slice.
|
||||
|
||||
## Manual
|
||||
|
||||
- In a real browser, choose 5 per page, navigate away and back, then hard-refresh: it stays at 5.
|
||||
- Private-window or storage-blocked browser: the list still works at 25 with no error.
|
||||
- Keyboard: the page-size select behaves as before (ArrowDown, Enter, Esc, focus ring), and focus returns to the select after choosing.
|
||||
- Open a copied /article?size=100 link in a profile that has 5 stored: the link shows 100 and the next plain visit shows 5.
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -2,29 +2,29 @@
|
||||
|
||||
## Automated (Vitest + Vue Test Utils, `task test:web`)
|
||||
|
||||
Mock `request` for `GET /v1/media/` by parsed query. Use the 12-image fixture from `media_browse.feature` as `items`, with `next_cursor: "c24"` and `counts: {all: 48, unused: 2, duplicates: 2}`. Mount EditorPage at `/article/12/edit` with a body containing `<img data-image-id="1">` and `<img data-image-id="4">`. Use fake timers for the 150 ms and 120 ms steps.
|
||||
Mock `request` for `GET /v1/media/` by parsed query. Use the 12-image fixture from `media_browse.feature` as `items`, with `total: 48`, `page`, `size: 25` and `counts: {all: 48, unused: 2, duplicates: 2}` (amended by `features/media_pagination_api.feature`: `next_cursor`, `cursor` and `limit` are gone). Mount EditorPage at `/article/12/edit` with a body containing `<img data-image-id="1">` and `<img data-image-id="4">`. Use fake timers for the 150 ms and 120 ms steps.
|
||||
|
||||
1. **Opens on Library with images** - first response has items; assert Library selected and skeleton tiles only after 150 ms.
|
||||
2. **Opens on Upload when empty** - empty first response; assert Upload selected; then click Library and assert the empty panel.
|
||||
3. **Drop sends no list request** - drop a file; assert no `?limit=24` request until Library is clicked.
|
||||
3. **Drop sends no list request** - drop a file; assert no `?page=1&size=25` request until Library is clicked.
|
||||
4. **Nothing selected** - assert the line, 12 tiles in order, the "In this article" tag on exactly ids 1 and 4, "Load more", "Insert image" disabled.
|
||||
5. **Active search hides the count** - the line reads "Newest first".
|
||||
6. **Last page** - `next_cursor: null` hides "Load more".
|
||||
6. **Last page** - a total equal to the loaded count (for example 12) hides "Load more".
|
||||
7. **Accessible names** - the three Examples.
|
||||
8. **Selection** - click, Enter and Space each select; check, outline, "Insert image" enabled.
|
||||
9. **Panel copy** - kitchen-after.webp panel, placeholder and help text exact; "Load more" hidden; SVG reads "Vector · 8 KB".
|
||||
10. **Single selection resets alt**; reactivating keeps it selected.
|
||||
11. **Insert** - alt "A kitchen with new oak worktops and white tiles" gives `insertImage("/static/uploadedImages/2.webp", alt, 2)`, SaveStatus "Unsaved changes", the toast, the dialog closed, no non-GET request. Empty alt gives `""`.
|
||||
12. **Cancel, x and Escape** change nothing.
|
||||
13. **Search** - type "wall"; at 119 ms no request, at 120 ms the request has `q=wall&limit=24` and no cursor; the selection is cleared. The field's x and "Clear search" send a request with no `q`.
|
||||
13. **Search** - type "wall"; at 119 ms no request, at 120 ms the request has `q=wall&page=1&size=25`; the selection is cleared. The field's x and "Clear search" send a request with no `q`.
|
||||
14. **No results** and **Empty** copy; "Upload an image" switches to the Upload tab.
|
||||
15. **Load more** - request has `cursor=c24`; button "Loading…" and disabled; skeleton row of 4 (3 below md); focus on the button, then on the first new tile after resolve; combined `q` and `cursor`.
|
||||
15. **Load more** - request has `page=2&size=25`; button "Loading…" and disabled; skeleton row of 4 (3 below md); focus on the button, then on the first new tile after resolve; combined `q` and `page=2`.
|
||||
16. **Failed to load, Examples 500 and network** - alert copy; "Try again" repeats the request; success replaces the alert.
|
||||
|
||||
## Visual: `task visual:check -- media-picker`
|
||||
|
||||
Add a setup in `web/visual/states.ts` for each of the 13 states in `docs/design/screens/media-picker.md`, from the board's script (`RenewCMS Screens Back-office.dc.html` lines 239 to 253). Every capture must be within 10 % at 1440×900 and 390×844, light and dark. The sample data to reuse:
|
||||
- Library mock: the 12-image fixture, `counts.all` 48, `next_cursor` non-null; images 1 and 4 in the article. The dialog is opened on the editor "How we price a bathroom renovation".
|
||||
- Library mock: the 12-image fixture, `counts.all` 48, `total` 48; images 1 and 4 in the article. The dialog is opened on the editor "How we price a bathroom renovation".
|
||||
- "Upload tab": Upload selected, idle drop zone.
|
||||
- "Upload tab: already in your library": a chosen file named `north-wall.jpg`; the hash lookup returns id 1 (12 Sep 2026, Marta Alves); the thumbnail is 72 × 54.
|
||||
- "Library: loading": skeleton tiles.
|
||||
@@ -46,9 +46,9 @@ Manual checks the screenshots can't cover:
|
||||
- [ ] Focus order is tabs, search, tiles, "Load more", footer. Enter and Space select a tile. Focus returns to the toolbar's image button when the dialog closes.
|
||||
- [ ] Hover on a tile and on "Load more", and the focus ring.
|
||||
- [ ] Motion: the dialog follows the standard 240 ms timing.
|
||||
- [ ] A real library of more than 24 images through `task dev`: "Load more" and the cursor.
|
||||
- [ ] A real library of more than 25 images through `task dev`: "Load more" and the page numbers.
|
||||
- [ ] A screen reader reads the tile names and the alt text help.
|
||||
|
||||
## Manual verification
|
||||
|
||||
Real `GET /v1/media/` with `q` and `cursor` against `task dev` with a live SQLite file.
|
||||
Real `GET /v1/media/` with `q`, `page` and `size` against `task dev` with a live SQLite file.
|
||||
@@ -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;
|
||||
|
||||
@@ -20,8 +20,12 @@ Feature: Media page, part 1: browse and manage
|
||||
And useMedia is a composable over useApi. It exposes the list state (items, counts, loading,
|
||||
error), the request functions for list, usage, rename and delete, and the copy helper, and
|
||||
it maps ApiError and NetworkError to the states below
|
||||
And MediaPage calls GET /v1/media/ with the query built from the URL, always with limit=100,
|
||||
and every request carries the jwt cookie through useApi
|
||||
And MediaPage calls GET /v1/media/ with the query built from the URL, always with an explicit
|
||||
page and size (page=1&size=25 by default, or the stored size), and every request carries
|
||||
the jwt cookie through useApi
|
||||
And this slice is amended by features/media_pagination_api.feature and
|
||||
features/media_pagination_screen.feature: limit=100 and next_cursor are gone, the body is
|
||||
{ items, total, page, size, counts }, and the list is paginated
|
||||
And search, tab, sort and view live in the URL query string so the view can be linked and
|
||||
survives a reload: ?q= (search text), ?filter= ("unused" or "duplicates"; absent means
|
||||
All), ?sort= ("oldest", "name" or "largest"; absent means Newest), ?view= ("list"; absent
|
||||
@@ -90,10 +94,10 @@ Feature: Media page, part 1: browse and manage
|
||||
| 10 | bathroom-tiles.png | 860 KB | 1800 × 1200 | PNG | 0 | Marta Alves | 4 Aug 2026 | alpha |
|
||||
| 11 | lime-plaster.jpg | 2.1 MB | 3200 × 2133 | JPEG | 2 | Inès Duarte | 19 Jul 2026 | |
|
||||
| 12 | floorboards.jpg | 2.4 MB | 3600 × 2400 | JPEG | 4 | Inès Duarte | 14 Jun 2026 | duplicate |
|
||||
with counts { all: 12, unused: 2, duplicates: 2 }, and next_cursor null
|
||||
with counts { all: 12, unused: 2, duplicates: 2 }, total 12, page 1, size 25
|
||||
|
||||
Scenario: Default, many images
|
||||
Given GET /v1/media/?filter=all&sort=newest&limit=100 resolves 200 with the 12-image fixture
|
||||
Given GET /v1/media/?filter=all&sort=newest&page=1&size=25 resolves 200 with the 12-image fixture
|
||||
When the user navigates to /media
|
||||
Then the breadcrumb reads "Media" (mobile title "Media")
|
||||
And the tabs read "All 12", "Unused 2", "Duplicates 2", with "All" selected, and the
|
||||
@@ -144,7 +148,7 @@ Feature: Media page, part 1: browse and manage
|
||||
Scenario Outline: The query string is the source of truth for search, tab, sort and view
|
||||
Given GET /v1/media/ is mocked
|
||||
When the user opens /media<url>
|
||||
Then GET /v1/media/ is sent with "<request>" and limit=100
|
||||
Then GET /v1/media/ is sent with "<request>" and page=1&size=25
|
||||
And the control shows "<control>"
|
||||
|
||||
Examples:
|
||||
@@ -161,7 +165,7 @@ Feature: Media page, part 1: browse and manage
|
||||
Scenario: Changing a control writes the query string and reloads the list
|
||||
Given the Default state has loaded
|
||||
When the user types "wall" in the search field
|
||||
Then after a 120 ms debounce the URL gains ?q=wall and GET /v1/media/?q=wall&filter=all&sort=newest&limit=100 is sent
|
||||
Then after a 120 ms debounce the URL gains ?q=wall and GET /v1/media/?q=wall&filter=all&sort=newest&page=1&size=25 is sent
|
||||
When the user selects the "Unused" tab
|
||||
Then the URL reads ?q=wall&filter=unused and the list is requested again
|
||||
When the user picks "Largest" in the sort menu
|
||||
@@ -187,7 +191,7 @@ Feature: Media page, part 1: browse and manage
|
||||
|
||||
Scenario: No results, search and filter
|
||||
Given the Default state has loaded
|
||||
When the user selects "Unused" and types "wall" and GET /v1/media/?q=wall&filter=unused&sort=newest&limit=100 resolves 200 with zero items
|
||||
When the user selects "Unused" and types "wall" and GET /v1/media/?q=wall&filter=unused&sort=newest&page=1&size=25 resolves 200 with zero items
|
||||
Then a panel replaces the images with the search icon, title `No images match "wall"`, text "Nothing matches in Unused. Check the spelling, or clear the filters." and an outline "Clear filters" button
|
||||
When the user activates "Clear filters"
|
||||
Then the search field is emptied, the tab returns to "All", ?q and ?filter leave the URL, and the Default cards reappear
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
@@ -0,0 +1,52 @@
|
||||
# QA: Media page pagination (SPA)
|
||||
|
||||
## Automated (Vitest + Vue Test Utils, `task test:web`)
|
||||
|
||||
Mock `/v1/media/` the way `web/src/views/__tests__/mediaTestKit.ts` does, extended so the mock honours `page` and `size`, builds the envelope `{items, total, page, size, counts}` and has no `next_cursor`. Use the 312-image fixture (25 per page) from the board's data in `docs/design/source/MediaFrame.dc.html`. Mount `MediaView` with a real router and Pinia. Reuse the helpers of `ArticlesPagination.spec.ts`.
|
||||
|
||||
One test per state:
|
||||
1. 44 First page: request `?filter=all&sort=newest&page=1&size=25`; "Showing 1–25 of 312"; First/Previous disabled; URL without page or size.
|
||||
2. 45 Middle page: `/media?page=7` -> "Showing 151–175 of 312"; `aria-current="page"` on 7; ellipses.
|
||||
3. 46 Last page: `/media?page=13` -> 12 items, "Showing 301–312 of 312"; Next/Last disabled.
|
||||
4. Single page: total 12 -> summary, page-size select, no First..Last.
|
||||
5. 47 List view: `?view=list&page=2` -> paginator under the table (md) and the card list (below md, the "Page 2 of 13" form).
|
||||
6. 48 Loading next page: pending page 2 -> skeleton cards, `aria-busy`, controls disabled, "Loading page 2…"; resolve -> URL `/media?page=2`.
|
||||
7. 49 Error (500, network): previous images stay, URL unchanged, notice "Couldn't load page 7. Showing page 6." with Retry; Retry succeeds -> `?page=7`, notice gone.
|
||||
8. 50 Out of range: `?page=99` with items [] and total 312 -> URL replaced by `?page=13` (assert `router.replace`, history length unchanged), notice text exact, last-page state.
|
||||
9. Total 0 out of range: empty state, URL `/media`; with `q` -> no-results state.
|
||||
10. Reset to page 1: table of search (120 ms debounce with fake timers on the timer only, never the system date), tab, sort, view, variants filter -> request carries `page=1`, URL has no page.
|
||||
11. 51 Page-size menu table: 5, 25, 50, 100 -> request `page=1&size=N`; URLs per the table.
|
||||
12. Invalid URL table (`size=10&page=abc`, `page=0`, `page=-2`, `page=1.5`, `size=0`, `size=200`): request page=1 size=25, URL replaced, no notice or alert.
|
||||
13. Controls kept: Next with `size=50&sort=largest&filter=unused` keeps every param.
|
||||
14. 52 Focus and keyboard: tab order select, first, previous, pages, next, last; ArrowDown opens, Enter selects, Esc closes and refocuses.
|
||||
15. Select all: "25 images", 25 selected, page change clears the selection and the bulk bar.
|
||||
16. Duplicates tab: with 130 duplicates, assert requests page=1 and page=2 at size=100, groups built from all 130, no paginator, URL drops page and size; selecting All returns to the paginated request.
|
||||
17. Last-image delete: steps back one page and replaces the URL; counts refreshed.
|
||||
18. Background refresh after rename/upload/merge: same page, no skeleton.
|
||||
19. Hash lookups send `hash=H&page=1&size=100` (`findCopies`, `useMediaUpload`, `ImageUploader`).
|
||||
20. Amended existing specs: every spec reading `next_cursor`, `limit=100`, `MEDIA_PAGE_LIMIT` or the old `/v1/media/?` mocks is migrated to the envelope and page/size requests; list them in the diff.
|
||||
|
||||
## Visual (`task visual:check -- media`)
|
||||
|
||||
Hard gate: every capture within 10 % at 1440×900 and 390×844, light and dark. Add `web/visual/states.ts` setups using the board's own sample data (312 images, 25 per page; filenames, sizes, uploaders and dates taken from the frame's script in `docs/design/source/MediaFrame.dc.html`; counts all 312 / unused 2 / duplicates 2):
|
||||
|
||||
| State | Setup |
|
||||
| --- | --- |
|
||||
| 44 Pagination: first page | `/media`, page 1 of 13 |
|
||||
| 45 middle page | `/media?page=7` |
|
||||
| 46 last page | `/media?page=13`, 12 items |
|
||||
| 47 list view | `/media?view=list&page=2` |
|
||||
| 48 loading next page | page 2 pending |
|
||||
| 49 error loading a page | page 7 fails, page 6 shown |
|
||||
| 50 page out of range | `/media?page=99` replaced by `?page=13` with the notice |
|
||||
| 51 page-size menu | select open upward |
|
||||
| 52 focus-visible | Next focused |
|
||||
|
||||
If a frame renders a relative date or greeting, mark that state `skip: clock-sensitive` and write no date-frozen test. The paginator's placement under the grid or list is checked against the board frame's markup, not `components.md` prose.
|
||||
|
||||
## Manual verification
|
||||
|
||||
- Keyboard: tab order and focus ring on every paginator control; ArrowDown/Enter/Esc in the page-size list; grid arrow-key navigation still works after a page change; focus is sensible after Next.
|
||||
- Hover and motion on the paginator and skeleton.
|
||||
- A real library of 60+ images through `task dev`: paging, size change, page past the end replaced with the notice, Duplicates tab loading beyond 100 duplicates.
|
||||
- Screen reader: the summary and "Loading page N…" are announced; selection announcements after a page change.
|
||||
@@ -10,13 +10,16 @@ Feature: Media page, part 2: upload, selection and duplicates
|
||||
DashboardShell, per docs/design/routes.md and docs/design/screens/media.md
|
||||
And this slice is SPA only: the API from slice A is used unchanged, and no Go code, use case
|
||||
or repository changes
|
||||
And this slice is amended by features/media_pagination_screen.feature: "Select all" covers the
|
||||
images of the current page only, the Duplicates tab loads the whole set at size=100 page after
|
||||
page until the loaded count reaches total, and hash lookups send page=1&size=100
|
||||
And this slice supersedes these B1 statements: the Upload button, the mobile pinned button and
|
||||
"Upload an image" are enabled; the Duplicates tab is enabled and ?filter=duplicates is a
|
||||
valid value; "Review duplicates" is enabled; Space on a card toggles selection; the checkbox,
|
||||
the "Select all" text and the partial state exist; the B2 states no longer carry
|
||||
skip 'pending' in web/visual/states.ts
|
||||
And the calls used are POST /v1/media/ (multipart field "file", sent with XMLHttpRequest so
|
||||
that upload progress is reported, with the jwt cookie), GET /v1/media/?hash=<sha256 hex>,
|
||||
that upload progress is reported, with the jwt cookie), GET /v1/media/?hash=<sha256 hex>&page=1&size=100,
|
||||
DELETE /v1/media/{id}, POST /v1/media/merge { keep_id, merge_ids } and the list call from
|
||||
B1 with filter=duplicates; every other /v1 call goes through useApi
|
||||
And 401 and 403 on any of these calls, including the XHR upload, send the user to
|
||||
@@ -130,7 +133,7 @@ Feature: Media page, part 2: upload, selection and duplicates
|
||||
Scenario: A row whose content already exists
|
||||
Given the user chooses "floorboards-2.jpg" (2.4 MB, valid)
|
||||
When the SHA-256 of the file is computed
|
||||
Then GET /v1/media/?hash=<that hex> is sent before any POST
|
||||
Then GET /v1/media/?hash=<that hex>&page=1&size=100 is sent before any POST
|
||||
And when it resolves 200 with the image "floorboards.jpg" (id 12, uploaded 14 Jun 2026 by Inès Duarte)
|
||||
Then the row shows "Already in your library", the text `Same as "floorboards.jpg", uploaded 14 Jun 2026 by Inès Duarte.`, and the buttons "Use existing" and "Upload anyway"
|
||||
And nothing is sent with POST /v1/media/ for it, and its concurrency slot is released
|
||||
@@ -146,7 +149,7 @@ Feature: Media page, part 2: upload, selection and duplicates
|
||||
And the image "floorboards.jpg" is selected, the bulk bar reads "1 selected", and the page announces "floorboards.jpg selected, 1 selected"
|
||||
|
||||
Scenario: Use existing when the image is not in the loaded list
|
||||
Given the existing image is not in the loaded list because of the search, the tab or the 100 limit
|
||||
Given the existing image is not in the loaded list because of the search, the tab or the current page
|
||||
When the user activates "Use existing"
|
||||
Then the search and the tab reset to All and the image is selected
|
||||
And if it is still not loaded the row is dropped without selecting
|
||||
@@ -275,7 +278,7 @@ Feature: Media page, part 2: upload, selection and duplicates
|
||||
When 3 images are selected
|
||||
Then that checkbox shows the partial state
|
||||
When the user activates it while it is partial
|
||||
Then all 12 loaded images are selected and it shows checked
|
||||
Then all 12 images of the current page are selected and it shows checked (never the other pages)
|
||||
When the user activates it while it is checked
|
||||
Then the selection is emptied
|
||||
And in list view at md width or more the table header checkbox does the same; the mobile card list has no "Select all" row and is selected through its 44 px row checkboxes
|
||||
@@ -358,13 +361,13 @@ Feature: Media page, part 2: upload, selection and duplicates
|
||||
Scenario: The Duplicates tab is enabled
|
||||
Given the Default state has loaded
|
||||
When the user selects the "Duplicates 2" tab
|
||||
Then the URL gains ?filter=duplicates and the list is requested with filter=duplicates and limit=100
|
||||
Then the URL gains ?filter=duplicates and the list is requested with filter=duplicates at size=100, page 1, 2, ... until the loaded count reaches total
|
||||
And the tab is selected, and "Duplicates" carries the info-tinted count of groups
|
||||
And opening /media?filter=duplicates lands on the same view, and "Review duplicates" in the detail panel of "floorboards.jpg" is enabled and selects this tab
|
||||
And search applies, the sort menu and the view toggle show as on the other tabs, sort is sent with the request, and the view toggle has no effect on the groups view while the grid or list preference stays in the URL
|
||||
|
||||
Scenario: Duplicates, groups
|
||||
Given GET /v1/media/?filter=duplicates&sort=newest&limit=100 resolves 200 with ids 5, 6, 7, 9 and 12
|
||||
Given GET /v1/media/?filter=duplicates&sort=newest&page=1&size=100 resolves 200 with ids 5, 6, 7, 9 and 12
|
||||
When the user opens the Duplicates tab
|
||||
Then the images are grouped by sha256 into 2 bordered cards stacked with a 16 px gap, the group with the oldest suggested copy first
|
||||
And group 1 has the header "Group 1" over "3 identical images · 2.4 MB each · 3600 × 2400", and group 2 has "Group 2" over "2 identical images · 3.1 MB each · 3600 × 2400"
|
||||
|
||||
@@ -13,7 +13,7 @@ Mount MediaPage in DashboardShell with a verified session and a test router at /
|
||||
3. **Entry points**: Upload (desktop header), the mobile pinned button and "Upload an image" each click the hidden `input[type=file]` (multiple, exact accept list).
|
||||
4. **Drop overlay**: dragenter with Files shows the copy exactly; dragleave hides; drop starts uploads; a drag without Files shows nothing; mobile pinned button hidden while shown.
|
||||
5. **Queue**: 6 valid files give at most 2 XHRs open at once; order kept; header "Uploading 6 images" and "N of 6 done" counts settled rows; "Uploading 1 image" singular; progress 1.1 of 2.6 MB reads "Uploading 1.1 of 2.6 MB" with a 42 % bar; 201 gives "Added to your library"; a silent list reload follows; files added while open are appended.
|
||||
6. **Duplicate check**: GET `?hash=` precedes POST; a match gives the Duplicate row with exact text (oldest match named); slot released; "Use existing" drops the row and selects the id (bulk bar "1 selected", live region text), including the reset-to-All path; "Upload anyway" sends POST; crypto undefined and lookup failure skip straight to POST.
|
||||
6. **Duplicate check**: GET `?hash=...&page=1&size=100` precedes POST; a match gives the Duplicate row with exact text (oldest match named); slot released; "Use existing" drops the row and selects the id (bulk bar "1 selected", live region text), including the reset-to-All path; "Upload anyway" sends POST; crypto undefined and lookup failure skip straight to POST.
|
||||
7. **Server statuses**: 201, 413, 415, 400, 500, network error: row copy and icon exact; "Try again" re-sends only that file with no new GET `?hash=`.
|
||||
8. **Cancel**: row x aborts that XHR and removes the row, the next file starts; "Cancel all" aborts all and removes waiting rows; panel closes when empty.
|
||||
9. **Summary**: Examples-driven summary lines; "Upload finished" / "{n} images" and "Dismiss"; "Nothing was uploaded" with "3 files were rejected" and with "2 files need attention", no summary; live-region text; Dismiss removes every row.
|
||||
@@ -22,7 +22,7 @@ Mount MediaPage in DashboardShell with a verified session and a test router at /
|
||||
12. **Selection**: checkbox visibility (hover, focus, any selection, always on mobile); tick and Space toggle; Enter opens the panel and ticking does not; live-region texts for select, unselect and "Selection cleared"; the checkbox is not a tab stop; select-all states (unchecked, partial, checked) in the grid row and the table header (no row in the mobile card list); Escape closes the panel first and clears the selection next; Clear; selection cleared on q/tab/sort change, kept on view change.
|
||||
13. **Bulk bar**: copy, buttons, desktop and mobile placement classes, replaces the mobile pinned button.
|
||||
14. **Bulk delete**: dialog copy exact (title, description with the summed count, rows "Used in 2", "Used in 3", "Unused"), singular form; no usage request; in progress (one DELETE per id, buttons disabled, not dismissible); done (cards leave, selection empty, focus target, toast id and text, silent reload); partly failed (two removed, failed one stays selected, bar "1 selected", toast with Retry and no auto-dismiss, Retry re-sends only the failed DELETE); several failures title; 404 counted as deleted; all failed gives the inline alert.
|
||||
15. **Duplicates tab**: enabled, URL and request (`filter=duplicates`, limit=100), deep link, "Review duplicates" enabled and switching tab; sort and view toggle shown (sort sent with the request, toggle keeps the preference in the URL); grouping by sha256, group order, headers (including the SVG/unknown uploader case), column order, "Suggested" tag and footer, "Uploaded 14 Jun 2026 by Inès Duarte", "Used in 4 articles" opens the panel; suggested-copy Examples; empty state and "Back to all images"; badge absent at 0.
|
||||
15. **Duplicates tab**: enabled, URL and request (`filter=duplicates`, size=100, page 1, 2, ... until total is reached; amended by `features/media_pagination_screen.feature`), deep link, "Review duplicates" enabled and switching tab; sort and view toggle shown (sort sent with the request, toggle keeps the preference in the URL); grouping by sha256, group order, headers (including the SVG/unknown uploader case), column order, "Suggested" tag and footer, "Uploaded 14 Jun 2026 by Inès Duarte", "Used in 4 articles" opens the panel; suggested-copy Examples; empty state and "Back to all images"; badge absent at 0.
|
||||
16. **Merge**: dialog copy exact for floorboards.jpg and another kept copy, singular form; POST body `{ keep_id: 12, merge_ids: [5, 6] }`; in progress not dismissible; 200 gives group removed, toast id "media-merged-12" and text, live-region text, silent reload, "No duplicates" when last; 500 and network give the alert; 409 gives the alert and a reload; 404 gives the info toast and closes.
|
||||
17. **Global errors**: 401 and 403 from XHR upload, merge, delete and hash lookup call the same redirect handler as useApi; no local copy.
|
||||
18. **B1 regression**: media_browse specs updated for the superseded statements and the rest still passes.
|
||||
|
||||
@@ -26,8 +26,8 @@ const MaxImageSize int64 = 10485760
|
||||
const (
|
||||
MinFilenameRunes = 1
|
||||
MaxFilenameRunes = 100
|
||||
DefaultListLimit = 24
|
||||
MaxListLimit = 100
|
||||
DefaultListPage = 1
|
||||
DefaultListSize = 25
|
||||
)
|
||||
|
||||
var allowedContentTypes = map[string]bool{
|
||||
@@ -128,12 +128,12 @@ type ListQuery struct {
|
||||
Filter Filter
|
||||
Variants VariantsFilter
|
||||
Sort Sort
|
||||
Limit int
|
||||
Cursor string
|
||||
Page int
|
||||
Size int
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -152,7 +152,12 @@ func (q ListQuery) Validate() error {
|
||||
default:
|
||||
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
|
||||
}
|
||||
if q.Hash != "" && !sha256Pattern.MatchString(q.Hash) {
|
||||
|
||||
@@ -4,13 +4,9 @@ import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"encoding/base64"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const cursorPrefix = "offset:"
|
||||
|
||||
type MediaItem struct {
|
||||
Image image.Image
|
||||
UploadedBy string
|
||||
@@ -28,9 +24,11 @@ type MediaCounts struct {
|
||||
}
|
||||
|
||||
type MediaPage struct {
|
||||
Items []MediaItem
|
||||
NextCursor string
|
||||
Counts MediaCounts
|
||||
Items []MediaItem
|
||||
Total int
|
||||
Page int
|
||||
Size int
|
||||
Counts MediaCounts
|
||||
}
|
||||
|
||||
type ListMediaUseCase struct {
|
||||
@@ -48,10 +46,6 @@ func (l *ListMediaUseCase) List(query image.ListQuery) (MediaPage, error) {
|
||||
if err := query.Validate(); err != nil {
|
||||
return MediaPage{}, err
|
||||
}
|
||||
offset, err := decodeCursor(query.Cursor)
|
||||
if err != nil {
|
||||
return MediaPage{}, err
|
||||
}
|
||||
|
||||
usage, err := l.usageCounter()
|
||||
if err != nil {
|
||||
@@ -83,8 +77,9 @@ func (l *ListMediaUseCase) List(query image.ListQuery) (MediaPage, error) {
|
||||
}
|
||||
}
|
||||
|
||||
pageEnd := min(offset+query.Limit, len(matching))
|
||||
pageStart := min(offset, pageEnd)
|
||||
offset := (query.Page - 1) * query.Size
|
||||
pageStart := min(offset, len(matching))
|
||||
pageEnd := min(pageStart+query.Size, len(matching))
|
||||
names := newUploaderNames(l.userRepository)
|
||||
items := []MediaItem{}
|
||||
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}
|
||||
if pageEnd < len(matching) {
|
||||
page.NextCursor = encodeCursor(pageEnd)
|
||||
}
|
||||
return page, nil
|
||||
return MediaPage{Items: items, Total: len(matching), Page: query.Page, Size: query.Size, Counts: counts}, nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,7 @@ type mockImageRepo struct {
|
||||
images map[uint32]imageDomain.Image
|
||||
nextID uint32
|
||||
fail failSwitch
|
||||
listCalls int
|
||||
failDeleteFor map[uint32]error
|
||||
deleteCalls []uint32
|
||||
createCalls int
|
||||
@@ -169,6 +170,7 @@ func (m *mockImageRepo) Delete(id uint32) error {
|
||||
}
|
||||
|
||||
func (m *mockImageRepo) List(query imageDomain.SearchQuery) ([]imageDomain.Image, error) {
|
||||
m.listCalls++
|
||||
if err := m.fail.trigger("List"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -3,9 +3,6 @@ package test
|
||||
import (
|
||||
domainArticle "RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
@@ -20,38 +17,6 @@ func (r *mediaOrderRecordingArticleRepo) UpdateBody(id uint32, body string) (dom
|
||||
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) {
|
||||
env := newMediaEnv(t)
|
||||
env.seedImage(2, "floorboards.jpg", 20, "image/jpeg", 3, 2, hashB, 1)
|
||||
|
||||
@@ -32,6 +32,7 @@ const (
|
||||
type mediaEnv struct {
|
||||
articles *mockArticleRepo
|
||||
images *mockImageRepo
|
||||
variants *mockVariantFileRepo
|
||||
storage *mockImageStorage
|
||||
users *mockUserRepo
|
||||
purgeSettings *mockPurgeSettingsRepo
|
||||
@@ -48,6 +49,7 @@ func newMediaEnv(t *testing.T) *mediaEnv {
|
||||
env := &mediaEnv{
|
||||
articles: newMockArticleRepo(),
|
||||
images: newMockImageRepo(),
|
||||
variants: newMockVariantFileRepo(),
|
||||
storage: newMockImageStorage(),
|
||||
users: newMockUserRepo(),
|
||||
token: jwtauth.New("HS256", []byte("test-secret"), nil),
|
||||
@@ -72,7 +74,7 @@ func newMediaEnv(t *testing.T) *mediaEnv {
|
||||
GetUserUseCase: useCases.NewGetUserUseCase(env.users),
|
||||
CreateImageUseCase: useCases.NewCreateImageUseCase(env.images, env.storage, discardRecorder),
|
||||
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),
|
||||
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(env.images, env.storage, newMockVariantFileRepo(), discardRecorder),
|
||||
MergeMediaUseCase: merge,
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os/exec"
|
||||
"reflect"
|
||||
"regexp"
|
||||
@@ -35,9 +34,11 @@ func seedListingLibrary(env *mediaEnv) {
|
||||
}
|
||||
|
||||
type listBody struct {
|
||||
Items []map[string]any `json:"items"`
|
||||
NextCursor *string `json:"next_cursor"`
|
||||
Counts map[string]int `json:"counts"`
|
||||
Items []map[string]any `json:"items"`
|
||||
Total int `json:"total"`
|
||||
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) {
|
||||
@@ -155,11 +156,11 @@ func TestMediaListing(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
if got.NextCursor != nil {
|
||||
t.Errorf("expected next_cursor null, got %v", *got.NextCursor)
|
||||
if strings.Contains(raw, "next_cursor") {
|
||||
t.Errorf("expected no next_cursor in the raw body")
|
||||
}
|
||||
if !strings.Contains(raw, `"next_cursor":null`) {
|
||||
t.Errorf("expected next_cursor null in the raw body")
|
||||
if got.Total != 5 || got.Page != 1 || got.Size != 25 {
|
||||
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}) {
|
||||
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)
|
||||
seedListingLibrary(env)
|
||||
|
||||
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")
|
||||
for id := uint32(1); id <= 12; id++ {
|
||||
env.seedImage(id, "image.jpg", 10, "image/jpeg", 3, 2, hashA, 1)
|
||||
}
|
||||
|
||||
second, _ := env.list(t, "?limit=2&cursor="+url.QueryEscape(*first.NextCursor))
|
||||
expectIds(t, idsOf(second.Items), []uint32{3, 2})
|
||||
if second.NextCursor == nil || *second.NextCursor == "" {
|
||||
t.Fatalf("expected a next_cursor on page 2")
|
||||
}
|
||||
first, _ := env.list(t, "?size=5&page=1")
|
||||
second, _ := env.list(t, "?size=5&page=2")
|
||||
third, _ := env.list(t, "?size=5&page=3")
|
||||
|
||||
third, _ := env.list(t, "?limit=2&cursor="+url.QueryEscape(*second.NextCursor))
|
||||
expectIds(t, idsOf(third.Items), []uint32{1})
|
||||
if third.NextCursor != nil {
|
||||
t.Errorf("expected next_cursor null on the last page, got %v", *third.NextCursor)
|
||||
expectIds(t, idsOf(first.Items), []uint32{12, 11, 10, 9, 8})
|
||||
expectIds(t, idsOf(second.Items), []uint32{7, 6, 5, 4, 3})
|
||||
expectIds(t, idsOf(third.Items), []uint32{2, 1})
|
||||
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) {
|
||||
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) {
|
||||
env := newMediaEnv(t)
|
||||
seedListingLibrary(env)
|
||||
|
||||
@@ -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 +5,6 @@ import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
domain "RenewCMS/internal/domain/image"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"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.seedLibrary()
|
||||
|
||||
first, firstBody := env.listIds("?variants=with&sort=total_size&limit=2")
|
||||
cursor, _ := firstBody["next_cursor"].(string)
|
||||
second, secondBody := env.listIds("?variants=with&sort=total_size&limit=2&cursor=" + url.QueryEscape(cursor))
|
||||
ids, body := env.listIds("?variants=with&sort=total_size&size=5&page=1")
|
||||
beyond, _ := env.listIds("?variants=with&sort=total_size&size=5&page=2")
|
||||
|
||||
if !slices.Equal(first, []float64{1, 4}) || cursor == "" || !slices.Equal(second, []float64{2}) || secondBody["next_cursor"] != nil {
|
||||
t.Errorf("first = %v (%q), second = %v (%v)", first, cursor, second, secondBody["next_cursor"])
|
||||
if !slices.Equal(ids, []float64{1, 4, 2}) || body["total"] != float64(3) || len(beyond) != 0 {
|
||||
t.Errorf("ids = %v, total = %v, beyond = %v", ids, body["total"], beyond)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
import { t, translate } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import type { ApiField } from '@/data/apiCatalogue'
|
||||
@@ -70,7 +70,7 @@ const columns = computed(() => [
|
||||
{{ row.original.required ? t('Yes') : t('No') }}
|
||||
</template>
|
||||
<template #rule-cell="{ row }">
|
||||
<span class="text-pretty">{{ row.original.rule }}</span>
|
||||
<span class="text-pretty">{{ translate(row.original.rule) }}</span>
|
||||
</template>
|
||||
</UTable>
|
||||
</template>
|
||||
@@ -17,7 +17,7 @@ const activeTab = ref('fetch')
|
||||
|
||||
const examples = computed(() => endpointExamples(props.endpoint, props.origin))
|
||||
const rowLabel = computed(
|
||||
() => `${props.endpoint.method} ${props.endpoint.path}. ${props.endpoint.description}`,
|
||||
() => `${props.endpoint.method} ${props.endpoint.path}. ${translate(props.endpoint.description)}`,
|
||||
)
|
||||
const bodyTitle = computed(() => {
|
||||
if (props.endpoint.bodyKind === 'multipart') {
|
||||
@@ -62,7 +62,7 @@ const snippetTabs = computed(() => [
|
||||
class="size-4 shrink-0 text-muted lg:hidden"
|
||||
/>
|
||||
</span>
|
||||
<span class="min-w-0 text-toned lg:truncate">{{ endpoint.description }}</span>
|
||||
<span class="min-w-0 text-toned lg:truncate">{{ translate(endpoint.description) }}</span>
|
||||
<span class="flex flex-wrap gap-1.5">
|
||||
<UBadge
|
||||
:label="translate(endpoint.access)"
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
sizeRejectionText,
|
||||
validateImageFile,
|
||||
} from '@/lib/imageUpload'
|
||||
import { toHashRequestQuery } from '@/lib/mediaQuery'
|
||||
import { libraryMatchText } from '@/lib/mediaFormat'
|
||||
import { insertedUrl } from '@/lib/variants/variantOptions'
|
||||
import { UNDERLINE_TAB_TRIGGER } from '@/lib/tabStyles'
|
||||
@@ -349,7 +350,9 @@ async function findExisting(file: File): Promise<MediaItem | null | 'handled'> {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const response = await request<{ items: MediaItem[] }>(`/v1/media/?hash=${hash}`)
|
||||
const response = await request<{ items: MediaItem[] }>(
|
||||
`/v1/media/?${toHashRequestQuery(hash)}`,
|
||||
)
|
||||
return oldestOf(response.items) ?? null
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && classifyMediaError(error) === 'handled') {
|
||||
@@ -796,7 +799,7 @@ onUnmounted(() => {
|
||||
:status="library.status.value"
|
||||
:show-skeleton="library.showSkeleton.value"
|
||||
:loading-more="library.loadingMore.value"
|
||||
:has-more="library.nextCursor.value !== null"
|
||||
:has-more="library.hasMore.value"
|
||||
:total="library.total.value"
|
||||
:query="library.query.value"
|
||||
:first-new-id="library.firstNewId.value"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { t, translate } from '@/i18n'
|
||||
|
||||
import ApiRouteRow from '@/components/ApiRouteRow.vue'
|
||||
import type { PublicRow, SessionGroup } from '@/data/apiCatalogue'
|
||||
import { LOGIN_FAMILY_CAPTION, type PublicRow, type SessionGroup } from '@/data/apiCatalogue'
|
||||
import { CHIP_CLASS } from '@/lib/apiReference'
|
||||
|
||||
defineProps<{
|
||||
@@ -10,6 +10,13 @@ defineProps<{
|
||||
publicRows: PublicRow[]
|
||||
}>()
|
||||
|
||||
function describe(description: string) {
|
||||
const suffix = `. ${LOGIN_FAMILY_CAPTION}`
|
||||
return description.endsWith(suffix)
|
||||
? `${translate(description.slice(0, -suffix.length))}. ${translate(LOGIN_FAMILY_CAPTION)}`
|
||||
: translate(description)
|
||||
}
|
||||
|
||||
const SUBGROUP_TITLE_CLASS =
|
||||
'px-3.5 pt-2 pb-1 text-xs font-medium tracking-wide text-muted uppercase'
|
||||
</script>
|
||||
@@ -36,7 +43,7 @@ const SUBGROUP_TITLE_CLASS =
|
||||
:key="`${row.method} ${row.path}`"
|
||||
:method="row.method"
|
||||
:path="row.path"
|
||||
:description="row.description"
|
||||
:description="describe(row.description)"
|
||||
data-session-row
|
||||
>
|
||||
<UBadge
|
||||
@@ -59,7 +66,7 @@ const SUBGROUP_TITLE_CLASS =
|
||||
:key="`${row.method} ${row.path}`"
|
||||
:method="row.method"
|
||||
:path="row.path"
|
||||
:description="row.description"
|
||||
:description="translate(row.description)"
|
||||
data-public-row
|
||||
>
|
||||
<UBadge
|
||||
|
||||
@@ -115,11 +115,11 @@ describe('The library has images, so Library opens', () => {
|
||||
expect(skeletonTiles()).toHaveLength(9)
|
||||
})
|
||||
|
||||
it('asks for 24 images and nothing else', async () => {
|
||||
it('asks for the first page of 25 images and nothing else', async () => {
|
||||
await openLibrary()
|
||||
|
||||
expect(listCalls()).toHaveLength(1)
|
||||
expect(listCalls()[0]!.toString()).toBe('limit=24')
|
||||
expect(listCalls()[0]!.toString()).toBe('page=1&size=25')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -197,7 +197,7 @@ describe('A search is active, so the count is not shown', () => {
|
||||
})
|
||||
|
||||
describe('Load more is hidden on the last page', () => {
|
||||
it('does not show "Load more" without a cursor', async () => {
|
||||
it('does not show "Load more" once every matching image is loaded', async () => {
|
||||
await openLibrary({ list: () => pageBody(fixture(), null) })
|
||||
|
||||
expect(findButton('Load more')).toBeUndefined()
|
||||
@@ -373,7 +373,7 @@ describe('Insert', () => {
|
||||
})
|
||||
|
||||
describe('Search', () => {
|
||||
it('searches after a 120 ms debounce, with no cursor, and clears the selection', async () => {
|
||||
it('searches after a 120 ms debounce, on page 1, and clears the selection', async () => {
|
||||
await openWithFakeTimers()
|
||||
tile('kitchen-after.webp').click()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
@@ -388,8 +388,8 @@ describe('Search', () => {
|
||||
|
||||
expect(listCalls()).toHaveLength(2)
|
||||
expect(listCalls()[1]!.get('q')).toBe('wall')
|
||||
expect(listCalls()[1]!.get('limit')).toBe('24')
|
||||
expect(listCalls()[1]!.has('cursor')).toBe(false)
|
||||
expect(listCalls()[1]!.get('size')).toBe('25')
|
||||
expect(listCalls()[1]!.get('page')).toBe('1')
|
||||
expect(findButton('Insert image')?.disabled).toBe(true)
|
||||
})
|
||||
|
||||
@@ -419,7 +419,7 @@ describe('Search', () => {
|
||||
expect(searchField().value).toBe('')
|
||||
const last = listCalls().at(-1)!
|
||||
expect(last.has('q')).toBe(false)
|
||||
expect(last.get('limit')).toBe('24')
|
||||
expect(last.get('size')).toBe('25')
|
||||
})
|
||||
|
||||
it('shows the no results panel and "Clear search" empties the field', async () => {
|
||||
@@ -447,7 +447,7 @@ describe('Load more', () => {
|
||||
it('shows progress, appends the new tiles and moves focus to the first one', async () => {
|
||||
const more = deferred<unknown>()
|
||||
await openWithFakeTimers({
|
||||
list: (query) => (query.get('cursor') ? more.promise : pageBody()),
|
||||
list: (query) => (query.get('page') === '2' ? more.promise : pageBody()),
|
||||
})
|
||||
const button = findButton('Load more')!
|
||||
button.focus()
|
||||
@@ -456,8 +456,8 @@ describe('Load more', () => {
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
const last = listCalls().at(-1)!
|
||||
expect(last.get('cursor')).toBe('c24')
|
||||
expect(last.get('limit')).toBe('24')
|
||||
expect(last.get('page')).toBe('2')
|
||||
expect(last.get('size')).toBe('25')
|
||||
const loading = findButton('Loading…')!
|
||||
expect(loading.getAttribute('aria-disabled')).toBe('true')
|
||||
expect(document.activeElement).toBe(loading)
|
||||
@@ -475,7 +475,10 @@ describe('Load more', () => {
|
||||
|
||||
it('draws a row of 3 skeleton tiles on mobile', async () => {
|
||||
await openWithFakeTimers(
|
||||
{ list: (query) => (query.get('cursor') ? new Promise(() => undefined) : pageBody()) },
|
||||
{
|
||||
list: (query) =>
|
||||
query.get('page') === '2' ? new Promise(() => undefined) : pageBody(),
|
||||
},
|
||||
MOBILE_WIDTH,
|
||||
)
|
||||
|
||||
@@ -485,9 +488,9 @@ describe('Load more', () => {
|
||||
expect(skeletonTiles()).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('combines the cursor with the search', async () => {
|
||||
it('combines the next page with the search', async () => {
|
||||
await openLibrary({
|
||||
list: (query) => pageBody(query.get('q') ? fixture([1, 2]) : fixture(), 'c24'),
|
||||
list: (query) => pageBody(query.get('q') ? fixture([1, 2]) : fixture(), 48),
|
||||
})
|
||||
await searchFor('wall')
|
||||
|
||||
@@ -495,8 +498,8 @@ describe('Load more', () => {
|
||||
|
||||
const last = listCalls().at(-1)!
|
||||
expect(last.get('q')).toBe('wall')
|
||||
expect(last.get('limit')).toBe('24')
|
||||
expect(last.get('cursor')).toBe('c24')
|
||||
expect(last.get('size')).toBe('25')
|
||||
expect(last.get('page')).toBe('2')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -11,14 +11,11 @@ import { fixture, textOf } from '@/views/__tests__/mediaTestKit'
|
||||
|
||||
export { textOf }
|
||||
|
||||
export const FIRST_PAGE_CURSOR = 'c24'
|
||||
export const FIRST_PAGE_TOTAL = 48
|
||||
export const PICKER_COUNTS = { all: 48, unused: 2, duplicates: 2 }
|
||||
|
||||
export function pageBody(
|
||||
items: MediaItem[] = fixture(),
|
||||
next_cursor: string | null = FIRST_PAGE_CURSOR,
|
||||
) {
|
||||
return { items, next_cursor, counts: PICKER_COUNTS }
|
||||
export function pageBody(items: MediaItem[] = fixture(), total: number | null = FIRST_PAGE_TOTAL) {
|
||||
return { items, total: total ?? items.length, page: 1, size: 25, counts: PICKER_COUNTS }
|
||||
}
|
||||
|
||||
export function secondPage() {
|
||||
|
||||
@@ -17,6 +17,8 @@ const CHIP =
|
||||
const props = defineProps<{
|
||||
name: string
|
||||
comparable: boolean
|
||||
originalSrc?: string | null
|
||||
variantSrc?: string | null
|
||||
}>()
|
||||
|
||||
const position = defineModel<number>({ default: 50 })
|
||||
@@ -64,7 +66,13 @@ function onKeydown(event: KeyboardEvent) {
|
||||
class="absolute inset-0 flex items-center justify-center bg-(--ui-color-secondary-200) text-muted dark:bg-(--ui-color-secondary-300)"
|
||||
:class="PATTERN"
|
||||
>
|
||||
<UIcon name="i-lucide-image" class="size-8 opacity-55" />
|
||||
<img
|
||||
v-if="originalSrc"
|
||||
:src="originalSrc"
|
||||
alt=""
|
||||
class="absolute inset-0 size-full object-cover"
|
||||
/>
|
||||
<UIcon v-else name="i-lucide-image" class="size-8 opacity-55" />
|
||||
</div>
|
||||
<template v-if="comparable">
|
||||
<div
|
||||
@@ -72,7 +80,14 @@ function onKeydown(event: KeyboardEvent) {
|
||||
class="absolute inset-0 bg-accented"
|
||||
:class="VARIANT_PATTERN"
|
||||
:style="{ clipPath: `inset(0 0 0 ${position}%)` }"
|
||||
/>
|
||||
>
|
||||
<img
|
||||
v-if="variantSrc"
|
||||
:src="variantSrc"
|
||||
alt=""
|
||||
class="absolute inset-0 size-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<span :class="[CHIP, 'left-2']">{{ t('Original') }}</span>
|
||||
<span :class="[CHIP, 'right-2 font-mono']">{{ shownName }}</span>
|
||||
<input
|
||||
|
||||
@@ -4,7 +4,13 @@ import { t } from '@/i18n'
|
||||
import { computed, ref } from 'vue'
|
||||
import ImageCompare from '@/components/variants/ImageCompare.vue'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { SAMPLE_SOURCE, estimateLabel, estimateVariant } from '@/lib/variants/variantEstimate'
|
||||
import { useVariantRender } from '@/composables/useVariantRender'
|
||||
import {
|
||||
SAMPLE_SOURCE,
|
||||
estimateLabel,
|
||||
estimateVariant,
|
||||
type EstimateRequest,
|
||||
} from '@/lib/variants/variantEstimate'
|
||||
import { parseDimension, sliderValue, type VariantFormValues } from '@/lib/variants/variantForm'
|
||||
import { formatVariantSize } from '@/lib/variants/variantFormat'
|
||||
|
||||
@@ -25,22 +31,31 @@ const dimensionsAreValid = computed(
|
||||
(props.values.height.trim() === '' || parseDimension(props.values.height) !== null),
|
||||
)
|
||||
|
||||
const estimate = computed(() =>
|
||||
const request = computed<EstimateRequest | null>(() =>
|
||||
dimensionsAreValid.value
|
||||
? estimateVariant({
|
||||
? {
|
||||
width: parseDimension(props.values.width),
|
||||
height: parseDimension(props.values.height),
|
||||
fit: props.values.fit,
|
||||
format: props.values.format,
|
||||
quality: sliderValue(props.values.quality),
|
||||
})
|
||||
}
|
||||
: null,
|
||||
)
|
||||
|
||||
const estimate = computed(() => (request.value ? estimateVariant(request.value) : null))
|
||||
const { original, variant } = useVariantRender(request)
|
||||
|
||||
const shownName = computed(() => props.values.name || t('name'))
|
||||
const outputLine = computed(() =>
|
||||
estimate.value ? estimateLabel(estimate.value) : t('No output yet'),
|
||||
)
|
||||
const outputLine = computed(() => {
|
||||
if (!estimate.value) {
|
||||
return t('No output yet')
|
||||
}
|
||||
const bytes = variant.value
|
||||
? Math.min(SAMPLE_SOURCE.bytes, variant.value.bytes)
|
||||
: estimate.value.bytes
|
||||
return estimateLabel({ size: estimate.value.size, bytes })
|
||||
})
|
||||
const originalLine = computed(
|
||||
() =>
|
||||
`${SAMPLE_SOURCE.width} × ${SAMPLE_SOURCE.height} · ${formatVariantSize(SAMPLE_SOURCE.bytes)}`,
|
||||
@@ -67,7 +82,13 @@ async function onCopy() {
|
||||
t('Sample: {0}', [SAMPLE_SOURCE.filename])
|
||||
}}</span>
|
||||
</div>
|
||||
<ImageCompare v-model="position" :name="values.name" :comparable="estimate !== null" />
|
||||
<ImageCompare
|
||||
v-model="position"
|
||||
:name="values.name"
|
||||
:comparable="estimate !== null"
|
||||
:original-src="original"
|
||||
:variant-src="variant?.url ?? null"
|
||||
/>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="flex flex-col">
|
||||
<span class="font-medium text-highlighted">{{ t('Original') }}</span>
|
||||
|
||||
@@ -26,10 +26,12 @@ function item(id: number, overrides: Partial<MediaItem> = {}): MediaItem {
|
||||
}
|
||||
}
|
||||
|
||||
function listBody(items: MediaItem[]) {
|
||||
function listBody(items: MediaItem[], paging: { total?: number; page?: number } = {}) {
|
||||
return {
|
||||
items,
|
||||
next_cursor: null,
|
||||
total: paging.total ?? items.length,
|
||||
page: paging.page ?? 1,
|
||||
size: 25,
|
||||
counts: { all: items.length, unused: 1, duplicates: 0 },
|
||||
}
|
||||
}
|
||||
@@ -57,20 +59,99 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('useMedia list state', () => {
|
||||
it('requests the list with the controls and limit=100 and stores items and counts', async () => {
|
||||
it('requests the list with the controls, page and size and stores items and counts', async () => {
|
||||
request.mockResolvedValue(listBody([item(1), item(2)]))
|
||||
const media = useMedia()
|
||||
|
||||
await media.load({ ...DEFAULT_CONTROLS, q: 'wall', filter: 'unused' })
|
||||
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
'/v1/media/?q=wall&filter=unused&sort=newest&limit=100',
|
||||
'/v1/media/?q=wall&filter=unused&sort=newest&page=1&size=25',
|
||||
)
|
||||
expect(media.items.value.map((entry) => entry.id)).toEqual([1, 2])
|
||||
expect(media.counts.value).toEqual({ all: 2, unused: 1, duplicates: 0 })
|
||||
expect(media.status.value).toBe('ready')
|
||||
})
|
||||
|
||||
it('sends the page and the size it was given and stores the total', async () => {
|
||||
request.mockResolvedValue(listBody([item(1)], { total: 312, page: 7 }))
|
||||
const media = useMedia()
|
||||
|
||||
const outcome = await media.load({ ...DEFAULT_CONTROLS, page: 7, size: 50 })
|
||||
|
||||
expect(request).toHaveBeenCalledWith('/v1/media/?filter=all&sort=newest&page=7&size=50')
|
||||
expect(media.total.value).toBe(312)
|
||||
expect(outcome).toEqual({ kind: 'loaded', page: 7, redirected: false })
|
||||
})
|
||||
|
||||
it('asks for the last page when the page is past the end and keeps the old items until then', async () => {
|
||||
request.mockResolvedValueOnce(listBody([], { total: 312, page: 99 }))
|
||||
request.mockResolvedValueOnce(listBody([item(1)], { total: 312, page: 13 }))
|
||||
const media = useMedia()
|
||||
|
||||
const outcome = await media.load({ ...DEFAULT_CONTROLS, page: 99 })
|
||||
|
||||
expect(request).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/v1/media/?filter=all&sort=newest&page=13&size=25',
|
||||
)
|
||||
expect(outcome).toEqual({ kind: 'loaded', page: 13, redirected: true })
|
||||
expect(media.items.value).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps page 1 and no redirect when the total is zero', async () => {
|
||||
request.mockResolvedValueOnce(listBody([], { total: 0, page: 5 }))
|
||||
const media = useMedia()
|
||||
|
||||
const outcome = await media.load({ ...DEFAULT_CONTROLS, page: 5 })
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(1)
|
||||
expect(outcome).toEqual({ kind: 'loaded', page: 1, redirected: false })
|
||||
})
|
||||
|
||||
it('loads the whole duplicate set at size 100 page after page', async () => {
|
||||
const first = Array.from({ length: 100 }, (_, index) => item(index + 1))
|
||||
const second = Array.from({ length: 30 }, (_, index) => item(index + 101))
|
||||
request.mockResolvedValueOnce(listBody(first, { total: 130 }))
|
||||
request.mockResolvedValueOnce(listBody(second, { total: 130, page: 2 }))
|
||||
const media = useMedia()
|
||||
|
||||
await media.load({ ...DEFAULT_CONTROLS, filter: 'duplicates', page: 4, size: 5 })
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(2)
|
||||
expect(request).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/v1/media/?filter=duplicates&sort=newest&page=1&size=100',
|
||||
)
|
||||
expect(request).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/v1/media/?filter=duplicates&sort=newest&page=2&size=100',
|
||||
)
|
||||
expect(media.items.value).toHaveLength(130)
|
||||
})
|
||||
|
||||
it('requests no second duplicate page when the first one holds everything', async () => {
|
||||
request.mockResolvedValueOnce(listBody([item(1), item(2)]))
|
||||
const media = useMedia()
|
||||
|
||||
await media.load({ ...DEFAULT_CONTROLS, filter: 'duplicates' })
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps the loaded items and the ready status when a page change fails with keepOnError', async () => {
|
||||
request.mockResolvedValueOnce(listBody([item(1)], { total: 60 }))
|
||||
const media = useMedia()
|
||||
await media.load(DEFAULT_CONTROLS)
|
||||
|
||||
request.mockRejectedValueOnce(new ApiError(500))
|
||||
const outcome = await media.load({ ...DEFAULT_CONTROLS, page: 2 }, { keepOnError: true })
|
||||
|
||||
expect(outcome).toEqual({ kind: 'failed', handled: false })
|
||||
expect(media.status.value).toBe('ready')
|
||||
expect(media.items.value).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('shows the skeleton only after 150 ms of loading', async () => {
|
||||
const pending = deferred<unknown>()
|
||||
request.mockReturnValue(pending.promise)
|
||||
@@ -184,7 +265,7 @@ describe('useMedia requests', () => {
|
||||
|
||||
const others = await media.findCopies(item(12, { sha256: 'abc123' }))
|
||||
|
||||
expect(request).toHaveBeenCalledWith('/v1/media/?hash=abc123')
|
||||
expect(request).toHaveBeenCalledWith('/v1/media/?hash=abc123&page=1&size=100')
|
||||
expect(others.map((entry) => entry.id)).toEqual([5, 6])
|
||||
})
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
import { useActivity, type PersonOptions } from '@/composables/useActivity'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { ACTIVITY_PAGE_SIZE_KEY, resolvePageSize, storePageSize } from '@/lib/pageSizeMemory'
|
||||
import {
|
||||
activeChips,
|
||||
clearChip,
|
||||
defaultFilters,
|
||||
DEFAULT_PAGE_SIZE,
|
||||
PAGE_SIZES,
|
||||
summaryRangeChanged,
|
||||
type ActivityFilters,
|
||||
type ActivityItem,
|
||||
@@ -45,11 +48,20 @@ function delayedFlag() {
|
||||
|
||||
export function useActivityPage() {
|
||||
const session = useSessionStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { listActivity, getSummary, listPeople } = useActivity()
|
||||
|
||||
const filters = ref<ActivityFilters>(defaultFilters())
|
||||
const page = ref(1)
|
||||
const size = ref(DEFAULT_PAGE_SIZE)
|
||||
const size = ref(
|
||||
resolvePageSize(
|
||||
route.query.size,
|
||||
ACTIVITY_PAGE_SIZE_KEY,
|
||||
[...PAGE_SIZES],
|
||||
DEFAULT_PAGE_SIZE,
|
||||
),
|
||||
)
|
||||
const items = ref<ActivityItem[]>([])
|
||||
const total = ref(0)
|
||||
const listState = ref<ListState>('idle')
|
||||
@@ -192,9 +204,20 @@ export function useActivityPage() {
|
||||
fetchPage(next)
|
||||
}
|
||||
|
||||
function syncSizeQuery() {
|
||||
const { size: current, ...rest } = route.query
|
||||
const wanted = size.value === DEFAULT_PAGE_SIZE ? undefined : String(size.value)
|
||||
if (current === wanted) return
|
||||
router.replace({ query: wanted === undefined ? rest : { ...rest, size: wanted } })
|
||||
}
|
||||
|
||||
syncSizeQuery()
|
||||
|
||||
function setSize(next: number) {
|
||||
if (next === size.value) return
|
||||
size.value = next
|
||||
storePageSize(ACTIVITY_PAGE_SIZE_KEY, next)
|
||||
syncSizeQuery()
|
||||
fetchPage(1)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { tryOnBeforeUnmount } from '@vueuse/core'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import {
|
||||
@@ -9,21 +9,22 @@ import {
|
||||
} from '@/composables/useMedia'
|
||||
import { useSeenImagesStore } from '@/stores/seenImages'
|
||||
|
||||
export const LIBRARY_PAGE_LIMIT = 24
|
||||
export const LIBRARY_PAGE_SIZE = 25
|
||||
const FIRST_PAGE = 1
|
||||
|
||||
export type LibraryStatus = 'idle' | 'loading' | 'ready' | 'error' | 'handled'
|
||||
|
||||
interface LibraryResponse {
|
||||
items: MediaItem[]
|
||||
next_cursor: string | null
|
||||
total: number
|
||||
counts: MediaCounts
|
||||
}
|
||||
|
||||
function requestQuery(query: string, cursor: string | null) {
|
||||
function requestQuery(query: string, page: number) {
|
||||
return [
|
||||
...(query ? [`q=${encodeURIComponent(query)}`] : []),
|
||||
`limit=${LIBRARY_PAGE_LIMIT}`,
|
||||
...(cursor ? [`cursor=${encodeURIComponent(cursor)}`] : []),
|
||||
`page=${page}`,
|
||||
`size=${LIBRARY_PAGE_SIZE}`,
|
||||
].join('&')
|
||||
}
|
||||
|
||||
@@ -33,7 +34,8 @@ export function useLibraryList() {
|
||||
|
||||
const items = ref<MediaItem[]>([])
|
||||
const total = ref(0)
|
||||
const nextCursor = ref<string | null>(null)
|
||||
const matching = ref(0)
|
||||
const loadedPages = ref(0)
|
||||
const status = ref<LibraryStatus>('idle')
|
||||
const query = ref('')
|
||||
const showSkeleton = ref(false)
|
||||
@@ -41,6 +43,7 @@ export function useLibraryList() {
|
||||
const firstNewId = ref<number | null>(null)
|
||||
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let latest = 0
|
||||
const hasMore = computed(() => items.value.length < matching.value)
|
||||
|
||||
function clearSkeletonTimer() {
|
||||
if (skeletonTimer !== null) {
|
||||
@@ -66,7 +69,8 @@ export function useLibraryList() {
|
||||
latest += 1
|
||||
items.value = []
|
||||
total.value = 0
|
||||
nextCursor.value = null
|
||||
matching.value = 0
|
||||
loadedPages.value = 0
|
||||
status.value = 'idle'
|
||||
query.value = ''
|
||||
loadingMore.value = false
|
||||
@@ -79,21 +83,23 @@ export function useLibraryList() {
|
||||
const ticket = latest
|
||||
query.value = nextQuery
|
||||
items.value = []
|
||||
nextCursor.value = null
|
||||
matching.value = 0
|
||||
loadedPages.value = 0
|
||||
loadingMore.value = false
|
||||
firstNewId.value = null
|
||||
status.value = 'loading'
|
||||
startSkeletonTimer()
|
||||
try {
|
||||
const response = await request<LibraryResponse>(
|
||||
`/v1/media/?${requestQuery(nextQuery, null)}`,
|
||||
`/v1/media/?${requestQuery(nextQuery, FIRST_PAGE)}`,
|
||||
)
|
||||
if (ticket !== latest) {
|
||||
return
|
||||
}
|
||||
items.value = response.items
|
||||
total.value = response.counts.all
|
||||
nextCursor.value = response.next_cursor
|
||||
matching.value = response.total
|
||||
loadedPages.value = FIRST_PAGE
|
||||
seen.remember(response.items)
|
||||
status.value = 'ready'
|
||||
} catch (error) {
|
||||
@@ -109,7 +115,7 @@ export function useLibraryList() {
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (nextCursor.value === null || loadingMore.value || status.value !== 'ready') {
|
||||
if (!hasMore.value || loadingMore.value || status.value !== 'ready') {
|
||||
return
|
||||
}
|
||||
const ticket = latest
|
||||
@@ -117,7 +123,7 @@ export function useLibraryList() {
|
||||
startSkeletonTimer()
|
||||
try {
|
||||
const response = await request<LibraryResponse>(
|
||||
`/v1/media/?${requestQuery(query.value, nextCursor.value)}`,
|
||||
`/v1/media/?${requestQuery(query.value, loadedPages.value + 1)}`,
|
||||
)
|
||||
if (ticket !== latest) {
|
||||
return
|
||||
@@ -125,7 +131,8 @@ export function useLibraryList() {
|
||||
const known = new Set(items.value.map((item) => item.id))
|
||||
const added = response.items.filter((item) => !known.has(item.id))
|
||||
items.value = [...items.value, ...added]
|
||||
nextCursor.value = response.next_cursor
|
||||
matching.value = response.total
|
||||
loadedPages.value += 1
|
||||
firstNewId.value = added[0]?.id ?? null
|
||||
seen.remember(added)
|
||||
} catch {
|
||||
@@ -156,7 +163,7 @@ export function useLibraryList() {
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
nextCursor,
|
||||
hasMore,
|
||||
status,
|
||||
query,
|
||||
showSkeleton,
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { ref } from 'vue'
|
||||
import { tryOnBeforeUnmount } from '@vueuse/core'
|
||||
import { ApiError, useApi } from '@/composables/useApi'
|
||||
import { toRequestQuery, type MediaControls } from '@/lib/mediaQuery'
|
||||
import {
|
||||
MEDIA_FULL_SET_PAGE_SIZE,
|
||||
isFullSet,
|
||||
toHashRequestQuery,
|
||||
toRequestQuery,
|
||||
type MediaControls,
|
||||
} from '@/lib/mediaQuery'
|
||||
import { pageCountFor } from '@/lib/pagination'
|
||||
import { bumpMediaRevision } from '@/lib/mediaRevision'
|
||||
|
||||
export interface MediaItem {
|
||||
@@ -44,12 +51,24 @@ export interface BulkDeleteResult {
|
||||
handled: boolean
|
||||
}
|
||||
|
||||
interface MediaListResponse {
|
||||
export interface MediaListResponse {
|
||||
items: MediaItem[]
|
||||
next_cursor: string | null
|
||||
total: number
|
||||
page: number
|
||||
size: number
|
||||
counts: MediaCounts
|
||||
}
|
||||
|
||||
export type MediaLoadOutcome =
|
||||
| { kind: 'loaded'; page: number; redirected: boolean }
|
||||
| { kind: 'failed'; handled: boolean }
|
||||
| { kind: 'stale' }
|
||||
|
||||
interface MediaLoadOptions {
|
||||
silent?: boolean
|
||||
keepOnError?: boolean
|
||||
}
|
||||
|
||||
export type MediaLoadStatus = 'loading' | 'ready' | 'error' | 'handled'
|
||||
export type MediaFailure = 'handled' | 'not-found' | 'failed'
|
||||
|
||||
@@ -68,10 +87,19 @@ export function classifyMediaError(error: unknown): MediaFailure {
|
||||
return 'failed'
|
||||
}
|
||||
|
||||
function isPastLastPage(response: MediaListResponse, page: number, size: number) {
|
||||
return (
|
||||
response.items.length === 0 &&
|
||||
response.total > 0 &&
|
||||
page > pageCountFor(response.total, size)
|
||||
)
|
||||
}
|
||||
|
||||
export function useMedia() {
|
||||
const { request } = useApi()
|
||||
|
||||
const items = ref<MediaItem[]>([])
|
||||
const total = ref(0)
|
||||
const counts = ref<MediaCounts>({ all: 0, unused: 0, duplicates: 0 })
|
||||
const status = ref<MediaLoadStatus>('loading')
|
||||
const showSkeleton = ref(false)
|
||||
@@ -94,7 +122,39 @@ export function useMedia() {
|
||||
}, MEDIA_SKELETON_DELAY_MS)
|
||||
}
|
||||
|
||||
async function load(controls: MediaControls, options: { silent?: boolean } = {}) {
|
||||
function requestPage(controls: MediaControls) {
|
||||
return request<MediaListResponse>(`/v1/media/?${toRequestQuery(controls)}`)
|
||||
}
|
||||
|
||||
async function requestFullSet(controls: MediaControls) {
|
||||
const query = { ...controls, page: 1, size: MEDIA_FULL_SET_PAGE_SIZE }
|
||||
const first = await requestPage(query)
|
||||
const loaded = [...first.items]
|
||||
let latest = first
|
||||
while (loaded.length < first.total && latest.items.length > 0) {
|
||||
query.page += 1
|
||||
latest = await requestPage(query)
|
||||
loaded.push(...latest.items)
|
||||
}
|
||||
return { ...latest, items: loaded, total: loaded.length, page: 1 }
|
||||
}
|
||||
|
||||
async function requestList(controls: MediaControls) {
|
||||
if (isFullSet(controls)) {
|
||||
return { response: await requestFullSet(controls), page: 1, redirected: false }
|
||||
}
|
||||
const response = await requestPage(controls)
|
||||
if (!isPastLastPage(response, controls.page, controls.size)) {
|
||||
return { response, page: response.total === 0 ? 1 : controls.page, redirected: false }
|
||||
}
|
||||
const page = pageCountFor(response.total, controls.size)
|
||||
return { response: await requestPage({ ...controls, page }), page, redirected: true }
|
||||
}
|
||||
|
||||
async function load(
|
||||
controls: MediaControls,
|
||||
options: MediaLoadOptions = {},
|
||||
): Promise<MediaLoadOutcome> {
|
||||
const silent = options.silent === true
|
||||
if (!silent) {
|
||||
latestLoad += 1
|
||||
@@ -102,20 +162,24 @@ export function useMedia() {
|
||||
}
|
||||
const ticket = latestLoad
|
||||
try {
|
||||
const response = await request<MediaListResponse>(
|
||||
`/v1/media/?${toRequestQuery(controls)}`,
|
||||
)
|
||||
const { response, page, redirected } = await requestList(controls)
|
||||
if (ticket !== latestLoad) {
|
||||
return
|
||||
return { kind: 'stale' }
|
||||
}
|
||||
items.value = response.items
|
||||
total.value = response.total
|
||||
counts.value = response.counts
|
||||
status.value = 'ready'
|
||||
return { kind: 'loaded', page, redirected }
|
||||
} catch (error) {
|
||||
if (silent || ticket !== latestLoad) {
|
||||
return
|
||||
if (ticket !== latestLoad) {
|
||||
return { kind: 'stale' }
|
||||
}
|
||||
status.value = classifyMediaError(error) === 'handled' ? 'handled' : 'error'
|
||||
const handled = classifyMediaError(error) === 'handled'
|
||||
if (!silent) {
|
||||
status.value = handled ? 'handled' : options.keepOnError ? 'ready' : 'error'
|
||||
}
|
||||
return { kind: 'failed', handled }
|
||||
} finally {
|
||||
if (!silent && ticket === latestLoad) {
|
||||
clearSkeletonTimer()
|
||||
@@ -129,7 +193,9 @@ export function useMedia() {
|
||||
}
|
||||
|
||||
async function findCopies(item: MediaItem) {
|
||||
const response = await request<MediaListResponse>(`/v1/media/?hash=${item.sha256}`)
|
||||
const response = await request<MediaListResponse>(
|
||||
`/v1/media/?${toHashRequestQuery(item.sha256)}`,
|
||||
)
|
||||
return response.items.filter((copy) => copy.id !== item.id)
|
||||
}
|
||||
|
||||
@@ -204,6 +270,7 @@ export function useMedia() {
|
||||
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
counts,
|
||||
status,
|
||||
showSkeleton,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { redirectToLogin, redirectUnverified, useApi } from '@/composables/useApi'
|
||||
import { classifyMediaError, type MediaItem } from '@/composables/useMedia'
|
||||
import { useRetryAfter } from '@/composables/useRetryAfter'
|
||||
import { toHashRequestQuery } from '@/lib/mediaQuery'
|
||||
import { hashFile } from '@/lib/fileHash'
|
||||
import { extensionMatchesType, validateImageFile } from '@/lib/imageUpload'
|
||||
import { describeUpload, parseRetryAfter } from '@/lib/mediaUploadRows'
|
||||
@@ -135,7 +136,9 @@ export function useMediaUpload(options: UploadOptions = {}) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const response = await request<{ items: MediaItem[] }>(`/v1/media/?hash=${hash}`)
|
||||
const response = await request<{ items: MediaItem[] }>(
|
||||
`/v1/media/?${toHashRequestQuery(hash)}`,
|
||||
)
|
||||
return oldestOf(response.items) ?? null
|
||||
} catch (error) {
|
||||
return classifyMediaError(error) === 'handled' ? 'halted' : null
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { onScopeDispose, ref, watch, type Ref } from 'vue'
|
||||
import sampleUrl from '@/assets/variant-sample.jpg'
|
||||
import { SAMPLE_SOURCE, type EstimateRequest } from '@/lib/variants/variantEstimate'
|
||||
import { outputSize } from '@/lib/variants/variantOutput'
|
||||
import { canRender, drawPlan, flattensTransparency, mimeFor } from '@/lib/variants/variantRender'
|
||||
|
||||
const RENDER_DELAY_MS = 150
|
||||
const PERCENT = 100
|
||||
|
||||
export interface RenderedVariant {
|
||||
url: string
|
||||
bytes: number
|
||||
}
|
||||
|
||||
let sampleImage: Promise<HTMLImageElement | null> | null = null
|
||||
|
||||
function loadSample() {
|
||||
sampleImage ??= new Promise((resolve) => {
|
||||
const image = new Image()
|
||||
image.onload = () => resolve(image)
|
||||
image.onerror = () => resolve(null)
|
||||
image.src = sampleUrl
|
||||
})
|
||||
return sampleImage
|
||||
}
|
||||
|
||||
function encode(canvas: HTMLCanvasElement, request: EstimateRequest) {
|
||||
const mime = mimeFor(request.format)
|
||||
const quality = mime === 'image/png' ? undefined : request.quality / PERCENT
|
||||
return new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, mime, quality))
|
||||
}
|
||||
|
||||
async function renderVariant(image: HTMLImageElement, request: EstimateRequest) {
|
||||
const size = outputSize(SAMPLE_SOURCE, request)
|
||||
if (size === null || !canRender(size)) {
|
||||
return null
|
||||
}
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = size.width
|
||||
canvas.height = size.height
|
||||
const context = canvas.getContext('2d')
|
||||
if (context === null) {
|
||||
return null
|
||||
}
|
||||
if (flattensTransparency(request.format)) {
|
||||
context.fillStyle = 'white'
|
||||
context.fillRect(0, 0, size.width, size.height)
|
||||
}
|
||||
const plan = drawPlan(SAMPLE_SOURCE, size, request.fit)
|
||||
context.drawImage(image, plan.sx, plan.sy, plan.sw, plan.sh, plan.dx, plan.dy, plan.dw, plan.dh)
|
||||
return encode(canvas, request)
|
||||
}
|
||||
|
||||
export function useVariantRender(request: Ref<EstimateRequest | null>) {
|
||||
const original = ref<string | null>(null)
|
||||
const variant = ref<RenderedVariant | null>(null)
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let token = 0
|
||||
|
||||
function release() {
|
||||
if (variant.value) {
|
||||
URL.revokeObjectURL(variant.value.url)
|
||||
variant.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function run(current: EstimateRequest | null, ticket: number) {
|
||||
const image = await loadSample()
|
||||
if (image === null || ticket !== token) {
|
||||
return
|
||||
}
|
||||
original.value = sampleUrl
|
||||
const blob = current === null ? null : await renderVariant(image, current)
|
||||
if (ticket !== token) {
|
||||
return
|
||||
}
|
||||
release()
|
||||
if (blob !== null) {
|
||||
variant.value = { url: URL.createObjectURL(blob), bytes: blob.size }
|
||||
}
|
||||
}
|
||||
|
||||
function schedule(current: EstimateRequest | null) {
|
||||
clearTimeout(timer)
|
||||
const ticket = ++token
|
||||
timer = setTimeout(() => void run(current, ticket), RENDER_DELAY_MS)
|
||||
}
|
||||
|
||||
watch(request, schedule, { immediate: true, deep: true })
|
||||
|
||||
onScopeDispose(() => {
|
||||
clearTimeout(timer)
|
||||
token++
|
||||
release()
|
||||
})
|
||||
|
||||
return { original, variant }
|
||||
}
|
||||
@@ -284,9 +284,9 @@ describe('Response samples use the real domain JSON keys', () => {
|
||||
expect(parsed(find('PATCH /v1/article/{id}/unpublish')).is_online).toBe(false)
|
||||
})
|
||||
|
||||
it('uses items, next_cursor and counts for the media list', () => {
|
||||
it('uses items, total, page, size and counts for the media list, with no next_cursor', () => {
|
||||
const list = parsed(find('GET /v1/media/'))
|
||||
expect(keysOf(list)).toEqual(['counts', 'items', 'next_cursor'])
|
||||
expect(keysOf(list)).toEqual(['counts', 'items', 'page', 'size', 'total'])
|
||||
expect(keysOf(list.counts)).toEqual(['all', 'duplicates', 'unused'])
|
||||
expectOnlyKeys(list.items[0], MEDIA_ITEM_KEYS)
|
||||
})
|
||||
@@ -418,18 +418,20 @@ describe('Request bodies and query parameters', () => {
|
||||
expect(Array.isArray(JSON.parse(list.response))).toBe(false)
|
||||
})
|
||||
|
||||
it('lists q, hash, cursor, filter, sort and limit on the media list only', () => {
|
||||
it('lists q, hash, filter, sort, variants, page and size on the media list only', () => {
|
||||
expect(find('GET /v1/media/').query.map((param) => param.name)).toEqual([
|
||||
'q',
|
||||
'hash',
|
||||
'cursor',
|
||||
'filter',
|
||||
'sort',
|
||||
'limit',
|
||||
'variants',
|
||||
'page',
|
||||
'size',
|
||||
])
|
||||
expect(find('GET /v1/media/').query.find((param) => param.name === 'limit')!.type).toBe(
|
||||
expect(find('GET /v1/media/').query.find((param) => param.name === 'size')!.type).toBe(
|
||||
'number',
|
||||
)
|
||||
expect(find('GET /v1/media/').errors.map((error) => error.status)).toContain(400)
|
||||
expect(find('POST /v1/media/').query).toEqual([])
|
||||
expect(find('GET /v1/settings').query).toEqual([])
|
||||
})
|
||||
|
||||
@@ -95,10 +95,24 @@ const FORMAT_QUERY: ApiField = {
|
||||
rule: 'markdown, html or json. Default markdown.',
|
||||
}
|
||||
|
||||
const PAGE_QUERY: ApiField = {
|
||||
name: 'page',
|
||||
type: 'number',
|
||||
required: false,
|
||||
rule: 'A whole number from 1. Default 1.',
|
||||
}
|
||||
|
||||
const SIZE_QUERY: ApiField = {
|
||||
name: 'size',
|
||||
type: 'number',
|
||||
required: false,
|
||||
rule: '5, 25, 50 or 100. Default 25.',
|
||||
}
|
||||
|
||||
const ARTICLE_LIST_QUERY: ApiField[] = [
|
||||
FORMAT_QUERY,
|
||||
{ name: 'page', type: 'number', required: false, rule: 'A whole number from 1. Default 1.' },
|
||||
{ name: 'size', type: 'number', required: false, rule: '5, 25, 50 or 100. Default 25.' },
|
||||
PAGE_QUERY,
|
||||
SIZE_QUERY,
|
||||
{
|
||||
name: 'q',
|
||||
type: 'string',
|
||||
@@ -122,12 +136,6 @@ const MEDIA_QUERY: ApiField[] = [
|
||||
required: false,
|
||||
rule: 'SHA-256 of the content, lowercased.',
|
||||
},
|
||||
{
|
||||
name: 'cursor',
|
||||
type: 'string',
|
||||
required: false,
|
||||
rule: 'The next_cursor of the previous page.',
|
||||
},
|
||||
{
|
||||
name: 'filter',
|
||||
type: 'string',
|
||||
@@ -138,9 +146,16 @@ const MEDIA_QUERY: ApiField[] = [
|
||||
name: 'sort',
|
||||
type: 'string',
|
||||
required: false,
|
||||
rule: 'newest, oldest, name or largest. Default newest.',
|
||||
rule: 'newest, oldest, name, largest or total_size. Default newest.',
|
||||
},
|
||||
{ name: 'limit', type: 'number', required: false, rule: '1 to 100. Default 24.' },
|
||||
{
|
||||
name: 'variants',
|
||||
type: 'string',
|
||||
required: false,
|
||||
rule: 'with or without. Default both.',
|
||||
},
|
||||
PAGE_QUERY,
|
||||
SIZE_QUERY,
|
||||
]
|
||||
|
||||
const json = (value: unknown) => JSON.stringify(value, null, 2)
|
||||
@@ -423,7 +438,9 @@ export const API_GROUPS: ApiGroup[] = [
|
||||
verified([e(400, 'The media query is invalid.'), e(401, UNAUTHORIZED)]),
|
||||
json({
|
||||
items: [mediaItem()],
|
||||
next_cursor: null,
|
||||
total: 1,
|
||||
page: 1,
|
||||
size: 25,
|
||||
counts: { all: 1, unused: 0, duplicates: 0 },
|
||||
}),
|
||||
{ query: MEDIA_QUERY },
|
||||
|
||||
@@ -1593,6 +1593,98 @@ export const messages = {
|
||||
Request: 'Requête',
|
||||
'The variant image. Generated on first request.':
|
||||
'L’image de la variante. Générée à la première requête.',
|
||||
'markdown, html or json. Default markdown.': 'markdown, html ou json. Par défaut markdown.',
|
||||
'A whole number from 1. Default 1.': 'Un nombre entier à partir de 1. Par défaut 1.',
|
||||
'5, 25, 50 or 100. Default 25.': '5, 25, 50 ou 100. Par défaut 25.',
|
||||
'Up to 200 characters, matched anywhere in the title, ignoring case and accents.':
|
||||
'200 caractères maximum, recherchés n’importe où dans le titre, sans tenir compte de la casse ni des accents.',
|
||||
'online or draft. Default both.': 'online ou draft. Par défaut les deux.',
|
||||
'field:direction, with field title, status, created_at or updated_at and direction asc or desc. Default 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é.',
|
||||
'SHA-256 of the content, lowercased.': 'SHA-256 du contenu, en minuscules.',
|
||||
'all, unused or duplicates. Default all.': 'all, unused ou duplicates. Par défaut all.',
|
||||
'newest, oldest, name, largest or total_size. Default newest.':
|
||||
'newest, oldest, name, largest ou total_size. Par défaut newest.',
|
||||
'with or without. Default both.': 'with ou without. Par défaut les deux.',
|
||||
"Read, write and publish articles. Titles can't be changed after creation.":
|
||||
'Lire, écrire et publier des articles. Les titres ne peuvent pas être modifiés après la création.',
|
||||
"3 to 200 characters. Can't be changed later.":
|
||||
'De 3 à 200 caractères. Ne peut pas être modifié ensuite.',
|
||||
'Up to 10 000 characters.': '10 000 caractères maximum.',
|
||||
'The image library. One image can be used in many articles.':
|
||||
'La médiathèque. Une même image peut servir dans plusieurs articles.',
|
||||
'PNG, JPEG, WEBP or SVG.': 'PNG, JPEG, WEBP ou SVG.',
|
||||
'The new filename.': 'Le nouveau nom de fichier.',
|
||||
'Greater than 0. The image to keep.': 'Supérieur à 0. L’image à conserver.',
|
||||
'At least 1, each greater than 0. Identical copies to delete. Articles switch to the kept image.':
|
||||
'Au moins 1, chacun supérieur à 0. Copies identiques à supprimer. Les articles passent à l’image conservée.',
|
||||
'Leave out to keep the current value.': 'À omettre pour conserver la valeur actuelle.',
|
||||
'1 to 365. Leave out to keep the current value.':
|
||||
'De 1 à 365. À omettre pour conserver la valeur actuelle.',
|
||||
'The fingerprint of the preview. Without it, the purge runs on the current library.':
|
||||
'L’empreinte de l’aperçu. Sans elle, la purge s’applique à la médiathèque actuelle.',
|
||||
'People who can sign in, and pending invitations.':
|
||||
'Les personnes qui peuvent se connecter, et les invitations en attente.',
|
||||
'Up to 60 characters.': '60 caractères maximum.',
|
||||
'A valid email address.': 'Une adresse e-mail valide.',
|
||||
'Read the site settings, including the activity log retention.':
|
||||
'Lire les paramètres du site, dont la conservation du journal d’activité.',
|
||||
'Hello world.': 'Hello world.',
|
||||
'Whether registration is open.': 'Indique si les inscriptions sont ouvertes.',
|
||||
'Set a new password with the emailed code.':
|
||||
'Définir un nouveau mot de passe avec le code reçu par e-mail.',
|
||||
'Check an invite code.': 'Vérifier un code d’invitation.',
|
||||
'Accept an invite.': 'Accepter une invitation.',
|
||||
'Plain text "ok". Outside /v1.': 'Texte brut « ok ». En dehors de /v1.',
|
||||
'Update status': 'État de la mise à jour',
|
||||
'Check for update': 'Rechercher une mise à jour',
|
||||
'Download update': 'Télécharger la mise à jour',
|
||||
Register: 'Inscription',
|
||||
'Verify email': 'Vérifier l’e-mail',
|
||||
'Reset password': 'Réinitialiser le mot de passe',
|
||||
'Change the activity log retention': 'Modifier la conservation du journal d’activité',
|
||||
'List activity events': 'Lister les événements d’activité',
|
||||
'Activity summary, owner and admin': 'Résumé de l’activité, propriétaire et administrateur',
|
||||
'Export activity as CSV, owner and admin':
|
||||
'Exporter l’activité en CSV, propriétaire et administrateur',
|
||||
'List keys': 'Lister les clés',
|
||||
'Create a key': 'Créer une clé',
|
||||
'Revoke a key': 'Révoquer une clé',
|
||||
'List one page of articles, newest update first, with the total and the tab counts. Anonymous callers get online articles only.':
|
||||
'Liste une page d’articles, du plus récemment modifié au plus ancien, avec le total et les compteurs des onglets. Les appels anonymes ne reçoivent que les articles publiés.',
|
||||
'Get one article. Drafts answer 404 to anonymous callers.':
|
||||
'Récupère un article. Les brouillons répondent 404 aux appels anonymes.',
|
||||
'Create a draft article.': 'Crée un article en brouillon.',
|
||||
'Update the body of an article.': 'Modifie le corps d’un article.',
|
||||
'Publish an article.': 'Publie un article.',
|
||||
'Unpublish an article.': 'Dépublie un article.',
|
||||
'Delete an article.': 'Supprime un article.',
|
||||
'List images.': 'Liste les images.',
|
||||
'Upload an image (multipart).': 'Téléverse une image (multipart).',
|
||||
'Rename an image. The URL stays the same.': 'Renomme une image. L’URL reste la même.',
|
||||
'Delete an image. Articles that use it show a broken image.':
|
||||
'Supprime une image. Les articles qui l’utilisent affichent une image cassée.',
|
||||
'Merge identical copies into one image.': 'Fusionne les copies identiques en une seule image.',
|
||||
'List the articles that use an image.': 'Liste les articles qui utilisent une image.',
|
||||
'Get the automatic duplicate purge settings.':
|
||||
'Récupère les paramètres de la purge automatique des doublons.',
|
||||
'Turn the automatic purge on or off, or change its interval.':
|
||||
'Active ou désactive la purge automatique, ou modifie son intervalle.',
|
||||
'Preview what a purge would merge. Nothing changes.':
|
||||
'Prévisualise ce qu’une purge fusionnerait. Rien n’est modifié.',
|
||||
'Merge every group of identical copies. Send the preview fingerprint to refuse a changed library.':
|
||||
'Fusionne chaque groupe de copies identiques. Envoyez l’empreinte de l’aperçu pour refuser une médiathèque modifiée.',
|
||||
'List users and invites.': 'Liste les utilisateurs et les invitations.',
|
||||
'Update my profile.': 'Modifie mon profil.',
|
||||
'Invite a user by email.': 'Invite un utilisateur par e-mail.',
|
||||
'Cancel a pending invite.': 'Annule une invitation en attente.',
|
||||
'Renew an expired invite.': 'Renouvelle une invitation expirée.',
|
||||
"Get the current user. With a key, the key's owner.":
|
||||
'Récupère l’utilisateur courant. Avec une clé, son propriétaire.',
|
||||
'Get settings.': 'Récupère les paramètres.',
|
||||
'Made for the browser. A key is ignored here, with no 403.':
|
||||
'Prévue pour le navigateur. Une clé est ignorée ici, sans 403.',
|
||||
} as const
|
||||
|
||||
export type MessageKey = keyof typeof messages
|
||||
@@ -136,7 +136,9 @@ describe('Switch language without losing work', () => {
|
||||
}
|
||||
return {
|
||||
items,
|
||||
next_cursor: null,
|
||||
total: items.length,
|
||||
page: 1,
|
||||
size: 25,
|
||||
counts: { all: items.length, unused: items.length, duplicates: 0 },
|
||||
}
|
||||
})
|
||||
@@ -518,13 +520,21 @@ describe('Hardener localization outcomes', () => {
|
||||
duplicate: false,
|
||||
},
|
||||
],
|
||||
next_cursor: null,
|
||||
total: 1,
|
||||
page: 1,
|
||||
size: 25,
|
||||
counts: { all: 1, unused: 1, duplicates: 0 },
|
||||
})
|
||||
const listing = await request()
|
||||
request.mockImplementation(async (path: string) =>
|
||||
path.includes('?hash=')
|
||||
? { items: [], next_cursor: null, counts: { all: 0, unused: 0, duplicates: 0 } }
|
||||
? {
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
size: 25,
|
||||
counts: { all: 0, unused: 0, duplicates: 0 },
|
||||
}
|
||||
: listing,
|
||||
)
|
||||
await mountRoute('/media')
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { DEFAULT_CONTROLS, parseMediaControls, toRequestQuery, toRouteQuery } from '../mediaQuery'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
DEFAULT_CONTROLS,
|
||||
parseMediaControls,
|
||||
toHashRequestQuery,
|
||||
toRequestQuery,
|
||||
toRouteQuery,
|
||||
} from '../mediaQuery'
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
describe('parseMediaControls', () => {
|
||||
it('uses the defaults for an empty query', () => {
|
||||
@@ -21,6 +35,34 @@ describe('parseMediaControls', () => {
|
||||
expect(parseMediaControls(query)).toEqual({ ...DEFAULT_CONTROLS, ...expected })
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{ page: '7' }, { page: 7 }],
|
||||
[{ size: '50' }, { size: 50 }],
|
||||
[
|
||||
{ page: '3', size: '5' },
|
||||
{ page: 3, size: 5 },
|
||||
],
|
||||
[{ page: '0' }, {}],
|
||||
[{ page: '-2' }, {}],
|
||||
[{ page: '1.5' }, {}],
|
||||
[{ page: 'abc' }, {}],
|
||||
[{ size: '10' }, {}],
|
||||
[{ size: '0' }, {}],
|
||||
[{ size: '200' }, {}],
|
||||
[{ size: '' }, {}],
|
||||
])('maps the paging of %j', (query, expected) => {
|
||||
expect(parseMediaControls(query)).toEqual({ ...DEFAULT_CONTROLS, ...expected })
|
||||
})
|
||||
|
||||
it('uses the stored size only when the URL has no size', () => {
|
||||
localStorage.setItem('renewcms-page-size-media', '5')
|
||||
|
||||
expect(parseMediaControls({}).size).toBe(5)
|
||||
expect(parseMediaControls({ size: '100' }).size).toBe(100)
|
||||
expect(parseMediaControls({ size: '10' }).size).toBe(25)
|
||||
expect(parseMediaControls({}, false).size).toBe(25)
|
||||
})
|
||||
|
||||
it('takes the first value of a repeated parameter and trims the search', () => {
|
||||
expect(parseMediaControls({ q: [' wall ', 'x'] }).q).toBe('wall')
|
||||
})
|
||||
@@ -33,20 +75,45 @@ describe('toRouteQuery', () => {
|
||||
|
||||
it('keeps only the non-default values', () => {
|
||||
expect(
|
||||
toRouteQuery({ q: 'wall', filter: 'unused', sort: 'largest', view: 'list' }),
|
||||
toRouteQuery({
|
||||
...DEFAULT_CONTROLS,
|
||||
q: 'wall',
|
||||
filter: 'unused',
|
||||
sort: 'largest',
|
||||
view: 'list',
|
||||
}),
|
||||
).toEqual({ q: 'wall', filter: 'unused', sort: 'largest', view: 'list' })
|
||||
})
|
||||
|
||||
it('keeps the page and the size when they are not the defaults', () => {
|
||||
expect(toRouteQuery({ ...DEFAULT_CONTROLS, page: 7, size: 50 })).toEqual({
|
||||
page: '7',
|
||||
size: '50',
|
||||
})
|
||||
})
|
||||
|
||||
it('drops the page and the size on the Duplicates tab', () => {
|
||||
expect(
|
||||
toRouteQuery({ ...DEFAULT_CONTROLS, filter: 'duplicates', page: 4, size: 5 }),
|
||||
).toEqual({ filter: 'duplicates' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('toRequestQuery', () => {
|
||||
it.each([
|
||||
[{}, 'filter=all&sort=newest&limit=100'],
|
||||
[{ filter: 'unused' }, 'filter=unused&sort=newest&limit=100'],
|
||||
[{ filter: 'duplicates' }, 'filter=duplicates&sort=newest&limit=100'],
|
||||
[{ sort: 'largest' }, 'filter=all&sort=largest&limit=100'],
|
||||
[{ q: 'wall' }, 'q=wall&filter=all&sort=newest&limit=100'],
|
||||
[{ q: 'a b&c', filter: 'unused' }, 'q=a%20b%26c&filter=unused&sort=newest&limit=100'],
|
||||
[{}, 'filter=all&sort=newest&page=1&size=25'],
|
||||
[{ filter: 'unused' }, 'filter=unused&sort=newest&page=1&size=25'],
|
||||
[{ filter: 'duplicates' }, 'filter=duplicates&sort=newest&page=1&size=25'],
|
||||
[{ sort: 'largest' }, 'filter=all&sort=largest&page=1&size=25'],
|
||||
[{ q: 'wall' }, 'q=wall&filter=all&sort=newest&page=1&size=25'],
|
||||
[{ q: 'a b&c', filter: 'unused' }, 'q=a%20b%26c&filter=unused&sort=newest&page=1&size=25'],
|
||||
])('builds the request for %j', (overrides, expected) => {
|
||||
expect(toRequestQuery({ ...DEFAULT_CONTROLS, ...overrides })).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('toHashRequestQuery', () => {
|
||||
it('asks for every copy in one page', () => {
|
||||
expect(toHashRequestQuery('abc')).toBe('hash=abc&page=1&size=100')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { readStoredPageSize, resolvePageSize, storePageSize } from '../pageSizeMemory'
|
||||
|
||||
const KEY = 'renewcms-page-size-articles'
|
||||
const SIZES = [5, 25, 50, 100]
|
||||
|
||||
describe('pageSizeMemory', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('reads null when nothing is stored', () => {
|
||||
expect(readStoredPageSize(KEY, SIZES)).toBeNull()
|
||||
})
|
||||
|
||||
it.each(['5', '25', '50', '100'])('reads the valid stored size %s', (raw) => {
|
||||
localStorage.setItem(KEY, raw)
|
||||
expect(readStoredPageSize(KEY, SIZES)).toBe(Number(raw))
|
||||
})
|
||||
|
||||
it.each(['10', 'abc', '0', '-5', '25.5', '', '5.0', ' 5'])('treats %j as absent', (raw) => {
|
||||
localStorage.setItem(KEY, raw)
|
||||
expect(readStoredPageSize(KEY, SIZES)).toBeNull()
|
||||
})
|
||||
|
||||
it('stores the bare number', () => {
|
||||
storePageSize(KEY, 50)
|
||||
expect(localStorage.getItem(KEY)).toBe('50')
|
||||
})
|
||||
|
||||
it('reads as absent when getItem throws', () => {
|
||||
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
||||
throw new Error('denied')
|
||||
})
|
||||
expect(readStoredPageSize(KEY, SIZES)).toBeNull()
|
||||
})
|
||||
|
||||
it('swallows a setItem that throws', () => {
|
||||
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||
throw new Error('quota')
|
||||
})
|
||||
expect(() => storePageSize(KEY, 50)).not.toThrow()
|
||||
})
|
||||
|
||||
it('copes with localStorage being undefined', () => {
|
||||
vi.stubGlobal('localStorage', undefined)
|
||||
expect(readStoredPageSize(KEY, SIZES)).toBeNull()
|
||||
expect(() => storePageSize(KEY, 50)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolvePageSize', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('uses the stored size when the query has none', () => {
|
||||
localStorage.setItem(KEY, '5')
|
||||
expect(resolvePageSize(undefined, KEY, SIZES, 25)).toBe(5)
|
||||
})
|
||||
|
||||
it('falls back when nothing valid is stored', () => {
|
||||
expect(resolvePageSize(undefined, KEY, SIZES, 25)).toBe(25)
|
||||
})
|
||||
|
||||
it('lets a valid query size win over the stored one', () => {
|
||||
localStorage.setItem(KEY, '5')
|
||||
expect(resolvePageSize('100', KEY, SIZES, 25)).toBe(100)
|
||||
})
|
||||
|
||||
it.each(['10', '', 'abc', ['5']])(
|
||||
'falls back to the default for %j, ignoring storage',
|
||||
(raw) => {
|
||||
localStorage.setItem(KEY, '5')
|
||||
expect(resolvePageSize(raw, KEY, SIZES, 25)).toBe(25)
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { LocationQuery } from 'vue-router'
|
||||
import { MEDIA_PAGE_SIZE_KEY, resolvePageSize } from '@/lib/pageSizeMemory'
|
||||
|
||||
export type MediaFilter = 'all' | 'unused' | 'duplicates'
|
||||
export type MediaSort = 'newest' | 'oldest' | 'name' | 'largest' | 'total_size'
|
||||
@@ -11,9 +12,15 @@ export interface MediaControls {
|
||||
sort: MediaSort
|
||||
variants: MediaVariantsFilter
|
||||
view: MediaView
|
||||
page: number
|
||||
size: number
|
||||
}
|
||||
|
||||
export const MEDIA_PAGE_LIMIT = 100
|
||||
export const MEDIA_PAGE_SIZES = [5, 25, 50, 100]
|
||||
export const DEFAULT_MEDIA_PAGE_SIZE = 25
|
||||
export const MEDIA_FULL_SET_PAGE_SIZE = 100
|
||||
const FIRST_PAGE = 1
|
||||
const POSITIVE_INTEGER = /^[1-9]\d*$/
|
||||
|
||||
export const DEFAULT_CONTROLS: MediaControls = {
|
||||
q: '',
|
||||
@@ -21,6 +28,8 @@ export const DEFAULT_CONTROLS: MediaControls = {
|
||||
sort: 'newest',
|
||||
variants: 'all',
|
||||
view: 'grid',
|
||||
page: FIRST_PAGE,
|
||||
size: DEFAULT_MEDIA_PAGE_SIZE,
|
||||
}
|
||||
|
||||
const FILTERS: MediaFilter[] = ['all', 'unused', 'duplicates']
|
||||
@@ -36,18 +45,57 @@ function pick<T extends string>(allowed: T[], value: string, fallback: T) {
|
||||
return (allowed as string[]).includes(value) ? (value as T) : fallback
|
||||
}
|
||||
|
||||
export function parseMediaControls(query: LocationQuery): MediaControls {
|
||||
function parsePage(value: string) {
|
||||
return POSITIVE_INTEGER.test(value) ? Number(value) : FIRST_PAGE
|
||||
}
|
||||
|
||||
function parseSize(value: LocationQuery[string] | undefined, useStoredSize: boolean) {
|
||||
const first = Array.isArray(value) ? value[0] : value
|
||||
const raw = first === null ? '' : first
|
||||
if (raw === undefined && !useStoredSize) {
|
||||
return DEFAULT_MEDIA_PAGE_SIZE
|
||||
}
|
||||
return resolvePageSize(raw, MEDIA_PAGE_SIZE_KEY, MEDIA_PAGE_SIZES, DEFAULT_MEDIA_PAGE_SIZE)
|
||||
}
|
||||
|
||||
export function parseMediaControls(query: LocationQuery, useStoredSize = true): MediaControls {
|
||||
return {
|
||||
q: firstValue(query.q).trim(),
|
||||
filter: pick(FILTERS, firstValue(query.filter), DEFAULT_CONTROLS.filter),
|
||||
sort: pick(SORTS, firstValue(query.sort), DEFAULT_CONTROLS.sort),
|
||||
variants: pick(VARIANTS_FILTERS, firstValue(query.variants), DEFAULT_CONTROLS.variants),
|
||||
view: pick(VIEWS, firstValue(query.view), DEFAULT_CONTROLS.view),
|
||||
page: parsePage(firstValue(query.page)),
|
||||
size: parseSize(query.size, useStoredSize),
|
||||
}
|
||||
}
|
||||
|
||||
export function isFullSet(controls: MediaControls) {
|
||||
return controls.filter === 'duplicates'
|
||||
}
|
||||
|
||||
export function sameMediaControls(left: MediaControls, right: MediaControls) {
|
||||
const samePaging =
|
||||
(isFullSet(left) && isFullSet(right)) ||
|
||||
(left.page === right.page && left.size === right.size)
|
||||
return (
|
||||
left.q === right.q &&
|
||||
left.filter === right.filter &&
|
||||
left.sort === right.sort &&
|
||||
left.variants === right.variants &&
|
||||
left.view === right.view &&
|
||||
samePaging
|
||||
)
|
||||
}
|
||||
|
||||
export function toRouteQuery(controls: MediaControls) {
|
||||
const query: Record<string, string> = {}
|
||||
if (!isFullSet(controls) && controls.page !== DEFAULT_CONTROLS.page) {
|
||||
query.page = String(controls.page)
|
||||
}
|
||||
if (!isFullSet(controls) && controls.size !== DEFAULT_CONTROLS.size) {
|
||||
query.size = String(controls.size)
|
||||
}
|
||||
if (controls.q) {
|
||||
query.q = controls.q
|
||||
}
|
||||
@@ -72,7 +120,12 @@ export function toRequestQuery(controls: MediaControls) {
|
||||
`filter=${controls.filter}`,
|
||||
`sort=${controls.sort}`,
|
||||
...(controls.variants === 'all' ? [] : [`variants=${controls.variants}`]),
|
||||
`limit=${MEDIA_PAGE_LIMIT}`,
|
||||
`page=${controls.page}`,
|
||||
`size=${controls.size}`,
|
||||
]
|
||||
return parts.join('&')
|
||||
}
|
||||
|
||||
export function toHashRequestQuery(hash: string) {
|
||||
return `hash=${hash}&page=${FIRST_PAGE}&size=${MEDIA_FULL_SET_PAGE_SIZE}`
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export const ARTICLES_PAGE_SIZE_KEY = 'renewcms-page-size-articles'
|
||||
export const ACTIVITY_PAGE_SIZE_KEY = 'renewcms-page-size-activity'
|
||||
export const MEDIA_PAGE_SIZE_KEY = 'renewcms-page-size-media'
|
||||
|
||||
export function readStoredPageSize(key: string, validSizes: number[]) {
|
||||
try {
|
||||
const raw = localStorage.getItem(key)
|
||||
return validSizes.find((size) => String(size) === raw) ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function storePageSize(key: string, size: number) {
|
||||
try {
|
||||
localStorage.setItem(key, String(size))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
export function resolvePageSize(raw: unknown, key: string, validSizes: number[], fallback: number) {
|
||||
if (raw === undefined) {
|
||||
return readStoredPageSize(key, validSizes) ?? fallback
|
||||
}
|
||||
return validSizes.find((size) => String(size) === raw) ?? fallback
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from '../variantEstimate'
|
||||
import { outputSize } from '../variantOutput'
|
||||
|
||||
const SAMPLE = { width: 4032, height: 3024 }
|
||||
const SAMPLE = { width: 1500, height: 1000 }
|
||||
|
||||
function request(overrides: Partial<EstimateRequest>): EstimateRequest {
|
||||
return {
|
||||
@@ -28,11 +28,11 @@ function labelOf(overrides: Partial<EstimateRequest>) {
|
||||
describe('Output size, never enlarged', () => {
|
||||
it.each([
|
||||
[1200, 630, 'cover', '1200×630'],
|
||||
[800, null, 'cover', '800×600'],
|
||||
[null, 2400, 'inside', '3200×2400'],
|
||||
[800, null, 'cover', '800×533'],
|
||||
[null, 2400, 'inside', '1500×1000'],
|
||||
[600, 600, 'contain', '600×600'],
|
||||
[1000, 1000, 'inside', '1000×750'],
|
||||
[8000, 8000, 'cover', '4032×3024'],
|
||||
[1000, 1000, 'inside', '1000×667'],
|
||||
[8000, 8000, 'cover', '1500×1000'],
|
||||
] as const)('width %s height %s fit %s gives %s', (width, height, fit, expected) => {
|
||||
const size = outputSize(SAMPLE, { width, height, fit })
|
||||
|
||||
@@ -61,10 +61,10 @@ describe('Output size, never enlarged', () => {
|
||||
|
||||
describe('Size estimate', () => {
|
||||
it.each([
|
||||
['hero', 1200, 630, 'cover', 'webp', 82, '1200×630 · about 96 KB'],
|
||||
['hero', 1200, 630, 'cover', 'webp', 70, '1200×630 · about 71 KB'],
|
||||
['hero', 1200, 630, 'cover', 'png', 82, '1200×630 · about 310 KB'],
|
||||
['thumb', 400, 300, 'cover', 'webp', 80, '400×300 · about 18 KB'],
|
||||
['hero', 1200, 630, 'cover', 'webp', 82, '1200×630 · about 30 KB'],
|
||||
['hero', 1200, 630, 'cover', 'webp', 70, '1200×630 · about 22 KB'],
|
||||
['hero', 1200, 630, 'cover', 'png', 82, '1200×630 · about 84 KB'],
|
||||
['thumb', 400, 300, 'cover', 'webp', 80, '400×300 · about 6 KB'],
|
||||
] as const)(
|
||||
'%s %s×%s %s %s q%s reads %s',
|
||||
(_name, width, height, fit, format, quality, expected) => {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { canRender, drawPlan, flattensTransparency, mimeFor } from '../variantRender'
|
||||
|
||||
const SOURCE = { width: 1500, height: 1000 }
|
||||
|
||||
describe('Variant render', () => {
|
||||
it.each([
|
||||
['png', 'image/png'],
|
||||
['webp', 'image/webp'],
|
||||
['jpeg', 'image/jpeg'],
|
||||
['original', 'image/jpeg'],
|
||||
] as const)('encodes %s as %s', (format, mime) => {
|
||||
expect(mimeFor(format)).toBe(mime)
|
||||
})
|
||||
|
||||
it('crops the centre of the source to fill a wider box', () => {
|
||||
const plan = drawPlan(SOURCE, { width: 1200, height: 400 }, 'cover')
|
||||
|
||||
expect(plan).toEqual({
|
||||
sx: 0,
|
||||
sy: 250,
|
||||
sw: 1500,
|
||||
sh: 500,
|
||||
dx: 0,
|
||||
dy: 0,
|
||||
dw: 1200,
|
||||
dh: 400,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the whole source when the ratio already matches', () => {
|
||||
const plan = drawPlan(SOURCE, { width: 750, height: 500 }, 'inside')
|
||||
|
||||
expect([plan.sx, plan.sy, plan.sw, plan.sh]).toEqual([0, 0, 1500, 1000])
|
||||
expect([plan.dx, plan.dy, plan.dw, plan.dh]).toEqual([0, 0, 750, 500])
|
||||
})
|
||||
|
||||
it('letterboxes the whole source inside a contain box', () => {
|
||||
const plan = drawPlan(SOURCE, { width: 600, height: 600 }, 'contain')
|
||||
|
||||
expect(plan).toEqual({
|
||||
sx: 0,
|
||||
sy: 0,
|
||||
sw: 1500,
|
||||
sh: 1000,
|
||||
dx: 0,
|
||||
dy: 100,
|
||||
dw: 600,
|
||||
dh: 400,
|
||||
})
|
||||
})
|
||||
|
||||
it('never enlarges the source inside a contain box', () => {
|
||||
const plan = drawPlan(SOURCE, { width: 3000, height: 3000 }, 'contain')
|
||||
|
||||
expect([plan.dx, plan.dy, plan.dw, plan.dh]).toEqual([750, 1000, 1500, 1000])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['jpeg', true],
|
||||
['original', true],
|
||||
['png', false],
|
||||
['webp', false],
|
||||
] as const)('flattens transparency for %s: %s', (format, flattens) => {
|
||||
expect(flattensTransparency(format)).toBe(flattens)
|
||||
})
|
||||
|
||||
it('refuses to render huge outputs', () => {
|
||||
expect(canRender({ width: 4000, height: 4000 })).toBe(true)
|
||||
expect(canRender({ width: 8000, height: 8000 })).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -5,23 +5,23 @@ import { outputSize, type OutputRequest, type PixelSize } from '@/lib/variants/v
|
||||
|
||||
export type { PixelSize } from '@/lib/variants/variantOutput'
|
||||
|
||||
const BYTES_PER_MB = 1024 * 1024
|
||||
const FULL_QUALITY = 100
|
||||
const REFERENCE_QUALITY = 82
|
||||
const PNG_BYTES_PER_PIXEL = 0.42
|
||||
const SIZE_EXPONENT = 0.885
|
||||
const QUALITY_EXPONENT = 1.9
|
||||
|
||||
const LOSSY_BASE: Record<Exclude<VariantFormat, 'png'>, number> = {
|
||||
webp: 0.9,
|
||||
jpeg: 1.4,
|
||||
original: 1.4,
|
||||
const FORMAT_WEIGHT: Record<Exclude<VariantFormat, 'png'>, number> = {
|
||||
webp: 0.65,
|
||||
jpeg: 1,
|
||||
original: 1,
|
||||
}
|
||||
|
||||
export const SAMPLE_SOURCE = {
|
||||
filename: 'north-wall.jpg',
|
||||
width: 4032,
|
||||
height: 3024,
|
||||
bytes: Math.round(3.9 * BYTES_PER_MB),
|
||||
filename: 'variant-sample.jpg',
|
||||
width: 1500,
|
||||
height: 1000,
|
||||
bytes: 86_382,
|
||||
}
|
||||
|
||||
export interface EstimateRequest extends OutputRequest {
|
||||
@@ -38,10 +38,12 @@ function rawBytes(pixels: number, request: EstimateRequest) {
|
||||
if (request.format === 'png') {
|
||||
return pixels * PNG_BYTES_PER_PIXEL
|
||||
}
|
||||
const quality = Math.min(FULL_QUALITY, Math.max(1, request.quality)) / FULL_QUALITY
|
||||
const quality = Math.min(FULL_QUALITY, Math.max(1, request.quality)) / REFERENCE_QUALITY
|
||||
const area = pixels / (SAMPLE_SOURCE.width * SAMPLE_SOURCE.height)
|
||||
return (
|
||||
LOSSY_BASE[request.format] *
|
||||
Math.pow(pixels, SIZE_EXPONENT) *
|
||||
SAMPLE_SOURCE.bytes *
|
||||
FORMAT_WEIGHT[request.format] *
|
||||
Math.pow(area, SIZE_EXPONENT) *
|
||||
Math.pow(quality, QUALITY_EXPONENT)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { VariantFit, VariantFormat } from '@/composables/useImageVariants'
|
||||
import type { PixelSize } from '@/lib/variants/variantOutput'
|
||||
|
||||
export const MAX_RENDER_PIXELS = 16_000_000
|
||||
|
||||
export interface DrawPlan {
|
||||
sx: number
|
||||
sy: number
|
||||
sw: number
|
||||
sh: number
|
||||
dx: number
|
||||
dy: number
|
||||
dw: number
|
||||
dh: number
|
||||
}
|
||||
|
||||
export function mimeFor(format: VariantFormat) {
|
||||
if (format === 'png') {
|
||||
return 'image/png'
|
||||
}
|
||||
if (format === 'webp') {
|
||||
return 'image/webp'
|
||||
}
|
||||
return 'image/jpeg'
|
||||
}
|
||||
|
||||
export function flattensTransparency(format: VariantFormat) {
|
||||
return mimeFor(format) === 'image/jpeg'
|
||||
}
|
||||
|
||||
export function canRender(size: PixelSize) {
|
||||
return size.width * size.height <= MAX_RENDER_PIXELS
|
||||
}
|
||||
|
||||
export function drawPlan(source: PixelSize, target: PixelSize, fit: VariantFit): DrawPlan {
|
||||
const widthScale = target.width / source.width
|
||||
const heightScale = target.height / source.height
|
||||
if (fit === 'contain') {
|
||||
const scale = Math.min(1, widthScale, heightScale)
|
||||
const dw = source.width * scale
|
||||
const dh = source.height * scale
|
||||
return {
|
||||
sx: 0,
|
||||
sy: 0,
|
||||
sw: source.width,
|
||||
sh: source.height,
|
||||
dx: (target.width - dw) / 2,
|
||||
dy: (target.height - dh) / 2,
|
||||
dw,
|
||||
dh,
|
||||
}
|
||||
}
|
||||
const scale = Math.max(widthScale, heightScale)
|
||||
const sw = target.width / scale
|
||||
const sh = target.height / scale
|
||||
return {
|
||||
sx: (source.width - sw) / 2,
|
||||
sy: (source.height - sh) / 2,
|
||||
sw,
|
||||
sh,
|
||||
dx: 0,
|
||||
dy: 0,
|
||||
dw: target.width,
|
||||
dh: target.height,
|
||||
}
|
||||
}
|
||||
@@ -256,7 +256,9 @@ describe('Nuxt UI shell foundation and router guards', () => {
|
||||
const getMe = vi.fn().mockResolvedValue({ verified: true })
|
||||
const request = vi.fn().mockResolvedValue({
|
||||
items: [],
|
||||
next_cursor: null,
|
||||
total: 0,
|
||||
page: 1,
|
||||
size: 25,
|
||||
counts: { all: 0, unused: 0, duplicates: 0 },
|
||||
})
|
||||
vi.mocked(useApi).mockReturnValue({ getMe, request })
|
||||
@@ -268,7 +270,7 @@ describe('Nuxt UI shell foundation and router guards', () => {
|
||||
|
||||
expect(app.find('nav[aria-label="breadcrumb"]').text()).toBe('Media')
|
||||
expect(app.find('a[href="/media"][aria-current="page"]').exists()).toBe(true)
|
||||
expect(request).toHaveBeenCalledWith('/v1/media/?filter=all&sort=newest&limit=100')
|
||||
expect(request).toHaveBeenCalledWith('/v1/media/?filter=all&sort=newest&page=1&size=25')
|
||||
})
|
||||
|
||||
it('An unverified session on /media is sent to /register/pending', async () => {
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from '@/composables/useArticles'
|
||||
import { bodyImageCount } from '@/lib/bodyImageIds'
|
||||
import {
|
||||
ARTICLE_PAGE_SIZES,
|
||||
effectiveSort,
|
||||
nextSort,
|
||||
parseArticleQuery,
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
type ArticleListQuery,
|
||||
} from '@/lib/articleListQuery'
|
||||
import { pageCountFor } from '@/lib/pagination'
|
||||
import { ARTICLES_PAGE_SIZE_KEY, readStoredPageSize, storePageSize } from '@/lib/pageSizeMemory'
|
||||
|
||||
const SKELETON_DELAY_MS = 150
|
||||
const SEARCH_DEBOUNCE_MS = 120
|
||||
@@ -49,7 +51,16 @@ const toast = useLocalizedToast()
|
||||
const items = ref<Article[]>([])
|
||||
const total = ref(0)
|
||||
const counts = ref<ArticleCounts>({ all: 0, online: 0, draft: 0 })
|
||||
const shown = ref<ArticleListQuery>(parseArticleQuery(route.query))
|
||||
function parseWithStoredSize(query: typeof route.query): ArticleListQuery {
|
||||
const parsed = parseArticleQuery(query)
|
||||
if (query.size !== undefined) {
|
||||
return parsed
|
||||
}
|
||||
const stored = readStoredPageSize(ARTICLES_PAGE_SIZE_KEY, ARTICLE_PAGE_SIZES)
|
||||
return stored === null ? parsed : { ...parsed, size: stored }
|
||||
}
|
||||
|
||||
const shown = ref<ArticleListQuery>(parseWithStoredSize(route.query))
|
||||
const desired = ref<ArticleListQuery>(shown.value)
|
||||
const hasLoaded = ref(false)
|
||||
const loading = ref(true)
|
||||
@@ -62,6 +73,7 @@ const newArticleModalOpen = ref(false)
|
||||
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let requestToken = 0
|
||||
let syncedTarget: Record<string, string> | null = null
|
||||
|
||||
const searchInput = ref(shown.value.q)
|
||||
|
||||
@@ -152,6 +164,7 @@ async function syncUrl(query: ArticleListQuery, mode: UrlMode) {
|
||||
if (sameRouteQuery(route.query, target)) {
|
||||
return
|
||||
}
|
||||
syncedTarget = target
|
||||
await router[mode]({ query: target })
|
||||
}
|
||||
|
||||
@@ -216,6 +229,7 @@ function changePage(page: number) {
|
||||
}
|
||||
|
||||
function changeSize(size: number) {
|
||||
storePageSize(ARTICLES_PAGE_SIZE_KEY, size)
|
||||
load({ ...desired.value, size, page: 1 }, 'push')
|
||||
}
|
||||
|
||||
@@ -250,7 +264,9 @@ watch(
|
||||
if (route.path !== ARTICLES_PATH) {
|
||||
return
|
||||
}
|
||||
const next = parseArticleQuery(route.query)
|
||||
const ownReplace = syncedTarget !== null && sameRouteQuery(route.query, syncedTarget)
|
||||
syncedTarget = null
|
||||
const next = ownReplace ? parseArticleQuery(route.query) : parseWithStoredSize(route.query)
|
||||
if (hasLoaded.value && sameArticleQuery(next, shown.value)) {
|
||||
syncUrl(shown.value, 'replace')
|
||||
return
|
||||
|
||||
@@ -287,7 +287,7 @@ const CARD_TITLE = 'text-md/6 font-semibold text-highlighted'
|
||||
</h2>
|
||||
<span class="text-muted">{{ routeCount(group.endpoints.length) }}</span>
|
||||
</div>
|
||||
<span class="text-pretty text-toned">{{ group.description }}</span>
|
||||
<span class="text-pretty text-toned">{{ translate(group.description) }}</span>
|
||||
<div class="border border-t-0 border-default bg-default">
|
||||
<EndpointCard
|
||||
v-for="endpoint in group.endpoints"
|
||||
|
||||
+180
-30
@@ -2,9 +2,10 @@
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useRoute, useRouter, type LocationQuery } from 'vue-router'
|
||||
import { useEventListener, useMediaQuery, useOnline } from '@vueuse/core'
|
||||
import { useLocalizedToast } from '@/i18n/presentation'
|
||||
import AppPaginator from '@/components/AppPaginator.vue'
|
||||
import BulkDeleteMediaDialog from '@/components/BulkDeleteMediaDialog.vue'
|
||||
import DashboardShell from '@/components/DashboardShell.vue'
|
||||
import DeleteMediaDialog from '@/components/DeleteMediaDialog.vue'
|
||||
@@ -38,16 +39,21 @@ import { toastsAboveBulkBar } from '@/composables/useToastOffset'
|
||||
import { groupDuplicates, type DuplicateGroup } from '@/lib/mediaDuplicates'
|
||||
import { joinNames } from '@/lib/mediaFormat'
|
||||
import {
|
||||
isFullSet,
|
||||
parseMediaControls,
|
||||
toRequestQuery,
|
||||
sameMediaControls,
|
||||
toRouteQuery,
|
||||
type MediaControls,
|
||||
} from '@/lib/mediaQuery'
|
||||
import { sameRouteQuery } from '@/lib/articleListQuery'
|
||||
import { MEDIA_PAGE_SIZE_KEY, storePageSize } from '@/lib/pageSizeMemory'
|
||||
import { pageCountFor } from '@/lib/pagination'
|
||||
import { purgedToast } from '@/lib/mediaPurge'
|
||||
import { RATE_LIMITED_TEXT, RATE_LIMITED_TITLE, UPLOAD_ACCEPT } from '@/lib/mediaUploadRows'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
|
||||
const DESKTOP_QUERY = '(min-width: 48rem)'
|
||||
const MEDIA_PATH = '/media'
|
||||
const TOAST_DURATION_MS = 5000
|
||||
const PERSISTENT_TOAST = 0
|
||||
const BULK_FAILED_TOAST_ID = 'media-bulk-delete-failed'
|
||||
@@ -72,6 +78,9 @@ const MERGE_CONFLICT_COPY =
|
||||
const OFFLINE_COPY =
|
||||
"You're offline. You can look through the images that are loaded, but uploading, renaming and deleting need a connection."
|
||||
|
||||
type UrlMode = 'push' | 'replace'
|
||||
type Notice = { kind: 'range' } | { kind: 'error'; query: MediaControls; mode: UrlMode }
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const toast = useLocalizedToast()
|
||||
@@ -82,11 +91,15 @@ const upload = useMediaUpload({ onAdded: refreshInBackground })
|
||||
const isDesktop = useMediaQuery(DESKTOP_QUERY)
|
||||
const online = useOnline()
|
||||
|
||||
const controls = computed(() => parseMediaControls(route.query))
|
||||
const requestQuery = computed(() => toRequestQuery(controls.value))
|
||||
const shown = ref<MediaControls>(parseMediaControls(route.query))
|
||||
const desired = ref<MediaControls>(shown.value)
|
||||
const controls = computed(() => shown.value)
|
||||
const offline = computed(() => !online.value)
|
||||
|
||||
const hasLoaded = ref(false)
|
||||
const loadedOnce = ref(false)
|
||||
const pendingPage = ref<number | null>(null)
|
||||
const notice = ref<Notice | null>(null)
|
||||
const heading = ref<HTMLElement | null>(null)
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const activeId = ref<number | null>(null)
|
||||
@@ -110,6 +123,8 @@ let bulkDeletedTotal = 0
|
||||
let bulkFailureRevision = 0
|
||||
let bulkFailureToastId = BULK_FAILED_TOAST_ID
|
||||
let failureToastShown = false
|
||||
let requestToken = 0
|
||||
let syncedTarget: Record<string, string> | null = null
|
||||
|
||||
const activeItem = computed(
|
||||
() => media.items.value.find((item) => item.id === activeId.value) ?? null,
|
||||
@@ -119,7 +134,9 @@ const panelItem = computed(() => activeItem.value ?? closingItem.value)
|
||||
const counts = computed(() => (hasLoaded.value ? media.counts.value : null))
|
||||
const loadFailed = computed(() => media.status.value === 'error')
|
||||
const loading = computed(() => media.status.value === 'loading')
|
||||
const ready = computed(() => media.status.value === 'ready')
|
||||
const ready = computed(
|
||||
() => media.status.value === 'ready' || (media.status.value === 'loading' && loadedOnce.value),
|
||||
)
|
||||
const isDuplicates = computed(() => controls.value.filter === 'duplicates')
|
||||
const groups = computed(() => (isDuplicates.value ? groupDuplicates(media.items.value) : []))
|
||||
const emptyLibrary = computed(
|
||||
@@ -133,8 +150,24 @@ const nothingToShow = computed(
|
||||
() =>
|
||||
ready.value &&
|
||||
!emptyLibrary.value &&
|
||||
(isDuplicates.value ? groups.value.length === 0 : media.items.value.length === 0),
|
||||
(isDuplicates.value
|
||||
? groups.value.length === 0
|
||||
: media.items.value.length === 0 && media.total.value === 0),
|
||||
)
|
||||
const showPaginator = computed(
|
||||
() => ready.value && !isDuplicates.value && !emptyLibrary.value && media.total.value > 0,
|
||||
)
|
||||
const pageCount = computed(() => pageCountFor(media.total.value, shown.value.size))
|
||||
const noticeText = computed(() => {
|
||||
const current = notice.value
|
||||
if (!current) {
|
||||
return ''
|
||||
}
|
||||
if (current.kind === 'range') {
|
||||
return t("That page doesn't exist. Showing the last page.")
|
||||
}
|
||||
return t("Couldn't load page {0}. Showing page {1}.", [current.query.page, shown.value.page])
|
||||
})
|
||||
const emptyVariant = computed(() => {
|
||||
if (isDuplicates.value && controls.value.q === '') {
|
||||
return 'no-duplicates'
|
||||
@@ -191,21 +224,78 @@ watch(showBulkBar, (visible) => (toastsAboveBulkBar.value = visible), {
|
||||
})
|
||||
onBeforeUnmount(() => (toastsAboveBulkBar.value = false))
|
||||
|
||||
function canonicalizeUrl() {
|
||||
const canonical = toRouteQuery(controls.value)
|
||||
const current = Object.fromEntries(
|
||||
Object.entries(route.query).map(([key, value]) => [
|
||||
key,
|
||||
Array.isArray(value) ? (value[0] ?? '') : (value ?? ''),
|
||||
]),
|
||||
function sameOrderedQuery(current: LocationQuery, target: Record<string, string>) {
|
||||
const flatten = (query: LocationQuery) =>
|
||||
Object.entries(query).map(([key, value]) => [key, Array.isArray(value) ? value[0] : value])
|
||||
return JSON.stringify(flatten(current)) === JSON.stringify(Object.entries(target))
|
||||
}
|
||||
|
||||
async function syncUrl(query: MediaControls, mode: UrlMode) {
|
||||
if (route.path !== MEDIA_PATH) {
|
||||
return
|
||||
}
|
||||
const target = toRouteQuery(query)
|
||||
if (sameOrderedQuery(route.query, target)) {
|
||||
return
|
||||
}
|
||||
syncedTarget = target
|
||||
await router[mode]({ query: target })
|
||||
}
|
||||
|
||||
function differsOnlyByPage(query: MediaControls, current: MediaControls) {
|
||||
return sameMediaControls({ ...query, page: current.page }, current)
|
||||
}
|
||||
|
||||
async function load(query: MediaControls, mode: UrlMode) {
|
||||
const token = ++requestToken
|
||||
desired.value = query
|
||||
pendingPage.value = loadedOnce.value && !isFullSet(query) ? query.page : null
|
||||
const keepOnError = loadedOnce.value && differsOnlyByPage(query, shown.value)
|
||||
const outcome = await media.load(query, { keepOnError })
|
||||
if (token !== requestToken || outcome.kind === 'stale') {
|
||||
return
|
||||
}
|
||||
pendingPage.value = null
|
||||
hasLoaded.value = true
|
||||
if (outcome.kind === 'failed') {
|
||||
if (keepOnError && !outcome.handled) {
|
||||
desired.value = shown.value
|
||||
notice.value = { kind: 'error', query, mode }
|
||||
}
|
||||
return
|
||||
}
|
||||
loadedOnce.value = true
|
||||
const effective = { ...query, page: outcome.page }
|
||||
selection.clear()
|
||||
shown.value = effective
|
||||
desired.value = effective
|
||||
notice.value = outcome.redirected ? { kind: 'range' } : null
|
||||
await syncUrl(effective, outcome.redirected ? 'replace' : mode)
|
||||
selectPending()
|
||||
}
|
||||
|
||||
function updateControls(patch: Partial<MediaControls>, mode: UrlMode = 'replace') {
|
||||
const changed = Object.entries(patch).some(
|
||||
([key, value]) => desired.value[key as keyof MediaControls] !== value,
|
||||
)
|
||||
if (JSON.stringify(canonical) !== JSON.stringify(current)) {
|
||||
router.replace({ query: canonical })
|
||||
if (changed) {
|
||||
load({ ...desired.value, page: 1, ...patch }, mode)
|
||||
}
|
||||
}
|
||||
|
||||
function updateControls(patch: Partial<MediaControls>) {
|
||||
router.replace({ query: toRouteQuery({ ...controls.value, ...patch }) })
|
||||
function changePage(page: number) {
|
||||
load({ ...desired.value, page }, 'push')
|
||||
}
|
||||
|
||||
function changeSize(size: number) {
|
||||
storePageSize(MEDIA_PAGE_SIZE_KEY, size)
|
||||
load({ ...desired.value, size, page: 1 }, 'push')
|
||||
}
|
||||
|
||||
function retryNotice() {
|
||||
if (notice.value?.kind === 'error') {
|
||||
load(notice.value.query, notice.value.mode)
|
||||
}
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
@@ -216,8 +306,6 @@ function announce(source: () => string) {
|
||||
announcementSource.value = source
|
||||
}
|
||||
|
||||
canonicalizeUrl()
|
||||
|
||||
watch(activeItem, (item) => {
|
||||
if (item) {
|
||||
closingItem.value = item
|
||||
@@ -225,16 +313,27 @@ watch(activeItem, (item) => {
|
||||
})
|
||||
|
||||
watch(
|
||||
requestQuery,
|
||||
async () => {
|
||||
selection.clear()
|
||||
await media.load(controls.value)
|
||||
hasLoaded.value = true
|
||||
selectPending()
|
||||
() => route.query,
|
||||
() => {
|
||||
if (route.path !== MEDIA_PATH) {
|
||||
return
|
||||
}
|
||||
const ownReplace = syncedTarget !== null && sameRouteQuery(route.query, syncedTarget)
|
||||
syncedTarget = null
|
||||
const next = parseMediaControls(route.query, !ownReplace)
|
||||
if (hasLoaded.value && sameMediaControls(next, shown.value)) {
|
||||
syncUrl(shown.value, 'replace')
|
||||
return
|
||||
}
|
||||
if (sameMediaControls(next, desired.value) && media.status.value === 'loading') {
|
||||
return
|
||||
}
|
||||
load(next, 'replace')
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
load(shown.value, 'replace')
|
||||
|
||||
watch(
|
||||
() => upload.header.value,
|
||||
(next, previous) => {
|
||||
@@ -249,8 +348,18 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
function refreshInBackground() {
|
||||
media.load(controls.value, { silent: true })
|
||||
async function refreshInBackground() {
|
||||
const outcome = await media.load(shown.value, { silent: true })
|
||||
if (outcome.kind !== 'loaded') {
|
||||
return
|
||||
}
|
||||
hasLoaded.value = true
|
||||
loadedOnce.value = true
|
||||
if (outcome.page !== shown.value.page) {
|
||||
shown.value = { ...shown.value, page: outcome.page }
|
||||
desired.value = shown.value
|
||||
await syncUrl(shown.value, 'replace')
|
||||
}
|
||||
}
|
||||
|
||||
function focusCard(id: number) {
|
||||
@@ -910,7 +1019,7 @@ async function mergeAction() {
|
||||
icon="i-lucide-rotate-cw"
|
||||
class="h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-7"
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
@click="media.load(controls)"
|
||||
@click="load(desired, 'replace')"
|
||||
>
|
||||
{{ t('Try again') }}
|
||||
</UButton>
|
||||
@@ -918,7 +1027,7 @@ async function mergeAction() {
|
||||
</UAlert>
|
||||
|
||||
<MediaToolbar
|
||||
:controls="controls"
|
||||
:controls="desired"
|
||||
:counts="counts"
|
||||
:show-purge="showPurge"
|
||||
:purge-disabled="offline"
|
||||
@@ -998,6 +1107,47 @@ async function mergeAction() {
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<UAlert
|
||||
v-if="notice && !isDuplicates"
|
||||
:role="notice.kind === 'error' ? 'alert' : 'status'"
|
||||
:color="notice.kind === 'error' ? 'error' : 'info'"
|
||||
variant="soft"
|
||||
orientation="vertical"
|
||||
:close="false"
|
||||
:icon="notice.kind === 'error' ? 'i-lucide-circle-x' : 'i-lucide-info'"
|
||||
:title="noticeText"
|
||||
:description="
|
||||
notice.kind === 'error'
|
||||
? t('Check your connection and try again.')
|
||||
: undefined
|
||||
"
|
||||
:ui="{ actions: 'mt-2' }"
|
||||
>
|
||||
<template v-if="notice.kind === 'error'" #actions>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-rotate-cw"
|
||||
class="h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-7"
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
@click="retryNotice"
|
||||
>
|
||||
{{ t('Retry') }}
|
||||
</UButton>
|
||||
</template>
|
||||
</UAlert>
|
||||
|
||||
<AppPaginator
|
||||
v-if="showPaginator"
|
||||
:page="shown.page"
|
||||
:page-count="pageCount"
|
||||
:total="media.total.value"
|
||||
:size="shown.size"
|
||||
:loading-page="pendingPage"
|
||||
@update:page="changePage"
|
||||
@update:size="changeSize"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import router from '@/router'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { initializeLocale } from '@/i18n'
|
||||
import ActivityPager from '@/components/ActivityPager.vue'
|
||||
import {
|
||||
harness,
|
||||
lastListQuery,
|
||||
mountActivity,
|
||||
queryObject,
|
||||
unmountActivity,
|
||||
} from './activityHarness'
|
||||
import { activityItems, pageOf } from './activityFixtures'
|
||||
|
||||
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() }) }
|
||||
})
|
||||
|
||||
const KEY = 'renewcms-page-size-activity'
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
initializeLocale()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks()
|
||||
unmountActivity()
|
||||
vi.mocked(useApi).mockReset()
|
||||
await router.push('/login')
|
||||
})
|
||||
|
||||
function list(query: URLSearchParams) {
|
||||
return pageOf(activityItems, {
|
||||
page: Number(query.get('page')),
|
||||
size: Number(query.get('size')),
|
||||
})
|
||||
}
|
||||
|
||||
async function chooseSize(size: number) {
|
||||
harness.wrapper!.findComponent(ActivityPager).vm.$emit('update:size', size)
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function url() {
|
||||
return router.currentRoute.value.fullPath
|
||||
}
|
||||
|
||||
describe('Activity list remembers the chosen page size', () => {
|
||||
it('stores the size, shows it in the URL and restores it on the next visit', async () => {
|
||||
await mountActivity({ list })
|
||||
expect(queryObject(lastListQuery()).size).toBe('25')
|
||||
expect(url()).toBe('/activity')
|
||||
|
||||
await chooseSize(50)
|
||||
expect(localStorage.getItem(KEY)).toBe('50')
|
||||
expect(queryObject(lastListQuery())).toMatchObject({ page: '1', size: '50' })
|
||||
expect(url()).toBe('/activity?size=50')
|
||||
|
||||
unmountActivity()
|
||||
await mountActivity({ list })
|
||||
expect(queryObject(lastListQuery()).size).toBe('50')
|
||||
expect(harness.wrapper!.findComponent(ActivityPager).props('size')).toBe(50)
|
||||
expect(url()).toBe('/activity?size=50')
|
||||
})
|
||||
|
||||
it('drops size from the URL and stores 25 when returning to the default', async () => {
|
||||
localStorage.setItem(KEY, '100')
|
||||
await mountActivity({ list })
|
||||
expect(url()).toBe('/activity?size=100')
|
||||
await chooseSize(25)
|
||||
expect(localStorage.getItem(KEY)).toBe('25')
|
||||
expect(url()).toBe('/activity')
|
||||
})
|
||||
|
||||
it('lets a valid size in the URL win without storing it', async () => {
|
||||
localStorage.setItem(KEY, '50')
|
||||
await mountActivity({ list, path: '/activity?size=100' })
|
||||
expect(queryObject(lastListQuery()).size).toBe('100')
|
||||
expect(localStorage.getItem(KEY)).toBe('50')
|
||||
expect(url()).toBe('/activity?size=100')
|
||||
})
|
||||
|
||||
it('falls back to 25 for an invalid size in the URL, ignoring the stored one', async () => {
|
||||
localStorage.setItem(KEY, '50')
|
||||
await mountActivity({ list, path: '/activity?size=10' })
|
||||
expect(queryObject(lastListQuery()).size).toBe('25')
|
||||
expect(localStorage.getItem(KEY)).toBe('50')
|
||||
expect(url()).toBe('/activity')
|
||||
})
|
||||
|
||||
it('keeps other query parameters when the size changes', async () => {
|
||||
await mountActivity({ list, path: '/activity?foo=bar' })
|
||||
await chooseSize(50)
|
||||
expect(url()).toBe('/activity?foo=bar&size=50')
|
||||
})
|
||||
|
||||
it.each(['10', 'abc', '5', '0', '-25', '25.5', ''])(
|
||||
'ignores the stored value %j and uses 25',
|
||||
async (stored) => {
|
||||
localStorage.setItem(KEY, stored)
|
||||
await mountActivity({ list })
|
||||
expect(queryObject(lastListQuery()).size).toBe('25')
|
||||
expect(url()).toBe('/activity')
|
||||
},
|
||||
)
|
||||
|
||||
it('keeps working when storage throws on read or write', async () => {
|
||||
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
||||
throw new Error('blocked')
|
||||
})
|
||||
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||
throw new Error('quota')
|
||||
})
|
||||
await mountActivity({ list })
|
||||
expect(queryObject(lastListQuery()).size).toBe('25')
|
||||
|
||||
await chooseSize(100)
|
||||
expect(queryObject(lastListQuery())).toMatchObject({ page: '1', size: '100' })
|
||||
expect(url()).toBe('/activity?size=100')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,295 @@
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import router from '@/router'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
|
||||
import ArticleView from '../ArticleView.vue'
|
||||
import { articleEnvelope, parseListUrl, type EnvelopeParams } from './articleEnvelope'
|
||||
|
||||
const KEY = 'renewcms-page-size-articles'
|
||||
const TOTAL = 312
|
||||
|
||||
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
|
||||
return {
|
||||
...actual,
|
||||
useOverlay: () => ({ create: vi.fn() }),
|
||||
useToast: () => ({ add: vi.fn() }),
|
||||
}
|
||||
})
|
||||
|
||||
const articles: Article[] = Array.from({ length: TOTAL }, (_, index) => ({
|
||||
id: index + 1,
|
||||
title: `Article ${index + 1}`,
|
||||
body: '',
|
||||
is_online: (index + 1) % 3 !== 0,
|
||||
created_at: '2026-03-12T09:14:00Z',
|
||||
updated_at: '2026-08-18T09:00:00Z',
|
||||
}))
|
||||
|
||||
let listCalls: EnvelopeParams[]
|
||||
let wrapper: Awaited<ReturnType<typeof mount>> | null = null
|
||||
|
||||
function installFetch() {
|
||||
global.fetch = vi.fn((url: string, init?: RequestInit) => {
|
||||
const method = init?.method ?? 'GET'
|
||||
if (method === 'GET' && url.startsWith('/v1/article/?')) {
|
||||
const params = parseListUrl(url)
|
||||
listCalls.push(params)
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: { get: () => null },
|
||||
json: async () => articleEnvelope(articles, params),
|
||||
} as unknown as Response)
|
||||
}
|
||||
throw new Error(`Unexpected fetch call: ${method} ${url}`)
|
||||
}) as unknown as typeof fetch
|
||||
}
|
||||
|
||||
async function mountAt(path: string) {
|
||||
setActivePinia(createPinia())
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, username: 'marta' }
|
||||
session.status = 'ready'
|
||||
await router.push(path)
|
||||
wrapper = mount(ArticleView, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [router, uiPlugin] },
|
||||
})
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function unmount() {
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
}
|
||||
|
||||
function selectText() {
|
||||
return document.body.querySelector('[aria-label="Rows per page"]')?.textContent?.trim()
|
||||
}
|
||||
|
||||
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 flushPromises()
|
||||
}
|
||||
|
||||
async function click(label: string) {
|
||||
document.body.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`)?.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function lastCall() {
|
||||
return listCalls.at(-1)
|
||||
}
|
||||
|
||||
function fullPath() {
|
||||
return router.currentRoute.value.fullPath
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
listCalls = []
|
||||
setViewportWidth(DESKTOP_WIDTH)
|
||||
installFetch()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
unmount()
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
await router.push('/login')
|
||||
})
|
||||
|
||||
describe('Choosing a size stores it', () => {
|
||||
it.each([
|
||||
['5 per page', '5', '/article?size=5'],
|
||||
['25 per page', '25', '/article'],
|
||||
['50 per page', '50', '/article?size=50'],
|
||||
['100 per page', '100', '/article?size=100'],
|
||||
])('%s', async (label, stored, url) => {
|
||||
await mountAt('/article')
|
||||
await choose(label)
|
||||
|
||||
expect(localStorage.getItem(KEY)).toBe(stored)
|
||||
expect(fullPath()).toBe(url)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Restoring the stored size', () => {
|
||||
it('The stored size is restored when the list is opened without a size', async () => {
|
||||
localStorage.setItem(KEY, '5')
|
||||
await mountAt('/article')
|
||||
|
||||
expect(listCalls).toEqual([{ page: 1, size: 5, q: '', status: '', sort: '' }])
|
||||
expect(selectText()).toBe('5 per page')
|
||||
expect(fullPath()).toBe('/article?size=5')
|
||||
})
|
||||
|
||||
it('The size survives leaving the list and coming back', async () => {
|
||||
await mountAt('/article')
|
||||
await choose('5 per page')
|
||||
unmount()
|
||||
await router.push('/')
|
||||
listCalls = []
|
||||
await mountAt('/article')
|
||||
|
||||
expect(lastCall()).toMatchObject({ size: 5 })
|
||||
expect(selectText()).toBe('5 per page')
|
||||
})
|
||||
|
||||
it('The size survives a full page refresh', async () => {
|
||||
await mountAt('/article')
|
||||
await choose('5 per page')
|
||||
unmount()
|
||||
listCalls = []
|
||||
await mountAt('/article?size=5')
|
||||
expect(lastCall()).toMatchObject({ size: 5 })
|
||||
expect(selectText()).toBe('5 per page')
|
||||
|
||||
unmount()
|
||||
listCalls = []
|
||||
await mountAt('/article')
|
||||
expect(lastCall()).toMatchObject({ size: 5 })
|
||||
expect(selectText()).toBe('5 per page')
|
||||
})
|
||||
|
||||
it('The stored size is kept across paging, search, tabs and sort', async () => {
|
||||
localStorage.setItem(KEY, '50')
|
||||
await mountAt('/article?size=50')
|
||||
await click('Next page')
|
||||
await router.push('/article?size=50&status=draft')
|
||||
await flushPromises()
|
||||
await router.push('/article?size=50&sort=title:asc')
|
||||
await flushPromises()
|
||||
await router.push('/article?size=50&q=plaster')
|
||||
await flushPromises()
|
||||
|
||||
expect(listCalls.length).toBeGreaterThan(1)
|
||||
expect(listCalls.every((call) => call.size === 50)).toBe(true)
|
||||
expect(localStorage.getItem(KEY)).toBe('50')
|
||||
})
|
||||
|
||||
it('An explicit page with no size uses the stored size', async () => {
|
||||
localStorage.setItem(KEY, '5')
|
||||
await mountAt('/article?page=3')
|
||||
|
||||
expect(lastCall()).toMatchObject({ page: 3, size: 5 })
|
||||
expect(fullPath()).toBe('/article?page=3&size=5')
|
||||
})
|
||||
|
||||
it('Logging out keeps the stored size', async () => {
|
||||
localStorage.setItem(KEY, '5')
|
||||
await mountAt('/article')
|
||||
unmount()
|
||||
await router.push('/login')
|
||||
listCalls = []
|
||||
await mountAt('/article')
|
||||
|
||||
expect(lastCall()).toMatchObject({ size: 5 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Replacing the stored size', () => {
|
||||
it('Changing the size again replaces the stored one', async () => {
|
||||
localStorage.setItem(KEY, '5')
|
||||
await mountAt('/article')
|
||||
await choose('100 per page')
|
||||
|
||||
expect(localStorage.getItem(KEY)).toBe('100')
|
||||
unmount()
|
||||
listCalls = []
|
||||
await mountAt('/article')
|
||||
expect(lastCall()).toMatchObject({ size: 100 })
|
||||
})
|
||||
|
||||
it('Returning to 25 is remembered too', async () => {
|
||||
localStorage.setItem(KEY, '5')
|
||||
await mountAt('/article')
|
||||
await choose('25 per page')
|
||||
|
||||
expect(localStorage.getItem(KEY)).toBe('25')
|
||||
unmount()
|
||||
listCalls = []
|
||||
await mountAt('/article')
|
||||
expect(lastCall()).toMatchObject({ size: 25 })
|
||||
expect(fullPath()).toBe('/article')
|
||||
})
|
||||
})
|
||||
|
||||
describe('URL precedence', () => {
|
||||
it('An explicit size in the URL wins and is not stored', async () => {
|
||||
localStorage.setItem(KEY, '5')
|
||||
await mountAt('/article?size=100')
|
||||
|
||||
expect(lastCall()).toMatchObject({ page: 1, size: 100 })
|
||||
expect(selectText()).toBe('100 per page')
|
||||
expect(localStorage.getItem(KEY)).toBe('5')
|
||||
unmount()
|
||||
listCalls = []
|
||||
await mountAt('/article')
|
||||
expect(lastCall()).toMatchObject({ size: 5 })
|
||||
})
|
||||
|
||||
it('An invalid size in the URL ignores the stored one', async () => {
|
||||
localStorage.setItem(KEY, '5')
|
||||
await mountAt('/article?size=10')
|
||||
|
||||
expect(listCalls).toEqual([{ page: 1, size: 25, q: '', status: '', sort: '' }])
|
||||
expect(fullPath()).toBe('/article')
|
||||
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 mountAt('/article')
|
||||
|
||||
expect(listCalls).toEqual([{ page: 1, size: 25, q: '', status: '', sort: '' }])
|
||||
expect(selectText()).toBe('25 per page')
|
||||
expect(fullPath()).toBe('/article')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Unavailable storage never breaks the list', () => {
|
||||
async function expectWorking() {
|
||||
await mountAt('/article')
|
||||
expect(listCalls).toEqual([{ page: 1, size: 25, q: '', status: '', sort: '' }])
|
||||
expect(document.body.querySelector('[role="alert"]')).toBeNull()
|
||||
await choose('50 per page')
|
||||
expect(lastCall()).toMatchObject({ page: 1, size: 50 })
|
||||
expect(fullPath()).toBe('/article?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()
|
||||
})
|
||||
})
|
||||
@@ -155,6 +155,7 @@ beforeAll(() => {
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
articles = buildArticles(TOTAL)
|
||||
listCalls = []
|
||||
listHandler = (params) => respondFromFixture(params)
|
||||
|
||||
@@ -187,12 +187,18 @@ afterEach(async () => {
|
||||
await router.push('/login')
|
||||
})
|
||||
|
||||
const EMPTY_LIBRARY = { items: [], next_cursor: null, counts: { all: 0, unused: 0, duplicates: 0 } }
|
||||
const EMPTY_LIBRARY = {
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
size: 25,
|
||||
counts: { all: 0, unused: 0, duplicates: 0 },
|
||||
}
|
||||
|
||||
function articleHandlers(extra: Record<string, () => Response | Promise<Response>> = {}) {
|
||||
return {
|
||||
'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()),
|
||||
'GET /v1/media/?limit=24': () => mockResponse(200, EMPTY_LIBRARY),
|
||||
'GET /v1/media/?page=1&size=25': () => mockResponse(200, EMPTY_LIBRARY),
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
@@ -217,7 +223,8 @@ describe('Dropping a valid file onto the editor opens the uploader on the crop s
|
||||
it('looks the hash up, skips the list, and uploads to /v1/media/ from the crop step', async () => {
|
||||
const wrapper = await mountEditor(
|
||||
articleHandlers({
|
||||
[`GET /v1/media/?hash=${'a'.repeat(64)}`]: () => mockResponse(200, EMPTY_LIBRARY),
|
||||
[`GET /v1/media/?hash=${'a'.repeat(64)}&page=1&size=100`]: () =>
|
||||
mockResponse(200, EMPTY_LIBRARY),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -231,7 +238,7 @@ describe('Dropping a valid file onto the editor opens the uploader on the crop s
|
||||
|
||||
expect(document.body.textContent).toContain('Crop and resize')
|
||||
expect(vi.mocked(global.fetch).mock.calls.map(([url]) => url)).not.toContain(
|
||||
'/v1/media/?limit=24',
|
||||
'/v1/media/?page=1&size=25',
|
||||
)
|
||||
|
||||
findButton('Upload image')?.click()
|
||||
|
||||
@@ -38,7 +38,7 @@ function libraryPage() {
|
||||
const items = fixture().map((item) =>
|
||||
item.id === 2 ? { ...item, url: '/static/uploadedImages/2.webp' } : item,
|
||||
)
|
||||
return { items, next_cursor: 'c24', counts: { all: 48, unused: 2, duplicates: 2 } }
|
||||
return { items, total: 48, page: 1, size: 25, counts: { all: 48, unused: 2, duplicates: 2 } }
|
||||
}
|
||||
|
||||
let toastAdd: ReturnType<typeof vi.fn>
|
||||
@@ -60,7 +60,7 @@ async function mountEditor(body = BODY_WITH_TWO_IMAGES) {
|
||||
if (url === '/v1/article/12?format=html') {
|
||||
return mockResponse(200, articleFixture(body))
|
||||
}
|
||||
if (url === '/v1/media/?limit=24') {
|
||||
if (url === '/v1/media/?page=1&size=25') {
|
||||
return mockResponse(200, libraryPage())
|
||||
}
|
||||
throw new Error(`Unexpected fetch call: ${url}`)
|
||||
|
||||
@@ -79,7 +79,13 @@ function libraryPage() {
|
||||
4: '/static/uploadedImages/4.svg',
|
||||
}
|
||||
const items = fixture().map((item) => ({ ...item, url: urls[item.id] ?? item.url }))
|
||||
return { items, next_cursor: null, counts: { all: 12, unused: 2, duplicates: 2 } }
|
||||
return {
|
||||
items,
|
||||
total: items.length,
|
||||
page: 1,
|
||||
size: 25,
|
||||
counts: { all: 12, unused: 2, duplicates: 2 },
|
||||
}
|
||||
}
|
||||
|
||||
let toastAdd: ReturnType<typeof vi.fn>
|
||||
@@ -113,7 +119,7 @@ async function mountEditor(options: EditorOptions = {}) {
|
||||
if (url === '/v1/article/12?format=html') {
|
||||
return reply(200, article(options.body ?? '<p>Intro</p>'))
|
||||
}
|
||||
if (url === '/v1/media/?limit=24') {
|
||||
if (url === '/v1/media/?page=1&size=25') {
|
||||
return reply(200, libraryPage())
|
||||
}
|
||||
if (url === '/v1/image-variants') {
|
||||
|
||||
@@ -234,14 +234,15 @@ describe('Group scrolled, one card expanded', () => {
|
||||
expect(published.text()).toContain('No request body.')
|
||||
})
|
||||
|
||||
it('shows the six media query parameters on the media list', async () => {
|
||||
it('shows the seven media query parameters on the media list', async () => {
|
||||
const wrapper = await mountIntegration()
|
||||
const list = await expand(wrapper, 'GET /v1/media/')
|
||||
|
||||
for (const name of ['q', 'hash', 'cursor', 'filter', 'sort', 'limit']) {
|
||||
for (const name of ['q', 'hash', 'filter', 'sort', 'variants', 'page', 'size']) {
|
||||
expect(list.text()).toContain(name)
|
||||
}
|
||||
expect(list.text()).toContain('1 to 100. Default 24.')
|
||||
expect(list.text()).toContain('5, 25, 50 or 100. Default 25.')
|
||||
expect(list.text()).not.toContain('next_cursor')
|
||||
})
|
||||
|
||||
it('states the multipart field on the upload and the owner-only 403 and rate limit on invites', async () => {
|
||||
|
||||
@@ -79,7 +79,7 @@ beforeEach(() => {
|
||||
afterEach(unmountPage)
|
||||
|
||||
describe('The Duplicates tab', () => {
|
||||
it('is enabled, writes the URL, requests filter=duplicates with limit=100 and shows sort and view', async () => {
|
||||
it('is enabled, writes the URL, requests filter=duplicates at size 100 and shows sort and view', async () => {
|
||||
await mountPage({ list: duplicatesList() })
|
||||
expect(document.body.querySelector('[aria-label="Sort"]')).not.toBeNull()
|
||||
|
||||
@@ -90,7 +90,7 @@ describe('The Duplicates tab', () => {
|
||||
await settle()
|
||||
|
||||
expect(router.currentRoute.value.query).toEqual({ filter: 'duplicates' })
|
||||
expect(lastListPath()).toBe('/v1/media/?filter=duplicates&sort=newest&limit=100')
|
||||
expect(lastListPath()).toBe('/v1/media/?filter=duplicates&sort=newest&page=1&size=100')
|
||||
expect(tab('Duplicates').getAttribute('aria-selected')).toBe('true')
|
||||
expect(tabs().map(textOf)).toEqual(['All 12', 'Unused 2', 'Duplicates 2'])
|
||||
expect(document.body.querySelector('[data-testid="duplicates-count"]')?.textContent).toBe(
|
||||
@@ -123,7 +123,9 @@ describe('The Duplicates tab', () => {
|
||||
vi.advanceTimersByTime(120)
|
||||
await flushPromises()
|
||||
|
||||
expect(lastListPath()).toBe('/v1/media/?q=floor&filter=duplicates&sort=newest&limit=100')
|
||||
expect(lastListPath()).toBe(
|
||||
'/v1/media/?q=floor&filter=duplicates&sort=newest&page=1&size=100',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import router from '@/router'
|
||||
import { listCalls, mountPage, press, settle, tab, textOf, unmountPage } from './mediaTestKit'
|
||||
import { pagedHandler, parsePaged, library } from './mediaPagingKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
|
||||
return { ...actual, useToast: () => ({ add: vi.fn(), remove: vi.fn(), update: vi.fn() }) }
|
||||
})
|
||||
|
||||
const KEY = 'renewcms-page-size-media'
|
||||
|
||||
function calls() {
|
||||
return listCalls().map(([requested]) => parsePaged(requested as string))
|
||||
}
|
||||
|
||||
function lastCall() {
|
||||
return calls().at(-1)
|
||||
}
|
||||
|
||||
function fullPath() {
|
||||
return router.currentRoute.value.fullPath
|
||||
}
|
||||
|
||||
function selectText() {
|
||||
return textOf(document.body.querySelector('[aria-label="Rows per page"]'))
|
||||
}
|
||||
|
||||
async function open(path = '/media') {
|
||||
await mountPage({ list: pagedHandler() }, path)
|
||||
}
|
||||
|
||||
async function reopen(path = '/media') {
|
||||
await unmountPage()
|
||||
await open(path)
|
||||
}
|
||||
|
||||
async function choose(label: string) {
|
||||
document.body.querySelector<HTMLElement>('[aria-label="Rows per page"]')?.click()
|
||||
await flushPromises()
|
||||
const option = Array.from(document.body.querySelectorAll('[role="option"]')).find(
|
||||
(element) => element.textContent?.trim() === label,
|
||||
)
|
||||
;(option as HTMLElement).click()
|
||||
await settle()
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await unmountPage()
|
||||
})
|
||||
|
||||
describe('Choosing a size stores it', () => {
|
||||
it.each([
|
||||
['5 per page', '5', '/media?size=5'],
|
||||
['25 per page', '25', '/media'],
|
||||
['50 per page', '50', '/media?size=50'],
|
||||
['100 per page', '100', '/media?size=100'],
|
||||
])('%s', async (label, stored, url) => {
|
||||
await open()
|
||||
await choose(label)
|
||||
|
||||
expect(localStorage.getItem(KEY)).toBe(stored)
|
||||
expect(fullPath()).toBe(url)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Restoring the stored size', () => {
|
||||
it('restores the stored size when /media is opened without a size', async () => {
|
||||
localStorage.setItem(KEY, '5')
|
||||
await open()
|
||||
|
||||
expect(calls()).toMatchObject([{ page: 1, size: 5 }])
|
||||
expect(selectText()).toBe('5 per page')
|
||||
expect(fullPath()).toBe('/media?size=5')
|
||||
})
|
||||
|
||||
it('keeps the size after leaving the page and coming back', async () => {
|
||||
await open()
|
||||
await choose('5 per page')
|
||||
await unmountPage()
|
||||
await router.push('/')
|
||||
await open()
|
||||
|
||||
expect(lastCall()).toMatchObject({ size: 5 })
|
||||
expect(selectText()).toBe('5 per page')
|
||||
})
|
||||
|
||||
it('keeps the size across a full page refresh', async () => {
|
||||
await open()
|
||||
await choose('5 per page')
|
||||
|
||||
await reopen('/media?size=5')
|
||||
expect(lastCall()).toMatchObject({ size: 5 })
|
||||
expect(selectText()).toBe('5 per page')
|
||||
|
||||
await reopen('/media')
|
||||
expect(lastCall()).toMatchObject({ size: 5 })
|
||||
expect(selectText()).toBe('5 per page')
|
||||
})
|
||||
|
||||
it('keeps the stored size across paging, search, tabs, sort and view', async () => {
|
||||
localStorage.setItem(KEY, '50')
|
||||
await open('/media?size=50')
|
||||
document.body.querySelector<HTMLButtonElement>('button[aria-label="Next page"]')?.click()
|
||||
await settle()
|
||||
tab('Unused').focus()
|
||||
press(tab('Unused'), 'Enter')
|
||||
await settle()
|
||||
await router.replace({ query: { size: '50', sort: 'largest' } })
|
||||
await settle()
|
||||
await router.replace({ query: { size: '50', view: 'list' } })
|
||||
await settle()
|
||||
await router.replace({ query: { size: '50', q: 'image' } })
|
||||
await settle()
|
||||
|
||||
expect(calls().length).toBeGreaterThan(4)
|
||||
expect(calls().every((call) => call.size === 50)).toBe(true)
|
||||
expect(localStorage.getItem(KEY)).toBe('50')
|
||||
})
|
||||
|
||||
it('uses the stored size when only a page is given', async () => {
|
||||
localStorage.setItem(KEY, '5')
|
||||
await open('/media?page=3')
|
||||
|
||||
expect(lastCall()).toMatchObject({ page: 3, size: 5 })
|
||||
expect(fullPath()).toBe('/media?page=3&size=5')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Replacing the stored size', () => {
|
||||
it('replaces the stored size when another one is chosen', async () => {
|
||||
localStorage.setItem(KEY, '5')
|
||||
await open()
|
||||
await choose('100 per page')
|
||||
|
||||
expect(localStorage.getItem(KEY)).toBe('100')
|
||||
await reopen()
|
||||
expect(lastCall()).toMatchObject({ size: 100 })
|
||||
})
|
||||
|
||||
it('remembers a return to 25', async () => {
|
||||
localStorage.setItem(KEY, '5')
|
||||
await open()
|
||||
await choose('25 per page')
|
||||
|
||||
expect(localStorage.getItem(KEY)).toBe('25')
|
||||
await reopen()
|
||||
expect(lastCall()).toMatchObject({ size: 25 })
|
||||
expect(fullPath()).toBe('/media')
|
||||
})
|
||||
})
|
||||
|
||||
describe('URL precedence', () => {
|
||||
it('lets an explicit size win without storing it', async () => {
|
||||
localStorage.setItem(KEY, '5')
|
||||
await open('/media?size=100')
|
||||
|
||||
expect(lastCall()).toMatchObject({ page: 1, size: 100 })
|
||||
expect(selectText()).toBe('100 per page')
|
||||
expect(localStorage.getItem(KEY)).toBe('5')
|
||||
await reopen()
|
||||
expect(lastCall()).toMatchObject({ size: 5 })
|
||||
})
|
||||
|
||||
it('ignores the stored size for an invalid size in the URL', async () => {
|
||||
localStorage.setItem(KEY, '5')
|
||||
await open('/media?size=10')
|
||||
|
||||
expect(calls()).toMatchObject([{ page: 1, size: 25 }])
|
||||
expect(fullPath()).toBe('/media')
|
||||
expect(localStorage.getItem(KEY)).toBe('5')
|
||||
})
|
||||
})
|
||||
|
||||
describe('An invalid stored value falls back to 25', () => {
|
||||
it.each(['10', 'abc', '0', '-5', '25.5', ''])('stored %j', async (stored) => {
|
||||
localStorage.setItem(KEY, stored)
|
||||
await open()
|
||||
|
||||
expect(calls()).toMatchObject([{ page: 1, size: 25 }])
|
||||
expect(selectText()).toBe('25 per page')
|
||||
expect(fullPath()).toBe('/media')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Unavailable storage never breaks the page', () => {
|
||||
async function expectWorking() {
|
||||
await open()
|
||||
expect(calls()).toMatchObject([{ page: 1, size: 25 }])
|
||||
expect(document.body.querySelector('[role="alert"]')).toBeNull()
|
||||
await choose('50 per page')
|
||||
expect(lastCall()).toMatchObject({ page: 1, size: 50 })
|
||||
expect(fullPath()).toBe('/media?size=50')
|
||||
}
|
||||
|
||||
it('throws on getItem', async () => {
|
||||
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
||||
throw new Error('denied')
|
||||
})
|
||||
await expectWorking()
|
||||
})
|
||||
|
||||
it('throws on setItem (quota)', async () => {
|
||||
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||
throw new Error('quota')
|
||||
})
|
||||
await expectWorking()
|
||||
})
|
||||
|
||||
it('is undefined', async () => {
|
||||
vi.stubGlobal('localStorage', undefined)
|
||||
await expectWorking()
|
||||
})
|
||||
})
|
||||
|
||||
describe('The three lists keep separate sizes', () => {
|
||||
it('reads only the media key on /media', async () => {
|
||||
localStorage.setItem('renewcms-page-size-articles', '50')
|
||||
localStorage.setItem('renewcms-page-size-activity', '100')
|
||||
localStorage.setItem(KEY, '5')
|
||||
await open()
|
||||
|
||||
expect(lastCall()).toMatchObject({ size: 5 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('The Duplicates tab ignores the stored size', () => {
|
||||
it('loads at size 100, keeps the stored value and sends it again on All', async () => {
|
||||
localStorage.setItem(KEY, '5')
|
||||
await mountPage({ list: pagedHandler(library(30, true)) }, '/media?filter=duplicates')
|
||||
|
||||
expect(calls().every((call) => call.size === 100)).toBe(true)
|
||||
expect(localStorage.getItem(KEY)).toBe('5')
|
||||
|
||||
tab('All').focus()
|
||||
press(tab('All'), 'Enter')
|
||||
await settle()
|
||||
|
||||
expect(lastCall()).toMatchObject({ page: 1, size: 5, filter: 'all' })
|
||||
expect(localStorage.getItem(KEY)).toBe('5')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,515 @@
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import router from '@/router'
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||
import {
|
||||
bulkBar,
|
||||
callsTo,
|
||||
cards,
|
||||
checkboxFor,
|
||||
deferred,
|
||||
findButton,
|
||||
listCalls,
|
||||
mountPage,
|
||||
press,
|
||||
selectAllCheckbox,
|
||||
settle,
|
||||
tab,
|
||||
textOf,
|
||||
unmountPage,
|
||||
} from './mediaTestKit'
|
||||
import { library, pagedBody, pagedHandler, parsePaged, PAGED_COUNTS } from './mediaPagingKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
|
||||
return { ...actual, useToast: () => ({ add: vi.fn(), remove: vi.fn(), update: vi.fn() }) }
|
||||
})
|
||||
|
||||
const FIRST_PAGE_REQUEST = '/v1/media/?filter=all&sort=newest&page=1&size=25'
|
||||
|
||||
function summary() {
|
||||
return textOf(document.body.querySelector('[data-testid="paginator-summary"]'))
|
||||
}
|
||||
|
||||
function control(label: string) {
|
||||
return document.body.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`)
|
||||
}
|
||||
|
||||
function paginatorNav() {
|
||||
return document.body.querySelector('nav[aria-label="Pagination"]')
|
||||
}
|
||||
|
||||
function paginatorButtons() {
|
||||
return Array.from(paginatorNav()?.querySelectorAll<HTMLButtonElement>('button') ?? [])
|
||||
}
|
||||
|
||||
function pageButtons() {
|
||||
return paginatorButtons().filter((button) => /^\d+$/.test(textOf(button)))
|
||||
}
|
||||
|
||||
function sizeTrigger() {
|
||||
return document.body.querySelector<HTMLElement>('[aria-label="Rows per page"]')
|
||||
}
|
||||
|
||||
function path() {
|
||||
return router.currentRoute.value.fullPath
|
||||
}
|
||||
|
||||
function alertText() {
|
||||
return textOf(document.body.querySelector('[role="alert"], [role="status"]:not(.sr-only)'))
|
||||
}
|
||||
|
||||
function listPaths() {
|
||||
return listCalls().map(([requested]) => requested as string)
|
||||
}
|
||||
|
||||
async function click(element: HTMLElement | null | undefined) {
|
||||
element?.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
async function choose(label: string) {
|
||||
sizeTrigger()?.click()
|
||||
await flushPromises()
|
||||
const option = Array.from(document.body.querySelectorAll('[role="option"]')).find(
|
||||
(element) => element.textContent?.trim() === label,
|
||||
)
|
||||
;(option as HTMLElement).click()
|
||||
await settle()
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await unmountPage()
|
||||
})
|
||||
|
||||
describe('44 First page', () => {
|
||||
it('requests page 1 of 25, shows the range and disables First and Previous', async () => {
|
||||
await mountPage({ list: pagedHandler() })
|
||||
|
||||
expect(listPaths()).toEqual([FIRST_PAGE_REQUEST])
|
||||
expect(summary()).toBe('Showing 1–25 of 312')
|
||||
expect(control('First page')?.disabled).toBe(true)
|
||||
expect(control('Previous page')?.disabled).toBe(true)
|
||||
expect(control('Next page')?.disabled).toBe(false)
|
||||
expect(control('Last page')?.disabled).toBe(false)
|
||||
expect(pageButtons()[0]?.getAttribute('aria-current')).toBe('page')
|
||||
expect(path()).toBe('/media')
|
||||
expect(cards()).toHaveLength(25)
|
||||
})
|
||||
})
|
||||
|
||||
describe('45 Middle page', () => {
|
||||
it('marks page 7 as current with ellipses and every control enabled', async () => {
|
||||
await mountPage({ list: pagedHandler() }, '/media?page=7')
|
||||
|
||||
expect(summary()).toBe('Showing 151–175 of 312')
|
||||
const current = pageButtons().filter((button) => button.getAttribute('aria-current'))
|
||||
expect(current.map(textOf)).toEqual(['7'])
|
||||
expect(current[0]?.className).toContain('bg-(--rc-sand)')
|
||||
expect(paginatorNav()?.textContent).toContain('…')
|
||||
expect(paginatorButtons().every((button) => !button.disabled)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('46 Last page', () => {
|
||||
it('shows 12 images, the last range and disables Next and Last', async () => {
|
||||
await mountPage({ list: pagedHandler() }, '/media?page=13')
|
||||
|
||||
expect(cards()).toHaveLength(12)
|
||||
expect(summary()).toBe('Showing 301–312 of 312')
|
||||
expect(control('Next page')?.disabled).toBe(true)
|
||||
expect(control('Last page')?.disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Single page', () => {
|
||||
it('keeps the summary and the page-size select and hides the paginator', async () => {
|
||||
await mountPage({ list: pagedHandler(library(12), { all: 12, unused: 2, duplicates: 2 }) })
|
||||
|
||||
expect(summary()).toBe('Showing 1–12 of 12')
|
||||
expect(sizeTrigger()).not.toBeNull()
|
||||
expect(paginatorNav()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('47 List view', () => {
|
||||
it('puts the paginator under the table on desktop', async () => {
|
||||
await mountPage({ list: pagedHandler() }, '/media?view=list&page=2')
|
||||
|
||||
const table = document.body.querySelector('[data-testid="media-row"]')!
|
||||
const bar = document.body.querySelector('[data-testid="paginator"]')!
|
||||
expect(table.compareDocumentPosition(bar) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
expect(pageButtons().length).toBeGreaterThan(2)
|
||||
})
|
||||
|
||||
it('shows Previous, "Page 2 of 13" and Next under the card list on mobile', async () => {
|
||||
await mountPage({ list: pagedHandler() }, '/media?view=list&page=2', MOBILE_WIDTH)
|
||||
|
||||
expect(paginatorNav()?.textContent).toContain('Page 2 of 13')
|
||||
expect(pageButtons()).toHaveLength(0)
|
||||
expect(paginatorButtons()).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('48 Loading next page', () => {
|
||||
it('shows the skeleton, disables the controls and moves the URL only on success', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
const all = library()
|
||||
const next = deferred<unknown>()
|
||||
await mountPage({
|
||||
list: (requested) =>
|
||||
parsePaged(requested).page === 2 ? next.promise : pagedBody(all, requested),
|
||||
})
|
||||
|
||||
await click(control('Next page'))
|
||||
await vi.advanceTimersByTimeAsync(150)
|
||||
|
||||
expect(document.body.querySelector('[aria-busy="true"]')).not.toBeNull()
|
||||
expect(summary()).toBe('Loading page 2…')
|
||||
expect(paginatorButtons().every((button) => button.disabled)).toBe(true)
|
||||
expect(path()).toBe('/media')
|
||||
|
||||
next.resolve(pagedBody(all, '/v1/media/?filter=all&sort=newest&page=2&size=25'))
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
await flushPromises()
|
||||
|
||||
expect(summary()).toBe('Showing 26–50 of 312')
|
||||
expect(path()).toBe('/media?page=2')
|
||||
})
|
||||
})
|
||||
|
||||
describe.each([
|
||||
['a 500 response', () => Promise.reject(new ApiError(500))],
|
||||
['a request that never reaches the server', () => Promise.reject(new TypeError('offline'))],
|
||||
])('49 Error loading a page keeps the previous images (%s)', (_name, failure) => {
|
||||
it('keeps the images and the URL, shows the notice and Retry loads the page', async () => {
|
||||
const all = library()
|
||||
let failing = false
|
||||
await mountPage(
|
||||
{
|
||||
list: (requested) =>
|
||||
failing && parsePaged(requested).page === 7
|
||||
? failure()
|
||||
: pagedBody(all, requested),
|
||||
},
|
||||
'/media?page=6',
|
||||
)
|
||||
const before = cards().map((card) => card.getAttribute('data-media-id'))
|
||||
failing = true
|
||||
|
||||
await click(control('Next page'))
|
||||
await settle()
|
||||
|
||||
expect(cards().map((card) => card.getAttribute('data-media-id'))).toEqual(before)
|
||||
expect(path()).toBe('/media?page=6')
|
||||
expect(alertText()).toContain("Couldn't load page 7. Showing page 6.")
|
||||
expect(control('Next page')?.disabled).toBe(false)
|
||||
|
||||
failing = false
|
||||
await click(findButton('Retry'))
|
||||
await settle()
|
||||
|
||||
expect(summary()).toBe('Showing 151–175 of 312')
|
||||
expect(path()).toBe('/media?page=7')
|
||||
expect(alertText()).not.toContain("Couldn't load page")
|
||||
})
|
||||
})
|
||||
|
||||
describe('50 Page out of range', () => {
|
||||
it('replaces the URL with the last page and shows the notice', async () => {
|
||||
const push = vi.spyOn(router, 'push')
|
||||
const replace = vi.spyOn(router, 'replace')
|
||||
|
||||
await mountPage({ list: pagedHandler() }, '/media?page=99')
|
||||
|
||||
expect(listPaths().map((requested) => parsePaged(requested).page)).toEqual([99, 13])
|
||||
expect(replace).toHaveBeenCalledWith({ query: { page: '13' } })
|
||||
expect(push).toHaveBeenCalledTimes(1)
|
||||
expect(path()).toBe('/media?page=13')
|
||||
expect(alertText()).toContain("That page doesn't exist. Showing the last page.")
|
||||
expect(summary()).toBe('Showing 301–312 of 312')
|
||||
})
|
||||
|
||||
it('shows the empty library state with no redirect when the total is 0', async () => {
|
||||
await mountPage(
|
||||
{ list: pagedHandler([], { all: 0, unused: 0, duplicates: 0 }) },
|
||||
'/media?page=5',
|
||||
)
|
||||
|
||||
expect(listPaths()).toHaveLength(1)
|
||||
expect(document.body.textContent).toContain('No images yet')
|
||||
expect(path()).toBe('/media')
|
||||
})
|
||||
|
||||
it('shows the no results state after a search with total 0', async () => {
|
||||
await mountPage({ list: pagedHandler() }, '/media?q=zzzz&page=5')
|
||||
|
||||
expect(document.body.textContent).toContain('No images match "zzzz"')
|
||||
expect(path()).toBe('/media?q=zzzz')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Controls reset to page 1', () => {
|
||||
async function expectResetTo(path1: string, act: () => Promise<void> | void) {
|
||||
await mountPage({ list: pagedHandler() }, '/media?page=7')
|
||||
const before = listPaths().length
|
||||
await act()
|
||||
await settle()
|
||||
const added = listPaths().slice(before)
|
||||
expect(added.length).toBeGreaterThan(0)
|
||||
expect(parsePaged(added.at(-1)!).page).toBe(1)
|
||||
expect(path()).toBe(path1)
|
||||
expect(textOf(document.body)).toContain('All 312')
|
||||
}
|
||||
|
||||
it('on a search, once the debounce elapsed', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
await mountPage({ list: pagedHandler() }, '/media?page=7')
|
||||
const input = document.body.querySelector<HTMLInputElement>(
|
||||
'input[placeholder="Search by filename"]',
|
||||
)!
|
||||
input.value = 'image'
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await vi.advanceTimersByTimeAsync(119)
|
||||
expect(listPaths()).toHaveLength(1)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await flushPromises()
|
||||
|
||||
expect(parsePaged(listPaths().at(-1)!).page).toBe(1)
|
||||
expect(path()).toBe('/media?q=image')
|
||||
expect(textOf(document.body)).toContain('All 312')
|
||||
})
|
||||
|
||||
it('on a tab change', () =>
|
||||
expectResetTo('/media?filter=unused', () => {
|
||||
tab('Unused').focus()
|
||||
press(tab('Unused'), 'Enter')
|
||||
}))
|
||||
|
||||
it('on a sort change', () =>
|
||||
expectResetTo('/media?sort=oldest', async () => {
|
||||
await router.replace({ query: { sort: 'oldest' } })
|
||||
}))
|
||||
|
||||
it('on a view change', () =>
|
||||
expectResetTo('/media?view=list', () => {
|
||||
document.body.querySelector<HTMLElement>('[aria-label="List view"]')?.click()
|
||||
}))
|
||||
|
||||
it('on a variants filter change', () =>
|
||||
expectResetTo('/media?variants=with', () => {
|
||||
Array.from(document.body.querySelectorAll<HTMLElement>('button'))
|
||||
.find((button) => textOf(button) === 'With variants')
|
||||
?.click()
|
||||
}))
|
||||
})
|
||||
|
||||
describe('51 Page size menu', () => {
|
||||
it.each([
|
||||
['5 per page', 5, '/media?size=5'],
|
||||
['25 per page', 25, '/media'],
|
||||
['50 per page', 50, '/media?size=50'],
|
||||
['100 per page', 100, '/media?size=100'],
|
||||
])('choosing %s requests page 1 of %i and ends on %s', async (label, size, url) => {
|
||||
await mountPage({ list: pagedHandler() }, '/media?page=3')
|
||||
|
||||
await choose(label)
|
||||
|
||||
expect(parsePaged(listPaths().at(-1)!)).toMatchObject({ page: 1, size })
|
||||
expect(path()).toBe(url)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Invalid URL values fall back silently', () => {
|
||||
it.each(['size=10&page=abc', 'page=0', 'page=-2', 'page=1.5', 'size=0', 'size=200'])(
|
||||
'/media?%s requests page 1 of 25 and replaces the URL',
|
||||
async (query) => {
|
||||
await mountPage({ list: pagedHandler() }, `/media?${query}`)
|
||||
|
||||
expect(listPaths()).toEqual([FIRST_PAGE_REQUEST])
|
||||
expect(path()).toBe('/media')
|
||||
expect(alertText()).toBe('')
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('Pagination is kept across the other controls', () => {
|
||||
it('keeps size, sort and filter on Next', async () => {
|
||||
await mountPage(
|
||||
{ list: pagedHandler(library(), PAGED_COUNTS) },
|
||||
'/media?size=50&sort=largest&filter=unused',
|
||||
)
|
||||
|
||||
await click(control('Next page'))
|
||||
|
||||
expect(listPaths().at(-1)).toBe('/v1/media/?filter=unused&sort=largest&page=2&size=50')
|
||||
expect(path()).toBe('/media?page=2&size=50&filter=unused&sort=largest')
|
||||
})
|
||||
})
|
||||
|
||||
describe('52 Focus-visible and keyboard', () => {
|
||||
it('gives every control the primary outline with a 2 px offset', async () => {
|
||||
await mountPage({ list: pagedHandler() }, '/media?page=7')
|
||||
|
||||
for (const element of [...paginatorButtons(), sizeTrigger()]) {
|
||||
expect(element?.className).toContain('focus-visible:outline-2')
|
||||
expect(element?.className).toContain('focus-visible:outline-offset-2')
|
||||
expect(element?.className).toContain('focus-visible:outline-primary')
|
||||
}
|
||||
})
|
||||
|
||||
it('opens the page-size list on ArrowDown and closes it on Escape', async () => {
|
||||
await mountPage({ list: pagedHandler() })
|
||||
const trigger = sizeTrigger()!
|
||||
trigger.focus()
|
||||
|
||||
trigger.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }),
|
||||
)
|
||||
await flushPromises()
|
||||
expect(document.body.querySelector('[role="listbox"]')).not.toBeNull()
|
||||
;(document.activeElement ?? document.body).dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }),
|
||||
)
|
||||
await flushPromises()
|
||||
expect(document.body.querySelector('[role="listbox"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Select all covers the current page only', () => {
|
||||
it('selects the 25 images of page 2 and clears on the next page', async () => {
|
||||
await mountPage({ list: pagedHandler() }, '/media?page=2')
|
||||
expect(textOf(document.body)).toContain('Select all 25 images')
|
||||
|
||||
selectAllCheckbox().click()
|
||||
await flushPromises()
|
||||
expect(textOf(bulkBar())).toContain('25 selected')
|
||||
|
||||
await click(control('Next page'))
|
||||
await settle()
|
||||
|
||||
expect(bulkBar()).toBeNull()
|
||||
expect(checkboxFor('image-51.jpg').getAttribute('aria-checked')).toBe('false')
|
||||
})
|
||||
})
|
||||
|
||||
describe('The Duplicates tab', () => {
|
||||
it('loads the whole set at size 100, groups it, shows no paginator and drops page and size', async () => {
|
||||
const all = library(130, true)
|
||||
await mountPage(
|
||||
{ list: pagedHandler(all, { all: 312, unused: 2, duplicates: 65 }) },
|
||||
'/media?filter=duplicates&page=4&size=5',
|
||||
)
|
||||
|
||||
expect(listPaths()).toEqual([
|
||||
'/v1/media/?filter=duplicates&sort=newest&page=1&size=100',
|
||||
'/v1/media/?filter=duplicates&sort=newest&page=2&size=100',
|
||||
])
|
||||
expect(document.body.querySelectorAll('[data-testid="duplicate-group"]')).toHaveLength(65)
|
||||
expect(paginatorNav()).toBeNull()
|
||||
expect(document.body.querySelector('[data-testid="paginator-summary"]')).toBeNull()
|
||||
expect(sizeTrigger()).toBeNull()
|
||||
expect(path()).toBe('/media?filter=duplicates')
|
||||
})
|
||||
|
||||
it('returns to the paginated list when All is selected', async () => {
|
||||
await mountPage({ list: pagedHandler(library(130, true)) }, '/media?filter=duplicates')
|
||||
|
||||
tab('All').focus()
|
||||
press(tab('All'), 'Enter')
|
||||
await settle()
|
||||
|
||||
expect(listPaths().at(-1)).toBe(FIRST_PAGE_REQUEST)
|
||||
expect(paginatorNav()).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Deleting the last image of the last page', () => {
|
||||
it('steps back one page, replaces the URL and refreshes the counts', async () => {
|
||||
const all = library(26)
|
||||
let removed = false
|
||||
await mountPage(
|
||||
{
|
||||
list: (requested) => {
|
||||
const live = removed ? all.slice(0, 25) : all
|
||||
const counts = { all: live.length, unused: 2, duplicates: 2 }
|
||||
return pagedBody(live, requested, counts)
|
||||
},
|
||||
remove: () => {
|
||||
removed = true
|
||||
},
|
||||
},
|
||||
'/media?page=2',
|
||||
)
|
||||
expect(cards()).toHaveLength(1)
|
||||
|
||||
cards()[0]!.click()
|
||||
await settle()
|
||||
findButton('Delete')!.click()
|
||||
await settle()
|
||||
const confirm = Array.from(document.body.querySelectorAll('button')).find(
|
||||
(button) => textOf(button) === 'Delete image',
|
||||
)
|
||||
confirm?.click()
|
||||
await settle()
|
||||
|
||||
expect(summary()).toBe('Showing 1–25 of 25')
|
||||
expect(path()).toBe('/media')
|
||||
expect(textOf(document.body)).toContain('All 25')
|
||||
})
|
||||
})
|
||||
|
||||
describe('A background refresh keeps the current page', () => {
|
||||
it('requests page 7 again after a delete, with no skeleton and the same URL', async () => {
|
||||
const all = library()
|
||||
await mountPage({ list: pagedHandler(all), remove: () => undefined }, '/media?page=7')
|
||||
const before = listPaths().length
|
||||
|
||||
cards()[0]!.click()
|
||||
await settle()
|
||||
findButton('Delete')!.click()
|
||||
await settle()
|
||||
Array.from(document.body.querySelectorAll('button'))
|
||||
.find((button) => textOf(button) === 'Delete image')
|
||||
?.click()
|
||||
await settle()
|
||||
|
||||
const added = listPaths().slice(before)
|
||||
expect(added).toEqual(['/v1/media/?filter=all&sort=newest&page=7&size=25'])
|
||||
expect(document.body.querySelector('[data-testid="media-skeleton"]')).toBeNull()
|
||||
expect(path()).toBe('/media?page=7')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Hash lookups do not touch the page', () => {
|
||||
it('sends hash, page=1 and size=100 and leaves the URL and the paginator alone', async () => {
|
||||
await mountPage(
|
||||
{
|
||||
list: pagedHandler(library(60, true)),
|
||||
hash: () => pagedBody(library(3, true), '/v1/media/?page=1&size=100'),
|
||||
},
|
||||
'/media?page=2',
|
||||
)
|
||||
|
||||
cards()[0]!.click()
|
||||
await settle()
|
||||
|
||||
expect(callsTo('GET', /\?hash=hash-\d+&page=1&size=100$/)).toHaveLength(1)
|
||||
expect(path()).toBe('/media?page=2')
|
||||
expect(summary()).toBe('Showing 26–50 of 60')
|
||||
})
|
||||
})
|
||||
@@ -261,16 +261,33 @@ describe('The selection follows the query', () => {
|
||||
expect(bulkBar()).toBeNull()
|
||||
})
|
||||
|
||||
it('clears when the sort changes and stays when the view changes', async () => {
|
||||
it('clears when the view changes', async () => {
|
||||
await mountPage()
|
||||
await select('north-wall.jpg', 'kitchen-after.webp')
|
||||
|
||||
await router.replace({ query: { view: 'list' } })
|
||||
await settle()
|
||||
expect(textOf(bulkBar())).toContain('2 selected')
|
||||
|
||||
await router.replace({ query: { view: 'list', sort: 'oldest' } })
|
||||
expect(bulkBar()).toBeNull()
|
||||
})
|
||||
|
||||
it('clears when the sort changes', async () => {
|
||||
await mountPage()
|
||||
await select('north-wall.jpg', 'kitchen-after.webp')
|
||||
|
||||
await router.replace({ query: { sort: 'oldest' } })
|
||||
await settle()
|
||||
|
||||
expect(bulkBar()).toBeNull()
|
||||
})
|
||||
|
||||
it('clears when the page changes', async () => {
|
||||
await mountPage()
|
||||
await select('north-wall.jpg')
|
||||
|
||||
await router.push({ query: { page: '2' } })
|
||||
await settle()
|
||||
|
||||
expect(bulkBar()).toBeNull()
|
||||
})
|
||||
|
||||
|
||||
@@ -63,7 +63,9 @@ function hashLookups() {
|
||||
|
||||
function hashHandlerFor(name: string, existing = [5, 6, 12]): Handlers['hash'] {
|
||||
return (path) =>
|
||||
path === `/v1/media/?hash=${hexOf(name)}` ? listBody(fixture(existing)) : listBody([])
|
||||
path === `/v1/media/?hash=${hexOf(name)}&page=1&size=100`
|
||||
? listBody(fixture(existing))
|
||||
: listBody([])
|
||||
}
|
||||
|
||||
function headerButton(label: string) {
|
||||
@@ -424,7 +426,7 @@ describe('A row whose content already exists', () => {
|
||||
await mountWithDuplicate()
|
||||
|
||||
expect(hashLookups().map(([path]) => path)).toEqual([
|
||||
`/v1/media/?hash=${hexOf('floorboards-2.jpg')}`,
|
||||
`/v1/media/?hash=${hexOf('floorboards-2.jpg')}&page=1&size=100`,
|
||||
])
|
||||
expect(FakeXhr.instances).toHaveLength(0)
|
||||
const row = uploadRows()[0]!
|
||||
|
||||
@@ -165,8 +165,8 @@ describe('The variants filter', () => {
|
||||
})
|
||||
|
||||
it.each([
|
||||
['With variants', '?variants=with', 'filter=all&sort=newest&variants=with&limit=100'],
|
||||
['Without', '?variants=without', 'filter=all&sort=newest&variants=without&limit=100'],
|
||||
['With variants', '?variants=with', 'filter=all&sort=newest&variants=with&page=1&size=25'],
|
||||
['Without', '?variants=without', 'filter=all&sort=newest&variants=without&page=1&size=25'],
|
||||
])('Choosing %s writes the URL and requests the list', async (label, search, request) => {
|
||||
await mountPage()
|
||||
|
||||
@@ -202,7 +202,7 @@ describe('The variants filter', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/media')
|
||||
expect(lastListPath()).toBe('/v1/media/?filter=all&sort=newest&limit=100')
|
||||
expect(lastListPath()).toBe('/v1/media/?filter=all&sort=newest&page=1&size=25')
|
||||
})
|
||||
|
||||
it.each([
|
||||
@@ -223,7 +223,7 @@ describe('The variants filter', () => {
|
||||
])('The query string is the source of truth: %s', async (search, request, selected, url) => {
|
||||
await mountPage({}, `/media${search}`)
|
||||
|
||||
expect(lastListPath()).toBe(`/v1/media/?${request}&limit=100`)
|
||||
expect(lastListPath()).toBe(`/v1/media/?${request}&page=1&size=25`)
|
||||
expect(segment(selected).getAttribute('aria-pressed')).toBe('true')
|
||||
expect(router.currentRoute.value.fullPath).toBe(url)
|
||||
})
|
||||
@@ -294,7 +294,7 @@ describe('Sorting by total size', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.query).toEqual({ sort: 'total_size' })
|
||||
expect(lastListPath()).toBe('/v1/media/?filter=all&sort=total_size&limit=100')
|
||||
expect(lastListPath()).toBe('/v1/media/?filter=all&sort=total_size&page=1&size=25')
|
||||
expect(textOf(document.body.querySelector('[aria-label="Sort"]'))).toBe(
|
||||
'Largest total size (original + variants)',
|
||||
)
|
||||
@@ -321,7 +321,7 @@ describe('Sorting by total size', () => {
|
||||
await mountPage({}, '/media?sort=total')
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/media')
|
||||
expect(lastListPath()).toBe('/v1/media/?filter=all&sort=newest&limit=100')
|
||||
expect(lastListPath()).toBe('/v1/media/?filter=all&sort=newest&page=1&size=25')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -226,7 +226,7 @@ function fixture(ids?: number[]): MediaItem[] {
|
||||
}
|
||||
|
||||
function listBody(items: MediaItem[], counts = { all: 12, unused: 2, duplicates: 2 }) {
|
||||
return { items, next_cursor: null, counts }
|
||||
return { items, total: items.length, page: 1, size: 25, counts }
|
||||
}
|
||||
|
||||
function listWithout(id: number) {
|
||||
@@ -440,7 +440,7 @@ describe('Default, many images', () => {
|
||||
)
|
||||
expect(document.body.querySelector('h1')?.textContent?.trim()).toBe('Media')
|
||||
expect(listCalls().map(([path]) => path)).toEqual([
|
||||
'/v1/media/?filter=all&sort=newest&limit=100',
|
||||
'/v1/media/?filter=all&sort=newest&page=1&size=25',
|
||||
])
|
||||
expect(tabs().map(textOf)).toEqual(['All 12', 'Unused 2', 'Duplicates 2'])
|
||||
expect(tab('All').getAttribute('aria-selected')).toBe('true')
|
||||
@@ -565,7 +565,8 @@ describe('List view', () => {
|
||||
|
||||
expect(router.currentRoute.value.query).toEqual({ view: 'list' })
|
||||
expect(document.body.querySelectorAll('[data-testid="media-row"]')).toHaveLength(12)
|
||||
expect(listCalls()).toHaveLength(1)
|
||||
expect(listCalls()).toHaveLength(2)
|
||||
expect(lastListPath()).toBe('/v1/media/?filter=all&sort=newest&page=1&size=25')
|
||||
|
||||
activate(ariaLabel('Grid view'))
|
||||
await flushPromises()
|
||||
@@ -603,14 +604,14 @@ describe('List view', () => {
|
||||
|
||||
describe('The query string is the source of truth', () => {
|
||||
it.each([
|
||||
['?filter=unused', 'filter=unused&sort=newest&limit=100'],
|
||||
['?sort=largest', 'filter=all&sort=largest&limit=100'],
|
||||
['?sort=name', 'filter=all&sort=name&limit=100'],
|
||||
['?sort=oldest', 'filter=all&sort=oldest&limit=100'],
|
||||
['?q=wall', 'q=wall&filter=all&sort=newest&limit=100'],
|
||||
['?view=list', 'filter=all&sort=newest&limit=100'],
|
||||
['?filter=bogus&sort=zzz', 'filter=all&sort=newest&limit=100'],
|
||||
['?filter=duplicates', 'filter=duplicates&sort=newest&limit=100'],
|
||||
['?filter=unused', 'filter=unused&sort=newest&page=1&size=25'],
|
||||
['?sort=largest', 'filter=all&sort=largest&page=1&size=25'],
|
||||
['?sort=name', 'filter=all&sort=name&page=1&size=25'],
|
||||
['?sort=oldest', 'filter=all&sort=oldest&page=1&size=25'],
|
||||
['?q=wall', 'q=wall&filter=all&sort=newest&page=1&size=25'],
|
||||
['?view=list', 'filter=all&sort=newest&page=1&size=25'],
|
||||
['?filter=bogus&sort=zzz', 'filter=all&sort=newest&page=1&size=25'],
|
||||
['?filter=duplicates', 'filter=duplicates&sort=newest&page=1&size=100'],
|
||||
])('opening /media%s requests %s', async (url, request) => {
|
||||
await mountPage({}, `/media${url}`)
|
||||
|
||||
@@ -657,7 +658,7 @@ describe('The query string is the source of truth', () => {
|
||||
vi.advanceTimersByTime(1)
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.query).toEqual({ q: 'wall' })
|
||||
expect(lastListPath()).toBe('/v1/media/?q=wall&filter=all&sort=newest&limit=100')
|
||||
expect(lastListPath()).toBe('/v1/media/?q=wall&filter=all&sort=newest&page=1&size=25')
|
||||
|
||||
tab('Unused').focus()
|
||||
press(tab('Unused'), 'Enter')
|
||||
@@ -712,7 +713,7 @@ describe('The Duplicates tab', () => {
|
||||
|
||||
expect(tab('Duplicates').getAttribute('aria-selected')).toBe('true')
|
||||
expect(router.currentRoute.value.query).toEqual({ filter: 'duplicates' })
|
||||
expect(lastListPath()).toBe('/v1/media/?filter=duplicates&sort=newest&limit=100')
|
||||
expect(lastListPath()).toBe('/v1/media/?filter=duplicates&sort=newest&page=1&size=100')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -813,7 +814,7 @@ describe('Empty library and no results', () => {
|
||||
vi.advanceTimersByTime(120)
|
||||
await flushPromises()
|
||||
|
||||
expect(lastListPath()).toBe('/v1/media/?q=wall&filter=unused&sort=newest&limit=100')
|
||||
expect(lastListPath()).toBe('/v1/media/?q=wall&filter=unused&sort=newest&page=1&size=25')
|
||||
expect(document.body.textContent).toContain('No images match "wall"')
|
||||
expect(document.body.textContent).toContain(
|
||||
'Nothing matches in Unused. Check the spelling, or clear the filters.',
|
||||
@@ -998,7 +999,9 @@ describe('Detail panel', () => {
|
||||
|
||||
await clickCard('floorboards.jpg')
|
||||
|
||||
expect(requestMock.mock.calls.some(([path]) => path === '/v1/media/?hash=0e')).toBe(true)
|
||||
expect(
|
||||
requestMock.mock.calls.some(([path]) => path === '/v1/media/?hash=0e&page=1&size=100'),
|
||||
).toBe(true)
|
||||
const alert = panel()!.querySelector('[data-testid="duplicate-alert"]')!
|
||||
expect(textOf(alert)).toContain('Identical to 2 other images')
|
||||
expect(textOf(alert)).toContain(
|
||||
|
||||
@@ -59,7 +59,7 @@ const QUALITY_ERROR = 'Enter a value from 1 to 100.'
|
||||
const DIMENSION_ERROR = 'Set a width, a height, or both.'
|
||||
const DIMENSION_HELP = 'Set a width, a height, or both. 1–8000 px. Images are never enlarged.'
|
||||
const SAVE_ERROR_TITLE = "Couldn't save the variant"
|
||||
const URL_PREFIX = '/static/uploadedImages/north-wall.jpg?v='
|
||||
const URL_PREFIX = '/static/uploadedImages/variant-sample.jpg?v='
|
||||
const WARNING_ALERT =
|
||||
'Changing dimensions, fit, format or quality purges 244 generated files; they will be regenerated on next request.'
|
||||
|
||||
@@ -438,7 +438,7 @@ describe('Rule: Form states', () => {
|
||||
'i-lucide-circle-dot',
|
||||
)
|
||||
expect(previewText()).toContain('hero')
|
||||
expect(previewText()).toContain('1200×630 · about 96 KB')
|
||||
expect(previewText()).toContain('1200×630 · about 30 KB')
|
||||
})
|
||||
|
||||
it.each([
|
||||
@@ -853,14 +853,14 @@ describe('Rule: Saving an edit', () => {
|
||||
it('Edit: dirty', async () => {
|
||||
await mountPage()
|
||||
await openEditForm('hero')
|
||||
expect(previewText()).toContain('1200×630 · about 96 KB')
|
||||
expect(previewText()).toContain('1200×630 · about 30 KB')
|
||||
|
||||
await typeInto(qualityInput(), '70')
|
||||
|
||||
expect(saveButton('Save changes').disabled).toBe(false)
|
||||
expect(footerStatus()).toBe('Unsaved changes')
|
||||
expect(qualityInput()!.value).toBe('70')
|
||||
expect(previewText()).toContain('1200×630 · about 71 KB')
|
||||
expect(previewText()).toContain('1200×630 · about 22 KB')
|
||||
expect(form().querySelector('[data-testid="edit-warning"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
@@ -954,13 +954,13 @@ describe('Rule: Preview and ImageCompare', () => {
|
||||
|
||||
const panel = form()
|
||||
expect(panel.querySelector('h3')?.textContent).toBe('Preview')
|
||||
expect(panel.textContent).toContain('Sample: north-wall.jpg')
|
||||
expect(panel.textContent).toContain('Sample: variant-sample.jpg')
|
||||
const box = panel.querySelector('[data-testid="image-compare"]')!
|
||||
expect(box.className).toContain('aspect-3/2')
|
||||
expect(box.className).toContain('border')
|
||||
expect(box.className).toContain('overflow-hidden')
|
||||
const labels = box.nextElementSibling!
|
||||
expect(textOf(labels)).toBe('Original 4032 × 3024 · 3.9 MB hero 1200×630 · about 96 KB')
|
||||
expect(textOf(labels)).toBe('Original 1500 × 1000 · 84 KB hero 1200×630 · about 30 KB')
|
||||
expect(panel.textContent).toContain(
|
||||
'Drag the divider, or focus it and use the arrow keys. The estimate comes from a sample; output is never larger than the source.',
|
||||
)
|
||||
@@ -1078,7 +1078,7 @@ describe('Rule: Preview and ImageCompare', () => {
|
||||
expect(overlay.className).toContain('bottom-3')
|
||||
expect(overlay.className).toContain('left-3')
|
||||
expect(textOf(box.nextElementSibling!)).toBe(
|
||||
'Original 4032 × 3024 · 3.9 MB name No output yet',
|
||||
'Original 1500 × 1000 · 84 KB name No output yet',
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1089,7 +1089,7 @@ describe('Rule: Preview and ImageCompare', () => {
|
||||
await typeInto(field('width'), '800')
|
||||
|
||||
expect(compareInput().getAttribute('aria-label')).toBe('Compare original and name')
|
||||
expect(previewText()).toContain('name 800×600 · about')
|
||||
expect(previewText()).toContain('name 800×533 · about')
|
||||
expect(
|
||||
Array.from(form().querySelectorAll('span.absolute.top-2')).map(
|
||||
(chip) => chip.textContent,
|
||||
@@ -1107,7 +1107,7 @@ describe('Rule: Preview and ImageCompare', () => {
|
||||
|
||||
expect(labels.map(Number)).toEqual([...labels.map(Number)].sort((a, b) => a - b))
|
||||
await setFormat('png')
|
||||
expect(previewText()).toContain('1200×630 · about 310 KB')
|
||||
expect(previewText()).toContain('1200×630 · about 84 KB')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface MountOptions extends ActivityApiMock {
|
||||
role?: string
|
||||
desktop?: boolean
|
||||
verified?: boolean
|
||||
path?: string
|
||||
}
|
||||
|
||||
export interface RecordedCall {
|
||||
@@ -69,7 +70,7 @@ export function callsTo(path: string) {
|
||||
}
|
||||
|
||||
export async function mountActivity(options: MountOptions = {}) {
|
||||
const { desktop = true, verified = true } = options
|
||||
const { desktop = true, verified = true, path = '/activity' } = options
|
||||
const role = 'role' in options ? options.role : 'owner'
|
||||
const api: ActivityApiMock = {
|
||||
list: () => pageOf(activityItems),
|
||||
@@ -126,7 +127,7 @@ export async function mountActivity(options: MountOptions = {}) {
|
||||
requestBlob,
|
||||
} as unknown as ReturnType<typeof useApi>)
|
||||
|
||||
await router.push('/activity')
|
||||
await router.push(path)
|
||||
harness.wrapper = mount(ActivityView, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [router, uiPlugin] },
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -248,8 +248,18 @@ export function fixture(ids?: number[]): MediaItem[] {
|
||||
)
|
||||
}
|
||||
|
||||
export function listBody(items: MediaItem[], counts = FULL_COUNTS) {
|
||||
return { items, next_cursor: null, counts }
|
||||
export function listBody(
|
||||
items: MediaItem[],
|
||||
counts = FULL_COUNTS,
|
||||
paging: { total?: number; page?: number; size?: number } = {},
|
||||
) {
|
||||
return {
|
||||
items,
|
||||
total: paging.total ?? items.length,
|
||||
page: paging.page ?? 1,
|
||||
size: paging.size ?? 25,
|
||||
counts,
|
||||
}
|
||||
}
|
||||
|
||||
export function deferred<T>() {
|
||||
|
||||
+233
-2
@@ -1254,11 +1254,15 @@ interface MediaSample {
|
||||
createdAt: string
|
||||
duplicate: boolean
|
||||
tone: number
|
||||
variantCount: number
|
||||
variantSize: number
|
||||
}
|
||||
|
||||
const mediaSamples: MediaSample[] = [
|
||||
{
|
||||
id: 1,
|
||||
variantCount: 4,
|
||||
variantSize: 266240,
|
||||
filename: 'north-wall.jpg',
|
||||
size: 4089446,
|
||||
width: 4032,
|
||||
@@ -1272,6 +1276,8 @@ const mediaSamples: MediaSample[] = [
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
variantCount: 5,
|
||||
variantSize: 419840,
|
||||
filename: 'kitchen-after.webp',
|
||||
size: 1258291,
|
||||
width: 2400,
|
||||
@@ -1285,6 +1291,8 @@ const mediaSamples: MediaSample[] = [
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
variantCount: 5,
|
||||
variantSize: 404480,
|
||||
filename: 'kitchen-before.webp',
|
||||
size: 1153434,
|
||||
width: 2400,
|
||||
@@ -1298,6 +1306,8 @@ const mediaSamples: MediaSample[] = [
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
variantCount: 0,
|
||||
variantSize: 0,
|
||||
filename: 'logo-renew.svg',
|
||||
size: 8192,
|
||||
width: null,
|
||||
@@ -1311,6 +1321,8 @@ const mediaSamples: MediaSample[] = [
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
variantCount: 3,
|
||||
variantSize: 245760,
|
||||
filename: 'floorboards-copy.jpg',
|
||||
size: 2516582,
|
||||
width: 3600,
|
||||
@@ -1324,6 +1336,8 @@ const mediaSamples: MediaSample[] = [
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
variantCount: 0,
|
||||
variantSize: 0,
|
||||
filename: 'IMG_2208.jpg',
|
||||
size: 2516582,
|
||||
width: 3600,
|
||||
@@ -1337,6 +1351,8 @@ const mediaSamples: MediaSample[] = [
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
variantCount: 2,
|
||||
variantSize: 153600,
|
||||
filename: 'IMG_4471.jpg',
|
||||
size: 3250585,
|
||||
width: 3600,
|
||||
@@ -1350,6 +1366,8 @@ const mediaSamples: MediaSample[] = [
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
variantCount: 0,
|
||||
variantSize: 0,
|
||||
filename: 'window-frame.jpg',
|
||||
size: 1887437,
|
||||
width: 3000,
|
||||
@@ -1363,6 +1381,8 @@ const mediaSamples: MediaSample[] = [
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
variantCount: 1,
|
||||
variantSize: 61440,
|
||||
filename: 'stone-wall-detail.jpg',
|
||||
size: 3250585,
|
||||
width: 3600,
|
||||
@@ -1376,6 +1396,8 @@ const mediaSamples: MediaSample[] = [
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
variantCount: 3,
|
||||
variantSize: 194560,
|
||||
filename: 'bathroom-tiles.png',
|
||||
size: 880640,
|
||||
width: 1800,
|
||||
@@ -1389,6 +1411,8 @@ const mediaSamples: MediaSample[] = [
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
variantCount: 4,
|
||||
variantSize: 286720,
|
||||
filename: 'lime-plaster.jpg',
|
||||
size: 2202009,
|
||||
width: 3200,
|
||||
@@ -1402,6 +1426,8 @@ const mediaSamples: MediaSample[] = [
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
variantCount: 5,
|
||||
variantSize: 440320,
|
||||
filename: 'floorboards.jpg',
|
||||
size: 2516582,
|
||||
width: 3600,
|
||||
@@ -1454,6 +1480,8 @@ function mediaItem(sample: MediaSample, filename = sample.filename) {
|
||||
created_at: sample.createdAt,
|
||||
used_in: sample.usedIn,
|
||||
duplicate: sample.duplicate,
|
||||
variant_count: sample.variantCount,
|
||||
variant_size: sample.variantSize,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1464,7 +1492,46 @@ function mediaItems(ids?: number[], without: number[] = []) {
|
||||
}
|
||||
|
||||
function mediaList(items: unknown[], counts = mediaFullCounts) {
|
||||
return { items, next_cursor: null, counts }
|
||||
return { items, total: items.length, page: 1, size: 25, counts }
|
||||
}
|
||||
|
||||
const mediaPagedTotal = 312
|
||||
const mediaPagedCounts = { all: mediaPagedTotal, unused: 2, duplicates: 2 }
|
||||
const mediaPastLastPage = 99
|
||||
const mediaFailingPage = '7'
|
||||
|
||||
function mediaPagedApi() {
|
||||
return mediaApi({
|
||||
'GET /v1/media/': {
|
||||
status: 200,
|
||||
bodyFor: (url) => {
|
||||
const page = Number(url.searchParams.get('page'))
|
||||
return {
|
||||
items: page === mediaPastLastPage ? [] : mediaItems(),
|
||||
total: mediaPagedTotal,
|
||||
page,
|
||||
size: Number(url.searchParams.get('size')),
|
||||
counts: mediaPagedCounts,
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function isMediaPage(url: URL, wanted: string) {
|
||||
return url.pathname === '/v1/media/' && url.searchParams.get('page') === wanted
|
||||
}
|
||||
|
||||
async function scrollEverythingToTop(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
for (const element of document.querySelectorAll('*')) {
|
||||
element.scrollTop = 0
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function pressNextPage(page: Page) {
|
||||
await page.getByRole('button', { name: 'Next page' }).click()
|
||||
}
|
||||
|
||||
function mediaApi(overrides: Record<string, ApiMock> = {}): Record<string, ApiMock> {
|
||||
@@ -2015,6 +2082,59 @@ const mediaStates: Record<string, VisualState> = {
|
||||
mediaDuplicatesPath,
|
||||
),
|
||||
'List failed to load': mediaState(mediaApi({ 'GET /v1/media/': { status: 500 } })),
|
||||
'Pagination: first page': mediaState(mediaPagedApi()),
|
||||
'Pagination: middle page': mediaState(mediaPagedApi(), undefined, '/media?page=7'),
|
||||
'Pagination: last page': mediaState(mediaPagedApi(), undefined, '/media?page=13'),
|
||||
'Pagination: list view': mediaState(mediaPagedApi(), undefined, '/media?view=list&page=2'),
|
||||
'Pagination: loading next page': mediaState(
|
||||
mediaPagedApi(),
|
||||
async (page) => {
|
||||
await page.route(
|
||||
(url) => isMediaPage(url, mediaFailingPage),
|
||||
() => undefined,
|
||||
)
|
||||
await pressNextPage(page)
|
||||
await page.getByTestId('media-skeleton').waitFor()
|
||||
await scrollEverythingToTop(page)
|
||||
},
|
||||
'/media?page=6',
|
||||
),
|
||||
'Pagination: error loading a page': mediaState(
|
||||
mediaPagedApi(),
|
||||
async (page) => {
|
||||
await page.route(
|
||||
(url) => isMediaPage(url, mediaFailingPage),
|
||||
(route) => route.fulfill({ status: 500 }),
|
||||
)
|
||||
await pressNextPage(page)
|
||||
await page.getByText("Couldn't load page 7. Showing page 6.").waitFor()
|
||||
await scrollEverythingToTop(page)
|
||||
},
|
||||
'/media?page=6',
|
||||
),
|
||||
'Pagination: page out of range': mediaState(
|
||||
mediaPagedApi(),
|
||||
async (page) => {
|
||||
await page.getByText("That page doesn't exist. Showing the last page.").waitFor()
|
||||
await scrollEverythingToTop(page)
|
||||
},
|
||||
`/media?page=${mediaPastLastPage}`,
|
||||
),
|
||||
'Pagination: page size menu': mediaState(mediaPagedApi(), async (page) => {
|
||||
await page.locator('[aria-label="Rows per page"]').click()
|
||||
await page.getByRole('listbox').waitFor()
|
||||
await scrollEverythingToTop(page)
|
||||
}),
|
||||
'Pagination: focus-visible': mediaState(
|
||||
mediaPagedApi(),
|
||||
async (page) => {
|
||||
await page.getByRole('button', { name: 'Next page' }).focus()
|
||||
await page.keyboard.press('Shift+Tab')
|
||||
await page.keyboard.press('Tab')
|
||||
await scrollEverythingToTop(page)
|
||||
},
|
||||
'/media?page=7',
|
||||
),
|
||||
Offline: mediaState(mediaApi(), goOffline),
|
||||
}
|
||||
|
||||
@@ -2931,6 +3051,23 @@ const mediaPurgeStates: Record<string, VisualState> = {
|
||||
openMediaPurgeDialog,
|
||||
mediaDuplicatesPath,
|
||||
),
|
||||
'Purge duplicates: confirm': mediaState(
|
||||
mediaDuplicatesApi(undefined, {
|
||||
[purgePreviewPath]: { status: 200, body: purgePreviewBody() },
|
||||
}),
|
||||
openMediaPurgeDialog,
|
||||
mediaDuplicatesPath,
|
||||
),
|
||||
'Duplicates: no purge button (member)': mediaState(
|
||||
mediaDuplicatesApi(undefined, {
|
||||
'GET /v1/auth/me': {
|
||||
status: 200,
|
||||
body: { ...(mediaSession.body as object), role: 'member' },
|
||||
},
|
||||
}),
|
||||
undefined,
|
||||
mediaDuplicatesPath,
|
||||
),
|
||||
'Duplicates tab with the button': mediaState(
|
||||
mediaDuplicatesApi(),
|
||||
undefined,
|
||||
@@ -4255,6 +4392,98 @@ function withVariantExceptions(states: Record<string, VisualState>) {
|
||||
)
|
||||
}
|
||||
|
||||
const variantsRowMobileCeilings: Record<string, number> = {
|
||||
'Grid, many images': 33,
|
||||
'Grid, few images': 26,
|
||||
'List view': 19,
|
||||
Loading: 30,
|
||||
'Hover, focus and keyboard': 28,
|
||||
'Selection and bulk bar': 31,
|
||||
'Bulk delete: confirm': 23,
|
||||
'Bulk delete: in progress': 23,
|
||||
'Bulk delete: partly failed': 24,
|
||||
'Bulk delete: done': 30,
|
||||
'Detail panel: in use': 21,
|
||||
'Detail panel: unused': 20,
|
||||
'Copy URL': 21,
|
||||
'Rename: dialog': 15,
|
||||
'Rename: saving': 19,
|
||||
'Rename: validation': 15,
|
||||
'Rename: failed': 14,
|
||||
'Rename: done': 20,
|
||||
'Delete: image in use': 29,
|
||||
'Delete: in progress': 29,
|
||||
'Delete: done': 32,
|
||||
'Image already gone (404)': 31,
|
||||
'Upload: several files, mixed': 16,
|
||||
'Upload: summary': 15,
|
||||
'Upload: rejected files (413, 400)': 17,
|
||||
'Upload: too many requests (429)': 29,
|
||||
'Duplicates: groups': 29,
|
||||
'Merge: confirm': 13,
|
||||
'Merge: in progress': 13,
|
||||
'Merge: done': 29,
|
||||
Offline: 32,
|
||||
'Pagination: first page': 33,
|
||||
'Pagination: middle page': 33,
|
||||
'Pagination: last page': 33,
|
||||
'Pagination: list view': 19,
|
||||
'Pagination: loading next page': 30,
|
||||
'Pagination: error loading a page': 33,
|
||||
'Pagination: page out of range': 33,
|
||||
'Pagination: page size menu': 33,
|
||||
'Pagination: focus-visible': 33,
|
||||
'Purge duplicates: confirm': 24,
|
||||
'Duplicates: no purge button (member)': 30,
|
||||
'Duplicates tab with the button': 29,
|
||||
'Duplicates tab: offline': 29,
|
||||
}
|
||||
|
||||
function withVariantsRowException(
|
||||
states: Record<string, VisualState>,
|
||||
): Record<string, VisualState> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(states).map(([name, state]) => {
|
||||
const mobile = variantsRowMobileCeilings[name]
|
||||
if (mobile === undefined) {
|
||||
return [name, state]
|
||||
}
|
||||
return [
|
||||
name,
|
||||
{
|
||||
...state,
|
||||
exception: {
|
||||
maxPercent: { desktop: MEDIA_DESKTOP_BUDGET, mobile },
|
||||
reason: 'variants filter row is not drawn on the Media board (docs/design README: not folded into Media)',
|
||||
approvedBy: 'user (Florian Sylvain), 2026-10-13',
|
||||
},
|
||||
},
|
||||
]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const MEDIA_DESKTOP_BUDGET = 10
|
||||
|
||||
function withDetailTabsException(states: Record<string, VisualState>): Record<string, VisualState> {
|
||||
const name = 'Detail panel: duplicate'
|
||||
const state = states[name]
|
||||
if (!state) {
|
||||
return states
|
||||
}
|
||||
return {
|
||||
...states,
|
||||
[name]: {
|
||||
...state,
|
||||
exception: {
|
||||
maxPercent: { desktop: 11, mobile: 30 },
|
||||
reason: 'Details | Variants tab row not drawn on the Media board (drawn on the Image detail board)',
|
||||
approvedBy: 'user (Florian Sylvain), 2026-10-13',
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
activity: activityStates,
|
||||
profile: {
|
||||
@@ -5644,6 +5873,8 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
...withVariantExceptions(variantsMediaStates),
|
||||
...withVariantExceptions(variantsEditorStates),
|
||||
},
|
||||
media: { ...mediaStates, ...mediaPurgeStates },
|
||||
media: withDetailTabsException(
|
||||
withVariantsRowException({ ...mediaStates, ...mediaPurgeStates }),
|
||||
),
|
||||
'media-picker': pickerStates,
|
||||
}
|
||||
Reference in new issue
Block a user