first commit

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
michilis
2026-08-20 22:41:25 +02:00
co-authored by Cursor
commit aa1771ea20
136 changed files with 27069 additions and 0 deletions
+221
View File
@@ -0,0 +1,221 @@
---
import Base from '../../layouts/Base.astro';
import Moai from '../../components/Moai.astro';
import { localePath, useI18n, type Locale } from '../../i18n';
import { localePaths } from '../../i18n/paths';
/**
* Also the fallback for a mint discovered between builds.
*
* A new mint's page does not exist until the next rebuild, so on a /mint/{host} miss
* this asks the API for that host and renders a page shell from it. Not as complete
* as the prerendered page, but the mint is reachable and reviewable immediately
* instead of invisible for a day (FRONTEND.md).
*
* Emitted per locale: `/404`, `/es/404`, `/nl/404`. A static host serves the one at the
* root for every miss unless it is told otherwise, so the README's nginx block adds an
* `error_page` per prefix. That is what keeps a miss under /es/ from answering in
* English.
*/
export const getStaticPaths = localePaths;
interface Props {
locale: Locale;
}
const { locale } = Astro.props;
const t = useI18n(locale);
---
<Base
title={t('notfound.title')}
description={t('notfound.description')}
current="mints"
offGraph
>
<div class="page">
<div class="notfound" data-notfound>
{/* 44px, not a hero graphic: DESIGN.md says the moai is never scaled large. */}
<span class="nf-moai"><Moai size={44} /></span>
<h1>{t('notfound.heading')}</h1>
<p class="lede">{t('notfound.lede')}</p>
<form class="search nf-search" action={localePath('/mints', locale)} method="get" role="search">
<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="nf-search">{t('home.search.label')}</label>
<input
id="nf-search"
name="q"
type="text"
autocomplete="off"
placeholder={t('home.search.placeholder')}
data-search-input
/>
<kbd aria-hidden="true">/</kbd>
</form>
<div class="nf-actions">
<a class="btn primary" href={localePath('/mints', locale)}>{t('notfound.browse')}</a>
<a class="btn" href={localePath('/', locale)}>{t('notfound.home')}</a>
</div>
</div>
{/*
Filled in by the fallback below when the URL looks like a mint page. Nothing
here is prerendered, so this is the one place on the site that gets a page
level skeleton. `data-boneyard` marks the capture target for `pnpm bones`.
*/}
<div class="mint-fallback" data-mint-fallback hidden>
<div data-boneyard="mint-page" data-boneyard-config='{"excludeSelectors":[".sr-only"]}'><div data-mint-fallback-body></div></div>
</div>
</div>
</Base>
<style>
.notfound { padding: 54px 0 20px; max-width: 620px; }
.nf-moai { display: block; margin-bottom: 18px; }
.notfound h1 { font-family: var(--display); font-size: 34px; font-weight: 700; letter-spacing: -.02em; }
.nf-actions { display: flex; gap: 12px; margin-top: 22px; flex-wrap: wrap; }
/* Same search control as the home page hero, so a miss lands somewhere useful. */
.nf-search {
display: flex; align-items: center; gap: 12px; margin-top: 24px;
background: var(--card); border: 1px solid var(--line);
border-radius: 14px; padding: 14px 16px;
transition: border-color .15s, box-shadow .15s;
}
.nf-search:focus-within { border-color: var(--amber-dim); box-shadow: 0 0 0 3px rgba(240, 169, 59, .08); }
.nf-search > svg { color: var(--faint); flex-shrink: 0; }
.nf-search input {
flex: 1; background: none; border: none; outline: none; min-width: 0;
color: var(--text); font-family: var(--body); font-size: 15.5px;
}
.nf-search input::placeholder { color: var(--faint); }
.nf-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;
}
/* The .fb-* rules live in global.css: this page builds that markup with
innerHTML, and Astro's scoped selectors do not reach injected elements. */
.mint-fallback { padding: 20px 0 40px; }
</style>
<script>
import { apiBase, escapeHtml, iconGradient } from '../../lib/client';
import { armBuildSnapshot, clearBones, isBuildMode, showBones, swapBones } from '../../lib/skeleton';
import { useI18n } from '../../i18n/client';
import { formatters, starString, type Formatters } from '../../i18n/format';
import { statusLabel } from '../../i18n/mint';
import { splitLocale } from '../../i18n/routing';
import { onReady } from '../../scripts/reveal';
import mintPageBones from '../../bones/mint-page.bones.json';
import type { MintDetail } from '@cashumints/shared';
/**
* The summary this page can build from the API alone. `pnpm bones` measures the
* output of this same function, so the bones are the shape of what actually
* arrives rather than of a fuller page that never does.
*/
function fallbackHtml(mint: MintDetail, f: Formatters): string {
const domain = mint.url.replace(/^https?:\/\//, '');
const name = mint.name ?? domain.split('/')[0] ?? domain;
return `
<div class="fb-head">
<span class="mc-icon" style="background:${iconGradient(domain)};width:52px;height:52px;border-radius:13px;font-size:21px">${escapeHtml(
(name[0] ?? '?').toUpperCase(),
)}</span>
<span>
<span class="fb-name">${escapeHtml(name)}</span>
<span class="fb-domain">${escapeHtml(mint.url)}</span>
</span>
<span class="status-chip ${escapeHtml(mint.status)}"><span class="status-dot"></span>${escapeHtml(
statusLabel(mint.status, f.t),
)}</span>
</div>
<div class="fb-stats">
<span class="fb-stat"><span class="k">${escapeHtml(
f.t('notfound.fb.rating'),
)}</span><span class="v">${
mint.rating_avg === null ? f.t('time.none') : f.decimal(mint.rating_avg)
} <span class="stars" style="font-size:14px">${starString(mint.rating_avg)}</span></span></span>
<span class="fb-stat"><span class="k">${escapeHtml(
f.t('notfound.fb.reviews'),
)}</span><span class="v">${f.number(mint.review_count)}</span></span>
<span class="fb-stat"><span class="k">${escapeHtml(
f.t('notfound.fb.software'),
)}</span><span class="v" style="font-family:var(--mono);font-size:16px">${escapeHtml(
mint.version ?? f.t('mint.software.unknown'),
)}</span></span>
<span class="fb-stat"><span class="k">${escapeHtml(
f.t('notfound.fb.lastOnline'),
)}</span><span class="v" style="font-size:16px">${escapeHtml(
f.relative(mint.last_online),
)}</span></span>
</div>
<p class="fb-note">${escapeHtml(f.t('notfound.fb.note'))}</p>`;
}
const setup = (): void => {
const f = formatters(useI18n());
/*
* The miss could be at /mint/x or at /es/mint/x, and both resolve to the same mint.
* The prefix comes off with the same helper the build routes with, so the two can
* never disagree about what counts as a locale segment.
*/
const { path } = splitLocale(window.location.pathname);
const match = /^\/mint\/([^/]+)\/?$/.exec(path);
const box = document.querySelector<HTMLElement>('[data-mint-fallback]');
const body = document.querySelector<HTMLElement>('[data-mint-fallback-body]');
const notfound = document.querySelector<HTMLElement>('[data-notfound]');
if (body && box && isBuildMode()) {
// `pnpm bones` is measuring this page. Show a representative mint so the
// capture has a real summary to measure instead of an empty box.
// Imported dynamically so the fixture never reaches a real visitor.
void armBuildSnapshot()
.then(() => import('../../lib/skeleton-fixtures'))
.then(({ FIXTURE_MINT }) => {
if (notfound) notfound.hidden = true;
box.hidden = false;
body.innerHTML = fallbackHtml(FIXTURE_MINT, f);
});
} else if (match?.[1] && box && body) {
const host = decodeURIComponent(match[1]);
// The "page not found" copy would be wrong if the API knows this mint, so hold
// it back while we ask instead of flashing it and taking it away again.
if (notfound) notfound.hidden = true;
box.hidden = false;
showBones(body, mintPageBones, { label: f.t('notfound.loading', { host }) });
void (async () => {
try {
const res = await fetch(`${apiBase}/api/mints/${encodeURIComponent(host)}`);
if (!res.ok) throw new Error(`api responded ${res.status}`);
const mint = (await res.json()) as MintDetail;
swapBones(body, (target) => {
target.innerHTML = fallbackHtml(mint, f);
});
const domain = mint.url.replace(/^https?:\/\//, '');
const name = mint.name ?? domain.split('/')[0] ?? domain;
document.title = f.t('mint.title', { name });
} catch {
// No such mint, or the API is unreachable. The plain 404 is the honest
// answer, so put it back rather than leaving bones on screen.
clearBones(body);
box.hidden = true;
if (notfound) notfound.hidden = false;
}
})();
}
};
onReady(setup);
</script>
+125
View File
@@ -0,0 +1,125 @@
---
import Base from '../../layouts/Base.astro';
import { fetchStats } from '../../lib/api';
import { useI18n, type Locale } from '../../i18n';
import { localePaths } from '../../i18n/paths';
import { DEFAULT_RELAYS, KIND_MINT_ANNOUNCEMENT, KIND_REVIEW } from '@cashumints/shared';
/** One page per locale: `/about`, `/es/about`, `/nl/about`. */
export const getStaticPaths = localePaths;
interface Props {
locale: Locale;
}
const { locale } = Astro.props;
const t = useI18n(locale);
const stats = await fetchStats();
const GITHUB = 'https://github.com/Azzamo-net/cashumints.space';
/*
* Prose with things inside it.
*
* Several sentences here carry a link, a <code> span or a bold lead. Rather than
* chopping each into "before" and "after" fragments, which pins the English word order
* onto every language, the whole sentence is one catalog string with a named
* placeholder, and the markup is built here and interpolated. A translator moves the
* placeholder wherever their sentence needs it.
*
* Everything interpolated is markup this file wrote, around values that are either
* constants or numbers. Nothing from a mint, a relay or a reader reaches these.
*/
const code = (value: string): string => `<code>${value}</code>`;
const link = (href: string, label: string): string =>
`<a href="${href}" rel="noopener">${label}</a>`;
const bold = (value: string): string => `<b>${value}</b>`;
const description = t('about.description');
const cashuP2 = t('about.cashu.p2', { link: link('https://cashu.space', 'cashu.space') });
const mintsP2 = t('about.mints.p2', { code: code('online') });
const nip87Li1 = t('about.nip87.li1', {
code: code(`kind ${KIND_MINT_ANNOUNCEMENT}`),
tag: code('u'),
});
const nip87Li2 = t('about.nip87.li2', {
code: code(`kind ${KIND_REVIEW}`),
utag: code('u'),
dtag: code('d'),
});
const howLi1 = t('about.how.li1', {
strong: bold(t('about.how.li1Label')),
code: code('/v1/info'),
});
const howLi2 = t('about.how.li2', { strong: bold(t('about.how.li2Label')) });
const nutsP1 = t('about.nuts.p1', {
link: link('https://github.com/cashubtc/nuts', 'github.com/cashubtc/nuts'),
});
const numbers = t('about.numbers.p1', {
mints: stats.mints_total,
online: stats.mints_online,
reviews: stats.reviews_total,
});
const sourceP1 = t('about.source.p1', {
github: link(GITHUB, 'GitHub'),
azzamo: link('https://azzamo.net', 'Azzamo'),
code: code('/v1/info'),
});
---
<Base title={t('about.title')} description={description} current="about">
<div class="page">
<div class="prose">
<h1>{t('about.heading')}</h1>
<p class="lede">{t('about.lede')}</p>
<h2 id="cashu">{t('about.cashu.title')}</h2>
<p>{t('about.cashu.p1')}</p>
<p set:html={cashuP2} />
<h2 id="mints">{t('about.mints.title')}</h2>
<p>{t('about.mints.p1')}</p>
<p set:html={mintsP2} />
<h2 id="nip87">{t('about.nip87.title')}</h2>
<p>{t('about.nip87.p1')}</p>
<ul>
<li set:html={nip87Li1} />
<li set:html={nip87Li2} />
</ul>
<p>{t('about.nip87.p2')}</p>
<h2 id="how">{t('about.how.title')}</h2>
<p>{t('about.how.p1')}</p>
<ul>
<li set:html={howLi1} />
<li set:html={howLi2} />
</ul>
<p>{t('about.how.p2')}</p>
<p>{t('about.how.p3')}</p>
<h2 id="relays">{t('about.relays.title')}</h2>
<p>{t('about.relays.p1')}</p>
{/* Relay URLs are addresses. Never translated, never reformatted. */}
<ul>
{DEFAULT_RELAYS.map((relay) => <li><code>{relay}</code></li>)}
</ul>
<p>{t('about.relays.p2')}</p>
<h2 id="nuts">{t('about.nuts.title')}</h2>
<p set:html={nutsP1} />
<h2 id="numbers">{t('about.numbers.title')}</h2>
<p>{numbers}</p>
<h2 id="source">{t('about.source.title')}</h2>
<p set:html={sourceP1} />
</div>
</div>
</Base>
@@ -0,0 +1,87 @@
---
/**
* The disclaimer. The most important page on the site: everything else here helps
* someone choose a mint, and this is the page that says what choosing a mint means.
*
* Linked from the site footer and from the bottom of every mint page, because that is
* where the decision actually gets made.
*
* Safety copy, so the translations are for plain meaning rather than for the shape of
* the English sentence. See src/i18n/GLOSSARY.md.
*/
import Base from '../../layouts/Base.astro';
import { localePath, useI18n, type Locale } from '../../i18n';
import { localePaths } from '../../i18n/paths';
/** One page per locale: `/disclaimer`, `/es/disclaimer`, `/nl/disclaimer`. */
export const getStaticPaths = localePaths;
interface Props {
locale: Locale;
}
const { locale } = Astro.props;
const t = useI18n(locale);
/** Bump `disclaimer.updated` in every catalog when the text changes. */
const updated = t('disclaimer.updated');
const description = t('disclaimer.description');
const siteP1 = t('disclaimer.site.p1', {
link: `<a href="${localePath('/terms', locale)}">${t('disclaimer.site.link')}</a>`,
});
---
<Base title={t('disclaimer.title')} description={description}>
<div class="page">
<div class="prose">
<h1>{t('disclaimer.heading')}</h1>
<p class="lede">{t('disclaimer.lede', { date: updated })}</p>
<div class="callout">
<p>{t('disclaimer.callout')}</p>
</div>
<h2 id="custodial">{t('disclaimer.custodial.title')}</h2>
<p>{t('disclaimer.custodial.p1')}</p>
<p>{t('disclaimer.custodial.p2')}</p>
<p>{t('disclaimer.custodial.p3')}</p>
<h2 id="reviews">{t('disclaimer.reviews.title')}</h2>
<p>{t('disclaimer.reviews.p1')}</p>
<p>{t('disclaimer.reviews.p2')}</p>
<h2 id="status">{t('disclaimer.status.title')}</h2>
<p>{t('disclaimer.status.p1')}</p>
<p>{t('disclaimer.status.p2')}</p>
<h2 id="ranking">{t('disclaimer.ranking.title')}</h2>
<p>{t('disclaimer.ranking.p1')}</p>
<p>{t('disclaimer.ranking.p2')}</p>
<h2 id="not-advice">{t('disclaimer.notAdvice.title')}</h2>
<p>{t('disclaimer.notAdvice.p1')}</p>
<h2 id="dyor">{t('disclaimer.dyor.title')}</h2>
<ul>
<li>{t('disclaimer.dyor.li1')}</li>
<li>{t('disclaimer.dyor.li2')}</li>
<li>{t('disclaimer.dyor.li3')}</li>
<li>{t('disclaimer.dyor.li4')}</li>
<li>{t('disclaimer.dyor.li5')}</li>
</ul>
<h2 id="site">{t('disclaimer.site.title')}</h2>
<p set:html={siteP1} />
</div>
</div>
</Base>
<style>
.callout {
border: 1px solid rgba(224, 100, 80, .3); background: rgba(224, 100, 80, .07);
border-radius: var(--radius); padding: 16px 18px; margin: 24px 0 8px;
}
.callout p { color: #E8C4BC; font-size: 15px; margin: 0; }
</style>
+658
View File
@@ -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>
+719
View File
@@ -0,0 +1,719 @@
---
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 MintQr from '../../../components/MintQr.astro';
import MintCard from '../../../components/MintCard.astro';
import {
getMintWarnings, HIGHLIGHT_NUTS, KNOWN_NUT_COUNT, readCapabilities,
type MintDetail, type MintListItem,
} from '@cashumints/shared';
import { fetchMint, fetchMints, iconUrl } from '../../../lib/api';
import { breadcrumbNode, mintServiceNode } from '../../../lib/schema';
import { clampWords, isIndexableMint, nameCollisions, seoName } from '../../../lib/seo';
import { bannerHtml, limitsHtml, localNutName, nutRowsHtml } from '../../../lib/mint-state';
import {
displayDomain, iconGradient, initials, shortNpub, 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 mint, in every locale.
*
* The API is asked once per mint, not once per mint per locale: the detail is fetched
* here and `localePathsFor` hands the same object to all three pages. ~55 mints times
* three locales is ~165 pages off ~55 requests.
*/
export async function getStaticPaths() {
const mints = await fetchMints();
const details = await Promise.all(
mints.map(async (mint, index) => {
try {
return { detail: await fetchMint(mint.host), rank: index + 1 };
} catch {
return null;
}
}),
);
/*
* Two things computed once here, from the list the page already had to fetch, rather
* than per mint: which published names identify nothing because more than one mint
* uses them, and the ring that gives every mint page three others to link to.
*/
const collisions = nameCollisions(mints);
/*
* The related-mints ring.
*
* Mint at position i links to i+1, i+2 and i+3, wrapping. Neighbours in the ranked
* order, so the three suggestions are of comparable standing and it reads sensibly to
* someone browsing; a ring rather than "the top three", so every mint page receives
* links instead of all of them pointing at the same three, and so a crawler entering
* anywhere can walk the whole index. Deterministic, so a rebuild with unchanged data
* produces an unchanged page.
*
* Unindexable mints are excluded from both ends: they are not offered as suggestions,
* and their own pages get no block, since a page nobody should index needs no crawl
* paths out of it.
*/
const ring = mints.filter(isIndexableMint);
const ringIndex = new Map(ring.map((mint, index) => [mint.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: MintDetail; rank: number } => d !== null)
.map(({ detail, rank }) => ({
params: { host: detail.host },
props: {
mint: detail,
rank,
related: related(detail.host),
seoName: seoName(detail.name, displayDomain(detail.url), collisions),
},
})),
);
}
// Props come from `getStaticPaths` above: the mint detail, its rank in the default
// ordering, and which of the three locales this copy of the page is.
const { mint, rank, related, seoName: titleName, locale } = Astro.props;
const t = useI18n(locale);
const f = formatters(t);
const domain = displayDomain(mint.url);
const name = mint.name ?? domain.split('/')[0] ?? domain;
const icon = iconUrl(mint.icon);
const offline = mint.status === 'offline';
const info = mint.info;
const status = statusLabel(mint.status, t);
/* verdict: rating */
const dist = mint.rating_distribution;
const distMax = Math.max(1, ...Object.values(dist));
// Reviews whose event carried no parseable rating are counted but not averaged, so
// say so rather than implying the average covers every review.
const ratedCount = Object.values(dist).reduce((a, b) => a + b, 0);
const unrated = mint.review_count - ratedCount;
/*
Warnings. One shared function decides what is wrong with a mint, so the banner, the
limits cell, the sidebar and the cards on /mints can never contradict each other.
The flags come from the cached /v1/info, so on an offline mint they describe the
configuration as it was when the mint last answered, and the copy says so.
*/
const caps = readCapabilities(info?.nuts);
// The banner's sentences come out of this locale's catalog; which banner it is comes
// out of shared/, identically in every language.
const warnings = getMintWarnings(mint, warningOptions(f));
const warning = warnings[0] ?? null;
/* software */
const software = mint.version ?? null;
const softwareName = software?.split('/')[0] ?? null;
/* NUTs */
const supported = new Set(mint.nuts);
const rest = mint.nuts.filter((n) => !HIGHLIGHT_NUTS.includes(n as never));
/* sidebar: contact */
const contacts = info?.contact ?? [];
const contactOf = (method: string): string | null =>
contacts.find((c) => c.method?.toLowerCase() === method)?.info ?? null;
const nostrContact = contactOf('nostr');
const emailContact = contactOf('email');
const serverTime = info?.time ? f.serverTime(info.time) : null;
/* MOTD and beta warnings */
const motd = info?.motd?.trim() || null;
const operatorNote = mint.description?.trim() || null;
const longDescription = info?.description_long?.trim() || null;
const warningText = [mint.description, longDescription].filter(Boolean).join(' ');
const isBeta = /\b(beta|alpha|experimental|research project|test|do not|don't store|not for real)\b/i.test(warningText);
/*
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.
*/
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');
// 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');
/*
* 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.
*/
const lead =
mint.description?.trim() ||
(titleName === domain
? t('mint.description.fallbackDomain', { domain })
: t('mint.description.fallback', { name, 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);
/*
* Indexability.
*
* A mint the site has never once reached and nobody has ever reviewed has no name, no
* description, no version and no reviews, because all of those come from a mint that
* answered or a person who wrote something. The page still exists, is still linked and
* is still reviewable; it just asks not to be indexed until there is something on it.
* The sitemap reads the same predicate.
*/
const indexable = isIndexableMint(mint);
const crumbs = [
{ name: t('nav.home'), url: new URL(localePath('/', locale), Astro.site).href },
{ name: t('mint.crumbMints'), url: new URL(localePath('/mints', locale), Astro.site).href },
{ name: domain },
];
const canonical = new URL(localePath(`/mint/${mint.host}`, locale), Astro.site).href;
const schema = [
breadcrumbNode(canonical, crumbs),
mintServiceNode({
canonical,
name: titleName,
description,
mintUrl: mint.url,
icon: icon ? new URL(icon, Astro.site).href : null,
/*
* Only when reviews actually carried ratings. `ratedCount` is what produced the
* average; `mint.review_count` includes reviews whose event had no parseable
* rating, which the page itself distinguishes a few lines below.
*/
rating:
ratedCount > 0 && mint.rating_avg !== null
? {
value: Number(mint.rating_avg.toFixed(1)),
ratingCount: ratedCount,
reviewCount: mint.review_count,
}
: null,
}),
];
---
<Base
title={t('mint.title', { name: titleName })}
description={description}
current="mints"
mintCount={undefined}
noindex={!indexable}
schema={schema}
>
<div class="page">
{/*
Home, then the index, then here. The trail has to match `crumbs` in the
frontmatter link for link: the BreadcrumbList built from it describes this
navigation, and describing a step that is not on screen is a claim about the page
that the page does not support.
*/}
<nav class="crumbs" aria-label={t('mint.breadcrumb')}>
<a href={localePath('/', locale)}>{t('nav.home')}</a>
<span class="sep" aria-hidden="true">/</span>
<a href={localePath('/mints', locale)}>{t('mint.crumbMints')}</a>
<span class="sep" aria-hidden="true">/</span>
<span class="here mono">{domain}</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', mint.host)}`}
/>
) : (
<span
class="mint-icon"
style={`background:${iconGradient(domain)};view-transition-name:${transitionName('icon', mint.host)}`}
aria-hidden="true"
>
{initials(name)}
</span>
)
}
<div class="mint-id">
<div class="mint-name-row">
<h1 style={`view-transition-name:${transitionName('name', mint.host)}`}>{name}</h1>
<span class:list={['status-chip', mint.status]} data-live-status>
<span class="status-dot"></span><span data-live-status-label>{status}</span>
</span>
</div>
<div class="mint-domain">
<span class="mono">{mint.url}</span>
<button class="copy-btn" data-copy={mint.url} aria-label={t('mint.copyUrl')} type="button">
<svg width="14" height="14" 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>
</button>
</div>
<div class="mint-tags">
<span class="tag">{t('mint.rankByScore', { rank: String(rank) })}</span>
{supported.has('4') && <span class="tag">{t('mint.tagLightning')}</span>}
{softwareName && <span class="tag mono">{softwareName}</span>}
</div>
</div>
<div class="head-actions">
<button class="btn" data-copy={mint.url} 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('mint.copyUrl')}
</button>
<button class="btn" data-qr-open type="button">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
<rect x="3" y="3" width="5" height="5" rx="1" /><rect x="16" y="3" width="5" height="5" rx="1" />
<rect x="3" y="16" width="5" height="5" rx="1" />
<path d="M21 16h-3a2 2 0 0 0-2 2v3M21 21v.01M12 7v3a2 2 0 0 1-2 2H7M12 3h.01M12 16v.01M16 12h1M21 12v.01M12 21v-1" />
</svg>
{t('mint.qrButton')}
</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>
{/*
One banner slot, one banner. Severity order in getMintWarnings decides which of
the six wins, and the losers fold into its last sentence rather than stacking a
wall of red nobody reads. Prerendered, so it is there with JavaScript off; the
live island rebuilds it in place if a fresh probe disagrees.
*/}
<Fragment set:html={bannerHtml(warning)} />
{/*
The four cells arrive left to right, 60ms apart, which is the order they should
be read in. Each carries its own delay rather than relying on the observer's
batching, because this strip is above the fold and arrives as one gesture.
*/}
<section class="verdict" aria-labelledby="mint-summary">
<h2 class="sr-only" id="mint-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">
{
mint.rating_avg === null ? (
<>
<div class="v-big">{t('time.none')}</div>
<div class="v-sub">{t('mint.rating.none')}</div>
</>
) : (
<>
{/* Counts up from 0.0 on reveal. The prerendered value is the one
that is here with JavaScript off, and the one it lands on. */}
{/*
`data-count-up` holds the raw number for the animation to count to;
the text beside it is the same number formatted for this locale, and
the count-up rewrites it in the locale's own notation as it runs.
*/}
<div class="v-big" data-count-up={mint.rating_avg.toFixed(1)} data-count-decimals="1">
{f.decimal(mint.rating_avg)}
</div>
<div class="stars" aria-hidden="true">{starString(mint.rating_avg)}</div>
<div class="v-sub">
{/* One expression, one string: Astro drops the whitespace between two
adjacent expressions, which is what produced "26reviews". */}
{t('mint.rating.reviews', { n: mint.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(mint.last_review_at)}</div>
<div class="v-sub">
{mint.last_review_at ? t('mint.activity.since') : t('mint.activity.noReviews')}
{mint.reviews_90d > 0 && (
<><br />{t('mint.activity.recent', { n: mint.reviews_90d })}</>
)}
</div>
<Sparkline probes={mint.probes_recent} uptime={mint.uptime_30d} />
</div>
<div class="v-cell" data-reveal data-reveal-delay="120">
<div class="v-label">{t('mint.limits.label')}</div>
{/* Published limits mean nothing in a direction that is switched off, so that
line is struck out and labelled rather than left looking like a promise. */}
<div data-live-limits set:html={limitsHtml(info?.nuts, caps, f)} />
</div>
<div class="v-cell" data-reveal data-reveal-delay="180">
<div class="v-label">{t('mint.software.label')}</div>
<div class="v-mono">
{/* The version string is what the mint reports about itself, so it renders
as-is. Only the word standing in for a mint that reported nothing is ours. */}
<b>{software ? software.replace('/', ' ') : t('mint.software.unknown')}</b><br />
<span data-live-checked>
{
offline
? mint.last_online
? t('mint.lastSeen', { when: f.relative(mint.last_online) })
: t('mint.neverReached')
: t('mint.checked', { when: f.relative(mint.last_probe) })
}
</span>
</div>
</div>
</section>
<div class="cols">
<Reviews mint={mint} />
<aside class="side">
{
(motd || (isBeta && operatorNote)) && (
<div class="panel" data-reveal>
<h2 class="side-title">{t('mint.operator.title')}</h2>
{motd && (
<div class="motd">
<div class="lbl">{t('mint.operator.motd')}</div>
{/* The MOTD is the operator's own words, rendered untouched. */}
{motd}
</div>
)}
{isBeta && (
<p class="warn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#F0A93B" stroke-width="2" aria-hidden="true">
<path d="m21.7 18-8-14a2 2 0 0 0-3.4 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.7-3Z" />
<path d="M12 9v4M12 17h.01" />
</svg>
<span>{t('mint.operator.beta')}</span>
</p>
)}
{/* Also the operator's own copy. Never translated. */}
{operatorNote && <p class="op-desc">{operatorNote}</p>}
</div>
)
}
<div class="panel" data-reveal>
<h2 class="side-title">{t('mint.nuts.title')}</h2>
{
mint.nuts.length === 0 ? (
<p class="side-empty">
{offline ? t('mint.nuts.noneOffline') : t('mint.nuts.noneYet')}
</p>
) : (
<>
<div class="nut-hi" data-live-nuts set:html={nutRowsHtml(mint.nuts, caps, f)} />
{rest.length > 0 && (
<div class="nut-rest">
{rest.map((n) => (
<span class="nut-chip" title={localNutName(n, f)}>{n.padStart(2, '0')}</span>
))}
</div>
)}
<p class="nut-more">
{t('mint.nuts.supported', { n: mint.nuts.length, total: KNOWN_NUT_COUNT })}
<span aria-hidden="true"> · </span>
<a href={`${localePath('/about', locale)}#nuts`}>{t('mint.nuts.whatAre')}</a>
</p>
</>
)
}
</div>
<div class="panel" data-reveal>
<h2 class="side-title">{t('mint.details.title')}</h2>
<div class="kv">
{
mint.pubkey && (
<div class="kv-row">
<span class="kv-k">{t('mint.details.pubkey')}</span>
<CopyableId
class="kv-v"
value={mint.pubkey}
label={truncateMiddle(mint.pubkey, 10, 6)}
kind={t('mint.kind.mintPubkey')}
/>
</div>
)
}
{
nostrContact && (
<div class="kv-row">
<span class="kv-k">{t('mint.details.nostr')}</span>
<CopyableId
class="kv-v"
value={nostrContact}
label={nostrContact.startsWith('npub1') ? shortNpub(nostrContact) : truncateMiddle(nostrContact, 10, 6)}
kind={t('mint.kind.nostrAddress')}
/>
</div>
)
}
{
emailContact && (
<div class="kv-row">
<span class="kv-k">{t('mint.details.email')}</span>
<CopyableId class="kv-v" value={emailContact} kind={t('mint.kind.emailAddress')} />
</div>
)
}
{
serverTime && (
<div class="kv-row">
<span class="kv-k">{t('mint.details.serverTime')}</span>
<span class="kv-v">{serverTime}</span>
</div>
)
}
<div class="kv-row">
<span class="kv-k">{t('mint.details.firstSeen')}</span>
<span class="kv-v">{f.monthYear(mint.first_seen)}</span>
</div>
{
info?.tos_url && (
<div class="kv-row">
<span class="kv-k">{t('mint.details.terms')}</span>
<a class="kv-v kv-link" href={info.tos_url} rel="noopener nofollow">{displayDomain(info.tos_url)}</a>
</div>
)
}
</div>
</div>
</aside>
</div>
{/*
Three more mints, and the way out to the rest.
Before this, a mint page's only links out were the breadcrumb, the NUT explainer
and the disclaimer: a reader who decided against this mint had nowhere to go but
back, and a crawler arriving here from a search result found the index and
nothing else. These three are the ring neighbours computed in `getStaticPaths` —
deterministic, so the page does not churn between builds, and never this mint.
No `capabilities`, so the cards show no melt-only or frozen chip: that comparison
is the point of /mints and the home grid, and earning it here would mean a detail
fetch per suggestion.
*/}
{
related.length > 0 && (
<section class="more-mints" aria-labelledby="more-mints">
<div class="sec-head" data-reveal>
<h2 class="sec-title" id="more-mints">{t('mint.more.title')}</h2>
<a class="sec-link" href={localePath('/mints', locale)}>{t('mint.more.all')}</a>
</div>
<div class="mint-grid">
{related.map((item) => <MintCard mint={item} />)}
</div>
</section>
)
}
{/*
The disclaimer belongs where the decision is made, not only in the site footer.
Everything above this line helps someone pick a mint; this is the line that says
what picking one means.
*/}
<p class="mint-disclaimer">
{t('mint.disclaimer')}
<a href={localePath('/disclaimer', locale)}>{t('mint.disclaimerLink')}</a>.
</p>
</div>
<MintQr url={mint.url} name={name} />
<MintLive host={mint.host} />
</Base>
<style>
/*
Plain margin, no `.section`: that class carries the page's max-width and gutter,
which this block is already inside. Nesting the two double-padded the row and left
the cards narrower than everything above them.
*/
.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-family: var(--mono); font-size: 14px; color: var(--muted); overflow-wrap: anywhere;
}
.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; }
.head-actions { margin-left: auto; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.verdict {
display: grid; grid-template-columns: 1.35fr 1fr 1fr 1fr;
border: 1px solid var(--line); border-radius: var(--radius);
background: var(--card); overflow: hidden; margin-bottom: 28px;
}
/* Every cell shares one padding, so the four labels sit on one baseline and the
four values start on the next. */
.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); }
.motd {
border-left: 3px solid var(--amber); background: var(--card2);
border-radius: 0 9px 9px 0; padding: 12px 15px; font-size: 14px; color: #D8D2C4;
overflow-wrap: anywhere;
}
.motd .lbl { font-size: 11px; color: var(--faint); letter-spacing: .06em; text-transform: uppercase; margin-bottom: 4px; }
.warn { margin-top: 12px; font-size: 12.5px; color: var(--muted); line-height: 1.55; display: flex; gap: 9px; }
.warn svg { flex-shrink: 0; margin-top: 2px; }
.op-desc { margin-top: 12px; font-size: 13px; color: var(--muted); line-height: 1.55; }
/* The rows themselves are styled in global.css: lib/mint-state.ts builds them for
both this page and the live island, and HTML built in a script never picks up
Astro's scoping attribute. What is left here is the page's own markup. */
/* Small enough that the usual eight or nine leftovers fit on two rows instead of
wrapping a single orphan onto a third. */
.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; }
.nut-more a { color: var(--muted); border-bottom: 1px dotted var(--faint); }
.nut-more a:hover { color: var(--amber); }
.kv-link { color: var(--amber); }
/* The copyable identifier is the value column itself, so it takes its metrics. */
.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 { 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 { 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%; }
/* Three actions: two to a row rather than three squeezed onto one. */
.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();
// The rating counts up once its cell has arrived, not on a timer of its own.
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>
+398
View File
@@ -0,0 +1,398 @@
---
import Base from '../../layouts/Base.astro';
import MintCard from '../../components/MintCard.astro';
import { readCapabilities } from '@cashumints/shared';
import { fetchMint, fetchMints } 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: `/mints`, `/es/mints`, `/nl/mints`. */
export const getStaticPaths = localePaths;
interface Props {
locale: Locale;
}
const { locale } = Astro.props;
// `t` formats the numbers inside its own strings, so no separate formatter is needed.
const t = useI18n(locale);
const mints = await fetchMints();
/*
The list payload carries no NUT information, so the "melt only" and "frozen" chips
need each mint's cached info. One detail fetch per mint, at build time, against the
API on the same machine: the mint pages already do exactly this in getStaticPaths.
A mint whose detail cannot be read simply gets no chip.
*/
const capabilities = await Promise.all(
mints.map((mint) =>
fetchMint(mint.host).then((detail) => readCapabilities(detail.info?.nuts)).catch(() => null),
),
);
const online = mints.filter((m) => m.status === 'online').length;
const offline = mints.filter((m) => m.status === 'offline').length;
const description = t('mints.description', { total: mints.length, online, offline });
/*
* The index, as a list a crawler can read in order.
*
* Only the mints whose own pages are indexable: listing a page here while telling a
* crawler not to index it is a contradiction. The order is the server-rendered one,
* which is the score order; the sort control rewrites the DOM but not this markup,
* so `ItemListOrderDescending` describes what shipped.
*/
const canonical = new URL(localePath('/mints', locale), Astro.site).href;
const schema = [
itemListNode({
canonical,
fragment: 'mints',
name: t('mints.heading'),
items: mints.filter(isIndexableMint).map((mint) => ({
name: mint.name ?? mint.host,
url: new URL(localePath(`/mint/${mint.host}`, locale), Astro.site).href,
})),
}),
];
---
<Base title={t('mints.title')} description={description} current="mints" schema={schema}>
{/*
The same .page container the home page and the mint pages use, so the heading,
the controls, the count line and the grid all share one left edge and one width.
*/}
<div class="page">
<div class="index-head">
<h1>{t('mints.heading')}</h1>
<p class="lede">{t('mints.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('mints.search.label')}</label>
<input
id="mint-search"
type="text"
autocomplete="off"
placeholder={t('mints.search.placeholder')}
data-search-input
/>
<kbd aria-hidden="true">/</kbd>
</form>
<div class="control-group">
<label class="sr-only" for="mint-sort">{t('mints.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>
<option value="seen">{t('mints.sort.seen')}</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>
{/*
A pressed pill rather than a native checkbox: it is the same control the
review filters use, and it is a real button, so it keeps the focus ring.
*/}
<button class="toggle-chip" type="button" data-hide-offline aria-pressed="false">
<span class="toggle-dot" aria-hidden="true"></span>
{t('mints.hideOffline')}
</button>
</div>
</div>
<p class="result-count" data-result-count aria-live="polite">
{t('mints.showing', { total: mints.length, online, offline })}
</p>
<div class="mint-grid" data-mint-grid>
{mints.map((mint, i) => <MintCard mint={mint} rank={i + 1} capabilities={capabilities[i]} />)}
</div>
<p class="no-results" data-no-results hidden>
{t('mints.noResults')}
<button class="link-btn" type="button" data-clear-search>{t('mints.clearSearch')}</button>
</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;
}
/* Search grows; sort and toggle sit together at the right and wrap as a pair. */
.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: 16px; font-family: var(--mono); }
/*
* A card leaving the results fades and settles back a touch, 120ms, then it is
* hidden and the grid reflows around it. Positions are not animated: the browser's
* own reflow is instant and honest, and 58 cards sliding to new places would be a
* light show rather than a search result.
*/
.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) {
/* Sort and toggle take their own row, still sharing the grid's left edge. */
.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();
// Re-read on every arrival, so the count line under the filters follows the page.
// `t` formats its own numbers, so the grouping separator follows the locale too.
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);
const SORTS: Record<string, (a: HTMLElement, b: HTMLElement) => number> = {
// Default keeps the API's rule: online first, then score. Every other sort
// ranks offline mints on their merits, so a dead mint with 40 good reviews
// still shows up under "Most reviewed".
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'),
seen: (a, b) => num(b, 'lastOnline') - num(a, 'lastOnline'),
};
/**
* One pending fade per card, at most.
*
* Typing into the search box calls `apply` on every keystroke, so a card can be
* told to leave and then to come back before its fade has finished. Cancelling
* the timer is what keeps those from stacking: the card either goes out once, or
* never starts.
*/
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;
// Back in the results after being filtered out: a plain fade, no rise. It
// was here before, it is not new.
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 mint URL should find the mint, so match on the bare domain too.
const needle = query.replace(/^https?:\/\//, '').replace(/\/+$/, '');
const hide = hideOffline!.getAttribute('aria-pressed') === 'true';
let visible = 0;
let onlineCount = 0;
let offlineCount = 0;
for (const card of cards) {
const matches =
needle === '' ||
(card.dataset['name'] ?? '').includes(needle) ||
(card.dataset['domain'] ?? '').includes(needle);
const isOffline = card.dataset['status'] === 'offline';
const keep = matches && !(hide && isOffline);
show(card, keep);
if (keep) {
visible++;
if (isOffline) offlineCount++;
else if (card.dataset['status'] === 'online') onlineCount++;
}
}
const sorted = [...cards].sort(SORTS[sortSelect!.value] ?? SORTS['score']!);
for (const card of sorted) grid!.append(card);
// A rank chip is a claim about position in the default order. It means nothing
// in a rating sort and nothing in a filtered list, so it is hidden in both.
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) {
// The count is the answer to what the reader just typed, so it cross-fades
// rather than flicking from one number to another.
swapText(
countEl,
visible === cards.length
? t('mints.showing', { total: visible, online: onlineCount, offline: offlineCount })
: t('mints.showingFiltered', {
shown: visible,
total: cards.length,
online: onlineCount,
offline: offlineCount,
}),
);
}
if (noResults) noResults.hidden = visible > 0;
}
searchInput.addEventListener('input', apply);
sortSelect.addEventListener('change', () => {
apply();
// A new order is a new list: it arrives the way the grid arrived, once, and
// the stagger is capped so the last card is never more than 400ms behind.
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 quick filters: /mints?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>
+111
View File
@@ -0,0 +1,111 @@
---
/**
* Privacy. The honest version is short and unusually good, so it is stated plainly
* rather than padded into a policy.
*
* Anything that depends on how the site is deployed (what the web server logs and for
* how long) is written conditionally and marked, because this repo cannot know the
* operator's nginx and logrotate settings. See NOTES-PAGES.md.
*/
import Base from '../../layouts/Base.astro';
import { localePath, useI18n, type Locale } from '../../i18n';
import { localePaths } from '../../i18n/paths';
/** One page per locale: `/privacy`, `/es/privacy`, `/nl/privacy`. */
export const getStaticPaths = localePaths;
interface Props {
locale: Locale;
}
const { locale } = Astro.props;
const t = useI18n(locale);
const GITHUB = 'https://github.com/Azzamo-net/cashumints.space';
/** Bump `disclaimer.updated` in every catalog when the text changes. */
const updated = t('disclaimer.updated');
const description = t('privacy.description');
/*
* Three list items that open with a bold lead, and two sentences with a link. Each is
* one catalog string with a placeholder, so the lead and the link can sit wherever the
* language puts them rather than always first.
*/
const bold = (value: string): string => `<b>${value}</b>`;
const operatorNote =
bold(t('privacy.logs.operatorLabel')) + ' ' + t('privacy.logs.operator');
const thirdPartyLi1 = t('privacy.thirdParties.li1', {
strong: bold(t('privacy.thirdParties.li1Label')),
link: `<a href="${localePath('/about', locale)}#relays">${t('privacy.thirdParties.li1Link')}</a>`,
});
const thirdPartyLi2 = t('privacy.thirdParties.li2', {
strong: bold(t('privacy.thirdParties.li2Label')),
});
const contactP1 = t('privacy.contact.p1', {
github: `<a href="${GITHUB}" rel="noopener">GitHub</a>`,
});
---
<Base title={t('privacy.title')} description={description}>
<div class="page">
<div class="prose">
<h1>{t('privacy.heading')}</h1>
<p class="lede">{t('privacy.lede', { date: updated })}</p>
<h2 id="no-accounts">{t('privacy.noAccounts.title')}</h2>
<p>{t('privacy.noAccounts.p1')}</p>
<h2 id="no-tracking">{t('privacy.noTracking.title')}</h2>
<p>{t('privacy.noTracking.p1')}</p>
<h2 id="logs">{t('privacy.logs.title')}</h2>
<p>{t('privacy.logs.p1')}</p>
<p class="operator-note" set:html={operatorNote} />
<h2 id="local-storage">{t('privacy.storage.title')}</h2>
<p>{t('privacy.storage.p1')}</p>
<ul>
<li>{t('privacy.storage.li1')}</li>
<li>{t('privacy.storage.li2')}</li>
</ul>
<p>{t('privacy.storage.p2')}</p>
<h2 id="third-parties">{t('privacy.thirdParties.title')}</h2>
<p>{t('privacy.thirdParties.p1')}</p>
<ul>
<li set:html={thirdPartyLi1} />
<li set:html={thirdPartyLi2} />
</ul>
<h2 id="publishing">{t('privacy.publishing.title')}</h2>
<p>{t('privacy.publishing.p1')}</p>
<h2 id="permanent">{t('privacy.permanent.title')}</h2>
<p>{t('privacy.permanent.p1')}</p>
<ul>
<li>{t('privacy.permanent.li1')}</li>
<li>{t('privacy.permanent.li2')}</li>
<li>{t('privacy.permanent.li3')}</li>
<li>{t('privacy.permanent.li4')}</li>
</ul>
<p>{t('privacy.permanent.p2')}</p>
<h2 id="contact">{t('privacy.contact.title')}</h2>
<p set:html={contactP1} />
</div>
</div>
</Base>
<style>
/* A note addressed to whoever deploys this, not to the reader. Marked as such so it
is obvious it is placeholder text until the operator confirms the real number. */
.operator-note {
border: 1px solid var(--line); border-left: 3px solid var(--amber-dim);
border-radius: 10px; background: var(--card);
padding: 14px 16px; font-size: 13.5px;
}
.operator-note b { color: var(--text); }
</style>
+547
View File
@@ -0,0 +1,547 @@
---
/**
* The global review feed: every mint's reviews in one list, 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
* and replaces the list with everything, paginated and filterable. Cards are built by
* `lib/review-cards.ts`, the same functions the mint page's reviews panel uses, so a
* review looks and behaves identically wherever you meet it.
*/
import Base from '../../layouts/Base.astro';
import { fetchMints, fetchStats } from '../../lib/api';
import { fetchReviewFeed } from '../../lib/nostr-build';
import { reviewHtml } from '../../lib/review-cards';
import type { LoadedReview } from '../../lib/reviews-client';
import { displayDomain } from '../../lib/format';
import { localePath, useI18n, type Locale } from '../../i18n';
import { formatters } from '../../i18n/format';
import { localePaths } from '../../i18n/paths';
/** One page per locale: `/reviews`, `/es/reviews`, `/nl/reviews`. */
export const getStaticPaths = localePaths;
interface Props {
locale: Locale;
}
const { locale } = Astro.props;
const t = useI18n(locale);
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);
/**
* 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.
*/
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 mintByHost = new Map(feedMints.map((mint) => [mint.host, mint]));
const mintsPayload = JSON.stringify(feedMints).replace(/</g, '\\u003c');
/** Prerendered cards, from the same builder the island uses. */
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,
});
});
const description = t('feed.description', {
reviews: stats.reviews_total,
mints: stats.mints_total,
});
/**
* Filters and pagination need JavaScript, and so does the live list. With it off the
* prerendered cards below are the whole page, so the controls are hidden rather than
* left there doing nothing.
*/
// `!important` because Astro's scoped `.controls` rule carries an extra attribute
// selector, so a plain attribute rule here loses to it on specificity.
const noscriptFallback =
`<noscript><style>[data-feed-controls],[data-feed-pager]{display:none!important}</style>` +
`<p class="feed-note">${escapeText(t('feed.noscript', { n: feed.length }))}</p></noscript>`;
/** This block goes in as raw HTML, so the one translated string in it is escaped. */
function escapeText(value: string): string {
return value
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
/*
* One sentence, one link in the middle of it. Interpolated as markup so a translator
* can put the link where the sentence wants it.
*/
const footNote = t('feed.foot', {
link: `<a href="${localePath('/disclaimer', locale)}">${t('feed.footLink')}</a>`,
});
---
<Base title={t('feed.title')} description={description} current="reviews">
<div class="page">
<div class="index-head">
<h1>{t('feed.heading')}</h1>
<p class="lede">{t('feed.lede')}</p>
</div>
<div class="controls" data-feed-controls>
<div class="filters" role="group" aria-label={t('reviews.filter.group')}>
<button class="filter" type="button" data-filter="all" aria-pressed="true">
{t('reviews.filter.all')}<span data-count="all"></span>
</button>
<button class="filter" type="button" data-filter="5" aria-pressed="false">
{t('reviews.filter.five')}<span data-count="5"></span>
</button>
<button class="filter" type="button" data-filter="critical" aria-pressed="false">
{t('reviews.filter.critical')}<span data-count="critical"></span>
</button>
</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.
*/}
<div class="mint-filter">
<label class="sr-only" for="feed-mint">{t('feed.filterByMint')}</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>
<input
id="feed-mint"
type="text"
list="feed-mint-options"
autocomplete="off"
placeholder={t('feed.anyMint')}
data-mint-filter
/>
<button class="mint-clear" type="button" data-mint-clear hidden aria-label={t('feed.clearMint')}>
<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>
</button>
<datalist id="feed-mint-options">
{feedMints.map((mint) => <option value={mint.domain}>{mint.name}</option>)}
</datalist>
</div>
</div>
<p class="feed-count" data-feed-count aria-live="polite">
{t('feed.showingBuild', { n: feed.length })}
</p>
<Fragment set:html={noscriptFallback} />
<section class="panel feed-panel" aria-label={t('feed.label')}>
<div data-feed-items>
{cards.map((card) => <Fragment set:html={card} />)}
{
feed.length === 0 && <p class="rev-status">{t('feed.emptyBuild')}</p>
}
</div>
<div class="load-row" data-feed-pager hidden>
<div class="pager" role="group" aria-label={t('common.reviewPages')}></div>
</div>
</section>
<p class="feed-foot" set:html={footNote} />
</div>
{/*
The mint list the island resolves reviews against. `is:inline` keeps Astro from
treating a JSON payload as a module to bundle, and the `<` escape keeps a mint
name that contains markup from closing this tag early.
*/}
<script is:inline type="application/json" data-feed-mints set:html={mintsPayload}></script>
</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; }
.controls .filters { margin-left: 0; }
.mint-filter {
display: flex; align-items: center; gap: 10px; margin-left: auto;
background: var(--card); border: 1px solid var(--line);
border-radius: 999px; padding: 8px 14px; min-width: 240px;
transition: border-color .15s, box-shadow .15s;
}
.mint-filter:focus-within { border-color: var(--amber-dim); box-shadow: 0 0 0 3px rgba(240, 169, 59, .08); }
.mint-filter > svg { color: var(--faint); flex-shrink: 0; }
.mint-filter input {
flex: 1; min-width: 0; background: none; border: none; outline: none;
color: var(--text); font-family: var(--mono); font-size: 13px;
}
.mint-filter input::placeholder { color: var(--faint); font-family: var(--body); font-size: 13.5px; }
.mint-clear {
background: none; border: none; padding: 0; cursor: pointer; color: var(--faint);
display: flex; align-items: center; flex-shrink: 0;
}
.mint-clear:hover { color: var(--amber); }
.feed-count { font-size: 13px; color: var(--faint); margin-bottom: 16px; font-family: var(--mono); }
/* Injected by the noscript block above, so it cannot be a scoped selector. */
:global(.feed-note) { font-size: 13.5px; color: var(--muted); margin-bottom: 16px; max-width: 620px; }
.feed-panel { overflow: hidden; }
.load-row { padding: 16px 20px; display: flex; justify-content: center; border-top: 1px solid var(--line-soft); }
.pager { display: flex; gap: 6px; align-items: center; font-size: 13.5px; flex-wrap: wrap; justify-content: center; }
.feed-foot { margin-top: 22px; font-size: 13.5px; color: var(--faint); }
.feed-foot a { color: var(--amber); border-bottom: 1px dotted rgba(240, 169, 59, .4); }
/* 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: 640px) {
.mint-filter { margin-left: 0; width: 100%; }
}
</style>
<script>
import { escapeHtml, wireCopyableIds } from '../../lib/client';
import { useI18n } from '../../i18n/client';
import { formatters } from '../../i18n/format';
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 { applyProfiles, isWritten, reviewHtml, summaryHtml, type MintRef } from '../../lib/review-cards';
import { loadProfiles } from '../../lib/reviews-client';
/** A one-line message with the retry button after it. */
const retryLine = (message: string): string =>
`${escapeHtml(message)} <button class="rev-retry" type="button" data-feed-retry>` +
`${escapeHtml(useI18n()('common.retry'))}</button>`;
const setup = (): void => {
wireCopyableIds();
/*
* Rebuilt on every arrival. The feed is the one page where this matters most: it
* re-renders its whole list from the relays a second after load, and a translator
* captured at module scope would redraw a Dutch page's cards in English.
*/
const t = useI18n();
const f = formatters(t);
const list = document.querySelector<HTMLElement>('[data-feed-items]');
const controls = document.querySelector<HTMLElement>('[data-feed-controls]');
const countEl = document.querySelector<HTMLElement>('[data-feed-count]');
const pagerWrap = document.querySelector<HTMLElement>('[data-feed-pager]');
const mintInput = document.querySelector<HTMLInputElement>('[data-mint-filter]');
const mintClear = document.querySelector<HTMLButtonElement>('[data-mint-clear]');
const mintsJson = document.querySelector<HTMLScriptElement>('[data-feed-mints]');
if (list && controls && pagerWrap && mintInput && mintsJson) {
const pager = pagerWrap.querySelector<HTMLElement>('.pager')!;
const mints = JSON.parse(mintsJson.textContent ?? '[]') as FeedMint[];
const PAGE_SIZE = 20;
let all: FeedReview[] = [];
let rating: 'all' | '5' | 'critical' = 'all';
let mintQuery = '';
let page = 1;
/** Day summaries the reader has opened, so a filter or page change does not shut them. */
const openDays = new Set<string>();
/** At most one scheduled repaint, so a filter changed twice does not queue two. */
let pendingPaint = 0;
/* ---------- rendering ---------- */
function filtered(): FeedReview[] {
const needle = mintQuery.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/+$/, '');
return all.filter((review) => {
if (rating === '5' && review.rating !== 5) return false;
if (rating === 'critical' && (review.rating === null || review.rating > 2)) return false;
if (needle === '') return true;
return (
review.mint_domain.toLowerCase().includes(needle) ||
review.mint_name.toLowerCase().includes(needle)
);
});
}
/*
* Mint links, in the locale this page is in.
*
* Taken from the page's own URL rather than from a prop: the island rebuilds the
* list from the relays, including reviews of mints the build never saw, and a
* reader on /es/reviews clicking a card must land on /es/mint/..., not on the
* English page. The same helper the build uses does the arithmetic, so the two
* cannot spell a locale prefix differently.
*/
const pathLocale = localeFromPath(window.location.pathname);
const mintRef = (review: FeedReview): MintRef => ({
href: localePath(`/mint/${review.mint_host}`, pathLocale),
label: review.mint_domain,
});
/* ---------- feed items ---------- */
/**
* A written review is a card. Ratings with no comment are not: dozens of them a
* day would bury the reviews that say something, so every one from the same day
* collapses into that day's single summary line, which is the rule the mint page
* applies per mint. The line sits at the day's newest rating, so the feed stays
* in order, and the ratings are one click away inside it.
*/
type Item =
| { kind: 'card'; at: number; review: FeedReview }
| { kind: 'day'; at: number; key: string; label: string; reviews: FeedReview[] };
// The key is machine-facing and stays ISO in every language; only the label the
// reader sees is formatted for the locale.
const dayKey = (unix: number) => new Date(unix * 1000).toISOString().slice(0, 10);
const dayLabel = (unix: number) => f.longDate(unix);
function items(): Item[] {
const out: Item[] = [];
const days = new Map<string, Item & { kind: 'day' }>();
for (const review of filtered()) {
if (isWritten(review)) {
out.push({ kind: 'card', at: review.created_at, review });
continue;
}
const key = dayKey(review.created_at);
let day = days.get(key);
if (!day) {
day = { kind: 'day', at: review.created_at, key, label: dayLabel(review.created_at), reviews: [] };
days.set(key, day);
out.push(day);
}
day.reviews.push(review);
day.at = Math.max(day.at, review.created_at);
}
return out.sort((a, b) => b.at - a.at);
}
function itemHtml(item: Item): string {
return item.kind === 'card'
? reviewHtml(item.review, f, { mint: mintRef(item.review) })
: summaryHtml(item.reviews, f, openDays.has(item.key), {
// Every row in this page is a FeedReview: the shared signature takes the
// narrower LoadedReview because the mint page has no mint fields to pass.
mintFor: (review) => mintRef(review as FeedReview),
when: item.label,
key: item.key,
});
}
/**
* Repaint, letting the outgoing rows fade first when the reader changed
* something. Same pattern as the mint page's reviews panel, and for the same
* reason: the list is the answer to the control they just used. Only one repaint
* is ever scheduled, and it renders whatever the state is when it runs, so
* hammering a filter never stacks or queues animations.
*/
function render(animate = false): void {
if (pendingPaint) return;
if (!animate || prefersReducedMotion()) {
paint();
return;
}
list!.classList.add('leaving');
pendingPaint = window.setTimeout(() => {
pendingPaint = 0;
list!.classList.remove('leaving');
paint();
}, 120);
}
function paint(): void {
const rows = items();
if (rows.length === 0) {
list!.innerHTML =
`<p class="rev-status">${escapeHtml(
all.length === 0 ? t('feed.emptyRelays') : t('feed.emptyFilter'),
)}</p>`;
pagerWrap!.hidden = true;
if (countEl) countEl.textContent = t('feed.showing', { shown: 0, total: all.length });
return;
}
const pages = Math.ceil(rows.length / PAGE_SIZE);
page = Math.min(page, pages);
const slice = rows.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
list!.innerHTML = slice.map(itemHtml).join('');
enterStagger([...list!.children] as HTMLElement[]);
if (countEl) {
// Counted in reviews, not in rows: a day summary is one row and twelve reviews,
// and the reader is asking how many reviews they are looking at.
const shown = slice.reduce((n, item) => n + (item.kind === 'card' ? 1 : item.reviews.length), 0);
const matching = rows.reduce((n, item) => n + (item.kind === 'card' ? 1 : item.reviews.length), 0);
countEl.textContent =
matching === all.length
? t('feed.showing', { shown, total: all.length })
: t('feed.showingMatching', { shown, matching, total: all.length });
}
pagerWrap!.hidden = pages <= 1;
if (pages > 1) {
const button = (label: string, target: number, current: boolean, aria?: string) =>
`<button class="pg${current ? ' on' : ''}" type="button" data-page="${target}"${
aria ? ` aria-label="${aria}"` : ''
}${current ? ' aria-current="true"' : ''}>${label}</button>`;
// Five pages of numbers around the current one: 40 numbered buttons is not a
// control, it is a wall.
const first = Math.max(1, Math.min(page - 2, pages - 4));
const last = Math.min(pages, first + 4);
const parts = [button('‹', Math.max(1, page - 1), false, t('common.previousPage'))];
if (first > 1) parts.push(button('1', 1, false), '<span class="pg-gap">…</span>');
for (let p = first; p <= last; p++) parts.push(button(String(p), p, p === page));
if (last < pages) parts.push('<span class="pg-gap">…</span>', button(String(pages), pages, false));
parts.push(button('›', Math.min(pages, page + 1), false, t('common.nextPage')));
pager.innerHTML = parts.join('');
}
}
function updateCounts(): void {
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);
}
/* ---------- loading ---------- */
async function load(): Promise<void> {
try {
all = await loadFeed(mints);
} catch {
// The prerendered cards are still on screen and are real reviews, just older.
// Blanking them for an error message would trade content for nothing.
if (countEl) {
countEl.innerHTML = retryLine(t('feed.relayError'));
}
return;
}
if (all.length === 0) {
// Same reasoning: an empty answer from a relay pool that swallows per relay
// failures is not proof that there are no reviews.
if (countEl) {
countEl.innerHTML = retryLine(t('feed.relayEmpty'));
}
return;
}
render();
updateCounts();
try {
const profiles = await loadProfiles(all.map((review) => review.pubkey));
for (const review of all) review.profile = profiles.get(review.pubkey) ?? null;
} catch {
// Names stay as short npubs. Nothing else changes.
} finally {
applyProfiles(list!, f, (pubkey) => all.find((review) => review.pubkey === pubkey));
}
}
/* ---------- events ---------- */
controls.addEventListener('click', (event) => {
const button = (event.target as HTMLElement).closest<HTMLButtonElement>('[data-filter]');
if (!button) return;
rating = button.dataset['filter'] as typeof rating;
page = 1;
for (const other of controls!.querySelectorAll('[data-filter]')) {
other.setAttribute('aria-pressed', String(other === button));
}
render(true);
});
mintInput.addEventListener('input', () => {
mintQuery = mintInput.value;
page = 1;
if (mintClear) mintClear.hidden = mintQuery === '';
render(true);
});
mintClear?.addEventListener('click', () => {
mintInput.value = '';
mintQuery = '';
mintClear.hidden = true;
page = 1;
mintInput.focus();
render(true);
});
/*
* Remember which day summaries are open. `toggle` does not bubble, so this
* listens in the capture phase rather than on each block, which would have to be
* re-bound after every render.
*/
list.addEventListener(
'toggle',
(event) => {
const details = event.target;
if (!(details instanceof HTMLDetailsElement)) return;
const key = details.dataset['summaryKey'];
if (!key) return;
if (details.open) openDays.add(key);
else openDays.delete(key);
},
true,
);
pagerWrap.addEventListener('click', (event) => {
const button = (event.target as HTMLElement).closest<HTMLButtonElement>('[data-page]');
if (!button) return;
page = Number(button.dataset['page']);
render(true);
list!.scrollIntoView({ behavior: scrollBehavior(), block: 'start' });
});
// Delegated from the count line the retry button is injected into, rather than
// from the document: this runs again on every arrival through the router, and a
// document listener would pile up one copy per visit.
countEl?.addEventListener('click', (event) => {
if (!(event.target as HTMLElement).closest('[data-feed-retry]')) return;
countEl.textContent = t('feed.reading');
void load();
});
// A mint can be preselected from a link: /reviews?mint=mint.example.com
const preset = new URLSearchParams(window.location.search).get('mint');
if (preset) {
mintInput.value = preset;
mintQuery = preset;
if (mintClear) mintClear.hidden = false;
}
void load();
}
};
onReady(setup);
</script>
+87
View File
@@ -0,0 +1,87 @@
---
/**
* Terms of use. Short, in the site's own voice, and true of what the site actually
* does: it indexes public data and displays it. Nothing here claims a relationship
* with a mint, because there is none.
*/
import Base from '../../layouts/Base.astro';
import { localePath, useI18n, type Locale } from '../../i18n';
import { localePaths } from '../../i18n/paths';
/** One page per locale: `/terms`, `/es/terms`, `/nl/terms`. */
export const getStaticPaths = localePaths;
interface Props {
locale: Locale;
}
const { locale } = Astro.props;
const t = useI18n(locale);
const GITHUB = 'https://github.com/Azzamo-net/cashumints.space';
/**
* Dated so a reader can tell how old these terms are. Bump `disclaimer.updated` in
* every catalog when the text changes: it is written out in each language's own date
* convention rather than formatted from a timestamp, because "last updated" on a legal
* page is a fact about the text, and the text is what a translator last touched.
*/
const updated = t('disclaimer.updated');
const description = t('terms.description');
/* Sentences that carry a link, kept whole so a translator can move the link. */
const link = (href: string, label: string): string => `<a href="${href}">${label}</a>`;
const external = (href: string, label: string): string =>
`<a href="${href}" rel="noopener">${label}</a>`;
const whatLi1 = t('terms.what.li1', { code: '<code>/v1/info</code>' });
const noAccountsP2 = t('terms.noAccounts.p2', {
link: link(localePath('/disclaimer', locale), t('terms.noAccounts.link')),
});
const reviewsP2 = t('terms.reviews.p2', {
link: link(localePath('/privacy', locale), t('terms.reviews.link')),
});
const contactP1 = t('terms.contact.p1', {
github: external(GITHUB, 'GitHub'),
azzamo: external('https://azzamo.net', 'Azzamo'),
});
---
<Base title={t('terms.title')} description={description}>
<div class="page">
<div class="prose">
<h1>{t('terms.heading')}</h1>
<p class="lede">{t('terms.lede', { date: updated })}</p>
<h2 id="what-this-is">{t('terms.what.title')}</h2>
<p>{t('terms.what.p1')}</p>
<ul>
<li set:html={whatLi1} />
<li>{t('terms.what.li2')}</li>
</ul>
<p>{t('terms.what.p2')}</p>
<h2 id="no-accounts">{t('terms.noAccounts.title')}</h2>
<p>{t('terms.noAccounts.p1')}</p>
<p set:html={noAccountsP2} />
<h2 id="no-custody">{t('terms.noCustody.title')}</h2>
<p>{t('terms.noCustody.p1')}</p>
<p>{t('terms.noCustody.p2')}</p>
<h2 id="reviews">{t('terms.reviews.title')}</h2>
<p>{t('terms.reviews.p1')}</p>
<p set:html={reviewsP2} />
<p>{t('terms.reviews.p3')}</p>
<h2 id="links">{t('terms.links.title')}</h2>
<p>{t('terms.links.p1')}</p>
<h2 id="changes">{t('terms.changes.title')}</h2>
<p>{t('terms.changes.p1')}</p>
<h2 id="contact">{t('terms.contact.title')}</h2>
<p set:html={contactP1} />
</div>
</div>
</Base>
+124
View File
@@ -0,0 +1,124 @@
---
import Base from '../../layouts/Base.astro';
import { iconGradient, initials } from '../../lib/format';
import { localePath, useI18n, type Locale } from '../../i18n';
import { itemListNode } from '../../lib/schema';
import { localePaths } from '../../i18n/paths';
/** One page per locale: `/wallets`, `/es/wallets`, `/nl/wallets`. */
export const getStaticPaths = localePaths;
interface Props {
locale: Locale;
}
const { locale } = Astro.props;
const t = useI18n(locale);
/**
* The old project had no wallet directory to port: its only wallet content was an
* external link to docs.cashu.space/wallets (Header.tsx:57). See NOTES.md. This list
* is curated instead, and is deliberately short and factual.
*
* A wallet's name and its URL are what its makers call it, so both are data and neither
* is translated. The blurb is ours, and lives in the catalogs keyed by `id`. The
* platform labels are ours too: "Browser extension" is a description, not a product.
*/
const wallets = [
{ id: 'minibits', name: 'Minibits', url: 'https://www.minibits.cash', platforms: ['android'] },
{ id: 'nutstash', name: 'Nutstash', url: 'https://nutstash.app', platforms: ['web', 'extension'] },
{ id: 'enuts', name: 'eNuts', url: 'https://www.enuts.cash', platforms: ['android', 'ios'] },
{ id: 'cashume', name: 'Cashu.me', url: 'https://cashu.me', platforms: ['web'] },
{ id: 'boardwalk', name: 'Boardwalk Cash', url: 'https://boardwalkcash.com', platforms: ['web'] },
{ id: 'coinos', name: 'Coinos', url: 'https://coinos.io', platforms: ['web', 'android', 'ios'] },
{ id: 'oxchat', name: '0xchat', url: 'https://0xchat.com', platforms: ['android', 'ios'] },
{ id: 'nutshell', name: 'Nutshell', url: 'https://github.com/cashubtc/nutshell', platforms: ['cli', 'python'] },
];
const description = t('wallets.description');
/* One sentence, one link in the middle of it, so the link can move with the grammar. */
const note = t('wallets.note', {
link: '<a href="https://docs.cashu.space/wallets" rel="noopener">docs.cashu.space/wallets</a>',
});
/*
* The wallet list, as names and URLs pointing at each project's own site. Nothing
* richer: a `SoftwareApplication` per wallet would want an application category, an
* operating system list and an offer, none of which this page knows.
*/
const schema = [
itemListNode({
canonical: new URL(localePath('/wallets', locale), Astro.site).href,
fragment: 'wallets',
name: t('wallets.heading'),
items: wallets.map((wallet) => ({ name: wallet.name, url: wallet.url })),
}),
];
---
<Base title={t('wallets.title')} description={description} current="wallets" schema={schema}>
<div class="page">
<div class="prose">
<h1>{t('wallets.heading')}</h1>
<p class="lede">{t('wallets.lede')}</p>
</div>
<div class="wallet-grid">
{
wallets.map((wallet) => (
<a class="wallet-card" href={wallet.url} rel="noopener" data-reveal>
<div class="w-top">
<span class="w-icon" style={`background:${iconGradient(wallet.name)}`} aria-hidden="true">
{initials(wallet.name)}
</span>
<span class="w-id">
<span class="w-name">{wallet.name}</span>
<span class="w-platforms mono">
{wallet.platforms.map((platform) => t(`wallets.platform.${platform}`)).join(' · ')}
</span>
</span>
</div>
<p class="w-blurb">{t(`wallets.item.${wallet.id}`)}</p>
</a>
))
}
</div>
<div class="prose wallet-note">
<p set:html={note} />
</div>
</div>
</Base>
<style>
.wallet-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-top: 30px; }
.wallet-card {
border: 1px solid var(--line); border-radius: var(--radius);
background: var(--card); padding: 20px;
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);
}
.wallet-card:hover {
border-color: #3A362B;
transform: translateY(-2px);
box-shadow: 0 6px 18px rgba(0, 0, 0, .3);
}
.wallet-card:active { transform: scale(.995); }
.w-top { display: flex; align-items: center; gap: 12px; }
.w-icon {
width: 42px; height: 42px; border-radius: 11px; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
font-family: var(--display); font-weight: 700; font-size: 17px; color: #0E0D0B;
border: 1px solid rgba(255, 255, 255, .12);
}
.w-id { min-width: 0; }
.w-name { font-family: var(--display); font-weight: 600; font-size: 16.5px; letter-spacing: -.01em; display: block; }
.w-platforms { font-size: 11.5px; color: var(--faint); display: block; }
.w-blurb { font-size: 13.5px; color: var(--muted); line-height: 1.6; }
.wallet-note { margin-top: 34px; }
@media (max-width: 980px) { .wallet-grid { grid-template-columns: 1fr 1fr; } }
@media (max-width: 640px) { .wallet-grid { grid-template-columns: 1fr; } }
</style>
+35
View File
@@ -0,0 +1,35 @@
/**
* robots.txt. Generated rather than dropped in public/ so the Sitemap line follows
* SITE_URL instead of being hardcoded to the production host.
*/
import type { APIRoute } from 'astro';
export const GET: APIRoute = ({ site }) => {
const origin = (site ?? new URL('https://cashumints.space')).origin;
/*
* Every page here is public data meant to be found, and nothing under a page is
* disallowed: the CSS, the JavaScript and the fonts all have to be fetchable or a
* crawler renders the site wrong and judges it on the result.
*
* The two exceptions are not pages. `/api/` is the JSON the site is built from and
* `/icons/` is the mint icon cache; both are same-origin by default (nginx proxies
* them to the indexer) so without these lines a crawler would work its way through
* an API surface that has no titles, no canonicals and no links out. The icons are
* decorative tiles rendered with `alt=""`, so dropping them out of image search
* costs nothing.
*/
const body = [
'User-agent: *',
'Allow: /',
'',
'# Not pages: the JSON API the site is built from, and the mint icon cache.',
'Disallow: /api/',
'Disallow: /icons/',
'',
`Sitemap: ${origin}/sitemap.xml`,
'',
].join('\n');
return new Response(body, { headers: { 'Content-Type': 'text/plain; charset=utf-8' } });
};
+141
View File
@@ -0,0 +1,141 @@
/**
* sitemap.xml, generated at build time.
*
* Written by hand rather than with @astrojs/sitemap because the mint pages have a real
* lastmod available (the last time the mint was seen or reviewed) and the integration
* would stamp every URL with the build time instead, which tells a crawler nothing.
*
* Every emitted page is listed, in every locale, and every entry carries the full
* `xhtml:link` alternate set. That is the sitemap half of the hreflang contract: the
* same reciprocal relationships the pages declare in their own `<head>`, declared again
* where a crawler reads them before it fetches anything. Both halves say the same
* thing, which is what makes either of them believable.
*
* /404 is deliberately absent, in every locale.
*/
import type { APIRoute } from 'astro';
import { fetchMints } from '../lib/api';
import { alternates } from '../i18n/routing';
import { isIndexableMint } from '../lib/seo';
import { DEFAULT_LOCALE, LOCALE_CODES } from '../i18n/config';
interface Entry {
/** The unprefixed path. One entry covers all of its locales. */
path: string;
lastmod: number;
changefreq: 'daily' | 'weekly' | 'monthly';
/**
* Relative importance within this site, which is all `priority` has ever meant. It
* says nothing to a crawler about ranking; it says which of these URLs to come back
* to first. Everything being 1.0 said nothing at all.
*/
priority: number;
}
/** Static routes, with how often each one's content actually moves. */
const STATIC_PAGES: Array<Omit<Entry, 'lastmod'>> = [
{ path: '/', changefreq: 'daily', priority: 1.0 },
{ path: '/mints', changefreq: 'daily', priority: 0.9 },
{ path: '/reviews', changefreq: 'daily', priority: 0.7 },
{ path: '/wallets', changefreq: 'monthly', priority: 0.5 },
{ path: '/about', changefreq: 'monthly', priority: 0.5 },
{ path: '/disclaimer', changefreq: 'monthly', priority: 0.3 },
{ path: '/terms', changefreq: 'monthly', priority: 0.3 },
{ path: '/privacy', changefreq: 'monthly', priority: 0.3 },
];
/**
* Every mint page carries the same priority.
*
* Deriving it from the score was tempting and would be wrong: the ranking moves daily,
* so every nightly build would rewrite most of the file with changes that mean nothing
* to a crawler. A flat value says "these are all mint pages", which is true.
*/
const MINT_PRIORITY = 0.8;
/**
* 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
* English. It is not a claim that the translations are worth less.
*/
const LOCALE_FACTOR = 0.8;
/** This entry's priority in this locale, to one decimal. */
const priority = (entry: Entry, locale: string): string =>
(entry.priority * (locale === DEFAULT_LOCALE ? 1 : LOCALE_FACTOR)).toFixed(1);
const isoDay = (unixSeconds: number): string =>
new Date(unixSeconds * 1000).toISOString().slice(0, 10);
const escapeXml = (value: string): string =>
value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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 entries: Entry[] = [
...STATIC_PAGES.map((page) => ({ ...page, lastmod: builtAt })),
/*
* Only the mints whose pages are indexable.
*
* `isIndexableMint` is the same predicate `mint/[host].astro` uses to decide the
* page's robots meta, imported rather than restated so the two cannot drift. A
* sitemap entry for a page that says `noindex` is a contradiction, and
* `check-hreflang.mjs` fails the build on it, checking the built HTML against the
* built sitemap rather than trusting that these two files still agree.
*/
...mints.filter(isIndexableMint).map((mint) => ({
path: `/mint/${mint.host}`,
// The newest thing that could have changed the page. Every mint reaching this
// point has been online or been reviewed at least once, so one of the two is set.
lastmod: Math.max(mint.last_review_at ?? 0, mint.last_online ?? 0) || builtAt,
changefreq: 'weekly' as const,
priority: MINT_PRIORITY,
})),
];
/**
* One `<url>` per locale per page, each listing every locale.
*
* A crawler expects to find the alternate set on every member of it, not only on the
* default one: an entry that lists alternates which do not list it back has the whole
* set discarded. So the same block is repeated under each locale's `<loc>`, which is
* the documented shape and is why this file is longer than it looks like it should be.
*/
const urls = entries.flatMap((entry) => {
const links = alternates(entry.path, origin)
.map(
(alternate) =>
` <xhtml:link rel="alternate" hreflang="${alternate.hreflang}" ` +
`href="${escapeXml(alternate.href)}" />\n`,
)
.join('');
return LOCALE_CODES.map((locale) => {
const self = alternates(entry.path, origin).find(
(alternate) => alternate.hreflang === locale,
);
return (
` <url>\n` +
` <loc>${escapeXml(self?.href ?? '')}</loc>\n` +
links +
` <lastmod>${isoDay(entry.lastmod)}</lastmod>\n` +
` <changefreq>${entry.changefreq}</changefreq>\n` +
` <priority>${priority(entry, locale)}</priority>\n` +
` </url>\n`
);
});
});
const body =
`<?xml version="1.0" encoding="UTF-8"?>\n` +
`<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"\n` +
` xmlns:xhtml="http://www.w3.org/1999/xhtml">\n` +
urls.join('') +
`</urlset>\n`;
return new Response(body, { headers: { 'Content-Type': 'application/xml; charset=utf-8' } });
};