/** * 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 `
${escapeHtml(c.blurb)}
` : ''} ${countLabel} `; }).join('\n ')}${escapeHtml(cat.blurb)}
${rows.length === 0 ? `No listings in this category yet. Be the first.` : `${rows.length} ${rows.length === 1 ? 'listing' : 'listings'}, newest first.`}
${rows.length === 0 ? emptyCategory(cat) : renderListingList(rows)}
${formatBytes(row.download_size)} ยท
${shortSha(row.download_sha256)}
${scanBadge}
${row.download_sha256}${escapeHtml(row.description)}
${row.homepage ? `` : ''}Signed by ${escapeHtml(row.publisher)} โ a BNS name owned by
${escapeHtml(row.signer_address)}. 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.
${cat ? `โ Back to ${escapeHtml(cat.title)}` : `โ Browse`}
${escapeHtml(truncate(row.description, 160))}