@@ -0,0 +1,658 @@
|
||||
---
|
||||
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 { fetchLatestReviews } from '../../lib/nostr-build';
|
||||
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, stats, health] = await Promise.all([fetchMints(), fetchStats(), fetchHealth()]);
|
||||
|
||||
const top = mints.slice(0, 6);
|
||||
const hostByUrl = new Map(mints.map((m) => [m.url, m.host]));
|
||||
|
||||
/*
|
||||
* 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.
|
||||
*
|
||||
* 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, 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),
|
||||
]);
|
||||
|
||||
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));
|
||||
|
||||
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>`,
|
||||
});
|
||||
---
|
||||
|
||||
<Base title={t('home.title')} description={description} current="mints">
|
||||
{/*
|
||||
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)}>{t('home.top.all', { n: mints.length })}</a>
|
||||
</div>
|
||||
<div class="mint-grid">
|
||||
{top.map((mint, i) => (
|
||||
<MintCard mint={mint} rank={i + 1} sentiment={sentiments[i]} capabilities={capabilities[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(`/mint/${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 { onLeave, onReady, prefersReducedMotion, scrollBehavior } 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;
|
||||
|
||||
const setup = (): void => {
|
||||
// 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>
|
||||
Reference in New Issue
Block a user