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
+626
View File
@@ -0,0 +1,626 @@
/**
* The satori templates: card model in, 1200x630 SVG out (build-og.mjs rasterises it).
*
* Design tokens are the site's own (src/styles/global.css): same background, same
* card surface, same amber/violet/mint/red, Space Grotesk over Inter over JetBrains
* Mono. The card should read as a cropped screenshot of the site, not a poster.
*
* Everything rendered here comes off the model object and nothing else — the model is
* the manifest hash input, so a value the template invented for itself would be a way
* for an image to change without its hash noticing.
*/
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import satori from 'satori';
const here = path.dirname(fileURLToPath(import.meta.url));
/* ---- palette (global.css :root) ---- */
const BG = '#0E0D0B';
const CARD = '#161512';
const LINE = '#2A2720';
const LINE_SOFT = '#211F19';
const TEXT = '#EDE8DC';
const MUTED = '#9A927E';
const FAINT = '#6B6555';
const AMBER = '#F0A93B';
const VIOLET = '#A78BFA';
const MINT = '#5FD68F';
const RED = '#E06450';
const GROTESK = 'Space Grotesk';
const INTER = 'Inter';
const MONO = 'JetBrains Mono';
/**
* Shorthand: satori consumes plain React-shaped objects. `display: flex` is the
* default because satori requires it on any div with an element child.
*/
const h = (type, style = {}, ...children) => ({
type,
props: {
style: { display: 'flex', ...style },
children: children.flat().filter((c) => c !== null && c !== false),
},
});
/** The pixel moai from Moai.astro, as a data URI so satori can place it as an image. */
function moai(width) {
const svg =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 11 13" shape-rendering="crispEdges">' +
'<rect x="2" y="0" width="7" height="1" fill="#C9BFA4"/>' +
'<rect x="1" y="1" width="9" height="2" fill="#C9BFA4"/>' +
'<rect x="1" y="3" width="2" height="7" fill="#A89B7C"/>' +
'<rect x="3" y="3" width="6" height="7" fill="#C9BFA4"/>' +
'<rect x="3" y="4" width="2" height="1" fill="#5C5340"/>' +
'<rect x="7" y="4" width="2" height="1" fill="#5C5340"/>' +
'<rect x="5" y="5" width="1" height="3" fill="#A89B7C"/>' +
'<rect x="2" y="10" width="8" height="2" fill="#A89B7C"/></svg>';
return {
type: 'img',
props: {
src: `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`,
width,
height: Math.round((width / 11) * 13),
style: {},
},
};
}
const STAR_PATH =
'M12 2l2.9 6.22 6.82.63-5.14 4.52 1.51 6.68L12 16.55l-6.09 3.5 1.51-6.68L2.28 8.85l6.82-.63L12 2z';
/** Five stars, filled to Math.round(rating) — the same rounding starString() uses. */
function stars(rating, size, color) {
const filled = Math.round(rating);
return h(
'div',
{ display: 'flex', gap: `${Math.round(size * 0.18)}px` },
...[1, 2, 3, 4, 5].map((n) => ({
type: 'svg',
props: {
width: size,
height: size,
viewBox: '0 0 24 24',
style: { display: 'flex' },
children: [
{
type: 'path',
props: {
d: STAR_PATH,
fill: n <= filled ? color : 'none',
stroke: n <= filled ? 'none' : FAINT,
'stroke-width': 1.6,
},
},
],
},
})),
);
}
/** The status chip beside the name. Green only for a status a check actually verified. */
function statusChip(model) {
const style = (color, bg, border) => ({
display: 'flex',
alignItems: 'center',
gap: '9px',
padding: '8px 17px',
borderRadius: '999px',
border: `1.5px solid ${border}`,
backgroundColor: bg,
color,
fontFamily: INTER,
fontWeight: 600,
fontSize: '20px',
whiteSpace: 'nowrap',
});
const dot = (color) =>
h('div', { width: '9px', height: '9px', borderRadius: '999px', backgroundColor: color });
switch (model.status) {
case 'online':
return h('div', style(MINT, 'rgba(95,214,143,.08)', 'rgba(95,214,143,.35)'), dot(MINT), 'Online');
case 'offline': {
const label = model.offlineDays !== null ? `Offline ${model.offlineDays}d` : 'Offline';
return h('div', style(RED, 'rgba(224,100,80,.09)', 'rgba(224,100,80,.4)'), dot(RED), label);
}
case 'announced':
// The honest-status rule: an announcement is not a heartbeat, so never green.
return h('div', style(AMBER, 'rgba(240,169,59,.07)', 'rgba(240,169,59,.32)'), dot(AMBER), 'Announced');
case 'degraded':
return h('div', style(MUTED, 'rgba(154,146,126,.07)', 'rgba(154,146,126,.35)'), dot(MUTED), 'Unreachable');
default:
return h('div', style(MUTED, 'rgba(154,146,126,.07)', 'rgba(154,146,126,.35)'), dot(MUTED), 'Not checked yet');
}
}
/** The type badge that makes a federation card unmistakable at thumbnail size. */
function fedimintBadge() {
return h(
'div',
{
display: 'flex',
padding: '9px 14px',
borderRadius: '9px',
border: `1.5px solid rgba(167,139,250,.5)`,
color: VIOLET,
fontFamily: MONO,
fontWeight: 500,
fontSize: '18px',
letterSpacing: '3px',
},
'FEDIMINT',
);
}
/** The icon tile: cached icon when the indexer has one, the site's gradient tile when not. */
function iconTile(model, iconData) {
const size = 116;
const shared = {
width: `${size}px`,
height: `${size}px`,
borderRadius: '28px',
border: '1.5px solid rgba(255,255,255,.14)',
flexShrink: 0,
};
if (iconData) {
return {
type: 'img',
props: { src: iconData, width: size, height: size, style: { ...shared, objectFit: 'cover' } },
};
}
const [c1, c2] = model.gradient;
return h(
'div',
{
...shared,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundImage: `linear-gradient(135deg, ${c1}, ${c2})`,
color: BG,
fontFamily: GROTESK,
fontWeight: 700,
fontSize: '52px',
},
model.initials,
);
}
/**
* Fit the name on one line by stepping the size down; extreme names get two lines,
* and satori's lineClamp guarantees nothing ever leaves the box regardless.
*/
function nameSize(name, maxWidth) {
for (const size of [58, 50, 42]) {
if (name.length * size * 0.58 <= maxWidth) return { size, clamp: 1 };
}
return { size: 38, clamp: 2 };
}
/** Middle-out truncation for a domain that will not fit the identity row. */
function fitMono(value, maxChars) {
if (value.length <= maxChars) return value;
const head = Math.ceil((maxChars - 1) * 0.62);
const tail = maxChars - 1 - head;
return `${value.slice(0, head)}…${value.slice(-tail)}`;
}
function cellLabel(text, color = FAINT) {
return h(
'div',
{
fontFamily: INTER,
fontWeight: 600,
fontSize: '15px',
letterSpacing: '2px',
color,
marginBottom: '16px',
},
text,
);
}
/** Rating + stars + sentiment split, or the honest "no reviews yet". */
function ratingCell(model) {
const children = [cellLabel('COMMUNITY RATING')];
if (model.reviewCount === 0 || model.rating === null) {
children.push(
h('div', { fontFamily: INTER, fontWeight: 500, fontSize: '30px', color: MUTED, marginTop: '14px' }, 'No reviews yet'),
);
} else {
const starColor = model.rating < 3 ? RED : AMBER;
children.push(
h(
'div',
{ display: 'flex', alignItems: 'flex-end', gap: '18px' },
h('div', { fontFamily: GROTESK, fontWeight: 700, fontSize: '58px', lineHeight: 1, color: TEXT }, model.rating.toFixed(1)),
h('div', { display: 'flex', marginBottom: '7px' }, stars(model.rating, 27, starColor)),
),
);
if (model.sentiment) {
const { pos, neg } = model.sentiment;
const neutral = Math.max(0, 100 - pos - neg);
children.push(
h(
'div',
{ display: 'flex', width: '250px', height: '8px', borderRadius: '4px', marginTop: '22px', overflow: 'hidden' },
pos > 0 && h('div', { width: `${pos}%`, backgroundColor: MINT }),
neutral > 0 && h('div', { width: `${neutral}%`, backgroundColor: LINE }),
neg > 0 && h('div', { width: `${neg}%`, backgroundColor: RED }),
),
);
}
}
return h('div', { display: 'flex', flexDirection: 'column', flex: 1.25, padding: '30px 36px' }, ...children);
}
function reviewsCell(model) {
return h(
'div',
{ display: 'flex', flexDirection: 'column', flex: 0.9, padding: '30px 36px', borderLeft: `1px solid ${LINE_SOFT}` },
cellLabel('REVIEWS'),
h('div', { fontFamily: GROTESK, fontWeight: 700, fontSize: '58px', lineHeight: 1, color: TEXT }, String(model.reviewCount)),
// Absolute month/year on purpose: "3d ago" goes stale inside a static PNG.
model.lastReviewMonth !== null &&
h('div', { fontFamily: INTER, fontWeight: 500, fontSize: '19px', color: MUTED, marginTop: '14px' }, `Last review ${model.lastReviewMonth}`),
);
}
/** Third cell, Cashu: published limits in mono, or the warning that replaces them. */
function thirdCellCashu(model) {
const base = {
display: 'flex',
flexDirection: 'column',
flex: 1.1,
padding: '30px 36px',
borderLeft: `1px solid ${LINE_SOFT}`,
};
if (model.warning) {
const color = model.warning.severity === 'critical' ? RED : AMBER;
return h(
'div',
{ ...base, backgroundColor: model.warning.severity === 'critical' ? 'rgba(224,100,80,.06)' : 'rgba(240,169,59,.05)' },
cellLabel('WARNING', color),
h('div', { fontFamily: GROTESK, fontWeight: 700, fontSize: '38px', lineHeight: 1.05, color }, model.warning.label),
h('div', { fontFamily: INTER, fontWeight: 500, fontSize: '19px', color: MUTED, marginTop: '14px' }, model.warning.sub),
);
}
const lines = [];
if (model.limits) {
const { min, max, unit } = model.limits;
if (min !== null) lines.push(['min', `${compact(min)} ${unit}`]);
if (max !== null) lines.push(['max', `${compact(max)} ${unit}`]);
}
return h(
'div',
base,
cellLabel('LIMITS'),
lines.length === 0
? h('div', { fontFamily: MONO, fontWeight: 400, fontSize: '24px', color: MUTED, marginTop: '8px' }, 'Not published')
: h(
'div',
{ display: 'flex', flexDirection: 'column', gap: '10px', marginTop: '4px' },
...lines.map(([k, v]) =>
h(
'div',
{ display: 'flex', alignItems: 'baseline', gap: '14px' },
h('div', { fontFamily: MONO, fontWeight: 400, fontSize: '20px', color: FAINT }, k),
h('div', { fontFamily: MONO, fontWeight: 500, fontSize: '27px', color: TEXT }, v),
),
),
),
);
}
function compact(n) {
if (n >= 1_000_000) return trimNum(n / 1_000_000) + 'M';
if (n >= 1_000) return trimNum(n / 1_000) + 'k';
return String(n);
}
function trimNum(n) {
const s = n.toFixed(1);
return s.endsWith('.0') ? s.slice(0, -2) : s;
}
/** Third cell, Fedimint: modules and network — never limits, never a software version. */
function thirdCellFedimint(model) {
const base = {
display: 'flex',
flexDirection: 'column',
flex: 1.1,
padding: '30px 36px',
borderLeft: `1px solid ${LINE_SOFT}`,
};
if (model.warning) {
return h(
'div',
{ ...base, backgroundColor: 'rgba(224,100,80,.06)' },
cellLabel('WARNING', RED),
h('div', { fontFamily: GROTESK, fontWeight: 700, fontSize: '36px', lineHeight: 1.05, color: RED }, model.warning.label),
h('div', { fontFamily: INTER, fontWeight: 500, fontSize: '19px', color: MUTED, marginTop: '14px' }, model.warning.sub),
);
}
const list = model.modules.length > 0 ? fitMono(model.modules.join(', '), 40) : 'Not announced';
const mainnet = model.network === 'mainnet';
return h(
'div',
base,
cellLabel('MODULES'),
h('div', { fontFamily: MONO, fontWeight: 500, fontSize: '24px', lineHeight: 1.4, color: model.modules.length > 0 ? TEXT : MUTED, maxWidth: '300px' }, list),
model.network &&
h(
'div',
{ display: 'flex', marginTop: '16px' },
h(
'div',
{
display: 'flex',
padding: '6px 13px',
borderRadius: '8px',
border: `1.5px solid ${mainnet ? 'rgba(95,214,143,.4)' : 'rgba(240,169,59,.4)'}`,
color: mainnet ? MINT : AMBER,
fontFamily: MONO,
fontWeight: 500,
fontSize: '17px',
letterSpacing: '1px',
},
model.network,
),
),
);
}
/** The background glow: amber for a healthy card, red-tinted for trouble, violet for Fedimint. */
function glow(model) {
const danger =
model.status === 'offline' || (model.type === 'cashu' && model.warning && model.warning.severity === 'critical');
const color = danger
? 'rgba(224,100,80,.13)'
: model.type === 'fedimint'
? 'rgba(167,139,250,.11)'
: 'rgba(240,169,59,.10)';
return h('div', {
position: 'absolute',
top: '-260px',
right: '-180px',
width: '760px',
height: '760px',
borderRadius: '999px',
backgroundImage: `radial-gradient(circle at center, ${color} 0%, rgba(14,13,11,0) 62%)`,
});
}
/** Brand row, shared by every variant. */
function brandRow(tagline) {
return h(
'div',
{ display: 'flex', alignItems: 'center', gap: '16px' },
moai(30),
h('div', { fontFamily: GROTESK, fontWeight: 700, fontSize: '29px', color: TEXT, letterSpacing: '-0.5px' }, 'Cashumints.space'),
h('div', { fontFamily: INTER, fontWeight: 500, fontSize: '23px', color: FAINT, marginTop: '2px' }, `· ${tagline}`),
);
}
/** One mint or federation card. `iconData` is a data: URI or null. */
export function mintCard(model, iconData) {
const isFed = model.type === 'fedimint';
const monoLine = isFed ? model.shortId : fitMono(model.domain, 46);
// Room beside the icon: canvas minus padding, icon, gaps and the chips.
const chipRoom = 240 + (isFed ? 180 : 0);
const { size, clamp } = nameSize(model.name, 1200 - 128 - 148 - chipRoom);
return h(
'div',
{
width: '1200px',
height: '630px',
display: 'flex',
flexDirection: 'column',
backgroundColor: BG,
padding: '56px 64px',
position: 'relative',
fontFamily: INTER,
},
glow(model),
brandRow(isFed ? 'Fedimint reviews' : 'Cashu mint reviews'),
h(
'div',
{ display: 'flex', alignItems: 'center', gap: '32px', marginTop: '52px', flexGrow: 1 },
iconTile(model, iconData),
h(
'div',
{ display: 'flex', flexDirection: 'column', flexGrow: 1, minWidth: 0 },
h(
'div',
{ display: 'flex', alignItems: 'center', gap: '20px', flexWrap: 'wrap' },
h(
'div',
{
fontFamily: GROTESK,
fontWeight: 700,
fontSize: `${size}px`,
lineHeight: 1.08,
color: TEXT,
letterSpacing: '-1px',
maxWidth: '760px',
lineClamp: clamp,
},
model.name,
),
statusChip(model),
isFed && fedimintBadge(),
),
h('div', { fontFamily: MONO, fontWeight: 400, fontSize: '25px', color: MUTED, marginTop: '14px' }, monoLine),
// The Fedimint soft warning is a muted line, deliberately not a red cell.
isFed &&
model.announcedNote !== null &&
h('div', { fontFamily: INTER, fontWeight: 500, fontSize: '20px', color: 'rgba(240,169,59,.75)', marginTop: '10px' }, model.announcedNote),
),
),
h(
'div',
{
display: 'flex',
backgroundColor: CARD,
border: `1px solid ${LINE}`,
borderRadius: '20px',
minHeight: '196px',
},
ratingCell(model),
reviewsCell(model),
isFed ? thirdCellFedimint(model) : thirdCellCashu(model),
),
);
}
/** The default card: brand, tagline, and the network's numbers. Used site-wide. */
export function defaultCard(model) {
const stat = (n, label) =>
h(
'div',
{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '10px' },
h('div', { fontFamily: GROTESK, fontWeight: 700, fontSize: '54px', lineHeight: 1, color: TEXT }, String(n)),
h('div', { fontFamily: INTER, fontWeight: 500, fontSize: '21px', color: MUTED }, label),
);
return h(
'div',
{
width: '1200px',
height: '630px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: BG,
position: 'relative',
fontFamily: INTER,
},
h('div', {
position: 'absolute',
top: '-300px',
left: '220px',
width: '760px',
height: '760px',
borderRadius: '999px',
backgroundImage: 'radial-gradient(circle at center, rgba(240,169,59,.11) 0%, rgba(14,13,11,0) 62%)',
}),
moai(64),
h(
'div',
{ fontFamily: GROTESK, fontWeight: 700, fontSize: '66px', color: TEXT, letterSpacing: '-1.5px', marginTop: '30px' },
'Cashumints.space',
),
h(
'div',
{ fontFamily: INTER, fontWeight: 500, fontSize: '28px', color: MUTED, marginTop: '14px' },
'Cashu mint and Fedimint reviews, signed on Nostr',
),
h(
'div',
{ display: 'flex', gap: '86px', marginTop: '64px' },
stat(model.mints, 'Cashu mints'),
stat(model.federations, 'federations'),
stat(model.reviews, 'signed reviews'),
),
);
}
/* ---------- fonts ---------- */
let fontsPromise = null;
/**
* The six static faces satori embeds as glyph paths. Static TTF instances rather than
* the site's variable woff2 files, because satori reads neither woff2 nor variable
* axes; the weights are the ones the mockup uses. A missing file is a failed build:
* satori silently falling back to another face is exactly the "serif in the PNG"
* failure the acceptance criteria forbid.
*/
export function loadFonts() {
fontsPromise ??= Promise.all(
[
[GROTESK, 600, 'space-grotesk-600.ttf'],
[GROTESK, 700, 'space-grotesk-700.ttf'],
[INTER, 500, 'inter-500.ttf'],
[INTER, 600, 'inter-600.ttf'],
[MONO, 400, 'jetbrains-mono-400.ttf'],
[MONO, 500, 'jetbrains-mono-500.ttf'],
].map(async ([name, weight, file]) => ({
name,
weight,
style: 'normal',
data: await readFile(path.join(here, 'fonts', file)),
})),
);
return fontsPromise;
}
/* ---------- glyphs outside Latin ---------- */
const assetCache = new Map();
const cacheDir = path.join(here, 'fonts', 'cache');
/**
* Satori calls this for graphemes the six faces cannot draw (one indexed mint has a
* CJK name today). The Noto subset for exactly those characters is fetched once from
* Google Fonts and cached on disk, so repeat builds are offline and deterministic.
* If the fetch fails the glyphs are skipped — a name missing three characters beats
* a failed build, and the failure is logged by the caller.
*/
async function loadAdditionalAsset(code, segment) {
if (code === 'emoji') return []; // no emoji font is embedded; skip rather than tofu
const family = { 'ja-JP': 'Noto Sans JP', 'ko-KR': 'Noto Sans KR', 'zh-CN': 'Noto Sans SC', 'zh-TW': 'Noto Sans TC', 'th-TH': 'Noto Sans Thai' }[code] ?? 'Noto Sans SC';
const key = createHash('sha1').update(`${family}:${segment}`).digest('hex');
if (assetCache.has(key)) return assetCache.get(key);
const file = path.join(cacheDir, `${key}.ttf`);
let data = await readFile(file).catch(() => null);
if (!data) {
try {
const css = await fetch(
`https://fonts.googleapis.com/css2?family=${encodeURIComponent(family)}&text=${encodeURIComponent(segment)}`,
{ headers: { 'User-Agent': 'curl/7.68' } },
).then((r) => r.text());
// The subsetted file comes back as `url(https://fonts.gstatic.com/l/font?kit=…)
// format('truetype')` — a kit URL, not a .ttf path.
const url = /url\((https:[^)]+)\)\s*format\('truetype'\)/.exec(css)?.[1];
if (!url) throw new Error('no truetype url in css response');
data = Buffer.from(await fetch(url).then((r) => r.arrayBuffer()));
await mkdir(cacheDir, { recursive: true });
await writeFile(file, data);
} catch (error) {
console.warn(`og: no glyphs for "${segment}" (${error.message}); rendering without`);
assetCache.set(key, []);
return [];
}
}
const fonts = [{ name: family, data, weight: 400, style: 'normal' }];
assetCache.set(key, fonts);
return fonts;
}
/** Render one card model to SVG. */
export async function renderSvg(model, iconData) {
const element = model.type === 'default' ? defaultCard(model) : mintCard(model, iconData);
return satori(element, {
width: 1200,
height: 630,
fonts: await loadFonts(),
loadAdditionalAsset,
});
}