Add showcase

This commit is contained in:
2023-09-13 00:44:25 +01:00
parent b3f2a321ab
commit 0ee45fa8d2
7 changed files with 461 additions and 5 deletions
+79 -4
View File
@@ -1,32 +1,107 @@
import * as THREE from "three";
import { useLoader } from "@react-three/fiber";
import { CoinProps } from "@/types/coin";
import { Suspense, useEffect, useMemo } from "react";
const SafeEdgeMaterial = ({
color,
edge,
}: Pick<CoinProps, "edge" | "color">) => {
try {
// eslint-disable-next-line react-hooks/rules-of-hooks
const edgeTexture: THREE.Texture = useLoader(
THREE.TextureLoader,
edge.picture,
);
edgeTexture.repeat = new THREE.Vector2(2, 1);
edgeTexture.offset = new THREE.Vector2(0.5, 0);
edgeTexture.wrapS = THREE.MirroredRepeatWrapping;
edgeTexture.wrapT = THREE.MirroredRepeatWrapping;
return (
<Suspense
fallback={
<meshStandardMaterial
metalness={1}
roughness={0.5}
color={color}
attach="material-0"
/>
}
>
<meshStandardMaterial
map={edgeTexture}
metalness={1}
roughness={0.5}
attach="material-0"
/>
</Suspense>
);
} catch {
return (
<meshStandardMaterial
metalness={1}
roughness={0.5}
color={color}
attach="material-0"
/>
);
}
};
export const CoinInstance = ({
orientation,
size,
thickness,
color,
obverse,
shape,
reverse,
edge,
}: CoinProps) => {
const sides = useMemo(() => {
return 32;
const parsed = Number(/(\d+)/.exec(shape)?.[0]);
return Math.min(32, Math.max(4, isNaN(parsed) ? 32 : parsed));
}, [shape]);
//const edgeTexture = useLoader(THREE.TextureLoader, edge.picture);
const obverseTexture = useLoader(THREE.TextureLoader, obverse.picture);
const reverseTexture = useLoader(THREE.TextureLoader, reverse.picture);
const obverseTexture: THREE.Texture = useLoader(
THREE.TextureLoader,
obverse.picture,
);
const reverseTexture: THREE.Texture = useLoader(
THREE.TextureLoader,
reverse.picture,
);
useEffect(() => {
if (orientation === "medal") {
reverseTexture.center = new THREE.Vector2(0.5, 0.5);
reverseTexture.rotation = Math.PI;
}
}, [orientation, reverseTexture]);
return (
<>
<cylinderBufferGeometry
attach="geometry"
args={[size / 16, size / 16, thickness / 16]}
args={[
(size ?? 16) / 16,
(size ?? 16) / 16,
(thickness ?? 2) / 16,
sides,
]}
/>
<meshStandardMaterial metalness={1} color={color} attach="material-0" />
<SafeEdgeMaterial color={color} edge={edge} />
<meshStandardMaterial
metalness={1}
roughness={0.5}
map={obverseTexture}
attach="material-1"
/>
<meshStandardMaterial
metalness={1}
roughness={0.5}
map={reverseTexture}
attach="material-2"
/>
@@ -0,0 +1,37 @@
import type { NextApiHandler } from "next";
import { readFile } from "fs/promises";
import path from "path";
const handler: NextApiHandler<string[] | { message: string }> = async (
req,
res,
) => {
if (typeof req.query.country !== "string") {
res.status(400).json({
message: `Invalid country "${JSON.stringify(req.query.country)}"`,
});
return;
}
if (typeof req.query.coin !== "string") {
res
.status(400)
.json({ message: `Invalid coin "${JSON.stringify(req.query.coin)}"` });
return;
}
try {
const coin = await readFile(
path.join("./public/coins", req.query.country, `${req.query.coin}.json`),
{ encoding: "utf-8" },
);
res.status(200).json(JSON.parse(coin));
} catch {
res.status(404).json({
message: `Coin "${req.query.country}/${req.query.coin}" not found`,
});
}
};
export default handler;
@@ -0,0 +1,35 @@
import type { NextApiHandler } from "next";
import { readdir } from "fs/promises";
import path from "path";
const handler: NextApiHandler<string[] | { message: string }> = async (
req,
res,
) => {
if (typeof req.query.country !== "string") {
res.status(400).json({
message: `Invalid country "${JSON.stringify(req.query.country)}"`,
});
return;
}
try {
const countryCoins = await readdir(
path.join("./public/coins", req.query.country),
);
res
.status(200)
.json(
countryCoins
.filter((file) => file.endsWith(".json"))
.map((file) => file.replace(/\.json$/, "")),
);
} catch {
res
.status(404)
.json({ message: `Country "${req.query.country}" not found` });
}
};
export default handler;
+10
View File
@@ -0,0 +1,10 @@
import { readdir } from "fs/promises";
import type { NextApiHandler } from "next";
const handler: NextApiHandler<string[]> = async (_req, res) => {
const countries = await readdir("./public/coins");
res.status(200).json(countries);
};
export default handler;
+243
View File
@@ -0,0 +1,243 @@
import Head from "next/head";
import { GetStaticPaths, GetStaticProps, NextPage } from "next";
import { CoinProps } from "@/types/coin";
import { Canvas, MeshProps, useFrame } from "@react-three/fiber";
import { Dispatch, SetStateAction, Suspense, useRef, useState } from "react";
import { CoinInstance } from "@/components/scene/coin";
import { Mesh } from "three";
import { OrbitControls } from "@react-three/drei";
import path from "path";
import { readFile, readdir } from "fs/promises";
type Props = {
coins: CoinProps[];
};
const SpinninCoin = ({
isSelected,
coinProps,
...props
}: { coinProps: CoinProps; isSelected?: boolean } & MeshProps) => {
const ref = useRef<Mesh>(null!);
useFrame((_state, delta) => {
if (!isSelected) {
return;
}
switch (coinProps.orientation) {
case "medal":
ref.current.rotation.x += delta;
break;
default:
ref.current.rotation.z += delta;
break;
}
});
return (
<mesh {...props}>
<mesh ref={ref}>
<CoinInstance {...coinProps} />
</mesh>
</mesh>
);
};
const Row = ({ coins, selectedIndex }: Props & { selectedIndex: number }) => {
const ref = useRef<Mesh>(null!);
useFrame(() => {
const xPosition = Math.round(10 * ref.current.position.x) / 10;
const target = -selectedIndex * 6;
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}>
{coins.map((coin, index) => (
<Suspense key={coin.id} fallback={null}>
<SpinninCoin
isSelected={selectedIndex === index}
coinProps={coin}
position={[index * 6, 1, 0]}
rotation={[Math.PI / 2, Math.PI / 2, 0]}
/>
</Suspense>
))}
</mesh>
);
};
const InfoBox = ({
coin,
maxIndex,
selectIndex,
}: {
coin: CoinProps;
maxIndex: number;
selectIndex: Dispatch<SetStateAction<number>>;
}) => {
return (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
position: "absolute",
bottom: 96,
left: 0,
right: 0,
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 8,
maxWidth: "100vw",
width: 512,
marginBottom: 16,
color: "white",
}}
>
<small>{coin.issuer.name}</small>
<h1 style={{ textAlign: "center" }}>{coin.title}</h1>
<p>{coin.min_year + " - " + coin.max_year}</p>
<small>{(coin.count ?? 1) + " in collection"}</small>
<p>{coin.weight + "g"}</p>
<p>{coin.size + "mm"}</p>
<p>{coin.thickness + "mm"}</p>
</div>
<div
style={{
width: 128,
display: "flex",
gap: 8,
}}
>
<button
style={{ flex: 1 }}
onClick={() => selectIndex((value) => Math.max(value - 1, 0))}
>
{"<-"}
</button>
<button
style={{ flex: 1 }}
onClick={() => selectIndex((value) => Math.min(value + 1, maxIndex))}
>
{"->"}
</button>
</div>
</div>
);
};
const CountryPage: NextPage<Props> = ({ coins }) => {
const [selectedIndex, selectIndex] = useState(0);
return (
<>
<Head>
<title>Coins</title>
<meta name="description" content="My father's coin collection" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<main id={"scene-root"} style={{ position: "relative" }}>
<Canvas shadows>
<color attach="background" args={["#111"]} />
<ambientLight />
<directionalLight position={[0, 0.125, 1]} />
<Row coins={coins} selectedIndex={selectedIndex} />
<OrbitControls />
</Canvas>
<div
style={{
position: "absolute",
display: "flex",
justifyContent: "space-between",
top: 0,
padding: 16,
width: "100%",
}}
>
<button
style={{
width: 128,
padding: "4px 8px",
}}
>
<a href={"/showcase-banknotes"}>{"<- All countries"}</a>
</button>
<button
style={{
width: 128,
padding: "4px 8px",
}}
>
<a href={"/showcase-banknotes"}>{"Banknotes"}</a>
</button>
</div>
<InfoBox
coin={coins[selectedIndex]}
selectIndex={selectIndex}
maxIndex={coins.length - 1}
/>
</main>
</>
);
};
export const getStaticProps: GetStaticProps<Props> = async (ctx) => {
if (typeof ctx.params?.country !== "string") {
return {
notFound: true,
};
}
const coinIds = await readdir(
path.join("./public/coins", ctx.params?.country),
);
const coins: CoinProps[] = [];
for (const coinId of coinIds) {
const coin = await readFile(
path.join("./public/coins", ctx.params.country, coinId),
{ encoding: "utf-8" },
);
coins.push(JSON.parse(coin));
}
return {
props: {
coins: coins.sort((a, b) => a.min_year - b.min_year),
},
};
};
export const getStaticPaths: GetStaticPaths = async () => {
const countries = await readdir("./public/coins");
return {
fallback: false,
paths: countries.map((country) => ({ params: { country } })),
};
};
export default CountryPage;
+56
View File
@@ -0,0 +1,56 @@
import Head from "next/head";
import { GetStaticProps, NextPage } from "next";
import { readdir } from "fs/promises";
type Props = {
countries: string[];
};
const CountriesPage: NextPage<Props> = ({ countries }) => {
return (
<>
<Head>
<title>Coins</title>
<meta name="description" content="My father's coin collection" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<main
id={"scene-root"}
style={{
backgroundColor: "#111",
color: "white",
position: "relative",
display: "flex",
flexDirection: "column",
gap: 16,
padding: "32px 16px",
}}
>
{countries.map((country) => (
<a key={country} href={"/showcase/" + country}>
{country}
</a>
))}
</main>
</>
);
};
export const getStaticProps: GetStaticProps<Props> = async () => {
const countries = await readdir("./public/coins");
if (!Array.isArray(countries)) {
return {
notFound: true,
};
}
return {
props: {
countries,
},
};
};
export default CountriesPage;
+1 -1
View File
@@ -10,6 +10,6 @@ body {
overflow-x: hidden;
}
#scene-root > div {
#scene-root > div:first-child {
height: 100vh !important;
}