From 25687e50f1733c7accbabab04d6eddb0919534f0 Mon Sep 17 00:00:00 2001 From: patrick Date: Tue, 10 Mar 2026 14:28:08 +0800 Subject: [PATCH 1/2] feat: :sparkles: add share game via link feature --- package-lock.json | 18 +++++ package.json | 2 + src/lib/shareGame.ts | 27 +++++++ .../analysis/panelHeader/loadGame.tsx | 20 ++++- src/sections/analysis/panelToolbar/index.tsx | 3 + .../analysis/panelToolbar/shareButton.tsx | 77 +++++++++++++++++++ 6 files changed, 145 insertions(+), 2 deletions(-) create mode 100644 src/lib/shareGame.ts create mode 100644 src/sections/analysis/panelToolbar/shareButton.tsx 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..7b73d750 --- /dev/null +++ b/src/lib/shareGame.ts @@ -0,0 +1,27 @@ +import { + compressToEncodedURIComponent, + decompressFromEncodedURIComponent, +} from "lz-string"; + +export const MAX_SHARE_URL_LENGTH = 4000; + +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..123e9d52 --- /dev/null +++ b/src/sections/analysis/panelToolbar/shareButton.tsx @@ -0,0 +1,77 @@ +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, MAX_SHARE_URL_LENGTH } 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); + console.log(url.length, "length"); + if (url.length > MAX_SHARE_URL_LENGTH) { + setSnackbar({ + open: true, + message: "Game too long to share via link. Use Copy PGN instead.", + severity: "warning", + }); + return; + } + + 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} + + + + ); +} From 80c6400aba18968f4fa794e2f75f382af4a58ecb Mon Sep 17 00:00:00 2001 From: patrick Date: Tue, 10 Mar 2026 21:06:54 +0800 Subject: [PATCH 2/2] fix: remove share url length restriction --- src/lib/shareGame.ts | 2 -- src/sections/analysis/panelToolbar/shareButton.tsx | 11 +---------- 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/src/lib/shareGame.ts b/src/lib/shareGame.ts index 7b73d750..5b123d33 100644 --- a/src/lib/shareGame.ts +++ b/src/lib/shareGame.ts @@ -3,8 +3,6 @@ import { decompressFromEncodedURIComponent, } from "lz-string"; -export const MAX_SHARE_URL_LENGTH = 4000; - export const compressPgn = (pgn: string): string => compressToEncodedURIComponent(pgn); diff --git a/src/sections/analysis/panelToolbar/shareButton.tsx b/src/sections/analysis/panelToolbar/shareButton.tsx index 123e9d52..cc5114d6 100644 --- a/src/sections/analysis/panelToolbar/shareButton.tsx +++ b/src/sections/analysis/panelToolbar/shareButton.tsx @@ -10,7 +10,7 @@ import { useAtomValue } from "jotai"; import { useState } from "react"; import { boardAtom, boardOrientationAtom, gameAtom } from "../states"; import { getGameToSave } from "@/lib/chess"; -import { buildShareUrl, MAX_SHARE_URL_LENGTH } from "@/lib/shareGame"; +import { buildShareUrl } from "@/lib/shareGame"; export default function ShareButton() { const game = useAtomValue(gameAtom); @@ -27,15 +27,6 @@ export default function ShareButton() { const handleShare = () => { const gameToShare = getGameToSave(game, board); const url = buildShareUrl(gameToShare.pgn(), orientation); - console.log(url.length, "length"); - if (url.length > MAX_SHARE_URL_LENGTH) { - setSnackbar({ - open: true, - message: "Game too long to share via link. Use Copy PGN instead.", - severity: "warning", - }); - return; - } navigator.clipboard?.writeText?.(url); setSnackbar({