openvibers/openvibe.shared profile-panel.js
profile-panel.js
| 1 | /** |
| 2 | * openvibe-shared/profile-panel.js — a person's stuff on any OpenVibe site, Steam-style: what they wear, their item |
| 3 | * count, their Quest badges and their achievements in every game, with links to the full profile and inventory. |
| 4 | * Browser global `OpenVibeProfilePanel`; draw with /shared/profile-panel.css. |
| 5 | * |
| 6 | * <div data-ov-profile="ana"><a href="https://openvibe.network/@ana">Ana's profile</a></div> |
| 7 | * <script src="/shared/profile-panel.js" defer></script> |
| 8 | * OpenVibeProfilePanel.render(el, 'ana') or every [data-ov-profile] on the page, by itself, once loaded |
| 9 | * <section data-ov-profile="me" hidden>…</section> whoever is signed in on this site (its own /auth/me, the navbar's |
| 10 | * session lookup), so a cached page can show "your stuff"; shown |
| 11 | * only once drawn, left as it was for someone signed out; |
| 12 | * a hidden [data-ov-profile-section] around it (a heading and |
| 13 | * a line) is shown with it |
| 14 | * |
| 15 | * It reads OpenVibe.Network's public profile (GET https://openvibe.network/api/v1/profiles/:username: profile@1, CORS |
| 16 | * open, no credentials, cached 60 s by the Network), which gathers Inventory, Quest and Play. The panel replaces the |
| 17 | * element's content (a plain link is the no-JavaScript fallback). Every value is set as text, never markup; pictures |
| 18 | * load only from openvibe.media and openvibe.network. A private profile shows its name and picture only; an answer |
| 19 | * that does not come leaves the fallback as it was. |
| 20 | */ |
| 21 | (function (root) { |
| 22 | ; |
| 23 | const NAME = /^[A-Za-z0-9_]{1,32}$/; |
| 24 | const MEDIA_ID = /^[A-Za-z0-9_-]{1,80}$/; |
| 25 | const RARITY = ['common', 'uncommon', 'rare', 'epic', 'legendary']; |
| 26 | const SAFE_HOST = /^(?:[a-z0-9-]+\.)*(?:openvibe\.[a-z]+|openre\.stream)$/; |
| 27 | const api = () => String((root.OpenVibeProfilePanelConfig && root.OpenVibeProfilePanelConfig.api) || 'https://openvibe.network').replace(/\/+$/, ''); |
| 28 | const seen = new Map(); // username \u2192 Promise(profile | null), once a page |
| 29 | |
| 30 | function load(username) { |
| 31 | if (!seen.has(username)) { |
| 32 | // At most eight seconds, and a failure is not kept: a request held while Network restarted used to stay in |
| 33 | // this map for the page's life, so that person's panel never opened again (11-03, the Live name card). |
| 34 | const signal = typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(8000) : undefined; |
| 35 | seen.set(username, fetch(`${api()}/api/v1/profiles/${encodeURIComponent(username)}`, { credentials: 'omit', headers: { Accept: 'application/json' }, signal }) |
| 36 | .then((r) => (r.ok ? r.json() : null)).then((b) => (b && b.profile) || null) |
| 37 | .catch(() => { seen.delete(username); return null; })); |
| 38 | } |
| 39 | return seen.get(username); |
| 40 | } |
| 41 | |
| 42 | function el(tag, cls, text) { |
| 43 | const n = root.document.createElement(tag); |
| 44 | if (cls) n.className = cls; |
| 45 | if (text != null) n.textContent = String(text); |
| 46 | return n; |
| 47 | } |
| 48 | /** Only https links on the network's own hosts (openvibe.* and its subdomains, openre.stream) become hrefs. */ |
| 49 | function safeHref(href) { |
| 50 | try { |
| 51 | const u = new URL(String(href || '')); |
| 52 | return u.protocol === 'https:' && SAFE_HOST.test(u.hostname) ? u.href : null; |
| 53 | } catch { return null; } |
| 54 | } |
| 55 | function link(href, cls, text) { |
| 56 | const a = el('a', cls, text); |
| 57 | const h = safeHref(href); |
| 58 | if (h) a.href = h; |
| 59 | return a; |
| 60 | } |
| 61 | function stat(n, label) { |
| 62 | const li = el('li'); |
| 63 | li.appendChild(el('b', null, Number(n) || 0)); |
| 64 | li.appendChild(el('span', null, label)); |
| 65 | return li; |
| 66 | } |
| 67 | function section(title, cls) { |
| 68 | const s = el('div', `ovpp-sec ${cls}`); |
| 69 | s.appendChild(el('h3', 'ovpp-h', title)); |
| 70 | return s; |
| 71 | } |
| 72 | function itemArt(art) { |
| 73 | const a = art || {}; |
| 74 | const box = el('span', 'ovpp-art'); |
| 75 | if (a.media_id && MEDIA_ID.test(String(a.media_id))) { |
| 76 | const img = el('img'); |
| 77 | img.src = `https://openvibe.media/o/${a.media_id}`; |
| 78 | img.alt = ''; |
| 79 | img.loading = 'lazy'; |
| 80 | img.width = 28; img.height = 28; |
| 81 | box.appendChild(img); |
| 82 | } else box.textContent = String(a.emoji || '\u25c6').slice(0, 8); |
| 83 | box.setAttribute('aria-hidden', 'true'); |
| 84 | return box; |
| 85 | } |
| 86 | |
| 87 | /** Show the drawn panel, and the section around it that waits for it ([data-ov-profile-section], hidden until then). */ |
| 88 | function reveal(target) { |
| 89 | target.hidden = false; |
| 90 | const section = target.closest && target.closest('[data-ov-profile-section]'); |
| 91 | if (section) section.hidden = false; |
| 92 | } |
| 93 | |
| 94 | function draw(target, p) { |
| 95 | const card = el('section', 'ovpp'); |
| 96 | card.setAttribute('aria-label', `${p.display_name || p.username} on OpenVibe`); |
| 97 | const head = link(p.profile_url, 'ovpp-head'); |
| 98 | const pic = el('img', 'ovpp-pic'); |
| 99 | pic.src = `${api()}/avatar/${encodeURIComponent(p.username)}?s=96`; |
| 100 | pic.alt = ''; pic.width = 48; pic.height = 48; |
| 101 | head.appendChild(pic); |
| 102 | const who = el('span', 'ovpp-who'); |
| 103 | who.appendChild(el('b', null, p.display_name || p.username)); |
| 104 | who.appendChild(el('small', null, `@${p.username}`)); |
| 105 | head.appendChild(who); |
| 106 | card.appendChild(head); |
| 107 | if (p.private) { |
| 108 | card.appendChild(el('p', 'ovpp-note', 'This profile is private.')); |
| 109 | target.replaceChildren(card); |
| 110 | reveal(target); |
| 111 | return card; |
| 112 | } |
| 113 | const ach = p.achievements || { total: 0, games: [] }; |
| 114 | const stats = el('ul', 'ovpp-stats'); |
| 115 | stats.appendChild(stat((p.items && p.items.count) || 0, 'items')); |
| 116 | stats.appendChild(stat((p.badges || []).length, 'badges')); |
| 117 | stats.appendChild(stat(ach.total || 0, 'achievements')); |
| 118 | stats.appendChild(stat((ach.games || []).length, 'games')); |
| 119 | card.appendChild(stats); |
| 120 | |
| 121 | if ((p.showcase || []).length) { |
| 122 | const s = section('Wearing', 'ovpp-wear'); |
| 123 | const ul = el('ul', 'ovpp-chips'); |
| 124 | p.showcase.slice(0, 6).forEach((i) => { |
| 125 | const li = el('li', `ovpp-chip r-${RARITY.includes(i.rarity) ? i.rarity : 'common'}`); |
| 126 | const a = link(i.url, null); |
| 127 | a.title = `${i.kind_name || ''} \u00b7 ${i.name}`; |
| 128 | a.appendChild(itemArt(i.art)); |
| 129 | a.appendChild(el('span', null, i.name)); |
| 130 | li.appendChild(a); |
| 131 | ul.appendChild(li); |
| 132 | }); |
| 133 | s.appendChild(ul); |
| 134 | card.appendChild(s); |
| 135 | } |
| 136 | if ((ach.games || []).length) { |
| 137 | const s = section('Achievements', 'ovpp-ach'); |
| 138 | const ul = el('ul', 'ovpp-list'); |
| 139 | ach.games.slice(0, 3).forEach((g) => { |
| 140 | const li = el('li'); |
| 141 | const a = link(g.url, 'ovpp-game', g.name); |
| 142 | li.appendChild(a); |
| 143 | li.appendChild(el('small', null, ` \u00b7 ${g.count} unlocked`)); |
| 144 | const latest = (g.recent || [])[0]; |
| 145 | if (latest) li.appendChild(el('span', 'ovpp-latest', `\u{1F3C6} ${latest.name}`)); |
| 146 | ul.appendChild(li); |
| 147 | }); |
| 148 | s.appendChild(ul); |
| 149 | card.appendChild(s); |
| 150 | } |
| 151 | if ((p.badges || []).length) { |
| 152 | const s = section('Badges', 'ovpp-badges'); |
| 153 | const ul = el('ul', 'ovpp-chips'); |
| 154 | p.badges.slice(0, 8).forEach((b) => ul.appendChild(el('li', 'ovpp-chip ovpp-badge', `\u{1F3C5} ${b.name}`))); |
| 155 | s.appendChild(ul); |
| 156 | card.appendChild(s); |
| 157 | } |
| 158 | const foot = el('p', 'ovpp-links'); |
| 159 | foot.appendChild(link(p.profile_url, null, 'Full profile')); |
| 160 | if (p.inventory_url) { foot.appendChild(root.document.createTextNode(' \u00b7 ')); foot.appendChild(link(p.inventory_url, null, 'Inventory')); } |
| 161 | card.appendChild(foot); |
| 162 | target.replaceChildren(card); |
| 163 | reveal(target); |
| 164 | return card; |
| 165 | } |
| 166 | |
| 167 | /** Draw `username`'s panel into `target`. → the panel, or null (the fallback stays) when there is no answer. */ |
| 168 | /** The signed-in person's username from this site's own /auth/me (the navbar's session lookup), once a page. */ |
| 169 | let mePromise = null; |
| 170 | function me() { |
| 171 | if (!mePromise) { |
| 172 | mePromise = fetch('/auth/me', { credentials: 'same-origin', headers: { Accept: 'application/json' } }) |
| 173 | .then((r) => (r.ok ? r.json() : null)).then((b) => { |
| 174 | const u = b && b.user; |
| 175 | const name = u && (u.username || u.name); |
| 176 | return NAME.test(String(name || '')) ? String(name) : null; |
| 177 | }).catch(() => null); |
| 178 | } |
| 179 | return mePromise; |
| 180 | } |
| 181 | |
| 182 | async function render(target, username) { |
| 183 | // "me": whoever is signed in on this site; nobody signed in leaves the element as it was (hidden stays hidden). |
| 184 | const name = String(username || '') === 'me' ? await me() : String(username || ''); |
| 185 | if (!target || !NAME.test(String(name || ''))) return null; |
| 186 | const p = await load(name); |
| 187 | return p ? draw(target, p) : null; |
| 188 | } |
| 189 | |
| 190 | function renderAll(node) { |
| 191 | const scope = node || root.document; |
| 192 | scope.querySelectorAll('[data-ov-profile]').forEach((n) => { |
| 193 | if (n.dataset.ovProfileDone) return; |
| 194 | n.dataset.ovProfileDone = '1'; |
| 195 | render(n, n.getAttribute('data-ov-profile')); |
| 196 | }); |
| 197 | } |
| 198 | |
| 199 | const out = { render, renderAll, _draw: draw }; |
| 200 | root.OpenVibeProfilePanel = out; |
| 201 | if (typeof module !== 'undefined' && module.exports) module.exports = out; |
| 202 | if (root.document && root.document.addEventListener) { |
| 203 | if (root.document.readyState === 'loading') root.document.addEventListener('DOMContentLoaded', () => renderAll()); |
| 204 | else renderAll(); |
| 205 | } |
| 206 | })(typeof window !== 'undefined' ? window : globalThis); |
| 207 |