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
+69 -23
View File
@@ -5,8 +5,13 @@
* Prerendered with the counts the API already knows, so the panel occupies its final
* space and shows real numbers with JavaScript off. Review bodies live on Nostr, so
* the list itself hydrates from relays on load.
*
* Used by both a mint page and a federation page, unchanged. What is being reviewed
* arrives as a `ReviewSubject` — which `k`, which `d`, which `u` — and nothing below
* this line asks which ecosystem produced it. See `lib/review-subject.ts`.
*/
import type { MintDetail } from '@cashumints/shared';
import type { RatingDistribution } from '@cashumints/shared';
import type { ReviewSubject } from '../lib/review-subject';
import { renderBones } from 'boneyard-js';
import reviewsBones from '../bones/reviews-panel.bones.json';
import { BONE_COLOR } from '../lib/skeleton';
@@ -15,15 +20,33 @@ import { formatters } from '../i18n/format';
import { pageLocale } from '../i18n/paths';
interface Props {
mint: MintDetail;
/** What is being reviewed: the tags a review carries and the filters that find them. */
subject: ReviewSubject;
/** The counts the API already knows, prerendered so the panel is honest without JS. */
reviewCount: number;
distribution: RatingDistribution;
/** Named in the write dialog's heading. The thing's own name, never translated. */
name: string;
}
const { mint } = Astro.props;
const { subject, reviewCount, distribution, name } = Astro.props;
const { locale } = pageLocale(Astro);
const t = useI18n(locale);
const f = formatters(t);
const dist = mint.rating_distribution;
const dist = distribution;
/*
* The placeholder asks about the thing being reviewed, which is the one sentence in
* this panel that cannot be ecosystem-neutral without going vague: "how did minting and
* melting work out" means nothing about a federation, and "how did it go" means nothing
* about anything. One key per ecosystem, falling back to the Cashu wording for a type
* whose own has not been written yet.
*/
const placeholderKey = `reviews.dialog.bodyPlaceholder.${subject.type}`;
const bodyPlaceholder = t.has(placeholderKey)
? t(placeholderKey)
: t('reviews.dialog.bodyPlaceholder.cashu');
const critical = dist['1'] + dist['2'];
const fiveStar = dist['5'];
@@ -44,7 +67,7 @@ const boneBlocks = Object.entries(reviewsBones.breakpoints)
// Bones stand in for reviews the API has already counted. With none counted there is
// nothing to wait for, and the empty state below is the truthful thing to prerender.
const showBoneBlocks = mint.review_count > 0 && boneBlocks.length > 0;
const showBoneBlocks = reviewCount > 0 && boneBlocks.length > 0;
/**
* Without JavaScript the relays are never queried, so the bones would sit there for
@@ -75,9 +98,7 @@ function escapeText(value: string): string {
aria-labelledby="reviews-title"
data-reveal
data-reviews-panel
data-mint-url={mint.url}
data-mint-pubkey={mint.pubkey ?? ''}
data-mint-name={mint.name ?? mint.host}
data-subject={JSON.stringify(subject)}
>
<div class="panel-head">
<h2 class="panel-title" id="reviews-title">{t('reviews.title')}</h2>
@@ -85,7 +106,7 @@ function escapeText(value: string): string {
<span class="nip-badge">NIP-87</span>
<div class="filters" role="group" aria-label={t('reviews.filter.group')}>
<button class="filter" type="button" data-filter="all" aria-pressed="true">
{t('reviews.filter.all')} · <span data-count="all">{f.number(mint.review_count)}</span>
{t('reviews.filter.all')} · <span data-count="all">{f.number(reviewCount)}</span>
</button>
<button class="filter" type="button" data-filter="5" aria-pressed="false">
{t('reviews.filter.five')} · <span data-count="5">{f.number(fiveStar)}</span>
@@ -111,7 +132,7 @@ function escapeText(value: string): string {
counts above are prerendered and stay put. `data-boneyard` marks the capture
target for `pnpm bones`, which snapshots this element's single child.
*/}
<div data-review-list data-review-count={mint.review_count}>
<div data-review-list data-review-count={reviewCount}>
<div data-boneyard="reviews-panel" data-boneyard-config='{"excludeSelectors":[".sr-only"]}'>
<div data-review-items>
{
@@ -123,16 +144,16 @@ function escapeText(value: string): string {
))}
</div>
<p class="sr-only" role="status">
{t('reviews.loading', { n: mint.review_count })}
{t('reviews.loading', { n: reviewCount })}
</p>
<Fragment set:html={noscriptFallback} />
</Fragment>
) : (
<p class="rev-status" data-review-status>
{
mint.review_count === 0
reviewCount === 0
? t('reviews.empty')
: `${t('reviews.loading', { n: mint.review_count })}…`
: `${t('reviews.loading', { n: reviewCount })}…`
}
</p>
)
@@ -162,7 +183,7 @@ function escapeText(value: string): string {
<form method="dialog" class="modal-body write-form" data-write-form novalidate>
<div class="modal-head">
{/* The mint's name is data, dropped into a translated sentence. */}
<h2 id="write-title">{t('reviews.dialog.title', { name: mint.name ?? mint.host })}</h2>
<h2 id="write-title">{t('reviews.dialog.title', { name })}</h2>
<button type="button" class="copy-btn" data-write-close aria-label={t('common.close')}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M18 6 6 18M6 6l12 12" />
@@ -197,7 +218,7 @@ function escapeText(value: string): string {
name="content"
rows="5"
aria-describedby="review-count"
placeholder={t('reviews.dialog.bodyPlaceholder')}></textarea>
placeholder={bodyPlaceholder}></textarea>
<span class="write-count" id="review-count" data-char-count>
{t('reviews.dialog.charCount', { n: 0, max: 2000 })}
</span>
@@ -232,6 +253,7 @@ function escapeText(value: string): string {
import { formatters } from '../i18n/format';
import { enterStagger, onReady, prefersReducedMotion, scrollBehavior } from '../scripts/reveal';
import { getExtension, loadProfiles, loadReviews, publishReview, safeNpub, type LoadedReview } from '../lib/reviews-client';
import type { ReviewSubject } from '../lib/review-subject';
import { activeSigner, onSessionChange, onSignerStatus, readSession, signerErrorMessage } from '../lib/nostr-signer';
// The identity row is the same identity the header shows, drawn by the same helpers.
import { avatarMarkup, identityLines, methodChipHtml, openLogin, resolveProfile, toNpub } from '../lib/auth-ui';
@@ -256,9 +278,34 @@ function escapeText(value: string): string {
const f = formatters(t);
const root = document.querySelector<HTMLElement>('[data-reviews-panel]');
if (root) {
const mintUrl = root.dataset['mintUrl'] ?? '';
const mintPubkey = root.dataset['mintPubkey'] || null;
/*
* The subject crosses from the page as JSON in a data attribute rather than as
* separate attributes per field. It is one object with one meaning, the page and the
* island are the same build, and a malformed one is a bug rather than user input —
* but it is still parsed defensively, because a panel that threw here would take the
* write dialog down with it.
*
* A function returning a const rather than a `let` assigned in a try: the closures
* below capture it, and TypeScript will not carry a narrowing into a closure over a
* mutable binding.
*/
const readSubject = (): ReviewSubject | null => {
try {
return JSON.parse(root?.dataset['subject'] ?? 'null') as ReviewSubject | null;
} catch {
return null;
}
};
const parsedSubject = readSubject();
if (root && parsedSubject) {
// Re-bound inside the guard as a non-nullable const. `load` below is a hoisted
// function declaration, and TypeScript will not carry the guard's narrowing into
// one of those; this is the local it can.
const subject: ReviewSubject = parsedSubject;
const listWrap = root.querySelector<HTMLElement>('[data-review-list]')!;
const list = root.querySelector<HTMLElement>('[data-review-items]')!;
const summarySlot = root.querySelector<HTMLElement>('[data-rating-summary]')!;
@@ -464,7 +511,7 @@ function escapeText(value: string): string {
let loaded: LoadedReview[];
try {
loaded = await withTimeout(loadReviews(mintUrl, mintPubkey), RELAY_TIMEOUT_MS);
loaded = await withTimeout(loadReviews(subject), RELAY_TIMEOUT_MS);
} catch {
showRelayError();
return;
@@ -589,7 +636,7 @@ function escapeText(value: string): string {
* is the life of the intention. Nothing here is secret, but nothing here belongs
* on the machine a week later either.
*/
const DRAFT_KEY = `cashumints:draft:${mintUrl}`;
const DRAFT_KEY = `cashumints:draft:${subject.key}`;
function saveDraft(): void {
if (!form) return;
@@ -900,8 +947,7 @@ function escapeText(value: string): string {
const result = await publishReview({
signer,
mintUrl,
mintPubkey,
subject,
rating,
content,
onSigned: (signed) => {
@@ -928,7 +974,7 @@ function escapeText(value: string): string {
created_at: result.event.created_at,
rating,
content: content.trim(),
mint_url: mintUrl,
mint_url: subject.key,
profile: null,
});