prometheus: taxonomy — Apps / Games / FinTech, with sub-chips inside Apps
Three top-level groups replace the flat eight categories on the home
page and /browse:
Apps 7 sub-categories inside: browsers / communication /
privacy / devtools / media / productivity / other
Games single bucket for now; sub-chips later when there's volume
FinTech single bucket for now (BCH wallets, bridges, POS, DeFi)
Routes:
/t/<slug> top-level page. For Apps shows the 7 sub-chips + an
aggregated listings view across them. For Games and
FinTech shows the listings directly (no sub-chips).
/c/<slug> sub-category page under Apps (browsers, communication, …).
The sole-sub categories /c/games and /c/fintech 301 to
/t/games and /t/fintech respectively, so each view has
exactly one canonical URL.
Slug renames + removals in the CATEGORIES allow-list on listings.js:
utilities → productivity (the display label was already pointing at
that in people's heads — "productivity"
fits storage + sync + file tools better)
wallets → fintech (broader scope: bridges + POS + DeFi,
not just wallets)
games stays as a top-level sole-bucket
browsers NEW sub-category under Apps for BCNR
resolvers, mobile browsers, extensions
Seed listings re-categorised + two slug fixes:
theseus.x/theseus-navigator-win category: other → browsers
ariadne.x/ariadne-resolver-win → RENAMED to ariadne-thread-win,
name "Ariadne's Thread
(Windows resolver)" shortened
to just "Ariadne's Thread";
category: privacy → browsers
ariadne.x/ariadne-android → RENAMED to ariadne-navigator,
name "Ariadne's Thread
(Android)" → "Ariadne
Navigator" (it is a
different program, not a
platform build of Thread);
category: privacy → browsers
pithos.x/pithos-desktop category: utilities → productivity
scripts/migrate-taxonomy.mjs is a one-shot that deletes every row with
signature='SM_OPERATOR_SEED'; a subsequent `seed.mjs` run then inserts
the fresh set with the new slugs. Publisher-POSTed listings (none in
production today) are left alone. Idempotent — a second run finds
nothing to remove.
New icons:
top-apps.svg 2×2 rounded tile grid (OS launcher metaphor)
cat-browsers.svg browser window + 3 chrome dots + content block
Three icons reused: cat-games.svg doubles as the Games top icon,
cat-wallets.svg as the FinTech top icon (same visual, different label);
cat-utilities.svg stays as the Productivity sub icon.
This commit is contained in:
parent
8e0830f046
commit
1192f38ea5
7 changed files with 300 additions and 52 deletions
12
public/icons/cat-browsers.svg
Normal file
12
public/icons/cat-browsers.svg
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<!-- Browsers & web tools — browser window with the familiar top chrome
|
||||
(three dots) + an address bar block. Reads at 16 px as "window". -->
|
||||
<rect width="64" height="64" rx="12" fill="#0b0e14"/>
|
||||
<rect x="10" y="14" width="44" height="36" rx="5" fill="none" stroke="#d6ff3d" stroke-width="3"/>
|
||||
<path d="M10 25 L54 25" stroke="#d6ff3d" stroke-width="3"/>
|
||||
<circle cx="16" cy="19.5" r="1.6" fill="#d6ff3d"/>
|
||||
<circle cx="21" cy="19.5" r="1.6" fill="#d6ff3d"/>
|
||||
<circle cx="26" cy="19.5" r="1.6" fill="#d6ff3d"/>
|
||||
<rect x="17" y="31" width="22" height="4" rx="2" fill="#d6ff3d" opacity="0.65"/>
|
||||
<rect x="17" y="39" width="14" height="3" rx="1.5" fill="#d6ff3d" opacity="0.4"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 764 B |
10
public/icons/top-apps.svg
Normal file
10
public/icons/top-apps.svg
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<!-- Apps top-level — 2x2 grid of rounded tiles. Reads as "collection of
|
||||
apps" from the OS launcher grid metaphor; distinct from Games
|
||||
(controller) and FinTech (coins). -->
|
||||
<rect width="64" height="64" rx="12" fill="#0b0e14"/>
|
||||
<rect x="12" y="12" width="18" height="18" rx="4" fill="#d6ff3d"/>
|
||||
<rect x="34" y="12" width="18" height="18" rx="4" fill="#d6ff3d"/>
|
||||
<rect x="12" y="34" width="18" height="18" rx="4" fill="#d6ff3d"/>
|
||||
<rect x="34" y="34" width="18" height="18" rx="4" fill="#d6ff3d" opacity="0.6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 605 B |
45
scripts/migrate-taxonomy.mjs
Normal file
45
scripts/migrate-taxonomy.mjs
Normal file
|
|
@ -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.');
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 <img> 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 <img> 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 `<div class="categories">
|
||||
${TOP_CATEGORIES.map((t) => {
|
||||
const n = counts[t.slug];
|
||||
const emptyCls = n === 0 ? ' empty' : '';
|
||||
const countLabel = `${n} ${n === 1 ? 'listing' : 'listings'}`;
|
||||
return `<a class="cat${emptyCls}" href="/t/${t.slug}">
|
||||
<img class="ico" src="${t.icon}" alt="" width="40" height="40">
|
||||
<h3>${escapeHtml(t.title)}</h3>
|
||||
${verbose ? `<p>${escapeHtml(t.blurb)}</p>` : ''}
|
||||
<span class="count">${countLabel}</span>
|
||||
</a>`;
|
||||
}).join('\n ')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 `<div class="categories">
|
||||
${CATEGORIES.map((c) => {
|
||||
${subs.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}">
|
||||
<img class="ico" src="${c.icon}" alt="" width="40" height="40">
|
||||
<h3>${escapeHtml(c.title)}</h3>
|
||||
${verbose ? `<p>${escapeHtml(c.blurb)}</p>` : ''}
|
||||
<p>${escapeHtml(c.blurb)}</p>
|
||||
<span class="count">${countLabel}</span>
|
||||
</a>`;
|
||||
}).join('\n ')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: `
|
||||
<header class="hero compact">
|
||||
<img class="mark" src="${top.icon}" alt="" width="64" height="64" style="display:block;margin:0 auto 1rem">
|
||||
<h1>${escapeHtml(title)}</h1>
|
||||
<p class="tag">${escapeHtml(top.blurb)}</p>
|
||||
</header>
|
||||
|
||||
<div class="wrap">
|
||||
|
||||
${hasSubs ? `
|
||||
<section class="plain">
|
||||
<h2>Narrow by sub-category</h2>
|
||||
${renderSubGrid(slug)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>All ${escapeHtml(title.toLowerCase())}</h2>
|
||||
<p class="lede">
|
||||
${rows.length === 0
|
||||
? `No listings in ${escapeHtml(title)} yet. <a href="/submit">Be the first</a>.`
|
||||
: `${rows.length} ${rows.length === 1 ? 'listing' : 'listings'} across ${subs.filter((c) => countsByCategory()[c.slug] > 0).length} sub-categories, newest first.`}
|
||||
</p>
|
||||
${rows.length === 0 ? '' : renderListingList(rows)}
|
||||
</section>
|
||||
` : `
|
||||
<section class="plain">
|
||||
<p class="lede">
|
||||
${rows.length === 0
|
||||
? `No listings in ${escapeHtml(title)} yet. <a href="/submit">Be the first</a>.`
|
||||
: `${rows.length} ${rows.length === 1 ? 'listing' : 'listings'}, newest first.`}
|
||||
</p>
|
||||
${rows.length === 0 ? emptyTop(top) : renderListingList(rows)}
|
||||
</section>
|
||||
`}
|
||||
|
||||
<p style="margin-top:1.8rem;color:var(--dim);font-size:13px">
|
||||
<a href="/browse">← All categories</a>
|
||||
</p>
|
||||
|
||||
</div>`,
|
||||
};
|
||||
}
|
||||
|
||||
function emptyTop(top) {
|
||||
return `<div class="note">
|
||||
<b>Nothing here yet.</b> If you ship ${escapeHtml(top.title.toLowerCase())},
|
||||
the <a href="/submit">submit flow</a> is live — upload, scan, sign, your listing
|
||||
appears in this view.
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/* ------------------------------- 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/<top> 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: `
|
||||
<header class="hero compact">
|
||||
|
|
@ -123,7 +277,7 @@ export function categoryPage(req, slug) {
|
|||
${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>
|
||||
<a href="${backHref}">${backLabel}</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
|
|
@ -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
|
||||
? `<a href="/c/${cat.slug}" class="pill p-upcoming" style="text-decoration:none;display:inline-flex;align-items:center;gap:4px"><img src="${cat.icon}" alt="" width="14" height="14" style="vertical-align:-2px"> ${escapeHtml(cat.title)}</a>`
|
||||
// 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
|
||||
? `<a href="${chipTarget.href}" class="pill p-upcoming" style="text-decoration:none;display:inline-flex;align-items:center;gap:4px"><img src="${chipTarget.icon}" alt="" width="14" height="14" style="vertical-align:-2px"> ${escapeHtml(chipTarget.title)}</a>`
|
||||
: '';
|
||||
|
||||
const scanReport = safeParse(row.scan_report);
|
||||
|
|
@ -229,7 +390,7 @@ export function appPage(req, publisher, slug) {
|
|||
</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>`}
|
||||
${chipTarget ? `<a href="${chipTarget.href}">← Back to ${escapeHtml(chipTarget.title)}</a>` : `<a href="/browse">← Browse</a>`}
|
||||
</p>
|
||||
|
||||
</div>`,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue