chore: groundwork for SEO fixes and tech&vis debt
This commit is contained in:
1 parent
02fb8751fd
commit
9f4ce426a8
11 files changed
+175
-24
No files matched your search
@@ -1,4 +1,18 @@
|
||||
# Change list for implementation (status after pass 2; SEO round drawn)
|
||||
# Change list for implementation (status after pass 2; SEO round drawn; cleanup round drawn)
|
||||
|
||||
## Cleanup round
|
||||
|
||||
1. Editor: site and authors, "Create author" states (4 states, desktop and mobile): the mobile dialog sat under the Details sheet (z-index 50 against 55) and was hidden. It now stacks above the sheet (z-index 60), as the SEO panel does. Full-width bottom sheet with grabber, same fields, Cancel left and "Create author" right (Cancel below on mobile, as the other mobile dialogs). Frame: `DirectoryFrame.dc.html`. The four states share the fix: dialog, "for me", slug taken, failed.
|
||||
2. Integration, "6. Errors": the 403 row gets the muted note "Site SEO defaults are owner only. Article SEO fields need only the right to edit the article." The row below shifts down by the wrapped note height (two 18 px lines desktop, three mobile, plus the 2 px row gap). The old inline parenthetical with the same sentence in the 403 cause text was removed, so it isn't said twice. Frame: `IntegrationPage.dc.html`.
|
||||
3. Integration, sitemap errors: 404 now shows the body `site_not_found` in code style (was "Site not found"); 400 now shows the generic body-mismatch text used by the other routes (was "Invalid page or size.").
|
||||
4. New groups on the Back-office board: "Sites: pagination" and "Authors: pagination", 10 states each (20 states, every one desktop and mobile; light and dark by the theme switch). Back-office total 409 → 429. Same pager as Articles and Media, sizes 5, 25, 50, 100, default 25 (no difference to report). One deviation: on a single page the page-size select is hidden and only "N sites" or "N authors" remains.
|
||||
5. Specs: screens/sites.md, screens/authors.md (pager states, URL rules, out-of-range fallback), screens/integration.md, states.md (pagination states, three HTTP rows), components.md (single-page variant, no new component), routes.md (Sites pagination in the URL).
|
||||
|
||||
**Backend assumptions to confirm.** GET /v1/site/ takes `q`, `page`, `size` and returns `X-Total-Count` (it returns every site today). GET /v1/author/ gets `page`, `size` and `X-Total-Count` (it has `q`).
|
||||
|
||||
**Open points.**
|
||||
- Sites has no search field. The two states that need one (empty after search, search resets to page 1) draw the Authors field on Sites with "Search by name or key". Drop those states if you don't want search on Sites.
|
||||
- Stale copy found, not changed: GET /v1/article/ on Integration still shows the 400 caption "Invalid page or size." while every other route, and now the sitemap, uses the body-mismatch text. The 404 row of the Errors table still lists only the four resource messages and doesn't mention `site_not_found`. The existing Sites and Authors "Default" frames don't draw the count line. README.md state counts and the "Pagination" notes were not updated (outside this round).
|
||||
|
||||
## SEO round: decisions (frames drawn)
|
||||
|
||||
|
||||
@@ -35,6 +35,8 @@ One component for every code or copyable value. Props: `tabs` (`{ k, label, code
|
||||
## Paginator
|
||||
First, previous, page numbers, next, last; "Showing 26–50 of 312"; page-size select "5 per page", "25 per page", "50 per page", "100 per page" (default 25). Mobile: previous, "Page 2 of 13", next. Hidden when there is one page. Buttons 32 px (44 px mobile), current page `--rc-sand` with `--ui-primary` border, disabled controls 40% opacity, focus ring 2 px `--ui-primary`. Page size and page live in the URL as `size` and `page`.
|
||||
|
||||
Variant for Sites and Authors (no new component): when everything fits on one page the page-size select is hidden together with the controls and the footer shows only the count line "N sites" or "N authors" in `--ui-text-muted`. Loading dims the whole pager to 40% and disables it; the summary reads "Loading page N…". Sizes are unchanged: 5, 25, 50, 100.
|
||||
|
||||
## Activity export
|
||||
Menu: "Export current view" (CSV, active filters, all pages) and "Export all activity…" (CSV, ignores filters; owner and admin only). Dialog: Format (read-only "CSV"; a CSV and JSON control appears when JSON ships, proposed), Date range (default "All time"). States: configure, "Preparing download…" (indeterminate bar, no percentage, Cancel left, "Preparing…" disabled right), "Download started" (Close), "Couldn't prepare the download" (Cancel, Retry), "Nothing to export" (Close), 403 "You can't export all activity" (Close). Synchronous: no job, no progress, no later notification.
|
||||
|
||||
|
||||
@@ -39,5 +39,7 @@
|
||||
## Sites and authors in the URL
|
||||
`/articles?site=journal` filters by site key; it combines with `page`, `size`, `tab` and `q`, and resets `page` to 1 when it changes. An unknown key is dropped from the URL with no error. `/authors?q=camille&page=2&size=25` follows the same pagination rules as Articles. Deep links: `/authors?edit={slug}` and `/settings/sites?edit={key}` open the slide-over for owner and admin (authors) and the owner (sites); an unknown value shows the info toast "That author no longer exists" or "That site no longer exists". For other roles `/authors?edit={slug}` opens the read-only detail, and `/settings/sites` and its `?edit=` link show the 403 page "You don't have access to Sites".
|
||||
|
||||
Sites pagination: `/settings/sites?q=&page=&size=` with the same rules as Articles (sizes 5, 25, 50, 100; default 25; defaults omitted; search and size changes reset to page 1; a page past the end falls back to the last page). API: GET /v1/site/?q=&page=&size=, total in `X-Total-Count` (design assumption). Authors: `/authors?q=&page=&size=` as above.
|
||||
|
||||
## SEO
|
||||
No new page routes. The panel and dialog are overlays on `/article/:id/edit`: `?panel=seo` (optionally `&field=seo_title|seo_description|slug|cover|authors|body` for the suggestion jump) and `?dialog=seo-api`. `/articles?seo=suggestions` is the Needs attention filter. API: GET /v1/article/{id}/seo, GET /v1/site/{key}/sitemap?page=&size=, PATCH /v1/article/{id} (new fields), PATCH /v1/site/{id} {site_name, title_template, default_description, social_image_id, default_robots}, GET /v1/article/?seo=ready|suggestions|hidden. 401 without a session; 403 owner_only on site SEO fields; 409 slug_taken {suggestion} on PATCH slug; 400 on invalid canonical_url or title_template.
|
||||
@@ -13,7 +13,7 @@ Visual reference: `RenewCMS Screens Back-office.dc.html`, groups "Authors", "Aut
|
||||
|
||||
### Authors (group "Authors", 8 states)
|
||||
|
||||
Sidebar entry between Media and Users, visible to every role. Owner and admin create, edit and delete any author. Other roles see the list and the detail read-only. Columns: avatar and name, slug, account (Linked or Guest), article count. Search by name or slug. Long names truncate in the row and show in full on hover. Above 25 authors the list uses the same paginator as Articles (not redrawn). List, panel and dialogs come from DirectoryFrame and are shared with Sites.
|
||||
Sidebar entry between Media and Users, visible to every role. Owner and admin create, edit and delete any author. Other roles see the list and the detail read-only. Columns: avatar and name, slug, account (Linked or Guest), article count. Search by name or slug. Long names truncate in the row and show in full on hover. Above 25 authors the list uses the same paginator as Articles; its states are drawn in "Authors: pagination". List, panel and dialogs come from DirectoryFrame and are shared with Sites.
|
||||
|
||||
| # | State | Trigger | Copy |
|
||||
|---|---|---|---|
|
||||
@@ -57,5 +57,26 @@ Owner and admin. Reassign: the dialog asks who gets the articles, with "Nobody"
|
||||
| 5 | Server fallback (409) | Fallback only. The dialog above always sends reassign_to, so this appears only when the server refuses a stale request because articles still credit the author. Drawn once, not as a flow. | Can't delete this author · "Camille Roche" is credited on 3 articles. Choose another author to credit instead, or remove Camille Roche from those articles first. · Close · Reassign articles |
|
||||
| 6 | No articles | An author with zero articles: plain confirm. | Delete this author? · "Léa Fontaine" will be deleted. No articles credit this author. This can't be undone. · Delete author |
|
||||
|
||||
### Authors: pagination (group "Authors: pagination", 10 states)
|
||||
|
||||
Route: `/authors?page=3&size=50`. API: GET /v1/author/?q=&page=&size= (total in `X-Total-Count`, array body unchanged; `q` already exists). Owner, admin and read-only roles see the same pager. Drawn with 128 authors (6 pages) and generated rows.
|
||||
|
||||
**URL.** `/authors?page=2&size=50`. `page` and `size` are the parameter names (as Articles and Activity). `size` is one of 5, 25, 50, 100; default 25. Defaults are omitted from the URL (`page=1`, `size=25`). Combined with search: `/authors?q=mar&page=2`. A search change or a size change resets `page` to 1 and drops it from the URL. An invalid `size` or `page` (not a positive integer) is replaced silently by the default with `history.replace`. A `page` past the last page falls back to the last page: the URL is replaced by `?page=<last>` and the info notice "That page doesn't exist. Showing the last page." is shown, as on Articles. Browser Back returns to the previous page of results.
|
||||
|
||||
**Pager.** The same component as Articles and Media, in the same footer position (below the list, right-aligned controls, summary and page-size select on the left; mobile: summary and select on one row, then Previous, "Page X of Y", Next). Nothing new is drawn. Sizes are the sizes of the existing pattern: 5, 25, 50, 100 (default 25). API: total in `X-Total-Count`, array body unchanged. Keyboard and aria as Articles. The only difference from Articles: on a single page the page-size select is hidden too and the footer holds only the count line "N authors" (Articles keeps its summary and select). Existing frames of this screen that show a short list (the "Default" frames above) don't draw the count line yet; it appears whenever the list has loaded.
|
||||
|
||||
| # | State | Trigger | Copy |
|
||||
|---|---|---|---|
|
||||
| 1 | First page | /authors. Previous and First are disabled (40% opacity, aria-disabled). Mobile: Previous disabled. | Showing 1–25 of 128 · 25 per page · Page 1 of 6 |
|
||||
| 2 | Middle page | /authors?page=3. Current page uses --rc-sand and --rc-on-sand with a --ui-primary border; ellipses mark skipped ranges once there are more than 7 pages. | Showing 51–75 of 128 · Page 3 of 6 |
|
||||
| 3 | Last page | /authors?page=6. Next and Last are disabled. | Showing 126–128 of 128 · Page 6 of 6 |
|
||||
| 4 | Single page | Everything fits on one page (6 authors). The pager and the page-size select are hidden; the count line stays in the footer position. Zero results use state 8 instead. | 6 authors |
|
||||
| 5 | Page size menu | The select opens upward. Sizes are the Articles sizes: 5, 25, 50, 100; default 25. The chosen size is saved as size and the page resets to 1. | 5 per page · 25 per page · 50 per page · 100 per page |
|
||||
| 6 | Loading a page | Next, Previous, a page number or a size change was used. The rows are replaced by the existing skeleton rows (8 desktop, 6 mobile). The pager stays and is disabled (40% opacity, not focusable); the summary reads "Loading page 4…" and the page number shown is still 3. | Loading page 4… |
|
||||
| 7 | Error loading a page | 500 or network failure on a page change. The rows of page 3 stay, the pager keeps "Page 3", and the inline error with Retry sits above the pager. Retry repeats the request for page 4. | Couldn't load page 4. Showing page 3. · Check your connection and try again. · Retry |
|
||||
| 8 | Empty after search | A search matched nothing. The existing empty state replaces the list; no pager, no count. Clear search returns to /authors and page 1. | No authors match "zoe" · Check the spelling, or search for a shorter part of the name. · Clear search |
|
||||
| 9 | Search resets to page 1 | Searching "mar" from page 5 goes to /authors?q=mar (page omitted). Any search change, and any size change, resets the page to 1. | Showing 1–25 of 31 |
|
||||
| 10 | Long labels on mobile | A large total on a 390 px screen. The summary and the page-size select share one row and wrap onto two if needed; Previous, the "Page 12 of 52" label and Next stay on one row (the two buttons flex, the label never wraps). Totals over 999 use thousands separators in both labels. | Showing 276–300 of 1,284 · Page 12 of 52 |
|
||||
|
||||
## SEO round
|
||||
No change to the Authors screen. Name, profile URL (website) and social links feed `authors[].name`, `authors[].url`, `authors[].same_as` and the JSON-LD author (`sameAs`) in GET /v1/article/{id}/seo. They are visible in the editor's "What the frontend gets" dialog.
|
||||
@@ -16,7 +16,7 @@ Sticky section index on the right (desktop), "Jump to" select (mobile). A refere
|
||||
- Note 1: "A key can't create, list or revoke API keys, log in, register, verify or reset a password, or call anything under `/v1/system`. Those routes answer 403 "This action needs a real session." An invalid, unknown, expired or revoked key answers 401, even on public routes, and so does a key whose user was deleted or unverified."
|
||||
- Note 2: "Each key can make 120 requests per minute. Beyond that the API answers 429 with a `Retry-After` header."
|
||||
- Groups: Articles (7), Media (6), Users and invites (6), Settings (1), Session only (12 routes, 3 sub-groups; every `/v1/system` route is in this group). Session-only banner: "These routes need a signed-in browser session. With an API key they answer 403 "This action needs a real session.""
|
||||
- Errors: "Every error is a plain-text body with a single sentence. The status code is in the HTTP status line, and the Content-Type is text/plain." Table 400, 401, 403, 404, 429, 500 with the real messages below. Sample: a status line plus the text body (`HTTP/1.1 403 Forbidden`, `Content-Type: text/plain`, blank line, `This action needs a real session.`).
|
||||
- Errors: "Every error is a plain-text body with a single sentence. The status code is in the HTTP status line, and the Content-Type is text/plain." Table 400, 401, 403, 404, 429, 500 with the real messages below. The 403 row has a note line under it: "Site SEO defaults are owner only. Article SEO fields need only the right to edit the article." (12/18, `--ui-text-muted`, in the cause column, 2 px under the cause text). It is the only row with a note; rows below it move down by the wrapped note height (two 18 px lines on desktop, three on mobile, plus 2 px). Sample: a status line plus the text body (`HTTP/1.1 403 Forbidden`, `Content-Type: text/plain`, blank line, `This action needs a real session.`).
|
||||
- Notes: image URL prefix, CORS (existing copy), and "Article routes accept `?format=markdown`, `?format=html` or `?format=json` to choose how `body` is returned. With `json`, `body` is the stored JSON structure. Articles have `body_format: markdown` by default."
|
||||
|
||||
## EndpointCard states
|
||||
@@ -34,8 +34,8 @@ Desktop 1440×900, mobile 390×844, light and dark.
|
||||
Plain text, one sentence. Used in the table and in the per-endpoint error captions.
|
||||
- 400: "The request cannot be processed due to a mismatch in the format of the body." / "The expiry must be a date in the future." Image requests (`/media/photo.png?v=`) answer 400 with a JSON body (`status`, `error`, `message`, `detail`) in two cases: `{ "status": 400, "error": "unknown_variant", "message": "Unknown variant", "detail": "unknown" }` for an unknown or disabled variant, and `{ "status": 400, "error": "unsupported_parameter", "message": "Unsupported parameter", "detail": "width" }` for any other query parameter (`width`, `height`, `format`, `quality`). Both are rows of the "Responses" table in "Images and variants".
|
||||
- 401: "You must be logged in to access this resource."
|
||||
- 403: "This action needs a real session." (session-only routes called with a key) / "Only the owner can do this." / "You must verify your account to access this resource."
|
||||
- 404: "Article not found" / "Image not found" / "Invite not found" / "API key not found"
|
||||
- 403: "This action needs a real session." (session-only routes called with a key) / "Only the owner can do this." / "You must verify your account to access this resource." Note under the 403 row: "Site SEO defaults are owner only. Article SEO fields need only the right to edit the article." The inline parenthetical that said the same thing in the row's cause text was removed to avoid saying it twice.
|
||||
- 404: "Article not found" / "Image not found" / "Invite not found" / "API key not found" / `site_not_found` (GET /v1/site/{key}/sitemap only; body is the code, not a sentence)
|
||||
- 429: "Too many requests. Please try again later." A per-key limit also sends `Retry-After`.
|
||||
- 500: "Internal server error"
|
||||
- 409, only as examples under their endpoints: `POST /v1/user/invite` "This address already has an account."; `POST /v1/media/merge` "Some of the images to merge changed. Refresh and try again." There is no 409 or 422 row in the table.
|
||||
@@ -112,5 +112,5 @@ New section "Images and variants" on the Integration page, after Media and befor
|
||||
## Search and sharing (SEO round)
|
||||
New group "Search and sharing" after Articles, in the sidebar index and the mobile Jump to select. Two cards in the existing endpoint style (collapsed, method badge, auth badge "Public", example, 401 and 404 rows, copy buttons):
|
||||
- GET /v1/article/{id}/seo: the resolved SEO object. Fields: title, description, canonical_url, robots, og{type,title,description,url,site_name,image,image_alt,locale}, twitter{card,title,description,image,image_alt}, image{url,alt,source}, authors[{name,url,same_as}], published_at, modified_at, fallbacks, json_ld. Drafts answer 404 to anonymous callers.
|
||||
- GET /v1/site/{key}/sitemap?page=&size=: yes, shown per site by key. Items: { url, modified_at, noindex }. Total in X-Total-Count. Sizes 25, 50, 100, 500, default 100. 404 "Site not found".
|
||||
- GET /v1/site/{key}/sitemap?page=&size=: yes, shown per site by key. Items: { url, modified_at, noindex }. Total in X-Total-Count. Sizes 25, 50, 100, 500, default 100. Errors: 400 "The request cannot be processed due to a mismatch in the format of the body." (same text as the other routes; bad `page` or `size`), 401, 404 `site_not_found` (unknown site key; this is the body the API returns, shown as a code and not rewritten as prose).
|
||||
PATCH /v1/article/{id} lists the new optional fields (slug, excerpt, seo_title, seo_description, cover_image_id, cover_image_alt, social_title, social_description, social_image_id, canonical_url, noindex, seo_dismissed). The 403 row of the HTTP status table adds: site SEO defaults are owner only; article SEO fields need only the right to edit the article.
|
||||
@@ -53,6 +53,45 @@ Owner only. A site with articles cannot be deleted without a destination: the di
|
||||
| 2 | Choose destination | The select is open. The site being deleted is not listed. | Journal is not offered. Options show name and host. |
|
||||
| 3 | No articles | A site with zero articles: plain confirm, no select. | Delete this site? · "Archive" will be deleted. No articles belong to it. This can't be undone. · Delete site |
|
||||
|
||||
### Sites: pagination (group "Sites: pagination", 10 states)
|
||||
|
||||
Route: `/settings/sites?page=2&size=50`. API: GET /v1/site/?q=&page=&size= (design assumption to confirm: `q`, `page`, `size` and `X-Total-Count` on the site list; today the list returns every site). Owner only, like the rest of Sites. Drawn with 61 sites (3 pages) and generated rows.
|
||||
|
||||
**URL.** `/settings/sites?page=2&size=50`. `page` and `size` are the parameter names (as Articles and Activity). `size` is one of 5, 25, 50, 100; default 25. Defaults are omitted from the URL (`page=1`, `size=25`). Combined with search: `/settings/sites?q=renew&page=2`. A search change or a size change resets `page` to 1 and drops it from the URL. An invalid `size` or `page` (not a positive integer) is replaced silently by the default with `history.replace`. A `page` past the last page falls back to the last page: the URL is replaced by `?page=<last>` and the info notice "That page doesn't exist. Showing the last page." is shown, as on Articles. Browser Back returns to the previous page of results.
|
||||
|
||||
**Pager.** The same component as Articles and Media, in the same footer position (below the list, right-aligned controls, summary and page-size select on the left; mobile: summary and select on one row, then Previous, "Page X of Y", Next). Nothing new is drawn. Sizes are the sizes of the existing pattern: 5, 25, 50, 100 (default 25). API: total in `X-Total-Count`, array body unchanged. Keyboard and aria as Articles. The only difference from Articles: on a single page the page-size select is hidden too and the footer holds only the count line "N sites" (Articles keeps its summary and select). Existing frames of this screen that show a short list (the "Default" frames above) don't draw the count line yet; it appears whenever the list has loaded.
|
||||
|
||||
**Search on Sites.** The Sites screen has no search field today. States 8 and 9 need one, so the pagination frames add the Authors field with the placeholder "Search by name or key" (name, key and base URL are matched). Treat it as a proposal; if you prefer no search on Sites, drop states 8 and 9 and the `q` parameter.
|
||||
|
||||
| # | State | Trigger | Copy |
|
||||
|---|---|---|---|
|
||||
| 1 | First page | /settings/sites. Previous and First are disabled (40% opacity, aria-disabled). Mobile: Previous disabled. | Showing 1–25 of 61 · 25 per page · Page 1 of 3 |
|
||||
| 2 | Middle page | /settings/sites?page=2. Current page uses --rc-sand and --rc-on-sand with a --ui-primary border; ellipses mark skipped ranges once there are more than 7 pages. | Showing 26–50 of 61 · Page 2 of 3 |
|
||||
| 3 | Last page | /settings/sites?page=3. Next and Last are disabled. | Showing 51–61 of 61 · Page 3 of 3 |
|
||||
| 4 | Single page | Everything fits on one page (4 sites). The pager and the page-size select are hidden; the count line stays in the footer position. Zero results use state 8 instead. | 4 sites |
|
||||
| 5 | Page size menu | The select opens upward. Sizes are the Articles sizes: 5, 25, 50, 100; default 25. The chosen size is saved as size and the page resets to 1. | 5 per page · 25 per page · 50 per page · 100 per page |
|
||||
| 6 | Loading a page | Next, Previous, a page number or a size change was used. The rows are replaced by the existing skeleton rows (8 desktop, 6 mobile). The pager stays and is disabled (40% opacity, not focusable); the summary reads "Loading page 3…" and the page number shown is still 2. | Loading page 3… |
|
||||
| 7 | Error loading a page | 500 or network failure on a page change. The rows of page 2 stay, the pager keeps "Page 2", and the inline error with Retry sits above the pager. Retry repeats the request for page 3. | Couldn't load page 3. Showing page 2. · Check your connection and try again. · Retry |
|
||||
| 8 | Empty after search | A search matched nothing. The existing empty state replaces the list; no pager, no count. Clear search returns to /settings/sites and page 1. | No sites match "blog" · Check the spelling, or search for a shorter part of the name. · Clear search |
|
||||
| 9 | Search resets to page 1 | Searching "renew" from page 4 goes to /settings/sites?q=renew (page omitted). Any search change, and any size change, resets the page to 1. | Showing 1–25 of 27 |
|
||||
| 10 | Long labels on mobile | A large total on a 390 px screen. The summary and the page-size select share one row and wrap onto two if needed; Previous, the "Page 6 of 13" label and Next stay on one row (the two buttons flex, the label never wraps). Totals over 999 use thousands separators in both labels. | Showing 126–150 of 312 · Page 6 of 13 |
|
||||
|
||||
|
||||
|
||||
Route: `/settings/sites`. API: PATCH /v1/site/{id} {site_name, title_template, default_description, social_image_id, default_robots}.
|
||||
|
||||
Owner only, like the rest of Sites. The existing add and edit form gains three fields under the default social image (reused, not duplicated): Site name, Title template with a live example, and Default description. The default robots policy keeps its place. A box under the form explains how articles use these defaults. Sites has no separate read-only details view: the edit form is the details, and other roles get the existing 403 page (403 owner_only on PATCH, 401 without a session).
|
||||
|
||||
| # | State | Trigger | Copy |
|
||||
|---|---|---|---|
|
||||
| 1 | Edit: SEO defaults | Edit on the default site. | Default social image (optional) · Site name · Shown in titles through the template, and sent as the Open Graph site name. · Title template · Use %title% for the article title and %site% for the site name. · Example · How we price a bathroom renovation \| Renew · Default description (optional) · Used when an article has no description, excerpt or body text. · How articles use these defaults · Title · Article title, then the template. · Description · Used last, after the article's description, excerpt and body. · Image · Used when an article has no cover image. · Robots · Used unless an article is hidden from search engines. |
|
||||
| 2 | Add: defaults | New sites start with the default template and nothing else. | Site name · e.g. Renew · %title% \| %site% · One or two sentences about the site. |
|
||||
| 3 | Template: custom | The example updates as the owner types. | Example · How we price a bathroom renovation – Journal |
|
||||
| 4 | Template: not valid | The template needs %title%. Nothing is sent. | The template must contain %title%. |
|
||||
| 5 | Save failed | 500 or network failure. | Couldn't save the site. Your changes are still here. Try again in a moment. |
|
||||
|
||||
Owner only. Non-owners get the existing 403 page. Sites has no read-only details view; the edit form is the details.
|
||||
|
||||
## Sites: SEO defaults (SEO round)
|
||||
|
||||
Route: `/settings/sites`. API: PATCH /v1/site/{id} {site_name, title_template, default_description, social_image_id, default_robots}.
|
||||
|
||||
@@ -10,21 +10,21 @@ Spec of record for the design is `change-list.md` (section "SEO round"), `screen
|
||||
| 2 | SEO object for drafts | `GET /v1/article/{id}/seo` answers drafts to authenticated callers who can read the article, and 404 to anonymous callers. The editor preview needs it. |
|
||||
| 3 | Noindex in the sitemap | Included with `noindex: true`, as drawn. The frontend decides. |
|
||||
| 4 | Who applies `title_template` | The API. `title` in the SEO object is resolved. The raw `seo_title` and the site template stay readable on their own routes. |
|
||||
| 5 | Backfill | Migration generates a slug for every existing article from its title (same slug rule as authors), unique per site, with `-2`, `-3` suffixes. An empty result becomes `article-<id>`. It runs once and is idempotent. |
|
||||
| 5 | Backfill | Migration generates a slug for every existing article from its title (same slug rule as authors), unique per site, with `-2`, `-3` suffixes. A title with no letter or digit becomes `article`, then `article-2`, `article-3`. It runs once and is idempotent. |
|
||||
| 6 | Excerpt in the list payload | Yes. Article items carry `excerpt`. |
|
||||
|
||||
## Decisions that the design leaves implicit
|
||||
|
||||
- Suggestions come from one server-side rule set. The same function feeds `seo_status` and `seo_suggestions` in the article list, the collapsed section summary, and the editor panel.
|
||||
- The editor panel's "checking" states and the "slug taken" state use one non-persisting endpoint: `POST /v1/article/{id}/seo/check`. Its body takes the same optional SEO fields as `PATCH /v1/article/{id}` and overrides the saved values for the check only. It answers `{ status, suggestions: [{ code, field, params }], slug: { available, suggestion } }`. Copy for each code lives in the SPA, with a French entry. `seo_dismissed` codes are filtered out of `suggestions` and are never counted in the list.
|
||||
- The editor panel's "checking" states and the "slug taken" state use one non-persisting endpoint: `POST /v1/article/{id}/seo/check`. Its body takes the same optional SEO fields as `PATCH /v1/article/{id}`, plus an optional `body` (never `site_id` or `author_ids`), and overrides the saved values for the check only. It answers `{ status, suggestions: [{ code, field, params }], slug: { available, suggestion } }`. Copy for each code lives in the SPA, with a French entry. `seo_dismissed` codes are filtered out of `suggestions` and are never counted in the list.
|
||||
- The suggestion codes are the ones the board uses: `title_long`, `title_empty`, `desc_short`, `desc_long`, `cover_alt`, `cover_none`, `slug_long`, `no_au`, `no_h`, `body_alt`, `short`.
|
||||
- Cover, social and site social images are media usages: `GET /media/{id}/usage`, `used_in`, the unused filter, merge, purge and delete protection must count them. This also closes the existing gap where author avatars and site social images are missing from `GET /media/{id}/usage`.
|
||||
- Roles: anyone who can edit an article edits its SEO fields and runs the check. Only the owner edits site SEO fields (403 "Only the owner can do this."). `GET /v1/site/{key}/sitemap` is public, like the other read routes that serve online articles. The activity log gets an `articles` event for SEO edits without changed values for the body.
|
||||
- Roles: anyone who can edit an article edits its SEO fields and runs the check. Only the owner edits site SEO fields (403 "Only the owner can do this."). `GET /v1/site/{key}/sitemap` is public, like the other read routes that serve online articles. SEO edits are logged as an `articles` event; no field values or body text are stored.
|
||||
- `PATCH /v1/article/{id}` still requires at least one known field and still answers 400 on `null`. The SEO fields join `body`, `site_id` and `author_ids`.
|
||||
|
||||
## Slices
|
||||
|
||||
1. **SEO data (backend).** Article and site fields, slug rule and uniqueness per site (409 `slug_taken` with `suggestion`), backfill, `excerpt` in reads and lists, media usage widening. No resolver yet.
|
||||
1. **SEO data (backend).** Article and site fields, slug rule and uniqueness per site (409 plain-text `slug_taken`; the first free alternative is in the `X-Slug-Suggestion` response header), backfill, `excerpt` in reads and lists, media usage widening. No resolver yet.
|
||||
2. **SEO output (backend).** `GET /v1/article/{id}/seo` (fallback chain, title template, JSON-LD), `POST /v1/article/{id}/seo/check`, `seo_status`, `seo_suggestions` and `?seo=` on the article list, `GET /v1/site/{key}/sitemap`.
|
||||
3. **Editor (SPA).** "Search and sharing" section, slide-over and sheet, live previews, suggestions with debounced check, slug field with Auto and Reset, "What the frontend gets" dialog, draft and Save behaviour.
|
||||
4. **Sites, Articles, Integration (SPA).** Site SEO defaults, status chip and "Needs attention" filter, Integration "Search and sharing" group, French strings, catalogue.
|
||||
|
||||
@@ -102,10 +102,52 @@ class Component extends DCLogic {
|
||||
D('position:absolute;right:12px;top:44px;z-index:5;width:220px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:4px', ...items)
|
||||
const Dots = () => S('width:' + (m ? 44 : 32) + 'px;height:' + (m ? 44 : 32) + 'px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)', s.ro ? I('chevron-right', 14) : I(m ? 'ellipsis-vertical' : 'ellipsis', m ? 18 : 16))
|
||||
const ph = 'Search by name or slug', q = s.q
|
||||
const SearchBox = () => D('height:' + bh + 'px;' + (m ? '' : 'width:280px;') + 'padding:0 4px 0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:' + (q ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)') + (m ? ';font-size:16px' : ''), I('search', 14), S('flex:1', q || ph), q && S('width:' + (m ? 36 : 24) + 'px;height:24px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)', I('x', 14)))
|
||||
const SearchBox = (ph2 = ph) => D('height:' + bh + 'px;' + (m ? '' : 'width:280px;') + 'padding:0 4px 0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:' + (q ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)') + (m ? ';font-size:16px' : ''), I('search', 14), S('flex:1', q || ph2), q && S('width:' + (m ? 36 : 24) + 'px;height:24px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)', I('x', 14)))
|
||||
const Head = (title, desc, btn) => 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)', title), S('color:var(--ui-text-muted)', desc)), !m && btn && B(btn, { k: 'primary', icon: 'plus' }))
|
||||
const TH = (cols, labels) => D('display:grid;grid-template-columns:' + cols + ';gap:12px;height:36px;align-items:center;padding:0 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500', ...labels.map(l => S('', l)))
|
||||
const Skel = n => D('background:var(--ui-bg);border:1px solid var(--ui-border);' + (m ? 'margin:0 -16px;border-left:0;border-right:0' : ''), ...Array.from({ length: n }, () => D('display:flex;align-items:center;gap:12px;height:' + (m ? 72 : 52) + 'px;padding:0 16px;border-bottom:1px solid var(--ui-border-muted)', S('display:block;width:' + (m ? 32 : 28) + 'px;height:' + (m ? 32 : 28) + 'px;background:var(--ui-bg-accented)'), D('flex:1;display:flex;flex-direction:column;gap:8px', S('display:block;width:40%;height:13px;background:var(--ui-bg-accented)'), S('display:block;width:60%;height:11px;background:var(--ui-bg-accented)')))))
|
||||
|
||||
// ---------- pagination (same Paginator as Articles and Media)
|
||||
const fmtN = n => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
|
||||
const slugify = t => t.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
||||
const SN = ['Atelier', 'Birchwood', 'Cedar Row', 'Dovetail', 'Eastgate', 'Fieldnotes', 'Granary', 'Harbour', 'Ironwork', 'Joinery Guide', 'Kiln', 'Larch', 'Millstone', 'Nettle', 'Orchard', 'Planing', 'Quarry', 'Ridgeway', 'Slate', 'Tannery', 'Underhill', 'Veneer', 'Workshop', 'Yardline', 'Zinc Works']
|
||||
const RN = ['Journal', 'Projects', 'Archive', 'Press', 'Careers', 'Docs', 'Events', 'Shop']
|
||||
const genSites = g => { const st = (g.page - 1) * g.per, n = Math.min(12, g.per, g.total - st); return Array.from({ length: n }, (_, i) => { const gi = st + i; let name
|
||||
if (g.q) name = 'Renew ' + RN[gi % RN.length] + (gi >= RN.length ? ' ' + (Math.floor(gi / RN.length) + 1) : '')
|
||||
else if (gi < SITES.length) return SITES[gi]
|
||||
else { const k = gi - SITES.length; name = SN[k % SN.length] + (k >= SN.length ? ' ' + (Math.floor(k / SN.length) + 1) : '') }
|
||||
const key = slugify(name); return { n: name, key, url: 'https://' + (g.q ? key.replace(/^renew-/, '') : key) + '.renew.example', c: (gi * 37) % 90, robots: 'Index' } }) }
|
||||
const FN = ['Anaïs', 'Bruno', 'Clara', 'Diego', 'Elise', 'Félix', 'Greta', 'Hugo', 'Iris', 'Julien', 'Karim', 'Lucie', 'Mathieu', 'Nora', 'Oscar', 'Pilar', 'Quentin', 'Rosa', 'Samir', 'Tess']
|
||||
const QF = ['Marc', 'Marie', 'Marta', 'Mario', 'Marina', 'Marius', 'Marlène', 'Marcel']
|
||||
const LN = ['Marchand', 'Nunes', 'Oliveira', 'Perrin', 'Quintela', 'Rocha', 'Santos', 'Teixeira', 'Vidal', 'Weber']
|
||||
const genAuthors = g => { const st = (g.page - 1) * g.per, n = Math.min(12, g.per, g.total - st); return Array.from({ length: n }, (_, i) => { const gi = st + i
|
||||
if (!g.q && gi < AUTH.length) return AUTH[gi]
|
||||
const name = (g.q ? QF[gi % QF.length] : FN[gi % FN.length]) + ' ' + LN[(gi * 3 + 1) % LN.length], sl = slugify(name)
|
||||
return { name, slug: sl, user: gi % 3 === 0 ? sl.split('-')[0] + '@renew.example' : undefined, c: (gi * 5) % 12, img: gi % 2 === 0 } }) }
|
||||
const PagerBlock = (g, noun) => {
|
||||
const pages = Math.max(1, Math.ceil(g.total / g.per)), busy = !!g.loading, dim = busy ? 0.4 : 1
|
||||
const out = []
|
||||
if (g.err) out.push(Alert('error', 'circle-x', g.err, 'Check your connection and try again.', 'Retry'))
|
||||
if (pages === 1) return D('flex-shrink:0;display:flex;flex-direction:column;gap:12px', ...out, S('display:block;color:var(--ui-text-muted)', pl(g.total, noun)))
|
||||
const from = (g.page - 1) * g.per + 1, to = Math.min(g.page * g.per, g.total)
|
||||
const back = g.page === 1 ? 0.4 : 1, fwd = g.page === pages ? 0.4 : 1
|
||||
const PB = (ic, op) => 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, I(ic))
|
||||
const set = new Set([1, pages, g.page - 1, g.page, g.page + 1].filter(n => n >= 1 && n <= pages))
|
||||
const arr = pages <= 7 ? Array.from({ length: 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;opacity:' + dim, 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)', 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 ' + fmtN(g.total))
|
||||
const NB = (t, left) => 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:' + (left ? back : fwd), left && I('chevron-left'), t, !left && I('chevron-right'))
|
||||
if (m) out.push(D('display:flex;flex-direction:column;gap:12px', D('display:flex;align-items:center;gap:12px;flex-wrap:wrap;justify-content:space-between', sum, Size()), D('display:flex;align-items:center;gap:8px;opacity:' + dim, NB('Previous', true), S('padding:0 4px;color:var(--ui-text-highlighted);font-weight:500;white-space:nowrap', 'Page ' + g.page + ' of ' + fmtN(pages)), NB('Next', false))))
|
||||
else out.push(D('display:flex;align-items:center;gap:16px', D('flex:1;display:flex;align-items:center;gap:16px', sum, Size()), D('display:flex;align-items:center;gap:4px;justify-content:flex-end;opacity:' + dim, PB('chevrons-left', back), PB('chevron-left', back), ...nums, PB('chevron-right', fwd), PB('chevrons-right', fwd))))
|
||||
return D('flex-shrink:0;display:flex;flex-direction:column;gap:12px', ...out)
|
||||
}
|
||||
const fab = label => m && !s.ro && !s.panel && !s.modal && !s.menu && s.list !== 'empty' && D('position:absolute;left:16px;right:16px;bottom:16px;height:48px;display:flex;align-items:center;justify-content:center;gap:8px;background:var(--ui-primary);color:var(--ui-bg);font-weight:500;font-size:15px;box-shadow:var(--rc-shadow-pop)', I('plus', 18), label)
|
||||
const shellBase = { kind: 'slot', display: 'Marta Alves' }
|
||||
let main = null, shell = s.shell, edMeta = null, edSeo = null
|
||||
@@ -131,11 +173,13 @@ class Component extends DCLogic {
|
||||
}
|
||||
const sitesPage = () => {
|
||||
if (s.forbidden) return D('flex:1;min-height:0;overflow:hidden;padding:' + (m ? '16px' : '24px') + ';display:flex;flex-direction:column;gap:16px', Empty('lock', 'You don\'t have access to Sites', 'Only the owner can manage sites. Ask the owner of this site if you need a change.', 'Back to Settings'))
|
||||
const st = s.sites || {}, rows = st.only ? [{ ...SITES[0], c: 312 }] : st.gone ? SITES.filter((_, i) => i !== 1) : SITES
|
||||
const pg = s.pg && { ...s.pg, q }, st = s.sites || {}, rows = pg ? (pg.empty ? [] : genSites(pg)) : st.only ? [{ ...SITES[0], c: 312 }] : st.gone ? SITES.filter((_, i) => i !== 1) : SITES
|
||||
const grid = 'minmax(0,1.1fr) minmax(0,1.5fr) 110px 44px'
|
||||
let body
|
||||
if (st.state === 'loading') body = Skel(3)
|
||||
else if (st.state === 'error') body = Alert('error', 'circle-x', 'Couldn\'t load sites', 'Check your connection and try again.', 'Try again')
|
||||
else if (pg && pg.loading) body = Skel(m ? 6 : 8)
|
||||
else if (pg && pg.empty) body = Empty('search', 'No sites match "' + q + '"', 'Check the spelling, or search for a shorter part of the name.', 'Clear search')
|
||||
else {
|
||||
const mi = st.menu
|
||||
const items = i => { const x = rows[i]; return x.def ? [menuItem('pencil', 'Edit', { hv: !m }), menuItem('trash-2', 'Delete…', { dis: true, err: true })] : [menuItem('pencil', 'Edit', { hv: !m }), menuItem('star', 'Set as default'), sep, menuItem('trash-2', 'Delete…', { err: true })] }
|
||||
@@ -151,7 +195,7 @@ class Component extends DCLogic {
|
||||
body = D('display:flex;flex-direction:column;gap:12px', D('background:var(--ui-bg);border:1px solid var(--ui-border);' + (m ? 'margin:0 -16px;border-left:0;border-right:0' : ''), !m && TH(grid, ['Site', 'Base URL', 'Articles', '']), ...trs),
|
||||
st.only && S('color:var(--ui-text-muted);text-wrap:pretty', 'Add another site if your content is shown on more than one frontend. Each article belongs to one site.'))
|
||||
}
|
||||
return D('flex:1;min-height:0;overflow:hidden;padding:' + (m ? '16px 16px 88px' : '24px') + ';display:flex;flex-direction:column;gap:16px;position:relative', Head('Sites', 'The frontends that display your content.', 'Add site'), body)
|
||||
return D('flex:1;min-height:0;overflow:hidden;padding:' + (m ? '16px 16px 88px' : '24px') + ';display:flex;flex-direction:column;gap:16px;position:relative', Head('Sites', 'The frontends that display your content.', 'Add site'), pg && SearchBox('Search by name or key'), pg ? D('flex:1;min-height:0;overflow:hidden', body) : body, pg && !pg.empty && PagerBlock(pg, 'site'))
|
||||
}
|
||||
const sitesModal = () => {
|
||||
const k = s.modal.k, x = s.modal, tgt = SITES[x.i ?? 1]
|
||||
@@ -196,11 +240,13 @@ class Component extends DCLogic {
|
||||
}
|
||||
const authorsPage = () => {
|
||||
const st = s.list || 'ok'
|
||||
let rows = AUTH
|
||||
if (q) rows = AUTH.filter(a => (a.name + a.slug).toLowerCase().includes(q.toLowerCase()))
|
||||
const pg = s.pg && { ...s.pg, q }
|
||||
let rows = pg ? (pg.empty ? [] : genAuthors(pg)) : AUTH
|
||||
if (q && !pg) rows = AUTH.filter(a => (a.name + a.slug).toLowerCase().includes(q.toLowerCase()))
|
||||
const grid = 'minmax(0,1.4fr) minmax(0,1fr) 110px 100px 44px'
|
||||
let body
|
||||
if (st === 'loading') body = Skel(6)
|
||||
else if (pg && pg.loading) body = Skel(m ? 6 : 8)
|
||||
else if (st === 'error') body = Alert('error', 'circle-x', 'Couldn\'t load authors', 'Check your connection and try again.', 'Try again')
|
||||
else if (st === 'empty') body = Empty('feather', 'No authors yet', 'Authors are the names readers see on articles. They don\'t need an account.', 'Add author', true)
|
||||
else if (q && !rows.length) body = Empty('search', 'No authors match "' + q + '"', 'Check the spelling, or search for a shorter part of the name.', 'Clear search')
|
||||
@@ -217,7 +263,7 @@ class Component extends DCLogic {
|
||||
body = D('background:var(--ui-bg);border:1px solid var(--ui-border);' + (m ? 'margin:0 -16px;border-left:0;border-right:0' : ''), !m && TH(grid, ['Author', 'Slug', 'Account', 'Articles', '']), ...trs)
|
||||
}
|
||||
const showTool = st !== 'empty' && st !== 'error'
|
||||
return D('flex:1;min-height:0;overflow:hidden;padding:' + (m ? '16px 16px 88px' : '24px') + ';display:flex;flex-direction:column;gap:16px;position:relative', Head('Authors', s.ro ? 'The names readers see on your articles. Only an owner or admin can change authors.' : 'The names readers see on your articles. An author doesn\'t need an account.', s.ro ? null : 'Add author'), showTool && SearchBox(), body)
|
||||
return D('flex:1;min-height:0;overflow:hidden;padding:' + (m ? '16px 16px 88px' : '24px') + ';display:flex;flex-direction:column;gap:16px;position:relative', Head('Authors', s.ro ? 'The names readers see on your articles. Only an owner or admin can change authors.' : 'The names readers see on your articles. An author doesn\'t need an account.', s.ro ? null : 'Add author'), showTool && SearchBox(), pg ? D('flex:1;min-height:0;overflow:hidden', body) : body, pg && !pg.empty && PagerBlock(pg, 'author'))
|
||||
}
|
||||
const authorsModal = () => {
|
||||
const x = s.modal, k = x.k, a = AUTH[x.i ?? 2]
|
||||
@@ -273,7 +319,7 @@ class Component extends DCLogic {
|
||||
}
|
||||
const newAuthorModal = () => {
|
||||
const x = s.modal
|
||||
return Shell({ title: 'Create author', desc: 'Adds a byline for this article. You can add a bio and avatar later in Authors.', cancel: 'Cancel', confirm: x.loading ? 'Creating…' : 'Create author', loading: x.loading, close: true, cdis: !x.name },
|
||||
return Shell({ z: 60, title: 'Create author', desc: 'Adds a byline for this article. You can add a bio and avatar later in Authors.', cancel: 'Cancel', confirm: x.loading ? 'Creating…' : 'Create author', loading: x.loading, close: true, cdis: !x.name },
|
||||
D('display:flex;flex-direction:column;gap:16px', x.notice && Alert('error', 'circle-x', 'Couldn\'t create the author', 'The article is unchanged. Try again in a moment.'),
|
||||
F('Name', x.name || 'e.g. Camille Roche', { val: !!x.name, focus: !x.name && !x.loading, err: x.err, dis: x.loading }),
|
||||
Slug('Slug', { mode: x.name ? (x.err ? 'manual' : 'auto') : 'empty', value: x.slug, help: 'Generated from the name. Unique across all authors.', err: x.slugErr, suggest: x.suggest }),
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
<sc-if value="{{ ep.noFields }}"><span style="color:var(--ui-text-muted)">No request body.</span></sc-if></div>
|
||||
<dc-import name="CodeSnippet" tabs="{{ ep.snips }}" hint-size="100%,200px"></dc-import>
|
||||
<div style="display:flex;flex-direction:column;gap:6px"><span style="font-weight:600;color:var(--ui-text-highlighted)">Errors</span>
|
||||
<sc-for list="{{ ep.errs }}" as="e" hint-placeholder-count="2"><div title="{{ e.cap }}" style="display:flex;align-items:baseline;gap:10px"><span style="width:36px;height:20px;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned);font:500 11px 'IBM Plex Mono',monospace">{{ e.c }}</span><span style="min-width:0;color:var(--ui-text-toned);text-wrap:pretty">{{ e.cap }}</span></div></sc-for></div>
|
||||
<sc-for list="{{ ep.errs }}" as="e" hint-placeholder-count="2"><div title="{{ e.cap }}" style="display:flex;align-items:baseline;gap:10px"><span style="width:36px;height:20px;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned);font:500 11px 'IBM Plex Mono',monospace">{{ e.c }}</span><sc-if value="{{ e.plain }}"><span style="min-width:0;color:var(--ui-text-toned);text-wrap:pretty">{{ e.cap }}</span></sc-if><sc-if value="{{ e.code }}"><span style="min-width:0;color:var(--ui-text-toned)"><code>{{ e.cap }}</code></span></sc-if></div></sc-for></div>
|
||||
</div>
|
||||
</sc-if>
|
||||
</div>
|
||||
@@ -104,7 +104,7 @@
|
||||
<span style="color:var(--ui-text-toned);text-wrap:pretty">Every error is a plain-text body with a single sentence. The status code is in the HTTP status line, and the Content-Type is text/plain.</span>
|
||||
<div style="border:1px solid var(--ui-border);background:var(--ui-bg);overflow:hidden">
|
||||
<div style="display:grid;grid-template-columns:{{ errCols }};gap:12px;padding:8px 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500"><span>Status</span><span>Meaning</span><sc-if value="{{ desk }}"><span>Typical cause</span></sc-if></div>
|
||||
<sc-for list="{{ errRows }}" as="r" hint-placeholder-count="8"><div style="display:grid;grid-template-columns:{{ errCols }};gap:2px 12px;padding:10px 12px;border-top:1px solid var(--ui-border)"><span style="font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ r.c }}</span><span style="font-weight:500;color:var(--ui-text-highlighted)">{{ r.m }}</span><span style="grid-column:{{ errCause }};color:var(--ui-text-toned);text-wrap:pretty">{{ r.w }}</span></div></sc-for>
|
||||
<sc-for list="{{ errRows }}" as="r" hint-placeholder-count="8"><div style="display:grid;grid-template-columns:{{ errCols }};gap:2px 12px;padding:10px 12px;border-top:1px solid var(--ui-border)"><span style="font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ r.c }}</span><span style="font-weight:500;color:var(--ui-text-highlighted)">{{ r.m }}</span><span style="grid-column:{{ errCause }};color:var(--ui-text-toned);text-wrap:pretty">{{ r.w }}</span><sc-if value="{{ r.n }}"><span style="grid-column:{{ errNoteCol }};color:var(--ui-text-muted);font-size:12px;line-height:18px;text-wrap:pretty">{{ r.n }}</span></sc-if></div></sc-for>
|
||||
</div>
|
||||
<dc-import name="CodeSnippet" tabs="{{ errSnips }}" hint-size="100%,120px"></dc-import>
|
||||
</section>
|
||||
@@ -189,7 +189,7 @@ class Component extends DCLogic {
|
||||
] },
|
||||
seo: { title: 'Search and sharing', desc: 'RenewCMS doesn\'t render pages or add meta tags. These endpoints give your frontend the resolved SEO data and the list of published URLs.', eps: [
|
||||
A('seo-get', 'GET', '/v1/article/{id}/seo', 'The resolved SEO object for one article: title (with the site template applied), description, canonical URL, robots, Open Graph and Twitter values, image with alt, authors, dates and ready-made Article JSON-LD. Every empty field already holds its fallback. Drafts answer 404 to anonymous callers.', 'Public', 1, [e401, e404], [], { pub: 1, r: j({ title: 'How we price a bathroom renovation | Renew', description: 'A bathroom renovation is priced by room size, plumbing changes and finish level.', canonical_url: 'https://renew.example/how-we-price-a-bathroom-renovation', robots: 'index, follow', og: { type: 'article', title: 'How we price a bathroom renovation | Renew', image: 'https://renew.example/media/north-wall.jpg', image_alt: 'A tiled bathroom wall', site_name: 'Renew' }, twitter: { card: 'summary_large_image' }, authors: [{ name: 'Inès Duarte', url: 'https://ines-duarte.example', same_as: [] }], published_at: '2026-03-12T09:14:00Z', modified_at: '2026-10-15T11:02:00Z', json_ld: { '@type': 'Article' } }) }),
|
||||
A('sitemap', 'GET', '/v1/site/{key}/sitemap', 'Published article URLs for one site, each with its last-modified date and a noindex flag, one page at a time. Use it to build your sitemap.xml. The total is in the X-Total-Count response header.', 'Public', 1, [[400, 'Invalid page or size.'], e401, [404, 'Site not found']], [S('page', 'integer', 'No', '1 or higher. Default 1.'), S('size', 'integer', 'No', 'One of 25, 50, 100 or 500. Default 100.')], { qp: 1, pub: 1, r: j([{ url: 'https://renew.example/how-we-price-a-bathroom-renovation', modified_at: '2026-10-15T11:02:00Z', noindex: false }]) })
|
||||
A('sitemap', 'GET', '/v1/site/{key}/sitemap', 'Published article URLs for one site, each with its last-modified date and a noindex flag, one page at a time. Use it to build your sitemap.xml. The total is in the X-Total-Count response header.', 'Public', 1, [[400, mm], e401, [404, 'site_not_found', true]], [S('page', 'integer', 'No', '1 or higher. Default 1.'), S('size', 'integer', 'No', 'One of 25, 50, 100 or 500. Default 100.')], { qp: 1, pub: 1, r: j([{ url: 'https://renew.example/how-we-price-a-bathroom-renovation', modified_at: '2026-10-15T11:02:00Z', noindex: false }]) })
|
||||
] },
|
||||
media: { title: 'Media', desc: 'The image library. One image can be used in many articles.', eps: [
|
||||
A('med-list', 'GET', '/v1/media/', 'List images.', 'Verified user', 1, [e401], [], { r: j([{ id: 4, filename: 'north-wall.jpg', size: 3900000 }]) }),
|
||||
@@ -239,7 +239,7 @@ class Component extends DCLogic {
|
||||
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 })) }
|
||||
errs: e.errs.map(([c, cap, code]) => ({ c, cap, code: !!code, plain: !code })) }
|
||||
}) }
|
||||
})
|
||||
}
|
||||
@@ -267,11 +267,11 @@ class Component extends DCLogic {
|
||||
fCols: mob ? 'minmax(0,1fr) auto auto' : '120px 72px 72px minmax(0,1fr)', ruleCol: mob ? '1 / -1' : 'auto',
|
||||
sessGroups: [sg('System · owner only', [['GET', '/v1/system/update', 'Update status'], ['POST', '/v1/system/update/check', 'Check for update'], ['POST', '/v1/system/update/download', 'Download update'], ['POST', '/v1/system/update/restart', 'Restart']]), sg('Sign in and account', [['POST', '/v1/auth/login', 'Log in'], ['POST', '/v1/auth/logout', 'Log out'], ['POST', '/v1/auth/register', 'Register'], ['POST', '/v1/auth/verify', 'Verify email'], ['POST', '/v1/auth/password-reset', 'Reset password']]), sg('API key management', [['GET', '/v1/api-key', 'List keys'], ['POST', '/v1/api-key', 'Create a key'], ['DELETE', '/v1/api-key/{id}', 'Revoke a key']])],
|
||||
sessCols: mob ? 'none' : '280px minmax(0,1fr) auto', sessGap: mob ? '2px' : '16px', sessPad: mob ? '8px 14px' : '0 14px', chipSelf: mob ? 'flex-start' : 'center',
|
||||
errCols: mob ? '56px minmax(0,1fr)' : '64px 200px minmax(0,1fr)', errCause: mob ? '2' : 'auto',
|
||||
errCols: mob ? '56px minmax(0,1fr)' : '64px 200px minmax(0,1fr)', errCause: mob ? '2' : 'auto', errNoteCol: mob ? '2' : '3',
|
||||
errRows: [
|
||||
{ c: 400, m: 'Bad request', w: '"The request cannot be processed due to a mismatch in the format of the body." or "The expiry must be a date in the future." Image requests answer with a JSON body whose error is "unknown_variant" or "unsupported_parameter".' },
|
||||
{ c: 401, m: 'Unauthorized', w: '"You must be logged in to access this resource." Sent for an invalid, unknown, expired or revoked key, even on public routes, and for a key whose user was deleted or unverified.' },
|
||||
{ c: 403, m: 'Forbidden', w: '"This action needs a real session." (a session-only route called with a key), "Only the owner can do this." (site SEO defaults: title template, default description, site name and default robots are owner only; article SEO fields need only the right to edit the article) or "You must verify your account to access this resource."' },
|
||||
{ c: 403, m: 'Forbidden', n: 'Site SEO defaults are owner only. Article SEO fields need only the right to edit the article.', w: '"This action needs a real session." (a session-only route called with a key), "Only the owner can do this." or "You must verify your account to access this resource."' },
|
||||
{ c: 404, m: 'Not found', w: '"Article not found", "Image not found", "Invite not found" or "API key not found". Drafts answer 404 to anonymous callers.' },
|
||||
{ c: 429, m: 'Too many requests', w: '"Too many requests. Please try again later." A per-key limit of 120 requests per minute also sends a Retry-After header.' },
|
||||
{ c: 500, m: 'Server error', w: '"Internal server error"' }
|
||||
|
||||
@@ -363,7 +363,7 @@ class Component extends DCLogic {
|
||||
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "3b. Copied: CodeBlock", trigger: "The Copy button in the API key example after a click. Back to \"Copy\" after 2 s.", int: {"scroll":"auth","copied":"auth"} }, { int: {"scroll":"auth","copied":"auth"} }),
|
||||
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "4. Authentication", trigger: "The API key card with its example, the \"Manage API keys\" link (goes to the API keys board) and the two notes.", int: {"scroll":"auth"} }, { int: {"scroll":"auth"} }),
|
||||
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "5. Session only", trigger: "Routes API keys cannot call. Dashed, muted and locked, so they read as unavailable and not as an error.", int: {"scroll":"session"} }, { int: {"scroll":"session"} }),
|
||||
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "6. Errors", trigger: "Status table and the plain-text error response (status line plus a one-sentence body). Key-specific causes are in the 401, 403 and 429 rows.", int: {"scroll":"errors"} }, { int: {"scroll":"errors"} }),
|
||||
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "6. Errors", trigger: "Status table and the plain-text error response (status line plus a one-sentence body). Key-specific causes are in the 401, 403 and 429 rows. The 403 row carries a muted note line (12/18, --ui-text-muted, aligned with the cause column, 2 px row gap above it): \"Site SEO defaults are owner only. Article SEO fields need only the right to edit the article.\" The 404 and 429 rows and everything below move down by the height of the wrapped note: two 18 px lines on desktop, three on mobile, plus the 2 px gap.", int: {"scroll":"errors"} }, { int: {"scroll":"errors"} }),
|
||||
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "7. Navigation", trigger: "Desktop: the sticky index with the active item (Media). Mobile: the \"Jump to\" select open. Keyboard: the index buttons and the select are in the tab order, Enter jumps, Escape closes the list.", int: {"scroll":"media"} }, { int: {"scroll":"media","jump":true} })
|
||||
] },
|
||||
{ label: 'Activity', title: 'Activity, owner and admin', route: '/activity', api: 'GET /v1/activity · GET /v1/activity/summary', note: 'New route in the sidebar after Users. Owner and admin see everything; regular users get "My activity". Copy, component mapping and API calls are at the bottom of this board.', states: [
|
||||
@@ -837,6 +837,25 @@ class Component extends DCLogic {
|
||||
const S = (name, trigger, copy, o, mo) => ({ name, trigger, copy, o, mo })
|
||||
const G = (label, title, route, api, note, states) => ({ label, title, route, api, note, states })
|
||||
const edT = 'How we price a bathroom renovation'
|
||||
const fmtP = n => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
|
||||
const PGS = (v, cfg) => {
|
||||
const { path, noun, N, mid, qN, qText, big, bigPage, single, empty } = cfg, pages = Math.ceil(N / 25)
|
||||
const o = (pg, x = {}) => v === 'sites' ? { v, sites: { state: 'ok' }, pg, ...x } : { v, pg, ...x }
|
||||
const rng = p => 'Showing ' + ((p - 1) * 25 + 1) + '–' + Math.min(p * 25, N) + ' of ' + fmtP(N)
|
||||
const bp = Math.ceil(big / 25), bm = bigPage
|
||||
return [
|
||||
S('First page', path + '. Previous and First are disabled. Mobile: Previous is disabled.', rng(1) + ' · 25 per page · Page 1 of ' + pages + ' · Previous · Next', o({ page: 1, per: 25, total: N })),
|
||||
S('Middle page', path + '?page=' + mid + '. Current page uses --rc-sand and --rc-on-sand.', rng(mid) + ' · Page ' + mid + ' of ' + pages, o({ page: mid, per: 25, total: N })),
|
||||
S('Last page', path + '?page=' + pages + '. Next and Last are disabled.', rng(pages) + ' · Page ' + pages + ' of ' + pages, o({ page: pages, per: 25, total: N })),
|
||||
S('Single page', 'Everything fits on one page. The pager and the page-size select are hidden; only the count remains.', single + ' ' + noun + (single === 1 ? '' : 's'), o({ page: 1, per: 25, total: single })),
|
||||
S('Page size menu', 'The select opens upward: 5, 25, 50 or 100 per page. 25 is the default. Choosing another size sets ?size= and returns to page 1.', '5 per page · 25 per page · 50 per page · 100 per page', o({ page: mid, per: 25, total: N, sizeOpen: true })),
|
||||
S('Loading a page', 'Next was pressed. The rows are replaced by skeleton rows; the pager stays, disabled, and the summary reads "Loading page ' + (mid + 1) + '…".', 'Loading page ' + (mid + 1) + '…', o({ page: mid, per: 25, total: N, loading: mid + 1 })),
|
||||
S('Error loading a page', '500 or network failure on a page change. The current rows and the current page number stay; Retry sits in the notice.', 'Couldn\'t load page ' + (mid + 1) + '. Showing page ' + mid + '. · Check your connection and try again. · Retry', o({ page: mid, per: 25, total: N, err: 'Couldn\'t load page ' + (mid + 1) + '. Showing page ' + mid + '.' })),
|
||||
S('Empty after search', 'A search matched nothing. The empty state replaces the list and the pager is hidden.', 'No ' + noun + 's match "' + empty + '" · Check the spelling, or search for a shorter part of the name. · Clear search', o({ empty: true, total: 0 }, { q: empty })),
|
||||
S('Search resets to page 1', 'Searching "' + qText + '" from page ' + (mid + 2) + ' goes to ' + path + '?q=' + qText + ' (page omitted). Changing the page size does the same.', rng(1).replace(' of ' + fmtP(N), ' of ' + qN), o({ page: 1, per: 25, total: qN }, { q: qText })),
|
||||
S('Long labels on mobile', 'Large totals on a 390 px screen. The summary and the page-size select wrap onto two lines if needed; Previous, "Page ' + bm + ' of ' + bp + '" and Next keep one row.', 'Showing ' + ((bm - 1) * 25 + 1) + '–' + (bm * 25) + ' of ' + fmtP(big) + ' · Page ' + bm + ' of ' + bp, o({ page: bm, per: 25, total: big }))
|
||||
]
|
||||
}
|
||||
const sitesNote = 'Settings, Publishing group. Compact list; actions in the row menu. Default site: no delete action.'
|
||||
return { groups: [
|
||||
G('Sites', 'Sites', '/settings/sites', 'GET /v1/site/ · PATCH /v1/site/{id}/default · DELETE /v1/site/{id}?reassign_to={id}', 'Only the owner can create, edit, set the default or delete a site. Other roles don\'t see the Sites entry under Settings; a direct link shows a 403 page. Choosing a site for an article is not site management: anyone who can edit the article can set its site in the editor and read the site list. New child of Settings, under a "Publishing" heading in the sidebar (Settings also gets a "Publishing" card with a "Manage sites" row, owner only). Columns: name with key, base URL, article count. The default site carries a "Default" badge and has no delete action. A site with robots set to noindex shows a "Noindex" badge. List, row menu and dialogs come from DirectoryFrame and are shared with Authors.', [
|
||||
@@ -864,6 +883,7 @@ class Component extends DCLogic {
|
||||
S('Choose destination', 'The select is open. The site being deleted is not listed.', 'Journal is not offered. Options show name and host.', { v: 'sites', sites: { state: 'ok' }, modal: { k: 'del', i: 1, open: true } }),
|
||||
S('No articles', 'A site with zero articles: plain confirm, no select.', 'Delete this site? · "Archive" will be deleted. No articles belong to it. This can\'t be undone. · Delete site', { v: 'sites', sites: { state: 'ok' }, modal: { k: 'del0', i: 3 } })
|
||||
]),
|
||||
G('Sites: pagination', 'Sites: pagination', '/settings/sites?page=2&size=50', 'GET /v1/site/?q=&page=&size= (total in the X-Total-Count header; array body unchanged)', 'Same Paginator as Articles and Media (components.md). Sizes 5, 25, 50, 100, default 25: the existing pattern, unchanged. Page and size in the URL as page and size; defaults omitted; a page past the end falls back to the last page. Search resets to page 1. The count line "N sites" replaces the pager on a single page. Sites has no search box today: it is added with the pager (same field as Authors, "Search by name or key") and is a proposal to confirm. The list is drawn with generated rows; 61 sites give 3 pages.', PGS('sites', { path: '/settings/sites', noun: 'site', N: 61, mid: 2, qN: 27, qText: 'renew', big: 312, bigPage: 6, single: 4, empty: 'blog' })),
|
||||
G('Authors', 'Authors', '/authors', 'GET /v1/author/?q=&page=&size= · DELETE /v1/author/{id}?reassign_to={id|none}', 'Sidebar entry between Media and Users, visible to every role. Owner and admin create, edit and delete any author. Other roles see the list and the detail read-only. Columns: avatar and name, slug, account (Linked or Guest), article count. Search by name or slug. Long names truncate in the row and show in full on hover. Above 25 authors the list uses the same paginator as Articles (not redrawn). List, panel and dialogs come from DirectoryFrame and are shared with Sites.', [
|
||||
S('Default', 'Owner or admin. The list loaded, newest first.', 'Authors · The names readers see on your articles. An author doesn\'t need an account. · Add author · Search by name or slug · Author · Slug · Account · Articles · Linked · Guest · 9 articles · 1 article · No articles', { v: 'authors' }),
|
||||
S('Loading', 'The first load, or a retry.', '', { v: 'authors', list: 'loading' }),
|
||||
@@ -895,6 +915,7 @@ class Component extends DCLogic {
|
||||
S('Server fallback (409)', 'Fallback only. The dialog above always sends reassign_to, so this appears only when the server refuses a stale request because articles still credit the author. Drawn once, not as a flow.', 'Can\'t delete this author · "Camille Roche" is credited on 3 articles. Choose another author to credit instead, or remove Camille Roche from those articles first. · Close · Reassign articles', { v: 'authors', modal: { k: 'blocked', i: 2 } }),
|
||||
S('No articles', 'An author with zero articles: plain confirm.', 'Delete this author? · "Léa Fontaine" will be deleted. No articles credit this author. This can\'t be undone. · Delete author', { v: 'authors', modal: { k: 'del0', i: 4 } })
|
||||
]),
|
||||
G('Authors: pagination', 'Authors: pagination', '/authors?page=3&size=50', 'GET /v1/author/?q=&page=&size= (total in the X-Total-Count header; array body unchanged)', 'Same Paginator as Articles and Media (components.md). Sizes 5, 25, 50, 100, default 25: the existing pattern, unchanged. Page and size in the URL as page and size; defaults omitted; a page past the end falls back to the last page. Search resets to page 1. The count line "N authors" replaces the pager on a single page. The list is drawn with generated rows; 128 authors give 6 pages.', PGS('authors', { path: '/authors', noun: 'author', N: 128, mid: 3, qN: 31, qText: 'mar', big: 1284, bigPage: 12, single: 6, empty: 'zoe' })),
|
||||
G('Editor: site and authors', 'Editor: site and authors', '/article/:id/edit', 'PATCH /v1/article/{id} {site_id, author_ids[]} · GET /v1/site/ · GET /v1/author/ · POST /v1/author/', 'States specific to this feature. The collapsed default (one site, the article has an author) and the Loading and Not found states are drawn in the existing Editor frames, which show the section in the same place; they are not repeated here. The section is open when the install has more than one site or the article has no authors, and collapsed to one summary line otherwise; the choice is remembered per browser. On mobile it is closed by default. With one site the Site select is not rendered. Anyone who can edit the article can set its site and pick authors; "Create author…", the Create author dialog and its "This is me" checkbox are for owner and admin only. For any other role, the "Create my author profile" hint (state 4) creates the profile directly with POST /v1/author/me, with no dialog, then shows the author in the list with a toast. Authors are a reorderable list; the first is the primary byline.', [
|
||||
S('Open: one site, one author', 'The section was opened. No Site select.', 'Authors · Add an author', { v: 'editor', metaOpen: true, meta: { authors: ['Inès Duarte'] } }),
|
||||
S('Open: several sites', 'Site select above the authors, with the canonical host as helper text.', 'Site · Journal · journal.renew.example · Canonical host: journal.renew.example', { v: 'editor', ed: { metaSummary: 'Journal · Inès Duarte' }, metaOpen: true, meta: { sites: true, site: 'Journal', authors: ['Inès Duarte'] } }),
|
||||
|
||||
@@ -51,6 +51,9 @@ Glossary pass 2, changes made: activity log lines "Removed N expired invites" an
|
||||
| 422 bio_too_long | "Shorten the bio to 280 characters." (inline under bio) |
|
||||
| 422 invalid_url | "Enter a full URL starting with https://." (inline under website and each social link) |
|
||||
| 404 author_not_found | "That author no longer exists" / "It was deleted somewhere else, so we removed it from the list." (info toast) |
|
||||
| 404 site_not_found (sitemap) | GET /v1/site/{key}/sitemap, unknown key. The body is the code `site_not_found`, shown on Integration in the code style (not rewritten as prose). |
|
||||
| 400 (sitemap page or size) | "The request cannot be processed due to a mismatch in the format of the body." (same text as the other routes on Integration) |
|
||||
| 403 owner_only vs article SEO | Integration note under the 403 row: "Site SEO defaults are owner only. Article SEO fields need only the right to edit the article." |
|
||||
| 500 – | "Couldn't save the site. Your changes are still here. Try again in a moment." / "Couldn't save the author. Your changes are still here. Try again in a moment." / "Couldn't create the author" / "The article is unchanged. Try again in a moment." (notice inside the panel or dialog) |
|
||||
|
||||
## Public image 400 bodies
|
||||
@@ -59,6 +62,9 @@ Glossary pass 2, changes made: activity log lines "Removed N expired invites" an
|
||||
## 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.
|
||||
|
||||
## Sites and authors pagination
|
||||
Groups "Sites: pagination" and "Authors: pagination", 10 states each, same pager as Articles and Media, sizes 5, 25, 50, 100 (default 25). First, middle and last page; single page (count line "N sites" or "N authors" only, no pager, no size select); page-size menu; loading ("Loading page N…", skeleton rows, pager kept and disabled); error ("Couldn't load page N. Showing page M." with Retry, rows and page number kept); empty after search (existing empty state, no pager); search resets to page 1; long labels on mobile. Out of range: ?page past the end is replaced by the last page, with "That page doesn't exist. Showing the last page." (not drawn; as Articles). Details in screens/sites.md and screens/authors.md.
|
||||
|
||||
## 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