Expand ecash explorer capabilities
Add Fedimint discovery, dual SQLite/Postgres storage, richer review handling, and generated social imagery.
This commit is contained in:
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user