Expand ecash explorer capabilities
Add Fedimint discovery, dual SQLite/Postgres storage, richer review handling, and generated social imagery.
This commit is contained in:
@@ -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,
|
||||
})),
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user