Improve code layout and remove repetition
This commit is contained in:
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 [];
|
||||
}
|
||||
};
|
||||
@@ -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 [];
|
||||
}
|
||||
};
|
||||
@@ -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 />
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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" });
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -10,6 +10,10 @@ body {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
#scene-root {
|
||||
background-color: #111;
|
||||
}
|
||||
|
||||
#scene-root > div:first-child {
|
||||
height: 100vh !important;
|
||||
}
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user