first commit

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
michilis
2026-08-20 22:41:25 +02:00
co-authored by Cursor
commit aa1771ea20
136 changed files with 27069 additions and 0 deletions
+177
View File
@@ -0,0 +1,177 @@
---
import { 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);
const domain = displayDomain(mint.url);
const name = mint.name ?? domain.split('/')[0] ?? domain;
const icon = iconUrl(mint.icon);
const offline = mint.status === 'offline';
/*
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={localePath(`/mint/${mint.host}`, locale)}
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="mc-domain">{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">
{
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>