openvibers/openvibe.network Commits
On OpenVibe now: one row per thing, the window said once (#783)
Changes
@@@ -616,7 +616,8 @@ button.td-stat:hover span { color: var(--accent-light); }.td-net-h { display: flex; align-items: center; gap: 10px; padding-bottom: 6px; }.td-net-ht { display: grid; flex: 1; min-width: 0; }.td-net-ht b { font-size: 15px; font-weight: 800; }--.td-net-ht small { font-size: 12px; color: var(--text-muted); }++.td-net-ht small { font-size: 12px; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.td-net-quiet { opacity: .75; font-style: italic; }.td-net-card[data-site="media-hub"] .td-site-ic { color: #f472b6; background: color-mix(in srgb, #ec4899 16%, transparent); }.td-net-card[data-site="games"] .td-site-ic { color: #4ade80; background: color-mix(in srgb, #22c55e 16%, transparent); }.td-net-card[data-site="live"] .td-site-ic { color: #f87171; background: color-mix(in srgb, #ef4444 16%, transparent); }@@@ -167,29 +167,38 @@ function renderNet(frame) {}).sort((a, b) => b.rank - a.rank).slice(0, 3);wrap.replaceChildren(...cards.map((c) => {const site = TD_SITES[c.site] || [c.site || 'OpenVibe', 'fa-globe', null];++ // One row per thing: the same title by the same person folds into one, whatever address each copy has (a++ // channel's streams each have their own page: "iZoR_One's Stream" four times was one channel, 2026-11-10).const grouped = new Map();for (const row of c.rows.sort((a, b) => (a.ageMs ?? Infinity) - (b.ageMs ?? Infinity))) {-- const key = `${row.title}\u0000${row.person || row.author || row.url}`;++ const key = `${String(row.title || '').trim().toLowerCase()}\u0000${row.person || row.author || ''}`;const old = grouped.get(key);if (old) old.count++;else grouped.set(key, { ...row, count: 1 });}const stale = c.newest !== Infinity && c.newest > 7 * 86400000;const visibleRows = [...grouped.values()].slice(0, stale ? 2 : 5);++ // A ranked card (no row has an age: "most watched this week") says its window once, in its head, not on every row.++ const ranked = c.newest === Infinity;++ const streams = /^(live|media-hub|video)$/.test(String(c.site || ''));const list = tdEl('ol', 'td-net-list', visibleRows.map((it, i) => {const src = it.image && tdSafeUrl(it.image.src);-- const when = it.ageMs == null ? (c.window || 'most watched this week') : String(it.meta || '').match(/\d+\s*(?:m|min|h|d|w)\s*ago/i)?.[0] || 'recently';-- const detail = [it.count > 1 ? `${it.count} streams this week` : '', when].filter(Boolean).join(' · ');++ const when = ranked ? '' : String(it.meta || '').match(/\d+\s*(?:m|min|h|d|w)\s*ago/i)?.[0] || 'recently';++ const times = it.count > 1 ? (streams ? `${it.count} streams this week` : `${it.count} times`) : '';++ const detail = [times, when].filter(Boolean).join(' · ');return tdEl('li', '', [tdEl('a', 'td-net-row', [tdEl('span', 'td-net-i', [String(i + 1)]),src ? tdEl('img', 'td-net-thumb', null, { src, alt: '', width: 64, height: 36, loading: 'lazy', decoding: 'async' }) : null,-- tdEl('span', 'td-net-t', [tdEl('b', '', [String(it.title || '')]), tdEl('small', '', [detail])]),++ tdEl('span', 'td-net-t', [tdEl('b', '', [String(it.title || '')]), detail ? tdEl('small', '', [detail]) : null]),], { href: tdSafeUrl(it.url) })]);}));const more = c.more && tdSafeUrl(c.more.url) ? tdEl('a', 'td-more', [String(c.more.label || 'More')], { href: tdSafeUrl(c.more.url) }) : null;return tdEl('section', 'td-box td-net-card', [-- tdEl('div', 'td-net-h', [tdEl('span', 'td-site-ic', [tdIcon(site[1])]), tdEl('span', 'td-net-ht', [tdEl('b', '', [String(c.title || site[0])]), tdEl('small', '', [site[0]])]), more]),-- stale ? tdEl('small', '', [`quiet for ${Math.floor(c.newest / 86400000)} days`]) : null,++ // The head's second line: the site, then the ranking window, or for a card nothing new reached in a week how long++ // it has been quiet (muted: it explains why the card is short; it is not news).++ tdEl('div', 'td-net-h', [tdEl('span', 'td-site-ic', [tdIcon(site[1])]), tdEl('span', 'td-net-ht', [tdEl('b', '', [String(c.title || site[0])]),++ tdEl('small', '', [site[0], ranked ? tdEl('span', 'td-net-when', [` · ${c.window || 'most watched this week'}`]) : null,++ stale ? tdEl('span', 'td-net-quiet', [` · quiet for ${Math.floor(c.newest / 86400000)} days`]) : null])]), more]),list,], { 'data-site': String(c.site || '') });}));@@@ -121,13 +121,23 @@ assert.match(CSS, /@media \(min-width:1000px\)\{[\s\S]*\.settings\{display:grid;const ordered = p.ids['td-net-cards'].children;assert.deepStrictEqual(ordered.map((c) => c.attrs['data-site']), ['live', 'media-hub', 'community'], 'live leads, then fresh video, then stale forum');assert.match(ordered[1].textContent, /1d ago/, 'a row states its age');-- assert.match(ordered[2].textContent, /quiet for 18 days/, 'stale cards say why they are quiet');++ assert.match(ordered[2].textContent, /quiet for 18 days/, 'stale cards say why they are quiet, in the head');++ assert.strictEqual(ordered[2].all((c) => c.className === 'td-net-quiet').length, 1, 'muted, beside the site');assert.strictEqual(ordered[2].all((c) => c.tagName === 'LI').length, 1, 'stale card keeps its newest row');p.sandbox.renderNet({ cards: [{ id: 'streams', site: 'live', title: 'Most watched', items: [{ title: 'Same stream', url: 'https://openvibe.live/@a', meta: '1d ago' },{ title: 'Same stream', url: 'https://openvibe.live/@a', meta: '2d ago' },] }] });assert.match(p.ids['td-net-cards'].textContent, /2 streams this week/, 'same title and person fold into one row');++ // A ranked card (no ages): each copy of a stream has its own address and still folds; the window is said once, in the head.++ p.sandbox.renderNet({ cards: [{ id: 'trend', site: 'media-hub', title: 'Trending', window: 'most watched this week', items: [++ { title: "iZoR_One's Stream", url: 'https://openvibe.video/v/1' }, { title: "iZoR_One's Stream", url: 'https://openvibe.video/v/2' },++ { title: 'LOTFX Live Trading', url: 'https://openvibe.video/v/3' }, { title: "iZoR_One's Stream", url: 'https://openvibe.video/v/4' },++ ] }] });++ const trend = p.ids['td-net-cards'].children[0];++ assert.strictEqual(trend.all((c) => c.tagName === 'LI').length, 2, 'four rows of two things: two rows');++ assert.strictEqual((trend.textContent.match(/most watched this week/g) || []).length, 1, 'the window once, in the head');++ assert.match(trend.textContent, /3 streams this week/, 'the folded row says how many');p.sandbox.renderNet(null);assert.strictEqual(p.ids['td-net'].hidden, true, 'no cards: the block stays out');
@@ -616,7 +616,8 @@ button.td-stat:hover span { color: var(--accent-light); }
@@ -616,7 +616,8 @@ button.td-stat:hover span { color: var(--accent-light); }
.td-net-h { display: flex; align-items: center; gap: 10px; padding-bottom: 6px; }
.td-net-h { display: flex; align-items: center; gap: 10px; padding-bottom: 6px; }
.td-net-ht { display: grid; flex: 1; min-width: 0; }
.td-net-ht { display: grid; flex: 1; min-width: 0; }
.td-net-ht b { font-size: 15px; font-weight: 800; }
.td-net-ht b { font-size: 15px; font-weight: 800; }
-.td-net-ht small { font-size: 12px; color: var(--text-muted); }
+.td-net-ht small { font-size: 12px; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.td-net-quiet { opacity: .75; font-style: italic; }
.td-net-card[data-site="media-hub"] .td-site-ic { color: #f472b6; background: color-mix(in srgb, #ec4899 16%, transparent); }
.td-net-card[data-site="media-hub"] .td-site-ic { color: #f472b6; background: color-mix(in srgb, #ec4899 16%, transparent); }
.td-net-card[data-site="games"] .td-site-ic { color: #4ade80; background: color-mix(in srgb, #22c55e 16%, transparent); }
.td-net-card[data-site="games"] .td-site-ic { color: #4ade80; background: color-mix(in srgb, #22c55e 16%, transparent); }
.td-net-card[data-site="live"] .td-site-ic { color: #f87171; background: color-mix(in srgb, #ef4444 16%, transparent); }
.td-net-card[data-site="live"] .td-site-ic { color: #f87171; background: color-mix(in srgb, #ef4444 16%, transparent); }
@@ -167,29 +167,38 @@ function renderNet(frame) {
@@ -167,29 +167,38 @@ function renderNet(frame) {
}).sort((a, b) => b.rank - a.rank).slice(0, 3);
}).sort((a, b) => b.rank - a.rank).slice(0, 3);
wrap.replaceChildren(...cards.map((c) => {
wrap.replaceChildren(...cards.map((c) => {
const site = TD_SITES[c.site] || [c.site || 'OpenVibe', 'fa-globe', null];
const site = TD_SITES[c.site] || [c.site || 'OpenVibe', 'fa-globe', null];
+ // One row per thing: the same title by the same person folds into one, whatever address each copy has (a
+ // channel's streams each have their own page: "iZoR_One's Stream" four times was one channel, 2026-11-10).
const grouped = new Map();
const grouped = new Map();
for (const row of c.rows.sort((a, b) => (a.ageMs ?? Infinity) - (b.ageMs ?? Infinity))) {
for (const row of c.rows.sort((a, b) => (a.ageMs ?? Infinity) - (b.ageMs ?? Infinity))) {
- const key = `${row.title}\u0000${row.person || row.author || row.url}`;
+ const key = `${String(row.title || '').trim().toLowerCase()}\u0000${row.person || row.author || ''}`;
const old = grouped.get(key);
const old = grouped.get(key);
if (old) old.count++;
if (old) old.count++;
else grouped.set(key, { ...row, count: 1 });
else grouped.set(key, { ...row, count: 1 });
}
}
const stale = c.newest !== Infinity && c.newest > 7 * 86400000;
const stale = c.newest !== Infinity && c.newest > 7 * 86400000;
const visibleRows = [...grouped.values()].slice(0, stale ? 2 : 5);
const visibleRows = [...grouped.values()].slice(0, stale ? 2 : 5);
+ // A ranked card (no row has an age: "most watched this week") says its window once, in its head, not on every row.
+ const ranked = c.newest === Infinity;
+ const streams = /^(live|media-hub|video)$/.test(String(c.site || ''));
const list = tdEl('ol', 'td-net-list', visibleRows.map((it, i) => {
const list = tdEl('ol', 'td-net-list', visibleRows.map((it, i) => {
const src = it.image && tdSafeUrl(it.image.src);
const src = it.image && tdSafeUrl(it.image.src);
- const when = it.ageMs == null ? (c.window || 'most watched this week') : String(it.meta || '').match(/\d+\s*(?:m|min|h|d|w)\s*ago/i)?.[0] || 'recently';
- const detail = [it.count > 1 ? `${it.count} streams this week` : '', when].filter(Boolean).join(' · ');
+ const when = ranked ? '' : String(it.meta || '').match(/\d+\s*(?:m|min|h|d|w)\s*ago/i)?.[0] || 'recently';
+ const times = it.count > 1 ? (streams ? `${it.count} streams this week` : `${it.count} times`) : '';
+ const detail = [times, when].filter(Boolean).join(' · ');
return tdEl('li', '', [tdEl('a', 'td-net-row', [
return tdEl('li', '', [tdEl('a', 'td-net-row', [
tdEl('span', 'td-net-i', [String(i + 1)]),
tdEl('span', 'td-net-i', [String(i + 1)]),
src ? tdEl('img', 'td-net-thumb', null, { src, alt: '', width: 64, height: 36, loading: 'lazy', decoding: 'async' }) : null,
src ? tdEl('img', 'td-net-thumb', null, { src, alt: '', width: 64, height: 36, loading: 'lazy', decoding: 'async' }) : null,
- tdEl('span', 'td-net-t', [tdEl('b', '', [String(it.title || '')]), tdEl('small', '', [detail])]),
+ tdEl('span', 'td-net-t', [tdEl('b', '', [String(it.title || '')]), detail ? tdEl('small', '', [detail]) : null]),
], { href: tdSafeUrl(it.url) })]);
], { href: tdSafeUrl(it.url) })]);
}));
}));
const more = c.more && tdSafeUrl(c.more.url) ? tdEl('a', 'td-more', [String(c.more.label || 'More')], { href: tdSafeUrl(c.more.url) }) : null;
const more = c.more && tdSafeUrl(c.more.url) ? tdEl('a', 'td-more', [String(c.more.label || 'More')], { href: tdSafeUrl(c.more.url) }) : null;
return tdEl('section', 'td-box td-net-card', [
return tdEl('section', 'td-box td-net-card', [
- tdEl('div', 'td-net-h', [tdEl('span', 'td-site-ic', [tdIcon(site[1])]), tdEl('span', 'td-net-ht', [tdEl('b', '', [String(c.title || site[0])]), tdEl('small', '', [site[0]])]), more]),
- stale ? tdEl('small', '', [`quiet for ${Math.floor(c.newest / 86400000)} days`]) : null,
+ // The head's second line: the site, then the ranking window, or for a card nothing new reached in a week how long
+ // it has been quiet (muted: it explains why the card is short; it is not news).
+ tdEl('div', 'td-net-h', [tdEl('span', 'td-site-ic', [tdIcon(site[1])]), tdEl('span', 'td-net-ht', [tdEl('b', '', [String(c.title || site[0])]),
+ tdEl('small', '', [site[0], ranked ? tdEl('span', 'td-net-when', [` · ${c.window || 'most watched this week'}`]) : null,
+ stale ? tdEl('span', 'td-net-quiet', [` · quiet for ${Math.floor(c.newest / 86400000)} days`]) : null])]), more]),
list,
list,
], { 'data-site': String(c.site || '') });
], { 'data-site': String(c.site || '') });
}));
}));
@@ -121,13 +121,23 @@ assert.match(CSS, /@media \(min-width:1000px\)\{[\s\S]*\.settings\{display:grid;
@@ -121,13 +121,23 @@ assert.match(CSS, /@media \(min-width:1000px\)\{[\s\S]*\.settings\{display:grid;
const ordered = p.ids['td-net-cards'].children;
const ordered = p.ids['td-net-cards'].children;
assert.deepStrictEqual(ordered.map((c) => c.attrs['data-site']), ['live', 'media-hub', 'community'], 'live leads, then fresh video, then stale forum');
assert.deepStrictEqual(ordered.map((c) => c.attrs['data-site']), ['live', 'media-hub', 'community'], 'live leads, then fresh video, then stale forum');
assert.match(ordered[1].textContent, /1d ago/, 'a row states its age');
assert.match(ordered[1].textContent, /1d ago/, 'a row states its age');
- assert.match(ordered[2].textContent, /quiet for 18 days/, 'stale cards say why they are quiet');
+ assert.match(ordered[2].textContent, /quiet for 18 days/, 'stale cards say why they are quiet, in the head');
+ assert.strictEqual(ordered[2].all((c) => c.className === 'td-net-quiet').length, 1, 'muted, beside the site');
assert.strictEqual(ordered[2].all((c) => c.tagName === 'LI').length, 1, 'stale card keeps its newest row');
assert.strictEqual(ordered[2].all((c) => c.tagName === 'LI').length, 1, 'stale card keeps its newest row');
p.sandbox.renderNet({ cards: [{ id: 'streams', site: 'live', title: 'Most watched', items: [
p.sandbox.renderNet({ cards: [{ id: 'streams', site: 'live', title: 'Most watched', items: [
{ title: 'Same stream', url: 'https://openvibe.live/@a', meta: '1d ago' },
{ title: 'Same stream', url: 'https://openvibe.live/@a', meta: '1d ago' },
{ title: 'Same stream', url: 'https://openvibe.live/@a', meta: '2d ago' },
{ title: 'Same stream', url: 'https://openvibe.live/@a', meta: '2d ago' },
] }] });
] }] });
assert.match(p.ids['td-net-cards'].textContent, /2 streams this week/, 'same title and person fold into one row');
assert.match(p.ids['td-net-cards'].textContent, /2 streams this week/, 'same title and person fold into one row');
+ // A ranked card (no ages): each copy of a stream has its own address and still folds; the window is said once, in the head.
+ p.sandbox.renderNet({ cards: [{ id: 'trend', site: 'media-hub', title: 'Trending', window: 'most watched this week', items: [
+ { title: "iZoR_One's Stream", url: 'https://openvibe.video/v/1' }, { title: "iZoR_One's Stream", url: 'https://openvibe.video/v/2' },
+ { title: 'LOTFX Live Trading', url: 'https://openvibe.video/v/3' }, { title: "iZoR_One's Stream", url: 'https://openvibe.video/v/4' },
+ ] }] });
+ const trend = p.ids['td-net-cards'].children[0];
+ assert.strictEqual(trend.all((c) => c.tagName === 'LI').length, 2, 'four rows of two things: two rows');
+ assert.strictEqual((trend.textContent.match(/most watched this week/g) || []).length, 1, 'the window once, in the head');
+ assert.match(trend.textContent, /3 streams this week/, 'the folded row says how many');
p.sandbox.renderNet(null);
p.sandbox.renderNet(null);
assert.strictEqual(p.ids['td-net'].hidden, true, 'no cards: the block stays out');
assert.strictEqual(p.ids['td-net'].hidden, true, 'no cards: the block stays out');