Add zustand

This commit is contained in:
2023-09-19 01:55:18 +01:00
parent 6bfe6ad84b
commit 62cbca8083
17 changed files with 761 additions and 643 deletions
+3 -1
View File
@@ -16,8 +16,10 @@
"next": "13.4.19", "next": "13.4.19",
"prettier": "^3.0.3", "prettier": "^3.0.3",
"react": "18.2.0", "react": "18.2.0",
"react-div-100vh": "^0.7.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"three": "0.152.2" "three": "0.152.2",
"zustand": "^4.4.1"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "20.6.0", "@types/node": "20.6.0",
+9 -27
View File
@@ -41,33 +41,15 @@ export const BanknoteInstance = ({ obverse, reverse }: CoinProps) => {
<> <>
<boxGeometry attach="geometry" args={[width, 2, 0.005]} /> <boxGeometry attach="geometry" args={[width, 2, 0.005]} />
<meshStandardMaterial {Array.from({ length: 4 }, (_, index) => (
color={"#888"} <meshStandardMaterial
metalness={0} key={index}
roughness={0.5} color={"#888"}
attach="material-0" metalness={0}
/> roughness={0.5}
attach={"material-" + index}
<meshStandardMaterial />
color={"#888"} ))}
metalness={0}
roughness={0.5}
attach="material-1"
/>
<meshStandardMaterial
color={"#888"}
metalness={0}
roughness={0.5}
attach="material-2"
/>
<meshStandardMaterial
color={"#888"}
metalness={0}
roughness={0.5}
attach="material-3"
/>
<meshStandardMaterial <meshStandardMaterial
color={"#888"} color={"#888"}
+3
View File
@@ -0,0 +1,3 @@
.canvas {
height: 100%;
}
+2 -2
View File
@@ -5,6 +5,7 @@ import { CoinInstances } from "./coin-instances";
import { CoinProps } from "@/types/coin"; import { CoinProps } from "@/types/coin";
import { PlaneProps, Physics, usePlane } from "@react-three/cannon"; import { PlaneProps, Physics, usePlane } from "@react-three/cannon";
import { Mesh } from "three"; import { Mesh } from "three";
import styles from "./index.module.css";
type Props = { type Props = {
coins: CoinProps[]; coins: CoinProps[];
@@ -31,8 +32,7 @@ const Lightbulb = (props: PointLightProps) => (
); );
export const Pile = ({ coins }: Props) => ( export const Pile = ({ coins }: Props) => (
<Canvas camera={{ position: [0, 16, 16] }}> <Canvas camera={{ position: [0, 16, 16] }} className={styles.canvas}>
<color attach="background" args={["#111"]} />
<ambientLight /> <ambientLight />
<Lightbulb position={[0, 16, 0]} /> <Lightbulb position={[0, 16, 0]} />
+8
View File
@@ -0,0 +1,8 @@
.wrapper {
position: absolute;
inset: -25% 0 0;
}
.canvas {
height: 100%;
}
+26 -138
View File
@@ -1,173 +1,61 @@
import styles from "./index.module.css";
import { CoinProps } from "@/types/coin"; import { CoinProps } from "@/types/coin";
import { Canvas, MeshProps, useFrame } from "@react-three/fiber"; import { Canvas } from "@react-three/fiber";
import { Suspense, useCallback, useEffect, useRef, useState } from "react"; import { useEffect } from "react";
import { CoinInstance } from "@/components/coin";
import { Mesh } from "three";
import { BanknoteInstance } from "@/components/banknote";
import { InfoBox } from "./info-box"; import { InfoBox } from "./info-box";
import { OrbitControls } from "@react-three/drei"; import { OrbitControls } from "@react-three/drei";
import { OrbitControls as OrbitControlsType } from "three-stdlib"; import { Row } from "./row";
import { useShowcaseStore } from "./store";
type Props = { type Props = {
category: "coins" | "banknotes"; category: "coins" | "banknotes";
types: CoinProps[]; types: CoinProps[];
}; };
const SpinninType = ({
isSelected,
isSpinning,
category,
typeProps,
...props
}: {
typeProps: CoinProps;
isSelected?: boolean;
isSpinning?: boolean;
} & MeshProps &
Pick<Props, "category">) => {
const ref = useRef<Mesh>(null!);
const Instance = category === "banknotes" ? BanknoteInstance : CoinInstance;
useFrame((_state, delta) => {
if (!isSelected || !isSpinning) {
return;
}
if (category === "banknotes") {
ref.current.rotation.x += delta;
return;
}
switch (typeProps.orientation) {
case "medal":
ref.current.rotation.x += delta;
break;
default:
ref.current.rotation.z += delta;
break;
}
});
return (
<mesh {...props}>
<mesh
ref={ref}
{...(category !== "banknotes"
? {
scale: [
(typeProps.size ?? 16) / 16,
(typeProps.thickness ?? 2) / 16,
(typeProps.size ?? 16) / 16,
],
}
: { rotation: [Math.PI / 2, Math.PI, Math.PI / 2] })}
>
<Instance {...typeProps} />
</mesh>
</mesh>
);
};
const Row = ({
category,
types,
selectedIndex,
isSpinning,
}: Props & { selectedIndex: number; isSpinning?: boolean }) => {
const ref = useRef<Mesh>(null!);
const spacing = category === "banknotes" ? 8 : 6;
useFrame(() => {
const xPosition = Math.round(10 * ref.current.position.x) / 10;
const target = -selectedIndex * spacing;
if (xPosition > target) {
ref.current.position.x -= Math.abs(xPosition - target) / 16;
}
if (xPosition < target) {
ref.current.position.x += Math.abs(xPosition - target) / 16;
}
});
return (
<mesh ref={ref}>
{types.map(
(type, index) =>
Math.abs(index - selectedIndex) <= 1 && (
<Suspense key={type.id} fallback={null}>
<SpinninType
category={category}
isSpinning={isSpinning}
isSelected={selectedIndex === index}
typeProps={type}
position={[index * spacing, 1, 0]}
rotation={[Math.PI / 2, Math.PI / 2, 0]}
/>
</Suspense>
),
)}
</mesh>
);
};
export const Showcase = (props: Props) => { export const Showcase = (props: Props) => {
const [selectedIndex, selectIndex] = useState(0); const isSpinning = useShowcaseStore((state) => state.isSpinning);
const [isSpinning, setIsSpinning] = useState(true); const nextIndex = useShowcaseStore((state) => state.nextIndex);
const orbitControlsRef = useRef<OrbitControlsType>(null!); const previousIndex = useShowcaseStore((state) => state.previousIndex);
const toggleSpin = useCallback(() => { const init = useShowcaseStore((state) => state.init);
setIsSpinning((value) => { const toggleSpin = useShowcaseStore((state) => state.toggleSpin);
if (!value) { const setOrbitControlsRef = useShowcaseStore(
orbitControlsRef.current?.setAzimuthalAngle(0); (state) => state.setOrbitControlsRef,
orbitControlsRef.current?.setPolarAngle(Math.PI / 2); );
return true;
} useEffect(() => init(props), [init, props]);
return false;
});
}, []);
useEffect(() => { useEffect(() => {
const onKeyDown = ({ key }: KeyboardEvent) => { const onKeyDown = ({ key }: KeyboardEvent) => {
switch (key) { switch (key) {
case "ArrowLeft": case "ArrowLeft":
return selectIndex((value) => Math.max(value - 1, 0)); return previousIndex();
case "ArrowRight": case "ArrowRight":
return selectIndex((value) => return nextIndex();
Math.min(value + 1, props.types.length - 1),
);
case " ": case " ":
return toggleSpin(); return toggleSpin();
} }
}; };
document.addEventListener("keydown", onKeyDown); document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown); return () => document.removeEventListener("keydown", onKeyDown);
}, [props.types.length, toggleSpin]); }, [nextIndex, previousIndex, toggleSpin]);
return ( return (
<> <div className={styles.wrapper}>
<Canvas> <Canvas className={styles.canvas}>
<color attach="background" args={["#111"]} />
<ambientLight /> <ambientLight />
<directionalLight position={[0, 0.125, 1]} /> <directionalLight position={[0, 0.125, 1]} />
<directionalLight position={[0, 0.125, -1]} /> <directionalLight position={[0, 0.125, -1]} />
<Row {...props} selectedIndex={selectedIndex} isSpinning={isSpinning} /> <Row />
<OrbitControls <OrbitControls
ref={orbitControlsRef} ref={setOrbitControlsRef}
enableRotate={!isSpinning} enableRotate={!isSpinning}
enablePan={false} enablePan={false}
minDistance={2}
maxDistance={10}
/> />
</Canvas> </Canvas>
<InfoBox <InfoBox />
category={props.category} </div>
type={props.types[selectedIndex]}
selectIndex={selectIndex}
selectedIndex={selectedIndex}
isSpinning={isSpinning}
toggleSpin={toggleSpin}
maxIndex={props.types.length - 1}
/>
</>
); );
}; };
+40 -36
View File
@@ -1,23 +1,31 @@
import { CoinProps } from "@/types/coin"; import { useShowcaseStore } from "./store";
import { Dispatch, SetStateAction } from "react";
export const InfoBox = () => {
const category = useShowcaseStore((state) => state.category);
const isSpinning = useShowcaseStore((state) => state.isSpinning);
const getType = useShowcaseStore((state) => state.getType);
const toggleSpin = useShowcaseStore((state) => state.toggleSpin);
const currentIndex = useShowcaseStore((state) => state.currentIndex);
const nextIndex = useShowcaseStore((state) => state.nextIndex);
const previousIndex = useShowcaseStore((state) => state.previousIndex);
const total = useShowcaseStore((state) => state.types.length);
const currentType = getType();
if (!currentType) {
return;
}
const {
issuer,
title,
min_year,
max_year,
count = 1,
weight,
size,
thickness,
} = currentType;
export const InfoBox = ({
category,
type,
maxIndex,
selectIndex,
selectedIndex,
isSpinning,
toggleSpin,
}: {
category: "banknotes" | "coins";
type: CoinProps;
maxIndex: number;
selectIndex: Dispatch<SetStateAction<number>>;
selectedIndex: number;
isSpinning: boolean;
toggleSpin: () => void;
}) => {
return ( return (
<div <div
style={{ style={{
@@ -25,7 +33,7 @@ export const InfoBox = ({
flexDirection: "column", flexDirection: "column",
alignItems: "center", alignItems: "center",
position: "absolute", position: "absolute",
bottom: 96, bottom: 32,
left: 0, left: 0,
right: 0, right: 0,
}} }}
@@ -42,19 +50,15 @@ export const InfoBox = ({
color: "white", color: "white",
}} }}
> >
<small>{type.issuer.name}</small> <small>{issuer.name}</small>
<h1 style={{ textAlign: "center" }}>{type.title}</h1> <h1 style={{ textAlign: "center" }}>{title}</h1>
<p> <p>{min_year !== max_year ? min_year + " - " + max_year : min_year}</p>
{type.min_year !== type.max_year <small>{(count ?? 1) + " in collection"}</small>
? type.min_year + " - " + type.max_year
: type.min_year}
</p>
<small>{(type.count ?? 1) + " in collection"}</small>
{category === "coins" && ( {category === "coins" && (
<> <>
{type.weight && <p>{type.weight + "g"}</p>} {weight && <p>{weight + "g"}</p>}
{type.size && <p>{type.size + "mm"}</p>} {size && <p>{size + "mm"}</p>}
{type.thickness && <p>{type.thickness + "mm"}</p>} {thickness && <p>{thickness + "mm"}</p>}
</> </>
)} )}
</div> </div>
@@ -74,19 +78,19 @@ export const InfoBox = ({
}} }}
> >
<button <button
disabled={selectedIndex <= 0} disabled={currentIndex <= 0}
style={{ flex: 1 }} style={{ flex: 1 }}
onClick={() => selectIndex((value) => Math.max(value - 1, 0))} onClick={previousIndex}
> >
{"<-"} {"<-"}
</button> </button>
<span>{selectedIndex + 1 + "/" + (maxIndex + 1)}</span> <span>{currentIndex + 1 + "/" + total}</span>
<button <button
disabled={selectedIndex >= maxIndex} disabled={currentIndex >= total - 1}
style={{ flex: 1 }} style={{ flex: 1 }}
onClick={() => selectIndex((value) => Math.min(value + 1, maxIndex))} onClick={nextIndex}
> >
{"->"} {"->"}
</button> </button>
+113
View File
@@ -0,0 +1,113 @@
import { MeshProps, useFrame } from "@react-three/fiber";
import { Suspense, useRef } from "react";
import { CoinInstance } from "@/components/coin";
import { Mesh } from "three";
import { BanknoteInstance } from "@/components/banknote";
import { useShowcaseStore } from "./store";
import { CoinProps } from "@/types/coin";
const SpinninType = ({
typeProps,
isSelected,
...props
}: MeshProps & {
typeProps?: CoinProps;
isSelected?: boolean;
}) => {
const ref = useRef<Mesh>(null!);
const category = useShowcaseStore((state) => state.category);
const isSpinning = useShowcaseStore((state) => state.isSpinning);
const isTransitioning = useShowcaseStore((state) => state.isTransitioning);
const Instance = category === "banknotes" ? BanknoteInstance : CoinInstance;
useFrame((_state, delta) => {
if (!isSelected || !isSpinning || !typeProps) {
return;
}
if (category === "banknotes") {
ref.current.rotation.x += delta;
return;
}
switch (typeProps.orientation) {
case "medal":
ref.current.rotation.x += delta;
break;
default:
ref.current.rotation.z += delta;
break;
}
});
if (!typeProps) {
return;
}
const { size = 16, thickness = 2 } = typeProps;
return (
<mesh {...props}>
<mesh
ref={ref}
{...(category !== "banknotes"
? {
scale: [size / 16, thickness / 16, size / 16],
}
: { rotation: [Math.PI / 2, Math.PI, Math.PI / 2] })}
{...(!isSelected &&
!isTransitioning && {
scale: [0, 0, 0],
})}
>
<Instance {...typeProps} />
</mesh>
</mesh>
);
};
export const Row = () => {
const ref = useRef<Mesh>(null!);
const category = useShowcaseStore((state) => state.category);
const currentIndex = useShowcaseStore((state) => state.currentIndex);
const getType = useShowcaseStore((state) => state.getType);
const isTransitioning = useShowcaseStore((state) => state.isTransitioning);
const endTransition = useShowcaseStore((state) => state.endTransition);
const types = useShowcaseStore((state) => state.types);
const spacing = category === "banknotes" ? 8 : 6;
useFrame(() => {
if (!isTransitioning) {
return;
}
const target = -currentIndex * spacing;
const diff = ref.current.position.x - target;
if (Math.abs(diff) > 1 / 32) {
ref.current.position.x -= diff / 16;
} else {
console.log("ending transition");
endTransition();
}
});
return (
<mesh ref={ref}>
{types.map(
(type, index) =>
Math.abs(index - currentIndex) <= 1 && (
<Suspense key={type.id} fallback={null}>
<SpinninType
typeProps={getType(index)}
isSelected={currentIndex === index}
position={[index * spacing, 0, 0]}
rotation={[Math.PI / 2, Math.PI / 2, 0]}
/>
</Suspense>
),
)}
</mesh>
);
};
+78
View File
@@ -0,0 +1,78 @@
import { create } from "zustand";
import { OrbitControls } from "three-stdlib";
import { CoinProps } from "@/types/coin";
import { MutableRefObject, createRef } from "react";
type Props = { category: "coins" | "banknotes"; types: CoinProps[] };
interface ShowcaseStore {
currentIndex: number;
endTransition: () => void;
isSpinning: boolean;
isTransitioning: boolean;
nextIndex: () => void;
previousIndex: () => void;
orbitControlsRef: MutableRefObject<OrbitControls | null>;
setOrbitControlsRef: (ref: OrbitControls | null) => void;
toggleSpin: () => void;
types: CoinProps[];
category: Props["category"] | null;
init: (props: Props) => void;
getType: (index?: number) => CoinProps | undefined;
}
export const useShowcaseStore = create<ShowcaseStore>((set, get) => ({
isSpinning: true,
isTransitioning: false,
currentIndex: 0,
types: [],
category: null,
orbitControlsRef: createRef(),
nextIndex: () =>
set((state) => {
const currentIndex = Math.min(
state.currentIndex + 1,
state.types.length - 1,
);
return {
currentIndex,
isTransitioning: true,
currentType: state.types[currentIndex],
};
}),
previousIndex: () =>
set((state) => {
const currentIndex = Math.max(state.currentIndex - 1, 0);
return {
currentIndex,
isTransitioning: true,
currentType: state.types[currentIndex],
};
}),
toggleSpin: () =>
set((state) => {
if (!state.isSpinning) {
state.orbitControlsRef.current?.setAzimuthalAngle(0);
state.orbitControlsRef.current?.setPolarAngle(Math.PI / 2);
return { isSpinning: true };
}
return { isSpinning: false };
}),
endTransition: () => set(() => ({ isTransitioning: false })),
init: (props) =>
set((state) => {
if (state.types.length) {
return {};
}
return props;
}),
getType: (index) => {
const { types, currentIndex } = get();
return types[index ?? currentIndex];
},
setOrbitControlsRef: (instance) =>
set(() => ({
orbitControlsRef: { current: instance },
})),
}));
+21 -3
View File
@@ -1,6 +1,24 @@
import "@/styles/globals.css"; import "@/styles/globals.css";
import type { AppProps } from "next/app"; import type { AppContext, AppProps } from "next/app";
import App from "next/app";
import Div100vh from "react-div-100vh";
export default function App({ Component, pageProps }: AppProps) { export default function MyApp({ Component, pageProps }: AppProps) {
return <Component {...pageProps} />; return (
<Div100vh>
<Component {...pageProps} />
</Div100vh>
);
} }
MyApp.getInitialProps = async (appContext: AppContext) => {
const appProps = await App.getInitialProps(appContext);
if (appContext.ctx.res?.statusCode === 404) {
appContext.ctx.res.writeHead(302, { Location: "/" });
appContext.ctx.res.end();
return;
}
return { ...appProps };
};
+5 -4
View File
@@ -28,16 +28,17 @@ const BanknotesPage: NextPage<Props> = ({ banknotes, ...props }) => (
<Header route={"banknotes"} {...props} /> <Header route={"banknotes"} {...props} />
<main id={"scene-root"} style={{ position: "relative" }}> <Showcase category="banknotes" types={banknotes} />
<Showcase category="banknotes" types={banknotes} />
</main>
</> </>
); );
export const getStaticProps: GetStaticProps<Props> = async ({ params }) => { export const getStaticProps: GetStaticProps<Props> = async ({ params }) => {
if (typeof params?.country !== "string") { if (typeof params?.country !== "string") {
return { return {
notFound: true, redirect: {
destination: "/",
permanent: true,
},
}; };
} }
+5 -4
View File
@@ -28,16 +28,17 @@ const CoinsPage: NextPage<Props> = ({ coins, ...props }) => (
<Header route={"coins"} {...props} /> <Header route={"coins"} {...props} />
<main id={"scene-root"} style={{ position: "relative" }}> <Showcase category="coins" types={coins} />
<Showcase category="coins" types={coins} />
</main>
</> </>
); );
export const getStaticProps: GetStaticProps<Props> = async ({ params }) => { export const getStaticProps: GetStaticProps<Props> = async ({ params }) => {
if (typeof params?.country !== "string") { if (typeof params?.country !== "string") {
return { return {
notFound: true, redirect: {
destination: "/",
permanent: true,
},
}; };
} }
-1
View File
@@ -36,7 +36,6 @@ const CountriesPage: NextPage<Props> = ({ countsByCountry }) => {
position: "relative", position: "relative",
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
minHeight: "100vh",
gap: 32, gap: 32,
padding: "32px 16px", padding: "32px 16px",
}} }}
+5 -4
View File
@@ -28,16 +28,17 @@ const PilePage: NextPage<Props> = ({ coins, ...props }) => (
<Header route={"pile"} {...props} /> <Header route={"pile"} {...props} />
<main id={"scene-root"}> <Pile coins={coins} />
<Pile coins={coins} />
</main>
</> </>
); );
export const getStaticProps: GetStaticProps<Props> = async ({ params }) => { export const getStaticProps: GetStaticProps<Props> = async ({ params }) => {
if (typeof params?.country !== "string") { if (typeof params?.country !== "string") {
return { return {
notFound: true, redirect: {
destination: "/",
permanent: true,
},
}; };
} }
-4
View File
@@ -11,7 +11,3 @@ body {
background-color: #111; background-color: #111;
color: white; color: white;
} }
#scene-root > div:first-child {
height: 100vh !important;
}
Executable
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
cd /mnt/bigdisk/home/miguel/Documents/projects/react/coins
rm -rf packages/site/.next
yarn
yarn build
yarn start
+436 -419
View File
File diff suppressed because it is too large Load Diff