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
@@ -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
|
||||
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>
|
||||
<h2 class="sec-title" id="top-mints">{t('home.top.title')}</h2>
|
||||
<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 class="mint-grid">
|
||||
<div class="mint-grid" data-home-grid="cashu">
|
||||
{top.map((mint, i) => (
|
||||
<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>
|
||||
<h2 class="sec-title" id="top-fedimints">{t('home.topFedimints.title')}</h2>
|
||||
<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 })}
|
||||
</a>
|
||||
</div>
|
||||
<div class="mint-grid">
|
||||
<div class="mint-grid" data-home-grid="fedimint">
|
||||
{topFederations.map((federation, i) => (
|
||||
<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>
|
||||
<h2 class="sec-title" id="top-lnurl">{t('home.topLnurl.title')}</h2>
|
||||
<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 })}
|
||||
</a>
|
||||
</div>
|
||||
<div class="mint-grid">
|
||||
<div class="mint-grid" data-home-grid="lnurl">
|
||||
{topLnurl.map((mint, i) => (
|
||||
<MintCard
|
||||
mint={mint}
|
||||
@@ -615,14 +628,67 @@ const signedBody = t('home.why.signed.body', {
|
||||
</style>
|
||||
|
||||
<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. */
|
||||
const AUTO_MS = 6000;
|
||||
/** Matches the track's CSS gap. */
|
||||
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 => {
|
||||
requestAnimationFrame(() => hydrateStrips());
|
||||
|
||||
// No track at all means the relays gave the build nothing, and the section is
|
||||
// showing its empty state instead.
|
||||
const found = {
|
||||
|
||||
Reference in New Issue
Block a user