@@ -0,0 +1,79 @@
|
||||
// Must come first: it populates process.env from the workspace .env, and everything
|
||||
// below reads process.env.
|
||||
import './scripts/load-env.mjs';
|
||||
|
||||
import { defineConfig } from 'astro/config';
|
||||
import { checkCatalogs } from './scripts/i18n-build-check.mjs';
|
||||
import { DEFAULT_LOCALE, LOCALE_CODES } from './src/i18n/locales.mjs';
|
||||
|
||||
const site = process.env.SITE_URL ?? 'https://cashumints.space';
|
||||
|
||||
/*
|
||||
* Catalog check, before anything renders.
|
||||
*
|
||||
* A missing key is a warning listing every key by locale, because English is a working
|
||||
* fallback and half a language beats none. A key used in the source that no catalog
|
||||
* defines, or an island reaching for a string the page never inlines, is an error and
|
||||
* stops the build: both of those put a humanised key on a real page.
|
||||
*/
|
||||
await checkCatalogs();
|
||||
|
||||
// Where the build (and the dev server proxy) reaches the API. This is a private,
|
||||
// build-machine address by default and must never end up in the browser's markup.
|
||||
const apiUrl = process.env.API_URL ?? 'http://127.0.0.1:8787';
|
||||
|
||||
// Browser-facing API origin. Empty means same origin: islands fetch /api/... and icons
|
||||
// resolve to /icons/..., which the dev proxy below and nginx in production forward to
|
||||
// the API. Set PUBLIC_API_URL only when the API is served from its own origin, e.g.
|
||||
// https://api.cashumints.space.
|
||||
const publicApiUrl = process.env.PUBLIC_API_URL ?? '';
|
||||
|
||||
// Dev server and preview port. Set WEB_PORT in .env; a --port flag still overrides it.
|
||||
const port = Number.parseInt(process.env.WEB_PORT ?? '', 10) || 4321;
|
||||
|
||||
// Same-origin mode only works if something forwards those two prefixes; in dev that is
|
||||
// this proxy. With PUBLIC_API_URL set, requests skip the dev server entirely.
|
||||
const proxy = publicApiUrl
|
||||
? undefined
|
||||
: {
|
||||
'/api': { target: apiUrl, changeOrigin: true },
|
||||
'/icons': { target: apiUrl, changeOrigin: true },
|
||||
};
|
||||
|
||||
export default defineConfig({
|
||||
site,
|
||||
output: 'static',
|
||||
trailingSlash: 'ignore',
|
||||
|
||||
/*
|
||||
* Path-prefix locales, English at the root: /mints, /es/mints, /nl/mints.
|
||||
*
|
||||
* `prefixDefaultLocale: false` is what keeps every existing English URL exactly where
|
||||
* it was, which matters more here than tidiness: those URLs are indexed, linked and
|
||||
* pasted into wallets. Astro uses this to resolve `Astro.currentLocale` and to route
|
||||
* in dev; the pages themselves are emitted per locale by `getStaticPaths` in
|
||||
* `src/pages/[...locale]/`, so the whole set is prerendered rather than negotiated.
|
||||
*
|
||||
* `redirectToDefaultLocale: false`: a visitor asking for /es/thing that does not
|
||||
* exist gets a 404, not a silent bounce to English. Redirecting by language is
|
||||
* exactly what this site does not do.
|
||||
*/
|
||||
i18n: {
|
||||
defaultLocale: DEFAULT_LOCALE,
|
||||
locales: LOCALE_CODES,
|
||||
routing: {
|
||||
prefixDefaultLocale: false,
|
||||
redirectToDefaultLocale: false,
|
||||
},
|
||||
},
|
||||
|
||||
build: { inlineStylesheets: 'auto' },
|
||||
devToolbar: { enabled: false },
|
||||
server: { port },
|
||||
vite: {
|
||||
define: {
|
||||
'import.meta.env.PUBLIC_API_URL': JSON.stringify(publicApiUrl),
|
||||
},
|
||||
server: { proxy },
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"breakpoints": [360, 768, 1280],
|
||||
"out": "./src/bones",
|
||||
"wait": 2000
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@cashumints/web",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"typecheck": "astro check",
|
||||
"bones": "node --env-file-if-exists=../.env scripts/bones.mjs",
|
||||
"check:links": "node scripts/check-links.mjs dist",
|
||||
"check:i18n": "node scripts/check-i18n.mjs",
|
||||
"check:hreflang": "node scripts/check-hreflang.mjs dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cashumints/shared": "workspace:*",
|
||||
"astro": "^5.1.1",
|
||||
"nostr-tools": "^2.10.4",
|
||||
"qrcode-generator": "^2.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/check": "^0.9.4",
|
||||
"boneyard-js": "^1.9.0",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 888 B |
|
After Width: | Height: | Size: 910 B |
@@ -0,0 +1 @@
|
||||
<svg width="11" height="13" viewBox="0 0 11 13" shape-rendering="crispEdges" xmlns="http://www.w3.org/2000/svg"><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>
|
||||
|
After Width: | Height: | Size: 559 B |
@@ -0,0 +1,303 @@
|
||||
The three typefaces served from this directory, and their licences.
|
||||
|
||||
All three are licensed under the SIL Open Font License, Version 1.1. The files here are
|
||||
the unmodified `latin` and `latin-ext` woff2 subsets as served by Google Fonts; they are
|
||||
self-hosted so that visiting this site does not make a request to a third party. The
|
||||
full notice for each family follows.
|
||||
|
||||
Space Grotesk https://github.com/floriankarsten/space-grotesk
|
||||
Inter https://github.com/rsms/inter
|
||||
JetBrains Mono https://github.com/JetBrains/JetBrainsMono
|
||||
|
||||
================================================================================
|
||||
Space Grotesk
|
||||
================================================================================
|
||||
|
||||
Copyright 2020 The Space Grotesk Project Authors (https://github.com/floriankarsten/space-grotesk)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
Inter
|
||||
================================================================================
|
||||
|
||||
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION AND CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
JetBrains Mono
|
||||
================================================================================
|
||||
|
||||
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 122 KiB |
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "Cashumints.space",
|
||||
"short_name": "Cashumints",
|
||||
"description": "Find a Cashu mint you can trust. Every mint on the Nostr network, with community reviews you can verify yourself.",
|
||||
"icons": [
|
||||
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" },
|
||||
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||
],
|
||||
"theme_color": "#0E0D0B",
|
||||
"background_color": "#0E0D0B",
|
||||
"display": "standalone",
|
||||
"start_url": "/"
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Regenerate the Boneyard bone files.
|
||||
*
|
||||
* Boneyard measures a running page, so this needs `pnpm dev` up. It captures exactly
|
||||
* two routes and does not crawl:
|
||||
*
|
||||
* - a real mint page, for the reviews panel's bones
|
||||
* - a /mint/ URL with no prerendered page, for the prerender miss skeleton
|
||||
*
|
||||
* The mint host is looked up from the API rather than hardcoded, so this keeps
|
||||
* working as mints come and go. Both islands render fixture content when the CLI
|
||||
* sets `window.__BONEYARD_BUILD`, so neither capture depends on a relay answering.
|
||||
*
|
||||
* Re-run after any layout change to a review card or to the prerender miss summary.
|
||||
*/
|
||||
import { spawn } from 'node:child_process';
|
||||
import { rmSync } from 'node:fs';
|
||||
|
||||
const WEB_PORT = process.env['WEB_PORT'] ?? '4321';
|
||||
const WEB_URL = process.env['BONES_URL'] ?? `http://localhost:${WEB_PORT}`;
|
||||
const API_URL = process.env['API_URL'] ?? 'http://127.0.0.1:8787';
|
||||
|
||||
/** A host that is never prerendered, so Astro serves the 404 fallback for it. */
|
||||
const MISS_HOST = 'not-prerendered.boneyard.invalid';
|
||||
|
||||
function fail(message) {
|
||||
console.error(`\n bones: ${message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function reachable(url) {
|
||||
try {
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(4000) });
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!(await reachable(WEB_URL))) {
|
||||
fail(`no dev server at ${WEB_URL}. Start one with "pnpm dev", then run this again.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* The dev server proxies /api to the API in same origin mode, so try it first and
|
||||
* fall back to the API's own address for setups that set PUBLIC_API_URL.
|
||||
*/
|
||||
async function firstMintHost() {
|
||||
for (const base of [WEB_URL, API_URL]) {
|
||||
try {
|
||||
const res = await fetch(`${base}/api/mints`, { signal: AbortSignal.timeout(8000) });
|
||||
if (!res.ok) continue;
|
||||
const mints = await res.json();
|
||||
if (Array.isArray(mints) && mints[0]?.host) return mints[0].host;
|
||||
} catch {
|
||||
// Try the next base.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const host = await firstMintHost();
|
||||
if (!host) {
|
||||
fail(`no mints from the API (tried ${WEB_URL} and ${API_URL}). Start it with "pnpm dev:api".`);
|
||||
}
|
||||
|
||||
const targets = [`${WEB_URL}/mint/${host}`, `${WEB_URL}/mint/${MISS_HOST}`];
|
||||
console.log(`\n bones: measuring\n ${targets.join('\n ')}\n`);
|
||||
|
||||
// Passing explicit non-root paths puts the CLI in single page mode: it visits only
|
||||
// these URLs, with no link following and no filesystem route scan.
|
||||
//
|
||||
// --force because Boneyard decides a skeleton is unchanged by hashing the captured
|
||||
// element's innerHTML. Bone geometry depends just as much on CSS, so a styling change
|
||||
// alone would silently reuse stale bones. There are only two skeletons here, so
|
||||
// always recapturing costs a few seconds and removes the trap.
|
||||
const child = spawn('boneyard-js', ['build', '--force', ...targets], { stdio: 'inherit', shell: false });
|
||||
|
||||
child.on('error', (error) => fail(`could not run boneyard-js (${error.message}).`));
|
||||
child.on('exit', (code) => {
|
||||
// Boneyard also writes a registry that calls registerBones() through a framework
|
||||
// adapter, defaulting to boneyard-js/react. We have no React and no <Skeleton>
|
||||
// components: the islands import the .bones.json files directly. Leaving an
|
||||
// unresolvable import in src/ would only break typechecking.
|
||||
rmSync(new URL('../src/bones/registry.ts', import.meta.url), { force: true });
|
||||
rmSync(new URL('../src/bones/registry.js', import.meta.url), { force: true });
|
||||
process.exit(code ?? 1);
|
||||
});
|
||||
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* The i18n SEO check, over the built site.
|
||||
*
|
||||
* `check-links.mjs` answers "does every link go somewhere". This answers the question
|
||||
* that matters once a site is in three languages: does every page correctly declare
|
||||
* which language it is, which page it is, and where its siblings are. Those three
|
||||
* declarations are what stop three translations of one page being indexed as three
|
||||
* duplicates, and every one of them is easy to get subtly wrong in a way nothing else
|
||||
* catches.
|
||||
*
|
||||
* Seven checks per page:
|
||||
*
|
||||
* lang <html lang> matches the locale its URL is under
|
||||
* canonical present, absolute, and pointing at itself (never at another locale)
|
||||
* alternates one per locale, plus exactly one x-default
|
||||
* absolute every alternate href is absolute
|
||||
* targets every alternate href is a page the build actually emitted
|
||||
* reciprocal every alternate lists this page back, under this page's own hreflang
|
||||
* x-default points at the default locale's version of this same page
|
||||
*
|
||||
* Plus, over the sitemap: every emitted page appears exactly once, with its own full
|
||||
* xhtml:link alternate set.
|
||||
*
|
||||
* Usage: node scripts/check-hreflang.mjs [distDir]
|
||||
* Exits non-zero on any problem, so it can gate a deploy.
|
||||
*/
|
||||
import { readdir, readFile } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const dist = path.resolve(process.argv[2] ?? path.join(root, 'dist'));
|
||||
|
||||
if (!existsSync(dist)) {
|
||||
console.error(`No such directory: ${dist}. Run \`pnpm build\` first.`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const { DEFAULT_LOCALE, LOCALE_CODES } = await import(path.join(root, 'src/i18n/locales.mjs'));
|
||||
const PREFIXED = LOCALE_CODES.filter((code) => code !== DEFAULT_LOCALE);
|
||||
|
||||
/* ---------- what the build emitted ---------- */
|
||||
|
||||
async function walk(dir, base = dir) {
|
||||
const out = [];
|
||||
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) out.push(...(await walk(full, base)));
|
||||
else out.push(path.relative(base, full).split(path.sep).join('/'));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const files = await walk(dist);
|
||||
const pages = files.filter((f) => f.endsWith('.html'));
|
||||
|
||||
/** dist-relative file to the route a visitor asks for. */
|
||||
const routeOf = (file) =>
|
||||
file === 'index.html' ? '/' : '/' + file.replace(/\/index\.html$/, '').replace(/\.html$/, '');
|
||||
|
||||
/** The locale a route is under, and the route with that prefix removed. */
|
||||
function split(route) {
|
||||
const first = route.split('/').filter(Boolean)[0];
|
||||
if (first && PREFIXED.includes(first)) {
|
||||
const rest = route.slice(first.length + 1);
|
||||
return { locale: first, path: rest === '' ? '/' : rest };
|
||||
}
|
||||
return { locale: DEFAULT_LOCALE, path: route };
|
||||
}
|
||||
|
||||
const problems = [];
|
||||
const fail = (page, kind, detail) => problems.push({ page, kind, detail });
|
||||
|
||||
/* ---------- read every page ---------- */
|
||||
|
||||
const LANG = /<html[^>]*\slang="([^"]*)"/i;
|
||||
const CANONICAL = /<link[^>]*rel="canonical"[^>]*href="([^"]*)"/i;
|
||||
const ROBOTS = /<meta[^>]*name="robots"[^>]*content="([^"]*)"/i;
|
||||
const ALTERNATE = /<link[^>]*rel="alternate"[^>]*hreflang="([^"]*)"[^>]*href="([^"]*)"/gi;
|
||||
|
||||
const seen = new Map(); // route -> { locale, path, lang, canonical, alternates: Map }
|
||||
const routes = new Set();
|
||||
|
||||
for (const file of pages) {
|
||||
const route = routeOf(file);
|
||||
routes.add(route);
|
||||
const html = await readFile(path.join(dist, file), 'utf8');
|
||||
|
||||
const alternates = new Map();
|
||||
for (const match of html.matchAll(ALTERNATE)) alternates.set(match[1], match[2]);
|
||||
|
||||
seen.set(route, {
|
||||
...split(route),
|
||||
route,
|
||||
lang: LANG.exec(html)?.[1] ?? null,
|
||||
canonical: CANONICAL.exec(html)?.[1] ?? null,
|
||||
robots: ROBOTS.exec(html)?.[1] ?? null,
|
||||
alternates,
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
* The 404 page is the one route with no place in the alternate graph.
|
||||
*
|
||||
* It is not a page anyone links to or a crawler should index; it is what a web server
|
||||
* hands back for a URL that does not exist. It still gets a `lang` and it still gets
|
||||
* one per locale, which is what the checks below verify, but it is deliberately absent
|
||||
* from the sitemap and it is not required to be reciprocal with anything.
|
||||
*/
|
||||
const isOffGraph = (page) => page.path === '/404';
|
||||
|
||||
/**
|
||||
* A page that exists but asks not to be indexed.
|
||||
*
|
||||
* Mint pages the site has never reached and nobody has reviewed carry this (see
|
||||
* `src/lib/seo.ts`). Unlike the 404 they are real pages: they keep their canonical and
|
||||
* their full alternate set, and only the sitemap leaves them out.
|
||||
*/
|
||||
const isNoindex = (page) => Boolean(page.robots?.includes('noindex'));
|
||||
|
||||
/* ---------- the seven checks ---------- */
|
||||
|
||||
const origins = new Set();
|
||||
|
||||
for (const page of seen.values()) {
|
||||
if (page.lang !== page.locale) {
|
||||
fail(page.route, 'lang', `<html lang="${page.lang}"> under /${page.locale}/`);
|
||||
}
|
||||
|
||||
if (isOffGraph(page)) {
|
||||
// No canonical, no alternates, and a robots meta saying so. Checked rather than
|
||||
// skipped: "the 404 has no alternate set" is a claim worth verifying, because the
|
||||
// easy mistake is for it to quietly acquire one.
|
||||
if (page.canonical) fail(page.route, 'noindex', `404 has a canonical: ${page.canonical}`);
|
||||
if (page.alternates.size > 0) {
|
||||
fail(page.route, 'noindex', `404 declares ${page.alternates.size} alternate(s)`);
|
||||
}
|
||||
if (!page.robots?.includes('noindex')) fail(page.route, 'noindex', '404 is missing noindex');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!page.canonical) {
|
||||
fail(page.route, 'canonical', 'no <link rel="canonical">');
|
||||
continue;
|
||||
}
|
||||
if (!/^https?:\/\//.test(page.canonical)) {
|
||||
fail(page.route, 'canonical', `relative: ${page.canonical}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const canonicalUrl = new URL(page.canonical);
|
||||
origins.add(canonicalUrl.origin);
|
||||
|
||||
|
||||
// Self-canonical. A locale canonicalising to another locale tells a crawler this
|
||||
// page is a duplicate that should not be indexed, which is the exact opposite of
|
||||
// what the alternate set beside it is claiming.
|
||||
if (canonicalUrl.pathname.replace(/\/$/, '') !== page.route.replace(/\/$/, '')) {
|
||||
fail(page.route, 'canonical', `points at ${canonicalUrl.pathname}, not at itself`);
|
||||
}
|
||||
|
||||
// One per locale, plus x-default, and nothing else.
|
||||
const expected = [...LOCALE_CODES, 'x-default'];
|
||||
for (const hreflang of expected) {
|
||||
if (!page.alternates.has(hreflang)) {
|
||||
fail(page.route, 'alternates', `missing hreflang="${hreflang}"`);
|
||||
}
|
||||
}
|
||||
for (const hreflang of page.alternates.keys()) {
|
||||
if (!expected.includes(hreflang)) {
|
||||
fail(page.route, 'alternates', `unexpected hreflang="${hreflang}"`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [hreflang, href] of page.alternates) {
|
||||
if (!/^https?:\/\//.test(href)) {
|
||||
fail(page.route, 'absolute', `hreflang="${hreflang}" is relative: ${href}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const target = new URL(href).pathname.replace(/\/$/, '') || '/';
|
||||
if (!routes.has(target)) {
|
||||
fail(page.route, 'targets', `hreflang="${hreflang}" points at ${target}, which was not emitted`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (hreflang === 'x-default') {
|
||||
const expectedDefault = split(target);
|
||||
if (expectedDefault.locale !== DEFAULT_LOCALE || expectedDefault.path !== page.path) {
|
||||
fail(page.route, 'x-default', `points at ${target}, not at the ${DEFAULT_LOCALE} version of ${page.path}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Reciprocity: the page this one points at has to point back, at this page, under
|
||||
// this page's own hreflang. A set that is not reciprocal is discarded whole, so a
|
||||
// one-sided link is worth less than no link.
|
||||
const other = seen.get(target);
|
||||
const back = other?.alternates.get(page.locale);
|
||||
if (!back) {
|
||||
fail(page.route, 'reciprocal', `${target} does not list hreflang="${page.locale}"`);
|
||||
continue;
|
||||
}
|
||||
const backPath = new URL(back).pathname.replace(/\/$/, '') || '/';
|
||||
if (backPath !== (page.route.replace(/\/$/, '') || '/')) {
|
||||
fail(page.route, 'reciprocal', `${target} lists hreflang="${page.locale}" as ${backPath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (origins.size > 1) {
|
||||
fail('(site)', 'origin', `canonical URLs use more than one origin: ${[...origins].join(', ')}`);
|
||||
}
|
||||
|
||||
/* ---------- the sitemap ---------- */
|
||||
|
||||
const sitemapPath = path.join(dist, 'sitemap.xml');
|
||||
let sitemapCount = 0;
|
||||
|
||||
if (!existsSync(sitemapPath)) {
|
||||
fail('(sitemap)', 'sitemap', 'sitemap.xml was not emitted');
|
||||
} else {
|
||||
const xml = await readFile(sitemapPath, 'utf8');
|
||||
const blocks = [...xml.matchAll(/<url>([\s\S]*?)<\/url>/g)].map((m) => m[1]);
|
||||
sitemapCount = blocks.length;
|
||||
|
||||
const listed = new Map();
|
||||
for (const block of blocks) {
|
||||
const loc = /<loc>([^<]*)<\/loc>/.exec(block)?.[1];
|
||||
if (!loc) {
|
||||
fail('(sitemap)', 'sitemap', 'a <url> block has no <loc>');
|
||||
continue;
|
||||
}
|
||||
const route = new URL(loc).pathname.replace(/\/$/, '') || '/';
|
||||
if (listed.has(route)) fail(route, 'sitemap', 'listed more than once');
|
||||
|
||||
const alternates = new Set(
|
||||
[...block.matchAll(/<xhtml:link[^>]*hreflang="([^"]*)"/g)].map((m) => m[1]),
|
||||
);
|
||||
listed.set(route, alternates);
|
||||
|
||||
for (const hreflang of [...LOCALE_CODES, 'x-default']) {
|
||||
if (!alternates.has(hreflang)) {
|
||||
fail(route, 'sitemap', `entry is missing xhtml:link hreflang="${hreflang}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* The invariant, both ways round: every indexable page is listed, and nothing that
|
||||
* asks not to be indexed is.
|
||||
*
|
||||
* Checking it here rather than trusting the two files to agree is the point. The
|
||||
* page's robots meta and the sitemap are produced by different code from one shared
|
||||
* predicate, and this reads the built output of both, so the day someone changes one
|
||||
* without the other the build says so.
|
||||
*/
|
||||
for (const page of seen.values()) {
|
||||
if (isNoindex(page)) {
|
||||
if (listed.has(page.route)) {
|
||||
fail(page.route, 'sitemap', 'says noindex but is listed in the sitemap');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!listed.has(page.route)) fail(page.route, 'sitemap', 'emitted but not in the sitemap');
|
||||
}
|
||||
for (const route of listed.keys()) {
|
||||
if (!routes.has(route)) fail(route, 'sitemap', 'listed but never emitted');
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- report ---------- */
|
||||
|
||||
const localeCounts = LOCALE_CODES.map(
|
||||
(code) => `${code}: ${[...seen.values()].filter((p) => p.locale === code).length}`,
|
||||
).join(', ');
|
||||
|
||||
console.log(
|
||||
`check-hreflang: ${pages.length} pages (${localeCounts}), ${sitemapCount} sitemap entries.`,
|
||||
);
|
||||
|
||||
if (problems.length === 0) {
|
||||
console.log('Every page declares its language, canonicalises to itself, and has a complete reciprocal alternate set.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const grouped = new Map();
|
||||
for (const problem of problems) {
|
||||
if (!grouped.has(problem.kind)) grouped.set(problem.kind, []);
|
||||
grouped.get(problem.kind).push(problem);
|
||||
}
|
||||
|
||||
console.log(`\n${problems.length} problem(s):`);
|
||||
for (const [kind, list] of grouped) {
|
||||
console.log(`\n [${kind}] ${list.length}`);
|
||||
for (const problem of list.slice(0, 15)) console.log(` ${problem.page}: ${problem.detail}`);
|
||||
if (list.length > 15) console.log(` … and ${list.length - 15} more`);
|
||||
}
|
||||
process.exit(1);
|
||||
@@ -0,0 +1,341 @@
|
||||
/**
|
||||
* Catalog checker. Runs before every build, and on its own via `pnpm check:i18n`.
|
||||
*
|
||||
* Four questions, all of which have bitten a multilingual site before:
|
||||
*
|
||||
* missing a key English has and this locale does not (falls back, but silently)
|
||||
* unknown a key a locale has and English does not (a typo, or a stale key)
|
||||
* undefined a `t('...')` in the source that no catalog defines (would render a
|
||||
* humanised key on a real page)
|
||||
* client an island reaching for a key outside CLIENT_NAMESPACES, which would
|
||||
* not be in the page's inline payload and would render as a humanised key
|
||||
*
|
||||
* Missing keys are a warning: English is a working fallback and a half-translated
|
||||
* language is better than no language. Everything else is an error, because every one
|
||||
* of them puts a wrong string in front of a visitor.
|
||||
*
|
||||
* Usage: node scripts/check-i18n.mjs [--strict]
|
||||
* --strict also fails on missing keys, for a release build.
|
||||
*/
|
||||
import { readdir, readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const i18nDir = path.join(root, 'src/i18n');
|
||||
const srcDir = path.join(root, 'src');
|
||||
|
||||
const strict = process.argv.includes('--strict');
|
||||
|
||||
/* ---------- the locale table, read from config.ts ---------- */
|
||||
|
||||
/*
|
||||
* Parsed out of the source rather than imported, because config.ts is TypeScript and
|
||||
* this script runs on plain node before anything is compiled. The two shapes it reads
|
||||
* are the two the file is allowed to have; a change to either fails loudly here rather
|
||||
* than quietly checking nothing.
|
||||
*/
|
||||
const configSource = await readFile(path.join(i18nDir, 'config.ts'), 'utf8');
|
||||
|
||||
const localeCodes = [...configSource.matchAll(/\{\s*code:\s*'([a-z-]+)'/g)].map((m) => m[1]);
|
||||
const defaultLocale = /DEFAULT_LOCALE\s*=\s*'([a-z-]+)'/.exec(configSource)?.[1];
|
||||
const clientBlock = /CLIENT_NAMESPACES\s*=\s*\[([^\]]*)\]/s.exec(configSource)?.[1] ?? '';
|
||||
const clientNamespaces = new Set([...clientBlock.matchAll(/'([\w-]+)'/g)].map((m) => m[1]));
|
||||
|
||||
if (localeCodes.length === 0 || !defaultLocale || clientNamespaces.size === 0) {
|
||||
console.error('check-i18n: could not read LOCALES, DEFAULT_LOCALE or CLIENT_NAMESPACES from src/i18n/config.ts');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
/*
|
||||
* `locales.mjs` is the same list in plain JavaScript, for `astro.config.mjs` and this
|
||||
* script, both of which run before TypeScript exists. Two lists is one more than
|
||||
* ideal, so they are compared here rather than trusted.
|
||||
*/
|
||||
const { DEFAULT_LOCALE: mjsDefault, LOCALE_CODES: mjsCodes } = await import(
|
||||
path.join(i18nDir, 'locales.mjs')
|
||||
);
|
||||
|
||||
if (mjsDefault !== defaultLocale || mjsCodes.join(',') !== localeCodes.join(',')) {
|
||||
console.error('check-i18n: src/i18n/config.ts and src/i18n/locales.mjs disagree.');
|
||||
console.error(` config.ts: default ${defaultLocale}, locales [${localeCodes.join(', ')}]`);
|
||||
console.error(` locales.mjs: default ${mjsDefault}, locales [${mjsCodes.join(', ')}]`);
|
||||
console.error('Both need the new language. See README, "Adding a language".');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
/* ---------- catalogs ---------- */
|
||||
|
||||
const catalogs = new Map();
|
||||
for (const code of localeCodes) {
|
||||
const file = path.join(i18nDir, `${code}.json`);
|
||||
try {
|
||||
catalogs.set(code, JSON.parse(await readFile(file, 'utf8')));
|
||||
} catch (error) {
|
||||
console.error(`check-i18n: cannot read src/i18n/${code}.json (${error.message})`);
|
||||
console.error(`Every locale in LOCALES needs a catalog. Copy en.json to ${code}.json and translate it.`);
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
const base = catalogs.get(defaultLocale);
|
||||
|
||||
/** CLDR plural categories, which are a key's suffix rather than a key of their own. */
|
||||
const PLURAL_SUFFIX = /\.(zero|one|two|few|many|other)$/;
|
||||
|
||||
/** `card.reviews.one` and `card.reviews` are the same key as far as coverage goes. */
|
||||
const stem = (key) => key.replace(PLURAL_SUFFIX, '');
|
||||
|
||||
/**
|
||||
* Every stem a catalog can resolve.
|
||||
*
|
||||
* Stems rather than literal keys, because a locale is allowed more plural categories
|
||||
* than English has: Spanish spells `time.ago.month` as `.one` and `.other` where
|
||||
* English needs one form, and neither is missing anything.
|
||||
*/
|
||||
const stems = (catalog) => new Set(Object.keys(catalog).map(stem));
|
||||
|
||||
const baseStems = stems(base);
|
||||
|
||||
const problems = [];
|
||||
const warnings = [];
|
||||
|
||||
for (const code of localeCodes) {
|
||||
if (code === defaultLocale) continue;
|
||||
const catalog = catalogs.get(code);
|
||||
const own = stems(catalog);
|
||||
|
||||
const missing = [...baseStems].filter((key) => !own.has(key)).sort();
|
||||
const unknown = [...own].filter((key) => !baseStems.has(key)).sort();
|
||||
|
||||
if (missing.length > 0) {
|
||||
(strict ? problems : warnings).push({
|
||||
code,
|
||||
kind: 'missing',
|
||||
keys: missing,
|
||||
note: `${missing.length} key(s) fall back to ${defaultLocale}`,
|
||||
});
|
||||
}
|
||||
if (unknown.length > 0) {
|
||||
problems.push({
|
||||
code,
|
||||
kind: 'unknown',
|
||||
keys: unknown,
|
||||
note: `${unknown.length} key(s) are not in ${defaultLocale}.json, so nothing reads them`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- the warning copy, which lives in two packages ---------- */
|
||||
|
||||
/*
|
||||
* `shared/src/warnings.ts` decides which banner a mint gets and writes the English
|
||||
* sentence for it, because the API's fixture tests assert on that sentence. The
|
||||
* catalogs carry the same sentences so they can be translated. Two copies of safety
|
||||
* copy is exactly the sort of thing that drifts, and a mint page and a mint card
|
||||
* disagreeing about whether withdrawals work is the worst possible thing to get wrong,
|
||||
* so the two are diffed here on every build.
|
||||
*
|
||||
* Read from shared's build output rather than its source: it is a prerequisite of the
|
||||
* web build, so it is always there, and importing it beats parsing TypeScript.
|
||||
*/
|
||||
const SHARED_TABLES = [
|
||||
{ module: 'WARNING_COPY_EN', prefix: 'mint.warnings.' },
|
||||
{ module: 'CHIP_COPY_EN', prefix: 'mint.chip.' },
|
||||
];
|
||||
|
||||
try {
|
||||
const shared = await import(path.join(root, '../shared/dist/warnings.js'));
|
||||
|
||||
for (const { module, prefix } of SHARED_TABLES) {
|
||||
const table = shared[module];
|
||||
if (!table) {
|
||||
problems.push({
|
||||
code: defaultLocale,
|
||||
kind: 'shared',
|
||||
keys: [module],
|
||||
note: `shared/dist/warnings.js no longer exports ${module}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const drift = [];
|
||||
for (const [key, english] of Object.entries(table)) {
|
||||
const catalogKey = prefix + key;
|
||||
const inCatalog = base[catalogKey];
|
||||
if (inCatalog === undefined) drift.push(`${catalogKey} (in ${module}, not in ${defaultLocale}.json)`);
|
||||
else if (inCatalog !== english) {
|
||||
drift.push(`${catalogKey}\n shared: ${english}\n catalog: ${inCatalog}`);
|
||||
}
|
||||
}
|
||||
// And the other direction: a catalog key under this prefix that shared never writes
|
||||
// is a key no banner will ever ask for.
|
||||
for (const key of Object.keys(base)) {
|
||||
if (!key.startsWith(prefix)) continue;
|
||||
if (table[key.slice(prefix.length)] === undefined) {
|
||||
drift.push(`${key} (in ${defaultLocale}.json, not in ${module})`);
|
||||
}
|
||||
}
|
||||
|
||||
if (drift.length > 0) {
|
||||
problems.push({
|
||||
code: defaultLocale,
|
||||
kind: 'shared',
|
||||
keys: drift,
|
||||
note: `${drift.length} difference(s) between shared's ${module} and ${defaultLocale}.json`,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
problems.push({
|
||||
code: defaultLocale,
|
||||
kind: 'shared',
|
||||
keys: [String(error.message)],
|
||||
note: 'could not read shared/dist/warnings.js. Run `pnpm --filter ./shared build` first',
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- key usage in the source ---------- */
|
||||
|
||||
async function walk(dir) {
|
||||
const out = [];
|
||||
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === 'i18n' || entry.name === 'bones') continue;
|
||||
out.push(...(await walk(full)));
|
||||
} else if (/\.(astro|ts)$/.test(entry.name)) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* `t('some.key')` and `t('some.key', { ... })`, with either quote.
|
||||
*
|
||||
* Only literal keys are found, which is the point: a key built at runtime cannot be
|
||||
* checked, so the codebase does not build them. The two places that legitimately need
|
||||
* a computed key (`nut.${n}` and `status.${mint.status}`) use a helper with its own
|
||||
* exhaustive list, declared below.
|
||||
*/
|
||||
const T_CALL = /\bt\(\s*(['"])([\w.-]+)\1/g;
|
||||
|
||||
/**
|
||||
* Key families built from a value at runtime, listed here so the checker knows they
|
||||
* are reached. Each entry is a prefix; every catalog key under it counts as used.
|
||||
*/
|
||||
const DYNAMIC_PREFIXES = [
|
||||
'nut.', // nutName(n)
|
||||
'status.', // statusLabel(mint.status)
|
||||
'reviews.dialog.stars.', // the star picker's phrase per rating
|
||||
'reviews.breakdown.', // ratingBreakdown, one key per star count
|
||||
'mint.warnings.', // warningStrings(), keyed by kind
|
||||
'mint.chip.', // mintChip()
|
||||
'login.method.', // methodChipHtml(session.method)
|
||||
'wallets.platform.', // the platform list on each wallet card
|
||||
'wallets.item.', // one blurb per wallet, keyed by its id
|
||||
'login.connect.', // CONNECT_COPY, keyed by method
|
||||
'time.ago.', // formatRelative, keyed by unit
|
||||
'time.short.', // formatShortDuration, keyed by unit
|
||||
];
|
||||
|
||||
const isDynamic = (key) => DYNAMIC_PREFIXES.some((prefix) => key.startsWith(prefix));
|
||||
|
||||
const files = await walk(srcDir);
|
||||
const used = new Set();
|
||||
const undefinedKeys = [];
|
||||
const clientLeaks = [];
|
||||
|
||||
for (const file of files) {
|
||||
const source = await readFile(file, 'utf8');
|
||||
const relative = path.relative(root, file);
|
||||
|
||||
/*
|
||||
* An .astro file is two programs: frontmatter and template render at build time, and
|
||||
* anything inside a <script> ships to the browser. Only the second half is limited to
|
||||
* the client namespaces, so the two are checked separately.
|
||||
*/
|
||||
const scripts = [...source.matchAll(/<script(?![^>]*\bis:inline\b)[^>]*>([\s\S]*?)<\/script>/g)]
|
||||
.map((m) => m[1])
|
||||
.join('\n');
|
||||
// A .ts file under lib/ or scripts/ is island code wholesale.
|
||||
const shipsToBrowser = /\/(lib|scripts)\//.test(relative) && relative.endsWith('.ts');
|
||||
|
||||
for (const match of source.matchAll(T_CALL)) {
|
||||
const key = match[2];
|
||||
used.add(key);
|
||||
if (!baseStems.has(stem(key))) {
|
||||
undefinedKeys.push({ key, file: relative });
|
||||
}
|
||||
}
|
||||
|
||||
const clientSource = shipsToBrowser ? source : scripts;
|
||||
for (const match of clientSource.matchAll(T_CALL)) {
|
||||
const key = match[2];
|
||||
const namespace = key.split('.')[0];
|
||||
if (!clientNamespaces.has(namespace)) {
|
||||
clientLeaks.push({ key, file: relative, namespace });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (undefinedKeys.length > 0) {
|
||||
problems.push({
|
||||
code: defaultLocale,
|
||||
kind: 'undefined',
|
||||
keys: undefinedKeys.map((u) => `${u.key} (${u.file})`),
|
||||
note: `${undefinedKeys.length} key(s) used in the source are not defined in ${defaultLocale}.json`,
|
||||
});
|
||||
}
|
||||
|
||||
if (clientLeaks.length > 0) {
|
||||
problems.push({
|
||||
code: '-',
|
||||
kind: 'client',
|
||||
keys: clientLeaks.map((c) => `${c.key} (${c.file}, namespace "${c.namespace}")`),
|
||||
note: `${clientLeaks.length} island key(s) outside CLIENT_NAMESPACES: add the namespace to config.ts or move the string`,
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- keys nothing reads ---------- */
|
||||
|
||||
const unused = [...baseStems]
|
||||
.filter((key) => !used.has(key) && !isDynamic(key))
|
||||
.filter((key) => !base[key] === false || true)
|
||||
.sort();
|
||||
|
||||
/* ---------- report ---------- */
|
||||
|
||||
const report = (list, level) => {
|
||||
for (const entry of list) {
|
||||
console.log(`\n${level} [${entry.kind}] ${entry.code}: ${entry.note}`);
|
||||
for (const key of entry.keys.slice(0, 40)) console.log(` ${key}`);
|
||||
if (entry.keys.length > 40) console.log(` … and ${entry.keys.length - 40} more`);
|
||||
}
|
||||
};
|
||||
|
||||
const total = Object.keys(base).length;
|
||||
console.log(
|
||||
`check-i18n: ${localeCodes.length} locales, ${total} keys in ${defaultLocale}.json, ` +
|
||||
`${used.size} literal key(s) used in src/.`,
|
||||
);
|
||||
|
||||
if (process.env['LIST_UNUSED'] && unused.length > 0) {
|
||||
console.log(`\n ${unused.length} key(s) with no literal or dynamic use:`);
|
||||
for (const key of unused) console.log(` ${key}`);
|
||||
}
|
||||
|
||||
report(warnings, 'WARN ');
|
||||
report(problems, 'ERROR');
|
||||
|
||||
if (problems.length > 0) {
|
||||
console.log(`\ncheck-i18n failed with ${problems.length} problem group(s).`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (warnings.length > 0) {
|
||||
console.log('\ncheck-i18n passed with warnings. Untranslated keys fall back to English.');
|
||||
} else {
|
||||
console.log('Every locale is complete and every key used in the source is defined.');
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Internal link checker for the built site.
|
||||
*
|
||||
* Walks every .html file in dist/, pulls out every href and src that stays on this
|
||||
* origin, and resolves it against what the build actually emitted. Three failures are
|
||||
* reported, all of them things a visitor would hit:
|
||||
*
|
||||
* missing the target route was never emitted (a 404 on a real link)
|
||||
* anchor the route exists but the #id it points at is not in that page
|
||||
* asset a referenced local file (css, js, image) is not in dist/
|
||||
*
|
||||
* Query strings are stripped before resolving: /mints?sort=rating is /mints. Links
|
||||
* that leave the origin (http(s)://other, mailto:, wss:) are counted and skipped.
|
||||
*
|
||||
* Usage: node scripts/check-links.mjs [distDir]
|
||||
* Exits non-zero when anything is broken, so it can gate a deploy.
|
||||
*/
|
||||
import { readdir, readFile, stat } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const dist = path.resolve(process.argv[2] ?? 'dist');
|
||||
|
||||
if (!existsSync(dist)) {
|
||||
console.error(`No such directory: ${dist}. Run \`pnpm build\` first.`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
/** Every file under dir, as paths relative to dir with forward slashes. */
|
||||
async function walk(dir, base = dir) {
|
||||
const out = [];
|
||||
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) out.push(...(await walk(full, base)));
|
||||
else out.push(path.relative(base, full).split(path.sep).join('/'));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const files = await walk(dist);
|
||||
const fileSet = new Set(files);
|
||||
const pages = files.filter((f) => f.endsWith('.html'));
|
||||
|
||||
/**
|
||||
* Routes a visitor can reach. Astro writes /mints as mints/index.html, and the site
|
||||
* runs with trailingSlash: 'ignore', so both spellings resolve.
|
||||
*/
|
||||
/*
|
||||
* `/404` counts as a route.
|
||||
*
|
||||
* It used to be skipped, because nothing linked to it and it exists to be served by
|
||||
* `error_page` rather than navigated to. The language switcher changed that: on a 404,
|
||||
* every language's version of the 404 is a real destination, and the nginx block in the
|
||||
* README resolves `/404` through its `$uri.html` rule exactly as it resolves the
|
||||
* prefixed ones through their directory index.
|
||||
*/
|
||||
const routes = new Set();
|
||||
for (const file of pages) {
|
||||
const route = file === 'index.html' ? '/' : '/' + file.replace(/\/index\.html$/, '').replace(/\.html$/, '');
|
||||
routes.add(route);
|
||||
if (route !== '/') routes.add(route + '/');
|
||||
}
|
||||
|
||||
/** #ids present in each page, so anchor links can be checked against the real markup. */
|
||||
const idsByRoute = new Map();
|
||||
const HREF = /(?:href|src)\s*=\s*"([^"]*)"/gi;
|
||||
const ASSET_EXT = /\.(css|js|mjs|json|xml|txt|png|jpe?g|gif|svg|webp|avif|ico|woff2?|ttf|otf|map|webmanifest|pdf|mp4|webm)$/i;
|
||||
const ID = /\sid\s*=\s*"([^"]+)"/gi;
|
||||
|
||||
const problems = [];
|
||||
const seen = new Map(); // target -> Set of pages linking to it
|
||||
let external = 0;
|
||||
|
||||
for (const file of pages) {
|
||||
const html = await readFile(path.join(dist, file), 'utf8');
|
||||
const route = file === 'index.html' ? '/' : '/' + file.replace(/\/index\.html$/, '').replace(/\.html$/, '');
|
||||
|
||||
const ids = new Set();
|
||||
for (const m of html.matchAll(ID)) ids.add(m[1]);
|
||||
idsByRoute.set(route, ids);
|
||||
idsByRoute.set(route + '/', ids);
|
||||
|
||||
for (const m of html.matchAll(HREF)) {
|
||||
const raw = m[1].trim();
|
||||
if (!raw || raw.startsWith('#') || raw.startsWith('data:')) continue;
|
||||
if (/^[a-z][a-z0-9+.-]*:/i.test(raw) || raw.startsWith('//')) { external++; continue; }
|
||||
if (!raw.startsWith('/')) continue; // relative asset paths: Astro does not emit any
|
||||
|
||||
const [pathPart, hashPart] = raw.split('#');
|
||||
const target = (pathPart ?? '').split('?')[0] || '/';
|
||||
|
||||
const key = hashPart ? `${target}#${hashPart}` : target;
|
||||
if (!seen.has(key)) seen.set(key, new Set());
|
||||
seen.get(key).add(route);
|
||||
|
||||
// The API serves /api/* and /icons/* in production; nothing under them is in dist.
|
||||
if (target.startsWith('/api/') || target.startsWith('/icons/')) continue;
|
||||
|
||||
const asFile = target.replace(/^\//, '');
|
||||
// Extension based, from a fixed list: mint slugs are domains, so "ends in a dot
|
||||
// and some letters" would call /mint/cashu.boats an asset and look for the file.
|
||||
const isAsset = ASSET_EXT.test(asFile);
|
||||
|
||||
if (isAsset) {
|
||||
if (!fileSet.has(asFile)) problems.push({ kind: 'asset', target, from: route });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!routes.has(target)) {
|
||||
problems.push({ kind: 'missing', target, from: route });
|
||||
continue;
|
||||
}
|
||||
if (hashPart) {
|
||||
const known = idsByRoute.get(target);
|
||||
// The target page may not be parsed yet: anchors are re-checked in a second pass.
|
||||
if (known && !known.has(hashPart)) problems.push({ kind: 'anchor', target: `${target}#${hashPart}`, from: route });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass for anchors whose target page had not been read yet on the first pass.
|
||||
for (const [key, froms] of seen) {
|
||||
if (!key.includes('#')) continue;
|
||||
const [target, hash] = key.split('#');
|
||||
if (!routes.has(target)) continue;
|
||||
const ids = idsByRoute.get(target);
|
||||
if (!ids || ids.has(hash)) continue;
|
||||
for (const from of froms) {
|
||||
if (!problems.some((p) => p.kind === 'anchor' && p.target === key && p.from === from)) {
|
||||
problems.push({ kind: 'anchor', target: key, from });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const internalTargets = [...seen.keys()].sort();
|
||||
console.log(`Checked ${pages.length} pages, ${routes.size / 1} route spellings, ${internalTargets.length} distinct internal targets (${external} external links skipped).`);
|
||||
|
||||
if (process.env['LIST_TARGETS']) {
|
||||
for (const target of internalTargets) {
|
||||
const froms = [...seen.get(target)].sort();
|
||||
const shown = froms.length > 4 ? `${froms.slice(0, 4).join(', ')} +${froms.length - 4} more` : froms.join(', ');
|
||||
console.log(` ${target.padEnd(34)} <- ${shown}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (problems.length === 0) {
|
||||
console.log('No broken internal links.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const label = { missing: 'route not emitted', anchor: 'no such id on the target page', asset: 'file not in dist' };
|
||||
console.log(`\n${problems.length} problem(s):`);
|
||||
for (const p of problems) console.log(` [${label[p.kind]}] ${p.target} linked from ${p.from}`);
|
||||
process.exit(1);
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* The build's front door to `check-i18n.mjs`.
|
||||
*
|
||||
* Imported by `astro.config.mjs` so a catalog problem is found before a single page
|
||||
* renders, rather than after a two-minute build produces 180 pages with a raw key on
|
||||
* them. Errors throw and stop the build; missing keys print and let it continue.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export async function checkCatalogs() {
|
||||
const result = spawnSync(process.execPath, [path.join(here, 'check-i18n.mjs')], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
// Warnings and the summary go straight through, so a missing-key list is visible in
|
||||
// the build log where whoever is adding a language will actually read it.
|
||||
if (result.stdout) process.stdout.write(result.stdout);
|
||||
if (result.stderr) process.stderr.write(result.stderr);
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
'check-i18n failed. Fix the keys listed above, or run `pnpm check:i18n` to see them again.',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Load the workspace .env into process.env.
|
||||
*
|
||||
* The API gets this from node's own --env-file-if-exists, but astro is launched by its
|
||||
* own binary, so the web side needs it done in-process. astro.config.mjs imports this
|
||||
* first, before it reads anything off process.env, and the build-time consumers
|
||||
* (web/src/lib/api.ts, web/src/lib/nostr-build.ts) run in the same process afterwards.
|
||||
*
|
||||
* Matching node's semantics: a variable already present in the real environment is left
|
||||
* alone, so `PORT=9000 pnpm dev` and systemd Environment= still win over the file.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
/** Repo root first, then a web-local override if someone wants one. */
|
||||
const files = [path.join(webRoot, '..', '.env'), path.join(webRoot, '.env')];
|
||||
|
||||
const LINE = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/;
|
||||
|
||||
function unquote(value) {
|
||||
const quote = value[0];
|
||||
if ((quote === '"' || quote === "'") && value.at(-1) === quote && value.length > 1) {
|
||||
const inner = value.slice(1, -1);
|
||||
return quote === '"' ? inner.replace(/\\n/g, '\n').replace(/\\"/g, '"') : inner;
|
||||
}
|
||||
// Unquoted: an inline comment ends the value.
|
||||
return value.split(/\s+#/)[0].trim();
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
let text;
|
||||
try {
|
||||
text = readFileSync(file, 'utf8');
|
||||
} catch {
|
||||
continue; // No .env is a supported setup — every variable has a default.
|
||||
}
|
||||
for (const line of text.split('\n')) {
|
||||
if (!line.trim() || line.trim().startsWith('#')) continue;
|
||||
const match = LINE.exec(line);
|
||||
if (!match) continue;
|
||||
const [, name, raw] = match;
|
||||
if (name in process.env) continue;
|
||||
process.env[name] = unquote(raw);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Generates the social card and the icon set into `public/`.
|
||||
*
|
||||
* Run by hand after a brand change; the output is committed. This is deliberately not
|
||||
* wired into `pnpm build` — the site must build on a machine with no browser, and these
|
||||
* files change roughly never.
|
||||
*
|
||||
* node scripts/make-assets.mjs
|
||||
*
|
||||
* Playwright and sharp are not dependencies of this workspace. Both arrive transitively
|
||||
* under `astro` (which ships sharp for its image pipeline) and are resolved through it,
|
||||
* the same trick `bones.mjs` relies on for its capture browser. If that ever stops
|
||||
* working, `pnpm dlx playwright@1` and `pnpm dlx sharp-cli` do the same job.
|
||||
*
|
||||
* The card is drawn in a real browser rather than assembled by hand because it uses the
|
||||
* site's own fonts and tokens: a social card that does not look like the site is worth
|
||||
* less than no card. The moai is the same rect geometry as `components/Moai.astro`.
|
||||
*/
|
||||
import { createRequire } from 'node:module';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const publicDir = path.join(root, 'public');
|
||||
const fontsDir = path.join(root, 'src', 'assets', 'fonts');
|
||||
|
||||
// astro's entry point; requiring from there reaches what pnpm hoisted under it.
|
||||
const require = createRequire(import.meta.resolve('astro'));
|
||||
|
||||
const { chromium } = require('playwright');
|
||||
const sharp = require('sharp');
|
||||
|
||||
/* The pixel moai, identical to components/Moai.astro. viewBox is 11x13. */
|
||||
const MOAI_RECTS = [
|
||||
[2, 0, 7, 1, '#C9BFA4'],
|
||||
[1, 1, 9, 2, '#C9BFA4'],
|
||||
[1, 3, 2, 7, '#A89B7C'],
|
||||
[3, 3, 6, 7, '#C9BFA4'],
|
||||
[3, 4, 2, 1, '#5C5340'],
|
||||
[7, 4, 2, 1, '#5C5340'],
|
||||
[5, 5, 1, 3, '#A89B7C'],
|
||||
[2, 10, 8, 2, '#A89B7C'],
|
||||
];
|
||||
|
||||
const moaiSvg = (width) =>
|
||||
`<svg width="${width}" height="${Math.round((width / 11) * 13)}" viewBox="0 0 11 13" ` +
|
||||
`shape-rendering="crispEdges" xmlns="http://www.w3.org/2000/svg">` +
|
||||
MOAI_RECTS.map(
|
||||
([x, y, w, h, fill]) => `<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="${fill}"/>`,
|
||||
).join('') +
|
||||
`</svg>`;
|
||||
|
||||
const fontFace = (family, weight, file) => `
|
||||
@font-face {
|
||||
font-family: '${family}';
|
||||
font-style: normal;
|
||||
font-weight: ${weight};
|
||||
src: url('file://${path.join(fontsDir, file)}') format('woff2');
|
||||
}`;
|
||||
|
||||
/* The 1200x630 card. Design tokens copied from styles/global.css. */
|
||||
const cardHtml = `<!doctype html><html><head><meta charset="utf-8"><style>
|
||||
${fontFace('Space Grotesk', 700, 'space-grotesk-latin.woff2')}
|
||||
${fontFace('Inter', 400, 'inter-latin.woff2')}
|
||||
${fontFace('JetBrains Mono', 400, 'jetbrains-mono-latin.woff2')}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
width: 1200px; height: 630px; background: #0E0D0B; color: #EDE8DC;
|
||||
font-family: 'Inter', sans-serif; display: flex; flex-direction: column;
|
||||
justify-content: center; padding: 0 88px; position: relative; overflow: hidden;
|
||||
}
|
||||
/* The same warm glow the hero carries, kept subtle so text stays legible. */
|
||||
body::after {
|
||||
content: ''; position: absolute; inset: auto -200px -320px auto;
|
||||
width: 720px; height: 720px; border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(240,169,59,.16), transparent 68%);
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 18px; margin-bottom: 40px; }
|
||||
.brand span {
|
||||
font-family: 'Space Grotesk', sans-serif; font-weight: 700; font-size: 34px;
|
||||
letter-spacing: -.01em;
|
||||
}
|
||||
h1 {
|
||||
font-family: 'Space Grotesk', sans-serif; font-weight: 700; font-size: 76px;
|
||||
line-height: 1.06; letter-spacing: -.03em; max-width: 15ch;
|
||||
}
|
||||
h1 em { font-style: normal; color: #F0A93B; }
|
||||
p { margin-top: 28px; font-size: 27px; line-height: 1.45; color: #9A927E; max-width: 34ch; }
|
||||
.rule { margin-top: 44px; width: 132px; height: 5px; background: #F0A93B; border-radius: 3px; }
|
||||
.host {
|
||||
position: absolute; bottom: 54px; right: 88px;
|
||||
font-family: 'JetBrains Mono', monospace; font-size: 21px; color: #6B6555;
|
||||
}
|
||||
</style></head><body>
|
||||
<div class="brand">${moaiSvg(40)}<span>Cashumints.space</span></div>
|
||||
<h1>Find a Cashu mint <em>you can trust</em></h1>
|
||||
<p>Every mint on the Nostr network, with community reviews you can verify yourself.</p>
|
||||
<div class="rule"></div>
|
||||
<div class="host">cashumints.space</div>
|
||||
</body></html>`;
|
||||
|
||||
/* The icon master: the moai on the site's background, generous padding. */
|
||||
const iconHtml = `<!doctype html><html><head><meta charset="utf-8"><style>
|
||||
* { margin: 0; padding: 0; }
|
||||
body {
|
||||
width: 512px; height: 512px; background: #0E0D0B;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
</style></head><body>${moaiSvg(320)}</body></html>`;
|
||||
|
||||
/**
|
||||
* A 32x32 ICO wrapping a PNG payload.
|
||||
*
|
||||
* sharp cannot write ICO and the format needs no library for this case: the container
|
||||
* is a 6-byte directory header, one 16-byte entry, then the PNG bytes verbatim. Worth
|
||||
* having because bare `/favicon.ico` requests from crawlers and feed readers still
|
||||
* happen, and a 404 there is noise in the log for no reason.
|
||||
*/
|
||||
function icoFromPng(png) {
|
||||
const header = Buffer.alloc(6);
|
||||
header.writeUInt16LE(0, 0); // reserved
|
||||
header.writeUInt16LE(1, 2); // type: icon
|
||||
header.writeUInt16LE(1, 4); // one image
|
||||
|
||||
const entry = Buffer.alloc(16);
|
||||
entry[0] = 32; // width
|
||||
entry[1] = 32; // height
|
||||
entry[2] = 0; // palette size: 0 means truecolour
|
||||
entry[3] = 0; // reserved
|
||||
entry.writeUInt16LE(1, 4); // colour planes
|
||||
entry.writeUInt16LE(32, 6); // bits per pixel
|
||||
entry.writeUInt32LE(png.length, 8);
|
||||
entry.writeUInt32LE(header.length + entry.length, 12);
|
||||
|
||||
return Buffer.concat([header, entry, png]);
|
||||
}
|
||||
|
||||
await mkdir(publicDir, { recursive: true });
|
||||
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
const page = await browser.newPage({
|
||||
viewport: { width: 1200, height: 630 },
|
||||
deviceScaleFactor: 1,
|
||||
});
|
||||
|
||||
await page.setContent(cardHtml, { waitUntil: 'load' });
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
await page.screenshot({ path: path.join(publicDir, 'og.png') });
|
||||
console.log('og.png');
|
||||
|
||||
await page.setViewportSize({ width: 512, height: 512 });
|
||||
await page.setContent(iconHtml, { waitUntil: 'load' });
|
||||
const master = await page.screenshot({ type: 'png' });
|
||||
|
||||
for (const [name, size] of [
|
||||
['icon-512.png', 512],
|
||||
['icon-192.png', 192],
|
||||
['favicon-32.png', 32],
|
||||
]) {
|
||||
await sharp(master).resize(size, size).png({ compressionLevel: 9 }).toFile(path.join(publicDir, name));
|
||||
console.log(name);
|
||||
}
|
||||
|
||||
// iOS ignores transparency and composites on white, so flatten onto the brand ground.
|
||||
await sharp(master)
|
||||
.resize(180, 180)
|
||||
.flatten({ background: '#0E0D0B' })
|
||||
.png({ compressionLevel: 9 })
|
||||
.toFile(path.join(publicDir, 'apple-touch-icon.png'));
|
||||
console.log('apple-touch-icon.png');
|
||||
|
||||
const png32 = await sharp(master).resize(32, 32).png({ compressionLevel: 9 }).toBuffer();
|
||||
await writeFile(path.join(publicDir, 'favicon.ico'), icoFromPng(png32));
|
||||
console.log('favicon.ico');
|
||||
|
||||
// The standalone SVG favicon: the same mark, at any size, for browsers that take it.
|
||||
await writeFile(path.join(publicDir, 'favicon.svg'), moaiSvg(11));
|
||||
console.log('favicon.svg');
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
{
|
||||
"breakpoints": {
|
||||
"360": {
|
||||
"name": "mint-page",
|
||||
"viewportWidth": 324,
|
||||
"width": 324,
|
||||
"height": 327,
|
||||
"bones": [
|
||||
[
|
||||
0,
|
||||
7,
|
||||
16.0494,
|
||||
52,
|
||||
13
|
||||
],
|
||||
[
|
||||
20.9877,
|
||||
0,
|
||||
59.2593,
|
||||
43,
|
||||
8
|
||||
],
|
||||
[
|
||||
20.9877,
|
||||
43,
|
||||
59.2593,
|
||||
22,
|
||||
8
|
||||
],
|
||||
[
|
||||
0,
|
||||
81,
|
||||
23.7654,
|
||||
29,
|
||||
999,
|
||||
true
|
||||
],
|
||||
[
|
||||
3.7037,
|
||||
92,
|
||||
2.1605,
|
||||
7,
|
||||
"50%"
|
||||
],
|
||||
[
|
||||
0,
|
||||
128,
|
||||
35.7301,
|
||||
17,
|
||||
8
|
||||
],
|
||||
[
|
||||
13.2716,
|
||||
159,
|
||||
22.4585,
|
||||
18,
|
||||
8
|
||||
],
|
||||
[
|
||||
43.7548,
|
||||
128,
|
||||
17.5733,
|
||||
17,
|
||||
8
|
||||
],
|
||||
[
|
||||
43.7548,
|
||||
149,
|
||||
12.037,
|
||||
31,
|
||||
8
|
||||
],
|
||||
[
|
||||
0,
|
||||
209,
|
||||
46.2963,
|
||||
17,
|
||||
8
|
||||
],
|
||||
[
|
||||
0,
|
||||
227,
|
||||
46.2963,
|
||||
21,
|
||||
8
|
||||
],
|
||||
[
|
||||
54.321,
|
||||
209,
|
||||
25.2749,
|
||||
17,
|
||||
8
|
||||
],
|
||||
[
|
||||
54.321,
|
||||
227,
|
||||
20.3704,
|
||||
21,
|
||||
8
|
||||
],
|
||||
[
|
||||
0,
|
||||
265,
|
||||
100,
|
||||
63,
|
||||
8
|
||||
]
|
||||
]
|
||||
},
|
||||
"768": {
|
||||
"name": "mint-page",
|
||||
"viewportWidth": 712,
|
||||
"width": 712,
|
||||
"height": 193,
|
||||
"bones": [
|
||||
[
|
||||
0,
|
||||
7,
|
||||
7.3034,
|
||||
52,
|
||||
13
|
||||
],
|
||||
[
|
||||
9.5506,
|
||||
0,
|
||||
26.9663,
|
||||
43,
|
||||
8
|
||||
],
|
||||
[
|
||||
9.5506,
|
||||
43,
|
||||
26.9663,
|
||||
22,
|
||||
8
|
||||
],
|
||||
[
|
||||
38.764,
|
||||
18,
|
||||
10.8146,
|
||||
29,
|
||||
999,
|
||||
true
|
||||
],
|
||||
[
|
||||
40.4494,
|
||||
29,
|
||||
0.9831,
|
||||
7,
|
||||
"50%"
|
||||
],
|
||||
[
|
||||
0,
|
||||
83,
|
||||
16.2592,
|
||||
17,
|
||||
8
|
||||
],
|
||||
[
|
||||
6.0393,
|
||||
113,
|
||||
10.2199,
|
||||
18,
|
||||
8
|
||||
],
|
||||
[
|
||||
19.9109,
|
||||
83,
|
||||
7.9968,
|
||||
17,
|
||||
8
|
||||
],
|
||||
[
|
||||
19.9109,
|
||||
103,
|
||||
5.4775,
|
||||
31,
|
||||
8
|
||||
],
|
||||
[
|
||||
31.5594,
|
||||
83,
|
||||
21.0674,
|
||||
17,
|
||||
8
|
||||
],
|
||||
[
|
||||
31.5594,
|
||||
101,
|
||||
21.0674,
|
||||
21,
|
||||
8
|
||||
],
|
||||
[
|
||||
56.2785,
|
||||
83,
|
||||
11.5015,
|
||||
17,
|
||||
8
|
||||
],
|
||||
[
|
||||
56.2785,
|
||||
101,
|
||||
9.2697,
|
||||
21,
|
||||
8
|
||||
],
|
||||
[
|
||||
0,
|
||||
151,
|
||||
100,
|
||||
42,
|
||||
8
|
||||
]
|
||||
]
|
||||
},
|
||||
"1280": {
|
||||
"name": "mint-page",
|
||||
"viewportWidth": 1184,
|
||||
"width": 1184,
|
||||
"height": 172,
|
||||
"bones": [
|
||||
[
|
||||
0,
|
||||
7,
|
||||
4.3919,
|
||||
52,
|
||||
13
|
||||
],
|
||||
[
|
||||
5.7432,
|
||||
0,
|
||||
16.2162,
|
||||
43,
|
||||
8
|
||||
],
|
||||
[
|
||||
5.7432,
|
||||
43,
|
||||
16.2162,
|
||||
22,
|
||||
8
|
||||
],
|
||||
[
|
||||
23.3108,
|
||||
18,
|
||||
6.5034,
|
||||
29,
|
||||
999,
|
||||
true
|
||||
],
|
||||
[
|
||||
24.3243,
|
||||
29,
|
||||
0.5912,
|
||||
7,
|
||||
"50%"
|
||||
],
|
||||
[
|
||||
0,
|
||||
83,
|
||||
9.7775,
|
||||
17,
|
||||
8
|
||||
],
|
||||
[
|
||||
3.6318,
|
||||
113,
|
||||
6.1457,
|
||||
18,
|
||||
8
|
||||
],
|
||||
[
|
||||
11.9734,
|
||||
83,
|
||||
4.8089,
|
||||
17,
|
||||
8
|
||||
],
|
||||
[
|
||||
11.9734,
|
||||
103,
|
||||
3.2939,
|
||||
31,
|
||||
8
|
||||
],
|
||||
[
|
||||
18.9783,
|
||||
83,
|
||||
12.6689,
|
||||
17,
|
||||
8
|
||||
],
|
||||
[
|
||||
18.9783,
|
||||
101,
|
||||
12.6689,
|
||||
21,
|
||||
8
|
||||
],
|
||||
[
|
||||
33.8432,
|
||||
83,
|
||||
6.9164,
|
||||
17,
|
||||
8
|
||||
],
|
||||
[
|
||||
33.8432,
|
||||
101,
|
||||
5.5743,
|
||||
21,
|
||||
8
|
||||
],
|
||||
[
|
||||
0,
|
||||
151,
|
||||
100,
|
||||
21,
|
||||
8
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"_hash": "f3f7f7e86a10ecca631b74af17fb0335"
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
{
|
||||
"breakpoints": {
|
||||
"360": {
|
||||
"name": "reviews-panel",
|
||||
"viewportWidth": 322,
|
||||
"width": 322,
|
||||
"height": 727,
|
||||
"bones": [
|
||||
[
|
||||
6.2112,
|
||||
20,
|
||||
11.8012,
|
||||
38,
|
||||
"50%"
|
||||
],
|
||||
[
|
||||
22.9814,
|
||||
19,
|
||||
32.6087,
|
||||
22,
|
||||
8
|
||||
],
|
||||
[
|
||||
22.9814,
|
||||
41,
|
||||
70.8075,
|
||||
16,
|
||||
5
|
||||
],
|
||||
[
|
||||
22.9814,
|
||||
70,
|
||||
20.429,
|
||||
16,
|
||||
8
|
||||
],
|
||||
[
|
||||
46.5159,
|
||||
68,
|
||||
11.8012,
|
||||
19,
|
||||
8
|
||||
],
|
||||
[
|
||||
22.9814,
|
||||
98,
|
||||
70.8075,
|
||||
116,
|
||||
8
|
||||
],
|
||||
[
|
||||
6.2112,
|
||||
255,
|
||||
11.8012,
|
||||
38,
|
||||
"50%"
|
||||
],
|
||||
[
|
||||
22.9814,
|
||||
254,
|
||||
23.6025,
|
||||
22,
|
||||
8
|
||||
],
|
||||
[
|
||||
22.9814,
|
||||
277,
|
||||
70.8075,
|
||||
16,
|
||||
5
|
||||
],
|
||||
[
|
||||
22.9814,
|
||||
305,
|
||||
20.429,
|
||||
16,
|
||||
8
|
||||
],
|
||||
[
|
||||
46.5159,
|
||||
303,
|
||||
12.1118,
|
||||
19,
|
||||
8
|
||||
],
|
||||
[
|
||||
22.9814,
|
||||
333,
|
||||
70.8075,
|
||||
116,
|
||||
8
|
||||
],
|
||||
[
|
||||
6.2112,
|
||||
490,
|
||||
11.8012,
|
||||
38,
|
||||
"50%"
|
||||
],
|
||||
[
|
||||
22.9814,
|
||||
489,
|
||||
31.0559,
|
||||
22,
|
||||
8
|
||||
],
|
||||
[
|
||||
22.9814,
|
||||
512,
|
||||
70.8075,
|
||||
16,
|
||||
5
|
||||
],
|
||||
[
|
||||
22.9814,
|
||||
540,
|
||||
20.429,
|
||||
16,
|
||||
8
|
||||
],
|
||||
[
|
||||
46.5159,
|
||||
538,
|
||||
12.7329,
|
||||
19,
|
||||
8
|
||||
],
|
||||
[
|
||||
22.9814,
|
||||
568,
|
||||
70.8075,
|
||||
139,
|
||||
8
|
||||
]
|
||||
]
|
||||
},
|
||||
"768": {
|
||||
"name": "reviews-panel",
|
||||
"viewportWidth": 710,
|
||||
"width": 710,
|
||||
"height": 455,
|
||||
"bones": [
|
||||
[
|
||||
2.8169,
|
||||
20,
|
||||
5.3521,
|
||||
38,
|
||||
"50%"
|
||||
],
|
||||
[
|
||||
10.4225,
|
||||
21,
|
||||
14.7887,
|
||||
22,
|
||||
8
|
||||
],
|
||||
[
|
||||
10.4225,
|
||||
43,
|
||||
75.2421,
|
||||
16,
|
||||
5
|
||||
],
|
||||
[
|
||||
87.9181,
|
||||
22,
|
||||
9.265,
|
||||
16,
|
||||
8
|
||||
],
|
||||
[
|
||||
91.831,
|
||||
42,
|
||||
5.3521,
|
||||
19,
|
||||
8
|
||||
],
|
||||
[
|
||||
10.4225,
|
||||
77,
|
||||
86.7606,
|
||||
46,
|
||||
8
|
||||
],
|
||||
[
|
||||
2.8169,
|
||||
164,
|
||||
5.3521,
|
||||
38,
|
||||
"50%"
|
||||
],
|
||||
[
|
||||
10.4225,
|
||||
165,
|
||||
10.7042,
|
||||
22,
|
||||
8
|
||||
],
|
||||
[
|
||||
10.4225,
|
||||
187,
|
||||
75.2421,
|
||||
16,
|
||||
5
|
||||
],
|
||||
[
|
||||
87.9181,
|
||||
166,
|
||||
9.265,
|
||||
16,
|
||||
8
|
||||
],
|
||||
[
|
||||
91.6901,
|
||||
186,
|
||||
5.493,
|
||||
19,
|
||||
8
|
||||
],
|
||||
[
|
||||
10.4225,
|
||||
221,
|
||||
86.7606,
|
||||
46,
|
||||
8
|
||||
],
|
||||
[
|
||||
2.8169,
|
||||
308,
|
||||
5.3521,
|
||||
38,
|
||||
"50%"
|
||||
],
|
||||
[
|
||||
10.4225,
|
||||
309,
|
||||
14.0845,
|
||||
22,
|
||||
8
|
||||
],
|
||||
[
|
||||
10.4225,
|
||||
331,
|
||||
75.2421,
|
||||
16,
|
||||
5
|
||||
],
|
||||
[
|
||||
87.9181,
|
||||
310,
|
||||
9.265,
|
||||
16,
|
||||
8
|
||||
],
|
||||
[
|
||||
91.4085,
|
||||
330,
|
||||
5.7746,
|
||||
19,
|
||||
8
|
||||
],
|
||||
[
|
||||
10.4225,
|
||||
365,
|
||||
86.7606,
|
||||
70,
|
||||
8
|
||||
]
|
||||
]
|
||||
},
|
||||
"1280": {
|
||||
"name": "reviews-panel",
|
||||
"viewportWidth": 784,
|
||||
"width": 784,
|
||||
"height": 431,
|
||||
"bones": [
|
||||
[
|
||||
2.551,
|
||||
20,
|
||||
4.8469,
|
||||
38,
|
||||
"50%"
|
||||
],
|
||||
[
|
||||
9.4388,
|
||||
21,
|
||||
13.3929,
|
||||
22,
|
||||
8
|
||||
],
|
||||
[
|
||||
9.4388,
|
||||
43,
|
||||
77.5789,
|
||||
16,
|
||||
5
|
||||
],
|
||||
[
|
||||
89.0585,
|
||||
22,
|
||||
8.3905,
|
||||
16,
|
||||
8
|
||||
],
|
||||
[
|
||||
92.602,
|
||||
42,
|
||||
4.8469,
|
||||
19,
|
||||
8
|
||||
],
|
||||
[
|
||||
9.4388,
|
||||
77,
|
||||
88.0102,
|
||||
46,
|
||||
8
|
||||
],
|
||||
[
|
||||
2.551,
|
||||
164,
|
||||
4.8469,
|
||||
38,
|
||||
"50%"
|
||||
],
|
||||
[
|
||||
9.4388,
|
||||
165,
|
||||
9.6939,
|
||||
22,
|
||||
8
|
||||
],
|
||||
[
|
||||
9.4388,
|
||||
187,
|
||||
77.5789,
|
||||
16,
|
||||
5
|
||||
],
|
||||
[
|
||||
89.0585,
|
||||
166,
|
||||
8.3905,
|
||||
16,
|
||||
8
|
||||
],
|
||||
[
|
||||
92.4745,
|
||||
186,
|
||||
4.9745,
|
||||
19,
|
||||
8
|
||||
],
|
||||
[
|
||||
9.4388,
|
||||
221,
|
||||
88.0102,
|
||||
46,
|
||||
8
|
||||
],
|
||||
[
|
||||
2.551,
|
||||
308,
|
||||
4.8469,
|
||||
38,
|
||||
"50%"
|
||||
],
|
||||
[
|
||||
9.4388,
|
||||
309,
|
||||
12.7551,
|
||||
22,
|
||||
8
|
||||
],
|
||||
[
|
||||
9.4388,
|
||||
331,
|
||||
77.5789,
|
||||
16,
|
||||
5
|
||||
],
|
||||
[
|
||||
89.0585,
|
||||
310,
|
||||
8.3905,
|
||||
16,
|
||||
8
|
||||
],
|
||||
[
|
||||
92.2194,
|
||||
330,
|
||||
5.2296,
|
||||
19,
|
||||
8
|
||||
],
|
||||
[
|
||||
9.4388,
|
||||
365,
|
||||
88.0102,
|
||||
46,
|
||||
8
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"_hash": "788093c50463a4ad673ece50c6db52da"
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
---
|
||||
/**
|
||||
* Island: the animated colour field behind the hero.
|
||||
*
|
||||
* The renderer is in scripts/color-bends.ts; this is the layer it draws into and the
|
||||
* fallback for everyone who never reaches it. The canvas is decorative, so it is
|
||||
* hidden from assistive technology and takes no pointer events: the hero's search box
|
||||
* sits on top of it and has to stay clickable.
|
||||
*
|
||||
* With JavaScript off, without WebGL, or in the moment before the first frame, the
|
||||
* gradient below is all there is — the same colour in the same place, holding still.
|
||||
* The hero never renders as a blank band.
|
||||
*/
|
||||
import type { ColorBendsOptions } from '../scripts/color-bends';
|
||||
|
||||
interface Props extends ColorBendsOptions {
|
||||
/** Opacity of the whole layer. The shader parameters stay as tuned; this is the mix. */
|
||||
opacity?: number;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const { opacity = 0.55, class: className, ...bends } = Astro.props;
|
||||
|
||||
/**
|
||||
* Serialised for the module script, which is a separate file and cannot read props.
|
||||
* The renderer's own defaults fill in anything not passed here.
|
||||
*/
|
||||
const config = JSON.stringify(bends);
|
||||
const first = bends.colors?.[0] ?? '#A78BFA';
|
||||
---
|
||||
|
||||
<div class={['color-bends', className].filter(Boolean).join(' ')} style={`--cb-opacity:${opacity};--cb-tint:${first}`}>
|
||||
<canvas data-color-bends data-config={config} aria-hidden="true"></canvas>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.color-bends {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
/* Decoration, behind the hero's own content and deaf to the pointer. */
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
opacity: var(--cb-opacity);
|
||||
/*
|
||||
* The field is a wash, not a panel: it fades out before it reaches any edge, so
|
||||
* there is no rectangle for the eye to find and no seam where it meets the pulse
|
||||
* strip below. Both axes, multiplied together by the two mask layers.
|
||||
*/
|
||||
-webkit-mask-image:
|
||||
linear-gradient(to bottom, transparent, #000 22%, #000 62%, transparent 100%),
|
||||
linear-gradient(to right, transparent, #000 18%, #000 82%, transparent);
|
||||
-webkit-mask-composite: source-in;
|
||||
mask-image:
|
||||
linear-gradient(to bottom, transparent, #000 22%, #000 62%, transparent 100%),
|
||||
linear-gradient(to right, transparent, #000 18%, #000 82%, transparent);
|
||||
mask-composite: intersect;
|
||||
}
|
||||
|
||||
/*
|
||||
* The base wash, under the canvas and never removed: the bands are sparse at this
|
||||
* scale and it is what fills the space between them. It doubles as the fallback,
|
||||
* which is why nothing has to be swapped in when there is no WebGL — what is left is
|
||||
* this, and it reads as a deliberate glow rather than a hole. `--cb-tint` is the
|
||||
* first colour passed to the shader, so the two agree without repeating the hex.
|
||||
*/
|
||||
.color-bends::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(120% 80% at 50% 18%, var(--cb-tint), transparent 62%);
|
||||
opacity: .5;
|
||||
}
|
||||
|
||||
.color-bends canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import { createColorBends, type ColorBendsOptions, type ColorBendsHandle } from '../scripts/color-bends';
|
||||
import { onLeave, onReady } from '../scripts/reveal';
|
||||
|
||||
const setup = () => {
|
||||
const canvas = document.querySelector<HTMLCanvasElement>('[data-color-bends]');
|
||||
if (!canvas) return;
|
||||
|
||||
let options: ColorBendsOptions = {};
|
||||
try {
|
||||
options = JSON.parse(canvas.dataset.config ?? '{}') as ColorBendsOptions;
|
||||
} catch {
|
||||
// Unreadable config is not worth a dead hero: the renderer's defaults will do.
|
||||
}
|
||||
|
||||
let handle: ColorBendsHandle | null = null;
|
||||
/*
|
||||
* One frame's delay. The hero's own entrance is the first thing that happens on
|
||||
* this page and compiling a shader on the same frame stutters it; nothing is
|
||||
* missing in the meantime, because the gradient fallback is already on screen.
|
||||
*/
|
||||
const armed = requestAnimationFrame(() => {
|
||||
handle = createColorBends(canvas, options);
|
||||
});
|
||||
|
||||
/*
|
||||
* The router replaces this page's DOM but not its JavaScript. Without this the
|
||||
* context outlives its canvas, and browsers drop the oldest live context once
|
||||
* enough of them pile up — which would blank a hero somebody navigated back to.
|
||||
*/
|
||||
onLeave(() => {
|
||||
cancelAnimationFrame(armed);
|
||||
handle?.destroy();
|
||||
handle = null;
|
||||
});
|
||||
};
|
||||
|
||||
onReady(setup);
|
||||
</script>
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
/**
|
||||
* A click to copy identifier.
|
||||
*
|
||||
* Every npub, pubkey and long key on the site renders through this, so they all
|
||||
* behave the same: shortened on screen, full value in `title`, full value on the
|
||||
* clipboard, and a "Copied" confirmation for a moment after. It is a real button, so
|
||||
* it is reachable and operable from the keyboard and takes the global focus ring.
|
||||
*
|
||||
* The island rendered equivalent is `copyableIdHtml()` in lib/client.ts, which emits
|
||||
* the same markup; one delegated handler drives both.
|
||||
*/
|
||||
import { useI18n } from '../i18n';
|
||||
import { pageLocale } from '../i18n/paths';
|
||||
|
||||
interface Props {
|
||||
/** The full value that goes on the clipboard. */
|
||||
value: string;
|
||||
/** What is shown. Defaults to the value itself. */
|
||||
label?: string;
|
||||
/**
|
||||
* Named in the accessible label: "npub", "pubkey", "Nostr address". Already
|
||||
* translated by the caller, which knows which kind of thing it is handing over.
|
||||
*/
|
||||
kind?: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const { locale } = pageLocale(Astro);
|
||||
const t = useI18n(locale);
|
||||
|
||||
const { value, label, kind = t('mint.kind.identifier'), class: className } = Astro.props;
|
||||
---
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class:list={['cid', className]}
|
||||
data-copy-id={value}
|
||||
title={value}
|
||||
aria-label={t('common.copyFull', { kind })}
|
||||
>
|
||||
<span class="cid-text">{label ?? value}</span>
|
||||
</button>
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
import LanguageSwitcher from './LanguageSwitcher.astro';
|
||||
import { localePath, useI18n } from '../i18n';
|
||||
import { pageLocale } from '../i18n/paths';
|
||||
|
||||
const year = new Date().getFullYear();
|
||||
const GITHUB = 'https://github.com/Azzamo-net/cashumints.space';
|
||||
|
||||
const { locale } = pageLocale(Astro);
|
||||
const t = useI18n(locale);
|
||||
|
||||
/**
|
||||
* Two rows: the pages people navigate to, then the ones they go looking for. The
|
||||
* disclaimer sits in the second row here and again at the bottom of every mint page,
|
||||
* which is where the decision it is about actually gets made.
|
||||
*/
|
||||
const nav = [
|
||||
{ path: '/mints', label: t('footer.allMints') },
|
||||
{ path: '/reviews', label: t('footer.allReviews') },
|
||||
{ path: '/wallets', label: t('footer.wallets') },
|
||||
{ path: '/about', label: t('footer.about') },
|
||||
];
|
||||
|
||||
const legal = [
|
||||
{ path: '/disclaimer', label: t('footer.disclaimer') },
|
||||
{ path: '/terms', label: t('footer.terms') },
|
||||
{ path: '/privacy', label: t('footer.privacy') },
|
||||
];
|
||||
|
||||
/*
|
||||
* One sentence with a link in the middle of it, so the whole thing is one string a
|
||||
* translator can reorder. The only interpolated value is markup this file wrote, and
|
||||
* the label inside it is a name, so there is nothing here that could come from a mint,
|
||||
* a relay or a reader.
|
||||
*/
|
||||
const madeBy = t('footer.madeBy', {
|
||||
azzamo: '<a href="https://azzamo.net" rel="noopener">Azzamo</a>',
|
||||
});
|
||||
---
|
||||
|
||||
<footer>
|
||||
<div class="foot-inner">
|
||||
<span>{t('footer.copyright', { year: String(year) })}</span>
|
||||
<div class="links">
|
||||
<a href={GITHUB} rel="noopener">GitHub</a>
|
||||
{nav.map((link) => <a href={localePath(link.path, locale)}>{link.label}</a>)}
|
||||
</div>
|
||||
<span class="foot-right" set:html={madeBy} />
|
||||
</div>
|
||||
<div class="foot-inner foot-legal">
|
||||
<div class="links">
|
||||
{legal.map((link) => <a href={localePath(link.path, locale)}>{link.label}</a>)}
|
||||
</div>
|
||||
<LanguageSwitcher variant="inline" />
|
||||
<span class="foot-right foot-warn">{t('footer.warning')}</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<style>
|
||||
/* The second row is quieter than the first and has no top border of its own: it
|
||||
reads as a continuation of the footer, not a second footer. */
|
||||
.foot-legal { padding-top: 0; border-top: none; font-size: 12.5px; }
|
||||
.foot-warn { color: var(--faint); }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.foot-legal { padding-top: 4px; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,225 @@
|
||||
---
|
||||
/**
|
||||
* The language switcher, in two shapes.
|
||||
*
|
||||
* `menu` is the header control: a compact button showing the current language's code,
|
||||
* opening a list of the three. `inline` is the footer's plain row of three links. Both
|
||||
* are built from the same list and the same hrefs, because they are the same control
|
||||
* and a reader who finds one and then the other should not have to work out whether
|
||||
* they do the same thing.
|
||||
*
|
||||
* Text, not flags, in both. A flag is a country: Spanish is not Spain, Dutch is not the
|
||||
* Netherlands, and English belongs to nobody in particular. Each language is written in
|
||||
* its own name, because someone looking for Nederlands is not scanning for "Dutch".
|
||||
*
|
||||
* Every link points at this same page in that language, built from the unprefixed path,
|
||||
* so this works identically on a mint page: /mint/kashu.me and /es/mint/kashu.me are
|
||||
* the same mint. There is no "switch to Spanish and land on the home page" case, which
|
||||
* is the usual failure of a switcher that only knows about the site root.
|
||||
*
|
||||
* The header shape is a `<details>` rather than a scripted dropdown. It opens, closes
|
||||
* and takes the keyboard with no JavaScript at all, which matters because the whole
|
||||
* point of this control is being able to leave a page you cannot read. The script below
|
||||
* only adds what `<details>` does not do by itself: closing on Escape and on a click
|
||||
* elsewhere.
|
||||
*/
|
||||
import { LOCALES, localePath, useI18n } from '../i18n';
|
||||
import { pageLocale } from '../i18n/paths';
|
||||
|
||||
interface Props {
|
||||
/** `menu` is the header control; `inline` is the footer row. */
|
||||
variant?: 'menu' | 'inline';
|
||||
}
|
||||
const { variant = 'inline' } = Astro.props;
|
||||
|
||||
const { locale, path } = pageLocale(Astro);
|
||||
const t = useI18n(locale);
|
||||
|
||||
const links = LOCALES.map((other) => ({
|
||||
code: other.code,
|
||||
label: other.label,
|
||||
href: localePath(path, other.code),
|
||||
current: other.code === locale,
|
||||
}));
|
||||
|
||||
/*
|
||||
* Two letters in the header, because the full name of the current language is a word
|
||||
* the reader already knows they are reading. The code is the locale's own, uppercased,
|
||||
* so a fourth language needs nothing added here.
|
||||
*/
|
||||
const currentCode = locale.toUpperCase();
|
||||
---
|
||||
|
||||
{
|
||||
variant === 'menu' ? (
|
||||
<details class="lang-menu" data-lang-menu>
|
||||
<summary aria-haspopup="true">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M3 12h18M12 3a15 15 0 0 1 4 9 15 15 0 0 1-4 9 15 15 0 0 1-4-9 15 15 0 0 1 4-9Z" />
|
||||
</svg>
|
||||
{/* The code alone does not say what it is, so the label is in the tree for a
|
||||
screen reader and out of the way for everyone else. */}
|
||||
<span class="sr-only">{t('lang.label')}</span>
|
||||
<span class="lang-code">{currentCode}</span>
|
||||
<svg class="lang-caret" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
</summary>
|
||||
|
||||
<nav class="lang-list" aria-label={t('lang.switcher')}>
|
||||
{
|
||||
links.map((other) => (
|
||||
<a
|
||||
href={other.href}
|
||||
hreflang={other.code}
|
||||
lang={other.code}
|
||||
aria-current={other.current ? 'true' : undefined}
|
||||
>
|
||||
<span>{other.label}</span>
|
||||
{other.current && (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
|
||||
<path d="m5 13 4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</nav>
|
||||
</details>
|
||||
) : (
|
||||
<nav class="lang-switch" aria-label={t('lang.switcher')}>
|
||||
{
|
||||
/*
|
||||
* The current language is still a link, and it goes where it says it goes.
|
||||
* `aria-current` is what tells a screen reader it is where you already are, and
|
||||
* the weight is what tells everyone else.
|
||||
*/
|
||||
links.map((other) => (
|
||||
<a
|
||||
href={other.href}
|
||||
hreflang={other.code}
|
||||
lang={other.code}
|
||||
aria-current={other.current ? 'true' : undefined}
|
||||
>
|
||||
{other.label}
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
<style>
|
||||
/* ---------- the header control ---------- */
|
||||
|
||||
.lang-menu { position: relative; flex-shrink: 0; }
|
||||
|
||||
.lang-menu summary {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 7px 10px; border-radius: 8px;
|
||||
color: var(--muted); font-size: 13.5px; font-weight: 500;
|
||||
cursor: pointer; list-style: none; user-select: none;
|
||||
transition: color .15s, background .15s;
|
||||
}
|
||||
/* The default disclosure triangle, in both engines that draw one. */
|
||||
.lang-menu summary::-webkit-details-marker { display: none; }
|
||||
.lang-menu summary::marker { content: ''; }
|
||||
|
||||
.lang-menu summary:hover { color: var(--text); background: var(--card2); }
|
||||
.lang-menu[open] summary { color: var(--text); background: var(--card2); }
|
||||
|
||||
.lang-code { font-family: var(--mono); font-size: 12.5px; letter-spacing: .04em; }
|
||||
.lang-caret { color: var(--faint); transition: transform var(--dur-quick) var(--ease-out); }
|
||||
.lang-menu[open] .lang-caret { transform: rotate(180deg); }
|
||||
|
||||
/* The same panel the account menu uses, so the two controls beside each other in the
|
||||
header open into the same shape. */
|
||||
.lang-list {
|
||||
position: absolute; top: calc(100% + 8px); right: 0; z-index: 60;
|
||||
min-width: 168px;
|
||||
background: var(--card); border: 1px solid var(--line); border-radius: 12px;
|
||||
padding: 6px; display: flex; flex-direction: column; gap: 2px;
|
||||
box-shadow: 0 18px 40px rgba(0, 0, 0, .45);
|
||||
}
|
||||
.lang-list a {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 8px 10px; border-radius: 8px;
|
||||
font-size: 13.5px; color: var(--muted);
|
||||
transition: color .15s, background .15s;
|
||||
}
|
||||
.lang-list a:hover { color: var(--text); background: var(--card2); }
|
||||
.lang-list a[aria-current] { color: var(--text); font-weight: 600; }
|
||||
.lang-list a svg { margin-left: auto; color: var(--amber); flex-shrink: 0; }
|
||||
|
||||
/*
|
||||
* Narrow: the globe alone.
|
||||
*
|
||||
* Below 760px the header is already dropping things (the search affordance goes at
|
||||
* the same breakpoint) and four nav items, an account control and this have to share
|
||||
* one row. The globe is the part that makes the control findable; the code and the
|
||||
* caret are the parts that only confirm what it is once you have found it. The
|
||||
* accessible name stays either way, because it was never the visible text.
|
||||
*/
|
||||
@media (max-width: 760px) {
|
||||
.lang-menu summary { padding: 7px 8px; gap: 0; }
|
||||
.lang-code,
|
||||
.lang-caret { display: none; }
|
||||
}
|
||||
@media (max-width: 420px) {
|
||||
.lang-menu summary { padding: 6px 6px; }
|
||||
}
|
||||
|
||||
/* ---------- the footer row ---------- */
|
||||
|
||||
.lang-switch { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
.lang-switch a {
|
||||
font-size: 12.5px; color: var(--faint);
|
||||
transition: color var(--dur-quick);
|
||||
}
|
||||
.lang-switch a:hover { color: var(--text); }
|
||||
.lang-switch a[aria-current] { color: var(--text); font-weight: 600; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import { onReady } from '../scripts/reveal';
|
||||
|
||||
/**
|
||||
* The two things `<details>` does not do on its own.
|
||||
*
|
||||
* Everything else about this control (open, close, keyboard, focus) is the element's
|
||||
* own behaviour and works with this script blocked, which is the point of using one.
|
||||
*
|
||||
* Attached to the document once rather than to the element, because the view
|
||||
* transition router replaces the header on every navigation and a per-element
|
||||
* listener would be re-bound, and leak, once per page visited.
|
||||
*/
|
||||
let wired = false;
|
||||
|
||||
const closeAll = (except?: Element | null): void => {
|
||||
for (const menu of document.querySelectorAll<HTMLDetailsElement>('[data-lang-menu][open]')) {
|
||||
if (menu !== except) menu.open = false;
|
||||
}
|
||||
};
|
||||
|
||||
onReady(() => {
|
||||
if (wired) return;
|
||||
wired = true;
|
||||
|
||||
document.addEventListener('click', (event) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
// A click inside the open menu is either the summary (which toggles itself) or a
|
||||
// link (which is about to navigate). Neither wants closing from here.
|
||||
closeAll(target?.closest('[data-lang-menu]'));
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key !== 'Escape') return;
|
||||
const open = document.querySelector<HTMLDetailsElement>('[data-lang-menu][open]');
|
||||
if (!open) return;
|
||||
open.open = false;
|
||||
// Back to the control that opened it, not to the top of the document.
|
||||
open.querySelector('summary')?.focus();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,173 @@
|
||||
---
|
||||
/**
|
||||
* The login popup, on every page because the header's "Log in" is on every page.
|
||||
*
|
||||
* Same shell as the review dialog and the mint QR: one modal shape on the site. It is
|
||||
* complete markup, so the only thing the script does is decide which step is visible
|
||||
* and fill in the QR once a pairing has actually started.
|
||||
*
|
||||
* Nothing here takes a key. The only text input is a connection link, and the code
|
||||
* behind it refuses anything that looks like a key before reading it.
|
||||
*/
|
||||
import { localePath, useI18n } from '../i18n';
|
||||
import { pageLocale } from '../i18n/paths';
|
||||
|
||||
const { locale } = pageLocale(Astro);
|
||||
const t = useI18n(locale);
|
||||
---
|
||||
|
||||
<dialog class="modal login-dialog" data-login-dialog aria-labelledby="login-title">
|
||||
<div class="modal-body">
|
||||
<div class="modal-head">
|
||||
<h2 id="login-title" data-connect-title>{t('login.title')}</h2>
|
||||
<button type="button" class="copy-btn" data-login-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" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="modal-note">{t('login.note')}</p>
|
||||
|
||||
{/* Step one: pick how to sign. */}
|
||||
<div data-login-step="choose">
|
||||
<ul class="login-options">
|
||||
<li>
|
||||
<button type="button" class="login-option" data-login-method="extension">
|
||||
<span class="login-icon">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
|
||||
<path d="M10 3a2 2 0 0 1 4 0v1h3a1 1 0 0 1 1 1v3h1a2 2 0 0 1 0 4h-1v3a1 1 0 0 1-1 1h-3v-1a2 2 0 0 0-4 0v1H7a1 1 0 0 1-1-1v-3H5a2 2 0 0 1 0-4h1V5a1 1 0 0 1 1-1h3Z" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="login-text">
|
||||
<span class="login-name">{t('login.extension.name')}</span>
|
||||
<span class="login-desc" data-extension-desc>{t('login.extension.desc')}</span>
|
||||
</span>
|
||||
<svg class="login-chevron" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="m9 6 6 6-6 6" />
|
||||
</svg>
|
||||
</button>
|
||||
{/* Shown only when no extension answered, so the row still leads somewhere. */}
|
||||
<p class="login-hint" data-extension-hint hidden>
|
||||
{t('login.extension.getOne')}
|
||||
<a href="https://getalby.com" rel="noopener noreferrer" target="_blank">Alby</a>
|
||||
<span aria-hidden="true"> · </span>
|
||||
<a href="https://github.com/fiatjaf/nos2x" rel="noopener noreferrer" target="_blank">nos2x</a>
|
||||
</p>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<button type="button" class="login-option" data-login-method="remote">
|
||||
<span class="login-icon">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1.5" /><rect x="14" y="3" width="7" height="7" rx="1.5" />
|
||||
<rect x="3" y="14" width="7" height="7" rx="1.5" /><path d="M14 14h3v3h-3zM20 14v3M14 20h6" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="login-text">
|
||||
<span class="login-name">{t('login.remote.name')}</span>
|
||||
<span class="login-desc">{t('login.remote.desc')}</span>
|
||||
</span>
|
||||
<svg class="login-chevron" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="m9 6 6 6-6 6" />
|
||||
</svg>
|
||||
</button>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<button type="button" class="login-option" data-login-method="primal">
|
||||
<span class="login-icon primal" aria-hidden="true">P</span>
|
||||
<span class="login-text">
|
||||
{/* A product name, not a label. */}
|
||||
<span class="login-name">{t('login.primal.name')}</span>
|
||||
<span class="login-desc">{t('login.primal.desc')}</span>
|
||||
</span>
|
||||
<svg class="login-chevron" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="m9 6 6 6-6 6" />
|
||||
</svg>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Step two: a pairing code, for the remote and Primal routes alike. */}
|
||||
<div data-login-step="connect" hidden>
|
||||
<button type="button" class="login-back" data-login-back>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</svg>
|
||||
{t('common.back')}
|
||||
</button>
|
||||
|
||||
<p class="login-lead" data-connect-lead></p>
|
||||
|
||||
<div class="login-qr" data-connect-qr></div>
|
||||
|
||||
<div class="login-uri">
|
||||
<span class="mono" data-connect-uri></span>
|
||||
<button type="button" class="copy-btn" data-connect-copy data-copy="" aria-label={t('login.copyLink')} hidden>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<rect x="9" y="9" width="12" height="12" rx="2" /><path d="M5 15V5a2 2 0 0 1 2-2h10" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="login-hint" data-connect-hint></p>
|
||||
|
||||
{/*
|
||||
Deliberately not a <form>.
|
||||
|
||||
A form here would be a form whose one field can be pasted into, and a browser
|
||||
(or Astro's router, which handles form submissions for view transitions) would
|
||||
put whatever was in it into the URL. This box only ever wants a connection
|
||||
link, but the one thing that must never reach an address bar, a history entry
|
||||
or a referrer is a key someone pasted into the wrong box. With no form there
|
||||
is no submission and no query string, ever.
|
||||
*/}
|
||||
<div class="login-paste" data-bunker-form>
|
||||
<label for="bunker-input">{t('login.paste.label')}</label>
|
||||
<div class="login-paste-row">
|
||||
<input
|
||||
id="bunker-input"
|
||||
name="bunker"
|
||||
type="text"
|
||||
inputmode="url"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
placeholder="bunker://…"
|
||||
/>
|
||||
<button type="button" class="btn" data-bunker-connect>{t('common.connect')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="login-status" data-login-status hidden role="status"></p>
|
||||
<p class="login-error" data-login-error hidden role="alert"></p>
|
||||
|
||||
<p class="login-foot"><a href={localePath('/about', locale)}>{t('login.whatIsNostr')}</a></p>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<script>
|
||||
import { initAuth, extensionAvailable } from '../lib/auth-ui';
|
||||
import { useI18n } from '../i18n/client';
|
||||
import { onReady } from '../scripts/reveal';
|
||||
|
||||
onReady(() => {
|
||||
initAuth();
|
||||
|
||||
/*
|
||||
* Whether an extension is installed is only knowable in the browser, and only
|
||||
* after it has injected itself. The option is prerendered enabled and demoted
|
||||
* here, rather than the other way round, so the common case never flickers.
|
||||
*/
|
||||
const option = document.querySelector<HTMLButtonElement>('[data-login-method="extension"]');
|
||||
const desc = document.querySelector<HTMLElement>('[data-extension-desc]');
|
||||
const hint = document.querySelector<HTMLElement>('[data-extension-hint]');
|
||||
if (option && !extensionAvailable()) {
|
||||
option.disabled = true;
|
||||
if (desc) desc.textContent = useI18n()('login.extension.none');
|
||||
if (hint) hint.hidden = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,177 @@
|
||||
---
|
||||
import { getMintWarnings, mintChip, type MintCapabilities, type MintListItem } from '@cashumints/shared';
|
||||
import { iconUrl } from '../lib/api';
|
||||
import { displayDomain, iconGradient, initials, transitionName } from '../lib/format';
|
||||
import { localePath, useI18n } from '../i18n';
|
||||
import { formatters, starString } from '../i18n/format';
|
||||
import { chipStrings, statusLabel, warningOptions } from '../i18n/mint';
|
||||
import { pageLocale } from '../i18n/paths';
|
||||
|
||||
interface Props {
|
||||
mint: MintListItem;
|
||||
rank?: number;
|
||||
/** Sentiment split, when the caller has the distribution (home page top mints). */
|
||||
sentiment?: { pos: number; neg: number };
|
||||
/**
|
||||
* Entrance delay in ms, for a grid of known size that arrives as one gesture (the
|
||||
* home page's six). The long index at /mints leaves this unset and the reveal
|
||||
* utility staggers whatever scrolls into view together instead, so a card in the
|
||||
* fortieth row is not waiting on the position of the thirty-ninth.
|
||||
*/
|
||||
revealDelay?: number;
|
||||
/**
|
||||
* NUT-04 / NUT-05 switches, read from the mint's cached info by whoever fetched it.
|
||||
* The list payload does not carry them, so a page that wants the chip reads them
|
||||
* from the detail it already has. Left off, the card simply has no chip.
|
||||
*/
|
||||
capabilities?: MintCapabilities | null;
|
||||
}
|
||||
|
||||
const { mint, rank, sentiment, revealDelay, capabilities } = Astro.props;
|
||||
|
||||
const { locale } = pageLocale(Astro);
|
||||
const t = useI18n(locale);
|
||||
const f = formatters(t);
|
||||
|
||||
const domain = displayDomain(mint.url);
|
||||
const name = mint.name ?? domain.split('/')[0] ?? domain;
|
||||
const icon = iconUrl(mint.icon);
|
||||
const offline = mint.status === 'offline';
|
||||
|
||||
/*
|
||||
Two words at most. Offline already has its own card styling, so only the states a
|
||||
glance at the status cannot reveal earn a chip: a mint that takes deposits it
|
||||
cannot pay back out, or one that has stopped moving sats in either direction. The
|
||||
mint page carries the explanation.
|
||||
*/
|
||||
const chip = capabilities
|
||||
? mintChip(
|
||||
getMintWarnings(
|
||||
{ status: mint.status, last_online: mint.last_online, capabilities },
|
||||
warningOptions(f),
|
||||
),
|
||||
chipStrings(t),
|
||||
)
|
||||
: null;
|
||||
|
||||
const status = statusLabel(mint.status, t);
|
||||
|
||||
/*
|
||||
* The mint's own name is never translated. It comes from the mint's /v1/info, and a
|
||||
* mint called "Bolverker" is called that in every language. Same for the domain, which
|
||||
* is an address.
|
||||
*/
|
||||
|
||||
// Fall back to the rating split when no distribution was passed: a mint with a 4.6
|
||||
// average is roughly 92% positive, which is what the bar is communicating.
|
||||
const bar =
|
||||
sentiment ??
|
||||
(mint.rating_avg === null
|
||||
? { pos: 0, neg: 0 }
|
||||
: {
|
||||
pos: Math.round(((mint.rating_avg - 1) / 4) * 100),
|
||||
neg: 100 - Math.round(((mint.rating_avg - 1) / 4) * 100),
|
||||
});
|
||||
---
|
||||
|
||||
{/*
|
||||
The icon and the name carry the view transition names their twins in the mint page
|
||||
header carry, so clicking a card morphs them into place instead of cross-fading the
|
||||
whole page over them.
|
||||
|
||||
They are held in data attributes rather than applied with `transition:name`, and
|
||||
scripts/reveal.ts moves the one card being clicked onto the real property. A named
|
||||
element is captured and animated separately, so naming all 58 cards on /mints would
|
||||
hand the browser 116 textures to composite on every navigation, for 114 of which the
|
||||
answer is "fade it out with everything else".
|
||||
*/}
|
||||
<a
|
||||
class:list={['mint-card', { 'is-offline': offline }]}
|
||||
href={localePath(`/mint/${mint.host}`, locale)}
|
||||
data-reveal
|
||||
data-reveal-delay={revealDelay}
|
||||
data-mint-card
|
||||
data-name={name.toLowerCase()}
|
||||
data-domain={domain.toLowerCase()}
|
||||
data-status={mint.status}
|
||||
data-score={mint.score}
|
||||
data-rating={mint.rating_avg ?? 0}
|
||||
data-reviews={mint.review_count}
|
||||
data-last-review={mint.last_review_at ?? 0}
|
||||
data-last-online={mint.last_online ?? 0}
|
||||
>
|
||||
<div class="mc-top">
|
||||
{
|
||||
icon ? (
|
||||
<img
|
||||
class="mc-icon"
|
||||
src={icon}
|
||||
alt=""
|
||||
width="42"
|
||||
height="42"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
data-vt-icon={transitionName('icon', mint.host)}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
class="mc-icon"
|
||||
style={`background:${iconGradient(domain)}`}
|
||||
aria-hidden="true"
|
||||
data-vt-icon={transitionName('icon', mint.host)}
|
||||
>
|
||||
{initials(name)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
<span class="mc-id">
|
||||
<span class="mc-name" data-vt-name={transitionName('name', mint.host)}>{name}</span>
|
||||
<span class="mc-domain">{domain}</span>
|
||||
</span>
|
||||
{rank !== undefined && <span class:list={['mc-rank', { gold: rank === 1 }]}>#{rank}</span>}
|
||||
</div>
|
||||
|
||||
<div class="mc-stats">
|
||||
{
|
||||
mint.rating_avg === null ? (
|
||||
<span class="mc-none">{t('card.noRatings')}</span>
|
||||
) : (
|
||||
<span class="mc-rating">
|
||||
{/* One decimal, with this locale's decimal separator: 4.6 and 4,6. */}
|
||||
<span class="mc-score">{f.decimal(mint.rating_avg)}</span>
|
||||
<span class="mc-stars" aria-hidden="true">{starString(mint.rating_avg)}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
<span class="mc-reviews">{t('card.reviews', { n: mint.review_count })}</span>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
Both segments live in one wrapper so the bar grows from the card's left edge as a
|
||||
single object (scaleX on the wrapper) rather than as two bars starting in two
|
||||
places. The widths themselves never animate.
|
||||
*/}
|
||||
<div class="mc-bar" aria-hidden="true">
|
||||
<span class="mc-bar-fill">
|
||||
{bar.pos > 0 && <span class="pos" style={`width:${bar.pos}%`} />}
|
||||
{bar.neg > 0 && <span class="neg" style={`width:${bar.neg}%`} />}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mc-foot">
|
||||
<span class:list={['mc-dot', mint.status]}></span>
|
||||
<span class="mc-status">{status}</span>
|
||||
{chip && <span class:list={['mc-chip', chip.severity]}>{chip.label}</span>}
|
||||
<span class="last">
|
||||
{
|
||||
offline
|
||||
? mint.last_online
|
||||
? t('card.lastSeen', { when: f.relative(mint.last_online) })
|
||||
: t('card.neverSeen')
|
||||
: mint.last_review_at
|
||||
? t('card.reviewed', { when: f.relative(mint.last_review_at) })
|
||||
: t('card.noReviews')
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
@@ -0,0 +1,144 @@
|
||||
---
|
||||
/**
|
||||
* Island: refresh the mint page's status chip, its "checked" line and its warning
|
||||
* banner from a fresh probe.
|
||||
*
|
||||
* Renders nothing. The prerendered markup already holds every value; this only
|
||||
* updates them in place, so there is no layout shift and no blank state if the API
|
||||
* is unreachable.
|
||||
*
|
||||
* The banner, the limits cell and the NUT rows are rebuilt from lib/mint-state.ts,
|
||||
* the same code that prerendered them, so a live upgrade can never leave the three
|
||||
* disagreeing with each other.
|
||||
*/
|
||||
interface Props {
|
||||
host: string;
|
||||
}
|
||||
const { host } = Astro.props;
|
||||
---
|
||||
|
||||
<span data-mint-live={host} hidden></span>
|
||||
|
||||
<script>
|
||||
import { getMintWarnings, readCapabilities, type MintWarning } from '@cashumints/shared';
|
||||
import { apiBase } from '../lib/client';
|
||||
import { bannerHtml, limitsHtml, nutRowsHtml } from '../lib/mint-state';
|
||||
import { useI18n } from '../i18n/client';
|
||||
import { formatters, type Formatters } from '../i18n/format';
|
||||
import { statusLabel, warningOptions } from '../i18n/mint';
|
||||
import { onReady, prefersReducedMotion } from '../scripts/reveal';
|
||||
import type { MintDetail } from '@cashumints/shared';
|
||||
|
||||
function remove(el: Element): void {
|
||||
el.classList.add('leaving');
|
||||
window.setTimeout(() => el.remove(), 140);
|
||||
}
|
||||
|
||||
/**
|
||||
* Put the live verdict on screen.
|
||||
*
|
||||
* Three outcomes, and only one of them is an entrance: the banner is gone (the
|
||||
* prerendered one is now false, and saying nothing beats saying something wrong),
|
||||
* the banner changed state (it arrives, and a critical one announces itself), or
|
||||
* the same banner is simply a few hours staler than the build (its text is swapped
|
||||
* with no animation, because nothing changed for the reader to notice).
|
||||
*/
|
||||
function renderBanner(mint: MintDetail, f: Formatters): void {
|
||||
const head = document.querySelector<HTMLElement>('.mint-head');
|
||||
if (!head) return;
|
||||
|
||||
const existing = document.querySelector<HTMLElement>('[data-mint-banner]');
|
||||
// Same decision the build made, in the language the page is in: a fresher probe
|
||||
// must never swap a Dutch banner for an English one.
|
||||
const next: MintWarning | undefined = getMintWarnings(mint, warningOptions(f))[0];
|
||||
|
||||
if (!next) {
|
||||
if (existing) remove(existing);
|
||||
return;
|
||||
}
|
||||
|
||||
if (existing && existing.dataset['warningKind'] === next.kind) {
|
||||
// Same story, possibly a different day count. Swap the words, move nothing.
|
||||
const quiet = document.createElement('div');
|
||||
quiet.innerHTML = bannerHtml(next);
|
||||
const paragraph = quiet.querySelector('p');
|
||||
const target = existing.querySelector('p');
|
||||
if (paragraph && target) target.replaceChildren(...paragraph.childNodes);
|
||||
return;
|
||||
}
|
||||
|
||||
const holder = document.createElement('div');
|
||||
holder.innerHTML = bannerHtml(next, { live: true });
|
||||
const banner = holder.firstElementChild as HTMLElement | null;
|
||||
if (!banner) return;
|
||||
|
||||
if (!prefersReducedMotion()) banner.classList.add('arriving');
|
||||
if (existing) existing.replaceWith(banner);
|
||||
else head.after(banner);
|
||||
}
|
||||
|
||||
/** Keep the two places that repeat the banner's facts in step with it. */
|
||||
function renderCapabilities(mint: MintDetail, f: Formatters): void {
|
||||
const caps = readCapabilities(mint.info?.nuts);
|
||||
|
||||
const limits = document.querySelector<HTMLElement>('[data-live-limits]');
|
||||
if (limits) limits.innerHTML = limitsHtml(mint.info?.nuts, caps, f);
|
||||
|
||||
// Absent when the mint has never had a NUT list cached, which the panel says in
|
||||
// words instead. Nothing to bring up to date in that case.
|
||||
const nuts = document.querySelector<HTMLElement>('[data-live-nuts]');
|
||||
if (nuts) nuts.innerHTML = nutRowsHtml(mint.nuts, caps, f);
|
||||
}
|
||||
|
||||
const setup = () => {
|
||||
const marker = document.querySelector<HTMLElement>('[data-mint-live]');
|
||||
const host = marker?.dataset['mintLive'];
|
||||
if (!host) return;
|
||||
|
||||
/*
|
||||
* Read inside setup, which `onReady` runs again after every view transition, so
|
||||
* clicking from an English mint page to a Dutch one gets a Dutch translator here
|
||||
* rather than the one this module captured on first load.
|
||||
*/
|
||||
const t = useI18n();
|
||||
const f = formatters(t);
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/api/mints/${encodeURIComponent(host)}`);
|
||||
if (!res.ok) return;
|
||||
const mint = (await res.json()) as MintDetail;
|
||||
|
||||
const chip = document.querySelector<HTMLElement>('[data-live-status]');
|
||||
const label = document.querySelector<HTMLElement>('[data-live-status-label]');
|
||||
if (chip && label) {
|
||||
chip.classList.remove('online', 'offline', 'degraded', 'unknown');
|
||||
chip.classList.add(mint.status);
|
||||
label.textContent = statusLabel(mint.status, t);
|
||||
|
||||
// One ring off the dot, once, the moment the status is confirmed live.
|
||||
const dot = chip.querySelector<HTMLElement>('.status-dot');
|
||||
if (dot && !prefersReducedMotion()) {
|
||||
dot.classList.add('ping');
|
||||
dot.addEventListener('animationend', () => dot.classList.remove('ping'), { once: true });
|
||||
}
|
||||
}
|
||||
|
||||
renderBanner(mint, f);
|
||||
renderCapabilities(mint, f);
|
||||
|
||||
const checked = document.querySelector<HTMLElement>('[data-live-checked]');
|
||||
if (checked) {
|
||||
checked.textContent =
|
||||
mint.status !== 'offline' ? t('mint.checked', { when: f.relative(mint.last_probe) })
|
||||
: mint.last_online ? t('mint.lastSeen', { when: f.relative(mint.last_online) })
|
||||
: t('mint.neverReached');
|
||||
}
|
||||
} catch {
|
||||
// API unreachable: the build-time state stays on screen rather than blanking.
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
onReady(setup);
|
||||
</script>
|
||||
@@ -0,0 +1,142 @@
|
||||
---
|
||||
/**
|
||||
* The "Mint QR" dialog: a scannable code that adds this mint to a wallet.
|
||||
*
|
||||
* The old site had this as a toggle that unfolded a QR underneath a copy button
|
||||
* (`components/cards/QRCard.tsx`). Here it is a modal instead, because the mint page
|
||||
* header is already dense and the code is only wanted for the few seconds it takes to
|
||||
* point a phone at it.
|
||||
*
|
||||
* The code is encoded at build time, so the dialog is complete markup: it opens
|
||||
* instantly and needs JavaScript only to be opened at all. Any `[data-qr-open]`
|
||||
* button on the page is a trigger, the same way `[data-write-open]` opens the review
|
||||
* dialog.
|
||||
*/
|
||||
import { qrSvg } from '../lib/qr';
|
||||
import { localePath, useI18n } from '../i18n';
|
||||
import { pageLocale } from '../i18n/paths';
|
||||
|
||||
interface Props {
|
||||
/** The mint URL, exactly as a wallet should receive it. */
|
||||
url: string;
|
||||
/** Shown in the heading. */
|
||||
name: string;
|
||||
}
|
||||
|
||||
const { url, name } = Astro.props;
|
||||
const svg = qrSvg(url);
|
||||
|
||||
const { locale } = pageLocale(Astro);
|
||||
const t = useI18n(locale);
|
||||
---
|
||||
|
||||
<dialog class="modal qr-dialog" data-qr-dialog aria-labelledby="qr-title">
|
||||
<div class="modal-body qr-body">
|
||||
<div class="modal-head qr-head">
|
||||
{/* The mint's name is data, dropped into a translated sentence. */}
|
||||
<h2 id="qr-title">{t('mint.qr.title', { name })}</h2>
|
||||
<button type="button" class="copy-btn" data-qr-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" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="qr-note">{t('mint.qr.note')}</p>
|
||||
|
||||
<div class="qr-frame">
|
||||
<Fragment set:html={svg} />
|
||||
</div>
|
||||
|
||||
<div class="qr-url">
|
||||
<span class="mono">{url}</span>
|
||||
<button class="copy-btn" data-copy={url} aria-label={t('mint.copyUrl')} type="button">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<rect x="9" y="9" width="12" height="12" rx="2" /><path d="M5 15V5a2 2 0 0 1 2-2h10" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="qr-warn">
|
||||
{t('mint.qr.warn')} <a href={localePath('/disclaimer', locale)}>{t('mint.qr.whatMeans')}</a>
|
||||
<span aria-hidden="true"> · </span>
|
||||
<a href={localePath('/wallets', locale)}>{t('mint.qr.wallets')}</a>
|
||||
</p>
|
||||
|
||||
<div class="qr-actions">
|
||||
<button type="button" class="btn" data-copy={url}>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<rect x="9" y="9" width="12" height="12" rx="2" /><path d="M5 15V5a2 2 0 0 1 2-2h10" />
|
||||
</svg>
|
||||
{t('mint.copyUrl')}
|
||||
</button>
|
||||
<button type="button" class="btn primary" data-qr-close>{t('common.done')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
/* The shell, the entrance and the head are the site's one modal (.modal, in
|
||||
global.css). Only the width and what is inside it are this dialog's own. */
|
||||
.qr-dialog { max-width: 400px; }
|
||||
.qr-note { font-size: 13px; color: var(--muted); line-height: 1.55; }
|
||||
|
||||
/*
|
||||
* The white plate is the quiet zone a camera needs, so it stays white in every
|
||||
* theme and never inherits the page's paper. The code itself is square and sized by
|
||||
* the plate, which is why the SVG is width/height 100%.
|
||||
*/
|
||||
.qr-frame {
|
||||
background: #FFFFFF; border-radius: 12px; padding: 12px;
|
||||
align-self: center; width: 236px; max-width: 100%;
|
||||
aspect-ratio: 1; box-sizing: border-box;
|
||||
}
|
||||
.qr-frame :global(svg) { display: block; }
|
||||
|
||||
.qr-url {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
background: var(--card2); border: 1px solid var(--line-soft);
|
||||
border-radius: 9px; padding: 9px 11px;
|
||||
font-size: 12.5px; color: var(--muted); overflow-wrap: anywhere;
|
||||
}
|
||||
.qr-url .mono { flex: 1; font-family: var(--mono); }
|
||||
.qr-url .copy-btn { flex-shrink: 0; }
|
||||
|
||||
.qr-warn { font-size: 12px; color: var(--faint); line-height: 1.55; }
|
||||
/* Neither link breaks across lines: half a link on each row reads as two links. */
|
||||
.qr-warn a { color: var(--muted); border-bottom: 1px dotted var(--faint); white-space: nowrap; }
|
||||
.qr-warn a:hover { color: var(--amber); }
|
||||
|
||||
.qr-actions { display: flex; gap: 10px; justify-content: flex-end; flex-wrap: wrap; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import { wireCopyButtons } from '../lib/client';
|
||||
import { closeModal, openModal, wireModal } from '../lib/modal';
|
||||
import { onReady } from '../scripts/reveal';
|
||||
|
||||
onReady(() => {
|
||||
const dialog = document.querySelector<HTMLDialogElement>('[data-qr-dialog]');
|
||||
if (!dialog) return;
|
||||
|
||||
// Backdrop click, Escape and the page lock behind it all come from lib/modal.ts,
|
||||
// which every dialog on the site shares.
|
||||
wireModal(dialog);
|
||||
|
||||
for (const button of document.querySelectorAll('[data-qr-close]')) {
|
||||
button.addEventListener('click', () => closeModal(dialog));
|
||||
}
|
||||
|
||||
for (const trigger of document.querySelectorAll('[data-qr-open]')) {
|
||||
trigger.addEventListener('click', () => {
|
||||
openModal(dialog);
|
||||
// Escape already closes it; give the keyboard somewhere to land first.
|
||||
dialog.querySelector<HTMLButtonElement>('[data-qr-close]')?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
// The two copy buttons inside the dialog are wired here rather than by the page,
|
||||
// so the dialog works wherever it is dropped in.
|
||||
wireCopyButtons(dialog);
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
/** The pixel moai. Crisp edges, ~22px, never animated (DESIGN.md). */
|
||||
interface Props {
|
||||
size?: number;
|
||||
}
|
||||
const { size = 22 } = Astro.props;
|
||||
const height = Math.round((size / 11) * 13);
|
||||
---
|
||||
|
||||
<svg
|
||||
width={size}
|
||||
height={height}
|
||||
viewBox="0 0 11 13"
|
||||
shape-rendering="crispEdges"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<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>
|
||||
@@ -0,0 +1,175 @@
|
||||
---
|
||||
/**
|
||||
* Island: the pulse strip under the hero.
|
||||
*
|
||||
* Prerendered with build-time values so it is never blank and never shifts layout,
|
||||
* then refreshed in one round trip from /api/stats (+ /api/health for the relay dot).
|
||||
*/
|
||||
import type { Health, Stats } from '@cashumints/shared';
|
||||
import { useI18n } from '../i18n';
|
||||
import { formatters } from '../i18n/format';
|
||||
import { pageLocale } from '../i18n/paths';
|
||||
|
||||
interface Props {
|
||||
stats: Stats;
|
||||
health: Health | null;
|
||||
}
|
||||
const { stats, health } = Astro.props;
|
||||
|
||||
const { locale } = pageLocale(Astro);
|
||||
const t = useI18n(locale);
|
||||
const f = formatters(t);
|
||||
|
||||
const indexerOk = health?.status === 'ok';
|
||||
---
|
||||
|
||||
<div class="pulse" data-pulse data-reveal>
|
||||
<div class="pulse-inner">
|
||||
<div class="pulse-item">
|
||||
<span class="pulse-dot" data-pulse-dot></span>
|
||||
<b data-pulse="mints_total">{f.number(stats.mints_total)}</b> {t('pulse.mintsIndexed')}
|
||||
</div>
|
||||
<div class="pulse-item">
|
||||
<b data-pulse="reviews_total">{f.number(stats.reviews_total)}</b> {t('pulse.reviewsOnNostr')}
|
||||
</div>
|
||||
<div class="pulse-item">
|
||||
{t('pulse.lastReview')} <b data-pulse="last_review">{f.relative(stats.last_review_at)}</b>
|
||||
</div>
|
||||
<div class="pulse-item">
|
||||
<b data-pulse="mints_online">{f.number(stats.mints_online)}</b> {t('pulse.mintsOnline')}
|
||||
</div>
|
||||
<div class="pulse-right">
|
||||
{t('pulse.indexer')}
|
||||
<span data-pulse-status>{`● ${indexerOk ? t('pulse.connected') : t('pulse.stale')}`}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.pulse {
|
||||
border-top: 1px solid var(--line-soft);
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
background: var(--card);
|
||||
}
|
||||
.pulse-inner {
|
||||
max-width: var(--page-max);
|
||||
margin: 0 auto;
|
||||
padding: 13px var(--page-pad);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 26px;
|
||||
flex-wrap: wrap;
|
||||
font-family: var(--mono);
|
||||
font-size: 12.5px;
|
||||
color: var(--faint);
|
||||
}
|
||||
.pulse-item { display: flex; align-items: center; gap: 8px; }
|
||||
/*
|
||||
* Tabular figures: these numbers count up and are replaced live, and proportional
|
||||
* digits would jitter the whole strip's width while they did it.
|
||||
*/
|
||||
.pulse-item b {
|
||||
color: var(--text); font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
display: inline-block;
|
||||
}
|
||||
.pulse-dot {
|
||||
width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0;
|
||||
background: var(--mint);
|
||||
box-shadow: 0 0 8px rgba(95, 214, 143, .7);
|
||||
}
|
||||
/* The one allowed flourish (DESIGN.md), gated on reduced motion below. */
|
||||
.pulse-dot { animation: pulse-glow 2.4s ease-in-out infinite; }
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% { box-shadow: 0 0 6px rgba(95, 214, 143, .5); }
|
||||
50% { box-shadow: 0 0 11px rgba(95, 214, 143, .9); }
|
||||
}
|
||||
.pulse-dot.stale { background: var(--amber); box-shadow: 0 0 8px rgba(240, 169, 59, .7); animation: none; }
|
||||
.pulse-right { margin-left: auto; color: var(--faint); }
|
||||
.pulse-right span { color: var(--mint); }
|
||||
.pulse-right span.stale { color: var(--amber); }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.pulse-right { display: none; }
|
||||
.pulse-inner { gap: 16px; font-size: 12px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import { apiBase } from '../lib/client';
|
||||
import { useI18n } from '../i18n/client';
|
||||
import { formatters } from '../i18n/format';
|
||||
import { countUp, onReady, swapText, whenRevealed } from '../scripts/reveal';
|
||||
import type { Health, Stats } from '@cashumints/shared';
|
||||
|
||||
const setup = () => {
|
||||
const root = document.querySelector<HTMLElement>('[data-pulse]');
|
||||
if (!root) return;
|
||||
|
||||
// Re-read on every arrival: the strip is on the home page in three languages.
|
||||
const t = useI18n();
|
||||
const f = formatters(t);
|
||||
|
||||
/*
|
||||
* A value that changed under the reader dips and rises 4px, so a number that
|
||||
* moved is visibly a different number and not a redraw. The first render is the
|
||||
* count-up below, not this.
|
||||
*/
|
||||
const set = (key: string, value: string) => {
|
||||
const el = root.querySelector<HTMLElement>(`[data-pulse="${key}"]`);
|
||||
if (el) swapText(el, value);
|
||||
};
|
||||
|
||||
/*
|
||||
* The three counts run up from zero when the strip arrives. Their targets are
|
||||
* read back out of the prerendered markup, which is the build-time truth and
|
||||
* what stays on screen if the API cannot be reached.
|
||||
*/
|
||||
whenRevealed(root, () => {
|
||||
for (const el of root.querySelectorAll<HTMLElement>('b[data-pulse]')) {
|
||||
const text = el.textContent?.trim() ?? '';
|
||||
/*
|
||||
* "last review" holds a duration, not a count, and only real numbers count up.
|
||||
* The separator is the locale's: 1,247 in English and 1.247 in Spanish and
|
||||
* Dutch, so both are stripped before the value is read back.
|
||||
*/
|
||||
if (!/^[\d.,\u202f\u00a0]+$/.test(text)) continue;
|
||||
const target = Number(text.replace(/[.,\u202f\u00a0]/g, ''));
|
||||
if (Number.isFinite(target) && target > 0) countUp(el, target);
|
||||
}
|
||||
});
|
||||
|
||||
const refresh = async () => {
|
||||
try {
|
||||
// Two cheap endpoints, fired together: one round trip's worth of latency.
|
||||
const [statsRes, healthRes] = await Promise.all([
|
||||
fetch(`${apiBase}/api/stats`),
|
||||
fetch(`${apiBase}/api/health`),
|
||||
]);
|
||||
if (!statsRes.ok) return;
|
||||
|
||||
const stats = (await statsRes.json()) as Stats;
|
||||
set('mints_total', f.number(stats.mints_total));
|
||||
set('reviews_total', f.number(stats.reviews_total));
|
||||
set('mints_online', f.number(stats.mints_online));
|
||||
set('last_review', f.relative(stats.last_review_at));
|
||||
|
||||
// /api/health answers 503 when the indexer is stale, which is still a body.
|
||||
const health = (await healthRes.json().catch(() => null)) as Health | null;
|
||||
const stale = health?.status !== 'ok';
|
||||
const dot = root.querySelector('[data-pulse-dot]');
|
||||
const label = root.querySelector('[data-pulse-status]');
|
||||
dot?.classList.toggle('stale', stale);
|
||||
label?.classList.toggle('stale', stale);
|
||||
if (label) label.textContent = `● ${stale ? t('pulse.stale') : t('pulse.connected')}`;
|
||||
} catch {
|
||||
// API unreachable at runtime: the prerendered numbers stay on screen.
|
||||
// Never blank a section that rendered at build time (FRONTEND.md).
|
||||
}
|
||||
};
|
||||
|
||||
void refresh();
|
||||
};
|
||||
|
||||
onReady(setup);
|
||||
</script>
|
||||
@@ -0,0 +1,991 @@
|
||||
---
|
||||
/**
|
||||
* Island: the reviews panel.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
import type { MintDetail } from '@cashumints/shared';
|
||||
import { renderBones } from 'boneyard-js';
|
||||
import reviewsBones from '../bones/reviews-panel.bones.json';
|
||||
import { BONE_COLOR } from '../lib/skeleton';
|
||||
import { useI18n } from '../i18n';
|
||||
import { formatters } from '../i18n/format';
|
||||
import { pageLocale } from '../i18n/paths';
|
||||
|
||||
interface Props {
|
||||
mint: MintDetail;
|
||||
}
|
||||
const { mint } = Astro.props;
|
||||
|
||||
const { locale } = pageLocale(Astro);
|
||||
const t = useI18n(locale);
|
||||
const f = formatters(t);
|
||||
|
||||
const dist = mint.rating_distribution;
|
||||
const critical = dist['1'] + dist['2'];
|
||||
const fiveStar = dist['5'];
|
||||
|
||||
/**
|
||||
* Bones are drawn into the prerendered HTML, not by the island.
|
||||
*
|
||||
* `renderBones` is a pure string builder, so it runs here at build time just as well
|
||||
* as in the browser. Doing it here means the skeleton is on screen at first paint
|
||||
* instead of after the island's chunk has downloaded, which on a slow connection is
|
||||
* the difference between a skeleton and a second of loading text followed by a jump.
|
||||
*
|
||||
* One block per captured breakpoint, picked by media query in global.css, because
|
||||
* bone heights differ per width and the server cannot know the viewport.
|
||||
*/
|
||||
const boneBlocks = Object.entries(reviewsBones.breakpoints)
|
||||
.map(([width, skeleton]) => ({ width: Number(width), html: renderBones(skeleton, BONE_COLOR, false) }))
|
||||
.sort((a, b) => a.width - b.width);
|
||||
|
||||
// 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;
|
||||
|
||||
/**
|
||||
* Without JavaScript the relays are never queried, so the bones would sit there for
|
||||
* good. Drop them and say what is actually going on.
|
||||
*
|
||||
* Emitted as raw HTML on purpose: Astro hoists every <style> it parses out of the
|
||||
* template and into the page's stylesheet, which would hide the bones for everyone.
|
||||
*/
|
||||
const noscriptFallback =
|
||||
`<noscript><style>[data-review-items] .by-bones{display:none}</style>` +
|
||||
`<p class="rev-status">${escapeText(t('reviews.noscript'))}</p></noscript>`;
|
||||
|
||||
/** This block goes in as raw HTML, so the one translated string in it is escaped. */
|
||||
function escapeText(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
---
|
||||
|
||||
{/*
|
||||
The island's root is `data-reviews-panel`, not `data-reviews`: a mint card already
|
||||
carries that one, as its review count, and this island now looks for its root on
|
||||
every page the router brings in, not only on a mint page.
|
||||
*/}
|
||||
<section
|
||||
class="panel"
|
||||
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}
|
||||
>
|
||||
<div class="panel-head">
|
||||
<h2 class="panel-title" id="reviews-title">{t('reviews.title')}</h2>
|
||||
{/* A protocol name, not a label: NIP-87 is NIP-87 in every language. */}
|
||||
<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>
|
||||
</button>
|
||||
<button class="filter" type="button" data-filter="5" aria-pressed="false">
|
||||
{t('reviews.filter.five')} · <span data-count="5">{f.number(fiveStar)}</span>
|
||||
</button>
|
||||
<button class="filter" type="button" data-filter="critical" aria-pressed="false">
|
||||
{t('reviews.filter.critical')} · <span data-count="critical">{f.number(critical)}</span>
|
||||
</button>
|
||||
<button class="filter" type="button" data-filter="recent" aria-pressed="false">
|
||||
{t('reviews.filter.recent')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
Filled by the island with one line summarising every review that carried a rating
|
||||
and no words. Empty (and invisible) until the relays answer, and on a mint where
|
||||
every review has a body it stays empty.
|
||||
*/}
|
||||
<div data-rating-summary></div>
|
||||
|
||||
{/*
|
||||
Only the panel body is skeletoned: the header, badge, filter chips and their
|
||||
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-boneyard="reviews-panel" data-boneyard-config='{"excludeSelectors":[".sr-only"]}'>
|
||||
<div data-review-items>
|
||||
{
|
||||
showBoneBlocks ? (
|
||||
<Fragment>
|
||||
<div class="by-bones by-shimmer" aria-hidden="true">
|
||||
{boneBlocks.map((block) => (
|
||||
<div class="by-bp" data-by-bp={block.width} set:html={block.html} />
|
||||
))}
|
||||
</div>
|
||||
<p class="sr-only" role="status">
|
||||
{t('reviews.loading', { n: mint.review_count })}
|
||||
</p>
|
||||
<Fragment set:html={noscriptFallback} />
|
||||
</Fragment>
|
||||
) : (
|
||||
<p class="rev-status" data-review-status>
|
||||
{
|
||||
mint.review_count === 0
|
||||
? t('reviews.empty')
|
||||
: `${t('reviews.loading', { n: mint.review_count })}…`
|
||||
}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="load-row" data-pager hidden>
|
||||
<div class="pager" role="group" aria-label={t('common.reviewPages')}></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!--
|
||||
The write review dialog.
|
||||
|
||||
A modal in the site's one modal shell (.modal, see global.css): centred, backdrop
|
||||
blurred, page locked behind it, focus trapped by the browser because it is a real
|
||||
<dialog> opened with showModal().
|
||||
|
||||
Everything except the identity row is prerendered. The identity row cannot be: who
|
||||
is signing is only knowable in the browser, and asking a NIP-07 extension for a
|
||||
pubkey is a question worth asking once the reader has opened the dialog, not on
|
||||
every page load.
|
||||
-->
|
||||
<dialog class="modal write-dialog" data-write-dialog aria-labelledby="write-title">
|
||||
<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>
|
||||
<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" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="modal-note">{t('reviews.dialog.note')}</p>
|
||||
|
||||
<div class="write-id" data-write-identity></div>
|
||||
|
||||
<fieldset class="star-picker" data-star-picker>
|
||||
<legend class="sr-only">{t('reviews.dialog.ratingLegend')}</legend>
|
||||
{
|
||||
[1, 2, 3, 4, 5].map((value) => (
|
||||
<label class="star-option">
|
||||
<input type="radio" name="rating" value={value} required />
|
||||
<span aria-hidden="true">★</span>
|
||||
<span class="sr-only">{t('reviews.dialog.starsSr', { n: value })}</span>
|
||||
</label>
|
||||
))
|
||||
}
|
||||
<span class="star-label" data-star-label aria-live="polite">
|
||||
{t('reviews.dialog.chooseRating')}
|
||||
</span>
|
||||
</fieldset>
|
||||
|
||||
<label class="write-label" for="review-body">{t('reviews.dialog.bodyLabel')}</label>
|
||||
<div class="write-box">
|
||||
<textarea
|
||||
id="review-body"
|
||||
name="content"
|
||||
rows="5"
|
||||
aria-describedby="review-count"
|
||||
placeholder={t('reviews.dialog.bodyPlaceholder')}></textarea>
|
||||
<span class="write-count" id="review-count" data-char-count>
|
||||
{t('reviews.dialog.charCount', { n: 0, max: 2000 })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="write-status" data-write-status hidden role="status"></p>
|
||||
<p class="write-error" data-write-error hidden role="alert"></p>
|
||||
|
||||
<div class="write-actions">
|
||||
<span class="write-why" data-write-why></span>
|
||||
<button type="button" class="btn" data-write-close>{t('common.cancel')}</button>
|
||||
<button type="submit" class="btn primary" data-write-submit disabled>
|
||||
<span class="write-spinner" aria-hidden="true"></span>
|
||||
<span data-write-submit-label>{t('reviews.dialog.submit')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
/* The write dialog's own styles live in global.css beside the other modals: the
|
||||
identity row and the status line are injected by the island, and Astro's scoped
|
||||
selectors do not reach markup added through innerHTML. */
|
||||
|
||||
.load-row { padding: 16px 20px; display: flex; justify-content: center; border-top: 1px solid var(--line-soft); }
|
||||
.pager { display: flex; gap: 6px; align-items: center; font-size: 13.5px; flex-wrap: wrap; justify-content: center; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import { escapeHtml, wireCopyableIds } from '../lib/client';
|
||||
import { useI18n } from '../i18n/client';
|
||||
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 { 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';
|
||||
import { closeModal, openModal, wireModal } from '../lib/modal';
|
||||
import type { Profile } from '@cashumints/shared';
|
||||
// The card itself lives in one place: /reviews renders the identical markup from
|
||||
// the same functions.
|
||||
import { applyProfiles, isWritten, reviewHtml, summaryHtml } from '../lib/review-cards';
|
||||
import { adoptBones, armBuildSnapshot, clearBones, isBuildMode, showBones, swapBones } from '../lib/skeleton';
|
||||
import reviewsBones from '../bones/reviews-panel.bones.json';
|
||||
|
||||
const setup = () => {
|
||||
wireCopyableIds();
|
||||
|
||||
/*
|
||||
* Built here, not at module scope. `onReady` runs this again after every view
|
||||
* transition, so a reader who clicks from an English mint page to a Dutch one gets a
|
||||
* panel whose filters, empty states and dialog are all in the language they just
|
||||
* asked for, rather than the one the module happened to load in.
|
||||
*/
|
||||
const t = useI18n();
|
||||
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;
|
||||
const listWrap = root.querySelector<HTMLElement>('[data-review-list]')!;
|
||||
const list = root.querySelector<HTMLElement>('[data-review-items]')!;
|
||||
const summarySlot = root.querySelector<HTMLElement>('[data-rating-summary]')!;
|
||||
const pagerWrap = root.querySelector<HTMLElement>('[data-pager]')!;
|
||||
const pager = pagerWrap.querySelector<HTMLElement>('.pager')!;
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
/** Relays get this long to answer before the bones give way to the error state. */
|
||||
const RELAY_TIMEOUT_MS = 8000;
|
||||
/** How many reviews the API counted at build time. Drives whether bones are worth showing. */
|
||||
const expected = Number(listWrap.dataset['reviewCount'] ?? '0');
|
||||
|
||||
let all: LoadedReview[] = [];
|
||||
let filter: 'all' | '5' | 'critical' | 'recent' = 'all';
|
||||
let page = 1;
|
||||
/** True while bones are on screen, so the first real render cross-fades over them. */
|
||||
let bonesUp = false;
|
||||
/**
|
||||
* A scheduled repaint, while the outgoing list is fading.
|
||||
*
|
||||
* At most one is ever in flight: a filter changed twice in 120ms does not queue a
|
||||
* second fade, it lands on whichever filter is set when the timer runs, because
|
||||
* the paint reads the current state rather than a captured one.
|
||||
*/
|
||||
let pendingPaint = 0;
|
||||
/** Set for one paint after publishing, to mark the new card as it goes in. */
|
||||
let markNewest = false;
|
||||
/**
|
||||
* Reviews published in this session, shown at the top until relays echo them back.
|
||||
*
|
||||
* Keyed by event id, holding how many relays took it: a review that only two of
|
||||
* four relays accepted says so on its card, because that is a thing the author
|
||||
* may want to act on.
|
||||
*/
|
||||
const optimistic = new Map<string, { accepted: number; total: number }>();
|
||||
|
||||
/* ---------- rendering ---------- */
|
||||
|
||||
function filtered(): LoadedReview[] {
|
||||
return filter === '5' ? all.filter((r) => r.rating === 5)
|
||||
: filter === 'critical' ? all.filter((r) => r.rating !== null && r.rating <= 2)
|
||||
: filter === 'recent' ? all.filter((r) => r.created_at > Date.now() / 1000 - 90 * 86400)
|
||||
: all;
|
||||
}
|
||||
|
||||
/* ---------- rating only summary ---------- */
|
||||
|
||||
/**
|
||||
* One row for every rating with no comment, collapsed by default. The filter
|
||||
* counts above still count these; this only decides how much room they take.
|
||||
*/
|
||||
function renderSummary(): void {
|
||||
const rows = filtered().filter((review) => !isWritten(review));
|
||||
const open = summarySlot.querySelector('details')?.open ?? false;
|
||||
summarySlot.innerHTML = summaryHtml(rows, f, open);
|
||||
}
|
||||
|
||||
function renderInto(target: HTMLElement): void {
|
||||
const rows = filtered().filter(isWritten);
|
||||
|
||||
if (rows.length === 0) {
|
||||
// With ratings but no words, "no reviews yet" would be false: the summary row
|
||||
// above is showing them. Say what is actually missing instead.
|
||||
const message =
|
||||
all.length === 0 ? t('reviews.empty')
|
||||
: filtered().length > 0 ? t('reviews.emptyWritten')
|
||||
: t('reviews.emptyFilter');
|
||||
target.innerHTML = `<p class="rev-status">${escapeHtml(message)}</p>`;
|
||||
pagerWrap.hidden = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const pages = Math.ceil(rows.length / PAGE_SIZE);
|
||||
page = Math.min(page, pages);
|
||||
const slice = rows.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
||||
target.innerHTML = slice
|
||||
.map((review) =>
|
||||
reviewHtml(review, f, {
|
||||
propagating: optimistic.has(review.id),
|
||||
publishedTo: optimistic.get(review.id) ?? null,
|
||||
}),
|
||||
)
|
||||
.join('');
|
||||
|
||||
pagerWrap.hidden = pages <= 1;
|
||||
if (pages > 1) {
|
||||
const button = (label: string, target: number, current: boolean, aria?: string) =>
|
||||
`<button class="pg${current ? ' on' : ''}" type="button" data-page="${target}"${
|
||||
aria ? ` aria-label="${aria}"` : ''
|
||||
}${current ? ' aria-current="true"' : ''}>${label}</button>`;
|
||||
|
||||
// The chevrons are glyphs, not words. Only their labels are translated.
|
||||
const parts = [button('‹', Math.max(1, page - 1), false, t('common.previousPage'))];
|
||||
for (let p = 1; p <= pages; p++) parts.push(button(String(p), p, p === page));
|
||||
parts.push(button('›', Math.min(pages, page + 1), false, t('common.nextPage')));
|
||||
pager.innerHTML = parts.join('');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw the list. The first call after the relays answer hands over from the
|
||||
* bones; every later one redraws in place, because by then the reviews are
|
||||
* already here and there is nothing to wait for.
|
||||
*/
|
||||
function paint(): void {
|
||||
renderSummary();
|
||||
if (bonesUp) {
|
||||
bonesUp = false;
|
||||
// swapBones staggers the rows in as the bones fade off them.
|
||||
swapBones(list, renderInto);
|
||||
} else {
|
||||
renderInto(list);
|
||||
enterStagger([...list.children] as HTMLElement[]);
|
||||
}
|
||||
if (markNewest) {
|
||||
markNewest = false;
|
||||
// Published in this session: a soft amber edge for a second and a half, then
|
||||
// it is an ordinary review like every other one.
|
||||
list.querySelector<HTMLElement>('.review')?.classList.add('is-new');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Repaint, optionally letting the outgoing list fade out first.
|
||||
*
|
||||
* `animate` is for a change the reader made (a filter, a page): the old rows fade
|
||||
* out in 120ms and the new ones rise in staggered behind them, under 350ms in
|
||||
* total. Only one repaint is ever scheduled, and it draws whatever the state is
|
||||
* when it runs, so hammering the filters cannot stack or queue animations.
|
||||
*/
|
||||
function render(animate = false): void {
|
||||
if (pendingPaint) return;
|
||||
if (!animate || bonesUp || prefersReducedMotion()) {
|
||||
paint();
|
||||
return;
|
||||
}
|
||||
list.classList.add('leaving');
|
||||
pendingPaint = window.setTimeout(() => {
|
||||
pendingPaint = 0;
|
||||
list.classList.remove('leaving');
|
||||
paint();
|
||||
}, 120);
|
||||
}
|
||||
|
||||
const patchProfiles = (): void =>
|
||||
applyProfiles(root!, f, (pubkey) => all.find((r) => r.pubkey === pubkey));
|
||||
|
||||
function updateCounts(): void {
|
||||
const set = (key: string, value: number) => {
|
||||
const el = root!.querySelector(`[data-count="${key}"]`);
|
||||
if (el) el.textContent = f.number(value);
|
||||
};
|
||||
// Counts cover every review, written or not, so the filters stay honest about
|
||||
// how many ratings a mint has even though most of them render as one line.
|
||||
set('all', all.length);
|
||||
set('5', all.filter((r) => r.rating === 5).length);
|
||||
set('critical', all.filter((r) => r.rating !== null && r.rating <= 2).length);
|
||||
}
|
||||
|
||||
/* ---------- loading ---------- */
|
||||
|
||||
/** Bones never spin forever: past this the reader gets an error and a way out. */
|
||||
function withTimeout<T>(work: Promise<T>, ms: number): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = window.setTimeout(() => reject(new Error('relay-timeout')), ms);
|
||||
work.then(
|
||||
(value) => {
|
||||
window.clearTimeout(timer);
|
||||
resolve(value);
|
||||
},
|
||||
(error: unknown) => {
|
||||
window.clearTimeout(timer);
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function showRelayError(): void {
|
||||
bonesUp = false;
|
||||
clearBones(list);
|
||||
list.innerHTML =
|
||||
`<p class="rev-status error" role="status">${escapeHtml(t('reviews.relayError'))} ` +
|
||||
`<button class="rev-retry" type="button" data-review-retry>${escapeHtml(t('common.retry'))}</button></p>`;
|
||||
pagerWrap.hidden = true;
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
// Bones stand in for reviews the API has already counted. With none counted
|
||||
// the prerendered "no reviews yet" line is the truthful thing to leave up.
|
||||
// The bones are already in the prerendered HTML, so first load only has to take
|
||||
// them over. After the error state has replaced them a retry has to draw them
|
||||
// again, and that path renders from the same bone data.
|
||||
if (expected > 0) {
|
||||
const bones =
|
||||
adoptBones(list, 3) ??
|
||||
showBones(list, reviewsBones, {
|
||||
groups: 3,
|
||||
label: t('reviews.loading', { n: expected }),
|
||||
});
|
||||
bonesUp = bones !== null;
|
||||
}
|
||||
|
||||
let loaded: LoadedReview[];
|
||||
try {
|
||||
loaded = await withTimeout(loadReviews(mintUrl, mintPubkey), RELAY_TIMEOUT_MS);
|
||||
} catch {
|
||||
showRelayError();
|
||||
return;
|
||||
}
|
||||
|
||||
// The relay pool swallows per relay failures and resolves empty, so nothing
|
||||
// coming back for a mint the API counted reviews for means the relays did not
|
||||
// answer, not that the reviews are gone. Saying "no reviews yet" there would
|
||||
// be a lie the reader cannot check.
|
||||
if (loaded.length === 0 && expected > 0) {
|
||||
showRelayError();
|
||||
return;
|
||||
}
|
||||
|
||||
all = loaded;
|
||||
// Rendered with npub fallbacks first: the text is what people came for, and
|
||||
// names arrive into a line that is already the right height.
|
||||
render();
|
||||
updateCounts();
|
||||
|
||||
try {
|
||||
const profiles = await loadProfiles(all.map((r) => r.pubkey));
|
||||
for (const review of all) review.profile = profiles.get(review.pubkey) ?? null;
|
||||
} catch {
|
||||
// Names stay as short npubs. Nothing else changes.
|
||||
} finally {
|
||||
patchProfiles();
|
||||
}
|
||||
}
|
||||
|
||||
root.addEventListener('click', (event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
if (target.closest('[data-review-retry]')) {
|
||||
void load();
|
||||
return;
|
||||
}
|
||||
|
||||
const filterButton = target.closest<HTMLButtonElement>('[data-filter]');
|
||||
if (filterButton) {
|
||||
filter = filterButton.dataset['filter'] as typeof filter;
|
||||
page = 1;
|
||||
for (const b of root!.querySelectorAll('[data-filter]')) {
|
||||
b.setAttribute('aria-pressed', String(b === filterButton));
|
||||
}
|
||||
render(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const pageButton = target.closest<HTMLButtonElement>('[data-page]');
|
||||
if (pageButton) {
|
||||
page = Number(pageButton.dataset['page']);
|
||||
render(true);
|
||||
root!.scrollIntoView({ behavior: scrollBehavior(), block: 'start' });
|
||||
}
|
||||
});
|
||||
|
||||
if (isBuildMode()) {
|
||||
// `pnpm bones` is measuring this panel. Relays are not involved: render the
|
||||
// fixture reviews through the same function the real ones go through, so the
|
||||
// bones Boneyard captures are the shape of a real card and stay that way when
|
||||
// the card's markup changes.
|
||||
// Imported dynamically so the fixture text never reaches a real visitor.
|
||||
void armBuildSnapshot()
|
||||
.then(() => import('../lib/skeleton-fixtures'))
|
||||
.then(({ FIXTURE_REVIEWS }) => {
|
||||
all = FIXTURE_REVIEWS;
|
||||
renderInto(list);
|
||||
});
|
||||
} else {
|
||||
void load();
|
||||
}
|
||||
|
||||
/* ---------- write a review ---------- */
|
||||
|
||||
const dialog = document.querySelector<HTMLDialogElement>('[data-write-dialog]');
|
||||
const form = dialog?.querySelector<HTMLFormElement>('[data-write-form]');
|
||||
const errorBox = dialog?.querySelector<HTMLElement>('[data-write-error]');
|
||||
const statusBox = dialog?.querySelector<HTMLElement>('[data-write-status]');
|
||||
const submit = dialog?.querySelector<HTMLButtonElement>('[data-write-submit]');
|
||||
const submitLabel = dialog?.querySelector<HTMLElement>('[data-write-submit-label]');
|
||||
const textarea = dialog?.querySelector<HTMLTextAreaElement>('textarea');
|
||||
const charCount = dialog?.querySelector<HTMLElement>('[data-char-count]');
|
||||
const starLabel = dialog?.querySelector<HTMLElement>('[data-star-label]');
|
||||
const identityRow = dialog?.querySelector<HTMLElement>('[data-write-identity]');
|
||||
const whyDisabled = dialog?.querySelector<HTMLElement>('[data-write-why]');
|
||||
|
||||
/** Reviews are capped at this length, and the counter turns red past it. */
|
||||
const MAX_CHARS = 2000;
|
||||
/** Below this a review says nothing anyone can use. */
|
||||
const MIN_CHARS = 10;
|
||||
|
||||
// One phrase per star, in the reader's words rather than the site's. Shown as
|
||||
// "2: had problems" beside the picker, and read out by the live region.
|
||||
// One phrase per star, from the catalog. Read through `t` rather than held in a
|
||||
// table here, so the phrases follow the page's language after a view transition.
|
||||
const starPhrase = (rating: number): string => t(`reviews.dialog.stars.${rating}`);
|
||||
|
||||
/** True once a publish is in flight, so nothing else runs over it. */
|
||||
let publishing = false;
|
||||
/** Who is going to sign, resolved when the dialog opens. */
|
||||
let canSign = false;
|
||||
|
||||
function showError(message: string): void {
|
||||
if (!errorBox) return;
|
||||
errorBox.textContent = message;
|
||||
errorBox.hidden = message === '';
|
||||
}
|
||||
|
||||
function showStatus(message: string): void {
|
||||
if (!statusBox) return;
|
||||
statusBox.textContent = message;
|
||||
statusBox.hidden = message === '';
|
||||
}
|
||||
|
||||
/* ---------- draft safety ---------- */
|
||||
|
||||
/**
|
||||
* A written review survives an accidental close.
|
||||
*
|
||||
* sessionStorage, keyed by mint: it lasts as long as the tab and no longer, which
|
||||
* 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}`;
|
||||
|
||||
function saveDraft(): void {
|
||||
if (!form) return;
|
||||
const data = new FormData(form);
|
||||
const rating = String(data.get('rating') ?? '');
|
||||
const content = String(data.get('content') ?? '');
|
||||
try {
|
||||
if (!rating && !content.trim()) sessionStorage.removeItem(DRAFT_KEY);
|
||||
else sessionStorage.setItem(DRAFT_KEY, JSON.stringify({ rating, content }));
|
||||
} catch {
|
||||
// Storage blocked. The draft lives in the open dialog and nowhere else.
|
||||
}
|
||||
}
|
||||
|
||||
function clearDraft(): void {
|
||||
try {
|
||||
sessionStorage.removeItem(DRAFT_KEY);
|
||||
} catch {
|
||||
// Nothing stored to begin with.
|
||||
}
|
||||
}
|
||||
|
||||
function restoreDraft(): void {
|
||||
if (!form) return;
|
||||
let draft: { rating?: string; content?: string } | null = null;
|
||||
try {
|
||||
const raw = sessionStorage.getItem(DRAFT_KEY);
|
||||
draft = raw ? (JSON.parse(raw) as { rating?: string; content?: string }) : null;
|
||||
} catch {
|
||||
draft = null;
|
||||
}
|
||||
if (!draft) return;
|
||||
|
||||
if (draft.rating) {
|
||||
const star = form.querySelector<HTMLInputElement>(`input[name="rating"][value="${draft.rating}"]`);
|
||||
if (star) star.checked = true;
|
||||
}
|
||||
if (draft.content && textarea) textarea.value = draft.content;
|
||||
syncStarLabel();
|
||||
syncCount();
|
||||
}
|
||||
|
||||
/* ---------- the form's own state ---------- */
|
||||
|
||||
function chosenRating(): number {
|
||||
return Number(new FormData(form ?? undefined).get('rating') ?? 0);
|
||||
}
|
||||
|
||||
function syncStarLabel(): void {
|
||||
if (!starLabel) return;
|
||||
const rating = chosenRating();
|
||||
starLabel.textContent = rating
|
||||
// The rating is a bare numeral here, so it is passed as a string: "3: good",
|
||||
// never "3,0: good".
|
||||
? t('reviews.dialog.starLabel', { n: String(rating), phrase: starPhrase(rating) })
|
||||
: t('reviews.dialog.chooseRating');
|
||||
}
|
||||
|
||||
function syncCount(): void {
|
||||
const length = textarea?.value.length ?? 0;
|
||||
if (charCount) {
|
||||
charCount.textContent = t('reviews.dialog.charCount', { n: length, max: MAX_CHARS });
|
||||
charCount.classList.toggle('over', length > MAX_CHARS);
|
||||
}
|
||||
syncSubmit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether "Sign and publish" is available, and if not, why.
|
||||
*
|
||||
* The reason is always on screen: a button that is off for an unstated reason is
|
||||
* a button people click twice and then leave.
|
||||
*/
|
||||
function syncSubmit(): void {
|
||||
if (!submit) return;
|
||||
const length = textarea?.value.trim().length ?? 0;
|
||||
const rating = chosenRating();
|
||||
|
||||
const over = (textarea?.value.length ?? 0) - MAX_CHARS;
|
||||
const reason = publishing
|
||||
? ''
|
||||
: !canSign ? t('reviews.dialog.why.login')
|
||||
: !rating ? t('reviews.dialog.why.rating')
|
||||
: length === 0 ? t('reviews.dialog.why.words')
|
||||
: length < MIN_CHARS ? t('reviews.dialog.why.more', { n: MIN_CHARS - length })
|
||||
: over > 0 ? t('reviews.dialog.why.over', { n: over })
|
||||
: '';
|
||||
|
||||
submit.disabled = publishing || reason !== '';
|
||||
if (whyDisabled) whyDisabled.textContent = publishing ? '' : reason;
|
||||
}
|
||||
|
||||
/** The publish button, mid-flight or back to rest. */
|
||||
function setBusy(busy: boolean, label = t('reviews.dialog.submit')): void {
|
||||
publishing = busy;
|
||||
submit?.classList.toggle('is-busy', busy);
|
||||
if (submitLabel) submitLabel.textContent = label;
|
||||
syncSubmit();
|
||||
}
|
||||
|
||||
/* ---------- who is signing ---------- */
|
||||
|
||||
/**
|
||||
* Draw the identity row, and decide whether publishing is possible at all.
|
||||
*
|
||||
* Three states, in the order they are likely:
|
||||
* - logged in: the session's signer, named, with a way to switch
|
||||
* - no session but a NIP-07 extension: quick-sign, exactly as before logins
|
||||
* existed, and no session is created by using it
|
||||
* - neither: the row becomes the way in, and the publish button says so
|
||||
*
|
||||
* The extension's pubkey is asked for here, on open, and never on page load: it
|
||||
* is a question some extensions put to the reader, and a page that asks it for
|
||||
* every visitor who might one day write a review is a page that nags.
|
||||
*/
|
||||
async function renderIdentity(): Promise<void> {
|
||||
if (!identityRow) return;
|
||||
|
||||
const session = readSession();
|
||||
if (session) {
|
||||
// A session that names an extension is only as good as the extension: it can
|
||||
// be uninstalled, or disabled for this site, between one visit and the next.
|
||||
canSign = session.method !== 'extension' || getExtension() !== null;
|
||||
syncSubmit();
|
||||
if (!canSign) {
|
||||
identityRow.innerHTML =
|
||||
`<span class="write-id-who"><span class="write-id-lead">${escapeHtml(
|
||||
t('reviews.dialog.identity.extensionGone'),
|
||||
)}</span></span>` +
|
||||
`<button type="button" class="btn" data-login-open>${escapeHtml(
|
||||
t('login.open'),
|
||||
)}</button>`;
|
||||
return;
|
||||
}
|
||||
const npub = await toNpub(session.pubkey);
|
||||
|
||||
const draw = (profile: Profile | null): void => {
|
||||
const { primary, secondary, named } = identityLines(profile, npub, t);
|
||||
// Whichever of the two lines is the npub is the machine-flavoured one, and
|
||||
// that is the one set in mono (DESIGN.md). The other is a name or a phrase.
|
||||
identityRow.innerHTML =
|
||||
avatarMarkup(session.pubkey, profile, npub, 34) +
|
||||
`<span class="write-id-who">` +
|
||||
`<span class="write-id-name${named ? '' : ' mono'}">${escapeHtml(primary)}</span>` +
|
||||
`<span class="write-id-sub${named ? ' mono' : ''}">${escapeHtml(secondary)}</span></span>` +
|
||||
methodChipHtml(session.method, t) +
|
||||
`<button type="button" class="write-id-switch" data-login-open>${escapeHtml(
|
||||
t('reviews.dialog.identity.switch'),
|
||||
)}</button>`;
|
||||
};
|
||||
|
||||
// Drawn from the npub first, then again if a name turns up: the row is the
|
||||
// same height either way, so nothing under it moves when the name lands.
|
||||
draw(null);
|
||||
const profile = await resolveProfile(session.pubkey);
|
||||
if (readSession()?.pubkey !== session.pubkey || !profile?.found) return;
|
||||
draw(profile);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!getExtension()) {
|
||||
canSign = false;
|
||||
syncSubmit();
|
||||
identityRow.innerHTML =
|
||||
`<span class="write-id-who"><span class="write-id-lead">${escapeHtml(
|
||||
t('reviews.dialog.identity.needSigner'),
|
||||
)}</span></span>` +
|
||||
`<button type="button" class="btn" data-login-open>${escapeHtml(
|
||||
t('reviews.dialog.identity.loginToPublish'),
|
||||
)}</button>`;
|
||||
return;
|
||||
}
|
||||
|
||||
canSign = true;
|
||||
syncSubmit();
|
||||
identityRow.innerHTML =
|
||||
`<span class="write-id-who"><span class="write-id-name">${escapeHtml(
|
||||
t('reviews.dialog.identity.extension'),
|
||||
)}</span>` +
|
||||
`<span class="write-id-sub">${escapeHtml(
|
||||
t('reviews.dialog.identity.readingKey'),
|
||||
)}</span></span>` +
|
||||
methodChipHtml('extension', t);
|
||||
|
||||
let pubkey = '';
|
||||
try {
|
||||
pubkey = (await getExtension()?.getPublicKey()) ?? '';
|
||||
} catch {
|
||||
// Declined, or the extension is locked. Publishing still works: it will ask
|
||||
// again at signing time, which is the request that actually matters.
|
||||
}
|
||||
if (!/^[0-9a-f]{64}$/i.test(pubkey)) {
|
||||
const sub = identityRow.querySelector('.write-id-sub');
|
||||
if (sub) sub.textContent = t('reviews.dialog.identity.willAsk');
|
||||
return;
|
||||
}
|
||||
|
||||
const npub = await toNpub(pubkey);
|
||||
const profile = await resolveProfile(pubkey);
|
||||
const { primary, named } = identityLines(profile, npub, t);
|
||||
identityRow.innerHTML =
|
||||
avatarMarkup(pubkey, profile, npub, 34) +
|
||||
`<span class="write-id-who">` +
|
||||
`<span class="write-id-name${named ? '' : ' mono'}">${escapeHtml(primary)}</span>` +
|
||||
`<span class="write-id-sub">${escapeHtml(
|
||||
t('reviews.dialog.identity.extension'),
|
||||
)}</span></span>` +
|
||||
methodChipHtml('extension', t);
|
||||
}
|
||||
|
||||
/* ---------- opening and closing ---------- */
|
||||
|
||||
if (dialog) {
|
||||
wireModal(dialog, {
|
||||
// A written review is not thrown away on a stray click outside the box.
|
||||
onBackdrop: () =>
|
||||
(textarea?.value.trim().length ?? 0) === 0 ||
|
||||
window.confirm(t('reviews.dialog.confirmClose')),
|
||||
onClose: () => {
|
||||
saveDraft();
|
||||
showStatus('');
|
||||
setBusy(false);
|
||||
},
|
||||
});
|
||||
|
||||
// The signer talks back while a signature is in flight: reconnecting, waiting,
|
||||
// approve-in-another-window. Mirrored into the dialog so the reader is never
|
||||
// watching a spinner with nothing behind it.
|
||||
onSignerStatus((update) => {
|
||||
if (dialog.open && publishing) showStatus(update.message);
|
||||
});
|
||||
|
||||
// Logging in from the identity row redraws it, without closing this dialog.
|
||||
onSessionChange(() => {
|
||||
if (dialog.open) void renderIdentity();
|
||||
});
|
||||
}
|
||||
|
||||
textarea?.addEventListener('input', () => {
|
||||
syncCount();
|
||||
saveDraft();
|
||||
});
|
||||
|
||||
form?.addEventListener('change', (event) => {
|
||||
if ((event.target as HTMLInputElement).name !== 'rating') return;
|
||||
syncStarLabel();
|
||||
syncSubmit();
|
||||
saveDraft();
|
||||
});
|
||||
|
||||
for (const button of document.querySelectorAll('[data-write-close]')) {
|
||||
button.addEventListener('click', () => closeModal(dialog!));
|
||||
}
|
||||
|
||||
for (const trigger of document.querySelectorAll('[data-write-open]')) {
|
||||
trigger.addEventListener('click', () => {
|
||||
if (!dialog) return;
|
||||
showError('');
|
||||
showStatus('');
|
||||
setBusy(false);
|
||||
restoreDraft();
|
||||
openModal(dialog);
|
||||
void renderIdentity();
|
||||
// Somewhere to land that is not the close button: the rating is the first
|
||||
// thing to decide, and a checked star keeps its own place in the group.
|
||||
const checked = form?.querySelector<HTMLInputElement>('input[name="rating"]:checked');
|
||||
(checked ?? form?.querySelector<HTMLInputElement>('input[name="rating"]'))?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- publishing ---------- */
|
||||
|
||||
form?.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
if (!form || !submit || publishing) return;
|
||||
|
||||
const rating = chosenRating();
|
||||
const content = textarea?.value ?? '';
|
||||
|
||||
const signer = activeSigner();
|
||||
if (!signer) {
|
||||
// No session and no extension. The row already says so; open the way in.
|
||||
openLogin(submit);
|
||||
return;
|
||||
}
|
||||
|
||||
showError('');
|
||||
setBusy(true, t('reviews.dialog.signing'));
|
||||
showStatus(
|
||||
signer.method === 'extension'
|
||||
? t('reviews.dialog.waitingExtension')
|
||||
: t('reviews.dialog.waitingSigner'),
|
||||
);
|
||||
|
||||
try {
|
||||
/*
|
||||
* The author's own kind 0, fetched while the relays are still taking the
|
||||
* review. Their pubkey is only knowable once the event is signed, so
|
||||
* `onSigned` starts this at the earliest moment there is: by the time the
|
||||
* card below is inserted the name is usually already in hand, and the author
|
||||
* does not watch their own review appear as "Anon" until the next reload.
|
||||
*
|
||||
* `refresh` skips the cache deliberately. Anyone who has reviewed before may
|
||||
* be sitting on an hour-old negative entry for their own key, and they are
|
||||
* exactly the reader who would notice their name missing.
|
||||
*/
|
||||
let profileLookup: Promise<Profile | null> | null = null;
|
||||
|
||||
const result = await publishReview({
|
||||
signer,
|
||||
mintUrl,
|
||||
mintPubkey,
|
||||
rating,
|
||||
content,
|
||||
onSigned: (signed) => {
|
||||
setBusy(true, t('reviews.dialog.publishing'));
|
||||
profileLookup = loadProfiles([signed.pubkey], { refresh: true })
|
||||
.then((profiles) => profiles.get(signed.pubkey) ?? null)
|
||||
.catch(() => {
|
||||
// Relays unreachable for the lookup. The card keeps "Anon" over its
|
||||
// npub, which is what every other unresolved card shows.
|
||||
return null;
|
||||
});
|
||||
},
|
||||
onProgress: (accepted, _settled, total) => {
|
||||
showStatus(t('reviews.dialog.publishingTo', { accepted, total }));
|
||||
},
|
||||
});
|
||||
|
||||
// Optimistic insert at the top. The relays will echo it back on next load.
|
||||
optimistic.set(result.event.id, { accepted: result.accepted, total: result.total });
|
||||
all.unshift({
|
||||
id: result.event.id,
|
||||
pubkey: result.event.pubkey,
|
||||
npub: safeNpub(result.event.pubkey),
|
||||
created_at: result.event.created_at,
|
||||
rating,
|
||||
content: content.trim(),
|
||||
mint_url: mintUrl,
|
||||
profile: null,
|
||||
});
|
||||
|
||||
filter = 'all';
|
||||
page = 1;
|
||||
for (const b of root!.querySelectorAll('[data-filter]')) {
|
||||
b.setAttribute('aria-pressed', String(b.getAttribute('data-filter') === 'all'));
|
||||
}
|
||||
markNewest = true;
|
||||
render();
|
||||
updateCounts();
|
||||
|
||||
// The dialog is about to close, so the confirmation belongs on the button
|
||||
// that opened it: one amber pulse, on success only.
|
||||
const trigger = document.querySelector<HTMLElement>('[data-write-open]');
|
||||
if (trigger && !prefersReducedMotion()) {
|
||||
trigger.classList.remove('did-it');
|
||||
void trigger.offsetWidth;
|
||||
trigger.classList.add('did-it');
|
||||
trigger.addEventListener('animationend', () => trigger.classList.remove('did-it'), { once: true });
|
||||
}
|
||||
|
||||
// The relays answer the publish before the lookup answers, near enough every
|
||||
// time, so the card goes up as "Anon" and the author's name cross-fades in a
|
||||
// moment later, exactly as it does on every other card.
|
||||
void (profileLookup as Promise<Profile | null> | null)?.then((profile) => {
|
||||
if (!profile?.found) return;
|
||||
for (const review of all) {
|
||||
if (review.pubkey === result.event.pubkey) review.profile = profile;
|
||||
}
|
||||
patchProfiles();
|
||||
});
|
||||
|
||||
clearDraft();
|
||||
form.reset();
|
||||
syncStarLabel();
|
||||
syncCount();
|
||||
setBusy(false);
|
||||
showStatus('');
|
||||
closeModal(dialog!);
|
||||
} catch (err) {
|
||||
// Back to an editable dialog with everything still in it. A declined
|
||||
// signature is not an error the reader made, and a relay that would not take
|
||||
// it is worth another press of the same button.
|
||||
setBusy(false);
|
||||
showStatus('');
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
showError(
|
||||
message === 'no-relay'
|
||||
? t('reviews.dialog.error.noRelay')
|
||||
: signerErrorMessage(err, t),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
onReady(setup);
|
||||
</script>
|
||||
@@ -0,0 +1,123 @@
|
||||
---
|
||||
/**
|
||||
* Uptime sparkline for the verdict strip.
|
||||
*
|
||||
* One filled band per probe window: high and unbroken is healthy, a notch to the
|
||||
* floor is an outage. This replaced a row of coloured blocks that were all the same
|
||||
* height, so they carried no shape and read as a stray progress bar under the numbers.
|
||||
*
|
||||
* Response time modulates the height inside the healthy band, which is what gives the
|
||||
* line something to say when nothing has gone wrong.
|
||||
*/
|
||||
import type { ProbeSample } from '@cashumints/shared';
|
||||
import { useI18n } from '../i18n';
|
||||
import { formatters } from '../i18n/format';
|
||||
import { pageLocale } from '../i18n/paths';
|
||||
|
||||
interface Props {
|
||||
probes: ProbeSample[];
|
||||
/** 0..1 from the API, rendered as the label under the line. */
|
||||
uptime?: number | null;
|
||||
}
|
||||
const { probes, uptime = null } = Astro.props;
|
||||
|
||||
const { locale } = pageLocale(Astro);
|
||||
const f = formatters(useI18n(locale));
|
||||
|
||||
/** Cap the sample count so a long window stays legible; keep the most recent. */
|
||||
const MAX_POINTS = 60;
|
||||
const shown = probes.slice(-MAX_POINTS);
|
||||
const okCount = shown.filter((p) => p.ok === 1).length;
|
||||
|
||||
const W = 200;
|
||||
const H = 24;
|
||||
/** Keeps the stroke inside the box at both extremes. */
|
||||
const PAD = 2;
|
||||
const FLOOR = H - PAD;
|
||||
|
||||
const latencies = shown.filter((p) => p.ok === 1 && p.latency_ms !== null).map((p) => p.latency_ms!);
|
||||
const fastest = latencies.length > 0 ? Math.min(...latencies) : 0;
|
||||
const slowest = latencies.length > 0 ? Math.max(...latencies) : 0;
|
||||
const span = slowest - fastest;
|
||||
|
||||
/**
|
||||
* A reachable probe sits high, a little lower the slower it answered. An unreachable
|
||||
* one drops to the floor, which is the only thing on the line worth noticing.
|
||||
*/
|
||||
function y(probe: ProbeSample): number {
|
||||
if (probe.ok !== 1) return FLOOR;
|
||||
if (span <= 0 || probe.latency_ms === null) return PAD + 2;
|
||||
const slowness = (probe.latency_ms - fastest) / span;
|
||||
// Confined to the top third: this is an uptime line, not a latency chart.
|
||||
return PAD + 2 + slowness * ((H - PAD * 2) * 0.3);
|
||||
}
|
||||
|
||||
const step = shown.length > 1 ? (W - PAD * 2) / (shown.length - 1) : 0;
|
||||
const points = shown.map((probe, i) => [PAD + i * step, y(probe)] as const);
|
||||
const line = points.map(([x, py]) => `${x.toFixed(1)},${py.toFixed(1)}`).join(' ');
|
||||
const area = `M ${PAD},${FLOOR} L ${points
|
||||
.map(([x, py]) => `${x.toFixed(1)},${py.toFixed(1)}`)
|
||||
.join(' L ')} L ${(W - PAD).toFixed(1)},${FLOOR} Z`;
|
||||
|
||||
const label =
|
||||
uptime === null
|
||||
? f.t('mint.uptime.checks', { ok: okCount, total: shown.length })
|
||||
// The percentage is a whole number, passed as a string: this is a share, not a
|
||||
// count, and grouping separators have no business in "97%".
|
||||
: f.t('mint.uptime.percent', { percent: String(Math.round(uptime * 100)) });
|
||||
const description = f.t('mint.uptime.description', { ok: okCount, total: shown.length });
|
||||
---
|
||||
|
||||
{
|
||||
shown.length > 1 && (
|
||||
<div class="spark-wrap">
|
||||
{/*
|
||||
Fill and stroke are presentation attributes rather than CSS: Astro's scoped
|
||||
styles do not reach elements inside an inline <svg>, and a path with no fill
|
||||
rule falls back to solid black.
|
||||
*/}
|
||||
<svg class="spark" viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" role="img" aria-label={description}>
|
||||
<path class="spark-area" d={area} fill="rgba(240, 169, 59, .12)" stroke="none" />
|
||||
{/*
|
||||
`pathLength="1"` normalises the line's length, so the dash that draws it in
|
||||
is one CSS rule rather than a measurement in JavaScript. With the animation
|
||||
off (no JS, or reduced motion) a dash of length 1 on a path of length 1 is
|
||||
just a solid line.
|
||||
*/}
|
||||
<polyline
|
||||
class="spark-line"
|
||||
points={line}
|
||||
pathLength="1"
|
||||
fill="none"
|
||||
stroke="#F0A93B"
|
||||
stroke-width="1.5"
|
||||
stroke-opacity=".85"
|
||||
stroke-linejoin="round"
|
||||
stroke-linecap="round"
|
||||
vector-effect="non-scaling-stroke"
|
||||
/>
|
||||
</svg>
|
||||
<span class="spark-label">{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<style>
|
||||
.spark-wrap { margin-top: 14px; }
|
||||
.spark { width: 100%; height: 24px; display: block; }
|
||||
.spark-label { display: block; margin-top: 8px; font-size: 12px; color: var(--muted); }
|
||||
|
||||
/*
|
||||
* The line draws itself once its verdict cell arrives, left to right, 500ms. The
|
||||
* undrawn state only exists while the cell is still waiting, so with JavaScript off
|
||||
* the line is simply there.
|
||||
*/
|
||||
.spark-line {
|
||||
stroke-dasharray: 1;
|
||||
stroke-dashoffset: 0;
|
||||
transition: stroke-dashoffset var(--dur-draw) var(--ease-out) calc(var(--reveal-delay, 0ms) + 120ms);
|
||||
}
|
||||
.spark-area { transition: opacity var(--dur-enter) var(--ease-out) calc(var(--reveal-delay, 0ms) + 200ms); }
|
||||
:global([data-reveal]:not(.in)) .spark-line { stroke-dashoffset: 1; }
|
||||
:global([data-reveal]:not(.in)) .spark-area { opacity: 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,83 @@
|
||||
---
|
||||
import Moai from './Moai.astro';
|
||||
import LanguageSwitcher from './LanguageSwitcher.astro';
|
||||
import { localePath, useI18n } from '../i18n';
|
||||
import { pageLocale } from '../i18n/paths';
|
||||
|
||||
interface Props {
|
||||
current?: 'mints' | 'reviews' | 'wallets' | 'about';
|
||||
/** Shows the compact search affordance next to the brand (mint pages, /mints). */
|
||||
mintCount?: number;
|
||||
}
|
||||
const { current, mintCount } = Astro.props;
|
||||
|
||||
const { locale } = pageLocale(Astro);
|
||||
const t = useI18n(locale);
|
||||
|
||||
/*
|
||||
* Route slugs stay English in every language, so the href is the English path run
|
||||
* through `localePath`. See src/i18n/routing.ts for why that trade was made.
|
||||
*/
|
||||
const links = [
|
||||
{ path: '/mints', label: t('nav.mints'), key: 'mints' },
|
||||
{ path: '/reviews', label: t('nav.reviews'), key: 'reviews' },
|
||||
{ path: '/wallets', label: t('nav.wallets'), key: 'wallets' },
|
||||
{ path: '/about', label: t('nav.about'), key: 'about' },
|
||||
] as const;
|
||||
---
|
||||
|
||||
<header class="topbar">
|
||||
<div class="topbar-inner">
|
||||
<a class="brand" href={localePath('/', locale)}>
|
||||
<Moai />
|
||||
{/* Below 420px the wordmark is what pushes the nav off the right edge. The
|
||||
moai stays, and the name stays in the accessibility tree. */}
|
||||
<span class="brand-text">Cashumints.space</span>
|
||||
<span class="sr-only">Cashumints.space</span>
|
||||
</a>
|
||||
|
||||
{
|
||||
mintCount !== undefined && (
|
||||
<a class="topbar-search" href={`${localePath('/mints', locale)}#mint-search`}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" />
|
||||
</svg>
|
||||
{t('nav.searchMints', { n: mintCount })}
|
||||
<kbd>/</kbd>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
<nav class="nav" aria-label={t('nav.main')}>
|
||||
{
|
||||
links.map((link) => (
|
||||
<a
|
||||
href={localePath(link.path, locale)}
|
||||
aria-current={current === link.key ? 'page' : undefined}
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</nav>
|
||||
|
||||
{/*
|
||||
The language control, next to the account one and in every layout.
|
||||
|
||||
In the header rather than only in the footer because of who needs it: a reader who
|
||||
landed on a page in a language they do not read should not have to scroll the whole
|
||||
of it to get out. The footer keeps its own copy, where someone deliberately looking
|
||||
for site settings expects to find one.
|
||||
*/}
|
||||
<LanguageSwitcher variant="menu" />
|
||||
|
||||
{/*
|
||||
The account control. Prerendered logged out, because a static build cannot know
|
||||
who is reading; the island swaps in the avatar and name from the stored session
|
||||
and the cached profile on first paint, before any relay is asked anything.
|
||||
*/}
|
||||
<div class="auth" data-auth>
|
||||
<button class="auth-login" type="button" data-login-open>{t('login.open')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -0,0 +1,73 @@
|
||||
# Translation glossary
|
||||
|
||||
The terms this site keeps consistent across every language. Decide a term once here,
|
||||
then use it everywhere: a reader who meets "reseña" on the home page and "opinión" on a
|
||||
mint page has to work out whether they are the same thing.
|
||||
|
||||
Adding a fourth language starts here, not in the catalog. Fill in the column, then
|
||||
translate `en.json` with it beside you.
|
||||
|
||||
## House rules, in every language
|
||||
|
||||
- **Sentence case.** Headings, buttons, labels. Not Title Case, not ALL CAPS.
|
||||
(Small caps on section labels come from CSS, so the source string stays sentence case.)
|
||||
- **No em dashes.** Ever, in any language. Use a comma, a colon, or two sentences.
|
||||
Spanish and Dutch both reach for the em dash naturally; do not.
|
||||
- **Specific over clever.** "Offline since 3 Aug 2026" beats "this mint has gone quiet".
|
||||
- **Second person, informal.** Spanish `tú`, Dutch `je`. Never `usted`, never `u`.
|
||||
- **Protocol terms stay English.** See the table.
|
||||
- **Never translate data.** Review text, reviewer names, mint names, mint descriptions,
|
||||
MOTD, URLs, npubs, version strings. Those are source data, not site copy.
|
||||
|
||||
## The terms
|
||||
|
||||
| English | Spanish | Dutch | Note |
|
||||
| --- | --- | --- | --- |
|
||||
| mint (noun) | el mint | de mint | Never "casa de la moneda" / "munt". It is the Cashu term. |
|
||||
| mint (verb) | acuñar | minten | Spanish spells it out once per page then uses it plainly. |
|
||||
| melt (verb) | fundir | melten | Same rule as mint. |
|
||||
| minting | la acuñación | minten | |
|
||||
| melting | el fundido | melten | |
|
||||
| ecash | ecash | ecash | Never "dinero electrónico". |
|
||||
| sat / sats | sat / sats | sat / sats | Never translated, never inflected. |
|
||||
| Lightning | Lightning | Lightning | Capital L, no article games. |
|
||||
| NUT / NUT-04 | NUT / NUT-04 | NUT / NUT-04 | The number is the name. The plain-language label beside it IS translated. |
|
||||
| Nostr | Nostr | Nostr | |
|
||||
| NIP-87, NIP-07, NIP-46 | unchanged | unchanged | |
|
||||
| relay | el relay | de relay | Plural: relays. Not "retransmisor" / "doorgeefluik". |
|
||||
| signer | el firmante | de signer | Dutch keeps the loanword: NL Nostr users say "signer". |
|
||||
| review (the written thing) | la reseña | de review | |
|
||||
| rating (the stars) | la valoración | de beoordeling | Deliberately a different word from review in all three. |
|
||||
| reviewer | quien escribió la reseña | wie de review schreef | Avoid a noun that implies a role or an account. |
|
||||
| wallet | el monedero | de wallet | Spanish follows bitcoin.org/es. Dutch keeps the loanword. |
|
||||
| key (private/public) | la clave | de sleutel | |
|
||||
| npub / pubkey | npub / pubkey | npub / pubkey | |
|
||||
| custodial | custodial | custodiaal | Always followed by what it means, never left bare. |
|
||||
| online (a mint) | en línea | online | |
|
||||
| offline (a mint) | sin conexión | offline | Shorter than "fuera de línea" and standard for a status. |
|
||||
| unreachable | no responde | onbereikbaar | |
|
||||
| uptime | disponibilidad | uptime | |
|
||||
| indexed | indexado | geïndexeerd | |
|
||||
| to probe (a mint) | comprobar | controleren | |
|
||||
| mint URL | la URL del mint | de mint-URL | |
|
||||
| bunker link | el enlace bunker | de bunker-link | `bunker://` itself is never translated. |
|
||||
| seed phrase | la frase semilla | de seed phrase | |
|
||||
|
||||
## Two decisions worth knowing
|
||||
|
||||
**Spanish spells out mint and melt on first use, then stops.** `acuñar (mint)` the first
|
||||
time a page uses it, `acuñar` after that. Spanish-speaking Bitcoin readers know the
|
||||
English verbs, but a page that only ever says "mint" reads as untranslated. Dutch does
|
||||
not need this: `minten` and `melten` are what NL and BE Nostr users actually write.
|
||||
|
||||
**Dutch keeps more English than Spanish does.** `review`, `wallet`, `signer`, `online`,
|
||||
`uptime` are all ordinary Dutch now, and translating them ("beoordeling" for the written
|
||||
review, "portemonnee", "ondertekenaar") reads as stiff or, worse, as a different concept.
|
||||
Spanish translates more, because Spanish tech writing does.
|
||||
|
||||
## Safety copy
|
||||
|
||||
The warning banners (`mint.warnings.*`) and the disclaimer are the copy someone reads
|
||||
before they risk money. Translate them for **plain, unambiguous meaning**, not word for
|
||||
word. If a literal translation is ambiguous about whether money can get out of a mint,
|
||||
the translation is wrong even if every word is right. Short sentences. No hedging.
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Island-side i18n. This is the file that ships.
|
||||
*
|
||||
* The page carries its own copy: `Base.astro` emits one `<script type="application/json"
|
||||
* data-i18n>` holding the current locale's client-facing keys, already resolved against
|
||||
* English. So a Spanish page ships Spanish strings and no others, in the HTML it was
|
||||
* going to send anyway. No second request, no catalog chunk per language, no waterfall
|
||||
* before an island can draw a label, and nothing in a shared JS chunk that has to be
|
||||
* one language for everybody.
|
||||
*
|
||||
* The alternative, a dynamic `import('./es.json')` keyed on locale, also avoids
|
||||
* shipping three languages, but it costs a round trip on the critical path of every
|
||||
* island and it leaves all three catalogs sitting in `dist/_astro/`. This does not.
|
||||
*/
|
||||
import { DEFAULT_LOCALE, type Locale } from './config.js';
|
||||
import { createTranslator, type Catalog, type Translator } from './translate.js';
|
||||
|
||||
interface Payload {
|
||||
locale: Locale;
|
||||
catalog: Catalog;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the payload out of the current document.
|
||||
*
|
||||
* Read every time rather than cached at module scope, because the view transition
|
||||
* router swaps the document under this module without reloading it. An island that
|
||||
* cached its translator on first load would keep drawing Spanish after the reader
|
||||
* clicked through to a Dutch page. Islands call `useI18n()` inside their `onReady`
|
||||
* setup, which runs again on every arrival, so this reads the page that is actually on
|
||||
* screen.
|
||||
*/
|
||||
function readPayload(): Payload {
|
||||
const tag = document.querySelector<HTMLScriptElement>('script[data-i18n]');
|
||||
if (!tag) return { locale: DEFAULT_LOCALE, catalog: {} };
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(tag.textContent ?? '{}') as Partial<Payload>;
|
||||
return {
|
||||
locale: parsed.locale ?? DEFAULT_LOCALE,
|
||||
catalog: parsed.catalog ?? {},
|
||||
};
|
||||
} catch {
|
||||
// A payload that will not parse is a build bug, not a runtime condition. Falling
|
||||
// back to the key humaniser beats throwing inside every island on the page.
|
||||
return { locale: DEFAULT_LOCALE, catalog: {} };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One translator per document, rebuilt when the router swaps in a new one.
|
||||
*
|
||||
* Keyed on the script element itself: a new page brings a new element, and identity
|
||||
* comparison is cheaper and more honest than re-parsing the JSON on every `t()`.
|
||||
*/
|
||||
let cachedTag: Element | null = null;
|
||||
let cached: Translator | null = null;
|
||||
|
||||
export function useI18n(): Translator {
|
||||
const tag = document.querySelector('script[data-i18n]');
|
||||
if (cached && tag === cachedTag) return cached;
|
||||
|
||||
const { locale, catalog } = readPayload();
|
||||
// The payload is already resolved against English at build time, so the fallback
|
||||
// catalog here is the same object: there is nothing further to fall back to.
|
||||
cached = createTranslator(locale, catalog, catalog);
|
||||
cachedTag = tag;
|
||||
return cached;
|
||||
}
|
||||
|
||||
/** The locale this page is in, for `Intl` calls that do not go through `t()`. */
|
||||
export function currentLocale(): Locale {
|
||||
return useI18n().locale;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* The locale table. Adding a language is this file, `xx.json`, and nothing else.
|
||||
*
|
||||
* Everything downstream reads from `LOCALES`: the Astro i18n config, the language
|
||||
* switcher, the hreflang set, the sitemap, the suggestion bar and the catalog loader.
|
||||
* There is deliberately no second list to keep in step.
|
||||
*/
|
||||
|
||||
export const DEFAULT_LOCALE = 'en' as const;
|
||||
|
||||
export interface LocaleInfo {
|
||||
/** URL prefix and catalog filename. Also the value of `<html lang>`. */
|
||||
code: string;
|
||||
/**
|
||||
* The language's own name for itself, for the switcher. Never a flag: a flag is a
|
||||
* country, and Spanish is not Spain.
|
||||
*/
|
||||
label: string;
|
||||
/**
|
||||
* BCP 47 tag handed to Intl. Region included because number and date formatting
|
||||
* differ by region even when the language does not.
|
||||
*/
|
||||
intl: string;
|
||||
/** `og:locale`. Underscored, region required by the OpenGraph spec. */
|
||||
og: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Order matters: this is the order the switcher renders in, and the default locale
|
||||
* comes first.
|
||||
*/
|
||||
export const LOCALES: LocaleInfo[] = [
|
||||
{ code: 'en', label: 'English', intl: 'en-GB', og: 'en_US' },
|
||||
{ code: 'es', label: 'Español', intl: 'es-ES', og: 'es_ES' },
|
||||
{ code: 'nl', label: 'Nederlands', intl: 'nl-NL', og: 'nl_NL' },
|
||||
];
|
||||
|
||||
export type Locale = string;
|
||||
|
||||
/** Just the codes, for the Astro config and for validating a URL segment. */
|
||||
export const LOCALE_CODES: string[] = LOCALES.map((l) => l.code);
|
||||
|
||||
/** Every locale except the default, which is the set that carries a URL prefix. */
|
||||
export const PREFIXED_LOCALES: string[] = LOCALE_CODES.filter((code) => code !== DEFAULT_LOCALE);
|
||||
|
||||
export function isLocale(value: unknown): value is Locale {
|
||||
return typeof value === 'string' && LOCALE_CODES.includes(value);
|
||||
}
|
||||
|
||||
export function localeInfo(locale: Locale): LocaleInfo {
|
||||
return LOCALES.find((l) => l.code === locale) ?? LOCALES[0]!;
|
||||
}
|
||||
|
||||
/** The Intl tag for a locale, for `Intl.NumberFormat` and friends. */
|
||||
export function intlTag(locale: Locale): string {
|
||||
return localeInfo(locale).intl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Namespaces whose keys are sent to the browser.
|
||||
*
|
||||
* Islands render strings at runtime, so their copy has to reach the client. The static
|
||||
* half of the site (home page sections, about, the legal pages) is rendered at build
|
||||
* time and its copy never needs to ship. Splitting on the namespace keeps the inline
|
||||
* payload to roughly a third of the catalog.
|
||||
*
|
||||
* `scripts/check-i18n.mjs` fails the build if an island reaches for a key outside this
|
||||
* list, so the split cannot silently rot into a missing string on a real page.
|
||||
*/
|
||||
export const CLIENT_NAMESPACES = [
|
||||
'common',
|
||||
'time',
|
||||
'status',
|
||||
'pulse',
|
||||
'mints',
|
||||
'mint',
|
||||
'nut',
|
||||
'reviews',
|
||||
'feed',
|
||||
'login',
|
||||
'signer',
|
||||
'notfound',
|
||||
] as const;
|
||||
@@ -0,0 +1,550 @@
|
||||
{
|
||||
"site.name": "Cashumints.space",
|
||||
"common.close": "Close",
|
||||
"common.cancel": "Cancel",
|
||||
"common.done": "Done",
|
||||
"common.back": "Back",
|
||||
"common.retry": "Retry",
|
||||
"common.connect": "Connect",
|
||||
"common.copied": "Copied",
|
||||
"common.copyFull": "Copy full {kind}",
|
||||
"common.previousPage": "Previous page",
|
||||
"common.nextPage": "Next page",
|
||||
"common.reviewPages": "Review pages",
|
||||
"time.never": "never",
|
||||
"time.justNow": "just now",
|
||||
"time.none": "-",
|
||||
"time.unknownDate": "an unknown date",
|
||||
"time.ago.minute": "{n}m ago",
|
||||
"time.ago.hour": "{n}h ago",
|
||||
"time.ago.day": "{n}d ago",
|
||||
"time.ago.week": "{n}w ago",
|
||||
"time.ago.month": "{n}mo ago",
|
||||
"time.ago.year": "{n}y ago",
|
||||
"time.short.minute": "{n}m",
|
||||
"time.short.hour": "{n}h",
|
||||
"time.short.day": "{n}d",
|
||||
"time.short.week": "{n}w",
|
||||
"time.short.month": "{n}mo",
|
||||
"time.short.year": "{n}y",
|
||||
"status.online": "Online",
|
||||
"status.offline": "Offline",
|
||||
"status.degraded": "Unreachable",
|
||||
"status.unknown": "Not checked yet",
|
||||
"nav.skip": "Skip to content",
|
||||
"nav.main": "Main",
|
||||
"nav.mints": "Mints",
|
||||
"nav.reviews": "Reviews",
|
||||
"nav.wallets": "Wallets",
|
||||
"nav.about": "About",
|
||||
"nav.searchMints": "Search {n} mints",
|
||||
"lang.label": "Language",
|
||||
"lang.switcher": "Choose a language",
|
||||
"footer.copyright": "© {year} Cashumints.space",
|
||||
"footer.allMints": "All mints",
|
||||
"footer.allReviews": "All reviews",
|
||||
"footer.wallets": "Wallets",
|
||||
"footer.about": "About",
|
||||
"footer.disclaimer": "Disclaimer",
|
||||
"footer.terms": "Terms",
|
||||
"footer.privacy": "Privacy",
|
||||
"footer.madeBy": "Made with ❤️ by {azzamo} · Powered by Cashu and NIP-87",
|
||||
"footer.warning": "Mints are custodial. A mint can disappear with your money.",
|
||||
"home.title": "Cashumints.space · Find a Cashu mint you can trust",
|
||||
"home.description": "Find a Cashu mint you can trust. {mints} mints indexed with {reviews} community reviews signed on Nostr, plus live uptime, supported NUTs and mint software.",
|
||||
"home.hero.title": "Find a Cashu mint",
|
||||
"home.hero.titleEm": "you can trust",
|
||||
"home.hero.lede": "Every mint on the Nostr network, with real community reviews. No gatekeepers, just signed reviews you can verify yourself.",
|
||||
"home.search.label": "Search mints by name, domain, or URL",
|
||||
"home.search.placeholder": "Search by name, domain, or paste a mint URL…",
|
||||
"home.filters.mostReviewed": "Most reviewed",
|
||||
"home.filters.highestRated": "Highest rated",
|
||||
"home.filters.recentlyActive": "Recently active",
|
||||
"home.filters.websockets": "Supports WebSockets",
|
||||
"home.top.title": "Top mints",
|
||||
"home.top.sub": "ranked by reviews and rating",
|
||||
"home.top.all": "All {n} mints →",
|
||||
"home.latest.title": "Latest reviews",
|
||||
"home.latest.sub": "live from the Nostr network",
|
||||
"home.latest.prev": "Previous reviews",
|
||||
"home.latest.next": "More reviews",
|
||||
"home.latest.all": "All reviews →",
|
||||
"home.latest.empty": "No reviews could be read from the relays just now. Open any mint page to read and write reviews directly.",
|
||||
"home.why.title": "Why trust these reviews?",
|
||||
"home.why.signed.title": "Signed, not submitted",
|
||||
"home.why.signed.body": "Every review is a {link} event signed by its author's Nostr key. Nothing here is edited, ranked, or removed by us.",
|
||||
"home.why.signed.link": "NIP-87",
|
||||
"home.why.verify.title": "Verify it yourself",
|
||||
"home.why.verify.body": "Reviews live on public Nostr relays, not our database. Point any client at the same relays and you'll see the same data.",
|
||||
"home.why.checked.title": "Checked continuously",
|
||||
"home.why.checked.body": "We probe every mint for uptime, software version, limits, and supported NUTs, so the specs you see are current, not self-reported.",
|
||||
"home.cta.title": "Thinking about a mint? Read it first.",
|
||||
"home.cta.body": "A mint holds your ecash. Read what other people found before you put sats in, and leave a review when you have used one yourself.",
|
||||
"home.cta.explore": "Explore all mints",
|
||||
"home.cta.learn": "Learn about Cashu",
|
||||
"pulse.mintsIndexed": "mints indexed",
|
||||
"pulse.reviewsOnNostr": "reviews on Nostr",
|
||||
"pulse.lastReview": "last review",
|
||||
"pulse.mintsOnline": "mints online",
|
||||
"pulse.indexer": "indexer",
|
||||
"pulse.connected": "connected",
|
||||
"pulse.stale": "stale",
|
||||
"mints.title": "All Cashu mints · Cashumints.space",
|
||||
"mints.description": "All {total} Cashu mints indexed from the Nostr network, {online} online and {offline} offline, with community ratings, uptime and mint software. Search and sort the full list.",
|
||||
"mints.heading": "All mints",
|
||||
"mints.lede": "Every mint discovered on the Nostr network, including the ones that are gone. Offline mints stay listed so you can still find and review them.",
|
||||
"mints.search.label": "Search mints by name or domain",
|
||||
"mints.search.placeholder": "Search by name or domain…",
|
||||
"mints.sort.label": "Sort mints",
|
||||
"mints.sort.score": "Best overall",
|
||||
"mints.sort.rating": "Highest rated",
|
||||
"mints.sort.reviews": "Most reviewed",
|
||||
"mints.sort.recent": "Recently reviewed",
|
||||
"mints.sort.seen": "Recently seen online",
|
||||
"mints.hideOffline": "Hide offline",
|
||||
"mints.showing": "Showing {total} mints ({online} online, {offline} offline)",
|
||||
"mints.showingFiltered": "Showing {shown} of {total} mints ({online} online, {offline} offline)",
|
||||
"mints.noResults": "No mints match. Try a domain, or paste a mint URL.",
|
||||
"mints.clearSearch": "Clear the search",
|
||||
"card.noRatings": "No ratings yet",
|
||||
"card.reviews.one": "{n} review",
|
||||
"card.reviews.other": "{n} reviews",
|
||||
"card.lastSeen": "last seen {when}",
|
||||
"card.neverSeen": "never seen online",
|
||||
"card.reviewed": "reviewed {when}",
|
||||
"card.noReviews": "no reviews yet",
|
||||
"mint.title": "{name} · Cashu mint reviews",
|
||||
"mint.description.fallback": "{name} is a Cashu mint at {domain}.",
|
||||
"mint.description.rated.one": "Rated {rating} out of 5 from {n} review signed on Nostr.",
|
||||
"mint.description.rated.other": "Rated {rating} out of 5 from {n} reviews signed on Nostr.",
|
||||
"mint.description.noReviews": "No community reviews yet.",
|
||||
"mint.description.online": "Currently online.",
|
||||
"mint.description.unreachable": "Currently unreachable.",
|
||||
"mint.breadcrumb": "Breadcrumb",
|
||||
"mint.crumbMints": "Mints",
|
||||
"mint.copyUrl": "Copy mint URL",
|
||||
"mint.qrButton": "Mint QR",
|
||||
"mint.writeReview": "Write a review",
|
||||
"mint.rankByScore": "#{rank} by score",
|
||||
"mint.tagLightning": "Lightning",
|
||||
"mint.summary": "Mint summary",
|
||||
"mint.rating.label": "Community rating",
|
||||
"mint.rating.none": "No ratings yet",
|
||||
"mint.rating.reviews.one": "{n} review",
|
||||
"mint.rating.reviews.other": "{n} reviews",
|
||||
"mint.rating.unrated.one": "{n} left no rating",
|
||||
"mint.rating.unrated.other": "{n} left no rating",
|
||||
"mint.activity.label": "Activity",
|
||||
"mint.activity.since": "since last review",
|
||||
"mint.activity.noReviews": "no reviews yet",
|
||||
"mint.activity.recent.one": "{n} review in the last 90 days",
|
||||
"mint.activity.recent.other": "{n} reviews in the last 90 days",
|
||||
"mint.limits.label": "Limits",
|
||||
"mint.limits.notPublished": "not published",
|
||||
"mint.limits.min": "min",
|
||||
"mint.limits.max": "max",
|
||||
"mint.limits.mintRow": "mint",
|
||||
"mint.limits.meltRow": "melt",
|
||||
"mint.limits.disabled": "disabled",
|
||||
"mint.limits.range": "{min} to {max} {unit}",
|
||||
"mint.limits.upTo": "up to {max} {unit}",
|
||||
"mint.limits.from": "from {min} {unit}",
|
||||
"mint.software.label": "Mint software",
|
||||
"mint.software.unknown": "unknown",
|
||||
"mint.checked": "checked {when}",
|
||||
"mint.lastSeen": "last seen {when}",
|
||||
"mint.neverReached": "never reached",
|
||||
"mint.uptime.checks": "{ok} of the last {total} checks reachable",
|
||||
"mint.uptime.percent": "{percent}% reachable over 30 days",
|
||||
"mint.uptime.description": "{ok} of the last {total} checks succeeded",
|
||||
"mint.operator.title": "From the operator",
|
||||
"mint.operator.motd": "Message of the day",
|
||||
"mint.operator.beta": "This mint describes itself as experimental or in beta. Read the operator's own words below before storing anything you cannot lose.",
|
||||
"mint.nuts.title": "Supported NUTs",
|
||||
"mint.nuts.noneOffline": "No NUT list cached for this mint, it went offline before one was recorded.",
|
||||
"mint.nuts.noneYet": "No NUT list cached for this mint yet.",
|
||||
"mint.nuts.supported": "{n} of {total} known NUTs supported",
|
||||
"mint.nuts.whatAre": "what are NUTs?",
|
||||
"mint.nutRow.disabled": "disabled",
|
||||
"mint.nutRow.notSupported": "not supported",
|
||||
"mint.details.title": "Mint details",
|
||||
"mint.details.pubkey": "Pubkey",
|
||||
"mint.details.nostr": "Nostr",
|
||||
"mint.details.email": "Email",
|
||||
"mint.details.serverTime": "Server time",
|
||||
"mint.details.firstSeen": "First seen",
|
||||
"mint.details.terms": "Terms",
|
||||
"mint.kind.identifier": "identifier",
|
||||
"mint.kind.mintPubkey": "mint pubkey",
|
||||
"mint.kind.nostrAddress": "Nostr address",
|
||||
"mint.kind.emailAddress": "email address",
|
||||
"mint.kind.npub": "npub",
|
||||
"mint.disclaimer": "This mint is custodial: it holds your money, and it can stop doing so at any time. Ratings are community opinions and online means reachable, not trustworthy.",
|
||||
"mint.disclaimerLink": "Read the full disclaimer",
|
||||
"mint.qr.title": "Add {name} to your wallet",
|
||||
"mint.qr.note": "Scan this with a Cashu wallet to add the mint, or copy the URL and paste it in.",
|
||||
"mint.qr.warn": "Adding a mint means trusting it with your money.",
|
||||
"mint.qr.whatMeans": "What that means",
|
||||
"mint.qr.wallets": "Wallets that support Cashu",
|
||||
"mint.chip.frozen": "Frozen",
|
||||
"mint.chip.noWithdrawals": "No withdrawals",
|
||||
"mint.chip.meltOnly": "Melt only",
|
||||
"mint.warnings.dayCount.one": "{n} day",
|
||||
"mint.warnings.dayCount.other": "{n} days",
|
||||
"mint.warnings.gone.lead": "Likely gone.",
|
||||
"mint.warnings.gone.body.since": "This mint has been unreachable since {date} ({days}). Treat funds held there as at risk, and do not send new funds. Details below show its last known state.",
|
||||
"mint.warnings.gone.body.neverDated": "This mint has not answered a single check since we started watching it in {month}. Treat funds held there as at risk, and do not send new funds. Anything below came from the Nostr announcement, not from the mint.",
|
||||
"mint.warnings.gone.body.never": "This mint has not answered a single check since we started watching it. Treat funds held there as at risk, and do not send new funds. Anything below came from the Nostr announcement, not from the mint.",
|
||||
"mint.warnings.gone.meta.since": "Offline since {date}",
|
||||
"mint.warnings.gone.meta.never": "Never reachable since it was discovered",
|
||||
"mint.warnings.meltDisabled.lead": "Withdrawals disabled.",
|
||||
"mint.warnings.meltDisabled.body.online": "This mint has Lightning melting (NUT-05) turned off: you can put sats in, but you cannot withdraw them via Lightning. Do not deposit until melting is back.",
|
||||
"mint.warnings.meltDisabled.body.offline": "This mint had Lightning melting (NUT-05) turned off: you can put sats in, but you cannot withdraw them via Lightning. Do not deposit until melting is back.",
|
||||
"mint.warnings.meltDisabled.meta.online": "Withdrawals currently disabled",
|
||||
"mint.warnings.meltDisabled.meta.offline": "Withdrawals were disabled when last seen",
|
||||
"mint.warnings.frozen.lead": "Mint frozen.",
|
||||
"mint.warnings.frozen.body.online": "Both minting and melting are disabled. No sats can move in or out via Lightning.",
|
||||
"mint.warnings.frozen.body.offline": "Both minting and melting were disabled. No sats can move in or out via Lightning.",
|
||||
"mint.warnings.frozen.meta.online": "Currently frozen, nothing moves in or out",
|
||||
"mint.warnings.frozen.meta.offline": "Frozen when last seen",
|
||||
"mint.warnings.offlineLong.lead": "Offline for {days}, since {date}.",
|
||||
"mint.warnings.offlineLong.body": "The mint may be gone. Everything below is its last known state, and reviews still work.",
|
||||
"mint.warnings.offlineLong.meta": "Offline since {date}",
|
||||
"mint.warnings.meltOnly.lead": "Melt only.",
|
||||
"mint.warnings.meltOnly.body.online": "New minting (NUT-04) is disabled: you cannot deposit, but existing ecash can still be withdrawn. This often means a mint is winding down, so if you hold its ecash, consider melting out.",
|
||||
"mint.warnings.meltOnly.body.offline": "New minting (NUT-04) was disabled: you cannot deposit, but existing ecash can still be withdrawn. This often means a mint is winding down, so if you hold its ecash, consider melting out.",
|
||||
"mint.warnings.meltOnly.meta.online": "Currently melt only",
|
||||
"mint.warnings.meltOnly.meta.offline": "Melt only when last seen",
|
||||
"mint.warnings.offline.lead": "Offline since {date}.",
|
||||
"mint.warnings.offline.body": "Details were last confirmed then. You can still write a review.",
|
||||
"mint.warnings.offline.meta": "Offline since {date}",
|
||||
"mint.warnings.also.meltDisabled": "Before going offline it had also disabled withdrawals.",
|
||||
"mint.warnings.also.mintDisabled": "Before going offline it had also disabled new minting.",
|
||||
"mint.warnings.also.offline": "It has also been offline since {date} ({days}).",
|
||||
"nut.0": "Notation and models",
|
||||
"nut.1": "Mint public key exchange",
|
||||
"nut.2": "Keysets and fees",
|
||||
"nut.3": "Swapping tokens",
|
||||
"nut.4": "Minting via Lightning",
|
||||
"nut.5": "Melting via Lightning",
|
||||
"nut.6": "Mint information",
|
||||
"nut.7": "Token state check",
|
||||
"nut.8": "Overpaid Lightning fees",
|
||||
"nut.9": "Restore signatures",
|
||||
"nut.10": "Spending conditions",
|
||||
"nut.11": "Pay to public key",
|
||||
"nut.12": "DLEQ proofs",
|
||||
"nut.13": "Deterministic secrets",
|
||||
"nut.14": "Hashed timelock contracts",
|
||||
"nut.15": "Multipath payments",
|
||||
"nut.16": "Animated QR codes",
|
||||
"nut.17": "WebSocket subscriptions",
|
||||
"nut.18": "Payment requests",
|
||||
"nut.19": "Cached responses",
|
||||
"nut.20": "Signed mint quotes",
|
||||
"nut.21": "Clear authentication",
|
||||
"nut.22": "Blind authentication",
|
||||
"nut.23": "BOLT11 payment method",
|
||||
"nut.24": "HTTP 402 payment required",
|
||||
"nut.25": "BOLT12 payment method",
|
||||
"reviews.title": "Reviews",
|
||||
"reviews.anon": "Anon",
|
||||
"reviews.filter.group": "Filter reviews",
|
||||
"reviews.filter.all": "All",
|
||||
"reviews.filter.five": "5★",
|
||||
"reviews.filter.critical": "Critical",
|
||||
"reviews.filter.recent": "Recent",
|
||||
"reviews.loading.one": "Loading {n} review from Nostr relays",
|
||||
"reviews.loading.other": "Loading {n} reviews from Nostr relays",
|
||||
"reviews.empty": "No reviews yet. Be the first to review this mint.",
|
||||
"reviews.emptyWritten": "No written reviews here yet. Be the first to write one.",
|
||||
"reviews.emptyFilter": "No reviews match this filter.",
|
||||
"reviews.noscript": "Reviews are signed on Nostr and load in your browser, so this list needs JavaScript. The rating and counts above come from the site itself.",
|
||||
"reviews.relayError": "Could not reach the relay. Reviews may be incomplete.",
|
||||
"reviews.srRating": "{rating} out of 5",
|
||||
"reviews.srNoRating": "No rating given",
|
||||
"reviews.anonNote": "First review from this npub, no other activity found",
|
||||
"reviews.published": "Published.",
|
||||
"reviews.publishedPartial": "Published to {accepted} of {total} relays.",
|
||||
"reviews.propagating": "Propagating, it may take a moment to appear elsewhere.",
|
||||
"reviews.summary.line.one": "{count} rating without a comment: {breakdown}",
|
||||
"reviews.summary.line.other": "{count} ratings without a comment: {breakdown}",
|
||||
"reviews.summary.lineDay.one": "{count} rating without a comment on {when}: {breakdown}",
|
||||
"reviews.summary.lineDay.other": "{count} ratings without a comment on {when}: {breakdown}",
|
||||
"reviews.summary.showOne": "Show it",
|
||||
"reviews.summary.showMany": "Show them",
|
||||
"reviews.breakdown.5": "{n} five star",
|
||||
"reviews.breakdown.4": "{n} four star",
|
||||
"reviews.breakdown.3": "{n} three star",
|
||||
"reviews.breakdown.2": "{n} two star",
|
||||
"reviews.breakdown.1": "{n} one star",
|
||||
"reviews.breakdown.none": "{n} with no rating",
|
||||
"reviews.dialog.title": "Review {name}",
|
||||
"reviews.dialog.note": "Signed with your Nostr key and published to public relays. It cannot be edited or deleted.",
|
||||
"reviews.dialog.ratingLegend": "Your rating",
|
||||
"reviews.dialog.starsSr.one": "{n} star",
|
||||
"reviews.dialog.starsSr.other": "{n} stars",
|
||||
"reviews.dialog.chooseRating": "Choose a rating",
|
||||
"reviews.dialog.starLabel": "{n}: {phrase}",
|
||||
"reviews.dialog.stars.1": "avoid it",
|
||||
"reviews.dialog.stars.2": "had problems",
|
||||
"reviews.dialog.stars.3": "worked, with caveats",
|
||||
"reviews.dialog.stars.4": "good",
|
||||
"reviews.dialog.stars.5": "works great",
|
||||
"reviews.dialog.bodyLabel": "What should other people know?",
|
||||
"reviews.dialog.bodyPlaceholder": "How did minting, melting and support work out? Be specific.",
|
||||
"reviews.dialog.charCount": "{n} / {max}",
|
||||
"reviews.dialog.submit": "Sign and publish",
|
||||
"reviews.dialog.signing": "Signing…",
|
||||
"reviews.dialog.publishing": "Publishing…",
|
||||
"reviews.dialog.publishingTo": "Publishing to relays ({accepted}/{total})",
|
||||
"reviews.dialog.waitingExtension": "Waiting for your extension",
|
||||
"reviews.dialog.waitingSigner": "Waiting for your signer",
|
||||
"reviews.dialog.confirmClose": "Close this review? Your draft is kept until you leave the site.",
|
||||
"reviews.dialog.why.login": "Log in to publish",
|
||||
"reviews.dialog.why.rating": "Choose a rating",
|
||||
"reviews.dialog.why.words": "Write a few words",
|
||||
"reviews.dialog.why.more.one": "{n} more character",
|
||||
"reviews.dialog.why.more.other": "{n} more characters",
|
||||
"reviews.dialog.why.over.one": "{n} character over the limit",
|
||||
"reviews.dialog.why.over.other": "{n} characters over the limit",
|
||||
"reviews.dialog.identity.needSigner": "You need a Nostr signer to publish. Your key stays in it, this site never sees it.",
|
||||
"reviews.dialog.identity.loginToPublish": "Log in to publish",
|
||||
"reviews.dialog.identity.switch": "Switch",
|
||||
"reviews.dialog.identity.extension": "Signing with your browser extension",
|
||||
"reviews.dialog.identity.readingKey": "Reading your public key…",
|
||||
"reviews.dialog.identity.willAsk": "Your extension will be asked to sign",
|
||||
"reviews.dialog.error.noRelay": "No relay accepted the review. Check your connection, then try again.",
|
||||
"reviews.validate.rating": "Choose a rating from 1 to 5 stars.",
|
||||
"reviews.validate.short": "Write at least {n} characters so the review is useful.",
|
||||
"reviews.validate.long": "Reviews are limited to {n} characters.",
|
||||
"feed.title": "Latest Cashu mint reviews · Cashumints.space",
|
||||
"feed.description": "Every Cashu mint review as it is published: {reviews} reviews across {mints} mints, signed on Nostr and readable here newest first. Filter by rating or by mint.",
|
||||
"feed.heading": "Latest Cashu mint reviews",
|
||||
"feed.lede": "Every review of every mint, newest first, read live from the Nostr relays. Each one is signed by its author and lives on the relays, not in our database.",
|
||||
"feed.label": "Reviews",
|
||||
"feed.filterByMint": "Filter by mint",
|
||||
"feed.anyMint": "Any mint",
|
||||
"feed.clearMint": "Clear the mint filter",
|
||||
"feed.showingBuild.one": "Showing the most recent review from the last build",
|
||||
"feed.showingBuild.other": "Showing the {n} most recent reviews from the last build",
|
||||
"feed.noscript": "Reviews are signed on Nostr and load in your browser, so filtering and the full list need JavaScript. The {n} reviews below were read from the relays when this page was built.",
|
||||
"feed.emptyBuild": "No reviews could be read from the relays when this page was built. The list reloads from the relays in your browser.",
|
||||
"feed.foot": "Reviews are community opinions, not verified claims. Read the {link} before you trust a mint with money.",
|
||||
"feed.footLink": "disclaimer",
|
||||
"feed.showing": "Showing {shown} of {total} reviews",
|
||||
"feed.showingMatching": "Showing {shown} of {matching} matching reviews ({total} in total)",
|
||||
"feed.emptyRelays": "No reviews came back from the relays. Open any mint page to read and write reviews there.",
|
||||
"feed.emptyFilter": "No reviews match this filter. Clear the mint box, or try All.",
|
||||
"feed.relayError": "Could not reach the relays. Showing the reviews from the last build.",
|
||||
"feed.relayEmpty": "No reviews came back from the relays. Showing the reviews from the last build.",
|
||||
"feed.reading": "Reading the relays…",
|
||||
"login.open": "Log in",
|
||||
"login.title": "Log in with Nostr",
|
||||
"login.note": "Your key stays in your signer. This site can never see it.",
|
||||
"login.extension.name": "Browser extension",
|
||||
"login.extension.desc": "Sign with Alby, nos2x or another NIP-07 extension",
|
||||
"login.extension.none": "No extension found",
|
||||
"login.extension.getOne": "Get one:",
|
||||
"login.remote.name": "Remote signer",
|
||||
"login.remote.desc": "Scan a code with a signer app, or paste a bunker link",
|
||||
"login.primal.name": "Primal",
|
||||
"login.primal.desc": "Approve the login in the Primal app on your phone",
|
||||
"login.paste.label": "Or paste the connection link your signer gave you",
|
||||
"login.copyLink": "Copy the connection link",
|
||||
"login.whatIsNostr": "What is Nostr?",
|
||||
"login.connect.remote.title": "Connect a remote signer",
|
||||
"login.connect.remote.lead": "Scan this with your signer app, or copy the link and paste it there.",
|
||||
"login.connect.remote.hint": "Works with any NIP-46 signer: nsec.app, Amber, Nostrify, your own bunker.",
|
||||
"login.connect.primal.title": "Connect with Primal",
|
||||
"login.connect.primal.lead": "Open Primal on your phone, find remote signing in its settings, and scan this code.",
|
||||
"login.connect.primal.hint": "Primal signs for other apps over NIP-46, so this is the same connection any signer uses, and your key stays in Primal. {link}",
|
||||
"login.status.buildingLink": "Building a connection link",
|
||||
"login.status.waitingExtension": "Waiting for your extension",
|
||||
"login.status.waitingSigner": "Waiting for your signer",
|
||||
"login.status.connecting": "Connecting to your signer",
|
||||
"login.status.connected": "Connected",
|
||||
"login.method.extension": "Extension",
|
||||
"login.method.remote": "Remote",
|
||||
"login.method.primal": "Primal",
|
||||
"login.account": "Your Nostr account",
|
||||
"login.copyNpub": "Copy your full npub",
|
||||
"login.logout": "Log out",
|
||||
"signer.status.waitingExtension": "Waiting for your extension",
|
||||
"signer.status.signed": "Signed",
|
||||
"signer.status.reconnecting": "Reconnecting to your signer",
|
||||
"signer.status.approveWindow": "Your signer needs you to approve this in its own window",
|
||||
"signer.status.waitingSigner": "Waiting for your signer",
|
||||
"signer.status.stillWaiting": "Still waiting for your signer to answer",
|
||||
"signer.status.connected": "Connected",
|
||||
"signer.error.noExtension": "No signing extension found. Install Alby or nos2x, then reload this page.",
|
||||
"signer.error.privateKey": "That looks like a private key, not a connection link. This site never takes a key. Paste the bunker:// link your signer gave you.",
|
||||
"signer.error.badLink": "That is not a connection link this site understands. Paste the bunker:// link from your signer.",
|
||||
"signer.error.noAnswer": "Your signer did not answer. It may be closed, or its relay may be down. Open it and try again.",
|
||||
"signer.error.timeout": "Your signer did not answer in time. Open it, check it is still connected, then try again.",
|
||||
"signer.error.cancelled": "Connection cancelled.",
|
||||
"signer.error.declined": "Signature request was declined",
|
||||
"notfound.title": "Page not found · Cashumints.space",
|
||||
"notfound.description": "That page does not exist on Cashumints.space.",
|
||||
"notfound.heading": "This page does not exist",
|
||||
"notfound.lede": "It may have moved, or the mint may not be indexed yet. Search for a mint by name or domain, or paste a mint URL.",
|
||||
"notfound.browse": "Browse all mints",
|
||||
"notfound.home": "Go home",
|
||||
"notfound.loading": "Loading {host}",
|
||||
"notfound.fb.rating": "Rating",
|
||||
"notfound.fb.reviews": "Reviews",
|
||||
"notfound.fb.software": "Software",
|
||||
"notfound.fb.lastOnline": "Last online",
|
||||
"notfound.fb.note": "This mint was indexed after the site was last built, so this is a summary from the live API. The full page with reviews appears at the next rebuild.",
|
||||
"wallets.title": "Cashu wallets · Cashumints.space",
|
||||
"wallets.description": "Cashu wallets that work with the mints listed here, across mobile, web, browser and the command line, with what each one is good at.",
|
||||
"wallets.heading": "Wallets",
|
||||
"wallets.lede": "You need a wallet to hold ecash from any of these mints. Most wallets talk to several mints at once, so you are not tied to the first one you try.",
|
||||
"wallets.note": "This list is not exhaustive and is not an endorsement. The Cashu project keeps a broader index at {link}. A wallet holds keys, a mint holds the money: read the mint reviews before you pick where your sats live.",
|
||||
"wallets.platform.android": "Android",
|
||||
"wallets.platform.ios": "iOS",
|
||||
"wallets.platform.web": "Web",
|
||||
"wallets.platform.extension": "Browser extension",
|
||||
"wallets.platform.cli": "CLI",
|
||||
"wallets.platform.python": "Python",
|
||||
"wallets.item.minibits": "Mobile ecash wallet with its own mint, Lightning address support and Nostr contacts.",
|
||||
"wallets.item.nutstash": "Browser wallet that handles multiple mints at once and imports and exports raw tokens.",
|
||||
"wallets.item.enuts": "Mobile wallet focused on multimint balances and sending ecash over Nostr.",
|
||||
"wallets.item.cashume": "Reference web wallet from the Cashu project. Runs entirely in the browser, installable as a PWA.",
|
||||
"wallets.item.boardwalk": "Web wallet built around Lightning addresses and sending ecash by link.",
|
||||
"wallets.item.coinos": "Lightning wallet and point of sale that also runs its own Cashu mint.",
|
||||
"wallets.item.oxchat": "Nostr messenger with an ecash wallet built in, for sending sats inside chats.",
|
||||
"wallets.item.nutshell": "The reference implementation. Runs as a wallet on the command line and as mint software.",
|
||||
"about.title": "About Cashumints.space",
|
||||
"about.description": "What Cashu is, what a mint actually holds, what NIP-87 is, and how this site indexes mints and reads reviews from Nostr relays.",
|
||||
"about.heading": "About",
|
||||
"about.lede": "Cashumints.space lists every Cashu mint we can find on the Nostr network and shows you what people say about them. It is made by Azzamo.",
|
||||
"about.cashu.title": "What Cashu is",
|
||||
"about.cashu.p1": "Cashu is an ecash protocol for Bitcoin. A mint issues blinded tokens backed by sats it holds, and those tokens move between people without the mint learning who sent what to whom. The privacy is real and it is the point: tokens are not accounts, and the mint cannot link the token it signed to the token that comes back.",
|
||||
"about.cashu.p2": "You can read the protocol and its specifications at {link}. The specifications themselves are called NUTs, which is where the NUT numbers on every mint page come from.",
|
||||
"about.mints.title": "What a mint is, and the tradeoff",
|
||||
"about.mints.p1": "A mint is custodial. When you mint ecash you are handing sats to whoever runs that server, and you are trusting them to honour the tokens when you melt them back out. There is no fraud proof, no escrow and no recourse. A mint operator can disappear with the balance, and mints have.",
|
||||
"about.mints.p2": "That is exactly why reviews exist here. This site never tells you a mint is safe. The status on a mint page says {code}, not \"trusted\", and it only means the server answered when we last asked. Treat ecash balances like cash in a coat pocket: fine for spending, wrong for savings.",
|
||||
"about.nip87.title": "What NIP-87 is",
|
||||
"about.nip87.p1": "NIP-87 is the Nostr convention for announcing and recommending ecash mints. Two event kinds matter here:",
|
||||
"about.nip87.li1": "{code} announces a mint. The mint URL is in the {tag} tag. This is how the site discovers mints it has never seen.",
|
||||
"about.nip87.li2": "{code} is a review. It carries the rating and the review text, and points at the mint it is about with a {utag} tag (the mint URL) and a {dtag} tag (the mint's own public key).",
|
||||
"about.nip87.p2": "Every review is signed by its author's Nostr key. We never call a review \"verified\", because nobody verified it: it is signed, which is a different and more useful claim. It means you can check who wrote it and that it was not altered. It does not mean the author is honest or ever used the mint.",
|
||||
"about.how.title": "How this site works",
|
||||
"about.how.p1": "There are two loops and one rule. The rule is that the mint owns its metadata and Nostr owns the reviews, so this site stores neither as an authority.",
|
||||
"about.how.li1": "{strong} asks every known mint for {code} every ten minutes and records what it says: name, description, version, supported NUTs, limits, contact details. It also records whether the mint answered at all, which is where uptime comes from.",
|
||||
"about.how.li1Label": "The probe loop",
|
||||
"about.how.li2": "{strong} connects to Nostr relays every hour, reads mint announcements and reviews, and adds any mint it has not seen before.",
|
||||
"about.how.li2Label": "The discovery loop",
|
||||
"about.how.p2": "The important part is what happens when a mint stops answering. Its cached metadata is never overwritten by a failed probe, so a dead mint keeps its full page: its specs, its history and its reviews, with a banner saying when it went dark. The previous version of this site showed an error page instead, which meant the mints people most needed to warn each other about were the ones that could not be reviewed. That was the main reason for the rebuild.",
|
||||
"about.how.p3": "Review text is never stored on our side. When you open a mint page your browser talks to the relays directly and reads the reviews itself, and when you write one it is signed by your own Nostr extension and sent straight to those relays. We keep a count so the pages can be ranked and sorted, nothing more. Nothing here can be edited or deleted by us, including reviews we would rather not have.",
|
||||
"about.relays.title": "Relays we read",
|
||||
"about.relays.p1": "These are the relays the indexer and your browser both use:",
|
||||
"about.relays.p2": "Point any Nostr client at the same relays and you will see the same events. That is the whole promise: nothing here is only true because we say so.",
|
||||
"about.nuts.title": "What are NUTs?",
|
||||
"about.nuts.p1": "NUTs are the numbered Cashu specifications, like BIPs for Bitcoin. A mint advertises which ones it implements, and that tells you what it can actually do: NUT-04 and NUT-05 are minting and melting over Lightning (without them a mint is not much use), NUT-07 lets a wallet check whether a token was already spent, NUT-17 gives wallets live updates over WebSockets instead of polling, and NUT-20 lets a wallet prove a mint quote was really issued to it. The full list is at {link}.",
|
||||
"about.numbers.title": "Right now",
|
||||
"about.numbers.p1": "{mints} mints indexed, {online} of them answering, {reviews} reviews counted from the relays above.",
|
||||
"about.source.title": "Source and credit",
|
||||
"about.source.p1": "The site is open source at {github}. It is built and run by {azzamo}. If a mint page has something wrong on it, fix it at the source: mint metadata comes from the mint's own {code}, and reviews come from Nostr.",
|
||||
"terms.title": "Terms of use · Cashumints.space",
|
||||
"terms.description": "Terms of use for Cashumints.space: what the site is, what it does not do, and who is responsible for what. No accounts, no warranties, no affiliation with any mint.",
|
||||
"terms.heading": "Terms of use",
|
||||
"terms.lede": "Plain terms for a site that only indexes public data. Last updated {date}.",
|
||||
"terms.what.title": "What this site is",
|
||||
"terms.what.p1": "Cashumints.space is an index of publicly available data about Cashu mints. Two kinds of data, both from somewhere else:",
|
||||
"terms.what.li1": "Mint metadata, read from each mint's own {code} endpoint: name, description, software version, supported NUTs, contact details and whether the mint answered when we last asked.",
|
||||
"terms.what.li2": "Reviews, read from public Nostr relays. These are NIP-87 events published by their authors to relays anyone can query.",
|
||||
"terms.what.p2": "We collect that data, normalise it, rank it and show it. We do not create it.",
|
||||
"terms.noAccounts.title": "No accounts, no warranties",
|
||||
"terms.noAccounts.p1": "There is nothing to sign up for. The site is provided as it is, with no warranty of any kind: not that the data is accurate, not that it is current, not that the site will be available. Mint data can be minutes or hours old, a mint can change after we last checked, and a relay can fail to answer, in which case you are seeing less than exists.",
|
||||
"terms.noAccounts.p2": "Use it at your own risk. We are not liable for any loss that follows from using this site or relying on anything you read here. See the {link} for the specific risks of using a Cashu mint.",
|
||||
"terms.noAccounts.link": "disclaimer",
|
||||
"terms.noCustody.title": "We do not hold your money",
|
||||
"terms.noCustody.p1": "This site never takes custody of funds. It has no wallet, it does not touch ecash, and it cannot move, freeze or recover anything. If you have a problem with a mint, the mint operator is the only party who can help you, and we cannot contact them on your behalf.",
|
||||
"terms.noCustody.p2": "We are not affiliated with any mint listed here, and no mint pays for its listing, its rank or its rating. Mints are discovered from public Nostr announcements and ranked by an algorithm over public review data.",
|
||||
"terms.reviews.title": "Reviews are other people's words",
|
||||
"terms.reviews.p1": "Reviews are user-generated content published to public Nostr relays by their authors. We display them, we do not control them. We did not write them, we cannot edit them, and we cannot delete them from the relays that hold them: they are signed events on a network we do not own.",
|
||||
"terms.reviews.p2": "A review is one person's experience, not a verified fact. If you publish one, you are responsible for what it says. Do not publish anything unlawful, and be aware that what you publish is public and permanent (see the {link}).",
|
||||
"terms.reviews.link": "privacy page",
|
||||
"terms.reviews.p3": "We can stop showing a specific review on this site, and we will do that for content that is unlawful. That removes it from these pages only. The event stays on the relays and any other Nostr client can still read it.",
|
||||
"terms.links.title": "Links to other sites",
|
||||
"terms.links.p1": "Mint URLs, wallet links, operator contacts and everything else that points off this site are there because they are part of the data. A link is not an endorsement and we do not vet what is on the other end.",
|
||||
"terms.changes.title": "Changes",
|
||||
"terms.changes.p1": "These terms can change. The date at the top says when they last did. The site is open source, so every change to this page is in the commit history.",
|
||||
"terms.contact.title": "Contact",
|
||||
"terms.contact.p1": "Issues and takedown requests are best filed on {github}. The site is run by {azzamo}.",
|
||||
"privacy.title": "Privacy · Cashumints.space",
|
||||
"privacy.description": "Privacy on Cashumints.space: no accounts, no tracking cookies, no analytics. localStorage is used for profile caching and nothing is sent anywhere. Reviews are signed in your own browser.",
|
||||
"privacy.heading": "Privacy",
|
||||
"privacy.lede": "We collect as little as a website can. Last updated {date}.",
|
||||
"privacy.noAccounts.title": "No accounts",
|
||||
"privacy.noAccounts.p1": "There is no sign up, no login and no profile. Nothing on this site asks you who you are, and there is no user record to leak because there are no users.",
|
||||
"privacy.noTracking.title": "No tracking cookies, no analytics",
|
||||
"privacy.noTracking.p1": "The site sets no cookies for tracking. There is no analytics script, no tag manager, no pixel, no session recording and no advertising network. Nothing on the page reports your behaviour to us or to anyone else.",
|
||||
"privacy.logs.title": "Server logs",
|
||||
"privacy.logs.p1": "The web server keeps standard access logs, which is what any web server does by default. A log line records your IP address, the time, the page you asked for, the response status, and the user agent and referrer your browser sent. The logs exist to debug errors and spot abuse. They are not joined to anything else and they are not sold or shared.",
|
||||
"privacy.logs.operatorLabel": "Operator note:",
|
||||
"privacy.logs.operator": "if you run this site, state your real retention here. The deployment in the README uses nginx with its default access log and whatever rotation your distribution ships (on Debian and Ubuntu that is logrotate, daily, keeping 14 days). Confirm what your server does and replace this paragraph with the number.",
|
||||
"privacy.storage.title": "What is stored in your browser",
|
||||
"privacy.storage.p1": "The site uses localStorage for exactly two things, both of which stay on your device and are never sent anywhere:",
|
||||
"privacy.storage.li1": "A cache of Nostr profile metadata (kind 0 events: names, avatars, NIP-05 addresses) for the reviewers whose reviews you have looked at. This saves re-querying relays on every page. Entries expire, and the cache is capped and evicted oldest first.",
|
||||
"privacy.storage.li2": "Interface preferences, so the site behaves the same way next time.",
|
||||
"privacy.storage.p2": "Clearing your browser's site data removes both. Nothing breaks, the profile lookups just run again.",
|
||||
"privacy.thirdParties.title": "What your browser talks to",
|
||||
"privacy.thirdParties.p1": "Loading a page connects your browser to a few places besides this site. That is how a site with no server-side tracking still reads live data:",
|
||||
"privacy.thirdParties.li1Label": "Public Nostr relays",
|
||||
"privacy.thirdParties.li1": "{strong}, over WebSocket, to read reviews and reviewer profiles. The relay sees your IP and what you asked for, as any server you connect to does. The relays are listed on the {link}.",
|
||||
"privacy.thirdParties.li1Link": "about page",
|
||||
"privacy.thirdParties.li2Label": "Reviewer avatar images",
|
||||
"privacy.thirdParties.li2": "{strong}, loaded from wherever the reviewer's profile points. These are sent with a no-referrer policy, so the host does not learn which page you were on.",
|
||||
"privacy.publishing.title": "Publishing a review",
|
||||
"privacy.publishing.p1": "Writing a review happens in your browser, not on our server. The event is signed by your NIP-07 extension (Alby, nos2x or similar) and broadcast from your browser straight to the relays. Your private key never leaves the extension and this site never sees it. We do not build custom key management and we never ask for a key or a seed phrase.",
|
||||
"privacy.permanent.title": "Anything you publish to Nostr is public and permanent",
|
||||
"privacy.permanent.p1": "This is the part worth reading twice. A review is a signed event on public relays. Once it is out, it is out:",
|
||||
"privacy.permanent.li1": "Anyone can read it, now and later, using any Nostr client. It is not \"public on Cashumints.space\", it is public on the network.",
|
||||
"privacy.permanent.li2": "It is permanently linked to your Nostr public key, alongside everything else that key has ever published.",
|
||||
"privacy.permanent.li3": "Deleting it is a request, not a command. You can publish a deletion event and some relays will honour it, but a relay is free to ignore it and anyone who already copied the event still has it. Treat a published review as permanent.",
|
||||
"privacy.permanent.li4": "We cannot delete it for you. We can stop displaying it here; that changes nothing on the relays.",
|
||||
"privacy.permanent.p2": "If you want to review a mint without that being tied to your main identity, use a separate Nostr key. Note that a key with no other activity is shown as such on harsh reviews, because a brand new key writing a one star review is a pattern readers deserve to see.",
|
||||
"privacy.contact.title": "Questions",
|
||||
"privacy.contact.p1": "The site is open source: if you want to check any of the above, read the code at {github}. Questions and corrections can go in an issue there.",
|
||||
"disclaimer.title": "Disclaimer · Cashumints.space",
|
||||
"disclaimer.description": "What to know before you use a Cashu mint: mints are custodial, a mint can disappear with your money, reviews are opinions and online means reachable, not trustworthy.",
|
||||
"disclaimer.heading": "Disclaimer",
|
||||
"disclaimer.lede": "Read this before you put money in a mint. Last updated {date}.",
|
||||
"disclaimer.callout": "A Cashu mint holds your money for you. If it disappears, your ecash is gone, and there is nobody to appeal to. Keep only what you can afford to lose.",
|
||||
"disclaimer.custodial.title": "Mints are custodial",
|
||||
"disclaimer.custodial.p1": "When you mint ecash, you hand bitcoin to the mint operator and get tokens back. Those tokens are a claim on that operator. They are not bitcoin you hold, and the operator is the one who decides whether to honour them.",
|
||||
"disclaimer.custodial.p2": "That means every mint carries the same risks: the operator can vanish, can be compelled to stop, can lose the funds through a bug or a hack, or can simply turn the server off. There is no deposit insurance, no chargeback, no support line and no legal recourse worth counting on. This is true of the highest rated mint on this site.",
|
||||
"disclaimer.custodial.p3": "Cashu is designed for small amounts moving quickly. Use it that way: hold what you are about to spend, not your savings, and spread across mints rather than parking everything in one.",
|
||||
"disclaimer.reviews.title": "Reviews are opinions, not verified claims",
|
||||
"disclaimer.reviews.p1": "Every review here was written by someone on Nostr and signed with their key. Signed means we can tell you which key wrote it. It does not mean the review is true, that the author used the mint, or that the author is not the operator, a competitor, or one person with ten keys.",
|
||||
"disclaimer.reviews.p2": "We do not verify reviewers, and we do not edit or remove reviews we disagree with. Read a few, look at what they actually say, and weigh a mint with three reviews differently from one with three hundred.",
|
||||
"disclaimer.status.title": "Online means reachable, not trustworthy",
|
||||
"disclaimer.status.p1": "We check every mint regularly and show whether it answered. That is all a green status means: the server responded. It says nothing about whether the operator is solvent, honest, or still intends to honour your tokens tomorrow.",
|
||||
"disclaimer.status.p2": "A mint can be online right up to the moment it is not. Uptime history, supported NUTs and software version tell you something about how a mint is run, but none of them are a promise about your money.",
|
||||
"disclaimer.ranking.title": "How ranking works, and what it does not mean",
|
||||
"disclaimer.ranking.p1": "Mints are ordered by an algorithm over public review data: rating, number of reviews and recent activity, with online mints ahead of offline ones by default. The details are in the open source code.",
|
||||
"disclaimer.ranking.p2": "Nobody pays for placement. There is no sponsored slot, no promoted listing and no arrangement with any mint on this site. A high rank means the algorithm liked the public data, and nothing more. It is not a recommendation and it is not a safety rating.",
|
||||
"disclaimer.notAdvice.title": "This is not financial advice",
|
||||
"disclaimer.notAdvice.p1": "Nothing on this site is financial, investment, legal or tax advice. We do not know your situation and we are not advising you. Using a mint, and choosing which one, is your decision.",
|
||||
"disclaimer.dyor.title": "Do your own research",
|
||||
"disclaimer.dyor.li1": "Look up the operator. A mint run by someone with a public reputation to lose is a different proposition from an anonymous URL.",
|
||||
"disclaimer.dyor.li2": "Read the mint's own terms and its MOTD. A mint that calls itself a test or a beta means it.",
|
||||
"disclaimer.dyor.li3": "Start small. Mint a little, spend it, melt it back out, and see what happens before you trust it with more.",
|
||||
"disclaimer.dyor.li4": "Watch the limits. A low maximum is often a sign of a careful operator, and a high one is not a sign of a safe one.",
|
||||
"disclaimer.dyor.li5": "Keep your tokens backed up, and remember that a token is a bearer asset: whoever holds it can spend it.",
|
||||
"disclaimer.site.title": "About the site itself",
|
||||
"disclaimer.site.p1": "Data here can be wrong or out of date. Mints are probed on a schedule, relays sometimes fail to answer, and a page you are reading was built at some point in the past. The site is provided as it is, with no warranty. See the {link}.",
|
||||
"disclaimer.site.link": "terms of use",
|
||||
"disclaimer.updated": "20 August 2026",
|
||||
"login.getPrimal": "Get Primal",
|
||||
"reviews.dialog.identity.extensionGone": "Your browser extension is not answering. Log in again, or use a remote signer.",
|
||||
"seo.orgDescription": "A directory of Cashu mints, with live status from each mint and community reviews signed on Nostr.",
|
||||
"seo.ogImageAlt": "Cashumints.space: find a Cashu mint you can trust.",
|
||||
"nav.home": "Home",
|
||||
"mint.description.fallbackDomain": "A Cashu mint running at {domain}.",
|
||||
"mint.more.title": "More mints",
|
||||
"mint.more.all": "See all mints"
|
||||
}
|
||||
@@ -0,0 +1,554 @@
|
||||
{
|
||||
"site.name": "Cashumints.space",
|
||||
"common.close": "Cerrar",
|
||||
"common.cancel": "Cancelar",
|
||||
"common.done": "Listo",
|
||||
"common.back": "Atrás",
|
||||
"common.retry": "Reintentar",
|
||||
"common.connect": "Conectar",
|
||||
"common.copied": "Copiado",
|
||||
"common.copyFull": "Copiar {kind} completo",
|
||||
"common.previousPage": "Página anterior",
|
||||
"common.nextPage": "Página siguiente",
|
||||
"common.reviewPages": "Páginas de reseñas",
|
||||
"time.never": "nunca",
|
||||
"time.justNow": "ahora mismo",
|
||||
"time.none": "-",
|
||||
"time.unknownDate": "una fecha desconocida",
|
||||
"time.ago.minute": "hace {n} min",
|
||||
"time.ago.hour": "hace {n} h",
|
||||
"time.ago.day": "hace {n} d",
|
||||
"time.ago.week": "hace {n} sem",
|
||||
"time.ago.month.one": "hace {n} mes",
|
||||
"time.ago.month.other": "hace {n} meses",
|
||||
"time.ago.year.one": "hace {n} año",
|
||||
"time.ago.year.other": "hace {n} años",
|
||||
"time.short.minute": "{n} min",
|
||||
"time.short.hour": "{n} h",
|
||||
"time.short.day": "{n} d",
|
||||
"time.short.week": "{n} sem",
|
||||
"time.short.month.one": "{n} mes",
|
||||
"time.short.month.other": "{n} meses",
|
||||
"time.short.year.one": "{n} año",
|
||||
"time.short.year.other": "{n} años",
|
||||
"status.online": "En línea",
|
||||
"status.offline": "Sin conexión",
|
||||
"status.degraded": "No responde",
|
||||
"status.unknown": "Sin comprobar aún",
|
||||
"nav.skip": "Saltar al contenido",
|
||||
"nav.main": "Principal",
|
||||
"nav.mints": "Mints",
|
||||
"nav.reviews": "Reseñas",
|
||||
"nav.wallets": "Monederos",
|
||||
"nav.about": "Acerca de",
|
||||
"nav.searchMints": "Buscar en {n} mints",
|
||||
"lang.label": "Idioma",
|
||||
"lang.switcher": "Elige un idioma",
|
||||
"footer.copyright": "© {year} Cashumints.space",
|
||||
"footer.allMints": "Todos los mints",
|
||||
"footer.allReviews": "Todas las reseñas",
|
||||
"footer.wallets": "Monederos",
|
||||
"footer.about": "Acerca de",
|
||||
"footer.disclaimer": "Aviso legal",
|
||||
"footer.terms": "Términos",
|
||||
"footer.privacy": "Privacidad",
|
||||
"footer.madeBy": "Hecho con ❤️ por {azzamo} · Funciona con Cashu y NIP-87",
|
||||
"footer.warning": "Los mints son custodiales. Un mint puede desaparecer con tu dinero.",
|
||||
"home.title": "Cashumints.space · Encuentra un mint Cashu en el que confiar",
|
||||
"home.description": "Encuentra un mint Cashu en el que confiar. {mints} mints indexados con {reviews} reseñas de la comunidad firmadas en Nostr, además de disponibilidad en directo, NUTs compatibles y software del mint.",
|
||||
"home.hero.title": "Encuentra un mint Cashu",
|
||||
"home.hero.titleEm": "en el que confiar",
|
||||
"home.hero.lede": "Todos los mints de la red Nostr, con reseñas reales de la comunidad. Sin intermediarios, solo reseñas firmadas que puedes verificar tú mismo.",
|
||||
"home.search.label": "Buscar mints por nombre, dominio o URL",
|
||||
"home.search.placeholder": "Busca por nombre, dominio, o pega la URL de un mint…",
|
||||
"home.filters.mostReviewed": "Más reseñados",
|
||||
"home.filters.highestRated": "Mejor valorados",
|
||||
"home.filters.recentlyActive": "Activos hace poco",
|
||||
"home.filters.websockets": "Compatibles con WebSockets",
|
||||
"home.top.title": "Mejores mints",
|
||||
"home.top.sub": "ordenados por reseñas y valoración",
|
||||
"home.top.all": "Ver los {n} mints →",
|
||||
"home.latest.title": "Últimas reseñas",
|
||||
"home.latest.sub": "en directo desde la red Nostr",
|
||||
"home.latest.prev": "Reseñas anteriores",
|
||||
"home.latest.next": "Más reseñas",
|
||||
"home.latest.all": "Ver todas las reseñas →",
|
||||
"home.latest.empty": "Ahora mismo no se ha podido leer ninguna reseña de los relays. Abre la página de cualquier mint para leer y escribir reseñas directamente.",
|
||||
"home.why.title": "¿Por qué fiarte de estas reseñas?",
|
||||
"home.why.signed.title": "Firmadas, no enviadas",
|
||||
"home.why.signed.body": "Cada reseña es un evento {link} firmado con la clave Nostr de quien la escribió. Aquí no editamos, ordenamos a mano ni borramos nada.",
|
||||
"home.why.signed.link": "NIP-87",
|
||||
"home.why.verify.title": "Compruébalo tú mismo",
|
||||
"home.why.verify.body": "Las reseñas viven en relays públicos de Nostr, no en nuestra base de datos. Apunta cualquier cliente a esos mismos relays y verás los mismos datos.",
|
||||
"home.why.checked.title": "Comprobados continuamente",
|
||||
"home.why.checked.body": "Comprobamos cada mint para saber su disponibilidad, versión del software, límites y NUTs compatibles, así que lo que ves está al día y no lo dice el propio mint.",
|
||||
"home.cta.title": "¿Estás pensando en un mint? Léelo primero.",
|
||||
"home.cta.body": "Un mint guarda tu ecash. Lee lo que ha encontrado otra gente antes de meter sats, y deja una reseña cuando hayas usado uno tú.",
|
||||
"home.cta.explore": "Explorar todos los mints",
|
||||
"home.cta.learn": "Aprender sobre Cashu",
|
||||
"pulse.mintsIndexed": "mints indexados",
|
||||
"pulse.reviewsOnNostr": "reseñas en Nostr",
|
||||
"pulse.lastReview": "última reseña",
|
||||
"pulse.mintsOnline": "mints en línea",
|
||||
"pulse.indexer": "indexador",
|
||||
"pulse.connected": "conectado",
|
||||
"pulse.stale": "desactualizado",
|
||||
"mints.title": "Todos los mints Cashu · Cashumints.space",
|
||||
"mints.description": "Los {total} mints Cashu indexados desde la red Nostr, {online} en línea y {offline} sin conexión, con valoraciones de la comunidad, disponibilidad y software del mint. Busca y ordena la lista completa.",
|
||||
"mints.heading": "Todos los mints",
|
||||
"mints.lede": "Todos los mints descubiertos en la red Nostr, incluidos los que ya no están. Los mints sin conexión siguen en la lista para que puedas encontrarlos y reseñarlos.",
|
||||
"mints.search.label": "Buscar mints por nombre o dominio",
|
||||
"mints.search.placeholder": "Busca por nombre o dominio…",
|
||||
"mints.sort.label": "Ordenar mints",
|
||||
"mints.sort.score": "Mejores en general",
|
||||
"mints.sort.rating": "Mejor valorados",
|
||||
"mints.sort.reviews": "Más reseñados",
|
||||
"mints.sort.recent": "Reseñados hace poco",
|
||||
"mints.sort.seen": "Vistos en línea hace poco",
|
||||
"mints.hideOffline": "Ocultar sin conexión",
|
||||
"mints.showing": "Mostrando {total} mints ({online} en línea, {offline} sin conexión)",
|
||||
"mints.showingFiltered": "Mostrando {shown} de {total} mints ({online} en línea, {offline} sin conexión)",
|
||||
"mints.noResults": "Ningún mint coincide. Prueba con un dominio, o pega la URL de un mint.",
|
||||
"mints.clearSearch": "Borrar la búsqueda",
|
||||
"card.noRatings": "Sin valoraciones aún",
|
||||
"card.reviews.one": "{n} reseña",
|
||||
"card.reviews.other": "{n} reseñas",
|
||||
"card.lastSeen": "visto {when}",
|
||||
"card.neverSeen": "nunca visto en línea",
|
||||
"card.reviewed": "reseñado {when}",
|
||||
"card.noReviews": "sin reseñas aún",
|
||||
"mint.title": "{name} · Reseñas del mint Cashu",
|
||||
"mint.description.fallback": "{name} es un mint Cashu en {domain}.",
|
||||
"mint.description.rated.one": "Valorado con {rating} sobre 5 a partir de {n} reseña firmada en Nostr.",
|
||||
"mint.description.rated.other": "Valorado con {rating} sobre 5 a partir de {n} reseñas firmadas en Nostr.",
|
||||
"mint.description.noReviews": "Todavía no hay reseñas de la comunidad.",
|
||||
"mint.description.online": "Ahora mismo en línea.",
|
||||
"mint.description.unreachable": "Ahora mismo no responde.",
|
||||
"mint.breadcrumb": "Ruta de navegación",
|
||||
"mint.crumbMints": "Mints",
|
||||
"mint.copyUrl": "Copiar la URL del mint",
|
||||
"mint.qrButton": "QR del mint",
|
||||
"mint.writeReview": "Escribir una reseña",
|
||||
"mint.rankByScore": "#{rank} por puntuación",
|
||||
"mint.tagLightning": "Lightning",
|
||||
"mint.summary": "Resumen del mint",
|
||||
"mint.rating.label": "Valoración de la comunidad",
|
||||
"mint.rating.none": "Sin valoraciones aún",
|
||||
"mint.rating.reviews.one": "{n} reseña",
|
||||
"mint.rating.reviews.other": "{n} reseñas",
|
||||
"mint.rating.unrated.one": "{n} no dejó valoración",
|
||||
"mint.rating.unrated.other": "{n} no dejaron valoración",
|
||||
"mint.activity.label": "Actividad",
|
||||
"mint.activity.since": "desde la última reseña",
|
||||
"mint.activity.noReviews": "sin reseñas aún",
|
||||
"mint.activity.recent.one": "{n} reseña en los últimos 90 días",
|
||||
"mint.activity.recent.other": "{n} reseñas en los últimos 90 días",
|
||||
"mint.limits.label": "Límites",
|
||||
"mint.limits.notPublished": "sin publicar",
|
||||
"mint.limits.min": "mín",
|
||||
"mint.limits.max": "máx",
|
||||
"mint.limits.mintRow": "acuñar",
|
||||
"mint.limits.meltRow": "fundir",
|
||||
"mint.limits.disabled": "desactivado",
|
||||
"mint.limits.range": "de {min} a {max} {unit}",
|
||||
"mint.limits.upTo": "hasta {max} {unit}",
|
||||
"mint.limits.from": "desde {min} {unit}",
|
||||
"mint.software.label": "Software del mint",
|
||||
"mint.software.unknown": "desconocido",
|
||||
"mint.checked": "comprobado {when}",
|
||||
"mint.lastSeen": "visto {when}",
|
||||
"mint.neverReached": "nunca alcanzado",
|
||||
"mint.uptime.checks": "{ok} de las últimas {total} comprobaciones respondieron",
|
||||
"mint.uptime.percent": "{percent}% de disponibilidad en 30 días",
|
||||
"mint.uptime.description": "{ok} de las últimas {total} comprobaciones salieron bien",
|
||||
"mint.operator.title": "De quien lo opera",
|
||||
"mint.operator.motd": "Mensaje del día",
|
||||
"mint.operator.beta": "Este mint se describe a sí mismo como experimental o en beta. Lee abajo lo que dice quien lo opera antes de guardar ahí nada que no puedas perder.",
|
||||
"mint.nuts.title": "NUTs compatibles",
|
||||
"mint.nuts.noneOffline": "No hay lista de NUTs guardada para este mint, se quedó sin conexión antes de que se registrara una.",
|
||||
"mint.nuts.noneYet": "Todavía no hay lista de NUTs guardada para este mint.",
|
||||
"mint.nuts.supported": "{n} de {total} NUTs conocidos son compatibles",
|
||||
"mint.nuts.whatAre": "¿qué son los NUTs?",
|
||||
"mint.nutRow.disabled": "desactivado",
|
||||
"mint.nutRow.notSupported": "no compatible",
|
||||
"mint.details.title": "Datos del mint",
|
||||
"mint.details.pubkey": "Pubkey",
|
||||
"mint.details.nostr": "Nostr",
|
||||
"mint.details.email": "Correo",
|
||||
"mint.details.serverTime": "Hora del servidor",
|
||||
"mint.details.firstSeen": "Visto por primera vez",
|
||||
"mint.details.terms": "Términos",
|
||||
"mint.kind.identifier": "identificador",
|
||||
"mint.kind.mintPubkey": "pubkey del mint",
|
||||
"mint.kind.nostrAddress": "dirección Nostr",
|
||||
"mint.kind.emailAddress": "dirección de correo",
|
||||
"mint.kind.npub": "npub",
|
||||
"mint.disclaimer": "Este mint es custodial: guarda tu dinero, y puede dejar de hacerlo en cualquier momento. Las valoraciones son opiniones de la comunidad, y en línea significa accesible, no fiable.",
|
||||
"mint.disclaimerLink": "Leer el aviso legal completo",
|
||||
"mint.qr.title": "Añade {name} a tu monedero",
|
||||
"mint.qr.note": "Escanea esto con un monedero Cashu para añadir el mint, o copia la URL y pégala ahí.",
|
||||
"mint.qr.warn": "Añadir un mint significa confiarle tu dinero.",
|
||||
"mint.qr.whatMeans": "Qué significa eso",
|
||||
"mint.qr.wallets": "Monederos compatibles con Cashu",
|
||||
"mint.chip.frozen": "Congelado",
|
||||
"mint.chip.noWithdrawals": "Sin retiradas",
|
||||
"mint.chip.meltOnly": "Solo fundir",
|
||||
"mint.warnings.dayCount.one": "{n} día",
|
||||
"mint.warnings.dayCount.other": "{n} días",
|
||||
"mint.warnings.gone.lead": "Probablemente ha desaparecido.",
|
||||
"mint.warnings.gone.body.since": "Este mint lleva sin responder desde el {date} ({days}). Da por perdido el dinero que tengas ahí y no envíes más. Los datos de abajo son su último estado conocido.",
|
||||
"mint.warnings.gone.body.neverDated": "Este mint no ha respondido a ni una sola comprobación desde que empezamos a vigilarlo en {month}. Da por perdido el dinero que tengas ahí y no envíes más. Todo lo de abajo viene del anuncio en Nostr, no del propio mint.",
|
||||
"mint.warnings.gone.body.never": "Este mint no ha respondido a ni una sola comprobación desde que empezamos a vigilarlo. Da por perdido el dinero que tengas ahí y no envíes más. Todo lo de abajo viene del anuncio en Nostr, no del propio mint.",
|
||||
"mint.warnings.gone.meta.since": "Sin conexión desde el {date}",
|
||||
"mint.warnings.gone.meta.never": "Nunca ha respondido desde que se descubrió",
|
||||
"mint.warnings.meltDisabled.lead": "Retiradas desactivadas.",
|
||||
"mint.warnings.meltDisabled.body.online": "Este mint tiene desactivado el fundido por Lightning (melt, NUT-05): puedes meter sats, pero no puedes sacarlos por Lightning. No deposites hasta que el fundido vuelva a funcionar.",
|
||||
"mint.warnings.meltDisabled.body.offline": "Este mint tenía desactivado el fundido por Lightning (melt, NUT-05): puedes meter sats, pero no puedes sacarlos por Lightning. No deposites hasta que el fundido vuelva a funcionar.",
|
||||
"mint.warnings.meltDisabled.meta.online": "Retiradas desactivadas ahora mismo",
|
||||
"mint.warnings.meltDisabled.meta.offline": "Las retiradas estaban desactivadas la última vez que respondió",
|
||||
"mint.warnings.frozen.lead": "Mint congelado.",
|
||||
"mint.warnings.frozen.body.online": "La acuñación y el fundido están los dos desactivados. No entra ni sale ni un sat por Lightning.",
|
||||
"mint.warnings.frozen.body.offline": "La acuñación y el fundido estaban los dos desactivados. No entraba ni salía ni un sat por Lightning.",
|
||||
"mint.warnings.frozen.meta.online": "Congelado ahora mismo, no entra ni sale nada",
|
||||
"mint.warnings.frozen.meta.offline": "Estaba congelado la última vez que respondió",
|
||||
"mint.warnings.offlineLong.lead": "Sin conexión desde hace {days}, desde el {date}.",
|
||||
"mint.warnings.offlineLong.body": "Puede que el mint haya desaparecido. Todo lo de abajo es su último estado conocido, y las reseñas siguen funcionando.",
|
||||
"mint.warnings.offlineLong.meta": "Sin conexión desde el {date}",
|
||||
"mint.warnings.meltOnly.lead": "Solo fundir.",
|
||||
"mint.warnings.meltOnly.body.online": "La acuñación de ecash nuevo (mint, NUT-04) está desactivada: no puedes depositar, pero el ecash que ya tienes todavía se puede retirar. Esto suele querer decir que el mint está cerrando, así que si tienes ecash suyo, plantéate fundirlo y sacarlo.",
|
||||
"mint.warnings.meltOnly.body.offline": "La acuñación de ecash nuevo (mint, NUT-04) estaba desactivada: no puedes depositar, pero el ecash que ya tienes todavía se puede retirar. Esto suele querer decir que el mint está cerrando, así que si tienes ecash suyo, plantéate fundirlo y sacarlo.",
|
||||
"mint.warnings.meltOnly.meta.online": "Ahora mismo solo se puede fundir",
|
||||
"mint.warnings.meltOnly.meta.offline": "Solo se podía fundir la última vez que respondió",
|
||||
"mint.warnings.offline.lead": "Sin conexión desde el {date}.",
|
||||
"mint.warnings.offline.body": "Los datos se confirmaron por última vez entonces. Todavía puedes escribir una reseña.",
|
||||
"mint.warnings.offline.meta": "Sin conexión desde el {date}",
|
||||
"mint.warnings.also.meltDisabled": "Antes de quedarse sin conexión también había desactivado las retiradas.",
|
||||
"mint.warnings.also.mintDisabled": "Antes de quedarse sin conexión también había desactivado la acuñación de ecash nuevo.",
|
||||
"mint.warnings.also.offline": "Además lleva sin conexión desde el {date} ({days}).",
|
||||
"nut.0": "Notación y modelos",
|
||||
"nut.1": "Intercambio de la clave pública del mint",
|
||||
"nut.2": "Conjuntos de claves y comisiones",
|
||||
"nut.3": "Intercambio de tokens",
|
||||
"nut.4": "Acuñación por Lightning",
|
||||
"nut.5": "Fundido por Lightning",
|
||||
"nut.6": "Información del mint",
|
||||
"nut.7": "Comprobación del estado de un token",
|
||||
"nut.8": "Comisiones de Lightning pagadas de más",
|
||||
"nut.9": "Restaurar firmas",
|
||||
"nut.10": "Condiciones de gasto",
|
||||
"nut.11": "Pago a clave pública",
|
||||
"nut.12": "Pruebas DLEQ",
|
||||
"nut.13": "Secretos deterministas",
|
||||
"nut.14": "Contratos con bloqueo temporal por hash",
|
||||
"nut.15": "Pagos por varias rutas",
|
||||
"nut.16": "Códigos QR animados",
|
||||
"nut.17": "Suscripciones por WebSocket",
|
||||
"nut.18": "Solicitudes de pago",
|
||||
"nut.19": "Respuestas en caché",
|
||||
"nut.20": "Cotizaciones de acuñación firmadas",
|
||||
"nut.21": "Autenticación en claro",
|
||||
"nut.22": "Autenticación ciega",
|
||||
"nut.23": "Método de pago BOLT11",
|
||||
"nut.24": "HTTP 402, pago requerido",
|
||||
"nut.25": "Método de pago BOLT12",
|
||||
"reviews.title": "Reseñas",
|
||||
"reviews.anon": "Anón",
|
||||
"reviews.filter.group": "Filtrar reseñas",
|
||||
"reviews.filter.all": "Todas",
|
||||
"reviews.filter.five": "5★",
|
||||
"reviews.filter.critical": "Críticas",
|
||||
"reviews.filter.recent": "Recientes",
|
||||
"reviews.loading.one": "Cargando {n} reseña de los relays de Nostr",
|
||||
"reviews.loading.other": "Cargando {n} reseñas de los relays de Nostr",
|
||||
"reviews.empty": "Todavía no hay reseñas. Sé quien escriba la primera.",
|
||||
"reviews.emptyWritten": "Todavía no hay reseñas escritas aquí. Sé quien escriba la primera.",
|
||||
"reviews.emptyFilter": "Ninguna reseña coincide con este filtro.",
|
||||
"reviews.noscript": "Las reseñas están firmadas en Nostr y se cargan en tu navegador, así que esta lista necesita JavaScript. La valoración y los recuentos de arriba vienen del propio sitio.",
|
||||
"reviews.relayError": "No se ha podido llegar al relay. Puede que falten reseñas.",
|
||||
"reviews.srRating": "{rating} sobre 5",
|
||||
"reviews.srNoRating": "Sin valoración",
|
||||
"reviews.anonNote": "Primera reseña de este npub, no se ha encontrado ninguna otra actividad",
|
||||
"reviews.published": "Publicada.",
|
||||
"reviews.publishedPartial": "Publicada en {accepted} de {total} relays.",
|
||||
"reviews.propagating": "Propagándose, puede tardar un momento en aparecer en otros sitios.",
|
||||
"reviews.summary.line.one": "{count} valoración sin comentario: {breakdown}",
|
||||
"reviews.summary.line.other": "{count} valoraciones sin comentario: {breakdown}",
|
||||
"reviews.summary.lineDay.one": "{count} valoración sin comentario del {when}: {breakdown}",
|
||||
"reviews.summary.lineDay.other": "{count} valoraciones sin comentario del {when}: {breakdown}",
|
||||
"reviews.summary.showOne": "Mostrarla",
|
||||
"reviews.summary.showMany": "Mostrarlas",
|
||||
"reviews.breakdown.5": "{n} de cinco estrellas",
|
||||
"reviews.breakdown.4": "{n} de cuatro estrellas",
|
||||
"reviews.breakdown.3": "{n} de tres estrellas",
|
||||
"reviews.breakdown.2": "{n} de dos estrellas",
|
||||
"reviews.breakdown.1": "{n} de una estrella",
|
||||
"reviews.breakdown.none": "{n} sin valoración",
|
||||
"reviews.dialog.title": "Reseñar {name}",
|
||||
"reviews.dialog.note": "Se firma con tu clave Nostr y se publica en relays públicos. No se puede editar ni borrar.",
|
||||
"reviews.dialog.ratingLegend": "Tu valoración",
|
||||
"reviews.dialog.starsSr.one": "{n} estrella",
|
||||
"reviews.dialog.starsSr.other": "{n} estrellas",
|
||||
"reviews.dialog.chooseRating": "Elige una valoración",
|
||||
"reviews.dialog.starLabel": "{n}: {phrase}",
|
||||
"reviews.dialog.stars.1": "evítalo",
|
||||
"reviews.dialog.stars.2": "tuve problemas",
|
||||
"reviews.dialog.stars.3": "funcionó, con reparos",
|
||||
"reviews.dialog.stars.4": "bien",
|
||||
"reviews.dialog.stars.5": "funciona genial",
|
||||
"reviews.dialog.bodyLabel": "¿Qué debería saber la gente?",
|
||||
"reviews.dialog.bodyPlaceholder": "¿Qué tal fue acuñar, fundir y el soporte? Sé concreto.",
|
||||
"reviews.dialog.charCount": "{n} / {max}",
|
||||
"reviews.dialog.submit": "Firmar y publicar",
|
||||
"reviews.dialog.signing": "Firmando…",
|
||||
"reviews.dialog.publishing": "Publicando…",
|
||||
"reviews.dialog.publishingTo": "Publicando en los relays ({accepted}/{total})",
|
||||
"reviews.dialog.waitingExtension": "Esperando a tu extensión",
|
||||
"reviews.dialog.waitingSigner": "Esperando a tu firmante",
|
||||
"reviews.dialog.confirmClose": "¿Cerrar esta reseña? Tu borrador se guarda hasta que salgas del sitio.",
|
||||
"reviews.dialog.why.login": "Inicia sesión para publicar",
|
||||
"reviews.dialog.why.rating": "Elige una valoración",
|
||||
"reviews.dialog.why.words": "Escribe unas palabras",
|
||||
"reviews.dialog.why.more.one": "{n} carácter más",
|
||||
"reviews.dialog.why.more.other": "{n} caracteres más",
|
||||
"reviews.dialog.why.over.one": "{n} carácter de más",
|
||||
"reviews.dialog.why.over.other": "{n} caracteres de más",
|
||||
"reviews.dialog.identity.needSigner": "Necesitas un firmante de Nostr para publicar. Tu clave se queda en él, este sitio no la ve nunca.",
|
||||
"reviews.dialog.identity.loginToPublish": "Inicia sesión para publicar",
|
||||
"reviews.dialog.identity.switch": "Cambiar",
|
||||
"reviews.dialog.identity.extension": "Firmando con la extensión de tu navegador",
|
||||
"reviews.dialog.identity.readingKey": "Leyendo tu clave pública…",
|
||||
"reviews.dialog.identity.willAsk": "Se le pedirá a tu extensión que firme",
|
||||
"reviews.dialog.error.noRelay": "Ningún relay ha aceptado la reseña. Comprueba tu conexión y vuelve a intentarlo.",
|
||||
"reviews.validate.rating": "Elige una valoración de 1 a 5 estrellas.",
|
||||
"reviews.validate.short": "Escribe al menos {n} caracteres para que la reseña sirva de algo.",
|
||||
"reviews.validate.long": "Las reseñas están limitadas a {n} caracteres.",
|
||||
"feed.title": "Últimas reseñas de mints Cashu · Cashumints.space",
|
||||
"feed.description": "Cada reseña de un mint Cashu según se publica: {reviews} reseñas de {mints} mints, firmadas en Nostr y legibles aquí de la más nueva a la más antigua. Filtra por valoración o por mint.",
|
||||
"feed.heading": "Últimas reseñas de mints Cashu",
|
||||
"feed.lede": "Todas las reseñas de todos los mints, de la más nueva a la más antigua, leídas en directo de los relays de Nostr. Cada una está firmada por quien la escribió y vive en los relays, no en nuestra base de datos.",
|
||||
"feed.label": "Reseñas",
|
||||
"feed.filterByMint": "Filtrar por mint",
|
||||
"feed.anyMint": "Cualquier mint",
|
||||
"feed.clearMint": "Borrar el filtro de mint",
|
||||
"feed.showingBuild.one": "Mostrando la reseña más reciente de la última compilación",
|
||||
"feed.showingBuild.other": "Mostrando las {n} reseñas más recientes de la última compilación",
|
||||
"feed.noscript": "Las reseñas están firmadas en Nostr y se cargan en tu navegador, así que los filtros y la lista completa necesitan JavaScript. Las {n} reseñas de abajo se leyeron de los relays cuando se compiló esta página.",
|
||||
"feed.emptyBuild": "No se pudo leer ninguna reseña de los relays cuando se compiló esta página. La lista se vuelve a cargar desde los relays en tu navegador.",
|
||||
"feed.foot": "Las reseñas son opiniones de la comunidad, no hechos verificados. Lee el {link} antes de confiarle dinero a un mint.",
|
||||
"feed.footLink": "aviso legal",
|
||||
"feed.showing": "Mostrando {shown} de {total} reseñas",
|
||||
"feed.showingMatching": "Mostrando {shown} de {matching} reseñas que coinciden ({total} en total)",
|
||||
"feed.emptyRelays": "Los relays no han devuelto ninguna reseña. Abre la página de cualquier mint para leer y escribir reseñas ahí.",
|
||||
"feed.emptyFilter": "Ninguna reseña coincide con este filtro. Borra la caja del mint, o prueba con Todas.",
|
||||
"feed.relayError": "No se ha podido llegar a los relays. Mostrando las reseñas de la última compilación.",
|
||||
"feed.relayEmpty": "Los relays no han devuelto ninguna reseña. Mostrando las reseñas de la última compilación.",
|
||||
"feed.reading": "Leyendo los relays…",
|
||||
"login.open": "Iniciar sesión",
|
||||
"login.title": "Inicia sesión con Nostr",
|
||||
"login.note": "Tu clave se queda en tu firmante. Este sitio no puede verla nunca.",
|
||||
"login.extension.name": "Extensión del navegador",
|
||||
"login.extension.desc": "Firma con Alby, nos2x u otra extensión NIP-07",
|
||||
"login.extension.none": "No se ha encontrado ninguna extensión",
|
||||
"login.extension.getOne": "Consigue una:",
|
||||
"login.remote.name": "Firmante remoto",
|
||||
"login.remote.desc": "Escanea un código con una app de firma, o pega un enlace bunker",
|
||||
"login.primal.name": "Primal",
|
||||
"login.primal.desc": "Aprueba el inicio de sesión en la app de Primal en tu móvil",
|
||||
"login.paste.label": "O pega el enlace de conexión que te dio tu firmante",
|
||||
"login.copyLink": "Copiar el enlace de conexión",
|
||||
"login.whatIsNostr": "¿Qué es Nostr?",
|
||||
"login.connect.remote.title": "Conectar un firmante remoto",
|
||||
"login.connect.remote.lead": "Escanea esto con tu app de firma, o copia el enlace y pégalo ahí.",
|
||||
"login.connect.remote.hint": "Funciona con cualquier firmante NIP-46: nsec.app, Amber, Nostrify, tu propio bunker.",
|
||||
"login.connect.primal.title": "Conectar con Primal",
|
||||
"login.connect.primal.lead": "Abre Primal en tu móvil, busca la firma remota en sus ajustes y escanea este código.",
|
||||
"login.connect.primal.hint": "Primal firma para otras apps por NIP-46, así que esta es la misma conexión que usa cualquier firmante, y tu clave se queda en Primal. {link}",
|
||||
"login.status.buildingLink": "Creando un enlace de conexión",
|
||||
"login.status.waitingExtension": "Esperando a tu extensión",
|
||||
"login.status.waitingSigner": "Esperando a tu firmante",
|
||||
"login.status.connecting": "Conectando con tu firmante",
|
||||
"login.status.connected": "Conectado",
|
||||
"login.method.extension": "Extensión",
|
||||
"login.method.remote": "Remoto",
|
||||
"login.method.primal": "Primal",
|
||||
"login.account": "Tu cuenta de Nostr",
|
||||
"login.copyNpub": "Copiar tu npub completo",
|
||||
"login.logout": "Cerrar sesión",
|
||||
"signer.status.waitingExtension": "Esperando a tu extensión",
|
||||
"signer.status.signed": "Firmado",
|
||||
"signer.status.reconnecting": "Reconectando con tu firmante",
|
||||
"signer.status.approveWindow": "Tu firmante necesita que lo apruebes en su propia ventana",
|
||||
"signer.status.waitingSigner": "Esperando a tu firmante",
|
||||
"signer.status.stillWaiting": "Seguimos esperando a que responda tu firmante",
|
||||
"signer.status.connected": "Conectado",
|
||||
"signer.error.noExtension": "No se ha encontrado ninguna extensión de firma. Instala Alby o nos2x y recarga esta página.",
|
||||
"signer.error.privateKey": "Eso parece una clave privada, no un enlace de conexión. Este sitio nunca acepta una clave. Pega el enlace bunker:// que te dio tu firmante.",
|
||||
"signer.error.badLink": "Ese no es un enlace de conexión que este sitio entienda. Pega el enlace bunker:// de tu firmante.",
|
||||
"signer.error.noAnswer": "Tu firmante no ha respondido. Puede que esté cerrado, o que su relay esté caído. Ábrelo y vuelve a intentarlo.",
|
||||
"signer.error.timeout": "Tu firmante no ha respondido a tiempo. Ábrelo, comprueba que sigue conectado y vuelve a intentarlo.",
|
||||
"signer.error.cancelled": "Conexión cancelada.",
|
||||
"signer.error.declined": "Se rechazó la petición de firma",
|
||||
"notfound.title": "Página no encontrada · Cashumints.space",
|
||||
"notfound.description": "Esa página no existe en Cashumints.space.",
|
||||
"notfound.heading": "Esta página no existe",
|
||||
"notfound.lede": "Puede que se haya movido, o que el mint todavía no esté indexado. Busca un mint por nombre o dominio, o pega la URL de un mint.",
|
||||
"notfound.browse": "Ver todos los mints",
|
||||
"notfound.home": "Ir al inicio",
|
||||
"notfound.loading": "Cargando {host}",
|
||||
"notfound.fb.rating": "Valoración",
|
||||
"notfound.fb.reviews": "Reseñas",
|
||||
"notfound.fb.software": "Software",
|
||||
"notfound.fb.lastOnline": "Última vez en línea",
|
||||
"notfound.fb.note": "Este mint se indexó después de la última compilación del sitio, así que esto es un resumen sacado de la API en directo. La página completa con reseñas aparecerá en la próxima compilación.",
|
||||
"wallets.title": "Monederos Cashu · Cashumints.space",
|
||||
"wallets.description": "Monederos Cashu que funcionan con los mints listados aquí, para móvil, web, navegador y línea de comandos, con lo que hace bien cada uno.",
|
||||
"wallets.heading": "Monederos",
|
||||
"wallets.lede": "Necesitas un monedero para guardar el ecash de cualquiera de estos mints. La mayoría hablan con varios mints a la vez, así que no te atas al primero que pruebes.",
|
||||
"wallets.note": "Esta lista no está completa y no es una recomendación. El proyecto Cashu mantiene un índice más amplio en {link}. Un monedero guarda claves, un mint guarda el dinero: lee las reseñas de los mints antes de elegir dónde viven tus sats.",
|
||||
"wallets.platform.android": "Android",
|
||||
"wallets.platform.ios": "iOS",
|
||||
"wallets.platform.web": "Web",
|
||||
"wallets.platform.extension": "Extensión de navegador",
|
||||
"wallets.platform.cli": "Línea de comandos",
|
||||
"wallets.platform.python": "Python",
|
||||
"wallets.item.minibits": "Monedero de ecash para móvil con su propio mint, direcciones Lightning y contactos de Nostr.",
|
||||
"wallets.item.nutstash": "Monedero de navegador que maneja varios mints a la vez e importa y exporta tokens en crudo.",
|
||||
"wallets.item.enuts": "Monedero móvil centrado en saldos repartidos entre mints y en enviar ecash por Nostr.",
|
||||
"wallets.item.cashume": "El monedero web de referencia del proyecto Cashu. Funciona entero en el navegador y se puede instalar como PWA.",
|
||||
"wallets.item.boardwalk": "Monedero web pensado para direcciones Lightning y para enviar ecash con un enlace.",
|
||||
"wallets.item.coinos": "Monedero Lightning y TPV que además opera su propio mint Cashu.",
|
||||
"wallets.item.oxchat": "Mensajería Nostr con un monedero de ecash integrado, para enviar sats dentro del chat.",
|
||||
"wallets.item.nutshell": "La implementación de referencia. Funciona como monedero en línea de comandos y como software de mint.",
|
||||
"about.title": "Acerca de Cashumints.space",
|
||||
"about.description": "Qué es Cashu, qué guarda de verdad un mint, qué es NIP-87 y cómo este sitio indexa mints y lee reseñas de los relays de Nostr.",
|
||||
"about.heading": "Acerca de",
|
||||
"about.lede": "Cashumints.space lista todos los mints Cashu que encontramos en la red Nostr y te enseña lo que dice la gente sobre ellos. Lo hace Azzamo.",
|
||||
"about.cashu.title": "Qué es Cashu",
|
||||
"about.cashu.p1": "Cashu es un protocolo de ecash para Bitcoin. Un mint emite tokens ciegos respaldados por los sats que guarda, y esos tokens pasan de una persona a otra sin que el mint sepa quién le envió qué a quién. La privacidad es real y es el objetivo: los tokens no son cuentas, y el mint no puede enlazar el token que firmó con el token que le vuelve.",
|
||||
"about.cashu.p2": "Puedes leer el protocolo y sus especificaciones en {link}. Las especificaciones se llaman NUTs, y de ahí salen los números NUT que ves en cada página de mint.",
|
||||
"about.mints.title": "Qué es un mint, y qué estás cediendo",
|
||||
"about.mints.p1": "Un mint es custodial. Cuando acuñas ecash le estás entregando sats a quien opere ese servidor, y confías en que respete los tokens cuando los fundas para sacarlos. No hay prueba de fraude, ni depósito en garantía, ni forma de reclamar. Quien opera un mint puede desaparecer con el saldo, y ha pasado.",
|
||||
"about.mints.p2": "Justo por eso existen aquí las reseñas. Este sitio nunca te dice que un mint sea seguro. El estado de la página de un mint dice {code}, no \"de fiar\", y solo significa que el servidor respondió la última vez que preguntamos. Trata los saldos de ecash como el dinero suelto de un bolsillo: bien para gastar, mal para ahorrar.",
|
||||
"about.nip87.title": "Qué es NIP-87",
|
||||
"about.nip87.p1": "NIP-87 es la convención de Nostr para anunciar y recomendar mints de ecash. Aquí importan dos tipos de evento:",
|
||||
"about.nip87.li1": "{code} anuncia un mint. La URL del mint va en la etiqueta {tag}. Así es como el sitio descubre mints que no ha visto nunca.",
|
||||
"about.nip87.li2": "{code} es una reseña. Lleva la valoración y el texto, y apunta al mint del que habla con una etiqueta {utag} (la URL del mint) y una etiqueta {dtag} (la clave pública del propio mint).",
|
||||
"about.nip87.p2": "Cada reseña está firmada con la clave Nostr de quien la escribió. Nunca decimos que una reseña esté \"verificada\", porque nadie la ha verificado: está firmada, que es otra cosa y más útil. Significa que puedes comprobar quién la escribió y que no se ha alterado. No significa que quien la escribió sea honesto ni que haya usado el mint.",
|
||||
"about.how.title": "Cómo funciona este sitio",
|
||||
"about.how.p1": "Hay dos bucles y una regla. La regla es que el mint es dueño de sus metadatos y Nostr es dueño de las reseñas, así que este sitio no guarda ninguna de las dos cosas como autoridad.",
|
||||
"about.how.li1": "{strong} le pide {code} a cada mint conocido cada diez minutos y anota lo que dice: nombre, descripción, versión, NUTs compatibles, límites, datos de contacto. También anota si el mint respondió, y de ahí sale la disponibilidad.",
|
||||
"about.how.li1Label": "El bucle de comprobación",
|
||||
"about.how.li2": "{strong} se conecta a los relays de Nostr cada hora, lee anuncios de mints y reseñas, y añade cualquier mint que no haya visto antes.",
|
||||
"about.how.li2Label": "El bucle de descubrimiento",
|
||||
"about.how.p2": "Lo importante es qué pasa cuando un mint deja de responder. Sus metadatos guardados nunca se sobrescriben con una comprobación fallida, así que un mint muerto conserva su página entera: sus especificaciones, su historial y sus reseñas, con un aviso que dice cuándo se apagó. La versión anterior de este sitio mostraba una página de error, lo que significaba que los mints sobre los que más falta hacía avisarse eran justo los que no se podían reseñar. Esa fue la razón principal para rehacerlo.",
|
||||
"about.how.p3": "El texto de las reseñas no se guarda nunca de nuestro lado. Cuando abres la página de un mint, tu navegador habla directamente con los relays y lee las reseñas él mismo, y cuando escribes una la firma tu propia extensión de Nostr y va derecha a esos relays. Nosotros guardamos un recuento para poder ordenar y clasificar las páginas, nada más. Aquí no podemos editar ni borrar nada, incluidas las reseñas que preferiríamos no tener.",
|
||||
"about.relays.title": "Relays que leemos",
|
||||
"about.relays.p1": "Estos son los relays que usan tanto el indexador como tu navegador:",
|
||||
"about.relays.p2": "Apunta cualquier cliente de Nostr a esos mismos relays y verás los mismos eventos. Esa es toda la promesa: aquí nada es cierto solo porque lo digamos nosotros.",
|
||||
"about.nuts.title": "¿Qué son los NUTs?",
|
||||
"about.nuts.p1": "Los NUTs son las especificaciones numeradas de Cashu, como los BIPs de Bitcoin. Un mint anuncia cuáles implementa, y eso te dice lo que puede hacer de verdad: NUT-04 y NUT-05 son acuñar y fundir por Lightning (sin ellos un mint no sirve de mucho), NUT-07 deja que un monedero compruebe si un token ya se gastó, NUT-17 da a los monederos avisos en directo por WebSockets en vez de preguntar una y otra vez, y NUT-20 deja que un monedero demuestre que una cotización de acuñación se le emitió a él. La lista completa está en {link}.",
|
||||
"about.numbers.title": "Ahora mismo",
|
||||
"about.numbers.p1": "{mints} mints indexados, {online} de ellos respondiendo, {reviews} reseñas contadas de los relays de arriba.",
|
||||
"about.source.title": "Código y créditos",
|
||||
"about.source.p1": "El sitio es de código abierto en {github}. Lo hace y lo mantiene {azzamo}. Si la página de un mint tiene algo mal, arréglalo en el origen: los metadatos del mint vienen de su propio {code}, y las reseñas vienen de Nostr.",
|
||||
"terms.title": "Términos de uso · Cashumints.space",
|
||||
"terms.description": "Términos de uso de Cashumints.space: qué es el sitio, qué no hace y quién responde de qué. Sin cuentas, sin garantías, sin vínculo con ningún mint.",
|
||||
"terms.heading": "Términos de uso",
|
||||
"terms.lede": "Términos sencillos para un sitio que solo indexa datos públicos. Última actualización: {date}.",
|
||||
"terms.what.title": "Qué es este sitio",
|
||||
"terms.what.p1": "Cashumints.space es un índice de datos públicos sobre mints Cashu. Dos tipos de datos, los dos de fuera:",
|
||||
"terms.what.li1": "Metadatos del mint, leídos del endpoint {code} de cada mint: nombre, descripción, versión del software, NUTs compatibles, datos de contacto y si el mint respondió la última vez que preguntamos.",
|
||||
"terms.what.li2": "Reseñas, leídas de relays públicos de Nostr. Son eventos NIP-87 que sus autores publican en relays que cualquiera puede consultar.",
|
||||
"terms.what.p2": "Recogemos esos datos, los normalizamos, los ordenamos y los mostramos. No los creamos.",
|
||||
"terms.noAccounts.title": "Sin cuentas, sin garantías",
|
||||
"terms.noAccounts.p1": "No hay nada a lo que registrarse. El sitio se ofrece tal cual, sin garantía de ningún tipo: ni de que los datos sean exactos, ni de que estén al día, ni de que el sitio vaya a estar disponible. Los datos de un mint pueden tener minutos u horas, un mint puede cambiar después de nuestra última comprobación, y un relay puede no responder, y entonces estás viendo menos de lo que hay.",
|
||||
"terms.noAccounts.p2": "Úsalo bajo tu responsabilidad. No respondemos de ninguna pérdida derivada de usar este sitio o de fiarte de lo que leas aquí. Consulta el {link} para los riesgos concretos de usar un mint Cashu.",
|
||||
"terms.noAccounts.link": "aviso legal",
|
||||
"terms.noCustody.title": "No guardamos tu dinero",
|
||||
"terms.noCustody.p1": "Este sitio nunca tiene la custodia de ningún fondo. No tiene monedero, no toca ecash, y no puede mover, congelar ni recuperar nada. Si tienes un problema con un mint, quien lo opera es la única parte que puede ayudarte, y nosotros no podemos contactar con esa persona en tu nombre.",
|
||||
"terms.noCustody.p2": "No estamos vinculados a ningún mint de los listados, y ningún mint paga por aparecer, por su posición ni por su valoración. Los mints se descubren desde anuncios públicos de Nostr y se ordenan con un algoritmo sobre datos públicos de reseñas.",
|
||||
"terms.reviews.title": "Las reseñas son palabras de otra gente",
|
||||
"terms.reviews.p1": "Las reseñas son contenido de usuarios publicado en relays públicos de Nostr por sus autores. Las mostramos, no las controlamos. No las escribimos, no podemos editarlas, y no podemos borrarlas de los relays que las guardan: son eventos firmados en una red que no es nuestra.",
|
||||
"terms.reviews.p2": "Una reseña es la experiencia de una persona, no un hecho verificado. Si publicas una, respondes de lo que diga. No publiques nada ilegal, y ten claro que lo que publicas es público y permanente (consulta la {link}).",
|
||||
"terms.reviews.link": "página de privacidad",
|
||||
"terms.reviews.p3": "Podemos dejar de mostrar una reseña concreta en este sitio, y lo haremos con contenido ilegal. Eso la quita solo de estas páginas. El evento sigue en los relays y cualquier otro cliente de Nostr puede seguir leyéndolo.",
|
||||
"terms.links.title": "Enlaces a otros sitios",
|
||||
"terms.links.p1": "Las URLs de los mints, los enlaces a monederos, los contactos de quienes los operan y todo lo demás que apunta fuera de este sitio está ahí porque forma parte de los datos. Un enlace no es una recomendación y no revisamos lo que hay al otro lado.",
|
||||
"terms.changes.title": "Cambios",
|
||||
"terms.changes.p1": "Estos términos pueden cambiar. La fecha de arriba dice cuándo lo hicieron por última vez. El sitio es de código abierto, así que cada cambio de esta página está en el historial de commits.",
|
||||
"terms.contact.title": "Contacto",
|
||||
"terms.contact.p1": "Las incidencias y las peticiones de retirada se presentan mejor en {github}. El sitio lo mantiene {azzamo}.",
|
||||
"privacy.title": "Privacidad · Cashumints.space",
|
||||
"privacy.description": "Privacidad en Cashumints.space: sin cuentas, sin cookies de seguimiento, sin analítica. localStorage se usa para guardar perfiles en caché y no se envía nada a ningún sitio. Las reseñas se firman en tu propio navegador.",
|
||||
"privacy.heading": "Privacidad",
|
||||
"privacy.lede": "Recogemos lo menos que puede recoger una web. Última actualización: {date}.",
|
||||
"privacy.noAccounts.title": "Sin cuentas",
|
||||
"privacy.noAccounts.p1": "No hay registro, ni inicio de sesión, ni perfil. Nada en este sitio te pregunta quién eres, y no hay ningún registro de usuario que se pueda filtrar porque no hay usuarios.",
|
||||
"privacy.noTracking.title": "Sin cookies de seguimiento, sin analítica",
|
||||
"privacy.noTracking.p1": "El sitio no pone ninguna cookie de seguimiento. No hay script de analítica, ni gestor de etiquetas, ni píxel, ni grabación de sesión, ni red publicitaria. Nada en la página informa de lo que haces, ni a nosotros ni a nadie.",
|
||||
"privacy.logs.title": "Registros del servidor",
|
||||
"privacy.logs.p1": "El servidor web guarda registros de acceso normales, que es lo que hace cualquier servidor web por defecto. Una línea de registro anota tu dirección IP, la hora, la página que pediste, el estado de la respuesta, y el agente de usuario y el referente que envió tu navegador. Los registros existen para depurar errores y detectar abusos. No se cruzan con nada más y no se venden ni se comparten.",
|
||||
"privacy.logs.operatorLabel": "Nota para quien opere el sitio:",
|
||||
"privacy.logs.operator": "si tú lo operas, indica aquí tu retención real. El despliegue del README usa nginx con su registro de acceso por defecto y la rotación que traiga tu distribución (en Debian y Ubuntu es logrotate, a diario, guardando 14 días). Confirma lo que hace tu servidor y sustituye este párrafo por el número.",
|
||||
"privacy.storage.title": "Qué se guarda en tu navegador",
|
||||
"privacy.storage.p1": "El sitio usa localStorage para exactamente dos cosas, y las dos se quedan en tu dispositivo y no se envían a ningún sitio:",
|
||||
"privacy.storage.li1": "Una caché de metadatos de perfiles de Nostr (eventos de tipo 0: nombres, avatares, direcciones NIP-05) de quienes escribieron las reseñas que has mirado. Así no hay que preguntar a los relays en cada página. Las entradas caducan, y la caché tiene un tope y se vacía por orden de antigüedad.",
|
||||
"privacy.storage.li2": "Preferencias de la interfaz, para que el sitio se comporte igual la próxima vez.",
|
||||
"privacy.storage.p2": "Borrar los datos del sitio en tu navegador quita las dos cosas. No se rompe nada, simplemente se vuelven a buscar los perfiles.",
|
||||
"privacy.thirdParties.title": "Con qué habla tu navegador",
|
||||
"privacy.thirdParties.p1": "Cargar una página conecta tu navegador con unos cuantos sitios además de este. Así es como una web sin seguimiento en el servidor sigue leyendo datos en directo:",
|
||||
"privacy.thirdParties.li1Label": "Relays públicos de Nostr",
|
||||
"privacy.thirdParties.li1": "{strong}, por WebSocket, para leer reseñas y perfiles. El relay ve tu IP y lo que pediste, como cualquier servidor al que te conectes. Los relays están listados en la {link}.",
|
||||
"privacy.thirdParties.li1Link": "página Acerca de",
|
||||
"privacy.thirdParties.li2Label": "Imágenes de avatar",
|
||||
"privacy.thirdParties.li2": "{strong}, cargadas desde donde apunte el perfil de quien escribió la reseña. Se piden con una política de no enviar referente, así que el servidor no sabe en qué página estabas.",
|
||||
"privacy.publishing.title": "Publicar una reseña",
|
||||
"privacy.publishing.p1": "Escribir una reseña ocurre en tu navegador, no en nuestro servidor. El evento lo firma tu extensión NIP-07 (Alby, nos2x o similar) y se emite desde tu navegador directamente a los relays. Tu clave privada no sale nunca de la extensión y este sitio no la ve. No hacemos gestión de claves propia y nunca pedimos una clave ni una frase semilla.",
|
||||
"privacy.permanent.title": "Todo lo que publiques en Nostr es público y permanente",
|
||||
"privacy.permanent.p1": "Esta es la parte que merece leerse dos veces. Una reseña es un evento firmado en relays públicos. Una vez fuera, está fuera:",
|
||||
"privacy.permanent.li1": "Cualquiera puede leerla, ahora y más adelante, con cualquier cliente de Nostr. No es \"pública en Cashumints.space\", es pública en la red.",
|
||||
"privacy.permanent.li2": "Queda ligada para siempre a tu clave pública de Nostr, junto a todo lo demás que haya publicado esa clave.",
|
||||
"privacy.permanent.li3": "Borrarla es una petición, no una orden. Puedes publicar un evento de borrado y algunos relays lo respetarán, pero un relay es libre de ignorarlo y quien ya copiara el evento lo sigue teniendo. Da una reseña publicada por permanente.",
|
||||
"privacy.permanent.li4": "No podemos borrarla por ti. Podemos dejar de mostrarla aquí, y eso no cambia nada en los relays.",
|
||||
"privacy.permanent.p2": "Si quieres reseñar un mint sin que quede ligado a tu identidad principal, usa una clave Nostr aparte. Ten en cuenta que una clave sin ninguna otra actividad se señala como tal en las reseñas duras, porque una clave recién hecha escribiendo una reseña de una estrella es un patrón que quien lee merece ver.",
|
||||
"privacy.contact.title": "Preguntas",
|
||||
"privacy.contact.p1": "El sitio es de código abierto: si quieres comprobar cualquier cosa de las de arriba, lee el código en {github}. Las preguntas y correcciones pueden ir en una incidencia ahí.",
|
||||
"disclaimer.title": "Aviso legal · Cashumints.space",
|
||||
"disclaimer.description": "Lo que hay que saber antes de usar un mint Cashu: los mints son custodiales, un mint puede desaparecer con tu dinero, las reseñas son opiniones y en línea significa accesible, no fiable.",
|
||||
"disclaimer.heading": "Aviso legal",
|
||||
"disclaimer.lede": "Lee esto antes de meter dinero en un mint. Última actualización: {date}.",
|
||||
"disclaimer.callout": "Un mint Cashu guarda tu dinero por ti. Si desaparece, tu ecash se va con él, y no hay nadie a quien reclamar. Guarda solo lo que te puedas permitir perder.",
|
||||
"disclaimer.custodial.title": "Los mints son custodiales",
|
||||
"disclaimer.custodial.p1": "Cuando acuñas ecash, le entregas bitcoin a quien opera el mint y recibes tokens a cambio. Esos tokens son un derecho frente a esa persona. No son bitcoin que tengas tú, y es quien opera el mint quien decide si los respeta.",
|
||||
"disclaimer.custodial.p2": "Eso quiere decir que todos los mints tienen los mismos riesgos: quien lo opera puede esfumarse, puede verse obligado a parar, puede perder los fondos por un fallo o un ataque, o puede simplemente apagar el servidor. No hay seguro de depósitos, ni devolución del cargo, ni soporte, ni vía legal con la que contar de verdad. Esto vale también para el mint mejor valorado de este sitio.",
|
||||
"disclaimer.custodial.p3": "Cashu está pensado para cantidades pequeñas que se mueven rápido. Úsalo así: guarda lo que estás a punto de gastar, no tus ahorros, y repártelo entre varios mints en vez de dejarlo todo en uno.",
|
||||
"disclaimer.reviews.title": "Las reseñas son opiniones, no hechos verificados",
|
||||
"disclaimer.reviews.p1": "Cada reseña de aquí la escribió alguien en Nostr y la firmó con su clave. Firmada quiere decir que podemos decirte qué clave la escribió. No quiere decir que la reseña sea cierta, ni que quien la escribió usara el mint, ni que no sea quien lo opera, la competencia, o una sola persona con diez claves.",
|
||||
"disclaimer.reviews.p2": "No verificamos a quien reseña, y no editamos ni quitamos reseñas con las que no estemos de acuerdo. Lee unas cuantas, fíjate en lo que dicen de verdad, y no midas igual un mint con tres reseñas y uno con trescientas.",
|
||||
"disclaimer.status.title": "En línea significa accesible, no fiable",
|
||||
"disclaimer.status.p1": "Comprobamos cada mint con regularidad y mostramos si respondió. Eso es todo lo que significa un estado en verde: el servidor contestó. No dice nada sobre si quien lo opera es solvente, honesto, o si mañana seguirá respetando tus tokens.",
|
||||
"disclaimer.status.p2": "Un mint puede estar en línea hasta el momento justo en que deja de estarlo. El historial de disponibilidad, los NUTs compatibles y la versión del software te dicen algo sobre cómo se lleva un mint, pero ninguno es una promesa sobre tu dinero.",
|
||||
"disclaimer.ranking.title": "Cómo funciona el orden, y qué no significa",
|
||||
"disclaimer.ranking.p1": "Los mints se ordenan con un algoritmo sobre datos públicos de reseñas: valoración, número de reseñas y actividad reciente, con los mints en línea por delante de los que no lo están. Los detalles están en el código abierto.",
|
||||
"disclaimer.ranking.p2": "Nadie paga por su posición. No hay hueco patrocinado, ni listado promocionado, ni acuerdo con ningún mint de este sitio. Estar arriba significa que al algoritmo le gustaron los datos públicos, y nada más. No es una recomendación y no es una calificación de seguridad.",
|
||||
"disclaimer.notAdvice.title": "Esto no es asesoramiento financiero",
|
||||
"disclaimer.notAdvice.p1": "Nada de este sitio es asesoramiento financiero, de inversión, legal ni fiscal. No conocemos tu situación y no te estamos aconsejando. Usar un mint, y cuál, es decisión tuya.",
|
||||
"disclaimer.dyor.title": "Infórmate por tu cuenta",
|
||||
"disclaimer.dyor.li1": "Busca quién lo opera. Un mint llevado por alguien con una reputación pública que perder no es lo mismo que una URL anónima.",
|
||||
"disclaimer.dyor.li2": "Lee los términos del propio mint y su mensaje del día. Un mint que se llama a sí mismo prueba o beta lo dice en serio.",
|
||||
"disclaimer.dyor.li3": "Empieza con poco. Acuña un poco, gástalo, fúndelo para sacarlo, y mira qué pasa antes de confiarle más.",
|
||||
"disclaimer.dyor.li4": "Fíjate en los límites. Un máximo bajo suele ser señal de que se opera con cuidado, y uno alto no es señal de que sea seguro.",
|
||||
"disclaimer.dyor.li5": "Guarda una copia de tus tokens, y recuerda que un token es un activo al portador: quien lo tiene, lo puede gastar.",
|
||||
"disclaimer.site.title": "Sobre el propio sitio",
|
||||
"disclaimer.site.p1": "Los datos de aquí pueden estar mal o desactualizados. Los mints se comprueban según un horario, los relays a veces no responden, y la página que estás leyendo se compiló en algún momento del pasado. El sitio se ofrece tal cual, sin garantía. Consulta los {link}.",
|
||||
"disclaimer.site.link": "términos de uso",
|
||||
"disclaimer.updated": "20 de agosto de 2026",
|
||||
"login.getPrimal": "Consigue Primal",
|
||||
"reviews.dialog.identity.extensionGone": "La extensión de tu navegador no responde. Vuelve a iniciar sesión, o usa un firmante remoto.",
|
||||
"seo.orgDescription": "Un directorio de mints Cashu, con el estado en vivo de cada mint y reseñas de la comunidad firmadas en Nostr.",
|
||||
"seo.ogImageAlt": "Cashumints.space: encuentra un mint Cashu en el que puedas confiar.",
|
||||
"nav.home": "Inicio",
|
||||
"mint.description.fallbackDomain": "Un mint Cashu que funciona en {domain}.",
|
||||
"mint.more.title": "Más mints",
|
||||
"mint.more.all": "Ver todos los mints"
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Locale-aware dates, times and numbers, for both halves of the site.
|
||||
*
|
||||
* One object per locale, built from that locale's translator, so a page and the island
|
||||
* that updates it in place format the same timestamp the same way. Everything that used
|
||||
* to be a hand-rolled `toLocaleDateString('en-GB', …)` goes through here now.
|
||||
*
|
||||
* This file ships to the browser, so it stays small: three cached Intl instances and
|
||||
* some arithmetic.
|
||||
*/
|
||||
import { intlTag, type Locale } from './config.js';
|
||||
import type { Translator } from './translate.js';
|
||||
|
||||
const MINUTE = 60;
|
||||
const HOUR = 3600;
|
||||
const DAY = 86_400;
|
||||
const WEEK = 604_800;
|
||||
const MONTH = 2_592_000;
|
||||
const YEAR = 31_536_000;
|
||||
|
||||
/** Largest first: the first threshold a difference clears is the unit it is said in. */
|
||||
const UNITS: Array<{ key: string; intl: Intl.RelativeTimeFormatUnit; seconds: number }> = [
|
||||
{ key: 'year', intl: 'year', seconds: YEAR },
|
||||
{ key: 'month', intl: 'month', seconds: MONTH },
|
||||
{ key: 'week', intl: 'week', seconds: WEEK },
|
||||
{ key: 'day', intl: 'day', seconds: DAY },
|
||||
{ key: 'hour', intl: 'hour', seconds: HOUR },
|
||||
{ key: 'minute', intl: 'minute', seconds: MINUTE },
|
||||
];
|
||||
|
||||
const relativeFormats = new Map<string, Intl.RelativeTimeFormat>();
|
||||
const dateFormats = new Map<string, Intl.DateTimeFormat>();
|
||||
const numberFormats = new Map<string, Intl.NumberFormat>();
|
||||
|
||||
function relativeFormat(tag: string): Intl.RelativeTimeFormat {
|
||||
let found = relativeFormats.get(tag);
|
||||
if (!found) {
|
||||
found = new Intl.RelativeTimeFormat(tag, { style: 'short', numeric: 'always' });
|
||||
relativeFormats.set(tag, found);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
function dateFormat(tag: string, options: Intl.DateTimeFormatOptions, id: string): Intl.DateTimeFormat {
|
||||
const cacheKey = `${tag}:${id}`;
|
||||
let found = dateFormats.get(cacheKey);
|
||||
if (!found) {
|
||||
found = new Intl.DateTimeFormat(tag, options);
|
||||
dateFormats.set(cacheKey, found);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
function numberFormat(tag: string): Intl.NumberFormat {
|
||||
let found = numberFormats.get(tag);
|
||||
if (!found) {
|
||||
found = new Intl.NumberFormat(tag);
|
||||
numberFormats.set(tag, found);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
export interface Formatters {
|
||||
locale: Locale;
|
||||
t: Translator;
|
||||
/** "4d ago", "hace 4 d", "4 d geleden". The dense form, used in rows and card feet. */
|
||||
relative(unix: number | null, now?: number): string;
|
||||
/** Magnitude only: "4d". The big number in the verdict strip. */
|
||||
shortDuration(unix: number | null, now?: number): string;
|
||||
/** "3 Aug 2026". Precise, for a banner where a fuzzy date is not good enough. */
|
||||
date(unix: number | null): string;
|
||||
/** "3 August 2026". The long form, for a day heading in the review feed. */
|
||||
longDate(unix: number): string;
|
||||
/** "Mar 2024", for "first seen". */
|
||||
monthYear(unix: number): string;
|
||||
/** "03/08/2026, 14:20 UTC". The mint's own clock, always in UTC. */
|
||||
serverTime(unix: number): string;
|
||||
/** Grouped: 1,000,000 in English, 1.000.000 in Spanish and Dutch. */
|
||||
number(n: number): string;
|
||||
/** A rating to one decimal, with the locale's own decimal separator. */
|
||||
decimal(n: number, digits?: number): string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the formatter set for one locale.
|
||||
*
|
||||
* Cheap enough to call per component: the Intl instances behind it are cached by tag,
|
||||
* and the object itself is a handful of closures.
|
||||
*/
|
||||
export function formatters(t: Translator): Formatters {
|
||||
const tag = intlTag(t.locale);
|
||||
|
||||
/**
|
||||
* The relative form, catalog first.
|
||||
*
|
||||
* `Intl.RelativeTimeFormat` knows every language's grammar and is the right default,
|
||||
* but its short forms are not short enough for this layout: English gives "4 days
|
||||
* ago" where the design has room for "4d ago", and these strings sit in card feet and
|
||||
* dense review rows that were measured against the compact form.
|
||||
*
|
||||
* So the catalog gets first refusal, per unit, and Intl is the fallback. A fourth
|
||||
* language that never adds a single `time.ago.*` key still renders correct, natural
|
||||
* relative times from day one; the three launch languages tune theirs for width.
|
||||
*/
|
||||
const relative = (unix: number | null, now = Date.now() / 1000): string => {
|
||||
if (unix === null) return t('time.never');
|
||||
const diff = Math.max(0, Math.floor(now - unix));
|
||||
if (diff < MINUTE) return t('time.justNow');
|
||||
|
||||
for (const unit of UNITS) {
|
||||
if (diff < unit.seconds) continue;
|
||||
const n = Math.floor(diff / unit.seconds);
|
||||
const key = `time.ago.${unit.key}`;
|
||||
if (t.has(key)) return t(key, { n });
|
||||
return relativeFormat(tag).format(-n, unit.intl);
|
||||
}
|
||||
|
||||
return t('time.justNow');
|
||||
};
|
||||
|
||||
const shortDuration = (unix: number | null, now = Date.now() / 1000): string => {
|
||||
if (unix === null) return t('time.none');
|
||||
const diff = Math.max(0, Math.floor(now - unix));
|
||||
// Under a minute still reads as one minute here: this is a magnitude in a big
|
||||
// display number, and "0m" says less than "1m".
|
||||
if (diff < HOUR) return t('time.short.minute', { n: Math.max(1, Math.floor(diff / MINUTE)) });
|
||||
|
||||
for (const unit of UNITS) {
|
||||
if (diff < unit.seconds) continue;
|
||||
return t(`time.short.${unit.key}`, { n: Math.floor(diff / unit.seconds) });
|
||||
}
|
||||
|
||||
return t('time.short.minute', { n: 1 });
|
||||
};
|
||||
|
||||
return {
|
||||
locale: t.locale,
|
||||
t,
|
||||
relative,
|
||||
shortDuration,
|
||||
|
||||
date: (unix) =>
|
||||
unix === null
|
||||
? t('time.unknownDate')
|
||||
: dateFormat(tag, { day: 'numeric', month: 'short', year: 'numeric' }, 'short').format(unix * 1000),
|
||||
|
||||
longDate: (unix) =>
|
||||
dateFormat(tag, { day: 'numeric', month: 'long', year: 'numeric' }, 'long').format(unix * 1000),
|
||||
|
||||
monthYear: (unix) =>
|
||||
dateFormat(tag, { month: 'short', year: 'numeric' }, 'monthYear').format(unix * 1000),
|
||||
|
||||
serverTime: (unix) =>
|
||||
dateFormat(
|
||||
tag,
|
||||
{ dateStyle: 'short', timeStyle: 'short', timeZone: 'UTC' },
|
||||
'serverTime',
|
||||
).format(unix * 1000) + ' UTC',
|
||||
|
||||
number: (n) => numberFormat(tag).format(n),
|
||||
|
||||
decimal: (n, digits = 1) =>
|
||||
new Intl.NumberFormat(tag, {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
}).format(n),
|
||||
};
|
||||
}
|
||||
|
||||
/** Locale-free, so it lives here beside the rest rather than in two places. */
|
||||
export function starString(rating: number | null): string {
|
||||
if (rating === null) return '☆☆☆☆☆';
|
||||
const filled = Math.round(rating);
|
||||
return '★'.repeat(filled) + '☆'.repeat(Math.max(0, 5 - filled));
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Build-time i18n entry point. Imported from .astro frontmatter, never from an island.
|
||||
*
|
||||
* Every catalog is imported statically here, which is exactly what we want at build
|
||||
* time (one process renders all three locales) and exactly what we do not want in the
|
||||
* browser. Islands import `./client` instead, which reads the one locale the page
|
||||
* already carries. Nothing in this file reaches a bundle: Astro frontmatter runs on the
|
||||
* build machine only.
|
||||
*/
|
||||
import en from './en.json';
|
||||
import es from './es.json';
|
||||
import nl from './nl.json';
|
||||
|
||||
import { CLIENT_NAMESPACES, DEFAULT_LOCALE, LOCALE_CODES, isLocale, type Locale } from './config.js';
|
||||
import { createTranslator, type Catalog, type Translator } from './translate.js';
|
||||
|
||||
export * from './config.js';
|
||||
export * from './routing.js';
|
||||
export type { Translator, Vars } from './translate.js';
|
||||
|
||||
const CATALOGS: Record<string, Catalog> = {
|
||||
en: en as Catalog,
|
||||
es: es as Catalog,
|
||||
nl: nl as Catalog,
|
||||
};
|
||||
|
||||
/** The English catalog, which is what every other locale falls back to key by key. */
|
||||
export const BASE_CATALOG: Catalog = CATALOGS[DEFAULT_LOCALE]!;
|
||||
|
||||
export function catalogFor(locale: Locale): Catalog {
|
||||
return CATALOGS[locale] ?? BASE_CATALOG;
|
||||
}
|
||||
|
||||
const translators = new Map<string, Translator>();
|
||||
|
||||
/**
|
||||
* The translator for one locale. Cached, because every component on every page asks
|
||||
* for it and there are ~180 pages.
|
||||
*/
|
||||
export function useI18n(locale: Locale): Translator {
|
||||
const code = isLocale(locale) ? locale : DEFAULT_LOCALE;
|
||||
let t = translators.get(code);
|
||||
if (!t) {
|
||||
t = createTranslator(code, catalogFor(code), BASE_CATALOG);
|
||||
translators.set(code, t);
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which keys are missing from which locale, as the build reports them.
|
||||
*
|
||||
* English is the source of truth: a key it does not have is not a key. Exported so
|
||||
* both the build warning in `astro.config.mjs` and `scripts/check-i18n.mjs` answer the
|
||||
* question the same way.
|
||||
*/
|
||||
export function missingKeys(): Record<string, string[]> {
|
||||
const expected = Object.keys(BASE_CATALOG);
|
||||
const out: Record<string, string[]> = {};
|
||||
for (const code of LOCALE_CODES) {
|
||||
if (code === DEFAULT_LOCALE) continue;
|
||||
const catalog = catalogFor(code);
|
||||
const missing = expected.filter((key) => catalog[key] === undefined);
|
||||
if (missing.length > 0) out[code] = missing;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The subset of a catalog an island can reach, resolved against English first.
|
||||
*
|
||||
* Two things happen here that matter. Keys outside `CLIENT_NAMESPACES` are dropped, so
|
||||
* the Spanish home page does not inline the Dutch legal pages or its own. And every
|
||||
* key that survives is resolved: a key this locale is missing arrives already filled
|
||||
* with the English string, so the browser needs no fallback catalog and ships exactly
|
||||
* one language.
|
||||
*/
|
||||
export function clientCatalog(locale: Locale): Catalog {
|
||||
const catalog = catalogFor(locale);
|
||||
const out: Catalog = {};
|
||||
for (const key of Object.keys(BASE_CATALOG)) {
|
||||
const namespace = key.split('.')[0] ?? '';
|
||||
if (!(CLIENT_NAMESPACES as readonly string[]).includes(namespace)) continue;
|
||||
out[key] = catalog[key] ?? BASE_CATALOG[key]!;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* The locale codes, as plain JavaScript.
|
||||
*
|
||||
* `config.ts` is the real table and everything the site renders reads from it. This
|
||||
* exists because two things run before TypeScript does: `astro.config.mjs`, which needs
|
||||
* the codes to build the i18n config, and the build-time checker. Keeping it to the
|
||||
* bare codes means the duplication is two short arrays that cannot drift far, and
|
||||
* `scripts/check-i18n.mjs` compares the two on every build so they cannot drift at all.
|
||||
*
|
||||
* Adding a language: add the code here and the full entry in `config.ts`.
|
||||
*/
|
||||
export const DEFAULT_LOCALE = 'en';
|
||||
|
||||
export const LOCALE_CODES = ['en', 'es', 'nl'];
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* The bridge between `shared/warnings.ts` and the catalogs.
|
||||
*
|
||||
* `getMintWarnings` decides what is wrong with a mint and asks for the sentences by
|
||||
* key; this hands it the sentences. Keeping the decision in shared and the words here
|
||||
* is what lets the mint page, the mint card and the live-status island say the same
|
||||
* thing in the same language without any of them knowing how the others render.
|
||||
*
|
||||
* Ships to the browser, so it stays to two small functions.
|
||||
*/
|
||||
import type { MintWarningOptions, WarningStrings } from '@cashumints/shared';
|
||||
import type { Formatters } from './format.js';
|
||||
import type { Translator } from './translate.js';
|
||||
|
||||
/** The catalog prefix the warning keys live under. */
|
||||
const PREFIX = 'mint.warnings.';
|
||||
|
||||
/**
|
||||
* A resolver over one locale's catalog.
|
||||
*
|
||||
* The plural handling that matters is already in `t()`: it uses `Intl.PluralRules`, so
|
||||
* `dayCount` is right in a language with six plural categories without this function or
|
||||
* shared/ knowing anything about it.
|
||||
*/
|
||||
export function warningStrings(t: Translator): WarningStrings {
|
||||
return (key, vars) => t(PREFIX + key, vars);
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything `getMintWarnings` needs to speak one locale: its sentences, and the date
|
||||
* formats the sentences interpolate.
|
||||
*
|
||||
* Passed by the mint page at build time and by the live island at runtime, so a banner
|
||||
* that a fresh probe replaces is in the same language as the one it replaced.
|
||||
*/
|
||||
export function warningOptions(f: Formatters): MintWarningOptions {
|
||||
return {
|
||||
strings: warningStrings(f.t),
|
||||
formatDate: (unix) => f.date(unix),
|
||||
formatMonth: (unix) => f.monthYear(unix),
|
||||
};
|
||||
}
|
||||
|
||||
/** The chip on a mint card, whose three labels sit under their own prefix. */
|
||||
export function chipStrings(t: Translator): WarningStrings {
|
||||
return (key) => t(`mint.chip.${key}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Online", "Sin conexión", "Onbereikbaar".
|
||||
*
|
||||
* An unknown status renders as itself rather than as a humanised key: if the API ever
|
||||
* grows a fifth state, showing the raw word is honest and showing a made-up label is
|
||||
* not.
|
||||
*/
|
||||
export function statusLabel(status: string, t: Translator): string {
|
||||
const key = `status.${status}`;
|
||||
return t.has(key) ? t(key) : status;
|
||||
}
|
||||
@@ -0,0 +1,550 @@
|
||||
{
|
||||
"site.name": "Cashumints.space",
|
||||
"common.close": "Sluiten",
|
||||
"common.cancel": "Annuleren",
|
||||
"common.done": "Klaar",
|
||||
"common.back": "Terug",
|
||||
"common.retry": "Opnieuw",
|
||||
"common.connect": "Verbinden",
|
||||
"common.copied": "Gekopieerd",
|
||||
"common.copyFull": "Volledige {kind} kopiëren",
|
||||
"common.previousPage": "Vorige pagina",
|
||||
"common.nextPage": "Volgende pagina",
|
||||
"common.reviewPages": "Reviewpagina's",
|
||||
"time.never": "nooit",
|
||||
"time.justNow": "net",
|
||||
"time.none": "-",
|
||||
"time.unknownDate": "een onbekende datum",
|
||||
"time.ago.minute": "{n} min geleden",
|
||||
"time.ago.hour": "{n} u geleden",
|
||||
"time.ago.day": "{n} d geleden",
|
||||
"time.ago.week": "{n} wk geleden",
|
||||
"time.ago.month": "{n} mnd geleden",
|
||||
"time.ago.year": "{n} jr geleden",
|
||||
"time.short.minute": "{n} min",
|
||||
"time.short.hour": "{n} u",
|
||||
"time.short.day": "{n} d",
|
||||
"time.short.week": "{n} wk",
|
||||
"time.short.month": "{n} mnd",
|
||||
"time.short.year": "{n} jr",
|
||||
"status.online": "Online",
|
||||
"status.offline": "Offline",
|
||||
"status.degraded": "Onbereikbaar",
|
||||
"status.unknown": "Nog niet gecontroleerd",
|
||||
"nav.skip": "Naar de inhoud",
|
||||
"nav.main": "Hoofdmenu",
|
||||
"nav.mints": "Mints",
|
||||
"nav.reviews": "Reviews",
|
||||
"nav.wallets": "Wallets",
|
||||
"nav.about": "Over",
|
||||
"nav.searchMints": "Zoek in {n} mints",
|
||||
"lang.label": "Taal",
|
||||
"lang.switcher": "Kies een taal",
|
||||
"footer.copyright": "© {year} Cashumints.space",
|
||||
"footer.allMints": "Alle mints",
|
||||
"footer.allReviews": "Alle reviews",
|
||||
"footer.wallets": "Wallets",
|
||||
"footer.about": "Over",
|
||||
"footer.disclaimer": "Disclaimer",
|
||||
"footer.terms": "Voorwaarden",
|
||||
"footer.privacy": "Privacy",
|
||||
"footer.madeBy": "Gemaakt met ❤️ door {azzamo} · Draait op Cashu en NIP-87",
|
||||
"footer.warning": "Mints zijn custodiaal. Een mint kan er met jouw geld vandoor gaan.",
|
||||
"home.title": "Cashumints.space · Vind een Cashu mint die je kunt vertrouwen",
|
||||
"home.description": "Vind een Cashu mint die je kunt vertrouwen. {mints} mints geïndexeerd met {reviews} reviews uit de community, ondertekend op Nostr, plus live uptime, ondersteunde NUTs en mint-software.",
|
||||
"home.hero.title": "Vind een Cashu mint",
|
||||
"home.hero.titleEm": "die je kunt vertrouwen",
|
||||
"home.hero.lede": "Elke mint op het Nostr-netwerk, met echte reviews uit de community. Geen poortwachters, alleen ondertekende reviews die je zelf kunt controleren.",
|
||||
"home.search.label": "Zoek mints op naam, domein of URL",
|
||||
"home.search.placeholder": "Zoek op naam of domein, of plak een mint-URL…",
|
||||
"home.filters.mostReviewed": "Meeste reviews",
|
||||
"home.filters.highestRated": "Best beoordeeld",
|
||||
"home.filters.recentlyActive": "Recent actief",
|
||||
"home.filters.websockets": "Ondersteunt WebSockets",
|
||||
"home.top.title": "Beste mints",
|
||||
"home.top.sub": "gerangschikt op reviews en beoordeling",
|
||||
"home.top.all": "Alle {n} mints →",
|
||||
"home.latest.title": "Laatste reviews",
|
||||
"home.latest.sub": "live van het Nostr-netwerk",
|
||||
"home.latest.prev": "Vorige reviews",
|
||||
"home.latest.next": "Meer reviews",
|
||||
"home.latest.all": "Alle reviews →",
|
||||
"home.latest.empty": "Er kwamen op dit moment geen reviews van de relays. Open een willekeurige mintpagina om daar reviews te lezen en te schrijven.",
|
||||
"home.why.title": "Waarom zou je deze reviews vertrouwen?",
|
||||
"home.why.signed.title": "Ondertekend, niet ingediend",
|
||||
"home.why.signed.body": "Elke review is een {link}-event, ondertekend met de Nostr-sleutel van wie het schreef. Wij bewerken, rangschikken of verwijderen hier niets.",
|
||||
"home.why.signed.link": "NIP-87",
|
||||
"home.why.verify.title": "Controleer het zelf",
|
||||
"home.why.verify.body": "Reviews staan op publieke Nostr-relays, niet in onze database. Richt een willekeurige client op dezelfde relays en je ziet dezelfde gegevens.",
|
||||
"home.why.checked.title": "Doorlopend gecontroleerd",
|
||||
"home.why.checked.body": "We controleren elke mint op uptime, softwareversie, limieten en ondersteunde NUTs, dus wat je ziet is actueel en komt niet van de mint zelf.",
|
||||
"home.cta.title": "Denk je aan een mint? Lees er eerst over.",
|
||||
"home.cta.body": "Een mint houdt jouw ecash vast. Lees wat anderen tegenkwamen voordat je er sats in stopt, en laat zelf een review achter als je er een gebruikt hebt.",
|
||||
"home.cta.explore": "Alle mints bekijken",
|
||||
"home.cta.learn": "Meer over Cashu",
|
||||
"pulse.mintsIndexed": "mints geïndexeerd",
|
||||
"pulse.reviewsOnNostr": "reviews op Nostr",
|
||||
"pulse.lastReview": "laatste review",
|
||||
"pulse.mintsOnline": "mints online",
|
||||
"pulse.indexer": "indexer",
|
||||
"pulse.connected": "verbonden",
|
||||
"pulse.stale": "verouderd",
|
||||
"mints.title": "Alle Cashu mints · Cashumints.space",
|
||||
"mints.description": "Alle {total} Cashu mints die op het Nostr-netwerk zijn geïndexeerd, {online} online en {offline} offline, met beoordelingen uit de community, uptime en mint-software. Doorzoek en sorteer de volledige lijst.",
|
||||
"mints.heading": "Alle mints",
|
||||
"mints.lede": "Elke mint die op het Nostr-netwerk gevonden is, ook de mints die er niet meer zijn. Offline mints blijven in de lijst staan zodat je ze nog kunt vinden en reviewen.",
|
||||
"mints.search.label": "Zoek mints op naam of domein",
|
||||
"mints.search.placeholder": "Zoek op naam of domein…",
|
||||
"mints.sort.label": "Mints sorteren",
|
||||
"mints.sort.score": "Algemeen beste",
|
||||
"mints.sort.rating": "Best beoordeeld",
|
||||
"mints.sort.reviews": "Meeste reviews",
|
||||
"mints.sort.recent": "Recent gereviewd",
|
||||
"mints.sort.seen": "Recent online gezien",
|
||||
"mints.hideOffline": "Offline verbergen",
|
||||
"mints.showing": "{total} mints te zien ({online} online, {offline} offline)",
|
||||
"mints.showingFiltered": "{shown} van {total} mints te zien ({online} online, {offline} offline)",
|
||||
"mints.noResults": "Geen enkele mint komt overeen. Probeer een domein, of plak een mint-URL.",
|
||||
"mints.clearSearch": "Zoekopdracht wissen",
|
||||
"card.noRatings": "Nog geen beoordelingen",
|
||||
"card.reviews.one": "{n} review",
|
||||
"card.reviews.other": "{n} reviews",
|
||||
"card.lastSeen": "laatst gezien {when}",
|
||||
"card.neverSeen": "nooit online gezien",
|
||||
"card.reviewed": "laatste review {when}",
|
||||
"card.noReviews": "nog geen reviews",
|
||||
"mint.title": "{name} · Reviews van deze Cashu mint",
|
||||
"mint.description.fallback": "{name} is een Cashu mint op {domain}.",
|
||||
"mint.description.rated.one": "Beoordeeld met {rating} van de 5 op basis van {n} review, ondertekend op Nostr.",
|
||||
"mint.description.rated.other": "Beoordeeld met {rating} van de 5 op basis van {n} reviews, ondertekend op Nostr.",
|
||||
"mint.description.noReviews": "Nog geen reviews uit de community.",
|
||||
"mint.description.online": "Nu online.",
|
||||
"mint.description.unreachable": "Nu onbereikbaar.",
|
||||
"mint.breadcrumb": "Kruimelpad",
|
||||
"mint.crumbMints": "Mints",
|
||||
"mint.copyUrl": "Mint-URL kopiëren",
|
||||
"mint.qrButton": "Mint-QR",
|
||||
"mint.writeReview": "Schrijf een review",
|
||||
"mint.rankByScore": "#{rank} op score",
|
||||
"mint.tagLightning": "Lightning",
|
||||
"mint.summary": "Samenvatting van de mint",
|
||||
"mint.rating.label": "Beoordeling uit de community",
|
||||
"mint.rating.none": "Nog geen beoordelingen",
|
||||
"mint.rating.reviews.one": "{n} review",
|
||||
"mint.rating.reviews.other": "{n} reviews",
|
||||
"mint.rating.unrated.one": "{n} gaf geen beoordeling",
|
||||
"mint.rating.unrated.other": "{n} gaven geen beoordeling",
|
||||
"mint.activity.label": "Activiteit",
|
||||
"mint.activity.since": "sinds de laatste review",
|
||||
"mint.activity.noReviews": "nog geen reviews",
|
||||
"mint.activity.recent.one": "{n} review in de laatste 90 dagen",
|
||||
"mint.activity.recent.other": "{n} reviews in de laatste 90 dagen",
|
||||
"mint.limits.label": "Limieten",
|
||||
"mint.limits.notPublished": "niet gepubliceerd",
|
||||
"mint.limits.min": "min",
|
||||
"mint.limits.max": "max",
|
||||
"mint.limits.mintRow": "minten",
|
||||
"mint.limits.meltRow": "melten",
|
||||
"mint.limits.disabled": "uit",
|
||||
"mint.limits.range": "{min} tot {max} {unit}",
|
||||
"mint.limits.upTo": "tot {max} {unit}",
|
||||
"mint.limits.from": "vanaf {min} {unit}",
|
||||
"mint.software.label": "Mint-software",
|
||||
"mint.software.unknown": "onbekend",
|
||||
"mint.checked": "gecontroleerd {when}",
|
||||
"mint.lastSeen": "laatst gezien {when}",
|
||||
"mint.neverReached": "nooit bereikt",
|
||||
"mint.uptime.checks": "{ok} van de laatste {total} controles bereikbaar",
|
||||
"mint.uptime.percent": "{percent}% bereikbaar over 30 dagen",
|
||||
"mint.uptime.description": "{ok} van de laatste {total} controles slaagden",
|
||||
"mint.operator.title": "Van de beheerder",
|
||||
"mint.operator.motd": "Bericht van de dag",
|
||||
"mint.operator.beta": "Deze mint noemt zichzelf experimenteel of beta. Lees hieronder wat de beheerder er zelf over zegt voordat je er iets in bewaart dat je niet kunt missen.",
|
||||
"mint.nuts.title": "Ondersteunde NUTs",
|
||||
"mint.nuts.noneOffline": "Er is geen NUT-lijst voor deze mint bewaard, hij ging offline voordat er een vastgelegd was.",
|
||||
"mint.nuts.noneYet": "Er is nog geen NUT-lijst voor deze mint bewaard.",
|
||||
"mint.nuts.supported": "{n} van de {total} bekende NUTs ondersteund",
|
||||
"mint.nuts.whatAre": "wat zijn NUTs?",
|
||||
"mint.nutRow.disabled": "uit",
|
||||
"mint.nutRow.notSupported": "niet ondersteund",
|
||||
"mint.details.title": "Gegevens van de mint",
|
||||
"mint.details.pubkey": "Pubkey",
|
||||
"mint.details.nostr": "Nostr",
|
||||
"mint.details.email": "E-mail",
|
||||
"mint.details.serverTime": "Servertijd",
|
||||
"mint.details.firstSeen": "Eerst gezien",
|
||||
"mint.details.terms": "Voorwaarden",
|
||||
"mint.kind.identifier": "identificatie",
|
||||
"mint.kind.mintPubkey": "pubkey van de mint",
|
||||
"mint.kind.nostrAddress": "Nostr-adres",
|
||||
"mint.kind.emailAddress": "e-mailadres",
|
||||
"mint.kind.npub": "npub",
|
||||
"mint.disclaimer": "Deze mint is custodiaal: hij houdt jouw geld vast, en hij kan daar op elk moment mee stoppen. Beoordelingen zijn meningen uit de community, en online betekent bereikbaar, niet betrouwbaar.",
|
||||
"mint.disclaimerLink": "Lees de volledige disclaimer",
|
||||
"mint.qr.title": "{name} aan je wallet toevoegen",
|
||||
"mint.qr.note": "Scan dit met een Cashu wallet om de mint toe te voegen, of kopieer de URL en plak hem daar.",
|
||||
"mint.qr.warn": "Een mint toevoegen betekent dat je hem je geld toevertrouwt.",
|
||||
"mint.qr.whatMeans": "Wat dat betekent",
|
||||
"mint.qr.wallets": "Wallets die Cashu ondersteunen",
|
||||
"mint.chip.frozen": "Bevroren",
|
||||
"mint.chip.noWithdrawals": "Geen opnames",
|
||||
"mint.chip.meltOnly": "Alleen melten",
|
||||
"mint.warnings.dayCount.one": "{n} dag",
|
||||
"mint.warnings.dayCount.other": "{n} dagen",
|
||||
"mint.warnings.gone.lead": "Waarschijnlijk verdwenen.",
|
||||
"mint.warnings.gone.body.since": "Deze mint is onbereikbaar sinds {date} ({days}). Beschouw geld dat je er hebt staan als verloren, en stuur er niets nieuws heen. Wat hieronder staat is de laatst bekende staat.",
|
||||
"mint.warnings.gone.body.neverDated": "Deze mint heeft geen enkele controle beantwoord sinds we hem in {month} zijn gaan volgen. Beschouw geld dat je er hebt staan als verloren, en stuur er niets nieuws heen. Alles hieronder komt uit de aankondiging op Nostr, niet van de mint zelf.",
|
||||
"mint.warnings.gone.body.never": "Deze mint heeft geen enkele controle beantwoord sinds we hem zijn gaan volgen. Beschouw geld dat je er hebt staan als verloren, en stuur er niets nieuws heen. Alles hieronder komt uit de aankondiging op Nostr, niet van de mint zelf.",
|
||||
"mint.warnings.gone.meta.since": "Offline sinds {date}",
|
||||
"mint.warnings.gone.meta.never": "Nooit bereikbaar geweest sinds hij gevonden werd",
|
||||
"mint.warnings.meltDisabled.lead": "Opnemen staat uit.",
|
||||
"mint.warnings.meltDisabled.body.online": "Deze mint heeft melten via Lightning (NUT-05) uitgezet: je kunt er sats in stoppen, maar je krijgt ze er via Lightning niet uit. Stort niets tot melten weer werkt.",
|
||||
"mint.warnings.meltDisabled.body.offline": "Deze mint had melten via Lightning (NUT-05) uitgezet: je kunt er sats in stoppen, maar je krijgt ze er via Lightning niet uit. Stort niets tot melten weer werkt.",
|
||||
"mint.warnings.meltDisabled.meta.online": "Opnemen staat nu uit",
|
||||
"mint.warnings.meltDisabled.meta.offline": "Opnemen stond uit toen hij voor het laatst gezien werd",
|
||||
"mint.warnings.frozen.lead": "Mint bevroren.",
|
||||
"mint.warnings.frozen.body.online": "Minten en melten staan allebei uit. Er gaat geen enkele sat in of uit via Lightning.",
|
||||
"mint.warnings.frozen.body.offline": "Minten en melten stonden allebei uit. Er ging geen enkele sat in of uit via Lightning.",
|
||||
"mint.warnings.frozen.meta.online": "Nu bevroren, er gaat niets in of uit",
|
||||
"mint.warnings.frozen.meta.offline": "Bevroren toen hij voor het laatst gezien werd",
|
||||
"mint.warnings.offlineLong.lead": "Al {days} offline, sinds {date}.",
|
||||
"mint.warnings.offlineLong.body": "Deze mint is misschien weg. Alles hieronder is zijn laatst bekende staat, en reviews werken nog gewoon.",
|
||||
"mint.warnings.offlineLong.meta": "Offline sinds {date}",
|
||||
"mint.warnings.meltOnly.lead": "Alleen melten.",
|
||||
"mint.warnings.meltOnly.body.online": "Nieuw minten (NUT-04) staat uit: je kunt niet storten, maar ecash dat je al hebt kun je nog wel opnemen. Vaak betekent dit dat een mint aan het afbouwen is, dus als je er ecash van hebt, overweeg het eruit te melten.",
|
||||
"mint.warnings.meltOnly.body.offline": "Nieuw minten (NUT-04) stond uit: je kunt niet storten, maar ecash dat je al hebt kun je nog wel opnemen. Vaak betekent dit dat een mint aan het afbouwen is, dus als je er ecash van hebt, overweeg het eruit te melten.",
|
||||
"mint.warnings.meltOnly.meta.online": "Nu alleen melten",
|
||||
"mint.warnings.meltOnly.meta.offline": "Alleen melten toen hij voor het laatst gezien werd",
|
||||
"mint.warnings.offline.lead": "Offline sinds {date}.",
|
||||
"mint.warnings.offline.body": "De gegevens zijn toen voor het laatst bevestigd. Je kunt nog steeds een review schrijven.",
|
||||
"mint.warnings.offline.meta": "Offline sinds {date}",
|
||||
"mint.warnings.also.meltDisabled": "Voordat hij offline ging had hij opnemen ook al uitgezet.",
|
||||
"mint.warnings.also.mintDisabled": "Voordat hij offline ging had hij nieuw minten ook al uitgezet.",
|
||||
"mint.warnings.also.offline": "Hij is bovendien offline sinds {date} ({days}).",
|
||||
"nut.0": "Notatie en modellen",
|
||||
"nut.1": "Uitwisseling van de publieke sleutel van de mint",
|
||||
"nut.2": "Keysets en kosten",
|
||||
"nut.3": "Tokens wisselen",
|
||||
"nut.4": "Minten via Lightning",
|
||||
"nut.5": "Melten via Lightning",
|
||||
"nut.6": "Mint-informatie",
|
||||
"nut.7": "Tokenstatus controleren",
|
||||
"nut.8": "Te veel betaalde Lightning-kosten",
|
||||
"nut.9": "Handtekeningen herstellen",
|
||||
"nut.10": "Uitgavevoorwaarden",
|
||||
"nut.11": "Betalen aan publieke sleutel",
|
||||
"nut.12": "DLEQ-bewijzen",
|
||||
"nut.13": "Deterministische secrets",
|
||||
"nut.14": "Hashed timelock contracts",
|
||||
"nut.15": "Betalingen via meerdere paden",
|
||||
"nut.16": "Bewegende QR-codes",
|
||||
"nut.17": "WebSocket-abonnementen",
|
||||
"nut.18": "Betaalverzoeken",
|
||||
"nut.19": "Antwoorden uit cache",
|
||||
"nut.20": "Ondertekende mint-quotes",
|
||||
"nut.21": "Open authenticatie",
|
||||
"nut.22": "Blinde authenticatie",
|
||||
"nut.23": "BOLT11-betaalmethode",
|
||||
"nut.24": "HTTP 402, betaling vereist",
|
||||
"nut.25": "BOLT12-betaalmethode",
|
||||
"reviews.title": "Reviews",
|
||||
"reviews.anon": "Anon",
|
||||
"reviews.filter.group": "Reviews filteren",
|
||||
"reviews.filter.all": "Alle",
|
||||
"reviews.filter.five": "5★",
|
||||
"reviews.filter.critical": "Kritisch",
|
||||
"reviews.filter.recent": "Recent",
|
||||
"reviews.loading.one": "{n} review van Nostr-relays laden",
|
||||
"reviews.loading.other": "{n} reviews van Nostr-relays laden",
|
||||
"reviews.empty": "Nog geen reviews. Wees de eerste die deze mint reviewt.",
|
||||
"reviews.emptyWritten": "Hier staan nog geen geschreven reviews. Wees de eerste die er een schrijft.",
|
||||
"reviews.emptyFilter": "Geen enkele review past bij dit filter.",
|
||||
"reviews.noscript": "Reviews worden op Nostr ondertekend en in je browser geladen, dus deze lijst heeft JavaScript nodig. De beoordeling en de aantallen hierboven komen van de site zelf.",
|
||||
"reviews.relayError": "De relay was niet bereikbaar. De reviews zijn misschien niet compleet.",
|
||||
"reviews.srRating": "{rating} van de 5",
|
||||
"reviews.srNoRating": "Geen beoordeling gegeven",
|
||||
"reviews.anonNote": "Eerste review van deze npub, verder geen activiteit gevonden",
|
||||
"reviews.published": "Gepubliceerd.",
|
||||
"reviews.publishedPartial": "Gepubliceerd op {accepted} van de {total} relays.",
|
||||
"reviews.propagating": "Wordt verspreid, het kan even duren voordat hij elders verschijnt.",
|
||||
"reviews.summary.line.one": "{count} beoordeling zonder tekst: {breakdown}",
|
||||
"reviews.summary.line.other": "{count} beoordelingen zonder tekst: {breakdown}",
|
||||
"reviews.summary.lineDay.one": "{count} beoordeling zonder tekst op {when}: {breakdown}",
|
||||
"reviews.summary.lineDay.other": "{count} beoordelingen zonder tekst op {when}: {breakdown}",
|
||||
"reviews.summary.showOne": "Laat zien",
|
||||
"reviews.summary.showMany": "Laat zien",
|
||||
"reviews.breakdown.5": "{n} van vijf sterren",
|
||||
"reviews.breakdown.4": "{n} van vier sterren",
|
||||
"reviews.breakdown.3": "{n} van drie sterren",
|
||||
"reviews.breakdown.2": "{n} van twee sterren",
|
||||
"reviews.breakdown.1": "{n} van één ster",
|
||||
"reviews.breakdown.none": "{n} zonder beoordeling",
|
||||
"reviews.dialog.title": "{name} reviewen",
|
||||
"reviews.dialog.note": "Wordt ondertekend met jouw Nostr-sleutel en gepubliceerd op publieke relays. Hij kan niet bewerkt of verwijderd worden.",
|
||||
"reviews.dialog.ratingLegend": "Jouw beoordeling",
|
||||
"reviews.dialog.starsSr.one": "{n} ster",
|
||||
"reviews.dialog.starsSr.other": "{n} sterren",
|
||||
"reviews.dialog.chooseRating": "Kies een beoordeling",
|
||||
"reviews.dialog.starLabel": "{n}: {phrase}",
|
||||
"reviews.dialog.stars.1": "blijf er weg",
|
||||
"reviews.dialog.stars.2": "had problemen",
|
||||
"reviews.dialog.stars.3": "werkte, met kanttekeningen",
|
||||
"reviews.dialog.stars.4": "goed",
|
||||
"reviews.dialog.stars.5": "werkt prima",
|
||||
"reviews.dialog.bodyLabel": "Wat moeten anderen weten?",
|
||||
"reviews.dialog.bodyPlaceholder": "Hoe ging het minten, het melten en de support? Wees concreet.",
|
||||
"reviews.dialog.charCount": "{n} / {max}",
|
||||
"reviews.dialog.submit": "Ondertekenen en publiceren",
|
||||
"reviews.dialog.signing": "Ondertekenen…",
|
||||
"reviews.dialog.publishing": "Publiceren…",
|
||||
"reviews.dialog.publishingTo": "Publiceren op relays ({accepted}/{total})",
|
||||
"reviews.dialog.waitingExtension": "Wachten op je extensie",
|
||||
"reviews.dialog.waitingSigner": "Wachten op je signer",
|
||||
"reviews.dialog.confirmClose": "Deze review sluiten? Je concept blijft bewaard tot je de site verlaat.",
|
||||
"reviews.dialog.why.login": "Log in om te publiceren",
|
||||
"reviews.dialog.why.rating": "Kies een beoordeling",
|
||||
"reviews.dialog.why.words": "Schrijf een paar woorden",
|
||||
"reviews.dialog.why.more.one": "nog {n} teken",
|
||||
"reviews.dialog.why.more.other": "nog {n} tekens",
|
||||
"reviews.dialog.why.over.one": "{n} teken te veel",
|
||||
"reviews.dialog.why.over.other": "{n} tekens te veel",
|
||||
"reviews.dialog.identity.needSigner": "Je hebt een Nostr-signer nodig om te publiceren. Je sleutel blijft daarin, deze site ziet hem nooit.",
|
||||
"reviews.dialog.identity.loginToPublish": "Log in om te publiceren",
|
||||
"reviews.dialog.identity.switch": "Wisselen",
|
||||
"reviews.dialog.identity.extension": "Ondertekenen met je browserextensie",
|
||||
"reviews.dialog.identity.readingKey": "Je publieke sleutel lezen…",
|
||||
"reviews.dialog.identity.willAsk": "Je extensie krijgt de vraag om te ondertekenen",
|
||||
"reviews.dialog.error.noRelay": "Geen enkele relay heeft de review aangenomen. Controleer je verbinding en probeer het opnieuw.",
|
||||
"reviews.validate.rating": "Kies een beoordeling van 1 tot 5 sterren.",
|
||||
"reviews.validate.short": "Schrijf minstens {n} tekens, anders heeft niemand iets aan de review.",
|
||||
"reviews.validate.long": "Reviews mogen maximaal {n} tekens lang zijn.",
|
||||
"feed.title": "Laatste reviews van Cashu mints · Cashumints.space",
|
||||
"feed.description": "Elke review van een Cashu mint zodra hij verschijnt: {reviews} reviews over {mints} mints, ondertekend op Nostr en hier te lezen met de nieuwste bovenaan. Filter op beoordeling of op mint.",
|
||||
"feed.heading": "Laatste reviews van Cashu mints",
|
||||
"feed.lede": "Elke review van elke mint, nieuwste bovenaan, live gelezen van de Nostr-relays. Elke review is ondertekend door wie hem schreef en staat op de relays, niet in onze database.",
|
||||
"feed.label": "Reviews",
|
||||
"feed.filterByMint": "Filteren op mint",
|
||||
"feed.anyMint": "Elke mint",
|
||||
"feed.clearMint": "Mintfilter wissen",
|
||||
"feed.showingBuild.one": "De meest recente review van de laatste build te zien",
|
||||
"feed.showingBuild.other": "De {n} meest recente reviews van de laatste build te zien",
|
||||
"feed.noscript": "Reviews worden op Nostr ondertekend en in je browser geladen, dus filteren en de volledige lijst hebben JavaScript nodig. De {n} reviews hieronder zijn van de relays gelezen toen deze pagina gebouwd werd.",
|
||||
"feed.emptyBuild": "Er konden geen reviews van de relays gelezen worden toen deze pagina gebouwd werd. De lijst wordt in je browser opnieuw van de relays geladen.",
|
||||
"feed.foot": "Reviews zijn meningen uit de community, geen gecontroleerde feiten. Lees de {link} voordat je een mint je geld toevertrouwt.",
|
||||
"feed.footLink": "disclaimer",
|
||||
"feed.showing": "{shown} van {total} reviews te zien",
|
||||
"feed.showingMatching": "{shown} van {matching} passende reviews te zien ({total} in totaal)",
|
||||
"feed.emptyRelays": "Er kwamen geen reviews terug van de relays. Open een willekeurige mintpagina om daar reviews te lezen en te schrijven.",
|
||||
"feed.emptyFilter": "Geen enkele review past bij dit filter. Maak het mintveld leeg, of probeer Alle.",
|
||||
"feed.relayError": "De relays waren niet bereikbaar. De reviews van de laatste build zijn te zien.",
|
||||
"feed.relayEmpty": "Er kwamen geen reviews terug van de relays. De reviews van de laatste build zijn te zien.",
|
||||
"feed.reading": "De relays lezen…",
|
||||
"login.open": "Inloggen",
|
||||
"login.title": "Inloggen met Nostr",
|
||||
"login.note": "Je sleutel blijft in je signer. Deze site kan hem nooit zien.",
|
||||
"login.extension.name": "Browserextensie",
|
||||
"login.extension.desc": "Onderteken met Alby, nos2x of een andere NIP-07-extensie",
|
||||
"login.extension.none": "Geen extensie gevonden",
|
||||
"login.extension.getOne": "Haal er een:",
|
||||
"login.remote.name": "Signer op afstand",
|
||||
"login.remote.desc": "Scan een code met een signer-app, of plak een bunker-link",
|
||||
"login.primal.name": "Primal",
|
||||
"login.primal.desc": "Keur het inloggen goed in de Primal-app op je telefoon",
|
||||
"login.paste.label": "Of plak de verbindingslink die je signer je gaf",
|
||||
"login.copyLink": "De verbindingslink kopiëren",
|
||||
"login.whatIsNostr": "Wat is Nostr?",
|
||||
"login.connect.remote.title": "Een signer op afstand verbinden",
|
||||
"login.connect.remote.lead": "Scan dit met je signer-app, of kopieer de link en plak hem daar.",
|
||||
"login.connect.remote.hint": "Werkt met elke NIP-46-signer: nsec.app, Amber, Nostrify, je eigen bunker.",
|
||||
"login.connect.primal.title": "Verbinden met Primal",
|
||||
"login.connect.primal.lead": "Open Primal op je telefoon, zoek ondertekenen op afstand in de instellingen, en scan deze code.",
|
||||
"login.connect.primal.hint": "Primal ondertekent via NIP-46 voor andere apps, dus dit is dezelfde verbinding die elke signer gebruikt, en je sleutel blijft in Primal. {link}",
|
||||
"login.status.buildingLink": "Een verbindingslink maken",
|
||||
"login.status.waitingExtension": "Wachten op je extensie",
|
||||
"login.status.waitingSigner": "Wachten op je signer",
|
||||
"login.status.connecting": "Verbinden met je signer",
|
||||
"login.status.connected": "Verbonden",
|
||||
"login.method.extension": "Extensie",
|
||||
"login.method.remote": "Op afstand",
|
||||
"login.method.primal": "Primal",
|
||||
"login.account": "Jouw Nostr-account",
|
||||
"login.copyNpub": "Je volledige npub kopiëren",
|
||||
"login.logout": "Uitloggen",
|
||||
"signer.status.waitingExtension": "Wachten op je extensie",
|
||||
"signer.status.signed": "Ondertekend",
|
||||
"signer.status.reconnecting": "Opnieuw verbinden met je signer",
|
||||
"signer.status.approveWindow": "Je signer wil dat je dit in zijn eigen venster goedkeurt",
|
||||
"signer.status.waitingSigner": "Wachten op je signer",
|
||||
"signer.status.stillWaiting": "Nog steeds wachten tot je signer antwoordt",
|
||||
"signer.status.connected": "Verbonden",
|
||||
"signer.error.noExtension": "Geen ondertekenextensie gevonden. Installeer Alby of nos2x en herlaad deze pagina.",
|
||||
"signer.error.privateKey": "Dat lijkt op een privésleutel, niet op een verbindingslink. Deze site neemt nooit een sleutel aan. Plak de bunker://-link die je signer je gaf.",
|
||||
"signer.error.badLink": "Dat is geen verbindingslink die deze site begrijpt. Plak de bunker://-link van je signer.",
|
||||
"signer.error.noAnswer": "Je signer antwoordde niet. Hij is misschien afgesloten, of zijn relay ligt eruit. Open hem en probeer het opnieuw.",
|
||||
"signer.error.timeout": "Je signer antwoordde niet op tijd. Open hem, kijk of hij nog verbonden is, en probeer het opnieuw.",
|
||||
"signer.error.cancelled": "Verbinding geannuleerd.",
|
||||
"signer.error.declined": "Het ondertekenverzoek is geweigerd",
|
||||
"notfound.title": "Pagina niet gevonden · Cashumints.space",
|
||||
"notfound.description": "Die pagina bestaat niet op Cashumints.space.",
|
||||
"notfound.heading": "Deze pagina bestaat niet",
|
||||
"notfound.lede": "Misschien is hij verplaatst, of is de mint nog niet geïndexeerd. Zoek een mint op naam of domein, of plak een mint-URL.",
|
||||
"notfound.browse": "Alle mints bekijken",
|
||||
"notfound.home": "Naar de startpagina",
|
||||
"notfound.loading": "{host} laden",
|
||||
"notfound.fb.rating": "Beoordeling",
|
||||
"notfound.fb.reviews": "Reviews",
|
||||
"notfound.fb.software": "Software",
|
||||
"notfound.fb.lastOnline": "Laatst online",
|
||||
"notfound.fb.note": "Deze mint is geïndexeerd nadat de site voor het laatst gebouwd is, dus dit is een samenvatting uit de live API. De volledige pagina met reviews verschijnt bij de volgende build.",
|
||||
"wallets.title": "Cashu wallets · Cashumints.space",
|
||||
"wallets.description": "Cashu wallets die werken met de mints die hier staan, voor mobiel, web, browser en de opdrachtregel, met waar elke wallet goed in is.",
|
||||
"wallets.heading": "Wallets",
|
||||
"wallets.lede": "Je hebt een wallet nodig om ecash van deze mints in te bewaren. De meeste wallets praten met meerdere mints tegelijk, dus je zit niet vast aan de eerste die je probeert.",
|
||||
"wallets.note": "Deze lijst is niet volledig en is geen aanbeveling. Het Cashu-project houdt een bredere index bij op {link}. Een wallet bewaart sleutels, een mint bewaart het geld: lees de reviews van de mints voordat je kiest waar je sats wonen.",
|
||||
"wallets.platform.android": "Android",
|
||||
"wallets.platform.ios": "iOS",
|
||||
"wallets.platform.web": "Web",
|
||||
"wallets.platform.extension": "Browserextensie",
|
||||
"wallets.platform.cli": "Opdrachtregel",
|
||||
"wallets.platform.python": "Python",
|
||||
"wallets.item.minibits": "Mobiele ecash wallet met een eigen mint, ondersteuning voor Lightning-adressen en Nostr-contacten.",
|
||||
"wallets.item.nutstash": "Browserwallet die meerdere mints tegelijk aankan en ruwe tokens importeert en exporteert.",
|
||||
"wallets.item.enuts": "Mobiele wallet gericht op saldo's bij meerdere mints en het versturen van ecash via Nostr.",
|
||||
"wallets.item.cashume": "De referentiewallet voor het web van het Cashu-project. Draait volledig in de browser en is te installeren als PWA.",
|
||||
"wallets.item.boardwalk": "Webwallet gebouwd rond Lightning-adressen en het versturen van ecash via een link.",
|
||||
"wallets.item.coinos": "Lightning wallet en kassasysteem dat ook een eigen Cashu mint draait.",
|
||||
"wallets.item.oxchat": "Nostr-messenger met een ingebouwde ecash wallet, om sats in een gesprek te versturen.",
|
||||
"wallets.item.nutshell": "De referentie-implementatie. Draait als wallet op de opdrachtregel en als mint-software.",
|
||||
"about.title": "Over Cashumints.space",
|
||||
"about.description": "Wat Cashu is, wat een mint werkelijk vasthoudt, wat NIP-87 is, en hoe deze site mints indexeert en reviews van Nostr-relays leest.",
|
||||
"about.heading": "Over",
|
||||
"about.lede": "Cashumints.space toont elke Cashu mint die we op het Nostr-netwerk kunnen vinden en laat zien wat mensen erover zeggen. De site is gemaakt door Azzamo.",
|
||||
"about.cashu.title": "Wat Cashu is",
|
||||
"about.cashu.p1": "Cashu is een ecash-protocol voor Bitcoin. Een mint geeft blinde tokens uit die gedekt zijn door sats die hij vasthoudt, en die tokens gaan van persoon naar persoon zonder dat de mint te weten komt wie wat aan wie stuurde. De privacy is echt en is het hele punt: tokens zijn geen rekeningen, en de mint kan het token dat hij ondertekende niet koppelen aan het token dat terugkomt.",
|
||||
"about.cashu.p2": "Je kunt het protocol en de specificaties lezen op {link}. De specificaties heten NUTs, en daar komen de NUT-nummers op elke mintpagina vandaan.",
|
||||
"about.mints.title": "Wat een mint is, en wat het je kost",
|
||||
"about.mints.p1": "Een mint is custodiaal. Als je ecash mint geef je sats aan wie die server draait, en vertrouw je erop dat die de tokens honoreert als je ze er weer uit melt. Er is geen fraudebewijs, geen escrow en geen verhaal. Wie een mint beheert kan er met het saldo vandoor gaan, en dat is gebeurd.",
|
||||
"about.mints.p2": "Precies daarom bestaan reviews hier. Deze site vertelt je nooit dat een mint veilig is. De status op een mintpagina zegt {code}, niet \"betrouwbaar\", en betekent alleen dat de server antwoordde toen we het voor het laatst vroegen. Behandel ecash-saldo's als los geld in een jaszak: prima om uit te geven, verkeerd om te sparen.",
|
||||
"about.nip87.title": "Wat NIP-87 is",
|
||||
"about.nip87.p1": "NIP-87 is de Nostr-afspraak voor het aankondigen en aanbevelen van ecash mints. Twee soorten events doen er hier toe:",
|
||||
"about.nip87.li1": "{code} kondigt een mint aan. De mint-URL staat in de {tag}-tag. Zo vindt de site mints die hij nog nooit gezien heeft.",
|
||||
"about.nip87.li2": "{code} is een review. Hij bevat de beoordeling en de tekst, en wijst naar de mint waar hij over gaat met een {utag}-tag (de mint-URL) en een {dtag}-tag (de eigen publieke sleutel van de mint).",
|
||||
"about.nip87.p2": "Elke review is ondertekend met de Nostr-sleutel van wie hem schreef. We noemen een review nooit \"geverifieerd\", want niemand heeft hem geverifieerd: hij is ondertekend, en dat is iets anders en nuttiger. Het betekent dat je kunt nagaan wie hem schreef en dat er niets aan veranderd is. Het betekent niet dat wie hem schreef eerlijk is of de mint ooit gebruikt heeft.",
|
||||
"about.how.title": "Hoe deze site werkt",
|
||||
"about.how.p1": "Er zijn twee lussen en één regel. De regel is dat de mint eigenaar is van zijn eigen gegevens en Nostr eigenaar is van de reviews, dus deze site bewaart geen van beide als autoriteit.",
|
||||
"about.how.li1": "{strong} vraagt elke bekende mint om de tien minuten om {code} en legt vast wat die zegt: naam, omschrijving, versie, ondersteunde NUTs, limieten, contactgegevens. Hij legt ook vast of de mint überhaupt antwoordde, en daar komt de uptime vandaan.",
|
||||
"about.how.li1Label": "De controlelus",
|
||||
"about.how.li2": "{strong} maakt elk uur verbinding met Nostr-relays, leest mintaankondigingen en reviews, en voegt elke mint toe die hij nog niet gezien had.",
|
||||
"about.how.li2Label": "De ontdekkingslus",
|
||||
"about.how.p2": "Het belangrijkste is wat er gebeurt als een mint stopt met antwoorden. Zijn bewaarde gegevens worden nooit overschreven door een mislukte controle, dus een dode mint houdt zijn volledige pagina: zijn specificaties, zijn geschiedenis en zijn reviews, met een waarschuwing die zegt wanneer hij donker werd. De vorige versie van deze site liet een foutpagina zien, en dat betekende dat juist de mints waar mensen elkaar het hardst voor moesten waarschuwen niet te reviewen waren. Dat was de belangrijkste reden om de site opnieuw te bouwen.",
|
||||
"about.how.p3": "Reviewteksten worden bij ons nooit bewaard. Als je een mintpagina opent praat je browser rechtstreeks met de relays en leest hij de reviews zelf, en als je er een schrijft wordt hij ondertekend door je eigen Nostr-extensie en gaat hij regelrecht naar die relays. Wij houden een telling bij zodat de pagina's gerangschikt en gesorteerd kunnen worden, meer niet. Wij kunnen hier niets bewerken of verwijderen, ook geen reviews die we liever niet hadden gehad.",
|
||||
"about.relays.title": "Relays die we lezen",
|
||||
"about.relays.p1": "Dit zijn de relays die zowel de indexer als jouw browser gebruiken:",
|
||||
"about.relays.p2": "Richt een willekeurige Nostr-client op dezelfde relays en je ziet dezelfde events. Dat is de hele belofte: hier is niets waar alleen omdat wij het zeggen.",
|
||||
"about.nuts.title": "Wat zijn NUTs?",
|
||||
"about.nuts.p1": "NUTs zijn de genummerde Cashu-specificaties, zoals BIPs voor Bitcoin. Een mint geeft aan welke hij toepast, en dat vertelt je wat hij echt kan: NUT-04 en NUT-05 zijn minten en melten via Lightning (zonder die twee heb je weinig aan een mint), NUT-07 laat een wallet nagaan of een token al uitgegeven is, NUT-17 geeft wallets live updates via WebSockets in plaats van steeds te moeten vragen, en NUT-20 laat een wallet bewijzen dat een mint-quote echt aan hem is uitgegeven. De volledige lijst staat op {link}.",
|
||||
"about.numbers.title": "Op dit moment",
|
||||
"about.numbers.p1": "{mints} mints geïndexeerd, waarvan er {online} antwoorden, {reviews} reviews geteld van de relays hierboven.",
|
||||
"about.source.title": "Broncode en credits",
|
||||
"about.source.p1": "De site is open source op {github}. Hij is gebouwd en wordt beheerd door {azzamo}. Staat er iets fout op een mintpagina, los het dan bij de bron op: de gegevens van een mint komen uit zijn eigen {code}, en reviews komen van Nostr.",
|
||||
"terms.title": "Gebruiksvoorwaarden · Cashumints.space",
|
||||
"terms.description": "Gebruiksvoorwaarden voor Cashumints.space: wat de site is, wat hij niet doet, en wie waarvoor verantwoordelijk is. Geen accounts, geen garanties, geen band met welke mint dan ook.",
|
||||
"terms.heading": "Gebruiksvoorwaarden",
|
||||
"terms.lede": "Nuchtere voorwaarden voor een site die alleen publieke gegevens indexeert. Laatst bijgewerkt op {date}.",
|
||||
"terms.what.title": "Wat deze site is",
|
||||
"terms.what.p1": "Cashumints.space is een index van openbaar beschikbare gegevens over Cashu mints. Twee soorten gegevens, allebei van elders:",
|
||||
"terms.what.li1": "Gegevens van de mint, gelezen van het {code}-endpoint van de mint zelf: naam, omschrijving, softwareversie, ondersteunde NUTs, contactgegevens en of de mint antwoordde toen we het voor het laatst vroegen.",
|
||||
"terms.what.li2": "Reviews, gelezen van publieke Nostr-relays. Dat zijn NIP-87-events die door hun auteurs zijn gepubliceerd op relays die iedereen kan bevragen.",
|
||||
"terms.what.p2": "Wij verzamelen die gegevens, maken ze uniform, rangschikken ze en tonen ze. Wij maken ze niet.",
|
||||
"terms.noAccounts.title": "Geen accounts, geen garanties",
|
||||
"terms.noAccounts.p1": "Er valt nergens iets aan te melden. De site wordt geleverd zoals hij is, zonder enige garantie: niet dat de gegevens kloppen, niet dat ze actueel zijn, en niet dat de site beschikbaar zal zijn. Gegevens van een mint kunnen minuten of uren oud zijn, een mint kan veranderd zijn na onze laatste controle, en een relay kan niet antwoorden, en dan zie je minder dan er is.",
|
||||
"terms.noAccounts.p2": "Gebruik hem op eigen risico. Wij zijn niet aansprakelijk voor verlies dat volgt uit het gebruik van deze site of uit het vertrouwen op wat je hier leest. Lees de {link} voor de specifieke risico's van het gebruik van een Cashu mint.",
|
||||
"terms.noAccounts.link": "disclaimer",
|
||||
"terms.noCustody.title": "Wij houden jouw geld niet vast",
|
||||
"terms.noCustody.p1": "Deze site neemt nooit geld in bewaring. Hij heeft geen wallet, raakt geen ecash aan, en kan niets verplaatsen, bevriezen of terughalen. Heb je een probleem met een mint, dan is de beheerder van die mint de enige partij die je kan helpen, en wij kunnen die niet namens jou benaderen.",
|
||||
"terms.noCustody.p2": "Wij hebben geen band met welke mint hier ook staat, en geen enkele mint betaalt voor zijn vermelding, zijn plek of zijn beoordeling. Mints worden gevonden via publieke aankondigingen op Nostr en gerangschikt door een algoritme over publieke reviewgegevens.",
|
||||
"terms.reviews.title": "Reviews zijn de woorden van anderen",
|
||||
"terms.reviews.p1": "Reviews zijn door gebruikers gemaakte inhoud die hun auteurs op publieke Nostr-relays publiceren. Wij tonen ze, wij hebben er geen zeggenschap over. Wij hebben ze niet geschreven, wij kunnen ze niet bewerken, en wij kunnen ze niet verwijderen van de relays die ze bewaren: het zijn ondertekende events op een netwerk dat niet van ons is.",
|
||||
"terms.reviews.p2": "Een review is de ervaring van één persoon, geen gecontroleerd feit. Publiceer je er een, dan ben je verantwoordelijk voor wat erin staat. Publiceer niets dat onwettig is, en besef dat wat je publiceert openbaar en blijvend is (zie de {link}).",
|
||||
"terms.reviews.link": "privacypagina",
|
||||
"terms.reviews.p3": "Wij kunnen een bepaalde review op deze site niet meer tonen, en dat doen we bij inhoud die onwettig is. Dat haalt hem alleen van deze pagina's af. Het event blijft op de relays staan en elke andere Nostr-client kan het nog steeds lezen.",
|
||||
"terms.links.title": "Links naar andere sites",
|
||||
"terms.links.p1": "Mint-URL's, links naar wallets, contactgegevens van beheerders en al het andere dat van deze site af wijst staat er omdat het bij de gegevens hoort. Een link is geen aanbeveling en wij controleren niet wat er aan de andere kant staat.",
|
||||
"terms.changes.title": "Wijzigingen",
|
||||
"terms.changes.p1": "Deze voorwaarden kunnen veranderen. De datum bovenaan zegt wanneer dat voor het laatst gebeurde. De site is open source, dus elke wijziging van deze pagina staat in de commitgeschiedenis.",
|
||||
"terms.contact.title": "Contact",
|
||||
"terms.contact.p1": "Meldingen en verzoeken om iets te verwijderen kun je het beste indienen op {github}. De site wordt beheerd door {azzamo}.",
|
||||
"privacy.title": "Privacy · Cashumints.space",
|
||||
"privacy.description": "Privacy op Cashumints.space: geen accounts, geen trackingcookies, geen analytics. localStorage wordt gebruikt om profielen te cachen en er gaat niets ergens naartoe. Reviews worden in je eigen browser ondertekend.",
|
||||
"privacy.heading": "Privacy",
|
||||
"privacy.lede": "We verzamelen zo weinig als een website maar kan. Laatst bijgewerkt op {date}.",
|
||||
"privacy.noAccounts.title": "Geen accounts",
|
||||
"privacy.noAccounts.p1": "Er is geen aanmelding, geen login en geen profiel. Niets op deze site vraagt wie je bent, en er is geen gebruikersgegeven dat kan uitlekken omdat er geen gebruikers zijn.",
|
||||
"privacy.noTracking.title": "Geen trackingcookies, geen analytics",
|
||||
"privacy.noTracking.p1": "De site zet geen cookies om je te volgen. Er is geen analyticsscript, geen tagmanager, geen pixel, geen sessieopname en geen advertentienetwerk. Niets op de pagina rapporteert jouw gedrag aan ons of aan iemand anders.",
|
||||
"privacy.logs.title": "Serverlogs",
|
||||
"privacy.logs.p1": "De webserver houdt gewone toegangslogs bij, zoals elke webserver standaard doet. Een logregel legt je IP-adres vast, het tijdstip, de pagina die je opvroeg, de responsstatus, en de user agent en de referrer die je browser meestuurde. De logs bestaan om fouten op te sporen en misbruik te zien. Ze worden nergens aan gekoppeld en ze worden niet verkocht of gedeeld.",
|
||||
"privacy.logs.operatorLabel": "Noot voor de beheerder:",
|
||||
"privacy.logs.operator": "draai jij deze site, zet hier dan je echte bewaartermijn neer. De opzet in de README gebruikt nginx met het standaard toegangslog en de rotatie die jouw distributie meelevert (op Debian en Ubuntu is dat logrotate, dagelijks, met 14 dagen bewaring). Kijk na wat jouw server doet en vervang deze alinea door dat getal.",
|
||||
"privacy.storage.title": "Wat er in je browser wordt bewaard",
|
||||
"privacy.storage.p1": "De site gebruikt localStorage voor precies twee dingen, en die blijven allebei op je apparaat en gaan nergens heen:",
|
||||
"privacy.storage.li1": "Een cache van Nostr-profielgegevens (kind 0-events: namen, avatars, NIP-05-adressen) van de mensen wier reviews je bekeken hebt. Zo hoeven de relays niet op elke pagina opnieuw bevraagd te worden. Regels verlopen, en de cache heeft een maximum en gooit de oudste er als eerste uit.",
|
||||
"privacy.storage.li2": "Voorkeuren voor de interface, zodat de site zich de volgende keer hetzelfde gedraagt.",
|
||||
"privacy.storage.p2": "Als je de sitegegevens in je browser wist verdwijnen ze allebei. Er gaat niets stuk, de profielen worden gewoon opnieuw opgezocht.",
|
||||
"privacy.thirdParties.title": "Waar je browser mee praat",
|
||||
"privacy.thirdParties.p1": "Een pagina laden verbindt je browser met een paar plekken buiten deze site. Zo leest een site zonder tracking aan de serverkant toch live gegevens:",
|
||||
"privacy.thirdParties.li1Label": "Publieke Nostr-relays",
|
||||
"privacy.thirdParties.li1": "{strong}, via WebSocket, om reviews en profielen te lezen. De relay ziet je IP en wat je opvroeg, zoals elke server waarmee je verbinding maakt. De relays staan op de {link}.",
|
||||
"privacy.thirdParties.li1Link": "pagina Over",
|
||||
"privacy.thirdParties.li2Label": "Avatarafbeeldingen",
|
||||
"privacy.thirdParties.li2": "{strong}, geladen van waar het profiel van wie de review schreef naar wijst. Ze worden opgevraagd met een no-referrer-beleid, dus die host komt niet te weten op welke pagina je was.",
|
||||
"privacy.publishing.title": "Een review publiceren",
|
||||
"privacy.publishing.p1": "Een review schrijven gebeurt in je browser, niet op onze server. Het event wordt ondertekend door je NIP-07-extensie (Alby, nos2x of iets dergelijks) en vanuit je browser rechtstreeks naar de relays gestuurd. Je privésleutel verlaat de extensie nooit en deze site ziet hem nooit. Wij bouwen geen eigen sleutelbeheer en wij vragen nooit om een sleutel of een seed phrase.",
|
||||
"privacy.permanent.title": "Alles wat je op Nostr publiceert is openbaar en blijvend",
|
||||
"privacy.permanent.p1": "Dit is het stuk dat het waard is om twee keer te lezen. Een review is een ondertekend event op publieke relays. Eenmaal weg is weg:",
|
||||
"privacy.permanent.li1": "Iedereen kan hem lezen, nu en later, met elke Nostr-client. Hij is niet \"openbaar op Cashumints.space\", hij is openbaar op het netwerk.",
|
||||
"privacy.permanent.li2": "Hij is voorgoed gekoppeld aan jouw publieke Nostr-sleutel, naast al het andere dat die sleutel ooit gepubliceerd heeft.",
|
||||
"privacy.permanent.li3": "Hem verwijderen is een verzoek, geen bevel. Je kunt een verwijderevent publiceren en sommige relays houden zich daaraan, maar een relay mag het negeren en wie het event al gekopieerd had heeft het nog steeds. Beschouw een gepubliceerde review als blijvend.",
|
||||
"privacy.permanent.li4": "Wij kunnen hem niet voor je verwijderen. Wij kunnen hem hier niet meer tonen, en dat verandert niets op de relays.",
|
||||
"privacy.permanent.p2": "Wil je een mint reviewen zonder dat het aan je hoofdidentiteit vastzit, gebruik dan een aparte Nostr-sleutel. Let wel: bij harde reviews wordt vermeld als een sleutel verder geen activiteit heeft, want een gloednieuwe sleutel die een review van één ster schrijft is een patroon dat lezers verdienen te zien.",
|
||||
"privacy.contact.title": "Vragen",
|
||||
"privacy.contact.p1": "De site is open source: wil je iets van het bovenstaande nagaan, lees dan de code op {github}. Vragen en correcties kunnen daar in een issue.",
|
||||
"disclaimer.title": "Disclaimer · Cashumints.space",
|
||||
"disclaimer.description": "Wat je moet weten voordat je een Cashu mint gebruikt: mints zijn custodiaal, een mint kan er met jouw geld vandoor gaan, reviews zijn meningen en online betekent bereikbaar, niet betrouwbaar.",
|
||||
"disclaimer.heading": "Disclaimer",
|
||||
"disclaimer.lede": "Lees dit voordat je geld in een mint stopt. Laatst bijgewerkt op {date}.",
|
||||
"disclaimer.callout": "Een Cashu mint houdt jouw geld voor je vast. Verdwijnt hij, dan is je ecash weg, en is er niemand bij wie je kunt aankloppen. Bewaar er alleen wat je kunt missen.",
|
||||
"disclaimer.custodial.title": "Mints zijn custodiaal",
|
||||
"disclaimer.custodial.p1": "Als je ecash mint geef je bitcoin aan de beheerder van de mint en krijg je tokens terug. Die tokens zijn een aanspraak op die beheerder. Het is geen bitcoin die jij vasthoudt, en de beheerder is degene die beslist of hij ze honoreert.",
|
||||
"disclaimer.custodial.p2": "Dat betekent dat elke mint dezelfde risico's draagt: de beheerder kan verdwijnen, kan gedwongen worden te stoppen, kan het geld kwijtraken door een bug of een hack, of kan de server gewoon uitzetten. Er is geen depositogarantie, geen terugboeking, geen helpdesk en geen juridische weg waar je op kunt rekenen. Dit geldt ook voor de best beoordeelde mint op deze site.",
|
||||
"disclaimer.custodial.p3": "Cashu is bedoeld voor kleine bedragen die snel bewegen. Gebruik het zo: bewaar wat je op het punt staat uit te geven, niet je spaargeld, en spreid over meerdere mints in plaats van alles bij één te parkeren.",
|
||||
"disclaimer.reviews.title": "Reviews zijn meningen, geen gecontroleerde feiten",
|
||||
"disclaimer.reviews.p1": "Elke review hier is geschreven door iemand op Nostr en ondertekend met diens sleutel. Ondertekend betekent dat we je kunnen vertellen welke sleutel hem schreef. Het betekent niet dat de review klopt, dat wie hem schreef de mint gebruikt heeft, of dat het niet de beheerder is, een concurrent, of één persoon met tien sleutels.",
|
||||
"disclaimer.reviews.p2": "Wij controleren niemand die een review schrijft, en wij bewerken of verwijderen geen reviews waar we het niet mee eens zijn. Lees er een paar, kijk wat er werkelijk staat, en weeg een mint met drie reviews anders dan een met driehonderd.",
|
||||
"disclaimer.status.title": "Online betekent bereikbaar, niet betrouwbaar",
|
||||
"disclaimer.status.p1": "We controleren elke mint regelmatig en laten zien of hij antwoordde. Meer betekent een groene status niet: de server gaf antwoord. Het zegt niets over of de beheerder solvabel is, eerlijk is, of morgen nog van plan is jouw tokens te honoreren.",
|
||||
"disclaimer.status.p2": "Een mint kan online zijn tot precies het moment dat hij het niet meer is. De uptime-geschiedenis, de ondersteunde NUTs en de softwareversie zeggen iets over hoe een mint gerund wordt, maar geen van drieën is een belofte over jouw geld.",
|
||||
"disclaimer.ranking.title": "Hoe de rangschikking werkt, en wat het niet betekent",
|
||||
"disclaimer.ranking.p1": "Mints worden geordend door een algoritme over publieke reviewgegevens: beoordeling, aantal reviews en recente activiteit, met online mints standaard boven offline mints. De details staan in de open source code.",
|
||||
"disclaimer.ranking.p2": "Niemand betaalt voor een plek. Er is geen gesponsorde plaats, geen uitgelichte vermelding en geen afspraak met welke mint op deze site dan ook. Hoog staan betekent dat het algoritme de publieke gegevens goed vond, meer niet. Het is geen aanbeveling en het is geen veiligheidsoordeel.",
|
||||
"disclaimer.notAdvice.title": "Dit is geen financieel advies",
|
||||
"disclaimer.notAdvice.p1": "Niets op deze site is financieel, beleggings-, juridisch of fiscaal advies. Wij kennen jouw situatie niet en wij adviseren je niet. Of je een mint gebruikt, en welke, is jouw beslissing.",
|
||||
"disclaimer.dyor.title": "Zoek het zelf uit",
|
||||
"disclaimer.dyor.li1": "Zoek uit wie hem beheert. Een mint van iemand met een publieke reputatie te verliezen is iets anders dan een anonieme URL.",
|
||||
"disclaimer.dyor.li2": "Lees de voorwaarden van de mint zelf en zijn bericht van de dag. Een mint die zichzelf een test of een beta noemt meent dat.",
|
||||
"disclaimer.dyor.li3": "Begin klein. Mint een beetje, geef het uit, melt het er weer uit, en kijk wat er gebeurt voordat je hem meer toevertrouwt.",
|
||||
"disclaimer.dyor.li4": "Let op de limieten. Een laag maximum is vaak een teken van een voorzichtige beheerder, en een hoog maximum is geen teken dat het veilig is.",
|
||||
"disclaimer.dyor.li5": "Maak een back-up van je tokens, en onthoud dat een token een waardepapier aan toonder is: wie het heeft, kan het uitgeven.",
|
||||
"disclaimer.site.title": "Over de site zelf",
|
||||
"disclaimer.site.p1": "De gegevens hier kunnen fout of verouderd zijn. Mints worden volgens een schema gecontroleerd, relays antwoorden soms niet, en de pagina die je leest is ergens in het verleden gebouwd. De site wordt geleverd zoals hij is, zonder garantie. Lees de {link}.",
|
||||
"disclaimer.site.link": "gebruiksvoorwaarden",
|
||||
"disclaimer.updated": "20 augustus 2026",
|
||||
"login.getPrimal": "Primal downloaden",
|
||||
"reviews.dialog.identity.extensionGone": "Je browserextensie antwoordt niet. Log opnieuw in, of gebruik een signer op afstand.",
|
||||
"seo.orgDescription": "Een overzicht van Cashu-mints, met de actuele status van elke mint en reviews uit de gemeenschap, ondertekend op Nostr.",
|
||||
"seo.ogImageAlt": "Cashumints.space: vind een Cashu-mint die je kunt vertrouwen.",
|
||||
"nav.home": "Home",
|
||||
"mint.description.fallbackDomain": "Een Cashu-mint die draait op {domain}.",
|
||||
"mint.more.title": "Meer mints",
|
||||
"mint.more.all": "Bekijk alle mints"
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* The `getStaticPaths` half of path-prefix routing.
|
||||
*
|
||||
* Every page lives under `src/pages/[...locale]/`, and a rest parameter of `undefined`
|
||||
* emits the bare path. So one file emits `/mints`, `/es/mints` and `/nl/mints`, and
|
||||
* there is exactly one copy of each page's markup, translated by `t()` rather than by
|
||||
* being written out three times. Astro's own i18n config knows the same locale list and
|
||||
* resolves `Astro.currentLocale` from the URL, but it does not generate pages, which is
|
||||
* what this does.
|
||||
*
|
||||
* ~55 mints plus 9 static routes, times three locales, is around 190 prerendered pages.
|
||||
* The whole set builds in one pass off one API fetch, so the cost is disk, not time.
|
||||
*/
|
||||
import type { AstroGlobal } from 'astro';
|
||||
import { DEFAULT_LOCALE, LOCALES, isLocale, type Locale } from './config.js';
|
||||
import { splitLocale } from './routing.js';
|
||||
|
||||
/** The rest-parameter value for a locale: `undefined` for English, the code otherwise. */
|
||||
export function localeParam(locale: Locale): string | undefined {
|
||||
return locale === DEFAULT_LOCALE ? undefined : locale;
|
||||
}
|
||||
|
||||
/**
|
||||
* One entry per locale, for a page with no other dynamic segment.
|
||||
*
|
||||
* The locale is passed through props as well as params so a page can read it without
|
||||
* re-deriving it from a parameter that is `undefined` for English.
|
||||
*/
|
||||
export function localePaths(): Array<{
|
||||
params: { locale: string | undefined };
|
||||
props: { locale: Locale };
|
||||
}> {
|
||||
return LOCALES.map(({ code }) => ({
|
||||
params: { locale: localeParam(code) },
|
||||
props: { locale: code },
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* The cross product of every locale with some other set of routes.
|
||||
*
|
||||
* Used by the mint pages: one entry per mint per locale, each carrying the mint detail
|
||||
* the page renders from, fetched once and shared across the three locales rather than
|
||||
* fetched three times.
|
||||
*/
|
||||
export function localePathsFor<T extends Record<string, unknown>>(
|
||||
items: Array<{ params: Record<string, string>; props: T }>,
|
||||
): Array<{ params: Record<string, string | undefined>; props: T & { locale: Locale } }> {
|
||||
return LOCALES.flatMap(({ code }) =>
|
||||
items.map((item) => ({
|
||||
params: { ...item.params, locale: localeParam(code) },
|
||||
props: { ...item.props, locale: code },
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The locale and the unprefixed path for the page being rendered.
|
||||
*
|
||||
* Derived from the URL rather than from props, so a layout or a component deep in the
|
||||
* tree can ask without every page above it threading two more props through. The path
|
||||
* it returns is the key the whole hreflang set is built from: `/mint/kashu.me`, the
|
||||
* same string for all three locales, which is exactly what makes them alternates of
|
||||
* each other.
|
||||
*/
|
||||
export function pageLocale(astro: AstroGlobal): { locale: Locale; path: string } {
|
||||
const fromUrl = splitLocale(astro.url.pathname);
|
||||
|
||||
/*
|
||||
* In dev, Astro serves the route before `getStaticPaths` props are in hand for some
|
||||
* requests, and `Astro.params.locale` is the more direct answer when it is set.
|
||||
* They agree in the build; preferring the param means dev cannot disagree with it.
|
||||
*/
|
||||
const param = astro.params['locale'];
|
||||
if (typeof param === 'string' && isLocale(param)) {
|
||||
return { locale: param, path: fromUrl.path };
|
||||
}
|
||||
|
||||
return fromUrl;
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Path arithmetic for path-prefix locales with the default language at the root.
|
||||
*
|
||||
* `/mints` is English, `/es/mints` is Spanish, `/nl/mints` is Dutch. Route slugs stay
|
||||
* English in every language: `/es/mints`, never `/es/mentas`. That is a deliberate
|
||||
* trade. Translated slugs would read marginally better in a browser's address bar and
|
||||
* would cost a permanent, growing table of slug-per-route-per-language that every
|
||||
* internal link, the link checker, the sitemap and the language switcher would have to
|
||||
* agree on, forever. Stable URLs are worth more here than translated ones, and the
|
||||
* hreflang set is what actually tells a crawler these pages are the same page.
|
||||
*/
|
||||
import { DEFAULT_LOCALE, LOCALE_CODES, isLocale, type Locale } from './config.js';
|
||||
|
||||
/**
|
||||
* The locale a path is in, and the path with its prefix removed.
|
||||
*
|
||||
* `/es/mint/kashu.me` is `{ locale: 'es', path: '/mint/kashu.me' }`. An unprefixed path
|
||||
* is the default locale. The path always starts with `/` and never ends with one,
|
||||
* except for the root, which is exactly `/`.
|
||||
*/
|
||||
export function splitLocale(pathname: string): { locale: Locale; path: string } {
|
||||
const clean = normalise(pathname);
|
||||
const segments = clean.split('/').filter(Boolean);
|
||||
const first = segments[0];
|
||||
|
||||
if (first !== undefined && isLocale(first) && first !== DEFAULT_LOCALE) {
|
||||
const rest = segments.slice(1).join('/');
|
||||
return { locale: first, path: rest ? `/${rest}` : '/' };
|
||||
}
|
||||
|
||||
return { locale: DEFAULT_LOCALE, path: clean };
|
||||
}
|
||||
|
||||
/** Just the locale, for a page that only needs to know which language it is. */
|
||||
export function localeFromPath(pathname: string): Locale {
|
||||
return splitLocale(pathname).locale;
|
||||
}
|
||||
|
||||
/** Just the unprefixed path, which is the key a page's translations are keyed by. */
|
||||
export function stripLocale(pathname: string): string {
|
||||
return splitLocale(pathname).path;
|
||||
}
|
||||
|
||||
/**
|
||||
* The same page in another language.
|
||||
*
|
||||
* `localePath('/mint/kashu.me', 'nl')` is `/nl/mint/kashu.me`. The default locale keeps
|
||||
* the bare path, which is the whole point of `prefixDefaultLocale: false`.
|
||||
*/
|
||||
export function localePath(path: string, locale: Locale): string {
|
||||
const clean = normalise(path);
|
||||
if (locale === DEFAULT_LOCALE) return clean;
|
||||
return clean === '/' ? `/${locale}` : `/${locale}${clean}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute URL for one locale's version of a page.
|
||||
*
|
||||
* hreflang and canonical both require absolute URLs, and a relative one there is not a
|
||||
* lesser signal, it is an ignored one.
|
||||
*/
|
||||
export function localeUrl(path: string, locale: Locale, site: URL | string): string {
|
||||
const origin = typeof site === 'string' ? new URL(site).origin : site.origin;
|
||||
return origin + localePath(path, locale);
|
||||
}
|
||||
|
||||
export interface Alternate {
|
||||
/** The `hreflang` value: a locale code, or `x-default`. */
|
||||
hreflang: string;
|
||||
href: string;
|
||||
locale: Locale;
|
||||
}
|
||||
|
||||
/**
|
||||
* The complete reciprocal alternate set for one page, x-default included.
|
||||
*
|
||||
* Every locale's version of this page lists every locale's version of this page,
|
||||
* itself among them, plus one `x-default` pointing at English. That reciprocity is the
|
||||
* part crawlers actually check: a page that lists alternates which do not list it back
|
||||
* has its whole set discarded, so a partial set is worth less than none.
|
||||
*
|
||||
* `x-default` is the English URL rather than a language selector, because there is no
|
||||
* selector page here and English is what an unmatched visitor gets.
|
||||
*/
|
||||
export function alternates(path: string, site: URL | string): Alternate[] {
|
||||
const out: Alternate[] = LOCALE_CODES.map((locale) => ({
|
||||
hreflang: locale,
|
||||
href: localeUrl(path, locale, site),
|
||||
locale,
|
||||
}));
|
||||
|
||||
out.push({
|
||||
hreflang: 'x-default',
|
||||
href: localeUrl(path, DEFAULT_LOCALE, site),
|
||||
locale: DEFAULT_LOCALE,
|
||||
});
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Leading slash, no trailing slash, no double slashes, no query or hash. */
|
||||
function normalise(pathname: string): string {
|
||||
const withoutQuery = pathname.split(/[?#]/)[0] ?? '';
|
||||
const trimmed = withoutQuery.replace(/\/+$/, '').replace(/\/{2,}/g, '/');
|
||||
if (trimmed === '') return '/';
|
||||
return trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* The `t()` core: one lookup, one interpolation, one plural rule.
|
||||
*
|
||||
* Deliberately tiny and dependency free, because it runs in two places with very
|
||||
* different budgets: in .astro frontmatter at build time, where nothing it does costs a
|
||||
* visitor anything, and inside an island, where every byte ships. There is no ICU
|
||||
* parser here. What ICU is actually needed for on this site is plurals, and
|
||||
* `Intl.PluralRules` is already in every browser and in node.
|
||||
*/
|
||||
import type { Locale } from './config.js';
|
||||
import { DEFAULT_LOCALE, intlTag } from './config.js';
|
||||
|
||||
/** A catalog is flat: dotted keys to strings. No nesting, so no path walking. */
|
||||
export type Catalog = Record<string, string>;
|
||||
|
||||
export type Vars = Record<string, string | number>;
|
||||
|
||||
/**
|
||||
* The interpolation placeholder. `{name}` and nothing else: no formats, no nested
|
||||
* selects. A string that needs more than a value dropped into it is two strings.
|
||||
*/
|
||||
const PLACEHOLDER = /\{(\w+)\}/g;
|
||||
|
||||
/** Cached per locale, because constructing one of these is not free. */
|
||||
const pluralRules = new Map<string, Intl.PluralRules>();
|
||||
const numberFormats = new Map<string, Intl.NumberFormat>();
|
||||
|
||||
function pluralRule(locale: Locale): Intl.PluralRules {
|
||||
const tag = intlTag(locale);
|
||||
let rule = pluralRules.get(tag);
|
||||
if (!rule) {
|
||||
rule = new Intl.PluralRules(tag);
|
||||
pluralRules.set(tag, rule);
|
||||
}
|
||||
return rule;
|
||||
}
|
||||
|
||||
function numberFormat(locale: Locale): Intl.NumberFormat {
|
||||
const tag = intlTag(locale);
|
||||
let format = numberFormats.get(tag);
|
||||
if (!format) {
|
||||
format = new Intl.NumberFormat(tag);
|
||||
numberFormats.set(tag, format);
|
||||
}
|
||||
return format;
|
||||
}
|
||||
|
||||
/**
|
||||
* The plural category for a count, as a catalog key suffix.
|
||||
*
|
||||
* English, Spanish and Dutch share one rule (`1` is singular, everything else is not),
|
||||
* so this looks like overkill for the three launch languages. It is not: it is what
|
||||
* makes language four a one-file job even when that language is Polish, which has three
|
||||
* categories, or Arabic, which has six. A catalog that only ships `.one` and `.other`
|
||||
* still resolves, because a missing category falls back to `.other`.
|
||||
*/
|
||||
function pluralKey(locale: Locale, key: string, n: number, catalog: Catalog): string | null {
|
||||
const category = pluralRule(locale).select(n);
|
||||
if (catalog[`${key}.${category}`] !== undefined) return `${key}.${category}`;
|
||||
if (catalog[`${key}.other`] !== undefined) return `${key}.other`;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Substitute `{name}` placeholders.
|
||||
*
|
||||
* Numbers are formatted for the locale on the way in, so `{n}` in "1,000,000 sats"
|
||||
* comes out as `1.000.000` in Spanish and Dutch without every caller remembering to
|
||||
* format first. Pass a string when a raw value is wanted (a NUT number, a year, a
|
||||
* rating already fixed to one decimal).
|
||||
*/
|
||||
function interpolate(template: string, vars: Vars | undefined, locale: Locale): string {
|
||||
if (!vars) return template;
|
||||
return template.replace(PLACEHOLDER, (whole, name: string) => {
|
||||
const value = vars[name];
|
||||
if (value === undefined) return whole;
|
||||
return typeof value === 'number' ? numberFormat(locale).format(value) : value;
|
||||
});
|
||||
}
|
||||
|
||||
export interface Translator {
|
||||
(key: string, vars?: Vars): string;
|
||||
locale: Locale;
|
||||
/** True when this key resolves in this locale without falling back. */
|
||||
has: (key: string) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a translator over one locale's catalog, with English behind it.
|
||||
*
|
||||
* A missing key renders the English string rather than the key itself: a visitor
|
||||
* reading a Spanish page should meet an English sentence at worst, never
|
||||
* `mint.warnings.meltOnly`. The build is what complains about the gap, loudly and by
|
||||
* name, in `scripts/check-i18n.mjs`.
|
||||
*/
|
||||
export function createTranslator(locale: Locale, catalog: Catalog, fallback: Catalog): Translator {
|
||||
const lookup = (key: string, n: number | undefined): string | undefined => {
|
||||
if (n !== undefined) {
|
||||
const plural = pluralKey(locale, key, n, catalog);
|
||||
if (plural) return catalog[plural];
|
||||
// The locale has no plural forms for this key. Take English's, picked with
|
||||
// English's own rule, rather than dropping to the bare key.
|
||||
const englishPlural = pluralKey(DEFAULT_LOCALE, key, n, fallback);
|
||||
if (englishPlural) return fallback[englishPlural];
|
||||
}
|
||||
return catalog[key] ?? fallback[key];
|
||||
};
|
||||
|
||||
const t = ((key: string, vars?: Vars): string => {
|
||||
const n = typeof vars?.['n'] === 'number' ? (vars['n'] as number) : undefined;
|
||||
const found = lookup(key, n);
|
||||
// Nothing anywhere. Render the last segment of the key as words rather than the
|
||||
// key: "meltOnly" is a bad string, "mint.warnings.meltOnly" is a bug on screen.
|
||||
if (found === undefined) return humanise(key);
|
||||
return interpolate(found, vars, locale);
|
||||
}) as Translator;
|
||||
|
||||
t.locale = locale;
|
||||
t.has = (key: string): boolean =>
|
||||
catalog[key] !== undefined ||
|
||||
catalog[`${key}.other`] !== undefined ||
|
||||
catalog[`${key}.one`] !== undefined;
|
||||
|
||||
return t;
|
||||
}
|
||||
|
||||
/**
|
||||
* The last resort, for a key that exists in no catalog at all.
|
||||
*
|
||||
* This should be unreachable: `check-i18n` fails the build on a key that is used and
|
||||
* not defined in English. It is here because "unreachable" and "never renders" are
|
||||
* different claims, and the difference is what a visitor sees.
|
||||
*/
|
||||
function humanise(key: string): string {
|
||||
const last = key.split('.').pop() ?? key;
|
||||
const spaced = last.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/[-_]+/g, ' ');
|
||||
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
---
|
||||
import { ClientRouter } from 'astro:transitions';
|
||||
import Topbar from '../components/Topbar.astro';
|
||||
import Footer from '../components/Footer.astro';
|
||||
import LoginDialog from '../components/LoginDialog.astro';
|
||||
import { alternates, clientCatalog, localeInfo, localeUrl, LOCALES, useI18n } from '../i18n';
|
||||
import { pageLocale } from '../i18n/paths';
|
||||
import {
|
||||
serializeGraph, siteNodes, webPageNode, type SchemaNode,
|
||||
} from '../lib/schema';
|
||||
import {
|
||||
AZZAMO_URL, GITHUB_URL, OG_IMAGE, OG_IMAGE_HEIGHT, OG_IMAGE_WIDTH, SITE_NAME,
|
||||
} from '../lib/site';
|
||||
/*
|
||||
* The two faces the first paint needs, imported so the preload points at the same
|
||||
* fingerprinted URL the stylesheet asks for. A hand-written `/fonts/...` path would
|
||||
* miss the hash, and the browser would download the file twice.
|
||||
*/
|
||||
import interLatin from '../assets/fonts/inter-latin.woff2?url';
|
||||
import groteskLatin from '../assets/fonts/space-grotesk-latin.woff2?url';
|
||||
import '../styles/global.css';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description: string;
|
||||
current?: 'mints' | 'reviews' | 'wallets' | 'about';
|
||||
mintCount?: number;
|
||||
ogType?: string;
|
||||
/**
|
||||
* A real page that should not be in the index.
|
||||
*
|
||||
* It keeps its canonical and its full alternate set, because it is still a page and
|
||||
* still one of three translations of itself; it just carries `noindex`, and the
|
||||
* sitemap leaves it out. Used by mint pages the site has never reached and nobody has
|
||||
* reviewed — see `lib/seo.ts`. Not the same thing as `offGraph` below, and the
|
||||
* difference matters: dropping the canonical from a page that exists tells a crawler
|
||||
* the URL is not canonical for anything, which is a different and wrong claim.
|
||||
*/
|
||||
noindex?: boolean;
|
||||
/**
|
||||
* A page that is not a page: the 404.
|
||||
*
|
||||
* It gets `<html lang>` like everything else, because a screen reader still has to
|
||||
* pronounce it, but no canonical, no alternates and no sitemap entry. A 404 is what a
|
||||
* server hands back for a URL that does not exist, so there is nothing for it to be
|
||||
* the canonical version of and nothing for it to be an alternate of. Giving it an
|
||||
* alternate set would put three non-pages into the graph and claim they were
|
||||
* translations of each other. Implies `noindex`.
|
||||
*/
|
||||
offGraph?: boolean;
|
||||
/**
|
||||
* Extra JSON-LD for this page, appended to the sitewide nodes.
|
||||
*
|
||||
* Pages contribute nodes; only this layout serialises them, so a page cannot emit a
|
||||
* second `application/ld+json` block or assign a clashing `@id`.
|
||||
*/
|
||||
schema?: SchemaNode[];
|
||||
/**
|
||||
* Social card. A site-relative path, resolved to the absolute URL `og:image` requires.
|
||||
* Defaults to the shared 1200x630 card.
|
||||
*/
|
||||
image?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
title, description, current, mintCount, ogType = 'website',
|
||||
noindex = false, offGraph = false, schema = [], image = OG_IMAGE,
|
||||
} = Astro.props;
|
||||
|
||||
/*
|
||||
* Both halves of the page's identity, from one place.
|
||||
*
|
||||
* `locale` is which language this is, `path` is which page it is with the language
|
||||
* taken off: `/mint/kashu.me` for all three of /mint/kashu.me, /es/mint/kashu.me and
|
||||
* /nl/mint/kashu.me. That shared path is what makes the three versions alternates of
|
||||
* each other, and every URL below is built from it.
|
||||
*/
|
||||
const { locale, path } = pageLocale(Astro);
|
||||
const t = useI18n(locale);
|
||||
const info = localeInfo(locale);
|
||||
|
||||
const site = Astro.site ?? new URL('https://cashumints.space');
|
||||
|
||||
/*
|
||||
* Canonical points at this locale's own URL, never at English.
|
||||
*
|
||||
* A Spanish page canonicalising to the English one would be telling a crawler the
|
||||
* Spanish page is a duplicate that should not be indexed, which is the exact opposite
|
||||
* of the point. Self-canonical plus a reciprocal hreflang set is the pair that says
|
||||
* "these are the same page in different languages", and it only works if both are right.
|
||||
*/
|
||||
const canonical = localeUrl(path, locale, site);
|
||||
|
||||
/*
|
||||
* The alternate set: every locale's version of this page, plus x-default.
|
||||
*
|
||||
* Every locale emits the identical list, itself included. That reciprocity is the part
|
||||
* crawlers verify: a set whose members do not all point back at each other is discarded
|
||||
* whole, so a partial set is worth less than none. `x-default` is the English URL,
|
||||
* because that is what a visitor whose language we do not have actually gets.
|
||||
*/
|
||||
const alternateLinks = alternates(path, site);
|
||||
|
||||
const ogAlternates = LOCALES.filter((l) => l.code !== locale).map((l) => l.og);
|
||||
|
||||
/**
|
||||
* Only the strings islands render, only in this language.
|
||||
*
|
||||
* Emitted as JSON in the body rather than imported by the islands, which is what keeps
|
||||
* a Spanish page from shipping English and Dutch: the island chunks are shared across
|
||||
* every locale, so anything imported into one is downloaded by all of them. This
|
||||
* travels in the HTML the page was sending anyway, costs no extra request, and is on
|
||||
* screen before the first island has finished downloading.
|
||||
*/
|
||||
const i18nPayload = JSON.stringify({ locale, catalog: clientCatalog(locale) }).replace(/</g, '\\u003c');
|
||||
|
||||
/**
|
||||
* The social card, absolute.
|
||||
*
|
||||
* `og:image` must be an absolute URL: every consumer that matters rejects a relative
|
||||
* one silently, which looks exactly like having no card at all. `new URL` accepts both
|
||||
* a path and an already-absolute value, so a per-mint card later is a one-line change
|
||||
* at the call site.
|
||||
*/
|
||||
const imageUrl = new URL(image, site).href;
|
||||
|
||||
/**
|
||||
* This page's JSON-LD, assembled here and nowhere else.
|
||||
*
|
||||
* The sitewide nodes, the page node, then whatever the page itself contributed. A 404
|
||||
* gets none of it: there is no page for the graph to be about.
|
||||
*
|
||||
* Serialising in one place is what guarantees one block per document. A component that
|
||||
* emitted its own would be a second `@id` namespace nobody is reconciling.
|
||||
*/
|
||||
const graph = offGraph
|
||||
? []
|
||||
: [
|
||||
...siteNodes({
|
||||
origin: site.origin,
|
||||
name: SITE_NAME,
|
||||
description: t('seo.orgDescription'),
|
||||
locale: info.code,
|
||||
logo: new URL('/icon-512.png', site).href,
|
||||
sameAs: [GITHUB_URL, AZZAMO_URL],
|
||||
}),
|
||||
webPageNode({
|
||||
origin: site.origin,
|
||||
canonical,
|
||||
title,
|
||||
description,
|
||||
locale: info.code,
|
||||
image: imageUrl,
|
||||
}),
|
||||
...schema,
|
||||
];
|
||||
|
||||
/**
|
||||
* Entrance animations start from a zeroed state, and the `.in` class that ends them
|
||||
* is added by JavaScript. With JavaScript off nobody ever adds it, so every zeroed
|
||||
* state is cancelled here instead: the element itself, and the four things inside one
|
||||
* that wait for their card to arrive (bar fills, star rows, the uptime line).
|
||||
*
|
||||
* Anything new that hides itself until `.in` belongs in this list too.
|
||||
*
|
||||
* Emitted as raw HTML because Astro hoists any <style> it parses out of the template
|
||||
* and into the page stylesheet, which would cancel the entrances for everyone.
|
||||
*/
|
||||
const noscriptMotion =
|
||||
'<noscript><style>' +
|
||||
'[data-reveal]{opacity:1!important;transform:none!important}' +
|
||||
'[data-reveal] .mc-bar-fill,[data-reveal] .dist-fill,[data-reveal] .rc-star' +
|
||||
'{opacity:1!important;transform:none!important}' +
|
||||
'[data-reveal] .spark-line{stroke-dashoffset:0!important}' +
|
||||
'[data-reveal] .spark-area{opacity:1!important}' +
|
||||
'</style></noscript>';
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang={info.code}>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{title}</title>
|
||||
<meta name="description" content={description} />
|
||||
{!offGraph && <link rel="canonical" href={canonical} />}
|
||||
|
||||
{/*
|
||||
The reciprocal set. Absolute URLs, because a relative hreflang is not a weaker
|
||||
signal, it is an ignored one.
|
||||
*/}
|
||||
{
|
||||
!offGraph &&
|
||||
alternateLinks.map((alternate) => (
|
||||
<link rel="alternate" hreflang={alternate.hreflang} href={alternate.href} />
|
||||
))
|
||||
}
|
||||
|
||||
{/*
|
||||
`follow`, not `none`: the links out of an unindexed page are how a crawler still
|
||||
reaches the pages that are indexed, and how someone on a 404 gets un-lost.
|
||||
*/}
|
||||
{(noindex || offGraph) && <meta name="robots" content="noindex, follow" />}
|
||||
|
||||
<Fragment set:html={noscriptMotion} />
|
||||
|
||||
{/* Same-site navigation cross-fades, and a mint card morphs into its mint page. */}
|
||||
<ClientRouter />
|
||||
|
||||
<meta property="og:type" content={ogType} />
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:url" content={canonical} />
|
||||
<meta property="og:site_name" content={SITE_NAME} />
|
||||
<meta property="og:locale" content={info.og} />
|
||||
{ogAlternates.map((og) => <meta property="og:locale:alternate" content={og} />)}
|
||||
<meta property="og:image" content={imageUrl} />
|
||||
<meta property="og:image:width" content={String(OG_IMAGE_WIDTH)} />
|
||||
<meta property="og:image:height" content={String(OG_IMAGE_HEIGHT)} />
|
||||
<meta property="og:image:alt" content={t('seo.ogImageAlt')} />
|
||||
|
||||
{/*
|
||||
`summary_large_image`, now that there is an image worth the space. With no
|
||||
`twitter:image` this was a `summary` card showing a blank square.
|
||||
*/}
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={title} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
<meta name="twitter:image" content={imageUrl} />
|
||||
<meta name="twitter:image:alt" content={t('seo.ogImageAlt')} />
|
||||
|
||||
{/*
|
||||
Two faces, and only two: Inter 400 is every paragraph on the site and Space
|
||||
Grotesk 700 is every h1 and the brand mark, so both are needed for the first
|
||||
paint. Preloading the rest would compete with the stylesheet for bandwidth to
|
||||
fetch faces most pages never use above the fold.
|
||||
|
||||
`crossorigin` is required on a font preload even for a same-origin file. Without
|
||||
it the preload lands in a different cache partition than the CSS request and the
|
||||
file is fetched twice.
|
||||
*/}
|
||||
<link rel="preload" as="font" type="font/woff2" href={interLatin} crossorigin />
|
||||
<link rel="preload" as="font" type="font/woff2" href={groteskLatin} crossorigin />
|
||||
|
||||
{/*
|
||||
The moai, in the three shapes browsers actually ask for: an SVG for anything
|
||||
modern, a 32px ICO for the bare /favicon.ico request crawlers and feed readers
|
||||
still make, and a 180px PNG for an iOS home screen.
|
||||
*/}
|
||||
<link rel="icon" href="/favicon.ico" sizes="32x32" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<meta name="theme-color" content="#0E0D0B" />
|
||||
|
||||
{/*
|
||||
One block per page, assembled above. `is:inline` keeps Astro from treating it as
|
||||
a module to bundle, and `serializeGraph` is what makes an operator-written mint
|
||||
name safe to put inside a script element.
|
||||
*/}
|
||||
{graph.length > 0 && (
|
||||
<script type="application/ld+json" is:inline set:html={serializeGraph(graph)} />
|
||||
)}
|
||||
</head>
|
||||
<body>
|
||||
{/*
|
||||
The page's own strings, for the islands. `is:inline` keeps Astro from treating a
|
||||
JSON payload as a module to bundle, and the `<` escape keeps a translated string
|
||||
containing markup from closing this tag early.
|
||||
|
||||
In the body, not the head: the view transition router replaces the body wholesale
|
||||
on every navigation, so an island reading this after a swap reads the page it is
|
||||
actually on. A head script would be kept and would keep answering in the language
|
||||
the reader arrived in.
|
||||
*/}
|
||||
<script is:inline type="application/json" data-i18n set:html={i18nPayload} />
|
||||
|
||||
<a class="skip-link" href="#main">{t('nav.skip')}</a>
|
||||
<Topbar current={current} mintCount={mintCount} />
|
||||
<main id="main">
|
||||
<slot />
|
||||
</main>
|
||||
<Footer />
|
||||
{/* Opened from the header and from the review dialog's identity row. */}
|
||||
<LoginDialog />
|
||||
|
||||
<script>
|
||||
import { armSharedTransitions, initReveal, resetReveal } from '../scripts/reveal';
|
||||
|
||||
/*
|
||||
* Reveal-on-scroll, wired for both kinds of arrival: straight away on a full
|
||||
* page load (this module is deferred, so the document is already parsed), and
|
||||
* again on every page the router swaps in. The observer is dropped first, since
|
||||
* the elements it was watching are about to be replaced.
|
||||
*/
|
||||
initReveal();
|
||||
// The mint card being clicked lends its icon and name to the page it opens.
|
||||
armSharedTransitions();
|
||||
document.addEventListener('astro:before-swap', resetReveal);
|
||||
// Before the browser snapshots the incoming page, not after, or an element
|
||||
// still waiting to be revealed would be captured invisible by the cross-fade.
|
||||
document.addEventListener('astro:after-swap', () => initReveal());
|
||||
document.addEventListener('astro:page-load', () => initReveal());
|
||||
|
||||
// "/" focuses the search box on any page that has one. Delegated from the
|
||||
// document, which survives a view transition, so it is wired exactly once.
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key !== '/' || event.metaKey || event.ctrlKey || event.altKey) return;
|
||||
const active = document.activeElement;
|
||||
if (active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement) return;
|
||||
|
||||
const search = document.querySelector<HTMLInputElement>('[data-search-input]');
|
||||
if (search) {
|
||||
event.preventDefault();
|
||||
search.focus();
|
||||
search.select();
|
||||
return;
|
||||
}
|
||||
|
||||
// Pages without a search box send you to the index, which has one.
|
||||
const link = document.querySelector<HTMLAnchorElement>('.topbar-search');
|
||||
if (link) {
|
||||
event.preventDefault();
|
||||
window.location.href = link.href;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { Health, MintDetail, MintListItem, Stats } from '@cashumints/shared';
|
||||
|
||||
/**
|
||||
* Build-time API base. Islands use PUBLIC_API_BASE (see api-client.ts) so the browser
|
||||
* can be pointed at a different origin than the build machine.
|
||||
*/
|
||||
export const API_URL = import.meta.env['API_URL'] ?? process.env['API_URL'] ?? 'http://127.0.0.1:8787';
|
||||
|
||||
async function get<T>(path: string): Promise<T> {
|
||||
const res = await fetch(`${API_URL}${path}`, { headers: { Accept: 'application/json' } });
|
||||
if (!res.ok) throw new Error(`${path} responded ${res.status}`);
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build-time fetches. A failure here fails the build on purpose: shipping a site with
|
||||
* an empty mint list would be worse than not shipping.
|
||||
*/
|
||||
export const fetchMints = (): Promise<MintListItem[]> => get<MintListItem[]>('/api/mints');
|
||||
export const fetchMint = (host: string): Promise<MintDetail> =>
|
||||
get<MintDetail>(`/api/mints/${encodeURIComponent(host)}`);
|
||||
export const fetchStats = (): Promise<Stats> => get<Stats>('/api/stats');
|
||||
|
||||
/** Health is allowed to be down at build time: the ticker just renders without it. */
|
||||
export async function fetchHealth(): Promise<Health | null> {
|
||||
try {
|
||||
return await get<Health>('/api/health');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser-facing API origin baked into the prerendered markup. Empty means same origin:
|
||||
* icons resolve to /icons on whatever host serves the page, which the dev server proxy
|
||||
* and the nginx config in the README both forward to the API. Never fall back to
|
||||
* API_URL here — that is a build-machine address (127.0.0.1) and a visitor's browser
|
||||
* cannot reach it. Set PUBLIC_API_URL only when the API lives on its own origin.
|
||||
*/
|
||||
export const PUBLIC_API_URL = process.env['PUBLIC_API_URL'] ?? '';
|
||||
|
||||
/** URL for a cached mint icon, or null. */
|
||||
export function iconUrl(icon: string | null): string | null {
|
||||
return icon ? `${PUBLIC_API_URL}${icon}` : null;
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
/**
|
||||
* The login popup and the header's account control.
|
||||
*
|
||||
* Shipped on every page, so the static half of this module is deliberately thin:
|
||||
* nothing about NIP-46, QR encoding or bech32 is imported until a reader actually
|
||||
* starts a login. A logged-out visitor reading reviews downloads a few hundred bytes
|
||||
* of this and nothing else.
|
||||
*
|
||||
* Everything it renders is public: a pubkey, a name, a picture. No key material of
|
||||
* any kind reaches this file.
|
||||
*/
|
||||
import type { Profile } from '@cashumints/shared';
|
||||
import { escapeHtml, iconGradient, shortNpub, truncateMiddle, wireCopyButtons } from './client';
|
||||
import { useI18n } from '../i18n/client';
|
||||
import type { Translator } from '../i18n/translate';
|
||||
import { closeModal, openModal, wireModal } from './modal';
|
||||
import {
|
||||
clearSession,
|
||||
connectBunker,
|
||||
connectExtension,
|
||||
hasExtension,
|
||||
onSessionChange,
|
||||
onSignerStatus,
|
||||
readSession,
|
||||
signerErrorMessage,
|
||||
startNostrConnect,
|
||||
type ConnectHandle,
|
||||
type Session,
|
||||
type SignerMethod,
|
||||
} from './nostr-signer';
|
||||
|
||||
/* ---------- identity rendering, shared with the review dialog ---------- */
|
||||
|
||||
function profileName(profile: Profile | null): string | null {
|
||||
const name = profile?.found ? (profile.display_name ?? profile.name) : null;
|
||||
return name && name.trim() ? name.trim() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The two lines an identity is shown on, wherever it appears.
|
||||
*
|
||||
* With a kind 0 name the npub goes underneath it, because the name is for reading and
|
||||
* the npub is for checking. Without one the npub is the name, and repeating it
|
||||
* underneath would be saying the same thing twice.
|
||||
*/
|
||||
export function identityLines(
|
||||
profile: Profile | null,
|
||||
npub: string,
|
||||
t: Translator = useI18n(),
|
||||
): { primary: string; secondary: string; named: boolean } {
|
||||
const name = profileName(profile);
|
||||
// The name is the reader's own, out of their kind 0, and is never touched. Only the
|
||||
// phrase standing in when there is no name is site copy.
|
||||
return name
|
||||
? { primary: name, secondary: shortNpub(npub), named: true }
|
||||
: { primary: shortNpub(npub), secondary: t('login.account'), named: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* The account avatar: a generated tile from the pubkey, with the kind 0 picture over
|
||||
* it once it decodes. Same two-layer trick as a review card, so a missing or dead
|
||||
* picture costs nothing and moves nothing.
|
||||
*/
|
||||
export function avatarMarkup(pubkey: string, profile: Profile | null, npub: string, size = 26): string {
|
||||
const name = profile?.found ? (profile.display_name ?? profile.name) : null;
|
||||
const initial = (name?.[0] ?? npub[5] ?? '?').toUpperCase();
|
||||
const tile =
|
||||
`<span class="avatar" style="background:${iconGradient(pubkey)};width:${size}px;height:${size}px;` +
|
||||
`font-size:${Math.round(size * 0.44)}px" aria-hidden="true">${escapeHtml(initial)}</span>`;
|
||||
|
||||
const picture = profile?.found ? profile.picture : null;
|
||||
if (!picture) return `<span class="id-avatar">${tile}</span>`;
|
||||
|
||||
return (
|
||||
`<span class="id-avatar">${tile}<img class="avatar pic" src="${escapeHtml(picture)}" alt=""` +
|
||||
` width="${size}" height="${size}" style="width:${size}px;height:${size}px" loading="lazy"` +
|
||||
` referrerpolicy="no-referrer" onload="this.classList.add('loaded')" onerror="this.remove()"></span>`
|
||||
);
|
||||
}
|
||||
|
||||
/** How the reader is signing, said in one word. "Primal" is a product name and stays. */
|
||||
export function methodLabel(method: SignerMethod, t: Translator = useI18n()): string {
|
||||
return t(`login.method.${method}`);
|
||||
}
|
||||
|
||||
export function methodChipHtml(method: SignerMethod, t: Translator = useI18n()): string {
|
||||
return `<span class="id-method">${escapeHtml(methodLabel(method, t))}</span>`;
|
||||
}
|
||||
|
||||
/** npub for a hex pubkey. bech32 is only pulled in once someone is actually logged in. */
|
||||
export async function toNpub(pubkey: string): Promise<string> {
|
||||
try {
|
||||
const { npubEncode } = await import('nostr-tools/nip19');
|
||||
return npubEncode(pubkey);
|
||||
} catch {
|
||||
return pubkey;
|
||||
}
|
||||
}
|
||||
|
||||
/** The profile for one pubkey, cache first, never throwing. */
|
||||
export async function resolveProfile(pubkey: string): Promise<Profile | null> {
|
||||
try {
|
||||
const { loadProfiles } = await import('./profiles');
|
||||
return (await loadProfiles([pubkey])).get(pubkey) ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- the login popup ---------- */
|
||||
|
||||
let dialog: HTMLDialogElement | null = null;
|
||||
/** The pairing currently on screen, so leaving the step can call it off. */
|
||||
let pairing: ConnectHandle | null = null;
|
||||
/** Where focus was when the popup opened, so it can go back there. */
|
||||
let opener: HTMLElement | null = null;
|
||||
|
||||
function el<T extends HTMLElement>(selector: string): T | null {
|
||||
return dialog?.querySelector<T>(selector) ?? null;
|
||||
}
|
||||
|
||||
function setStatus(message: string, tone: 'busy' | 'error' | 'done' | 'none' = 'busy'): void {
|
||||
const box = el<HTMLElement>('[data-login-status]');
|
||||
if (!box) return;
|
||||
box.textContent = message;
|
||||
box.className = `login-status${tone === 'none' ? '' : ` is-${tone}`}`;
|
||||
box.hidden = message === '';
|
||||
}
|
||||
|
||||
function showError(message: string): void {
|
||||
const box = el<HTMLElement>('[data-login-error]');
|
||||
if (!box) return;
|
||||
box.textContent = message;
|
||||
box.hidden = message === '';
|
||||
}
|
||||
|
||||
function step(name: 'choose' | 'connect'): void {
|
||||
for (const panel of dialog?.querySelectorAll<HTMLElement>('[data-login-step]') ?? []) {
|
||||
panel.hidden = panel.dataset['loginStep'] !== name;
|
||||
}
|
||||
}
|
||||
|
||||
/** Leave the pairing step, calling off whatever it started. */
|
||||
function cancelPairing(): void {
|
||||
pairing?.cancel();
|
||||
pairing = null;
|
||||
}
|
||||
|
||||
export function openLogin(from?: HTMLElement | null): void {
|
||||
if (!dialog) return;
|
||||
opener = from ?? (document.activeElement as HTMLElement | null);
|
||||
showError('');
|
||||
setStatus('', 'none');
|
||||
step('choose');
|
||||
openModal(dialog);
|
||||
el<HTMLButtonElement>('[data-login-method]')?.focus();
|
||||
}
|
||||
|
||||
export function closeLogin(): void {
|
||||
if (dialog) closeModal(dialog);
|
||||
}
|
||||
|
||||
/* ---------- the three flows ---------- */
|
||||
|
||||
async function runExtension(): Promise<void> {
|
||||
const t = useI18n();
|
||||
showError('');
|
||||
setStatus(t('login.status.waitingExtension'), 'busy');
|
||||
try {
|
||||
await connectExtension();
|
||||
setStatus('', 'none');
|
||||
closeLogin();
|
||||
} catch (error) {
|
||||
setStatus('', 'none');
|
||||
showError(signerErrorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The one link this screen's copy carries, built here so the catalog only has to say
|
||||
* where in the sentence it goes.
|
||||
*/
|
||||
function primalDownloadLink(t: Translator): string {
|
||||
return (
|
||||
'<a href="https://primal.net/downloads" rel="noopener noreferrer" target="_blank">' +
|
||||
`${escapeHtml(t('login.getPrimal'))}</a>`
|
||||
);
|
||||
}
|
||||
|
||||
async function runConnect(method: 'remote' | 'primal'): Promise<void> {
|
||||
const t = useI18n();
|
||||
cancelPairing();
|
||||
showError('');
|
||||
step('connect');
|
||||
|
||||
const lead = el<HTMLElement>('[data-connect-lead]');
|
||||
const hint = el<HTMLElement>('[data-connect-hint]');
|
||||
const qr = el<HTMLElement>('[data-connect-qr]');
|
||||
const uriBox = el<HTMLElement>('[data-connect-uri]');
|
||||
const copy = el<HTMLButtonElement>('[data-connect-copy]');
|
||||
const title = el<HTMLElement>('[data-connect-title]');
|
||||
|
||||
if (title) title.textContent = t(`login.connect.${method}.title`);
|
||||
if (lead) lead.textContent = t(`login.connect.${method}.lead`);
|
||||
/*
|
||||
* Catalog copy, with one anchor this file built, interpolated into it. Nothing here
|
||||
* comes from a relay, a URL or the reader, and the only value substituted in is the
|
||||
* link above, whose text goes through escapeHtml on the way.
|
||||
*/
|
||||
if (hint) hint.innerHTML = t(`login.connect.${method}.hint`, { link: primalDownloadLink(t) });
|
||||
if (qr) qr.innerHTML = '';
|
||||
setStatus(t('login.status.buildingLink'), 'busy');
|
||||
|
||||
let handle: ConnectHandle;
|
||||
try {
|
||||
handle = await startNostrConnect(method);
|
||||
} catch (error) {
|
||||
setStatus('', 'none');
|
||||
showError(signerErrorMessage(error));
|
||||
return;
|
||||
}
|
||||
pairing = handle;
|
||||
|
||||
const { qrSvg } = await import('./qr');
|
||||
if (qr) qr.innerHTML = qrSvg(handle.uri);
|
||||
if (uriBox) uriBox.textContent = truncateMiddle(handle.uri, 28, 12);
|
||||
if (copy) {
|
||||
copy.dataset['copy'] = handle.uri;
|
||||
copy.hidden = false;
|
||||
}
|
||||
setStatus(t('login.status.waitingSigner'), 'busy');
|
||||
|
||||
try {
|
||||
await handle.session;
|
||||
if (pairing !== handle) return; // The reader went back; this pairing is stale.
|
||||
pairing = null;
|
||||
setStatus(t('login.status.connected'), 'done');
|
||||
closeLogin();
|
||||
} catch (error) {
|
||||
if (pairing !== handle) return;
|
||||
pairing = null;
|
||||
setStatus('', 'none');
|
||||
showError(signerErrorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
async function runBunkerPaste(input: string): Promise<void> {
|
||||
const t = useI18n();
|
||||
showError('');
|
||||
setStatus(t('login.status.connecting'), 'busy');
|
||||
try {
|
||||
await connectBunker(input, 'remote');
|
||||
setStatus(t('login.status.connected'), 'done');
|
||||
closeLogin();
|
||||
} catch (error) {
|
||||
setStatus('', 'none');
|
||||
showError(signerErrorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- the header control ---------- */
|
||||
|
||||
let menuOpen = false;
|
||||
|
||||
function headerRoot(): HTMLElement | null {
|
||||
return document.querySelector<HTMLElement>('[data-auth]');
|
||||
}
|
||||
|
||||
function loggedOutHtml(): string {
|
||||
return (
|
||||
'<button class="auth-login" type="button" data-login-open>' +
|
||||
`${escapeHtml(useI18n()('login.open'))}</button>`
|
||||
);
|
||||
}
|
||||
|
||||
function loggedInHtml(session: Session, npub: string, profile: Profile | null): string {
|
||||
const t = useI18n();
|
||||
const { primary: name, named } = identityLines(profile, npub, t);
|
||||
return (
|
||||
`<button class="auth-user" type="button" data-auth-menu-button aria-haspopup="true"` +
|
||||
` aria-expanded="false" aria-controls="auth-menu">` +
|
||||
`${avatarMarkup(session.pubkey, profile, npub)}` +
|
||||
`<span class="auth-name${named ? '' : ' mono'}">${escapeHtml(name)}</span>` +
|
||||
`<svg class="auth-caret" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"` +
|
||||
` stroke-width="2.5" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>` +
|
||||
`</button>` +
|
||||
`<div class="auth-menu" id="auth-menu" data-auth-menu hidden>` +
|
||||
// The npub is a row of its own below, so the head only names someone who has a
|
||||
// name to be named by.
|
||||
`<p class="auth-menu-head">${escapeHtml(named ? name : t('login.account'))}` +
|
||||
`<span>${escapeHtml(methodLabel(session.method, t))}</span></p>` +
|
||||
`<button class="auth-menu-row npub" type="button" data-copy-id="${escapeHtml(npub)}"` +
|
||||
` title="${escapeHtml(npub)}" aria-label="${escapeHtml(t('login.copyNpub'))}">` +
|
||||
`<span class="cid-text mono">${escapeHtml(shortNpub(npub))}</span></button>` +
|
||||
`<button class="auth-menu-row" type="button" data-logout>${escapeHtml(t('login.logout'))}</button>` +
|
||||
`</div>`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw the header for the session as it stands.
|
||||
*
|
||||
* Runs twice on a restored session: once from the cache, immediately, so the avatar
|
||||
* and name are there at first paint, and again if the relays turn up something newer.
|
||||
* Neither pass changes the control's height.
|
||||
*/
|
||||
async function renderHeader(): Promise<void> {
|
||||
const root = headerRoot();
|
||||
if (!root) return;
|
||||
|
||||
const session = readSession();
|
||||
if (!session) {
|
||||
menuOpen = false;
|
||||
root.innerHTML = loggedOutHtml();
|
||||
return;
|
||||
}
|
||||
|
||||
const npub = await toNpub(session.pubkey);
|
||||
const cached = await resolveProfile(session.pubkey);
|
||||
if (readSession()?.pubkey !== session.pubkey) return; // Logged out while we waited.
|
||||
root.innerHTML = loggedInHtml(session, npub, cached);
|
||||
}
|
||||
|
||||
function setMenu(open: boolean): void {
|
||||
const root = headerRoot();
|
||||
const button = root?.querySelector<HTMLButtonElement>('[data-auth-menu-button]');
|
||||
const menu = root?.querySelector<HTMLElement>('[data-auth-menu]');
|
||||
if (!button || !menu) return;
|
||||
|
||||
menuOpen = open;
|
||||
menu.hidden = !open;
|
||||
button.setAttribute('aria-expanded', String(open));
|
||||
if (open) menu.querySelector<HTMLButtonElement>('button')?.focus();
|
||||
}
|
||||
|
||||
/** Arrow keys walk the menu, Escape shuts it and hands focus back to its button. */
|
||||
function menuKeys(event: KeyboardEvent): void {
|
||||
const root = headerRoot();
|
||||
const menu = root?.querySelector<HTMLElement>('[data-auth-menu]');
|
||||
if (!menu || menu.hidden) return;
|
||||
|
||||
const items = [...menu.querySelectorAll<HTMLButtonElement>('button')];
|
||||
const index = items.indexOf(document.activeElement as HTMLButtonElement);
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
setMenu(false);
|
||||
root?.querySelector<HTMLButtonElement>('[data-auth-menu-button]')?.focus();
|
||||
return;
|
||||
}
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
const next = event.key === 'ArrowDown' ? index + 1 : index - 1;
|
||||
items[(next + items.length) % items.length]?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- wiring ---------- */
|
||||
|
||||
let wired = false;
|
||||
|
||||
/**
|
||||
* Wire the header and the popup. Called on first paint and after every view
|
||||
* transition; the document-level listeners are attached once, the header is redrawn
|
||||
* every time because the router replaces it.
|
||||
*/
|
||||
export function initAuth(): void {
|
||||
dialog = document.querySelector<HTMLDialogElement>('[data-login-dialog]');
|
||||
void renderHeader();
|
||||
|
||||
if (dialog) {
|
||||
// The connection link's copy button. Its value is set when a pairing starts; the
|
||||
// handler reads it at click time, so wiring it once here is enough.
|
||||
wireCopyButtons(dialog);
|
||||
wireModal(dialog, {
|
||||
onClose: () => {
|
||||
cancelPairing();
|
||||
opener?.focus();
|
||||
opener = null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (wired) return;
|
||||
wired = true;
|
||||
|
||||
onSessionChange(() => {
|
||||
void renderHeader();
|
||||
});
|
||||
|
||||
// The signer talks back through here: reconnecting, waiting, approve-in-a-window.
|
||||
onSignerStatus((update) => {
|
||||
if (!dialog?.open) return;
|
||||
setStatus(update.message, update.state === 'error' ? 'error' : update.state === 'ready' ? 'done' : 'busy');
|
||||
});
|
||||
|
||||
document.addEventListener('click', (event) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (!target) return;
|
||||
|
||||
const open = target.closest<HTMLElement>('[data-login-open]');
|
||||
if (open) {
|
||||
event.preventDefault();
|
||||
openLogin(open);
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.closest('[data-login-close]')) {
|
||||
closeLogin();
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.closest('[data-login-back]')) {
|
||||
cancelPairing();
|
||||
showError('');
|
||||
setStatus('', 'none');
|
||||
step('choose');
|
||||
el<HTMLButtonElement>('[data-login-method]')?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const method = target.closest<HTMLButtonElement>('[data-login-method]');
|
||||
if (method) {
|
||||
const kind = method.dataset['loginMethod'] as SignerMethod;
|
||||
if (kind === 'extension') void runExtension();
|
||||
else void runConnect(kind === 'primal' ? 'primal' : 'remote');
|
||||
return;
|
||||
}
|
||||
|
||||
const menuButton = target.closest<HTMLElement>('[data-auth-menu-button]');
|
||||
if (menuButton) {
|
||||
setMenu(!menuOpen);
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.closest('[data-bunker-connect]')) {
|
||||
const input = dialog?.querySelector<HTMLInputElement>('#bunker-input');
|
||||
void runBunkerPaste(input?.value ?? '');
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.closest('[data-logout]')) {
|
||||
clearSession();
|
||||
return;
|
||||
}
|
||||
|
||||
// A click anywhere else closes an open account menu.
|
||||
if (menuOpen && !target.closest('[data-auth-menu]')) setMenu(false);
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', menuKeys);
|
||||
|
||||
// Enter in the paste box does what the Connect button does. There is no form here
|
||||
// on purpose (see LoginDialog.astro), so this is the whole of "submitting" it.
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key !== 'Enter') return;
|
||||
const input = (event.target as HTMLElement).closest<HTMLInputElement>('#bunker-input');
|
||||
if (!input) return;
|
||||
event.preventDefault();
|
||||
void runBunkerPaste(input.value);
|
||||
});
|
||||
}
|
||||
|
||||
/** True when the extension option should be offered. Checked on open, not at build. */
|
||||
export function extensionAvailable(): boolean {
|
||||
return hasExtension();
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
/** Browser-side helpers shared by islands. Keep this small: it ships to every page. */
|
||||
import { useI18n } from '../i18n/client';
|
||||
|
||||
/** Runtime API origin, injected by astro.config.mjs at build time. */
|
||||
export const apiBase: string = import.meta.env.PUBLIC_API_URL;
|
||||
|
||||
/*
|
||||
* Relative times, dates and numbers are not here any more.
|
||||
*
|
||||
* They read differently in every language, so they come from `formatters(useI18n())`
|
||||
* in src/i18n/format.ts, which an island builds once in its setup from the locale the
|
||||
* page is carrying. `starString` moved with them, because the two are always used
|
||||
* together on a review row.
|
||||
*/
|
||||
|
||||
export function truncateMiddle(value: string, head = 10, tail = 8): string {
|
||||
if (value.length <= head + tail + 1) return value;
|
||||
return `${value.slice(0, head)}…${value.slice(-tail)}`;
|
||||
}
|
||||
|
||||
export function iconGradient(seed: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < seed.length; i++) hash = (hash * 31 + seed.charCodeAt(i)) >>> 0;
|
||||
const hue = hash % 360;
|
||||
return `linear-gradient(135deg, hsl(${hue} 45% 55%), hsl(${(hue + 40) % 360} 45% 45%))`;
|
||||
}
|
||||
|
||||
/** The tick every copy confirmation on the site uses. It scales in from 0.8. */
|
||||
const TICK_SVG =
|
||||
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#5FD68F" ' +
|
||||
'stroke-width="2.5" aria-hidden="true"><path d="m5 13 4 4L19 7"/></svg>';
|
||||
|
||||
/** One amber pulse on the control that just did something. Success only, never hover. */
|
||||
function pulseSuccess(el: HTMLElement): void {
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||||
el.classList.remove('did-it');
|
||||
// Reading the layout restarts the animation when the same button is used twice.
|
||||
void el.offsetWidth;
|
||||
el.classList.add('did-it');
|
||||
el.addEventListener('animationend', () => el.classList.remove('did-it'), { once: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy to clipboard, swapping the button's icon for a tick for a moment.
|
||||
*
|
||||
* Only the icon and the label are swapped, not the whole button: replacing the inner
|
||||
* HTML wholesale used to collapse "Copy mint URL" to a bare tick and shuffle the row
|
||||
* it sits in. The width is pinned for the same reason, and the tick scales in rather
|
||||
* than appearing, so the confirmation reads as a response to the click.
|
||||
*/
|
||||
export function wireCopyButtons(root: ParentNode = document): void {
|
||||
for (const button of root.querySelectorAll<HTMLButtonElement>('[data-copy]')) {
|
||||
if (button.dataset['copyWired']) continue;
|
||||
button.dataset['copyWired'] = '1';
|
||||
|
||||
button.addEventListener('click', async (event) => {
|
||||
event.preventDefault();
|
||||
const text = button.dataset['copy'];
|
||||
if (!text || button.dataset['copied']) return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const icon = button.querySelector('svg');
|
||||
const label = button.getAttribute('aria-label');
|
||||
// The button's own wording, if it has any, is its first real text node.
|
||||
const wording = [...button.childNodes].find(
|
||||
(node): node is Text => node.nodeType === Node.TEXT_NODE && node.textContent!.trim() !== '',
|
||||
);
|
||||
const wordingWas = wording?.textContent ?? null;
|
||||
const iconWas = icon?.outerHTML ?? null;
|
||||
|
||||
// Read at click time, not at module load: the router swaps pages under this
|
||||
// module, and the confirmation belongs to the page the button is on.
|
||||
const copied = useI18n()('common.copied');
|
||||
|
||||
button.style.minWidth = `${button.offsetWidth}px`;
|
||||
button.dataset['copied'] = '1';
|
||||
if (icon) icon.outerHTML = `<span class="copy-tick">${TICK_SVG}</span>`;
|
||||
if (wording) wording.textContent = ` ${copied}`;
|
||||
button.setAttribute('aria-label', copied);
|
||||
pulseSuccess(button);
|
||||
|
||||
setTimeout(() => {
|
||||
const tick = button.querySelector('.copy-tick');
|
||||
if (tick && iconWas) tick.outerHTML = iconWas;
|
||||
if (wording && wordingWas !== null) wording.textContent = wordingWas;
|
||||
if (label) button.setAttribute('aria-label', label);
|
||||
button.style.minWidth = '';
|
||||
delete button.dataset['copied'];
|
||||
}, COPIED_MS);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** How long a "Copied" confirmation stays up, wherever it is shown. */
|
||||
const COPIED_MS = 1200;
|
||||
|
||||
/* ---------- copyable identifiers ---------- */
|
||||
|
||||
/**
|
||||
* Every identifier on the site is the same control: a button showing a shortened
|
||||
* form, carrying the full value in `title` and copying it on click.
|
||||
*
|
||||
* The markup is generated in two places (this function for island rendered rows, and
|
||||
* `components/CopyableId.astro` for prerendered ones) and both produce exactly this
|
||||
* shape, so one delegated handler drives all of them.
|
||||
*/
|
||||
export function copyableIdHtml(options: {
|
||||
full: string;
|
||||
short: string;
|
||||
/**
|
||||
* The accessible label, already translated: "Copy full npub".
|
||||
*
|
||||
* Required rather than defaulted, and translated by the caller rather than here.
|
||||
* This function also runs at build time, where there is no page to read a locale
|
||||
* from, so a default would be an English string with no way to reach the catalogs.
|
||||
* One caller, one label, no fallback to forget about.
|
||||
*/
|
||||
label: string;
|
||||
className?: string;
|
||||
}): string {
|
||||
const { full, short, label, className } = options;
|
||||
return (
|
||||
`<button type="button" class="cid${className ? ` ${className}` : ''}"` +
|
||||
` data-copy-id="${escapeHtml(full)}" title="${escapeHtml(full)}"` +
|
||||
` aria-label="${escapeHtml(label)}">` +
|
||||
`<span class="cid-text">${escapeHtml(short)}</span></button>`
|
||||
);
|
||||
}
|
||||
|
||||
let copyableWired = false;
|
||||
|
||||
/**
|
||||
* Wire every copyable identifier on the page, now and in the future.
|
||||
*
|
||||
* Delegated from the document because review rows are re-rendered through innerHTML
|
||||
* whenever a filter or a page changes, and re-binding after each render is how
|
||||
* listeners get lost.
|
||||
*/
|
||||
export function wireCopyableIds(): void {
|
||||
if (copyableWired) return;
|
||||
copyableWired = true;
|
||||
|
||||
document.addEventListener('click', (event) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
const button = target?.closest<HTMLButtonElement>('[data-copy-id]');
|
||||
if (!button) return;
|
||||
|
||||
event.preventDefault();
|
||||
// Identifiers sit inside links on some cards; copying is not navigation.
|
||||
event.stopPropagation();
|
||||
|
||||
const value = button.dataset['copyId'];
|
||||
const label = button.querySelector<HTMLElement>('.cid-text');
|
||||
if (!value || !label || button.dataset['copied']) return;
|
||||
|
||||
void navigator.clipboard.writeText(value).then(
|
||||
() => {
|
||||
const original = label.textContent ?? '';
|
||||
// Hold the width so swapping in a shorter word does not shuffle the row.
|
||||
button.style.minWidth = `${button.offsetWidth}px`;
|
||||
button.dataset['copied'] = '1';
|
||||
button.classList.add('is-copied');
|
||||
// A tick scales in and the label cross-fades to the confirmation. Same swap
|
||||
// wherever an identifier is copied, because it is the same control.
|
||||
button.insertAdjacentHTML('afterbegin', `<span class="cid-tick">${TICK_SVG}</span>`);
|
||||
label.textContent = useI18n()('common.copied');
|
||||
|
||||
window.setTimeout(() => {
|
||||
button.querySelector('.cid-tick')?.remove();
|
||||
label.textContent = original;
|
||||
button.classList.remove('is-copied');
|
||||
button.style.minWidth = '';
|
||||
delete button.dataset['copied'];
|
||||
}, COPIED_MS);
|
||||
},
|
||||
() => {
|
||||
// Clipboard denied (insecure origin, or the user said no). Nothing to undo.
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Escape text destined for innerHTML. Review bodies are untrusted relay content. */
|
||||
export function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* The one shortened form for an npub: enough of the prefix to recognise, enough of
|
||||
* the tail to tell two apart. Full value lives in the copy button behind it.
|
||||
*/
|
||||
export function shortNpub(npub: string): string {
|
||||
return truncateMiddle(npub, 12, 6);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Browser-side global review feed: every mint's reviews in one list.
|
||||
*
|
||||
* The mint page asks the relays about one mint (`loadReviews` in reviews-client.ts).
|
||||
* This asks for kind 38000 with no mint filter at all and resolves each event back to
|
||||
* a mint the site knows about, which is the only way to build a feed: relays cannot
|
||||
* be asked for "reviews of any of these 58 URLs" without a filter per spelling per
|
||||
* mint, and that is hundreds of filters.
|
||||
*
|
||||
* Events that resolve to no known mint are dropped rather than shown. A review of a
|
||||
* mint this site has never indexed has nowhere to link to, and a card that says
|
||||
* nothing about which mint it is about is not worth a row.
|
||||
*/
|
||||
import { SimplePool } from 'nostr-tools/pool';
|
||||
import {
|
||||
KIND_REVIEW,
|
||||
cleanReviewContent,
|
||||
isCashuMintReview,
|
||||
mintUrlsFromEvent,
|
||||
normalizeMintUrl,
|
||||
parseRating,
|
||||
} from '@cashumints/shared';
|
||||
import { RELAYS, lastPublish, safeNpub, type LoadedReview } from './reviews-client';
|
||||
|
||||
/** The mint list the page was built with: enough to resolve and link a review. */
|
||||
export interface FeedMint {
|
||||
url: string;
|
||||
host: string;
|
||||
name: string;
|
||||
domain: string;
|
||||
}
|
||||
|
||||
export interface FeedReview extends LoadedReview {
|
||||
mint_host: string;
|
||||
mint_name: string;
|
||||
mint_domain: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* How many events to ask each relay for. Reviews are sparse (a few thousand exist in
|
||||
* total across the network), so this is a page or two of feed after the ones that
|
||||
* resolve to no known mint are dropped, not a truncated view of a firehose.
|
||||
*/
|
||||
const EVENT_LIMIT = 1000;
|
||||
|
||||
/** Relays get this long to answer before the caller shows its error state. */
|
||||
const RELAY_TIMEOUT_MS = 9000;
|
||||
|
||||
let pool: SimplePool | null = null;
|
||||
function getPool(): SimplePool {
|
||||
pool ??= new SimplePool();
|
||||
return pool;
|
||||
}
|
||||
|
||||
/**
|
||||
* The feed this tab has already read, good for `CACHE_TTL_MS`.
|
||||
*
|
||||
* Same reasoning as the mint page's cache in `reviews-client.ts`, and the same five
|
||||
* minutes: the island reloads on every arrival, the router counts going back to
|
||||
* /reviews as an arrival, and a thousand-event query bounded at nine seconds is not
|
||||
* something to repeat for a list the tab is still holding. One slot, because there is
|
||||
* one feed.
|
||||
*/
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
let cache: { at: number; feed: FeedReview[] } | null = null;
|
||||
|
||||
/** True if the cached feed is still worth using. */
|
||||
function usable(entry: { at: number; feed: FeedReview[] }): boolean {
|
||||
// A feed read before this tab published a review does not contain it.
|
||||
return Date.now() - entry.at < CACHE_TTL_MS && entry.at > lastPublish();
|
||||
}
|
||||
|
||||
/**
|
||||
* Newest review per author per mint, newest first.
|
||||
*
|
||||
* Ratings with no comment are kept: the page collapses them per day into one summary
|
||||
* line, the same rule the mint page applies per mint. Most review events on the
|
||||
* network are exactly that, so dropping them would understate every count.
|
||||
*/
|
||||
export async function loadFeed(mints: FeedMint[]): Promise<FeedReview[]> {
|
||||
if (cache && usable(cache)) return cache.feed;
|
||||
|
||||
const byUrl = new Map(mints.map((mint) => [mint.url, mint]));
|
||||
|
||||
const events = await getPool().querySync(
|
||||
RELAYS,
|
||||
{ kinds: [KIND_REVIEW], limit: EVENT_LIMIT },
|
||||
{ maxWait: RELAY_TIMEOUT_MS },
|
||||
);
|
||||
|
||||
// Kind 38000 is addressable: a later event from the same author about the same mint
|
||||
// replaces the earlier one. Keyed on author plus mint, so one person reviewing three
|
||||
// mints keeps three reviews.
|
||||
const newest = new Map<string, FeedReview>();
|
||||
|
||||
for (const event of events) {
|
||||
const content = cleanReviewContent(event.content);
|
||||
if (!isCashuMintReview(event) && mintUrlsFromEvent(event).length === 0) continue;
|
||||
|
||||
const raw = mintUrlsFromEvent(event)[0];
|
||||
if (!raw) continue;
|
||||
const normalized = normalizeMintUrl(raw);
|
||||
if (!normalized) continue;
|
||||
|
||||
const mint = byUrl.get(normalized.url);
|
||||
if (!mint) continue;
|
||||
|
||||
const key = `${event.pubkey}:${mint.host}`;
|
||||
const existing = newest.get(key);
|
||||
if (existing && existing.created_at >= event.created_at) continue;
|
||||
|
||||
newest.set(key, {
|
||||
id: event.id,
|
||||
pubkey: event.pubkey,
|
||||
npub: safeNpub(event.pubkey),
|
||||
created_at: event.created_at,
|
||||
rating: parseRating(event),
|
||||
content,
|
||||
mint_url: mint.url,
|
||||
mint_host: mint.host,
|
||||
mint_name: mint.name,
|
||||
mint_domain: mint.domain,
|
||||
profile: null,
|
||||
});
|
||||
}
|
||||
|
||||
const feed = [...newest.values()].sort((a, b) => b.created_at - a.created_at);
|
||||
|
||||
// An empty answer from a pool that swallows per relay failures is not proof there
|
||||
// are no reviews, and the page says as much. Only a real feed is worth keeping.
|
||||
if (feed.length > 0) cache = { at: Date.now(), feed };
|
||||
|
||||
return feed;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Formatting helpers with no language in them.
|
||||
*
|
||||
* Everything that reads differently in Spanish or Dutch (dates, relative times,
|
||||
* numbers, and every string that goes with one) moved to `src/i18n/format.ts`, which
|
||||
* takes a translator and is used identically by a prerendered page and by the island
|
||||
* that later updates it. What is left here is arithmetic and string surgery: a
|
||||
* gradient seeded from a domain, the initials on a fallback tile, a truncated npub.
|
||||
*
|
||||
* Copy rules from DESIGN.md: sentence case, no em dashes, specific over clever, in
|
||||
* every language (see src/i18n/GLOSSARY.md).
|
||||
*/
|
||||
|
||||
/** Deterministic warm gradient for a mint with no cached icon. */
|
||||
export function iconGradient(seed: string): string {
|
||||
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 `linear-gradient(135deg, hsl(${hue} 45% 55%), hsl(${hue2} 45% 45%))`;
|
||||
}
|
||||
|
||||
/** One or two characters for the fallback icon tile. */
|
||||
export function initials(name: string): string {
|
||||
const cleaned = name.replace(/^(https?:\/\/)?(mint\.|www\.)?/, '');
|
||||
const first = cleaned.trim()[0] ?? '?';
|
||||
if (/\d/.test(first)) return cleaned.slice(0, 2);
|
||||
return first.toUpperCase();
|
||||
}
|
||||
|
||||
/** Sentiment split for the mint card bar: 4 to 5 positive, 1 to 2 negative. */
|
||||
export function sentiment(dist: Record<string, number>): { pos: number; neg: number } {
|
||||
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 { pos: 0, neg: 0 };
|
||||
return {
|
||||
pos: Math.round((positive / total) * 100),
|
||||
neg: Math.round((negative / total) * 100),
|
||||
};
|
||||
}
|
||||
|
||||
/** Truncate a hex key for display, keeping both ends recognisable. */
|
||||
export function truncateMiddle(value: string, head = 10, tail = 8): string {
|
||||
if (value.length <= head + tail + 1) return value;
|
||||
return `${value.slice(0, head)}…${value.slice(-tail)}`;
|
||||
}
|
||||
|
||||
export function displayDomain(url: string): string {
|
||||
return url.replace(/^https?:\/\//, '').replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* The one shortened form for an npub: enough of the prefix to recognise, enough of
|
||||
* the tail to tell two apart. Full value lives in the copy button behind it.
|
||||
*/
|
||||
export function shortNpub(npub: string): string {
|
||||
return truncateMiddle(npub, 12, 6);
|
||||
}
|
||||
|
||||
/**
|
||||
* A view transition name for one mint, as a valid CSS identifier.
|
||||
*
|
||||
* The mint card and the mint page header both call this for the same host, and the
|
||||
* two elements morph into each other because the names match. Hosts contain dots,
|
||||
* which a CSS identifier cannot, so every non-alphanumeric character is encoded as
|
||||
* its character code: `mint.example.com` becomes `mint_2eexample_2ecom`.
|
||||
*
|
||||
* Applied as an inline `view-transition-name` on both sides rather than through
|
||||
* `transition:name`, so that exactly one encoding is in play: Astro applies its own
|
||||
* to whatever it is handed, and two encoders would have to agree forever.
|
||||
*/
|
||||
export function transitionName(kind: 'icon' | 'name', host: string): string {
|
||||
const ident = host.replace(/[^a-zA-Z0-9]/g, (c) => `_${c.charCodeAt(0).toString(16).padStart(2, '0')}`);
|
||||
return `mint-${kind}-${ident}`;
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* The three regions of a mint page that have to agree about whether a mint can take
|
||||
* deposits and pay them out: the warning banner, the verdict strip's limits cell, and
|
||||
* the NUT-04 / NUT-05 rows in the sidebar.
|
||||
*
|
||||
* They are built here as HTML strings rather than as markup in the page, because two
|
||||
* things render them: the page, from the cached probe at build time, and the
|
||||
* live-status island, from a fresh one. Two implementations would drift, and a page
|
||||
* whose banner says withdrawals are disabled while its sidebar shows a green check is
|
||||
* worse than no banner at all.
|
||||
*
|
||||
* One consequence worth knowing: their CSS lives in global.css, not in a scoped
|
||||
* <style> block. Astro scopes styles by stamping an attribute on the elements it
|
||||
* compiles, and HTML built here never passes through that step.
|
||||
*
|
||||
* All three take a `Formatters` for the same reason the review card does: the page
|
||||
* builds them at build time from a locale it is handed, and the island rebuilds them in
|
||||
* the browser from the locale the page is carrying. The banner's own sentences come
|
||||
* pre-translated on the `MintWarning`, because `getMintWarnings` in shared/ resolved
|
||||
* them through this locale's translator. Only the labels around them are looked up here.
|
||||
*/
|
||||
import {
|
||||
HIGHLIGHT_NUTS, nutName, parseLimits,
|
||||
type MintCapabilities, type MintLimits, type MintWarning,
|
||||
} from '@cashumints/shared';
|
||||
import type { Formatters } from '../i18n/format';
|
||||
|
||||
/** The same triangle the offline banner has always used. */
|
||||
const ALERT_ICON =
|
||||
'<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" ' +
|
||||
'stroke-width="2" aria-hidden="true">' +
|
||||
'<path d="m21.7 18-8-14a2 2 0 0 0-3.4 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.7-3Z"/>' +
|
||||
'<path d="M12 9v4M12 17h.01"/></svg>';
|
||||
|
||||
const CHECK_ICON =
|
||||
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" ' +
|
||||
'stroke-width="2.5" aria-hidden="true"><path d="m5 13 4 4L19 7"/></svg>';
|
||||
|
||||
const CROSS_ICON =
|
||||
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" ' +
|
||||
'stroke-width="2.5" aria-hidden="true"><path d="M6 6l12 12M18 6 6 18"/></svg>';
|
||||
|
||||
function escape(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* The one banner, or an empty string for a mint with nothing wrong with it.
|
||||
*
|
||||
* Prerendered banners announce as `status`: a page that opens by interrupting a screen
|
||||
* reader with an alert is not urgent, it is rude. `live` is set only when the island
|
||||
* has just contradicted the build, which is a state change worth interrupting for, and
|
||||
* only a critical one earns `alert`.
|
||||
*/
|
||||
export function bannerHtml(warning: MintWarning | null, options: { live?: boolean } = {}): string {
|
||||
if (!warning) return '';
|
||||
|
||||
const role = options.live && warning.severity === 'critical' ? 'alert' : 'status';
|
||||
|
||||
return (
|
||||
`<div class="mint-banner ${warning.severity}" role="${role}" data-mint-banner ` +
|
||||
`data-warning-kind="${warning.kind}">${ALERT_ICON}` +
|
||||
`<p><b>${escape(warning.lead)}</b> ${escape(warning.body)}</p></div>`
|
||||
);
|
||||
}
|
||||
|
||||
/** null when the mint published no limits at all, which is not a number to strike out. */
|
||||
function limitText(limits: MintLimits | null, f: Formatters): string | null {
|
||||
if (!limits || (limits.min === null && limits.max === null)) return null;
|
||||
// The unit comes from the mint's own /v1/info ("sat", "usd"), so it is data and is
|
||||
// dropped in untranslated.
|
||||
if (limits.min !== null && limits.max !== null) {
|
||||
return f.t('mint.limits.range', { min: limits.min, max: limits.max, unit: limits.unit });
|
||||
}
|
||||
if (limits.max !== null) return f.t('mint.limits.upTo', { max: limits.max, unit: limits.unit });
|
||||
return f.t('mint.limits.from', { min: limits.min!, unit: limits.unit });
|
||||
}
|
||||
|
||||
/**
|
||||
* The inside of the verdict strip's limits cell.
|
||||
*
|
||||
* A healthy mint keeps the plain min/max pair the strip has always shown. As soon as
|
||||
* one direction is switched off the cell splits in two, because a single unlabelled
|
||||
* pair of numbers next to a struck-out line cannot say which direction is shut: the
|
||||
* numbers are still published, they just do not apply any more.
|
||||
*/
|
||||
export function limitsHtml(nuts: unknown, caps: MintCapabilities, f: Formatters): string {
|
||||
const mint = parseLimits(nuts, 4);
|
||||
const melt = parseLimits(nuts, 5);
|
||||
|
||||
if (!caps.mintDisabled && !caps.meltDisabled) {
|
||||
if (!mint) {
|
||||
return `<div class="v-mono"><span>${escape(f.t('mint.limits.notPublished'))}</span></div>`;
|
||||
}
|
||||
const lines = [
|
||||
mint.min !== null
|
||||
? `<span>${escape(f.t('mint.limits.min'))}</span> <b>${f.number(mint.min)} ${escape(mint.unit)}</b>`
|
||||
: '',
|
||||
mint.max !== null
|
||||
? `<span>${escape(f.t('mint.limits.max'))}</span> <b>${f.number(mint.max)} ${escape(mint.unit)}</b>`
|
||||
: '',
|
||||
].filter(Boolean);
|
||||
return `<div class="v-mono">${lines.join('<br />')}</div>`;
|
||||
}
|
||||
|
||||
return (
|
||||
'<div class="v-mono lim-split">' +
|
||||
limitRow(f.t('mint.limits.mintRow'), mint, caps.mintDisabled, f) +
|
||||
limitRow(f.t('mint.limits.meltRow'), melt, caps.meltDisabled, f) +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
function limitRow(label: string, limits: MintLimits | null, off: boolean, f: Formatters): string {
|
||||
// The value and its label share a wrapping column, so in a narrow cell the label
|
||||
// drops under the numbers it belongs to rather than under the next direction.
|
||||
const text = limitText(limits, f);
|
||||
|
||||
return (
|
||||
`<div class="lim-row${off ? ' off' : ''}">` +
|
||||
`<span class="lim-k">${escape(label)}</span>` +
|
||||
'<span class="lim-body">' +
|
||||
(text === null
|
||||
? `<span class="lim-v none">${escape(f.t('mint.limits.notPublished'))}</span>`
|
||||
: `<b class="lim-v">${escape(text)}</b>`) +
|
||||
(off ? `<span class="lim-off">${escape(f.t('mint.limits.disabled'))}</span>` : '') +
|
||||
'</span></div>'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The five named NUT rows in the sidebar.
|
||||
*
|
||||
* Three states, not two. A NUT the mint never published is "not supported" and stays
|
||||
* grey; NUT-04 or NUT-05 published with the switch off is "disabled" and turns red,
|
||||
* because that is a decision the operator made and the banner above is already
|
||||
* shouting about it.
|
||||
*/
|
||||
export function nutRowsHtml(supported: string[], caps: MintCapabilities, f: Formatters): string {
|
||||
const has = new Set(supported);
|
||||
const switchedOff: Record<string, boolean> = { '4': caps.mintDisabled, '5': caps.meltDisabled };
|
||||
|
||||
return HIGHLIGHT_NUTS.map((n) => {
|
||||
const off = switchedOff[n] === true;
|
||||
const ok = has.has(n);
|
||||
|
||||
const state = off
|
||||
? `<span class="nut-state disabled">${CROSS_ICON}${escape(f.t('mint.nutRow.disabled'))}</span>`
|
||||
: ok
|
||||
? `<span class="nut-state ok">${CHECK_ICON}</span>`
|
||||
: `<span class="nut-state none">${escape(f.t('mint.nutRow.notSupported'))}</span>`;
|
||||
|
||||
const classes = off ? 'nut-row disabled' : ok ? 'nut-row' : 'nut-row off';
|
||||
|
||||
return (
|
||||
`<div class="${classes}" data-nut-row="${n}">` +
|
||||
// "NUT-04" is the spec's name and is the same in every language. The plain
|
||||
// language label beside it is not, and comes from the catalog.
|
||||
`<span class="nut-num">NUT-${n.padStart(2, '0')}</span>` +
|
||||
`<span class="nut-name">${escape(localNutName(n, f))}</span>` +
|
||||
`${state}</div>`
|
||||
);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* A NUT's plain-language name in this locale.
|
||||
*
|
||||
* `shared`'s NUT_NAMES stays the English source of truth (the API and its tests read
|
||||
* it), and the catalogs carry a translation per number. A number neither knows renders
|
||||
* as the bare "NUT-19", which is still a true label.
|
||||
*/
|
||||
export function localNutName(n: string, f: Formatters): string {
|
||||
const key = `nut.${n}`;
|
||||
if (f.t.has(key)) return f.t(key);
|
||||
return nutName(n) ?? `NUT-${n}`;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* One modal behaviour for every dialog on the site.
|
||||
*
|
||||
* `<dialog>` with `showModal()` already gives the two things that are easy to get
|
||||
* wrong by hand: the focus trap and Escape. What it does not give is a locked page
|
||||
* behind it, and Safari and Firefox will happily scroll the body under an open modal.
|
||||
* That is what this file adds, plus the backdrop click every dialog here supports.
|
||||
*
|
||||
* The lock is counted rather than toggled, because the review dialog can open the
|
||||
* login popup over itself and the first one to close must not unlock the page.
|
||||
*/
|
||||
|
||||
let depth = 0;
|
||||
/** The gap the scrollbar leaves behind when it goes, held open so nothing shifts. */
|
||||
let padded = false;
|
||||
|
||||
function lock(): void {
|
||||
if (depth++ > 0) return;
|
||||
|
||||
const gap = window.innerWidth - document.documentElement.clientWidth;
|
||||
if (gap > 0) {
|
||||
document.body.style.paddingRight = `${gap}px`;
|
||||
padded = true;
|
||||
}
|
||||
document.documentElement.classList.add('modal-open');
|
||||
}
|
||||
|
||||
function unlock(): void {
|
||||
if (depth === 0) return;
|
||||
if (--depth > 0) return;
|
||||
|
||||
document.documentElement.classList.remove('modal-open');
|
||||
if (padded) {
|
||||
document.body.style.paddingRight = '';
|
||||
padded = false;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ModalOptions {
|
||||
/**
|
||||
* Asked before a backdrop click closes the dialog. Returning false keeps it open,
|
||||
* which is how the review dialog protects a half-written review.
|
||||
*/
|
||||
onBackdrop?: () => boolean;
|
||||
/** Runs after the dialog has closed, however it closed. */
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
const wired = new WeakSet<HTMLDialogElement>();
|
||||
|
||||
/**
|
||||
* Give a dialog the site's modal behaviour. Safe to call more than once per element:
|
||||
* the router re-runs island setup on every page it swaps in.
|
||||
*/
|
||||
export function wireModal(dialog: HTMLDialogElement, options: ModalOptions = {}): void {
|
||||
if (wired.has(dialog)) return;
|
||||
wired.add(dialog);
|
||||
|
||||
// A click on the backdrop is a click on the dialog element itself: everything
|
||||
// visible is inside a child that fills it.
|
||||
dialog.addEventListener('click', (event) => {
|
||||
if (event.target !== dialog) return;
|
||||
if (options.onBackdrop && !options.onBackdrop()) return;
|
||||
dialog.close();
|
||||
});
|
||||
|
||||
// Escape is left to the browser on purpose. It closes, always: a draft is kept
|
||||
// whichever way the dialog goes away, so there is nothing for a confirmation to
|
||||
// protect and a modal that argues with Escape is a modal people fight.
|
||||
dialog.addEventListener('close', () => {
|
||||
unlock();
|
||||
options.onClose?.();
|
||||
});
|
||||
}
|
||||
|
||||
/** Open a wired dialog, locking the page behind it. */
|
||||
export function openModal(dialog: HTMLDialogElement): void {
|
||||
if (dialog.open) return;
|
||||
lock();
|
||||
dialog.showModal();
|
||||
}
|
||||
|
||||
/** Close a dialog. The `close` event does the unlocking, whoever triggered it. */
|
||||
export function closeModal(dialog: HTMLDialogElement): void {
|
||||
if (dialog.open) dialog.close();
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
/**
|
||||
* Build-time Nostr reads. Runs in Node during `astro build`, never in the browser.
|
||||
*
|
||||
* Two consumers: the home page "latest reviews" strip (three cards, junk filtered,
|
||||
* one per author) and the /reviews feed (the newest written reviews across every
|
||||
* mint, prerendered so the page has real content for a crawler before the island
|
||||
* takes over with live relay data).
|
||||
*/
|
||||
import { SimplePool } from 'nostr-tools/pool';
|
||||
import type { Event as NostrEvent } from 'nostr-tools';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import {
|
||||
DEFAULT_RELAYS,
|
||||
KIND_PROFILE,
|
||||
KIND_REVIEW,
|
||||
PROFILE_RELAYS,
|
||||
cleanReviewContent,
|
||||
isCashuMintReview,
|
||||
normalizeMintUrl,
|
||||
mintUrlsFromEvent,
|
||||
parseProfileContent,
|
||||
parseRating,
|
||||
WRITTEN_MIN_CHARS,
|
||||
type Profile,
|
||||
} from '@cashumints/shared';
|
||||
|
||||
const list = (value: string | undefined): string[] | null => {
|
||||
const parsed = value?.split(',').map((relay) => relay.trim()).filter(Boolean);
|
||||
return parsed && parsed.length > 0 ? parsed : null;
|
||||
};
|
||||
|
||||
const RELAYS = list(process.env['RELAYS']) ?? [...DEFAULT_RELAYS];
|
||||
|
||||
/**
|
||||
* Profiles come from a wider pool than reviews do. The review relays hold few kind 0
|
||||
* events, which is why this strip used to render npubs where it had names available
|
||||
* one relay over.
|
||||
*/
|
||||
const PROFILE_POOL = list(process.env['PROFILE_RELAYS']) ?? [...PROFILE_RELAYS];
|
||||
|
||||
export interface BuildReview {
|
||||
id: string;
|
||||
pubkey: string;
|
||||
npub: string;
|
||||
created_at: number;
|
||||
rating: number | null;
|
||||
content: string;
|
||||
mint_url: string;
|
||||
mint_host: string;
|
||||
profile: Profile | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Junk filter for the home page strip only, per FRONTEND.md: reviews shorter than 8
|
||||
* characters or matching a common test string do not qualify. They still appear in
|
||||
* full on the mint page, nothing is hidden, this strip is just a shop window.
|
||||
*/
|
||||
/** How many candidates to gather per card shown, so the ordering has a real choice. */
|
||||
const POOL_FACTOR = 12;
|
||||
|
||||
/**
|
||||
* How recent a review must still be to be promoted for having a named author.
|
||||
*
|
||||
* The ordering below prefers reviews whose author has a kind 0, and this is the leash
|
||||
* on that preference. Without it the strip would headline a named review from two
|
||||
* years ago under a heading that says "Latest reviews"; with it, a named review from
|
||||
* within the season can lead and anything older cannot.
|
||||
*/
|
||||
const PROMOTE_MAX_AGE_S = 90 * 24 * 60 * 60;
|
||||
|
||||
const TEST_STRINGS = new Set([
|
||||
'test', 'testing', 'test test', 'hello', 'hi', 'ok', 'okay', 'good', 'nice',
|
||||
'great', 'cool', 'gm', 'a', '.', '..', '...', 'asdf', 'qwerty', '123',
|
||||
]);
|
||||
|
||||
function qualifies(content: string): boolean {
|
||||
const text = content.trim();
|
||||
if (text.length < 8) return false;
|
||||
if (TEST_STRINGS.has(text.toLowerCase().replace(/[.!?]+$/, ''))) return false;
|
||||
// A body that is only punctuation or emoji says nothing about the mint.
|
||||
if (!/[a-z]{3}/i.test(text)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ---------- the shared pool ---------- */
|
||||
|
||||
/**
|
||||
* How long the pool waits with nothing to do before closing its sockets.
|
||||
*
|
||||
* One pool spans every read here rather than one per call: the review query and the
|
||||
* profile query that follows it used to re-handshake five relays between them, and a
|
||||
* dev server rendering three locales did it again for each. Long enough to cover that
|
||||
* run of work, short enough that it is gone by the time anything looks.
|
||||
*
|
||||
* It cannot simply stay open forever. An open websocket keeps Node alive, so `astro
|
||||
* build` would finish the site and then sit there with nothing to do.
|
||||
*/
|
||||
const IDLE_CLOSE_MS = 2000;
|
||||
|
||||
let pool: SimplePool | null = null;
|
||||
let idle: ReturnType<typeof setTimeout> | null = null;
|
||||
let inFlight = 0;
|
||||
|
||||
function closePool(): void {
|
||||
const open = pool;
|
||||
pool = null;
|
||||
idle = null;
|
||||
if (!open) return;
|
||||
try {
|
||||
open.close([...new Set([...RELAYS, ...PROFILE_POOL])]);
|
||||
} catch {
|
||||
// A relay that already dropped throws on close. Harmless at build time.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `fn` against the shared pool, and close the pool once nothing else is using it.
|
||||
*
|
||||
* The close is armed on the way out rather than on the way in, so a query that takes
|
||||
* its full ten seconds is never closed out from under itself, and two reads running
|
||||
* together both finish before either arms it.
|
||||
*/
|
||||
async function withPool<T>(fn: (pool: SimplePool) => Promise<T>): Promise<T> {
|
||||
pool ??= new SimplePool();
|
||||
if (idle) {
|
||||
clearTimeout(idle);
|
||||
idle = null;
|
||||
}
|
||||
inFlight += 1;
|
||||
try {
|
||||
return await fn(pool);
|
||||
} finally {
|
||||
inFlight -= 1;
|
||||
if (inFlight === 0) {
|
||||
idle = setTimeout(closePool, IDLE_CLOSE_MS);
|
||||
// The timer must not be the thing holding the process open either.
|
||||
idle.unref?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- the shared read ---------- */
|
||||
|
||||
/**
|
||||
* How many events to ask the relays for.
|
||||
*
|
||||
* Both readers below want the same thing: recent kind 38000, unfiltered. They differ
|
||||
* only in how they narrow it afterwards, so they share one query at the larger of the
|
||||
* two limits they used to ask for separately.
|
||||
*/
|
||||
const EVENT_LIMIT = 500;
|
||||
|
||||
/**
|
||||
* How long that one read stays good.
|
||||
*
|
||||
* `astro build` calls both readers once per locale — six identical ten-second-bounded
|
||||
* queries for one answer. A build never runs long enough for this to expire, so it
|
||||
* works out to exactly one relay read for the whole build.
|
||||
*
|
||||
* The TTL is there for `astro dev`, which renders a page per request: without it the
|
||||
* full relay round trip sits in front of every single click on `/` and `/reviews`,
|
||||
* which is what made those two pages take a second and a half while every other page
|
||||
* on the site answered instantly. Five minutes is short enough that a review
|
||||
* published while the dev server is up still turns up without restarting it.
|
||||
*/
|
||||
const READ_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
let cached: { at: number; events: Promise<NostrEvent[]> } | null = null;
|
||||
|
||||
/** Recent review events, from the relays at most once every `READ_TTL_MS`. */
|
||||
function readReviewEvents(): Promise<NostrEvent[]> {
|
||||
const at = Date.now();
|
||||
if (cached && at - cached.at < READ_TTL_MS) return cached.events;
|
||||
|
||||
const events = withPool((p) =>
|
||||
p.querySync(RELAYS, { kinds: [KIND_REVIEW], limit: EVENT_LIMIT }, { maxWait: 10_000 }),
|
||||
).catch((error: unknown) => {
|
||||
// A failed read must not sit in the cache: one bad moment would otherwise leave
|
||||
// every page built in the next five minutes with no reviews on it.
|
||||
if (cached?.at === at) cached = null;
|
||||
throw error;
|
||||
});
|
||||
|
||||
cached = { at, events };
|
||||
return events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Newest review events that are worth showing, resolved against the mints the API
|
||||
* knows about. `hostByUrl` maps normalized mint URL to routing slug.
|
||||
*/
|
||||
export async function fetchLatestReviews(
|
||||
hostByUrl: Map<string, string>,
|
||||
limit: number,
|
||||
): Promise<BuildReview[]> {
|
||||
try {
|
||||
const events = await readReviewEvents();
|
||||
|
||||
const candidates: BuildReview[] = [];
|
||||
const seenAuthors = new Set<string>();
|
||||
|
||||
for (const event of [...events].sort((a, b) => b.created_at - a.created_at)) {
|
||||
const content = cleanReviewContent(event.content);
|
||||
if (!qualifies(content)) continue;
|
||||
if (!isCashuMintReview(event) && mintUrlsFromEvent(event).length === 0) continue;
|
||||
|
||||
const raw = mintUrlsFromEvent(event)[0];
|
||||
if (!raw) continue;
|
||||
const normalized = normalizeMintUrl(raw);
|
||||
if (!normalized) continue;
|
||||
|
||||
const host = hostByUrl.get(normalized.url);
|
||||
if (!host) continue;
|
||||
|
||||
// One card per author, so the strip is not three posts from the same npub.
|
||||
if (seenAuthors.has(event.pubkey)) continue;
|
||||
seenAuthors.add(event.pubkey);
|
||||
|
||||
candidates.push({
|
||||
id: event.id,
|
||||
pubkey: event.pubkey,
|
||||
npub: safeNpub(event.pubkey),
|
||||
created_at: event.created_at,
|
||||
rating: parseRating(event),
|
||||
content,
|
||||
mint_url: normalized.url,
|
||||
mint_host: host,
|
||||
profile: null,
|
||||
});
|
||||
|
||||
if (candidates.length >= limit * POOL_FACTOR) break;
|
||||
}
|
||||
|
||||
const profiles = await fetchProfiles(candidates.map((c) => c.pubkey));
|
||||
for (const review of candidates) review.profile = profiles.get(review.pubkey) ?? null;
|
||||
|
||||
/*
|
||||
* Recent reviews with a named author come first; everything else stays newest
|
||||
* first behind them.
|
||||
*
|
||||
* Most review keys have published exactly one event in their life: the review
|
||||
* itself. Nothing can be fetched for them, so a strict recency order fills this
|
||||
* strip with anonymous npubs while a named review sits a few rows below the
|
||||
* cut. This is the home page shop window, which already drops junk bodies, so
|
||||
* it prefers a review a reader can attach a person to. Nothing is hidden:
|
||||
* every review is on its mint page, and PROMOTE_MAX_AGE_S keeps "latest"
|
||||
* meaning latest.
|
||||
*/
|
||||
const cutoff = Math.floor(Date.now() / 1000) - PROMOTE_MAX_AGE_S;
|
||||
const promoted = (review: BuildReview): number =>
|
||||
review.profile?.found && review.created_at >= cutoff ? 1 : 0;
|
||||
|
||||
candidates.sort((a, b) => promoted(b) - promoted(a) || b.created_at - a.created_at);
|
||||
|
||||
return candidates.slice(0, limit);
|
||||
} catch {
|
||||
// Relays down at build time must not fail the build: the section renders its
|
||||
// empty state and the site still ships.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The global feed: newest written reviews across every mint.
|
||||
*
|
||||
* Same source and same parsing as the home page strip, three differences:
|
||||
* every author may appear more than once (once per mint they reviewed), the junk
|
||||
* filter is not applied (a short body is still a review, and this page is the record
|
||||
* rather than a shop window), and only reviews with a body are returned because the
|
||||
* feed renders cards. Ratings without a comment are a per mint thing and stay on the
|
||||
* mint page.
|
||||
*
|
||||
* Whatever this returns is a snapshot at build time. The island re-queries the relays
|
||||
* on load and replaces it, so a review published a minute ago is not missing for a
|
||||
* visitor, only for a crawler.
|
||||
*/
|
||||
export async function fetchReviewFeed(
|
||||
hostByUrl: Map<string, string>,
|
||||
limit: number,
|
||||
): Promise<BuildReview[]> {
|
||||
try {
|
||||
const events = await readReviewEvents();
|
||||
|
||||
/*
|
||||
* Kind 38000 is addressable: a later event from the same author about the same
|
||||
* mint replaces the earlier one, exactly as the mint page treats it. Keyed on
|
||||
* author plus mint, so one person reviewing three mints keeps three reviews.
|
||||
*/
|
||||
const newest = new Map<string, BuildReview>();
|
||||
|
||||
for (const event of events) {
|
||||
const content = cleanReviewContent(event.content);
|
||||
if (content.length < WRITTEN_MIN_CHARS) continue;
|
||||
if (!isCashuMintReview(event) && mintUrlsFromEvent(event).length === 0) continue;
|
||||
|
||||
const raw = mintUrlsFromEvent(event)[0];
|
||||
if (!raw) continue;
|
||||
const normalized = normalizeMintUrl(raw);
|
||||
if (!normalized) continue;
|
||||
|
||||
const host = hostByUrl.get(normalized.url);
|
||||
if (!host) continue;
|
||||
|
||||
const key = `${event.pubkey}:${host}`;
|
||||
const existing = newest.get(key);
|
||||
if (existing && existing.created_at >= event.created_at) continue;
|
||||
|
||||
newest.set(key, {
|
||||
id: event.id,
|
||||
pubkey: event.pubkey,
|
||||
npub: safeNpub(event.pubkey),
|
||||
created_at: event.created_at,
|
||||
rating: parseRating(event),
|
||||
content,
|
||||
mint_url: normalized.url,
|
||||
mint_host: host,
|
||||
profile: null,
|
||||
});
|
||||
}
|
||||
|
||||
const feed = [...newest.values()]
|
||||
.sort((a, b) => b.created_at - a.created_at)
|
||||
.slice(0, limit);
|
||||
|
||||
const profiles = await fetchProfiles(feed.map((review) => review.pubkey));
|
||||
for (const review of feed) review.profile = profiles.get(review.pubkey) ?? null;
|
||||
|
||||
return feed;
|
||||
} catch {
|
||||
// Relays down at build time must not fail the build: the page ships with its
|
||||
// loading state and the island fills it in.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- profiles ---------- */
|
||||
|
||||
/** Names resolved so far, and every key already asked about, hit or miss. */
|
||||
const resolved = new Map<string, Profile>();
|
||||
const asked = new Set<string>();
|
||||
|
||||
/**
|
||||
* Names and avatars for `pubkeys`, asking the relays only about keys not seen before.
|
||||
*
|
||||
* The two readers above draw from the same events, so the feed's authors are largely
|
||||
* the home strip's authors, and all of it repeats per locale. Without this the build
|
||||
* would put the same hundred keys to eight relays six times over. In practice only
|
||||
* the first call asks anything.
|
||||
*
|
||||
* A key is marked asked only when a query actually came back, so relays being down
|
||||
* once does not cache a permanent miss for the rest of the build.
|
||||
*/
|
||||
async function fetchProfiles(pubkeys: string[]): Promise<Map<string, Profile>> {
|
||||
const wanted = [...new Set(pubkeys)];
|
||||
const missing = wanted.filter((pubkey) => !asked.has(pubkey));
|
||||
|
||||
if (missing.length > 0) {
|
||||
try {
|
||||
const events = await withPool((p) =>
|
||||
p.querySync(PROFILE_POOL, { kinds: [KIND_PROFILE], authors: missing }, { maxWait: 6000 }),
|
||||
);
|
||||
|
||||
// Keep only the newest kind-0 per author.
|
||||
const newest = new Map<string, NostrEvent>();
|
||||
for (const event of events) {
|
||||
const existing = newest.get(event.pubkey);
|
||||
if (!existing || event.created_at > existing.created_at) newest.set(event.pubkey, event);
|
||||
}
|
||||
|
||||
// Same parse and sanitize rules the browser island uses, so a name renders the
|
||||
// same way whether it was resolved at build time or on the page.
|
||||
for (const [pubkey, event] of newest) {
|
||||
const profile = parseProfileContent(pubkey, event.content);
|
||||
if (profile.found) resolved.set(pubkey, profile);
|
||||
}
|
||||
|
||||
for (const pubkey of missing) asked.add(pubkey);
|
||||
} catch {
|
||||
// No profiles is fine: cards fall back to a short npub.
|
||||
}
|
||||
}
|
||||
|
||||
const out = new Map<string, Profile>();
|
||||
for (const pubkey of wanted) {
|
||||
const profile = resolved.get(pubkey);
|
||||
if (profile) out.set(pubkey, profile);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function safeNpub(pubkey: string): string {
|
||||
try {
|
||||
return nip19.npubEncode(pubkey);
|
||||
} catch {
|
||||
return pubkey;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,545 @@
|
||||
/**
|
||||
* Who signs, and how the site remembers them.
|
||||
*
|
||||
* One interface, three ways to satisfy it:
|
||||
*
|
||||
* - `extension`: a NIP-07 browser extension (Alby, nos2x). Nothing to store.
|
||||
* - `remote`: a NIP-46 remote signer, reached over relays.
|
||||
* - `primal`: the same NIP-46 session, started from a Primal-branded shortcut.
|
||||
*
|
||||
* The site never sees a secret key. There is no code path here that reads, parses,
|
||||
* accepts or stores an nsec, and there is deliberately no "paste your key" option to
|
||||
* add one to. Signing happens in the extension or in the remote signer, or it does
|
||||
* not happen.
|
||||
*
|
||||
* The one key this module does generate is the NIP-46 *client* key: an ephemeral
|
||||
* keypair this browser uses to talk to a remote signer, defined by NIP-46 itself. It
|
||||
* is created here, never entered by anyone, never leaves this origin's localStorage,
|
||||
* and it signs nothing except the encrypted RPC envelopes addressed to the signer.
|
||||
* The reader's identity key stays in the signer. Storing it is what makes a session
|
||||
* survive a reload, which the spec for NIP-46 clients requires.
|
||||
*/
|
||||
import { useI18n } from '../i18n/client';
|
||||
import type { Translator } from '../i18n/translate';
|
||||
import type { BunkerPointer, BunkerSigner } from 'nostr-tools/nip46';
|
||||
|
||||
/* ---------- types ---------- */
|
||||
|
||||
export type SignerMethod = 'extension' | 'remote' | 'primal';
|
||||
|
||||
export interface EventTemplate {
|
||||
kind: number;
|
||||
created_at: number;
|
||||
tags: string[][];
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface SignedEvent extends EventTemplate {
|
||||
id: string;
|
||||
pubkey: string;
|
||||
sig: string;
|
||||
}
|
||||
|
||||
export interface NostrSigner {
|
||||
method: SignerMethod;
|
||||
getPublicKey(): Promise<string>;
|
||||
signEvent(event: EventTemplate): Promise<SignedEvent>;
|
||||
}
|
||||
|
||||
/** What a NIP-46 session needs to come back to life after a reload. */
|
||||
export interface ConnectInfo {
|
||||
/** The remote signer's own pubkey: the other end of the encrypted channel. */
|
||||
signer_pubkey: string;
|
||||
/** Relays the signer listens on. */
|
||||
relays: string[];
|
||||
/** The connect secret, when the pairing carried one. */
|
||||
secret: string | null;
|
||||
/**
|
||||
* This browser's NIP-46 client key, hex.
|
||||
*
|
||||
* Not the reader's key and never confusable with one: it is generated in
|
||||
* `startNostrConnect`/`connectBunker` below, it identifies this browser to the
|
||||
* signer, and the signer would refuse a request from any other key. Losing it means
|
||||
* pairing again, which is exactly what "log out" does.
|
||||
*/
|
||||
client_key: string;
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
method: SignerMethod;
|
||||
/** The reader's own pubkey, hex. */
|
||||
pubkey: string;
|
||||
/** Absent for `extension`. */
|
||||
connect?: ConnectInfo;
|
||||
}
|
||||
|
||||
/* ---------- config ---------- */
|
||||
|
||||
/**
|
||||
* Relays used to reach a remote signer. Overridable at build time with
|
||||
* PUBLIC_CONNECT_RELAYS (comma separated).
|
||||
*
|
||||
* These are not the review relays: a signer relay carries small encrypted RPC and
|
||||
* has to be one both ends can reach, so the default is the pair that signer apps
|
||||
* themselves default to.
|
||||
*/
|
||||
export const CONNECT_RELAYS: string[] = (() => {
|
||||
const configured = import.meta.env['PUBLIC_CONNECT_RELAYS'] as string | undefined;
|
||||
const list = configured
|
||||
?.split(',')
|
||||
.map((relay) => relay.trim())
|
||||
.filter(Boolean);
|
||||
return list && list.length > 0 ? list : ['wss://relay.nsec.app', 'wss://relay.primal.net'];
|
||||
})();
|
||||
|
||||
/** How long a signature request waits before the reader gets an error instead. */
|
||||
export const SIGN_TIMEOUT_MS = 30_000;
|
||||
/** How long a pairing waits before the popup admits it is still waiting. */
|
||||
export const CONNECT_PATIENCE_MS = 30_000;
|
||||
|
||||
/** Shown to the signer app so the reader knows what they are approving. */
|
||||
const CLIENT_METADATA = {
|
||||
name: 'Cashumints.space',
|
||||
url: 'https://cashumints.space',
|
||||
};
|
||||
|
||||
/**
|
||||
* A status line, in the language of the page the reader is looking at.
|
||||
*
|
||||
* Looked up at the moment the status fires rather than held in a table: a signature
|
||||
* can take half a minute, and the reader may have changed language in another tab or
|
||||
* navigated within this one before it lands.
|
||||
*/
|
||||
const signerText = (key: string): string => useI18n()(key);
|
||||
|
||||
/** Only what this site actually asks for: sign a review, read who you are. */
|
||||
const PERMS = ['get_public_key', 'sign_event:38000'];
|
||||
|
||||
const SESSION_KEY = 'cashumints:session:v1';
|
||||
|
||||
/* ---------- session storage ---------- */
|
||||
|
||||
function isSession(value: unknown): value is Session {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
const session = value as Partial<Session>;
|
||||
if (session.method !== 'extension' && session.method !== 'remote' && session.method !== 'primal') {
|
||||
return false;
|
||||
}
|
||||
if (typeof session.pubkey !== 'string' || !/^[0-9a-f]{64}$/i.test(session.pubkey)) return false;
|
||||
if (session.method === 'extension') return true;
|
||||
const connect = session.connect;
|
||||
return (
|
||||
!!connect &&
|
||||
typeof connect.signer_pubkey === 'string' &&
|
||||
Array.isArray(connect.relays) &&
|
||||
connect.relays.length > 0 &&
|
||||
typeof connect.client_key === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
export function readSession(): Session | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(SESSION_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
return isSession(parsed) ? parsed : null;
|
||||
} catch {
|
||||
// Storage blocked or corrupt. The reader is simply logged out, which is a state
|
||||
// the whole site already works in.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeSession(session: Session): void {
|
||||
try {
|
||||
localStorage.setItem(SESSION_KEY, JSON.stringify(session));
|
||||
} catch {
|
||||
// Private mode, or storage full. The session lives for this page view only.
|
||||
}
|
||||
emitSession(session);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log out.
|
||||
*
|
||||
* Drops the session and the live NIP-46 connection with it. The profile cache is
|
||||
* deliberately left alone: it holds public kind 0 metadata for everyone whose review
|
||||
* is on screen, and clearing it would make the next page load re-query relays for
|
||||
* names that have not changed.
|
||||
*/
|
||||
export function clearSession(): void {
|
||||
const bunker = liveBunker;
|
||||
liveBunker = null;
|
||||
liveBunkerKey = '';
|
||||
void bunker?.close().catch(() => {
|
||||
// Already gone. Nothing to close.
|
||||
});
|
||||
|
||||
try {
|
||||
localStorage.removeItem(SESSION_KEY);
|
||||
} catch {
|
||||
// Nothing stored to begin with.
|
||||
}
|
||||
emitSession(null);
|
||||
}
|
||||
|
||||
/* ---------- session change events ---------- */
|
||||
|
||||
export const SESSION_EVENT = 'cashumints:session';
|
||||
|
||||
function emitSession(session: Session | null): void {
|
||||
window.dispatchEvent(new CustomEvent<Session | null>(SESSION_EVENT, { detail: session }));
|
||||
}
|
||||
|
||||
/** Called whenever the reader logs in, logs out, or a session is restored. */
|
||||
export function onSessionChange(handler: (session: Session | null) => void): () => void {
|
||||
const listener = (event: Event): void => handler((event as CustomEvent<Session | null>).detail);
|
||||
window.addEventListener(SESSION_EVENT, listener);
|
||||
return () => window.removeEventListener(SESSION_EVENT, listener);
|
||||
}
|
||||
|
||||
/* ---------- signer status ---------- */
|
||||
|
||||
export type SignerState = 'connecting' | 'reconnecting' | 'waiting' | 'ready' | 'error';
|
||||
|
||||
export interface SignerStatus {
|
||||
state: SignerState;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const statusHandlers = new Set<(status: SignerStatus) => void>();
|
||||
|
||||
/**
|
||||
* Watch what the signer is doing.
|
||||
*
|
||||
* The `NostrSigner` interface is three methods and stays that way, so anything the
|
||||
* reader needs to be told while a request is in flight (a relay dropped, a session is
|
||||
* being re-established, the signer has not answered yet) is announced here instead of
|
||||
* threaded through every call site.
|
||||
*/
|
||||
export function onSignerStatus(handler: (status: SignerStatus) => void): () => void {
|
||||
statusHandlers.add(handler);
|
||||
return () => statusHandlers.delete(handler);
|
||||
}
|
||||
|
||||
function status(state: SignerState, message: string): void {
|
||||
for (const handler of statusHandlers) handler({ state, message });
|
||||
}
|
||||
|
||||
/* ---------- extension (NIP-07) ---------- */
|
||||
|
||||
interface Nip07 {
|
||||
getPublicKey(): Promise<string>;
|
||||
signEvent(event: EventTemplate): Promise<SignedEvent>;
|
||||
}
|
||||
|
||||
export function getExtension(): Nip07 | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return (window as unknown as { nostr?: Nip07 }).nostr ?? null;
|
||||
}
|
||||
|
||||
export function hasExtension(): boolean {
|
||||
return getExtension() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The NIP-07 signer.
|
||||
*
|
||||
* Used two ways: as a logged-in session's signer, and by the quick-sign path for a
|
||||
* reader who has an extension and never opened the login popup. Both go through
|
||||
* exactly this object, so there is one signing code path, not two.
|
||||
*/
|
||||
export function extensionSigner(): NostrSigner | null {
|
||||
const extension = getExtension();
|
||||
if (!extension) return null;
|
||||
|
||||
return {
|
||||
method: 'extension',
|
||||
getPublicKey: () => extension.getPublicKey(),
|
||||
async signEvent(event) {
|
||||
status('waiting', signerText('signer.status.waitingExtension'));
|
||||
const signed = await extension.signEvent(event);
|
||||
status('ready', signerText('signer.status.signed'));
|
||||
return signed;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------- remote (NIP-46) ---------- */
|
||||
|
||||
/** The connected bunker for this page view, and the session it belongs to. */
|
||||
let liveBunker: BunkerSigner | null = null;
|
||||
let liveBunkerKey = '';
|
||||
|
||||
function bunkerKey(connect: ConnectInfo): string {
|
||||
return `${connect.signer_pubkey}:${connect.client_key}`;
|
||||
}
|
||||
|
||||
function timeout<T>(work: Promise<T>, ms: number, message: string): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = window.setTimeout(() => reject(new Error(message)), ms);
|
||||
work.then(
|
||||
(value) => {
|
||||
window.clearTimeout(timer);
|
||||
resolve(value);
|
||||
},
|
||||
(error: unknown) => {
|
||||
window.clearTimeout(timer);
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reach the remote signer, connecting first if this page view has not yet.
|
||||
*
|
||||
* Nothing connects on page load: a reader who opens the site and reads reviews never
|
||||
* opens a websocket to their signer. The connection is built the first time a
|
||||
* signature is actually wanted, which is the only moment it is worth anything.
|
||||
*/
|
||||
async function reachBunker(connect: ConnectInfo): Promise<BunkerSigner> {
|
||||
if (liveBunker && liveBunkerKey === bunkerKey(connect)) return liveBunker;
|
||||
|
||||
status('reconnecting', signerText('signer.status.reconnecting'));
|
||||
const [{ BunkerSigner: Bunker }, { hexToBytes }] = await Promise.all([
|
||||
import('nostr-tools/nip46'),
|
||||
import('nostr-tools/utils'),
|
||||
]);
|
||||
|
||||
const pointer: BunkerPointer = {
|
||||
pubkey: connect.signer_pubkey,
|
||||
relays: connect.relays,
|
||||
secret: connect.secret,
|
||||
};
|
||||
|
||||
const bunker = Bunker.fromBunker(hexToBytes(connect.client_key), pointer, {
|
||||
onauth: (url) => openAuthWindow(url),
|
||||
});
|
||||
|
||||
// A relay that has gone down since the session was made shows up here, as a ping
|
||||
// that never comes back, rather than as a signature request that hangs.
|
||||
await timeout(bunker.ping(), CONNECT_PATIENCE_MS, 'signer-unreachable');
|
||||
|
||||
liveBunker = bunker;
|
||||
liveBunkerKey = bunkerKey(connect);
|
||||
return bunker;
|
||||
}
|
||||
|
||||
/**
|
||||
* Some signers answer a request with "send the reader here first".
|
||||
*
|
||||
* Opened as a popup rather than navigating: the reader is mid-review, and taking the
|
||||
* page away from them would lose it. A blocked popup is not fatal, the signer simply
|
||||
* never gets its approval and the request times out with a message.
|
||||
*/
|
||||
function openAuthWindow(url: string): void {
|
||||
status('waiting', signerText('signer.status.approveWindow'));
|
||||
window.open(url, 'cashumints-signer-auth', 'width=420,height=640,noopener,noreferrer');
|
||||
}
|
||||
|
||||
function remoteSigner(method: SignerMethod, connect: ConnectInfo): NostrSigner {
|
||||
return {
|
||||
method,
|
||||
async getPublicKey() {
|
||||
const bunker = await reachBunker(connect);
|
||||
return timeout(bunker.getPublicKey(), SIGN_TIMEOUT_MS, 'signer-timeout');
|
||||
},
|
||||
async signEvent(event) {
|
||||
const bunker = await reachBunker(connect);
|
||||
status('waiting', signerText('signer.status.waitingSigner'));
|
||||
const signed = await timeout(bunker.signEvent(event), SIGN_TIMEOUT_MS, 'signer-timeout');
|
||||
status('ready', signerText('signer.status.signed'));
|
||||
return signed as SignedEvent;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------- pairing ---------- */
|
||||
|
||||
export interface ConnectHandle {
|
||||
/** The URI to show as a QR code and offer as a copy. */
|
||||
uri: string;
|
||||
/** Resolves when the signer has paired and the session is stored. */
|
||||
session: Promise<Session>;
|
||||
/** Give up on this pairing. */
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a pairing this side: build a `nostrconnect://` URI, wait for a signer to
|
||||
* answer it.
|
||||
*
|
||||
* This is the flow behind both the "Remote signer" QR and the Primal button; the only
|
||||
* difference is the instructions printed next to the code and the method the session
|
||||
* is stored under.
|
||||
*/
|
||||
export async function startNostrConnect(method: 'remote' | 'primal'): Promise<ConnectHandle> {
|
||||
const [{ BunkerSigner: Bunker, createNostrConnectURI }, { generateSecretKey, getPublicKey }, { bytesToHex }] =
|
||||
await Promise.all([
|
||||
import('nostr-tools/nip46'),
|
||||
import('nostr-tools/pure'),
|
||||
import('nostr-tools/utils'),
|
||||
]);
|
||||
|
||||
// This browser's end of the NIP-46 channel. Generated, never entered, and useless
|
||||
// to anyone: it cannot sign a Nostr event as the reader, only address the signer.
|
||||
const clientKey = generateSecretKey();
|
||||
const clientPubkey = getPublicKey(clientKey);
|
||||
const secret = randomSecret();
|
||||
|
||||
const uri = createNostrConnectURI({
|
||||
clientPubkey,
|
||||
relays: CONNECT_RELAYS,
|
||||
secret,
|
||||
perms: PERMS,
|
||||
...CLIENT_METADATA,
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
const patience = window.setTimeout(
|
||||
() => status('waiting', signerText('signer.status.stillWaiting')),
|
||||
CONNECT_PATIENCE_MS,
|
||||
);
|
||||
|
||||
status('connecting', signerText('signer.status.waitingSigner'));
|
||||
|
||||
const session = Bunker.fromURI(clientKey, uri, { onauth: (url) => openAuthWindow(url) }, controller.signal)
|
||||
.then(async (bunker) => {
|
||||
window.clearTimeout(patience);
|
||||
const pubkey = await timeout(bunker.getPublicKey(), SIGN_TIMEOUT_MS, 'signer-timeout');
|
||||
|
||||
const connect: ConnectInfo = {
|
||||
signer_pubkey: bunker.bp.pubkey,
|
||||
relays: bunker.bp.relays,
|
||||
secret: bunker.bp.secret,
|
||||
client_key: bytesToHex(clientKey),
|
||||
};
|
||||
liveBunker = bunker;
|
||||
liveBunkerKey = bunkerKey(connect);
|
||||
|
||||
const stored: Session = { method, pubkey, connect };
|
||||
writeSession(stored);
|
||||
status('ready', signerText('signer.status.connected'));
|
||||
return stored;
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
window.clearTimeout(patience);
|
||||
throw error instanceof Error ? error : new Error(String(error));
|
||||
});
|
||||
|
||||
return {
|
||||
uri,
|
||||
session,
|
||||
cancel: () => {
|
||||
window.clearTimeout(patience);
|
||||
controller.abort();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pair from the other side: the reader's signer hands them a `bunker://` URI and they
|
||||
* paste it here. A `name@domain` NIP-05 pointing at a bunker works too, which is what
|
||||
* nsec.app hands out.
|
||||
*/
|
||||
export async function connectBunker(input: string, method: 'remote' | 'primal' = 'remote'): Promise<Session> {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) throw new Error('empty-bunker');
|
||||
// Said plainly and early: this box takes a connection URI, and a key pasted into it
|
||||
// would be a key handed to a web page. Refuse before anything reads it.
|
||||
if (/^nsec1/i.test(trimmed) || /^[0-9a-f]{64}$/i.test(trimmed)) throw new Error('not-a-bunker');
|
||||
|
||||
const [{ BunkerSigner: Bunker, parseBunkerInput }, { generateSecretKey }, { bytesToHex }] = await Promise.all([
|
||||
import('nostr-tools/nip46'),
|
||||
import('nostr-tools/pure'),
|
||||
import('nostr-tools/utils'),
|
||||
]);
|
||||
|
||||
const pointer = await parseBunkerInput(trimmed);
|
||||
if (!pointer || pointer.relays.length === 0) throw new Error('bad-bunker');
|
||||
|
||||
const clientKey = generateSecretKey();
|
||||
status('connecting', signerText('signer.status.waitingSigner'));
|
||||
|
||||
const bunker = Bunker.fromBunker(clientKey, pointer, { onauth: (url) => openAuthWindow(url) });
|
||||
await timeout(bunker.connect(CLIENT_METADATA), CONNECT_PATIENCE_MS, 'connect-timeout');
|
||||
const pubkey = await timeout(bunker.getPublicKey(), SIGN_TIMEOUT_MS, 'signer-timeout');
|
||||
|
||||
const connect: ConnectInfo = {
|
||||
signer_pubkey: bunker.bp.pubkey,
|
||||
relays: bunker.bp.relays,
|
||||
secret: bunker.bp.secret,
|
||||
client_key: bytesToHex(clientKey),
|
||||
};
|
||||
liveBunker = bunker;
|
||||
liveBunkerKey = bunkerKey(connect);
|
||||
|
||||
const session: Session = { method, pubkey, connect };
|
||||
writeSession(session);
|
||||
status('ready', signerText('signer.status.connected'));
|
||||
return session;
|
||||
}
|
||||
|
||||
/** Log in with the extension: one click, one pubkey, nothing to pair. */
|
||||
export async function connectExtension(): Promise<Session> {
|
||||
const extension = getExtension();
|
||||
if (!extension) throw new Error('no-extension');
|
||||
const pubkey = await extension.getPublicKey();
|
||||
if (!/^[0-9a-f]{64}$/i.test(pubkey)) throw new Error('bad-pubkey');
|
||||
const session: Session = { method: 'extension', pubkey };
|
||||
writeSession(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
/* ---------- picking a signer ---------- */
|
||||
|
||||
/**
|
||||
* The signer to publish with, or null when there is no way to sign.
|
||||
*
|
||||
* Order matters and is the publishing rule in one function: a stored session wins, and
|
||||
* with no session an extension still signs, because quick-sign has to keep working for
|
||||
* someone who never opened the login popup.
|
||||
*/
|
||||
export function activeSigner(): NostrSigner | null {
|
||||
const session = readSession();
|
||||
if (session?.method === 'extension') return extensionSigner();
|
||||
if (session?.connect) return remoteSigner(session.method, session.connect);
|
||||
return extensionSigner();
|
||||
}
|
||||
|
||||
/**
|
||||
* Human wording for a signer failure, wherever one is caught.
|
||||
*
|
||||
* The thrown values are codes (`no-extension`, `signer-timeout`), not sentences, so
|
||||
* they map cleanly onto catalog keys. The last line is the exception: an error nothing
|
||||
* here recognises is passed through as its own text, which is usually the signer's or
|
||||
* the browser's and is in whatever language they chose. Inventing a translation for a
|
||||
* message we cannot read would be worse than showing what actually went wrong.
|
||||
*/
|
||||
export function signerErrorMessage(error: unknown, t: Translator = useI18n()): string {
|
||||
const raw = error instanceof Error ? error.message : String(error);
|
||||
const message = raw.toLowerCase();
|
||||
|
||||
if (message.includes('no-extension')) return t('signer.error.noExtension');
|
||||
if (message.includes('not-a-bunker')) return t('signer.error.privateKey');
|
||||
if (message.includes('bad-bunker') || message.includes('empty-bunker')) {
|
||||
return t('signer.error.badLink');
|
||||
}
|
||||
if (message.includes('signer-unreachable')) return t('signer.error.noAnswer');
|
||||
if (message.includes('signer-timeout') || message.includes('connect-timeout')) {
|
||||
return t('signer.error.timeout');
|
||||
}
|
||||
if (message.includes('abort')) return t('signer.error.cancelled');
|
||||
if (message.includes('denied') || message.includes('reject') || message.includes('declined')) {
|
||||
return t('signer.error.declined');
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
/** A short random pairing secret. Not a key: it is echoed back in the clear. */
|
||||
function randomSecret(): string {
|
||||
const bytes = new Uint8Array(16);
|
||||
crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* Reviewer identity: resolve kind 0 metadata for the pubkeys on the page.
|
||||
*
|
||||
* Three things were wrong before and are fixed here:
|
||||
*
|
||||
* 1. Only the review relays were asked. Several of them keep no kind 0 at all, so
|
||||
* the lookup came back empty and every card fell back to a truncated pubkey.
|
||||
* `PROFILE_RELAYS` adds two aggregators (see shared/src/nostr.ts).
|
||||
* 2. A miss was cached for 24 hours next to a hit, so a pubkey with no profile was
|
||||
* never retried; a miss now expires after an hour.
|
||||
* 3. Nothing bounded the cache, so it grew until localStorage refused to take it and
|
||||
* writes started failing silently. It is capped and evicted oldest first.
|
||||
*
|
||||
* One REQ per relay per page view: the whole page's pubkeys go out in a single
|
||||
* batched filter, and the subscription closes on EOSE from every relay or after
|
||||
* `PROFILE_TIMEOUT_MS`, whichever comes first.
|
||||
*/
|
||||
import { SimplePool } from 'nostr-tools/pool';
|
||||
import type { Event as NostrEvent } from 'nostr-tools';
|
||||
import { KIND_PROFILE, PROFILE_RELAYS, parseProfileContent, type Profile } from '@cashumints/shared';
|
||||
|
||||
/**
|
||||
* Relays asked for kind 0. Configurable: set PUBLIC_PROFILE_RELAYS at build time to a
|
||||
* comma separated list to replace the default pool entirely.
|
||||
*/
|
||||
export const PROFILE_RELAY_URLS: string[] = (() => {
|
||||
const configured = import.meta.env['PUBLIC_PROFILE_RELAYS'] as string | undefined;
|
||||
const list = configured
|
||||
?.split(',')
|
||||
.map((relay) => relay.trim())
|
||||
.filter(Boolean);
|
||||
return list && list.length > 0 ? list : [...PROFILE_RELAYS];
|
||||
})();
|
||||
|
||||
/** Relays get this long to answer before the page keeps its npub fallbacks. */
|
||||
const PROFILE_TIMEOUT_MS = 4000;
|
||||
|
||||
/** A resolved profile is good for a day. */
|
||||
const HIT_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
/**
|
||||
* A miss is only good for an hour. Long enough that reloading a mint page does not
|
||||
* re-query a hundred dead pubkeys, short enough that someone who publishes a kind 0
|
||||
* today is named on the card tomorrow.
|
||||
*/
|
||||
const MISS_TTL_MS = 60 * 60 * 1000;
|
||||
|
||||
/** Roughly 100kB of localStorage at typical profile sizes. */
|
||||
const MAX_ENTRIES = 500;
|
||||
|
||||
const CACHE_KEY = 'cashumints:profiles:v2';
|
||||
|
||||
interface CacheEntry {
|
||||
/** Milliseconds since epoch, the eviction and TTL key. */
|
||||
fetched_at: number;
|
||||
name?: string;
|
||||
picture?: string;
|
||||
nip05?: string;
|
||||
/** false for a negative cache entry. */
|
||||
found: boolean;
|
||||
}
|
||||
|
||||
type Cache = Record<string, CacheEntry>;
|
||||
|
||||
function readCache(): Cache {
|
||||
try {
|
||||
const raw = localStorage.getItem(CACHE_KEY);
|
||||
if (!raw) return {};
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
|
||||
return parsed as Cache;
|
||||
} catch {
|
||||
// Blocked, disabled or corrupt storage. Profiles just resolve again next visit.
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeCache(cache: Cache): void {
|
||||
// Oldest out first, so a heavy browsing session cannot grow the entry unbounded.
|
||||
const entries = Object.entries(cache);
|
||||
if (entries.length > MAX_ENTRIES) {
|
||||
entries.sort((a, b) => b[1].fetched_at - a[1].fetched_at);
|
||||
cache = Object.fromEntries(entries.slice(0, MAX_ENTRIES));
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.setItem(CACHE_KEY, JSON.stringify(cache));
|
||||
} catch {
|
||||
// Storage full or blocked: nothing to do, the next visit re-fetches.
|
||||
}
|
||||
}
|
||||
|
||||
function fresh(entry: CacheEntry, now: number): boolean {
|
||||
return now - entry.fetched_at < (entry.found ? HIT_TTL_MS : MISS_TTL_MS);
|
||||
}
|
||||
|
||||
function toProfile(pubkey: string, entry: CacheEntry): Profile {
|
||||
if (!entry.found) return { pubkey, found: false };
|
||||
return {
|
||||
pubkey,
|
||||
name: entry.name,
|
||||
display_name: entry.name,
|
||||
picture: entry.picture,
|
||||
nip05: entry.nip05,
|
||||
found: true,
|
||||
};
|
||||
}
|
||||
|
||||
function toEntry(profile: Profile, now: number): CacheEntry {
|
||||
if (!profile.found) return { fetched_at: now, found: false };
|
||||
const entry: CacheEntry = { fetched_at: now, found: true };
|
||||
const name = profile.display_name ?? profile.name;
|
||||
if (name) entry.name = name;
|
||||
if (profile.picture) entry.picture = profile.picture;
|
||||
if (profile.nip05) entry.nip05 = profile.nip05;
|
||||
return entry;
|
||||
}
|
||||
|
||||
let pool: SimplePool | null = null;
|
||||
function getPool(): SimplePool {
|
||||
pool ??= new SimplePool();
|
||||
return pool;
|
||||
}
|
||||
|
||||
/**
|
||||
* One batched kind 0 query for every pubkey that is not already cached.
|
||||
*
|
||||
* Resolves with the newest kind 0 per author. Relays are raced: whichever answers
|
||||
* first contributes, and a slow or dead relay costs nothing beyond the timeout.
|
||||
*/
|
||||
async function fetchProfiles(pubkeys: string[]): Promise<Map<string, Profile>> {
|
||||
const newest = new Map<string, NostrEvent>();
|
||||
if (pubkeys.length === 0) return new Map();
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
let done = false;
|
||||
const finish = (): void => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
window.clearTimeout(timer);
|
||||
try {
|
||||
sub.close();
|
||||
} catch {
|
||||
// Already closed by EOSE.
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
|
||||
const timer = window.setTimeout(finish, PROFILE_TIMEOUT_MS);
|
||||
|
||||
const sub = getPool().subscribeManyEose(
|
||||
PROFILE_RELAY_URLS,
|
||||
{ kinds: [KIND_PROFILE], authors: pubkeys },
|
||||
{
|
||||
maxWait: PROFILE_TIMEOUT_MS,
|
||||
onevent(event) {
|
||||
if (event.kind !== KIND_PROFILE) return;
|
||||
const existing = newest.get(event.pubkey);
|
||||
if (!existing || event.created_at > existing.created_at) {
|
||||
newest.set(event.pubkey, event);
|
||||
}
|
||||
},
|
||||
onclose: finish,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const out = new Map<string, Profile>();
|
||||
for (const [pubkey, event] of newest) out.set(pubkey, parseProfileContent(pubkey, event.content));
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface LoadProfilesOptions {
|
||||
/**
|
||||
* Ignore what the cache holds and ask the relays again, then overwrite it.
|
||||
*
|
||||
* For the person who just signed a review: a key that looked profile-less an hour
|
||||
* ago is negative-cached for the rest of that hour, and the author is exactly the
|
||||
* reader who would notice their own card saying "Anon".
|
||||
*/
|
||||
refresh?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve every pubkey on the page, cache first.
|
||||
*
|
||||
* Always resolves: relay trouble returns whatever the cache had rather than throwing,
|
||||
* because the caller has already rendered the cards and only wants to upgrade them.
|
||||
*/
|
||||
export async function loadProfiles(
|
||||
pubkeys: string[],
|
||||
options: LoadProfilesOptions = {},
|
||||
): Promise<Map<string, Profile>> {
|
||||
const out = new Map<string, Profile>();
|
||||
const distinct = [...new Set(pubkeys.filter((p) => /^[0-9a-f]{64}$/i.test(p)))];
|
||||
if (distinct.length === 0) return out;
|
||||
|
||||
const cache = readCache();
|
||||
const now = Date.now();
|
||||
const missing: string[] = [];
|
||||
|
||||
for (const pubkey of distinct) {
|
||||
const entry = cache[pubkey];
|
||||
if (!options.refresh && entry && fresh(entry, now)) out.set(pubkey, toProfile(pubkey, entry));
|
||||
else missing.push(pubkey);
|
||||
}
|
||||
|
||||
if (missing.length === 0) return out;
|
||||
|
||||
let found: Map<string, Profile>;
|
||||
try {
|
||||
found = await fetchProfiles(missing);
|
||||
} catch {
|
||||
// No relay answered. Do not write negative entries for what was never asked.
|
||||
return out;
|
||||
}
|
||||
|
||||
for (const pubkey of missing) {
|
||||
const profile = found.get(pubkey) ?? { pubkey, found: false };
|
||||
out.set(pubkey, profile);
|
||||
cache[pubkey] = toEntry(profile, now);
|
||||
}
|
||||
|
||||
writeCache(cache);
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Build-time QR codes.
|
||||
*
|
||||
* Every mint URL is known when the page is generated, so the QR is encoded here and
|
||||
* ships as inline SVG. Nothing about it needs a browser: no canvas, no library in the
|
||||
* bundle, and it is on screen the moment the dialog opens, with JavaScript or without.
|
||||
*
|
||||
* The old site did this in the browser instead (`components/cards/QRCard.tsx` rendered
|
||||
* `<QRCodeSVG>` from `qrcode.react`), which put an encoder on every mint page whether
|
||||
* or not anyone opened the QR.
|
||||
*/
|
||||
import qrcode from 'qrcode-generator';
|
||||
|
||||
/**
|
||||
* The library's own byte encoder truncates each character to its low byte, which is
|
||||
* Latin-1, not UTF-8. Mint URLs are ASCII today and the two agree there, but a single
|
||||
* non-ASCII character would otherwise encode to something a scanner reads back wrong.
|
||||
*/
|
||||
const utf8 = new TextEncoder();
|
||||
qrcode.stringToBytes = (value: string): number[] => Array.from(utf8.encode(value));
|
||||
|
||||
export interface QrOptions {
|
||||
/** Quiet zone in modules. Four is what the spec asks for; less risks a bad read. */
|
||||
margin?: number;
|
||||
/** Colour of the dark modules. */
|
||||
color?: string;
|
||||
/** Colour behind them, including the quiet zone. */
|
||||
background?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode `value` as an inline `<svg>` string, sized by its container.
|
||||
*
|
||||
* Error correction is M (~15%). H would survive more damage, but this code is read off
|
||||
* a lit screen a few inches away rather than off a scuffed sticker, and M keeps the
|
||||
* module count — and so the module size — comfortable for a phone camera.
|
||||
*/
|
||||
export function qrSvg(value: string, options: QrOptions = {}): string {
|
||||
const { margin = 4, color = '#0E0D0B', background = '#FFFFFF' } = options;
|
||||
|
||||
const qr = qrcode(0, 'M');
|
||||
qr.addData(value);
|
||||
qr.make();
|
||||
|
||||
const count = qr.getModuleCount();
|
||||
const size = count + margin * 2;
|
||||
|
||||
// One path for every dark module, with horizontal runs merged into a single
|
||||
// subpath. A 33x33 code is a few hundred bytes this way instead of a few thousand
|
||||
// as individual rects.
|
||||
let path = '';
|
||||
for (let row = 0; row < count; row++) {
|
||||
let run = 0;
|
||||
for (let col = 0; col <= count; col++) {
|
||||
const dark = col < count && qr.isDark(row, col);
|
||||
if (dark) {
|
||||
run++;
|
||||
continue;
|
||||
}
|
||||
if (run > 0) {
|
||||
path += `M${col - run + margin} ${row + margin}h${run}v1h-${run}z`;
|
||||
run = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" ` +
|
||||
`width="100%" height="100%" shape-rendering="crispEdges" role="img" ` +
|
||||
`aria-label="QR code for ${escapeAttr(value)}">` +
|
||||
`<rect width="${size}" height="${size}" fill="${background}"/>` +
|
||||
`<path d="${path}" fill="${color}"/>` +
|
||||
`</svg>`
|
||||
);
|
||||
}
|
||||
|
||||
function escapeAttr(value: string): string {
|
||||
return value.replace(/&/g, '&').replace(/</g, '<').replace(/"/g, '"');
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* The review card, as HTML strings.
|
||||
*
|
||||
* Extracted from the mint page's reviews island so the global feed at /reviews can
|
||||
* render the identical card: same identity resolution, same copy-to-clipboard npub,
|
||||
* same "ratings without a comment collapse into one line" rule. Two pages rendering
|
||||
* a review two ways would be two things to keep in step, and they would drift.
|
||||
*
|
||||
* Everything here is a pure string builder except `applyProfiles`, which patches
|
||||
* already-rendered cards in place. Review bodies are untrusted relay content, so
|
||||
* every interpolated value goes through `escapeHtml`.
|
||||
*
|
||||
* Every function that has words in it takes a `Formatters`, which carries both the
|
||||
* translator and the locale's date and number formatting. It is passed in rather than
|
||||
* read from the page because these run in two places: the review feed prerenders cards
|
||||
* at build time, where there is no document to read a locale from, and the islands
|
||||
* render the identical markup in the browser. One argument is the price of the two
|
||||
* staying identical.
|
||||
*
|
||||
* What is never translated: the review body, the reviewer's name, their NIP-05 and
|
||||
* their npub. That is what someone wrote and who wrote it, not site copy, and it
|
||||
* renders byte for byte the same in all three languages.
|
||||
*/
|
||||
import { WRITTEN_MIN_CHARS, profileName } from '@cashumints/shared';
|
||||
import { copyableIdHtml, escapeHtml, iconGradient, shortNpub } from './client';
|
||||
import { starString, type Formatters } from '../i18n/format';
|
||||
import type { LoadedReview } from './reviews-client';
|
||||
|
||||
/** The mint a card points at. Only the global feed passes one: on a mint page it is the page. */
|
||||
export interface MintRef {
|
||||
href: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface CardOptions {
|
||||
/** Adds the "propagating to relays" note, for a review published in this session. */
|
||||
propagating?: boolean;
|
||||
/**
|
||||
* How many relays took it, when the review was published in this session.
|
||||
*
|
||||
* Only said out loud when some relay refused: "published to 2 of 4" is a fact the
|
||||
* author can act on (try again later, and it is already on the network), where a
|
||||
* clean 4 of 4 is just noise on the card.
|
||||
*/
|
||||
publishedTo?: { accepted: number; total: number } | null;
|
||||
/** Adds a link to the mint being reviewed. */
|
||||
mint?: MintRef | null;
|
||||
}
|
||||
|
||||
/** A review with something to read, as opposed to a bare rating. */
|
||||
export function isWritten(review: LoadedReview): boolean {
|
||||
return review.content.trim().length >= WRITTEN_MIN_CHARS;
|
||||
}
|
||||
|
||||
/**
|
||||
* The reviewer's avatar: the generated initial always, with the kind-0 picture layered
|
||||
* over it. A picture that fails to load removes itself and what was already underneath
|
||||
* shows through, so a dead image URL costs nothing.
|
||||
*/
|
||||
export function avatarHtml(review: LoadedReview): string {
|
||||
const name = profileName(review.profile);
|
||||
// With no name the letter comes from the npub, not from the word "Anon": every
|
||||
// anonymous reviewer would otherwise get the same tile, and the point of the
|
||||
// generated avatar is telling two of them apart.
|
||||
const initial = (name?.[0] ?? review.npub[5] ?? '?').toUpperCase();
|
||||
const fallback = `<span class="avatar" style="background:${iconGradient(
|
||||
review.pubkey,
|
||||
)}" aria-hidden="true">${escapeHtml(initial)}</span>`;
|
||||
|
||||
const picture = review.profile?.picture;
|
||||
if (!picture) return `<span class="rev-avatar">${fallback}</span>`;
|
||||
|
||||
// The picture fades over the initial once it has actually decoded, so a slow image
|
||||
// arrives rather than popping, and a dead one costs nothing: it removes itself and
|
||||
// what was already underneath shows through. Neither path moves any layout.
|
||||
return `<span class="rev-avatar">${fallback}<img class="avatar pic" src="${escapeHtml(
|
||||
picture,
|
||||
)}" alt="" width="38" height="38" loading="lazy" referrerpolicy="no-referrer" ` +
|
||||
`onload="this.classList.add('loaded')" onerror="this.remove()"></span>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Just the name row, for patching a card in place when its profile lands.
|
||||
*
|
||||
* `resolved` marks a name that arrived after the card was drawn, so it can cross-fade
|
||||
* in over the npub it replaces. The first render never carries it: there is nothing
|
||||
* to fade from.
|
||||
*/
|
||||
export function identityLabelHtml(review: LoadedReview, f: Formatters, resolved = false): string {
|
||||
const name = profileName(review.profile);
|
||||
const mark = resolved ? ' resolved' : '';
|
||||
const nip05 =
|
||||
name && review.profile?.nip05
|
||||
? `<span class="nip05${mark}" title="${escapeHtml(review.profile.nip05)}">${escapeHtml(review.profile.nip05)}</span>`
|
||||
: '';
|
||||
return name
|
||||
? `<span class="rev-name${mark}">${escapeHtml(name)}</span>${nip05}`
|
||||
// No kind 0 for this key. The placeholder is site copy, not a name someone chose,
|
||||
// so it is the one part of an identity that does get translated.
|
||||
: `<span class="rev-name anon">${escapeHtml(f.t('reviews.anon'))}</span>`;
|
||||
}
|
||||
|
||||
/** The shortened npub, as the copy control. Same value whatever the name says. */
|
||||
export function npubHtml(review: LoadedReview, f: Formatters): string {
|
||||
return copyableIdHtml({
|
||||
full: review.npub,
|
||||
short: shortNpub(review.npub),
|
||||
label: f.t('common.copyFull', { kind: f.t('mint.kind.npub') }),
|
||||
className: 'rev-npub',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Who wrote it: the kind-0 name (with its NIP-05 beside it) or "Anon", and the
|
||||
* shortened npub under it as the copy control. Two lines, one identity: the name is
|
||||
* for reading, the npub is for checking, and neither is a hex pubkey.
|
||||
*
|
||||
* The block is a fixed height whichever branch runs, so a name arriving from a relay
|
||||
* drops into a line that already had the space.
|
||||
*/
|
||||
export function identityHtml(review: LoadedReview, f: Formatters): string {
|
||||
return `<span class="rev-identity">${identityLabelHtml(review, f)}</span>${npubHtml(review, f)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The provenance note is a warning, not a stamp. It only earns its place when a harsh
|
||||
* review comes from an npub with no other trace on the network at all; on every other
|
||||
* card it was noise on nearly every row.
|
||||
*/
|
||||
export function noteHtml(review: LoadedReview, f: Formatters): string {
|
||||
const unknown = review.profile !== null && !review.profile.found;
|
||||
if (!unknown || review.rating === null || review.rating > 2) return '';
|
||||
return `<p class="rev-note"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M12 8v4M12 16h.01"/></svg>${escapeHtml(
|
||||
f.t('reviews.anonNote'),
|
||||
)}</p>`;
|
||||
}
|
||||
|
||||
export function metaHtml(review: LoadedReview, f: Formatters): string {
|
||||
const bad = review.rating !== null && review.rating <= 2;
|
||||
const ratingText = ratingLabel(review.rating, f);
|
||||
return `<span class="rev-meta">
|
||||
<span class="rev-stars${bad ? ' bad' : ''}"><span class="sr-only">${escapeHtml(
|
||||
ratingText,
|
||||
)}</span><span aria-hidden="true">${starString(review.rating)}</span></span>
|
||||
<span class="rev-date">${escapeHtml(f.relative(review.created_at))}</span>
|
||||
</span>`;
|
||||
}
|
||||
|
||||
/** What a screen reader hears in place of the star row. */
|
||||
function ratingLabel(rating: number | null, f: Formatters): string {
|
||||
return rating === null
|
||||
? f.t('reviews.srNoRating')
|
||||
// The rating is a small integer, passed as a string so it is not run through the
|
||||
// locale's number formatting: "4" should not become "4,0" anywhere.
|
||||
: f.t('reviews.srRating', { rating: String(rating) });
|
||||
}
|
||||
|
||||
/** The mint this review is about, as a link. Empty on a mint page, where it is the page. */
|
||||
function mintLinkHtml(mint: MintRef | null | undefined): string {
|
||||
if (!mint) return '';
|
||||
return `<p class="rev-mint"><a href="${escapeHtml(mint.href)}">${escapeHtml(mint.label)}</a></p>`;
|
||||
}
|
||||
|
||||
export function reviewHtml(review: LoadedReview, f: Formatters, options: CardOptions = {}): string {
|
||||
// The note pulses gently while the review is still in flight. It stops the moment
|
||||
// the relays echo the review back, because then the card is redrawn without it.
|
||||
const relays = options.publishedTo;
|
||||
const partial =
|
||||
relays && relays.accepted < relays.total
|
||||
? f.t('reviews.publishedPartial', { accepted: relays.accepted, total: relays.total })
|
||||
: f.t('reviews.published');
|
||||
const propagating = options.propagating
|
||||
? `<p class="rev-note propagating">${escapeHtml(partial)} ${escapeHtml(f.t('reviews.propagating'))}</p>`
|
||||
: '';
|
||||
|
||||
return `<article class="review" data-review-pubkey="${escapeHtml(review.pubkey)}">
|
||||
${avatarHtml(review)}
|
||||
<span class="rev-who">${identityHtml(review, f)}</span>
|
||||
${metaHtml(review, f)}
|
||||
<p class="rev-body">${escapeHtml(review.content)}</p>
|
||||
${mintLinkHtml(options.mint)}${noteHtml(review, f)}${propagating}
|
||||
</article>`;
|
||||
}
|
||||
|
||||
/* ---------- rating only summary ---------- */
|
||||
|
||||
/**
|
||||
* "18 five star, 1 four star, 3 one star", highest rating first.
|
||||
*
|
||||
* One catalog key per star count rather than a number and a spelled-out word glued
|
||||
* together: languages put the number, the numeral word and the noun in different
|
||||
* orders, and Spanish and Dutch both inflect "star" where English does not here.
|
||||
*/
|
||||
export function ratingBreakdown(rows: LoadedReview[], f: Formatters): string {
|
||||
const parts: string[] = [];
|
||||
for (let stars = 5; stars >= 1; stars--) {
|
||||
const count = rows.filter((r) => r.rating === stars).length;
|
||||
if (count > 0) parts.push(f.t(`reviews.breakdown.${stars}`, { n: count }));
|
||||
}
|
||||
const unrated = rows.filter((r) => r.rating === null).length;
|
||||
if (unrated > 0) parts.push(f.t('reviews.breakdown.none', { n: unrated }));
|
||||
return parts.join(', ');
|
||||
}
|
||||
|
||||
export function summaryRowHtml(review: LoadedReview, f: Formatters, options: CardOptions = {}): string {
|
||||
const bad = review.rating !== null && review.rating <= 2;
|
||||
const ratingText = ratingLabel(review.rating, f);
|
||||
const mint = options.mint
|
||||
? `<a class="rs-mint" href="${escapeHtml(options.mint.href)}">${escapeHtml(options.mint.label)}</a>`
|
||||
: '';
|
||||
|
||||
// A summary row is one line, so the name and the npub sit side by side on it.
|
||||
return `<li class="rs-row" data-review-pubkey="${escapeHtml(review.pubkey)}">
|
||||
${identityLabelHtml(review, f)}${npubHtml(review, f)}${mint}
|
||||
<span class="rs-stars${bad ? ' bad' : ''}"><span class="sr-only">${escapeHtml(
|
||||
ratingText,
|
||||
)}</span><span aria-hidden="true">${starString(review.rating)}</span></span>
|
||||
<span class="rs-date">${escapeHtml(f.relative(review.created_at))}</span>
|
||||
</li>`;
|
||||
}
|
||||
|
||||
export interface SummaryOptions {
|
||||
/** Asked per row, so the global feed can link each rating to its own mint. */
|
||||
mintFor?: (review: LoadedReview) => MintRef | null;
|
||||
/**
|
||||
* Day this block covers, already formatted. The mint page has one block for the
|
||||
* whole mint and omits it; the feed has one block per day and needs to say which.
|
||||
*/
|
||||
when?: string;
|
||||
/** Identifies the block across re-renders, so an opened one stays open. */
|
||||
key?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One collapsed block for the ratings that came with no comment.
|
||||
*
|
||||
* Dozens of "rated 5, no comment" cards drown the reviews that say something, on a
|
||||
* mint page and even more so in a feed across every mint. They are not hidden: the
|
||||
* list is one click away and the filter counts above still count them.
|
||||
*/
|
||||
export function summaryHtml(
|
||||
rows: LoadedReview[],
|
||||
f: Formatters,
|
||||
open: boolean,
|
||||
options: SummaryOptions = {},
|
||||
): string {
|
||||
if (rows.length === 0) return '';
|
||||
const key = options.key ? ` data-summary-key="${escapeHtml(options.key)}"` : '';
|
||||
|
||||
/*
|
||||
* Two whole sentences rather than one with an optional " on {date}" spliced in.
|
||||
* English tolerates a trailing fragment; Spanish and Dutch put the date somewhere
|
||||
* else in the clause, and a translator handed a dangling " on {when}" cannot move it.
|
||||
* The count arrives as pre-escaped bold markup so its position is theirs to choose
|
||||
* too.
|
||||
*/
|
||||
const count = `<b>${f.number(rows.length)}</b>`;
|
||||
const breakdown = escapeHtml(ratingBreakdown(rows, f));
|
||||
const line = options.when
|
||||
? f.t('reviews.summary.lineDay', {
|
||||
n: rows.length,
|
||||
count,
|
||||
when: escapeHtml(options.when),
|
||||
breakdown,
|
||||
})
|
||||
: f.t('reviews.summary.line', { n: rows.length, count, breakdown });
|
||||
|
||||
return `<details class="rating-summary"${open ? ' open' : ''}${key}>
|
||||
<summary>
|
||||
<span class="rs-line">${line}</span>
|
||||
<span class="rs-toggle">${escapeHtml(
|
||||
f.t(rows.length === 1 ? 'reviews.summary.showOne' : 'reviews.summary.showMany'),
|
||||
)}</span>
|
||||
</summary>
|
||||
<ul class="rs-list">${rows
|
||||
.map((review) => summaryRowHtml(review, f, { mint: options.mintFor?.(review) ?? null }))
|
||||
.join('')}</ul>
|
||||
</details>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upgrade rendered cards in place once the kind-0 lookups land.
|
||||
*
|
||||
* A patch rather than a re-render: only cards whose profile actually resolved change,
|
||||
* so nothing that is already correct flickers, and the identity line has a fixed
|
||||
* height either way so nothing moves.
|
||||
*/
|
||||
export function applyProfiles(
|
||||
root: ParentNode,
|
||||
f: Formatters,
|
||||
lookup: (pubkey: string) => LoadedReview | undefined,
|
||||
): void {
|
||||
for (const card of root.querySelectorAll<HTMLElement>('[data-review-pubkey]')) {
|
||||
const review = lookup(card.dataset['reviewPubkey'] ?? '');
|
||||
if (!review?.profile) continue;
|
||||
const isRow = card.classList.contains('rs-row');
|
||||
|
||||
if (review.profile.found) {
|
||||
// Only the name row is rewritten. The npub under it is the same either way, so
|
||||
// its copy button is left alone rather than rebuilt underneath a cursor.
|
||||
const label = card.querySelector('.rev-name');
|
||||
if (label) label.outerHTML = identityLabelHtml(review, f, true);
|
||||
if (!isRow) {
|
||||
const avatar = card.querySelector('.rev-avatar');
|
||||
if (avatar) avatar.outerHTML = avatarHtml(review);
|
||||
// A cached picture can finish loading before its onload attribute is live.
|
||||
const pic = card.querySelector<HTMLImageElement>('.avatar.pic');
|
||||
if (pic?.complete && pic.naturalWidth > 0) pic.classList.add('loaded');
|
||||
}
|
||||
} else if (!isRow && !card.querySelector('.rev-note')) {
|
||||
// Nothing found for this npub. That only matters on a harsh review, and
|
||||
// `noteHtml` returns an empty string on every other one.
|
||||
card.insertAdjacentHTML('beforeend', noteHtml(review, f));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* Browser-side Nostr: read review content from relays, resolve reviewer profiles,
|
||||
* and publish a new review signed by whichever signer the reader is using.
|
||||
*
|
||||
* Kinds, tags and relays are exactly what the old site used (see NOTES.md), so this
|
||||
* reads and writes the same events any other client sees.
|
||||
*/
|
||||
import { useI18n } from '../i18n/client';
|
||||
import { SimplePool } from 'nostr-tools/pool';
|
||||
import type { Event as NostrEvent } from 'nostr-tools';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import {
|
||||
DEFAULT_RELAYS,
|
||||
KIND_MINT_ANNOUNCEMENT,
|
||||
KIND_REVIEW,
|
||||
PRIMARY_RELAY,
|
||||
cleanReviewContent,
|
||||
mintUrlSpellings,
|
||||
parseRating,
|
||||
type Profile,
|
||||
type Review,
|
||||
} from '@cashumints/shared';
|
||||
|
||||
/**
|
||||
* Relays reviews are read from and published to.
|
||||
*
|
||||
* Overridable with PUBLIC_REVIEW_RELAYS (comma separated) at build time, the same way
|
||||
* `profiles.ts` takes PUBLIC_PROFILE_RELAYS. Pointing a dev build at a local relay is
|
||||
* the only way to exercise the publish path without putting test reviews on the
|
||||
* public network.
|
||||
*/
|
||||
export const RELAYS: string[] = (() => {
|
||||
const configured = import.meta.env['PUBLIC_REVIEW_RELAYS'] as string | undefined;
|
||||
const list = configured
|
||||
?.split(',')
|
||||
.map((relay) => relay.trim())
|
||||
.filter(Boolean);
|
||||
return list && list.length > 0 ? list : [...DEFAULT_RELAYS];
|
||||
})();
|
||||
|
||||
export interface LoadedReview extends Review {
|
||||
npub: string;
|
||||
profile: Profile | null;
|
||||
}
|
||||
|
||||
let pool: SimplePool | null = null;
|
||||
function getPool(): SimplePool {
|
||||
pool ??= new SimplePool();
|
||||
return pool;
|
||||
}
|
||||
|
||||
/* ---------- the read cache ---------- */
|
||||
|
||||
/**
|
||||
* How long a mint's reviews stay good for this tab.
|
||||
*
|
||||
* The panel calls `loadReviews` on every arrival, and the router counts a back button
|
||||
* as an arrival, so leaving a mint page and returning to it used to mean another
|
||||
* eight-second-bounded relay query and another screen of skeleton for reviews the tab
|
||||
* already had. Long enough that browsing a few mints and coming back is instant,
|
||||
* short enough that a tab left open all afternoon is not reading yesterday.
|
||||
*
|
||||
* Module scope is deliberately the whole of it: the router never re-executes a module
|
||||
* script, so this survives navigation, and a reload clears it — which is exactly what
|
||||
* someone reloading a page to see if anything changed is asking for.
|
||||
*
|
||||
* What comes back is the cached array itself, not a copy, and the panel does write to
|
||||
* it: it fills in `profile` on each review, which is worth keeping, and it unshifts an
|
||||
* optimistic card after publishing, which is not. The second is why publishing drops
|
||||
* the entry rather than leaving it to expire.
|
||||
*/
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
const cache = new Map<string, { at: number; reviews: LoadedReview[] }>();
|
||||
|
||||
/**
|
||||
* Forget what the relays said about a mint, so the next read asks them again.
|
||||
*
|
||||
* Called after publishing: an author who has just written a review has to see it in
|
||||
* the list, and a cached answer from thirty seconds ago does not contain it.
|
||||
*/
|
||||
export function invalidateReviews(mintUrl: string): void {
|
||||
cache.delete(mintUrl);
|
||||
}
|
||||
|
||||
let publishedAt = 0;
|
||||
|
||||
/**
|
||||
* When this tab last published a review, or 0 if it has not.
|
||||
*
|
||||
* The feed cache in `feed-client.ts` cannot be reached from here without importing
|
||||
* that module onto every mint page, which has no feed on it. So it asks instead: any
|
||||
* feed read before this moment is missing the review just written and is thrown out.
|
||||
*/
|
||||
export function lastPublish(): number {
|
||||
return publishedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch reviews for one mint.
|
||||
*
|
||||
* Two filters, OR'd, matching the old site's read path:
|
||||
* - `#d` = the mint's own pubkey from /v1/info, with `#k` = 38172 (proper NIP-87)
|
||||
* - `#u` = the mint URL in every spelling the old site published
|
||||
* The URL filter is what finds legacy reviews, and dropping it would lose most of them.
|
||||
*/
|
||||
export async function loadReviews(mintUrl: string, mintPubkey: string | null): Promise<LoadedReview[]> {
|
||||
const hit = cache.get(mintUrl);
|
||||
if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.reviews;
|
||||
|
||||
const filters: Record<string, unknown>[] = [];
|
||||
|
||||
if (mintPubkey) {
|
||||
filters.push({
|
||||
kinds: [KIND_REVIEW],
|
||||
'#d': [mintPubkey],
|
||||
'#k': [String(KIND_MINT_ANNOUNCEMENT)],
|
||||
limit: 500,
|
||||
});
|
||||
}
|
||||
|
||||
filters.push({ kinds: [KIND_REVIEW], '#u': mintUrlSpellings(mintUrl), limit: 500 });
|
||||
|
||||
const events = await Promise.all(
|
||||
filters.map((filter) =>
|
||||
getPool()
|
||||
.querySync(RELAYS, filter as never, { maxWait: 8000 })
|
||||
.catch(() => [] as NostrEvent[]),
|
||||
),
|
||||
);
|
||||
|
||||
// One review per author, newest wins: kind 38000 is addressable, so a later event
|
||||
// from the same npub replaces the earlier one.
|
||||
const byAuthor = new Map<string, NostrEvent>();
|
||||
for (const event of events.flat()) {
|
||||
const existing = byAuthor.get(event.pubkey);
|
||||
if (!existing || event.created_at > existing.created_at) byAuthor.set(event.pubkey, event);
|
||||
}
|
||||
|
||||
const reviews = [...byAuthor.values()]
|
||||
.sort((a, b) => b.created_at - a.created_at)
|
||||
.map((event) => ({
|
||||
id: event.id,
|
||||
pubkey: event.pubkey,
|
||||
npub: safeNpub(event.pubkey),
|
||||
created_at: event.created_at,
|
||||
rating: parseRating(event),
|
||||
content: cleanReviewContent(event.content),
|
||||
mint_url: mintUrl,
|
||||
profile: null,
|
||||
}));
|
||||
|
||||
// Nothing back is not the same as no reviews: the pool swallows per relay failures
|
||||
// and resolves empty, which is why the panel treats an empty answer as a relay
|
||||
// error. Caching that would turn its retry button into a no-op for five minutes.
|
||||
if (reviews.length > 0) cache.set(mintUrl, { at: Date.now(), reviews });
|
||||
|
||||
return reviews;
|
||||
}
|
||||
|
||||
/* ---------- profiles ---------- */
|
||||
|
||||
/**
|
||||
* Reviewer names and avatars. The lookup itself lives in `profiles.ts`, which owns the
|
||||
* relay pool, the batching and the cache; re-exported here so the island has one import.
|
||||
*/
|
||||
export { loadProfiles, PROFILE_RELAY_URLS } from './profiles';
|
||||
|
||||
export function safeNpub(pubkey: string): string {
|
||||
try {
|
||||
return nip19.npubEncode(pubkey);
|
||||
} catch {
|
||||
return pubkey;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- publishing ---------- */
|
||||
|
||||
/**
|
||||
* The signer abstraction lives in `nostr-signer.ts`: extension, remote and Primal all
|
||||
* satisfy the same three methods, so this file signs one way and never asks which.
|
||||
* `getExtension` is re-exported because the quick-sign path still needs to know
|
||||
* whether a NIP-07 extension is there before it offers to use one.
|
||||
*/
|
||||
import type { NostrSigner } from './nostr-signer';
|
||||
|
||||
export { getExtension, hasExtension, activeSigner } from './nostr-signer';
|
||||
export type { NostrSigner, SignerMethod } from './nostr-signer';
|
||||
|
||||
export interface PublishResult {
|
||||
event: NostrEvent;
|
||||
/** Relays that took it. */
|
||||
accepted: number;
|
||||
/** Relays that were asked. */
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface PublishOptions {
|
||||
signer: NostrSigner;
|
||||
mintUrl: string;
|
||||
mintPubkey: string | null;
|
||||
rating: number;
|
||||
content: string;
|
||||
/**
|
||||
* Fires the moment the event is signed, before the relay round trips.
|
||||
*
|
||||
* The author's pubkey is only knowable at that point, and the caller uses it to
|
||||
* fetch their kind 0 while the relays are busy, so the card the review lands in
|
||||
* already carries their name instead of showing "Anon" until the next reload.
|
||||
*/
|
||||
onSigned?: (event: NostrEvent) => void;
|
||||
/** Called as each relay answers, so the button can count them off. */
|
||||
onProgress?: (accepted: number, settled: number, total: number) => void;
|
||||
}
|
||||
|
||||
/** A relay that has not answered by now is not going to. */
|
||||
const PUBLISH_TIMEOUT_MS = 10_000;
|
||||
|
||||
/**
|
||||
* Sign and broadcast a review.
|
||||
*
|
||||
* The event is built exactly as the old publisher built it (kind 38000, `[N/5]`
|
||||
* content prefix, k/u/d tags), plus the `rating` and `a` tags its dead code path
|
||||
* intended. Adding those is safe: readers that only understand the content prefix
|
||||
* still parse it, and readers that prefer the tag get a cleaner value.
|
||||
*
|
||||
* One relay accepting is a success: the review is on the network and the others will
|
||||
* see it. How many took it is returned either way, because "published to 2 of 4" is
|
||||
* a different thing to tell the author than "published".
|
||||
*/
|
||||
/** Matches the dialog's own MIN_CHARS / MAX_CHARS, which is what the reader sees. */
|
||||
const MIN_REVIEW_CHARS = 10;
|
||||
const MAX_REVIEW_CHARS = 2000;
|
||||
|
||||
export async function publishReview(options: PublishOptions): Promise<PublishResult> {
|
||||
const { signer, mintUrl, mintPubkey, rating, content, onSigned, onProgress } = options;
|
||||
|
||||
/*
|
||||
* These three are the last line of defence, not the reader's usual path: the dialog
|
||||
* disables its own submit button and says why, in the same language, long before any
|
||||
* of them can fire. They are still translated because "last line of defence" is not
|
||||
* "unreachable", and an English sentence surfacing in a Dutch dialog is exactly the
|
||||
* kind of seam that gets shipped.
|
||||
*/
|
||||
const t = useI18n();
|
||||
if (rating < 1 || rating > 5) throw new Error(t('reviews.validate.rating'));
|
||||
const body = content.trim();
|
||||
if (body.length < MIN_REVIEW_CHARS) {
|
||||
throw new Error(t('reviews.validate.short', { n: MIN_REVIEW_CHARS }));
|
||||
}
|
||||
if (body.length > MAX_REVIEW_CHARS) {
|
||||
throw new Error(t('reviews.validate.long', { n: MAX_REVIEW_CHARS }));
|
||||
}
|
||||
|
||||
const tags: string[][] = [
|
||||
['k', String(KIND_MINT_ANNOUNCEMENT)],
|
||||
['u', mintUrl, 'cashu'],
|
||||
['rating', String(rating)],
|
||||
];
|
||||
if (mintPubkey) {
|
||||
tags.push(['d', mintPubkey]);
|
||||
tags.push(['a', `${KIND_MINT_ANNOUNCEMENT}:${mintPubkey}:${mintPubkey}`, PRIMARY_RELAY]);
|
||||
} else {
|
||||
// Without the mint's own pubkey there is no NIP-33 identifier to use, so fall back
|
||||
// to the mint URL. The `u` tag is what the reader matches on anyway.
|
||||
tags.push(['d', mintUrl]);
|
||||
}
|
||||
|
||||
const signed = (await signer.signEvent({
|
||||
kind: KIND_REVIEW,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags,
|
||||
content: `[${rating}/5] ${body}`,
|
||||
})) as NostrEvent;
|
||||
|
||||
onSigned?.(signed);
|
||||
|
||||
const total = RELAYS.length;
|
||||
let accepted = 0;
|
||||
let settled = 0;
|
||||
onProgress?.(0, 0, total);
|
||||
|
||||
const sends = getPool()
|
||||
.publish(RELAYS, signed)
|
||||
.map((send) =>
|
||||
send.then(
|
||||
() => {
|
||||
accepted++;
|
||||
},
|
||||
() => {
|
||||
// One relay refusing or being unreachable is normal. The count says so.
|
||||
},
|
||||
).finally(() => {
|
||||
settled++;
|
||||
onProgress?.(accepted, settled, total);
|
||||
}),
|
||||
);
|
||||
|
||||
// A dead relay can leave its promise pending for as long as its socket stays open.
|
||||
// The author is not made to watch that: whatever has answered by then is the count.
|
||||
await Promise.race([
|
||||
Promise.allSettled(sends),
|
||||
new Promise((resolve) => window.setTimeout(resolve, PUBLISH_TIMEOUT_MS)),
|
||||
]);
|
||||
|
||||
if (accepted === 0) throw new Error('no-relay');
|
||||
|
||||
// The author's own review has to be there the next time either list is read, and a
|
||||
// cached answer from before they wrote it is not.
|
||||
invalidateReviews(mintUrl);
|
||||
publishedAt = Date.now();
|
||||
|
||||
return { event: signed, accepted, total };
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* JSON-LD builders.
|
||||
*
|
||||
* Pure functions: strings and numbers in, plain objects out. No `t()` calls, because
|
||||
* `check-i18n.mjs` treats every `.ts` under `lib/` as island code and would hold these
|
||||
* to the client-namespace rule; the copy is translated in the `.astro` frontmatter and
|
||||
* handed in already resolved.
|
||||
*
|
||||
* `Base.astro` is the only file that serialises any of this, so a page can contribute
|
||||
* nodes but cannot emit a second `<script type="application/ld+json">`. One graph per
|
||||
* page, one place where `@id`s are assigned, no chance of two blocks disagreeing.
|
||||
*
|
||||
* Nothing here invents a fact. Every property is either a constant about this site or a
|
||||
* value already printed on the page the graph sits on. Structured data that disagrees
|
||||
* with the visible page is worse than none: it teaches a crawler that neither can be
|
||||
* trusted.
|
||||
*
|
||||
* Two absences are deliberate:
|
||||
*
|
||||
* - **No `SearchAction` on `WebSite`.** The search on /mints is an island filtering
|
||||
* markup already in the page. There is no `?q=` URL to hand a crawler, and declaring
|
||||
* one would promise a query endpoint that does not exist.
|
||||
* - **No `Review` nodes.** Review bodies are fetched from Nostr relays in the browser,
|
||||
* so they are not in the prerendered HTML. Marking up text a crawler cannot see in
|
||||
* the page is the same mismatch.
|
||||
*/
|
||||
|
||||
/** A node in the graph. Loose by design: schema.org shapes vary per type. */
|
||||
export interface SchemaNode {
|
||||
'@type': string | string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `<script>` body, and the only place graph objects become HTML.
|
||||
*
|
||||
* Mint names, descriptions and MOTDs are written by whoever runs the mint, so they are
|
||||
* untrusted text that reaches this function. Every escape below is a valid JSON
|
||||
* `\uXXXX` sequence, so the value a parser reads back is identical to the value that
|
||||
* went in; what changes is that none of it can terminate the script element:
|
||||
*
|
||||
* `<` a literal `</script` or `<!--` would end the block early
|
||||
* `>` closes off `-->` and `]]>` for parsers that honour them
|
||||
* `&` stops an entity-encoded `</script>` being re-parsed into the above
|
||||
*
|
||||
* U+2028 and U+2029 are legal in JSON strings but are line terminators to a JavaScript
|
||||
* parser, which matters because this is a script element.
|
||||
*
|
||||
* `Base.astro` already uses the same `<` trick for the inline i18n payload.
|
||||
*/
|
||||
export function serializeGraph(nodes: SchemaNode[]): string {
|
||||
return JSON.stringify({ '@context': 'https://schema.org', '@graph': nodes })
|
||||
.replace(/</g, '\\u003c')
|
||||
.replace(/>/g, '\\u003e')
|
||||
.replace(/&/g, '\\u0026')
|
||||
.replace(/\u2028/g, '\\u2028')
|
||||
.replace(/\u2029/g, '\\u2029');
|
||||
}
|
||||
|
||||
/**
|
||||
* Text destined for a schema field: control characters stripped, whitespace collapsed,
|
||||
* capped, and `undefined` when there is nothing left. `JSON.stringify` drops undefined
|
||||
* keys, so an absent value disappears from the output instead of arriving as `""`.
|
||||
*/
|
||||
function text(value: string | null | undefined, max: number): string | undefined {
|
||||
if (!value) return undefined;
|
||||
const clean = value
|
||||
.replace(/[\u0000-\u001f\u007f]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
if (clean === '') return undefined;
|
||||
return clean.length > max ? clean.slice(0, max) : clean;
|
||||
}
|
||||
|
||||
/** A URL the site did not construct itself: parsed and omitted rather than trusted. */
|
||||
function safeUrl(value: string | null | undefined): string | undefined {
|
||||
if (!value) return undefined;
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
return parsed.protocol === 'https:' || parsed.protocol === 'http:' ? parsed.href : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export const websiteId = (origin: string): string => `${origin}/#website`;
|
||||
export const orgId = (origin: string): string => `${origin}/#organization`;
|
||||
|
||||
/**
|
||||
* The publication and the people behind it, as two nodes rather than one.
|
||||
*
|
||||
* They are different things: the site is a publication, Azzamo is who runs it. The
|
||||
* `publisher` reference ties them by `@id` so a consumer reads one graph instead of two
|
||||
* unrelated claims.
|
||||
*/
|
||||
export function siteNodes(o: {
|
||||
origin: string;
|
||||
name: string;
|
||||
description: string;
|
||||
locale: string;
|
||||
logo: string;
|
||||
sameAs: string[];
|
||||
}): SchemaNode[] {
|
||||
return [
|
||||
{
|
||||
'@type': 'Organization',
|
||||
'@id': orgId(o.origin),
|
||||
name: o.name,
|
||||
url: `${o.origin}/`,
|
||||
description: text(o.description, 300),
|
||||
logo: { '@type': 'ImageObject', url: o.logo, width: 512, height: 512 },
|
||||
sameAs: o.sameAs,
|
||||
},
|
||||
{
|
||||
'@type': 'WebSite',
|
||||
'@id': websiteId(o.origin),
|
||||
name: o.name,
|
||||
url: `${o.origin}/`,
|
||||
inLanguage: o.locale,
|
||||
publisher: { '@id': orgId(o.origin) },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** The page itself. Emitted on every indexable page. */
|
||||
export function webPageNode(o: {
|
||||
origin: string;
|
||||
canonical: string;
|
||||
title: string;
|
||||
description: string;
|
||||
locale: string;
|
||||
image: string;
|
||||
}): SchemaNode {
|
||||
return {
|
||||
'@type': 'WebPage',
|
||||
'@id': o.canonical,
|
||||
url: o.canonical,
|
||||
name: text(o.title, 160),
|
||||
description: text(o.description, 300),
|
||||
inLanguage: o.locale,
|
||||
isPartOf: { '@id': websiteId(o.origin) },
|
||||
primaryImageOfPage: { '@type': 'ImageObject', url: o.image },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A breadcrumb trail, mirroring the one the page renders.
|
||||
*
|
||||
* The last crumb is the current page and carries no `item`: it is where you already
|
||||
* are. The trail must match the visible `<nav class="crumbs">` link for link, or the
|
||||
* markup describes navigation that is not on screen.
|
||||
*/
|
||||
export function breadcrumbNode(
|
||||
canonical: string,
|
||||
crumbs: Array<{ name: string; url?: string }>,
|
||||
): SchemaNode {
|
||||
return {
|
||||
'@type': 'BreadcrumbList',
|
||||
'@id': `${canonical}#breadcrumb`,
|
||||
itemListElement: crumbs.map((crumb, index) => ({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
name: text(crumb.name, 120),
|
||||
...(crumb.url ? { item: crumb.url } : {}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* An ordered list of pages, for /mints and /wallets.
|
||||
*
|
||||
* Names and URLs, not embedded entities: the list page's job is to say what it links to
|
||||
* and in what order. Each linked page describes itself.
|
||||
*/
|
||||
export function itemListNode(o: {
|
||||
canonical: string;
|
||||
fragment: string;
|
||||
name: string;
|
||||
items: Array<{ name: string; url: string }>;
|
||||
}): SchemaNode {
|
||||
return {
|
||||
'@type': 'ItemList',
|
||||
'@id': `${o.canonical}#${o.fragment}`,
|
||||
name: text(o.name, 120),
|
||||
numberOfItems: o.items.length,
|
||||
itemListElement: o.items.map((item, index) => ({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
name: text(item.name, 120),
|
||||
url: item.url,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A mint, as the thing its page is about.
|
||||
*
|
||||
* `Service` with an explicit `serviceType`, which is what a Cashu mint is: something a
|
||||
* third party runs and you can use. Not a `Product` (nothing is sold here), not an
|
||||
* `Organization` (the operator is usually anonymous and this site knows nothing about
|
||||
* them beyond a URL), not a `SoftwareApplication` (the software is named in `version`,
|
||||
* but this page is about one deployment, not about cdk-mintd in general).
|
||||
*
|
||||
* `Service` is not on Google's review-snippet list, so this will not draw stars in a
|
||||
* search result. That is the accepted trade: `Product` would be eligible and would also
|
||||
* be a lie about what a custodial mint is, on a site whose whole premise is not
|
||||
* overclaiming.
|
||||
*/
|
||||
export function mintServiceNode(o: {
|
||||
canonical: string;
|
||||
name: string;
|
||||
description: string;
|
||||
mintUrl: string;
|
||||
icon?: string | null;
|
||||
rating: { value: number; ratingCount: number; reviewCount: number } | null;
|
||||
}): SchemaNode {
|
||||
const node: SchemaNode = {
|
||||
'@type': 'Service',
|
||||
'@id': `${o.canonical}#mint`,
|
||||
name: text(o.name, 120),
|
||||
description: text(o.description, 300),
|
||||
serviceType: 'Cashu ecash mint',
|
||||
url: safeUrl(o.mintUrl),
|
||||
mainEntityOfPage: { '@id': o.canonical },
|
||||
};
|
||||
|
||||
const image = safeUrl(o.icon);
|
||||
if (image) node['image'] = image;
|
||||
|
||||
/*
|
||||
* Ratings are 1 to 5, and the average covers only the reviews whose event carried a
|
||||
* parseable rating. The page says as much ("N reviews, M without a rating") and the
|
||||
* two counts here keep that distinction: `ratingCount` is what produced the average,
|
||||
* `reviewCount` is how many reviews exist. Collapsing them would claim an average
|
||||
* over reviews that were never averaged.
|
||||
*
|
||||
* Both bounds are stated because the scale is 1 to 5; a consumer assuming 0 to 5
|
||||
* renders the same number as a different score.
|
||||
*/
|
||||
if (o.rating) {
|
||||
node['aggregateRating'] = {
|
||||
'@type': 'AggregateRating',
|
||||
ratingValue: o.rating.value,
|
||||
bestRating: 5,
|
||||
worstRating: 1,
|
||||
ratingCount: o.rating.ratingCount,
|
||||
reviewCount: o.rating.reviewCount,
|
||||
};
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* The two indexing judgements the site makes, in one place.
|
||||
*
|
||||
* Both are imported by more than one file on purpose. `isIndexableMint` decides a mint
|
||||
* page's `robots` meta *and* whether the sitemap lists it, and those two must agree:
|
||||
* a sitemap entry for a page that says `noindex` is a contradiction a crawler notices.
|
||||
* `check-hreflang.mjs` verifies the agreement from the built HTML, so the invariant is
|
||||
* enforced rather than merely intended.
|
||||
*
|
||||
* Pure functions, no `t()`: `check-i18n.mjs` treats every `.ts` under `lib/` as island
|
||||
* code and would hold any key here to the client-namespace rule.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Is this mint's page worth a place in the index?
|
||||
*
|
||||
* No, in exactly one case: the site has never once reached it *and* nobody has ever
|
||||
* reviewed it. Such a page has no operator name, no description, no version, no NUT
|
||||
* list and no reviews, because every one of those comes from a mint that answered or a
|
||||
* person who wrote something. What is left is the site's own furniture and a URL, which
|
||||
* is the same page as the next never-reached mint's.
|
||||
*
|
||||
* It stays listed on /mints, stays linked, stays searchable on the site and stays
|
||||
* reviewable — the rule that offline mints must remain findable is about this site's
|
||||
* own listings, and nothing here changes them. Only search engines skip it, and only
|
||||
* until the mint answers once or someone reviews it, at which point the next build
|
||||
* puts it back.
|
||||
*/
|
||||
export function isIndexableMint(mint: {
|
||||
last_online: number | null;
|
||||
review_count: number;
|
||||
}): boolean {
|
||||
return !(mint.last_online === null && mint.review_count === 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Names that say nothing about which mint they belong to.
|
||||
*
|
||||
* Anchored, so a mint genuinely called "Bolverker Mint" or "Mint Cuba Bitcoin" is left
|
||||
* alone; only a name that is *entirely* a generic noun matches. Two mints currently
|
||||
* publish `name: "Cashu mint"`, which produced two identical `<title>`s competing with
|
||||
* each other for the same query.
|
||||
*/
|
||||
const GENERIC_NAME = /^(cashu[\s-]?mint|mint|cashu|ecash[\s-]?mint|test(\s?mint)?)$/i;
|
||||
|
||||
/** Names published by more than one mint, lowercased. Built once from the full list. */
|
||||
export function nameCollisions(mints: Array<{ name: string | null }>): Set<string> {
|
||||
const seen = new Map<string, number>();
|
||||
for (const mint of mints) {
|
||||
const key = mint.name?.trim().toLowerCase();
|
||||
if (!key) continue;
|
||||
seen.set(key, (seen.get(key) ?? 0) + 1);
|
||||
}
|
||||
return new Set([...seen].filter(([, count]) => count > 1).map(([key]) => key));
|
||||
}
|
||||
|
||||
/**
|
||||
* The name a `<title>` should use for a mint.
|
||||
*
|
||||
* The operator's own name, unless it identifies nothing — either because it is missing,
|
||||
* or because it is a generic noun, or because another mint publishes the same one. In
|
||||
* those cases the domain does the identifying, since that is what actually tells two
|
||||
* mints apart.
|
||||
*
|
||||
* This is for the title and the description only. The `<h1>` keeps the operator's name
|
||||
* verbatim: the heading is the mint's name for itself, and rewriting it would be
|
||||
* putting words in their mouth. The title is this site's label for its own page.
|
||||
*/
|
||||
export function seoName(
|
||||
name: string | null,
|
||||
domain: string,
|
||||
collisions: Set<string>,
|
||||
): string {
|
||||
const raw = name?.trim() ?? '';
|
||||
if (raw === '' || GENERIC_NAME.test(raw)) return domain;
|
||||
if (collisions.has(raw.toLowerCase())) return `${raw} (${domain})`;
|
||||
return raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trim to a length without cutting a word in half.
|
||||
*
|
||||
* Meta descriptions are truncated by the search engine anyway, but a description stored
|
||||
* ending mid-word reads as broken in every other consumer of it (link previews, feed
|
||||
* readers) where nothing re-truncates.
|
||||
*/
|
||||
export function clampWords(value: string, max: number): string {
|
||||
if (value.length <= max) return value;
|
||||
const cut = value.slice(0, max);
|
||||
const lastSpace = cut.lastIndexOf(' ');
|
||||
return (lastSpace > max * 0.6 ? cut.slice(0, lastSpace) : cut).trimEnd();
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Who this site is, in one place.
|
||||
*
|
||||
* These four values were previously spelled out again in every file that needed them
|
||||
* (the GitHub URL alone appeared in four). They are also the values the `Organization`
|
||||
* and `WebSite` JSON-LD is built from, and a schema that disagrees with the footer it
|
||||
* sits under is worse than no schema at all, so they live here and both read from it.
|
||||
*/
|
||||
|
||||
/** The name in the topbar, the footer, `og:site_name` and `Organization.name`. */
|
||||
export const SITE_NAME = 'Cashumints.space';
|
||||
|
||||
/** The repository. Linked from the footer, /about, /terms and /privacy. */
|
||||
export const GITHUB_URL = 'https://github.com/Azzamo-net/cashumints.space';
|
||||
|
||||
/** Who makes it. Credited in the footer and on /about. */
|
||||
export const AZZAMO_URL = 'https://azzamo.net';
|
||||
|
||||
/**
|
||||
* The default social card, served from `public/`.
|
||||
*
|
||||
* Relative on purpose: `Base.astro` resolves it against `Astro.site`, because an
|
||||
* `og:image` that is not absolute is not a weaker signal, it is an ignored one.
|
||||
*/
|
||||
export const OG_IMAGE = '/og.png';
|
||||
export const OG_IMAGE_WIDTH = 1200;
|
||||
export const OG_IMAGE_HEIGHT = 630;
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Build-time stand-in content for `pnpm bones`.
|
||||
*
|
||||
* Boneyard captures bone geometry from whatever is on screen when its headless
|
||||
* browser looks, and both skeletoned regions are empty at that moment: reviews come
|
||||
* from relays that may be slow or empty, and the prerender-miss page has no mint
|
||||
* until the API answers. These fixtures give the capture something typical to measure.
|
||||
*
|
||||
* They are data, not markup, and are fed through the same render functions the real
|
||||
* content uses. A change to a review card's layout therefore changes the fixture's
|
||||
* layout too, so re-running `pnpm bones` is all that is ever needed to resync.
|
||||
*
|
||||
* Timestamps are relative to now so a capture taken today and one taken next month
|
||||
* produce the same "3d ago" strings, and so the same bones.
|
||||
*/
|
||||
import type { MintDetail } from '@cashumints/shared';
|
||||
import type { LoadedReview } from './reviews-client';
|
||||
|
||||
const daysAgo = (days: number): number => Math.floor(Date.now() / 1000) - days * 86400;
|
||||
|
||||
/**
|
||||
* Three reviews of typical shape: a full display name, a handle, and a longer
|
||||
* critical review. Body lengths sit around the median of what relays actually
|
||||
* return for a busy mint.
|
||||
*
|
||||
* None carries a nip05 on purpose. Boneyard bones elements, not text nodes, so a
|
||||
* `.rev-name` holding both a bare name and a `.nip05` span yields a bone for the
|
||||
* span alone and none for the name, which puts one card's name bone in a different
|
||||
* place from the other two. Most reviewers have no nip05 anyway.
|
||||
*/
|
||||
export const FIXTURE_REVIEWS: LoadedReview[] = [
|
||||
{
|
||||
id: 'fixture-1',
|
||||
pubkey: '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d',
|
||||
npub: 'npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6',
|
||||
created_at: daysAgo(3),
|
||||
rating: 5,
|
||||
content:
|
||||
'Minting and melting have both been instant for me over the last two months. Support answered on Nostr within the hour when I asked about a stuck quote.',
|
||||
mint_url: 'https://mint.example.com',
|
||||
profile: {
|
||||
pubkey: '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d',
|
||||
name: 'satoshiflower',
|
||||
display_name: 'Satoshi Flower',
|
||||
found: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'fixture-2',
|
||||
pubkey: '82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2',
|
||||
npub: 'npub1sg6plzptd64u62a878hep2kev88swjh3tw00gjsfl8f237lmu63q0uf63m',
|
||||
created_at: daysAgo(11),
|
||||
rating: 4,
|
||||
content:
|
||||
'Reliable for small amounts and the fees are fair. Melting to Lightning failed twice during a busy evening, both retried fine an hour later.',
|
||||
mint_url: 'https://mint.example.com',
|
||||
profile: {
|
||||
pubkey: '82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2',
|
||||
display_name: 'nutcracker',
|
||||
found: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'fixture-3',
|
||||
pubkey: 'd0debf9fb12def81f43d7c69429bb784812ac1e4d2d53a202db6aac7ea4b466c',
|
||||
npub: 'npub16r0tln7cjaa7q73a03d99xahpypz4s0y6t2n5gpdk64v06jtgeksyfnrmn',
|
||||
created_at: daysAgo(27),
|
||||
rating: 2,
|
||||
content:
|
||||
'Was fine for months, then withdrawals started timing out and the operator stopped answering. Moved the rest of my balance out and would not put more back until that changes.',
|
||||
mint_url: 'https://mint.example.com',
|
||||
profile: {
|
||||
pubkey: 'd0debf9fb12def81f43d7c69429bb784812ac1e4d2d53a202db6aac7ea4b466c',
|
||||
name: 'plebcustodian',
|
||||
found: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* One representative mint for the prerender-miss page, with the fields that page
|
||||
* actually renders: name, URL, status, rating, review count, software and last seen.
|
||||
*/
|
||||
export const FIXTURE_MINT: MintDetail = {
|
||||
url: 'https://mint.example.com',
|
||||
host: 'mint.example.com',
|
||||
name: 'Example Mint',
|
||||
icon: null,
|
||||
status: 'online',
|
||||
last_online: daysAgo(0),
|
||||
review_count: 128,
|
||||
rating_avg: 4.6,
|
||||
score: 4.51,
|
||||
last_review_at: daysAgo(3),
|
||||
version: 'Nutshell/0.18.1',
|
||||
description: 'A Cashu mint run for the example community.',
|
||||
pubkey: '0296d0aa13b6a31cf0cd974249f4c6ed579061a4705ab9a4c1b6b1e1e4d7f6f9',
|
||||
info: null,
|
||||
nuts: ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12'],
|
||||
first_seen: daysAgo(420),
|
||||
last_probe: daysAgo(0),
|
||||
updated_at: daysAgo(0),
|
||||
rating_distribution: { '1': 4, '2': 6, '3': 9, '4': 27, '5': 82 },
|
||||
reviews_90d: 19,
|
||||
uptime_30d: 0.994,
|
||||
probes_recent: [],
|
||||
};
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Boneyard bridge for our vanilla islands.
|
||||
*
|
||||
* Boneyard's ergonomics (shimmer, stagger, fade, dark colours) live only in its
|
||||
* React/Preact/Vue components. The core `renderBones` we can call from vanilla TS
|
||||
* draws pulse-only bones in one flat colour and knows nothing about
|
||||
* `prefers-reduced-motion`, so the animation, the stagger and the cross-fade below
|
||||
* are ours, driven by the `.by-*` rules in global.css. What Boneyard does give us is
|
||||
* the part worth having: bone geometry captured from the real rendered cards at
|
||||
* three widths, so the bones match the content that replaces them.
|
||||
*
|
||||
* Bones are only ever drawn over content that does not exist yet. Anything already
|
||||
* in the prerendered HTML stays on screen (FRONTEND.md).
|
||||
*/
|
||||
import { renderBones } from 'boneyard-js';
|
||||
import type { ResponsiveBones, SkeletonResult } from 'boneyard-js';
|
||||
import { enterStagger, prefersReducedMotion } from '../scripts/reveal';
|
||||
|
||||
/** --text at 5% on our dark surfaces (DESIGN.md). */
|
||||
export const BONE_COLOR = 'rgba(237, 232, 220, 0.05)';
|
||||
|
||||
/** Delay between staggered bone groups, matching Boneyard's own `stagger: true`. */
|
||||
const STAGGER_MS = 80;
|
||||
|
||||
/** How long the bones take to fade off the top of the content that replaced them. */
|
||||
const FADE_MS = 150;
|
||||
|
||||
/**
|
||||
* Honour the reader's motion preference. Boneyard ships no `prefers-reduced-motion`
|
||||
* handling of its own (verified against boneyard-js 1.9.0: the string appears nowhere
|
||||
* in the package), so every animation we add is gated here and again in CSS.
|
||||
*/
|
||||
export function reducedMotion(): boolean {
|
||||
return prefersReducedMotion();
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the capture whose breakpoint is closest below the current viewport width.
|
||||
* Mirrors Boneyard's own resolver, which is not exported from the core entry.
|
||||
*/
|
||||
function pickBreakpoint(bones: ResponsiveBones, width: number): SkeletonResult | null {
|
||||
const widths = Object.keys(bones.breakpoints)
|
||||
.map(Number)
|
||||
.filter((n) => !Number.isNaN(n))
|
||||
.sort((a, b) => a - b);
|
||||
if (widths.length === 0) return null;
|
||||
|
||||
const match = [...widths].reverse().find((bp) => width >= bp) ?? widths[0]!;
|
||||
return bones.breakpoints[match] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delay each bone by which row of the list it falls in, so the skeleton reads top to
|
||||
* bottom instead of every bone pulsing at once. Rows are found by vertical position:
|
||||
* the bones are a flat list with no notion of the cards they came from.
|
||||
*/
|
||||
function staggerBones(wrap: HTMLElement, groups: number): void {
|
||||
const bones = [...wrap.querySelectorAll<HTMLElement>('.boneyard-bone')];
|
||||
if (bones.length === 0) return;
|
||||
|
||||
const tops = bones.map((bone) => Number.parseFloat(bone.style.top)).filter((n) => !Number.isNaN(n));
|
||||
const height = Math.max(...tops, 0) + 1;
|
||||
const band = height / groups;
|
||||
|
||||
for (const bone of bones) {
|
||||
const top = Number.parseFloat(bone.style.top);
|
||||
if (Number.isNaN(top)) continue;
|
||||
bone.style.animationDelay = `${Math.min(groups - 1, Math.floor(top / band)) * STAGGER_MS}ms`;
|
||||
}
|
||||
}
|
||||
|
||||
export interface BoneOptions {
|
||||
/**
|
||||
* Number of repeated rows in the capture. Bones are staggered by which row they
|
||||
* fall in, so a list reads top to bottom instead of every bone pulsing at once.
|
||||
*/
|
||||
groups?: number;
|
||||
/** Accessible description of what is loading, announced once. */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw bones into `host` and return the element holding them.
|
||||
*
|
||||
* `renderBones` is called with `animate: false` so Boneyard does not inject its own
|
||||
* pulse keyframes. It still writes each bone's captured border radius inline, which
|
||||
* is what keeps bone corners matching the real cards and text lines.
|
||||
*/
|
||||
export function showBones(
|
||||
host: HTMLElement,
|
||||
bones: ResponsiveBones,
|
||||
options: BoneOptions = {},
|
||||
): HTMLElement | null {
|
||||
const skeleton = pickBreakpoint(bones, window.innerWidth);
|
||||
if (!skeleton) return null;
|
||||
|
||||
const still = reducedMotion();
|
||||
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = still ? 'by-bones' : 'by-bones by-shimmer';
|
||||
wrap.innerHTML = renderBones(skeleton, BONE_COLOR, false);
|
||||
|
||||
// Bones are decoration. Screen readers get the status line instead.
|
||||
wrap.setAttribute('aria-hidden', 'true');
|
||||
|
||||
if (!still && options.groups && options.groups > 1) staggerBones(wrap, options.groups);
|
||||
|
||||
host.classList.add('by-host');
|
||||
host.replaceChildren(wrap);
|
||||
|
||||
if (options.label) {
|
||||
const status = document.createElement('p');
|
||||
status.className = 'sr-only';
|
||||
status.setAttribute('role', 'status');
|
||||
status.textContent = options.label;
|
||||
host.append(status);
|
||||
}
|
||||
|
||||
return wrap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Take over bones that are already in the HTML.
|
||||
*
|
||||
* The reviews panel prerenders its own bones so the skeleton is up at first paint,
|
||||
* long before this island's chunk arrives. There is nothing to draw by then, only the
|
||||
* stagger to apply, which cannot be prerendered because it depends on which
|
||||
* breakpoint block the media query actually chose.
|
||||
*/
|
||||
export function adoptBones(host: HTMLElement, groups?: number): HTMLElement | null {
|
||||
const wrap = host.querySelector<HTMLElement>('.by-bones');
|
||||
if (!wrap) return null;
|
||||
|
||||
host.classList.add('by-host');
|
||||
if (!reducedMotion() && groups && groups > 1) staggerBones(wrap, groups);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace bones with real content.
|
||||
*
|
||||
* The bones are pulled out of flow first, so the real content takes the space at
|
||||
* once and the bones fade out over the top of it (150ms). The content arrives on the
|
||||
* site's ordinary entrance, staggered row by row, so a skeleton handing over to a
|
||||
* list looks like the same system as a list scrolling into view. Nothing waits on
|
||||
* either: the reviews are readable from the first frame.
|
||||
*
|
||||
* The rows are built in a detached element and moved in already assembled, because a
|
||||
* node moved after its animation has started would restart it.
|
||||
*/
|
||||
export function swapBones(host: HTMLElement, fill: (target: HTMLElement) => void): void {
|
||||
const wrap = host.querySelector<HTMLElement>('.by-bones');
|
||||
|
||||
if (!wrap || reducedMotion()) {
|
||||
host.classList.remove('by-host');
|
||||
host.replaceChildren();
|
||||
fill(host);
|
||||
return;
|
||||
}
|
||||
|
||||
wrap.classList.add('by-out');
|
||||
wrap.style.position = 'absolute';
|
||||
wrap.style.inset = '0';
|
||||
|
||||
// Drop the screen reader status line, the real content speaks for itself now.
|
||||
for (const status of host.querySelectorAll('p.sr-only[role="status"]')) status.remove();
|
||||
|
||||
const staging = document.createElement('div');
|
||||
fill(staging);
|
||||
const rows = [...staging.children] as HTMLElement[];
|
||||
host.append(...staging.childNodes);
|
||||
enterStagger(rows);
|
||||
|
||||
window.setTimeout(() => {
|
||||
wrap.remove();
|
||||
host.classList.remove('by-host');
|
||||
}, FADE_MS);
|
||||
}
|
||||
|
||||
/** Clear bones without a fade, for the error path. */
|
||||
export function clearBones(host: HTMLElement): void {
|
||||
host.classList.remove('by-host');
|
||||
host.replaceChildren();
|
||||
}
|
||||
|
||||
/* ---------- build mode ---------- */
|
||||
|
||||
interface BuildWindow {
|
||||
__BONEYARD_BUILD?: boolean;
|
||||
__BONEYARD_SNAPSHOT?: unknown;
|
||||
}
|
||||
|
||||
/** True while `pnpm bones` is driving the page in a headless browser. */
|
||||
export function isBuildMode(): boolean {
|
||||
return (window as BuildWindow).__BONEYARD_BUILD === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand Boneyard's CLI the snapshot function it expects on `window`.
|
||||
*
|
||||
* The `<Skeleton>` components do this on mount; with no component there is nobody to
|
||||
* do it, so the islands call this instead. The import is dynamic so the extraction
|
||||
* code (and the layout engine it pulls in) never reaches a real visitor's bundle.
|
||||
*/
|
||||
export async function armBuildSnapshot(): Promise<void> {
|
||||
if (!isBuildMode()) return;
|
||||
const { snapshotBones } = await import('boneyard-js');
|
||||
(window as BuildWindow).__BONEYARD_SNAPSHOT = snapshotBones;
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
---
|
||||
import Base from '../../layouts/Base.astro';
|
||||
import Moai from '../../components/Moai.astro';
|
||||
import { localePath, useI18n, type Locale } from '../../i18n';
|
||||
import { localePaths } from '../../i18n/paths';
|
||||
|
||||
/**
|
||||
* Also the fallback for a mint discovered between builds.
|
||||
*
|
||||
* A new mint's page does not exist until the next rebuild, so on a /mint/{host} miss
|
||||
* this asks the API for that host and renders a page shell from it. Not as complete
|
||||
* as the prerendered page, but the mint is reachable and reviewable immediately
|
||||
* instead of invisible for a day (FRONTEND.md).
|
||||
*
|
||||
* Emitted per locale: `/404`, `/es/404`, `/nl/404`. A static host serves the one at the
|
||||
* root for every miss unless it is told otherwise, so the README's nginx block adds an
|
||||
* `error_page` per prefix. That is what keeps a miss under /es/ from answering in
|
||||
* English.
|
||||
*/
|
||||
export const getStaticPaths = localePaths;
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
}
|
||||
const { locale } = Astro.props;
|
||||
|
||||
const t = useI18n(locale);
|
||||
---
|
||||
|
||||
<Base
|
||||
title={t('notfound.title')}
|
||||
description={t('notfound.description')}
|
||||
current="mints"
|
||||
offGraph
|
||||
>
|
||||
<div class="page">
|
||||
<div class="notfound" data-notfound>
|
||||
{/* 44px, not a hero graphic: DESIGN.md says the moai is never scaled large. */}
|
||||
<span class="nf-moai"><Moai size={44} /></span>
|
||||
<h1>{t('notfound.heading')}</h1>
|
||||
<p class="lede">{t('notfound.lede')}</p>
|
||||
|
||||
<form class="search nf-search" action={localePath('/mints', locale)} method="get" role="search">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" />
|
||||
</svg>
|
||||
<label class="sr-only" for="nf-search">{t('home.search.label')}</label>
|
||||
<input
|
||||
id="nf-search"
|
||||
name="q"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
placeholder={t('home.search.placeholder')}
|
||||
data-search-input
|
||||
/>
|
||||
<kbd aria-hidden="true">/</kbd>
|
||||
</form>
|
||||
|
||||
<div class="nf-actions">
|
||||
<a class="btn primary" href={localePath('/mints', locale)}>{t('notfound.browse')}</a>
|
||||
<a class="btn" href={localePath('/', locale)}>{t('notfound.home')}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
Filled in by the fallback below when the URL looks like a mint page. Nothing
|
||||
here is prerendered, so this is the one place on the site that gets a page
|
||||
level skeleton. `data-boneyard` marks the capture target for `pnpm bones`.
|
||||
*/}
|
||||
<div class="mint-fallback" data-mint-fallback hidden>
|
||||
<div data-boneyard="mint-page" data-boneyard-config='{"excludeSelectors":[".sr-only"]}'><div data-mint-fallback-body></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.notfound { padding: 54px 0 20px; max-width: 620px; }
|
||||
.nf-moai { display: block; margin-bottom: 18px; }
|
||||
.notfound h1 { font-family: var(--display); font-size: 34px; font-weight: 700; letter-spacing: -.02em; }
|
||||
.nf-actions { display: flex; gap: 12px; margin-top: 22px; flex-wrap: wrap; }
|
||||
|
||||
/* Same search control as the home page hero, so a miss lands somewhere useful. */
|
||||
.nf-search {
|
||||
display: flex; align-items: center; gap: 12px; margin-top: 24px;
|
||||
background: var(--card); border: 1px solid var(--line);
|
||||
border-radius: 14px; padding: 14px 16px;
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
}
|
||||
.nf-search:focus-within { border-color: var(--amber-dim); box-shadow: 0 0 0 3px rgba(240, 169, 59, .08); }
|
||||
.nf-search > svg { color: var(--faint); flex-shrink: 0; }
|
||||
.nf-search input {
|
||||
flex: 1; background: none; border: none; outline: none; min-width: 0;
|
||||
color: var(--text); font-family: var(--body); font-size: 15.5px;
|
||||
}
|
||||
.nf-search input::placeholder { color: var(--faint); }
|
||||
.nf-search kbd {
|
||||
font-family: var(--mono); font-size: 11.5px; color: var(--faint);
|
||||
border: 1px solid var(--line); border-radius: 6px; padding: 2px 7px; flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* The .fb-* rules live in global.css: this page builds that markup with
|
||||
innerHTML, and Astro's scoped selectors do not reach injected elements. */
|
||||
.mint-fallback { padding: 20px 0 40px; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import { apiBase, escapeHtml, iconGradient } from '../../lib/client';
|
||||
import { armBuildSnapshot, clearBones, isBuildMode, showBones, swapBones } from '../../lib/skeleton';
|
||||
import { useI18n } from '../../i18n/client';
|
||||
import { formatters, starString, type Formatters } from '../../i18n/format';
|
||||
import { statusLabel } from '../../i18n/mint';
|
||||
import { splitLocale } from '../../i18n/routing';
|
||||
import { onReady } from '../../scripts/reveal';
|
||||
import mintPageBones from '../../bones/mint-page.bones.json';
|
||||
import type { MintDetail } from '@cashumints/shared';
|
||||
|
||||
/**
|
||||
* The summary this page can build from the API alone. `pnpm bones` measures the
|
||||
* output of this same function, so the bones are the shape of what actually
|
||||
* arrives rather than of a fuller page that never does.
|
||||
*/
|
||||
function fallbackHtml(mint: MintDetail, f: Formatters): string {
|
||||
const domain = mint.url.replace(/^https?:\/\//, '');
|
||||
const name = mint.name ?? domain.split('/')[0] ?? domain;
|
||||
|
||||
return `
|
||||
<div class="fb-head">
|
||||
<span class="mc-icon" style="background:${iconGradient(domain)};width:52px;height:52px;border-radius:13px;font-size:21px">${escapeHtml(
|
||||
(name[0] ?? '?').toUpperCase(),
|
||||
)}</span>
|
||||
<span>
|
||||
<span class="fb-name">${escapeHtml(name)}</span>
|
||||
<span class="fb-domain">${escapeHtml(mint.url)}</span>
|
||||
</span>
|
||||
<span class="status-chip ${escapeHtml(mint.status)}"><span class="status-dot"></span>${escapeHtml(
|
||||
statusLabel(mint.status, f.t),
|
||||
)}</span>
|
||||
</div>
|
||||
<div class="fb-stats">
|
||||
<span class="fb-stat"><span class="k">${escapeHtml(
|
||||
f.t('notfound.fb.rating'),
|
||||
)}</span><span class="v">${
|
||||
mint.rating_avg === null ? f.t('time.none') : f.decimal(mint.rating_avg)
|
||||
} <span class="stars" style="font-size:14px">${starString(mint.rating_avg)}</span></span></span>
|
||||
<span class="fb-stat"><span class="k">${escapeHtml(
|
||||
f.t('notfound.fb.reviews'),
|
||||
)}</span><span class="v">${f.number(mint.review_count)}</span></span>
|
||||
<span class="fb-stat"><span class="k">${escapeHtml(
|
||||
f.t('notfound.fb.software'),
|
||||
)}</span><span class="v" style="font-family:var(--mono);font-size:16px">${escapeHtml(
|
||||
mint.version ?? f.t('mint.software.unknown'),
|
||||
)}</span></span>
|
||||
<span class="fb-stat"><span class="k">${escapeHtml(
|
||||
f.t('notfound.fb.lastOnline'),
|
||||
)}</span><span class="v" style="font-size:16px">${escapeHtml(
|
||||
f.relative(mint.last_online),
|
||||
)}</span></span>
|
||||
</div>
|
||||
<p class="fb-note">${escapeHtml(f.t('notfound.fb.note'))}</p>`;
|
||||
}
|
||||
|
||||
const setup = (): void => {
|
||||
const f = formatters(useI18n());
|
||||
|
||||
/*
|
||||
* The miss could be at /mint/x or at /es/mint/x, and both resolve to the same mint.
|
||||
* The prefix comes off with the same helper the build routes with, so the two can
|
||||
* never disagree about what counts as a locale segment.
|
||||
*/
|
||||
const { path } = splitLocale(window.location.pathname);
|
||||
const match = /^\/mint\/([^/]+)\/?$/.exec(path);
|
||||
const box = document.querySelector<HTMLElement>('[data-mint-fallback]');
|
||||
const body = document.querySelector<HTMLElement>('[data-mint-fallback-body]');
|
||||
const notfound = document.querySelector<HTMLElement>('[data-notfound]');
|
||||
|
||||
if (body && box && isBuildMode()) {
|
||||
// `pnpm bones` is measuring this page. Show a representative mint so the
|
||||
// capture has a real summary to measure instead of an empty box.
|
||||
// Imported dynamically so the fixture never reaches a real visitor.
|
||||
void armBuildSnapshot()
|
||||
.then(() => import('../../lib/skeleton-fixtures'))
|
||||
.then(({ FIXTURE_MINT }) => {
|
||||
if (notfound) notfound.hidden = true;
|
||||
box.hidden = false;
|
||||
body.innerHTML = fallbackHtml(FIXTURE_MINT, f);
|
||||
});
|
||||
} else if (match?.[1] && box && body) {
|
||||
const host = decodeURIComponent(match[1]);
|
||||
|
||||
// The "page not found" copy would be wrong if the API knows this mint, so hold
|
||||
// it back while we ask instead of flashing it and taking it away again.
|
||||
if (notfound) notfound.hidden = true;
|
||||
box.hidden = false;
|
||||
showBones(body, mintPageBones, { label: f.t('notfound.loading', { host }) });
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/api/mints/${encodeURIComponent(host)}`);
|
||||
if (!res.ok) throw new Error(`api responded ${res.status}`);
|
||||
const mint = (await res.json()) as MintDetail;
|
||||
|
||||
swapBones(body, (target) => {
|
||||
target.innerHTML = fallbackHtml(mint, f);
|
||||
});
|
||||
|
||||
const domain = mint.url.replace(/^https?:\/\//, '');
|
||||
const name = mint.name ?? domain.split('/')[0] ?? domain;
|
||||
document.title = f.t('mint.title', { name });
|
||||
} catch {
|
||||
// No such mint, or the API is unreachable. The plain 404 is the honest
|
||||
// answer, so put it back rather than leaving bones on screen.
|
||||
clearBones(body);
|
||||
box.hidden = true;
|
||||
if (notfound) notfound.hidden = false;
|
||||
}
|
||||
})();
|
||||
}
|
||||
};
|
||||
|
||||
onReady(setup);
|
||||
</script>
|
||||
@@ -0,0 +1,125 @@
|
||||
---
|
||||
import Base from '../../layouts/Base.astro';
|
||||
import { fetchStats } from '../../lib/api';
|
||||
import { useI18n, type Locale } from '../../i18n';
|
||||
import { localePaths } from '../../i18n/paths';
|
||||
import { DEFAULT_RELAYS, KIND_MINT_ANNOUNCEMENT, KIND_REVIEW } from '@cashumints/shared';
|
||||
|
||||
/** One page per locale: `/about`, `/es/about`, `/nl/about`. */
|
||||
export const getStaticPaths = localePaths;
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
}
|
||||
const { locale } = Astro.props;
|
||||
|
||||
const t = useI18n(locale);
|
||||
|
||||
const stats = await fetchStats();
|
||||
const GITHUB = 'https://github.com/Azzamo-net/cashumints.space';
|
||||
|
||||
/*
|
||||
* Prose with things inside it.
|
||||
*
|
||||
* Several sentences here carry a link, a <code> span or a bold lead. Rather than
|
||||
* chopping each into "before" and "after" fragments, which pins the English word order
|
||||
* onto every language, the whole sentence is one catalog string with a named
|
||||
* placeholder, and the markup is built here and interpolated. A translator moves the
|
||||
* placeholder wherever their sentence needs it.
|
||||
*
|
||||
* Everything interpolated is markup this file wrote, around values that are either
|
||||
* constants or numbers. Nothing from a mint, a relay or a reader reaches these.
|
||||
*/
|
||||
const code = (value: string): string => `<code>${value}</code>`;
|
||||
const link = (href: string, label: string): string =>
|
||||
`<a href="${href}" rel="noopener">${label}</a>`;
|
||||
const bold = (value: string): string => `<b>${value}</b>`;
|
||||
|
||||
const description = t('about.description');
|
||||
|
||||
const cashuP2 = t('about.cashu.p2', { link: link('https://cashu.space', 'cashu.space') });
|
||||
const mintsP2 = t('about.mints.p2', { code: code('online') });
|
||||
|
||||
const nip87Li1 = t('about.nip87.li1', {
|
||||
code: code(`kind ${KIND_MINT_ANNOUNCEMENT}`),
|
||||
tag: code('u'),
|
||||
});
|
||||
const nip87Li2 = t('about.nip87.li2', {
|
||||
code: code(`kind ${KIND_REVIEW}`),
|
||||
utag: code('u'),
|
||||
dtag: code('d'),
|
||||
});
|
||||
|
||||
const howLi1 = t('about.how.li1', {
|
||||
strong: bold(t('about.how.li1Label')),
|
||||
code: code('/v1/info'),
|
||||
});
|
||||
const howLi2 = t('about.how.li2', { strong: bold(t('about.how.li2Label')) });
|
||||
|
||||
const nutsP1 = t('about.nuts.p1', {
|
||||
link: link('https://github.com/cashubtc/nuts', 'github.com/cashubtc/nuts'),
|
||||
});
|
||||
|
||||
const numbers = t('about.numbers.p1', {
|
||||
mints: stats.mints_total,
|
||||
online: stats.mints_online,
|
||||
reviews: stats.reviews_total,
|
||||
});
|
||||
|
||||
const sourceP1 = t('about.source.p1', {
|
||||
github: link(GITHUB, 'GitHub'),
|
||||
azzamo: link('https://azzamo.net', 'Azzamo'),
|
||||
code: code('/v1/info'),
|
||||
});
|
||||
---
|
||||
|
||||
<Base title={t('about.title')} description={description} current="about">
|
||||
<div class="page">
|
||||
<div class="prose">
|
||||
<h1>{t('about.heading')}</h1>
|
||||
<p class="lede">{t('about.lede')}</p>
|
||||
|
||||
<h2 id="cashu">{t('about.cashu.title')}</h2>
|
||||
<p>{t('about.cashu.p1')}</p>
|
||||
<p set:html={cashuP2} />
|
||||
|
||||
<h2 id="mints">{t('about.mints.title')}</h2>
|
||||
<p>{t('about.mints.p1')}</p>
|
||||
<p set:html={mintsP2} />
|
||||
|
||||
<h2 id="nip87">{t('about.nip87.title')}</h2>
|
||||
<p>{t('about.nip87.p1')}</p>
|
||||
<ul>
|
||||
<li set:html={nip87Li1} />
|
||||
<li set:html={nip87Li2} />
|
||||
</ul>
|
||||
<p>{t('about.nip87.p2')}</p>
|
||||
|
||||
<h2 id="how">{t('about.how.title')}</h2>
|
||||
<p>{t('about.how.p1')}</p>
|
||||
<ul>
|
||||
<li set:html={howLi1} />
|
||||
<li set:html={howLi2} />
|
||||
</ul>
|
||||
<p>{t('about.how.p2')}</p>
|
||||
<p>{t('about.how.p3')}</p>
|
||||
|
||||
<h2 id="relays">{t('about.relays.title')}</h2>
|
||||
<p>{t('about.relays.p1')}</p>
|
||||
{/* Relay URLs are addresses. Never translated, never reformatted. */}
|
||||
<ul>
|
||||
{DEFAULT_RELAYS.map((relay) => <li><code>{relay}</code></li>)}
|
||||
</ul>
|
||||
<p>{t('about.relays.p2')}</p>
|
||||
|
||||
<h2 id="nuts">{t('about.nuts.title')}</h2>
|
||||
<p set:html={nutsP1} />
|
||||
|
||||
<h2 id="numbers">{t('about.numbers.title')}</h2>
|
||||
<p>{numbers}</p>
|
||||
|
||||
<h2 id="source">{t('about.source.title')}</h2>
|
||||
<p set:html={sourceP1} />
|
||||
</div>
|
||||
</div>
|
||||
</Base>
|
||||
@@ -0,0 +1,87 @@
|
||||
---
|
||||
/**
|
||||
* The disclaimer. The most important page on the site: everything else here helps
|
||||
* someone choose a mint, and this is the page that says what choosing a mint means.
|
||||
*
|
||||
* Linked from the site footer and from the bottom of every mint page, because that is
|
||||
* where the decision actually gets made.
|
||||
*
|
||||
* Safety copy, so the translations are for plain meaning rather than for the shape of
|
||||
* the English sentence. See src/i18n/GLOSSARY.md.
|
||||
*/
|
||||
import Base from '../../layouts/Base.astro';
|
||||
import { localePath, useI18n, type Locale } from '../../i18n';
|
||||
import { localePaths } from '../../i18n/paths';
|
||||
|
||||
/** One page per locale: `/disclaimer`, `/es/disclaimer`, `/nl/disclaimer`. */
|
||||
export const getStaticPaths = localePaths;
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
}
|
||||
const { locale } = Astro.props;
|
||||
|
||||
const t = useI18n(locale);
|
||||
|
||||
/** Bump `disclaimer.updated` in every catalog when the text changes. */
|
||||
const updated = t('disclaimer.updated');
|
||||
|
||||
const description = t('disclaimer.description');
|
||||
|
||||
const siteP1 = t('disclaimer.site.p1', {
|
||||
link: `<a href="${localePath('/terms', locale)}">${t('disclaimer.site.link')}</a>`,
|
||||
});
|
||||
---
|
||||
|
||||
<Base title={t('disclaimer.title')} description={description}>
|
||||
<div class="page">
|
||||
<div class="prose">
|
||||
<h1>{t('disclaimer.heading')}</h1>
|
||||
<p class="lede">{t('disclaimer.lede', { date: updated })}</p>
|
||||
|
||||
<div class="callout">
|
||||
<p>{t('disclaimer.callout')}</p>
|
||||
</div>
|
||||
|
||||
<h2 id="custodial">{t('disclaimer.custodial.title')}</h2>
|
||||
<p>{t('disclaimer.custodial.p1')}</p>
|
||||
<p>{t('disclaimer.custodial.p2')}</p>
|
||||
<p>{t('disclaimer.custodial.p3')}</p>
|
||||
|
||||
<h2 id="reviews">{t('disclaimer.reviews.title')}</h2>
|
||||
<p>{t('disclaimer.reviews.p1')}</p>
|
||||
<p>{t('disclaimer.reviews.p2')}</p>
|
||||
|
||||
<h2 id="status">{t('disclaimer.status.title')}</h2>
|
||||
<p>{t('disclaimer.status.p1')}</p>
|
||||
<p>{t('disclaimer.status.p2')}</p>
|
||||
|
||||
<h2 id="ranking">{t('disclaimer.ranking.title')}</h2>
|
||||
<p>{t('disclaimer.ranking.p1')}</p>
|
||||
<p>{t('disclaimer.ranking.p2')}</p>
|
||||
|
||||
<h2 id="not-advice">{t('disclaimer.notAdvice.title')}</h2>
|
||||
<p>{t('disclaimer.notAdvice.p1')}</p>
|
||||
|
||||
<h2 id="dyor">{t('disclaimer.dyor.title')}</h2>
|
||||
<ul>
|
||||
<li>{t('disclaimer.dyor.li1')}</li>
|
||||
<li>{t('disclaimer.dyor.li2')}</li>
|
||||
<li>{t('disclaimer.dyor.li3')}</li>
|
||||
<li>{t('disclaimer.dyor.li4')}</li>
|
||||
<li>{t('disclaimer.dyor.li5')}</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="site">{t('disclaimer.site.title')}</h2>
|
||||
<p set:html={siteP1} />
|
||||
</div>
|
||||
</div>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.callout {
|
||||
border: 1px solid rgba(224, 100, 80, .3); background: rgba(224, 100, 80, .07);
|
||||
border-radius: var(--radius); padding: 16px 18px; margin: 24px 0 8px;
|
||||
}
|
||||
.callout p { color: #E8C4BC; font-size: 15px; margin: 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,658 @@
|
||||
---
|
||||
import Base from '../../layouts/Base.astro';
|
||||
import ColorBends from '../../components/ColorBends.astro';
|
||||
import MintCard from '../../components/MintCard.astro';
|
||||
import PulseTicker from '../../components/PulseTicker.astro';
|
||||
import { fetchHealth, fetchMint, fetchMints, fetchStats } from '../../lib/api';
|
||||
import { fetchLatestReviews } from '../../lib/nostr-build';
|
||||
import { displayDomain, iconGradient, sentiment, shortNpub } from '../../lib/format';
|
||||
import { profileName, readCapabilities } from '@cashumints/shared';
|
||||
import { localePath, useI18n, type Locale } from '../../i18n';
|
||||
import { formatters } from '../../i18n/format';
|
||||
import { localePaths } from '../../i18n/paths';
|
||||
|
||||
/** One page per locale: `/`, `/es`, `/nl`. */
|
||||
export const getStaticPaths = localePaths;
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
}
|
||||
const { locale } = Astro.props;
|
||||
|
||||
const t = useI18n(locale);
|
||||
const f = formatters(t);
|
||||
|
||||
const [mints, stats, health] = await Promise.all([fetchMints(), fetchStats(), fetchHealth()]);
|
||||
|
||||
const top = mints.slice(0, 6);
|
||||
const hostByUrl = new Map(mints.map((m) => [m.url, m.host]));
|
||||
|
||||
/*
|
||||
* The mint details and the relay read have nothing to say to each other — the reviews
|
||||
* only need `hostByUrl`, which the mint list above already gave us — so they wait
|
||||
* together rather than one behind the other.
|
||||
*
|
||||
* The carousel holds a screenful and a bit: enough that scrolling it is worth doing,
|
||||
* few enough that they are all real, recent, written reviews. Anything the relays
|
||||
* cannot fill is simply fewer cards, and the carousel's controls disappear with them.
|
||||
*/
|
||||
const [details, latest] = await Promise.all([
|
||||
// Distributions give the card sentiment bars real data instead of a rating proxy.
|
||||
Promise.all(top.map((m) => fetchMint(m.host).catch(() => null))),
|
||||
fetchLatestReviews(hostByUrl, 10),
|
||||
]);
|
||||
|
||||
const sentiments = details.map((d) => (d ? sentiment(d.rating_distribution) : undefined));
|
||||
// Same details, read a second way: the card's "melt only" or "frozen" chip.
|
||||
const capabilities = details.map((d) => (d ? readCapabilities(d.info?.nuts) : null));
|
||||
|
||||
const description = t('home.description', {
|
||||
mints: stats.mints_total,
|
||||
reviews: stats.reviews_total,
|
||||
});
|
||||
|
||||
/*
|
||||
* The one sentence on the page with a link inside it. Interpolated as markup rather
|
||||
* than split into three strings, so a translator can put "NIP-87" wherever the
|
||||
* sentence needs it instead of wherever English left a gap.
|
||||
*/
|
||||
const signedBody = t('home.why.signed.body', {
|
||||
link: `<a href="${localePath('/about', locale)}#nip87">${t('home.why.signed.link')}</a>`,
|
||||
});
|
||||
---
|
||||
|
||||
<Base title={t('home.title')} description={description} current="mints">
|
||||
{/*
|
||||
The hero sits on an animated colour field: ColorBends from React Bits, ported to
|
||||
plain WebGL in scripts/color-bends.ts. The wrapper is what the field fills, and it
|
||||
is full width, so the wash runs edge to edge rather than stopping at the 1240px
|
||||
column the text keeps to.
|
||||
*/}
|
||||
<div class="hero-wrap">
|
||||
<ColorBends
|
||||
rotation={90}
|
||||
speed={0.2}
|
||||
colors={['#A78BFA']}
|
||||
transparent
|
||||
autoRotate={1}
|
||||
scale={0.4}
|
||||
frequency={1}
|
||||
warpStrength={1}
|
||||
mouseInfluence={0.5}
|
||||
parallax={0.2}
|
||||
noise={0.2}
|
||||
iterations={1}
|
||||
intensity={1.3}
|
||||
bandWidth={7.5}
|
||||
/>
|
||||
|
||||
{/*
|
||||
The hero arrives as a sequence, 60ms apart, starting the moment the page is
|
||||
parsed. `data-reveal="load"` means exactly once, on a full page load: coming back
|
||||
here through the router puts it on screen without replaying the greeting.
|
||||
*/}
|
||||
<section class="hero">
|
||||
{/*
|
||||
Two strings, because the emphasised half is a phrase and not the tail of a
|
||||
sentence: Spanish and Dutch both put "you can trust" somewhere English does not,
|
||||
and a translator needs to be able to move it.
|
||||
*/}
|
||||
<h1 data-reveal="load" data-reveal-delay="0">
|
||||
{t('home.hero.title')} <em>{t('home.hero.titleEm')}</em>
|
||||
</h1>
|
||||
<p data-reveal="load" data-reveal-delay="60">{t('home.hero.lede')}</p>
|
||||
|
||||
<div class="search-wrap">
|
||||
<form
|
||||
class="search"
|
||||
action={localePath('/mints', locale)}
|
||||
method="get"
|
||||
role="search"
|
||||
data-reveal="load"
|
||||
data-reveal-delay="120"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" />
|
||||
</svg>
|
||||
<label class="sr-only" for="home-search">{t('home.search.label')}</label>
|
||||
<input
|
||||
id="home-search"
|
||||
name="q"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
placeholder={t('home.search.placeholder')}
|
||||
data-search-input
|
||||
/>
|
||||
<kbd aria-hidden="true">/</kbd>
|
||||
</form>
|
||||
<div class="quick-filters" data-reveal="load" data-reveal-delay="180">
|
||||
<a class="qf" href={`${localePath('/mints', locale)}?sort=reviews`}>{t('home.filters.mostReviewed')}</a>
|
||||
<a class="qf" href={`${localePath('/mints', locale)}?sort=rating`}>{t('home.filters.highestRated')}</a>
|
||||
<a class="qf" href={`${localePath('/mints', locale)}?sort=recent`}>{t('home.filters.recentlyActive')}</a>
|
||||
<a class="qf" href={`${localePath('/mints', locale)}?nut=17`}>{t('home.filters.websockets')}</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<PulseTicker stats={stats} health={health} />
|
||||
|
||||
<section class="section" aria-labelledby="top-mints">
|
||||
<div class="sec-head" data-reveal>
|
||||
<h2 class="sec-title" id="top-mints">{t('home.top.title')}</h2>
|
||||
<span class="sec-sub">{t('home.top.sub')}</span>
|
||||
<a class="sec-link" href={localePath('/mints', locale)}>{t('home.top.all', { n: mints.length })}</a>
|
||||
</div>
|
||||
<div class="mint-grid">
|
||||
{top.map((mint, i) => (
|
||||
<MintCard mint={mint} rank={i + 1} sentiment={sentiments[i]} capabilities={capabilities[i]} revealDelay={i * 50} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" aria-labelledby="latest-reviews-title" id="latest-reviews">
|
||||
<div class="sec-head" data-reveal>
|
||||
<h2 class="sec-title" id="latest-reviews-title">{t('home.latest.title')}</h2>
|
||||
<span class="sec-sub">{t('home.latest.sub')}</span>
|
||||
{/*
|
||||
The controls sit in the header row, never over the cards: the previous site
|
||||
put arrows on top of the reviews and they covered the text. They start hidden
|
||||
and the island only shows them when the track actually overflows, so with
|
||||
JavaScript off, or with three reviews on a wide screen, there is nothing here
|
||||
that does not work.
|
||||
*/}
|
||||
<div class="sec-actions">
|
||||
<button class="car-btn" type="button" data-carousel-prev aria-label={t('home.latest.prev')} hidden>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true">
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="car-btn" type="button" data-carousel-next aria-label={t('home.latest.next')} hidden>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true">
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
<a class="sec-link" href={localePath('/reviews', locale)}>{t('home.latest.all')}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{
|
||||
latest.length === 0 ? (
|
||||
<p class="empty">{t('home.latest.empty')}</p>
|
||||
) : (
|
||||
<div class="rev-track" data-carousel-track>
|
||||
{latest.map((review) => {
|
||||
// One identity per card: the name from kind 0, or "Anon". The npub is
|
||||
// display only here, because the whole card is one link and a copy button
|
||||
// inside it would be a control inside a control.
|
||||
const name = profileName(review.profile);
|
||||
const initial = (name?.[0] ?? review.npub[5] ?? '?').toUpperCase();
|
||||
const filled = review.rating === null ? 0 : Math.round(review.rating);
|
||||
return (
|
||||
<a class="rev-card" href={localePath(`/mint/${review.mint_host}`, locale)} data-reveal>
|
||||
<span class="rc-top">
|
||||
<span class="rc-avatar-wrap">
|
||||
<span class="rc-avatar" style={`background:${iconGradient(review.pubkey)}`} aria-hidden="true">
|
||||
{initial}
|
||||
</span>
|
||||
{review.profile?.picture && (
|
||||
<img
|
||||
class="rc-avatar pic"
|
||||
src={review.profile.picture}
|
||||
alt=""
|
||||
width="34"
|
||||
height="34"
|
||||
loading="lazy"
|
||||
referrerpolicy="no-referrer"
|
||||
onload="this.classList.add('loaded')"
|
||||
onerror="this.remove()"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span class="rc-who">
|
||||
<span class="rc-identity">
|
||||
{
|
||||
// No NIP-05 here: at this card width it truncates to half a
|
||||
// domain, which says less than nothing. It renders in full on
|
||||
// the mint page, where the row has the room.
|
||||
name ? (
|
||||
<span class="rc-name">{name}</span>
|
||||
) : (
|
||||
<span class="rc-name anon">{t('reviews.anon')}</span>
|
||||
)
|
||||
}
|
||||
</span>
|
||||
<span class="rc-npub mono">{shortNpub(review.npub)}</span>
|
||||
</span>
|
||||
<span class:list={['rc-stars', { bad: review.rating !== null && review.rating <= 2 }]}>
|
||||
<span class="sr-only">
|
||||
{
|
||||
review.rating === null
|
||||
? t('reviews.srNoRating')
|
||||
: t('reviews.srRating', { rating: String(review.rating) })
|
||||
}
|
||||
</span>
|
||||
{/* Five elements rather than one string, so the row fills star by
|
||||
star when the card arrives. */}
|
||||
<span class="rc-star-row" aria-hidden="true">
|
||||
{[0, 1, 2, 3, 4].map((i) => (
|
||||
<span class="rc-star" style={`--star-i:${i}`}>{i < filled ? '★' : '☆'}</span>
|
||||
))}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
{/* The review, exactly as its author wrote it. Never translated. */}
|
||||
<span class="rc-body">{review.content}</span>
|
||||
<span class="rc-foot">
|
||||
<span class="rc-mint">{displayDomain(review.mint_url)}</span>
|
||||
<span class="rc-arrow" aria-hidden="true">→</span>
|
||||
<span class="rc-date">{f.relative(review.created_at)}</span>
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="section" aria-labelledby="why-trust">
|
||||
<div class="sec-head" data-reveal>
|
||||
<h2 class="sec-title" id="why-trust">{t('home.why.title')}</h2>
|
||||
</div>
|
||||
<div class="how">
|
||||
<div class="how-card" data-reveal>
|
||||
<span class="how-icon" aria-hidden="true">
|
||||
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Z" />
|
||||
</svg>
|
||||
</span>
|
||||
<h3>{t('home.why.signed.title')}</h3>
|
||||
<p set:html={signedBody} />
|
||||
</div>
|
||||
<div class="how-card" data-reveal>
|
||||
<span class="how-icon" aria-hidden="true">
|
||||
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10Z" />
|
||||
</svg>
|
||||
</span>
|
||||
<h3>{t('home.why.verify.title')}</h3>
|
||||
<p>{t('home.why.verify.body')}</p>
|
||||
</div>
|
||||
<div class="how-card" data-reveal>
|
||||
<span class="how-icon" aria-hidden="true">
|
||||
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M13 2 3 14h9l-1 8 10-12h-9l1-8Z" />
|
||||
</svg>
|
||||
</span>
|
||||
<h3>{t('home.why.checked.title')}</h3>
|
||||
<p>{t('home.why.checked.body')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="cta">
|
||||
<div class="cta-box" data-reveal>
|
||||
<h2>{t('home.cta.title')}</h2>
|
||||
<p>{t('home.cta.body')}</p>
|
||||
<div class="cta-actions">
|
||||
<a class="btn primary" href={localePath('/mints', locale)}>{t('home.cta.explore')}</a>
|
||||
<a class="btn" href={localePath('/about', locale)}>{t('home.cta.learn')}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
/* Positions the colour field, which is absolutely placed against this box. */
|
||||
.hero-wrap { position: relative; }
|
||||
/* Above the field, and its own stacking context so the search box stays clickable. */
|
||||
.hero { position: relative; z-index: 1; }
|
||||
|
||||
.hero {
|
||||
max-width: var(--page-max);
|
||||
margin: 0 auto;
|
||||
/* A third off the old 76/52. The pulse ticker is the proof that the site is
|
||||
live, and it belongs on screen at 1280x800 without scrolling. */
|
||||
padding: 136px var(--page-pad) 80px;
|
||||
text-align: center;
|
||||
}
|
||||
.hero h1 {
|
||||
font-family: var(--display);
|
||||
font-weight: 700;
|
||||
font-size: clamp(34px, 5vw, 56px);
|
||||
letter-spacing: -.03em;
|
||||
line-height: 1.06;
|
||||
/* Narrow enough to break after "mint", balanced so the two lines are even:
|
||||
"Find a Cashu mint" over "you can trust", never "you can" over "trust". */
|
||||
max-width: 620px;
|
||||
text-wrap: balance;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.hero h1 em { font-style: normal; color: var(--amber); }
|
||||
.hero p { color: var(--muted); font-size: 17px; max-width: 560px; margin: 16px auto 0; text-wrap: balance; }
|
||||
|
||||
.search-wrap { max-width: 640px; margin: 26px auto 0; }
|
||||
.search {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
background: var(--card); border: 1px solid var(--line);
|
||||
border-radius: 14px; padding: 16px 18px;
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
}
|
||||
.search:focus-within { border-color: var(--amber-dim); box-shadow: 0 0 0 3px rgba(240, 169, 59, .08); }
|
||||
.search svg { color: var(--faint); flex-shrink: 0; }
|
||||
.search input {
|
||||
flex: 1; background: none; border: none; outline: none;
|
||||
color: var(--text); font-family: var(--body); font-size: 16px; min-width: 0;
|
||||
}
|
||||
.search input::placeholder { color: var(--faint); }
|
||||
.search kbd {
|
||||
font-family: var(--mono); font-size: 11.5px; color: var(--faint);
|
||||
border: 1px solid var(--line); border-radius: 6px; padding: 2px 7px; flex-shrink: 0;
|
||||
}
|
||||
|
||||
.quick-filters { display: flex; gap: 8px; justify-content: center; margin-top: 14px; margin-bottom: 28px; flex-wrap: wrap; }
|
||||
.qf {
|
||||
font-size: 13px; font-weight: 500; color: var(--muted);
|
||||
border: 1px solid var(--line); border-radius: 999px; padding: 6px 14px;
|
||||
transition: color .15s, border-color .15s, background .15s;
|
||||
}
|
||||
.qf:hover { color: var(--text); border-color: #38342A; background: var(--card); }
|
||||
|
||||
/* ---------- latest reviews carousel ---------- */
|
||||
/*
|
||||
* A scroll-snap track, not a JavaScript slider: native touch and trackpad swiping,
|
||||
* native momentum, and it still works with the island switched off. The buttons and
|
||||
* the auto-advance are additions on top of something that already scrolls.
|
||||
*/
|
||||
.sec-actions { margin-left: auto; display: flex; align-items: center; gap: 8px; }
|
||||
.sec-actions .sec-link { margin-left: 4px; }
|
||||
.car-btn {
|
||||
width: 30px; height: 30px; flex-shrink: 0;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
border: 1px solid var(--line); border-radius: 8px;
|
||||
background: var(--card); color: var(--muted); cursor: pointer;
|
||||
transition: color var(--dur-quick), border-color var(--dur-quick), background var(--dur-quick);
|
||||
}
|
||||
.car-btn:hover { color: var(--text); border-color: #38342A; background: var(--card2); }
|
||||
.car-btn:active { transform: translateY(1px); }
|
||||
.car-btn[disabled] { opacity: .4; cursor: not-allowed; }
|
||||
.car-btn[disabled]:hover { color: var(--muted); border-color: var(--line); background: var(--card); }
|
||||
|
||||
.rev-track {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scroll-snap-type: x mandatory;
|
||||
/* Snap points land on the container's own left edge, so a card is never half cut
|
||||
at the start of the track. */
|
||||
scroll-padding-left: 0;
|
||||
/* Room for the card's hover lift and its focus ring, which an overflow container
|
||||
would otherwise clip. */
|
||||
padding: 4px 0 12px;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.rev-track::-webkit-scrollbar { display: none; }
|
||||
/* Soft edge while there is more to come, so the track reads as scrollable. Removed
|
||||
at the end of the track, where there is nothing left to hint at. */
|
||||
.rev-track.has-next {
|
||||
-webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 44px), transparent 100%);
|
||||
mask-image: linear-gradient(90deg, #000 calc(100% - 44px), transparent 100%);
|
||||
}
|
||||
|
||||
.rev-card {
|
||||
flex: 0 0 380px;
|
||||
/* Never wider than the viewport: at 360px the card fits the column instead of
|
||||
running its text under the edge. */
|
||||
max-width: calc(100vw - var(--page-pad) * 2);
|
||||
scroll-snap-align: start;
|
||||
border: 1px solid var(--line); border-radius: var(--radius);
|
||||
background: var(--card); padding: 20px; min-width: 0;
|
||||
display: flex; flex-direction: column; gap: 12px;
|
||||
transition: border-color var(--dur-quick), transform var(--dur-quick) var(--ease-out),
|
||||
box-shadow var(--dur-quick);
|
||||
}
|
||||
.rev-card:hover {
|
||||
border-color: #3A362B;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, .3);
|
||||
}
|
||||
.rev-card:active { transform: scale(.995); }
|
||||
|
||||
.rc-top { display: flex; align-items: center; gap: 11px; }
|
||||
.rc-avatar-wrap { position: relative; width: 34px; height: 34px; flex-shrink: 0; }
|
||||
.rc-avatar {
|
||||
width: 34px; height: 34px; border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-family: var(--display); font-weight: 600; font-size: 14px; color: #0E0D0B;
|
||||
border: 1px solid var(--line); object-fit: cover;
|
||||
}
|
||||
/* The picture covers the generated initial, fades in when it decodes, and removes
|
||||
itself if it 404s. */
|
||||
.rc-avatar.pic { position: absolute; inset: 0; background: var(--card2); }
|
||||
.rc-who { min-width: 0; display: flex; flex-direction: column; justify-content: center; gap: 1px; height: 34px; }
|
||||
.rc-identity { display: flex; align-items: center; gap: 7px; min-width: 0; height: 19px; }
|
||||
.rc-name { font-weight: 600; font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.rc-name.anon { color: var(--muted); font-weight: 500; }
|
||||
/* Display only on this card: the whole card is a link, so there is no button in it. */
|
||||
.rc-npub { font-size: 11px; color: var(--faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.rc-stars { margin-left: auto; color: var(--amber); font-size: 12.5px; flex-shrink: 0; }
|
||||
.rc-stars.bad { color: var(--red); }
|
||||
.rc-star-row { display: inline-flex; gap: 1.5px; }
|
||||
/* The row fills star by star once the card has arrived, 40ms apart. */
|
||||
.rc-star {
|
||||
transition:
|
||||
opacity var(--dur-quick) var(--ease-out) var(--star-in),
|
||||
transform var(--dur-quick) var(--ease-out) var(--star-in);
|
||||
--star-in: calc(var(--reveal-delay, 0ms) + 140ms + var(--star-i) * 40ms);
|
||||
}
|
||||
:global([data-reveal]:not(.in)) .rc-star { opacity: 0; transform: scale(.8); }
|
||||
|
||||
/* Three lines and an ellipsis, so every card in the row is the same height. */
|
||||
.rc-body {
|
||||
font-size: 14px; color: #D8D2C4; line-height: 1.55; flex: 1; overflow-wrap: anywhere;
|
||||
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; line-clamp: 3;
|
||||
overflow: hidden;
|
||||
}
|
||||
.rc-foot {
|
||||
display: flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--faint);
|
||||
border-top: 1px solid var(--line-soft); padding-top: 12px;
|
||||
}
|
||||
.rc-mint {
|
||||
display: block; min-width: 0; font-family: var(--mono); font-size: 12px; color: var(--muted);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
transition: color var(--dur-quick);
|
||||
}
|
||||
/* The destination, nudged: hovering the card says where it goes. */
|
||||
.rc-arrow {
|
||||
color: var(--amber); opacity: 0; flex-shrink: 0;
|
||||
transform: translateX(-4px);
|
||||
transition: opacity var(--dur-quick) var(--ease-out), transform var(--dur-quick) var(--ease-out);
|
||||
}
|
||||
.rev-card:hover .rc-mint, .rev-card:focus-visible .rc-mint { color: var(--amber); }
|
||||
.rev-card:hover .rc-arrow, .rev-card:focus-visible .rc-arrow { opacity: 1; transform: none; }
|
||||
.rc-date { margin-left: auto; flex-shrink: 0; }
|
||||
|
||||
.empty { color: var(--muted); font-size: 14.5px; }
|
||||
|
||||
.how { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
|
||||
.how-card { border: 1px solid var(--line); border-radius: var(--radius); background: var(--card); padding: 24px; }
|
||||
.how-icon {
|
||||
width: 40px; height: 40px; border-radius: 10px;
|
||||
background: var(--card2); border: 1px solid var(--line);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: var(--amber); margin-bottom: 16px;
|
||||
}
|
||||
.how-card h3 { font-family: var(--display); font-size: 16.5px; font-weight: 600; margin-bottom: 8px; letter-spacing: -.01em; }
|
||||
.how-card p { font-size: 13.5px; color: var(--muted); line-height: 1.6; }
|
||||
.how-card p a { color: var(--violet); border-bottom: 1px dotted rgba(167, 139, 250, .4); }
|
||||
|
||||
.cta { max-width: var(--page-max); margin: 64px auto 0; padding: 0 var(--page-pad); }
|
||||
.cta-box {
|
||||
border: 1px solid var(--line); border-radius: 18px;
|
||||
background: linear-gradient(135deg, var(--card) 0%, #1B1712 100%);
|
||||
padding: 52px 40px; text-align: center; position: relative; overflow: hidden;
|
||||
}
|
||||
.cta-box::before {
|
||||
content: ""; position: absolute; inset: 0; pointer-events: none;
|
||||
background: radial-gradient(600px 200px at 50% 0%, rgba(240, 169, 59, .07), transparent 70%);
|
||||
}
|
||||
.cta-box h2 { font-family: var(--display); font-size: 30px; font-weight: 700; letter-spacing: -.02em; position: relative; }
|
||||
.cta-box p { color: var(--muted); max-width: 480px; margin: 12px auto 26px; position: relative; }
|
||||
.cta-actions { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; position: relative; }
|
||||
|
||||
@media (max-width: 1279px) {
|
||||
.how { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.how { grid-template-columns: minmax(0, 1fr); }
|
||||
.hero { padding: 116px var(--page-pad) 72px; }
|
||||
.section { padding: 44px var(--page-pad) 8px; }
|
||||
.cta-box { padding: 38px 22px; }
|
||||
.cta-box h2 { font-size: 25px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import { onLeave, onReady, prefersReducedMotion, scrollBehavior } from '../../scripts/reveal';
|
||||
|
||||
/** One card every six seconds, until the reader touches the track. */
|
||||
const AUTO_MS = 6000;
|
||||
/** Matches the track's CSS gap. */
|
||||
const GAP = 16;
|
||||
|
||||
const setup = (): void => {
|
||||
// No track at all means the relays gave the build nothing, and the section is
|
||||
// showing its empty state instead.
|
||||
const found = {
|
||||
track: document.querySelector<HTMLElement>('[data-carousel-track]'),
|
||||
prev: document.querySelector<HTMLButtonElement>('[data-carousel-prev]'),
|
||||
next: document.querySelector<HTMLButtonElement>('[data-carousel-next]'),
|
||||
};
|
||||
if (!found.track || !found.prev || !found.next) return;
|
||||
const track: HTMLElement = found.track;
|
||||
const prev: HTMLButtonElement = found.prev;
|
||||
const next: HTMLButtonElement = found.next;
|
||||
|
||||
let timer = 0;
|
||||
/**
|
||||
* Auto-advance is a hint that the track scrolls, not a feature. The first time
|
||||
* the reader scrolls it themselves, by button, swipe, wheel or key, it is their
|
||||
* track and it never moves on its own again. With reduced motion set it never
|
||||
* starts at all.
|
||||
*/
|
||||
let surrendered = prefersReducedMotion();
|
||||
|
||||
const cardStep = (): number => {
|
||||
const card = track.querySelector<HTMLElement>('.rev-card');
|
||||
return card ? card.getBoundingClientRect().width + GAP : track.clientWidth;
|
||||
};
|
||||
|
||||
const overflows = (): boolean => track.scrollWidth - track.clientWidth > 4;
|
||||
const atStart = (): boolean => track.scrollLeft <= 2;
|
||||
const atEnd = (): boolean => track.scrollLeft >= track.scrollWidth - track.clientWidth - 2;
|
||||
|
||||
const stop = (): void => {
|
||||
if (timer) {
|
||||
window.clearInterval(timer);
|
||||
timer = 0;
|
||||
}
|
||||
};
|
||||
|
||||
/** Buttons only exist when there is something to scroll to. */
|
||||
function sync(): void {
|
||||
const scrollable = overflows();
|
||||
prev.hidden = !scrollable;
|
||||
next.hidden = !scrollable;
|
||||
track.classList.toggle('has-next', scrollable && !atEnd());
|
||||
if (!scrollable) {
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
prev.disabled = atStart();
|
||||
next.disabled = atEnd();
|
||||
}
|
||||
|
||||
const scrollByCard = (direction: number): void => {
|
||||
track.scrollBy({ left: direction * cardStep(), behavior: scrollBehavior() });
|
||||
};
|
||||
|
||||
function tick(): void {
|
||||
// Paused while the pointer is over the track or focus is inside it: nothing
|
||||
// should slide out from under a reader who is reading it.
|
||||
if (surrendered || track.matches(':hover, :focus-within') || !overflows()) return;
|
||||
if (atEnd()) track.scrollTo({ left: 0, behavior: scrollBehavior() });
|
||||
else scrollByCard(1);
|
||||
}
|
||||
|
||||
const start = (): void => {
|
||||
if (surrendered || timer || !overflows()) return;
|
||||
timer = window.setInterval(tick, AUTO_MS);
|
||||
};
|
||||
|
||||
const surrender = (): void => {
|
||||
surrendered = true;
|
||||
stop();
|
||||
};
|
||||
|
||||
prev.addEventListener('click', () => {
|
||||
surrender();
|
||||
scrollByCard(-1);
|
||||
});
|
||||
next.addEventListener('click', () => {
|
||||
surrender();
|
||||
scrollByCard(1);
|
||||
});
|
||||
|
||||
// Deliberate interaction, as opposed to the smooth scroll this island starts
|
||||
// itself. Watching these rather than the scroll event is what keeps the two
|
||||
// apart without a flag and a timeout guessing at which is which.
|
||||
for (const event of ['pointerdown', 'touchstart', 'keydown'] as const) {
|
||||
track.addEventListener(event, surrender, { passive: true });
|
||||
}
|
||||
// A wheel only counts when it is aimed at the track. Scrolling the page with the
|
||||
// pointer resting over the carousel is not a request to take it over.
|
||||
track.addEventListener(
|
||||
'wheel',
|
||||
(event) => {
|
||||
if (Math.abs(event.deltaX) > Math.abs(event.deltaY)) surrender();
|
||||
},
|
||||
{ passive: true },
|
||||
);
|
||||
|
||||
let frame = 0;
|
||||
track.addEventListener(
|
||||
'scroll',
|
||||
() => {
|
||||
if (frame) return;
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = 0;
|
||||
sync();
|
||||
});
|
||||
},
|
||||
{ passive: true },
|
||||
);
|
||||
|
||||
// A window narrow enough to make the track scrollable brings the controls, and
|
||||
// the auto-advance, back with it.
|
||||
const onResize = (): void => {
|
||||
sync();
|
||||
start();
|
||||
};
|
||||
window.addEventListener('resize', onResize);
|
||||
|
||||
sync();
|
||||
start();
|
||||
|
||||
onLeave(() => {
|
||||
stop();
|
||||
window.removeEventListener('resize', onResize);
|
||||
if (frame) cancelAnimationFrame(frame);
|
||||
});
|
||||
};
|
||||
|
||||
onReady(setup);
|
||||
</script>
|
||||
@@ -0,0 +1,719 @@
|
||||
---
|
||||
import Base from '../../../layouts/Base.astro';
|
||||
import Reviews from '../../../components/Reviews.astro';
|
||||
import MintLive from '../../../components/MintLive.astro';
|
||||
import Sparkline from '../../../components/Sparkline.astro';
|
||||
import CopyableId from '../../../components/CopyableId.astro';
|
||||
import MintQr from '../../../components/MintQr.astro';
|
||||
import MintCard from '../../../components/MintCard.astro';
|
||||
import {
|
||||
getMintWarnings, HIGHLIGHT_NUTS, KNOWN_NUT_COUNT, readCapabilities,
|
||||
type MintDetail, type MintListItem,
|
||||
} from '@cashumints/shared';
|
||||
import { fetchMint, fetchMints, iconUrl } from '../../../lib/api';
|
||||
import { breadcrumbNode, mintServiceNode } from '../../../lib/schema';
|
||||
import { clampWords, isIndexableMint, nameCollisions, seoName } from '../../../lib/seo';
|
||||
import { bannerHtml, limitsHtml, localNutName, nutRowsHtml } from '../../../lib/mint-state';
|
||||
import {
|
||||
displayDomain, iconGradient, initials, shortNpub, transitionName, truncateMiddle,
|
||||
} from '../../../lib/format';
|
||||
import { localePath, useI18n } from '../../../i18n';
|
||||
import { formatters, starString } from '../../../i18n/format';
|
||||
import { statusLabel, warningOptions } from '../../../i18n/mint';
|
||||
import { localePathsFor } from '../../../i18n/paths';
|
||||
|
||||
/**
|
||||
* Every mint, in every locale.
|
||||
*
|
||||
* The API is asked once per mint, not once per mint per locale: the detail is fetched
|
||||
* here and `localePathsFor` hands the same object to all three pages. ~55 mints times
|
||||
* three locales is ~165 pages off ~55 requests.
|
||||
*/
|
||||
export async function getStaticPaths() {
|
||||
const mints = await fetchMints();
|
||||
const details = await Promise.all(
|
||||
mints.map(async (mint, index) => {
|
||||
try {
|
||||
return { detail: await fetchMint(mint.host), rank: index + 1 };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
/*
|
||||
* Two things computed once here, from the list the page already had to fetch, rather
|
||||
* than per mint: which published names identify nothing because more than one mint
|
||||
* uses them, and the ring that gives every mint page three others to link to.
|
||||
*/
|
||||
const collisions = nameCollisions(mints);
|
||||
|
||||
/*
|
||||
* The related-mints ring.
|
||||
*
|
||||
* Mint at position i links to i+1, i+2 and i+3, wrapping. Neighbours in the ranked
|
||||
* order, so the three suggestions are of comparable standing and it reads sensibly to
|
||||
* someone browsing; a ring rather than "the top three", so every mint page receives
|
||||
* links instead of all of them pointing at the same three, and so a crawler entering
|
||||
* anywhere can walk the whole index. Deterministic, so a rebuild with unchanged data
|
||||
* produces an unchanged page.
|
||||
*
|
||||
* Unindexable mints are excluded from both ends: they are not offered as suggestions,
|
||||
* and their own pages get no block, since a page nobody should index needs no crawl
|
||||
* paths out of it.
|
||||
*/
|
||||
const ring = mints.filter(isIndexableMint);
|
||||
const ringIndex = new Map(ring.map((mint, index) => [mint.host, index]));
|
||||
|
||||
const related = (host: string): MintListItem[] => {
|
||||
const at = ringIndex.get(host);
|
||||
if (at === undefined || ring.length < 2) return [];
|
||||
return [1, 2, 3]
|
||||
.filter((step) => step < ring.length)
|
||||
.map((step) => ring[(at + step) % ring.length]!);
|
||||
};
|
||||
|
||||
return localePathsFor(
|
||||
details
|
||||
.filter((d): d is { detail: MintDetail; rank: number } => d !== null)
|
||||
.map(({ detail, rank }) => ({
|
||||
params: { host: detail.host },
|
||||
props: {
|
||||
mint: detail,
|
||||
rank,
|
||||
related: related(detail.host),
|
||||
seoName: seoName(detail.name, displayDomain(detail.url), collisions),
|
||||
},
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
// Props come from `getStaticPaths` above: the mint detail, its rank in the default
|
||||
// ordering, and which of the three locales this copy of the page is.
|
||||
const { mint, rank, related, seoName: titleName, locale } = Astro.props;
|
||||
|
||||
const t = useI18n(locale);
|
||||
const f = formatters(t);
|
||||
|
||||
const domain = displayDomain(mint.url);
|
||||
const name = mint.name ?? domain.split('/')[0] ?? domain;
|
||||
const icon = iconUrl(mint.icon);
|
||||
const offline = mint.status === 'offline';
|
||||
const info = mint.info;
|
||||
|
||||
const status = statusLabel(mint.status, t);
|
||||
|
||||
/* verdict: rating */
|
||||
const dist = mint.rating_distribution;
|
||||
const distMax = Math.max(1, ...Object.values(dist));
|
||||
// Reviews whose event carried no parseable rating are counted but not averaged, so
|
||||
// say so rather than implying the average covers every review.
|
||||
const ratedCount = Object.values(dist).reduce((a, b) => a + b, 0);
|
||||
const unrated = mint.review_count - ratedCount;
|
||||
|
||||
/*
|
||||
Warnings. One shared function decides what is wrong with a mint, so the banner, the
|
||||
limits cell, the sidebar and the cards on /mints can never contradict each other.
|
||||
The flags come from the cached /v1/info, so on an offline mint they describe the
|
||||
configuration as it was when the mint last answered, and the copy says so.
|
||||
*/
|
||||
const caps = readCapabilities(info?.nuts);
|
||||
// The banner's sentences come out of this locale's catalog; which banner it is comes
|
||||
// out of shared/, identically in every language.
|
||||
const warnings = getMintWarnings(mint, warningOptions(f));
|
||||
const warning = warnings[0] ?? null;
|
||||
|
||||
/* software */
|
||||
const software = mint.version ?? null;
|
||||
const softwareName = software?.split('/')[0] ?? null;
|
||||
|
||||
/* NUTs */
|
||||
const supported = new Set(mint.nuts);
|
||||
const rest = mint.nuts.filter((n) => !HIGHLIGHT_NUTS.includes(n as never));
|
||||
|
||||
/* sidebar: contact */
|
||||
const contacts = info?.contact ?? [];
|
||||
const contactOf = (method: string): string | null =>
|
||||
contacts.find((c) => c.method?.toLowerCase() === method)?.info ?? null;
|
||||
const nostrContact = contactOf('nostr');
|
||||
const emailContact = contactOf('email');
|
||||
const serverTime = info?.time ? f.serverTime(info.time) : null;
|
||||
|
||||
/* MOTD and beta warnings */
|
||||
const motd = info?.motd?.trim() || null;
|
||||
const operatorNote = mint.description?.trim() || null;
|
||||
const longDescription = info?.description_long?.trim() || null;
|
||||
const warningText = [mint.description, longDescription].filter(Boolean).join(' ');
|
||||
const isBeta = /\b(beta|alpha|experimental|research project|test|do not|don't store|not for real)\b/i.test(warningText);
|
||||
|
||||
/*
|
||||
SEO.
|
||||
|
||||
The description is three sentences, and only two of them are ours. The first is the
|
||||
mint's own description when it has published one, verbatim, in whatever language the
|
||||
operator wrote it in: it is their copy about their service and translating it would be
|
||||
putting words in their mouth. Only when there is none does the site write that
|
||||
sentence itself.
|
||||
*/
|
||||
const ratingPart =
|
||||
mint.review_count > 0 && mint.rating_avg !== null
|
||||
? t('mint.description.rated', {
|
||||
n: mint.review_count,
|
||||
rating: f.decimal(mint.rating_avg),
|
||||
})
|
||||
: t('mint.description.noReviews');
|
||||
|
||||
// The state goes in the description too, so a search result for a melt-only or a
|
||||
// long-dead mint carries the warning before anyone clicks through to read it, in the
|
||||
// language of the result they are looking at.
|
||||
const statusPart = warning
|
||||
? `${warning.meta}.`
|
||||
: mint.status === 'online'
|
||||
? t('mint.description.online')
|
||||
: t('mint.description.unreachable');
|
||||
|
||||
/*
|
||||
* The opening sentence. The mint's own description when it published one; otherwise the
|
||||
* site writes it, and which sentence it writes depends on whether the mint's name says
|
||||
* anything. "Cashu mint is a Cashu mint at mint.endfiat.money" is what the name-shaped
|
||||
* sentence produces for a mint called "Cashu mint", so a generic or missing name gets a
|
||||
* sentence built around the domain instead.
|
||||
*/
|
||||
const lead =
|
||||
mint.description?.trim() ||
|
||||
(titleName === domain
|
||||
? t('mint.description.fallbackDomain', { domain })
|
||||
: t('mint.description.fallback', { name, domain }));
|
||||
|
||||
// Clamped on a word boundary: a description cut mid-word reads as broken everywhere
|
||||
// that does not re-truncate it, which is every link preview and feed reader.
|
||||
const description = clampWords(`${lead} ${ratingPart} ${statusPart}`, 300);
|
||||
|
||||
/*
|
||||
* Indexability.
|
||||
*
|
||||
* A mint the site has never once reached and nobody has ever reviewed has no name, no
|
||||
* description, no version and no reviews, because all of those come from a mint that
|
||||
* answered or a person who wrote something. The page still exists, is still linked and
|
||||
* is still reviewable; it just asks not to be indexed until there is something on it.
|
||||
* The sitemap reads the same predicate.
|
||||
*/
|
||||
const indexable = isIndexableMint(mint);
|
||||
|
||||
const crumbs = [
|
||||
{ name: t('nav.home'), url: new URL(localePath('/', locale), Astro.site).href },
|
||||
{ name: t('mint.crumbMints'), url: new URL(localePath('/mints', locale), Astro.site).href },
|
||||
{ name: domain },
|
||||
];
|
||||
|
||||
const canonical = new URL(localePath(`/mint/${mint.host}`, locale), Astro.site).href;
|
||||
|
||||
const schema = [
|
||||
breadcrumbNode(canonical, crumbs),
|
||||
mintServiceNode({
|
||||
canonical,
|
||||
name: titleName,
|
||||
description,
|
||||
mintUrl: mint.url,
|
||||
icon: icon ? new URL(icon, Astro.site).href : null,
|
||||
/*
|
||||
* Only when reviews actually carried ratings. `ratedCount` is what produced the
|
||||
* average; `mint.review_count` includes reviews whose event had no parseable
|
||||
* rating, which the page itself distinguishes a few lines below.
|
||||
*/
|
||||
rating:
|
||||
ratedCount > 0 && mint.rating_avg !== null
|
||||
? {
|
||||
value: Number(mint.rating_avg.toFixed(1)),
|
||||
ratingCount: ratedCount,
|
||||
reviewCount: mint.review_count,
|
||||
}
|
||||
: null,
|
||||
}),
|
||||
];
|
||||
---
|
||||
|
||||
<Base
|
||||
title={t('mint.title', { name: titleName })}
|
||||
description={description}
|
||||
current="mints"
|
||||
mintCount={undefined}
|
||||
noindex={!indexable}
|
||||
schema={schema}
|
||||
>
|
||||
<div class="page">
|
||||
{/*
|
||||
Home, then the index, then here. The trail has to match `crumbs` in the
|
||||
frontmatter link for link: the BreadcrumbList built from it describes this
|
||||
navigation, and describing a step that is not on screen is a claim about the page
|
||||
that the page does not support.
|
||||
*/}
|
||||
<nav class="crumbs" aria-label={t('mint.breadcrumb')}>
|
||||
<a href={localePath('/', locale)}>{t('nav.home')}</a>
|
||||
<span class="sep" aria-hidden="true">/</span>
|
||||
<a href={localePath('/mints', locale)}>{t('mint.crumbMints')}</a>
|
||||
<span class="sep" aria-hidden="true">/</span>
|
||||
<span class="here mono">{domain}</span>
|
||||
</nav>
|
||||
|
||||
<div class="mint-head">
|
||||
{
|
||||
icon ? (
|
||||
<img
|
||||
class="mint-icon"
|
||||
src={icon}
|
||||
alt=""
|
||||
width="72"
|
||||
height="72"
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
fetchpriority="high"
|
||||
style={`view-transition-name:${transitionName('icon', mint.host)}`}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
class="mint-icon"
|
||||
style={`background:${iconGradient(domain)};view-transition-name:${transitionName('icon', mint.host)}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{initials(name)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
<div class="mint-id">
|
||||
<div class="mint-name-row">
|
||||
<h1 style={`view-transition-name:${transitionName('name', mint.host)}`}>{name}</h1>
|
||||
<span class:list={['status-chip', mint.status]} data-live-status>
|
||||
<span class="status-dot"></span><span data-live-status-label>{status}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mint-domain">
|
||||
<span class="mono">{mint.url}</span>
|
||||
<button class="copy-btn" data-copy={mint.url} aria-label={t('mint.copyUrl')} type="button">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<rect x="9" y="9" width="12" height="12" rx="2" /><path d="M5 15V5a2 2 0 0 1 2-2h10" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mint-tags">
|
||||
<span class="tag">{t('mint.rankByScore', { rank: String(rank) })}</span>
|
||||
{supported.has('4') && <span class="tag">{t('mint.tagLightning')}</span>}
|
||||
{softwareName && <span class="tag mono">{softwareName}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="head-actions">
|
||||
<button class="btn" data-copy={mint.url} type="button">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<rect x="9" y="9" width="12" height="12" rx="2" /><path d="M5 15V5a2 2 0 0 1 2-2h10" />
|
||||
</svg>
|
||||
{t('mint.copyUrl')}
|
||||
</button>
|
||||
<button class="btn" data-qr-open type="button">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
|
||||
<rect x="3" y="3" width="5" height="5" rx="1" /><rect x="16" y="3" width="5" height="5" rx="1" />
|
||||
<rect x="3" y="16" width="5" height="5" rx="1" />
|
||||
<path d="M21 16h-3a2 2 0 0 0-2 2v3M21 21v.01M12 7v3a2 2 0 0 1-2 2H7M12 3h.01M12 16v.01M16 12h1M21 12v.01M12 21v-1" />
|
||||
</svg>
|
||||
{t('mint.qrButton')}
|
||||
</button>
|
||||
<button class="btn primary" data-write-open type="button">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M12 20h9" /><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" />
|
||||
</svg>
|
||||
{t('mint.writeReview')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
One banner slot, one banner. Severity order in getMintWarnings decides which of
|
||||
the six wins, and the losers fold into its last sentence rather than stacking a
|
||||
wall of red nobody reads. Prerendered, so it is there with JavaScript off; the
|
||||
live island rebuilds it in place if a fresh probe disagrees.
|
||||
*/}
|
||||
<Fragment set:html={bannerHtml(warning)} />
|
||||
|
||||
{/*
|
||||
The four cells arrive left to right, 60ms apart, which is the order they should
|
||||
be read in. Each carries its own delay rather than relying on the observer's
|
||||
batching, because this strip is above the fold and arrives as one gesture.
|
||||
*/}
|
||||
<section class="verdict" aria-labelledby="mint-summary">
|
||||
<h2 class="sr-only" id="mint-summary">{t('mint.summary')}</h2>
|
||||
<div class="v-cell rating-cell" data-reveal data-reveal-delay="0">
|
||||
<div class="v-label">{t('mint.rating.label')}</div>
|
||||
<div class="rating-body">
|
||||
<div class="rating-figure">
|
||||
{
|
||||
mint.rating_avg === null ? (
|
||||
<>
|
||||
<div class="v-big">{t('time.none')}</div>
|
||||
<div class="v-sub">{t('mint.rating.none')}</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Counts up from 0.0 on reveal. The prerendered value is the one
|
||||
that is here with JavaScript off, and the one it lands on. */}
|
||||
{/*
|
||||
`data-count-up` holds the raw number for the animation to count to;
|
||||
the text beside it is the same number formatted for this locale, and
|
||||
the count-up rewrites it in the locale's own notation as it runs.
|
||||
*/}
|
||||
<div class="v-big" data-count-up={mint.rating_avg.toFixed(1)} data-count-decimals="1">
|
||||
{f.decimal(mint.rating_avg)}
|
||||
</div>
|
||||
<div class="stars" aria-hidden="true">{starString(mint.rating_avg)}</div>
|
||||
<div class="v-sub">
|
||||
{/* One expression, one string: Astro drops the whitespace between two
|
||||
adjacent expressions, which is what produced "26reviews". */}
|
||||
{t('mint.rating.reviews', { n: mint.review_count })}
|
||||
{unrated > 0 && <><br />{t('mint.rating.unrated', { n: unrated })}</>}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<div class="dist" aria-hidden="true">
|
||||
{
|
||||
([5, 4, 3, 2, 1] as const).map((n) => {
|
||||
const count = dist[String(n) as '1' | '2' | '3' | '4' | '5'];
|
||||
return (
|
||||
<div class="dist-row">
|
||||
<span class="n">{n}</span>
|
||||
<span class="dist-bar">
|
||||
<span
|
||||
class:list={['dist-fill', { low: n === 1 }]}
|
||||
style={`width:${Math.round((count / distMax) * 100)}%`}
|
||||
/>
|
||||
</span>
|
||||
<span class="c">{f.number(count)}</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="v-cell" data-reveal data-reveal-delay="60">
|
||||
<div class="v-label">{t('mint.activity.label')}</div>
|
||||
<div class="v-big">{f.shortDuration(mint.last_review_at)}</div>
|
||||
<div class="v-sub">
|
||||
{mint.last_review_at ? t('mint.activity.since') : t('mint.activity.noReviews')}
|
||||
{mint.reviews_90d > 0 && (
|
||||
<><br />{t('mint.activity.recent', { n: mint.reviews_90d })}</>
|
||||
)}
|
||||
</div>
|
||||
<Sparkline probes={mint.probes_recent} uptime={mint.uptime_30d} />
|
||||
</div>
|
||||
|
||||
<div class="v-cell" data-reveal data-reveal-delay="120">
|
||||
<div class="v-label">{t('mint.limits.label')}</div>
|
||||
{/* Published limits mean nothing in a direction that is switched off, so that
|
||||
line is struck out and labelled rather than left looking like a promise. */}
|
||||
<div data-live-limits set:html={limitsHtml(info?.nuts, caps, f)} />
|
||||
</div>
|
||||
|
||||
<div class="v-cell" data-reveal data-reveal-delay="180">
|
||||
<div class="v-label">{t('mint.software.label')}</div>
|
||||
<div class="v-mono">
|
||||
{/* The version string is what the mint reports about itself, so it renders
|
||||
as-is. Only the word standing in for a mint that reported nothing is ours. */}
|
||||
<b>{software ? software.replace('/', ' ') : t('mint.software.unknown')}</b><br />
|
||||
<span data-live-checked>
|
||||
{
|
||||
offline
|
||||
? mint.last_online
|
||||
? t('mint.lastSeen', { when: f.relative(mint.last_online) })
|
||||
: t('mint.neverReached')
|
||||
: t('mint.checked', { when: f.relative(mint.last_probe) })
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="cols">
|
||||
<Reviews mint={mint} />
|
||||
|
||||
<aside class="side">
|
||||
{
|
||||
(motd || (isBeta && operatorNote)) && (
|
||||
<div class="panel" data-reveal>
|
||||
<h2 class="side-title">{t('mint.operator.title')}</h2>
|
||||
{motd && (
|
||||
<div class="motd">
|
||||
<div class="lbl">{t('mint.operator.motd')}</div>
|
||||
{/* The MOTD is the operator's own words, rendered untouched. */}
|
||||
{motd}
|
||||
</div>
|
||||
)}
|
||||
{isBeta && (
|
||||
<p class="warn">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#F0A93B" stroke-width="2" aria-hidden="true">
|
||||
<path d="m21.7 18-8-14a2 2 0 0 0-3.4 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.7-3Z" />
|
||||
<path d="M12 9v4M12 17h.01" />
|
||||
</svg>
|
||||
<span>{t('mint.operator.beta')}</span>
|
||||
</p>
|
||||
)}
|
||||
{/* Also the operator's own copy. Never translated. */}
|
||||
{operatorNote && <p class="op-desc">{operatorNote}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<div class="panel" data-reveal>
|
||||
<h2 class="side-title">{t('mint.nuts.title')}</h2>
|
||||
{
|
||||
mint.nuts.length === 0 ? (
|
||||
<p class="side-empty">
|
||||
{offline ? t('mint.nuts.noneOffline') : t('mint.nuts.noneYet')}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div class="nut-hi" data-live-nuts set:html={nutRowsHtml(mint.nuts, caps, f)} />
|
||||
{rest.length > 0 && (
|
||||
<div class="nut-rest">
|
||||
{rest.map((n) => (
|
||||
<span class="nut-chip" title={localNutName(n, f)}>{n.padStart(2, '0')}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p class="nut-more">
|
||||
{t('mint.nuts.supported', { n: mint.nuts.length, total: KNOWN_NUT_COUNT })}
|
||||
<span aria-hidden="true"> · </span>
|
||||
<a href={`${localePath('/about', locale)}#nuts`}>{t('mint.nuts.whatAre')}</a>
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="panel" data-reveal>
|
||||
<h2 class="side-title">{t('mint.details.title')}</h2>
|
||||
<div class="kv">
|
||||
{
|
||||
mint.pubkey && (
|
||||
<div class="kv-row">
|
||||
<span class="kv-k">{t('mint.details.pubkey')}</span>
|
||||
<CopyableId
|
||||
class="kv-v"
|
||||
value={mint.pubkey}
|
||||
label={truncateMiddle(mint.pubkey, 10, 6)}
|
||||
kind={t('mint.kind.mintPubkey')}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
nostrContact && (
|
||||
<div class="kv-row">
|
||||
<span class="kv-k">{t('mint.details.nostr')}</span>
|
||||
<CopyableId
|
||||
class="kv-v"
|
||||
value={nostrContact}
|
||||
label={nostrContact.startsWith('npub1') ? shortNpub(nostrContact) : truncateMiddle(nostrContact, 10, 6)}
|
||||
kind={t('mint.kind.nostrAddress')}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
emailContact && (
|
||||
<div class="kv-row">
|
||||
<span class="kv-k">{t('mint.details.email')}</span>
|
||||
<CopyableId class="kv-v" value={emailContact} kind={t('mint.kind.emailAddress')} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
serverTime && (
|
||||
<div class="kv-row">
|
||||
<span class="kv-k">{t('mint.details.serverTime')}</span>
|
||||
<span class="kv-v">{serverTime}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<div class="kv-row">
|
||||
<span class="kv-k">{t('mint.details.firstSeen')}</span>
|
||||
<span class="kv-v">{f.monthYear(mint.first_seen)}</span>
|
||||
</div>
|
||||
{
|
||||
info?.tos_url && (
|
||||
<div class="kv-row">
|
||||
<span class="kv-k">{t('mint.details.terms')}</span>
|
||||
<a class="kv-v kv-link" href={info.tos_url} rel="noopener nofollow">{displayDomain(info.tos_url)}</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
Three more mints, and the way out to the rest.
|
||||
|
||||
Before this, a mint page's only links out were the breadcrumb, the NUT explainer
|
||||
and the disclaimer: a reader who decided against this mint had nowhere to go but
|
||||
back, and a crawler arriving here from a search result found the index and
|
||||
nothing else. These three are the ring neighbours computed in `getStaticPaths` —
|
||||
deterministic, so the page does not churn between builds, and never this mint.
|
||||
|
||||
No `capabilities`, so the cards show no melt-only or frozen chip: that comparison
|
||||
is the point of /mints and the home grid, and earning it here would mean a detail
|
||||
fetch per suggestion.
|
||||
*/}
|
||||
{
|
||||
related.length > 0 && (
|
||||
<section class="more-mints" aria-labelledby="more-mints">
|
||||
<div class="sec-head" data-reveal>
|
||||
<h2 class="sec-title" id="more-mints">{t('mint.more.title')}</h2>
|
||||
<a class="sec-link" href={localePath('/mints', locale)}>{t('mint.more.all')}</a>
|
||||
</div>
|
||||
<div class="mint-grid">
|
||||
{related.map((item) => <MintCard mint={item} />)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
{/*
|
||||
The disclaimer belongs where the decision is made, not only in the site footer.
|
||||
Everything above this line helps someone pick a mint; this is the line that says
|
||||
what picking one means.
|
||||
*/}
|
||||
<p class="mint-disclaimer">
|
||||
{t('mint.disclaimer')}
|
||||
<a href={localePath('/disclaimer', locale)}>{t('mint.disclaimerLink')}</a>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<MintQr url={mint.url} name={name} />
|
||||
<MintLive host={mint.host} />
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
/*
|
||||
Plain margin, no `.section`: that class carries the page's max-width and gutter,
|
||||
which this block is already inside. Nesting the two double-padded the row and left
|
||||
the cards narrower than everything above them.
|
||||
*/
|
||||
.more-mints { margin-top: 52px; }
|
||||
|
||||
.mint-disclaimer {
|
||||
margin-top: 30px; padding-top: 18px; border-top: 1px solid var(--line-soft);
|
||||
font-size: 13px; color: var(--faint); max-width: 720px;
|
||||
}
|
||||
.mint-disclaimer a { color: var(--amber); border-bottom: 1px dotted rgba(240, 169, 59, .4); }
|
||||
|
||||
.mint-head { display: flex; align-items: flex-start; gap: 20px; flex-wrap: wrap; margin-bottom: 22px; }
|
||||
.mint-icon {
|
||||
width: 72px; height: 72px; border-radius: 18px; flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-family: var(--display); font-weight: 700; font-size: 32px; color: #0E0D0B;
|
||||
border: 1px solid rgba(255, 255, 255, .14); object-fit: cover; overflow: hidden;
|
||||
}
|
||||
.mint-id { min-width: 240px; flex: 1; }
|
||||
.mint-name-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
h1 { font-family: var(--display); font-size: 32px; font-weight: 700; letter-spacing: -.02em; line-height: 1.1; overflow-wrap: anywhere; }
|
||||
.mint-domain {
|
||||
display: flex; align-items: center; gap: 8px; margin-top: 8px;
|
||||
font-family: var(--mono); font-size: 14px; color: var(--muted); overflow-wrap: anywhere;
|
||||
}
|
||||
.mint-tags { display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
|
||||
.tag { font-size: 12px; font-weight: 500; color: var(--muted); border: 1px solid var(--line); border-radius: 7px; padding: 3px 9px; }
|
||||
.head-actions { margin-left: auto; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||
|
||||
.verdict {
|
||||
display: grid; grid-template-columns: 1.35fr 1fr 1fr 1fr;
|
||||
border: 1px solid var(--line); border-radius: var(--radius);
|
||||
background: var(--card); overflow: hidden; margin-bottom: 28px;
|
||||
}
|
||||
/* Every cell shares one padding, so the four labels sit on one baseline and the
|
||||
four values start on the next. */
|
||||
.v-cell { padding: 20px; border-right: 1px solid var(--line-soft); position: relative; }
|
||||
.v-cell:last-child { border-right: none; }
|
||||
.v-label {
|
||||
font-size: 11px; font-weight: 600; letter-spacing: .09em; text-transform: uppercase;
|
||||
color: var(--faint); margin-bottom: 10px; display: flex; align-items: center; gap: 6px;
|
||||
height: 16px;
|
||||
}
|
||||
.v-big { font-family: var(--display); font-size: 34px; font-weight: 700; letter-spacing: -.02em; line-height: 1; }
|
||||
.v-sub { font-size: 13px; color: var(--muted); margin-top: 6px; }
|
||||
.rating-body { display: flex; gap: 24px; align-items: flex-start; flex-wrap: wrap; }
|
||||
.rating-figure { flex: 0 0 auto; }
|
||||
.rating-body .dist { flex: 1 1 150px; }
|
||||
.rating-cell .stars { font-size: 15px; margin-top: 7px; }
|
||||
.cols { display: grid; grid-template-columns: 1fr 372px; gap: 26px; align-items: start; }
|
||||
.side { display: flex; flex-direction: column; gap: 20px; }
|
||||
.side .panel { padding: 20px; }
|
||||
.side-empty { font-size: 13.5px; color: var(--muted); }
|
||||
|
||||
.motd {
|
||||
border-left: 3px solid var(--amber); background: var(--card2);
|
||||
border-radius: 0 9px 9px 0; padding: 12px 15px; font-size: 14px; color: #D8D2C4;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.motd .lbl { font-size: 11px; color: var(--faint); letter-spacing: .06em; text-transform: uppercase; margin-bottom: 4px; }
|
||||
.warn { margin-top: 12px; font-size: 12.5px; color: var(--muted); line-height: 1.55; display: flex; gap: 9px; }
|
||||
.warn svg { flex-shrink: 0; margin-top: 2px; }
|
||||
.op-desc { margin-top: 12px; font-size: 13px; color: var(--muted); line-height: 1.55; }
|
||||
|
||||
/* The rows themselves are styled in global.css: lib/mint-state.ts builds them for
|
||||
both this page and the live island, and HTML built in a script never picks up
|
||||
Astro's scoping attribute. What is left here is the page's own markup. */
|
||||
/* Small enough that the usual eight or nine leftovers fit on two rows instead of
|
||||
wrapping a single orphan onto a third. */
|
||||
.nut-rest { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.nut-chip { font-family: var(--mono); font-size: 10.5px; line-height: 1.5; color: var(--muted); border: 1px solid var(--line); border-radius: 5px; padding: 2px 6px; }
|
||||
.nut-more { font-size: 12px; color: var(--faint); margin-top: 10px; }
|
||||
.nut-more a { color: var(--muted); border-bottom: 1px dotted var(--faint); }
|
||||
.nut-more a:hover { color: var(--amber); }
|
||||
|
||||
.kv-link { color: var(--amber); }
|
||||
/* The copyable identifier is the value column itself, so it takes its metrics. */
|
||||
.kv-row :global(.cid.kv-v) { flex: 1; font-size: 12.5px; color: var(--text); }
|
||||
.kv-row :global(.cid.kv-v:hover) { color: var(--amber); }
|
||||
|
||||
@media (max-width: 1020px) {
|
||||
.cols { grid-template-columns: 1fr; }
|
||||
.verdict { grid-template-columns: 1fr 1fr; }
|
||||
.v-cell:nth-child(2) { border-right: none; }
|
||||
.v-cell:nth-child(1), .v-cell:nth-child(2) { border-bottom: 1px solid var(--line-soft); }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.verdict { grid-template-columns: 1fr; }
|
||||
.v-cell { border-right: none; border-bottom: 1px solid var(--line-soft); }
|
||||
.v-cell:last-child { border-bottom: none; }
|
||||
.head-actions { margin-left: 0; width: 100%; }
|
||||
/* Three actions: two to a row rather than three squeezed onto one. */
|
||||
.head-actions .btn { flex: 1 1 44%; justify-content: center; }
|
||||
.page { padding: 20px var(--page-pad) 60px; }
|
||||
h1 { font-size: 26px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import { wireCopyableIds, wireCopyButtons } from '../../../lib/client';
|
||||
import { countUp, onReady, whenRevealed } from '../../../scripts/reveal';
|
||||
|
||||
onReady(() => {
|
||||
wireCopyButtons();
|
||||
wireCopyableIds();
|
||||
|
||||
// The rating counts up once its cell has arrived, not on a timer of its own.
|
||||
const big = document.querySelector<HTMLElement>('[data-count-up]');
|
||||
const cell = big?.closest<HTMLElement>('[data-reveal]');
|
||||
if (big && cell) {
|
||||
const target = Number(big.dataset['countUp']);
|
||||
const decimals = Number(big.dataset['countDecimals'] ?? '0');
|
||||
whenRevealed(cell, () => countUp(big, target, { decimals }));
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,398 @@
|
||||
---
|
||||
import Base from '../../layouts/Base.astro';
|
||||
import MintCard from '../../components/MintCard.astro';
|
||||
import { readCapabilities } from '@cashumints/shared';
|
||||
import { fetchMint, fetchMints } from '../../lib/api';
|
||||
import { localePath, useI18n, type Locale } from '../../i18n';
|
||||
import { itemListNode } from '../../lib/schema';
|
||||
import { isIndexableMint } from '../../lib/seo';
|
||||
import { localePaths } from '../../i18n/paths';
|
||||
|
||||
/** One page per locale: `/mints`, `/es/mints`, `/nl/mints`. */
|
||||
export const getStaticPaths = localePaths;
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
}
|
||||
const { locale } = Astro.props;
|
||||
|
||||
// `t` formats the numbers inside its own strings, so no separate formatter is needed.
|
||||
const t = useI18n(locale);
|
||||
|
||||
const mints = await fetchMints();
|
||||
|
||||
/*
|
||||
The list payload carries no NUT information, so the "melt only" and "frozen" chips
|
||||
need each mint's cached info. One detail fetch per mint, at build time, against the
|
||||
API on the same machine: the mint pages already do exactly this in getStaticPaths.
|
||||
A mint whose detail cannot be read simply gets no chip.
|
||||
*/
|
||||
const capabilities = await Promise.all(
|
||||
mints.map((mint) =>
|
||||
fetchMint(mint.host).then((detail) => readCapabilities(detail.info?.nuts)).catch(() => null),
|
||||
),
|
||||
);
|
||||
const online = mints.filter((m) => m.status === 'online').length;
|
||||
const offline = mints.filter((m) => m.status === 'offline').length;
|
||||
|
||||
const description = t('mints.description', { total: mints.length, online, offline });
|
||||
|
||||
/*
|
||||
* The index, as a list a crawler can read in order.
|
||||
*
|
||||
* Only the mints whose own pages are indexable: listing a page here while telling a
|
||||
* crawler not to index it is a contradiction. The order is the server-rendered one,
|
||||
* which is the score order; the sort control rewrites the DOM but not this markup,
|
||||
* so `ItemListOrderDescending` describes what shipped.
|
||||
*/
|
||||
const canonical = new URL(localePath('/mints', locale), Astro.site).href;
|
||||
const schema = [
|
||||
itemListNode({
|
||||
canonical,
|
||||
fragment: 'mints',
|
||||
name: t('mints.heading'),
|
||||
items: mints.filter(isIndexableMint).map((mint) => ({
|
||||
name: mint.name ?? mint.host,
|
||||
url: new URL(localePath(`/mint/${mint.host}`, locale), Astro.site).href,
|
||||
})),
|
||||
}),
|
||||
];
|
||||
---
|
||||
|
||||
<Base title={t('mints.title')} description={description} current="mints" schema={schema}>
|
||||
{/*
|
||||
The same .page container the home page and the mint pages use, so the heading,
|
||||
the controls, the count line and the grid all share one left edge and one width.
|
||||
*/}
|
||||
<div class="page">
|
||||
<div class="index-head">
|
||||
<h1>{t('mints.heading')}</h1>
|
||||
<p class="lede">{t('mints.lede')}</p>
|
||||
</div>
|
||||
|
||||
<div class="controls" data-mint-controls>
|
||||
<form class="search" role="search" onsubmit="return false">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" />
|
||||
</svg>
|
||||
<label class="sr-only" for="mint-search">{t('mints.search.label')}</label>
|
||||
<input
|
||||
id="mint-search"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
placeholder={t('mints.search.placeholder')}
|
||||
data-search-input
|
||||
/>
|
||||
<kbd aria-hidden="true">/</kbd>
|
||||
</form>
|
||||
|
||||
<div class="control-group">
|
||||
<label class="sr-only" for="mint-sort">{t('mints.sort.label')}</label>
|
||||
<div class="select-wrap">
|
||||
<select id="mint-sort" data-sort>
|
||||
<option value="score">{t('mints.sort.score')}</option>
|
||||
<option value="rating">{t('mints.sort.rating')}</option>
|
||||
<option value="reviews">{t('mints.sort.reviews')}</option>
|
||||
<option value="recent">{t('mints.sort.recent')}</option>
|
||||
<option value="seen">{t('mints.sort.seen')}</option>
|
||||
</select>
|
||||
<svg class="chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true">
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
A pressed pill rather than a native checkbox: it is the same control the
|
||||
review filters use, and it is a real button, so it keeps the focus ring.
|
||||
*/}
|
||||
<button class="toggle-chip" type="button" data-hide-offline aria-pressed="false">
|
||||
<span class="toggle-dot" aria-hidden="true"></span>
|
||||
{t('mints.hideOffline')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="result-count" data-result-count aria-live="polite">
|
||||
{t('mints.showing', { total: mints.length, online, offline })}
|
||||
</p>
|
||||
|
||||
<div class="mint-grid" data-mint-grid>
|
||||
{mints.map((mint, i) => <MintCard mint={mint} rank={i + 1} capabilities={capabilities[i]} />)}
|
||||
</div>
|
||||
|
||||
<p class="no-results" data-no-results hidden>
|
||||
{t('mints.noResults')}
|
||||
<button class="link-btn" type="button" data-clear-search>{t('mints.clearSearch')}</button>
|
||||
</p>
|
||||
</div>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.index-head { margin-bottom: 24px; }
|
||||
.index-head h1 { font-family: var(--display); font-size: 32px; font-weight: 700; letter-spacing: -.02em; line-height: 1.1; }
|
||||
.index-head .lede { max-width: 620px; font-size: 15.5px; margin-top: 10px; }
|
||||
|
||||
.controls { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin-bottom: 14px; }
|
||||
.search {
|
||||
display: flex; align-items: center; gap: 12px; flex: 1 1 320px; min-width: 240px;
|
||||
background: var(--card); border: 1px solid var(--line);
|
||||
border-radius: 12px; padding: 10px 16px;
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
}
|
||||
.search:focus-within { border-color: var(--amber-dim); box-shadow: 0 0 0 3px rgba(240, 169, 59, .08); }
|
||||
.search svg { color: var(--faint); flex-shrink: 0; }
|
||||
.search input {
|
||||
flex: 1; background: none; border: none; outline: none; min-width: 0;
|
||||
color: var(--text); font-family: var(--body); font-size: 15px;
|
||||
}
|
||||
.search input::placeholder { color: var(--faint); }
|
||||
.search kbd {
|
||||
font-family: var(--mono); font-size: 11.5px; color: var(--faint);
|
||||
border: 1px solid var(--line); border-radius: 6px; padding: 2px 7px; flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Search grows; sort and toggle sit together at the right and wrap as a pair. */
|
||||
.control-group { display: flex; gap: 10px; align-items: center; margin-left: auto; flex-wrap: wrap; }
|
||||
|
||||
.select-wrap { position: relative; display: flex; align-items: center; }
|
||||
.select-wrap select {
|
||||
appearance: none; -webkit-appearance: none;
|
||||
background: var(--card); border: 1px solid var(--line); border-radius: 9px;
|
||||
color: var(--text); font-family: var(--body); font-size: 14px;
|
||||
padding: 10px 34px 10px 14px; cursor: pointer;
|
||||
transition: border-color .15s;
|
||||
}
|
||||
.select-wrap select:hover { border-color: #38342A; }
|
||||
.select-wrap .chevron { position: absolute; right: 12px; color: var(--faint); pointer-events: none; }
|
||||
.select-wrap option { background: var(--card); color: var(--text); }
|
||||
|
||||
.toggle-chip {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
font-family: var(--body); font-size: 13.5px; font-weight: 500;
|
||||
color: var(--muted); cursor: pointer;
|
||||
border: 1px solid var(--line); background: none; border-radius: 999px;
|
||||
padding: 9px 15px;
|
||||
transition: color .15s, border-color .15s, background .15s;
|
||||
}
|
||||
.toggle-chip:hover { color: var(--text); border-color: #38342A; }
|
||||
.toggle-dot {
|
||||
width: 7px; height: 7px; border-radius: 50%; background: var(--faint); flex-shrink: 0;
|
||||
transition: background .15s;
|
||||
}
|
||||
.toggle-chip[aria-pressed='true'] {
|
||||
color: #141109; background: var(--amber); border-color: var(--amber); font-weight: 600;
|
||||
}
|
||||
.toggle-chip[aria-pressed='true'] .toggle-dot { background: #141109; }
|
||||
|
||||
.result-count { font-size: 13px; color: var(--faint); margin-bottom: 16px; font-family: var(--mono); }
|
||||
|
||||
/*
|
||||
* A card leaving the results fades and settles back a touch, 120ms, then it is
|
||||
* hidden and the grid reflows around it. Positions are not animated: the browser's
|
||||
* own reflow is instant and honest, and 58 cards sliding to new places would be a
|
||||
* light show rather than a search result.
|
||||
*/
|
||||
.mint-grid :global(.mint-card.leaving) {
|
||||
transform: scale(.98);
|
||||
transition: opacity 120ms var(--ease-out), transform 120ms var(--ease-out);
|
||||
}
|
||||
|
||||
.no-results { color: var(--muted); font-size: 14.5px; padding: 34px 0; }
|
||||
.link-btn {
|
||||
background: none; border: none; padding: 0; cursor: pointer;
|
||||
font-family: var(--body); font-size: inherit; color: var(--amber);
|
||||
border-bottom: 1px solid var(--amber-dim);
|
||||
}
|
||||
.link-btn:hover { border-bottom-color: var(--amber); }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
/* Sort and toggle take their own row, still sharing the grid's left edge. */
|
||||
.control-group { margin-left: 0; width: 100%; }
|
||||
.select-wrap, .select-wrap select { flex: 1; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import { wireCopyableIds } from '../../lib/client';
|
||||
import { useI18n } from '../../i18n/client';
|
||||
import { enterStagger, onLeave, prefersReducedMotion, onReady, swapText } from '../../scripts/reveal';
|
||||
|
||||
/** How long a card takes to fade out of the results before it is hidden. */
|
||||
const LEAVE_MS = 120;
|
||||
|
||||
const setup = (): void => {
|
||||
wireCopyableIds();
|
||||
|
||||
// Re-read on every arrival, so the count line under the filters follows the page.
|
||||
// `t` formats its own numbers, so the grouping separator follows the locale too.
|
||||
const t = useI18n();
|
||||
|
||||
const grid = document.querySelector<HTMLElement>('[data-mint-grid]');
|
||||
const searchInput = document.querySelector<HTMLInputElement>('[data-search-input]');
|
||||
const sortSelect = document.querySelector<HTMLSelectElement>('[data-sort]');
|
||||
const hideOffline = document.querySelector<HTMLButtonElement>('[data-hide-offline]');
|
||||
const countEl = document.querySelector<HTMLElement>('[data-result-count]');
|
||||
const noResults = document.querySelector<HTMLElement>('[data-no-results]');
|
||||
const clearSearch = document.querySelector<HTMLButtonElement>('[data-clear-search]');
|
||||
|
||||
if (grid && searchInput && sortSelect && hideOffline) {
|
||||
const cards = [...grid.querySelectorAll<HTMLElement>('[data-mint-card]')];
|
||||
const num = (card: HTMLElement, key: string) => Number(card.dataset[key] ?? 0);
|
||||
const offlineRank = (card: HTMLElement) => (card.dataset['status'] === 'offline' ? 1 : 0);
|
||||
|
||||
const SORTS: Record<string, (a: HTMLElement, b: HTMLElement) => number> = {
|
||||
// Default keeps the API's rule: online first, then score. Every other sort
|
||||
// ranks offline mints on their merits, so a dead mint with 40 good reviews
|
||||
// still shows up under "Most reviewed".
|
||||
score: (a, b) => offlineRank(a) - offlineRank(b) || num(b, 'score') - num(a, 'score'),
|
||||
rating: (a, b) => num(b, 'rating') - num(a, 'rating') || num(b, 'reviews') - num(a, 'reviews'),
|
||||
reviews: (a, b) => num(b, 'reviews') - num(a, 'reviews'),
|
||||
recent: (a, b) => num(b, 'lastReview') - num(a, 'lastReview'),
|
||||
seen: (a, b) => num(b, 'lastOnline') - num(a, 'lastOnline'),
|
||||
};
|
||||
|
||||
/**
|
||||
* One pending fade per card, at most.
|
||||
*
|
||||
* Typing into the search box calls `apply` on every keystroke, so a card can be
|
||||
* told to leave and then to come back before its fade has finished. Cancelling
|
||||
* the timer is what keeps those from stacking: the card either goes out once, or
|
||||
* never starts.
|
||||
*/
|
||||
const leaving = new Map<HTMLElement, number>();
|
||||
|
||||
function show(card: HTMLElement, visible: boolean): void {
|
||||
const pending = leaving.get(card);
|
||||
if (pending !== undefined) {
|
||||
window.clearTimeout(pending);
|
||||
leaving.delete(card);
|
||||
}
|
||||
|
||||
if (visible) {
|
||||
const wasHidden = card.hidden;
|
||||
card.classList.remove('leaving');
|
||||
card.hidden = false;
|
||||
// Back in the results after being filtered out: a plain fade, no rise. It
|
||||
// was here before, it is not new.
|
||||
if (wasHidden && !prefersReducedMotion()) {
|
||||
card.classList.add('appearing');
|
||||
card.addEventListener('animationend', () => card.classList.remove('appearing'), { once: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (card.hidden) return;
|
||||
if (prefersReducedMotion()) {
|
||||
card.hidden = true;
|
||||
return;
|
||||
}
|
||||
card.classList.add('leaving');
|
||||
leaving.set(
|
||||
card,
|
||||
window.setTimeout(() => {
|
||||
leaving.delete(card);
|
||||
card.hidden = true;
|
||||
card.classList.remove('leaving');
|
||||
}, LEAVE_MS),
|
||||
);
|
||||
}
|
||||
|
||||
function apply(): void {
|
||||
const query = searchInput!.value.trim().toLowerCase();
|
||||
// A pasted mint URL should find the mint, so match on the bare domain too.
|
||||
const needle = query.replace(/^https?:\/\//, '').replace(/\/+$/, '');
|
||||
const hide = hideOffline!.getAttribute('aria-pressed') === 'true';
|
||||
|
||||
let visible = 0;
|
||||
let onlineCount = 0;
|
||||
let offlineCount = 0;
|
||||
|
||||
for (const card of cards) {
|
||||
const matches =
|
||||
needle === '' ||
|
||||
(card.dataset['name'] ?? '').includes(needle) ||
|
||||
(card.dataset['domain'] ?? '').includes(needle);
|
||||
const isOffline = card.dataset['status'] === 'offline';
|
||||
const keep = matches && !(hide && isOffline);
|
||||
|
||||
show(card, keep);
|
||||
if (keep) {
|
||||
visible++;
|
||||
if (isOffline) offlineCount++;
|
||||
else if (card.dataset['status'] === 'online') onlineCount++;
|
||||
}
|
||||
}
|
||||
|
||||
const sorted = [...cards].sort(SORTS[sortSelect!.value] ?? SORTS['score']!);
|
||||
for (const card of sorted) grid!.append(card);
|
||||
|
||||
// A rank chip is a claim about position in the default order. It means nothing
|
||||
// in a rating sort and nothing in a filtered list, so it is hidden in both.
|
||||
const ranked = sortSelect!.value === 'score' && needle === '' && !hide;
|
||||
let rank = 0;
|
||||
for (const card of sorted) {
|
||||
const chip = card.querySelector<HTMLElement>('.mc-rank');
|
||||
if (!chip) continue;
|
||||
if (!ranked) {
|
||||
chip.hidden = true;
|
||||
continue;
|
||||
}
|
||||
rank++;
|
||||
chip.hidden = false;
|
||||
chip.textContent = `#${rank}`;
|
||||
chip.classList.toggle('gold', rank === 1);
|
||||
}
|
||||
|
||||
if (countEl) {
|
||||
// The count is the answer to what the reader just typed, so it cross-fades
|
||||
// rather than flicking from one number to another.
|
||||
swapText(
|
||||
countEl,
|
||||
visible === cards.length
|
||||
? t('mints.showing', { total: visible, online: onlineCount, offline: offlineCount })
|
||||
: t('mints.showingFiltered', {
|
||||
shown: visible,
|
||||
total: cards.length,
|
||||
online: onlineCount,
|
||||
offline: offlineCount,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (noResults) noResults.hidden = visible > 0;
|
||||
}
|
||||
|
||||
searchInput.addEventListener('input', apply);
|
||||
sortSelect.addEventListener('change', () => {
|
||||
apply();
|
||||
// A new order is a new list: it arrives the way the grid arrived, once, and
|
||||
// the stagger is capped so the last card is never more than 400ms behind.
|
||||
enterStagger(cards.filter((card) => !card.hidden));
|
||||
});
|
||||
hideOffline.addEventListener('click', () => {
|
||||
const on = hideOffline.getAttribute('aria-pressed') === 'true';
|
||||
hideOffline.setAttribute('aria-pressed', String(!on));
|
||||
apply();
|
||||
});
|
||||
clearSearch?.addEventListener('click', () => {
|
||||
searchInput.value = '';
|
||||
searchInput.focus();
|
||||
apply();
|
||||
});
|
||||
|
||||
// Deep links from the home page quick filters: /mints?q=…&sort=…
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const q = params.get('q');
|
||||
const sort = params.get('sort');
|
||||
if (q) searchInput.value = q;
|
||||
if (sort && sort in SORTS) sortSelect.value = sort;
|
||||
if (q || sort) apply();
|
||||
|
||||
onLeave(() => {
|
||||
for (const timer of leaving.values()) window.clearTimeout(timer);
|
||||
leaving.clear();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
onReady(setup);
|
||||
</script>
|
||||
@@ -0,0 +1,111 @@
|
||||
---
|
||||
/**
|
||||
* Privacy. The honest version is short and unusually good, so it is stated plainly
|
||||
* rather than padded into a policy.
|
||||
*
|
||||
* Anything that depends on how the site is deployed (what the web server logs and for
|
||||
* how long) is written conditionally and marked, because this repo cannot know the
|
||||
* operator's nginx and logrotate settings. See NOTES-PAGES.md.
|
||||
*/
|
||||
import Base from '../../layouts/Base.astro';
|
||||
import { localePath, useI18n, type Locale } from '../../i18n';
|
||||
import { localePaths } from '../../i18n/paths';
|
||||
|
||||
/** One page per locale: `/privacy`, `/es/privacy`, `/nl/privacy`. */
|
||||
export const getStaticPaths = localePaths;
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
}
|
||||
const { locale } = Astro.props;
|
||||
|
||||
const t = useI18n(locale);
|
||||
|
||||
const GITHUB = 'https://github.com/Azzamo-net/cashumints.space';
|
||||
/** Bump `disclaimer.updated` in every catalog when the text changes. */
|
||||
const updated = t('disclaimer.updated');
|
||||
|
||||
const description = t('privacy.description');
|
||||
|
||||
/*
|
||||
* Three list items that open with a bold lead, and two sentences with a link. Each is
|
||||
* one catalog string with a placeholder, so the lead and the link can sit wherever the
|
||||
* language puts them rather than always first.
|
||||
*/
|
||||
const bold = (value: string): string => `<b>${value}</b>`;
|
||||
|
||||
const operatorNote =
|
||||
bold(t('privacy.logs.operatorLabel')) + ' ' + t('privacy.logs.operator');
|
||||
|
||||
const thirdPartyLi1 = t('privacy.thirdParties.li1', {
|
||||
strong: bold(t('privacy.thirdParties.li1Label')),
|
||||
link: `<a href="${localePath('/about', locale)}#relays">${t('privacy.thirdParties.li1Link')}</a>`,
|
||||
});
|
||||
const thirdPartyLi2 = t('privacy.thirdParties.li2', {
|
||||
strong: bold(t('privacy.thirdParties.li2Label')),
|
||||
});
|
||||
const contactP1 = t('privacy.contact.p1', {
|
||||
github: `<a href="${GITHUB}" rel="noopener">GitHub</a>`,
|
||||
});
|
||||
---
|
||||
|
||||
<Base title={t('privacy.title')} description={description}>
|
||||
<div class="page">
|
||||
<div class="prose">
|
||||
<h1>{t('privacy.heading')}</h1>
|
||||
<p class="lede">{t('privacy.lede', { date: updated })}</p>
|
||||
|
||||
<h2 id="no-accounts">{t('privacy.noAccounts.title')}</h2>
|
||||
<p>{t('privacy.noAccounts.p1')}</p>
|
||||
|
||||
<h2 id="no-tracking">{t('privacy.noTracking.title')}</h2>
|
||||
<p>{t('privacy.noTracking.p1')}</p>
|
||||
|
||||
<h2 id="logs">{t('privacy.logs.title')}</h2>
|
||||
<p>{t('privacy.logs.p1')}</p>
|
||||
<p class="operator-note" set:html={operatorNote} />
|
||||
|
||||
<h2 id="local-storage">{t('privacy.storage.title')}</h2>
|
||||
<p>{t('privacy.storage.p1')}</p>
|
||||
<ul>
|
||||
<li>{t('privacy.storage.li1')}</li>
|
||||
<li>{t('privacy.storage.li2')}</li>
|
||||
</ul>
|
||||
<p>{t('privacy.storage.p2')}</p>
|
||||
|
||||
<h2 id="third-parties">{t('privacy.thirdParties.title')}</h2>
|
||||
<p>{t('privacy.thirdParties.p1')}</p>
|
||||
<ul>
|
||||
<li set:html={thirdPartyLi1} />
|
||||
<li set:html={thirdPartyLi2} />
|
||||
</ul>
|
||||
|
||||
<h2 id="publishing">{t('privacy.publishing.title')}</h2>
|
||||
<p>{t('privacy.publishing.p1')}</p>
|
||||
|
||||
<h2 id="permanent">{t('privacy.permanent.title')}</h2>
|
||||
<p>{t('privacy.permanent.p1')}</p>
|
||||
<ul>
|
||||
<li>{t('privacy.permanent.li1')}</li>
|
||||
<li>{t('privacy.permanent.li2')}</li>
|
||||
<li>{t('privacy.permanent.li3')}</li>
|
||||
<li>{t('privacy.permanent.li4')}</li>
|
||||
</ul>
|
||||
<p>{t('privacy.permanent.p2')}</p>
|
||||
|
||||
<h2 id="contact">{t('privacy.contact.title')}</h2>
|
||||
<p set:html={contactP1} />
|
||||
</div>
|
||||
</div>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
/* A note addressed to whoever deploys this, not to the reader. Marked as such so it
|
||||
is obvious it is placeholder text until the operator confirms the real number. */
|
||||
.operator-note {
|
||||
border: 1px solid var(--line); border-left: 3px solid var(--amber-dim);
|
||||
border-radius: 10px; background: var(--card);
|
||||
padding: 14px 16px; font-size: 13.5px;
|
||||
}
|
||||
.operator-note b { color: var(--text); }
|
||||
</style>
|
||||
@@ -0,0 +1,547 @@
|
||||
---
|
||||
/**
|
||||
* The global review feed: every mint's reviews in one list, newest first.
|
||||
*
|
||||
* Prerendered from relay data at build time so the page has real reviews for a
|
||||
* crawler and for a reader with JavaScript off, then the island re-queries the relays
|
||||
* and replaces the list with everything, paginated and filterable. Cards are built by
|
||||
* `lib/review-cards.ts`, the same functions the mint page's reviews panel uses, so a
|
||||
* review looks and behaves identically wherever you meet it.
|
||||
*/
|
||||
import Base from '../../layouts/Base.astro';
|
||||
import { fetchMints, fetchStats } from '../../lib/api';
|
||||
import { fetchReviewFeed } from '../../lib/nostr-build';
|
||||
import { reviewHtml } from '../../lib/review-cards';
|
||||
import type { LoadedReview } from '../../lib/reviews-client';
|
||||
import { displayDomain } from '../../lib/format';
|
||||
import { localePath, useI18n, type Locale } from '../../i18n';
|
||||
import { formatters } from '../../i18n/format';
|
||||
import { localePaths } from '../../i18n/paths';
|
||||
|
||||
/** One page per locale: `/reviews`, `/es/reviews`, `/nl/reviews`. */
|
||||
export const getStaticPaths = localePaths;
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
}
|
||||
const { locale } = Astro.props;
|
||||
|
||||
const t = useI18n(locale);
|
||||
const f = formatters(t);
|
||||
|
||||
/** How many reviews the build renders. Enough to fill the page above and below the fold. */
|
||||
const PRERENDER_COUNT = 30;
|
||||
|
||||
const [mints, stats] = await Promise.all([fetchMints(), fetchStats()]);
|
||||
|
||||
const hostByUrl = new Map(mints.map((mint) => [mint.url, mint.host]));
|
||||
const feed = await fetchReviewFeed(hostByUrl, PRERENDER_COUNT);
|
||||
|
||||
/**
|
||||
* Handed to the island as JSON so the browser can resolve a review's `u` tag back to
|
||||
* a mint without asking the API again. Name and domain are what the card links show.
|
||||
*/
|
||||
const feedMints = mints.map((mint) => {
|
||||
const domain = displayDomain(mint.url);
|
||||
return { url: mint.url, host: mint.host, name: mint.name ?? domain.split('/')[0] ?? domain, domain };
|
||||
});
|
||||
|
||||
const mintByHost = new Map(feedMints.map((mint) => [mint.host, mint]));
|
||||
|
||||
const mintsPayload = JSON.stringify(feedMints).replace(/</g, '\\u003c');
|
||||
|
||||
/** Prerendered cards, from the same builder the island uses. */
|
||||
const cards = feed.map((review) => {
|
||||
const mint = mintByHost.get(review.mint_host);
|
||||
return reviewHtml(review as unknown as LoadedReview, f, {
|
||||
mint: mint ? { href: localePath(`/mint/${mint.host}`, locale), label: mint.domain } : null,
|
||||
});
|
||||
});
|
||||
|
||||
const description = t('feed.description', {
|
||||
reviews: stats.reviews_total,
|
||||
mints: stats.mints_total,
|
||||
});
|
||||
|
||||
/**
|
||||
* Filters and pagination need JavaScript, and so does the live list. With it off the
|
||||
* prerendered cards below are the whole page, so the controls are hidden rather than
|
||||
* left there doing nothing.
|
||||
*/
|
||||
// `!important` because Astro's scoped `.controls` rule carries an extra attribute
|
||||
// selector, so a plain attribute rule here loses to it on specificity.
|
||||
const noscriptFallback =
|
||||
`<noscript><style>[data-feed-controls],[data-feed-pager]{display:none!important}</style>` +
|
||||
`<p class="feed-note">${escapeText(t('feed.noscript', { n: feed.length }))}</p></noscript>`;
|
||||
|
||||
/** This block goes in as raw HTML, so the one translated string in it is escaped. */
|
||||
function escapeText(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/*
|
||||
* One sentence, one link in the middle of it. Interpolated as markup so a translator
|
||||
* can put the link where the sentence wants it.
|
||||
*/
|
||||
const footNote = t('feed.foot', {
|
||||
link: `<a href="${localePath('/disclaimer', locale)}">${t('feed.footLink')}</a>`,
|
||||
});
|
||||
---
|
||||
|
||||
<Base title={t('feed.title')} description={description} current="reviews">
|
||||
<div class="page">
|
||||
<div class="index-head">
|
||||
<h1>{t('feed.heading')}</h1>
|
||||
<p class="lede">{t('feed.lede')}</p>
|
||||
</div>
|
||||
|
||||
<div class="controls" data-feed-controls>
|
||||
<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"></span>
|
||||
</button>
|
||||
<button class="filter" type="button" data-filter="5" aria-pressed="false">
|
||||
{t('reviews.filter.five')}<span data-count="5"></span>
|
||||
</button>
|
||||
<button class="filter" type="button" data-filter="critical" aria-pressed="false">
|
||||
{t('reviews.filter.critical')}<span data-count="critical"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
A text input backed by a datalist rather than a select: 58 options is too many
|
||||
to scroll, and typing "azz" should be enough. Any substring of a name or a
|
||||
domain narrows the list, so a partial guess still works.
|
||||
*/}
|
||||
<div class="mint-filter">
|
||||
<label class="sr-only" for="feed-mint">{t('feed.filterByMint')}</label>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" />
|
||||
</svg>
|
||||
<input
|
||||
id="feed-mint"
|
||||
type="text"
|
||||
list="feed-mint-options"
|
||||
autocomplete="off"
|
||||
placeholder={t('feed.anyMint')}
|
||||
data-mint-filter
|
||||
/>
|
||||
<button class="mint-clear" type="button" data-mint-clear hidden aria-label={t('feed.clearMint')}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M18 6 6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<datalist id="feed-mint-options">
|
||||
{feedMints.map((mint) => <option value={mint.domain}>{mint.name}</option>)}
|
||||
</datalist>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="feed-count" data-feed-count aria-live="polite">
|
||||
{t('feed.showingBuild', { n: feed.length })}
|
||||
</p>
|
||||
|
||||
<Fragment set:html={noscriptFallback} />
|
||||
|
||||
<section class="panel feed-panel" aria-label={t('feed.label')}>
|
||||
<div data-feed-items>
|
||||
{cards.map((card) => <Fragment set:html={card} />)}
|
||||
{
|
||||
feed.length === 0 && <p class="rev-status">{t('feed.emptyBuild')}</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="load-row" data-feed-pager hidden>
|
||||
<div class="pager" role="group" aria-label={t('common.reviewPages')}></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p class="feed-foot" set:html={footNote} />
|
||||
</div>
|
||||
|
||||
{/*
|
||||
The mint list the island resolves reviews against. `is:inline` keeps Astro from
|
||||
treating a JSON payload as a module to bundle, and the `<` escape keeps a mint
|
||||
name that contains markup from closing this tag early.
|
||||
*/}
|
||||
<script is:inline type="application/json" data-feed-mints set:html={mintsPayload}></script>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.index-head { margin-bottom: 24px; }
|
||||
.index-head h1 { font-family: var(--display); font-size: 32px; font-weight: 700; letter-spacing: -.02em; line-height: 1.1; }
|
||||
.index-head .lede { max-width: 620px; font-size: 15.5px; margin-top: 10px; }
|
||||
|
||||
.controls { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin-bottom: 14px; }
|
||||
.controls .filters { margin-left: 0; }
|
||||
|
||||
.mint-filter {
|
||||
display: flex; align-items: center; gap: 10px; margin-left: auto;
|
||||
background: var(--card); border: 1px solid var(--line);
|
||||
border-radius: 999px; padding: 8px 14px; min-width: 240px;
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
}
|
||||
.mint-filter:focus-within { border-color: var(--amber-dim); box-shadow: 0 0 0 3px rgba(240, 169, 59, .08); }
|
||||
.mint-filter > svg { color: var(--faint); flex-shrink: 0; }
|
||||
.mint-filter input {
|
||||
flex: 1; min-width: 0; background: none; border: none; outline: none;
|
||||
color: var(--text); font-family: var(--mono); font-size: 13px;
|
||||
}
|
||||
.mint-filter input::placeholder { color: var(--faint); font-family: var(--body); font-size: 13.5px; }
|
||||
.mint-clear {
|
||||
background: none; border: none; padding: 0; cursor: pointer; color: var(--faint);
|
||||
display: flex; align-items: center; flex-shrink: 0;
|
||||
}
|
||||
.mint-clear:hover { color: var(--amber); }
|
||||
|
||||
.feed-count { font-size: 13px; color: var(--faint); margin-bottom: 16px; font-family: var(--mono); }
|
||||
/* Injected by the noscript block above, so it cannot be a scoped selector. */
|
||||
:global(.feed-note) { font-size: 13.5px; color: var(--muted); margin-bottom: 16px; max-width: 620px; }
|
||||
|
||||
.feed-panel { overflow: hidden; }
|
||||
.load-row { padding: 16px 20px; display: flex; justify-content: center; border-top: 1px solid var(--line-soft); }
|
||||
.pager { display: flex; gap: 6px; align-items: center; font-size: 13.5px; flex-wrap: wrap; justify-content: center; }
|
||||
|
||||
.feed-foot { margin-top: 22px; font-size: 13.5px; color: var(--faint); }
|
||||
.feed-foot a { color: var(--amber); border-bottom: 1px dotted rgba(240, 169, 59, .4); }
|
||||
|
||||
/* The pager is rebuilt through innerHTML, so its gap marker cannot be scoped. */
|
||||
:global(.pg-gap) { color: var(--faint); padding: 0 2px; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.mint-filter { margin-left: 0; width: 100%; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import { escapeHtml, wireCopyableIds } from '../../lib/client';
|
||||
import { useI18n } from '../../i18n/client';
|
||||
import { formatters } from '../../i18n/format';
|
||||
import { localeFromPath, localePath } from '../../i18n/routing';
|
||||
import { enterStagger, onReady, prefersReducedMotion, scrollBehavior } from '../../scripts/reveal';
|
||||
import { loadFeed, type FeedMint, type FeedReview } from '../../lib/feed-client';
|
||||
import { applyProfiles, isWritten, reviewHtml, summaryHtml, type MintRef } from '../../lib/review-cards';
|
||||
import { loadProfiles } from '../../lib/reviews-client';
|
||||
|
||||
/** A one-line message with the retry button after it. */
|
||||
const retryLine = (message: string): string =>
|
||||
`${escapeHtml(message)} <button class="rev-retry" type="button" data-feed-retry>` +
|
||||
`${escapeHtml(useI18n()('common.retry'))}</button>`;
|
||||
|
||||
const setup = (): void => {
|
||||
wireCopyableIds();
|
||||
|
||||
/*
|
||||
* Rebuilt on every arrival. The feed is the one page where this matters most: it
|
||||
* re-renders its whole list from the relays a second after load, and a translator
|
||||
* captured at module scope would redraw a Dutch page's cards in English.
|
||||
*/
|
||||
const t = useI18n();
|
||||
const f = formatters(t);
|
||||
|
||||
const list = document.querySelector<HTMLElement>('[data-feed-items]');
|
||||
const controls = document.querySelector<HTMLElement>('[data-feed-controls]');
|
||||
const countEl = document.querySelector<HTMLElement>('[data-feed-count]');
|
||||
const pagerWrap = document.querySelector<HTMLElement>('[data-feed-pager]');
|
||||
const mintInput = document.querySelector<HTMLInputElement>('[data-mint-filter]');
|
||||
const mintClear = document.querySelector<HTMLButtonElement>('[data-mint-clear]');
|
||||
const mintsJson = document.querySelector<HTMLScriptElement>('[data-feed-mints]');
|
||||
|
||||
if (list && controls && pagerWrap && mintInput && mintsJson) {
|
||||
const pager = pagerWrap.querySelector<HTMLElement>('.pager')!;
|
||||
const mints = JSON.parse(mintsJson.textContent ?? '[]') as FeedMint[];
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
let all: FeedReview[] = [];
|
||||
let rating: 'all' | '5' | 'critical' = 'all';
|
||||
let mintQuery = '';
|
||||
let page = 1;
|
||||
/** Day summaries the reader has opened, so a filter or page change does not shut them. */
|
||||
const openDays = new Set<string>();
|
||||
/** At most one scheduled repaint, so a filter changed twice does not queue two. */
|
||||
let pendingPaint = 0;
|
||||
|
||||
/* ---------- rendering ---------- */
|
||||
|
||||
function filtered(): FeedReview[] {
|
||||
const needle = mintQuery.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/+$/, '');
|
||||
return all.filter((review) => {
|
||||
if (rating === '5' && review.rating !== 5) return false;
|
||||
if (rating === 'critical' && (review.rating === null || review.rating > 2)) return false;
|
||||
if (needle === '') return true;
|
||||
return (
|
||||
review.mint_domain.toLowerCase().includes(needle) ||
|
||||
review.mint_name.toLowerCase().includes(needle)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
* Mint links, in the locale this page is in.
|
||||
*
|
||||
* Taken from the page's own URL rather than from a prop: the island rebuilds the
|
||||
* list from the relays, including reviews of mints the build never saw, and a
|
||||
* reader on /es/reviews clicking a card must land on /es/mint/..., not on the
|
||||
* English page. The same helper the build uses does the arithmetic, so the two
|
||||
* cannot spell a locale prefix differently.
|
||||
*/
|
||||
const pathLocale = localeFromPath(window.location.pathname);
|
||||
|
||||
const mintRef = (review: FeedReview): MintRef => ({
|
||||
href: localePath(`/mint/${review.mint_host}`, pathLocale),
|
||||
label: review.mint_domain,
|
||||
});
|
||||
|
||||
/* ---------- feed items ---------- */
|
||||
|
||||
/**
|
||||
* A written review is a card. Ratings with no comment are not: dozens of them a
|
||||
* day would bury the reviews that say something, so every one from the same day
|
||||
* collapses into that day's single summary line, which is the rule the mint page
|
||||
* applies per mint. The line sits at the day's newest rating, so the feed stays
|
||||
* in order, and the ratings are one click away inside it.
|
||||
*/
|
||||
type Item =
|
||||
| { kind: 'card'; at: number; review: FeedReview }
|
||||
| { kind: 'day'; at: number; key: string; label: string; reviews: FeedReview[] };
|
||||
|
||||
// The key is machine-facing and stays ISO in every language; only the label the
|
||||
// reader sees is formatted for the locale.
|
||||
const dayKey = (unix: number) => new Date(unix * 1000).toISOString().slice(0, 10);
|
||||
const dayLabel = (unix: number) => f.longDate(unix);
|
||||
|
||||
function items(): Item[] {
|
||||
const out: Item[] = [];
|
||||
const days = new Map<string, Item & { kind: 'day' }>();
|
||||
|
||||
for (const review of filtered()) {
|
||||
if (isWritten(review)) {
|
||||
out.push({ kind: 'card', at: review.created_at, review });
|
||||
continue;
|
||||
}
|
||||
const key = dayKey(review.created_at);
|
||||
let day = days.get(key);
|
||||
if (!day) {
|
||||
day = { kind: 'day', at: review.created_at, key, label: dayLabel(review.created_at), reviews: [] };
|
||||
days.set(key, day);
|
||||
out.push(day);
|
||||
}
|
||||
day.reviews.push(review);
|
||||
day.at = Math.max(day.at, review.created_at);
|
||||
}
|
||||
|
||||
return out.sort((a, b) => b.at - a.at);
|
||||
}
|
||||
|
||||
function itemHtml(item: Item): string {
|
||||
return item.kind === 'card'
|
||||
? reviewHtml(item.review, f, { mint: mintRef(item.review) })
|
||||
: summaryHtml(item.reviews, f, openDays.has(item.key), {
|
||||
// Every row in this page is a FeedReview: the shared signature takes the
|
||||
// narrower LoadedReview because the mint page has no mint fields to pass.
|
||||
mintFor: (review) => mintRef(review as FeedReview),
|
||||
when: item.label,
|
||||
key: item.key,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Repaint, letting the outgoing rows fade first when the reader changed
|
||||
* something. Same pattern as the mint page's reviews panel, and for the same
|
||||
* reason: the list is the answer to the control they just used. Only one repaint
|
||||
* is ever scheduled, and it renders whatever the state is when it runs, so
|
||||
* hammering a filter never stacks or queues animations.
|
||||
*/
|
||||
function render(animate = false): void {
|
||||
if (pendingPaint) return;
|
||||
if (!animate || prefersReducedMotion()) {
|
||||
paint();
|
||||
return;
|
||||
}
|
||||
list!.classList.add('leaving');
|
||||
pendingPaint = window.setTimeout(() => {
|
||||
pendingPaint = 0;
|
||||
list!.classList.remove('leaving');
|
||||
paint();
|
||||
}, 120);
|
||||
}
|
||||
|
||||
function paint(): void {
|
||||
const rows = items();
|
||||
|
||||
if (rows.length === 0) {
|
||||
list!.innerHTML =
|
||||
`<p class="rev-status">${escapeHtml(
|
||||
all.length === 0 ? t('feed.emptyRelays') : t('feed.emptyFilter'),
|
||||
)}</p>`;
|
||||
pagerWrap!.hidden = true;
|
||||
if (countEl) countEl.textContent = t('feed.showing', { shown: 0, total: all.length });
|
||||
return;
|
||||
}
|
||||
|
||||
const pages = Math.ceil(rows.length / PAGE_SIZE);
|
||||
page = Math.min(page, pages);
|
||||
const slice = rows.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
||||
|
||||
list!.innerHTML = slice.map(itemHtml).join('');
|
||||
enterStagger([...list!.children] as HTMLElement[]);
|
||||
|
||||
if (countEl) {
|
||||
// Counted in reviews, not in rows: a day summary is one row and twelve reviews,
|
||||
// and the reader is asking how many reviews they are looking at.
|
||||
const shown = slice.reduce((n, item) => n + (item.kind === 'card' ? 1 : item.reviews.length), 0);
|
||||
const matching = rows.reduce((n, item) => n + (item.kind === 'card' ? 1 : item.reviews.length), 0);
|
||||
countEl.textContent =
|
||||
matching === all.length
|
||||
? t('feed.showing', { shown, total: all.length })
|
||||
: t('feed.showingMatching', { shown, matching, total: all.length });
|
||||
}
|
||||
|
||||
pagerWrap!.hidden = pages <= 1;
|
||||
if (pages > 1) {
|
||||
const button = (label: string, target: number, current: boolean, aria?: string) =>
|
||||
`<button class="pg${current ? ' on' : ''}" type="button" data-page="${target}"${
|
||||
aria ? ` aria-label="${aria}"` : ''
|
||||
}${current ? ' aria-current="true"' : ''}>${label}</button>`;
|
||||
|
||||
// Five pages of numbers around the current one: 40 numbered buttons is not a
|
||||
// control, it is a wall.
|
||||
const first = Math.max(1, Math.min(page - 2, pages - 4));
|
||||
const last = Math.min(pages, first + 4);
|
||||
|
||||
const parts = [button('‹', Math.max(1, page - 1), false, t('common.previousPage'))];
|
||||
if (first > 1) parts.push(button('1', 1, false), '<span class="pg-gap">…</span>');
|
||||
for (let p = first; p <= last; p++) parts.push(button(String(p), p, p === page));
|
||||
if (last < pages) parts.push('<span class="pg-gap">…</span>', button(String(pages), pages, false));
|
||||
parts.push(button('›', Math.min(pages, page + 1), false, t('common.nextPage')));
|
||||
pager.innerHTML = parts.join('');
|
||||
}
|
||||
}
|
||||
|
||||
function updateCounts(): void {
|
||||
const set = (key: string, value: number) => {
|
||||
const el = controls!.querySelector(`[data-count="${key}"]`);
|
||||
if (el) el.textContent = ` · ${f.number(value)}`;
|
||||
};
|
||||
set('all', all.length);
|
||||
set('5', all.filter((r) => r.rating === 5).length);
|
||||
set('critical', all.filter((r) => r.rating !== null && r.rating <= 2).length);
|
||||
}
|
||||
|
||||
/* ---------- loading ---------- */
|
||||
|
||||
async function load(): Promise<void> {
|
||||
try {
|
||||
all = await loadFeed(mints);
|
||||
} catch {
|
||||
// The prerendered cards are still on screen and are real reviews, just older.
|
||||
// Blanking them for an error message would trade content for nothing.
|
||||
if (countEl) {
|
||||
countEl.innerHTML = retryLine(t('feed.relayError'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (all.length === 0) {
|
||||
// Same reasoning: an empty answer from a relay pool that swallows per relay
|
||||
// failures is not proof that there are no reviews.
|
||||
if (countEl) {
|
||||
countEl.innerHTML = retryLine(t('feed.relayEmpty'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
render();
|
||||
updateCounts();
|
||||
|
||||
try {
|
||||
const profiles = await loadProfiles(all.map((review) => review.pubkey));
|
||||
for (const review of all) review.profile = profiles.get(review.pubkey) ?? null;
|
||||
} catch {
|
||||
// Names stay as short npubs. Nothing else changes.
|
||||
} finally {
|
||||
applyProfiles(list!, f, (pubkey) => all.find((review) => review.pubkey === pubkey));
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- events ---------- */
|
||||
|
||||
controls.addEventListener('click', (event) => {
|
||||
const button = (event.target as HTMLElement).closest<HTMLButtonElement>('[data-filter]');
|
||||
if (!button) return;
|
||||
rating = button.dataset['filter'] as typeof rating;
|
||||
page = 1;
|
||||
for (const other of controls!.querySelectorAll('[data-filter]')) {
|
||||
other.setAttribute('aria-pressed', String(other === button));
|
||||
}
|
||||
render(true);
|
||||
});
|
||||
|
||||
mintInput.addEventListener('input', () => {
|
||||
mintQuery = mintInput.value;
|
||||
page = 1;
|
||||
if (mintClear) mintClear.hidden = mintQuery === '';
|
||||
render(true);
|
||||
});
|
||||
|
||||
mintClear?.addEventListener('click', () => {
|
||||
mintInput.value = '';
|
||||
mintQuery = '';
|
||||
mintClear.hidden = true;
|
||||
page = 1;
|
||||
mintInput.focus();
|
||||
render(true);
|
||||
});
|
||||
|
||||
/*
|
||||
* Remember which day summaries are open. `toggle` does not bubble, so this
|
||||
* listens in the capture phase rather than on each block, which would have to be
|
||||
* re-bound after every render.
|
||||
*/
|
||||
list.addEventListener(
|
||||
'toggle',
|
||||
(event) => {
|
||||
const details = event.target;
|
||||
if (!(details instanceof HTMLDetailsElement)) return;
|
||||
const key = details.dataset['summaryKey'];
|
||||
if (!key) return;
|
||||
if (details.open) openDays.add(key);
|
||||
else openDays.delete(key);
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
pagerWrap.addEventListener('click', (event) => {
|
||||
const button = (event.target as HTMLElement).closest<HTMLButtonElement>('[data-page]');
|
||||
if (!button) return;
|
||||
page = Number(button.dataset['page']);
|
||||
render(true);
|
||||
list!.scrollIntoView({ behavior: scrollBehavior(), block: 'start' });
|
||||
});
|
||||
|
||||
// Delegated from the count line the retry button is injected into, rather than
|
||||
// from the document: this runs again on every arrival through the router, and a
|
||||
// document listener would pile up one copy per visit.
|
||||
countEl?.addEventListener('click', (event) => {
|
||||
if (!(event.target as HTMLElement).closest('[data-feed-retry]')) return;
|
||||
countEl.textContent = t('feed.reading');
|
||||
void load();
|
||||
});
|
||||
|
||||
// A mint can be preselected from a link: /reviews?mint=mint.example.com
|
||||
const preset = new URLSearchParams(window.location.search).get('mint');
|
||||
if (preset) {
|
||||
mintInput.value = preset;
|
||||
mintQuery = preset;
|
||||
if (mintClear) mintClear.hidden = false;
|
||||
}
|
||||
|
||||
void load();
|
||||
}
|
||||
};
|
||||
|
||||
onReady(setup);
|
||||
</script>
|
||||
@@ -0,0 +1,87 @@
|
||||
---
|
||||
/**
|
||||
* Terms of use. Short, in the site's own voice, and true of what the site actually
|
||||
* does: it indexes public data and displays it. Nothing here claims a relationship
|
||||
* with a mint, because there is none.
|
||||
*/
|
||||
import Base from '../../layouts/Base.astro';
|
||||
import { localePath, useI18n, type Locale } from '../../i18n';
|
||||
import { localePaths } from '../../i18n/paths';
|
||||
|
||||
/** One page per locale: `/terms`, `/es/terms`, `/nl/terms`. */
|
||||
export const getStaticPaths = localePaths;
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
}
|
||||
const { locale } = Astro.props;
|
||||
|
||||
const t = useI18n(locale);
|
||||
|
||||
const GITHUB = 'https://github.com/Azzamo-net/cashumints.space';
|
||||
/**
|
||||
* Dated so a reader can tell how old these terms are. Bump `disclaimer.updated` in
|
||||
* every catalog when the text changes: it is written out in each language's own date
|
||||
* convention rather than formatted from a timestamp, because "last updated" on a legal
|
||||
* page is a fact about the text, and the text is what a translator last touched.
|
||||
*/
|
||||
const updated = t('disclaimer.updated');
|
||||
|
||||
const description = t('terms.description');
|
||||
|
||||
/* Sentences that carry a link, kept whole so a translator can move the link. */
|
||||
const link = (href: string, label: string): string => `<a href="${href}">${label}</a>`;
|
||||
const external = (href: string, label: string): string =>
|
||||
`<a href="${href}" rel="noopener">${label}</a>`;
|
||||
|
||||
const whatLi1 = t('terms.what.li1', { code: '<code>/v1/info</code>' });
|
||||
const noAccountsP2 = t('terms.noAccounts.p2', {
|
||||
link: link(localePath('/disclaimer', locale), t('terms.noAccounts.link')),
|
||||
});
|
||||
const reviewsP2 = t('terms.reviews.p2', {
|
||||
link: link(localePath('/privacy', locale), t('terms.reviews.link')),
|
||||
});
|
||||
const contactP1 = t('terms.contact.p1', {
|
||||
github: external(GITHUB, 'GitHub'),
|
||||
azzamo: external('https://azzamo.net', 'Azzamo'),
|
||||
});
|
||||
---
|
||||
|
||||
<Base title={t('terms.title')} description={description}>
|
||||
<div class="page">
|
||||
<div class="prose">
|
||||
<h1>{t('terms.heading')}</h1>
|
||||
<p class="lede">{t('terms.lede', { date: updated })}</p>
|
||||
|
||||
<h2 id="what-this-is">{t('terms.what.title')}</h2>
|
||||
<p>{t('terms.what.p1')}</p>
|
||||
<ul>
|
||||
<li set:html={whatLi1} />
|
||||
<li>{t('terms.what.li2')}</li>
|
||||
</ul>
|
||||
<p>{t('terms.what.p2')}</p>
|
||||
|
||||
<h2 id="no-accounts">{t('terms.noAccounts.title')}</h2>
|
||||
<p>{t('terms.noAccounts.p1')}</p>
|
||||
<p set:html={noAccountsP2} />
|
||||
|
||||
<h2 id="no-custody">{t('terms.noCustody.title')}</h2>
|
||||
<p>{t('terms.noCustody.p1')}</p>
|
||||
<p>{t('terms.noCustody.p2')}</p>
|
||||
|
||||
<h2 id="reviews">{t('terms.reviews.title')}</h2>
|
||||
<p>{t('terms.reviews.p1')}</p>
|
||||
<p set:html={reviewsP2} />
|
||||
<p>{t('terms.reviews.p3')}</p>
|
||||
|
||||
<h2 id="links">{t('terms.links.title')}</h2>
|
||||
<p>{t('terms.links.p1')}</p>
|
||||
|
||||
<h2 id="changes">{t('terms.changes.title')}</h2>
|
||||
<p>{t('terms.changes.p1')}</p>
|
||||
|
||||
<h2 id="contact">{t('terms.contact.title')}</h2>
|
||||
<p set:html={contactP1} />
|
||||
</div>
|
||||
</div>
|
||||
</Base>
|
||||
@@ -0,0 +1,124 @@
|
||||
---
|
||||
import Base from '../../layouts/Base.astro';
|
||||
import { iconGradient, initials } from '../../lib/format';
|
||||
import { localePath, useI18n, type Locale } from '../../i18n';
|
||||
import { itemListNode } from '../../lib/schema';
|
||||
import { localePaths } from '../../i18n/paths';
|
||||
|
||||
/** One page per locale: `/wallets`, `/es/wallets`, `/nl/wallets`. */
|
||||
export const getStaticPaths = localePaths;
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
}
|
||||
const { locale } = Astro.props;
|
||||
|
||||
const t = useI18n(locale);
|
||||
|
||||
/**
|
||||
* The old project had no wallet directory to port: its only wallet content was an
|
||||
* external link to docs.cashu.space/wallets (Header.tsx:57). See NOTES.md. This list
|
||||
* is curated instead, and is deliberately short and factual.
|
||||
*
|
||||
* A wallet's name and its URL are what its makers call it, so both are data and neither
|
||||
* is translated. The blurb is ours, and lives in the catalogs keyed by `id`. The
|
||||
* platform labels are ours too: "Browser extension" is a description, not a product.
|
||||
*/
|
||||
const wallets = [
|
||||
{ id: 'minibits', name: 'Minibits', url: 'https://www.minibits.cash', platforms: ['android'] },
|
||||
{ id: 'nutstash', name: 'Nutstash', url: 'https://nutstash.app', platforms: ['web', 'extension'] },
|
||||
{ id: 'enuts', name: 'eNuts', url: 'https://www.enuts.cash', platforms: ['android', 'ios'] },
|
||||
{ id: 'cashume', name: 'Cashu.me', url: 'https://cashu.me', platforms: ['web'] },
|
||||
{ id: 'boardwalk', name: 'Boardwalk Cash', url: 'https://boardwalkcash.com', platforms: ['web'] },
|
||||
{ id: 'coinos', name: 'Coinos', url: 'https://coinos.io', platforms: ['web', 'android', 'ios'] },
|
||||
{ id: 'oxchat', name: '0xchat', url: 'https://0xchat.com', platforms: ['android', 'ios'] },
|
||||
{ id: 'nutshell', name: 'Nutshell', url: 'https://github.com/cashubtc/nutshell', platforms: ['cli', 'python'] },
|
||||
];
|
||||
|
||||
const description = t('wallets.description');
|
||||
|
||||
/* One sentence, one link in the middle of it, so the link can move with the grammar. */
|
||||
const note = t('wallets.note', {
|
||||
link: '<a href="https://docs.cashu.space/wallets" rel="noopener">docs.cashu.space/wallets</a>',
|
||||
});
|
||||
|
||||
/*
|
||||
* The wallet list, as names and URLs pointing at each project's own site. Nothing
|
||||
* richer: a `SoftwareApplication` per wallet would want an application category, an
|
||||
* operating system list and an offer, none of which this page knows.
|
||||
*/
|
||||
const schema = [
|
||||
itemListNode({
|
||||
canonical: new URL(localePath('/wallets', locale), Astro.site).href,
|
||||
fragment: 'wallets',
|
||||
name: t('wallets.heading'),
|
||||
items: wallets.map((wallet) => ({ name: wallet.name, url: wallet.url })),
|
||||
}),
|
||||
];
|
||||
---
|
||||
|
||||
<Base title={t('wallets.title')} description={description} current="wallets" schema={schema}>
|
||||
<div class="page">
|
||||
<div class="prose">
|
||||
<h1>{t('wallets.heading')}</h1>
|
||||
<p class="lede">{t('wallets.lede')}</p>
|
||||
</div>
|
||||
|
||||
<div class="wallet-grid">
|
||||
{
|
||||
wallets.map((wallet) => (
|
||||
<a class="wallet-card" href={wallet.url} rel="noopener" data-reveal>
|
||||
<div class="w-top">
|
||||
<span class="w-icon" style={`background:${iconGradient(wallet.name)}`} aria-hidden="true">
|
||||
{initials(wallet.name)}
|
||||
</span>
|
||||
<span class="w-id">
|
||||
<span class="w-name">{wallet.name}</span>
|
||||
<span class="w-platforms mono">
|
||||
{wallet.platforms.map((platform) => t(`wallets.platform.${platform}`)).join(' · ')}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<p class="w-blurb">{t(`wallets.item.${wallet.id}`)}</p>
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="prose wallet-note">
|
||||
<p set:html={note} />
|
||||
</div>
|
||||
</div>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.wallet-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-top: 30px; }
|
||||
.wallet-card {
|
||||
border: 1px solid var(--line); border-radius: var(--radius);
|
||||
background: var(--card); padding: 20px;
|
||||
display: flex; flex-direction: column; gap: 12px;
|
||||
transition: border-color var(--dur-quick), transform var(--dur-quick) var(--ease-out),
|
||||
box-shadow var(--dur-quick);
|
||||
}
|
||||
.wallet-card:hover {
|
||||
border-color: #3A362B;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, .3);
|
||||
}
|
||||
.wallet-card:active { transform: scale(.995); }
|
||||
.w-top { display: flex; align-items: center; gap: 12px; }
|
||||
.w-icon {
|
||||
width: 42px; height: 42px; border-radius: 11px; flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-family: var(--display); font-weight: 700; font-size: 17px; color: #0E0D0B;
|
||||
border: 1px solid rgba(255, 255, 255, .12);
|
||||
}
|
||||
.w-id { min-width: 0; }
|
||||
.w-name { font-family: var(--display); font-weight: 600; font-size: 16.5px; letter-spacing: -.01em; display: block; }
|
||||
.w-platforms { font-size: 11.5px; color: var(--faint); display: block; }
|
||||
.w-blurb { font-size: 13.5px; color: var(--muted); line-height: 1.6; }
|
||||
.wallet-note { margin-top: 34px; }
|
||||
|
||||
@media (max-width: 980px) { .wallet-grid { grid-template-columns: 1fr 1fr; } }
|
||||
@media (max-width: 640px) { .wallet-grid { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* robots.txt. Generated rather than dropped in public/ so the Sitemap line follows
|
||||
* SITE_URL instead of being hardcoded to the production host.
|
||||
*/
|
||||
import type { APIRoute } from 'astro';
|
||||
|
||||
export const GET: APIRoute = ({ site }) => {
|
||||
const origin = (site ?? new URL('https://cashumints.space')).origin;
|
||||
|
||||
/*
|
||||
* Every page here is public data meant to be found, and nothing under a page is
|
||||
* disallowed: the CSS, the JavaScript and the fonts all have to be fetchable or a
|
||||
* crawler renders the site wrong and judges it on the result.
|
||||
*
|
||||
* The two exceptions are not pages. `/api/` is the JSON the site is built from and
|
||||
* `/icons/` is the mint icon cache; both are same-origin by default (nginx proxies
|
||||
* them to the indexer) so without these lines a crawler would work its way through
|
||||
* an API surface that has no titles, no canonicals and no links out. The icons are
|
||||
* decorative tiles rendered with `alt=""`, so dropping them out of image search
|
||||
* costs nothing.
|
||||
*/
|
||||
const body = [
|
||||
'User-agent: *',
|
||||
'Allow: /',
|
||||
'',
|
||||
'# Not pages: the JSON API the site is built from, and the mint icon cache.',
|
||||
'Disallow: /api/',
|
||||
'Disallow: /icons/',
|
||||
'',
|
||||
`Sitemap: ${origin}/sitemap.xml`,
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
return new Response(body, { headers: { 'Content-Type': 'text/plain; charset=utf-8' } });
|
||||
};
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* sitemap.xml, generated at build time.
|
||||
*
|
||||
* Written by hand rather than with @astrojs/sitemap because the mint pages have a real
|
||||
* lastmod available (the last time the mint was seen or reviewed) and the integration
|
||||
* would stamp every URL with the build time instead, which tells a crawler nothing.
|
||||
*
|
||||
* Every emitted page is listed, in every locale, and every entry carries the full
|
||||
* `xhtml:link` alternate set. That is the sitemap half of the hreflang contract: the
|
||||
* same reciprocal relationships the pages declare in their own `<head>`, declared again
|
||||
* where a crawler reads them before it fetches anything. Both halves say the same
|
||||
* thing, which is what makes either of them believable.
|
||||
*
|
||||
* /404 is deliberately absent, in every locale.
|
||||
*/
|
||||
import type { APIRoute } from 'astro';
|
||||
import { fetchMints } from '../lib/api';
|
||||
import { alternates } from '../i18n/routing';
|
||||
import { isIndexableMint } from '../lib/seo';
|
||||
import { DEFAULT_LOCALE, LOCALE_CODES } from '../i18n/config';
|
||||
|
||||
interface Entry {
|
||||
/** The unprefixed path. One entry covers all of its locales. */
|
||||
path: string;
|
||||
lastmod: number;
|
||||
changefreq: 'daily' | 'weekly' | 'monthly';
|
||||
/**
|
||||
* Relative importance within this site, which is all `priority` has ever meant. It
|
||||
* says nothing to a crawler about ranking; it says which of these URLs to come back
|
||||
* to first. Everything being 1.0 said nothing at all.
|
||||
*/
|
||||
priority: number;
|
||||
}
|
||||
|
||||
/** Static routes, with how often each one's content actually moves. */
|
||||
const STATIC_PAGES: Array<Omit<Entry, 'lastmod'>> = [
|
||||
{ path: '/', changefreq: 'daily', priority: 1.0 },
|
||||
{ path: '/mints', changefreq: 'daily', priority: 0.9 },
|
||||
{ path: '/reviews', changefreq: 'daily', priority: 0.7 },
|
||||
{ path: '/wallets', changefreq: 'monthly', priority: 0.5 },
|
||||
{ path: '/about', changefreq: 'monthly', priority: 0.5 },
|
||||
{ path: '/disclaimer', changefreq: 'monthly', priority: 0.3 },
|
||||
{ path: '/terms', changefreq: 'monthly', priority: 0.3 },
|
||||
{ path: '/privacy', changefreq: 'monthly', priority: 0.3 },
|
||||
];
|
||||
|
||||
/**
|
||||
* Every mint page carries the same priority.
|
||||
*
|
||||
* Deriving it from the score was tempting and would be wrong: the ranking moves daily,
|
||||
* so every nightly build would rewrite most of the file with changes that mean nothing
|
||||
* to a crawler. A flat value says "these are all mint pages", which is true.
|
||||
*/
|
||||
const MINT_PRIORITY = 0.8;
|
||||
|
||||
/**
|
||||
* Non-default locales sit slightly below their English counterpart, which is the same
|
||||
* thing `x-default` says in the alternate set: when a crawler has to pick one, pick
|
||||
* English. It is not a claim that the translations are worth less.
|
||||
*/
|
||||
const LOCALE_FACTOR = 0.8;
|
||||
|
||||
/** This entry's priority in this locale, to one decimal. */
|
||||
const priority = (entry: Entry, locale: string): string =>
|
||||
(entry.priority * (locale === DEFAULT_LOCALE ? 1 : LOCALE_FACTOR)).toFixed(1);
|
||||
|
||||
const isoDay = (unixSeconds: number): string =>
|
||||
new Date(unixSeconds * 1000).toISOString().slice(0, 10);
|
||||
|
||||
const escapeXml = (value: string): string =>
|
||||
value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
|
||||
export const GET: APIRoute = async ({ site }) => {
|
||||
const origin = site ?? new URL('https://cashumints.space');
|
||||
const builtAt = Math.floor(Date.now() / 1000);
|
||||
|
||||
const mints = await fetchMints();
|
||||
|
||||
const entries: Entry[] = [
|
||||
...STATIC_PAGES.map((page) => ({ ...page, lastmod: builtAt })),
|
||||
/*
|
||||
* Only the mints whose pages are indexable.
|
||||
*
|
||||
* `isIndexableMint` is the same predicate `mint/[host].astro` uses to decide the
|
||||
* page's robots meta, imported rather than restated so the two cannot drift. A
|
||||
* sitemap entry for a page that says `noindex` is a contradiction, and
|
||||
* `check-hreflang.mjs` fails the build on it, checking the built HTML against the
|
||||
* built sitemap rather than trusting that these two files still agree.
|
||||
*/
|
||||
...mints.filter(isIndexableMint).map((mint) => ({
|
||||
path: `/mint/${mint.host}`,
|
||||
// The newest thing that could have changed the page. Every mint reaching this
|
||||
// point has been online or been reviewed at least once, so one of the two is set.
|
||||
lastmod: Math.max(mint.last_review_at ?? 0, mint.last_online ?? 0) || builtAt,
|
||||
changefreq: 'weekly' as const,
|
||||
priority: MINT_PRIORITY,
|
||||
})),
|
||||
];
|
||||
|
||||
/**
|
||||
* One `<url>` per locale per page, each listing every locale.
|
||||
*
|
||||
* A crawler expects to find the alternate set on every member of it, not only on the
|
||||
* default one: an entry that lists alternates which do not list it back has the whole
|
||||
* set discarded. So the same block is repeated under each locale's `<loc>`, which is
|
||||
* the documented shape and is why this file is longer than it looks like it should be.
|
||||
*/
|
||||
const urls = entries.flatMap((entry) => {
|
||||
const links = alternates(entry.path, origin)
|
||||
.map(
|
||||
(alternate) =>
|
||||
` <xhtml:link rel="alternate" hreflang="${alternate.hreflang}" ` +
|
||||
`href="${escapeXml(alternate.href)}" />\n`,
|
||||
)
|
||||
.join('');
|
||||
|
||||
return LOCALE_CODES.map((locale) => {
|
||||
const self = alternates(entry.path, origin).find(
|
||||
(alternate) => alternate.hreflang === locale,
|
||||
);
|
||||
return (
|
||||
` <url>\n` +
|
||||
` <loc>${escapeXml(self?.href ?? '')}</loc>\n` +
|
||||
links +
|
||||
` <lastmod>${isoDay(entry.lastmod)}</lastmod>\n` +
|
||||
` <changefreq>${entry.changefreq}</changefreq>\n` +
|
||||
` <priority>${priority(entry, locale)}</priority>\n` +
|
||||
` </url>\n`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const body =
|
||||
`<?xml version="1.0" encoding="UTF-8"?>\n` +
|
||||
`<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"\n` +
|
||||
` xmlns:xhtml="http://www.w3.org/1999/xhtml">\n` +
|
||||
urls.join('') +
|
||||
`</urlset>\n`;
|
||||
|
||||
return new Response(body, { headers: { 'Content-Type': 'application/xml; charset=utf-8' } });
|
||||
};
|
||||
@@ -0,0 +1,484 @@
|
||||
/**
|
||||
* ColorBends: the animated field behind the hero.
|
||||
*
|
||||
* A port of React Bits' ColorBends (`@react-bits/ColorBends-JS-CSS`). Upstream is a
|
||||
* React component that asks three.js for an orthographic camera, a scene, and a
|
||||
* PlaneGeometry, so it can draw one full-screen quad with one ShaderMaterial. That is
|
||||
* ~170KB of renderer, plus React, for a job raw WebGL does in the few dozen lines
|
||||
* below — on a site that ships neither.
|
||||
*
|
||||
* The fragment shader is upstream's, unchanged apart from the precision header that
|
||||
* three would otherwise prepend. The uniforms, their defaults, and the pointer easing
|
||||
* all match it too, so the parameters from the React Bits playground mean here exactly
|
||||
* what they mean there.
|
||||
*
|
||||
* What is new is the housekeeping a background on this site needs and a 1080x1080 demo
|
||||
* box does not: the loop stops when the hero scrolls away, `prefers-reduced-motion`
|
||||
* gets a single still frame instead of an animation, and everything is releasable in
|
||||
* one call because the router swaps this page out from under us.
|
||||
*/
|
||||
|
||||
/** Upstream's uniform array length. More colours than this are dropped. */
|
||||
const MAX_COLORS = 8;
|
||||
|
||||
/** Ceiling on the backing store, upstream's `setPixelRatio(min(dpr, 2))`. */
|
||||
const MAX_DPR = 2;
|
||||
|
||||
/**
|
||||
* How fast the pointer catches up, per second. Upstream's `pointerSmoothRef`: the
|
||||
* field leans toward the cursor rather than snapping to it.
|
||||
*/
|
||||
const POINTER_EASE = 8;
|
||||
|
||||
/**
|
||||
* Longest frame the pointer easing will act on. A tab that was in the background for a
|
||||
* minute comes back with one enormous delta, and without this the lean would jump.
|
||||
*/
|
||||
const MAX_FRAME = 0.1;
|
||||
|
||||
export interface ColorBendsOptions {
|
||||
/** Degrees. Direction the bands travel in. */
|
||||
rotation?: number;
|
||||
/** Degrees per second added to `rotation`, so the field turns as it flows. */
|
||||
autoRotate?: number;
|
||||
speed?: number;
|
||||
/** Hex strings. Empty paints upstream's RGB fallback instead. */
|
||||
colors?: string[];
|
||||
/** Off makes the canvas opaque black behind the bands. */
|
||||
transparent?: boolean;
|
||||
scale?: number;
|
||||
frequency?: number;
|
||||
warpStrength?: number;
|
||||
/** How far the field leans toward the pointer. 0 ignores it. */
|
||||
mouseInfluence?: number;
|
||||
/** How far the field slides under the pointer. 0 ignores it. */
|
||||
parallax?: number;
|
||||
noise?: number;
|
||||
iterations?: number;
|
||||
intensity?: number;
|
||||
bandWidth?: number;
|
||||
}
|
||||
|
||||
/** Everything created here, releasable in one call. */
|
||||
export interface ColorBendsHandle {
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
const VERT = `
|
||||
attribute vec2 aPosition;
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = aPosition * 0.5 + 0.5;
|
||||
gl_Position = vec4(aPosition, 0.0, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
/*
|
||||
* Upstream's fragment shader, verbatim below the precision header. `uTransparent`
|
||||
* premultiplies the alpha into the colour on the way out, which is what lets the
|
||||
* canvas composite over the page background without a dark fringe.
|
||||
*/
|
||||
const FRAG = `
|
||||
#ifdef GL_FRAGMENT_PRECISION_HIGH
|
||||
precision highp float;
|
||||
#else
|
||||
precision mediump float;
|
||||
#endif
|
||||
#define MAX_COLORS ${MAX_COLORS}
|
||||
uniform vec2 uCanvas;
|
||||
uniform float uTime;
|
||||
uniform float uSpeed;
|
||||
uniform vec2 uRot;
|
||||
uniform int uColorCount;
|
||||
uniform vec3 uColors[MAX_COLORS];
|
||||
uniform int uTransparent;
|
||||
uniform float uScale;
|
||||
uniform float uFrequency;
|
||||
uniform float uWarpStrength;
|
||||
uniform vec2 uPointer; // in NDC [-1,1]
|
||||
uniform float uMouseInfluence;
|
||||
uniform float uParallax;
|
||||
uniform float uNoise;
|
||||
uniform int uIterations;
|
||||
uniform float uIntensity;
|
||||
uniform float uBandWidth;
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
float t = uTime * uSpeed;
|
||||
vec2 p = vUv * 2.0 - 1.0;
|
||||
p += uPointer * uParallax * 0.1;
|
||||
vec2 rp = vec2(p.x * uRot.x - p.y * uRot.y, p.x * uRot.y + p.y * uRot.x);
|
||||
vec2 q = vec2(rp.x * (uCanvas.x / uCanvas.y), rp.y);
|
||||
q /= max(uScale, 0.0001);
|
||||
q /= 0.5 + 0.2 * dot(q, q);
|
||||
q += 0.2 * cos(t) - 7.56;
|
||||
vec2 toward = (uPointer - rp);
|
||||
q += toward * uMouseInfluence * 0.2;
|
||||
|
||||
for (int j = 0; j < 5; j++) {
|
||||
if (j >= uIterations - 1) break;
|
||||
vec2 rr = sin(1.5 * (q.yx * uFrequency) + 2.0 * cos(q * uFrequency));
|
||||
q += (rr - q) * 0.15;
|
||||
}
|
||||
|
||||
vec3 col = vec3(0.0);
|
||||
float a = 1.0;
|
||||
|
||||
if (uColorCount > 0) {
|
||||
vec2 s = q;
|
||||
vec3 sumCol = vec3(0.0);
|
||||
float cover = 0.0;
|
||||
for (int i = 0; i < MAX_COLORS; ++i) {
|
||||
if (i >= uColorCount) break;
|
||||
s -= 0.01;
|
||||
vec2 r = sin(1.5 * (s.yx * uFrequency) + 2.0 * cos(s * uFrequency));
|
||||
float m0 = length(r + sin(5.0 * r.y * uFrequency - 3.0 * t + float(i)) / 4.0);
|
||||
float kBelow = clamp(uWarpStrength, 0.0, 1.0);
|
||||
float kMix = pow(kBelow, 0.3); // strong response across 0..1
|
||||
float gain = 1.0 + max(uWarpStrength - 1.0, 0.0); // allow >1 to amplify displacement
|
||||
vec2 disp = (r - s) * kBelow;
|
||||
vec2 warped = s + disp * gain;
|
||||
float m1 = length(warped + sin(5.0 * warped.y * uFrequency - 3.0 * t + float(i)) / 4.0);
|
||||
float m = mix(m0, m1, kMix);
|
||||
float w = 1.0 - exp(-uBandWidth / exp(uBandWidth * m));
|
||||
sumCol += uColors[i] * w;
|
||||
cover = max(cover, w);
|
||||
}
|
||||
col = clamp(sumCol, 0.0, 1.0);
|
||||
a = uTransparent > 0 ? cover : 1.0;
|
||||
} else {
|
||||
vec2 s = q;
|
||||
for (int k = 0; k < 3; ++k) {
|
||||
s -= 0.01;
|
||||
vec2 r = sin(1.5 * (s.yx * uFrequency) + 2.0 * cos(s * uFrequency));
|
||||
float m0 = length(r + sin(5.0 * r.y * uFrequency - 3.0 * t + float(k)) / 4.0);
|
||||
float kBelow = clamp(uWarpStrength, 0.0, 1.0);
|
||||
float kMix = pow(kBelow, 0.3);
|
||||
float gain = 1.0 + max(uWarpStrength - 1.0, 0.0);
|
||||
vec2 disp = (r - s) * kBelow;
|
||||
vec2 warped = s + disp * gain;
|
||||
float m1 = length(warped + sin(5.0 * warped.y * uFrequency - 3.0 * t + float(k)) / 4.0);
|
||||
float m = mix(m0, m1, kMix);
|
||||
col[k] = 1.0 - exp(-uBandWidth / exp(uBandWidth * m));
|
||||
}
|
||||
a = uTransparent > 0 ? max(max(col.r, col.g), col.b) : 1.0;
|
||||
}
|
||||
|
||||
col *= uIntensity;
|
||||
|
||||
if (uNoise > 0.0001) {
|
||||
float n = fract(sin(dot(gl_FragCoord.xy + vec2(uTime), vec2(12.9898, 78.233))) * 43758.5453123);
|
||||
col += (n - 0.5) * uNoise;
|
||||
col = clamp(col, 0.0, 1.0);
|
||||
}
|
||||
|
||||
vec3 rgb = (uTransparent > 0) ? col * a : col;
|
||||
gl_FragColor = vec4(rgb, a);
|
||||
}
|
||||
`;
|
||||
|
||||
function compile(gl: WebGLRenderingContext, type: number, source: string): WebGLShader | null {
|
||||
const shader = gl.createShader(type);
|
||||
if (!shader) return null;
|
||||
gl.shaderSource(shader, source);
|
||||
gl.compileShader(shader);
|
||||
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
||||
// A driver that will not take the shader is not worth a broken page: the caller
|
||||
// bails out and the CSS underneath the canvas stays on screen.
|
||||
gl.deleteShader(shader);
|
||||
return null;
|
||||
}
|
||||
return shader;
|
||||
}
|
||||
|
||||
function link(gl: WebGLRenderingContext): WebGLProgram | null {
|
||||
const vert = compile(gl, gl.VERTEX_SHADER, VERT);
|
||||
const frag = compile(gl, gl.FRAGMENT_SHADER, FRAG);
|
||||
if (!vert || !frag) return null;
|
||||
|
||||
const program = gl.createProgram();
|
||||
if (!program) return null;
|
||||
gl.attachShader(program, vert);
|
||||
gl.attachShader(program, frag);
|
||||
gl.linkProgram(program);
|
||||
// The shaders are attached to the program and no longer needed on their own.
|
||||
gl.deleteShader(vert);
|
||||
gl.deleteShader(frag);
|
||||
|
||||
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
||||
gl.deleteProgram(program);
|
||||
return null;
|
||||
}
|
||||
return program;
|
||||
}
|
||||
|
||||
/** `#rgb` or `#rrggbb` to the 0..1 triple the shader wants. Unreadable hex reads black. */
|
||||
function toRgb(hex: string): [number, number, number] {
|
||||
const h = hex.replace('#', '').trim();
|
||||
const full = h.length === 3 ? `${h[0]}${h[0]}${h[1]}${h[1]}${h[2]}${h[2]}` : h;
|
||||
const n = Number.parseInt(full.slice(0, 6), 16);
|
||||
if (!Number.isFinite(n)) return [0, 0, 0];
|
||||
return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the field on `canvas`. Returns a handle whose `destroy` releases the context,
|
||||
* the observers and the listeners; call it before the element goes away.
|
||||
*
|
||||
* Returns null when there is no WebGL to be had, which is the caller's cue to leave
|
||||
* whatever is painted underneath the canvas on screen.
|
||||
*/
|
||||
export function createColorBends(
|
||||
canvas: HTMLCanvasElement,
|
||||
options: ColorBendsOptions = {},
|
||||
): ColorBendsHandle | null {
|
||||
const {
|
||||
rotation = 0,
|
||||
autoRotate = 0,
|
||||
speed = 0.2,
|
||||
colors = [],
|
||||
transparent = true,
|
||||
scale = 1,
|
||||
frequency = 1,
|
||||
warpStrength = 1,
|
||||
mouseInfluence = 1,
|
||||
parallax = 0.5,
|
||||
noise = 0.15,
|
||||
iterations = 1,
|
||||
intensity = 1.5,
|
||||
bandWidth = 6,
|
||||
} = options;
|
||||
|
||||
const context = canvas.getContext('webgl', {
|
||||
alpha: true,
|
||||
antialias: false,
|
||||
depth: false,
|
||||
stencil: false,
|
||||
// The shader hands back colour already multiplied by coverage, which is what the
|
||||
// compositor expects when it blends this canvas over the page.
|
||||
premultipliedAlpha: true,
|
||||
powerPreference: 'high-performance',
|
||||
});
|
||||
if (!context) return null;
|
||||
/*
|
||||
* Bound again under a non-nullable type. `destroy` below is a hoisted declaration, so
|
||||
* TypeScript treats it as created before the check above and will not carry the
|
||||
* narrowing into it; this way there is nothing to narrow.
|
||||
*/
|
||||
const gl: WebGLRenderingContext = context;
|
||||
|
||||
const program = link(gl);
|
||||
if (!program) return null;
|
||||
|
||||
// One quad as two triangles, in clip space. The vertex shader reads texture
|
||||
// coordinates straight off these, so there is nothing else in the buffer.
|
||||
const buffer = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
|
||||
|
||||
gl.useProgram(program);
|
||||
const position = gl.getAttribLocation(program, 'aPosition');
|
||||
gl.enableVertexAttribArray(position);
|
||||
gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
const u = (name: string) => gl.getUniformLocation(program, name);
|
||||
const uCanvas = u('uCanvas');
|
||||
const uTime = u('uTime');
|
||||
const uRot = u('uRot');
|
||||
const uPointer = u('uPointer');
|
||||
|
||||
// Everything that never changes after setup is written once, here.
|
||||
gl.uniform1f(u('uSpeed'), speed);
|
||||
gl.uniform1f(u('uScale'), scale);
|
||||
gl.uniform1f(u('uFrequency'), frequency);
|
||||
gl.uniform1f(u('uWarpStrength'), warpStrength);
|
||||
gl.uniform1f(u('uMouseInfluence'), mouseInfluence);
|
||||
gl.uniform1f(u('uParallax'), parallax);
|
||||
gl.uniform1f(u('uNoise'), noise);
|
||||
gl.uniform1i(u('uIterations'), iterations);
|
||||
gl.uniform1f(u('uIntensity'), intensity);
|
||||
gl.uniform1f(u('uBandWidth'), bandWidth);
|
||||
gl.uniform1i(u('uTransparent'), transparent ? 1 : 0);
|
||||
|
||||
const palette = colors.filter(Boolean).slice(0, MAX_COLORS);
|
||||
const flat = new Float32Array(MAX_COLORS * 3);
|
||||
palette.forEach((hex, i) => flat.set(toRgb(hex), i * 3));
|
||||
gl.uniform3fv(u('uColors[0]'), flat);
|
||||
gl.uniform1i(u('uColorCount'), palette.length);
|
||||
|
||||
gl.clearColor(0, 0, 0, transparent ? 0 : 1);
|
||||
|
||||
/* ---------- size ---------- */
|
||||
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
let ratio = 0;
|
||||
|
||||
const resize = (): void => {
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);
|
||||
// Upstream measures the container; here the canvas is the layer, stretched by CSS.
|
||||
const w = canvas.clientWidth || 1;
|
||||
const h = canvas.clientHeight || 1;
|
||||
// Dragged to a denser screen counts as a resize even at the same CSS size.
|
||||
if (w === width && h === height && dpr === ratio) return;
|
||||
width = w;
|
||||
height = h;
|
||||
ratio = dpr;
|
||||
|
||||
canvas.width = Math.max(1, Math.round(w * dpr));
|
||||
canvas.height = Math.max(1, Math.round(h * dpr));
|
||||
gl.viewport(0, 0, canvas.width, canvas.height);
|
||||
// The shader only wants the aspect ratio, so CSS pixels are the right unit and
|
||||
// the field does not reshape itself when it moves to a denser screen.
|
||||
gl.uniform2f(uCanvas, w, h);
|
||||
};
|
||||
|
||||
/* ---------- pointer ---------- */
|
||||
|
||||
const target = { x: 0, y: 0 };
|
||||
const current = { x: 0, y: 0 };
|
||||
const tracksPointer = mouseInfluence !== 0 || parallax !== 0;
|
||||
|
||||
/*
|
||||
* Listened for on the window rather than on the canvas. The canvas sits behind the
|
||||
* hero's own text and takes no pointer events of its own, so it would never hear a
|
||||
* move; the rect maths is upstream's either way.
|
||||
*/
|
||||
const onPointerMove = (event: PointerEvent): void => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
target.x = ((event.clientX - rect.left) / (rect.width || 1)) * 2 - 1;
|
||||
target.y = -(((event.clientY - rect.top) / (rect.height || 1)) * 2 - 1);
|
||||
};
|
||||
|
||||
/* ---------- the loop ---------- */
|
||||
|
||||
let frame: number | null = null;
|
||||
let last = 0;
|
||||
let elapsed = 0;
|
||||
|
||||
const draw = (): void => {
|
||||
const deg = (rotation % 360) + autoRotate * elapsed;
|
||||
const rad = (deg * Math.PI) / 180;
|
||||
gl.uniform1f(uTime, elapsed);
|
||||
gl.uniform2f(uRot, Math.cos(rad), Math.sin(rad));
|
||||
gl.uniform2f(uPointer, current.x, current.y);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
||||
};
|
||||
|
||||
const loop = (now: number): void => {
|
||||
const dt = Math.min((now - last) / 1000, MAX_FRAME);
|
||||
last = now;
|
||||
elapsed += dt;
|
||||
|
||||
const amount = Math.min(1, dt * POINTER_EASE);
|
||||
current.x += (target.x - current.x) * amount;
|
||||
current.y += (target.y - current.y) * amount;
|
||||
|
||||
resize();
|
||||
draw();
|
||||
frame = requestAnimationFrame(loop);
|
||||
};
|
||||
|
||||
const start = (): void => {
|
||||
if (frame !== null) return;
|
||||
last = performance.now();
|
||||
frame = requestAnimationFrame(loop);
|
||||
};
|
||||
|
||||
const stop = (): void => {
|
||||
if (frame === null) return;
|
||||
cancelAnimationFrame(frame);
|
||||
frame = null;
|
||||
};
|
||||
|
||||
/* ---------- lifecycle ---------- */
|
||||
|
||||
const still = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
let visible = true;
|
||||
|
||||
/*
|
||||
* Reduced motion gets the field, not the animation: one still frame at t=0, centred
|
||||
* pointer, redrawn only when the element is resized. Reading the preference live,
|
||||
* rather than once at startup, is what the rest of the site's motion does too.
|
||||
*/
|
||||
const applyMotion = (): void => {
|
||||
if (still.matches) {
|
||||
stop();
|
||||
window.removeEventListener('pointermove', onPointerMove);
|
||||
elapsed = 0;
|
||||
current.x = 0;
|
||||
current.y = 0;
|
||||
target.x = 0;
|
||||
target.y = 0;
|
||||
resize();
|
||||
draw();
|
||||
return;
|
||||
}
|
||||
if (tracksPointer) {
|
||||
window.addEventListener('pointermove', onPointerMove, { passive: true });
|
||||
}
|
||||
if (visible) start();
|
||||
};
|
||||
|
||||
let observer: IntersectionObserver | null = null;
|
||||
if (typeof IntersectionObserver !== 'undefined') {
|
||||
// A hero that has been scrolled past has no business holding the GPU awake.
|
||||
observer = new IntersectionObserver((entries) => {
|
||||
visible = entries.some((entry) => entry.isIntersecting);
|
||||
if (still.matches) return;
|
||||
if (visible) start();
|
||||
else stop();
|
||||
});
|
||||
observer.observe(canvas);
|
||||
}
|
||||
|
||||
let resizer: ResizeObserver | null = null;
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
resizer = new ResizeObserver(() => {
|
||||
resize();
|
||||
// While paused there is no loop to pick the new size up on the next frame.
|
||||
if (frame === null) draw();
|
||||
});
|
||||
resizer.observe(canvas);
|
||||
} else {
|
||||
window.addEventListener('resize', resize);
|
||||
}
|
||||
|
||||
const onContextLost = (event: Event): void => {
|
||||
// Without this the context is gone for good and the canvas stays blank.
|
||||
event.preventDefault();
|
||||
stop();
|
||||
};
|
||||
const onContextRestored = (): void => {
|
||||
// The program and buffers died with the context. The page will outlive this
|
||||
// component, so hand the work back to the caller rather than rebuilding here.
|
||||
destroy();
|
||||
};
|
||||
|
||||
resize();
|
||||
canvas.addEventListener('webglcontextlost', onContextLost);
|
||||
canvas.addEventListener('webglcontextrestored', onContextRestored);
|
||||
still.addEventListener('change', applyMotion);
|
||||
applyMotion();
|
||||
|
||||
function destroy(): void {
|
||||
stop();
|
||||
observer?.disconnect();
|
||||
resizer?.disconnect();
|
||||
if (!resizer) window.removeEventListener('resize', resize);
|
||||
window.removeEventListener('pointermove', onPointerMove);
|
||||
canvas.removeEventListener('webglcontextlost', onContextLost);
|
||||
canvas.removeEventListener('webglcontextrestored', onContextRestored);
|
||||
still.removeEventListener('change', applyMotion);
|
||||
|
||||
gl.deleteProgram(program);
|
||||
gl.deleteBuffer(buffer);
|
||||
// Frees the drawing buffer now instead of whenever the canvas is collected: the
|
||||
// router leaves a trail of these otherwise, and browsers cap live contexts.
|
||||
gl.getExtension('WEBGL_lose_context')?.loseContext();
|
||||
}
|
||||
|
||||
return { destroy };
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
/**
|
||||
* The site's one motion utility. No animation library: everything visual is CSS,
|
||||
* and this file only decides *when* a CSS class goes on.
|
||||
*
|
||||
* Four jobs:
|
||||
* 1. `initReveal` watches `[data-reveal]` elements and adds `.in` at 15% visibility,
|
||||
* once, with a stagger so a row of cards arrives as a sequence.
|
||||
* 2. `onReady` runs an island's setup on first paint and again after a view
|
||||
* transition, because a module script that has already run is never re-executed
|
||||
* by the router (astro/dist/transitions/swap-functions.js marks it as done).
|
||||
* 3. `countUp` and `swapText` animate a number into place.
|
||||
* 4. `enterStagger` gives island-rendered rows the same entrance as prerendered ones.
|
||||
*
|
||||
* Every one of them checks `prefers-reduced-motion` first and lands on the final
|
||||
* state immediately when it is set. The CSS carries the same gate, so a preference
|
||||
* changed mid-visit is honoured too.
|
||||
*/
|
||||
import { intlTag } from '../i18n/config';
|
||||
import { useI18n } from '../i18n/client';
|
||||
|
||||
|
||||
/** Delay between items in a staggered group. `--stagger` in global.css. */
|
||||
const STEP_MS = 50;
|
||||
/** Nothing waits longer than this to arrive, however long the list is. `--stagger-cap`. */
|
||||
const CAP_MS = 400;
|
||||
/** Fraction of an element that has to be on screen before it counts as arrived. */
|
||||
const RATIO = 0.15;
|
||||
|
||||
export function prefersReducedMotion(): boolean {
|
||||
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
}
|
||||
|
||||
/** The stagger delay for item `index` of a group, capped so the tail is not left waiting. */
|
||||
export function staggerDelay(index: number, step = STEP_MS, cap = CAP_MS): number {
|
||||
return Math.min(index * step, cap);
|
||||
}
|
||||
|
||||
/**
|
||||
* `smooth` unless the reader asked for less motion, in which case scrolling jumps.
|
||||
* A programmatic smooth scroll is movement like any other.
|
||||
*/
|
||||
export function scrollBehavior(): ScrollBehavior {
|
||||
return prefersReducedMotion() ? 'auto' : 'smooth';
|
||||
}
|
||||
|
||||
/* ---------- reveal on scroll ---------- */
|
||||
|
||||
let observer: IntersectionObserver | null = null;
|
||||
|
||||
/**
|
||||
* True once the router has swapped a page in, so an island can tell a full page load
|
||||
* from an arrival by view transition. The hero uses it: its entrance is a greeting
|
||||
* for someone who has just loaded the site, not something to replay every time they
|
||||
* come back to the home page.
|
||||
*/
|
||||
let arrived = false;
|
||||
// Guarded: lib/skeleton.ts imports from here and is used in build-time frontmatter,
|
||||
// so this module is evaluated on the server as well as in the browser.
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('astro:after-swap', () => {
|
||||
arrived = true;
|
||||
});
|
||||
}
|
||||
export function arrivedByRouter(): boolean {
|
||||
return arrived;
|
||||
}
|
||||
|
||||
function reveal(el: HTMLElement, delay: number, instant = false): void {
|
||||
if (instant) {
|
||||
// Straight to the final state with no transition to run through on the way.
|
||||
el.style.transition = 'none';
|
||||
el.classList.add('in');
|
||||
void el.offsetWidth;
|
||||
el.style.transition = '';
|
||||
el.dispatchEvent(new CustomEvent('reveal'));
|
||||
return;
|
||||
}
|
||||
if (delay > 0) el.style.setProperty('--reveal-delay', `${delay}ms`);
|
||||
el.classList.add('in');
|
||||
// Islands hang count-ups and bar fills off this rather than running their own
|
||||
// observer. `whenRevealed` below covers the case where the listener is late.
|
||||
el.dispatchEvent(new CustomEvent('reveal'));
|
||||
}
|
||||
|
||||
function onIntersect(entries: IntersectionObserverEntry[], obs: IntersectionObserver): void {
|
||||
const viewport = window.innerHeight || 1;
|
||||
|
||||
// An element taller than the viewport can never reach 15% of itself on screen, so
|
||||
// it also counts as arrived once 15% of a screenful of it is showing.
|
||||
const arrived = entries.filter(
|
||||
(entry) =>
|
||||
entry.isIntersecting &&
|
||||
(entry.intersectionRatio >= RATIO || entry.intersectionRect.height >= viewport * RATIO),
|
||||
);
|
||||
|
||||
// Reading order, so a grid row lights up left to right rather than in whatever
|
||||
// order the observer happened to queue them.
|
||||
arrived.sort(
|
||||
(a, b) =>
|
||||
a.boundingClientRect.top - b.boundingClientRect.top ||
|
||||
a.boundingClientRect.left - b.boundingClientRect.left,
|
||||
);
|
||||
|
||||
arrived.forEach((entry, index) => {
|
||||
const el = entry.target as HTMLElement;
|
||||
obs.unobserve(el);
|
||||
const explicit = el.dataset['revealDelay'];
|
||||
// No explicit delay means "stagger with whatever else arrived in this batch",
|
||||
// which is what keeps a long grid from dumping a whole screen at once and keeps
|
||||
// a row scrolled into view later from waiting on the index of the row above it.
|
||||
reveal(el, explicit === undefined ? staggerDelay(index) : Number(explicit));
|
||||
});
|
||||
}
|
||||
|
||||
/** Start watching every unrevealed `[data-reveal]` under `root`. Safe to call twice. */
|
||||
export function initReveal(root: ParentNode = document): void {
|
||||
const targets = [...root.querySelectorAll<HTMLElement>('[data-reveal]:not(.in)')];
|
||||
if (targets.length === 0) return;
|
||||
|
||||
if (prefersReducedMotion() || typeof IntersectionObserver === 'undefined') {
|
||||
for (const el of targets) reveal(el, 0, true);
|
||||
return;
|
||||
}
|
||||
|
||||
observer ??= new IntersectionObserver(onIntersect, { threshold: [0, RATIO] });
|
||||
for (const el of targets) {
|
||||
// `data-reveal="load"` is a first-impression entrance: it plays on a full page
|
||||
// load and never again, so coming back to the page through the router does not
|
||||
// replay it.
|
||||
if (el.dataset['reveal'] === 'load' && arrived) {
|
||||
reveal(el, 0, true);
|
||||
continue;
|
||||
}
|
||||
observer.observe(el);
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop the observer before the router swaps the body out from under it. */
|
||||
export function resetReveal(): void {
|
||||
observer?.disconnect();
|
||||
observer = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `callback` when `el` has arrived, now if it already has.
|
||||
*
|
||||
* Islands cannot rely on being listening before the observer fires, so the check for
|
||||
* `.in` is the important half of this.
|
||||
*/
|
||||
export function whenRevealed(el: Element, callback: () => void): void {
|
||||
if (el.classList.contains('in')) {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
el.addEventListener('reveal', callback, { once: true });
|
||||
}
|
||||
|
||||
/* ---------- shared element page transitions ---------- */
|
||||
|
||||
/**
|
||||
* Hand the card being clicked the view transition names that its twins in the mint
|
||||
* page header already carry, so the icon and the name morph across the navigation
|
||||
* instead of cross-fading with the rest of the page.
|
||||
*
|
||||
* Only the clicked card gets them. A named element is captured and composited on its
|
||||
* own, and /mints has 58 cards: naming them all up front would mean 116 separate
|
||||
* textures per navigation to animate 2 of them. The names are cleared again after the
|
||||
* swap, and if the reader cancels the navigation the stale names go with the page.
|
||||
*
|
||||
* Capture phase, so this runs before the router's own click handler starts the
|
||||
* transition and takes its snapshot of the outgoing page.
|
||||
*/
|
||||
export function armSharedTransitions(): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
// Only ever the cards' own two elements. The mint page header carries its half of
|
||||
// the pair as a permanent inline style, and clearing that would take the morph's
|
||||
// other end with it.
|
||||
const clear = (): void => {
|
||||
for (const el of document.querySelectorAll<HTMLElement>('[data-vt-icon], [data-vt-name]')) {
|
||||
el.style.removeProperty('view-transition-name');
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener(
|
||||
'click',
|
||||
(event) => {
|
||||
const card = (event.target as HTMLElement | null)?.closest<HTMLElement>('[data-mint-card]');
|
||||
if (!card) return;
|
||||
clear();
|
||||
const icon = card.querySelector<HTMLElement>('[data-vt-icon]');
|
||||
const name = card.querySelector<HTMLElement>('[data-vt-name]');
|
||||
if (icon?.dataset['vtIcon']) icon.style.setProperty('view-transition-name', icon.dataset['vtIcon']);
|
||||
if (name?.dataset['vtName']) name.style.setProperty('view-transition-name', name.dataset['vtName']);
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
document.addEventListener('astro:after-swap', clear);
|
||||
}
|
||||
|
||||
/* ---------- island lifecycle ---------- */
|
||||
|
||||
/**
|
||||
* Run an island's setup on this page, and again on every page the router swaps in.
|
||||
*
|
||||
* The router never re-executes a module script it has already run, so an island that
|
||||
* only does its work at module scope is dead after the first navigation. It does fire
|
||||
* `astro:page-load` on every arrival, including the first, but on the first it waits
|
||||
* for `window.load`, which is far too late to be the only trigger. Hence both.
|
||||
*/
|
||||
export function onReady(setup: () => void): void {
|
||||
let done = false;
|
||||
const run = (): void => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
setup();
|
||||
};
|
||||
|
||||
// Module scripts are deferred, so the document is parsed by the time this runs.
|
||||
run();
|
||||
document.addEventListener('astro:after-swap', () => {
|
||||
done = false;
|
||||
});
|
||||
document.addEventListener('astro:page-load', run);
|
||||
}
|
||||
|
||||
/** Register a teardown to run just before the router replaces this page. */
|
||||
export function onLeave(teardown: () => void): void {
|
||||
document.addEventListener('astro:before-swap', teardown, { once: true });
|
||||
}
|
||||
|
||||
/* ---------- numbers ---------- */
|
||||
|
||||
const frames = new WeakMap<Element, number>();
|
||||
|
||||
/** Stop an in-flight count, so a live update never fights the count-up. */
|
||||
export function cancelCount(el: Element): void {
|
||||
const running = frames.get(el);
|
||||
if (running !== undefined) {
|
||||
cancelAnimationFrame(running);
|
||||
frames.delete(el);
|
||||
if (el instanceof HTMLElement) el.style.minWidth = '';
|
||||
}
|
||||
}
|
||||
|
||||
/** Decelerating, so the number lands rather than stopping dead. */
|
||||
const easeOut = (t: number): number => 1 - Math.pow(1 - t, 3);
|
||||
|
||||
export interface CountUpOptions {
|
||||
/** Decimal places. 0 for counts, 1 for a rating. */
|
||||
decimals?: number;
|
||||
duration?: number;
|
||||
/** Overrides the default locale formatting (used for thousands separators). */
|
||||
format?: (value: number) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count `el` from zero up to `to`.
|
||||
*
|
||||
* Interrupting an in-flight count cancels it rather than stacking a second one, and
|
||||
* with reduced motion set the final value is written straight away.
|
||||
*/
|
||||
export function countUp(el: HTMLElement, to: number, options: CountUpOptions = {}): void {
|
||||
const { decimals = 0, duration = 350, format } = options;
|
||||
/*
|
||||
* The locale comes from the page, not from the browser's own default: a Dutch
|
||||
* reader with an en-US machine is reading a Dutch page, and 4,6 counting up to 4.6
|
||||
* would be the number changing notation halfway through the animation.
|
||||
*/
|
||||
const locale = intlTag(useI18n().locale);
|
||||
const render = (value: number): string =>
|
||||
format ? format(value) : value.toLocaleString(locale, {
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals,
|
||||
});
|
||||
|
||||
cancelCount(el);
|
||||
|
||||
if (prefersReducedMotion() || !Number.isFinite(to)) {
|
||||
el.textContent = render(to);
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
* Hold the width the final value needs before counting up to it.
|
||||
*
|
||||
* "1,247" is wider than "4", and without this the numbers either side of it in the
|
||||
* pulse strip would shuffle for the length of the count. Tabular figures fix the
|
||||
* digits, not how many of them there are.
|
||||
*/
|
||||
el.textContent = render(to);
|
||||
el.style.minWidth = `${el.getBoundingClientRect().width}px`;
|
||||
|
||||
const start = performance.now();
|
||||
const step = (now: number): void => {
|
||||
const progress = Math.min(1, (now - start) / duration);
|
||||
el.textContent = render(to * easeOut(progress));
|
||||
if (progress < 1) {
|
||||
frames.set(el, requestAnimationFrame(step));
|
||||
} else {
|
||||
frames.delete(el);
|
||||
el.textContent = render(to);
|
||||
el.style.minWidth = '';
|
||||
}
|
||||
};
|
||||
frames.set(el, requestAnimationFrame(step));
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace text with a dip and a 4px rise, for a value that changed under the reader
|
||||
* rather than one arriving for the first time.
|
||||
*/
|
||||
export function swapText(el: HTMLElement, text: string): void {
|
||||
// A count-up still running on this element would keep writing over the new value.
|
||||
cancelCount(el);
|
||||
if (el.textContent === text) return;
|
||||
if (prefersReducedMotion()) {
|
||||
el.textContent = text;
|
||||
return;
|
||||
}
|
||||
|
||||
el.classList.remove('num-swap');
|
||||
// Reading offsetWidth restarts the animation when a value changes twice quickly.
|
||||
void el.offsetWidth;
|
||||
el.classList.add('num-swap');
|
||||
window.setTimeout(() => {
|
||||
el.textContent = text;
|
||||
}, 100);
|
||||
el.addEventListener('animationend', () => el.classList.remove('num-swap'), { once: true });
|
||||
}
|
||||
|
||||
/* ---------- island rendered rows ---------- */
|
||||
|
||||
/**
|
||||
* Give freshly rendered rows the same entrance prerendered ones get.
|
||||
*
|
||||
* The class is removed when the animation ends so a later re-render can re-apply it,
|
||||
* and rows already on screen are not re-animated by a filter change they survived.
|
||||
*/
|
||||
export function enterStagger(elements: Iterable<HTMLElement>, step = STEP_MS): void {
|
||||
if (prefersReducedMotion()) return;
|
||||
|
||||
let index = 0;
|
||||
for (const el of elements) {
|
||||
el.style.setProperty('--reveal-delay', `${staggerDelay(index, step)}ms`);
|
||||
el.classList.add('entering');
|
||||
el.addEventListener(
|
||||
'animationend',
|
||||
() => {
|
||||
el.classList.remove('entering');
|
||||
el.style.removeProperty('--reveal-delay');
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
index++;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"allowJs": true
|
||||
},
|
||||
"include": [".astro/types.d.ts", "src/**/*", "astro.config.mjs"],
|
||||
"exclude": ["dist"]
|
||||
}
|
||||