chore: groundwork for sites & authors
This commit is contained in:
1 parent
0d03a27f31
commit
45fcbd890d
17 files changed
+866
-57
No files matched your search
@@ -12,7 +12,7 @@
|
||||
<div style="width:224px;flex-shrink:0;background:var(--ui-bg);border-right:1px solid var(--ui-border);display:flex;flex-direction:column;padding:12px;gap:2px">
|
||||
<div style="display:flex;align-items:center;gap:10px;height:40px;padding:0 6px;margin-bottom:10px"><svg width="26" height="26" viewBox="0 0 16 16" shape-rendering="crispEdges"><rect width="16" height="16" fill="#111"></rect><path fill="#fff" fill-rule="evenodd" d="M4 3h6v1h1v1h1v3h-1v1h-1v1h1v1h1v2h-2v-2h-1v-1H6v3H4zm2 2v3h4V5z"></path></svg><span style="font-weight:600;font-size:14px;color:var(--ui-text-highlighted)">RenewCMS</span></div>
|
||||
<sc-for list="{{ nav }}" as="n" hint-placeholder-count="3">
|
||||
<div data-act="nav-{{ n.key }}" style="display:flex;align-items:center;gap:10px;height:32px;padding:0 10px;background:{{ n.bg }};color:{{ n.fg }};font-weight:{{ n.fw }}"><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ n.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ n.icon }}.svg') center / contain no-repeat"></span>{{ n.t }}<sc-if value="{{ n.badge }}"><span style="margin-left:auto;height:18px;padding:0 6px;display:inline-flex;align-items:center;background:var(--ui-info);color:var(--ui-bg);font:500 11px 'IBM Plex Sans',sans-serif">Update</span></sc-if><sc-if value="{{ n.chev }}"><span aria-hidden="true" style="margin-left:{{ n.chevML }};display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ n.chev }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ n.chev }}.svg') center / contain no-repeat"></span></sc-if></div><sc-if value="{{ n.sub }}"><div style="display:flex;flex-direction:column;gap:2px;margin-left:19px;border-left:1px solid var(--ui-border)"><span style="padding:6px 10px 2px 12px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Media</span><sc-for list="{{ n.subs }}" as="x" hint-placeholder-count="2"><div data-act="nav-{{ x.t }}" style="display:flex;align-items:center;gap:10px;height:32px;padding:0 10px 0 12px;background:{{ x.bg }};color:{{ x.fg }};font-weight:{{ x.fw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ x.ic }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ x.ic }}.svg') center / contain no-repeat"></span>{{ x.t }}</div></sc-for></div></sc-if>
|
||||
<div data-act="nav-{{ n.key }}" style="display:flex;align-items:center;gap:10px;height:32px;padding:0 10px;background:{{ n.bg }};color:{{ n.fg }};font-weight:{{ n.fw }}"><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ n.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ n.icon }}.svg') center / contain no-repeat"></span>{{ n.t }}<sc-if value="{{ n.badge }}"><span style="margin-left:auto;height:18px;padding:0 6px;display:inline-flex;align-items:center;background:var(--ui-info);color:var(--ui-bg);font:500 11px 'IBM Plex Sans',sans-serif">Update</span></sc-if><sc-if value="{{ n.chev }}"><span aria-hidden="true" style="margin-left:{{ n.chevML }};display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ n.chev }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ n.chev }}.svg') center / contain no-repeat"></span></sc-if></div><sc-if value="{{ n.sub }}"><div style="display:flex;flex-direction:column;gap:2px;margin-left:19px;border-left:1px solid var(--ui-border)"><span style="padding:6px 10px 2px 12px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Media</span><sc-for list="{{ n.subs }}" as="x" hint-placeholder-count="2"><div data-act="nav-{{ x.t }}" style="display:flex;align-items:center;gap:10px;height:32px;padding:0 10px 0 12px;background:{{ x.bg }};color:{{ x.fg }};font-weight:{{ x.fw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ x.ic }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ x.ic }}.svg') center / contain no-repeat"></span>{{ x.t }}</div></sc-for><sc-if value="{{ n.hasSites }}"><span style="padding:6px 10px 2px 12px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Publishing</span></sc-if><sc-for list="{{ n.subs2 }}" as="x2" hint-placeholder-count="1"><div data-act="nav-{{ x2.t }}" style="display:flex;align-items:center;gap:10px;height:32px;padding:0 10px 0 12px;background:{{ x2.bg }};color:{{ x2.fg }};font-weight:{{ x2.fw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ x2.ic }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ x2.ic }}.svg') center / contain no-repeat"></span>{{ x2.t }}</div></sc-for></div></sc-if>
|
||||
</sc-for>
|
||||
<div style="flex:1"></div>
|
||||
<sc-if value="{{ s.boot }}"><div style="display:flex;align-items:center;gap:10px;height:40px;padding:0 6px"><span style="width:26px;height:26px;background:var(--ui-bg-accented)"></span><span style="width:80px;height:12px;background:var(--ui-bg-accented)"></span></div></sc-if>
|
||||
@@ -83,8 +83,10 @@
|
||||
<div style="display:flex;align-items:flex-end;gap:16px;flex-wrap:wrap"><div style="flex:1;min-width:200px;display:flex;flex-direction:column;gap:2px"><span style="font:600 20px/28px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Articles</span><span style="color:var(--ui-text-muted)">Everything you've written, online and in draft.</span></div><sc-if value="{{ desk }}"><span data-act="new" style="height:32px;padding:0 12px;display:flex;align-items:center;gap:6px;background:var(--ui-primary);color:var(--ui-bg);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/plus.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/plus.svg') center / contain no-repeat"></span>New article</span></sc-if></div>
|
||||
<div style="display:flex;flex-direction:{{ tbDir }};gap:{{ tbGap }};border-bottom:{{ tbBorder }}">
|
||||
<sc-if value="{{ mob }}"><span style="height:44px;padding:0 4px 0 12px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:{{ qFg }};font-size:16px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/search.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/search.svg') center / contain no-repeat"></span><span style="flex:1">{{ qText }}</span><sc-if value="{{ s.list.q }}"><span style="width:36px;height:36px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat"></span></span></sc-if></span></sc-if>
|
||||
<sc-if value="{{ multiMob }}"><span style="position:relative;display:flex;flex-direction:column;"><span style="height:44px;padding:0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:{{ sfFg }};font-size:16px;"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat"></span><span style="flex:1">{{ sfText }}</span><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat"></span></span><sc-if value="{{ sfMenu }}"><div role="listbox" aria-label="Site" style="position:absolute;left:0;right:0;top:48px;z-index:5;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:4px"><sc-for list="{{ sfOpts }}" as="o" hint-placeholder-count="3"><div role="option" aria-selected="{{ o.on }}" style="height:44px;padding:0 8px;display:flex;align-items:center;justify-content:space-between;background:{{ o.bg }};color:var(--ui-text-highlighted)">{{ o.t }}<span style="opacity:{{ o.op }};display:flex"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat"></span></span></div></sc-for></div></sc-if></span></sc-if>
|
||||
<div style="display:flex;gap:20px;flex:1;border-bottom:{{ tabBorder }}"><sc-for list="{{ tabs }}" as="t" hint-placeholder-count="3"><span style="height:{{ tabH }};display:flex;align-items:center;gap:6px;border-bottom:2px solid {{ t.line }};margin-bottom:-1px;font-weight:{{ t.fw }};color:{{ t.fg }};font-size:{{ tabF }}">{{ t.t }}<span style="font:500 11px 'IBM Plex Mono',monospace;color:var(--ui-text-muted)">{{ t.n }}</span></span></sc-for></div>
|
||||
<sc-if value="{{ desk }}"><span style="width:260px;height:32px;margin-bottom:6px;padding:0 4px 0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:{{ qFg }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/search.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/search.svg') center / contain no-repeat"></span><span style="flex:1">{{ qText }}</span><sc-if value="{{ s.list.q }}"><span style="width:24px;height:24px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat"></span></span></sc-if></span></sc-if>
|
||||
<sc-if value="{{ multiDesk }}"><span style="position:relative;display:flex;flex-direction:column;width:150px;margin-bottom:6px;"><span style="height:32px;padding:0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:{{ sfFg }};"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat"></span><span style="flex:1">{{ sfText }}</span><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat"></span></span><sc-if value="{{ sfMenu }}"><div role="listbox" aria-label="Site" style="position:absolute;left:0;right:0;top:36px;z-index:5;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:4px"><sc-for list="{{ sfOpts }}" as="o" hint-placeholder-count="3"><div role="option" aria-selected="{{ o.on }}" style="height:32px;padding:0 8px;display:flex;align-items:center;justify-content:space-between;background:{{ o.bg }};color:var(--ui-text-highlighted)">{{ o.t }}<span style="opacity:{{ o.op }};display:flex"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat"></span></span></div></sc-for></div></sc-if></span></sc-if>
|
||||
</div>
|
||||
<sc-if value="{{ s.list.empty }}">
|
||||
<div style="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"><span style="width:44px;height:44px;display:flex;align-items:center;justify-content:center;background:var(--ui-bg-elevated);color:var(--ui-text-toned)"><span aria-hidden="true" style="display:inline-block;width:20px;height:20px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ s.list.empty.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ s.list.empty.icon }}.svg') center / contain no-repeat"></span></span><span style="font-weight:600;font-size:15px;color:var(--ui-text-highlighted)">{{ s.list.empty.title }}</span><span style="color:var(--ui-text-muted);max-width:280px">{{ s.list.empty.text }}</span><span data-act="empty" style="height:{{ btnH }};padding:0 12px;display:flex;align-items:center;font-weight:500;background:{{ emptyBg }};color:{{ emptyFg }};border:1px solid {{ emptyBd }}">{{ s.list.empty.action }}</span></div>
|
||||
@@ -93,12 +95,13 @@
|
||||
<sc-if value="{{ pgNote }}"><div role="status" style="display:flex;align-items:center;gap:10px;padding:10px 14px;background:var(--rc-info-soft);color:var(--ui-info)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat"></span><span style="flex:1;color:var(--ui-text-toned)">{{ pgNote }}</span></div></sc-if>
|
||||
<sc-if value="{{ showTable }}">
|
||||
<div style="background:var(--ui-bg);border:1px solid var(--ui-border)">
|
||||
<div style="display:grid;grid-template-columns:minmax(0,1fr) 110px 130px 140px 44px;height:36px;align-items:center;padding:0 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500"><span>Title</span><span>Status</span><span>Created</span><span style="display:flex;align-items:center;gap:4px;color:var(--ui-text-highlighted)">Updated<span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/arrow-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/arrow-down.svg') center / contain no-repeat"></span></span><span></span></div>
|
||||
<div style="display:grid;grid-template-columns:{{ gridCols }};height:36px;align-items:center;padding:0 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500"><span>Title</span><sc-if value="{{ authorsCol }}"><span>Authors</span></sc-if><sc-if value="{{ multi }}"><span>Site</span></sc-if><span>Status</span><span>Created</span><span style="display:flex;align-items:center;gap:4px;color:var(--ui-text-highlighted)">Updated<span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/arrow-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/arrow-down.svg') center / contain no-repeat"></span></span><span></span></div>
|
||||
<sc-for list="{{ rows }}" as="a" hint-placeholder-count="6">
|
||||
<div data-act="open-{{ a.id }}" style="display:grid;grid-template-columns:minmax(0,1fr) 110px 130px 140px 44px;height:44px;align-items:center;padding:0 12px;border-top:1px solid var(--ui-border);background:{{ a.bg }};opacity:{{ a.op }};position:relative">
|
||||
<sc-if value="{{ a.skel }}"><span style="height:12px;width:60%;background:var(--ui-bg-accented)"></span><span style="width:60px;height:20px;background:var(--ui-bg-accented)"></span><span style="width:80px;height:12px;background:var(--ui-bg-accented)"></span><span style="width:80px;height:12px;background:var(--ui-bg-accented)"></span><span></span></sc-if>
|
||||
<div data-act="open-{{ a.id }}" style="display:grid;grid-template-columns:{{ gridCols }};height:44px;align-items:center;padding:0 12px;border-top:1px solid var(--ui-border);background:{{ a.bg }};opacity:{{ a.op }};position:relative">
|
||||
<sc-if value="{{ a.skel }}"><span style="height:12px;width:60%;background:var(--ui-bg-accented)"></span><sc-if value="{{ authorsCol }}"><span style="width:60px;height:24px;background:var(--ui-bg-accented)"></span></sc-if><sc-if value="{{ multi }}"><span style="width:70px;height:12px;background:var(--ui-bg-accented)"></span></sc-if><span style="width:60px;height:20px;background:var(--ui-bg-accented)"></span><span style="width:80px;height:12px;background:var(--ui-bg-accented)"></span><span style="width:80px;height:12px;background:var(--ui-bg-accented)"></span><span></span></sc-if>
|
||||
<sc-if value="{{ a.t }}">
|
||||
<span style="font-weight:500;color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding-right:12px">{{ a.t }}</span>
|
||||
<sc-if value="{{ authorsCol }}"><span title="{{ a.auNames }}" style="display:flex;align-items:center;min-width:0"><sc-if value="{{ a.hasAu }}"><sc-for list="{{ a.avs }}" as="v" hint-placeholder-count="2"><span style="width:24px;height:24px;margin-left:{{ v.ml }};flex-shrink:0;display:flex;align-items:center;justify-content:center;background:{{ v.bg }};color:var(--ui-text-highlighted);border:2px solid var(--ui-bg);font:600 10px/1 'IBM Plex Sans',sans-serif">{{ v.i }}</span></sc-for><sc-if value="{{ a.auMore }}"><span style="margin-left:6px;font-size:12px;color:var(--ui-text-muted)">{{ a.auMore }}</span></sc-if></sc-if><sc-if value="{{ a.noAu }}"><span aria-label="No authors" style="color:var(--ui-text-dimmed)">—</span></sc-if></span></sc-if><sc-if value="{{ multi }}"><span style="color:var(--ui-text-toned);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding-right:12px">{{ a.site }}</span></sc-if>
|
||||
<span><sc-if value="{{ a.busy }}"><span style="height:22px;padding:0 7px;display:inline-flex;align-items:center;gap:4px;border:1px solid var(--ui-border-accented);color:var(--ui-text-muted);font-size:12px;font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat"></span>{{ a.busy }}</span></sc-if><sc-if value="{{ a.showOn }}"><span style="height:22px;padding:0 7px;display:inline-flex;align-items:center;gap:4px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font-size:12px;font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat"></span>Online</span></sc-if><sc-if value="{{ a.showDraft }}"><span style="height:22px;padding:0 7px;display:inline-flex;align-items:center;gap:4px;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned);font-size:12px;font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/pencil-line.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/pencil-line.svg') center / contain no-repeat"></span>Draft</span></sc-if></span>
|
||||
<span style="color:var(--ui-text-muted)">{{ a.c }}</span><span style="color:var(--ui-text-muted)">{{ a.u }}</span>
|
||||
<span data-act="menu-{{ a.id }}" style="width:32px;height:32px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted);background:{{ a.menuBg }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/ellipsis.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/ellipsis.svg') center / contain no-repeat"></span></span>
|
||||
@@ -121,7 +124,7 @@
|
||||
<sc-for list="{{ rows }}" as="a" hint-placeholder-count="6">
|
||||
<div data-act="open-{{ a.id }}" style="position:relative;display:flex;align-items:center;gap:8px;padding:12px 4px 12px 16px;border-bottom:1px solid var(--ui-border);min-height:68px;opacity:{{ a.op }}">
|
||||
<sc-if value="{{ a.skel }}"><div style="flex:1;display:flex;flex-direction:column;gap:8px"><span style="width:70%;height:14px;background:var(--ui-bg-accented)"></span><span style="width:40%;height:12px;background:var(--ui-bg-accented)"></span></div></sc-if>
|
||||
<sc-if value="{{ a.t }}"><div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:6px"><span style="font-weight:500;font-size:15px;line-height:21px;color:var(--ui-text-highlighted)">{{ a.t }}</span><div style="display:flex;align-items:center;gap:10px;font-size:12px;color:var(--ui-text-muted)"><sc-if value="{{ a.busy }}"><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;border:1px solid var(--ui-border-accented);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat"></span>{{ a.busy }}</span></sc-if><sc-if value="{{ a.showOn }}"><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat"></span>Online</span></sc-if><sc-if value="{{ a.showDraft }}"><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/pencil-line.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/pencil-line.svg') center / contain no-repeat"></span>Draft</span></sc-if><span>Updated {{ a.u }}</span></div></div><span data-act="menu-{{ a.id }}" style="width:44px;height:44px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/ellipsis-vertical.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/ellipsis-vertical.svg') center / contain no-repeat"></span></span></sc-if><sc-if value="{{ a.menu }}"><div role="menu" style="position:absolute;right:8px;top:56px;z-index:2;width:220px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:4px"><div data-act="open-{{ a.id }}" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:8px;background:var(--ui-bg-elevated);color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat"></span>Edit</div><div data-act="pub-{{ a.id }}" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:8px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ a.menuIcon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ a.menuIcon }}.svg') center / contain no-repeat"></span>{{ a.menuPub }}</div><div data-act="copyurl" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:8px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/link.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/link.svg') center / contain no-repeat"></span>Copy API URL</div><div style="height:1px;background:var(--ui-border);margin:4px 0"></div><div data-act="del-{{ a.id }}" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:8px;color:var(--ui-error)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/trash-2.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/trash-2.svg') center / contain no-repeat"></span>Delete…</div></div></sc-if>
|
||||
<sc-if value="{{ a.t }}"><div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:6px"><span style="font-weight:500;font-size:15px;line-height:21px;color:var(--ui-text-highlighted)">{{ a.t }}</span><div style="display:flex;align-items:center;gap:10px;font-size:12px;color:var(--ui-text-muted)"><sc-if value="{{ a.busy }}"><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;border:1px solid var(--ui-border-accented);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat"></span>{{ a.busy }}</span></sc-if><sc-if value="{{ a.showOn }}"><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat"></span>Online</span></sc-if><sc-if value="{{ a.showDraft }}"><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/pencil-line.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/pencil-line.svg') center / contain no-repeat"></span>Draft</span></sc-if><span>Updated {{ a.u }}</span></div><sc-if value="{{ authorsCol }}"><div style="display:flex;align-items:center;gap:8px;font-size:12px;color:var(--ui-text-muted);min-width:0"><sc-if value="{{ a.hasAu }}"><span style="display:flex;align-items:center"><sc-for list="{{ a.avs }}" as="v" hint-placeholder-count="2"><span style="width:22px;height:22px;margin-left:{{ v.ml }};flex-shrink:0;display:flex;align-items:center;justify-content:center;background:{{ v.bg }};color:var(--ui-text-highlighted);border:2px solid var(--ui-bg);font:600 9px/1 'IBM Plex Sans',sans-serif">{{ v.i }}</span></sc-for></span></sc-if><span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ a.auFirst }}</span><sc-if value="{{ multi }}"><span>· {{ a.site }}</span></sc-if></div></sc-if></div><span data-act="menu-{{ a.id }}" style="width:44px;height:44px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/ellipsis-vertical.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/ellipsis-vertical.svg') center / contain no-repeat"></span></span></sc-if><sc-if value="{{ a.menu }}"><div role="menu" style="position:absolute;right:8px;top:56px;z-index:2;width:220px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:4px"><div data-act="open-{{ a.id }}" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:8px;background:var(--ui-bg-elevated);color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat"></span>Edit</div><div data-act="pub-{{ a.id }}" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:8px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ a.menuIcon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ a.menuIcon }}.svg') center / contain no-repeat"></span>{{ a.menuPub }}</div><div data-act="copyurl" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:8px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/link.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/link.svg') center / contain no-repeat"></span>Copy API URL</div><div style="height:1px;background:var(--ui-border);margin:4px 0"></div><div data-act="del-{{ a.id }}" style="height:32px;padding:0 8px;display:flex;align-items:center;gap:8px;color:var(--ui-error)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;hLine truncated
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
@@ -186,8 +189,9 @@
|
||||
<sc-if value="{{ desk }}">
|
||||
<div style="width:280px;flex-shrink:0;border-left:1px solid var(--ui-border);background:var(--ui-bg);padding:20px;display:flex;flex-direction:column;gap:16px">
|
||||
<span style="font-weight:600;font-size:14px;color:var(--ui-text-highlighted)">Details</span>
|
||||
<sc-if value="{{ s.ed.loading }}"><div style="display:flex;flex-direction:column;gap:10px"><span style="height:12px;background:var(--ui-bg-accented)"></span><span style="height:12px;background:var(--ui-bg-accented)"></span><span style="height:12px;width:60%;background:var(--ui-bg-accented)"></span></div></sc-if>
|
||||
<sc-if value="{{ s.ed.loading }}"><div style="display:flex;flex-direction:column;gap:12px;padding-bottom:16px;border-bottom:1px solid var(--ui-border)"><div style="display:flex;align-items:center;gap:8px;min-height:24px;color:var(--ui-text-highlighted)"><span style="display:flex;transform:rotate(-90deg)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat"></span></span><span style="font-weight:600;font-size:14px">Site and authors</span></div><span style="margin-left:22px;width:40%;height:12px;background:var(--ui-bg-accented)"></span></div><div style="display:flex;flex-direction:column;gap:10px"><span style="height:12px;background:var(--ui-bg-accented)"></span><span style="height:12px;background:var(--ui-bg-accented)"></span><span style="height:12px;width:60%;background:var(--ui-bg-accented)"></span></div></sc-if>
|
||||
<sc-if value="{{ edReady }}">
|
||||
<div style="display:flex;flex-direction:column;gap:12px;padding-bottom:16px;border-bottom:1px solid var(--ui-border)"><div data-act="meta-toggle" role="button" tabindex="0" aria-expanded="{{ metaOpen }}" aria-controls="meta-body" style="display:flex;align-items:center;gap:8px;min-height:24px;color:var(--ui-text-highlighted)"><span style="display:flex;transform:rotate({{ metaRot }})"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat"></span></span><span style="font-weight:600;font-size:14px">Site and authors</span></div><sc-if value="{{ metaOpen }}"><div id="meta-body" style="display:flex;flex-direction:column;gap:16px">{{ edMeta }}</div></sc-if><sc-if value="{{ metaClosed }}"><span style="padding-left:22px;color:var(--ui-text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ metaSummary }}</span></sc-if></div>
|
||||
<div style="display:grid;grid-template-columns:auto 1fr;gap:8px 12px"><span style="color:var(--ui-text-muted)">Created</span><span>12 Mar 2026, 09:14</span><span style="color:var(--ui-text-muted)">Updated</span><span>Today, 11:02</span><span style="color:var(--ui-text-muted)">ID</span><span style="font-family:'IBM Plex Mono',monospace">12</span><span style="color:var(--ui-text-muted)">Images</span><span>{{ imgCount }}</span></div>
|
||||
<div style="display:flex;flex-direction:column;gap:6px"><span style="color:var(--ui-text-muted)">Public API URL</span><span style="display:flex;align-items:center;gap:6px;padding:6px 8px;background:var(--ui-bg-elevated);font:400 12px 'IBM Plex Mono',monospace"><span style="flex:1">/v1/article/12</span><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/copy.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/copy.svg') center / contain no-repeat"></span></span><span style="color:var(--ui-text-muted);font-size:12px;line-height:18px">{{ apiNote }}</span></div>
|
||||
<div style="display:flex;flex-direction:column;gap:4px;padding-top:12px;border-top:1px solid var(--ui-border)"><span style="color:var(--ui-text-muted)">Title</span><span style="font-size:12px;line-height:18px;color:var(--ui-text-toned)">Titles can't be changed after an article is created. To use a different title, create a new article and delete this one.</span></div>
|
||||
@@ -251,6 +255,7 @@
|
||||
<sc-if value="{{ s.upd.link }}"><span style="align-self:flex-start;display:flex;align-items:center;gap:6px;color:var(--ui-text-highlighted);text-decoration:underline;text-underline-offset:3px;font-weight:500">{{ s.upd.link }}<span aria-hidden="true" style="display:inline-block;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/external-link.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/external-link.svg') center / contain no-repeat"></span></span></sc-if>
|
||||
</div>
|
||||
<div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;flex-direction:column;gap:12px"><div style="display:flex;flex-direction:column;gap:2px"><span style="font:600 15px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Media</span><span style="color:var(--ui-text-muted);text-wrap:pretty">Image sizes, cache and cleanup for your media library.</span></div><div style="display:flex;flex-direction:column"><div style="display:flex;align-items:center;gap:12px;min-height:44px;padding:8px 0;border-top:1px solid var(--ui-border);color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/layers.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/layers.svg') center / contain no-repeat"></span><div style="flex:1;min-width:0;display:flex;flex-direction:column"><span style="font-weight:500">Manage image variants</span><span style="color:var(--ui-text-muted)">Named sizes requested with ?v=</span></div><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-right.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-right.svg') center / contain no-repeat"></span></div><div style="display:flex;align-items:center;gap:12px;min-height:44px;padding:8px 0;border-top:1px solid var(--ui-border);color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/database.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/database.svg') center / contain no-repeat"></span><div style="flex:1;min-width:0;display:flex;flex-direction:column"><span style="font-weight:500">Cache usage</span><span style="color:var(--ui-text-muted)">Generated images and disk space</span></div><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-right.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-right.svg') center / contain no-repeat"></span></div></div></div>
|
||||
<sc-if value="{{ showSites }}"><div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;flex-direction:column;gap:12px"><div style="display:flex;flex-direction:column;gap:2px"><span style="font:600 15px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Publishing</span><span style="color:var(--ui-text-muted);text-wrap:pretty">Where your content is shown.</span></div><div style="display:flex;flex-direction:column"><div style="display:flex;align-items:center;gap:12px;min-height:44px;padding:8px 0;border-top:1px solid var(--ui-border);color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/globe.svg') center / contain no-repeat"></span><div style="flex:1;min-width:0;display:flex;flex-direction:column"><span style="font-weight:500">Manage sites</span><span style="color:var(--ui-text-muted)">Base URLs, default image and robots policy</span></div><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-right.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-right.svg') center / contain no-repeat"></span></div></div></div></sc-if>
|
||||
<div role="region" aria-labelledby="cleanup-h" style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;flex-direction:column;gap:16px"><sc-if value="{{ cl.loading }}"><div aria-busy="true" style="display:flex;flex-direction:column;gap:16px"><span style="display:block;width:140px;height:16px;background:var(--ui-bg-accented)"></span><span style="display:block;width:90%;height:13px;background:var(--ui-bg-accented)"></span><span style="display:block;width:55%;height:13px;background:var(--ui-bg-accented)"></span><div style="display:flex;justify-content:space-between;align-items:center"><div style="display:flex;flex-direction:column;gap:6px"><span style="display:block;width:120px;height:13px;background:var(--ui-bg-accented)"></span><span style="display:block;width:180px;height:11px;background:var(--ui-bg-accented)"></span></div><span style="display:block;width:36px;height:20px;background:var(--ui-bg-accented)"></span></div><span style="display:block;width:60%;height:13px;background:var(--ui-bg-accented)"></span><div style="height:1px;background:var(--ui-border)"></div><div style="display:flex;justify-content:space-between;align-items:center"><div style="display:flex;flex-direction:column;gap:6px"><span style="display:block;width:100px;height:13px;background:var(--ui-bg-accented)"></span><span style="display:block;width:220px;height:11px;background:var(--ui-bg-accented)"></span></div><span style="display:block;width:140px;height:32px;background:var(--ui-bg-accented)"></span></div></div></sc-if><sc-if value="{{ cl.ready }}"><div style="display:flex;align-items:flex-start;gap:12px"><div style="flex:1;display:flex;flex-direction:column;gap:2px"><span id="cleanup-h" tabindex="-1" style="align-self:flex-start;font:600 15px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Media cleanup</span><span style="color:var(--ui-text-muted);text-wrap:pretty">Merge identical images so your library stays tidy. Articles that use a copy are switched to the most used one.</span></div><div style="height:24px;display:flex;align-items:center;flex-shrink:0"><sc-if value="{{ cl.saving }}"><span style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat;"></span>Saving…</span></sc-if><sc-if value="{{ cl.saved }}"><span role="status" style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--ui-success)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat;"></span>Saved</span></sc-if></div></div><sc-if value="{{ cl.ro }}"><div style="display:flex;gap:10px;padding:12px 14px;background:var(--rc-info-soft);color:var(--ui-info)"><span style="display:flex;padding-top:2px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat;"></span></span><span style="color:var(--ui-text-toned)">Only owners and admins can change this.</span></div></sc-if><div style="display:flex;flex-direction:{{ rowDir }};align-items:{{ rowAlign }};justify-content:{{ rowJust }};gap:{{ rowGap }}"><div style="display:flex;flex-direction:column;gap:2px;min-width:0"><span style="font-weight:500;color:var(--ui-text-highlighted)">Automatic purge</span><span style="font-size:12px;color:var(--ui-text-muted)">Runs in the background. Off by default.</span></div><div role="switch" aria-checked="{{ cl.on }}" style="display:flex;align-items:center;justify-content:center;width:{{ cl.swW }};height:{{ cl.swH }};flex-shrink:0;opacity:{{ cl.swOp }}"><div style="position:relative;width:36px;height:20px;border:1px solid {{ cl.swBd }};background:{{ cl.swBg }};outline:{{ cl.swRing }};outline-offset:2px"><div style="position:absolute;top:1px;left:{{ cl.knobL }};width:16px;height:16px;background:var(--ui-bg);border:1px solid {{ cl.knobBd }};box-sizing:border-box"></div></div></div></div><sc-if value="{{ cl.on }}"><div style="display:flex;flex-direction:{{ rowDir }};align-items:{{ rowAlign }};justify-content:{{ rowJust }};gap:{{ rowGap }}"><div style="display:flex;flex-direction:column;gap:2px;min-width:0"><span style="font-weight:500;color:var(--ui-text-highlighted)">Every</span><span style="font-size:12px;color:var(--ui-text-muted)">Between 1 and 365 days.</span></div><div style="display:flex;flex-direction:column;gaLine truncated
|
||||
<sc-if value="{{ rt.show }}"><div role="region" aria-labelledby="retention-h" style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;flex-direction:column;gap:16px"><div style="display:flex;align-items:flex-start;gap:12px"><div style="flex:1;display:flex;flex-direction:column;gap:2px"><span id="retention-h" tabindex="-1" style="align-self:flex-start;font:600 15px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Retention</span><span style="color:var(--ui-text-muted);text-wrap:pretty">How long activity is kept, and how much log data to keep. Whichever limit is reached first removes the oldest events. Only owners can change it.</span></div><div style="height:24px;display:flex;align-items:center;flex-shrink:0"><sc-if value="{{ rt.saving }}"><span style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/loader-circle.svg') center / contain no-repeat;"></span>Saving…</span></sc-if><sc-if value="{{ rt.saved }}"><span role="status" style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--ui-success)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat;"></span>Saved</span></sc-if></div></div><sc-if value="{{ rt.err }}"><div style="display:flex;gap:10px;padding:12px 14px;background:var(--rc-error-soft);color:var(--ui-error)"><span style="display:flex;padding-top:2px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/circle-x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-x.svg') center / contain no-repeat;"></span></span><div style="flex:1;display:flex;flex-direction:column;gap:8px"><div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600">Couldn't save the retention period</span><span style="color:var(--ui-text-toned)">Your previous setting is still in place.</span></div><span role="button" style="height:28px;padding:0 10px;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;font-size:12px;white-space:nowrap;opacity:1">Try again</span></div></div></sc-if><div style="display:flex;flex-direction:{{ rowDir }};align-items:{{ rowAlign }};justify-content:{{ rowJust }};gap:{{ rowGap }}"><div style="display:flex;flex-direction:column;gap:2px;min-width:0"><span style="font-weight:500;color:var(--ui-text-highlighted)">Keep activity for</span><span style="font-size:12px;color:var(--ui-text-muted)">{{ rt.help }}</span></div><div style="display:flex;align-items:center;width:{{ rt.selW }};min-height:{{ inH }};padding:0 12px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);font-size:{{ inF }};color:var(--ui-text-highlighted)"><span style="flex:1">{{ rt.val }}</span><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;color:var(--ui-text-muted)"></span></div></div><div style="display:flex;align-items:center;gap:12px;color:var(--ui-text-muted);font-size:12px;font-weight:500"><span style="flex:1;height:1px;background:var(--ui-border)"></span><span>or</span><span style="flex:1;height:1px;background:var(--ui-border)"></span></div><div style="display:flex;flex-direction:{{ rowDir }};align-items:{{ rowAlign }};justify-content:{{ rowJust }};gap:{{ rowGap }}"><div style="display:flex;flex-direction:column;gap:2px;min-width:0"><span style="font-weight:500;color:var(--ui-text-highlighted)">Keep at most</span><span style="font-size:12px;color:var(--ui-text-muted)">{{ rt.sizeHelp }}</span></div><div style="display:flex;align-items:center;width:{{ rt.selW }};min-height:{{ inH }};padding:0 12px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);font-size:{{ inF }};color:var(--ui-text-highlighted)"><span style="flex:1">{{ rt.sizeVal }}</span><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;cLine truncated
|
||||
</div>
|
||||
@@ -277,6 +282,12 @@
|
||||
<div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600;font-size:14px;color:var(--ui-text-highlighted)">Account</span><span style="color:var(--ui-text-muted);text-wrap:pretty">Set when your account was created. You log in with your username, even after adding a display name.</span></div>
|
||||
<div style="display:grid;grid-template-columns:{{ pfCols }};gap:14px"><div style="display:flex;flex-direction:column;gap:6px;min-width:0"><span style="font-weight:500;color:var(--ui-text-highlighted)">Username</span><div aria-disabled="true" style="height:{{ inH }};padding:0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border);background:var(--ui-bg-elevated);color:var(--ui-text-toned);font-size:{{ inF }};cursor:not-allowed"><span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">marta</span><span style="display:flex;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat"></span></span></div></div><div style="display:flex;flex-direction:column;gap:6px;min-width:0"><span style="font-weight:500;color:var(--ui-text-highlighted)">Email</span><div aria-disabled="true" style="height:{{ inH }};padding:0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border);background:var(--ui-bg-elevated);color:var(--ui-text-toned);font-size:{{ inF }};cursor:not-allowed"><span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">marta@renew.example</span><span style="display:flex;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat"></span></span></div></div></div>
|
||||
</div>
|
||||
<div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;flex-direction:column;gap:14px"><div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600;font-size:14px;color:var(--ui-text-highlighted)">Author profile</span><span style="color:var(--ui-text-muted);text-wrap:pretty">Your public byline on articles. It's separate from your account.</span></div>
|
||||
<sc-if value="{{ pAu.err }}"><div role="alert" style="display:flex;gap:10px;padding:12px 14px;background:var(--rc-error-soft);color:var(--ui-error)"><span style="display:flex;padding-top:2px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/circle-x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/circle-x.svg') center / contain no-repeat"></span></span><div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600">Couldn't create your author profile</span><span style="color:var(--ui-text-toned)">Try again in a moment.</span></div></div></sc-if>
|
||||
<sc-if value="{{ pAu.none }}"><span style="color:var(--ui-text-toned);text-wrap:pretty">You don't have an author profile yet. Create one to be credited on your own articles.</span><div style="display:flex;flex-direction:{{ pwDir }};align-items:{{ pwAlign }};gap:12px"><span style="height:{{ btnH }};padding:0 12px;display:flex;align-items:center;justify-content:center;gap:6px;background:var(--ui-primary);color:var(--ui-bg);font-weight:500;opacity:{{ pAu.op }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ pAu.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ pAu.icon }}.svg') center / contain no-repeat"></span>{{ pAu.btn }}</span><span style="color:var(--ui-text-muted);font-size:12px">Uses your display name, Marta Alves. You can change it afterwards.</span></div></sc-if>
|
||||
<sc-if value="{{ pAu.has }}"><div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap"><span style="width:40px;height:40px;flex-shrink:0;display:flex;align-items:center;justify-content:center;background:var(--rc-sand);color:var(--rc-on-sand);border:1px solid var(--ui-border);font:600 14px/1 'IBM Plex Sans',sans-serif">MA</span><div style="flex:1;min-width:140px;display:flex;flex-direction:column"><span style="font-weight:500;color:var(--ui-text-highlighted)">Marta Alves</span><span style="font:400 12px/18px 'IBM Plex Mono',monospace;color:var(--ui-text-muted)">{{ pAu.slug }}</span></div><span style="height:{{ btnH }};padding:0 12px;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"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat"></span>Edit author</span></div>
|
||||
<sc-if value="{{ pAu.slugNote }}"><div role="status" style="display:flex;gap:10px;padding:12px 14px;background:var(--rc-info-soft);color:var(--ui-info)"><span style="display:flex;padding-top:2px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat"></span></span><div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600">Your slug is {{ pAu.slug }}</span><span style="color:var(--ui-text-toned)">marta-alves was already used by another author. You can change it in Edit author.</span></div></div></sc-if>
|
||||
<div style="display:flex;align-items:center;gap:6px;color:var(--ui-text-muted);font-size:12px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/link.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/link.svg') center / contain no-repeat"></span>Linked to your account. New articles start with this author.</div></sc-if></div>
|
||||
<div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:{{ cardPad }};display:flex;flex-direction:column;gap:14px">
|
||||
<div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600;font-size:14px;color:var(--ui-text-highlighted)">Password</span><span style="color:var(--ui-text-muted);text-wrap:pretty">To change it, we'll email a reset link to marta@renew.example. The link works once, within 1 hour.</span></div>
|
||||
<div style="display:flex;flex-direction:{{ pwDir }};align-items:{{ pwAlign }};gap:12px"><div style="flex:1;min-width:0"><div style="display:flex;flex-direction:column;gap:6px;min-width:0"><span style="font-weight:500;color:var(--ui-text-highlighted)">Password</span><div aria-disabled="true" style="height:{{ inH }};padding:0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border);background:var(--ui-bg-elevated);color:var(--ui-text-toned);font-size:{{ inF }};cursor:not-allowed"><span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;letter-spacing:0.12em">••••••••••••</span><span style="display:flex;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat"></span></span></div></div></div><span data-act="pf-reset" style="height:{{ inH }};padding:0 14px;display:flex;align-items:center;justify-content:center;gap:6px;flex-shrink:0;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted);font-weight:500;white-space:nowrap;opacity:{{ rsBtn.op }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ rsBtn.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ rsBtn.icon }}.svg') center / contain no-repeat"></span>{{ rsBtn.t }}</span></div>
|
||||
@@ -357,8 +368,9 @@
|
||||
</sc-if>
|
||||
|
||||
{{ purgeDlg }}
|
||||
<sc-if value="{{ navDrawer }}"><div data-act="mnav-close" style="position:absolute;inset:0;z-index:60;display:flex;background:var(--rc-overlay)"><div role="navigation" aria-label="Main" style="width:300px;max-width:85%;height:100%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column;padding:12px;gap:2px"><div style="display:flex;align-items:center;gap:10px;height:40px;padding:0 6px;margin-bottom:10px"><svg width="26" height="26" viewBox="0 0 16 16" shape-rendering="crispEdges"><rect width="16" height="16" fill="#111"></rect><path fill="#fff" fill-rule="evenodd" d="M4 3h6v1h1v1h1v3h-1v1h-1v1h1v1h1v2h-2v-2h-1v-1H6v3H4zm2 2v3h4V5z"></path></svg><span style="flex:1;font-weight:600;font-size:14px;color:var(--ui-text-highlighted)">RenewCMS</span></div><sc-for list="{{ nav }}" as="n" hint-placeholder-count="3"><div data-act="nav-{{ n.key }}" style="display:flex;align-items:center;gap:10px;height:44px;padding:0 10px;background:{{ n.bg }};color:{{ n.fg }};font-weight:{{ n.fw }}"><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ n.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ n.icon }}.svg') center / contain no-repeat"></span><span style="flex:1">{{ n.t }}</span><sc-if value="{{ n.chev }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ n.chev }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ n.chev }}.svg') center / contain no-repeat"></span></sc-if></div><sc-if value="{{ n.sub }}"><div style="display:flex;flex-direction:column;gap:2px;margin-left:19px;border-left:1px solid var(--ui-border)"><span style="padding:6px 10px 2px 12px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Media</span><sc-for list="{{ n.subs }}" as="x" hint-placeholder-count="2"><div data-act="nav-{{ x.t }}" style="display:flex;align-items:center;gap:10px;height:44px;padding:0 10px 0 12px;background:{{ x.bg }};color:{{ x.fg }};font-weight:{{ x.fw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ x.ic }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ x.ic }}.svg') center / contain no-repeat"></span>{{ x.t }}</div></sc-for></div></sc-if></sc-for><div style="flex:1"></div><div style="display:flex;align-items:center;gap:10px;height:44px;padding:0 6px;color:var(--ui-text-highlighted)"><span style="width:26px;height:26px;flex-shrink:0;background:var(--rc-sand);color:var(--rc-on-sand);display:flex;align-items:center;justify-content:center;font:600 11px 'IBM Plex Sans',sans-serif">{{ ini }}</span><span style="flex:1;font-weight:500">{{ who }}</span></div></div></div></sc-if>
|
||||
<sc-if value="{{ navDrawer }}"><div data-act="mnav-close" style="position:absolute;inset:0;z-index:60;display:flex;background:var(--rc-overlay)"><div role="navigation" aria-label="Main" style="width:300px;max-width:85%;height:100%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column;padding:12px;gap:2px"><div style="display:flex;align-items:center;gap:10px;height:40px;padding:0 6px;margin-bottom:10px"><svg width="26" height="26" viewBox="0 0 16 16" shape-rendering="crispEdges"><rect width="16" height="16" fill="#111"></rect><path fill="#fff" fill-rule="evenodd" d="M4 3h6v1h1v1h1v3h-1v1h-1v1h1v1h1v2h-2v-2h-1v-1H6v3H4zm2 2v3h4V5z"></path></svg><span style="flex:1;font-weight:600;font-size:14px;color:var(--ui-text-highlighted)">RenewCMS</span></div><sc-for list="{{ nav }}" as="n" hint-placeholder-count="3"><div data-act="nav-{{ n.key }}" style="display:flex;align-items:center;gap:10px;height:44px;padding:0 10px;background:{{ n.bg }};color:{{ n.fg }};font-weight:{{ n.fw }}"><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ n.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ n.icon }}.svg') center / contain no-repeat"></span><span style="flex:1">{{ n.t }}</span><sc-if value="{{ n.chev }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ n.chev }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ n.chev }}.svg') center / contain no-repeat"></span></sc-if></div><sc-if value="{{ n.sub }}"><div style="display:flex;flex-direction:column;gap:2px;margin-left:19px;border-left:1px solid var(--ui-border)"><span style="padding:6px 10px 2px 12px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Media</span><sc-for list="{{ n.subs }}" as="x" hint-placeholder-count="2"><div data-act="nav-{{ x.t }}" style="display:flex;align-items:center;gap:10px;height:44px;padding:0 10px 0 12px;background:{{ x.bg }};color:{{ x.fg }};font-weight:{{ x.fw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ x.ic }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ x.ic }}.svg') center / contain no-repeat"></span>{{ x.t }}</div></sc-for><sc-if value="{{ n.hasSites }}"><span style="padding:6px 10px 2px 12px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Publishing</span></sc-if><sc-for list="{{ n.subs2 }}" as="x2" hint-placeholder-count="1"><div data-act="nav-{{ x2.t }}" style="display:flex;align-items:center;gap:10px;height:44px;padding:0 10px 0 12px;background:{{ x2.bg }};color:{{ x2.fg }};font-weight:{{ x2.fw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ x2.ic }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ x2.ic }}.svg') center / contain no-repeat"></span>{{ x2.t }}</div></sc-for></div></sc-if></sc-for><div style="flex:1"></div><div style="display:flex;align-items:center;gap:10px;height:44px;padding:0 6px;color:var(--ui-text-highlighted)"><span style="width:26px;height:26px;flex-shrink:0;background:var(--rc-sand);color:var(--rc-on-sand);display:flex;align-items:center;justify-content:center;font:600 11px 'IBM Plex Sans',sans-serif">{{ ini }}</span><span style="flex:1;font-weight:500">{{ who }}</span></div></div></div></sc-if>
|
||||
<sc-if value="{{ lk.sheet }}"><div style="position:absolute;inset:0;z-index:55;background:var(--rc-overlay);display:flex;align-items:flex-end"><div role="dialog" aria-label="Link" style="width:100%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column"><div style="display:flex;justify-content:center;padding-top:8px"><span style="display:block;width:36px;height:4px;background:var(--ui-border-accented)"></span></div><div style="display:flex;flex-direction:column;gap:2px;padding:12px 16px 12px;border-bottom:1px solid var(--ui-border)"><span style="font-weight:600;font-size:16px;line-height:24px;color:var(--ui-text-highlighted)">timber windows</span><span style="font:400 12px/18px 'IBM Plex Mono',monospace;color:var(--ui-text-toned);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">/articles/winter-checklist</span></div><div style="display:flex;flex-direction:column;padding:8px 0 16px"><span style="height:48px;padding:0 16px;display:flex;align-items:center;gap:12px;color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat"></span>Edit link</span><span style="height:48px;padding:0 16px;display:flex;align-items:center;gap:12px;color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/external-link.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/external-link.svg') center / contain no-repeat"></span>Open link</span><span style="height:48px;padding:0 16px;display:flex;align-items:center;gap:12px;color:var(--ui-error)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/unlink.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/unlink.svg') center / contain no-repeat"></span>Remove link</span></div></div></div></sc-if>
|
||||
<sc-if value="{{ edSheet }}"><div style="position:absolute;inset:0;z-index:55;background:var(--rc-overlay);display:flex;align-items:flex-end"><div role="dialog" aria-modal="true" aria-label="Details" style="width:100%;height:92%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column"><div style="display:flex;justify-content:center;padding-top:8px;flex-shrink:0"><span style="display:block;width:36px;height:4px;background:var(--ui-border-accented)"></span></div><div style="padding:8px 16px 0 16px;display:flex;align-items:center;gap:12px;flex-shrink:0"><span style="flex:1;font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Details</span><span data-act="modal-close" style="width:44px;height:44px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat"></span></span></div><div style="flex:1;min-height:0;overflow:hidden;padding:12px 16px 16px;display:flex;flex-direction:column;gap:16px"><div style="display:flex;flex-direction:column;gap:12px;padding-bottom:16px;border-bottom:1px solid var(--ui-border)"><div data-act="meta-toggle" role="button" tabindex="0" aria-expanded="{{ metaOpen }}" aria-controls="meta-body" style="display:flex;align-items:center;gap:8px;min-height:24px;color:var(--ui-text-highlighted)"><span style="display:flex;transform:rotate({{ metaRot }})"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat"></span></span><span style="font-weight:600;font-size:14px">Site and authors</span></div><sc-if value="{{ metaOpen }}"><div id="meta-body" style="display:flex;flex-direction:column;gap:16px">{{ edMeta }}</div></sc-if><sc-if value="{{ metaClosed }}"><span style="padding-left:22px;color:var(--ui-text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ metaSummary }}</span></sc-if></div><div style="display:grid;grid-template-columns:auto 1fr;gap:8px 12px"><span style="color:var(--ui-text-muted)">Created</span><span>12 Mar 2026, 09:14</span><span style="color:var(--ui-text-muted)">Updated</span><span>Today, 11:02</span><span style="color:var(--ui-text-muted)">ID</span><span style="font-family:'IBM Plex Mono',monospace">12</span></div><div style="display:flex;flex-direction:column;gap:6px"><span style="color:var(--ui-text-muted)">Public API URL</span><span style="padding:6px 8px;background:var(--ui-bg-elevated);font:400 12px 'IBM Plex Mono',monospace">/v1/article/12</span></div></div></div></div></sc-if>
|
||||
{{ slotOverlays }}
|
||||
<sc-if value="{{ hasToasts }}">
|
||||
<div style="position:absolute;z-index:60;display:flex;flex-direction:column;gap:8px;left:{{ tL }};right:{{ tR }};bottom:{{ tB }};width:{{ tW }}">
|
||||
@@ -375,14 +387,16 @@ class Component extends DCLogic {
|
||||
const s = this.props.s || {}
|
||||
const m = !!this.props.mobile
|
||||
const k = s.kind || 'home'
|
||||
const navItems = s.role === 'user' ? [['home', 'house', 'Home'], ['articles', 'file-text', 'Articles'], ['media', 'images', 'Media'], ['activity', 'history', 'Activity']] : [['home', 'house', 'Home'], ['articles', 'file-text', 'Articles'], ['media', 'images', 'Media'], ['users', 'users', 'Users'], ['activity', 'history', 'Activity'], ['integration', 'plug', 'Integration'], ['settings', 'settings', 'Settings']]
|
||||
const navItems = s.role === 'user' ? [['home', 'house', 'Home'], ['articles', 'file-text', 'Articles'], ['media', 'images', 'Media'], ['authors', 'feather', 'Authors'], ['activity', 'history', 'Activity']] : [['home', 'house', 'Home'], ['articles', 'file-text', 'Articles'], ['media', 'images', 'Media'], ['authors', 'feather', 'Authors'], ['users', 'users', 'Users'], ['activity', 'history', 'Activity'], ['integration', 'plug', 'Integration'], ['settings', 'settings', 'Settings']]
|
||||
const cr0 = s.crumbs || [], setOpen = s.nav === 'settings' || !!s.sideOpen, parentOn = (key) => s.nav === key && (key !== 'settings' || cr0.length <= 1)
|
||||
const nav = navItems.map(([key, icon, t]) => { const on = parentOn(key), open = key === 'settings' && setOpen; return { key, icon, t, bg: on ? 'var(--rc-sand)' : 'transparent', badge: key === 'settings' && !!s.navBadge, fg: on ? 'var(--rc-on-sand)' : open ? 'var(--ui-text-highlighted)' : 'var(--ui-text-toned)', fw: on || open ? 500 : 400, chev: key === 'settings' ? (open ? 'chevron-down' : 'chevron-right') : '', chevML: key === 'settings' && s.navBadge ? '8px' : 'auto', sub: open, subs: ['Variants', 'Cache'].map(x => { const o = cr0[cr0.length - 1] === x; return { t: x, ic: x === 'Variants' ? 'layers' : 'database', bg: o ? 'var(--rc-sand)' : 'transparent', fg: o ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)', fw: o ? 500 : 400 } }) } })
|
||||
const nav = navItems.map(([key, icon, t]) => { const on = parentOn(key), open = key === 'settings' && setOpen; return { key, icon, t, bg: on ? 'var(--rc-sand)' : 'transparent', badge: key === 'settings' && !!s.navBadge, fg: on ? 'var(--rc-on-sand)' : open ? 'var(--ui-text-highlighted)' : 'var(--ui-text-toned)', fw: on || open ? 500 : 400, chev: key === 'settings' ? (open ? 'chevron-down' : 'chevron-right') : '', chevML: key === 'settings' && s.navBadge ? '8px' : 'auto', sub: open, hasSites: !s.role || s.role === 'owner', subs2: (!s.role || s.role === 'owner') ? (() => { const o = cr0[cr0.length - 1] === 'Sites'; return [{ t: 'Sites', ic: 'globe', bg: o ? 'var(--rc-sand)' : 'transparent', fg: o ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)', fw: o ? 500 : 400 }] })() : [], subs: ['Variants', 'Cache'].map(x => { const o = cr0[cr0.length - 1] === x; return { t: x, ic: x === 'Variants' ? 'layers' : 'database', bg: o ? 'var(--rc-sand)' : 'transparent', fg: o ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)', fw: o ? 500 : 400 } }) } })
|
||||
const crumbs = (s.crumbs || []).map((t, i, a) => ({ t, sep: i > 0, fg: i === a.length - 1 ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)', fw: i === a.length - 1 ? 500 : 400 }))
|
||||
const L = s.list || {}
|
||||
const counts = L.counts || [6, 4, 2]
|
||||
const tabs = ['All', 'Online', 'Drafts'].map((t, i) => { const on = (L.tab || 0) === i; return { t, n: counts[i], line: on ? 'var(--ui-primary)' : 'transparent', fg: on ? 'var(--ui-text-highlighted)' : 'var(--ui-text-toned)', fw: on ? 500 : 400 } })
|
||||
const rows = (L.loading ? Array.from({ length: 6 }, () => ({ skel: true })) : (L.rows || [])).map(a => ({ ...a, showOn: !a.busy && a.on, showDraft: !a.busy && a.t && !a.on, op: a.busy ? '0.55' : '1', bg: a.menu ? 'var(--ui-bg-elevated)' : 'transparent', menuBg: a.menu ? 'var(--ui-bg-accented)' : 'transparent', menuPub: a.on ? 'Unpublish' : 'Publish', menuIcon: a.on ? 'globe-lock' : 'globe' }))
|
||||
const AVT = ['var(--rc-sand)', 'var(--ui-bg-accented)', 'var(--ui-bg-elevated)'], ini2 = n => n.replace(/^Dr\.\s*/, '').split(/\s+/).slice(0, 2).map(x => x[0]).join('').toUpperCase()
|
||||
const auInfo = a => { if (a.skel) return {}; const au = a.au || []; return { hasAu: au.length > 0, noAu: au.length === 0, avs: au.slice(0, 3).map((n, i) => ({ i: ini2(n), bg: AVT[(n.length + i) % 3], ml: i ? '-6px' : '0' })), auMore: au.length > 3 ? '+' + (au.length - 3) : '', auNames: au.join(', '), auFirst: au.length ? au[0] + (au.length > 1 ? ' +' + (au.length - 1) : '') : 'No authors', site: a.site || '' } }
|
||||
const rows = (L.loading ? Array.from({ length: 6 }, () => ({ skel: true })) : (L.rows || [])).map(a => ({ ...a, showOn: !a.busy && a.on, showDraft: !a.busy && a.t && !a.on, op: a.busy ? '0.55' : '1', bg: a.menu ? 'var(--ui-bg-elevated)' : 'transparent', menuBg: a.menu ? 'var(--ui-bg-accented)' : 'transparent', menuPub: a.on ? 'Unpublish' : 'Publish', menuIcon: a.on ? 'globe-lock' : 'globe', ...auInfo(a) }))
|
||||
const e = s.ed || {}
|
||||
const saveMap = { clean: ['check', 'Saved', 'var(--ui-text-muted)'], dirty: ['circle-dot', 'Unsaved changes', 'var(--ui-warning)'], saving: ['loader-circle', 'Saving…', 'var(--ui-text-muted)'], saved: ['check', 'Saved at 11:02', 'var(--ui-text-muted)'], error: ['circle-alert', 'Save failed', 'var(--ui-error)'], offline: ['wifi-off', 'Waiting for connection', 'var(--ui-text-muted)'] }
|
||||
const sv = saveMap[e.save || 'saved']
|
||||
@@ -522,7 +536,7 @@ class Component extends DCLogic {
|
||||
const edReady = k === 'editor' && !e.loading && !e.notfound
|
||||
const edTitle = e.title || 'How we price a bathroom renovation'
|
||||
return {
|
||||
lk, tbOverflow: m ? 'hidden' : 'visible', tbShift: m ? -(LK.shift || 0) + 'px' : '0px', tbFadeL: m && !!LK.shift, tbFadeR: m && !LK.end, act, cl, rt, purgeDlg, rowDir: m ? 'column' : 'row', rowAlign: m ? 'stretch' : 'flex-start', rowJust: m ? 'flex-start' : 'space-between', rowGap: m ? '12px' : '16px', setScroll: -(s.scrollY || 0) + 'px', offText: s.offText || 'You\'re offline. You can keep writing, but saving and publishing will wait until you\'re back online.', cr, s, nav, crumbs, who, ini, pf, dn, dnSave, rsNote, rsBtn,
|
||||
lk, edMeta: this.props.edMeta || null, metaOpen: !!this.props.edMeta, metaClosed: !this.props.edMeta, metaRot: this.props.edMeta ? '0deg' : '-90deg', metaSummary: e.metaSummary || 'Inès Duarte', edSheet: m && !!e.sheet, pAu: (() => { const q = s.pa || {}; return { none: q.k !== 'has', has: q.k === 'has', err: !!q.err, slug: q.slug || 'marta-alves', slugNote: !!q.slug, btn: q.loading ? 'Creating…' : 'Create my author profile', icon: q.loading ? 'loader-circle' : 'feather', op: q.loading ? 0.7 : 1 } })(), showSites: !s.role || s.role === 'owner', authorsCol: L.authors !== false, multi: !!L.multi, multiDesk: !m && !!L.multi, multiMob: m && !!L.multi, gridCols: (L.authors !== false ? 'minmax(0,1fr) 104px ' : 'minmax(0,1fr) ') + (L.multi ? '130px ' : '') + '110px 130px 140px 44px', sfText: L.site || 'All sites', sfFg: L.site ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)', sfMenu: !!L.siteMenu, sfOpts: ['All sites', 'Renew', 'Journal', 'Projects', 'Archive'].map(x => { const on = (L.site || 'All sites') === x; return { t: x, on, op: on ? 1 : 0, bg: on ? 'var(--ui-bg-elevated)' : 'transparent' } }), tbOverflow: m ? 'hidden' : 'visible', tbShift: m ? -(LK.shift || 0) + 'px' : '0px', tbFadeL: m && !!LK.shift, tbFadeR: m && !LK.end, act, cl, rt, purgeDlg, rowDir: m ? 'column' : 'row', rowAlign: m ? 'stretch' : 'flex-start', rowJust: m ? 'flex-start' : 'space-between', rowGap: m ? '12px' : '16px', setScroll: -(s.scrollY || 0) + 'px', offText: s.offText || 'You\'re offline. You can keep writing, but saving and publishing will wait until you\'re back online.', cr, s, nav, crumbs, who, ini, pf, dn, dnSave, rsNote, rsBtn,
|
||||
langSeg: [['en','English'],['fr','Français']].map(([k,tt])=>{const on=(s.lang||'en')===k;return {act:'lang-'+k,code:k,t:tt,on,op:on?1:0}}), themeSeg: [['system','monitor','System'],['light','sun','Light'],['dark','moon','Dark']].map(([k,icon,tt])=>{const on=(s.mode||'light')===k;return {act:'theme-'+k,icon,t:tt,on,op:on?1:0}}), menuOpen: !!s.userMenu, deskMenu: !m && !!s.userMenu, mobMenu: m && !!s.userMenu, pfItemBg: 'var(--ui-bg-elevated)',
|
||||
chipBg: s.userMenu || s.nav === 'profile' ? 'var(--rc-sand)' : 'transparent', chipFg: s.userMenu || s.nav === 'profile' ? 'var(--rc-on-sand)' : 'var(--ui-text-highlighted)',
|
||||
isSettings: k === 'settings', isProfile: k === 'profile', pfLoading: k === 'profile' && !!pf.loading, pfReady: k === 'profile' && !pf.loading,
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
<!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 }}" 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, '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('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:40;display:flex;justify-content:' + (m ? 'center' : 'flex-end') + ';align-items:' + (m ? 'flex-end' : 'stretch') + ';background:var(--rc-overlay)',
|
||||
D('width:' + (m ? '100%' : '480px') + ';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: 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 = () => 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 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)')))))
|
||||
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
|
||||
const ov = []
|
||||
// ---------- Sites
|
||||
const siteForm = () => {
|
||||
const p = s.panel, f = { name: '', key: '', keyMode: 'empty', url: '', img: null, 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.'),
|
||||
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.')))
|
||||
}
|
||||
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 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 {
|
||||
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'), body)
|
||||
}
|
||||
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'
|
||||
let rows = AUTH
|
||||
if (q) 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 (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(), body)
|
||||
}
|
||||
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({ 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.')))))
|
||||
}
|
||||
// ---------- 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.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 }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -37,10 +37,10 @@
|
||||
<div style="display:flex;flex-wrap:wrap;gap:40px;align-items:flex-start">
|
||||
<sc-for list="{{ g.states }}" as="st" hint-placeholder-count="4">
|
||||
<div style="display:flex;flex-direction:column;gap:12px">
|
||||
<div style="display:flex;flex-direction:column;gap:2px;max-width:1040px"><span style="font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ st.name }}</span><span style="font-size:13px;color:var(--ui-text-muted)">{{ st.trigger }}</span></div>
|
||||
<div style="display:flex;flex-direction:column;gap:2px;max-width:1040px"><span style="font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ st.name }}<sc-if value="{{ st.clock }}"><span style="margin-left:10px;padding:1px 6px;border:1px dashed var(--ui-border-accented);color:var(--ui-text-toned);font:500 11px/16px 'IBM Plex Mono',monospace;vertical-align:middle">Clock-sensitive</span></sc-if></span><span style="font-size:13px;color:var(--ui-text-muted)">{{ st.trigger }}</span></div>
|
||||
<div style="display:flex;gap:24px;align-items:flex-start">
|
||||
<div style="width:720px;height:450px;overflow:hidden;border:1px solid var(--ui-border);flex-shrink:0"><div style="width:1440px;height:900px;zoom:0.5"><sc-if value="{{ st.notMedia }}"><dc-import name="AppFrame" s="{{ st }}" mobile="{{ false }}" hint-size="1440px,900px"></dc-import></sc-if><sc-if value="{{ st.isMedia }}"><dc-import name="MediaFrame" s="{{ st }}" mobile="{{ false }}" hint-size="1440px,900px"></dc-import></sc-if><sc-if value="{{ st.isAct }}"><dc-import name="ActivityFrame" s="{{ st }}" mobile="{{ false }}" hint-size="1440px,900px"></dc-import></sc-if><sc-if value="{{ st.isVar }}"><dc-import name="VariantsFrame" s="{{ st.vs }}" mobile="{{ false }}" hint-size="1440px,900px"></dc-import></sc-if><sc-if value="{{ st.isImg }}"><div style="width:1440px;height:900px;overflow:hidden;box-sizing:border-box;background:var(--ui-bg-muted);padding:40px 280px"><dc-import name="IntegrationImages" s="{{ st.img }}" mobile="{{ false }}" hint-size="880px,820px"></dc-import></div></sc-if></div></div>
|
||||
<div style="width:293px;height:633px;overflow:hidden;border:1px solid var(--ui-border);flex-shrink:0"><div style="width:390px;height:844px;zoom:0.75"><sc-if value="{{ st.notMedia }}"><dc-import name="AppFrame" s="{{ st.m }}" mobile="{{ true }}" hint-size="390px,844px"></dc-import></sc-if><sc-if value="{{ st.isMedia }}"><dc-import name="MediaFrame" s="{{ st.m }}" mobile="{{ true }}" hint-size="390px,844px"></dc-import></sc-if><sc-if value="{{ st.isAct }}"><dc-import name="ActivityFrame" s="{{ st.m }}" mobile="{{ true }}" hint-size="390px,844px"></dc-import></sc-if><sc-if value="{{ st.isVar }}"><dc-import name="VariantsFrame" s="{{ st.vm }}" mobile="{{ true }}" hint-size="390px,844px"></dc-import></sc-if><sc-if value="{{ st.isImg }}"><div style="width:390px;height:844px;overflow:hidden;box-sizing:border-box;background:var(--ui-bg-muted);padding:16px"><dc-import name="IntegrationImages" s="{{ st.img }}" mobile="{{ true }}" hint-size="358px,800px"></dc-import></div></sc-if></div></div>
|
||||
<div style="width:720px;height:450px;overflow:hidden;border:1px solid var(--ui-border);flex-shrink:0"><div style="width:1440px;height:900px;zoom:0.5"><sc-if value="{{ st.notMedia }}"><dc-import name="AppFrame" s="{{ st }}" mobile="{{ false }}" hint-size="1440px,900px"></dc-import></sc-if><sc-if value="{{ st.isMedia }}"><dc-import name="MediaFrame" s="{{ st }}" mobile="{{ false }}" hint-size="1440px,900px"></dc-import></sc-if><sc-if value="{{ st.isAct }}"><dc-import name="ActivityFrame" s="{{ st }}" mobile="{{ false }}" hint-size="1440px,900px"></dc-import></sc-if><sc-if value="{{ st.isDir }}"><dc-import name="DirectoryFrame" s="{{ st.m }}" mobile="{{ false }}" hint-size="390px,844px"></dc-import></sc-if><sc-if value="{{ st.isVar }}"><dc-import name="VariantsFrame" s="{{ st.vs }}" mobile="{{ false }}" hint-size="1440px,900px"></dc-import></sc-if><sc-if value="{{ st.isImg }}"><div style="width:1440px;height:900px;overflow:hidden;box-sizing:border-box;background:var(--ui-bg-muted);padding:40px 280px"><dc-import name="IntegrationImages" s="{{ st.img }}" mobile="{{ false }}" hint-size="880px,820px"></dc-import></div></sc-if></div></div>
|
||||
<div style="width:293px;height:633px;overflow:hidden;border:1px solid var(--ui-border);flex-shrink:0"><div style="width:390px;height:844px;zoom:0.75"><sc-if value="{{ st.notMedia }}"><dc-import name="AppFrame" s="{{ st.m }}" mobile="{{ true }}" hint-size="390px,844px"></dc-import></sc-if><sc-if value="{{ st.isMedia }}"><dc-import name="MediaFrame" s="{{ st.m }}" mobile="{{ true }}" hint-size="390px,844px"></dc-import></sc-if><sc-if value="{{ st.isAct }}"><dc-import name="ActivityFrame" s="{{ st.m }}" mobile="{{ true }}" hint-size="390px,844px"></dc-import></sc-if><sc-if value="{{ st.isDir }}"><dc-import name="DirectoryFrame" s="{{ st.m }}" mobile="{{ true }}" hint-size="390px,844px"></dc-import></sc-if><sc-if value="{{ st.isVar }}"><dc-import name="VariantsFrame" s="{{ st.vm }}" mobile="{{ true }}" hint-size="390px,844px"></dc-import></sc-if><sc-if value="{{ st.isImg }}"><div style="width:390px;height:844px;overflow:hidden;box-sizing:border-box;background:var(--ui-bg-muted);padding:16px"><dc-import name="IntegrationImages" s="{{ st.img }}" mobile="{{ true }}" hint-size="358px,800px"></dc-import></div></sc-if></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</sc-for>
|
||||
@@ -53,6 +53,7 @@
|
||||
</sc-for>
|
||||
|
||||
<section data-screen-label="States matrix: controls" style="display:flex;flex-direction:column;gap:20px"><div style="display:flex;flex-direction:column;gap:6px;max-width:1100px"><h2 style="margin:0;font:600 28px/36px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">States matrix: controls</h2><p style="margin:4px 0 0;font-size:15px;line-height:24px;color:var(--ui-text-toned);text-wrap:pretty">Hover, focus-visible, active and disabled for every interactive element, once. Focus-visible is a 2 px --ui-primary outline with a 2 px offset (inset for tabs, segmented controls and rows). Disabled is 40% opacity (rows 55%). The mobile column set has no hover and uses 44 px targets. The theme switch above applies to both.</p></div><div style="display:flex;gap:40px;align-items:flex-start;flex-wrap:wrap"><dc-import name="StatesMatrix" mobile="{{ false }}" hint-size="1240px,2400px"></dc-import><dc-import name="StatesMatrix" mobile="{{ true }}" hint-size="390px,2400px"></dc-import></div></section>
|
||||
<section data-screen-label="Sites and authors: copy" style="display:flex;flex-direction:column;gap:20px;max-width:1240px"><h2 style="margin:0;font:600 28px/36px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Sites and authors: copy per state</h2><div style="background:var(--ui-bg);border:1px solid var(--ui-border)"><sc-for list="{{ dirCopy }}" as="r" hint-placeholder-count="5"><div style="display:grid;grid-template-columns:300px minmax(0,1fr);gap:16px;padding:10px 12px;border-top:1px solid var(--ui-border);line-height:20px"><span style="font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ r.a }}</span><span style="color:var(--ui-text-highlighted);text-wrap:pretty">{{ r.b }}</span></div></sc-for></div></section>
|
||||
<section style="display:flex;flex-direction:column;gap:20px;max-width:1240px"><h2 style="margin:0;font:600 28px/36px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Activity: copy per state</h2><div style="background:var(--ui-bg);border:1px solid var(--ui-border)"><sc-for list="{{ spCopy }}" as="r" hint-placeholder-count="5"><div style="display:grid;grid-template-columns:220px minmax(0,1fr);padding:10px 12px;border-top:1px solid var(--ui-border);line-height:20px"><span style="font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ r.a }}</span><span style="color:var(--ui-text-highlighted);text-wrap:pretty">{{ r.b }}</span></div></sc-for></div></section>
|
||||
<section style="display:flex;flex-direction:column;gap:20px;max-width:1240px"><h2 style="margin:0;font:600 28px/36px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Activity: component inventory (Nuxt UI v4)</h2><div style="background:var(--ui-bg);border:1px solid var(--ui-border)"><sc-for list="{{ spComps }}" as="r" hint-placeholder-count="5"><div style="display:grid;grid-template-columns:220px minmax(0,1fr);padding:10px 12px;border-top:1px solid var(--ui-border);line-height:20px"><span style="font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ r.a }}</span><span style="color:var(--ui-text-highlighted);text-wrap:pretty">{{ r.b }}</span></div></sc-for></div></section>
|
||||
<section style="display:flex;flex-direction:column;gap:20px;max-width:1240px"><h2 style="margin:0;font:600 28px/36px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Activity: API calls</h2><div style="background:var(--ui-bg);border:1px solid var(--ui-border)"><sc-for list="{{ spApi }}" as="r" hint-placeholder-count="5"><div style="display:grid;grid-template-columns:220px minmax(0,1fr);padding:10px 12px;border-top:1px solid var(--ui-border);line-height:20px"><span style="font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ r.a }}</span><span style="color:var(--ui-text-highlighted);text-wrap:pretty">{{ r.b }}</span></div></sc-for></div></section>
|
||||
@@ -94,18 +95,18 @@ class Component extends DCLogic {
|
||||
}
|
||||
boGroups() {
|
||||
const base = [
|
||||
{ t: 'How we price a bathroom renovation', on: true, c: '12 Mar 2026', u: '2 hours ago' },
|
||||
{ t: 'Choosing lime plaster for old stone walls', on: false, c: '9 Sep 2026', u: 'Yesterday' },
|
||||
{ t: 'Before and after: the Rue Verte kitchen', on: true, c: '28 Aug 2026', u: '3 days ago' },
|
||||
{ t: 'Winter checklist for timber windows', on: false, c: '2 Sep 2026', u: '5 days ago' },
|
||||
{ t: 'Why we reuse original floorboards', on: true, c: '14 Jun 2026', u: '18 Aug 2026' },
|
||||
{ t: 'Our first passive-house retrofit', on: true, c: '3 Apr 2026', u: '2 Jul 2026' }
|
||||
{ t: 'How we price a bathroom renovation', au: ['Inès Duarte'], on: true, c: '12 Mar 2026', u: '2 hours ago' },
|
||||
{ t: 'Choosing lime plaster for old stone walls', au: ['Inès Duarte', 'Camille Roche'], on: false, c: '9 Sep 2026', u: 'Yesterday' },
|
||||
{ t: 'Before and after: the Rue Verte kitchen', au: ['Jonas Pereira'], on: true, c: '28 Aug 2026', u: '3 days ago' },
|
||||
{ t: 'Winter checklist for timber windows', au: ['Inès Duarte', 'Jonas Pereira', 'Camille Roche', 'Paul Huber', 'Léa Fontaine'], on: false, c: '2 Sep 2026', u: '5 days ago' },
|
||||
{ t: 'Why we reuse original floorboards', au: ['Paul Huber'], on: true, c: '14 Jun 2026', u: '18 Aug 2026' },
|
||||
{ t: 'Our first passive-house retrofit', au: [], on: true, c: '3 Apr 2026', u: '2 Jul 2026' }
|
||||
]
|
||||
const more = [
|
||||
{ t: 'Repointing a gable wall in lime mortar', on: true, c: '21 May 2026', u: '4 Jun 2026' },
|
||||
{ t: 'What a heat pump survey includes', on: false, c: '30 Jul 2026', u: '1 Aug 2026' },
|
||||
{ t: 'Choosing a damp-proof course', on: true, c: '7 Feb 2026', u: '19 Apr 2026' },
|
||||
{ t: 'Reglazing sash windows without replacing them', on: true, c: '16 Jan 2026', u: '2 Mar 2026' }
|
||||
{ t: 'Repointing a gable wall in lime mortar', au: ['Camille Roche'], on: true, c: '21 May 2026', u: '4 Jun 2026' },
|
||||
{ t: 'What a heat pump survey includes', au: ['Jonas Pereira'], on: false, c: '30 Jul 2026', u: '1 Aug 2026' },
|
||||
{ t: 'Choosing a damp-proof course', au: ['Inès Duarte'], on: true, c: '7 Feb 2026', u: '19 Apr 2026' },
|
||||
{ t: 'Reglazing sash windows without replacing them', au: ['Inès Duarte', 'Paul Huber'], on: true, c: '16 Jan 2026', u: '2 Mar 2026' }
|
||||
]
|
||||
const pgRows = n => [...base, ...more].slice(0, n).map(r => ({ ...r }))
|
||||
const PG = (g, o = {}) => { const rn = o.rows || 10; const { rows: _r, ...rest } = o; const { list: xl, ...ro } = rest; return L({ ...ro, list: { rows: pgRows(rn), pg: { per: 25, total: 312, pages: 13, ...g }, ...(xl || {}) } }) }
|
||||
@@ -197,7 +198,7 @@ class Component extends DCLogic {
|
||||
withM({ ...L({ list: { q: 'tiles', empty: { icon: 'search', title: 'No articles match "tiles"', text: 'Check the spelling, or search for a shorter part of the title.', action: 'Clear search' } } }), name: 'No results', trigger: 'Search matched nothing. The tab counts still show the full list.' }),
|
||||
withM({ ...L({ list: { tab: 2, counts: [4, 4, 0], empty: { icon: 'pencil-line', title: 'No drafts', text: 'Everything you\'ve written is online.', action: 'New article' } } }), name: 'No drafts', trigger: 'Drafts tab with nothing in it. The "Online" tab has an equivalent "Nothing is online yet" state.' }),
|
||||
withM({ ...L({ list: { rows: rows({ 3: { busy: 'Publishing…' } }) } }), name: 'Action in progress', trigger: 'Publish chosen from the row menu. That row is disabled.' }),
|
||||
withM({ ...L({ list: { rows: rows({ 3: { on: true, u: 'Just now' } }) }, toasts: [T('success', 'circle-check', 'Article published', '"Winter checklist for timber windows" is now online.')] }), name: 'Published', trigger: '200: the row updates in place and a toast confirms.' }),
|
||||
withM({ ...L({ list: { rows: rows({ 3: { on: true, u: 'Just now' } }) }, toasts: [T('success', 'circle-check', 'Article published', '"Winter checklist for timber windows" is now online.')] }), clock: true, name: 'Published', trigger: '200: the row updates in place and a toast confirms.' }),
|
||||
withM({ ...L({ toasts: [T('error', 'circle-x', 'Couldn\'t unpublish', 'The server didn\'t respond. Nothing changed.', 'Retry')] }), name: 'Action failed', trigger: 'A 500 or network error. The row stays as it was, and the toast stays until retried or dismissed.' }),
|
||||
withM({ ...L({ list: { counts: [5, 4, 1], rows: rows().filter((r, i) => i !== 1) }, toasts: [T('info', 'info', 'That article no longer exists', 'It was deleted somewhere else, so we removed it from the list.')] }), name: 'Row vanished (404)', trigger: 'Any row action gets a 404.' }),
|
||||
withM({ ...L({ modal: del(false) }), name: 'Delete: confirm', trigger: 'Delete… from the row menu.' }),
|
||||
@@ -309,7 +310,7 @@ class Component extends DCLogic {
|
||||
Mp({ upload: upT([rowOk('kitchen-roof.jpg', '2.2 MB', 2), { n: 'porch-steps.jpg', sz: '2.6 MB', st: 'prog', p: '1.1 of 2.6 MB', pct: '42%', im: 11 }, rowDup, rowRej, rowFail, { n: 'attic.jpg', sz: '1.9 MB', st: 'wait', im: 3 }]), name: 'Upload: several files, mixed', trigger: 'Upload button or drop, several files. Each file has its own row and progress. Files upload two at a time. A row can end as added, "Already in your library", rejected in the browser, or failed. Rejected and failed rows stay until dismissed.' }),
|
||||
Mp({ upload: upT([rowOk('kitchen-roof.jpg', '2.2 MB', 2), rowOk('porch-steps.jpg', '2.6 MB', 11), rowDup, rowRej, rowFail], { title: 'Upload finished', sub: '5 images', done: true, summary: '2 uploaded · 1 already in your library · 2 need attention' }), name: 'Upload: summary', trigger: 'All rows settled. The summary counts each outcome. "Use existing" selects the existing image and drops the row; "Upload anyway" stores a second copy, which then appears under Duplicates. "Try again" retries one file. Announced when it finishes.' }),
|
||||
Mp({ upload: upT([rowRej, { n: 'notes.pdf', sz: '210 KB', st: 'rej', title: 'notes.pdf isn\'t an image', msg: 'Images can be up to 10 MB. Use a PNG, JPEG, WEBP or SVG image.' }, { n: 'broken.png', sz: '3.0 MB', st: 'rej', title: 'broken.png couldn\'t be read', msg: 'The server couldn\'t read this image. Save it again as a PNG or JPEG, then try again.' }], { title: 'Nothing was uploaded', sub: '3 files were rejected', done: true }), name: 'Upload: rejected files (413, 400)', trigger: 'Type and size are checked in the browser first, so nothing is sent. A server 413 or 400 for a file that got through shows the same row with its own message.' }),
|
||||
Mp({ limited: true, name: 'Upload: too many requests (429)', trigger: 'Rate limited. Retry-After is counted down in the button. It re-enables at zero. Already-added files are kept.' }),
|
||||
Mp({ limited: true, clock: true, name: 'Upload: too many requests (429)', trigger: 'Rate limited. Retry-After is counted down in the button. It re-enables at zero. Already-added files are kept.' }),
|
||||
Mp({ tab: 'dups', name: 'Duplicates: groups', trigger: 'The Duplicates tab. Groups of identical images side by side, each with its uploader, upload date and "Used in N articles". The suggested copy to keep is the most used, then the oldest.' }),
|
||||
Mp({ tab: 'dups', modal: { k: 'purge' }, name: 'Purge duplicates: confirm', trigger: '"Purge duplicates" in the toolbar, next to the sort menu (in the stacked toolbar on mobile). Opens the same dialog as Settings; every other state is on the Media cleanup board.' }),
|
||||
Mp({ tab: 'dups', member: true, name: 'Duplicates: no purge button (member)', trigger: 'Verified users who are not owner or admin. The "Purge duplicates" button is hidden. "Merge into this one" is unchanged.' }),
|
||||
@@ -433,16 +434,16 @@ class Component extends DCLogic {
|
||||
withM({ ...U({ modal: { title: 'Cancel this invite?', desc: 'The link sent to sam.oliveira@renew.example will stop working. You can invite the address again later.', cancel: 'Keep invite', confirm: 'Cancel invite', tone: 'danger', notice: 'Couldn\'t cancel the invite. Try again in a moment.' } }), name: 'Cancel invite: failed', trigger: '500 or network failure. The dialog stays open.' }),
|
||||
withM({ ...U({ users: { rows: people.slice(1) }, toasts: [T('success', 'circle-check', 'Invite cancelled', 'sam.oliveira@renew.example can no longer use the link.')] }), name: 'Cancel invite: done', trigger: '204: the row disappears and a toast confirms.' }),
|
||||
withM({ ...U({ users: { rows: people.map(p => p.st === 'expired' ? { ...p, busy: 'Renewing…' } : p) } }), name: 'Renew invite: sending', trigger: '"Renew invite" on an Expired row. No confirmation, since it only sends an email. The button shows progress and is disabled.' }),
|
||||
withM({ ...U({ users: { rows: [{ ...people[1], st: 'pending', c: 'Today', hl: true }, ...people.filter((p, i) => i !== 1)] }, toasts: [T('success', 'circle-check', 'Invite renewed', 'p.huber@gmail.com has 3 days to accept the new link.')] }), name: 'Renew invite: done', trigger: '200: the row becomes Pending with today\'s date, moves to the top, and the old link stops working.' }),
|
||||
withM({ ...U({ users: { rows: [{ ...people[1], st: 'pending', c: 'Today', hl: true }, ...people.filter((p, i) => i !== 1)] }, toasts: [T('success', 'circle-check', 'Invite renewed', 'p.huber@gmail.com has 3 days to accept the new link.')] }), clock: true, name: 'Renew invite: done', trigger: '200: the row becomes Pending with today\'s date, moves to the top, and the old link stops working.' }),
|
||||
withM({ ...U({ toasts: [T('error', 'circle-x', 'Couldn\'t renew the invite', 'Try again in a moment.')] }), name: 'Renew invite: failed', trigger: '500 or network failure. The row stays Expired and the button can be pressed again.' }),
|
||||
withM({ ...U({ users: { rows: [{ email: 'lea.martin@renew.example', role: 'admin', st: 'pending', c: 'Today', hl: true }, ...people] } , toasts: [T('success', 'circle-check', 'Invite sent', 'lea.martin@renew.example has 3 days to accept.')] }), name: 'Invite sent', trigger: '201: the dialog closes, the new invite appears at the top as Pending, and a toast confirms.' })
|
||||
] },
|
||||
{ label: 'Settings', title: 'Settings: Updates', route: '/settings', api: 'GET /v1/system/update · POST …/check · POST …/download · POST …/update/restart (all pending)', note: 'The page is one column of cards, top to bottom: Version, Updates, Media cleanup, Retention (owner only). The screens below are scrolled to the card they show. New Updates card under Version, owner only. Update downloads and checks the new version and changes nothing. Restart installs it and restarts the site (about 10 seconds). "Restart later" is simply leaving the card or the dialog. A plain Restart shows only when no update is waiting. Admins see the version and a note. When the server isn\'t prepared, the card shows the notice and a "How to update" link with no buttons. Progress is a progressbar with a value, announced at 25% steps and when each step changes. The card is a polite live region. After the restart the page reloads, focus lands on the "Updates" heading, and a toast confirms. Buttons are disabled while any step runs, and any owner on any device sees the same state from the server. Cue elsewhere: see Home. Out of scope: choosing versions, downgrades, automatic updates, update history.', states: [
|
||||
SU({ icon: 'circle-check', tone: 'success', title: 'You\'re up to date', text: 'You have the latest version of RenewCMS.', meta: LAST, btns: [B('Check for updates', 'sec', 'refresh-cw'), B('Restart', 'sec', 'power')], hint: RESTART_FOOT }, { name: 'Up to date', trigger: 'Opened with no newer version. Restart is available but not emphasised.' }),
|
||||
SU({ icon: 'circle-check', tone: 'success', title: 'You\'re up to date', text: 'You have the latest version of RenewCMS.', meta: LAST, btns: [B('Check for updates', 'sec', 'refresh-cw'), B('Restart', 'sec', 'power')], hint: RESTART_FOOT }, { clock: true, name: 'Up to date', trigger: 'Opened with no newer version. Restart is available but not emphasised.' }),
|
||||
SU({ icon: 'loader-circle', title: 'Checking for updates…', text: 'This usually takes a few seconds.', btns: [B('Checking…', 'sec', 'loader-circle', true)] }, { name: 'Checking', trigger: 'Check for updates pressed, or the first open after 24 hours. Announced as "Checking for updates". Buttons are disabled.' }),
|
||||
SU({ icon: 'wifi-off', tone: 'warning', title: 'Couldn\'t check for updates', text: 'RenewCMS couldn\'t reach the update server. Check your connection and try again. Your site isn\'t affected.', meta: 'Last checked yesterday at 18:12', btns: [B('Try again', 'primary', 'rotate-cw'), B('Restart', 'sec', 'power')], hint: RESTART_FOOT }, { name: 'Check failed', trigger: 'Offline, or the update server didn\'t answer (500, network). The last known result is kept.' }),
|
||||
SU({ icon: 'circle-arrow-up', tone: 'info', title: 'Version 2.3.0 is available', text: 'You have v2.2.0-beta. Update downloads the new version and checks it. Nothing changes until you restart.', meta: LAST, notes: NOTES(false), btns: [B('Update', 'primary', 'download')], hint: 'Takes about 2 minutes. Your site stays online.' }, { name: 'Update available', trigger: 'A newer version exists. Release notes are collapsed. Update is the one primary button.', navBadge: true }),
|
||||
SU({ icon: 'circle-arrow-up', tone: 'info', title: 'Version 2.3.0 is available', text: 'You have v2.2.0-beta. Update downloads the new version and checks it. Nothing changes until you restart.', meta: LAST, notes: NOTES(true), btns: [B('Update', 'primary', 'download')], hint: 'Takes about 2 minutes. Your site stays online.' }, { name: 'Update available, notes open', trigger: '"What\'s new in 2.3.0" expanded. It is a button with aria-expanded, and the list is plain text.', navBadge: true }),
|
||||
SU({ icon: 'wifi-off', tone: 'warning', title: 'Couldn\'t check for updates', text: 'RenewCMS couldn\'t reach the update server. Check your connection and try again. Your site isn\'t affected.', meta: 'Last checked yesterday at 18:12', btns: [B('Try again', 'primary', 'rotate-cw'), B('Restart', 'sec', 'power')], hint: RESTART_FOOT }, { clock: true, name: 'Check failed', trigger: 'Offline, or the update server didn\'t answer (500, network). The last known result is kept.' }),
|
||||
SU({ icon: 'circle-arrow-up', tone: 'info', title: 'Version 2.3.0 is available', text: 'You have v2.2.0-beta. Update downloads the new version and checks it. Nothing changes until you restart.', meta: LAST, notes: NOTES(false), btns: [B('Update', 'primary', 'download')], hint: 'Takes about 2 minutes. Your site stays online.' }, { clock: true, name: 'Update available', trigger: 'A newer version exists. Release notes are collapsed. Update is the one primary button.', navBadge: true }),
|
||||
SU({ icon: 'circle-arrow-up', tone: 'info', title: 'Version 2.3.0 is available', text: 'You have v2.2.0-beta. Update downloads the new version and checks it. Nothing changes until you restart.', meta: LAST, notes: NOTES(true), btns: [B('Update', 'primary', 'download')], hint: 'Takes about 2 minutes. Your site stays online.' }, { clock: true, name: 'Update available, notes open', trigger: '"What\'s new in 2.3.0" expanded. It is a button with aria-expanded, and the list is plain text.', navBadge: true }),
|
||||
SU({ icon: 'download', tone: 'info', title: 'Downloading version 2.3.0…', text: 'You can leave this page. The download continues and your site stays online.', pct: 42, barLabel: 'Downloading version 2.3.0', barText: '42% · 11 of 26 MB', btns: [B('Updating…', 'sec', 'loader-circle', true)] }, { name: 'Downloading', trigger: 'Update pressed (202). Progress polls every second. Announced as "Downloading, 25 percent" and so on, never every tick.', navBadge: true }),
|
||||
SU({ icon: 'shield-check', tone: 'info', title: 'Checking the download…', text: 'Making sure the file is complete and safe to install. This takes a few seconds.', indet: true, barLabel: 'Checking the download', barText: 'Almost there', btns: [B('Updating…', 'sec', 'loader-circle', true)] }, { name: 'Verifying', trigger: 'The download finished. No percentage, so the bar is indeterminate. Announced as "Checking the download".', navBadge: true }),
|
||||
SU({ icon: 'circle-check', tone: 'success', title: 'Version 2.3.0 is ready to install', text: 'The download is checked. Restart to install it. Your site will be unavailable for about 10 seconds.', meta: 'Nothing has changed yet.', notes: NOTES(false), btns: [B('Restart', 'primary', 'power')], hint: 'Not now? Leave this page. The update waits here, and the banner on Home reminds you.' }, { name: 'Ready to install', trigger: 'The file passed its checks. Restart becomes the primary action. "Restart later" is closing the dialog or leaving the page.', navBadge: true }),
|
||||
@@ -450,19 +451,19 @@ class Component extends DCLogic {
|
||||
SU({ icon: 'circle-check', tone: 'success', title: 'Version 2.3.0 is ready to install', text: 'The download is checked. Restart to install it. Your site will be unavailable for about 10 seconds.', meta: 'Nothing has changed yet.', btns: [B('Restart', 'primary', 'power')] }, { name: 'Restart: request failed', trigger: '500 or network failure before the restart began. The site is still running.', navBadge: true, modal: installModal({ notice: 'Couldn\'t start the restart. Nothing was changed and your site is still running. Try again in a moment.' }) }),
|
||||
SU({ icon: 'circle-check', tone: 'success', title: 'Version 2.3.0 is ready to install', text: '', btns: [] }, { name: 'Restarting to install', trigger: 'Full-page overlay, so it survives the server going away. The page polls until the site answers, then reloads. It is a live region announced once as "Installing version 2.3.0. Your site is restarting."', overlay: { title: 'Installing version 2.3.0', text: 'Your site is restarting with the new version. This takes about 10 seconds. This page reloads on its own when the site is back.', meta: 'Started at 09:51', barL: '30%' } }),
|
||||
SU({ icon: 'circle-check', tone: 'success', title: '', text: '', btns: [] }, { name: 'Restarting, taking longer', trigger: 'No answer after 60 seconds. The copy changes and a manual reload appears. The overlay stays until the site is back.', overlay: { title: 'Still restarting', text: 'This is taking longer than usual. Keep this page open. It reloads on its own when the site is back, or you can reload it now.', meta: 'Started at 09:51 · 1 minute ago', btn: 'Reload page', barL: '65%' } }),
|
||||
SU({ icon: 'circle-check', tone: 'success', title: 'You\'re on version 2.3.0', text: 'The update is installed and your site is running again.', meta: 'Updated today at 09:52', btns: [B('Check for updates', 'sec', 'refresh-cw'), B('Restart', 'sec', 'power')], hint: RESTART_FOOT, link: 'Read the release notes', v: 'v2.3.0', focus: '2px solid var(--ui-primary)', toasts: undefined }, { name: 'Update done', trigger: 'The page reloaded on the new version. Focus lands on the "Updates" heading (ring shown), the toast is announced, and the version updates.', toasts: [T('success', 'circle-check', 'Update installed', 'RenewCMS is now on version 2.3.0.')] }),
|
||||
SU({ icon: 'circle-check', tone: 'success', title: 'You\'re on version 2.3.0', text: 'The update is installed and your site is running again.', meta: 'Updated today at 09:52', btns: [B('Check for updates', 'sec', 'refresh-cw'), B('Restart', 'sec', 'power')], hint: RESTART_FOOT, link: 'Read the release notes', v: 'v2.3.0', focus: '2px solid var(--ui-primary)', toasts: undefined }, { clock: true, name: 'Update done', trigger: 'The page reloaded on the new version. Focus lands on the "Updates" heading (ring shown), the toast is announced, and the version updates.', toasts: [T('success', 'circle-check', 'Update installed', 'RenewCMS is now on version 2.3.0.')] }),
|
||||
SU({ icon: 'triangle-alert', tone: 'warning', title: 'The update didn\'t install', text: 'RenewCMS put the previous version back, so your site is running as before. Nothing was lost.', meta: 'What to do next: try again in a few minutes. If it fails again, ask whoever manages your server to check the update.', btns: [B('Try again', 'primary', 'rotate-cw')] }, { name: 'Update failed, rolled back', trigger: 'The site came back on v2.2.0-beta. The error toast stays until dismissed and the card keeps the explanation.', toasts: [T('error', 'circle-x', 'The update didn\'t install', 'Your site is back on v2.2.0-beta.')] }),
|
||||
SU({ icon: 'circle-x', tone: 'error', title: 'The download didn\'t finish', text: 'The connection dropped before the file was complete. Nothing was changed and your site is running as before.', btns: [B('Try again', 'primary', 'rotate-cw')] }, { name: 'Download failed', trigger: 'A 500, or the connection dropped. Focus moves to the card heading. Try again restarts the download.', navBadge: true }),
|
||||
SU({ icon: 'triangle-alert', tone: 'warning', title: 'We didn\'t use this update', text: 'The file didn\'t pass our safety check, so it was not installed. Nothing was changed. Try downloading it again. If this keeps happening, contact RenewCMS support.', btns: [B('Try again', 'primary', 'rotate-cw')] }, { name: 'Verification failed', trigger: 'The downloaded file isn\'t trusted. The tone is a warning, and the file is thrown away. Technical detail goes to the server log, not the screen.', navBadge: true }),
|
||||
SU({ icon: 'info', tone: 'info', title: 'Too many tries', text: 'Wait a moment before trying again.', btns: [B('Try again in 0:42', 'sec', 'clock', true)] }, { name: 'Too many requests (429)', trigger: 'Retry-After counted down in the button. It re-enables at zero.' }),
|
||||
SU({ icon: 'info', tone: 'info', title: 'Too many tries', text: 'Wait a moment before trying again.', btns: [B('Try again in 0:42', 'sec', 'clock', true)] }, { clock: true, name: 'Too many requests (429)', trigger: 'Retry-After counted down in the button. It re-enables at zero.' }),
|
||||
SU(notPrep(false), { name: 'Not prepared', trigger: 'The server can\'t update from the browser. No buttons, not even Restart. The command is for the person who runs the server and is a single line. The placeholder command is subject to the real one.', navBadge: true }),
|
||||
SU(notPrep(true), { name: 'Not prepared: command copied', trigger: 'Copy pressed. The button reads "Copied" for 2 s and it is announced.', navBadge: true }),
|
||||
SU({ icon: 'circle-check', tone: 'success', title: 'You\'re up to date', text: 'You have the latest version of RenewCMS.', meta: LAST, btns: [B('Check for updates', 'sec', 'refresh-cw'), B('Restart', 'sec', 'power')], hint: RESTART_FOOT }, { name: 'Plain restart: confirm', trigger: 'Restart with no update waiting. Nothing is installed.', modal: restartModal() }),
|
||||
SU({ icon: 'circle-check', tone: 'success', title: 'You\'re up to date', text: 'You have the latest version of RenewCMS.', meta: LAST, btns: [B('Check for updates', 'sec', 'refresh-cw'), B('Restart', 'sec', 'power')], hint: RESTART_FOOT }, { clock: true, name: 'Plain restart: confirm', trigger: 'Restart with no update waiting. Nothing is installed.', modal: restartModal() }),
|
||||
SU({ icon: 'circle-check', tone: 'success', title: '', text: '', btns: [] }, { name: 'Plain restart: restarting', trigger: 'Same overlay as the update restart with shorter copy.', overlay: { title: 'Restarting your site', text: 'This takes about 10 seconds. This page reloads on its own when the site is back.', meta: 'Started at 09:51', barL: '30%' } }),
|
||||
SU({ icon: 'circle-check', tone: 'success', title: 'Your site is back', text: '', btns: [] }, { name: 'Plain restart: done', trigger: 'The page reloaded. Focus lands on the "Updates" heading.', toasts: [T('success', 'circle-check', 'Site restarted', 'Your site is running again.')] }),
|
||||
SU({ icon: 'circle-arrow-up', tone: 'info', title: 'Version 2.3.0 is available', text: 'You have v2.2.0-beta. Only the owner can update RenewCMS. Ask the owner of this site.', meta: LAST }, { noRet: true, name: 'Admin view', trigger: 'Admins see the version and the note, with no buttons. Up to date: "You\'re up to date" and "Only the owner can update or restart the site." No Home banner or Settings pill.' }),
|
||||
SU({ icon: 'circle-arrow-up', tone: 'info', title: 'Version 2.3.0 is available', text: 'You have v2.2.0-beta. Only the owner can update RenewCMS. Ask the owner of this site.', meta: LAST }, { noRet: true, name: 'Role changed (403)', trigger: 'An owner was changed to admin while the page was open and pressed a button. The card reloads in the admin view.', toasts: [T('error', 'circle-x', 'Only the owner can do this', 'Ask the owner of this site to update RenewCMS.')] }),
|
||||
SU({ icon: 'circle-arrow-up', tone: 'info', title: 'Version 2.3.0 is available', text: 'You have v2.2.0-beta. Update downloads the new version and checks it. Nothing changes until you restart.', meta: LAST, btns: [B('Update', 'primary', 'download', true)] }, { name: 'Update already running (409)', trigger: 'Another owner started it. The card switches to that run\'s progress.', navBadge: true, toasts: [T('info', 'info', 'An update is already running', 'Another owner started it. Progress shows here.')] })
|
||||
SU({ icon: 'circle-arrow-up', tone: 'info', title: 'Version 2.3.0 is available', text: 'You have v2.2.0-beta. Only the owner can update RenewCMS. Ask the owner of this site.', meta: LAST }, { noRet: true, clock: true, name: 'Admin view', trigger: 'Admins see the version and the note, with no buttons. Up to date: "You\'re up to date" and "Only the owner can update or restart the site." No Home banner or Settings pill.' }),
|
||||
SU({ icon: 'circle-arrow-up', tone: 'info', title: 'Version 2.3.0 is available', text: 'You have v2.2.0-beta. Only the owner can update RenewCMS. Ask the owner of this site.', meta: LAST }, { noRet: true, clock: true, name: 'Role changed (403)', trigger: 'An owner was changed to admin while the page was open and pressed a button. The card reloads in the admin view.', toasts: [T('error', 'circle-x', 'Only the owner can do this', 'Ask the owner of this site to update RenewCMS.')] }),
|
||||
SU({ icon: 'circle-arrow-up', tone: 'info', title: 'Version 2.3.0 is available', text: 'You have v2.2.0-beta. Update downloads the new version and checks it. Nothing changes until you restart.', meta: LAST, btns: [B('Update', 'primary', 'download', true)] }, { clock: true, name: 'Update already running (409)', trigger: 'Another owner started it. The card switches to that run\'s progress.', navBadge: true, toasts: [T('info', 'info', 'An update is already running', 'Another owner started it. Progress shows here.')] })
|
||||
], tableTitle: 'Status to copy', table: [
|
||||
{ a: '200', b: 'Check, up to date', c: '"You\'re up to date" / "You have the latest version of RenewCMS." / "Last checked today at 09:41"' },
|
||||
{ a: '200', b: 'Check, newer version', c: '"Version 2.3.0 is available" / "You have v2.2.0-beta. Update downloads the new version and checks it. Nothing changes until you restart."' },
|
||||
@@ -580,7 +581,7 @@ class Component extends DCLogic {
|
||||
im('I8', 'Snippet copied', 'Copy pressed on the snippet. The label reads Copied for 2 s and is announced politely.', { v: 'many', tab: 'js', copied: true }),
|
||||
im('I9', 'URL builder copied', 'Copy pressed on the built URL.', { v: 'many', variant: 'card', urlCopied: true })
|
||||
] }
|
||||
return [...bo, imgG, navG, ...vg.map(adapt)]
|
||||
return [...bo, ...this.dirGroups(bo), imgG, navG, ...vg.map(adapt)]
|
||||
}
|
||||
cell(t, i, o = {}) { return { t, fg: i === 0 && !o.plain ? 'var(--ui-text-highlighted)' : o.muted ? 'var(--ui-text-muted)' : 'var(--ui-text-toned)', fw: i === 0 && !o.plain ? 500 : 400, ff: o.mono ? "'IBM Plex Mono',monospace" : "'IBM Plex Sans',sans-serif", fs: o.mono ? '12px' : '13px' } }
|
||||
tbl(title, cols, tpl, rows, o = {}) { return { title, cols, tpl, rows: rows.map(r => r.map((t, i) => this.cell(t, i, { mono: o.mono && o.mono.includes(i), muted: t === '–' }))) } }
|
||||
@@ -831,6 +832,213 @@ class Component extends DCLogic {
|
||||
{ label: 'Vocabulary and accessibility', title: 'Vocabulary and accessibility', note: 'Use "variant", "generated" and "purge" everywhere. Never "thumbnail preset", "version" or "rendition". Delete removes a variant definition; purge removes only its generated files. Every status is an icon plus text: Generated (circle-check), Not generated (circle-dashed), Generating (loader), Failed (circle-x), Enabled (circle-check), Disabled (circle-off). The comparison slider has a visible focus ring and works from the keyboard. Dialogs trap focus and return it to the opener. Progress is announced with aria-live="polite". Touch targets on mobile are at least 44 px. The only token not already in the system is --rc-on-sand (dark ink on the sand selection color), used for the active nav item in both themes.', tables: [] }
|
||||
]
|
||||
}
|
||||
// DIR-START
|
||||
dirDoc() {
|
||||
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 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.', [
|
||||
S('Default', 'The list loaded. Four sites, Renew is the default. Owner only.', 'Sites · The frontends that display your content. · Add site · Site · Base URL · Articles · Default · Noindex · 268 articles · 38 articles · 6 articles · No articles', { v: 'sites', sites: { state: 'ok' } }),
|
||||
S('Only the default site', 'First run: the seeded site only. A hint under the list, no empty-state wall.', 'Add another site if your content is shown on more than one frontend. Each article belongs to one site.', { v: 'sites', sites: { state: 'ok', only: true } }),
|
||||
S('Loading', 'The first load, or a retry. Skeleton rows appear after 150 ms.', '', { v: 'sites', sites: { state: 'loading' } }),
|
||||
S('Error', 'A 500 or network failure on the list request.', 'Couldn\'t load sites · Check your connection and try again. · Try again', { v: 'sites', sites: { state: 'error' } }),
|
||||
S('Row menu', 'The ellipsis button, or Enter on the focused menu button. Mobile: bottom sheet titled with the site name.', 'Edit · Set as default · Delete…', { v: 'sites', sites: { state: 'ok', menu: 1 } }),
|
||||
S('Default site: delete unavailable', 'Menu on the default site. "Delete…" is aria-disabled, stays focusable and explains itself: tooltip on desktop, text in the sheet on touch.', 'The default site can\'t be deleted. Make another site the default first.', { v: 'sites', sites: { state: 'ok', menu: 0 }, tip: true }),
|
||||
S('Set as default: confirm', '"Set as default" in the row menu. On 200 the badge moves and a toast confirms. The button reads "Saving…" while the request runs.', 'Make Journal the default site? · New articles start on this site. Articles that are already on other sites stay where they are. · Cancel · Make default · Default site changed · "Journal" is now the default site.', { v: 'sites', sites: { state: 'ok' }, modal: { k: 'setdef', i: 1 } }),
|
||||
S('Hidden entry (admin)', 'An admin opens Settings. The sidebar has no Publishing group and no Sites entry, and the page has no Publishing card. Roles without Settings have nothing to hide.', 'No Publishing group · No Sites entry · No "Manage sites" row', { v: 'adminSettings' }),
|
||||
S('Forbidden (403)', 'A role other than owner opens /settings/sites from a bookmark or a link. The sidebar has no Sites entry, so the page explains instead of redirecting. The API answers 403.', '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', { v: 'sites', forbidden: true, role: 'admin', fab: false })
|
||||
]),
|
||||
G('Sites: form', 'Sites: add and edit', 'slide-over (desktop) · 92% bottom sheet (mobile)', 'POST /v1/site/ · PATCH /v1/site/{id}', 'Owner only. A slide-over, because the form has a live preview (the canonical URL). Fields: name, key, base URL, default social image (media picker), default robots policy, optional locale, default switch. The key is set when the site is added and can\'t be edited afterwards: the edit form shows it read-only with a lock and the text "Used in API paths, so it can\'t change." Closing with changes asks "Discard changes?" with the existing discard dialog. The slug field, media field and picker are the shared ones from DirectoryFrame (the picker is drawn once, in Authors: form). Locale error: "Use a language tag like en or fr-CH."', [
|
||||
S('Add: empty', '"Add site" in the page header, or the bottom button on mobile.', 'Add site · Name · e.g. Journal · Key · Generated from the name · Base URL · https://blog.example.com · The public address of the frontend. Use https and no path. · Default social image (optional) · Choose image · Used when an article has no image of its own. · Default robots policy · Index · Noindex · Search engines may list this site\'s pages. · Locale (optional) · e.g. en or fr-CH · A language tag. Leave empty if the site has no single language. · Make this the default site · New articles start here. The current default is Renew. · Canonical URL preview · Cancel · Add site', { v: 'sites', sites: { state: 'ok' }, panel: { k: 'site', mode: 'add', focus: 'name' } }),
|
||||
S('Add: key from the name', 'Typing a name fills the key. The pencil unlocks it until the site is added.', 'journal · Auto · Lowercase letters, numbers and hyphens. Used in API paths. It can\'t be changed after the site is added.', { v: 'sites', sites: { state: 'ok' }, panel: { k: 'site', mode: 'add', focus: 'url', f: { name: 'Journal', key: 'journal', keyMode: 'auto' } } }),
|
||||
S('Add: URL not valid', 'On blur or submit. Nothing is sent. A URL with a path and text that is not a URL get their own messages, drawn the same way.', 'Use https. For example https://blog.example.com. · Remove the path. Use only the host, like https://blog.example.com. · Enter a full URL, like https://blog.example.com.', { v: 'sites', sites: { state: 'ok' }, panel: { k: 'site', mode: 'add', f: { name: 'Journal', key: 'journal', keyMode: 'auto', url: 'http://journal.renew.example' }, err: { url: 'Use https. For example https://blog.example.com.' } } }),
|
||||
S('Add: duplicate key', '409 on submit, or on blur after a check.', 'A site with the key "journal" already exists. Choose another key. · Use journal-2', { v: 'sites', sites: { state: 'ok' }, panel: { k: 'site', mode: 'add', f: { name: 'Journal', key: 'journal', keyMode: 'manual', url: 'https://journal2.renew.example' }, err: { key: 'A site with the key "journal" already exists. Choose another key.', keySuggest: 'journal-2' } } }),
|
||||
S('Add: save failed', '500 or network failure. The values stay.', 'Couldn\'t save the site. Your changes are still here. Try again in a moment.', { v: 'sites', sites: { state: 'ok' }, panel: { k: 'site', mode: 'add', notice: 'Couldn\'t save the site. Your changes are still here. Try again in a moment.', f: { name: 'Journal', key: 'journal', keyMode: 'auto', url: 'https://journal.renew.example' } } }),
|
||||
S('Edit: default site', 'Edit on the default site. The key is read-only with a lock, because it is used in API paths. The switch is on and locked.', 'Edit Renew · Key · renew · Used in API paths, so it can\'t change. · Default site · New articles start on the default site. To change it, make another site the default. · Save changes', { v: 'sites', sites: { state: 'ok' }, panel: { k: 'site', mode: 'edit', i: 0, f: { name: 'Renew', key: 'renew', keyMode: 'saved', url: 'https://renew.example', img: 'north-wall.jpg', loc: 'en' } } }),
|
||||
S('Edit: other site, noindex', 'Edit on a non-default site.', 'Edit Projects · Key · projects · Used in API paths, so it can\'t change. · Noindex · Asks search engines not to list this site\'s pages. · Make this the default site', { v: 'sites', sites: { state: 'ok' }, panel: { k: 'site', mode: 'edit', i: 2, f: { name: 'Projects', key: 'projects', keyMode: 'saved', url: 'https://projects.renew.example', img: 'kitchen-after.webp', robots: 'Noindex', loc: 'fr' } } })
|
||||
]),
|
||||
G('Sites: delete', 'Sites: delete', 'dialog (desktop) · bottom sheet (mobile)', 'DELETE /v1/site/{id}?reassign_to={id}', 'Owner only. A site with articles cannot be deleted without a destination: the dialog has a select and shows how many articles move. Destination defaults to the default site. Without reassign_to the server answers 409. This is the shared ReassignDialog from DirectoryFrame; its failed state is drawn once, in Authors: delete.', [
|
||||
S('With articles', '"Delete…" on a site that has articles. The button reads "Moving articles…" while the request runs. On 204 the row leaves the list and a toast reads "Site deleted" · "38 articles moved to Renew."', 'Delete this site? · "Journal" will be deleted. 38 articles belong to it, so choose a site to move them to. This can\'t be undone. · Move articles to · Renew · renew.example · 38 articles will move to Renew. Their canonical host changes to renew.example. · Cancel · Move 38 articles and delete', { v: 'sites', sites: { state: 'ok' }, modal: { k: 'del', i: 1 } }),
|
||||
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('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' }),
|
||||
S('Error', '500 or network failure.', 'Couldn\'t load authors · Check your connection and try again. · Try again', { v: 'authors', list: 'error' }),
|
||||
S('Empty, first run', 'Zero authors. Toolbar hidden, one call to action. Other roles see the same text without the button.', 'No authors yet · Authors are the names readers see on articles. They don\'t need an account. · Add author', { v: 'authors', list: 'empty' }),
|
||||
S('No results', 'Search matched nothing.', 'No authors match "zoe" · Check the spelling, or search for a shorter part of the name. · Clear search', { v: 'authors', q: 'zoe' }),
|
||||
S('Row menu', 'Owner or admin. The ellipsis button. Mobile: bottom sheet.', 'Edit · Delete…', { v: 'authors', menu: 2 }),
|
||||
S('Read-only list (other roles)', 'Any role except owner and admin. No Add author button, no row menu and no bottom button on mobile. Search still works. A row opens the read-only detail.', 'Authors · The names readers see on your articles. Only an owner or admin can change authors. · Search by name or slug · Author · Slug · Account · Articles', { v: 'authors', ro: true, role: 'user' }),
|
||||
S('Read-only detail', 'A row was opened by a role without author rights. Same panel width as the form, no fields, Close as the only action. The account email is not shown.', 'Inès Duarte · Linked · 9 articles · Bio · Website · Only an owner or admin can change this author. · Close', { v: 'authors', ro: true, role: 'user', panel: { k: 'authorView', i: 0 } })
|
||||
]),
|
||||
G('Authors: form', 'Authors: add and edit', 'slide-over (desktop) · 92% bottom sheet (mobile)', 'POST /v1/author/ · PATCH /v1/author/{id}', 'Owner and admin. A slide-over, because the form has a live preview (the byline). Fields: avatar (media picker), name, slug (auto, editable), bio with counter, website, social links (X, LinkedIn, GitHub, Mastodon, Custom; up to 5), linked user (only users without an author). Slug field, media field and picker are the shared ones from DirectoryFrame. Saving shows "Saving…" in the button, as in the other forms.', [
|
||||
S('Add: empty', '"Add author". Owner or admin.', 'Add author · Avatar (optional) · Choose image · Name · e.g. Camille Roche · Slug · Generated from the name · Bio (optional) · A sentence or two about the author. · 0 / 280 · Website (optional) · https://example.com · Social links (optional) · Add link · 0 of 5 · Linked user (optional) · No linked user · Link a CMS user if this author has an account. Guest writers need no link. · Byline preview · Author name · The bio appears here. · Cancel · Add author', { v: 'authors', panel: { k: 'author', mode: 'add', focus: 'name' } }),
|
||||
S('Slug from the name', 'Typing a name fills the slug. Auto badge and "Edit slug" pencil.', 'camille-roche · Auto · Generated from the name. Used in public author URLs and API paths. Unique across all authors.', { v: 'authors', panel: { k: 'author', mode: 'add', focus: 'bio', f: { name: 'Camille Roche', slug: 'camille-roche', slugMode: 'auto', bio: 'Freelance writer on lime and stone.' } } }),
|
||||
S('Slug collision', '409 on submit, or on blur after a check.', 'The slug "camille-roche" is already used by another author. · Use camille-roche-2 · Reset to match the name', { v: 'authors', panel: { k: 'author', mode: 'add', f: { name: 'Camille Roche', slug: 'camille-roche', slugMode: 'manual' }, err: { slug: 'The slug "camille-roche" is already used by another author.', suggest: 'camille-roche-2' } } }),
|
||||
S('Validation', 'On submit or blur. Nothing is sent.', 'Enter a name. · Shorten the bio to 280 characters. · 291 / 280 · Enter a full URL starting with https://.', { v: 'authors', panel: { k: 'author', mode: 'add', f: { name: '', bio: 'x'.repeat(291), web: 'example', slugMode: 'empty' }, err: { name: 'Enter a name.', bio: 'Shorten the bio to 280 characters.', web: 'Enter a full URL starting with https://.' } } }),
|
||||
S('Social links', 'Add link adds a row with a type select and a URL. One link is invalid.', 'X · LinkedIn · GitHub · Mastodon · Custom · https://x.com/username · https://www.linkedin.com/in/username · https://github.com/username · https://mastodon.social/@username · https://example.com · Enter a full URL starting with https://. · You can add up to 5 links.', { v: 'authors', panel: { k: 'author', mode: 'edit', i: 2, f: { name: 'Camille Roche', slug: 'camille-roche', slugMode: 'saved', bio: 'Freelance writer on lime and stone.', links: [{ t: 'Mastodon', v: 'https://mastodon.social/@camille' }, { t: 'LinkedIn', v: 'linkedin.com/in/camille', err: 'Enter a full URL starting with https://.' }, { t: 'Custom', v: '', focus: true }] } } }),
|
||||
S('Linked user: choose', 'The select is open. Only users without an author. When none are left it reads "Every user already has an author."', 'Choose a user · Users without an author · Marta Alves · marta@renew.example · Rui Santos · rui@renew.example', { v: 'authors', panel: { k: 'author', mode: 'edit', i: 2, f: { name: 'Camille Roche', slug: 'camille-roche', slugMode: 'saved', uopen: true } } }),
|
||||
S('Edit: linked author', 'Edit on an author linked to a user.', 'Edit Inès Duarte · Linked user (optional) · ines.duarte@renew.example · Unlink · New articles by this user start with this author. · Save changes', { v: 'authors', panel: { k: 'author', mode: 'edit', i: 0, f: { av: 'ines.jpg', name: 'Inès Duarte', slug: 'ines-duarte', slugMode: 'saved', bio: 'Site manager and restoration carpenter. Writes about timber and joinery.', web: 'https://ines-duarte.example', links: [{ t: 'LinkedIn', v: 'https://www.linkedin.com/in/ines-duarte' }], user: 'ines.duarte@renew.example' } } }),
|
||||
S('Avatar: picker', '"Choose image". The existing media picker; shown as a square. The site social image uses this same picker.', 'Choose an image · The avatar for Camille Roche. · Shown as a square. Choose an image with the face or logo near the middle. · Use image', { v: 'authors', panel: { k: 'author', mode: 'edit', i: 2, f: { name: 'Camille Roche', slug: 'camille-roche', slugMode: 'saved' } }, modal: { k: 'pick', n: 'Camille Roche', sel: 1 } }),
|
||||
S('Save failed', '500 or network failure.', 'Couldn\'t save the author. Your changes are still here. Try again in a moment.', { v: 'authors', panel: { k: 'author', mode: 'edit', i: 2, notice: 'Couldn\'t save the author. Your changes are still here. Try again in a moment.', f: { name: 'Camille Roche', slug: 'camille-roche', slugMode: 'saved', bio: 'Freelance writer on lime and stone.' } } }),
|
||||
S('Unlink: confirm', '"Unlink" next to the linked user, owner or admin. On 200 the field returns to "No linked user" and a toast reads "User unlinked" · "Inès Duarte is now a guest author."', 'Unlink Inès Duarte? · Inès Duarte will stay on their articles. New articles by ines.duarte@renew.example will no longer start with this author. · Cancel · Unlink user', { v: 'authors', panel: { k: 'author', mode: 'edit', i: 0, f: { av: 'ines.jpg', name: 'Inès Duarte', slug: 'ines-duarte', slugMode: 'saved', user: 'ines.duarte@renew.example' } }, modal: { k: 'unlink', i: 0 } }),
|
||||
S('Re-link', '"Link user" on a guest author in the row menu or the form. Same list as above.', 'Link Camille Roche to a user? · Only users without an author are listed. · Cancel · Link user', { v: 'authors', modal: { k: 'relink', i: 2 } })
|
||||
]),
|
||||
G('Authors: delete', 'Authors: delete', 'dialog (desktop) · bottom sheet (mobile)', 'DELETE /v1/author/{id}?reassign_to={id|none}', 'Owner and admin. Reassign: the dialog asks who gets the articles, with "Nobody" as the last option, and shows the count in the button. Zero authors are allowed, so Nobody is always valid. The 409 dialog is the server fallback and is drawn once.', [
|
||||
S('Reassign (recommended)', '"Delete…" on an author with articles, owner or admin. The button reads "Reassigning…" while the request runs. On 204 a toast reads "Author deleted" · "3 articles now credit Inès Duarte." A linked author also shows "Linked to ines.duarte@renew.example. The user account isn\'t affected."', 'Delete this author? · "Camille Roche" will be deleted and is credited on 3 articles. This can\'t be undone. · Credit these articles to · Inès Duarte · Where an article has several authors, Camille Roche is removed and the others stay. · Cancel · Delete and reassign 3 articles', { v: 'authors', modal: { k: 'del', i: 2 } }),
|
||||
S('Choose who', 'The select is open. The deleted author is not listed.', 'Nobody · Remove the byline', { v: 'authors', modal: { k: 'del', i: 2, open: true } }),
|
||||
S('Nobody', '"Nobody" chosen.', 'The articles keep any other authors. Articles with only this author will have no byline.', { v: 'authors', modal: { k: 'del', i: 2, none: true } }),
|
||||
S('Failed (shared by site and author delete)', '500 or network failure. Both delete dialogs use this notice.', 'Couldn\'t delete the author · Nothing was changed. Try again in a moment.', { v: 'authors', modal: { k: 'del', i: 2, notice: true } }),
|
||||
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('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'] } }),
|
||||
S('Site select open', 'The select is open. Options show name and host; the default site is first.', 'Renew · renew.example · Journal · journal.renew.example · Projects · Archive', { v: 'editor', metaOpen: true, meta: { sites: true, site: 'Journal', open: 'site', authors: ['Inès Duarte'] } }),
|
||||
S('No authors, with hint', 'The article has no authors and the user has no author profile. The hint is dismissible and does not return once dismissed. "Create my author profile" creates the profile directly for every role (POST /v1/author/me, no dialog), then the author appears in the list and a toast confirms.', 'You don\'t have an author profile yet · Create one to be credited on your articles. · Create my author profile · No authors. This article will have no byline.', { v: 'editor', metaOpen: true, meta: { hint: true, authors: [] } }),
|
||||
S('Many authors', 'Four authors. The first shows "Primary". Each row has a drag handle and a remove button. Long names truncate with an ellipsis and carry the full name in title and aria-label.', 'Primary · Drag to reorder. The first author is the primary byline. · Dr. Alexandra Konstantinopoulou-Whitfield', { v: 'editor', ed: { metaSummary: 'Inès Duarte +3' }, metaOpen: true, meta: { authors: ['Inès Duarte', 'Jonas Pereira', 'Dr. Alexandra Konstantinopoulou-Whitfield', 'Paul Huber'] } }),
|
||||
S('Add author: open', 'The Add an author combobox is open. Authors already on the article are checked and dimmed. "Create author…" is shown only to owner and admin.', 'Search authors · Create author…', { v: 'editor', metaOpen: true, meta: { authors: ['Inès Duarte'], open: 'authors' } }),
|
||||
S('Add author: no match', 'Typing a name that matches nobody. "Create author…" is shown only to owner and admin.', 'No author matches "Zoe Lambert". · Create author "Zoe Lambert"…', { v: 'editor', metaOpen: true, meta: { authors: ['Inès Duarte'], open: 'authors', q: 'Zoe Lambert' } }),
|
||||
S('Add author: open (no create)', 'A role without author rights. The combobox lists the authors with no "Create author…" row, because that row is shown only to owner and admin.', 'Search authors', { v: 'editor', role: 'user', metaOpen: true, meta: { authors: ['Inès Duarte'], open: 'authors', noCreate: true } }),
|
||||
S('Add author: no match (no create)', 'A role without author rights types a name that matches nobody. Only the message shows; there is no create link, because "Create author…" is shown only to owner and admin.', 'No author matches "Zoe Lambert".', { v: 'editor', role: 'user', metaOpen: true, meta: { authors: ['Inès Duarte'], open: 'authors', q: 'Zoe Lambert', noCreate: true } }),
|
||||
S('Add author: error', 'GET /v1/author/ failed. The article keeps its authors.', 'Couldn\'t load authors · Check your connection and try again. · Try again', { v: 'editor', metaOpen: true, meta: { authors: ['Inès Duarte'], open: 'authors', state: 'error' } }),
|
||||
S('Reorder: keyboard', 'Space or Enter on the handle grabs the row. Arrow keys move it.', 'Jonas Pereira grabbed. Position 2 of 3. Use the up and down arrow keys to move, Space to drop, Escape to cancel.', { v: 'editor', metaOpen: true, meta: { authors: ['Inès Duarte', 'Jonas Pereira', 'Camille Roche'], grab: 1 } }),
|
||||
S('Reorder: pointer', 'Dragging a row. The row lifts and a line shows the drop position.', '', { v: 'editor', metaOpen: true, meta: { authors: ['Inès Duarte', 'Jonas Pereira', 'Camille Roche'], drag: 2, drop: 0 } }),
|
||||
S('Create author: dialog', '"Create author…" in the dropdown, owner and admin only. The writer stays in the editor.', 'Create author · Adds a byline for this article. You can add a bio and avatar later in Authors. · Name · Slug · Zoe Lambert · zoe-lambert · Cancel · Create author', { v: 'editor', metaOpen: true, meta: { authors: ['Inès Duarte'] }, modal: { name: 'Zoe Lambert', slug: 'zoe-lambert' } }),
|
||||
S('Create author: for me', 'Owner and admin only. When the user has no author profile, a checkbox links the new author to them.', 'This is me · Links the author to marta@renew.example.', { v: 'editor', metaOpen: true, meta: { authors: ['Inès Duarte'], hint: true }, modal: { name: 'Marta Alves', slug: 'marta-alves', me: true } }),
|
||||
S('Create author: slug taken', 'The slug exists. The dialog suggests a free one.', 'The slug "zoe-lambert" is already used by another author. · Use zoe-lambert-2', { v: 'editor', metaOpen: true, meta: { authors: ['Inès Duarte'] }, modal: { name: 'Zoe Lambert', slug: 'zoe-lambert', err: true, slugErr: 'The slug "zoe-lambert" is already used by another author.', suggest: 'zoe-lambert-2' } }),
|
||||
S('Create author: failed', '500 or network failure.', 'Couldn\'t create the author · The article is unchanged. Try again in a moment.', { v: 'editor', metaOpen: true, meta: { authors: ['Inès Duarte'] }, modal: { name: 'Zoe Lambert', slug: 'zoe-lambert', notice: true } })
|
||||
]),
|
||||
G('Profile: author profile', 'Profile: author profile', '/profile', 'POST /v1/author/me · GET /v1/auth/me (author field)', 'The "Author profile" card sits between Account and Password in every Profile frame, defined once in AppFrame. The existing Profile frames show its default state ("Create my author profile"). All roles can create their own profile and edit their own linked author. Only the states specific to the card are drawn here.', [
|
||||
S('No author profile', 'The user has no linked author.', 'Author profile · Your public byline on articles. It\'s separate from your account. · You don\'t have an author profile yet. Create one to be credited on your own articles. · Create my author profile · Uses your display name, Marta Alves. You can change it afterwards.', { v: 'profile', pa: { k: 'none' } }),
|
||||
S('Error', '500 or network failure.', 'Couldn\'t create your author profile · Try again in a moment.', { v: 'profile', pa: { k: 'none', err: true } }),
|
||||
S('Created', '201: the card shows the author. The author is named from the display name and linked to the user.', 'Author profile created · Marta Alves is now your byline. · Edit author · Linked to your account. New articles start with this author.', { v: 'profile', pa: { k: 'has' }, toasts: [['success', 'circle-check', 'Author profile created', 'Marta Alves is now your byline.']] }),
|
||||
S('Created, slug adjusted', 'The slug was taken, so the server added a number. No error is shown.', 'Your slug is marta-alves-2 · marta-alves was already used by another author. You can change it in Edit author.', { v: 'profile', pa: { k: 'has', slug: 'marta-alves-2' } }),
|
||||
S('Edit own author', 'Edit author on the card, by a role without author rights. The form opens with the linked user locked and no Unlink. There is no delete action in the form or on this screen.', 'Edit Marta Alves · Linked user · marta@renew.example · This is your author profile. Only an owner or admin can change the link. · Save changes', { v: 'profile', role: 'user', pa: { k: 'has' }, panel: { k: 'author', mode: 'edit', own: true, f: { name: 'Marta Alves', slug: 'marta-alves', slugMode: 'saved', bio: 'Project coordinator. Writes about permits and planning.', user: 'marta@renew.example' } } })
|
||||
]),
|
||||
G('Articles: sites and authors', 'Articles: sites and authors', '/articles?site=journal', 'GET /v1/article/?site={key}&page=&size=&q=&status=', 'Every Articles frame has the Authors column (avatars, at most 3 plus "+N"; "—" when none; on mobile avatars with the first name). When more than one site exists, a Site column and a Site filter appear, read-only text for every role; with one site they are not rendered.', [
|
||||
S('Several sites', 'More than one site exists.', 'Authors · Site · All sites · Renew · Journal · Projects', { v: 'app', list: { multi: true } }),
|
||||
S('Site filter open', 'The Site select in the toolbar. Choosing a site stores ?site=key and resets the page to 1.', 'All sites · Renew · Journal · Projects · Archive', { v: 'app', list: { multi: true, siteMenu: true } }),
|
||||
S('No articles on a site', 'The chosen site has no articles.', 'No articles on "Archive" · Move articles here from the editor, or choose another site. · Show all sites', { v: 'app', list: { multi: true, site: 'Archive', counts: [0, 0, 0], empty: { icon: 'file-text', title: 'No articles on "Archive"', text: 'Move articles here from the editor, or choose another site.', action: 'Show all sites' } } })
|
||||
])
|
||||
],
|
||||
specs: [
|
||||
{ label: 'Sites and authors: decisions', title: 'Sites and authors: decisions to confirm', note: 'Only choices that change behaviour or the API. Rows marked Decided were answered in the revision brief and are no longer questions.', tables: [{ cols: ['#', 'Decision', 'Status', 'To confirm'], tpl: '40px minmax(0,1.8fr) 90px minmax(0,1.6fr)', rows: [
|
||||
['1', 'Sites: only the owner can create, edit, set the default or delete. Other roles don\'t see the Sites entry; a direct link shows a 403 page. Anyone who can edit an article can still set its site and read the site list.', 'Decided', 'Roles, from the brief.'],
|
||||
['2', 'Authors: owner and admin manage all authors. Other roles see the list and details read-only, pick authors in the editor, and can create and edit their own author profile.', 'Decided', 'Roles, from the brief.'],
|
||||
['3', 'A site key is set when the site is created and can\'t be edited. The edit form shows it read-only.', 'Decided', 'Used in API paths. PATCH rejects a changed key.'],
|
||||
['4', 'An article may have zero authors. Nothing blocks publishing.', 'Decided', 'Zero authors allowed.'],
|
||||
['5', 'Deleting an author asks who gets the articles, with "Nobody" as the last option.', 'Decided', 'Reassign on delete.'],
|
||||
['6', 'With one site, the editor hides the Site select and the list hides the Site column and filter.', 'Open', 'Hide in the editor too, or only in the list.'],
|
||||
['7', 'Deleting a site with articles requires a destination, preselected as the default site. The default site can\'t be deleted.', 'Open', 'API: DELETE needs reassign_to when article_count is above 0.'],
|
||||
['8', 'A user has at most one author and an author at most one user. Only accepted users without an author are listed. Unlinking keeps the author and its articles.', 'Open', 'API: unique link. Unlink is PATCH with user_id null.'],
|
||||
['9', '"Create my author profile" never fails on a taken slug. The server adds a number and the card says so.', 'Open', 'API: the POST /v1/author/me response carries the final slug.'],
|
||||
['10', 'Article counts on sites and authors include drafts.', 'Open', 'API: counts guard deletion, so they must be complete.'],
|
||||
['11', 'Moving articles between sites changes their canonical host at once. No redirects are created.', 'Open', 'Behaviour. Redirects belong to a later slice.'],
|
||||
['12', 'Limits: bio 280 characters, 5 social links, name 100 characters, locale as a language tag.', 'Open', 'API validation.'],
|
||||
['13', 'The first site is seeded from PUBLIC_URL and named from its host.', 'Open', 'Migration: the seed name.']
|
||||
] }] },
|
||||
{ label: 'Sites and authors: deviations', title: 'Sites and authors: deviations from existing patterns', note: 'Where the design departs from a pattern already on the board, and why.', tables: [{ cols: ['Pattern', 'Existing', 'Here', 'Why'], tpl: 'minmax(0,1fr) minmax(0,1.2fr) minmax(0,1.4fr) minmax(0,1.4fr)', rows: [
|
||||
['Settings children', 'One group, "Media", with Variants and Cache', 'A second group, "Publishing", with Sites', 'Sites is not a media setting'],
|
||||
['Slide-over', 'Variant form and record details only', 'Site and author forms', 'Both have a live preview'],
|
||||
['Delete dialog', 'Text and two buttons', 'A select for the destination, plus a count in the button', 'Articles must not be orphaned'],
|
||||
['Media picker', 'Title "Add an image", inserts into text, has an alt text step', 'Title "Choose an image", button "Use image", no alt text', 'The image is a field value, not content'],
|
||||
['Row menu on a disabled action', 'Actions are hidden when not allowed', 'Delete stays visible, aria-disabled, with an explanation', 'The brief asks for an explanatory tooltip. Touch shows the text'],
|
||||
['Chips', 'No existing chip control', 'A vertical list of rows, not wrapped inline chips', 'Reordering needs one axis and 44 px targets on touch'],
|
||||
['Editor sidebar', 'Static Details list', 'A collapsible section above Details', 'Holds the only new editor fields'],
|
||||
['Role-restricted entries', 'Pages are shown or hidden by role', 'The Sites entry is removed for non-owners, and /settings/sites shows a 403 page', 'No dead entries, and a direct link still gets an answer'],
|
||||
['Counter', 'Title field shows "n / 200"', 'Bio shows "n / 280", red above the limit', 'Same counter, same colours']
|
||||
] }] },
|
||||
{ label: 'Sites and authors: API and errors', title: 'Sites and authors: API and HTTP statuses', note: 'Rows for states.md. Inline errors sit under the named field; dialogs show the notice inside.', tables: [{ cols: ['Status', 'Code', 'Copy', 'Where'], tpl: '70px minmax(0,1fr) minmax(0,2fr) minmax(0,1fr)', rows: [
|
||||
['400', 'invalid_site_url', 'Use https. For example https://blog.example.com. · Remove the path. Use only the host, like https://blog.example.com. · Enter a full URL, like https://blog.example.com.', 'Inline under Base URL'],
|
||||
['409', 'site_key_taken', 'A site with the key "journal" already exists. Choose another key.', 'Inline under Key, with a suggestion'],
|
||||
['409', 'site_has_articles', 'Choose where the articles go. This site has 38 articles.', 'Inside the delete dialog'],
|
||||
['403', 'default_site', 'The default site can\'t be deleted. Make another site the default first.', 'Toast if the menu was stale'],
|
||||
['403', 'owner_only', 'You don\'t have access to Sites · Only the owner can manage sites. Ask the owner of this site if you need a change.', '403 page on /settings/sites'],
|
||||
['403', 'owner_only (action)', 'Only the owner can manage sites · Ask the owner of this site.', 'Error toast if the role changed while the page was open'],
|
||||
['403', 'author_rights', 'Only an owner or admin can change authors · You can still edit your own author profile.', 'Error toast if the role changed while the page was open'],
|
||||
['400', 'key_immutable', 'Used in API paths, so it can\'t change.', 'Not shown: the edit form has no editable key'],
|
||||
['400', 'invalid_locale', 'Use a language tag like en or fr-CH.', 'Inline under Locale'],
|
||||
['404', 'site_not_found', 'That site no longer exists · It was deleted somewhere else, so we removed it from the list.', 'Info toast'],
|
||||
['409', 'author_slug_taken', 'The slug "camille-roche" is already used by another author.', 'Inline under Slug, with a suggestion'],
|
||||
['409', 'author_has_articles', '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.', 'Blocked dialog'],
|
||||
['409', 'user_already_linked', 'That user already has an author. Choose another user.', 'Inline under Linked user'],
|
||||
['422', 'bio_too_long', 'Shorten the bio to 280 characters.', 'Inline under Bio'],
|
||||
['422', 'invalid_url', 'Enter a full URL starting with https://.', 'Inline under Website and each social link'],
|
||||
['404', 'author_not_found', 'That author no longer exists · It was deleted somewhere else, so we removed it from the list.', 'Info toast'],
|
||||
['500', '–', 'Couldn\'t save the site. Your changes are still here. Try again in a moment. · Couldn\'t save the author. Your changes are still here. Try again in a moment. · Couldn\'t create the author · The article is unchanged. Try again in a moment.', 'Notice inside the panel or dialog']
|
||||
] }, { cols: ['Method and path', 'Used by', 'Notes'], tpl: 'minmax(0,1.3fr) minmax(0,1fr) minmax(0,1.6fr)', rows: [
|
||||
['GET /v1/site/', 'Sites, Site label, filter', 'Any role that edits articles. Returns article_count per site'],
|
||||
['POST /v1/site/', 'Add site', 'Owner only. name, key, base_url, social_image_id, robots, locale, is_default'],
|
||||
['PATCH /v1/site/{id}', 'Edit site, set as default', 'Owner only. The key can\'t be changed. is_default true moves the flag'],
|
||||
['DELETE /v1/site/{id}?reassign_to={id}', 'Delete site', 'Owner only. reassign_to required when article_count > 0'],
|
||||
['GET /v1/author/?q=&page=&size=', 'Authors, Add author combobox', 'Any role. Total in X-Total-Count, as for articles'],
|
||||
['POST /v1/author/ · PATCH · DELETE ?reassign_to={id|none}', 'Authors, editor dialog', 'Owner and admin. none removes the byline'],
|
||||
['POST /v1/author/me', 'Create my author profile', 'Any role. Name from the display name, linked to the user. A user can also PATCH their own linked author'],
|
||||
['PATCH /v1/article/{id}', 'Editor', 'site_id and author_ids[] in order, any role that edits the article; is_online is untouched']
|
||||
] }] },
|
||||
{ label: 'Sites and authors: components', title: 'Sites and authors: components (Nuxt UI v4)', note: 'New components use existing tokens only.', tables: [{ cols: ['Component', 'Status', 'Nuxt UI', 'Notes'], tpl: 'minmax(0,1fr) 80px minmax(0,1.4fr) minmax(0,1.8fr)', rows: [
|
||||
['SitesTable, AuthorsTable', 'New', 'UTable; below 640 px a list of UCard', 'Same row heights as Users. Defined once as DirectoryList in DirectoryFrame and used by both. Read-only variant has no actions'],
|
||||
['SiteForm, AuthorForm', 'New', 'USlideover + UForm, UFormField, UInput, UTextarea, URadioGroup, USwitch', 'Sheet on mobile. Close with changes opens the discard UModal'],
|
||||
['SlugField', 'New', 'UInput + UButton (icon) + UBadge', 'Defined once in DirectoryFrame. Auto badge, pencil, "Reset to match the name". Locked read-only variant with a lock for the site key after creation'],
|
||||
['SocialLinksRepeater', 'New', 'USelect + UInput + UButton, in UFormField', 'Max 5 rows. Placeholder follows the type'],
|
||||
['MediaField', 'New', 'UButton + UModal (existing picker)', 'Defined once in DirectoryFrame. Used for the social image and the avatar'],
|
||||
['AuthorViewPanel', 'New', 'USlideover, read-only', 'Detail for roles without author rights. Close is the only action'],
|
||||
['AuthorAvatar', 'New', 'UAvatar', 'Initials fallback, square, radius 0'],
|
||||
['AuthorStack', 'New', 'UAvatarGroup max=3', 'Shows "+N"'],
|
||||
['AuthorList', 'New', 'List of UButton, UAvatar, UBadge; sorting with useSortable', 'No Nuxt UI match for sortable rows. Keyboard reordering is custom'],
|
||||
['AuthorSelect', 'New', 'USelectMenu multiple with create-item', 'Footer item "Create author…" opens the dialog'],
|
||||
['SiteSelect', 'New', 'USelectMenu', 'Item slot shows name and host'],
|
||||
['UserSelect', 'New', 'USelectMenu', 'Only users without an author'],
|
||||
['ReassignDialog', 'New', 'UModal + USelectMenu + UAlert', 'Defined once in DirectoryFrame. Used for site and author deletion'],
|
||||
['MetaSection', 'New', 'UCollapsible', 'Desktop sidebar and mobile sheet'],
|
||||
['AuthorProfileCard', 'New', 'UCard + UButton + UAlert', 'Profile screen'],
|
||||
['CreateAuthorHint', 'New', 'UAlert closable', 'Dismissal stored per user'],
|
||||
['Default badge, Linked badge, Guest badge', 'New', 'UBadge', 'Sand fill, info soft fill, outline'],
|
||||
['Row menu with a disabled item', 'Existing + change', 'UDropdownMenu + UTooltip', 'The item stays focusable']
|
||||
] }] },
|
||||
{ label: 'Sites and authors: accessibility', title: 'Sites and authors: keyboard and screen reader', note: 'Applies to the reorderable author list and to the media picker used by both forms.', tables: [{ cols: ['Control', 'Keyboard', 'Screen reader'], tpl: 'minmax(0,0.8fr) minmax(0,1.6fr) minmax(0,1.8fr)', rows: [
|
||||
['Author list, handle', 'Tab reaches each handle. Space or Enter grabs. Up and Down move one place; Home and End move to the first and last place. Space or Enter drops. Escape cancels and restores the order.', 'The handle is a button: "Reorder Inès Duarte, position 1 of 3". The list has aria-describedby text "Drag to reorder. The first author is the primary byline." A polite live region announces: "Jonas Pereira grabbed. Position 2 of 3. Use the up and down arrow keys to move, Space to drop, Escape to cancel." Then "Jonas Pereira moved to position 1 of 3.", "Jonas Pereira dropped at position 1 of 3." or "Reorder cancelled. Jonas Pereira is back at position 2 of 3."'],
|
||||
['Author list, remove', 'Tab, then Enter or Space. Focus moves to the next row\'s handle, or to the Add an author field after the last row.', 'Button "Remove Camille Roche from this article". Live region: "Camille Roche removed. 2 authors."'],
|
||||
['Author list, pointer and touch', 'Pointer: drag the handle after 4 px. Touch: press and hold the 44 px handle for 250 ms, then drag.', 'Same live region messages as the keyboard.'],
|
||||
['Add an author', 'Down arrow or typing opens the list. Up and Down move, Enter adds or removes, Escape closes and returns focus to the field. "Create author…" is the last option.', 'role=combobox, aria-expanded, aria-controls, aria-multiselectable. Selected options have aria-selected. Live region: "Inès Duarte added. 2 authors."'],
|
||||
['Media picker', 'Focus moves into the dialog and is trapped. Tabs use arrow keys. Tiles form one radio group: arrow keys move, Space selects. "Use image" is aria-disabled until a tile is selected. Escape closes and focus returns to "Choose image" or "Change image".', 'Each tile reads "north-wall.jpg, 3.9 MB". After "Use image" a polite message reads "Image selected: north-wall.jpg", and focus lands on "Change image".'],
|
||||
['Slug field', '"Edit slug" moves focus into the input. "Reset to match the name" keeps focus on itself.', 'The input has aria-invalid and aria-describedby for the error. Reset announces "Slug reset to camille-roche".'],
|
||||
['Section header', 'Enter or Space toggles.', 'Button with aria-expanded and aria-controls.'],
|
||||
['Disabled delete', 'Stays in the tab order of the menu.', 'aria-disabled, with aria-describedby pointing to the explanation.']
|
||||
] }] }
|
||||
] }
|
||||
}
|
||||
dirGroups(bo) {
|
||||
const doc = this.dirDoc()
|
||||
const rowsA = ['How we price a bathroom renovation', 'Choosing lime plaster for old stone walls', 'Before and after: the Rue Verte kitchen', 'Winter checklist for timber windows', 'Why we reuse original floorboards', 'Our first passive-house retrofit']
|
||||
const meta = [[true, '12 Mar 2026', '2 hours ago', ['Inès Duarte'], 'Journal'], [false, '9 Sep 2026', 'Yesterday', ['Inès Duarte', 'Camille Roche'], 'Renew'], [true, '28 Aug 2026', '3 days ago', ['Jonas Pereira'], 'Projects'], [false, '2 Sep 2026', '5 days ago', ['Inès Duarte', 'Jonas Pereira', 'Camille Roche', 'Paul Huber', 'Léa Fontaine'], 'Journal'], [true, '14 Jun 2026', '18 Aug 2026', ['Paul Huber'], 'Renew'], [true, '3 Apr 2026', '2 Jul 2026', [], 'Journal']]
|
||||
const rows = meta.map((x, i) => ({ t: rowsA[i], on: x[0], c: x[1], u: x[2], au: x[3], site: x[4] }))
|
||||
const edShell = ed => ({ kind: 'editor', nav: 'articles', crumbs: ['Articles', 'How we price a bathroom renovation'], mTitle: 'Edit article', mLead: 'arrow-left', mAction: 'ellipsis', ed: { status: 'draft', save: 'saved', sheet: true, ...ed } })
|
||||
const pfShell = { kind: 'profile', nav: 'profile', crumbs: ['Profile'], mTitle: 'Profile', pf: {} }
|
||||
return doc.groups.map(g => ({ label: g.label, title: g.title, route: g.route, api: g.api, note: g.note, states: g.states.map(st => {
|
||||
const o = st.o
|
||||
if (o.v === 'adminSettings') { const src = bo.flatMap(x => x.states).find(x => x.name === 'Admin view'); const ch = { name: st.name, trigger: st.trigger, role: 'admin', sideOpen: true }; return { ...src, ...ch, m: { ...src.m, ...ch } } }
|
||||
if (o.v === 'app') {
|
||||
const L = { ...o.list }
|
||||
if (!L.empty && !L.loading) L.rows = L.only ? rows.filter(r => r.site === L.only) : rows
|
||||
const base = { kind: 'articles', nav: 'articles', crumbs: ['Articles'], mTitle: 'Articles', list: L }
|
||||
return { notMedia: true, name: st.name, trigger: st.trigger, ...base, m: { ...base } }
|
||||
}
|
||||
let s = { ...o }
|
||||
if (o.v === 'editor') s.shell = { ...edShell(o.ed), role: o.role }
|
||||
if (o.v === 'profile') s.shell = { ...pfShell, pa: o.pa, role: o.role }
|
||||
const x = { isDir: true, notMedia: false, name: st.name, trigger: st.trigger, ...s }
|
||||
return { ...x, m: { ...x, ...(st.mo || {}) } }
|
||||
}) }))
|
||||
}
|
||||
dirSpecs() { return this.dirDoc().specs.map(sp => ({ label: sp.label, title: sp.title, note: sp.note, tables: sp.tables.map(t => ({ cols: t.cols, tpl: t.tpl, rows: t.rows.map(r => r.map((x, i) => this.cell(x, i, { muted: x === '–' }))) })) })) }
|
||||
dirCopy() { return this.dirDoc().groups.flatMap(g => g.states.filter(s => s.copy).map(s => [g.title + ': ' + s.name, s.copy])) }
|
||||
// DIR-END
|
||||
finish(gs) { return gs.map(g => ({ ...g, states: g.states.map(st => ({ notMedia: !st.isMedia && !st.isAct, ...st, m: { ...st.m } })) })) }
|
||||
renderVals() {
|
||||
const modes = [['system', 'System'], ['light', 'Light'], ['dark', 'Dark']].map(([k, label]) => {
|
||||
@@ -838,7 +1046,7 @@ class Component extends DCLogic {
|
||||
return { label, on, bg: on ? 'var(--ui-primary)' : 'transparent', fg: on ? 'var(--ui-bg)' : 'var(--ui-text-toned)', pick: () => this.setState({ mode: k }) }
|
||||
})
|
||||
const rows = a => a.map(r => ({ a: r[0], b: r[1] }))
|
||||
return { rootRef: this.rootRef, modes, groups: this.groups(), specs: this.varSpecs(), spCopy: rows(this.state.spec.copy), spComps: rows(this.state.spec.comps), spApi: rows(this.state.spec.api) }
|
||||
return { rootRef: this.rootRef, modes, groups: this.groups(), specs: [...this.varSpecs(), ...this.dirSpecs()], dirCopy: this.dirCopy().map(r => ({ a: r[0], b: r[1] })), spCopy: rows(this.state.spec.copy), spComps: rows(this.state.spec.comps), spApi: rows(this.state.spec.api) }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
Reference in new issue
Block a user