fix: design harmonization on last features
This commit is contained in:
1 parent
953be7342d
commit
70c8492f4f
23 files changed
+372
-686
No files matched your search
@@ -33,18 +33,20 @@ The visual spec of record for the back-office SPA and the transactional emails,
|
||||
| [Media picker](screens/media-picker.md) | `/article/:id/edit` (dialog) | 13 | `source/RenewCMS Screens Back-office.dc.html` |
|
||||
| [Integration](screens/integration.md) | `/integration` | 8 | `source/RenewCMS Screens Back-office.dc.html` |
|
||||
| [API keys](screens/integration-api-keys.md) | `/integration/api-keys` | 12 | `source/RenewCMS Screens API keys.dc.html` |
|
||||
| [Activity](screens/activity.md) | `/activity` | 9 (plus 6 details, 3 My activity) | `source/RenewCMS Screens Activity.dc.html` |
|
||||
| [Activity](screens/activity.md) | `/activity` | 9 (plus 6 details, 3 My activity) | `source/RenewCMS Screens Back-office.dc.html` |
|
||||
| [Users](screens/users.md) | `/users` | 17 | `source/RenewCMS Screens Back-office.dc.html` |
|
||||
| [Settings](screens/settings.md) | `/settings` | 25 | `source/RenewCMS Screens Back-office.dc.html`, `source/RenewCMS Screens Media cleanup.dc.html` |
|
||||
| [Settings](screens/settings.md) | `/settings` | 25 | `source/RenewCMS Screens Back-office.dc.html` |
|
||||
| [Profile](screens/profile.md) | `/profile` | 13 | `source/RenewCMS Screens Back-office.dc.html` |
|
||||
| [Accept invite](screens/accept-invite.md) | `/invite/accept?c=CODE` | 9 | `source/RenewCMS Screens Auth.dc.html` |
|
||||
| [Global](screens/global.md) | `any route` | 4 | `source/RenewCMS Screens Back-office.dc.html` |
|
||||
|
||||
## Other boards
|
||||
|
||||
- `source/ActivityFrame.dc.html`: the frame that draws every Activity state, the Home card and the Settings retention card (the Activity board imports it). Its copy, component and API tables are in `source/activity-spec.js`. It holds the exact values. Home and Settings are drawn around dashed placeholders.
|
||||
The Back-office board is now the one place where every back-office screen is drawn together, including Activity, Media cleanup, Retention and the Home activity section, all through `source/AppFrame.dc.html`. The separate Activity and Media cleanup boards and `SettingsFrame.dc.html` were removed from the export; their states now live in the Back-office board.
|
||||
|
||||
|
||||
- `source/ActivityFrame.dc.html`: the frame that draws every Activity state (the Back-office board imports it). Its copy, component and API tables are in `source/activity-spec.js`, rendered at the bottom of the Back-office board. It holds the exact values. The Home activity section and the Retention card are drawn by `AppFrame.dc.html`.
|
||||
- `source/MediaFrame.dc.html`: the frame that draws the Media and Media picker states (the Back-office board imports it). Like `AppFrame.dc.html`, it holds the exact values.
|
||||
- `source/SettingsFrame.dc.html`: the Media cleanup card and its purge dialog (the Media cleanup board imports it, with `MediaFrame.dc.html` for the Duplicates toolbar button). They hold the exact values.
|
||||
- `source/IntegrationPage.dc.html` and `source/ApiKeysPage.dc.html`: the page components for the Integration and API keys screens (imported by `AppFrame.dc.html`). They hold the exact values.
|
||||
- `source/RenewCMS Prototype.dc.html`: the clickable flows (first run, edit and publish, password reset, delete, session expiry mid-edit).
|
||||
- `source/RenewCMS Handoff.dc.html`: the handoff page the first markdown here was generated from. It predates the Users and Profile screens and the invitation email, so it does not list them. Those are written by hand from the Back-office board, `AppFrame.dc.html`, the Prototype and `emails/invitation.html`.
|
||||
|
||||
@@ -70,7 +70,7 @@ Responsive 400 px card from sm up, with its top 12 vh from the top. Below sm , t
|
||||
|
||||
### DashboardShell
|
||||
|
||||
The sidebar has five destinations (Home, Articles, Users, Integration, Settings), and the user menu sits at its foot. The top bar carries the breadcrumbs and page title. Collapsing the sidebar leaves a 56 px icon rail. Below lg , the sidebar becomes a slideover.
|
||||
The sidebar has seven destinations for owner and admin (Home, Articles, Media, Users, Activity, Integration, Settings; regular users get Home, Articles, Media, Activity), and the user menu sits at its foot. The top bar carries the breadcrumbs and page title. Collapsing the sidebar leaves a 56 px icon rail. Below lg , the sidebar becomes a slideover.
|
||||
|
||||
RenewCMS
|
||||
|
||||
@@ -400,7 +400,7 @@ The chip at the foot of the sidebar no longer logs out on click. It opens the ac
|
||||
|
||||
### DashboardShell (changed)
|
||||
|
||||
The navigation gains **Users** (users icon) between Articles and Integration, in the sidebar and in the mobile drawer. Breadcrumbs for the new pages are "Users" and "Profile". It later gains **Settings** (settings icon) after Integration, with the breadcrumb "Settings".
|
||||
The navigation gains **Users** (users icon) between Articles and Integration, in the sidebar and in the mobile drawer. Breadcrumbs for the new pages are "Users" and "Profile". It later gains **Settings** (settings icon) after Integration, with the breadcrumb "Settings". **Activity** (history icon) sits between Users and Integration; see `screens/activity.md`, "Sidebar".
|
||||
|
||||
### UsersTable, UserCardList
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
- **Route:** `/activity`
|
||||
- **API:** GET /v1/activity · GET /v1/activity/summary · GET /v1/activity/export (see "API contract" below)
|
||||
- **Visual reference:** `source/RenewCMS Screens Activity.dc.html`, group "Activity, owner and admin" (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle). The frame is `source/ActivityFrame.dc.html`; its copy, component mapping and API tables are `source/activity-spec.js`.
|
||||
- **Visual reference:** `source/RenewCMS Screens Back-office.dc.html`, group "Activity" (the same board also has the groups "Activity details" and "My activity"), drawn by `source/AppFrame.dc.html` with the page from `source/ActivityFrame.dc.html` (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle). The frame is `source/ActivityFrame.dc.html`; its copy, component mapping and API tables are `source/activity-spec.js`.
|
||||
|
||||
The audit log of the back-office: who did what, and when, across articles, media, users and invites, API keys, authentication and system tasks. It is for non-technical owners, so every row reads as a sentence, not a log line. Events are append-only: nothing in the app edits or deletes them. Only the retention period removes them (Settings, "Retention").
|
||||
|
||||
@@ -12,8 +12,8 @@ Other groups of the same board:
|
||||
|---|---|---|
|
||||
| Details slide-over | The slide-over and its bottom-sheet variant, six events | this file, "Details slide-over" |
|
||||
| My activity | The same screen for a user who is not owner or admin | this file, "My activity" |
|
||||
| Home card | The Recent activity card on Home | `screens/home.md`, "Recent activity card" |
|
||||
| Settings retention | The Retention card on Settings | `screens/settings.md`, "Activity retention" |
|
||||
| Home section | The Recent activity section on Home | `screens/home.md`, "Recent activity" |
|
||||
| Settings retention | The Retention card, last on Settings | `screens/settings.md`, "Activity retention" |
|
||||
|
||||
## Roles
|
||||
|
||||
|
||||
@@ -55,12 +55,12 @@ The greeting reads "Good morning, {name}", where the name is the display name wh
|
||||
|
||||
- Banner: success tone, `circle-check` icon, title "Version 2.3.0 is ready to install", text "Restart from Settings to finish. The site is unavailable for about 10 seconds.", button "Go to Settings"
|
||||
|
||||
## Recent activity card
|
||||
## Recent activity
|
||||
|
||||
**API:** GET /v1/activity?size=5. Visual reference: `source/RenewCMS Screens Activity.dc.html`, group "Home card". The board draws the rest of Home as a dashed "Existing Home cards" placeholder, so this card is not pixel-checked against it.
|
||||
**API:** GET /v1/activity?size=5. Visual reference: `source/RenewCMS Screens Back-office.dc.html`, group "Home" (states Default, Recent activity: loading, error and empty, Regular user), drawn by `source/AppFrame.dc.html`. This is the layout of record. The older Home card on the Activity board is superseded.
|
||||
|
||||
A card with the last five events. Header "Recent activity" (regular users: "Your recent activity") and a "View all" link with `arrow-right` to `/activity`. Each row: a 24 px avatar, the sentence clamped to two lines with the target in semibold, then "Marta Alves · 2 min ago" (own events: only the time). A failed event gets a "Failed" badge; a successful security event a small `shield`.
|
||||
A section with the last five events, built like "Recently updated" above it: a 16 px semibold heading on the left ("Recent activity") and an underlined "View all" link on the right, then a bordered list. Header "Recent activity" (regular users: "Your recent activity") and a "View all" link with `arrow-right` to `/activity`. Each row: a 24 px avatar, the sentence clamped to two lines with the target in semibold, then "Marta Alves · 2 min ago" (own events: only the time). A failed event gets a "Failed" badge; a successful security event a small `shield`.
|
||||
|
||||
- Placement as drawn: a 420 px column to the right of the existing cards on desktop; stacked on mobile, drawn above the existing cards. Confirm against the real Home layout before coding.
|
||||
- Placement: full width, below the stats and the "Recently updated" list, on desktop and mobile. There is no side column. "View all" links to `/activity`.
|
||||
- States: Owner or admin, Regular user, Loading (five skeleton rows after 150 ms), Error ("Couldn't load recent activity" with "Try again"), Empty ("No activity yet", "Actions on your site will appear here." / own: "Your actions will appear here.").
|
||||
- The card never blocks the page: an error or empty card leaves the stats and the article list as they are.
|
||||
- The section never blocks the page: an error or empty card leaves the stats and the article list as they are.
|
||||
@@ -1,7 +1,7 @@
|
||||
# Settings
|
||||
|
||||
- **Route:** `/settings`
|
||||
- **API:** GET /v1/media/purge-settings · PATCH /v1/media/purge-settings · GET /v1/media/purge-preview · POST /v1/media/purge (see "Media cleanup (second card)" at the end) · GET /v1/settings (version) · GET /v1/system/update · POST /v1/system/update/check · POST /v1/system/update/download · POST /v1/system/update/restart
|
||||
- **API:** GET /v1/media/purge-settings · PATCH /v1/media/purge-settings · GET /v1/media/purge-preview · POST /v1/media/purge (see "Media cleanup (third card)" at the end) · GET /v1/settings (version) · GET /v1/system/update · POST /v1/system/update/check · POST /v1/system/update/download · POST /v1/system/update/restart
|
||||
- **Visual reference:** `source/RenewCMS Screens Back-office.dc.html`, group "Settings", drawn by `source/AppFrame.dc.html` (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle).
|
||||
|
||||
Opened from the sidebar (Settings, settings icon, after Integration) and from the mobile drawer. Every verified user sees it. It holds two sections: Version (everyone) and Updates (the owner has the buttons, admins read the status).
|
||||
@@ -12,7 +12,7 @@ Out of scope: choosing versions, downgrades, automatic updates, update history.
|
||||
|
||||
## The page
|
||||
|
||||
Title "Settings", subtitle "Workspace information.", 720 px at most, 20 px between sections. Page padding 32 px on desktop and `20px 16px 88px` on mobile.
|
||||
One column of cards, top to bottom: Version, Updates, Media cleanup, Retention (owner only). Title "Settings" (20 px/28 px semibold), subtitle "Workspace information.", 720 px at most, 20 px between sections. Every card heading (Version, Updates, Media cleanup, Retention) is 15 px/24 px semibold in `--ui-text-highlighted`; a card heading is never larger than the page title and never smaller than a body line. The Back-office board draws this page scrolled to the card each state is about. Page padding 32 px on desktop and `20px 16px 88px` on mobile.
|
||||
|
||||
**Version card** (unchanged): label "Version" and the running build's version in IBM Plex Mono 14 px, shown exactly as the server reports it (for example `v2.2.0-beta`).
|
||||
|
||||
@@ -28,7 +28,7 @@ Title "Settings", subtitle "Workspace information.", 720 px at most, 20 px betwe
|
||||
| Server command | Only in the "Not prepared" state: "For whoever manages the server", a one-line `code` block, a Copy button (reads "Copied" with a check for 2 s, announced), and the note "They run this once on the server. After that, Update and Restart appear here." The command shown is `renewcms enable-browser-updates` and is subject to the real one. |
|
||||
| Buttons | 32 px high (44 px on mobile, stacked full width), 14 px side padding. One primary at most. Disabled buttons use 40 % opacity. |
|
||||
| Hint | 12 px muted line under the buttons. |
|
||||
| Link | Underlined, with an `arrow-up-right`: "How to update" or "Read the release notes". |
|
||||
| Link | Underlined, with an `external-link`: "How to update" or "Read the release notes". |
|
||||
|
||||
Buttons used: **Check for updates** (`refresh-cw`, secondary), **Update** (`download`, primary), **Restart** (`power`, primary and inside the status block when an update is ready, otherwise secondary in the buttons row), **Try again** (`rotate-cw`, primary), and the disabled "Checking…" and "Updating…" (`loader-circle`). Copy such as "Version 2.3.0" names the newer release. Decision for the spec: show the release tag without its leading `v` ("Version 2.3.0-beta" for tag `v2.3.0-beta`), while the Version card keeps the running build exactly as the server reports it.
|
||||
|
||||
@@ -126,15 +126,15 @@ For the owner only: an **Update** pill on the Settings sidebar item, and a dot o
|
||||
| Role changed (403) | An owner was changed to admin while the page was open and pressed a button. The card reloads in the admin view. |
|
||||
| Update already running (409) | Another owner started it. The card switches to that run's progress. |
|
||||
|
||||
## Media cleanup (second card)
|
||||
## Media cleanup (third card)
|
||||
|
||||
Route: `/settings`. Visual reference: `RenewCMS Screens Media cleanup.dc.html` (frames: `SettingsFrame.dc.html`, `MediaFrame.dc.html`).
|
||||
Route: `/settings`. Visual reference: `source/RenewCMS Screens Back-office.dc.html`, groups "Settings: Media cleanup", "Purge dialog", "Purge results" and "Media duplicates entry" (frames: `source/AppFrame.dc.html`, `source/MediaFrame.dc.html`).
|
||||
API: `GET/PATCH /v1/media/purge-settings` (owner, admin), `GET /v1/media/purge-preview`, `POST /v1/media/purge`. 401 and 403 are global: no local copy.
|
||||
|
||||
Rule: a group is purged only if at least one copy is used in an article. All copies merge into the one used in the most articles (ties: oldest, then lowest id). Groups with no used copy are left alone. Automatic purge is off by default.
|
||||
|
||||
### Card
|
||||
Second card below Version, 20 px gap, same card styling. Page max width 720 px.
|
||||
Third card, below Updates, 20 px gap, same card styling as the others. The board scrolls the page to this card for its states.
|
||||
- Title: "Media cleanup". Description: "Merge identical images so your library stays tidy. Articles that use a copy are switched to the most used one."
|
||||
- "Automatic purge" / helper "Runs in the background. Off by default." / USwitch.
|
||||
- When on: "Every" / helper "Between 1 and 365 days." / number input, suffix "days", default 30.
|
||||
@@ -180,9 +180,9 @@ Duplicates tab: secondary "Purge duplicates" button (merge) next to the sort men
|
||||
|
||||
## Activity retention
|
||||
|
||||
**API:** GET /v1/settings · PATCH /v1/settings (owner only). Visual reference: `source/RenewCMS Screens Activity.dc.html`, group "Settings retention". The board draws the other cards as a dashed "Existing Settings cards" placeholder and gives the page a different subtitle ("Site-wide options. Only owners can change them."), which is not a decision to change the existing one.
|
||||
**API:** GET /v1/settings · PATCH /v1/settings (owner only). Visual reference: `source/RenewCMS Screens Back-office.dc.html`, group "Settings: Retention", drawn by `source/AppFrame.dc.html`.
|
||||
|
||||
A third card below Media cleanup, owner only (hidden for admins), same card styling, 640 px at most as drawn.
|
||||
The last card on the page, below Media cleanup, owner only (hidden for admins and for the read-only Media cleanup view), same card styling and 720 px column as the others.
|
||||
|
||||
- Header: "Retention" / "How long activity is kept." "Saved" (check, success) at the right after a save, fading after 2 s.
|
||||
- "Keep activity for" `USelectMenu`: 30 days, 90 days, 365 days, Forever. Default 90 days. Helper: "Older events are removed automatically." For Forever: "Nothing is removed automatically. The log grows over time."
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
<sc-if value="{{ mob }}">
|
||||
<div style="height:56px;flex-shrink:0;border-bottom:1px solid var(--ui-border);background:var(--ui-bg);display:flex;align-items:center;padding:0 6px;gap:4px;color:var(--ui-text-highlighted)"><span data-act="m-lead" style="position:relative;width:44px;height:44px;display:flex;align-items:center;justify-content:center"><sc-if value="{{ s.navBadge }}"><span aria-label="Update available" style="position:absolute;top:9px;right:9px;width:10px;height:10px;background:var(--ui-info);border:2px solid var(--ui-bg)"></span></sc-if><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:20px;height:20px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ mLead }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ mLead }}.svg') center / contain no-repeat"></span></span><span style="flex:1;min-width:0;font-weight:600;font-size:16px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ s.mTitle }}</span><sc-if value="{{ s.mAction }}"><span data-act="m-action" style="width:44px;height:44px;display:flex;align-items:center;justify-content:center"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:20px;height:20px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ s.mAction }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ s.mAction }}.svg') center / contain no-repeat"></span></span></sc-if></div>
|
||||
</sc-if>
|
||||
<sc-if value="{{ s.offline }}"><div role="status" style="flex-shrink:0;display:flex;align-items:center;gap:10px;padding:10px 16px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted)"><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/wifi-off.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/wifi-off.svg') center / contain no-repeat"></span><span style="flex:1">You're offline. You can keep writing, but saving and publishing will wait until you're back online.</span></div></sc-if>
|
||||
<sc-if value="{{ s.offline }}"><div role="status" style="flex-shrink:0;display:flex;align-items:center;gap:10px;padding:10px 16px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted)"><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/wifi-off.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/wifi-off.svg') center / contain no-repeat"></span><span style="flex:1">{{ offText }}</span></div></sc-if>
|
||||
|
||||
<div style="flex:1;min-height:0;overflow:hidden;display:flex;flex-direction:column">
|
||||
|
||||
@@ -66,6 +66,14 @@
|
||||
</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
<sc-if value="{{ act.show }}">
|
||||
<div style="display:flex;flex-direction:column;gap:10px">
|
||||
<div style="display:flex;align-items:baseline;justify-content:space-between"><span style="font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ act.title }}</span><span data-act="nav-activity" style="color:var(--ui-text-toned);text-decoration:underline;text-underline-offset:3px">View all</span></div>
|
||||
<sc-if value="{{ act.error }}"><div style="display:flex;gap:10px;padding:12px 14px;background:var(--rc-error-soft);color:var(--ui-error)"><span style="display:flex;padding-top:2px"><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/circle-x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-x.svg') center / contain no-repeat;"></span></span><div style="flex:1;display:flex;flex-direction:column;gap:8px"><div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600">Couldn't load recent activity</span><span style="color:var(--ui-text-toned)">Check your connection and try again.</span></div><span role="button" style="height:28px;padding:0 10px;display:flex;align-items:center;justify-content:center;gap:6px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted);font-weight:500;font-size:12px;white-space:nowrap;opacity:1"><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/rotate-cw.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/rotate-cw.svg') center / contain no-repeat;"></span>Try again</span></div></div></sc-if>
|
||||
<sc-if value="{{ act.empty }}"><div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:32px 24px;display:flex;flex-direction:column;align-items:center;gap:6px;text-align:center"><span style="color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:20px;height:20px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/history.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/history.svg') center / contain no-repeat;"></span></span><span style="font-weight:600;color:var(--ui-text-highlighted)">No activity yet</span><span style="color:var(--ui-text-muted);max-width:260px">{{ act.emptyText }}</span></div></sc-if>
|
||||
<sc-if value="{{ act.rows }}"><div style="background:var(--ui-bg);border:1px solid var(--ui-border)"><sc-for list="{{ act.rows }}" as="r" hint-placeholder-count="5"><div style="display:flex;align-items:center;gap:12px;min-height:{{ rowH }};padding:8px 12px;border-top:1px solid var(--ui-border-muted)"><sc-if value="{{ r.skel }}"><span style="width:24px;height:24px;flex-shrink:0;background:var(--ui-bg-accented)"></span><span style="flex:1;height:12px;background:var(--ui-bg-accented);max-width:60%"></span></sc-if><sc-if value="{{ r.s }}"><span style="width:24px;height:24px;flex-shrink:0;display:flex;align-items:center;justify-content:center;background:var(--rc-sand);color:#111;font:600 10px 'IBM Plex Sans',sans-serif">{{ r.ini }}</span><div style="flex:1;min-width:0;display:flex;flex-direction:column"><span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--ui-text)">{{ r.s }}<span style="font-weight:600;color:var(--ui-text-highlighted)">{{ r.em }}</span></span><span style="font-size:12px;color:var(--ui-text-muted)">{{ r.meta }}</span></div><sc-if value="{{ r.fail }}"><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;background:var(--rc-error-soft);color:var(--ui-error);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/circle-x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-x.svg') center / contain no-repeat;"></span>Failed</span></sc-if></sc-if></div></sc-for></div></sc-if>
|
||||
</div>
|
||||
</sc-if>
|
||||
<div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:16px;display:flex;gap:14px;align-items:flex-start"><span style="width:36px;height:36px;flex-shrink:0;display:flex;align-items:center;justify-content:center;background:var(--rc-sand);color:#111"><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/plug.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/plug.svg') center / contain no-repeat"></span></span><div style="flex:1;display:flex;flex-direction:column;gap:4px"><span style="font-weight:600;font-size:14px;color:var(--ui-text-highlighted)">Connect your website</span><span style="color:var(--ui-text-muted);text-wrap:pretty">Your site reads published articles from RenewCMS. Share this guide with whoever builds it.</span><span data-act="nav-integration" style="color:var(--ui-text-highlighted);font-weight:500;text-decoration:underline;text-underline-offset:3px;margin-top:4px">Open the guide</span></div></div>
|
||||
</div>
|
||||
</sc-if>
|
||||
@@ -211,7 +219,7 @@
|
||||
</sc-if>
|
||||
|
||||
<sc-if value="{{ isSettings }}">
|
||||
<div style="padding:{{ pad }};display:flex;flex-direction:column;gap:20px;max-width:720px;overflow:hidden">
|
||||
<div style="padding:{{ pad }};display:flex;flex-direction:column;gap:20px;max-width:720px;flex-shrink:0;margin-top:{{ setScroll }}">
|
||||
<div style="display:flex;flex-direction:column;gap:2px"><span style="font:600 20px/28px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Settings</span><span style="color:var(--ui-text-muted)">Workspace information.</span></div>
|
||||
<div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;align-items:center;justify-content:space-between;gap:16px"><span style="font:600 15px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Version</span><span style="font:400 14px/22px 'IBM Plex Mono',monospace;color:var(--ui-text-toned)">{{ s.upd.v }}</span></div>
|
||||
<div role="region" aria-labelledby="updates-h" style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;flex-direction:column;gap:16px">
|
||||
@@ -223,6 +231,8 @@
|
||||
<sc-if value="{{ s.upd.hint }}"><span style="font-size:12px;color:var(--ui-text-muted);text-wrap:pretty">{{ s.upd.hint }}</span></sc-if>
|
||||
<sc-if value="{{ s.upd.link }}"><span style="align-self:flex-start;display:flex;align-items:center;gap:6px;color:var(--ui-text-highlighted);text-decoration:underline;text-underline-offset:3px;font-weight:500">{{ s.upd.link }}<span aria-hidden="true" style="display:inline-block;width:14px;height:14px;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></span></sc-if>
|
||||
</div>
|
||||
<div role="region" aria-labelledby="cleanup-h" style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;flex-direction:column;gap:16px"><sc-if value="{{ cl.loading }}"><div aria-busy="true" style="display:flex;flex-direction:column;gap:16px"><span style="display:block;width:140px;height:16px;background:var(--ui-bg-accented)"></span><span style="display:block;width:90%;height:13px;background:var(--ui-bg-accented)"></span><span style="display:block;width:55%;height:13px;background:var(--ui-bg-accented)"></span><div style="display:flex;justify-content:space-between;align-items:center"><div style="display:flex;flex-direction:column;gap:6px"><span style="display:block;width:120px;height:13px;background:var(--ui-bg-accented)"></span><span style="display:block;width:180px;height:11px;background:var(--ui-bg-accented)"></span></div><span style="display:block;width:36px;height:20px;background:var(--ui-bg-accented)"></span></div><span style="display:block;width:60%;height:13px;background:var(--ui-bg-accented)"></span><div style="height:1px;background:var(--ui-border)"></div><div style="display:flex;justify-content:space-between;align-items:center"><div style="display:flex;flex-direction:column;gap:6px"><span style="display:block;width:100px;height:13px;background:var(--ui-bg-accented)"></span><span style="display:block;width:220px;height:11px;background:var(--ui-bg-accented)"></span></div><span style="display:block;width:140px;height:32px;background:var(--ui-bg-accented)"></span></div></div></sc-if><sc-if value="{{ cl.ready }}"><div style="display:flex;align-items:flex-start;gap:12px"><div style="flex:1;display:flex;flex-direction:column;gap:2px"><span id="cleanup-h" tabindex="-1" style="align-self:flex-start;font:600 15px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Media cleanup</span><span style="color:var(--ui-text-muted);text-wrap:pretty">Merge identical images so your library stays tidy. Articles that use a copy are switched to the most used one.</span></div><div style="height:24px;display:flex;align-items:center;flex-shrink:0"><sc-if value="{{ cl.saving }}"><span style="display:flex;align-items:center;gap:6px;font-size:12px;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/loader-circle.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat;"></span>Saving…</span></sc-if><sc-if value="{{ cl.saved }}"><span role="status" style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--ui-success)"><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>Saved</span></sc-if></div></div><sc-if value="{{ cl.ro }}"><div style="display:flex;gap:10px;padding:12px 14px;background:var(--rc-info-soft);color:var(--ui-info)"><span style="display:flex;padding-top:2px"><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/info.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat;"></span></span><span style="color:var(--ui-text-toned)">Only owners and admins can change this.</span></div></sc-if><div style="display:flex;flex-direction:{{ rowDir }};align-items:{{ rowAlign }};justify-content:{{ rowJust }};gap:{{ rowGap }}"><div style="display:flex;flex-direction:column;gap:2px;min-width:0"><span style="font-weight:500;color:var(--ui-text-highlighted)">Automatic purge</span><span style="font-size:12px;color:var(--ui-text-muted)">Runs in the background. Off by default.</span></div><div role="switch" aria-checked="{{ cl.on }}" style="display:flex;align-items:center;justify-content:center;width:{{ cl.swW }};height:{{ cl.swH }};flex-shrink:0;opacity:{{ cl.swOp }}"><div style="position:relative;width:36px;height:20px;border:1px solid {{ cl.swBd }};background:{{ cl.swBg }};outline:{{ cl.swRing }};outline-offset:2px"><div style="position:absolute;top:1px;left:{{ cl.knobL }};width:16px;height:16px;background:var(--ui-bg);border:1px solid {{ cl.knobBd }};box-sizing:border-box"></div></div></div></div><sc-if value="{{ cl.on }}"><div style="display:flex;flex-direction:{{ rowDir }};align-items:{{ rowAlign }};justify-content:{{ rowJust }};gap:{{ rowGap }}"><div style="display:flex;flex-direction:column;gap:2px;min-width:0"><span style="font-weight:500;color:var(--ui-text-highlighted)">Every</span><span style="font-size:12px;color:var(--ui-text-muted)">Between 1 and 365 days.</span></div><div style="display:flex;flex-direction:column;gaLine truncated
|
||||
<sc-if value="{{ rt.show }}"><div role="region" aria-labelledby="retention-h" style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;flex-direction:column;gap:16px"><div style="display:flex;align-items:flex-start;gap:12px"><div style="flex:1;display:flex;flex-direction:column;gap:2px"><span id="retention-h" tabindex="-1" style="align-self:flex-start;font:600 15px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Retention</span><span style="color:var(--ui-text-muted);text-wrap:pretty">How long activity is kept. Only owners can change it.</span></div><div style="height:24px;display:flex;align-items:center;flex-shrink:0"><sc-if value="{{ rt.saving }}"><span style="display:flex;align-items:center;gap:6px;font-size:12px;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/loader-circle.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat;"></span>Saving…</span></sc-if><sc-if value="{{ rt.saved }}"><span role="status" style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--ui-success)"><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>Saved</span></sc-if></div></div><sc-if value="{{ rt.err }}"><div style="display:flex;gap:10px;padding:12px 14px;background:var(--rc-error-soft);color:var(--ui-error)"><span style="display:flex;padding-top:2px"><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/circle-x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-x.svg') center / contain no-repeat;"></span></span><div style="flex:1;display:flex;flex-direction:column;gap:8px"><div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600">Couldn't save the retention period</span><span style="color:var(--ui-text-toned)">Your previous setting is still in place.</span></div><span role="button" style="height:28px;padding:0 10px;display:flex;align-items:center;justify-content:center;gap:6px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted);font-weight:500;font-size:12px;white-space:nowrap;opacity:1">Try again</span></div></div></sc-if><div style="display:flex;flex-direction:{{ rowDir }};align-items:{{ rowAlign }};justify-content:{{ rowJust }};gap:{{ rowGap }}"><div style="display:flex;flex-direction:column;gap:2px;min-width:0"><span style="font-weight:500;color:var(--ui-text-highlighted)">Keep activity for</span><span style="font-size:12px;color:var(--ui-text-muted)">{{ rt.help }}</span></div><div style="display:flex;align-items:center;width:{{ rt.selW }};min-height:{{ inH }};padding:0 12px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);font-size:{{ inF }};color:var(--ui-text-highlighted)"><span style="flex:1">{{ rt.val }}</span><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/chevron-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;color:var(--ui-text-muted)"></span></div></div><sc-if value="{{ rt.shorten }}"><div style="display:flex;gap:10px;padding:12px 14px;background:var(--rc-warning-soft);color:var(--ui-warning)"><span style="display:flex;padding-top:2px"><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/triangle-alert.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/triangle-alert.svg') center / contain no-repeat;"></span></span><div style="flex:1;display:flex;flex-direction:column;gap:8px"><div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600">Events older than 30 days will be removed</span><span style="color:var(--ui-text-toned)">This happens at the next cleanup on 7 Oct 2026, 03:00. It can't be undone.</span></div><div style="display:flex;gap:8px"><span role="button" style="height:28px;padding:0 10px;display:flex;align-items:center;justify-content:center;gap:6px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted);font-weight:500;font-size:12px;white-space:nowrap;opacity:1">Apply 30 days</span><span role="button" style="height:28px;padding:0 10px;display:flex;align-items:center;justify-content:center;gap:6px;border:1Line truncated
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
@@ -323,6 +333,7 @@
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
{{ purgeDlg }}
|
||||
<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 }}">
|
||||
<sc-for list="{{ toasts }}" as="t" hint-placeholder-count="1">
|
||||
@@ -338,7 +349,7 @@ class Component extends DCLogic {
|
||||
const s = this.props.s || {}
|
||||
const m = !!this.props.mobile
|
||||
const k = s.kind || 'home'
|
||||
const navItems = [['home', 'house', 'Home'], ['articles', 'file-text', 'Articles'], ['media', 'images', 'Media'], ['users', 'users', 'Users'], ['integration', 'plug', 'Integration'], ['settings', 'settings', 'Settings']]
|
||||
const navItems = s.role === 'user' ? [['home', 'house', 'Home'], ['articles', 'file-text', 'Articles'], ['media', 'images', 'Media'], ['activity', 'history', 'Activity']] : [['home', 'house', 'Home'], ['articles', 'file-text', 'Articles'], ['media', 'images', 'Media'], ['users', 'users', 'Users'], ['activity', 'history', 'Activity'], ['integration', 'plug', 'Integration'], ['settings', 'settings', 'Settings']]
|
||||
const nav = navItems.map(([key, icon, t]) => ({ key, icon, t, bg: s.nav === key ? 'var(--rc-sand)' : 'transparent', badge: key === 'settings' && !!s.navBadge, fg: s.nav === key ? '#111111' : 'var(--ui-text-toned)', fw: s.nav === key ? 500 : 400 }))
|
||||
const crumbs = (s.crumbs || []).map((t, i, a) => ({ t, sep: i > 0, fg: i === a.length - 1 ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)', fw: i === a.length - 1 ? 500 : 400 }))
|
||||
const L = s.list || {}
|
||||
@@ -406,6 +417,71 @@ class Component extends DCLogic {
|
||||
const rsMap = { sent: { bg: 'var(--rc-success-soft)', fg: 'var(--ui-success)', icon: 'mail-check', title: 'Reset link sent', text: 'Check marta@renew.example. The link works once, within 1 hour.' }, limited: { bg: 'var(--rc-warning-soft)', fg: 'var(--ui-warning)', icon: 'clock-alert', title: 'Too many requests', text: 'You\'ve asked for several links recently. Wait a few minutes, then try again.' }, error: { bg: 'var(--rc-error-soft)', fg: 'var(--ui-error)', icon: 'circle-x', title: 'Couldn\'t send the link', text: 'Check your connection and try again.' } }
|
||||
const rsNote = rsMap[rs] || null
|
||||
const rsBtn = { t: rs === 'sending' ? 'Sending…' : rs === 'sent' ? 'Send again' : 'Send reset link', icon: rs === 'sending' ? 'loader-circle' : 'mail', op: rs === 'limited' ? '0.4' : '1' }
|
||||
const E5 = [['Marta Alves', 'Published the article ', 'Spring release', '2 min ago', 1], ['Marta Alves', 'Edited the article ', 'Why we reuse original floorboards', '29 min ago', 1], ['Not signed in', 'Failed login attempt for ', 'alex@example.com', '1 h ago', 0], ['Jonas Weber', 'Revoked API key ', 'Mobile app', '2 h ago', 1], ['Scheduler', 'Ran the nightly backup', '', '7 h ago', 1]]
|
||||
const E4 = [['Marta Alves', 'Published the article ', 'Spring release', '2 min ago', 1], ['Marta Alves', 'Edited the article ', 'Why we reuse original floorboards', '29 min ago', 1], ['Marta Alves', 'Deleted the image ', 'old-banner.png', 'Yesterday', 1], ['Marta Alves', 'Uploaded the image ', 'floorboards-copy.jpg', '3 Oct 2026', 1]]
|
||||
const ho = s.home || {}, own = s.role === 'user'
|
||||
const actRows = ho.act === 'loading' ? Array.from({ length: 5 }, () => ({ skel: true })) : ho.act === 'ok' ? (own ? E4 : E5).map(([a, t, em, rel, ok]) => ({ s: t, em, meta: own ? rel : a + ' · ' + rel, fail: !ok, ini: a === 'Scheduler' ? 'SY' : a === 'Not signed in' ? '?' : a.split(' ').map(w => w[0]).join('') })) : null
|
||||
const act = { show: !!ho.act && k === 'home' && !s.boot, title: own ? 'Your recent activity' : 'Recent activity', rows: actRows, error: ho.act === 'error', empty: ho.act === 'empty', emptyText: own ? 'Your actions will appear here.' : 'Actions on your site will appear here.' }
|
||||
const C = s.cleanup || {}, cOn = !!C.on, cDis = !!(C.ro || C.offline || C.loading), cErr = !!(C.invalid || C.saveErr)
|
||||
const cl = { loading: !!C.loading, ready: !C.loading, ro: !!C.ro, notRo: !C.ro, on: cOn, saving: !!C.saving, saved: !!C.saved && !C.saving, swW: m ? '44px' : '36px', swH: m ? '44px' : '24px', swOp: cDis ? '0.4' : '1', swRing: C.focusSw ? '2px solid var(--ui-primary)' : '0', swBd: cOn ? 'var(--ui-primary)' : 'var(--ui-border-accented)', swBg: cOn ? 'var(--ui-primary)' : 'var(--ui-bg-accented)', knobL: cOn ? '17px' : '1px', knobBd: cOn ? 'transparent' : 'var(--ui-border-accented)', daysW: m ? '100%' : '140px', daysAlign: m ? 'stretch' : 'flex-end', daysBd: cErr ? 'var(--ui-error)' : C.focusDays ? 'var(--ui-primary)' : 'var(--ui-border-accented)', daysSh: C.focusDays && !cErr ? '0 0 0 1px var(--ui-primary)' : 'none', daysOp: cDis ? '0.4' : '1', daysVal: C.days ?? '30', hasErr: cErr, errTxt: C.invalid ? 'Enter a whole number of days between 1 and 365.' : 'Couldn\'t save. Try again in a moment.', errMax: m ? 'none' : '300px', errTA: m ? 'left' : 'right', lastLine: C.last ? 'Last purge: ' + C.last.d + ' · ' + C.last.c + ' copies merged · ' + C.last.a + ' articles updated' : 'Never run', nextLine: 'Next purge: ' + (C.next || '14 Jul 2026'), purgeOp: C.offline || C.loading ? '0.4' : '1', btnShrink: m ? '1' : '0' }
|
||||
const R = s.ret || {}, rd = R.days || '90', fv = rd === '0'
|
||||
const rt = { show: !s.noRet, saving: !!R.saving, saved: !!R.saved, err: !!R.err, shorten: !!R.shorten, val: { '30': '30 days', '90': '90 days', '365': '365 days', '0': 'Forever' }[R.shorten ? '30' : rd], help: fv ? 'Nothing is removed automatically. The log grows over time.' : 'Older events are removed automatically.', last: fv ? 'Last cleanup: Never' : 'Last cleanup: 6 Oct 2026, 03:00 · 142 events removed', next: fv ? 'Next cleanup: Not scheduled' : 'Next cleanup: 7 Oct 2026, 03:00', selW: m ? '100%' : '160px' }
|
||||
const purgeDlg = (() => {
|
||||
if (!s.purge) return null
|
||||
const h = React.createElement, bh = m ? 44 : 32
|
||||
const P = t => Object.fromEntries(t.split(';').filter(x => x.trim()).map(x => { const i = x.indexOf(':'); const k = x.slice(0, i).trim(); return [k.startsWith('--') ? k : k.replace(/-([a-z])/g, (_, c) => c.toUpperCase()), x.slice(i + 1).trim()] }))
|
||||
const D = (t, ...c) => h('div', { style: P(t) }, ...c)
|
||||
const S = (t, ...c) => h('span', { style: P(t) }, ...c)
|
||||
const I = (n, z = 16, t = '') => h('span', { 'aria-hidden': 'true', style: { display: 'inline-block', flexShrink: 0, width: z, height: z, backgroundColor: 'currentColor', WebkitMask: 'url(https://api.iconify.design/lucide/' + n + '.svg) center / contain no-repeat', mask: 'url(https://api.iconify.design/lucide/' + n + '.svg) center / contain no-repeat', ...(t ? P(t) : {}) } })
|
||||
const B = (label, o = {}) => {
|
||||
const k = o.k || 'outline'
|
||||
const bg = k === 'primary' ? 'var(--ui-primary)' : k === 'danger' ? 'var(--ui-error)' : 'var(--ui-bg)'
|
||||
const fg = k === 'primary' || k === 'danger' ? 'var(--ui-bg)' : 'var(--ui-text-highlighted)'
|
||||
const bd = k === 'primary' || k === 'danger' ? bg : 'var(--ui-border-accented)'
|
||||
return S('height:' + (o.h || bh) + 'px;padding:0 ' + (o.px || 12) + 'px;display:flex;align-items:center;justify-content:center;gap:6px;background:' + bg + ';color:' + fg + ';border:1px solid ' + bd + ';font-weight:500;font-size:13px;white-space:nowrap;opacity:' + (o.dis ? 0.4 : 1) + ';' + (o.full ? 'flex:1;' : ''), o.icon && I(o.icon, 16), label)
|
||||
}
|
||||
const CHECK = 'conic-gradient(var(--ui-bg-accented) 25%, var(--ui-bg) 0 50%, var(--ui-bg-accented) 0 75%, var(--ui-bg) 0) 0 0/16px 16px'
|
||||
const TONES = ['var(--rc-sand)', 'var(--ui-bg-accented)', 'color-mix(in srgb, var(--ui-border-accented) 35%, var(--ui-bg))', 'var(--ui-bg-elevated)']
|
||||
const mark = h('svg', { width: 20, height: 20, viewBox: '0 0 16 16', shapeRendering: 'crispEdges' }, h('rect', { width: 16, height: 16, fill: '#111' }), h('path', { fill: '#fff', fillRule: 'evenodd', d: 'M4 3h6v1h1v1h1v3h-1v1h-1v1h1v1h1v2h-2v-2h-1v-1H6v3H4zm2 2v3h4V5z' }))
|
||||
const Th = (im, t = '') => D('position:relative;overflow:hidden;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted);background:' + (im.alpha ? CHECK : 'repeating-linear-gradient(135deg, rgb(128 128 128 / 0.12) 0 1px, transparent 1px 12px), ' + TONES[im.tone % 4]) + ';' + t, im.svg ? mark : I('image', 16, 'opacity:0.55'))
|
||||
const IMX = { 12: { tone: 0 }, 7: { tone: 2 }, 2: { tone: 1 }, 4: { tone: 3, alpha: true, svg: true }, 11: { tone: 1 } }
|
||||
const Alert = (tone, icon, text, title) => D('display:flex;gap:10px;padding:12px 14px;background:var(--rc-' + tone + '-soft);color:var(--ui-' + tone + ')', S('display:flex;padding-top:2px', I(icon)), D('flex:1;display:flex;flex-direction:column;gap:2px', title && S('font-weight:600', title), S('color:var(--ui-text-toned)', text)))
|
||||
const hl = 'color:var(--ui-text-highlighted)'
|
||||
const mono = "font-family:'IBM Plex Mono',monospace"
|
||||
|
||||
const SkelBar = (w, hh = 13) => S('display:block;width:' + w + ';height:' + hh + 'px;background:var(--ui-bg-accented)')
|
||||
// ---------- dialog
|
||||
const G = [{ im: 12, n: 'floorboards.jpg', u: 4, c: 2 }, { im: 7, n: 'IMG_4471.jpg', u: 2, c: 1 }, { im: 2, n: 'kitchen-after.webp', u: 3, c: 3 }, { im: 4, n: 'logo-renew.svg', u: 5, c: 3 }, { im: 11, n: 'lime-plaster.jpg', u: 2, c: 1 }]
|
||||
const pl = (n, w) => n + ' ' + w + (n === 1 ? '' : 's')
|
||||
const PV = { groups: 4, copies: 9, arts: 12, list: G.slice(0, 4), more: 0, ...(s.purge && s.purge.pv) }
|
||||
const summary = pv => pl(pv.groups, 'group') + ' · ' + pv.copies + (pv.copies === 1 ? ' copy' : ' copies') + ' will be deleted · ' + pl(pv.arts, 'article') + ' will be updated'
|
||||
const GList = pv => D('border:1px solid var(--ui-border)', ...pv.list.map((g, i) => D('display:flex;align-items:center;gap:12px;padding:8px 12px;border-top:' + (i ? '1px solid var(--ui-border-muted)' : '0'), Th(IMX[g.im], 'width:40px;height:30px;flex-shrink:0;border:1px solid var(--ui-border)'), D('flex:1;min-width:0;display:flex;flex-direction:column', S('font-weight:500;' + hl + ';overflow:hidden;text-overflow:ellipsis;white-space:nowrap', g.n), S('font-size:12px;color:var(--ui-text-muted)', 'Kept · used in ' + pl(g.u, 'article'))), S('font-size:12px;color:var(--ui-text-toned);white-space:nowrap;text-align:right', pl(g.c, 'copy').replace('copys', 'copies') + ' merged'))), pv.more > 0 && D('padding:8px 12px;border-top:1px solid var(--ui-border-muted);color:var(--ui-text-muted)', 'and ' + pv.more + ' more groups'))
|
||||
const Link = () => S('display:inline-flex;align-items:center;gap:4px;align-self:flex-start;font-weight:500;' + hl + ';text-decoration:underline;text-underline-offset:3px;min-height:' + (m ? 44 : 20) + 'px', 'Review in Duplicates', I('arrow-right', 14))
|
||||
const dg = s.purge
|
||||
const Shell = (o, body) => D('position:absolute;inset:0;z-index:50;background:var(--rc-overlay);display:flex;align-items:' + (m ? 'flex-end' : 'flex-start') + ';justify-content:center;padding:' + (m ? 16 : 140) + 'px 16px 16px',
|
||||
D('width:' + (m ? '100%' : '448px') + ';max-height:100%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column',
|
||||
o.center ? D('padding:28px 24px 0;display:flex;flex-direction:column;align-items:center;gap:12px;text-align:center', S('width:44px;height:44px;display:flex;align-items:center;justify-content:center;background:var(--rc-info-soft);color:var(--ui-info)', I(o.icon, 20)), S('font:600 16px/24px \'IBM Plex Sans\',sans-serif;' + hl, o.title), S('color:var(--ui-text-muted);text-wrap:pretty;max-width:340px', o.desc))
|
||||
: D('padding:20px 24px 0;display:flex;flex-direction:column;gap:4px', S('font:600 16px/24px \'IBM Plex Sans\',sans-serif;' + hl, o.title), o.desc && S('color:var(--ui-text-muted);text-wrap:pretty', o.desc)),
|
||||
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, full: false }), o.confirm && S('height:' + bh + 'px;padding:0 12px;display:flex;align-items:center;justify-content:center;gap:6px;background:var(--ui-error);color:var(--ui-bg);font-weight:500;opacity:' + (o.cdis ? 0.4 : 1), o.loading ? I('loader-circle') : o.cicon && I(o.cicon), o.confirm))))
|
||||
const DESC = 'Articles that use a duplicate will be switched to the most used copy of that image, and the other copies will be deleted. This can\'t be undone.'
|
||||
const DSkel = () => [SkelBar('80%', 16), D('border:1px solid var(--ui-border)', ...[0, 1, 2].map(i => D('display:flex;align-items:center;gap:12px;padding:8px 12px;border-top:' + (i ? '1px solid var(--ui-border-muted)' : '0'), SkelBar('40px', 30), D('flex:1;display:flex;flex-direction:column;gap:6px', SkelBar('45%'), SkelBar('60%', 11)))))]
|
||||
const Dialog = () => {
|
||||
const k = dg.k, pv = PV
|
||||
const gp = 'Purge ' + pl(pv.groups, 'group')
|
||||
const note = (tone, icon, t) => Alert(tone, icon, t)
|
||||
if (k === 'loading') return Shell({ title: 'Purge duplicates?', desc: DESC, cancel: 'Cancel', confirm: 'Purge duplicates', cdis: true }, DSkel())
|
||||
if (k === 'nothing') return Shell({ center: true, icon: 'copy', title: 'Nothing to purge', desc: 'No duplicate is used in an article, so there is nothing to merge.', cancel: 'Close' }, null)
|
||||
const body = [S('font-weight:500;' + hl, summary(pv)), GList(pv), Link()]
|
||||
if (k === 'confirm') return Shell({ title: 'Purge duplicates?', desc: DESC, cancel: 'Cancel', confirm: gp }, body)
|
||||
if (k === 'progress') return Shell({ title: 'Purge duplicates?', desc: DESC, cancel: 'Cancel', confirm: 'Purging…', loading: true }, body)
|
||||
if (k === 'error') return Shell({ title: 'Purge duplicates?', desc: DESC, cancel: 'Cancel', confirm: gp }, [note('error', 'circle-x', 'Couldn\'t purge the duplicates. Nothing was changed. Try again in a moment.'), ...body])
|
||||
if (k === 'partial') return Shell({ title: 'Purge duplicates?', desc: DESC, cancel: 'Close', confirm: 'Retry', cicon: 'rotate-cw' }, [note('warning', 'triangle-alert', 'Some groups couldn\'t be merged. 3 of 4 groups were purged. Try again to finish.'), ...body])
|
||||
if (k === 'conflict') return Shell({ title: 'Purge duplicates?', desc: DESC, cancel: 'Cancel', confirm: gp }, [note('warning', 'triangle-alert', 'The library changed, so nothing was purged. Check the new summary and try again.'), ...body])
|
||||
}
|
||||
|
||||
return Dialog()
|
||||
})()
|
||||
const toasts = (s.toasts || []).map(t => ({ ...t, fg: 'var(--ui-' + t.tone + ')' }))
|
||||
const endpoints = [
|
||||
{ path: '/v1/article/', desc: 'Lists every online article.', l1: 'var(--ui-primary)', code: "const res = await fetch('https://cms.renew.example/v1/article/')\nconst articles = await res.json()" },
|
||||
@@ -417,7 +493,7 @@ class Component extends DCLogic {
|
||||
const edReady = k === 'editor' && !e.loading && !e.notfound
|
||||
const edTitle = e.title || 'How we price a bathroom renovation'
|
||||
return {
|
||||
cr, s, nav, crumbs, who, ini, pf, dn, dnSave, rsNote, rsBtn,
|
||||
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)',
|
||||
chipBg: s.userMenu || s.nav === 'profile' ? 'var(--rc-sand)' : 'transparent', chipFg: s.userMenu || s.nav === 'profile' ? '#111111' : 'var(--ui-text-highlighted)',
|
||||
isSettings: k === 'settings', isProfile: k === 'profile', pfLoading: k === 'profile' && !!pf.loading, pfReady: k === 'profile' && !pf.loading,
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<meta name="design_doc_mode" content="canvas">
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<style>body{margin:0;font-family:'IBM Plex Sans',sans-serif;font-size:13px;line-height:20px}*{box-sizing:border-box}a{color:inherit;text-underline-offset:3px}a:hover{opacity:.8}</style>
|
||||
</helmet>
|
||||
<div ref="{{ rootRef }}" data-screen-label="Activity screens" style="background:var(--rc-canvas);color:var(--ui-text);padding:64px;display:flex;flex-direction:column;gap:72px;width:max-content;min-width:100%">
|
||||
<div style="display:flex;align-items:flex-end;gap:32px;max-width:1400px">
|
||||
<div style="flex:1;display:flex;flex-direction:column;gap:10px">
|
||||
<h1 style="margin:0;font:600 36px/44px 'IBM Plex Sans',sans-serif;letter-spacing:-0.02em;color:var(--ui-text-highlighted)">Screen: Activity log</h1>
|
||||
<p style="margin:0;max-width:880px;font-size:16px;line-height:26px;color:var(--ui-text-toned);text-wrap:pretty">New /activity screen, a Recent activity card for Home, and a Retention card for Settings. 1440 px at 50%, 390 px at 75%. Owner and admin see everything; regular users get "My activity". The theme switch shows every state in dark mode. Copy, component mapping and API calls are at the bottom.</p>
|
||||
</div>
|
||||
<div role="radiogroup" aria-label="Theme" style="display:flex;border:1px solid var(--ui-border-accented);background:var(--ui-bg)">
|
||||
<sc-for list="{{ modes }}" as="m" hint-placeholder-count="3">
|
||||
<button role="radio" aria-checked="{{ m.on }}" onClick="{{ m.pick }}" style="all:unset;cursor:pointer;height:36px;padding:0 14px;display:flex;align-items:center;font-weight:500;font-size:14px;background:{{ m.bg }};color:{{ m.fg }}" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:2px">{{ m.label }}</button>
|
||||
</sc-for>
|
||||
</div>
|
||||
</div>
|
||||
<sc-for list="{{ groups }}" as="g" hint-placeholder-count="5">
|
||||
<section data-screen-label="{{ g.label }}" style="display:flex;flex-direction:column;gap:28px">
|
||||
<div style="display:flex;flex-direction:column;gap:6px;max-width:1100px">
|
||||
<h2 style="margin:0;font:600 28px/36px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ g.title }}</h2>
|
||||
<div style="display:flex;gap:16px;flex-wrap:wrap;font:400 14px/22px 'IBM Plex Mono',monospace;color:var(--ui-text-toned)"><span>{{ g.route }}</span><span style="color:var(--ui-text-muted)">{{ g.api }}</span></div>
|
||||
</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:40px;align-items:flex-start">
|
||||
<sc-for list="{{ g.states }}" as="st" hint-placeholder-count="4">
|
||||
<div style="display:flex;flex-direction:column;gap:12px">
|
||||
<div style="display:flex;flex-direction:column;gap:2px;max-width:1040px"><span style="font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ st.name }}</span><span style="font-size:13px;color:var(--ui-text-muted)">{{ st.trigger }}</span></div>
|
||||
<div style="display:flex;gap:24px;align-items:flex-start">
|
||||
<div style="width:720px;height:450px;overflow:hidden;border:1px solid var(--ui-border);flex-shrink:0"><div style="width:1440px;height:900px;zoom:0.5"><dc-import name="ActivityFrame" s="{{ st.s }}" mobile="{{ false }}" hint-size="1440px,900px"></dc-import></div></div>
|
||||
<div style="width:293px;height:633px;overflow:hidden;border:1px solid var(--ui-border);flex-shrink:0"><div style="width:390px;height:844px;zoom:0.75"><dc-import name="ActivityFrame" s="{{ st.m }}" mobile="{{ true }}" hint-size="390px,844px"></dc-import></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
</section>
|
||||
</sc-for>
|
||||
|
||||
<section style="display:flex;flex-direction:column;gap:20px;max-width:1240px">
|
||||
<h2 style="margin:0;font:600 28px/36px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Copy per state</h2>
|
||||
<div style="background:var(--ui-bg);border:1px solid var(--ui-border)">
|
||||
<sc-for list="{{ copy }}" as="r" hint-placeholder-count="5"><div style="display:grid;grid-template-columns:220px minmax(0,1fr);padding:10px 12px;border-top:1px solid var(--ui-border);line-height:20px"><span style="font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ r.a }}</span><span style="color:var(--ui-text-highlighted);text-wrap:pretty">{{ r.b }}</span></div></sc-for>
|
||||
</div>
|
||||
</section>
|
||||
<section style="display:flex;flex-direction:column;gap:20px;max-width:1240px">
|
||||
<h2 style="margin:0;font:600 28px/36px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Component inventory (Nuxt UI v4)</h2>
|
||||
<div style="background:var(--ui-bg);border:1px solid var(--ui-border)">
|
||||
<sc-for list="{{ comps }}" as="r" hint-placeholder-count="5"><div style="display:grid;grid-template-columns:220px minmax(0,1fr);padding:10px 12px;border-top:1px solid var(--ui-border);line-height:20px"><span style="font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ r.a }}</span><span style="color:var(--ui-text-highlighted);text-wrap:pretty">{{ r.b }}</span></div></sc-for>
|
||||
</div>
|
||||
</section>
|
||||
<section style="display:flex;flex-direction:column;gap:20px;max-width:1240px">
|
||||
<h2 style="margin:0;font:600 28px/36px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">API calls</h2>
|
||||
<div style="background:var(--ui-bg);border:1px solid var(--ui-border)">
|
||||
<sc-for list="{{ api }}" as="r" hint-placeholder-count="5"><div style="display:grid;grid-template-columns:220px minmax(0,1fr);padding:10px 12px;border-top:1px solid var(--ui-border);line-height:20px"><span style="font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ r.a }}</span><span style="color:var(--ui-text-highlighted);text-wrap:pretty">{{ r.b }}</span></div></sc-for>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props="{"theme":{"editor":"enum","options":["system","light","dark"],"default":"light","tsType":"'system' | 'light' | 'dark'"}}">
|
||||
class Component extends DCLogic {
|
||||
state = { mode: this.props.theme ?? 'light', spec: { copy: [], comps: [], api: [] } }
|
||||
rootRef = React.createRef()
|
||||
light = { '--ui-bg': '#ffffff', '--ui-bg-muted': '#fafafa', '--ui-bg-elevated': '#f3f2f0', '--ui-bg-accented': '#e6e4e0', '--ui-bg-inverted': '#111111', '--ui-text-dimmed': '#999999', '--ui-text-muted': '#666666', '--ui-text-toned': '#444444', '--ui-text': '#222222', '--ui-text-highlighted': '#111111', '--ui-text-inverted': '#ffffff', '--ui-border': '#e6e4e0', '--ui-border-muted': '#f3f2f0', '--ui-border-accented': '#8a8783', '--ui-primary': '#111111', '--ui-secondary': '#635643', '--ui-success': '#315b33', '--ui-info': '#34536d', '--ui-warning': '#6c4700', '--ui-error': '#882c21', '--rc-sand': '#e8dfd1', '--rc-on-sand': '#111111', '--rc-success-soft': '#eaefea', '--rc-info-soft': '#ebeef0', '--rc-warning-soft': '#f0ede6', '--rc-error-soft': '#f3eae9', '--rc-overlay': 'rgb(10 10 10 / 0.4)', '--rc-shadow-pop': '0 4px 16px rgb(17 17 17 / 0.08), 0 1px 2px rgb(17 17 17 / 0.06)', '--rc-shadow-modal': '0 16px 48px rgb(17 17 17 / 0.18)', '--rc-canvas': '#ecebe8' }
|
||||
dark = { '--ui-bg': '#111111', '--ui-bg-muted': '#0a0a0a', '--ui-bg-elevated': '#1a1a1a', '--ui-bg-accented': '#222222', '--ui-bg-inverted': '#fafafa', '--ui-text-dimmed': '#666666', '--ui-text-muted': '#999999', '--ui-text-toned': '#cfccc7', '--ui-text': '#e6e4e0', '--ui-text-highlighted': '#fafafa', '--ui-text-inverted': '#111111', '--ui-border': '#333333', '--ui-border-muted': '#222222', '--ui-border-accented': '#666666', '--ui-primary': '#fafafa', '--ui-secondary': '#d4c5b0', '--ui-success': '#a3caa3', '--ui-info': '#a5c2db', '--ui-warning': '#dfb67d', '--ui-error': '#fba394', '--rc-sand': '#d4c5b0', '--rc-on-sand': '#111111', '--rc-success-soft': '#1f241f', '--rc-info-soft': '#202428', '--rc-warning-soft': '#2a251e', '--rc-error-soft': '#2c201f', '--rc-overlay': 'rgb(0 0 0 / 0.6)', '--rc-shadow-pop': '0 4px 16px rgb(0 0 0 / 0.5)', '--rc-shadow-modal': '0 16px 48px rgb(0 0 0 / 0.6)', '--rc-canvas': '#1c1c1c' }
|
||||
resolved() {
|
||||
if (this.state.mode === 'system') return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
return this.state.mode
|
||||
}
|
||||
apply() {
|
||||
const el = this.rootRef.current
|
||||
if (!el) return
|
||||
const vars = this.resolved() === 'dark' ? this.dark : this.light
|
||||
for (const k in vars) el.style.setProperty(k, vars[k])
|
||||
document.body.style.background = vars['--rc-canvas']
|
||||
}
|
||||
componentDidMount() {
|
||||
this.apply()
|
||||
import('./activity-spec.js').then(x => this.setState({ spec: { copy: x.copy, comps: x.comps, api: x.api } })).catch(() => {})
|
||||
}
|
||||
componentDidUpdate(p) {
|
||||
if (p.theme !== this.props.theme && this.props.theme) this.setState({ mode: this.props.theme })
|
||||
this.apply()
|
||||
}
|
||||
groups() {
|
||||
const W = (name, trigger, o = {}, mo = {}) => ({ name, trigger, s: o, m: { ...o, ...mo } })
|
||||
const U = { role: 'user' }
|
||||
const f1 = { cat: 'Authentication', out: 'Failed', range: '30 days' }
|
||||
return [
|
||||
{ label: 'Activity, owner and admin', title: 'Activity, owner and admin', route: '/activity', api: 'GET /v1/activity · GET /v1/activity/summary', states: [
|
||||
W('Default, large dataset', 'Newest first, day separators, a security row, 1,284 events across 52 pages. Hover on the first row shows the exact time.', { tip: 1 }),
|
||||
W('Loading', 'Skeleton strip and table while both requests are in flight.', { state: 'loading' }),
|
||||
W('Zero-value summary strip', 'Quiet period: Failed and Security show a calm 0.', { sum: 'zero' }),
|
||||
W('Filtered, card applied', 'Failed actions card clicked, chips and Clear all. Mobile shows the count on Filters.', { f: f1, card: 2, count: 6, total: '6', ids: [3], sum: 'default' }, {}),
|
||||
W('Mobile filters sheet', 'Filters open as a bottom sheet (desktop uses the inline bar above).', { f: { ...f1, range: 'Custom' }, drawer: 1, count: 6, total: '6', ids: [3] }),
|
||||
W('Empty, no activity yet', 'New site. Export is disabled.', { state: 'none', sum: 'none' }),
|
||||
W('Empty, filters match nothing', 'Offers Clear filters.', { state: 'nomatch', f: { q: 'sitemap', cat: 'Media', out: 'Failed' }, total: '0' }),
|
||||
W('Error with retry', 'Request failed. The summary strip still shows if its call succeeded.', { state: 'error' }),
|
||||
W('Forbidden (403)', 'API returned 403 for this role or filter.', { state: 'forbidden', role: 'user' })
|
||||
] },
|
||||
{ label: 'Details slide-over', title: 'Details slide-over and bottom sheet', route: 'Row click', api: 'Uses the item from GET /v1/activity', states: [
|
||||
W('Edit with a diff', 'Title change and "Body edited (+312 characters)". No body text.', { panel: 2 }),
|
||||
W('Settings diff, long text', 'Long values clamp to two lines with Show more.', { panel: 11 }),
|
||||
W('Failed action, very long target title', 'Failure reason shown, target title wraps.', { panel: 9 }),
|
||||
W('Deleted target, no diff', 'Struck-through target with a Deleted badge.', { panel: 8 }),
|
||||
W('System-initiated, no human actor', 'Scheduler with a System badge. IP and browser not applicable.', { panel: 5 }),
|
||||
W('Security event, failed login', 'Actor not signed in. Restrained shield marker.', { panel: 3 })
|
||||
] },
|
||||
{ label: 'My activity', title: 'My activity, regular user', route: '/activity', api: 'GET /v1/activity (server scoped to the user)', states: [
|
||||
W('Default', 'No summary strip, no actor filter, no Export. Title becomes My activity.', U),
|
||||
W('Empty', 'The user has not done anything yet.', { ...U, state: 'none' }),
|
||||
W('Details', 'Same slide-over, own event.', { ...U, panel: 1 })
|
||||
] },
|
||||
{ label: 'Home card', title: 'Home, Recent activity card', route: '/', api: 'GET /v1/activity?size=5', states: [
|
||||
W('Owner or admin', 'Last 5 events with the actor name.', { kind: 'home' }),
|
||||
W('Regular user', 'Own events only.', { kind: 'home', ...U }),
|
||||
W('Loading', '', { kind: 'home', card: 'loading' }),
|
||||
W('Error', '', { kind: 'home', card: 'error' }),
|
||||
W('Empty', '', { kind: 'home', card: 'empty', ...U })
|
||||
] },
|
||||
{ label: 'Settings retention', title: 'Settings, Retention (owner only)', route: '/settings', api: 'GET /v1/settings · PATCH /v1/settings', states: [
|
||||
W('Default, 90 days', 'Saves on change.', { kind: 'settings', saved: true }),
|
||||
W('Shortening the period', 'Confirms before older events are removed.', { kind: 'settings', ret: '90', shorten: true }),
|
||||
W('Forever', 'No cleanup scheduled.', { kind: 'settings', ret: '0' }),
|
||||
W('Save error', '', { kind: 'settings', err: true })
|
||||
] }
|
||||
]
|
||||
}
|
||||
renderVals() {
|
||||
const cur = this.state.mode
|
||||
const spec = this.state.spec
|
||||
const rows = a => a.map(r => ({ a: r[0], b: r[1] }))
|
||||
return {
|
||||
rootRef: this.rootRef,
|
||||
modes: ['light', 'dark', 'system'].map(k => ({ label: k[0].toUpperCase() + k.slice(1), on: cur === k, bg: cur === k ? 'var(--ui-primary)' : 'var(--ui-bg)', fg: cur === k ? 'var(--ui-bg)' : 'var(--ui-text-highlighted)', pick: () => this.setState({ mode: k }) })),
|
||||
groups: this.groups(),
|
||||
copy: rows(spec.copy), comps: rows(spec.comps), api: rows(spec.api)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -13,12 +13,12 @@
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&family=Source+Serif+4:ital,opsz,wght@0,8..60,400;0,8..60,600;1,8..60,400&display=swap" rel="stylesheet">
|
||||
<style>body{margin:0;font-family:'IBM Plex Sans',sans-serif;font-size:13px;line-height:20px}*{box-sizing:border-box}a{color:inherit;text-underline-offset:3px}a:hover{opacity:.8}code{font-family:'IBM Plex Mono',monospace;font-size:12px}</style>
|
||||
</helmet>
|
||||
<div ref="{{ rootRef }}" data-screen-label="Back-office screens" style="--ui-bg:#ffffff;--ui-bg-muted:#fafafa;--ui-bg-elevated:#f3f2f0;--ui-bg-accented:#e6e4e0;--ui-bg-inverted:#111111;--ui-text-dimmed:#999999;--ui-text-muted:#666666;--ui-text-toned:#444444;--ui-text:#222222;--ui-text-highlighted:#111111;--ui-text-inverted:#ffffff;--ui-border:#e6e4e0;--ui-border-muted:#f3f2f0;--ui-border-accented:#8a8783;--ui-primary:#111111;--ui-secondary:#635643;--ui-success:#315b33;--ui-info:#34536d;--ui-warning:#6c4700;--ui-error:#882c21;--rc-sand:#e8dfd1;--rc-success-soft:#eaefea;--rc-info-soft:#ebeef0;--rc-warning-soft:#f0ede6;--rc-error-soft:#f3eae9;--rc-overlay:rgb(10 10 10 / 0.4);--rc-shadow-pop:0 4px 16px rgb(17 17 17 / 0.08), 0 1px 2px rgb(17 17 17 / 0.06);--rc-shadow-modal:0 16px 48px rgb(17 17 17 / 0.18);--rc-canvas:#ecebe8;background:var(--rc-canvas);color:var(--ui-text);padding:64px;display:flex;flex-direction:column;gap:72px;width:max-content;min-width:100%">
|
||||
<div ref="{{ rootRef }}" data-screen-label="Back-office screens" style="--ui-bg:#ffffff;--ui-bg-muted:#fafafa;--ui-bg-elevated:#f3f2f0;--ui-bg-accented:#e6e4e0;--ui-bg-inverted:#111111;--ui-text-dimmed:#999999;--ui-text-muted:#666666;--ui-text-toned:#444444;--ui-text:#222222;--ui-text-highlighted:#111111;--ui-text-inverted:#ffffff;--ui-border:#e6e4e0;--ui-border-muted:#f3f2f0;--ui-border-accented:#8a8783;--ui-primary:#111111;--ui-secondary:#635643;--ui-success:#315b33;--ui-info:#34536d;--ui-warning:#6c4700;--ui-error:#882c21;--rc-sand:#e8dfd1;--rc-on-sand:#111111;--rc-success-soft:#eaefea;--rc-info-soft:#ebeef0;--rc-warning-soft:#f0ede6;--rc-error-soft:#f3eae9;--rc-overlay:rgb(10 10 10 / 0.4);--rc-shadow-pop:0 4px 16px rgb(17 17 17 / 0.08), 0 1px 2px rgb(17 17 17 / 0.06);--rc-shadow-modal:0 16px 48px rgb(17 17 17 / 0.18);--rc-canvas:#ecebe8;background:var(--rc-canvas);color:var(--ui-text);padding:64px;display:flex;flex-direction:column;gap:72px;width:max-content;min-width:100%">
|
||||
|
||||
<div style="display:flex;align-items:flex-end;gap:32px;max-width:1400px">
|
||||
<div style="flex:1;display:flex;flex-direction:column;gap:10px">
|
||||
<h1 style="margin:0;font:600 36px/44px 'IBM Plex Sans',sans-serif;letter-spacing:-0.02em;color:var(--ui-text-highlighted)">Screens: back-office and global</h1>
|
||||
<p style="margin:0;max-width:880px;font-size:16px;line-height:26px;color:var(--ui-text-toned);text-wrap:pretty">Every state of the verified-only routes, plus the global states, at 1440 px (shown at 50%) and 390 px (shown at 75%). The theme switch shows all of them in dark mode. Session expiry and unverified redirects end on screens from <a href="RenewCMS Screens Auth.dc.html">the auth board</a>.</p>
|
||||
<p style="margin:0;max-width:880px;font-size:16px;line-height:26px;color:var(--ui-text-toned);text-wrap:pretty">Every state of the verified-only routes, plus the global states, at 1440 px (shown at 50%) and 390 px (shown at 75%). Home, Settings and Media show every card in one page, so each card matches its neighbours. Activity, Media cleanup and the activity cards live here too. The theme switch shows all of them in dark mode. Session expiry and unverified redirects end on screens from <a href="RenewCMS Screens Auth.dc.html">the auth board</a>.</p>
|
||||
</div>
|
||||
<div role="radiogroup" aria-label="Theme" style="display:flex;border:1px solid var(--ui-border-accented);background:var(--ui-bg)">
|
||||
<sc-for list="{{ modes }}" as="m" hint-placeholder-count="3">
|
||||
@@ -39,8 +39,8 @@
|
||||
<div style="display:flex;flex-direction:column;gap:12px">
|
||||
<div style="display:flex;flex-direction:column;gap:2px;max-width:1040px"><span style="font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ st.name }}</span><span style="font-size:13px;color:var(--ui-text-muted)">{{ st.trigger }}</span></div>
|
||||
<div style="display:flex;gap:24px;align-items:flex-start">
|
||||
<div style="width:720px;height:450px;overflow:hidden;border:1px solid var(--ui-border);flex-shrink:0"><div style="width:1440px;height:900px;zoom:0.5"><sc-if value="{{ st.notMedia }}"><dc-import name="AppFrame" s="{{ st }}" mobile="{{ false }}" hint-size="1440px,900px"></dc-import></sc-if><sc-if value="{{ st.isMedia }}"><dc-import name="MediaFrame" s="{{ st }}" mobile="{{ false }}" hint-size="1440px,900px"></dc-import></sc-if></div></div>
|
||||
<div style="width:293px;height:633px;overflow:hidden;border:1px solid var(--ui-border);flex-shrink:0"><div style="width:390px;height:844px;zoom:0.75"><sc-if value="{{ st.notMedia }}"><dc-import name="AppFrame" s="{{ st.m }}" mobile="{{ true }}" hint-size="390px,844px"></dc-import></sc-if><sc-if value="{{ st.isMedia }}"><dc-import name="MediaFrame" s="{{ st.m }}" mobile="{{ true }}" hint-size="390px,844px"></dc-import></sc-if></div></div>
|
||||
<div style="width:720px;height:450px;overflow:hidden;border:1px solid var(--ui-border);flex-shrink:0"><div style="width:1440px;height:900px;zoom:0.5"><sc-if value="{{ st.notMedia }}"><dc-import name="AppFrame" s="{{ st }}" mobile="{{ false }}" hint-size="1440px,900px"></dc-import></sc-if><sc-if value="{{ st.isMedia }}"><dc-import name="MediaFrame" s="{{ st }}" mobile="{{ false }}" hint-size="1440px,900px"></dc-import></sc-if><sc-if value="{{ st.isAct }}"><dc-import name="ActivityFrame" s="{{ st }}" mobile="{{ false }}" hint-size="1440px,900px"></dc-import></sc-if></div></div>
|
||||
<div style="width:293px;height:633px;overflow:hidden;border:1px solid var(--ui-border);flex-shrink:0"><div style="width:390px;height:844px;zoom:0.75"><sc-if value="{{ st.notMedia }}"><dc-import name="AppFrame" s="{{ st.m }}" mobile="{{ true }}" hint-size="390px,844px"></dc-import></sc-if><sc-if value="{{ st.isMedia }}"><dc-import name="MediaFrame" s="{{ st.m }}" mobile="{{ true }}" hint-size="390px,844px"></dc-import></sc-if><sc-if value="{{ st.isAct }}"><dc-import name="ActivityFrame" s="{{ st.m }}" mobile="{{ true }}" hint-size="390px,844px"></dc-import></sc-if></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</sc-for>
|
||||
@@ -49,14 +49,17 @@
|
||||
</section>
|
||||
</sc-for>
|
||||
|
||||
<section style="display:flex;flex-direction:column;gap:20px;max-width:1240px"><h2 style="margin:0;font:600 28px/36px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Activity: copy per state</h2><div style="background:var(--ui-bg);border:1px solid var(--ui-border)"><sc-for list="{{ spCopy }}" as="r" hint-placeholder-count="5"><div style="display:grid;grid-template-columns:220px minmax(0,1fr);padding:10px 12px;border-top:1px solid var(--ui-border);line-height:20px"><span style="font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ r.a }}</span><span style="color:var(--ui-text-highlighted);text-wrap:pretty">{{ r.b }}</span></div></sc-for></div></section>
|
||||
<section style="display:flex;flex-direction:column;gap:20px;max-width:1240px"><h2 style="margin:0;font:600 28px/36px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Activity: component inventory (Nuxt UI v4)</h2><div style="background:var(--ui-bg);border:1px solid var(--ui-border)"><sc-for list="{{ spComps }}" as="r" hint-placeholder-count="5"><div style="display:grid;grid-template-columns:220px minmax(0,1fr);padding:10px 12px;border-top:1px solid var(--ui-border);line-height:20px"><span style="font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ r.a }}</span><span style="color:var(--ui-text-highlighted);text-wrap:pretty">{{ r.b }}</span></div></sc-for></div></section>
|
||||
<section style="display:flex;flex-direction:column;gap:20px;max-width:1240px"><h2 style="margin:0;font:600 28px/36px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Activity: API calls</h2><div style="background:var(--ui-bg);border:1px solid var(--ui-border)"><sc-for list="{{ spApi }}" as="r" hint-placeholder-count="5"><div style="display:grid;grid-template-columns:220px minmax(0,1fr);padding:10px 12px;border-top:1px solid var(--ui-border);line-height:20px"><span style="font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ r.a }}</span><span style="color:var(--ui-text-highlighted);text-wrap:pretty">{{ r.b }}</span></div></sc-for></div></section>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props="{"theme":{"editor":"enum","options":["system","light","dark"],"default":"light","tsType":"'system' | 'light' | 'dark'"}}">
|
||||
<script type="text/x-dc" data-dc-script data-props="{"theme":{"editor":"enum","options":["system","light","dark"],"default":"system","tsType":"'system' | 'light' | 'dark'"}}">
|
||||
class Component extends DCLogic {
|
||||
state = { mode: this.props.theme ?? 'light' }
|
||||
state = { mode: this.props.theme ?? 'light', spec: { copy: [], comps: [], api: [] } }
|
||||
rootRef = React.createRef()
|
||||
light = { '--ui-bg': '#ffffff', '--ui-bg-muted': '#fafafa', '--ui-bg-elevated': '#f3f2f0', '--ui-bg-accented': '#e6e4e0', '--ui-bg-inverted': '#111111', '--ui-text-dimmed': '#999999', '--ui-text-muted': '#666666', '--ui-text-toned': '#444444', '--ui-text': '#222222', '--ui-text-highlighted': '#111111', '--ui-text-inverted': '#ffffff', '--ui-border': '#e6e4e0', '--ui-border-muted': '#f3f2f0', '--ui-border-accented': '#8a8783', '--ui-primary': '#111111', '--ui-secondary': '#635643', '--ui-success': '#315b33', '--ui-info': '#34536d', '--ui-warning': '#6c4700', '--ui-error': '#882c21', '--rc-sand': '#e8dfd1', '--rc-success-soft': '#eaefea', '--rc-info-soft': '#ebeef0', '--rc-warning-soft': '#f0ede6', '--rc-error-soft': '#f3eae9', '--rc-overlay': 'rgb(10 10 10 / 0.4)', '--rc-shadow-pop': '0 4px 16px rgb(17 17 17 / 0.08), 0 1px 2px rgb(17 17 17 / 0.06)', '--rc-shadow-modal': '0 16px 48px rgb(17 17 17 / 0.18)', '--rc-canvas': '#ecebe8' }
|
||||
dark = { '--ui-bg': '#111111', '--ui-bg-muted': '#0a0a0a', '--ui-bg-elevated': '#1a1a1a', '--ui-bg-accented': '#222222', '--ui-bg-inverted': '#fafafa', '--ui-text-dimmed': '#666666', '--ui-text-muted': '#999999', '--ui-text-toned': '#cfccc7', '--ui-text': '#e6e4e0', '--ui-text-highlighted': '#fafafa', '--ui-text-inverted': '#111111', '--ui-border': '#333333', '--ui-border-muted': '#222222', '--ui-border-accented': '#666666', '--ui-primary': '#fafafa', '--ui-secondary': '#d4c5b0', '--ui-success': '#a3caa3', '--ui-info': '#a5c2db', '--ui-warning': '#dfb67d', '--ui-error': '#fba394', '--rc-sand': '#d4c5b0', '--rc-success-soft': '#1f241f', '--rc-info-soft': '#202428', '--rc-warning-soft': '#2a251e', '--rc-error-soft': '#2c201f', '--rc-overlay': 'rgb(0 0 0 / 0.6)', '--rc-shadow-pop': '0 4px 16px rgb(0 0 0 / 0.5)', '--rc-shadow-modal': '0 16px 48px rgb(0 0 0 / 0.6)', '--rc-canvas': '#1c1c1c' }
|
||||
light = { '--ui-bg': '#ffffff', '--ui-bg-muted': '#fafafa', '--ui-bg-elevated': '#f3f2f0', '--ui-bg-accented': '#e6e4e0', '--ui-bg-inverted': '#111111', '--ui-text-dimmed': '#999999', '--ui-text-muted': '#666666', '--ui-text-toned': '#444444', '--ui-text': '#222222', '--ui-text-highlighted': '#111111', '--ui-text-inverted': '#ffffff', '--ui-border': '#e6e4e0', '--ui-border-muted': '#f3f2f0', '--ui-border-accented': '#8a8783', '--ui-primary': '#111111', '--ui-secondary': '#635643', '--ui-success': '#315b33', '--ui-info': '#34536d', '--ui-warning': '#6c4700', '--ui-error': '#882c21', '--rc-sand': '#e8dfd1', '--rc-on-sand': '#111111', '--rc-success-soft': '#eaefea', '--rc-info-soft': '#ebeef0', '--rc-warning-soft': '#f0ede6', '--rc-error-soft': '#f3eae9', '--rc-overlay': 'rgb(10 10 10 / 0.4)', '--rc-shadow-pop': '0 4px 16px rgb(17 17 17 / 0.08), 0 1px 2px rgb(17 17 17 / 0.06)', '--rc-shadow-modal': '0 16px 48px rgb(17 17 17 / 0.18)', '--rc-canvas': '#ecebe8' }
|
||||
dark = { '--ui-bg': '#111111', '--ui-bg-muted': '#0a0a0a', '--ui-bg-elevated': '#1a1a1a', '--ui-bg-accented': '#222222', '--ui-bg-inverted': '#fafafa', '--ui-text-dimmed': '#666666', '--ui-text-muted': '#999999', '--ui-text-toned': '#cfccc7', '--ui-text': '#e6e4e0', '--ui-text-highlighted': '#fafafa', '--ui-text-inverted': '#111111', '--ui-border': '#333333', '--ui-border-muted': '#222222', '--ui-border-accented': '#666666', '--ui-primary': '#fafafa', '--ui-secondary': '#d4c5b0', '--ui-success': '#a3caa3', '--ui-info': '#a5c2db', '--ui-warning': '#dfb67d', '--ui-error': '#fba394', '--rc-sand': '#d4c5b0', '--rc-on-sand': '#111111', '--rc-success-soft': '#1f241f', '--rc-info-soft': '#202428', '--rc-warning-soft': '#2a251e', '--rc-error-soft': '#2c201f', '--rc-overlay': 'rgb(0 0 0 / 0.6)', '--rc-shadow-pop': '0 4px 16px rgb(0 0 0 / 0.5)', '--rc-shadow-modal': '0 16px 48px rgb(0 0 0 / 0.6)', '--rc-canvas': '#1c1c1c' }
|
||||
resolved() {
|
||||
if (this.state.mode === 'system') return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
return this.state.mode
|
||||
@@ -68,7 +71,10 @@ class Component extends DCLogic {
|
||||
for (const k in vars) el.style.setProperty(k, vars[k])
|
||||
document.body.style.background = vars['--rc-canvas']
|
||||
}
|
||||
componentDidMount() { this.apply() }
|
||||
componentDidMount() {
|
||||
this.apply()
|
||||
import('./activity-spec.js').then(x => this.setState({ spec: { copy: x.copy, comps: x.comps, api: x.api } })).catch(() => {})
|
||||
}
|
||||
componentDidUpdate(p) {
|
||||
if (p.theme !== this.props.theme && this.props.theme) this.setState({ mode: this.props.theme })
|
||||
this.apply()
|
||||
@@ -122,7 +128,7 @@ class Component extends DCLogic {
|
||||
const B = (t, kind, icon, dis) => ({ t, icon, bg: kind === 'primary' ? 'var(--ui-primary)' : 'var(--ui-bg)', fg: kind === 'primary' ? 'var(--ui-bg)' : 'var(--ui-text-highlighted)', bd: kind === 'primary' ? 'var(--ui-primary)' : 'var(--ui-border-accented)', op: dis ? '0.4' : '1' })
|
||||
const UPD = (o) => { const rdy = o.title === 'Version 2.3.0 is ready to install' && o.btns && o.btns.length; if (rdy) o = { ...o, tone: 'warning', icon: 'clock', pendingTag: true, inBtn: o.btns[0], btns: undefined }; const tone = o.tone || 'neutral'; return { v: 'v2.2.0-beta', focus: 'none', ...o, fg: tone === 'neutral' ? 'var(--ui-text-toned)' : 'var(--ui-' + tone + ')', bg: TB[tone], bar: o.pct !== undefined || !!o.indet, barW: o.indet ? '35%' : (o.pct || 0) + '%', barL: o.indet ? '30%' : '0', pct: o.indet ? undefined : o.pct } }
|
||||
const NOTES = (open) => ({ open, chev: open ? 'chevron-down' : 'chevron-right', ver: '2.3.0', date: 'Released 24 Sep 2026', items: [{ t: 'Images upload faster, and large images no longer stall.' }, { t: 'Fixed a problem where a draft could show an old title after saving.' }, { t: 'Small improvements to the Users page.' }] })
|
||||
const SU = (o, extra) => withM({ kind: 'settings', nav: 'settings', crumbs: ['Settings'], mTitle: 'Settings', ...extra, upd: UPD(o) })
|
||||
const SU = (o, extra, mo) => withM({ kind: 'settings', nav: 'settings', crumbs: ['Settings'], mTitle: 'Settings', ...extra, upd: UPD(o) }, mo)
|
||||
const LAST = 'Last checked today at 09:41'
|
||||
const RESTART_FOOT = 'Restart takes about 10 seconds. Your site is unavailable while it restarts.'
|
||||
const bn = (icon, title, text) => ({ icon, title, text, btn: 'Go to Settings', fg: 'var(--ui-info)', bg: 'var(--rc-info-soft)' })
|
||||
@@ -130,17 +136,36 @@ class Component extends DCLogic {
|
||||
const notPrep = (copied) => ({ icon: 'info', tone: 'info', title: 'Version 2.3.0 is available', text: 'This site can\'t update itself from the browser yet. Whoever manages your server can set it up once, or update RenewCMS for you.', meta: LAST, cmd: { line: cmdLine, t: copied ? 'Copied' : 'Copy', icon: copied ? 'check' : 'copy', fg: copied ? 'var(--ui-success)' : 'var(--ui-text-highlighted)', note: 'They run this once on the server. After that, Update and Restart appear here.' }, link: 'How to update' })
|
||||
const restartModal = (o) => ({ title: 'Restart the site?', desc: 'Your site will be unavailable for about 10 seconds. This page reloads on its own when the site is back. Save anything you\'re editing first.', cancel: 'Cancel', confirm: 'Restart site', ...o })
|
||||
const installModal = (o) => ({ title: 'Restart to finish the update?', desc: 'RenewCMS will install version 2.3.0 and restart. Your site will be unavailable for about 10 seconds. This page reloads on its own when the site is back. Save anything you\'re editing first.', cancel: 'Restart later', confirm: 'Restart now', ...o })
|
||||
const Act = (name, trigger, o = {}, mo = {}) => ({ isAct: true, name, trigger, ...o, m: { isAct: true, name, trigger, ...o, ...mo } })
|
||||
const UR = { role: 'user' }
|
||||
const af1 = { cat: 'Authentication', out: 'Failed', range: '30 days' }
|
||||
const UTD = { icon: 'circle-check', tone: 'success', title: 'You\'re up to date', text: 'You have the latest version of RenewCMS.', meta: LAST, btns: [B('Check for updates', 'sec', 'refresh-cw'), B('Restart', 'sec', 'power')], hint: RESTART_FOOT }
|
||||
const SCR = { c: 300, cm: 480, r: 560, rm: 940 }
|
||||
const cLast = { d: '14 Jun 2026', c: 3, a: 5 }, cOn = { on: true, days: '30', last: cLast, next: '14 Jul 2026' }
|
||||
const SC = (kind, name, trigger, cu, extra = {}) => { const sy = kind === 'c' ? SCR.c : SCR.r, sm = kind === 'c' ? SCR.cm : SCR.rm; return withM({ kind: 'settings', nav: 'settings', crumbs: ['Settings'], mTitle: 'Settings', upd: UPD(UTD), scrollY: sy, name, trigger, ...cu, ...extra }, { scrollY: sm }) }
|
||||
const CU = (name, trigger, cleanup, extra) => SC('c', name, trigger, { cleanup }, extra)
|
||||
const RT = (name, trigger, ret, extra) => SC('r', name, trigger, { ret }, extra)
|
||||
const PD = (k, name, trigger, dg = {}) => SC('c', name, trigger, { cleanup: { ...cOn, last: null, on: false }, purge: { k, ...dg } })
|
||||
const pvMore = { groups: 8, copies: 15, arts: 24, list: [{ im: 12, n: 'floorboards.jpg', u: 4, c: 2 }, { im: 7, n: 'IMG_4471.jpg', u: 2, c: 1 }, { im: 2, n: 'kitchen-after.webp', u: 3, c: 3 }, { im: 4, n: 'logo-renew.svg', u: 5, c: 3 }, { im: 11, n: 'lime-plaster.jpg', u: 2, c: 1 }], more: 3 }
|
||||
const pvOne = { groups: 1, copies: 1, arts: 2, list: [{ im: 7, n: 'IMG_4471.jpg', u: 2, c: 1 }], more: 0 }
|
||||
const pv3 = { groups: 3, copies: 7, arts: 10, list: [{ im: 12, n: 'floorboards.jpg', u: 4, c: 2 }, { im: 2, n: 'kitchen-after.webp', u: 3, c: 3 }, { im: 4, n: 'logo-renew.svg', u: 5, c: 3 }], more: 0 }
|
||||
const purged = TT('success', 'circle-check', 'Duplicates purged', '9 copies merged. 12 articles updated.')
|
||||
const doneLast = { d: '5 Oct 2026', c: 9, a: 12 }
|
||||
return this.finish([
|
||||
{ label: 'Boot', title: 'App boot', route: 'any route, first load', api: 'GET /v1/auth/me (pending)', note: 'While the session is checked, the shell renders with skeletons, never a spinner. The possible outcomes are: 200 with a verified user, which renders the route; 200 with an unverified user, which goes to /register/pending; and 401, which goes to /login?redirect=<path>. Public routes skip this check.', states: [
|
||||
withM({ ...home, name: 'Checking session', trigger: 'The first paint on any back-office URL, and usually visible for well under a second.', boot: true })
|
||||
] },
|
||||
{ label: 'Home', title: 'Home', route: '/home', api: 'GET /v1/article/', note: 'The counts come from the list, so there\'s no separate endpoint. The five most recently updated articles link to the editor.', states: [
|
||||
withM({ ...home, name: 'Default', trigger: 'The articles loaded.', home: { sub: 'Here\'s where things stand on your site.', stats, recent } }),
|
||||
withM({ ...home, name: 'Loading', trigger: 'The list request is in flight. Skeletons appear after 150 ms.', home: { sub: 'Here\'s where things stand on your site.', stats: stats.map(() => ({ skel: true })), recent: Array.from({ length: 5 }, () => ({ skel: true })) } }),
|
||||
{ label: 'Home', title: 'Home', route: '/home', api: 'GET /v1/article/ · GET /v1/activity?size=5', note: 'The counts come from the list, so there\'s no separate endpoint. The five most recently updated articles link to the editor. Below them, a Recent activity section shows the last 5 events (owner and admin) or the user\'s own (regular users), and links to Activity.', states: [
|
||||
withM({ ...home, name: 'Default', trigger: 'The articles loaded.', home: { sub: 'Here\'s where things stand on your site.', stats, recent, act: 'ok' } }),
|
||||
withM({ ...home, name: 'Loading', trigger: 'The list request is in flight. Skeletons appear after 150 ms.', home: { sub: 'Here\'s where things stand on your site.', stats: stats.map(() => ({ skel: true })), recent: Array.from({ length: 5 }, () => ({ skel: true })), act: 'loading' } }),
|
||||
withM({ ...home, name: 'Empty, first run', trigger: 'Zero articles: a single call to action, and no stats.', home: { sub: 'Welcome to RenewCMS.', empty: true } }),
|
||||
withM({ ...home, name: 'Error', trigger: 'A 500 or network failure on the list request.', home: { sub: 'Here\'s where things stand on your site.', error: true } }),
|
||||
withM({ ...home, navBadge: true, name: 'Update available (owner)', trigger: 'The owner only. A quiet banner above the content, and an "Update" pill on the Settings item (a dot on the menu icon on mobile, read as "Update available"). Dismissing hides the banner until the next version; the pill stays. Admins see neither.', home: { sub: 'Here\'s where things stand on your site.', stats, recent, banner: bn('circle-arrow-up', 'Version 2.3.0 is available', 'Update it from Settings. It takes about 2 minutes and the site stays online.') } }),
|
||||
withM({ ...home, navBadge: true, name: 'Update ready to install (owner)', trigger: 'The download is checked. The same banner changes its copy, and a dismiss comes back on the next visit because the update is waiting.', home: { sub: 'Here\'s where things stand on your site.', stats, recent, banner: bn('circle-check', 'Version 2.3.0 is ready to install', 'Restart from Settings to finish. The site is unavailable for about 10 seconds.') } })
|
||||
withM({ ...home, navBadge: true, name: 'Update available (owner)', trigger: 'The owner only. A quiet banner above the content, and an "Update" pill on the Settings item (a dot on the menu icon on mobile, read as "Update available"). Dismissing hides the banner until the next version; the pill stays. Admins see neither.', home: { sub: 'Here\'s where things stand on your site.', stats, recent, act: 'ok', banner: bn('circle-arrow-up', 'Version 2.3.0 is available', 'Update it from Settings. It takes about 2 minutes and the site stays online.') } }),
|
||||
withM({ ...home, navBadge: true, name: 'Update ready to install (owner)', trigger: 'The download is checked. The same banner changes its copy, and a dismiss comes back on the next visit because the update is waiting.', home: { sub: 'Here\'s where things stand on your site.', stats, recent, act: 'ok', banner: bn('circle-check', 'Version 2.3.0 is ready to install', 'Restart from Settings to finish. The site is unavailable for about 10 seconds.') } }),
|
||||
withM({ ...home, name: 'Recent activity: loading', trigger: 'The activity request is in flight. Five skeleton rows.', home: { sub: 'Here\'s where things stand on your site.', stats, recent, act: 'loading' } }),
|
||||
withM({ ...home, name: 'Recent activity: error', trigger: 'A failed request shows an inline alert in the section only. The rest of Home is unaffected.', home: { sub: 'Here\'s where things stand on your site.', stats, recent, act: 'error' } }),
|
||||
withM({ ...home, name: 'Recent activity: empty', trigger: 'No events yet.', home: { sub: 'Here\'s where things stand on your site.', stats, recent, act: 'empty' } }),
|
||||
withM({ ...home, role: 'user', name: 'Regular user', trigger: 'Own events only, titled "Your recent activity". The sidebar has no Users, Integration or Settings.', home: { sub: 'Here\'s where things stand on your site.', stats, recent, act: 'ok' } })
|
||||
] },
|
||||
{ label: 'Articles', title: 'Articles', route: '/article', api: 'GET /v1/article/ · PATCH …/publish · PATCH …/unpublish · DELETE /v1/article/{id}', note: 'Rows update when the server responds, not optimistically. Search, tab and sort are stored in the query string.', states: [
|
||||
withM({ ...L(), name: 'Default', trigger: 'The list loaded, sorted by last updated.' }),
|
||||
@@ -277,6 +302,30 @@ class Component extends DCLogic {
|
||||
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "6. Errors", trigger: "Status table and the plain-text error response (status line plus a one-sentence body). Key-specific causes are in the 401, 403 and 429 rows.", int: {"scroll":"errors"} }, { int: {"scroll":"errors"} }),
|
||||
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "7. Navigation", trigger: "Desktop: the sticky index with the active item (Media). Mobile: the \"Jump to\" select open. Keyboard: the index buttons and the select are in the tab order, Enter jumps, Escape closes the list.", int: {"scroll":"media"} }, { int: {"scroll":"media","jump":true} })
|
||||
] },
|
||||
{ label: 'Activity', title: 'Activity, owner and admin', route: '/activity', api: 'GET /v1/activity · GET /v1/activity/summary', note: 'New route in the sidebar after Users. Owner and admin see everything; regular users get "My activity". Copy, component mapping and API calls are at the bottom of this board.', states: [
|
||||
Act('Default, large dataset', 'Newest first, day separators, a security row, 1,284 events across 52 pages. Hover on the first row shows the exact time.', { tip: 1 }),
|
||||
Act('Loading', 'Skeleton strip and table while both requests are in flight.', { state: 'loading' }),
|
||||
Act('Zero-value summary strip', 'Quiet period: Failed and Security show a calm 0.', { sum: 'zero' }),
|
||||
Act('Filtered, card applied', 'Failed actions card clicked, chips and Clear all. Mobile shows the count on Filters.', { f: af1, card: 2, count: 6, total: '6', ids: [3], sum: 'default' }),
|
||||
Act('Mobile filters sheet', 'Filters open as a bottom sheet (desktop uses the inline bar above).', { f: { ...af1, range: 'Custom' }, drawer: 1, count: 6, total: '6', ids: [3] }),
|
||||
Act('Empty, no activity yet', 'New site. Export is disabled.', { state: 'none', sum: 'none' }),
|
||||
Act('Empty, filters match nothing', 'Offers Clear filters.', { state: 'nomatch', f: { q: 'sitemap', cat: 'Media', out: 'Failed' }, total: '0' }),
|
||||
Act('Error with retry', 'Request failed. The summary strip still shows if its call succeeded.', { state: 'error' }),
|
||||
Act('Forbidden (403)', 'API returned 403 for this role or filter.', { state: 'forbidden', role: 'user' })
|
||||
] },
|
||||
{ label: 'Activity details', title: 'Activity: details slide-over and bottom sheet', route: 'Row click', api: 'Uses the item from GET /v1/activity', states: [
|
||||
Act('Edit with a diff', 'Title change and "Body edited (+312 characters)". No body text.', { panel: 2 }),
|
||||
Act('Settings diff, long text', 'Long values clamp to two lines with Show more.', { panel: 11 }),
|
||||
Act('Failed action, very long target title', 'Failure reason shown, target title wraps.', { panel: 9 }),
|
||||
Act('Deleted target, no diff', 'Struck-through target with a Deleted badge.', { panel: 8 }),
|
||||
Act('System-initiated, no human actor', 'Scheduler with a System badge. IP and browser not applicable.', { panel: 5 }),
|
||||
Act('Security event, failed login', 'Actor not signed in. Restrained shield marker.', { panel: 3 })
|
||||
] },
|
||||
{ label: 'My activity', title: 'My activity, regular user', route: '/activity', api: 'GET /v1/activity (server scoped to the user)', states: [
|
||||
Act('Default', 'No summary strip, no actor filter, no Export. Title becomes My activity.', UR),
|
||||
Act('Empty', 'The user has not done anything yet.', { ...UR, state: 'none' }),
|
||||
Act('Details', 'Same slide-over, own event.', { ...UR, panel: 1 })
|
||||
] },
|
||||
{ label: 'Users', title: 'Users', route: '/users', api: 'GET /v1/user/ · POST /v1/user/invite · DELETE /v1/user/invite/:id (pending) · POST /v1/user/invite/:id/renew (pending)', note: 'One shared workspace: every user sees the same articles and this same list. Columns: email, display name, role (owner or admin), invite status and created date. A pending invite can be cancelled and an expired one renewed; accepted accounts have no row actions. An invite is Pending until its link is used, Expired after 3 days, and Accepted once the account is created.', states: [
|
||||
withM({ ...U(), name: 'Default', trigger: 'The list loaded, newest first.' }),
|
||||
withM({ ...U({ users: { loading: true } }), name: 'Loading', trigger: 'The first load, or a retry. Skeletons appear after 150 ms.' }),
|
||||
@@ -296,7 +345,7 @@ class Component extends DCLogic {
|
||||
withM({ ...U({ toasts: [T('error', 'circle-x', 'Couldn\'t renew the invite', 'Try again in a moment.')] }), name: 'Renew invite: failed', trigger: '500 or network failure. The row stays Expired and the button can be pressed again.' }),
|
||||
withM({ ...U({ users: { rows: [{ email: 'lea.martin@renew.example', role: 'admin', st: 'pending', c: 'Today', hl: true }, ...people] } , toasts: [T('success', 'circle-check', 'Invite sent', 'lea.martin@renew.example has 3 days to accept.')] }), name: 'Invite sent', trigger: '201: the dialog closes, the new invite appears at the top as Pending, and a toast confirms.' })
|
||||
] },
|
||||
{ label: 'Settings', title: 'Settings: Updates', route: '/settings', api: 'GET /v1/system/update · POST …/check · POST …/download · POST …/update/restart (all pending)', note: 'New Updates card under Version, owner only. Update downloads and checks the new version and changes nothing. Restart installs it and restarts the site (about 10 seconds). "Restart later" is simply leaving the card or the dialog. A plain Restart shows only when no update is waiting. Admins see the version and a note. When the server isn\'t prepared, the card shows the notice and a "How to update" link with no buttons. Progress is a progressbar with a value, announced at 25% steps and when each step changes. The card is a polite live region. After the restart the page reloads, focus lands on the "Updates" heading, and a toast confirms. Buttons are disabled while any step runs, and any owner on any device sees the same state from the server. Cue elsewhere: see Home. Out of scope: choosing versions, downgrades, automatic updates, update history.', states: [
|
||||
{ label: 'Settings', title: 'Settings: Updates', route: '/settings', api: 'GET /v1/system/update · POST …/check · POST …/download · POST …/update/restart (all pending)', note: 'The page is one column of cards, top to bottom: Version, Updates, Media cleanup, Retention (owner only). The screens below are scrolled to the card they show. New Updates card under Version, owner only. Update downloads and checks the new version and changes nothing. Restart installs it and restarts the site (about 10 seconds). "Restart later" is simply leaving the card or the dialog. A plain Restart shows only when no update is waiting. Admins see the version and a note. When the server isn\'t prepared, the card shows the notice and a "How to update" link with no buttons. Progress is a progressbar with a value, announced at 25% steps and when each step changes. The card is a polite live region. After the restart the page reloads, focus lands on the "Updates" heading, and a toast confirms. Buttons are disabled while any step runs, and any owner on any device sees the same state from the server. Cue elsewhere: see Home. Out of scope: choosing versions, downgrades, automatic updates, update history.', states: [
|
||||
SU({ icon: 'circle-check', tone: 'success', title: 'You\'re up to date', text: 'You have the latest version of RenewCMS.', meta: LAST, btns: [B('Check for updates', 'sec', 'refresh-cw'), B('Restart', 'sec', 'power')], hint: RESTART_FOOT }, { name: 'Up to date', trigger: 'Opened with no newer version. Restart is available but not emphasised.' }),
|
||||
SU({ icon: 'loader-circle', title: 'Checking for updates…', text: 'This usually takes a few seconds.', btns: [B('Checking…', 'sec', 'loader-circle', true)] }, { name: 'Checking', trigger: 'Check for updates pressed, or the first open after 24 hours. Announced as "Checking for updates". Buttons are disabled.' }),
|
||||
SU({ icon: 'wifi-off', tone: 'warning', title: 'Couldn\'t check for updates', text: 'RenewCMS couldn\'t reach the update server. Check your connection and try again. Your site isn\'t affected.', meta: 'Last checked yesterday at 18:12', btns: [B('Try again', 'primary', 'rotate-cw'), B('Restart', 'sec', 'power')], hint: RESTART_FOOT }, { name: 'Check failed', trigger: 'Offline, or the update server didn\'t answer (500, network). The last known result is kept.' }),
|
||||
@@ -319,8 +368,8 @@ class Component extends DCLogic {
|
||||
SU({ icon: 'circle-check', tone: 'success', title: 'You\'re up to date', text: 'You have the latest version of RenewCMS.', meta: LAST, btns: [B('Check for updates', 'sec', 'refresh-cw'), B('Restart', 'sec', 'power')], hint: RESTART_FOOT }, { name: 'Plain restart: confirm', trigger: 'Restart with no update waiting. Nothing is installed.', modal: restartModal() }),
|
||||
SU({ icon: 'circle-check', tone: 'success', title: '', text: '', btns: [] }, { name: 'Plain restart: restarting', trigger: 'Same overlay as the update restart with shorter copy.', overlay: { title: 'Restarting your site', text: 'This takes about 10 seconds. This page reloads on its own when the site is back.', meta: 'Started at 09:51', barL: '30%' } }),
|
||||
SU({ icon: 'circle-check', tone: 'success', title: 'Your site is back', text: '', btns: [] }, { name: 'Plain restart: done', trigger: 'The page reloaded. Focus lands on the "Updates" heading.', toasts: [T('success', 'circle-check', 'Site restarted', 'Your site is running again.')] }),
|
||||
SU({ icon: 'circle-arrow-up', tone: 'info', title: 'Version 2.3.0 is available', text: 'You have v2.2.0-beta. Only the owner can update RenewCMS. Ask the owner of this site.', meta: LAST }, { name: 'Admin view', trigger: 'Admins see the version and the note, with no buttons. Up to date: "You\'re up to date" and "Only the owner can update or restart the site." No Home banner or Settings pill.' }),
|
||||
SU({ icon: 'circle-arrow-up', tone: 'info', title: 'Version 2.3.0 is available', text: 'You have v2.2.0-beta. Only the owner can update RenewCMS. Ask the owner of this site.', meta: LAST }, { name: 'Role changed (403)', trigger: 'An owner was changed to admin while the page was open and pressed a button. The card reloads in the admin view.', toasts: [T('error', 'circle-x', 'Only the owner can do this', 'Ask the owner of this site to update RenewCMS.')] }),
|
||||
SU({ icon: 'circle-arrow-up', tone: 'info', title: 'Version 2.3.0 is available', text: 'You have v2.2.0-beta. Only the owner can update RenewCMS. Ask the owner of this site.', meta: LAST }, { noRet: true, name: 'Admin view', trigger: 'Admins see the version and the note, with no buttons. Up to date: "You\'re up to date" and "Only the owner can update or restart the site." No Home banner or Settings pill.' }),
|
||||
SU({ icon: 'circle-arrow-up', tone: 'info', title: 'Version 2.3.0 is available', text: 'You have v2.2.0-beta. Only the owner can update RenewCMS. Ask the owner of this site.', meta: LAST }, { noRet: true, name: 'Role changed (403)', trigger: 'An owner was changed to admin while the page was open and pressed a button. The card reloads in the admin view.', toasts: [T('error', 'circle-x', 'Only the owner can do this', 'Ask the owner of this site to update RenewCMS.')] }),
|
||||
SU({ icon: 'circle-arrow-up', tone: 'info', title: 'Version 2.3.0 is available', text: 'You have v2.2.0-beta. Update downloads the new version and checks it. Nothing changes until you restart.', meta: LAST, btns: [B('Update', 'primary', 'download', true)] }, { name: 'Update already running (409)', trigger: 'Another owner started it. The card switches to that run\'s progress.', navBadge: true, toasts: [T('info', 'info', 'An update is already running', 'Another owner started it. Progress shows here.')] })
|
||||
], tableTitle: 'Status to copy', table: [
|
||||
{ a: '200', b: 'Check, up to date', c: '"You\'re up to date" / "You have the latest version of RenewCMS." / "Last checked today at 09:41"' },
|
||||
@@ -341,8 +390,50 @@ class Component extends DCLogic {
|
||||
{ a: 'No answer, 60 s', b: 'Restart overlay', c: '"Still restarting" / "This is taking longer than usual. Keep this page open. It reloads on its own when the site is back, or you can reload it now."' },
|
||||
{ a: 'Offline', b: 'Whole page', c: 'Existing banner. Check, Update and Restart are disabled. During a restart, the overlay keeps polling and ignores the offline state.' }
|
||||
] },
|
||||
{ label: 'Settings: Media cleanup', title: 'Settings: Media cleanup', route: '/settings', api: 'GET /v1/media/purge-settings · PATCH /v1/media/purge-settings (owner, admin)', note: 'A card below Updates, same card styling and 20 px gap. Automatic purge is off by default. Settings save immediately on toggle and on blur or Enter of the days field; there is no Save button. 401 and 403 are global (login redirect, /register/pending) and have no local copy.', states: [
|
||||
CU('Default: off, never run', 'Owner or admin. Automatic purge is off, so the "Every" row and "Next purge" are hidden.', {}),
|
||||
CU('On: 30 days, last run', 'Switch on. "Every" appears with the default of 30 days. The last-run line and the next run are shown.', cOn),
|
||||
CU('Saving', 'Brief state after toggling or leaving the field. "Saving…" with loader-circle sits in the card header; controls stay usable.', { ...cOn, saving: true, focusDays: true }),
|
||||
CU('Saved', 'The save succeeded. A small "Saved" confirmation shows in the card header and fades after 2 s. It is announced politely.', { ...cOn, saved: true }),
|
||||
CU('Save failed', 'A 500 or network failure. The error sits under the field and the control reverts to its previous value (30).', { ...cOn, saveErr: true, days: '30', focusDays: true }),
|
||||
CU('Validation error', 'Empty, 0, above 365 or not a whole number, checked on blur or Enter. Nothing is sent. The field keeps what was typed until corrected.', { ...cOn, days: '400', invalid: true, focusDays: true }),
|
||||
CU('Read-only (non-admin)', 'Other verified users. Switch and input are disabled, "Purge now" is hidden, and an info alert explains why.', { ...cOn, ro: true }, { noRet: true }),
|
||||
CU('Loading skeleton', 'The settings request is in flight. Skeleton shows only after 150 ms.', { loading: true }),
|
||||
CU('Offline', 'navigator.onLine is false. The banner matches Media. Switch, input and button are disabled.', { ...cOn, offline: true }, { offline: true, offText: 'You\'re offline. Changing automatic purge and purging duplicates need a connection.' })
|
||||
] },
|
||||
{ label: 'Purge dialog', title: 'Purge duplicates: dialog', route: 'opens from "Purge now" (Settings) and "Purge duplicates" (Media, Duplicates tab)', api: 'GET /v1/media/purge-preview · POST /v1/media/purge', note: 'UModal, sm:max-w-md, bottom sheet on mobile. Cancel first, then the danger confirm. While the request runs, the confirm shows loader-circle and the dialog can\'t be dismissed. The preview is computed before confirming. A group is purged only if one of its copies is used in an article; the kept copy is the one used in the most articles (ties: oldest, then lowest id).', states: [
|
||||
PD('loading', 'Loading preview', 'The preview request is in flight. Skeleton rows show after 150 ms and the confirm is disabled.'),
|
||||
PD('confirm', 'Confirm', '"Purge now" pressed. Summary, up to 5 groups, a link to the Duplicates tab.'),
|
||||
PD('confirm', 'Confirm: many groups', 'More than 5 groups. The list stops at 5 and says how many more.', { pv: pvMore }),
|
||||
PD('progress', 'In progress', 'POST in flight. Confirm reads "Purging…" with loader-circle; Cancel is disabled and Esc, the overlay and swipe do nothing.'),
|
||||
PD('nothing', 'Nothing to purge', 'The preview has no group with an image in use. No confirm; one Close button.'),
|
||||
PD('error', 'Error', '500 or network failure. The dialog stays open, nothing changed. Confirm retries.'),
|
||||
PD('partial', 'Partial failure', 'Some groups failed. The summary refreshes to what is left. Buttons: Close, Retry.', { pv: pvOne }),
|
||||
PD('conflict', 'Conflict (409)', 'The library changed since the preview. Nothing was purged, the summary refreshes and the dialog stays open.', { pv: pv3 })
|
||||
] },
|
||||
{ label: 'Purge results', title: 'Purge: results and feedback', route: '/settings', api: 'after POST /v1/media/purge', note: 'An unattended run does not toast. The next visit to Settings shows the updated "Last purge" line.', states: [
|
||||
CU('Success', 'The dialog closes, a toast shows for 5 s, and the "Last purge" line updates in place.', { on: false, last: doneLast }, { toasts: [T('success', 'circle-check', 'Duplicates purged', '9 copies merged. 12 articles updated.')] }),
|
||||
CU('Success, automatic purge on', 'Same, with the next run moved forward by the interval.', { on: true, days: '30', last: doneLast, next: '4 Nov 2026' }, { toasts: [T('success', 'circle-check', 'Duplicates purged', '9 copies merged. 12 articles updated.')] })
|
||||
], tableTitle: 'Status to copy', table: [
|
||||
{ a: '200', b: 'Purge done', c: 'Toast "Duplicates purged" / "9 copies merged. 12 articles updated."' },
|
||||
{ a: '409', b: 'Library changed', c: '"The library changed, so nothing was purged. Check the new summary and try again."' },
|
||||
{ a: '207', b: 'Partial', c: '"Some groups couldn\'t be merged. 3 of 4 groups were purged. Try again to finish."' },
|
||||
{ a: '500', b: 'Purge failed', c: '"Couldn\'t purge the duplicates. Nothing was changed. Try again in a moment."' },
|
||||
{ a: '500', b: 'Setting not saved', c: '"Couldn\'t save. Try again in a moment."' },
|
||||
{ a: '400', b: 'Days invalid', c: '"Enter a whole number of days between 1 and 365."' }
|
||||
] },
|
||||
{ label: 'Media duplicates entry', title: 'Media: Duplicates tab entry point', route: '/media?filter=duplicates', api: 'owner and admin only', note: 'A secondary "Purge duplicates" button (icon merge) in the toolbar next to the sort menu, and in the stacked toolbar on mobile. The dialog and the member state are listed with the Media states above. Per-group "Merge into this one" is unchanged.', states: [
|
||||
Mp({ tab: 'dups', name: 'Duplicates tab with the button', trigger: 'Owner or admin.' }),
|
||||
Mp({ tab: 'dups', offline: true, name: 'Duplicates tab: offline', trigger: 'The button is disabled with the existing banner.' })
|
||||
] },
|
||||
{ label: 'Settings: Retention', title: 'Settings: Retention (owner only)', route: '/settings', api: 'GET /v1/settings · PATCH /v1/settings', note: 'The last card on the page, same card styling as Version, Updates and Media cleanup. Saves on change.', states: [
|
||||
RT('Default, 90 days', 'Saves on change.', { days: '90', saved: true }),
|
||||
RT('Shortening the period', 'Confirms before older events are removed.', { days: '90', shorten: true }),
|
||||
RT('Forever', 'No cleanup scheduled.', { days: '0' }),
|
||||
RT('Save error', 'The previous setting is still in place.', { days: '90', err: true })
|
||||
] },
|
||||
{ label: 'Profile', title: 'Profile', route: '/profile', api: 'GET /v1/auth/me · PATCH /v1/user/me (pending) · POST /v1/auth/password-reset', note: 'Opened from the account menu at the bottom of the sidebar. Username, email and password are read-only. The display name is optional, trimmed, and up to 60 characters. Once saved it replaces the username everywhere a person is named: the sidebar, the account menu, the Home greeting, the Users list (with the username beside it) and the emails RenewCMS sends, such as "Marta Alves invited you to RenewCMS". Saving it empty goes back to the username. The reset link uses the same flow as "Forgot password" and ends on the auth board\'s "Choose a new password" screen.', states: [
|
||||
withM({ ...home, home: { sub: 'Here\'s where things stand on your site.', stats, recent }, userMenu: true, name: 'Account menu', trigger: 'The account chip at the bottom of the sidebar. On mobile, the account row in the navigation drawer opens it as a sheet. Escape or a click outside closes it.' }),
|
||||
withM({ ...home, home: { sub: 'Here\'s where things stand on your site.', stats, recent, act: 'ok' }, userMenu: true, name: 'Account menu', trigger: 'The account chip at the bottom of the sidebar. On mobile, the account row in the navigation drawer opens it as a sheet. Escape or a click outside closes it.' }),
|
||||
P({ loading: true }, { name: 'Loading', trigger: 'Opened directly by URL before /auth/me has returned. From the menu, the cached user renders straight away.' }),
|
||||
P({}, { name: 'Default, no display name', trigger: 'The usual first visit. The username is used everywhere.' }),
|
||||
P({ value: dname, focus: true, dirty: true }, { name: 'Editing', trigger: 'Typing in the field. Save turns on, and leaving the page asks for confirmation.' }),
|
||||
@@ -364,13 +455,14 @@ class Component extends DCLogic {
|
||||
] }
|
||||
])
|
||||
}
|
||||
finish(gs) { return gs.map(g => ({ ...g, states: g.states.map(st => ({ notMedia: !st.isMedia, ...st, m: { ...st.m } })) })) }
|
||||
finish(gs) { return gs.map(g => ({ ...g, states: g.states.map(st => ({ notMedia: !st.isMedia && !st.isAct, ...st, m: { ...st.m } })) })) }
|
||||
renderVals() {
|
||||
const modes = [['system', 'System'], ['light', 'Light'], ['dark', 'Dark']].map(([k, label]) => {
|
||||
const on = this.state.mode === k
|
||||
return { label, on, bg: on ? 'var(--ui-primary)' : 'transparent', fg: on ? 'var(--ui-bg)' : 'var(--ui-text-toned)', pick: () => this.setState({ mode: k }) }
|
||||
})
|
||||
return { rootRef: this.rootRef, modes, groups: this.groups() }
|
||||
const rows = a => a.map(r => ({ a: r[0], b: r[1] }))
|
||||
return { rootRef: this.rootRef, modes, groups: this.groups(), spCopy: rows(this.state.spec.copy), spComps: rows(this.state.spec.comps), spApi: rows(this.state.spec.api) }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<meta name="design_doc_mode" content="canvas">
|
||||
<link rel="icon" href="brand/favicon.svg">
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&family=Source+Serif+4:ital,opsz,wght@0,8..60,400;0,8..60,600;1,8..60,400&display=swap" rel="stylesheet">
|
||||
<style>body{margin:0;font-family:'IBM Plex Sans',sans-serif;font-size:13px;line-height:20px}*{box-sizing:border-box}a{color:inherit;text-underline-offset:3px}a:hover{opacity:.8}code{font-family:'IBM Plex Mono',monospace;font-size:12px}</style>
|
||||
</helmet>
|
||||
<div ref="{{ rootRef }}" data-screen-label="Media cleanup screens" style="--ui-bg:#ffffff;--ui-bg-muted:#fafafa;--ui-bg-elevated:#f3f2f0;--ui-bg-accented:#e6e4e0;--ui-bg-inverted:#111111;--ui-text-dimmed:#999999;--ui-text-muted:#666666;--ui-text-toned:#444444;--ui-text:#222222;--ui-text-highlighted:#111111;--ui-text-inverted:#ffffff;--ui-border:#e6e4e0;--ui-border-muted:#f3f2f0;--ui-border-accented:#8a8783;--ui-primary:#111111;--ui-secondary:#635643;--ui-success:#315b33;--ui-info:#34536d;--ui-warning:#6c4700;--ui-error:#882c21;--rc-sand:#e8dfd1;--rc-success-soft:#eaefea;--rc-info-soft:#ebeef0;--rc-warning-soft:#f0ede6;--rc-error-soft:#f3eae9;--rc-overlay:rgb(10 10 10 / 0.4);--rc-shadow-pop:0 4px 16px rgb(17 17 17 / 0.08), 0 1px 2px rgb(17 17 17 / 0.06);--rc-shadow-modal:0 16px 48px rgb(17 17 17 / 0.18);--rc-canvas:#ecebe8;background:var(--rc-canvas);color:var(--ui-text);padding:64px;display:flex;flex-direction:column;gap:72px;width:max-content;min-width:100%">
|
||||
|
||||
<div style="display:flex;align-items:flex-end;gap:32px;max-width:1400px">
|
||||
<div style="flex:1;display:flex;flex-direction:column;gap:10px">
|
||||
<h1 style="margin:0;font:600 36px/44px 'IBM Plex Sans',sans-serif;letter-spacing:-0.02em;color:var(--ui-text-highlighted)">Screen: Media cleanup</h1>
|
||||
<p style="margin:0;max-width:880px;font-size:16px;line-height:26px;color:var(--ui-text-toned);text-wrap:pretty">A Media cleanup card on Settings, a purge dialog, and an entry point on the Media Duplicates tab (also in <a href="RenewCMS Screens Back-office.dc.html">the Back-office board</a>). 1440 px at 50%, 390 px at 75%. The theme switch shows every state in dark mode.</p>
|
||||
</div>
|
||||
<div role="radiogroup" aria-label="Theme" style="display:flex;border:1px solid var(--ui-border-accented);background:var(--ui-bg)">
|
||||
<sc-for list="{{ modes }}" as="m" hint-placeholder-count="3">
|
||||
<button role="radio" aria-checked="{{ m.on }}" onClick="{{ m.pick }}" style="all:unset;cursor:pointer;height:36px;padding:0 14px;display:flex;align-items:center;font-weight:500;font-size:14px;background:{{ m.bg }};color:{{ m.fg }}" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:2px">{{ m.label }}</button>
|
||||
</sc-for>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<sc-for list="{{ groups }}" as="g" hint-placeholder-count="7">
|
||||
<section data-screen-label="{{ g.label }}" style="display:flex;flex-direction:column;gap:28px">
|
||||
<div style="display:flex;flex-direction:column;gap:6px;max-width:1100px">
|
||||
<h2 style="margin:0;font:600 28px/36px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ g.title }}</h2>
|
||||
<div style="display:flex;gap:16px;flex-wrap:wrap;font:400 14px/22px 'IBM Plex Mono',monospace;color:var(--ui-text-toned)"><span>{{ g.route }}</span><span style="color:var(--ui-text-muted)">{{ g.api }}</span></div>
|
||||
<sc-if value="{{ g.note }}"><p style="margin:4px 0 0;font-size:15px;line-height:24px;color:var(--ui-text-toned);text-wrap:pretty">{{ g.note }}</p></sc-if>
|
||||
</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:40px;align-items:flex-start">
|
||||
<sc-for list="{{ g.states }}" as="st" hint-placeholder-count="4">
|
||||
<div style="display:flex;flex-direction:column;gap:12px">
|
||||
<div style="display:flex;flex-direction:column;gap:2px;max-width:1040px"><span style="font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ st.name }}</span><span style="font-size:13px;color:var(--ui-text-muted)">{{ st.trigger }}</span></div>
|
||||
<div style="display:flex;gap:24px;align-items:flex-start">
|
||||
<div style="width:720px;height:450px;overflow:hidden;border:1px solid var(--ui-border);flex-shrink:0"><div style="width:1440px;height:900px;zoom:0.5"><sc-if value="{{ st.notMedia }}"><dc-import name="SettingsFrame" s="{{ st }}" mobile="{{ false }}" hint-size="1440px,900px"></dc-import></sc-if><sc-if value="{{ st.isMedia }}"><dc-import name="MediaFrame" s="{{ st }}" mobile="{{ false }}" hint-size="1440px,900px"></dc-import></sc-if></div></div>
|
||||
<div style="width:293px;height:633px;overflow:hidden;border:1px solid var(--ui-border);flex-shrink:0"><div style="width:390px;height:844px;zoom:0.75"><sc-if value="{{ st.notMedia }}"><dc-import name="SettingsFrame" s="{{ st.m }}" mobile="{{ true }}" hint-size="390px,844px"></dc-import></sc-if><sc-if value="{{ st.isMedia }}"><dc-import name="MediaFrame" s="{{ st.m }}" mobile="{{ true }}" hint-size="390px,844px"></dc-import></sc-if></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
<sc-if value="{{ g.table }}"><div style="display:flex;flex-direction:column;gap:8px;max-width:1240px"><span style="font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ g.tableTitle }}</span><div style="background:var(--ui-bg);border:1px solid var(--ui-border)"><div style="display:grid;grid-template-columns:90px 240px minmax(0,1fr);padding:8px 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500"><span>Status</span><span>Where</span><span>Copy</span></div><sc-for list="{{ g.table }}" as="r" hint-placeholder-count="3"><div style="display:grid;grid-template-columns:90px 240px minmax(0,1fr);gap:0;padding:10px 12px;border-top:1px solid var(--ui-border);line-height:20px"><span style="font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ r.a }}</span><span style="color:var(--ui-text-toned);padding-right:16px">{{ r.b }}</span><span style="color:var(--ui-text-highlighted);text-wrap:pretty">{{ r.c }}</span></div></sc-for></div></div></sc-if>
|
||||
</section>
|
||||
</sc-for>
|
||||
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props="{"theme":{"editor":"enum","options":["system","light","dark"],"default":"system","tsType":"'system' | 'light' | 'dark'"}}">
|
||||
class Component extends DCLogic {
|
||||
state = { mode: this.props.theme ?? 'light' }
|
||||
rootRef = React.createRef()
|
||||
light = { '--ui-bg': '#ffffff', '--ui-bg-muted': '#fafafa', '--ui-bg-elevated': '#f3f2f0', '--ui-bg-accented': '#e6e4e0', '--ui-bg-inverted': '#111111', '--ui-text-dimmed': '#999999', '--ui-text-muted': '#666666', '--ui-text-toned': '#444444', '--ui-text': '#222222', '--ui-text-highlighted': '#111111', '--ui-text-inverted': '#ffffff', '--ui-border': '#e6e4e0', '--ui-border-muted': '#f3f2f0', '--ui-border-accented': '#8a8783', '--ui-primary': '#111111', '--ui-secondary': '#635643', '--ui-success': '#315b33', '--ui-info': '#34536d', '--ui-warning': '#6c4700', '--ui-error': '#882c21', '--rc-sand': '#e8dfd1', '--rc-success-soft': '#eaefea', '--rc-info-soft': '#ebeef0', '--rc-warning-soft': '#f0ede6', '--rc-error-soft': '#f3eae9', '--rc-overlay': 'rgb(10 10 10 / 0.4)', '--rc-shadow-pop': '0 4px 16px rgb(17 17 17 / 0.08), 0 1px 2px rgb(17 17 17 / 0.06)', '--rc-shadow-modal': '0 16px 48px rgb(17 17 17 / 0.18)', '--rc-canvas': '#ecebe8' }
|
||||
dark = { '--ui-bg': '#111111', '--ui-bg-muted': '#0a0a0a', '--ui-bg-elevated': '#1a1a1a', '--ui-bg-accented': '#222222', '--ui-bg-inverted': '#fafafa', '--ui-text-dimmed': '#666666', '--ui-text-muted': '#999999', '--ui-text-toned': '#cfccc7', '--ui-text': '#e6e4e0', '--ui-text-highlighted': '#fafafa', '--ui-text-inverted': '#111111', '--ui-border': '#333333', '--ui-border-muted': '#222222', '--ui-border-accented': '#666666', '--ui-primary': '#fafafa', '--ui-secondary': '#d4c5b0', '--ui-success': '#a3caa3', '--ui-info': '#a5c2db', '--ui-warning': '#dfb67d', '--ui-error': '#fba394', '--rc-sand': '#d4c5b0', '--rc-success-soft': '#1f241f', '--rc-info-soft': '#202428', '--rc-warning-soft': '#2a251e', '--rc-error-soft': '#2c201f', '--rc-overlay': 'rgb(0 0 0 / 0.6)', '--rc-shadow-pop': '0 4px 16px rgb(0 0 0 / 0.5)', '--rc-shadow-modal': '0 16px 48px rgb(0 0 0 / 0.6)', '--rc-canvas': '#1c1c1c' }
|
||||
resolved() {
|
||||
if (this.state.mode === 'system') return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
return this.state.mode
|
||||
}
|
||||
apply() {
|
||||
const el = this.rootRef.current
|
||||
if (!el) return
|
||||
const vars = this.resolved() === 'dark' ? this.dark : this.light
|
||||
for (const k in vars) el.style.setProperty(k, vars[k])
|
||||
document.body.style.background = vars['--rc-canvas']
|
||||
}
|
||||
componentDidMount() { this.apply() }
|
||||
componentDidUpdate(p) {
|
||||
if (p.theme !== this.props.theme && this.props.theme) this.setState({ mode: this.props.theme })
|
||||
this.apply()
|
||||
}
|
||||
groups() {
|
||||
const W = (name, trigger, o = {}, mo = {}) => ({ name, trigger, ...o, m: { ...o, ...mo } })
|
||||
const L = { d: '14 Jun 2026', c: 3, a: 5 }
|
||||
const on = { on: true, days: '30', last: L, next: '14 Jul 2026' }
|
||||
const D = (k, name, trigger, o = {}) => W(name, trigger, { ...on, last: null, on: false, dialog: { k, ...(o.dg || {}) }, ...o.s })
|
||||
const pvMore = { groups: 8, copies: 15, arts: 24, list: [{ im: 12, n: 'floorboards.jpg', u: 4, c: 2 }, { im: 7, n: 'IMG_4471.jpg', u: 2, c: 1 }, { im: 2, n: 'kitchen-after.webp', u: 3, c: 3 }, { im: 4, n: 'logo-renew.svg', u: 5, c: 3 }, { im: 11, n: 'lime-plaster.jpg', u: 2, c: 1 }], more: 3 }
|
||||
const pvOne = { groups: 1, copies: 1, arts: 2, list: [{ im: 7, n: 'IMG_4471.jpg', u: 2, c: 1 }], more: 0 }
|
||||
const pv3 = { groups: 3, copies: 7, arts: 10, list: [{ im: 12, n: 'floorboards.jpg', u: 4, c: 2 }, { im: 2, n: 'kitchen-after.webp', u: 3, c: 3 }, { im: 4, n: 'logo-renew.svg', u: 5, c: 3 }], more: 0 }
|
||||
const base = { last: null }
|
||||
const ST = (o) => ({ ...base, ...o })
|
||||
const Mp = (o) => ({ isMedia: true, kind: 'page', ...o, m: { isMedia: true, kind: 'page', ...o } })
|
||||
return this.finish([
|
||||
{ label: 'Settings', title: 'Settings: Media cleanup', route: '/settings', api: 'GET /v1/media/purge-settings · PATCH /v1/media/purge-settings (owner, admin)', note: 'A second card below Version, 20 px gap, same card styling. Automatic purge is off by default. Settings save immediately on toggle and on blur or Enter of the days field; there is no Save button. 401 and 403 are global (login redirect, /register/pending) and have no local copy.', states: [
|
||||
W('Default: off, never run', 'Owner or admin. Automatic purge is off, so the "Every" row and "Next purge" are hidden.', ST({})),
|
||||
W('On: 30 days, last run', 'Switch on. "Every" appears with the default of 30 days. The last-run line and the next run are shown.', on),
|
||||
W('Saving', 'Brief state after toggling or leaving the field. "Saving…" with loader-circle sits in the card header; controls stay usable.', { ...on, saving: true, focusDays: true }),
|
||||
W('Saved', 'The save succeeded. A small "Saved" confirmation shows in the card header and fades after 2 s. It is announced politely.', { ...on, saved: true }),
|
||||
W('Save failed', 'A 500 or network failure. The error sits under the field and the control reverts to its previous value (30).', { ...on, saveErr: true, days: '30', focusDays: true }),
|
||||
W('Validation error', 'Empty, 0, above 365 or not a whole number, checked on blur or Enter. Nothing is sent. The field keeps what was typed until corrected.', { ...on, days: '400', invalid: true, focusDays: true }),
|
||||
W('Read-only (non-admin)', 'Other verified users. Switch and input are disabled, "Purge now" is hidden, and an info alert explains why.', { ...on, ro: true }),
|
||||
W('Loading skeleton', 'The settings request is in flight. Skeleton shows only after 150 ms.', { loading: true }),
|
||||
W('Offline', 'navigator.onLine is false. The banner matches Media. Switch, input and button are disabled.', { ...on, offline: true })
|
||||
] },
|
||||
{ label: 'Dialog', title: 'Purge duplicates: dialog', route: 'opens from "Purge now" (Settings) and "Purge duplicates" (Media, Duplicates tab)', api: 'GET /v1/media/purge-preview · POST /v1/media/purge', note: 'UModal, sm:max-w-md, bottom sheet on mobile. Cancel first, then the danger confirm. While the request runs, the confirm shows loader-circle and the dialog can\'t be dismissed. The preview is computed before confirming. A group is purged only if one of its copies is used in an article; the kept copy is the one used in the most articles (ties: oldest, then lowest id).', states: [
|
||||
D('loading', 'Loading preview', 'The preview request is in flight. Skeleton rows show after 150 ms and the confirm is disabled.'),
|
||||
D('confirm', 'Confirm', '"Purge now" pressed. Summary, up to 5 groups, a link to the Duplicates tab.'),
|
||||
D('confirm', 'Confirm: many groups', 'More than 5 groups. The list stops at 5 and says how many more.', { dg: { pv: pvMore } }),
|
||||
D('progress', 'In progress', 'POST in flight. Confirm reads "Purging…" with loader-circle; Cancel is disabled and Esc, the overlay and swipe do nothing.'),
|
||||
D('nothing', 'Nothing to purge', 'The preview has no group with an image in use. No confirm; one Close button.'),
|
||||
D('error', 'Error', '500 or network failure. The dialog stays open, nothing changed. Confirm retries.'),
|
||||
D('partial', 'Partial failure', 'Some groups failed. The summary refreshes to what is left. Buttons: Close, Retry.', { dg: { pv: pvOne } }),
|
||||
D('conflict', 'Conflict (409)', 'The library changed since the preview. Nothing was purged, the summary refreshes and the dialog stays open.', { dg: { pv: pv3 } })
|
||||
] },
|
||||
{ label: 'Results', title: 'Results and feedback', route: '/settings', api: 'after POST /v1/media/purge', note: 'An unattended run does not toast. The next visit to Settings shows the updated "Last purge" line.', states: [
|
||||
W('Success', 'The dialog closes, a toast shows for 5 s, and the "Last purge" line updates in place.', { on: false, last: { d: '5 Oct 2026', c: 9, a: 12 }, toast: true }),
|
||||
W('Success, automatic purge on', 'Same, with the next run moved forward by the interval.', { on: true, days: '30', last: { d: '5 Oct 2026', c: 9, a: 12 }, next: '4 Nov 2026', toast: true })
|
||||
] },
|
||||
{ label: 'Media', title: 'Media: Duplicates tab entry point', route: '/media?filter=duplicates', api: 'owner and admin only', note: 'A secondary "Purge duplicates" button (icon merge) in the toolbar next to the sort menu, and in the stacked toolbar on mobile. Per-group "Merge into this one" is unchanged.', states: [
|
||||
Mp({ tab: 'dups', name: 'Duplicates tab with the button', trigger: 'Owner or admin.' }),
|
||||
Mp({ tab: 'dups', modal: { k: 'purge' }, name: 'Dialog from Media', trigger: 'The same dialog as on Settings.' }),
|
||||
Mp({ tab: 'dups', member: true, name: 'Member: button hidden', trigger: 'Other verified users.' }),
|
||||
Mp({ tab: 'dups', offline: true, name: 'Offline', trigger: 'The button is disabled with the existing banner.' })
|
||||
], tableTitle: 'Status to copy', table: [
|
||||
{ a: '200', b: 'Purge done', c: 'Toast "Duplicates purged" / "9 copies merged. 12 articles updated."' },
|
||||
{ a: '409', b: 'Library changed', c: '"The library changed, so nothing was purged. Check the new summary and try again."' },
|
||||
{ a: '207', b: 'Partial', c: '"Some groups couldn\'t be merged. 3 of 4 groups were purged. Try again to finish."' },
|
||||
{ a: '500', b: 'Purge failed', c: '"Couldn\'t purge the duplicates. Nothing was changed. Try again in a moment."' },
|
||||
{ a: '500', b: 'Setting not saved', c: '"Couldn\'t save. Try again in a moment."' },
|
||||
{ a: '400', b: 'Days invalid', c: '"Enter a whole number of days between 1 and 365."' }
|
||||
] }
|
||||
])
|
||||
}
|
||||
finish(gs) { return gs.map(g => ({ ...g, states: g.states.map(st => ({ notMedia: !st.isMedia, ...st, m: { ...st.m } })) })) }
|
||||
renderVals() {
|
||||
const modes = [['system', 'System'], ['light', 'Light'], ['dark', 'Dark']].map(([k, label]) => {
|
||||
const on = this.state.mode === k
|
||||
return { label, on, bg: on ? 'var(--ui-primary)' : 'transparent', fg: on ? 'var(--ui-bg)' : 'var(--ui-text-toned)', pick: () => this.setState({ mode: k }) }
|
||||
})
|
||||
return { rootRef: this.rootRef, modes, groups: this.groups() }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,121 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<div style="display:contents">{{ view }}</div>
|
||||
</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"}}">
|
||||
class Component extends DCLogic {
|
||||
renderVals() {
|
||||
const s = this.props.s || {}, m = !!this.props.mobile, h = React.createElement
|
||||
const P = t => Object.fromEntries(t.split(';').filter(x => x.trim()).map(x => { const i = x.indexOf(':'); const k = x.slice(0, i).trim(); return [k.startsWith('--') ? k : k.replace(/-([a-z])/g, (_, c) => c.toUpperCase()), x.slice(i + 1).trim()] }))
|
||||
const D = (t, ...c) => h('div', { style: P(t) }, ...c)
|
||||
const S = (t, ...c) => h('span', { style: P(t) }, ...c)
|
||||
const I = (n, z = 16, t = '') => h('span', { 'aria-hidden': 'true', style: { display: 'inline-block', flexShrink: 0, width: z, height: z, backgroundColor: 'currentColor', WebkitMask: 'url(https://api.iconify.design/lucide/' + n + '.svg) center / contain no-repeat', mask: 'url(https://api.iconify.design/lucide/' + n + '.svg) center / contain no-repeat', ...(t ? P(t) : {}) } })
|
||||
const bh = m ? 44 : 32
|
||||
const B = (label, o = {}) => {
|
||||
const k = o.k || 'outline'
|
||||
const bg = k === 'primary' ? 'var(--ui-primary)' : k === 'danger' ? 'var(--ui-error)' : 'var(--ui-bg)'
|
||||
const fg = k === 'primary' || k === 'danger' ? 'var(--ui-bg)' : 'var(--ui-text-highlighted)'
|
||||
const bd = k === 'primary' || k === 'danger' ? bg : 'var(--ui-border-accented)'
|
||||
return S('height:' + (o.h || bh) + 'px;padding:0 ' + (o.px || 12) + 'px;display:flex;align-items:center;justify-content:center;gap:6px;background:' + bg + ';color:' + fg + ';border:1px solid ' + bd + ';font-weight:500;font-size:13px;white-space:nowrap;opacity:' + (o.dis ? 0.4 : 1) + ';' + (o.full ? 'flex:1;' : ''), o.icon && I(o.icon, 16), label)
|
||||
}
|
||||
const CHECK = 'conic-gradient(var(--ui-bg-accented) 25%, var(--ui-bg) 0 50%, var(--ui-bg-accented) 0 75%, var(--ui-bg) 0) 0 0/16px 16px'
|
||||
const TONES = ['var(--rc-sand)', 'var(--ui-bg-accented)', 'color-mix(in srgb, var(--ui-border-accented) 35%, var(--ui-bg))', 'var(--ui-bg-elevated)']
|
||||
const mark = h('svg', { width: 20, height: 20, viewBox: '0 0 16 16', shapeRendering: 'crispEdges' }, h('rect', { width: 16, height: 16, fill: '#111' }), h('path', { fill: '#fff', fillRule: 'evenodd', d: 'M4 3h6v1h1v1h1v3h-1v1h-1v1h1v1h1v2h-2v-2h-1v-1H6v3H4zm2 2v3h4V5z' }))
|
||||
const Th = (im, t = '') => D('position:relative;overflow:hidden;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted);background:' + (im.alpha ? CHECK : 'repeating-linear-gradient(135deg, rgb(128 128 128 / 0.12) 0 1px, transparent 1px 12px), ' + TONES[im.tone % 4]) + ';' + t, im.svg ? mark : I('image', 16, 'opacity:0.55'))
|
||||
const IMX = { 12: { tone: 0 }, 7: { tone: 2 }, 2: { tone: 1 }, 4: { tone: 3, alpha: true, svg: true }, 11: { tone: 1 } }
|
||||
const Alert = (tone, icon, text, title) => D('display:flex;gap:10px;padding:12px 14px;background:var(--rc-' + tone + '-soft);color:var(--ui-' + tone + ')', S('display:flex;padding-top:2px', I(icon)), D('flex:1;display:flex;flex-direction:column;gap:2px', title && S('font-weight:600', title), S('color:var(--ui-text-toned)', text)))
|
||||
const hl = 'color:var(--ui-text-highlighted)'
|
||||
const mono = "font-family:'IBM Plex Mono',monospace"
|
||||
|
||||
// ---------- card
|
||||
const dis = !!(s.ro || s.offline || s.loading)
|
||||
const on = !!s.on
|
||||
const Sw = () => D('display:flex;align-items:center;justify-content:center;width:' + (m ? 44 : 36) + 'px;height:' + (m ? 44 : 24) + 'px;flex-shrink:0;opacity:' + (dis ? 0.4 : 1), D('position:relative;width:36px;height:20px;border:1px solid ' + (on ? 'var(--ui-primary)' : 'var(--ui-border-accented)') + ';background:' + (on ? 'var(--ui-primary)' : 'var(--ui-bg-accented)') + ';' + (s.focusSw ? 'outline:2px solid var(--ui-primary);outline-offset:2px;' : ''), D('position:absolute;top:1px;width:16px;height:16px;left:' + (on ? 17 : 1) + 'px;background:' + (on ? 'var(--ui-bg)' : 'var(--ui-bg)') + ';border:1px solid ' + (on ? 'transparent' : 'var(--ui-border-accented)') + ';box-sizing:border-box')))
|
||||
const Row = (label, help, ctl, stack) => D('display:flex;' + (m || stack ? 'flex-direction:column;align-items:stretch;' : 'align-items:flex-start;justify-content:space-between;') + 'gap:' + (m ? 12 : 16) + 'px', D('display:flex;flex-direction:column;gap:2px;min-width:0', S('font-weight:500;' + hl, label), help && S('font-size:12px;color:var(--ui-text-muted)', help)), ctl)
|
||||
const hasErr = s.invalid || s.saveErr
|
||||
const errTxt = s.invalid ? 'Enter a whole number of days between 1 and 365.' : 'Couldn\'t save. Try again in a moment.'
|
||||
const Days = () => D('display:flex;flex-direction:column;gap:6px;' + (m ? '' : 'align-items:flex-end;'),
|
||||
D('display:flex;align-items:center;width:' + (m ? '100%' : '140px') + ';min-height:' + (m ? 44 : 40) + 'px;border:1px solid ' + (hasErr ? 'var(--ui-error)' : s.focusDays ? 'var(--ui-primary)' : 'var(--ui-border-accented)') + ';' + (s.focusDays && !hasErr ? 'box-shadow:0 0 0 1px var(--ui-primary);' : '') + 'background:var(--ui-bg);font-size:' + (m ? 16 : 13) + 'px;opacity:' + (dis ? 0.4 : 1), S('flex:1;min-width:0;padding:0 12px;' + mono + ';' + hl, s.days ?? '30'), S('padding:0 12px;color:var(--ui-text-muted);' + mono + ';font-size:13px', 'days')),
|
||||
hasErr && S('display:flex;gap:6px;align-items:flex-start;color:var(--ui-error);font-size:12px;' + (m ? '' : 'max-width:300px;text-align:right;'), I('circle-alert', 14, 'margin-top:2px'), errTxt))
|
||||
const stat = s.saving ? S('display:flex;align-items:center;gap:6px;font-size:12px;color:var(--ui-text-muted)', I('loader-circle', 14), 'Saving…') : s.saved ? S('display:flex;align-items:center;gap:6px;font-size:12px;color:var(--ui-success)', I('check', 14), 'Saved') : null
|
||||
const last = s.last === undefined ? null : s.last
|
||||
const lastLine = last ? 'Last purge: ' + last.d + ' · ' + last.c + ' copies merged · ' + last.a + ' articles updated' : 'Never run'
|
||||
const Cleanup = () => {
|
||||
const kids = [
|
||||
D('display:flex;align-items:flex-start;gap:12px', D('flex:1;display:flex;flex-direction:column;gap:2px', S('font:600 15px/24px \'IBM Plex Sans\',sans-serif;' + hl, 'Media cleanup'), S('color:var(--ui-text-muted);text-wrap:pretty', 'Merge identical images so your library stays tidy. Articles that use a copy are switched to the most used one.')), D('height:24px;display:flex;align-items:center;flex-shrink:0', stat))
|
||||
]
|
||||
if (s.ro) kids.push(Alert('info', 'info', 'Only owners and admins can change this.'))
|
||||
kids.push(Row('Automatic purge', 'Runs in the background. Off by default.', Sw()))
|
||||
if (on) kids.push(Row('Every', 'Between 1 and 365 days.', Days()))
|
||||
kids.push(D('display:flex;flex-direction:column;gap:4px;color:var(--ui-text-toned)', S('', lastLine), on && S('', 'Next purge: ' + (s.next || '14 Jul 2026'))))
|
||||
if (!s.ro) {
|
||||
kids.push(D('height:1px;background:var(--ui-border)'))
|
||||
kids.push(Row('Purge now', 'Merge every group of duplicates that has an image in use, right away.', D('display:flex;' + (m ? '' : 'flex-shrink:0'), B('Purge duplicates', { icon: 'merge', dis: !!(s.offline || s.loading), full: m, ring: s.focusBtn }))))
|
||||
}
|
||||
return D('background:var(--ui-bg);border:1px solid var(--ui-border);padding:' + (m ? 16 : 20) + 'px;display:flex;flex-direction:column;gap:16px', ...kids)
|
||||
}
|
||||
const SkelBar = (w, hh = 13) => S('display:block;width:' + w + ';height:' + hh + 'px;background:var(--ui-bg-accented)')
|
||||
const CleanupSkel = () => D('background:var(--ui-bg);border:1px solid var(--ui-border);padding:' + (m ? 16 : 20) + 'px;display:flex;flex-direction:column;gap:16px', SkelBar('140px', 16), SkelBar('90%'), SkelBar('55%'), D('display:flex;justify-content:space-between;align-items:center', D('display:flex;flex-direction:column;gap:6px', SkelBar('120px'), SkelBar('180px', 11)), SkelBar('36px', 20)), SkelBar('60%'), D('height:1px;background:var(--ui-border)'), D('display:flex;justify-content:space-between;align-items:center', D('display:flex;flex-direction:column;gap:6px', SkelBar('100px'), SkelBar('220px', 11)), SkelBar('140px', bh)))
|
||||
const pad = m ? 16 : 24
|
||||
const page = D('flex:1;min-height:0;overflow:hidden;padding:' + pad + 'px;position:relative', D('max-width:720px;display:flex;flex-direction:column;gap:20px',
|
||||
D('display:flex;flex-direction:column;gap:2px', S('font:600 20px/28px \'IBM Plex Sans\',sans-serif;' + hl, 'Settings'), S('color:var(--ui-text-muted)', 'Workspace information.')),
|
||||
D('background:var(--ui-bg);border:1px solid var(--ui-border);padding:' + (m ? 16 : 20) + 'px;display:flex;align-items:center;justify-content:space-between;gap:16px', S('font:600 15px/24px \'IBM Plex Sans\',sans-serif;' + hl, 'Version'), S('font:400 14px/22px \'IBM Plex Mono\',monospace;color:var(--ui-text-toned)', 'v2.2.0-beta')),
|
||||
s.loading ? CleanupSkel() : Cleanup()))
|
||||
|
||||
// ---------- dialog
|
||||
const G = [{ im: 12, n: 'floorboards.jpg', u: 4, c: 2 }, { im: 7, n: 'IMG_4471.jpg', u: 2, c: 1 }, { im: 2, n: 'kitchen-after.webp', u: 3, c: 3 }, { im: 4, n: 'logo-renew.svg', u: 5, c: 3 }, { im: 11, n: 'lime-plaster.jpg', u: 2, c: 1 }]
|
||||
const pl = (n, w) => n + ' ' + w + (n === 1 ? '' : 's')
|
||||
const PV = { groups: 4, copies: 9, arts: 12, list: G.slice(0, 4), more: 0, ...(s.dialog && s.dialog.pv) }
|
||||
const summary = pv => pl(pv.groups, 'group') + ' · ' + pv.copies + (pv.copies === 1 ? ' copy' : ' copies') + ' will be deleted · ' + pl(pv.arts, 'article') + ' will be updated'
|
||||
const GList = pv => D('border:1px solid var(--ui-border)', ...pv.list.map((g, i) => D('display:flex;align-items:center;gap:12px;padding:8px 12px;border-top:' + (i ? '1px solid var(--ui-border-muted)' : '0'), Th(IMX[g.im], 'width:40px;height:30px;flex-shrink:0;border:1px solid var(--ui-border)'), D('flex:1;min-width:0;display:flex;flex-direction:column', S('font-weight:500;' + hl + ';overflow:hidden;text-overflow:ellipsis;white-space:nowrap', g.n), S('font-size:12px;color:var(--ui-text-muted)', 'Kept · used in ' + pl(g.u, 'article'))), S('font-size:12px;color:var(--ui-text-toned);white-space:nowrap;text-align:right', pl(g.c, 'copy').replace('copys', 'copies') + ' merged'))), pv.more > 0 && D('padding:8px 12px;border-top:1px solid var(--ui-border-muted);color:var(--ui-text-muted)', 'and ' + pv.more + ' more groups'))
|
||||
const Link = () => S('display:inline-flex;align-items:center;gap:4px;align-self:flex-start;font-weight:500;' + hl + ';text-decoration:underline;text-underline-offset:3px;min-height:' + (m ? 44 : 20) + 'px', 'Review in Duplicates', I('arrow-right', 14))
|
||||
const dg = s.dialog
|
||||
const Shell = (o, body) => D('position:absolute;inset:0;z-index:50;background:var(--rc-overlay);display:flex;align-items:' + (m ? 'flex-end' : 'flex-start') + ';justify-content:center;padding:' + (m ? 16 : 140) + 'px 16px 16px',
|
||||
D('width:' + (m ? '100%' : '448px') + ';max-height:100%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column',
|
||||
o.center ? D('padding:28px 24px 0;display:flex;flex-direction:column;align-items:center;gap:12px;text-align:center', S('width:44px;height:44px;display:flex;align-items:center;justify-content:center;background:var(--rc-info-soft);color:var(--ui-info)', I(o.icon, 20)), S('font:600 16px/24px \'IBM Plex Sans\',sans-serif;' + hl, o.title), S('color:var(--ui-text-muted);text-wrap:pretty;max-width:340px', o.desc))
|
||||
: D('padding:20px 24px 0;display:flex;flex-direction:column;gap:4px', S('font:600 16px/24px \'IBM Plex Sans\',sans-serif;' + hl, o.title), o.desc && S('color:var(--ui-text-muted);text-wrap:pretty', o.desc)),
|
||||
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, full: false }), o.confirm && S('height:' + bh + 'px;padding:0 12px;display:flex;align-items:center;justify-content:center;gap:6px;background:var(--ui-error);color:var(--ui-bg);font-weight:500;opacity:' + (o.cdis ? 0.4 : 1), o.loading ? I('loader-circle') : o.cicon && I(o.cicon), o.confirm))))
|
||||
const DESC = 'Articles that use a duplicate will be switched to the most used copy of that image, and the other copies will be deleted. This can\'t be undone.'
|
||||
const DSkel = () => [SkelBar('80%', 16), D('border:1px solid var(--ui-border)', ...[0, 1, 2].map(i => D('display:flex;align-items:center;gap:12px;padding:8px 12px;border-top:' + (i ? '1px solid var(--ui-border-muted)' : '0'), SkelBar('40px', 30), D('flex:1;display:flex;flex-direction:column;gap:6px', SkelBar('45%'), SkelBar('60%', 11)))))]
|
||||
const Dialog = () => {
|
||||
const k = dg.k, pv = PV
|
||||
const gp = 'Purge ' + pl(pv.groups, 'group')
|
||||
const note = (tone, icon, t) => Alert(tone, icon, t)
|
||||
if (k === 'loading') return Shell({ title: 'Purge duplicates?', desc: DESC, cancel: 'Cancel', confirm: 'Purge duplicates', cdis: true }, DSkel())
|
||||
if (k === 'nothing') return Shell({ center: true, icon: 'copy', title: 'Nothing to purge', desc: 'No duplicate is used in an article, so there is nothing to merge.', cancel: 'Close' }, null)
|
||||
const body = [S('font-weight:500;' + hl, summary(pv)), GList(pv), Link()]
|
||||
if (k === 'confirm') return Shell({ title: 'Purge duplicates?', desc: DESC, cancel: 'Cancel', confirm: gp }, body)
|
||||
if (k === 'progress') return Shell({ title: 'Purge duplicates?', desc: DESC, cancel: 'Cancel', confirm: 'Purging…', loading: true }, body)
|
||||
if (k === 'error') return Shell({ title: 'Purge duplicates?', desc: DESC, cancel: 'Cancel', confirm: gp }, [note('error', 'circle-x', 'Couldn\'t purge the duplicates. Nothing was changed. Try again in a moment.'), ...body])
|
||||
if (k === 'partial') return Shell({ title: 'Purge duplicates?', desc: DESC, cancel: 'Close', confirm: 'Retry', cicon: 'rotate-cw' }, [note('warning', 'triangle-alert', 'Some groups couldn\'t be merged. 3 of 4 groups were purged. Try again to finish.'), ...body])
|
||||
if (k === 'conflict') return Shell({ title: 'Purge duplicates?', desc: DESC, cancel: 'Cancel', confirm: gp }, [note('warning', 'triangle-alert', 'The library changed, so nothing was purged. Check the new summary and try again.'), ...body])
|
||||
}
|
||||
const Toast = () => D('position:absolute;z-index:60;' + (m ? 'left:16px;right:16px;bottom:16px' : 'right:24px;bottom:24px;width:356px') + ';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-success)', I('circle-check')), D('flex:1;display:flex;flex-direction:column;gap:2px', S('font-weight:600;' + hl, 'Duplicates purged'), S('color:var(--ui-text-muted)', '9 copies merged. 12 articles updated.')), I('x'))
|
||||
|
||||
// ---------- shell
|
||||
const nav = [['home', 'house', 'Home'], ['articles', 'file-text', 'Articles'], ['media', 'images', 'Media'], ['users', 'users', 'Users'], ['integration', 'plug', 'Integration'], ['settings', 'settings', 'Settings']]
|
||||
const mk = (z) => h('svg', { width: z, height: z, viewBox: '0 0 16 16', shapeRendering: 'crispEdges' }, h('rect', { width: 16, height: 16, fill: '#111' }), h('path', { fill: '#fff', fillRule: 'evenodd', d: 'M4 3h6v1h1v1h1v3h-1v1h-1v1h1v1h1v2h-2v-2h-1v-1H6v3H4zm2 2v3h4V5z' }))
|
||||
const side = D('width:224px;flex-shrink:0;background:var(--ui-bg);border-right:1px solid var(--ui-border);display:flex;flex-direction:column;padding:12px;gap:2px',
|
||||
D('display:flex;align-items:center;gap:10px;height:40px;padding:0 6px;margin-bottom:10px', mk(26), S('font-weight:600;font-size:14px;' + hl, 'RenewCMS')),
|
||||
...nav.map(([k, ic, t]) => D('display:flex;align-items:center;gap:10px;height:32px;padding:0 10px;background:' + (k === 'settings' ? 'var(--rc-sand)' : 'transparent') + ';color:' + (k === 'settings' ? '#111111' : 'var(--ui-text-toned)') + ';font-weight:' + (k === 'settings' ? 500 : 400), I(ic), t)),
|
||||
D('flex:1'), D('display:flex;align-items:center;gap:10px;height:40px;padding:0 6px;' + hl, S('width:26px;height:26px;flex-shrink:0;background:#d4c5b0;color:#111;display:flex;align-items:center;justify-content:center;font:600 11px \'IBM Plex Sans\',sans-serif', 'MA'), S('flex:1;font-weight:500', 'Marta Alves'), I('chevrons-up-down', 14)))
|
||||
const topbar = m
|
||||
? D('height:56px;flex-shrink:0;border-bottom:1px solid var(--ui-border);background:var(--ui-bg);display:flex;align-items:center;padding:0 6px;gap:4px;' + hl, S('width:44px;height:44px;display:flex;align-items:center;justify-content:center', I('menu', 20)), S('flex:1;font-weight:600;font-size:16px', 'Settings'))
|
||||
: D('height:48px;flex-shrink:0;border-bottom:1px solid var(--ui-border);background:var(--ui-bg);display:flex;align-items:center;padding:0 24px;gap:8px;color:var(--ui-text-muted)', I('panel-left-close'), S('width:1px;height:16px;background:var(--ui-border);margin:0 6px'), S('font-weight:500;' + hl, 'Settings'))
|
||||
const offline = s.offline && D('flex-shrink:0;display:flex;align-items:center;gap:10px;padding:10px 16px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted)', I('wifi-off'), S('flex:1', 'You\'re offline. Changing automatic purge and purging duplicates need a connection.'))
|
||||
const root = D('width:' + (m ? 390 : 1440) + 'px;height:' + (m ? 844 : 900) + 'px;position:relative;display:flex;flex-direction:' + (m ? 'column' : 'row') + ';background:var(--ui-bg-muted);color:var(--ui-text);font-family:\'IBM Plex Sans\',sans-serif;font-size:13px;line-height:20px;overflow:hidden',
|
||||
!m && side, D('flex:1;min-width:0;min-height:0;display:flex;flex-direction:column', topbar, offline, page), dg && Dialog(), s.toast && Toast())
|
||||
return { view: root }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -7,7 +7,7 @@ Feature: Recent activity card on Home and Retention card on Settings
|
||||
Background:
|
||||
Given the activity API (features/activity-log-backend.feature, activity-log-producers.feature, activity_screen.feature) and the settings retention fields already exist
|
||||
And this slice adds one field to the settings API, activity_next_cleanup_at, and wires the SPA to the existing endpoints; there is no other Go change
|
||||
And the Home card and the Settings "Retention" card are the two items activity_screen.feature left out of scope; the spec of record is docs/design/screens/home.md ("Recent activity card") and docs/design/screens/settings.md ("Activity retention"), groups "Home card" and "Settings retention" of RenewCMS Screens Activity.dc.html
|
||||
And the Home card and the Settings "Retention" card are the two items activity_screen.feature left out of scope; the spec of record is docs/design/screens/home.md ("Recent activity") and docs/design/screens/settings.md ("Activity retention"), groups "Home" and "Settings: Retention" of RenewCMS Screens Back-office.dc.html
|
||||
And both boards draw the existing cards as dashed placeholders, so neither card is pixel-compared and Vitest is the SPA coverage
|
||||
And "owner" and "admin" are the full Home view; any other or absent session role is the regular-user view
|
||||
And every new English string has a French entry in messages.ts
|
||||
|
||||
@@ -18,10 +18,10 @@ Feature: Media cleanup in the SPA: Settings card, purge dialog and Media Duplica
|
||||
summary and try again." (stale fingerprint) or "A purge is already running." (busy)
|
||||
And "owner or admin" is session.role in ('owner', 'admin'); only those two roles exist, so any
|
||||
other role is a mocked value in tests
|
||||
And /settings is the verified route of settings_page.feature; the Media cleanup card is its second
|
||||
And /settings is the verified route of settings_page.feature; the Media cleanup card is its third
|
||||
card, below Version and Updates, 20 px under the previous card, in a page at most 720 px wide
|
||||
And the card is --ui-bg with a 1 px --ui-border, 20 px padding (16 px on mobile), 16 px gap, as
|
||||
drawn in docs/design/source/SettingsFrame.dc.html
|
||||
drawn in docs/design/source/AppFrame.dc.html
|
||||
And the components are Nuxt UI v4 only (UCard, USwitch, UInputNumber, UFormField, UAlert, UButton,
|
||||
UModal, USkeleton, useToast) and design tokens only
|
||||
And PurgeDuplicatesDialog is one component shared by the card and the Media Duplicates tab: props
|
||||
|
||||
@@ -61,7 +61,7 @@ Amendments to existing specs, in the same change: `SettingsView.spec.ts` and the
|
||||
|
||||
## Visual (`task visual:check -- settings` and `task visual:check -- media`, desktop and mobile, light and dark)
|
||||
|
||||
References: `docs/design/screens/settings.md` ("Media cleanup (second card)"), `docs/design/screens/media.md`, `docs/design/source/RenewCMS Screens Media cleanup.dc.html` and the frames `SettingsFrame.dc.html`, `MediaFrame.dc.html` (exact values). Add every state below to `web/visual/states.ts`, with the mocked `/v1` calls the state needs. The gate is hard: every capture within 10 %. Nothing is skipped: no state depends on the clock, because dates come from fixture timestamps (midday UTC, so the day is stable).
|
||||
References: `docs/design/screens/settings.md` ("Media cleanup (third card)"), `docs/design/screens/media.md`, `docs/design/source/RenewCMS Screens Back-office.dc.html` and the frames `AppFrame.dc.html`, `MediaFrame.dc.html` (exact values). Add every state below to `web/visual/states.ts`, with the mocked `/v1` calls the state needs. The gate is hard: every capture within 10 %. Nothing is skipped: no state depends on the clock, because dates come from fixture timestamps (midday UTC, so the day is stable).
|
||||
|
||||
Board sample data to reuse:
|
||||
- Last purge fixture: `last_purge_at` 14 Jun 2026, merged 3, articles 5; "on" has 30 days and next 14 Jul 2026 (`next_purge_at` "2026-07-14T09:00:00Z").
|
||||
|
||||
@@ -31,48 +31,37 @@ function metaLine(item: ActivityItem, own: boolean) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UCard
|
||||
<section
|
||||
data-testid="recent-activity-card"
|
||||
:aria-busy="loading && showSkeleton ? 'true' : undefined"
|
||||
:ui="{
|
||||
root: 'min-w-0 rounded-none border border-default bg-default shadow-none ring-0 divide-y-0',
|
||||
header: 'flex items-center gap-2 border-b border-default px-4 py-3 sm:px-4 sm:py-3',
|
||||
body: 'flex flex-col px-4 pt-1 pb-1.5 sm:px-4 sm:pt-1 sm:pb-1.5',
|
||||
}"
|
||||
class="flex min-w-0 flex-col gap-2.5"
|
||||
>
|
||||
<template #header>
|
||||
<h2 class="flex-1 text-base font-semibold text-highlighted">
|
||||
<div class="flex items-baseline justify-between">
|
||||
<h2 class="text-base/6 font-semibold text-highlighted">
|
||||
{{ own ? t('Your recent activity') : t('Recent activity') }}
|
||||
</h2>
|
||||
<RouterLink
|
||||
to="/activity"
|
||||
class="flex items-center gap-1 font-medium text-highlighted underline underline-offset-3 max-lg:min-h-11 lg:min-h-5"
|
||||
>
|
||||
<RouterLink to="/activity" class="text-toned underline underline-offset-3">
|
||||
{{ t('View all') }}
|
||||
<UIcon name="i-lucide-arrow-right" class="size-3.5" />
|
||||
</RouterLink>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<template v-if="loading">
|
||||
<template v-if="showSkeleton">
|
||||
<div v-if="showSkeleton" class="border border-default bg-default">
|
||||
<div
|
||||
v-for="n in SKELETON_ROWS"
|
||||
:key="n"
|
||||
data-testid="recent-activity-skeleton"
|
||||
class="flex gap-2.5 py-2.5"
|
||||
class="flex min-h-13 items-center gap-3 px-3 py-2 lg:min-h-11"
|
||||
:class="n > 1 ? 'border-t border-muted' : ''"
|
||||
>
|
||||
<USkeleton class="size-6 shrink-0 rounded-none bg-accented" />
|
||||
<div class="flex flex-1 flex-col gap-1.5">
|
||||
<USkeleton
|
||||
class="h-3 rounded-none bg-accented"
|
||||
:class="SKELETON_SENTENCE_WIDTHS[n - 1]"
|
||||
/>
|
||||
<USkeleton class="h-2.5 w-7/20 rounded-none bg-accented" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<UAlert
|
||||
v-else-if="error"
|
||||
@@ -83,13 +72,16 @@ function metaLine(item: ActivityItem, own: boolean) {
|
||||
:close="false"
|
||||
icon="i-lucide-circle-x"
|
||||
:title="t('Couldn\'t load recent activity')"
|
||||
:description="t('Check your connection and try again.')"
|
||||
:ui="{ actions: 'mt-2' }"
|
||||
>
|
||||
<template #actions>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-rotate-cw"
|
||||
class="h-11 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 lg:h-7"
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
@click="$emit('retry')"
|
||||
>
|
||||
{{ t('Try again') }}
|
||||
@@ -100,11 +92,11 @@ function metaLine(item: ActivityItem, own: boolean) {
|
||||
<div
|
||||
v-else-if="items.length === 0"
|
||||
data-testid="recent-activity-empty"
|
||||
class="flex flex-col items-center gap-1.5 py-6 text-center"
|
||||
class="flex flex-col items-center gap-1.5 border border-default bg-default px-6 py-8 text-center"
|
||||
>
|
||||
<UIcon name="i-lucide-history" class="size-5 text-muted" />
|
||||
<span class="font-semibold text-highlighted">{{ t('No activity yet') }}</span>
|
||||
<span class="max-w-60 text-muted">
|
||||
<span class="max-w-65 text-muted">
|
||||
{{
|
||||
own
|
||||
? t('Your actions will appear here.')
|
||||
@@ -113,26 +105,23 @@ function metaLine(item: ActivityItem, own: boolean) {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ul v-else class="flex flex-col">
|
||||
<ul v-else class="border border-default bg-default">
|
||||
<li
|
||||
v-for="(item, index) in items"
|
||||
:key="item.id"
|
||||
data-testid="recent-activity-row"
|
||||
class="flex items-start gap-2.5 py-2.5"
|
||||
class="flex min-h-13 items-center gap-3 px-3 py-2 lg:min-h-11"
|
||||
:class="index > 0 ? 'border-t border-muted' : ''"
|
||||
>
|
||||
<ActivityAvatar :actor="item.actor" />
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span
|
||||
class="line-clamp-2 text-default wrap-anywhere"
|
||||
data-testid="recent-activity-sentence"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<span class="truncate text-default" data-testid="recent-activity-sentence">
|
||||
<ActivitySentence :item="item" />
|
||||
</span>
|
||||
<span
|
||||
data-testid="recent-activity-meta"
|
||||
:title="fullLocalTime(item.created_at)"
|
||||
class="truncate text-xs/5 text-muted"
|
||||
class="truncate text-xs text-muted"
|
||||
>
|
||||
{{ metaLine(item, own) }}
|
||||
</span>
|
||||
@@ -146,5 +135,5 @@ function metaLine(item: ActivityItem, own: boolean) {
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</UCard>
|
||||
</section>
|
||||
</template>
|
||||
@@ -193,7 +193,7 @@ function reviewDuplicates() {
|
||||
<template v-else>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<h2 id="media-cleanup-h" class="text-lg font-semibold text-highlighted">
|
||||
<h2 id="media-cleanup-h" class="text-[0.9375rem]/6 font-semibold text-highlighted">
|
||||
{{ t('Media cleanup') }}
|
||||
</h2>
|
||||
<p class="text-pretty text-muted">
|
||||
|
||||
@@ -146,23 +146,20 @@ onBeforeUnmount(clearSavedTimer)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UCard
|
||||
as="section"
|
||||
<section
|
||||
aria-labelledby="retention-h"
|
||||
data-testid="retention-card"
|
||||
:aria-busy="skeletonVisible ? 'true' : undefined"
|
||||
:ui="{
|
||||
root: 'rounded-none border border-default bg-default shadow-none ring-0 divide-y-0',
|
||||
header: 'flex items-center gap-2 border-b border-default px-4 py-3 sm:px-4 sm:py-3',
|
||||
body: 'flex flex-col gap-4 p-4 sm:p-4',
|
||||
}"
|
||||
class="flex flex-col gap-4 border border-default bg-default p-4 lg:p-5"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<h2 id="retention-h" class="text-base font-semibold text-highlighted">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<h2 id="retention-h" class="text-[0.9375rem]/6 font-semibold text-highlighted">
|
||||
{{ t('Retention') }}
|
||||
</h2>
|
||||
<p class="text-xs text-muted">{{ t('How long activity is kept.') }}</p>
|
||||
<p class="text-pretty text-muted">
|
||||
{{ t('How long activity is kept. Only owners can change it.') }}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
aria-live="polite"
|
||||
@@ -184,7 +181,7 @@ onBeforeUnmount(clearSavedTimer)
|
||||
{{ t('Saved') }}
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<template v-if="skeletonVisible">
|
||||
<USkeleton :class="[SKELETON_BAR, 'h-3.5 w-30']" />
|
||||
@@ -244,10 +241,13 @@ onBeforeUnmount(clearSavedTimer)
|
||||
</template>
|
||||
</UAlert>
|
||||
|
||||
<div class="flex max-w-65 flex-col gap-1.5">
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between md:gap-4">
|
||||
<div class="flex min-w-0 flex-col gap-0.5">
|
||||
<span id="retention-label" class="font-medium text-highlighted">
|
||||
{{ t('Keep activity for') }}
|
||||
</span>
|
||||
<span class="text-xs text-muted">{{ helperCopy(selected) }}</span>
|
||||
</div>
|
||||
<USelectMenu
|
||||
:model-value="selected"
|
||||
:items="options"
|
||||
@@ -257,15 +257,14 @@ onBeforeUnmount(clearSavedTimer)
|
||||
aria-labelledby="retention-label"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
class="w-full md:w-40"
|
||||
:ui="{
|
||||
base: 'h-11 rounded-none border border-accented bg-default px-2.5 text-sm ring-0 md:h-8',
|
||||
trailingIcon: 'size-3.5 text-muted',
|
||||
base: 'h-11 rounded-none border border-accented bg-default px-3 text-base ring-0 md:h-8 md:text-sm',
|
||||
trailingIcon: 'size-4 text-muted',
|
||||
content: 'min-w-48',
|
||||
}"
|
||||
@update:model-value="choose($event as number)"
|
||||
/>
|
||||
<span class="text-xs text-muted">{{ helperCopy(selected) }}</span>
|
||||
</div>
|
||||
|
||||
<UAlert
|
||||
@@ -316,22 +315,20 @@ onBeforeUnmount(clearSavedTimer)
|
||||
"
|
||||
/>
|
||||
|
||||
<USeparator />
|
||||
|
||||
<dl class="flex flex-col gap-1.5">
|
||||
<div class="flex flex-col md:flex-row md:gap-3">
|
||||
<dt class="shrink-0 text-muted md:w-30">{{ t('Last cleanup') }}</dt>
|
||||
<dd data-testid="retention-last" class="text-highlighted">
|
||||
<dl class="flex flex-col gap-1 text-toned">
|
||||
<div class="flex flex-wrap gap-x-1">
|
||||
<dt class="after:content-[':']">{{ t('Last cleanup') }}</dt>
|
||||
<dd data-testid="retention-last">
|
||||
{{ lastCleanupLine(settings) }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex flex-col md:flex-row md:gap-3">
|
||||
<dt class="shrink-0 text-muted md:w-30">{{ t('Next cleanup') }}</dt>
|
||||
<div class="flex flex-wrap gap-x-1">
|
||||
<dt class="after:content-[':']">{{ t('Next cleanup') }}</dt>
|
||||
<dd data-testid="retention-next" class="text-highlighted">
|
||||
{{ nextCleanupLine(settings) }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</template>
|
||||
</UCard>
|
||||
</section>
|
||||
</template>
|
||||
@@ -412,6 +412,7 @@ const block = computed<Block>(() => {
|
||||
"This site can't update itself from the browser yet. Whoever manages your server can set it up once, or update RenewCMS for you.",
|
||||
)
|
||||
},
|
||||
meta: lastChecked.value,
|
||||
}
|
||||
}
|
||||
if (props.status.available) {
|
||||
@@ -427,6 +428,7 @@ const block = computed<Block>(() => {
|
||||
[props.status.current],
|
||||
)
|
||||
},
|
||||
meta: lastChecked.value,
|
||||
}
|
||||
}
|
||||
return {
|
||||
@@ -602,7 +604,7 @@ const notes = computed(() => {
|
||||
id="updates-h"
|
||||
ref="heading"
|
||||
tabindex="-1"
|
||||
class="self-start text-lg font-semibold text-highlighted focus:outline-2 focus:outline-offset-4 focus:outline-primary"
|
||||
class="self-start text-[0.9375rem]/6 font-semibold text-highlighted focus:outline-2 focus:outline-offset-4 focus:outline-primary"
|
||||
>
|
||||
{{ t('Updates') }}
|
||||
</h2>
|
||||
@@ -689,7 +691,7 @@ const notes = computed(() => {
|
||||
class="inline-flex items-center gap-1.5 self-start text-sm font-medium text-highlighted underline underline-offset-3"
|
||||
>
|
||||
{{ t('How to update') }}
|
||||
<UIcon name="i-lucide-arrow-up-right" class="size-3.5" />
|
||||
<UIcon name="i-lucide-external-link" class="size-3.5" />
|
||||
</a>
|
||||
|
||||
<a
|
||||
@@ -700,7 +702,7 @@ const notes = computed(() => {
|
||||
class="inline-flex items-center gap-1.5 self-start text-sm font-medium text-highlighted underline underline-offset-3"
|
||||
>
|
||||
{{ t('How to update') }}
|
||||
<UIcon name="i-lucide-arrow-up-right" class="size-3.5" />
|
||||
<UIcon name="i-lucide-external-link" class="size-3.5" />
|
||||
</a>
|
||||
|
||||
<a
|
||||
@@ -711,7 +713,7 @@ const notes = computed(() => {
|
||||
class="inline-flex items-center gap-1.5 self-start text-sm font-medium text-highlighted underline underline-offset-3"
|
||||
>
|
||||
{{ t('Read the release notes') }}
|
||||
<UIcon name="i-lucide-arrow-up-right" class="size-3.5" />
|
||||
<UIcon name="i-lucide-external-link" class="size-3.5" />
|
||||
</a>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1193,7 +1193,8 @@ export const messages = {
|
||||
'Actions on your site will appear here.': 'Les actions sur votre site apparaîtront ici.',
|
||||
'Your actions will appear here.': 'Vos actions apparaîtront ici.',
|
||||
Retention: 'Conservation',
|
||||
'How long activity is kept.': 'Durée de conservation de l’activité.',
|
||||
'How long activity is kept. Only owners can change it.':
|
||||
'Durée de conservation de l’activité. Seul le propriétaire peut la modifier.',
|
||||
'Keep activity for': 'Conserver l’activité pendant',
|
||||
'90 days': '90 jours',
|
||||
'365 days': '365 jours',
|
||||
|
||||
@@ -206,11 +206,7 @@ onMounted(loadArticles)
|
||||
@dismiss="dismissBanner"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,1fr)_--spacing(105)] lg:items-start"
|
||||
>
|
||||
<RecentActivityCard class="lg:order-2" />
|
||||
<div class="flex min-w-0 flex-col gap-5 lg:order-1 lg:gap-7">
|
||||
<div class="flex min-w-0 flex-col gap-5 lg:gap-7">
|
||||
<template
|
||||
v-if="(loadState === 'loading' && showSkeleton) || loadState === 'default'"
|
||||
>
|
||||
@@ -236,7 +232,7 @@ onMounted(loadArticles)
|
||||
|
||||
<section class="flex flex-col gap-2.5">
|
||||
<div class="flex items-baseline justify-between">
|
||||
<h2 class="text-lg font-semibold text-highlighted">
|
||||
<h2 class="text-base/6 font-semibold text-highlighted">
|
||||
{{ t('Recently updated') }}
|
||||
</h2>
|
||||
<RouterLink
|
||||
@@ -343,6 +339,8 @@ onMounted(loadArticles)
|
||||
</template>
|
||||
</UAlert>
|
||||
|
||||
<RecentActivityCard />
|
||||
|
||||
<div class="flex items-start gap-3.5 border border-default bg-default p-4">
|
||||
<div
|
||||
class="flex size-9 shrink-0 items-center justify-center bg-secondary-200 text-primary-900 dark:bg-secondary-300"
|
||||
@@ -370,7 +368,6 @@ onMounted(loadArticles)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NewArticleModal v-model:open="newArticleModalOpen" @created="onArticleCreated" />
|
||||
</DashboardShell>
|
||||
|
||||
@@ -430,7 +430,7 @@ onBeforeUnmount(() => {
|
||||
</UAlert>
|
||||
|
||||
<div v-else class="flex items-center justify-between gap-4">
|
||||
<span class="text-lg font-semibold text-highlighted">{{ t('Version') }}</span>
|
||||
<span class="text-[0.9375rem]/6 font-semibold text-highlighted">{{ t('Version') }}</span>
|
||||
<USkeleton
|
||||
v-if="loadState === 'loading' && showSkeleton"
|
||||
data-testid="settings-skeleton"
|
||||
|
||||
@@ -198,14 +198,16 @@ describe('Home rows, unknown action, failed login and not interactive', () => {
|
||||
expect(router.currentRoute.value.path).toBe('/home')
|
||||
})
|
||||
|
||||
it('clamps a long sentence to two lines', async () => {
|
||||
it('truncates a long sentence on one line', async () => {
|
||||
const items = [makeItem({ id: 1, target: target('article', '8', 'T'.repeat(150)) })]
|
||||
await mountHome('owner', {
|
||||
[ACTIVITY_ROUTE]: () => page(items),
|
||||
[ARTICLES_ROUTE]: () => response(200, articles),
|
||||
})
|
||||
|
||||
expect(rows()[0]!.find('.line-clamp-2').exists()).toBe(true)
|
||||
expect(rows()[0]!.find('[data-testid="recent-activity-sentence"]').classes()).toContain(
|
||||
'truncate',
|
||||
)
|
||||
expect(rows()[0]!.find('[data-testid="recent-activity-meta"]').classes()).toContain(
|
||||
'truncate',
|
||||
)
|
||||
@@ -322,22 +324,19 @@ describe('Home card and article list are independent', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Home layout is two columns from lg and stacked below', () => {
|
||||
it('uses the lg two-column grid with the 420 px column and no max-width cap', async () => {
|
||||
describe('Home layout puts Recent activity full width below Recently updated', () => {
|
||||
it('renders the section after the recent list inside the same single column', async () => {
|
||||
await mountHome('owner', {
|
||||
[ACTIVITY_ROUTE]: () => page(recentItems(1)),
|
||||
[ARTICLES_ROUTE]: () => response(200, articles),
|
||||
})
|
||||
|
||||
const grid = card().element.parentElement!
|
||||
expect(grid.className).toContain('lg:grid-cols-[minmax(0,1fr)_--spacing(105)]')
|
||||
expect(grid.className).toContain('lg:items-start')
|
||||
expect(grid.className).toContain('gap-4')
|
||||
const capped = [grid, grid.parentElement!].some((element) =>
|
||||
/(^|\s)max-w-/.test(element.className),
|
||||
)
|
||||
expect(capped).toBe(false)
|
||||
expect(card().classes()).toContain('lg:order-2')
|
||||
const column = card().element.parentElement!
|
||||
expect(column.className).not.toContain('grid-cols')
|
||||
const list = column.querySelector('[data-testid="recent-list"]')!
|
||||
expect(
|
||||
list.compareDocumentPosition(card().element) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
+14
-20
@@ -26,14 +26,13 @@ interface Screen {
|
||||
sources: Source[]
|
||||
}
|
||||
|
||||
const cleanupBoard = 'RenewCMS Screens Media cleanup.dc.html'
|
||||
const activityBoard = 'RenewCMS Screens Activity.dc.html'
|
||||
const officeBoard = 'RenewCMS Screens Back-office.dc.html'
|
||||
|
||||
const extraSources: Record<string, Source[]> = {
|
||||
activity: [
|
||||
{
|
||||
board: activityBoard,
|
||||
group: 'Details slide-over',
|
||||
board: officeBoard,
|
||||
group: 'Activity details',
|
||||
states: [
|
||||
'Edit with a diff',
|
||||
'Settings diff, long text',
|
||||
@@ -45,7 +44,7 @@ const extraSources: Record<string, Source[]> = {
|
||||
alias: {},
|
||||
},
|
||||
{
|
||||
board: activityBoard,
|
||||
board: officeBoard,
|
||||
group: 'My activity',
|
||||
states: ['Default', 'Empty', 'Details'],
|
||||
alias: {},
|
||||
@@ -53,8 +52,8 @@ const extraSources: Record<string, Source[]> = {
|
||||
],
|
||||
settings: [
|
||||
{
|
||||
board: cleanupBoard,
|
||||
group: 'Settings',
|
||||
board: officeBoard,
|
||||
group: 'Settings: Media cleanup',
|
||||
states: [
|
||||
'Default: off, never run',
|
||||
'On: 30 days, last run',
|
||||
@@ -69,8 +68,8 @@ const extraSources: Record<string, Source[]> = {
|
||||
alias: {},
|
||||
},
|
||||
{
|
||||
board: cleanupBoard,
|
||||
group: 'Dialog',
|
||||
board: officeBoard,
|
||||
group: 'Purge dialog',
|
||||
states: [
|
||||
'Loading preview',
|
||||
'Confirm',
|
||||
@@ -84,23 +83,18 @@ const extraSources: Record<string, Source[]> = {
|
||||
alias: {},
|
||||
},
|
||||
{
|
||||
board: cleanupBoard,
|
||||
group: 'Results',
|
||||
board: officeBoard,
|
||||
group: 'Purge results',
|
||||
states: ['Success', 'Success, automatic purge on'],
|
||||
alias: {},
|
||||
},
|
||||
],
|
||||
media: [
|
||||
{
|
||||
board: cleanupBoard,
|
||||
group: 'Media',
|
||||
states: [
|
||||
'Duplicates tab with the button',
|
||||
'Dialog from Media',
|
||||
'Member: button hidden',
|
||||
'Offline: button disabled',
|
||||
],
|
||||
alias: { 'Offline: button disabled': 'Offline' },
|
||||
board: officeBoard,
|
||||
group: 'Media duplicates entry',
|
||||
states: ['Duplicates tab with the button', 'Duplicates tab: offline'],
|
||||
alias: {},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
+69
-122
@@ -165,11 +165,6 @@ const verifiedSession: ApiMock = {
|
||||
}
|
||||
|
||||
const updateRunningVersion = 'v2.2.0-beta'
|
||||
const newCardAbsentReason = 'new card absent from board frame (dashed placeholder)'
|
||||
const homeActivityMock: ApiMock = {
|
||||
status: 200,
|
||||
body: { items: [], total: 0, page: 1, size: 5 },
|
||||
}
|
||||
const workspaceSettingsBody = {
|
||||
version: updateRunningVersion,
|
||||
activity_retention_days: 90,
|
||||
@@ -304,6 +299,7 @@ function updateApi(
|
||||
return {
|
||||
'GET /v1/auth/me': role === 'owner' ? verifiedSession : adminSession,
|
||||
'GET /v1/settings/': { status: 200, body: workspaceSettingsBody },
|
||||
[purgeSettingsPath]: settingsBody(purgeSettingsBody()),
|
||||
'GET /v1/system/update': { status: 200, body: status },
|
||||
'POST /v1/system/update/check': check,
|
||||
}
|
||||
@@ -2392,7 +2388,10 @@ function cleanupApi(
|
||||
return {
|
||||
'GET /v1/auth/me': sessionWithRole(role),
|
||||
'GET /v1/settings/': { status: 200, body: workspaceSettingsBody },
|
||||
'GET /v1/system/update': { status: 200, hang: true },
|
||||
'GET /v1/system/update': {
|
||||
status: 200,
|
||||
body: updateStatusBody({ checked_at: '2026-09-10T09:41:00Z', can_apply: true }),
|
||||
},
|
||||
[purgeSettingsPath]: settings,
|
||||
...overrides,
|
||||
}
|
||||
@@ -2402,12 +2401,47 @@ function settingsBody(body: unknown): ApiMock {
|
||||
return { status: 200, body }
|
||||
}
|
||||
|
||||
async function scrollSettingsToCleanup(page: Page) {
|
||||
const top = (page.viewportSize()?.width ?? 1440) < 768 ? 480 : 300
|
||||
await page.evaluate((top) => {
|
||||
const card = document.querySelector<HTMLElement>('[data-testid="settings-card"]')
|
||||
if (card?.parentElement && !card.parentElement.querySelector('[data-scroll-room]')) {
|
||||
const room = document.createElement('div')
|
||||
room.setAttribute('data-scroll-room', '')
|
||||
room.style.cssText = 'flex-shrink:0;height:1200px'
|
||||
card.parentElement.append(room)
|
||||
}
|
||||
let node: HTMLElement | null = card
|
||||
while (node) {
|
||||
if (
|
||||
node.scrollHeight > node.clientHeight &&
|
||||
/auto|scroll/.test(getComputedStyle(node).overflowY)
|
||||
) {
|
||||
node.scrollTop = top
|
||||
return
|
||||
}
|
||||
node = node.parentElement
|
||||
}
|
||||
window.scrollTo(0, top)
|
||||
}, top)
|
||||
}
|
||||
|
||||
function cleanupState(
|
||||
api: Record<string, ApiMock>,
|
||||
steps?: (page: Page) => Promise<void>,
|
||||
exception?: DiffException,
|
||||
): VisualState {
|
||||
return { path: '/settings', api, steps, exception }
|
||||
return {
|
||||
path: '/settings',
|
||||
now: boardNow,
|
||||
api,
|
||||
exception,
|
||||
steps: async (page) => {
|
||||
await scrollSettingsToCleanup(page)
|
||||
await steps?.(page)
|
||||
await scrollSettingsToCleanup(page)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function openCleanupDialog(page: Page) {
|
||||
@@ -2500,11 +2534,7 @@ const cleanupStates: Record<string, VisualState> = {
|
||||
await page.getByText('Enter a whole number of days between 1 and 365.').waitFor()
|
||||
}),
|
||||
'Read-only (non-admin)': cleanupState(cleanupApi(settingsBody(purgeOnBody), {}, 'member')),
|
||||
'Loading skeleton': cleanupState(cleanupApi({ status: 200, hang: true }), undefined, {
|
||||
maxPercent: 17.5,
|
||||
reason: newCardAbsentReason,
|
||||
approvedBy: 'fallen',
|
||||
}),
|
||||
'Loading skeleton': cleanupState(cleanupApi({ status: 200, hang: true })),
|
||||
Offline: cleanupState(cleanupApi(settingsBody(purgeOnBody)), goOffline),
|
||||
'Loading preview': cleanupState(
|
||||
cleanupDialogApi({ [purgePreviewPath]: { status: 200, hang: true } }),
|
||||
@@ -2579,19 +2609,7 @@ const mediaPurgeStates: Record<string, VisualState> = {
|
||||
undefined,
|
||||
mediaDuplicatesPath,
|
||||
),
|
||||
'Dialog from Media': mediaState(
|
||||
mediaDuplicatesApi(undefined, {
|
||||
[purgePreviewPath]: { status: 200, body: purgePreviewBody() },
|
||||
}),
|
||||
openMediaPurgeDialog,
|
||||
mediaDuplicatesPath,
|
||||
),
|
||||
'Member: button hidden': mediaState(
|
||||
mediaDuplicatesApi(undefined, { 'GET /v1/auth/me': sessionWithRole('member') }),
|
||||
undefined,
|
||||
mediaDuplicatesPath,
|
||||
),
|
||||
'Offline: button disabled': mediaState(mediaDuplicatesApi(), goOffline, mediaDuplicatesPath),
|
||||
'Duplicates tab: offline': mediaState(mediaDuplicatesApi(), goOffline, mediaDuplicatesPath),
|
||||
}
|
||||
|
||||
const activityOwnerSession: ApiMock = {
|
||||
@@ -2869,6 +2887,25 @@ const activityBoardEvents = [
|
||||
),
|
||||
]
|
||||
|
||||
const homeActivityTimes = [
|
||||
'2026-09-10T08:58:00Z',
|
||||
'2026-09-10T08:31:00Z',
|
||||
'2026-09-10T07:55:00Z',
|
||||
'2026-09-10T06:55:00Z',
|
||||
'2026-09-10T02:00:00Z',
|
||||
]
|
||||
const homeActivityMock: ApiMock = {
|
||||
status: 200,
|
||||
body: {
|
||||
items: activityBoardEvents
|
||||
.slice(0, 5)
|
||||
.map((event, index) => ({ ...event, created_at: homeActivityTimes[index] })),
|
||||
total: 5,
|
||||
page: 1,
|
||||
size: 5,
|
||||
},
|
||||
}
|
||||
|
||||
function activityPage(items: unknown[], total: number) {
|
||||
return { items, total, page: 1, size: 25 }
|
||||
}
|
||||
@@ -3121,11 +3158,6 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
'Up to date': { path: '/settings', skip: 'clock-sensitive' },
|
||||
Checking: {
|
||||
path: '/settings',
|
||||
exception: {
|
||||
maxPercent: 11.3,
|
||||
reason: newCardAbsentReason,
|
||||
approvedBy: 'fallen',
|
||||
},
|
||||
api: updateApi('owner', updateNeverChecked(), updateCheckHangs),
|
||||
},
|
||||
'Check failed': { path: '/settings', skip: 'clock-sensitive' },
|
||||
@@ -3134,20 +3166,10 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
Downloading: {
|
||||
path: '/settings',
|
||||
api: updateApi('owner', updateDownloading()),
|
||||
exception: {
|
||||
maxPercent: 10.6,
|
||||
reason: newCardAbsentReason,
|
||||
approvedBy: 'fallen',
|
||||
},
|
||||
},
|
||||
Verifying: {
|
||||
path: '/settings',
|
||||
api: updateApi('owner', updateJob('verifying')),
|
||||
exception: {
|
||||
maxPercent: 10.8,
|
||||
reason: newCardAbsentReason,
|
||||
approvedBy: 'fallen',
|
||||
},
|
||||
},
|
||||
'Ready to install': { path: '/settings', api: updateApi('owner', updateJob('ready')) },
|
||||
'Restart: confirm': {
|
||||
@@ -3188,29 +3210,14 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
},
|
||||
'Download failed': {
|
||||
path: '/settings',
|
||||
exception: {
|
||||
maxPercent: 10.9,
|
||||
reason: newCardAbsentReason,
|
||||
approvedBy: 'fallen',
|
||||
},
|
||||
api: updateApi('owner', updateJob('failed', { error: 'download' })),
|
||||
},
|
||||
'Verification failed': {
|
||||
path: '/settings',
|
||||
exception: {
|
||||
maxPercent: 10.5,
|
||||
reason: newCardAbsentReason,
|
||||
approvedBy: 'fallen',
|
||||
},
|
||||
api: updateApi('owner', updateJob('failed', { error: 'verification' })),
|
||||
},
|
||||
'Too many requests (429)': {
|
||||
path: '/settings',
|
||||
exception: {
|
||||
maxPercent: 11.3,
|
||||
reason: newCardAbsentReason,
|
||||
approvedBy: 'fallen',
|
||||
},
|
||||
api: updateApi('owner', updateNeverChecked(), {
|
||||
status: 429,
|
||||
headers: { 'Retry-After': '42' },
|
||||
@@ -3218,22 +3225,14 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
},
|
||||
'Not prepared': {
|
||||
path: '/settings',
|
||||
exception: {
|
||||
maxPercent: 11.9,
|
||||
reason: newCardAbsentReason,
|
||||
approvedBy: 'fallen',
|
||||
},
|
||||
api: updateApi('owner', updateAvailable()),
|
||||
now: boardNow,
|
||||
api: updateApi('owner', { ...updateAvailable(), checked_at: '2026-09-10T09:41:00Z' }),
|
||||
},
|
||||
'Not prepared: command copied': {
|
||||
path: '/settings',
|
||||
exception: {
|
||||
maxPercent: 11.9,
|
||||
reason: newCardAbsentReason,
|
||||
approvedBy: 'fallen',
|
||||
},
|
||||
origin: integrationOrigin,
|
||||
api: updateApi('owner', updateAvailable()),
|
||||
now: boardNow,
|
||||
api: updateApi('owner', { ...updateAvailable(), checked_at: '2026-09-10T09:41:00Z' }),
|
||||
steps: async (page) => {
|
||||
await page.getByRole('button', { name: 'Copy', exact: true }).click()
|
||||
await page.mouse.move(0, 0)
|
||||
@@ -3556,11 +3555,6 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
home: {
|
||||
Default: {
|
||||
path: '/home',
|
||||
exception: {
|
||||
maxPercent: 13.5,
|
||||
reason: newCardAbsentReason,
|
||||
approvedBy: 'fallen',
|
||||
},
|
||||
now: boardNow,
|
||||
api: {
|
||||
'GET /v1/auth/me': verifiedSession,
|
||||
@@ -3571,26 +3565,16 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
},
|
||||
Loading: {
|
||||
path: '/home',
|
||||
exception: {
|
||||
maxPercent: 18.6,
|
||||
reason: newCardAbsentReason,
|
||||
approvedBy: 'fallen',
|
||||
},
|
||||
now: boardNow,
|
||||
api: {
|
||||
'GET /v1/auth/me': verifiedSession,
|
||||
'GET /v1/activity': homeActivityMock,
|
||||
'GET /v1/activity': { ...homeActivityMock, hang: true },
|
||||
'GET /v1/system/update': { status: 200, body: updateStatusBody() },
|
||||
'GET /v1/article/': { status: 200, body: sixArticlesFixture, hang: true },
|
||||
},
|
||||
},
|
||||
'Empty, first run': {
|
||||
path: '/home',
|
||||
exception: {
|
||||
maxPercent: 11.4,
|
||||
reason: newCardAbsentReason,
|
||||
approvedBy: 'fallen',
|
||||
},
|
||||
now: boardNow,
|
||||
api: {
|
||||
'GET /v1/auth/me': verifiedSession,
|
||||
@@ -3601,11 +3585,6 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
},
|
||||
Error: {
|
||||
path: '/home',
|
||||
exception: {
|
||||
maxPercent: 25.3,
|
||||
reason: newCardAbsentReason,
|
||||
approvedBy: 'fallen',
|
||||
},
|
||||
now: boardNow,
|
||||
api: {
|
||||
'GET /v1/auth/me': verifiedSession,
|
||||
@@ -3616,11 +3595,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
},
|
||||
'Update available (owner)': {
|
||||
path: '/home',
|
||||
exception: {
|
||||
maxPercent: 11.9,
|
||||
reason: newCardAbsentReason,
|
||||
approvedBy: 'fallen',
|
||||
},
|
||||
now: boardNow,
|
||||
api: {
|
||||
'GET /v1/auth/me': verifiedSession,
|
||||
'GET /v1/activity': homeActivityMock,
|
||||
@@ -3630,11 +3605,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
},
|
||||
'Update ready to install (owner)': {
|
||||
path: '/home',
|
||||
exception: {
|
||||
maxPercent: 11.9,
|
||||
reason: newCardAbsentReason,
|
||||
approvedBy: 'fallen',
|
||||
},
|
||||
now: boardNow,
|
||||
api: {
|
||||
'GET /v1/auth/me': verifiedSession,
|
||||
'GET /v1/activity': homeActivityMock,
|
||||
@@ -4176,27 +4147,3 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
media: { ...mediaStates, ...mediaPurgeStates },
|
||||
'media-picker': pickerStates,
|
||||
}
|
||||
|
||||
const hideCleanupCard = '[data-testid="media-cleanup-card"] { display: none !important }'
|
||||
|
||||
function withoutCleanupCard(state: VisualState): VisualState {
|
||||
if (state.skip) {
|
||||
return state
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
api: { ...state.api, [purgeSettingsPath]: { status: 200, hang: true } },
|
||||
steps: async (page) => {
|
||||
await page.addStyleTag({ content: hideCleanupCard })
|
||||
await state.steps?.(page)
|
||||
await page.addStyleTag({ content: hideCleanupCard })
|
||||
await page.mouse.move(0, 0)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
for (const [name, state] of Object.entries(visualStates.settings!)) {
|
||||
if (!(name in cleanupStates)) {
|
||||
visualStates.settings![name] = withoutCleanupCard(state)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user