openvibers/openvibe.community Commits

Feed: cards that say who, what, where and when in one line (#381)

OP OpenVibers committed 2 days ago · 46cd1a1a2c · 3 files, +92 −6

* Feed: cards that say who, what, where and when in one line

The feed printed "2026-11-11T16:10:38.000Z", labelled cards "Quest · quest", and laid a 640 px screenshot beside a
badge column. Now a card is its mark (coloured by kind: live red, members green, quests and games amber), one line of
who · what · where · when ("50 min ago", the full date in the title; the site left out when it repeats the kind),
the title, its line, and the picture at its own shape with its length or LIVE on it (at most 560 px wide; edge to
edge under the mark on a phone). The search is one rounded box with its button inside; Try is a row of chips that
scrolls on a phone; the kind tabs never wrap; one column at a reading width; nothing found says so and links back.

* Feed: a banner's mark is a drawn glyph; the feed test reads the Try chips

Changes

ddiff --git a/public/css/community.css b/public/css/community.css
iindex 8f8a2f3..e044f3b 100644
---- a/public/css/community.css
++++ b/public/css/community.css
@@@ -536,3 +536,53 @@ button.rx:hover { border-color: var(--accent); }
.locked-file a { white-space: nowrap; }
.md .pics-embed { display: block; max-width: 100%; height: auto; border-radius: 8px; margin: 1em 0; }
++
++/* ── The feed (Opus, 2026-11-11) ──
++ A card is its mark, then one line of who · what · where · when, the title, its line, and the picture at its own
++ shape (never wider than 560 px, so a screenshot is not a wall). The search is one rounded box with its button inside;
++ "Try" is a row of chips that scrolls sideways on a phone. */
++.feed-find { display: flex; align-items: center; gap: 8px; max-width: 640px; margin: 4px 0 10px; padding: 4px 4px 4px 14px; border: 1px solid var(--border); border-radius: 999px; background: var(--bg-card); }
++.feed-find:focus-within { border-color: var(--accent); }
++.feed-find > i { color: var(--text-secondary); font-size: .9rem; }
++.feed-find input { flex: 1; min-width: 0; min-height: 40px; border: 0; background: transparent; color: var(--text-primary); font: inherit; outline: none; }
++.feed-find button { flex: none; min-height: 40px; padding: 0 16px; border: 0; border-radius: 999px; background: var(--accent); color: #fff; font: inherit; font-weight: 600; cursor: pointer; }
++.feed-try { display: flex; align-items: center; gap: 8px; margin: 0 0 6px; overflow-x: auto; scrollbar-width: none; padding-bottom: 2px; }
++.feed-try::-webkit-scrollbar { display: none; }
++.feed-try > span { flex: none; color: var(--text-secondary); font-size: .85rem; }
++.feed-try a { flex: none; display: inline-flex; align-items: center; min-height: 34px; padding: 0 12px; border: 1px solid var(--border); border-radius: 999px; color: var(--text-primary); font-size: .85rem; text-decoration: none; white-space: nowrap; }
++.feed-try a:hover { border-color: var(--accent); text-decoration: none; }
++.feed-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
++.feed-item { display: grid; grid-template-columns: 36px minmax(0, 1fr); gap: 12px; padding: 14px 16px; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); }
++.feed-ic { display: grid; place-items: center; width: 36px; height: 36px; border-radius: 10px; background: color-mix(in srgb, var(--accent) 14%, transparent); color: var(--accent-light); font-size: .95rem; }
++.feed-item.is-live .feed-ic { background: color-mix(in srgb, #ef4444 18%, transparent); color: #f87171; }
++.feed-item.is-member .feed-ic, .feed-item.is-follow .feed-ic { background: color-mix(in srgb, #22c55e 16%, transparent); color: #4ade80; }
++.feed-item.is-quest .feed-ic, .feed-item.is-game .feed-ic { background: color-mix(in srgb, #f59e0b 16%, transparent); color: #fbbf24; }
++.feed-body { min-width: 0; display: grid; gap: 4px; }
++.feed-top { display: flex; flex-wrap: wrap; align-items: center; gap: 2px 0; margin: 0; font-size: .82rem; color: var(--text-secondary); line-height: 1.4; }
++.feed-top > * + *::before { content: "·"; margin: 0 6px; color: var(--text-muted, var(--text-secondary)); }
++.feed-who { display: inline-flex; align-items: center; gap: 6px; color: var(--text-primary); font-weight: 600; }
++.feed-who img { width: 20px; height: 20px; border-radius: 50%; object-fit: cover; }
++.feed-what { color: var(--text-primary); }
++.feed-title { font-weight: 650; font-size: 1.02rem; line-height: 1.35; color: var(--text-primary); text-decoration: none; overflow-wrap: anywhere; }
++.feed-title:hover { color: var(--accent-light); text-decoration: none; }
++.feed-text { margin: 0; color: var(--text-secondary); font-size: .92rem; line-height: 1.45; overflow-wrap: anywhere; }
++.feed-media { position: relative; display: block; width: 100%; max-width: 560px; margin-top: 6px; overflow: hidden; border-radius: 12px; background: var(--bg-secondary, #101828); }
++.feed-media.is-tall { max-width: 300px; }
++.feed-media img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; transition: transform .25s ease; }
++.feed-media:hover img { transform: scale(1.02); }
++.feed-len, .feed-live { position: absolute; bottom: 8px; right: 8px; padding: 2px 7px; border-radius: 6px; background: rgba(0, 0, 0, .72); color: #fff; font-size: .75rem; font-weight: 600; font-variant-numeric: tabular-nums; }
++.feed-live { left: 8px; right: auto; background: #dc2626; letter-spacing: .04em; }
++.feed-empty { display: grid; justify-items: center; gap: 4px; padding: 36px 16px; text-align: center; border: 1px dashed var(--border); border-radius: var(--radius); }
++.feed-empty > i { font-size: 1.6rem; color: var(--text-secondary); margin-bottom: 6px; }
++.feed-empty p { margin: 0; }
++.feed-empty .btn { margin-top: 10px; }
++@media (max-width: 600px) {
++ .feed-item { grid-template-columns: 28px minmax(0, 1fr); gap: 10px; padding: 12px; }
++ .feed-ic { width: 28px; height: 28px; border-radius: 8px; font-size: .8rem; }
++ .feed-media { margin-left: -38px; width: calc(100% + 38px); max-width: none; }
++ .feed-media.is-tall { max-width: 260px; width: auto; margin-left: 0; }
++}
++.feed-kind-tabs .tab { flex: none; white-space: nowrap; }
++/* One column a reader can follow down, at a reading width (two columns left holes beside the short cards). */
++.feed-list, .feed-find, .feed-empty { max-width: 760px; }
++@media (prefers-reduced-motion: reduce) { .feed-media img { transition: none; } .feed-media:hover img { transform: none; } }
ddiff --git a/server/render/feed.js b/server/render/feed.js
iindex 55500d5..0639fdf 100644
---- a/server/render/feed.js
++++ b/server/render/feed.js
@@@ -5,20 +5,56 @@ const { renderPage } = require('./layout');
const { escapeHtml: esc } = require('./highlight');
const { card } = require('../pulse/feed');
++// What each kind is called on a card and its mark (Opus, 2026-11-11: "Live · video" and "Quest · quest" said the same
++// thing twice; a card now says what happened, who, where and when, in one line).
++const KIND_LOOK = {
++ live: ['Live now', 'fa-tower-broadcast'], video: ['Recording', 'fa-film'], clip: ['Clip', 'fa-scissors'], pic: ['Picture', 'fa-image'],
++ post: ['Post', 'fa-pen-nib'], paste: ['Paste', 'fa-paste'], meme: ['Meme', 'fa-face-laugh'], game: ['Game', 'fa-gamepad'],
++ quest: ['Quest', 'fa-ranking-star'], member: ['New members', 'fa-user-plus'], update: ['Update', 'fa-bolt'], avatar: ['New picture', 'fa-user'],
++ banner: ['New banner', 'fa-image'], follow: ['Follow', 'fa-user-check'], site: ['New site', 'fa-globe'], space: ['Space', 'fa-shapes'],
++ episode: ['Episode', 'fa-microphone'],
++};
++const lookOf = (kind) => KIND_LOOK[kind] || [String(kind || 'Update').replace(/^./, (c) => c.toUpperCase()), 'fa-circle-nodes'];
++/** "3 min ago", "2 h ago", "yesterday", "Nov 3": when, the way a feed says it (the full date stays in the title). */
++function ago(iso, now = Date.now()) {
++ const t = Date.parse(iso || '');
++ if (!Number.isFinite(t)) return '';
++ const s = Math.max(0, (now - t) / 1000);
++ if (s < 60) return 'just now';
++ if (s < 3600) return `${Math.round(s / 60)} min ago`;
++ if (s < 86400) return `${Math.round(s / 3600)} h ago`;
++ if (s < 172800) return 'yesterday';
++ if (s < 604800) return `${Math.round(s / 86400)} days ago`;
++ return new Date(t).toLocaleDateString('en-US', { month: 'short', day: 'numeric', ...(now - t > 300 * 86400000 ? { year: 'numeric' } : {}), timeZone: 'UTC' });
++}
++const fullDate = (iso) => { const t = Date.parse(iso || ''); return Number.isFinite(t) ? new Date(t).toUTCString().replace(' GMT', ' UTC') : ''; };
++const clock = (sec) => { const n = Math.round(Number(sec) || 0); if (!n) return ''; const h = Math.floor(n / 3600); const m = Math.floor((n % 3600) / 60); const x = n % 60; return h ? `${h}:${String(m).padStart(2, '0')}:${String(x).padStart(2, '0')}` : `${m}:${String(x).padStart(2, '0')}`; };
++/** One card: its mark, who and what, where and when on one line; the title; the line; the picture at its own shape. */
++function feedCard(c, now) {
++ const [what, icon] = lookOf(c.kind);
++ const who = c.actor && c.actor.name ? `<span class="feed-who">${c.actor.avatar ? `<img src="${esc(c.actor.avatar)}" alt="" width="20" height="20" loading="lazy" decoding="async">` : ''}${esc(c.actor.name)}</span>` : '';
++ const when = c.at ? `<time datetime="${esc(c.at)}" title="${esc(fullDate(c.at))}">${esc(ago(c.at, now))}</time>` : '';
++ const ratio = c.media && Number(c.media.ratio) > 0 ? Math.min(2.4, Math.max(0.5, Number(c.media.ratio))) : 16 / 9;
++ const media = c.media ? `<a class="feed-media${ratio < 1 ? ' is-tall' : ''}" href="${esc(c.url)}" tabindex="-1" aria-hidden="true" style="aspect-ratio:${ratio.toFixed(3)}"><img src="${esc(c.media.thumb)}" alt="" loading="lazy" decoding="async">${c.media.length ? `<span class="feed-len">${esc(clock(c.media.length))}</span>` : ''}${c.kind === 'live' ? '<span class="feed-live">LIVE</span>' : ''}</a>` : '';
++ return `<li class="feed-item is-${esc(c.kind || 'update')}"><span class="feed-ic" aria-hidden="true"><i class="fa-solid ${icon}"></i></span><div class="feed-body">`
++ + `<p class="feed-top">${who}<span class="feed-what">${esc(what)}</span>${c.site_name && c.site_name !== what ? `<span class="feed-site">${esc(c.site_name)}</span>` : ''}${when}</p>`
++ + `<a class="feed-title" href="${esc(c.url)}">${esc(c.title)}</a>${c.text ? `<p class="feed-text">${esc(c.text)}</p>` : ''}${media}</div></li>`;
++}
++
const KINDS = [['', 'For you'], ['live', 'Live'], ['clip', 'Clips'], ['video', 'Videos'], ['pic', 'Pics'], ['post', 'Posts'], ['paste', 'Pastes'], ['meme', 'Memes'], ['game', 'Games'], ['update', 'Updates']];
const href = (kind, cursor = '', search = '') => { const q = new URLSearchParams(); if (kind) q.set('kind', kind); if (search) q.set('q', search); if (cursor) q.set('cursor', cursor); return `/feed${q.size ? `?${q}` : ''}`; };
--function feedPage({ items = [], kind = '', q = '', examples = [], cursor = null, nextCursor = null } = {}) {
++function feedPage({ items = [], kind = '', q = '', examples = [], cursor = null, nextCursor = null, now = Date.now() } = {}) {
const cards = items.map((item) => item && item.kind ? item : card(item));
const chips = KINDS.map(([id, label]) => `<a class="tab${id === kind ? ' active' : ''}" href="${esc(href(id, '', q))}"${id === kind ? ' aria-current="page"' : ''}>${label}</a>`).join('');
const body = `<header class="page-head"><nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <span aria-current="page">Feed</span></nav><h1>Feed</h1><p class="muted">What's happening across OpenVibe.</p></header>
--<form action="/feed" method="get" role="search"><label for="feed-search">Search the feed</label><input id="feed-search" type="search" name="q" value="${esc(q)}" placeholder="People, clips, sites, dates" maxlength="80">${kind ? `<input type="hidden" name="kind" value="${esc(kind)}">` : ''}<button type="submit">Search</button></form>
--${examples.length ? `<p class="muted">Try: ${examples.map((x) => `<a href="${esc(href('', '', x.text))}">${esc(x.text)}</a>`).join(' · ')}</p>` : ''}
++<form class="feed-find" action="/feed" method="get" role="search"><label for="feed-search" class="sr-only">Search the feed</label><i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i><input id="feed-search" type="search" name="q" value="${esc(q)}" placeholder="People, clips, sites, dates" maxlength="80">${kind ? `<input type="hidden" name="kind" value="${esc(kind)}">` : ''}<button type="submit">Search</button></form>
++${examples.length ? `<p class="feed-try"><span>Try</span>${examples.map((x) => `<a href="${esc(href('', '', x.text))}">${esc(x.text)}</a>`).join('')}</p>` : ''}
<nav class="tabs sort-tabs feed-kind-tabs" aria-label="Feed type">${chips}</nav>
--<ol class="thread-list pulse-list">${cards.map((c) => `<li class="pulse-item"><span class="badge pulse-source">${esc(c.site_name)} · ${esc(c.kind)}</span><div class="pulse-main"><a class="thread-title" href="${esc(c.url)}">${esc(c.title)}</a>${c.text ? `<p>${esc(c.text)}</p>` : ''}${c.media ? `<img src="${esc(c.media.thumb)}" alt="" loading="lazy" decoding="async">` : ''}<p class="thread-meta">${c.actor ? `${esc(c.actor.name || '')} · ` : ''}<time datetime="${esc(c.at || '')}">${esc(c.at || '')}</time></p></div></li>`).join('')}</ol>
++${cards.length ? `<ol class="feed-list">${cards.map((c) => feedCard(c, now)).join('')}</ol>` : `<div class="feed-empty"><i class="fa-solid fa-wind" aria-hidden="true"></i><p><strong>${q ? 'Nothing in the feed matches that.' : 'Nothing here yet.'}</strong></p><p class="muted">${q ? 'Try a name, a site or a date.' : 'New clips, videos, pictures and posts land here as they happen.'}</p>${q || kind ? `<a class="btn" href="/feed">The whole feed</a>` : ''}</div>`}
${cursor || nextCursor ? `<nav class="pager" aria-label="Feed pages">${cursor ? `<a href="${esc(href(kind, '', q))}">Latest</a>` : '<span></span>'}<span></span>${nextCursor ? `<a rel="next" href="${esc(href(kind, nextCursor, q))}">Older</a>` : '<span></span>'}</nav>` : ''}`;
const itemsLd = cards.length ? [{ '@context': 'https://schema.org', '@type': 'ItemList', name: 'OpenVibe Feed', url: 'https://openvibe.community/feed', itemListElement: cards.map((c, i) => ({ '@type': 'ListItem', position: i + 1, name: c.title, url: c.url })) }] : [];
return renderPage({ title: `Feed${kind ? ` — ${KINDS.find(([id]) => id === kind)?.[1] || kind}` : ''}`, description: 'New clips, videos, pictures, posts, live streams and updates from across OpenVibe.', canonicalPath: href(kind), robots: cursor || q || !cards.length ? 'noindex,follow' : 'index,follow', active: 'pulse', jsonLd: [ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Feed', url: '/feed' }]), ...itemsLd], body });
}
--module.exports = { feedPage };
++module.exports = { feedPage, ago };
ddiff --git a/test/feed-api.test.js b/test/feed-api.test.js
iindex f40cf93..471b858 100644
---- a/test/feed-api.test.js
++++ b/test/feed-api.test.js
@@@ -119,7 +119,7 @@ const at = (minsAgo) => new Date(Date.now() - minsAgo * 60_000).toISOString().re
assert.ok(r.text.includes('https://openvibe.video/v/1'), 'card link is in the response HTML');
assert.ok(r.text.includes('schema.org') && r.text.includes('ItemList'), 'ItemList JSON-LD is present');
assert.ok(r.text.includes('Older'), 'the page has a crawlable older-page link');
-- assert.ok(r.text.includes('id="feed-search"') && r.text.includes('Try:'), 'the page offers feed search and current examples');
++ assert.ok(r.text.includes('id="feed-search"') && r.text.includes('class="feed-try"'), 'the page offers feed search and current examples');
const searched = await t.get('/feed?q=videos');
assert.ok(searched.text.includes('A video') && searched.text.includes('name="q" value="videos"'), 'the page applies a natural kind search');
});