openvibers/openvibe.network Commits

Profiles: each badge wears its site's icon; item names break between words (#508)

OP OpenVibers committed 11 days ago · e6d33410df · 2 files, +26 −5

Changes

ddiff --git a/server/profile/public.js b/server/profile/public.js
iindex 05aabb84..44fdd71c 100644
---- a/server/profile/public.js
++++ b/server/profile/public.js
@@@ -20,6 +20,8 @@ const express = require('express');
const seo = require('openvibe-shared/seo');
const cache = require('openvibe-shared/cache-policy');
const usernames = require('../identity/usernames');
++const icons = require('openvibe-shared/icons');
++const { siteForHost, fullName } = require('../frame/sites');
const esc = seo.esc;
const SITE = 'https://openvibe.network';
@@@ -93,7 +95,11 @@ function createPublicProfiles({ db, selfToken = () => null, inventoryUrl = 'http
const body = await r.json();
const badges = (Array.isArray(body.badges) ? body.badges : [])
.filter((b) => b && typeof b.id === 'string' && typeof b.name === 'string')
-- .map((b) => ({ id: b.id.slice(0, 64), name: b.name.slice(0, 60), awarded_at: typeof b.awarded_at === 'string' ? b.awarded_at : null }))
++ .map((b) => ({
++ id: b.id.slice(0, 64), name: b.name.slice(0, 60), awarded_at: typeof b.awarded_at === 'string' ? b.awarded_at : null,
++ // The site whose quest awards it (Quest #173): the badge shows that site's icon.
++ site: typeof b.site === 'string' && /^[a-z0-9][a-z0-9.-]{2,60}$/.test(b.site) ? b.site : null,
++ }))
.sort((a, b) => String(b.awarded_at || '').localeCompare(String(a.awarded_at || '')));
value = { badges };
} catch (err) {
@@@ -273,7 +279,14 @@ function showcaseHtml(inv, name) {
function badgesHtml(inv) {
if (!inv.badges || !inv.badges.length) return '';
return `<section class="pf-section" aria-labelledby="pf-badges"><h2 id="pf-badges">Badges <span class="pf-count">${inv.badges.length}</span></h2>
--<ul class="pf-badges">${inv.badges.map((b) => `<li><a class="pf-badge" href="${QUEST_SITE}/" title="${esc(b.awarded_at ? `Earned ${monthOf(b.awarded_at) || ''} on OpenVibe.Quest` : 'Earned on OpenVibe.Quest')}"><span class="pf-badge-ic" aria-hidden="true">🏅</span><span>${esc(b.name)}</span>${b.awarded_at && monthOf(b.awarded_at) ? `<small>${esc(monthOf(b.awarded_at))}</small>` : ''}</a></li>`).join('')}</ul></section>`;
++<ul class="pf-badges">${inv.badges.map((b) => {
++ // Each badge wears the icon of the site whose quest awards it; the medal only when the site is not known.
++ const site = b.site ? siteForHost(b.site) : null;
++ const when = b.awarded_at && monthOf(b.awarded_at) ? monthOf(b.awarded_at) : '';
++ const title = `${when ? `Earned ${when}` : 'Earned'} on OpenVibe.Quest${site ? `: an ${fullName(site)} quest` : ''}`;
++ const ic = site && site.icon ? `<span class="pf-badge-ic is-site" aria-hidden="true">${icons.svg(site.icon)}</span>` : '<span class="pf-badge-ic" aria-hidden="true">🏅</span>';
++ return `<li><a class="pf-badge" href="${QUEST_SITE}/" title="${esc(title)}">${ic}<span>${esc(b.name)}</span>${when ? `<small>${esc(when)}</small>` : ''}</a></li>`;
++ }).join('')}</ul></section>`;
}
/** Games: each game the person played with its achievements (newest first), Steam-style; links to the game on OpenVibe.Play. */
@@@ -285,6 +298,9 @@ function gamesHtml(inv) {
<ul class="pf-ach">${g.recent.map((x) => `<li title="${esc(x.description || x.name)}"><span aria-hidden="true">🏆</span> ${esc(x.name)}${x.unlocked_at && monthOf(x.unlocked_at) ? ` <small>${esc(monthOf(x.unlocked_at))}</small>` : ''}</li>`).join('')}</ul></li>`).join('')}</ul></section>`;
}
++/** Break opportunities in a run-together name (CrackheadCarl → Crackhead<wbr>Carl): on escaped text, letters only. */
++const softBreaks = (escaped) => escaped.replace(/([a-z])([A-Z])/g, '$1<wbr>$2');
++
function itemsHtml(inv, u) {
const name = esc(u.display_name || u.username);
if (inv.unavailable) return `<section class="pf-section" aria-labelledby="pf-items"><h2 id="pf-items">Items</h2><p class="pf-empty">The items could not be loaded just now. They are safe on <a href="${INVENTORY_SITE}/">OpenVibe.Inventory</a>; try again in a minute.</p></section>`;
@@@ -301,7 +317,7 @@ function itemsHtml(inv, u) {
${sorted.map((g) => {
g.list.sort((a, b) => rarityRank(b.rarity) - rarityRank(a.rarity) || a.name.localeCompare(b.name));
return `<h3 class="pf-kind-head">${esc(g.name)} <span class="pf-count">${g.list.length}</span></h3>
--<ul class="pf-grid">${g.list.map((i) => `<li class="pf-card r-${esc(i.rarity)}${worn.has(i.instance_id) ? ' is-worn' : ''}"><a href="${esc(i.url)}" title="${esc(`${i.name} · ${RARITY_LABEL[i.rarity]}`)}">${art(i)}<span class="pf-card-name">${esc(i.name)}</span>${rarityBadge(i.rarity)}${worn.has(i.instance_id) ? '<span class="pf-worn-tag">Worn</span>' : ''}</a></li>`).join('')}</ul>`;
++<ul class="pf-grid">${g.list.map((i) => `<li class="pf-card r-${esc(i.rarity)}${worn.has(i.instance_id) ? ' is-worn' : ''}"><a href="${esc(i.url)}" title="${esc(`${i.name} · ${RARITY_LABEL[i.rarity]}`)}">${art(i)}<span class="pf-card-name">${softBreaks(esc(i.name))}</span>${rarityBadge(i.rarity)}${worn.has(i.instance_id) ? '<span class="pf-worn-tag">Worn</span>' : ''}</a></li>`).join('')}</ul>`;
}).join('\n')}
<p class="pf-note"><a href="${esc(all)}">${inv.more ? 'Every item' : 'This inventory'} on OpenVibe.Inventory</a> · <a href="${INVENTORY_SITE}/items">Every item there is</a></p></section>`;
}
@@@ -404,7 +420,7 @@ a{color:var(--accent)}
.pf-badge{display:inline-flex;align-items:center;gap:8px;padding:7px 14px 7px 10px;border-radius:999px;border:1px solid color-mix(in srgb,var(--r-legendary) 40%,var(--border));background:color-mix(in srgb,var(--r-legendary) 10%,var(--bg-secondary));color:var(--text-primary);text-decoration:none;font-weight:600;font-size:.88rem}
.pf-badge:hover,.pf-badge:focus-visible{border-color:var(--r-legendary)}
.pf-badge small{font-weight:500;color:var(--text-muted);font-size:.75rem}
--.pf-badge-ic{font-size:1.05rem}
++.pf-badge-ic{font-size:1.05rem}.pf-badge-ic.is-site{display:inline-grid;place-items:center;width:20px;height:20px;color:var(--accent)}.pf-badge-ic.is-site svg{width:16px;height:16px}
.pf-games{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(auto-fill,minmax(min(300px,100%),1fr));gap:12px}
.pf-game{border:1px solid var(--border);border-radius:14px;background:var(--bg-secondary);padding:12px 14px;min-width:0}
.pf-game-head{display:flex;justify-content:space-between;gap:10px;align-items:baseline;text-decoration:none;color:inherit;margin-bottom:8px}
ddiff --git a/test/public-profile.test.js b/test/public-profile.test.js
iindex 8199df70..2ebc8346 100644
---- a/test/public-profile.test.js
++++ b/test/public-profile.test.js
@@@ -64,7 +64,7 @@ const INSTANCES = [
if (u.pathname === `/api/v1/people/${A}/equipped`) return json(200, { subject: A, slots: { 'live.hat:hat': { instance_id: INSTANCES[0].id, definition_id: INSTANCES[0].definition_id }, 'live.name_effect:name_effect': { instance_id: INSTANCES[1].id, definition_id: INSTANCES[1].definition_id, token: 'name-fx-rainbow' }, 'network.badge:badge': { instance_id: INSTANCES[3].id, definition_id: INSTANCES[3].definition_id, media_id: 'med_01JZ00000000000000000000B1' } } });
if (u.pathname === `/api/v1/people/${A}/items`) return json(200, { subject: A, instances: INSTANCES, definitions: DEFS, next_cursor: null });
if (u.pathname === `/api/v1/people/${A}/achievements`) return json(200, { subject: A, total: 3, titles: [{ title: 'salvage-rush', name: 'Salvage <Rush>', achievements: [{ id: 'big-haul', name: 'Big Haul', description: 'Bring back 50 parts', unlocked_at: '2026-10-20T10:00:00Z' }, { id: 'first-haul', name: 'First Haul', description: '', unlocked_at: '2026-10-19T10:00:00Z' }] }, { title: 'vibe-heroes', name: 'Vibe Heroes', achievements: [{ id: 'hello', name: 'Hello', unlocked_at: '2026-10-01T10:00:00Z' }] }] });
-- if (u.pathname === `/api/v1/profiles/${A}/badges`) return json(200, { subject: A, badges: [{ id: 'first-follow', name: 'First Follow', awarded_at: '2026-10-02T10:00:00Z' }, { id: 'thread-starter', name: 'Thread <Starter>', awarded_at: '2026-10-05T10:00:00Z' }] });
++ if (u.pathname === `/api/v1/profiles/${A}/badges`) return json(200, { subject: A, badges: [{ id: 'first-follow', name: 'First Follow', awarded_at: '2026-10-02T10:00:00Z', site: 'openvibe.live' }, { id: 'thread-starter', name: 'Thread <Starter>', awarded_at: '2026-10-05T10:00:00Z' }] });
return json(404, { code: 'inventory.unknown_subject' });
});
await new Promise((r) => inv.listen(0, '127.0.0.1', r));
@@@ -105,6 +105,11 @@ const INSTANCES = [
assert.ok(/<span class="pf-stat-k">Achievements<\/span><span class="pf-stat-v">3<\/span>/.test(r.text));
assert.strictEqual(calls.find((c) => c.path.endsWith('/badges')).auth, 'Bearer tok:openvibe.quest:quest.badge.read', 'and for Quest');
assert.ok(r.text.includes('id="pf-badges"') && /Thread &lt;Starter&gt;[\s\S]*First Follow/.test(r.text), 'badges, newest first, escaped');
++ // A badge wears the icon of the site whose quest awards it (Quest #173); one with no site keeps the medal.
++ const follow = r.text.slice(r.text.lastIndexOf('<li>', r.text.indexOf('<span>First Follow</span>')), r.text.indexOf('<span>First Follow</span>'));
++ assert.ok(follow.includes('class="pf-badge-ic is-site"') && follow.includes('<svg') && follow.includes('an OpenVibe.Live quest'), 'First Follow shows Live\'s icon');
++ const thread = r.text.slice(r.text.lastIndexOf('<li>', r.text.indexOf('Thread &lt;Starter&gt;')), r.text.indexOf('Thread &lt;Starter&gt;'));
++ assert.ok(thread.includes('🏅') && !thread.includes('is-site'), 'a badge with no site keeps the medal');
// The worn badge's image rule (.85em square) once shared the chips' class and squeezed every chip into it.
const css = (sel) => (r.text.match(new RegExp(`\\n${sel.replace('.', '\\.')}\\{([^}]*)\\}`)) || [])[1] || '';
assert.ok(/padding:7px 14px/.test(css('.pf-badge')) && !/width/.test(css('.pf-badge')), 'a badge chip sizes to its name');