Expand i18n locales and improve reviews UI

Add many new language packs with RTL support, refresh brand assets, and harden review rendering with tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
michilis
2026-08-21 05:06:58 +02:00
co-authored by Cursor
parent 1c5df18e81
commit 47e6537dde
51 changed files with 15291 additions and 246 deletions
+24 -80
View File
@@ -3,7 +3,7 @@ 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 { fetchFedimints, fetchHealth, fetchMint, fetchMints, fetchStats } from '../../lib/api';
import { fetchFedimint, 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';
@@ -31,6 +31,7 @@ const [mints, federations, stats, health] = await Promise.all([
]);
const top = mints.slice(0, 6);
const topFederations = federations.slice(0, 6);
/*
* The reviews strip resolves against both ecosystems, because its heading is "latest
@@ -49,15 +50,18 @@ const reviewTargets = feedTargets(mints, federations);
* few enough that they are all real, recent, written reviews. Anything the relays
* cannot fill is simply fewer cards, and the carousel's controls disappear with them.
*/
const [details, latest] = await Promise.all([
const [details, fediDetails, 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))),
Promise.all(topFederations.map((federation) => fetchFedimint(federation.host).catch(() => null))),
fetchLatestReviews(reviewTargets, 10),
]);
const sentiments = details.map((d) => (d ? sentiment(d.rating_distribution) : undefined));
// Same details, read a second way: the card's "melt only" or "frozen" chip.
const capabilities = details.map((d) => (d ? readCapabilities(d.info?.nuts) : null));
// No capability read for federations: they publish no NUT switches to draw a chip from.
const fediSentiments = fediDetails.map((d) => (d ? sentiment(d.rating_distribution) : undefined));
const description = t('home.description', {
mints: stats.mints_total,
@@ -164,45 +168,26 @@ const signedBody = t('home.why.signed.body', {
</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.
The Fedimint grid: the mint grid's twin, under it and above the reviews. The same
ranked cards as /fedimints, with the same real sentiment bars the Cashu grid gets,
so a reader moving between the two sections does not have to re-learn a card.
*/}
{
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>
topFederations.length > 0 && (
<section class="section" aria-labelledby="top-fedimints">
<div class="sec-head" data-reveal>
<h2 class="sec-title" id="top-fedimints">{t('home.topFedimints.title')}</h2>
<span class="sec-sub">{t('home.topFedimints.sub')}</span>
<a class="sec-link" href={localePath('/fedimints', locale)}>
{t('home.topFedimints.all', { n: federations.length })}
</a>
</div>
<div class="mint-grid">
{topFederations.map((federation, i) => (
<MintCard mint={federation} rank={i + 1} sentiment={fediSentiments[i]} revealDelay={i * 50} />
))}
</div>
</section>
)
}
@@ -420,47 +405,6 @@ 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,