feat(media): pagination
This commit is contained in:
1 parent
04dc151ed7
commit
63c1e7076e
65 files changed
+2960
-365
No files matched your search
+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).
|
||||
|
||||
Reference in new issue
Block a user