Add game download feature and fix missing moves in history

- Add download button to game history section with modal
- Support downloading game as PGN or current position as FEN
- Add translations for download feature in all 5 languages (EN, DE, FR, IT, PL)
- Fix bug where first moves were missing from history: now wait for evalP0 (pre-analysis) before allowing player moves
- This ensures all moves are properly tracked with evaluations and tactics detection
This commit is contained in:
Stefan
2025-12-01 12:19:29 +01:00
parent 870efbbff7
commit 21f11ce1dd
2 changed files with 109 additions and 7 deletions
+79 -1
View File
@@ -13,7 +13,7 @@ import { SupportedLanguage } from "@/lib/i18n/translations";
import { lookupOpening, lookupPossibleOpenings, extractMoveSequenceFromPGN, OpeningMetadata } from "@/lib/openings";
import { GameAnalysisModal } from "./GameAnalysisModal";
import { GameOverModal, MoveHistoryItem } from "./GameOverModal";
import { Brain, ArrowLeft } from "lucide-react";
import { Brain, ArrowLeft, Download } from "lucide-react";
import { CapturedPieces } from "./CapturedPieces";
import { detectMissedTactics, uciToSan, DetectedTactic } from "@/lib/tacticDetection";
import { upsertSavedGame } from "@/lib/savedGames";
@@ -64,6 +64,7 @@ export default function ChessGame({ gameId, initialFen, initialPgn, initialPerso
// Game State
const [playerColor, setPlayerColor] = useState<'white' | 'black'>(initialColor);
const [showAnalysisModal, setShowAnalysisModal] = useState(false);
const [showDownloadModal, setShowDownloadModal] = useState(false);
const [gameOverState, setGameOverState] = useState<{ result: string, winner: "White" | "Black" | "Draw" } | null>(null);
const [moveHistory, setMoveHistory] = useState<MoveHistoryItem[]>([]);
const [selectedPersonality, setSelectedPersonality] = useState<Personality>(initialPersonality);
@@ -290,6 +291,13 @@ export default function ChessGame({ gameId, initialFen, initialPgn, initialPerso
return false;
}
// Wait for pre-analysis (evalP0) to be available before allowing moves
// This ensures we can properly track move history with evaluations
if (!evalP0) {
console.log("Waiting for position analysis before move...");
return false;
}
const move = {
from: sourceSquare,
to: targetSquare,
@@ -468,6 +476,34 @@ export default function ChessGame({ gameId, initialFen, initialPgn, initialPerso
updateCapturedPieces();
};
const handleDownloadPGN = () => {
const pgn = gameRef.current.pgn();
const blob = new Blob([pgn], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `chess-game-${Date.now()}.pgn`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
setShowDownloadModal(false);
};
const handleDownloadFEN = () => {
const fen = gameRef.current.fen();
const blob = new Blob([fen], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `chess-position-${Date.now()}.fen`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
setShowDownloadModal(false);
};
// Determine material advantage
// If Black lost more value, White has advantage
const whiteAdvantage = materialScore.black - materialScore.white;
@@ -627,6 +663,13 @@ export default function ChessGame({ gameId, initialFen, initialPgn, initialPerso
<div className="md:col-span-3 bg-white dark:bg-gray-800 p-4 rounded-lg shadow-lg flex flex-col">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">Game History</h3>
<div className="flex gap-2">
<button
onClick={() => setShowDownloadModal(true)}
className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded hover:bg-green-200 dark:bg-green-900 dark:text-green-200 flex items-center gap-1"
>
<Download size={12} /> Download
</button>
<button
onClick={() => setShowAnalysisModal(true)}
className="text-xs bg-purple-100 text-purple-700 px-2 py-1 rounded hover:bg-purple-200 dark:bg-purple-900 dark:text-purple-200 flex items-center gap-1"
@@ -634,6 +677,7 @@ export default function ChessGame({ gameId, initialFen, initialPgn, initialPerso
<Brain size={12} /> Analyze
</button>
</div>
</div>
<div className="overflow-y-auto border border-gray-200 dark:border-gray-700 rounded bg-gray-50 dark:bg-gray-900 p-2 max-h-40">
<table className="w-full text-sm text-left">
<thead>
@@ -715,6 +759,40 @@ export default function ChessGame({ gameId, initialFen, initialPgn, initialPerso
onNewGame={handleNewGame}
/>
)}
{showDownloadModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl max-w-md w-full p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
{t.analysis.downloadTitle}
</h2>
<button
onClick={() => setShowDownloadModal(false)}
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
</button>
</div>
<div className="space-y-3">
<button
onClick={handleDownloadPGN}
className="w-full py-3 px-4 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium shadow-md transition-all flex items-center justify-center gap-2"
>
<Download size={18} />
{t.analysis.downloadPGN}
</button>
<button
onClick={handleDownloadFEN}
className="w-full py-3 px-4 bg-green-600 text-white rounded-lg hover:bg-green-700 font-medium shadow-md transition-all flex items-center justify-center gap-2"
>
<Download size={18} />
{t.analysis.downloadFEN}
</button>
</div>
</div>
</div>
)}
</>
);
}
+24
View File
@@ -130,6 +130,10 @@ export interface Translations {
noApiKey: string;
askFollowUp: string;
possibleOpenings: string;
downloadGame: string;
downloadPGN: string;
downloadFEN: string;
downloadTitle: string;
};
// API Key Input
@@ -293,6 +297,10 @@ const en: Translations = {
noApiKey: 'Please add an API key in settings to get opening explanations.',
askFollowUp: 'Ask a follow-up question about this opening...',
possibleOpenings: 'possible openings',
downloadGame: 'Download Game',
downloadPGN: 'Download as PGN',
downloadFEN: 'Download Current Position (FEN)',
downloadTitle: 'Export Game',
},
apiKeyInput: {
title: 'API Key Required',
@@ -457,6 +465,10 @@ const de: Translations = {
noApiKey: 'Bitte fügen Sie in den Einstellungen einen API-Schlüssel hinzu.',
askFollowUp: 'Stellen Sie eine Folgefrage zu dieser Eröffnung...',
possibleOpenings: 'mögliche Eröffnungen',
downloadGame: 'Partie herunterladen',
downloadPGN: 'Als PGN herunterladen',
downloadFEN: 'Aktuelle Position herunterladen (FEN)',
downloadTitle: 'Partie exportieren',
},
apiKeyInput: {
title: 'API-Schlüssel erforderlich',
@@ -621,6 +633,10 @@ const fr: Translations = {
noApiKey: 'Veuillez ajouter une clé API dans les paramètres.',
askFollowUp: 'Posez une question sur cette ouverture...',
possibleOpenings: 'ouvertures possibles',
downloadGame: 'Télécharger la partie',
downloadPGN: 'Télécharger en PGN',
downloadFEN: 'Télécharger la position actuelle (FEN)',
downloadTitle: 'Exporter la partie',
},
apiKeyInput: {
title: 'Clé API requise',
@@ -785,6 +801,10 @@ const it: Translations = {
noApiKey: 'Aggiungi una chiave API nelle impostazioni.',
askFollowUp: 'Fai una domanda su questa apertura...',
possibleOpenings: 'aperture possibili',
downloadGame: 'Scarica partita',
downloadPGN: 'Scarica come PGN',
downloadFEN: 'Scarica posizione attuale (FEN)',
downloadTitle: 'Esporta partita',
},
apiKeyInput: {
title: 'Chiave API richiesta',
@@ -949,6 +969,10 @@ const pl: Translations = {
noApiKey: 'Dodaj klucz API w ustawieniach.',
askFollowUp: 'Zadaj pytanie uzupełniające o to otwarcie...',
possibleOpenings: 'możliwe otwarcia',
downloadGame: 'Pobierz grę',
downloadPGN: 'Pobierz jako PGN',
downloadFEN: 'Pobierz aktualną pozycję (FEN)',
downloadTitle: 'Eksportuj grę',
},
apiKeyInput: {
title: 'Wymagany klucz API',