Merge remote changes and add evaluation bar to analysis page
This commit is contained in:
Generated
+20
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 l’adversaire',
|
||||
startPlay: 'Commencer ici',
|
||||
modeTitle: 'Analyser une partie existante',
|
||||
modeDescription: 'Importez un PGN ou un FEN et laissez le coach commenter chaque coup avec l’aide 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 l’avversario',
|
||||
chooseSide: 'Scegli il tuo colore',
|
||||
chooseStrength: 'Forza dell’avversario',
|
||||
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',
|
||||
|
||||
Reference in New Issue
Block a user