/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>
823 lines
34 KiB
Plaintext
823 lines
34 KiB
Plaintext
---
|
|
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 {
|
|
fetchFedimint, fetchFedimints, fetchHealth, fetchLnurlMint, fetchLnurlMints, 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';
|
|
import { formatters } from '../../i18n/format';
|
|
import { localePaths } from '../../i18n/paths';
|
|
|
|
/** One page per locale: `/`, `/es`, `/nl`. */
|
|
export const getStaticPaths = localePaths;
|
|
|
|
interface Props {
|
|
locale: Locale;
|
|
}
|
|
const { locale } = Astro.props;
|
|
|
|
const t = useI18n(locale);
|
|
const f = formatters(t);
|
|
|
|
const [mints, federations, lnurlMints, stats, health] = await Promise.all([
|
|
fetchMints(),
|
|
fetchFedimints(),
|
|
fetchLnurlMints(),
|
|
fetchStats(),
|
|
fetchHealth(),
|
|
]);
|
|
|
|
const top = mints.slice(0, 6);
|
|
const topFederations = federations.slice(0, 6);
|
|
const topLnurl = lnurlMints.slice(0, 6);
|
|
|
|
/*
|
|
* The reviews strip resolves against every ecosystem, 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. The
|
|
* same is true of an LNURL mint, which today has the further handicap that 38174 is a
|
|
* proposed kind with barely any events on the network at all.
|
|
*/
|
|
const reviewTargets = feedTargets(mints, federations, lnurlMints);
|
|
|
|
/*
|
|
* The mint details and the relay read have nothing to say to each other — the reviews
|
|
* 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
|
|
* cannot fill is simply fewer cards, and the carousel's controls disappear with them.
|
|
*/
|
|
const [details, fediDetails, lnurlDetails, 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))),
|
|
Promise.all(topLnurl.map((mint) => fetchLnurlMint(mint.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 lnurlSentiments = lnurlDetails.map((d) => (d ? sentiment(d.rating_distribution) : undefined));
|
|
/*
|
|
* An LNURL card's chip comes from two probed facts rather than from NUT switches: an
|
|
* advertised withdraw ceiling of zero, and a Lightning node the mint cannot reach.
|
|
*/
|
|
const lnurlChips = lnurlDetails.map((d) =>
|
|
d
|
|
? {
|
|
max_withdrawable_msat: d.max_withdrawable_msat ?? null,
|
|
funding_available: d.funding_available ?? null,
|
|
}
|
|
: null,
|
|
);
|
|
|
|
const description = t('home.description', {
|
|
mints: stats.mints_total,
|
|
reviews: stats.reviews_total,
|
|
});
|
|
|
|
/*
|
|
* The one sentence on the page with a link inside it. Interpolated as markup rather
|
|
* than split into three strings, so a translator can put "NIP-87" wherever the
|
|
* sentence needs it instead of wherever English left a gap.
|
|
*/
|
|
const signedBody = t('home.why.signed.body', {
|
|
link: `<a href="${localePath('/about', locale)}#nip87">${t('home.why.signed.link')}</a>`,
|
|
});
|
|
---
|
|
|
|
{/*
|
|
`clientNamespaces` inlines the `home.` catalog on this page and no other. The three
|
|
grids below refresh from the API after paint and rewrite their own "All 56 mints →"
|
|
links, so those strings have to reach the browser; 1,300 mint pages have no use for
|
|
them. See `clientCatalog` in src/i18n/index.ts.
|
|
*/}
|
|
<Base
|
|
title={t('home.title')}
|
|
description={description}
|
|
current="mints"
|
|
clientNamespaces={['home']}
|
|
>
|
|
{/*
|
|
The hero sits on an animated colour field: ColorBends from React Bits, ported to
|
|
plain WebGL in scripts/color-bends.ts. The wrapper is what the field fills, and it
|
|
is full width, so the wash runs edge to edge rather than stopping at the 1240px
|
|
column the text keeps to.
|
|
*/}
|
|
<div class="hero-wrap">
|
|
<ColorBends
|
|
rotation={90}
|
|
speed={0.2}
|
|
colors={['#A78BFA']}
|
|
transparent
|
|
autoRotate={1}
|
|
scale={0.4}
|
|
frequency={1}
|
|
warpStrength={1}
|
|
mouseInfluence={0.5}
|
|
parallax={0.2}
|
|
noise={0.2}
|
|
iterations={1}
|
|
intensity={1.3}
|
|
bandWidth={7.5}
|
|
/>
|
|
|
|
{/*
|
|
The hero arrives as a sequence, 60ms apart, starting the moment the page is
|
|
parsed. `data-reveal="load"` means exactly once, on a full page load: coming back
|
|
here through the router puts it on screen without replaying the greeting.
|
|
*/}
|
|
<section class="hero">
|
|
{/*
|
|
Two strings, because the emphasised half is a phrase and not the tail of a
|
|
sentence: Spanish and Dutch both put "you can trust" somewhere English does not,
|
|
and a translator needs to be able to move it.
|
|
*/}
|
|
<h1 data-reveal="load" data-reveal-delay="0">
|
|
{t('home.hero.title')} <em>{t('home.hero.titleEm')}</em>
|
|
</h1>
|
|
<p data-reveal="load" data-reveal-delay="60">{t('home.hero.lede')}</p>
|
|
|
|
<div class="search-wrap">
|
|
<form
|
|
class="search"
|
|
action={localePath('/mints', locale)}
|
|
method="get"
|
|
role="search"
|
|
data-reveal="load"
|
|
data-reveal-delay="120"
|
|
>
|
|
<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="home-search">{t('home.search.label')}</label>
|
|
<input
|
|
id="home-search"
|
|
name="q"
|
|
type="text"
|
|
autocomplete="off"
|
|
placeholder={t('home.search.placeholder')}
|
|
data-search-input
|
|
/>
|
|
<kbd aria-hidden="true">/</kbd>
|
|
</form>
|
|
<div class="quick-filters" data-reveal="load" data-reveal-delay="180">
|
|
<a class="qf" href={`${localePath('/mints', locale)}?sort=reviews`}>{t('home.filters.mostReviewed')}</a>
|
|
<a class="qf" href={`${localePath('/mints', locale)}?sort=rating`}>{t('home.filters.highestRated')}</a>
|
|
<a class="qf" href={`${localePath('/mints', locale)}?sort=recent`}>{t('home.filters.recentlyActive')}</a>
|
|
<a class="qf" href={`${localePath('/mints', locale)}?nut=17`}>{t('home.filters.websockets')}</a>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<PulseTicker stats={stats} health={health} />
|
|
|
|
<section class="section" aria-labelledby="top-mints">
|
|
<div class="sec-head" data-reveal>
|
|
<h2 class="sec-title" id="top-mints">{t('home.top.title')}</h2>
|
|
<span class="sec-sub">{t('home.top.sub')}</span>
|
|
<a class="sec-link" href={localePath('/mints', locale)} data-all-link="cashu">
|
|
{t('home.top.all', { n: mints.length })}
|
|
</a>
|
|
</div>
|
|
<div class="mint-grid" data-home-grid="cashu">
|
|
{top.map((mint, i) => (
|
|
<MintCard mint={mint} rank={i + 1} sentiment={sentiments[i]} capabilities={capabilities[i]} revealDelay={i * 50} />
|
|
))}
|
|
</div>
|
|
</section>
|
|
|
|
{/*
|
|
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.
|
|
*/}
|
|
{
|
|
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)} data-all-link="fedimint">
|
|
{t('home.topFedimints.all', { n: federations.length })}
|
|
</a>
|
|
</div>
|
|
<div class="mint-grid" data-home-grid="fedimint">
|
|
{topFederations.map((federation, i) => (
|
|
<MintCard mint={federation} rank={i + 1} sentiment={fediSentiments[i]} revealDelay={i * 50} />
|
|
))}
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
{/*
|
|
The LNURL grid: the third ecosystem strip, in the same order the nav lists them.
|
|
Same ranked cards as /lnurl-mints, same real sentiment bars, and the same chip rule
|
|
— so a reader moving down the page meets one card three times rather than three
|
|
cards once each.
|
|
*/}
|
|
{
|
|
topLnurl.length > 0 && (
|
|
<section class="section" aria-labelledby="top-lnurl">
|
|
<div class="sec-head" data-reveal>
|
|
<h2 class="sec-title" id="top-lnurl">{t('home.topLnurl.title')}</h2>
|
|
<span class="sec-sub">{t('home.topLnurl.sub')}</span>
|
|
<a class="sec-link" href={localePath('/lnurl-mints', locale)} data-all-link="lnurl">
|
|
{t('home.topLnurl.all', { n: stats.lnurl_total })}
|
|
</a>
|
|
</div>
|
|
<div class="mint-grid" data-home-grid="lnurl">
|
|
{topLnurl.map((mint, i) => (
|
|
<MintCard
|
|
mint={mint}
|
|
rank={i + 1}
|
|
sentiment={lnurlSentiments[i]}
|
|
lnurl={lnurlChips[i]}
|
|
revealDelay={i * 50}
|
|
/>
|
|
))}
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
<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>
|
|
<span class="sec-sub">{t('home.latest.sub')}</span>
|
|
{/*
|
|
The controls sit in the header row, never over the cards: the previous site
|
|
put arrows on top of the reviews and they covered the text. They start hidden
|
|
and the island only shows them when the track actually overflows, so with
|
|
JavaScript off, or with three reviews on a wide screen, there is nothing here
|
|
that does not work.
|
|
*/}
|
|
<div class="sec-actions">
|
|
<button class="car-btn" type="button" data-carousel-prev aria-label={t('home.latest.prev')} hidden>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true">
|
|
<path d="m15 18-6-6 6-6" />
|
|
</svg>
|
|
</button>
|
|
<button class="car-btn" type="button" data-carousel-next aria-label={t('home.latest.next')} hidden>
|
|
<svg width="16" height="16" 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>
|
|
</button>
|
|
<a class="sec-link" href={localePath('/reviews', locale)}>{t('home.latest.all')}</a>
|
|
</div>
|
|
</div>
|
|
|
|
{
|
|
latest.length === 0 ? (
|
|
<p class="empty">{t('home.latest.empty')}</p>
|
|
) : (
|
|
<div class="rev-track" data-carousel-track>
|
|
{latest.map((review) => {
|
|
// One identity per card: the name from kind 0, or "Anon". The npub is
|
|
// display only here, because the whole card is one link and a copy button
|
|
// inside it would be a control inside a control.
|
|
const name = profileName(review.profile);
|
|
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(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">
|
|
{initial}
|
|
</span>
|
|
{review.profile?.picture && (
|
|
<img
|
|
class="rc-avatar pic"
|
|
src={review.profile.picture}
|
|
alt=""
|
|
width="34"
|
|
height="34"
|
|
loading="lazy"
|
|
referrerpolicy="no-referrer"
|
|
onload="this.classList.add('loaded')"
|
|
onerror="this.remove()"
|
|
/>
|
|
)}
|
|
</span>
|
|
<span class="rc-who">
|
|
<span class="rc-identity">
|
|
{
|
|
// No NIP-05 here: at this card width it truncates to half a
|
|
// domain, which says less than nothing. It renders in full on
|
|
// the mint page, where the row has the room.
|
|
name ? (
|
|
<span class="rc-name">{name}</span>
|
|
) : (
|
|
<span class="rc-name anon">{t('reviews.anon')}</span>
|
|
)
|
|
}
|
|
</span>
|
|
<span class="rc-npub mono">{shortNpub(review.npub)}</span>
|
|
</span>
|
|
<span class:list={['rc-stars', { bad: review.rating !== null && review.rating <= 2 }]}>
|
|
<span class="sr-only">
|
|
{
|
|
review.rating === null
|
|
? t('reviews.srNoRating')
|
|
: t('reviews.srRating', { rating: String(review.rating) })
|
|
}
|
|
</span>
|
|
{/* Five elements rather than one string, so the row fills star by
|
|
star when the card arrives. */}
|
|
<span class="rc-star-row" aria-hidden="true">
|
|
{[0, 1, 2, 3, 4].map((i) => (
|
|
<span class="rc-star" style={`--star-i:${i}`}>{i < filled ? '★' : '☆'}</span>
|
|
))}
|
|
</span>
|
|
</span>
|
|
</span>
|
|
{/* The review, exactly as its author wrote it. Never translated. */}
|
|
<span class="rc-body">{review.content}</span>
|
|
<span class="rc-foot">
|
|
<span class="rc-mint">{displayDomain(review.mint_url)}</span>
|
|
<span class="rc-arrow" aria-hidden="true">→</span>
|
|
<span class="rc-date">{f.relative(review.created_at)}</span>
|
|
</span>
|
|
</a>
|
|
);
|
|
})}
|
|
</div>
|
|
)
|
|
}
|
|
</section>
|
|
|
|
<section class="section" aria-labelledby="why-trust">
|
|
<div class="sec-head" data-reveal>
|
|
<h2 class="sec-title" id="why-trust">{t('home.why.title')}</h2>
|
|
</div>
|
|
<div class="how">
|
|
<div class="how-card" data-reveal>
|
|
<span class="how-icon" aria-hidden="true">
|
|
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Z" />
|
|
</svg>
|
|
</span>
|
|
<h3>{t('home.why.signed.title')}</h3>
|
|
<p set:html={signedBody} />
|
|
</div>
|
|
<div class="how-card" data-reveal>
|
|
<span class="how-icon" aria-hidden="true">
|
|
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<circle cx="12" cy="12" r="10" />
|
|
<path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10Z" />
|
|
</svg>
|
|
</span>
|
|
<h3>{t('home.why.verify.title')}</h3>
|
|
<p>{t('home.why.verify.body')}</p>
|
|
</div>
|
|
<div class="how-card" data-reveal>
|
|
<span class="how-icon" aria-hidden="true">
|
|
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M13 2 3 14h9l-1 8 10-12h-9l1-8Z" />
|
|
</svg>
|
|
</span>
|
|
<h3>{t('home.why.checked.title')}</h3>
|
|
<p>{t('home.why.checked.body')}</p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<div class="cta">
|
|
<div class="cta-box" data-reveal>
|
|
<h2>{t('home.cta.title')}</h2>
|
|
<p>{t('home.cta.body')}</p>
|
|
<div class="cta-actions">
|
|
<a class="btn primary" href={localePath('/mints', locale)}>{t('home.cta.explore')}</a>
|
|
<a class="btn" href={localePath('/about', locale)}>{t('home.cta.learn')}</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Base>
|
|
|
|
<style>
|
|
/* Positions the colour field, which is absolutely placed against this box. */
|
|
.hero-wrap { position: relative; }
|
|
/* Above the field, and its own stacking context so the search box stays clickable. */
|
|
.hero { position: relative; z-index: 1; }
|
|
|
|
.hero {
|
|
max-width: var(--page-max);
|
|
margin: 0 auto;
|
|
/* A third off the old 76/52. The pulse ticker is the proof that the site is
|
|
live, and it belongs on screen at 1280x800 without scrolling. */
|
|
padding: 136px var(--page-pad) 80px;
|
|
text-align: center;
|
|
}
|
|
.hero h1 {
|
|
font-family: var(--display);
|
|
font-weight: 700;
|
|
font-size: clamp(34px, 5vw, 56px);
|
|
letter-spacing: -.03em;
|
|
line-height: 1.06;
|
|
/* Narrow enough to break after "mint", balanced so the two lines are even:
|
|
"Find a Cashu mint" over "you can trust", never "you can" over "trust". */
|
|
max-width: 620px;
|
|
text-wrap: balance;
|
|
margin: 0 auto;
|
|
}
|
|
.hero h1 em { font-style: normal; color: var(--amber); }
|
|
.hero p { color: var(--muted); font-size: 17px; max-width: 560px; margin: 16px auto 0; text-wrap: balance; }
|
|
|
|
.search-wrap { max-width: 640px; margin: 26px auto 0; }
|
|
.search {
|
|
display: flex; align-items: center; gap: 12px;
|
|
background: var(--card); border: 1px solid var(--line);
|
|
border-radius: 14px; padding: 16px 18px;
|
|
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;
|
|
color: var(--text); font-family: var(--body); font-size: 16px; min-width: 0;
|
|
}
|
|
.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;
|
|
}
|
|
|
|
.quick-filters { display: flex; gap: 8px; justify-content: center; margin-top: 14px; margin-bottom: 28px; flex-wrap: wrap; }
|
|
.qf {
|
|
font-size: 13px; font-weight: 500; color: var(--muted);
|
|
border: 1px solid var(--line); border-radius: 999px; padding: 6px 14px;
|
|
transition: color .15s, border-color .15s, background .15s;
|
|
}
|
|
.qf:hover { color: var(--text); border-color: #38342A; background: var(--card); }
|
|
|
|
/* ---------- latest reviews carousel ---------- */
|
|
/*
|
|
* A scroll-snap track, not a JavaScript slider: native touch and trackpad swiping,
|
|
* native momentum, and it still works with the island switched off. The buttons and
|
|
* the auto-advance are additions on top of something that already scrolls.
|
|
*/
|
|
.sec-actions { margin-left: auto; display: flex; align-items: center; gap: 8px; }
|
|
.sec-actions .sec-link { margin-left: 4px; }
|
|
.car-btn {
|
|
width: 30px; height: 30px; flex-shrink: 0;
|
|
display: inline-flex; align-items: center; justify-content: center;
|
|
border: 1px solid var(--line); border-radius: 8px;
|
|
background: var(--card); color: var(--muted); cursor: pointer;
|
|
transition: color var(--dur-quick), border-color var(--dur-quick), background var(--dur-quick);
|
|
}
|
|
.car-btn:hover { color: var(--text); border-color: #38342A; background: var(--card2); }
|
|
.car-btn:active { transform: translateY(1px); }
|
|
.car-btn[disabled] { opacity: .4; cursor: not-allowed; }
|
|
.car-btn[disabled]:hover { color: var(--muted); border-color: var(--line); background: var(--card); }
|
|
|
|
.rev-track {
|
|
display: flex;
|
|
gap: 16px;
|
|
overflow-x: auto;
|
|
overflow-y: hidden;
|
|
scroll-snap-type: x mandatory;
|
|
/* Snap points land on the container's own left edge, so a card is never half cut
|
|
at the start of the track. */
|
|
scroll-padding-left: 0;
|
|
/* Room for the card's hover lift and its focus ring, which an overflow container
|
|
would otherwise clip. */
|
|
padding: 4px 0 12px;
|
|
-webkit-overflow-scrolling: touch;
|
|
scrollbar-width: none;
|
|
}
|
|
.rev-track::-webkit-scrollbar { display: none; }
|
|
/* Soft edge while there is more to come, so the track reads as scrollable. Removed
|
|
at the end of the track, where there is nothing left to hint at. */
|
|
.rev-track.has-next {
|
|
-webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 44px), transparent 100%);
|
|
mask-image: linear-gradient(90deg, #000 calc(100% - 44px), transparent 100%);
|
|
}
|
|
|
|
.rev-card {
|
|
flex: 0 0 380px;
|
|
/* Never wider than the viewport: at 360px the card fits the column instead of
|
|
running its text under the edge. */
|
|
max-width: calc(100vw - var(--page-pad) * 2);
|
|
scroll-snap-align: start;
|
|
border: 1px solid var(--line); border-radius: var(--radius);
|
|
background: var(--card); padding: 20px; min-width: 0;
|
|
display: flex; flex-direction: column; gap: 12px;
|
|
transition: border-color var(--dur-quick), transform var(--dur-quick) var(--ease-out),
|
|
box-shadow var(--dur-quick);
|
|
}
|
|
.rev-card:hover {
|
|
border-color: #3A362B;
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 6px 18px rgba(0, 0, 0, .3);
|
|
}
|
|
.rev-card:active { transform: scale(.995); }
|
|
|
|
.rc-top { display: flex; align-items: center; gap: 11px; }
|
|
.rc-avatar-wrap { position: relative; width: 34px; height: 34px; flex-shrink: 0; }
|
|
.rc-avatar {
|
|
width: 34px; height: 34px; border-radius: 50%;
|
|
display: flex; align-items: center; justify-content: center;
|
|
font-family: var(--display); font-weight: 600; font-size: 14px; color: #0E0D0B;
|
|
border: 1px solid var(--line); object-fit: cover;
|
|
}
|
|
/* The picture covers the generated initial, fades in when it decodes, and removes
|
|
itself if it 404s. */
|
|
.rc-avatar.pic { position: absolute; inset: 0; background: var(--card2); }
|
|
.rc-who { min-width: 0; display: flex; flex-direction: column; justify-content: center; gap: 1px; height: 34px; }
|
|
.rc-identity { display: flex; align-items: center; gap: 7px; min-width: 0; height: 19px; }
|
|
.rc-name { font-weight: 600; font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.rc-name.anon { color: var(--muted); font-weight: 500; }
|
|
/* Display only on this card: the whole card is a link, so there is no button in it. */
|
|
.rc-npub { font-size: 11px; color: var(--faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.rc-stars { margin-left: auto; color: var(--amber); font-size: 12.5px; flex-shrink: 0; }
|
|
.rc-stars.bad { color: var(--red); }
|
|
.rc-star-row { display: inline-flex; gap: 1.5px; }
|
|
/* The row fills star by star once the card has arrived, 40ms apart. */
|
|
.rc-star {
|
|
transition:
|
|
opacity var(--dur-quick) var(--ease-out) var(--star-in),
|
|
transform var(--dur-quick) var(--ease-out) var(--star-in);
|
|
--star-in: calc(var(--reveal-delay, 0ms) + 140ms + var(--star-i) * 40ms);
|
|
}
|
|
:global([data-reveal]:not(.in)) .rc-star { opacity: 0; transform: scale(.8); }
|
|
|
|
/* Three lines and an ellipsis, so every card in the row is the same height. */
|
|
.rc-body {
|
|
font-size: 14px; color: #D8D2C4; line-height: 1.55; flex: 1; overflow-wrap: anywhere;
|
|
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; line-clamp: 3;
|
|
overflow: hidden;
|
|
}
|
|
.rc-foot {
|
|
display: flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--faint);
|
|
border-top: 1px solid var(--line-soft); padding-top: 12px;
|
|
}
|
|
.rc-mint {
|
|
display: block; min-width: 0; font-family: var(--mono); font-size: 12px; color: var(--muted);
|
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
transition: color var(--dur-quick);
|
|
}
|
|
/* The destination, nudged: hovering the card says where it goes. */
|
|
.rc-arrow {
|
|
color: var(--amber); opacity: 0; flex-shrink: 0;
|
|
transform: translateX(-4px);
|
|
transition: opacity var(--dur-quick) var(--ease-out), transform var(--dur-quick) var(--ease-out);
|
|
}
|
|
.rev-card:hover .rc-mint, .rev-card:focus-visible .rc-mint { color: var(--amber); }
|
|
.rev-card:hover .rc-arrow, .rev-card:focus-visible .rc-arrow { opacity: 1; transform: none; }
|
|
.rc-date { margin-left: auto; flex-shrink: 0; }
|
|
|
|
.empty { color: var(--muted); font-size: 14.5px; }
|
|
|
|
.how { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
|
|
.how-card { border: 1px solid var(--line); border-radius: var(--radius); background: var(--card); padding: 24px; }
|
|
.how-icon {
|
|
width: 40px; height: 40px; border-radius: 10px;
|
|
background: var(--card2); border: 1px solid var(--line);
|
|
display: flex; align-items: center; justify-content: center;
|
|
color: var(--amber); margin-bottom: 16px;
|
|
}
|
|
.how-card h3 { font-family: var(--display); font-size: 16.5px; font-weight: 600; margin-bottom: 8px; letter-spacing: -.01em; }
|
|
.how-card p { font-size: 13.5px; color: var(--muted); line-height: 1.6; }
|
|
.how-card p a { color: var(--violet); border-bottom: 1px dotted rgba(167, 139, 250, .4); }
|
|
|
|
.cta { max-width: var(--page-max); margin: 64px auto 0; padding: 0 var(--page-pad); }
|
|
.cta-box {
|
|
border: 1px solid var(--line); border-radius: 18px;
|
|
background: linear-gradient(135deg, var(--card) 0%, #1B1712 100%);
|
|
padding: 52px 40px; text-align: center; position: relative; overflow: hidden;
|
|
}
|
|
.cta-box::before {
|
|
content: ""; position: absolute; inset: 0; pointer-events: none;
|
|
background: radial-gradient(600px 200px at 50% 0%, rgba(240, 169, 59, .07), transparent 70%);
|
|
}
|
|
.cta-box h2 { font-family: var(--display); font-size: 30px; font-weight: 700; letter-spacing: -.02em; position: relative; }
|
|
.cta-box p { color: var(--muted); max-width: 480px; margin: 12px auto 26px; position: relative; }
|
|
.cta-actions { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; position: relative; }
|
|
|
|
@media (max-width: 1279px) {
|
|
.how { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
}
|
|
@media (max-width: 640px) {
|
|
.how { grid-template-columns: minmax(0, 1fr); }
|
|
.hero { padding: 116px var(--page-pad) 72px; }
|
|
.section { padding: 44px var(--page-pad) 8px; }
|
|
.cta-box { padding: 38px 22px; }
|
|
.cta-box h2 { font-size: 25px; }
|
|
}
|
|
</style>
|
|
|
|
<script>
|
|
import { hydrateMintGrid } from '../../lib/mint-cards';
|
|
import { useI18n } from '../../i18n/client';
|
|
import { onLeave, onReady, prefersReducedMotion, scrollBehavior, swapText } from '../../scripts/reveal';
|
|
|
|
/** One card every six seconds, until the reader touches the track. */
|
|
const AUTO_MS = 6000;
|
|
/** Matches the track's CSS gap. */
|
|
const GAP = 16;
|
|
|
|
/**
|
|
* The three top-six strips, and the "All N" link over each of them.
|
|
*
|
|
* The home page prerenders six cards per ecosystem and a count beside them, from the
|
|
* same API the index pages read. Between builds the strips went stale in two ways at
|
|
* once: a new mint could not appear in the top six however good it was, and the count
|
|
* beside the link said how many mints existed at 03:30. Both are one fetch away.
|
|
*
|
|
* The key is the catalog string for that link, which is why this page inlines the
|
|
* `home.` namespace (see `clientNamespaces` on Base above). `t()` formats the number
|
|
* for the locale, so "All 1,247 mints" and "All 1.247 mints" both come out right.
|
|
*
|
|
* Sentiment bars are the one thing hydration cannot improve here: the prerendered
|
|
* cards get real rating distributions from a per-mint detail fetch at build time, and
|
|
* the list payload has no distribution in it. A refreshed card falls back to the
|
|
* rating proxy the index pages have always used — a 4.6 average reads as 92% positive
|
|
* — which is a slightly coarser bar on a card whose numbers are otherwise newer.
|
|
*/
|
|
const STRIPS = [
|
|
{ type: 'cashu', key: 'home.top.all' },
|
|
{ type: 'fedimint', key: 'home.topFedimints.all' },
|
|
{ type: 'lnurl', key: 'home.topLnurl.all' },
|
|
] as const;
|
|
|
|
const hydrateStrips = (): void => {
|
|
const t = useI18n();
|
|
|
|
for (const strip of STRIPS) {
|
|
const grid = document.querySelector<HTMLElement>(`[data-home-grid="${strip.type}"]`);
|
|
// A section with nothing in it is not rendered at all, and a strip that was empty
|
|
// at build time stays empty until the next one: there is no heading to hang cards
|
|
// under. Rare, and not worth building a section in JavaScript for.
|
|
if (!grid) continue;
|
|
|
|
void hydrateMintGrid({
|
|
type: strip.type,
|
|
grid,
|
|
limit: 6,
|
|
// The reveal delay the build gives these six, so a refreshed strip arrives the
|
|
// same way the prerendered one did.
|
|
revealDelayStep: 50,
|
|
onReplaced: (_cards, all) => {
|
|
const link = document.querySelector<HTMLElement>(`[data-all-link="${strip.type}"]`);
|
|
if (link) swapText(link, t(strip.key, { n: all.length }));
|
|
},
|
|
});
|
|
}
|
|
};
|
|
|
|
const setup = (): void => {
|
|
requestAnimationFrame(() => hydrateStrips());
|
|
|
|
// No track at all means the relays gave the build nothing, and the section is
|
|
// showing its empty state instead.
|
|
const found = {
|
|
track: document.querySelector<HTMLElement>('[data-carousel-track]'),
|
|
prev: document.querySelector<HTMLButtonElement>('[data-carousel-prev]'),
|
|
next: document.querySelector<HTMLButtonElement>('[data-carousel-next]'),
|
|
};
|
|
if (!found.track || !found.prev || !found.next) return;
|
|
const track: HTMLElement = found.track;
|
|
const prev: HTMLButtonElement = found.prev;
|
|
const next: HTMLButtonElement = found.next;
|
|
|
|
let timer = 0;
|
|
/**
|
|
* Auto-advance is a hint that the track scrolls, not a feature. The first time
|
|
* the reader scrolls it themselves, by button, swipe, wheel or key, it is their
|
|
* track and it never moves on its own again. With reduced motion set it never
|
|
* starts at all.
|
|
*/
|
|
let surrendered = prefersReducedMotion();
|
|
|
|
const cardStep = (): number => {
|
|
const card = track.querySelector<HTMLElement>('.rev-card');
|
|
return card ? card.getBoundingClientRect().width + GAP : track.clientWidth;
|
|
};
|
|
|
|
const overflows = (): boolean => track.scrollWidth - track.clientWidth > 4;
|
|
const atStart = (): boolean => track.scrollLeft <= 2;
|
|
const atEnd = (): boolean => track.scrollLeft >= track.scrollWidth - track.clientWidth - 2;
|
|
|
|
const stop = (): void => {
|
|
if (timer) {
|
|
window.clearInterval(timer);
|
|
timer = 0;
|
|
}
|
|
};
|
|
|
|
/** Buttons only exist when there is something to scroll to. */
|
|
function sync(): void {
|
|
const scrollable = overflows();
|
|
prev.hidden = !scrollable;
|
|
next.hidden = !scrollable;
|
|
track.classList.toggle('has-next', scrollable && !atEnd());
|
|
if (!scrollable) {
|
|
stop();
|
|
return;
|
|
}
|
|
prev.disabled = atStart();
|
|
next.disabled = atEnd();
|
|
}
|
|
|
|
const scrollByCard = (direction: number): void => {
|
|
track.scrollBy({ left: direction * cardStep(), behavior: scrollBehavior() });
|
|
};
|
|
|
|
function tick(): void {
|
|
// Paused while the pointer is over the track or focus is inside it: nothing
|
|
// should slide out from under a reader who is reading it.
|
|
if (surrendered || track.matches(':hover, :focus-within') || !overflows()) return;
|
|
if (atEnd()) track.scrollTo({ left: 0, behavior: scrollBehavior() });
|
|
else scrollByCard(1);
|
|
}
|
|
|
|
const start = (): void => {
|
|
if (surrendered || timer || !overflows()) return;
|
|
timer = window.setInterval(tick, AUTO_MS);
|
|
};
|
|
|
|
const surrender = (): void => {
|
|
surrendered = true;
|
|
stop();
|
|
};
|
|
|
|
prev.addEventListener('click', () => {
|
|
surrender();
|
|
scrollByCard(-1);
|
|
});
|
|
next.addEventListener('click', () => {
|
|
surrender();
|
|
scrollByCard(1);
|
|
});
|
|
|
|
// Deliberate interaction, as opposed to the smooth scroll this island starts
|
|
// itself. Watching these rather than the scroll event is what keeps the two
|
|
// apart without a flag and a timeout guessing at which is which.
|
|
for (const event of ['pointerdown', 'touchstart', 'keydown'] as const) {
|
|
track.addEventListener(event, surrender, { passive: true });
|
|
}
|
|
// A wheel only counts when it is aimed at the track. Scrolling the page with the
|
|
// pointer resting over the carousel is not a request to take it over.
|
|
track.addEventListener(
|
|
'wheel',
|
|
(event) => {
|
|
if (Math.abs(event.deltaX) > Math.abs(event.deltaY)) surrender();
|
|
},
|
|
{ passive: true },
|
|
);
|
|
|
|
let frame = 0;
|
|
track.addEventListener(
|
|
'scroll',
|
|
() => {
|
|
if (frame) return;
|
|
frame = requestAnimationFrame(() => {
|
|
frame = 0;
|
|
sync();
|
|
});
|
|
},
|
|
{ passive: true },
|
|
);
|
|
|
|
// A window narrow enough to make the track scrollable brings the controls, and
|
|
// the auto-advance, back with it.
|
|
const onResize = (): void => {
|
|
sync();
|
|
start();
|
|
};
|
|
window.addEventListener('resize', onResize);
|
|
|
|
sync();
|
|
start();
|
|
|
|
onLeave(() => {
|
|
stop();
|
|
window.removeEventListener('resize', onResize);
|
|
if (frame) cancelAnimationFrame(frame);
|
|
});
|
|
};
|
|
|
|
onReady(setup);
|
|
</script>
|