openvibers/openvibe.live Commits
Home: pulse rail, viewer sparkline, leaders, digest, AI moments, goal bars, ship line
Changes
@@@ -2198,6 +2198,84 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }text-shadow: 0 1px 8px rgba(0, 0, 0, 0.5);}.hero-stat-row { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; }++.hero-stat--spark { flex-direction: column; gap: 2px; padding: 7px 12px; color: var(--accent); }++.hero-stat--spark svg { display: block; opacity: 0.9; }++++/* Newest shipped commit — hero one-liner */++.hero-latest {++ display: inline-flex; align-items: center; gap: 7px; margin-top: 14px;++ padding: 6px 14px; border-radius: 999px; font-size: 0.78rem; text-decoration: none;++ color: var(--text-secondary); background: color-mix(in srgb, var(--bg-secondary) 70%, transparent);++ border: 1px solid color-mix(in srgb, var(--accent) 22%, transparent); backdrop-filter: blur(6px);++ max-width: min(640px, 90vw); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;++}++.hero-latest i { color: var(--accent); }++.hero-latest b { color: var(--text-primary); font-weight: 600; overflow: hidden; text-overflow: ellipsis; }++.hero-latest:hover { border-color: var(--accent); color: var(--text-primary); }++++/* ── Community pulse rail ── */++.pulse-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 12px; margin-bottom: 28px; }++.pulse-card {++ display: block; padding: 13px 15px; border-radius: 14px; text-decoration: none; color: var(--text-primary);++ background: var(--bg-secondary); border: 1px solid var(--border);++ transition: border-color 0.15s ease, transform 0.15s ease;++}++a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }++.pulse-kicker { font-size: 0.68rem; font-weight: 800; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-muted); margin-bottom: 7px; }++.pulse-kicker i { color: var(--accent); margin-right: 4px; }++.pulse-title { font-weight: 700; margin-bottom: 7px; }++.pulse-sub { font-size: 0.78rem; color: var(--text-muted); margin-top: 5px; }++.pulse-sub b { color: var(--accent); }++.pulse-act { font-size: 0.85rem; padding: 4px 0; line-height: 1.45; }++.pulse-act i { color: var(--accent); margin-right: 5px; }++.pulse-rank { display: flex; align-items: center; gap: 7px; font-size: 0.85rem; padding: 3px 0; }++.pulse-rank a { color: var(--text-primary); text-decoration: none; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.pulse-rank a:hover { color: var(--accent); }++.pulse-rank b { font-variant-numeric: tabular-nums; }++.pulse-medal { flex-shrink: 0; }++++/* ── AI Moments showcase row ── */++.moments-row { display: flex; gap: 12px; overflow-x: auto; padding-bottom: 10px; margin-bottom: 22px; scrollbar-width: thin; }++.moment-card {++ position: relative; flex: 0 0 220px; height: 124px; border-radius: 12px; overflow: hidden;++ border: 1px solid var(--border); transition: border-color 0.15s ease, transform 0.15s ease;++}++.moment-card:hover { border-color: var(--accent); transform: translateY(-2px); }++.moment-card img { width: 100%; height: 100%; object-fit: cover; display: block; }++.moment-overlay {++ position: absolute; inset: auto 0 0 0; padding: 20px 10px 8px;++ background: linear-gradient(transparent, rgba(0, 0, 0, 0.85));++}++.moment-title { font-size: 0.78rem; font-weight: 700; color: #fff; line-height: 1.25; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }++.moment-user { font-size: 0.68rem; color: rgba(255, 255, 255, 0.75); margin-top: 2px; }++++/* ── "While you were away" digest ── */++.home-digest {++ margin-bottom: 22px; padding: 13px 16px; border-radius: 14px;++ background: linear-gradient(135deg, color-mix(in srgb, var(--accent) 10%, var(--bg-secondary)), var(--bg-secondary));++ border: 1px solid color-mix(in srgb, var(--accent) 30%, var(--border));++}++.digest-head { font-size: 0.8rem; font-weight: 800; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-muted); margin-bottom: 9px; }++.digest-head i { color: var(--accent); margin-right: 5px; }++.digest-row { display: flex; flex-wrap: wrap; gap: 8px; }++.digest-chip {++ display: inline-flex; align-items: center; gap: 8px; padding: 6px 12px 6px 7px; border-radius: 999px;++ background: var(--bg-tertiary); border: 1px solid var(--border); text-decoration: none; color: var(--text-primary);++ font-size: 0.84rem; transition: border-color 0.15s ease;++}++.digest-chip:hover { border-color: var(--accent); }++.digest-chip--live { border-color: color-mix(in srgb, #e0245e 55%, var(--border)); }++.digest-chip .stream-card-avatar { width: 24px; height: 24px; font-size: 0.7rem; }++.digest-name { font-weight: 700; }++.digest-extra { color: var(--text-muted); font-size: 0.76rem; }++.digest-extra .live-dot { font-size: 0.5rem; }++++/* Recently Online cards: nearest-goal progress */++.streamer-group-goal { display: flex; align-items: center; gap: 8px; margin: 6px 5px 2px; font-size: 0.76rem; }++.streamer-group-goal .goal-bar { flex: 1; }++.sgg-title { color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 42%; }++.sgg-title i { color: var(--accent); margin-right: 3px; }++.sgg-pct { font-weight: 700; color: var(--accent); font-variant-numeric: tabular-nums; }.hero-stat {display: flex; align-items: center; gap: 9px; padding: 9px 15px; border-radius: 14px;background: color-mix(in srgb, var(--bg-secondary) 78%, transparent);@@@ -62,7 +62,7 @@</script><!-- Stylesheets -->-- <link rel="stylesheet" href="/css/style.css?v=137">++ <link rel="stylesheet" href="/css/style.css?v=138"><link rel="stylesheet" href="/css/chat-shared.css?v=1"><link rel="stylesheet" href="/css/broadcast.css?v=11"><link rel="stylesheet" href="/css/cosmetics.css?v=2">@@@ -243,6 +243,8 @@<i class="fa-solid fa-wand-magic-sparkles"></i> Vibe Coders</a></div>++ <!-- Newest shipped commit — built in the open, receipts included. -->++ <a class="hero-latest" id="hero-latest" href="/updates" onclick="return handleLinkClick(event, '/updates')" style="display:none"></a><div class="hero-philosophy-links"><a href="#home-cta-banner" class="hero-philosophy-btn" onclick="event.preventDefault();openHomeAbout();"><i class="fa-solid fa-book-open"></i> What Is This? (Philosophy)@@@ -257,6 +259,9 @@</div><div class="container">++ <!-- "While you were away" — returning users' followed-channel digest -->++ <div class="home-digest" id="home-digest" style="display:none"></div>++<!-- Live Streams --><div class="section-header"><h2><i class="fa-solid fa-circle live-dot"></i> Live Now</h2>@@@ -270,6 +275,23 @@</div></div>++ <!-- Community pulse: goals near completion, latest activity, weekly leaders -->++ <div id="home-pulse-section" style="display:none">++ <div class="section-header">++ <h2><i class="fa-solid fa-bolt"></i> Happening Now</h2>++ </div>++ <div class="pulse-grid" id="pulse-grid"></div>++ </div>++++ <!-- AI Moments showcase -->++ <div id="home-moments-section" style="display:none">++ <div class="section-header">++ <h2><i class="fa-solid fa-brain"></i> AI Moments</h2>++ <span class="muted" style="font-size:0.8rem">wild seconds the AI clipped from recent streams</span>++ </div>++ <div class="moments-row" id="home-moments-row"></div>++ </div>++<!-- Streaming CTA --><div class="home-cta-banner" id="home-cta-banner"><div class="cta-ambient" aria-hidden="true"></div>@@@ -4999,7 +5021,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig<!-- Scripts --><script src="/js/theme-engine.js?v=4"></script><script src="/live-notify.js?v=3" defer></script>--<script src="/js/app.js?v=129" defer></script>++<script src="/js/app.js?v=130" defer></script><script src="/js/stream-player.js?v=21" defer></script><script src="/js/stream-pip.js?v=1" defer></script><script src="/js/chat-shared.js?v=3" defer></script>@@@ -1226,6 +1226,19 @@ function renderHeroStats(stats) {if (stats.liveNow > 0) now.push({ cls: 'hero-stat--live', icon: 'fa-circle', num: stats.liveNow, label: 'Live', title: 'Streams live right now' });if (stats.viewersNow > 0) now.push({ cls: 'hero-stat--live', icon: 'fa-eye', num: stats.viewersNow, label: 'Watching', title: 'Viewers watching right now' });now.push({ icon: 'fa-fire', num: stats.weeklyActive, label: 'Active', title: 'Active this week — chatters incl. anons & relays' + (stats.weeklyVisitors ? `, plus ${stats.weeklyVisitors} new visitors` : ''), sub: stats.weeklyVisitors ? `+${_fmtCount(stats.weeklyVisitors)} new` : '' });++ // 24h viewer sparkline (5-minute samples) — trends read better than a snapshot.++ const trend = Array.isArray(stats.viewerTrend) ? stats.viewerTrend : [];++ if (trend.length >= 2 && trend.some(t => (t.viewers || 0) > 0)) {++ const max = Math.max(...trend.map(t => t.viewers || 0), 1);++ const W = 110, H = 26;++ const pts = trend.map((t, i) => `${(i / (trend.length - 1) * W).toFixed(1)},${(H - 2 - ((t.viewers || 0) / max) * (H - 4)).toFixed(1)}`).join(' ');++ now.push({++ html: `<div class="hero-stat hero-stat--spark" title="Viewers over the last 24h (peak ${max})">++ <svg viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" preserveAspectRatio="none" aria-hidden="true">++ <polyline points="${pts}" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>++ </svg><span class="hero-stat-label">24h viewers</span></div>`,++ });++ }groups.push({ kicker: 'Right now', rows: now });// ── Community ────────────────────────────────────────────────@@@ -1265,6 +1278,7 @@ function renderHeroStats(stats) {const recTitle = (rec, u = '') => rec ? ` — +${_fmtCount(rec.d)}${u} today · +${_fmtCount(rec.w)}${u} this week · +${_fmtCount(rec.m)}${u} this month` : '';const recSub = (rec, u = '') => rec ? `+${_fmtCount(rec.w)}${u} wk` : '';const chip = (r) => {++ if (r.html) return r.html; // pre-rendered chips (sparkline)const title = (r.title || '') + recTitle(r.recent, r.unit || '');const sub = r.sub || recSub(r.recent, r.unit || '');return `<div class="hero-stat ${r.cls || ''}" title="${esc(title)}"><i class="fa-solid ${r.icon}"></i><div class="hero-stat-meta"><span class="hero-stat-num" data-n="${r.num || 0}">0</span><span class="hero-stat-label">${r.label}</span>${sub ? `<span class="hero-stat-sub">${sub}</span>` : ''}</div></div>`;@@@ -1643,6 +1657,8 @@ async function loadHome() {} catch (e) { console.error('Failed to load live streams', e); }loadHomeRecentOnline();++ void loadHomePulse(); // happening-now rail + weekly leaders + AI moments + latest update++ void loadHomeDigest(); // "while you were away" for returning logged-in users// Load recent VODsloadHomeRecentVods();@@@ -1658,6 +1674,100 @@ async function loadHome() {startHomeRefresh(); // live grid + sections auto-update in real time}++/* ── Community pulse: happening-now rail, weekly leaders, AI moments, latest ship ── */++async function loadHomePulse() {++ let p;++ try { p = await api('/home/pulse'); } catch { return; }++ if (!p) return;++++ // Hero one-liner: the newest shipped commit.++ const latest = document.getElementById('hero-latest');++ if (latest && p.latestUpdate && p.latestUpdate.subject) {++ latest.innerHTML = `<i class="fa-solid fa-rocket"></i> shipped ${esc(timeAgo(p.latestUpdate.date))}: <b>${esc(p.latestUpdate.subject)}</b>`;++ latest.style.display = '';++ }++++ const grid = document.getElementById('pulse-grid');++ const section = document.getElementById('home-pulse-section');++ if (!grid || !section) return;++ const cards = [];++++ // Goal cards — nearest to completion, with progress bars.++ for (const g of (p.goals || [])) {++ const pct = Math.min(100, Math.round((g.current_amount / g.target_amount) * 100));++ const href = `/@${g.username}`;++ cards.push(`++ <a class="pulse-card pulse-goal" href="${href}" onclick="return handleLinkClick(event, '${href}')">++ <div class="pulse-kicker"><i class="fa-solid fa-bullseye"></i> Goal · ${esc(g.display_name || g.username)}</div>++ <div class="pulse-title">${esc(g.title)}</div>++ <div class="goal-bar"><div class="goal-fill" style="width:${pct}%"></div></div>++ <div class="pulse-sub">${Number(g.current_amount).toLocaleString()} / ${Number(g.target_amount).toLocaleString()} Vibes · <b>${pct}%</b></div>++ </a>`);++ }++++ // Latest activity card (tip + follow together).++ const act = [];++ if (p.latestTip) act.push(`<div class="pulse-act"><i class="fa-solid fa-hand-holding-dollar"></i> <b>${esc(p.latestTip.from_display || p.latestTip.from_username || 'Someone')}</b> tipped <b>${Number(p.latestTip.amount).toLocaleString()}</b> Vibes to <b>${esc(p.latestTip.to_display || p.latestTip.to_username)}</b> <span class="muted">${esc(timeAgo(p.latestTip.created_at))}</span></div>`);++ if (p.newestFollow) act.push(`<div class="pulse-act"><i class="fa-solid fa-heart"></i> <b>${esc(p.newestFollow.follower_display || p.newestFollow.follower_username)}</b> followed <b>${esc(p.newestFollow.streamer_display || p.newestFollow.streamer_username)}</b> <span class="muted">${esc(timeAgo(p.newestFollow.created_at))}</span></div>`);++ if (act.length) cards.push(`<div class="pulse-card"><div class="pulse-kicker"><i class="fa-solid fa-wave-square"></i> Latest activity</div>${act.join('')}</div>`);++++ // Weekly leader teasers.++ const board = (title, icon, rows, unit) => rows && rows.length ? `++ <div class="pulse-card">++ <div class="pulse-kicker"><i class="fa-solid ${icon}"></i> ${title}</div>++ ${rows.map((r, i) => `<div class="pulse-rank"><span class="pulse-medal">${['🥇', '🥈', '🥉'][i] || (i + 1)}</span> <a href="/@${esc(r.username)}" onclick="return handleLinkClick(event, '/@${esc(r.username)}')">${esc(r.display_name || r.username)}</a> <b>${Number(r.total).toLocaleString()}</b> <span class="muted">${unit}</span></div>`).join('')}++ </div>` : '';++ const supporters = board('Top supporters this week', 'fa-trophy', p.topSupporters, 'Vibes');++ const earners = board('Top point earners this week', 'fa-coins', p.topEarners, 'pts');++ if (supporters) cards.push(supporters);++ if (earners) cards.push(earners);++++ section.style.display = cards.length ? '' : 'none';++ grid.innerHTML = cards.join('');++++ // AI Moments showcase row.++ const momentsRow = document.getElementById('home-moments-row');++ const momentsSection = document.getElementById('home-moments-section');++ if (momentsRow && momentsSection) {++ const ms = (p.moments || []).filter(m => m.thumbnail);++ momentsSection.style.display = ms.length ? '' : 'none';++ momentsRow.innerHTML = ms.map(m => `++ <a class="moment-card" href="${esc(m.href)}" onclick="return handleLinkClick(event, '${esc(m.href)}')">++ <img src="${esc(m.thumbnail)}" alt="" loading="lazy">++ <div class="moment-overlay">++ <div class="moment-title">${esc(m.title)}</div>++ ${m.username ? `<div class="moment-user">@${esc(m.username)}</div>` : ''}++ </div>++ </a>`).join('');++ }++}++++/* ── "While you were away" digest (logged-in returning users) ── */++async function loadHomeDigest() {++ const box = document.getElementById('home-digest');++ if (!box || !currentUser) return;++ const KEY = 'openvibe_last_visit';++ let since = null;++ try { since = localStorage.getItem(KEY); } catch { /* */ }++ try { localStorage.setItem(KEY, new Date().toISOString()); } catch { /* */ }++ // First visit (nothing to compare against) → no banner, just start the clock.++ if (!since) return;++ let d;++ try { d = await api(`/home/digest?since=${encodeURIComponent(since)}`); } catch { return; }++ if (!d || (!d.liveNow?.length && !d.missed?.length)) return;++ const chip = (u, extra, live) => `++ <a class="digest-chip ${live ? 'digest-chip--live' : ''}" href="/@${esc(u.username)}" onclick="return handleLinkClick(event, '/@${esc(u.username)}')">++ ${_avatarSpan(u.avatar_url, u.username, u.profile_color)}++ <span class="digest-name">${esc(u.display_name || u.username)}</span>++ <span class="digest-extra">${extra}</span>++ </a>`;++ const parts = [];++ for (const u of (d.liveNow || [])) parts.push(chip(u, '<i class="fa-solid fa-circle live-dot"></i> LIVE now', true));++ for (const u of (d.missed || [])) parts.push(chip(u, `streamed ${u.sessions > 1 ? u.sessions + '× ' : ''}${esc(timeAgo(u.last_at))}`, false));++ box.innerHTML = `<div class="digest-head"><i class="fa-solid fa-clock-rotate-left"></i> While you were away</div><div class="digest-row">${parts.join('')}</div>`;++ box.style.display = '';++}++async function loadHomeRecentOnline(page) {if (page !== undefined) _homeRecentOnlinePage = page;const offset = (_homeRecentOnlinePage - 1) * HOME_RECENT_ONLINE_PAGE_SIZE;@@@ -1705,6 +1815,14 @@ function renderRecentlyOnline(containerId, streamers) {</a></div>${_cardAiHTML(s.ai_overview_short, s.ai_overview)}++ ${s.top_goal ? (() => {++ const pct = Math.min(100, Math.round((s.top_goal.current / s.top_goal.target) * 100));++ return `<div class="streamer-group-goal" title="${esc(s.top_goal.title)}: ${Number(s.top_goal.current).toLocaleString()} / ${Number(s.top_goal.target).toLocaleString()} Vibes">++ <span class="sgg-title"><i class="fa-solid fa-bullseye"></i> ${esc(s.top_goal.title)}</span>++ <div class="goal-bar"><div class="goal-fill" style="width:${pct}%"></div></div>++ <span class="sgg-pct">${pct}%</span>++ </div>`;++ })() : ''}<div class="streamer-group-streams">${streamsHtml}</div></div>`;@@@ -7,7 +7,7 @@<!-- Keep these in step with index.html — the popout renders the same chatcomponents (cosmetics.css positions particle effects; without it they sitinline next to the username as "broken badges" until their cleanup timer). -->-- <link rel="stylesheet" href="/css/style.css?v=137">++ <link rel="stylesheet" href="/css/style.css?v=138"><link rel="stylesheet" href="/css/chat-shared.css?v=1"><link rel="stylesheet" href="/css/cosmetics.css?v=2"><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">@@@ -3788,6 +3788,84 @@ let _homeStatsCache = null;let _homeStatsCacheAt = 0;const _HOME_STATS_TTL = 30 * 1000; // 30s memo so the windowed COUNTs don't hammer SQLite++// ── Viewer trend sampling (home hero sparkline) ──────────────++// One row every ~5 minutes: total native viewers + live stream count.++function _ensureViewerSamples() {++ try {++ getDb().exec(`CREATE TABLE IF NOT EXISTS viewer_samples (++ sampled_at DATETIME DEFAULT CURRENT_TIMESTAMP,++ viewers INTEGER NOT NULL DEFAULT 0,++ live_streams INTEGER NOT NULL DEFAULT 0++ )`);++ getDb().exec('CREATE INDEX IF NOT EXISTS idx_viewer_samples_at ON viewer_samples(sampled_at)');++ } catch { /* */ }++}++function recordViewerSample() {++ _ensureViewerSamples();++ const r = get(`SELECT COALESCE(SUM(viewer_count),0) AS v, COUNT(*) AS n FROM streams WHERE is_live = 1`) || { v: 0, n: 0 };++ run('INSERT INTO viewer_samples (viewers, live_streams) VALUES (?, ?)', [r.v || 0, r.n || 0]);++ run(`DELETE FROM viewer_samples WHERE sampled_at < datetime('now', '-7 days')`);++ return r;++}++function getViewerTrend(hours = 24, maxPoints = 48) {++ _ensureViewerSamples();++ const rows = all(`SELECT strftime('%s', sampled_at) AS t, viewers, live_streams FROM viewer_samples++ WHERE sampled_at >= datetime('now', ?) ORDER BY sampled_at ASC`, [`-${Math.max(1, hours)} hours`]) || [];++ if (rows.length <= maxPoints) return rows;++ const step = rows.length / maxPoints;++ const out = [];++ for (let i = 0; i < maxPoints; i++) out.push(rows[Math.floor(i * step)]);++ return out;++}++++// ── Home "pulse": goals near completion, latest activity, weekly leaders ──────++function getHomePulse() {++ const safe = (fn, dflt) => { try { return fn() ?? dflt; } catch { return dflt; } };++ return {++ // Active goals closest to completion, site-wide.++ goals: safe(() => all(`SELECT g.id, g.title, g.current_amount, g.target_amount, g.image_url,++ u.username, u.display_name++ FROM donation_goals g JOIN users u ON u.id = g.user_id++ WHERE g.is_active = 1 AND g.target_amount > 0++ ORDER BY (CAST(g.current_amount AS REAL) / g.target_amount) DESC, g.current_amount DESC LIMIT 3`), []),++ latestTip: safe(() => get(`SELECT t.amount, t.created_at,++ fu.username AS from_username, fu.display_name AS from_display,++ tu.username AS to_username, tu.display_name AS to_display++ FROM transactions t++ LEFT JOIN users fu ON fu.id = t.from_user_id++ JOIN users tu ON tu.id = t.to_user_id++ WHERE t.type = 'donation' ORDER BY t.id DESC LIMIT 1`), null),++ newestFollow: safe(() => get(`SELECT f.created_at,++ fu.username AS follower_username, fu.display_name AS follower_display,++ su.username AS streamer_username, su.display_name AS streamer_display++ FROM follows f++ JOIN users fu ON fu.id = f.follower_id++ JOIN users su ON su.id = f.streamer_id++ ORDER BY f.id DESC LIMIT 1`), null),++ // This week's leaders.++ topSupporters: safe(() => all(`SELECT u.username, u.display_name, u.avatar_url, SUM(t.amount) AS total++ FROM transactions t JOIN users u ON u.id = t.from_user_id++ WHERE t.type = 'donation' AND t.created_at >= datetime('now', '-7 days')++ GROUP BY t.from_user_id ORDER BY total DESC LIMIT 3`), []),++ topEarners: safe(() => all(`SELECT u.username, u.display_name, u.avatar_url, SUM(c.amount) AS total++ FROM coin_transactions c JOIN users u ON u.id = c.user_id++ WHERE c.amount > 0 AND c.created_at >= datetime('now', '-7 days')++ GROUP BY c.user_id ORDER BY total DESC LIMIT 3`), []),++ };++}++++// Nearest active goal per streamer (for Recently Online cards) — one query.++function getActiveGoalsForUsers(userIds) {++ if (!userIds || !userIds.length) return {};++ const ph = userIds.map(() => '?').join(',');++ const rows = all(`SELECT user_id, title, current_amount, target_amount FROM donation_goals++ WHERE is_active = 1 AND target_amount > 0 AND user_id IN (${ph})++ ORDER BY (CAST(current_amount AS REAL) / target_amount) DESC`, userIds) || [];++ const out = {};++ for (const r of rows) if (!out[r.user_id]) out[r.user_id] = r;++ return out;++}++function getHomeStats() {const now = Date.now();if (_homeStatsCache && (now - _homeStatsCacheAt) < _HOME_STATS_TTL) return _homeStatsCache;@@@ -7817,6 +7895,7 @@ module.exports = {scheduleClipNotifyState, bumpClipNotifyNowState, markClipNotifiedState, getDueClipNotifies,getDb, initDb, run, get, all, close,getDonationGoalsForWidget, getAllDonationGoals, getActiveDonationGoals, getDonationGoalById,++ recordViewerSample, getViewerTrend, getHomePulse, getActiveGoalsForUsers,createDonationGoal, updateDonationGoal, deleteDonationGoal, addToDonationGoal,setChannelAlertSound, getChannelAlertSoundsByUser,// Users@@@ -188,13 +188,87 @@ async function heroStats() {router.get('/hero', async (req, res) => {try {res.set('Cache-Control', 'public, max-age=20');-- res.json({ stats: await heroStats(), media: await heroMedia(), moments: heroMoments(), slogans: heroSlogans() });++ const stats = await heroStats();++ // 24h viewer trend for the hero sparkline (sampled every 5 min by the sampler).++ try { stats.viewerTrend = db.getViewerTrend(24, 48); } catch { stats.viewerTrend = []; }++ res.json({ stats, media: await heroMedia(), moments: heroMoments(), slogans: heroSlogans() });} catch (err) {console.error('[Home] hero error:', err.message);res.status(500).json({ error: 'Failed to load hero' });}});++// ── Community pulse: goals near completion, latest activity, weekly leaders,++// AI moment showcase, and the newest shipped update. Cached ~30s server-side.++let _pulseCache = { at: 0, data: null };++router.get('/pulse', async (req, res) => {++ try {++ res.set('Cache-Control', 'public, max-age=30');++ if (Date.now() - _pulseCache.at < 30_000 && _pulseCache.data) return res.json(_pulseCache.data);++ const pulse = db.getHomePulse();++ pulse.moments = heroMoments().slice(0, 10);++ pulse.latestUpdate = _latestUpdate();++ _pulseCache = { at: Date.now(), data: pulse };++ res.json(pulse);++ } catch (err) {++ console.error('[Home] pulse error:', err.message);++ res.status(500).json({ error: 'Failed to load pulse' });++ }++});++++// Newest git commit = the latest shipped update (built-in-the-open hero one-liner).++let _updateCache = { at: 0, data: null };++function _latestUpdate() {++ if (Date.now() - _updateCache.at < 5 * 60_000) return _updateCache.data;++ try {++ const { execSync } = require('child_process');++ const raw = execSync(`git --no-pager log --pretty=format:'%h||%s||%aI' -1`,++ { cwd: require('path').join(__dirname, '../..'), encoding: 'utf8', timeout: 4000 });++ const [short, subject, date] = String(raw).trim().split('||');++ _updateCache = { at: Date.now(), data: subject ? { short, subject, date } : null };++ } catch { _updateCache = { at: Date.now(), data: null }; }++ return _updateCache.data;++}++++// ── "While you were away" digest for returning users ─────────────────────────++// ?since=<ISO> (the client remembers its own last visit). Followed channels that++// are live right now + those that streamed since the last visit.++const { requireAuth } = require('../auth/auth');++router.get('/digest', requireAuth, (req, res) => {++ try {++ const since = req.query.since && !Number.isNaN(Date.parse(req.query.since))++ ? new Date(req.query.since).toISOString()++ : new Date(Date.now() - 3 * 24 * 3600 * 1000).toISOString();++ const liveNow = db.all(`SELECT DISTINCT u.username, u.display_name, u.avatar_url++ FROM follows f JOIN users u ON u.id = f.streamer_id++ JOIN streams s ON s.user_id = f.streamer_id AND s.is_live = 1++ WHERE f.follower_id = ?`, [req.user.id]) || [];++ const missed = db.all(`SELECT u.username, u.display_name, u.avatar_url,++ COUNT(s.id) AS sessions, MAX(s.started_at) AS last_at, MAX(s.title) AS last_title++ FROM follows f JOIN users u ON u.id = f.streamer_id++ JOIN streams s ON s.user_id = f.streamer_id AND s.started_at >= ? AND s.is_live = 0++ WHERE f.follower_id = ?++ GROUP BY f.streamer_id ORDER BY last_at DESC LIMIT 6`, [since, req.user.id]) || [];++ // Don't list a channel as "missed" when it's live right now — it's in liveNow.++ const liveSet = new Set(liveNow.map(x => x.username));++ res.json({ since, liveNow, missed: missed.filter(m => !liveSet.has(m.username)) });++ } catch (err) {++ console.error('[Home] digest error:', err.message);++ res.status(500).json({ error: 'Failed to load digest' });++ }++});++++// Sample the live viewer total every 5 minutes for the hero sparkline.++let _samplerTimer = null;++function startViewerSampler() {++ if (_samplerTimer) return;++ try { db.recordViewerSample(); } catch { /* */ }++ _samplerTimer = setInterval(() => { try { db.recordViewerSample(); } catch { /* */ } }, 5 * 60 * 1000);++ if (_samplerTimer.unref) _samplerTimer.unref();++ console.log('[Home] viewer sampler started (5m)');++}++module.exports = router;++module.exports.startViewerSampler = startViewerSampler;module.exports.FALLBACK_AUDIENCES = FALLBACK_AUDIENCES;module.exports.FALLBACK_QUIPS = FALLBACK_QUIPS;@@@ -1018,6 +1018,8 @@ async function start() {try { require('./integrations/powerchat-platform').startCurrencyEarnFlusher(); } catch (e) { console.warn('[PowerChat] earn flusher not started:', e.message); }// Auto-renew lapsed channel subs (Vibes-balance renewal; Stripe renews natively).try { require('./monetization/payments').startRenewalSweeper(); } catch (e) { console.warn('[Payments] renewal sweeper not started:', e.message); }++ // 5-minute live-viewer samples → the home hero's 24h sparkline.++ try { require('./home/routes').startViewerSampler(); } catch (e) { console.warn('[Home] viewer sampler not started:', e.message); }// PowerChat: prune the webhook-dedupe log daily so it can't grow unbounded.try {const _pcClean = () => { try { db.cleanupPowerchatDeliveries(3); } catch { /* */ } };@@@ -1114,6 +1114,14 @@ router.get('/recently-online', async (req, res) => {} catch (err) {console.warn('[Streaming] recently-online thumb enrichment failed:', err.message);}++ // Each streamer's nearest active donation goal → progress bar on the card.++ try {++ const goals = db.getActiveGoalsForUsers(streamers.map(s => s.user_id).filter(Boolean));++ for (const s of streamers) {++ const g = goals[s.user_id];++ if (g) s.top_goal = { title: g.title, current: g.current_amount, target: g.target_amount };++ }++ } catch { /* best-effort */ }res.json({ streamers, total, limit, offset, hasMore: offset + streamers.length < total });} catch (err) {console.error('[Streaming]', err.message);
@@ -2198,6 +2198,84 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
@@ -2198,6 +2198,84 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
text-shadow: 0 1px 8px rgba(0, 0, 0, 0.5);
text-shadow: 0 1px 8px rgba(0, 0, 0, 0.5);
}
}
.hero-stat-row { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; }
.hero-stat-row { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; }
+.hero-stat--spark { flex-direction: column; gap: 2px; padding: 7px 12px; color: var(--accent); }
+.hero-stat--spark svg { display: block; opacity: 0.9; }
+
+/* Newest shipped commit — hero one-liner */
+.hero-latest {
+ display: inline-flex; align-items: center; gap: 7px; margin-top: 14px;
+ padding: 6px 14px; border-radius: 999px; font-size: 0.78rem; text-decoration: none;
+ color: var(--text-secondary); background: color-mix(in srgb, var(--bg-secondary) 70%, transparent);
+ border: 1px solid color-mix(in srgb, var(--accent) 22%, transparent); backdrop-filter: blur(6px);
+ max-width: min(640px, 90vw); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
+}
+.hero-latest i { color: var(--accent); }
+.hero-latest b { color: var(--text-primary); font-weight: 600; overflow: hidden; text-overflow: ellipsis; }
+.hero-latest:hover { border-color: var(--accent); color: var(--text-primary); }
+
+/* ── Community pulse rail ── */
+.pulse-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 12px; margin-bottom: 28px; }
+.pulse-card {
+ display: block; padding: 13px 15px; border-radius: 14px; text-decoration: none; color: var(--text-primary);
+ background: var(--bg-secondary); border: 1px solid var(--border);
+ transition: border-color 0.15s ease, transform 0.15s ease;
+}
+a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }
+.pulse-kicker { font-size: 0.68rem; font-weight: 800; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-muted); margin-bottom: 7px; }
+.pulse-kicker i { color: var(--accent); margin-right: 4px; }
+.pulse-title { font-weight: 700; margin-bottom: 7px; }
+.pulse-sub { font-size: 0.78rem; color: var(--text-muted); margin-top: 5px; }
+.pulse-sub b { color: var(--accent); }
+.pulse-act { font-size: 0.85rem; padding: 4px 0; line-height: 1.45; }
+.pulse-act i { color: var(--accent); margin-right: 5px; }
+.pulse-rank { display: flex; align-items: center; gap: 7px; font-size: 0.85rem; padding: 3px 0; }
+.pulse-rank a { color: var(--text-primary); text-decoration: none; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.pulse-rank a:hover { color: var(--accent); }
+.pulse-rank b { font-variant-numeric: tabular-nums; }
+.pulse-medal { flex-shrink: 0; }
+
+/* ── AI Moments showcase row ── */
+.moments-row { display: flex; gap: 12px; overflow-x: auto; padding-bottom: 10px; margin-bottom: 22px; scrollbar-width: thin; }
+.moment-card {
+ position: relative; flex: 0 0 220px; height: 124px; border-radius: 12px; overflow: hidden;
+ border: 1px solid var(--border); transition: border-color 0.15s ease, transform 0.15s ease;
+}
+.moment-card:hover { border-color: var(--accent); transform: translateY(-2px); }
+.moment-card img { width: 100%; height: 100%; object-fit: cover; display: block; }
+.moment-overlay {
+ position: absolute; inset: auto 0 0 0; padding: 20px 10px 8px;
+ background: linear-gradient(transparent, rgba(0, 0, 0, 0.85));
+}
+.moment-title { font-size: 0.78rem; font-weight: 700; color: #fff; line-height: 1.25; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
+.moment-user { font-size: 0.68rem; color: rgba(255, 255, 255, 0.75); margin-top: 2px; }
+
+/* ── "While you were away" digest ── */
+.home-digest {
+ margin-bottom: 22px; padding: 13px 16px; border-radius: 14px;
+ background: linear-gradient(135deg, color-mix(in srgb, var(--accent) 10%, var(--bg-secondary)), var(--bg-secondary));
+ border: 1px solid color-mix(in srgb, var(--accent) 30%, var(--border));
+}
+.digest-head { font-size: 0.8rem; font-weight: 800; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-muted); margin-bottom: 9px; }
+.digest-head i { color: var(--accent); margin-right: 5px; }
+.digest-row { display: flex; flex-wrap: wrap; gap: 8px; }
+.digest-chip {
+ display: inline-flex; align-items: center; gap: 8px; padding: 6px 12px 6px 7px; border-radius: 999px;
+ background: var(--bg-tertiary); border: 1px solid var(--border); text-decoration: none; color: var(--text-primary);
+ font-size: 0.84rem; transition: border-color 0.15s ease;
+}
+.digest-chip:hover { border-color: var(--accent); }
+.digest-chip--live { border-color: color-mix(in srgb, #e0245e 55%, var(--border)); }
+.digest-chip .stream-card-avatar { width: 24px; height: 24px; font-size: 0.7rem; }
+.digest-name { font-weight: 700; }
+.digest-extra { color: var(--text-muted); font-size: 0.76rem; }
+.digest-extra .live-dot { font-size: 0.5rem; }
+
+/* Recently Online cards: nearest-goal progress */
+.streamer-group-goal { display: flex; align-items: center; gap: 8px; margin: 6px 5px 2px; font-size: 0.76rem; }
+.streamer-group-goal .goal-bar { flex: 1; }
+.sgg-title { color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 42%; }
+.sgg-title i { color: var(--accent); margin-right: 3px; }
+.sgg-pct { font-weight: 700; color: var(--accent); font-variant-numeric: tabular-nums; }
.hero-stat {
.hero-stat {
display: flex; align-items: center; gap: 9px; padding: 9px 15px; border-radius: 14px;
display: flex; align-items: center; gap: 9px; padding: 9px 15px; border-radius: 14px;
background: color-mix(in srgb, var(--bg-secondary) 78%, transparent);
background: color-mix(in srgb, var(--bg-secondary) 78%, transparent);
@@ -62,7 +62,7 @@
@@ -62,7 +62,7 @@
</script>
</script>
<!-- Stylesheets -->
<!-- Stylesheets -->
- <link rel="stylesheet" href="/css/style.css?v=137">
+ <link rel="stylesheet" href="/css/style.css?v=138">
<link rel="stylesheet" href="/css/chat-shared.css?v=1">
<link rel="stylesheet" href="/css/chat-shared.css?v=1">
<link rel="stylesheet" href="/css/broadcast.css?v=11">
<link rel="stylesheet" href="/css/broadcast.css?v=11">
<link rel="stylesheet" href="/css/cosmetics.css?v=2">
<link rel="stylesheet" href="/css/cosmetics.css?v=2">
@@ -243,6 +243,8 @@
@@ -243,6 +243,8 @@
<i class="fa-solid fa-wand-magic-sparkles"></i> Vibe Coders
<i class="fa-solid fa-wand-magic-sparkles"></i> Vibe Coders
</a>
</a>
</div>
</div>
+ <!-- Newest shipped commit — built in the open, receipts included. -->
+ <a class="hero-latest" id="hero-latest" href="/updates" onclick="return handleLinkClick(event, '/updates')" style="display:none"></a>
<div class="hero-philosophy-links">
<div class="hero-philosophy-links">
<a href="#home-cta-banner" class="hero-philosophy-btn" onclick="event.preventDefault();openHomeAbout();">
<a href="#home-cta-banner" class="hero-philosophy-btn" onclick="event.preventDefault();openHomeAbout();">
<i class="fa-solid fa-book-open"></i> What Is This? (Philosophy)
<i class="fa-solid fa-book-open"></i> What Is This? (Philosophy)
@@ -257,6 +259,9 @@
@@ -257,6 +259,9 @@
</div>
</div>
<div class="container">
<div class="container">
+ <!-- "While you were away" — returning users' followed-channel digest -->
+ <div class="home-digest" id="home-digest" style="display:none"></div>
+
<!-- Live Streams -->
<!-- Live Streams -->
<div class="section-header">
<div class="section-header">
<h2><i class="fa-solid fa-circle live-dot"></i> Live Now</h2>
<h2><i class="fa-solid fa-circle live-dot"></i> Live Now</h2>
@@ -270,6 +275,23 @@
@@ -270,6 +275,23 @@
</div>
</div>
</div>
</div>
+ <!-- Community pulse: goals near completion, latest activity, weekly leaders -->
+ <div id="home-pulse-section" style="display:none">
+ <div class="section-header">
+ <h2><i class="fa-solid fa-bolt"></i> Happening Now</h2>
+ </div>
+ <div class="pulse-grid" id="pulse-grid"></div>
+ </div>
+
+ <!-- AI Moments showcase -->
+ <div id="home-moments-section" style="display:none">
+ <div class="section-header">
+ <h2><i class="fa-solid fa-brain"></i> AI Moments</h2>
+ <span class="muted" style="font-size:0.8rem">wild seconds the AI clipped from recent streams</span>
+ </div>
+ <div class="moments-row" id="home-moments-row"></div>
+ </div>
+
<!-- Streaming CTA -->
<!-- Streaming CTA -->
<div class="home-cta-banner" id="home-cta-banner">
<div class="home-cta-banner" id="home-cta-banner">
<div class="cta-ambient" aria-hidden="true"></div>
<div class="cta-ambient" aria-hidden="true"></div>
@@ -4999,7 +5021,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
@@ -4999,7 +5021,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<!-- Scripts -->
<!-- Scripts -->
<script src="/js/theme-engine.js?v=4"></script>
<script src="/js/theme-engine.js?v=4"></script>
<script src="/live-notify.js?v=3" defer></script>
<script src="/live-notify.js?v=3" defer></script>
-<script src="/js/app.js?v=129" defer></script>
+<script src="/js/app.js?v=130" defer></script>
<script src="/js/stream-player.js?v=21" defer></script>
<script src="/js/stream-player.js?v=21" defer></script>
<script src="/js/stream-pip.js?v=1" defer></script>
<script src="/js/stream-pip.js?v=1" defer></script>
<script src="/js/chat-shared.js?v=3" defer></script>
<script src="/js/chat-shared.js?v=3" defer></script>
@@ -1226,6 +1226,19 @@ function renderHeroStats(stats) {
@@ -1226,6 +1226,19 @@ function renderHeroStats(stats) {
if (stats.liveNow > 0) now.push({ cls: 'hero-stat--live', icon: 'fa-circle', num: stats.liveNow, label: 'Live', title: 'Streams live right now' });
if (stats.liveNow > 0) now.push({ cls: 'hero-stat--live', icon: 'fa-circle', num: stats.liveNow, label: 'Live', title: 'Streams live right now' });
if (stats.viewersNow > 0) now.push({ cls: 'hero-stat--live', icon: 'fa-eye', num: stats.viewersNow, label: 'Watching', title: 'Viewers watching right now' });
if (stats.viewersNow > 0) now.push({ cls: 'hero-stat--live', icon: 'fa-eye', num: stats.viewersNow, label: 'Watching', title: 'Viewers watching right now' });
now.push({ icon: 'fa-fire', num: stats.weeklyActive, label: 'Active', title: 'Active this week — chatters incl. anons & relays' + (stats.weeklyVisitors ? `, plus ${stats.weeklyVisitors} new visitors` : ''), sub: stats.weeklyVisitors ? `+${_fmtCount(stats.weeklyVisitors)} new` : '' });
now.push({ icon: 'fa-fire', num: stats.weeklyActive, label: 'Active', title: 'Active this week — chatters incl. anons & relays' + (stats.weeklyVisitors ? `, plus ${stats.weeklyVisitors} new visitors` : ''), sub: stats.weeklyVisitors ? `+${_fmtCount(stats.weeklyVisitors)} new` : '' });
+ // 24h viewer sparkline (5-minute samples) — trends read better than a snapshot.
+ const trend = Array.isArray(stats.viewerTrend) ? stats.viewerTrend : [];
+ if (trend.length >= 2 && trend.some(t => (t.viewers || 0) > 0)) {
+ const max = Math.max(...trend.map(t => t.viewers || 0), 1);
+ const W = 110, H = 26;
+ const pts = trend.map((t, i) => `${(i / (trend.length - 1) * W).toFixed(1)},${(H - 2 - ((t.viewers || 0) / max) * (H - 4)).toFixed(1)}`).join(' ');
+ now.push({
+ html: `<div class="hero-stat hero-stat--spark" title="Viewers over the last 24h (peak ${max})">
+ <svg viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" preserveAspectRatio="none" aria-hidden="true">
+ <polyline points="${pts}" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>
+ </svg><span class="hero-stat-label">24h viewers</span></div>`,
+ });
+ }
groups.push({ kicker: 'Right now', rows: now });
groups.push({ kicker: 'Right now', rows: now });
// ── Community ────────────────────────────────────────────────
// ── Community ────────────────────────────────────────────────
@@ -1265,6 +1278,7 @@ function renderHeroStats(stats) {
@@ -1265,6 +1278,7 @@ function renderHeroStats(stats) {
const recTitle = (rec, u = '') => rec ? ` — +${_fmtCount(rec.d)}${u} today · +${_fmtCount(rec.w)}${u} this week · +${_fmtCount(rec.m)}${u} this month` : '';
const recTitle = (rec, u = '') => rec ? ` — +${_fmtCount(rec.d)}${u} today · +${_fmtCount(rec.w)}${u} this week · +${_fmtCount(rec.m)}${u} this month` : '';
const recSub = (rec, u = '') => rec ? `+${_fmtCount(rec.w)}${u} wk` : '';
const recSub = (rec, u = '') => rec ? `+${_fmtCount(rec.w)}${u} wk` : '';
const chip = (r) => {
const chip = (r) => {
+ if (r.html) return r.html; // pre-rendered chips (sparkline)
const title = (r.title || '') + recTitle(r.recent, r.unit || '');
const title = (r.title || '') + recTitle(r.recent, r.unit || '');
const sub = r.sub || recSub(r.recent, r.unit || '');
const sub = r.sub || recSub(r.recent, r.unit || '');
return `<div class="hero-stat ${r.cls || ''}" title="${esc(title)}"><i class="fa-solid ${r.icon}"></i><div class="hero-stat-meta"><span class="hero-stat-num" data-n="${r.num || 0}">0</span><span class="hero-stat-label">${r.label}</span>${sub ? `<span class="hero-stat-sub">${sub}</span>` : ''}</div></div>`;
return `<div class="hero-stat ${r.cls || ''}" title="${esc(title)}"><i class="fa-solid ${r.icon}"></i><div class="hero-stat-meta"><span class="hero-stat-num" data-n="${r.num || 0}">0</span><span class="hero-stat-label">${r.label}</span>${sub ? `<span class="hero-stat-sub">${sub}</span>` : ''}</div></div>`;
@@ -1643,6 +1657,8 @@ async function loadHome() {
@@ -1643,6 +1657,8 @@ async function loadHome() {
} catch (e) { console.error('Failed to load live streams', e); }
} catch (e) { console.error('Failed to load live streams', e); }
loadHomeRecentOnline();
loadHomeRecentOnline();
+ void loadHomePulse(); // happening-now rail + weekly leaders + AI moments + latest update
+ void loadHomeDigest(); // "while you were away" for returning logged-in users
// Load recent VODs
// Load recent VODs
loadHomeRecentVods();
loadHomeRecentVods();
@@ -1658,6 +1674,100 @@ async function loadHome() {
@@ -1658,6 +1674,100 @@ async function loadHome() {
startHomeRefresh(); // live grid + sections auto-update in real time
startHomeRefresh(); // live grid + sections auto-update in real time
}
}
+/* ── Community pulse: happening-now rail, weekly leaders, AI moments, latest ship ── */
+async function loadHomePulse() {
+ let p;
+ try { p = await api('/home/pulse'); } catch { return; }
+ if (!p) return;
+
+ // Hero one-liner: the newest shipped commit.
+ const latest = document.getElementById('hero-latest');
+ if (latest && p.latestUpdate && p.latestUpdate.subject) {
+ latest.innerHTML = `<i class="fa-solid fa-rocket"></i> shipped ${esc(timeAgo(p.latestUpdate.date))}: <b>${esc(p.latestUpdate.subject)}</b>`;
+ latest.style.display = '';
+ }
+
+ const grid = document.getElementById('pulse-grid');
+ const section = document.getElementById('home-pulse-section');
+ if (!grid || !section) return;
+ const cards = [];
+
+ // Goal cards — nearest to completion, with progress bars.
+ for (const g of (p.goals || [])) {
+ const pct = Math.min(100, Math.round((g.current_amount / g.target_amount) * 100));
+ const href = `/@${g.username}`;
+ cards.push(`
+ <a class="pulse-card pulse-goal" href="${href}" onclick="return handleLinkClick(event, '${href}')">
+ <div class="pulse-kicker"><i class="fa-solid fa-bullseye"></i> Goal · ${esc(g.display_name || g.username)}</div>
+ <div class="pulse-title">${esc(g.title)}</div>
+ <div class="goal-bar"><div class="goal-fill" style="width:${pct}%"></div></div>
+ <div class="pulse-sub">${Number(g.current_amount).toLocaleString()} / ${Number(g.target_amount).toLocaleString()} Vibes · <b>${pct}%</b></div>
+ </a>`);
+ }
+
+ // Latest activity card (tip + follow together).
+ const act = [];
+ if (p.latestTip) act.push(`<div class="pulse-act"><i class="fa-solid fa-hand-holding-dollar"></i> <b>${esc(p.latestTip.from_display || p.latestTip.from_username || 'Someone')}</b> tipped <b>${Number(p.latestTip.amount).toLocaleString()}</b> Vibes to <b>${esc(p.latestTip.to_display || p.latestTip.to_username)}</b> <span class="muted">${esc(timeAgo(p.latestTip.created_at))}</span></div>`);
+ if (p.newestFollow) act.push(`<div class="pulse-act"><i class="fa-solid fa-heart"></i> <b>${esc(p.newestFollow.follower_display || p.newestFollow.follower_username)}</b> followed <b>${esc(p.newestFollow.streamer_display || p.newestFollow.streamer_username)}</b> <span class="muted">${esc(timeAgo(p.newestFollow.created_at))}</span></div>`);
+ if (act.length) cards.push(`<div class="pulse-card"><div class="pulse-kicker"><i class="fa-solid fa-wave-square"></i> Latest activity</div>${act.join('')}</div>`);
+
+ // Weekly leader teasers.
+ const board = (title, icon, rows, unit) => rows && rows.length ? `
+ <div class="pulse-card">
+ <div class="pulse-kicker"><i class="fa-solid ${icon}"></i> ${title}</div>
+ ${rows.map((r, i) => `<div class="pulse-rank"><span class="pulse-medal">${['🥇', '🥈', '🥉'][i] || (i + 1)}</span> <a href="/@${esc(r.username)}" onclick="return handleLinkClick(event, '/@${esc(r.username)}')">${esc(r.display_name || r.username)}</a> <b>${Number(r.total).toLocaleString()}</b> <span class="muted">${unit}</span></div>`).join('')}
+ </div>` : '';
+ const supporters = board('Top supporters this week', 'fa-trophy', p.topSupporters, 'Vibes');
+ const earners = board('Top point earners this week', 'fa-coins', p.topEarners, 'pts');
+ if (supporters) cards.push(supporters);
+ if (earners) cards.push(earners);
+
+ section.style.display = cards.length ? '' : 'none';
+ grid.innerHTML = cards.join('');
+
+ // AI Moments showcase row.
+ const momentsRow = document.getElementById('home-moments-row');
+ const momentsSection = document.getElementById('home-moments-section');
+ if (momentsRow && momentsSection) {
+ const ms = (p.moments || []).filter(m => m.thumbnail);
+ momentsSection.style.display = ms.length ? '' : 'none';
+ momentsRow.innerHTML = ms.map(m => `
+ <a class="moment-card" href="${esc(m.href)}" onclick="return handleLinkClick(event, '${esc(m.href)}')">
+ <img src="${esc(m.thumbnail)}" alt="" loading="lazy">
+ <div class="moment-overlay">
+ <div class="moment-title">${esc(m.title)}</div>
+ ${m.username ? `<div class="moment-user">@${esc(m.username)}</div>` : ''}
+ </div>
+ </a>`).join('');
+ }
+}
+
+/* ── "While you were away" digest (logged-in returning users) ── */
+async function loadHomeDigest() {
+ const box = document.getElementById('home-digest');
+ if (!box || !currentUser) return;
+ const KEY = 'openvibe_last_visit';
+ let since = null;
+ try { since = localStorage.getItem(KEY); } catch { /* */ }
+ try { localStorage.setItem(KEY, new Date().toISOString()); } catch { /* */ }
+ // First visit (nothing to compare against) → no banner, just start the clock.
+ if (!since) return;
+ let d;
+ try { d = await api(`/home/digest?since=${encodeURIComponent(since)}`); } catch { return; }
+ if (!d || (!d.liveNow?.length && !d.missed?.length)) return;
+ const chip = (u, extra, live) => `
+ <a class="digest-chip ${live ? 'digest-chip--live' : ''}" href="/@${esc(u.username)}" onclick="return handleLinkClick(event, '/@${esc(u.username)}')">
+ ${_avatarSpan(u.avatar_url, u.username, u.profile_color)}
+ <span class="digest-name">${esc(u.display_name || u.username)}</span>
+ <span class="digest-extra">${extra}</span>
+ </a>`;
+ const parts = [];
+ for (const u of (d.liveNow || [])) parts.push(chip(u, '<i class="fa-solid fa-circle live-dot"></i> LIVE now', true));
+ for (const u of (d.missed || [])) parts.push(chip(u, `streamed ${u.sessions > 1 ? u.sessions + '× ' : ''}${esc(timeAgo(u.last_at))}`, false));
+ box.innerHTML = `<div class="digest-head"><i class="fa-solid fa-clock-rotate-left"></i> While you were away</div><div class="digest-row">${parts.join('')}</div>`;
+ box.style.display = '';
+}
+
async function loadHomeRecentOnline(page) {
async function loadHomeRecentOnline(page) {
if (page !== undefined) _homeRecentOnlinePage = page;
if (page !== undefined) _homeRecentOnlinePage = page;
const offset = (_homeRecentOnlinePage - 1) * HOME_RECENT_ONLINE_PAGE_SIZE;
const offset = (_homeRecentOnlinePage - 1) * HOME_RECENT_ONLINE_PAGE_SIZE;
@@ -1705,6 +1815,14 @@ function renderRecentlyOnline(containerId, streamers) {
@@ -1705,6 +1815,14 @@ function renderRecentlyOnline(containerId, streamers) {
</a>
</a>
</div>
</div>
${_cardAiHTML(s.ai_overview_short, s.ai_overview)}
${_cardAiHTML(s.ai_overview_short, s.ai_overview)}
+ ${s.top_goal ? (() => {
+ const pct = Math.min(100, Math.round((s.top_goal.current / s.top_goal.target) * 100));
+ return `<div class="streamer-group-goal" title="${esc(s.top_goal.title)}: ${Number(s.top_goal.current).toLocaleString()} / ${Number(s.top_goal.target).toLocaleString()} Vibes">
+ <span class="sgg-title"><i class="fa-solid fa-bullseye"></i> ${esc(s.top_goal.title)}</span>
+ <div class="goal-bar"><div class="goal-fill" style="width:${pct}%"></div></div>
+ <span class="sgg-pct">${pct}%</span>
+ </div>`;
+ })() : ''}
<div class="streamer-group-streams">${streamsHtml}</div>
<div class="streamer-group-streams">${streamsHtml}</div>
</div>
</div>
`;
`;
@@ -7,7 +7,7 @@
@@ -7,7 +7,7 @@
<!-- Keep these in step with index.html — the popout renders the same chat
<!-- Keep these in step with index.html — the popout renders the same chat
components (cosmetics.css positions particle effects; without it they sit
components (cosmetics.css positions particle effects; without it they sit
inline next to the username as "broken badges" until their cleanup timer). -->
inline next to the username as "broken badges" until their cleanup timer). -->
- <link rel="stylesheet" href="/css/style.css?v=137">
+ <link rel="stylesheet" href="/css/style.css?v=138">
<link rel="stylesheet" href="/css/chat-shared.css?v=1">
<link rel="stylesheet" href="/css/chat-shared.css?v=1">
<link rel="stylesheet" href="/css/cosmetics.css?v=2">
<link rel="stylesheet" href="/css/cosmetics.css?v=2">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
@@ -3788,6 +3788,84 @@ let _homeStatsCache = null;
@@ -3788,6 +3788,84 @@ let _homeStatsCache = null;
let _homeStatsCacheAt = 0;
let _homeStatsCacheAt = 0;
const _HOME_STATS_TTL = 30 * 1000; // 30s memo so the windowed COUNTs don't hammer SQLite
const _HOME_STATS_TTL = 30 * 1000; // 30s memo so the windowed COUNTs don't hammer SQLite
+// ── Viewer trend sampling (home hero sparkline) ──────────────
+// One row every ~5 minutes: total native viewers + live stream count.
+function _ensureViewerSamples() {
+ try {
+ getDb().exec(`CREATE TABLE IF NOT EXISTS viewer_samples (
+ sampled_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ viewers INTEGER NOT NULL DEFAULT 0,
+ live_streams INTEGER NOT NULL DEFAULT 0
+ )`);
+ getDb().exec('CREATE INDEX IF NOT EXISTS idx_viewer_samples_at ON viewer_samples(sampled_at)');
+ } catch { /* */ }
+}
+function recordViewerSample() {
+ _ensureViewerSamples();
+ const r = get(`SELECT COALESCE(SUM(viewer_count),0) AS v, COUNT(*) AS n FROM streams WHERE is_live = 1`) || { v: 0, n: 0 };
+ run('INSERT INTO viewer_samples (viewers, live_streams) VALUES (?, ?)', [r.v || 0, r.n || 0]);
+ run(`DELETE FROM viewer_samples WHERE sampled_at < datetime('now', '-7 days')`);
+ return r;
+}
+function getViewerTrend(hours = 24, maxPoints = 48) {
+ _ensureViewerSamples();
+ const rows = all(`SELECT strftime('%s', sampled_at) AS t, viewers, live_streams FROM viewer_samples
+ WHERE sampled_at >= datetime('now', ?) ORDER BY sampled_at ASC`, [`-${Math.max(1, hours)} hours`]) || [];
+ if (rows.length <= maxPoints) return rows;
+ const step = rows.length / maxPoints;
+ const out = [];
+ for (let i = 0; i < maxPoints; i++) out.push(rows[Math.floor(i * step)]);
+ return out;
+}
+
+// ── Home "pulse": goals near completion, latest activity, weekly leaders ──────
+function getHomePulse() {
+ const safe = (fn, dflt) => { try { return fn() ?? dflt; } catch { return dflt; } };
+ return {
+ // Active goals closest to completion, site-wide.
+ goals: safe(() => all(`SELECT g.id, g.title, g.current_amount, g.target_amount, g.image_url,
+ u.username, u.display_name
+ FROM donation_goals g JOIN users u ON u.id = g.user_id
+ WHERE g.is_active = 1 AND g.target_amount > 0
+ ORDER BY (CAST(g.current_amount AS REAL) / g.target_amount) DESC, g.current_amount DESC LIMIT 3`), []),
+ latestTip: safe(() => get(`SELECT t.amount, t.created_at,
+ fu.username AS from_username, fu.display_name AS from_display,
+ tu.username AS to_username, tu.display_name AS to_display
+ FROM transactions t
+ LEFT JOIN users fu ON fu.id = t.from_user_id
+ JOIN users tu ON tu.id = t.to_user_id
+ WHERE t.type = 'donation' ORDER BY t.id DESC LIMIT 1`), null),
+ newestFollow: safe(() => get(`SELECT f.created_at,
+ fu.username AS follower_username, fu.display_name AS follower_display,
+ su.username AS streamer_username, su.display_name AS streamer_display
+ FROM follows f
+ JOIN users fu ON fu.id = f.follower_id
+ JOIN users su ON su.id = f.streamer_id
+ ORDER BY f.id DESC LIMIT 1`), null),
+ // This week's leaders.
+ topSupporters: safe(() => all(`SELECT u.username, u.display_name, u.avatar_url, SUM(t.amount) AS total
+ FROM transactions t JOIN users u ON u.id = t.from_user_id
+ WHERE t.type = 'donation' AND t.created_at >= datetime('now', '-7 days')
+ GROUP BY t.from_user_id ORDER BY total DESC LIMIT 3`), []),
+ topEarners: safe(() => all(`SELECT u.username, u.display_name, u.avatar_url, SUM(c.amount) AS total
+ FROM coin_transactions c JOIN users u ON u.id = c.user_id
+ WHERE c.amount > 0 AND c.created_at >= datetime('now', '-7 days')
+ GROUP BY c.user_id ORDER BY total DESC LIMIT 3`), []),
+ };
+}
+
+// Nearest active goal per streamer (for Recently Online cards) — one query.
+function getActiveGoalsForUsers(userIds) {
+ if (!userIds || !userIds.length) return {};
+ const ph = userIds.map(() => '?').join(',');
+ const rows = all(`SELECT user_id, title, current_amount, target_amount FROM donation_goals
+ WHERE is_active = 1 AND target_amount > 0 AND user_id IN (${ph})
+ ORDER BY (CAST(current_amount AS REAL) / target_amount) DESC`, userIds) || [];
+ const out = {};
+ for (const r of rows) if (!out[r.user_id]) out[r.user_id] = r;
+ return out;
+}
+
function getHomeStats() {
function getHomeStats() {
const now = Date.now();
const now = Date.now();
if (_homeStatsCache && (now - _homeStatsCacheAt) < _HOME_STATS_TTL) return _homeStatsCache;
if (_homeStatsCache && (now - _homeStatsCacheAt) < _HOME_STATS_TTL) return _homeStatsCache;
@@ -7817,6 +7895,7 @@ module.exports = {
@@ -7817,6 +7895,7 @@ module.exports = {
scheduleClipNotifyState, bumpClipNotifyNowState, markClipNotifiedState, getDueClipNotifies,
scheduleClipNotifyState, bumpClipNotifyNowState, markClipNotifiedState, getDueClipNotifies,
getDb, initDb, run, get, all, close,
getDb, initDb, run, get, all, close,
getDonationGoalsForWidget, getAllDonationGoals, getActiveDonationGoals, getDonationGoalById,
getDonationGoalsForWidget, getAllDonationGoals, getActiveDonationGoals, getDonationGoalById,
+ recordViewerSample, getViewerTrend, getHomePulse, getActiveGoalsForUsers,
createDonationGoal, updateDonationGoal, deleteDonationGoal, addToDonationGoal,
createDonationGoal, updateDonationGoal, deleteDonationGoal, addToDonationGoal,
setChannelAlertSound, getChannelAlertSoundsByUser,
setChannelAlertSound, getChannelAlertSoundsByUser,
// Users
// Users
@@ -188,13 +188,87 @@ async function heroStats() {
@@ -188,13 +188,87 @@ async function heroStats() {
router.get('/hero', async (req, res) => {
router.get('/hero', async (req, res) => {
try {
try {
res.set('Cache-Control', 'public, max-age=20');
res.set('Cache-Control', 'public, max-age=20');
- res.json({ stats: await heroStats(), media: await heroMedia(), moments: heroMoments(), slogans: heroSlogans() });
+ const stats = await heroStats();
+ // 24h viewer trend for the hero sparkline (sampled every 5 min by the sampler).
+ try { stats.viewerTrend = db.getViewerTrend(24, 48); } catch { stats.viewerTrend = []; }
+ res.json({ stats, media: await heroMedia(), moments: heroMoments(), slogans: heroSlogans() });
} catch (err) {
} catch (err) {
console.error('[Home] hero error:', err.message);
console.error('[Home] hero error:', err.message);
res.status(500).json({ error: 'Failed to load hero' });
res.status(500).json({ error: 'Failed to load hero' });
}
}
});
});
+// ── Community pulse: goals near completion, latest activity, weekly leaders,
+// AI moment showcase, and the newest shipped update. Cached ~30s server-side.
+let _pulseCache = { at: 0, data: null };
+router.get('/pulse', async (req, res) => {
+ try {
+ res.set('Cache-Control', 'public, max-age=30');
+ if (Date.now() - _pulseCache.at < 30_000 && _pulseCache.data) return res.json(_pulseCache.data);
+ const pulse = db.getHomePulse();
+ pulse.moments = heroMoments().slice(0, 10);
+ pulse.latestUpdate = _latestUpdate();
+ _pulseCache = { at: Date.now(), data: pulse };
+ res.json(pulse);
+ } catch (err) {
+ console.error('[Home] pulse error:', err.message);
+ res.status(500).json({ error: 'Failed to load pulse' });
+ }
+});
+
+// Newest git commit = the latest shipped update (built-in-the-open hero one-liner).
+let _updateCache = { at: 0, data: null };
+function _latestUpdate() {
+ if (Date.now() - _updateCache.at < 5 * 60_000) return _updateCache.data;
+ try {
+ const { execSync } = require('child_process');
+ const raw = execSync(`git --no-pager log --pretty=format:'%h||%s||%aI' -1`,
+ { cwd: require('path').join(__dirname, '../..'), encoding: 'utf8', timeout: 4000 });
+ const [short, subject, date] = String(raw).trim().split('||');
+ _updateCache = { at: Date.now(), data: subject ? { short, subject, date } : null };
+ } catch { _updateCache = { at: Date.now(), data: null }; }
+ return _updateCache.data;
+}
+
+// ── "While you were away" digest for returning users ─────────────────────────
+// ?since=<ISO> (the client remembers its own last visit). Followed channels that
+// are live right now + those that streamed since the last visit.
+const { requireAuth } = require('../auth/auth');
+router.get('/digest', requireAuth, (req, res) => {
+ try {
+ const since = req.query.since && !Number.isNaN(Date.parse(req.query.since))
+ ? new Date(req.query.since).toISOString()
+ : new Date(Date.now() - 3 * 24 * 3600 * 1000).toISOString();
+ const liveNow = db.all(`SELECT DISTINCT u.username, u.display_name, u.avatar_url
+ FROM follows f JOIN users u ON u.id = f.streamer_id
+ JOIN streams s ON s.user_id = f.streamer_id AND s.is_live = 1
+ WHERE f.follower_id = ?`, [req.user.id]) || [];
+ const missed = db.all(`SELECT u.username, u.display_name, u.avatar_url,
+ COUNT(s.id) AS sessions, MAX(s.started_at) AS last_at, MAX(s.title) AS last_title
+ FROM follows f JOIN users u ON u.id = f.streamer_id
+ JOIN streams s ON s.user_id = f.streamer_id AND s.started_at >= ? AND s.is_live = 0
+ WHERE f.follower_id = ?
+ GROUP BY f.streamer_id ORDER BY last_at DESC LIMIT 6`, [since, req.user.id]) || [];
+ // Don't list a channel as "missed" when it's live right now — it's in liveNow.
+ const liveSet = new Set(liveNow.map(x => x.username));
+ res.json({ since, liveNow, missed: missed.filter(m => !liveSet.has(m.username)) });
+ } catch (err) {
+ console.error('[Home] digest error:', err.message);
+ res.status(500).json({ error: 'Failed to load digest' });
+ }
+});
+
+// Sample the live viewer total every 5 minutes for the hero sparkline.
+let _samplerTimer = null;
+function startViewerSampler() {
+ if (_samplerTimer) return;
+ try { db.recordViewerSample(); } catch { /* */ }
+ _samplerTimer = setInterval(() => { try { db.recordViewerSample(); } catch { /* */ } }, 5 * 60 * 1000);
+ if (_samplerTimer.unref) _samplerTimer.unref();
+ console.log('[Home] viewer sampler started (5m)');
+}
+
module.exports = router;
module.exports = router;
+module.exports.startViewerSampler = startViewerSampler;
module.exports.FALLBACK_AUDIENCES = FALLBACK_AUDIENCES;
module.exports.FALLBACK_AUDIENCES = FALLBACK_AUDIENCES;
module.exports.FALLBACK_QUIPS = FALLBACK_QUIPS;
module.exports.FALLBACK_QUIPS = FALLBACK_QUIPS;
@@ -1018,6 +1018,8 @@ async function start() {
@@ -1018,6 +1018,8 @@ async function start() {
try { require('./integrations/powerchat-platform').startCurrencyEarnFlusher(); } catch (e) { console.warn('[PowerChat] earn flusher not started:', e.message); }
try { require('./integrations/powerchat-platform').startCurrencyEarnFlusher(); } catch (e) { console.warn('[PowerChat] earn flusher not started:', e.message); }
// Auto-renew lapsed channel subs (Vibes-balance renewal; Stripe renews natively).
// Auto-renew lapsed channel subs (Vibes-balance renewal; Stripe renews natively).
try { require('./monetization/payments').startRenewalSweeper(); } catch (e) { console.warn('[Payments] renewal sweeper not started:', e.message); }
try { require('./monetization/payments').startRenewalSweeper(); } catch (e) { console.warn('[Payments] renewal sweeper not started:', e.message); }
+ // 5-minute live-viewer samples → the home hero's 24h sparkline.
+ try { require('./home/routes').startViewerSampler(); } catch (e) { console.warn('[Home] viewer sampler not started:', e.message); }
// PowerChat: prune the webhook-dedupe log daily so it can't grow unbounded.
// PowerChat: prune the webhook-dedupe log daily so it can't grow unbounded.
try {
try {
const _pcClean = () => { try { db.cleanupPowerchatDeliveries(3); } catch { /* */ } };
const _pcClean = () => { try { db.cleanupPowerchatDeliveries(3); } catch { /* */ } };
@@ -1114,6 +1114,14 @@ router.get('/recently-online', async (req, res) => {
@@ -1114,6 +1114,14 @@ router.get('/recently-online', async (req, res) => {
} catch (err) {
} catch (err) {
console.warn('[Streaming] recently-online thumb enrichment failed:', err.message);
console.warn('[Streaming] recently-online thumb enrichment failed:', err.message);
}
}
+ // Each streamer's nearest active donation goal → progress bar on the card.
+ try {
+ const goals = db.getActiveGoalsForUsers(streamers.map(s => s.user_id).filter(Boolean));
+ for (const s of streamers) {
+ const g = goals[s.user_id];
+ if (g) s.top_goal = { title: g.title, current: g.current_amount, target: g.target_amount };
+ }
+ } catch { /* best-effort */ }
res.json({ streamers, total, limit, offset, hasMore: offset + streamers.length < total });
res.json({ streamers, total, limit, offset, hasMore: offset + streamers.length < total });
} catch (err) {
} catch (err) {
console.error('[Streaming]', err.message);
console.error('[Streaming]', err.message);