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.
This commit is contained in:
Local Dev 2026-10-08 01:56:52 +02:00
parent 72f7d41902
commit bb07c73aaf
3 changed files with 370 additions and 86 deletions

303
service/pages-browse.js Normal file
View file

@ -0,0 +1,303 @@
/**
* 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 ''; }
}

View file

@ -10,6 +10,35 @@
* as the SEO-friendly mirror.
*/
import { renderCategoriesGrid, countsByCategory } from './pages-browse.js';
import { countActive } from './db.js';
/**
* The "Browse by category" section used on the home page. Lives here so the
* home page's inline template reads cleanly, but delegates the actual grid to
* pages-browse.js so a single row of counts drives both surfaces.
*/
function renderHomeBrowseSection() {
const total = countActive();
return `<section class="plain">
<h2>Browse by category</h2>
<p class="lede">On a conventional store, one company decides which shelf an app
belongs on. Here the publisher labels their own listing, and your trust list
decides whether to show it. The categories below are the initial shape; a new
one gets proposed by publishing a listing against it.</p>
${renderCategoriesGrid({ verbose: true })}
${total === 0 ? `
<div class="note warn" style="margin-top:1.6rem"><b>No live listings yet.</b>
The on-chain publication protocol (<code class="inline">APS1</code>) is in
progress; in the meantime the site, the scanner, the publisher-signed
listing endpoint and the publishers' walkthrough are up. First real
listings appear in this grid as they publish.</div>
` : ''}
</section>`;
}
/* ------------------------------- home ------------------------------- */
export function home() {
@ -36,70 +65,7 @@ export function home() {
<div class="wrap">
<section class="plain">
<h2>Browse by category</h2>
<p class="lede">On a conventional store, one company decides which shelf an app
belongs on. Here the publisher labels their own listing, and your trust list
decides whether to show it. The categories below are the initial shape; a new
one gets proposed by publishing a listing against it.</p>
<div class="categories">
<a class="cat empty" href="/c/communication"><div class="ico">💬</div>
<h3>Communication</h3>
<p>Messengers, voice, boards, group chats. Clients that route through peers
rather than someone else's server.</p>
<span class="count">0 listings</span>
</a>
<a class="cat empty" href="/c/privacy"><div class="ico">🛡️</div>
<h3>Privacy &amp; security</h3>
<p>Tor and mix-net tooling, key and vault managers, disk crypto. The things
a conventional store delists first when a government writes.</p>
<span class="count">0 listings</span>
</a>
<a class="cat empty" href="/c/wallets"><div class="ico">💸</div>
<h3>Wallets &amp; payments</h3>
<p>BCH wallets, cross-chain bridges, point-of-sale terminals. The chain
publishes the listing and the chain settles the payment.</p>
<span class="count">0 listings</span>
</a>
<a class="cat empty" href="/c/games"><div class="ico">🎮</div>
<h3>Games</h3>
<p>Standalone titles, on-chain leaderboards, in-app purchases that reach
the publisher instead of a 30% toll.</p>
<span class="count">0 listings</span>
</a>
<a class="cat empty" href="/c/devtools"><div class="ico">🧰</div>
<h3>Developer tools</h3>
<p>SDKs, libraries, indexers, faucets, protocol utilities. The pieces other
apps on this list are built out of.</p>
<span class="count">0 listings</span>
</a>
<a class="cat empty" href="/c/media"><div class="ico">🎧</div>
<h3>Media &amp; publishing</h3>
<p>Readers, players, one-file blogs, podcast hosts. Clients that find their
audience without going through an aggregator.</p>
<span class="count">0 listings</span>
</a>
<a class="cat empty" href="/c/utilities"><div class="ico">⚙️</div>
<h3>Utilities</h3>
<p>File tools, format converters, backup, calculators. The small sharp
things that solve one problem and do not ask for an account first.</p>
<span class="count">0 listings</span>
</a>
<a class="cat empty" href="/c/other"><div class="ico">✨</div>
<h3>Everything else</h3>
<p>Anything that does not fit above. New categories are proposed in-band —
any listing can declare one and clients will offer to add it.</p>
<span class="count">0 listings</span>
</a>
</div>
<div class="note warn" style="margin-top:1.6rem"><b>No live listings yet.</b>
The listing protocol (<code class="inline">APS1</code> record on chain) and
the submission flow are being wired now. The site, the scanner, the
publishers' walkthrough and this directory are up; a listing you can click
on and install is next.</div>
</section>
${renderHomeBrowseSection()}
<section>
<h2>How Prometheus works, in a nutshell</h2>
@ -303,6 +269,10 @@ export function publish() {
/* ------------------------------- browse ----------------------------- */
export function browse() {
const total = countActive();
const totalLabel = total === 0
? `No listings yet — the eight categories are set and ready to receive publications.`
: `${total} ${total === 1 ? 'listing' : 'listings'} across ${Object.values(countsByCategory()).filter((n) => n > 0).length} ${Object.values(countsByCategory()).filter((n) => n > 0).length === 1 ? 'category' : 'categories'}.`;
return {
title: 'Browse',
description:
@ -318,29 +288,9 @@ export function browse() {
<div class="wrap">
<section class="plain">
<div class="note warn"><b>No live listings yet.</b> The listing protocol is
being wired; the browse pages below will light up as the APS1 index starts
carrying records. In the meantime, the eight categories are set and ready
to receive publications.</div>
<p class="lede">${totalLabel}</p>
<div class="categories" style="margin-top:1.4rem">
<a class="cat empty" href="/c/communication"><div class="ico">💬</div>
<h3>Communication</h3><span class="count">0 listings</span></a>
<a class="cat empty" href="/c/privacy"><div class="ico">🛡️</div>
<h3>Privacy &amp; security</h3><span class="count">0 listings</span></a>
<a class="cat empty" href="/c/wallets"><div class="ico">💸</div>
<h3>Wallets &amp; payments</h3><span class="count">0 listings</span></a>
<a class="cat empty" href="/c/games"><div class="ico">🎮</div>
<h3>Games</h3><span class="count">0 listings</span></a>
<a class="cat empty" href="/c/devtools"><div class="ico">🧰</div>
<h3>Developer tools</h3><span class="count">0 listings</span></a>
<a class="cat empty" href="/c/media"><div class="ico">🎧</div>
<h3>Media &amp; publishing</h3><span class="count">0 listings</span></a>
<a class="cat empty" href="/c/utilities"><div class="ico">⚙️</div>
<h3>Utilities</h3><span class="count">0 listings</span></a>
<a class="cat empty" href="/c/other"><div class="ico">✨</div>
<h3>Everything else</h3><span class="count">0 listings</span></a>
</div>
${renderCategoriesGrid({ verbose: false })}
</section>
</div>`,

View file

@ -26,6 +26,7 @@ import {
handleListingIndex,
handleListingGet,
} from './listings.js';
import { categoryPage, appPage } from './pages-browse.js';
const PORT = Number(process.env.PORT) || 3700;
const BIND = process.env.BIND || '127.0.0.1';
@ -88,6 +89,17 @@ const server = createServer(async (req, res) => {
await serveHealth(res);
} else if (url === '/.well-known/prometheus-service-key.json') {
serveServiceKey(res);
} else if (url.startsWith('/c/')) {
const slug = url.slice('/c/'.length);
servePageFn(() => categoryPage(req, slug), res);
} else if (url.startsWith('/app/')) {
// /app/<publisher>/<slug>
const parts = url.slice('/app/'.length).split('/').filter(Boolean);
if (parts.length === 2) {
servePageFn(() => appPage(req, parts[0], parts[1]), res);
} else {
servePage(pages.notFound, res, 404, req);
}
} else if (PAGE_ROUTES[url]) {
servePage(PAGE_ROUTES[url], res);
} else {
@ -133,6 +145,25 @@ function normalizeRequestUrl(raw) {
*/
function servePage(fn, res, status = 200, req = null) {
const page = fn(req);
renderPage(page, res, status);
}
/**
* Render a page object (not a function). Used by routes that take extra
* arguments to produce the page (category slug, app publisher/slug) and
* therefore need to call the generator themselves rather than hand a bare
* reference to servePage.
*/
function servePageFn(produce, res) {
const page = produce();
// A `notFound()` return has this exact title; routes use it as the convention
// for "the thing you asked for does not exist" so we don't need a parallel
// status plumbing path.
const status = page.title === 'Not here' ? 404 : 200;
renderPage(page, res, status);
}
function renderPage(page, res, status) {
const html = layout(page);
res.writeHead(status, {
'content-type': 'text/html; charset=utf-8',