/**
* 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';
import { softwareApplicationLd } from './seo.js';
/* ------------------------------- taxonomy ------------------------------- */
/**
* Two levels: three top categories (Apps / Games / FinTech) on the home + browse
* tiles, and for Apps, seven sub-categories a reader clicks through to narrow.
* Games and FinTech do not have sub-categories at launch — their top-level
* page IS the listings page. (Sub-chips can be added inside them later when
* there's volume: Wallets / Bridges / POS inside FinTech, genres inside Games.)
*
* `icon` paths point at /static/icons/ SVGs rendered via at the sizes
* the templates need. All icons share one family with the sibling .x marks:
* 12-radius dark square + acid-green mark.
*
* `CATEGORIES` carries the sub-category metadata AND the single-bucket
* categories under Games and FinTech. Every row has a `top` field that places
* it under one of the three top-level groups.
*/
export const TOP_CATEGORIES = [
{ slug: 'apps', icon: '/static/icons/top-apps.svg', title: 'Apps',
blurb: `Everything that isn't a game or a fintech tool. Browsers, messengers, media, developer tools, productivity.` },
{ slug: 'games', icon: '/static/icons/cat-games.svg', title: 'Games',
blurb: `Standalone titles, on-chain leaderboards, in-app purchases that reach the publisher instead of a 30% toll.` },
{ slug: 'fintech', icon: '/static/icons/cat-wallets.svg', title: 'FinTech',
blurb: `BCH wallets, cross-chain bridges, point-of-sale terminals, DeFi primitives. The chain publishes the listing and the chain settles the payment.` },
];
const TOP_BY_SLUG = Object.fromEntries(TOP_CATEGORIES.map((t) => [t.slug, t]));
export const CATEGORIES = [
// ===== Apps sub-categories =====
{ slug: 'browsers', top: 'apps', icon: '/static/icons/cat-browsers.svg', title: 'Browsers & web tools',
blurb: `Web3 browsers and decentralized domain name resolvers. BCNR tooling that teaches Chrome, Firefox and other browsers to reach .x and the other decentralized TLDs.` },
{ slug: 'communication', top: 'apps', icon: '/static/icons/cat-communication.svg', title: 'Communication',
blurb: `Messengers, voice, boards, group chats. Clients that route through peers rather than someone else's server.` },
{ slug: 'privacy', top: 'apps', icon: '/static/icons/cat-privacy.svg', 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: 'devtools', top: 'apps', icon: '/static/icons/cat-devtools.svg', title: 'Developer tools',
blurb: `SDKs, libraries, indexers, faucets, protocol utilities. The pieces other apps on this list are built out of.` },
{ slug: 'media', top: 'apps', icon: '/static/icons/cat-media.svg', title: 'Media & publishing',
blurb: `Readers, players, one-file blogs, podcast hosts. Clients that find their audience without going through an aggregator.` },
{ slug: 'productivity', top: 'apps', icon: '/static/icons/cat-utilities.svg', title: 'Productivity',
blurb: `File tools, storage, format converters, backup, calculators. The small sharp things that solve one problem and do not ask for an account first.` },
{ slug: 'other', top: 'apps', icon: '/static/icons/cat-other.svg', title: 'Other',
blurb: `Apps that don't fit the categories above. A new category can be proposed by publishing against it and asking an operator to promote it.` },
// ===== Games (single bucket for now) =====
{ slug: 'games', top: 'games', icon: '/static/icons/cat-games.svg', title: 'Games',
blurb: TOP_BY_SLUG.games?.blurb || '' },
// ===== FinTech (single bucket for now) =====
{ slug: 'fintech', top: 'fintech', icon: '/static/icons/cat-wallets.svg', title: 'FinTech',
blurb: TOP_BY_SLUG.fintech?.blurb || '' },
];
const CATEGORY_BY_SLUG = Object.fromEntries(CATEGORIES.map((c) => [c.slug, c]));
const CATEGORIES_BY_TOP = Object.fromEntries(
TOP_CATEGORIES.map((t) => [t.slug, CATEGORIES.filter((c) => c.top === t.slug)])
);
/**
* {slug: count} for every sub-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;
}
/**
* {slug: count} for every top-level group — sums its subs' counts.
*/
export function countsByTop() {
const subs = countsByCategory();
const out = {};
for (const t of TOP_CATEGORIES) {
out[t.slug] = CATEGORIES_BY_TOP[t.slug].reduce((n, c) => n + subs[c.slug], 0);
}
return out;
}
/**
* The three big tiles — the primary navigation into the directory.
* Used on the home page and on /browse.
*/
export function renderTopGrid({ verbose = true } = {}) {
const counts = countsByTop();
return `
${escapeHtml(t.blurb)}
` : ''} ${countLabel} `; }).join('\n ')}${escapeHtml(c.blurb)}
${countLabel} `; }).join('\n ')}${escapeHtml(top.blurb)}
${rows.length === 0 ? `No listings in ${escapeHtml(title)} yet. Be the first.` : `${rows.length} ${rows.length === 1 ? 'listing' : 'listings'} across ${subs.filter((c) => countsByCategory()[c.slug] > 0).length} sub-categories, newest first.`}
${rows.length === 0 ? '' : renderListingList(rows)}${rows.length === 0 ? `No listings in ${escapeHtml(title)} yet. Be the first.` : `${rows.length} ${rows.length === 1 ? 'listing' : 'listings'}, newest first.`}
${rows.length === 0 ? emptyTop(top) : renderListingList(rows)}${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(shortenPublisher(row.publisher))} —
${/^(bitcoincash|bchtest):q[a-z0-9]{41}$/.test(String(row.publisher))
? `a raw BCH address publisher. The signing key is this address itself; no BNS name is attached.`
: `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.
${chipTarget ? `← Back to ${escapeHtml(chipTarget.title)}` : `← Browse`}
${escapeHtml(truncate(row.description, 160))}