f8cf03c888
- Add family training mode where users select an opening family (e.g. "Italian Game") and can play any variation within that family - Create variation tree data structure to efficiently track which variations match the current move sequence - FamilySelector now navigates directly to /learning/openings/family/[familyName] instead of showing a second selector for individual variations - OpeningTrainer displays matching variations, possible moves, and current line - LLM tutor explains which variation is being played and mentions alternatives - Remove the OpeningSelector step from the flow for a simpler UX The new flow: 1. User selects an opening family (e.g. "Sicilian Defense") 2. Training starts immediately with all variations loaded 3. User can play any move that exists in any variation 4. The UI shows which variations are still possible 5. The AI tutor guides the user through the repertoire
724 lines
27 KiB
TypeScript
724 lines
27 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useState, useMemo } from 'react';
|
|
import { Chess } from 'chess.js';
|
|
import { Chessboard } from 'react-chessboard';
|
|
import { OpeningMetadata } from '@/lib/openings';
|
|
import { useOpeningTraining, useChessInstance } from '@/contexts/OpeningTrainingContext';
|
|
import { loadSession } from '@/lib/openingTrainer/sessionManager';
|
|
import {
|
|
parseMoveSequence,
|
|
getUserColor,
|
|
VariationTree,
|
|
getAllPossibleNextMoves,
|
|
identifyCurrentVariation,
|
|
isMoveInVariationTree,
|
|
describeCurrentPosition,
|
|
} from '@/lib/openingTrainer/gameLogic';
|
|
import { getWikipediaSummary } from '@/lib/openingTrainer/wikipediaService';
|
|
import { WikipediaSummary as WikipediaSummaryType } from '@/types/openingTraining';
|
|
import { extractFamilyName } from '@/lib/openingTrainer/openingFamilies';
|
|
import WikipediaSummary from './WikipediaSummary';
|
|
import DeviationDialog from './DeviationDialog';
|
|
import { Tutor } from '@/components/Tutor';
|
|
import { Personality } from '@/lib/personalities';
|
|
import { SupportedLanguage } from '@/lib/i18n/translations';
|
|
import { useRouter } from 'next/navigation';
|
|
|
|
interface OpeningTrainerProps {
|
|
opening: OpeningMetadata;
|
|
personality: Personality;
|
|
apiKey: string;
|
|
language: SupportedLanguage;
|
|
// Family training mode - allows multiple variations
|
|
variationTree?: VariationTree;
|
|
allVariations?: OpeningMetadata[];
|
|
}
|
|
|
|
export default function OpeningTrainer({
|
|
opening,
|
|
personality,
|
|
apiKey,
|
|
language,
|
|
variationTree,
|
|
allVariations,
|
|
}: OpeningTrainerProps) {
|
|
const router = useRouter();
|
|
const isFamilyMode = !!variationTree && !!allVariations;
|
|
|
|
const {
|
|
session,
|
|
stockfish,
|
|
currentFeedback,
|
|
initializeSession,
|
|
makeMove,
|
|
navigateToMove,
|
|
} = useOpeningTraining();
|
|
|
|
// Get Chess instance on-demand from current FEN
|
|
const chess = useChessInstance(session);
|
|
|
|
const [boardOrientation, setBoardOrientation] = useState<'white' | 'black'>(
|
|
'white'
|
|
);
|
|
const [isInitializing, setIsInitializing] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [showRecoveryDialog, setShowRecoveryDialog] = useState(false);
|
|
const [existingSession, setExistingSession] = useState<any>(null);
|
|
const [wikipediaSummary, setWikipediaSummary] = useState<WikipediaSummaryType | null>(null);
|
|
|
|
// Tutor message control - track when tutor last spoke
|
|
const [lastTutorMessageMoveIndex, setLastTutorMessageMoveIndex] = useState<number>(-1);
|
|
|
|
// Deviation handling
|
|
const [showDeviationDialog, setShowDeviationDialog] = useState(false);
|
|
|
|
// ============================================================================
|
|
// Tutor Message Guardrail (computed values - must be before early returns)
|
|
// ============================================================================
|
|
|
|
const moveCount = session?.moveHistory.length ?? 0;
|
|
const userColor = getUserColor(opening);
|
|
|
|
// Determine when tutor should be allowed to speak
|
|
const shouldTutorSpeak = useMemo(() => {
|
|
if (!session || moveCount === 0) {
|
|
// At start, tutor can give initial greeting
|
|
return lastTutorMessageMoveIndex === -1;
|
|
}
|
|
|
|
// Check if we've had new moves since tutor last spoke
|
|
const newMovesSinceLastMessage = moveCount - lastTutorMessageMoveIndex;
|
|
|
|
if (session.deviationMoveIndex !== null) {
|
|
// Off-book: Tutor speaks immediately after player's deviation
|
|
return newMovesSinceLastMessage >= 1;
|
|
}
|
|
|
|
// In theory: Wait for both player AND opponent to move
|
|
const isAtEndOfRepertoire = session.phase === 'end_of_repertoire';
|
|
const movesNeeded = isAtEndOfRepertoire ? 1 : 2;
|
|
|
|
return newMovesSinceLastMessage >= movesNeeded;
|
|
}, [session, moveCount, lastTutorMessageMoveIndex]);
|
|
|
|
// Detect deviation and show dialog
|
|
useEffect(() => {
|
|
if (session?.deviationMoveIndex !== null && !showDeviationDialog) {
|
|
const timer = setTimeout(() => {
|
|
setShowDeviationDialog(true);
|
|
}, 500);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
}, [session?.deviationMoveIndex, showDeviationDialog]);
|
|
|
|
useEffect(() => {
|
|
checkForExistingSession();
|
|
}, [opening.eco]);
|
|
|
|
// Fetch Wikipedia summary for the opening (using family name for better results)
|
|
useEffect(() => {
|
|
const fetchWikipediaSummary = async () => {
|
|
try {
|
|
// Extract family name (e.g., "French Defense" from "French Defense: Exchange Variation")
|
|
// This ensures we find the Wikipedia article for the main opening, not specific variations
|
|
const familyName = extractFamilyName(opening.name);
|
|
console.log(`[Wikipedia] Looking up: "${familyName}" (from "${opening.name}")`);
|
|
|
|
const summary = await getWikipediaSummary(familyName);
|
|
setWikipediaSummary(summary);
|
|
} catch (err) {
|
|
console.error('Failed to fetch Wikipedia summary:', err);
|
|
// Silently fail - Wikipedia is nice-to-have, not critical
|
|
}
|
|
};
|
|
|
|
fetchWikipediaSummary();
|
|
}, [opening.name]);
|
|
|
|
const checkForExistingSession = () => {
|
|
const saved = loadSession(opening.eco);
|
|
|
|
if (saved && saved.moveHistory.length > 0) {
|
|
// Found existing session with moves
|
|
setExistingSession(saved);
|
|
setShowRecoveryDialog(true);
|
|
setIsInitializing(false);
|
|
} else {
|
|
// No existing session or empty session - start fresh
|
|
initSession(false);
|
|
}
|
|
};
|
|
|
|
const initSession = async (forceNew: boolean = false) => {
|
|
setIsInitializing(true);
|
|
setError(null);
|
|
setShowRecoveryDialog(false);
|
|
|
|
try {
|
|
await initializeSession(opening, forceNew);
|
|
|
|
// Determine board orientation from opening
|
|
// ECO D and E are typically Black defenses
|
|
const orientation = ['D', 'E'].includes(opening.eco[0]) ? 'black' : 'white';
|
|
setBoardOrientation(orientation);
|
|
} catch (err) {
|
|
console.error('Session initialization error:', err);
|
|
setError('Failed to initialize training session');
|
|
} finally {
|
|
setIsInitializing(false);
|
|
}
|
|
};
|
|
|
|
const handleResumeSession = () => {
|
|
initSession(false);
|
|
};
|
|
|
|
const handleStartFresh = () => {
|
|
initSession(true);
|
|
};
|
|
|
|
const handlePieceDrop = (
|
|
sourceSquare: string,
|
|
targetSquare: string
|
|
): boolean => {
|
|
if (!chess) return false;
|
|
|
|
try {
|
|
// Create a temporary clone to test if the move is legal
|
|
// WITHOUT modifying the actual chess instance
|
|
const testChess = new Chess();
|
|
testChess.loadPgn(chess.pgn());
|
|
|
|
// Try to make the move on the clone (this can throw for invalid moves)
|
|
let move;
|
|
try {
|
|
move = testChess.move({
|
|
from: sourceSquare,
|
|
to: targetSquare,
|
|
promotion: 'q', // Always promote to queen for simplicity
|
|
});
|
|
} catch (moveError) {
|
|
// Invalid move format or illegal move - silently reject
|
|
console.log('Invalid move attempt:', { from: sourceSquare, to: targetSquare });
|
|
return false;
|
|
}
|
|
|
|
if (move === null) {
|
|
// Illegal move
|
|
return false;
|
|
}
|
|
|
|
// Move was legal - process it on the actual chess instance via makeMove
|
|
makeMove(move.san);
|
|
return true;
|
|
} catch (error) {
|
|
console.error('Unexpected error in handlePieceDrop:', error);
|
|
return false;
|
|
}
|
|
};
|
|
|
|
// ============================================================================
|
|
// Deviation Dialog Handlers
|
|
// ============================================================================
|
|
|
|
const handleUndoDeviation = () => {
|
|
if (!session || session.deviationMoveIndex === null) return;
|
|
|
|
// Navigate back to the move before deviation
|
|
navigateToMove(session.deviationMoveIndex - 1);
|
|
setShowDeviationDialog(false);
|
|
|
|
// After a brief delay, make another legal move to continue in theory
|
|
// This allows the player to try again
|
|
};
|
|
|
|
const handleStartGameFromPosition = () => {
|
|
if (!session || !chess) return;
|
|
|
|
// Store the game start data in localStorage with the expected key
|
|
// The home page (/) will pick this up and start the game
|
|
const gameStartData = {
|
|
fen: session.currentFEN,
|
|
personalityId: personality.id,
|
|
color: getUserColor(opening),
|
|
stockfishDepth: 15,
|
|
};
|
|
|
|
localStorage.setItem('chess_tutor_pending_game', JSON.stringify(gameStartData));
|
|
|
|
// Optional: Store additional context for the tutor
|
|
const openingContext = {
|
|
openingName: opening.name,
|
|
openingEco: opening.eco,
|
|
movesCompleted: session.deviationMoveIndex || session.moveHistory.length,
|
|
wikipediaSummary: wikipediaSummary?.extract,
|
|
contextMessage: `You've studied the ${opening.name} (${opening.eco}) up to move ${
|
|
session.deviationMoveIndex || session.moveHistory.length
|
|
}. Let's continue playing from here!`,
|
|
};
|
|
|
|
localStorage.setItem('chess_tutor_opening_context', JSON.stringify(openingContext));
|
|
|
|
// Navigate to home page which will start the game
|
|
router.push('/');
|
|
};
|
|
|
|
const handleContinueExploring = () => {
|
|
// User wants to continue exploring off-book moves
|
|
// Just close the dialog and let them continue
|
|
setShowDeviationDialog(false);
|
|
};
|
|
|
|
const handleTutorMessageSent = () => {
|
|
// Called when tutor successfully sends a message
|
|
// Update the tracking to prevent rapid-fire messages
|
|
setLastTutorMessageMoveIndex(moveCount);
|
|
};
|
|
|
|
// Session recovery dialog
|
|
if (showRecoveryDialog && existingSession) {
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-center min-h-[500px]">
|
|
<div className="bg-white rounded-lg shadow-xl p-8 max-w-md">
|
|
<h2 className="text-2xl font-bold text-gray-900 mb-4">
|
|
Resume Training?
|
|
</h2>
|
|
<p className="text-gray-600 mb-6">
|
|
You have an existing training session for this opening with{' '}
|
|
<span className="font-semibold">
|
|
{existingSession.moveHistory.length} move
|
|
{existingSession.moveHistory.length !== 1 ? 's' : ''}
|
|
</span>
|
|
. Would you like to resume where you left off or start fresh?
|
|
</p>
|
|
|
|
<div className="space-y-3">
|
|
<button
|
|
onClick={handleResumeSession}
|
|
className="w-full px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium"
|
|
>
|
|
Resume Session
|
|
</button>
|
|
<button
|
|
onClick={handleStartFresh}
|
|
className="w-full px-6 py-3 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 font-medium"
|
|
>
|
|
Start Fresh
|
|
</button>
|
|
</div>
|
|
|
|
<p className="text-xs text-gray-500 mt-4 text-center">
|
|
Last updated:{' '}
|
|
{new Date(existingSession.lastUpdated).toLocaleString()}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (isInitializing) {
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-center min-h-[500px]">
|
|
<div className="text-center space-y-4">
|
|
<div className="w-12 h-12 border-4 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto"></div>
|
|
<p className="text-gray-600 dark:text-gray-400">Initializing training session...</p>
|
|
{!stockfish && (
|
|
<p className="text-sm text-gray-500 dark:text-gray-400">Loading chess engine...</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="bg-red-50 border border-red-200 rounded-lg p-6 text-center">
|
|
<h3 className="font-semibold text-red-900 mb-2">Error</h3>
|
|
<p className="text-red-700">{error}</p>
|
|
<button
|
|
onClick={() => initSession()}
|
|
className="mt-4 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
|
|
>
|
|
Retry
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!session || !chess) {
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-6 text-center">
|
|
<p className="text-gray-600 dark:text-gray-400">No active session</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const currentPosition = chess.fen();
|
|
|
|
// Build opening practice mode prop for Tutor
|
|
const repertoireMoves = parseMoveSequence(opening.moves);
|
|
const lastMove = session.moveHistory.length > 0
|
|
? session.moveHistory[session.moveHistory.length - 1]
|
|
: null;
|
|
|
|
// Determine which color the tutor is playing
|
|
const tutorColor = userColor === 'white' ? 'black' : 'white';
|
|
|
|
// Find last user move and last tutor move
|
|
const userMoves = session.moveHistory.filter(
|
|
m => m.color === userColor
|
|
);
|
|
const tutorMoves = session.moveHistory.filter(
|
|
m => m.color === tutorColor
|
|
);
|
|
|
|
const lastUserMove = userMoves.length > 0 ? userMoves[userMoves.length - 1] : null;
|
|
const lastTutorMove = tutorMoves.length > 0 ? tutorMoves[tutorMoves.length - 1] : null;
|
|
|
|
// Family mode: compute current position info from variation tree
|
|
const variationPositionInfo = useMemo(() => {
|
|
if (!isFamilyMode || !variationTree) {
|
|
return null;
|
|
}
|
|
const positionDesc = describeCurrentPosition(variationTree, session.moveHistory);
|
|
const currentVariations = identifyCurrentVariation(variationTree, session.moveHistory);
|
|
const possibleMoves = getAllPossibleNextMoves(variationTree, session.moveHistory);
|
|
|
|
return {
|
|
...positionDesc,
|
|
currentVariations,
|
|
possibleMoves,
|
|
isInAnyVariation: positionDesc.matchingCount > 0,
|
|
};
|
|
}, [isFamilyMode, variationTree, session.moveHistory]);
|
|
|
|
// Get all possible next moves (for display and tutor context)
|
|
const theoreticalMoves = useMemo(() => {
|
|
if (isFamilyMode && variationPositionInfo) {
|
|
return variationPositionInfo.nextMoves;
|
|
}
|
|
// Single variation mode
|
|
const moves = parseMoveSequence(opening.moves);
|
|
const nextMove = moves[session.moveHistory.length];
|
|
return nextMove ? [nextMove] : [];
|
|
}, [isFamilyMode, variationPositionInfo, opening.moves, session.moveHistory.length]);
|
|
|
|
const openingPracticeMode = {
|
|
openingName: opening.name,
|
|
openingEco: opening.eco,
|
|
repertoireMoves,
|
|
currentMoveIndex: session.moveHistory.length,
|
|
isInTheory: isFamilyMode
|
|
? (variationPositionInfo?.isInAnyVariation ?? false)
|
|
: session.deviationMoveIndex === null,
|
|
deviationMoveIndex: session.deviationMoveIndex,
|
|
lastUserMove: lastUserMove ? {
|
|
from: lastUserMove.uci.substring(0, 2),
|
|
to: lastUserMove.uci.substring(2, 4),
|
|
san: lastUserMove.san,
|
|
color: lastUserMove.color === 'white' ? 'w' : 'b',
|
|
piece: lastUserMove.san[0].toLowerCase(),
|
|
flags: '',
|
|
captured: undefined,
|
|
promotion: lastUserMove.uci.length > 4 ? lastUserMove.uci[4] : undefined
|
|
} as any : null,
|
|
lastTutorMove: lastTutorMove ? {
|
|
from: lastTutorMove.uci.substring(0, 2),
|
|
to: lastTutorMove.uci.substring(2, 4),
|
|
san: lastTutorMove.san,
|
|
color: lastTutorMove.color === 'white' ? 'w' : 'b',
|
|
piece: lastTutorMove.san[0].toLowerCase(),
|
|
flags: '',
|
|
captured: undefined,
|
|
promotion: lastTutorMove.uci.length > 4 ? lastTutorMove.uci[4] : undefined
|
|
} as any : null,
|
|
currentFeedback: currentFeedback ? {
|
|
category: currentFeedback.classification.category,
|
|
evaluationChange: currentFeedback.classification.evaluationChange,
|
|
theoreticalAlternatives: isFamilyMode ? theoreticalMoves : currentFeedback.classification.theoreticalAlternatives
|
|
} : null,
|
|
wikipediaSummary: wikipediaSummary?.extract || undefined,
|
|
shouldTutorSpeak,
|
|
onTutorMessageSent: handleTutorMessageSent,
|
|
// Family mode specific info
|
|
isFamilyMode,
|
|
variationInfo: isFamilyMode && variationPositionInfo ? {
|
|
matchingVariations: variationPositionInfo.matchingCount,
|
|
currentVariationNames: variationPositionInfo.currentVariationNames,
|
|
possibleMoves: variationPositionInfo.nextMoves,
|
|
isEndOfLine: variationPositionInfo.isEndOfLine,
|
|
} : undefined,
|
|
};
|
|
|
|
return (
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
{/* Main board area */}
|
|
<div className="lg:col-span-2 space-y-4">
|
|
{/* Board */}
|
|
<div
|
|
className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4"
|
|
role="region"
|
|
aria-label="Chess board"
|
|
>
|
|
<Chessboard
|
|
key={currentPosition}
|
|
options={{
|
|
position: currentPosition,
|
|
onPieceDrop: ({ sourceSquare, targetSquare }) => {
|
|
if (!targetSquare) return false;
|
|
return handlePieceDrop(sourceSquare, targetSquare);
|
|
},
|
|
boardOrientation: boardOrientation,
|
|
darkSquareStyle: { backgroundColor: '#779954' },
|
|
lightSquareStyle: { backgroundColor: '#e9edcc' },
|
|
animationDurationInMs: 200,
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{/* Move controls */}
|
|
<div
|
|
className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4"
|
|
role="region"
|
|
aria-label="Move history and navigation"
|
|
>
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h3 className="font-semibold text-gray-900 dark:text-white">Move History</h3>
|
|
<div className="flex gap-2" role="group" aria-label="Move navigation">
|
|
<button
|
|
onClick={() =>
|
|
navigateToMove(Math.max(0, session.currentMoveIndex - 1))
|
|
}
|
|
disabled={session.currentMoveIndex === 0}
|
|
className="px-3 py-1 text-sm bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-300 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
aria-label="Go to previous move"
|
|
>
|
|
← Back
|
|
</button>
|
|
<button
|
|
onClick={() =>
|
|
navigateToMove(
|
|
Math.min(moveCount - 1, session.currentMoveIndex + 1)
|
|
)
|
|
}
|
|
disabled={session.currentMoveIndex >= moveCount - 1}
|
|
className="px-3 py-1 text-sm bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-300 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
aria-label="Go to next move"
|
|
>
|
|
Forward →
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Move list */}
|
|
<div
|
|
className="space-y-2 max-h-[200px] overflow-y-auto"
|
|
role="list"
|
|
aria-label="List of moves played"
|
|
>
|
|
{moveCount === 0 ? (
|
|
<p className="text-sm text-gray-500 dark:text-gray-400 text-center py-4">
|
|
No moves yet. Make your first move!
|
|
</p>
|
|
) : (
|
|
session.moveHistory.map((move, index) => (
|
|
<div
|
|
key={index}
|
|
onClick={() => navigateToMove(index)}
|
|
role="listitem"
|
|
className={`p-2 rounded cursor-pointer transition-colors ${
|
|
index === session.currentMoveIndex
|
|
? 'bg-blue-100 dark:bg-blue-900/30 border border-blue-300 dark:border-blue-700'
|
|
: 'bg-gray-50 dark:bg-gray-700/50 hover:bg-gray-100 dark:hover:bg-gray-700'
|
|
}`}
|
|
aria-label={`Move ${move.moveNumber}${
|
|
move.color === 'white' ? '.' : '...'
|
|
} ${move.san}, classified as ${move.classification.category}`}
|
|
aria-current={index === session.currentMoveIndex ? 'true' : undefined}
|
|
>
|
|
<div className="flex justify-between items-center">
|
|
<span className="font-mono text-sm font-semibold">
|
|
{move.moveNumber}
|
|
{move.color === 'white' ? '.' : '...'} {move.san}
|
|
</span>
|
|
<span
|
|
className={`text-xs px-2 py-1 rounded ${
|
|
move.classification.category === 'in-theory'
|
|
? 'bg-green-100 text-green-800'
|
|
: move.classification.category === 'playable'
|
|
? 'bg-yellow-100 text-yellow-800'
|
|
: 'bg-red-100 text-red-800'
|
|
}`}
|
|
>
|
|
{move.classification.category}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Sidebar - tutor and info */}
|
|
<div className="space-y-4">
|
|
{/* Tutor Chat */}
|
|
{apiKey ? (
|
|
<Tutor
|
|
game={chess}
|
|
currentFen={currentPosition}
|
|
userMove={null} // Will be updated in Phase 2
|
|
computerMove={null}
|
|
stockfish={stockfish}
|
|
evalP0={null}
|
|
evalP2={null}
|
|
openingData={[]}
|
|
missedTactics={[]}
|
|
onAnalysisComplete={() => {}}
|
|
apiKey={apiKey}
|
|
personality={personality}
|
|
language={language}
|
|
playerColor={userColor}
|
|
onCheckComputerMove={() => {}}
|
|
resignationContext={null}
|
|
openingPracticeMode={openingPracticeMode}
|
|
/>
|
|
) : (
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 p-6">
|
|
<div className="text-center">
|
|
<div className="text-4xl mb-4">🔑</div>
|
|
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-2">
|
|
API Key Required
|
|
</h3>
|
|
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
|
To chat with your coach, please set up your Gemini API key in the settings.
|
|
</p>
|
|
<button
|
|
onClick={() => window.location.href = '/onboarding'}
|
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
|
>
|
|
Set Up API Key
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Wikipedia summary */}
|
|
<WikipediaSummary
|
|
openingName={opening.name}
|
|
wikipediaSlug={opening.wikipediaSlug}
|
|
language={language}
|
|
/>
|
|
|
|
{/* Session info */}
|
|
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 space-y-2 text-sm">
|
|
<div className="flex justify-between">
|
|
<span className="text-gray-600 dark:text-gray-400">Opening:</span>
|
|
<span className="font-medium text-gray-900 dark:text-white">{opening.eco}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-gray-600 dark:text-gray-400">Moves played:</span>
|
|
<span className="font-medium text-gray-900 dark:text-white">{moveCount}</span>
|
|
</div>
|
|
|
|
{/* Family mode: show variation info */}
|
|
{isFamilyMode && variationPositionInfo && (
|
|
<>
|
|
<div className="pt-2 border-t border-gray-300 dark:border-gray-600">
|
|
<div className="flex justify-between mb-1">
|
|
<span className="text-gray-600 dark:text-gray-400">Matching variations:</span>
|
|
<span className="font-medium text-blue-600 dark:text-blue-400">
|
|
{variationPositionInfo.matchingCount}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Show possible moves */}
|
|
{variationPositionInfo.nextMoves.length > 0 && (
|
|
<div className="mt-2">
|
|
<span className="text-gray-600 dark:text-gray-400 block mb-1">Possible moves:</span>
|
|
<div className="flex flex-wrap gap-1">
|
|
{variationPositionInfo.nextMoves.map((move) => (
|
|
<span
|
|
key={move}
|
|
className="inline-block px-2 py-1 bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-400 rounded text-xs font-mono"
|
|
>
|
|
{move}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Show current variation names (if narrowed down) */}
|
|
{variationPositionInfo.matchingCount > 0 && variationPositionInfo.matchingCount <= 3 && (
|
|
<div className="mt-2">
|
|
<span className="text-gray-600 dark:text-gray-400 block mb-1">Current line:</span>
|
|
<div className="space-y-1">
|
|
{variationPositionInfo.currentVariationNames.slice(0, 3).map((name) => (
|
|
<span
|
|
key={name}
|
|
className="block text-xs text-gray-700 dark:text-gray-300 truncate"
|
|
title={name}
|
|
>
|
|
{name}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{variationPositionInfo.isEndOfLine && (
|
|
<div className="mt-2">
|
|
<span className="inline-block px-2 py-1 bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-400 rounded text-xs">
|
|
End of repertoire line
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Show off-book indicator (non-family mode or when truly off-book) */}
|
|
{!isFamilyMode && session.deviationMoveIndex !== null && (
|
|
<div className="pt-2 border-t border-gray-300 dark:border-gray-600">
|
|
<span className="inline-block px-2 py-1 bg-orange-100 dark:bg-orange-900/30 text-orange-800 dark:text-orange-400 rounded text-xs">
|
|
Off-book since move {session.deviationMoveIndex + 1}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Family mode: show off-book when not in any variation */}
|
|
{isFamilyMode && variationPositionInfo && !variationPositionInfo.isInAnyVariation && moveCount > 0 && (
|
|
<div className="pt-2 border-t border-gray-300 dark:border-gray-600">
|
|
<span className="inline-block px-2 py-1 bg-orange-100 dark:bg-orange-900/30 text-orange-800 dark:text-orange-400 rounded text-xs">
|
|
Off-book - move not in any variation
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Deviation Dialog */}
|
|
{showDeviationDialog && session?.deviationMoveIndex !== null && (
|
|
<DeviationDialog
|
|
openingName={opening.name}
|
|
movesCompleted={session.deviationMoveIndex}
|
|
onUndo={handleUndoDeviation}
|
|
onStartGame={handleStartGameFromPosition}
|
|
onContinueExploring={handleContinueExploring}
|
|
language={language}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|