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,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