Add i18n
Build and Deploy / build-deploy (push) Successful in 1m19s

This commit is contained in:
2026-06-22 23:16:54 +01:00
parent 9007adde61
commit 9964cf1262
29 changed files with 692 additions and 267 deletions
+13
View File
@@ -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=="],
+3
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
+16 -21
View File
@@ -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 [xBLspineWidthW .. xBLspineWidth]
@@ -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]}>
+5 -4
View File
@@ -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>
))}
-23
View File
@@ -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',
};
+8 -8
View File
@@ -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}`}
+47 -38
View File
@@ -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]}
+3 -1
View File
@@ -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>
+4 -1
View File
@@ -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>
);
}
+6 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 }[];
+49
View File
@@ -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;
};
}
}
+137
View File
@@ -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',
};
+134
View File
@@ -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',
};
+1 -3
View File
@@ -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) {
+3 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 &lt;id&gt;
</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
View File
@@ -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
View File
@@ -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>
</>