chore: groundwork for SEO fixes and tech&vis debt
This commit is contained in:
1 parent
02fb8751fd
commit
9f4ce426a8
11 files changed
+175
-24
No files matched your search
@@ -102,10 +102,52 @@ class Component extends DCLogic {
|
||||
D('position:absolute;right:12px;top:44px;z-index:5;width:220px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:4px', ...items)
|
||||
const Dots = () => S('width:' + (m ? 44 : 32) + 'px;height:' + (m ? 44 : 32) + 'px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)', s.ro ? I('chevron-right', 14) : I(m ? 'ellipsis-vertical' : 'ellipsis', m ? 18 : 16))
|
||||
const ph = 'Search by name or slug', q = s.q
|
||||
const SearchBox = () => D('height:' + bh + 'px;' + (m ? '' : 'width:280px;') + 'padding:0 4px 0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:' + (q ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)') + (m ? ';font-size:16px' : ''), I('search', 14), S('flex:1', q || ph), q && S('width:' + (m ? 36 : 24) + 'px;height:24px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)', I('x', 14)))
|
||||
const SearchBox = (ph2 = ph) => D('height:' + bh + 'px;' + (m ? '' : 'width:280px;') + 'padding:0 4px 0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:' + (q ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)') + (m ? ';font-size:16px' : ''), I('search', 14), S('flex:1', q || ph2), q && S('width:' + (m ? 36 : 24) + 'px;height:24px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)', I('x', 14)))
|
||||
const Head = (title, desc, btn) => D('display:flex;align-items:flex-end;gap:16px;flex-wrap:wrap', D('flex:1;min-width:200px;display:flex;flex-direction:column;gap:2px', S('font:600 20px/28px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', title), S('color:var(--ui-text-muted)', desc)), !m && btn && B(btn, { k: 'primary', icon: 'plus' }))
|
||||
const TH = (cols, labels) => D('display:grid;grid-template-columns:' + cols + ';gap:12px;height:36px;align-items:center;padding:0 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500', ...labels.map(l => S('', l)))
|
||||
const Skel = n => D('background:var(--ui-bg);border:1px solid var(--ui-border);' + (m ? 'margin:0 -16px;border-left:0;border-right:0' : ''), ...Array.from({ length: n }, () => D('display:flex;align-items:center;gap:12px;height:' + (m ? 72 : 52) + 'px;padding:0 16px;border-bottom:1px solid var(--ui-border-muted)', S('display:block;width:' + (m ? 32 : 28) + 'px;height:' + (m ? 32 : 28) + 'px;background:var(--ui-bg-accented)'), D('flex:1;display:flex;flex-direction:column;gap:8px', S('display:block;width:40%;height:13px;background:var(--ui-bg-accented)'), S('display:block;width:60%;height:11px;background:var(--ui-bg-accented)')))))
|
||||
|
||||
// ---------- pagination (same Paginator as Articles and Media)
|
||||
const fmtN = n => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
|
||||
const slugify = t => t.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
||||
const SN = ['Atelier', 'Birchwood', 'Cedar Row', 'Dovetail', 'Eastgate', 'Fieldnotes', 'Granary', 'Harbour', 'Ironwork', 'Joinery Guide', 'Kiln', 'Larch', 'Millstone', 'Nettle', 'Orchard', 'Planing', 'Quarry', 'Ridgeway', 'Slate', 'Tannery', 'Underhill', 'Veneer', 'Workshop', 'Yardline', 'Zinc Works']
|
||||
const RN = ['Journal', 'Projects', 'Archive', 'Press', 'Careers', 'Docs', 'Events', 'Shop']
|
||||
const genSites = g => { const st = (g.page - 1) * g.per, n = Math.min(12, g.per, g.total - st); return Array.from({ length: n }, (_, i) => { const gi = st + i; let name
|
||||
if (g.q) name = 'Renew ' + RN[gi % RN.length] + (gi >= RN.length ? ' ' + (Math.floor(gi / RN.length) + 1) : '')
|
||||
else if (gi < SITES.length) return SITES[gi]
|
||||
else { const k = gi - SITES.length; name = SN[k % SN.length] + (k >= SN.length ? ' ' + (Math.floor(k / SN.length) + 1) : '') }
|
||||
const key = slugify(name); return { n: name, key, url: 'https://' + (g.q ? key.replace(/^renew-/, '') : key) + '.renew.example', c: (gi * 37) % 90, robots: 'Index' } }) }
|
||||
const FN = ['Anaïs', 'Bruno', 'Clara', 'Diego', 'Elise', 'Félix', 'Greta', 'Hugo', 'Iris', 'Julien', 'Karim', 'Lucie', 'Mathieu', 'Nora', 'Oscar', 'Pilar', 'Quentin', 'Rosa', 'Samir', 'Tess']
|
||||
const QF = ['Marc', 'Marie', 'Marta', 'Mario', 'Marina', 'Marius', 'Marlène', 'Marcel']
|
||||
const LN = ['Marchand', 'Nunes', 'Oliveira', 'Perrin', 'Quintela', 'Rocha', 'Santos', 'Teixeira', 'Vidal', 'Weber']
|
||||
const genAuthors = g => { const st = (g.page - 1) * g.per, n = Math.min(12, g.per, g.total - st); return Array.from({ length: n }, (_, i) => { const gi = st + i
|
||||
if (!g.q && gi < AUTH.length) return AUTH[gi]
|
||||
const name = (g.q ? QF[gi % QF.length] : FN[gi % FN.length]) + ' ' + LN[(gi * 3 + 1) % LN.length], sl = slugify(name)
|
||||
return { name, slug: sl, user: gi % 3 === 0 ? sl.split('-')[0] + '@renew.example' : undefined, c: (gi * 5) % 12, img: gi % 2 === 0 } }) }
|
||||
const PagerBlock = (g, noun) => {
|
||||
const pages = Math.max(1, Math.ceil(g.total / g.per)), busy = !!g.loading, dim = busy ? 0.4 : 1
|
||||
const out = []
|
||||
if (g.err) out.push(Alert('error', 'circle-x', g.err, 'Check your connection and try again.', 'Retry'))
|
||||
if (pages === 1) return D('flex-shrink:0;display:flex;flex-direction:column;gap:12px', ...out, S('display:block;color:var(--ui-text-muted)', pl(g.total, noun)))
|
||||
const from = (g.page - 1) * g.per + 1, to = Math.min(g.page * g.per, g.total)
|
||||
const back = g.page === 1 ? 0.4 : 1, fwd = g.page === pages ? 0.4 : 1
|
||||
const PB = (ic, op) => S('width:' + bh + 'px;height:' + bh + 'px;display:flex;align-items:center;justify-content:center;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted);opacity:' + op, I(ic))
|
||||
const set = new Set([1, pages, g.page - 1, g.page, g.page + 1].filter(n => n >= 1 && n <= pages))
|
||||
const arr = pages <= 7 ? Array.from({ length: pages }, (_, i) => i + 1) : [...set].sort((a, b) => a - b)
|
||||
const nums = []
|
||||
arr.forEach((n, i) => {
|
||||
if (i && n - arr[i - 1] > 1) nums.push(S('min-width:' + bh + 'px;height:' + bh + 'px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)', '…'))
|
||||
const cur = n === g.page
|
||||
nums.push(S('min-width:' + bh + 'px;height:' + bh + 'px;padding:0 6px;display:flex;align-items:center;justify-content:center;border:1px solid ' + (cur ? 'var(--ui-primary)' : 'var(--ui-border-accented)') + ';background:' + (cur ? 'var(--rc-sand)' : 'var(--ui-bg)') + ';color:' + (cur ? 'var(--rc-on-sand)' : 'var(--ui-text-highlighted)') + ';font-weight:' + (cur ? 600 : 400), String(n)))
|
||||
})
|
||||
const Size = () => D('position:relative;opacity:' + dim, S('height:' + bh + 'px;padding:0 8px 0 12px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted)', g.per + ' per page', I('chevron-down', 14)),
|
||||
g.sizeOpen && D('position:absolute;' + (m ? 'right' : 'left') + ':0;bottom:calc(100% + 4px);z-index:40;width:160px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:4px', ...[5, 25, 50, 100].map(n => D('height:' + bh + 'px;padding:0 8px;display:flex;align-items:center;background:' + (n === g.per ? 'var(--rc-sand)' : 'transparent') + ';color:' + (n === g.per ? 'var(--rc-on-sand)' : 'var(--ui-text-highlighted)') + ';font-weight:' + (n === g.per ? 600 : 400), n + ' per page'))))
|
||||
const sum = S('color:var(--ui-text-muted)', busy ? 'Loading page ' + g.loading + '…' : 'Showing ' + from + '–' + to + ' of ' + fmtN(g.total))
|
||||
const NB = (t, left) => S('flex:1;height:44px;display:flex;align-items:center;justify-content:center;gap:6px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted);font-weight:500;opacity:' + (left ? back : fwd), left && I('chevron-left'), t, !left && I('chevron-right'))
|
||||
if (m) out.push(D('display:flex;flex-direction:column;gap:12px', D('display:flex;align-items:center;gap:12px;flex-wrap:wrap;justify-content:space-between', sum, Size()), D('display:flex;align-items:center;gap:8px;opacity:' + dim, NB('Previous', true), S('padding:0 4px;color:var(--ui-text-highlighted);font-weight:500;white-space:nowrap', 'Page ' + g.page + ' of ' + fmtN(pages)), NB('Next', false))))
|
||||
else out.push(D('display:flex;align-items:center;gap:16px', D('flex:1;display:flex;align-items:center;gap:16px', sum, Size()), D('display:flex;align-items:center;gap:4px;justify-content:flex-end;opacity:' + dim, PB('chevrons-left', back), PB('chevron-left', back), ...nums, PB('chevron-right', fwd), PB('chevrons-right', fwd))))
|
||||
return D('flex-shrink:0;display:flex;flex-direction:column;gap:12px', ...out)
|
||||
}
|
||||
const fab = label => m && !s.ro && !s.panel && !s.modal && !s.menu && s.list !== 'empty' && D('position:absolute;left:16px;right:16px;bottom:16px;height:48px;display:flex;align-items:center;justify-content:center;gap:8px;background:var(--ui-primary);color:var(--ui-bg);font-weight:500;font-size:15px;box-shadow:var(--rc-shadow-pop)', I('plus', 18), label)
|
||||
const shellBase = { kind: 'slot', display: 'Marta Alves' }
|
||||
let main = null, shell = s.shell, edMeta = null, edSeo = null
|
||||
@@ -131,11 +173,13 @@ class Component extends DCLogic {
|
||||
}
|
||||
const sitesPage = () => {
|
||||
if (s.forbidden) return D('flex:1;min-height:0;overflow:hidden;padding:' + (m ? '16px' : '24px') + ';display:flex;flex-direction:column;gap:16px', Empty('lock', 'You don\'t have access to Sites', 'Only the owner can manage sites. Ask the owner of this site if you need a change.', 'Back to Settings'))
|
||||
const st = s.sites || {}, rows = st.only ? [{ ...SITES[0], c: 312 }] : st.gone ? SITES.filter((_, i) => i !== 1) : SITES
|
||||
const pg = s.pg && { ...s.pg, q }, st = s.sites || {}, rows = pg ? (pg.empty ? [] : genSites(pg)) : st.only ? [{ ...SITES[0], c: 312 }] : st.gone ? SITES.filter((_, i) => i !== 1) : SITES
|
||||
const grid = 'minmax(0,1.1fr) minmax(0,1.5fr) 110px 44px'
|
||||
let body
|
||||
if (st.state === 'loading') body = Skel(3)
|
||||
else if (st.state === 'error') body = Alert('error', 'circle-x', 'Couldn\'t load sites', 'Check your connection and try again.', 'Try again')
|
||||
else if (pg && pg.loading) body = Skel(m ? 6 : 8)
|
||||
else if (pg && pg.empty) body = Empty('search', 'No sites match "' + q + '"', 'Check the spelling, or search for a shorter part of the name.', 'Clear search')
|
||||
else {
|
||||
const mi = st.menu
|
||||
const items = i => { const x = rows[i]; return x.def ? [menuItem('pencil', 'Edit', { hv: !m }), menuItem('trash-2', 'Delete…', { dis: true, err: true })] : [menuItem('pencil', 'Edit', { hv: !m }), menuItem('star', 'Set as default'), sep, menuItem('trash-2', 'Delete…', { err: true })] }
|
||||
@@ -151,7 +195,7 @@ class Component extends DCLogic {
|
||||
body = D('display:flex;flex-direction:column;gap:12px', D('background:var(--ui-bg);border:1px solid var(--ui-border);' + (m ? 'margin:0 -16px;border-left:0;border-right:0' : ''), !m && TH(grid, ['Site', 'Base URL', 'Articles', '']), ...trs),
|
||||
st.only && S('color:var(--ui-text-muted);text-wrap:pretty', 'Add another site if your content is shown on more than one frontend. Each article belongs to one site.'))
|
||||
}
|
||||
return D('flex:1;min-height:0;overflow:hidden;padding:' + (m ? '16px 16px 88px' : '24px') + ';display:flex;flex-direction:column;gap:16px;position:relative', Head('Sites', 'The frontends that display your content.', 'Add site'), body)
|
||||
return D('flex:1;min-height:0;overflow:hidden;padding:' + (m ? '16px 16px 88px' : '24px') + ';display:flex;flex-direction:column;gap:16px;position:relative', Head('Sites', 'The frontends that display your content.', 'Add site'), pg && SearchBox('Search by name or key'), pg ? D('flex:1;min-height:0;overflow:hidden', body) : body, pg && !pg.empty && PagerBlock(pg, 'site'))
|
||||
}
|
||||
const sitesModal = () => {
|
||||
const k = s.modal.k, x = s.modal, tgt = SITES[x.i ?? 1]
|
||||
@@ -196,11 +240,13 @@ class Component extends DCLogic {
|
||||
}
|
||||
const authorsPage = () => {
|
||||
const st = s.list || 'ok'
|
||||
let rows = AUTH
|
||||
if (q) rows = AUTH.filter(a => (a.name + a.slug).toLowerCase().includes(q.toLowerCase()))
|
||||
const pg = s.pg && { ...s.pg, q }
|
||||
let rows = pg ? (pg.empty ? [] : genAuthors(pg)) : AUTH
|
||||
if (q && !pg) rows = AUTH.filter(a => (a.name + a.slug).toLowerCase().includes(q.toLowerCase()))
|
||||
const grid = 'minmax(0,1.4fr) minmax(0,1fr) 110px 100px 44px'
|
||||
let body
|
||||
if (st === 'loading') body = Skel(6)
|
||||
else if (pg && pg.loading) body = Skel(m ? 6 : 8)
|
||||
else if (st === 'error') body = Alert('error', 'circle-x', 'Couldn\'t load authors', 'Check your connection and try again.', 'Try again')
|
||||
else if (st === 'empty') body = Empty('feather', 'No authors yet', 'Authors are the names readers see on articles. They don\'t need an account.', 'Add author', true)
|
||||
else if (q && !rows.length) body = Empty('search', 'No authors match "' + q + '"', 'Check the spelling, or search for a shorter part of the name.', 'Clear search')
|
||||
@@ -217,7 +263,7 @@ class Component extends DCLogic {
|
||||
body = D('background:var(--ui-bg);border:1px solid var(--ui-border);' + (m ? 'margin:0 -16px;border-left:0;border-right:0' : ''), !m && TH(grid, ['Author', 'Slug', 'Account', 'Articles', '']), ...trs)
|
||||
}
|
||||
const showTool = st !== 'empty' && st !== 'error'
|
||||
return D('flex:1;min-height:0;overflow:hidden;padding:' + (m ? '16px 16px 88px' : '24px') + ';display:flex;flex-direction:column;gap:16px;position:relative', Head('Authors', s.ro ? 'The names readers see on your articles. Only an owner or admin can change authors.' : 'The names readers see on your articles. An author doesn\'t need an account.', s.ro ? null : 'Add author'), showTool && SearchBox(), body)
|
||||
return D('flex:1;min-height:0;overflow:hidden;padding:' + (m ? '16px 16px 88px' : '24px') + ';display:flex;flex-direction:column;gap:16px;position:relative', Head('Authors', s.ro ? 'The names readers see on your articles. Only an owner or admin can change authors.' : 'The names readers see on your articles. An author doesn\'t need an account.', s.ro ? null : 'Add author'), showTool && SearchBox(), pg ? D('flex:1;min-height:0;overflow:hidden', body) : body, pg && !pg.empty && PagerBlock(pg, 'author'))
|
||||
}
|
||||
const authorsModal = () => {
|
||||
const x = s.modal, k = x.k, a = AUTH[x.i ?? 2]
|
||||
@@ -273,7 +319,7 @@ class Component extends DCLogic {
|
||||
}
|
||||
const newAuthorModal = () => {
|
||||
const x = s.modal
|
||||
return Shell({ title: 'Create author', desc: 'Adds a byline for this article. You can add a bio and avatar later in Authors.', cancel: 'Cancel', confirm: x.loading ? 'Creating…' : 'Create author', loading: x.loading, close: true, cdis: !x.name },
|
||||
return Shell({ z: 60, title: 'Create author', desc: 'Adds a byline for this article. You can add a bio and avatar later in Authors.', cancel: 'Cancel', confirm: x.loading ? 'Creating…' : 'Create author', loading: x.loading, close: true, cdis: !x.name },
|
||||
D('display:flex;flex-direction:column;gap:16px', x.notice && Alert('error', 'circle-x', 'Couldn\'t create the author', 'The article is unchanged. Try again in a moment.'),
|
||||
F('Name', x.name || 'e.g. Camille Roche', { val: !!x.name, focus: !x.name && !x.loading, err: x.err, dis: x.loading }),
|
||||
Slug('Slug', { mode: x.name ? (x.err ? 'manual' : 'auto') : 'empty', value: x.slug, help: 'Generated from the name. Unique across all authors.', err: x.slugErr, suggest: x.suggest }),
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
<sc-if value="{{ ep.noFields }}"><span style="color:var(--ui-text-muted)">No request body.</span></sc-if></div>
|
||||
<dc-import name="CodeSnippet" tabs="{{ ep.snips }}" hint-size="100%,200px"></dc-import>
|
||||
<div style="display:flex;flex-direction:column;gap:6px"><span style="font-weight:600;color:var(--ui-text-highlighted)">Errors</span>
|
||||
<sc-for list="{{ ep.errs }}" as="e" hint-placeholder-count="2"><div title="{{ e.cap }}" style="display:flex;align-items:baseline;gap:10px"><span style="width:36px;height:20px;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned);font:500 11px 'IBM Plex Mono',monospace">{{ e.c }}</span><span style="min-width:0;color:var(--ui-text-toned);text-wrap:pretty">{{ e.cap }}</span></div></sc-for></div>
|
||||
<sc-for list="{{ ep.errs }}" as="e" hint-placeholder-count="2"><div title="{{ e.cap }}" style="display:flex;align-items:baseline;gap:10px"><span style="width:36px;height:20px;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned);font:500 11px 'IBM Plex Mono',monospace">{{ e.c }}</span><sc-if value="{{ e.plain }}"><span style="min-width:0;color:var(--ui-text-toned);text-wrap:pretty">{{ e.cap }}</span></sc-if><sc-if value="{{ e.code }}"><span style="min-width:0;color:var(--ui-text-toned)"><code>{{ e.cap }}</code></span></sc-if></div></sc-for></div>
|
||||
</div>
|
||||
</sc-if>
|
||||
</div>
|
||||
@@ -104,7 +104,7 @@
|
||||
<span style="color:var(--ui-text-toned);text-wrap:pretty">Every error is a plain-text body with a single sentence. The status code is in the HTTP status line, and the Content-Type is text/plain.</span>
|
||||
<div style="border:1px solid var(--ui-border);background:var(--ui-bg);overflow:hidden">
|
||||
<div style="display:grid;grid-template-columns:{{ errCols }};gap:12px;padding:8px 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500"><span>Status</span><span>Meaning</span><sc-if value="{{ desk }}"><span>Typical cause</span></sc-if></div>
|
||||
<sc-for list="{{ errRows }}" as="r" hint-placeholder-count="8"><div style="display:grid;grid-template-columns:{{ errCols }};gap:2px 12px;padding:10px 12px;border-top:1px solid var(--ui-border)"><span style="font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ r.c }}</span><span style="font-weight:500;color:var(--ui-text-highlighted)">{{ r.m }}</span><span style="grid-column:{{ errCause }};color:var(--ui-text-toned);text-wrap:pretty">{{ r.w }}</span></div></sc-for>
|
||||
<sc-for list="{{ errRows }}" as="r" hint-placeholder-count="8"><div style="display:grid;grid-template-columns:{{ errCols }};gap:2px 12px;padding:10px 12px;border-top:1px solid var(--ui-border)"><span style="font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ r.c }}</span><span style="font-weight:500;color:var(--ui-text-highlighted)">{{ r.m }}</span><span style="grid-column:{{ errCause }};color:var(--ui-text-toned);text-wrap:pretty">{{ r.w }}</span><sc-if value="{{ r.n }}"><span style="grid-column:{{ errNoteCol }};color:var(--ui-text-muted);font-size:12px;line-height:18px;text-wrap:pretty">{{ r.n }}</span></sc-if></div></sc-for>
|
||||
</div>
|
||||
<dc-import name="CodeSnippet" tabs="{{ errSnips }}" hint-size="100%,120px"></dc-import>
|
||||
</section>
|
||||
@@ -189,7 +189,7 @@ class Component extends DCLogic {
|
||||
] },
|
||||
seo: { title: 'Search and sharing', desc: 'RenewCMS doesn\'t render pages or add meta tags. These endpoints give your frontend the resolved SEO data and the list of published URLs.', eps: [
|
||||
A('seo-get', 'GET', '/v1/article/{id}/seo', 'The resolved SEO object for one article: title (with the site template applied), description, canonical URL, robots, Open Graph and Twitter values, image with alt, authors, dates and ready-made Article JSON-LD. Every empty field already holds its fallback. Drafts answer 404 to anonymous callers.', 'Public', 1, [e401, e404], [], { pub: 1, r: j({ title: 'How we price a bathroom renovation | Renew', description: 'A bathroom renovation is priced by room size, plumbing changes and finish level.', canonical_url: 'https://renew.example/how-we-price-a-bathroom-renovation', robots: 'index, follow', og: { type: 'article', title: 'How we price a bathroom renovation | Renew', image: 'https://renew.example/media/north-wall.jpg', image_alt: 'A tiled bathroom wall', site_name: 'Renew' }, twitter: { card: 'summary_large_image' }, authors: [{ name: 'Inès Duarte', url: 'https://ines-duarte.example', same_as: [] }], published_at: '2026-03-12T09:14:00Z', modified_at: '2026-10-15T11:02:00Z', json_ld: { '@type': 'Article' } }) }),
|
||||
A('sitemap', 'GET', '/v1/site/{key}/sitemap', 'Published article URLs for one site, each with its last-modified date and a noindex flag, one page at a time. Use it to build your sitemap.xml. The total is in the X-Total-Count response header.', 'Public', 1, [[400, 'Invalid page or size.'], e401, [404, 'Site not found']], [S('page', 'integer', 'No', '1 or higher. Default 1.'), S('size', 'integer', 'No', 'One of 25, 50, 100 or 500. Default 100.')], { qp: 1, pub: 1, r: j([{ url: 'https://renew.example/how-we-price-a-bathroom-renovation', modified_at: '2026-10-15T11:02:00Z', noindex: false }]) })
|
||||
A('sitemap', 'GET', '/v1/site/{key}/sitemap', 'Published article URLs for one site, each with its last-modified date and a noindex flag, one page at a time. Use it to build your sitemap.xml. The total is in the X-Total-Count response header.', 'Public', 1, [[400, mm], e401, [404, 'site_not_found', true]], [S('page', 'integer', 'No', '1 or higher. Default 1.'), S('size', 'integer', 'No', 'One of 25, 50, 100 or 500. Default 100.')], { qp: 1, pub: 1, r: j([{ url: 'https://renew.example/how-we-price-a-bathroom-renovation', modified_at: '2026-10-15T11:02:00Z', noindex: false }]) })
|
||||
] },
|
||||
media: { title: 'Media', desc: 'The image library. One image can be used in many articles.', eps: [
|
||||
A('med-list', 'GET', '/v1/media/', 'List images.', 'Verified user', 1, [e401], [], { r: j([{ id: 4, filename: 'north-wall.jpg', size: 3900000 }]) }),
|
||||
@@ -239,7 +239,7 @@ class Component extends DCLogic {
|
||||
bodyTitle: e.qp ? 'Query parameters' : e.multi ? 'Request body · multipart/form-data, 1 file field' : e.body ? 'Request body · JSON' : 'Request body', hasFields: e.fields.length > 0, noFields: e.fields.length === 0, fields: e.fields,
|
||||
tabs: ['fetch', 'curl', 'Response'].map(t => { const k = t === 'Response' ? 'response' : t, on = (k === tab) || (t === tab); return { t, on, bd: on ? 'var(--ui-primary)' : 'transparent', fg: on ? 'var(--ui-text-highlighted)' : 'var(--ui-text-toned)', pick: () => this.setState(s => ({ tab: { ...s.tab, [e.id]: k } })) } }),
|
||||
snips: [{ k: 'fetch', label: 'fetch', code: fe }, { k: 'curl', label: 'curl', code: curl }, { k: 'response', label: 'Response', code: e.r }],
|
||||
errs: e.errs.map(([c, cap]) => ({ c, cap })) }
|
||||
errs: e.errs.map(([c, cap, code]) => ({ c, cap, code: !!code, plain: !code })) }
|
||||
}) }
|
||||
})
|
||||
}
|
||||
@@ -267,11 +267,11 @@ class Component extends DCLogic {
|
||||
fCols: mob ? 'minmax(0,1fr) auto auto' : '120px 72px 72px minmax(0,1fr)', ruleCol: mob ? '1 / -1' : 'auto',
|
||||
sessGroups: [sg('System · owner only', [['GET', '/v1/system/update', 'Update status'], ['POST', '/v1/system/update/check', 'Check for update'], ['POST', '/v1/system/update/download', 'Download update'], ['POST', '/v1/system/update/restart', 'Restart']]), sg('Sign in and account', [['POST', '/v1/auth/login', 'Log in'], ['POST', '/v1/auth/logout', 'Log out'], ['POST', '/v1/auth/register', 'Register'], ['POST', '/v1/auth/verify', 'Verify email'], ['POST', '/v1/auth/password-reset', 'Reset password']]), sg('API key management', [['GET', '/v1/api-key', 'List keys'], ['POST', '/v1/api-key', 'Create a key'], ['DELETE', '/v1/api-key/{id}', 'Revoke a key']])],
|
||||
sessCols: mob ? 'none' : '280px minmax(0,1fr) auto', sessGap: mob ? '2px' : '16px', sessPad: mob ? '8px 14px' : '0 14px', chipSelf: mob ? 'flex-start' : 'center',
|
||||
errCols: mob ? '56px minmax(0,1fr)' : '64px 200px minmax(0,1fr)', errCause: mob ? '2' : 'auto',
|
||||
errCols: mob ? '56px minmax(0,1fr)' : '64px 200px minmax(0,1fr)', errCause: mob ? '2' : 'auto', errNoteCol: mob ? '2' : '3',
|
||||
errRows: [
|
||||
{ c: 400, m: 'Bad request', w: '"The request cannot be processed due to a mismatch in the format of the body." or "The expiry must be a date in the future." Image requests answer with a JSON body whose error is "unknown_variant" or "unsupported_parameter".' },
|
||||
{ c: 401, m: 'Unauthorized', w: '"You must be logged in to access this resource." Sent for an invalid, unknown, expired or revoked key, even on public routes, and for a key whose user was deleted or unverified.' },
|
||||
{ c: 403, m: 'Forbidden', w: '"This action needs a real session." (a session-only route called with a key), "Only the owner can do this." (site SEO defaults: title template, default description, site name and default robots are owner only; article SEO fields need only the right to edit the article) or "You must verify your account to access this resource."' },
|
||||
{ c: 403, m: 'Forbidden', n: 'Site SEO defaults are owner only. Article SEO fields need only the right to edit the article.', w: '"This action needs a real session." (a session-only route called with a key), "Only the owner can do this." or "You must verify your account to access this resource."' },
|
||||
{ c: 404, m: 'Not found', w: '"Article not found", "Image not found", "Invite not found" or "API key not found". Drafts answer 404 to anonymous callers.' },
|
||||
{ c: 429, m: 'Too many requests', w: '"Too many requests. Please try again later." A per-key limit of 120 requests per minute also sends a Retry-After header.' },
|
||||
{ c: 500, m: 'Server error', w: '"Internal server error"' }
|
||||
|
||||
@@ -363,7 +363,7 @@ class Component extends DCLogic {
|
||||
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "3b. Copied: CodeBlock", trigger: "The Copy button in the API key example after a click. Back to \"Copy\" after 2 s.", int: {"scroll":"auth","copied":"auth"} }, { int: {"scroll":"auth","copied":"auth"} }),
|
||||
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "4. Authentication", trigger: "The API key card with its example, the \"Manage API keys\" link (goes to the API keys board) and the two notes.", int: {"scroll":"auth"} }, { int: {"scroll":"auth"} }),
|
||||
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "5. Session only", trigger: "Routes API keys cannot call. Dashed, muted and locked, so they read as unavailable and not as an error.", int: {"scroll":"session"} }, { int: {"scroll":"session"} }),
|
||||
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "6. Errors", trigger: "Status table and the plain-text error response (status line plus a one-sentence body). Key-specific causes are in the 401, 403 and 429 rows.", int: {"scroll":"errors"} }, { int: {"scroll":"errors"} }),
|
||||
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "6. Errors", trigger: "Status table and the plain-text error response (status line plus a one-sentence body). Key-specific causes are in the 401, 403 and 429 rows. The 403 row carries a muted note line (12/18, --ui-text-muted, aligned with the cause column, 2 px row gap above it): \"Site SEO defaults are owner only. Article SEO fields need only the right to edit the article.\" The 404 and 429 rows and everything below move down by the height of the wrapped note: two 18 px lines on desktop, three on mobile, plus the 2 px gap.", int: {"scroll":"errors"} }, { int: {"scroll":"errors"} }),
|
||||
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "7. Navigation", trigger: "Desktop: the sticky index with the active item (Media). Mobile: the \"Jump to\" select open. Keyboard: the index buttons and the select are in the tab order, Enter jumps, Escape closes the list.", int: {"scroll":"media"} }, { int: {"scroll":"media","jump":true} })
|
||||
] },
|
||||
{ label: 'Activity', title: 'Activity, owner and admin', route: '/activity', api: 'GET /v1/activity · GET /v1/activity/summary', note: 'New route in the sidebar after Users. Owner and admin see everything; regular users get "My activity". Copy, component mapping and API calls are at the bottom of this board.', states: [
|
||||
@@ -837,6 +837,25 @@ class Component extends DCLogic {
|
||||
const S = (name, trigger, copy, o, mo) => ({ name, trigger, copy, o, mo })
|
||||
const G = (label, title, route, api, note, states) => ({ label, title, route, api, note, states })
|
||||
const edT = 'How we price a bathroom renovation'
|
||||
const fmtP = n => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
|
||||
const PGS = (v, cfg) => {
|
||||
const { path, noun, N, mid, qN, qText, big, bigPage, single, empty } = cfg, pages = Math.ceil(N / 25)
|
||||
const o = (pg, x = {}) => v === 'sites' ? { v, sites: { state: 'ok' }, pg, ...x } : { v, pg, ...x }
|
||||
const rng = p => 'Showing ' + ((p - 1) * 25 + 1) + '–' + Math.min(p * 25, N) + ' of ' + fmtP(N)
|
||||
const bp = Math.ceil(big / 25), bm = bigPage
|
||||
return [
|
||||
S('First page', path + '. Previous and First are disabled. Mobile: Previous is disabled.', rng(1) + ' · 25 per page · Page 1 of ' + pages + ' · Previous · Next', o({ page: 1, per: 25, total: N })),
|
||||
S('Middle page', path + '?page=' + mid + '. Current page uses --rc-sand and --rc-on-sand.', rng(mid) + ' · Page ' + mid + ' of ' + pages, o({ page: mid, per: 25, total: N })),
|
||||
S('Last page', path + '?page=' + pages + '. Next and Last are disabled.', rng(pages) + ' · Page ' + pages + ' of ' + pages, o({ page: pages, per: 25, total: N })),
|
||||
S('Single page', 'Everything fits on one page. The pager and the page-size select are hidden; only the count remains.', single + ' ' + noun + (single === 1 ? '' : 's'), o({ page: 1, per: 25, total: single })),
|
||||
S('Page size menu', 'The select opens upward: 5, 25, 50 or 100 per page. 25 is the default. Choosing another size sets ?size= and returns to page 1.', '5 per page · 25 per page · 50 per page · 100 per page', o({ page: mid, per: 25, total: N, sizeOpen: true })),
|
||||
S('Loading a page', 'Next was pressed. The rows are replaced by skeleton rows; the pager stays, disabled, and the summary reads "Loading page ' + (mid + 1) + '…".', 'Loading page ' + (mid + 1) + '…', o({ page: mid, per: 25, total: N, loading: mid + 1 })),
|
||||
S('Error loading a page', '500 or network failure on a page change. The current rows and the current page number stay; Retry sits in the notice.', 'Couldn\'t load page ' + (mid + 1) + '. Showing page ' + mid + '. · Check your connection and try again. · Retry', o({ page: mid, per: 25, total: N, err: 'Couldn\'t load page ' + (mid + 1) + '. Showing page ' + mid + '.' })),
|
||||
S('Empty after search', 'A search matched nothing. The empty state replaces the list and the pager is hidden.', 'No ' + noun + 's match "' + empty + '" · Check the spelling, or search for a shorter part of the name. · Clear search', o({ empty: true, total: 0 }, { q: empty })),
|
||||
S('Search resets to page 1', 'Searching "' + qText + '" from page ' + (mid + 2) + ' goes to ' + path + '?q=' + qText + ' (page omitted). Changing the page size does the same.', rng(1).replace(' of ' + fmtP(N), ' of ' + qN), o({ page: 1, per: 25, total: qN }, { q: qText })),
|
||||
S('Long labels on mobile', 'Large totals on a 390 px screen. The summary and the page-size select wrap onto two lines if needed; Previous, "Page ' + bm + ' of ' + bp + '" and Next keep one row.', 'Showing ' + ((bm - 1) * 25 + 1) + '–' + (bm * 25) + ' of ' + fmtP(big) + ' · Page ' + bm + ' of ' + bp, o({ page: bm, per: 25, total: big }))
|
||||
]
|
||||
}
|
||||
const sitesNote = 'Settings, Publishing group. Compact list; actions in the row menu. Default site: no delete action.'
|
||||
return { groups: [
|
||||
G('Sites', 'Sites', '/settings/sites', 'GET /v1/site/ · PATCH /v1/site/{id}/default · DELETE /v1/site/{id}?reassign_to={id}', 'Only the owner can create, edit, set the default or delete a site. Other roles don\'t see the Sites entry under Settings; a direct link shows a 403 page. Choosing a site for an article is not site management: anyone who can edit the article can set its site in the editor and read the site list. New child of Settings, under a "Publishing" heading in the sidebar (Settings also gets a "Publishing" card with a "Manage sites" row, owner only). Columns: name with key, base URL, article count. The default site carries a "Default" badge and has no delete action. A site with robots set to noindex shows a "Noindex" badge. List, row menu and dialogs come from DirectoryFrame and are shared with Authors.', [
|
||||
@@ -864,6 +883,7 @@ class Component extends DCLogic {
|
||||
S('Choose destination', 'The select is open. The site being deleted is not listed.', 'Journal is not offered. Options show name and host.', { v: 'sites', sites: { state: 'ok' }, modal: { k: 'del', i: 1, open: true } }),
|
||||
S('No articles', 'A site with zero articles: plain confirm, no select.', 'Delete this site? · "Archive" will be deleted. No articles belong to it. This can\'t be undone. · Delete site', { v: 'sites', sites: { state: 'ok' }, modal: { k: 'del0', i: 3 } })
|
||||
]),
|
||||
G('Sites: pagination', 'Sites: pagination', '/settings/sites?page=2&size=50', 'GET /v1/site/?q=&page=&size= (total in the X-Total-Count header; array body unchanged)', 'Same Paginator as Articles and Media (components.md). Sizes 5, 25, 50, 100, default 25: the existing pattern, unchanged. Page and size in the URL as page and size; defaults omitted; a page past the end falls back to the last page. Search resets to page 1. The count line "N sites" replaces the pager on a single page. Sites has no search box today: it is added with the pager (same field as Authors, "Search by name or key") and is a proposal to confirm. The list is drawn with generated rows; 61 sites give 3 pages.', PGS('sites', { path: '/settings/sites', noun: 'site', N: 61, mid: 2, qN: 27, qText: 'renew', big: 312, bigPage: 6, single: 4, empty: 'blog' })),
|
||||
G('Authors', 'Authors', '/authors', 'GET /v1/author/?q=&page=&size= · DELETE /v1/author/{id}?reassign_to={id|none}', 'Sidebar entry between Media and Users, visible to every role. Owner and admin create, edit and delete any author. Other roles see the list and the detail read-only. Columns: avatar and name, slug, account (Linked or Guest), article count. Search by name or slug. Long names truncate in the row and show in full on hover. Above 25 authors the list uses the same paginator as Articles (not redrawn). List, panel and dialogs come from DirectoryFrame and are shared with Sites.', [
|
||||
S('Default', 'Owner or admin. The list loaded, newest first.', 'Authors · The names readers see on your articles. An author doesn\'t need an account. · Add author · Search by name or slug · Author · Slug · Account · Articles · Linked · Guest · 9 articles · 1 article · No articles', { v: 'authors' }),
|
||||
S('Loading', 'The first load, or a retry.', '', { v: 'authors', list: 'loading' }),
|
||||
@@ -895,6 +915,7 @@ class Component extends DCLogic {
|
||||
S('Server fallback (409)', 'Fallback only. The dialog above always sends reassign_to, so this appears only when the server refuses a stale request because articles still credit the author. Drawn once, not as a flow.', 'Can\'t delete this author · "Camille Roche" is credited on 3 articles. Choose another author to credit instead, or remove Camille Roche from those articles first. · Close · Reassign articles', { v: 'authors', modal: { k: 'blocked', i: 2 } }),
|
||||
S('No articles', 'An author with zero articles: plain confirm.', 'Delete this author? · "Léa Fontaine" will be deleted. No articles credit this author. This can\'t be undone. · Delete author', { v: 'authors', modal: { k: 'del0', i: 4 } })
|
||||
]),
|
||||
G('Authors: pagination', 'Authors: pagination', '/authors?page=3&size=50', 'GET /v1/author/?q=&page=&size= (total in the X-Total-Count header; array body unchanged)', 'Same Paginator as Articles and Media (components.md). Sizes 5, 25, 50, 100, default 25: the existing pattern, unchanged. Page and size in the URL as page and size; defaults omitted; a page past the end falls back to the last page. Search resets to page 1. The count line "N authors" replaces the pager on a single page. The list is drawn with generated rows; 128 authors give 6 pages.', PGS('authors', { path: '/authors', noun: 'author', N: 128, mid: 3, qN: 31, qText: 'mar', big: 1284, bigPage: 12, single: 6, empty: 'zoe' })),
|
||||
G('Editor: site and authors', 'Editor: site and authors', '/article/:id/edit', 'PATCH /v1/article/{id} {site_id, author_ids[]} · GET /v1/site/ · GET /v1/author/ · POST /v1/author/', 'States specific to this feature. The collapsed default (one site, the article has an author) and the Loading and Not found states are drawn in the existing Editor frames, which show the section in the same place; they are not repeated here. The section is open when the install has more than one site or the article has no authors, and collapsed to one summary line otherwise; the choice is remembered per browser. On mobile it is closed by default. With one site the Site select is not rendered. Anyone who can edit the article can set its site and pick authors; "Create author…", the Create author dialog and its "This is me" checkbox are for owner and admin only. For any other role, the "Create my author profile" hint (state 4) creates the profile directly with POST /v1/author/me, with no dialog, then shows the author in the list with a toast. Authors are a reorderable list; the first is the primary byline.', [
|
||||
S('Open: one site, one author', 'The section was opened. No Site select.', 'Authors · Add an author', { v: 'editor', metaOpen: true, meta: { authors: ['Inès Duarte'] } }),
|
||||
S('Open: several sites', 'Site select above the authors, with the canonical host as helper text.', 'Site · Journal · journal.renew.example · Canonical host: journal.renew.example', { v: 'editor', ed: { metaSummary: 'Journal · Inès Duarte' }, metaOpen: true, meta: { sites: true, site: 'Journal', authors: ['Inès Duarte'] } }),
|
||||
|
||||
Reference in new issue
Block a user