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 `
+ ${TOP_CATEGORIES.map((t) => { + const n = counts[t.slug]; + const emptyCls = n === 0 ? ' empty' : ''; + const countLabel = `${n} ${n === 1 ? 'listing' : 'listings'}`; + return ` + +

${escapeHtml(t.title)}

+ ${verbose ? `

${escapeHtml(t.blurb)}

` : ''} + ${countLabel} +
`; + }).join('\n ')} +
`; +} + +/** + * Secondary grid of the sub-categories under one top. Only used on /t/apps + * (Games and FinTech are single-bucket so their top page is the listings). + */ +export function renderSubGrid(topSlug) { + const subs = CATEGORIES_BY_TOP[topSlug] || []; + if (subs.length < 2) return ''; // nothing worth showing for a single-sub top const counts = countsByCategory(); return `
- ${CATEGORIES.map((c) => { + ${subs.map((c) => { const n = counts[c.slug]; const emptyCls = n === 0 ? ' empty' : ''; const countLabel = `${n} ${n === 1 ? 'listing' : 'listings'}`; return `

${escapeHtml(c.title)}

- ${verbose ? `

${escapeHtml(c.blurb)}

` : ''} +

${escapeHtml(c.blurb)}

${countLabel}
`; }).join('\n ')}
`; } +/** + * Kept as an alias so pages.js's older call site still works; home page now + * shows the top grid instead of the sub grid. Removing this would be a + * minor churn in pages.js that landed in the previous commit. + */ +export const renderCategoriesGrid = renderTopGrid; + +/* ------------------------------- top-category page ------------------------------- */ + +export function topCategoryPage(req, slug) { + const top = TOP_BY_SLUG[slug]; + if (!top) return notFound(req); + + const subs = CATEGORIES_BY_TOP[slug] || []; + const subSlugs = subs.map((c) => c.slug); + + // Aggregate across all subs. For single-bucket tops (Games, FinTech) + // this is just one slug; for Apps it's seven. + const rows = []; + for (const s of subSlugs) rows.push(...listingsByCategory(s)); + rows.sort((a, b) => (b.created_at || '').localeCompare(a.created_at || '')); + + const title = top.title; + const here = `/t/${slug}`; + const hasSubs = subs.length > 1; + + return { + title, + description: `${top.title} on Prometheus — ${top.blurb}`, + here, + body: ` +
+ +

${escapeHtml(title)}

+

${escapeHtml(top.blurb)}

+
+ +
+ +${hasSubs ? ` +
+

Narrow by sub-category

+ ${renderSubGrid(slug)} +
+ +
+

All ${escapeHtml(title.toLowerCase())}

+

+ ${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 +

+ +
`, + }; +} + +function emptyTop(top) { + return `
+ Nothing here yet. If you ship ${escapeHtml(top.title.toLowerCase())}, + the submit flow is live — upload, scan, sign, your listing + appears in this view. +
`; +} + /* ------------------------------- category page ------------------------------- */ export function categoryPage(req, slug) { const cat = CATEGORY_BY_SLUG[slug]; if (!cat) return notFound(req); + // Sole-sub categories (games, fintech) redirect to their /t/ page, + // because the top page IS the listings view and we don't want two URLs + // for the same content. + const top = TOP_BY_SLUG[cat.top]; + const soleSub = top && CATEGORIES_BY_TOP[cat.top].length === 1; + if (soleSub) { + return { + title: cat.title, + description: cat.blurb, + status: 301, + redirect: `/t/${cat.top}`, + here: '', + body: '', + }; + } + const rows = listingsByCategory(slug); const title = cat.title; const here = `/c/${slug}`; + const backLabel = top ? `← Back to ${escapeHtml(top.title)}` : '← All categories'; + const backHref = top ? `/t/${top.slug}` : '/browse'; return { title, - description: `${cat.title} apps on Prometheus — ${cat.blurb}`, + description: `${cat.title} on Prometheus — ${cat.blurb}`, here, body: `
@@ -123,7 +277,7 @@ export function categoryPage(req, slug) { ${rows.length === 0 ? emptyCategory(cat) : renderListingList(rows)}

- ← 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 - ? ` ${escapeHtml(cat.title)}` + // Sole-sub categories (games, fintech) show as the top-level chip since + // the sub URL just redirects to the top anyway. + const chipTarget = cat && TOP_BY_SLUG[cat.top] && CATEGORIES_BY_TOP[cat.top].length === 1 + ? { href: `/t/${cat.top}`, title: TOP_BY_SLUG[cat.top].title, icon: TOP_BY_SLUG[cat.top].icon } + : cat + ? { href: `/c/${cat.slug}`, title: cat.title, icon: cat.icon } + : null; + const catChip = chipTarget + ? ` ${escapeHtml(chipTarget.title)}` : ''; const scanReport = safeParse(row.scan_report); @@ -229,7 +390,7 @@ export function appPage(req, publisher, slug) {

- ${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.