Add lnurl list/detail routes, OG fixtures, i18n strings, and the write/ index client flows so the site surfaces the new mint type end to end. Co-authored-by: Cursor <cursoragent@cursor.com>
427 lines
16 KiB
Plaintext
427 lines
16 KiB
Plaintext
---
|
|
/**
|
|
* 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 ReviewByUrl from '../../components/ReviewByUrl.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">
|
|
<div class="index-head-text">
|
|
<h1>{t('fedimints.heading')}</h1>
|
|
<p class="lede">{t('fedimints.lede')}</p>
|
|
</div>
|
|
{/*
|
|
The way in for something this site has never seen. Every other route to writing
|
|
a review starts from a page that already exists, which is exactly the case a
|
|
reader with a brand new mint does not have.
|
|
*/}
|
|
<button class="btn primary index-head-action" type="button" data-review-url-open="fedimint">
|
|
{t('reviews.byUrl.open')}
|
|
</button>
|
|
</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>
|
|
|
|
{/* Two steps in one dialog: identify the mint, then the ordinary review form. */}
|
|
<ReviewByUrl type="fedimint" />
|
|
</Base>
|
|
|
|
<style>
|
|
/* Heading and lede on the left, the one primary action on the right, wrapping to
|
|
its own line before the lede has to give up any width. */
|
|
.index-head { margin-bottom: 24px; display: flex; align-items: flex-start; gap: 16px; flex-wrap: wrap; }
|
|
.index-head-text { flex: 1 1 320px; }
|
|
.index-head-action { flex-shrink: 0; }
|
|
.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>
|