Hydrate the mint lists from the live API after paint.
/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 at once — the 404 resolver saw to that — and simply had no card until 03:30. Every card's rating, review count and status were as stale as the page. The three index pages and the home page's three top-six strips now refetch `GET /api/mints?type=…` once, after paint, and rebuild their grids. The prerendered cards stay: they are the first paint, what a crawler indexes, and the whole page without JavaScript. Hydration only ever replaces them with something newer, and never with nothing — neither a failed fetch nor a well-formed empty array touches a grid that has cards in it. To make that affordable, the list payload grew the facts a chip is drawn from: `nuts`, `capabilities`, and the two probed LNURL fields. /mints and /lnurl-mints were fetching `GET /api/mints/:host` once per mint at build time to read two booleans off each; that N+1 is gone from both, which takes the build from fifty-six requests to one and is what makes the same read possible in a browser. Additive: `MintDetail` already had all four. web/src/lib/mint-cards.ts is MintCard.astro's parallel renderer, the same relationship review-cards.ts has with the reviews panel. Same classes, same data-* attributes — the sort, the search, the rank chips and the shared-element view transitions all read the DOM — and the same i18n, through the page's own inlined catalog rather than a build-time one. Base.astro gained `clientNamespaces`, so the home page can inline the `home.` catalog its strips need to rewrite "All 60 mints →" without putting 2KB of marketing copy on 1,300 mint pages. check-i18n reads the prop off the page, so the two cannot disagree. Verified in Chromium against the built site: 60 prerendered cards become 61 including a mint inserted after the build; sort, search and hide-offline operate on the new cards; /es/mints renders "En línea", "54 reseñas", "4,9" and "Solo fundir"; JavaScript disabled still shows all 60; an aborted or empty API leaves the grid alone; and a navigation away and back re-hydrates. 2016 pages build, link and hreflang checks pass, 30 web tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
060c7f1a59
commit
14548179a0
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user