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==", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.27.1", "@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5", "@babel/generator": "^7.28.5",
@@ -699,6 +700,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
}, },
@@ -722,6 +724,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@@ -743,6 +746,7 @@
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
@@ -2945,6 +2949,7 @@
"integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==", "integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
@@ -2954,6 +2959,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.6.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.6.tgz",
"integrity": "sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w==", "integrity": "sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
} }
@@ -2964,6 +2970,7 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
@@ -3051,6 +3058,7 @@
"integrity": "sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==", "integrity": "sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.47.0", "@typescript-eslint/scope-manager": "8.47.0",
"@typescript-eslint/types": "8.47.0", "@typescript-eslint/types": "8.47.0",
@@ -3587,6 +3595,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -4107,6 +4116,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.8.25", "baseline-browser-mapping": "^2.8.25",
"caniuse-lite": "^1.0.30001754", "caniuse-lite": "^1.0.30001754",
@@ -5085,6 +5095,7 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@@ -5270,6 +5281,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@rtsao/scc": "^1.1.0", "@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9", "array-includes": "^3.1.9",
@@ -7080,6 +7092,7 @@
"integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@jest/core": "30.2.0", "@jest/core": "30.2.0",
"@jest/types": "30.2.0", "@jest/types": "30.2.0",
@@ -8066,6 +8079,7 @@
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"cssstyle": "^4.2.1", "cssstyle": "^4.2.1",
"data-urls": "^5.0.0", "data-urls": "^5.0.0",
@@ -10119,6 +10133,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -10146,6 +10161,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.27.0" "scheduler": "^0.27.0"
}, },
@@ -11307,6 +11323,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -11498,6 +11515,7 @@
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@cspotcode/source-map-support": "^0.8.0", "@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7", "@tsconfig/node10": "^1.0.7",
@@ -11688,6 +11706,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -12420,6 +12439,7 @@
"integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }
+11 -2
View File
@@ -1,4 +1,5 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { DebugProvider } from "@/contexts/DebugContext";
jest.mock("next/navigation", () => ({ jest.mock("next/navigation", () => ({
useRouter: jest.fn(() => ({ useRouter: jest.fn(() => ({
@@ -83,7 +84,11 @@ describe("AnalysisPage", () => {
`; `;
const loadGame = () => { const loadGame = () => {
render(<AnalysisPage />); render(
<DebugProvider>
<AnalysisPage />
</DebugProvider>
);
const textarea = screen.getByPlaceholderText(/Paste PGN or FEN here/i); const textarea = screen.getByPlaceholderText(/Paste PGN or FEN here/i);
fireEvent.change(textarea, { target: { value: samplePgn } }); fireEvent.change(textarea, { target: { value: samplePgn } });
fireEvent.click(screen.getByText(/Start Analysis/i)); fireEvent.click(screen.getByText(/Start Analysis/i));
@@ -125,7 +130,11 @@ describe("AnalysisPage", () => {
}); });
it("shows an error when the notation cannot be parsed", () => { 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); const textarea = screen.getByPlaceholderText(/Paste PGN or FEN here/i);
fireEvent.change(textarea, { target: { value: "invalid" } }); fireEvent.change(textarea, { target: { value: "invalid" } });
fireEvent.click(screen.getByText(/Start Analysis/i)); fireEvent.click(screen.getByText(/Start Analysis/i));
+247 -30
View File
@@ -3,7 +3,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Chess } from "chess.js"; import { Chess } from "chess.js";
import { Chessboard } from "react-chessboard"; 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 { useRouter } from "next/navigation";
import Header from "@/components/Header"; import Header from "@/components/Header";
@@ -20,6 +20,7 @@ import ReactMarkdown from "react-markdown";
import { useDebug } from "@/contexts/DebugContext"; import { useDebug } from "@/contexts/DebugContext";
import { GameImportModal } from "@/components/GameImportModal"; import { GameImportModal } from "@/components/GameImportModal";
import { EvaluationBar } from "@/components/EvaluationBar"; import { EvaluationBar } from "@/components/EvaluationBar";
import { OpeningsModal } from "@/components/OpeningsModal";
interface MoveStep { interface MoveStep {
san: string; san: string;
@@ -65,14 +66,35 @@ export default function AnalysisPage() {
const [comments, setComments] = useState<Record<number, string>>({}); const [comments, setComments] = useState<Record<number, string>>({});
const [chatSession, setChatSession] = useState<ChatSession | null>(null); const [chatSession, setChatSession] = useState<ChatSession | null>(null);
const [showImportModal, setShowImportModal] = useState(false); 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(() => { useEffect(() => {
const storedKey = localStorage.getItem("gemini_api_key"); const storedKey = localStorage.getItem("gemini_api_key");
const storedLang = localStorage.getItem("chess_tutor_language"); const storedLang = localStorage.getItem("chess_tutor_language");
if (storedKey) setApiKey(storedKey); if (storedKey) setApiKey(storedKey);
if (storedLang) setLanguage(storedLang as SupportedLanguage); 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(() => { useEffect(() => {
const sf = new Stockfish(); const sf = new Stockfish();
setStockfish(sf); setStockfish(sf);
@@ -219,6 +241,31 @@ IMPORTANT:
loadGameFromPgnOrFen(pgn); 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(() => { useEffect(() => {
if (!stockfish || !currentFen) return; if (!stockfish || !currentFen) return;
ensureEvaluation(currentFen); ensureEvaluation(currentFen);
@@ -381,6 +428,8 @@ INSTRUCTIONS:
<main className="flex-grow w-full flex justify-center px-4 py-8"> <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="w-full max-w-6xl space-y-8">
{/* 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="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 className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div> <div>
@@ -389,17 +438,6 @@ INSTRUCTIONS:
</h1> </h1>
<p className="text-gray-600 dark:text-gray-300 mt-2">{t.analysis.modeDescription}</p> <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>
</div> </div>
<div className="mt-6 grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="mt-6 grid grid-cols-1 md:grid-cols-2 gap-6">
@@ -463,7 +501,7 @@ INSTRUCTIONS:
</div> </div>
</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="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"> <div className="w-full max-w-md">
<Chessboard <Chessboard
options={{ options={{
@@ -477,42 +515,112 @@ INSTRUCTIONS:
}} }}
/> />
</div> </div>
<p className="mt-4 text-sm text-gray-500 dark:text-gray-400 text-center">
{t.analysis.currentPosition}
</p>
</div>
</div>
</div>
)}
{/* 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>
<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="flex flex-col items-center gap-4">
<div className="w-full max-w-lg">
<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>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<button <button
onClick={() => setCurrentIndex(i => Math.max(0, i - 1))} 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} disabled={currentIndex === 0}
aria-label={t.analysis.previous} aria-label={t.analysis.previous}
> >
<ChevronLeft size={20} /> <ChevronLeft size={20} />
</button> </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} {t.analysis.step} {currentIndex} / {steps.length}
</div> </div>
<button <button
onClick={() => setCurrentIndex(i => Math.min(steps.length, i + 1))} 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} disabled={currentIndex >= steps.length}
aria-label={t.analysis.next} aria-label={t.analysis.next}
> >
<ChevronRight size={20} /> <ChevronRight size={20} />
</button> </button>
</div> </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>
</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="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="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"> <div className="flex items-center justify-between">
<h2 className="text-xl font-bold text-gray-900 dark:text-white">{t.analysis.title}</h2> <h2 className="text-xl font-bold text-gray-900 dark:text-white">{t.analysis.title}</h2>
{possibleOpenings.length > 0 && ( {possibleOpenings.length > 0 && (
<span className="text-sm text-blue-700 dark:text-blue-300"> <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 {t.analysis.opening}: {possibleOpenings.length === 1
? `${possibleOpenings[0].name} (${possibleOpenings[0].eco})` ? `${possibleOpenings[0].name} (${possibleOpenings[0].eco})`
: `${possibleOpenings.length} possible openings` : `${possibleOpenings.length} ${t.analysis.possibleOpenings}`
} }
</span> </button>
)} )}
</div> </div>
{currentIndex === 0 ? ( {currentIndex === 0 ? (
@@ -538,16 +646,6 @@ INSTRUCTIONS:
</div> </div>
</div> </div>
{/* Horizontal Evaluation Bar */}
<div className="w-full">
<EvaluationBar
score={currentDetails?.evalAfter?.score}
mate={currentDetails?.evalAfter?.mate}
isPlayerWhite={true}
orientation="horizontal"
/>
</div>
<div> <div>
<div className="font-semibold text-gray-800 dark:text-gray-100 mb-2">{t.analysis.missedTactics}</div> <div className="font-semibold text-gray-800 dark:text-gray-100 mb-2">{t.analysis.missedTactics}</div>
{tacticSummary.length === 0 && ( {tacticSummary.length === 0 && (
@@ -571,6 +669,19 @@ INSTRUCTIONS:
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow border border-gray-200 dark:border-gray-700 p-6 space-y-4"> <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> <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}
isPlayerWhite={true}
orientation="horizontal"
/>
</div>
)}
{!apiKey && ( {!apiKey && (
<p className="text-sm text-gray-600 dark:text-gray-400">Please add an API key in settings to receive commentary.</p> <p className="text-sm text-gray-600 dark:text-gray-400">Please add an API key in settings to receive commentary.</p>
)} )}
@@ -597,6 +708,7 @@ INSTRUCTIONS:
)} )}
</div> </div>
</div> </div>
)}
</div> </div>
</main> </main>
@@ -608,6 +720,111 @@ INSTRUCTIONS:
language={language} 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> </div>
); );
} }
+30 -1
View File
@@ -4,7 +4,7 @@ import { useState, useEffect } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import ChessGame from "@/components/ChessGame"; import ChessGame from "@/components/ChessGame";
import StartScreen from "@/components/StartScreen"; import StartScreen from "@/components/StartScreen";
import { Personality } from "@/lib/personalities"; import { Personality, PERSONALITIES } from "@/lib/personalities";
import { SavedGame, deleteSavedGame, loadSavedGames } from "@/lib/savedGames"; import { SavedGame, deleteSavedGame, loadSavedGames } from "@/lib/savedGames";
type ViewState = 'start' | 'game'; type ViewState = 'start' | 'game';
@@ -21,6 +21,7 @@ export default function Home() {
initialPgn?: string; initialPgn?: string;
initialPersonality: Personality; initialPersonality: Personality;
initialColor: 'white' | 'black'; initialColor: 'white' | 'black';
initialStockfishDepth?: number;
} | null>(null); } | null>(null);
const [savedGames, setSavedGames] = useState<SavedGame[]>([]); const [savedGames, setSavedGames] = useState<SavedGame[]>([]);
@@ -35,6 +36,33 @@ export default function Home() {
setSavedGames(loadSavedGames()); 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); setMounted(true);
}, [router]); }, [router]);
@@ -98,6 +126,7 @@ export default function Home() {
initialPgn={gameProps.initialPgn} initialPgn={gameProps.initialPgn}
initialPersonality={gameProps.initialPersonality} initialPersonality={gameProps.initialPersonality}
initialColor={gameProps.initialColor} initialColor={gameProps.initialColor}
initialStockfishDepth={gameProps.initialStockfishDepth}
onBack={handleBackToMenu} onBack={handleBackToMenu}
/> />
)} )}
+9 -2
View File
@@ -24,6 +24,7 @@ interface ChessGameProps {
initialPgn?: string; initialPgn?: string;
initialPersonality: Personality; initialPersonality: Personality;
initialColor: 'white' | 'black'; initialColor: 'white' | 'black';
initialStockfishDepth?: number;
onBack: () => void; onBack: () => void;
} }
@@ -36,7 +37,7 @@ const PIECE_VALUES: Record<string, number> = {
'k': 0 '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 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 [fen, setFen] = useState(gameRef.current.fen());
const [stockfish, setStockfish] = useState<Stockfish | null>(null); 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 [computerMove, setComputerMove] = useState<Move | null>(null);
const [isAnalyzing, setIsAnalyzing] = useState(false); const [isAnalyzing, setIsAnalyzing] = useState(false);
const [apiKey, setApiKey] = useState<string | null>(null); const [apiKey, setApiKey] = useState<string | null>(null);
const [stockfishDepth, setStockfishDepth] = useState(15); const [stockfishDepth, setStockfishDepth] = useState(initialStockfishDepth ?? 15);
// Settings // Settings
const [language, setLanguage] = useState<SupportedLanguage>('en'); const [language, setLanguage] = useState<SupportedLanguage>('en');
@@ -108,6 +109,12 @@ export default function ChessGame({ gameId, initialFen, initialPgn, initialPerso
return () => sf.terminate(); return () => sf.terminate();
}, []); }, []);
useEffect(() => {
if (typeof initialStockfishDepth === 'number') {
setStockfishDepth(initialStockfishDepth);
}
}, [initialStockfishDepth]);
// Load Settings & Initial State // Load Settings & Initial State
useEffect(() => { useEffect(() => {
const storedKey = localStorage.getItem("gemini_api_key"); 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>
);
}
+18 -2
View File
@@ -2,7 +2,7 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation"; 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 { Personality, PERSONALITIES } from "@/lib/personalities";
import { useTranslation } from "@/lib/i18n/useTranslation"; import { useTranslation } from "@/lib/i18n/useTranslation";
import { SupportedLanguage } from "@/lib/i18n/translations"; import { SupportedLanguage } from "@/lib/i18n/translations";
@@ -145,16 +145,32 @@ export default function StartScreen({ onStartGame, onResumeGame, savedGames, onD
onClick={() => onResumeGame(game)} 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" 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"
> >
<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 <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onDeleteSavedGame(game.id); onDeleteSavedGame(game.id);
}} }}
aria-label={t.start.deleteGame} 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" className="p-2 rounded-full bg-white dark:bg-gray-800 text-gray-500 hover:text-red-600 shadow"
> >
<Trash2 size={16} /> <Trash2 size={16} />
</button> </button>
</div>
<div className="bg-[#779954] p-[2px] rounded-sm"> <div className="bg-[#779954] p-[2px] rounded-sm">
<Chessboard <Chessboard
+65
View File
@@ -48,6 +48,7 @@ export interface Translations {
evaluationLabel: string; evaluationLabel: string;
noEvaluation: string; noEvaluation: string;
deleteGame: string; deleteGame: string;
analyzeThisGame: string;
}; };
// Game // Game
@@ -97,6 +98,12 @@ export interface Translations {
evaluation: string; evaluation: string;
bestMove: string; bestMove: string;
aiAnalysis: string; aiAnalysis: string;
playFromHere: string;
playDescription: string;
chooseOpponent: string;
chooseSide: string;
chooseStrength: string;
startPlay: string;
modeTitle: string; modeTitle: string;
modeDescription: string; modeDescription: string;
pasteLabel: string; pasteLabel: string;
@@ -114,6 +121,12 @@ export interface Translations {
enginePending: string; enginePending: string;
coachPending: string; coachPending: string;
importError: string; importError: string;
loadNewGame: string;
openingsExplorer: string;
generatingExplanation: string;
noApiKey: string;
askFollowUp: string;
possibleOpenings: string;
}; };
// API Key Input // API Key Input
@@ -203,6 +216,7 @@ const en: Translations = {
evaluationLabel: 'Evaluation', evaluationLabel: 'Evaluation',
noEvaluation: 'No evaluation yet', noEvaluation: 'No evaluation yet',
deleteGame: 'Delete game', deleteGame: 'Delete game',
analyzeThisGame: 'Analyze this game',
}, },
game: { game: {
playingAs: 'Playing as', playingAs: 'Playing as',
@@ -244,6 +258,12 @@ const en: Translations = {
evaluation: 'Evaluation', evaluation: 'Evaluation',
bestMove: 'Best Move', bestMove: 'Best Move',
aiAnalysis: 'AI Analysis', 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', modeTitle: 'Analyze an Existing Game',
modeDescription: 'Upload a PGN or FEN and let your coach walk you through every move with engine-backed insights.', modeDescription: 'Upload a PGN or FEN and let your coach walk you through every move with engine-backed insights.',
pasteLabel: 'PGN or FEN Input', pasteLabel: 'PGN or FEN Input',
@@ -261,6 +281,12 @@ const en: Translations = {
enginePending: 'Running engine evaluation...', enginePending: 'Running engine evaluation...',
coachPending: 'Coach is preparing feedback...', coachPending: 'Coach is preparing feedback...',
importError: 'Could not load that PGN or FEN. Please check the notation.', 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: { apiKeyInput: {
title: 'API Key Required', title: 'API Key Required',
@@ -351,6 +377,7 @@ const de: Translations = {
evaluationLabel: 'Bewertung', evaluationLabel: 'Bewertung',
noEvaluation: 'Keine Bewertung', noEvaluation: 'Keine Bewertung',
deleteGame: 'Partie löschen', deleteGame: 'Partie löschen',
analyzeThisGame: 'Diese Partie analysieren',
}, },
game: { game: {
playingAs: 'Spielst als', playingAs: 'Spielst als',
@@ -392,6 +419,12 @@ const de: Translations = {
evaluation: 'Bewertung', evaluation: 'Bewertung',
bestMove: 'Bester Zug', bestMove: 'Bester Zug',
aiAnalysis: 'KI-Analyse', 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', modeTitle: 'Bestehende Partie analysieren',
modeDescription: 'PGN oder FEN hochladen und vom Coach mit Engine-Unterstützung durch die Partie führen lassen.', modeDescription: 'PGN oder FEN hochladen und vom Coach mit Engine-Unterstützung durch die Partie führen lassen.',
pasteLabel: 'PGN- oder FEN-Eingabe', pasteLabel: 'PGN- oder FEN-Eingabe',
@@ -409,6 +442,12 @@ const de: Translations = {
enginePending: 'Engine-Bewertung läuft...', enginePending: 'Engine-Bewertung läuft...',
coachPending: 'Coach bereitet Feedback vor...', coachPending: 'Coach bereitet Feedback vor...',
importError: 'PGN oder FEN konnte nicht geladen werden. Bitte Notation prüfen.', 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: { apiKeyInput: {
title: 'API-Schlüssel erforderlich', title: 'API-Schlüssel erforderlich',
@@ -499,6 +538,7 @@ const fr: Translations = {
evaluationLabel: 'Évaluation', evaluationLabel: 'Évaluation',
noEvaluation: 'Pas d\'évaluation', noEvaluation: 'Pas d\'évaluation',
deleteGame: 'Supprimer la partie', deleteGame: 'Supprimer la partie',
analyzeThisGame: 'Analyser cette partie',
}, },
game: { game: {
playingAs: 'Jouant', playingAs: 'Jouant',
@@ -540,6 +580,12 @@ const fr: Translations = {
evaluation: 'Évaluation', evaluation: 'Évaluation',
bestMove: 'Meilleur coup', bestMove: 'Meilleur coup',
aiAnalysis: 'Analyse IA', 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', modeTitle: 'Analyser une partie existante',
modeDescription: 'Importez un PGN ou un FEN et laissez le coach commenter chaque coup avec laide du moteur.', modeDescription: 'Importez un PGN ou un FEN et laissez le coach commenter chaque coup avec laide du moteur.',
pasteLabel: 'Saisie PGN ou FEN', pasteLabel: 'Saisie PGN ou FEN',
@@ -557,6 +603,12 @@ const fr: Translations = {
enginePending: 'Évaluation du moteur en cours...', enginePending: 'Évaluation du moteur en cours...',
coachPending: 'Le coach prépare son retour...', coachPending: 'Le coach prépare son retour...',
importError: 'Impossible de charger ce PGN ou FEN. Merci de vérifier la notation.', 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: { apiKeyInput: {
title: 'Clé API requise', title: 'Clé API requise',
@@ -647,6 +699,7 @@ const it: Translations = {
evaluationLabel: 'Valutazione', evaluationLabel: 'Valutazione',
noEvaluation: 'Nessuna valutazione', noEvaluation: 'Nessuna valutazione',
deleteGame: 'Elimina partita', deleteGame: 'Elimina partita',
analyzeThisGame: 'Analizza questa partita',
}, },
game: { game: {
playingAs: 'Giocando', playingAs: 'Giocando',
@@ -688,6 +741,12 @@ const it: Translations = {
evaluation: 'Valutazione', evaluation: 'Valutazione',
bestMove: 'Mossa migliore', bestMove: 'Mossa migliore',
aiAnalysis: 'Analisi IA', 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', 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.', 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', pasteLabel: 'Input PGN o FEN',
@@ -705,6 +764,12 @@ const it: Translations = {
enginePending: 'Valutazione del motore in corso...', enginePending: 'Valutazione del motore in corso...',
coachPending: 'Il coach sta preparando il feedback...', coachPending: 'Il coach sta preparando il feedback...',
importError: 'Impossibile caricare questo PGN o FEN. Controlla la notazione.', 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: { apiKeyInput: {
title: 'Chiave API richiesta', title: 'Chiave API richiesta',