/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>
483 lines
19 KiB
Plaintext
483 lines
19 KiB
Plaintext
---
|
|
import Base from '../../layouts/Base.astro';
|
|
import MintCard from '../../components/MintCard.astro';
|
|
import ReviewByUrl from '../../components/ReviewByUrl.astro';
|
|
import { fetchLnurlMints } from '../../lib/api';
|
|
import { localePath, useI18n, type Locale } from '../../i18n';
|
|
import { itemListNode } from '../../lib/schema';
|
|
import { isIndexableMint } from '../../lib/seo';
|
|
import { localePaths } from '../../i18n/paths';
|
|
|
|
/**
|
|
* The LNURL mint index. /mints' twin, and built from the same parts for the same reason
|
|
* /fedimints is: same container, same card, same search, same result line, same
|
|
* leave-and-return animation.
|
|
*
|
|
* Unlike /fedimints it keeps the full sort set including "recently seen online". That
|
|
* page drops it because a third of its rows have no `last_online` at all — nothing
|
|
* checks those federations, so ordering on the column is a shuffle rather than a sort.
|
|
* An LNURL mint is genuinely probed over HTTPS, exactly as a Cashu mint is, so every row
|
|
* here has a real answer and the sort means what it says.
|
|
*/
|
|
|
|
/** One page per locale: `/lnurl-mints`, `/es/lnurl-mints`, `/nl/lnurl-mints`. */
|
|
export const getStaticPaths = localePaths;
|
|
|
|
interface Props {
|
|
locale: Locale;
|
|
}
|
|
const { locale } = Astro.props;
|
|
|
|
// `t` formats the numbers inside its own strings, so no separate formatter is needed.
|
|
const t = useI18n(locale);
|
|
|
|
/*
|
|
One request, no N+1.
|
|
|
|
This used to be `fetchLnurlMints()` followed by one `fetchLnurlMint(host)` per mint,
|
|
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 mints = await fetchLnurlMints();
|
|
|
|
const online = mints.filter((m) => m.status === 'online').length;
|
|
const offline = mints.filter((m) => m.status === 'offline').length;
|
|
|
|
const description = t('lnurlMints.description', { total: mints.length, online, offline });
|
|
|
|
/*
|
|
* The index, as a list a crawler can read in order.
|
|
*
|
|
* Only the mints whose own pages are indexable: listing a page here while telling a
|
|
* crawler not to index it is a contradiction. The order is the server-rendered one,
|
|
* which is the score order; the sort control rewrites the DOM but not this markup,
|
|
* so `ItemListOrderDescending` describes what shipped.
|
|
*/
|
|
const canonical = new URL(localePath('/lnurl-mints', locale), Astro.site).href;
|
|
const schema = [
|
|
itemListNode({
|
|
canonical,
|
|
fragment: 'lnurl-mints',
|
|
name: t('lnurlMints.heading'),
|
|
items: mints.filter(isIndexableMint).map((mint) => ({
|
|
name: mint.name ?? mint.host,
|
|
url: new URL(localePath(`/lnurl-mint/${mint.host}`, locale), Astro.site).href,
|
|
})),
|
|
}),
|
|
];
|
|
---
|
|
|
|
<Base
|
|
title={t('lnurlMints.title')}
|
|
description={description}
|
|
current="lnurl-mints"
|
|
schema={schema}
|
|
>
|
|
{/*
|
|
The same .page container the home page and the mint pages use, so the heading,
|
|
the controls, the count line and the grid all share one left edge and one width.
|
|
*/}
|
|
<div class="page">
|
|
<div class="index-head">
|
|
<div class="index-head-text">
|
|
<h1>{t('lnurlMints.heading')}</h1>
|
|
<p class="lede">{t('lnurlMints.lede')}</p>
|
|
</div>
|
|
{/*
|
|
The way in for something this site has never seen. Every other route to writing
|
|
a review starts from a page that already exists, which is exactly the case a
|
|
reader with a brand new mint does not have.
|
|
*/}
|
|
<button class="btn primary index-head-action" type="button" data-review-url-open="lnurl">
|
|
{t('reviews.byUrl.open')}
|
|
</button>
|
|
</div>
|
|
|
|
<div class="controls" data-mint-controls>
|
|
<form class="search" role="search" onsubmit="return false">
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
|
<circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" />
|
|
</svg>
|
|
<label class="sr-only" for="mint-search">{t('lnurlMints.search.label')}</label>
|
|
<input
|
|
id="mint-search"
|
|
type="text"
|
|
autocomplete="off"
|
|
placeholder={t('lnurlMints.search.placeholder')}
|
|
data-search-input
|
|
/>
|
|
<kbd aria-hidden="true">/</kbd>
|
|
</form>
|
|
|
|
<div class="control-group">
|
|
<label class="sr-only" for="mint-sort">{t('lnurlMints.sort.label')}</label>
|
|
<div class="select-wrap">
|
|
<select id="mint-sort" data-sort>
|
|
<option value="score">{t('mints.sort.score')}</option>
|
|
<option value="rating">{t('mints.sort.rating')}</option>
|
|
<option value="reviews">{t('mints.sort.reviews')}</option>
|
|
<option value="recent">{t('mints.sort.recent')}</option>
|
|
<option value="seen">{t('mints.sort.seen')}</option>
|
|
</select>
|
|
<svg class="chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true">
|
|
<path d="m6 9 6 6 6-6" />
|
|
</svg>
|
|
</div>
|
|
|
|
{/*
|
|
A pressed pill rather than a native checkbox: it is the same control the
|
|
review filters use, and it is a real button, so it keeps the focus ring.
|
|
*/}
|
|
<button class="toggle-chip" type="button" data-hide-offline aria-pressed="false">
|
|
<span class="toggle-dot" aria-hidden="true"></span>
|
|
{t('lnurlMints.hideOffline')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<p class="result-count" data-result-count aria-live="polite">
|
|
{t('lnurlMints.showing', { total: mints.length, online, offline })}
|
|
</p>
|
|
|
|
<div class="mint-grid" data-mint-grid>
|
|
{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>
|
|
|
|
<p class="no-results" data-no-results hidden>
|
|
{t('lnurlMints.noResults')}
|
|
<button class="link-btn" type="button" data-clear-search>{t('mints.clearSearch')}</button>
|
|
</p>
|
|
|
|
{mints.length === 0 && <p class="no-results">{t('lnurlMints.emptyIndex')}</p>}
|
|
</div>
|
|
|
|
{/* Two steps in one dialog: identify the mint, then the ordinary review form. */}
|
|
<ReviewByUrl type="lnurl" />
|
|
</Base>
|
|
|
|
<style>
|
|
/* Heading and lede on the left, the one primary action on the right, wrapping to
|
|
its own line before the lede has to give up any width. */
|
|
.index-head { margin-bottom: 24px; display: flex; align-items: flex-start; gap: 16px; flex-wrap: wrap; }
|
|
.index-head-text { flex: 1 1 320px; }
|
|
.index-head-action { flex-shrink: 0; }
|
|
.index-head h1 { font-family: var(--display); font-size: 32px; font-weight: 700; letter-spacing: -.02em; line-height: 1.1; }
|
|
.index-head .lede { max-width: 620px; font-size: 15.5px; margin-top: 10px; }
|
|
|
|
.controls { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin-bottom: 14px; }
|
|
.search {
|
|
display: flex; align-items: center; gap: 12px; flex: 1 1 320px; min-width: 240px;
|
|
background: var(--card); border: 1px solid var(--line);
|
|
border-radius: 12px; padding: 10px 16px;
|
|
transition: border-color .15s, box-shadow .15s;
|
|
}
|
|
.search:focus-within { border-color: var(--amber-dim); box-shadow: 0 0 0 3px rgba(240, 169, 59, .08); }
|
|
.search svg { color: var(--faint); flex-shrink: 0; }
|
|
.search input {
|
|
flex: 1; background: none; border: none; outline: none; min-width: 0;
|
|
color: var(--text); font-family: var(--body); font-size: 15px;
|
|
}
|
|
.search input::placeholder { color: var(--faint); }
|
|
.search kbd {
|
|
font-family: var(--mono); font-size: 11.5px; color: var(--faint);
|
|
border: 1px solid var(--line); border-radius: 6px; padding: 2px 7px; flex-shrink: 0;
|
|
}
|
|
|
|
/* Search grows; sort and toggle sit together at the right and wrap as a pair. */
|
|
.control-group { display: flex; gap: 10px; align-items: center; margin-left: auto; flex-wrap: wrap; }
|
|
|
|
.select-wrap { position: relative; display: flex; align-items: center; }
|
|
.select-wrap select {
|
|
appearance: none; -webkit-appearance: none;
|
|
background: var(--card); border: 1px solid var(--line); border-radius: 9px;
|
|
color: var(--text); font-family: var(--body); font-size: 14px;
|
|
padding: 10px 34px 10px 14px; cursor: pointer;
|
|
transition: border-color .15s;
|
|
}
|
|
.select-wrap select:hover { border-color: #38342A; }
|
|
.select-wrap .chevron { position: absolute; right: 12px; color: var(--faint); pointer-events: none; }
|
|
.select-wrap option { background: var(--card); color: var(--text); }
|
|
|
|
.toggle-chip {
|
|
display: inline-flex; align-items: center; gap: 8px;
|
|
font-family: var(--body); font-size: 13.5px; font-weight: 500;
|
|
color: var(--muted); cursor: pointer;
|
|
border: 1px solid var(--line); background: none; border-radius: 999px;
|
|
padding: 9px 15px;
|
|
transition: color .15s, border-color .15s, background .15s;
|
|
}
|
|
.toggle-chip:hover { color: var(--text); border-color: #38342A; }
|
|
.toggle-dot {
|
|
width: 7px; height: 7px; border-radius: 50%; background: var(--faint); flex-shrink: 0;
|
|
transition: background .15s;
|
|
}
|
|
.toggle-chip[aria-pressed='true'] {
|
|
color: #141109; background: var(--amber); border-color: var(--amber); font-weight: 600;
|
|
}
|
|
.toggle-chip[aria-pressed='true'] .toggle-dot { background: #141109; }
|
|
|
|
.result-count { font-size: 13px; color: var(--faint); margin-bottom: 16px; font-family: var(--mono); }
|
|
|
|
/*
|
|
* A card leaving the results fades and settles back a touch, 120ms, then it is
|
|
* hidden and the grid reflows around it. Positions are not animated: the browser's
|
|
* own reflow is instant and honest, and 58 cards sliding to new places would be a
|
|
* light show rather than a search result.
|
|
*/
|
|
.mint-grid :global(.mint-card.leaving) {
|
|
transform: scale(.98);
|
|
transition: opacity 120ms var(--ease-out), transform 120ms var(--ease-out);
|
|
}
|
|
|
|
.no-results { color: var(--muted); font-size: 14.5px; padding: 34px 0; }
|
|
.link-btn {
|
|
background: none; border: none; padding: 0; cursor: pointer;
|
|
font-family: var(--body); font-size: inherit; color: var(--amber);
|
|
border-bottom: 1px solid var(--amber-dim);
|
|
}
|
|
.link-btn:hover { border-bottom-color: var(--amber); }
|
|
|
|
@media (max-width: 640px) {
|
|
/* Sort and toggle take their own row, still sharing the grid's left edge. */
|
|
.control-group { margin-left: 0; width: 100%; }
|
|
.select-wrap, .select-wrap select { flex: 1; }
|
|
}
|
|
</style>
|
|
|
|
<script>
|
|
import { wireCopyableIds } from '../../lib/client';
|
|
import { hydrateMintGrid } from '../../lib/mint-cards';
|
|
import { useI18n } from '../../i18n/client';
|
|
import { enterStagger, onLeave, prefersReducedMotion, onReady, swapText } from '../../scripts/reveal';
|
|
|
|
/** How long a card takes to fade out of the results before it is hidden. */
|
|
const LEAVE_MS = 120;
|
|
|
|
const setup = (): void => {
|
|
wireCopyableIds();
|
|
|
|
// Re-read on every arrival, so the count line under the filters follows the page.
|
|
// `t` formats its own numbers, so the grouping separator follows the locale too.
|
|
const t = useI18n();
|
|
|
|
const grid = document.querySelector<HTMLElement>('[data-mint-grid]');
|
|
const searchInput = document.querySelector<HTMLInputElement>('[data-search-input]');
|
|
const sortSelect = document.querySelector<HTMLSelectElement>('[data-sort]');
|
|
const hideOffline = document.querySelector<HTMLButtonElement>('[data-hide-offline]');
|
|
const countEl = document.querySelector<HTMLElement>('[data-result-count]');
|
|
const noResults = document.querySelector<HTMLElement>('[data-no-results]');
|
|
const clearSearch = document.querySelector<HTMLButtonElement>('[data-clear-search]');
|
|
|
|
if (grid && searchInput && sortSelect && hideOffline) {
|
|
/*
|
|
* 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 offlineRank = (card: HTMLElement) => (card.dataset['status'] === 'offline' ? 1 : 0);
|
|
|
|
const SORTS: Record<string, (a: HTMLElement, b: HTMLElement) => number> = {
|
|
// Default keeps the API's rule: online first, then score. Every other sort
|
|
// ranks offline mints on their merits, so a dead mint with 40 good reviews
|
|
// still shows up under "Most reviewed".
|
|
score: (a, b) => offlineRank(a) - offlineRank(b) || num(b, 'score') - num(a, 'score'),
|
|
rating: (a, b) => num(b, 'rating') - num(a, 'rating') || num(b, 'reviews') - num(a, 'reviews'),
|
|
reviews: (a, b) => num(b, 'reviews') - num(a, 'reviews'),
|
|
recent: (a, b) => num(b, 'lastReview') - num(a, 'lastReview'),
|
|
seen: (a, b) => num(b, 'lastOnline') - num(a, 'lastOnline'),
|
|
};
|
|
|
|
/**
|
|
* One pending fade per card, at most.
|
|
*
|
|
* Typing into the search box calls `apply` on every keystroke, so a card can be
|
|
* told to leave and then to come back before its fade has finished. Cancelling
|
|
* the timer is what keeps those from stacking: the card either goes out once, or
|
|
* never starts.
|
|
*/
|
|
const leaving = new Map<HTMLElement, number>();
|
|
|
|
function show(card: HTMLElement, visible: boolean): void {
|
|
const pending = leaving.get(card);
|
|
if (pending !== undefined) {
|
|
window.clearTimeout(pending);
|
|
leaving.delete(card);
|
|
}
|
|
|
|
if (visible) {
|
|
const wasHidden = card.hidden;
|
|
card.classList.remove('leaving');
|
|
card.hidden = false;
|
|
// Back in the results after being filtered out: a plain fade, no rise. It
|
|
// was here before, it is not new.
|
|
if (wasHidden && !prefersReducedMotion()) {
|
|
card.classList.add('appearing');
|
|
card.addEventListener('animationend', () => card.classList.remove('appearing'), { once: true });
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (card.hidden) return;
|
|
if (prefersReducedMotion()) {
|
|
card.hidden = true;
|
|
return;
|
|
}
|
|
card.classList.add('leaving');
|
|
leaving.set(
|
|
card,
|
|
window.setTimeout(() => {
|
|
leaving.delete(card);
|
|
card.hidden = true;
|
|
card.classList.remove('leaving');
|
|
}, LEAVE_MS),
|
|
);
|
|
}
|
|
|
|
function apply(): void {
|
|
const query = searchInput!.value.trim().toLowerCase();
|
|
// A pasted mint URL should find the mint, so match on the bare domain too.
|
|
const needle = query.replace(/^https?:\/\//, '').replace(/\/+$/, '');
|
|
const hide = hideOffline!.getAttribute('aria-pressed') === 'true';
|
|
|
|
let visible = 0;
|
|
let onlineCount = 0;
|
|
let offlineCount = 0;
|
|
|
|
for (const card of cards) {
|
|
const matches =
|
|
needle === '' ||
|
|
(card.dataset['name'] ?? '').includes(needle) ||
|
|
(card.dataset['domain'] ?? '').includes(needle);
|
|
const isOffline = card.dataset['status'] === 'offline';
|
|
const keep = matches && !(hide && isOffline);
|
|
|
|
show(card, keep);
|
|
if (keep) {
|
|
visible++;
|
|
if (isOffline) offlineCount++;
|
|
else if (card.dataset['status'] === 'online') onlineCount++;
|
|
}
|
|
}
|
|
|
|
const sorted = [...cards].sort(SORTS[sortSelect!.value] ?? SORTS['score']!);
|
|
for (const card of sorted) grid!.append(card);
|
|
|
|
// A rank chip is a claim about position in the default order. It means nothing
|
|
// in a rating sort and nothing in a filtered list, so it is hidden in both.
|
|
const ranked = sortSelect!.value === 'score' && needle === '' && !hide;
|
|
let rank = 0;
|
|
for (const card of sorted) {
|
|
const chip = card.querySelector<HTMLElement>('.mc-rank');
|
|
if (!chip) continue;
|
|
if (!ranked) {
|
|
chip.hidden = true;
|
|
continue;
|
|
}
|
|
rank++;
|
|
chip.hidden = false;
|
|
chip.textContent = `#${rank}`;
|
|
chip.classList.toggle('gold', rank === 1);
|
|
}
|
|
|
|
if (countEl) {
|
|
// The count is the answer to what the reader just typed, so it cross-fades
|
|
// rather than flicking from one number to another.
|
|
swapText(
|
|
countEl,
|
|
visible === cards.length
|
|
? t('lnurlMints.showing', { total: visible, online: onlineCount, offline: offlineCount })
|
|
: t('lnurlMints.showingFiltered', {
|
|
shown: visible,
|
|
total: cards.length,
|
|
online: onlineCount,
|
|
offline: offlineCount,
|
|
}),
|
|
);
|
|
}
|
|
if (noResults) noResults.hidden = visible > 0;
|
|
}
|
|
|
|
searchInput.addEventListener('input', apply);
|
|
sortSelect.addEventListener('change', () => {
|
|
apply();
|
|
// A new order is a new list: it arrives the way the grid arrived, once, and
|
|
// the stagger is capped so the last card is never more than 400ms behind.
|
|
enterStagger(cards.filter((card) => !card.hidden));
|
|
});
|
|
hideOffline.addEventListener('click', () => {
|
|
const on = hideOffline.getAttribute('aria-pressed') === 'true';
|
|
hideOffline.setAttribute('aria-pressed', String(!on));
|
|
apply();
|
|
});
|
|
clearSearch?.addEventListener('click', () => {
|
|
searchInput.value = '';
|
|
searchInput.focus();
|
|
apply();
|
|
});
|
|
|
|
// Deep links from the home page quick filters: /mints?q=…&sort=…
|
|
const params = new URLSearchParams(window.location.search);
|
|
const q = params.get('q');
|
|
const sort = params.get('sort');
|
|
if (q) searchInput.value = q;
|
|
if (sort && sort in SORTS) sortSelect.value = sort;
|
|
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(() => {
|
|
for (const timer of leaving.values()) window.clearTimeout(timer);
|
|
leaving.clear();
|
|
});
|
|
}
|
|
};
|
|
|
|
onReady(setup);
|
|
</script>
|