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,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>
|
||||
Reference in New Issue
Block a user