Add Fedimint discovery, dual SQLite/Postgres storage, richer review handling, and generated social imagery.
206 lines
7.4 KiB
Plaintext
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>
|