openvibers/openvibe.community Commits
Feed: cards that say who, what, where and when in one line (#381)
Changes
@@@ -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; } }@@@ -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 };@@@ -119,7 +119,7 @@ const at = (minsAgo) => new Date(Date.now() - minsAgo * 60_000).toISOString().reassert.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');});
@@ -536,3 +536,53 @@ button.rx:hover { border-color: var(--accent); }
@@ -536,3 +536,53 @@ button.rx:hover { border-color: var(--accent); }
.locked-file a { white-space: nowrap; }
.locked-file a { white-space: nowrap; }
.md .pics-embed { display: block; max-width: 100%; height: auto; border-radius: 8px; margin: 1em 0; }
.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; } }
@@ -5,20 +5,56 @@ const { renderPage } = require('./layout');
@@ -5,20 +5,56 @@ const { renderPage } = require('./layout');
const { escapeHtml: esc } = require('./highlight');
const { escapeHtml: esc } = require('./highlight');
const { card } = require('../pulse/feed');
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 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}` : ''}`; };
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 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 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>
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>
<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>` : ''}`;
${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 })) }] : [];
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 });
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 };
@@ -119,7 +119,7 @@ const at = (minsAgo) => new Date(Date.now() - minsAgo * 60_000).toISOString().re
@@ -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('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('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('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');
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');
assert.ok(searched.text.includes('A video') && searched.text.includes('name="q" value="videos"'), 'the page applies a natural kind search');
});
});