openvibers/openvibe.shared shipped.js
shipped.js
| 1 | /*! |
| 2 | * openvibe-shared/shipped — what shipped, on every OpenVibe site, from one feed: the network changelog |
| 3 | * OpenVibe.Blog keeps (every deployed commit of every site) and serves through OpenVibe.Network at |
| 4 | * GET https://openvibe.network/api/v1/changelog, which every site's CSP already allows. |
| 5 | * |
| 6 | * Three views, the same on every site: |
| 7 | * latest the one-liner "🚀 shipped 5m ago: <newest change>", linking to the site's updates page |
| 8 | * list the newest few changes, each linked to its commit, plus the latest "Patch notes" post |
| 9 | * log the full updates page: changes grouped by day, "Load more", this site or the whole |
| 10 | * network (with a filter per site), and the patch notes posts |
| 11 | * |
| 12 | * Markup only (the script mounts every element on the page, and the shared footer loads it): |
| 13 | * <a data-ov-shipped="latest" data-service="wiki" href="/updates"></a> |
| 14 | * <section data-ov-shipped="list" data-service="wiki" data-limit="8" data-more="/updates"></section> |
| 15 | * <div data-ov-shipped="log" data-service="wiki"></div> data-service="" = the whole network |
| 16 | * <script src="https://openvibe.network/shared/shipped.js" defer></script> |
| 17 | * |
| 18 | * Or from code: OpenVibeShipped.latest(el, opts), .list(el, opts) (alias mount), .log(el, opts), |
| 19 | * .scan(root). Options: service (a registry id; 'auto' = from the hostname; omitted = every site), |
| 20 | * limit, title ('' = no heading), href / more (links), api (the feed URL, for tests). DOM nodes only |
| 21 | * (commit text is never parsed as HTML), https links only; a failed or empty feed hides the element, |
| 22 | * never an error on the page. Styles follow the theme's CSS variables. A classic script, no modules. |
| 23 | */ |
| 24 | (function (root, factory) { |
| 25 | const api = factory(); |
| 26 | if (typeof module === 'object' && module.exports) module.exports = api; |
| 27 | if (typeof window !== 'undefined') { |
| 28 | window.OpenVibeShipped = api; |
| 29 | if (typeof document !== 'undefined') { |
| 30 | if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => api.scan(document)); |
| 31 | else api.scan(document); |
| 32 | } |
| 33 | } |
| 34 | }(typeof globalThis !== 'undefined' ? globalThis : this, function () { |
| 35 | ; |
| 36 | const API = 'https://openvibe.network/api/v1/changelog'; |
| 37 | // Every OpenVibe service's display name (openvibe-contracts site.name). A service missing here still reads as a name |
| 38 | // (siteName capitalises its id), never as a lowercase id next to capitalised ones. |
| 39 | const SITE_NAMES = { |
| 40 | live: 'Live', network: 'Network', tools: 'Tools', media: 'Media', community: 'Community', chat: 'Chat', games: 'Games', |
| 41 | blog: 'Blog', wiki: 'Wiki', news: 'News', reviews: 'Reviews', deals: 'Deals', coupons: 'Coupons', trade: 'Trade', |
| 42 | codes: 'Codes', host: 'Host', ai: 'AI', search: 'Search', sources: 'Sources', events: 'Events', billing: 'Billing', |
| 43 | tips: 'Tips', vip: 'VIP', openre: 'Restream', bot: 'Bot', sites: 'Sites', realtime: 'Realtime', space: 'Space', |
| 44 | services: 'Services', actor: 'Actor', run: 'Run', watch: 'Watch', link: 'Link', zone: 'Zone', download: 'Download', |
| 45 | video: 'Video', pics: 'Pics', rent: 'Rent', homes: 'Homes', work: 'Work', food: 'Food', help: 'Help', quest: 'Quest', |
| 46 | website: 'Website', fund: 'Fund', 'media-hub': 'Media Hub', extensions: 'Extensions', contracts: 'Contracts', sdk: 'SDK', |
| 47 | shared: 'Shared', examples: 'Examples', publishing: 'Publishing', play: 'Play', inventory: 'Inventory', |
| 48 | bench: 'Bench', cam: 'Cam', casino: 'Casino', design: 'Design', domains: 'Domains', exchange: 'Exchange', fun: 'Fun', |
| 49 | meme: 'Meme', report: 'Report', surf: 'Surf', today: 'Today', tours: 'Tours', vision: 'Vision', shoes: 'Shoes', |
| 50 | }; |
| 51 | const STYLE_ID = 'ov-shipped-style'; |
| 52 | const TEXT = 'var(--text-primary,var(--text,inherit))'; |
| 53 | const MUTED = 'var(--text-secondary,var(--text-muted,#8b93ad))'; |
| 54 | const ACCENT = 'var(--accent-light,var(--accent,#3b82f6))'; |
| 55 | const BORDER = 'var(--border,rgba(127,127,127,.25))'; |
| 56 | const CSS = [ |
| 57 | `.ov-shipped-latest{display:inline-flex;align-items:center;gap:7px;padding:6px 14px;border-radius:999px;font-size:.8rem;text-decoration:none;color:${MUTED};background:color-mix(in srgb,var(--bg-secondary,#111827) 70%,transparent);border:1px solid color-mix(in srgb,var(--accent,#3b82f6) 22%,transparent);max-width:min(640px,100%);min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;box-sizing:border-box}`, |
| 58 | `.ov-shipped-latest:hover{border-color:var(--accent,#3b82f6);color:${TEXT}}.ov-shipped-latest svg{flex:none;color:var(--accent,#3b82f6)}`, |
| 59 | `.ov-shipped-latest b{color:${TEXT};font-weight:600;overflow:hidden;text-overflow:ellipsis;min-width:0}`, |
| 60 | '[data-ov-shipped][hidden],.ov-shipped[hidden],.ov-shipped-latest[hidden]{display:none!important}', |
| 61 | '.ov-shipped-home{display:grid;gap:12px;justify-items:start;margin:16px 0;min-width:0}.ov-shipped-home>.ov-shipped{justify-self:stretch}', |
| 62 | `.ov-shipped-n{margin-left:2px;white-space:pre;font-size:.78em;font-weight:600;font-variant-numeric:tabular-nums}`, |
| 63 | `.ov-shipped-kinds{margin-top:-4px}.ov-shipped-house{margin-top:6px;border-top:1px dashed var(--border,rgba(255,255,255,.12));padding-top:6px}.ov-shipped-house>summary{cursor:pointer;color:${MUTED};font-size:13px;padding:6px 0}.ov-shipped-house .ov-shipped-entry{opacity:.85}`, |
| 64 | `.ov-updates{min-width:0}.ov-updates h1{margin:.2em 0 .3em}.ov-updates-lede{color:${MUTED};line-height:1.55;margin:0 0 18px}`, |
| 65 | '.ov-shipped{font-size:.92rem;min-width:0}.ov-shipped h3{font-size:1rem;margin:0 0 .5rem;display:flex;gap:.4rem;align-items:center}', |
| 66 | '.ov-shipped ul{list-style:none;margin:0;padding:0;display:grid;gap:.35rem}.ov-shipped li{display:flex;gap:.5rem;align-items:baseline;min-width:0}', |
| 67 | `.ov-shipped-site{flex:none;font-size:.72rem;font-weight:700;padding:.05rem .45rem;border-radius:999px;background:color-mix(in srgb,var(--accent,#3b82f6) 16%,transparent);color:${TEXT};text-decoration:none}`, |
| 68 | `.ov-shipped a.ov-shipped-subject{color:${TEXT};text-decoration:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}`, |
| 69 | // Touch (4.46.0): a taller hit box with no change to the layout (padding in, margin out); overflow:hidden rules out a pseudo-element. |
| 70 | '@media (pointer:coarse){.ov-shipped ul{gap:0}.ov-shipped li{min-height:44px;align-items:center}.ov-shipped a.ov-shipped-subject{padding-top:12px;padding-bottom:12px}}', |
| 71 | '.ov-shipped a.ov-shipped-subject:hover{text-decoration:underline}.ov-shipped li time{flex:none;margin-left:auto;font-size:.75rem;opacity:.7}', |
| 72 | `.ov-shipped-major{font-weight:600}.ov-shipped-links{display:flex;flex-wrap:wrap;gap:.4rem 1rem;margin-top:.6rem}.ov-shipped-links a{font-weight:600;color:${ACCENT};text-decoration:none}.ov-shipped-links a:hover{text-decoration:underline}`, |
| 73 | '.ov-shipped-log{display:flex;flex-direction:column;gap:22px;min-width:0}', |
| 74 | '.ov-shipped-bar{display:flex;flex-wrap:wrap;gap:6px;align-items:center}', |
| 75 | `.ov-shipped-chip{font:inherit;font-size:.8rem;padding:4px 12px;border-radius:999px;border:1px solid ${BORDER};background:transparent;color:${MUTED};cursor:pointer;text-decoration:none}`, |
| 76 | `.ov-shipped-chip:hover{color:${TEXT};border-color:var(--accent,#3b82f6)}.ov-shipped-chip[aria-pressed="true"]{background:var(--accent-strong,#1d4ed8);border-color:var(--accent-strong,#1d4ed8);color:var(--on-accent-strong,#fff)}`, |
| 77 | `.ov-shipped-day h3{font-size:.95rem;font-weight:600;color:${MUTED};padding-bottom:8px;border-bottom:1px solid ${BORDER};margin:0 0 4px}`, |
| 78 | '.ov-shipped-entries{display:flex;flex-direction:column;gap:2px}', |
| 79 | '.ov-shipped-entry{display:flex;align-items:baseline;gap:10px;padding:8px 12px;border-radius:6px;min-width:0}', |
| 80 | '.ov-shipped-entry:hover{background:var(--bg-tertiary,rgba(127,127,127,.08))}', |
| 81 | `.ov-shipped-hash{font-family:'JetBrains Mono','Fira Code',ui-monospace,monospace;font-size:.8rem;color:${ACCENT};text-decoration:none;flex:none}.ov-shipped-hash:hover{text-decoration:underline}`, |
| 82 | `.ov-shipped-text{flex:1;min-width:0;font-size:.9rem;color:${TEXT};overflow-wrap:anywhere}.ov-shipped-text.ov-shipped-major{font-weight:600}`, |
| 83 | `.ov-shipped-meta{font-size:.75rem;color:${MUTED};flex:none;white-space:nowrap}`, |
| 84 | `.ov-shipped-more{align-self:center;font:inherit;font-size:.85rem;padding:8px 18px;border-radius:999px;border:1px solid ${BORDER};background:transparent;color:${TEXT};cursor:pointer}.ov-shipped-more:hover{border-color:var(--accent,#3b82f6)}.ov-shipped-more[disabled]{opacity:.6;cursor:default}`, |
| 85 | `.ov-shipped-posts{border:1px solid ${BORDER};border-radius:12px;padding:12px 16px}.ov-shipped-posts h3{font-size:.95rem;margin:0 0 8px}.ov-shipped-posts ul{list-style:none;margin:0;padding:0;display:grid;gap:6px}`, |
| 86 | `.ov-shipped-posts a{color:${ACCENT};text-decoration:none;font-weight:600}.ov-shipped-posts a:hover{text-decoration:underline}.ov-shipped-posts small{color:${MUTED};margin-left:6px}`, |
| 87 | `.ov-shipped-empty{color:${MUTED};text-align:center;padding:24px 0;margin:0}`, |
| 88 | // 4.47.2: the same change on many sites is one row (a pin round was 31 rows of the same line). |
| 89 | '.ov-shipped-group{flex-wrap:wrap}', |
| 90 | `.ov-shipped-count{flex:none;min-width:2em;text-align:center;font-size:.72rem;font-weight:800;padding:.12rem .45rem;border-radius:999px;background:color-mix(in srgb,var(--accent,#3b82f6) 22%,transparent);color:${TEXT}}`, |
| 91 | `.ov-shipped-notes{color:${ACCENT};font-size:.82rem;font-weight:600;text-decoration:none;white-space:nowrap}.ov-shipped-notes:hover{text-decoration:underline}`, |
| 92 | // 4.53.1: the patch-notes link under the list carries a whole post title ("Patch notes: 81 changes across 43 sites |
| 93 | // (Oct 30) →"); kept on one line it ran past a 320 px screen. It wraps there; the short "What's in it" stays whole. |
| 94 | '.ov-shipped-links .ov-shipped-notes{white-space:normal;min-width:0;overflow-wrap:anywhere}', |
| 95 | '@media (pointer:coarse){.ov-shipped-links a{display:inline-flex;align-items:center;min-height:44px}.ov-shipped-entry{padding-top:12px;padding-bottom:12px}.ov-shipped-hash{padding-top:12px;padding-bottom:12px;margin-top:-12px;margin-bottom:-12px}}', |
| 96 | `.ov-shipped-fold{flex-basis:100%;min-width:0;font-size:.82rem;color:${MUTED}}.ov-shipped-fold summary{cursor:pointer;padding:3px 0;overflow-wrap:anywhere}`, |
| 97 | `.ov-shipped-fold .ov-shipped-entries{margin:4px 0 2px;padding-left:10px;border-left:2px solid ${BORDER}}.ov-shipped-fold .ov-shipped-entry{padding:5px 8px}`, |
| 98 | '@media (max-width:600px){.ov-shipped-entry{flex-wrap:wrap;gap:4px 8px}.ov-shipped-meta{width:100%}}', |
| 99 | // 11-09 (Opus): the "What's new" card: a devblog lead with its picture, rows with a kind tag and area chips. |
| 100 | `.ov-shipped-dev{display:grid;grid-template-columns:96px minmax(0,1fr);gap:12px;align-items:center;margin:0 0 12px;padding:8px;border:1px solid ${BORDER};border-radius:14px;text-decoration:none;color:${TEXT}}`, |
| 101 | '.ov-shipped-dev:not(:has(img)){grid-template-columns:minmax(0,1fr)}', |
| 102 | `.ov-shipped-dev:hover{border-color:var(--accent,#3b82f6)}.ov-shipped-dev img{width:96px;height:64px;object-fit:cover;border-radius:10px;display:block;background:${BORDER}}`, |
| 103 | `.ov-shipped-dev-text{display:grid;gap:2px;min-width:0}.ov-shipped-dev-text small{font-size:.68rem;font-weight:800;letter-spacing:.06em;text-transform:uppercase;color:${ACCENT}}`, |
| 104 | '.ov-shipped-dev-text b{font-size:.95rem;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}', |
| 105 | `.ov-shipped-dev-text>span{font-size:.82rem;color:${MUTED};display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}`, |
| 106 | '.ov-shipped .ov-shipped-row{display:flex;align-items:center;gap:8px;min-width:0}', |
| 107 | // The kind of a change: a drawn mark (a star, an arrow up, a tick) and, for New and Fixed, the word. An improvement |
| 108 | // is the mark alone: most rows are improvements, and the word beside every one of them read as noise. |
| 109 | '.ov-shipped-kind{flex:none;display:inline-flex;align-items:center;gap:5px;font-size:.72rem;font-weight:700;line-height:1.2;padding:2px 9px 2px 6px;border-radius:999px}', |
| 110 | '.ov-shipped-kind::before{content:"";flex:none;width:11px;height:11px;background:var(--k,currentColor)}', |
| 111 | '.ov-shipped-kind.is-new::before{--k:#22c55e;clip-path:polygon(50% 0,63% 37%,100% 50%,63% 63%,50% 100%,37% 63%,0 50%,37% 37%)}', |
| 112 | '.ov-shipped-kind.is-better::before{--k:var(--accent-light,var(--accent,#60a5fa));clip-path:polygon(50% 0,100% 52%,68% 52%,68% 100%,32% 100%,32% 52%,0 52%)}', |
| 113 | '.ov-shipped-kind.is-fixed::before{--k:#f59e0b;clip-path:polygon(6% 54%,22% 38%,40% 56%,78% 12%,94% 28%,40% 88%)}', |
| 114 | '.ov-shipped-kind.is-better{gap:0;padding:5px;font-size:0}', |
| 115 | `.ov-shipped-kind.is-new{background:color-mix(in srgb,#22c55e 20%,transparent);color:${TEXT}}.ov-shipped-kind.is-better{background:color-mix(in srgb,var(--accent,#3b82f6) 20%,transparent);color:${TEXT}}.ov-shipped-kind.is-fixed{background:color-mix(in srgb,#f59e0b 22%,transparent);color:${TEXT}}`, |
| 116 | `.ov-shipped-areas{display:inline-flex;gap:4px;flex:none}.ov-shipped-areas span{font-size:.68rem;padding:1px 7px;border-radius:999px;border:1px solid ${BORDER};color:${MUTED};white-space:nowrap}`, |
| 117 | '@media (max-width:520px){.ov-shipped .ov-shipped-row{flex-wrap:wrap;row-gap:2px}.ov-shipped .ov-shipped-row .ov-shipped-subject{flex:1 1 60%;white-space:normal;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}.ov-shipped-areas{order:5}}', |
| 118 | ].join(''); |
| 119 | const ROCKET = 'M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09zM12 15l-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2zM9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5'; |
| 120 | |
| 121 | // ── helpers ───────────────────────────────────────────────── |
| 122 | function ago(iso, now) { |
| 123 | const t = Date.parse(iso); |
| 124 | if (!t) return ''; |
| 125 | const s = Math.max(0, ((now || Date.now()) - t) / 1000); |
| 126 | if (s < 45) return 'just now'; |
| 127 | if (s < 3600) return `${Math.max(1, Math.round(s / 60))}m ago`; |
| 128 | if (s < 86400) return `${Math.round(s / 3600)}h ago`; |
| 129 | if (s < 86400 * 45) return `${Math.round(s / 86400)}d ago`; |
| 130 | return `${Math.round(s / (86400 * 30))}mo ago`; |
| 131 | } |
| 132 | function el(tag, attrs, text) { |
| 133 | const n = document.createElement(tag); |
| 134 | for (const k of Object.keys(attrs || {})) if (attrs[k] != null) n.setAttribute(k, attrs[k]); |
| 135 | if (text != null) n.textContent = text; |
| 136 | return n; |
| 137 | } |
| 138 | function style() { |
| 139 | if (typeof document === 'undefined' || document.getElementById(STYLE_ID)) return; |
| 140 | const s = el('style', { id: STYLE_ID }); |
| 141 | s.textContent = CSS; |
| 142 | (document.head || document.documentElement).appendChild(s); |
| 143 | } |
| 144 | function safeUrl(u) { return /^https:\/\//.test(String(u || '')) ? String(u) : null; } |
| 145 | function localHref(u) { const s = String(u || ''); return /^\/(?!\/)/.test(s) || /^https:\/\//.test(s) ? s : null; } |
| 146 | function clamp(n, lo, hi, d) { const v = parseInt(n, 10); return Number.isFinite(v) ? Math.min(Math.max(v, lo), hi) : d; } |
| 147 | const siteName = (id) => SITE_NAMES[id] || String(id || '').replace(/(^|-)([a-z])/g, (m, d, c) => `${d ? ' ' : ''}${c.toUpperCase()}`); |
| 148 | function timeEl(iso) { const t = el('time', { datetime: iso, 'data-ov-ago': '' }, ago(iso)); t.title = new Date(iso).toLocaleString(); return t; } |
| 149 | function rocket() { |
| 150 | const ns = 'http://www.w3.org/2000/svg'; |
| 151 | const svg = document.createElementNS(ns, 'svg'); |
| 152 | for (const [k, v] of Object.entries({ width: '14', height: '14', viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '2', 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'aria-hidden': 'true' })) svg.setAttribute(k, v); |
| 153 | const p = document.createElementNS(ns, 'path'); |
| 154 | p.setAttribute('d', ROCKET); |
| 155 | svg.appendChild(p); |
| 156 | return svg; |
| 157 | } |
| 158 | |
| 159 | /** The registry id for a hostname (or an id already): openvibe.wiki → wiki, pdf.openvibe.tools → tools. */ |
| 160 | function serviceFor(value) { |
| 161 | const v = String(value || '').toLowerCase().trim(); |
| 162 | if (SITE_NAMES[v]) return v; |
| 163 | const h = v.replace(/:\d+$/, '').replace(/^www\./, ''); |
| 164 | if (/(^|\.)openre\.stream$/.test(h)) return 'openre'; |
| 165 | if (/(^|\.)openvibe\.tools$/.test(h)) return 'tools'; |
| 166 | if (/(^|\.)openvibe\.network$/.test(h)) { |
| 167 | const sub = h.replace(/\.?openvibe\.network$/, ''); |
| 168 | return SITE_NAMES[sub] && sub !== 'network' ? sub : 'network'; |
| 169 | } |
| 170 | // The Services family lives on its subdomains (ai., bench.openvibe.services), like the Network's. |
| 171 | if (/(^|\.)openvibe\.services$/.test(h)) { |
| 172 | const sub = h.replace(/\.?openvibe\.services$/, ''); |
| 173 | return SITE_NAMES[sub] && sub !== 'services' ? sub : 'services'; |
| 174 | } |
| 175 | if (/(^|\.)open\.shoes$/.test(h)) return 'shoes'; |
| 176 | const m = /(?:^|\.)openvibe\.([a-z]+)$/.exec(h); |
| 177 | return m && SITE_NAMES[m[1]] ? m[1] : null; |
| 178 | } |
| 179 | /** 'auto' = this page's host; a site id that is not a registry id (a Tools satellite's 'dev') also falls back to the host; '' = the whole network. */ |
| 180 | function resolveService(s) { |
| 181 | const host = () => (typeof location !== 'undefined' ? serviceFor(location.hostname) : null); |
| 182 | if (s === 'auto') return host(); |
| 183 | return s ? (serviceFor(s) || host()) : null; |
| 184 | } |
| 185 | |
| 186 | // One request per feed URL for 30 s, shared by the footer line, the pill and the list on a page. |
| 187 | const _feeds = new Map(); |
| 188 | function feed({ service = null, kind = null, limit = 5, before = null, api = null } = {}) { |
| 189 | const url = new URL(api || API); |
| 190 | if (service) url.searchParams.set('service', String(service)); |
| 191 | if (kind) url.searchParams.set('kind', String(kind)); |
| 192 | url.searchParams.set('limit', String(clamp(limit, 1, 100, 5))); |
| 193 | if (before) url.searchParams.set('before', String(before)); |
| 194 | const key = url.toString(); |
| 195 | const hit = _feeds.get(key); |
| 196 | if (hit && Date.now() - hit.at < 30000) return hit.promise; |
| 197 | const promise = fetch(key, { headers: { Accept: 'application/json' }, credentials: 'omit' }) |
| 198 | .then((r) => (r.ok ? r.json() : null)).catch(() => null); |
| 199 | _feeds.set(key, { at: Date.now(), promise }); |
| 200 | return promise; |
| 201 | } |
| 202 | |
| 203 | // "5m ago" stays true while the page is open. |
| 204 | let _ticker = null; |
| 205 | function tick() { |
| 206 | if (_ticker || typeof setInterval === 'undefined' || typeof document === 'undefined') return; |
| 207 | _ticker = setInterval(() => { |
| 208 | for (const t of document.querySelectorAll('time[data-ov-ago]')) t.textContent = ago(t.getAttribute('datetime')); |
| 209 | }, 60000); |
| 210 | if (_ticker.unref) _ticker.unref(); |
| 211 | } |
| 212 | |
| 213 | // ── what a visitor reads ──────────────────────────────────── |
| 214 | // A library pin bump ("openvibe-shared v4.1.0 (was v4.0.0) (#34)", opened by pin-bump.js on every repo at once) |
| 215 | // says nothing to a visitor: the one-liner and the short list pass over them, while the full log keeps every |
| 216 | // deployed commit. If nothing else shipped, the newest entry is shown anyway, so a site never looks dead. |
| 217 | const PIN_BUMP = /^openvibe-[a-z][a-z0-9-]* v\d+\.\d+\.\d+ \(was v\d+\.\d+\.\d+\)/; |
| 218 | function isHousekeeping(e) { return Boolean(e && (e.kind === 'internal' || PIN_BUMP.test(String(e.subject || '')))); } |
| 219 | function forVisitors(entries, n) { |
| 220 | const all = Array.isArray(entries) ? entries : []; |
| 221 | const told = all.filter((e) => !isHousekeeping(e)); |
| 222 | return (told.length ? told : all).slice(0, n); |
| 223 | } |
| 224 | |
| 225 | // ── latest: the one-liner ─────────────────────────────────── |
| 226 | /** Render the newest entry into `node` (exported for tests). A node that is an <a> keeps its own href. */ |
| 227 | function renderLatest(node, data, opts) { |
| 228 | const o = opts || {}; |
| 229 | const e = forVisitors(data && data.entries, 1)[0] || null; |
| 230 | node.textContent = ''; |
| 231 | if (!e || !e.subject) { node.hidden = true; return node; } |
| 232 | node.classList.add('ov-shipped-latest'); |
| 233 | const href = localHref(o.href) || (node.tagName === 'A' && node.getAttribute('href')) || null; |
| 234 | if (node.tagName === 'A' && href) node.setAttribute('href', href); |
| 235 | node.appendChild(rocket()); |
| 236 | node.appendChild(document.createTextNode(o.service ? ' shipped ' : ` ${siteName(e.service)} shipped `)); |
| 237 | node.appendChild(timeEl(e.deployed_at)); |
| 238 | node.appendChild(document.createTextNode(': ')); |
| 239 | node.appendChild(el('b', {}, e.subject)); |
| 240 | node.title = e.subject; |
| 241 | node.hidden = false; |
| 242 | if (node.style && node.style.display === 'none') node.style.display = ''; |
| 243 | tick(); |
| 244 | return node; |
| 245 | } |
| 246 | async function latest(target, opts) { |
| 247 | const o = { ...(opts || {}) }; |
| 248 | const node = typeof target === 'string' ? document.querySelector(target) : target; |
| 249 | if (!node) return null; |
| 250 | o.service = resolveService(o.service); |
| 251 | style(); |
| 252 | // A few more than one, so a run of pin bumps does not hide the newest real change. |
| 253 | return renderLatest(node, await feed({ service: o.service, limit: 12, api: o.api }), o); |
| 254 | } |
| 255 | |
| 256 | // ── list: the newest few (the "What's new" card) ───────────── |
| 257 | // Opus's design (11-09, the owner: "show the latest blog post… rich content… with the image… elegant… use AI for |
| 258 | // tags or topics"): the site's newest devblog post leads with its picture, then the last changes as rows, each with |
| 259 | // what kind of change it is (New, Better, Fixed: Blog's blog.changelog.tag task), its plain words and up to two |
| 260 | // areas. Entries without those fields (before the task ran) read as before: their subject, no kind, no areas. |
| 261 | const KINDS = { new: 'New', better: 'Improved', fixed: 'Fixed' }; |
| 262 | function kindTag(e) { const k = e && KINDS[e.kind]; return k && e.kind !== 'better' ? el('span', { class: `ov-shipped-kind is-${e.kind}` }, k) : null; } |
| 263 | function areaChips(e, n) { |
| 264 | const areas = Array.isArray(e && e.areas) ? e.areas.filter((a) => typeof a === 'string' && a).slice(0, n) : []; |
| 265 | if (!areas.length) return null; |
| 266 | const box = el('span', { class: 'ov-shipped-areas' }); |
| 267 | for (const a of areas) box.appendChild(el('span', {}, a)); |
| 268 | return box; |
| 269 | } |
| 270 | function words(e) { return typeof (e && e.plain) === 'string' && e.plain.trim() ? e.plain.trim() : plainSubject(e); } |
| 271 | function devblogCard(post) { |
| 272 | const href = post && safeUrl(post.url); |
| 273 | if (!href || !post.title) return null; |
| 274 | const a = el('a', { class: 'ov-shipped-dev', href }); |
| 275 | const fig = safeUrl(post.figure); |
| 276 | if (fig) { const img = el('img', { src: fig, alt: '', loading: 'lazy', decoding: 'async' }); a.appendChild(img); } |
| 277 | const text = el('span', { class: 'ov-shipped-dev-text' }); |
| 278 | text.appendChild(el('small', {}, 'Devblog')); |
| 279 | text.appendChild(el('b', {}, String(post.title))); |
| 280 | if (post.summary) text.appendChild(el('span', {}, String(post.summary))); |
| 281 | a.appendChild(text); |
| 282 | return a; |
| 283 | } |
| 284 | /** Render a changelog answer into `node` (exported for tests). */ |
| 285 | function render(node, data, opts) { |
| 286 | const o = opts || {}; |
| 287 | const entries = forVisitors(data && data.entries, clamp(o.limit, 1, 20, 5)); |
| 288 | const lead = devblogCard(data && data.latest_devblog); |
| 289 | if (typeof document !== 'undefined') style(); |
| 290 | node.textContent = ''; |
| 291 | if (!entries.length && !lead) { node.hidden = true; return node; } |
| 292 | node.classList.add('ov-shipped'); |
| 293 | if (o.title !== '') node.appendChild(el('h3', {}, o.title || "What's new")); |
| 294 | if (lead) node.appendChild(lead); |
| 295 | const ul = el('ul', { class: 'ov-shipped-rows' }); |
| 296 | const showSite = o.showSite != null ? o.showSite : !o.service; |
| 297 | for (const e of entries) { |
| 298 | const li = el('li', { class: 'ov-shipped-row' }); |
| 299 | const k = kindTag(e); if (k) li.appendChild(k); |
| 300 | if (showSite) li.appendChild(el('span', { class: 'ov-shipped-site' }, siteName(e.service))); |
| 301 | const href = safeUrl(e.url); |
| 302 | const text = words(e); |
| 303 | const a = el(href ? 'a' : 'span', href ? { href, class: `ov-shipped-subject${e.major ? ' ov-shipped-major' : ''}`, rel: 'noopener', target: '_blank', title: e.subject || text } : { class: `ov-shipped-subject${e.major ? ' ov-shipped-major' : ''}` }, text); |
| 304 | li.appendChild(a); |
| 305 | const chips = areaChips(e, 2); if (chips) li.appendChild(chips); |
| 306 | if (e.deployed_at) li.appendChild(timeEl(e.deployed_at)); |
| 307 | ul.appendChild(li); |
| 308 | } |
| 309 | if (entries.length) node.appendChild(ul); |
| 310 | const links = el('div', { class: 'ov-shipped-links' }); |
| 311 | const more = localHref(o.more); |
| 312 | if (more) links.appendChild(el('a', { href: more }, 'All updates →')); |
| 313 | const post = data.latest_post && safeUrl(data.latest_post.url) ? data.latest_post : null; |
| 314 | if (post) links.appendChild(el('a', { class: 'ov-shipped-notes', href: post.url }, `Patch notes: ${String(post.title || 'latest').replace(/^Patch notes:\s*/, '')} →`)); |
| 315 | if (links.childNodes.length) node.appendChild(links); |
| 316 | node.hidden = false; |
| 317 | tick(); |
| 318 | return node; |
| 319 | } |
| 320 | async function list(target, opts) { |
| 321 | const o = { ...(opts || {}) }; |
| 322 | const node = typeof target === 'string' ? document.querySelector(target) : target; |
| 323 | if (!node) return null; |
| 324 | o.service = resolveService(o.service); |
| 325 | style(); |
| 326 | return render(node, await feed({ service: o.service, limit: Math.min(50, clamp(o.limit, 1, 20, 5) * 3), api: o.api }), o); |
| 327 | } |
| 328 | |
| 329 | // ── grouping (4.47.2): one row for the same change on many sites ── |
| 330 | // A pin round lands the same commit line on thirty sites within a minute, and a fleet fix ("Touch targets on |
| 331 | // phones") on a dozen: shown one by one they bury everything else. Entries of one day with the same change fold |
| 332 | // into one row: a pin bump by package and version, anything else by its subject without the PR number. |
| 333 | const PIN_KEY = /^(openvibe-[a-z][a-z0-9-]*) v(\d+\.\d+\.\d+)(?: \(was v\d+\.\d+\.\d+\))?/; |
| 334 | const PKG = { 'openvibe-shared': ['Shared', 'OpenVibe.Shared'], 'openvibe-sdk': ['SDK', 'OpenVibe.SDK'], 'openvibe-contracts': ['Contracts', 'OpenVibe.Contracts'] }; |
| 335 | // The subject without its PR number, and without a leading "Play: " naming the entry's own site, so "Play: sign-in |
| 336 | // on …" and "Help: sign-in on …" are the same change. |
| 337 | const squash = (x) => String(x || '').replace(/[\s.-]/g, '').toLowerCase(); |
| 338 | function plainSubject(e) { |
| 339 | let s = String((e && e.subject) || '').replace(/\s*\(#\d+\)\s*$/, '').trim(); |
| 340 | const m = /^([A-Za-z][\w .-]{0,24}):\s+(.+)$/.exec(s); |
| 341 | if (m && e) { |
| 342 | const p = squash(m[1]).replace(/^openvibe/, ''); |
| 343 | if (p && (p === squash(e.service) || p === squash(siteName(e.service)))) s = m[2].charAt(0).toUpperCase() + m[2].slice(1); |
| 344 | } |
| 345 | return s; |
| 346 | } |
| 347 | function pinOf(e) { |
| 348 | const m = PIN_KEY.exec(plainSubject(e)); |
| 349 | if (!m) return null; |
| 350 | const k = PKG[m[1]] || [m[1].replace(/^openvibe-/, ''), null]; |
| 351 | return { pkg: m[1], version: m[2], name: k[0], repo: k[1] }; |
| 352 | } |
| 353 | function groupKey(e) { const p = pinOf(e); return p ? `pin:${p.pkg}@${p.version}` : `s:${plainSubject(e).toLowerCase()}`; } |
| 354 | /** Entries in their order, the same change on many sites folded into one group (exported: the server page uses it). */ |
| 355 | function groupEntries(entries, dayKey) { |
| 356 | const out = [], at = new Map(); |
| 357 | for (const e of entries || []) { |
| 358 | const k = `${dayKey ? dayKey(e) : ''}|${groupKey(e)}`; |
| 359 | const g = at.get(k); |
| 360 | if (g) g.entries.push(e); |
| 361 | else { const ng = { key: k, entries: [e], pin: pinOf(e) }; at.set(k, ng); out.push(ng); } |
| 362 | } |
| 363 | return out; |
| 364 | } |
| 365 | /** What a group's row says: "Shared 4.45.0 reached 31 sites", or the shared subject. */ |
| 366 | function groupTitle(g) { |
| 367 | const sites = new Set(g.entries.map((e) => e.service)).size; |
| 368 | if (g.pin) return `${g.pin.name} ${g.pin.version} reached ${sites} ${sites === 1 ? 'site' : 'sites'}`; |
| 369 | return plainSubject(g.entries[0]); |
| 370 | } |
| 371 | function groupNotes(g) { return g.pin && g.pin.repo ? `https://github.com/OpenVibers/${g.pin.repo}/blob/v${g.pin.version}/CHANGELOG.md` : null; } |
| 372 | |
| 373 | // ── log: the updates page ─────────────────────────────────── |
| 374 | function dayOf(iso) { return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }); } |
| 375 | function entryRow(e, showSite) { |
| 376 | const row = el('div', { class: 'ov-shipped-entry' }); |
| 377 | const sha = String(e.sha || ''); |
| 378 | const repo = String(e.repo || '').split('/').pop(); |
| 379 | const href = repo && /^[A-Za-z0-9_.-]+$/.test(repo) && /^[0-9a-f]{7,40}$/i.test(sha) |
| 380 | ? `https://git.openvibe.codes/OpenVibers/${repo}/commit/${sha}` : safeUrl(e.url); |
| 381 | row.appendChild(el(href ? 'a' : 'span', href ? { class: 'ov-shipped-hash', href, target: '_blank', rel: 'noopener', title: 'The change on OpenVibers Git' } : { class: 'ov-shipped-hash' }, e.short || sha.slice(0, 7))); |
| 382 | if (showSite) row.appendChild(el('span', { class: 'ov-shipped-site' }, siteName(e.service))); |
| 383 | const text = el('span', { class: `ov-shipped-text${e.major ? ' ov-shipped-major' : ''}`, title: String(e.subject || '') }); |
| 384 | const kind = kindTag(e); |
| 385 | if (kind) text.appendChild(kind); |
| 386 | text.appendChild(document.createTextNode(words(e))); |
| 387 | const areas = areaChips(e, 2); |
| 388 | if (areas) text.appendChild(areas); |
| 389 | row.appendChild(text); |
| 390 | if (e.kind) row.setAttribute('data-kind', String(e.kind)); |
| 391 | const meta = el('span', { class: 'ov-shipped-meta' }); |
| 392 | const time = new Date(e.deployed_at).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); |
| 393 | // Every commit is the OpenVibers account's: naming it on each row says nothing. |
| 394 | meta.textContent = `${e.author && e.author !== 'OpenVibers' ? `${e.author} · ` : ''}${time}`; |
| 395 | row.appendChild(meta); |
| 396 | return row; |
| 397 | } |
| 398 | const hhmm = (iso) => new Date(iso).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); |
| 399 | function groupRow(g, open) { |
| 400 | const row = el('div', { class: 'ov-shipped-entry ov-shipped-group' }); |
| 401 | const sites = [...new Set(g.entries.map((e) => e.service))]; |
| 402 | row.appendChild(el('span', { class: 'ov-shipped-count', title: `${g.entries.length} changes` }, String(sites.length))); |
| 403 | const text = el('span', { class: 'ov-shipped-text' }, groupTitle(g)); |
| 404 | const notes = groupNotes(g); |
| 405 | if (notes) { text.appendChild(document.createTextNode(' ')); text.appendChild(el('a', { class: 'ov-shipped-notes', href: notes, target: '_blank', rel: 'noopener' }, "What's in it")); } |
| 406 | row.appendChild(text); |
| 407 | const first = hhmm(g.entries[g.entries.length - 1].deployed_at), last = hhmm(g.entries[0].deployed_at); |
| 408 | row.appendChild(el('span', { class: 'ov-shipped-meta' }, first === last ? last : `${first}–${last}`)); |
| 409 | const fold = el('details', { class: 'ov-shipped-fold' }); |
| 410 | if (open) fold.open = true; |
| 411 | const names = sites.map(siteName); |
| 412 | fold.appendChild(el('summary', {}, names.length > 7 ? `${names.slice(0, 6).join(', ')} and ${names.length - 6} more` : names.join(', '))); |
| 413 | const list = el('div', { class: 'ov-shipped-entries' }); |
| 414 | for (const e of g.entries) list.appendChild(entryRow(e, true)); |
| 415 | fold.appendChild(list); |
| 416 | row.appendChild(fold); |
| 417 | return row; |
| 418 | } |
| 419 | /** Append entries under day headings, continuing the last day already shown (exported for tests). */ |
| 420 | function appendDays(box, entries, showSite) { |
| 421 | let day = box.lastElementChild && box.lastElementChild.classList.contains('ov-shipped-day') ? box.lastElementChild : null; |
| 422 | for (const e of entries) { |
| 423 | const d = dayOf(e.deployed_at); |
| 424 | if (!day || day.getAttribute('data-day') !== d) { |
| 425 | day = el('section', { class: 'ov-shipped-day', 'data-day': d }); |
| 426 | day.appendChild(el('h3', {}, d)); |
| 427 | day.appendChild(el('div', { class: 'ov-shipped-entries' })); |
| 428 | box.appendChild(day); |
| 429 | } |
| 430 | // The same change again on this day: it joins that row (across "Load more" pages too). |
| 431 | if (!day._groups) day._groups = new Map(); |
| 432 | const k = groupKey(e), hit = day._groups.get(k); |
| 433 | if (hit) { |
| 434 | hit.g.entries.push(e); |
| 435 | const det = hit.node.querySelector && hit.node.querySelector('details'); |
| 436 | const fresh = groupRow(hit.g, Boolean(det && det.open)); |
| 437 | hit.node.replaceWith(fresh); |
| 438 | hit.node = fresh; |
| 439 | } else if (e.kind === 'internal' && !pinOf(e)) { |
| 440 | let fold = day.querySelector(':scope > .ov-shipped-house'); |
| 441 | if (!fold) { |
| 442 | fold = el('details', { class: 'ov-shipped-house' }); |
| 443 | fold.appendChild(el('summary', {}, '')); |
| 444 | fold.appendChild(el('div', { class: 'ov-shipped-entries' })); |
| 445 | day.appendChild(fold); |
| 446 | } |
| 447 | fold.lastElementChild.appendChild(entryRow(e, showSite)); |
| 448 | const n = fold.lastElementChild.children.length; |
| 449 | fold.firstElementChild.textContent = `${n} housekeeping ${n === 1 ? 'change' : 'changes'} (library updates and internal work)`; |
| 450 | } else { |
| 451 | const node = entryRow(e, showSite); |
| 452 | day._groups.set(k, { g: { key: k, entries: [e], pin: pinOf(e) }, node }); |
| 453 | day.querySelector(':scope > .ov-shipped-entries').appendChild(node); |
| 454 | } |
| 455 | } |
| 456 | } |
| 457 | async function log(target, opts) { |
| 458 | const o = { ...(opts || {}) }; |
| 459 | const node = typeof target === 'string' ? document.querySelector(target) : target; |
| 460 | if (!node) return null; |
| 461 | style(); |
| 462 | const own = resolveService(o.service); |
| 463 | const pageSize = clamp(o.limit, 10, 100, 50); |
| 464 | const params = typeof location !== 'undefined' ? new URLSearchParams(location.search) : new URLSearchParams(); |
| 465 | // ?site= picks the site on the whole-network page (and "all" on a site's own page). |
| 466 | let current = own; |
| 467 | const asked = params.get('site'); |
| 468 | if (asked === 'all') current = null; |
| 469 | else if (asked && serviceFor(asked)) current = serviceFor(asked); |
| 470 | |
| 471 | node.textContent = ''; |
| 472 | node.classList.add('ov-shipped-log'); |
| 473 | const bar = el('div', { class: 'ov-shipped-bar', role: 'group', 'aria-label': 'Which site' }); |
| 474 | const kinds = el('div', { class: 'ov-shipped-bar ov-shipped-kinds', role: 'group', 'aria-label': 'Which kind of change' }); |
| 475 | let kindNow = KINDS[params.get('kind')] ? params.get('kind') : ''; |
| 476 | let facets = null; |
| 477 | const posts = el('aside', { class: 'ov-shipped-posts', hidden: '' }); |
| 478 | const days = el('div', { class: 'ov-shipped-days' }); |
| 479 | days.style.cssText = 'display:flex;flex-direction:column;gap:24px;min-width:0'; |
| 480 | const more = el('button', { type: 'button', class: 'ov-shipped-more', hidden: '' }, 'Load more'); |
| 481 | // The first load shows one "Loading…" line on its own (and a site's own chips, known up front), so the chips, |
| 482 | // the patch notes and the days arrive together instead of pushing a visible line down the page (layout shift). |
| 483 | const loading = el('p', { class: 'ov-shipped-empty' }, 'Loading…'); |
| 484 | function drawKinds() { |
| 485 | kinds.textContent = ''; |
| 486 | for (const [k, label] of [['', 'All changes'], ['new', 'New'], ['better', 'Improved'], ['fixed', 'Fixed']]) { |
| 487 | const count = k && facets && facets.kinds ? Number(facets.kinds[k]) || 0 : null; |
| 488 | const b = el('button', { type: 'button', class: 'ov-shipped-chip', 'aria-pressed': String(kindNow === k) }, label); |
| 489 | if (count != null) b.appendChild(el('span', { class: 'ov-shipped-n' }, ` ${count.toLocaleString()}`)); |
| 490 | b.addEventListener('click', () => { |
| 491 | kindNow = k; |
| 492 | if (o.syncUrl !== false && typeof history !== 'undefined' && history.replaceState) { |
| 493 | const u = new URL(location.href); |
| 494 | if (k) u.searchParams.set('kind', k); else u.searchParams.delete('kind'); |
| 495 | history.replaceState(history.state, '', u.pathname + u.search + u.hash); |
| 496 | } |
| 497 | drawKinds(); |
| 498 | load(true); |
| 499 | }); |
| 500 | kinds.appendChild(b); |
| 501 | } |
| 502 | } |
| 503 | drawKinds(); |
| 504 | if (kindNow) days.setAttribute('data-kind', kindNow); |
| 505 | node.append(bar, kinds, posts, loading, days, more); |
| 506 | let next = null; |
| 507 | let seq = 0; |
| 508 | |
| 509 | function setUrl(svc) { |
| 510 | if (o.syncUrl === false || typeof history === 'undefined' || !history.replaceState) return; |
| 511 | const u = new URL(location.href); |
| 512 | if (own ? svc === own : !svc) u.searchParams.delete('site'); else u.searchParams.set('site', svc || 'all'); |
| 513 | history.replaceState(history.state, '', u.pathname + u.search + u.hash); |
| 514 | } |
| 515 | function chip(label, svc) { |
| 516 | const b = el('button', { type: 'button', class: 'ov-shipped-chip', 'aria-pressed': String((svc || null) === (current || null)) }, label); |
| 517 | b.addEventListener('click', () => { current = svc || null; setUrl(current); load(true); }); |
| 518 | return b; |
| 519 | } |
| 520 | function drawBar(sites) { |
| 521 | bar.textContent = ''; |
| 522 | if (own) { bar.append(chip(siteName(own), own), chip('All of OpenVibe', null)); return; } |
| 523 | bar.append(chip('Everything', null)); |
| 524 | for (const s of (sites || []).slice(0, 30)) bar.appendChild(chip(siteName(s.service), s.service)); |
| 525 | } |
| 526 | function drawPosts(list) { |
| 527 | posts.textContent = ''; |
| 528 | const good = (list || []).filter((p) => safeUrl(p.url)).slice(0, 5); |
| 529 | if (!good.length || o.posts === false) { posts.hidden = true; return; } |
| 530 | posts.appendChild(el('h3', {}, 'Patch notes')); |
| 531 | const ul = el('ul'); |
| 532 | for (const p of good) { |
| 533 | const li = el('li'); |
| 534 | li.appendChild(el('a', { href: p.url }, p.title || 'Patch notes')); |
| 535 | if (p.published_at) li.appendChild(el('small', {}, `${new Date(p.published_at).toLocaleDateString()}${p.entries ? ` · ${p.entries} changes` : ''}`)); |
| 536 | ul.appendChild(li); |
| 537 | } |
| 538 | posts.appendChild(ul); |
| 539 | posts.hidden = false; |
| 540 | } |
| 541 | let sitesSeen = null; |
| 542 | async function load(reset) { |
| 543 | const mine = ++seq; |
| 544 | if (reset) { |
| 545 | next = null; |
| 546 | days.textContent = ''; |
| 547 | if (!loading.parentNode) days.appendChild(el('p', { class: 'ov-shipped-empty' }, 'Loading…')); |
| 548 | for (const b of bar.querySelectorAll('.ov-shipped-chip')) b.setAttribute('aria-pressed', 'false'); |
| 549 | } |
| 550 | more.disabled = true; |
| 551 | const data = await feed({ service: current, kind: kindNow || null, limit: pageSize, before: reset ? null : next, api: o.api }); |
| 552 | if (mine !== seq) return; |
| 553 | if (reset) { |
| 554 | days.textContent = ''; |
| 555 | loading.remove(); |
| 556 | if (data && data.sites) sitesSeen = data.sites; |
| 557 | if (!own && !sitesSeen) { |
| 558 | const all = await feed({ service: null, limit: 1, api: o.api }); |
| 559 | sitesSeen = all && all.sites; |
| 560 | } |
| 561 | drawBar(sitesSeen); |
| 562 | for (const b of bar.querySelectorAll('.ov-shipped-chip')) if (b.textContent === (current ? siteName(current) : (own ? 'All of OpenVibe' : 'Everything'))) b.setAttribute('aria-pressed', 'true'); |
| 563 | drawPosts(data && data.posts); |
| 564 | facets = data.facets || null; |
| 565 | drawKinds(); |
| 566 | } |
| 567 | if (!data) { |
| 568 | if (reset) days.appendChild(el('p', { class: 'ov-shipped-empty' }, 'The update history could not be loaded just now.')); |
| 569 | more.hidden = true; |
| 570 | return; |
| 571 | } |
| 572 | const entries = Array.isArray(data.entries) ? data.entries : []; |
| 573 | if (reset && !entries.length) days.appendChild(el('p', { class: 'ov-shipped-empty' }, kindNow ? `Nothing marked ${KINDS[kindNow]} in this history yet.` : 'Nothing has shipped here yet.')); |
| 574 | appendDays(days, entries, !current); |
| 575 | next = data.next || null; |
| 576 | more.hidden = !next; |
| 577 | more.disabled = false; |
| 578 | tick(); |
| 579 | } |
| 580 | more.addEventListener('click', () => load(false)); |
| 581 | if (own) drawBar(null); |
| 582 | await load(true); |
| 583 | return node; |
| 584 | } |
| 585 | |
| 586 | // ── markup ────────────────────────────────────────────────── |
| 587 | const VIEWS = { latest, list, log }; |
| 588 | /** Mount every [data-ov-shipped] element under `root` once. */ |
| 589 | function scan(rootEl) { |
| 590 | const r = rootEl || (typeof document !== 'undefined' ? document : null); |
| 591 | if (!r || !r.querySelectorAll) return []; |
| 592 | const out = []; |
| 593 | for (const n of r.querySelectorAll('[data-ov-shipped]')) { |
| 594 | if (n.hasAttribute('data-ov-shipped-mounted')) continue; |
| 595 | const view = VIEWS[n.getAttribute('data-ov-shipped')]; |
| 596 | if (!view) continue; |
| 597 | n.setAttribute('data-ov-shipped-mounted', ''); |
| 598 | const d = n.dataset || {}; |
| 599 | out.push(view(n, { service: d.service || null, limit: d.limit, title: d.title, href: d.href, more: d.more, api: d.api, posts: d.posts === 'off' ? false : undefined }).catch(() => null)); |
| 600 | } |
| 601 | return out; |
| 602 | } |
| 603 | |
| 604 | return { latest, list, mount: list, log, scan, render, renderLatest, appendDays, groupEntries, groupTitle, groupNotes, serviceFor, ago, API, SITE_NAMES, siteName, isHousekeeping }; |
| 605 | })); |
| 606 |