Compare commits

...

19 Commits

Author SHA1 Message Date
owenrees a5bb7dafd9 pdf generation toasts 2026-06-02 21:22:57 +02:00
owenrees 1c8f1674b7 basic large screen layout 2026-06-02 21:15:03 +02:00
owenrees 19c2951cc3 rudimentary buttons 2026-06-02 21:02:46 +02:00
owenrees 1251921ea1 handlePlyChange passed as prop to useEffect is being recreated every time state changes, so wrapper in useCallback 2026-06-02 20:26:47 +02:00
owenrees 7d8f390a50 move game state and head interfaces into shared file 2026-06-02 20:19:47 +02:00
owenrees 1d6a9f8e6e use shared IPosition interface for diagrams and current move state 2026-06-02 20:17:48 +02:00
owenrees 87fa6997ee update with button styling 2026-06-02 15:24:44 +02:00
owenrees f841b7aeee pass generatingPdf state to checkbox to disable, remove container compoentn for chessboard 2026-06-02 15:24:19 +02:00
owenrees 9fab5e10f2 add state to track pdf generation state 2026-06-02 15:09:43 +02:00
owenrees be47dc449e update with zod, headers, suspense, toasts, logger 2026-06-02 15:07:23 +02:00
owenrees a0d2247ff8 rudimentary pdf saving 2026-06-02 15:06:56 +02:00
owenrees 95bef61a29 update todo list 2026-06-02 14:55:48 +02:00
owenrees 1a9d26a6ea wire diagram toggle checkbox state 2026-06-02 14:52:23 +02:00
owenrees 50664f4759 trigger reducer pased on if the diagram is in the position or not 2026-06-02 14:48:33 +02:00
owenrees 98fbc4d253 pass current fen and ply to state 2026-06-02 14:44:18 +02:00
owenrees b8bd48885e pass ply change to callback 2026-06-02 14:24:17 +02:00
owenrees 42ef8f20e0 add board listeners 2026-06-02 13:49:34 +02:00
owenrees 6b21f0bfac style diagram select checkbox and label text 2026-06-02 13:16:22 +02:00
owenrees 401ac760cf style render move times toggle 2026-06-02 13:11:13 +02:00
16 changed files with 417 additions and 118 deletions
+16
View File
@@ -0,0 +1,16 @@
// ZOD
FEN string
Env Vars
API base URL
- rearrange buttons / header space - collapse on smaller screens
- add / edit headers
- custom headers - title etc
- Loading Chessboard with suspense and spinner
- toasts
- Lichess login
- stringUtils - downloadString - move it out of try / catch and let program use the try/catch
- logger
+1
View File
@@ -30,6 +30,7 @@
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-icons": "^5.6.0",
"react-toastify": "^11.1.0",
"tailwindcss": "^4.1.18",
"zod": "^4.4.3"
},
+15
View File
@@ -53,6 +53,9 @@ importers:
react-icons:
specifier: ^5.6.0
version: 5.6.0(react@19.2.6)
react-toastify:
specifier: ^11.1.0
version: 11.1.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
tailwindcss:
specifier: ^4.1.18
version: 4.3.0
@@ -1858,6 +1861,12 @@ packages:
react-is@17.0.2:
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
react-toastify@11.1.0:
resolution: {integrity: sha512-e9h23x3phN0wbFeB6yovmWp7lobzV4CaCH0LO8nVP6H7Y+3GbcLpIzMm9dJhcp1RXbpyfvjgpfXqO80QAmn7sg==}
peerDependencies:
react: ^18 || ^19
react-dom: ^18 || ^19
react@19.2.6:
resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==}
engines: {node: '>=0.10.0'}
@@ -3839,6 +3848,12 @@ snapshots:
react-is@17.0.2: {}
react-toastify@11.1.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
clsx: 2.1.1
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
react@19.2.6: {}
readdirp@5.0.0: {}
+59 -4
View File
@@ -1,15 +1,17 @@
import LichessPgnViewer from "lichess-pgn-viewer";
import type PgnViewerType from "lichess-pgn-viewer/pgnViewer";
import { useEffect, useRef } from "react";
import type { IPosition } from "#/interfaces.ts";
interface IProps {
gamePgn: string;
handlePlyChange: ({ ply, fen }: IPosition) => void;
}
const PgnViewer = ({ gamePgn }: IProps) => {
const PgnViewer = ({ gamePgn, handlePlyChange }: IProps) => {
const containerRef = useRef<HTMLDivElement | null>(null);
const viewerRef = useRef<PgnViewerType | null>(null);
const viewerRef = useRef(null);
// TODO make thin, and pull functions out
useEffect(() => {
const element: HTMLElement | null = document.querySelector(".lpv-board");
if (!element) return;
@@ -19,12 +21,65 @@ const PgnViewer = ({ gamePgn }: IProps) => {
scrollToMove: false,
});
// set initial position - TODO change to on file load
// handlePlyChange({
// ply: 0,
// fen: viewerRef.current?.curData().fen,
// });
const boardButtons = document.querySelectorAll(".lpv__controls");
const movesList = document.querySelectorAll(".lpv__moves");
if (!boardButtons || !movesList) return;
const clickHandlerDelay = () => {
setTimeout(() => handleClick(), 250);
};
// Hack using DOM manipulation
const boardEventListener = () => {
const variationTags = document.querySelector("variation");
const moves = [...document.querySelectorAll("move")].filter((m) => {
if (!variationTags) return m.className !== "empty";
return (
m.parentNode?.querySelector("variation") && m.className !== "empty"
);
});
const ply = moves.findIndex((m) => m.classList.contains("current")) + 1;
return { moves, ply };
};
const handleClick = () => {
const { ply } = boardEventListener();
const fen = viewerRef.current?.curData().fen;
handlePlyChange({ ply, fen });
};
boardButtons.forEach((button) => {
button.addEventListener("click", handleClick);
button.addEventListener("touchstart", handleClick);
});
movesList.forEach((move) => {
move.addEventListener("click", clickHandlerDelay);
move.addEventListener("touchstart", clickHandlerDelay);
});
return () => {
if (containerRef.current) {
containerRef.current = null;
}
boardButtons.forEach((button) => {
button.removeEventListener("click", handleClick);
button.removeEventListener("touchstart", handleClick);
});
movesList.forEach((move) => {
move.removeEventListener("click", clickHandlerDelay);
move.removeEventListener("touchstart", clickHandlerDelay);
});
};
}, [gamePgn]);
}, [gamePgn, handlePlyChange]);
return <div ref={containerRef} className="lpv-board" />;
};
+88 -3
View File
@@ -1,19 +1,87 @@
import { type ChangeEvent, useReducer, useRef } from "react";
import {
type ChangeEvent,
useCallback,
useReducer,
useRef,
useState,
} from "react";
import { toast } from "react-toastify";
import type { IPosition } from "#/interfaces.ts";
import { gameReducer, initialGameState } from "#/reducers/gameReducer.ts";
import { downloadPDF } from "#/utils/pdfUtils.ts";
import { buildPgnString, getHeaders } from "#/utils/pgnUtils.ts";
import { downloadString } from "#/utils/stringUtils.ts";
export const useChessGame = () => {
const [gameState, gameDispatch] = useReducer(gameReducer, initialGameState);
const [currentPosition, setCurrentPosition] = useState<IPosition>({
ply: 0,
fen: "",
});
const [generatingPdf, setGeneratingPdf] = useState(false);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const handlePlyChange = useCallback(({ ply, fen }: IPosition) => {
setCurrentPosition({ ply, fen });
}, []);
const handleSavePgn = () => {
const pgnString = buildPgnString(gameState);
downloadString(pgnString, "game.pgn");
};
const handleSavePdf = () => {
console.log("Save PDF");
const handleSavePdf = async () => {
try {
setGeneratingPdf(true);
const { diagrams, diagramClock } = gameState;
const pgnString = buildPgnString(gameState);
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL;
const response = await fetch(`${apiBaseUrl}/pdf`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
pgn: pgnString,
diagrams,
diagramClock,
}),
});
if (response.ok) {
downloadPDF(await response.blob());
// TODO add metrics logger - add success to analytics
toast.success("PDF successfully generated!", {
toastId: "pdf-success",
});
} else {
// TODO toast and remove console but pass to logger
const body = await response.json();
console.error("Error saving PDF:", body.error);
toast.error("An error has occurred. Please try again later.", {
toastId: "pdf-error",
});
}
} catch (error: unknown) {
// TODO add metrics / logger
console.error("Error saving PDF:", error);
if (error instanceof Error) {
toast.error("An error has occurred. Please try again later.", {
toastId: "pdf-error",
});
}
} finally {
setGeneratingPdf(false);
}
};
const handleClearGame = () => {
@@ -72,6 +140,19 @@ export const useChessGame = () => {
});
};
const handleToggleDiagram = () => {
if (!gameState.pgn) return;
const { ply, fen } = currentPosition;
if (ply === 0) return;
const exists = gameState.diagrams.some((d) => d.ply === ply);
gameDispatch(
exists
? { type: "DELETE_DIAGRAM", payload: { ply } }
: { type: "ADD_DIAGRAM", payload: { ply, fen } },
);
};
return {
gameState,
fileInputRef,
@@ -80,5 +161,9 @@ export const useChessGame = () => {
handleClearGame,
handleLoadPgn,
handleToggleClock,
handlePlyChange,
handleToggleDiagram,
currentPosition,
generatingPdf,
};
};
+28
View File
@@ -0,0 +1,28 @@
export interface IPosition {
ply: number;
fen: string;
}
export interface IHeader {
event: string;
site: string;
date: string;
round: string;
white: string;
black: string;
result: string;
eco: string;
whiteElo: string;
blackElo: string;
plyCount: string;
eventDate: string;
source: string;
[key: string]: string;
}
export interface IGameState {
pgn: string;
headers: IHeader;
diagrams: IPosition[];
diagramClock: boolean;
}
-20
View File
@@ -1,20 +0,0 @@
import { useChessGame } from "#/hooks/useChessGame.ts";
import Chessboard from "#/pages/Chessboard.tsx";
const ChessboardContainer = () => {
const chessGameProps = useChessGame();
return (
<Chessboard
gameState={chessGameProps.gameState}
handleLoadPgn={chessGameProps.handleLoadPgn}
handleClearGame={chessGameProps.handleClearGame}
handleSavePgn={chessGameProps.handleSavePgn}
handleSavePdf={chessGameProps.handleSavePdf}
handleToggleClock={chessGameProps.handleToggleClock}
fileInputRef={chessGameProps.fileInputRef}
/>
);
};
export default ChessboardContainer;
+87 -54
View File
@@ -1,28 +1,23 @@
import type { ChangeEvent, RefObject } from "react";
import LichessButton from "#/components/LichessButton.tsx";
import PgnViewer from "#/components/PgnViewer.tsx";
import Section from "#/components/Section.tsx";
import type { IGameState } from "#/reducers/gameReducer.ts";
import { useChessGame } from "#/hooks/useChessGame.ts";
interface IProps {
gameState: IGameState;
handleLoadPgn: (e: ChangeEvent<HTMLInputElement>) => void;
handleClearGame: () => void;
handleSavePgn: () => void;
handleSavePdf: () => void;
handleToggleClock: () => void;
fileInputRef: RefObject<HTMLInputElement | null>;
}
const Chessboard = () => {
const {
gameState,
fileInputRef,
currentPosition,
generatingPdf,
handleLoadPgn,
handleClearGame,
handleSavePgn,
handleSavePdf,
handleToggleClock,
handleToggleDiagram,
handlePlyChange,
} = useChessGame();
const Chessboard = ({
gameState,
handleLoadPgn,
handleClearGame,
handleSavePgn,
handleSavePdf,
handleToggleClock,
fileInputRef,
}: IProps) => {
return (
<main className="px-4 pb-8 place-self-center min-h-[calc(100vh-226px)]">
<Section title="Convert PGN to PDF">
@@ -36,43 +31,81 @@ const Chessboard = ({
/>
</Section>
<div className="flex gap-4">
<button
className="cursor-pointer"
type="button"
onClick={handleClearGame}
>
Clear Game
</button>
<div className="grid grid-cols-2 gap-4">
<div>
<PgnViewer
gamePgn={gameState.pgn || ""}
handlePlyChange={handlePlyChange}
/>
<div className="grid grid-cols-3 gap-2 max-w-150 mt-2">
<button
className="btn btn-primary"
type="button"
onClick={handleClearGame}
>
Clear Game
</button>
<div className="flex gap-2 items-center">
<label
htmlFor="diagram-add"
className="text-sm text-(--base-content)"
>
Select Diagram
</label>
<input
id="diagram-toggle"
name="diagram-toggle"
type="checkbox"
className="checkbox-accent"
checked={gameState.diagrams.some(
(d) => d.ply === currentPosition.ply,
)}
onChange={handleToggleDiagram}
/>
</div>
<button
className="cursor-pointer"
type="button"
onClick={handleSavePgn}
disabled={!gameState.pgn}
>
Save PGN
</button>
<label className="toggle text-sm text-(--base-content)">
Render move times
<input
id="render-times"
name="render-times"
type="checkbox"
checked={gameState.diagramClock}
onChange={handleToggleClock}
/>
<span className="slider round" />
</label>
</div>
</div>
<button
className="cursor-pointer"
type="button"
onClick={handleSavePdf}
disabled={!gameState.pgn}
>
Save PDF
</button>
<div className="flex gap-4">
{/*<button*/}
{/* className="btn btn-primary"*/}
{/* type="button"*/}
{/* onClick={handleClearGame}*/}
{/*>*/}
{/* Clear Game*/}
{/*</button>*/}
<button
className="btn btn-secondary"
type="button"
onClick={handleSavePgn}
disabled={!gameState.pgn}
>
Save PGN
</button>
<button
className="btn btn-secondary"
type="button"
onClick={handleSavePdf}
disabled={!gameState.pgn || generatingPdf}
>
Save PDF
</button>
</div>
</div>
<PgnViewer gamePgn={gameState.pgn || ""} />
<label htmlFor="diagram-clock">
Render Clock Times
<input
id="diagram-clock"
name="diagram-clock"
type="checkbox"
onChange={handleToggleClock}
/>
</label>
</main>
);
};
+1 -4
View File
@@ -23,10 +23,7 @@ const Home = () => (
Upload and convert your games into a book format, along with variation
and annotations.
</h2>
<Link
to="/chessboard"
className="rounded-3xl text-xl font-bold btn btn-primary"
>
<Link to="/chessboard" className="btn btn-primary btn-large">
Get Started
</Link>
</div>
+3 -30
View File
@@ -1,37 +1,10 @@
interface IDiagrams {
ply: number;
fen: string;
}
export interface IHeader {
event: string;
site: string;
date: string;
round: string;
white: string;
black: string;
result: string;
eco: string;
whiteElo: string;
blackElo: string;
plyCount: string;
eventDate: string;
source: string;
[key: string]: string;
}
export interface IGameState {
pgn: string;
headers: IHeader;
diagrams: IDiagrams[];
diagramClock: boolean;
}
import type { IGameState, IHeader, IPosition } from "#/interfaces.ts";
type GameAction =
| { type: "SET_GAME"; payload: { pgn: string; headers: IHeader } }
| { type: "CLEAR_GAME" }
| { type: "SET_HEADERS"; payload: IHeader }
| { type: "ADD_DIAGRAM"; payload: IDiagrams }
| { type: "ADD_DIAGRAM"; payload: IPosition }
| { type: "DELETE_DIAGRAM"; payload: { ply: number } }
| { type: "TOGGLE_DIAGRAM_CLOCK" };
@@ -86,7 +59,7 @@ export const gameReducer = (state: IGameState, action: GameAction) => {
return {
...state,
diagrams: state.diagrams.filter(
(diagram: IDiagrams) => diagram.ply !== action.payload.ply,
(diagram: IPosition) => diagram.ply !== action.payload.ply,
),
};
case "TOGGLE_DIAGRAM_CLOCK":
+2
View File
@@ -2,6 +2,7 @@ import { TanStackDevtools } from "@tanstack/react-devtools";
import { createRootRoute, HeadContent, Scripts } from "@tanstack/react-router";
import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools";
import { type ReactNode, Suspense } from "react";
import { ToastContainer } from "react-toastify";
import { MatomoAnalytics } from "#/components/MatomoAnalytics.tsx";
import Footer from "../components/Footer";
import Header from "../components/Header";
@@ -40,6 +41,7 @@ function RootDocument({ children }: { children: ReactNode }) {
<body className="font-sans antialiased">
<Header />
{children}
<ToastContainer position="bottom-right" />
<Footer />
<TanStackDevtools
config={{
+2 -2
View File
@@ -1,10 +1,10 @@
import { createFileRoute } from "@tanstack/react-router";
import ChessboardContainer from "#/pages/Chessboard.container.tsx";
import Chessboard from "#/pages/Chessboard.tsx";
export const Route = createFileRoute("/chessboard")({
component: RouteComponent,
});
function RouteComponent() {
return <ChessboardContainer />;
return <Chessboard />;
}
+2
View File
@@ -1,5 +1,7 @@
@import "tailwindcss";
@import "styles/_accordion.css";
@import "./styles/_input.css";
@import "./styles/_button.css";
@import "./styles/_file-input.css";
@import './styles/_pgn-viewer.css';
+46
View File
@@ -0,0 +1,46 @@
.btn {
display: inline-flex;
font-size: 1rem;
font-weight: bold;
cursor: pointer;
border-radius: 0.5rem;
height: 50px;
align-items: center;
justify-items: center;
transition: all 0.3s ease;
&:disabled {
cursor: default;
pointer-events: none;
opacity: 0.5;
}
}
.btn-large {
font-size: 1.25rem;
padding: 1rem 4rem;
border-radius: 1.5rem;
height: auto;
}
.btn-primary {
background-color: var(--accent);
color: var(--bg-base);
&:hover {
background-color: var(--bg-base);
color: var(--accent);
}
}
.btn-secondary {
background-color: var(--bg-base);
color: var(--accent);
border: 1px solid var(--accent);
&:hover {
background-color: var(--accent);
color: var(--bg-base);
}
}
+66
View File
@@ -0,0 +1,66 @@
/* Checkbox */
.checkbox-accent {
accent-color: var(--accent);
width: 18px;
height: 18px;
cursor: pointer;
}
/* Toggle */
.toggle {
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.toggle input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: relative;
cursor: pointer;
width: 36px;
height: 20px;
background-color: #ccc;
-webkit-transition: 0.4s;
transition: 0.4s;
flex-shrink: 0;
}
.slider:before {
position: absolute;
content: "";
height: 14px;
width: 14px;
left: 3px;
bottom: 3px;
background-color: white;
-webkit-transition: 0.4s;
transition: 0.4s;
}
input:checked + .slider {
background-color: var(--accent);
}
input:focus + .slider {
box-shadow: 0 0 1px var(--accent);
}
input:checked + .slider:before {
-webkit-transform: translateX(16px);
-ms-transform: translateX(16px);
transform: translateX(16px);
}
.slider.round {
border-radius: 12px;
}
.slider.round:before {
border-radius: 50%;
}
+1 -1
View File
@@ -1,4 +1,4 @@
import type { IGameState } from "#/reducers/gameReducer.ts";
import type { IGameState } from "#/interfaces.ts";
export const getHeaders = (pgn: string) => {
const pgnHeader = pgn.split(/\n\n/g)[0];