Merge remote changes and add evaluation bar to analysis page

This commit is contained in:
Stefan
2025-12-01 10:30:37 +01:00
8 changed files with 812 additions and 176 deletions
+20
View File
@@ -112,6 +112,7 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -699,6 +700,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -722,6 +724,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@@ -743,6 +746,7 @@
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
@@ -2945,6 +2949,7 @@
"integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -2954,6 +2959,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.6.tgz",
"integrity": "sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -2964,6 +2970,7 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -3051,6 +3058,7 @@
"integrity": "sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.47.0",
"@typescript-eslint/types": "8.47.0",
@@ -3587,6 +3595,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -4107,6 +4116,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.25",
"caniuse-lite": "^1.0.30001754",
@@ -5085,6 +5095,7 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -5270,6 +5281,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -7080,6 +7092,7 @@
"integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@jest/core": "30.2.0",
"@jest/types": "30.2.0",
@@ -8066,6 +8079,7 @@
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"cssstyle": "^4.2.1",
"data-urls": "^5.0.0",
@@ -10119,6 +10133,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -10146,6 +10161,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -11307,6 +11323,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -11498,6 +11515,7 @@
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
@@ -11688,6 +11706,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -12420,6 +12439,7 @@
"integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
+11 -2
View File
@@ -1,4 +1,5 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { DebugProvider } from "@/contexts/DebugContext";
jest.mock("next/navigation", () => ({
useRouter: jest.fn(() => ({
@@ -83,7 +84,11 @@ describe("AnalysisPage", () => {
`;
const loadGame = () => {
render(<AnalysisPage />);
render(
<DebugProvider>
<AnalysisPage />
</DebugProvider>
);
const textarea = screen.getByPlaceholderText(/Paste PGN or FEN here/i);
fireEvent.change(textarea, { target: { value: samplePgn } });
fireEvent.click(screen.getByText(/Start Analysis/i));
@@ -125,7 +130,11 @@ describe("AnalysisPage", () => {
});
it("shows an error when the notation cannot be parsed", () => {
render(<AnalysisPage />);
render(
<DebugProvider>
<AnalysisPage />
</DebugProvider>
);
const textarea = screen.getByPlaceholderText(/Paste PGN or FEN here/i);
fireEvent.change(textarea, { target: { value: "invalid" } });
fireEvent.click(screen.getByText(/Start Analysis/i));
+377 -160
View File
@@ -3,7 +3,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Chess } from "chess.js";
import { Chessboard } from "react-chessboard";
import { Brain, ChevronLeft, ChevronRight, Loader2, ArrowLeft, Download } from "lucide-react";
import { Brain, ChevronLeft, ChevronRight, Loader2, ArrowLeft, Download, PlayCircle, Upload, RotateCcw } from "lucide-react";
import { useRouter } from "next/navigation";
import Header from "@/components/Header";
@@ -20,6 +20,7 @@ import ReactMarkdown from "react-markdown";
import { useDebug } from "@/contexts/DebugContext";
import { GameImportModal } from "@/components/GameImportModal";
import { EvaluationBar } from "@/components/EvaluationBar";
import { OpeningsModal } from "@/components/OpeningsModal";
interface MoveStep {
san: string;
@@ -65,14 +66,35 @@ export default function AnalysisPage() {
const [comments, setComments] = useState<Record<number, string>>({});
const [chatSession, setChatSession] = useState<ChatSession | null>(null);
const [showImportModal, setShowImportModal] = useState(false);
const [showPlayModal, setShowPlayModal] = useState(false);
const [showOpeningsModal, setShowOpeningsModal] = useState(false);
const [playPersonality, setPlayPersonality] = useState<Personality>(PERSONALITIES[0]);
const [playColor, setPlayColor] = useState<"white" | "black">("white");
const [playStrength, setPlayStrength] = useState(15);
useEffect(() => {
const storedKey = localStorage.getItem("gemini_api_key");
const storedLang = localStorage.getItem("chess_tutor_language");
if (storedKey) setApiKey(storedKey);
if (storedLang) setLanguage(storedLang as SupportedLanguage);
// Check for pending analysis from saved game
const pendingAnalysis = localStorage.getItem("chess_tutor_pending_analysis");
if (pendingAnalysis) {
localStorage.removeItem("chess_tutor_pending_analysis");
setInput(pendingAnalysis);
setDetectedFormat(detectChessFormat(pendingAnalysis));
// Load the game after a short delay to ensure stockfish is ready
setTimeout(() => {
loadGameFromPgnOrFen(pendingAnalysis);
}, 100);
}
}, []);
useEffect(() => {
setPlayPersonality(selectedPersonality);
}, [selectedPersonality]);
useEffect(() => {
const sf = new Stockfish();
setStockfish(sf);
@@ -219,6 +241,31 @@ IMPORTANT:
loadGameFromPgnOrFen(pgn);
};
const handleResetAnalysis = () => {
setInput("");
setDetectedFormat(null);
setSteps([]);
setCurrentIndex(0);
setStepDetails({});
setComments({});
setInitialFen(DEFAULT_START);
evaluationCache.current = {};
setEvaluationVersion(v => v + 1);
setError(null);
};
const handleStartGameFromPosition = () => {
const payload = {
fen: currentFen,
personalityId: playPersonality.id,
color: playColor,
stockfishDepth: playStrength,
};
localStorage.setItem("chess_tutor_pending_game", JSON.stringify(payload));
router.push("/");
};
useEffect(() => {
if (!stockfish || !currentFen) return;
ensureEvaluation(currentFen);
@@ -381,90 +428,137 @@ INSTRUCTIONS:
<main className="flex-grow w-full flex justify-center px-4 py-8">
<div className="w-full max-w-6xl space-y-8">
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg p-6 md:p-8 border border-gray-200 dark:border-gray-700">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
<Brain className="text-purple-600" /> {t.analysis.modeTitle}
</h1>
<p className="text-gray-600 dark:text-gray-300 mt-2">{t.analysis.modeDescription}</p>
{/* Phase 1: Import View - shown when no game is loaded */}
{steps.length === 0 && (
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg p-6 md:p-8 border border-gray-200 dark:border-gray-700">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
<Brain className="text-purple-600" /> {t.analysis.modeTitle}
</h1>
<p className="text-gray-600 dark:text-gray-300 mt-2">{t.analysis.modeDescription}</p>
</div>
</div>
<div className="flex items-center gap-3">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">{t.analysis.orientation}</label>
<select
value={orientation}
onChange={(e) => setOrientation(e.target.value as "white" | "black")}
className="p-2 rounded-lg border border-gray-300 dark:border-gray-600 dark:bg-gray-700"
>
<option value="white">White</option>
<option value="black">Black</option>
</select>
<div className="mt-6 grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-4">
<label className="block text-sm font-semibold text-gray-700 dark:text-gray-300">{t.analysis.pasteLabel}</label>
<textarea
value={input}
onChange={(e) => handleInputChange(e.target.value)}
placeholder={t.analysis.pastePlaceholder}
className="w-full p-3 border rounded-lg dark:bg-gray-700 dark:border-gray-600 font-mono text-sm min-h-[180px]"
/>
{detectedFormat && (
<p className="text-xs text-gray-500">Detected: {detectedFormat.toUpperCase()}</p>
)}
{error && <p className="text-sm text-red-500">{error}</p>}
<div className="space-y-2">
<p className="text-sm font-semibold text-gray-700 dark:text-gray-300">{t.analysis.chooseCoach}</p>
<div className="grid grid-cols-2 gap-2">
{PERSONALITIES.map(p => (
<button
key={p.id}
onClick={() => setSelectedPersonality(p)}
className={`p-3 rounded-lg border flex items-center gap-2 ${selectedPersonality.id === p.id
? "border-purple-500 bg-purple-50 dark:bg-purple-900/20"
: "border-gray-200 dark:border-gray-700"}`}
>
<span className="text-xl">{p.image}</span>
<span className="text-sm text-left text-gray-800 dark:text-gray-100">{p.name}</span>
</button>
))}
</div>
</div>
<button
onClick={handleLoadGame}
className="w-full py-3 bg-purple-600 text-white rounded-xl hover:bg-purple-700 font-semibold shadow-lg"
>
{t.analysis.startButton}
</button>
{/* Import from Online Platforms */}
<div className="pt-4 border-t border-gray-200 dark:border-gray-700">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-2 text-center">
Or import from online platforms
</p>
<div className="grid grid-cols-2 gap-2">
<button
onClick={() => setShowImportModal(true)}
className="py-2 px-3 bg-green-600 text-white rounded-lg hover:bg-green-700 font-medium text-sm flex items-center justify-center gap-2 shadow"
>
<Download size={16} />
Chess.com
</button>
<button
onClick={() => setShowImportModal(true)}
className="py-2 px-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium text-sm flex items-center justify-center gap-2 shadow"
>
<Download size={16} />
Lichess
</button>
</div>
</div>
</div>
<div className="bg-gray-50 dark:bg-gray-900 rounded-xl p-4 flex flex-col items-center justify-center border border-gray-200 dark:border-gray-700">
<div className="w-full max-w-md">
<Chessboard
options={{
position: currentFen,
boardOrientation: orientation,
allowDragging: false,
darkSquareStyle: { backgroundColor: '#779954' },
lightSquareStyle: { backgroundColor: '#e9edcc' },
animationDurationInMs: 200,
boardStyle: { borderRadius: "12px", boxShadow: "0 8px 30px rgba(0,0,0,0.12)" }
}}
/>
</div>
<p className="mt-4 text-sm text-gray-500 dark:text-gray-400 text-center">
{t.analysis.currentPosition}
</p>
</div>
</div>
</div>
)}
<div className="mt-6 grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-4">
<label className="block text-sm font-semibold text-gray-700 dark:text-gray-300">{t.analysis.pasteLabel}</label>
<textarea
value={input}
onChange={(e) => handleInputChange(e.target.value)}
placeholder={t.analysis.pastePlaceholder}
className="w-full p-3 border rounded-lg dark:bg-gray-700 dark:border-gray-600 font-mono text-sm min-h-[180px]"
/>
{detectedFormat && (
<p className="text-xs text-gray-500">Detected: {detectedFormat.toUpperCase()}</p>
)}
{error && <p className="text-sm text-red-500">{error}</p>}
<div className="space-y-2">
<p className="text-sm font-semibold text-gray-700 dark:text-gray-300">{t.analysis.chooseCoach}</p>
<div className="grid grid-cols-2 gap-2">
{PERSONALITIES.map(p => (
<button
key={p.id}
onClick={() => setSelectedPersonality(p)}
className={`p-3 rounded-lg border flex items-center gap-2 ${selectedPersonality.id === p.id
? "border-purple-500 bg-purple-50 dark:bg-purple-900/20"
: "border-gray-200 dark:border-gray-700"}`}
>
<span className="text-xl">{p.image}</span>
<span className="text-sm text-left text-gray-800 dark:text-gray-100">{p.name}</span>
</button>
))}
{/* Phase 2: Analysis View - shown when game is loaded */}
{steps.length > 0 && (
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg p-6 md:p-8 border border-gray-200 dark:border-gray-700">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-6">
<div className="flex items-center gap-3">
<span className="text-2xl">{selectedPersonality.image}</span>
<div>
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
{t.analysis.modeTitle}
</h1>
<p className="text-sm text-gray-500 dark:text-gray-400">
Coach: {selectedPersonality.name}
</p>
</div>
</div>
<button
onClick={handleLoadGame}
className="w-full py-3 bg-purple-600 text-white rounded-xl hover:bg-purple-700 font-semibold shadow-lg"
>
{t.analysis.startButton}
</button>
{/* Import from Online Platforms */}
<div className="pt-4 border-t border-gray-200 dark:border-gray-700">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-2 text-center">
Or import from online platforms
</p>
<div className="grid grid-cols-2 gap-2">
<button
onClick={() => setShowImportModal(true)}
className="py-2 px-3 bg-green-600 text-white rounded-lg hover:bg-green-700 font-medium text-sm flex items-center justify-center gap-2 shadow"
>
<Download size={16} />
Chess.com
</button>
<button
onClick={() => setShowImportModal(true)}
className="py-2 px-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium text-sm flex items-center justify-center gap-2 shadow"
>
<Download size={16} />
Lichess
</button>
</div>
<div className="flex items-center gap-3">
<button
onClick={handleResetAnalysis}
className="px-3 py-2 text-sm font-medium text-gray-600 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center gap-2"
>
<RotateCcw size={16} />
{t.analysis.loadNewGame}
</button>
<select
value={orientation}
onChange={(e) => setOrientation(e.target.value as "white" | "black")}
className="p-2 rounded-lg border border-gray-300 dark:border-gray-600 dark:bg-gray-700 text-sm"
>
<option value="white">White</option>
<option value="black">Black</option>
</select>
</div>
</div>
<div className="bg-gray-50 dark:bg-gray-900 rounded-xl p-4 flex flex-col items-center gap-3 border border-gray-200 dark:border-gray-700">
<div className="w-full max-w-md">
<div className="flex flex-col items-center gap-4">
<div className="w-full max-w-lg">
<Chessboard
options={{
position: currentFen,
@@ -480,123 +574,141 @@ INSTRUCTIONS:
<div className="flex items-center gap-4">
<button
onClick={() => setCurrentIndex(i => Math.max(0, i - 1))}
className="p-2 rounded-lg bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50"
className="p-2 rounded-lg bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-600 disabled:opacity-50"
disabled={currentIndex === 0}
aria-label={t.analysis.previous}
>
<ChevronLeft size={20} />
</button>
<div className="text-sm text-gray-600 dark:text-gray-300">
<div className="text-sm text-gray-600 dark:text-gray-300 min-w-[100px] text-center">
{t.analysis.step} {currentIndex} / {steps.length}
</div>
<button
onClick={() => setCurrentIndex(i => Math.min(steps.length, i + 1))}
className="p-2 rounded-lg bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50"
className="p-2 rounded-lg bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-600 disabled:opacity-50"
disabled={currentIndex >= steps.length}
aria-label={t.analysis.next}
>
<ChevronRight size={20} />
</button>
</div>
<button
onClick={() => {
setPlayColor(orientation);
setShowPlayModal(true);
}}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg shadow hover:bg-blue-700 transition-colors"
>
<PlayCircle size={18} /> {t.analysis.playFromHere}
</button>
</div>
</div>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 bg-white dark:bg-gray-800 rounded-2xl shadow border border-gray-200 dark:border-gray-700 p-6 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-xl font-bold text-gray-900 dark:text-white">{t.analysis.title}</h2>
{possibleOpenings.length > 0 && (
<span className="text-sm text-blue-700 dark:text-blue-300">
{t.analysis.opening}: {possibleOpenings.length === 1
? `${possibleOpenings[0].name} (${possibleOpenings[0].eco})`
: `${possibleOpenings.length} possible openings`
}
</span>
)}
</div>
{currentIndex === 0 ? (
<p className="text-gray-600 dark:text-gray-300">{t.analysis.currentPosition}</p>
) : (
<div className="space-y-3">
<div className="flex flex-wrap gap-4 text-sm text-gray-700 dark:text-gray-200">
<div>
<div className="font-semibold">{t.analysis.step} {currentIndex}</div>
<div>{steps[currentIndex - 1]?.san}</div>
{/* Analysis Panels - only shown when game is loaded */}
{steps.length > 0 && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 bg-white dark:bg-gray-800 rounded-2xl shadow border border-gray-200 dark:border-gray-700 p-6 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-xl font-bold text-gray-900 dark:text-white">{t.analysis.title}</h2>
{possibleOpenings.length > 0 && (
<button
onClick={() => setShowOpeningsModal(true)}
className="text-sm text-blue-700 dark:text-blue-300 hover:text-blue-900 dark:hover:text-blue-100 hover:underline cursor-pointer transition-colors"
>
{t.analysis.opening}: {possibleOpenings.length === 1
? `${possibleOpenings[0].name} (${possibleOpenings[0].eco})`
: `${possibleOpenings.length} ${t.analysis.possibleOpenings}`
}
</button>
)}
</div>
{currentIndex === 0 ? (
<p className="text-gray-600 dark:text-gray-300">{t.analysis.currentPosition}</p>
) : (
<div className="space-y-3">
<div className="flex flex-wrap gap-4 text-sm text-gray-700 dark:text-gray-200">
<div>
<div className="font-semibold">{t.analysis.step} {currentIndex}</div>
<div>{steps[currentIndex - 1]?.san}</div>
</div>
<div>
<div className="font-semibold">{t.analysis.evaluation}</div>
<div>{formatEval(currentDetails?.evalAfter)}</div>
</div>
<div>
<div className="font-semibold">{t.analysis.cpLoss}</div>
<div>{formatCpLoss(currentDetails?.cpLoss)}</div>
</div>
<div>
<div className="font-semibold">{t.analysis.bestMove}</div>
<div>{currentDetails?.bestMoveSan || t.analysis.enginePending}</div>
</div>
</div>
<div>
<div className="font-semibold">{t.analysis.evaluation}</div>
<div>{formatEval(currentDetails?.evalAfter)}</div>
</div>
<div>
<div className="font-semibold">{t.analysis.cpLoss}</div>
<div>{formatCpLoss(currentDetails?.cpLoss)}</div>
</div>
<div>
<div className="font-semibold">{t.analysis.bestMove}</div>
<div>{currentDetails?.bestMoveSan || t.analysis.enginePending}</div>
<div className="font-semibold text-gray-800 dark:text-gray-100 mb-2">{t.analysis.missedTactics}</div>
{tacticSummary.length === 0 && (
<p className="text-sm text-gray-600 dark:text-gray-400">{t.analysis.none}</p>
)}
{tacticSummary.length > 0 && (
<ul className="list-disc pl-5 space-y-1 text-sm text-gray-700 dark:text-gray-300">
{tacticSummary.map((tactic, idx) => (
<li key={`${tactic.move}-${idx}`}>
{tactic.tactic_type}
{tactic.material_delta ? ` (~${(tactic.material_delta / 100).toFixed(1)} pawns)` : ""}
{tactic.affected_squares ? ` on ${tactic.affected_squares.join(", ")}` : ""}
</li>
))}
</ul>
)}
</div>
</div>
)}
</div>
{/* Horizontal Evaluation Bar */}
<div className="w-full">
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow border border-gray-200 dark:border-gray-700 p-6 space-y-4">
<h2 className="text-xl font-bold text-gray-900 dark:text-white">{t.analysis.aiAnalysis}</h2>
{/* Horizontal Evaluation Bar */}
{currentIndex > 0 && currentDetails?.evalAfter && (
<div className="w-full mb-4">
<EvaluationBar
score={currentDetails?.evalAfter?.score}
mate={currentDetails?.evalAfter?.mate}
score={currentDetails.evalAfter.score}
mate={currentDetails.evalAfter.mate}
isPlayerWhite={true}
orientation="horizontal"
/>
</div>
)}
<div>
<div className="font-semibold text-gray-800 dark:text-gray-100 mb-2">{t.analysis.missedTactics}</div>
{tacticSummary.length === 0 && (
<p className="text-sm text-gray-600 dark:text-gray-400">{t.analysis.none}</p>
{!apiKey && (
<p className="text-sm text-gray-600 dark:text-gray-400">Please add an API key in settings to receive commentary.</p>
)}
{currentIndex === 0 && (
<p className="text-sm text-gray-600 dark:text-gray-400">{t.analysis.currentPosition}</p>
)}
{currentIndex > 0 && (
<div className="min-h-[140px]">
{isCommenting && (
<div className="flex items-center gap-2 text-gray-600 dark:text-gray-300">
<Loader2 className="animate-spin" size={18} />
<span>{t.analysis.coachPending}</span>
</div>
)}
{tacticSummary.length > 0 && (
<ul className="list-disc pl-5 space-y-1 text-sm text-gray-700 dark:text-gray-300">
{tacticSummary.map((tactic, idx) => (
<li key={`${tactic.move}-${idx}`}>
{tactic.tactic_type}
{tactic.material_delta ? ` (~${(tactic.material_delta / 100).toFixed(1)} pawns)` : ""}
{tactic.affected_squares ? ` on ${tactic.affected_squares.join(", ")}` : ""}
</li>
))}
</ul>
{!isCommenting && comments[currentIndex] && (
<div className="prose prose-sm dark:prose-invert max-w-none">
<ReactMarkdown>{comments[currentIndex]}</ReactMarkdown>
</div>
)}
{!isCommenting && !comments[currentIndex] && (
<p className="text-sm text-gray-600 dark:text-gray-400">{t.analysis.coachPending}</p>
)}
</div>
</div>
)}
)}
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow border border-gray-200 dark:border-gray-700 p-6 space-y-4">
<h2 className="text-xl font-bold text-gray-900 dark:text-white">{t.analysis.aiAnalysis}</h2>
{!apiKey && (
<p className="text-sm text-gray-600 dark:text-gray-400">Please add an API key in settings to receive commentary.</p>
)}
{currentIndex === 0 && (
<p className="text-sm text-gray-600 dark:text-gray-400">{t.analysis.currentPosition}</p>
)}
{currentIndex > 0 && (
<div className="min-h-[140px]">
{isCommenting && (
<div className="flex items-center gap-2 text-gray-600 dark:text-gray-300">
<Loader2 className="animate-spin" size={18} />
<span>{t.analysis.coachPending}</span>
</div>
)}
{!isCommenting && comments[currentIndex] && (
<div className="prose prose-sm dark:prose-invert max-w-none">
<ReactMarkdown>{comments[currentIndex]}</ReactMarkdown>
</div>
)}
{!isCommenting && !comments[currentIndex] && (
<p className="text-sm text-gray-600 dark:text-gray-400">{t.analysis.coachPending}</p>
)}
</div>
)}
</div>
</div>
)}
</div>
</main>
@@ -608,6 +720,111 @@ INSTRUCTIONS:
language={language}
/>
)}
{/* Openings Explorer Modal */}
{showOpeningsModal && possibleOpenings.length > 0 && (
<OpeningsModal
openings={possibleOpenings}
currentFen={currentFen}
language={language}
personality={selectedPersonality}
onClose={() => setShowOpeningsModal(false)}
/>
)}
{/* Play From Position Modal */}
{showPlayModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4 py-8">
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl max-w-2xl w-full p-6 space-y-6 border border-gray-200 dark:border-gray-700">
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="text-2xl font-bold text-gray-900 dark:text-white">{t.analysis.playFromHere}</h3>
<p className="text-sm text-gray-600 dark:text-gray-300 mt-1">{t.analysis.playDescription}</p>
</div>
<button
onClick={() => setShowPlayModal(false)}
className="text-gray-500 hover:text-gray-700 dark:hover:text-gray-200"
aria-label={t.common.close}
>
</button>
</div>
<div className="space-y-4">
<div>
<p className="text-sm font-semibold text-gray-800 dark:text-gray-100 mb-2">{t.analysis.chooseOpponent}</p>
<div className="grid grid-cols-2 gap-2">
{PERSONALITIES.map(p => (
<button
key={p.id}
onClick={() => setPlayPersonality(p)}
className={`p-3 rounded-lg border flex items-center gap-2 ${playPersonality.id === p.id
? "border-blue-500 bg-blue-50 dark:bg-blue-900/30"
: "border-gray-200 dark:border-gray-700"}`}
>
<span className="text-xl">{p.image}</span>
<div className="text-left">
<div className="text-sm font-semibold text-gray-900 dark:text-white">{p.name}</div>
<div className="text-xs text-gray-600 dark:text-gray-300 line-clamp-2">{p.description}</div>
</div>
</button>
))}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<p className="text-sm font-semibold text-gray-800 dark:text-gray-100">{t.analysis.chooseSide}</p>
<div className="grid grid-cols-2 gap-2">
{(["white", "black"] as const).map(color => (
<button
key={color}
onClick={() => setPlayColor(color)}
className={`py-2 px-3 rounded-lg border text-sm font-medium ${playColor === color
? "border-blue-500 bg-blue-50 dark:bg-blue-900/30"
: "border-gray-200 dark:border-gray-700"}`}
>
{color === "white" ? t.game.white : t.game.black}
</button>
))}
</div>
</div>
<div className="space-y-2">
<p className="text-sm font-semibold text-gray-800 dark:text-gray-100">{t.analysis.chooseStrength}</p>
<div className="bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg p-3">
<div className="text-sm text-gray-700 dark:text-gray-200 mb-1">{t.game.stockfishStrength}: {playStrength}</div>
<input
type="range"
min="1"
max="20"
value={playStrength}
onChange={(e) => setPlayStrength(parseInt(e.target.value))}
className="w-full"
/>
<div className="text-[11px] text-gray-500 dark:text-gray-400 mt-1">{t.game.depth}: {playStrength}</div>
</div>
</div>
</div>
</div>
<div className="flex items-center justify-end gap-3">
<button
onClick={() => setShowPlayModal(false)}
className="px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-700 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"
>
{t.common.cancel}
</button>
<button
onClick={handleStartGameFromPosition}
className="px-4 py-2 rounded-lg bg-blue-600 text-white hover:bg-blue-700 shadow"
>
{t.analysis.startPlay}
</button>
</div>
</div>
</div>
)}
</div>
);
}
+30 -1
View File
@@ -4,7 +4,7 @@ import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import ChessGame from "@/components/ChessGame";
import StartScreen from "@/components/StartScreen";
import { Personality } from "@/lib/personalities";
import { Personality, PERSONALITIES } from "@/lib/personalities";
import { SavedGame, deleteSavedGame, loadSavedGames } from "@/lib/savedGames";
type ViewState = 'start' | 'game';
@@ -21,6 +21,7 @@ export default function Home() {
initialPgn?: string;
initialPersonality: Personality;
initialColor: 'white' | 'black';
initialStockfishDepth?: number;
} | null>(null);
const [savedGames, setSavedGames] = useState<SavedGame[]>([]);
@@ -35,6 +36,33 @@ export default function Home() {
setSavedGames(loadSavedGames());
const pendingGameRaw = localStorage.getItem("chess_tutor_pending_game");
if (pendingGameRaw) {
try {
const pendingGame = JSON.parse(pendingGameRaw) as {
fen: string;
personalityId: string;
color: 'white' | 'black';
stockfishDepth?: number;
};
const personality = PERSONALITIES.find(p => p.id === pendingGame.personalityId) || PERSONALITIES[0];
setGameProps({
gameId: crypto.randomUUID ? crypto.randomUUID() : `game-${Date.now()}`,
initialFen: pendingGame.fen,
initialPersonality: personality,
initialColor: pendingGame.color,
initialStockfishDepth: pendingGame.stockfishDepth,
});
setView('game');
} catch (err) {
console.error("Failed to load pending game", err);
} finally {
localStorage.removeItem("chess_tutor_pending_game");
}
}
setMounted(true);
}, [router]);
@@ -98,6 +126,7 @@ export default function Home() {
initialPgn={gameProps.initialPgn}
initialPersonality={gameProps.initialPersonality}
initialColor={gameProps.initialColor}
initialStockfishDepth={gameProps.initialStockfishDepth}
onBack={handleBackToMenu}
/>
)}
+9 -2
View File
@@ -24,6 +24,7 @@ interface ChessGameProps {
initialPgn?: string;
initialPersonality: Personality;
initialColor: 'white' | 'black';
initialStockfishDepth?: number;
onBack: () => void;
}
@@ -36,7 +37,7 @@ const PIECE_VALUES: Record<string, number> = {
'k': 0
};
export default function ChessGame({ gameId, initialFen, initialPgn, initialPersonality, initialColor, onBack }: ChessGameProps) {
export default function ChessGame({ gameId, initialFen, initialPgn, initialPersonality, initialColor, initialStockfishDepth, onBack }: ChessGameProps) {
const gameRef = useRef(new Chess(initialFen || "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"));
const [fen, setFen] = useState(gameRef.current.fen());
const [stockfish, setStockfish] = useState<Stockfish | null>(null);
@@ -55,7 +56,7 @@ export default function ChessGame({ gameId, initialFen, initialPgn, initialPerso
const [computerMove, setComputerMove] = useState<Move | null>(null);
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [apiKey, setApiKey] = useState<string | null>(null);
const [stockfishDepth, setStockfishDepth] = useState(15);
const [stockfishDepth, setStockfishDepth] = useState(initialStockfishDepth ?? 15);
// Settings
const [language, setLanguage] = useState<SupportedLanguage>('en');
@@ -108,6 +109,12 @@ export default function ChessGame({ gameId, initialFen, initialPgn, initialPerso
return () => sf.terminate();
}, []);
useEffect(() => {
if (typeof initialStockfishDepth === 'number') {
setStockfishDepth(initialStockfishDepth);
}
}, [initialStockfishDepth]);
// Load Settings & Initial State
useEffect(() => {
const storedKey = localStorage.getItem("gemini_api_key");
+273
View File
@@ -0,0 +1,273 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { X, Loader2, Send, BookOpen } from "lucide-react";
import { OpeningMetadata } from "@/lib/openings";
import { getGenAIModel } from "@/lib/gemini";
import { ChatSession } from "@google/generative-ai";
import ReactMarkdown from "react-markdown";
import { SupportedLanguage, translations } from "@/lib/i18n/translations";
import { Personality } from "@/lib/personalities";
interface OpeningsModalProps {
openings: OpeningMetadata[];
currentFen: string;
language: SupportedLanguage;
personality: Personality;
onClose: () => void;
}
interface OpeningExplanation {
content: string;
isLoading: boolean;
chatSession?: ChatSession;
messages: { role: "user" | "assistant"; content: string }[];
}
export function OpeningsModal({
openings,
currentFen,
language,
personality,
onClose,
}: OpeningsModalProps) {
const t = translations[language];
const [activeTab, setActiveTab] = useState(0);
const [explanations, setExplanations] = useState<Record<number, OpeningExplanation>>({});
const [followUpInput, setFollowUpInput] = useState("");
const [isSending, setIsSending] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};
useEffect(() => {
scrollToBottom();
}, [explanations, activeTab]);
// Generate explanation when tab is clicked
const generateExplanation = async (index: number) => {
if (explanations[index]?.content || explanations[index]?.isLoading) return;
const opening = openings[index];
const apiKey = typeof window !== "undefined" ? localStorage.getItem("gemini_api_key") : null;
if (!apiKey) return;
setExplanations(prev => ({
...prev,
[index]: { content: "", isLoading: true, messages: [] }
}));
try {
const model = getGenAIModel(apiKey);
const chat = model.startChat({
history: [],
generationConfig: { maxOutputTokens: 1024 },
});
const prompt = `You are ${personality.name}, a chess coach with this style: "${personality.systemPrompt}".
The player is analyzing a game and has reached this position (FEN): ${currentFen}
The opening being played is: ${opening.name} (ECO: ${opening.eco})
Move sequence: ${opening.moves}
Please provide a brief but insightful explanation about this opening. Cover:
1. What strategy is White pursuing with this opening?
2. What is Black's typical response and counter-strategy?
3. How do these strategies pair against each other? Is this a good matchup for one side?
4. One interesting historical fact or famous game featuring this opening (keep it brief)
Be conversational and engaging, matching your personality. Keep your response concise (about 150-200 words).
Respond in ${language === 'de' ? 'German' : language === 'fr' ? 'French' : language === 'it' ? 'Italian' : 'English'}.`;
const result = await chat.sendMessage(prompt);
const responseText = result.response.text();
setExplanations(prev => ({
...prev,
[index]: {
content: responseText,
isLoading: false,
chatSession: chat,
messages: [{ role: "assistant", content: responseText }]
}
}));
} catch (err) {
console.error("Failed to generate opening explanation:", err);
setExplanations(prev => ({
...prev,
[index]: { content: "Failed to generate explanation. Please check your API key.", isLoading: false, messages: [] }
}));
}
};
// Generate explanation for first tab on mount
useEffect(() => {
if (openings.length > 0) {
generateExplanation(0);
}
}, []);
// Handle tab change
const handleTabChange = (index: number) => {
setActiveTab(index);
setFollowUpInput("");
generateExplanation(index);
};
// Handle follow-up question
const handleFollowUp = async () => {
if (!followUpInput.trim() || isSending) return;
const currentExplanation = explanations[activeTab];
if (!currentExplanation?.chatSession) return;
const userMessage = followUpInput.trim();
setFollowUpInput("");
setIsSending(true);
// Add user message immediately
setExplanations(prev => ({
...prev,
[activeTab]: {
...prev[activeTab],
messages: [...prev[activeTab].messages, { role: "user", content: userMessage }]
}
}));
try {
const result = await currentExplanation.chatSession.sendMessage(userMessage);
const responseText = result.response.text();
setExplanations(prev => ({
...prev,
[activeTab]: {
...prev[activeTab],
messages: [...prev[activeTab].messages, { role: "assistant", content: responseText }]
}
}));
} catch (err) {
console.error("Failed to send follow-up:", err);
} finally {
setIsSending(false);
}
};
const currentOpening = openings[activeTab];
const currentExplanation = explanations[activeTab];
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl w-full max-w-2xl max-h-[85vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-2">
<BookOpen className="text-purple-600" size={24} />
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
{t.analysis.openingsExplorer || "Opening Explorer"}
</h2>
</div>
<button
onClick={onClose}
className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
>
<X size={20} className="text-gray-500" />
</button>
</div>
{/* Tabs */}
<div className="flex overflow-x-auto border-b border-gray-200 dark:border-gray-700 px-4">
{openings.map((opening, index) => (
<button
key={`${opening.eco}-${index}`}
onClick={() => handleTabChange(index)}
className={`px-4 py-3 text-sm font-medium whitespace-nowrap border-b-2 transition-colors ${activeTab === index
? "border-purple-600 text-purple-600"
: "border-transparent text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
}`}
>
{opening.eco}: {opening.name.length > 20 ? opening.name.substring(0, 20) + "..." : opening.name}
</button>
))}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4">
{currentOpening && (
<div className="space-y-4">
{/* Opening Info */}
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<h3 className="font-bold text-blue-900 dark:text-blue-100 text-lg">
{currentOpening.name}
</h3>
<p className="text-sm text-blue-700 dark:text-blue-300 mt-1">
ECO: {currentOpening.eco} {currentOpening.moves}
</p>
</div>
{/* Messages/Explanation */}
<div className="space-y-4">
{currentExplanation?.isLoading && !currentExplanation.content ? (
<div className="flex items-center gap-3 text-gray-600 dark:text-gray-300 py-8 justify-center">
<Loader2 className="animate-spin" size={24} />
<span>{t.analysis.generatingExplanation || "Generating explanation..."}</span>
</div>
) : currentExplanation?.messages.length > 0 ? (
currentExplanation.messages.map((msg, idx) => (
<div
key={idx}
className={`p-4 rounded-lg ${msg.role === "user"
? "bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800 ml-8"
: "bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700"
}`}
>
{msg.role === "user" ? (
<p className="text-gray-800 dark:text-gray-200">{msg.content}</p>
) : (
<div className="prose prose-sm dark:prose-invert max-w-none">
<ReactMarkdown>{msg.content}</ReactMarkdown>
</div>
)}
</div>
))
) : (
<p className="text-gray-500 dark:text-gray-400 text-center py-8">
{t.analysis.noApiKey || "Please add an API key in settings to get opening explanations."}
</p>
)}
<div ref={messagesEndRef} />
</div>
</div>
)}
</div>
{/* Follow-up Input */}
{currentExplanation?.chatSession && (
<div className="p-4 border-t border-gray-200 dark:border-gray-700">
<div className="flex gap-2">
<input
type="text"
value={followUpInput}
onChange={(e) => setFollowUpInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleFollowUp()}
placeholder={t.analysis.askFollowUp || "Ask a follow-up question about this opening..."}
className="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500"
disabled={isSending}
/>
<button
onClick={handleFollowUp}
disabled={!followUpInput.trim() || isSending}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{isSending ? <Loader2 className="animate-spin" size={18} /> : <Send size={18} />}
</button>
</div>
</div>
)}
</div>
</div>
);
}
+27 -11
View File
@@ -2,7 +2,7 @@
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { Settings, ChevronDown, ChevronUp, Brain, Trash2 } from "lucide-react";
import { Settings, ChevronDown, ChevronUp, Brain, Trash2, BarChart2 } from "lucide-react";
import { Personality, PERSONALITIES } from "@/lib/personalities";
import { useTranslation } from "@/lib/i18n/useTranslation";
import { SupportedLanguage } from "@/lib/i18n/translations";
@@ -145,16 +145,32 @@ export default function StartScreen({ onStartGame, onResumeGame, savedGames, onD
onClick={() => onResumeGame(game)}
className="group relative bg-gray-50 dark:bg-gray-700 p-4 rounded-xl border border-gray-200 dark:border-gray-600 hover:border-blue-400 dark:hover:border-blue-300 shadow-sm hover:shadow-md transition-all cursor-pointer"
>
<button
onClick={(e) => {
e.stopPropagation();
onDeleteSavedGame(game.id);
}}
aria-label={t.start.deleteGame}
className="absolute top-2 right-2 p-2 rounded-full bg-white dark:bg-gray-800 text-gray-500 hover:text-red-600 shadow opacity-0 group-hover:opacity-100 transition-opacity"
>
<Trash2 size={16} />
</button>
<div className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => {
e.stopPropagation();
// Navigate to analysis with this game's PGN
if (game.pgn) {
localStorage.setItem('chess_tutor_pending_analysis', game.pgn);
}
router.push('/analysis');
}}
aria-label={t.start.analyzeThisGame}
className="p-2 rounded-full bg-white dark:bg-gray-800 text-gray-500 hover:text-purple-600 shadow"
>
<BarChart2 size={16} />
</button>
<button
onClick={(e) => {
e.stopPropagation();
onDeleteSavedGame(game.id);
}}
aria-label={t.start.deleteGame}
className="p-2 rounded-full bg-white dark:bg-gray-800 text-gray-500 hover:text-red-600 shadow"
>
<Trash2 size={16} />
</button>
</div>
<div className="bg-[#779954] p-[2px] rounded-sm">
<Chessboard
+65
View File
@@ -48,6 +48,7 @@ export interface Translations {
evaluationLabel: string;
noEvaluation: string;
deleteGame: string;
analyzeThisGame: string;
};
// Game
@@ -97,6 +98,12 @@ export interface Translations {
evaluation: string;
bestMove: string;
aiAnalysis: string;
playFromHere: string;
playDescription: string;
chooseOpponent: string;
chooseSide: string;
chooseStrength: string;
startPlay: string;
modeTitle: string;
modeDescription: string;
pasteLabel: string;
@@ -114,6 +121,12 @@ export interface Translations {
enginePending: string;
coachPending: string;
importError: string;
loadNewGame: string;
openingsExplorer: string;
generatingExplanation: string;
noApiKey: string;
askFollowUp: string;
possibleOpenings: string;
};
// API Key Input
@@ -203,6 +216,7 @@ const en: Translations = {
evaluationLabel: 'Evaluation',
noEvaluation: 'No evaluation yet',
deleteGame: 'Delete game',
analyzeThisGame: 'Analyze this game',
},
game: {
playingAs: 'Playing as',
@@ -244,6 +258,12 @@ const en: Translations = {
evaluation: 'Evaluation',
bestMove: 'Best Move',
aiAnalysis: 'AI Analysis',
playFromHere: 'Play from this position',
playDescription: 'Pick a character, side, and engine strength to continue playing from the current move.',
chooseOpponent: 'Choose your opponent',
chooseSide: 'Choose your color',
chooseStrength: 'Opponent strength',
startPlay: 'Start from here',
modeTitle: 'Analyze an Existing Game',
modeDescription: 'Upload a PGN or FEN and let your coach walk you through every move with engine-backed insights.',
pasteLabel: 'PGN or FEN Input',
@@ -261,6 +281,12 @@ const en: Translations = {
enginePending: 'Running engine evaluation...',
coachPending: 'Coach is preparing feedback...',
importError: 'Could not load that PGN or FEN. Please check the notation.',
loadNewGame: 'Load New Game',
openingsExplorer: 'Opening Explorer',
generatingExplanation: 'Generating explanation...',
noApiKey: 'Please add an API key in settings to get opening explanations.',
askFollowUp: 'Ask a follow-up question about this opening...',
possibleOpenings: 'possible openings',
},
apiKeyInput: {
title: 'API Key Required',
@@ -351,6 +377,7 @@ const de: Translations = {
evaluationLabel: 'Bewertung',
noEvaluation: 'Keine Bewertung',
deleteGame: 'Partie löschen',
analyzeThisGame: 'Diese Partie analysieren',
},
game: {
playingAs: 'Spielst als',
@@ -392,6 +419,12 @@ const de: Translations = {
evaluation: 'Bewertung',
bestMove: 'Bester Zug',
aiAnalysis: 'KI-Analyse',
playFromHere: 'Von dieser Stellung spielen',
playDescription: 'Wähle Charakter, Farbe und Engine-Stärke, um ab dem aktuellen Zug weiterzuspielen.',
chooseOpponent: 'Gegner auswählen',
chooseSide: 'Wähle deine Farbe',
chooseStrength: 'Stärke des Gegners',
startPlay: 'Hier weiterspielen',
modeTitle: 'Bestehende Partie analysieren',
modeDescription: 'PGN oder FEN hochladen und vom Coach mit Engine-Unterstützung durch die Partie führen lassen.',
pasteLabel: 'PGN- oder FEN-Eingabe',
@@ -409,6 +442,12 @@ const de: Translations = {
enginePending: 'Engine-Bewertung läuft...',
coachPending: 'Coach bereitet Feedback vor...',
importError: 'PGN oder FEN konnte nicht geladen werden. Bitte Notation prüfen.',
loadNewGame: 'Neue Partie laden',
openingsExplorer: 'Eröffnungs-Explorer',
generatingExplanation: 'Erklärung wird generiert...',
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',
},
apiKeyInput: {
title: 'API-Schlüssel erforderlich',
@@ -499,6 +538,7 @@ const fr: Translations = {
evaluationLabel: 'Évaluation',
noEvaluation: 'Pas d\'évaluation',
deleteGame: 'Supprimer la partie',
analyzeThisGame: 'Analyser cette partie',
},
game: {
playingAs: 'Jouant',
@@ -540,6 +580,12 @@ const fr: Translations = {
evaluation: 'Évaluation',
bestMove: 'Meilleur coup',
aiAnalysis: 'Analyse IA',
playFromHere: 'Jouer depuis cette position',
playDescription: 'Choisissez un personnage, une couleur et la force du moteur pour continuer depuis ce coup.',
chooseOpponent: 'Choisir votre adversaire',
chooseSide: 'Choisissez votre couleur',
chooseStrength: 'Force de ladversaire',
startPlay: 'Commencer ici',
modeTitle: 'Analyser une partie existante',
modeDescription: 'Importez un PGN ou un FEN et laissez le coach commenter chaque coup avec laide du moteur.',
pasteLabel: 'Saisie PGN ou FEN',
@@ -557,6 +603,12 @@ const fr: Translations = {
enginePending: 'Évaluation du moteur en cours...',
coachPending: 'Le coach prépare son retour...',
importError: 'Impossible de charger ce PGN ou FEN. Merci de vérifier la notation.',
loadNewGame: 'Charger nouvelle partie',
openingsExplorer: 'Explorateur d\'ouvertures',
generatingExplanation: 'Génération de l\'explication...',
noApiKey: 'Veuillez ajouter une clé API dans les paramètres.',
askFollowUp: 'Posez une question sur cette ouverture...',
possibleOpenings: 'ouvertures possibles',
},
apiKeyInput: {
title: 'Clé API requise',
@@ -647,6 +699,7 @@ const it: Translations = {
evaluationLabel: 'Valutazione',
noEvaluation: 'Nessuna valutazione',
deleteGame: 'Elimina partita',
analyzeThisGame: 'Analizza questa partita',
},
game: {
playingAs: 'Giocando',
@@ -688,6 +741,12 @@ const it: Translations = {
evaluation: 'Valutazione',
bestMove: 'Mossa migliore',
aiAnalysis: 'Analisi IA',
playFromHere: 'Gioca da questa posizione',
playDescription: 'Scegli personaggio, colore e forza del motore per continuare da questa mossa.',
chooseOpponent: 'Scegli lavversario',
chooseSide: 'Scegli il tuo colore',
chooseStrength: 'Forza dellavversario',
startPlay: 'Inizia da qui',
modeTitle: 'Analizza una partita esistente',
modeDescription: 'Carica un PGN o un FEN e lascia che il coach commenti ogni mossa con il supporto del motore.',
pasteLabel: 'Input PGN o FEN',
@@ -705,6 +764,12 @@ const it: Translations = {
enginePending: 'Valutazione del motore in corso...',
coachPending: 'Il coach sta preparando il feedback...',
importError: 'Impossibile caricare questo PGN o FEN. Controlla la notazione.',
loadNewGame: 'Carica nuova partita',
openingsExplorer: 'Esplora aperture',
generatingExplanation: 'Generazione spiegazione...',
noApiKey: 'Aggiungi una chiave API nelle impostazioni.',
askFollowUp: 'Fai una domanda su questa apertura...',
possibleOpenings: 'aperture possibili',
},
apiKeyInput: {
title: 'Chiave API richiesta',