This commit is contained in:
Stefan
2025-11-29 10:18:23 +01:00
parent 4102135acd
commit 0e678e0187
5 changed files with 613 additions and 180 deletions
+20
View File
@@ -112,6 +112,7 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -699,6 +700,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -722,6 +724,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@@ -743,6 +746,7 @@
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
@@ -2945,6 +2949,7 @@
"integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -2954,6 +2959,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.6.tgz",
"integrity": "sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -2964,6 +2970,7 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -3051,6 +3058,7 @@
"integrity": "sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.47.0",
"@typescript-eslint/types": "8.47.0",
@@ -3587,6 +3595,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -4107,6 +4116,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.25",
"caniuse-lite": "^1.0.30001754",
@@ -5085,6 +5095,7 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -5270,6 +5281,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -7080,6 +7092,7 @@
"integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@jest/core": "30.2.0",
"@jest/types": "30.2.0",
@@ -8066,6 +8079,7 @@
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"cssstyle": "^4.2.1",
"data-urls": "^5.0.0",
@@ -10119,6 +10133,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -10146,6 +10161,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -11307,6 +11323,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -11498,6 +11515,7 @@
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
@@ -11688,6 +11706,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -12420,6 +12439,7 @@
"integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
+112 -23
View File
@@ -3,7 +3,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Chess } from "chess.js";
import { Chessboard } from "react-chessboard";
import { Brain, ChevronLeft, ChevronRight, Loader2, ArrowLeft, Download, PlayCircle } 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";
@@ -19,6 +19,7 @@ import { ChatSession } from "@google/generative-ai";
import ReactMarkdown from "react-markdown";
import { useDebug } from "@/contexts/DebugContext";
import { GameImportModal } from "@/components/GameImportModal";
import { OpeningsModal } from "@/components/OpeningsModal";
interface MoveStep {
san: string;
@@ -65,6 +66,7 @@ export default function AnalysisPage() {
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);
@@ -74,6 +76,18 @@ export default function AnalysisPage() {
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(() => {
@@ -226,6 +240,19 @@ 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,
@@ -400,6 +427,8 @@ INSTRUCTIONS:
<main className="flex-grow w-full flex justify-center px-4 py-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="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
@@ -408,17 +437,6 @@ INSTRUCTIONS:
</h1>
<p className="text-gray-600 dark:text-gray-300 mt-2">{t.analysis.modeDescription}</p>
</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 className="mt-6 grid grid-cols-1 md:grid-cols-2 gap-6">
@@ -482,7 +500,7 @@ INSTRUCTIONS:
</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">
<Chessboard
options={{
@@ -496,53 +514,112 @@ INSTRUCTIONS:
}}
/>
</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">
<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>
<div className="w-full">
<button
onClick={() => {
setPlayColor(orientation);
setShowPlayModal(true);
}}
className="mt-3 inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg shadow hover:bg-blue-700 transition-colors w-full justify-center"
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>
)}
{/* 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 && (
<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
? `${possibleOpenings[0].name} (${possibleOpenings[0].eco})`
: `${possibleOpenings.length} possible openings`
: `${possibleOpenings.length} ${t.analysis.possibleOpenings}`
}
</span>
</button>
)}
</div>
{currentIndex === 0 ? (
@@ -617,6 +694,7 @@ INSTRUCTIONS:
)}
</div>
</div>
)}
</div>
</main>
@@ -629,6 +707,17 @@ INSTRUCTIONS:
/>
)}
{/* 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">
+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 { 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"
>
<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="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} />
</button>
</div>
<div className="bg-[#779954] p-[2px] rounded-sm">
<Chessboard
+35
View File
@@ -48,6 +48,7 @@ export interface Translations {
evaluationLabel: string;
noEvaluation: string;
deleteGame: string;
analyzeThisGame: string;
};
// Game
@@ -120,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
@@ -209,6 +216,7 @@ const en: Translations = {
evaluationLabel: 'Evaluation',
noEvaluation: 'No evaluation yet',
deleteGame: 'Delete game',
analyzeThisGame: 'Analyze this game',
},
game: {
playingAs: 'Playing as',
@@ -273,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',
@@ -363,6 +377,7 @@ const de: Translations = {
evaluationLabel: 'Bewertung',
noEvaluation: 'Keine Bewertung',
deleteGame: 'Partie löschen',
analyzeThisGame: 'Diese Partie analysieren',
},
game: {
playingAs: 'Spielst als',
@@ -427,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',
@@ -517,6 +538,7 @@ const fr: Translations = {
evaluationLabel: 'Évaluation',
noEvaluation: 'Pas d\'évaluation',
deleteGame: 'Supprimer la partie',
analyzeThisGame: 'Analyser cette partie',
},
game: {
playingAs: 'Jouant',
@@ -581,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',
@@ -671,6 +699,7 @@ const it: Translations = {
evaluationLabel: 'Valutazione',
noEvaluation: 'Nessuna valutazione',
deleteGame: 'Elimina partita',
analyzeThisGame: 'Analizza questa partita',
},
game: {
playingAs: 'Giocando',
@@ -735,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',