140 lines
16 KiB
HTML
140 lines
16 KiB
HTML
<!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>
|