/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.
303 lines
12 KiB
JavaScript
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 ''; }
|
|
}
|