Expand ecash explorer capabilities

Add Fedimint discovery, dual SQLite/Postgres storage, richer review handling, and generated social imagery.
This commit is contained in:
michilis
2026-08-21 02:10:48 +02:00
parent aa1771ea20
commit 6f17b572b1
80 changed files with 7580 additions and 704 deletions
@@ -0,0 +1,633 @@
---
/**
* One federation. The mint page's layout, with the panels a federation can honestly
* fill and none of the ones it cannot.
*
* What is the same: the identity header, the warning banner slot, the verdict strip,
* the reviews panel (identical component, identical cards, identical write dialog), the
* related ring and the disclaimer.
*
* What is different, and why:
*
* - The NUTs panel becomes a Modules panel. Same rows, same check, but there is no
* disabled state, because a `modules` tag says what a federation runs and nothing
* about whether any of it is switched on.
* - The verdict strip drops the software and limits cells. A federation reports no
* version and publishes no min/max, and a cell reading "unknown" twice is furniture.
* Network and "announced / last confirmed" take their place, which are two things
* this page does know.
* - The invite codes block replaces the copy-URL header action, because a federation
* has no URL to copy and the invite code is what a wallet actually needs.
*/
import Base from '../../../layouts/Base.astro';
import Reviews from '../../../components/Reviews.astro';
import MintLive from '../../../components/MintLive.astro';
import Sparkline from '../../../components/Sparkline.astro';
import CopyableId from '../../../components/CopyableId.astro';
import InviteCodes from '../../../components/InviteCodes.astro';
import MintCard from '../../../components/MintCard.astro';
import {
getMintWarnings, isTestNetwork, type FedimintDetail, type MintListItem,
} from '@cashumints/shared';
import { fetchFedimint, fetchFedimints, iconUrl } from '../../../lib/api';
import { ogImageFor } from '../../../lib/og';
import { fedimintSubject } from '../../../lib/review-subject';
import { breadcrumbNode, mintServiceNode } from '../../../lib/schema';
import { clampWords, cleanMintDescription, isIndexableMint, nameCollisions, seoName } from '../../../lib/seo';
import { bannerHtml } from '../../../lib/mint-state';
import { localModuleName, localNetworkName, moduleRowsHtml, otherModules } from '../../../lib/fedimint-ui';
import { iconGradient, initials, transitionName, truncateMiddle } from '../../../lib/format';
import { localePath, useI18n } from '../../../i18n';
import { formatters, starString } from '../../../i18n/format';
import { statusLabel, warningOptions } from '../../../i18n/mint';
import { localePathsFor } from '../../../i18n/paths';
/** Every federation, in every locale. One API call per federation, shared across the three. */
export async function getStaticPaths() {
const federations = await fetchFedimints();
const details = await Promise.all(
federations.map(async (federation, index) => {
try {
return { detail: await fetchFedimint(federation.host), rank: index + 1 };
} catch {
return null;
}
}),
);
const collisions = nameCollisions(federations);
/*
* The same related ring the mint pages use: position i links to i+1, i+2 and i+3,
* wrapping. Deterministic, never itself, and drawn only from federations whose own
* pages are indexable — a page nobody should index needs no crawl paths out of it.
*/
const ring = federations.filter(isIndexableMint);
const ringIndex = new Map(ring.map((federation, index) => [federation.host, index]));
const related = (host: string): MintListItem[] => {
const at = ringIndex.get(host);
if (at === undefined || ring.length < 2) return [];
return [1, 2, 3]
.filter((step) => step < ring.length)
.map((step) => ring[(at + step) % ring.length]!);
};
return localePathsFor(
details
.filter((d): d is { detail: FedimintDetail; rank: number } => d !== null)
.map(({ detail, rank }) => ({
params: { id: detail.host },
props: {
federation: detail,
rank,
related: related(detail.host),
seoName: seoName(detail.name, detail.host, collisions),
},
})),
);
}
const { federation, rank, related, seoName: titleName, locale } = Astro.props;
const t = useI18n(locale);
const f = formatters(t);
const federationId = federation.federation_id ?? '';
const shortId = truncateMiddle(federationId, 10, 6);
const name = federation.name ?? t('fedimint.unnamed');
const icon = iconUrl(federation.icon);
const status = statusLabel(federation.status, t);
/** Nothing has ever confirmed this federation is running. */
const announced = federation.status === 'announced';
const modules = federation.modules ?? [];
const extraModules = otherModules(modules);
const inviteCodes = federation.invite_codes ?? [];
const network = federation.network;
/* verdict: rating. Identical to the mint page's, because a rating means the same thing. */
const dist = federation.rating_distribution;
const distMax = Math.max(1, ...Object.values(dist));
const ratedCount = Object.values(dist).reduce((a, b) => a + b, 0);
const unrated = federation.review_count - ratedCount;
/*
* The banner.
*
* `getMintWarnings` takes the Fedimint branch on `type`, so this page can only ever
* receive one of two: a real check reported the guardians down, or the announcement is
* a month old and nothing has confirmed it. There is no code path from here to a
* capability warning, which is what keeps this page from inventing one.
*/
const warning = getMintWarnings(federation, warningOptions(f))[0] ?? null;
/*
* SEO. Same templates as a mint page, with the Cashu-only clause swapped out: a
* federation's third fact is its modules and network, never limits or a version.
*/
const hasRating = federation.review_count > 0 && federation.rating_avg !== null;
const offlineDays =
federation.status === 'offline' && federation.last_online !== null
? Math.max(0, Math.floor(Date.now() / 1000 / 86400 - federation.last_online / 86400))
: null;
const title =
offlineDays !== null
? t('seo.mint.titleOffline', { name: titleName, n: offlineDays })
: hasRating
? t('seo.mint.title', {
name: titleName,
rating: f.decimal(federation.rating_avg!),
n: federation.review_count,
})
: t('seo.fedimint.titleNoReviews', { name: titleName });
/*
* The opening sentence is the announcement's own description when it carried one,
* cleaned for a meta tag but never translated. The fallback identifies the federation
* by its short id, which is the one string that unambiguously names it.
*/
const lead =
cleanMintDescription(federation.description) ??
t('seo.desc.fallback', { name, domain: shortId });
const ratingPart = hasRating
? t('seo.desc.rated', { rating: f.decimal(federation.rating_avg!), n: federation.review_count })
: t('seo.desc.noReviews');
// A warning's own meta sentence ("Announced 28 May 2024, never confirmed") replaces
// the plain status: the state is the one thing a search result must not soften.
const moduleList = modules.slice(0, 5).join(', ');
const statusPart = warning
? `${warning.meta}.`
: moduleList
? network
? t('seo.desc.fedModulesNetwork', { status, modules: moduleList, network: localNetworkName(network, f) })
: t('seo.desc.fedModules', { status, modules: moduleList })
: t('seo.desc.status', { status });
const description = clampWords(`${lead} ${ratingPart} ${statusPart}`, 158);
/* The card: one English render shared by all locales, hashed filename per state. */
const ogImage = ogImageFor(federation.host);
const imageAlt = hasRating
? t('seo.mint.ogAlt', { name: titleName, rating: f.decimal(federation.rating_avg!), n: federation.review_count })
: t('seo.mint.ogAltNoReviews', { name: titleName });
/*
* Indexability, on exactly the same rule as a mint page: never confirmed and never
* reviewed is a page with nothing on it that is not also on the next one. It stays
* listed, linked and reviewable; it just carries `noindex, follow` until a check
* reaches it or somebody writes something.
*/
const indexable = isIndexableMint(federation);
const crumbs = [
{ name: t('nav.home'), url: new URL(localePath('/', locale), Astro.site).href },
{ name: t('fedimint.crumb'), url: new URL(localePath('/fedimints', locale), Astro.site).href },
{ name: shortId },
];
const canonical = new URL(localePath(`/fedimint/${federation.host}`, locale), Astro.site).href;
const schema = [
breadcrumbNode(canonical, crumbs),
mintServiceNode({
canonical,
name: titleName,
description,
/*
* The invite code stands in for the service's address, because it is the address:
* it is the string a wallet is given to reach this federation. A federation with no
* code would not be listed at all, so there is always one.
*/
mintUrl: inviteCodes[0] ?? federation.url,
icon: icon ? new URL(icon, Astro.site).href : null,
rating:
ratedCount > 0 && federation.rating_avg !== null
? {
value: Number(federation.rating_avg.toFixed(1)),
ratingCount: ratedCount,
reviewCount: federation.review_count,
}
: null,
}),
];
---
<Base
title={t('fedimint.title', { name: titleName })}
description={description}
current="fedimints"
noindex={!indexable}
schema={schema}
>
<div class="page">
<nav class="crumbs" aria-label={t('fedimint.breadcrumb')}>
<a href={localePath('/', locale)}>{t('nav.home')}</a>
<span class="sep" aria-hidden="true">/</span>
<a href={localePath('/fedimints', locale)}>{t('fedimint.crumb')}</a>
<span class="sep" aria-hidden="true">/</span>
<span class="here mono">{shortId}</span>
</nav>
<div class="mint-head">
{
icon ? (
<img
class="mint-icon"
src={icon}
alt=""
width="72"
height="72"
loading="eager"
decoding="async"
fetchpriority="high"
style={`view-transition-name:${transitionName('icon', federation.host)}`}
/>
) : (
<span
class="mint-icon"
style={`background:${iconGradient(federationId)};view-transition-name:${transitionName('icon', federation.host)}`}
aria-hidden="true"
>
{initials(name)}
</span>
)
}
<div class="mint-id">
<div class="mint-name-row">
<h1 style={`view-transition-name:${transitionName('name', federation.host)}`}>{name}</h1>
<span class:list={['status-chip', federation.status]} data-live-status>
<span class="status-dot"></span><span data-live-status-label>{status}</span>
</span>
</div>
{/*
The federation id, in full behind a copy button. It is the identifier a review
points at and the one thing that unambiguously names this federation, so it
takes the line the mint page gives to a URL.
*/}
<div class="mint-domain">
<span class="fed-label">{t('fedimint.idLabel')}</span>
<CopyableId
value={federationId}
label={shortId}
kind={t('fedimint.kind.federationId')}
/>
</div>
<div class="mint-tags">
<span class="tag">{t('mint.rankByScore', { rank: String(rank) })}</span>
{
/*
The network badge. Anything that is not mainnet is called out rather than
stated flatly: a signet federation's ecash is worth nothing, and someone
scanning the page for the joinable one needs that to be loud.
*/
network && (
<span class:list={['tag', { testnet: isTestNetwork(network) }]}>
{localNetworkName(network, f)}
</span>
)
}
{modules.length > 0 && <span class="tag">{t('fedimint.moduleCount', { n: modules.length })}</span>}
</div>
</div>
<div class="head-actions">
{
inviteCodes[0] && (
<button class="btn" data-copy={inviteCodes[0]} type="button">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<rect x="9" y="9" width="12" height="12" rx="2" /><path d="M5 15V5a2 2 0 0 1 2-2h10" />
</svg>
{t('fedimint.copyInvite')}
</button>
)
}
<button class="btn primary" data-write-open type="button">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M12 20h9" /><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" />
</svg>
{t('mint.writeReview')}
</button>
</div>
</div>
<Fragment set:html={bannerHtml(warning)} />
{/*
Three cells, not four. Rating and activity mean exactly what they mean on a mint
page; the third says who last confirmed this federation and when, which is the
question the software and limits cells cannot be asked here.
*/}
<section class="verdict verdict-3" aria-labelledby="fed-summary">
<h2 class="sr-only" id="fed-summary">{t('mint.summary')}</h2>
<div class="v-cell rating-cell" data-reveal data-reveal-delay="0">
<div class="v-label">{t('mint.rating.label')}</div>
<div class="rating-body">
<div class="rating-figure">
{
federation.rating_avg === null ? (
<>
<div class="v-big">{t('time.none')}</div>
<div class="v-sub">{t('mint.rating.none')}</div>
</>
) : (
<>
<div class="v-big" data-count-up={federation.rating_avg.toFixed(1)} data-count-decimals="1">
{f.decimal(federation.rating_avg)}
</div>
<div class="stars" aria-hidden="true">{starString(federation.rating_avg)}</div>
<div class="v-sub">
{t('mint.rating.reviews', { n: federation.review_count })}
{unrated > 0 && <><br />{t('mint.rating.unrated', { n: unrated })}</>}
</div>
</>
)
}
</div>
<div class="dist" aria-hidden="true">
{
([5, 4, 3, 2, 1] as const).map((n) => {
const count = dist[String(n) as '1' | '2' | '3' | '4' | '5'];
return (
<div class="dist-row">
<span class="n">{n}</span>
<span class="dist-bar">
<span
class:list={['dist-fill', { low: n === 1 }]}
style={`width:${Math.round((count / distMax) * 100)}%`}
/>
</span>
<span class="c">{f.number(count)}</span>
</div>
);
})
}
</div>
</div>
</div>
<div class="v-cell" data-reveal data-reveal-delay="60">
<div class="v-label">{t('mint.activity.label')}</div>
<div class="v-big">{f.shortDuration(federation.last_review_at)}</div>
<div class="v-sub">
{federation.last_review_at ? t('mint.activity.since') : t('mint.activity.noReviews')}
{federation.reviews_90d > 0 && (
<><br />{t('mint.activity.recent', { n: federation.reviews_90d })}</>
)}
</div>
{/*
The sparkline draws real check results and nothing else. A federation nothing
checks has no probe rows at all, so it renders empty rather than flat — and an
empty strip is the honest picture of no data.
*/}
<Sparkline probes={federation.probes_recent} uptime={federation.uptime_30d} />
</div>
<div class="v-cell" data-reveal data-reveal-delay="120">
<div class="v-label">{t('fedimint.confirmed.label')}</div>
<div class="v-mono" data-live-checked>
{
/*
The one cell on this page that could most easily lie, so it is the most
carefully worded. "Announced" gets no date at all beyond the announcement's
own, and says outright that nothing has checked. A confirmed federation
names who confirmed it, because this site did not: it read fedimint.observer.
*/
announced ? (
<>
<b>{t('fedimint.confirmed.never')}</b><br />
<span>
{federation.announced_at
? t('fedimint.announcedOn', { when: f.relative(federation.announced_at) })
: t('fedimint.announcedUnknown')}
</span>
</>
) : (
<>
<b>{f.relative(federation.status === 'offline' ? federation.last_online : federation.last_probe)}</b><br />
<span>
{federation.status_source
? t('fedimint.confirmed.by', { source: federation.status_source })
: t('fedimint.confirmed.unattributed')}
</span>
</>
)
}
</div>
</div>
</section>
<div class="cols">
<Reviews
subject={fedimintSubject(federation)}
reviewCount={federation.review_count}
distribution={federation.rating_distribution}
name={name}
/>
<aside class="side">
{inviteCodes.length > 0 && <InviteCodes codes={inviteCodes} name={name} />}
<div class="panel" data-reveal>
<h2 class="side-title">{t('fedimint.modules.title')}</h2>
{
modules.length === 0 ? (
<p class="side-empty">{t('fedimint.modules.none')}</p>
) : (
<>
<div class="nut-hi" data-live-modules set:html={moduleRowsHtml(modules, f)} />
{extraModules.length > 0 && (
<div class="nut-rest">
{extraModules.map((module) => (
<span class="nut-chip" title={localModuleName(module, f)}>{module}</span>
))}
</div>
)}
<p class="nut-more">{t('fedimint.modules.note')}</p>
</>
)
}
</div>
<div class="panel" data-reveal>
<h2 class="side-title">{t('fedimint.details.title')}</h2>
<div class="kv">
<div class="kv-row">
<span class="kv-k">{t('fedimint.details.federationId')}</span>
<CopyableId
class="kv-v"
value={federationId}
label={truncateMiddle(federationId, 10, 6)}
kind={t('fedimint.kind.federationId')}
/>
</div>
{
network && (
<div class="kv-row">
<span class="kv-k">{t('fedimint.details.network')}</span>
<span class="kv-v">{localNetworkName(network, f)}</span>
</div>
)
}
{
federation.announcer_pubkey && (
<div class="kv-row">
<span class="kv-k">{t('fedimint.details.announcedBy')}</span>
<CopyableId
class="kv-v"
value={federation.announcer_pubkey}
label={truncateMiddle(federation.announcer_pubkey, 10, 6)}
kind={t('mint.kind.mintPubkey')}
/>
</div>
)
}
{
federation.announced_at && (
<div class="kv-row">
<span class="kv-k">{t('fedimint.details.announced')}</span>
<span class="kv-v">{f.date(federation.announced_at)}</span>
</div>
)
}
<div class="kv-row">
<span class="kv-k">{t('mint.details.firstSeen')}</span>
<span class="kv-v">{f.monthYear(federation.first_seen)}</span>
</div>
</div>
</div>
</aside>
</div>
{
related.length > 0 && (
<section class="more-mints" aria-labelledby="more-federations">
<div class="sec-head" data-reveal>
<h2 class="sec-title" id="more-federations">{t('fedimint.more.title')}</h2>
<a class="sec-link" href={localePath('/fedimints', locale)}>{t('fedimint.more.all')}</a>
</div>
<div class="mint-grid">
{related.map((item) => <MintCard mint={item} />)}
</div>
</section>
)
}
<p class="mint-disclaimer">
{t('fedimint.disclaimer')}
<a href={localePath('/disclaimer', locale)}>{t('mint.disclaimerLink')}</a>.
</p>
</div>
<MintLive host={federation.host} />
</Base>
<style>
.more-mints { margin-top: 52px; }
.mint-disclaimer {
margin-top: 30px; padding-top: 18px; border-top: 1px solid var(--line-soft);
font-size: 13px; color: var(--faint); max-width: 720px;
}
.mint-disclaimer a { color: var(--amber); border-bottom: 1px dotted rgba(240, 169, 59, .4); }
.mint-head { display: flex; align-items: flex-start; gap: 20px; flex-wrap: wrap; margin-bottom: 22px; }
.mint-icon {
width: 72px; height: 72px; border-radius: 18px; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
font-family: var(--display); font-weight: 700; font-size: 32px; color: #0E0D0B;
border: 1px solid rgba(255, 255, 255, .14); object-fit: cover; overflow: hidden;
}
.mint-id { min-width: 240px; flex: 1; }
.mint-name-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
h1 { font-family: var(--display); font-size: 32px; font-weight: 700; letter-spacing: -.02em; line-height: 1.1; overflow-wrap: anywhere; }
.mint-domain {
display: flex; align-items: center; gap: 8px; margin-top: 8px;
font-size: 14px; color: var(--muted);
}
.fed-label { font-size: 12px; color: var(--faint); letter-spacing: .04em; text-transform: uppercase; }
.mint-domain :global(.cid) { font-family: var(--mono); font-size: 13.5px; color: var(--muted); }
.mint-domain :global(.cid:hover) { color: var(--amber); }
.mint-tags { display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
.tag { font-size: 12px; font-weight: 500; color: var(--muted); border: 1px solid var(--line); border-radius: 7px; padding: 3px 9px; }
/* Not mainnet: the ecash is play money and the badge has to read that way. */
.tag.testnet { color: var(--amber); border-color: var(--amber-dim); background: rgba(240, 169, 59, .07); font-weight: 600; }
.head-actions { margin-left: auto; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.verdict {
display: grid;
border: 1px solid var(--line); border-radius: var(--radius);
background: var(--card); overflow: hidden; margin-bottom: 28px;
}
/* Three cells rather than the mint page's four, with the rating keeping the width it
needs for its distribution bars. */
.verdict-3 { grid-template-columns: 1.35fr 1fr 1fr; }
.v-cell { padding: 20px; border-right: 1px solid var(--line-soft); position: relative; }
.v-cell:last-child { border-right: none; }
.v-label {
font-size: 11px; font-weight: 600; letter-spacing: .09em; text-transform: uppercase;
color: var(--faint); margin-bottom: 10px; display: flex; align-items: center; gap: 6px;
height: 16px;
}
.v-big { font-family: var(--display); font-size: 34px; font-weight: 700; letter-spacing: -.02em; line-height: 1; }
.v-sub { font-size: 13px; color: var(--muted); margin-top: 6px; }
.rating-body { display: flex; gap: 24px; align-items: flex-start; flex-wrap: wrap; }
.rating-figure { flex: 0 0 auto; }
.rating-body .dist { flex: 1 1 150px; }
.rating-cell .stars { font-size: 15px; margin-top: 7px; }
.cols { display: grid; grid-template-columns: 1fr 372px; gap: 26px; align-items: start; }
.side { display: flex; flex-direction: column; gap: 20px; }
.side .panel { padding: 20px; }
.side-empty { font-size: 13.5px; color: var(--muted); }
.nut-rest { display: flex; flex-wrap: wrap; gap: 6px; }
.nut-chip { font-family: var(--mono); font-size: 10.5px; line-height: 1.5; color: var(--muted); border: 1px solid var(--line); border-radius: 5px; padding: 2px 6px; }
.nut-more { font-size: 12px; color: var(--faint); margin-top: 10px; line-height: 1.55; }
.kv-row :global(.cid.kv-v) { flex: 1; font-size: 12.5px; color: var(--text); }
.kv-row :global(.cid.kv-v:hover) { color: var(--amber); }
@media (max-width: 1020px) {
.cols { grid-template-columns: 1fr; }
.verdict-3 { grid-template-columns: 1fr 1fr; }
.v-cell:nth-child(2) { border-right: none; }
.v-cell:nth-child(1), .v-cell:nth-child(2) { border-bottom: 1px solid var(--line-soft); }
}
@media (max-width: 640px) {
.verdict-3 { grid-template-columns: 1fr; }
.v-cell { border-right: none; border-bottom: 1px solid var(--line-soft); }
.v-cell:last-child { border-bottom: none; }
.head-actions { margin-left: 0; width: 100%; }
.head-actions .btn { flex: 1 1 44%; justify-content: center; }
.page { padding: 20px var(--page-pad) 60px; }
h1 { font-size: 26px; }
}
</style>
<script>
import { wireCopyableIds, wireCopyButtons } from '../../../lib/client';
import { countUp, onReady, whenRevealed } from '../../../scripts/reveal';
onReady(() => {
wireCopyButtons();
wireCopyableIds();
const big = document.querySelector<HTMLElement>('[data-count-up]');
const cell = big?.closest<HTMLElement>('[data-reveal]');
if (big && cell) {
const target = Number(big.dataset['countUp']);
const decimals = Number(big.dataset['countDecimals'] ?? '0');
whenRevealed(cell, () => countUp(big, target, { decimals }));
}
});
</script>
+408
View File
@@ -0,0 +1,408 @@
---
/**
* The federation index. /mints' twin, and deliberately built from the same parts.
*
* Same container, same card, same search, same result line, same leave-and-return
* animation. Two differences, both of them because a federation is a different thing
* rather than because this page is:
*
* - There is no "recently seen online" sort. It reads as a claim about uptime, and
* for the federations nothing checks there is no such reading. Ordering a column
* that is null for a third of the rows is not a sort, it is a shuffle.
* - The count line names the announced ones separately, because "12 federations, 6
* online" invites the reader to assume the other 6 are down.
*/
import Base from '../../layouts/Base.astro';
import MintCard from '../../components/MintCard.astro';
import { fetchFedimints } 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';
/** One page per locale: `/fedimints`, `/es/fedimints`, `/nl/fedimints`. */
export const getStaticPaths = localePaths;
interface Props {
locale: Locale;
}
const { locale } = Astro.props;
const t = useI18n(locale);
const federations = await fetchFedimints();
/*
* No capability fetch, unlike /mints.
*
* That page asks the API for each mint's detail to read NUT-04 and NUT-05 and draw a
* "melt only" chip. A federation publishes no such switches, so there is nothing to
* fetch and no chip to draw — and inventing one from its module list would be exactly
* the fake capability warning this whole feature refuses to ship.
*/
const online = federations.filter((f) => f.status === 'online').length;
const announced = federations.filter((f) => f.status === 'announced').length;
const description = t('fedimints.description', {
total: federations.length,
online,
announced,
});
const canonical = new URL(localePath('/fedimints', locale), Astro.site).href;
const schema = [
itemListNode({
canonical,
fragment: 'fedimints',
name: t('fedimints.heading'),
items: federations.filter(isIndexableMint).map((federation) => ({
name: federation.name ?? federation.host,
url: new URL(localePath(`/fedimint/${federation.host}`, locale), Astro.site).href,
})),
}),
];
---
<Base title={t('fedimints.title')} description={description} current="fedimints" schema={schema}>
<div class="page">
<div class="index-head">
<h1>{t('fedimints.heading')}</h1>
<p class="lede">{t('fedimints.lede')}</p>
</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('fedimints.search.label')}</label>
<input
id="mint-search"
type="text"
autocomplete="off"
placeholder={t('fedimints.search.placeholder')}
data-search-input
/>
<kbd aria-hidden="true">/</kbd>
</form>
<div class="control-group">
<label class="sr-only" for="mint-sort">{t('fedimints.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>
</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>
<button class="toggle-chip" type="button" data-hide-offline aria-pressed="false">
<span class="toggle-dot" aria-hidden="true"></span>
{t('fedimints.hideOffline')}
</button>
</div>
</div>
<p class="result-count" data-result-count aria-live="polite">
{t('fedimints.showing', { total: federations.length, online, announced })}
</p>
{/*
What "announced" means, said once at the top of the list rather than on every
card that carries it. The status is unusual enough that a reader meeting it for
the first time deserves the sentence.
*/}
{
announced > 0 && (
<p class="index-note">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<circle cx="12" cy="12" r="10" /><path d="M12 16v-4M12 8h.01" />
</svg>
<span>{t('fedimints.announcedNote', { n: announced })}</span>
</p>
)
}
<div class="mint-grid" data-mint-grid>
{federations.map((federation, i) => <MintCard mint={federation} rank={i + 1} />)}
</div>
<p class="no-results" data-no-results hidden>
{t('fedimints.noResults')}
<button class="link-btn" type="button" data-clear-search>{t('mints.clearSearch')}</button>
</p>
{
federations.length === 0 && (
<p class="no-results">{t('fedimints.emptyIndex')}</p>
)
}
</div>
</Base>
<style>
.index-head { margin-bottom: 24px; }
.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;
}
.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: 8px; font-family: var(--mono); }
.index-note {
display: flex; gap: 9px; align-items: flex-start; max-width: 720px;
font-size: 13px; color: var(--muted); line-height: 1.55; margin-bottom: 16px;
}
.index-note svg { flex-shrink: 0; margin-top: 2px; color: var(--faint); }
.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) {
.control-group { margin-left: 0; width: 100%; }
.select-wrap, .select-wrap select { flex: 1; }
}
</style>
<script>
import { wireCopyableIds } from '../../lib/client';
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();
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) {
const 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);
/*
* The same four sorts /mints offers, minus "recently seen online".
*
* That one reads `data-last-online`, which is 0 for every federation nothing
* checks — a third of this list — so the sort would silently bury them all under
* a heading that implies they went offline. There is no honest way to order rows
* by a measurement that was never taken.
*/
const SORTS: Record<string, (a: HTMLElement, b: HTMLElement) => number> = {
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'),
};
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;
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 federation id should find its federation, whether or not the reader
// trimmed it to the sixteen characters the card shows.
const needle = query.replace(/^https?:\/\//, '').replace(/\/+$/, '');
const hide = hideOffline!.getAttribute('aria-pressed') === 'true';
let visible = 0;
let onlineCount = 0;
let announcedCount = 0;
for (const card of cards) {
const domain = card.dataset['domain'] ?? '';
const matches =
needle === '' ||
(card.dataset['name'] ?? '').includes(needle) ||
domain.includes(needle) ||
// The card shows the first 16 characters of the id; a pasted full id starts
// with them, so match that way round too.
(domain.length > 0 && needle.startsWith(domain));
const isOffline = card.dataset['status'] === 'offline';
const keep = matches && !(hide && isOffline);
show(card, keep);
if (keep) {
visible++;
if (card.dataset['status'] === 'online') onlineCount++;
else if (card.dataset['status'] === 'announced') announcedCount++;
}
}
const sorted = [...cards].sort(SORTS[sortSelect!.value] ?? SORTS['score']!);
for (const card of sorted) grid!.append(card);
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) {
swapText(
countEl,
visible === cards.length
? t('fedimints.showing', {
total: visible,
online: onlineCount,
announced: announcedCount,
})
: t('fedimints.showingFiltered', {
shown: visible,
total: cards.length,
online: onlineCount,
announced: announcedCount,
}),
);
}
if (noResults) noResults.hidden = visible > 0;
}
searchInput.addEventListener('input', apply);
sortSelect.addEventListener('change', () => {
apply();
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 strip: /fedimints?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();
onLeave(() => {
for (const timer of leaving.values()) window.clearTimeout(timer);
leaving.clear();
});
}
};
onReady(setup);
</script>
+108 -7
View File
@@ -3,8 +3,9 @@ import Base from '../../layouts/Base.astro';
import ColorBends from '../../components/ColorBends.astro';
import MintCard from '../../components/MintCard.astro';
import PulseTicker from '../../components/PulseTicker.astro';
import { fetchHealth, fetchMint, fetchMints, fetchStats } from '../../lib/api';
import { fetchFedimints, fetchHealth, fetchMint, fetchMints, fetchStats } from '../../lib/api';
import { fetchLatestReviews } from '../../lib/nostr-build';
import { feedTargets, targetPath } from '../../lib/feed-resolve';
import { displayDomain, iconGradient, sentiment, shortNpub } from '../../lib/format';
import { profileName, readCapabilities } from '@cashumints/shared';
import { localePath, useI18n, type Locale } from '../../i18n';
@@ -22,15 +23,27 @@ const { locale } = Astro.props;
const t = useI18n(locale);
const f = formatters(t);
const [mints, stats, health] = await Promise.all([fetchMints(), fetchStats(), fetchHealth()]);
const [mints, federations, stats, health] = await Promise.all([
fetchMints(),
fetchFedimints(),
fetchStats(),
fetchHealth(),
]);
const top = mints.slice(0, 6);
const hostByUrl = new Map(mints.map((m) => [m.url, m.host]));
/*
* The reviews strip resolves against both ecosystems, because its heading is "latest
* reviews" and not "latest mint reviews". Federations rarely reach it in practice — the
* junk filter wants a written body and nearly every 38173 review is a bare `[5/5]` —
* but when one does, it is genuinely among the latest and it links to its own page.
*/
const reviewTargets = feedTargets(mints, federations);
/*
* The mint details and the relay read have nothing to say to each other — the reviews
* only need `hostByUrl`, which the mint list above already gave us — so they wait
* together rather than one behind the other.
* only need the target list above — so they wait together rather than one behind the
* other.
*
* The carousel holds a screenful and a bit: enough that scrolling it is worth doing,
* few enough that they are all real, recent, written reviews. Anything the relays
@@ -39,7 +52,7 @@ const hostByUrl = new Map(mints.map((m) => [m.url, m.host]));
const [details, latest] = await Promise.all([
// Distributions give the card sentiment bars real data instead of a rating proxy.
Promise.all(top.map((m) => fetchMint(m.host).catch(() => null))),
fetchLatestReviews(hostByUrl, 10),
fetchLatestReviews(reviewTargets, 10),
]);
const sentiments = details.map((d) => (d ? sentiment(d.rating_distribution) : undefined));
@@ -150,6 +163,49 @@ const signedBody = t('home.why.signed.body', {
</div>
</section>
{/*
The Fedimint strip.
One row, under the mint grid and above the reviews, because that is what it is: a
signpost to a second, smaller index, not a second front page. The home page stays
Cashu-first — the hero, the search, the six ranked cards and the ticker are all
unchanged — and this is the one place that says the other ecosystem exists.
The counts come from `/api/stats`, the same source as the ticker, and they name the
announced federations separately for the same reason the index does: "12 federations,
6 online" invites the reader to assume the other six are down.
*/}
{
stats.fedimint_total > 0 && (
<div class="section eco-strip-wrap">
<a class="eco-strip" href={localePath('/fedimints', locale)} data-reveal>
<span class="eco-mark" aria-hidden="true">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<circle cx="12" cy="6" r="2.4" /><circle cx="5.5" cy="17" r="2.4" /><circle cx="18.5" cy="17" r="2.4" />
<path d="M10.6 8 6.9 14.9M13.4 8l3.7 6.9M8 17.4h8" />
</svg>
</span>
<span class="eco-body">
<span class="eco-title">{t('home.fedimint.title')}</span>
<span class="eco-sub">
{t('home.fedimint.body', {
total: stats.fedimint_total,
online: stats.fedimint_online,
announced: stats.fedimint_announced,
})}
</span>
</span>
<span class="eco-go">
{t('home.fedimint.link')}
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true">
<path d="m9 18 6-6-6-6" />
</svg>
</span>
</a>
</div>
)
}
<section class="section" aria-labelledby="latest-reviews-title" id="latest-reviews">
<div class="sec-head" data-reveal>
<h2 class="sec-title" id="latest-reviews-title">{t('home.latest.title')}</h2>
@@ -189,7 +245,11 @@ const signedBody = t('home.why.signed.body', {
const initial = (name?.[0] ?? review.npub[5] ?? '?').toUpperCase();
const filled = review.rating === null ? 0 : Math.round(review.rating);
return (
<a class="rev-card" href={localePath(`/mint/${review.mint_host}`, locale)} data-reveal>
<a
class="rev-card"
href={localePath(targetPath({ type: review.mint_type, host: review.mint_host }), locale)}
data-reveal
>
<span class="rc-top">
<span class="rc-avatar-wrap">
<span class="rc-avatar" style={`background:${iconGradient(review.pubkey)}`} aria-hidden="true">
@@ -360,6 +420,47 @@ const signedBody = t('home.why.signed.body', {
}
.qf:hover { color: var(--text); border-color: #38342A; background: var(--card); }
/* ---------- fedimint strip ---------- */
/*
* One card wide enough to be a section and short enough not to be one. It sits in a
* `.section` for the page's gutter and max width, but carries no heading of its own:
* a `<h2>` here would give a signpost the same weight as the mint grid above it.
*/
.eco-strip-wrap { padding-top: 0; }
.eco-strip {
display: flex; align-items: center; gap: 16px;
border: 1px solid var(--line); border-radius: var(--radius);
background: var(--card); padding: 18px 20px;
transition: border-color var(--dur-quick), background var(--dur-quick), transform var(--dur-quick);
}
.eco-strip:hover { border-color: #38342A; background: var(--card2); }
.eco-strip:hover .eco-go { color: var(--amber); }
.eco-strip:hover .eco-go svg { transform: translateX(2px); }
.eco-mark {
width: 40px; height: 40px; border-radius: 11px; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
color: var(--amber); background: rgba(240, 169, 59, .09);
border: 1px solid rgba(240, 169, 59, .22);
}
.eco-body { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.eco-title { font-family: var(--display); font-size: 16.5px; font-weight: 600; letter-spacing: -.01em; color: var(--text); }
.eco-sub { font-size: 13.5px; color: var(--muted); }
.eco-go {
margin-left: auto; flex-shrink: 0;
display: inline-flex; align-items: center; gap: 6px;
font-size: 13.5px; font-weight: 500; color: var(--muted);
transition: color var(--dur-quick);
}
.eco-go svg { transition: transform var(--dur-quick); }
@media (max-width: 560px) {
/* The arrow label drops; the whole row is the link and the chevron still points. */
.eco-strip { gap: 13px; }
.eco-go { font-size: 0; gap: 0; }
.eco-go svg { font-size: 15px; }
}
/* ---------- latest reviews carousel ---------- */
/*
* A scroll-snap track, not a JavaScript slider: native touch and trackpad swiping,
+79 -36
View File
@@ -7,12 +7,14 @@ import CopyableId from '../../../components/CopyableId.astro';
import MintQr from '../../../components/MintQr.astro';
import MintCard from '../../../components/MintCard.astro';
import {
getMintWarnings, HIGHLIGHT_NUTS, KNOWN_NUT_COUNT, readCapabilities,
getMintWarnings, HIGHLIGHT_NUTS, KNOWN_NUT_COUNT, parseLimits, readCapabilities,
type MintDetail, type MintListItem,
} from '@cashumints/shared';
import { fetchMint, fetchMints, iconUrl } from '../../../lib/api';
import { ogImageFor } from '../../../lib/og';
import { mintSubject } from '../../../lib/review-subject';
import { breadcrumbNode, mintServiceNode } from '../../../lib/schema';
import { clampWords, isIndexableMint, nameCollisions, seoName } from '../../../lib/seo';
import { clampWords, cleanMintDescription, isIndexableMint, nameCollisions, seoName } from '../../../lib/seo';
import { bannerHtml, limitsHtml, localNutName, nutRowsHtml } from '../../../lib/mint-state';
import {
displayDomain, iconGradient, initials, shortNpub, transitionName, truncateMiddle,
@@ -149,45 +151,79 @@ const isBeta = /\b(beta|alpha|experimental|research project|test|do not|don't st
/*
SEO.
The description is three sentences, and only two of them are ours. The first is the
mint's own description when it has published one, verbatim, in whatever language the
operator wrote it in: it is their copy about their service and translating it would be
putting words in their mouth. Only when there is none does the site write that
sentence itself.
Title first. Three shapes, most urgent wins: a mint that is offline leads with that
(a search result should warn before anyone clicks), a rated mint leads with its
rating, and a mint nobody has reviewed says plainly what the page is.
*/
const ratingPart =
mint.review_count > 0 && mint.rating_avg !== null
? t('mint.description.rated', {
n: mint.review_count,
rating: f.decimal(mint.rating_avg),
})
: t('mint.description.noReviews');
const hasRating = mint.review_count > 0 && mint.rating_avg !== null;
const offlineDays =
offline && mint.last_online !== null
? Math.max(0, Math.floor(Date.now() / 1000 / 86400 - mint.last_online / 86400))
: null;
// The state goes in the description too, so a search result for a melt-only or a
// long-dead mint carries the warning before anyone clicks through to read it, in the
// language of the result they are looking at.
const statusPart = warning
? `${warning.meta}.`
: mint.status === 'online'
? t('mint.description.online')
: t('mint.description.unreachable');
const title =
offlineDays !== null
? t('seo.mint.titleOffline', { name: titleName, n: offlineDays })
: hasRating
? t('seo.mint.title', {
name: titleName,
rating: f.decimal(mint.rating_avg!),
n: mint.review_count,
})
: t('seo.mint.titleNoReviews', { name: titleName });
/*
* The opening sentence. The mint's own description when it published one; otherwise the
* site writes it, and which sentence it writes depends on whether the mint's name says
* anything. "Cashu mint is a Cashu mint at mint.endfiat.money" is what the name-shaped
* sentence produces for a mint called "Cashu mint", so a generic or missing name gets a
* sentence built around the domain instead.
* The description: the mint's own words, then the rating, then the state.
*
* The opening sentence is the mint's own description when it published one — cleaned
* for a meta tag (first sentence, shouting normalised, 70 chars on a word boundary)
* but never translated: it is their copy about their service. Only when there is none
* does the site write that sentence, and that one is localized.
*
* The state goes in too, so a search result for a melt-only or long-dead mint carries
* the warning before anyone clicks through: a warning's own `meta` sentence replaces
* the plain status, and the limits are only quoted for a mint that can actually take
* a deposit.
*/
const lead =
mint.description?.trim() ||
(titleName === domain
? t('mint.description.fallbackDomain', { domain })
: t('mint.description.fallback', { name, domain }));
cleanMintDescription(mint.description) ??
t('seo.desc.fallback', { name: titleName === domain ? name : titleName, domain });
// Clamped on a word boundary: a description cut mid-word reads as broken everywhere
// that does not re-truncate it, which is every link preview and feed reader.
const description = clampWords(`${lead} ${ratingPart} ${statusPart}`, 300);
const ratingPart = hasRating
? t('seo.desc.rated', { rating: f.decimal(mint.rating_avg!), n: mint.review_count })
: t('seo.desc.noReviews');
const limits = parseLimits(info?.nuts, 4);
const limitsText =
!warning && limits && !caps.mintDisabled
? limits.min !== null && limits.max !== null
? t('mint.limits.range', { min: f.compact(limits.min), max: f.compact(limits.max), unit: limits.unit })
: limits.max !== null
? t('mint.limits.upTo', { max: f.compact(limits.max), unit: limits.unit })
: limits.min !== null
? t('mint.limits.from', { min: f.compact(limits.min), unit: limits.unit })
: null
: null;
const statusPart = warning
? `${warning.meta}.`
: limitsText
? t('seo.desc.statusLimits', { status, limits: limitsText })
: t('seo.desc.status', { status });
// Clamped on a word boundary to the length search results actually show; anything
// longer is cut mid-thought by every consumer that matters.
const description = clampWords(`${lead} ${ratingPart} ${statusPart}`, 158);
/*
* The card. One English render shared by every locale (the manifest maps host to a
* content-hashed filename), while title, description and alt text translate. A mint
* whose card failed to render falls back to the sitewide default via Base.
*/
const ogImage = ogImageFor(mint.host);
const imageAlt = hasRating
? t('seo.mint.ogAlt', { name: titleName, rating: f.decimal(mint.rating_avg!), n: mint.review_count })
: t('seo.mint.ogAltNoReviews', { name: titleName });
/*
* Indexability.
@@ -234,12 +270,14 @@ const schema = [
---
<Base
title={t('mint.title', { name: titleName })}
title={title}
description={description}
current="mints"
mintCount={undefined}
noindex={!indexable}
schema={schema}
image={ogImage ?? undefined}
imageAlt={imageAlt}
>
<div class="page">
{/*
@@ -438,7 +476,12 @@ const schema = [
</section>
<div class="cols">
<Reviews mint={mint} />
<Reviews
subject={mintSubject(mint)}
reviewCount={mint.review_count}
distribution={mint.rating_distribution}
name={mint.name ?? mint.host}
/>
<aside class="side">
{
+144 -27
View File
@@ -1,6 +1,6 @@
---
/**
* The global review feed: every mint's reviews in one list, newest first.
* The global review feed: every review of every mint and federation, newest first.
*
* Prerendered from relay data at build time so the page has real reviews for a
* crawler and for a reader with JavaScript off, then the island re-queries the relays
@@ -9,11 +9,12 @@
* review looks and behaves identically wherever you meet it.
*/
import Base from '../../layouts/Base.astro';
import { fetchMints, fetchStats } from '../../lib/api';
import { fetchFedimints, fetchMints, fetchStats } from '../../lib/api';
import { fetchReviewFeed } from '../../lib/nostr-build';
import { feedTargets, targetPath } from '../../lib/feed-resolve';
import { reviewHtml } from '../../lib/review-cards';
import type { LoadedReview } from '../../lib/reviews-client';
import { displayDomain } from '../../lib/format';
import { ECOSYSTEMS } from '@cashumints/shared';
import { localePath, useI18n, type Locale } from '../../i18n';
import { formatters } from '../../i18n/format';
import { localePaths } from '../../i18n/paths';
@@ -32,19 +33,21 @@ const f = formatters(t);
/** How many reviews the build renders. Enough to fill the page above and below the fold. */
const PRERENDER_COUNT = 30;
const [mints, stats] = await Promise.all([fetchMints(), fetchStats()]);
const hostByUrl = new Map(mints.map((mint) => [mint.url, mint.host]));
const feed = await fetchReviewFeed(hostByUrl, PRERENDER_COUNT);
const [mints, federations, stats] = await Promise.all([
fetchMints(),
fetchFedimints(),
fetchStats(),
]);
/**
* Handed to the island as JSON so the browser can resolve a review's `u` tag back to
* a mint without asking the API again. Name and domain are what the card links show.
* Everything the feed can resolve a review to, both ecosystems in one list.
*
* Handed to the island as JSON so the browser can resolve a review back to a listing
* without asking the API again. Name and domain are what the card's link shows; `ref`
* is the federation id a 38173 review points at, read straight out of the row key.
*/
const feedMints = mints.map((mint) => {
const domain = displayDomain(mint.url);
return { url: mint.url, host: mint.host, name: mint.name ?? domain.split('/')[0] ?? domain, domain };
});
const feedMints = feedTargets(mints, federations);
const feed = await fetchReviewFeed(feedMints, PRERENDER_COUNT);
const mintByHost = new Map(feedMints.map((mint) => [mint.host, mint]));
@@ -54,13 +57,26 @@ const mintsPayload = JSON.stringify(feedMints).replace(/</g, '\\u003c');
const cards = feed.map((review) => {
const mint = mintByHost.get(review.mint_host);
return reviewHtml(review as unknown as LoadedReview, f, {
mint: mint ? { href: localePath(`/mint/${mint.host}`, locale), label: mint.domain } : null,
mint: mint ? { href: localePath(targetPath(mint), locale), label: mint.domain } : null,
});
});
/**
* The ecosystem filter's options: "All", then one per ecosystem, in `ECOSYSTEMS` order.
*
* Built from that list rather than written out, so a third ecosystem is a line in
* `shared/src/nostr.ts` and a catalog key — not an edit to this page, its island, or
* the counting below.
*/
const ecosystemFilters = [
{ key: 'all', label: t('feed.ecosystem.all') },
...ECOSYSTEMS.map((type) => ({ key: type, label: t(`feed.ecosystem.${type}`) })),
];
const description = t('feed.description', {
reviews: stats.reviews_total,
mints: stats.mints_total,
mints: stats.cashu_total,
federations: stats.fedimint_total,
});
/**
@@ -111,12 +127,37 @@ const footNote = t('feed.foot', {
</div>
{/*
A text input backed by a datalist rather than a select: 58 options is too many
to scroll, and typing "azz" should be enough. Any substring of a name or a
domain narrows the list, so a partial guess still works.
Which ecosystem, as its own group of pills rather than as more options inside
the rating group: they answer different questions and pressing one must not
clear the other. Same control, same styling, one row down on a narrow screen.
*/}
<div class="filters eco-filters" role="group" aria-label={t('feed.ecosystem.group')}>
{
ecosystemFilters.map((option, index) => (
<button
class="filter"
type="button"
data-eco={option.key}
aria-pressed={index === 0 ? 'true' : 'false'}
>
{/*
No count on "All": the rating group beside it already shows the same
total, and two adjacent pills both reading "All · 180" read as one
control that has been rendered twice.
*/}
{option.label}{option.key !== 'all' && <span data-eco-count={option.key}></span>}
</button>
))
}
</div>
{/*
A text input backed by a datalist rather than a select: seventy options is too
many to scroll, and typing "azz" should be enough. Any substring of a name, a
domain or a federation id narrows the list, so a partial guess still works.
*/}
<div class="mint-filter">
<label class="sr-only" for="feed-mint">{t('feed.filterByMint')}</label>
<label class="sr-only" for="feed-mint">{t('feed.filterBySubject')}</label>
<svg width="15" height="15" 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>
@@ -125,10 +166,10 @@ const footNote = t('feed.foot', {
type="text"
list="feed-mint-options"
autocomplete="off"
placeholder={t('feed.anyMint')}
placeholder={t('feed.anySubject')}
data-mint-filter
/>
<button class="mint-clear" type="button" data-mint-clear hidden aria-label={t('feed.clearMint')}>
<button class="mint-clear" type="button" data-mint-clear hidden aria-label={t('feed.clearSubject')}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M18 6 6 18M6 6l12 12" />
</svg>
@@ -176,6 +217,16 @@ const footNote = t('feed.foot', {
.controls { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin-bottom: 14px; }
.controls .filters { margin-left: 0; }
/*
* A hairline before the ecosystem group, so two runs of identical pills read as two
* questions rather than as one long row of seven. It disappears when the row wraps,
* where the line break already does that job.
*/
.eco-filters { position: relative; padding-left: 12px; }
.eco-filters::before {
content: ''; position: absolute; left: 0; top: 50%; transform: translateY(-50%);
width: 1px; height: 20px; background: var(--line);
}
.mint-filter {
display: flex; align-items: center; gap: 10px; margin-left: auto;
@@ -210,6 +261,11 @@ const footNote = t('feed.foot', {
/* The pager is rebuilt through innerHTML, so its gap marker cannot be scoped. */
:global(.pg-gap) { color: var(--faint); padding: 0 2px; }
@media (max-width: 900px) {
/* Wrapped onto its own line: the divider would be pointing at nothing. */
.eco-filters { padding-left: 0; }
.eco-filters::before { display: none; }
}
@media (max-width: 640px) {
.mint-filter { margin-left: 0; width: 100%; }
}
@@ -222,6 +278,7 @@ const footNote = t('feed.foot', {
import { localeFromPath, localePath } from '../../i18n/routing';
import { enterStagger, onReady, prefersReducedMotion, scrollBehavior } from '../../scripts/reveal';
import { loadFeed, type FeedMint, type FeedReview } from '../../lib/feed-client';
import { targetPath } from '../../lib/feed-resolve';
import { applyProfiles, isWritten, reviewHtml, summaryHtml, type MintRef } from '../../lib/review-cards';
import { loadProfiles } from '../../lib/reviews-client';
@@ -243,6 +300,7 @@ const footNote = t('feed.foot', {
const list = document.querySelector<HTMLElement>('[data-feed-items]');
const controls = document.querySelector<HTMLElement>('[data-feed-controls]');
const ecoGroup = document.querySelector<HTMLElement>('.eco-filters');
const countEl = document.querySelector<HTMLElement>('[data-feed-count]');
const pagerWrap = document.querySelector<HTMLElement>('[data-feed-pager]');
const mintInput = document.querySelector<HTMLInputElement>('[data-mint-filter]');
@@ -257,6 +315,14 @@ const footNote = t('feed.foot', {
let all: FeedReview[] = [];
let rating: 'all' | '5' | 'critical' = 'all';
/**
* Which ecosystem is showing: `all`, or a `mint_type` value.
*
* A string rather than a union, because the options are generated from
* `ECOSYSTEMS` in the frontmatter and this has to accept whatever is in that list.
* A third ecosystem needs no change here at all.
*/
let ecosystem = 'all';
let mintQuery = '';
let page = 1;
/** Day summaries the reader has opened, so a filter or page change does not shut them. */
@@ -269,6 +335,7 @@ const footNote = t('feed.foot', {
function filtered(): FeedReview[] {
const needle = mintQuery.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/+$/, '');
return all.filter((review) => {
if (ecosystem !== 'all' && review.mint_type !== ecosystem) return false;
if (rating === '5' && review.rating !== 5) return false;
if (rating === 'critical' && (review.rating === null || review.rating > 2)) return false;
if (needle === '') return true;
@@ -291,7 +358,9 @@ const footNote = t('feed.foot', {
const pathLocale = localeFromPath(window.location.pathname);
const mintRef = (review: FeedReview): MintRef => ({
href: localePath(`/mint/${review.mint_host}`, pathLocale),
// One route per ecosystem, decided by the same helper the build uses, so a card
// cannot link to /mint/fed-… after the island redraws it.
href: localePath(targetPath({ type: review.mint_type, host: review.mint_host }), pathLocale),
label: review.mint_domain,
});
@@ -422,13 +491,30 @@ const footNote = t('feed.foot', {
}
function updateCounts(): void {
/*
* The rating counts are counts within the chosen ecosystem, not across the whole
* feed. Pressing "Fedimint" and still reading "5 stars · 380" would be counting
* reviews the list is not going to show.
*/
const inEcosystem = all.filter((r) => ecosystem === 'all' || r.mint_type === ecosystem);
const set = (key: string, value: number) => {
const el = controls!.querySelector(`[data-count="${key}"]`);
if (el) el.textContent = ` · ${f.number(value)}`;
};
set('all', all.length);
set('5', all.filter((r) => r.rating === 5).length);
set('critical', all.filter((r) => r.rating !== null && r.rating <= 2).length);
set('all', inEcosystem.length);
set('5', inEcosystem.filter((r) => r.rating === 5).length);
set('critical', inEcosystem.filter((r) => r.rating !== null && r.rating <= 2).length);
// The ecosystem counts are the other way round: each one counts its whole
// ecosystem, so the group says how much there is of each rather than reacting to
// the rating filter beside it.
for (const button of ecoGroup?.querySelectorAll<HTMLElement>('[data-eco]') ?? []) {
const key = button.dataset['eco'] ?? 'all';
// "All" has no count slot, by design: see the comment on the markup.
const slot = button.querySelector('[data-eco-count]');
if (slot) slot.textContent = ` · ${f.number(all.filter((r) => r.mint_type === key).length)}`;
}
}
/* ---------- loading ---------- */
@@ -470,7 +556,22 @@ const footNote = t('feed.foot', {
/* ---------- events ---------- */
controls.addEventListener('click', (event) => {
const button = (event.target as HTMLElement).closest<HTMLButtonElement>('[data-filter]');
const target = event.target as HTMLElement;
const ecoButton = target.closest<HTMLButtonElement>('[data-eco]');
if (ecoButton) {
ecosystem = ecoButton.dataset['eco'] ?? 'all';
page = 1;
for (const other of ecoGroup?.querySelectorAll('[data-eco]') ?? []) {
other.setAttribute('aria-pressed', String(other === ecoButton));
}
render(true);
// The rating counts are scoped to the ecosystem, so they move with it.
updateCounts();
return;
}
const button = target.closest<HTMLButtonElement>('[data-filter]');
if (!button) return;
rating = button.dataset['filter'] as typeof rating;
page = 1;
@@ -531,14 +632,30 @@ const footNote = t('feed.foot', {
void load();
});
const params = new URLSearchParams(window.location.search);
// A mint can be preselected from a link: /reviews?mint=mint.example.com
const preset = new URLSearchParams(window.location.search).get('mint');
const preset = params.get('mint');
if (preset) {
mintInput.value = preset;
mintQuery = preset;
if (mintClear) mintClear.hidden = false;
}
// And an ecosystem: /reviews?type=fedimint, which is what the federation pages and
// the home page strip link to. An unknown value is ignored rather than showing an
// empty feed for a filter with no button pressed to explain it.
const presetType = params.get('type');
const ecoButton = presetType
? ecoGroup?.querySelector<HTMLButtonElement>(`[data-eco="${CSS.escape(presetType)}"]`)
: null;
if (ecoButton) {
ecosystem = presetType!;
for (const other of ecoGroup?.querySelectorAll('[data-eco]') ?? []) {
other.setAttribute('aria-pressed', String(other === ecoButton));
}
}
void load();
}
};
+28 -2
View File
@@ -14,7 +14,7 @@
* /404 is deliberately absent, in every locale.
*/
import type { APIRoute } from 'astro';
import { fetchMints } from '../lib/api';
import { fetchFedimints, fetchMints } from '../lib/api';
import { alternates } from '../i18n/routing';
import { isIndexableMint } from '../lib/seo';
import { DEFAULT_LOCALE, LOCALE_CODES } from '../i18n/config';
@@ -36,6 +36,12 @@ interface Entry {
const STATIC_PAGES: Array<Omit<Entry, 'lastmod'>> = [
{ path: '/', changefreq: 'daily', priority: 1.0 },
{ path: '/mints', changefreq: 'daily', priority: 0.9 },
/*
* Below /mints, above /reviews. There are an order of magnitude fewer federations
* than mints and this site's centre of gravity is still Cashu, so the index that
* changes most and matters most keeps the higher number.
*/
{ path: '/fedimints', changefreq: 'daily', priority: 0.8 },
{ path: '/reviews', changefreq: 'daily', priority: 0.7 },
{ path: '/wallets', changefreq: 'monthly', priority: 0.5 },
{ path: '/about', changefreq: 'monthly', priority: 0.5 },
@@ -53,6 +59,9 @@ const STATIC_PAGES: Array<Omit<Entry, 'lastmod'>> = [
*/
const MINT_PRIORITY = 0.8;
/** One notch below a mint page, for the same reason /fedimints sits below /mints. */
const FEDIMINT_PRIORITY = 0.7;
/**
* Non-default locales sit slightly below their English counterpart, which is the same
* thing `x-default` says in the alternate set: when a crawler has to pick one, pick
@@ -74,7 +83,7 @@ export const GET: APIRoute = async ({ site }) => {
const origin = site ?? new URL('https://cashumints.space');
const builtAt = Math.floor(Date.now() / 1000);
const mints = await fetchMints();
const [mints, federations] = await Promise.all([fetchMints(), fetchFedimints()]);
const entries: Entry[] = [
...STATIC_PAGES.map((page) => ({ ...page, lastmod: builtAt })),
@@ -95,6 +104,23 @@ export const GET: APIRoute = async ({ site }) => {
changefreq: 'weekly' as const,
priority: MINT_PRIORITY,
})),
/*
* Federations, on exactly the same rule: only the ones whose own pages are
* indexable, which `isIndexableMint` decides for both and `check-hreflang.mjs`
* verifies against the built HTML.
*
* `lastmod` differs in one way worth noting. A federation that no check has ever
* confirmed has no `last_online`, so its date comes from its newest review or, with
* neither, from the build — and such a page is unindexable anyway, so that fallback
* is unreachable in practice rather than merely unlikely.
*/
...federations.filter(isIndexableMint).map((federation) => ({
path: `/fedimint/${federation.host}`,
lastmod:
Math.max(federation.last_review_at ?? 0, federation.last_online ?? 0) || builtAt,
changefreq: 'weekly' as const,
priority: FEDIMINT_PRIORITY,
})),
];
/**