prometheus/service/pages-browse.js
Local Dev bb07c73aaf prometheus: browse pages — categories and per-listing detail
/c/<slug>  renders one of the eight categories with every active listing
           in it, newest first, as a grid of cards. Empty category shows
           a "be the first" prompt rather than a dead page.

/app/<pub>/<slug>  renders a listing: icon + name + publisher + version
                   chip, a prominent Download card with size, hash chip,
                   signed scan badge (green/clean | red/infected |
                   neutral/unknown), description, homepage link, who
                   signed, and an endorsements placeholder for when APS1
                   lands.

The home-page and /browse grids now read real counts from the DB — the
hardcoded "0 listings" placeholders are gone. A new helper
`renderCategoriesGrid()` is the one place category cards are rendered,
so the two surfaces always agree.

404s for unknown categories (`/c/nope`) and unknown listings
(`/app/foo/bar`) go through the same notFound page and now actually
return HTTP 404 instead of 200 — pages opt in by returning the
notFound object, server detects it by title and sets the status.

CATEGORIES is the single source of truth for slug/icon/title/blurb;
adding a new category now means one row here plus one entry in the
listings allow-list.

Verified end-to-end with three listings across three categories: counts
update, cards render, detail page shows the signed scan badge, 404s
return 404.
2026-10-08 01:56:52 +02:00

303 lines
12 KiB
JavaScript

/**
* Browse pages — category index and per-listing detail.
*
* Everything here is rendered server-side from the SQLite listings table. No
* client JS, so Googlebot sees the real page, Theseus sees the real page, and
* the s3 snapshot (future milestone #12) is just the same HTML written to disk.
*
* These pages are also where the "0 listings" messaging on the home page and
* /browse flips to real counts — `countsByCategory()` is exported here and
* imported by pages.js so the two render surfaces agree on what is live.
*/
import {
listingsByCategory,
getListing,
countByCategory as countByCategoryDb,
} from './db.js';
import { escapeHtml } from './layout.js';
import { notFound } from './pages.js';
/* ------------------------------- categories ------------------------------- */
/**
* The authoritative metadata for the eight categories — icon, title, and the
* blurb that shows on the home grid and at the top of the category page. One
* table so a copy change is a one-liner rather than scattered across templates.
*
* Adding a new category means adding a row here AND adding the slug to the
* allow-list in listings.js. The browse grid picks up the new row
* automatically.
*/
export const CATEGORIES = [
{ slug: 'communication', icon: '💬', title: 'Communication',
blurb: `Messengers, voice, boards, group chats. Clients that route through peers rather than someone else's server.` },
{ slug: 'privacy', icon: '🛡️', title: 'Privacy & security',
blurb: `Tor and mix-net tooling, key and vault managers, disk crypto. The things a conventional store delists first when a government writes.` },
{ slug: 'wallets', icon: '💸', title: 'Wallets & payments',
blurb: `BCH wallets, cross-chain bridges, point-of-sale terminals. The chain publishes the listing and the chain settles the payment.` },
{ slug: 'games', icon: '🎮', title: 'Games',
blurb: `Standalone titles, on-chain leaderboards, in-app purchases that reach the publisher instead of a 30% toll.` },
{ slug: 'devtools', icon: '🧰', title: 'Developer tools',
blurb: `SDKs, libraries, indexers, faucets, protocol utilities. The pieces other apps on this list are built out of.` },
{ slug: 'media', icon: '🎧', title: 'Media & publishing',
blurb: `Readers, players, one-file blogs, podcast hosts. Clients that find their audience without going through an aggregator.` },
{ slug: 'utilities', icon: '⚙️', title: 'Utilities',
blurb: `File tools, format converters, backup, calculators. The small sharp things that solve one problem and do not ask for an account first.` },
{ slug: 'other', icon: '✨', title: 'Everything else',
blurb: `Anything that does not fit above. New categories are proposed in-band — any listing can declare one and clients will offer to add it.` },
];
const CATEGORY_BY_SLUG = Object.fromEntries(CATEGORIES.map((c) => [c.slug, c]));
/**
* {slug: count} for every category — categories with 0 listings are present
* with the value 0, so templates can read them unconditionally.
*/
export function countsByCategory() {
const raw = countByCategoryDb();
const out = {};
for (const c of CATEGORIES) out[c.slug] = raw[c.slug] || 0;
return out;
}
/**
* Shared category grid, used on the home page and on /browse.
*
* `verbose` controls whether each card carries its blurb. Home page wants it;
* the dedicated browse page already has the blurb at the top and does not
* need to repeat it on every card.
*/
export function renderCategoriesGrid({ verbose = true } = {}) {
const counts = countsByCategory();
return `<div class="categories">
${CATEGORIES.map((c) => {
const n = counts[c.slug];
const emptyCls = n === 0 ? ' empty' : '';
const countLabel = `${n} ${n === 1 ? 'listing' : 'listings'}`;
return `<a class="cat${emptyCls}" href="/c/${c.slug}">
<div class="ico">${c.icon}</div>
<h3>${escapeHtml(c.title)}</h3>
${verbose ? `<p>${escapeHtml(c.blurb)}</p>` : ''}
<span class="count">${countLabel}</span>
</a>`;
}).join('\n ')}
</div>`;
}
/* ------------------------------- category page ------------------------------- */
export function categoryPage(req, slug) {
const cat = CATEGORY_BY_SLUG[slug];
if (!cat) return notFound(req);
const rows = listingsByCategory(slug);
const title = cat.title;
const here = `/c/${slug}`;
return {
title,
description: `${cat.title} apps on Prometheus — ${cat.blurb}`,
here,
body: `
<header class="hero compact">
<div class="mark" style="font-size:40px">${cat.icon}</div>
<h1>${escapeHtml(title)}</h1>
<p class="tag">${escapeHtml(cat.blurb)}</p>
</header>
<div class="wrap">
<section class="plain">
<p class="lede">
${rows.length === 0
? `No listings in this category yet. <a href="/submit">Be the first</a>.`
: `${rows.length} ${rows.length === 1 ? 'listing' : 'listings'}, newest first.`}
</p>
${rows.length === 0 ? emptyCategory(cat) : renderListingList(rows)}
<p style="margin-top:1.8rem;color:var(--dim);font-size:13px">
<a href="/browse">← All categories</a>
</p>
</section>
</div>`,
};
}
/* ------------------------------- app detail page ------------------------------- */
export function appPage(req, publisher, slug) {
const id = `${String(publisher).toLowerCase()}/${String(slug).toLowerCase()}`;
const row = getListing(id);
if (!row || row.withdrawn_at) return notFound(req);
const cat = CATEGORY_BY_SLUG[row.category];
const catChip = cat
? `<a href="/c/${cat.slug}" class="pill p-upcoming" style="text-decoration:none">${cat.icon} ${escapeHtml(cat.title)}</a>`
: '';
const scanReport = safeParse(row.scan_report);
const scanPayload = scanReport?.payload ? safeParse(fromBase64Url(scanReport.payload)) : null;
const verdict = scanPayload?.scan?.verdict || 'unknown';
const scanBadge = renderScanBadge(verdict, scanPayload);
const iconHtml = row.icon
? `<img src="${escapeAttr(row.icon)}" alt="" width="80" height="80"
style="width:80px;height:80px;border-radius:16px;border:1px solid var(--line);background:var(--panel);object-fit:cover">`
: `<div style="width:80px;height:80px;border-radius:16px;border:1px solid var(--line);background:var(--panel);display:grid;place-items:center;font-size:36px">${cat?.icon || '📦'}</div>`;
return {
title: `${row.name} by ${row.publisher}`,
description: row.description.slice(0, 200),
here: `/app/${row.publisher}/${row.slug}`,
body: `
<header class="hero compact" style="padding-bottom:1rem">
<div style="display:flex;gap:18px;align-items:center;justify-content:center;flex-wrap:wrap;margin-bottom:.8rem">
${iconHtml}
<div style="text-align:left">
<h1 style="margin:0;font-size:1.8rem">${escapeHtml(row.name)}</h1>
<div style="color:var(--mut);font-size:14px;margin-top:.3rem">
by <b style="color:var(--ink)">${escapeHtml(row.publisher)}</b> ·
v${escapeHtml(row.version)} ·
${catChip}
</div>
</div>
</div>
</header>
<div class="wrap">
<section class="plain">
<div class="card" style="padding:22px 26px">
<div style="display:flex;gap:1rem;align-items:center;justify-content:space-between;flex-wrap:wrap">
<div>
<h3 style="margin:0 0 .3rem;font-size:1.05rem">Download</h3>
<p style="margin:0;color:var(--mut);font-size:13px">
${formatBytes(row.download_size)} ·
<code class="inline" title="SHA-256">${shortSha(row.download_sha256)}</code>
${scanBadge}
</p>
</div>
<a class="btn acid" href="${escapeAttr(row.download_url)}" rel="noopener">Download</a>
</div>
<details style="margin-top:1rem">
<summary style="color:var(--dim);font-size:13px;cursor:pointer">Full hash + source URL</summary>
<div style="margin-top:.6rem;font-size:13px;color:var(--mut)">
<div><b style="color:var(--ink)">URL</b> <a href="${escapeAttr(row.download_url)}" rel="noopener">${escapeHtml(row.download_url)}</a></div>
<div style="margin-top:.4rem"><b style="color:var(--ink)">SHA-256</b> <code class="inline">${row.download_sha256}</code></div>
</div>
</details>
</div>
</section>
<section>
<h2>About</h2>
<p style="white-space:pre-wrap">${escapeHtml(row.description)}</p>
${row.homepage ? `<p style="margin-top:1rem"><a href="${escapeAttr(row.homepage)}" rel="noopener">Project homepage →</a></p>` : ''}
</section>
<section>
<h2>Who published this</h2>
<p>Signed by <b>${escapeHtml(row.publisher)}</b> — a BNS name owned by
<code class="inline">${escapeHtml(row.signer_address)}</code>. Any installer
the chain says belongs to that key is trusted by your client; any client
that follows a different trust list will not see it as trusted.</p>
</section>
<section>
<h2>Endorsements</h2>
<div class="note"><b>No endorsements yet.</b> An endorsement is a signed
on-chain statement from any BNS name saying "I vouch for this app." Readers
whose trust list includes the endorser see the endorsement; those who do
not, do not. The endorsement protocol lands with APS1.</div>
</section>
<p style="margin-top:1.8rem;color:var(--dim);font-size:13px">
${cat ? `<a href="/c/${cat.slug}">← Back to ${escapeHtml(cat.title)}</a>` : `<a href="/browse">← Browse</a>`}
</p>
</div>`,
};
}
/* ------------------------------- helpers ------------------------------- */
function renderListingList(rows) {
return `<div class="grid" style="margin-top:1.2rem">
${rows.map(renderListingCard).join('\n')}
</div>`;
}
function renderListingCard(row) {
const href = `/app/${encodeURIComponent(row.publisher)}/${encodeURIComponent(row.slug)}`;
return `<a class="card" href="${href}"
style="text-decoration:none;color:inherit;display:flex;flex-direction:column;gap:.5rem">
<h3 style="margin:0">${escapeHtml(row.name)}</h3>
<p style="margin:0;color:var(--mut);font-size:13.5px;line-height:1.5">
${escapeHtml(truncate(row.description, 160))}
</p>
<div style="color:var(--dim);font-size:12px;margin-top:auto;padding-top:.5rem">
by ${escapeHtml(row.publisher)} · v${escapeHtml(row.version)} · ${formatBytes(row.download_size)}
</div>
</a>`;
}
function emptyCategory(cat) {
return `<div class="note">
<b>Nothing here yet.</b> If you ship software that fits "${escapeHtml(cat.title)}",
the <a href="/submit">submit flow</a> is live — upload, scan, sign, your listing
appears in this view.
</div>`;
}
function renderScanBadge(verdict, payload) {
const engine = payload?.scan?.engine || '';
const sigs = payload?.scan?.signatures || [];
switch (verdict) {
case 'clean':
return `<span class="pill p-live" style="margin-left:.4rem">scan ✓ clean${engine ? ` (${escapeHtml(engine)})` : ''}</span>`;
case 'infected':
return `<span class="pill" style="background:rgba(246,118,138,.14);color:var(--taken);margin-left:.4rem" title="${escapeAttr(sigs.join(', '))}">scan ⚠ infected</span>`;
default:
return `<span class="pill p-upcoming" style="margin-left:.4rem">scan ? ${escapeHtml(verdict)}</span>`;
}
}
function formatBytes(n) {
const num = Number(n) || 0;
if (num < 1024) return `${num} B`;
if (num < 1024 * 1024) return `${(num / 1024).toFixed(1)} KiB`;
if (num < 1024 * 1024 * 1024) return `${(num / 1024 / 1024).toFixed(1)} MiB`;
return `${(num / 1024 / 1024 / 1024).toFixed(2)} GiB`;
}
function shortSha(sha) {
if (!sha) return '';
const s = String(sha);
if (s.length <= 16) return s;
return `${s.slice(0, 8)}…${s.slice(-6)}`;
}
function truncate(s, max) {
const str = String(s || '');
if (str.length <= max) return str;
return str.slice(0, max - 1) + '…';
}
function escapeAttr(s) {
// Same as text escape; attributes are also always wrapped in double-quotes.
return escapeHtml(s);
}
function safeParse(s) {
if (!s) return null;
if (typeof s === 'object') return s;
try { return JSON.parse(s); } catch { return null; }
}
function fromBase64Url(s) {
try { return Buffer.from(String(s), 'base64url').toString('utf8'); }
catch { return ''; }
}