@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/*
|
||||
* 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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' } });
|
||||
};
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
|
||||
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' } });
|
||||
};
|
||||
Reference in New Issue
Block a user