openvibers/openvibe.network Commits

Front door: the most visited things to do lead, with this week's visits (L43) (#473)

OP OpenVibers committed 12 days ago · c734d1e050 · 5 files, +49 −7

Changes

ddiff --git a/public/index.html b/public/index.html
iindex a8390cec..b48bb159 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -109,7 +109,7 @@ a{color:inherit}
.intents{list-style:none;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin-top:14px}
.intent{display:flex;gap:12px;align-items:flex-start;padding:11px 12px;border-radius:12px;border:1px solid var(--border);background:color-mix(in srgb,var(--bg-secondary) 80%,transparent);text-decoration:none;color:inherit;transition:border-color .15s,background .15s,opacity .15s,transform .15s;height:100%}
.intent>i{width:34px;height:34px;flex:none;display:grid;place-items:center;border-radius:10px;font-size:15px;color:var(--accent-light);background:color-mix(in srgb,var(--accent) 14%,transparent)}
--.intent b{display:block;font-size:14.5px;letter-spacing:-.1px}.intent small{display:block;color:var(--text-secondary);font-size:12.5px;line-height:1.4;margin-top:1px}
++.intent b{display:block;font-size:14.5px;letter-spacing:-.1px}.intent-n{display:inline-block;margin-top:5px;padding:1px 7px;border-radius:999px;font-style:normal;font-size:11px;font-weight:650;color:var(--accent-light);background:color-mix(in srgb,var(--accent) 14%,transparent)}.intent small{display:block;color:var(--text-secondary);font-size:12.5px;line-height:1.4;margin-top:1px}
.intent:hover,.intent:focus-visible{border-color:var(--accent);background:var(--bg-hover);outline:0;transform:translateY(-1px)}
.intent.match{border-color:var(--accent);background:var(--bg-hover)}
.intent.dim{opacity:.38}
ddiff --git a/server/home/activity.js b/server/home/activity.js
iindex fb253c05..6f2683cf 100644
---- a/server/home/activity.js
++++ b/server/home/activity.js
@@@ -22,7 +22,7 @@ const SOURCES = {
};
const PUBLIC = { live: 'https://openvibe.live', video: 'https://openvibe.video', community: 'https://openvibe.community', blog: 'https://openvibe.blog', work: 'https://openvibe.work', coupons: 'https://openvibe.coupons' };
--const EMPTY = { live: [], liveCount: 0, recordings: [], pastes: [], posts: [], jobs: [], codes: [], counts: {} };
++const EMPTY = { live: [], liveCount: 0, recordings: [], pastes: [], posts: [], jobs: [], codes: [], counts: {}, popularity: null };
let state = { at: 0, version: 0, ...EMPTY };
let inflight = null;
// Tests switch the reads off; the page then shows the "nothing live" state.
@@@ -182,7 +182,7 @@ async function refresh({ fetch: fetchImpl = globalThis.fetch } = {}) {
if (inflight) return inflight;
inflight = (async () => {
const q = (owner, type) => `${SOURCES.search}/api/v1/search?owner=${owner}&type=${type}&limit=12`;
-- const [live, vods, pastes, posts, work, codes, accounts, services] = await Promise.all([
++ const [live, vods, pastes, posts, work, codes, accounts, services, popularity] = await Promise.all([
getJson(`${SOURCES.live}/api/streams`, fetchImpl),
getJson(`${SOURCES.live}/api/vods?limit=40`, fetchImpl),
getJson(`${SOURCES.community}/api/pastes?limit=12`, fetchImpl),
@@@ -191,6 +191,7 @@ async function refresh({ fetch: fetchImpl = globalThis.fetch } = {}) {
getJson(q('coupons', 'coupon'), fetchImpl),
readCount(readers.accounts),
readCount(readers.services),
++ readCount(readers.popularity),
]);
const counts = { ...state.counts };
if (Number.isFinite(Number(accounts)) && Number(accounts) > 0) counts.accounts = Number(accounts);
@@@ -206,6 +207,8 @@ async function refresh({ fetch: fetchImpl = globalThis.fetch } = {}) {
jobs: work ? jobs(work) : state.jobs,
codes: codes ? coupons(codes) : state.codes,
counts,
++ // Visits per site this week (Network's open stats): the front door's intents are ordered by them.
++ popularity: popularity && typeof popularity === 'object' ? popularity : state.popularity,
};
return state;
})().finally(() => { inflight = null; });
@@@ -220,7 +223,8 @@ function peek() {
module.exports = {
peek, refresh, liveStreams, recordings, publicPastes, blogPosts, jobs, coupons, runtime, ADULT, LOOKS,
-- /** server/index.js: Network's own numbers, read on each refresh ({ accounts: async () => n, services: () => ({ up, live }) }). */
++ /** server/index.js: Network's own numbers, read on each refresh ({ accounts: async () => n, services: () => ({ up, live }),
++ * popularity: async () => ({ siteId: visits this week }) }). */
configure(r) { readers = { ...readers, ...(r || {}) }; },
/** Tests: switch the background reads on or off. */
setEnabled(on) { enabled = Boolean(on); },
ddiff --git a/server/home/render.js b/server/home/render.js
iindex f9075f2a..28b895b0 100644
---- a/server/home/render.js
++++ b/server/home/render.js
@@@ -75,9 +75,23 @@ const INTENTS = [
['help', 'fa-life-ring', 'Get help', 'Every OpenVibe site\'s questions answered, and a person when they are not.', 'https://openvibe.help/', 'help support faq question questions ticket contact problem how'],
];
--function intents(catalog) {
++// The site whose visits rank an intent (the owner's plan L43: "the most popular things to do now, from the numbers").
++// One site counts once: Ask Vibe and Chat are both openvibe.chat, so Chat carries its number; pictures and files are
++// openvibe.video's products, so Video does. An intent with no site of its own keeps its place after the ranked ones.
++const INTENT_SITE = { vibe: null, paste: 'community', video: 'media-hub', pics: null, 'media-hub': null };
++const siteOfIntent = (id) => (Object.prototype.hasOwnProperty.call(INTENT_SITE, id) ? INTENT_SITE[id] : id);
++const fmtVisits = (n) => (n >= 10000 ? `${Math.round(n / 1000)}k` : n >= 1000 ? `${(n / 1000).toFixed(1).replace(/\.0$/, '')}k` : String(n));
++/** The intents, most visited first this week; the first five say how many visits. Unknown numbers keep the order. */
++function rankedIntents(popularity) {
++ const visits = (id) => { const s = siteOfIntent(id); const n = s && popularity ? Number(popularity[s]) : NaN; return Number.isFinite(n) && n > 0 ? n : 0; };
++ if (!popularity) return INTENTS.map((it) => ({ it, n: 0 }));
++ return INTENTS.map((it, i) => ({ it, n: visits(it[0]), i })).sort((a, b) => (b.n - a.n) || (a.i - b.i));
++}
++
++function intents(catalog, popularity = null) {
const toolsLine = catalog.tools.length ? `Convert, compress, look up or download: ${catalog.tools.length} tools that just open.` : 'Convert, compress, look up or download: free tools that just open.';
-- return `<ul class="intents" aria-label="What you can do here">${INTENTS.map(([id, fa, verb, line, href, k]) => `<li><a class="intent" href="${esc(href)}" data-id="${esc(id)}" data-k="${esc(k)}"><i class="fa-solid ${esc(fa)}" aria-hidden="true"></i><span><b>${esc(verb)}</b><small>${esc(line || toolsLine)}</small></span></a></li>`).join('')}</ul>`;
++ const ranked = rankedIntents(popularity);
++ return `<ul class="intents" aria-label="What you can do here${popularity ? ', most visited this week first' : ''}">${ranked.map(({ it: [id, fa, verb, line, href, k], n }, pos) => `<li><a class="intent" href="${esc(href)}" data-id="${esc(id)}" data-k="${esc(k)}"><i class="fa-solid ${esc(fa)}" aria-hidden="true"></i><span><b>${esc(verb)}</b><small>${esc(line || toolsLine)}</small>${pos < 5 && n >= 10 ? `<em class="intent-n">${esc(fmtVisits(n))} visits this week</em>` : ''}</span></a></li>`).join('')}</ul>`;
}
// The network as an orbit (the owner, 2026-10-28: the hero was "ugly af, boring… not easy to understand… nice animated
@@@ -421,7 +435,7 @@ function render({ releaseId = '' } = {}) {
})) + seo.jsonLdTag(seo.jsonLd.itemList(sitesHeading(), OPEN.map((site) => ({ name: siteName(site), url: `https://${site.host}/`, description: site.what })))) + seo.jsonLdTag(seo.jsonLd.faq(faqPairs()));
const card = social.url('network', 'https://openvibe.network/shared') || 'https://openvibe.network/assets/logo-512.png';
const summary = summaryOf(catalog);
-- const html = lookBoot.apply(fs.readFileSync(SHELL, 'utf8')).replace('<div id="navbar-mount"></div>', '<div id="navbar-mount"></div>' + summary.body + require('openvibe-shared/frame').noscriptNav({ name: 'OpenVibe.Network', links: [{ label: 'Sign in', href: '/login' }, { label: 'Themes', href: '/themes' }] })).replace('<!--OV:HOME-->', body(catalog, act)).replace('<!--OV:INTENTS-->', intents(catalog)).replace('<!--OV:CONSTELLATION-->', () => orbit() + numbers(catalog, act)).replaceAll('__OV_CARD__', card).replace('<!--OV:SITES-->', String(OPEN.length + 1)).replace('<!--OV:COUNT-->', catalog.tools.length ? String(catalog.tools.length) : 'free').replace('<div id="ov-footer"></div>', require('openvibe-shared/footer').ssr({ service: 'network', variant: 'full' })).replace('</head>', () => `${ld}\n${summary.head}\n${boost.meta(releaseId)}\n</head>`);
++ const html = lookBoot.apply(fs.readFileSync(SHELL, 'utf8')).replace('<div id="navbar-mount"></div>', '<div id="navbar-mount"></div>' + summary.body + require('openvibe-shared/frame').noscriptNav({ name: 'OpenVibe.Network', links: [{ label: 'Sign in', href: '/login' }, { label: 'Themes', href: '/themes' }] })).replace('<!--OV:HOME-->', body(catalog, act)).replace('<!--OV:INTENTS-->', intents(catalog, act.popularity)).replace('<!--OV:CONSTELLATION-->', () => orbit() + numbers(catalog, act)).replaceAll('__OV_CARD__', card).replace('<!--OV:SITES-->', String(OPEN.length + 1)).replace('<!--OV:COUNT-->', catalog.tools.length ? String(catalog.tools.length) : 'free').replace('<div id="ov-footer"></div>', require('openvibe-shared/footer').ssr({ service: 'network', variant: 'full' })).replace('</head>', () => `${ld}\n${summary.head}\n${boost.meta(releaseId)}\n</head>`);
pageCache = { key, html, etag: '"' + crypto.createHash('sha1').update(html).digest('base64url').slice(0, 20) + '"' };
return pageCache;
}
ddiff --git a/server/index.js b/server/index.js
iindex 90ece385..bc500470 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -818,6 +818,12 @@ app.get(['/login.html', '/admin.html'], (req, res) => {
// accounts (people, not guests or banned ones) and how many public services answered their last health check (a
// count is left out until at least half of them have been checked once).
require('./home/activity').configure({
++ // Visits per site this week, from the open stats (page views the shared navbar counts): the front door's
++ // intents are ordered by them (plan L43).
++ popularity: async () => {
++ const d = await openStats.collect(7);
++ return d && Array.isArray(d.sites) ? Object.fromEntries(d.sites.map((s) => [s.id, Number(s.pageviews) || 0])) : null;
++ },
accounts: async () => (await db.prepare('SELECT COUNT(*) AS cnt FROM users WHERE COALESCE(is_anon, 0) = 0 AND COALESCE(is_banned, 0) = 0').get()).cnt,
services: () => {
const live = require('./status/routes').rows(ecosystem).filter((r) => r.exposure && r.exposure.state === 'live');
ddiff --git a/test/home-front-door.test.js b/test/home-front-door.test.js
iindex 5789347b..ce6d7115 100644
---- a/test/home-front-door.test.js
++++ b/test/home-front-door.test.js
@@@ -152,4 +152,22 @@ assert.ok(html.includes('href="https://openvibe.video/v/9"') && html.includes('<
assert.ok(html.includes('.more [data-more-part]:target{display:block}'), 'a link to a section opens it');
}
assert.ok(/<b>1,234<\/b><span>accounts/.test(html) && /<b>37<\/b><span>services up <small>of 38 checked/.test(html) && /<b>1<\/b><span>stream live now/.test(html), 'the numbers read render');
++// The most visited things to do lead the intents, with this week's visits on the first five (plan L43). One site counts
++// once (Ask Vibe and Chat are both openvibe.chat: Chat carries the number), and no numbers keep the written order.
++{
++ activity._set({ live: [], pastes: [], posts: [], popularity: { live: 59680, 'media-hub': 1615, tools: 232, games: 151, community: 89, chat: 40, work: 3 } });
++ const page = home.render().html;
++ const order = [...page.matchAll(/<a class="intent" href="[^"]*" data-id="([a-z-]+)"/g)].map((m) => m[1]);
++ assert.deepStrictEqual(order.slice(0, 6), ['live', 'video', 'tools', 'games', 'paste', 'chat'], `most visited first: ${order.slice(0, 8).join(', ')}`);
++ assert.match(page, /data-id="live"[\s\S]*?<em class="intent-n">60k visits this week<\/em>/, 'the count says what it counts');
++ assert.match(page, /data-id="video"[\s\S]*?<em class="intent-n">1.6k visits this week<\/em>/);
++ assert.strictEqual([...page.matchAll(/class="intent-n"/g)].length, 5, 'only the first five say a number');
++ assert.ok(order.indexOf('vibe') > order.indexOf('chat'), 'Ask Vibe does not borrow Chat\'s number');
++ assert.match(page, /aria-label="What you can do here, most visited this week first"/);
++ activity._set({ live: [], pastes: [], posts: [] });
++ const plain = home.render().html;
++ const plainOrder = [...plain.matchAll(/<a class="intent" href="[^"]*" data-id="([a-z-]+)"/g)].map((m) => m[1]);
++ assert.deepStrictEqual(plainOrder.slice(0, 3), ['vibe', 'live', 'tools'], 'without numbers, the written order');
++ assert.ok(!plain.includes('class="intent-n"'), 'and no counts');
++}
console.log('home front door: all checks passed');