diff --git a/package-lock.json b/package-lock.json
index eedca5c7..3efe66e1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -22,6 +22,7 @@
"firebase": "^11.1.0",
"idb": "^8.0.1",
"jotai": "^2.11.0",
+ "lz-string": "^1.5.0",
"next": "15.2.4",
"react": "18.3.1",
"react-chessboard": "^4.7.3",
@@ -31,6 +32,7 @@
},
"devDependencies": {
"@tanstack/eslint-plugin-query": "^5.74.7",
+ "@types/lz-string": "^1.3.34",
"@types/node": "^22.10.2",
"@types/react": "18.2.11",
"@types/react-dom": "^18.3.5",
@@ -3804,6 +3806,13 @@
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
"dev": true
},
+ "node_modules/@types/lz-string": {
+ "version": "1.3.34",
+ "resolved": "https://registry.npmjs.org/@types/lz-string/-/lz-string-1.3.34.tgz",
+ "integrity": "sha512-j6G1e8DULJx3ONf6NdR5JiR2ZY3K3PaaqiEuKYkLQO0Czfi1AzrtjfnfCROyWGeDd5IVMKCwsgSmMip9OWijow==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/mysql": {
"version": "2.15.26",
"resolved": "https://registry.npmjs.org/@types/mysql/-/mysql-2.15.26.tgz",
@@ -8373,6 +8382,15 @@
"yallist": "^3.0.2"
}
},
+ "node_modules/lz-string": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
+ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
+ "license": "MIT",
+ "bin": {
+ "lz-string": "bin/bin.js"
+ }
+ },
"node_modules/magic-string": {
"version": "0.30.17",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
diff --git a/package.json b/package.json
index 5c51c6b8..e822bc3a 100644
--- a/package.json
+++ b/package.json
@@ -24,6 +24,7 @@
"firebase": "^11.1.0",
"idb": "^8.0.1",
"jotai": "^2.11.0",
+ "lz-string": "^1.5.0",
"next": "15.2.4",
"react": "18.3.1",
"react-chessboard": "^4.7.3",
@@ -33,6 +34,7 @@
},
"devDependencies": {
"@tanstack/eslint-plugin-query": "^5.74.7",
+ "@types/lz-string": "^1.3.34",
"@types/node": "^22.10.2",
"@types/react": "18.2.11",
"@types/react-dom": "^18.3.5",
diff --git a/src/lib/shareGame.ts b/src/lib/shareGame.ts
new file mode 100644
index 00000000..5b123d33
--- /dev/null
+++ b/src/lib/shareGame.ts
@@ -0,0 +1,25 @@
+import {
+ compressToEncodedURIComponent,
+ decompressFromEncodedURIComponent,
+} from "lz-string";
+
+export const compressPgn = (pgn: string): string =>
+ compressToEncodedURIComponent(pgn);
+
+export const decompressPgn = (compressed: string): string | null => {
+ try {
+ const result = decompressFromEncodedURIComponent(compressed);
+ return result || null;
+ } catch {
+ return null;
+ }
+};
+
+export const buildShareUrl = (pgn: string, orientation?: boolean): string => {
+ const compressed = compressPgn(pgn);
+ const params = new URLSearchParams({ pgn: compressed });
+ if (orientation === false) {
+ params.set("orientation", "black");
+ }
+ return `${window.location.origin}${window.location.pathname}?${params.toString()}`;
+};
diff --git a/src/sections/analysis/panelHeader/loadGame.tsx b/src/sections/analysis/panelHeader/loadGame.tsx
index 30cd657c..260077c1 100644
--- a/src/sections/analysis/panelHeader/loadGame.tsx
+++ b/src/sections/analysis/panelHeader/loadGame.tsx
@@ -14,6 +14,7 @@ import { Chess } from "chess.js";
import { useRouter } from "next/router";
import { GameEval } from "@/types/eval";
import { fetchLichessGame } from "@/lib/lichess";
+import { decompressPgn } from "@/lib/shareGame";
export default function LoadGame() {
const router = useRouter();
@@ -41,7 +42,11 @@ export default function LoadGame() {
[joinedGameHistory, resetBoard, setGamePgn, setEval, setBoardOrientation]
);
- const { lichessGameId, orientation: orientationParam } = router.query;
+ const {
+ lichessGameId,
+ orientation: orientationParam,
+ pgn: pgnParam,
+ } = router.query;
useEffect(() => {
const handleLichess = async (id: string) => {
@@ -58,8 +63,19 @@ export default function LoadGame() {
resetAndSetGamePgn(gameFromUrl.pgn, orientation, gameFromUrl.eval);
} else if (typeof lichessGameId === "string" && !!lichessGameId) {
handleLichess(lichessGameId);
+ } else if (typeof pgnParam === "string" && !!pgnParam) {
+ const decompressed = decompressPgn(pgnParam);
+ if (decompressed) {
+ resetAndSetGamePgn(decompressed, orientationParam !== "black");
+ }
}
- }, [gameFromUrl, lichessGameId, orientationParam, resetAndSetGamePgn]);
+ }, [
+ gameFromUrl,
+ lichessGameId,
+ orientationParam,
+ pgnParam,
+ resetAndSetGamePgn,
+ ]);
useEffect(() => {
const eventHandler = (event: MessageEvent) => {
diff --git a/src/sections/analysis/panelToolbar/index.tsx b/src/sections/analysis/panelToolbar/index.tsx
index 827d64ce..ac73ee1e 100644
--- a/src/sections/analysis/panelToolbar/index.tsx
+++ b/src/sections/analysis/panelToolbar/index.tsx
@@ -7,6 +7,7 @@ import FlipBoardButton from "./flipBoardButton";
import NextMoveButton from "./nextMoveButton";
import GoToLastPositionButton from "./goToLastPositionButton";
import SaveButton from "./saveButton";
+import ShareButton from "./shareButton";
import { useEffect } from "react";
export default function PanelToolBar() {
@@ -80,6 +81,8 @@ export default function PanelToolBar() {
+
+
);
diff --git a/src/sections/analysis/panelToolbar/shareButton.tsx b/src/sections/analysis/panelToolbar/shareButton.tsx
new file mode 100644
index 00000000..cc5114d6
--- /dev/null
+++ b/src/sections/analysis/panelToolbar/shareButton.tsx
@@ -0,0 +1,68 @@
+import { Icon } from "@iconify/react";
+import {
+ Alert,
+ Grid2 as Grid,
+ IconButton,
+ Snackbar,
+ Tooltip,
+} from "@mui/material";
+import { useAtomValue } from "jotai";
+import { useState } from "react";
+import { boardAtom, boardOrientationAtom, gameAtom } from "../states";
+import { getGameToSave } from "@/lib/chess";
+import { buildShareUrl } from "@/lib/shareGame";
+
+export default function ShareButton() {
+ const game = useAtomValue(gameAtom);
+ const board = useAtomValue(boardAtom);
+ const orientation = useAtomValue(boardOrientationAtom);
+ const [snackbar, setSnackbar] = useState<{
+ open: boolean;
+ message: string;
+ severity: "success" | "warning";
+ }>({ open: false, message: "", severity: "success" });
+
+ const hasGame = game.history().length > 0 || board.history().length > 0;
+
+ const handleShare = () => {
+ const gameToShare = getGameToSave(game, board);
+ const url = buildShareUrl(gameToShare.pgn(), orientation);
+
+ navigator.clipboard?.writeText?.(url);
+ setSnackbar({
+ open: true,
+ message: "Link copied to clipboard!",
+ severity: "success",
+ });
+ };
+
+ return (
+ <>
+
+
+
+
+
+
+
+ setSnackbar((s) => ({ ...s, open: false }))}
+ anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
+ >
+
+ {snackbar.message}
+
+
+ >
+ );
+}