Files
CashuMints.space/web/src/components/MintCard.astro
T
michilis 6f17b572b1 Expand ecash explorer capabilities
Add Fedimint discovery, dual SQLite/Postgres storage, richer review handling, and generated social imagery.
2026-08-21 02:10:48 +02:00

206 lines
7.4 KiB
Plaintext

---
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';
import { formatters, starString } from '../i18n/format';
import { chipStrings, statusLabel, warningOptions } from '../i18n/mint';
import { pageLocale } from '../i18n/paths';
interface Props {
mint: MintListItem;
rank?: number;
/** Sentiment split, when the caller has the distribution (home page top mints). */
sentiment?: { pos: number; neg: number };
/**
* Entrance delay in ms, for a grid of known size that arrives as one gesture (the
* home page's six). The long index at /mints leaves this unset and the reveal
* utility staggers whatever scrolls into view together instead, so a card in the
* fortieth row is not waiting on the position of the thirty-ninth.
*/
revealDelay?: number;
/**
* NUT-04 / NUT-05 switches, read from the mint's cached info by whoever fetched it.
* The list payload does not carry them, so a page that wants the chip reads them
* from the detail it already has. Left off, the card simply has no chip.
*/
capabilities?: MintCapabilities | null;
}
const { mint, rank, sentiment, revealDelay, capabilities } = Astro.props;
const { locale } = pageLocale(Astro);
const t = useI18n(locale);
const f = formatters(t);
/*
* 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
glance at the status cannot reveal earn a chip: a mint that takes deposits it
cannot pay back out, or one that has stopped moving sats in either direction. The
mint page carries the explanation.
*/
const chip = capabilities
? mintChip(
getMintWarnings(
{ status: mint.status, last_online: mint.last_online, capabilities },
warningOptions(f),
),
chipStrings(t),
)
: null;
const status = statusLabel(mint.status, t);
/*
* The mint's own name is never translated. It comes from the mint's /v1/info, and a
* mint called "Bolverker" is called that in every language. Same for the domain, which
* is an address.
*/
// Fall back to the rating split when no distribution was passed: a mint with a 4.6
// average is roughly 92% positive, which is what the bar is communicating.
const bar =
sentiment ??
(mint.rating_avg === null
? { pos: 0, neg: 0 }
: {
pos: Math.round(((mint.rating_avg - 1) / 4) * 100),
neg: 100 - Math.round(((mint.rating_avg - 1) / 4) * 100),
});
---
{/*
The icon and the name carry the view transition names their twins in the mint page
header carry, so clicking a card morphs them into place instead of cross-fading the
whole page over them.
They are held in data attributes rather than applied with `transition:name`, and
scripts/reveal.ts moves the one card being clicked onto the real property. A named
element is captured and animated separately, so naming all 58 cards on /mints would
hand the browser 116 textures to composite on every navigation, for 114 of which the
answer is "fade it out with everything else".
*/}
<a
class:list={['mint-card', { 'is-offline': offline }]}
href={href}
data-reveal
data-reveal-delay={revealDelay}
data-mint-card
data-name={name.toLowerCase()}
data-domain={domain.toLowerCase()}
data-status={mint.status}
data-score={mint.score}
data-rating={mint.rating_avg ?? 0}
data-reviews={mint.review_count}
data-last-review={mint.last_review_at ?? 0}
data-last-online={mint.last_online ?? 0}
>
<div class="mc-top">
{
icon ? (
<img
class="mc-icon"
src={icon}
alt=""
width="42"
height="42"
loading="lazy"
decoding="async"
data-vt-icon={transitionName('icon', mint.host)}
/>
) : (
<span
class="mc-icon"
style={`background:${iconGradient(domain)}`}
aria-hidden="true"
data-vt-icon={transitionName('icon', mint.host)}
>
{initials(name)}
</span>
)
}
<span class="mc-id">
<span class="mc-name" data-vt-name={transitionName('name', mint.host)}>{name}</span>
<span class:list={['mc-domain', { mono: fedimint }]}>{domain}</span>
</span>
{rank !== undefined && <span class:list={['mc-rank', { gold: rank === 1 }]}>#{rank}</span>}
</div>
<div class="mc-stats">
{
mint.rating_avg === null ? (
<span class="mc-none">{t('card.noRatings')}</span>
) : (
<span class="mc-rating">
{/* One decimal, with this locale's decimal separator: 4.6 and 4,6. */}
<span class="mc-score">{f.decimal(mint.rating_avg)}</span>
<span class="mc-stars" aria-hidden="true">{starString(mint.rating_avg)}</span>
</span>
)
}
<span class="mc-reviews">{t('card.reviews', { n: mint.review_count })}</span>
</div>
{/*
Both segments live in one wrapper so the bar grows from the card's left edge as a
single object (scaleX on the wrapper) rather than as two bars starting in two
places. The widths themselves never animate.
*/}
<div class="mc-bar" aria-hidden="true">
<span class="mc-bar-fill">
{bar.pos > 0 && <span class="pos" style={`width:${bar.pos}%`} />}
{bar.neg > 0 && <span class="neg" style={`width:${bar.neg}%`} />}
</span>
</div>
<div class="mc-foot">
<span class:list={['mc-dot', mint.status]}></span>
<span class="mc-status">{status}</span>
{chip && <span class:list={['mc-chip', chip.severity]}>{chip.label}</span>}
<span class="last">
{
/*
"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>
</a>