Add showcase
This commit is contained in:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -10,6 +10,6 @@ body {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
#scene-root > div {
|
||||
#scene-root > div:first-child {
|
||||
height: 100vh !important;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user