Expand ecash explorer capabilities
Add Fedimint discovery, dual SQLite/Postgres storage, richer review handling, and generated social imagery.
This commit is contained in:
@@ -16,6 +16,7 @@ const t = useI18n(locale);
|
||||
*/
|
||||
const nav = [
|
||||
{ path: '/mints', label: t('footer.allMints') },
|
||||
{ path: '/fedimints', label: t('footer.allFedimints') },
|
||||
{ path: '/reviews', label: t('footer.allReviews') },
|
||||
{ path: '/wallets', label: t('footer.wallets') },
|
||||
{ path: '/about', label: t('footer.about') },
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
---
|
||||
/**
|
||||
* The invite codes block on a federation page.
|
||||
*
|
||||
* An invite code is the whole point of the page: it is what a wallet needs to join, it
|
||||
* is 150 to 300 characters long, and it is opaque. So it gets the two affordances that
|
||||
* length demands and nothing else — one click to copy the whole thing, and a QR to
|
||||
* point a phone at.
|
||||
*
|
||||
* Both are prerendered. The code is truncated in the middle by CSS-free string maths
|
||||
* (`shortInviteCode`) rather than by an ellipsis rule, so the tail is visible: two codes
|
||||
* for the same federation differ at the end, and a row that cuts there shows two
|
||||
* identical rows. The QR is encoded at build time like the mint page's, so opening it
|
||||
* costs no JavaScript beyond the toggle itself and works on a page whose island never
|
||||
* loaded — the panel is a `<details>`, which opens on its own.
|
||||
*
|
||||
* Most federations publish exactly one code. Some publish several (different guardian
|
||||
* address sets for the same federation), and every one of them is equally valid, so
|
||||
* they are all listed rather than one being picked.
|
||||
*/
|
||||
import { shortInviteCode } from '@cashumints/shared';
|
||||
import { qrSvg } from '../lib/qr';
|
||||
import { useI18n } from '../i18n';
|
||||
import { pageLocale } from '../i18n/paths';
|
||||
|
||||
interface Props {
|
||||
codes: string[];
|
||||
/** Named in the QR's caption, so a screenshot of it says which federation it joins. */
|
||||
name: string;
|
||||
}
|
||||
|
||||
const { codes, name } = Astro.props;
|
||||
|
||||
const { locale } = pageLocale(Astro);
|
||||
const t = useI18n(locale);
|
||||
|
||||
/*
|
||||
* Encoded once per code, at build time.
|
||||
*
|
||||
* An invite code is long enough that its QR runs to a high version with small modules,
|
||||
* which is exactly why the frame below is given as much width as the panel has: a
|
||||
* 300 character code at 180px is not readable by a phone camera.
|
||||
*/
|
||||
const rows = codes.map((code) => ({
|
||||
code,
|
||||
short: shortInviteCode(code),
|
||||
qr: qrSvg(code),
|
||||
}));
|
||||
---
|
||||
|
||||
<div class="panel invite-panel" data-reveal>
|
||||
<h2 class="side-title">
|
||||
{t('fedimint.invite.title')}
|
||||
{rows.length > 1 && <span class="invite-count">{t('fedimint.invite.count', { n: rows.length })}</span>}
|
||||
</h2>
|
||||
<p class="invite-note">{t('fedimint.invite.note')}</p>
|
||||
|
||||
{
|
||||
rows.map((row, index) => (
|
||||
<div class="invite">
|
||||
<div class="invite-row">
|
||||
{/*
|
||||
The code is the copy control itself, not a label beside one: it is the only
|
||||
thing on this page anybody copies, and hiding that behind a small icon
|
||||
button would be making the primary action the least visible.
|
||||
*/}
|
||||
<button
|
||||
type="button"
|
||||
class="cid invite-code"
|
||||
data-copy-id={row.code}
|
||||
title={row.code}
|
||||
aria-label={t('common.copyFull', { kind: t('fedimint.invite.kind') })}
|
||||
>
|
||||
<span class="cid-text mono">{row.short}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="invite-qr-btn"
|
||||
data-invite-qr={index}
|
||||
aria-expanded="false"
|
||||
aria-controls={`invite-qr-${index}`}
|
||||
>
|
||||
<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>
|
||||
<span>{t('fedimint.invite.qr')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
Hidden rather than absent, so the toggle has something to reveal without a
|
||||
round trip and the code is in the markup for anything that reads it.
|
||||
*/}
|
||||
<div class="invite-qr" id={`invite-qr-${index}`} hidden>
|
||||
<div class="qr-frame">
|
||||
<Fragment set:html={row.qr} />
|
||||
</div>
|
||||
<p class="invite-qr-cap">{t('fedimint.invite.qrCaption', { name })}</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.invite-panel { padding: 20px; }
|
||||
.side-title { display: flex; align-items: baseline; gap: 8px; }
|
||||
.invite-count { font-family: var(--mono); font-size: 11px; color: var(--faint); letter-spacing: 0; text-transform: none; }
|
||||
.invite-note { font-size: 12.5px; color: var(--muted); line-height: 1.55; margin-bottom: 12px; }
|
||||
|
||||
.invite + .invite { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--line-soft); }
|
||||
.invite-row { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
/* The code fills the row and the QR button keeps its own width beside it. */
|
||||
.invite-code {
|
||||
flex: 1; min-width: 0; justify-content: flex-start;
|
||||
background: var(--card2); border: 1px solid var(--line-soft); border-radius: 9px;
|
||||
padding: 9px 11px; font-size: 12.5px; color: var(--text);
|
||||
}
|
||||
.invite-code:hover { color: var(--amber); border-color: #38342A; }
|
||||
.invite-code .cid-text { overflow-wrap: anywhere; }
|
||||
|
||||
.invite-qr-btn {
|
||||
display: inline-flex; align-items: center; gap: 7px; flex-shrink: 0;
|
||||
font-family: var(--body); font-size: 12.5px; font-weight: 500; color: var(--muted);
|
||||
background: none; border: 1px solid var(--line); border-radius: 9px;
|
||||
padding: 9px 12px; cursor: pointer;
|
||||
transition: color .15s, border-color .15s;
|
||||
}
|
||||
.invite-qr-btn:hover { color: var(--text); border-color: #38342A; }
|
||||
.invite-qr-btn[aria-expanded='true'] { color: var(--amber); border-color: var(--amber-dim); }
|
||||
|
||||
.invite-qr { margin-top: 12px; display: flex; flex-direction: column; align-items: center; gap: 8px; }
|
||||
/* `display: flex` above outranks the `hidden` attribute's own `display: none`, so
|
||||
the code opened on page load until this said otherwise. */
|
||||
.invite-qr[hidden] { display: none; }
|
||||
/*
|
||||
* The white plate is the quiet zone a camera needs, so it stays white in every theme.
|
||||
* Wider than the mint page's 236px: an invite code encodes to a much denser code, and
|
||||
* at that size the modules are too small for a phone to resolve.
|
||||
*/
|
||||
.invite-qr .qr-frame {
|
||||
background: #FFFFFF; border-radius: 12px; padding: 12px;
|
||||
width: 100%; max-width: 300px; aspect-ratio: 1; box-sizing: border-box;
|
||||
}
|
||||
.invite-qr .qr-frame :global(svg) { display: block; }
|
||||
.invite-qr-cap { font-size: 11.5px; color: var(--faint); text-align: center; }
|
||||
|
||||
@media (max-width: 420px) {
|
||||
/* Two controls on one row stop fitting; the code keeps the full width. */
|
||||
.invite-row { flex-wrap: wrap; }
|
||||
.invite-qr-btn { width: 100%; justify-content: center; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import { wireCopyableIds } from '../lib/client';
|
||||
import { onReady, prefersReducedMotion } from '../scripts/reveal';
|
||||
|
||||
onReady(() => {
|
||||
// The codes are ordinary copyable identifiers; one delegated handler drives every
|
||||
// one of them, the same one the npubs on a review card use.
|
||||
wireCopyableIds();
|
||||
|
||||
for (const button of document.querySelectorAll<HTMLButtonElement>('[data-invite-qr]')) {
|
||||
if (button.dataset['qrWired']) continue;
|
||||
button.dataset['qrWired'] = '1';
|
||||
|
||||
button.addEventListener('click', () => {
|
||||
const panel = document.getElementById(button.getAttribute('aria-controls') ?? '');
|
||||
if (!panel) return;
|
||||
const open = button.getAttribute('aria-expanded') === 'true';
|
||||
button.setAttribute('aria-expanded', String(!open));
|
||||
panel.hidden = open;
|
||||
// The code arrives rather than appearing, which reads as a response to the
|
||||
// press. Nothing moves under it: the panel is the last thing in its block.
|
||||
if (!open && !prefersReducedMotion()) {
|
||||
panel.classList.add('appearing');
|
||||
panel.addEventListener('animationend', () => panel.classList.remove('appearing'), { once: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -1,5 +1,8 @@
|
||||
---
|
||||
import { getMintWarnings, mintChip, type MintCapabilities, type MintListItem } from '@cashumints/shared';
|
||||
import {
|
||||
federationIdFromSlug, getMintWarnings, mintChip,
|
||||
type MintCapabilities, type MintListItem,
|
||||
} from '@cashumints/shared';
|
||||
import { iconUrl } from '../lib/api';
|
||||
import { displayDomain, iconGradient, initials, transitionName } from '../lib/format';
|
||||
import { localePath, useI18n } from '../i18n';
|
||||
@@ -33,10 +36,28 @@ const { locale } = pageLocale(Astro);
|
||||
const t = useI18n(locale);
|
||||
const f = formatters(t);
|
||||
|
||||
const domain = displayDomain(mint.url);
|
||||
const name = mint.name ?? domain.split('/')[0] ?? domain;
|
||||
/*
|
||||
* One card, both ecosystems.
|
||||
*
|
||||
* A federation has no URL, so the second line cannot be a domain: `fedimint:aeca6c…`
|
||||
* is a database key and means nothing to a reader. It shows the federation id instead,
|
||||
* shortened, which is the thing that actually identifies it — and which is what the
|
||||
* search box below matches on, so pasting an id finds its federation.
|
||||
*
|
||||
* Everything else on the card is identical, and deliberately: rating, review count,
|
||||
* sentiment bar and status all mean the same thing for both, and a reader moving
|
||||
* between /mints and /fedimints should not have to re-learn a card.
|
||||
*/
|
||||
const fedimint = mint.type === 'fedimint';
|
||||
const domain = fedimint ? federationIdFromSlug(mint.host) : displayDomain(mint.url);
|
||||
const name = fedimint
|
||||
? mint.name ?? t('card.unnamedFederation')
|
||||
: mint.name ?? domain.split('/')[0] ?? domain;
|
||||
const icon = iconUrl(mint.icon);
|
||||
const offline = mint.status === 'offline';
|
||||
/** Announced on Nostr and never confirmed by any check. Federations only. */
|
||||
const announced = mint.status === 'announced';
|
||||
const href = localePath(`${fedimint ? '/fedimint' : '/mint'}/${mint.host}`, locale);
|
||||
|
||||
/*
|
||||
Two words at most. Offline already has its own card styling, so only the states a
|
||||
@@ -87,7 +108,7 @@ const bar =
|
||||
*/}
|
||||
<a
|
||||
class:list={['mint-card', { 'is-offline': offline }]}
|
||||
href={localePath(`/mint/${mint.host}`, locale)}
|
||||
href={href}
|
||||
data-reveal
|
||||
data-reveal-delay={revealDelay}
|
||||
data-mint-card
|
||||
@@ -126,7 +147,7 @@ const bar =
|
||||
}
|
||||
<span class="mc-id">
|
||||
<span class="mc-name" data-vt-name={transitionName('name', mint.host)}>{name}</span>
|
||||
<span class="mc-domain">{domain}</span>
|
||||
<span class:list={['mc-domain', { mono: fedimint }]}>{domain}</span>
|
||||
</span>
|
||||
{rank !== undefined && <span class:list={['mc-rank', { gold: rank === 1 }]}>#{rank}</span>}
|
||||
</div>
|
||||
@@ -164,13 +185,20 @@ const bar =
|
||||
{chip && <span class:list={['mc-chip', chip.severity]}>{chip.label}</span>}
|
||||
<span class="last">
|
||||
{
|
||||
offline
|
||||
? mint.last_online
|
||||
? t('card.lastSeen', { when: f.relative(mint.last_online) })
|
||||
: t('card.neverSeen')
|
||||
: mint.last_review_at
|
||||
? t('card.reviewed', { when: f.relative(mint.last_review_at) })
|
||||
: t('card.noReviews')
|
||||
/*
|
||||
"Announced" has no "last seen" to print, and printing one anyway is exactly
|
||||
the fake status this site refuses to show. What it has instead is the fact
|
||||
that nothing has checked it, said in as many words.
|
||||
*/
|
||||
announced
|
||||
? t('card.notChecked')
|
||||
: offline
|
||||
? mint.last_online
|
||||
? t('card.lastSeen', { when: f.relative(mint.last_online) })
|
||||
: t('card.neverSeen')
|
||||
: mint.last_review_at
|
||||
? t('card.reviewed', { when: f.relative(mint.last_review_at) })
|
||||
: t('card.noReviews')
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,12 @@
|
||||
* The banner, the limits cell and the NUT rows are rebuilt from lib/mint-state.ts,
|
||||
* the same code that prerendered them, so a live upgrade can never leave the three
|
||||
* disagreeing with each other.
|
||||
*
|
||||
* Both a mint page and a federation page mount this, unchanged. It fetches the same
|
||||
* endpoint for both and updates whichever of the four regions the page actually has:
|
||||
* a federation has no limits cell and no NUT rows, and a mint has no modules panel, so
|
||||
* each lookup simply misses. `getMintWarnings` branches on the `type` in the payload,
|
||||
* so a fresh read can never put a Cashu banner on a federation.
|
||||
*/
|
||||
interface Props {
|
||||
host: string;
|
||||
@@ -23,6 +29,7 @@ const { host } = Astro.props;
|
||||
import { getMintWarnings, readCapabilities, type MintWarning } from '@cashumints/shared';
|
||||
import { apiBase } from '../lib/client';
|
||||
import { bannerHtml, limitsHtml, nutRowsHtml } from '../lib/mint-state';
|
||||
import { moduleRowsHtml } from '../lib/fedimint-ui';
|
||||
import { useI18n } from '../i18n/client';
|
||||
import { formatters, type Formatters } from '../i18n/format';
|
||||
import { statusLabel, warningOptions } from '../i18n/mint';
|
||||
@@ -77,8 +84,20 @@ const { host } = Astro.props;
|
||||
else head.after(banner);
|
||||
}
|
||||
|
||||
/** Keep the two places that repeat the banner's facts in step with it. */
|
||||
/** Keep the places that repeat the banner's facts in step with it. */
|
||||
function renderCapabilities(mint: MintDetail, f: Formatters): void {
|
||||
/*
|
||||
* A federation's modules panel, rebuilt from a fresh announcement. Handled before
|
||||
* the NUT work rather than after it because the two are mutually exclusive: the
|
||||
* element only exists on a federation page, and `readCapabilities` on a payload
|
||||
* with no `info` would be reading NUT switches that federations do not have.
|
||||
*/
|
||||
const modules = document.querySelector<HTMLElement>('[data-live-modules]');
|
||||
if (modules) {
|
||||
modules.innerHTML = moduleRowsHtml(mint.modules ?? [], f);
|
||||
return;
|
||||
}
|
||||
|
||||
const caps = readCapabilities(mint.info?.nuts);
|
||||
|
||||
const limits = document.querySelector<HTMLElement>('[data-live-limits]');
|
||||
@@ -112,7 +131,7 @@ const { host } = Astro.props;
|
||||
const chip = document.querySelector<HTMLElement>('[data-live-status]');
|
||||
const label = document.querySelector<HTMLElement>('[data-live-status-label]');
|
||||
if (chip && label) {
|
||||
chip.classList.remove('online', 'offline', 'degraded', 'unknown');
|
||||
chip.classList.remove('online', 'offline', 'degraded', 'unknown', 'announced');
|
||||
chip.classList.add(mint.status);
|
||||
label.textContent = statusLabel(mint.status, t);
|
||||
|
||||
@@ -127,8 +146,15 @@ const { host } = Astro.props;
|
||||
renderBanner(mint, f);
|
||||
renderCapabilities(mint, f);
|
||||
|
||||
/*
|
||||
* The "checked" line, on a mint page only.
|
||||
*
|
||||
* A federation's equivalent cell says who confirmed it as well as when, in two
|
||||
* elements rather than one string, and is left to the page: rewriting it from
|
||||
* here would mean this island owning a second wording it cannot see.
|
||||
*/
|
||||
const checked = document.querySelector<HTMLElement>('[data-live-checked]');
|
||||
if (checked) {
|
||||
if (checked && mint.type !== 'fedimint') {
|
||||
checked.textContent =
|
||||
mint.status !== 'offline' ? t('mint.checked', { when: f.relative(mint.last_probe) })
|
||||
: mint.last_online ? t('mint.lastSeen', { when: f.relative(mint.last_online) })
|
||||
|
||||
@@ -5,8 +5,13 @@
|
||||
* Prerendered with the counts the API already knows, so the panel occupies its final
|
||||
* space and shows real numbers with JavaScript off. Review bodies live on Nostr, so
|
||||
* the list itself hydrates from relays on load.
|
||||
*
|
||||
* Used by both a mint page and a federation page, unchanged. What is being reviewed
|
||||
* arrives as a `ReviewSubject` — which `k`, which `d`, which `u` — and nothing below
|
||||
* this line asks which ecosystem produced it. See `lib/review-subject.ts`.
|
||||
*/
|
||||
import type { MintDetail } from '@cashumints/shared';
|
||||
import type { RatingDistribution } from '@cashumints/shared';
|
||||
import type { ReviewSubject } from '../lib/review-subject';
|
||||
import { renderBones } from 'boneyard-js';
|
||||
import reviewsBones from '../bones/reviews-panel.bones.json';
|
||||
import { BONE_COLOR } from '../lib/skeleton';
|
||||
@@ -15,15 +20,33 @@ import { formatters } from '../i18n/format';
|
||||
import { pageLocale } from '../i18n/paths';
|
||||
|
||||
interface Props {
|
||||
mint: MintDetail;
|
||||
/** What is being reviewed: the tags a review carries and the filters that find them. */
|
||||
subject: ReviewSubject;
|
||||
/** The counts the API already knows, prerendered so the panel is honest without JS. */
|
||||
reviewCount: number;
|
||||
distribution: RatingDistribution;
|
||||
/** Named in the write dialog's heading. The thing's own name, never translated. */
|
||||
name: string;
|
||||
}
|
||||
const { mint } = Astro.props;
|
||||
const { subject, reviewCount, distribution, name } = Astro.props;
|
||||
|
||||
const { locale } = pageLocale(Astro);
|
||||
const t = useI18n(locale);
|
||||
const f = formatters(t);
|
||||
|
||||
const dist = mint.rating_distribution;
|
||||
const dist = distribution;
|
||||
|
||||
/*
|
||||
* The placeholder asks about the thing being reviewed, which is the one sentence in
|
||||
* this panel that cannot be ecosystem-neutral without going vague: "how did minting and
|
||||
* melting work out" means nothing about a federation, and "how did it go" means nothing
|
||||
* about anything. One key per ecosystem, falling back to the Cashu wording for a type
|
||||
* whose own has not been written yet.
|
||||
*/
|
||||
const placeholderKey = `reviews.dialog.bodyPlaceholder.${subject.type}`;
|
||||
const bodyPlaceholder = t.has(placeholderKey)
|
||||
? t(placeholderKey)
|
||||
: t('reviews.dialog.bodyPlaceholder.cashu');
|
||||
const critical = dist['1'] + dist['2'];
|
||||
const fiveStar = dist['5'];
|
||||
|
||||
@@ -44,7 +67,7 @@ const boneBlocks = Object.entries(reviewsBones.breakpoints)
|
||||
|
||||
// Bones stand in for reviews the API has already counted. With none counted there is
|
||||
// nothing to wait for, and the empty state below is the truthful thing to prerender.
|
||||
const showBoneBlocks = mint.review_count > 0 && boneBlocks.length > 0;
|
||||
const showBoneBlocks = reviewCount > 0 && boneBlocks.length > 0;
|
||||
|
||||
/**
|
||||
* Without JavaScript the relays are never queried, so the bones would sit there for
|
||||
@@ -75,9 +98,7 @@ function escapeText(value: string): string {
|
||||
aria-labelledby="reviews-title"
|
||||
data-reveal
|
||||
data-reviews-panel
|
||||
data-mint-url={mint.url}
|
||||
data-mint-pubkey={mint.pubkey ?? ''}
|
||||
data-mint-name={mint.name ?? mint.host}
|
||||
data-subject={JSON.stringify(subject)}
|
||||
>
|
||||
<div class="panel-head">
|
||||
<h2 class="panel-title" id="reviews-title">{t('reviews.title')}</h2>
|
||||
@@ -85,7 +106,7 @@ function escapeText(value: string): string {
|
||||
<span class="nip-badge">NIP-87</span>
|
||||
<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">{f.number(mint.review_count)}</span>
|
||||
{t('reviews.filter.all')} · <span data-count="all">{f.number(reviewCount)}</span>
|
||||
</button>
|
||||
<button class="filter" type="button" data-filter="5" aria-pressed="false">
|
||||
{t('reviews.filter.five')} · <span data-count="5">{f.number(fiveStar)}</span>
|
||||
@@ -111,7 +132,7 @@ function escapeText(value: string): string {
|
||||
counts above are prerendered and stay put. `data-boneyard` marks the capture
|
||||
target for `pnpm bones`, which snapshots this element's single child.
|
||||
*/}
|
||||
<div data-review-list data-review-count={mint.review_count}>
|
||||
<div data-review-list data-review-count={reviewCount}>
|
||||
<div data-boneyard="reviews-panel" data-boneyard-config='{"excludeSelectors":[".sr-only"]}'>
|
||||
<div data-review-items>
|
||||
{
|
||||
@@ -123,16 +144,16 @@ function escapeText(value: string): string {
|
||||
))}
|
||||
</div>
|
||||
<p class="sr-only" role="status">
|
||||
{t('reviews.loading', { n: mint.review_count })}
|
||||
{t('reviews.loading', { n: reviewCount })}
|
||||
</p>
|
||||
<Fragment set:html={noscriptFallback} />
|
||||
</Fragment>
|
||||
) : (
|
||||
<p class="rev-status" data-review-status>
|
||||
{
|
||||
mint.review_count === 0
|
||||
reviewCount === 0
|
||||
? t('reviews.empty')
|
||||
: `${t('reviews.loading', { n: mint.review_count })}…`
|
||||
: `${t('reviews.loading', { n: reviewCount })}…`
|
||||
}
|
||||
</p>
|
||||
)
|
||||
@@ -162,7 +183,7 @@ function escapeText(value: string): string {
|
||||
<form method="dialog" class="modal-body write-form" data-write-form novalidate>
|
||||
<div class="modal-head">
|
||||
{/* The mint's name is data, dropped into a translated sentence. */}
|
||||
<h2 id="write-title">{t('reviews.dialog.title', { name: mint.name ?? mint.host })}</h2>
|
||||
<h2 id="write-title">{t('reviews.dialog.title', { name })}</h2>
|
||||
<button type="button" class="copy-btn" data-write-close aria-label={t('common.close')}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M18 6 6 18M6 6l12 12" />
|
||||
@@ -197,7 +218,7 @@ function escapeText(value: string): string {
|
||||
name="content"
|
||||
rows="5"
|
||||
aria-describedby="review-count"
|
||||
placeholder={t('reviews.dialog.bodyPlaceholder')}></textarea>
|
||||
placeholder={bodyPlaceholder}></textarea>
|
||||
<span class="write-count" id="review-count" data-char-count>
|
||||
{t('reviews.dialog.charCount', { n: 0, max: 2000 })}
|
||||
</span>
|
||||
@@ -232,6 +253,7 @@ function escapeText(value: string): string {
|
||||
import { formatters } from '../i18n/format';
|
||||
import { enterStagger, onReady, prefersReducedMotion, scrollBehavior } from '../scripts/reveal';
|
||||
import { getExtension, loadProfiles, loadReviews, publishReview, safeNpub, type LoadedReview } from '../lib/reviews-client';
|
||||
import type { ReviewSubject } from '../lib/review-subject';
|
||||
import { activeSigner, onSessionChange, onSignerStatus, readSession, signerErrorMessage } from '../lib/nostr-signer';
|
||||
// The identity row is the same identity the header shows, drawn by the same helpers.
|
||||
import { avatarMarkup, identityLines, methodChipHtml, openLogin, resolveProfile, toNpub } from '../lib/auth-ui';
|
||||
@@ -256,9 +278,34 @@ function escapeText(value: string): string {
|
||||
const f = formatters(t);
|
||||
|
||||
const root = document.querySelector<HTMLElement>('[data-reviews-panel]');
|
||||
if (root) {
|
||||
const mintUrl = root.dataset['mintUrl'] ?? '';
|
||||
const mintPubkey = root.dataset['mintPubkey'] || null;
|
||||
|
||||
/*
|
||||
* The subject crosses from the page as JSON in a data attribute rather than as
|
||||
* separate attributes per field. It is one object with one meaning, the page and the
|
||||
* island are the same build, and a malformed one is a bug rather than user input —
|
||||
* but it is still parsed defensively, because a panel that threw here would take the
|
||||
* write dialog down with it.
|
||||
*
|
||||
* A function returning a const rather than a `let` assigned in a try: the closures
|
||||
* below capture it, and TypeScript will not carry a narrowing into a closure over a
|
||||
* mutable binding.
|
||||
*/
|
||||
const readSubject = (): ReviewSubject | null => {
|
||||
try {
|
||||
return JSON.parse(root?.dataset['subject'] ?? 'null') as ReviewSubject | null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const parsedSubject = readSubject();
|
||||
|
||||
if (root && parsedSubject) {
|
||||
// Re-bound inside the guard as a non-nullable const. `load` below is a hoisted
|
||||
// function declaration, and TypeScript will not carry the guard's narrowing into
|
||||
// one of those; this is the local it can.
|
||||
const subject: ReviewSubject = parsedSubject;
|
||||
|
||||
const listWrap = root.querySelector<HTMLElement>('[data-review-list]')!;
|
||||
const list = root.querySelector<HTMLElement>('[data-review-items]')!;
|
||||
const summarySlot = root.querySelector<HTMLElement>('[data-rating-summary]')!;
|
||||
@@ -464,7 +511,7 @@ function escapeText(value: string): string {
|
||||
|
||||
let loaded: LoadedReview[];
|
||||
try {
|
||||
loaded = await withTimeout(loadReviews(mintUrl, mintPubkey), RELAY_TIMEOUT_MS);
|
||||
loaded = await withTimeout(loadReviews(subject), RELAY_TIMEOUT_MS);
|
||||
} catch {
|
||||
showRelayError();
|
||||
return;
|
||||
@@ -589,7 +636,7 @@ function escapeText(value: string): string {
|
||||
* is the life of the intention. Nothing here is secret, but nothing here belongs
|
||||
* on the machine a week later either.
|
||||
*/
|
||||
const DRAFT_KEY = `cashumints:draft:${mintUrl}`;
|
||||
const DRAFT_KEY = `cashumints:draft:${subject.key}`;
|
||||
|
||||
function saveDraft(): void {
|
||||
if (!form) return;
|
||||
@@ -900,8 +947,7 @@ function escapeText(value: string): string {
|
||||
|
||||
const result = await publishReview({
|
||||
signer,
|
||||
mintUrl,
|
||||
mintPubkey,
|
||||
subject,
|
||||
rating,
|
||||
content,
|
||||
onSigned: (signed) => {
|
||||
@@ -928,7 +974,7 @@ function escapeText(value: string): string {
|
||||
created_at: result.event.created_at,
|
||||
rating,
|
||||
content: content.trim(),
|
||||
mint_url: mintUrl,
|
||||
mint_url: subject.key,
|
||||
profile: null,
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { localePath, useI18n } from '../i18n';
|
||||
import { pageLocale } from '../i18n/paths';
|
||||
|
||||
interface Props {
|
||||
current?: 'mints' | 'reviews' | 'wallets' | 'about';
|
||||
current?: 'mints' | 'fedimints' | 'reviews' | 'wallets' | 'about';
|
||||
/** Shows the compact search affordance next to the brand (mint pages, /mints). */
|
||||
mintCount?: number;
|
||||
}
|
||||
@@ -18,8 +18,18 @@ const t = useI18n(locale);
|
||||
* Route slugs stay English in every language, so the href is the English path run
|
||||
* through `localePath`. See src/i18n/routing.ts for why that trade was made.
|
||||
*/
|
||||
/*
|
||||
* "Cashu mints", not "Mints".
|
||||
*
|
||||
* Two ecosystems are listed now, so a bare "Mints" beside "Fedimints" reads as though
|
||||
* federations were something other than mints — or as though the first link covered
|
||||
* both. The extra word costs ~45px in the widest nav row and buys a label that is
|
||||
* unambiguous at a glance. It is the same wording on /mints' own heading, in the
|
||||
* breadcrumb and in the footer, so nothing calls the same page two things.
|
||||
*/
|
||||
const links = [
|
||||
{ path: '/mints', label: t('nav.mints'), key: 'mints' },
|
||||
{ path: '/fedimints', label: t('nav.fedimints'), key: 'fedimints' },
|
||||
{ path: '/reviews', label: t('nav.reviews'), key: 'reviews' },
|
||||
{ path: '/wallets', label: t('nav.wallets'), key: 'wallets' },
|
||||
{ path: '/about', label: t('nav.about'), key: 'about' },
|
||||
|
||||
@@ -52,6 +52,15 @@ translate `en.json` with it beside you.
|
||||
| mint URL | la URL del mint | de mint-URL | |
|
||||
| bunker link | el enlace bunker | de bunker-link | `bunker://` itself is never translated. |
|
||||
| seed phrase | la frase semilla | de seed phrase | |
|
||||
| Fedimint | Fedimint | Fedimint | The project's name. Never inflected, never translated. |
|
||||
| federation | la federación | de federatie | The thing a Fedimint is. Not "el fedimint". |
|
||||
| guardian | el guardián | de guardian | Who runs a federation. Dutch keeps the loanword, as with signer. |
|
||||
| invite code | el código de invitación | de invite code | The `fed11…` string. Spanish translates it, Dutch does not: NL users say "invite code". |
|
||||
| federation id | el id de la federación | het federatie-id | The 64 hex characters. Never "identificador". |
|
||||
| module | el módulo | de module | `ln`, `mint`, `wallet`. The short name itself is never translated. |
|
||||
| announced (a federation) | anunciada | aangekondigd | Its own status. NOT a synonym for unknown: see the note below. |
|
||||
| confirmed (a federation) | confirmada | bevestigd | Something outside this site checked and it answered. |
|
||||
| network (mainnet, signet) | la red | het netwerk | The network names themselves stay English. |
|
||||
|
||||
## Two decisions worth knowing
|
||||
|
||||
@@ -65,6 +74,13 @@ not need this: `minten` and `melten` are what NL and BE Nostr users actually wri
|
||||
review, "portemonnee", "ondertekenaar") reads as stiff or, worse, as a different concept.
|
||||
Spanish translates more, because Spanish tech writing does.
|
||||
|
||||
**"Announced" is not "unknown", in any language.** A Cashu mint that has not been probed
|
||||
yet is `status.unknown`: we have not looked. A federation that is `status.announced` is
|
||||
one nobody *can* look at — there is no check to run, and there may never be. Translating
|
||||
both with the same word throws away the only distinction the page is making, so pick two
|
||||
words and keep them apart: `Sin comprobar aún` against `Solo anunciada`, `Nog niet
|
||||
gecontroleerd` against `Alleen aangekondigd`.
|
||||
|
||||
## Safety copy
|
||||
|
||||
The warning banners (`mint.warnings.*`) and the disclaimer are the copy someone reads
|
||||
|
||||
@@ -75,6 +75,11 @@ export const CLIENT_NAMESPACES = [
|
||||
'mints',
|
||||
'mint',
|
||||
'nut',
|
||||
// The federation index's own count line, and the module names the live island
|
||||
// redraws when a fresh announcement disagrees with the build.
|
||||
'fedimints',
|
||||
'fedimint',
|
||||
'card',
|
||||
'reviews',
|
||||
'feed',
|
||||
'login',
|
||||
|
||||
+118
-14
@@ -33,7 +33,7 @@
|
||||
"status.unknown": "Not checked yet",
|
||||
"nav.skip": "Skip to content",
|
||||
"nav.main": "Main",
|
||||
"nav.mints": "Mints",
|
||||
"nav.mints": "Cashu mints",
|
||||
"nav.reviews": "Reviews",
|
||||
"nav.wallets": "Wallets",
|
||||
"nav.about": "About",
|
||||
@@ -41,7 +41,7 @@
|
||||
"lang.label": "Language",
|
||||
"lang.switcher": "Choose a language",
|
||||
"footer.copyright": "© {year} Cashumints.space",
|
||||
"footer.allMints": "All mints",
|
||||
"footer.allMints": "All Cashu mints",
|
||||
"footer.allReviews": "All reviews",
|
||||
"footer.wallets": "Wallets",
|
||||
"footer.about": "About",
|
||||
@@ -91,7 +91,7 @@
|
||||
"pulse.stale": "stale",
|
||||
"mints.title": "All Cashu mints · Cashumints.space",
|
||||
"mints.description": "All {total} Cashu mints indexed from the Nostr network, {online} online and {offline} offline, with community ratings, uptime and mint software. Search and sort the full list.",
|
||||
"mints.heading": "All mints",
|
||||
"mints.heading": "All Cashu mints",
|
||||
"mints.lede": "Every mint discovered on the Nostr network, including the ones that are gone. Offline mints stay listed so you can still find and review them.",
|
||||
"mints.search.label": "Search mints by name or domain",
|
||||
"mints.search.placeholder": "Search by name or domain…",
|
||||
@@ -121,7 +121,7 @@
|
||||
"mint.description.online": "Currently online.",
|
||||
"mint.description.unreachable": "Currently unreachable.",
|
||||
"mint.breadcrumb": "Breadcrumb",
|
||||
"mint.crumbMints": "Mints",
|
||||
"mint.crumbMints": "Cashu mints",
|
||||
"mint.copyUrl": "Copy mint URL",
|
||||
"mint.qrButton": "Mint QR",
|
||||
"mint.writeReview": "Write a review",
|
||||
@@ -256,7 +256,7 @@
|
||||
"reviews.filter.recent": "Recent",
|
||||
"reviews.loading.one": "Loading {n} review from Nostr relays",
|
||||
"reviews.loading.other": "Loading {n} reviews from Nostr relays",
|
||||
"reviews.empty": "No reviews yet. Be the first to review this mint.",
|
||||
"reviews.empty": "No reviews yet. Be the first to write one.",
|
||||
"reviews.emptyWritten": "No written reviews here yet. Be the first to write one.",
|
||||
"reviews.emptyFilter": "No reviews match this filter.",
|
||||
"reviews.noscript": "Reviews are signed on Nostr and load in your browser, so this list needs JavaScript. The rating and counts above come from the site itself.",
|
||||
@@ -292,7 +292,6 @@
|
||||
"reviews.dialog.stars.4": "good",
|
||||
"reviews.dialog.stars.5": "works great",
|
||||
"reviews.dialog.bodyLabel": "What should other people know?",
|
||||
"reviews.dialog.bodyPlaceholder": "How did minting, melting and support work out? Be specific.",
|
||||
"reviews.dialog.charCount": "{n} / {max}",
|
||||
"reviews.dialog.submit": "Sign and publish",
|
||||
"reviews.dialog.signing": "Signing…",
|
||||
@@ -318,14 +317,11 @@
|
||||
"reviews.validate.rating": "Choose a rating from 1 to 5 stars.",
|
||||
"reviews.validate.short": "Write at least {n} characters so the review is useful.",
|
||||
"reviews.validate.long": "Reviews are limited to {n} characters.",
|
||||
"feed.title": "Latest Cashu mint reviews · Cashumints.space",
|
||||
"feed.description": "Every Cashu mint review as it is published: {reviews} reviews across {mints} mints, signed on Nostr and readable here newest first. Filter by rating or by mint.",
|
||||
"feed.heading": "Latest Cashu mint reviews",
|
||||
"feed.lede": "Every review of every mint, newest first, read live from the Nostr relays. Each one is signed by its author and lives on the relays, not in our database.",
|
||||
"feed.title": "Latest mint and federation reviews · Cashumints.space",
|
||||
"feed.description": "Every mint and federation review as it is published: {reviews} reviews across {mints} Cashu mints and {federations} Fedimint federations, signed on Nostr and readable here newest first. Filter by ecosystem, by rating, or by name.",
|
||||
"feed.heading": "Latest reviews",
|
||||
"feed.lede": "Every review of every Cashu mint and Fedimint federation, newest first, read live from the Nostr relays. Each one is signed by its author and lives on the relays, not in our database.",
|
||||
"feed.label": "Reviews",
|
||||
"feed.filterByMint": "Filter by mint",
|
||||
"feed.anyMint": "Any mint",
|
||||
"feed.clearMint": "Clear the mint filter",
|
||||
"feed.showingBuild.one": "Showing the most recent review from the last build",
|
||||
"feed.showingBuild.other": "Showing the {n} most recent reviews from the last build",
|
||||
"feed.noscript": "Reviews are signed on Nostr and load in your browser, so filtering and the full list need JavaScript. The {n} reviews below were read from the relays when this page was built.",
|
||||
@@ -543,8 +539,116 @@
|
||||
"reviews.dialog.identity.extensionGone": "Your browser extension is not answering. Log in again, or use a remote signer.",
|
||||
"seo.orgDescription": "A directory of Cashu mints, with live status from each mint and community reviews signed on Nostr.",
|
||||
"seo.ogImageAlt": "Cashumints.space: find a Cashu mint you can trust.",
|
||||
"seo.mint.title.one": "{name} reviews: rated {rating} from {n} review | Cashumints.space",
|
||||
"seo.mint.title.other": "{name} reviews: rated {rating} from {n} reviews | Cashumints.space",
|
||||
"seo.mint.titleOffline.one": "{name}: offline for {n} day | Cashumints.space",
|
||||
"seo.mint.titleOffline.other": "{name}: offline for {n} days | Cashumints.space",
|
||||
"seo.mint.titleNoReviews": "{name}: Cashu mint info and reviews | Cashumints.space",
|
||||
"seo.fedimint.titleNoReviews": "{name}: Fedimint federation info and reviews | Cashumints.space",
|
||||
"seo.desc.fallback": "Community reviews for {name} ({domain}).",
|
||||
"seo.desc.rated.one": "Rated {rating}/5 from {n} signed Nostr review.",
|
||||
"seo.desc.rated.other": "Rated {rating}/5 from {n} signed Nostr reviews.",
|
||||
"seo.desc.noReviews": "No reviews yet.",
|
||||
"seo.desc.status": "{status}.",
|
||||
"seo.desc.statusLimits": "{status}, limits {limits}.",
|
||||
"seo.desc.fedModules": "{status}. Modules: {modules}.",
|
||||
"seo.desc.fedModulesNetwork": "{status}. Modules: {modules} on {network}.",
|
||||
"seo.mint.ogAlt.one": "{name}: {rating} stars from {n} review on Cashumints.space",
|
||||
"seo.mint.ogAlt.other": "{name}: {rating} stars from {n} reviews on Cashumints.space",
|
||||
"seo.mint.ogAltNoReviews": "{name} on Cashumints.space",
|
||||
"nav.home": "Home",
|
||||
"mint.description.fallbackDomain": "A Cashu mint running at {domain}.",
|
||||
"mint.more.title": "More mints",
|
||||
"mint.more.all": "See all mints"
|
||||
"mint.more.all": "See all mints",
|
||||
"nav.fedimints": "Fedimints",
|
||||
"status.announced": "Announced only",
|
||||
"card.unnamedFederation": "Unnamed federation",
|
||||
"card.notChecked": "never checked",
|
||||
"fedimints.title": "All Fedimint federations · Cashumints.space",
|
||||
"fedimints.description": "All {total} Fedimint federations announced on the Nostr network: {online} confirmed running by an outside check, {announced} announced and never confirmed. Community ratings, modules and invite codes for each.",
|
||||
"fedimints.heading": "All federations",
|
||||
"fedimints.lede": "Every Fedimint federation announced on the Nostr network. A federation has no public status endpoint of its own, so some of these are confirmed running by an outside check and the rest are listed exactly as they were announced.",
|
||||
"fedimints.search.label": "Search federations by name or id",
|
||||
"fedimints.search.placeholder": "Search by name or federation id…",
|
||||
"fedimints.sort.label": "Sort federations",
|
||||
"fedimints.hideOffline": "Hide offline",
|
||||
"fedimints.showing": "Showing {total} federations ({online} confirmed up, {announced} announced only)",
|
||||
"fedimints.showingFiltered": "Showing {shown} of {total} federations ({online} confirmed up, {announced} announced only)",
|
||||
"fedimints.announcedNote.one": "One of these was announced on Nostr and nothing has confirmed since that it is running. It is listed as announced rather than as online or offline, because neither would be true.",
|
||||
"fedimints.announcedNote.other": "{n} of these were announced on Nostr and nothing has confirmed since that they are running. They are listed as announced rather than as online or offline, because neither would be true.",
|
||||
"fedimints.noResults": "No federations match. Try a name, or paste a federation id.",
|
||||
"fedimints.emptyIndex": "No federations have been announced on the relays this site reads. The list fills itself as announcements arrive.",
|
||||
"fedimint.title": "{name} · Fedimint federation reviews",
|
||||
"fedimint.crumb": "Fedimints",
|
||||
"fedimint.breadcrumb": "Breadcrumb",
|
||||
"fedimint.unnamed": "Unnamed federation",
|
||||
"fedimint.idLabel": "Federation",
|
||||
"fedimint.kind.federationId": "federation id",
|
||||
"fedimint.moduleCount.one": "{n} module",
|
||||
"fedimint.moduleCount.other": "{n} modules",
|
||||
"fedimint.copyInvite": "Copy invite code",
|
||||
"fedimint.confirmed.label": "Confirmed",
|
||||
"fedimint.confirmed.never": "Never",
|
||||
"fedimint.confirmed.by": "by {source}",
|
||||
"fedimint.confirmed.unattributed": "last check",
|
||||
"fedimint.announcedOn": "announced {when}",
|
||||
"fedimint.announcedUnknown": "no announcement date",
|
||||
"fedimint.modules.title": "Modules",
|
||||
"fedimint.modules.none": "This federation's announcement lists no modules.",
|
||||
"fedimint.modules.note": "Modules are the parts a federation runs. The announcement says which are present, and nothing about whether any of them is accepting deposits.",
|
||||
"fedimint.modules.notListed": "not listed",
|
||||
"fedimint.invite.title": "Invite code",
|
||||
"fedimint.invite.count.one": "{n} code",
|
||||
"fedimint.invite.count.other": "{n} codes",
|
||||
"fedimint.invite.note": "Paste this into a Fedimint wallet to join. It carries the federation id and its guardians' addresses, and it is meant to be shared.",
|
||||
"fedimint.invite.kind": "invite code",
|
||||
"fedimint.invite.qr": "QR",
|
||||
"fedimint.invite.qrCaption": "Scan to join {name}",
|
||||
"fedimint.details.federationId": "Federation id",
|
||||
"fedimint.details.network": "Network",
|
||||
"fedimint.details.announcedBy": "Announced by",
|
||||
"fedimint.details.announced": "Announced",
|
||||
"fedimint.more.title": "More federations",
|
||||
"fedimint.more.all": "See all federations",
|
||||
"fedimint.disclaimer": "Joining a federation means trusting its guardians with your money. Ratings are community opinions, and confirmed running means reachable, not trustworthy.",
|
||||
"fedimint.description.online": "A check confirmed it running.",
|
||||
"fedimint.description.announced": "No check has confirmed it running.",
|
||||
"fedimint.description.fallback": "{name} is a Fedimint federation announced on Nostr.",
|
||||
"fedimint.description.fallbackId": "A Fedimint federation announced on Nostr as {id}.",
|
||||
"fedimint.module.lightning": "Lightning",
|
||||
"fedimint.module.mint": "Ecash mint",
|
||||
"fedimint.module.wallet": "On-chain wallet",
|
||||
"fedimint.module.meta": "Metadata",
|
||||
"fedimint.module.stability_pool": "Stability pool",
|
||||
"fedimint.module.multi_sig_stability_pool": "Stability pool (multisig)",
|
||||
"fedimint.module.fedi-social": "Social recovery",
|
||||
"fedimint.network.mainnet": "Bitcoin mainnet",
|
||||
"fedimint.network.signet": "Signet",
|
||||
"fedimint.network.testnet": "Testnet",
|
||||
"fedimint.network.testnet4": "Testnet4",
|
||||
"fedimint.network.regtest": "Regtest",
|
||||
"feed.ecosystem.group": "Ecosystem",
|
||||
"feed.ecosystem.all": "All",
|
||||
"feed.ecosystem.cashu": "Cashu",
|
||||
"feed.ecosystem.fedimint": "Fedimint",
|
||||
"feed.filterBySubject": "Filter by mint or federation",
|
||||
"feed.anySubject": "Any mint or federation",
|
||||
"feed.clearSubject": "Clear the name filter",
|
||||
"home.fedimint.title": "Fedimint federations",
|
||||
"home.fedimint.body": "{total} announced on Nostr, {online} confirmed running, {announced} never confirmed.",
|
||||
"home.fedimint.link": "Browse federations",
|
||||
"reviews.validate.subject": "There is nothing to point this review at.",
|
||||
"mint.warnings.fedimintOffline.lead": "Reported offline.",
|
||||
"mint.warnings.fedimintOffline.body.since": "The last check that reached this federation was on {date} ({days} ago). Its guardians have not answered since. Treat funds held there as at risk, and do not join it with new funds.",
|
||||
"mint.warnings.fedimintOffline.body.never": "No check of this federation has ever reached it, and the most recent one failed. Treat funds held there as at risk, and do not join it with new funds.",
|
||||
"mint.warnings.fedimintOffline.meta.since": "Reported offline since {date}",
|
||||
"mint.warnings.fedimintOffline.meta.never": "Reported offline, never once reached",
|
||||
"mint.warnings.neverConfirmed.lead": "Announced, never confirmed.",
|
||||
"mint.warnings.neverConfirmed.body.reviewed": "This federation was announced on Nostr on {date}, {days} ago, and nothing has confirmed since then that it is running. The reviews below are the only sign of life on this page; everything else came from the announcement.",
|
||||
"mint.warnings.neverConfirmed.body.quiet": "This federation was announced on Nostr on {date}, {days} ago, nothing has confirmed since then that it is running, and nobody has reviewed it recently. Everything below came from the announcement.",
|
||||
"mint.warnings.neverConfirmed.meta": "Announced {date}, never confirmed",
|
||||
"footer.allFedimints": "All federations",
|
||||
"reviews.dialog.bodyPlaceholder.cashu": "How did minting, melting and support work out? Be specific.",
|
||||
"reviews.dialog.bodyPlaceholder.fedimint": "How did joining, Lightning payments and support work out? Be specific.",
|
||||
"fedimint.details.title": "Federation details"
|
||||
}
|
||||
|
||||
+118
-14
@@ -37,7 +37,7 @@
|
||||
"status.unknown": "Sin comprobar aún",
|
||||
"nav.skip": "Saltar al contenido",
|
||||
"nav.main": "Principal",
|
||||
"nav.mints": "Mints",
|
||||
"nav.mints": "Mints Cashu",
|
||||
"nav.reviews": "Reseñas",
|
||||
"nav.wallets": "Monederos",
|
||||
"nav.about": "Acerca de",
|
||||
@@ -45,7 +45,7 @@
|
||||
"lang.label": "Idioma",
|
||||
"lang.switcher": "Elige un idioma",
|
||||
"footer.copyright": "© {year} Cashumints.space",
|
||||
"footer.allMints": "Todos los mints",
|
||||
"footer.allMints": "Todos los mints Cashu",
|
||||
"footer.allReviews": "Todas las reseñas",
|
||||
"footer.wallets": "Monederos",
|
||||
"footer.about": "Acerca de",
|
||||
@@ -95,7 +95,7 @@
|
||||
"pulse.stale": "desactualizado",
|
||||
"mints.title": "Todos los mints Cashu · Cashumints.space",
|
||||
"mints.description": "Los {total} mints Cashu indexados desde la red Nostr, {online} en línea y {offline} sin conexión, con valoraciones de la comunidad, disponibilidad y software del mint. Busca y ordena la lista completa.",
|
||||
"mints.heading": "Todos los mints",
|
||||
"mints.heading": "Todos los mints Cashu",
|
||||
"mints.lede": "Todos los mints descubiertos en la red Nostr, incluidos los que ya no están. Los mints sin conexión siguen en la lista para que puedas encontrarlos y reseñarlos.",
|
||||
"mints.search.label": "Buscar mints por nombre o dominio",
|
||||
"mints.search.placeholder": "Busca por nombre o dominio…",
|
||||
@@ -125,7 +125,7 @@
|
||||
"mint.description.online": "Ahora mismo en línea.",
|
||||
"mint.description.unreachable": "Ahora mismo no responde.",
|
||||
"mint.breadcrumb": "Ruta de navegación",
|
||||
"mint.crumbMints": "Mints",
|
||||
"mint.crumbMints": "Mints Cashu",
|
||||
"mint.copyUrl": "Copiar la URL del mint",
|
||||
"mint.qrButton": "QR del mint",
|
||||
"mint.writeReview": "Escribir una reseña",
|
||||
@@ -260,7 +260,7 @@
|
||||
"reviews.filter.recent": "Recientes",
|
||||
"reviews.loading.one": "Cargando {n} reseña de los relays de Nostr",
|
||||
"reviews.loading.other": "Cargando {n} reseñas de los relays de Nostr",
|
||||
"reviews.empty": "Todavía no hay reseñas. Sé quien escriba la primera.",
|
||||
"reviews.empty": "Todavía no hay reseñas. Sé la primera persona en escribir una.",
|
||||
"reviews.emptyWritten": "Todavía no hay reseñas escritas aquí. Sé quien escriba la primera.",
|
||||
"reviews.emptyFilter": "Ninguna reseña coincide con este filtro.",
|
||||
"reviews.noscript": "Las reseñas están firmadas en Nostr y se cargan en tu navegador, así que esta lista necesita JavaScript. La valoración y los recuentos de arriba vienen del propio sitio.",
|
||||
@@ -296,7 +296,6 @@
|
||||
"reviews.dialog.stars.4": "bien",
|
||||
"reviews.dialog.stars.5": "funciona genial",
|
||||
"reviews.dialog.bodyLabel": "¿Qué debería saber la gente?",
|
||||
"reviews.dialog.bodyPlaceholder": "¿Qué tal fue acuñar, fundir y el soporte? Sé concreto.",
|
||||
"reviews.dialog.charCount": "{n} / {max}",
|
||||
"reviews.dialog.submit": "Firmar y publicar",
|
||||
"reviews.dialog.signing": "Firmando…",
|
||||
@@ -322,14 +321,11 @@
|
||||
"reviews.validate.rating": "Elige una valoración de 1 a 5 estrellas.",
|
||||
"reviews.validate.short": "Escribe al menos {n} caracteres para que la reseña sirva de algo.",
|
||||
"reviews.validate.long": "Las reseñas están limitadas a {n} caracteres.",
|
||||
"feed.title": "Últimas reseñas de mints Cashu · Cashumints.space",
|
||||
"feed.description": "Cada reseña de un mint Cashu según se publica: {reviews} reseñas de {mints} mints, firmadas en Nostr y legibles aquí de la más nueva a la más antigua. Filtra por valoración o por mint.",
|
||||
"feed.heading": "Últimas reseñas de mints Cashu",
|
||||
"feed.lede": "Todas las reseñas de todos los mints, de la más nueva a la más antigua, leídas en directo de los relays de Nostr. Cada una está firmada por quien la escribió y vive en los relays, no en nuestra base de datos.",
|
||||
"feed.title": "Últimas reseñas de mints y federaciones · Cashumints.space",
|
||||
"feed.description": "Cada reseña de un mint o una federación según se publica: {reviews} reseñas de {mints} mints Cashu y {federations} federaciones Fedimint, firmadas en Nostr y legibles aquí de la más nueva a la más antigua. Filtra por ecosistema, por valoración o por nombre.",
|
||||
"feed.heading": "Últimas reseñas",
|
||||
"feed.lede": "Todas las reseñas de todos los mints Cashu y federaciones Fedimint, de la más nueva a la más antigua, leídas en directo de los relays de Nostr. Cada una está firmada por quien la escribió y vive en los relays, no en nuestra base de datos.",
|
||||
"feed.label": "Reseñas",
|
||||
"feed.filterByMint": "Filtrar por mint",
|
||||
"feed.anyMint": "Cualquier mint",
|
||||
"feed.clearMint": "Borrar el filtro de mint",
|
||||
"feed.showingBuild.one": "Mostrando la reseña más reciente de la última compilación",
|
||||
"feed.showingBuild.other": "Mostrando las {n} reseñas más recientes de la última compilación",
|
||||
"feed.noscript": "Las reseñas están firmadas en Nostr y se cargan en tu navegador, así que los filtros y la lista completa necesitan JavaScript. Las {n} reseñas de abajo se leyeron de los relays cuando se compiló esta página.",
|
||||
@@ -547,8 +543,116 @@
|
||||
"reviews.dialog.identity.extensionGone": "La extensión de tu navegador no responde. Vuelve a iniciar sesión, o usa un firmante remoto.",
|
||||
"seo.orgDescription": "Un directorio de mints Cashu, con el estado en vivo de cada mint y reseñas de la comunidad firmadas en Nostr.",
|
||||
"seo.ogImageAlt": "Cashumints.space: encuentra un mint Cashu en el que puedas confiar.",
|
||||
"seo.mint.title.one": "Reseñas de {name}: {rating} de {n} reseña | Cashumints.space",
|
||||
"seo.mint.title.other": "Reseñas de {name}: {rating} de {n} reseñas | Cashumints.space",
|
||||
"seo.mint.titleOffline.one": "{name}: sin conexión desde hace {n} día | Cashumints.space",
|
||||
"seo.mint.titleOffline.other": "{name}: sin conexión desde hace {n} días | Cashumints.space",
|
||||
"seo.mint.titleNoReviews": "{name}: información y reseñas del mint Cashu | Cashumints.space",
|
||||
"seo.fedimint.titleNoReviews": "{name}: información y reseñas de la federación Fedimint | Cashumints.space",
|
||||
"seo.desc.fallback": "Reseñas de la comunidad sobre {name} ({domain}).",
|
||||
"seo.desc.rated.one": "Valorado {rating}/5 en {n} reseña firmada en Nostr.",
|
||||
"seo.desc.rated.other": "Valorado {rating}/5 en {n} reseñas firmadas en Nostr.",
|
||||
"seo.desc.noReviews": "Aún sin reseñas.",
|
||||
"seo.desc.status": "{status}.",
|
||||
"seo.desc.statusLimits": "{status}, límites {limits}.",
|
||||
"seo.desc.fedModules": "{status}. Módulos: {modules}.",
|
||||
"seo.desc.fedModulesNetwork": "{status}. Módulos: {modules} en {network}.",
|
||||
"seo.mint.ogAlt.one": "{name}: {rating} estrellas de {n} reseña en Cashumints.space",
|
||||
"seo.mint.ogAlt.other": "{name}: {rating} estrellas de {n} reseñas en Cashumints.space",
|
||||
"seo.mint.ogAltNoReviews": "{name} en Cashumints.space",
|
||||
"nav.home": "Inicio",
|
||||
"mint.description.fallbackDomain": "Un mint Cashu que funciona en {domain}.",
|
||||
"mint.more.title": "Más mints",
|
||||
"mint.more.all": "Ver todos los mints"
|
||||
"mint.more.all": "Ver todos los mints",
|
||||
"nav.fedimints": "Fedimints",
|
||||
"status.announced": "Solo anunciada",
|
||||
"card.unnamedFederation": "Federación sin nombre",
|
||||
"card.notChecked": "sin comprobar nunca",
|
||||
"fedimints.title": "Todas las federaciones Fedimint · Cashumints.space",
|
||||
"fedimints.description": "Las {total} federaciones Fedimint anunciadas en la red Nostr: {online} confirmadas en funcionamiento por una comprobación externa, {announced} anunciadas y nunca confirmadas. Valoraciones de la comunidad, módulos y códigos de invitación de cada una.",
|
||||
"fedimints.heading": "Todas las federaciones",
|
||||
"fedimints.lede": "Cada federación Fedimint anunciada en la red Nostr. Una federación no tiene un endpoint público de estado, así que algunas de estas están confirmadas en funcionamiento por una comprobación externa y el resto aparecen tal y como se anunciaron.",
|
||||
"fedimints.search.label": "Buscar federaciones por nombre o id",
|
||||
"fedimints.search.placeholder": "Busca por nombre o id de federación…",
|
||||
"fedimints.sort.label": "Ordenar federaciones",
|
||||
"fedimints.hideOffline": "Ocultar sin conexión",
|
||||
"fedimints.showing": "Mostrando {total} federaciones ({online} confirmadas, {announced} solo anunciadas)",
|
||||
"fedimints.showingFiltered": "Mostrando {shown} de {total} federaciones ({online} confirmadas, {announced} solo anunciadas)",
|
||||
"fedimints.announcedNote.one": "Una de estas se anunció en Nostr y nada ha confirmado desde entonces que esté funcionando. Aparece como anunciada, no como en línea ni sin conexión, porque ninguna de las dos sería cierta.",
|
||||
"fedimints.announcedNote.other": "{n} de estas se anunciaron en Nostr y nada ha confirmado desde entonces que estén funcionando. Aparecen como anunciadas, no como en línea ni sin conexión, porque ninguna de las dos sería cierta.",
|
||||
"fedimints.noResults": "Ninguna federación coincide. Prueba con un nombre, o pega un id de federación.",
|
||||
"fedimints.emptyIndex": "No se ha anunciado ninguna federación en los relays que lee este sitio. La lista se llena sola según van llegando anuncios.",
|
||||
"fedimint.title": "{name} · Reseñas de la federación Fedimint",
|
||||
"fedimint.crumb": "Fedimints",
|
||||
"fedimint.breadcrumb": "Ruta de navegación",
|
||||
"fedimint.unnamed": "Federación sin nombre",
|
||||
"fedimint.idLabel": "Federación",
|
||||
"fedimint.kind.federationId": "id de la federación",
|
||||
"fedimint.moduleCount.one": "{n} módulo",
|
||||
"fedimint.moduleCount.other": "{n} módulos",
|
||||
"fedimint.copyInvite": "Copiar código de invitación",
|
||||
"fedimint.confirmed.label": "Confirmada",
|
||||
"fedimint.confirmed.never": "Nunca",
|
||||
"fedimint.confirmed.by": "por {source}",
|
||||
"fedimint.confirmed.unattributed": "última comprobación",
|
||||
"fedimint.announcedOn": "anunciada {when}",
|
||||
"fedimint.announcedUnknown": "sin fecha de anuncio",
|
||||
"fedimint.modules.title": "Módulos",
|
||||
"fedimint.modules.none": "El anuncio de esta federación no lista ningún módulo.",
|
||||
"fedimint.modules.note": "Los módulos son las piezas que ejecuta una federación. El anuncio dice cuáles están presentes, y nada sobre si alguno acepta depósitos.",
|
||||
"fedimint.modules.notListed": "no listado",
|
||||
"fedimint.invite.title": "Código de invitación",
|
||||
"fedimint.invite.count.one": "{n} código",
|
||||
"fedimint.invite.count.other": "{n} códigos",
|
||||
"fedimint.invite.note": "Pega esto en un monedero Fedimint para unirte. Lleva el id de la federación y las direcciones de sus guardianes, y está pensado para compartirse.",
|
||||
"fedimint.invite.kind": "código de invitación",
|
||||
"fedimint.invite.qr": "QR",
|
||||
"fedimint.invite.qrCaption": "Escanea para unirte a {name}",
|
||||
"fedimint.details.federationId": "Id de la federación",
|
||||
"fedimint.details.network": "Red",
|
||||
"fedimint.details.announcedBy": "Anunciada por",
|
||||
"fedimint.details.announced": "Anunciada",
|
||||
"fedimint.more.title": "Más federaciones",
|
||||
"fedimint.more.all": "Ver todas las federaciones",
|
||||
"fedimint.disclaimer": "Unirte a una federación significa confiar tu dinero a sus guardianes. Las valoraciones son opiniones de la comunidad, y confirmada en funcionamiento significa accesible, no fiable.",
|
||||
"fedimint.description.online": "Una comprobación confirmó que está funcionando.",
|
||||
"fedimint.description.announced": "Ninguna comprobación ha confirmado que esté funcionando.",
|
||||
"fedimint.description.fallback": "{name} es una federación Fedimint anunciada en Nostr.",
|
||||
"fedimint.description.fallbackId": "Una federación Fedimint anunciada en Nostr como {id}.",
|
||||
"fedimint.module.lightning": "Lightning",
|
||||
"fedimint.module.mint": "Mint de ecash",
|
||||
"fedimint.module.wallet": "Monedero on-chain",
|
||||
"fedimint.module.meta": "Metadatos",
|
||||
"fedimint.module.stability_pool": "Pool de estabilidad",
|
||||
"fedimint.module.multi_sig_stability_pool": "Pool de estabilidad (multifirma)",
|
||||
"fedimint.module.fedi-social": "Recuperación social",
|
||||
"fedimint.network.mainnet": "Bitcoin mainnet",
|
||||
"fedimint.network.signet": "Signet",
|
||||
"fedimint.network.testnet": "Testnet",
|
||||
"fedimint.network.testnet4": "Testnet4",
|
||||
"fedimint.network.regtest": "Regtest",
|
||||
"feed.ecosystem.group": "Ecosistema",
|
||||
"feed.ecosystem.all": "Todo",
|
||||
"feed.ecosystem.cashu": "Cashu",
|
||||
"feed.ecosystem.fedimint": "Fedimint",
|
||||
"feed.filterBySubject": "Filtrar por mint o federación",
|
||||
"feed.anySubject": "Cualquier mint o federación",
|
||||
"feed.clearSubject": "Borrar el filtro por nombre",
|
||||
"home.fedimint.title": "Federaciones Fedimint",
|
||||
"home.fedimint.body": "{total} anunciadas en Nostr, {online} confirmadas en funcionamiento, {announced} nunca confirmadas.",
|
||||
"home.fedimint.link": "Ver federaciones",
|
||||
"reviews.validate.subject": "No hay nada a lo que apuntar esta reseña.",
|
||||
"mint.warnings.fedimintOffline.lead": "Reportada sin conexión.",
|
||||
"mint.warnings.fedimintOffline.body.since": "La última comprobación que llegó a esta federación fue el {date} (hace {days}). Sus guardianes no responden desde entonces. Da por perdido el dinero que tengas ahí y no te unas con dinero nuevo.",
|
||||
"mint.warnings.fedimintOffline.body.never": "Ninguna comprobación ha llegado nunca a esta federación, y la más reciente falló. Da por perdido el dinero que tengas ahí y no te unas con dinero nuevo.",
|
||||
"mint.warnings.fedimintOffline.meta.since": "Reportada sin conexión desde el {date}",
|
||||
"mint.warnings.fedimintOffline.meta.never": "Reportada sin conexión, nunca accesible",
|
||||
"mint.warnings.neverConfirmed.lead": "Anunciada, nunca confirmada.",
|
||||
"mint.warnings.neverConfirmed.body.reviewed": "Esta federación se anunció en Nostr el {date}, hace {days}, y nada ha confirmado desde entonces que esté funcionando. Las reseñas de abajo son la única señal de vida de esta página; todo lo demás viene del anuncio.",
|
||||
"mint.warnings.neverConfirmed.body.quiet": "Esta federación se anunció en Nostr el {date}, hace {days}, nada ha confirmado desde entonces que esté funcionando, y nadie la ha reseñado recientemente. Todo lo de abajo viene del anuncio.",
|
||||
"mint.warnings.neverConfirmed.meta": "Anunciada el {date}, nunca confirmada",
|
||||
"footer.allFedimints": "Todas las federaciones",
|
||||
"reviews.dialog.bodyPlaceholder.cashu": "¿Qué tal fueron la acuñación, el fundido y el soporte? Sé concreto.",
|
||||
"reviews.dialog.bodyPlaceholder.fedimint": "¿Qué tal fueron unirte, los pagos Lightning y el soporte? Sé concreto.",
|
||||
"fedimint.details.title": "Datos de la federación"
|
||||
}
|
||||
|
||||
@@ -77,6 +77,8 @@ export interface Formatters {
|
||||
serverTime(unix: number): string;
|
||||
/** Grouped: 1,000,000 in English, 1.000.000 in Spanish and Dutch. */
|
||||
number(n: number): string;
|
||||
/** Compact: "500K" / "500 mil", for a limit quoted inside a meta description. */
|
||||
compact(n: number): string;
|
||||
/** A rating to one decimal, with the locale's own decimal separator. */
|
||||
decimal(n: number, digits?: number): string;
|
||||
}
|
||||
@@ -159,6 +161,9 @@ export function formatters(t: Translator): Formatters {
|
||||
|
||||
number: (n) => numberFormat(tag).format(n),
|
||||
|
||||
compact: (n) =>
|
||||
new Intl.NumberFormat(tag, { notation: 'compact', maximumFractionDigits: 1 }).format(n),
|
||||
|
||||
decimal: (n, digits = 1) =>
|
||||
new Intl.NumberFormat(tag, {
|
||||
minimumFractionDigits: digits,
|
||||
|
||||
+118
-14
@@ -33,7 +33,7 @@
|
||||
"status.unknown": "Nog niet gecontroleerd",
|
||||
"nav.skip": "Naar de inhoud",
|
||||
"nav.main": "Hoofdmenu",
|
||||
"nav.mints": "Mints",
|
||||
"nav.mints": "Cashu mints",
|
||||
"nav.reviews": "Reviews",
|
||||
"nav.wallets": "Wallets",
|
||||
"nav.about": "Over",
|
||||
@@ -41,7 +41,7 @@
|
||||
"lang.label": "Taal",
|
||||
"lang.switcher": "Kies een taal",
|
||||
"footer.copyright": "© {year} Cashumints.space",
|
||||
"footer.allMints": "Alle mints",
|
||||
"footer.allMints": "Alle Cashu mints",
|
||||
"footer.allReviews": "Alle reviews",
|
||||
"footer.wallets": "Wallets",
|
||||
"footer.about": "Over",
|
||||
@@ -91,7 +91,7 @@
|
||||
"pulse.stale": "verouderd",
|
||||
"mints.title": "Alle Cashu mints · Cashumints.space",
|
||||
"mints.description": "Alle {total} Cashu mints die op het Nostr-netwerk zijn geïndexeerd, {online} online en {offline} offline, met beoordelingen uit de community, uptime en mint-software. Doorzoek en sorteer de volledige lijst.",
|
||||
"mints.heading": "Alle mints",
|
||||
"mints.heading": "Alle Cashu mints",
|
||||
"mints.lede": "Elke mint die op het Nostr-netwerk gevonden is, ook de mints die er niet meer zijn. Offline mints blijven in de lijst staan zodat je ze nog kunt vinden en reviewen.",
|
||||
"mints.search.label": "Zoek mints op naam of domein",
|
||||
"mints.search.placeholder": "Zoek op naam of domein…",
|
||||
@@ -121,7 +121,7 @@
|
||||
"mint.description.online": "Nu online.",
|
||||
"mint.description.unreachable": "Nu onbereikbaar.",
|
||||
"mint.breadcrumb": "Kruimelpad",
|
||||
"mint.crumbMints": "Mints",
|
||||
"mint.crumbMints": "Cashu mints",
|
||||
"mint.copyUrl": "Mint-URL kopiëren",
|
||||
"mint.qrButton": "Mint-QR",
|
||||
"mint.writeReview": "Schrijf een review",
|
||||
@@ -256,7 +256,7 @@
|
||||
"reviews.filter.recent": "Recent",
|
||||
"reviews.loading.one": "{n} review van Nostr-relays laden",
|
||||
"reviews.loading.other": "{n} reviews van Nostr-relays laden",
|
||||
"reviews.empty": "Nog geen reviews. Wees de eerste die deze mint reviewt.",
|
||||
"reviews.empty": "Nog geen reviews. Schrijf als eerste er een.",
|
||||
"reviews.emptyWritten": "Hier staan nog geen geschreven reviews. Wees de eerste die er een schrijft.",
|
||||
"reviews.emptyFilter": "Geen enkele review past bij dit filter.",
|
||||
"reviews.noscript": "Reviews worden op Nostr ondertekend en in je browser geladen, dus deze lijst heeft JavaScript nodig. De beoordeling en de aantallen hierboven komen van de site zelf.",
|
||||
@@ -292,7 +292,6 @@
|
||||
"reviews.dialog.stars.4": "goed",
|
||||
"reviews.dialog.stars.5": "werkt prima",
|
||||
"reviews.dialog.bodyLabel": "Wat moeten anderen weten?",
|
||||
"reviews.dialog.bodyPlaceholder": "Hoe ging het minten, het melten en de support? Wees concreet.",
|
||||
"reviews.dialog.charCount": "{n} / {max}",
|
||||
"reviews.dialog.submit": "Ondertekenen en publiceren",
|
||||
"reviews.dialog.signing": "Ondertekenen…",
|
||||
@@ -318,14 +317,11 @@
|
||||
"reviews.validate.rating": "Kies een beoordeling van 1 tot 5 sterren.",
|
||||
"reviews.validate.short": "Schrijf minstens {n} tekens, anders heeft niemand iets aan de review.",
|
||||
"reviews.validate.long": "Reviews mogen maximaal {n} tekens lang zijn.",
|
||||
"feed.title": "Laatste reviews van Cashu mints · Cashumints.space",
|
||||
"feed.description": "Elke review van een Cashu mint zodra hij verschijnt: {reviews} reviews over {mints} mints, ondertekend op Nostr en hier te lezen met de nieuwste bovenaan. Filter op beoordeling of op mint.",
|
||||
"feed.heading": "Laatste reviews van Cashu mints",
|
||||
"feed.lede": "Elke review van elke mint, nieuwste bovenaan, live gelezen van de Nostr-relays. Elke review is ondertekend door wie hem schreef en staat op de relays, niet in onze database.",
|
||||
"feed.title": "Laatste reviews van mints en federaties · Cashumints.space",
|
||||
"feed.description": "Elke review van een mint of federatie zodra hij verschijnt: {reviews} reviews over {mints} Cashu mints en {federations} Fedimint-federaties, ondertekend op Nostr en hier te lezen met de nieuwste bovenaan. Filter op ecosysteem, op beoordeling of op naam.",
|
||||
"feed.heading": "Laatste reviews",
|
||||
"feed.lede": "Elke review van elke Cashu mint en elke Fedimint-federatie, nieuwste bovenaan, live gelezen van de Nostr-relays. Elke review is ondertekend door wie hem schreef en staat op de relays, niet in onze database.",
|
||||
"feed.label": "Reviews",
|
||||
"feed.filterByMint": "Filteren op mint",
|
||||
"feed.anyMint": "Elke mint",
|
||||
"feed.clearMint": "Mintfilter wissen",
|
||||
"feed.showingBuild.one": "De meest recente review van de laatste build te zien",
|
||||
"feed.showingBuild.other": "De {n} meest recente reviews van de laatste build te zien",
|
||||
"feed.noscript": "Reviews worden op Nostr ondertekend en in je browser geladen, dus filteren en de volledige lijst hebben JavaScript nodig. De {n} reviews hieronder zijn van de relays gelezen toen deze pagina gebouwd werd.",
|
||||
@@ -543,8 +539,116 @@
|
||||
"reviews.dialog.identity.extensionGone": "Je browserextensie antwoordt niet. Log opnieuw in, of gebruik een signer op afstand.",
|
||||
"seo.orgDescription": "Een overzicht van Cashu-mints, met de actuele status van elke mint en reviews uit de gemeenschap, ondertekend op Nostr.",
|
||||
"seo.ogImageAlt": "Cashumints.space: vind een Cashu-mint die je kunt vertrouwen.",
|
||||
"seo.mint.title.one": "Reviews van {name}: {rating} uit {n} review | Cashumints.space",
|
||||
"seo.mint.title.other": "Reviews van {name}: {rating} uit {n} reviews | Cashumints.space",
|
||||
"seo.mint.titleOffline.one": "{name}: al {n} dag offline | Cashumints.space",
|
||||
"seo.mint.titleOffline.other": "{name}: al {n} dagen offline | Cashumints.space",
|
||||
"seo.mint.titleNoReviews": "{name}: info en reviews over deze Cashu-mint | Cashumints.space",
|
||||
"seo.fedimint.titleNoReviews": "{name}: info en reviews over deze Fedimint-federatie | Cashumints.space",
|
||||
"seo.desc.fallback": "Reviews uit de gemeenschap over {name} ({domain}).",
|
||||
"seo.desc.rated.one": "Beoordeeld met {rating}/5 uit {n} op Nostr ondertekende review.",
|
||||
"seo.desc.rated.other": "Beoordeeld met {rating}/5 uit {n} op Nostr ondertekende reviews.",
|
||||
"seo.desc.noReviews": "Nog geen reviews.",
|
||||
"seo.desc.status": "{status}.",
|
||||
"seo.desc.statusLimits": "{status}, limieten {limits}.",
|
||||
"seo.desc.fedModules": "{status}. Modules: {modules}.",
|
||||
"seo.desc.fedModulesNetwork": "{status}. Modules: {modules} op {network}.",
|
||||
"seo.mint.ogAlt.one": "{name}: {rating} sterren uit {n} review op Cashumints.space",
|
||||
"seo.mint.ogAlt.other": "{name}: {rating} sterren uit {n} reviews op Cashumints.space",
|
||||
"seo.mint.ogAltNoReviews": "{name} op Cashumints.space",
|
||||
"nav.home": "Home",
|
||||
"mint.description.fallbackDomain": "Een Cashu-mint die draait op {domain}.",
|
||||
"mint.more.title": "Meer mints",
|
||||
"mint.more.all": "Bekijk alle mints"
|
||||
"mint.more.all": "Bekijk alle mints",
|
||||
"nav.fedimints": "Fedimints",
|
||||
"status.announced": "Alleen aangekondigd",
|
||||
"card.unnamedFederation": "Naamloze federatie",
|
||||
"card.notChecked": "nooit gecontroleerd",
|
||||
"fedimints.title": "Alle Fedimint-federaties · Cashumints.space",
|
||||
"fedimints.description": "Alle {total} Fedimint-federaties die op het Nostr-netwerk zijn aangekondigd: {online} door een externe controle bevestigd draaiend, {announced} aangekondigd en nooit bevestigd. Beoordelingen uit de community, modules en invite codes van elke federatie.",
|
||||
"fedimints.heading": "Alle federaties",
|
||||
"fedimints.lede": "Elke Fedimint-federatie die op het Nostr-netwerk is aangekondigd. Een federatie heeft zelf geen publiek status-endpoint, dus een deel hiervan is door een externe controle bevestigd draaiend en de rest staat er precies zoals hij is aangekondigd.",
|
||||
"fedimints.search.label": "Zoek federaties op naam of id",
|
||||
"fedimints.search.placeholder": "Zoek op naam of federatie-id…",
|
||||
"fedimints.sort.label": "Federaties sorteren",
|
||||
"fedimints.hideOffline": "Offline verbergen",
|
||||
"fedimints.showing": "{total} federaties te zien ({online} bevestigd, {announced} alleen aangekondigd)",
|
||||
"fedimints.showingFiltered": "{shown} van {total} federaties te zien ({online} bevestigd, {announced} alleen aangekondigd)",
|
||||
"fedimints.announcedNote.one": "Eén hiervan is op Nostr aangekondigd en niets heeft sindsdien bevestigd dat hij draait. Hij staat er als aangekondigd, niet als online of offline, want geen van beide zou kloppen.",
|
||||
"fedimints.announcedNote.other": "{n} hiervan zijn op Nostr aangekondigd en niets heeft sindsdien bevestigd dat ze draaien. Ze staan er als aangekondigd, niet als online of offline, want geen van beide zou kloppen.",
|
||||
"fedimints.noResults": "Geen enkele federatie komt overeen. Probeer een naam, of plak een federatie-id.",
|
||||
"fedimints.emptyIndex": "Er is geen enkele federatie aangekondigd op de relays die deze site leest. De lijst vult zichzelf zodra er aankondigingen binnenkomen.",
|
||||
"fedimint.title": "{name} · Reviews van deze Fedimint-federatie",
|
||||
"fedimint.crumb": "Fedimints",
|
||||
"fedimint.breadcrumb": "Kruimelpad",
|
||||
"fedimint.unnamed": "Naamloze federatie",
|
||||
"fedimint.idLabel": "Federatie",
|
||||
"fedimint.kind.federationId": "federatie-id",
|
||||
"fedimint.moduleCount.one": "{n} module",
|
||||
"fedimint.moduleCount.other": "{n} modules",
|
||||
"fedimint.copyInvite": "Invite code kopiëren",
|
||||
"fedimint.confirmed.label": "Bevestigd",
|
||||
"fedimint.confirmed.never": "Nooit",
|
||||
"fedimint.confirmed.by": "door {source}",
|
||||
"fedimint.confirmed.unattributed": "laatste controle",
|
||||
"fedimint.announcedOn": "aangekondigd {when}",
|
||||
"fedimint.announcedUnknown": "geen datum van aankondiging",
|
||||
"fedimint.modules.title": "Modules",
|
||||
"fedimint.modules.none": "De aankondiging van deze federatie noemt geen modules.",
|
||||
"fedimint.modules.note": "Modules zijn de onderdelen die een federatie draait. De aankondiging zegt welke er zijn, en niets over of een ervan stortingen aanneemt.",
|
||||
"fedimint.modules.notListed": "niet genoemd",
|
||||
"fedimint.invite.title": "Invite code",
|
||||
"fedimint.invite.count.one": "{n} code",
|
||||
"fedimint.invite.count.other": "{n} codes",
|
||||
"fedimint.invite.note": "Plak dit in een Fedimint-wallet om mee te doen. Hij bevat het federatie-id en de adressen van de guardians, en hij is bedoeld om te delen.",
|
||||
"fedimint.invite.kind": "invite code",
|
||||
"fedimint.invite.qr": "QR",
|
||||
"fedimint.invite.qrCaption": "Scan om mee te doen met {name}",
|
||||
"fedimint.details.federationId": "Federatie-id",
|
||||
"fedimint.details.network": "Netwerk",
|
||||
"fedimint.details.announcedBy": "Aangekondigd door",
|
||||
"fedimint.details.announced": "Aangekondigd",
|
||||
"fedimint.more.title": "Meer federaties",
|
||||
"fedimint.more.all": "Alle federaties bekijken",
|
||||
"fedimint.disclaimer": "Meedoen met een federatie betekent dat je jouw geld toevertrouwt aan haar guardians. Beoordelingen zijn meningen uit de community, en bevestigd draaiend betekent bereikbaar, niet betrouwbaar.",
|
||||
"fedimint.description.online": "Een controle heeft bevestigd dat hij draait.",
|
||||
"fedimint.description.announced": "Geen enkele controle heeft bevestigd dat hij draait.",
|
||||
"fedimint.description.fallback": "{name} is een Fedimint-federatie die op Nostr is aangekondigd.",
|
||||
"fedimint.description.fallbackId": "Een Fedimint-federatie die op Nostr is aangekondigd als {id}.",
|
||||
"fedimint.module.lightning": "Lightning",
|
||||
"fedimint.module.mint": "Ecash mint",
|
||||
"fedimint.module.wallet": "On-chain wallet",
|
||||
"fedimint.module.meta": "Metadata",
|
||||
"fedimint.module.stability_pool": "Stability pool",
|
||||
"fedimint.module.multi_sig_stability_pool": "Stability pool (multisig)",
|
||||
"fedimint.module.fedi-social": "Social recovery",
|
||||
"fedimint.network.mainnet": "Bitcoin mainnet",
|
||||
"fedimint.network.signet": "Signet",
|
||||
"fedimint.network.testnet": "Testnet",
|
||||
"fedimint.network.testnet4": "Testnet4",
|
||||
"fedimint.network.regtest": "Regtest",
|
||||
"feed.ecosystem.group": "Ecosysteem",
|
||||
"feed.ecosystem.all": "Alles",
|
||||
"feed.ecosystem.cashu": "Cashu",
|
||||
"feed.ecosystem.fedimint": "Fedimint",
|
||||
"feed.filterBySubject": "Filter op mint of federatie",
|
||||
"feed.anySubject": "Elke mint of federatie",
|
||||
"feed.clearSubject": "Het naamfilter wissen",
|
||||
"home.fedimint.title": "Fedimint-federaties",
|
||||
"home.fedimint.body": "{total} aangekondigd op Nostr, {online} bevestigd draaiend, {announced} nooit bevestigd.",
|
||||
"home.fedimint.link": "Federaties bekijken",
|
||||
"reviews.validate.subject": "Er is niets om deze review aan te koppelen.",
|
||||
"mint.warnings.fedimintOffline.lead": "Gemeld als offline.",
|
||||
"mint.warnings.fedimintOffline.body.since": "De laatste controle die deze federatie bereikte was op {date} ({days} geleden). Haar guardians reageren sindsdien niet meer. Beschouw geld dat je er hebt staan als verloren, en doe er niet met nieuw geld mee.",
|
||||
"mint.warnings.fedimintOffline.body.never": "Geen enkele controle heeft deze federatie ooit bereikt, en de laatste mislukte. Beschouw geld dat je er hebt staan als verloren, en doe er niet met nieuw geld mee.",
|
||||
"mint.warnings.fedimintOffline.meta.since": "Gemeld als offline sinds {date}",
|
||||
"mint.warnings.fedimintOffline.meta.never": "Gemeld als offline, nooit bereikt",
|
||||
"mint.warnings.neverConfirmed.lead": "Aangekondigd, nooit bevestigd.",
|
||||
"mint.warnings.neverConfirmed.body.reviewed": "Deze federatie is op {date} op Nostr aangekondigd, {days} geleden, en niets heeft sindsdien bevestigd dat hij draait. De reviews hieronder zijn het enige teken van leven op deze pagina; al het andere komt uit de aankondiging.",
|
||||
"mint.warnings.neverConfirmed.body.quiet": "Deze federatie is op {date} op Nostr aangekondigd, {days} geleden, niets heeft sindsdien bevestigd dat hij draait, en niemand heeft hem recent gereviewd. Alles hieronder komt uit de aankondiging.",
|
||||
"mint.warnings.neverConfirmed.meta": "Aangekondigd op {date}, nooit bevestigd",
|
||||
"footer.allFedimints": "Alle federaties",
|
||||
"reviews.dialog.bodyPlaceholder.cashu": "Hoe gingen minten, melten en de support? Wees concreet.",
|
||||
"reviews.dialog.bodyPlaceholder.fedimint": "Hoe gingen meedoen, Lightning-betalingen en de support? Wees concreet.",
|
||||
"fedimint.details.title": "Gegevens van de federatie"
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ import '../styles/global.css';
|
||||
interface Props {
|
||||
title: string;
|
||||
description: string;
|
||||
current?: 'mints' | 'reviews' | 'wallets' | 'about';
|
||||
current?: 'mints' | 'fedimints' | 'reviews' | 'wallets' | 'about';
|
||||
mintCount?: number;
|
||||
ogType?: string;
|
||||
/**
|
||||
|
||||
+13
-2
@@ -1,4 +1,4 @@
|
||||
import type { Health, MintDetail, MintListItem, Stats } from '@cashumints/shared';
|
||||
import type { FedimintDetail, Health, MintDetail, MintListItem, Stats } from '@cashumints/shared';
|
||||
|
||||
/**
|
||||
* Build-time API base. Islands use PUBLIC_API_BASE (see api-client.ts) so the browser
|
||||
@@ -15,10 +15,21 @@ async function get<T>(path: string): Promise<T> {
|
||||
/**
|
||||
* Build-time fetches. A failure here fails the build on purpose: shipping a site with
|
||||
* an empty mint list would be worse than not shipping.
|
||||
*
|
||||
* `fetchMints` asks for `type=cashu` rather than for everything, which is what keeps
|
||||
* /mints, the mint pages, the home page grid and the sitemap's mint entries meaning
|
||||
* exactly what they meant before federations were indexed. Federations are their own
|
||||
* call, their own pages and their own count; nothing has to filter a mixed list and
|
||||
* nothing silently grew.
|
||||
*/
|
||||
export const fetchMints = (): Promise<MintListItem[]> => get<MintListItem[]>('/api/mints');
|
||||
export const fetchMints = (): Promise<MintListItem[]> => get<MintListItem[]>('/api/mints?type=cashu');
|
||||
export const fetchFedimints = (): Promise<MintListItem[]> =>
|
||||
get<MintListItem[]>('/api/mints?type=fedimint');
|
||||
export const fetchMint = (host: string): Promise<MintDetail> =>
|
||||
get<MintDetail>(`/api/mints/${encodeURIComponent(host)}`);
|
||||
/** The same endpoint, typed for a federation's flattened fields. */
|
||||
export const fetchFedimint = (host: string): Promise<FedimintDetail> =>
|
||||
get<FedimintDetail>(`/api/mints/${encodeURIComponent(host)}`);
|
||||
export const fetchStats = (): Promise<Stats> => get<Stats>('/api/stats');
|
||||
|
||||
/** Health is allowed to be down at build time: the ticker just renders without it. */
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* The federation page's Modules panel, as HTML strings.
|
||||
*
|
||||
* The Cashu counterpart is `nutRowsHtml` in mint-state.ts, and this is deliberately its
|
||||
* twin: same three-column row, same check and same grey "not published", so a reader
|
||||
* who has learned to scan one panel can scan the other without relearning it. The
|
||||
* classes are the same too, which is why there is no second stylesheet here.
|
||||
*
|
||||
* Built as strings rather than as markup in the page for the same reason that file
|
||||
* gives: two things render this panel — the page at build time from the last
|
||||
* announcement, and the live island from a fresh read of the API — and two
|
||||
* implementations would drift.
|
||||
*
|
||||
* What is deliberately *not* twinned: there is no `limitsHtml` here and no disabled
|
||||
* state. A federation's `modules` tag says which modules it runs, and nothing in the
|
||||
* announcement says whether any of them is switched off, accepting deposits, or
|
||||
* paying out. A red "disabled" row would be an invention, so a module is either listed
|
||||
* or it is not.
|
||||
*/
|
||||
import {
|
||||
HIGHLIGHT_MODULES, MODULE_ALIASES, MODULE_NAMES_EN, highlightModuleFor,
|
||||
type HighlightModule,
|
||||
} from '@cashumints/shared';
|
||||
import type { Formatters } from '../i18n/format';
|
||||
|
||||
const CHECK_ICON =
|
||||
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" ' +
|
||||
'stroke-width="2.5" aria-hidden="true"><path d="m5 13 4 4L19 7"/></svg>';
|
||||
|
||||
function escape(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* A module's plain-language name in this locale.
|
||||
*
|
||||
* Same rule as `localNutName`: the catalog first, `shared`'s English table second, and
|
||||
* the module's own short name last. A federation running something this build has never
|
||||
* heard of renders as `stability_pool` rather than as a guess, which is a true label.
|
||||
*/
|
||||
export function localModuleName(module: string, f: Formatters): string {
|
||||
const key = `fedimint.module.${module}`;
|
||||
if (f.t.has(key)) return f.t(key);
|
||||
return MODULE_NAMES_EN[module] ?? module;
|
||||
}
|
||||
|
||||
/**
|
||||
* The three named rows: Lightning, ecash mint, on-chain wallet.
|
||||
*
|
||||
* The short name in the mono column is the alias this federation actually runs, so a
|
||||
* federation on `lnv2` says `lnv2` rather than being relabelled `ln`. A row nothing
|
||||
* satisfies falls back to the canonical spelling, since there is no real value to show.
|
||||
*/
|
||||
export function moduleRowsHtml(modules: readonly string[], f: Formatters): string {
|
||||
const present = modules.map((module) => module.toLowerCase());
|
||||
|
||||
return HIGHLIGHT_MODULES.map((key: HighlightModule) => {
|
||||
const aliases = MODULE_ALIASES[key] ?? [];
|
||||
const running = present.find((module) => aliases.includes(module));
|
||||
|
||||
const state = running
|
||||
? `<span class="nut-state ok">${CHECK_ICON}</span>`
|
||||
: `<span class="nut-state none">${escape(f.t('fedimint.modules.notListed'))}</span>`;
|
||||
|
||||
return (
|
||||
`<div class="nut-row${running ? '' : ' off'}" data-module-row="${escape(key)}">` +
|
||||
`<span class="nut-num">${escape(running ?? aliases[0] ?? key)}</span>` +
|
||||
`<span class="nut-name">${escape(localModuleName(key, f))}</span>` +
|
||||
`${state}</div>`
|
||||
);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/** Modules with no named row of their own, for the chip list under the three. */
|
||||
export function otherModules(modules: readonly string[]): string[] {
|
||||
return modules.filter((module) => highlightModuleFor(module) === null);
|
||||
}
|
||||
|
||||
/**
|
||||
* A network's name in this locale, or the value the federation published.
|
||||
*
|
||||
* Publishing `n: "liquidtestnet"` is not something this build has a word for, and
|
||||
* showing the raw value says exactly as much as it should.
|
||||
*/
|
||||
export function localNetworkName(network: string, f: Formatters): string {
|
||||
const key = `fedimint.network.${network}`;
|
||||
return f.t.has(key) ? f.t(key) : network;
|
||||
}
|
||||
+31
-41
@@ -1,39 +1,35 @@
|
||||
/**
|
||||
* Browser-side global review feed: every mint's reviews in one list.
|
||||
*
|
||||
* The mint page asks the relays about one mint (`loadReviews` in reviews-client.ts).
|
||||
* This asks for kind 38000 with no mint filter at all and resolves each event back to
|
||||
* a mint the site knows about, which is the only way to build a feed: relays cannot
|
||||
* be asked for "reviews of any of these 58 URLs" without a filter per spelling per
|
||||
* mint, and that is hundreds of filters.
|
||||
* A mint or federation page asks the relays about one subject (`loadReviews` in
|
||||
* reviews-client.ts). This asks for kind 38000 with no subject filter at all and
|
||||
* resolves each event afterwards, which is the only way to build a feed: relays cannot
|
||||
* be asked for "reviews of any of these seventy things" without a filter per spelling
|
||||
* per subject, and that is hundreds of filters.
|
||||
*
|
||||
* Events that resolve to no known mint are dropped rather than shown. A review of a
|
||||
* mint this site has never indexed has nowhere to link to, and a card that says
|
||||
* nothing about which mint it is about is not worth a row.
|
||||
* Both ecosystems are in one list, newest first, and the page filters between them.
|
||||
* Resolution lives in `feed-resolve.ts` so the build-time prerender and this agree.
|
||||
*/
|
||||
import { SimplePool } from 'nostr-tools/pool';
|
||||
import {
|
||||
KIND_REVIEW,
|
||||
cleanReviewContent,
|
||||
isCashuMintReview,
|
||||
mintUrlsFromEvent,
|
||||
normalizeMintUrl,
|
||||
parseRating,
|
||||
} from '@cashumints/shared';
|
||||
import { KIND_REVIEW, cleanReviewContent, parseRating } from '@cashumints/shared';
|
||||
import { buildFeedIndex, resolveFeedTarget, type FeedTarget } from './feed-resolve';
|
||||
import { RELAYS, lastPublish, safeNpub, type LoadedReview } from './reviews-client';
|
||||
|
||||
/** The mint list the page was built with: enough to resolve and link a review. */
|
||||
export interface FeedMint {
|
||||
url: string;
|
||||
host: string;
|
||||
name: string;
|
||||
domain: string;
|
||||
}
|
||||
/**
|
||||
* The listing the page was built with: enough to resolve and link a review.
|
||||
*
|
||||
* This is `FeedTarget` under its old name, kept because the page's JSON payload and its
|
||||
* island both spell it. Mints and federations are the same shape here on purpose — the
|
||||
* feed mixes them, and every difference between them is one field.
|
||||
*/
|
||||
export type FeedMint = FeedTarget;
|
||||
|
||||
export interface FeedReview extends LoadedReview {
|
||||
mint_host: string;
|
||||
mint_name: string;
|
||||
mint_domain: string;
|
||||
/** `cashu` or `fedimint`: picks the card's link, and drives the ecosystem filter. */
|
||||
mint_type: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -81,7 +77,7 @@ function usable(entry: { at: number; feed: FeedReview[] }): boolean {
|
||||
export async function loadFeed(mints: FeedMint[]): Promise<FeedReview[]> {
|
||||
if (cache && usable(cache)) return cache.feed;
|
||||
|
||||
const byUrl = new Map(mints.map((mint) => [mint.url, mint]));
|
||||
const index = buildFeedIndex(mints);
|
||||
|
||||
const events = await getPool().querySync(
|
||||
RELAYS,
|
||||
@@ -89,24 +85,17 @@ export async function loadFeed(mints: FeedMint[]): Promise<FeedReview[]> {
|
||||
{ maxWait: RELAY_TIMEOUT_MS },
|
||||
);
|
||||
|
||||
// Kind 38000 is addressable: a later event from the same author about the same mint
|
||||
// replaces the earlier one. Keyed on author plus mint, so one person reviewing three
|
||||
// mints keeps three reviews.
|
||||
// Kind 38000 is addressable: a later event from the same author about the same
|
||||
// subject replaces the earlier one. Keyed on author plus subject, so one person
|
||||
// reviewing three mints and a federation keeps four reviews.
|
||||
const newest = new Map<string, FeedReview>();
|
||||
|
||||
for (const event of events) {
|
||||
const content = cleanReviewContent(event.content);
|
||||
if (!isCashuMintReview(event) && mintUrlsFromEvent(event).length === 0) continue;
|
||||
const target = resolveFeedTarget(event, index);
|
||||
if (!target) continue;
|
||||
|
||||
const raw = mintUrlsFromEvent(event)[0];
|
||||
if (!raw) continue;
|
||||
const normalized = normalizeMintUrl(raw);
|
||||
if (!normalized) continue;
|
||||
|
||||
const mint = byUrl.get(normalized.url);
|
||||
if (!mint) continue;
|
||||
|
||||
const key = `${event.pubkey}:${mint.host}`;
|
||||
const key = `${event.pubkey}:${target.host}`;
|
||||
const existing = newest.get(key);
|
||||
if (existing && existing.created_at >= event.created_at) continue;
|
||||
|
||||
@@ -117,10 +106,11 @@ export async function loadFeed(mints: FeedMint[]): Promise<FeedReview[]> {
|
||||
created_at: event.created_at,
|
||||
rating: parseRating(event),
|
||||
content,
|
||||
mint_url: mint.url,
|
||||
mint_host: mint.host,
|
||||
mint_name: mint.name,
|
||||
mint_domain: mint.domain,
|
||||
mint_url: target.url,
|
||||
mint_host: target.host,
|
||||
mint_name: target.name,
|
||||
mint_domain: target.domain,
|
||||
mint_type: target.type,
|
||||
profile: null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Which listing a review event is about, for the two feeds that read kind 38000 with no
|
||||
* subject filter at all: the /reviews island and the build-time prerender behind it.
|
||||
*
|
||||
* A per-subject read asks the relays a filter per mint or per federation. A feed cannot:
|
||||
* "reviews of any of these seventy things" is hundreds of filters, so it asks for recent
|
||||
* 38000 wholesale and works out afterwards what each event points at. That resolution is
|
||||
* this file, shared by the browser and the build so a card cannot resolve one way at
|
||||
* build time and another way a second later when the island redraws it.
|
||||
*
|
||||
* Events that resolve to nothing are dropped rather than shown. A review of a mint or a
|
||||
* federation this site has never indexed has nowhere to link to, and a card that cannot
|
||||
* say what it is about is not worth a row.
|
||||
*
|
||||
* No `t()` here: `check-i18n.mjs` treats every `.ts` under `lib/` as island code, and
|
||||
* there is nothing to translate in a lookup anyway.
|
||||
*/
|
||||
import {
|
||||
federationIdFromKey, federationIdFromSlug, mintUrlsFromEvent, normalizeMintUrl,
|
||||
reviewEcosystem, reviewTargetId, type MintListItem, type NostrEventLike,
|
||||
} from '@cashumints/shared';
|
||||
import { displayDomain } from './format';
|
||||
|
||||
/** One indexed listing, with enough to resolve a review to it and then link to it. */
|
||||
export interface FeedTarget {
|
||||
/** Row key: a mint URL, or `fedimint:<id>`. */
|
||||
url: string;
|
||||
/** Routing slug. */
|
||||
host: string;
|
||||
name: string;
|
||||
/** The label a card prints: a domain, or a shortened federation id. */
|
||||
domain: string;
|
||||
/** `cashu` or `fedimint`. Picks both the resolver and the link's route. */
|
||||
type: string;
|
||||
/**
|
||||
* The `d` identifier reviews of this thing carry, when that is how it is found.
|
||||
*
|
||||
* A federation id. null for a mint, which is resolved through its `u` tag instead —
|
||||
* that is how every legacy review points at one, and changing it would strand them.
|
||||
*/
|
||||
ref: string | null;
|
||||
}
|
||||
|
||||
/** Both lookups a feed needs, built once per read rather than per event. */
|
||||
export interface FeedIndex {
|
||||
byUrl: Map<string, FeedTarget>;
|
||||
byRef: Map<string, FeedTarget>;
|
||||
}
|
||||
|
||||
export function buildFeedIndex(targets: readonly FeedTarget[]): FeedIndex {
|
||||
const byUrl = new Map<string, FeedTarget>();
|
||||
const byRef = new Map<string, FeedTarget>();
|
||||
for (const target of targets) {
|
||||
byUrl.set(target.url, target);
|
||||
if (target.ref) byRef.set(target.ref.toLowerCase(), target);
|
||||
}
|
||||
return { byUrl, byRef };
|
||||
}
|
||||
|
||||
/**
|
||||
* The listing a review points at, or null.
|
||||
*
|
||||
* The `k` tag picks the resolver, and it has to: a federation id and a mint's pubkey are
|
||||
* both 64 hex characters, so a `d`-first resolver with no ecosystem check could file a
|
||||
* federation review against a mint. An event with no `k` is Cashu — every one of those
|
||||
* predates this site listing anything else — and one naming a kind this build has no
|
||||
* ecosystem for belongs to neither and is dropped.
|
||||
*/
|
||||
export function resolveFeedTarget(event: NostrEventLike, index: FeedIndex): FeedTarget | null {
|
||||
const ecosystem = reviewEcosystem(event);
|
||||
|
||||
if (ecosystem === 'fedimint') {
|
||||
const d = reviewTargetId(event);
|
||||
return d ? index.byRef.get(d.toLowerCase()) ?? null : null;
|
||||
}
|
||||
|
||||
if (ecosystem !== 'cashu') return null;
|
||||
|
||||
const raw = mintUrlsFromEvent(event)[0];
|
||||
if (!raw) return null;
|
||||
const normalized = normalizeMintUrl(raw);
|
||||
if (!normalized) return null;
|
||||
return index.byUrl.get(normalized.url) ?? null;
|
||||
}
|
||||
|
||||
/** The page a card links to. One route per ecosystem, decided in one place. */
|
||||
export function targetPath(target: Pick<FeedTarget, 'type' | 'host'>): string {
|
||||
return `/${target.type === 'fedimint' ? 'fedimint' : 'mint'}/${target.host}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn the two list payloads into what a feed resolves against.
|
||||
*
|
||||
* A federation's `ref` is not fetched from anywhere: its row key already *is*
|
||||
* `fedimint:<federation id>`, so the full 64-character id a review's `d` tag carries is
|
||||
* sitting in the list payload. That is worth knowing, because the routing slug holds
|
||||
* only the first sixteen and matching on a prefix would have been the obvious wrong
|
||||
* answer here.
|
||||
*
|
||||
* Order is preserved and mints come first, which is the order the caller passes and the
|
||||
* order /reviews shows before its own sort.
|
||||
*/
|
||||
export function feedTargets(...lists: Array<readonly MintListItem[]>): FeedTarget[] {
|
||||
return lists.flat().map((item) => {
|
||||
const federationId = federationIdFromKey(item.url);
|
||||
const domain = federationId ? federationIdFromSlug(item.host) : displayDomain(item.url);
|
||||
return {
|
||||
url: item.url,
|
||||
host: item.host,
|
||||
name: item.name ?? domain.split('/')[0] ?? domain,
|
||||
domain,
|
||||
type: item.type,
|
||||
ref: federationId,
|
||||
};
|
||||
});
|
||||
}
|
||||
+27
-31
@@ -15,14 +15,12 @@ import {
|
||||
KIND_REVIEW,
|
||||
PROFILE_RELAYS,
|
||||
cleanReviewContent,
|
||||
isCashuMintReview,
|
||||
normalizeMintUrl,
|
||||
mintUrlsFromEvent,
|
||||
parseProfileContent,
|
||||
parseRating,
|
||||
WRITTEN_MIN_CHARS,
|
||||
type Profile,
|
||||
} from '@cashumints/shared';
|
||||
import { buildFeedIndex, resolveFeedTarget, type FeedTarget } from './feed-resolve';
|
||||
|
||||
const list = (value: string | undefined): string[] | null => {
|
||||
const parsed = value?.split(',').map((relay) => relay.trim()).filter(Boolean);
|
||||
@@ -47,6 +45,8 @@ export interface BuildReview {
|
||||
content: string;
|
||||
mint_url: string;
|
||||
mint_host: string;
|
||||
/** `cashu` or `fedimint`: which route the card links to, and which filter shows it. */
|
||||
mint_type: string;
|
||||
profile: Profile | null;
|
||||
}
|
||||
|
||||
@@ -186,15 +186,19 @@ function readReviewEvents(): Promise<NostrEvent[]> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Newest review events that are worth showing, resolved against the mints the API
|
||||
* knows about. `hostByUrl` maps normalized mint URL to routing slug.
|
||||
* Newest review events that are worth showing, resolved against everything the API
|
||||
* knows about — mints and federations both, since the strip's heading is "latest
|
||||
* reviews" and not "latest mint reviews". In practice federations rarely reach it: the
|
||||
* junk filter above wants a written body, and almost every 38173 review on the network
|
||||
* is a bare `[5/5]`.
|
||||
*/
|
||||
export async function fetchLatestReviews(
|
||||
hostByUrl: Map<string, string>,
|
||||
targets: readonly FeedTarget[],
|
||||
limit: number,
|
||||
): Promise<BuildReview[]> {
|
||||
try {
|
||||
const events = await readReviewEvents();
|
||||
const index = buildFeedIndex(targets);
|
||||
|
||||
const candidates: BuildReview[] = [];
|
||||
const seenAuthors = new Set<string>();
|
||||
@@ -202,15 +206,9 @@ export async function fetchLatestReviews(
|
||||
for (const event of [...events].sort((a, b) => b.created_at - a.created_at)) {
|
||||
const content = cleanReviewContent(event.content);
|
||||
if (!qualifies(content)) continue;
|
||||
if (!isCashuMintReview(event) && mintUrlsFromEvent(event).length === 0) continue;
|
||||
|
||||
const raw = mintUrlsFromEvent(event)[0];
|
||||
if (!raw) continue;
|
||||
const normalized = normalizeMintUrl(raw);
|
||||
if (!normalized) continue;
|
||||
|
||||
const host = hostByUrl.get(normalized.url);
|
||||
if (!host) continue;
|
||||
const target = resolveFeedTarget(event, index);
|
||||
if (!target) continue;
|
||||
|
||||
// One card per author, so the strip is not three posts from the same npub.
|
||||
if (seenAuthors.has(event.pubkey)) continue;
|
||||
@@ -223,8 +221,9 @@ export async function fetchLatestReviews(
|
||||
created_at: event.created_at,
|
||||
rating: parseRating(event),
|
||||
content,
|
||||
mint_url: normalized.url,
|
||||
mint_host: host,
|
||||
mint_url: target.url,
|
||||
mint_host: target.host,
|
||||
mint_type: target.type,
|
||||
profile: null,
|
||||
});
|
||||
|
||||
@@ -261,7 +260,7 @@ export async function fetchLatestReviews(
|
||||
}
|
||||
|
||||
/**
|
||||
* The global feed: newest written reviews across every mint.
|
||||
* The global feed: newest written reviews across every mint and federation.
|
||||
*
|
||||
* Same source and same parsing as the home page strip, three differences:
|
||||
* every author may appear more than once (once per mint they reviewed), the junk
|
||||
@@ -275,33 +274,29 @@ export async function fetchLatestReviews(
|
||||
* visitor, only for a crawler.
|
||||
*/
|
||||
export async function fetchReviewFeed(
|
||||
hostByUrl: Map<string, string>,
|
||||
targets: readonly FeedTarget[],
|
||||
limit: number,
|
||||
): Promise<BuildReview[]> {
|
||||
try {
|
||||
const events = await readReviewEvents();
|
||||
const index = buildFeedIndex(targets);
|
||||
|
||||
/*
|
||||
* Kind 38000 is addressable: a later event from the same author about the same
|
||||
* mint replaces the earlier one, exactly as the mint page treats it. Keyed on
|
||||
* author plus mint, so one person reviewing three mints keeps three reviews.
|
||||
* subject replaces the earlier one, exactly as a subject page treats it. Keyed on
|
||||
* author plus subject, so one person reviewing three mints and a federation keeps
|
||||
* four reviews.
|
||||
*/
|
||||
const newest = new Map<string, BuildReview>();
|
||||
|
||||
for (const event of events) {
|
||||
const content = cleanReviewContent(event.content);
|
||||
if (content.length < WRITTEN_MIN_CHARS) continue;
|
||||
if (!isCashuMintReview(event) && mintUrlsFromEvent(event).length === 0) continue;
|
||||
|
||||
const raw = mintUrlsFromEvent(event)[0];
|
||||
if (!raw) continue;
|
||||
const normalized = normalizeMintUrl(raw);
|
||||
if (!normalized) continue;
|
||||
const target = resolveFeedTarget(event, index);
|
||||
if (!target) continue;
|
||||
|
||||
const host = hostByUrl.get(normalized.url);
|
||||
if (!host) continue;
|
||||
|
||||
const key = `${event.pubkey}:${host}`;
|
||||
const key = `${event.pubkey}:${target.host}`;
|
||||
const existing = newest.get(key);
|
||||
if (existing && existing.created_at >= event.created_at) continue;
|
||||
|
||||
@@ -312,8 +307,9 @@ export async function fetchReviewFeed(
|
||||
created_at: event.created_at,
|
||||
rating: parseRating(event),
|
||||
content,
|
||||
mint_url: normalized.url,
|
||||
mint_host: host,
|
||||
mint_url: target.url,
|
||||
mint_host: target.host,
|
||||
mint_type: target.type,
|
||||
profile: null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -334,6 +334,10 @@ async function reachBunker(connect: ConnectInfo): Promise<BunkerSigner> {
|
||||
* never gets its approval and the request times out with a message.
|
||||
*/
|
||||
function openAuthWindow(url: string): void {
|
||||
// The URL comes from the remote signer, which is a stranger until this pairing
|
||||
// succeeds. Anything but http(s) — javascript:, data:, a custom scheme — has no
|
||||
// business being opened from here, noopener or not.
|
||||
if (!/^https?:\/\//i.test(url)) return;
|
||||
status('waiting', signerText('signer.status.approveWindow'));
|
||||
window.open(url, 'cashumints-signer-auth', 'width=420,height=640,noopener,noreferrer');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* The social-image manifest, as the pages read it.
|
||||
*
|
||||
* `pnpm og` (the first half of `pnpm build`) renders one PNG per mint and federation
|
||||
* into `public/og/` and writes the slug-to-filename map to
|
||||
* `src/generated/og-manifest.json`. Filenames carry a content hash
|
||||
* (`mint.example.com.a1b2c3d4e5.png`), so a mint whose card changed gets a new URL —
|
||||
* which is the part that actually busts scraper caches, since the big link-preview
|
||||
* crawlers cache an og:image URL itself and ignore its response headers.
|
||||
*
|
||||
* Read with `fs` rather than imported, because the file is generated: an import would
|
||||
* make `astro dev` unrunnable before the first `pnpm og`. Missing manifest (or a slug
|
||||
* that failed to render) falls back to the shared default card, so a page never
|
||||
* points at an image that is not on disk.
|
||||
*
|
||||
* Build-time only. Never import this from an island: it reads the filesystem.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
interface OgManifest {
|
||||
default?: { file: string };
|
||||
images?: Record<string, { file: string }>;
|
||||
}
|
||||
|
||||
// Resolved from the working directory (astro runs with cwd = web/) rather than from
|
||||
// `import.meta.url`, which points into the bundled output once the build compiles this.
|
||||
const manifestFile = path.resolve(process.cwd(), 'src/generated/og-manifest.json');
|
||||
|
||||
let manifest: OgManifest | null = null;
|
||||
try {
|
||||
manifest = JSON.parse(readFileSync(manifestFile, 'utf8')) as OgManifest;
|
||||
} catch {
|
||||
manifest = null;
|
||||
}
|
||||
|
||||
/** Site-relative path of one mint's card, or null when no card was generated for it. */
|
||||
export function ogImageFor(slug: string): string | null {
|
||||
const file = manifest?.images?.[slug]?.file;
|
||||
return file ? `/og/${file}` : null;
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* What a review is *about*, in the one shape both ecosystems fit.
|
||||
*
|
||||
* The reviews panel used to take a mint: it read `mint.url` and `mint.pubkey`, built a
|
||||
* `#u` filter out of URL spellings, and published a `d` tag holding a mint's own
|
||||
* pubkey. None of that has a Fedimint meaning — a federation has no URL, no `/v1/info`
|
||||
* and no pubkey of its own — but the *shape* is identical in both cases, because NIP-87
|
||||
* says so: a review points at an announcement of kind `k` by its `d` identifier, and
|
||||
* optionally repeats the thing's address in `u`.
|
||||
*
|
||||
* So the panel takes one of these instead, and asks nothing about which ecosystem it
|
||||
* came from. A third ecosystem needs a builder here and nothing at all in the panel,
|
||||
* the card, the publisher or the dialog.
|
||||
*
|
||||
* It crosses from the page into the island as a JSON blob in a data attribute, so it
|
||||
* has to stay plain data — no functions, no class instances.
|
||||
*/
|
||||
import {
|
||||
KIND_FEDIMINT_ANNOUNCEMENT, KIND_MINT_ANNOUNCEMENT, PRIMARY_RELAY, mintUrlSpellings,
|
||||
type FedimintDetail, type MintDetail,
|
||||
} from '@cashumints/shared';
|
||||
|
||||
export interface ReviewSubject {
|
||||
/** `cashu` or `fedimint`. Only used for labelling; no logic branches on it. */
|
||||
type: string;
|
||||
/** The announcement kind, written to the review's `k` tag: 38172 or 38173. */
|
||||
kind: number;
|
||||
/**
|
||||
* The `d` identifier of the announcement being reviewed, and the value the `#d` relay
|
||||
* filter asks for. A mint's own pubkey from `/v1/info`, or a federation id.
|
||||
*
|
||||
* null for a Cashu mint that has never answered a probe: there is no pubkey to point
|
||||
* at, so such a review is matched by `u` alone. That is the pre-existing behaviour
|
||||
* and it is why this is nullable at all — a federation always has one.
|
||||
*/
|
||||
d: string | null;
|
||||
/**
|
||||
* Every spelling of the thing's address that may appear in a `u` tag.
|
||||
*
|
||||
* Six URL spellings for a mint (the old site published all of them, see NOTES.md);
|
||||
* the invite codes for a federation.
|
||||
*/
|
||||
u: string[];
|
||||
/**
|
||||
* The third element of the `u` tag on a published review, which NIP-87 leaves as a
|
||||
* free-form marker. The old publisher wrote `cashu` and this keeps writing it.
|
||||
*/
|
||||
marker: string;
|
||||
/**
|
||||
* Who published the announcement, for the `a` tag's pubkey.
|
||||
*
|
||||
* A federation's announcement is published by a person, and `a` is an address into
|
||||
* that person's replaceable events, so this is genuinely required to build a correct
|
||||
* pointer. See `aTag` for why the Cashu side does not use it.
|
||||
*/
|
||||
announcer: string | null;
|
||||
/**
|
||||
* Cache and draft key. The row's primary key: a mint URL, or `fedimint:<id>`.
|
||||
* Never rendered.
|
||||
*/
|
||||
key: string;
|
||||
/**
|
||||
* How many `u` values a relay filter is allowed to carry.
|
||||
*
|
||||
* A mint's six URL spellings are short. A federation's invite code is 150+ characters
|
||||
* and two of the five relays answer `ERROR: bad req: filter item too large` and
|
||||
* nothing else, so a federation asks by `d` only and this is 0 for one.
|
||||
*/
|
||||
filterByU: boolean;
|
||||
}
|
||||
|
||||
/** A Cashu mint, reviewed exactly as it has always been. */
|
||||
export function mintSubject(mint: Pick<MintDetail, 'url' | 'pubkey'>): ReviewSubject {
|
||||
return {
|
||||
type: 'cashu',
|
||||
kind: KIND_MINT_ANNOUNCEMENT,
|
||||
d: mint.pubkey ?? null,
|
||||
u: mintUrlSpellings(mint.url),
|
||||
marker: 'cashu',
|
||||
announcer: null,
|
||||
key: mint.url,
|
||||
filterByU: true,
|
||||
};
|
||||
}
|
||||
|
||||
/** A Fedimint federation, identified by the id in its announcement's `d` tag. */
|
||||
export function fedimintSubject(
|
||||
federation: Pick<FedimintDetail, 'url' | 'federation_id' | 'invite_codes' | 'announcer_pubkey'>,
|
||||
): ReviewSubject {
|
||||
return {
|
||||
type: 'fedimint',
|
||||
kind: KIND_FEDIMINT_ANNOUNCEMENT,
|
||||
d: federation.federation_id,
|
||||
u: federation.invite_codes ?? [],
|
||||
marker: 'fedimint',
|
||||
announcer: federation.announcer_pubkey ?? null,
|
||||
key: federation.url,
|
||||
filterByU: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The tags a published review carries, per NIP-87.
|
||||
*
|
||||
* `k` names the kind being recommended, `d` repeats the announcement's identifier so
|
||||
* the review replaces the author's previous one about the same thing, `u` carries the
|
||||
* address, and `a` points at the announcement event itself with a relay hint.
|
||||
*
|
||||
* The `a` tag differs by ecosystem, and deliberately:
|
||||
*
|
||||
* - Cashu writes `38172:<mint pubkey>:<mint pubkey>`. That is not what an `a` tag
|
||||
* means — the middle field is the *author* of the addressed event, not the subject
|
||||
* — but it is byte for byte what this site has always published, other clients
|
||||
* read it, and changing it would orphan every review already on the network from
|
||||
* the ones written after. NOTES.md has the history.
|
||||
* - Fedimint writes `38173:<announcer pubkey>:<federation id>`, which is the correct
|
||||
* form, because there is no legacy to preserve and the announcer is known.
|
||||
*
|
||||
* A subject with neither `d` nor any `u` value cannot be pointed at, and the caller
|
||||
* refuses to publish rather than sign an event nothing can resolve.
|
||||
*/
|
||||
export function reviewTags(subject: ReviewSubject): string[][] {
|
||||
const tags: string[][] = [['k', String(subject.kind)]];
|
||||
|
||||
const address = subject.u[0];
|
||||
if (address) tags.push(['u', address, subject.marker]);
|
||||
|
||||
if (subject.d) {
|
||||
tags.push(['d', subject.d]);
|
||||
const author = subject.announcer ?? subject.d;
|
||||
tags.push(['a', `${subject.kind}:${author}:${subject.d}`, PRIMARY_RELAY]);
|
||||
} else if (address) {
|
||||
// No announcement identifier to point at. The address becomes the `d`, which is
|
||||
// what the old publisher did for a mint that had never answered a probe.
|
||||
tags.push(['d', address]);
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
/** Everything a published review needs to resolve to something. */
|
||||
export function isPublishable(subject: ReviewSubject): boolean {
|
||||
return Boolean(subject.d) || subject.u.length > 0;
|
||||
}
|
||||
@@ -2,8 +2,11 @@
|
||||
* Browser-side Nostr: read review content from relays, resolve reviewer profiles,
|
||||
* and publish a new review signed by whichever signer the reader is using.
|
||||
*
|
||||
* Kinds, tags and relays are exactly what the old site used (see NOTES.md), so this
|
||||
* reads and writes the same events any other client sees.
|
||||
* Kinds, tags and relays for a Cashu mint are exactly what the old site used (see
|
||||
* NOTES.md), so this reads and writes the same events any other client sees. Which
|
||||
* kinds and tags those are is not decided here: the caller hands in a `ReviewSubject`
|
||||
* and this file publishes whatever that says, which is how one panel, one dialog and
|
||||
* one card serve both mints and federations.
|
||||
*/
|
||||
import { useI18n } from '../i18n/client';
|
||||
import { SimplePool } from 'nostr-tools/pool';
|
||||
@@ -11,15 +14,15 @@ import type { Event as NostrEvent } from 'nostr-tools';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import {
|
||||
DEFAULT_RELAYS,
|
||||
KIND_MINT_ANNOUNCEMENT,
|
||||
KIND_REVIEW,
|
||||
PRIMARY_RELAY,
|
||||
cleanReviewContent,
|
||||
mintUrlSpellings,
|
||||
parseRating,
|
||||
tagValue,
|
||||
tagValues,
|
||||
type Profile,
|
||||
type Review,
|
||||
} from '@cashumints/shared';
|
||||
import { isPublishable, reviewTags, type ReviewSubject } from './review-subject';
|
||||
|
||||
/**
|
||||
* Relays reviews are read from and published to.
|
||||
@@ -52,7 +55,7 @@ function getPool(): SimplePool {
|
||||
/* ---------- the read cache ---------- */
|
||||
|
||||
/**
|
||||
* How long a mint's reviews stay good for this tab.
|
||||
* How long one subject's reviews stay good for this tab.
|
||||
*
|
||||
* The panel calls `loadReviews` on every arrival, and the router counts a back button
|
||||
* as an arrival, so leaving a mint page and returning to it used to mean another
|
||||
@@ -74,13 +77,13 @@ const CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
const cache = new Map<string, { at: number; reviews: LoadedReview[] }>();
|
||||
|
||||
/**
|
||||
* Forget what the relays said about a mint, so the next read asks them again.
|
||||
* Forget what the relays said about one subject, so the next read asks them again.
|
||||
*
|
||||
* Called after publishing: an author who has just written a review has to see it in
|
||||
* the list, and a cached answer from thirty seconds ago does not contain it.
|
||||
*/
|
||||
export function invalidateReviews(mintUrl: string): void {
|
||||
cache.delete(mintUrl);
|
||||
export function invalidateReviews(key: string): void {
|
||||
cache.delete(key);
|
||||
}
|
||||
|
||||
let publishedAt = 0;
|
||||
@@ -97,29 +100,41 @@ export function lastPublish(): number {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch reviews for one mint.
|
||||
* Fetch reviews about one subject: a mint, or a federation.
|
||||
*
|
||||
* Two filters, OR'd, matching the old site's read path:
|
||||
* - `#d` = the mint's own pubkey from /v1/info, with `#k` = 38172 (proper NIP-87)
|
||||
* - `#u` = the mint URL in every spelling the old site published
|
||||
* The URL filter is what finds legacy reviews, and dropping it would lose most of them.
|
||||
* - `#d` = the announcement's identifier, with `#k` = its kind (proper NIP-87). For a
|
||||
* mint that is the pubkey from /v1/info; for a federation, the federation id.
|
||||
* - `#u` = the address, in every spelling the old site published
|
||||
*
|
||||
* The URL filter is what finds legacy mint reviews, and dropping it would lose most of
|
||||
* them. A federation sets `filterByU: false` and is asked by `#d` alone — its `u` value
|
||||
* is a 150 character invite code, and relays reject a filter item that long outright,
|
||||
* so sending it costs round trips and returns nothing. See `review-subject.ts`.
|
||||
*/
|
||||
export async function loadReviews(mintUrl: string, mintPubkey: string | null): Promise<LoadedReview[]> {
|
||||
const hit = cache.get(mintUrl);
|
||||
/** Longest body a card will render. Anything real fits; a stunt event is cut. */
|
||||
const MAX_RENDERED_CHARS = 4000;
|
||||
|
||||
export async function loadReviews(subject: ReviewSubject): Promise<LoadedReview[]> {
|
||||
const hit = cache.get(subject.key);
|
||||
if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.reviews;
|
||||
|
||||
const filters: Record<string, unknown>[] = [];
|
||||
|
||||
if (mintPubkey) {
|
||||
if (subject.d) {
|
||||
filters.push({
|
||||
kinds: [KIND_REVIEW],
|
||||
'#d': [mintPubkey],
|
||||
'#k': [String(KIND_MINT_ANNOUNCEMENT)],
|
||||
'#d': [subject.d],
|
||||
'#k': [String(subject.kind)],
|
||||
limit: 500,
|
||||
});
|
||||
}
|
||||
|
||||
filters.push({ kinds: [KIND_REVIEW], '#u': mintUrlSpellings(mintUrl), limit: 500 });
|
||||
if (subject.filterByU && subject.u.length > 0) {
|
||||
filters.push({ kinds: [KIND_REVIEW], '#u': subject.u, limit: 500 });
|
||||
}
|
||||
|
||||
if (filters.length === 0) return [];
|
||||
|
||||
const events = await Promise.all(
|
||||
filters.map((filter) =>
|
||||
@@ -129,10 +144,30 @@ export async function loadReviews(mintUrl: string, mintPubkey: string | null): P
|
||||
),
|
||||
);
|
||||
|
||||
/*
|
||||
* The pool verifies signatures, but which events come back is the relay's word: a
|
||||
* misbehaving relay can answer a filter with events that do not match it, and
|
||||
* rendering those would file someone's review of one mint under another. So every
|
||||
* event is re-checked against the subject's own tags, mirroring the two filters.
|
||||
*/
|
||||
const uSpellings = new Set(subject.u);
|
||||
const aboutSubject = (event: NostrEvent): boolean => {
|
||||
if (event.kind !== KIND_REVIEW) return false;
|
||||
if (
|
||||
subject.d &&
|
||||
tagValue(event.tags, 'd') === subject.d &&
|
||||
tagValue(event.tags, 'k') === String(subject.kind)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return subject.filterByU && tagValues(event.tags, 'u').some((u) => uSpellings.has(u));
|
||||
};
|
||||
|
||||
// One review per author, newest wins: kind 38000 is addressable, so a later event
|
||||
// from the same npub replaces the earlier one.
|
||||
const byAuthor = new Map<string, NostrEvent>();
|
||||
for (const event of events.flat()) {
|
||||
if (!aboutSubject(event)) continue;
|
||||
const existing = byAuthor.get(event.pubkey);
|
||||
if (!existing || event.created_at > existing.created_at) byAuthor.set(event.pubkey, event);
|
||||
}
|
||||
@@ -145,15 +180,17 @@ export async function loadReviews(mintUrl: string, mintPubkey: string | null): P
|
||||
npub: safeNpub(event.pubkey),
|
||||
created_at: event.created_at,
|
||||
rating: parseRating(event),
|
||||
content: cleanReviewContent(event.content),
|
||||
mint_url: mintUrl,
|
||||
// Publishing caps a review at 2000 characters, but legacy and foreign clients
|
||||
// did not; an unbounded body would be an unbounded card.
|
||||
content: cleanReviewContent(event.content).slice(0, MAX_RENDERED_CHARS),
|
||||
mint_url: subject.key,
|
||||
profile: null,
|
||||
}));
|
||||
|
||||
// Nothing back is not the same as no reviews: the pool swallows per relay failures
|
||||
// and resolves empty, which is why the panel treats an empty answer as a relay
|
||||
// error. Caching that would turn its retry button into a no-op for five minutes.
|
||||
if (reviews.length > 0) cache.set(mintUrl, { at: Date.now(), reviews });
|
||||
if (reviews.length > 0) cache.set(subject.key, { at: Date.now(), reviews });
|
||||
|
||||
return reviews;
|
||||
}
|
||||
@@ -185,6 +222,7 @@ export function safeNpub(pubkey: string): string {
|
||||
import type { NostrSigner } from './nostr-signer';
|
||||
|
||||
export { getExtension, hasExtension, activeSigner } from './nostr-signer';
|
||||
export { mintSubject, fedimintSubject, type ReviewSubject } from './review-subject';
|
||||
export type { NostrSigner, SignerMethod } from './nostr-signer';
|
||||
|
||||
export interface PublishResult {
|
||||
@@ -197,8 +235,8 @@ export interface PublishResult {
|
||||
|
||||
export interface PublishOptions {
|
||||
signer: NostrSigner;
|
||||
mintUrl: string;
|
||||
mintPubkey: string | null;
|
||||
/** What is being reviewed. Decides `k`, `d`, `u` and `a` — see `review-subject.ts`. */
|
||||
subject: ReviewSubject;
|
||||
rating: number;
|
||||
content: string;
|
||||
/**
|
||||
@@ -219,10 +257,14 @@ const PUBLISH_TIMEOUT_MS = 10_000;
|
||||
/**
|
||||
* Sign and broadcast a review.
|
||||
*
|
||||
* The event is built exactly as the old publisher built it (kind 38000, `[N/5]`
|
||||
* A Cashu review is built exactly as the old publisher built it (kind 38000, `[N/5]`
|
||||
* content prefix, k/u/d tags), plus the `rating` and `a` tags its dead code path
|
||||
* intended. Adding those is safe: readers that only understand the content prefix
|
||||
* still parse it, and readers that prefer the tag get a cleaner value.
|
||||
* intended. Adding those was safe and stays safe: readers that only understand the
|
||||
* content prefix still parse it, and readers that prefer the tag get a cleaner value.
|
||||
*
|
||||
* A Fedimint review is the same event with `k` = 38173, `d` = the federation id and
|
||||
* `u` = an invite code. The pointer tags come from the subject rather than from
|
||||
* anything here, so this function has no idea which ecosystem it is publishing about.
|
||||
*
|
||||
* One relay accepting is a success: the review is on the network and the others will
|
||||
* see it. How many took it is returned either way, because "published to 2 of 4" is
|
||||
@@ -233,7 +275,7 @@ const MIN_REVIEW_CHARS = 10;
|
||||
const MAX_REVIEW_CHARS = 2000;
|
||||
|
||||
export async function publishReview(options: PublishOptions): Promise<PublishResult> {
|
||||
const { signer, mintUrl, mintPubkey, rating, content, onSigned, onProgress } = options;
|
||||
const { signer, subject, rating, content, onSigned, onProgress } = options;
|
||||
|
||||
/*
|
||||
* These three are the last line of defence, not the reader's usual path: the dialog
|
||||
@@ -252,19 +294,16 @@ export async function publishReview(options: PublishOptions): Promise<PublishRes
|
||||
throw new Error(t('reviews.validate.long', { n: MAX_REVIEW_CHARS }));
|
||||
}
|
||||
|
||||
const tags: string[][] = [
|
||||
['k', String(KIND_MINT_ANNOUNCEMENT)],
|
||||
['u', mintUrl, 'cashu'],
|
||||
['rating', String(rating)],
|
||||
];
|
||||
if (mintPubkey) {
|
||||
tags.push(['d', mintPubkey]);
|
||||
tags.push(['a', `${KIND_MINT_ANNOUNCEMENT}:${mintPubkey}:${mintPubkey}`, PRIMARY_RELAY]);
|
||||
} else {
|
||||
// Without the mint's own pubkey there is no NIP-33 identifier to use, so fall back
|
||||
// to the mint URL. The `u` tag is what the reader matches on anyway.
|
||||
tags.push(['d', mintUrl]);
|
||||
}
|
||||
// A review nothing can resolve is worse than no review: it would be signed, accepted
|
||||
// by relays and invisible on every page including the one it was written on.
|
||||
if (!isPublishable(subject)) throw new Error(t('reviews.validate.subject'));
|
||||
|
||||
const tags = reviewTags(subject);
|
||||
// The `rating` tag goes immediately after `u`, which is where this site has always
|
||||
// written it. Nothing reads tag order, but an event that differs from its
|
||||
// predecessors only in field order is a needless difference to explain later.
|
||||
const afterU = tags.findIndex((tag) => tag[0] === 'u');
|
||||
tags.splice(afterU === -1 ? tags.length : afterU + 1, 0, ['rating', String(rating)]);
|
||||
|
||||
const signed = (await signer.signEvent({
|
||||
kind: KIND_REVIEW,
|
||||
@@ -307,7 +346,7 @@ export async function publishReview(options: PublishOptions): Promise<PublishRes
|
||||
|
||||
// The author's own review has to be there the next time either list is read, and a
|
||||
// cached answer from before they wrote it is not.
|
||||
invalidateReviews(mintUrl);
|
||||
invalidateReviews(subject.key);
|
||||
publishedAt = Date.now();
|
||||
|
||||
return { event: signed, accepted, total };
|
||||
|
||||
@@ -90,3 +90,46 @@ export function clampWords(value: string, max: number): string {
|
||||
const lastSpace = cut.lastIndexOf(' ');
|
||||
return (lastSpace > max * 0.6 ? cut.slice(0, lastSpace) : cut).trimEnd();
|
||||
}
|
||||
|
||||
/**
|
||||
* A mint's own description, made fit to open a meta description.
|
||||
*
|
||||
* It is operator-written text headed for a `content` attribute and a link preview, so
|
||||
* everything that does not belong in either comes out: HTML tags and control
|
||||
* characters are stripped, whitespace collapses, and only the first sentence
|
||||
* survives — a meta description quoting three sentences of marketing leaves no room
|
||||
* for the rating and status that make the snippet useful. (Astro does the actual
|
||||
* attribute escaping; this removes what escaping would merely neutralise.)
|
||||
*
|
||||
* Two judgement calls:
|
||||
*
|
||||
* - SHOUTING is normalised to sentence case. A description that is ≥80% capitals
|
||||
* reads as noise in a search result, and the mint's page still shows the original.
|
||||
* - The sentence split requires whitespace after the terminator, so "mint.example.com"
|
||||
* inside a sentence does not end it.
|
||||
*
|
||||
* Returns null when nothing usable is left, and the caller falls back to its own copy.
|
||||
*/
|
||||
export function cleanMintDescription(value: string | null | undefined, max = 70): string | null {
|
||||
if (!value) return null;
|
||||
|
||||
let clean = value
|
||||
.replace(/<[^>]*>/g, ' ')
|
||||
.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
if (clean === '') return null;
|
||||
|
||||
const sentence = /^.*?[.!?](?=\s|$)/.exec(clean);
|
||||
clean = (sentence?.[0] ?? clean).trim();
|
||||
|
||||
const letters = clean.replace(/[^\p{L}]/gu, '');
|
||||
if (letters.length >= 8 && letters.replace(/\p{Ll}/gu, '').length / letters.length > 0.8) {
|
||||
clean = clean.toLowerCase();
|
||||
clean = clean[0]!.toUpperCase() + clean.slice(1);
|
||||
}
|
||||
|
||||
clean = clampWords(clean, max);
|
||||
if (!/[.!?]$/.test(clean)) clean += '.';
|
||||
return clean;
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@ export const FIXTURE_REVIEWS: LoadedReview[] = [
|
||||
* actually renders: name, URL, status, rating, review count, software and last seen.
|
||||
*/
|
||||
export const FIXTURE_MINT: MintDetail = {
|
||||
type: 'cashu',
|
||||
url: 'https://mint.example.com',
|
||||
host: 'mint.example.com',
|
||||
name: 'Example Mint',
|
||||
|
||||
@@ -0,0 +1,633 @@
|
||||
---
|
||||
/**
|
||||
* One federation. The mint page's layout, with the panels a federation can honestly
|
||||
* fill and none of the ones it cannot.
|
||||
*
|
||||
* What is the same: the identity header, the warning banner slot, the verdict strip,
|
||||
* the reviews panel (identical component, identical cards, identical write dialog), the
|
||||
* related ring and the disclaimer.
|
||||
*
|
||||
* What is different, and why:
|
||||
*
|
||||
* - The NUTs panel becomes a Modules panel. Same rows, same check, but there is no
|
||||
* disabled state, because a `modules` tag says what a federation runs and nothing
|
||||
* about whether any of it is switched on.
|
||||
* - The verdict strip drops the software and limits cells. A federation reports no
|
||||
* version and publishes no min/max, and a cell reading "unknown" twice is furniture.
|
||||
* Network and "announced / last confirmed" take their place, which are two things
|
||||
* this page does know.
|
||||
* - The invite codes block replaces the copy-URL header action, because a federation
|
||||
* has no URL to copy and the invite code is what a wallet actually needs.
|
||||
*/
|
||||
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 InviteCodes from '../../../components/InviteCodes.astro';
|
||||
import MintCard from '../../../components/MintCard.astro';
|
||||
import {
|
||||
getMintWarnings, isTestNetwork, type FedimintDetail, type MintListItem,
|
||||
} from '@cashumints/shared';
|
||||
import { fetchFedimint, fetchFedimints, iconUrl } from '../../../lib/api';
|
||||
import { ogImageFor } from '../../../lib/og';
|
||||
import { fedimintSubject } from '../../../lib/review-subject';
|
||||
import { breadcrumbNode, mintServiceNode } from '../../../lib/schema';
|
||||
import { clampWords, cleanMintDescription, isIndexableMint, nameCollisions, seoName } from '../../../lib/seo';
|
||||
import { bannerHtml } from '../../../lib/mint-state';
|
||||
import { localModuleName, localNetworkName, moduleRowsHtml, otherModules } from '../../../lib/fedimint-ui';
|
||||
import { iconGradient, initials, 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 federation, in every locale. One API call per federation, shared across the three. */
|
||||
export async function getStaticPaths() {
|
||||
const federations = await fetchFedimints();
|
||||
const details = await Promise.all(
|
||||
federations.map(async (federation, index) => {
|
||||
try {
|
||||
return { detail: await fetchFedimint(federation.host), rank: index + 1 };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const collisions = nameCollisions(federations);
|
||||
|
||||
/*
|
||||
* The same related ring the mint pages use: position i links to i+1, i+2 and i+3,
|
||||
* wrapping. Deterministic, never itself, and drawn only from federations whose own
|
||||
* pages are indexable — a page nobody should index needs no crawl paths out of it.
|
||||
*/
|
||||
const ring = federations.filter(isIndexableMint);
|
||||
const ringIndex = new Map(ring.map((federation, index) => [federation.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: FedimintDetail; rank: number } => d !== null)
|
||||
.map(({ detail, rank }) => ({
|
||||
params: { id: detail.host },
|
||||
props: {
|
||||
federation: detail,
|
||||
rank,
|
||||
related: related(detail.host),
|
||||
seoName: seoName(detail.name, detail.host, collisions),
|
||||
},
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
const { federation, rank, related, seoName: titleName, locale } = Astro.props;
|
||||
|
||||
const t = useI18n(locale);
|
||||
const f = formatters(t);
|
||||
|
||||
const federationId = federation.federation_id ?? '';
|
||||
const shortId = truncateMiddle(federationId, 10, 6);
|
||||
const name = federation.name ?? t('fedimint.unnamed');
|
||||
const icon = iconUrl(federation.icon);
|
||||
const status = statusLabel(federation.status, t);
|
||||
/** Nothing has ever confirmed this federation is running. */
|
||||
const announced = federation.status === 'announced';
|
||||
|
||||
const modules = federation.modules ?? [];
|
||||
const extraModules = otherModules(modules);
|
||||
const inviteCodes = federation.invite_codes ?? [];
|
||||
const network = federation.network;
|
||||
|
||||
/* verdict: rating. Identical to the mint page's, because a rating means the same thing. */
|
||||
const dist = federation.rating_distribution;
|
||||
const distMax = Math.max(1, ...Object.values(dist));
|
||||
const ratedCount = Object.values(dist).reduce((a, b) => a + b, 0);
|
||||
const unrated = federation.review_count - ratedCount;
|
||||
|
||||
/*
|
||||
* The banner.
|
||||
*
|
||||
* `getMintWarnings` takes the Fedimint branch on `type`, so this page can only ever
|
||||
* receive one of two: a real check reported the guardians down, or the announcement is
|
||||
* a month old and nothing has confirmed it. There is no code path from here to a
|
||||
* capability warning, which is what keeps this page from inventing one.
|
||||
*/
|
||||
const warning = getMintWarnings(federation, warningOptions(f))[0] ?? null;
|
||||
|
||||
/*
|
||||
* SEO. Same templates as a mint page, with the Cashu-only clause swapped out: a
|
||||
* federation's third fact is its modules and network, never limits or a version.
|
||||
*/
|
||||
const hasRating = federation.review_count > 0 && federation.rating_avg !== null;
|
||||
const offlineDays =
|
||||
federation.status === 'offline' && federation.last_online !== null
|
||||
? Math.max(0, Math.floor(Date.now() / 1000 / 86400 - federation.last_online / 86400))
|
||||
: null;
|
||||
|
||||
const title =
|
||||
offlineDays !== null
|
||||
? t('seo.mint.titleOffline', { name: titleName, n: offlineDays })
|
||||
: hasRating
|
||||
? t('seo.mint.title', {
|
||||
name: titleName,
|
||||
rating: f.decimal(federation.rating_avg!),
|
||||
n: federation.review_count,
|
||||
})
|
||||
: t('seo.fedimint.titleNoReviews', { name: titleName });
|
||||
|
||||
/*
|
||||
* The opening sentence is the announcement's own description when it carried one,
|
||||
* cleaned for a meta tag but never translated. The fallback identifies the federation
|
||||
* by its short id, which is the one string that unambiguously names it.
|
||||
*/
|
||||
const lead =
|
||||
cleanMintDescription(federation.description) ??
|
||||
t('seo.desc.fallback', { name, domain: shortId });
|
||||
|
||||
const ratingPart = hasRating
|
||||
? t('seo.desc.rated', { rating: f.decimal(federation.rating_avg!), n: federation.review_count })
|
||||
: t('seo.desc.noReviews');
|
||||
|
||||
// A warning's own meta sentence ("Announced 28 May 2024, never confirmed") replaces
|
||||
// the plain status: the state is the one thing a search result must not soften.
|
||||
const moduleList = modules.slice(0, 5).join(', ');
|
||||
const statusPart = warning
|
||||
? `${warning.meta}.`
|
||||
: moduleList
|
||||
? network
|
||||
? t('seo.desc.fedModulesNetwork', { status, modules: moduleList, network: localNetworkName(network, f) })
|
||||
: t('seo.desc.fedModules', { status, modules: moduleList })
|
||||
: t('seo.desc.status', { status });
|
||||
|
||||
const description = clampWords(`${lead} ${ratingPart} ${statusPart}`, 158);
|
||||
|
||||
/* The card: one English render shared by all locales, hashed filename per state. */
|
||||
const ogImage = ogImageFor(federation.host);
|
||||
const imageAlt = hasRating
|
||||
? t('seo.mint.ogAlt', { name: titleName, rating: f.decimal(federation.rating_avg!), n: federation.review_count })
|
||||
: t('seo.mint.ogAltNoReviews', { name: titleName });
|
||||
|
||||
/*
|
||||
* Indexability, on exactly the same rule as a mint page: never confirmed and never
|
||||
* reviewed is a page with nothing on it that is not also on the next one. It stays
|
||||
* listed, linked and reviewable; it just carries `noindex, follow` until a check
|
||||
* reaches it or somebody writes something.
|
||||
*/
|
||||
const indexable = isIndexableMint(federation);
|
||||
|
||||
const crumbs = [
|
||||
{ name: t('nav.home'), url: new URL(localePath('/', locale), Astro.site).href },
|
||||
{ name: t('fedimint.crumb'), url: new URL(localePath('/fedimints', locale), Astro.site).href },
|
||||
{ name: shortId },
|
||||
];
|
||||
|
||||
const canonical = new URL(localePath(`/fedimint/${federation.host}`, locale), Astro.site).href;
|
||||
|
||||
const schema = [
|
||||
breadcrumbNode(canonical, crumbs),
|
||||
mintServiceNode({
|
||||
canonical,
|
||||
name: titleName,
|
||||
description,
|
||||
/*
|
||||
* The invite code stands in for the service's address, because it is the address:
|
||||
* it is the string a wallet is given to reach this federation. A federation with no
|
||||
* code would not be listed at all, so there is always one.
|
||||
*/
|
||||
mintUrl: inviteCodes[0] ?? federation.url,
|
||||
icon: icon ? new URL(icon, Astro.site).href : null,
|
||||
rating:
|
||||
ratedCount > 0 && federation.rating_avg !== null
|
||||
? {
|
||||
value: Number(federation.rating_avg.toFixed(1)),
|
||||
ratingCount: ratedCount,
|
||||
reviewCount: federation.review_count,
|
||||
}
|
||||
: null,
|
||||
}),
|
||||
];
|
||||
---
|
||||
|
||||
<Base
|
||||
title={t('fedimint.title', { name: titleName })}
|
||||
description={description}
|
||||
current="fedimints"
|
||||
noindex={!indexable}
|
||||
schema={schema}
|
||||
>
|
||||
<div class="page">
|
||||
<nav class="crumbs" aria-label={t('fedimint.breadcrumb')}>
|
||||
<a href={localePath('/', locale)}>{t('nav.home')}</a>
|
||||
<span class="sep" aria-hidden="true">/</span>
|
||||
<a href={localePath('/fedimints', locale)}>{t('fedimint.crumb')}</a>
|
||||
<span class="sep" aria-hidden="true">/</span>
|
||||
<span class="here mono">{shortId}</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', federation.host)}`}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
class="mint-icon"
|
||||
style={`background:${iconGradient(federationId)};view-transition-name:${transitionName('icon', federation.host)}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{initials(name)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
<div class="mint-id">
|
||||
<div class="mint-name-row">
|
||||
<h1 style={`view-transition-name:${transitionName('name', federation.host)}`}>{name}</h1>
|
||||
<span class:list={['status-chip', federation.status]} data-live-status>
|
||||
<span class="status-dot"></span><span data-live-status-label>{status}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
The federation id, in full behind a copy button. It is the identifier a review
|
||||
points at and the one thing that unambiguously names this federation, so it
|
||||
takes the line the mint page gives to a URL.
|
||||
*/}
|
||||
<div class="mint-domain">
|
||||
<span class="fed-label">{t('fedimint.idLabel')}</span>
|
||||
<CopyableId
|
||||
value={federationId}
|
||||
label={shortId}
|
||||
kind={t('fedimint.kind.federationId')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mint-tags">
|
||||
<span class="tag">{t('mint.rankByScore', { rank: String(rank) })}</span>
|
||||
{
|
||||
/*
|
||||
The network badge. Anything that is not mainnet is called out rather than
|
||||
stated flatly: a signet federation's ecash is worth nothing, and someone
|
||||
scanning the page for the joinable one needs that to be loud.
|
||||
*/
|
||||
network && (
|
||||
<span class:list={['tag', { testnet: isTestNetwork(network) }]}>
|
||||
{localNetworkName(network, f)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
{modules.length > 0 && <span class="tag">{t('fedimint.moduleCount', { n: modules.length })}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="head-actions">
|
||||
{
|
||||
inviteCodes[0] && (
|
||||
<button class="btn" data-copy={inviteCodes[0]} 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('fedimint.copyInvite')}
|
||||
</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>
|
||||
|
||||
<Fragment set:html={bannerHtml(warning)} />
|
||||
|
||||
{/*
|
||||
Three cells, not four. Rating and activity mean exactly what they mean on a mint
|
||||
page; the third says who last confirmed this federation and when, which is the
|
||||
question the software and limits cells cannot be asked here.
|
||||
*/}
|
||||
<section class="verdict verdict-3" aria-labelledby="fed-summary">
|
||||
<h2 class="sr-only" id="fed-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">
|
||||
{
|
||||
federation.rating_avg === null ? (
|
||||
<>
|
||||
<div class="v-big">{t('time.none')}</div>
|
||||
<div class="v-sub">{t('mint.rating.none')}</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div class="v-big" data-count-up={federation.rating_avg.toFixed(1)} data-count-decimals="1">
|
||||
{f.decimal(federation.rating_avg)}
|
||||
</div>
|
||||
<div class="stars" aria-hidden="true">{starString(federation.rating_avg)}</div>
|
||||
<div class="v-sub">
|
||||
{t('mint.rating.reviews', { n: federation.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(federation.last_review_at)}</div>
|
||||
<div class="v-sub">
|
||||
{federation.last_review_at ? t('mint.activity.since') : t('mint.activity.noReviews')}
|
||||
{federation.reviews_90d > 0 && (
|
||||
<><br />{t('mint.activity.recent', { n: federation.reviews_90d })}</>
|
||||
)}
|
||||
</div>
|
||||
{/*
|
||||
The sparkline draws real check results and nothing else. A federation nothing
|
||||
checks has no probe rows at all, so it renders empty rather than flat — and an
|
||||
empty strip is the honest picture of no data.
|
||||
*/}
|
||||
<Sparkline probes={federation.probes_recent} uptime={federation.uptime_30d} />
|
||||
</div>
|
||||
|
||||
<div class="v-cell" data-reveal data-reveal-delay="120">
|
||||
<div class="v-label">{t('fedimint.confirmed.label')}</div>
|
||||
<div class="v-mono" data-live-checked>
|
||||
{
|
||||
/*
|
||||
The one cell on this page that could most easily lie, so it is the most
|
||||
carefully worded. "Announced" gets no date at all beyond the announcement's
|
||||
own, and says outright that nothing has checked. A confirmed federation
|
||||
names who confirmed it, because this site did not: it read fedimint.observer.
|
||||
*/
|
||||
announced ? (
|
||||
<>
|
||||
<b>{t('fedimint.confirmed.never')}</b><br />
|
||||
<span>
|
||||
{federation.announced_at
|
||||
? t('fedimint.announcedOn', { when: f.relative(federation.announced_at) })
|
||||
: t('fedimint.announcedUnknown')}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<b>{f.relative(federation.status === 'offline' ? federation.last_online : federation.last_probe)}</b><br />
|
||||
<span>
|
||||
{federation.status_source
|
||||
? t('fedimint.confirmed.by', { source: federation.status_source })
|
||||
: t('fedimint.confirmed.unattributed')}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="cols">
|
||||
<Reviews
|
||||
subject={fedimintSubject(federation)}
|
||||
reviewCount={federation.review_count}
|
||||
distribution={federation.rating_distribution}
|
||||
name={name}
|
||||
/>
|
||||
|
||||
<aside class="side">
|
||||
{inviteCodes.length > 0 && <InviteCodes codes={inviteCodes} name={name} />}
|
||||
|
||||
<div class="panel" data-reveal>
|
||||
<h2 class="side-title">{t('fedimint.modules.title')}</h2>
|
||||
{
|
||||
modules.length === 0 ? (
|
||||
<p class="side-empty">{t('fedimint.modules.none')}</p>
|
||||
) : (
|
||||
<>
|
||||
<div class="nut-hi" data-live-modules set:html={moduleRowsHtml(modules, f)} />
|
||||
{extraModules.length > 0 && (
|
||||
<div class="nut-rest">
|
||||
{extraModules.map((module) => (
|
||||
<span class="nut-chip" title={localModuleName(module, f)}>{module}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p class="nut-more">{t('fedimint.modules.note')}</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="panel" data-reveal>
|
||||
<h2 class="side-title">{t('fedimint.details.title')}</h2>
|
||||
<div class="kv">
|
||||
<div class="kv-row">
|
||||
<span class="kv-k">{t('fedimint.details.federationId')}</span>
|
||||
<CopyableId
|
||||
class="kv-v"
|
||||
value={federationId}
|
||||
label={truncateMiddle(federationId, 10, 6)}
|
||||
kind={t('fedimint.kind.federationId')}
|
||||
/>
|
||||
</div>
|
||||
{
|
||||
network && (
|
||||
<div class="kv-row">
|
||||
<span class="kv-k">{t('fedimint.details.network')}</span>
|
||||
<span class="kv-v">{localNetworkName(network, f)}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
federation.announcer_pubkey && (
|
||||
<div class="kv-row">
|
||||
<span class="kv-k">{t('fedimint.details.announcedBy')}</span>
|
||||
<CopyableId
|
||||
class="kv-v"
|
||||
value={federation.announcer_pubkey}
|
||||
label={truncateMiddle(federation.announcer_pubkey, 10, 6)}
|
||||
kind={t('mint.kind.mintPubkey')}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
federation.announced_at && (
|
||||
<div class="kv-row">
|
||||
<span class="kv-k">{t('fedimint.details.announced')}</span>
|
||||
<span class="kv-v">{f.date(federation.announced_at)}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<div class="kv-row">
|
||||
<span class="kv-k">{t('mint.details.firstSeen')}</span>
|
||||
<span class="kv-v">{f.monthYear(federation.first_seen)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{
|
||||
related.length > 0 && (
|
||||
<section class="more-mints" aria-labelledby="more-federations">
|
||||
<div class="sec-head" data-reveal>
|
||||
<h2 class="sec-title" id="more-federations">{t('fedimint.more.title')}</h2>
|
||||
<a class="sec-link" href={localePath('/fedimints', locale)}>{t('fedimint.more.all')}</a>
|
||||
</div>
|
||||
<div class="mint-grid">
|
||||
{related.map((item) => <MintCard mint={item} />)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
<p class="mint-disclaimer">
|
||||
{t('fedimint.disclaimer')}
|
||||
<a href={localePath('/disclaimer', locale)}>{t('mint.disclaimerLink')}</a>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<MintLive host={federation.host} />
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.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-size: 14px; color: var(--muted);
|
||||
}
|
||||
.fed-label { font-size: 12px; color: var(--faint); letter-spacing: .04em; text-transform: uppercase; }
|
||||
.mint-domain :global(.cid) { font-family: var(--mono); font-size: 13.5px; color: var(--muted); }
|
||||
.mint-domain :global(.cid:hover) { color: var(--amber); }
|
||||
|
||||
.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; }
|
||||
/* Not mainnet: the ecash is play money and the badge has to read that way. */
|
||||
.tag.testnet { color: var(--amber); border-color: var(--amber-dim); background: rgba(240, 169, 59, .07); font-weight: 600; }
|
||||
|
||||
.head-actions { margin-left: auto; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||
|
||||
.verdict {
|
||||
display: grid;
|
||||
border: 1px solid var(--line); border-radius: var(--radius);
|
||||
background: var(--card); overflow: hidden; margin-bottom: 28px;
|
||||
}
|
||||
/* Three cells rather than the mint page's four, with the rating keeping the width it
|
||||
needs for its distribution bars. */
|
||||
.verdict-3 { grid-template-columns: 1.35fr 1fr 1fr; }
|
||||
.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); }
|
||||
|
||||
.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; line-height: 1.55; }
|
||||
|
||||
.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-3 { 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-3 { 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%; }
|
||||
.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();
|
||||
|
||||
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,408 @@
|
||||
---
|
||||
/**
|
||||
* The federation index. /mints' twin, and deliberately built from the same parts.
|
||||
*
|
||||
* Same container, same card, same search, same result line, same leave-and-return
|
||||
* animation. Two differences, both of them because a federation is a different thing
|
||||
* rather than because this page is:
|
||||
*
|
||||
* - There is no "recently seen online" sort. It reads as a claim about uptime, and
|
||||
* for the federations nothing checks there is no such reading. Ordering a column
|
||||
* that is null for a third of the rows is not a sort, it is a shuffle.
|
||||
* - The count line names the announced ones separately, because "12 federations, 6
|
||||
* online" invites the reader to assume the other 6 are down.
|
||||
*/
|
||||
import Base from '../../layouts/Base.astro';
|
||||
import MintCard from '../../components/MintCard.astro';
|
||||
import { fetchFedimints } 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: `/fedimints`, `/es/fedimints`, `/nl/fedimints`. */
|
||||
export const getStaticPaths = localePaths;
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
}
|
||||
const { locale } = Astro.props;
|
||||
|
||||
const t = useI18n(locale);
|
||||
|
||||
const federations = await fetchFedimints();
|
||||
|
||||
/*
|
||||
* No capability fetch, unlike /mints.
|
||||
*
|
||||
* That page asks the API for each mint's detail to read NUT-04 and NUT-05 and draw a
|
||||
* "melt only" chip. A federation publishes no such switches, so there is nothing to
|
||||
* fetch and no chip to draw — and inventing one from its module list would be exactly
|
||||
* the fake capability warning this whole feature refuses to ship.
|
||||
*/
|
||||
const online = federations.filter((f) => f.status === 'online').length;
|
||||
const announced = federations.filter((f) => f.status === 'announced').length;
|
||||
|
||||
const description = t('fedimints.description', {
|
||||
total: federations.length,
|
||||
online,
|
||||
announced,
|
||||
});
|
||||
|
||||
const canonical = new URL(localePath('/fedimints', locale), Astro.site).href;
|
||||
const schema = [
|
||||
itemListNode({
|
||||
canonical,
|
||||
fragment: 'fedimints',
|
||||
name: t('fedimints.heading'),
|
||||
items: federations.filter(isIndexableMint).map((federation) => ({
|
||||
name: federation.name ?? federation.host,
|
||||
url: new URL(localePath(`/fedimint/${federation.host}`, locale), Astro.site).href,
|
||||
})),
|
||||
}),
|
||||
];
|
||||
---
|
||||
|
||||
<Base title={t('fedimints.title')} description={description} current="fedimints" schema={schema}>
|
||||
<div class="page">
|
||||
<div class="index-head">
|
||||
<h1>{t('fedimints.heading')}</h1>
|
||||
<p class="lede">{t('fedimints.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('fedimints.search.label')}</label>
|
||||
<input
|
||||
id="mint-search"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
placeholder={t('fedimints.search.placeholder')}
|
||||
data-search-input
|
||||
/>
|
||||
<kbd aria-hidden="true">/</kbd>
|
||||
</form>
|
||||
|
||||
<div class="control-group">
|
||||
<label class="sr-only" for="mint-sort">{t('fedimints.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>
|
||||
</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>
|
||||
|
||||
<button class="toggle-chip" type="button" data-hide-offline aria-pressed="false">
|
||||
<span class="toggle-dot" aria-hidden="true"></span>
|
||||
{t('fedimints.hideOffline')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="result-count" data-result-count aria-live="polite">
|
||||
{t('fedimints.showing', { total: federations.length, online, announced })}
|
||||
</p>
|
||||
|
||||
{/*
|
||||
What "announced" means, said once at the top of the list rather than on every
|
||||
card that carries it. The status is unusual enough that a reader meeting it for
|
||||
the first time deserves the sentence.
|
||||
*/}
|
||||
{
|
||||
announced > 0 && (
|
||||
<p class="index-note">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10" /><path d="M12 16v-4M12 8h.01" />
|
||||
</svg>
|
||||
<span>{t('fedimints.announcedNote', { n: announced })}</span>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
<div class="mint-grid" data-mint-grid>
|
||||
{federations.map((federation, i) => <MintCard mint={federation} rank={i + 1} />)}
|
||||
</div>
|
||||
|
||||
<p class="no-results" data-no-results hidden>
|
||||
{t('fedimints.noResults')}
|
||||
<button class="link-btn" type="button" data-clear-search>{t('mints.clearSearch')}</button>
|
||||
</p>
|
||||
|
||||
{
|
||||
federations.length === 0 && (
|
||||
<p class="no-results">{t('fedimints.emptyIndex')}</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;
|
||||
}
|
||||
|
||||
.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: 8px; font-family: var(--mono); }
|
||||
|
||||
.index-note {
|
||||
display: flex; gap: 9px; align-items: flex-start; max-width: 720px;
|
||||
font-size: 13px; color: var(--muted); line-height: 1.55; margin-bottom: 16px;
|
||||
}
|
||||
.index-note svg { flex-shrink: 0; margin-top: 2px; color: var(--faint); }
|
||||
|
||||
.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) {
|
||||
.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();
|
||||
|
||||
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);
|
||||
|
||||
/*
|
||||
* The same four sorts /mints offers, minus "recently seen online".
|
||||
*
|
||||
* That one reads `data-last-online`, which is 0 for every federation nothing
|
||||
* checks — a third of this list — so the sort would silently bury them all under
|
||||
* a heading that implies they went offline. There is no honest way to order rows
|
||||
* by a measurement that was never taken.
|
||||
*/
|
||||
const SORTS: Record<string, (a: HTMLElement, b: HTMLElement) => number> = {
|
||||
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'),
|
||||
};
|
||||
|
||||
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;
|
||||
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 federation id should find its federation, whether or not the reader
|
||||
// trimmed it to the sixteen characters the card shows.
|
||||
const needle = query.replace(/^https?:\/\//, '').replace(/\/+$/, '');
|
||||
const hide = hideOffline!.getAttribute('aria-pressed') === 'true';
|
||||
|
||||
let visible = 0;
|
||||
let onlineCount = 0;
|
||||
let announcedCount = 0;
|
||||
|
||||
for (const card of cards) {
|
||||
const domain = card.dataset['domain'] ?? '';
|
||||
const matches =
|
||||
needle === '' ||
|
||||
(card.dataset['name'] ?? '').includes(needle) ||
|
||||
domain.includes(needle) ||
|
||||
// The card shows the first 16 characters of the id; a pasted full id starts
|
||||
// with them, so match that way round too.
|
||||
(domain.length > 0 && needle.startsWith(domain));
|
||||
const isOffline = card.dataset['status'] === 'offline';
|
||||
const keep = matches && !(hide && isOffline);
|
||||
|
||||
show(card, keep);
|
||||
if (keep) {
|
||||
visible++;
|
||||
if (card.dataset['status'] === 'online') onlineCount++;
|
||||
else if (card.dataset['status'] === 'announced') announcedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
const sorted = [...cards].sort(SORTS[sortSelect!.value] ?? SORTS['score']!);
|
||||
for (const card of sorted) grid!.append(card);
|
||||
|
||||
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) {
|
||||
swapText(
|
||||
countEl,
|
||||
visible === cards.length
|
||||
? t('fedimints.showing', {
|
||||
total: visible,
|
||||
online: onlineCount,
|
||||
announced: announcedCount,
|
||||
})
|
||||
: t('fedimints.showingFiltered', {
|
||||
shown: visible,
|
||||
total: cards.length,
|
||||
online: onlineCount,
|
||||
announced: announcedCount,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (noResults) noResults.hidden = visible > 0;
|
||||
}
|
||||
|
||||
searchInput.addEventListener('input', apply);
|
||||
sortSelect.addEventListener('change', () => {
|
||||
apply();
|
||||
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 strip: /fedimints?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>
|
||||
@@ -3,8 +3,9 @@ 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 { fetchFedimints, fetchHealth, fetchMint, fetchMints, fetchStats } from '../../lib/api';
|
||||
import { fetchLatestReviews } from '../../lib/nostr-build';
|
||||
import { feedTargets, targetPath } from '../../lib/feed-resolve';
|
||||
import { displayDomain, iconGradient, sentiment, shortNpub } from '../../lib/format';
|
||||
import { profileName, readCapabilities } from '@cashumints/shared';
|
||||
import { localePath, useI18n, type Locale } from '../../i18n';
|
||||
@@ -22,15 +23,27 @@ const { locale } = Astro.props;
|
||||
const t = useI18n(locale);
|
||||
const f = formatters(t);
|
||||
|
||||
const [mints, stats, health] = await Promise.all([fetchMints(), fetchStats(), fetchHealth()]);
|
||||
const [mints, federations, stats, health] = await Promise.all([
|
||||
fetchMints(),
|
||||
fetchFedimints(),
|
||||
fetchStats(),
|
||||
fetchHealth(),
|
||||
]);
|
||||
|
||||
const top = mints.slice(0, 6);
|
||||
const hostByUrl = new Map(mints.map((m) => [m.url, m.host]));
|
||||
|
||||
/*
|
||||
* The reviews strip resolves against both ecosystems, because its heading is "latest
|
||||
* reviews" and not "latest mint reviews". Federations rarely reach it in practice — the
|
||||
* junk filter wants a written body and nearly every 38173 review is a bare `[5/5]` —
|
||||
* but when one does, it is genuinely among the latest and it links to its own page.
|
||||
*/
|
||||
const reviewTargets = feedTargets(mints, federations);
|
||||
|
||||
/*
|
||||
* 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.
|
||||
* only need the target list above — so they wait together rather than one behind the
|
||||
* other.
|
||||
*
|
||||
* The carousel holds a screenful and a bit: enough that scrolling it is worth doing,
|
||||
* few enough that they are all real, recent, written reviews. Anything the relays
|
||||
@@ -39,7 +52,7 @@ const hostByUrl = new Map(mints.map((m) => [m.url, m.host]));
|
||||
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),
|
||||
fetchLatestReviews(reviewTargets, 10),
|
||||
]);
|
||||
|
||||
const sentiments = details.map((d) => (d ? sentiment(d.rating_distribution) : undefined));
|
||||
@@ -150,6 +163,49 @@ const signedBody = t('home.why.signed.body', {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/*
|
||||
The Fedimint strip.
|
||||
|
||||
One row, under the mint grid and above the reviews, because that is what it is: a
|
||||
signpost to a second, smaller index, not a second front page. The home page stays
|
||||
Cashu-first — the hero, the search, the six ranked cards and the ticker are all
|
||||
unchanged — and this is the one place that says the other ecosystem exists.
|
||||
|
||||
The counts come from `/api/stats`, the same source as the ticker, and they name the
|
||||
announced federations separately for the same reason the index does: "12 federations,
|
||||
6 online" invites the reader to assume the other six are down.
|
||||
*/}
|
||||
{
|
||||
stats.fedimint_total > 0 && (
|
||||
<div class="section eco-strip-wrap">
|
||||
<a class="eco-strip" href={localePath('/fedimints', locale)} data-reveal>
|
||||
<span class="eco-mark" aria-hidden="true">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<circle cx="12" cy="6" r="2.4" /><circle cx="5.5" cy="17" r="2.4" /><circle cx="18.5" cy="17" r="2.4" />
|
||||
<path d="M10.6 8 6.9 14.9M13.4 8l3.7 6.9M8 17.4h8" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="eco-body">
|
||||
<span class="eco-title">{t('home.fedimint.title')}</span>
|
||||
<span class="eco-sub">
|
||||
{t('home.fedimint.body', {
|
||||
total: stats.fedimint_total,
|
||||
online: stats.fedimint_online,
|
||||
announced: stats.fedimint_announced,
|
||||
})}
|
||||
</span>
|
||||
</span>
|
||||
<span class="eco-go">
|
||||
{t('home.fedimint.link')}
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true">
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<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>
|
||||
@@ -189,7 +245,11 @@ const signedBody = t('home.why.signed.body', {
|
||||
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>
|
||||
<a
|
||||
class="rev-card"
|
||||
href={localePath(targetPath({ type: review.mint_type, host: review.mint_host }), locale)}
|
||||
data-reveal
|
||||
>
|
||||
<span class="rc-top">
|
||||
<span class="rc-avatar-wrap">
|
||||
<span class="rc-avatar" style={`background:${iconGradient(review.pubkey)}`} aria-hidden="true">
|
||||
@@ -360,6 +420,47 @@ const signedBody = t('home.why.signed.body', {
|
||||
}
|
||||
.qf:hover { color: var(--text); border-color: #38342A; background: var(--card); }
|
||||
|
||||
/* ---------- fedimint strip ---------- */
|
||||
/*
|
||||
* One card wide enough to be a section and short enough not to be one. It sits in a
|
||||
* `.section` for the page's gutter and max width, but carries no heading of its own:
|
||||
* a `<h2>` here would give a signpost the same weight as the mint grid above it.
|
||||
*/
|
||||
.eco-strip-wrap { padding-top: 0; }
|
||||
.eco-strip {
|
||||
display: flex; align-items: center; gap: 16px;
|
||||
border: 1px solid var(--line); border-radius: var(--radius);
|
||||
background: var(--card); padding: 18px 20px;
|
||||
transition: border-color var(--dur-quick), background var(--dur-quick), transform var(--dur-quick);
|
||||
}
|
||||
.eco-strip:hover { border-color: #38342A; background: var(--card2); }
|
||||
.eco-strip:hover .eco-go { color: var(--amber); }
|
||||
.eco-strip:hover .eco-go svg { transform: translateX(2px); }
|
||||
|
||||
.eco-mark {
|
||||
width: 40px; height: 40px; border-radius: 11px; flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: var(--amber); background: rgba(240, 169, 59, .09);
|
||||
border: 1px solid rgba(240, 169, 59, .22);
|
||||
}
|
||||
.eco-body { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
|
||||
.eco-title { font-family: var(--display); font-size: 16.5px; font-weight: 600; letter-spacing: -.01em; color: var(--text); }
|
||||
.eco-sub { font-size: 13.5px; color: var(--muted); }
|
||||
.eco-go {
|
||||
margin-left: auto; flex-shrink: 0;
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
font-size: 13.5px; font-weight: 500; color: var(--muted);
|
||||
transition: color var(--dur-quick);
|
||||
}
|
||||
.eco-go svg { transition: transform var(--dur-quick); }
|
||||
|
||||
@media (max-width: 560px) {
|
||||
/* The arrow label drops; the whole row is the link and the chevron still points. */
|
||||
.eco-strip { gap: 13px; }
|
||||
.eco-go { font-size: 0; gap: 0; }
|
||||
.eco-go svg { font-size: 15px; }
|
||||
}
|
||||
|
||||
/* ---------- latest reviews carousel ---------- */
|
||||
/*
|
||||
* A scroll-snap track, not a JavaScript slider: native touch and trackpad swiping,
|
||||
|
||||
@@ -7,12 +7,14 @@ 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,
|
||||
getMintWarnings, HIGHLIGHT_NUTS, KNOWN_NUT_COUNT, parseLimits, readCapabilities,
|
||||
type MintDetail, type MintListItem,
|
||||
} from '@cashumints/shared';
|
||||
import { fetchMint, fetchMints, iconUrl } from '../../../lib/api';
|
||||
import { ogImageFor } from '../../../lib/og';
|
||||
import { mintSubject } from '../../../lib/review-subject';
|
||||
import { breadcrumbNode, mintServiceNode } from '../../../lib/schema';
|
||||
import { clampWords, isIndexableMint, nameCollisions, seoName } from '../../../lib/seo';
|
||||
import { clampWords, cleanMintDescription, isIndexableMint, nameCollisions, seoName } from '../../../lib/seo';
|
||||
import { bannerHtml, limitsHtml, localNutName, nutRowsHtml } from '../../../lib/mint-state';
|
||||
import {
|
||||
displayDomain, iconGradient, initials, shortNpub, transitionName, truncateMiddle,
|
||||
@@ -149,45 +151,79 @@ const isBeta = /\b(beta|alpha|experimental|research project|test|do not|don't st
|
||||
/*
|
||||
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.
|
||||
Title first. Three shapes, most urgent wins: a mint that is offline leads with that
|
||||
(a search result should warn before anyone clicks), a rated mint leads with its
|
||||
rating, and a mint nobody has reviewed says plainly what the page is.
|
||||
*/
|
||||
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');
|
||||
const hasRating = mint.review_count > 0 && mint.rating_avg !== null;
|
||||
const offlineDays =
|
||||
offline && mint.last_online !== null
|
||||
? Math.max(0, Math.floor(Date.now() / 1000 / 86400 - mint.last_online / 86400))
|
||||
: null;
|
||||
|
||||
// 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');
|
||||
const title =
|
||||
offlineDays !== null
|
||||
? t('seo.mint.titleOffline', { name: titleName, n: offlineDays })
|
||||
: hasRating
|
||||
? t('seo.mint.title', {
|
||||
name: titleName,
|
||||
rating: f.decimal(mint.rating_avg!),
|
||||
n: mint.review_count,
|
||||
})
|
||||
: t('seo.mint.titleNoReviews', { name: titleName });
|
||||
|
||||
/*
|
||||
* 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.
|
||||
* The description: the mint's own words, then the rating, then the state.
|
||||
*
|
||||
* The opening sentence is the mint's own description when it published one — cleaned
|
||||
* for a meta tag (first sentence, shouting normalised, 70 chars on a word boundary)
|
||||
* but never translated: it is their copy about their service. Only when there is none
|
||||
* does the site write that sentence, and that one is localized.
|
||||
*
|
||||
* The state goes in too, so a search result for a melt-only or long-dead mint carries
|
||||
* the warning before anyone clicks through: a warning's own `meta` sentence replaces
|
||||
* the plain status, and the limits are only quoted for a mint that can actually take
|
||||
* a deposit.
|
||||
*/
|
||||
const lead =
|
||||
mint.description?.trim() ||
|
||||
(titleName === domain
|
||||
? t('mint.description.fallbackDomain', { domain })
|
||||
: t('mint.description.fallback', { name, domain }));
|
||||
cleanMintDescription(mint.description) ??
|
||||
t('seo.desc.fallback', { name: titleName === domain ? name : titleName, 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);
|
||||
const ratingPart = hasRating
|
||||
? t('seo.desc.rated', { rating: f.decimal(mint.rating_avg!), n: mint.review_count })
|
||||
: t('seo.desc.noReviews');
|
||||
|
||||
const limits = parseLimits(info?.nuts, 4);
|
||||
const limitsText =
|
||||
!warning && limits && !caps.mintDisabled
|
||||
? limits.min !== null && limits.max !== null
|
||||
? t('mint.limits.range', { min: f.compact(limits.min), max: f.compact(limits.max), unit: limits.unit })
|
||||
: limits.max !== null
|
||||
? t('mint.limits.upTo', { max: f.compact(limits.max), unit: limits.unit })
|
||||
: limits.min !== null
|
||||
? t('mint.limits.from', { min: f.compact(limits.min), unit: limits.unit })
|
||||
: null
|
||||
: null;
|
||||
|
||||
const statusPart = warning
|
||||
? `${warning.meta}.`
|
||||
: limitsText
|
||||
? t('seo.desc.statusLimits', { status, limits: limitsText })
|
||||
: t('seo.desc.status', { status });
|
||||
|
||||
// Clamped on a word boundary to the length search results actually show; anything
|
||||
// longer is cut mid-thought by every consumer that matters.
|
||||
const description = clampWords(`${lead} ${ratingPart} ${statusPart}`, 158);
|
||||
|
||||
/*
|
||||
* The card. One English render shared by every locale (the manifest maps host to a
|
||||
* content-hashed filename), while title, description and alt text translate. A mint
|
||||
* whose card failed to render falls back to the sitewide default via Base.
|
||||
*/
|
||||
const ogImage = ogImageFor(mint.host);
|
||||
const imageAlt = hasRating
|
||||
? t('seo.mint.ogAlt', { name: titleName, rating: f.decimal(mint.rating_avg!), n: mint.review_count })
|
||||
: t('seo.mint.ogAltNoReviews', { name: titleName });
|
||||
|
||||
/*
|
||||
* Indexability.
|
||||
@@ -234,12 +270,14 @@ const schema = [
|
||||
---
|
||||
|
||||
<Base
|
||||
title={t('mint.title', { name: titleName })}
|
||||
title={title}
|
||||
description={description}
|
||||
current="mints"
|
||||
mintCount={undefined}
|
||||
noindex={!indexable}
|
||||
schema={schema}
|
||||
image={ogImage ?? undefined}
|
||||
imageAlt={imageAlt}
|
||||
>
|
||||
<div class="page">
|
||||
{/*
|
||||
@@ -438,7 +476,12 @@ const schema = [
|
||||
</section>
|
||||
|
||||
<div class="cols">
|
||||
<Reviews mint={mint} />
|
||||
<Reviews
|
||||
subject={mintSubject(mint)}
|
||||
reviewCount={mint.review_count}
|
||||
distribution={mint.rating_distribution}
|
||||
name={mint.name ?? mint.host}
|
||||
/>
|
||||
|
||||
<aside class="side">
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
/**
|
||||
* The global review feed: every mint's reviews in one list, newest first.
|
||||
* The global review feed: every review of every mint and federation, 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
|
||||
@@ -9,11 +9,12 @@
|
||||
* review looks and behaves identically wherever you meet it.
|
||||
*/
|
||||
import Base from '../../layouts/Base.astro';
|
||||
import { fetchMints, fetchStats } from '../../lib/api';
|
||||
import { fetchFedimints, fetchMints, fetchStats } from '../../lib/api';
|
||||
import { fetchReviewFeed } from '../../lib/nostr-build';
|
||||
import { feedTargets, targetPath } from '../../lib/feed-resolve';
|
||||
import { reviewHtml } from '../../lib/review-cards';
|
||||
import type { LoadedReview } from '../../lib/reviews-client';
|
||||
import { displayDomain } from '../../lib/format';
|
||||
import { ECOSYSTEMS } from '@cashumints/shared';
|
||||
import { localePath, useI18n, type Locale } from '../../i18n';
|
||||
import { formatters } from '../../i18n/format';
|
||||
import { localePaths } from '../../i18n/paths';
|
||||
@@ -32,19 +33,21 @@ 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);
|
||||
const [mints, federations, stats] = await Promise.all([
|
||||
fetchMints(),
|
||||
fetchFedimints(),
|
||||
fetchStats(),
|
||||
]);
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Everything the feed can resolve a review to, both ecosystems in one list.
|
||||
*
|
||||
* Handed to the island as JSON so the browser can resolve a review back to a listing
|
||||
* without asking the API again. Name and domain are what the card's link shows; `ref`
|
||||
* is the federation id a 38173 review points at, read straight out of the row key.
|
||||
*/
|
||||
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 feedMints = feedTargets(mints, federations);
|
||||
const feed = await fetchReviewFeed(feedMints, PRERENDER_COUNT);
|
||||
|
||||
const mintByHost = new Map(feedMints.map((mint) => [mint.host, mint]));
|
||||
|
||||
@@ -54,13 +57,26 @@ const mintsPayload = JSON.stringify(feedMints).replace(/</g, '\\u003c');
|
||||
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,
|
||||
mint: mint ? { href: localePath(targetPath(mint), locale), label: mint.domain } : null,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The ecosystem filter's options: "All", then one per ecosystem, in `ECOSYSTEMS` order.
|
||||
*
|
||||
* Built from that list rather than written out, so a third ecosystem is a line in
|
||||
* `shared/src/nostr.ts` and a catalog key — not an edit to this page, its island, or
|
||||
* the counting below.
|
||||
*/
|
||||
const ecosystemFilters = [
|
||||
{ key: 'all', label: t('feed.ecosystem.all') },
|
||||
...ECOSYSTEMS.map((type) => ({ key: type, label: t(`feed.ecosystem.${type}`) })),
|
||||
];
|
||||
|
||||
const description = t('feed.description', {
|
||||
reviews: stats.reviews_total,
|
||||
mints: stats.mints_total,
|
||||
mints: stats.cashu_total,
|
||||
federations: stats.fedimint_total,
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -111,12 +127,37 @@ const footNote = t('feed.foot', {
|
||||
</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.
|
||||
Which ecosystem, as its own group of pills rather than as more options inside
|
||||
the rating group: they answer different questions and pressing one must not
|
||||
clear the other. Same control, same styling, one row down on a narrow screen.
|
||||
*/}
|
||||
<div class="filters eco-filters" role="group" aria-label={t('feed.ecosystem.group')}>
|
||||
{
|
||||
ecosystemFilters.map((option, index) => (
|
||||
<button
|
||||
class="filter"
|
||||
type="button"
|
||||
data-eco={option.key}
|
||||
aria-pressed={index === 0 ? 'true' : 'false'}
|
||||
>
|
||||
{/*
|
||||
No count on "All": the rating group beside it already shows the same
|
||||
total, and two adjacent pills both reading "All · 180" read as one
|
||||
control that has been rendered twice.
|
||||
*/}
|
||||
{option.label}{option.key !== 'all' && <span data-eco-count={option.key}></span>}
|
||||
</button>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
{/*
|
||||
A text input backed by a datalist rather than a select: seventy options is too
|
||||
many to scroll, and typing "azz" should be enough. Any substring of a name, a
|
||||
domain or a federation id narrows the list, so a partial guess still works.
|
||||
*/}
|
||||
<div class="mint-filter">
|
||||
<label class="sr-only" for="feed-mint">{t('feed.filterByMint')}</label>
|
||||
<label class="sr-only" for="feed-mint">{t('feed.filterBySubject')}</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>
|
||||
@@ -125,10 +166,10 @@ const footNote = t('feed.foot', {
|
||||
type="text"
|
||||
list="feed-mint-options"
|
||||
autocomplete="off"
|
||||
placeholder={t('feed.anyMint')}
|
||||
placeholder={t('feed.anySubject')}
|
||||
data-mint-filter
|
||||
/>
|
||||
<button class="mint-clear" type="button" data-mint-clear hidden aria-label={t('feed.clearMint')}>
|
||||
<button class="mint-clear" type="button" data-mint-clear hidden aria-label={t('feed.clearSubject')}>
|
||||
<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>
|
||||
@@ -176,6 +217,16 @@ const footNote = t('feed.foot', {
|
||||
|
||||
.controls { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin-bottom: 14px; }
|
||||
.controls .filters { margin-left: 0; }
|
||||
/*
|
||||
* A hairline before the ecosystem group, so two runs of identical pills read as two
|
||||
* questions rather than as one long row of seven. It disappears when the row wraps,
|
||||
* where the line break already does that job.
|
||||
*/
|
||||
.eco-filters { position: relative; padding-left: 12px; }
|
||||
.eco-filters::before {
|
||||
content: ''; position: absolute; left: 0; top: 50%; transform: translateY(-50%);
|
||||
width: 1px; height: 20px; background: var(--line);
|
||||
}
|
||||
|
||||
.mint-filter {
|
||||
display: flex; align-items: center; gap: 10px; margin-left: auto;
|
||||
@@ -210,6 +261,11 @@ const footNote = t('feed.foot', {
|
||||
/* 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: 900px) {
|
||||
/* Wrapped onto its own line: the divider would be pointing at nothing. */
|
||||
.eco-filters { padding-left: 0; }
|
||||
.eco-filters::before { display: none; }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.mint-filter { margin-left: 0; width: 100%; }
|
||||
}
|
||||
@@ -222,6 +278,7 @@ const footNote = t('feed.foot', {
|
||||
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 { targetPath } from '../../lib/feed-resolve';
|
||||
import { applyProfiles, isWritten, reviewHtml, summaryHtml, type MintRef } from '../../lib/review-cards';
|
||||
import { loadProfiles } from '../../lib/reviews-client';
|
||||
|
||||
@@ -243,6 +300,7 @@ const footNote = t('feed.foot', {
|
||||
|
||||
const list = document.querySelector<HTMLElement>('[data-feed-items]');
|
||||
const controls = document.querySelector<HTMLElement>('[data-feed-controls]');
|
||||
const ecoGroup = document.querySelector<HTMLElement>('.eco-filters');
|
||||
const countEl = document.querySelector<HTMLElement>('[data-feed-count]');
|
||||
const pagerWrap = document.querySelector<HTMLElement>('[data-feed-pager]');
|
||||
const mintInput = document.querySelector<HTMLInputElement>('[data-mint-filter]');
|
||||
@@ -257,6 +315,14 @@ const footNote = t('feed.foot', {
|
||||
|
||||
let all: FeedReview[] = [];
|
||||
let rating: 'all' | '5' | 'critical' = 'all';
|
||||
/**
|
||||
* Which ecosystem is showing: `all`, or a `mint_type` value.
|
||||
*
|
||||
* A string rather than a union, because the options are generated from
|
||||
* `ECOSYSTEMS` in the frontmatter and this has to accept whatever is in that list.
|
||||
* A third ecosystem needs no change here at all.
|
||||
*/
|
||||
let ecosystem = 'all';
|
||||
let mintQuery = '';
|
||||
let page = 1;
|
||||
/** Day summaries the reader has opened, so a filter or page change does not shut them. */
|
||||
@@ -269,6 +335,7 @@ const footNote = t('feed.foot', {
|
||||
function filtered(): FeedReview[] {
|
||||
const needle = mintQuery.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/+$/, '');
|
||||
return all.filter((review) => {
|
||||
if (ecosystem !== 'all' && review.mint_type !== ecosystem) return false;
|
||||
if (rating === '5' && review.rating !== 5) return false;
|
||||
if (rating === 'critical' && (review.rating === null || review.rating > 2)) return false;
|
||||
if (needle === '') return true;
|
||||
@@ -291,7 +358,9 @@ const footNote = t('feed.foot', {
|
||||
const pathLocale = localeFromPath(window.location.pathname);
|
||||
|
||||
const mintRef = (review: FeedReview): MintRef => ({
|
||||
href: localePath(`/mint/${review.mint_host}`, pathLocale),
|
||||
// One route per ecosystem, decided by the same helper the build uses, so a card
|
||||
// cannot link to /mint/fed-… after the island redraws it.
|
||||
href: localePath(targetPath({ type: review.mint_type, host: review.mint_host }), pathLocale),
|
||||
label: review.mint_domain,
|
||||
});
|
||||
|
||||
@@ -422,13 +491,30 @@ const footNote = t('feed.foot', {
|
||||
}
|
||||
|
||||
function updateCounts(): void {
|
||||
/*
|
||||
* The rating counts are counts within the chosen ecosystem, not across the whole
|
||||
* feed. Pressing "Fedimint" and still reading "5 stars · 380" would be counting
|
||||
* reviews the list is not going to show.
|
||||
*/
|
||||
const inEcosystem = all.filter((r) => ecosystem === 'all' || r.mint_type === ecosystem);
|
||||
|
||||
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);
|
||||
set('all', inEcosystem.length);
|
||||
set('5', inEcosystem.filter((r) => r.rating === 5).length);
|
||||
set('critical', inEcosystem.filter((r) => r.rating !== null && r.rating <= 2).length);
|
||||
|
||||
// The ecosystem counts are the other way round: each one counts its whole
|
||||
// ecosystem, so the group says how much there is of each rather than reacting to
|
||||
// the rating filter beside it.
|
||||
for (const button of ecoGroup?.querySelectorAll<HTMLElement>('[data-eco]') ?? []) {
|
||||
const key = button.dataset['eco'] ?? 'all';
|
||||
// "All" has no count slot, by design: see the comment on the markup.
|
||||
const slot = button.querySelector('[data-eco-count]');
|
||||
if (slot) slot.textContent = ` · ${f.number(all.filter((r) => r.mint_type === key).length)}`;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- loading ---------- */
|
||||
@@ -470,7 +556,22 @@ const footNote = t('feed.foot', {
|
||||
/* ---------- events ---------- */
|
||||
|
||||
controls.addEventListener('click', (event) => {
|
||||
const button = (event.target as HTMLElement).closest<HTMLButtonElement>('[data-filter]');
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
const ecoButton = target.closest<HTMLButtonElement>('[data-eco]');
|
||||
if (ecoButton) {
|
||||
ecosystem = ecoButton.dataset['eco'] ?? 'all';
|
||||
page = 1;
|
||||
for (const other of ecoGroup?.querySelectorAll('[data-eco]') ?? []) {
|
||||
other.setAttribute('aria-pressed', String(other === ecoButton));
|
||||
}
|
||||
render(true);
|
||||
// The rating counts are scoped to the ecosystem, so they move with it.
|
||||
updateCounts();
|
||||
return;
|
||||
}
|
||||
|
||||
const button = target.closest<HTMLButtonElement>('[data-filter]');
|
||||
if (!button) return;
|
||||
rating = button.dataset['filter'] as typeof rating;
|
||||
page = 1;
|
||||
@@ -531,14 +632,30 @@ const footNote = t('feed.foot', {
|
||||
void load();
|
||||
});
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
|
||||
// A mint can be preselected from a link: /reviews?mint=mint.example.com
|
||||
const preset = new URLSearchParams(window.location.search).get('mint');
|
||||
const preset = params.get('mint');
|
||||
if (preset) {
|
||||
mintInput.value = preset;
|
||||
mintQuery = preset;
|
||||
if (mintClear) mintClear.hidden = false;
|
||||
}
|
||||
|
||||
// And an ecosystem: /reviews?type=fedimint, which is what the federation pages and
|
||||
// the home page strip link to. An unknown value is ignored rather than showing an
|
||||
// empty feed for a filter with no button pressed to explain it.
|
||||
const presetType = params.get('type');
|
||||
const ecoButton = presetType
|
||||
? ecoGroup?.querySelector<HTMLButtonElement>(`[data-eco="${CSS.escape(presetType)}"]`)
|
||||
: null;
|
||||
if (ecoButton) {
|
||||
ecosystem = presetType!;
|
||||
for (const other of ecoGroup?.querySelectorAll('[data-eco]') ?? []) {
|
||||
other.setAttribute('aria-pressed', String(other === ecoButton));
|
||||
}
|
||||
}
|
||||
|
||||
void load();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* /404 is deliberately absent, in every locale.
|
||||
*/
|
||||
import type { APIRoute } from 'astro';
|
||||
import { fetchMints } from '../lib/api';
|
||||
import { fetchFedimints, fetchMints } from '../lib/api';
|
||||
import { alternates } from '../i18n/routing';
|
||||
import { isIndexableMint } from '../lib/seo';
|
||||
import { DEFAULT_LOCALE, LOCALE_CODES } from '../i18n/config';
|
||||
@@ -36,6 +36,12 @@ interface Entry {
|
||||
const STATIC_PAGES: Array<Omit<Entry, 'lastmod'>> = [
|
||||
{ path: '/', changefreq: 'daily', priority: 1.0 },
|
||||
{ path: '/mints', changefreq: 'daily', priority: 0.9 },
|
||||
/*
|
||||
* Below /mints, above /reviews. There are an order of magnitude fewer federations
|
||||
* than mints and this site's centre of gravity is still Cashu, so the index that
|
||||
* changes most and matters most keeps the higher number.
|
||||
*/
|
||||
{ path: '/fedimints', changefreq: 'daily', priority: 0.8 },
|
||||
{ path: '/reviews', changefreq: 'daily', priority: 0.7 },
|
||||
{ path: '/wallets', changefreq: 'monthly', priority: 0.5 },
|
||||
{ path: '/about', changefreq: 'monthly', priority: 0.5 },
|
||||
@@ -53,6 +59,9 @@ const STATIC_PAGES: Array<Omit<Entry, 'lastmod'>> = [
|
||||
*/
|
||||
const MINT_PRIORITY = 0.8;
|
||||
|
||||
/** One notch below a mint page, for the same reason /fedimints sits below /mints. */
|
||||
const FEDIMINT_PRIORITY = 0.7;
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -74,7 +83,7 @@ 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 [mints, federations] = await Promise.all([fetchMints(), fetchFedimints()]);
|
||||
|
||||
const entries: Entry[] = [
|
||||
...STATIC_PAGES.map((page) => ({ ...page, lastmod: builtAt })),
|
||||
@@ -95,6 +104,23 @@ export const GET: APIRoute = async ({ site }) => {
|
||||
changefreq: 'weekly' as const,
|
||||
priority: MINT_PRIORITY,
|
||||
})),
|
||||
/*
|
||||
* Federations, on exactly the same rule: only the ones whose own pages are
|
||||
* indexable, which `isIndexableMint` decides for both and `check-hreflang.mjs`
|
||||
* verifies against the built HTML.
|
||||
*
|
||||
* `lastmod` differs in one way worth noting. A federation that no check has ever
|
||||
* confirmed has no `last_online`, so its date comes from its newest review or, with
|
||||
* neither, from the build — and such a page is unindexable anyway, so that fallback
|
||||
* is unreachable in practice rather than merely unlikely.
|
||||
*/
|
||||
...federations.filter(isIndexableMint).map((federation) => ({
|
||||
path: `/fedimint/${federation.host}`,
|
||||
lastmod:
|
||||
Math.max(federation.last_review_at ?? 0, federation.last_online ?? 0) || builtAt,
|
||||
changefreq: 'weekly' as const,
|
||||
priority: FEDIMINT_PRIORITY,
|
||||
})),
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -365,6 +365,19 @@ main { flex: 1; }
|
||||
.status-chip.degraded .status-dot { background: var(--amber); box-shadow: 0 0 8px rgba(240, 169, 59, .7); }
|
||||
.status-chip.unknown { color: var(--muted); background: rgba(154, 146, 126, .07); border-color: rgba(154, 146, 126, .22); }
|
||||
.status-chip.unknown .status-dot { background: var(--muted); box-shadow: none; }
|
||||
/*
|
||||
* "Announced": a federation on Nostr that no check has ever confirmed.
|
||||
*
|
||||
* Grey and, uniquely, hollow. Every other state here is a filled dot with a glow,
|
||||
* because every other state is something this site found out; a ring with nothing in it
|
||||
* is the one that says the opposite. It must never be mistaken for the green one at a
|
||||
* glance, which is the whole reason it is not simply a paler mint.
|
||||
*/
|
||||
.status-chip.announced { color: var(--muted); background: rgba(154, 146, 126, .07); border-color: rgba(154, 146, 126, .22); }
|
||||
.status-chip.announced .status-dot {
|
||||
background: none; box-shadow: none;
|
||||
border: 2px solid var(--faint); box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ---------- panels ---------- */
|
||||
.panel { border: 1px solid var(--line); border-radius: var(--radius); background: var(--card); }
|
||||
@@ -461,6 +474,8 @@ main { flex: 1; }
|
||||
.mc-dot.offline { background: var(--red); }
|
||||
.mc-dot.degraded { background: var(--amber); }
|
||||
.mc-dot.unknown { background: var(--muted); }
|
||||
/* Hollow, for the same reason the chip's dot is. See `.status-chip.announced`. */
|
||||
.mc-dot.announced { background: none; border: 2px solid var(--faint); box-sizing: border-box; width: 7px; height: 7px; }
|
||||
/* Two words next to the status, in the same colours the banner uses. Cards never
|
||||
explain, they only flag: the mint page has the sentence. */
|
||||
.mc-chip {
|
||||
@@ -536,13 +551,24 @@ footer { border-top: 1px solid var(--line-soft); margin-top: 70px; }
|
||||
/*
|
||||
* Below this the header stops being one row.
|
||||
*
|
||||
* Four nav items, the brand, the language control and the account control do not fit
|
||||
* across a phone, and a header that does not fit is a page that scrolls sideways: the
|
||||
* body inherits the overflow and every section under it drifts. So the nav takes a row
|
||||
* of its own and the two controls stay up top with the brand, which is also the order
|
||||
* they are wanted in: what page am I on, then who am I and what language is this.
|
||||
* Five nav items, the brand, the language control and the account control do not fit
|
||||
* across anything narrower than a laptop, and a header that does not fit is a page that
|
||||
* scrolls sideways: the body inherits the overflow and every section under it drifts. So
|
||||
* the nav takes a row of its own and the two controls stay up top with the brand, which
|
||||
* is also the order they are wanted in: what page am I on, then who am I and what
|
||||
* language is this.
|
||||
*
|
||||
* 1040px, not the 620px this used to be. The old figure was already two pixels short of
|
||||
* fitting four English items at 768, and five need 400px against the 303px the first row
|
||||
* has left there — no amount of tightening the padding closes a gap that size.
|
||||
*
|
||||
* The number is set by Spanish, which is the longest of the three ("Mints Cashu /
|
||||
* Fedimints / Reseñas / Monederos / Acerca de") and needs 1008px to fit on one row
|
||||
* against English's 906. Picking the English figure is how this shipped broken once
|
||||
* already: every locale renders the same stylesheet, so the breakpoint has to clear the
|
||||
* widest of them, with room for the next language to be longer still.
|
||||
*/
|
||||
@media (max-width: 620px) {
|
||||
@media (max-width: 1040px) {
|
||||
.topbar-inner { flex-wrap: wrap; row-gap: 10px; }
|
||||
/* Pushes the pair to the right of the first row, where they were before it wrapped. */
|
||||
.topbar-inner > .lang-menu { margin-left: auto; }
|
||||
@@ -553,6 +579,15 @@ footer { border-top: 1px solid var(--line-soft); margin-top: 70px; }
|
||||
/* The row's own edges, so the first and last item line up with the brand and the
|
||||
account control above them rather than sitting inset by their padding. */
|
||||
margin-inline: -9px;
|
||||
/*
|
||||
* Wraps onto a second line rather than overflowing.
|
||||
*
|
||||
* Four items fitted one narrow row; "Cashu mints" and "Fedimints" together do not,
|
||||
* and a nav that overflows here is the sideways scroll this whole block exists to
|
||||
* prevent. Two tidy rows beat one row with a cut-off "About".
|
||||
*/
|
||||
flex-wrap: wrap;
|
||||
row-gap: 2px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
|
||||
Reference in New Issue
Block a user