chore: grounwork for SEO features
This commit is contained in:
1 parent
6b8990c55e
commit
cb1410f0b0
15 files changed
+350
-28
No files matched your search
@@ -9,7 +9,7 @@ Nuxt UI v4 back-office. Tokens only, no raw hex (brand mark excepted). Light and
|
|||||||
- Unchanged: Foundations, Components, Handoff, Directions and Emails boards.
|
- Unchanged: Foundations, Components, Handoff, Directions and Emails boards.
|
||||||
|
|
||||||
## Pass 3: sites and authors (revised)
|
## Pass 3: sites and authors (revised)
|
||||||
Roles, decisions and the slice order are in `sites-authors-slices.md`.
|
Roles, decisions and the slice order are in `sites-authors-slices.md` (Sites and Authors) and `seo-slices.md` (SEO).
|
||||||
|
|
||||||
Back-office board groups: Sites, Sites: form, Sites: delete, Authors, Authors: form, Authors: delete, Editor: site and authors, Profile: author profile, Articles: sites and authors. Plus tables: decisions to confirm (13 rows, 5 marked Decided), deviations, API and errors (with the 403 rows), components, accessibility, copy per state.
|
Back-office board groups: Sites, Sites: form, Sites: delete, Authors, Authors: form, Authors: delete, Editor: site and authors, Profile: author profile, Articles: sites and authors. Plus tables: decisions to confirm (13 rows, 5 marked Decided), deviations, API and errors (with the 403 rows), components, accessibility, copy per state.
|
||||||
- Roles: only creating, editing, deleting and setting the default site are owner only (no sidebar entry or Publishing card for others, 403 page on a direct link). Anyone who can edit an article can set its site and read the site list. Authors: owner and admin manage; other roles read-only, and any role can create and edit their own author (user link locked, no delete).
|
- Roles: only creating, editing, deleting and setting the default site are owner only (no sidebar entry or Publishing card for others, 403 page on a direct link). Anyone who can edit an article can set its site and read the site list. Authors: owner and admin manage; other roles read-only, and any role can create and edit their own author (user link locked, no delete).
|
||||||
@@ -130,4 +130,5 @@ Back-office total 341 (pass 2 groups, table above) + 68 (sites and authors) = 40
|
|||||||
- Contrast was not computed for every hover and active background, only the pairs listed in foundations.md.
|
- Contrast was not computed for every hover and active background, only the pairs listed in foundations.md.
|
||||||
- The glossary pass was a search for known patterns, not a line-by-line read of every string. Titles in Title Case were not searched.
|
- The glossary pass was a search for known patterns, not a line-by-line read of every string. Titles in Title Case were not searched.
|
||||||
- The Components, Handoff and Foundations boards were not updated to mention the new tokens, CodeSnippet or the matrix.
|
- The Components, Handoff and Foundations boards were not updated to mention the new tokens, CodeSnippet or the matrix.
|
||||||
|
- Integration still lists "max 10000" for article bodies, as the backend does today (change-list item 11).
|
||||||
- The sandbox cannot export; no files were bundled.
|
- The sandbox cannot export; no files were bundled.
|
||||||
@@ -1,4 +1,50 @@
|
|||||||
# Change list for implementation (status after pass 2)
|
# Change list for implementation (status after pass 2; SEO round drawn)
|
||||||
|
|
||||||
|
## SEO round: decisions (frames drawn)
|
||||||
|
|
||||||
|
**Placement.** A "Search and sharing" section sits under "Site and authors" in the Editor sidebar and in the mobile Details sheet. It is collapsed by default, shows a one-line summary ("Ready", "2 suggestions" or "Hidden from search") and is remembered per browser like Site and authors. It holds only the slug, the full URL and three actions: "Edit search and sharing", "What the frontend gets" and the suggestion list. The fields and live previews open in a slide-over (desktop) or a 92% bottom sheet stacked over the Details sheet (mobile), because the 280 px sidebar is too narrow for previews and the slide-over rule already covers forms with a live preview. The API preview is a 720 px dialog (desktop) or a bottom sheet (mobile).
|
||||||
|
|
||||||
|
**Principles.** RenewCMS does not render pages or inject meta tags. Every field is optional and the UI shows its fallback. No scores. Suggestions never block saving or publishing; publishing is allowed regardless. Suggestions are dismissible per article.
|
||||||
|
|
||||||
|
**Fallback chain (resolved server-side, shown in the UI).**
|
||||||
|
| Output | Order |
|
||||||
|
|---|---|
|
||||||
|
| title | seo_title, then article title; site title_template applied last |
|
||||||
|
| description | seo_description, then excerpt, then first sentence of the body, then site default_description |
|
||||||
|
| image | og_image_id, then cover_image_id, then site social image |
|
||||||
|
| canonical | canonical_url, then site base_url + "/" + slug |
|
||||||
|
| robots | noindex true gives "noindex, nofollow"; otherwise site default_robots |
|
||||||
|
| og/twitter title and description | social_title, social_description, then the resolved values above |
|
||||||
|
|
||||||
|
**Soft ranges (counters, never blocks).** Meta title 30–60 characters; description 70–160; slug "very long" above 75 characters. Counter turns neutral-warning (`--ui-warning`) outside the range, never red.
|
||||||
|
|
||||||
|
**New article fields (PATCH /v1/article/{id}, all optional, snake_case).** `slug`, `excerpt` (max 300), `seo_title` (max 120), `seo_description` (max 320), `cover_image_id`, `cover_image_alt` (max 200; the cover is "ready" only with alt text), `social_title`, `social_description`, `social_image_id`, `canonical_url` (absolute https URL), `noindex` (boolean; only sets a flag in the API, it doesn't change visibility), `seo_dismissed` (string[] of suggestion codes). Slug: lowercase letters, numbers, hyphens; unique per site; 409 `slug_taken` with `suggestion` ("<slug>-2"); auto-generated from the title until edited.
|
||||||
|
|
||||||
|
**New site fields (PATCH /v1/site/{id}, owner only).** `site_name`, `title_template` (default "%title% | %site%", must contain %title%), `default_description` (max 320), `social_image_id` (existing), `default_robots` ("index" | "noindex"; existing as robots).
|
||||||
|
|
||||||
|
**New endpoints.**
|
||||||
|
- `GET /v1/article/{id}/seo`: resolved object, public for online articles, 404 for drafts to anonymous callers. Shape: `{ title, description, canonical_url, robots, og: { type, title, description, image, image_alt, url, site_name, locale }, twitter: { card, title, description, image, image_alt }, image: { url, alt, source }, authors: [{ name, url, same_as: [] }], published_at, modified_at, fallbacks: { title, description, image, canonical }, json_ld }`. `json_ld` is a ready Article object (headline, description, image, author[] with name, url and sameAs from author profile URL and social links, datePublished, dateModified, mainEntityOfPage).
|
||||||
|
- `GET /v1/site/{key}/sitemap?page=&size=`: published URLs only, total in `X-Total-Count`. Items: `{ url, modified_at, noindex }`. Decision: yes, shown on Integration under "Search and sharing" with a copy button; noindex articles are included with the flag so the frontend can skip them.
|
||||||
|
- `GET /v1/article/?seo=ready|suggestions|hidden`: filter behind "Needs attention" (suggestions) with `seo_status` and `seo_suggestions` (count) added to list items.
|
||||||
|
|
||||||
|
**Articles list.** Quiet indicator inside the Status cell (and under the title on mobile cards): nothing when ready, a lightbulb chip "N" for suggestions, an eye-off chip "Hidden" for noindex. Toolbar gets a "Needs attention" toggle next to search; tabs and counts are unchanged. It behaves the same on a single-site install (independent of the Site filter, which stays hidden).
|
||||||
|
|
||||||
|
**Authors.** No change to the Authors screen. Profile URL (website) and social links feed `authors[].url` and `same_as`, and JSON-LD `author[].sameAs`; the API preview shows them.
|
||||||
|
|
||||||
|
**Permissions.** Anyone who can edit an article edits its SEO fields. Only the owner edits site SEO defaults. 401 and 403 rows join the existing HTTP table: 403 `owner_only` on Site SEO fields, 401 on a missing session.
|
||||||
|
|
||||||
|
**Left out on purpose.** Keyword scoring and focus keywords, readability scores, redirects management, schema types beyond Article, hreflang, robots.txt and sitemap rendering (RenewCMS only provides data), automatic image generation for social cards.
|
||||||
|
|
||||||
|
**Open questions.**
|
||||||
|
1. Is `slug` immutable after first publish, or can it change (and break URLs with no redirects)?
|
||||||
|
2. Does the API return the SEO object for drafts to authenticated callers (for frontend previews)?
|
||||||
|
3. Should noindex articles be excluded from the sitemap endpoint instead of flagged?
|
||||||
|
4. Is title_template applied by the API (resolved title) or only returned for the frontend to apply?
|
||||||
|
5. Backfill: generate slugs for existing articles from their titles at migration, with `-2` suffixes on conflicts?
|
||||||
|
6. Should excerpt be exposed in the list payload as the article teaser?
|
||||||
|
|
||||||
|
**Status.** Frames drawn on the Back-office board (groups: Articles: search status, Editor: search and sharing, Editor: what the frontend gets, Sites: SEO defaults), Integration page group added, specs updated. Placement rationale: the sidebar holds only the summary, address and suggestions because it is 280 px wide; previews and fields need a slide-over.
|
||||||
|
|
||||||
|
|
||||||
Ordered by implementation dependency: tokens and shell first, then shared components, then screens. "Frontend only" means no backend work is needed.
|
Ordered by implementation dependency: tokens and shell first, then shared components, then screens. "Frontend only" means no backend work is needed.
|
||||||
|
|
||||||
|
|||||||
@@ -77,3 +77,13 @@ Frames: `DirectoryFrame.dc.html` (Sites, Authors, editor section content; the li
|
|||||||
| CreateAuthorHint | New | UAlert closable | Dismissal stored per user |
|
| CreateAuthorHint | New | UAlert closable | Dismissal stored per user |
|
||||||
| Default badge, Linked badge, Guest badge | New | UBadge | Sand fill, info soft fill, outline |
|
| Default badge, Linked badge, Guest badge | New | UBadge | Sand fill, info soft fill, outline |
|
||||||
| Row menu with a disabled item | Existing + change | UDropdownMenu + UTooltip | The item stays focusable |
|
| Row menu with a disabled item | Existing + change | UDropdownMenu + UTooltip | The item stays focusable |
|
||||||
|
| SearchSharingSection | New | UCollapsible + UButton + USeparator | Editor sidebar and Details sheet, after Site and authors. Summary: Ready, N suggestions, Hidden from search, Checking… |
|
||||||
|
| SeoPanel | New | USlideover (desktop 960 px) / bottom sheet (mobile) + UFormField, UInput, UTextarea, USwitch, UCollapsible | Defined once in DirectoryFrame. Reuses SlugField, MediaField and the picker. Fallback chip: dashed outline, "Using …" |
|
||||||
|
| SoftCounter | New | text | Mono, "n / max". Amber (--ui-warning) outside the recommended range, never red, never blocks |
|
||||||
|
| SearchPreview | New | UCard | Desktop and Mobile toggle (UTabs, 2 items). Title cut at 60 (desktop) or 70 (mobile) characters including the template, description at 160 or 120 |
|
||||||
|
| SocialCardPreview | New | UCard | 1.91:1 image, host, title, 2-line description; fallback chips |
|
||||||
|
| SuggestionList | New | UAlert-style rows + UButton (link, ghost icon) | Calm elevated block. States: list, all good, checking. Per-article dismiss; "Show dismissed" |
|
||||||
|
| ApiPreviewDialog | New | UModal / bottom sheet + UTabs + UButton + UAlert | Formatted and Raw; Copy JSON; loading, error, draft note |
|
||||||
|
| SeoStatusChip | New | UBadge | Lightbulb + count (warning soft) or eye-off "Hidden" (outline). Articles rows and cards |
|
||||||
|
| NeedsAttentionToggle | New | UButton (pressed state) | Articles toolbar, sand when on |
|
||||||
|
| TitleTemplateField | New | UInput + live example | Sites form |
|
||||||
@@ -38,3 +38,6 @@
|
|||||||
|
|
||||||
## Sites and authors in the URL
|
## 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".
|
`/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".
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -85,3 +85,17 @@ Every Articles frame has the Authors column (avatars, at most 3 plus "+N"; "—"
|
|||||||
| 3 | No articles on a site | The chosen site has no articles. | No articles on "Archive" · Move articles here from the editor, or choose another site. · Show all sites |
|
| 3 | No articles on a site | The chosen site has no articles. | No articles on "Archive" · Move articles here from the editor, or choose another site. · Show all sites |
|
||||||
|
|
||||||
All existing Articles frames show the Authors column (avatars, at most 3 plus "+N", "—" when none).
|
All existing Articles frames show the Authors column (avatars, at most 3 plus "+N", "—" when none).
|
||||||
|
|
||||||
|
## Articles: search status (SEO round)
|
||||||
|
|
||||||
|
Route: `/articles?seo=suggestions`. API: GET /v1/article/?seo=suggestions&page=&size= (items carry seo_status and seo_suggestions).
|
||||||
|
|
||||||
|
A quiet indicator in the Status cell on desktop and under the title on mobile cards: a lightbulb chip with a count when there are suggestions, an eye-off chip "Hidden" when noindex is on, nothing when the article is ready. The toolbar gets a "Needs attention" toggle with a count next to the search field. Tabs and their counts are unchanged. On a single-site install nothing changes: the Site filter and column stay hidden and the toggle works the same, over all articles.
|
||||||
|
|
||||||
|
| # | State | Trigger | Copy |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | Status chips | Row 2 has 2 suggestions, row 3 is hidden, row 4 has 3, row 5 has 1. | Needs attention · 3 · Hidden · 2 suggestions (tooltip) · Hidden from search engines (tooltip) |
|
||||||
|
| 2 | Needs attention on | The toggle is pressed. The list shows only articles with suggestions. Hidden-only articles are not included. | Needs attention · 3 |
|
||||||
|
| 3 | Needs attention: none | Nothing to show with the toggle on. | Nothing needs attention · Every article has a clean check. Suggestions are optional, so this is just a bonus. · Show all articles |
|
||||||
|
|
||||||
|
Filter: "Needs attention" sets `?seo=suggestions` and resets `page` to 1. Tab counts do not change. Single-site installs behave the same; the Site filter stays hidden.
|
||||||
@@ -56,3 +56,6 @@ Owner and admin. Reassign: the dialog asks who gets the articles, with "Nobody"
|
|||||||
| 4 | Failed (shared by site and author delete) | 500 or network failure. Both delete dialogs use this notice. | Couldn't delete the author · Nothing was changed. Try again in a moment. |
|
| 4 | Failed (shared by site and author delete) | 500 or network failure. Both delete dialogs use this notice. | Couldn't delete the author · Nothing was changed. Try again in a moment. |
|
||||||
| 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 |
|
| 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 |
|
| 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 |
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -125,3 +125,53 @@ States specific to this feature. The collapsed default (one site, the article ha
|
|||||||
| 14 | Create author: for me | Owner and admin only. When the user has no author profile, a checkbox links the new author to them. | This is me · Links the author to marta@renew.example. |
|
| 14 | Create author: for me | Owner and admin only. When the user has no author profile, a checkbox links the new author to them. | This is me · Links the author to marta@renew.example. |
|
||||||
| 15 | Create author: slug taken | The slug exists. The dialog suggests a free one. | The slug "zoe-lambert" is already used by another author. · Use zoe-lambert-2 |
|
| 15 | Create author: slug taken | The slug exists. The dialog suggests a free one. | The slug "zoe-lambert" is already used by another author. · Use zoe-lambert-2 |
|
||||||
| 16 | Create author: failed | 500 or network failure. | Couldn't create the author · The article is unchanged. Try again in a moment. |
|
| 16 | Create author: failed | 500 or network failure. | Couldn't create the author · The article is unchanged. Try again in a moment. |
|
||||||
|
|
||||||
|
## Editor: search and sharing (SEO round)
|
||||||
|
|
||||||
|
Route: `/article/:id/edit`. API: PATCH /v1/article/{id} {slug, excerpt, seo_title, seo_description, cover_image_id, cover_image_alt, social_title, social_description, social_image_id, canonical_url, noindex, seo_dismissed[]}.
|
||||||
|
|
||||||
|
RenewCMS is headless: it never renders pages or adds meta tags. This section helps the writer fill in SEO data and shows what the frontend will receive. Everything is optional and each empty field shows its fallback. Placement: a "Search and sharing" section under "Site and authors" in the sidebar and in the mobile Details sheet. Collapsed by default with a one-line summary (Ready, N suggestions, Hidden from search), remembered per browser. The fields and live previews open in a slide-over (desktop, 960 px) or a 92% sheet over the Details sheet (mobile). Anyone who can edit the article can edit these fields. Suggestions never block saving or publishing: publishing is allowed regardless. Counters are soft guides and turn amber, never red, outside the recommended range. Light and dark follow the theme switch above.
|
||||||
|
|
||||||
|
| # | State | Trigger | Copy |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | Collapsed: ready | The default. Nothing to suggest. | Search and sharing · Ready |
|
||||||
|
| 2 | Collapsed: suggestions | Summary in the warning colour. No count badge elsewhere. | Search and sharing · 2 suggestions |
|
||||||
|
| 3 | Collapsed: hidden | noindex is on. | Search and sharing · Hidden from search |
|
||||||
|
| 4 | Open: ready | Address, the all-good message and two actions. | Address · renew.example/how-we-price-a-bathroom-renovation · All good · Nothing to suggest for this article. · Edit search and sharing · What the frontend gets |
|
||||||
|
| 5 | Open: suggestions | At most three suggestions here, then a count. Each links to its field. | The meta title is 74 characters. Search engines may cut it off after about 60. · Go to meta title · The cover image has no alt text. Add a short description of it. · Go to cover image · The body has no headings. H2 headings help readers scan the article. · Go to body · +1 more in Search and sharing |
|
||||||
|
| 6 | Open: checking | While the checks run after the editor loads. The rest of the section stays usable. | Checking this article… |
|
||||||
|
| 7 | Open: hidden | A line explains the flag. | Hidden from search engines. This only sets a flag in the API. |
|
||||||
|
| 8 | Open: other site | The address uses the article's site. | journal.renew.example/how-we-price-a-bathroom-renovation |
|
||||||
|
| 9 | Panel: defaults | A writer who changed nothing. Every value is a fallback and says so. | Search and sharing · URL slug · Generated from the title until you edit it. · Meta title · Using article title · Recommended 30 to 60 characters. This is a guide, not a limit. · Meta description · Using first sentence of the body · Recommended 70 to 160 characters. · Excerpt · A short summary. It is also sent as the article teaser. · Cover image · Using the site social image (north-wall.jpg). · Social sharing · Optional · Uses the meta values · Advanced · Canonical URL: automatic · Hide from search engines · Sets noindex in the API. It doesn't change who can read the article. · Search result · Social card · Suggestions · What the frontend gets · Cancel · Save changes |
|
||||||
|
| 10 | Panel: filled in | Own values everywhere. The fallback chips are gone and the suggestion list shows All good. | All good · Nothing to suggest for this article. |
|
||||||
|
| 11 | Panel: outside the soft ranges | Counters turn amber. The preview shows the cut-off. Nothing is blocked. | The meta title is 74 characters. Search engines may cut it off after about 60. · The description is 41 characters. About 70 to 160 reads best in results. · Cut off here, the way a search engine would. |
|
||||||
|
| 12 | Panel: slug edited | The Auto badge is gone. "Reset to match the title" brings it back. | Reset to match the title |
|
||||||
|
| 13 | Panel: slug taken | Checked on blur and on save. Nothing is saved until a free slug is chosen. | The slug "bathroom-costs" is already used on Renew. · Use bathroom-costs-2 |
|
||||||
|
| 14 | Panel: cover without alt | Alt text is required before the cover counts as ready. It is a suggestion, not an error. | Alt text · Needed before the cover counts as ready. Sent as image alt in the API. · Describe the image for people who can't see it · The cover image has no alt text. Add a short description of it. |
|
||||||
|
| 15 | Panel: choose cover | The shared Media picker. | Choose a cover image · The cover for this article. · Use image |
|
||||||
|
| 16 | Panel: social sharing open | Optional overrides. Each falls back to the meta values. | Social sharing · Optional · Social title · Using meta title · Social description · Using meta description · Social image · Using the cover image. |
|
||||||
|
| 17 | Panel: social customised | The card shows the overrides, so the Using chips disappear. | Customised |
|
||||||
|
| 18 | Panel: advanced, invalid canonical | Collapsed by default. The address must be absolute. | Advanced · Canonical URL · Only change this if the same content lives at another address. · Enter a full address starting with https:// |
|
||||||
|
| 19 | Panel: hidden from search | The switch only sets a flag in the API. | Hide from search engines · Sets noindex in the API. It doesn't change who can read the article. |
|
||||||
|
| 20 | Panel: mobile search preview | The Desktop and Mobile toggle changes the width and where the title is cut. | Search result · Desktop · Mobile |
|
||||||
|
| 21 | Panel: checking | Checks run on open and after each pause in typing. | Checking this article… |
|
||||||
|
| 22 | Panel: saving | Fields are disabled while the request runs. | Saving… |
|
||||||
|
| 23 | Panel: save failed | 500 or network failure. The values stay. | Couldn't save search and sharing. Your changes are still here. Try again in a moment. |
|
||||||
|
| 24 | Saved | The panel closes. The summary updates. | Search and sharing saved · Your search and sharing settings are up to date. |
|
||||||
|
|
||||||
|
Mobile: the section sits under "Site and authors" in the Details sheet. "Edit search and sharing" opens a 92% sheet on top of the Details sheet, with suggestions first, then fields, then the two previews. Publishing is allowed regardless of suggestions; Save and Publish are never disabled by them.
|
||||||
|
|
||||||
|
## Editor: what the frontend gets (SEO round)
|
||||||
|
|
||||||
|
Route: `/article/:id/edit`. API: GET /v1/article/{id}/seo.
|
||||||
|
|
||||||
|
A read-only dialog (desktop, 720 px) or bottom sheet (mobile) with the resolved SEO object exactly as the API returns it. Opened from the sidebar section or the panel. Each value that came from a fallback carries a chip. Formatted shows a compact key and value list, Raw shows the JSON. Copy JSON copies the raw object. Author name, profile URL and social links come from the Authors screen and surface in authors[] and in the JSON-LD author, so Authors itself needs no change.
|
||||||
|
|
||||||
|
| # | State | Trigger | Copy |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | Formatted | Default view. | What the frontend gets · The SEO data returned by GET /v1/article/12/seo, read only. RenewCMS doesn't render pages or add meta tags: your frontend does. · Formatted · Raw JSON · Copy JSON · Close |
|
||||||
|
| 2 | Raw JSON | The JSON as the API returns it. | Raw JSON |
|
||||||
|
| 3 | Copied | The button reads Copied for 2 s and this is announced. | Copied |
|
||||||
|
| 4 | Draft article | Drafts answer 404 to anonymous callers. | This article is a draft. The API returns this data only to signed-in callers until it is published. |
|
||||||
|
| 5 | Loading | Copy is disabled. | – |
|
||||||
|
| 6 | Error | GET failed. Nothing in the article changes. | Couldn't load the SEO data · GET /v1/article/12/seo failed. Your article is unchanged. Try again in a moment. · Try again |
|
||||||
@@ -108,3 +108,9 @@ New section "Images and variants" on the Integration page, after Media and befor
|
|||||||
## Pass 2 notes
|
## Pass 2 notes
|
||||||
- The base URL field and the image URL builder output are CodeSnippets with a single tab.
|
- The base URL field and the image URL builder output are CodeSnippets with a single tab.
|
||||||
- Page padding 24 px (16 px on mobile), page title 20/28, section headings 18/26, card titles 15/24, sub headings 14/22 (foundations.md).
|
- Page padding 24 px (16 px on mobile), page title 20/28, section headings 18/26, card titles 15/24, sub headings 14/22 (foundations.md).
|
||||||
|
|
||||||
|
## 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".
|
||||||
|
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.
|
||||||
@@ -52,3 +52,19 @@ Owner only. A site with articles cannot be deleted without a destination: the di
|
|||||||
| 1 | With articles | "Delete…" on a site that has articles. The button reads "Moving articles…" while the request runs. On 204 the row leaves the list and a toast reads "Site deleted" · "38 articles moved to Renew." | Delete this site? · "Journal" will be deleted. 38 articles belong to it, so choose a site to move them to. This can't be undone. · Move articles to · Renew · renew.example · 38 articles will move to Renew. Their canonical host changes to renew.example. · Cancel · Move 38 articles and delete |
|
| 1 | With articles | "Delete…" on a site that has articles. The button reads "Moving articles…" while the request runs. On 204 the row leaves the list and a toast reads "Site deleted" · "38 articles moved to Renew." | Delete this site? · "Journal" will be deleted. 38 articles belong to it, so choose a site to move them to. This can't be undone. · Move articles to · Renew · renew.example · 38 articles will move to Renew. Their canonical host changes to renew.example. · Cancel · Move 38 articles and delete |
|
||||||
| 2 | Choose destination | The select is open. The site being deleted is not listed. | Journal is not offered. Options show name and host. |
|
| 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 |
|
| 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: SEO defaults (SEO round)
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# SEO: decisions and slice order
|
||||||
|
|
||||||
|
Spec of record for the design is `change-list.md` (section "SEO round"), `screens/editor.md`, `screens/sites.md`, `screens/articles.md`, `screens/integration.md` and `states.md`. This file records the product decisions that settle the design's open questions, and the order of the implementation slices.
|
||||||
|
|
||||||
|
## Decisions on the open questions
|
||||||
|
|
||||||
|
| # | Question | Decision |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | Is `slug` immutable after publication? | No. It stays editable. RenewCMS does not manage redirects, so the frontend owns them. No extra warning copy is added to the design. |
|
||||||
|
| 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. |
|
||||||
|
| 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 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.
|
||||||
|
- `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.
|
||||||
|
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.
|
||||||
|
|
||||||
|
Run them in order and commit between them: 2 uses the fields of 1, 3 uses the endpoints of 2, and 3 and 4 share the article API types.
|
||||||
@@ -83,9 +83,9 @@
|
|||||||
<div style="display:flex;align-items:flex-end;gap:16px;flex-wrap:wrap"><div style="flex:1;min-width:200px;display:flex;flex-direction:column;gap:2px"><span style="font:600 20px/28px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Articles</span><span style="color:var(--ui-text-muted)">Everything you've written, online and in draft.</span></div><sc-if value="{{ desk }}"><span data-act="new" style="height:32px;padding:0 12px;display:flex;align-items:center;gap:6px;background:var(--ui-primary);color:var(--ui-bg);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/plus.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/plus.svg') center / contain no-repeat"></span>New article</span></sc-if></div>
|
<div style="display:flex;align-items:flex-end;gap:16px;flex-wrap:wrap"><div style="flex:1;min-width:200px;display:flex;flex-direction:column;gap:2px"><span style="font:600 20px/28px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Articles</span><span style="color:var(--ui-text-muted)">Everything you've written, online and in draft.</span></div><sc-if value="{{ desk }}"><span data-act="new" style="height:32px;padding:0 12px;display:flex;align-items:center;gap:6px;background:var(--ui-primary);color:var(--ui-bg);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/plus.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/plus.svg') center / contain no-repeat"></span>New article</span></sc-if></div>
|
||||||
<div style="display:flex;flex-direction:{{ tbDir }};gap:{{ tbGap }};border-bottom:{{ tbBorder }}">
|
<div style="display:flex;flex-direction:{{ tbDir }};gap:{{ tbGap }};border-bottom:{{ tbBorder }}">
|
||||||
<sc-if value="{{ mob }}"><span style="height:44px;padding:0 4px 0 12px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:{{ qFg }};font-size:16px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/search.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/search.svg') center / contain no-repeat"></span><span style="flex:1">{{ qText }}</span><sc-if value="{{ s.list.q }}"><span style="width:36px;height:36px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat"></span></span></sc-if></span></sc-if>
|
<sc-if value="{{ mob }}"><span style="height:44px;padding:0 4px 0 12px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:{{ qFg }};font-size:16px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/search.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/search.svg') center / contain no-repeat"></span><span style="flex:1">{{ qText }}</span><sc-if value="{{ s.list.q }}"><span style="width:36px;height:36px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat"></span></span></sc-if></span></sc-if>
|
||||||
<sc-if value="{{ multiMob }}"><span style="position:relative;display:flex;flex-direction:column;"><span style="height:44px;padding:0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:{{ sfFg }};font-size:16px;"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat"></span><span style="flex:1">{{ sfText }}</span><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat"></span></span><sc-if value="{{ sfMenu }}"><div role="listbox" aria-label="Site" style="position:absolute;left:0;right:0;top:48px;z-index:5;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:4px"><sc-for list="{{ sfOpts }}" as="o" hint-placeholder-count="3"><div role="option" aria-selected="{{ o.on }}" style="height:44px;padding:0 8px;display:flex;align-items:center;justify-content:space-between;background:{{ o.bg }};color:var(--ui-text-highlighted)">{{ o.t }}<span style="opacity:{{ o.op }};display:flex"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat"></span></span></div></sc-for></div></sc-if></span></sc-if>
|
<sc-if value="{{ mob }}"><span role="switch" aria-checked="{{ naOn }}" style="display:flex;flex-direction:column"><span style="height:44px;padding:0 10px;display:flex;align-items:center;gap:8px;border:1px solid {{ naBd }};background:{{ naBg }};color:{{ naFg }};font-size:15px;font-weight:{{ naFw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/lightbulb.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/lightbulb.svg') center / contain no-repeat"></span>Needs attention<span style="font:500 11px 'IBM Plex Mono',monospace">{{ naN }}</span></span></span></sc-if><sc-if value="{{ multiMob }}"><span style="position:relative;display:flex;flex-direction:column;"><span style="height:44px;padding:0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:{{ sfFg }};font-size:16px;"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat"></span><span style="flex:1">{{ sfText }}</span><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat"></span></span><sc-if value="{{ sfMenu }}"><div role="listbox" aria-label="Site" style="position:absolute;left:0;right:0;top:48px;z-index:5;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:4px"><sc-for list="{{ sfOpts }}" as="o" hint-placeholder-count="3"><div role="option" aria-selected="{{ o.on }}" style="height:44px;padding:0 8px;display:flex;align-items:center;justify-content:space-between;background:{{ o.bg }};color:var(--ui-text-highlighted)">{{ o.t }}<span style="opacity:{{ o.op }};display:flex"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat"></span></span></div></sc-for></div></sc-if></span></sc-if>
|
||||||
<div style="display:flex;gap:20px;flex:1;border-bottom:{{ tabBorder }}"><sc-for list="{{ tabs }}" as="t" hint-placeholder-count="3"><span style="height:{{ tabH }};display:flex;align-items:center;gap:6px;border-bottom:2px solid {{ t.line }};margin-bottom:-1px;font-weight:{{ t.fw }};color:{{ t.fg }};font-size:{{ tabF }}">{{ t.t }}<span style="font:500 11px 'IBM Plex Mono',monospace;color:var(--ui-text-muted)">{{ t.n }}</span></span></sc-for></div>
|
<div style="display:flex;gap:20px;flex:1;border-bottom:{{ tabBorder }}"><sc-for list="{{ tabs }}" as="t" hint-placeholder-count="3"><span style="height:{{ tabH }};display:flex;align-items:center;gap:6px;border-bottom:2px solid {{ t.line }};margin-bottom:-1px;font-weight:{{ t.fw }};color:{{ t.fg }};font-size:{{ tabF }}">{{ t.t }}<span style="font:500 11px 'IBM Plex Mono',monospace;color:var(--ui-text-muted)">{{ t.n }}</span></span></sc-for></div>
|
||||||
<sc-if value="{{ desk }}"><span style="width:260px;height:32px;margin-bottom:6px;padding:0 4px 0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:{{ qFg }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/search.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/search.svg') center / contain no-repeat"></span><span style="flex:1">{{ qText }}</span><sc-if value="{{ s.list.q }}"><span style="width:24px;height:24px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat"></span></span></sc-if></span></sc-if>
|
<sc-if value="{{ desk }}"><span role="switch" aria-checked="{{ naOn }}" style="margin-bottom:6px;display:flex"><span style="height:32px;padding:0 10px;display:flex;align-items:center;gap:8px;border:1px solid {{ naBd }};background:{{ naBg }};color:{{ naFg }};font-size:13px;font-weight:{{ naFw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/lightbulb.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/lightbulb.svg') center / contain no-repeat"></span>Needs attention<span style="font:500 11px 'IBM Plex Mono',monospace">{{ naN }}</span></span></span></sc-if><sc-if value="{{ desk }}"><span style="width:260px;height:32px;margin-bottom:6px;padding:0 4px 0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:{{ qFg }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/search.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/search.svg') center / contain no-repeat"></span><span style="flex:1">{{ qText }}</span><sc-if value="{{ s.list.q }}"><span style="width:24px;height:24px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat"></span></span></sc-if></span></sc-if>
|
||||||
<sc-if value="{{ multiDesk }}"><span style="position:relative;display:flex;flex-direction:column;width:150px;margin-bottom:6px;"><span style="height:32px;padding:0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:{{ sfFg }};"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat"></span><span style="flex:1">{{ sfText }}</span><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat"></span></span><sc-if value="{{ sfMenu }}"><div role="listbox" aria-label="Site" style="position:absolute;left:0;right:0;top:36px;z-index:5;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:4px"><sc-for list="{{ sfOpts }}" as="o" hint-placeholder-count="3"><div role="option" aria-selected="{{ o.on }}" style="height:32px;padding:0 8px;display:flex;align-items:center;justify-content:space-between;background:{{ o.bg }};color:var(--ui-text-highlighted)">{{ o.t }}<span style="opacity:{{ o.op }};display:flex"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat"></span></span></div></sc-for></div></sc-if></span></sc-if>
|
<sc-if value="{{ multiDesk }}"><span style="position:relative;display:flex;flex-direction:column;width:150px;margin-bottom:6px;"><span style="height:32px;padding:0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:{{ sfFg }};"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat"></span><span style="flex:1">{{ sfText }}</span><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat"></span></span><sc-if value="{{ sfMenu }}"><div role="listbox" aria-label="Site" style="position:absolute;left:0;right:0;top:36px;z-index:5;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:4px"><sc-for list="{{ sfOpts }}" as="o" hint-placeholder-count="3"><div role="option" aria-selected="{{ o.on }}" style="height:32px;padding:0 8px;display:flex;align-items:center;justify-content:space-between;background:{{ o.bg }};color:var(--ui-text-highlighted)">{{ o.t }}<span style="opacity:{{ o.op }};display:flex"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat"></span></span></div></sc-for></div></sc-if></span></sc-if>
|
||||||
</div>
|
</div>
|
||||||
<sc-if value="{{ s.list.empty }}">
|
<sc-if value="{{ s.list.empty }}">
|
||||||
@@ -102,7 +102,7 @@
|
|||||||
<sc-if value="{{ a.t }}">
|
<sc-if value="{{ a.t }}">
|
||||||
<span style="font-weight:500;color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding-right:12px">{{ a.t }}</span>
|
<span style="font-weight:500;color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding-right:12px">{{ a.t }}</span>
|
||||||
<sc-if value="{{ authorsCol }}"><span title="{{ a.auNames }}" style="display:flex;align-items:center;min-width:0"><sc-if value="{{ a.hasAu }}"><sc-for list="{{ a.avs }}" as="v" hint-placeholder-count="2"><span style="width:24px;height:24px;margin-left:{{ v.ml }};flex-shrink:0;display:flex;align-items:center;justify-content:center;background:{{ v.bg }};color:var(--ui-text-highlighted);border:2px solid var(--ui-bg);font:600 10px/1 'IBM Plex Sans',sans-serif">{{ v.i }}</span></sc-for><sc-if value="{{ a.auMore }}"><span style="margin-left:6px;font-size:12px;color:var(--ui-text-muted)">{{ a.auMore }}</span></sc-if></sc-if><sc-if value="{{ a.noAu }}"><span aria-label="No authors" style="color:var(--ui-text-dimmed)">—</span></sc-if></span></sc-if><sc-if value="{{ multi }}"><span style="color:var(--ui-text-toned);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding-right:12px">{{ a.site }}</span></sc-if>
|
<sc-if value="{{ authorsCol }}"><span title="{{ a.auNames }}" style="display:flex;align-items:center;min-width:0"><sc-if value="{{ a.hasAu }}"><sc-for list="{{ a.avs }}" as="v" hint-placeholder-count="2"><span style="width:24px;height:24px;margin-left:{{ v.ml }};flex-shrink:0;display:flex;align-items:center;justify-content:center;background:{{ v.bg }};color:var(--ui-text-highlighted);border:2px solid var(--ui-bg);font:600 10px/1 'IBM Plex Sans',sans-serif">{{ v.i }}</span></sc-for><sc-if value="{{ a.auMore }}"><span style="margin-left:6px;font-size:12px;color:var(--ui-text-muted)">{{ a.auMore }}</span></sc-if></sc-if><sc-if value="{{ a.noAu }}"><span aria-label="No authors" style="color:var(--ui-text-dimmed)">—</span></sc-if></span></sc-if><sc-if value="{{ multi }}"><span style="color:var(--ui-text-toned);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding-right:12px">{{ a.site }}</span></sc-if>
|
||||||
<span><sc-if value="{{ a.busy }}"><span style="height:22px;padding:0 7px;display:inline-flex;align-items:center;gap:4px;border:1px solid var(--ui-border-accented);color:var(--ui-text-muted);font-size:12px;font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat"></span>{{ a.busy }}</span></sc-if><sc-if value="{{ a.showOn }}"><span style="height:22px;padding:0 7px;display:inline-flex;align-items:center;gap:4px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font-size:12px;font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat"></span>Online</span></sc-if><sc-if value="{{ a.showDraft }}"><span style="height:22px;padding:0 7px;display:inline-flex;align-items:center;gap:4px;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned);font-size:12px;font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/pencil-line.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/pencil-line.svg') center / contain no-repeat"></span>Draft</span></sc-if></span>
|
<span><sc-if value="{{ a.busy }}"><span style="height:22px;padding:0 7px;display:inline-flex;align-items:center;gap:4px;border:1px solid var(--ui-border-accented);color:var(--ui-text-muted);font-size:12px;font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat"></span>{{ a.busy }}</span></sc-if><sc-if value="{{ a.showOn }}"><span style="height:22px;padding:0 7px;display:inline-flex;align-items:center;gap:4px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font-size:12px;font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat"></span>Online</span></sc-if><sc-if value="{{ a.showDraft }}"><span style="height:22px;padding:0 7px;display:inline-flex;align-items:center;gap:4px;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned);font-size:12px;font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/pencil-line.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/pencil-line.svg') center / contain no-repeat"></span>Draft</span></sc-if><sc-if value="{{ a.seoSug }}"><span title="{{ a.seoTitle }}" style="margin-left:6px;height:22px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;background:var(--rc-warning-soft);color:var(--ui-warning);font-size:12px;font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/lightbulb.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/lightbulb.svg') center / contain no-repeat"></span>{{ a.seoN }}</span></sc-if><sc-if value="{{ a.seoHid }}"><span title="Hidden from search engines" style="margin-left:6px;height:22px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned);font-size:12px;font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/eye-off.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/eye-off.svg') center / contain no-repeat"></span>Hidden</span></sc-if></span>
|
||||||
<span style="color:var(--ui-text-muted)">{{ a.c }}</span><span style="color:var(--ui-text-muted)">{{ a.u }}</span>
|
<span style="color:var(--ui-text-muted)">{{ a.c }}</span><span style="color:var(--ui-text-muted)">{{ a.u }}</span>
|
||||||
<span data-act="menu-{{ a.id }}" style="width:32px;height:32px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted);background:{{ a.menuBg }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/ellipsis.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/ellipsis.svg') center / contain no-repeat"></span></span>
|
<span data-act="menu-{{ a.id }}" style="width:32px;height:32px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted);background:{{ a.menuBg }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/ellipsis.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/ellipsis.svg') center / contain no-repeat"></span></span>
|
||||||
<sc-if value="{{ a.menu }}"><div role="menu" style="position:absolute;right:12px;top:40px;z-index:2;width:220px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:4px"><div data-act="open-{{ a.id }}" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:8px;background:var(--ui-bg-elevated);color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat"></span>Edit</div><div data-act="pub-{{ a.id }}" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:8px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ a.menuIcon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ a.menuIcon }}.svg') center / contain no-repeat"></span>{{ a.menuPub }}</div><div data-act="copyurl" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:8px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/link.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/link.svg') center / contain no-repeat"></span>Copy API URL</div><div style="height:1px;background:var(--ui-border);margin:4px 0"></div><div data-act="del-{{ a.id }}" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:8px;color:var(--ui-error)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/trash-2.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/trash-2.svg') center / contain no-repeat"></span>Delete…</div></div></sc-if>
|
<sc-if value="{{ a.menu }}"><div role="menu" style="position:absolute;right:12px;top:40px;z-index:2;width:220px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:4px"><div data-act="open-{{ a.id }}" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:8px;background:var(--ui-bg-elevated);color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat"></span>Edit</div><div data-act="pub-{{ a.id }}" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:8px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ a.menuIcon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ a.menuIcon }}.svg') center / contain no-repeat"></span>{{ a.menuPub }}</div><div data-act="copyurl" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:8px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/link.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/link.svg') center / contain no-repeat"></span>Copy API URL</div><div style="height:1px;background:var(--ui-border);margin:4px 0"></div><div data-act="del-{{ a.id }}" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:8px;color:var(--ui-error)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/trash-2.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/trash-2.svg') center / contain no-repeat"></span>Delete…</div></div></sc-if>
|
||||||
@@ -124,7 +124,7 @@
|
|||||||
<sc-for list="{{ rows }}" as="a" hint-placeholder-count="6">
|
<sc-for list="{{ rows }}" as="a" hint-placeholder-count="6">
|
||||||
<div data-act="open-{{ a.id }}" style="position:relative;display:flex;align-items:center;gap:8px;padding:12px 4px 12px 16px;border-bottom:1px solid var(--ui-border);min-height:68px;opacity:{{ a.op }}">
|
<div data-act="open-{{ a.id }}" style="position:relative;display:flex;align-items:center;gap:8px;padding:12px 4px 12px 16px;border-bottom:1px solid var(--ui-border);min-height:68px;opacity:{{ a.op }}">
|
||||||
<sc-if value="{{ a.skel }}"><div style="flex:1;display:flex;flex-direction:column;gap:8px"><span style="width:70%;height:14px;background:var(--ui-bg-accented)"></span><span style="width:40%;height:12px;background:var(--ui-bg-accented)"></span></div></sc-if>
|
<sc-if value="{{ a.skel }}"><div style="flex:1;display:flex;flex-direction:column;gap:8px"><span style="width:70%;height:14px;background:var(--ui-bg-accented)"></span><span style="width:40%;height:12px;background:var(--ui-bg-accented)"></span></div></sc-if>
|
||||||
<sc-if value="{{ a.t }}"><div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:6px"><span style="font-weight:500;font-size:15px;line-height:21px;color:var(--ui-text-highlighted)">{{ a.t }}</span><div style="display:flex;align-items:center;gap:10px;font-size:12px;color:var(--ui-text-muted)"><sc-if value="{{ a.busy }}"><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;border:1px solid var(--ui-border-accented);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat"></span>{{ a.busy }}</span></sc-if><sc-if value="{{ a.showOn }}"><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat"></span>Online</span></sc-if><sc-if value="{{ a.showDraft }}"><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/pencil-line.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/pencil-line.svg') center / contain no-repeat"></span>Draft</span></sc-if><span>Updated {{ a.u }}</span></div><sc-if value="{{ authorsCol }}"><div style="display:flex;align-items:center;gap:8px;font-size:12px;color:var(--ui-text-muted);min-width:0"><sc-if value="{{ a.hasAu }}"><span style="display:flex;align-items:center"><sc-for list="{{ a.avs }}" as="v" hint-placeholder-count="2"><span style="width:22px;height:22px;margin-left:{{ v.ml }};flex-shrink:0;display:flex;align-items:center;justify-content:center;background:{{ v.bg }};color:var(--ui-text-highlighted);border:2px solid var(--ui-bg);font:600 9px/1 'IBM Plex Sans',sans-serif">{{ v.i }}</span></sc-for></span></sc-if><span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ a.auFirst }}</span><sc-if value="{{ multi }}"><span>· {{ a.site }}</span></sc-if></div></sc-if></div><span data-act="menu-{{ a.id }}" style="width:44px;height:44px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/ellipsis-vertical.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/ellipsis-vertical.svg') center / contain no-repeat"></span></span></sc-if><sc-if value="{{ a.menu }}"><div role="menu" style="position:absolute;right:8px;top:56px;z-index:2;width:220px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:4px"><div data-act="open-{{ a.id }}" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:8px;background:var(--ui-bg-elevated);color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat"></span>Edit</div><div data-act="pub-{{ a.id }}" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:8px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ a.menuIcon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ a.menuIcon }}.svg') center / contain no-repeat"></span>{{ a.menuPub }}</div><div data-act="copyurl" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:8px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/link.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/link.svg') center / contain no-repeat"></span>Copy API URL</div><div style="height:1px;background:var(--ui-border);margin:4px 0"></div><div data-act="del-{{ a.id }}" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:8px;color:var(--ui-error)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;hLine truncated
|
<sc-if value="{{ a.t }}"><div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:6px"><span style="font-weight:500;font-size:15px;line-height:21px;color:var(--ui-text-highlighted)">{{ a.t }}</span><div style="display:flex;align-items:center;gap:10px;font-size:12px;color:var(--ui-text-muted)"><sc-if value="{{ a.busy }}"><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;border:1px solid var(--ui-border-accented);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat"></span>{{ a.busy }}</span></sc-if><sc-if value="{{ a.showOn }}"><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat"></span>Online</span></sc-if><sc-if value="{{ a.showDraft }}"><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/pencil-line.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/pencil-line.svg') center / contain no-repeat"></span>Draft</span></sc-if><sc-if value="{{ a.seoSug }}"><span title="{{ a.seoTitle }}" style="height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;background:var(--rc-warning-soft);color:var(--ui-warning);font-size:12px;font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/lightbulb.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/lightbulb.svg') center / contain no-repeat"></span>{{ a.seoN }}</span></sc-if><sc-if value="{{ a.seoHid }}"><span title="Hidden from search engines" style="height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned);font-size:12px;font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/eye-off.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/eye-off.svg') center / contain no-repeat"></span>Hidden</span></sc-if><span>Updated {{ a.u }}</span></div><sc-if value="{{ authorsCol }}"><div style="display:flex;align-items:center;gap:8px;font-size:12px;color:var(--ui-text-muted);min-width:0"><sc-if value="{{ a.hasAu }}"><span style="display:flex;align-items:center"><sc-for list="{{ a.avs }}" as="v" hint-placeholder-count="2"><span style="width:22px;height:22px;margin-left:{{ v.ml }};flex-shrink:0;display:flex;align-items:center;justify-content:center;background:{{ v.bg }};color:var(--ui-text-highlighted);border:2px solid var(--ui-bg);font:600 9px/1 'IBM Plex Sans',sans-serif">{{ v.i }}</span></sc-for></span></sc-if><span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ a.auFirst }}</span><sc-if value="{{ multi }}"><span>· {{ a.site }}</span></sc-if></div></sc-if></div><span data-act="menu-{{ a.id }}" style="width:44px;height:44px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/ellipsis-vertical.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/ellipsis-vertical.svg') center / contain no-repeat"></span></span></sc-if><sc-if value="{{ a.menu }}"><div role="menu" style="position:absolute;right:8px;top:56px;z-index:2;width:220px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:4px"><div data-act="open-{{ a.id }}" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:8px;background:var(--ui-bg-elevated);color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat"></span>Edit<Line truncated
|
||||||
</div>
|
</div>
|
||||||
</sc-for>
|
</sc-for>
|
||||||
</div>
|
</div>
|
||||||
@@ -191,7 +191,7 @@
|
|||||||
<span style="font-weight:600;font-size:14px;color:var(--ui-text-highlighted)">Details</span>
|
<span style="font-weight:600;font-size:14px;color:var(--ui-text-highlighted)">Details</span>
|
||||||
<sc-if value="{{ s.ed.loading }}"><div style="display:flex;flex-direction:column;gap:12px;padding-bottom:16px;border-bottom:1px solid var(--ui-border)"><div style="display:flex;align-items:center;gap:8px;min-height:24px;color:var(--ui-text-highlighted)"><span style="display:flex;transform:rotate(-90deg)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat"></span></span><span style="font-weight:600;font-size:14px">Site and authors</span></div><span style="margin-left:22px;width:40%;height:12px;background:var(--ui-bg-accented)"></span></div><div style="display:flex;flex-direction:column;gap:10px"><span style="height:12px;background:var(--ui-bg-accented)"></span><span style="height:12px;background:var(--ui-bg-accented)"></span><span style="height:12px;width:60%;background:var(--ui-bg-accented)"></span></div></sc-if>
|
<sc-if value="{{ s.ed.loading }}"><div style="display:flex;flex-direction:column;gap:12px;padding-bottom:16px;border-bottom:1px solid var(--ui-border)"><div style="display:flex;align-items:center;gap:8px;min-height:24px;color:var(--ui-text-highlighted)"><span style="display:flex;transform:rotate(-90deg)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat"></span></span><span style="font-weight:600;font-size:14px">Site and authors</span></div><span style="margin-left:22px;width:40%;height:12px;background:var(--ui-bg-accented)"></span></div><div style="display:flex;flex-direction:column;gap:10px"><span style="height:12px;background:var(--ui-bg-accented)"></span><span style="height:12px;background:var(--ui-bg-accented)"></span><span style="height:12px;width:60%;background:var(--ui-bg-accented)"></span></div></sc-if>
|
||||||
<sc-if value="{{ edReady }}">
|
<sc-if value="{{ edReady }}">
|
||||||
<div style="display:flex;flex-direction:column;gap:12px;padding-bottom:16px;border-bottom:1px solid var(--ui-border)"><div data-act="meta-toggle" role="button" tabindex="0" aria-expanded="{{ metaOpen }}" aria-controls="meta-body" style="display:flex;align-items:center;gap:8px;min-height:24px;color:var(--ui-text-highlighted)"><span style="display:flex;transform:rotate({{ metaRot }})"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat"></span></span><span style="font-weight:600;font-size:14px">Site and authors</span></div><sc-if value="{{ metaOpen }}"><div id="meta-body" style="display:flex;flex-direction:column;gap:16px">{{ edMeta }}</div></sc-if><sc-if value="{{ metaClosed }}"><span style="padding-left:22px;color:var(--ui-text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ metaSummary }}</span></sc-if></div>
|
<div style="display:flex;flex-direction:column;gap:12px;padding-bottom:16px;border-bottom:1px solid var(--ui-border)"><div data-act="meta-toggle" role="button" tabindex="0" aria-expanded="{{ metaOpen }}" aria-controls="meta-body" style="display:flex;align-items:center;gap:8px;min-height:24px;color:var(--ui-text-highlighted)"><span style="display:flex;transform:rotate({{ metaRot }})"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat"></span></span><span style="font-weight:600;font-size:14px">Site and authors</span></div><sc-if value="{{ metaOpen }}"><div id="meta-body" style="display:flex;flex-direction:column;gap:16px">{{ edMeta }}</div></sc-if><sc-if value="{{ metaClosed }}"><span style="padding-left:22px;color:var(--ui-text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ metaSummary }}</span></sc-if></div><div style="display:flex;flex-direction:column;gap:12px;padding-bottom:16px;border-bottom:1px solid var(--ui-border)"><div data-act="seo-toggle" role="button" tabindex="0" aria-expanded="{{ seoOpen }}" aria-controls="seo-body" style="display:flex;align-items:center;gap:8px;min-height:24px;color:var(--ui-text-highlighted)"><span style="display:flex;transform:rotate({{ seoRot }})"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat"></span></span><span style="font-weight:600;font-size:14px">Search and sharing</span></div><sc-if value="{{ seoOpen }}"><div id="seo-body" style="display:flex;flex-direction:column;gap:12px">{{ edSeo }}</div></sc-if><sc-if value="{{ seoClosed }}"><span style="padding-left:22px;color:{{ seoFg }};overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ seoSummary }}</span></sc-if></div>
|
||||||
<div style="display:grid;grid-template-columns:auto 1fr;gap:8px 12px"><span style="color:var(--ui-text-muted)">Created</span><span>12 Mar 2026, 09:14</span><span style="color:var(--ui-text-muted)">Updated</span><span>Today, 11:02</span><span style="color:var(--ui-text-muted)">ID</span><span style="font-family:'IBM Plex Mono',monospace">12</span><span style="color:var(--ui-text-muted)">Images</span><span>{{ imgCount }}</span></div>
|
<div style="display:grid;grid-template-columns:auto 1fr;gap:8px 12px"><span style="color:var(--ui-text-muted)">Created</span><span>12 Mar 2026, 09:14</span><span style="color:var(--ui-text-muted)">Updated</span><span>Today, 11:02</span><span style="color:var(--ui-text-muted)">ID</span><span style="font-family:'IBM Plex Mono',monospace">12</span><span style="color:var(--ui-text-muted)">Images</span><span>{{ imgCount }}</span></div>
|
||||||
<div style="display:flex;flex-direction:column;gap:6px"><span style="color:var(--ui-text-muted)">Public API URL</span><span style="display:flex;align-items:center;gap:6px;padding:6px 8px;background:var(--ui-bg-elevated);font:400 12px 'IBM Plex Mono',monospace"><span style="flex:1">/v1/article/12</span><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/copy.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/copy.svg') center / contain no-repeat"></span></span><span style="color:var(--ui-text-muted);font-size:12px;line-height:18px">{{ apiNote }}</span></div>
|
<div style="display:flex;flex-direction:column;gap:6px"><span style="color:var(--ui-text-muted)">Public API URL</span><span style="display:flex;align-items:center;gap:6px;padding:6px 8px;background:var(--ui-bg-elevated);font:400 12px 'IBM Plex Mono',monospace"><span style="flex:1">/v1/article/12</span><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/copy.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/copy.svg') center / contain no-repeat"></span></span><span style="color:var(--ui-text-muted);font-size:12px;line-height:18px">{{ apiNote }}</span></div>
|
||||||
<div style="display:flex;flex-direction:column;gap:4px;padding-top:12px;border-top:1px solid var(--ui-border)"><span style="color:var(--ui-text-muted)">Title</span><span style="font-size:12px;line-height:18px;color:var(--ui-text-toned)">Titles can't be changed after an article is created. To use a different title, create a new article and delete this one.</span></div>
|
<div style="display:flex;flex-direction:column;gap:4px;padding-top:12px;border-top:1px solid var(--ui-border)"><span style="color:var(--ui-text-muted)">Title</span><span style="font-size:12px;line-height:18px;color:var(--ui-text-toned)">Titles can't be changed after an article is created. To use a different title, create a new article and delete this one.</span></div>
|
||||||
@@ -370,7 +370,7 @@
|
|||||||
{{ purgeDlg }}
|
{{ purgeDlg }}
|
||||||
<sc-if value="{{ navDrawer }}"><div data-act="mnav-close" style="position:absolute;inset:0;z-index:60;display:flex;background:var(--rc-overlay)"><div role="navigation" aria-label="Main" style="width:300px;max-width:85%;height:100%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column;padding:12px;gap:2px"><div style="display:flex;align-items:center;gap:10px;height:40px;padding:0 6px;margin-bottom:10px"><svg width="26" height="26" viewBox="0 0 16 16" shape-rendering="crispEdges"><rect width="16" height="16" fill="#111"></rect><path fill="#fff" fill-rule="evenodd" d="M4 3h6v1h1v1h1v3h-1v1h-1v1h1v1h1v2h-2v-2h-1v-1H6v3H4zm2 2v3h4V5z"></path></svg><span style="flex:1;font-weight:600;font-size:14px;color:var(--ui-text-highlighted)">RenewCMS</span></div><sc-for list="{{ nav }}" as="n" hint-placeholder-count="3"><div data-act="nav-{{ n.key }}" style="display:flex;align-items:center;gap:10px;height:44px;padding:0 10px;background:{{ n.bg }};color:{{ n.fg }};font-weight:{{ n.fw }}"><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ n.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ n.icon }}.svg') center / contain no-repeat"></span><span style="flex:1">{{ n.t }}</span><sc-if value="{{ n.chev }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ n.chev }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ n.chev }}.svg') center / contain no-repeat"></span></sc-if></div><sc-if value="{{ n.sub }}"><div style="display:flex;flex-direction:column;gap:2px;margin-left:19px;border-left:1px solid var(--ui-border)"><span style="padding:6px 10px 2px 12px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Media</span><sc-for list="{{ n.subs }}" as="x" hint-placeholder-count="2"><div data-act="nav-{{ x.t }}" style="display:flex;align-items:center;gap:10px;height:44px;padding:0 10px 0 12px;background:{{ x.bg }};color:{{ x.fg }};font-weight:{{ x.fw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ x.ic }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ x.ic }}.svg') center / contain no-repeat"></span>{{ x.t }}</div></sc-for><sc-if value="{{ n.hasSites }}"><span style="padding:6px 10px 2px 12px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Publishing</span></sc-if><sc-for list="{{ n.subs2 }}" as="x2" hint-placeholder-count="1"><div data-act="nav-{{ x2.t }}" style="display:flex;align-items:center;gap:10px;height:44px;padding:0 10px 0 12px;background:{{ x2.bg }};color:{{ x2.fg }};font-weight:{{ x2.fw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ x2.ic }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ x2.ic }}.svg') center / contain no-repeat"></span>{{ x2.t }}</div></sc-for></div></sc-if></sc-for><div style="flex:1"></div><div style="display:flex;align-items:center;gap:10px;height:44px;padding:0 6px;color:var(--ui-text-highlighted)"><span style="width:26px;height:26px;flex-shrink:0;background:var(--rc-sand);color:var(--rc-on-sand);display:flex;align-items:center;justify-content:center;font:600 11px 'IBM Plex Sans',sans-serif">{{ ini }}</span><span style="flex:1;font-weight:500">{{ who }}</span></div></div></div></sc-if>
|
<sc-if value="{{ navDrawer }}"><div data-act="mnav-close" style="position:absolute;inset:0;z-index:60;display:flex;background:var(--rc-overlay)"><div role="navigation" aria-label="Main" style="width:300px;max-width:85%;height:100%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column;padding:12px;gap:2px"><div style="display:flex;align-items:center;gap:10px;height:40px;padding:0 6px;margin-bottom:10px"><svg width="26" height="26" viewBox="0 0 16 16" shape-rendering="crispEdges"><rect width="16" height="16" fill="#111"></rect><path fill="#fff" fill-rule="evenodd" d="M4 3h6v1h1v1h1v3h-1v1h-1v1h1v1h1v2h-2v-2h-1v-1H6v3H4zm2 2v3h4V5z"></path></svg><span style="flex:1;font-weight:600;font-size:14px;color:var(--ui-text-highlighted)">RenewCMS</span></div><sc-for list="{{ nav }}" as="n" hint-placeholder-count="3"><div data-act="nav-{{ n.key }}" style="display:flex;align-items:center;gap:10px;height:44px;padding:0 10px;background:{{ n.bg }};color:{{ n.fg }};font-weight:{{ n.fw }}"><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ n.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ n.icon }}.svg') center / contain no-repeat"></span><span style="flex:1">{{ n.t }}</span><sc-if value="{{ n.chev }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ n.chev }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ n.chev }}.svg') center / contain no-repeat"></span></sc-if></div><sc-if value="{{ n.sub }}"><div style="display:flex;flex-direction:column;gap:2px;margin-left:19px;border-left:1px solid var(--ui-border)"><span style="padding:6px 10px 2px 12px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Media</span><sc-for list="{{ n.subs }}" as="x" hint-placeholder-count="2"><div data-act="nav-{{ x.t }}" style="display:flex;align-items:center;gap:10px;height:44px;padding:0 10px 0 12px;background:{{ x.bg }};color:{{ x.fg }};font-weight:{{ x.fw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ x.ic }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ x.ic }}.svg') center / contain no-repeat"></span>{{ x.t }}</div></sc-for><sc-if value="{{ n.hasSites }}"><span style="padding:6px 10px 2px 12px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Publishing</span></sc-if><sc-for list="{{ n.subs2 }}" as="x2" hint-placeholder-count="1"><div data-act="nav-{{ x2.t }}" style="display:flex;align-items:center;gap:10px;height:44px;padding:0 10px 0 12px;background:{{ x2.bg }};color:{{ x2.fg }};font-weight:{{ x2.fw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ x2.ic }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ x2.ic }}.svg') center / contain no-repeat"></span>{{ x2.t }}</div></sc-for></div></sc-if></sc-for><div style="flex:1"></div><div style="display:flex;align-items:center;gap:10px;height:44px;padding:0 6px;color:var(--ui-text-highlighted)"><span style="width:26px;height:26px;flex-shrink:0;background:var(--rc-sand);color:var(--rc-on-sand);display:flex;align-items:center;justify-content:center;font:600 11px 'IBM Plex Sans',sans-serif">{{ ini }}</span><span style="flex:1;font-weight:500">{{ who }}</span></div></div></div></sc-if>
|
||||||
<sc-if value="{{ lk.sheet }}"><div style="position:absolute;inset:0;z-index:55;background:var(--rc-overlay);display:flex;align-items:flex-end"><div role="dialog" aria-label="Link" style="width:100%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column"><div style="display:flex;justify-content:center;padding-top:8px"><span style="display:block;width:36px;height:4px;background:var(--ui-border-accented)"></span></div><div style="display:flex;flex-direction:column;gap:2px;padding:12px 16px 12px;border-bottom:1px solid var(--ui-border)"><span style="font-weight:600;font-size:16px;line-height:24px;color:var(--ui-text-highlighted)">timber windows</span><span style="font:400 12px/18px 'IBM Plex Mono',monospace;color:var(--ui-text-toned);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">/articles/winter-checklist</span></div><div style="display:flex;flex-direction:column;padding:8px 0 16px"><span style="height:48px;padding:0 16px;display:flex;align-items:center;gap:12px;color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat"></span>Edit link</span><span style="height:48px;padding:0 16px;display:flex;align-items:center;gap:12px;color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/external-link.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/external-link.svg') center / contain no-repeat"></span>Open link</span><span style="height:48px;padding:0 16px;display:flex;align-items:center;gap:12px;color:var(--ui-error)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/unlink.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/unlink.svg') center / contain no-repeat"></span>Remove link</span></div></div></div></sc-if>
|
<sc-if value="{{ lk.sheet }}"><div style="position:absolute;inset:0;z-index:55;background:var(--rc-overlay);display:flex;align-items:flex-end"><div role="dialog" aria-label="Link" style="width:100%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column"><div style="display:flex;justify-content:center;padding-top:8px"><span style="display:block;width:36px;height:4px;background:var(--ui-border-accented)"></span></div><div style="display:flex;flex-direction:column;gap:2px;padding:12px 16px 12px;border-bottom:1px solid var(--ui-border)"><span style="font-weight:600;font-size:16px;line-height:24px;color:var(--ui-text-highlighted)">timber windows</span><span style="font:400 12px/18px 'IBM Plex Mono',monospace;color:var(--ui-text-toned);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">/articles/winter-checklist</span></div><div style="display:flex;flex-direction:column;padding:8px 0 16px"><span style="height:48px;padding:0 16px;display:flex;align-items:center;gap:12px;color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat"></span>Edit link</span><span style="height:48px;padding:0 16px;display:flex;align-items:center;gap:12px;color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/external-link.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/external-link.svg') center / contain no-repeat"></span>Open link</span><span style="height:48px;padding:0 16px;display:flex;align-items:center;gap:12px;color:var(--ui-error)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/unlink.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/unlink.svg') center / contain no-repeat"></span>Remove link</span></div></div></div></sc-if>
|
||||||
<sc-if value="{{ edSheet }}"><div style="position:absolute;inset:0;z-index:55;background:var(--rc-overlay);display:flex;align-items:flex-end"><div role="dialog" aria-modal="true" aria-label="Details" style="width:100%;height:92%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column"><div style="display:flex;justify-content:center;padding-top:8px;flex-shrink:0"><span style="display:block;width:36px;height:4px;background:var(--ui-border-accented)"></span></div><div style="padding:8px 16px 0 16px;display:flex;align-items:center;gap:12px;flex-shrink:0"><span style="flex:1;font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Details</span><span data-act="modal-close" style="width:44px;height:44px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat"></span></span></div><div style="flex:1;min-height:0;overflow:hidden;padding:12px 16px 16px;display:flex;flex-direction:column;gap:16px"><div style="display:flex;flex-direction:column;gap:12px;padding-bottom:16px;border-bottom:1px solid var(--ui-border)"><div data-act="meta-toggle" role="button" tabindex="0" aria-expanded="{{ metaOpen }}" aria-controls="meta-body" style="display:flex;align-items:center;gap:8px;min-height:24px;color:var(--ui-text-highlighted)"><span style="display:flex;transform:rotate({{ metaRot }})"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat"></span></span><span style="font-weight:600;font-size:14px">Site and authors</span></div><sc-if value="{{ metaOpen }}"><div id="meta-body" style="display:flex;flex-direction:column;gap:16px">{{ edMeta }}</div></sc-if><sc-if value="{{ metaClosed }}"><span style="padding-left:22px;color:var(--ui-text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ metaSummary }}</span></sc-if></div><div style="display:grid;grid-template-columns:auto 1fr;gap:8px 12px"><span style="color:var(--ui-text-muted)">Created</span><span>12 Mar 2026, 09:14</span><span style="color:var(--ui-text-muted)">Updated</span><span>Today, 11:02</span><span style="color:var(--ui-text-muted)">ID</span><span style="font-family:'IBM Plex Mono',monospace">12</span></div><div style="display:flex;flex-direction:column;gap:6px"><span style="color:var(--ui-text-muted)">Public API URL</span><span style="padding:6px 8px;background:var(--ui-bg-elevated);font:400 12px 'IBM Plex Mono',monospace">/v1/article/12</span></div></div></div></div></sc-if>
|
<sc-if value="{{ edSheet }}"><div style="position:absolute;inset:0;z-index:55;background:var(--rc-overlay);display:flex;align-items:flex-end"><div role="dialog" aria-modal="true" aria-label="Details" style="width:100%;height:92%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column"><div style="display:flex;justify-content:center;padding-top:8px;flex-shrink:0"><span style="display:block;width:36px;height:4px;background:var(--ui-border-accented)"></span></div><div style="padding:8px 16px 0 16px;display:flex;align-items:center;gap:12px;flex-shrink:0"><span style="flex:1;font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Details</span><span data-act="modal-close" style="width:44px;height:44px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat"></span></span></div><div style="flex:1;min-height:0;overflow:hidden;padding:12px 16px 16px;display:flex;flex-direction:column;gap:16px"><div style="display:flex;flex-direction:column;gap:12px;padding-bottom:16px;border-bottom:1px solid var(--ui-border)"><div data-act="meta-toggle" role="button" tabindex="0" aria-expanded="{{ metaOpen }}" aria-controls="meta-body" style="display:flex;align-items:center;gap:8px;min-height:24px;color:var(--ui-text-highlighted)"><span style="display:flex;transform:rotate({{ metaRot }})"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat"></span></span><span style="font-weight:600;font-size:14px">Site and authors</span></div><sc-if value="{{ metaOpen }}"><div id="meta-body" style="display:flex;flex-direction:column;gap:16px">{{ edMeta }}</div></sc-if><sc-if value="{{ metaClosed }}"><span style="padding-left:22px;color:var(--ui-text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ metaSummary }}</span></sc-if></div><div style="display:flex;flex-direction:column;gap:12px;padding-bottom:16px;border-bottom:1px solid var(--ui-border)"><div data-act="seo-toggle" role="button" tabindex="0" aria-expanded="{{ seoOpen }}" aria-controls="seo-body" style="display:flex;align-items:center;gap:8px;min-height:24px;color:var(--ui-text-highlighted)"><span style="display:flex;transform:rotate({{ seoRot }})"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat"></span></span><span style="font-weight:600;font-size:14px">Search and sharing</span></div><sc-if value="{{ seoOpen }}"><div id="seo-body" style="display:flex;flex-direction:column;gap:12px">{{ edSeo }}</div></sc-if><sc-if value="{{ seoClosed }}"><span style="padding-left:22px;color:{{ seoFg }};overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ seoSummary }}</span></sc-if></div><div style="display:grid;grid-template-columns:auto 1fr;gap:8px 12px"><span style="color:var(--ui-text-muted)">Created</span><span>12 Mar 2026, 09:14</span><span style="color:var(--ui-text-muted)">Updated</span><span>Today, 11:02</span><span style="color:var(--ui-text-muted)">ID</span><span style="font-family:'IBM Plex Mono',monospace">12</span></div><div style="display:flex;flex-direction:column;gap:6px"><span style="color:var(--ui-text-muted)">Public API URL</span><span style="padding:6px 8px;background:var(--ui-bg-elevated);font:400 12px 'IBM Plex Mono',monospace">/v1/article/12</span></div></div></div></div></sc-if>
|
||||||
{{ slotOverlays }}
|
{{ slotOverlays }}
|
||||||
<sc-if value="{{ hasToasts }}">
|
<sc-if value="{{ hasToasts }}">
|
||||||
<div style="position:absolute;z-index:60;display:flex;flex-direction:column;gap:8px;left:{{ tL }};right:{{ tR }};bottom:{{ tB }};width:{{ tW }}">
|
<div style="position:absolute;z-index:60;display:flex;flex-direction:column;gap:8px;left:{{ tL }};right:{{ tR }};bottom:{{ tB }};width:{{ tW }}">
|
||||||
@@ -396,7 +396,7 @@ class Component extends DCLogic {
|
|||||||
const tabs = ['All', 'Online', 'Drafts'].map((t, i) => { const on = (L.tab || 0) === i; return { t, n: counts[i], line: on ? 'var(--ui-primary)' : 'transparent', fg: on ? 'var(--ui-text-highlighted)' : 'var(--ui-text-toned)', fw: on ? 500 : 400 } })
|
const tabs = ['All', 'Online', 'Drafts'].map((t, i) => { const on = (L.tab || 0) === i; return { t, n: counts[i], line: on ? 'var(--ui-primary)' : 'transparent', fg: on ? 'var(--ui-text-highlighted)' : 'var(--ui-text-toned)', fw: on ? 500 : 400 } })
|
||||||
const AVT = ['var(--rc-sand)', 'var(--ui-bg-accented)', 'var(--ui-bg-elevated)'], ini2 = n => n.replace(/^Dr\.\s*/, '').split(/\s+/).slice(0, 2).map(x => x[0]).join('').toUpperCase()
|
const AVT = ['var(--rc-sand)', 'var(--ui-bg-accented)', 'var(--ui-bg-elevated)'], ini2 = n => n.replace(/^Dr\.\s*/, '').split(/\s+/).slice(0, 2).map(x => x[0]).join('').toUpperCase()
|
||||||
const auInfo = a => { if (a.skel) return {}; const au = a.au || []; return { hasAu: au.length > 0, noAu: au.length === 0, avs: au.slice(0, 3).map((n, i) => ({ i: ini2(n), bg: AVT[(n.length + i) % 3], ml: i ? '-6px' : '0' })), auMore: au.length > 3 ? '+' + (au.length - 3) : '', auNames: au.join(', '), auFirst: au.length ? au[0] + (au.length > 1 ? ' +' + (au.length - 1) : '') : 'No authors', site: a.site || '' } }
|
const auInfo = a => { if (a.skel) return {}; const au = a.au || []; return { hasAu: au.length > 0, noAu: au.length === 0, avs: au.slice(0, 3).map((n, i) => ({ i: ini2(n), bg: AVT[(n.length + i) % 3], ml: i ? '-6px' : '0' })), auMore: au.length > 3 ? '+' + (au.length - 3) : '', auNames: au.join(', '), auFirst: au.length ? au[0] + (au.length > 1 ? ' +' + (au.length - 1) : '') : 'No authors', site: a.site || '' } }
|
||||||
const rows = (L.loading ? Array.from({ length: 6 }, () => ({ skel: true })) : (L.rows || [])).map(a => ({ ...a, showOn: !a.busy && a.on, showDraft: !a.busy && a.t && !a.on, op: a.busy ? '0.55' : '1', bg: a.menu ? 'var(--ui-bg-elevated)' : 'transparent', menuBg: a.menu ? 'var(--ui-bg-accented)' : 'transparent', menuPub: a.on ? 'Unpublish' : 'Publish', menuIcon: a.on ? 'globe-lock' : 'globe', ...auInfo(a) }))
|
const rows = (L.loading ? Array.from({ length: 6 }, () => ({ skel: true })) : (L.rows || [])).map(a => ({ ...a, seoSug: a.seo > 0, seoN: a.seo, seoTitle: a.seo + (a.seo === 1 ? ' suggestion' : ' suggestions'), seoHid: a.seo === 'hidden', showOn: !a.busy && a.on, showDraft: !a.busy && a.t && !a.on, op: a.busy ? '0.55' : '1', bg: a.menu ? 'var(--ui-bg-elevated)' : 'transparent', menuBg: a.menu ? 'var(--ui-bg-accented)' : 'transparent', menuPub: a.on ? 'Unpublish' : 'Publish', menuIcon: a.on ? 'globe-lock' : 'globe', ...auInfo(a) }))
|
||||||
const e = s.ed || {}
|
const e = s.ed || {}
|
||||||
const saveMap = { clean: ['check', 'Saved', 'var(--ui-text-muted)'], dirty: ['circle-dot', 'Unsaved changes', 'var(--ui-warning)'], saving: ['loader-circle', 'Saving…', 'var(--ui-text-muted)'], saved: ['check', 'Saved at 11:02', 'var(--ui-text-muted)'], error: ['circle-alert', 'Save failed', 'var(--ui-error)'], offline: ['wifi-off', 'Waiting for connection', 'var(--ui-text-muted)'] }
|
const saveMap = { clean: ['check', 'Saved', 'var(--ui-text-muted)'], dirty: ['circle-dot', 'Unsaved changes', 'var(--ui-warning)'], saving: ['loader-circle', 'Saving…', 'var(--ui-text-muted)'], saved: ['check', 'Saved at 11:02', 'var(--ui-text-muted)'], error: ['circle-alert', 'Save failed', 'var(--ui-error)'], offline: ['wifi-off', 'Waiting for connection', 'var(--ui-text-muted)'] }
|
||||||
const sv = saveMap[e.save || 'saved']
|
const sv = saveMap[e.save || 'saved']
|
||||||
@@ -536,7 +536,7 @@ class Component extends DCLogic {
|
|||||||
const edReady = k === 'editor' && !e.loading && !e.notfound
|
const edReady = k === 'editor' && !e.loading && !e.notfound
|
||||||
const edTitle = e.title || 'How we price a bathroom renovation'
|
const edTitle = e.title || 'How we price a bathroom renovation'
|
||||||
return {
|
return {
|
||||||
lk, edMeta: this.props.edMeta || null, metaOpen: !!this.props.edMeta, metaClosed: !this.props.edMeta, metaRot: this.props.edMeta ? '0deg' : '-90deg', metaSummary: e.metaSummary || 'Inès Duarte', edSheet: m && !!e.sheet, pAu: (() => { const q = s.pa || {}; return { none: q.k !== 'has', has: q.k === 'has', err: !!q.err, slug: q.slug || 'marta-alves', slugNote: !!q.slug, btn: q.loading ? 'Creating…' : 'Create my author profile', icon: q.loading ? 'loader-circle' : 'feather', op: q.loading ? 0.7 : 1 } })(), showSites: !s.role || s.role === 'owner', authorsCol: L.authors !== false, multi: !!L.multi, multiDesk: !m && !!L.multi, multiMob: m && !!L.multi, gridCols: (L.authors !== false ? 'minmax(0,1fr) 104px ' : 'minmax(0,1fr) ') + (L.multi ? '130px ' : '') + '110px 130px 140px 44px', sfText: L.site || 'All sites', sfFg: L.site ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)', sfMenu: !!L.siteMenu, sfOpts: ['All sites', 'Renew', 'Journal', 'Projects', 'Archive'].map(x => { const on = (L.site || 'All sites') === x; return { t: x, on, op: on ? 1 : 0, bg: on ? 'var(--ui-bg-elevated)' : 'transparent' } }), tbOverflow: m ? 'hidden' : 'visible', tbShift: m ? -(LK.shift || 0) + 'px' : '0px', tbFadeL: m && !!LK.shift, tbFadeR: m && !LK.end, act, cl, rt, purgeDlg, rowDir: m ? 'column' : 'row', rowAlign: m ? 'stretch' : 'flex-start', rowJust: m ? 'flex-start' : 'space-between', rowGap: m ? '12px' : '16px', setScroll: -(s.scrollY || 0) + 'px', offText: s.offText || 'You\'re offline. You can keep writing, but saving and publishing will wait until you\'re back online.', cr, s, nav, crumbs, who, ini, pf, dn, dnSave, rsNote, rsBtn,
|
lk, edSeo: this.props.edSeo || null, seoOpen: !!this.props.edSeo, seoClosed: !this.props.edSeo, seoRot: this.props.edSeo ? '0deg' : '-90deg', seoSummary: e.seoSummary || 'Ready', seoFg: /suggest/.test(e.seoSummary || '') ? 'var(--ui-warning)' : 'var(--ui-text-muted)', naOn: !!L.needs, naBg: L.needs ? 'var(--rc-sand)' : 'var(--ui-bg)', naFg: L.needs ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)', naBd: L.needs ? 'var(--rc-sand)' : 'var(--ui-border-accented)', naFw: L.needs ? 500 : 400, naN: L.needsN || 3, edMeta: this.props.edMeta || null, metaOpen: !!this.props.edMeta, metaClosed: !this.props.edMeta, metaRot: this.props.edMeta ? '0deg' : '-90deg', metaSummary: e.metaSummary || 'Inès Duarte', edSheet: m && !!e.sheet, pAu: (() => { const q = s.pa || {}; return { none: q.k !== 'has', has: q.k === 'has', err: !!q.err, slug: q.slug || 'marta-alves', slugNote: !!q.slug, btn: q.loading ? 'Creating…' : 'Create my author profile', icon: q.loading ? 'loader-circle' : 'feather', op: q.loading ? 0.7 : 1 } })(), showSites: !s.role || s.role === 'owner', authorsCol: L.authors !== false, multi: !!L.multi, multiDesk: !m && !!L.multi, multiMob: m && !!L.multi, gridCols: (L.authors !== false ? 'minmax(0,1fr) 104px ' : 'minmax(0,1fr) ') + (L.multi ? '130px ' : '') + '110px 130px 140px 44px', sfText: L.site || 'All sites', sfFg: L.site ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)', sfMenu: !!L.siteMenu, sfOpts: ['All sites', 'Renew', 'Journal', 'Projects', 'Archive'].map(x => { const on = (L.site || 'All sites') === x; return { t: x, on, op: on ? 1 : 0, bg: on ? 'var(--ui-bg-elevated)' : 'transparent' } }), tbOverflow: m ? 'hidden' : 'visible', tbShift: m ? -(LK.shift || 0) + 'px' : '0px', tbFadeL: m && !!LK.shift, tbFadeR: m && !LK.end, act, cl, rt, purgeDlg, rowDir: m ? 'column' : 'row', rowAlign: m ? 'stretch' : 'flex-start', rowJust: m ? 'flex-start' : 'space-between', rowGap: m ? '12px' : '16px', setScroll: -(s.scrollY || 0) + 'px', offText: s.offText || 'You\'re offline. You can keep writing, but saving and publishing will wait until you\'re back online.', cr, s, nav, crumbs, who, ini, pf, dn, dnSave, rsNote, rsBtn,
|
||||||
langSeg: [['en','English'],['fr','Français']].map(([k,tt])=>{const on=(s.lang||'en')===k;return {act:'lang-'+k,code:k,t:tt,on,op:on?1:0}}), themeSeg: [['system','monitor','System'],['light','sun','Light'],['dark','moon','Dark']].map(([k,icon,tt])=>{const on=(s.mode||'light')===k;return {act:'theme-'+k,icon,t:tt,on,op:on?1:0}}), menuOpen: !!s.userMenu, deskMenu: !m && !!s.userMenu, mobMenu: m && !!s.userMenu, pfItemBg: 'var(--ui-bg-elevated)',
|
langSeg: [['en','English'],['fr','Français']].map(([k,tt])=>{const on=(s.lang||'en')===k;return {act:'lang-'+k,code:k,t:tt,on,op:on?1:0}}), themeSeg: [['system','monitor','System'],['light','sun','Light'],['dark','moon','Dark']].map(([k,icon,tt])=>{const on=(s.mode||'light')===k;return {act:'theme-'+k,icon,t:tt,on,op:on?1:0}}), menuOpen: !!s.userMenu, deskMenu: !m && !!s.userMenu, mobMenu: m && !!s.userMenu, pfItemBg: 'var(--ui-bg-elevated)',
|
||||||
chipBg: s.userMenu || s.nav === 'profile' ? 'var(--rc-sand)' : 'transparent', chipFg: s.userMenu || s.nav === 'profile' ? 'var(--rc-on-sand)' : 'var(--ui-text-highlighted)',
|
chipBg: s.userMenu || s.nav === 'profile' ? 'var(--rc-sand)' : 'transparent', chipFg: s.userMenu || s.nav === 'profile' ? 'var(--rc-on-sand)' : 'var(--ui-text-highlighted)',
|
||||||
isSettings: k === 'settings', isProfile: k === 'profile', pfLoading: k === 'profile' && !!pf.loading, pfReady: k === 'profile' && !pf.loading,
|
isSettings: k === 'settings', isProfile: k === 'profile', pfLoading: k === 'profile' && !!pf.loading, pfReady: k === 'profile' && !pf.loading,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<x-dc>
|
<x-dc>
|
||||||
<dc-import name="AppFrame" s="{{ shell }}" mobile="{{ mob }}" slot-body="{{ main }}" slot-overlays="{{ ov }}" ed-meta="{{ edMeta }}" hint-size="100%,100%"></dc-import>
|
<dc-import name="AppFrame" s="{{ shell }}" mobile="{{ mob }}" slot-body="{{ main }}" slot-overlays="{{ ov }}" ed-meta="{{ edMeta }}" ed-seo="{{ edSeo }}" hint-size="100%,100%"></dc-import>
|
||||||
</x-dc>
|
</x-dc>
|
||||||
<script type="text/x-dc" data-dc-script data-props="{"$preview":{"width":1440,"height":900},"s":{"editor":null,"tsType":"object"},"mobile":{"editor":null,"tsType":"boolean"}}">
|
<script type="text/x-dc" data-dc-script data-props="{"$preview":{"width":1440,"height":900},"s":{"editor":null,"tsType":"object"},"mobile":{"editor":null,"tsType":"boolean"}}">
|
||||||
class Component extends DCLogic {
|
class Component extends DCLogic {
|
||||||
@@ -61,9 +61,9 @@ class Component extends DCLogic {
|
|||||||
const Slug = (lab, o) => {
|
const Slug = (lab, o) => {
|
||||||
const mode = o.mode || 'empty'
|
const mode = o.mode || 'empty'
|
||||||
if (mode === 'saved') return F(lab, o.value, { mono: true, dis: !!o.locked, help: o.locked ? o.lockHelp : o.help2, suffix: o.locked && I('lock', 14, 'margin-right:10px;color:var(--ui-text-muted)') })
|
if (mode === 'saved') return F(lab, o.value, { mono: true, dis: !!o.locked, help: o.locked ? o.lockHelp : o.help2, suffix: o.locked && I('lock', 14, 'margin-right:10px;color:var(--ui-text-muted)') })
|
||||||
if (mode === 'empty') return F(lab, 'Generated from the name', { val: false, dis: true, mono: true, help: o.help })
|
if (mode === 'empty') return F(lab, o.emptyText || 'Generated from the name', { val: false, dis: true, mono: true, help: o.help })
|
||||||
if (mode === 'auto') return F(lab, o.value, { mono: true, dis: true, help: o.help, suffix: S('display:flex;align-items:center;gap:6px;padding:0 4px', S('height:20px;padding:0 6px;display:inline-flex;align-items:center;border:1px dashed var(--ui-border-accented);font:500 11px \'IBM Plex Mono\',monospace;color:var(--ui-text-toned)', 'Auto'), S('width:' + (m ? 44 : 28) + 'px;height:' + (m ? 44 : 28) + 'px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-highlighted)', I('pencil', 14))) })
|
if (mode === 'auto') return F(lab, o.value, { mono: true, dis: true, help: o.help, suffix: S('display:flex;align-items:center;gap:6px;padding:0 4px', S('height:20px;padding:0 6px;display:inline-flex;align-items:center;border:1px dashed var(--ui-border-accented);font:500 11px \'IBM Plex Mono\',monospace;color:var(--ui-text-toned)', 'Auto'), S('width:' + (m ? 44 : 28) + 'px;height:' + (m ? 44 : 28) + 'px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-highlighted)', I('pencil', 14))) })
|
||||||
return D('display:flex;flex-direction:column;gap:6px', F(lab, o.value, { mono: true, focus: !o.err, err: o.err, help: o.err ? null : o.help2 || o.help }), o.suggest && D('display:flex;align-items:center;gap:8px;flex-wrap:wrap', B('Use ' + o.suggest, { h: m ? 44 : 28, fs: 12, px: 10, icon: 'wand-sparkles', z: 12 })), mode === 'manual' && Link('Reset to match the name', 'rotate-ccw'))
|
return D('display:flex;flex-direction:column;gap:6px', F(lab, o.value, { mono: true, focus: !o.err, err: o.err, help: o.err ? null : o.help2 || o.help }), o.suggest && D('display:flex;align-items:center;gap:8px;flex-wrap:wrap', B('Use ' + o.suggest, { h: m ? 44 : 28, fs: 12, px: 10, icon: 'wand-sparkles', z: 12 })), mode === 'manual' && Link(o.reset || 'Reset to match the name', 'rotate-ccw'))
|
||||||
}
|
}
|
||||||
const Seg = (opts, on) => D('display:flex;border:1px solid var(--ui-border-accented);width:100%', ...opts.map(([k, t]) => S('flex:1;height:' + (m ? 44 : 32) + 'px;display:flex;align-items:center;justify-content:center;font-weight:' + (on === k ? 500 : 400) + ';background:' + (on === k ? 'var(--rc-sand)' : 'var(--ui-bg)') + ';color:' + (on === k ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)') + ';font-size:' + (m ? 14 : 13) + 'px', t)))
|
const Seg = (opts, on) => D('display:flex;border:1px solid var(--ui-border-accented);width:100%', ...opts.map(([k, t]) => S('flex:1;height:' + (m ? 44 : 32) + 'px;display:flex;align-items:center;justify-content:center;font-weight:' + (on === k ? 500 : 400) + ';background:' + (on === k ? 'var(--rc-sand)' : 'var(--ui-bg)') + ';color:' + (on === k ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)') + ';font-size:' + (m ? 14 : 13) + 'px', t)))
|
||||||
const Sw = (on, lab, help, dis) => D('display:flex;gap:12px;align-items:flex-start;opacity:' + (dis ? 0.6 : 1), S('width:32px;height:18px;margin-top:2px;padding:2px;display:flex;flex-shrink:0;justify-content:' + (on ? 'flex-end' : 'flex-start') + ';background:' + (on ? 'var(--ui-primary)' : 'var(--ui-bg-accented)'), S('width:14px;height:14px;background:var(--ui-bg)')), D('display:flex;flex-direction:column;gap:2px', S('font-weight:500;color:var(--ui-text-highlighted)', lab), help && S('color:var(--ui-text-muted);font-size:12px;line-height:18px;text-wrap:pretty', help)))
|
const Sw = (on, lab, help, dis) => D('display:flex;gap:12px;align-items:flex-start;opacity:' + (dis ? 0.6 : 1), S('width:32px;height:18px;margin-top:2px;padding:2px;display:flex;flex-shrink:0;justify-content:' + (on ? 'flex-end' : 'flex-start') + ';background:' + (on ? 'var(--ui-primary)' : 'var(--ui-bg-accented)'), S('width:14px;height:14px;background:var(--ui-bg)')), D('display:flex;flex-direction:column;gap:2px', S('font-weight:500;color:var(--ui-text-highlighted)', lab), help && S('color:var(--ui-text-muted);font-size:12px;line-height:18px;text-wrap:pretty', help)))
|
||||||
@@ -78,17 +78,17 @@ class Component extends DCLogic {
|
|||||||
D('padding:20px 24px 0;display:flex;align-items:flex-start;gap:12px', D('flex:1;display:flex;flex-direction:column;gap:4px', S('font:600 16px/24px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', o.title), o.desc && S('color:var(--ui-text-muted);text-wrap:pretty', o.desc)), o.close && S('width:' + (m ? 44 : 28) + 'px;height:' + (m ? 44 : 28) + 'px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)', I('x'))),
|
D('padding:20px 24px 0;display:flex;align-items:flex-start;gap:12px', D('flex:1;display:flex;flex-direction:column;gap:4px', S('font:600 16px/24px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', o.title), o.desc && S('color:var(--ui-text-muted);text-wrap:pretty', o.desc)), o.close && S('width:' + (m ? 44 : 28) + 'px;height:' + (m ? 44 : 28) + 'px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)', I('x'))),
|
||||||
D('padding:20px 24px 0;display:flex;flex-direction:column;gap:16px;min-height:0;overflow:hidden', body),
|
D('padding:20px 24px 0;display:flex;flex-direction:column;gap:16px;min-height:0;overflow:hidden', body),
|
||||||
D('padding:20px 24px;display:flex;flex-direction:' + (m ? 'column-reverse' : 'row') + ';justify-content:flex-end;gap:8px;flex-shrink:0', o.cancel && B(o.cancel, { dis: o.loading }), o.confirm && S('height:' + bh + 'px;padding:0 12px;display:flex;align-items:center;justify-content:center;gap:6px;background:' + (o.tone === 'danger' ? 'var(--ui-error)' : 'var(--ui-primary)') + ';color:var(--ui-bg);font-weight:500;white-space:nowrap;opacity:' + (o.cdis ? 0.4 : 1), o.loading && I('loader-circle'), o.confirm))))
|
D('padding:20px 24px;display:flex;flex-direction:' + (m ? 'column-reverse' : 'row') + ';justify-content:flex-end;gap:8px;flex-shrink:0', o.cancel && B(o.cancel, { dis: o.loading }), o.confirm && S('height:' + bh + 'px;padding:0 12px;display:flex;align-items:center;justify-content:center;gap:6px;background:' + (o.tone === 'danger' ? 'var(--ui-error)' : 'var(--ui-primary)') + ';color:var(--ui-bg);font-weight:500;white-space:nowrap;opacity:' + (o.cdis ? 0.4 : 1), o.loading && I('loader-circle'), o.confirm))))
|
||||||
const Panel = (o, body, pv) => D('position:absolute;inset:0;z-index:40;display:flex;justify-content:' + (m ? 'center' : 'flex-end') + ';align-items:' + (m ? 'flex-end' : 'stretch') + ';background:var(--rc-overlay)',
|
const Panel = (o, body, ...pv) => D('position:absolute;inset:0;z-index:' + (o.z || 40) + ';display:flex;justify-content:' + (m ? 'center' : 'flex-end') + ';align-items:' + (m ? 'flex-end' : 'stretch') + ';background:var(--rc-overlay)',
|
||||||
D('width:' + (m ? '100%' : '480px') + ';height:' + (m ? '92%' : '100%') + ';background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column', m && D('display:flex;justify-content:center;padding-top:8px;flex-shrink:0', S('display:block;width:36px;height:4px;background:var(--ui-border-accented)')),
|
D('width:' + (m ? '100%' : (o.w || 480) + 'px') + ';height:' + (m ? '92%' : '100%') + ';background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column', m && D('display:flex;justify-content:center;padding-top:8px;flex-shrink:0', S('display:block;width:36px;height:4px;background:var(--ui-border-accented)')),
|
||||||
D('flex-shrink:0;display:flex;align-items:center;gap:12px;padding:' + (m ? '4px 6px 4px 16px' : '0 12px 0 20px') + ';height:56px;border-bottom:1px solid var(--ui-border)', S('flex:1;min-width:0;font:600 16px/24px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', o.title), S('width:' + (m ? 44 : 28) + 'px;height:' + (m ? 44 : 28) + 'px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)', I('x'))),
|
D('flex-shrink:0;display:flex;align-items:center;gap:12px;padding:' + (m ? '4px 6px 4px 16px' : '0 12px 0 20px') + ';height:56px;border-bottom:1px solid var(--ui-border)', S('flex:1;min-width:0;font:600 16px/24px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', o.title), S('width:' + (m ? 44 : 28) + 'px;height:' + (m ? 44 : 28) + 'px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)', I('x'))),
|
||||||
D('flex:1;min-height:0;overflow:hidden;padding:' + (m ? 16 : 20) + 'px;display:flex;flex-direction:column;gap:18px', o.notice && Alert('error', 'circle-x', o.notice), body, pv),
|
D('flex:1;min-height:0;overflow:hidden;padding:' + (m ? 16 : 20) + 'px;display:flex;flex-direction:column;gap:18px', o.notice && Alert('error', 'circle-x', o.notice), body, ...pv),
|
||||||
D('flex-shrink:0;padding:' + (m ? '12px 16px' : '12px 20px') + ';border-top:1px solid var(--ui-border);display:flex;flex-direction:' + (m ? 'column-reverse' : 'row') + ';justify-content:' + (o.ro ? 'flex-end' : 'space-between') + ';gap:8px', B(o.ro ? 'Close' : 'Cancel', { dis: o.saving }), !o.ro && S('height:' + bh + 'px;padding:0 16px;display:flex;align-items:center;justify-content:center;gap:6px;background:var(--ui-primary);color:var(--ui-bg);font-weight:500;white-space:nowrap;opacity:' + (o.cdis ? 0.4 : 1), o.saving && I('loader-circle'), o.confirm))))
|
D('flex-shrink:0;padding:' + (m ? '12px 16px' : '12px 20px') + ';border-top:1px solid var(--ui-border);display:flex;flex-direction:' + (m ? 'column-reverse' : 'row') + ';justify-content:' + (o.ro ? 'flex-end' : 'space-between') + ';gap:8px', B(o.ro ? 'Close' : 'Cancel', { dis: o.saving }), !o.ro && S('height:' + bh + 'px;padding:0 16px;display:flex;align-items:center;justify-content:center;gap:6px;background:var(--ui-primary);color:var(--ui-bg);font-weight:500;white-space:nowrap;opacity:' + (o.cdis ? 0.4 : 1), o.saving && I('loader-circle'), o.confirm))))
|
||||||
const Toasts = () => D('position:absolute;z-index:60;display:flex;flex-direction:column;gap:8px;' + (m ? 'left:16px;right:16px;bottom:16px' : 'right:24px;bottom:24px;width:356px'), ...s.toasts.map(t => D('display:flex;gap:10px;padding:12px 14px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop)', S('margin-top:2px;display:flex;color:var(--ui-' + t[0] + ')', I(t[1])), D('flex:1;display:flex;flex-direction:column;gap:6px', D('display:flex;flex-direction:column;gap:2px', S('font-weight:600;color:var(--ui-text-highlighted)', t[2]), S('color:var(--ui-text-muted)', t[3])), t[4] && B(t[4], { icon: 'rotate-cw', z: 14, h: m ? 44 : 28, fs: 12, px: 10 })), I('x'))))
|
const Toasts = () => D('position:absolute;z-index:60;display:flex;flex-direction:column;gap:8px;' + (m ? 'left:16px;right:16px;bottom:16px' : 'right:24px;bottom:24px;width:356px'), ...s.toasts.map(t => D('display:flex;gap:10px;padding:12px 14px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop)', S('margin-top:2px;display:flex;color:var(--ui-' + t[0] + ')', I(t[1])), D('flex:1;display:flex;flex-direction:column;gap:6px', D('display:flex;flex-direction:column;gap:2px', S('font-weight:600;color:var(--ui-text-highlighted)', t[2]), S('color:var(--ui-text-muted)', t[3])), t[4] && B(t[4], { icon: 'rotate-cw', z: 14, h: m ? 44 : 28, fs: 12, px: 10 })), I('x'))))
|
||||||
// media picker (shared by site image and author avatar)
|
// media picker (shared by site image and author avatar)
|
||||||
const IMGS = ['north-wall.jpg', 'kitchen-after.webp', 'kitchen-before.webp', 'logo-renew.svg', 'window-frame.jpg', 'lime-plaster.jpg', 'floorboards.jpg', 'bathroom-tiles.png']
|
const IMGS = ['north-wall.jpg', 'kitchen-after.webp', 'kitchen-before.webp', 'logo-renew.svg', 'window-frame.jpg', 'lime-plaster.jpg', 'floorboards.jpg', 'bathroom-tiles.png']
|
||||||
const Picker = pk => {
|
const Picker = pk => {
|
||||||
const cols = m ? 3 : 4
|
const cols = m ? 3 : 4
|
||||||
return Shell({ title: pk.title, desc: pk.desc, close: true, w: 640, top: 40, z: 55, cancel: 'Cancel', confirm: 'Use image', cdis: pk.sel == null }, D('display:flex;flex-direction:column;gap:12px',
|
return Shell({ title: pk.title, desc: pk.desc, close: true, w: 640, top: 40, z: pk.z || 55, cancel: 'Cancel', confirm: 'Use image', cdis: pk.sel == null }, D('display:flex;flex-direction:column;gap:12px',
|
||||||
D('display:flex;gap:20px;border-bottom:1px solid var(--ui-border)', ...['Upload', 'Library'].map(t => S('height:' + (m ? 44 : 36) + 'px;display:flex;align-items:center;gap:6px;border-bottom:2px solid ' + (t === 'Library' ? 'var(--ui-primary)' : 'transparent') + ';margin-bottom:-1px;font-weight:' + (t === 'Library' ? 500 : 400) + ';color:' + (t === 'Library' ? 'var(--ui-text-highlighted)' : 'var(--ui-text-toned)'), I(t === 'Upload' ? 'upload' : 'images', 14), t))),
|
D('display:flex;gap:20px;border-bottom:1px solid var(--ui-border)', ...['Upload', 'Library'].map(t => S('height:' + (m ? 44 : 36) + 'px;display:flex;align-items:center;gap:6px;border-bottom:2px solid ' + (t === 'Library' ? 'var(--ui-primary)' : 'transparent') + ';margin-bottom:-1px;font-weight:' + (t === 'Library' ? 500 : 400) + ';color:' + (t === 'Library' ? 'var(--ui-text-highlighted)' : 'var(--ui-text-toned)'), I(t === 'Upload' ? 'upload' : 'images', 14), t))),
|
||||||
D('height:' + ih + 'px;padding:0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);color:var(--ui-text-muted);font-size:' + fs + 'px', I('search', 14), 'Search by filename'),
|
D('height:' + ih + 'px;padding:0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);color:var(--ui-text-muted);font-size:' + fs + 'px', I('search', 14), 'Search by filename'),
|
||||||
D('display:grid;grid-template-columns:repeat(' + cols + ',minmax(0,1fr));gap:10px', ...IMGS.slice(0, m ? 6 : 8).map((n, i) => D('display:flex;flex-direction:column;gap:4px', S('position:relative;display:flex;align-items:center;justify-content:center;aspect-ratio:' + (pk.sq ? '1/1' : '4/3') + ';background:repeating-linear-gradient(135deg, var(--ui-border) 0 1px, transparent 1px 12px), ' + TONE[i % 3] + ';color:var(--ui-text-muted);border:1px solid ' + (pk.sel === i ? 'var(--ui-primary)' : 'var(--ui-border)') + ';' + (pk.sel === i ? 'box-shadow:inset 0 0 0 1px var(--ui-primary);' : ''), I('image', 18), pk.sel === i && S('position:absolute;top:4px;right:4px;width:20px;height:20px;display:flex;align-items:center;justify-content:center;background:var(--ui-primary);color:var(--ui-bg)', I('check', 14))), S('font-size:12px;color:var(--ui-text-toned);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', n)))),
|
D('display:grid;grid-template-columns:repeat(' + cols + ',minmax(0,1fr));gap:10px', ...IMGS.slice(0, m ? 6 : 8).map((n, i) => D('display:flex;flex-direction:column;gap:4px', S('position:relative;display:flex;align-items:center;justify-content:center;aspect-ratio:' + (pk.sq ? '1/1' : '4/3') + ';background:repeating-linear-gradient(135deg, var(--ui-border) 0 1px, transparent 1px 12px), ' + TONE[i % 3] + ';color:var(--ui-text-muted);border:1px solid ' + (pk.sel === i ? 'var(--ui-primary)' : 'var(--ui-border)') + ';' + (pk.sel === i ? 'box-shadow:inset 0 0 0 1px var(--ui-primary);' : ''), I('image', 18), pk.sel === i && S('position:absolute;top:4px;right:4px;width:20px;height:20px;display:flex;align-items:center;justify-content:center;background:var(--ui-primary);color:var(--ui-bg)', I('check', 14))), S('font-size:12px;color:var(--ui-text-toned);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', n)))),
|
||||||
@@ -108,11 +108,11 @@ class Component extends DCLogic {
|
|||||||
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)')))))
|
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)')))))
|
||||||
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 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' }
|
const shellBase = { kind: 'slot', display: 'Marta Alves' }
|
||||||
let main = null, shell = s.shell, edMeta = null
|
let main = null, shell = s.shell, edMeta = null, edSeo = null
|
||||||
const ov = []
|
const ov = []
|
||||||
// ---------- Sites
|
// ---------- Sites
|
||||||
const siteForm = () => {
|
const siteForm = () => {
|
||||||
const p = s.panel, f = { name: '', key: '', keyMode: 'empty', url: '', img: null, robots: 'Index', loc: '', def: false, ...p.f }, er = p.err || {}, add = p.mode === 'add'
|
const p = s.panel, f = { name: '', key: '', keyMode: 'empty', url: '', img: null, sname: p.mode === 'add' ? '' : (p.f || {}).name, tpl: '%title% | %site%', ddesc: '', robots: 'Index', loc: '', def: false, ...p.f }, er = p.err || {}, add = p.mode === 'add'
|
||||||
const isDef = !add && SITES[p.i || 0].def
|
const isDef = !add && SITES[p.i || 0].def
|
||||||
const prev = (f.url && !er.url ? f.url : 'https://journal.example.com') + '/how-we-price-a-bathroom-renovation'
|
const prev = (f.url && !er.url ? f.url : 'https://journal.example.com') + '/how-we-price-a-bathroom-renovation'
|
||||||
return Panel({ title: add ? 'Add site' : 'Edit ' + f.name, notice: p.notice, saving: p.saving, confirm: p.saving ? 'Saving…' : add ? 'Add site' : 'Save changes' },
|
return Panel({ title: add ? 'Add site' : 'Edit ' + f.name, notice: p.notice, saving: p.saving, confirm: p.saving ? 'Saving…' : add ? 'Add site' : 'Save changes' },
|
||||||
@@ -120,10 +120,14 @@ class Component extends DCLogic {
|
|||||||
Slug('Key', { mode: f.keyMode, value: f.key, err: er.key, suggest: er.keySuggest, locked: !add, lockHelp: 'Used in API paths, so it can\'t change.', help: 'Lowercase letters, numbers and hyphens. Used in API paths. It can\'t be changed after the site is added.', help2: 'Lowercase letters, numbers and hyphens. Used in API paths. It can\'t be changed after the site is added.' }),
|
Slug('Key', { mode: f.keyMode, value: f.key, err: er.key, suggest: er.keySuggest, locked: !add, lockHelp: 'Used in API paths, so it can\'t change.', help: 'Lowercase letters, numbers and hyphens. Used in API paths. It can\'t be changed after the site is added.', help2: 'Lowercase letters, numbers and hyphens. Used in API paths. It can\'t be changed after the site is added.' }),
|
||||||
F('Base URL', f.url || 'https://blog.example.com', { val: !!f.url, mono: true, focus: p.focus === 'url', err: er.url, help: er.url ? null : 'The public address of the frontend. Use https and no path.' }),
|
F('Base URL', f.url || 'https://blog.example.com', { val: !!f.url, mono: true, focus: p.focus === 'url', err: er.url, help: er.url ? null : 'The public address of the frontend. Use https and no path.' }),
|
||||||
Pick(f.img, 'Default social image', 'Used when an article has no image of its own.'),
|
Pick(f.img, 'Default social image', 'Used when an article has no image of its own.'),
|
||||||
|
F('Site name', f.sname || 'e.g. Renew', { val: !!f.sname, focus: p.focus === 'sname', help: 'Shown in titles through the template, and sent as the Open Graph site name.' }),
|
||||||
|
D('display:flex;flex-direction:column;gap:6px', F('Title template', f.tpl, { mono: true, focus: p.focus === 'tpl', err: er.tpl, help: er.tpl ? null : 'Use %title% for the article title and %site% for the site name.' }), !er.tpl && D('display:flex;gap:6px;align-items:center;padding:8px 10px;background:var(--ui-bg-elevated);min-width:0', S('flex-shrink:0;font-size:12px;color:var(--ui-text-muted)', 'Example'), S('flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--ui-text-highlighted)', f.tpl.replace('%title%', 'How we price a bathroom renovation').replace('%site%', f.sname || 'Renew')))),
|
||||||
|
F('Default description', f.ddesc || 'One or two sentences about the site.', { opt: true, area: true, val: !!f.ddesc, count: [...f.ddesc].length + ' / 320', help: 'Used when an article has no description, excerpt or body text.' }),
|
||||||
D('display:flex;flex-direction:column;gap:6px', S('font-weight:500;color:var(--ui-text-highlighted)', 'Default robots policy'), Seg([['Index', 'Index'], ['Noindex', 'Noindex']], f.robots), S('color:var(--ui-text-muted);font-size:12px;line-height:18px', f.robots === 'Index' ? 'Search engines may list this site\'s pages.' : 'Asks search engines not to list this site\'s pages.')),
|
D('display:flex;flex-direction:column;gap:6px', S('font-weight:500;color:var(--ui-text-highlighted)', 'Default robots policy'), Seg([['Index', 'Index'], ['Noindex', 'Noindex']], f.robots), S('color:var(--ui-text-muted);font-size:12px;line-height:18px', f.robots === 'Index' ? 'Search engines may list this site\'s pages.' : 'Asks search engines not to list this site\'s pages.')),
|
||||||
F('Locale', f.loc || 'e.g. en or fr-CH', { opt: true, val: !!f.loc, mono: true, err: er.loc, help: er.loc ? null : 'A language tag. Leave empty if the site has no single language.' }),
|
F('Locale', f.loc || 'e.g. en or fr-CH', { opt: true, val: !!f.loc, mono: true, err: er.loc, help: er.loc ? null : 'A language tag. Leave empty if the site has no single language.' }),
|
||||||
Sw(isDef || f.def, isDef ? 'Default site' : 'Make this the default site', isDef ? 'New articles start on the default site. To change it, make another site the default.' : 'New articles start here. The current default is Renew.', isDef)),
|
Sw(isDef || f.def, isDef ? 'Default site' : 'Make this the default site', isDef ? 'New articles start on the default site. To change it, make another site the default.' : 'New articles start here. The current default is Renew.', isDef)),
|
||||||
D('display:flex;flex-direction:column;gap:4px;padding:12px;background:var(--ui-bg-elevated)', S('font-size:12px;color:var(--ui-text-muted)', 'Canonical URL preview'), S('font:400 12px/18px \'IBM Plex Mono\',monospace;color:var(--ui-text-highlighted);word-break:break-all', prev), er.url && S('font-size:12px;color:var(--ui-text-muted)', 'Shown when the base URL is valid.')))
|
D('display:flex;flex-direction:column;gap:4px;padding:12px;background:var(--ui-bg-elevated)', S('font-size:12px;color:var(--ui-text-muted)', 'Canonical URL preview'), S('font:400 12px/18px \'IBM Plex Mono\',monospace;color:var(--ui-text-highlighted);word-break:break-all', prev), er.url && S('font-size:12px;color:var(--ui-text-muted)', 'Shown when the base URL is valid.')),
|
||||||
|
D('display:flex;flex-direction:column;gap:6px;padding:12px;background:var(--ui-bg-elevated)', S('font-size:12px;color:var(--ui-text-muted)', '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.']].map(([a, b]) => D('display:flex;gap:8px', S('width:84px;flex-shrink:0;font-weight:500;color:var(--ui-text-highlighted)', a), S('color:var(--ui-text-toned);text-wrap:pretty', b)))))
|
||||||
}
|
}
|
||||||
const sitesPage = () => {
|
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'))
|
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'))
|
||||||
@@ -275,6 +279,85 @@ class Component extends DCLogic {
|
|||||||
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 }),
|
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 }),
|
||||||
x.me && D('display:flex;gap:10px;align-items:flex-start', S('width:20px;height:20px;flex-shrink:0;margin-top:1px;display:flex;align-items:center;justify-content:center;border:1px solid var(--ui-primary);background:var(--ui-primary);color:var(--ui-bg)', I('check', 14)), D('display:flex;flex-direction:column', S('font-weight:500;color:var(--ui-text-highlighted)', 'This is me'), S('color:var(--ui-text-muted);font-size:12px', 'Links the author to marta@renew.example.')))))
|
x.me && D('display:flex;gap:10px;align-items:flex-start', S('width:20px;height:20px;flex-shrink:0;margin-top:1px;display:flex;align-items:center;justify-content:center;border:1px solid var(--ui-primary);background:var(--ui-primary);color:var(--ui-bg)', I('check', 14)), D('display:flex;flex-direction:column', S('font-weight:500;color:var(--ui-text-highlighted)', 'This is me'), S('color:var(--ui-text-muted);font-size:12px', 'Links the author to marta@renew.example.')))))
|
||||||
}
|
}
|
||||||
|
// ---------- Editor: search and sharing
|
||||||
|
const ATT = 'How we price a bathroom renovation', ASLUG = 'how-we-price-a-bathroom-renovation', AD1 = 'A bathroom renovation is priced by room size, plumbing changes and finish level.'
|
||||||
|
const SUG = {
|
||||||
|
title_long: ['The meta title is 74 characters. Search engines may cut it off after about 60.', 'meta title'],
|
||||||
|
title_empty: ['No meta title yet. The article title is used instead.', 'meta title'],
|
||||||
|
desc_short: ['The description is 41 characters. About 70 to 160 reads best in results.', 'meta description'],
|
||||||
|
desc_long: ['The description is 212 characters. Search engines may cut it off after about 160.', 'meta description'],
|
||||||
|
cover_alt: ['The cover image has no alt text. Add a short description of it.', 'cover image'],
|
||||||
|
cover_none: ['No cover image. The site\'s social image is used.', 'cover image'],
|
||||||
|
slug_long: ['The slug is 86 characters. Shorter addresses are easier to share.', 'URL slug'],
|
||||||
|
no_au: ['This article has no authors, so the structured data has no author.', 'authors'],
|
||||||
|
no_h: ['The body has no headings. H2 headings help readers scan the article.', 'body'],
|
||||||
|
body_alt: ['2 images in the body have no alt text.', 'body'],
|
||||||
|
short: ['The article is under 300 words. That is fine for a short note.', 'body']
|
||||||
|
}
|
||||||
|
const Chip = t => S('height:20px;padding:0 6px;align-self:flex-start;display:inline-flex;align-items:center;gap:4px;border:1px dashed var(--ui-border-accented);font:500 11px/16px \'IBM Plex Mono\',monospace;color:var(--ui-text-toned);white-space:nowrap', I('corner-down-right', 10), t)
|
||||||
|
const Ct = (n, lo, hi) => S('font:400 12px \'IBM Plex Mono\',monospace;color:' + (n && (n < lo || n > hi) ? 'var(--ui-warning)' : 'var(--ui-text-muted)'), n + ' / ' + hi)
|
||||||
|
const FW = (lab, val, o = {}) => D('display:flex;flex-direction:column;gap:6px;min-width:0', D('display:flex;justify-content:space-between;align-items:center;gap:8px', S('font-weight:500;color:var(--ui-text-highlighted)', lab, !o.req && S('font-weight:400;color:var(--ui-text-muted)', ' (optional)')), o.ct), F(null, val, o), o.fb && Chip(o.fb))
|
||||||
|
const Dis = (t, o = {}) => D('display:flex;flex-direction:column;gap:12px;padding:12px;border:1px solid var(--ui-border)', D('display:flex;align-items:center;gap:8px;min-height:' + (m ? 44 : 24) + 'px;color:var(--ui-text-highlighted)', I(o.open ? 'chevron-down' : 'chevron-right', 14), S('font-weight:600', t), o.tag && Note(o.tag), S('flex:1;text-align:right;font-size:12px;color:var(--ui-text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', o.sum)), ...(o.open ? o.kids : []))
|
||||||
|
const SugRow = (c, o = {}) => D('display:flex;align-items:flex-start;gap:8px', S('display:flex;padding-top:2px;color:var(--ui-warning)', I('lightbulb', 14)), D('flex:1;min-width:0;display:flex;flex-direction:column;gap:2px', S('text-wrap:pretty;color:var(--ui-text-highlighted)', SUG[c][0]), Link('Go to ' + SUG[c][1], 'arrow-right')), !o.compact && S('width:' + (m ? 44 : 24) + 'px;height:' + (m ? 44 : 24) + 'px;flex-shrink:0;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)', I('x', 14)))
|
||||||
|
const Sugs = (codes, st, o = {}) => {
|
||||||
|
const box = (...c) => D('display:flex;flex-direction:column;gap:12px;padding:12px;background:var(--ui-bg-elevated)', ...c)
|
||||||
|
if (st === 'loading') return box(D('display:flex;align-items:center;gap:8px;color:var(--ui-text-toned)', I('loader-circle', 14), 'Checking this article…'), ...[70, 90, 55].map(w => S('display:block;height:12px;width:' + w + '%;background:var(--ui-bg-accented)')))
|
||||||
|
if (!codes.length) return box(D('display:flex;align-items:flex-start;gap:8px', S('display:flex;padding-top:2px;color:var(--ui-success)', I('circle-check', 14)), D('display:flex;flex-direction:column;gap:2px', S('font-weight:600;color:var(--ui-text-highlighted)', 'All good'), S('color:var(--ui-text-muted)', o.dismissed ? o.dismissed + ' dismissed suggestions are hidden.' : 'Nothing to suggest for this article.')), o.dismissed && Link('Show dismissed')))
|
||||||
|
const shown = o.compact ? codes.slice(0, 3) : codes
|
||||||
|
return box(...shown.map(c => SugRow(c, o)), o.compact && codes.length > 3 && S('color:var(--ui-text-muted);font-size:12px', '+' + (codes.length - 3) + ' more in Search and sharing'), !o.compact && S('color:var(--ui-text-muted);font-size:12px;line-height:18px;text-wrap:pretty', 'Suggestions never block saving or publishing. Dismiss one and it stays hidden for this article.' ))
|
||||||
|
}
|
||||||
|
const SEOSIDE = (k) => {
|
||||||
|
const codes = k.codes || [], st = k.state || 'ok'
|
||||||
|
return D('display:flex;flex-direction:column;gap:12px',
|
||||||
|
D('display:flex;flex-direction:column;gap:4px;padding:8px;background:var(--ui-bg-elevated)', S('font-size:12px;color:var(--ui-text-muted)', 'Address'), S('font:400 12px/18px \'IBM Plex Mono\',monospace;color:var(--ui-text-highlighted);word-break:break-all', host(SITES[k.site || 0].url) + '/' + (k.slug || ASLUG))),
|
||||||
|
k.hidden && D('display:flex;gap:8px;color:var(--ui-warning)', S('display:flex;padding-top:2px', I('eye-off', 14)), S('color:var(--ui-text-toned);text-wrap:pretty', 'Hidden from search engines. This only sets a flag in the API.')),
|
||||||
|
Sugs(codes, st, { compact: true, dismissed: k.dismissed }),
|
||||||
|
D('display:flex;flex-direction:column;gap:8px', B('Edit search and sharing', { icon: 'search', h: m ? 44 : 32 }), B('What the frontend gets', { icon: 'braces', h: m ? 44 : 32 })))
|
||||||
|
}
|
||||||
|
const trunc = (t, n) => [...t].length > n ? [...t].slice(0, n - 1).join('').trimEnd() + '…' : t
|
||||||
|
const SERP = (p, site) => {
|
||||||
|
const dev = p.dev || 'desktop', tt = trunc((p.mt || ATT) + ' | ' + site.n, dev === 'desktop' ? 60 : 70)
|
||||||
|
const dd = p.md || p.ex || AD1, dt = trunc(dd, dev === 'desktop' ? 160 : 120)
|
||||||
|
const fb = [!p.mt && 'Using article title', !p.md && p.ex && 'Using excerpt', !p.md && !p.ex && 'Using first sentence of the body'].filter(Boolean)
|
||||||
|
return D('display:flex;flex-direction:column;gap:8px', D('display:flex;justify-content:space-between;align-items:center;gap:8px', S('font-weight:600;color:var(--ui-text-highlighted)', 'Search result'), D('display:flex;border:1px solid var(--ui-border-accented);width:150px', ...[['desktop', 'Desktop'], ['mobile', 'Mobile']].map(([k, t]) => S('flex:1;height:' + (m ? 44 : 24) + 'px;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:' + (dev === k ? 500 : 400) + ';background:' + (dev === k ? 'var(--rc-sand)' : 'var(--ui-bg)') + ';color:' + (dev === k ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)'), t)))),
|
||||||
|
D('padding:14px;border:1px solid var(--ui-border);background:var(--ui-bg);display:flex;flex-direction:column;gap:4px;' + (dev === 'mobile' && !m ? 'width:280px;' : ''), D('display:flex;align-items:center;gap:8px;min-width:0', S('width:22px;height:22px;flex-shrink:0;display:flex;align-items:center;justify-content:center;border-radius:50%;background:var(--ui-bg-elevated);font:600 11px/1 \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', site.n[0]), D('display:flex;flex-direction:column;min-width:0', S('font-size:12px;line-height:16px;color:var(--ui-text-highlighted)', site.n), S('font-size:11px;line-height:14px;color:var(--ui-text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', host(site.url) + ' › ' + (p.slug || ASLUG).slice(0, 36)))), S('font:400 ' + (dev === 'desktop' ? 18 : 16) + 'px/1.3 \'IBM Plex Sans\',sans-serif;color:var(--ui-info);margin-top:4px;word-break:break-word', tt), S('font-size:13px;line-height:19px;color:var(--ui-text-toned);word-break:break-word', dt)),
|
||||||
|
fb.length ? D('display:flex;gap:6px;flex-wrap:wrap', ...fb.map(Chip)) : null, (p.mt || ATT).length + site.n.length + 3 > (dev === 'desktop' ? 60 : 70) ? S('font-size:12px;color:var(--ui-text-muted)', 'Cut off here, the way a search engine would.') : null)
|
||||||
|
}
|
||||||
|
const SOC = (p, site) => {
|
||||||
|
const sx = p.soc || {}, hasImg = !!(sx.img || p.cover), t = sx.t || p.mt || ATT, d = sx.d || p.md || p.ex || AD1
|
||||||
|
return D('display:flex;flex-direction:column;gap:8px', S('font-weight:600;color:var(--ui-text-highlighted)', 'Social card'),
|
||||||
|
D('border:1px solid var(--ui-border);background:var(--ui-bg)', S('display:flex;align-items:center;justify-content:center;aspect-ratio:1.91/1;background:repeating-linear-gradient(135deg, var(--ui-border) 0 1px, transparent 1px 12px), var(--rc-sand);color:var(--ui-text-muted);font:400 11px \'IBM Plex Mono\',monospace', hasImg ? (sx.img || 'north-wall.jpg') : (site.img || 'site social image')), D('padding:10px 12px;display:flex;flex-direction:column;gap:2px;border-top:1px solid var(--ui-border)', S('font-size:11px;line-height:16px;text-transform:uppercase;color:var(--ui-text-muted)', host(site.url)), S('font-weight:600;color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', t), S('font-size:12px;line-height:18px;color:var(--ui-text-toned);overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical', d))),
|
||||||
|
D('display:flex;gap:6px;flex-wrap:wrap', ...[!sx.t && 'Using meta title', !sx.d && 'Using meta description', !sx.img && (p.cover ? 'Using cover image' : 'Using site social image')].filter(Boolean).map(Chip)))
|
||||||
|
}
|
||||||
|
const seoPanel = () => {
|
||||||
|
const p = s.seo.panel, site = SITES[p.site || 0], dis = p.state === 'saving', er = p.err || {}
|
||||||
|
const sl = p.slug || ASLUG, sm = p.slugMode || 'auto'
|
||||||
|
const cv = p.cover, soc = p.soc || {}
|
||||||
|
const left = D('display:flex;flex-direction:column;gap:18px;margin-top:' + (p.scrollY ? -p.scrollY + 'px' : '0'),
|
||||||
|
D('display:flex;flex-direction:column;gap:8px', Slug('URL slug', { mode: sm === 'taken' ? 'manual' : sm, value: sl, err: sm === 'taken' ? 'The slug "' + sl + '" is already used on ' + site.n + '.' : er.slug, suggest: sm === 'taken' ? sl + '-2' : null, reset: 'Reset to match the title', emptyText: 'Generated from the title', help: 'Generated from the title until you edit it. Lowercase letters, numbers and hyphens. Unique on ' + site.n + '.', help2: 'Lowercase letters, numbers and hyphens. Unique on ' + site.n + '.' }), D('display:flex;gap:6px;align-items:center;padding:8px 10px;background:var(--ui-bg-elevated);min-width:0', I('link', 12, 'color:var(--ui-text-muted)'), S('font:400 12px/18px \'IBM Plex Mono\',monospace;color:var(--ui-text-highlighted);word-break:break-all', site.url + '/' + sl))),
|
||||||
|
FW('Meta title', p.mt || ATT, { val: !!p.mt, ct: Ct([...(p.mt || '')].length, 30, 60), focus: p.focus === 'mt', dis, fb: p.mt ? null : 'Using article title', help: 'Recommended 30 to 60 characters. This is a guide, not a limit.' }),
|
||||||
|
FW('Meta description', p.md || p.ex || AD1, { val: !!p.md, area: true, ct: Ct([...(p.md || '')].length, 70, 160), focus: p.focus === 'md', dis, fb: p.md ? null : p.ex ? 'Using excerpt' : 'Using first sentence of the body', help: 'Recommended 70 to 160 characters.' }),
|
||||||
|
FW('Excerpt', p.ex || 'A short summary. It is also sent as the article teaser.', { val: !!p.ex, area: true, ct: S('font:400 12px \'IBM Plex Mono\',monospace;color:' + (p.exErr ? 'var(--ui-error)' : 'var(--ui-text-muted)'), [...(p.ex || '')].length + ' / 300'), err: p.exErr, dis, help: p.exErr ? null : 'Used as the description when there is none, and as the teaser for the frontend.' }),
|
||||||
|
D('display:flex;flex-direction:column;gap:10px', Pick(cv ? 'north-wall.jpg' : null, 'Cover image', cv ? null : 'Using the site social image' + (site.img ? ' (' + site.img + ')' : '') + '.'), cv && FW('Alt text', cv.alt || 'Describe the image for people who can\'t see it', { req: true, val: !!cv.alt, dis, help: cv.alt ? null : 'Needed before the cover counts as ready. Sent as image alt in the API.' })),
|
||||||
|
Dis('Social sharing', { open: p.social, tag: 'Optional', sum: p.social ? '' : (soc.t || soc.d || soc.img) ? 'Customised' : 'Uses the meta values', kids: [FW('Social title', soc.t || p.mt || ATT, { val: !!soc.t, ct: Ct([...(soc.t || '')].length, 0, 90), dis, fb: soc.t ? null : 'Using meta title' }), FW('Social description', soc.d || p.md || p.ex || AD1, { val: !!soc.d, area: true, dis, fb: soc.d ? null : 'Using meta description' }), Pick(soc.img || null, 'Social image', soc.img ? null : 'Using the cover image.')] }),
|
||||||
|
Dis('Advanced', { open: p.adv, sum: p.adv ? '' : p.canon ? 'Canonical URL set' : 'Canonical URL: automatic', kids: [FW('Canonical URL', p.canon || site.url + '/' + sl, { val: !!p.canon, mono: true, dis, err: er.canon, focus: p.focus === 'canon', fb: p.canon ? null : 'Using site address and slug', help: er.canon ? null : 'Only change this if the same content lives at another address.' })] }),
|
||||||
|
Sw(!!p.noindex, 'Hide from search engines', 'Sets noindex in the API. It doesn\'t change who can read the article.', dis))
|
||||||
|
const right = D('display:flex;flex-direction:column;gap:18px;min-width:0', SERP(p, site), SOC(p, site), D('display:flex;flex-direction:column;gap:8px', S('font-weight:600;color:var(--ui-text-highlighted)', 'Suggestions'), Sugs(p.codes || [], p.sugState, { dismissed: p.dismissed })), D('display:flex;gap:8px;align-items:center', B('What the frontend gets', { icon: 'braces', h: m ? 44 : 28, fs: 12, px: 10 })))
|
||||||
|
const body = m ? D('display:flex;flex-direction:column;gap:18px', Sugs(p.codes || [], p.sugState, { dismissed: p.dismissed }), left, SERP(p, site), SOC(p, site), B('What the frontend gets', { icon: 'braces' })) : D('display:grid;grid-template-columns:minmax(0,1fr) 340px;gap:28px;height:100%;overflow:hidden', D('overflow:hidden;min-height:0', left), right)
|
||||||
|
return Panel({ title: 'Search and sharing', w: 960, z: 60, notice: p.notice, saving: dis, cdis: p.noChange, confirm: dis ? 'Saving…' : 'Save changes' }, body)
|
||||||
|
}
|
||||||
|
const API = { title: ATT + ' | Renew', description: AD1, canonical_url: 'https://renew.example/' + ASLUG, robots: 'index, follow', og: { type: 'article', title: ATT + ' | Renew', description: AD1, url: 'https://renew.example/' + ASLUG, site_name: 'Renew', image: 'https://renew.example/media/north-wall.jpg', image_alt: 'A tiled bathroom wall during renovation', locale: 'en' }, twitter: { card: 'summary_large_image', title: ATT + ' | Renew', description: AD1, image: 'https://renew.example/media/north-wall.jpg', image_alt: 'A tiled bathroom wall during renovation' }, image: { url: 'https://renew.example/media/north-wall.jpg', alt: 'A tiled bathroom wall during renovation', source: 'cover_image' }, authors: [{ name: 'Inès Duarte', url: 'https://ines-duarte.example', same_as: ['https://www.linkedin.com/in/ines-duarte'] }], published_at: '2026-03-12T09:14:00Z', modified_at: '2026-10-15T11:02:00Z', fallbacks: { title: 'article_title', description: 'body_first_sentence', image: 'cover_image', canonical: 'site_base_url_and_slug' }, json_ld: { '@context': 'https://schema.org', '@type': 'Article', headline: ATT, description: AD1, image: ['https://renew.example/media/north-wall.jpg'], author: [{ '@type': 'Person', name: 'Inès Duarte', url: 'https://ines-duarte.example', sameAs: ['https://www.linkedin.com/in/ines-duarte'] }], datePublished: '2026-03-12T09:14:00Z', dateModified: '2026-10-15T11:02:00Z', mainEntityOfPage: 'https://renew.example/' + ASLUG } }
|
||||||
|
const apiDialog = () => {
|
||||||
|
const x = s.seo.api, raw = x.view === 'raw', st = x.state || 'ok'
|
||||||
|
const bar = D('display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap', D('display:flex;border:1px solid var(--ui-border-accented);width:200px', ...[['formatted', 'Formatted'], ['raw', 'Raw JSON']].map(([k, t]) => S('flex:1;height:' + bh + 'px;display:flex;align-items:center;justify-content:center;font-weight:' + ((raw ? 'raw' : 'formatted') === k ? 500 : 400) + ';background:' + ((raw ? 'raw' : 'formatted') === k ? 'var(--rc-sand)' : 'var(--ui-bg)') + ';color:' + ((raw ? 'raw' : 'formatted') === k ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)'), t))), B(x.copied ? 'Copied' : 'Copy JSON', { icon: x.copied ? 'check' : 'copy', dis: st !== 'ok' }))
|
||||||
|
const KV = (k, v, fbk) => D('display:grid;grid-template-columns:' + (m ? '1fr' : '130px minmax(0,1fr)') + ';gap:' + (m ? 2 : 12) + 'px;padding:6px 0;border-bottom:1px solid var(--ui-border-muted)', S('color:var(--ui-text-muted);font:400 12px/20px \'IBM Plex Mono\',monospace', k), D('display:flex;gap:8px;flex-wrap:wrap;align-items:center;min-width:0', S('color:var(--ui-text-highlighted);word-break:break-all;' + (m ? '' : ''), v), fbk && Chip(fbk)))
|
||||||
|
let body
|
||||||
|
if (st === 'loading') body = D('display:flex;flex-direction:column;gap:12px', ...[40, 80, 60, 70, 50, 75].map(w => S('display:block;height:14px;width:' + w + '%;background:var(--ui-bg-accented)')))
|
||||||
|
else if (st === 'error') body = Alert('error', 'circle-x', 'Couldn\'t load the SEO data', 'GET /v1/article/12/seo failed. Your article is unchanged. Try again in a moment.', 'Try again')
|
||||||
|
else if (raw) body = S('display:block;max-height:' + (m ? 440 : 400) + 'px;overflow:hidden;padding:12px;background:var(--ui-bg-elevated);white-space:pre;font:400 12px/18px \'IBM Plex Mono\',monospace;color:var(--ui-text-highlighted)', JSON.stringify(API, null, 2))
|
||||||
|
else body = D('display:flex;flex-direction:column', KV('title', API.title, 'article title + template'), KV('description', API.description, 'first sentence of the body'), KV('canonical_url', API.canonical_url, 'site address + slug'), KV('robots', API.robots, 'site default'), KV('og.type', 'article'), KV('og.image', API.image.url, 'cover image'), KV('og.image_alt', API.image.alt), KV('twitter.card', 'summary_large_image'), KV('authors', 'Inès Duarte · ines-duarte.example · 1 social link'), KV('published_at', '12 Mar 2026, 09:14'), KV('modified_at', '15 Oct 2026, 11:02'), KV('json_ld', 'Article · headline, image, author, datePublished, dateModified, mainEntityOfPage'))
|
||||||
|
return Shell({ title: 'What the frontend gets', desc: 'The SEO data returned by GET /v1/article/12/seo, read only. RenewCMS doesn\'t render pages or add meta tags: your frontend does.', close: true, w: 720, top: 40, z: 60, cancel: 'Close' }, D('display:flex;flex-direction:column;gap:12px', x.draft && D('display:flex;gap:8px;padding:10px 12px;background:var(--rc-info-soft);color:var(--ui-info)', I('info', 14, 'margin-top:3px'), S('color:var(--ui-text-toned)', 'This article is a draft. The API returns this data only to signed-in callers until it is published.')), bar, body))
|
||||||
|
}
|
||||||
// ---------- compose
|
// ---------- compose
|
||||||
const v = s.v
|
const v = s.v
|
||||||
if (v === 'sites') {
|
if (v === 'sites') {
|
||||||
@@ -291,13 +374,17 @@ class Component extends DCLogic {
|
|||||||
if (s.list !== 'empty' && s.list !== 'error' && s.list !== 'loading') ov.push(fab('Add author'))
|
if (s.list !== 'empty' && s.list !== 'error' && s.list !== 'loading') ov.push(fab('Add author'))
|
||||||
} else if (v === 'editor') {
|
} else if (v === 'editor') {
|
||||||
if (s.metaOpen) edMeta = metaUI()
|
if (s.metaOpen) edMeta = metaUI()
|
||||||
|
if (s.seo && s.seo.side) edSeo = SEOSIDE(s.seo.side)
|
||||||
|
if (s.seo && s.seo.panel) ov.push(seoPanel())
|
||||||
|
if (s.seo && s.seo.pick) ov.push(Picker({ title: 'Choose a cover image', desc: 'The cover for this article.', sel: 1, z: 70 }))
|
||||||
|
if (s.seo && s.seo.api) ov.push(apiDialog())
|
||||||
if (s.modal) ov.push(newAuthorModal())
|
if (s.modal) ov.push(newAuthorModal())
|
||||||
if (s.ann) ov.push(D('position:absolute;z-index:20;' + (m ? 'left:16px;right:16px;bottom:88px' : 'left:248px;bottom:20px'), Note(s.ann)))
|
if (s.ann) ov.push(D('position:absolute;z-index:20;' + (m ? 'left:16px;right:16px;bottom:88px' : 'left:248px;bottom:20px'), Note(s.ann)))
|
||||||
} else if (v === 'profile') {
|
} else if (v === 'profile') {
|
||||||
if (s.panel) ov.push(authorForm())
|
if (s.panel) ov.push(authorForm())
|
||||||
}
|
}
|
||||||
if (s.toasts) ov.push(Toasts())
|
if (s.toasts) ov.push(Toasts())
|
||||||
return { shell, mob: m, main, ov: D('display:contents', ...ov), edMeta }
|
return { shell, mob: m, main, ov: D('display:contents', ...ov), edMeta, edSeo }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ class Component extends DCLogic {
|
|||||||
rootRef = React.createRef()
|
rootRef = React.createRef()
|
||||||
BASE = 'https://cms.renew.example'
|
BASE = 'https://cms.renew.example'
|
||||||
KEY = 'rcms_Kf3x9QwL0aBc…'
|
KEY = 'rcms_Kf3x9QwL0aBc…'
|
||||||
IDX = [['auth', 'Authentication'], ['articles', 'Articles'], ['media', 'Media'], ['images', 'Images and variants'], ['users', 'Users and invites'], ['settings', 'Settings'], ['session', 'Session only'], ['errors', 'Errors'], ['notes', 'Notes']]
|
IDX = [['auth', 'Authentication'], ['articles', 'Articles'], ['seo', 'Search and sharing'], ['media', 'Media'], ['images', 'Images and variants'], ['users', 'Users and invites'], ['settings', 'Settings'], ['session', 'Session only'], ['errors', 'Errors'], ['notes', 'Notes']]
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
const i = (this.props.s && this.props.s.int) || {}
|
const i = (this.props.s && this.props.s.int) || {}
|
||||||
const go = () => { if (i.scroll) this.scrollTo(i.scroll, !!this.props.mobile) }
|
const go = () => { if (i.scroll) this.scrollTo(i.scroll, !!this.props.mobile) }
|
||||||
@@ -182,11 +182,15 @@ class Component extends DCLogic {
|
|||||||
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-list', 'GET', '/v1/article/', 'List articles, one page at a time. Anonymous callers get online articles only, and the total counts only those. The total is in the X-Total-Count response header; the body stays a JSON array.', 'Public', 1, [[400, 'Invalid page or size.'], e401], [S('page', 'integer', 'No', '1 or higher. Default 1. A page past the end returns an empty array.'), S('size', 'integer', 'No', 'One of 5, 25, 50 or 100. Default 25.')], { qp: 1, pub: 1, r: j([{ id: 12, title: 'How we price a bathroom renovation', status: 'online' }]) }),
|
||||||
A('art-get', 'GET', '/v1/article/{id}', 'Get one article. Drafts answer 404 to anonymous callers.', 'Public', 1, [e401, e404], [], { pub: 1, r: j({ id: 12, title: 'How we price a bathroom renovation', status: 'online', body: 'A bathroom renovation is priced by room size, plumbing changes and finish level.', body_format: 'markdown' }) }),
|
A('art-get', 'GET', '/v1/article/{id}', 'Get one article. Drafts answer 404 to anonymous callers.', 'Public', 1, [e401, e404], [], { pub: 1, r: j({ id: 12, title: 'How we price a bathroom renovation', status: 'online', body: 'A bathroom renovation is priced by room size, plumbing changes and finish level.', body_format: 'markdown' }) }),
|
||||||
A('art-create', 'POST', '/v1/article/', 'Create a draft article.', 'Verified user', 1, [[400, mm], e401], [S('title', 'string', 'Yes', '3 to 200 characters. Can\'t be changed later.'), S('body', 'string', 'No', 'Optional. Any length.')], { body: { title: 'Choosing tiles for a small bathroom' }, r: j({ id: 13, status: 'draft' }) }),
|
A('art-create', 'POST', '/v1/article/', 'Create a draft article.', 'Verified user', 1, [[400, mm], e401], [S('title', 'string', 'Yes', '3 to 200 characters. Can\'t be changed later.'), S('body', 'string', 'No', 'Optional. Any length.')], { body: { title: 'Choosing tiles for a small bathroom' }, r: j({ id: 13, status: 'draft' }) }),
|
||||||
A('art-update', 'PATCH', '/v1/article/{id}', 'Update the body of an article.', 'Verified user', 1, [[400, mm], e401, e404], [S('body', 'string', 'Yes', 'Any length.')], { body: { body: 'Updated text.' }, r: j({ id: 12, status: 'draft' }) }),
|
A('art-update', 'PATCH', '/v1/article/{id}', 'Update the body of an article.', 'Verified user', 1, [[400, mm], e401, e404], [S('body', 'string', 'No', 'Any length.'), S('slug', 'string', 'No', 'Lowercase letters, numbers and hyphens. Unique on the article\'s site. 409 slug_taken if used.'), S('excerpt', 'string', 'No', 'Up to 300 characters. Also the article teaser.'), S('seo_title', 'string', 'No', 'Up to 120 characters. Falls back to the article title.'), S('seo_description', 'string', 'No', 'Up to 320 characters. Falls back to the excerpt, then the first sentence of the body.'), S('cover_image_id', 'integer', 'No', 'A media item. Falls back to the site social image.'), S('cover_image_alt', 'string', 'No', 'Up to 200 characters.'), S('social_title', 'string', 'No', 'Open Graph and Twitter title. Falls back to the resolved title.'), S('social_description', 'string', 'No', 'Falls back to the resolved description.'), S('social_image_id', 'integer', 'No', 'Falls back to the cover image.'), S('canonical_url', 'string', 'No', 'An absolute https URL. Default is the site base URL plus the slug.'), S('noindex', 'boolean', 'No', 'Sets robots to noindex in the SEO object. It doesn\'t change who can read the article.'), S('seo_dismissed', 'string[]', 'No', 'Suggestion codes the editor dismissed. Used by the back-office only.')], { body: { slug: 'bathroom-costs', seo_title: 'What a bathroom renovation costs' }, r: j({ id: 12, status: 'draft', slug: 'bathroom-costs' }) }),
|
||||||
A('art-pub', 'PATCH', '/v1/article/{id}/publish', 'Publish an article.', 'Verified user', 1, [e401, e404], [], { r: j({ id: 12, status: 'online' }) }),
|
A('art-pub', 'PATCH', '/v1/article/{id}/publish', 'Publish an article.', 'Verified user', 1, [e401, e404], [], { r: j({ id: 12, status: 'online' }) }),
|
||||||
A('art-unpub', 'PATCH', '/v1/article/{id}/unpublish', 'Unpublish an article.', 'Verified user', 1, [e401, e404], [], { r: j({ id: 12, status: 'draft' }) }),
|
A('art-unpub', 'PATCH', '/v1/article/{id}/unpublish', 'Unpublish an article.', 'Verified user', 1, [e401, e404], [], { r: j({ id: 12, status: 'draft' }) }),
|
||||||
A('art-del', 'DELETE', '/v1/article/{id}', 'Delete an article and its images.', 'Verified user', 1, [e401, e404], [], { r: '204 No Content' })
|
A('art-del', 'DELETE', '/v1/article/{id}', 'Delete an article and its images.', 'Verified user', 1, [e401, e404], [], { r: '204 No Content' })
|
||||||
] },
|
] },
|
||||||
|
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 }]) })
|
||||||
|
] },
|
||||||
media: { title: 'Media', desc: 'The image library. One image can be used in many articles.', eps: [
|
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 }]) }),
|
A('med-list', 'GET', '/v1/media/', 'List images.', 'Verified user', 1, [e401], [], { r: j([{ id: 4, filename: 'north-wall.jpg', size: 3900000 }]) }),
|
||||||
A('med-up', 'POST', '/v1/media/', 'Upload an image (multipart).', 'Verified user', 1, [[400, mm], e401, [413, 'The file is over 10 MB.'], [415, 'Not a PNG, JPEG, WEBP or SVG image.']], [S('file', 'file', 'Yes', 'PNG, JPEG, WEBP or SVG, up to 10 MB.')], { multi: true, form: [['file', '@north-wall.jpg']], r: j({ id: 4, filename: 'north-wall.jpg', url: '/static/uploadedImages/3f9c.jpg' }) }),
|
A('med-up', 'POST', '/v1/media/', 'Upload an image (multipart).', 'Verified user', 1, [[400, mm], e401, [413, 'The file is over 10 MB.'], [415, 'Not a PNG, JPEG, WEBP or SVG image.']], [S('file', 'file', 'Yes', 'PNG, JPEG, WEBP or SVG, up to 10 MB.')], { multi: true, form: [['file', '@north-wall.jpg']], r: j({ id: 4, filename: 'north-wall.jpg', url: '/static/uploadedImages/3f9c.jpg' }) }),
|
||||||
@@ -267,7 +271,7 @@ class Component extends DCLogic {
|
|||||||
errRows: [
|
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: 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: 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." or "You must verify your account to access this resource."' },
|
{ 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: 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: 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: 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"' }
|
{ c: 500, m: 'Server error', w: '"Internal server error"' }
|
||||||
|
|||||||
@@ -96,9 +96,9 @@ class Component extends DCLogic {
|
|||||||
boGroups() {
|
boGroups() {
|
||||||
const base = [
|
const base = [
|
||||||
{ t: 'How we price a bathroom renovation', au: ['Inès Duarte'], on: true, c: '12 Mar 2026', u: '2 hours ago' },
|
{ t: 'How we price a bathroom renovation', au: ['Inès Duarte'], on: true, c: '12 Mar 2026', u: '2 hours ago' },
|
||||||
{ t: 'Choosing lime plaster for old stone walls', au: ['Inès Duarte', 'Camille Roche'], on: false, c: '9 Sep 2026', u: 'Yesterday' },
|
{ t: 'Choosing lime plaster for old stone walls', au: ['Inès Duarte', 'Camille Roche'], on: false, c: '9 Sep 2026', u: 'Yesterday', seo: 2 },
|
||||||
{ t: 'Before and after: the Rue Verte kitchen', au: ['Jonas Pereira'], on: true, c: '28 Aug 2026', u: '3 days ago' },
|
{ t: 'Before and after: the Rue Verte kitchen', au: ['Jonas Pereira'], on: true, c: '28 Aug 2026', u: '3 days ago', seo: 'hidden' },
|
||||||
{ t: 'Winter checklist for timber windows', au: ['Inès Duarte', 'Jonas Pereira', 'Camille Roche', 'Paul Huber', 'Léa Fontaine'], on: false, c: '2 Sep 2026', u: '5 days ago' },
|
{ t: 'Winter checklist for timber windows', au: ['Inès Duarte', 'Jonas Pereira', 'Camille Roche', 'Paul Huber', 'Léa Fontaine'], on: false, c: '2 Sep 2026', u: '5 days ago', seo: 3 },
|
||||||
{ t: 'Why we reuse original floorboards', au: ['Paul Huber'], on: true, c: '14 Jun 2026', u: '18 Aug 2026' },
|
{ t: 'Why we reuse original floorboards', au: ['Paul Huber'], on: true, c: '14 Jun 2026', u: '18 Aug 2026' },
|
||||||
{ t: 'Our first passive-house retrofit', au: [], on: true, c: '3 Apr 2026', u: '2 Jul 2026' }
|
{ t: 'Our first passive-house retrofit', au: [], on: true, c: '3 Apr 2026', u: '2 Jul 2026' }
|
||||||
]
|
]
|
||||||
@@ -924,6 +924,52 @@ class Component extends DCLogic {
|
|||||||
S('Several sites', 'More than one site exists.', 'Authors · Site · All sites · Renew · Journal · Projects', { v: 'app', list: { multi: true } }),
|
S('Several sites', 'More than one site exists.', 'Authors · Site · All sites · Renew · Journal · Projects', { v: 'app', list: { multi: true } }),
|
||||||
S('Site filter open', 'The Site select in the toolbar. Choosing a site stores ?site=key and resets the page to 1.', 'All sites · Renew · Journal · Projects · Archive', { v: 'app', list: { multi: true, siteMenu: true } }),
|
S('Site filter open', 'The Site select in the toolbar. Choosing a site stores ?site=key and resets the page to 1.', 'All sites · Renew · Journal · Projects · Archive', { v: 'app', list: { multi: true, siteMenu: true } }),
|
||||||
S('No articles on a site', 'The chosen site has no articles.', 'No articles on "Archive" · Move articles here from the editor, or choose another site. · Show all sites', { v: 'app', list: { multi: true, site: 'Archive', counts: [0, 0, 0], empty: { icon: 'file-text', title: 'No articles on "Archive"', text: 'Move articles here from the editor, or choose another site.', action: 'Show all sites' } } })
|
S('No articles on a site', 'The chosen site has no articles.', 'No articles on "Archive" · Move articles here from the editor, or choose another site. · Show all sites', { v: 'app', list: { multi: true, site: 'Archive', counts: [0, 0, 0], empty: { icon: 'file-text', title: 'No articles on "Archive"', text: 'Move articles here from the editor, or choose another site.', action: 'Show all sites' } } })
|
||||||
|
]),
|
||||||
|
G('Articles: search status', 'Articles: search status', '/articles?seo=suggestions', 'GET /v1/article/?seo=suggestions&page=&size= (items carry seo_status and seo_suggestions)', 'A quiet indicator in the Status cell on desktop and under the title on mobile cards: a lightbulb chip with a count when there are suggestions, an eye-off chip "Hidden" when noindex is on, nothing when the article is ready. The toolbar gets a "Needs attention" toggle with a count next to the search field. Tabs and their counts are unchanged. On a single-site install nothing changes: the Site filter and column stay hidden and the toggle works the same, over all articles.', [
|
||||||
|
S('Status chips', 'Row 2 has 2 suggestions, row 3 is hidden, row 4 has 3, row 5 has 1.', 'Needs attention · 3 · Hidden · 2 suggestions (tooltip) · Hidden from search engines (tooltip)', { v: 'app', list: {} }),
|
||||||
|
S('Needs attention on', 'The toggle is pressed. The list shows only articles with suggestions. Hidden-only articles are not included.', 'Needs attention · 3', { v: 'app', list: { needs: true } }),
|
||||||
|
S('Needs attention: none', 'Nothing to show with the toggle on.', 'Nothing needs attention · Every article has a clean check. Suggestions are optional, so this is just a bonus. · Show all articles', { v: 'app', list: { needs: true, needsN: 0, counts: [0, 0, 0], empty: { icon: 'circle-check', title: 'Nothing needs attention', text: 'Every article has a clean check. Suggestions are optional, so this is just a bonus.', action: 'Show all articles' } } })
|
||||||
|
]),
|
||||||
|
G('Editor: search and sharing', 'Editor: search and sharing', '/article/:id/edit', 'PATCH /v1/article/{id} {slug, excerpt, seo_title, seo_description, cover_image_id, cover_image_alt, social_title, social_description, social_image_id, canonical_url, noindex, seo_dismissed[]}', 'RenewCMS is headless: it never renders pages or adds meta tags. This section helps the writer fill in SEO data and shows what the frontend will receive. Everything is optional and each empty field shows its fallback. Placement: a "Search and sharing" section under "Site and authors" in the sidebar and in the mobile Details sheet. Collapsed by default with a one-line summary (Ready, N suggestions, Hidden from search), remembered per browser. The fields and live previews open in a slide-over (desktop, 960 px) or a 92% sheet over the Details sheet (mobile). Anyone who can edit the article can edit these fields. Suggestions never block saving or publishing: publishing is allowed regardless. Counters are soft guides and turn amber, never red, outside the recommended range. Light and dark follow the theme switch above.', [
|
||||||
|
S('Collapsed: ready', 'The default. Nothing to suggest.', 'Search and sharing · Ready', { v: 'editor', ed: { seoSummary: 'Ready' } }),
|
||||||
|
S('Collapsed: suggestions', 'Summary in the warning colour. No count badge elsewhere.', 'Search and sharing · 2 suggestions', { v: 'editor', ed: { seoSummary: '2 suggestions' } }),
|
||||||
|
S('Collapsed: hidden', 'noindex is on.', 'Search and sharing · Hidden from search', { v: 'editor', ed: { seoSummary: 'Hidden from search' } }),
|
||||||
|
S('Open: ready', 'Address, the all-good message and two actions.', 'Address · renew.example/how-we-price-a-bathroom-renovation · All good · Nothing to suggest for this article. · Edit search and sharing · What the frontend gets', { v: 'editor', ed: { seoSummary: 'Ready' }, seo: { side: { codes: [] } } }),
|
||||||
|
S('Open: suggestions', 'At most three suggestions here, then a count. Each links to its field.', 'The meta title is 74 characters. Search engines may cut it off after about 60. · Go to meta title · The cover image has no alt text. Add a short description of it. · Go to cover image · The body has no headings. H2 headings help readers scan the article. · Go to body · +1 more in Search and sharing', { v: 'editor', ed: { seoSummary: '4 suggestions' }, seo: { side: { codes: ['title_long', 'cover_alt', 'no_h', 'short'] } } }),
|
||||||
|
S('Open: checking', 'While the checks run after the editor loads. The rest of the section stays usable.', 'Checking this article…', { v: 'editor', ed: { seoSummary: 'Checking…' }, seo: { side: { state: 'loading' } } }),
|
||||||
|
S('Open: hidden', 'A line explains the flag.', 'Hidden from search engines. This only sets a flag in the API.', { v: 'editor', ed: { seoSummary: 'Hidden from search' }, seo: { side: { codes: [], hidden: true } } }),
|
||||||
|
S('Open: other site', 'The address uses the article\'s site.', 'journal.renew.example/how-we-price-a-bathroom-renovation', { v: 'editor', ed: { seoSummary: 'Ready' }, seo: { side: { codes: [], site: 1 } } }),
|
||||||
|
S('Panel: defaults', 'A writer who changed nothing. Every value is a fallback and says so.', 'Search and sharing · URL slug · Generated from the title until you edit it. · Meta title · Using article title · Recommended 30 to 60 characters. This is a guide, not a limit. · Meta description · Using first sentence of the body · Recommended 70 to 160 characters. · Excerpt · A short summary. It is also sent as the article teaser. · Cover image · Using the site social image (north-wall.jpg). · Social sharing · Optional · Uses the meta values · Advanced · Canonical URL: automatic · Hide from search engines · Sets noindex in the API. It doesn\'t change who can read the article. · Search result · Social card · Suggestions · What the frontend gets · Cancel · Save changes', { v: 'editor', ed: { seoSummary: '1 suggestion' }, seo: { panel: { codes: ['cover_none'] } } }),
|
||||||
|
S('Panel: filled in', 'Own values everywhere. The fallback chips are gone and the suggestion list shows All good.', 'All good · Nothing to suggest for this article.', { v: 'editor', ed: { seoSummary: 'Ready' }, seo: { panel: { mt: 'What a bathroom renovation costs', md: 'A bathroom renovation is priced by room size, plumbing changes and finish level. Here is how we build a quote.', ex: 'How we price a bathroom renovation, from the survey to the final invoice.', cover: { alt: 'A tiled bathroom wall during renovation' }, codes: [] } } }),
|
||||||
|
S('Panel: outside the soft ranges', 'Counters turn amber. The preview shows the cut-off. Nothing is blocked.', 'The meta title is 74 characters. Search engines may cut it off after about 60. · The description is 41 characters. About 70 to 160 reads best in results. · Cut off here, the way a search engine would.', { v: 'editor', ed: { seoSummary: '2 suggestions' }, seo: { panel: { mt: 'How we price a bathroom renovation: surveys, plumbing, tiling and finishes', md: 'What a bathroom renovation costs.', codes: ['title_long', 'desc_short'] } } }),
|
||||||
|
S('Panel: slug edited', 'The Auto badge is gone. "Reset to match the title" brings it back.', 'Reset to match the title', { v: 'editor', ed: { seoSummary: 'Ready' }, seo: { panel: { slug: 'bathroom-costs', slugMode: 'manual', codes: [] } } }),
|
||||||
|
S('Panel: slug taken', 'Checked on blur and on save. Nothing is saved until a free slug is chosen.', 'The slug "bathroom-costs" is already used on Renew. · Use bathroom-costs-2', { v: 'editor', ed: { seoSummary: 'Ready' }, seo: { panel: { slug: 'bathroom-costs', slugMode: 'taken', codes: [] } } }),
|
||||||
|
S('Panel: cover without alt', 'Alt text is required before the cover counts as ready. It is a suggestion, not an error.', 'Alt text · Needed before the cover counts as ready. Sent as image alt in the API. · Describe the image for people who can\'t see it · The cover image has no alt text. Add a short description of it.', { v: 'editor', ed: { seoSummary: '1 suggestion' }, seo: { panel: { cover: { alt: '' }, codes: ['cover_alt'] } } }),
|
||||||
|
S('Panel: choose cover', 'The shared Media picker.', 'Choose a cover image · The cover for this article. · Use image', { v: 'editor', ed: { seoSummary: '1 suggestion' }, seo: { panel: { codes: ['cover_none'] }, pick: true } }),
|
||||||
|
S('Panel: social sharing open', 'Optional overrides. Each falls back to the meta values.', 'Social sharing · Optional · Social title · Using meta title · Social description · Using meta description · Social image · Using the cover image.', { v: 'editor', ed: { seoSummary: 'Ready' }, seo: { panel: { social: true, cover: { alt: 'A tiled bathroom wall during renovation' }, codes: [] } } }, { seo: { panel: { social: true, scrollY: 560, cover: { alt: 'A tiled bathroom wall during renovation' }, codes: [] } } }),
|
||||||
|
S('Panel: social customised', 'The card shows the overrides, so the Using chips disappear.', 'Customised', { v: 'editor', ed: { seoSummary: 'Ready' }, seo: { panel: { social: true, soc: { t: 'Bathroom costs, explained', d: 'A plain guide to what drives the price.', img: 'kitchen-after.webp' }, codes: [] } } }, { seo: { panel: { social: true, scrollY: 560, soc: { t: 'Bathroom costs, explained', d: 'A plain guide to what drives the price.', img: 'kitchen-after.webp' }, codes: [] } } }),
|
||||||
|
S('Panel: advanced, invalid canonical', 'Collapsed by default. The address must be absolute.', 'Advanced · Canonical URL · Only change this if the same content lives at another address. · Enter a full address starting with https://', { v: 'editor', ed: { seoSummary: 'Ready' }, seo: { panel: { adv: true, canon: 'renew.example/costs', err: { canon: 'Enter a full address starting with https://' }, focus: 'canon', codes: [] } } }, { seo: { panel: { adv: true, scrollY: 640, canon: 'renew.example/costs', err: { canon: 'Enter a full address starting with https://' }, focus: 'canon', codes: [] } } }),
|
||||||
|
S('Panel: hidden from search', 'The switch only sets a flag in the API.', 'Hide from search engines · Sets noindex in the API. It doesn\'t change who can read the article.', { v: 'editor', ed: { seoSummary: 'Hidden from search' }, seo: { panel: { noindex: true, codes: [] } } }, { seo: { panel: { noindex: true, scrollY: 700, codes: [] } } }),
|
||||||
|
S('Panel: mobile search preview', 'The Desktop and Mobile toggle changes the width and where the title is cut.', 'Search result · Desktop · Mobile', { v: 'editor', ed: { seoSummary: 'Ready' }, seo: { panel: { dev: 'mobile', mt: 'How we price a bathroom renovation: surveys, plumbing, tiling and finishes', codes: ['title_long'] } } }),
|
||||||
|
S('Panel: checking', 'Checks run on open and after each pause in typing.', 'Checking this article…', { v: 'editor', ed: { seoSummary: 'Checking…' }, seo: { panel: { sugState: 'loading' } } }),
|
||||||
|
S('Panel: saving', 'Fields are disabled while the request runs.', 'Saving…', { v: 'editor', ed: { seoSummary: 'Ready' }, seo: { panel: { state: 'saving', mt: 'What a bathroom renovation costs', codes: [] } } }),
|
||||||
|
S('Panel: save failed', '500 or network failure. The values stay.', 'Couldn\'t save search and sharing. Your changes are still here. Try again in a moment.', { v: 'editor', ed: { seoSummary: 'Ready' }, seo: { panel: { notice: 'Couldn\'t save search and sharing. Your changes are still here. Try again in a moment.', mt: 'What a bathroom renovation costs', codes: [] } } }),
|
||||||
|
S('Saved', 'The panel closes. The summary updates.', 'Search and sharing saved · Your search and sharing settings are up to date.', { v: 'editor', ed: { seoSummary: 'Ready' }, seo: { side: { codes: [] } }, toasts: [['success', 'circle-check', 'Search and sharing saved', 'Your search and sharing settings are up to date.']] })
|
||||||
|
]),
|
||||||
|
G('Editor: what the frontend gets', 'Editor: what the frontend gets', '/article/:id/edit', 'GET /v1/article/{id}/seo', 'A read-only dialog (desktop, 720 px) or bottom sheet (mobile) with the resolved SEO object exactly as the API returns it. Opened from the sidebar section or the panel. Each value that came from a fallback carries a chip. Formatted shows a compact key and value list, Raw shows the JSON. Copy JSON copies the raw object. Author name, profile URL and social links come from the Authors screen and surface in authors[] and in the JSON-LD author, so Authors itself needs no change.', [
|
||||||
|
S('Formatted', 'Default view.', 'What the frontend gets · The SEO data returned by GET /v1/article/12/seo, read only. RenewCMS doesn\'t render pages or add meta tags: your frontend does. · Formatted · Raw JSON · Copy JSON · Close', { v: 'editor', ed: { seoSummary: 'Ready' }, seo: { side: { codes: [] }, api: {} } }),
|
||||||
|
S('Raw JSON', 'The JSON as the API returns it.', 'Raw JSON', { v: 'editor', ed: { seoSummary: 'Ready' }, seo: { side: { codes: [] }, api: { view: 'raw' } } }),
|
||||||
|
S('Copied', 'The button reads Copied for 2 s and this is announced.', 'Copied', { v: 'editor', ed: { seoSummary: 'Ready' }, seo: { side: { codes: [] }, api: { view: 'raw', copied: true } } }),
|
||||||
|
S('Draft article', 'Drafts answer 404 to anonymous callers.', 'This article is a draft. The API returns this data only to signed-in callers until it is published.', { v: 'editor', ed: { seoSummary: 'Ready' }, seo: { side: { codes: [] }, api: { draft: true } } }),
|
||||||
|
S('Loading', 'Copy is disabled.', '', { v: 'editor', ed: { seoSummary: 'Ready' }, seo: { side: { codes: [] }, api: { state: 'loading' } } }),
|
||||||
|
S('Error', 'GET failed. Nothing in the article changes.', 'Couldn\'t load the SEO data · GET /v1/article/12/seo failed. Your article is unchanged. Try again in a moment. · Try again', { v: 'editor', ed: { seoSummary: 'Ready' }, seo: { side: { codes: [] }, api: { state: 'error' } } })
|
||||||
|
]),
|
||||||
|
G('Sites: SEO defaults', 'Sites: SEO defaults', '/settings/sites', '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).', [
|
||||||
|
S('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.', { v: 'sites', sites: { state: 'ok' }, panel: { k: 'site', mode: 'edit', i: 0, f: { name: 'Renew', key: 'renew', keyMode: 'saved', url: 'https://renew.example', img: 'north-wall.jpg', loc: 'en', ddesc: 'Practical advice on renovating old buildings, from the people who do the work.' } } }),
|
||||||
|
S('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.', { v: 'sites', sites: { state: 'ok' }, panel: { k: 'site', mode: 'add', f: { name: 'Journal', key: 'journal', keyMode: 'auto', url: 'https://journal.renew.example' } } }),
|
||||||
|
S('Template: custom', 'The example updates as the owner types.', 'Example · How we price a bathroom renovation – Journal', { v: 'sites', sites: { state: 'ok' }, panel: { k: 'site', mode: 'edit', i: 1, focus: 'tpl', f: { name: 'Journal', key: 'journal', keyMode: 'saved', url: 'https://journal.renew.example', sname: 'Journal', tpl: '%title% – %site%' } } }),
|
||||||
|
S('Template: not valid', 'The template needs %title%. Nothing is sent.', 'The template must contain %title%.', { v: 'sites', sites: { state: 'ok' }, panel: { k: 'site', mode: 'edit', i: 1, focus: 'tpl', err: { tpl: 'The template must contain %title%.' }, f: { name: 'Journal', key: 'journal', keyMode: 'saved', url: 'https://journal.renew.example', sname: 'Journal', tpl: '%site%' } } }),
|
||||||
|
S('Save failed', '500 or network failure.', 'Couldn\'t save the site. Your changes are still here. Try again in a moment.', { v: 'sites', sites: { state: 'ok' }, panel: { k: 'site', mode: 'edit', i: 0, notice: 'Couldn\'t save the site. Your changes are still here. Try again in a moment.', f: { name: 'Renew', key: 'renew', keyMode: 'saved', url: 'https://renew.example', img: 'north-wall.jpg', loc: 'en' } } })
|
||||||
])
|
])
|
||||||
],
|
],
|
||||||
specs: [
|
specs: [
|
||||||
@@ -1017,7 +1063,7 @@ class Component extends DCLogic {
|
|||||||
const doc = this.dirDoc()
|
const doc = this.dirDoc()
|
||||||
const rowsA = ['How we price a bathroom renovation', 'Choosing lime plaster for old stone walls', 'Before and after: the Rue Verte kitchen', 'Winter checklist for timber windows', 'Why we reuse original floorboards', 'Our first passive-house retrofit']
|
const rowsA = ['How we price a bathroom renovation', 'Choosing lime plaster for old stone walls', 'Before and after: the Rue Verte kitchen', 'Winter checklist for timber windows', 'Why we reuse original floorboards', 'Our first passive-house retrofit']
|
||||||
const meta = [[true, '12 Mar 2026', '2 hours ago', ['Inès Duarte'], 'Journal'], [false, '9 Sep 2026', 'Yesterday', ['Inès Duarte', 'Camille Roche'], 'Renew'], [true, '28 Aug 2026', '3 days ago', ['Jonas Pereira'], 'Projects'], [false, '2 Sep 2026', '5 days ago', ['Inès Duarte', 'Jonas Pereira', 'Camille Roche', 'Paul Huber', 'Léa Fontaine'], 'Journal'], [true, '14 Jun 2026', '18 Aug 2026', ['Paul Huber'], 'Renew'], [true, '3 Apr 2026', '2 Jul 2026', [], 'Journal']]
|
const meta = [[true, '12 Mar 2026', '2 hours ago', ['Inès Duarte'], 'Journal'], [false, '9 Sep 2026', 'Yesterday', ['Inès Duarte', 'Camille Roche'], 'Renew'], [true, '28 Aug 2026', '3 days ago', ['Jonas Pereira'], 'Projects'], [false, '2 Sep 2026', '5 days ago', ['Inès Duarte', 'Jonas Pereira', 'Camille Roche', 'Paul Huber', 'Léa Fontaine'], 'Journal'], [true, '14 Jun 2026', '18 Aug 2026', ['Paul Huber'], 'Renew'], [true, '3 Apr 2026', '2 Jul 2026', [], 'Journal']]
|
||||||
const rows = meta.map((x, i) => ({ t: rowsA[i], on: x[0], c: x[1], u: x[2], au: x[3], site: x[4] }))
|
const rows = meta.map((x, i) => ({ t: rowsA[i], on: x[0], c: x[1], u: x[2], au: x[3], site: x[4], seo: [0, 2, 'hidden', 3, 1, 0][i] }))
|
||||||
const edShell = ed => ({ kind: 'editor', nav: 'articles', crumbs: ['Articles', 'How we price a bathroom renovation'], mTitle: 'Edit article', mLead: 'arrow-left', mAction: 'ellipsis', ed: { status: 'draft', save: 'saved', sheet: true, ...ed } })
|
const edShell = ed => ({ kind: 'editor', nav: 'articles', crumbs: ['Articles', 'How we price a bathroom renovation'], mTitle: 'Edit article', mLead: 'arrow-left', mAction: 'ellipsis', ed: { status: 'draft', save: 'saved', sheet: true, ...ed } })
|
||||||
const pfShell = { kind: 'profile', nav: 'profile', crumbs: ['Profile'], mTitle: 'Profile', pf: {} }
|
const pfShell = { kind: 'profile', nav: 'profile', crumbs: ['Profile'], mTitle: 'Profile', pf: {} }
|
||||||
return doc.groups.map(g => ({ label: g.label, title: g.title, route: g.route, api: g.api, note: g.note, states: g.states.map(st => {
|
return doc.groups.map(g => ({ label: g.label, title: g.title, route: g.route, api: g.api, note: g.note, states: g.states.map(st => {
|
||||||
@@ -1026,6 +1072,7 @@ class Component extends DCLogic {
|
|||||||
if (o.v === 'app') {
|
if (o.v === 'app') {
|
||||||
const L = { ...o.list }
|
const L = { ...o.list }
|
||||||
if (!L.empty && !L.loading) L.rows = L.only ? rows.filter(r => r.site === L.only) : rows
|
if (!L.empty && !L.loading) L.rows = L.only ? rows.filter(r => r.site === L.only) : rows
|
||||||
|
if (L.needs && !L.empty) L.rows = rows.filter(r => r.seo > 0)
|
||||||
const base = { kind: 'articles', nav: 'articles', crumbs: ['Articles'], mTitle: 'Articles', list: L }
|
const base = { kind: 'articles', nav: 'articles', crumbs: ['Articles'], mTitle: 'Articles', list: L }
|
||||||
return { notMedia: true, name: st.name, trigger: st.trigger, ...base, m: { ...base } }
|
return { notMedia: true, name: st.name, trigger: st.trigger, ...base, m: { ...base } }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,3 +70,6 @@ As in screens/settings.md, screens/image-variants.md and screens/activity.md. Sy
|
|||||||
|
|
||||||
## Per-screen copy
|
## Per-screen copy
|
||||||
screens/<name>.md, section "Copy per state" (generated from the board source).
|
screens/<name>.md, section "Copy per state" (generated from the board source).
|
||||||
|
|
||||||
|
## SEO round
|
||||||
|
Frames: Editor: search and sharing (24), Editor: what the frontend gets (6), Sites: SEO defaults (5), Articles: search status (3). Each is drawn desktop and mobile; light and dark follow the theme switch. None is clock-sensitive: dates in the API preview are fixed sample values. Disabled state: all fields at 40% while saving. Validation: slug taken, canonical not absolute, title template without %title%, excerpt over 300. Suggestions never block saving or publishing. Copy per state: screens/editor.md, articles.md, sites.md.
|
||||||
Reference in new issue
Block a user