diff --git a/src/app/analysis/__tests__/page.test.tsx b/src/app/analysis/__tests__/page.test.tsx index db43817..19db74c 100644 --- a/src/app/analysis/__tests__/page.test.tsx +++ b/src/app/analysis/__tests__/page.test.tsx @@ -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(); + render( + + + + ); 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(); + render( + + + + ); const textarea = screen.getByPlaceholderText(/Paste PGN or FEN here/i); fireEvent.change(textarea, { target: { value: "invalid" } }); fireEvent.click(screen.getByText(/Start Analysis/i)); diff --git a/src/app/analysis/page.tsx b/src/app/analysis/page.tsx index 2090086..1963fae 100644 --- a/src/app/analysis/page.tsx +++ b/src/app/analysis/page.tsx @@ -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 } from "lucide-react"; import { useRouter } from "next/navigation"; import Header from "@/components/Header"; @@ -64,6 +64,10 @@ export default function AnalysisPage() { const [comments, setComments] = useState>({}); const [chatSession, setChatSession] = useState(null); const [showImportModal, setShowImportModal] = useState(false); + const [showPlayModal, setShowPlayModal] = useState(false); + const [playPersonality, setPlayPersonality] = useState(PERSONALITIES[0]); + const [playColor, setPlayColor] = useState<"white" | "black">("white"); + const [playStrength, setPlayStrength] = useState(15); useEffect(() => { const storedKey = localStorage.getItem("gemini_api_key"); @@ -72,6 +76,10 @@ export default function AnalysisPage() { if (storedLang) setLanguage(storedLang as SupportedLanguage); }, []); + useEffect(() => { + setPlayPersonality(selectedPersonality); + }, [selectedPersonality]); + useEffect(() => { const sf = new Stockfish(); setStockfish(sf); @@ -218,6 +226,18 @@ IMPORTANT: loadGameFromPgnOrFen(pgn); }; + 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); @@ -497,6 +517,17 @@ INSTRUCTIONS: + + { + 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" + > + {t.analysis.playFromHere} + + @@ -597,6 +628,100 @@ INSTRUCTIONS: language={language} /> )} + + {/* Play From Position Modal */} + {showPlayModal && ( + + + + + {t.analysis.playFromHere} + {t.analysis.playDescription} + + setShowPlayModal(false)} + className="text-gray-500 hover:text-gray-700 dark:hover:text-gray-200" + aria-label={t.common.close} + > + ✕ + + + + + + {t.analysis.chooseOpponent} + + {PERSONALITIES.map(p => ( + 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"}`} + > + {p.image} + + {p.name} + {p.description} + + + ))} + + + + + + {t.analysis.chooseSide} + + {(["white", "black"] as const).map(color => ( + 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} + + ))} + + + + + {t.analysis.chooseStrength} + + {t.game.stockfishStrength}: {playStrength} + setPlayStrength(parseInt(e.target.value))} + className="w-full" + /> + {t.game.depth}: {playStrength} + + + + + + + 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} + + + {t.analysis.startPlay} + + + + + )} ); } diff --git a/src/app/page.tsx b/src/app/page.tsx index 0a28489..44f80d7 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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([]); @@ -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} /> )} diff --git a/src/components/ChessGame.tsx b/src/components/ChessGame.tsx index 0932d4d..e795535 100644 --- a/src/components/ChessGame.tsx +++ b/src/components/ChessGame.tsx @@ -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 = { '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(null); @@ -55,7 +56,7 @@ export default function ChessGame({ gameId, initialFen, initialPgn, initialPerso const [computerMove, setComputerMove] = useState(null); const [isAnalyzing, setIsAnalyzing] = useState(false); const [apiKey, setApiKey] = useState(null); - const [stockfishDepth, setStockfishDepth] = useState(15); + const [stockfishDepth, setStockfishDepth] = useState(initialStockfishDepth ?? 15); // Settings const [language, setLanguage] = useState('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"); diff --git a/src/lib/i18n/translations.ts b/src/lib/i18n/translations.ts index bf9bec9..97832d5 100644 --- a/src/lib/i18n/translations.ts +++ b/src/lib/i18n/translations.ts @@ -97,6 +97,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; @@ -244,6 +250,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', @@ -392,6 +404,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', @@ -540,6 +558,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', @@ -688,6 +712,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',
{t.analysis.playDescription}
{t.analysis.chooseOpponent}
{t.analysis.chooseSide}
{t.analysis.chooseStrength}