prometheus: browse pages — categories and per-listing detail
/c/<slug> renders one of the eight categories with every active listing
in it, newest first, as a grid of cards. Empty category shows
a "be the first" prompt rather than a dead page.
/app/<pub>/<slug> renders a listing: icon + name + publisher + version
chip, a prominent Download card with size, hash chip,
signed scan badge (green/clean | red/infected |
neutral/unknown), description, homepage link, who
signed, and an endorsements placeholder for when APS1
lands.
The home-page and /browse grids now read real counts from the DB — the
hardcoded "0 listings" placeholders are gone. A new helper
`renderCategoriesGrid()` is the one place category cards are rendered,
so the two surfaces always agree.
404s for unknown categories (`/c/nope`) and unknown listings
(`/app/foo/bar`) go through the same notFound page and now actually
return HTTP 404 instead of 200 — pages opt in by returning the
notFound object, server detects it by title and sets the status.
CATEGORIES is the single source of truth for slug/icon/title/blurb;
adding a new category now means one row here plus one entry in the
listings allow-list.
Verified end-to-end with three listings across three categories: counts
update, cards render, detail page shows the signed scan badge, 404s
return 404.
This commit is contained in:
parent
72f7d41902
commit
bb07c73aaf
3 changed files with 370 additions and 86 deletions
303
service/pages-browse.js
Normal file
303
service/pages-browse.js
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
/**
|
||||
* 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 `<div class="categories">
|
||||
${CATEGORIES.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}">
|
||||
<div class="ico">${c.icon}</div>
|
||||
<h3>${escapeHtml(c.title)}</h3>
|
||||
${verbose ? `<p>${escapeHtml(c.blurb)}</p>` : ''}
|
||||
<span class="count">${countLabel}</span>
|
||||
</a>`;
|
||||
}).join('\n ')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/* ------------------------------- category page ------------------------------- */
|
||||
|
||||
export function categoryPage(req, slug) {
|
||||
const cat = CATEGORY_BY_SLUG[slug];
|
||||
if (!cat) return notFound(req);
|
||||
|
||||
const rows = listingsByCategory(slug);
|
||||
const title = cat.title;
|
||||
const here = `/c/${slug}`;
|
||||
|
||||
return {
|
||||
title,
|
||||
description: `${cat.title} apps on Prometheus — ${cat.blurb}`,
|
||||
here,
|
||||
body: `
|
||||
<header class="hero compact">
|
||||
<div class="mark" style="font-size:40px">${cat.icon}</div>
|
||||
<h1>${escapeHtml(title)}</h1>
|
||||
<p class="tag">${escapeHtml(cat.blurb)}</p>
|
||||
</header>
|
||||
|
||||
<div class="wrap">
|
||||
|
||||
<section class="plain">
|
||||
<p class="lede">
|
||||
${rows.length === 0
|
||||
? `No listings in this category yet. <a href="/submit">Be the first</a>.`
|
||||
: `${rows.length} ${rows.length === 1 ? 'listing' : 'listings'}, newest first.`}
|
||||
</p>
|
||||
|
||||
${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>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
</div>`,
|
||||
};
|
||||
}
|
||||
|
||||
/* ------------------------------- app detail page ------------------------------- */
|
||||
|
||||
export function appPage(req, publisher, slug) {
|
||||
const id = `${String(publisher).toLowerCase()}/${String(slug).toLowerCase()}`;
|
||||
const row = getListing(id);
|
||||
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">${cat.icon} ${escapeHtml(cat.title)}</a>`
|
||||
: '';
|
||||
|
||||
const scanReport = safeParse(row.scan_report);
|
||||
const scanPayload = scanReport?.payload ? safeParse(fromBase64Url(scanReport.payload)) : null;
|
||||
const verdict = scanPayload?.scan?.verdict || 'unknown';
|
||||
const scanBadge = renderScanBadge(verdict, scanPayload);
|
||||
|
||||
const iconHtml = row.icon
|
||||
? `<img src="${escapeAttr(row.icon)}" alt="" width="80" height="80"
|
||||
style="width:80px;height:80px;border-radius:16px;border:1px solid var(--line);background:var(--panel);object-fit:cover">`
|
||||
: `<div style="width:80px;height:80px;border-radius:16px;border:1px solid var(--line);background:var(--panel);display:grid;place-items:center;font-size:36px">${cat?.icon || '📦'}</div>`;
|
||||
|
||||
return {
|
||||
title: `${row.name} by ${row.publisher}`,
|
||||
description: row.description.slice(0, 200),
|
||||
here: `/app/${row.publisher}/${row.slug}`,
|
||||
body: `
|
||||
<header class="hero compact" style="padding-bottom:1rem">
|
||||
<div style="display:flex;gap:18px;align-items:center;justify-content:center;flex-wrap:wrap;margin-bottom:.8rem">
|
||||
${iconHtml}
|
||||
<div style="text-align:left">
|
||||
<h1 style="margin:0;font-size:1.8rem">${escapeHtml(row.name)}</h1>
|
||||
<div style="color:var(--mut);font-size:14px;margin-top:.3rem">
|
||||
by <b style="color:var(--ink)">${escapeHtml(row.publisher)}</b> ·
|
||||
v${escapeHtml(row.version)} ·
|
||||
${catChip}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="wrap">
|
||||
|
||||
<section class="plain">
|
||||
<div class="card" style="padding:22px 26px">
|
||||
<div style="display:flex;gap:1rem;align-items:center;justify-content:space-between;flex-wrap:wrap">
|
||||
<div>
|
||||
<h3 style="margin:0 0 .3rem;font-size:1.05rem">Download</h3>
|
||||
<p style="margin:0;color:var(--mut);font-size:13px">
|
||||
${formatBytes(row.download_size)} ·
|
||||
<code class="inline" title="SHA-256">${shortSha(row.download_sha256)}</code>
|
||||
${scanBadge}
|
||||
</p>
|
||||
</div>
|
||||
<a class="btn acid" href="${escapeAttr(row.download_url)}" rel="noopener">Download</a>
|
||||
</div>
|
||||
<details style="margin-top:1rem">
|
||||
<summary style="color:var(--dim);font-size:13px;cursor:pointer">Full hash + source URL</summary>
|
||||
<div style="margin-top:.6rem;font-size:13px;color:var(--mut)">
|
||||
<div><b style="color:var(--ink)">URL</b> <a href="${escapeAttr(row.download_url)}" rel="noopener">${escapeHtml(row.download_url)}</a></div>
|
||||
<div style="margin-top:.4rem"><b style="color:var(--ink)">SHA-256</b> <code class="inline">${row.download_sha256}</code></div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>About</h2>
|
||||
<p style="white-space:pre-wrap">${escapeHtml(row.description)}</p>
|
||||
${row.homepage ? `<p style="margin-top:1rem"><a href="${escapeAttr(row.homepage)}" rel="noopener">Project homepage →</a></p>` : ''}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Who published this</h2>
|
||||
<p>Signed by <b>${escapeHtml(row.publisher)}</b> — a BNS name owned by
|
||||
<code class="inline">${escapeHtml(row.signer_address)}</code>. 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.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Endorsements</h2>
|
||||
<div class="note"><b>No endorsements yet.</b> An endorsement is a signed
|
||||
on-chain statement from any BNS name saying "I vouch for this app." Readers
|
||||
whose trust list includes the endorser see the endorsement; those who do
|
||||
not, do not. The endorsement protocol lands with APS1.</div>
|
||||
</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>`}
|
||||
</p>
|
||||
|
||||
</div>`,
|
||||
};
|
||||
}
|
||||
|
||||
/* ------------------------------- helpers ------------------------------- */
|
||||
|
||||
function renderListingList(rows) {
|
||||
return `<div class="grid" style="margin-top:1.2rem">
|
||||
${rows.map(renderListingCard).join('\n')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderListingCard(row) {
|
||||
const href = `/app/${encodeURIComponent(row.publisher)}/${encodeURIComponent(row.slug)}`;
|
||||
return `<a class="card" href="${href}"
|
||||
style="text-decoration:none;color:inherit;display:flex;flex-direction:column;gap:.5rem">
|
||||
<h3 style="margin:0">${escapeHtml(row.name)}</h3>
|
||||
<p style="margin:0;color:var(--mut);font-size:13.5px;line-height:1.5">
|
||||
${escapeHtml(truncate(row.description, 160))}
|
||||
</p>
|
||||
<div style="color:var(--dim);font-size:12px;margin-top:auto;padding-top:.5rem">
|
||||
by ${escapeHtml(row.publisher)} · v${escapeHtml(row.version)} · ${formatBytes(row.download_size)}
|
||||
</div>
|
||||
</a>`;
|
||||
}
|
||||
|
||||
function emptyCategory(cat) {
|
||||
return `<div class="note">
|
||||
<b>Nothing here yet.</b> If you ship software that fits "${escapeHtml(cat.title)}",
|
||||
the <a href="/submit">submit flow</a> is live — upload, scan, sign, your listing
|
||||
appears in this view.
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderScanBadge(verdict, payload) {
|
||||
const engine = payload?.scan?.engine || '';
|
||||
const sigs = payload?.scan?.signatures || [];
|
||||
switch (verdict) {
|
||||
case 'clean':
|
||||
return `<span class="pill p-live" style="margin-left:.4rem">scan ✓ clean${engine ? ` (${escapeHtml(engine)})` : ''}</span>`;
|
||||
case 'infected':
|
||||
return `<span class="pill" style="background:rgba(246,118,138,.14);color:var(--taken);margin-left:.4rem" title="${escapeAttr(sigs.join(', '))}">scan ⚠ infected</span>`;
|
||||
default:
|
||||
return `<span class="pill p-upcoming" style="margin-left:.4rem">scan ? ${escapeHtml(verdict)}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
function formatBytes(n) {
|
||||
const num = Number(n) || 0;
|
||||
if (num < 1024) return `${num} B`;
|
||||
if (num < 1024 * 1024) return `${(num / 1024).toFixed(1)} KiB`;
|
||||
if (num < 1024 * 1024 * 1024) return `${(num / 1024 / 1024).toFixed(1)} MiB`;
|
||||
return `${(num / 1024 / 1024 / 1024).toFixed(2)} GiB`;
|
||||
}
|
||||
|
||||
function shortSha(sha) {
|
||||
if (!sha) return '';
|
||||
const s = String(sha);
|
||||
if (s.length <= 16) return s;
|
||||
return `${s.slice(0, 8)}…${s.slice(-6)}`;
|
||||
}
|
||||
|
||||
function truncate(s, max) {
|
||||
const str = String(s || '');
|
||||
if (str.length <= max) return str;
|
||||
return str.slice(0, max - 1) + '…';
|
||||
}
|
||||
|
||||
function escapeAttr(s) {
|
||||
// Same as text escape; attributes are also always wrapped in double-quotes.
|
||||
return escapeHtml(s);
|
||||
}
|
||||
|
||||
function safeParse(s) {
|
||||
if (!s) return null;
|
||||
if (typeof s === 'object') return s;
|
||||
try { return JSON.parse(s); } catch { return null; }
|
||||
}
|
||||
|
||||
function fromBase64Url(s) {
|
||||
try { return Buffer.from(String(s), 'base64url').toString('utf8'); }
|
||||
catch { return ''; }
|
||||
}
|
||||
122
service/pages.js
122
service/pages.js
|
|
@ -10,6 +10,35 @@
|
|||
* as the SEO-friendly mirror.
|
||||
*/
|
||||
|
||||
import { renderCategoriesGrid, countsByCategory } from './pages-browse.js';
|
||||
import { countActive } from './db.js';
|
||||
|
||||
/**
|
||||
* The "Browse by category" section used on the home page. Lives here so the
|
||||
* home page's inline template reads cleanly, but delegates the actual grid to
|
||||
* pages-browse.js so a single row of counts drives both surfaces.
|
||||
*/
|
||||
function renderHomeBrowseSection() {
|
||||
const total = countActive();
|
||||
return `<section class="plain">
|
||||
<h2>Browse by category</h2>
|
||||
<p class="lede">On a conventional store, one company decides which shelf an app
|
||||
belongs on. Here the publisher labels their own listing, and your trust list
|
||||
decides whether to show it. The categories below are the initial shape; a new
|
||||
one gets proposed by publishing a listing against it.</p>
|
||||
|
||||
${renderCategoriesGrid({ verbose: true })}
|
||||
|
||||
${total === 0 ? `
|
||||
<div class="note warn" style="margin-top:1.6rem"><b>No live listings yet.</b>
|
||||
The on-chain publication protocol (<code class="inline">APS1</code>) is in
|
||||
progress; in the meantime the site, the scanner, the publisher-signed
|
||||
listing endpoint and the publishers' walkthrough are up. First real
|
||||
listings appear in this grid as they publish.</div>
|
||||
` : ''}
|
||||
</section>`;
|
||||
}
|
||||
|
||||
/* ------------------------------- home ------------------------------- */
|
||||
|
||||
export function home() {
|
||||
|
|
@ -36,70 +65,7 @@ export function home() {
|
|||
|
||||
<div class="wrap">
|
||||
|
||||
<section class="plain">
|
||||
<h2>Browse by category</h2>
|
||||
<p class="lede">On a conventional store, one company decides which shelf an app
|
||||
belongs on. Here the publisher labels their own listing, and your trust list
|
||||
decides whether to show it. The categories below are the initial shape; a new
|
||||
one gets proposed by publishing a listing against it.</p>
|
||||
|
||||
<div class="categories">
|
||||
<a class="cat empty" href="/c/communication"><div class="ico">💬</div>
|
||||
<h3>Communication</h3>
|
||||
<p>Messengers, voice, boards, group chats. Clients that route through peers
|
||||
rather than someone else's server.</p>
|
||||
<span class="count">0 listings</span>
|
||||
</a>
|
||||
<a class="cat empty" href="/c/privacy"><div class="ico">🛡️</div>
|
||||
<h3>Privacy & security</h3>
|
||||
<p>Tor and mix-net tooling, key and vault managers, disk crypto. The things
|
||||
a conventional store delists first when a government writes.</p>
|
||||
<span class="count">0 listings</span>
|
||||
</a>
|
||||
<a class="cat empty" href="/c/wallets"><div class="ico">💸</div>
|
||||
<h3>Wallets & payments</h3>
|
||||
<p>BCH wallets, cross-chain bridges, point-of-sale terminals. The chain
|
||||
publishes the listing and the chain settles the payment.</p>
|
||||
<span class="count">0 listings</span>
|
||||
</a>
|
||||
<a class="cat empty" href="/c/games"><div class="ico">🎮</div>
|
||||
<h3>Games</h3>
|
||||
<p>Standalone titles, on-chain leaderboards, in-app purchases that reach
|
||||
the publisher instead of a 30% toll.</p>
|
||||
<span class="count">0 listings</span>
|
||||
</a>
|
||||
<a class="cat empty" href="/c/devtools"><div class="ico">🧰</div>
|
||||
<h3>Developer tools</h3>
|
||||
<p>SDKs, libraries, indexers, faucets, protocol utilities. The pieces other
|
||||
apps on this list are built out of.</p>
|
||||
<span class="count">0 listings</span>
|
||||
</a>
|
||||
<a class="cat empty" href="/c/media"><div class="ico">🎧</div>
|
||||
<h3>Media & publishing</h3>
|
||||
<p>Readers, players, one-file blogs, podcast hosts. Clients that find their
|
||||
audience without going through an aggregator.</p>
|
||||
<span class="count">0 listings</span>
|
||||
</a>
|
||||
<a class="cat empty" href="/c/utilities"><div class="ico">⚙️</div>
|
||||
<h3>Utilities</h3>
|
||||
<p>File tools, format converters, backup, calculators. The small sharp
|
||||
things that solve one problem and do not ask for an account first.</p>
|
||||
<span class="count">0 listings</span>
|
||||
</a>
|
||||
<a class="cat empty" href="/c/other"><div class="ico">✨</div>
|
||||
<h3>Everything else</h3>
|
||||
<p>Anything that does not fit above. New categories are proposed in-band —
|
||||
any listing can declare one and clients will offer to add it.</p>
|
||||
<span class="count">0 listings</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="note warn" style="margin-top:1.6rem"><b>No live listings yet.</b>
|
||||
The listing protocol (<code class="inline">APS1</code> record on chain) and
|
||||
the submission flow are being wired now. The site, the scanner, the
|
||||
publishers' walkthrough and this directory are up; a listing you can click
|
||||
on and install is next.</div>
|
||||
</section>
|
||||
${renderHomeBrowseSection()}
|
||||
|
||||
<section>
|
||||
<h2>How Prometheus works, in a nutshell</h2>
|
||||
|
|
@ -303,6 +269,10 @@ export function publish() {
|
|||
/* ------------------------------- browse ----------------------------- */
|
||||
|
||||
export function browse() {
|
||||
const total = countActive();
|
||||
const totalLabel = total === 0
|
||||
? `No listings yet — the eight categories are set and ready to receive publications.`
|
||||
: `${total} ${total === 1 ? 'listing' : 'listings'} across ${Object.values(countsByCategory()).filter((n) => n > 0).length} ${Object.values(countsByCategory()).filter((n) => n > 0).length === 1 ? 'category' : 'categories'}.`;
|
||||
return {
|
||||
title: 'Browse',
|
||||
description:
|
||||
|
|
@ -318,29 +288,9 @@ export function browse() {
|
|||
<div class="wrap">
|
||||
|
||||
<section class="plain">
|
||||
<div class="note warn"><b>No live listings yet.</b> The listing protocol is
|
||||
being wired; the browse pages below will light up as the APS1 index starts
|
||||
carrying records. In the meantime, the eight categories are set and ready
|
||||
to receive publications.</div>
|
||||
<p class="lede">${totalLabel}</p>
|
||||
|
||||
<div class="categories" style="margin-top:1.4rem">
|
||||
<a class="cat empty" href="/c/communication"><div class="ico">💬</div>
|
||||
<h3>Communication</h3><span class="count">0 listings</span></a>
|
||||
<a class="cat empty" href="/c/privacy"><div class="ico">🛡️</div>
|
||||
<h3>Privacy & security</h3><span class="count">0 listings</span></a>
|
||||
<a class="cat empty" href="/c/wallets"><div class="ico">💸</div>
|
||||
<h3>Wallets & payments</h3><span class="count">0 listings</span></a>
|
||||
<a class="cat empty" href="/c/games"><div class="ico">🎮</div>
|
||||
<h3>Games</h3><span class="count">0 listings</span></a>
|
||||
<a class="cat empty" href="/c/devtools"><div class="ico">🧰</div>
|
||||
<h3>Developer tools</h3><span class="count">0 listings</span></a>
|
||||
<a class="cat empty" href="/c/media"><div class="ico">🎧</div>
|
||||
<h3>Media & publishing</h3><span class="count">0 listings</span></a>
|
||||
<a class="cat empty" href="/c/utilities"><div class="ico">⚙️</div>
|
||||
<h3>Utilities</h3><span class="count">0 listings</span></a>
|
||||
<a class="cat empty" href="/c/other"><div class="ico">✨</div>
|
||||
<h3>Everything else</h3><span class="count">0 listings</span></a>
|
||||
</div>
|
||||
${renderCategoriesGrid({ verbose: false })}
|
||||
</section>
|
||||
|
||||
</div>`,
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import {
|
|||
handleListingIndex,
|
||||
handleListingGet,
|
||||
} from './listings.js';
|
||||
import { categoryPage, appPage } from './pages-browse.js';
|
||||
|
||||
const PORT = Number(process.env.PORT) || 3700;
|
||||
const BIND = process.env.BIND || '127.0.0.1';
|
||||
|
|
@ -88,6 +89,17 @@ const server = createServer(async (req, res) => {
|
|||
await serveHealth(res);
|
||||
} else if (url === '/.well-known/prometheus-service-key.json') {
|
||||
serveServiceKey(res);
|
||||
} else if (url.startsWith('/c/')) {
|
||||
const slug = url.slice('/c/'.length);
|
||||
servePageFn(() => categoryPage(req, slug), res);
|
||||
} else if (url.startsWith('/app/')) {
|
||||
// /app/<publisher>/<slug>
|
||||
const parts = url.slice('/app/'.length).split('/').filter(Boolean);
|
||||
if (parts.length === 2) {
|
||||
servePageFn(() => appPage(req, parts[0], parts[1]), res);
|
||||
} else {
|
||||
servePage(pages.notFound, res, 404, req);
|
||||
}
|
||||
} else if (PAGE_ROUTES[url]) {
|
||||
servePage(PAGE_ROUTES[url], res);
|
||||
} else {
|
||||
|
|
@ -133,6 +145,25 @@ function normalizeRequestUrl(raw) {
|
|||
*/
|
||||
function servePage(fn, res, status = 200, req = null) {
|
||||
const page = fn(req);
|
||||
renderPage(page, res, status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a page object (not a function). Used by routes that take extra
|
||||
* arguments to produce the page (category slug, app publisher/slug) and
|
||||
* therefore need to call the generator themselves rather than hand a bare
|
||||
* reference to servePage.
|
||||
*/
|
||||
function servePageFn(produce, res) {
|
||||
const page = produce();
|
||||
// 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.
|
||||
const status = page.title === 'Not here' ? 404 : 200;
|
||||
renderPage(page, res, status);
|
||||
}
|
||||
|
||||
function renderPage(page, res, status) {
|
||||
const html = layout(page);
|
||||
res.writeHead(status, {
|
||||
'content-type': 'text/html; charset=utf-8',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue