Dev #8
+54
-10
@@ -3,6 +3,7 @@ import {
|
|||||||
compareMints,
|
compareMints,
|
||||||
NEUTRAL_PRIOR_MEAN,
|
NEUTRAL_PRIOR_MEAN,
|
||||||
parseNuts,
|
parseNuts,
|
||||||
|
readCapabilities,
|
||||||
type Health,
|
type Health,
|
||||||
type MintDetail,
|
type MintDetail,
|
||||||
type MintInfo,
|
type MintInfo,
|
||||||
@@ -106,6 +107,39 @@ function round1(n: number | null): number | null {
|
|||||||
return n === null ? null : Math.round(n * 10) / 10;
|
return n === null ? null : Math.round(n * 10) / 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The NUT numbers a row publishes.
|
||||||
|
*
|
||||||
|
* `nuts_json` is what the prober wrote and wins; `info.nuts` is the raw NUT-06 object it
|
||||||
|
* was derived from, kept as a fallback for rows written before that column existed. One
|
||||||
|
* function so a list card and a detail page can never read a different answer off the
|
||||||
|
* same row.
|
||||||
|
*/
|
||||||
|
function rowNuts(row: MintRow, info: MintInfo | null): string[] {
|
||||||
|
if (row.nuts_json) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(row.nuts_json) as string[];
|
||||||
|
if (parsed.length > 0) return parsed;
|
||||||
|
} catch {
|
||||||
|
// Fall through to the info object below.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return info ? parseNuts(info.nuts) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One list item.
|
||||||
|
*
|
||||||
|
* The chip fields at the bottom are why this now parses `info_json`. The alternative was
|
||||||
|
* what /mints and /lnurl-mints used to do: fetch `GET /api/mints/:host` once per mint to
|
||||||
|
* read two booleans off each one. That is an acceptable price for a build machine
|
||||||
|
* rendering fifty-five cards once a night and an unacceptable one for every browser that
|
||||||
|
* opens the page, which is what the list has to survive now that it hydrates.
|
||||||
|
*
|
||||||
|
* Facts, not sentences. `capabilities` is two booleans and `mintChip` turns them into
|
||||||
|
* "Melt only" in the reader's language, wherever the card is being drawn. Rendering the
|
||||||
|
* label here would ship one language to twenty-four locales.
|
||||||
|
*/
|
||||||
function toListItem(row: MintRow, agg: AggRow | undefined, mean: number, now: number): MintListItem {
|
function toListItem(row: MintRow, agg: AggRow | undefined, mean: number, now: number): MintListItem {
|
||||||
const base = {
|
const base = {
|
||||||
review_count: agg?.review_count ?? 0,
|
review_count: agg?.review_count ?? 0,
|
||||||
@@ -114,6 +148,15 @@ function toListItem(row: MintRow, agg: AggRow | undefined, mean: number, now: nu
|
|||||||
last_review_at: agg?.last_review_at ?? null,
|
last_review_at: agg?.last_review_at ?? null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const info = parseInfo(row.info_json);
|
||||||
|
// A federation and an LNURL mint have no `info_json` and so get null, which is the
|
||||||
|
// honest value: not "both NUTs are enabled", but "there is nothing here to read".
|
||||||
|
const capabilities = info ? readCapabilities(info.nuts) : null;
|
||||||
|
|
||||||
|
// Only the two facts the LNURL chip is drawn from, not the whole ecosystem blob: this
|
||||||
|
// payload is fetched by every visitor on three pages.
|
||||||
|
const lnurl = row.type === 'lnurl' ? parseEcosystem<LnurlFields>(row) : null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
url: row.url,
|
url: row.url,
|
||||||
host: row.host,
|
host: row.host,
|
||||||
@@ -127,6 +170,14 @@ function toListItem(row: MintRow, agg: AggRow | undefined, mean: number, now: nu
|
|||||||
score: bayesianScore(base, mean, now),
|
score: bayesianScore(base, mean, now),
|
||||||
last_review_at: base.last_review_at,
|
last_review_at: base.last_review_at,
|
||||||
version: row.version,
|
version: row.version,
|
||||||
|
nuts: rowNuts(row, info),
|
||||||
|
capabilities,
|
||||||
|
...(lnurl
|
||||||
|
? {
|
||||||
|
max_withdrawable_msat: lnurl.max_withdrawable_msat ?? null,
|
||||||
|
funding_available: lnurl.funding_available ?? null,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,16 +284,9 @@ export async function getMintDetail(host: string): Promise<MintDetail | null> {
|
|||||||
|
|
||||||
const item = toListItem(row, agg.get(row.url), mean, now);
|
const item = toListItem(row, agg.get(row.url), mean, now);
|
||||||
const info = parseInfo(row.info_json);
|
const info = parseInfo(row.info_json);
|
||||||
|
// `item.nuts` is the same read, through `rowNuts`. It used to be computed a second
|
||||||
let nuts: string[] = [];
|
// time here with a subtly different fallback rule; one function now answers for both.
|
||||||
if (row.nuts_json) {
|
const nuts = item.nuts;
|
||||||
try {
|
|
||||||
nuts = JSON.parse(row.nuts_json) as string[];
|
|
||||||
} catch {
|
|
||||||
nuts = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (nuts.length === 0 && info) nuts = parseNuts(info.nuts);
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Type-specific columns are spread across the payload rather than nested under a key.
|
* Type-specific columns are spread across the payload rather than nested under a key.
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
/** Shapes returned by the API. The web app builds against these. */
|
/** Shapes returned by the API. The web app builds against these. */
|
||||||
|
|
||||||
|
import type { MintCapabilities } from './warnings.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Statuses a listed thing can be in.
|
* Statuses a listed thing can be in.
|
||||||
*
|
*
|
||||||
@@ -35,6 +37,40 @@ export interface MintListItem {
|
|||||||
score: number;
|
score: number;
|
||||||
last_review_at: number | null;
|
last_review_at: number | null;
|
||||||
version: string | null;
|
version: string | null;
|
||||||
|
|
||||||
|
/* ---- card chips ----
|
||||||
|
*
|
||||||
|
* The three fields below exist so a card can be drawn from the list payload alone.
|
||||||
|
* Before them, /mints fetched `GET /api/mints/:host` once per mint at build time just
|
||||||
|
* to read two booleans off each one, which is fine for fifty-five mints on one build
|
||||||
|
* machine and is not fine for every visitor's browser once the list hydrates. They are
|
||||||
|
* facts, never rendered strings: the label a chip prints is decided by `mintChip` in
|
||||||
|
* the reader's own language, on whichever side is drawing the card.
|
||||||
|
*
|
||||||
|
* A federation has no counterpart and needs none — it publishes no capability list, so
|
||||||
|
* `mintChip` returns null for one and always will. See the Fedimint branch of
|
||||||
|
* `getMintWarnings`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NUT numbers this mint publishes, as strings: `["4", "5", "17"]`. Cashu only; empty
|
||||||
|
* for the other ecosystems and for a mint whose `/v1/info` has never been read.
|
||||||
|
*/
|
||||||
|
nuts: string[];
|
||||||
|
/**
|
||||||
|
* NUT-04 and NUT-05 switches, the Cashu chip's only input. null means nothing is
|
||||||
|
* cached for this mint, which is a different fact from "both are on" — see
|
||||||
|
* `readCapabilities`.
|
||||||
|
*/
|
||||||
|
capabilities: MintCapabilities | null;
|
||||||
|
/**
|
||||||
|
* LNURL: the advertised withdraw ceiling, millisatoshi. Optional rather than
|
||||||
|
* `| null`, so this stays exactly what `Partial<LnurlFields>` declares on `MintDetail`
|
||||||
|
* and the two do not have to be kept identical by hand.
|
||||||
|
*/
|
||||||
|
max_withdrawable_msat?: number | null;
|
||||||
|
/** LNURL: whether the last probe reached the mint's funding node. */
|
||||||
|
funding_available?: boolean | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProbeSample {
|
export interface ProbeSample {
|
||||||
|
|||||||
@@ -268,6 +268,20 @@ for (const file of files) {
|
|||||||
// A .ts file under lib/ or scripts/ is island code wholesale.
|
// A .ts file under lib/ or scripts/ is island code wholesale.
|
||||||
const shipsToBrowser = /\/(lib|scripts)\//.test(relative) && relative.endsWith('.ts');
|
const shipsToBrowser = /\/(lib|scripts)\//.test(relative) && relative.endsWith('.ts');
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A page can inline one extra namespace for its own islands, through Base.astro's
|
||||||
|
* `clientNamespaces` prop. The home page does: its grids hydrate from the API and
|
||||||
|
* rewrite their own "All 56 mints" links, whose strings live under `home.` — a
|
||||||
|
* namespace not worth inlining on 1,300 mint pages that never read it.
|
||||||
|
*
|
||||||
|
* Read out of the page rather than listed here, so the prop and this check cannot
|
||||||
|
* disagree. A namespace a page does not actually pass is still a leak.
|
||||||
|
*/
|
||||||
|
const extraNamespaces = new Set(
|
||||||
|
[...(/clientNamespaces=\{\[([^\]]*)\]\}/.exec(source)?.[1] ?? '').matchAll(/'([\w-]+)'/g)]
|
||||||
|
.map((m) => m[1]),
|
||||||
|
);
|
||||||
|
|
||||||
for (const match of source.matchAll(T_CALL)) {
|
for (const match of source.matchAll(T_CALL)) {
|
||||||
const key = match[2];
|
const key = match[2];
|
||||||
used.add(key);
|
used.add(key);
|
||||||
@@ -280,7 +294,7 @@ for (const file of files) {
|
|||||||
for (const match of clientSource.matchAll(T_CALL)) {
|
for (const match of clientSource.matchAll(T_CALL)) {
|
||||||
const key = match[2];
|
const key = match[2];
|
||||||
const namespace = key.split('.')[0];
|
const namespace = key.split('.')[0];
|
||||||
if (!clientNamespaces.has(namespace)) {
|
if (!clientNamespaces.has(namespace) && !extraNamespaces.has(namespace)) {
|
||||||
clientLeaks.push({ key, file: relative, namespace });
|
clientLeaks.push({ key, file: relative, namespace });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-2
@@ -121,13 +121,22 @@ export function missingKeys(): Record<string, string[]> {
|
|||||||
* key that survives is resolved: a key this locale is missing arrives already filled
|
* key that survives is resolved: a key this locale is missing arrives already filled
|
||||||
* with the English string, so the browser needs no fallback catalog and ships exactly
|
* with the English string, so the browser needs no fallback catalog and ships exactly
|
||||||
* one language.
|
* one language.
|
||||||
|
*
|
||||||
|
* `extra` is for a namespace exactly one page's islands need. The home page's grids
|
||||||
|
* hydrate and have to rewrite their own "All 56 mints →" links, which live under
|
||||||
|
* `home.` — a namespace worth about 2KB that every other page, including 1,300 mint
|
||||||
|
* pages, has no use for. Passed per page through `Base.astro`'s `clientNamespaces`
|
||||||
|
* prop, it is inlined where it is read and nowhere else. `check-i18n.mjs` does not know
|
||||||
|
* about this, so a key reached this way must still be in a namespace the checker
|
||||||
|
* accepts, or listed in `CLIENT_NAMESPACES` — see the note there.
|
||||||
*/
|
*/
|
||||||
export function clientCatalog(locale: Locale): Catalog {
|
export function clientCatalog(locale: Locale, extra: readonly string[] = []): Catalog {
|
||||||
const catalog = catalogFor(locale);
|
const catalog = catalogFor(locale);
|
||||||
|
const allowed = new Set<string>([...CLIENT_NAMESPACES, ...extra]);
|
||||||
const out: Catalog = {};
|
const out: Catalog = {};
|
||||||
for (const key of Object.keys(BASE_CATALOG)) {
|
for (const key of Object.keys(BASE_CATALOG)) {
|
||||||
const namespace = key.split('.')[0] ?? '';
|
const namespace = key.split('.')[0] ?? '';
|
||||||
if (!(CLIENT_NAMESPACES as readonly string[]).includes(namespace)) continue;
|
if (!allowed.has(namespace)) continue;
|
||||||
out[key] = catalog[key] ?? BASE_CATALOG[key]!;
|
out[key] = catalog[key] ?? BASE_CATALOG[key]!;
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
|
|||||||
@@ -25,6 +25,14 @@ interface Props {
|
|||||||
description: string;
|
description: string;
|
||||||
current?: 'mints' | 'fedimints' | 'lnurl-mints' | 'reviews' | 'wallets' | 'about';
|
current?: 'mints' | 'fedimints' | 'lnurl-mints' | 'reviews' | 'wallets' | 'about';
|
||||||
mintCount?: number;
|
mintCount?: number;
|
||||||
|
/**
|
||||||
|
* Catalog namespaces this page's islands need on top of `CLIENT_NAMESPACES`.
|
||||||
|
*
|
||||||
|
* The home page passes `['home']`: its three grids refresh from the API and rewrite
|
||||||
|
* their own "All 56 mints →" links, so those strings have to reach the browser. They
|
||||||
|
* are inlined on the one page that reads them rather than on all 1,300.
|
||||||
|
*/
|
||||||
|
clientNamespaces?: readonly string[];
|
||||||
ogType?: string;
|
ogType?: string;
|
||||||
/**
|
/**
|
||||||
* A real page that should not be in the index.
|
* A real page that should not be in the index.
|
||||||
@@ -70,7 +78,7 @@ interface Props {
|
|||||||
const {
|
const {
|
||||||
title, description, current, mintCount, ogType = 'website',
|
title, description, current, mintCount, ogType = 'website',
|
||||||
noindex = false, offGraph = false, schema = [], image = OG_IMAGE,
|
noindex = false, offGraph = false, schema = [], image = OG_IMAGE,
|
||||||
imageAlt,
|
imageAlt, clientNamespaces = [],
|
||||||
} = Astro.props;
|
} = Astro.props;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -118,7 +126,7 @@ const ogAlternates = LOCALES.filter((l) => l.code !== locale).map((l) => l.og);
|
|||||||
* travels in the HTML the page was sending anyway, costs no extra request, and is on
|
* travels in the HTML the page was sending anyway, costs no extra request, and is on
|
||||||
* screen before the first island has finished downloading.
|
* screen before the first island has finished downloading.
|
||||||
*/
|
*/
|
||||||
const i18nPayload = JSON.stringify({ locale, catalog: clientCatalog(locale) }).replace(/</g, '\\u003c');
|
const i18nPayload = JSON.stringify({ locale, catalog: clientCatalog(locale, clientNamespaces) }).replace(/</g, '\\u003c');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The social card, absolute.
|
* The social card, absolute.
|
||||||
|
|||||||
@@ -0,0 +1,312 @@
|
|||||||
|
/**
|
||||||
|
* The mint card, as HTML strings, and the hydration that puts them on a page.
|
||||||
|
*
|
||||||
|
* `MintCard.astro` renders the same card at build time and cannot run in the browser, so
|
||||||
|
* this is its parallel renderer — the same relationship `review-cards.ts` has with the
|
||||||
|
* reviews panel. The two must agree on every class name and every `data-*` attribute,
|
||||||
|
* because the sort, the filter, the rank chips and the shared-element view transitions
|
||||||
|
* on the three index pages all read the DOM rather than any model:
|
||||||
|
*
|
||||||
|
* data-mint-card what the sort collects and the transition arms
|
||||||
|
* data-name / data-domain what the search box matches
|
||||||
|
* data-status "hide offline", and the online/offline counts
|
||||||
|
* data-score / -rating / -reviews the sort keys
|
||||||
|
* data-last-review / -last-online the other two sort keys
|
||||||
|
* data-vt-icon / data-vt-name shared-element names, applied on click
|
||||||
|
*
|
||||||
|
* Why this exists at all: /mints was a snapshot of whatever the API held when `astro
|
||||||
|
* build` ran, and stayed that until the next build. A mint indexed at noon was reviewable
|
||||||
|
* immediately — the 404 resolver saw to that — and simply had no card until the nightly
|
||||||
|
* rebuild. The list now refetches after paint. The prerendered cards stay exactly as they
|
||||||
|
* were: they are the first paint, they are what a crawler and a reader with no JavaScript
|
||||||
|
* get, and this only ever replaces them with something newer.
|
||||||
|
*
|
||||||
|
* Nothing here talks to a relay. Card counts come from the API's ingested aggregates,
|
||||||
|
* which is what they always were; review *bodies* remain a detail-page and /reviews
|
||||||
|
* concern. See docs/dynamic-mint-data.md.
|
||||||
|
*
|
||||||
|
* Everything interpolated goes through `escapeHtml`. A mint's name comes from its own
|
||||||
|
* `/v1/info` — a string an operator controls — and this builds markup with strings.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
baseUrlFromKey,
|
||||||
|
federationIdFromSlug,
|
||||||
|
getMintWarnings,
|
||||||
|
mintChip,
|
||||||
|
type MintListItem,
|
||||||
|
} from '@cashumints/shared';
|
||||||
|
import { apiBase, escapeHtml, iconGradient } from './client';
|
||||||
|
import { targetPath } from './feed-resolve';
|
||||||
|
import { displayDomain, initials, transitionName } from './format';
|
||||||
|
import { localePath, splitLocale } from '../i18n/routing';
|
||||||
|
import type { Locale } from '../i18n/config';
|
||||||
|
import { useI18n } from '../i18n/client';
|
||||||
|
import { formatters, starString, type Formatters } from '../i18n/format';
|
||||||
|
import { chipStrings, statusLabel, warningOptions } from '../i18n/mint';
|
||||||
|
import { initReveal } from '../scripts/reveal';
|
||||||
|
|
||||||
|
export interface CardOptions {
|
||||||
|
/** 1-based position in the default order. Omitted, the card has no rank chip. */
|
||||||
|
rank?: number;
|
||||||
|
/** Entrance delay in ms, for a grid of known size that arrives as one gesture. */
|
||||||
|
revealDelay?: number;
|
||||||
|
/**
|
||||||
|
* Render already revealed, with no entrance.
|
||||||
|
*
|
||||||
|
* Set for a card replacing one the reader is already looking at. `[data-reveal]` is
|
||||||
|
* `opacity: 0` until `.in` lands, so without this a hydration would fade the whole
|
||||||
|
* visible grid back in a second after load — an animation that says "something
|
||||||
|
* changed" about forty cards where at most one did.
|
||||||
|
*/
|
||||||
|
revealed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One card's markup.
|
||||||
|
*
|
||||||
|
* `f` carries both the translator and the locale's number and date formatting, and
|
||||||
|
* `locale` is what prefixes the href. Both are passed in rather than read here, because
|
||||||
|
* a grid renders dozens of these and rebuilding the formatter per card would be the
|
||||||
|
* expensive part of the whole hydration.
|
||||||
|
*/
|
||||||
|
export function mintCardHtml(
|
||||||
|
mint: MintListItem,
|
||||||
|
locale: Locale,
|
||||||
|
f: Formatters,
|
||||||
|
options: CardOptions = {},
|
||||||
|
): string {
|
||||||
|
const t = f.t;
|
||||||
|
const { rank, revealDelay, revealed } = options;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* One card, all three ecosystems — the same reasoning as MintCard.astro.
|
||||||
|
*
|
||||||
|
* A federation has no URL, so its second line is a shortened federation id rather than
|
||||||
|
* `fedimint:aeca6c…`, which is a database key. An LNURL mint's row key carries an
|
||||||
|
* `lnurl:` scheme in front of its URL, so the domain comes out of the key.
|
||||||
|
*/
|
||||||
|
const fedimint = mint.type === 'fedimint';
|
||||||
|
const lnurlBase = baseUrlFromKey(mint.url);
|
||||||
|
const domain = fedimint
|
||||||
|
? federationIdFromSlug(mint.host)
|
||||||
|
: displayDomain(lnurlBase ?? mint.url);
|
||||||
|
const name = fedimint
|
||||||
|
? mint.name ?? t('card.unnamedFederation')
|
||||||
|
: mint.name ?? domain.split('/')[0] ?? domain;
|
||||||
|
|
||||||
|
// Never `API_URL`: that is a build-machine address and a visitor's browser cannot
|
||||||
|
// reach it. `apiBase` is PUBLIC_API_URL, empty in production, which resolves /icons
|
||||||
|
// against whatever origin is serving the page.
|
||||||
|
const icon = mint.icon ? `${apiBase}${mint.icon}` : null;
|
||||||
|
const offline = mint.status === 'offline';
|
||||||
|
const announced = mint.status === 'announced';
|
||||||
|
const href = localePath(targetPath(mint), locale);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The chip, from facts the list payload now carries.
|
||||||
|
*
|
||||||
|
* It used to take one `GET /api/mints/:host` per mint to read these — fine for a build
|
||||||
|
* machine rendering the grid once a night, ruinous as an N+1 in every visitor's
|
||||||
|
* browser. `capabilities` and the two LNURL fields were added to `MintListItem` for
|
||||||
|
* exactly this. A federation has no chip and never will: `mintChip` returns null for
|
||||||
|
* one, because a federation publishes no capability list to draw a claim from.
|
||||||
|
*/
|
||||||
|
const chipSource =
|
||||||
|
mint.capabilities || mint.max_withdrawable_msat !== undefined || mint.funding_available !== undefined
|
||||||
|
? {
|
||||||
|
type: mint.type,
|
||||||
|
status: mint.status,
|
||||||
|
last_online: mint.last_online,
|
||||||
|
capabilities: mint.capabilities ?? null,
|
||||||
|
max_withdrawable_msat: mint.max_withdrawable_msat ?? null,
|
||||||
|
funding_available: mint.funding_available ?? null,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
const chip = chipSource
|
||||||
|
? mintChip(getMintWarnings(chipSource, warningOptions(f)), chipStrings(t))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Falls back to the rating split when there is no distribution, exactly as the build
|
||||||
|
// does for /mints: a 4.6 average is roughly 92% positive, which is what the bar says.
|
||||||
|
const pos =
|
||||||
|
mint.rating_avg === null ? 0 : Math.round(((mint.rating_avg - 1) / 4) * 100);
|
||||||
|
const neg = mint.rating_avg === null ? 0 : 100 - pos;
|
||||||
|
|
||||||
|
const vtIcon = escapeHtml(transitionName('icon', mint.host));
|
||||||
|
const vtName = escapeHtml(transitionName('name', mint.host));
|
||||||
|
|
||||||
|
const iconHtml = icon
|
||||||
|
? `<img class="mc-icon" src="${escapeHtml(icon)}" alt="" width="42" height="42" ` +
|
||||||
|
`loading="lazy" decoding="async" data-vt-icon="${vtIcon}">`
|
||||||
|
: `<span class="mc-icon" style="background:${escapeHtml(iconGradient(domain))}" ` +
|
||||||
|
`aria-hidden="true" data-vt-icon="${vtIcon}">${escapeHtml(initials(name))}</span>`;
|
||||||
|
|
||||||
|
const statsHtml =
|
||||||
|
mint.rating_avg === null
|
||||||
|
? `<span class="mc-none">${escapeHtml(t('card.noRatings'))}</span>`
|
||||||
|
: `<span class="mc-rating">` +
|
||||||
|
`<span class="mc-score">${escapeHtml(f.decimal(mint.rating_avg))}</span>` +
|
||||||
|
`<span class="mc-stars" aria-hidden="true">${starString(mint.rating_avg)}</span>` +
|
||||||
|
`</span>`;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* "Announced" has no "last seen" to print, and printing one anyway is exactly the fake
|
||||||
|
* status this site refuses to show. What it has instead is the fact that nothing has
|
||||||
|
* checked it, said in as many words.
|
||||||
|
*/
|
||||||
|
const last = announced
|
||||||
|
? t('card.notChecked')
|
||||||
|
: offline
|
||||||
|
? mint.last_online
|
||||||
|
? t('card.lastSeen', { when: f.relative(mint.last_online) })
|
||||||
|
: t('card.neverSeen')
|
||||||
|
: mint.last_review_at
|
||||||
|
? t('card.reviewed', { when: f.relative(mint.last_review_at) })
|
||||||
|
: t('card.noReviews');
|
||||||
|
|
||||||
|
const classes = ['mint-card'];
|
||||||
|
if (offline) classes.push('is-offline');
|
||||||
|
if (revealed) classes.push('in');
|
||||||
|
|
||||||
|
return (
|
||||||
|
`<a class="${classes.join(' ')}" href="${escapeHtml(href)}" data-reveal` +
|
||||||
|
(revealDelay === undefined ? '' : ` data-reveal-delay="${revealDelay}"`) +
|
||||||
|
` data-mint-card` +
|
||||||
|
` data-name="${escapeHtml(name.toLowerCase())}"` +
|
||||||
|
` data-domain="${escapeHtml(domain.toLowerCase())}"` +
|
||||||
|
` data-status="${escapeHtml(mint.status)}"` +
|
||||||
|
` data-score="${mint.score}"` +
|
||||||
|
` data-rating="${mint.rating_avg ?? 0}"` +
|
||||||
|
` data-reviews="${mint.review_count}"` +
|
||||||
|
` data-last-review="${mint.last_review_at ?? 0}"` +
|
||||||
|
` data-last-online="${mint.last_online ?? 0}">` +
|
||||||
|
`<div class="mc-top">` +
|
||||||
|
iconHtml +
|
||||||
|
`<span class="mc-id">` +
|
||||||
|
`<span class="mc-name" data-vt-name="${vtName}">${escapeHtml(name)}</span>` +
|
||||||
|
`<span class="mc-domain${fedimint ? ' mono' : ''}">${escapeHtml(domain)}</span>` +
|
||||||
|
`</span>` +
|
||||||
|
(rank === undefined
|
||||||
|
? ''
|
||||||
|
: `<span class="mc-rank${rank === 1 ? ' gold' : ''}">#${rank}</span>`) +
|
||||||
|
`</div>` +
|
||||||
|
`<div class="mc-stats">` +
|
||||||
|
statsHtml +
|
||||||
|
`<span class="mc-reviews">${escapeHtml(t('card.reviews', { n: mint.review_count }))}</span>` +
|
||||||
|
`</div>` +
|
||||||
|
`<div class="mc-bar" aria-hidden="true"><span class="mc-bar-fill">` +
|
||||||
|
(pos > 0 ? `<span class="pos" style="width:${pos}%"></span>` : '') +
|
||||||
|
(neg > 0 ? `<span class="neg" style="width:${neg}%"></span>` : '') +
|
||||||
|
`</span></div>` +
|
||||||
|
`<div class="mc-foot">` +
|
||||||
|
`<span class="mc-dot ${escapeHtml(mint.status)}"></span>` +
|
||||||
|
`<span class="mc-status">${escapeHtml(statusLabel(mint.status, t))}</span>` +
|
||||||
|
(chip ? `<span class="mc-chip ${escapeHtml(chip.severity)}">${escapeHtml(chip.label)}</span>` : '') +
|
||||||
|
`<span class="last">${escapeHtml(last)}</span>` +
|
||||||
|
`</div>` +
|
||||||
|
`</a>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One ecosystem's full listing, or null.
|
||||||
|
*
|
||||||
|
* Null on anything at all going wrong, and the caller's job is then to do nothing: the
|
||||||
|
* prerendered grid is already on screen and correct as of the last build, so a failed
|
||||||
|
* refresh should be invisible rather than an error message about a list the reader can
|
||||||
|
* see. This is the same rule the reviews panel and the pulse ticker follow.
|
||||||
|
*/
|
||||||
|
export async function fetchListing(type: string): Promise<MintListItem[] | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${apiBase}/api/mints?type=${encodeURIComponent(type)}`, {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const items = (await res.json()) as MintListItem[];
|
||||||
|
// A well-formed empty answer is still not a reason to empty a grid that has cards in
|
||||||
|
// it. An API serving nothing is the failure the build gate exists to catch, and a
|
||||||
|
// page that renders it as "no mints" would be this bug wearing a different hat.
|
||||||
|
return Array.isArray(items) && items.length > 0 ? items : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace a grid's cards with freshly rendered ones.
|
||||||
|
*
|
||||||
|
* Cards whose host was already on screen and revealed are rendered revealed, so the
|
||||||
|
* common case — the list is the same list, with newer numbers — is a silent swap rather
|
||||||
|
* than forty cards fading in again. Genuinely new hosts get the ordinary entrance from
|
||||||
|
* `initReveal`, which is also what reveals anything below the fold on scroll.
|
||||||
|
*
|
||||||
|
* Returns the new card elements, in DOM order, for the caller to re-apply its sort and
|
||||||
|
* filter to.
|
||||||
|
*/
|
||||||
|
export function renderMintGrid(
|
||||||
|
grid: HTMLElement,
|
||||||
|
items: MintListItem[],
|
||||||
|
options: { ranked?: boolean; revealDelayStep?: number } = {},
|
||||||
|
): HTMLElement[] {
|
||||||
|
const { ranked = true, revealDelayStep } = options;
|
||||||
|
const t = useI18n();
|
||||||
|
const f = formatters(t);
|
||||||
|
const { locale } = splitLocale(window.location.pathname);
|
||||||
|
|
||||||
|
// Which hosts the reader can already see. Keyed by host rather than by index: the list
|
||||||
|
// may have grown, shrunk or reordered, and the question is per mint.
|
||||||
|
const revealed = new Set<string>();
|
||||||
|
for (const card of grid.querySelectorAll<HTMLAnchorElement>('[data-mint-card]')) {
|
||||||
|
if (card.classList.contains('in')) {
|
||||||
|
const host = card.getAttribute('href')?.split('/').pop();
|
||||||
|
if (host) revealed.add(decodeURIComponent(host));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
grid.innerHTML = items
|
||||||
|
.map((mint, i) =>
|
||||||
|
mintCardHtml(mint, locale, f, {
|
||||||
|
...(ranked ? { rank: i + 1 } : {}),
|
||||||
|
...(revealDelayStep === undefined ? {} : { revealDelay: i * revealDelayStep }),
|
||||||
|
revealed: revealed.has(mint.host),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
initReveal(grid);
|
||||||
|
return [...grid.querySelectorAll<HTMLElement>('[data-mint-card]')];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HydrateOptions {
|
||||||
|
/** `cashu`, `fedimint` or `lnurl`. */
|
||||||
|
type: string;
|
||||||
|
/** The grid to rebuild. */
|
||||||
|
grid: HTMLElement;
|
||||||
|
/** Keep only the first N, for the home page's top-six strips. */
|
||||||
|
limit?: number;
|
||||||
|
ranked?: boolean;
|
||||||
|
revealDelayStep?: number;
|
||||||
|
/** Called with the new cards and the payload they were built from, on success only. */
|
||||||
|
onReplaced?: (cards: HTMLElement[], items: MintListItem[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch one ecosystem and rebuild its grid, after paint.
|
||||||
|
*
|
||||||
|
* Deliberately silent on failure — see `fetchListing`. Deliberately unconditional on
|
||||||
|
* success: the API is the newer of the two by construction, since the prerendered grid
|
||||||
|
* is a copy of what this same endpoint said at build time.
|
||||||
|
*/
|
||||||
|
export async function hydrateMintGrid(options: HydrateOptions): Promise<void> {
|
||||||
|
const { type, grid, limit, ranked, revealDelayStep, onReplaced } = options;
|
||||||
|
|
||||||
|
const all = await fetchListing(type);
|
||||||
|
if (!all) return;
|
||||||
|
const items = limit === undefined ? all : all.slice(0, limit);
|
||||||
|
|
||||||
|
const cards = renderMintGrid(grid, items, {
|
||||||
|
...(ranked === undefined ? {} : { ranked }),
|
||||||
|
...(revealDelayStep === undefined ? {} : { revealDelayStep }),
|
||||||
|
});
|
||||||
|
onReplaced?.(cards, all);
|
||||||
|
}
|
||||||
@@ -98,6 +98,9 @@ export const FIXTURE_MINT: MintDetail = {
|
|||||||
pubkey: '0296d0aa13b6a31cf0cd974249f4c6ed579061a4705ab9a4c1b6b1e1e4d7f6f9',
|
pubkey: '0296d0aa13b6a31cf0cd974249f4c6ed579061a4705ab9a4c1b6b1e1e4d7f6f9',
|
||||||
info: null,
|
info: null,
|
||||||
nuts: ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12'],
|
nuts: ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12'],
|
||||||
|
// Both NUTs published and neither switched off, so this fixture draws no card chip —
|
||||||
|
// which is what a representative healthy mint should look like.
|
||||||
|
capabilities: { mintDisabled: false, meltDisabled: false, mintPublished: true, meltPublished: true },
|
||||||
first_seen: daysAgo(420),
|
first_seen: daysAgo(420),
|
||||||
last_probe: daysAgo(0),
|
last_probe: daysAgo(0),
|
||||||
updated_at: daysAgo(0),
|
updated_at: daysAgo(0),
|
||||||
|
|||||||
@@ -247,6 +247,7 @@ const schema = [
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { wireCopyableIds } from '../../lib/client';
|
import { wireCopyableIds } from '../../lib/client';
|
||||||
|
import { hydrateMintGrid } from '../../lib/mint-cards';
|
||||||
import { useI18n } from '../../i18n/client';
|
import { useI18n } from '../../i18n/client';
|
||||||
import { enterStagger, onLeave, prefersReducedMotion, onReady, swapText } from '../../scripts/reveal';
|
import { enterStagger, onLeave, prefersReducedMotion, onReady, swapText } from '../../scripts/reveal';
|
||||||
|
|
||||||
@@ -267,7 +268,15 @@ const schema = [
|
|||||||
const clearSearch = document.querySelector<HTMLButtonElement>('[data-clear-search]');
|
const clearSearch = document.querySelector<HTMLButtonElement>('[data-clear-search]');
|
||||||
|
|
||||||
if (grid && searchInput && sortSelect && hideOffline) {
|
if (grid && searchInput && sortSelect && hideOffline) {
|
||||||
const cards = [...grid.querySelectorAll<HTMLElement>('[data-mint-card]')];
|
/*
|
||||||
|
* Re-readable, not captured once.
|
||||||
|
*
|
||||||
|
* The grid is rebuilt from the live API a moment after paint (see the bottom of this
|
||||||
|
* block), so a `const cards` snapshot taken at setup would leave every control
|
||||||
|
* sorting and filtering elements that are no longer in the document — the search box
|
||||||
|
* would appear to do nothing at all.
|
||||||
|
*/
|
||||||
|
let cards = [...grid.querySelectorAll<HTMLElement>('[data-mint-card]')];
|
||||||
const num = (card: HTMLElement, key: string) => Number(card.dataset[key] ?? 0);
|
const num = (card: HTMLElement, key: string) => Number(card.dataset[key] ?? 0);
|
||||||
const offlineRank = (card: HTMLElement) => (card.dataset['status'] === 'offline' ? 1 : 0);
|
const offlineRank = (card: HTMLElement) => (card.dataset['status'] === 'offline' ? 1 : 0);
|
||||||
|
|
||||||
@@ -415,6 +424,37 @@ const schema = [
|
|||||||
if (sort && sort in SORTS) sortSelect.value = sort;
|
if (sort && sort in SORTS) sortSelect.value = sort;
|
||||||
if (q || sort) apply();
|
if (q || sort) apply();
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The live list, one fetch after paint.
|
||||||
|
*
|
||||||
|
* Everything above this line operates on the prerendered grid, which is a copy of
|
||||||
|
* what this same endpoint returned when `astro build` ran. That copy is the first
|
||||||
|
* paint, it is what a crawler indexes, and it is the whole page for a reader with no
|
||||||
|
* JavaScript — so it stays, and this only ever replaces it with something newer.
|
||||||
|
* A mint indexed since the last build gets its card here; every card's rating,
|
||||||
|
* review count and status arrive current rather than as of 03:30.
|
||||||
|
*
|
||||||
|
* `requestAnimationFrame` so the fetch is not competing with the first paint it is
|
||||||
|
* improving. Failure is silent by design: `hydrateMintGrid` does nothing at all
|
||||||
|
* unless it has a non-empty list in hand, and a correct-as-of-last-build grid is a
|
||||||
|
* far better answer to a flaky network than an error about a list already on screen.
|
||||||
|
*
|
||||||
|
* `apply()` afterwards re-applies whatever the reader had already set — a search
|
||||||
|
* they typed, a sort they picked, "hide offline" — against the new cards, so the
|
||||||
|
* refresh cannot undo an interaction that happened before it landed.
|
||||||
|
*/
|
||||||
|
const hydrate = (): void => {
|
||||||
|
void hydrateMintGrid({
|
||||||
|
type: 'fedimint',
|
||||||
|
grid: grid!,
|
||||||
|
onReplaced: (fresh) => {
|
||||||
|
cards = fresh;
|
||||||
|
apply();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
requestAnimationFrame(() => hydrate());
|
||||||
|
|
||||||
onLeave(() => {
|
onLeave(() => {
|
||||||
for (const timer of leaving.values()) window.clearTimeout(timer);
|
for (const timer of leaving.values()) window.clearTimeout(timer);
|
||||||
leaving.clear();
|
leaving.clear();
|
||||||
|
|||||||
@@ -100,7 +100,18 @@ const signedBody = t('home.why.signed.body', {
|
|||||||
});
|
});
|
||||||
---
|
---
|
||||||
|
|
||||||
<Base title={t('home.title')} description={description} current="mints">
|
{/*
|
||||||
|
`clientNamespaces` inlines the `home.` catalog on this page and no other. The three
|
||||||
|
grids below refresh from the API after paint and rewrite their own "All 56 mints →"
|
||||||
|
links, so those strings have to reach the browser; 1,300 mint pages have no use for
|
||||||
|
them. See `clientCatalog` in src/i18n/index.ts.
|
||||||
|
*/}
|
||||||
|
<Base
|
||||||
|
title={t('home.title')}
|
||||||
|
description={description}
|
||||||
|
current="mints"
|
||||||
|
clientNamespaces={['home']}
|
||||||
|
>
|
||||||
{/*
|
{/*
|
||||||
The hero sits on an animated colour field: ColorBends from React Bits, ported to
|
The hero sits on an animated colour field: ColorBends from React Bits, ported to
|
||||||
plain WebGL in scripts/color-bends.ts. The wrapper is what the field fills, and it
|
plain WebGL in scripts/color-bends.ts. The wrapper is what the field fills, and it
|
||||||
@@ -180,9 +191,11 @@ const signedBody = t('home.why.signed.body', {
|
|||||||
<div class="sec-head" data-reveal>
|
<div class="sec-head" data-reveal>
|
||||||
<h2 class="sec-title" id="top-mints">{t('home.top.title')}</h2>
|
<h2 class="sec-title" id="top-mints">{t('home.top.title')}</h2>
|
||||||
<span class="sec-sub">{t('home.top.sub')}</span>
|
<span class="sec-sub">{t('home.top.sub')}</span>
|
||||||
<a class="sec-link" href={localePath('/mints', locale)}>{t('home.top.all', { n: mints.length })}</a>
|
<a class="sec-link" href={localePath('/mints', locale)} data-all-link="cashu">
|
||||||
|
{t('home.top.all', { n: mints.length })}
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="mint-grid">
|
<div class="mint-grid" data-home-grid="cashu">
|
||||||
{top.map((mint, i) => (
|
{top.map((mint, i) => (
|
||||||
<MintCard mint={mint} rank={i + 1} sentiment={sentiments[i]} capabilities={capabilities[i]} revealDelay={i * 50} />
|
<MintCard mint={mint} rank={i + 1} sentiment={sentiments[i]} capabilities={capabilities[i]} revealDelay={i * 50} />
|
||||||
))}
|
))}
|
||||||
@@ -200,11 +213,11 @@ const signedBody = t('home.why.signed.body', {
|
|||||||
<div class="sec-head" data-reveal>
|
<div class="sec-head" data-reveal>
|
||||||
<h2 class="sec-title" id="top-fedimints">{t('home.topFedimints.title')}</h2>
|
<h2 class="sec-title" id="top-fedimints">{t('home.topFedimints.title')}</h2>
|
||||||
<span class="sec-sub">{t('home.topFedimints.sub')}</span>
|
<span class="sec-sub">{t('home.topFedimints.sub')}</span>
|
||||||
<a class="sec-link" href={localePath('/fedimints', locale)}>
|
<a class="sec-link" href={localePath('/fedimints', locale)} data-all-link="fedimint">
|
||||||
{t('home.topFedimints.all', { n: federations.length })}
|
{t('home.topFedimints.all', { n: federations.length })}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="mint-grid">
|
<div class="mint-grid" data-home-grid="fedimint">
|
||||||
{topFederations.map((federation, i) => (
|
{topFederations.map((federation, i) => (
|
||||||
<MintCard mint={federation} rank={i + 1} sentiment={fediSentiments[i]} revealDelay={i * 50} />
|
<MintCard mint={federation} rank={i + 1} sentiment={fediSentiments[i]} revealDelay={i * 50} />
|
||||||
))}
|
))}
|
||||||
@@ -225,11 +238,11 @@ const signedBody = t('home.why.signed.body', {
|
|||||||
<div class="sec-head" data-reveal>
|
<div class="sec-head" data-reveal>
|
||||||
<h2 class="sec-title" id="top-lnurl">{t('home.topLnurl.title')}</h2>
|
<h2 class="sec-title" id="top-lnurl">{t('home.topLnurl.title')}</h2>
|
||||||
<span class="sec-sub">{t('home.topLnurl.sub')}</span>
|
<span class="sec-sub">{t('home.topLnurl.sub')}</span>
|
||||||
<a class="sec-link" href={localePath('/lnurl-mints', locale)}>
|
<a class="sec-link" href={localePath('/lnurl-mints', locale)} data-all-link="lnurl">
|
||||||
{t('home.topLnurl.all', { n: stats.lnurl_total })}
|
{t('home.topLnurl.all', { n: stats.lnurl_total })}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="mint-grid">
|
<div class="mint-grid" data-home-grid="lnurl">
|
||||||
{topLnurl.map((mint, i) => (
|
{topLnurl.map((mint, i) => (
|
||||||
<MintCard
|
<MintCard
|
||||||
mint={mint}
|
mint={mint}
|
||||||
@@ -615,14 +628,67 @@ const signedBody = t('home.why.signed.body', {
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { onLeave, onReady, prefersReducedMotion, scrollBehavior } from '../../scripts/reveal';
|
import { hydrateMintGrid } from '../../lib/mint-cards';
|
||||||
|
import { useI18n } from '../../i18n/client';
|
||||||
|
import { onLeave, onReady, prefersReducedMotion, scrollBehavior, swapText } from '../../scripts/reveal';
|
||||||
|
|
||||||
/** One card every six seconds, until the reader touches the track. */
|
/** One card every six seconds, until the reader touches the track. */
|
||||||
const AUTO_MS = 6000;
|
const AUTO_MS = 6000;
|
||||||
/** Matches the track's CSS gap. */
|
/** Matches the track's CSS gap. */
|
||||||
const GAP = 16;
|
const GAP = 16;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The three top-six strips, and the "All N" link over each of them.
|
||||||
|
*
|
||||||
|
* The home page prerenders six cards per ecosystem and a count beside them, from the
|
||||||
|
* same API the index pages read. Between builds the strips went stale in two ways at
|
||||||
|
* once: a new mint could not appear in the top six however good it was, and the count
|
||||||
|
* beside the link said how many mints existed at 03:30. Both are one fetch away.
|
||||||
|
*
|
||||||
|
* The key is the catalog string for that link, which is why this page inlines the
|
||||||
|
* `home.` namespace (see `clientNamespaces` on Base above). `t()` formats the number
|
||||||
|
* for the locale, so "All 1,247 mints" and "All 1.247 mints" both come out right.
|
||||||
|
*
|
||||||
|
* Sentiment bars are the one thing hydration cannot improve here: the prerendered
|
||||||
|
* cards get real rating distributions from a per-mint detail fetch at build time, and
|
||||||
|
* the list payload has no distribution in it. A refreshed card falls back to the
|
||||||
|
* rating proxy the index pages have always used — a 4.6 average reads as 92% positive
|
||||||
|
* — which is a slightly coarser bar on a card whose numbers are otherwise newer.
|
||||||
|
*/
|
||||||
|
const STRIPS = [
|
||||||
|
{ type: 'cashu', key: 'home.top.all' },
|
||||||
|
{ type: 'fedimint', key: 'home.topFedimints.all' },
|
||||||
|
{ type: 'lnurl', key: 'home.topLnurl.all' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const hydrateStrips = (): void => {
|
||||||
|
const t = useI18n();
|
||||||
|
|
||||||
|
for (const strip of STRIPS) {
|
||||||
|
const grid = document.querySelector<HTMLElement>(`[data-home-grid="${strip.type}"]`);
|
||||||
|
// A section with nothing in it is not rendered at all, and a strip that was empty
|
||||||
|
// at build time stays empty until the next one: there is no heading to hang cards
|
||||||
|
// under. Rare, and not worth building a section in JavaScript for.
|
||||||
|
if (!grid) continue;
|
||||||
|
|
||||||
|
void hydrateMintGrid({
|
||||||
|
type: strip.type,
|
||||||
|
grid,
|
||||||
|
limit: 6,
|
||||||
|
// The reveal delay the build gives these six, so a refreshed strip arrives the
|
||||||
|
// same way the prerendered one did.
|
||||||
|
revealDelayStep: 50,
|
||||||
|
onReplaced: (_cards, all) => {
|
||||||
|
const link = document.querySelector<HTMLElement>(`[data-all-link="${strip.type}"]`);
|
||||||
|
if (link) swapText(link, t(strip.key, { n: all.length }));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const setup = (): void => {
|
const setup = (): void => {
|
||||||
|
requestAnimationFrame(() => hydrateStrips());
|
||||||
|
|
||||||
// No track at all means the relays gave the build nothing, and the section is
|
// No track at all means the relays gave the build nothing, and the section is
|
||||||
// showing its empty state instead.
|
// showing its empty state instead.
|
||||||
const found = {
|
const found = {
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
import Base from '../../layouts/Base.astro';
|
import Base from '../../layouts/Base.astro';
|
||||||
import MintCard from '../../components/MintCard.astro';
|
import MintCard from '../../components/MintCard.astro';
|
||||||
import ReviewByUrl from '../../components/ReviewByUrl.astro';
|
import ReviewByUrl from '../../components/ReviewByUrl.astro';
|
||||||
import type { LnurlDetail } from '@cashumints/shared';
|
import { fetchLnurlMints } from '../../lib/api';
|
||||||
import { fetchLnurlMint, fetchLnurlMints } from '../../lib/api';
|
|
||||||
import { localePath, useI18n, type Locale } from '../../i18n';
|
import { localePath, useI18n, type Locale } from '../../i18n';
|
||||||
import { itemListNode } from '../../lib/schema';
|
import { itemListNode } from '../../lib/schema';
|
||||||
import { isIndexableMint } from '../../lib/seo';
|
import { isIndexableMint } from '../../lib/seo';
|
||||||
@@ -32,25 +31,17 @@ const { locale } = Astro.props;
|
|||||||
// `t` formats the numbers inside its own strings, so no separate formatter is needed.
|
// `t` formats the numbers inside its own strings, so no separate formatter is needed.
|
||||||
const t = useI18n(locale);
|
const t = useI18n(locale);
|
||||||
|
|
||||||
const mints = await fetchLnurlMints();
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
The list payload carries no LNURL fields, so the "no withdrawals" and "no mint / melt"
|
One request, no N+1.
|
||||||
chips need each mint's detail. One fetch per mint, at build time, against the API on
|
|
||||||
the same machine — exactly what /mints does for its NUT switches. A mint whose detail
|
This used to be `fetchLnurlMints()` followed by one `fetchLnurlMint(host)` per mint,
|
||||||
cannot be read simply gets no chip.
|
purely to read the two probed facts behind the "no withdrawals" and "no mint / melt"
|
||||||
|
chips: an advertised withdraw ceiling of zero, and an unreachable Lightning node.
|
||||||
|
`MintListItem` carries both now, exactly as it carries the Cashu NUT switches, so the
|
||||||
|
chips come off the same payload as everything else — which is what makes the hydration
|
||||||
|
below possible without an N+1 in every visitor's browser.
|
||||||
*/
|
*/
|
||||||
const details = await Promise.all(
|
const mints = await fetchLnurlMints();
|
||||||
mints.map((mint) => fetchLnurlMint(mint.host).catch(() => null)),
|
|
||||||
);
|
|
||||||
const chipSources = details.map((detail: LnurlDetail | null) =>
|
|
||||||
detail
|
|
||||||
? {
|
|
||||||
max_withdrawable_msat: detail.max_withdrawable_msat ?? null,
|
|
||||||
funding_available: detail.funding_available ?? null,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
);
|
|
||||||
|
|
||||||
const online = mints.filter((m) => m.status === 'online').length;
|
const online = mints.filter((m) => m.status === 'online').length;
|
||||||
const offline = mints.filter((m) => m.status === 'offline').length;
|
const offline = mints.filter((m) => m.status === 'offline').length;
|
||||||
@@ -152,7 +143,16 @@ const schema = [
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="mint-grid" data-mint-grid>
|
<div class="mint-grid" data-mint-grid>
|
||||||
{mints.map((mint, i) => <MintCard mint={mint} rank={i + 1} lnurl={chipSources[i]} />)}
|
{mints.map((mint, i) => (
|
||||||
|
<MintCard
|
||||||
|
mint={mint}
|
||||||
|
rank={i + 1}
|
||||||
|
lnurl={{
|
||||||
|
max_withdrawable_msat: mint.max_withdrawable_msat ?? null,
|
||||||
|
funding_available: mint.funding_available ?? null,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="no-results" data-no-results hidden>
|
<p class="no-results" data-no-results hidden>
|
||||||
@@ -258,6 +258,7 @@ const schema = [
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { wireCopyableIds } from '../../lib/client';
|
import { wireCopyableIds } from '../../lib/client';
|
||||||
|
import { hydrateMintGrid } from '../../lib/mint-cards';
|
||||||
import { useI18n } from '../../i18n/client';
|
import { useI18n } from '../../i18n/client';
|
||||||
import { enterStagger, onLeave, prefersReducedMotion, onReady, swapText } from '../../scripts/reveal';
|
import { enterStagger, onLeave, prefersReducedMotion, onReady, swapText } from '../../scripts/reveal';
|
||||||
|
|
||||||
@@ -280,7 +281,15 @@ const schema = [
|
|||||||
const clearSearch = document.querySelector<HTMLButtonElement>('[data-clear-search]');
|
const clearSearch = document.querySelector<HTMLButtonElement>('[data-clear-search]');
|
||||||
|
|
||||||
if (grid && searchInput && sortSelect && hideOffline) {
|
if (grid && searchInput && sortSelect && hideOffline) {
|
||||||
const cards = [...grid.querySelectorAll<HTMLElement>('[data-mint-card]')];
|
/*
|
||||||
|
* Re-readable, not captured once.
|
||||||
|
*
|
||||||
|
* The grid is rebuilt from the live API a moment after paint (see the bottom of this
|
||||||
|
* block), so a `const cards` snapshot taken at setup would leave every control
|
||||||
|
* sorting and filtering elements that are no longer in the document — the search box
|
||||||
|
* would appear to do nothing at all.
|
||||||
|
*/
|
||||||
|
let cards = [...grid.querySelectorAll<HTMLElement>('[data-mint-card]')];
|
||||||
const num = (card: HTMLElement, key: string) => Number(card.dataset[key] ?? 0);
|
const num = (card: HTMLElement, key: string) => Number(card.dataset[key] ?? 0);
|
||||||
const offlineRank = (card: HTMLElement) => (card.dataset['status'] === 'offline' ? 1 : 0);
|
const offlineRank = (card: HTMLElement) => (card.dataset['status'] === 'offline' ? 1 : 0);
|
||||||
|
|
||||||
@@ -431,6 +440,37 @@ const schema = [
|
|||||||
if (sort && sort in SORTS) sortSelect.value = sort;
|
if (sort && sort in SORTS) sortSelect.value = sort;
|
||||||
if (q || sort) apply();
|
if (q || sort) apply();
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The live list, one fetch after paint.
|
||||||
|
*
|
||||||
|
* Everything above this line operates on the prerendered grid, which is a copy of
|
||||||
|
* what this same endpoint returned when `astro build` ran. That copy is the first
|
||||||
|
* paint, it is what a crawler indexes, and it is the whole page for a reader with no
|
||||||
|
* JavaScript — so it stays, and this only ever replaces it with something newer.
|
||||||
|
* A mint indexed since the last build gets its card here; every card's rating,
|
||||||
|
* review count and status arrive current rather than as of 03:30.
|
||||||
|
*
|
||||||
|
* `requestAnimationFrame` so the fetch is not competing with the first paint it is
|
||||||
|
* improving. Failure is silent by design: `hydrateMintGrid` does nothing at all
|
||||||
|
* unless it has a non-empty list in hand, and a correct-as-of-last-build grid is a
|
||||||
|
* far better answer to a flaky network than an error about a list already on screen.
|
||||||
|
*
|
||||||
|
* `apply()` afterwards re-applies whatever the reader had already set — a search
|
||||||
|
* they typed, a sort they picked, "hide offline" — against the new cards, so the
|
||||||
|
* refresh cannot undo an interaction that happened before it landed.
|
||||||
|
*/
|
||||||
|
const hydrate = (): void => {
|
||||||
|
void hydrateMintGrid({
|
||||||
|
type: 'lnurl',
|
||||||
|
grid: grid!,
|
||||||
|
onReplaced: (fresh) => {
|
||||||
|
cards = fresh;
|
||||||
|
apply();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
requestAnimationFrame(() => hydrate());
|
||||||
|
|
||||||
onLeave(() => {
|
onLeave(() => {
|
||||||
for (const timer of leaving.values()) window.clearTimeout(timer);
|
for (const timer of leaving.values()) window.clearTimeout(timer);
|
||||||
leaving.clear();
|
leaving.clear();
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
import Base from '../../layouts/Base.astro';
|
import Base from '../../layouts/Base.astro';
|
||||||
import MintCard from '../../components/MintCard.astro';
|
import MintCard from '../../components/MintCard.astro';
|
||||||
import ReviewByUrl from '../../components/ReviewByUrl.astro';
|
import ReviewByUrl from '../../components/ReviewByUrl.astro';
|
||||||
import { readCapabilities } from '@cashumints/shared';
|
import { fetchMints } from '../../lib/api';
|
||||||
import { fetchMint, fetchMints } from '../../lib/api';
|
|
||||||
import { localePath, useI18n, type Locale } from '../../i18n';
|
import { localePath, useI18n, type Locale } from '../../i18n';
|
||||||
import { itemListNode } from '../../lib/schema';
|
import { itemListNode } from '../../lib/schema';
|
||||||
import { isIndexableMint } from '../../lib/seo';
|
import { isIndexableMint } from '../../lib/seo';
|
||||||
@@ -20,19 +19,18 @@ const { locale } = Astro.props;
|
|||||||
// `t` formats the numbers inside its own strings, so no separate formatter is needed.
|
// `t` formats the numbers inside its own strings, so no separate formatter is needed.
|
||||||
const t = useI18n(locale);
|
const t = useI18n(locale);
|
||||||
|
|
||||||
|
/*
|
||||||
|
One request, no N+1.
|
||||||
|
|
||||||
|
This used to be `fetchMints()` followed by one `fetchMint(host)` per mint, purely to
|
||||||
|
read the two NUT switches behind the "melt only" and "frozen" chips. `MintListItem`
|
||||||
|
carries `capabilities` now, so the chips come off the same payload as everything else.
|
||||||
|
That was worth doing for the build — fifty-six requests down to one — and it was
|
||||||
|
necessary for the hydration below, which does the same read in every visitor's browser
|
||||||
|
and could not have done it fifty-six times.
|
||||||
|
*/
|
||||||
const mints = await fetchMints();
|
const mints = await fetchMints();
|
||||||
|
|
||||||
/*
|
|
||||||
The list payload carries no NUT information, so the "melt only" and "frozen" chips
|
|
||||||
need each mint's cached info. One detail fetch per mint, at build time, against the
|
|
||||||
API on the same machine: the mint pages already do exactly this in getStaticPaths.
|
|
||||||
A mint whose detail cannot be read simply gets no chip.
|
|
||||||
*/
|
|
||||||
const capabilities = await Promise.all(
|
|
||||||
mints.map((mint) =>
|
|
||||||
fetchMint(mint.host).then((detail) => readCapabilities(detail.info?.nuts)).catch(() => null),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const online = mints.filter((m) => m.status === 'online').length;
|
const online = mints.filter((m) => m.status === 'online').length;
|
||||||
const offline = mints.filter((m) => m.status === 'offline').length;
|
const offline = mints.filter((m) => m.status === 'offline').length;
|
||||||
|
|
||||||
@@ -128,7 +126,7 @@ const schema = [
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="mint-grid" data-mint-grid>
|
<div class="mint-grid" data-mint-grid>
|
||||||
{mints.map((mint, i) => <MintCard mint={mint} rank={i + 1} capabilities={capabilities[i]} />)}
|
{mints.map((mint, i) => <MintCard mint={mint} rank={i + 1} capabilities={mint.capabilities} />)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="no-results" data-no-results hidden>
|
<p class="no-results" data-no-results hidden>
|
||||||
@@ -232,6 +230,7 @@ const schema = [
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { wireCopyableIds } from '../../lib/client';
|
import { wireCopyableIds } from '../../lib/client';
|
||||||
|
import { hydrateMintGrid } from '../../lib/mint-cards';
|
||||||
import { useI18n } from '../../i18n/client';
|
import { useI18n } from '../../i18n/client';
|
||||||
import { enterStagger, onLeave, prefersReducedMotion, onReady, swapText } from '../../scripts/reveal';
|
import { enterStagger, onLeave, prefersReducedMotion, onReady, swapText } from '../../scripts/reveal';
|
||||||
|
|
||||||
@@ -254,7 +253,15 @@ const schema = [
|
|||||||
const clearSearch = document.querySelector<HTMLButtonElement>('[data-clear-search]');
|
const clearSearch = document.querySelector<HTMLButtonElement>('[data-clear-search]');
|
||||||
|
|
||||||
if (grid && searchInput && sortSelect && hideOffline) {
|
if (grid && searchInput && sortSelect && hideOffline) {
|
||||||
const cards = [...grid.querySelectorAll<HTMLElement>('[data-mint-card]')];
|
/*
|
||||||
|
* Re-readable, not captured once.
|
||||||
|
*
|
||||||
|
* The grid is rebuilt from the live API a moment after paint (see the bottom of this
|
||||||
|
* block), so a `const cards` snapshot taken at setup would leave every control
|
||||||
|
* sorting and filtering elements that are no longer in the document — the search box
|
||||||
|
* would appear to do nothing at all.
|
||||||
|
*/
|
||||||
|
let cards = [...grid.querySelectorAll<HTMLElement>('[data-mint-card]')];
|
||||||
const num = (card: HTMLElement, key: string) => Number(card.dataset[key] ?? 0);
|
const num = (card: HTMLElement, key: string) => Number(card.dataset[key] ?? 0);
|
||||||
const offlineRank = (card: HTMLElement) => (card.dataset['status'] === 'offline' ? 1 : 0);
|
const offlineRank = (card: HTMLElement) => (card.dataset['status'] === 'offline' ? 1 : 0);
|
||||||
|
|
||||||
@@ -405,6 +412,37 @@ const schema = [
|
|||||||
if (sort && sort in SORTS) sortSelect.value = sort;
|
if (sort && sort in SORTS) sortSelect.value = sort;
|
||||||
if (q || sort) apply();
|
if (q || sort) apply();
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The live list, one fetch after paint.
|
||||||
|
*
|
||||||
|
* Everything above this line operates on the prerendered grid, which is a copy of
|
||||||
|
* what this same endpoint returned when `astro build` ran. That copy is the first
|
||||||
|
* paint, it is what a crawler indexes, and it is the whole page for a reader with no
|
||||||
|
* JavaScript — so it stays, and this only ever replaces it with something newer.
|
||||||
|
* A mint indexed since the last build gets its card here; every card's rating,
|
||||||
|
* review count and status arrive current rather than as of 03:30.
|
||||||
|
*
|
||||||
|
* `requestAnimationFrame` so the fetch is not competing with the first paint it is
|
||||||
|
* improving. Failure is silent by design: `hydrateMintGrid` does nothing at all
|
||||||
|
* unless it has a non-empty list in hand, and a correct-as-of-last-build grid is a
|
||||||
|
* far better answer to a flaky network than an error about a list already on screen.
|
||||||
|
*
|
||||||
|
* `apply()` afterwards re-applies whatever the reader had already set — a search
|
||||||
|
* they typed, a sort they picked, "hide offline" — against the new cards, so the
|
||||||
|
* refresh cannot undo an interaction that happened before it landed.
|
||||||
|
*/
|
||||||
|
const hydrate = (): void => {
|
||||||
|
void hydrateMintGrid({
|
||||||
|
type: 'cashu',
|
||||||
|
grid: grid!,
|
||||||
|
onReplaced: (fresh) => {
|
||||||
|
cards = fresh;
|
||||||
|
apply();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
requestAnimationFrame(() => hydrate());
|
||||||
|
|
||||||
onLeave(() => {
|
onLeave(() => {
|
||||||
for (const timer of leaving.values()) window.clearTimeout(timer);
|
for (const timer of leaving.values()) window.clearTimeout(timer);
|
||||||
leaving.clear();
|
leaving.clear();
|
||||||
|
|||||||
Reference in New Issue
Block a user