diff --git a/public/icons/cat-browsers.svg b/public/icons/cat-browsers.svg
new file mode 100644
index 0000000..c58ae4a
--- /dev/null
+++ b/public/icons/cat-browsers.svg
@@ -0,0 +1,12 @@
+
diff --git a/public/icons/top-apps.svg b/public/icons/top-apps.svg
new file mode 100644
index 0000000..db6c79b
--- /dev/null
+++ b/public/icons/top-apps.svg
@@ -0,0 +1,10 @@
+
diff --git a/scripts/migrate-taxonomy.mjs b/scripts/migrate-taxonomy.mjs
new file mode 100644
index 0000000..e0730e1
--- /dev/null
+++ b/scripts/migrate-taxonomy.mjs
@@ -0,0 +1,45 @@
+#!/usr/bin/env node
+/**
+ * One-shot migration for the taxonomy change (Oct 2026):
+ *
+ * - Top-level groups introduced: Apps / Games / FinTech.
+ * - Sub-category slugs renamed:
+ * wallets → fintech
+ * utilities → productivity
+ * - New sub-category under Apps: browsers (for Theseus, Ariadne's Thread,
+ * Ariadne Navigator)
+ * - Removed listing slug: ariadne-android (mislabel of ariadne-navigator)
+ * - Renamed listing slug: ariadne-resolver-win → ariadne-thread-win
+ * (to match the product's actual name,
+ * Ariadne's Thread)
+ *
+ * This script deletes every row that was inserted by `seed.mjs` (identified by
+ * signature='SM_OPERATOR_SEED'), so a subsequent `seed.mjs` run inserts the
+ * fresh set with the new slugs + categories. Publisher-POSTed listings are
+ * NOT touched — a listing with a real BIP-137 signature stays put, and the
+ * admin can rewrite its category by withdrawing + republishing if needed.
+ *
+ * Run with:
+ * node scripts/migrate-taxonomy.mjs
+ * or inside the VPS container:
+ * docker exec prometheus-prometheus-1 node scripts/migrate-taxonomy.mjs
+ *
+ * Idempotent — a second run finds no SM_OPERATOR_SEED rows and does nothing.
+ */
+
+import { db } from '../service/db.js';
+
+const stmt = db.prepare(`
+ DELETE FROM listings
+ WHERE signature = 'SM_OPERATOR_SEED'
+ RETURNING id, category
+`);
+
+const removed = stmt.all();
+if (removed.length === 0) {
+ console.log('[migrate] no operator-seeded rows found — nothing to remove.');
+} else {
+ console.log(`[migrate] removed ${removed.length} operator-seeded rows:`);
+ for (const r of removed) console.log(` - ${r.id} (category=${r.category})`);
+ console.log('[migrate] now run `node scripts/seed.mjs` to insert the fresh set.');
+}
diff --git a/scripts/seed-listings.json b/scripts/seed-listings.json
index 50daf77..05b7cd2 100644
--- a/scripts/seed-listings.json
+++ b/scripts/seed-listings.json
@@ -11,7 +11,7 @@
"slug": "theseus-navigator-win",
"name": "Theseus Navigator",
"description": "The browser the rest of this directory assumes. Resolves BCNR names (.x, .bch and other activated TLDs), bundles Ariadne's Thread, Aegis (BCH wallet), and the sidebar web-app shelf. Windows installer; portable build + Android available at theseus.x.",
- "category": "other",
+ "category": "browsers",
"version": "0.3.89",
"homepage": "https://theseus.x/",
"icon": "/static/icons/theseus.svg",
@@ -21,10 +21,10 @@
},
{
"publisher": "ariadne.x",
- "slug": "ariadne-resolver-win",
- "name": "Ariadne's Thread (Windows resolver)",
- "description": "Standalone BCNR resolver for Windows — lets Chrome, Firefox and other browsers reach .x names without Theseus. Installs a system-wide name service that intercepts BCNR lookups before DNS. Also ships as a Firefox extension and an Android app.",
- "category": "privacy",
+ "slug": "ariadne-thread-win",
+ "name": "Ariadne's Thread",
+ "description": "Standalone BCNR resolver for Windows — lets Chrome, Firefox and other browsers reach .x names without Theseus. Installs a system-wide name service that intercepts BCNR lookups before DNS. Different program from Ariadne Navigator (which is the Android browser).",
+ "category": "browsers",
"version": "0.1.13",
"homepage": "https://ariadne.x/",
"icon": "/static/icons/ariadne.svg",
@@ -34,10 +34,10 @@
},
{
"publisher": "ariadne.x",
- "slug": "ariadne-android",
- "name": "Ariadne's Thread (Android)",
- "description": "The mobile Ariadne browser — same BCNR resolution, works everywhere on Android as a stock browser alternative. Signed APK, side-load install.",
- "category": "privacy",
+ "slug": "ariadne-navigator",
+ "name": "Ariadne Navigator",
+ "description": "The Android browser from the Ariadne family — a stock-browser alternative with built-in BCNR resolution for .x and the other activated TLDs. Different program from Ariadne's Thread (which is the Windows resolver). Signed APK, side-load install.",
+ "category": "browsers",
"version": "0.22",
"homepage": "https://ariadne.x/",
"icon": "/static/icons/ariadne.svg",
@@ -50,7 +50,7 @@
"slug": "pithos-desktop",
"name": "Pithos",
"description": "Sia storage GUI — upload, browse and share files on the Sia network with no CLI. Supports hosted mode (sign in at pithos.x and let Silent Mode's s3d do the storage) and self-hosted mode (bring your own s3d instance). Available as a desktop app, a web app on pithos.x, and a Theseus add-on.",
- "category": "utilities",
+ "category": "productivity",
"version": "0.3.26",
"homepage": "https://pithos.x/",
"icon": "/static/icons/pithos.svg",
diff --git a/service/listings.js b/service/listings.js
index c87f2b3..cfc948f 100644
--- a/service/listings.js
+++ b/service/listings.js
@@ -36,9 +36,15 @@ import {
} from './db.js';
import { paymentsRequired } from './payments.js';
+// Nine category slugs: seven sub-categories under Apps, plus the two
+// single-bucket top-level categories (games, fintech).
+// `wallets` and `utilities` are the previous names of `fintech` and
+// `productivity`; the migration script that landed with the taxonomy change
+// rewrote any seeded rows carrying the old slugs.
const CATEGORIES = new Set([
- 'communication', 'privacy', 'wallets', 'games',
- 'devtools', 'media', 'utilities', 'other',
+ 'browsers', 'communication', 'privacy', 'devtools', 'media', 'productivity', 'other',
+ 'games',
+ 'fintech',
]);
const MAX_BODY_BYTES = 1024 * 1024; // 1 MiB — listings are small JSON
diff --git a/service/pages-browse.js b/service/pages-browse.js
index 98ed9a8..442fa09 100644
--- a/service/pages-browse.js
+++ b/service/pages-browse.js
@@ -19,44 +19,67 @@ import { escapeHtml } from './layout.js';
import { notFound } from './pages.js';
import { softwareApplicationLd } from './seo.js';
-/* ------------------------------- categories ------------------------------- */
+/* ------------------------------- taxonomy ------------------------------- */
/**
- * 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.
+ * 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.)
*
- * 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.
+ * `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.
*/
-// `icon` is a path to an SVG under /static/icons/. Rendered via
at the
-// three sizes the templates use (26 px in the grid, 40 px in the category
-// hero, 1em in the inline chip on listing detail pages). The icons share a
-// family with the sibling .x marks: 12-radius dark square + acid-green mark.
-export const CATEGORIES = [
- { slug: 'communication', 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', 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: 'wallets', icon: '/static/icons/cat-wallets.svg', 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: '/static/icons/cat-games.svg', title: 'Games',
+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: 'devtools', icon: '/static/icons/cat-devtools.svg', title: 'Developer tools',
+ { 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: `BCNR resolvers, mobile browsers, extensions that teach other browsers to speak .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', icon: '/static/icons/cat-media.svg', title: 'Media & publishing',
+ { 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: 'utilities', icon: '/static/icons/cat-utilities.svg', 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: '/static/icons/cat-other.svg', 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.` },
+ { 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 category — categories with 0 listings are present
+ * {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() {
@@ -67,42 +90,173 @@ export function countsByCategory() {
}
/**
- * 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.
+ * {slug: count} for every top-level group — sums its subs' counts.
*/
-export function renderCategoriesGrid({ verbose = true } = {}) {
+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)}
` : ''} +${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)} ++ ← All categories +
+ +- ← All categories + ${backLabel}
@@ -139,8 +293,15 @@ export function appPage(req, publisher, slug) { if (!row || row.withdrawn_at) return notFound(req); const cat = CATEGORY_BY_SLUG[row.category]; - const catChip = cat - ? `- ${cat ? `← Back to ${escapeHtml(cat.title)}` : `← Browse`} + ${chipTarget ? `← Back to ${escapeHtml(chipTarget.title)}` : `← Browse`}
`, diff --git a/service/server.js b/service/server.js index bd6cdf9..2d0039d 100644 --- a/service/server.js +++ b/service/server.js @@ -26,7 +26,7 @@ import { handleListingIndex, handleListingGet, } from './listings.js'; -import { categoryPage, appPage, CATEGORIES } from './pages-browse.js'; +import { categoryPage, topCategoryPage, appPage, CATEGORIES } from './pages-browse.js'; import { handleAdmin } from './admin.js'; import { renderSitemap, renderRobots } from './seo.js'; import { allActiveListings } from './db.js'; @@ -103,6 +103,9 @@ const server = createServer(async (req, res) => { serveRobots(res); } else if (url === '/admin' || url.startsWith('/admin/')) { await handleAdmin(req, res, url); + } else if (url.startsWith('/t/')) { + const slug = url.slice('/t/'.length); + servePageFn(() => topCategoryPage(req, slug), res); } else if (url.startsWith('/c/')) { const slug = url.slice('/c/'.length); servePageFn(() => categoryPage(req, slug), res); @@ -170,6 +173,17 @@ function servePage(fn, res, status = 200, req = null) { */ function servePageFn(produce, res) { const page = produce(); + // Redirects: a page can return {status: 301, redirect: '/to'} and the server + // sends a Location header instead of HTML. Used by categoryPage to collapse + // a sole-sub category URL (/c/games) onto its top-level view (/t/games). + if (page.redirect) { + res.writeHead(page.status || 302, { + location: page.redirect, + 'cache-control': 'public, max-age=600', + }); + res.end(); + return; + } // 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.