@@ -0,0 +1,398 @@
|
||||
---
|
||||
import Base from '../../layouts/Base.astro';
|
||||
import MintCard from '../../components/MintCard.astro';
|
||||
import { readCapabilities } from '@cashumints/shared';
|
||||
import { fetchMint, fetchMints } 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: `/mints`, `/es/mints`, `/nl/mints`. */
|
||||
export const getStaticPaths = localePaths;
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
}
|
||||
const { locale } = Astro.props;
|
||||
|
||||
// `t` formats the numbers inside its own strings, so no separate formatter is needed.
|
||||
const t = useI18n(locale);
|
||||
|
||||
const mints = await fetchMints();
|
||||
|
||||
/*
|
||||
The list payload carries no NUT information, so the "melt only" and "frozen" chips
|
||||
need each mint's cached info. One detail fetch per mint, at build time, against the
|
||||
API on the same machine: the mint pages already do exactly this in getStaticPaths.
|
||||
A mint whose detail cannot be read simply gets no chip.
|
||||
*/
|
||||
const capabilities = await Promise.all(
|
||||
mints.map((mint) =>
|
||||
fetchMint(mint.host).then((detail) => readCapabilities(detail.info?.nuts)).catch(() => null),
|
||||
),
|
||||
);
|
||||
const online = mints.filter((m) => m.status === 'online').length;
|
||||
const offline = mints.filter((m) => m.status === 'offline').length;
|
||||
|
||||
const description = t('mints.description', { total: mints.length, online, offline });
|
||||
|
||||
/*
|
||||
* The index, as a list a crawler can read in order.
|
||||
*
|
||||
* Only the mints whose own pages are indexable: listing a page here while telling a
|
||||
* crawler not to index it is a contradiction. The order is the server-rendered one,
|
||||
* which is the score order; the sort control rewrites the DOM but not this markup,
|
||||
* so `ItemListOrderDescending` describes what shipped.
|
||||
*/
|
||||
const canonical = new URL(localePath('/mints', locale), Astro.site).href;
|
||||
const schema = [
|
||||
itemListNode({
|
||||
canonical,
|
||||
fragment: 'mints',
|
||||
name: t('mints.heading'),
|
||||
items: mints.filter(isIndexableMint).map((mint) => ({
|
||||
name: mint.name ?? mint.host,
|
||||
url: new URL(localePath(`/mint/${mint.host}`, locale), Astro.site).href,
|
||||
})),
|
||||
}),
|
||||
];
|
||||
---
|
||||
|
||||
<Base title={t('mints.title')} description={description} current="mints" schema={schema}>
|
||||
{/*
|
||||
The same .page container the home page and the mint pages use, so the heading,
|
||||
the controls, the count line and the grid all share one left edge and one width.
|
||||
*/}
|
||||
<div class="page">
|
||||
<div class="index-head">
|
||||
<h1>{t('mints.heading')}</h1>
|
||||
<p class="lede">{t('mints.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('mints.search.label')}</label>
|
||||
<input
|
||||
id="mint-search"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
placeholder={t('mints.search.placeholder')}
|
||||
data-search-input
|
||||
/>
|
||||
<kbd aria-hidden="true">/</kbd>
|
||||
</form>
|
||||
|
||||
<div class="control-group">
|
||||
<label class="sr-only" for="mint-sort">{t('mints.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>
|
||||
<option value="seen">{t('mints.sort.seen')}</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>
|
||||
|
||||
{/*
|
||||
A pressed pill rather than a native checkbox: it is the same control the
|
||||
review filters use, and it is a real button, so it keeps the focus ring.
|
||||
*/}
|
||||
<button class="toggle-chip" type="button" data-hide-offline aria-pressed="false">
|
||||
<span class="toggle-dot" aria-hidden="true"></span>
|
||||
{t('mints.hideOffline')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="result-count" data-result-count aria-live="polite">
|
||||
{t('mints.showing', { total: mints.length, online, offline })}
|
||||
</p>
|
||||
|
||||
<div class="mint-grid" data-mint-grid>
|
||||
{mints.map((mint, i) => <MintCard mint={mint} rank={i + 1} capabilities={capabilities[i]} />)}
|
||||
</div>
|
||||
|
||||
<p class="no-results" data-no-results hidden>
|
||||
{t('mints.noResults')}
|
||||
<button class="link-btn" type="button" data-clear-search>{t('mints.clearSearch')}</button>
|
||||
</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;
|
||||
}
|
||||
|
||||
/* Search grows; sort and toggle sit together at the right and wrap as a pair. */
|
||||
.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: 16px; font-family: var(--mono); }
|
||||
|
||||
/*
|
||||
* A card leaving the results fades and settles back a touch, 120ms, then it is
|
||||
* hidden and the grid reflows around it. Positions are not animated: the browser's
|
||||
* own reflow is instant and honest, and 58 cards sliding to new places would be a
|
||||
* light show rather than a search result.
|
||||
*/
|
||||
.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) {
|
||||
/* Sort and toggle take their own row, still sharing the grid's left edge. */
|
||||
.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();
|
||||
|
||||
// Re-read on every arrival, so the count line under the filters follows the page.
|
||||
// `t` formats its own numbers, so the grouping separator follows the locale too.
|
||||
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);
|
||||
|
||||
const SORTS: Record<string, (a: HTMLElement, b: HTMLElement) => number> = {
|
||||
// Default keeps the API's rule: online first, then score. Every other sort
|
||||
// ranks offline mints on their merits, so a dead mint with 40 good reviews
|
||||
// still shows up under "Most reviewed".
|
||||
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'),
|
||||
seen: (a, b) => num(b, 'lastOnline') - num(a, 'lastOnline'),
|
||||
};
|
||||
|
||||
/**
|
||||
* One pending fade per card, at most.
|
||||
*
|
||||
* Typing into the search box calls `apply` on every keystroke, so a card can be
|
||||
* told to leave and then to come back before its fade has finished. Cancelling
|
||||
* the timer is what keeps those from stacking: the card either goes out once, or
|
||||
* never starts.
|
||||
*/
|
||||
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;
|
||||
// Back in the results after being filtered out: a plain fade, no rise. It
|
||||
// was here before, it is not new.
|
||||
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 mint URL should find the mint, so match on the bare domain too.
|
||||
const needle = query.replace(/^https?:\/\//, '').replace(/\/+$/, '');
|
||||
const hide = hideOffline!.getAttribute('aria-pressed') === 'true';
|
||||
|
||||
let visible = 0;
|
||||
let onlineCount = 0;
|
||||
let offlineCount = 0;
|
||||
|
||||
for (const card of cards) {
|
||||
const matches =
|
||||
needle === '' ||
|
||||
(card.dataset['name'] ?? '').includes(needle) ||
|
||||
(card.dataset['domain'] ?? '').includes(needle);
|
||||
const isOffline = card.dataset['status'] === 'offline';
|
||||
const keep = matches && !(hide && isOffline);
|
||||
|
||||
show(card, keep);
|
||||
if (keep) {
|
||||
visible++;
|
||||
if (isOffline) offlineCount++;
|
||||
else if (card.dataset['status'] === 'online') onlineCount++;
|
||||
}
|
||||
}
|
||||
|
||||
const sorted = [...cards].sort(SORTS[sortSelect!.value] ?? SORTS['score']!);
|
||||
for (const card of sorted) grid!.append(card);
|
||||
|
||||
// A rank chip is a claim about position in the default order. It means nothing
|
||||
// in a rating sort and nothing in a filtered list, so it is hidden in both.
|
||||
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) {
|
||||
// The count is the answer to what the reader just typed, so it cross-fades
|
||||
// rather than flicking from one number to another.
|
||||
swapText(
|
||||
countEl,
|
||||
visible === cards.length
|
||||
? t('mints.showing', { total: visible, online: onlineCount, offline: offlineCount })
|
||||
: t('mints.showingFiltered', {
|
||||
shown: visible,
|
||||
total: cards.length,
|
||||
online: onlineCount,
|
||||
offline: offlineCount,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (noResults) noResults.hidden = visible > 0;
|
||||
}
|
||||
|
||||
searchInput.addEventListener('input', apply);
|
||||
sortSelect.addEventListener('change', () => {
|
||||
apply();
|
||||
// A new order is a new list: it arrives the way the grid arrived, once, and
|
||||
// the stagger is capped so the last card is never more than 400ms behind.
|
||||
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 quick filters: /mints?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