439 lines
78 KiB
HTML
439 lines
78 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>
|
||
<dc-import name="AppFrame" s="{{ shell }}" mobile="{{ mob }}" slot-body="{{ main }}" slot-overlays="{{ ov }}" ed-meta="{{ edMeta }}" ed-seo="{{ edSeo }}" hint-size="100%,100%"></dc-import>
|
||
</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
|
||
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 h = React.createElement
|
||
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, ih = m ? 44 : 40, fs = m ? 16 : 13
|
||
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)' : k === 'del' ? 'var(--ui-error)' : '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:' + (o.fs || 13) + 'px;white-space:nowrap;opacity:' + (o.dis ? 0.4 : 1) + ';' + (o.full ? 'flex:1;' : '') + (o.ring ? 'outline:2px solid var(--ui-primary);outline-offset:2px;' : ''), o.icon && I(o.icon, o.z || 16), label)
|
||
}
|
||
const Alert = (tone, icon, title, text, action) => 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:8px', D('display:flex;flex-direction:column;gap:2px', S('font-weight:600', title), text && S('color:var(--ui-text-toned)', text)), action && B(action, { icon: 'rotate-cw', z: 14, h: m ? 44 : 28, fs: 12, px: 10 })))
|
||
const Empty = (icon, title, text, action, primary) => D('background:var(--ui-bg);border:1px solid var(--ui-border);padding:56px 24px;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(--ui-bg-elevated);color:var(--ui-text-toned)', I(icon, 20)), S('font-weight:600;font-size:15px;color:var(--ui-text-highlighted)', title), S('color:var(--ui-text-muted);max-width:340px;text-wrap:pretty', text), action && B(action, { k: primary ? 'primary' : 'outline', icon: primary ? 'plus' : null, px: 16 }))
|
||
const Note = t => S('align-self:flex-start;min-height:20px;padding:1px 6px;display:inline-flex;align-items:center;border:1px dashed var(--ui-border-accented);color:var(--ui-text-toned);font:500 11px/16px \'IBM Plex Mono\',monospace', t)
|
||
const BG = { def: ['background:var(--rc-sand);color:var(--rc-on-sand)', 'star'], linked: ['background:var(--rc-info-soft);color:var(--ui-info)', 'link'], guest: ['border:1px solid var(--ui-border-accented);color:var(--ui-text-toned)', 'user'], noidx: ['background:var(--rc-warning-soft);color:var(--ui-warning)', 'eye-off'], prim: ['border:1px solid var(--ui-border-accented);color:var(--ui-text-toned)'] }
|
||
const Bd = (t, k, sz = 20) => S('height:' + sz + 'px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;font-size:12px;font-weight:500;white-space:nowrap;flex-shrink:0;' + BG[k][0], BG[k][1] && I(BG[k][1], 12), t)
|
||
const mono = 'font-family:\'IBM Plex Mono\',monospace;'
|
||
// ---------- data
|
||
const SITES = [
|
||
{ n: 'Renew', key: 'renew', url: 'https://renew.example', c: 268, def: true, robots: 'Index', loc: 'en', img: 'north-wall.jpg' },
|
||
{ n: 'Journal', key: 'journal', url: 'https://journal.renew.example', c: 38, robots: 'Index', loc: '', img: null },
|
||
{ n: 'Projects', key: 'projects', url: 'https://projects.renew.example', c: 6, robots: 'Noindex', loc: 'fr', img: 'kitchen-after.webp' },
|
||
{ n: 'Archive', key: 'archive', url: 'https://archive.renew.example', c: 0, robots: 'Noindex', loc: '', img: null }
|
||
]
|
||
const AUTH = [
|
||
{ name: 'Inès Duarte', slug: 'ines-duarte', user: 'ines.duarte@renew.example', c: 9, img: true, web: 'https://ines-duarte.example', bio: 'Site manager and restoration carpenter. Writes about timber and joinery.' },
|
||
{ name: 'Jonas Pereira', slug: 'jonas-pereira', user: 'jonas@renew.example', c: 6, img: true, bio: 'Heat pumps, insulation and the numbers behind them.' },
|
||
{ name: 'Camille Roche', slug: 'camille-roche', c: 3, bio: 'Freelance writer on lime and stone.' },
|
||
{ name: 'Paul Huber', slug: 'paul-huber', c: 2, img: true, bio: '' },
|
||
{ name: 'Léa Fontaine', slug: 'lea-fontaine', c: 0, bio: '' },
|
||
{ name: 'Dr. Alexandra Konstantinopoulou-Whitfield', slug: 'dr-alexandra-konstantinopoulou-whitfield', c: 1, bio: 'Conservation architect.' }
|
||
]
|
||
const AV = n => AUTH.find(a => a.name === n) || { name: n, slug: n.toLowerCase().replace(/[^a-z0-9]+/g, '-'), c: 0 }
|
||
const TONE = ['var(--rc-sand)', 'var(--ui-bg-accented)', 'var(--ui-bg-elevated)']
|
||
const ini = n => n.replace(/^Dr\.\s*/, '').split(/\s+/).slice(0, 2).map(x => x[0]).join('').toUpperCase()
|
||
const Av = (a, z = 32, ring) => S('width:' + z + 'px;height:' + z + 'px;flex-shrink:0;display:flex;align-items:center;justify-content:center;background:' + (a.img ? 'repeating-linear-gradient(135deg, var(--ui-border) 0 1px, transparent 1px 8px), ' : '') + TONE[a.name.length % 3] + ';color:var(--ui-text-highlighted);border:' + (ring ? '2px solid var(--ui-bg)' : '1px solid var(--ui-border)') + ';font:600 ' + Math.round(z * 0.36) + 'px/1 \'IBM Plex Sans\',sans-serif', ini(a.name))
|
||
const pl = (n, w) => n === 0 ? 'No ' + w + 's' : n + ' ' + w + (n === 1 ? '' : 's')
|
||
const host = u => u.replace('https://', '')
|
||
// ---------- fields
|
||
const F = (lab, val, o = {}) => D('display:flex;flex-direction:column;gap:6px;min-width:0', (lab || o.count) && D('display:flex;justify-content:space-between;gap:8px', S('font-weight:500;color:var(--ui-text-highlighted)', lab, o.opt && S('font-weight:400;color:var(--ui-text-muted)', ' (optional)')), o.count && S('font:400 12px \'IBM Plex Mono\',monospace;color:' + (o.countErr ? 'var(--ui-error)' : 'var(--ui-text-muted)'), o.count)),
|
||
D('display:flex;align-items:' + (o.area ? 'flex-start' : 'center') + ';min-height:' + (o.area ? (m ? 120 : 96) : ih) + 'px;border:1px solid ' + (o.err ? 'var(--ui-error)' : o.focus ? 'var(--ui-primary)' : 'var(--ui-border-accented)') + ';' + (o.err ? 'box-shadow:inset 0 0 0 1px var(--ui-error);' : o.focus ? 'box-shadow:inset 0 0 0 1px var(--ui-primary);' : '') + 'background:' + (o.dis ? 'var(--ui-bg-elevated)' : 'var(--ui-bg)') + ';font-size:' + fs + 'px', o.pre, S('flex:1;min-width:0;padding:' + (o.area ? '8px 10px' : '0 10px') + ';color:' + (o.val === false ? 'var(--ui-text-muted)' : 'var(--ui-text-highlighted)') + ';' + (o.area ? '' : 'overflow:hidden;text-overflow:ellipsis;white-space:nowrap;') + (o.mono ? mono + 'font-size:' + (m ? 14 : 12) + 'px;' : ''), val), o.sel && I('chevron-down', 14, 'margin-right:10px;color:var(--ui-text-muted)'), o.suffix),
|
||
o.err && S('display:flex;gap:6px;align-items:flex-start;color:var(--ui-error);font-size:12px;line-height:18px', I('circle-alert', 14, 'margin-top:2px'), o.err), o.help && S('color:var(--ui-text-muted);font-size:12px;line-height:18px;text-wrap:pretty', o.help))
|
||
const Link = (t, icon) => S('display:inline-flex;align-items:center;gap:4px;min-height:' + (m ? 44 : 20) + 'px;color:var(--ui-text-highlighted);text-decoration:underline;text-underline-offset:3px;font-size:12px;font-weight:500', icon && I(icon, 12), t)
|
||
const Slug = (lab, o) => {
|
||
const mode = o.mode || 'empty'
|
||
if (mode === 'saved') return F(lab, o.value, { mono: true, dis: !!o.locked, help: o.locked ? o.lockHelp : o.help2, suffix: o.locked && I('lock', 14, 'margin-right:10px;color:var(--ui-text-muted)') })
|
||
if (mode === 'empty') return F(lab, o.emptyText || 'Generated from the name', { val: false, dis: true, mono: true, help: o.help })
|
||
if (mode === 'auto') return F(lab, o.value, { mono: true, dis: true, help: o.help, suffix: S('display:flex;align-items:center;gap:6px;padding:0 4px', S('height:20px;padding:0 6px;display:inline-flex;align-items:center;border:1px dashed var(--ui-border-accented);font:500 11px \'IBM Plex Mono\',monospace;color:var(--ui-text-toned)', 'Auto'), S('width:' + (m ? 44 : 28) + 'px;height:' + (m ? 44 : 28) + 'px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-highlighted)', I('pencil', 14))) })
|
||
return D('display:flex;flex-direction:column;gap:6px', F(lab, o.value, { mono: true, focus: !o.err, err: o.err, help: o.err ? null : o.help2 || o.help }), o.suggest && D('display:flex;align-items:center;gap:8px;flex-wrap:wrap', B('Use ' + o.suggest, { h: m ? 44 : 28, fs: 12, px: 10, icon: 'wand-sparkles', z: 12 })), mode === 'manual' && Link(o.reset || 'Reset to match the name', 'rotate-ccw'))
|
||
}
|
||
const Seg = (opts, on) => D('display:flex;border:1px solid var(--ui-border-accented);width:100%', ...opts.map(([k, t]) => S('flex:1;height:' + (m ? 44 : 32) + 'px;display:flex;align-items:center;justify-content:center;font-weight:' + (on === k ? 500 : 400) + ';background:' + (on === k ? 'var(--rc-sand)' : 'var(--ui-bg)') + ';color:' + (on === k ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)') + ';font-size:' + (m ? 14 : 13) + 'px', t)))
|
||
const Sw = (on, lab, help, dis) => D('display:flex;gap:12px;align-items:flex-start;opacity:' + (dis ? 0.6 : 1), S('width:32px;height:18px;margin-top:2px;padding:2px;display:flex;flex-shrink:0;justify-content:' + (on ? 'flex-end' : 'flex-start') + ';background:' + (on ? 'var(--ui-primary)' : 'var(--ui-bg-accented)'), S('width:14px;height:14px;background:var(--ui-bg)')), D('display:flex;flex-direction:column;gap:2px', S('font-weight:500;color:var(--ui-text-highlighted)', lab), help && S('color:var(--ui-text-muted);font-size:12px;line-height:18px;text-wrap:pretty', help)))
|
||
const Opts = (items, on, hdr) => D('border:1px solid var(--ui-border);background:var(--ui-bg);box-shadow:var(--rc-shadow-pop);padding:4px', hdr, ...items.map(([t, sub, k, av]) => D('min-height:' + (m ? 48 : 36) + 'px;padding:4px 8px;display:flex;align-items:center;gap:8px;background:' + (on === t ? 'var(--ui-bg-elevated)' : 'transparent') + ';color:var(--ui-text-highlighted)', av, D('flex:1;min-width:0;display:flex;flex-direction:column', S('overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500', t), sub && S('font-size:12px;color:var(--ui-text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', sub)), on === t && I('check', 14))))
|
||
const Pick = (img, lab, help, o = {}) => D('display:flex;flex-direction:column;gap:6px', S('font-weight:500;color:var(--ui-text-highlighted)', lab, S('font-weight:400;color:var(--ui-text-muted)', ' (optional)')),
|
||
D('display:flex;align-items:center;gap:12px', o.av ? Av(o.av, 64) : S('width:' + (img ? 96 : 96) + 'px;aspect-ratio:1.91/1;flex-shrink:0;display:flex;align-items:center;justify-content:center;border:1px ' + (img ? 'solid' : 'dashed') + ' var(--ui-border-accented);background:' + (img ? 'repeating-linear-gradient(135deg, var(--ui-border) 0 1px, transparent 1px 10px), var(--rc-sand)' : 'var(--ui-bg)') + ';color:var(--ui-text-muted)', I('image', 18)),
|
||
D('flex:1;min-width:0;display:flex;flex-direction:column;gap:6px', img && S('font-size:12px;color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;' + mono, img), D('display:flex;gap:8px;flex-wrap:wrap', B(img ? 'Change image' : 'Choose image', { icon: 'images', h: m ? 44 : 28, fs: 12, px: 10 }), img && B('Remove', { h: m ? 44 : 28, fs: 12, px: 10 })))),
|
||
help && S('color:var(--ui-text-muted);font-size:12px;line-height:18px', help))
|
||
// ---------- overlays
|
||
const Shell = (o, body) => D('position:absolute;inset:0;z-index:' + (o.z || 50) + ';background:var(--rc-overlay);display:flex;align-items:' + (m ? 'flex-end' : 'flex-start') + ';justify-content:center;padding:' + (m ? '0' : (o.top || 140) + 'px 16px 16px'),
|
||
D('width:' + (m ? '100%' : (o.w || 460) + 'px') + ';max-height:100%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column', m && D('display:flex;justify-content:center;padding-top:8px;flex-shrink:0', S('display:block;width:36px;height:4px;background:var(--ui-border-accented)')),
|
||
D('padding:20px 24px 0;display:flex;align-items:flex-start;gap:12px', D('flex:1;display:flex;flex-direction:column;gap:4px', S('font:600 16px/24px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', o.title), o.desc && S('color:var(--ui-text-muted);text-wrap:pretty', o.desc)), o.close && S('width:' + (m ? 44 : 28) + 'px;height:' + (m ? 44 : 28) + 'px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)', I('x'))),
|
||
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 }), o.confirm && S('height:' + bh + 'px;padding:0 12px;display:flex;align-items:center;justify-content:center;gap:6px;background:' + (o.tone === 'danger' ? 'var(--ui-error)' : 'var(--ui-primary)') + ';color:var(--ui-bg);font-weight:500;white-space:nowrap;opacity:' + (o.cdis ? 0.4 : 1), o.loading && I('loader-circle'), o.confirm))))
|
||
const Panel = (o, body, ...pv) => D('position:absolute;inset:0;z-index:' + (o.z || 40) + ';display:flex;justify-content:' + (m ? 'center' : 'flex-end') + ';align-items:' + (m ? 'flex-end' : 'stretch') + ';background:var(--rc-overlay)',
|
||
D('width:' + (m ? '100%' : (o.w || 480) + 'px') + ';height:' + (m ? '92%' : '100%') + ';background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column', m && D('display:flex;justify-content:center;padding-top:8px;flex-shrink:0', S('display:block;width:36px;height:4px;background:var(--ui-border-accented)')),
|
||
D('flex-shrink:0;display:flex;align-items:center;gap:12px;padding:' + (m ? '4px 6px 4px 16px' : '0 12px 0 20px') + ';height:56px;border-bottom:1px solid var(--ui-border)', S('flex:1;min-width:0;font:600 16px/24px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', o.title), S('width:' + (m ? 44 : 28) + 'px;height:' + (m ? 44 : 28) + 'px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)', I('x'))),
|
||
D('flex:1;min-height:0;overflow:hidden;padding:' + (m ? 16 : 20) + 'px;display:flex;flex-direction:column;gap:18px', o.notice && Alert('error', 'circle-x', o.notice), body, ...pv),
|
||
D('flex-shrink:0;padding:' + (m ? '12px 16px' : '12px 20px') + ';border-top:1px solid var(--ui-border);display:flex;flex-direction:' + (m ? 'column-reverse' : 'row') + ';justify-content:' + (o.ro ? 'flex-end' : 'space-between') + ';gap:8px', B(o.ro ? 'Close' : 'Cancel', { dis: o.saving }), !o.ro && S('height:' + bh + 'px;padding:0 16px;display:flex;align-items:center;justify-content:center;gap:6px;background:var(--ui-primary);color:var(--ui-bg);font-weight:500;white-space:nowrap;opacity:' + (o.cdis ? 0.4 : 1), o.saving && I('loader-circle'), o.confirm))))
|
||
const Toasts = () => D('position:absolute;z-index:60;display:flex;flex-direction:column;gap:8px;' + (m ? 'left:16px;right:16px;bottom:16px' : 'right:24px;bottom:24px;width:356px'), ...s.toasts.map(t => D('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-' + t[0] + ')', I(t[1])), D('flex:1;display:flex;flex-direction:column;gap:6px', D('display:flex;flex-direction:column;gap:2px', S('font-weight:600;color:var(--ui-text-highlighted)', t[2]), S('color:var(--ui-text-muted)', t[3])), t[4] && B(t[4], { icon: 'rotate-cw', z: 14, h: m ? 44 : 28, fs: 12, px: 10 })), I('x'))))
|
||
// media picker (shared by site image and author avatar)
|
||
const IMGS = ['north-wall.jpg', 'kitchen-after.webp', 'kitchen-before.webp', 'logo-renew.svg', 'window-frame.jpg', 'lime-plaster.jpg', 'floorboards.jpg', 'bathroom-tiles.png']
|
||
const Picker = pk => {
|
||
const cols = m ? 3 : 4
|
||
return Shell({ title: pk.title, desc: pk.desc, close: true, w: 640, top: 40, z: pk.z || 55, cancel: 'Cancel', confirm: 'Use image', cdis: pk.sel == null }, D('display:flex;flex-direction:column;gap:12px',
|
||
D('display:flex;gap:20px;border-bottom:1px solid var(--ui-border)', ...['Upload', 'Library'].map(t => S('height:' + (m ? 44 : 36) + 'px;display:flex;align-items:center;gap:6px;border-bottom:2px solid ' + (t === 'Library' ? 'var(--ui-primary)' : 'transparent') + ';margin-bottom:-1px;font-weight:' + (t === 'Library' ? 500 : 400) + ';color:' + (t === 'Library' ? 'var(--ui-text-highlighted)' : 'var(--ui-text-toned)'), I(t === 'Upload' ? 'upload' : 'images', 14), t))),
|
||
D('height:' + ih + 'px;padding:0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);color:var(--ui-text-muted);font-size:' + fs + 'px', I('search', 14), 'Search by filename'),
|
||
D('display:grid;grid-template-columns:repeat(' + cols + ',minmax(0,1fr));gap:10px', ...IMGS.slice(0, m ? 6 : 8).map((n, i) => D('display:flex;flex-direction:column;gap:4px', S('position:relative;display:flex;align-items:center;justify-content:center;aspect-ratio:' + (pk.sq ? '1/1' : '4/3') + ';background:repeating-linear-gradient(135deg, var(--ui-border) 0 1px, transparent 1px 12px), ' + TONE[i % 3] + ';color:var(--ui-text-muted);border:1px solid ' + (pk.sel === i ? 'var(--ui-primary)' : 'var(--ui-border)') + ';' + (pk.sel === i ? 'box-shadow:inset 0 0 0 1px var(--ui-primary);' : ''), I('image', 18), pk.sel === i && S('position:absolute;top:4px;right:4px;width:20px;height:20px;display:flex;align-items:center;justify-content:center;background:var(--ui-primary);color:var(--ui-bg)', I('check', 14))), S('font-size:12px;color:var(--ui-text-toned);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', n)))),
|
||
pk.sq && S('color:var(--ui-text-muted);font-size:12px', 'Shown as a square. Choose an image with the face or logo near the middle.')))
|
||
}
|
||
// ---------- list parts
|
||
const menuItem = (icon, t, o = {}) => D('min-height:' + (m ? 48 : 32) + 'px;padding:' + (m ? '0 16px' : '0 8px') + ';display:flex;align-items:center;gap:' + (m ? 12 : 8) + 'px;color:' + (o.err ? 'var(--ui-error)' : 'var(--ui-text-highlighted)') + ';opacity:' + (o.dis ? 0.4 : 1) + ';background:' + (o.hv ? 'var(--ui-bg-elevated)' : 'transparent') + ';font-size:' + (m ? 15 : 13) + 'px', I(icon, m ? 18 : 16), t)
|
||
const sep = D('height:1px;background:var(--ui-border);margin:4px 0')
|
||
const MenuBox = (items, title, desc) => m ?
|
||
D('position:absolute;inset:0;z-index:45;background:var(--rc-overlay);display:flex;align-items:flex-end', D('width:100%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);padding:8px 0 24px', D('display:flex;justify-content:center;padding-bottom:8px', S('display:block;width:36px;height:4px;background:var(--ui-border-accented)')), D('display:flex;align-items:center;padding:0 6px 0 16px;min-height:44px', S('flex:1;font-weight:600;font-size:15px;color:var(--ui-text-highlighted)', title), S('width:44px;height:44px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)', I('x'))), ...items, desc && S('display:block;padding:4px 16px 0 46px;color:var(--ui-text-muted);font-size:12px;line-height:18px', desc))) :
|
||
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 = (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
|
||
const ov = []
|
||
// ---------- Sites
|
||
const siteForm = () => {
|
||
const p = s.panel, f = { name: '', key: '', keyMode: 'empty', url: '', img: null, sname: p.mode === 'add' ? '' : (p.f || {}).name, tpl: '%title% | %site%', ddesc: '', robots: 'Index', loc: '', def: false, ...p.f }, er = p.err || {}, add = p.mode === 'add'
|
||
const isDef = !add && SITES[p.i || 0].def
|
||
const prev = (f.url && !er.url ? f.url : 'https://journal.example.com') + '/how-we-price-a-bathroom-renovation'
|
||
return Panel({ title: add ? 'Add site' : 'Edit ' + f.name, notice: p.notice, saving: p.saving, confirm: p.saving ? 'Saving…' : add ? 'Add site' : 'Save changes' },
|
||
D('display:flex;flex-direction:column;gap:18px', F('Name', f.name || 'e.g. Journal', { val: !!f.name, focus: p.focus === 'name', err: er.name }),
|
||
Slug('Key', { mode: f.keyMode, value: f.key, err: er.key, suggest: er.keySuggest, locked: !add, lockHelp: 'Used in API paths, so it can\'t change.', help: 'Lowercase letters, numbers and hyphens. Used in API paths. It can\'t be changed after the site is added.', help2: 'Lowercase letters, numbers and hyphens. Used in API paths. It can\'t be changed after the site is added.' }),
|
||
F('Base URL', f.url || 'https://blog.example.com', { val: !!f.url, mono: true, focus: p.focus === 'url', err: er.url, help: er.url ? null : 'The public address of the frontend. Use https and no path.' }),
|
||
Pick(f.img, 'Default social image', 'Used when an article has no image of its own.'),
|
||
F('Site name', f.sname || 'e.g. Renew', { val: !!f.sname, focus: p.focus === 'sname', help: 'Shown in titles through the template, and sent as the Open Graph site name.' }),
|
||
D('display:flex;flex-direction:column;gap:6px', F('Title template', f.tpl, { mono: true, focus: p.focus === 'tpl', err: er.tpl, help: er.tpl ? null : 'Use %title% for the article title and %site% for the site name.' }), !er.tpl && D('display:flex;gap:6px;align-items:center;padding:8px 10px;background:var(--ui-bg-elevated);min-width:0', S('flex-shrink:0;font-size:12px;color:var(--ui-text-muted)', 'Example'), S('flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--ui-text-highlighted)', f.tpl.replace('%title%', 'How we price a bathroom renovation').replace('%site%', f.sname || 'Renew')))),
|
||
F('Default description', f.ddesc || 'One or two sentences about the site.', { opt: true, area: true, val: !!f.ddesc, count: [...f.ddesc].length + ' / 320', help: 'Used when an article has no description, excerpt or body text.' }),
|
||
D('display:flex;flex-direction:column;gap:6px', S('font-weight:500;color:var(--ui-text-highlighted)', 'Default robots policy'), Seg([['Index', 'Index'], ['Noindex', 'Noindex']], f.robots), S('color:var(--ui-text-muted);font-size:12px;line-height:18px', f.robots === 'Index' ? 'Search engines may list this site\'s pages.' : 'Asks search engines not to list this site\'s pages.')),
|
||
F('Locale', f.loc || 'e.g. en or fr-CH', { opt: true, val: !!f.loc, mono: true, err: er.loc, help: er.loc ? null : 'A language tag. Leave empty if the site has no single language.' }),
|
||
Sw(isDef || f.def, isDef ? 'Default site' : 'Make this the default site', isDef ? 'New articles start on the default site. To change it, make another site the default.' : 'New articles start here. The current default is Renew.', isDef)),
|
||
D('display:flex;flex-direction:column;gap:4px;padding:12px;background:var(--ui-bg-elevated)', S('font-size:12px;color:var(--ui-text-muted)', 'Canonical URL preview'), S('font:400 12px/18px \'IBM Plex Mono\',monospace;color:var(--ui-text-highlighted);word-break:break-all', prev), er.url && S('font-size:12px;color:var(--ui-text-muted)', 'Shown when the base URL is valid.')),
|
||
D('display:flex;flex-direction:column;gap:6px;padding:12px;background:var(--ui-bg-elevated)', S('font-size:12px;color:var(--ui-text-muted)', 'How articles use these defaults'), ...[['Title', 'Article title, then the template.'], ['Description', 'Used last, after the article\'s description, excerpt and body.'], ['Image', 'Used when an article has no cover image.'], ['Robots', 'Used unless an article is hidden from search engines.']].map(([a, b]) => D('display:flex;gap:8px', S('width:84px;flex-shrink:0;font-weight:500;color:var(--ui-text-highlighted)', a), S('color:var(--ui-text-toned);text-wrap:pretty', b)))))
|
||
}
|
||
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 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 })] }
|
||
const tip = 'The default site can\'t be deleted. Make another site the default first.'
|
||
const trs = rows.map((x, i) => {
|
||
const open = mi === i
|
||
const nameEl = D('display:flex;align-items:center;gap:8px;min-width:0', S('font-weight:500;color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', x.n), x.def && Bd('Default', 'def'), x.robots === 'Noindex' && Bd('Noindex', 'noidx'))
|
||
if (m) return D('position:relative;display:flex;align-items:center;gap:8px;padding:12px 4px 12px 16px;min-height:76px;border-bottom:1px solid var(--ui-border);background:' + (open ? 'var(--ui-bg-elevated)' : 'var(--ui-bg)'), D('flex:1;min-width:0;display:flex;flex-direction:column;gap:4px', nameEl, S('font:400 12px/18px \'IBM Plex Mono\',monospace;color:var(--ui-text-toned);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', host(x.url)), S('font-size:12px;color:var(--ui-text-muted)', pl(x.c, 'article'))), Dots())
|
||
return D('position:relative;display:grid;grid-template-columns:' + grid + ';gap:12px;min-height:52px;align-items:center;padding:0 12px;border-top:1px solid var(--ui-border);background:' + (open ? 'var(--ui-bg-elevated)' : 'var(--ui-bg)'), D('display:flex;flex-direction:column;gap:2px;min-width:0', nameEl, S('font:400 12px/16px \'IBM Plex Mono\',monospace;color:var(--ui-text-muted)', x.key)), S('font:400 12px/18px \'IBM Plex Mono\',monospace;color:var(--ui-text-toned);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', x.url), S('color:var(--ui-text-muted)', pl(x.c, 'article')), Dots(),
|
||
open && !m && MenuBox(items(i)), open && !m && rows[i].def && s.tip && D('position:absolute;right:240px;top:76px;z-index:6;max-width:240px;padding:6px 8px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font-size:12px;line-height:18px;box-shadow:var(--rc-shadow-pop)', tip))
|
||
})
|
||
if (mi != null && m) ov.push(MenuBox(items(mi), rows[mi].n, rows[mi].def ? tip : null))
|
||
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'), 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]
|
||
if (k === 'setdef') return Shell({ title: 'Make ' + tgt.n + ' the default site?', desc: 'New articles start on this site. Articles that are already on other sites stay where they are.', cancel: 'Cancel', confirm: x.loading ? 'Saving…' : 'Make default', loading: x.loading, close: true }, D('display:none'))
|
||
if (k === 'pick') return Picker({ title: 'Choose an image', desc: 'The default social image for ' + (x.n || 'this site') + '.', sel: x.sel })
|
||
const n = tgt.c
|
||
if (k === 'del0') return Shell({ title: 'Delete this site?', desc: '"' + tgt.n + '" will be deleted. No articles belong to it. This can\'t be undone.', cancel: 'Cancel', confirm: x.loading ? 'Deleting…' : 'Delete site', tone: 'danger', loading: x.loading }, x.notice ? Alert('error', 'circle-x', 'Couldn\'t delete the site, so it\'s still there. Try again in a moment.') : D('display:none'))
|
||
const to = SITES[0]
|
||
return Shell({ title: 'Delete this site?', desc: '"' + tgt.n + '" will be deleted. ' + pl(n, 'article') + ' belong to it, so choose a site to move them to. This can\'t be undone.', cancel: 'Cancel', confirm: x.loading ? 'Moving articles…' : 'Move ' + n + ' articles and delete', tone: 'danger', loading: x.loading, cdis: x.dis },
|
||
D('display:flex;flex-direction:column;gap:16px', x.notice && Alert('error', 'circle-x', 'Couldn\'t delete the site', 'No articles were moved. Try again in a moment.'),
|
||
F('Move articles to', to.n + ' · ' + host(to.url), { sel: true, focus: !!x.open, help: x.open ? null : pl(n, 'article') + ' will move to ' + to.n + '. Their canonical host changes to ' + host(to.url) + '.', dis: x.loading }),
|
||
x.open && Opts(SITES.filter(y => y.n !== tgt.n).map(y => [y.n, host(y.url), 0]), to.n)))
|
||
}
|
||
// ---------- Authors
|
||
const socials = { X: 'https://x.com/username', LinkedIn: 'https://www.linkedin.com/in/username', GitHub: 'https://github.com/username', Mastodon: 'https://mastodon.social/@username', Custom: 'https://example.com' }
|
||
const authorForm = () => {
|
||
const p = s.panel, f = { av: null, name: '', slug: '', slugMode: 'empty', bio: '', web: '', links: [], user: null, ...p.f }, er = p.err || {}, add = p.mode === 'add'
|
||
const bl = [...f.bio].length
|
||
const sl = f.links.map((l, i) => D('display:flex;gap:8px;align-items:flex-start', D('width:' + (m ? 112 : 108) + 'px;flex-shrink:0', F(null, l.t, { sel: true })), D('flex:1;min-width:0', F(null, l.v || socials[l.t], { val: !!l.v, mono: true, err: l.err, focus: l.focus })), S('width:' + (m ? 44 : 40) + 'px;height:' + ih + 'px;flex-shrink:0;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted);border:1px solid var(--ui-border-accented)', I('trash-2', 16))))
|
||
const linked = f.user && !f.unlinkAsk
|
||
const ownUser = D('display:flex;flex-direction:column;gap:6px', S('font-weight:500;color:var(--ui-text-highlighted)', 'Linked user'), D('display:flex;align-items:center;gap:10px;min-height:' + ih + 'px;padding:0 10px;border:1px solid var(--ui-border);background:var(--ui-bg-elevated)', I('link', 14, 'color:var(--ui-info)'), S('flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--ui-text-highlighted)', f.user), I('lock', 14, 'color:var(--ui-text-muted)')), S('color:var(--ui-text-muted);font-size:12px;line-height:18px', 'This is your author profile. Only an owner or admin can change the link.'))
|
||
const userF = p.own ? ownUser : f.user ? D('display:flex;flex-direction:column;gap:6px', S('font-weight:500;color:var(--ui-text-highlighted)', 'Linked user', S('font-weight:400;color:var(--ui-text-muted)', ' (optional)')), D('display:flex;align-items:center;gap:10px;min-height:' + ih + 'px;padding:0 6px 0 10px;border:1px solid var(--ui-border-accented)', I('link', 14, 'color:var(--ui-info)'), S('flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--ui-text-highlighted)', f.user), B('Unlink', { h: m ? 44 : 28, fs: 12, px: 10 })), S('color:var(--ui-text-muted);font-size:12px;line-height:18px', 'New articles by this user start with this author.')) :
|
||
D('display:flex;flex-direction:column;gap:6px', F('Linked user', f.uopen ? 'Choose a user' : 'No linked user', { opt: true, val: false, sel: true, focus: !!f.uopen, help: f.uopen ? null : 'Link a CMS user if this author has an account. Guest writers need no link.' }), f.uopen && (f.noUsers ? D('padding:12px;border:1px solid var(--ui-border);background:var(--ui-bg);color:var(--ui-text-muted)', 'Every user already has an author.') : Opts([['Marta Alves', 'marta@renew.example', 0, Av({ name: 'Marta Alves' }, 24)], ['Rui Santos', 'rui@renew.example', 0, Av({ name: 'Rui Santos' }, 24)]], null, S('display:block;padding:4px 8px 6px;font-size:12px;color:var(--ui-text-muted)', 'Users without an author'))))
|
||
const pv = D('display:flex;gap:12px;padding:12px;background:var(--ui-bg-elevated);align-items:flex-start', Av({ name: f.name || 'Author', img: !!f.av }, 40), D('flex:1;min-width:0;display:flex;flex-direction:column;gap:2px', S('font-size:12px;color:var(--ui-text-muted)', 'Byline preview'), S('font-weight:600;color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', f.name || 'Author name'), S('color:var(--ui-text-toned);font-size:12px;line-height:18px;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical', f.bio || 'The bio appears here.')))
|
||
return Panel({ title: add ? 'Add author' : 'Edit ' + f.name, notice: p.notice, saving: p.saving, confirm: p.saving ? 'Saving…' : add ? 'Add author' : 'Save changes' },
|
||
D('display:flex;flex-direction:column;gap:18px', Pick(f.av, 'Avatar', null, { av: { name: f.name || 'Author', img: !!f.av } }),
|
||
F('Name', f.name || 'e.g. Camille Roche', { val: !!f.name, focus: p.focus === 'name', err: er.name }),
|
||
Slug('Slug', { mode: f.slugMode, value: f.slug, err: er.slug, suggest: er.suggest, help: 'Generated from the name. Used in public author URLs and API paths. Unique across all authors.', help2: 'Lowercase letters, numbers and hyphens. Unique across all authors.' }),
|
||
F('Bio', f.bio || 'A sentence or two about the author.', { opt: true, area: true, val: !!f.bio, count: bl + ' / 280', countErr: bl > 280, err: er.bio, focus: p.focus === 'bio' }),
|
||
F('Website', f.web || 'https://example.com', { opt: true, val: !!f.web, mono: true, err: er.web }),
|
||
D('display:flex;flex-direction:column;gap:8px', S('font-weight:500;color:var(--ui-text-highlighted)', 'Social links', S('font-weight:400;color:var(--ui-text-muted)', ' (optional)')), ...sl, D('display:flex;align-items:center;gap:12px', f.links.length < 5 ? B('Add link', { icon: 'plus', h: m ? 44 : 28, fs: 12, px: 10 }) : S('color:var(--ui-text-muted);font-size:12px', 'You can add up to 5 links.'), S('color:var(--ui-text-muted);font-size:12px', f.links.length + ' of 5'))),
|
||
userF), pv)
|
||
}
|
||
const authorView = () => {
|
||
const a = AUTH[s.panel.i ?? 0]
|
||
const lab = (t, v) => D('display:flex;flex-direction:column;gap:4px', S('font-weight:500;color:var(--ui-text-highlighted)', t), v)
|
||
return Panel({ title: a.name, ro: true }, D('display:flex;flex-direction:column;gap:18px',
|
||
D('display:flex;gap:14px;align-items:center', Av(a, 64), D('flex:1;min-width:0;display:flex;flex-direction:column;gap:4px', S('font:400 12px/18px \'IBM Plex Mono\',monospace;color:var(--ui-text-muted)', a.slug), D('display:flex;align-items:center;gap:8px;font-size:12px;color:var(--ui-text-muted)', a.user ? Bd('Linked', 'linked') : Bd('Guest', 'guest'), pl(a.c, 'article')))),
|
||
a.bio && lab('Bio', S('color:var(--ui-text-toned);text-wrap:pretty', a.bio)),
|
||
a.web && lab('Website', S('color:var(--ui-text-highlighted);text-decoration:underline;text-underline-offset:3px;' + mono, a.web)),
|
||
D('display:flex;gap:10px;padding:12px;background:var(--ui-bg-elevated);color:var(--ui-text-toned)', I('lock', 14, 'margin-top:3px'), 'Only an owner or admin can change this author.')))
|
||
}
|
||
const authorsPage = () => {
|
||
const st = s.list || 'ok'
|
||
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')
|
||
else {
|
||
const mi = s.menu
|
||
const items = [menuItem('pencil', 'Edit', { hv: !m }), sep, menuItem('trash-2', 'Delete…', { err: true })]
|
||
if (mi != null && m) ov.push(MenuBox(items, rows[mi].name))
|
||
const acct = a => a.user ? Bd('Linked', 'linked') : Bd('Guest', 'guest')
|
||
const trs = rows.map((a, i) => {
|
||
const open = mi === i
|
||
if (m) return D('position:relative;display:flex;align-items:center;gap:12px;padding:12px 4px 12px 16px;min-height:76px;border-bottom:1px solid var(--ui-border);background:' + (open ? 'var(--ui-bg-elevated)' : 'var(--ui-bg)'), Av(a, 40), D('flex:1;min-width:0;display:flex;flex-direction:column;gap:3px', S('font-weight:500;font-size:15px;line-height:21px;color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', a.name), S('font:400 12px/16px \'IBM Plex Mono\',monospace;color:var(--ui-text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', a.slug), D('display:flex;align-items:center;gap:8px;font-size:12px;color:var(--ui-text-muted)', acct(a), pl(a.c, 'article'))), Dots())
|
||
return D('position:relative;display:grid;grid-template-columns:' + grid + ';gap:12px;min-height:52px;align-items:center;padding:0 12px;border-top:1px solid var(--ui-border);background:' + (open ? 'var(--ui-bg-elevated)' : 'var(--ui-bg)'), D('display:flex;align-items:center;gap:10px;min-width:0', Av(a, 32), S('font-weight:500;color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', a.name)), S('font:400 12px/18px \'IBM Plex Mono\',monospace;color:var(--ui-text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', a.slug), D('display:flex', acct(a)), S('color:var(--ui-text-muted)', pl(a.c, 'article')), Dots(), open && MenuBox(items))
|
||
})
|
||
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(), 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]
|
||
if (k === 'pick') return Picker({ title: 'Choose an image', desc: 'The avatar for ' + (x.n || 'this author') + '.', sel: x.sel, sq: true })
|
||
if (k === 'unlink') return Shell({ title: 'Unlink ' + a.name + '?', desc: a.name + ' will stay on their articles. New articles by ' + a.user + ' will no longer start with this author.', cancel: 'Cancel', confirm: x.loading ? 'Unlinking…' : 'Unlink user', loading: x.loading, close: true }, D('display:none'))
|
||
if (k === 'relink') return Shell({ title: 'Link ' + a.name + ' to a user?', desc: 'Only users without an author are listed.', cancel: 'Cancel', confirm: 'Link user', cdis: true, close: true }, D('display:flex;flex-direction:column;gap:12px', F('Linked user', 'Choose a user', { sel: true, val: false, focus: true }), Opts([['Marta Alves', 'marta@renew.example', 0, Av({ name: 'Marta Alves' }, 24)], [ 'Rui Santos', 'rui@renew.example', 0, Av({ name: 'Rui Santos' }, 24)]])))
|
||
const n = a.c
|
||
if (k === 'del0') return Shell({ title: 'Delete this author?', desc: '"' + a.name + '" will be deleted. No articles credit this author. This can\'t be undone.', cancel: 'Cancel', confirm: x.loading ? 'Deleting…' : 'Delete author', tone: 'danger', loading: x.loading }, D('display:none'))
|
||
if (k === 'blocked') return Shell({ title: 'Can\'t delete this author', desc: '"' + a.name + '" is credited on ' + pl(n, 'article') + '. Choose another author to credit instead, or remove ' + a.name + ' from those articles first.', cancel: 'Close', confirm: 'Reassign articles', close: true }, a.user ? Alert('info', 'info', 'Linked to ' + a.user, 'Deleting the author doesn\'t affect the user account.') : D('display:none'))
|
||
const to = AUTH[0]
|
||
return Shell({ title: 'Delete this author?', desc: '"' + a.name + '" will be deleted and is credited on ' + pl(n, 'article') + '. This can\'t be undone.', cancel: 'Cancel', confirm: x.loading ? 'Reassigning…' : 'Delete and reassign ' + n + ' articles', tone: 'danger', loading: x.loading, close: !x.loading },
|
||
D('display:flex;flex-direction:column;gap:16px', x.notice && Alert('error', 'circle-x', 'Couldn\'t delete the author', 'Nothing was changed. Try again in a moment.'),
|
||
F('Credit these articles to', x.none ? 'Nobody' : to.name, { sel: true, focus: !!x.open, dis: x.loading, help: x.open ? null : x.none ? 'The articles keep any other authors. Articles with only this author will have no byline.' : 'Where an article has several authors, ' + a.name + ' is removed and the others stay.' }),
|
||
x.open && Opts([...AUTH.filter(y => y.name !== a.name).slice(0, 4).map(y => [y.name, y.slug, 0, Av(y, 24)]), ['Nobody', 'Remove the byline', 0, S('width:24px;height:24px;display:flex;align-items:center;justify-content:center;border:1px dashed var(--ui-border-accented);color:var(--ui-text-muted)', I('user-x', 14))]], x.none ? 'Nobody' : to.name),
|
||
a.user && S('color:var(--ui-text-muted);font-size:12px', 'Linked to ' + a.user + '. The user account isn\'t affected.')))
|
||
}
|
||
// ---------- Editor: site and authors
|
||
const metaUI = () => {
|
||
const mt = s.meta || {}, au = (mt.authors || []).map(AV), n = au.length, st = mt.state || 'ok'
|
||
const out = []
|
||
if (mt.hint) out.push(D('display:flex;gap:10px;padding:12px;background:var(--rc-info-soft);color:var(--ui-info)', S('display:flex;padding-top:2px', I('info')), D('flex:1;min-width:0;display:flex;flex-direction:column;gap:8px', D('display:flex;flex-direction:column;gap:2px', S('font-weight:600', 'You don\'t have an author profile yet'), S('color:var(--ui-text-toned)', 'Create one to be credited on your articles.')), B('Create my author profile', { icon: 'feather', h: m ? 44 : 28, fs: 12, px: 10 })), S('width:' + (m ? 44 : 24) + 'px;height:' + (m ? 44 : 24) + 'px;margin:' + (m ? '-10px -10px 0 0' : '-4px -4px 0 0') + ';display:flex;align-items:center;justify-content:center;color:var(--ui-text-toned)', I('x', 14))))
|
||
if (mt.sites) out.push(D('display:flex;flex-direction:column;gap:6px', F('Site', (mt.site || 'Journal') + ' · ' + host(SITES.find(x => x.n === (mt.site || 'Journal')).url), { sel: true, focus: mt.open === 'site' }), mt.open === 'site' ? Opts(SITES.map(x => [x.n, host(x.url), 0]), mt.site || 'Journal') : D('display:flex;align-items:center;gap:6px;color:var(--ui-text-muted);font-size:12px;line-height:18px', I('link', 12), S('', 'Canonical host: ', S('color:var(--ui-text-toned);' + mono, host(SITES.find(x => x.n === (mt.site || 'Journal')).url))))))
|
||
const chip = (a, i) => {
|
||
const grab = mt.grab === i, lift = mt.drag === i
|
||
return D('display:flex;align-items:center;gap:' + (m ? 8 : 6) + 'px;min-height:' + (m ? 48 : 32) + 'px;padding:0 ' + (m ? 4 : 2) + 'px 0 2px;border:1px solid ' + (grab || lift ? 'var(--ui-primary)' : 'var(--ui-border-accented)') + ';background:' + (lift ? 'var(--ui-bg)' : 'var(--ui-bg)') + ';' + (grab ? 'outline:2px solid var(--ui-primary);outline-offset:1px;' : '') + (lift ? 'box-shadow:var(--rc-shadow-pop);position:relative;z-index:2;transform:translateX(4px);' : ''),
|
||
S('width:' + (m ? 36 : 24) + 'px;height:' + (m ? 36 : 24) + 'px;flex-shrink:0;display:flex;align-items:center;justify-content:center;color:' + (grab || lift ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)'), I('grip-vertical', 14)), Av(a, m ? 28 : 22),
|
||
S('flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500;color:var(--ui-text-highlighted)', a.name),
|
||
i === 0 && n > 1 && Bd('Primary', 'prim', 18),
|
||
S('width:' + (m ? 44 : 24) + 'px;height:' + (m ? 44 : 24) + 'px;flex-shrink:0;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)', I('x', 14)))
|
||
}
|
||
const list = []
|
||
au.forEach((a, i) => { if (mt.drop === i) list.push(D('height:2px;background:var(--ui-primary);margin:-1px 0')); list.push(chip(a, i)) })
|
||
const body = [S('font-weight:500;color:var(--ui-text-highlighted)', 'Authors')]
|
||
if (!n) body.push(D('padding:12px;border:1px dashed var(--ui-border-accented);color:var(--ui-text-muted);text-wrap:pretty', 'No authors. This article will have no byline.'))
|
||
else body.push(D('display:flex;flex-direction:column;gap:4px', ...list))
|
||
const trig = D('display:flex;align-items:center;gap:8px;min-height:' + ih + 'px;padding:0 10px;border:1px solid ' + (mt.open === 'authors' ? 'var(--ui-primary)' : 'var(--ui-border-accented)') + ';' + (mt.open === 'authors' ? 'box-shadow:inset 0 0 0 1px var(--ui-primary);' : '') + 'background:var(--ui-bg);color:var(--ui-text-muted);font-size:' + fs + 'px', I('plus', 14), S('flex:1;color:' + (mt.q ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)'), mt.open === 'authors' ? (mt.q || 'Search authors') : 'Add an author'), I('chevron-down', 14))
|
||
body.push(trig)
|
||
if (mt.open === 'authors') {
|
||
const chosen = au.map(a => a.name), q2 = mt.q
|
||
let opts = AUTH.filter(a => !q2 || a.name.toLowerCase().includes(q2.toLowerCase()))
|
||
const create = D('min-height:' + (m ? 48 : 36) + 'px;padding:4px 8px;display:flex;align-items:center;gap:8px;color:var(--ui-text-highlighted);font-weight:500;border-top:1px solid var(--ui-border)', I('plus', 16), q2 ? 'Create author "' + q2 + '"…' : 'Create author…')
|
||
let inner
|
||
if (st === 'loading') inner = [D('display:flex;flex-direction:column;gap:10px;padding:10px 8px', ...[0, 1, 2].map(() => D('display:flex;gap:8px;align-items:center', S('display:block;width:24px;height:24px;background:var(--ui-bg-accented)'), S('display:block;width:50%;height:12px;background:var(--ui-bg-accented)'))))]
|
||
else if (st === 'error') inner = [D('padding:10px 8px;display:flex;flex-direction:column;gap:8px', S('color:var(--ui-error);font-weight:500', 'Couldn\'t load authors'), S('color:var(--ui-text-muted)', 'Check your connection and try again.'), B('Try again', { icon: 'rotate-cw', z: 14, h: m ? 44 : 28, fs: 12, px: 10 }))]
|
||
else if (!opts.length) inner = [D('padding:10px 8px;color:var(--ui-text-muted)', 'No author matches "' + q2 + '".')]
|
||
else inner = opts.map(a => D('min-height:' + (m ? 48 : 36) + 'px;padding:4px 8px;display:flex;align-items:center;gap:8px;color:var(--ui-text-highlighted);opacity:' + (chosen.includes(a.name) ? 0.6 : 1), Av(a, 24), D('flex:1;min-width:0;display:flex;flex-direction:column', S('overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500', a.name), S('font-size:12px;color:var(--ui-text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;' + mono, a.slug)), chosen.includes(a.name) && I('check', 14)))
|
||
body.push(D('border:1px solid var(--ui-border);background:var(--ui-bg);box-shadow:var(--rc-shadow-pop);' + (m ? '' : ''), D('padding:4px', ...inner), st === 'error' || mt.noCreate ? null : create))
|
||
}
|
||
if (mt.grab != null) body.push(S('font-size:12px;line-height:18px;color:var(--ui-text-toned);padding:6px 8px;background:var(--ui-bg-elevated)', au[mt.grab].name + ' grabbed. Position ' + (mt.grab + 1) + ' of ' + n + '. Use the up and down arrow keys to move, Space to drop, Escape to cancel.'))
|
||
else if (n > 1 && mt.open !== 'authors') body.push(S('font-size:12px;line-height:18px;color:var(--ui-text-muted)', 'Drag to reorder. The first author is the primary byline.'))
|
||
out.push(D('display:flex;flex-direction:column;gap:8px', ...body))
|
||
return D('display:flex;flex-direction:column;gap:16px', ...out)
|
||
}
|
||
const newAuthorModal = () => {
|
||
const x = s.modal
|
||
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 }),
|
||
x.me && D('display:flex;gap:10px;align-items:flex-start', S('width:20px;height:20px;flex-shrink:0;margin-top:1px;display:flex;align-items:center;justify-content:center;border:1px solid var(--ui-primary);background:var(--ui-primary);color:var(--ui-bg)', I('check', 14)), D('display:flex;flex-direction:column', S('font-weight:500;color:var(--ui-text-highlighted)', 'This is me'), S('color:var(--ui-text-muted);font-size:12px', 'Links the author to marta@renew.example.')))))
|
||
}
|
||
// ---------- Editor: search and sharing
|
||
const ATT = 'How we price a bathroom renovation', ASLUG = 'how-we-price-a-bathroom-renovation', AD1 = 'A bathroom renovation is priced by room size, plumbing changes and finish level.'
|
||
const SUG = {
|
||
title_long: ['The meta title is 74 characters. Search engines may cut it off after about 60.', 'meta title'],
|
||
title_empty: ['No meta title yet. The article title is used instead.', 'meta title'],
|
||
desc_short: ['The description is 41 characters. About 70 to 160 reads best in results.', 'meta description'],
|
||
desc_long: ['The description is 212 characters. Search engines may cut it off after about 160.', 'meta description'],
|
||
cover_alt: ['The cover image has no alt text. Add a short description of it.', 'cover image'],
|
||
cover_none: ['No cover image. The site\'s social image is used.', 'cover image'],
|
||
slug_long: ['The slug is 86 characters. Shorter addresses are easier to share.', 'URL slug'],
|
||
no_au: ['This article has no authors, so the structured data has no author.', 'authors'],
|
||
no_h: ['The body has no headings. H2 headings help readers scan the article.', 'body'],
|
||
body_alt: ['2 images in the body have no alt text.', 'body'],
|
||
short: ['The article is under 300 words. That is fine for a short note.', 'body']
|
||
}
|
||
const Chip = t => S('height:20px;padding:0 6px;align-self:flex-start;display:inline-flex;align-items:center;gap:4px;border:1px dashed var(--ui-border-accented);font:500 11px/16px \'IBM Plex Mono\',monospace;color:var(--ui-text-toned);white-space:nowrap', I('corner-down-right', 10), t)
|
||
const Ct = (n, lo, hi) => S('font:400 12px \'IBM Plex Mono\',monospace;color:' + (n && (n < lo || n > hi) ? 'var(--ui-warning)' : 'var(--ui-text-muted)'), n + ' / ' + hi)
|
||
const FW = (lab, val, o = {}) => D('display:flex;flex-direction:column;gap:6px;min-width:0', D('display:flex;justify-content:space-between;align-items:center;gap:8px', S('font-weight:500;color:var(--ui-text-highlighted)', lab, !o.req && S('font-weight:400;color:var(--ui-text-muted)', ' (optional)')), o.ct), F(null, val, o), o.fb && Chip(o.fb))
|
||
const Dis = (t, o = {}) => D('display:flex;flex-direction:column;gap:12px;padding:12px;border:1px solid var(--ui-border)', D('display:flex;align-items:center;gap:8px;min-height:' + (m ? 44 : 24) + 'px;color:var(--ui-text-highlighted)', I(o.open ? 'chevron-down' : 'chevron-right', 14), S('font-weight:600', t), o.tag && Note(o.tag), S('flex:1;text-align:right;font-size:12px;color:var(--ui-text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', o.sum)), ...(o.open ? o.kids : []))
|
||
const SugRow = (c, o = {}) => D('display:flex;align-items:flex-start;gap:8px', S('display:flex;padding-top:2px;color:var(--ui-warning)', I('lightbulb', 14)), D('flex:1;min-width:0;display:flex;flex-direction:column;gap:2px', S('text-wrap:pretty;color:var(--ui-text-highlighted)', SUG[c][0]), Link('Go to ' + SUG[c][1], 'arrow-right')), !o.compact && S('width:' + (m ? 44 : 24) + 'px;height:' + (m ? 44 : 24) + 'px;flex-shrink:0;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)', I('x', 14)))
|
||
const Sugs = (codes, st, o = {}) => {
|
||
const box = (...c) => D('display:flex;flex-direction:column;gap:12px;padding:12px;background:var(--ui-bg-elevated)', ...c)
|
||
if (st === 'loading') return box(D('display:flex;align-items:center;gap:8px;color:var(--ui-text-toned)', I('loader-circle', 14), 'Checking this article…'), ...[70, 90, 55].map(w => S('display:block;height:12px;width:' + w + '%;background:var(--ui-bg-accented)')))
|
||
if (!codes.length) return box(D('display:flex;align-items:flex-start;gap:8px', S('display:flex;padding-top:2px;color:var(--ui-success)', I('circle-check', 14)), D('display:flex;flex-direction:column;gap:2px', S('font-weight:600;color:var(--ui-text-highlighted)', 'All good'), S('color:var(--ui-text-muted)', o.dismissed ? o.dismissed + ' dismissed suggestions are hidden.' : 'Nothing to suggest for this article.')), o.dismissed && Link('Show dismissed')))
|
||
const shown = o.compact ? codes.slice(0, 3) : codes
|
||
return box(...shown.map(c => SugRow(c, o)), o.compact && codes.length > 3 && S('color:var(--ui-text-muted);font-size:12px', '+' + (codes.length - 3) + ' more in Search and sharing'), !o.compact && S('color:var(--ui-text-muted);font-size:12px;line-height:18px;text-wrap:pretty', 'Suggestions never block saving or publishing. Dismiss one and it stays hidden for this article.' ))
|
||
}
|
||
const SEOSIDE = (k) => {
|
||
const codes = k.codes || [], st = k.state || 'ok'
|
||
return D('display:flex;flex-direction:column;gap:12px',
|
||
D('display:flex;flex-direction:column;gap:4px;padding:8px;background:var(--ui-bg-elevated)', S('font-size:12px;color:var(--ui-text-muted)', 'Address'), S('font:400 12px/18px \'IBM Plex Mono\',monospace;color:var(--ui-text-highlighted);word-break:break-all', host(SITES[k.site || 0].url) + '/' + (k.slug || ASLUG))),
|
||
k.hidden && D('display:flex;gap:8px;color:var(--ui-warning)', S('display:flex;padding-top:2px', I('eye-off', 14)), S('color:var(--ui-text-toned);text-wrap:pretty', 'Hidden from search engines. This only sets a flag in the API.')),
|
||
Sugs(codes, st, { compact: true, dismissed: k.dismissed }),
|
||
D('display:flex;flex-direction:column;gap:8px', B('Edit search and sharing', { icon: 'search', h: m ? 44 : 32 }), B('What the frontend gets', { icon: 'braces', h: m ? 44 : 32 })))
|
||
}
|
||
const trunc = (t, n) => [...t].length > n ? [...t].slice(0, n - 1).join('').trimEnd() + '…' : t
|
||
const SERP = (p, site) => {
|
||
const dev = p.dev || 'desktop', tt = trunc((p.mt || ATT) + ' | ' + site.n, dev === 'desktop' ? 60 : 70)
|
||
const dd = p.md || p.ex || AD1, dt = trunc(dd, dev === 'desktop' ? 160 : 120)
|
||
const fb = [!p.mt && 'Using article title', !p.md && p.ex && 'Using excerpt', !p.md && !p.ex && 'Using first sentence of the body'].filter(Boolean)
|
||
return D('display:flex;flex-direction:column;gap:8px', D('display:flex;justify-content:space-between;align-items:center;gap:8px', S('font-weight:600;color:var(--ui-text-highlighted)', 'Search result'), D('display:flex;border:1px solid var(--ui-border-accented);width:150px', ...[['desktop', 'Desktop'], ['mobile', 'Mobile']].map(([k, t]) => S('flex:1;height:' + (m ? 44 : 24) + 'px;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:' + (dev === k ? 500 : 400) + ';background:' + (dev === k ? 'var(--rc-sand)' : 'var(--ui-bg)') + ';color:' + (dev === k ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)'), t)))),
|
||
D('padding:14px;border:1px solid var(--ui-border);background:var(--ui-bg);display:flex;flex-direction:column;gap:4px;' + (dev === 'mobile' && !m ? 'width:280px;' : ''), D('display:flex;align-items:center;gap:8px;min-width:0', S('width:22px;height:22px;flex-shrink:0;display:flex;align-items:center;justify-content:center;border-radius:50%;background:var(--ui-bg-elevated);font:600 11px/1 \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', site.n[0]), D('display:flex;flex-direction:column;min-width:0', S('font-size:12px;line-height:16px;color:var(--ui-text-highlighted)', site.n), S('font-size:11px;line-height:14px;color:var(--ui-text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', host(site.url) + ' › ' + (p.slug || ASLUG).slice(0, 36)))), S('font:400 ' + (dev === 'desktop' ? 18 : 16) + 'px/1.3 \'IBM Plex Sans\',sans-serif;color:var(--ui-info);margin-top:4px;word-break:break-word', tt), S('font-size:13px;line-height:19px;color:var(--ui-text-toned);word-break:break-word', dt)),
|
||
fb.length ? D('display:flex;gap:6px;flex-wrap:wrap', ...fb.map(Chip)) : null, (p.mt || ATT).length + site.n.length + 3 > (dev === 'desktop' ? 60 : 70) ? S('font-size:12px;color:var(--ui-text-muted)', 'Cut off here, the way a search engine would.') : null)
|
||
}
|
||
const SOC = (p, site) => {
|
||
const sx = p.soc || {}, hasImg = !!(sx.img || p.cover), t = sx.t || p.mt || ATT, d = sx.d || p.md || p.ex || AD1
|
||
return D('display:flex;flex-direction:column;gap:8px', S('font-weight:600;color:var(--ui-text-highlighted)', 'Social card'),
|
||
D('border:1px solid var(--ui-border);background:var(--ui-bg)', S('display:flex;align-items:center;justify-content:center;aspect-ratio:1.91/1;background:repeating-linear-gradient(135deg, var(--ui-border) 0 1px, transparent 1px 12px), var(--rc-sand);color:var(--ui-text-muted);font:400 11px \'IBM Plex Mono\',monospace', hasImg ? (sx.img || 'north-wall.jpg') : (site.img || 'site social image')), D('padding:10px 12px;display:flex;flex-direction:column;gap:2px;border-top:1px solid var(--ui-border)', S('font-size:11px;line-height:16px;text-transform:uppercase;color:var(--ui-text-muted)', host(site.url)), S('font-weight:600;color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', t), S('font-size:12px;line-height:18px;color:var(--ui-text-toned);overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical', d))),
|
||
D('display:flex;gap:6px;flex-wrap:wrap', ...[!sx.t && 'Using meta title', !sx.d && 'Using meta description', !sx.img && (p.cover ? 'Using cover image' : 'Using site social image')].filter(Boolean).map(Chip)))
|
||
}
|
||
const seoPanel = () => {
|
||
const p = s.seo.panel, site = SITES[p.site || 0], dis = p.state === 'saving', er = p.err || {}
|
||
const sl = p.slug || ASLUG, sm = p.slugMode || 'auto'
|
||
const cv = p.cover, soc = p.soc || {}
|
||
const left = D('display:flex;flex-direction:column;gap:18px;margin-top:' + (p.scrollY ? -p.scrollY + 'px' : '0'),
|
||
D('display:flex;flex-direction:column;gap:8px', Slug('URL slug', { mode: sm === 'taken' ? 'manual' : sm, value: sl, err: sm === 'taken' ? 'The slug "' + sl + '" is already used on ' + site.n + '.' : er.slug, suggest: sm === 'taken' ? sl + '-2' : null, reset: 'Reset to match the title', emptyText: 'Generated from the title', help: 'Generated from the title until you edit it. Lowercase letters, numbers and hyphens. Unique on ' + site.n + '.', help2: 'Lowercase letters, numbers and hyphens. Unique on ' + site.n + '.' }), D('display:flex;gap:6px;align-items:center;padding:8px 10px;background:var(--ui-bg-elevated);min-width:0', I('link', 12, 'color:var(--ui-text-muted)'), S('font:400 12px/18px \'IBM Plex Mono\',monospace;color:var(--ui-text-highlighted);word-break:break-all', site.url + '/' + sl))),
|
||
FW('Meta title', p.mt || ATT, { val: !!p.mt, ct: Ct([...(p.mt || '')].length, 30, 60), focus: p.focus === 'mt', dis, fb: p.mt ? null : 'Using article title', help: 'Recommended 30 to 60 characters. This is a guide, not a limit.' }),
|
||
FW('Meta description', p.md || p.ex || AD1, { val: !!p.md, area: true, ct: Ct([...(p.md || '')].length, 70, 160), focus: p.focus === 'md', dis, fb: p.md ? null : p.ex ? 'Using excerpt' : 'Using first sentence of the body', help: 'Recommended 70 to 160 characters.' }),
|
||
FW('Excerpt', p.ex || 'A short summary. It is also sent as the article teaser.', { val: !!p.ex, area: true, ct: S('font:400 12px \'IBM Plex Mono\',monospace;color:' + (p.exErr ? 'var(--ui-error)' : 'var(--ui-text-muted)'), [...(p.ex || '')].length + ' / 300'), err: p.exErr, dis, help: p.exErr ? null : 'Used as the description when there is none, and as the teaser for the frontend.' }),
|
||
D('display:flex;flex-direction:column;gap:10px', Pick(cv ? 'north-wall.jpg' : null, 'Cover image', cv ? null : 'Using the site social image' + (site.img ? ' (' + site.img + ')' : '') + '.'), cv && FW('Alt text', cv.alt || 'Describe the image for people who can\'t see it', { req: true, val: !!cv.alt, dis, help: cv.alt ? null : 'Needed before the cover counts as ready. Sent as image alt in the API.' })),
|
||
Dis('Social sharing', { open: p.social, tag: 'Optional', sum: p.social ? '' : (soc.t || soc.d || soc.img) ? 'Customised' : 'Uses the meta values', kids: [FW('Social title', soc.t || p.mt || ATT, { val: !!soc.t, ct: Ct([...(soc.t || '')].length, 0, 90), dis, fb: soc.t ? null : 'Using meta title' }), FW('Social description', soc.d || p.md || p.ex || AD1, { val: !!soc.d, area: true, dis, fb: soc.d ? null : 'Using meta description' }), Pick(soc.img || null, 'Social image', soc.img ? null : 'Using the cover image.')] }),
|
||
Dis('Advanced', { open: p.adv, sum: p.adv ? '' : p.canon ? 'Canonical URL set' : 'Canonical URL: automatic', kids: [FW('Canonical URL', p.canon || site.url + '/' + sl, { val: !!p.canon, mono: true, dis, err: er.canon, focus: p.focus === 'canon', fb: p.canon ? null : 'Using site address and slug', help: er.canon ? null : 'Only change this if the same content lives at another address.' })] }),
|
||
Sw(!!p.noindex, 'Hide from search engines', 'Sets noindex in the API. It doesn\'t change who can read the article.', dis))
|
||
const right = D('display:flex;flex-direction:column;gap:18px;min-width:0', SERP(p, site), SOC(p, site), D('display:flex;flex-direction:column;gap:8px', S('font-weight:600;color:var(--ui-text-highlighted)', 'Suggestions'), Sugs(p.codes || [], p.sugState, { dismissed: p.dismissed })), D('display:flex;gap:8px;align-items:center', B('What the frontend gets', { icon: 'braces', h: m ? 44 : 28, fs: 12, px: 10 })))
|
||
const body = m ? D('display:flex;flex-direction:column;gap:18px', Sugs(p.codes || [], p.sugState, { dismissed: p.dismissed }), left, SERP(p, site), SOC(p, site), B('What the frontend gets', { icon: 'braces' })) : D('display:grid;grid-template-columns:minmax(0,1fr) 340px;gap:28px;height:100%;overflow:hidden', D('overflow:hidden;min-height:0', left), right)
|
||
return Panel({ title: 'Search and sharing', w: 960, z: 60, notice: p.notice, saving: dis, cdis: p.noChange, confirm: dis ? 'Saving…' : 'Save changes' }, body)
|
||
}
|
||
const API = { title: ATT + ' | Renew', description: AD1, canonical_url: 'https://renew.example/' + ASLUG, robots: 'index, follow', og: { type: 'article', title: ATT + ' | Renew', description: AD1, url: 'https://renew.example/' + ASLUG, site_name: 'Renew', image: 'https://renew.example/media/north-wall.jpg', image_alt: 'A tiled bathroom wall during renovation', locale: 'en' }, twitter: { card: 'summary_large_image', title: ATT + ' | Renew', description: AD1, image: 'https://renew.example/media/north-wall.jpg', image_alt: 'A tiled bathroom wall during renovation' }, image: { url: 'https://renew.example/media/north-wall.jpg', alt: 'A tiled bathroom wall during renovation', source: 'cover_image' }, authors: [{ name: 'Inès Duarte', url: 'https://ines-duarte.example', same_as: ['https://www.linkedin.com/in/ines-duarte'] }], published_at: '2026-03-12T09:14:00Z', modified_at: '2026-10-15T11:02:00Z', fallbacks: { title: 'article_title', description: 'body_first_sentence', image: 'cover_image', canonical: 'site_base_url_and_slug' }, json_ld: { '@context': 'https://schema.org', '@type': 'Article', headline: ATT, description: AD1, image: ['https://renew.example/media/north-wall.jpg'], author: [{ '@type': 'Person', name: 'Inès Duarte', url: 'https://ines-duarte.example', sameAs: ['https://www.linkedin.com/in/ines-duarte'] }], datePublished: '2026-03-12T09:14:00Z', dateModified: '2026-10-15T11:02:00Z', mainEntityOfPage: 'https://renew.example/' + ASLUG } }
|
||
const apiDialog = () => {
|
||
const x = s.seo.api, raw = x.view === 'raw', st = x.state || 'ok'
|
||
const bar = D('display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap', D('display:flex;border:1px solid var(--ui-border-accented);width:200px', ...[['formatted', 'Formatted'], ['raw', 'Raw JSON']].map(([k, t]) => S('flex:1;height:' + bh + 'px;display:flex;align-items:center;justify-content:center;font-weight:' + ((raw ? 'raw' : 'formatted') === k ? 500 : 400) + ';background:' + ((raw ? 'raw' : 'formatted') === k ? 'var(--rc-sand)' : 'var(--ui-bg)') + ';color:' + ((raw ? 'raw' : 'formatted') === k ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)'), t))), B(x.copied ? 'Copied' : 'Copy JSON', { icon: x.copied ? 'check' : 'copy', dis: st !== 'ok' }))
|
||
const KV = (k, v, fbk) => D('display:grid;grid-template-columns:' + (m ? '1fr' : '130px minmax(0,1fr)') + ';gap:' + (m ? 2 : 12) + 'px;padding:6px 0;border-bottom:1px solid var(--ui-border-muted)', S('color:var(--ui-text-muted);font:400 12px/20px \'IBM Plex Mono\',monospace', k), D('display:flex;gap:8px;flex-wrap:wrap;align-items:center;min-width:0', S('color:var(--ui-text-highlighted);word-break:break-all;' + (m ? '' : ''), v), fbk && Chip(fbk)))
|
||
let body
|
||
if (st === 'loading') body = D('display:flex;flex-direction:column;gap:12px', ...[40, 80, 60, 70, 50, 75].map(w => S('display:block;height:14px;width:' + w + '%;background:var(--ui-bg-accented)')))
|
||
else if (st === 'error') body = Alert('error', 'circle-x', 'Couldn\'t load the SEO data', 'GET /v1/article/12/seo failed. Your article is unchanged. Try again in a moment.', 'Try again')
|
||
else if (raw) body = S('display:block;max-height:' + (m ? 440 : 400) + 'px;overflow:hidden;padding:12px;background:var(--ui-bg-elevated);white-space:pre;font:400 12px/18px \'IBM Plex Mono\',monospace;color:var(--ui-text-highlighted)', JSON.stringify(API, null, 2))
|
||
else body = D('display:flex;flex-direction:column', KV('title', API.title, 'article title + template'), KV('description', API.description, 'first sentence of the body'), KV('canonical_url', API.canonical_url, 'site address + slug'), KV('robots', API.robots, 'site default'), KV('og.type', 'article'), KV('og.image', API.image.url, 'cover image'), KV('og.image_alt', API.image.alt), KV('twitter.card', 'summary_large_image'), KV('authors', 'Inès Duarte · ines-duarte.example · 1 social link'), KV('published_at', '12 Mar 2026, 09:14'), KV('modified_at', '15 Oct 2026, 11:02'), KV('json_ld', 'Article · headline, image, author, datePublished, dateModified, mainEntityOfPage'))
|
||
return Shell({ title: 'What the frontend gets', desc: 'The SEO data returned by GET /v1/article/12/seo, read only. RenewCMS doesn\'t render pages or add meta tags: your frontend does.', close: true, w: 720, top: 40, z: 60, cancel: 'Close' }, D('display:flex;flex-direction:column;gap:12px', x.draft && D('display:flex;gap:8px;padding:10px 12px;background:var(--rc-info-soft);color:var(--ui-info)', I('info', 14, 'margin-top:3px'), S('color:var(--ui-text-toned)', 'This article is a draft. The API returns this data only to signed-in callers until it is published.')), bar, body))
|
||
}
|
||
// ---------- compose
|
||
const v = s.v
|
||
if (v === 'sites') {
|
||
Object.assign(shellBase, { nav: 'settings', crumbs: ['Settings', 'Sites'], mTitle: 'Sites', role: s.role }); shell = shellBase
|
||
main = D('flex:1;min-width:0;min-height:0;display:flex;flex-direction:column;position:relative', sitesPage())
|
||
if (s.panel) ov.push(siteForm())
|
||
if (s.modal) ov.push(sitesModal())
|
||
if (!s.menu && s.fab !== false && !(s.sites && ['loading', 'error'].includes(s.sites.state))) ov.push(fab('Add site'))
|
||
} else if (v === 'authors') {
|
||
Object.assign(shellBase, { nav: 'authors', crumbs: ['Authors'], mTitle: 'Authors', role: s.role }); shell = shellBase
|
||
main = D('flex:1;min-width:0;min-height:0;display:flex;flex-direction:column;position:relative', authorsPage())
|
||
if (s.panel) ov.push(s.panel.k === 'authorView' ? authorView() : authorForm())
|
||
if (s.modal) ov.push(authorsModal())
|
||
if (s.list !== 'empty' && s.list !== 'error' && s.list !== 'loading') ov.push(fab('Add author'))
|
||
} else if (v === 'editor') {
|
||
if (s.metaOpen) edMeta = metaUI()
|
||
if (s.seo && s.seo.side) edSeo = SEOSIDE(s.seo.side)
|
||
if (s.seo && s.seo.panel) ov.push(seoPanel())
|
||
if (s.seo && s.seo.pick) ov.push(Picker({ title: 'Choose a cover image', desc: 'The cover for this article.', sel: 1, z: 70 }))
|
||
if (s.seo && s.seo.api) ov.push(apiDialog())
|
||
if (s.modal) ov.push(newAuthorModal())
|
||
if (s.ann) ov.push(D('position:absolute;z-index:20;' + (m ? 'left:16px;right:16px;bottom:88px' : 'left:248px;bottom:20px'), Note(s.ann)))
|
||
} else if (v === 'profile') {
|
||
if (s.panel) ov.push(authorForm())
|
||
}
|
||
if (s.toasts) ov.push(Toasts())
|
||
return { shell, mob: m, main, ov: D('display:contents', ...ov), edMeta, edSeo }
|
||
}
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|