Expand ecash explorer capabilities

Add Fedimint discovery, dual SQLite/Postgres storage, richer review handling, and generated social imagery.
This commit is contained in:
michilis
2026-08-21 02:10:48 +02:00
parent aa1771ea20
commit 6f17b572b1
80 changed files with 7580 additions and 704 deletions
+408
View File
@@ -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>