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
+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,