Link album coins to showcase
Build and Deploy / build-deploy (push) Successful in 35s

This commit is contained in:
2026-06-23 16:45:06 +01:00
parent 9964cf1262
commit df32ba18e0
9 changed files with 109 additions and 9 deletions
+11 -1
View File
@@ -11,8 +11,11 @@ const SETTLE_EPS = 0.0008;
export type BinderInput = {
positionRef: React.RefObject<number>;
center: number; // round(position); gates the render window
draggedRef: React.RefObject<boolean>; // reset on pointerdown, so a pointerup tap handler reads the gesture just ended
};
const DRAG_THRESHOLD_PX = 5;
function clamp(v: number, min: number, max: number): number {
return Math.max(min, Math.min(max, v));
}
@@ -32,6 +35,8 @@ export function useBinderInput(
const positionRef = useRef(start);
const targetRef = useRef(start);
const draggingRef = useRef(false);
const draggedRef = useRef(false);
const downXYRef = useRef({ x: 0, y: 0 });
const lastXRef = useRef(0);
const centerRef = useRef(start);
const [center, setCenter] = useState(start);
@@ -53,6 +58,8 @@ export function useBinderInput(
const onPointerDown = (e: PointerEvent) => {
draggingRef.current = true;
draggedRef.current = false;
downXYRef.current = { x: e.clientX, y: e.clientY };
lastXRef.current = coordOf(e);
el.setPointerCapture(e.pointerId);
};
@@ -62,6 +69,9 @@ export function useBinderInput(
const c = coordOf(e);
const d = c - lastXRef.current;
lastXRef.current = c;
// 2D travel from the press point, so a long perpendicular swipe still counts as a drag, not a tap.
const { x, y } = downXYRef.current;
if (Math.hypot(e.clientX - x, e.clientY - y) > DRAG_THRESHOLD_PX) draggedRef.current = true;
// Roughly a full turn per half-extent swipe.
const extent = dragAxis === 'y' ? el.clientHeight : el.clientWidth;
const sens = 1.8 / Math.max(320, extent);
@@ -125,5 +135,5 @@ export function useBinderInput(
}
});
return { positionRef, center };
return { positionRef, center, draggedRef };
}
+13 -2
View File
@@ -1,6 +1,6 @@
import { PerspectiveCamera } from '@react-three/drei';
import { useThree } from '@react-three/fiber';
import { memo, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
import * as THREE from 'three';
import { type Book, countryAtLeaf } from '@/album/book';
import { type BinderMetrics, COIN_WINDOW, computeMetrics } from '@/album/constants';
@@ -10,6 +10,7 @@ import { BinderShell } from '@/components/album/hardware';
import { Leaf, type Materials } from '@/components/album/leaf';
import { buildLeafGeometry, disposeLeafGeometry } from '@/components/album/leaf-geometry';
import { AlbumLighting } from '@/components/album/lighting';
import type { PieceWithIssuer } from '@/db/types';
export type Orientation = 'portrait' | 'landscape';
@@ -85,6 +86,7 @@ export const Binder = memo(function Binder({
orientation,
reducedMotion,
onActiveCountry,
onSelectPiece,
}: {
book: Book;
maxDiameterMm: number;
@@ -92,6 +94,7 @@ export const Binder = memo(function Binder({
orientation: Orientation;
reducedMotion: boolean;
onActiveCountry: (name: string) => void;
onSelectPiece: (piece: PieceWithIssuer) => void;
}) {
const metrics = useMemo(
() => computeMetrics(book.grid, maxDiameterMm, maxThicknessMm),
@@ -116,7 +119,7 @@ export const Binder = memo(function Binder({
// 1 unless a deep link points into a country.
const totalSheets = book.leaves.length;
const initial = useMemo(() => leafFromUrl(book) ?? -1, [book]);
const { positionRef, center } = useBinderInput(
const { positionRef, center, draggedRef } = useBinderInput(
totalSheets,
reducedMotion,
-1,
@@ -124,6 +127,13 @@ export const Binder = memo(function Binder({
orientation === 'portrait' ? 'y' : 'x',
);
const onSelect = useCallback(
(piece: PieceWithIssuer) => {
if (!draggedRef.current) onSelectPiece(piece);
},
[draggedRef, onSelectPiece],
);
useEffect(() => {
onActiveCountry(countryAtLeaf(book.countries, center)?.name ?? book.countries[0]?.name ?? '');
syncUrl(book, center);
@@ -153,6 +163,7 @@ export const Binder = memo(function Binder({
mountCoins={Math.abs(s - center) <= COIN_WINDOW}
reducedMotion={reducedMotion}
contentSpin={contentSpin}
onSelect={onSelect}
/>,
);
}
+39 -3
View File
@@ -1,5 +1,5 @@
import { useFrame } from '@react-three/fiber';
import { type RefObject, Suspense, useMemo, useRef } from 'react';
import { type RefObject, Suspense, useEffect, useMemo, useRef } from 'react';
import * as THREE from 'three';
import type { CoinPocket, LabelPocket, Leaf as LeafModel } from '@/album/book';
import type { BinderMetrics } from '@/album/constants';
@@ -20,14 +20,44 @@ function PocketCoin({
x,
y,
spin,
onSelect,
}: {
entry: CoinPocket;
x: number;
y: number;
spin: number;
onSelect: (piece: CoinPocket['piece']) => void;
}) {
// Coins unmount as the binder scrolls; if one unmounts mid-hover, no pointerout fires, so clear
// the cursor on teardown — but only when this coin is the one actually hovered.
const hoveredRef = useRef(false);
useEffect(
() => () => {
if (hoveredRef.current) document.body.style.cursor = '';
},
[],
);
return (
<group position={[x, y, 0]} rotation={[0, 0, spin]}>
<group
position={[x, y, 0]}
rotation={[0, 0, spin]}
onPointerUp={(e) => {
if (e.button !== 0) return;
e.stopPropagation();
onSelect(entry.piece);
}}
onPointerOver={(e) => {
e.stopPropagation();
hoveredRef.current = true;
document.body.style.cursor = 'pointer';
}}
onPointerOut={(e) => {
e.stopPropagation();
hoveredRef.current = false;
document.body.style.cursor = '';
}}
>
<group rotation={[Math.PI / 2, 0, 0]}>
<Suspense fallback={null}>
<Coin coin={entry.piece} atlas={256} />
@@ -92,6 +122,7 @@ function PanelContent({
leaf,
mountCoins,
contentSpin,
onSelect,
}: {
panelIndex: number;
geometry: LeafGeometrySet;
@@ -100,6 +131,7 @@ function PanelContent({
leaf: LeafModel;
mountCoins: boolean;
contentSpin: number;
onSelect: (piece: CoinPocket['piece']) => void;
}) {
const panelGeo = geometry.panels[panelIndex];
const cols = metrics.grid.cols;
@@ -135,6 +167,7 @@ function PanelContent({
x={pocketLocalX}
y={cy}
spin={contentSpin}
onSelect={onSelect}
/>
);
})}
@@ -155,6 +188,7 @@ export function Leaf({
mountCoins,
reducedMotion,
contentSpin,
onSelect,
}: {
leaf: LeafModel;
sheetIndex: number; // = leaf index; covers are static
@@ -167,6 +201,7 @@ export function Leaf({
mountCoins: boolean;
reducedMotion: boolean;
contentSpin: number;
onSelect: (piece: CoinPocket['piece']) => void;
}) {
const cols = metrics.grid.cols;
const rootRef = useRef<THREE.Group>(null);
@@ -204,6 +239,7 @@ export function Leaf({
leaf={leaf}
mountCoins={mountCoins}
contentSpin={contentSpin}
onSelect={onSelect}
/>
);
if (i === 0) {
@@ -230,7 +266,7 @@ export function Leaf({
}
}
return node;
}, [cols, geometry, metrics, leaf, mountCoins, materials, contentSpin]);
}, [cols, geometry, metrics, leaf, mountCoins, materials, contentSpin, onSelect]);
return <group ref={rootRef}>{chain}</group>;
}
+10
View File
@@ -1,3 +1,4 @@
import { parseAsString, useQueryState } from 'nuqs';
import { startTransition, use, useState } from 'react';
import { Button, Link } from 'react-aria-components';
import { useTranslation } from 'react-i18next';
@@ -24,6 +25,7 @@ export function FilterSidebar({
excludedCategories?: readonly Category[];
}) {
const [isOpen, setIsOpen] = useState(false);
const [returnUrl] = useQueryState('returnUrl', parseAsString);
const { filters, setState } = useFilters();
const { facets } = use(fetchPieces(filters));
const { t } = useTranslation();
@@ -77,6 +79,14 @@ export function FilterSidebar({
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">
{returnUrl && (
<>
<Link href={returnUrl} className={navLinkClass}>
{t('nav.back')}
</Link>
<span className="text-ink-faint">·</span>
</>
)}
<Link href="/" className={navLinkClass}>
{t('nav.home')}
</Link>
+1
View File
@@ -4,6 +4,7 @@ export const common = {
home: 'home',
theAlbum: 'the album',
album: 'album',
back: 'back',
pile: 'pile',
showcase: 'showcase',
},
+1
View File
@@ -4,6 +4,7 @@ export const common = {
home: 'início',
theAlbum: 'o álbum',
album: 'álbum',
back: 'voltar',
pile: 'pilha',
showcase: 'vitrina',
},
+4 -1
View File
@@ -3,7 +3,7 @@ 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 { Redirect, Route, Switch, useLocation } from 'wouter';
import { Loading } from '@/components/loading';
import i18n from '@/i18n';
import { Album } from '@/pages/album';
@@ -34,6 +34,9 @@ function App() {
<Route path="/showcase" component={Showcase} />
<Route path="/pile" component={Pile} />
<Route path="/shape-test" component={ShapeTest} />
<Route>
<Redirect to="/" />
</Route>
</Switch>
</Suspense>
</NuqsAdapter>
+22
View File
@@ -1,9 +1,11 @@
import { Canvas } from '@react-three/fiber';
import { Suspense, use, useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useLocation } from 'wouter';
import { buildBook, type GridSpec, maxDiameterMm, maxThicknessMm } from '@/album/book';
import { fetchPieces } from '@/api';
import { Binder, type Orientation } from '@/components/album/binder';
import type { PieceWithIssuer } from '@/db/types';
// 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
@@ -84,6 +86,12 @@ function Overlay({ country }: { country: string }) {
<p className="caps text-mini text-ink-dim">{t('album.kicker')}</p>
<h1 className="text-2xl text-ink mt-1">{country || '—'}</h1>
</div>
<Link
href="/"
className="absolute top-6 right-6 caps text-mini text-ink-dim hover:text-brass transition-colors"
>
{t('nav.home')}
</Link>
<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">
{t('album.hint')}
</div>
@@ -109,6 +117,19 @@ export function Album() {
const [country, setCountry] = useState('');
const onActiveCountry = useCallback((name: string) => setCountry(name), []);
const [, navigate] = useLocation();
// The binder keeps ?country&leaf in the URL via replaceState, so the live search string is the
// return address back to this exact page.
const onSelectPiece = useCallback(
(piece: PieceWithIssuer) => {
const returnUrl = encodeURIComponent(`/album${window.location.search}`);
navigate(
`/showcase?countries=${encodeURIComponent(piece.issuer_code)}&id=${piece.id}&returnUrl=${returnUrl}`,
);
},
[navigate],
);
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">
@@ -136,6 +157,7 @@ export function Album() {
orientation={orientation}
reducedMotion={reducedMotion}
onActiveCountry={onActiveCountry}
onSelectPiece={onSelectPiece}
/>
</Suspense>
</Canvas>
+8 -2
View File
@@ -82,7 +82,7 @@ export function Home() {
.join(' · ')}
</p>
</div>
<div className="flex gap-5 caps text-mini">
<div className="flex flex-col items-end gap-1 sm:flex-row sm:items-center sm:gap-5 caps text-mini">
<Link
href={`/showcase?countries=${encodeURIComponent(c.code)}`}
className="text-ink-dim hover:text-brass transition-colors"
@@ -91,10 +91,16 @@ export function Home() {
</Link>
<Link
href={`/pile?countries=${encodeURIComponent(c.code)}`}
className="text-ink-faint hover:text-brass transition-colors"
className="text-ink-dim hover:text-brass transition-colors"
>
{t('nav.pile')}
</Link>
<Link
href={`/album?country=${encodeURIComponent(c.code)}`}
className="text-ink-dim hover:text-brass transition-colors"
>
{t('nav.album')}
</Link>
</div>
</li>
))}