174 lines
23 KiB
HTML
174 lines
23 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="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 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>
|
|
</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"><dc-import name="AppFrame" s="{{ st }}" 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="AppFrame" s="{{ st.m }}" mobile="{{ true }}" hint-size="390px,844px"></dc-import></div></div>
|
|
</div>
|
|
</div>
|
|
</sc-for>
|
|
</div>
|
|
</section>
|
|
</sc-for>
|
|
|
|
</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' }
|
|
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 base = [
|
|
{ t: 'How we price a bathroom renovation', on: true, c: '12 Mar 2026', u: '2 hours ago' },
|
|
{ t: 'Choosing lime plaster for old stone walls', on: false, c: '9 Sep 2026', u: 'Yesterday' },
|
|
{ t: 'Before and after: the Rue Verte kitchen', on: true, c: '28 Aug 2026', u: '3 days ago' },
|
|
{ t: 'Winter checklist for timber windows', on: false, c: '2 Sep 2026', u: '5 days ago' },
|
|
{ t: 'Why we reuse original floorboards', on: true, c: '14 Jun 2026', u: '18 Aug 2026' },
|
|
{ t: 'Our first passive-house retrofit', on: true, c: '3 Apr 2026', u: '2 Jul 2026' }
|
|
]
|
|
const rows = (patch = {}) => base.map((r, i) => patch[i] ? { ...r, ...patch[i] } : r)
|
|
const T = (tone, icon, title, text, action) => ({ tone, icon, title, text, action })
|
|
const withM = (st, mo = {}) => ({ ...st, m: { ...st, ...mo } })
|
|
const home = { kind: 'home', nav: 'home', crumbs: ['Home'], mTitle: 'Home', mAction: 'plus' }
|
|
const stats = [{ n: '6', l: 'articles' }, { n: '4', l: 'online' }, { n: '2', l: 'drafts' }]
|
|
const recent = base.slice(0, 5).map(r => ({ ...r, draft: !r.on }))
|
|
const list = { kind: 'articles', nav: 'articles', crumbs: ['Articles'], mTitle: 'Articles' }
|
|
const L = (o = {}) => ({ ...list, ...o, list: { rows: rows(), ...(o.list || {}) } })
|
|
const nm = (o = {}) => ({ title: 'New article', desc: 'It\'s saved as a draft. Titles can\'t be changed later, so check it now.', close: true, cancel: 'Cancel', confirm: 'Create draft', wide: true, ...o, fields: o.fields || [{ label: 'Title', ph: 'e.g. Choosing tiles for a small bathroom', count: '0 / 200', help: '3 to 200 characters.', focus: true }, { label: 'First paragraph', ph: 'Start writing. You\'ll continue in the editor.', area: true, help: 'You can format it and add images in the editor.' }] })
|
|
const nf = (title, body, extra = {}) => [{ label: 'Title', value: title, count: [...title].length + ' / 200', ...(extra.t || {}) }, { label: 'First paragraph', value: body, area: true, ...(extra.b || {}) }]
|
|
const ed = { kind: 'editor', nav: 'articles', crumbs: ['Articles', 'How we price a bathroom renovation'], mTitle: 'Edit article', mLead: 'arrow-left', mAction: 'ellipsis' }
|
|
const E = (o = {}, extra = {}) => withM({ ...ed, ...extra, ed: { status: 'draft', save: 'saved', ...o } })
|
|
const del = (loading, err) => ({ title: 'Delete this article?', desc: '"Winter checklist for timber windows" and its 3 images will be deleted. This can\'t be undone.', cancel: 'Cancel', confirm: loading ? 'Deleting…' : 'Delete article', tone: 'danger', loading, notice: err })
|
|
return [
|
|
{ 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 })) } }),
|
|
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 } })
|
|
] },
|
|
{ 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.' }),
|
|
withM({ ...L({ list: { rows: rows({ 3: { menu: true } }) } }), name: 'Row menu open', trigger: 'The ellipsis button, or Enter on the focused menu button.' }),
|
|
withM({ ...L({ list: { loading: true } }), name: 'Loading', trigger: 'The first load, or a retry.' }),
|
|
withM({ ...L({ list: { counts: [0, 0, 0], empty: { icon: 'file-text', title: 'No articles yet', text: 'Write your first one. It stays a draft until you publish it.', action: 'New article', primary: true } } }), name: 'Empty', trigger: 'The list is empty.' }),
|
|
withM({ ...L({ list: { q: 'tiles', empty: { icon: 'search', title: 'No articles match "tiles"', text: 'Check the spelling, or search for a shorter part of the title.', action: 'Clear search' } } }), name: 'No results', trigger: 'Search matched nothing. The tab counts still show the full list.' }),
|
|
withM({ ...L({ list: { tab: 2, counts: [4, 4, 0], empty: { icon: 'pencil-line', title: 'No drafts', text: 'Everything you\'ve written is online.', action: 'New article' } } }), name: 'No drafts', trigger: 'Drafts tab with nothing in it. The "Online" tab has an equivalent "Nothing is online yet" state.' }),
|
|
withM({ ...L({ list: { rows: rows({ 3: { busy: 'Publishing…' } }) } }), name: 'Action in progress', trigger: 'Publish chosen from the row menu. That row is disabled.' }),
|
|
withM({ ...L({ list: { rows: rows({ 3: { on: true, u: 'Just now' } }) }, toasts: [T('success', 'circle-check', 'Article published', '"Winter checklist for timber windows" is now online.')] }), name: 'Published', trigger: '200: the row updates in place and a toast confirms.' }),
|
|
withM({ ...L({ toasts: [T('error', 'circle-x', 'Couldn\'t unpublish', 'The server didn\'t respond. Nothing changed.', 'Retry')] }), name: 'Action failed', trigger: 'A 500 or network error. The row stays as it was, and the toast stays until retried or dismissed.' }),
|
|
withM({ ...L({ list: { counts: [5, 4, 1], rows: rows().filter((r, i) => i !== 1) }, toasts: [T('info', 'info', 'That article no longer exists', 'It was deleted somewhere else, so we removed it from the list.')] }), name: 'Row vanished (404)', trigger: 'Any row action gets a 404.' }),
|
|
withM({ ...L({ modal: del(false) }), name: 'Delete: confirm', trigger: 'Delete… from the row menu.' }),
|
|
withM({ ...L({ modal: del(true) }), name: 'Delete: in progress', trigger: 'DELETE in flight. The dialog can\'t be dismissed.' }),
|
|
withM({ ...L({ modal: del(false, 'Couldn\'t delete the article, so it\'s still there. Try again in a moment.') }), name: 'Delete: failed', trigger: '500, a partial failure where the article is kept. The error shows inside the dialog.' }),
|
|
withM({ ...L({ list: { counts: [5, 4, 1], rows: rows().filter((r, i) => i !== 3) }, toasts: [T('success', 'circle-check', 'Article deleted', '"Winter checklist for timber windows" and its images are gone.')] }), name: 'Deleted', trigger: '204: the row is removed and focus moves to the next row. This is also where a delete from the editor ends up.' })
|
|
] },
|
|
{ label: 'New article', title: 'New article', route: 'modal from anywhere · /article/new', api: 'POST /v1/article/', note: 'Recommendation: a modal. It needs two fields, and keeping the page underneath means cancelling costs nothing. /article/new opens the same modal over the article list, so it can be linked to and the back button closes it. A successful create always makes a draft and goes to the editor.', states: [
|
|
withM({ ...L({ modal: nm() }), name: 'Default', trigger: '"New article" from Home, Articles or the empty state.' }),
|
|
withM({ ...L({ modal: nm({ fields: nf('Ti', '', { t: { err: 'Use at least 3 characters.', countErr: true }, b: { err: 'Add a first paragraph. It can be a single sentence.' } }) }) }), name: 'Validation', trigger: 'On submit or blur. Nothing is sent.' }),
|
|
withM({ ...L({ modal: nm({ loading: true, confirm: 'Creating…', fields: nf('Choosing tiles for a small bathroom', 'Small rooms show every grout line, so we start with the floor.') }) }), name: 'Submitting', trigger: 'Request in flight. Fields are disabled and the dialog can\'t be dismissed.' }),
|
|
withM({ ...L({ modal: nm({ notice: 'Something in the form isn\'t valid. Check the highlighted fields.', fields: nf('Choosing tiles for a small bathroom', 'Small rooms show every grout line, so we start with the floor.', { t: { err: 'Check this title and try again.' } }) }) }), name: 'Title rejected (400)', trigger: 'A server 400 that got past the browser checks. The raw error isn\'t shown.' }),
|
|
withM({ ...L({ modal: nm({ notice: 'Couldn\'t create the article. Your text is still here. Try again in a moment.', fields: nf('Choosing tiles for a small bathroom', 'Small rooms show every grout line, so we start with the floor.') }) }), name: 'Error', trigger: '500 or network failure.' })
|
|
] },
|
|
{ label: 'Editor', title: 'Editor', route: '/article/:id/edit', api: 'GET /v1/article/{id} · PATCH /v1/article/{id} · PATCH …/publish|unpublish · POST …/image · DELETE', note: 'On mobile, Save and Publish sit in a sticky bottom bar and the details panel opens as a slideover. A session expiry mid-edit keeps the draft in sessionStorage and goes to /login?redirect=/article/12/edit. After logging in, the editor restores the draft and shows "Unsaved changes".', states: [
|
|
E({ loading: true }, { name: 'Loading', trigger: 'GET in flight. Skeletons for the title, content and details.' }),
|
|
E({}, { name: 'Saved', trigger: 'Just opened or just saved. Save is disabled.' }),
|
|
E({ save: 'dirty' }, { name: 'Unsaved changes', trigger: 'Any edit. Publish becomes "Save and publish". Leaving asks for confirmation.' }),
|
|
E({ save: 'saving' }, { name: 'Saving', trigger: 'Save, or Ctrl/⌘ S.' }),
|
|
E({ save: 'error' }, { name: 'Save failed', trigger: '500 or network failure. The changes are kept and Retry resends them.' }),
|
|
E({ save: 'dirty', len: 9240 }, { name: 'Near the limit', trigger: '90% of 10 000 HTML characters.' }),
|
|
E({ save: 'dirty', len: 10214 }, { name: 'Too long', trigger: 'Over 10 000. Save and Publish are disabled until it\'s shorter.' }),
|
|
E({ preview: true }, { name: 'Preview', trigger: 'The Preview switch. Shows the title and body in the content scale, the way a public site might.' }),
|
|
E({ save: 'dirty' }, { name: 'Upload: choose', trigger: 'The image button in the toolbar, or a file dropped on the editor.', modal: { title: 'Add an image', close: true, upload: { state: 'idle' }, cancel: 'Cancel' } }),
|
|
E({ save: 'dirty' }, { name: 'Upload: in progress', trigger: 'A valid file is uploading. Closing the dialog cancels the upload.', modal: { title: 'Add an image', close: true, upload: { state: 'uploading' }, cancel: 'Cancel upload' } }),
|
|
E({ save: 'dirty' }, { name: 'Upload: rejected', trigger: 'Checked in the browser, so nothing is sent.', modal: { title: 'Add an image', close: true, upload: { state: 'rejected' }, cancel: 'Cancel', confirm: 'Choose another file' } }),
|
|
E({ save: 'dirty', image: true }, { name: 'Upload: inserted', trigger: '201: the image goes in at the cursor, and the article now has unsaved changes.', toasts: [T('success', 'circle-check', 'Image added', 'Save to keep it in the article.')] }),
|
|
E({ save: 'saving', pending: 'Saving and publishing…' }, { name: 'Publishing', trigger: '"Save and publish": the PATCH body, then the publish request.' }),
|
|
E({ status: 'online' }, { name: 'Published', trigger: '200: the badge flips, Unpublish is offered and a toast confirms.', toasts: [T('success', 'circle-check', 'Article published', '"How we price a bathroom renovation" is now online.')] }),
|
|
E({ status: 'online', pending: 'Unpublishing…' }, { name: 'Unpublishing', trigger: 'Unpublish doesn\'t save pending edits.' }),
|
|
E({}, { name: 'Delete: confirm', trigger: 'Delete… from the overflow menu. After a 204, it goes to /article with the "Article deleted" toast.', modal: { title: 'Delete this article?', desc: '"How we price a bathroom renovation" and its 3 images will be deleted. This can\'t be undone.', cancel: 'Cancel', confirm: 'Delete article', tone: 'danger' } }),
|
|
E({ save: 'dirty' }, { name: 'Leave with unsaved changes', trigger: 'Back, a nav link or closing the tab.', modal: { title: 'Leave without saving?', desc: 'Your changes to "How we price a bathroom renovation" haven\'t been saved.', cancel: 'Discard changes', confirm: 'Keep editing' } }),
|
|
E({ save: 'offline' }, { name: 'Offline', trigger: 'The connection dropped. Writing continues, and Save and Publish wait.', offline: true }),
|
|
E({ notfound: true }, { name: 'Not found (404)', trigger: 'Unknown id, or the article was deleted elsewhere.' })
|
|
] },
|
|
{ label: 'Integration', title: 'Connect your website', route: '/integration', api: 'none (static)', note: 'Written for a developer. The base URL is window.location.origin.', states: [
|
|
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: 'Default', trigger: 'Opened from the sidebar or the Home card.' }),
|
|
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', copied: true, name: 'Copied', trigger: 'The base URL was copied. The button reads "Copied" for 2 s and this is announced.' })
|
|
] },
|
|
{ label: 'Global', title: 'Global states', route: 'any route', api: '', note: 'A 401 on any back-office call redirects to /login?redirect=<path>&reason=expired (see "Session expired" on the auth board). A 403 from an unverified account redirects to /register/pending. A 429 appears only on log in and password reset. Toasts sit bottom right on desktop and bottom center on mobile, at most 3 at once. Success and info last 5 s; errors stay until dismissed.', states: [
|
|
withM({ kind: 'error', nav: '', crumbs: ['Not found'], mTitle: 'Not found', name: '404 inside the shell', trigger: 'An unknown route while logged in. Logged out, the same content appears inside AuthShell.', err: { code: '404', title: 'Page not found', text: 'The link may be mistyped, or the page may have moved.', primary: 'Go to Home', secondary: 'All articles' } }),
|
|
withM({ kind: 'error', nav: 'home', crumbs: ['Home'], mTitle: 'Home', name: 'Generic error', trigger: 'An unexpected error while rendering a route.', err: { code: 'Error', title: 'Something went wrong', text: 'This page couldn\'t load. Your saved work is safe. Try again, and if it keeps happening, restart the RenewCMS server.', primary: 'Try again', secondary: 'Go to Home' } }),
|
|
withM({ ...L({ offline: true }), name: 'Offline banner', trigger: 'navigator.onLine is false. The banner is shown on every back-office page.' }),
|
|
withM({ ...L({ toasts: [T('success', 'circle-check', 'Article published', '"Winter checklist for timber windows" is now online.'), T('info', 'info', 'That article no longer exists', 'It was deleted somewhere else, so we removed it from the list.'), T('error', 'circle-x', 'Couldn\'t unpublish', 'The server didn\'t respond. Nothing changed.', 'Retry')] }), name: 'Toast stack', trigger: 'Three at most, newest at the bottom. A fourth pushes out the oldest non-error toast.' })
|
|
] }
|
|
]
|
|
}
|
|
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>
|