Expand ecash explorer capabilities
Add Fedimint discovery, dual SQLite/Postgres storage, richer review handling, and generated social imagery.
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* Social image build step. `pnpm og`, and the first half of `pnpm build`.
|
||||
*
|
||||
* Renders one 1200x630 PNG per mint and federation into `public/og/`, plus the
|
||||
* default card every non-mint page shares, from the same API the pages are built
|
||||
* from. satori turns the card template into SVG (text becomes glyph paths, so the
|
||||
* rasteriser needs no fonts), resvg turns the SVG into a PNG.
|
||||
*
|
||||
* Filenames carry a content hash — `mint.example.com.a1b2c3d4e5.png` — and the pages
|
||||
* read the mapping from `src/generated/og-manifest.json`, which this script writes.
|
||||
* The hash is over the card model (see model.mjs), so:
|
||||
*
|
||||
* - a rebuild with unchanged data renders nothing (the manifest says every hash is
|
||||
* already on disk);
|
||||
* - one mint's rating moving regenerates exactly that mint's image, at a new URL,
|
||||
* which is what actually busts scraper caches — Telegram and friends cache an
|
||||
* og:image URL for days and ignore Cache-Control on it;
|
||||
* - the previous file is deleted, so `public/og/` never accumulates history.
|
||||
*
|
||||
* `--fixtures` renders the six edge-case cards from fixtures.mjs into `og-fixtures/`
|
||||
* at a pinned timestamp instead: byte-stable snapshots for the repo, to eyeball after
|
||||
* template changes.
|
||||
*/
|
||||
import '../load-env.mjs';
|
||||
import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
||||
import { createHash } from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Resvg } from '@resvg/resvg-js';
|
||||
import { renderSvg } from './card.mjs';
|
||||
import { cashuModel, defaultModel, fedimintModel } from './model.mjs';
|
||||
import { FIXTURES, NOW as FIXTURE_NOW } from './fixtures.mjs';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const webRoot = path.resolve(here, '../..');
|
||||
const outDir = path.join(webRoot, 'public/og');
|
||||
const manifestFile = path.join(webRoot, 'src/generated/og-manifest.json');
|
||||
|
||||
const API_URL = process.env.API_URL ?? 'http://127.0.0.1:8787';
|
||||
|
||||
async function getJson(pathname) {
|
||||
const res = await fetch(`${API_URL}${pathname}`, { headers: { Accept: 'application/json' } });
|
||||
if (!res.ok) throw new Error(`${pathname} responded ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** Ten hex characters of the model hash: the cache-busting part of the filename. */
|
||||
const hashModel = (model) =>
|
||||
createHash('sha256').update(JSON.stringify(model)).digest('hex').slice(0, 10);
|
||||
|
||||
/**
|
||||
* The cached icon as a data URI resvg can decode, plus a digest for the model hash.
|
||||
*
|
||||
* Only PNG and JPEG go in — resvg decodes nothing else, and an image tag it cannot
|
||||
* decode becomes a blank square, which is worse than the gradient tile. The one webp
|
||||
* icon in the index today falls back to the tile; logged so it is a known trade, not
|
||||
* a silent one.
|
||||
*/
|
||||
async function fetchIcon(icon, host) {
|
||||
if (!icon) return null;
|
||||
try {
|
||||
const res = await fetch(`${API_URL}${icon}`);
|
||||
if (!res.ok) throw new Error(`responded ${res.status}`);
|
||||
const bytes = Buffer.from(await res.arrayBuffer());
|
||||
|
||||
const isPng = bytes.subarray(0, 4).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47]));
|
||||
const isJpeg = bytes[0] === 0xff && bytes[1] === 0xd8;
|
||||
if (!isPng && !isJpeg) {
|
||||
console.warn(`og: ${host}: icon is not PNG/JPEG, using the gradient tile`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
data: `data:image/${isPng ? 'png' : 'jpeg'};base64,${bytes.toString('base64')}`,
|
||||
hash: createHash('sha1').update(bytes).digest('hex').slice(0, 10),
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn(`og: ${host}: icon fetch failed (${error.message}), using the gradient tile`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function renderPng(model, iconData) {
|
||||
const svg = await renderSvg(model, iconData);
|
||||
const resvg = new Resvg(svg, { font: { loadSystemFonts: false } });
|
||||
return resvg.render().asPng();
|
||||
}
|
||||
|
||||
/* ---------- fixtures mode ---------- */
|
||||
|
||||
async function buildFixtures() {
|
||||
const dir = path.join(webRoot, 'og-fixtures');
|
||||
await mkdir(dir, { recursive: true });
|
||||
|
||||
for (const raw of FIXTURES) {
|
||||
const model =
|
||||
raw.type === 'fedimint'
|
||||
? fedimintModel(raw, { now: FIXTURE_NOW })
|
||||
: cashuModel(raw, { now: FIXTURE_NOW });
|
||||
const png = await renderPng(model, null);
|
||||
const file = path.join(dir, `${raw.host}.png`);
|
||||
await writeFile(file, png);
|
||||
console.log(`og: ${path.relative(webRoot, file)} (${(png.length / 1024).toFixed(0)}KB)`);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- the real build ---------- */
|
||||
|
||||
async function build() {
|
||||
const started = Date.now();
|
||||
await mkdir(outDir, { recursive: true });
|
||||
await mkdir(path.dirname(manifestFile), { recursive: true });
|
||||
|
||||
const previous = JSON.parse(await readFile(manifestFile, 'utf8').catch(() => '{}'));
|
||||
const previousImages = previous.images ?? {};
|
||||
|
||||
const [cashu, fedimint, stats] = await Promise.all([
|
||||
getJson('/api/mints?type=cashu'),
|
||||
getJson('/api/mints?type=fedimint'),
|
||||
getJson('/api/stats'),
|
||||
]);
|
||||
|
||||
/*
|
||||
* One clock for the whole run, so two rebuilds inside the same day produce the same
|
||||
* day counts and therefore the same hashes. Day granularity is deliberate: an
|
||||
* offline mint's "Offline 12d" chip regenerates once a day, not once a build.
|
||||
*/
|
||||
const now = Math.floor(Date.now() / 1000 / 86400) * 86400;
|
||||
|
||||
const onDisk = new Set(await readdir(outDir).catch(() => []));
|
||||
const images = {};
|
||||
let rendered = 0;
|
||||
let skipped = 0;
|
||||
|
||||
const listings = [
|
||||
...cashu.map((m) => ({ listing: m, kind: 'cashu' })),
|
||||
...fedimint.map((m) => ({ listing: m, kind: 'fedimint' })),
|
||||
];
|
||||
|
||||
// A handful at a time: satori renders in milliseconds, the icon fetches are local,
|
||||
// and bounded concurrency keeps 60 base64 icons from piling up in memory at once.
|
||||
const queue = [...listings];
|
||||
const workers = Array.from({ length: 8 }, async () => {
|
||||
for (;;) {
|
||||
const next = queue.shift();
|
||||
if (!next) return;
|
||||
const { listing, kind } = next;
|
||||
|
||||
let detail;
|
||||
try {
|
||||
detail = await getJson(`/api/mints/${encodeURIComponent(listing.host)}`);
|
||||
} catch (error) {
|
||||
console.warn(`og: ${listing.host}: detail fetch failed (${error.message}), skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const icon = await fetchIcon(detail.icon, detail.host);
|
||||
const model =
|
||||
kind === 'fedimint'
|
||||
? fedimintModel(detail, { iconHash: icon?.hash ?? null, now })
|
||||
: cashuModel(detail, { iconHash: icon?.hash ?? null, now });
|
||||
|
||||
const hash = hashModel(model);
|
||||
const file = `${model.slug}.${hash}.png`;
|
||||
images[model.slug] = { file, hash };
|
||||
|
||||
if (previousImages[model.slug]?.hash === hash && onDisk.has(file)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const png = await renderPng(model, icon?.data ?? null);
|
||||
if (png.length > 300 * 1024) {
|
||||
console.warn(`og: ${file} is ${(png.length / 1024).toFixed(0)}KB, over the 300KB budget`);
|
||||
}
|
||||
await writeFile(path.join(outDir, file), png);
|
||||
rendered++;
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
|
||||
/*
|
||||
* The default card. Stable filename on purpose — it is referenced by every non-mint
|
||||
* page and by mints that failed above, and it changes with the site's own stats,
|
||||
* which is what `Cache-Control: max-age=3600` is for (see README).
|
||||
*/
|
||||
const fallback = defaultModel(stats);
|
||||
const fallbackHash = hashModel(fallback);
|
||||
if (previous.default?.hash !== fallbackHash || !onDisk.has('default.png')) {
|
||||
await writeFile(path.join(outDir, 'default.png'), await renderPng(fallback, null));
|
||||
rendered++;
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
|
||||
/* Sweep files no current listing produced, so removed mints do not linger. */
|
||||
const keep = new Set([...Object.values(images).map((i) => i.file), 'default.png']);
|
||||
for (const file of await readdir(outDir)) {
|
||||
if (!keep.has(file)) await rm(path.join(outDir, file));
|
||||
}
|
||||
|
||||
await writeFile(
|
||||
manifestFile,
|
||||
JSON.stringify({ default: { file: 'default.png', hash: fallbackHash }, images }, null, 2) + '\n',
|
||||
);
|
||||
|
||||
const seconds = ((Date.now() - started) / 1000).toFixed(1);
|
||||
console.log(
|
||||
`og: ${rendered} rendered, ${skipped} unchanged, ${Object.keys(images).length} mints+federations, ${seconds}s`,
|
||||
);
|
||||
}
|
||||
|
||||
if (process.argv.includes('--fixtures')) {
|
||||
await buildFixtures();
|
||||
} else {
|
||||
await build();
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* The edge cases the template must survive, as synthetic API payloads.
|
||||
*
|
||||
* Run through the same cashuModel/fedimintModel derivation as real data — a fixture
|
||||
* that bypassed the model would test a card no build can produce. `NOW` is pinned so
|
||||
* the rendered PNGs under og-fixtures/ are byte-stable and can live in the repo as
|
||||
* eyeball snapshots.
|
||||
*/
|
||||
|
||||
/** 1 Aug 2026 00:00 UTC. Day counts and month stamps in fixtures derive from this. */
|
||||
export const NOW = 1_785_542_400;
|
||||
|
||||
const dist = (five = 0, four = 0, three = 0, two = 0, one = 0) => ({
|
||||
5: five, 4: four, 3: three, 2: two, 1: one,
|
||||
});
|
||||
|
||||
const base = {
|
||||
type: 'cashu',
|
||||
status: 'online',
|
||||
last_online: NOW - 300,
|
||||
last_probe: NOW - 300,
|
||||
first_seen: NOW - 400 * 86400,
|
||||
review_count: 26,
|
||||
rating_avg: 4.6,
|
||||
rating_distribution: dist(18, 5, 2, 0, 1),
|
||||
last_review_at: NOW - 12 * 86400,
|
||||
info: {
|
||||
nuts: {
|
||||
4: { methods: [{ method: 'bolt11', unit: 'sat', min_amount: 100, max_amount: 500000 }], disabled: false },
|
||||
5: { methods: [{ method: 'bolt11', unit: 'sat' }], disabled: false },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const FIXTURES = [
|
||||
{
|
||||
// A name at the 30-character mark: must step down, never clip.
|
||||
...base,
|
||||
host: 'fixture-long-name',
|
||||
url: 'https://mint.veryserious.example',
|
||||
name: 'The Extremely Serious Mint Co.',
|
||||
icon: null,
|
||||
},
|
||||
{
|
||||
// No cached icon: the deterministic initial-on-gradient tile.
|
||||
...base,
|
||||
host: 'fixture-no-icon',
|
||||
url: 'https://mint.plain.example',
|
||||
name: 'Plain Mint',
|
||||
icon: null,
|
||||
},
|
||||
{
|
||||
// Zero reviews: "No reviews yet" in the rating cell, no stars, no sentiment bar.
|
||||
...base,
|
||||
host: 'fixture-zero-reviews',
|
||||
url: 'https://mint.unreviewed.example',
|
||||
name: 'Fresh Mint',
|
||||
icon: null,
|
||||
review_count: 0,
|
||||
rating_avg: null,
|
||||
rating_distribution: dist(),
|
||||
last_review_at: null,
|
||||
},
|
||||
{
|
||||
// Offline 12 days: red glow, "Offline 12d" chip, warning cell instead of limits.
|
||||
...base,
|
||||
host: 'fixture-offline',
|
||||
url: 'https://mint.gone.example',
|
||||
name: 'Vanished Mint',
|
||||
icon: null,
|
||||
status: 'offline',
|
||||
last_online: NOW - 12 * 86400,
|
||||
rating_avg: 2.4,
|
||||
rating_distribution: dist(1, 1, 2, 4, 6),
|
||||
review_count: 14,
|
||||
},
|
||||
{
|
||||
// Melt only but ONLINE: green chip stays, warning cell appears anyway.
|
||||
...base,
|
||||
host: 'fixture-melt-only',
|
||||
url: 'https://mint.windingdown.example',
|
||||
name: 'Winding Down Mint',
|
||||
icon: null,
|
||||
info: {
|
||||
nuts: {
|
||||
4: { methods: [], disabled: true },
|
||||
5: { methods: [{ method: 'bolt11', unit: 'sat' }], disabled: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
// A federation announced 3 months ago that nothing ever confirmed.
|
||||
type: 'fedimint',
|
||||
host: 'fixture-fed-announced',
|
||||
url: 'fed11qfixture',
|
||||
name: 'Quiet Corner Federation',
|
||||
icon: null,
|
||||
status: 'announced',
|
||||
last_online: null,
|
||||
last_probe: null,
|
||||
first_seen: NOW - 95 * 86400,
|
||||
announced_at: NOW - 92 * 86400,
|
||||
review_count: 3,
|
||||
rating_avg: 4.3,
|
||||
rating_distribution: dist(1, 2),
|
||||
last_review_at: NOW - 40 * 86400,
|
||||
federation_id: 'a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90',
|
||||
modules: ['ln', 'mint', 'wallet', 'meta'],
|
||||
network: 'mainnet',
|
||||
status_source: null,
|
||||
},
|
||||
];
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* The card model: everything one social image renders, and nothing else.
|
||||
*
|
||||
* This object is the hash input for the manifest, so it must be derived purely from
|
||||
* API data plus the day the build runs on. Two rules follow from that:
|
||||
*
|
||||
* - No relative times finer than a day. "3 minutes ago" in a static PNG is a lie by
|
||||
* the time anyone sees it; day counts and month/year stamps go stale slowly enough
|
||||
* to survive until the next scheduled rebuild regenerates them.
|
||||
* - Everything the template touches is in the model, and the template touches
|
||||
* nothing but the model. A design change is a TEMPLATE_VERSION bump, which busts
|
||||
* every hash at once; a data change busts exactly the mints it happened to.
|
||||
*
|
||||
* The warning logic is imported from shared/, not restated: the image must never
|
||||
* disagree with the page it decorates about whether a mint is frozen or gone.
|
||||
*/
|
||||
import { getMintWarnings, parseLimits, readCapabilities } from '@cashumints/shared';
|
||||
|
||||
/** Bump when the template's layout or copy changes, to regenerate every image. */
|
||||
export const TEMPLATE_VERSION = 1;
|
||||
|
||||
const DAY = 86_400;
|
||||
|
||||
/** English month/year ("Mar 2026") — the images are one English render for all locales. */
|
||||
export function monthYear(unix) {
|
||||
return new Date(unix * 1000).toLocaleDateString('en-GB', {
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
}
|
||||
|
||||
/** "3 Aug 2026", matching f.date() on the pages. */
|
||||
export function shortDate(unix) {
|
||||
return new Date(unix * 1000).toLocaleDateString('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
}
|
||||
|
||||
/* ---- mirrors of web/src/lib/format.ts, which is TypeScript this script cannot load.
|
||||
Same algorithms, asserted by eyeball against the site's own fallback tiles. ---- */
|
||||
|
||||
export function displayDomain(url) {
|
||||
return url.replace(/^https?:\/\//, '').replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
/** The site's deterministic gradient, resolved to hex pairs satori understands. */
|
||||
export function iconGradient(seed) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < seed.length; i++) hash = (hash * 31 + seed.charCodeAt(i)) >>> 0;
|
||||
const hue = hash % 360;
|
||||
const hue2 = (hue + 40) % 360;
|
||||
return [hsl(hue, 45, 55), hsl(hue2, 45, 45)];
|
||||
}
|
||||
|
||||
function hsl(h, s, l) {
|
||||
s /= 100;
|
||||
l /= 100;
|
||||
const k = (n) => (n + h / 30) % 12;
|
||||
const a = s * Math.min(l, 1 - l);
|
||||
const f = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
|
||||
const hex = (v) => Math.round(v * 255).toString(16).padStart(2, '0');
|
||||
return `#${hex(f(0))}${hex(f(8))}${hex(f(4))}`;
|
||||
}
|
||||
|
||||
export function initials(name) {
|
||||
const cleaned = name.replace(/^(https?:\/\/)?(mint\.|www\.)?/, '');
|
||||
const first = cleaned.trim()[0] ?? '?';
|
||||
if (/\d/.test(first)) return cleaned.slice(0, 2);
|
||||
return first.toUpperCase();
|
||||
}
|
||||
|
||||
export function truncateMiddle(value, head = 10, tail = 8) {
|
||||
if (value.length <= head + tail + 1) return value;
|
||||
return `${value.slice(0, head)}…${value.slice(-tail)}`;
|
||||
}
|
||||
|
||||
/** 4–5 star share vs 1–2 star share, as the site's sentiment bar splits them. */
|
||||
export function sentiment(dist) {
|
||||
const positive = (dist['5'] ?? 0) + (dist['4'] ?? 0);
|
||||
const negative = (dist['2'] ?? 0) + (dist['1'] ?? 0);
|
||||
const total = positive + negative + (dist['3'] ?? 0);
|
||||
if (total === 0) return null;
|
||||
return {
|
||||
pos: Math.round((positive / total) * 100),
|
||||
neg: Math.round((negative / total) * 100),
|
||||
};
|
||||
}
|
||||
|
||||
/** "500k", "1M", "100" — sat amounts at stats-bar size. */
|
||||
export function compactSats(n) {
|
||||
if (n >= 1_000_000) return trim(n / 1_000_000) + 'M';
|
||||
if (n >= 1_000) return trim(n / 1_000) + 'k';
|
||||
return String(n);
|
||||
}
|
||||
|
||||
function trim(n) {
|
||||
const s = n.toFixed(1);
|
||||
return s.endsWith('.0') ? s.slice(0, -2) : s;
|
||||
}
|
||||
|
||||
/** The warning cell's two lines, from the same warning object the page banner uses. */
|
||||
function warningCell(warning, mint, now) {
|
||||
if (!warning) return null;
|
||||
|
||||
const since = mint.last_online;
|
||||
const label = {
|
||||
'gone': 'Likely gone',
|
||||
'melt-disabled': 'No withdrawals',
|
||||
'frozen': 'Frozen',
|
||||
'melt-only': 'Melt only',
|
||||
'offline-long': 'Offline',
|
||||
'offline': 'Offline',
|
||||
'fedimint-offline': 'Reported offline',
|
||||
}[warning.kind];
|
||||
if (!label) return null;
|
||||
|
||||
let sub;
|
||||
switch (warning.kind) {
|
||||
case 'gone':
|
||||
case 'offline-long':
|
||||
case 'offline':
|
||||
case 'fedimint-offline':
|
||||
sub = since !== null ? `Since ${shortDate(since)}` : 'Never reached';
|
||||
break;
|
||||
case 'melt-only':
|
||||
sub = 'Deposits disabled';
|
||||
break;
|
||||
case 'melt-disabled':
|
||||
sub = 'Deposits still open';
|
||||
break;
|
||||
case 'frozen':
|
||||
sub = 'Nothing moves in or out';
|
||||
break;
|
||||
}
|
||||
|
||||
// The mint is also offline while a capability warning outranks that fact: the cell
|
||||
// still has to say so, the way the page banner folds it into its last sentence.
|
||||
if (['melt-only', 'melt-disabled', 'frozen'].includes(warning.kind) && mint.status === 'offline' && since !== null) {
|
||||
sub = `Offline since ${shortDate(since)}`;
|
||||
}
|
||||
|
||||
return { kind: warning.kind, severity: warning.severity, label, sub };
|
||||
}
|
||||
|
||||
/**
|
||||
* One Cashu mint's card model, from its detail payload.
|
||||
*
|
||||
* `iconHash` arrives from the caller (a digest of the cached icon bytes), so a mint
|
||||
* that changes its logo behind the same URL regenerates. `now` is passed in rather
|
||||
* than read here so fixtures render the same PNG forever.
|
||||
*/
|
||||
export function cashuModel(mint, { iconHash = null, now = Math.floor(Date.now() / 1000) } = {}) {
|
||||
const domain = displayDomain(mint.url);
|
||||
const name = (mint.name ?? '').trim() || domain.split('/')[0] || domain;
|
||||
const warning = getMintWarnings(mint, { now })[0] ?? null;
|
||||
const offline = mint.status === 'offline';
|
||||
const offlineDays =
|
||||
offline && mint.last_online !== null
|
||||
? Math.max(0, Math.floor((now - mint.last_online) / DAY))
|
||||
: null;
|
||||
|
||||
const limits = parseLimits(mint.info?.nuts, 4);
|
||||
const caps = readCapabilities(mint.info?.nuts);
|
||||
|
||||
return {
|
||||
v: TEMPLATE_VERSION,
|
||||
type: 'cashu',
|
||||
slug: mint.host,
|
||||
name,
|
||||
domain,
|
||||
status: mint.status,
|
||||
offlineDays,
|
||||
rating: mint.rating_avg,
|
||||
reviewCount: mint.review_count,
|
||||
sentiment: sentiment(mint.rating_distribution ?? {}),
|
||||
lastReviewMonth: mint.last_review_at ? monthYear(mint.last_review_at) : null,
|
||||
limits:
|
||||
limits && (limits.min !== null || limits.max !== null) && !caps.mintDisabled
|
||||
? { min: limits.min, max: limits.max, unit: limits.unit }
|
||||
: null,
|
||||
warning: warningCell(warning, mint, now),
|
||||
iconHash,
|
||||
gradient: iconHash ? null : iconGradient(domain),
|
||||
initials: iconHash ? null : initials(name),
|
||||
};
|
||||
}
|
||||
|
||||
/** One federation's card model. Same skeleton, only honest fields. */
|
||||
export function fedimintModel(fed, { iconHash = null, now = Math.floor(Date.now() / 1000) } = {}) {
|
||||
const federationId = fed.federation_id ?? '';
|
||||
const name = (fed.name ?? '').trim() || 'Unnamed federation';
|
||||
const warning = getMintWarnings(fed, { now })[0] ?? null;
|
||||
const announced = fed.status === 'announced';
|
||||
const offlineDays =
|
||||
fed.status === 'offline' && fed.last_online !== null
|
||||
? Math.max(0, Math.floor((now - fed.last_online) / DAY))
|
||||
: null;
|
||||
|
||||
return {
|
||||
v: TEMPLATE_VERSION,
|
||||
type: 'fedimint',
|
||||
slug: fed.host,
|
||||
name,
|
||||
/** `fed11…` prefix plus the id tail: what the identity row shows where a domain sits. */
|
||||
shortId: federationId ? `fed11…${federationId.slice(-6)}` : fed.host,
|
||||
status: fed.status,
|
||||
offlineDays,
|
||||
rating: fed.rating_avg,
|
||||
reviewCount: fed.review_count,
|
||||
sentiment: sentiment(fed.rating_distribution ?? {}),
|
||||
lastReviewMonth: fed.last_review_at ? monthYear(fed.last_review_at) : null,
|
||||
modules: fed.modules ?? [],
|
||||
network: fed.network,
|
||||
/** The soft warning: never a red cell, just this muted line under the identity row. */
|
||||
announcedNote:
|
||||
announced && warning?.kind === 'never-confirmed' && fed.announced_at
|
||||
? `Announced ${shortDate(fed.announced_at)}, never confirmed`
|
||||
: null,
|
||||
warning: fed.status === 'offline' ? warningCell(warning, fed, now) : null,
|
||||
iconHash,
|
||||
gradient: iconHash ? null : iconGradient(federationId || fed.host),
|
||||
initials: iconHash ? null : initials(name),
|
||||
};
|
||||
}
|
||||
|
||||
/** The default card: brand, tagline, and the network's own numbers. */
|
||||
export function defaultModel(stats) {
|
||||
return {
|
||||
v: TEMPLATE_VERSION,
|
||||
type: 'default',
|
||||
slug: 'default',
|
||||
mints: stats.cashu_total,
|
||||
federations: stats.fedimint_total,
|
||||
reviews: stats.reviews_total,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user