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",
"prettier": "^3.0.3",
"react": "18.2.0",
"react-div-100vh": "^0.7.0",
"react-dom": "18.2.0",
"three": "0.152.2"
"three": "0.152.2",
"zustand": "^4.4.1"
},
"devDependencies": {
"@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]} />
<meshStandardMaterial
color={"#888"}
metalness={0}
roughness={0.5}
attach="material-0"
/>
<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"
/>
{Array.from({ length: 4 }, (_, index) => (
<meshStandardMaterial
key={index}
color={"#888"}
metalness={0}
roughness={0.5}
attach={"material-" + index}
/>
))}
<meshStandardMaterial
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 { PlaneProps, Physics, usePlane } from "@react-three/cannon";
import { Mesh } from "three";
import styles from "./index.module.css";
type Props = {
coins: CoinProps[];
@@ -31,8 +32,7 @@ const Lightbulb = (props: PointLightProps) => (
);
export const Pile = ({ coins }: Props) => (
<Canvas camera={{ position: [0, 16, 16] }}>
<color attach="background" args={["#111"]} />
<Canvas camera={{ position: [0, 16, 16] }} className={styles.canvas}>
<ambientLight />
<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 { Canvas, MeshProps, useFrame } from "@react-three/fiber";
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
import { CoinInstance } from "@/components/coin";
import { Mesh } from "three";
import { BanknoteInstance } from "@/components/banknote";
import { Canvas } from "@react-three/fiber";
import { useEffect } from "react";
import { InfoBox } from "./info-box";
import { OrbitControls } from "@react-three/drei";
import { OrbitControls as OrbitControlsType } from "three-stdlib";
import { Row } from "./row";
import { useShowcaseStore } from "./store";
type Props = {
category: "coins" | "banknotes";
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) => {
const [selectedIndex, selectIndex] = useState(0);
const [isSpinning, setIsSpinning] = useState(true);
const orbitControlsRef = useRef<OrbitControlsType>(null!);
const toggleSpin = useCallback(() => {
setIsSpinning((value) => {
if (!value) {
orbitControlsRef.current?.setAzimuthalAngle(0);
orbitControlsRef.current?.setPolarAngle(Math.PI / 2);
return true;
}
return false;
});
}, []);
const isSpinning = useShowcaseStore((state) => state.isSpinning);
const nextIndex = useShowcaseStore((state) => state.nextIndex);
const previousIndex = useShowcaseStore((state) => state.previousIndex);
const init = useShowcaseStore((state) => state.init);
const toggleSpin = useShowcaseStore((state) => state.toggleSpin);
const setOrbitControlsRef = useShowcaseStore(
(state) => state.setOrbitControlsRef,
);
useEffect(() => init(props), [init, props]);
useEffect(() => {
const onKeyDown = ({ key }: KeyboardEvent) => {
switch (key) {
case "ArrowLeft":
return selectIndex((value) => Math.max(value - 1, 0));
return previousIndex();
case "ArrowRight":
return selectIndex((value) =>
Math.min(value + 1, props.types.length - 1),
);
return nextIndex();
case " ":
return toggleSpin();
}
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [props.types.length, toggleSpin]);
}, [nextIndex, previousIndex, toggleSpin]);
return (
<>
<Canvas>
<color attach="background" args={["#111"]} />
<div className={styles.wrapper}>
<Canvas className={styles.canvas}>
<ambientLight />
<directionalLight position={[0, 0.125, 1]} />
<directionalLight position={[0, 0.125, -1]} />
<Row {...props} selectedIndex={selectedIndex} isSpinning={isSpinning} />
<Row />
<OrbitControls
ref={orbitControlsRef}
ref={setOrbitControlsRef}
enableRotate={!isSpinning}
enablePan={false}
minDistance={2}
maxDistance={10}
/>
</Canvas>
<InfoBox
category={props.category}
type={props.types[selectedIndex]}
selectIndex={selectIndex}
selectedIndex={selectedIndex}
isSpinning={isSpinning}
toggleSpin={toggleSpin}
maxIndex={props.types.length - 1}
/>
</>
<InfoBox />
</div>
);
};
+40 -36
View File
@@ -1,23 +1,31 @@
import { CoinProps } from "@/types/coin";
import { Dispatch, SetStateAction } from "react";
import { useShowcaseStore } from "./store";
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 (
<div
style={{
@@ -25,7 +33,7 @@ export const InfoBox = ({
flexDirection: "column",
alignItems: "center",
position: "absolute",
bottom: 96,
bottom: 32,
left: 0,
right: 0,
}}
@@ -42,19 +50,15 @@ export const InfoBox = ({
color: "white",
}}
>
<small>{type.issuer.name}</small>
<h1 style={{ textAlign: "center" }}>{type.title}</h1>
<p>
{type.min_year !== type.max_year
? type.min_year + " - " + type.max_year
: type.min_year}
</p>
<small>{(type.count ?? 1) + " in collection"}</small>
<small>{issuer.name}</small>
<h1 style={{ textAlign: "center" }}>{title}</h1>
<p>{min_year !== max_year ? min_year + " - " + max_year : min_year}</p>
<small>{(count ?? 1) + " in collection"}</small>
{category === "coins" && (
<>
{type.weight && <p>{type.weight + "g"}</p>}
{type.size && <p>{type.size + "mm"}</p>}
{type.thickness && <p>{type.thickness + "mm"}</p>}
{weight && <p>{weight + "g"}</p>}
{size && <p>{size + "mm"}</p>}
{thickness && <p>{thickness + "mm"}</p>}
</>
)}
</div>
@@ -74,19 +78,19 @@ export const InfoBox = ({
}}
>
<button
disabled={selectedIndex <= 0}
disabled={currentIndex <= 0}
style={{ flex: 1 }}
onClick={() => selectIndex((value) => Math.max(value - 1, 0))}
onClick={previousIndex}
>
{"<-"}
</button>
<span>{selectedIndex + 1 + "/" + (maxIndex + 1)}</span>
<span>{currentIndex + 1 + "/" + total}</span>
<button
disabled={selectedIndex >= maxIndex}
disabled={currentIndex >= total - 1}
style={{ flex: 1 }}
onClick={() => selectIndex((value) => Math.min(value + 1, maxIndex))}
onClick={nextIndex}
>
{"->"}
</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 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) {
return <Component {...pageProps} />;
export default function MyApp({ Component, pageProps }: AppProps) {
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} />
<main id={"scene-root"} style={{ position: "relative" }}>
<Showcase category="banknotes" types={banknotes} />
</main>
<Showcase category="banknotes" types={banknotes} />
</>
);
export const getStaticProps: GetStaticProps<Props> = async ({ params }) => {
if (typeof params?.country !== "string") {
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} />
<main id={"scene-root"} style={{ position: "relative" }}>
<Showcase category="coins" types={coins} />
</main>
<Showcase category="coins" types={coins} />
</>
);
export const getStaticProps: GetStaticProps<Props> = async ({ params }) => {
if (typeof params?.country !== "string") {
return {
notFound: true,
redirect: {
destination: "/",
permanent: true,
},
};
}
-1
View File
@@ -36,7 +36,6 @@ const CountriesPage: NextPage<Props> = ({ countsByCountry }) => {
position: "relative",
display: "flex",
flexDirection: "column",
minHeight: "100vh",
gap: 32,
padding: "32px 16px",
}}
+5 -4
View File
@@ -28,16 +28,17 @@ const PilePage: NextPage<Props> = ({ coins, ...props }) => (
<Header route={"pile"} {...props} />
<main id={"scene-root"}>
<Pile coins={coins} />
</main>
<Pile coins={coins} />
</>
);
export const getStaticProps: GetStaticProps<Props> = async ({ params }) => {
if (typeof params?.country !== "string") {
return {
notFound: true,
redirect: {
destination: "/",
permanent: true,
},
};
}
-4
View File
@@ -11,7 +11,3 @@ body {
background-color: #111;
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