save as PGN, reuse clear game handler, get headers

This commit is contained in:
2026-06-01 15:18:10 +02:00
parent 38abf30ecc
commit 324e4eac31
5 changed files with 162 additions and 17 deletions
+13 -12
View File
@@ -1,13 +1,16 @@
import { type ChangeEvent, useReducer, useRef } from "react"; import { type ChangeEvent, useReducer, useRef } from "react";
import Chessboard from "#/pages/Chessboard.tsx"; import Chessboard from "#/pages/Chessboard.tsx";
import { gameReducer, initialGameState } from "#/reducers/gameReducer.ts"; import { gameReducer, initialGameState } from "#/reducers/gameReducer.ts";
import { buildPgnString, getHeaders } from "#/utils/pgnUtils.ts";
import { downloadString } from "#/utils/stringUtils.ts";
const ChessboardContainer = () => { const ChessboardContainer = () => {
const [gameState, gameDispatch] = useReducer(gameReducer, initialGameState); const [gameState, gameDispatch] = useReducer(gameReducer, initialGameState);
const fileInputRef = useRef<HTMLInputElement | null>(null); const fileInputRef = useRef<HTMLInputElement | null>(null);
const handleSavePgn = () => { const handleSavePgn = () => {
console.log("Save PGN"); const pgnString = buildPgnString(gameState);
downloadString(pgnString, "game.pgn");
}; };
const handleSavePdf = () => { const handleSavePdf = () => {
@@ -44,25 +47,23 @@ const ChessboardContainer = () => {
} }
if (pgnString) { if (pgnString) {
const headers = {
...getHeaders(pgnString),
title: "",
subtitle: "",
};
gameDispatch({ gameDispatch({
type: "SET_GAME", type: "SET_GAME",
payload: { pgn: pgnString, headers: "" }, payload: { pgn: pgnString, headers: headers },
}); });
} else { } else {
gameDispatch({ e.target.files = null;
type: "CLEAR_GAME", handleClearGame();
});
} }
}; };
reader.readAsText(selectedFile); reader.readAsText(selectedFile);
} else { } else {
if (fileInputRef.current) { handleClearGame();
fileInputRef.current.value = "";
}
e.target.files = null;
gameDispatch({
type: "CLEAR_GAME",
});
} }
}; };
+67 -5
View File
@@ -3,20 +3,60 @@ interface IDiagrams {
fen: string; 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 { export interface IGameState {
pgn: string; pgn: string;
headers: string; headers: IHeader;
diagrams: IDiagrams[]; diagrams: IDiagrams[];
diagramClock: boolean; diagramClock: boolean;
} }
type GameAction = type GameAction =
| { type: "SET_GAME"; payload: { pgn: string; headers: "" } } | { type: "SET_GAME"; payload: { pgn: string; headers: "" } }
| { type: "CLEAR_GAME" }; | { type: "CLEAR_GAME" }
| { type: "SET_HEADERS"; payload: IHeader }
| { type: "ADD_DIAGRAM"; payload: IDiagrams }
| { type: "DELETE_DIAGRAM"; payload: { ply: number } }
| { type: "TOGGLE_DIAGRAM_CLOCK"; payload: boolean };
export const initialGameState = { const defaultHeaderFields = {
event: "",
site: "",
date: "",
round: "",
white: "",
black: "",
result: "",
eco: "",
whiteElo: "",
blackElo: "",
plyCount: "",
eventDate: "",
source: "",
title: "",
subtitle: "",
author: "",
} satisfies IHeader;
export const initialGameState: IGameState = {
pgn: "", pgn: "",
headers: "", headers: defaultHeaderFields,
diagrams: [], diagrams: [],
diagramClock: false, diagramClock: false,
}; };
@@ -26,12 +66,34 @@ export const gameReducer = (state: IGameState, action: GameAction) => {
case "SET_GAME": case "SET_GAME":
return { return {
pgn: action.payload.pgn, pgn: action.payload.pgn,
headers: "", headers: action.payload.headers,
diagrams: [], diagrams: [],
diagramClock: false, diagramClock: false,
}; };
case "CLEAR_GAME": case "CLEAR_GAME":
return initialGameState; return initialGameState;
case "SET_HEADERS":
return {
...state,
headers: action.payload,
};
case "ADD_DIAGRAM":
return {
...state,
diagrams: [...state.diagrams, action.payload],
};
case "DELETE_DIAGRAM":
return {
...state,
diagrams: state.diagrams.filter(
(diagram: IDiagrams) => diagram.ply !== action.payload.ply,
),
};
case "TOGGLE_DIAGRAM_CLOCK":
return {
...state,
diagramClock: action.payload,
};
default: default:
return state; return state;
} }
+8
View File
@@ -0,0 +1,8 @@
export const downloadPDF = (pdf: Blob) => {
const fileName = "game.pdf";
const fileURL = URL.createObjectURL(pdf);
const a = document.createElement("a");
a.href = fileURL;
a.download = fileName;
a.click();
};
+61
View File
@@ -0,0 +1,61 @@
import type { IGameState } from "#/reducers/gameReducer.ts";
export const getHeaders = (pgn: string) => {
const pgnHeader = pgn.split(/\n\n/g)[0];
const getHeaderField = (field: string) => {
const regex = new RegExp(`(?<=${field}.").*(?=")`);
const match = pgnHeader.match(regex);
return match && match[0] !== "undefined" ? match[0] : "";
};
return {
event: getHeaderField("Event"),
site: getHeaderField("Site"),
date: getHeaderField("Date"),
round: getHeaderField("Round"),
white: getHeaderField("White"),
black: getHeaderField("Black"),
result: getHeaderField("Result"),
eco: getHeaderField("ECO"),
whiteElo: getHeaderField("WhiteElo"),
blackElo: getHeaderField("BlackElo"),
plyCount: getHeaderField("PlyCount"),
eventDate: getHeaderField("EventDate"),
source: getHeaderField("Source"),
};
};
// Create a string based on new header values
export const buildPgnString = (game: IGameState) => {
const moves = game.pgn?.split(/\n\n/g)[1];
// if customer headers are present, use them
if (
game.headers.title?.length > 0 ||
game.headers.subtitle?.length > 0 ||
game.headers.author?.length > 0
) {
return `[Result "${game.headers.result}"]
[Title "${game.headers.title}"]
[Subtitle "${game.headers.subtitle}"]
[Date "${game.headers.date}"]
[Author "${game.headers.author}"]\n
${moves}`;
}
return `[Event "${game.headers.event}"]
[Site "${game.headers.site}"]
[Date "${game.headers.date}"]
[Round "${game.headers.round}"]
[White "${game.headers.white}"]
[Black "${game.headers.black}"]
[Result "${game.headers.result}"]
[ECO "${game.headers.eco}"]
[WhiteElo "${game.headers.whiteElo}"]
[BlackElo "${game.headers.blackElo}"]
[PlyCount "${game.headers.plyCount}"]
[EventDate "${game.headers.eventDate}"]
[Source "${game.headers.source}"]\n
${moves}`;
};
+13
View File
@@ -0,0 +1,13 @@
export const downloadString = (string: string, filename: string) => {
try {
const element = document.createElement("a");
const file = new Blob([string], { type: "text/plain" });
element.href = URL.createObjectURL(file);
element.download = filename;
document.body.appendChild(element);
element.click();
} catch (error) {
throw new Error("Failed to download file");
}
};