This commit is contained in:
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -4,6 +4,7 @@ export const common = {
|
||||
home: 'home',
|
||||
theAlbum: 'the album',
|
||||
album: 'album',
|
||||
back: 'back',
|
||||
pile: 'pile',
|
||||
showcase: 'showcase',
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ export const common = {
|
||||
home: 'início',
|
||||
theAlbum: 'o álbum',
|
||||
album: 'álbum',
|
||||
back: 'voltar',
|
||||
pile: 'pilha',
|
||||
showcase: 'vitrina',
|
||||
},
|
||||
|
||||
+4
-1
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user