chore: groundwork for media purge system
This commit is contained in:
1 parent
c1e73bda81
commit
161ddc0f7d
11 files changed
+366
-9
No files matched your search
@@ -34,7 +34,7 @@ The visual spec of record for the back-office SPA and the transactional emails,
|
||||
| [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` |
|
||||
| [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` |
|
||||
| [Settings](screens/settings.md) | `/settings` | 25 | `source/RenewCMS Screens Back-office.dc.html`, `source/RenewCMS Screens Media cleanup.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` |
|
||||
@@ -42,6 +42,7 @@ The visual spec of record for the back-office SPA and the transactional emails,
|
||||
## Other boards
|
||||
|
||||
- `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`.
|
||||
|
||||
@@ -814,3 +814,18 @@ Exact values live in `source/AppFrame.dc.html` (the Settings and Home states). C
|
||||
| RestartOverlay | custom full-page `role="status"` | title, text, startedAt, slow | reload | App shell |
|
||||
| UpdateBanner | UAlert | state: 'available'\|'ready' | go-to-settings, dismiss | Home (owner) |
|
||||
| UpdatePill | UBadge | none | none | DashboardShell Settings item (dot on mobile) |
|
||||
|
||||
## Added with Media cleanup
|
||||
### PurgeDuplicatesDialog
|
||||
Used by Settings ("Purge now") and Media, Duplicates tab ("Purge duplicates"). Built on `UModal`, `sm:max-w-md`, bottom sheet on mobile, cancel first, confirm is `color="error"`.
|
||||
- Props: `open`, `preview` (`groups`, `copies`, `articles`, `list[]`, `more`), `status` (`loading | ready | purging | nothing | error | partial | conflict`).
|
||||
- While `purging`: confirm shows `loader-circle` and reads "Purging…"; `dismissible` false, Cancel disabled.
|
||||
- Group row: 40×30 thumbnail, filename, "Kept · used in N articles", right-aligned "N copies merged". Max 5 rows, then "and N more groups".
|
||||
- Notices sit above the summary in a `UAlert` (soft): error for failure, warning for partial and conflict.
|
||||
- Emits `purged` (with counts). The parent shows the toast.
|
||||
|
||||
### MediaCleanupCard
|
||||
Settings card. `USwitch` (square, `rounded-none`), `UInputNumber` with "days" suffix, read-only lines, divider, outline button. Saves on change and on blur or Enter; shows "Saving…" then "Saved" in the header. Read-only mode disables controls, hides the button and shows an info `UAlert`.
|
||||
|
||||
### Media toolbar button
|
||||
Secondary `UButton` icon `merge`, label "Purge duplicates", after the sort menu on desktop; full width in the stacked toolbar on mobile (44 px).
|
||||
@@ -15,11 +15,11 @@ A global `router.beforeEach` guard reads `meta.auth`. On the first navigation it
|
||||
| `/article` | DashboardShell | verified | ArticlesPage | GET /v1/article/ · PATCH /{id}/publish\|unpublish · DELETE /{id} |
|
||||
| `/article/new` | DashboardShell | verified | ArticlesPage + NewArticleModal | POST /v1/article/ |
|
||||
| `/article/:id/edit` | DashboardShell | verified | EditorPage | GET /{id} · PATCH /{id} · PATCH /{id}/publish\|unpublish · GET /v1/media/ · POST /v1/media/ · DELETE /{id} |
|
||||
| `/media` | DashboardShell | verified | MediaPage | GET /v1/media/ · POST /v1/media/ · PATCH /v1/media/{id} · DELETE /v1/media/{id} · POST /v1/media/merge · GET /v1/media/{id}/usage |
|
||||
| `/media` | DashboardShell | verified | MediaPage | GET /v1/media/purge-preview · POST /v1/media/purge · GET /v1/media/ · POST /v1/media/ · PATCH /v1/media/{id} · DELETE /v1/media/{id} · POST /v1/media/merge · GET /v1/media/{id}/usage |
|
||||
| `/integration` | DashboardShell | verified | IntegrationPage | none |
|
||||
| `/integration/api-keys` | DashboardShell | verified | ApiKeysPage + CreateApiKeyModal | GET /v1/api-key · POST /v1/api-key · DELETE /v1/api-key/{id} |
|
||||
| `/users` | DashboardShell | verified | UsersPage + InviteUserModal | GET /v1/user/ · POST /v1/user/invite · DELETE /v1/user/invite/:id · POST /v1/user/invite/:id/renew |
|
||||
| `/settings` | DashboardShell | verified | SettingsPage | GET /v1/settings · GET /v1/system/update · POST /v1/system/update/check · POST /v1/system/update/download · POST /v1/system/update/restart |
|
||||
| `/settings` | DashboardShell | verified | SettingsPage | GET /v1/settings · GET /v1/system/update · POST /v1/system/update/check · POST /v1/system/update/download · POST /v1/system/update/restart · GET /v1/media/purge-settings · PATCH /v1/media/purge-settings · GET /v1/media/purge-preview · POST /v1/media/purge |
|
||||
| `/profile` | DashboardShell | verified | ProfilePage | GET /v1/auth/me · PATCH /v1/user/me · POST /v1/auth/password-reset |
|
||||
| `/invite/accept` | AuthShell | public | AcceptInvitePage | GET /v1/auth/invite?c=* · POST /v1/auth/invite/accept* |
|
||||
| `/:pathMatch(.*)*` | either | any | NotFoundPage | none |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Media
|
||||
|
||||
- **Route:** `/media` (verified users)
|
||||
- **API:** GET /v1/media/ · POST /v1/media/ · PATCH /v1/media/{id} · DELETE /v1/media/{id} · POST /v1/media/merge · GET /v1/media/{id}/usage
|
||||
- **API:** GET /v1/media/purge-preview · POST /v1/media/purge · GET /v1/media/ · POST /v1/media/ · PATCH /v1/media/{id} · DELETE /v1/media/{id} · POST /v1/media/merge · GET /v1/media/{id}/usage
|
||||
- **Visual reference:** `source/RenewCMS Screens Back-office.dc.html`, group "Media", drawn by `source/MediaFrame.dc.html` (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle).
|
||||
|
||||
The library of every uploaded image. It replaces "images belong to one article": an image belongs to the library and can be used in any number of articles. Deleting an article never deletes images. The sidebar gains **Media** (`images` icon) between Articles and Users, breadcrumb "Media".
|
||||
@@ -12,7 +12,7 @@ New route for verified users, in the sidebar between Articles and Users with the
|
||||
|
||||
Header: title "Media", subtitle "Every image you've uploaded, ready to use in any article.", primary "Upload" button (`upload` icon) on the right. On mobile the button is a full-width 48 px button pinned to the bottom (it turns into "Try again in 0:42" while rate limited, and is replaced by the bulk bar while items are selected).
|
||||
|
||||
Toolbar: tabs **All**, **Unused** and **Duplicates** (count badge of duplicate groups, only while groups exist), then search ("Search by filename"), sort and the grid/list toggle. Sort options: Newest (default), Oldest, Name, Largest. Below md the toolbar stacks: search, tabs, then sort and toggle.
|
||||
Toolbar: tabs **All**, **Unused** and **Duplicates** (count badge of duplicate groups, only while groups exist), then search ("Search by filename"), sort and the grid/list toggle. Sort options: Newest (default), Oldest, Name, Largest. On the Duplicates tab, owners and admins also get a secondary "Purge duplicates" button (`merge` icon) after the sort menu (full width, 44 px, in the stacked toolbar on mobile), hidden while there are no groups; it opens the PurgeDuplicatesDialog described in [settings](settings.md#media-cleanup-second-card). Below md the toolbar stacks: search, tabs, then sort and toggle.
|
||||
|
||||
Grid (default): 5 columns on desktop, 2 on mobile, 16 px gap (10 px on mobile). Each card is one button: 4:3 thumbnail (checkerboard behind transparent PNG and SVG), filename (medium, ellipsis), `size · dimensions` (SVG dimensions read "Vector"), then badges. The accessible name reads "north-wall.jpg, 3.9 MB, used in 2 articles" or "…, unused". A "Select all" row with the image count sits above the grid.
|
||||
|
||||
@@ -149,6 +149,7 @@ Grid items are buttons, one tab stop each. Enter opens the detail panel, Space t
|
||||
| Upload: rejected files (413, 400) | Type and size are checked in the browser first, so nothing is sent. A server 413 or 400 for a file that got through shows the same row with its own message. |
|
||||
| Upload: too many requests (429) | Rate limited. Retry-After is counted down in the button. It re-enables at zero. Already-added files are kept. |
|
||||
| Duplicates: groups | The Duplicates tab. Groups of identical images side by side, each with its uploader, upload date and "Used in N articles". The suggested copy to keep is the most used, then the oldest. |
|
||||
| Duplicates: purge all | Owner or admin, groups exist. "Purge duplicates" opens the purge dialog (preview, confirm, progress, results), the same one as Settings "Purge now". "Merge into this one" is unchanged. |
|
||||
| Duplicates: empty | No groups. The Duplicates tab loses its count badge. |
|
||||
| Merge: confirm | "Merge into this one" on floorboards.jpg. The dialog says articles using the other copies will switch to this image and the other copies will be deleted, with the article count. |
|
||||
| Merge: in progress | Request in flight. The dialog can't be dismissed. |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Settings
|
||||
|
||||
- **Route:** `/settings`
|
||||
- **API:** 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 (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
|
||||
- **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).
|
||||
@@ -125,3 +125,55 @@ For the owner only: an **Update** pill on the Settings sidebar item, and a dot o
|
||||
| Admin view | 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. |
|
||||
| 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)
|
||||
|
||||
Route: `/settings`. Visual reference: `RenewCMS Screens Media cleanup.dc.html` (frames: `SettingsFrame.dc.html`, `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.
|
||||
- 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.
|
||||
- Read-only lines: "Last purge: 14 Jun 2026 · 3 copies merged · 5 articles updated" or "Never run". When on, also "Next purge: 14 Jul 2026".
|
||||
- Divider, then "Purge now" / "Merge every group of duplicates that has an image in use, right away." / outline button "Purge duplicates" (merge).
|
||||
- No Save button. Saves on toggle and on blur or Enter of the days field. "Saved" (check, success) sits in the card header and fades after 2 s; "Saving…" (loader-circle) while pending.
|
||||
- Non-owner/admin: switch and input disabled, button and divider hidden, info alert "Only owners and admins can change this."
|
||||
|
||||
### States (names matched exactly)
|
||||
- Default: off, never run
|
||||
- On: 30 days, last run
|
||||
- Saving
|
||||
- Saved
|
||||
- Save failed: "Couldn't save. Try again in a moment." under the field; the control reverts.
|
||||
- Validation error: "Enter a whole number of days between 1 and 365." Nothing is sent.
|
||||
- Read-only (non-admin)
|
||||
- Loading skeleton (after 150 ms)
|
||||
- Offline: banner "You're offline. Changing automatic purge and purging duplicates need a connection." Switch, input and button disabled.
|
||||
|
||||
### Dialog
|
||||
UModal, `sm:max-w-md`, bottom sheet on mobile, cancel first.
|
||||
- Title "Purge duplicates?". Description "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."
|
||||
- Summary: "4 groups · 9 copies will be deleted · 12 articles will be updated". Singular: "1 group · 1 copy will be deleted · 2 articles will be updated".
|
||||
- Bordered list of up to 5 groups: thumbnail, filename, "Kept · used in 4 articles", and "2 copies merged". More: "and 3 more groups".
|
||||
- Link "Review in Duplicates" to `/media?filter=duplicates`.
|
||||
- Buttons: "Cancel", danger "Purge 4 groups". Pending: "Purging…" with loader-circle, dialog can't be dismissed.
|
||||
- Loading preview: skeleton rows, confirm disabled.
|
||||
- Nothing to purge: icon copy, "Nothing to purge", "No duplicate is used in an article, so there is nothing to merge.", button "Close". No confirm.
|
||||
- Error (stays open): "Couldn't purge the duplicates. Nothing was changed. Try again in a moment."
|
||||
- Partial: "Some groups couldn't be merged. 3 of 4 groups were purged. Try again to finish." Buttons "Close", "Retry". Summary refreshes to the remaining groups.
|
||||
- Conflict (409): "The library changed, so nothing was purged. Check the new summary and try again." Summary refreshes.
|
||||
|
||||
### Results
|
||||
- Toast (5 s): circle-check, "Duplicates purged" / "9 copies merged. 12 articles updated."
|
||||
- "Last purge" updates in place; "Next purge" moves forward by the interval.
|
||||
- An unattended run shows no toast. The next visit shows the updated line.
|
||||
|
||||
### Media entry point
|
||||
Duplicates tab: secondary "Purge duplicates" button (merge) next to the sort menu; stacked toolbar on mobile. Opens the same dialog. Hidden for non-owner/admin and when there are no groups. "Merge into this one" is unchanged.
|
||||
|
||||
### Components (Nuxt UI v4)
|
||||
`UCard`, `USwitch`, `UInputNumber` (min 1, max 365, suffix), `UFormField`, `UAlert`, `UButton`, `UModal`, `USkeleton`, `useToast`.
|
||||
@@ -144,9 +144,10 @@ class Component extends DCLogic {
|
||||
if (s.empty === 'first') { parts.push(Empty('images', 'No images yet', 'Upload your first image. You can use it in any article.', 'Upload an image', true), S('align-self:center;color:var(--ui-text-muted);font-size:12px', 'PNG, JPEG, WEBP, SVG · max 10 MB each')); return parts }
|
||||
if (s.limited) parts.push(Alert('info', 'timer', 'Too many uploads', 'For your security, wait a moment before uploading again.'))
|
||||
if (s.error) parts.push(Alert('error', 'circle-x', 'Couldn\'t load your images', 'Check your connection and try again.', 'Try again'))
|
||||
const pb = tab === 'dups' && !s.member && !s.dupEmpty && B('Purge duplicates', { icon: 'merge', dis: s.offline, full: m })
|
||||
const tb = m
|
||||
? D('display:flex;flex-direction:column;gap:12px', Search(), TabRow(), D('display:flex;gap:8px', Sort(), Toggle()))
|
||||
: D('display:flex;align-items:flex-end;gap:12px;border-bottom:1px solid var(--ui-border)', TabRow(), S('display:flex;padding-bottom:6px;gap:8px', Search(), Sort(), Toggle()))
|
||||
? D('display:flex;flex-direction:column;gap:12px', Search(), TabRow(), D('display:flex;gap:8px', Sort(), Toggle()), pb && D('display:flex', pb))
|
||||
: D('display:flex;align-items:flex-end;gap:12px;border-bottom:1px solid var(--ui-border)', TabRow(), S('display:flex;padding-bottom:6px;gap:8px', Search(), Sort(), pb, Toggle()))
|
||||
parts.push(tb)
|
||||
if (UP) parts.push(UpPanel())
|
||||
if (s.loading) parts.push(Skel())
|
||||
@@ -189,6 +190,7 @@ class Component extends DCLogic {
|
||||
if (k === 'delUsed') return { title: 'Delete an image that\'s in use?', desc: '"north-wall.jpg" is used in 2 articles. If you delete it, they will show a broken image. This can\'t be undone.', body: [ArtBox(usedList(2))], cancel: 'Cancel', confirm: md.loading ? 'Deleting…' : 'Delete image', tone: 'danger' }
|
||||
if (k === 'bulk') return { title: 'Delete 3 images?', desc: '2 of them are used in 5 articles, which will show a broken image. The other one isn\'t used anywhere. This can\'t be undone.', body: [D('display:flex;flex-direction:column;border:1px solid var(--ui-border)', ...[['north-wall.jpg', 'Used in 2'], ['kitchen-after.webp', 'Used in 3'], ['window-frame.jpg', 'Unused']].map(([n, u], i) => D('display:flex;align-items:center;gap:8px;min-height:36px;padding:0 12px;border-top:' + (i ? '1px solid var(--ui-border-muted)' : '0'), S('flex:1;color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', n), Bd(u === 'Unused' ? 'Unused' : u, u === 'Unused' ? 'unused' : 'used'))))], cancel: 'Cancel', confirm: md.loading ? 'Deleting…' : 'Delete 3 images', tone: 'danger' }
|
||||
if (k === 'merge') return { title: 'Merge into floorboards.jpg?', desc: 'Articles that use the other 2 copies will be switched to floorboards.jpg, and the other 2 copies will be deleted. 5 articles will be updated. This can\'t be undone.', body: [D('display:flex;gap:12px;align-items:center;border:1px solid var(--ui-border);padding:10px 12px', Th(byId(12), 'width:56px;height:42px;flex-shrink:0;border:1px solid var(--ui-border)', 16), D('display:flex;flex-direction:column;min-width:0', S('font-weight:500;color:var(--ui-text-highlighted)', 'floorboards.jpg'), S('font-size:12px;color:var(--ui-text-muted)', 'Kept · used in 4 articles now')))], cancel: 'Cancel', confirm: md.loading ? 'Merging…' : 'Merge 2 copies', tone: 'danger' }
|
||||
if (k === 'purge') return { title: 'Purge duplicates?', 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.', body: [S('font-weight:500;color:var(--ui-text-highlighted)', '4 groups · 9 copies will be deleted · 12 articles will be updated'), D('border:1px solid var(--ui-border)', ...[[12, 'floorboards.jpg', 4, '2 copies'], [7, 'IMG_4471.jpg', 2, '1 copy'], [2, 'kitchen-after.webp', 3, '3 copies'], [4, 'logo-renew.svg', 5, '3 copies']].map(([id, n, u, c], i) => D('display:flex;align-items:center;gap:12px;padding:8px 12px;border-top:' + (i ? '1px solid var(--ui-border-muted)' : '0'), Th(byId(id), 'width:40px;height:30px;flex-shrink:0;border:1px solid var(--ui-border)', 16), D('flex:1;min-width:0;display:flex;flex-direction:column', S('font-weight:500;color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', n), S('font-size:12px;color:var(--ui-text-muted)', 'Kept · used in ' + pl(u))), S('font-size:12px;color:var(--ui-text-toned);white-space:nowrap', c + ' merged')))), S('display:inline-flex;align-items:center;gap:4px;align-self:flex-start;font-weight:500;color:var(--ui-text-highlighted);text-decoration:underline;text-underline-offset:3px;min-height:' + (m ? 44 : 20) + 'px', 'Review in Duplicates', I('arrow-right', 14))], cancel: 'Cancel', confirm: 'Purge 4 groups', tone: 'danger' }
|
||||
if (k === 'remove') return { title: 'Remove this image from the article?', desc: 'It\'s removed from this article only. The file stays in your library and in any other article that uses it.', body: [D('display:flex;gap:12px;align-items:center;border:1px solid var(--ui-border);padding:10px 12px', Th(byId(1), 'width:56px;height:42px;flex-shrink:0;border:1px solid var(--ui-border)', 16), D('display:flex;flex-direction:column;min-width:0', S('font-weight:500;color:var(--ui-text-highlighted)', 'north-wall.jpg'), S('font-size:12px;color:var(--ui-text-muted)', 'Stays in the library · 3.9 MB')))], cancel: 'Cancel', confirm: 'Remove image' }
|
||||
}
|
||||
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 : o.top || 140) + 'px 16px 16px',
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
|
||||
</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' }
|
||||
rootRef = React.createRef()
|
||||
|
||||
@@ -223,6 +223,8 @@ class Component extends DCLogic {
|
||||
Mp({ upload: upT([rowRej, { n: 'notes.pdf', sz: '210 KB', st: 'rej', title: 'notes.pdf isn\'t an image', msg: 'Images can be up to 10 MB. Use a PNG, JPEG, WEBP or SVG image.' }, { n: 'broken.png', sz: '3.0 MB', st: 'rej', title: 'broken.png couldn\'t be read', msg: 'The server couldn\'t read this image. Save it again as a PNG or JPEG, then try again.' }], { title: 'Nothing was uploaded', sub: '3 files were rejected', done: true }), name: 'Upload: rejected files (413, 400)', trigger: 'Type and size are checked in the browser first, so nothing is sent. A server 413 or 400 for a file that got through shows the same row with its own message.' }),
|
||||
Mp({ limited: true, name: 'Upload: too many requests (429)', trigger: 'Rate limited. Retry-After is counted down in the button. It re-enables at zero. Already-added files are kept.' }),
|
||||
Mp({ tab: 'dups', name: 'Duplicates: groups', trigger: 'The Duplicates tab. Groups of identical images side by side, each with its uploader, upload date and "Used in N articles". The suggested copy to keep is the most used, then the oldest.' }),
|
||||
Mp({ tab: 'dups', modal: { k: 'purge' }, name: 'Purge duplicates: confirm', trigger: '"Purge duplicates" in the toolbar, next to the sort menu (in the stacked toolbar on mobile). Opens the same dialog as Settings; every other state is on the Media cleanup board.' }),
|
||||
Mp({ tab: 'dups', member: true, name: 'Duplicates: no purge button (member)', trigger: 'Verified users who are not owner or admin. The "Purge duplicates" button is hidden. "Merge into this one" is unchanged.' }),
|
||||
Mp({ tab: 'dups', dupEmpty: true, name: 'Duplicates: empty', trigger: 'No groups. The Duplicates tab loses its count badge.' }),
|
||||
Mp({ tab: 'dups', modal: { k: 'merge' }, name: 'Merge: confirm', trigger: '"Merge into this one" on floorboards.jpg. The dialog says articles using the other copies will switch to this image and the other copies will be deleted, with the article count.' }),
|
||||
Mp({ tab: 'dups', modal: { k: 'merge', loading: true }, name: 'Merge: in progress', trigger: 'Request in flight. The dialog can\'t be dismissed.' }),
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
<!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>
|
||||
@@ -0,0 +1,121 @@
|
||||
<!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>
|
||||
@@ -281,3 +281,27 @@ Added by the Media screens, none of which exist yet:
|
||||
- `POST /v1/media/merge`: `{ keep_id, merge_ids }`. Rewrites article bodies from the merged copies' URLs to the kept image's URL, then deletes the copies. 200 with `{ articles_updated, merged }`, 404, 409 when a listed copy is gone or the group changed.
|
||||
- `GET /v1/media/{id}/usage`: `[{ id, title, published }]`, read from article bodies.
|
||||
- Images no longer belong to an article, and deleting an article no longer deletes images. `POST /v1/article/{id}/image` and `DELETE /v1/article/{id}/image/{imageId}` are replaced by the endpoints above.
|
||||
|
||||
## Added with Media cleanup
|
||||
### Media cleanup
|
||||
| State | Surface | Behaviour |
|
||||
|---|---|---|
|
||||
| Default | Settings card | Off, "Never run", no "Every" row |
|
||||
| On | Settings card | "Every" 30 days, last and next run |
|
||||
| Saving / Saved | Settings card header | "Saving…" then "Saved" for 2 s, polite live region |
|
||||
| Save failed | Under the field | "Couldn't save. Try again in a moment." Control reverts |
|
||||
| Validation | Under the field | "Enter a whole number of days between 1 and 365." Nothing sent |
|
||||
| Read-only | Settings card | Controls disabled, button hidden, info alert |
|
||||
| Loading | Settings card | Skeleton after 150 ms |
|
||||
| Offline | Settings and Media | Banner as on Media. Controls and buttons disabled |
|
||||
| Preview loading | Dialog | Skeleton rows, confirm disabled |
|
||||
| Confirm | Dialog | Summary, up to 5 groups, link to Duplicates |
|
||||
| In progress | Dialog | "Purging…", loader-circle, not dismissible |
|
||||
| Nothing to purge | Dialog | Info state, "Close" only |
|
||||
| Error | Dialog | Notice, stays open, confirm retries |
|
||||
| Partial | Dialog | Warning, summary refreshes, "Close" and "Retry" |
|
||||
| Conflict (409) | Dialog | Warning, summary refreshes |
|
||||
| Success | Toast, 5 s | "Duplicates purged" / "9 copies merged. 12 articles updated." "Last purge" updates in place |
|
||||
| Unattended run | Settings | No toast. Updated on next visit |
|
||||
|
||||
401 and 403 are global and have no local copy.
|
||||
Reference in new issue
Block a user