Improve code layout and remove repetition

This commit is contained in:
2023-09-15 12:05:52 +01:00
parent ba217d2c93
commit e88f403a08
22 changed files with 538 additions and 627 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

@@ -0,0 +1,24 @@
import { CoinProps } from "@/types/coin";
import { readFile, readdir } from "fs/promises";
import path from "path";
export const getCountryBanknotes = async (country: string) => {
try {
const banknoteIds = await readdir(path.join("./public/banknotes", country));
const banknotes: CoinProps[] = [];
for (const banknoteId of banknoteIds) {
const banknote = await readFile(
path.join("./public/banknotes", country, banknoteId),
{ encoding: "utf-8" },
);
banknotes.push(JSON.parse(banknote));
}
return banknotes;
} catch {
return [];
}
};
+24
View File
@@ -0,0 +1,24 @@
import { CoinProps } from "@/types/coin";
import { readFile, readdir } from "fs/promises";
import path from "path";
export const getCountryCoins = async (country: string) => {
try {
const coinIds = await readdir(path.join("./public/coins", country));
const coins: CoinProps[] = [];
for (const coinId of coinIds) {
const coin = await readFile(
path.join("./public/coins", country, coinId),
{ encoding: "utf-8" },
);
coins.push(JSON.parse(coin));
}
return coins;
} catch {
return [];
}
};
+70
View File
@@ -0,0 +1,70 @@
type Props = {
route: "pile" | "coins" | "banknotes";
country: string;
coinCount: number;
banknoteCount: number;
};
export const Header = ({ route, country, banknoteCount, coinCount }: Props) => (
<div
style={{
position: "absolute",
display: "flex",
justifyContent: "space-between",
top: 0,
padding: 16,
width: "100%",
zIndex: 1,
gap: 8,
}}
>
<button
style={{
width: 128,
padding: "4px 8px",
}}
>
<a href={"/"}>{"<- All countries"}</a>
</button>
<div
style={{
display: "flex",
justifyContent: "space-between",
gap: 8,
}}
>
{route !== "pile" && coinCount > 0 && (
<button
style={{
width: 128,
padding: "4px 8px",
}}
>
<a href={`/pile/${country}`}>{"pile (" + coinCount + ")"}</a>
</button>
)}
{route !== "coins" && coinCount > 0 && (
<button
style={{
width: 128,
padding: "4px 8px",
}}
>
<a href={`/coins/${country}`}>{"coins (" + coinCount + ")"}</a>
</button>
)}
{route !== "banknotes" && banknoteCount > 0 && (
<button
style={{
width: 128,
padding: "4px 8px",
}}
>
<a href={`/banknotes/${country}`}>
{"banknotes (" + banknoteCount + ")"}
</a>
</button>
)}
</div>
</div>
);
@@ -1,5 +1,5 @@
import { CoinProps } from "@/types/coin";
import { CoinInstance } from "./coin";
import { CoinInstance } from "../coin";
import { useEffect, useRef } from "react";
import { InstancedMesh } from "three";
import { useCylinder } from "@react-three/cannon";
@@ -20,7 +20,7 @@ const Plane = (props: PlaneProps) => {
);
};
export const Scene = ({ coins }: Props) => (
export const Pile = ({ coins }: Props) => (
<Canvas shadows camera={{ position: [0, 16, 16] }}>
<color attach="background" args={["#111"]} />
<ambientLight />
+118
View File
@@ -0,0 +1,118 @@
import { CoinProps } from "@/types/coin";
import { Canvas, MeshProps, useFrame } from "@react-three/fiber";
import { Suspense, useRef, useState } from "react";
import { CoinInstance } from "@/components/coin";
import { Mesh } from "three";
import { OrbitControls } from "@react-three/drei";
import { BanknoteInstance } from "@/components/banknote";
import { InfoBox } from "./info-box";
type Props = {
category: "coins" | "banknotes";
types: CoinProps[];
};
const SpinninType = ({
isSelected,
category,
typeProps,
...props
}: { typeProps: CoinProps; isSelected?: boolean } & MeshProps &
Pick<Props, "category">) => {
const ref = useRef<Mesh>(null!);
const Instance = category === "banknotes" ? BanknoteInstance : CoinInstance;
useFrame((_state, delta) => {
if (!isSelected) {
return;
}
if (category === "banknotes") {
ref.current.rotation.y += 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}>
<Instance {...typeProps} />
</mesh>
</mesh>
);
};
const Row = ({
category,
types,
selectedIndex,
}: Props & { selectedIndex: number }) => {
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={"loading..."}>
<SpinninType
category={category}
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);
return (
<>
<Canvas shadows>
<color attach="background" args={["#111"]} />
<ambientLight />
<directionalLight position={[0, 0.125, 1]} />
<Row {...props} selectedIndex={selectedIndex} />
<OrbitControls />
</Canvas>
<InfoBox
category={props.category}
type={props.types[selectedIndex]}
selectIndex={selectIndex}
selectedIndex={selectedIndex}
maxIndex={props.types.length - 1}
/>
</>
);
};
+79
View File
@@ -0,0 +1,79 @@
import { CoinProps } from "@/types/coin";
import { Dispatch, SetStateAction } from "react";
export const InfoBox = ({
category,
type,
maxIndex,
selectIndex,
selectedIndex,
}: {
category: "banknotes" | "coins";
type: CoinProps;
maxIndex: number;
selectIndex: Dispatch<SetStateAction<number>>;
selectedIndex: 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>{type.issuer.name}</small>
<h1 style={{ textAlign: "center" }}>{type.title}</h1>
<p>{type.min_year + " - " + type.max_year}</p>
<small>{(type.count ?? 1) + " in collection"}</small>
{category === "coins" && (
<>
<p>{type.weight + "g"}</p>
<p>{type.size + "mm"}</p>
<p>{type.thickness + "mm"}</p>
</>
)}
</div>
<div
style={{
width: 128,
display: "flex",
gap: 8,
}}
>
<button
disabled={selectedIndex <= 0}
style={{ flex: 1 }}
onClick={() => selectIndex((value) => Math.max(value - 1, 0))}
>
{"<-"}
</button>
<button
disabled={selectedIndex >= maxIndex}
style={{ flex: 1 }}
onClick={() => selectIndex((value) => Math.min(value + 1, maxIndex))}
>
{"->"}
</button>
</div>
</div>
);
};
@@ -1,37 +0,0 @@
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;
@@ -1,35 +0,0 @@
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
@@ -1,10 +0,0 @@
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;
-13
View File
@@ -1,13 +0,0 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from "next";
type Data = {
name: string;
};
export default function handler(
req: NextApiRequest,
res: NextApiResponse<Data>,
) {
res.status(200).json({ name: "John Doe" });
}
+69
View File
@@ -0,0 +1,69 @@
import Head from "next/head";
import { GetStaticPaths, GetStaticProps, NextPage } from "next";
import { CoinProps } from "@/types/coin";
import { readdir } from "fs/promises";
import { Showcase } from "@/components/showcase";
import { Header } from "@/components/header";
import { getCountryBanknotes } from "@/api/banknotes/get-country-banknotes";
import { getCountryCoins } from "@/api/coins/get-country-coins";
type Props = {
country: string;
coinCount: number;
banknoteCount: number;
banknotes: CoinProps[];
};
const BanknotesPage: NextPage<Props> = ({ banknotes, ...props }) => (
<>
<Head>
<title>Banknotes</title>
<meta
name="description"
content="An exhibition of my father's banknote collection"
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<Header route={"banknotes"} {...props} />
<main id={"scene-root"} style={{ position: "relative" }}>
<Showcase category="banknotes" types={banknotes} />
</main>
</>
);
export const getStaticProps: GetStaticProps<Props> = async ({ params }) => {
if (typeof params?.country !== "string") {
return {
notFound: true,
};
}
const coins = await getCountryCoins(params.country);
const banknotes = await getCountryBanknotes(params.country);
return {
props: {
country: params.country,
coinCount: coins.reduce((total, coin) => total + (coin.count ?? 1), 0),
banknoteCount: banknotes.reduce(
(total, banknote) => total + (banknote.count ?? 1),
0,
),
banknotes: banknotes.sort((a, b) => a.min_year - b.min_year),
},
};
};
export const getStaticPaths: GetStaticPaths = async () => {
const countries = await readdir("./public/banknotes");
return {
fallback: false,
paths: countries.map((country) => ({ params: { country } })),
};
};
export default BanknotesPage;
+71
View File
@@ -0,0 +1,71 @@
import Head from "next/head";
import { GetStaticPaths, GetStaticProps, NextPage } from "next";
import { CoinProps } from "@/types/coin";
import { readdir } from "fs/promises";
import { Showcase } from "@/components/showcase";
import { Header } from "@/components/header";
import { getCountryBanknotes } from "@/api/banknotes/get-country-banknotes";
import { getCountryCoins } from "@/api/coins/get-country-coins";
type Props = {
country: string;
coinCount: number;
banknoteCount: number;
coins: CoinProps[];
};
const CoinsPage: NextPage<Props> = ({ coins, ...props }) => (
<>
<Head>
<title>Coins</title>
<meta
name="description"
content="An exhibition of my father's coin collection"
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<Header route={"coins"} {...props} />
<main id={"scene-root"} style={{ position: "relative" }}>
<Showcase category="coins" types={coins} />
</main>
</>
);
export const getStaticProps: GetStaticProps<Props> = async ({ params }) => {
if (typeof params?.country !== "string") {
return {
notFound: true,
};
}
const coins = await getCountryCoins(params.country);
const banknotes = await getCountryBanknotes(params.country);
console.log({ coins, banknotes });
return {
props: {
country: params.country,
banknoteCount: banknotes.reduce(
(total, banknote) => total + (banknote.count ?? 1),
0,
),
coinCount: coins.reduce((total, coin) => total + (coin.count ?? 1), 0),
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 CoinsPage;
+38 -15
View File
@@ -2,19 +2,23 @@ import Head from "next/head";
import { GetStaticProps, NextPage } from "next";
import { readdir } from "fs/promises";
import { useMemo } from "react";
import { getCountryCoins } from "@/api/coins/get-country-coins";
import { getCountryBanknotes } from "@/api/banknotes/get-country-banknotes";
type Props = {
banknoteCountries: string[];
coinCountries: string[];
countsByCountry: Record<
string,
{
coinCount: number;
banknoteCount: number;
}
>;
};
const CountriesPage: NextPage<Props> = ({
banknoteCountries,
coinCountries,
}) => {
const CountriesPage: NextPage<Props> = ({ countsByCountry }) => {
const countries = useMemo(
() => Array.from(new Set([...banknoteCountries, ...coinCountries])).sort(),
[banknoteCountries, coinCountries],
() => Object.keys(countsByCountry).sort(),
[countsByCountry],
);
return (
@@ -51,10 +55,14 @@ const CountriesPage: NextPage<Props> = ({
width: "min(640px, 95vw)",
}}
>
{coinCountries.includes(country) ? (
{countsByCountry[country].coinCount > 0 ? (
<>
<a href={"/pile/" + country}>{"pile"}</a>
<a href={"/showcase/" + country}>{"coins"}</a>
<a href={"/pile/" + country}>
{"pile (" + countsByCountry[country].coinCount + ")"}
</a>
<a href={"/coins/" + country}>
{"coins (" + countsByCountry[country].coinCount + ")"}
</a>
</>
) : (
<>
@@ -63,8 +71,10 @@ const CountriesPage: NextPage<Props> = ({
</>
)}
{banknoteCountries.includes(country) ? (
<a href={"/showcase-banknotes/" + country}>{"banknotes"}</a>
{countsByCountry[country].banknoteCount > 0 ? (
<a href={"/banknotes/" + country}>
{"banknotes (" + countsByCountry[country].banknoteCount + ")"}
</a>
) : (
<div />
)}
@@ -79,11 +89,24 @@ const CountriesPage: NextPage<Props> = ({
export const getStaticProps: GetStaticProps<Props> = async () => {
const coinCountries = await readdir("./public/coins");
const banknoteCountries = await readdir("./public/banknotes");
const countsByCountry: Props["countsByCountry"] = {};
for (const country of new Set([...coinCountries, ...banknoteCountries])) {
countsByCountry[country] = {
coinCount: (await getCountryCoins(country)).reduce(
(total, coin) => total + (coin.count ?? 1),
0,
),
banknoteCount: (await getCountryBanknotes(country)).reduce(
(total, banknote) => total + (banknote.count ?? 1),
0,
),
};
}
return {
props: {
banknoteCountries,
coinCountries,
countsByCountry,
},
};
};
+38 -55
View File
@@ -1,75 +1,58 @@
import Head from "next/head";
import { Scene } from "@/components/scene";
import { Pile } from "@/components/pile";
import { GetStaticPaths, GetStaticProps, NextPage } from "next";
import { CoinProps } from "@/types/coin";
import { readFile, readdir } from "fs/promises";
import path from "path";
import { readdir } from "fs/promises";
import { Header } from "@/components/header";
import { getCountryBanknotes } from "@/api/banknotes/get-country-banknotes";
import { getCountryCoins } from "@/api/coins/get-country-coins";
type Props = {
country: string;
coinCount: number;
banknoteCount: number;
coins: CoinProps[];
};
const Home: NextPage<Props> = ({ coins }) => {
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"}>
<Scene coins={coins} />
const PilePage: NextPage<Props> = ({ coins, ...props }) => (
<>
<Head>
<title>Coin pile</title>
<meta
name="description"
content="My father's coin collection in a big pile"
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<div
style={{
position: "absolute",
display: "flex",
justifyContent: "space-between",
top: 0,
padding: 16,
width: "100%",
}}
>
<button
style={{
width: 128,
padding: "4px 8px",
}}
>
<a href={"/"}>{"<- All countries"}</a>
</button>
</div>
</main>
</>
);
};
<Header route={"pile"} {...props} />
export const getStaticProps: GetStaticProps<Props> = async (ctx) => {
if (typeof ctx.params?.country !== "string") {
<main id={"scene-root"}>
<Pile coins={coins} />
</main>
</>
);
export const getStaticProps: GetStaticProps<Props> = async ({ params }) => {
if (typeof 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));
}
const coins = await getCountryCoins(params.country);
const banknotes = await getCountryBanknotes(params.country);
return {
props: {
coins,
country: params.country,
banknoteCount: banknotes.reduce(
(total, banknote) => total + (banknote.count ?? 1),
0,
),
coinCount: coins.reduce((total, coin) => total + (coin.count ?? 1), 0),
coins: coins.sort((a, b) => a.min_year - b.min_year),
},
};
};
@@ -83,4 +66,4 @@ export const getStaticPaths: GetStaticPaths = async () => {
};
};
export default Home;
export default PilePage;
-225
View File
@@ -1,225 +0,0 @@
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 { Mesh } from "three";
import { OrbitControls } from "@react-three/drei";
import path from "path";
import { readFile, readdir } from "fs/promises";
import { BanknoteInstance } from "@/components/scene/banknote";
type Props = {
banknotes: CoinProps[];
};
const SpinninBanknote = ({
isSelected,
coinProps,
...props
}: { coinProps: CoinProps; isSelected?: boolean } & MeshProps) => {
const ref = useRef<Mesh>(null!);
useFrame((_state, delta) => {
if (isSelected) {
ref.current.rotation.y += delta;
}
});
return (
<mesh {...props}>
<mesh ref={ref}>
<BanknoteInstance {...coinProps} />
</mesh>
</mesh>
);
};
const Row = ({
banknotes,
selectedIndex,
}: Props & { selectedIndex: number }) => {
const ref = useRef<Mesh>(null!);
useFrame(() => {
const xPosition = Math.round(10 * ref.current.position.x) / 10;
const target = -selectedIndex * 8;
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}>
{banknotes.map((coin, index) => (
<Suspense key={coin.id} fallback={null}>
<SpinninBanknote
isSelected={selectedIndex === index}
coinProps={coin}
position={[index * 8, 1, 0]}
rotation={[0, 0, 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>
</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> = ({ banknotes }) => {
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 banknotes={banknotes} 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={"/"}>{"<- All countries"}</a>
</button>
</div>
<InfoBox
coin={banknotes[selectedIndex]}
selectIndex={selectIndex}
maxIndex={banknotes.length - 1}
/>
</main>
</>
);
};
export const getStaticProps: GetStaticProps<Props> = async (ctx) => {
console.log(ctx.params?.country);
if (typeof ctx.params?.country !== "string") {
return {
notFound: true,
};
}
const coinIds = await readdir(
path.join("./public/banknotes", ctx.params?.country),
);
const banknotes: CoinProps[] = [];
for (const coinId of coinIds) {
const coin = await readFile(
path.join("./public/banknotes", ctx.params.country, coinId),
{ encoding: "utf-8" },
);
banknotes.push(JSON.parse(coin));
}
return {
props: {
banknotes: banknotes.sort((a, b) => a.min_year - b.min_year),
},
};
};
export const getStaticPaths: GetStaticPaths = async () => {
const countries = await readdir("./public/banknotes");
return {
fallback: false,
paths: countries.map((country) => ({ params: { country } })),
};
};
export default CountryPage;
-234
View File
@@ -1,234 +0,0 @@
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={"/"}>{"<- All countries"}</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;
+4
View File
@@ -10,6 +10,10 @@ body {
overflow-x: hidden;
}
#scene-root {
background-color: #111;
}
#scene-root > div:first-child {
height: 100vh !important;
}
+1 -1
View File
@@ -14,7 +14,7 @@ export type CoinProps = {
id: number;
url: string;
title: string;
category: "coin" | "exonumia";
category: "banknote" | "coin" | "exonumia";
issuer: {
code: string;
name: string;