@@ -11,12 +11,15 @@
|
||||
"@react-three/rapier": "^2.2.0",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"i18next": "^26.3.1",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lru-cache": "^11.5.1",
|
||||
"node-vibrant": "^4.0.4",
|
||||
"nuqs": "^2.8.9",
|
||||
"react": "^19.2.7",
|
||||
"react-aria-components": "^1.18.0",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-i18next": "^17.0.8",
|
||||
"sharp": "^0.35.0",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"three": "^0.184.0",
|
||||
@@ -447,6 +450,12 @@
|
||||
|
||||
"hls.js": ["hls.js@1.6.16", "", {}, "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA=="],
|
||||
|
||||
"html-parse-stringify": ["html-parse-stringify@3.0.1", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="],
|
||||
|
||||
"i18next": ["i18next@26.3.1", "", { "peerDependencies": { "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ=="],
|
||||
|
||||
"i18next-browser-languagedetector": ["i18next-browser-languagedetector@8.2.1", "", { "dependencies": { "@babel/runtime": "^7.23.2" } }, "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw=="],
|
||||
|
||||
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||
|
||||
"image-q": ["image-q@4.0.0", "", { "dependencies": { "@types/node": "16.9.1" } }, "sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw=="],
|
||||
@@ -573,6 +582,8 @@
|
||||
|
||||
"react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="],
|
||||
|
||||
"react-i18next": ["react-i18next@17.0.8", "", { "dependencies": { "@babel/runtime": "^7.29.2", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 26.2.0", "react": ">= 16.8.0", "react-dom": "*", "react-native": "*", "typescript": "^5 || ^6" }, "optionalPeers": ["react-dom", "react-native", "typescript"] }, "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw=="],
|
||||
|
||||
"react-stately": ["react-stately@3.47.0", "", { "dependencies": { "@internationalized/date": "^3.12.2", "@internationalized/number": "^3.6.7", "@internationalized/string": "^3.2.9", "@react-types/shared": "^3.35.0", "@swc/helpers": "^0.5.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-H3ar+SOWP920EbVg7qWfP3fZjZiwhlEJAEJQqjt+w8oKijCwFgr0+R4941PIHscOXRNRvEOjvWilitImC0DdBg=="],
|
||||
|
||||
"react-use-measure": ["react-use-measure@2.1.7", "", { "peerDependencies": { "react": ">=16.13", "react-dom": ">=16.13" }, "optionalPeers": ["react-dom"] }, "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg=="],
|
||||
@@ -667,6 +678,8 @@
|
||||
|
||||
"vite-plugin-svgr": ["vite-plugin-svgr@5.2.0", "", { "dependencies": { "@rollup/pluginutils": "^5.3.0", "@svgr/core": "^8.1.0", "@svgr/plugin-jsx": "^8.1.0" }, "peerDependencies": { "vite": ">=3.0.0" } }, "sha512-qj2eAKF8C6PZWemVTvQA0xgQIcP1hHU6Buh7fl6BhvayWwnuxE+z417miKxeDvRWbDrupQ1oK99hfxElopJ3sQ=="],
|
||||
|
||||
"void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="],
|
||||
|
||||
"webgl-constants": ["webgl-constants@1.1.1", "", {}, "sha512-LkBXKjU5r9vAW7Gcu3T5u+5cvSvh5WwINdr0C+9jpzVB41cjQAP5ePArDtk/WHYdVj0GefCgM73BA7FlIiNtdg=="],
|
||||
|
||||
"webgl-sdf-generator": ["webgl-sdf-generator@1.1.1", "", {}, "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA=="],
|
||||
|
||||
@@ -19,12 +19,15 @@
|
||||
"@react-three/rapier": "^2.2.0",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"i18next": "^26.3.1",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lru-cache": "^11.5.1",
|
||||
"node-vibrant": "^4.0.4",
|
||||
"nuqs": "^2.8.9",
|
||||
"react": "^19.2.7",
|
||||
"react-aria-components": "^1.18.0",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-i18next": "^17.0.8",
|
||||
"sharp": "^0.35.0",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"three": "^0.184.0",
|
||||
|
||||
+24
-46
@@ -1,20 +1,16 @@
|
||||
import type { PieceWithIssuer } from '@/db/types';
|
||||
|
||||
// The pagination engine. Pure: no React, no three — everything the binder renders derives from the
|
||||
// `Book` this produces, and the grid size can change responsively without touching the renderer.
|
||||
|
||||
export type GridSpec = { cols: number; rows: number };
|
||||
|
||||
export type CoinPocket = {
|
||||
kind: 'coin';
|
||||
piece: PieceWithIssuer;
|
||||
// 0..count-1 for a piece with count instances. Nothing renders differently per instance; it only
|
||||
// keeps React keys stable when one physical type fills several pockets.
|
||||
// Only keeps React keys stable when one physical type fills several pockets; nothing renders
|
||||
// differently per instance.
|
||||
instance: number;
|
||||
};
|
||||
|
||||
// The first pocket of each country is a title tile (flag + name) instead of a coin — so every
|
||||
// country's coins shift one pocket along.
|
||||
// The first pocket of each country is a title tile, so its coins shift one pocket along.
|
||||
export type LabelPocket = {
|
||||
kind: 'label';
|
||||
code: string;
|
||||
@@ -27,8 +23,8 @@ export type Pocket = CoinPocket | LabelPocket;
|
||||
export type Leaf = {
|
||||
index: number;
|
||||
country: string; // issuer_code
|
||||
// length === cols*rows, row-major. The SAME contents are seen from either side of the leaf
|
||||
// (obverse from the front, reverse from the back). null === empty pocket.
|
||||
// length === cols*rows, row-major. The same contents are seen from either side of the leaf
|
||||
// (obverse from the front, reverse from the back).
|
||||
pockets: (Pocket | null)[];
|
||||
};
|
||||
|
||||
@@ -45,19 +41,14 @@ export type Book = {
|
||||
grid: GridSpec;
|
||||
};
|
||||
|
||||
// Year used for ordering within a country. year_min is the issue (or first-issue) year; fall back
|
||||
// to year_max. Pieces with neither sort last (see compareWithinCountry).
|
||||
function yearKey(piece: PieceWithIssuer): number | null {
|
||||
return piece.year_min ?? piece.year_max ?? null;
|
||||
}
|
||||
|
||||
function faceValue(piece: PieceWithIssuer): number {
|
||||
// Undated/valueless pieces (much exonumia) get +Infinity so they trail dated/valued ones.
|
||||
return piece.currency_numeric_value ?? Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
// Within a country: by year ascending; pieces with no year last; ties broken by face value then id
|
||||
// so the order is fully deterministic regardless of input order.
|
||||
function compareWithinCountry(a: PieceWithIssuer, b: PieceWithIssuer): number {
|
||||
const ya = yearKey(a);
|
||||
const yb = yearKey(b);
|
||||
@@ -70,18 +61,14 @@ function compareWithinCountry(a: PieceWithIssuer, b: PieceWithIssuer): number {
|
||||
return a.id - b.id;
|
||||
}
|
||||
|
||||
// Country (issuer) order matches the rest of the app: alphabetical by issuer name. This mirrors the
|
||||
// server's `ORDER BY i.name` in queries.issuerCounts / queryPieces. Ties broken by code.
|
||||
function compareCountries(a: PieceWithIssuer, b: PieceWithIssuer): number {
|
||||
const byName = a.issuer_name.localeCompare(b.issuer_name);
|
||||
if (byName !== 0) return byName;
|
||||
return a.issuer_code.localeCompare(b.issuer_code);
|
||||
}
|
||||
|
||||
export function buildBook(pieces: PieceWithIssuer[], grid: GridSpec): Book {
|
||||
export function buildBook(
|
||||
pieces: PieceWithIssuer[],
|
||||
grid: GridSpec,
|
||||
countryName: (code: string) => string,
|
||||
): Book {
|
||||
const perLeaf = grid.cols * grid.rows;
|
||||
|
||||
// 1. Expand by count — one pocket entry per physical instance.
|
||||
// One pocket entry per physical instance.
|
||||
const entries: CoinPocket[] = [];
|
||||
for (const piece of pieces) {
|
||||
const n = Math.max(1, piece.count ?? 1);
|
||||
@@ -89,7 +76,6 @@ export function buildBook(pieces: PieceWithIssuer[], grid: GridSpec): Book {
|
||||
entries.push({ kind: 'coin', piece, instance });
|
||||
}
|
||||
|
||||
// 2. Group by country.
|
||||
const byCountry = new Map<string, CoinPocket[]>();
|
||||
for (const entry of entries) {
|
||||
const code = entry.piece.issuer_code;
|
||||
@@ -98,32 +84,27 @@ export function buildBook(pieces: PieceWithIssuer[], grid: GridSpec): Book {
|
||||
else byCountry.set(code, [entry]);
|
||||
}
|
||||
|
||||
// Country order: alphabetical by name (every bucket has ≥1 entry; first piece names the country).
|
||||
const groups = [...byCountry.values()].sort((a, b) => compareCountries(a[0].piece, b[0].piece));
|
||||
const groups = [...byCountry.entries()].sort(
|
||||
([ca], [cb]) => countryName(ca).localeCompare(countryName(cb)) || ca.localeCompare(cb),
|
||||
);
|
||||
|
||||
const leaves: Leaf[] = [];
|
||||
const countries: CountryMark[] = [];
|
||||
|
||||
for (const bucket of groups) {
|
||||
const code = bucket[0].piece.issuer_code;
|
||||
// 3. Sort within the country by year ascending (stable, deterministic tie-break).
|
||||
for (const [code, bucket] of groups) {
|
||||
bucket.sort((ea, eb) => compareWithinCountry(ea.piece, eb.piece));
|
||||
|
||||
const first = bucket[0].piece;
|
||||
const name = countryName(code);
|
||||
const flagPath = bucket[0].piece.issuer_flag_path;
|
||||
countries.push({
|
||||
code,
|
||||
name: first.issuer_name,
|
||||
flagPath: first.issuer_flag_path,
|
||||
leafIndex: leaves.length, // 4. each country starts a fresh leaf
|
||||
name,
|
||||
flagPath,
|
||||
leafIndex: leaves.length, // each country starts a fresh leaf
|
||||
});
|
||||
|
||||
// 4. The country's pockets: a title tile first, then its coins (so coins shift by one).
|
||||
const cells: Pocket[] = [
|
||||
{ kind: 'label', code, name: first.issuer_name, flagPath: first.issuer_flag_path },
|
||||
...bucket,
|
||||
];
|
||||
const cells: Pocket[] = [{ kind: 'label', code, name, flagPath }, ...bucket];
|
||||
|
||||
// 5. Chunk into leaves of cols*rows pockets, row-major, padding the final leaf with nulls.
|
||||
for (let offset = 0; offset < cells.length; offset += perLeaf) {
|
||||
const slice = cells.slice(offset, offset + perLeaf);
|
||||
const pockets: (Pocket | null)[] = new Array(perLeaf).fill(null);
|
||||
@@ -135,8 +116,7 @@ export function buildBook(pieces: PieceWithIssuer[], grid: GridSpec): Book {
|
||||
return { leaves, countries, grid };
|
||||
}
|
||||
|
||||
// The country an (absolute) leaf belongs to — the last divider at or before it. null before the
|
||||
// first country (e.g. while the book is still closed).
|
||||
// The country a leaf belongs to: the last divider at or before it. null before the first country.
|
||||
export function countryAtLeaf(countries: CountryMark[], leafIndex: number): CountryMark | null {
|
||||
let found: CountryMark | null = null;
|
||||
for (const c of countries) {
|
||||
@@ -146,16 +126,14 @@ export function countryAtLeaf(countries: CountryMark[], leafIndex: number): Coun
|
||||
return found;
|
||||
}
|
||||
|
||||
// Largest coin diameter (mm) in the collection. Drives pocket pitch so nothing clips its window;
|
||||
// falls back to a sane default for an empty set. Banknotes are already excluded server-side.
|
||||
// Drives pocket pitch so the biggest coin clears its window.
|
||||
export function maxDiameterMm(pieces: PieceWithIssuer[]): number {
|
||||
let max = 0;
|
||||
for (const p of pieces) if (p.diameter && p.diameter > max) max = p.diameter;
|
||||
return max || 30;
|
||||
}
|
||||
|
||||
// Thickest coin (mm) in the collection. Drives leaf-to-leaf spacing so leaves stack as tightly as
|
||||
// possible without two back-to-back coins overlapping.
|
||||
// Drives leaf-to-leaf spacing so leaves stack tightly without back-to-back coins overlapping.
|
||||
export function maxThicknessMm(pieces: PieceWithIssuer[]): number {
|
||||
let max = 0;
|
||||
for (const p of pieces) if (p.thickness && p.thickness > max) max = p.thickness;
|
||||
|
||||
+67
-34
@@ -1,10 +1,21 @@
|
||||
import * as THREE from 'three';
|
||||
|
||||
// three can't sample an SVG directly, so flags (and the serif labels) are rasterised onto a canvas
|
||||
// and wrapped in a CanvasTexture. Everything is cached by key. Fully offline — no troika font fetch,
|
||||
// no external image host (the static-server CSP would block both).
|
||||
// three can't sample an SVG directly, so flags and labels are rasterised onto a canvas and wrapped
|
||||
// in a CanvasTexture, cached by key.
|
||||
|
||||
const SERIF = 'Georgia, "Times New Roman", serif';
|
||||
// EB Garamond is a web font, so it must be loaded before fillText or the canvas silently falls back
|
||||
// (see ensureFont).
|
||||
const SERIF = '"EB Garamond", Georgia, "Times New Roman", serif';
|
||||
|
||||
// Canvas can't trigger a web-font fetch on its own, so a cached texture's first paint would bake
|
||||
// whatever fallback was ready. Load the face once, then re-paint when it resolves.
|
||||
let fontReady: Promise<void> | null = null;
|
||||
function ensureFont(): Promise<void> {
|
||||
if (!fontReady) {
|
||||
fontReady = document.fonts?.load('1em "EB Garamond"').then(() => {}) ?? Promise.resolve();
|
||||
}
|
||||
return fontReady;
|
||||
}
|
||||
|
||||
const flagImageCache = new Map<string, Promise<HTMLImageElement>>();
|
||||
|
||||
@@ -33,7 +44,7 @@ function drawFlag(
|
||||
w: number,
|
||||
h: number,
|
||||
) {
|
||||
// Contain the flag within w×h, preserving aspect.
|
||||
// Contain within w×h, preserving aspect.
|
||||
const ar = img.width / img.height || 1.5;
|
||||
let dw = w;
|
||||
let dh = w / ar;
|
||||
@@ -44,8 +55,7 @@ function drawFlag(
|
||||
ctx.drawImage(img, x + (w - dw) / 2, y + (h - dh) / 2, dw, dh);
|
||||
}
|
||||
|
||||
// A square title tile for a country's first pocket: flag above, country name below, on a transparent
|
||||
// ground (so the page shows through). Cached; the flag fills in async once it loads.
|
||||
// Square title tile for a country's first pocket: flag above, name below. The flag fills in async.
|
||||
export function getLabelTexture(
|
||||
code: string,
|
||||
name: string,
|
||||
@@ -60,7 +70,7 @@ export function getLabelTexture(
|
||||
canvas.width = S * dpr;
|
||||
canvas.height = S * dpr;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return new THREE.CanvasTexture(canvas); // 2d context unavailable (shouldn't happen)
|
||||
if (!ctx) return new THREE.CanvasTexture(canvas);
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
const flagW = S * 0.5;
|
||||
@@ -70,7 +80,7 @@ export function getLabelTexture(
|
||||
|
||||
const paint = (img?: HTMLImageElement) => {
|
||||
ctx.clearRect(0, 0, S, S);
|
||||
ctx.fillStyle = '#f4f3ee'; // opaque white card
|
||||
ctx.fillStyle = '#f4f3ee';
|
||||
ctx.fillRect(0, 0, S, S);
|
||||
if (img) {
|
||||
drawFlag(ctx, img, flagX, flagY, flagW, flagH);
|
||||
@@ -81,7 +91,7 @@ export function getLabelTexture(
|
||||
ctx.fillStyle = '#2a2620';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
// Shrink the font until the name fits the tile width.
|
||||
// Shrink until the name fits the tile width.
|
||||
let size = 30;
|
||||
do {
|
||||
ctx.font = `small-caps ${size}px ${SERIF}`;
|
||||
@@ -96,6 +106,9 @@ export function getLabelTexture(
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
texture.anisotropy = 4;
|
||||
paint();
|
||||
ensureFont()
|
||||
.then(() => paint())
|
||||
.catch(() => {});
|
||||
if (flagPath)
|
||||
loadFlagImage(flagPath)
|
||||
.then(paint)
|
||||
@@ -105,13 +118,15 @@ export function getLabelTexture(
|
||||
return texture;
|
||||
}
|
||||
|
||||
let titleTexture: THREE.CanvasTexture | null = null;
|
||||
|
||||
// Aspect ratio (W/H) of the cover title texture, so the mesh can match it.
|
||||
// Fixed regardless of title text: longer titles shrink to fit rather than widening the tile.
|
||||
export const COVER_TITLE_ASPECT = 512 / 220;
|
||||
|
||||
export function getCoverTitleTexture(): THREE.CanvasTexture {
|
||||
if (titleTexture) return titleTexture;
|
||||
const titleTextureCache = new Map<string, THREE.CanvasTexture>();
|
||||
|
||||
export function getCoverTitleTexture(title: string): THREE.CanvasTexture {
|
||||
const cached = titleTextureCache.get(title);
|
||||
if (cached) return cached;
|
||||
|
||||
const dpr = 2;
|
||||
const W = 512;
|
||||
const H = 220;
|
||||
@@ -119,27 +134,45 @@ export function getCoverTitleTexture(): THREE.CanvasTexture {
|
||||
canvas.width = W * dpr;
|
||||
canvas.height = H * dpr;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return new THREE.CanvasTexture(canvas); // 2d context unavailable (shouldn't happen)
|
||||
if (!ctx) return new THREE.CanvasTexture(canvas);
|
||||
ctx.scale(dpr, dpr);
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
|
||||
ctx.fillStyle = '#c79a5e';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.font = `small-caps 150px ${SERIF}`;
|
||||
// Small-caps + extra letter-spacing for a struck-title feel.
|
||||
const text = 'Coins';
|
||||
const chars = [...title];
|
||||
const tracking = 10;
|
||||
const widths = [...text].map((ch) => ctx.measureText(ch).width + tracking);
|
||||
const total = widths.reduce((a, b) => a + b, 0) - tracking;
|
||||
let x = W / 2 - total / 2;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
ctx.fillText(text[i], x + (widths[i] - tracking) / 2, H / 2);
|
||||
x += widths[i];
|
||||
}
|
||||
const maxWidth = W * 0.9;
|
||||
|
||||
titleTexture = new THREE.CanvasTexture(canvas);
|
||||
titleTexture.colorSpace = THREE.SRGBColorSpace;
|
||||
titleTexture.anisotropy = 4;
|
||||
return titleTexture;
|
||||
const paint = () => {
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
ctx.fillStyle = '#c79a5e';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
// Shrink until the tracked title fits the tile.
|
||||
let size = 150;
|
||||
let widths: number[] = [];
|
||||
let total = 0;
|
||||
for (;;) {
|
||||
ctx.font = `small-caps ${size}px ${SERIF}`;
|
||||
widths = chars.map((ch) => ctx.measureText(ch).width + tracking);
|
||||
total = widths.reduce((a, b) => a + b, 0) - tracking;
|
||||
if (total <= maxWidth || size <= 40) break;
|
||||
size -= 4;
|
||||
}
|
||||
let x = W / 2 - total / 2;
|
||||
for (let i = 0; i < chars.length; i++) {
|
||||
ctx.fillText(chars[i], x + (widths[i] - tracking) / 2, H / 2);
|
||||
x += widths[i];
|
||||
}
|
||||
texture.needsUpdate = true;
|
||||
};
|
||||
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
texture.anisotropy = 4;
|
||||
paint();
|
||||
ensureFont()
|
||||
.then(() => paint())
|
||||
.catch(() => {});
|
||||
|
||||
titleTextureCache.set(title, texture);
|
||||
return texture;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import { useFrame } from '@react-three/fiber';
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import * as THREE from 'three';
|
||||
import type { BinderMetrics } from '@/album/constants';
|
||||
import { COVER_TITLE_ASPECT, getCoverTitleTexture } from '@/album/flag-texture';
|
||||
|
||||
// The binder shell — front cover, spine, back cover — and the rings. The back cover and rings are
|
||||
// static; the front cover + spine ACTUATE: the book starts closed (front cover folded over the
|
||||
// stack, spine standing) and opens as `position` rises from −1 to 0, swinging flat. The rings are
|
||||
// mounted FULLY within the back cover (never the spine); the right stack rests on the back cover,
|
||||
// the left (flipped) stack reaches left over the spine and front cover. Back cover width = leaf
|
||||
// width + distance between the ring legs + inner gap + paddings; the front cover is the same width.
|
||||
// The back cover and rings are static; the front cover + spine actuate, swinging flat as `position`
|
||||
// rises from −1 (closed, front cover folded over the stack) to 0. Rings mount fully within the back
|
||||
// cover, never the spine.
|
||||
//
|
||||
// Layout along x (z=0 is the table top; leaves stack up in +z, covers sit just below):
|
||||
// front cover [xBL−spineWidth−W .. xBL−spineWidth]
|
||||
@@ -17,23 +15,24 @@ import { COVER_TITLE_ASPECT, getCoverTitleTexture } from '@/album/flag-texture';
|
||||
// back cover [xBL .. xBL+W] (encloses both ring legs at ±ringR)
|
||||
// where xBL = −(ringR + innerGap).
|
||||
|
||||
const OUTER = 0x1a1611; // exterior / edges of a cover
|
||||
const INNER = 0xbdb6a3; // inner face (toward the pages) — subtle warm, not saturated
|
||||
const OUTER = 0x1a1611;
|
||||
const INNER = 0xbdb6a3;
|
||||
const SPINE = 0x141009;
|
||||
|
||||
// Title plate on the OUTSIDE of the front cover (the face that ends up on top when the book is
|
||||
// closed). The cover folds 180° about Y when closing, so the plate's own Ry(π) cancels that to read
|
||||
// upright; `contentSpin` then keeps it upright when the whole binder is rolled for portrait.
|
||||
// Plate on the outside of the front cover. The cover folds 180° about Y when closing, so the plate's
|
||||
// own Ry(π) cancels that to read upright; contentSpin keeps it upright under the portrait roll.
|
||||
function CoverTitle({ metrics, contentSpin }: { metrics: BinderMetrics; contentSpin: number }) {
|
||||
const { coverW, coverThickness } = metrics.shell;
|
||||
const { t } = useTranslation();
|
||||
const title = t('app.title');
|
||||
const material = useMemo(
|
||||
() =>
|
||||
new THREE.MeshBasicMaterial({
|
||||
map: getCoverTitleTexture(),
|
||||
map: getCoverTitleTexture(title),
|
||||
transparent: true,
|
||||
toneMapped: false,
|
||||
}),
|
||||
[],
|
||||
[title],
|
||||
);
|
||||
const w = coverW * 0.62;
|
||||
const h = w / COVER_TITLE_ASPECT;
|
||||
@@ -60,9 +59,8 @@ export function BinderShell({
|
||||
const { ringR, legTop, spineWidth, coverW, coverH, coverThickness, xBL } = metrics.shell;
|
||||
const coverZ = -coverThickness / 2; // top face at z=0
|
||||
|
||||
// All shell geometry/materials in one bundle so a single effect can release them when the metrics
|
||||
// change (grid resize) or the binder unmounts. Rings: legs at x = ±ringR up to legTop, joined by
|
||||
// an upside-down semicircle — the exact hole path, so they stay threaded for every leaf position.
|
||||
// Rings: legs at x = ±ringR up to legTop, joined by an upside-down semicircle (the hole path), so
|
||||
// they stay threaded for every leaf position.
|
||||
const tube = 0.0014;
|
||||
const { coverGeo, spineGeo, coverMats, spineMat, ringMat, legGeo, arcGeo } = useMemo(() => {
|
||||
const outer = new THREE.MeshStandardMaterial({
|
||||
@@ -110,9 +108,7 @@ export function BinderShell({
|
||||
[coverGeo, spineGeo, coverMats, spineMat, ringMat, legGeo, arcGeo],
|
||||
);
|
||||
|
||||
// The spine + front cover fold open as `open` goes 0 (closed) → 1 (flat). The spine hinges up at
|
||||
// the back cover's edge; the front cover folds a further equal angle, landing flat at open=1 and
|
||||
// over the top of the stack at open=0.
|
||||
// Spine and front cover each rotate by the same angle as `open` goes 0 (closed) → 1 (flat).
|
||||
const spineRef = useRef<THREE.Group>(null);
|
||||
const frontRef = useRef<THREE.Group>(null);
|
||||
useFrame(() => {
|
||||
@@ -124,10 +120,9 @@ export function BinderShell({
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Back cover — static, the binder rests on it. */}
|
||||
<mesh geometry={coverGeo} material={coverMats} position={[xBL + coverW / 2, 0, coverZ]} />
|
||||
|
||||
{/* Spine, hinged at the back cover's spine edge (xBL); front cover hinged at the spine's end. */}
|
||||
{/* Spine hinged at xBL; front cover hinged at the spine's end. */}
|
||||
<group ref={spineRef} position={[xBL, 0, 0]}>
|
||||
<mesh geometry={spineGeo} material={spineMat} position={[-spineWidth / 2, 0, coverZ]} />
|
||||
<group ref={frontRef} position={[-spineWidth, 0, 0]}>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Label, Slider, SliderOutput, SliderThumb, SliderTrack } from 'react-aria-components';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
// Face value slider with "Any" as the leftmost stop. Position 0 == any (null), position k+1 ==
|
||||
// steps[k].
|
||||
// Position 0 == any (null), position k+1 == steps[k].
|
||||
export function FaceValueSlider({
|
||||
steps,
|
||||
value,
|
||||
@@ -11,8 +11,9 @@ export function FaceValueSlider({
|
||||
value: number | null;
|
||||
onChange: (v: number | null) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const sliderIndex = value === null ? 0 : Math.max(0, steps.indexOf(value)) + 1;
|
||||
const label = sliderIndex === 0 ? 'Any' : String(steps[sliderIndex - 1]);
|
||||
const label = sliderIndex === 0 ? t('filters.any') : String(steps[sliderIndex - 1]);
|
||||
|
||||
return (
|
||||
<Slider
|
||||
@@ -45,7 +46,7 @@ export function FaceValueSlider({
|
||||
</SliderTrack>
|
||||
<SliderOutput className="sr-only" />
|
||||
<div className="flex justify-between text-mini text-ink-faint tnum">
|
||||
<span>Any</span>
|
||||
<span>{t('filters.any')}</span>
|
||||
{steps.map((s) => (
|
||||
<span key={s}>{s}</span>
|
||||
))}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { Category, Edition, ShapeVariant } from '@/filter-types';
|
||||
|
||||
export const CATEGORY_LABELS: Record<Category, string> = {
|
||||
coin: 'Coins',
|
||||
banknote: 'Banknotes',
|
||||
exonumia: 'Exonumia',
|
||||
};
|
||||
|
||||
export const EDITION_LABELS: Record<Edition, string> = {
|
||||
standard: 'Standard',
|
||||
special: 'Special',
|
||||
};
|
||||
|
||||
export const SHAPE_LABELS: Record<ShapeVariant, string> = {
|
||||
round: 'Round',
|
||||
polygon: 'Polygon',
|
||||
'holed-round': 'Holed round',
|
||||
'holed-polygon': 'Holed polygon',
|
||||
'notched-round': 'Flower',
|
||||
'sine-round': 'Wavy',
|
||||
rectangular: 'Rectangular',
|
||||
irregular: 'Irregular',
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Button, DialogTrigger, ListBox, ListBoxItem, Popover } from 'react-aria-components';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import CheckIcon from './icons/check.svg?react';
|
||||
import ChevronIcon from './icons/chevron.svg?react';
|
||||
|
||||
@@ -20,22 +21,21 @@ export function MultiSelect<T extends string>({
|
||||
value: T[];
|
||||
onChange: (val: T[] | null) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const labelById = new Map(options.map((o) => [o.value, o.label]));
|
||||
// Hide URL values that aren't in the current facet so the button label and the dropdown stay
|
||||
// consistent. We don't proactively clean up the URL — any hidden values fall out the next time
|
||||
// the user interacts with this field.
|
||||
// Hide values absent from the current facet; the URL keeps them until the next interaction here.
|
||||
const visibleValue = value.filter((v) => labelById.has(v));
|
||||
const display =
|
||||
visibleValue.length === 0
|
||||
? placeholder
|
||||
: visibleValue.length <= 2
|
||||
? visibleValue.map((v) => labelById.get(v) ?? v).join(', ')
|
||||
: `${visibleValue.length} selected`;
|
||||
: t('filters.selected', { count: visibleValue.length });
|
||||
|
||||
return (
|
||||
<DialogTrigger>
|
||||
<Button
|
||||
className={`w-full flex items-center gap-2 px-3 h-9 border border-rule-strong bg-card text-data text-left outline-none data-[hovered]:border-brass data-[focus-visible]:border-brass transition-colors ${visibleValue.length === 0 ? 'text-ink-dim italic' : 'text-ink'}`}
|
||||
className={`w-full flex items-center gap-2 px-3 h-9 border border-rule-strong bg-card text-data text-left outline-none data-hovered:border-brass data-focus-visible:border-brass transition-colors ${visibleValue.length === 0 ? 'text-ink-dim italic' : 'text-ink'}`}
|
||||
>
|
||||
<span className="flex-1 truncate">{display}</span>
|
||||
<ChevronIcon className="text-ink-dim" />
|
||||
@@ -43,7 +43,7 @@ export function MultiSelect<T extends string>({
|
||||
<Popover
|
||||
placement="bottom start"
|
||||
offset={4}
|
||||
className="w-(--trigger-width) max-h-72 overflow-y-auto border border-rule-strong bg-card outline-none data-[entering]:animate-pop-in data-[exiting]:animate-pop-out"
|
||||
className="w-(--trigger-width) max-h-72 overflow-y-auto border border-rule-strong bg-card outline-none data-entering:animate-pop-in data-exiting:animate-pop-out"
|
||||
>
|
||||
<ListBox
|
||||
selectionMode="multiple"
|
||||
@@ -61,9 +61,9 @@ export function MultiSelect<T extends string>({
|
||||
<ListBoxItem
|
||||
id={option.value}
|
||||
textValue={option.label}
|
||||
className="group flex items-center gap-2.5 px-3 py-1.5 text-data text-ink cursor-pointer outline-none data-[focused]:bg-page data-[selected]:text-brass"
|
||||
className="group flex items-center gap-2.5 px-3 py-1.5 text-data text-ink cursor-pointer outline-none data-focused:bg-page data-selected:text-brass"
|
||||
>
|
||||
<CheckIcon className="w-3 h-3 text-brass opacity-0 group-data-[selected]:opacity-100 shrink-0" />
|
||||
<CheckIcon className="w-3 h-3 text-brass opacity-0 group-data-selected:opacity-100 shrink-0" />
|
||||
{option.flag !== undefined && option.flag !== null && (
|
||||
<img
|
||||
src={`/flags/${option.flag}`}
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { startTransition, use, useState } from 'react';
|
||||
import { Button, Link } from 'react-aria-components';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { fetchPieces } from '@/api';
|
||||
import { FaceValueSlider } from '@/components/filters/face-value-slider';
|
||||
import FiltersIcon from '@/components/filters/icons/filters.svg?react';
|
||||
import { InvertedGroup } from '@/components/filters/inverted-group';
|
||||
import { CATEGORY_LABELS, EDITION_LABELS, SHAPE_LABELS } from '@/components/filters/labels';
|
||||
import { MultiSelect } from '@/components/filters/multi-select';
|
||||
import { Section } from '@/components/filters/section';
|
||||
import { isAnyFilterActive, useFilters } from '@/components/filters/state';
|
||||
import { YearRangeSlider } from '@/components/filters/year-range-slider';
|
||||
import { CATEGORIES, type Category, EDITIONS } from '@/filter-types';
|
||||
import { useCountryName } from '@/i18n';
|
||||
|
||||
const TOP_BAR_HEIGHT = 'h-14'; // 56px — sets the offset for the drawer below
|
||||
const TOP_BAR_HEIGHT = 'h-14';
|
||||
const navLinkClass =
|
||||
'caps text-mini px-2.5 h-8 flex items-center text-ink-dim outline-none transition-colors data-[hovered]:text-brass data-[pressed]:text-brass-deep data-[focus-visible]:text-brass';
|
||||
|
||||
@@ -25,6 +26,18 @@ export function FilterSidebar({
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { filters, setState } = useFilters();
|
||||
const { facets } = use(fetchPieces(filters));
|
||||
const { t } = useTranslation();
|
||||
const countryName = useCountryName();
|
||||
|
||||
const categoryLabels: Record<Category, string> = {
|
||||
coin: t('filters.category.coin'),
|
||||
banknote: t('filters.category.banknote'),
|
||||
exonumia: t('filters.category.exonumia'),
|
||||
};
|
||||
const editionLabels = {
|
||||
standard: t('filters.edition.standard'),
|
||||
special: t('filters.edition.special'),
|
||||
};
|
||||
|
||||
const update = (changes: Parameters<typeof setState>[0]) => {
|
||||
startTransition(() => {
|
||||
@@ -53,67 +66,61 @@ export function FilterSidebar({
|
||||
const categoryCounts = new Map(facets.categories.map((c) => [c.category, c.count]));
|
||||
const editionCounts = new Map(facets.editions.map((e) => [e.edition, e.count]));
|
||||
|
||||
// Preserve query string so filters survive cross-page nav. Read at render time — the host page
|
||||
// re-renders on nuqs state change, so this stays current.
|
||||
// Preserve query string so filters survive cross-page nav.
|
||||
const search = typeof window !== 'undefined' ? window.location.search : '';
|
||||
const otherHref = `/${other}${search}`;
|
||||
const otherLabel = other === 'pile' ? 'Pile' : 'Showcase';
|
||||
const otherLabel = t(other === 'pile' ? 'nav.pile' : 'nav.showcase');
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Top bar — always visible, same width as the drawer. The drawer slides
|
||||
in below it so the nav stays accessible whether the drawer is open
|
||||
or closed. */}
|
||||
<div
|
||||
className={`absolute top-0 right-0 w-full sm:w-96 ${TOP_BAR_HEIGHT} z-40 border-b border-l border-rule bg-page/85 backdrop-blur-sm flex items-center px-4`}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<Link href="/" className={navLinkClass}>
|
||||
home
|
||||
{t('nav.home')}
|
||||
</Link>
|
||||
<span className="text-ink-faint">·</span>
|
||||
<Link href={otherHref} className={navLinkClass}>
|
||||
{otherLabel.toLowerCase()}
|
||||
{otherLabel}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
onPress={() => setIsOpen((o) => !o)}
|
||||
aria-label="Toggle filters"
|
||||
aria-label={t('filters.toggle')}
|
||||
aria-expanded={isOpen}
|
||||
className={`caps text-mini flex items-center gap-2 px-2.5 h-8 outline-none transition-colors ${isOpen ? 'text-brass' : 'text-ink-dim data-[hovered]:text-brass'} data-[focus-visible]:text-brass`}
|
||||
>
|
||||
<FiltersIcon />
|
||||
filters
|
||||
{t('filters.title')}
|
||||
{isAnyFilterActive(filters) && (
|
||||
<span className="ml-0.5 w-1.5 h-1.5 rounded-full bg-brass" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Drawer — anchored below the top bar, slides in from the right.
|
||||
Plain animated div (no react-aria Modal) so the canvas keeps full
|
||||
pointer events even when the drawer is open. */}
|
||||
{/* Plain animated div (no react-aria Modal) so the canvas keeps pointer events while open. */}
|
||||
<aside
|
||||
aria-label="Filters"
|
||||
aria-label={t('filters.title')}
|
||||
aria-hidden={!isOpen}
|
||||
className={`absolute top-14 right-0 bottom-0 w-full sm:w-96 bg-page border-l border-rule z-30 transition-transform duration-250 ease-out ${isOpen ? 'translate-x-0' : 'translate-x-full pointer-events-none'}`}
|
||||
>
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-rule">
|
||||
<h2 className="caps text-mini text-ink">filters</h2>
|
||||
<h2 className="caps text-mini text-ink">{t('filters.title')}</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
{isAnyFilterActive(filters) && (
|
||||
<Button
|
||||
onPress={clearAll}
|
||||
className="caps text-mini text-ink-dim data-[hovered]:text-brass outline-none transition-colors"
|
||||
>
|
||||
clear all
|
||||
{t('filters.clearAll')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onPress={() => setIsOpen(false)}
|
||||
aria-label="Close filters"
|
||||
aria-label={t('filters.close')}
|
||||
className="w-7 h-7 flex items-center justify-center text-ink-dim data-[hovered]:text-brass outline-none transition-colors"
|
||||
>
|
||||
✕
|
||||
@@ -123,15 +130,17 @@ export function FilterSidebar({
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-6">
|
||||
{facets.countries.length > 0 && (
|
||||
<Section title="Country">
|
||||
<Section title={t('filters.section.country')}>
|
||||
<MultiSelect
|
||||
placeholder="All countries"
|
||||
options={facets.countries.map((c) => ({
|
||||
value: c.code,
|
||||
label: c.name,
|
||||
count: c.count,
|
||||
flag: c.flag_path,
|
||||
}))}
|
||||
placeholder={t('filters.allCountries')}
|
||||
options={facets.countries
|
||||
.map((c) => ({
|
||||
value: c.code,
|
||||
label: countryName(c.code),
|
||||
count: c.count,
|
||||
flag: c.flag_path,
|
||||
}))
|
||||
.sort((a, b) => a.label.localeCompare(b.label))}
|
||||
value={filters.countries ?? []}
|
||||
onChange={(v) => update({ countries: v })}
|
||||
/>
|
||||
@@ -139,9 +148,9 @@ export function FilterSidebar({
|
||||
)}
|
||||
|
||||
{facets.currencies.length > 0 && (
|
||||
<Section title="Currency">
|
||||
<Section title={t('filters.section.currency')}>
|
||||
<MultiSelect
|
||||
placeholder="All currencies"
|
||||
placeholder={t('filters.allCurrencies')}
|
||||
options={facets.currencies.map((c) => ({
|
||||
value: c.name,
|
||||
label: c.name,
|
||||
@@ -154,7 +163,7 @@ export function FilterSidebar({
|
||||
)}
|
||||
|
||||
{faceValueSteps.length > 0 && (
|
||||
<Section title="Face value">
|
||||
<Section title={t('filters.section.faceValue')}>
|
||||
<FaceValueSlider
|
||||
steps={faceValueSteps}
|
||||
value={filters.faceValue ?? null}
|
||||
@@ -163,10 +172,10 @@ export function FilterSidebar({
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section title="Type">
|
||||
<Section title={t('filters.section.type')}>
|
||||
<InvertedGroup
|
||||
options={CATEGORIES}
|
||||
labels={CATEGORY_LABELS}
|
||||
labels={categoryLabels}
|
||||
facetCounts={categoryCounts}
|
||||
selected={filters.categories ?? []}
|
||||
excluded={excludedCategories}
|
||||
@@ -174,10 +183,10 @@ export function FilterSidebar({
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section title="Edition">
|
||||
<Section title={t('filters.section.edition')}>
|
||||
<InvertedGroup
|
||||
options={EDITIONS}
|
||||
labels={EDITION_LABELS}
|
||||
labels={editionLabels}
|
||||
facetCounts={editionCounts}
|
||||
selected={filters.editions ?? []}
|
||||
onChange={(v) => update({ editions: v })}
|
||||
@@ -185,12 +194,12 @@ export function FilterSidebar({
|
||||
</Section>
|
||||
|
||||
{facets.shapes.length > 0 && (
|
||||
<Section title="Shape">
|
||||
<Section title={t('filters.section.shape')}>
|
||||
<MultiSelect
|
||||
placeholder="All shapes"
|
||||
placeholder={t('filters.allShapes')}
|
||||
options={facets.shapes.map((s) => ({
|
||||
value: s.shape,
|
||||
label: SHAPE_LABELS[s.shape],
|
||||
label: t(`filters.shape.${s.shape}`),
|
||||
count: s.count,
|
||||
}))}
|
||||
value={filters.shapes ?? []}
|
||||
@@ -200,7 +209,7 @@ export function FilterSidebar({
|
||||
)}
|
||||
|
||||
{yearRangeUsable && (
|
||||
<Section title="Years">
|
||||
<Section title={t('filters.section.years')}>
|
||||
<YearRangeSlider
|
||||
bounds={[yearMin, yearMax]}
|
||||
value={[filters.yearMin ?? yearMin, filters.yearMax ?? yearMax]}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
SliderThumb,
|
||||
SliderTrack,
|
||||
} from 'react-aria-components';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export function YearRangeSlider({
|
||||
bounds,
|
||||
@@ -18,6 +19,7 @@ export function YearRangeSlider({
|
||||
active: boolean;
|
||||
onChange: (min: number | null, max: number | null) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Slider
|
||||
value={value}
|
||||
@@ -39,7 +41,7 @@ export function YearRangeSlider({
|
||||
onPress={() => onChange(null, null)}
|
||||
className="caps text-mini text-ink-dim data-[hovered]:text-brass outline-none transition-colors"
|
||||
>
|
||||
clear
|
||||
{t('filters.clear')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export function Loading() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="h-svh flex items-center justify-center bg-page text-ink-dim caps text-mini italic">
|
||||
loading…
|
||||
{t('loading')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { PieceWithIssuer } from '@/db/types';
|
||||
import { useCountryName } from '@/i18n';
|
||||
|
||||
export function InfoPanel({ piece }: { piece: PieceWithIssuer }) {
|
||||
const { t } = useTranslation();
|
||||
const countryName = useCountryName();
|
||||
const subtitle = [
|
||||
piece.currency_numeric_value !== null && piece.currency_name
|
||||
? `${piece.currency_numeric_value} ${piece.currency_name}`
|
||||
@@ -36,7 +40,7 @@ export function InfoPanel({ piece }: { piece: PieceWithIssuer }) {
|
||||
className="w-6 h-4 object-contain opacity-90"
|
||||
/>
|
||||
)}
|
||||
<span className="caps text-mini text-ink-dim">{piece.issuer_name}</span>
|
||||
<span className="caps text-mini text-ink-dim">{countryName(piece.issuer_code)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-baseline gap-3 whitespace-nowrap">
|
||||
@@ -59,7 +63,7 @@ export function InfoPanel({ piece }: { piece: PieceWithIssuer }) {
|
||||
rel="noopener noreferrer"
|
||||
className="caps text-mini text-brass hover:text-ink mt-5 inline-block pointer-events-auto transition-colors"
|
||||
>
|
||||
view on numista ↗
|
||||
{t('piece.viewOnNumista')}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
|
||||
+8
-8
@@ -52,8 +52,8 @@ function buildClauses(
|
||||
params.push(...filters.shapes);
|
||||
}
|
||||
if (exclude !== 'years') {
|
||||
// Year overlap: a coin (year_min..year_max) overlaps the filter range if coin.year_min <=
|
||||
// filter.yearMax and coin.year_max >= filter.yearMin. Treat NULL years as "always overlaps".
|
||||
// Overlap, not containment: coin year_min..year_max overlaps the filter range iff year_min <=
|
||||
// yearMax and year_max >= yearMin. NULL years always overlap.
|
||||
if (filters.yearMin !== undefined && filters.yearMax !== undefined) {
|
||||
clauses.push('(c.year_min IS NULL OR c.year_min <= ?)');
|
||||
clauses.push('(c.year_max IS NULL OR c.year_max >= ?)');
|
||||
@@ -78,11 +78,11 @@ export function queryPieces(filters: PieceFilters): PiecesResponse {
|
||||
const { clauses, params } = buildClauses(filters);
|
||||
const pieces = db
|
||||
.prepare(`
|
||||
SELECT c.*, i.name AS issuer_name, i.flag_path AS issuer_flag_path
|
||||
SELECT c.*, i.flag_path AS issuer_flag_path
|
||||
FROM coins c
|
||||
LEFT JOIN issuers i ON i.code = c.issuer_code
|
||||
${whereOf(clauses)}
|
||||
ORDER BY i.name, c.year_min, c.currency_numeric_value
|
||||
ORDER BY c.issuer_code, c.year_min, c.currency_numeric_value
|
||||
`)
|
||||
.all(...params) as PieceWithIssuer[];
|
||||
|
||||
@@ -93,14 +93,14 @@ function queryFacets(filters: PieceFilters): PieceFacets {
|
||||
const c = buildClauses(filters, 'countries');
|
||||
const countries = db
|
||||
.prepare(`
|
||||
SELECT c.issuer_code AS code, i.name, i.flag_path, COUNT(*) AS count
|
||||
SELECT c.issuer_code AS code, i.flag_path, COUNT(*) AS count
|
||||
FROM coins c
|
||||
LEFT JOIN issuers i ON i.code = c.issuer_code
|
||||
${whereOf(c.clauses)}
|
||||
GROUP BY c.issuer_code, i.name, i.flag_path
|
||||
ORDER BY i.name
|
||||
GROUP BY c.issuer_code, i.flag_path
|
||||
ORDER BY c.issuer_code
|
||||
`)
|
||||
.all(...c.params) as { code: string; name: string; flag_path: string | null; count: number }[];
|
||||
.all(...c.params) as { code: string; flag_path: string | null; count: number }[];
|
||||
|
||||
const cu = buildClauses(filters, 'currencies');
|
||||
cu.clauses.push('c.currency_name IS NOT NULL');
|
||||
|
||||
+31
-1
@@ -1,5 +1,5 @@
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { mkdirSync, readFileSync } from 'node:fs';
|
||||
import { mkdirSync, readdirSync, readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
export const dataDir = process.env.COINS_DATA_DIR ?? './data';
|
||||
@@ -17,3 +17,33 @@ db.exec('PRAGMA foreign_keys = ON');
|
||||
|
||||
const schemaPath = join(dirname(import.meta.path), 'schema.sql');
|
||||
db.exec(readFileSync(schemaPath, 'utf8'));
|
||||
|
||||
// schema.sql is the create-from-scratch baseline; ordered .sql files in ./migrations carry it
|
||||
// forward, each applied once and recorded by filename. A fresh DB runs the baseline then every
|
||||
// migration; an existing one runs only unseen migrations. Both converge on the same shape.
|
||||
function runMigrations(): void {
|
||||
db.exec(
|
||||
'CREATE TABLE IF NOT EXISTS _migrations (name TEXT PRIMARY KEY, applied_at INTEGER NOT NULL DEFAULT (unixepoch()))',
|
||||
);
|
||||
const applied = new Set(
|
||||
db
|
||||
.query<{ name: string }, []>('SELECT name FROM _migrations')
|
||||
.all()
|
||||
.map((r) => r.name),
|
||||
);
|
||||
const dir = join(dirname(import.meta.path), 'migrations');
|
||||
const files = readdirSync(dir)
|
||||
.filter((f) => f.endsWith('.sql'))
|
||||
.sort();
|
||||
for (const file of files) {
|
||||
if (applied.has(file)) continue;
|
||||
const sql = readFileSync(join(dir, file), 'utf8');
|
||||
db.transaction(() => {
|
||||
db.exec(sql);
|
||||
db.query('INSERT INTO _migrations (name) VALUES (?)').run(file);
|
||||
})();
|
||||
console.log(`[db] applied migration ${file}`);
|
||||
}
|
||||
}
|
||||
|
||||
runMigrations();
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Country names live in the i18n `country` namespace keyed by issuer code, so the issuer table
|
||||
-- does not store a name.
|
||||
ALTER TABLE issuers DROP COLUMN name;
|
||||
+7
-5
@@ -2,15 +2,17 @@ import { db } from '@/db';
|
||||
import type { Issuer, IssuerCount, Piece, PieceWithIssuer } from '@/db/types';
|
||||
|
||||
export const queries = {
|
||||
// Country names live in the i18n `country` namespace keyed by issuer code, not the DB; the client
|
||||
// re-sorts by the localized name where display order matters.
|
||||
allPieces: db.query<PieceWithIssuer, []>(`
|
||||
SELECT c.*, i.name AS issuer_name, i.flag_path AS issuer_flag_path
|
||||
SELECT c.*, i.flag_path AS issuer_flag_path
|
||||
FROM coins c
|
||||
LEFT JOIN issuers i ON i.code = c.issuer_code
|
||||
ORDER BY i.name, c.year_min, c.currency_numeric_value
|
||||
ORDER BY c.issuer_code, c.year_min, c.currency_numeric_value
|
||||
`),
|
||||
pieceById: db.query<Piece, [number]>('SELECT * FROM coins WHERE id = ?'),
|
||||
issuerCounts: db.query<IssuerCount, []>(`
|
||||
SELECT i.code, i.name, i.flag_path,
|
||||
SELECT i.code, i.flag_path,
|
||||
SUM(CASE WHEN c.category != 'banknote' THEN 1 ELSE 0 END) AS coin_count,
|
||||
COALESCE(SUM(CASE WHEN c.category != 'banknote' THEN c.count ELSE 0 END), 0) AS coin_sum,
|
||||
SUM(CASE WHEN c.category = 'banknote' THEN 1 ELSE 0 END) AS banknote_count,
|
||||
@@ -19,8 +21,8 @@ export const queries = {
|
||||
LEFT JOIN coins c ON c.issuer_code = i.code
|
||||
GROUP BY i.code
|
||||
HAVING COUNT(c.id) > 0
|
||||
ORDER BY i.name
|
||||
ORDER BY i.code
|
||||
`),
|
||||
issuerByCode: db.query<Issuer, [string]>('SELECT * FROM issuers WHERE code = ?'),
|
||||
allIssuers: db.query<Issuer, []>('SELECT * FROM issuers ORDER BY name'),
|
||||
allIssuers: db.query<Issuer, []>('SELECT * FROM issuers ORDER BY code'),
|
||||
};
|
||||
|
||||
+3
-7
@@ -1,5 +1,6 @@
|
||||
CREATE TABLE IF NOT EXISTS issuers (
|
||||
code TEXT PRIMARY KEY,
|
||||
-- Baseline only: dropped by migration 001. Country names live in the i18n `country` namespace.
|
||||
name TEXT NOT NULL,
|
||||
wikidata_id TEXT,
|
||||
flag_path TEXT,
|
||||
@@ -15,11 +16,7 @@ CREATE TABLE IF NOT EXISTS coins (
|
||||
currency_numeric_value REAL,
|
||||
year_min INTEGER,
|
||||
year_max INTEGER,
|
||||
-- `shape_variant` tells the renderer which procedural outline to use;
|
||||
-- `shape_params` is JSON whose schema depends on the variant. Variants
|
||||
-- that don't take params (round, irregular) must have NULL params; all
|
||||
-- other variants must have a JSON object. Validity is enforced by SQLite
|
||||
-- (the cross-column CHECK below + json_valid on the params column).
|
||||
-- shape_params is JSON whose schema depends on shape_variant; see the cross-column CHECK below.
|
||||
shape_variant TEXT NOT NULL DEFAULT 'round' CHECK (
|
||||
shape_variant IN (
|
||||
'round',
|
||||
@@ -46,8 +43,7 @@ CREATE TABLE IF NOT EXISTS coins (
|
||||
reverse_src TEXT,
|
||||
edge_src TEXT,
|
||||
added_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
-- Variant-params consistency: parameterless variants must have NULL params,
|
||||
-- parameter-taking variants must NOT.
|
||||
-- Parameterless variants must have NULL params; parameter-taking variants must not.
|
||||
CHECK (
|
||||
(shape_variant IN ('round', 'irregular', 'rectangular') AND shape_params IS NULL)
|
||||
OR (shape_variant NOT IN ('round', 'irregular', 'rectangular') AND shape_params IS NOT NULL)
|
||||
|
||||
+3
-7
@@ -1,6 +1,5 @@
|
||||
export type Issuer = {
|
||||
code: string;
|
||||
name: string;
|
||||
wikidata_id: string | null;
|
||||
flag_path: string | null;
|
||||
added_at: number;
|
||||
@@ -18,9 +17,8 @@ export const SHAPE_VARIANTS = [
|
||||
] as const;
|
||||
export type ShapeVariant = (typeof SHAPE_VARIANTS)[number];
|
||||
|
||||
// Shape of `shape_params` JSON per variant (after JSON.parse). `null` means the column is NULL
|
||||
// (parameterless variants only). The actual DB column is always a JSON string or NULL — see
|
||||
// `parseShapeParams` for safe access.
|
||||
// Shape of `shape_params` after JSON.parse; `null` is the parameterless variants. The DB column is
|
||||
// a JSON string or NULL.
|
||||
export type ShapeParams = {
|
||||
round: null;
|
||||
polygon: {
|
||||
@@ -53,7 +51,7 @@ export type Piece = {
|
||||
year_min: number | null;
|
||||
year_max: number | null;
|
||||
shape_variant: ShapeVariant;
|
||||
shape_params: string | null; // JSON, schema depends on shape_variant
|
||||
shape_params: string | null;
|
||||
diameter: number | null;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
@@ -70,13 +68,11 @@ export type Piece = {
|
||||
};
|
||||
|
||||
export type PieceWithIssuer = Piece & {
|
||||
issuer_name: string;
|
||||
issuer_flag_path: string | null;
|
||||
};
|
||||
|
||||
export type IssuerCount = {
|
||||
code: string;
|
||||
name: string;
|
||||
flag_path: string | null;
|
||||
coin_count: number;
|
||||
coin_sum: number;
|
||||
|
||||
+3
-3
@@ -1,5 +1,5 @@
|
||||
// Pure types + runtime constants shared by client and server. Kept free of any runtime dependency
|
||||
// on bun:sqlite so it can be imported from the client.
|
||||
// Shared client/server types and constants. Must stay free of any bun:sqlite runtime dependency so
|
||||
// the client can import it.
|
||||
|
||||
import type { PieceWithIssuer } from '@/db/types';
|
||||
import { SHAPE_VARIANTS, type ShapeVariant } from '@/db/types';
|
||||
@@ -24,7 +24,7 @@ export type PieceFilters = {
|
||||
};
|
||||
|
||||
export type PieceFacets = {
|
||||
countries: { code: string; name: string; flag_path: string | null; count: number }[];
|
||||
countries: { code: string; flag_path: string | null; count: number }[];
|
||||
currencies: { name: string; count: number }[];
|
||||
faceValues: { value: number; count: number }[];
|
||||
categories: { category: Category; count: number }[];
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import i18n from 'i18next';
|
||||
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||
import { useCallback } from 'react';
|
||||
import { initReactI18next, useTranslation } from 'react-i18next';
|
||||
import { common as enCommon, country as enCountry } from './locales/en';
|
||||
import { common as ptCommon, country as ptCountry } from './locales/pt';
|
||||
|
||||
export const SUPPORTED_LNGS = ['en', 'pt'] as const;
|
||||
export const FALLBACK_LNG = 'en';
|
||||
|
||||
const resources = {
|
||||
en: { common: enCommon, country: enCountry },
|
||||
pt: { common: ptCommon, country: ptCountry },
|
||||
};
|
||||
|
||||
i18n
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources,
|
||||
fallbackLng: FALLBACK_LNG,
|
||||
supportedLngs: [...SUPPORTED_LNGS],
|
||||
nonExplicitSupportedLngs: true,
|
||||
load: 'currentOnly',
|
||||
ns: ['common', 'country'],
|
||||
defaultNS: 'common',
|
||||
interpolation: { escapeValue: false },
|
||||
detection: { order: ['navigator'], caches: [] },
|
||||
returnNull: false,
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
|
||||
// Untranslated codes degrade to the raw code, not an empty string.
|
||||
export function useCountryName(): (code: string) => string {
|
||||
const { t } = useTranslation('country');
|
||||
// Stable across renders so callers can memoize on it; `t`'s identity changes with the locale.
|
||||
return useCallback((code: string) => t(code, { defaultValue: code }), [t]);
|
||||
}
|
||||
|
||||
declare module 'i18next' {
|
||||
interface CustomTypeOptions {
|
||||
defaultNS: 'common';
|
||||
resources: {
|
||||
common: typeof enCommon;
|
||||
country: typeof enCountry;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
export const common = {
|
||||
app: { title: 'Coins' },
|
||||
nav: {
|
||||
home: 'home',
|
||||
theAlbum: 'the album',
|
||||
album: 'album',
|
||||
pile: 'pile',
|
||||
showcase: 'showcase',
|
||||
},
|
||||
loading: 'loading…',
|
||||
home: {
|
||||
tagline: 'a personal collection',
|
||||
empty: 'No coins ingested yet. Try',
|
||||
coins_one: '{{count}} coin',
|
||||
coins_other: '{{count}} coins',
|
||||
banknotes_one: '{{count}} banknote',
|
||||
banknotes_other: '{{count}} banknotes',
|
||||
unique: '({{count}} unique)',
|
||||
},
|
||||
showcase: {
|
||||
empty: 'no pieces match',
|
||||
prev: 'Previous coin',
|
||||
next: 'Next coin',
|
||||
pauseSpin: 'Pause spinning',
|
||||
resumeSpin: 'Resume spinning',
|
||||
controls: '← → cycle · space toggle spin ·',
|
||||
},
|
||||
pile: {
|
||||
empty: 'no coins match',
|
||||
// Noun only; the count is a separate styled span.
|
||||
coins_one: 'coin',
|
||||
coins_other: 'coins',
|
||||
types_one: 'type',
|
||||
types_other: 'types',
|
||||
},
|
||||
album: {
|
||||
kicker: 'the album',
|
||||
hint: 'scroll or drag to turn the pages',
|
||||
noWebgl: 'the album needs a WebGL-capable browser to render the binder.',
|
||||
empty: 'no pieces to bind yet',
|
||||
},
|
||||
piece: { viewOnNumista: 'view on numista' },
|
||||
filters: {
|
||||
title: 'filters',
|
||||
toggle: 'Toggle filters',
|
||||
close: 'Close filters',
|
||||
clearAll: 'clear all',
|
||||
clear: 'clear',
|
||||
any: 'Any',
|
||||
selected_one: '{{count}} selected',
|
||||
selected_other: '{{count}} selected',
|
||||
allCountries: 'All countries',
|
||||
allCurrencies: 'All currencies',
|
||||
allShapes: 'All shapes',
|
||||
section: {
|
||||
country: 'Country',
|
||||
currency: 'Currency',
|
||||
faceValue: 'Face value',
|
||||
type: 'Type',
|
||||
edition: 'Edition',
|
||||
shape: 'Shape',
|
||||
years: 'Years',
|
||||
},
|
||||
category: { coin: 'Coins', banknote: 'Banknotes', exonumia: 'Exonumia' },
|
||||
edition: { standard: 'Standard', special: 'Special' },
|
||||
shape: {
|
||||
round: 'Round',
|
||||
polygon: 'Polygon',
|
||||
'holed-round': 'Holed round',
|
||||
'holed-polygon': 'Holed polygon',
|
||||
'notched-round': 'Flower',
|
||||
'sine-round': 'Wavy',
|
||||
rectangular: 'Rectangular',
|
||||
irregular: 'Irregular',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Source of truth for country names (not the DB), keyed by issuer code. A new issuer needs an entry
|
||||
// here and in every other locale.
|
||||
export const country = {
|
||||
afrique_du_sud: 'South Africa',
|
||||
algerie: 'Algeria',
|
||||
allemagne: 'Germany',
|
||||
ancienne_urss: 'Soviet Union',
|
||||
andorre: 'Andorra',
|
||||
argentine: 'Argentina',
|
||||
australie: 'Australia',
|
||||
autriche: 'Austria',
|
||||
bahamas: 'Bahamas',
|
||||
belgique: 'Belgium',
|
||||
bolivie: 'Bolivia',
|
||||
bresil: 'Brazil',
|
||||
bulgarie: 'Bulgaria',
|
||||
canada: 'Canada',
|
||||
'cap-vert': 'Cape Verde',
|
||||
chili: 'Chile',
|
||||
chine: 'China',
|
||||
chypre: 'Cyprus',
|
||||
colombie: 'Colombia',
|
||||
croatie: 'Croatia',
|
||||
emirats_arabes_unis: 'United Arab Emirates',
|
||||
england: 'England',
|
||||
equateur: 'Ecuador',
|
||||
espagne: 'Spain',
|
||||
estonie: 'Estonia',
|
||||
'etats-unis': 'United States',
|
||||
finlande: 'Finland',
|
||||
france: 'France',
|
||||
grece: 'Greece',
|
||||
hongrie: 'Hungary',
|
||||
irlande: 'Ireland',
|
||||
italie: 'Italy',
|
||||
japon: 'Japan',
|
||||
liban: 'Lebanon',
|
||||
lituanie: 'Lithuania',
|
||||
luxembourg: 'Luxembourg',
|
||||
malte: 'Malta',
|
||||
maroc: 'Morocco',
|
||||
mexique: 'Mexico',
|
||||
nepal: 'Nepal',
|
||||
'pays-bas': 'Netherlands',
|
||||
pologne: 'Poland',
|
||||
portugal: 'Portugal',
|
||||
republique_dominicaine: 'Dominican Republic',
|
||||
roumanie: 'Romania',
|
||||
'royaume-uni': 'United Kingdom',
|
||||
russie: 'Russia',
|
||||
singapour: 'Singapore',
|
||||
slovaquie: 'Slovakia',
|
||||
slovenie: 'Slovenia',
|
||||
suede: 'Sweden',
|
||||
suisse: 'Switzerland',
|
||||
thailande: 'Thailand',
|
||||
tunisie: 'Tunisia',
|
||||
turquie: 'Turkey',
|
||||
};
|
||||
@@ -0,0 +1,134 @@
|
||||
export const common = {
|
||||
app: { title: 'Moedas' },
|
||||
nav: {
|
||||
home: 'início',
|
||||
theAlbum: 'o álbum',
|
||||
album: 'álbum',
|
||||
pile: 'pilha',
|
||||
showcase: 'vitrina',
|
||||
},
|
||||
loading: 'a carregar…',
|
||||
home: {
|
||||
tagline: 'uma coleção pessoal',
|
||||
empty: 'Ainda não há moedas. Experimenta',
|
||||
coins_one: '{{count}} moeda',
|
||||
coins_other: '{{count}} moedas',
|
||||
banknotes_one: '{{count}} nota',
|
||||
banknotes_other: '{{count}} notas',
|
||||
unique: '({{count}} únicas)',
|
||||
},
|
||||
showcase: {
|
||||
empty: 'nenhuma peça corresponde',
|
||||
prev: 'Moeda anterior',
|
||||
next: 'Moeda seguinte',
|
||||
pauseSpin: 'Parar rotação',
|
||||
resumeSpin: 'Retomar rotação',
|
||||
controls: '← → percorrer · espaço alterna rotação ·',
|
||||
},
|
||||
pile: {
|
||||
empty: 'nenhuma moeda corresponde',
|
||||
coins_one: 'moeda',
|
||||
coins_other: 'moedas',
|
||||
types_one: 'tipo',
|
||||
types_other: 'tipos',
|
||||
},
|
||||
album: {
|
||||
kicker: 'o álbum',
|
||||
hint: 'desliza ou arrasta para virar as páginas',
|
||||
noWebgl: 'o álbum precisa de um navegador com WebGL para mostrar o álbum.',
|
||||
empty: 'ainda não há peças para por no álbum',
|
||||
},
|
||||
piece: { viewOnNumista: 'ver no numista' },
|
||||
filters: {
|
||||
title: 'filtros',
|
||||
toggle: 'Alternar filtros',
|
||||
close: 'Fechar filtros',
|
||||
clearAll: 'limpar tudo',
|
||||
clear: 'limpar',
|
||||
any: 'Qualquer',
|
||||
selected_one: '{{count}} selecionado',
|
||||
selected_other: '{{count}} selecionados',
|
||||
allCountries: 'Todos os países',
|
||||
allCurrencies: 'Todas as moedas',
|
||||
allShapes: 'Todas as formas',
|
||||
section: {
|
||||
country: 'País',
|
||||
currency: 'Moeda',
|
||||
faceValue: 'Valor facial',
|
||||
type: 'Tipo',
|
||||
edition: 'Edição',
|
||||
shape: 'Forma',
|
||||
years: 'Anos',
|
||||
},
|
||||
category: { coin: 'Moedas', banknote: 'Notas', exonumia: 'Exonumismática' },
|
||||
edition: { standard: 'Padrão', special: 'Especial' },
|
||||
shape: {
|
||||
round: 'Redonda',
|
||||
polygon: 'Polígono',
|
||||
'holed-round': 'Redonda furada',
|
||||
'holed-polygon': 'Polígono furado',
|
||||
'notched-round': 'Flor',
|
||||
'sine-round': 'Ondulada',
|
||||
rectangular: 'Retangular',
|
||||
irregular: 'Irregular',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const country = {
|
||||
afrique_du_sud: 'África do Sul',
|
||||
algerie: 'Argélia',
|
||||
allemagne: 'Alemanha',
|
||||
ancienne_urss: 'União Soviética',
|
||||
andorre: 'Andorra',
|
||||
argentine: 'Argentina',
|
||||
australie: 'Austrália',
|
||||
autriche: 'Áustria',
|
||||
bahamas: 'Baamas',
|
||||
belgique: 'Bélgica',
|
||||
bolivie: 'Bolívia',
|
||||
bresil: 'Brasil',
|
||||
bulgarie: 'Bulgária',
|
||||
canada: 'Canadá',
|
||||
'cap-vert': 'Cabo Verde',
|
||||
chili: 'Chile',
|
||||
chine: 'China',
|
||||
chypre: 'Chipre',
|
||||
colombie: 'Colômbia',
|
||||
croatie: 'Croácia',
|
||||
emirats_arabes_unis: 'Emirados Árabes Unidos',
|
||||
england: 'Inglaterra',
|
||||
equateur: 'Equador',
|
||||
espagne: 'Espanha',
|
||||
estonie: 'Estónia',
|
||||
'etats-unis': 'Estados Unidos',
|
||||
finlande: 'Finlândia',
|
||||
france: 'França',
|
||||
grece: 'Grécia',
|
||||
hongrie: 'Hungria',
|
||||
irlande: 'Irlanda',
|
||||
italie: 'Itália',
|
||||
japon: 'Japão',
|
||||
liban: 'Líbano',
|
||||
lituanie: 'Lituânia',
|
||||
luxembourg: 'Luxemburgo',
|
||||
malte: 'Malta',
|
||||
maroc: 'Marrocos',
|
||||
mexique: 'México',
|
||||
nepal: 'Nepal',
|
||||
'pays-bas': 'Países Baixos',
|
||||
pologne: 'Polónia',
|
||||
portugal: 'Portugal',
|
||||
republique_dominicaine: 'República Dominicana',
|
||||
roumanie: 'Roménia',
|
||||
'royaume-uni': 'Reino Unido',
|
||||
russie: 'Rússia',
|
||||
singapour: 'Singapura',
|
||||
slovaquie: 'Eslováquia',
|
||||
slovenie: 'Eslovénia',
|
||||
suede: 'Suécia',
|
||||
suisse: 'Suíça',
|
||||
thailande: 'Tailândia',
|
||||
tunisie: 'Tunísia',
|
||||
turquie: 'Turquia',
|
||||
};
|
||||
@@ -60,8 +60,6 @@ async function ingest(id: number, count: number) {
|
||||
|
||||
const initialCoin = normalize(raw, { count, colorHex, issuerCode: issuer.code });
|
||||
let coin = initialCoin;
|
||||
// Banknotes are always rectangular and have nothing to prompt about; only coin / exonumia rows go
|
||||
// through the shape resolver.
|
||||
if (initialCoin.category !== 'banknote') {
|
||||
const { variant, paramsJson } = await resolveShape(
|
||||
initialCoin.shape_variant,
|
||||
@@ -72,7 +70,7 @@ async function ingest(id: number, count: number) {
|
||||
}
|
||||
|
||||
upsert(coin);
|
||||
console.log(`[${id}] done: ${issuer.name} / ${coin.title} (count=${coin.count})`);
|
||||
console.log(`[${id}] done: ${raw.issuer.name} / ${coin.title} (count=${coin.count})`);
|
||||
}
|
||||
|
||||
export async function add(id: number, count: number) {
|
||||
|
||||
@@ -65,7 +65,8 @@ export async function ensureIssuer(code: string, name: string): Promise<Issuer>
|
||||
const existing = db.query<Issuer, [string]>('SELECT * FROM issuers WHERE code = ?').get(code);
|
||||
if (existing) return existing;
|
||||
|
||||
console.log(`[issuer] new: ${code} (${name})`);
|
||||
// A new issuer needs a `country` entry in every src/i18n/locales/* file; names live there, not the DB.
|
||||
console.log(`[issuer] new: ${code} (${name}) — add "${code}" to the i18n country namespace`);
|
||||
|
||||
const all = await getNumistaIssuers();
|
||||
const wikidataId = all.find((i) => i.code === code)?.wikidata_id ?? null;
|
||||
@@ -77,9 +78,8 @@ export async function ensureIssuer(code: string, name: string): Promise<Issuer>
|
||||
);
|
||||
}
|
||||
|
||||
db.query('INSERT INTO issuers (code, name, wikidata_id, flag_path) VALUES (?, ?, ?, ?)').run(
|
||||
db.query('INSERT INTO issuers (code, wikidata_id, flag_path) VALUES (?, ?, ?)').run(
|
||||
code,
|
||||
name,
|
||||
wikidataId,
|
||||
flagPath,
|
||||
);
|
||||
|
||||
+12
-1
@@ -1,9 +1,11 @@
|
||||
import { NuqsAdapter } from 'nuqs/adapters/react';
|
||||
import { StrictMode, Suspense } from 'react';
|
||||
import { StrictMode, Suspense, useEffect } from 'react';
|
||||
import { RouterProvider } from 'react-aria-components';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Route, Switch, useLocation } from 'wouter';
|
||||
import { Loading } from '@/components/loading';
|
||||
import i18n from '@/i18n';
|
||||
import { Album } from '@/pages/album';
|
||||
import { Home } from '@/pages/home';
|
||||
import { Pile } from '@/pages/pile';
|
||||
@@ -13,6 +15,15 @@ import '@/styles.css';
|
||||
|
||||
function App() {
|
||||
const [, navigate] = useLocation();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Reflect the locale onto the document so the tab title and <html lang> match the rendered
|
||||
// language.
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = i18n.resolvedLanguage ?? 'en';
|
||||
document.title = t('app.title');
|
||||
}, [t]);
|
||||
|
||||
return (
|
||||
<RouterProvider navigate={navigate}>
|
||||
<NuqsAdapter>
|
||||
|
||||
+16
-16
@@ -1,16 +1,13 @@
|
||||
import { Canvas } from '@react-three/fiber';
|
||||
import { Suspense, use, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { buildBook, type GridSpec, maxDiameterMm, maxThicknessMm } from '@/album/book';
|
||||
import { fetchPieces } from '@/api';
|
||||
import { Binder, type Orientation } from '@/components/album/binder';
|
||||
|
||||
// Landscape spreads two stacks along the (wide) flip axis and turns right-to-left; portrait (taller
|
||||
// than wide) stacks them vertically and turns bottom-to-top. The grid is just a prop on the binder
|
||||
// model (plan §6) — changing it re-derives the book and renderer.
|
||||
//
|
||||
// The grid is fitted to the viewport rather than hardcoded: `cols` runs along the flip axis, which
|
||||
// shows ~2 stacks, so it maps to the screen's LONG side ÷ 2; `rows` runs along the spine, mapping to
|
||||
// the SHORT side. CELL is the rough on-screen size we want a pocket to occupy. Approximate by design.
|
||||
// The grid is fitted to the viewport: `cols` runs along the flip axis, which shows ~2 stacks, so it
|
||||
// maps to the screen's long side ÷ 2; `rows` runs along the spine, mapping to the short side. CELL is
|
||||
// the rough on-screen size a pocket should occupy. Approximate by design.
|
||||
const CELL_PX = 200;
|
||||
|
||||
function clampInt(n: number, lo: number, hi: number): number {
|
||||
@@ -38,8 +35,7 @@ function useViewport(): Viewport {
|
||||
const onResize = () =>
|
||||
setVp((prev) => {
|
||||
const next = readViewport();
|
||||
// Only re-render (and rebuild the book) when the grid or orientation actually changes — the
|
||||
// rounding makes that happen at thresholds, not on every resize pixel.
|
||||
// Rebuild only when the rounded grid or orientation changes, not on every resize pixel.
|
||||
return next.grid.cols === prev.grid.cols &&
|
||||
next.grid.rows === prev.grid.rows &&
|
||||
next.orientation === prev.orientation
|
||||
@@ -72,7 +68,7 @@ function hasWebGL(): boolean {
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
const gl = canvas.getContext('webgl2') ?? canvas.getContext('webgl');
|
||||
// Release the probe context immediately — browsers cap live WebGL contexts.
|
||||
// Release the probe context immediately: browsers cap live WebGL contexts.
|
||||
gl?.getExtension('WEBGL_lose_context')?.loseContext();
|
||||
return !!gl;
|
||||
} catch {
|
||||
@@ -81,14 +77,15 @@ function hasWebGL(): boolean {
|
||||
}
|
||||
|
||||
function Overlay({ country }: { country: string }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<div className="absolute top-6 left-6 pointer-events-none select-none">
|
||||
<p className="caps text-mini text-ink-dim">the album</p>
|
||||
<p className="caps text-mini text-ink-dim">{t('album.kicker')}</p>
|
||||
<h1 className="text-2xl text-ink mt-1">{country || '—'}</h1>
|
||||
</div>
|
||||
<div className="absolute bottom-5 left-1/2 -translate-x-1/2 caps text-mini text-ink-faint select-none whitespace-nowrap pointer-events-none">
|
||||
scroll or drag to turn the pages
|
||||
{t('album.hint')}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
@@ -98,11 +95,14 @@ export function Album() {
|
||||
const { grid, orientation } = useViewport();
|
||||
const reducedMotion = usePrefersReducedMotion();
|
||||
const webgl = useMemo(hasWebGL, []);
|
||||
const { t } = useTranslation();
|
||||
|
||||
// The album's only filter: banknotes excluded at the server (plan §4). Never filtered client-side.
|
||||
const { pieces } = use(fetchPieces({ categories: ['coin', 'exonumia'] }));
|
||||
|
||||
const book = useMemo(() => buildBook(pieces, grid), [pieces, grid]);
|
||||
const book = useMemo(
|
||||
() => buildBook(pieces, grid, (code) => t(code, { ns: 'country', defaultValue: code })),
|
||||
[pieces, grid, t],
|
||||
);
|
||||
const maxDiameter = useMemo(() => maxDiameterMm(pieces), [pieces]);
|
||||
const maxThickness = useMemo(() => maxThicknessMm(pieces), [pieces]);
|
||||
|
||||
@@ -112,7 +112,7 @@ export function Album() {
|
||||
if (!webgl) {
|
||||
return (
|
||||
<div className="h-svh flex items-center justify-center bg-page text-ink-dim caps text-mini italic px-8 text-center">
|
||||
the album needs a WebGL-capable browser to render the binder.
|
||||
{t('album.noWebgl')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -120,7 +120,7 @@ export function Album() {
|
||||
if (book.leaves.length === 0) {
|
||||
return (
|
||||
<div className="h-svh flex items-center justify-center bg-page text-ink-dim caps text-mini italic">
|
||||
no pieces to bind yet
|
||||
{t('album.empty')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+35
-18
@@ -1,13 +1,9 @@
|
||||
import { use } from 'react';
|
||||
import { use, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'wouter';
|
||||
import { fetchCountryCounts, fetchPieces } from '@/api';
|
||||
import { Hail } from '@/components/hail';
|
||||
|
||||
function formatCountLine(noun: string, count: number, sum: number): string | null {
|
||||
if (count === 0) return null;
|
||||
const plural = sum === 1 ? noun : `${noun}s`;
|
||||
return count === sum ? `${sum} ${plural}` : `${sum} ${plural} (${count} unique)`;
|
||||
}
|
||||
import { useCountryName } from '@/i18n';
|
||||
|
||||
export function Home() {
|
||||
// Kick off both fetches in parallel before the first use() can suspend. Hail renders <Coin>
|
||||
@@ -18,24 +14,45 @@ export function Home() {
|
||||
const countries = use(countsPromise);
|
||||
const { pieces: hailPool } = use(piecesPromise);
|
||||
|
||||
const { t } = useTranslation();
|
||||
const countryName = useCountryName();
|
||||
|
||||
// The server groups by issuer code; present the list alphabetically by localized country name.
|
||||
const sorted = useMemo(
|
||||
() => [...countries].sort((a, b) => countryName(a.code).localeCompare(countryName(b.code))),
|
||||
[countries, countryName],
|
||||
);
|
||||
|
||||
// "{total} coins" plus "({distinct} unique)" when some pieces are duplicates. `key` is a literal
|
||||
// so the typed catalog can check it.
|
||||
const countLine = (
|
||||
key: 'home.coins' | 'home.banknotes',
|
||||
distinct: number,
|
||||
total: number,
|
||||
): string | null => {
|
||||
if (distinct === 0) return null;
|
||||
const base = t(key, { count: total });
|
||||
return distinct === total ? base : `${base} ${t('home.unique', { count: distinct })}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative min-h-svh">
|
||||
<Hail pool={hailPool} />
|
||||
|
||||
<main className="relative z-10 max-w-2xl mx-auto px-6 py-20">
|
||||
<header className="text-center mb-16">
|
||||
<h1 className="text-6xl text-ink leading-none">Coins</h1>
|
||||
<p className="caps text-mini text-ink-dim mt-4">a personal collection</p>
|
||||
<h1 className="text-6xl text-ink leading-none">{t('app.title')}</h1>
|
||||
<p className="caps text-mini text-ink-dim mt-4">{t('home.tagline')}</p>
|
||||
<nav className="caps text-mini mt-5 flex justify-center">
|
||||
<Link href="/album" className="text-brass hover:text-ink transition-colors">
|
||||
the album
|
||||
{t('nav.theAlbum')}
|
||||
</Link>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{countries.length === 0 ? (
|
||||
{sorted.length === 0 ? (
|
||||
<p className="text-center text-ink-dim text-data italic">
|
||||
No coins ingested yet. Try{' '}
|
||||
{t('home.empty')}{' '}
|
||||
<code className="font-mono not-italic text-ink text-mini px-1.5 py-0.5 bg-card border border-rule">
|
||||
bun run ingest add <id>
|
||||
</code>
|
||||
@@ -43,7 +60,7 @@ export function Home() {
|
||||
</p>
|
||||
) : (
|
||||
<ul className="border-t border-rule">
|
||||
{countries.map((c) => (
|
||||
{sorted.map((c) => (
|
||||
<li key={c.code} className="group flex items-center gap-4 py-4 border-b border-rule">
|
||||
{c.flag_path ? (
|
||||
<img
|
||||
@@ -55,11 +72,11 @@ export function Home() {
|
||||
<div className="w-10 h-7 bg-card flex-shrink-0" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="caps text-data text-ink truncate">{c.name}</h2>
|
||||
<h2 className="caps text-data text-ink truncate">{countryName(c.code)}</h2>
|
||||
<p className="text-mini text-ink-faint tnum mt-1">
|
||||
{[
|
||||
formatCountLine('coin', c.coin_count, c.coin_sum),
|
||||
formatCountLine('banknote', c.banknote_count, c.banknote_sum),
|
||||
countLine('home.coins', c.coin_count, c.coin_sum),
|
||||
countLine('home.banknotes', c.banknote_count, c.banknote_sum),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
@@ -70,13 +87,13 @@ export function Home() {
|
||||
href={`/showcase?countries=${encodeURIComponent(c.code)}`}
|
||||
className="text-ink-dim hover:text-brass transition-colors"
|
||||
>
|
||||
showcase
|
||||
{t('nav.showcase')}
|
||||
</Link>
|
||||
<Link
|
||||
href={`/pile?countries=${encodeURIComponent(c.code)}`}
|
||||
className="text-ink-faint hover:text-brass transition-colors"
|
||||
>
|
||||
pile
|
||||
{t('nav.pile')}
|
||||
</Link>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
+25
-10
@@ -1,19 +1,22 @@
|
||||
import { Canvas } from '@react-three/fiber';
|
||||
import { use, useEffect, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { fetchPieces } from '@/api';
|
||||
import { FilterSidebar } from '@/components/filters/sidebar';
|
||||
import { useFilters } from '@/components/filters/state';
|
||||
import { PileScene } from '@/components/pile/scene';
|
||||
import { CATEGORIES, type Category } from '@/filter-types';
|
||||
import { useCountryName } from '@/i18n';
|
||||
|
||||
const PILE_EXCLUDED_CATEGORIES: readonly Category[] = ['banknote'];
|
||||
|
||||
export function Pile() {
|
||||
const { filters, state, setState } = useFilters();
|
||||
const { t } = useTranslation();
|
||||
const countryName = useCountryName();
|
||||
|
||||
// Pile never shows banknotes. Mirror that into the URL so the disabled checkbox in the sidebar
|
||||
// matches the actual filter state — and so a cross-page nav back to /showcase carries no surprise
|
||||
// inclusions either.
|
||||
// Mirror the banknote exclusion into the URL so the disabled sidebar checkbox matches filter
|
||||
// state and a nav to /showcase carries no surprise inclusions.
|
||||
useEffect(() => {
|
||||
const base = state.categories.length === 0 ? CATEGORIES : state.categories;
|
||||
const next = base.filter((c) => !PILE_EXCLUDED_CATEGORIES.includes(c));
|
||||
@@ -24,9 +27,8 @@ export function Pile() {
|
||||
}
|
||||
}, [state.categories, setState]);
|
||||
|
||||
// Strip banknote from the filter passed to fetch synchronously, so the very first render after a
|
||||
// URL with categories=banknote doesn't flash banknotes into the pile before the effect-driven URL
|
||||
// sync lands.
|
||||
// Strip banknote synchronously so the first render after a categories=banknote URL doesn't flash
|
||||
// banknotes into the pile before the effect-driven URL sync lands.
|
||||
const effectiveFilters = useMemo(() => {
|
||||
if (!filters.categories) {
|
||||
return { ...filters, categories: CATEGORIES.filter((c) => c !== 'banknote') };
|
||||
@@ -37,7 +39,19 @@ export function Pile() {
|
||||
};
|
||||
}, [filters]);
|
||||
|
||||
const { pieces: coins } = use(fetchPieces(effectiveFilters));
|
||||
const { pieces: unsorted } = use(fetchPieces(effectiveFilters));
|
||||
|
||||
// Re-sort by localized country name to match the other listings; stable sort preserves the
|
||||
// server's within-country order.
|
||||
const coins = useMemo(
|
||||
() =>
|
||||
[...unsorted].sort(
|
||||
(a, b) =>
|
||||
countryName(a.issuer_code).localeCompare(countryName(b.issuer_code)) ||
|
||||
a.issuer_code.localeCompare(b.issuer_code),
|
||||
),
|
||||
[unsorted, countryName],
|
||||
);
|
||||
|
||||
const total = useMemo(() => coins.reduce((sum, c) => sum + c.count, 0), [coins]);
|
||||
|
||||
@@ -49,14 +63,15 @@ export function Pile() {
|
||||
|
||||
{coins.length === 0 ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<p className="caps text-mini text-ink-dim italic">no coins match</p>
|
||||
<p className="caps text-mini text-ink-dim italic">{t('pile.empty')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="absolute top-16 sm:top-6 left-6 pointer-events-none select-none">
|
||||
<p className="caps text-mini text-ink-dim tnum">
|
||||
<span className="text-ink">{total}</span> coin{total === 1 ? '' : 's'}
|
||||
<span className="text-ink">{total}</span> {t('pile.coins', { count: total })}
|
||||
<span className="text-ink-faint"> · </span>
|
||||
<span className="text-ink">{coins.length}</span> type{coins.length === 1 ? '' : 's'}
|
||||
<span className="text-ink">{coins.length}</span>{' '}
|
||||
{t('pile.types', { count: coins.length })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
+26
-7
@@ -2,6 +2,7 @@ import { OrbitControls, PerspectiveCamera } from '@react-three/drei';
|
||||
import { Canvas } from '@react-three/fiber';
|
||||
import { parseAsBoolean, parseAsInteger, useQueryState } from 'nuqs';
|
||||
import { use, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RectAreaLightUniformsLib } from 'three/addons/lights/RectAreaLightUniformsLib.js';
|
||||
import { fetchPieces } from '@/api';
|
||||
import { FilterSidebar } from '@/components/filters/sidebar';
|
||||
@@ -10,6 +11,7 @@ import { InfoPanel } from '@/components/showcase/info-panel';
|
||||
import { LightRig, StudioLight } from '@/components/showcase/lights';
|
||||
import { NavButton } from '@/components/showcase/nav-button';
|
||||
import { PieceStage } from '@/components/showcase/piece-stage';
|
||||
import { useCountryName } from '@/i18n';
|
||||
|
||||
RectAreaLightUniformsLib.init();
|
||||
|
||||
@@ -17,9 +19,23 @@ export function Showcase() {
|
||||
const [id, setId] = useQueryState('id', parseAsInteger);
|
||||
const [spin, setSpin] = useQueryState('spin', parseAsBoolean.withDefault(true));
|
||||
const { filters } = useFilters();
|
||||
const { t } = useTranslation();
|
||||
const countryName = useCountryName();
|
||||
const transitioningRef = useRef(false);
|
||||
|
||||
const { pieces } = use(fetchPieces(filters));
|
||||
const { pieces: unsorted } = use(fetchPieces(filters));
|
||||
|
||||
// Re-sort by localized country name to match the other listings; stable sort preserves the
|
||||
// server's within-country order.
|
||||
const pieces = useMemo(
|
||||
() =>
|
||||
[...unsorted].sort(
|
||||
(a, b) =>
|
||||
countryName(a.issuer_code).localeCompare(countryName(b.issuer_code)) ||
|
||||
a.issuer_code.localeCompare(b.issuer_code),
|
||||
),
|
||||
[unsorted, countryName],
|
||||
);
|
||||
|
||||
const index = useMemo(() => {
|
||||
if (id === null) return 0;
|
||||
@@ -65,7 +81,7 @@ export function Showcase() {
|
||||
return (
|
||||
<div className="h-svh bg-page text-ink relative overflow-hidden">
|
||||
<Canvas>
|
||||
{/* ~100mm full-frame lens equivalent: flat perspective, good for product shots. */}
|
||||
{/* fov 14 ≈ 100mm full-frame lens: flat perspective for product shots. */}
|
||||
<PerspectiveCamera makeDefault position={[0, 0, 0.178]} fov={14} near={0.01} far={1} />
|
||||
<LightRig>
|
||||
<StudioLight
|
||||
@@ -103,26 +119,29 @@ export function Showcase() {
|
||||
|
||||
{!current ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<p className="caps text-mini text-ink-dim italic">no pieces match</p>
|
||||
<p className="caps text-mini text-ink-dim italic">{t('showcase.empty')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<InfoPanel piece={current} />
|
||||
|
||||
<div className="absolute bottom-14 left-1/2 -translate-x-1/2 flex gap-2">
|
||||
<NavButton onPress={navPrev} label="Previous coin">
|
||||
<NavButton onPress={navPrev} label={t('showcase.prev')}>
|
||||
←
|
||||
</NavButton>
|
||||
<NavButton onPress={toggleSpin} label={spin ? 'Pause spinning' : 'Resume spinning'}>
|
||||
<NavButton
|
||||
onPress={toggleSpin}
|
||||
label={spin ? t('showcase.pauseSpin') : t('showcase.resumeSpin')}
|
||||
>
|
||||
{spin ? '⏸' : '▶'}
|
||||
</NavButton>
|
||||
<NavButton onPress={navNext} label="Next coin">
|
||||
<NavButton onPress={navNext} label={t('showcase.next')}>
|
||||
→
|
||||
</NavButton>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-5 left-1/2 -translate-x-1/2 caps text-mini text-ink-faint tnum select-none whitespace-nowrap">
|
||||
← → cycle · space toggle spin · <span className="text-ink-dim">{index + 1}</span> /{' '}
|
||||
{t('showcase.controls')} <span className="text-ink-dim">{index + 1}</span> /{' '}
|
||||
{pieces.length}
|
||||
</div>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user