openvibers/openvibe.community Commits
Icons: OpenVibe glyphs, no Font Awesome (#422)
Changes
@@@ -120,8 +120,8 @@ on the data role (`add-service.sh community`). Record the N-1 fixtures after a d## Tests`npm test` runs each `test/*.test.js` in its own process through `openvibe-shared/test-runner`--(60 s/file) with in-process Live/Network mocks. Notable guards: `test/no-font-awesome.test.js` (no FA--link, font or unknown `fa-` name; every icon page loads the glyph drop-in),++(60 s/file) with in-process Live/Network mocks. Notable guards: the no-FA guard (no FA link, font or unknown++`fa-` name; every icon page loads the glyph drop-in),`test/empty-states.test.js`, `test/markdown.test.js` (an unsafescheme never becomes a link), `test/members-only.test.js`, `test/privacy.test.js`, the Pulse sourcerules, `test/nginx-auth-limit.test.js`, `test/nginx-internal-paths.test.js` and the N-1 fixtures.@@@ -139,7 +139,7 @@ rules, `test/nginx-auth-limit.test.js`, `test/nginx-internal-paths.test.js` and## Pitfalls--- **No Font Awesome.** `test/no-font-awesome.test.js` fails on a page/stylesheet that links it, or on++- **No Font Awesome.** The no-FA guard fails on a page/stylesheet that links it, or onan `fa-` name no glyph/brand draws. Use `openvibe-shared/glyphs` and `openvibe-shared/brands`.- **Community is the only paste authority.** Do not add a second store or read pastes from Media/Live.- **Identity comes from the verified token only** — never a body or a query.@@@ -22,7 +22,7 @@function flash(btn, text) {if (!btn) return;-- var old = btn.innerHTML; btn.innerHTML = '<i class="fa-solid fa-check" aria-hidden="true"></i> ' + text;++ var old = btn.innerHTML; btn.innerHTML = '<i class="ovg-fill" data-ov-icon="check" aria-hidden="true"></i> ' + text;setTimeout(function () { btn.innerHTML = old; }, 1600);}function copyText(text) {@@@ -167,7 +167,7 @@var codeWrap = $('[data-code]');// A Markdown paste shows its document; its source (inside "Markdown source") already wraps.if (codeWrap && !codeWrap.closest('.paste-source')) {-- var wrapBtn = document.createElement('button'); wrapBtn.type = 'button'; wrapBtn.className = 'btn btn-sm'; wrapBtn.innerHTML = '<i class="fa-solid fa-align-left" aria-hidden="true"></i> Wrap';++ var wrapBtn = document.createElement('button'); wrapBtn.type = 'button'; wrapBtn.className = 'btn btn-sm'; wrapBtn.innerHTML = '<i class="ovg-fill" data-ov-icon="align-left" aria-hidden="true"></i> Wrap';wrapBtn.addEventListener('click', function () { codeWrap.classList.toggle('wrap'); }, { signal });var actions = $('.actions'); if (actions) actions.appendChild(wrapBtn);}@@@ -26,13 +26,13 @@ const plural = (n, one, many) => `${num(n)} ${n === 1 ? one : many}`;/** One space as a board-index row: icon, name, description, child boards, topics, posts, last post. */function boardRow(sp) {-- const icon = sp.style === 'forum' ? 'fa-comments' : 'fa-fire';++ const icon = sp.style === 'forum' ? 'chat' : 'fire';const last = sp.last_post? `<a href="${esc(sp.last_post.thread.url)}#post-${Number(sp.last_post.id)}">${esc(ld.clean(sp.last_post.thread.title, 48))}</a><span class="small muted">by ${sp.last_post.author ? wearName(sp.last_post.author, sp.last_post.author.display_name || sp.last_post.author.username || 'someone') : 'someone'} · ${timeTag(sp.last_post.created_at)}</span>`: '<span class="muted small">No posts yet</span>';const children = (sp.children || []).length ? `<p class="small board-children">Child boards: ${sp.children.map((c) => `<a href="/forum/${esc(c.slug)}">${esc(c.name)}</a>`).join(', ')}</p>` : '';return `<tr>-- <td class="board-name"><span class="board-icon" aria-hidden="true"><i class="fa-solid ${icon}"></i></span><div>++ <td class="board-name"><span class="board-icon" aria-hidden="true"><i class="ovg-fill" data-ov-icon="${esc(icon)}"></i></span><div><a class="board-title" href="/forum/${esc(sp.slug)}">${esc(sp.name)}</a> <span class="badge badge-style">${STYLE_LABELS[sp.style] || 'Feed'}</span>${f.visBadge(sp)}${sp.description ? `<p class="muted small">${esc(sp.description)}</p>` : ''}${children}</div></td>@@@ -55,7 +55,7 @@ function boardIndexPage({ groups = [], user, canModerate = false }) {${crumbs([['Home', '/'], ['Forum', '/forum']])}<h1>Forum</h1><p class="muted">Forums for the people of OpenVibe. Classic forums read like a message board, newest reply on top. Feed forums rank posts by votes. Read without an account; sign in to post, rate and vote. See what is happening across the network on <a href="/pulse">Pulse</a>.</p>-- ${canModerate ? '<p><a class="btn btn-sm" href="/forum/new"><i class="fa-solid fa-plus" aria-hidden="true"></i> New forum</a></p>' : ''}++ ${canModerate ? '<p><a class="btn btn-sm" href="/forum/new"><i class="ovg-fill" data-ov-icon="plus" aria-hidden="true"></i> New forum</a></p>' : ''}</header>${groups.length ? groups.map((g) => `<section class="board-group" id="g-${esc(g.slug || 'more')}" aria-labelledby="gh-${esc(g.slug || 'more')}"><h2 class="board-group-h" id="gh-${esc(g.slug || 'more')}">${esc(g.name)}</h2>@@@ -84,12 +84,12 @@ function topicPages(base, pages) {function topicRow(t, space) {const base = `/forum/${space.slug}/${t.slug}`;-- const icon = t.locked ? 'fa-lock' : t.pinned ? 'fa-thumbtack' : t.reply_count >= 15 ? 'fa-fire' : 'fa-comment';++ const icon = t.locked ? 'lock' : t.pinned ? 'tack' : t.reply_count >= 15 ? 'fire' : 'chat';const label = t.locked ? 'Locked' : t.pinned ? 'Sticky' : t.reply_count >= 15 ? 'Hot topic' : 'Topic';const who = (a) => (a ? wearName(a, a.display_name || a.username || 'someone') : 'someone');const labels = labelsUi.inForce(t);return `<tr class="${t.pinned ? 'sticky' : ''}"${labelsUi.attrs(labels, { owner: t.author && t.author.subject })}>-- <td class="topic-icon" aria-hidden="true"><i class="fa-solid ${icon}" title="${label}"></i></td>++ <td class="topic-icon" aria-hidden="true"><i class="ovg-fill" data-ov-icon="${esc(icon)}" title="${label}"></i></td><td class="topic-main"${labels.length ? ' data-ov-labels-media' : ''}>${t.pinned ? '<span class="badge badge-sticky">Sticky</span> ' : ''}<a class="topic-title" href="${esc(base)}">${esc(t.title)}</a>${f.statusBadge(t)}${f.categoryBadge(t, space)}${f.vipBadge(t)}${labelsUi.chips(labels)}<span class="small muted topic-by">Started by ${who(t.author)} · ${timeTag(t.created_at)}${topicPages(base, t.pages || 1)}</span></td>@@@ -102,7 +102,7 @@ function topicRow(t, space) {/** Moderators: the space's settings (style, votes, ratings, group, description), no JS. */function settingsForm(space, groups = []) {const opt = (v, label, on) => `<option value="${esc(v)}"${on ? ' selected' : ''}>${esc(label)}</option>`;-- return `<details class="space-settings"><summary><i class="fa-solid fa-gear" aria-hidden="true"></i> Forum settings</summary>++ return `<details class="space-settings"><summary><i class="ovg-fill" data-ov-icon="gear" aria-hidden="true"></i> Forum settings</summary><form class="paste-form" method="post" action="/forum/${esc(space.slug)}/settings"><label class="field"><span>Name</span><input name="name" required minlength="2" maxlength="60" value="${esc(space.name)}"></label><label class="field"><span>Description</span><input name="description" maxlength="300" value="${esc(space.description || '')}"></label>@@@ -126,7 +126,7 @@ function moderatorsBox(space, moderators = [], { me = null, error = null } = {})return `<li class="mod-row">${authorHtml(m)}${m.added_at ? ` <span class="muted small">since ${timeTag(m.added_at)}</span>` : ''}<form class="inline-form" method="post" action="/forum/${slug}/moderators/remove"><input type="hidden" name="subject" value="${esc(m.subject)}"><button class="btn btn-sm btn-ghost" type="submit"${self ? '' : ` aria-label="Remove ${esc(name)} as a moderator"`}>${self ? 'Step down' : 'Remove'}</button></form></li>`;}).join('');-- return `<details class="space-settings" id="moderators"${error ? ' open' : ''}><summary><i class="fa-solid fa-user-shield" aria-hidden="true"></i> Moderators (${moderators.length})</summary>++ return `<details class="space-settings" id="moderators"${error ? ' open' : ''}><summary><i class="ovg-fill" data-ov-icon="shield" aria-hidden="true"></i> Moderators (${moderators.length})</summary>${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}${rows ? `<ul class="mod-list">${rows}</ul>` : '<p class="muted small">No one moderates this forum yet; discussion staff do.</p>'}<form class="paste-form" method="post" action="/forum/${slug}/moderators">@@@ -145,13 +145,13 @@ function forumSpacePage({ space, threads, page, pages, total, user, categories =const tags = categories.length ? `<nav class="chips" aria-label="Categories">${chip('All', href({ category: null }), !category)}${categories.map((c) => chip(esc(c.name), href({ category: c.slug }), c.slug === category)).join('')}</nav>` : '';const statuses = (space.statuses || []).length ? `<nav class="chips" aria-label="Status">${chip('Any status', href({ status: null }), !status)}${space.statuses.map((st) => chip(esc(f.STATUS_LABELS[st] || st), href({ status: st }), st === status)).join('')}</nav>` : '';const start = viewer.can_start !== false-- ? `<a class="btn btn-primary" href="/forum/${esc(space.slug)}/new"><i class="fa-solid fa-plus" aria-hidden="true"></i> ${space.thread_kind === 'request' ? 'New request' : 'New topic'}</a>`++ ? `<a class="btn btn-primary" href="/forum/${esc(space.slug)}/new"><i class="ovg-fill" data-ov-icon="plus" aria-hidden="true"></i> ${space.thread_kind === 'request' ? 'New request' : 'New topic'}</a>`: '<span class="muted small">Staff add roadmap items; reply to one to discuss it.</span>';const pagerHtml = f.pager((n) => href({ page: n }), page, pages, ['Previous', 'Next']);const body = `<header class="page-head">${crumbs([...trail(space), [space.name, `/forum/${space.slug}`]])}-- <h1>${esc(space.name)}${f.visBadge(space)}${feedable ? ` <a class="rss-link" href="/forum/${esc(space.slug)}/feed.xml" title="RSS feed of ${esc(space.name)}"><i class="fa-solid fa-rss" aria-hidden="true"></i> RSS</a>` : ''}</h1>++ <h1>${esc(space.name)}${f.visBadge(space)}${feedable ? ` <a class="rss-link" href="/forum/${esc(space.slug)}/feed.xml" title="RSS feed of ${esc(space.name)}"><i class="ovg-fill" data-ov-icon="rss" aria-hidden="true"></i> RSS</a>` : ''}</h1>${space.description ? `<p class="muted">${esc(space.description)}</p>` : ''}</header>${children.length ? `<section class="board-group" aria-label="Child boards">${boardTable(children, 'Child boards')}</section>` : ''}@@@ -198,7 +198,7 @@ function authorPanel(p) {function postbit(p, n, { base, page, catalogue, canReply, canModerate }) {if (p.deleted) return `<article class="postbit post-deleted" id="post-${p.id}"><div class="postbit-main"><p class="muted small">#${n} · This post was deleted.</p></div></article>`;-- const quote = canReply ? ` <a class="small" href="${esc(base)}?quote=${Number(p.id)}#reply"><i class="fa-solid fa-quote-left" aria-hidden="true"></i> Quote</a>` : '';++ const quote = canReply ? ` <a class="small" href="${esc(base)}?quote=${Number(p.id)}#reply"><i class="ovg-fill" data-ov-icon="quote" aria-hidden="true"></i> Quote</a>` : '';return `<article class="postbit${p.is_opening ? ' post-opening' : ''}" id="post-${p.id}">${authorPanel(p)}<div class="postbit-main">@@@ -226,7 +226,7 @@ function forumTopicPage({ space, thread, posts, page, pages, perPage = 50, viewe<label class="field"><span class="sr-only">Your reply (Markdown)</span><textarea name="body" rows="7" maxlength="40000" required placeholder="Say it, own it.">${esc(draft)}</textarea></label>${f.imageField(attachmentsEnabled, space)}${f.pasteField()}-- <div class="form-actions"><button class="btn btn-primary" type="submit"><i class="fa-solid fa-reply" aria-hidden="true"></i> Post reply</button><span class="muted small">**bold**, *italic*, \`code\`, > quotes, [links](https://…)</span></div>++ <div class="form-actions"><button class="btn btn-primary" type="submit"><i class="ovg-fill" data-ov-icon="reply" aria-hidden="true"></i> Post reply</button><span class="muted small">**bold**, *italic*, \`code\`, > quotes, [links](https://…)</span></div></form>`: thread.locked ? '<p class="alert">This topic is locked.</p>': !user ? `<p class="alert"><a href="/auth/login?next=${encodeURIComponent(base)}">Sign in with your OpenVibe account</a> to reply.</p>` : '';@@@ -239,10 +239,10 @@ function forumTopicPage({ space, thread, posts, page, pages, perPage = 50, viewe<header class="thread-head">${space.votes === false ? '' : f.voteForm(base, thread, viewer)}<div>-- <h1>${thread.pinned ? '<i class="fa-solid fa-thumbtack" title="Pinned" aria-hidden="true"></i><span class="sr-only">Pinned</span> ' : ''}${thread.locked ? '<i class="fa-solid fa-lock" title="Locked" aria-hidden="true"></i><span class="sr-only">Locked</span> ' : ''}${esc(thread.title)}${f.vipBadge(thread)}</h1>++ <h1>${thread.pinned ? '<i class="ovg-fill" data-ov-icon="tack" title="Pinned" aria-hidden="true"></i><span class="sr-only">Pinned</span> ' : ''}${thread.locked ? '<i class="ovg-fill" data-ov-icon="lock" title="Locked" aria-hidden="true"></i><span class="sr-only">Locked</span> ' : ''}${esc(thread.title)}${f.vipBadge(thread)}</h1>${thread.status || thread.category ? `<p class="thread-tags">${f.statusBadge(thread)}${f.categoryBadge(thread, space)}</p>` : ''}${labelsUi.chips(labels)}-- <p class="small muted">${plural(thread.reply_count, 'reply', 'replies')} · ${plural(thread.views, 'view', 'views')} · <a class="rss-link" href="${esc(base)}/feed.xml" title="RSS feed of this thread"><i class="fa-solid fa-rss" aria-hidden="true"></i> Follow this thread by RSS</a> ${kit.askAgent({ text: `About the forum thread “${thread.title}” (${config.baseUrl}${base}): ` })}</p>++ <p class="small muted">${plural(thread.reply_count, 'reply', 'replies')} · ${plural(thread.views, 'view', 'views')} · <a class="rss-link" href="${esc(base)}/feed.xml" title="RSS feed of this thread"><i class="ovg-fill" data-ov-icon="rss" aria-hidden="true"></i> Follow this thread by RSS</a> ${kit.askAgent({ text: `About the forum thread “${thread.title}” (${config.baseUrl}${base}): ` })}</p></div></header>${f.crosspostBlock(crosspost, base)}@@@ -7,13 +7,13 @@ const { escapeHtml: esc } = require('./highlight');// 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'],++ live: ['Live now', 'live'], video: ['Recording', 'film'], clip: ['Clip', 'scissors'], pic: ['Picture', 'image'],++ post: ['Post', 'pencil'], paste: ['Paste', 'clipboard'], meme: ['Meme', 'laugh'], game: ['Game', 'game'],++ quest: ['Quest', 'ranking'], member: ['New members', 'invite'], update: ['Update', 'zap'], avatar: ['New picture', 'user'],++ banner: ['New banner', 'image'], follow: ['Follow', 'user-check'], site: ['New site', 'globe'], space: ['Space', 'shapes'],++ episode: ['Episode', 'mic'],};--const lookOf = (kind) => KIND_LOOK[kind] || [String(kind || 'Update').replace(/^./, (c) => c.toUpperCase()), 'fa-circle-nodes'];++const lookOf = (kind) => KIND_LOOK[kind] || [String(kind || 'Update').replace(/^./, (c) => c.toUpperCase()), '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 || '');@@@ -50,7 +50,7 @@ function feedCard(c, now) {const ratio = c.media && Number(c.media.ratio) > 0 ? Math.min(2.4, Math.max(0.5, Number(c.media.ratio))) : 16 / 9;const strip = c.group ? groupStrip(c) : '';const media = strip || !c.media ? strip : `<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">`++ return `<li class="feed-item is-${esc(c.kind || 'update')}"><span class="feed-ic" aria-hidden="true"><i class="ovg-fill" data-ov-icon="${esc(icon)}"></i></span><div class="feed-body">`+ `<p class="feed-top">${who}<span class="feed-what">${esc(what)}</span>${c.site_name && !String(what).toLowerCase().startsWith(String(c.site_name).toLowerCase()) && !String(what).toLowerCase().endsWith(String(c.site_name).toLowerCase()) ? `<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>`;}@@@ -62,10 +62,10 @@ function feedPage({ items = [], kind = '', q = '', examples = [], cursor = null,const cards = items.filter((item) => item && item.kind);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 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>++<form class="feed-find" action="/feed" method="get" role="search"><label for="feed-search" class="sr-only">Search the feed</label><i class="ovg-fill" data-ov-icon="search" 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>--${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>`}++${cards.length ? `<ol class="feed-list">${cards.map((c) => feedCard(c, now)).join('')}</ol>` : `<div class="feed-empty"><i class="ovg-fill" data-ov-icon="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 });@@@ -52,21 +52,21 @@ function moderationFields(id) {/** A moderator's form to remove one post (its opening post removes the thread instead). */function postModerationForm(base, post) {-- return `<form class="post-mod inline-form" method="post" action="${esc(base)}/post/${Number(post.id)}/delete">${moderationFields(`post-${post.id}`)}<button class="btn btn-sm btn-danger" type="submit"><i class="fa-solid fa-trash" aria-hidden="true"></i> Delete post</button></form>`;++ return `<form class="post-mod inline-form" method="post" action="${esc(base)}/post/${Number(post.id)}/delete">${moderationFields(`post-${post.id}`)}<button class="btn btn-sm btn-danger" type="submit"><i class="ovg-fill" data-ov-icon="trash" aria-hidden="true"></i> Delete post</button></form>`;}function pager(href, page, pages, labels = ['Newer', 'Older']) {if (pages <= 1) return '';return `<nav class="pager" aria-label="Pages">-- ${page > 1 ? `<a rel="prev" href="${esc(href(page - 1))}"><i class="fa-solid fa-chevron-left" aria-hidden="true"></i> ${labels[0]}</a>` : '<span></span>'}++ ${page > 1 ? `<a rel="prev" href="${esc(href(page - 1))}"><i class="ovg-fill" data-ov-icon="back" aria-hidden="true"></i> ${labels[0]}</a>` : '<span></span>'}<span class="pager-info">Page ${page} of ${pages}</span>-- ${page < pages ? `<a rel="next" href="${esc(href(page + 1))}">${labels[1]} <i class="fa-solid fa-chevron-right" aria-hidden="true"></i></a>` : '<span></span>'}++ ${page < pages ? `<a rel="next" href="${esc(href(page + 1))}">${labels[1]} <i class="ovg-fill" data-ov-icon="chevron" aria-hidden="true"></i></a>` : '<span></span>'}</nav>`;}--const visBadge = (s) => (VIS_LABELS[s.visibility] ? ` <span class="badge badge-vis"><i class="fa-solid fa-lock" aria-hidden="true"></i> ${esc(VIS_LABELS[s.visibility])}</span>` : '') + vipBadge(s);++const visBadge = (s) => (VIS_LABELS[s.visibility] ? ` <span class="badge badge-vis"><i class="ovg-fill" data-ov-icon="lock" aria-hidden="true"></i> ${esc(VIS_LABELS[s.visibility])}</span>` : '') + vipBadge(s);/** A members-only (OpenVibe.VIP) space or thread. */--const vipBadge = (x) => (x && x.members_only ? ` <span class="badge badge-vis badge-vip" title="For this creator's OpenVibe.VIP members"><i class="fa-solid fa-star" aria-hidden="true"></i> VIP members only</span>` : '');++const vipBadge = (x) => (x && x.members_only ? ` <span class="badge badge-vis badge-vip" title="For this creator's OpenVibe.VIP members"><i class="ovg-fill" data-ov-icon="star" aria-hidden="true"></i> VIP members only</span>` : '');const vipJoin = (mo, label = 'Join on OpenVibe.VIP') => (mo && mo.join_url ? `<a class="btn btn-primary" href="${esc(mo.join_url)}" rel="noopener">${esc(label)}</a>` : '');// ── /forum ───────────────────────────────────────────────────────@@@ -98,14 +98,14 @@ ${list}`;// ── /forum/:space ────────────────────────────────────────────────function threadRow(t, space) {-- const flags = `${t.pinned ? '<i class="fa-solid fa-thumbtack" title="Pinned" aria-hidden="true"></i><span class="sr-only">Pinned</span> ' : ''}${t.locked ? '<i class="fa-solid fa-lock" title="Locked" aria-hidden="true"></i><span class="sr-only">Locked</span> ' : ''}`;++ const flags = `${t.pinned ? '<i class="ovg-fill" data-ov-icon="tack" title="Pinned" aria-hidden="true"></i><span class="sr-only">Pinned</span> ' : ''}${t.locked ? '<i class="ovg-fill" data-ov-icon="lock" title="Locked" aria-hidden="true"></i><span class="sr-only">Locked</span> ' : ''}`;const labels = labelsUi.inForce(t);return `<li class="thread-row${space.votes === false ? ' no-votes' : ''}"${labelsUi.attrs(labels, { owner: t.author && t.author.subject })}>${space.votes === false ? '' : `<span class="thread-score" title="Score">${num(t.score)}</span>`}<div class="thread-main"><a class="thread-title" href="/forum/${esc(space.slug)}/${esc(t.slug)}">${flags}${esc(t.title)}</a>${statusBadge(t)}${categoryBadge(t, space)}${vipBadge(t)}${labelsUi.chips(labels)}-- <p class="thread-meta">${who(t.author)} <span class="sep">·</span> ${timeTag(t.created_at)} <span class="sep">·</span> <span class="stat"><i class="fa-solid fa-comment" aria-hidden="true"></i> ${num(t.reply_count)} ${t.reply_count === 1 ? 'reply' : 'replies'}</span>${t.reply_count ? ` <span class="sep">·</span> active ${timeTag(t.last_activity_at)}` : ''}</p>++ <p class="thread-meta">${who(t.author)} <span class="sep">·</span> ${timeTag(t.created_at)} <span class="sep">·</span> <span class="stat"><i class="ovg-fill" data-ov-icon="chat" aria-hidden="true"></i> ${num(t.reply_count)} ${t.reply_count === 1 ? 'reply' : 'replies'}</span>${t.reply_count ? ` <span class="sep">·</span> active ${timeTag(t.last_activity_at)}` : ''}</p></div></li>`;}@@@ -118,7 +118,7 @@ function chatRoomBox(space, { canManage = false, error = null } = {}) {const r = space.chat_room;if (!r && !canManage) return '';const kind = r ? ({ call: 'Call room', system: 'Announcements' }[r.kind] || 'Chat room') : '';-- const link = r ? `<p><a class="btn" href="${esc(r.url)}" rel="noopener"><i class="fa-solid fa-comments" aria-hidden="true"></i> ${esc(r.name)}</a> <span class="muted small">${kind} on OpenVibe.Chat${r.visibility === 'private' ? ' · members only' : ''}</span></p>` : '';++ const link = r ? `<p><a class="btn" href="${esc(r.url)}" rel="noopener"><i class="ovg-fill" data-ov-icon="chat" aria-hidden="true"></i> ${esc(r.name)}</a> <span class="muted small">${kind} on OpenVibe.Chat${r.visibility === 'private' ? ' · members only' : ''}</span></p>` : '';const manage = canManage ? `<details class="space-settings"${error ? ' open' : ''}><summary>${r ? 'Change or detach the chat room' : 'Attach a chat room'}</summary>${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}<form class="paste-form" method="post" action="/forum/${esc(space.slug)}/chat-room">@@@ -140,13 +140,13 @@ function spacePage({ space, threads, sort, page, pages, total, user, categoriesconst statusNav = (space.statuses || []).length ? `<nav class="chips" aria-label="Status">${chip('Any status', spaceHref(space.slug, { sort, category }), !status)}${space.statuses.map((st) => chip(esc(STATUS_LABELS[st] || st), spaceHref(space.slug, { sort, category, status: st }), st === status)).join('')}</nav>` : '';const canStart = viewer.can_start !== false;const startLabel = space.thread_kind === 'request' ? 'New request' : space.thread_kind === 'roadmap' ? 'New roadmap item' : 'New thread';-- const start = canStart ? `<p><a class="btn btn-primary" href="/forum/${esc(space.slug)}/new"><i class="fa-solid fa-plus" aria-hidden="true"></i> ${startLabel}</a>${user ? '' : ' <span class="muted small">Sign in with your OpenVibe account to post.</span>'}</p>`++ const start = canStart ? `<p><a class="btn btn-primary" href="/forum/${esc(space.slug)}/new"><i class="ovg-fill" data-ov-icon="plus" aria-hidden="true"></i> ${startLabel}</a>${user ? '' : ' <span class="muted small">Sign in with your OpenVibe account to post.</span>'}</p>`: `<p class="muted small">Staff add roadmap items. Open one to ask about it or argue for it, or suggest something new in <a href="/forum/feedback">Feedback</a>.</p>`;const categoryName = category ? (categories.find((c) => c.slug === category) || {}).name : null;const body = `<header class="page-head"><nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/forum">Forum</a> › <span aria-current="page">${esc(space.name)}</span></nav>-- <h1>${esc(space.name)}${visBadge(space)}${feedable ? ` <a class="rss-link" href="/forum/${esc(space.slug)}/feed.xml" title="RSS feed of ${esc(space.name)}"><i class="fa-solid fa-rss" aria-hidden="true"></i> RSS</a>` : ''}</h1>++ <h1>${esc(space.name)}${visBadge(space)}${feedable ? ` <a class="rss-link" href="/forum/${esc(space.slug)}/feed.xml" title="RSS feed of ${esc(space.name)}"><i class="ovg-fill" data-ov-icon="rss" aria-hidden="true"></i> RSS</a>` : ''}</h1>${space.description ? `<p class="muted">${esc(space.description)}</p>` : ''}${start}</header>@@@ -174,10 +174,10 @@ ${footer}/** The pin / lock / delete / members-only / status / category buttons (no JS), each with rule and reason. */function modActions({ base, space, thread, viewer, categories = [] }) {return viewer.can_moderate || viewer.can_delete ? `<div class="actions mod-actions">-- ${viewer.can_moderate ? `<form class="mod-form" method="post" action="${esc(base)}/state"><input type="hidden" name="pinned" value="${thread.pinned ? 0 : 1}">${moderationFields('pin')}<button class="btn btn-sm" type="submit"><i class="fa-solid fa-thumbtack" aria-hidden="true"></i> ${thread.pinned ? 'Unpin' : 'Pin'}</button></form>-- <form class="mod-form" method="post" action="${esc(base)}/state"><input type="hidden" name="locked" value="${thread.locked ? 0 : 1}">${moderationFields('lock')}<button class="btn btn-sm" type="submit"><i class="fa-solid fa-lock" aria-hidden="true"></i> ${thread.locked ? 'Unlock' : 'Lock'}</button></form>` : ''}-- ${viewer.can_delete ? `<form class="mod-form" method="post" action="${esc(base)}/delete">${viewer.can_moderate ? moderationFields('delete') : ''}<button class="btn btn-sm btn-danger" type="submit"><i class="fa-solid fa-trash" aria-hidden="true"></i> Delete thread</button></form>` : ''}-- ${viewer.can_gate ? `<form method="post" action="${esc(base)}/members-only"><input type="hidden" name="on" value="${thread.members_only ? 0 : 1}"><button class="btn btn-sm" type="submit"><i class="fa-solid fa-star" aria-hidden="true"></i> ${thread.members_only ? 'Open to everyone' : 'VIP members only'}</button></form>` : ''}++ ${viewer.can_moderate ? `<form class="mod-form" method="post" action="${esc(base)}/state"><input type="hidden" name="pinned" value="${thread.pinned ? 0 : 1}">${moderationFields('pin')}<button class="btn btn-sm" type="submit"><i class="ovg-fill" data-ov-icon="tack" aria-hidden="true"></i> ${thread.pinned ? 'Unpin' : 'Pin'}</button></form>++ <form class="mod-form" method="post" action="${esc(base)}/state"><input type="hidden" name="locked" value="${thread.locked ? 0 : 1}">${moderationFields('lock')}<button class="btn btn-sm" type="submit"><i class="ovg-fill" data-ov-icon="lock" aria-hidden="true"></i> ${thread.locked ? 'Unlock' : 'Lock'}</button></form>` : ''}++ ${viewer.can_delete ? `<form class="mod-form" method="post" action="${esc(base)}/delete">${viewer.can_moderate ? moderationFields('delete') : ''}<button class="btn btn-sm btn-danger" type="submit"><i class="ovg-fill" data-ov-icon="trash" aria-hidden="true"></i> Delete thread</button></form>` : ''}++ ${viewer.can_gate ? `<form method="post" action="${esc(base)}/members-only"><input type="hidden" name="on" value="${thread.members_only ? 0 : 1}"><button class="btn btn-sm" type="submit"><i class="ovg-fill" data-ov-icon="star" aria-hidden="true"></i> ${thread.members_only ? 'Open to everyone' : 'VIP members only'}</button></form>` : ''}${viewer.can_moderate && (space.statuses || []).length && thread.status ? `<form class="mod-form" method="post" action="${esc(base)}/status"><label class="sr-only" for="thread-status">Status</label><select id="thread-status" name="status">${space.statuses.map((st) => `<option value="${esc(st)}"${st === thread.status ? ' selected' : ''}>${esc(STATUS_LABELS[st] || st)}</option>`).join('')}</select>${moderationFields('status')}<button class="btn btn-sm" type="submit">Set status</button></form>` : ''}${categories.length && (viewer.can_moderate || viewer.can_delete) ? `<form class="inline-form" method="post" action="${esc(base)}/category"><label class="sr-only" for="thread-category">Category</label><select id="thread-category" name="category"><option value="">No category</option>${categories.map((c) => `<option value="${esc(c.slug)}"${thread.category && thread.category.slug === c.slug ? ' selected' : ''}>${esc(c.name)}</option>`).join('')}</select><button class="btn btn-sm" type="submit">Set category</button></form>` : ''}</div>` : '';@@@ -201,7 +201,7 @@ function pasteCards(p) {if (!list.length) return '';const { highlight, languageLabel } = require('./highlight');return `<div class="paste-embeds">${list.map((x) => `<figure class="paste-embed">-- <figcaption><a href="${esc(x.url)}"><i class="fa-solid fa-${x.type === 'screenshot' ? 'image' : 'code'}" aria-hidden="true"></i> ${esc(x.title || 'Untitled')}</a>++ <figcaption><a href="${esc(x.url)}"><i class="ovg-fill" data-ov-icon="${x.type === 'screenshot' ? 'image' : 'code'}" aria-hidden="true"></i> ${esc(x.title || 'Untitled')}</a><span class="muted small">${x.type === 'screenshot' ? 'screenshot' : `${esc(languageLabel ? languageLabel(x.language) : x.language)} · ${num(x.lines)} ${x.lines === 1 ? 'line' : 'lines'}`}${x.visibility === 'unlisted' ? ' · unlisted' : ''}</span></figcaption>${x.type === 'screenshot' && x.screenshot_url ? `<a href="${esc(x.url)}"><img src="${esc(x.screenshot_url)}" alt="${esc(x.title || 'Screenshot')}" loading="lazy"></a>` : `<pre class="paste-embed-code"><code class="hljs">${highlight(x.excerpt || '', x.language).html}</code></pre>${x.lines > 12 ? `<a class="small" href="${esc(x.url)}">See all ${num(x.lines)} lines</a>` : ''}`}</figure>`).join('')}</div>`;@@@ -212,9 +212,9 @@ function pasteCards(p) {*/function crosspostBlock(c, base) {if (!c) return '';-- const from = c.from ? `<p class="crosspost-note"><i class="fa-solid fa-right-left" aria-hidden="true"></i> Crossposted from <strong>${esc(c.from.space)}</strong>: <a href="${esc(c.from.url)}">${esc(c.from.title)}</a></p>` : '';++ const from = c.from ? `<p class="crosspost-note"><i class="ovg-fill" data-ov-icon="swap" aria-hidden="true"></i> Crossposted from <strong>${esc(c.from.space)}</strong>: <a href="${esc(c.from.url)}">${esc(c.from.title)}</a></p>` : '';const to = (c.to || []).length ? `<p class="crosspost-note small muted">Also in ${c.to.map((x) => `<a href="${esc(x.url)}">${esc(x.space)}</a>`).join(', ')}</p>` : '';-- const form = (c.targets || []).length ? `<details class="crosspost-form"><summary><i class="fa-solid fa-right-left" aria-hidden="true"></i> Crosspost</summary>++ const form = (c.targets || []).length ? `<details class="crosspost-form"><summary><i class="ovg-fill" data-ov-icon="swap" aria-hidden="true"></i> Crosspost</summary><form class="inline-form" method="post" action="${esc(base)}/crosspost"><label class="sr-only" for="crosspost-to">Crosspost to</label><select id="crosspost-to" name="to">${c.targets.map((t) => `<option value="${esc(t.slug)}">${esc(t.name)}</option>`).join('')}</select><button class="btn btn-sm" type="submit">Crosspost</button></form></details>` : '';return from || to || form ? `<div class="crosspost">${from}${to}${form}</div>` : '';}@@@ -236,7 +236,7 @@ function reactionBar(p, base, page, catalogue = []) {const chip = (r) => (p.can_react? `<button class="rx rx-${esc(r.group || 'positive')}${r.mine ? ' mine' : ''}" type="submit" name="reaction" value="${esc(r.key)}" title="${esc(title(r))}" aria-pressed="${r.mine ? 'true' : 'false'}" aria-label="${esc(`${r.label}, ${r.count}`)}"><span aria-hidden="true">${r.emoji}</span> ${num(r.count)}</button>`: `<span class="rx rx-${esc(r.group || 'positive')}" title="${esc(title(r))}"><span aria-hidden="true">${r.emoji}</span> ${num(r.count)}<span class="sr-only"> ${esc(r.label)}</span></span>`);-- const picker = p.can_react && catalogue.length ? `<details class="rx-pick"><summary><i class="fa-regular fa-face-smile" aria-hidden="true"></i> Rate</summary><div class="rx-menu">${['positive', 'negative', 'utility'].map((g) => {++ const picker = p.can_react && catalogue.length ? `<details class="rx-pick"><summary><i data-ov-icon="smile" aria-hidden="true"></i> Rate</summary><div class="rx-menu">${['positive', 'negative', 'utility'].map((g) => {const list = catalogue.filter((r) => r.group === g);return list.length ? `<div class="rx-row">${list.map((r) => `<button class="rx-btn rx-${g}" type="submit" name="reaction" value="${esc(r.key)}" title="${esc(r.label)}"><span aria-hidden="true">${r.emoji}</span> ${esc(r.label)}</button>`).join('')}</div>` : '';}).join('')}</div></details>` : '';@@@ -257,9 +257,9 @@ function voteForm(base, thread, viewer) {if (!viewer.can_vote) return `<div class="vote"><span class="vote-score" title="Score">${num(thread.score)}</span></div>`;const btn = (value, icon, label) => {const active = thread.my_vote === value;-- return `<button class="vote-btn${active ? ' active' : ''}" type="submit" name="value" value="${active ? 0 : value}" aria-pressed="${active}" aria-label="${label}"><i class="fa-solid ${icon}" aria-hidden="true"></i></button>`;++ return `<button class="vote-btn${active ? ' active' : ''}" type="submit" name="value" value="${active ? 0 : value}" aria-pressed="${active}" aria-label="${label}"><i class="ovg-fill" data-ov-icon="${icon}" aria-hidden="true"></i></button>`;};-- return `<form class="vote" method="post" action="${esc(base)}/vote">${btn(1, 'fa-arrow-up', 'Upvote')}<span class="vote-score" title="Score">${num(thread.score)}</span>${btn(-1, 'fa-arrow-down', 'Downvote')}</form>`;++ return `<form class="vote" method="post" action="${esc(base)}/vote">${btn(1, 'arrow-up', 'Upvote')}<span class="vote-score" title="Score">${num(thread.score)}</span>${btn(-1, 'arrow-down', 'Downvote')}</form>`;}/**@@@ -270,7 +270,7 @@ function labelsBox(base, thread, viewer) {if (!viewer || !viewer.can_label) return '';const now = labelsUi.inForce(thread);const line = now.length ? now.map((l) => labelsUi.NAMES[l]).join(', ') : 'None set';-- return `<details class="labels-box" id="labels"><summary><span class="labels-box-title"><i class="fa-solid fa-tag" aria-hidden="true"></i> Labels</span> <span class="labels-box-line">${esc(line)}</span></summary>++ return `<details class="labels-box" id="labels"><summary><span class="labels-box-title"><i class="ovg-fill" data-ov-icon="tag" aria-hidden="true"></i> Labels</span> <span class="labels-box-line">${esc(line)}</span></summary><form method="post" action="${esc(base)}/labels" class="labels-form">${labelsUi.picker({ current: now, suggested: labelsUi.suggestedOf(thread), id: 'thread-labels' })}<div class="form-actions"><button class="btn btn-primary" type="submit">Save labels</button></div>@@@ -290,7 +290,7 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u<label class="field"><span>Reply (Markdown)</span><textarea name="body" rows="7" maxlength="40000" required placeholder="Say it, own it.">${esc(draft)}</textarea></label>${imageField(attachmentsEnabled, space)}${pasteField()}-- <div class="form-actions"><button class="btn btn-primary" type="submit"><i class="fa-solid fa-reply" aria-hidden="true"></i> Reply</button><span class="muted small">**bold**, *italic*, \`code\`, \`\`\` blocks, [links](https://…), > quotes and lists.</span></div>++ <div class="form-actions"><button class="btn btn-primary" type="submit"><i class="ovg-fill" data-ov-icon="reply" aria-hidden="true"></i> Reply</button><span class="muted small">**bold**, *italic*, \`code\`, \`\`\` blocks, [links](https://…), > quotes and lists.</span></div></form>`: thread.locked ? '<p class="alert">This thread is locked.</p>': !user ? `<p class="alert"><a href="/auth/login?next=${encodeURIComponent(base)}">Sign in with your OpenVibe account</a> to reply.</p>` : '';@@@ -304,11 +304,11 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u<header class="thread-head">${space.votes === false ? '' : voteForm(base, thread, viewer)}<div>-- <h1>${thread.pinned ? '<i class="fa-solid fa-thumbtack" title="Pinned" aria-hidden="true"></i><span class="sr-only">Pinned</span> ' : ''}${thread.locked ? '<i class="fa-solid fa-lock" title="Locked" aria-hidden="true"></i><span class="sr-only">Locked</span> ' : ''}${esc(thread.title)}${vipBadge(thread)}</h1>++ <h1>${thread.pinned ? '<i class="ovg-fill" data-ov-icon="tack" title="Pinned" aria-hidden="true"></i><span class="sr-only">Pinned</span> ' : ''}${thread.locked ? '<i class="ovg-fill" data-ov-icon="lock" title="Locked" aria-hidden="true"></i><span class="sr-only">Locked</span> ' : ''}${esc(thread.title)}${vipBadge(thread)}</h1>${thread.status || thread.category ? `<p class="thread-tags">${statusBadge(thread)}${categoryBadge(thread, space)}</p>` : ''}${labelsUi.chips(labels)}-- <p class="paste-meta">${who(thread.author)} <span class="sep">·</span> <time datetime="${esc(thread.created_at || '')}">${esc(fmtDate(thread.created_at))}</time> <span class="sep">·</span> <span class="stat"><i class="fa-solid fa-comment" aria-hidden="true"></i> ${num(thread.reply_count)} ${thread.reply_count === 1 ? 'reply' : 'replies'}</span></p>-- <p class="thread-follow"><a class="rss-link" href="${esc(base)}/feed.xml" title="RSS feed of this thread"><i class="fa-solid fa-rss" aria-hidden="true"></i> Follow this thread by RSS</a> ${kit.askAgent({ text: `About the forum thread “${thread.title}” (${config.baseUrl}${base}): ` })}</p>++ <p class="paste-meta">${who(thread.author)} <span class="sep">·</span> <time datetime="${esc(thread.created_at || '')}">${esc(fmtDate(thread.created_at))}</time> <span class="sep">·</span> <span class="stat"><i class="ovg-fill" data-ov-icon="chat" aria-hidden="true"></i> ${num(thread.reply_count)} ${thread.reply_count === 1 ? 'reply' : 'replies'}</span></p>++ <p class="thread-follow"><a class="rss-link" href="${esc(base)}/feed.xml" title="RSS feed of this thread"><i class="ovg-fill" data-ov-icon="rss" aria-hidden="true"></i> Follow this thread by RSS</a> ${kit.askAgent({ text: `About the forum thread “${thread.title}” (${config.baseUrl}${base}): ` })}</p></div></header>${crosspostBlock(crosspost, base)}@@@ -360,7 +360,7 @@ function newThreadPage({ space, user, values = {}, error = null, categories = []${labelsUi.picker({ current: values.labels || [], id: 'new-labels' })}<label class="check"><input type="checkbox" name="members_only" value="1"${values.members_only ? ' checked' : ''}> Only my OpenVibe.VIP members can read and reply</label><div class="form-actions">-- <button class="btn btn-primary" type="submit" data-ov-guide="community.thread"><i class="fa-solid fa-paper-plane" aria-hidden="true"></i> Post thread</button>++ <button class="btn btn-primary" type="submit" data-ov-guide="community.thread"><i class="ovg-fill" data-ov-icon="plane" aria-hidden="true"></i> Post thread</button><span class="muted small">Posting as <strong>${esc(user.display_name || user.username || 'you')}</strong>. By posting you agree to the <a href="/terms">rules</a>.</span></div></form>` : `<p class="alert"><a href="/auth/login?next=${encodeURIComponent(`/forum/${space.slug}/new`)}">Sign in with your OpenVibe account</a> to start a thread. Reading needs no account.</p>`;@@@ -400,7 +400,7 @@ function membersOnlyPage({ space, thread = null, members_only: mo = null, reasonconst body = `<header class="page-head">${crumbs}-- <h1>${esc(thread ? thread.title : space.name)} <span class="badge badge-vis badge-vip"><i class="fa-solid fa-star" aria-hidden="true"></i> VIP members only</span></h1>++ <h1>${esc(thread ? thread.title : space.name)} <span class="badge badge-vis badge-vip"><i class="ovg-fill" data-ov-icon="star" aria-hidden="true"></i> VIP members only</span></h1>${!thread && space.description ? `<p class="muted">${esc(space.description)}</p>` : ''}</header><section class="members-only">@@@ -64,14 +64,14 @@ function navbarInit(opts) {links: [// The product's own places: the main ones carry an icon (phones get them as the bottom tab bar), the rest fold// into the navbar's More (Shared 4.22). Search is the navbar's magnifier; What shipped is the footer's.-- { label: 'Forum', href: '/forum', icon: 'fa-comments', active: opts.active === 'spaces' },-- { label: 'Pastes', href: '/pastes', icon: 'fa-paste', active: opts.active === 'pastes' },-- { label: 'Pulse', href: '/pulse', icon: 'fa-bolt', active: opts.active === 'pulse' },-- { label: 'New paste', href: '/new', icon: 'fa-plus', active: opts.active === 'new' },++ { label: 'Forum', href: '/forum', icon: 'chat', active: opts.active === 'spaces' },++ { label: 'Pastes', href: '/pastes', icon: 'clipboard', active: opts.active === 'pastes' },++ { label: 'Pulse', href: '/pulse', icon: 'zap', active: opts.active === 'pulse' },++ { label: 'New paste', href: '/new', icon: 'plus', active: opts.active === 'new' },],appBar: true, // phones: the four places as the bottom tab bar (Shared 4.20)search: { page: '/search', label: 'OpenVibe.Community' }, // the magnifier offers this site's own search first-- menu: { after: [{ label: 'My pastes', href: '/my', icon: 'fa-paste' }] },++ menu: { after: [{ label: 'My pastes', href: '/my', icon: 'clipboard' }] },history: { type: opts.historyType || 'page', title: opts.historyTitle || opts.title, image_url: opts.historyImage || null, private: !!opts.historyPrivate, mature: !!opts.historyMature },silentLogin: `${config.baseUrl}/auth/login?silent=1&next={url}`,sessionUrl: '/auth/me',@@@ -98,7 +98,7 @@ function authorHtml(p, { link = true } = {}) {return `<a class="author" href="${esc(config.liveUrl)}/@${encodeURIComponent(p.username)}" rel="author">${inner}</a>`;}function langBadge(p) {-- if (p.type === 'screenshot') return `<span class="badge badge-lang"><i class="fa-solid fa-image" aria-hidden="true"></i> image</span>`;++ if (p.type === 'screenshot') return `<span class="badge badge-lang"><i class="ovg-fill" data-ov-icon="image" aria-hidden="true"></i> image</span>`;const l = p.language || 'text';return `<a class="badge badge-lang" href="/pastes?lang=${encodeURIComponent(l)}">${esc(languageLabel(l))}</a>`;}@@@ -119,11 +119,11 @@ function pasteCard(p, { eager = false } = {}) {const isShot = p.type === 'screenshot';const locked = !!p.locked;const preview = locked-- ? `<div class="card-locked"><i class="fa-solid fa-lock" aria-hidden="true"></i> Locked — OpenVibe can’t read what’s inside</div>`++ ? `<div class="card-locked"><i class="ovg-fill" data-ov-icon="lock" aria-hidden="true"></i> Locked — OpenVibe can’t read what’s inside</div>`: isShot? (p.screenshot_url ? `<div class="card-shot">${screenshotImg(p, { eager })}</div>` : ''): `<pre class="card-code" aria-hidden="true">${esc(String(p.content || '').slice(0, 220))}${String(p.content || '').length > 220 ? '…' : ''}</pre>`;-- const flags = `${p.pinned ? '<i class="fa-solid fa-thumbtack" title="Pinned"></i> ' : ''}${p.burn_after_read ? '<i class="fa-solid fa-fire" title="Burns after reading"></i> ' : ''}${locked ? '<i class="fa-solid fa-lock" title="Locked"></i> ' : ''}${p.is_nsfw ? '<span class="badge badge-nsfw">NSFW</span> ' : ''}`;++ const flags = `${p.pinned ? '<i class="ovg-fill" data-ov-icon="tack" title="Pinned"></i> ' : ''}${p.burn_after_read ? '<i class="ovg-fill" data-ov-icon="fire" title="Burns after reading"></i> ' : ''}${locked ? '<i class="ovg-fill" data-ov-icon="lock" title="Locked"></i> ' : ''}${p.is_nsfw ? '<span class="badge badge-nsfw">NSFW</span> ' : ''}`;return `<article class="card${p.is_nsfw ? ' card-nsfw' : ''}${locked ? ' card-is-locked' : ''}"><a class="card-link" href="/p/${esc(pastePathSlug(p))}">${preview}@@@ -131,7 +131,7 @@ function pasteCard(p, { eager = false } = {}) {</a><div class="card-meta">${authorHtml(p)}-- <span class="card-right">${langBadge(p)}<span class="stat" title="Views"><i class="fa-solid fa-eye" aria-hidden="true"></i> ${num(p.views)}</span>${timeTag(p.created_at)}</span>++ <span class="card-right">${langBadge(p)}<span class="stat" title="Views"><i class="ovg-fill" data-ov-icon="eye" aria-hidden="true"></i> ${num(p.views)}</span>${timeTag(p.created_at)}</span></div></article>`;}@@@ -341,8 +341,8 @@ function homePage({ latest, fromStreams = [], languages, user, spaces = [], roadlede: 'Paste code, logs, configs or a screenshot and get a short link that works anywhere. No account needed to read or share.',// Two, side by side on a phone: the paste itself, then the list (signed in: your own; See all opens everyone's).actions: [-- { label: 'Start a paste', href: '/new', primary: true, icon: 'fa-plus' },-- user ? { label: 'My pastes', href: '/my', icon: 'fa-user' } : { label: 'Browse pastes', href: '/pastes', icon: 'fa-paste' },++ { label: 'Start a paste', href: '/new', primary: true, icon: 'ov:paste' },++ user ? { label: 'My pastes', href: '/my', icon: 'ov:account' } : { label: 'Browse pastes', href: '/pastes', icon: 'ov:paste' },],// The terminal beside the words on a computer; a phone skips it (community.css), and the API fold has it too.aside: { html: `<pre class="cm-hero-code" aria-label="Paste a file from a terminal"><code>${esc(TERMINAL)}</code></pre>` },@@@ -397,9 +397,9 @@ function browsePage(result, languages) {if (lang && !(languages || []).some((l) => l.language === lang)) langOptions.push(opt(lang, languageLabel(lang)));const pager = pages > 1 ? `<nav class="pager" aria-label="Pages">-- ${page > 1 ? `<a rel="prev" href="${esc(pagerHref({ q, sort, lang, type, from }, page - 1))}"><i class="fa-solid fa-chevron-left" aria-hidden="true"></i> Newer</a>` : '<span></span>'}++ ${page > 1 ? `<a rel="prev" href="${esc(pagerHref({ q, sort, lang, type, from }, page - 1))}"><i class="ovg-fill" data-ov-icon="back" aria-hidden="true"></i> Newer</a>` : '<span></span>'}<span class="pager-info">Page ${page} of ${pages}</span>-- ${page < pages ? `<a rel="next" href="${esc(pagerHref({ q, sort, lang, type, from }, page + 1))}">Older <i class="fa-solid fa-chevron-right" aria-hidden="true"></i></a>` : '<span></span>'}++ ${page < pages ? `<a rel="next" href="${esc(pagerHref({ q, sort, lang, type, from }, page + 1))}">Older <i class="ovg-fill" data-ov-icon="chevron" aria-hidden="true"></i></a>` : '<span></span>'}</nav>` : '';const body = `@@@ -408,7 +408,7 @@ function browsePage(result, languages) {<p class="muted">${num(total)} ${total === 1 ? 'paste' : 'pastes'}${from === 'streams' ? ' from streams, written up by OpenVibe.AI' : from === 'people' ? ' by people' : ''}${windowed ? ` in the latest ${windowSize}` : ''}${q ? ` matching “${esc(q)}”` : ''}</p></header><form class="filters" method="get" action="/pastes" role="search" data-browse>-- <label class="search"><i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i><input type="search" name="q" value="${esc(q)}" placeholder="Search titles and content" aria-label="Search pastes"></label>++ <label class="search"><i class="ovg-fill" data-ov-icon="search" aria-hidden="true"></i><input type="search" name="q" value="${esc(q)}" placeholder="Search titles and content" aria-label="Search pastes"></label><label>From <select name="from"><option value="people"${from === 'people' ? ' selected' : ''}>People</option><option value="streams"${from === 'streams' ? ' selected' : ''}>Streams (AI)</option><option value="all"${from === 'all' ? ' selected' : ''}>Everyone</option></select></label><label>Show <select name="type"><option value="pastes"${type === 'pastes' ? ' selected' : ''}>Pastes</option><option value="images"${type === 'images' ? ' selected' : ''}>Images</option><option value="all"${type === 'all' ? ' selected' : ''}>All</option></select></label><label>Sort <select name="sort"><option value="new"${sort === 'new' ? ' selected' : ''}>Newest</option><option value="views"${sort === 'views' ? ' selected' : ''}>Most viewed</option></select></label>@@@ -507,7 +507,7 @@ function pastePage({ paste: p, related, user, comments = null, commentSort = 'to// away. Copy, Raw and Download still give the source.bodyBlock = isDoc ? `<div class="md paste-doc">${renderMarkdown(withoutTitle(p.content, title), { headingShift: 1, document: true })}</div><details class="paste-source">-- <summary><i class="fa-solid fa-code" aria-hidden="true"></i> Markdown source <span class="muted small">${num(hl.lines)} lines</span></summary>++ <summary><i class="ovg-fill" data-ov-icon="code" aria-hidden="true"></i> Markdown source <span class="muted small">${num(hl.lines)} lines</span></summary>${code}</details>` : code;}@@@ -515,16 +515,16 @@ function pastePage({ paste: p, related, user, comments = null, commentSort = 'to// The primary action of a locked paste is the card's own (unlock your locker, the password, the link's key): the// ordinary actions here are Share, and Delete for its owner. The envelope as a file is a quiet line under the card.const actions = locked ? `<div class="actions">-- <button type="button" class="btn btn-sm" data-ov-share-sheet data-title="${esc(title)}" data-kind="paste" data-id="${esc(p.slug)}"><i class="fa-solid fa-share-nodes" aria-hidden="true"></i> Share</button>-- ${isOwner ? `<button type="button" class="btn btn-sm btn-danger" data-delete="${esc(p.slug)}"><i class="fa-solid fa-trash" aria-hidden="true"></i> Delete</button>` : ''}++ <button type="button" class="btn btn-sm" data-ov-share-sheet data-title="${esc(title)}" data-kind="paste" data-id="${esc(p.slug)}"><i class="ovg-fill" data-ov-icon="nodes" aria-hidden="true"></i> Share</button>++ ${isOwner ? `<button type="button" class="btn btn-sm btn-danger" data-delete="${esc(p.slug)}"><i class="ovg-fill" data-ov-icon="trash" aria-hidden="true"></i> Delete</button>` : ''}</div>` : `<div class="actions">-- ${!isShot ? `<button type="button" class="btn btn-sm btn-primary" data-copy-content="${esc(p.slug)}"><i class="fa-solid fa-copy" aria-hidden="true"></i> Copy</button>` : ''}-- ${!isShot ? `<a class="btn btn-sm" href="/p/${esc(pageSlug)}/raw"><i class="fa-solid fa-file-lines" aria-hidden="true"></i> Raw</a>` : `<a class="btn btn-sm" href="/p/${esc(pageSlug)}/screenshot"><i class="fa-solid fa-image" aria-hidden="true"></i> Full image</a>`}-- <a class="btn btn-sm" href="/p/${esc(pageSlug)}/download" download="${esc(pageSlug)}.${esc(isShot ? 'png' : ext)}"><i class="fa-solid fa-download" aria-hidden="true"></i> Download</a>-- ${!isShot ? `<a class="btn btn-sm" href="/new?fork=${esc(p.slug)}"><i class="fa-solid fa-code-fork" aria-hidden="true"></i> Fork</a>` : ''}-- <button type="button" class="btn btn-sm" data-ov-share-sheet data-title="${esc(title)}" data-kind="paste" data-id="${esc(p.slug)}"><i class="fa-solid fa-share-nodes" aria-hidden="true"></i> Share</button>-- ${!restricted && p.visibility === 'public' && !p.burn_after_read ? `<a class="btn btn-sm" href="${esc(comments && comments.thread.forum_url || '#comments')}"><i class="fa-solid fa-comments" aria-hidden="true"></i> Discuss in the forum</a>` : ''}-- ${isOwner ? `<button type="button" class="btn btn-sm btn-danger" data-delete="${esc(p.slug)}"><i class="fa-solid fa-trash" aria-hidden="true"></i> Delete</button>` : ''}++ ${!isShot ? `<button type="button" class="btn btn-sm btn-primary" data-copy-content="${esc(p.slug)}"><i class="ovg-fill" data-ov-icon="copy" aria-hidden="true"></i> Copy</button>` : ''}++ ${!isShot ? `<a class="btn btn-sm" href="/p/${esc(pageSlug)}/raw"><i class="ovg-fill" data-ov-icon="file" aria-hidden="true"></i> Raw</a>` : `<a class="btn btn-sm" href="/p/${esc(pageSlug)}/screenshot"><i class="ovg-fill" data-ov-icon="image" aria-hidden="true"></i> Full image</a>`}++ <a class="btn btn-sm" href="/p/${esc(pageSlug)}/download" download="${esc(pageSlug)}.${esc(isShot ? 'png' : ext)}"><i class="ovg-fill" data-ov-icon="download" aria-hidden="true"></i> Download</a>++ ${!isShot ? `<a class="btn btn-sm" href="/new?fork=${esc(p.slug)}"><i class="ovg-fill" data-ov-icon="branch" aria-hidden="true"></i> Fork</a>` : ''}++ <button type="button" class="btn btn-sm" data-ov-share-sheet data-title="${esc(title)}" data-kind="paste" data-id="${esc(p.slug)}"><i class="ovg-fill" data-ov-icon="nodes" aria-hidden="true"></i> Share</button>++ ${!restricted && p.visibility === 'public' && !p.burn_after_read ? `<a class="btn btn-sm" href="${esc(comments && comments.thread.forum_url || '#comments')}"><i class="ovg-fill" data-ov-icon="chat" aria-hidden="true"></i> Discuss in the forum</a>` : ''}++ ${isOwner ? `<button type="button" class="btn btn-sm btn-danger" data-delete="${esc(p.slug)}"><i class="ovg-fill" data-ov-icon="trash" aria-hidden="true"></i> Delete</button>` : ''}</div>`;const offer = share && share.transfer ? `<div id="paste-offer" data-transfer="${esc(share.transfer.id)}" data-username="${esc(p.username || '')}" data-display-name="${esc(p.display_name || '')}" data-expires="${esc(share.transfer.expires_at || '')}" data-keep-access="${share.transfer.keep_access ? '1' : '0'}"></div>` : '';@@@ -538,17 +538,17 @@ ${offer}${authorHtml(p)}${locked ? '' : `<span class="sep">·</span>${langBadge(p)}`}<span class="sep">·</span><time datetime="${esc(ld.isoDate(p.created_at) || '')}" itemprop="datePublished">${esc(fmtDate(p.created_at))}</time>-- <span class="sep">·</span><span class="stat" title="Views"><i class="fa-solid fa-eye" aria-hidden="true"></i> ${num(p.views)} views</span>++ <span class="sep">·</span><span class="stat" title="Views"><i class="ovg-fill" data-ov-icon="eye" aria-hidden="true"></i> ${num(p.views)} views</span>${!isShot && !locked && hl ? `<span class="sep">·</span><span class="stat">${num(hl.lines)} lines</span>` : ''}-- ${locked ? `<span class="sep">·</span><span class="badge badge-lock"><i class="fa-solid fa-lock" aria-hidden="true"></i> locked</span>` : ''}-- ${share && share.audience && share.audience !== 'public' ? `<span class="sep">·</span><span class="badge badge-vis"><i class="fa-solid fa-${share.audience === 'private' ? 'lock' : share.audience === 'people' ? 'users' : 'eye-slash'}" aria-hidden="true"></i> ${esc(share.audience === 'link' ? 'anyone with the link' : share.audience === 'people' ? 'specific people' : 'only me')}</span>` : ''}-- ${p.burn_after_read ? `<span class="sep">·</span><span class="badge badge-burn"><i class="fa-solid fa-fire" aria-hidden="true"></i> burns after reading</span>` : ''}++ ${locked ? `<span class="sep">·</span><span class="badge badge-lock"><i class="ovg-fill" data-ov-icon="lock" aria-hidden="true"></i> locked</span>` : ''}++ ${share && share.audience && share.audience !== 'public' ? `<span class="sep">·</span><span class="badge badge-vis"><i class="ovg-fill" data-ov-icon="${share.audience === 'private' ? 'lock' : share.audience === 'people' ? 'members' : 'eye-off'}" aria-hidden="true"></i> ${esc(share.audience === 'link' ? 'anyone with the link' : share.audience === 'people' ? 'specific people' : 'only me')}</span>` : ''}++ ${p.burn_after_read ? `<span class="sep">·</span><span class="badge badge-burn"><i class="ovg-fill" data-ov-icon="fire" aria-hidden="true"></i> burns after reading</span>` : ''}</p>-- ${p.ai_summary ? `<p class="summary" itemprop="description"><i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i> ${esc(p.ai_summary)}</p>` : ''}++ ${p.ai_summary ? `<p class="summary" itemprop="description"><i class="ovg-fill" data-ov-icon="wand" aria-hidden="true"></i> ${esc(p.ai_summary)}</p>` : ''}${actions}</header>${bodyBlock}-- ${locked ? `<p class="locked-file muted small"><a href="/p/${esc(pageSlug)}/download" download="${esc(pageSlug)}.ovlk"><i class="fa-solid fa-lock" aria-hidden="true"></i> Download the locked file (.ovlk)</a> — it opens only with its key, in the Locker.</p>` : ''}++ ${locked ? `<p class="locked-file muted small"><a href="/p/${esc(pageSlug)}/download" download="${esc(pageSlug)}.ovlk"><i class="ovg-fill" data-ov-icon="lock" aria-hidden="true"></i> Download the locked file (.ovlk)</a> — it opens only with its key, in the Locker.</p>` : ''}<footer class="paste-foot"><p class="muted small">Stored by <a href="${esc(config.mediaUrl)}" rel="noopener">OpenVibe.Media</a>, account by <a href="${NETWORK_URL}" rel="noopener">OpenVibe.Network</a>. Something wrong with this paste? <a href="${esc(config.liveUrl)}/dmca">Report it</a>.</p></footer>@@@ -558,7 +558,7 @@ ${p.burn_after_read ? '' : `<section class="section">${require('./comments').commentPanel({ page: comments, user, base: `/p/${pageSlug}`, action: `/p/${pageSlug}/comments`, sort: commentSort, replyId, expandedId, expandedReplies })}</section>`}${!locked && related && related.length ? `<section class="section" id="related">-- <div class="section-head"><h2>More like this</h2><a class="more" href="${!isShot && p.language && p.language !== 'text' ? `/pastes?lang=${encodeURIComponent(p.language)}` : '/pastes'}">Browse <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></a></div>++ <div class="section-head"><h2>More like this</h2><a class="more" href="${!isShot && p.language && p.language !== 'text' ? `/pastes?lang=${encodeURIComponent(p.language)}` : '/pastes'}">Browse <i class="ovg-fill" data-ov-icon="arrow-right" aria-hidden="true"></i></a></div>${cardGrid(related, { title: 'No related pastes yet', text: 'Related pastes appear here when this one is linked with others.' })}</section>` : ''}<script src="${ovServe.url('ov-share.js')}" defer></script>${locked ? `@@@ -54,7 +54,7 @@ function pulsePage({ items, origin = '', after = null, nextCursor = null, origin<section data-results${after ? '' : ' data-pulse-live'}><p class="pulse-new" hidden><button type="button" class="btn btn-sm" data-pulse-show></button></p>${items.length ? `<ol class="thread-list pulse-list">${items.map(itemHtml).join('')}</ol>` : kit.empty({ icon: 'live', title: 'Nothing here yet', text: origin ? 'Nothing from this part of the network has arrived yet.' : 'New public clips, threads, pastes and posts land here as they happen.', action: origin ? { href: '/pulse', label: 'Everything', icon: 'live' } : { href: '/forum', label: 'Visit the forum', icon: 'chat' } })}-- ${after || nextCursor ? `<nav class="pager" aria-label="Pages">${after ? `<a href="${esc(href(origin))}"><i class="fa-solid fa-chevron-left" aria-hidden="true"></i> Latest</a>` : '<span></span>'}<span></span>${nextCursor ? `<a rel="next" href="${esc(href(origin, nextCursor))}">Older <i class="fa-solid fa-chevron-right" aria-hidden="true"></i></a>` : '<span></span>'}</nav>` : ''}++ ${after || nextCursor ? `<nav class="pager" aria-label="Pages">${after ? `<a href="${esc(href(origin))}"><i class="ovg-fill" data-ov-icon="back" aria-hidden="true"></i> Latest</a>` : '<span></span>'}<span></span>${nextCursor ? `<a rel="next" href="${esc(href(origin, nextCursor))}">Older <i class="ovg-fill" data-ov-icon="chevron" aria-hidden="true"></i></a>` : '<span></span>'}</nav>` : ''}</section>`;return renderPage({title: `Pulse${origin ? ` — ${FILTERS.find(([id]) => id === origin)[1]}` : ''}`,@@@ -15,8 +15,8 @@ const pathOf = (url) => { try { const u = new URL(url); return u.pathname + u.sefunction resultItem(r) {const f = r.facets || {};const kind = r.type === 'thread'-- ? `<span class="badge"><i class="fa-solid fa-comments" aria-hidden="true"></i> Thread${f.space ? ` in ${esc(f.space)}` : ''}</span>`-- : `<span class="badge"><i class="fa-solid fa-${f.kind === 'screenshot' ? 'image' : 'code'}" aria-hidden="true"></i> ${f.kind === 'screenshot' ? 'Screenshot' : 'Paste'}${f.syntax && f.syntax !== 'text' ? ` · ${esc(f.syntax)}` : ''}</span>`;++ ? `<span class="badge"><i class="ovg-fill" data-ov-icon="chat" aria-hidden="true"></i> Thread${f.space ? ` in ${esc(f.space)}` : ''}</span>`++ : `<span class="badge"><i class="ovg-fill" data-ov-icon="${f.kind === 'screenshot' ? 'image' : 'code'}" aria-hidden="true"></i> ${f.kind === 'screenshot' ? 'Screenshot' : 'Paste'}${f.syntax && f.syntax !== 'text' ? ` · ${esc(f.syntax)}` : ''}</span>`;const when = r.updated_at || r.published_at;return `<li class="search-hit"><a class="search-title" href="${esc(pathOf(r.canonical_url))}">${esc(r.title || 'Untitled')}</a>@@@ -39,7 +39,7 @@ function searchPage({ q = '', type = '', results = [], nextCursor = null, unavai<p class="muted">Threads and pastes${q ? ` matching “${esc(q)}”` : ''}. <a href="https://search.openvibe.network/?q=${encodeURIComponent(q)}">Search the whole OpenVibe network</a></p></header><form class="filters" method="get" action="/search" role="search">-- <label class="search"><i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i><input type="search" name="q" value="${esc(q)}" placeholder="Search threads and pastes" aria-label="Search threads and pastes" maxlength="200" autofocus></label>++ <label class="search"><i class="ovg-fill" data-ov-icon="search" aria-hidden="true"></i><input type="search" name="q" value="${esc(q)}" placeholder="Search threads and pastes" aria-label="Search threads and pastes" maxlength="200" autofocus></label><label>Show <select name="type">${opt('', 'Everything')}${opt('thread', 'Threads')}${opt('paste', 'Pastes')}</select></label><button class="btn" type="submit">Search</button></form>@@@ -40,7 +40,7 @@ function itemHtml(s) {function listBlock(items, { empty, action = null, prev, next }) {return `${items.length ? `<ol class="thread-list pulse-list">${items.map(itemHtml).join('')}</ol>` : kit.empty({ icon: 'mail', title: empty.title, text: empty.text, action })}-- ${prev || next ? `<nav class="pager" aria-label="Pages">${prev ? `<a href="${esc(prev)}"><i class="fa-solid fa-chevron-left" aria-hidden="true"></i> Newest</a>` : '<span></span>'}<span></span>${next ? `<a rel="next" href="${esc(next)}">Older <i class="fa-solid fa-chevron-right" aria-hidden="true"></i></a>` : '<span></span>'}</nav>` : ''}`;++ ${prev || next ? `<nav class="pager" aria-label="Pages">${prev ? `<a href="${esc(prev)}"><i class="ovg-fill" data-ov-icon="back" aria-hidden="true"></i> Newest</a>` : '<span></span>'}<span></span>${next ? `<a rel="next" href="${esc(next)}">Older <i class="ovg-fill" data-ov-icon="chevron" aria-hidden="true"></i></a>` : '<span></span>'}</nav>` : ''}`;}function submitForm({ user, values = {}, error = null }) {@@@ -54,7 +54,7 @@ function submitForm({ user, values = {}, error = null }) {<label class="field"><span>Link (a clip, a picture, anything with an address)</span><input type="url" name="url" maxlength="2000" value="${esc(values.url || '')}" placeholder="https://"></label><label class="field"><span>Description (Markdown)</span><textarea name="body" rows="8" maxlength="10000">${esc(values.body || '')}</textarea></label><div class="form-actions">-- <button class="btn btn-primary" type="submit"><i class="fa-solid fa-paper-plane" aria-hidden="true"></i> Submit for review</button>++ <button class="btn btn-primary" type="submit"><i class="ovg-fill" data-ov-icon="plane" aria-hidden="true"></i> Submit for review</button><span class="muted small">Sending as <strong>${esc(user.display_name || user.username || 'you')}</strong>. Moderators review it before it is public. By submitting you agree to the <a href="/terms">rules</a>.</span></div></form>`;@@@ -94,7 +94,7 @@ ${submitForm({ user, values, error })}`;/** GET /submissions/:slug */function submissionPage({ submission: s, user = null, error = null }) {-- const link = s.url ? `<p><a class="btn" href="${esc(s.url)}" rel="nofollow ugc noopener" target="_blank"><i class="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"></i> Open the link</a> <span class="muted small">${esc(s.url)}</span></p>` : '';++ const link = s.url ? `<p><a class="btn" href="${esc(s.url)}" rel="nofollow ugc noopener" target="_blank"><i class="ovg-fill" data-ov-icon="open" aria-hidden="true"></i> Open the link</a> <span class="muted small">${esc(s.url)}</span></p>` : '';const media = s.media_ref ? `<p class="muted small">OpenVibe.Media object <code>${esc(s.media_ref)}</code></p>` : '';const review = s.status !== 'pending' && (s.review_note || s.reviewer)? `<aside class="alert"><strong>${esc(STATUS_LABELS[s.status])}</strong>${s.reviewer ? ` by ${who(s.reviewer)}` : ''}${s.reviewed_at ? ` · ${timeTag(s.reviewed_at)}` : ''}${s.review_note ? `<p>${esc(s.review_note)}</p>` : ''}</aside>` : '';@@@ -11,7 +11,7 @@ const html = String(board.forumTopicPage({posts: [], page: 1, pages: 1, viewer: {}, user: null,}));const h1 = html.slice(html.indexOf('<h1>'), html.indexOf('</h1>') + 5);--assert.ok(h1.includes('<i class="fa-solid fa-thumbtack" title="Pinned" aria-hidden="true"></i><span class="sr-only">Pinned</span>'), 'the pin icon is hidden; the word is in the heading');--assert.ok(h1.includes('<i class="fa-solid fa-lock" title="Locked" aria-hidden="true"></i><span class="sr-only">Locked</span>'), 'the lock icon is hidden; the word is in the heading');++assert.ok(h1.includes('<i class="ovg-fill" data-ov-icon="tack" title="Pinned" aria-hidden="true"></i><span class="sr-only">Pinned</span>'), 'the pin icon is hidden; the word is in the heading');++assert.ok(h1.includes('<i class="ovg-fill" data-ov-icon="lock" title="Locked" aria-hidden="true"></i><span class="sr-only">Locked</span>'), 'the lock icon is hidden; the word is in the heading');assert.ok(!h1.includes('aria-label='), 'no aria-label on the role-less icons');console.log('board pin: the icons are hidden, the words read');@@@ -200,10 +200,10 @@ const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toStringconst r = await t.get(url, as(samJwt));has(r.text, 'This thread is locked.');assert.ok(!r.text.includes(`action="${url}/reply"`));-- has(r.text, '<h1><i class="fa-solid fa-thumbtack" title="Pinned" aria-hidden="true"></i><span class="sr-only">Pinned</span> <i class="fa-solid fa-lock" title="Locked" aria-hidden="true"></i><span class="sr-only">Locked</span> ');++ has(r.text, '<h1><i class="ovg-fill" data-ov-icon="tack" title="Pinned" aria-hidden="true"></i><span class="sr-only">Pinned</span> <i class="ovg-fill" data-ov-icon="lock" title="Locked" aria-hidden="true"></i><span class="sr-only">Locked</span> ');assert.ok(!r.text.includes('aria-label="Pinned"'), 'the pin does not carry an aria-label on an icon');const list = await t.get('/forum/general');-- has(list.text, '<i class="fa-solid fa-thumbtack" title="Pinned" aria-hidden="true"></i><span class="sr-only">Pinned</span>');++ has(list.text, '<i class="ovg-fill" data-ov-icon="tack" title="Pinned" aria-hidden="true"></i><span class="sr-only">Pinned</span>');assert.ok(!list.text.includes('aria-label="Pinned"'), 'the list pin does not carry an aria-label either');const admin = await t.get(url, as(adminJwt));has(admin.text, 'Unpin');@@@ -62,18 +62,21 @@ for (const f of LAYOUTS) {assert.ok(/shell\.page|ov-glyphs\.js|\/shared\/navbar/.test(t), `${f} renders pages without the navbar or /shared/ov-glyphs.js`);}--// Every fa- name in the pages, the scripts and the server views is drawn by a glyph or a brand.--const MOD = /^(solid|regular|light|thin|duotone|brands|sharp|classic|fw|lg|xl|xs|sm|2xs|2xl|\d+x|li|ul|border|inverse|pull-left|pull-right|rotate-\d+|rotate-by|flip-horizontal|flip-vertical|flip-both|width-auto|spin|spin-pulse|spin-reverse|pulse|beat|beat-fade|bounce|fade|flip|shake|stack|stack-1x|stack-2x|layers|layers-text|layers-counter|swap-opacity|stack-text)$/;++// No Font Awesome markup is left: every icon names an OpenVibe glyph or brand, and the name is one that is drawn.const files = [];for (const r of CODE_ROOTS) files.push(...walk(path.join(ROOT, r), /\.(html|js)$/));const missing = new Map();for (const f of files) {-- for (const m of fs.readFileSync(f, 'utf8').matchAll(/\bfa-([a-z0-9]+(?:-[a-z0-9]+)*)\b/g)) {-- const n = m[1];-- if (MOD.test(n) || glyphs.fromFa(n) || brands.fromFa(n)) continue;-- if (!missing.has(n)) missing.set(n, rel(f));++ const t = fs.readFileSync(f, 'utf8');++ const fa = t.match(/class=["'][^"']*\bfa-[a-z][^"']*["']/);++ assert.ok(!fa, `${rel(f)} still uses Font Awesome markup: ${fa ? fa[0] : ''}`);++ assert.ok(!/\bfa-(solid|regular|brands|light)\b/.test(t), `${rel(f)} still names a Font Awesome style class`);++ for (const m of t.matchAll(/data-ov-(icon|brand)="([a-z0-9-]+)"/g)) {++ const [kind, name] = [m[1], m[2]];++ const drawn = kind === 'icon' ? glyphs.NAMES.includes(name) : Boolean(brands.BRANDS[name]);++ if (!drawn && !missing.has(`${kind}:${name}`)) missing.set(`${kind}:${name}`, rel(f));}}--assert.deepStrictEqual([...missing].map(([n, f]) => `fa-${n} (${f})`), [], 'every icon this site names is drawn by the OpenVibe glyphs or brands');++assert.deepStrictEqual([...missing].map(([n, f]) => `${n} (${f})`), [], 'every icon this site names is drawn by the OpenVibe glyphs or brands');--console.log(`no font awesome: ${pages.length} pages, ${files.length} files, every fa- name drawn`);++console.log(`no font awesome: ${pages.length} pages, ${files.length} files, every icon named is drawn`);
@@ -120,8 +120,8 @@ on the data role (`add-service.sh community`). Record the N-1 fixtures after a d
@@ -120,8 +120,8 @@ on the data role (`add-service.sh community`). Record the N-1 fixtures after a d
## Tests
## Tests
`npm test` runs each `test/*.test.js` in its own process through `openvibe-shared/test-runner`
`npm test` runs each `test/*.test.js` in its own process through `openvibe-shared/test-runner`
-(60 s/file) with in-process Live/Network mocks. Notable guards: `test/no-font-awesome.test.js` (no FA
-link, font or unknown `fa-` name; every icon page loads the glyph drop-in),
+(60 s/file) with in-process Live/Network mocks. Notable guards: the no-FA guard (no FA link, font or unknown
+`fa-` name; every icon page loads the glyph drop-in),
`test/empty-states.test.js`, `test/markdown.test.js` (an unsafe
`test/empty-states.test.js`, `test/markdown.test.js` (an unsafe
scheme never becomes a link), `test/members-only.test.js`, `test/privacy.test.js`, the Pulse source
scheme never becomes a link), `test/members-only.test.js`, `test/privacy.test.js`, the Pulse source
rules, `test/nginx-auth-limit.test.js`, `test/nginx-internal-paths.test.js` and the N-1 fixtures.
rules, `test/nginx-auth-limit.test.js`, `test/nginx-internal-paths.test.js` and the N-1 fixtures.
@@ -139,7 +139,7 @@ rules, `test/nginx-auth-limit.test.js`, `test/nginx-internal-paths.test.js` and
@@ -139,7 +139,7 @@ rules, `test/nginx-auth-limit.test.js`, `test/nginx-internal-paths.test.js` and
## Pitfalls
## Pitfalls
-- **No Font Awesome.** `test/no-font-awesome.test.js` fails on a page/stylesheet that links it, or on
+- **No Font Awesome.** The no-FA guard fails on a page/stylesheet that links it, or on
an `fa-` name no glyph/brand draws. Use `openvibe-shared/glyphs` and `openvibe-shared/brands`.
an `fa-` name no glyph/brand draws. Use `openvibe-shared/glyphs` and `openvibe-shared/brands`.
- **Community is the only paste authority.** Do not add a second store or read pastes from Media/Live.
- **Community is the only paste authority.** Do not add a second store or read pastes from Media/Live.
- **Identity comes from the verified token only** — never a body or a query.
- **Identity comes from the verified token only** — never a body or a query.
@@ -22,7 +22,7 @@
@@ -22,7 +22,7 @@
function flash(btn, text) {
function flash(btn, text) {
if (!btn) return;
if (!btn) return;
- var old = btn.innerHTML; btn.innerHTML = '<i class="fa-solid fa-check" aria-hidden="true"></i> ' + text;
+ var old = btn.innerHTML; btn.innerHTML = '<i class="ovg-fill" data-ov-icon="check" aria-hidden="true"></i> ' + text;
setTimeout(function () { btn.innerHTML = old; }, 1600);
setTimeout(function () { btn.innerHTML = old; }, 1600);
}
}
function copyText(text) {
function copyText(text) {
@@ -167,7 +167,7 @@
@@ -167,7 +167,7 @@
var codeWrap = $('[data-code]');
var codeWrap = $('[data-code]');
// A Markdown paste shows its document; its source (inside "Markdown source") already wraps.
// A Markdown paste shows its document; its source (inside "Markdown source") already wraps.
if (codeWrap && !codeWrap.closest('.paste-source')) {
if (codeWrap && !codeWrap.closest('.paste-source')) {
- var wrapBtn = document.createElement('button'); wrapBtn.type = 'button'; wrapBtn.className = 'btn btn-sm'; wrapBtn.innerHTML = '<i class="fa-solid fa-align-left" aria-hidden="true"></i> Wrap';
+ var wrapBtn = document.createElement('button'); wrapBtn.type = 'button'; wrapBtn.className = 'btn btn-sm'; wrapBtn.innerHTML = '<i class="ovg-fill" data-ov-icon="align-left" aria-hidden="true"></i> Wrap';
wrapBtn.addEventListener('click', function () { codeWrap.classList.toggle('wrap'); }, { signal });
wrapBtn.addEventListener('click', function () { codeWrap.classList.toggle('wrap'); }, { signal });
var actions = $('.actions'); if (actions) actions.appendChild(wrapBtn);
var actions = $('.actions'); if (actions) actions.appendChild(wrapBtn);
}
}
@@ -26,13 +26,13 @@ const plural = (n, one, many) => `${num(n)} ${n === 1 ? one : many}`;
@@ -26,13 +26,13 @@ const plural = (n, one, many) => `${num(n)} ${n === 1 ? one : many}`;
/** One space as a board-index row: icon, name, description, child boards, topics, posts, last post. */
/** One space as a board-index row: icon, name, description, child boards, topics, posts, last post. */
function boardRow(sp) {
function boardRow(sp) {
- const icon = sp.style === 'forum' ? 'fa-comments' : 'fa-fire';
+ const icon = sp.style === 'forum' ? 'chat' : 'fire';
const last = sp.last_post
const last = sp.last_post
? `<a href="${esc(sp.last_post.thread.url)}#post-${Number(sp.last_post.id)}">${esc(ld.clean(sp.last_post.thread.title, 48))}</a><span class="small muted">by ${sp.last_post.author ? wearName(sp.last_post.author, sp.last_post.author.display_name || sp.last_post.author.username || 'someone') : 'someone'} · ${timeTag(sp.last_post.created_at)}</span>`
? `<a href="${esc(sp.last_post.thread.url)}#post-${Number(sp.last_post.id)}">${esc(ld.clean(sp.last_post.thread.title, 48))}</a><span class="small muted">by ${sp.last_post.author ? wearName(sp.last_post.author, sp.last_post.author.display_name || sp.last_post.author.username || 'someone') : 'someone'} · ${timeTag(sp.last_post.created_at)}</span>`
: '<span class="muted small">No posts yet</span>';
: '<span class="muted small">No posts yet</span>';
const children = (sp.children || []).length ? `<p class="small board-children">Child boards: ${sp.children.map((c) => `<a href="/forum/${esc(c.slug)}">${esc(c.name)}</a>`).join(', ')}</p>` : '';
const children = (sp.children || []).length ? `<p class="small board-children">Child boards: ${sp.children.map((c) => `<a href="/forum/${esc(c.slug)}">${esc(c.name)}</a>`).join(', ')}</p>` : '';
return `<tr>
return `<tr>
- <td class="board-name"><span class="board-icon" aria-hidden="true"><i class="fa-solid ${icon}"></i></span><div>
+ <td class="board-name"><span class="board-icon" aria-hidden="true"><i class="ovg-fill" data-ov-icon="${esc(icon)}"></i></span><div>
<a class="board-title" href="/forum/${esc(sp.slug)}">${esc(sp.name)}</a> <span class="badge badge-style">${STYLE_LABELS[sp.style] || 'Feed'}</span>${f.visBadge(sp)}
<a class="board-title" href="/forum/${esc(sp.slug)}">${esc(sp.name)}</a> <span class="badge badge-style">${STYLE_LABELS[sp.style] || 'Feed'}</span>${f.visBadge(sp)}
${sp.description ? `<p class="muted small">${esc(sp.description)}</p>` : ''}${children}
${sp.description ? `<p class="muted small">${esc(sp.description)}</p>` : ''}${children}
</div></td>
</div></td>
@@ -55,7 +55,7 @@ function boardIndexPage({ groups = [], user, canModerate = false }) {
@@ -55,7 +55,7 @@ function boardIndexPage({ groups = [], user, canModerate = false }) {
${crumbs([['Home', '/'], ['Forum', '/forum']])}
${crumbs([['Home', '/'], ['Forum', '/forum']])}
<h1>Forum</h1>
<h1>Forum</h1>
<p class="muted">Forums for the people of OpenVibe. Classic forums read like a message board, newest reply on top. Feed forums rank posts by votes. Read without an account; sign in to post, rate and vote. See what is happening across the network on <a href="/pulse">Pulse</a>.</p>
<p class="muted">Forums for the people of OpenVibe. Classic forums read like a message board, newest reply on top. Feed forums rank posts by votes. Read without an account; sign in to post, rate and vote. See what is happening across the network on <a href="/pulse">Pulse</a>.</p>
- ${canModerate ? '<p><a class="btn btn-sm" href="/forum/new"><i class="fa-solid fa-plus" aria-hidden="true"></i> New forum</a></p>' : ''}
+ ${canModerate ? '<p><a class="btn btn-sm" href="/forum/new"><i class="ovg-fill" data-ov-icon="plus" aria-hidden="true"></i> New forum</a></p>' : ''}
</header>
</header>
${groups.length ? groups.map((g) => `<section class="board-group" id="g-${esc(g.slug || 'more')}" aria-labelledby="gh-${esc(g.slug || 'more')}">
${groups.length ? groups.map((g) => `<section class="board-group" id="g-${esc(g.slug || 'more')}" aria-labelledby="gh-${esc(g.slug || 'more')}">
<h2 class="board-group-h" id="gh-${esc(g.slug || 'more')}">${esc(g.name)}</h2>
<h2 class="board-group-h" id="gh-${esc(g.slug || 'more')}">${esc(g.name)}</h2>
@@ -84,12 +84,12 @@ function topicPages(base, pages) {
@@ -84,12 +84,12 @@ function topicPages(base, pages) {
function topicRow(t, space) {
function topicRow(t, space) {
const base = `/forum/${space.slug}/${t.slug}`;
const base = `/forum/${space.slug}/${t.slug}`;
- const icon = t.locked ? 'fa-lock' : t.pinned ? 'fa-thumbtack' : t.reply_count >= 15 ? 'fa-fire' : 'fa-comment';
+ const icon = t.locked ? 'lock' : t.pinned ? 'tack' : t.reply_count >= 15 ? 'fire' : 'chat';
const label = t.locked ? 'Locked' : t.pinned ? 'Sticky' : t.reply_count >= 15 ? 'Hot topic' : 'Topic';
const label = t.locked ? 'Locked' : t.pinned ? 'Sticky' : t.reply_count >= 15 ? 'Hot topic' : 'Topic';
const who = (a) => (a ? wearName(a, a.display_name || a.username || 'someone') : 'someone');
const who = (a) => (a ? wearName(a, a.display_name || a.username || 'someone') : 'someone');
const labels = labelsUi.inForce(t);
const labels = labelsUi.inForce(t);
return `<tr class="${t.pinned ? 'sticky' : ''}"${labelsUi.attrs(labels, { owner: t.author && t.author.subject })}>
return `<tr class="${t.pinned ? 'sticky' : ''}"${labelsUi.attrs(labels, { owner: t.author && t.author.subject })}>
- <td class="topic-icon" aria-hidden="true"><i class="fa-solid ${icon}" title="${label}"></i></td>
+ <td class="topic-icon" aria-hidden="true"><i class="ovg-fill" data-ov-icon="${esc(icon)}" title="${label}"></i></td>
<td class="topic-main"${labels.length ? ' data-ov-labels-media' : ''}>${t.pinned ? '<span class="badge badge-sticky">Sticky</span> ' : ''}<a class="topic-title" href="${esc(base)}">${esc(t.title)}</a>${f.statusBadge(t)}${f.categoryBadge(t, space)}${f.vipBadge(t)}
<td class="topic-main"${labels.length ? ' data-ov-labels-media' : ''}>${t.pinned ? '<span class="badge badge-sticky">Sticky</span> ' : ''}<a class="topic-title" href="${esc(base)}">${esc(t.title)}</a>${f.statusBadge(t)}${f.categoryBadge(t, space)}${f.vipBadge(t)}
${labelsUi.chips(labels)}
${labelsUi.chips(labels)}
<span class="small muted topic-by">Started by ${who(t.author)} · ${timeTag(t.created_at)}${topicPages(base, t.pages || 1)}</span></td>
<span class="small muted topic-by">Started by ${who(t.author)} · ${timeTag(t.created_at)}${topicPages(base, t.pages || 1)}</span></td>
@@ -102,7 +102,7 @@ function topicRow(t, space) {
@@ -102,7 +102,7 @@ function topicRow(t, space) {
/** Moderators: the space's settings (style, votes, ratings, group, description), no JS. */
/** Moderators: the space's settings (style, votes, ratings, group, description), no JS. */
function settingsForm(space, groups = []) {
function settingsForm(space, groups = []) {
const opt = (v, label, on) => `<option value="${esc(v)}"${on ? ' selected' : ''}>${esc(label)}</option>`;
const opt = (v, label, on) => `<option value="${esc(v)}"${on ? ' selected' : ''}>${esc(label)}</option>`;
- return `<details class="space-settings"><summary><i class="fa-solid fa-gear" aria-hidden="true"></i> Forum settings</summary>
+ return `<details class="space-settings"><summary><i class="ovg-fill" data-ov-icon="gear" aria-hidden="true"></i> Forum settings</summary>
<form class="paste-form" method="post" action="/forum/${esc(space.slug)}/settings">
<form class="paste-form" method="post" action="/forum/${esc(space.slug)}/settings">
<label class="field"><span>Name</span><input name="name" required minlength="2" maxlength="60" value="${esc(space.name)}"></label>
<label class="field"><span>Name</span><input name="name" required minlength="2" maxlength="60" value="${esc(space.name)}"></label>
<label class="field"><span>Description</span><input name="description" maxlength="300" value="${esc(space.description || '')}"></label>
<label class="field"><span>Description</span><input name="description" maxlength="300" value="${esc(space.description || '')}"></label>
@@ -126,7 +126,7 @@ function moderatorsBox(space, moderators = [], { me = null, error = null } = {})
@@ -126,7 +126,7 @@ function moderatorsBox(space, moderators = [], { me = null, error = null } = {})
return `<li class="mod-row">${authorHtml(m)}${m.added_at ? ` <span class="muted small">since ${timeTag(m.added_at)}</span>` : ''}
return `<li class="mod-row">${authorHtml(m)}${m.added_at ? ` <span class="muted small">since ${timeTag(m.added_at)}</span>` : ''}
<form class="inline-form" method="post" action="/forum/${slug}/moderators/remove"><input type="hidden" name="subject" value="${esc(m.subject)}"><button class="btn btn-sm btn-ghost" type="submit"${self ? '' : ` aria-label="Remove ${esc(name)} as a moderator"`}>${self ? 'Step down' : 'Remove'}</button></form></li>`;
<form class="inline-form" method="post" action="/forum/${slug}/moderators/remove"><input type="hidden" name="subject" value="${esc(m.subject)}"><button class="btn btn-sm btn-ghost" type="submit"${self ? '' : ` aria-label="Remove ${esc(name)} as a moderator"`}>${self ? 'Step down' : 'Remove'}</button></form></li>`;
}).join('');
}).join('');
- return `<details class="space-settings" id="moderators"${error ? ' open' : ''}><summary><i class="fa-solid fa-user-shield" aria-hidden="true"></i> Moderators (${moderators.length})</summary>
+ return `<details class="space-settings" id="moderators"${error ? ' open' : ''}><summary><i class="ovg-fill" data-ov-icon="shield" aria-hidden="true"></i> Moderators (${moderators.length})</summary>
${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}
${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}
${rows ? `<ul class="mod-list">${rows}</ul>` : '<p class="muted small">No one moderates this forum yet; discussion staff do.</p>'}
${rows ? `<ul class="mod-list">${rows}</ul>` : '<p class="muted small">No one moderates this forum yet; discussion staff do.</p>'}
<form class="paste-form" method="post" action="/forum/${slug}/moderators">
<form class="paste-form" method="post" action="/forum/${slug}/moderators">
@@ -145,13 +145,13 @@ function forumSpacePage({ space, threads, page, pages, total, user, categories =
@@ -145,13 +145,13 @@ function forumSpacePage({ space, threads, page, pages, total, user, categories =
const tags = categories.length ? `<nav class="chips" aria-label="Categories">${chip('All', href({ category: null }), !category)}${categories.map((c) => chip(esc(c.name), href({ category: c.slug }), c.slug === category)).join('')}</nav>` : '';
const tags = categories.length ? `<nav class="chips" aria-label="Categories">${chip('All', href({ category: null }), !category)}${categories.map((c) => chip(esc(c.name), href({ category: c.slug }), c.slug === category)).join('')}</nav>` : '';
const statuses = (space.statuses || []).length ? `<nav class="chips" aria-label="Status">${chip('Any status', href({ status: null }), !status)}${space.statuses.map((st) => chip(esc(f.STATUS_LABELS[st] || st), href({ status: st }), st === status)).join('')}</nav>` : '';
const statuses = (space.statuses || []).length ? `<nav class="chips" aria-label="Status">${chip('Any status', href({ status: null }), !status)}${space.statuses.map((st) => chip(esc(f.STATUS_LABELS[st] || st), href({ status: st }), st === status)).join('')}</nav>` : '';
const start = viewer.can_start !== false
const start = viewer.can_start !== false
- ? `<a class="btn btn-primary" href="/forum/${esc(space.slug)}/new"><i class="fa-solid fa-plus" aria-hidden="true"></i> ${space.thread_kind === 'request' ? 'New request' : 'New topic'}</a>`
+ ? `<a class="btn btn-primary" href="/forum/${esc(space.slug)}/new"><i class="ovg-fill" data-ov-icon="plus" aria-hidden="true"></i> ${space.thread_kind === 'request' ? 'New request' : 'New topic'}</a>`
: '<span class="muted small">Staff add roadmap items; reply to one to discuss it.</span>';
: '<span class="muted small">Staff add roadmap items; reply to one to discuss it.</span>';
const pagerHtml = f.pager((n) => href({ page: n }), page, pages, ['Previous', 'Next']);
const pagerHtml = f.pager((n) => href({ page: n }), page, pages, ['Previous', 'Next']);
const body = `
const body = `
<header class="page-head">
<header class="page-head">
${crumbs([...trail(space), [space.name, `/forum/${space.slug}`]])}
${crumbs([...trail(space), [space.name, `/forum/${space.slug}`]])}
- <h1>${esc(space.name)}${f.visBadge(space)}${feedable ? ` <a class="rss-link" href="/forum/${esc(space.slug)}/feed.xml" title="RSS feed of ${esc(space.name)}"><i class="fa-solid fa-rss" aria-hidden="true"></i> RSS</a>` : ''}</h1>
+ <h1>${esc(space.name)}${f.visBadge(space)}${feedable ? ` <a class="rss-link" href="/forum/${esc(space.slug)}/feed.xml" title="RSS feed of ${esc(space.name)}"><i class="ovg-fill" data-ov-icon="rss" aria-hidden="true"></i> RSS</a>` : ''}</h1>
${space.description ? `<p class="muted">${esc(space.description)}</p>` : ''}
${space.description ? `<p class="muted">${esc(space.description)}</p>` : ''}
</header>
</header>
${children.length ? `<section class="board-group" aria-label="Child boards">${boardTable(children, 'Child boards')}</section>` : ''}
${children.length ? `<section class="board-group" aria-label="Child boards">${boardTable(children, 'Child boards')}</section>` : ''}
@@ -198,7 +198,7 @@ function authorPanel(p) {
@@ -198,7 +198,7 @@ function authorPanel(p) {
function postbit(p, n, { base, page, catalogue, canReply, canModerate }) {
function postbit(p, n, { base, page, catalogue, canReply, canModerate }) {
if (p.deleted) return `<article class="postbit post-deleted" id="post-${p.id}"><div class="postbit-main"><p class="muted small">#${n} · This post was deleted.</p></div></article>`;
if (p.deleted) return `<article class="postbit post-deleted" id="post-${p.id}"><div class="postbit-main"><p class="muted small">#${n} · This post was deleted.</p></div></article>`;
- const quote = canReply ? ` <a class="small" href="${esc(base)}?quote=${Number(p.id)}#reply"><i class="fa-solid fa-quote-left" aria-hidden="true"></i> Quote</a>` : '';
+ const quote = canReply ? ` <a class="small" href="${esc(base)}?quote=${Number(p.id)}#reply"><i class="ovg-fill" data-ov-icon="quote" aria-hidden="true"></i> Quote</a>` : '';
return `<article class="postbit${p.is_opening ? ' post-opening' : ''}" id="post-${p.id}">
return `<article class="postbit${p.is_opening ? ' post-opening' : ''}" id="post-${p.id}">
${authorPanel(p)}
${authorPanel(p)}
<div class="postbit-main">
<div class="postbit-main">
@@ -226,7 +226,7 @@ function forumTopicPage({ space, thread, posts, page, pages, perPage = 50, viewe
@@ -226,7 +226,7 @@ function forumTopicPage({ space, thread, posts, page, pages, perPage = 50, viewe
<label class="field"><span class="sr-only">Your reply (Markdown)</span><textarea name="body" rows="7" maxlength="40000" required placeholder="Say it, own it.">${esc(draft)}</textarea></label>
<label class="field"><span class="sr-only">Your reply (Markdown)</span><textarea name="body" rows="7" maxlength="40000" required placeholder="Say it, own it.">${esc(draft)}</textarea></label>
${f.imageField(attachmentsEnabled, space)}
${f.imageField(attachmentsEnabled, space)}
${f.pasteField()}
${f.pasteField()}
- <div class="form-actions"><button class="btn btn-primary" type="submit"><i class="fa-solid fa-reply" aria-hidden="true"></i> Post reply</button><span class="muted small">**bold**, *italic*, \`code\`, > quotes, [links](https://…)</span></div>
+ <div class="form-actions"><button class="btn btn-primary" type="submit"><i class="ovg-fill" data-ov-icon="reply" aria-hidden="true"></i> Post reply</button><span class="muted small">**bold**, *italic*, \`code\`, > quotes, [links](https://…)</span></div>
</form>`
</form>`
: thread.locked ? '<p class="alert">This topic is locked.</p>'
: thread.locked ? '<p class="alert">This topic is locked.</p>'
: !user ? `<p class="alert"><a href="/auth/login?next=${encodeURIComponent(base)}">Sign in with your OpenVibe account</a> to reply.</p>` : '';
: !user ? `<p class="alert"><a href="/auth/login?next=${encodeURIComponent(base)}">Sign in with your OpenVibe account</a> to reply.</p>` : '';
@@ -239,10 +239,10 @@ function forumTopicPage({ space, thread, posts, page, pages, perPage = 50, viewe
@@ -239,10 +239,10 @@ function forumTopicPage({ space, thread, posts, page, pages, perPage = 50, viewe
<header class="thread-head">
<header class="thread-head">
${space.votes === false ? '' : f.voteForm(base, thread, viewer)}
${space.votes === false ? '' : f.voteForm(base, thread, viewer)}
<div>
<div>
- <h1>${thread.pinned ? '<i class="fa-solid fa-thumbtack" title="Pinned" aria-hidden="true"></i><span class="sr-only">Pinned</span> ' : ''}${thread.locked ? '<i class="fa-solid fa-lock" title="Locked" aria-hidden="true"></i><span class="sr-only">Locked</span> ' : ''}${esc(thread.title)}${f.vipBadge(thread)}</h1>
+ <h1>${thread.pinned ? '<i class="ovg-fill" data-ov-icon="tack" title="Pinned" aria-hidden="true"></i><span class="sr-only">Pinned</span> ' : ''}${thread.locked ? '<i class="ovg-fill" data-ov-icon="lock" title="Locked" aria-hidden="true"></i><span class="sr-only">Locked</span> ' : ''}${esc(thread.title)}${f.vipBadge(thread)}</h1>
${thread.status || thread.category ? `<p class="thread-tags">${f.statusBadge(thread)}${f.categoryBadge(thread, space)}</p>` : ''}
${thread.status || thread.category ? `<p class="thread-tags">${f.statusBadge(thread)}${f.categoryBadge(thread, space)}</p>` : ''}
${labelsUi.chips(labels)}
${labelsUi.chips(labels)}
- <p class="small muted">${plural(thread.reply_count, 'reply', 'replies')} · ${plural(thread.views, 'view', 'views')} · <a class="rss-link" href="${esc(base)}/feed.xml" title="RSS feed of this thread"><i class="fa-solid fa-rss" aria-hidden="true"></i> Follow this thread by RSS</a> ${kit.askAgent({ text: `About the forum thread “${thread.title}” (${config.baseUrl}${base}): ` })}</p>
+ <p class="small muted">${plural(thread.reply_count, 'reply', 'replies')} · ${plural(thread.views, 'view', 'views')} · <a class="rss-link" href="${esc(base)}/feed.xml" title="RSS feed of this thread"><i class="ovg-fill" data-ov-icon="rss" aria-hidden="true"></i> Follow this thread by RSS</a> ${kit.askAgent({ text: `About the forum thread “${thread.title}” (${config.baseUrl}${base}): ` })}</p>
</div>
</div>
</header>
</header>
${f.crosspostBlock(crosspost, base)}
${f.crosspostBlock(crosspost, base)}
@@ -7,13 +7,13 @@ const { escapeHtml: esc } = require('./highlight');
@@ -7,13 +7,13 @@ const { escapeHtml: esc } = require('./highlight');
// What each kind is called on a card and its mark (Opus, 2026-11-11: "Live · video" and "Quest · quest" said the same
// 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).
// thing twice; a card now says what happened, who, where and when, in one line).
const KIND_LOOK = {
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'],
+ live: ['Live now', 'live'], video: ['Recording', 'film'], clip: ['Clip', 'scissors'], pic: ['Picture', 'image'],
+ post: ['Post', 'pencil'], paste: ['Paste', 'clipboard'], meme: ['Meme', 'laugh'], game: ['Game', 'game'],
+ quest: ['Quest', 'ranking'], member: ['New members', 'invite'], update: ['Update', 'zap'], avatar: ['New picture', 'user'],
+ banner: ['New banner', 'image'], follow: ['Follow', 'user-check'], site: ['New site', 'globe'], space: ['Space', 'shapes'],
+ episode: ['Episode', 'mic'],
};
};
-const lookOf = (kind) => KIND_LOOK[kind] || [String(kind || 'Update').replace(/^./, (c) => c.toUpperCase()), 'fa-circle-nodes'];
+const lookOf = (kind) => KIND_LOOK[kind] || [String(kind || 'Update').replace(/^./, (c) => c.toUpperCase()), 'nodes'];
/** "3 min ago", "2 h ago", "yesterday", "Nov 3": when, the way a feed says it (the full date stays in the title). */
/** "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()) {
function ago(iso, now = Date.now()) {
const t = Date.parse(iso || '');
const t = Date.parse(iso || '');
@@ -50,7 +50,7 @@ function feedCard(c, now) {
@@ -50,7 +50,7 @@ function feedCard(c, now) {
const ratio = c.media && Number(c.media.ratio) > 0 ? Math.min(2.4, Math.max(0.5, Number(c.media.ratio))) : 16 / 9;
const ratio = c.media && Number(c.media.ratio) > 0 ? Math.min(2.4, Math.max(0.5, Number(c.media.ratio))) : 16 / 9;
const strip = c.group ? groupStrip(c) : '';
const strip = c.group ? groupStrip(c) : '';
const media = strip || !c.media ? strip : `<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>`;
const media = strip || !c.media ? strip : `<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">`
+ return `<li class="feed-item is-${esc(c.kind || 'update')}"><span class="feed-ic" aria-hidden="true"><i class="ovg-fill" data-ov-icon="${esc(icon)}"></i></span><div class="feed-body">`
+ `<p class="feed-top">${who}<span class="feed-what">${esc(what)}</span>${c.site_name && !String(what).toLowerCase().startsWith(String(c.site_name).toLowerCase()) && !String(what).toLowerCase().endsWith(String(c.site_name).toLowerCase()) ? `<span class="feed-site">${esc(c.site_name)}</span>` : ''}${when}</p>`
+ `<p class="feed-top">${who}<span class="feed-what">${esc(what)}</span>${c.site_name && !String(what).toLowerCase().startsWith(String(c.site_name).toLowerCase()) && !String(what).toLowerCase().endsWith(String(c.site_name).toLowerCase()) ? `<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>`;
+ `<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>`;
}
}
@@ -62,10 +62,10 @@ function feedPage({ items = [], kind = '', q = '', examples = [], cursor = null,
@@ -62,10 +62,10 @@ function feedPage({ items = [], kind = '', q = '', examples = [], cursor = null,
const cards = items.filter((item) => item && item.kind);
const cards = items.filter((item) => item && item.kind);
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 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>
+<form class="feed-find" action="/feed" method="get" role="search"><label for="feed-search" class="sr-only">Search the feed</label><i class="ovg-fill" data-ov-icon="search" 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>` : ''}
${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>
-${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>`}
+${cards.length ? `<ol class="feed-list">${cards.map((c) => feedCard(c, now)).join('')}</ol>` : `<div class="feed-empty"><i class="ovg-fill" data-ov-icon="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 });
@@ -52,21 +52,21 @@ function moderationFields(id) {
@@ -52,21 +52,21 @@ function moderationFields(id) {
/** A moderator's form to remove one post (its opening post removes the thread instead). */
/** A moderator's form to remove one post (its opening post removes the thread instead). */
function postModerationForm(base, post) {
function postModerationForm(base, post) {
- return `<form class="post-mod inline-form" method="post" action="${esc(base)}/post/${Number(post.id)}/delete">${moderationFields(`post-${post.id}`)}<button class="btn btn-sm btn-danger" type="submit"><i class="fa-solid fa-trash" aria-hidden="true"></i> Delete post</button></form>`;
+ return `<form class="post-mod inline-form" method="post" action="${esc(base)}/post/${Number(post.id)}/delete">${moderationFields(`post-${post.id}`)}<button class="btn btn-sm btn-danger" type="submit"><i class="ovg-fill" data-ov-icon="trash" aria-hidden="true"></i> Delete post</button></form>`;
}
}
function pager(href, page, pages, labels = ['Newer', 'Older']) {
function pager(href, page, pages, labels = ['Newer', 'Older']) {
if (pages <= 1) return '';
if (pages <= 1) return '';
return `<nav class="pager" aria-label="Pages">
return `<nav class="pager" aria-label="Pages">
- ${page > 1 ? `<a rel="prev" href="${esc(href(page - 1))}"><i class="fa-solid fa-chevron-left" aria-hidden="true"></i> ${labels[0]}</a>` : '<span></span>'}
+ ${page > 1 ? `<a rel="prev" href="${esc(href(page - 1))}"><i class="ovg-fill" data-ov-icon="back" aria-hidden="true"></i> ${labels[0]}</a>` : '<span></span>'}
<span class="pager-info">Page ${page} of ${pages}</span>
<span class="pager-info">Page ${page} of ${pages}</span>
- ${page < pages ? `<a rel="next" href="${esc(href(page + 1))}">${labels[1]} <i class="fa-solid fa-chevron-right" aria-hidden="true"></i></a>` : '<span></span>'}
+ ${page < pages ? `<a rel="next" href="${esc(href(page + 1))}">${labels[1]} <i class="ovg-fill" data-ov-icon="chevron" aria-hidden="true"></i></a>` : '<span></span>'}
</nav>`;
</nav>`;
}
}
-const visBadge = (s) => (VIS_LABELS[s.visibility] ? ` <span class="badge badge-vis"><i class="fa-solid fa-lock" aria-hidden="true"></i> ${esc(VIS_LABELS[s.visibility])}</span>` : '') + vipBadge(s);
+const visBadge = (s) => (VIS_LABELS[s.visibility] ? ` <span class="badge badge-vis"><i class="ovg-fill" data-ov-icon="lock" aria-hidden="true"></i> ${esc(VIS_LABELS[s.visibility])}</span>` : '') + vipBadge(s);
/** A members-only (OpenVibe.VIP) space or thread. */
/** A members-only (OpenVibe.VIP) space or thread. */
-const vipBadge = (x) => (x && x.members_only ? ` <span class="badge badge-vis badge-vip" title="For this creator's OpenVibe.VIP members"><i class="fa-solid fa-star" aria-hidden="true"></i> VIP members only</span>` : '');
+const vipBadge = (x) => (x && x.members_only ? ` <span class="badge badge-vis badge-vip" title="For this creator's OpenVibe.VIP members"><i class="ovg-fill" data-ov-icon="star" aria-hidden="true"></i> VIP members only</span>` : '');
const vipJoin = (mo, label = 'Join on OpenVibe.VIP') => (mo && mo.join_url ? `<a class="btn btn-primary" href="${esc(mo.join_url)}" rel="noopener">${esc(label)}</a>` : '');
const vipJoin = (mo, label = 'Join on OpenVibe.VIP') => (mo && mo.join_url ? `<a class="btn btn-primary" href="${esc(mo.join_url)}" rel="noopener">${esc(label)}</a>` : '');
// ── /forum ───────────────────────────────────────────────────────
// ── /forum ───────────────────────────────────────────────────────
@@ -98,14 +98,14 @@ ${list}`;
@@ -98,14 +98,14 @@ ${list}`;
// ── /forum/:space ────────────────────────────────────────────────
// ── /forum/:space ────────────────────────────────────────────────
function threadRow(t, space) {
function threadRow(t, space) {
- const flags = `${t.pinned ? '<i class="fa-solid fa-thumbtack" title="Pinned" aria-hidden="true"></i><span class="sr-only">Pinned</span> ' : ''}${t.locked ? '<i class="fa-solid fa-lock" title="Locked" aria-hidden="true"></i><span class="sr-only">Locked</span> ' : ''}`;
+ const flags = `${t.pinned ? '<i class="ovg-fill" data-ov-icon="tack" title="Pinned" aria-hidden="true"></i><span class="sr-only">Pinned</span> ' : ''}${t.locked ? '<i class="ovg-fill" data-ov-icon="lock" title="Locked" aria-hidden="true"></i><span class="sr-only">Locked</span> ' : ''}`;
const labels = labelsUi.inForce(t);
const labels = labelsUi.inForce(t);
return `<li class="thread-row${space.votes === false ? ' no-votes' : ''}"${labelsUi.attrs(labels, { owner: t.author && t.author.subject })}>
return `<li class="thread-row${space.votes === false ? ' no-votes' : ''}"${labelsUi.attrs(labels, { owner: t.author && t.author.subject })}>
${space.votes === false ? '' : `<span class="thread-score" title="Score">${num(t.score)}</span>`}
${space.votes === false ? '' : `<span class="thread-score" title="Score">${num(t.score)}</span>`}
<div class="thread-main">
<div class="thread-main">
<a class="thread-title" href="/forum/${esc(space.slug)}/${esc(t.slug)}">${flags}${esc(t.title)}</a>${statusBadge(t)}${categoryBadge(t, space)}${vipBadge(t)}
<a class="thread-title" href="/forum/${esc(space.slug)}/${esc(t.slug)}">${flags}${esc(t.title)}</a>${statusBadge(t)}${categoryBadge(t, space)}${vipBadge(t)}
${labelsUi.chips(labels)}
${labelsUi.chips(labels)}
- <p class="thread-meta">${who(t.author)} <span class="sep">·</span> ${timeTag(t.created_at)} <span class="sep">·</span> <span class="stat"><i class="fa-solid fa-comment" aria-hidden="true"></i> ${num(t.reply_count)} ${t.reply_count === 1 ? 'reply' : 'replies'}</span>${t.reply_count ? ` <span class="sep">·</span> active ${timeTag(t.last_activity_at)}` : ''}</p>
+ <p class="thread-meta">${who(t.author)} <span class="sep">·</span> ${timeTag(t.created_at)} <span class="sep">·</span> <span class="stat"><i class="ovg-fill" data-ov-icon="chat" aria-hidden="true"></i> ${num(t.reply_count)} ${t.reply_count === 1 ? 'reply' : 'replies'}</span>${t.reply_count ? ` <span class="sep">·</span> active ${timeTag(t.last_activity_at)}` : ''}</p>
</div>
</div>
</li>`;
</li>`;
}
}
@@ -118,7 +118,7 @@ function chatRoomBox(space, { canManage = false, error = null } = {}) {
@@ -118,7 +118,7 @@ function chatRoomBox(space, { canManage = false, error = null } = {}) {
const r = space.chat_room;
const r = space.chat_room;
if (!r && !canManage) return '';
if (!r && !canManage) return '';
const kind = r ? ({ call: 'Call room', system: 'Announcements' }[r.kind] || 'Chat room') : '';
const kind = r ? ({ call: 'Call room', system: 'Announcements' }[r.kind] || 'Chat room') : '';
- const link = r ? `<p><a class="btn" href="${esc(r.url)}" rel="noopener"><i class="fa-solid fa-comments" aria-hidden="true"></i> ${esc(r.name)}</a> <span class="muted small">${kind} on OpenVibe.Chat${r.visibility === 'private' ? ' · members only' : ''}</span></p>` : '';
+ const link = r ? `<p><a class="btn" href="${esc(r.url)}" rel="noopener"><i class="ovg-fill" data-ov-icon="chat" aria-hidden="true"></i> ${esc(r.name)}</a> <span class="muted small">${kind} on OpenVibe.Chat${r.visibility === 'private' ? ' · members only' : ''}</span></p>` : '';
const manage = canManage ? `<details class="space-settings"${error ? ' open' : ''}><summary>${r ? 'Change or detach the chat room' : 'Attach a chat room'}</summary>
const manage = canManage ? `<details class="space-settings"${error ? ' open' : ''}><summary>${r ? 'Change or detach the chat room' : 'Attach a chat room'}</summary>
${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}
${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}
<form class="paste-form" method="post" action="/forum/${esc(space.slug)}/chat-room">
<form class="paste-form" method="post" action="/forum/${esc(space.slug)}/chat-room">
@@ -140,13 +140,13 @@ function spacePage({ space, threads, sort, page, pages, total, user, categories
@@ -140,13 +140,13 @@ function spacePage({ space, threads, sort, page, pages, total, user, categories
const statusNav = (space.statuses || []).length ? `<nav class="chips" aria-label="Status">${chip('Any status', spaceHref(space.slug, { sort, category }), !status)}${space.statuses.map((st) => chip(esc(STATUS_LABELS[st] || st), spaceHref(space.slug, { sort, category, status: st }), st === status)).join('')}</nav>` : '';
const statusNav = (space.statuses || []).length ? `<nav class="chips" aria-label="Status">${chip('Any status', spaceHref(space.slug, { sort, category }), !status)}${space.statuses.map((st) => chip(esc(STATUS_LABELS[st] || st), spaceHref(space.slug, { sort, category, status: st }), st === status)).join('')}</nav>` : '';
const canStart = viewer.can_start !== false;
const canStart = viewer.can_start !== false;
const startLabel = space.thread_kind === 'request' ? 'New request' : space.thread_kind === 'roadmap' ? 'New roadmap item' : 'New thread';
const startLabel = space.thread_kind === 'request' ? 'New request' : space.thread_kind === 'roadmap' ? 'New roadmap item' : 'New thread';
- const start = canStart ? `<p><a class="btn btn-primary" href="/forum/${esc(space.slug)}/new"><i class="fa-solid fa-plus" aria-hidden="true"></i> ${startLabel}</a>${user ? '' : ' <span class="muted small">Sign in with your OpenVibe account to post.</span>'}</p>`
+ const start = canStart ? `<p><a class="btn btn-primary" href="/forum/${esc(space.slug)}/new"><i class="ovg-fill" data-ov-icon="plus" aria-hidden="true"></i> ${startLabel}</a>${user ? '' : ' <span class="muted small">Sign in with your OpenVibe account to post.</span>'}</p>`
: `<p class="muted small">Staff add roadmap items. Open one to ask about it or argue for it, or suggest something new in <a href="/forum/feedback">Feedback</a>.</p>`;
: `<p class="muted small">Staff add roadmap items. Open one to ask about it or argue for it, or suggest something new in <a href="/forum/feedback">Feedback</a>.</p>`;
const categoryName = category ? (categories.find((c) => c.slug === category) || {}).name : null;
const categoryName = category ? (categories.find((c) => c.slug === category) || {}).name : null;
const body = `
const body = `
<header class="page-head">
<header class="page-head">
<nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/forum">Forum</a> › <span aria-current="page">${esc(space.name)}</span></nav>
<nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/forum">Forum</a> › <span aria-current="page">${esc(space.name)}</span></nav>
- <h1>${esc(space.name)}${visBadge(space)}${feedable ? ` <a class="rss-link" href="/forum/${esc(space.slug)}/feed.xml" title="RSS feed of ${esc(space.name)}"><i class="fa-solid fa-rss" aria-hidden="true"></i> RSS</a>` : ''}</h1>
+ <h1>${esc(space.name)}${visBadge(space)}${feedable ? ` <a class="rss-link" href="/forum/${esc(space.slug)}/feed.xml" title="RSS feed of ${esc(space.name)}"><i class="ovg-fill" data-ov-icon="rss" aria-hidden="true"></i> RSS</a>` : ''}</h1>
${space.description ? `<p class="muted">${esc(space.description)}</p>` : ''}
${space.description ? `<p class="muted">${esc(space.description)}</p>` : ''}
${start}
${start}
</header>
</header>
@@ -174,10 +174,10 @@ ${footer}
@@ -174,10 +174,10 @@ ${footer}
/** The pin / lock / delete / members-only / status / category buttons (no JS), each with rule and reason. */
/** The pin / lock / delete / members-only / status / category buttons (no JS), each with rule and reason. */
function modActions({ base, space, thread, viewer, categories = [] }) {
function modActions({ base, space, thread, viewer, categories = [] }) {
return viewer.can_moderate || viewer.can_delete ? `<div class="actions mod-actions">
return viewer.can_moderate || viewer.can_delete ? `<div class="actions mod-actions">
- ${viewer.can_moderate ? `<form class="mod-form" method="post" action="${esc(base)}/state"><input type="hidden" name="pinned" value="${thread.pinned ? 0 : 1}">${moderationFields('pin')}<button class="btn btn-sm" type="submit"><i class="fa-solid fa-thumbtack" aria-hidden="true"></i> ${thread.pinned ? 'Unpin' : 'Pin'}</button></form>
- <form class="mod-form" method="post" action="${esc(base)}/state"><input type="hidden" name="locked" value="${thread.locked ? 0 : 1}">${moderationFields('lock')}<button class="btn btn-sm" type="submit"><i class="fa-solid fa-lock" aria-hidden="true"></i> ${thread.locked ? 'Unlock' : 'Lock'}</button></form>` : ''}
- ${viewer.can_delete ? `<form class="mod-form" method="post" action="${esc(base)}/delete">${viewer.can_moderate ? moderationFields('delete') : ''}<button class="btn btn-sm btn-danger" type="submit"><i class="fa-solid fa-trash" aria-hidden="true"></i> Delete thread</button></form>` : ''}
- ${viewer.can_gate ? `<form method="post" action="${esc(base)}/members-only"><input type="hidden" name="on" value="${thread.members_only ? 0 : 1}"><button class="btn btn-sm" type="submit"><i class="fa-solid fa-star" aria-hidden="true"></i> ${thread.members_only ? 'Open to everyone' : 'VIP members only'}</button></form>` : ''}
+ ${viewer.can_moderate ? `<form class="mod-form" method="post" action="${esc(base)}/state"><input type="hidden" name="pinned" value="${thread.pinned ? 0 : 1}">${moderationFields('pin')}<button class="btn btn-sm" type="submit"><i class="ovg-fill" data-ov-icon="tack" aria-hidden="true"></i> ${thread.pinned ? 'Unpin' : 'Pin'}</button></form>
+ <form class="mod-form" method="post" action="${esc(base)}/state"><input type="hidden" name="locked" value="${thread.locked ? 0 : 1}">${moderationFields('lock')}<button class="btn btn-sm" type="submit"><i class="ovg-fill" data-ov-icon="lock" aria-hidden="true"></i> ${thread.locked ? 'Unlock' : 'Lock'}</button></form>` : ''}
+ ${viewer.can_delete ? `<form class="mod-form" method="post" action="${esc(base)}/delete">${viewer.can_moderate ? moderationFields('delete') : ''}<button class="btn btn-sm btn-danger" type="submit"><i class="ovg-fill" data-ov-icon="trash" aria-hidden="true"></i> Delete thread</button></form>` : ''}
+ ${viewer.can_gate ? `<form method="post" action="${esc(base)}/members-only"><input type="hidden" name="on" value="${thread.members_only ? 0 : 1}"><button class="btn btn-sm" type="submit"><i class="ovg-fill" data-ov-icon="star" aria-hidden="true"></i> ${thread.members_only ? 'Open to everyone' : 'VIP members only'}</button></form>` : ''}
${viewer.can_moderate && (space.statuses || []).length && thread.status ? `<form class="mod-form" method="post" action="${esc(base)}/status"><label class="sr-only" for="thread-status">Status</label><select id="thread-status" name="status">${space.statuses.map((st) => `<option value="${esc(st)}"${st === thread.status ? ' selected' : ''}>${esc(STATUS_LABELS[st] || st)}</option>`).join('')}</select>${moderationFields('status')}<button class="btn btn-sm" type="submit">Set status</button></form>` : ''}
${viewer.can_moderate && (space.statuses || []).length && thread.status ? `<form class="mod-form" method="post" action="${esc(base)}/status"><label class="sr-only" for="thread-status">Status</label><select id="thread-status" name="status">${space.statuses.map((st) => `<option value="${esc(st)}"${st === thread.status ? ' selected' : ''}>${esc(STATUS_LABELS[st] || st)}</option>`).join('')}</select>${moderationFields('status')}<button class="btn btn-sm" type="submit">Set status</button></form>` : ''}
${categories.length && (viewer.can_moderate || viewer.can_delete) ? `<form class="inline-form" method="post" action="${esc(base)}/category"><label class="sr-only" for="thread-category">Category</label><select id="thread-category" name="category"><option value="">No category</option>${categories.map((c) => `<option value="${esc(c.slug)}"${thread.category && thread.category.slug === c.slug ? ' selected' : ''}>${esc(c.name)}</option>`).join('')}</select><button class="btn btn-sm" type="submit">Set category</button></form>` : ''}
${categories.length && (viewer.can_moderate || viewer.can_delete) ? `<form class="inline-form" method="post" action="${esc(base)}/category"><label class="sr-only" for="thread-category">Category</label><select id="thread-category" name="category"><option value="">No category</option>${categories.map((c) => `<option value="${esc(c.slug)}"${thread.category && thread.category.slug === c.slug ? ' selected' : ''}>${esc(c.name)}</option>`).join('')}</select><button class="btn btn-sm" type="submit">Set category</button></form>` : ''}
</div>` : '';
</div>` : '';
@@ -201,7 +201,7 @@ function pasteCards(p) {
@@ -201,7 +201,7 @@ function pasteCards(p) {
if (!list.length) return '';
if (!list.length) return '';
const { highlight, languageLabel } = require('./highlight');
const { highlight, languageLabel } = require('./highlight');
return `<div class="paste-embeds">${list.map((x) => `<figure class="paste-embed">
return `<div class="paste-embeds">${list.map((x) => `<figure class="paste-embed">
- <figcaption><a href="${esc(x.url)}"><i class="fa-solid fa-${x.type === 'screenshot' ? 'image' : 'code'}" aria-hidden="true"></i> ${esc(x.title || 'Untitled')}</a>
+ <figcaption><a href="${esc(x.url)}"><i class="ovg-fill" data-ov-icon="${x.type === 'screenshot' ? 'image' : 'code'}" aria-hidden="true"></i> ${esc(x.title || 'Untitled')}</a>
<span class="muted small">${x.type === 'screenshot' ? 'screenshot' : `${esc(languageLabel ? languageLabel(x.language) : x.language)} · ${num(x.lines)} ${x.lines === 1 ? 'line' : 'lines'}`}${x.visibility === 'unlisted' ? ' · unlisted' : ''}</span></figcaption>
<span class="muted small">${x.type === 'screenshot' ? 'screenshot' : `${esc(languageLabel ? languageLabel(x.language) : x.language)} · ${num(x.lines)} ${x.lines === 1 ? 'line' : 'lines'}`}${x.visibility === 'unlisted' ? ' · unlisted' : ''}</span></figcaption>
${x.type === 'screenshot' && x.screenshot_url ? `<a href="${esc(x.url)}"><img src="${esc(x.screenshot_url)}" alt="${esc(x.title || 'Screenshot')}" loading="lazy"></a>` : `<pre class="paste-embed-code"><code class="hljs">${highlight(x.excerpt || '', x.language).html}</code></pre>${x.lines > 12 ? `<a class="small" href="${esc(x.url)}">See all ${num(x.lines)} lines</a>` : ''}`}
${x.type === 'screenshot' && x.screenshot_url ? `<a href="${esc(x.url)}"><img src="${esc(x.screenshot_url)}" alt="${esc(x.title || 'Screenshot')}" loading="lazy"></a>` : `<pre class="paste-embed-code"><code class="hljs">${highlight(x.excerpt || '', x.language).html}</code></pre>${x.lines > 12 ? `<a class="small" href="${esc(x.url)}">See all ${num(x.lines)} lines</a>` : ''}`}
</figure>`).join('')}</div>`;
</figure>`).join('')}</div>`;
@@ -212,9 +212,9 @@ function pasteCards(p) {
@@ -212,9 +212,9 @@ function pasteCards(p) {
*/
*/
function crosspostBlock(c, base) {
function crosspostBlock(c, base) {
if (!c) return '';
if (!c) return '';
- const from = c.from ? `<p class="crosspost-note"><i class="fa-solid fa-right-left" aria-hidden="true"></i> Crossposted from <strong>${esc(c.from.space)}</strong>: <a href="${esc(c.from.url)}">${esc(c.from.title)}</a></p>` : '';
+ const from = c.from ? `<p class="crosspost-note"><i class="ovg-fill" data-ov-icon="swap" aria-hidden="true"></i> Crossposted from <strong>${esc(c.from.space)}</strong>: <a href="${esc(c.from.url)}">${esc(c.from.title)}</a></p>` : '';
const to = (c.to || []).length ? `<p class="crosspost-note small muted">Also in ${c.to.map((x) => `<a href="${esc(x.url)}">${esc(x.space)}</a>`).join(', ')}</p>` : '';
const to = (c.to || []).length ? `<p class="crosspost-note small muted">Also in ${c.to.map((x) => `<a href="${esc(x.url)}">${esc(x.space)}</a>`).join(', ')}</p>` : '';
- const form = (c.targets || []).length ? `<details class="crosspost-form"><summary><i class="fa-solid fa-right-left" aria-hidden="true"></i> Crosspost</summary>
+ const form = (c.targets || []).length ? `<details class="crosspost-form"><summary><i class="ovg-fill" data-ov-icon="swap" aria-hidden="true"></i> Crosspost</summary>
<form class="inline-form" method="post" action="${esc(base)}/crosspost"><label class="sr-only" for="crosspost-to">Crosspost to</label><select id="crosspost-to" name="to">${c.targets.map((t) => `<option value="${esc(t.slug)}">${esc(t.name)}</option>`).join('')}</select><button class="btn btn-sm" type="submit">Crosspost</button></form></details>` : '';
<form class="inline-form" method="post" action="${esc(base)}/crosspost"><label class="sr-only" for="crosspost-to">Crosspost to</label><select id="crosspost-to" name="to">${c.targets.map((t) => `<option value="${esc(t.slug)}">${esc(t.name)}</option>`).join('')}</select><button class="btn btn-sm" type="submit">Crosspost</button></form></details>` : '';
return from || to || form ? `<div class="crosspost">${from}${to}${form}</div>` : '';
return from || to || form ? `<div class="crosspost">${from}${to}${form}</div>` : '';
}
}
@@ -236,7 +236,7 @@ function reactionBar(p, base, page, catalogue = []) {
@@ -236,7 +236,7 @@ function reactionBar(p, base, page, catalogue = []) {
const chip = (r) => (p.can_react
const chip = (r) => (p.can_react
? `<button class="rx rx-${esc(r.group || 'positive')}${r.mine ? ' mine' : ''}" type="submit" name="reaction" value="${esc(r.key)}" title="${esc(title(r))}" aria-pressed="${r.mine ? 'true' : 'false'}" aria-label="${esc(`${r.label}, ${r.count}`)}"><span aria-hidden="true">${r.emoji}</span> ${num(r.count)}</button>`
? `<button class="rx rx-${esc(r.group || 'positive')}${r.mine ? ' mine' : ''}" type="submit" name="reaction" value="${esc(r.key)}" title="${esc(title(r))}" aria-pressed="${r.mine ? 'true' : 'false'}" aria-label="${esc(`${r.label}, ${r.count}`)}"><span aria-hidden="true">${r.emoji}</span> ${num(r.count)}</button>`
: `<span class="rx rx-${esc(r.group || 'positive')}" title="${esc(title(r))}"><span aria-hidden="true">${r.emoji}</span> ${num(r.count)}<span class="sr-only"> ${esc(r.label)}</span></span>`);
: `<span class="rx rx-${esc(r.group || 'positive')}" title="${esc(title(r))}"><span aria-hidden="true">${r.emoji}</span> ${num(r.count)}<span class="sr-only"> ${esc(r.label)}</span></span>`);
- const picker = p.can_react && catalogue.length ? `<details class="rx-pick"><summary><i class="fa-regular fa-face-smile" aria-hidden="true"></i> Rate</summary><div class="rx-menu">${['positive', 'negative', 'utility'].map((g) => {
+ const picker = p.can_react && catalogue.length ? `<details class="rx-pick"><summary><i data-ov-icon="smile" aria-hidden="true"></i> Rate</summary><div class="rx-menu">${['positive', 'negative', 'utility'].map((g) => {
const list = catalogue.filter((r) => r.group === g);
const list = catalogue.filter((r) => r.group === g);
return list.length ? `<div class="rx-row">${list.map((r) => `<button class="rx-btn rx-${g}" type="submit" name="reaction" value="${esc(r.key)}" title="${esc(r.label)}"><span aria-hidden="true">${r.emoji}</span> ${esc(r.label)}</button>`).join('')}</div>` : '';
return list.length ? `<div class="rx-row">${list.map((r) => `<button class="rx-btn rx-${g}" type="submit" name="reaction" value="${esc(r.key)}" title="${esc(r.label)}"><span aria-hidden="true">${r.emoji}</span> ${esc(r.label)}</button>`).join('')}</div>` : '';
}).join('')}</div></details>` : '';
}).join('')}</div></details>` : '';
@@ -257,9 +257,9 @@ function voteForm(base, thread, viewer) {
@@ -257,9 +257,9 @@ function voteForm(base, thread, viewer) {
if (!viewer.can_vote) return `<div class="vote"><span class="vote-score" title="Score">${num(thread.score)}</span></div>`;
if (!viewer.can_vote) return `<div class="vote"><span class="vote-score" title="Score">${num(thread.score)}</span></div>`;
const btn = (value, icon, label) => {
const btn = (value, icon, label) => {
const active = thread.my_vote === value;
const active = thread.my_vote === value;
- return `<button class="vote-btn${active ? ' active' : ''}" type="submit" name="value" value="${active ? 0 : value}" aria-pressed="${active}" aria-label="${label}"><i class="fa-solid ${icon}" aria-hidden="true"></i></button>`;
+ return `<button class="vote-btn${active ? ' active' : ''}" type="submit" name="value" value="${active ? 0 : value}" aria-pressed="${active}" aria-label="${label}"><i class="ovg-fill" data-ov-icon="${icon}" aria-hidden="true"></i></button>`;
};
};
- return `<form class="vote" method="post" action="${esc(base)}/vote">${btn(1, 'fa-arrow-up', 'Upvote')}<span class="vote-score" title="Score">${num(thread.score)}</span>${btn(-1, 'fa-arrow-down', 'Downvote')}</form>`;
+ return `<form class="vote" method="post" action="${esc(base)}/vote">${btn(1, 'arrow-up', 'Upvote')}<span class="vote-score" title="Score">${num(thread.score)}</span>${btn(-1, 'arrow-down', 'Downvote')}</form>`;
}
}
/**
/**
@@ -270,7 +270,7 @@ function labelsBox(base, thread, viewer) {
@@ -270,7 +270,7 @@ function labelsBox(base, thread, viewer) {
if (!viewer || !viewer.can_label) return '';
if (!viewer || !viewer.can_label) return '';
const now = labelsUi.inForce(thread);
const now = labelsUi.inForce(thread);
const line = now.length ? now.map((l) => labelsUi.NAMES[l]).join(', ') : 'None set';
const line = now.length ? now.map((l) => labelsUi.NAMES[l]).join(', ') : 'None set';
- return `<details class="labels-box" id="labels"><summary><span class="labels-box-title"><i class="fa-solid fa-tag" aria-hidden="true"></i> Labels</span> <span class="labels-box-line">${esc(line)}</span></summary>
+ return `<details class="labels-box" id="labels"><summary><span class="labels-box-title"><i class="ovg-fill" data-ov-icon="tag" aria-hidden="true"></i> Labels</span> <span class="labels-box-line">${esc(line)}</span></summary>
<form method="post" action="${esc(base)}/labels" class="labels-form">
<form method="post" action="${esc(base)}/labels" class="labels-form">
${labelsUi.picker({ current: now, suggested: labelsUi.suggestedOf(thread), id: 'thread-labels' })}
${labelsUi.picker({ current: now, suggested: labelsUi.suggestedOf(thread), id: 'thread-labels' })}
<div class="form-actions"><button class="btn btn-primary" type="submit">Save labels</button></div>
<div class="form-actions"><button class="btn btn-primary" type="submit">Save labels</button></div>
@@ -290,7 +290,7 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u
@@ -290,7 +290,7 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u
<label class="field"><span>Reply (Markdown)</span><textarea name="body" rows="7" maxlength="40000" required placeholder="Say it, own it.">${esc(draft)}</textarea></label>
<label class="field"><span>Reply (Markdown)</span><textarea name="body" rows="7" maxlength="40000" required placeholder="Say it, own it.">${esc(draft)}</textarea></label>
${imageField(attachmentsEnabled, space)}
${imageField(attachmentsEnabled, space)}
${pasteField()}
${pasteField()}
- <div class="form-actions"><button class="btn btn-primary" type="submit"><i class="fa-solid fa-reply" aria-hidden="true"></i> Reply</button><span class="muted small">**bold**, *italic*, \`code\`, \`\`\` blocks, [links](https://…), > quotes and lists.</span></div>
+ <div class="form-actions"><button class="btn btn-primary" type="submit"><i class="ovg-fill" data-ov-icon="reply" aria-hidden="true"></i> Reply</button><span class="muted small">**bold**, *italic*, \`code\`, \`\`\` blocks, [links](https://…), > quotes and lists.</span></div>
</form>`
</form>`
: thread.locked ? '<p class="alert">This thread is locked.</p>'
: thread.locked ? '<p class="alert">This thread is locked.</p>'
: !user ? `<p class="alert"><a href="/auth/login?next=${encodeURIComponent(base)}">Sign in with your OpenVibe account</a> to reply.</p>` : '';
: !user ? `<p class="alert"><a href="/auth/login?next=${encodeURIComponent(base)}">Sign in with your OpenVibe account</a> to reply.</p>` : '';
@@ -304,11 +304,11 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u
@@ -304,11 +304,11 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u
<header class="thread-head">
<header class="thread-head">
${space.votes === false ? '' : voteForm(base, thread, viewer)}
${space.votes === false ? '' : voteForm(base, thread, viewer)}
<div>
<div>
- <h1>${thread.pinned ? '<i class="fa-solid fa-thumbtack" title="Pinned" aria-hidden="true"></i><span class="sr-only">Pinned</span> ' : ''}${thread.locked ? '<i class="fa-solid fa-lock" title="Locked" aria-hidden="true"></i><span class="sr-only">Locked</span> ' : ''}${esc(thread.title)}${vipBadge(thread)}</h1>
+ <h1>${thread.pinned ? '<i class="ovg-fill" data-ov-icon="tack" title="Pinned" aria-hidden="true"></i><span class="sr-only">Pinned</span> ' : ''}${thread.locked ? '<i class="ovg-fill" data-ov-icon="lock" title="Locked" aria-hidden="true"></i><span class="sr-only">Locked</span> ' : ''}${esc(thread.title)}${vipBadge(thread)}</h1>
${thread.status || thread.category ? `<p class="thread-tags">${statusBadge(thread)}${categoryBadge(thread, space)}</p>` : ''}
${thread.status || thread.category ? `<p class="thread-tags">${statusBadge(thread)}${categoryBadge(thread, space)}</p>` : ''}
${labelsUi.chips(labels)}
${labelsUi.chips(labels)}
- <p class="paste-meta">${who(thread.author)} <span class="sep">·</span> <time datetime="${esc(thread.created_at || '')}">${esc(fmtDate(thread.created_at))}</time> <span class="sep">·</span> <span class="stat"><i class="fa-solid fa-comment" aria-hidden="true"></i> ${num(thread.reply_count)} ${thread.reply_count === 1 ? 'reply' : 'replies'}</span></p>
- <p class="thread-follow"><a class="rss-link" href="${esc(base)}/feed.xml" title="RSS feed of this thread"><i class="fa-solid fa-rss" aria-hidden="true"></i> Follow this thread by RSS</a> ${kit.askAgent({ text: `About the forum thread “${thread.title}” (${config.baseUrl}${base}): ` })}</p>
+ <p class="paste-meta">${who(thread.author)} <span class="sep">·</span> <time datetime="${esc(thread.created_at || '')}">${esc(fmtDate(thread.created_at))}</time> <span class="sep">·</span> <span class="stat"><i class="ovg-fill" data-ov-icon="chat" aria-hidden="true"></i> ${num(thread.reply_count)} ${thread.reply_count === 1 ? 'reply' : 'replies'}</span></p>
+ <p class="thread-follow"><a class="rss-link" href="${esc(base)}/feed.xml" title="RSS feed of this thread"><i class="ovg-fill" data-ov-icon="rss" aria-hidden="true"></i> Follow this thread by RSS</a> ${kit.askAgent({ text: `About the forum thread “${thread.title}” (${config.baseUrl}${base}): ` })}</p>
</div>
</div>
</header>
</header>
${crosspostBlock(crosspost, base)}
${crosspostBlock(crosspost, base)}
@@ -360,7 +360,7 @@ function newThreadPage({ space, user, values = {}, error = null, categories = []
@@ -360,7 +360,7 @@ function newThreadPage({ space, user, values = {}, error = null, categories = []
${labelsUi.picker({ current: values.labels || [], id: 'new-labels' })}
${labelsUi.picker({ current: values.labels || [], id: 'new-labels' })}
<label class="check"><input type="checkbox" name="members_only" value="1"${values.members_only ? ' checked' : ''}> Only my OpenVibe.VIP members can read and reply</label>
<label class="check"><input type="checkbox" name="members_only" value="1"${values.members_only ? ' checked' : ''}> Only my OpenVibe.VIP members can read and reply</label>
<div class="form-actions">
<div class="form-actions">
- <button class="btn btn-primary" type="submit" data-ov-guide="community.thread"><i class="fa-solid fa-paper-plane" aria-hidden="true"></i> Post thread</button>
+ <button class="btn btn-primary" type="submit" data-ov-guide="community.thread"><i class="ovg-fill" data-ov-icon="plane" aria-hidden="true"></i> Post thread</button>
<span class="muted small">Posting as <strong>${esc(user.display_name || user.username || 'you')}</strong>. By posting you agree to the <a href="/terms">rules</a>.</span>
<span class="muted small">Posting as <strong>${esc(user.display_name || user.username || 'you')}</strong>. By posting you agree to the <a href="/terms">rules</a>.</span>
</div>
</div>
</form>` : `<p class="alert"><a href="/auth/login?next=${encodeURIComponent(`/forum/${space.slug}/new`)}">Sign in with your OpenVibe account</a> to start a thread. Reading needs no account.</p>`;
</form>` : `<p class="alert"><a href="/auth/login?next=${encodeURIComponent(`/forum/${space.slug}/new`)}">Sign in with your OpenVibe account</a> to start a thread. Reading needs no account.</p>`;
@@ -400,7 +400,7 @@ function membersOnlyPage({ space, thread = null, members_only: mo = null, reason
@@ -400,7 +400,7 @@ function membersOnlyPage({ space, thread = null, members_only: mo = null, reason
const body = `
const body = `
<header class="page-head">
<header class="page-head">
${crumbs}
${crumbs}
- <h1>${esc(thread ? thread.title : space.name)} <span class="badge badge-vis badge-vip"><i class="fa-solid fa-star" aria-hidden="true"></i> VIP members only</span></h1>
+ <h1>${esc(thread ? thread.title : space.name)} <span class="badge badge-vis badge-vip"><i class="ovg-fill" data-ov-icon="star" aria-hidden="true"></i> VIP members only</span></h1>
${!thread && space.description ? `<p class="muted">${esc(space.description)}</p>` : ''}
${!thread && space.description ? `<p class="muted">${esc(space.description)}</p>` : ''}
</header>
</header>
<section class="members-only">
<section class="members-only">
@@ -64,14 +64,14 @@ function navbarInit(opts) {
@@ -64,14 +64,14 @@ function navbarInit(opts) {
links: [
links: [
// The product's own places: the main ones carry an icon (phones get them as the bottom tab bar), the rest fold
// The product's own places: the main ones carry an icon (phones get them as the bottom tab bar), the rest fold
// into the navbar's More (Shared 4.22). Search is the navbar's magnifier; What shipped is the footer's.
// into the navbar's More (Shared 4.22). Search is the navbar's magnifier; What shipped is the footer's.
- { label: 'Forum', href: '/forum', icon: 'fa-comments', active: opts.active === 'spaces' },
- { label: 'Pastes', href: '/pastes', icon: 'fa-paste', active: opts.active === 'pastes' },
- { label: 'Pulse', href: '/pulse', icon: 'fa-bolt', active: opts.active === 'pulse' },
- { label: 'New paste', href: '/new', icon: 'fa-plus', active: opts.active === 'new' },
+ { label: 'Forum', href: '/forum', icon: 'chat', active: opts.active === 'spaces' },
+ { label: 'Pastes', href: '/pastes', icon: 'clipboard', active: opts.active === 'pastes' },
+ { label: 'Pulse', href: '/pulse', icon: 'zap', active: opts.active === 'pulse' },
+ { label: 'New paste', href: '/new', icon: 'plus', active: opts.active === 'new' },
],
],
appBar: true, // phones: the four places as the bottom tab bar (Shared 4.20)
appBar: true, // phones: the four places as the bottom tab bar (Shared 4.20)
search: { page: '/search', label: 'OpenVibe.Community' }, // the magnifier offers this site's own search first
search: { page: '/search', label: 'OpenVibe.Community' }, // the magnifier offers this site's own search first
- menu: { after: [{ label: 'My pastes', href: '/my', icon: 'fa-paste' }] },
+ menu: { after: [{ label: 'My pastes', href: '/my', icon: 'clipboard' }] },
history: { type: opts.historyType || 'page', title: opts.historyTitle || opts.title, image_url: opts.historyImage || null, private: !!opts.historyPrivate, mature: !!opts.historyMature },
history: { type: opts.historyType || 'page', title: opts.historyTitle || opts.title, image_url: opts.historyImage || null, private: !!opts.historyPrivate, mature: !!opts.historyMature },
silentLogin: `${config.baseUrl}/auth/login?silent=1&next={url}`,
silentLogin: `${config.baseUrl}/auth/login?silent=1&next={url}`,
sessionUrl: '/auth/me',
sessionUrl: '/auth/me',
@@ -98,7 +98,7 @@ function authorHtml(p, { link = true } = {}) {
@@ -98,7 +98,7 @@ function authorHtml(p, { link = true } = {}) {
return `<a class="author" href="${esc(config.liveUrl)}/@${encodeURIComponent(p.username)}" rel="author">${inner}</a>`;
return `<a class="author" href="${esc(config.liveUrl)}/@${encodeURIComponent(p.username)}" rel="author">${inner}</a>`;
}
}
function langBadge(p) {
function langBadge(p) {
- if (p.type === 'screenshot') return `<span class="badge badge-lang"><i class="fa-solid fa-image" aria-hidden="true"></i> image</span>`;
+ if (p.type === 'screenshot') return `<span class="badge badge-lang"><i class="ovg-fill" data-ov-icon="image" aria-hidden="true"></i> image</span>`;
const l = p.language || 'text';
const l = p.language || 'text';
return `<a class="badge badge-lang" href="/pastes?lang=${encodeURIComponent(l)}">${esc(languageLabel(l))}</a>`;
return `<a class="badge badge-lang" href="/pastes?lang=${encodeURIComponent(l)}">${esc(languageLabel(l))}</a>`;
}
}
@@ -119,11 +119,11 @@ function pasteCard(p, { eager = false } = {}) {
@@ -119,11 +119,11 @@ function pasteCard(p, { eager = false } = {}) {
const isShot = p.type === 'screenshot';
const isShot = p.type === 'screenshot';
const locked = !!p.locked;
const locked = !!p.locked;
const preview = locked
const preview = locked
- ? `<div class="card-locked"><i class="fa-solid fa-lock" aria-hidden="true"></i> Locked — OpenVibe can’t read what’s inside</div>`
+ ? `<div class="card-locked"><i class="ovg-fill" data-ov-icon="lock" aria-hidden="true"></i> Locked — OpenVibe can’t read what’s inside</div>`
: isShot
: isShot
? (p.screenshot_url ? `<div class="card-shot">${screenshotImg(p, { eager })}</div>` : '')
? (p.screenshot_url ? `<div class="card-shot">${screenshotImg(p, { eager })}</div>` : '')
: `<pre class="card-code" aria-hidden="true">${esc(String(p.content || '').slice(0, 220))}${String(p.content || '').length > 220 ? '…' : ''}</pre>`;
: `<pre class="card-code" aria-hidden="true">${esc(String(p.content || '').slice(0, 220))}${String(p.content || '').length > 220 ? '…' : ''}</pre>`;
- const flags = `${p.pinned ? '<i class="fa-solid fa-thumbtack" title="Pinned"></i> ' : ''}${p.burn_after_read ? '<i class="fa-solid fa-fire" title="Burns after reading"></i> ' : ''}${locked ? '<i class="fa-solid fa-lock" title="Locked"></i> ' : ''}${p.is_nsfw ? '<span class="badge badge-nsfw">NSFW</span> ' : ''}`;
+ const flags = `${p.pinned ? '<i class="ovg-fill" data-ov-icon="tack" title="Pinned"></i> ' : ''}${p.burn_after_read ? '<i class="ovg-fill" data-ov-icon="fire" title="Burns after reading"></i> ' : ''}${locked ? '<i class="ovg-fill" data-ov-icon="lock" title="Locked"></i> ' : ''}${p.is_nsfw ? '<span class="badge badge-nsfw">NSFW</span> ' : ''}`;
return `<article class="card${p.is_nsfw ? ' card-nsfw' : ''}${locked ? ' card-is-locked' : ''}">
return `<article class="card${p.is_nsfw ? ' card-nsfw' : ''}${locked ? ' card-is-locked' : ''}">
<a class="card-link" href="/p/${esc(pastePathSlug(p))}">
<a class="card-link" href="/p/${esc(pastePathSlug(p))}">
${preview}
${preview}
@@ -131,7 +131,7 @@ function pasteCard(p, { eager = false } = {}) {
@@ -131,7 +131,7 @@ function pasteCard(p, { eager = false } = {}) {
</a>
</a>
<div class="card-meta">
<div class="card-meta">
${authorHtml(p)}
${authorHtml(p)}
- <span class="card-right">${langBadge(p)}<span class="stat" title="Views"><i class="fa-solid fa-eye" aria-hidden="true"></i> ${num(p.views)}</span>${timeTag(p.created_at)}</span>
+ <span class="card-right">${langBadge(p)}<span class="stat" title="Views"><i class="ovg-fill" data-ov-icon="eye" aria-hidden="true"></i> ${num(p.views)}</span>${timeTag(p.created_at)}</span>
</div>
</div>
</article>`;
</article>`;
}
}
@@ -341,8 +341,8 @@ function homePage({ latest, fromStreams = [], languages, user, spaces = [], road
@@ -341,8 +341,8 @@ function homePage({ latest, fromStreams = [], languages, user, spaces = [], road
lede: 'Paste code, logs, configs or a screenshot and get a short link that works anywhere. No account needed to read or share.',
lede: 'Paste code, logs, configs or a screenshot and get a short link that works anywhere. No account needed to read or share.',
// Two, side by side on a phone: the paste itself, then the list (signed in: your own; See all opens everyone's).
// Two, side by side on a phone: the paste itself, then the list (signed in: your own; See all opens everyone's).
actions: [
actions: [
- { label: 'Start a paste', href: '/new', primary: true, icon: 'fa-plus' },
- user ? { label: 'My pastes', href: '/my', icon: 'fa-user' } : { label: 'Browse pastes', href: '/pastes', icon: 'fa-paste' },
+ { label: 'Start a paste', href: '/new', primary: true, icon: 'ov:paste' },
+ user ? { label: 'My pastes', href: '/my', icon: 'ov:account' } : { label: 'Browse pastes', href: '/pastes', icon: 'ov:paste' },
],
],
// The terminal beside the words on a computer; a phone skips it (community.css), and the API fold has it too.
// The terminal beside the words on a computer; a phone skips it (community.css), and the API fold has it too.
aside: { html: `<pre class="cm-hero-code" aria-label="Paste a file from a terminal"><code>${esc(TERMINAL)}</code></pre>` },
aside: { html: `<pre class="cm-hero-code" aria-label="Paste a file from a terminal"><code>${esc(TERMINAL)}</code></pre>` },
@@ -397,9 +397,9 @@ function browsePage(result, languages) {
@@ -397,9 +397,9 @@ function browsePage(result, languages) {
if (lang && !(languages || []).some((l) => l.language === lang)) langOptions.push(opt(lang, languageLabel(lang)));
if (lang && !(languages || []).some((l) => l.language === lang)) langOptions.push(opt(lang, languageLabel(lang)));
const pager = pages > 1 ? `<nav class="pager" aria-label="Pages">
const pager = pages > 1 ? `<nav class="pager" aria-label="Pages">
- ${page > 1 ? `<a rel="prev" href="${esc(pagerHref({ q, sort, lang, type, from }, page - 1))}"><i class="fa-solid fa-chevron-left" aria-hidden="true"></i> Newer</a>` : '<span></span>'}
+ ${page > 1 ? `<a rel="prev" href="${esc(pagerHref({ q, sort, lang, type, from }, page - 1))}"><i class="ovg-fill" data-ov-icon="back" aria-hidden="true"></i> Newer</a>` : '<span></span>'}
<span class="pager-info">Page ${page} of ${pages}</span>
<span class="pager-info">Page ${page} of ${pages}</span>
- ${page < pages ? `<a rel="next" href="${esc(pagerHref({ q, sort, lang, type, from }, page + 1))}">Older <i class="fa-solid fa-chevron-right" aria-hidden="true"></i></a>` : '<span></span>'}
+ ${page < pages ? `<a rel="next" href="${esc(pagerHref({ q, sort, lang, type, from }, page + 1))}">Older <i class="ovg-fill" data-ov-icon="chevron" aria-hidden="true"></i></a>` : '<span></span>'}
</nav>` : '';
</nav>` : '';
const body = `
const body = `
@@ -408,7 +408,7 @@ function browsePage(result, languages) {
@@ -408,7 +408,7 @@ function browsePage(result, languages) {
<p class="muted">${num(total)} ${total === 1 ? 'paste' : 'pastes'}${from === 'streams' ? ' from streams, written up by OpenVibe.AI' : from === 'people' ? ' by people' : ''}${windowed ? ` in the latest ${windowSize}` : ''}${q ? ` matching “${esc(q)}”` : ''}</p>
<p class="muted">${num(total)} ${total === 1 ? 'paste' : 'pastes'}${from === 'streams' ? ' from streams, written up by OpenVibe.AI' : from === 'people' ? ' by people' : ''}${windowed ? ` in the latest ${windowSize}` : ''}${q ? ` matching “${esc(q)}”` : ''}</p>
</header>
</header>
<form class="filters" method="get" action="/pastes" role="search" data-browse>
<form class="filters" method="get" action="/pastes" role="search" data-browse>
- <label class="search"><i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i><input type="search" name="q" value="${esc(q)}" placeholder="Search titles and content" aria-label="Search pastes"></label>
+ <label class="search"><i class="ovg-fill" data-ov-icon="search" aria-hidden="true"></i><input type="search" name="q" value="${esc(q)}" placeholder="Search titles and content" aria-label="Search pastes"></label>
<label>From <select name="from"><option value="people"${from === 'people' ? ' selected' : ''}>People</option><option value="streams"${from === 'streams' ? ' selected' : ''}>Streams (AI)</option><option value="all"${from === 'all' ? ' selected' : ''}>Everyone</option></select></label>
<label>From <select name="from"><option value="people"${from === 'people' ? ' selected' : ''}>People</option><option value="streams"${from === 'streams' ? ' selected' : ''}>Streams (AI)</option><option value="all"${from === 'all' ? ' selected' : ''}>Everyone</option></select></label>
<label>Show <select name="type"><option value="pastes"${type === 'pastes' ? ' selected' : ''}>Pastes</option><option value="images"${type === 'images' ? ' selected' : ''}>Images</option><option value="all"${type === 'all' ? ' selected' : ''}>All</option></select></label>
<label>Show <select name="type"><option value="pastes"${type === 'pastes' ? ' selected' : ''}>Pastes</option><option value="images"${type === 'images' ? ' selected' : ''}>Images</option><option value="all"${type === 'all' ? ' selected' : ''}>All</option></select></label>
<label>Sort <select name="sort"><option value="new"${sort === 'new' ? ' selected' : ''}>Newest</option><option value="views"${sort === 'views' ? ' selected' : ''}>Most viewed</option></select></label>
<label>Sort <select name="sort"><option value="new"${sort === 'new' ? ' selected' : ''}>Newest</option><option value="views"${sort === 'views' ? ' selected' : ''}>Most viewed</option></select></label>
@@ -507,7 +507,7 @@ function pastePage({ paste: p, related, user, comments = null, commentSort = 'to
@@ -507,7 +507,7 @@ function pastePage({ paste: p, related, user, comments = null, commentSort = 'to
// away. Copy, Raw and Download still give the source.
// away. Copy, Raw and Download still give the source.
bodyBlock = isDoc ? `<div class="md paste-doc">${renderMarkdown(withoutTitle(p.content, title), { headingShift: 1, document: true })}</div>
bodyBlock = isDoc ? `<div class="md paste-doc">${renderMarkdown(withoutTitle(p.content, title), { headingShift: 1, document: true })}</div>
<details class="paste-source">
<details class="paste-source">
- <summary><i class="fa-solid fa-code" aria-hidden="true"></i> Markdown source <span class="muted small">${num(hl.lines)} lines</span></summary>
+ <summary><i class="ovg-fill" data-ov-icon="code" aria-hidden="true"></i> Markdown source <span class="muted small">${num(hl.lines)} lines</span></summary>
${code}
${code}
</details>` : code;
</details>` : code;
}
}
@@ -515,16 +515,16 @@ function pastePage({ paste: p, related, user, comments = null, commentSort = 'to
@@ -515,16 +515,16 @@ function pastePage({ paste: p, related, user, comments = null, commentSort = 'to
// The primary action of a locked paste is the card's own (unlock your locker, the password, the link's key): the
// The primary action of a locked paste is the card's own (unlock your locker, the password, the link's key): the
// ordinary actions here are Share, and Delete for its owner. The envelope as a file is a quiet line under the card.
// ordinary actions here are Share, and Delete for its owner. The envelope as a file is a quiet line under the card.
const actions = locked ? `<div class="actions">
const actions = locked ? `<div class="actions">
- <button type="button" class="btn btn-sm" data-ov-share-sheet data-title="${esc(title)}" data-kind="paste" data-id="${esc(p.slug)}"><i class="fa-solid fa-share-nodes" aria-hidden="true"></i> Share</button>
- ${isOwner ? `<button type="button" class="btn btn-sm btn-danger" data-delete="${esc(p.slug)}"><i class="fa-solid fa-trash" aria-hidden="true"></i> Delete</button>` : ''}
+ <button type="button" class="btn btn-sm" data-ov-share-sheet data-title="${esc(title)}" data-kind="paste" data-id="${esc(p.slug)}"><i class="ovg-fill" data-ov-icon="nodes" aria-hidden="true"></i> Share</button>
+ ${isOwner ? `<button type="button" class="btn btn-sm btn-danger" data-delete="${esc(p.slug)}"><i class="ovg-fill" data-ov-icon="trash" aria-hidden="true"></i> Delete</button>` : ''}
</div>` : `<div class="actions">
</div>` : `<div class="actions">
- ${!isShot ? `<button type="button" class="btn btn-sm btn-primary" data-copy-content="${esc(p.slug)}"><i class="fa-solid fa-copy" aria-hidden="true"></i> Copy</button>` : ''}
- ${!isShot ? `<a class="btn btn-sm" href="/p/${esc(pageSlug)}/raw"><i class="fa-solid fa-file-lines" aria-hidden="true"></i> Raw</a>` : `<a class="btn btn-sm" href="/p/${esc(pageSlug)}/screenshot"><i class="fa-solid fa-image" aria-hidden="true"></i> Full image</a>`}
- <a class="btn btn-sm" href="/p/${esc(pageSlug)}/download" download="${esc(pageSlug)}.${esc(isShot ? 'png' : ext)}"><i class="fa-solid fa-download" aria-hidden="true"></i> Download</a>
- ${!isShot ? `<a class="btn btn-sm" href="/new?fork=${esc(p.slug)}"><i class="fa-solid fa-code-fork" aria-hidden="true"></i> Fork</a>` : ''}
- <button type="button" class="btn btn-sm" data-ov-share-sheet data-title="${esc(title)}" data-kind="paste" data-id="${esc(p.slug)}"><i class="fa-solid fa-share-nodes" aria-hidden="true"></i> Share</button>
- ${!restricted && p.visibility === 'public' && !p.burn_after_read ? `<a class="btn btn-sm" href="${esc(comments && comments.thread.forum_url || '#comments')}"><i class="fa-solid fa-comments" aria-hidden="true"></i> Discuss in the forum</a>` : ''}
- ${isOwner ? `<button type="button" class="btn btn-sm btn-danger" data-delete="${esc(p.slug)}"><i class="fa-solid fa-trash" aria-hidden="true"></i> Delete</button>` : ''}
+ ${!isShot ? `<button type="button" class="btn btn-sm btn-primary" data-copy-content="${esc(p.slug)}"><i class="ovg-fill" data-ov-icon="copy" aria-hidden="true"></i> Copy</button>` : ''}
+ ${!isShot ? `<a class="btn btn-sm" href="/p/${esc(pageSlug)}/raw"><i class="ovg-fill" data-ov-icon="file" aria-hidden="true"></i> Raw</a>` : `<a class="btn btn-sm" href="/p/${esc(pageSlug)}/screenshot"><i class="ovg-fill" data-ov-icon="image" aria-hidden="true"></i> Full image</a>`}
+ <a class="btn btn-sm" href="/p/${esc(pageSlug)}/download" download="${esc(pageSlug)}.${esc(isShot ? 'png' : ext)}"><i class="ovg-fill" data-ov-icon="download" aria-hidden="true"></i> Download</a>
+ ${!isShot ? `<a class="btn btn-sm" href="/new?fork=${esc(p.slug)}"><i class="ovg-fill" data-ov-icon="branch" aria-hidden="true"></i> Fork</a>` : ''}
+ <button type="button" class="btn btn-sm" data-ov-share-sheet data-title="${esc(title)}" data-kind="paste" data-id="${esc(p.slug)}"><i class="ovg-fill" data-ov-icon="nodes" aria-hidden="true"></i> Share</button>
+ ${!restricted && p.visibility === 'public' && !p.burn_after_read ? `<a class="btn btn-sm" href="${esc(comments && comments.thread.forum_url || '#comments')}"><i class="ovg-fill" data-ov-icon="chat" aria-hidden="true"></i> Discuss in the forum</a>` : ''}
+ ${isOwner ? `<button type="button" class="btn btn-sm btn-danger" data-delete="${esc(p.slug)}"><i class="ovg-fill" data-ov-icon="trash" aria-hidden="true"></i> Delete</button>` : ''}
</div>`;
</div>`;
const offer = share && share.transfer ? `<div id="paste-offer" data-transfer="${esc(share.transfer.id)}" data-username="${esc(p.username || '')}" data-display-name="${esc(p.display_name || '')}" data-expires="${esc(share.transfer.expires_at || '')}" data-keep-access="${share.transfer.keep_access ? '1' : '0'}"></div>` : '';
const offer = share && share.transfer ? `<div id="paste-offer" data-transfer="${esc(share.transfer.id)}" data-username="${esc(p.username || '')}" data-display-name="${esc(p.display_name || '')}" data-expires="${esc(share.transfer.expires_at || '')}" data-keep-access="${share.transfer.keep_access ? '1' : '0'}"></div>` : '';
@@ -538,17 +538,17 @@ ${offer}
@@ -538,17 +538,17 @@ ${offer}
${authorHtml(p)}
${authorHtml(p)}
${locked ? '' : `<span class="sep">·</span>${langBadge(p)}`}
${locked ? '' : `<span class="sep">·</span>${langBadge(p)}`}
<span class="sep">·</span><time datetime="${esc(ld.isoDate(p.created_at) || '')}" itemprop="datePublished">${esc(fmtDate(p.created_at))}</time>
<span class="sep">·</span><time datetime="${esc(ld.isoDate(p.created_at) || '')}" itemprop="datePublished">${esc(fmtDate(p.created_at))}</time>
- <span class="sep">·</span><span class="stat" title="Views"><i class="fa-solid fa-eye" aria-hidden="true"></i> ${num(p.views)} views</span>
+ <span class="sep">·</span><span class="stat" title="Views"><i class="ovg-fill" data-ov-icon="eye" aria-hidden="true"></i> ${num(p.views)} views</span>
${!isShot && !locked && hl ? `<span class="sep">·</span><span class="stat">${num(hl.lines)} lines</span>` : ''}
${!isShot && !locked && hl ? `<span class="sep">·</span><span class="stat">${num(hl.lines)} lines</span>` : ''}
- ${locked ? `<span class="sep">·</span><span class="badge badge-lock"><i class="fa-solid fa-lock" aria-hidden="true"></i> locked</span>` : ''}
- ${share && share.audience && share.audience !== 'public' ? `<span class="sep">·</span><span class="badge badge-vis"><i class="fa-solid fa-${share.audience === 'private' ? 'lock' : share.audience === 'people' ? 'users' : 'eye-slash'}" aria-hidden="true"></i> ${esc(share.audience === 'link' ? 'anyone with the link' : share.audience === 'people' ? 'specific people' : 'only me')}</span>` : ''}
- ${p.burn_after_read ? `<span class="sep">·</span><span class="badge badge-burn"><i class="fa-solid fa-fire" aria-hidden="true"></i> burns after reading</span>` : ''}
+ ${locked ? `<span class="sep">·</span><span class="badge badge-lock"><i class="ovg-fill" data-ov-icon="lock" aria-hidden="true"></i> locked</span>` : ''}
+ ${share && share.audience && share.audience !== 'public' ? `<span class="sep">·</span><span class="badge badge-vis"><i class="ovg-fill" data-ov-icon="${share.audience === 'private' ? 'lock' : share.audience === 'people' ? 'members' : 'eye-off'}" aria-hidden="true"></i> ${esc(share.audience === 'link' ? 'anyone with the link' : share.audience === 'people' ? 'specific people' : 'only me')}</span>` : ''}
+ ${p.burn_after_read ? `<span class="sep">·</span><span class="badge badge-burn"><i class="ovg-fill" data-ov-icon="fire" aria-hidden="true"></i> burns after reading</span>` : ''}
</p>
</p>
- ${p.ai_summary ? `<p class="summary" itemprop="description"><i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i> ${esc(p.ai_summary)}</p>` : ''}
+ ${p.ai_summary ? `<p class="summary" itemprop="description"><i class="ovg-fill" data-ov-icon="wand" aria-hidden="true"></i> ${esc(p.ai_summary)}</p>` : ''}
${actions}
${actions}
</header>
</header>
${bodyBlock}
${bodyBlock}
- ${locked ? `<p class="locked-file muted small"><a href="/p/${esc(pageSlug)}/download" download="${esc(pageSlug)}.ovlk"><i class="fa-solid fa-lock" aria-hidden="true"></i> Download the locked file (.ovlk)</a> — it opens only with its key, in the Locker.</p>` : ''}
+ ${locked ? `<p class="locked-file muted small"><a href="/p/${esc(pageSlug)}/download" download="${esc(pageSlug)}.ovlk"><i class="ovg-fill" data-ov-icon="lock" aria-hidden="true"></i> Download the locked file (.ovlk)</a> — it opens only with its key, in the Locker.</p>` : ''}
<footer class="paste-foot">
<footer class="paste-foot">
<p class="muted small">Stored by <a href="${esc(config.mediaUrl)}" rel="noopener">OpenVibe.Media</a>, account by <a href="${NETWORK_URL}" rel="noopener">OpenVibe.Network</a>. Something wrong with this paste? <a href="${esc(config.liveUrl)}/dmca">Report it</a>.</p>
<p class="muted small">Stored by <a href="${esc(config.mediaUrl)}" rel="noopener">OpenVibe.Media</a>, account by <a href="${NETWORK_URL}" rel="noopener">OpenVibe.Network</a>. Something wrong with this paste? <a href="${esc(config.liveUrl)}/dmca">Report it</a>.</p>
</footer>
</footer>
@@ -558,7 +558,7 @@ ${p.burn_after_read ? '' : `<section class="section">
@@ -558,7 +558,7 @@ ${p.burn_after_read ? '' : `<section class="section">
${require('./comments').commentPanel({ page: comments, user, base: `/p/${pageSlug}`, action: `/p/${pageSlug}/comments`, sort: commentSort, replyId, expandedId, expandedReplies })}
${require('./comments').commentPanel({ page: comments, user, base: `/p/${pageSlug}`, action: `/p/${pageSlug}/comments`, sort: commentSort, replyId, expandedId, expandedReplies })}
</section>`}
</section>`}
${!locked && related && related.length ? `<section class="section" id="related">
${!locked && related && related.length ? `<section class="section" id="related">
- <div class="section-head"><h2>More like this</h2><a class="more" href="${!isShot && p.language && p.language !== 'text' ? `/pastes?lang=${encodeURIComponent(p.language)}` : '/pastes'}">Browse <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></a></div>
+ <div class="section-head"><h2>More like this</h2><a class="more" href="${!isShot && p.language && p.language !== 'text' ? `/pastes?lang=${encodeURIComponent(p.language)}` : '/pastes'}">Browse <i class="ovg-fill" data-ov-icon="arrow-right" aria-hidden="true"></i></a></div>
${cardGrid(related, { title: 'No related pastes yet', text: 'Related pastes appear here when this one is linked with others.' })}
${cardGrid(related, { title: 'No related pastes yet', text: 'Related pastes appear here when this one is linked with others.' })}
</section>` : ''}
</section>` : ''}
<script src="${ovServe.url('ov-share.js')}" defer></script>${locked ? `
<script src="${ovServe.url('ov-share.js')}" defer></script>${locked ? `
@@ -54,7 +54,7 @@ function pulsePage({ items, origin = '', after = null, nextCursor = null, origin
@@ -54,7 +54,7 @@ function pulsePage({ items, origin = '', after = null, nextCursor = null, origin
<section data-results${after ? '' : ' data-pulse-live'}>
<section data-results${after ? '' : ' data-pulse-live'}>
<p class="pulse-new" hidden><button type="button" class="btn btn-sm" data-pulse-show></button></p>
<p class="pulse-new" hidden><button type="button" class="btn btn-sm" data-pulse-show></button></p>
${items.length ? `<ol class="thread-list pulse-list">${items.map(itemHtml).join('')}</ol>` : kit.empty({ icon: 'live', title: 'Nothing here yet', text: origin ? 'Nothing from this part of the network has arrived yet.' : 'New public clips, threads, pastes and posts land here as they happen.', action: origin ? { href: '/pulse', label: 'Everything', icon: 'live' } : { href: '/forum', label: 'Visit the forum', icon: 'chat' } })}
${items.length ? `<ol class="thread-list pulse-list">${items.map(itemHtml).join('')}</ol>` : kit.empty({ icon: 'live', title: 'Nothing here yet', text: origin ? 'Nothing from this part of the network has arrived yet.' : 'New public clips, threads, pastes and posts land here as they happen.', action: origin ? { href: '/pulse', label: 'Everything', icon: 'live' } : { href: '/forum', label: 'Visit the forum', icon: 'chat' } })}
- ${after || nextCursor ? `<nav class="pager" aria-label="Pages">${after ? `<a href="${esc(href(origin))}"><i class="fa-solid fa-chevron-left" aria-hidden="true"></i> Latest</a>` : '<span></span>'}<span></span>${nextCursor ? `<a rel="next" href="${esc(href(origin, nextCursor))}">Older <i class="fa-solid fa-chevron-right" aria-hidden="true"></i></a>` : '<span></span>'}</nav>` : ''}
+ ${after || nextCursor ? `<nav class="pager" aria-label="Pages">${after ? `<a href="${esc(href(origin))}"><i class="ovg-fill" data-ov-icon="back" aria-hidden="true"></i> Latest</a>` : '<span></span>'}<span></span>${nextCursor ? `<a rel="next" href="${esc(href(origin, nextCursor))}">Older <i class="ovg-fill" data-ov-icon="chevron" aria-hidden="true"></i></a>` : '<span></span>'}</nav>` : ''}
</section>`;
</section>`;
return renderPage({
return renderPage({
title: `Pulse${origin ? ` — ${FILTERS.find(([id]) => id === origin)[1]}` : ''}`,
title: `Pulse${origin ? ` — ${FILTERS.find(([id]) => id === origin)[1]}` : ''}`,
@@ -15,8 +15,8 @@ const pathOf = (url) => { try { const u = new URL(url); return u.pathname + u.se
@@ -15,8 +15,8 @@ const pathOf = (url) => { try { const u = new URL(url); return u.pathname + u.se
function resultItem(r) {
function resultItem(r) {
const f = r.facets || {};
const f = r.facets || {};
const kind = r.type === 'thread'
const kind = r.type === 'thread'
- ? `<span class="badge"><i class="fa-solid fa-comments" aria-hidden="true"></i> Thread${f.space ? ` in ${esc(f.space)}` : ''}</span>`
- : `<span class="badge"><i class="fa-solid fa-${f.kind === 'screenshot' ? 'image' : 'code'}" aria-hidden="true"></i> ${f.kind === 'screenshot' ? 'Screenshot' : 'Paste'}${f.syntax && f.syntax !== 'text' ? ` · ${esc(f.syntax)}` : ''}</span>`;
+ ? `<span class="badge"><i class="ovg-fill" data-ov-icon="chat" aria-hidden="true"></i> Thread${f.space ? ` in ${esc(f.space)}` : ''}</span>`
+ : `<span class="badge"><i class="ovg-fill" data-ov-icon="${f.kind === 'screenshot' ? 'image' : 'code'}" aria-hidden="true"></i> ${f.kind === 'screenshot' ? 'Screenshot' : 'Paste'}${f.syntax && f.syntax !== 'text' ? ` · ${esc(f.syntax)}` : ''}</span>`;
const when = r.updated_at || r.published_at;
const when = r.updated_at || r.published_at;
return `<li class="search-hit">
return `<li class="search-hit">
<a class="search-title" href="${esc(pathOf(r.canonical_url))}">${esc(r.title || 'Untitled')}</a>
<a class="search-title" href="${esc(pathOf(r.canonical_url))}">${esc(r.title || 'Untitled')}</a>
@@ -39,7 +39,7 @@ function searchPage({ q = '', type = '', results = [], nextCursor = null, unavai
@@ -39,7 +39,7 @@ function searchPage({ q = '', type = '', results = [], nextCursor = null, unavai
<p class="muted">Threads and pastes${q ? ` matching “${esc(q)}”` : ''}. <a href="https://search.openvibe.network/?q=${encodeURIComponent(q)}">Search the whole OpenVibe network</a></p>
<p class="muted">Threads and pastes${q ? ` matching “${esc(q)}”` : ''}. <a href="https://search.openvibe.network/?q=${encodeURIComponent(q)}">Search the whole OpenVibe network</a></p>
</header>
</header>
<form class="filters" method="get" action="/search" role="search">
<form class="filters" method="get" action="/search" role="search">
- <label class="search"><i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i><input type="search" name="q" value="${esc(q)}" placeholder="Search threads and pastes" aria-label="Search threads and pastes" maxlength="200" autofocus></label>
+ <label class="search"><i class="ovg-fill" data-ov-icon="search" aria-hidden="true"></i><input type="search" name="q" value="${esc(q)}" placeholder="Search threads and pastes" aria-label="Search threads and pastes" maxlength="200" autofocus></label>
<label>Show <select name="type">${opt('', 'Everything')}${opt('thread', 'Threads')}${opt('paste', 'Pastes')}</select></label>
<label>Show <select name="type">${opt('', 'Everything')}${opt('thread', 'Threads')}${opt('paste', 'Pastes')}</select></label>
<button class="btn" type="submit">Search</button>
<button class="btn" type="submit">Search</button>
</form>
</form>
@@ -40,7 +40,7 @@ function itemHtml(s) {
@@ -40,7 +40,7 @@ function itemHtml(s) {
function listBlock(items, { empty, action = null, prev, next }) {
function listBlock(items, { empty, action = null, prev, next }) {
return `${items.length ? `<ol class="thread-list pulse-list">${items.map(itemHtml).join('')}</ol>` : kit.empty({ icon: 'mail', title: empty.title, text: empty.text, action })}
return `${items.length ? `<ol class="thread-list pulse-list">${items.map(itemHtml).join('')}</ol>` : kit.empty({ icon: 'mail', title: empty.title, text: empty.text, action })}
- ${prev || next ? `<nav class="pager" aria-label="Pages">${prev ? `<a href="${esc(prev)}"><i class="fa-solid fa-chevron-left" aria-hidden="true"></i> Newest</a>` : '<span></span>'}<span></span>${next ? `<a rel="next" href="${esc(next)}">Older <i class="fa-solid fa-chevron-right" aria-hidden="true"></i></a>` : '<span></span>'}</nav>` : ''}`;
+ ${prev || next ? `<nav class="pager" aria-label="Pages">${prev ? `<a href="${esc(prev)}"><i class="ovg-fill" data-ov-icon="back" aria-hidden="true"></i> Newest</a>` : '<span></span>'}<span></span>${next ? `<a rel="next" href="${esc(next)}">Older <i class="ovg-fill" data-ov-icon="chevron" aria-hidden="true"></i></a>` : '<span></span>'}</nav>` : ''}`;
}
}
function submitForm({ user, values = {}, error = null }) {
function submitForm({ user, values = {}, error = null }) {
@@ -54,7 +54,7 @@ function submitForm({ user, values = {}, error = null }) {
@@ -54,7 +54,7 @@ function submitForm({ user, values = {}, error = null }) {
<label class="field"><span>Link (a clip, a picture, anything with an address)</span><input type="url" name="url" maxlength="2000" value="${esc(values.url || '')}" placeholder="https://"></label>
<label class="field"><span>Link (a clip, a picture, anything with an address)</span><input type="url" name="url" maxlength="2000" value="${esc(values.url || '')}" placeholder="https://"></label>
<label class="field"><span>Description (Markdown)</span><textarea name="body" rows="8" maxlength="10000">${esc(values.body || '')}</textarea></label>
<label class="field"><span>Description (Markdown)</span><textarea name="body" rows="8" maxlength="10000">${esc(values.body || '')}</textarea></label>
<div class="form-actions">
<div class="form-actions">
- <button class="btn btn-primary" type="submit"><i class="fa-solid fa-paper-plane" aria-hidden="true"></i> Submit for review</button>
+ <button class="btn btn-primary" type="submit"><i class="ovg-fill" data-ov-icon="plane" aria-hidden="true"></i> Submit for review</button>
<span class="muted small">Sending as <strong>${esc(user.display_name || user.username || 'you')}</strong>. Moderators review it before it is public. By submitting you agree to the <a href="/terms">rules</a>.</span>
<span class="muted small">Sending as <strong>${esc(user.display_name || user.username || 'you')}</strong>. Moderators review it before it is public. By submitting you agree to the <a href="/terms">rules</a>.</span>
</div>
</div>
</form>`;
</form>`;
@@ -94,7 +94,7 @@ ${submitForm({ user, values, error })}`;
@@ -94,7 +94,7 @@ ${submitForm({ user, values, error })}`;
/** GET /submissions/:slug */
/** GET /submissions/:slug */
function submissionPage({ submission: s, user = null, error = null }) {
function submissionPage({ submission: s, user = null, error = null }) {
- const link = s.url ? `<p><a class="btn" href="${esc(s.url)}" rel="nofollow ugc noopener" target="_blank"><i class="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"></i> Open the link</a> <span class="muted small">${esc(s.url)}</span></p>` : '';
+ const link = s.url ? `<p><a class="btn" href="${esc(s.url)}" rel="nofollow ugc noopener" target="_blank"><i class="ovg-fill" data-ov-icon="open" aria-hidden="true"></i> Open the link</a> <span class="muted small">${esc(s.url)}</span></p>` : '';
const media = s.media_ref ? `<p class="muted small">OpenVibe.Media object <code>${esc(s.media_ref)}</code></p>` : '';
const media = s.media_ref ? `<p class="muted small">OpenVibe.Media object <code>${esc(s.media_ref)}</code></p>` : '';
const review = s.status !== 'pending' && (s.review_note || s.reviewer)
const review = s.status !== 'pending' && (s.review_note || s.reviewer)
? `<aside class="alert"><strong>${esc(STATUS_LABELS[s.status])}</strong>${s.reviewer ? ` by ${who(s.reviewer)}` : ''}${s.reviewed_at ? ` · ${timeTag(s.reviewed_at)}` : ''}${s.review_note ? `<p>${esc(s.review_note)}</p>` : ''}</aside>` : '';
? `<aside class="alert"><strong>${esc(STATUS_LABELS[s.status])}</strong>${s.reviewer ? ` by ${who(s.reviewer)}` : ''}${s.reviewed_at ? ` · ${timeTag(s.reviewed_at)}` : ''}${s.review_note ? `<p>${esc(s.review_note)}</p>` : ''}</aside>` : '';
@@ -11,7 +11,7 @@ const html = String(board.forumTopicPage({
@@ -11,7 +11,7 @@ const html = String(board.forumTopicPage({
posts: [], page: 1, pages: 1, viewer: {}, user: null,
posts: [], page: 1, pages: 1, viewer: {}, user: null,
}));
}));
const h1 = html.slice(html.indexOf('<h1>'), html.indexOf('</h1>') + 5);
const h1 = html.slice(html.indexOf('<h1>'), html.indexOf('</h1>') + 5);
-assert.ok(h1.includes('<i class="fa-solid fa-thumbtack" title="Pinned" aria-hidden="true"></i><span class="sr-only">Pinned</span>'), 'the pin icon is hidden; the word is in the heading');
-assert.ok(h1.includes('<i class="fa-solid fa-lock" title="Locked" aria-hidden="true"></i><span class="sr-only">Locked</span>'), 'the lock icon is hidden; the word is in the heading');
+assert.ok(h1.includes('<i class="ovg-fill" data-ov-icon="tack" title="Pinned" aria-hidden="true"></i><span class="sr-only">Pinned</span>'), 'the pin icon is hidden; the word is in the heading');
+assert.ok(h1.includes('<i class="ovg-fill" data-ov-icon="lock" title="Locked" aria-hidden="true"></i><span class="sr-only">Locked</span>'), 'the lock icon is hidden; the word is in the heading');
assert.ok(!h1.includes('aria-label='), 'no aria-label on the role-less icons');
assert.ok(!h1.includes('aria-label='), 'no aria-label on the role-less icons');
console.log('board pin: the icons are hidden, the words read');
console.log('board pin: the icons are hidden, the words read');
@@ -200,10 +200,10 @@ const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toString
@@ -200,10 +200,10 @@ const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toString
const r = await t.get(url, as(samJwt));
const r = await t.get(url, as(samJwt));
has(r.text, 'This thread is locked.');
has(r.text, 'This thread is locked.');
assert.ok(!r.text.includes(`action="${url}/reply"`));
assert.ok(!r.text.includes(`action="${url}/reply"`));
- has(r.text, '<h1><i class="fa-solid fa-thumbtack" title="Pinned" aria-hidden="true"></i><span class="sr-only">Pinned</span> <i class="fa-solid fa-lock" title="Locked" aria-hidden="true"></i><span class="sr-only">Locked</span> ');
+ has(r.text, '<h1><i class="ovg-fill" data-ov-icon="tack" title="Pinned" aria-hidden="true"></i><span class="sr-only">Pinned</span> <i class="ovg-fill" data-ov-icon="lock" title="Locked" aria-hidden="true"></i><span class="sr-only">Locked</span> ');
assert.ok(!r.text.includes('aria-label="Pinned"'), 'the pin does not carry an aria-label on an icon');
assert.ok(!r.text.includes('aria-label="Pinned"'), 'the pin does not carry an aria-label on an icon');
const list = await t.get('/forum/general');
const list = await t.get('/forum/general');
- has(list.text, '<i class="fa-solid fa-thumbtack" title="Pinned" aria-hidden="true"></i><span class="sr-only">Pinned</span>');
+ has(list.text, '<i class="ovg-fill" data-ov-icon="tack" title="Pinned" aria-hidden="true"></i><span class="sr-only">Pinned</span>');
assert.ok(!list.text.includes('aria-label="Pinned"'), 'the list pin does not carry an aria-label either');
assert.ok(!list.text.includes('aria-label="Pinned"'), 'the list pin does not carry an aria-label either');
const admin = await t.get(url, as(adminJwt));
const admin = await t.get(url, as(adminJwt));
has(admin.text, 'Unpin');
has(admin.text, 'Unpin');
@@ -62,18 +62,21 @@ for (const f of LAYOUTS) {
@@ -62,18 +62,21 @@ for (const f of LAYOUTS) {
assert.ok(/shell\.page|ov-glyphs\.js|\/shared\/navbar/.test(t), `${f} renders pages without the navbar or /shared/ov-glyphs.js`);
assert.ok(/shell\.page|ov-glyphs\.js|\/shared\/navbar/.test(t), `${f} renders pages without the navbar or /shared/ov-glyphs.js`);
}
}
-// Every fa- name in the pages, the scripts and the server views is drawn by a glyph or a brand.
-const MOD = /^(solid|regular|light|thin|duotone|brands|sharp|classic|fw|lg|xl|xs|sm|2xs|2xl|\d+x|li|ul|border|inverse|pull-left|pull-right|rotate-\d+|rotate-by|flip-horizontal|flip-vertical|flip-both|width-auto|spin|spin-pulse|spin-reverse|pulse|beat|beat-fade|bounce|fade|flip|shake|stack|stack-1x|stack-2x|layers|layers-text|layers-counter|swap-opacity|stack-text)$/;
+// No Font Awesome markup is left: every icon names an OpenVibe glyph or brand, and the name is one that is drawn.
const files = [];
const files = [];
for (const r of CODE_ROOTS) files.push(...walk(path.join(ROOT, r), /\.(html|js)$/));
for (const r of CODE_ROOTS) files.push(...walk(path.join(ROOT, r), /\.(html|js)$/));
const missing = new Map();
const missing = new Map();
for (const f of files) {
for (const f of files) {
- for (const m of fs.readFileSync(f, 'utf8').matchAll(/\bfa-([a-z0-9]+(?:-[a-z0-9]+)*)\b/g)) {
- const n = m[1];
- if (MOD.test(n) || glyphs.fromFa(n) || brands.fromFa(n)) continue;
- if (!missing.has(n)) missing.set(n, rel(f));
+ const t = fs.readFileSync(f, 'utf8');
+ const fa = t.match(/class=["'][^"']*\bfa-[a-z][^"']*["']/);
+ assert.ok(!fa, `${rel(f)} still uses Font Awesome markup: ${fa ? fa[0] : ''}`);
+ assert.ok(!/\bfa-(solid|regular|brands|light)\b/.test(t), `${rel(f)} still names a Font Awesome style class`);
+ for (const m of t.matchAll(/data-ov-(icon|brand)="([a-z0-9-]+)"/g)) {
+ const [kind, name] = [m[1], m[2]];
+ const drawn = kind === 'icon' ? glyphs.NAMES.includes(name) : Boolean(brands.BRANDS[name]);
+ if (!drawn && !missing.has(`${kind}:${name}`)) missing.set(`${kind}:${name}`, rel(f));
}
}
}
}
-assert.deepStrictEqual([...missing].map(([n, f]) => `fa-${n} (${f})`), [], 'every icon this site names is drawn by the OpenVibe glyphs or brands');
+assert.deepStrictEqual([...missing].map(([n, f]) => `${n} (${f})`), [], 'every icon this site names is drawn by the OpenVibe glyphs or brands');
-console.log(`no font awesome: ${pages.length} pages, ${files.length} files, every fa- name drawn`);
+console.log(`no font awesome: ${pages.length} pages, ${files.length} files, every icon named is drawn`);