diff --git a/src/app/analysis/page.tsx b/src/app/analysis/page.tsx index fa038bb..2090086 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 } from "lucide-react"; +import { Brain, ChevronLeft, ChevronRight, Loader2, ArrowLeft, Download } from "lucide-react"; import { useRouter } from "next/navigation"; import Header from "@/components/Header"; @@ -18,6 +18,7 @@ import { getGenAIModel } from "@/lib/gemini"; import { ChatSession } from "@google/generative-ai"; import ReactMarkdown from "react-markdown"; import { useDebug } from "@/contexts/DebugContext"; +import { GameImportModal } from "@/components/GameImportModal"; interface MoveStep { san: string; @@ -62,6 +63,7 @@ export default function AnalysisPage() { const [isCommenting, setIsCommenting] = useState(false); const [comments, setComments] = useState>({}); const [chatSession, setChatSession] = useState(null); + const [showImportModal, setShowImportModal] = useState(false); useEffect(() => { const storedKey = localStorage.getItem("gemini_api_key"); @@ -144,6 +146,18 @@ IMPORTANT: return; } + loadGameFromPgnOrFen(trimmed); + }; + + const loadGameFromPgnOrFen = (notation: string) => { + const trimmed = notation.trim(); + const format = detectChessFormat(trimmed); + + if (!trimmed || format === "invalid") { + setError(t.analysis.importError); + return; + } + try { const parsedGame = new Chess(); const nextSteps: MoveStep[] = []; @@ -198,6 +212,12 @@ IMPORTANT: } }; + const handleImportGame = (pgn: string) => { + setInput(pgn); + setDetectedFormat(detectChessFormat(pgn)); + loadGameFromPgnOrFen(pgn); + }; + useEffect(() => { if (!stockfish || !currentFen) return; ensureEvaluation(currentFen); @@ -417,20 +437,45 @@ INSTRUCTIONS: > {t.analysis.startButton} + + {/* Import from Online Platforms */} +
+

+ Or import from online platforms +

+
+ + +
+
- +
+ +
+ + {/* Game Import Modal */} + {showImportModal && ( + setShowImportModal(false)} + onSelectGame={handleImportGame} + language={language} + /> + )} ); } diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index e934e84..a77c637 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -11,15 +11,21 @@ export default function SettingsPage() { const router = useRouter(); const [apiKey, setApiKey] = useState(""); const [language, setLanguage] = useState('en'); + const [chesscomUsername, setChesscomUsername] = useState(""); + const [lichessUsername, setLichessUsername] = useState(""); const [mounted, setMounted] = useState(false); // Load settings on mount useEffect(() => { const storedKey = localStorage.getItem("gemini_api_key"); const storedLang = localStorage.getItem("chess_tutor_language"); + const storedChesscomUsername = localStorage.getItem("chesscom_username"); + const storedLichessUsername = localStorage.getItem("lichess_username"); if (storedKey) setApiKey(storedKey); if (storedLang) setLanguage(storedLang as SupportedLanguage); + if (storedChesscomUsername) setChesscomUsername(storedChesscomUsername); + if (storedLichessUsername) setLichessUsername(storedLichessUsername); setMounted(true); }, []); @@ -35,6 +41,19 @@ export default function SettingsPage() { localStorage.setItem("chess_tutor_language", language); + // Save online platform usernames + if (chesscomUsername.trim()) { + localStorage.setItem("chesscom_username", chesscomUsername.trim()); + } else { + localStorage.removeItem("chesscom_username"); + } + + if (lichessUsername.trim()) { + localStorage.setItem("lichess_username", lichessUsername.trim()); + } else { + localStorage.removeItem("lichess_username"); + } + // Go back to home router.push("/"); }; @@ -99,6 +118,46 @@ export default function SettingsPage() {

+ + {/* Online Platform Usernames */} +
+

+ Online Platform Integration +

+

+ Save your usernames to quickly import games from Chess.com and Lichess in the Analysis page. +

+ +
+ {/* Chess.com Username */} +
+ + setChesscomUsername(e.target.value)} + placeholder="Enter your Chess.com username" + className="w-full p-3 border rounded-lg dark:bg-gray-700 dark:border-gray-600 text-gray-900 dark:text-white focus:ring-2 focus:ring-green-500 outline-none transition-all" + /> +
+ + {/* Lichess Username */} +
+ + setLichessUsername(e.target.value)} + placeholder="Enter your Lichess username" + className="w-full p-3 border rounded-lg dark:bg-gray-700 dark:border-gray-600 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 outline-none transition-all" + /> +
+
+
diff --git a/src/components/GameImportModal.tsx b/src/components/GameImportModal.tsx new file mode 100644 index 0000000..0b9961f --- /dev/null +++ b/src/components/GameImportModal.tsx @@ -0,0 +1,260 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Chessboard } from "react-chessboard"; +import { X, Loader2, Download, ExternalLink } from "lucide-react"; +import { fetchChessComGames, fetchLichessGames, GameMetadata, Platform } from "@/lib/gameImport"; + +interface GameImportModalProps { + onClose: () => void; + onSelectGame: (pgn: string) => void; + language: 'en' | 'de' | 'fr' | 'it'; +} + +export function GameImportModal({ onClose, onSelectGame, language }: GameImportModalProps) { + const [platform, setPlatform] = useState('chesscom'); + const [username, setUsername] = useState(''); + const [games, setGames] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + // Load saved usernames from localStorage + useEffect(() => { + const savedChessComUsername = localStorage.getItem('chesscom_username'); + const savedLichessUsername = localStorage.getItem('lichess_username'); + + if (platform === 'chesscom' && savedChessComUsername) { + setUsername(savedChessComUsername); + } else if (platform === 'lichess' && savedLichessUsername) { + setUsername(savedLichessUsername); + } else { + setUsername(''); + } + }, [platform]); + + const handleFetchGames = async () => { + if (!username.trim()) { + setError('Please enter a username'); + return; + } + + setIsLoading(true); + setError(null); + setGames([]); + + try { + let fetchedGames: GameMetadata[]; + + if (platform === 'chesscom') { + fetchedGames = await fetchChessComGames(username.trim(), 20); + // Save username to localStorage + localStorage.setItem('chesscom_username', username.trim()); + } else { + fetchedGames = await fetchLichessGames(username.trim(), 20); + // Save username to localStorage + localStorage.setItem('lichess_username', username.trim()); + } + + if (fetchedGames.length === 0) { + setError('No games found for this user'); + } else { + setGames(fetchedGames); + } + } catch (err) { + console.error('Error fetching games:', err); + setError(`Failed to fetch games. Please check the username and try again.`); + } finally { + setIsLoading(false); + } + }; + + const handleSelectGame = (game: GameMetadata) => { + onSelectGame(game.pgn); + onClose(); + }; + + const formatDate = (isoDate: string) => { + const date = new Date(isoDate); + return date.toLocaleDateString(language, { year: 'numeric', month: 'short', day: 'numeric' }); + }; + + const getResultColor = (result: string, white: string, black: string, currentUsername: string) => { + if (result === '1/2-1/2') return 'text-gray-600 dark:text-gray-400'; + + const isWhite = white.toLowerCase() === currentUsername.toLowerCase(); + const won = (result === '1-0' && isWhite) || (result === '0-1' && !isWhite); + + return won ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'; + }; + + const getResultText = (result: string) => { + if (result === '1-0') return 'White Won'; + if (result === '0-1') return 'Black Won'; + if (result === '1/2-1/2') return 'Draw'; + return 'In Progress'; + }; + + return ( +
+
+ {/* Header */} +
+

+ + Import Game from Online Platform +

+ +
+ + {/* Content */} +
+ {/* Platform Selection */} +
+ +
+ + +
+
+ + {/* Username Input */} +
+ +
+ setUsername(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleFetchGames()} + placeholder={`Enter ${platform === 'chesscom' ? 'Chess.com' : 'Lichess'} username`} + className="flex-1 px-4 py-3 border rounded-lg dark:bg-gray-700 dark:border-gray-600 focus:ring-2 focus:ring-purple-500 focus:border-transparent" + /> + +
+
+ + {/* Error Message */} + {error && ( +
+ {error} +
+ )} + + {/* Loading State */} + {isLoading && ( +
+ +

Fetching games from {platform === 'chesscom' ? 'Chess.com' : 'Lichess'}...

+
+ )} + + {/* Games Grid */} + {!isLoading && games.length > 0 && ( +
+

+ Recent Games ({games.length}) +

+
+ {games.map((game) => ( +
handleSelectGame(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-purple-400 dark:hover:border-purple-300 shadow-sm hover:shadow-md transition-all cursor-pointer" + > + {/* Game Info */} +
+ {/* Mini Chessboard */} +
+ +
+ + {/* Game Details */} +
+ + +
+

{formatDate(game.date)}

+

{game.timeControl}

+ {game.opening && ( +

+ {game.opening} +

+ )} +
+
+
+ + {/* Hover Effect */} +
+
+ ))} +
+
+ )} +
+
+
+ ); +} + diff --git a/src/lib/gameImport.ts b/src/lib/gameImport.ts new file mode 100644 index 0000000..652df70 --- /dev/null +++ b/src/lib/gameImport.ts @@ -0,0 +1,205 @@ +/** + * Game Import API Integration + * + * Fetches chess games from Chess.com and Lichess public APIs + * Both APIs are free and require no authentication for public games + */ + +import { Chess } from 'chess.js'; + +export type Platform = 'chesscom' | 'lichess'; + +export interface GameMetadata { + id: string; + platform: Platform; + white: string; + black: string; + result: string; // "1-0", "0-1", "1/2-1/2" + date: string; // ISO date string + timeControl: string; + opening?: string; + pgn: string; + finalFen: string; // For thumbnail display + url?: string; // Link to game on platform +} + +/** + * Fetch games from Chess.com + * Uses the Published-Data API (PubAPI) - no authentication required + * + * @param username - Chess.com username + * @param maxGames - Maximum number of games to fetch (default: 20) + * @returns Array of game metadata + */ +export async function fetchChessComGames( + username: string, + maxGames: number = 20 +): Promise { + try { + // Step 1: Get list of available archives + const archivesResponse = await fetch( + `https://api.chess.com/pub/player/${username}/games/archives`, + { + headers: { + 'User-Agent': 'ChessTutor/1.0 (Educational App)' + } + } + ); + + if (!archivesResponse.ok) { + throw new Error(`Chess.com API error: ${archivesResponse.status}`); + } + + const archivesData = await archivesResponse.json(); + const archives: string[] = archivesData.archives || []; + + if (archives.length === 0) { + return []; + } + + // Step 2: Fetch games from most recent archives until we have enough + const games: GameMetadata[] = []; + + // Start from most recent archive + for (let i = archives.length - 1; i >= 0 && games.length < maxGames; i--) { + const archiveUrl = archives[i]; + const gamesResponse = await fetch(archiveUrl, { + headers: { + 'User-Agent': 'ChessTutor/1.0 (Educational App)' + } + }); + + if (!gamesResponse.ok) continue; + + const gamesData = await gamesResponse.json(); + const archiveGames = gamesData.games || []; + + // Process games in reverse order (most recent first) + for (let j = archiveGames.length - 1; j >= 0 && games.length < maxGames; j--) { + const game = archiveGames[j]; + + try { + const metadata = parseChessComGame(game); + games.push(metadata); + } catch (e) { + console.warn('Failed to parse Chess.com game:', e); + } + } + } + + return games; + } catch (error) { + console.error('Error fetching Chess.com games:', error); + throw error; + } +} + +/** + * Parse a Chess.com game object into our GameMetadata format + */ +function parseChessComGame(game: any): GameMetadata { + const pgn = game.pgn; + const chess = new Chess(); + chess.loadPgn(pgn); + + // Extract metadata from PGN headers + const headers = chess.header(); + + return { + id: game.uuid || game.url, + platform: 'chesscom', + white: game.white.username || headers.White || 'Unknown', + black: game.black.username || headers.Black || 'Unknown', + result: headers.Result || '*', + date: formatChessComDate(game.end_time), + timeControl: game.time_class || headers.TimeControl || 'Unknown', + opening: headers.ECO ? `${headers.ECO}: ${headers.ECOUrl?.split('/').pop()?.replace(/-/g, ' ')}` : undefined, + pgn: pgn, + finalFen: chess.fen(), + url: game.url + }; +} + +/** + * Format Chess.com timestamp to ISO date string + */ +function formatChessComDate(timestamp: number): string { + return new Date(timestamp * 1000).toISOString(); +} + +/** + * Fetch games from Lichess + * Uses the Lichess API - no authentication required for public games + * + * @param username - Lichess username + * @param maxGames - Maximum number of games to fetch (default: 20) + * @returns Array of game metadata + */ +export async function fetchLichessGames( + username: string, + maxGames: number = 20 +): Promise { + try { + const response = await fetch( + `https://lichess.org/api/games/user/${username}?max=${maxGames}&pgnInJson=true&clocks=false&evals=false&opening=true`, + { + headers: { + 'Accept': 'application/x-ndjson', + 'User-Agent': 'ChessTutor/1.0 (Educational App)' + } + } + ); + + if (!response.ok) { + throw new Error(`Lichess API error: ${response.status}`); + } + + const text = await response.text(); + const lines = text.trim().split('\n'); + const games: GameMetadata[] = []; + + for (const line of lines) { + if (!line.trim()) continue; + + try { + const game = JSON.parse(line); + const metadata = parseLichessGame(game); + games.push(metadata); + } catch (e) { + console.warn('Failed to parse Lichess game:', e); + } + } + + return games; + } catch (error) { + console.error('Error fetching Lichess games:', error); + throw error; + } +} + +/** + * Parse a Lichess game object into our GameMetadata format + */ +function parseLichessGame(game: any): GameMetadata { + const pgn = game.pgn; + const chess = new Chess(); + chess.loadPgn(pgn); + + const players = game.players || {}; + const opening = game.opening; + + return { + id: game.id, + platform: 'lichess', + white: players.white?.user?.name || 'Unknown', + black: players.black?.user?.name || 'Unknown', + result: game.status === 'draw' ? '1/2-1/2' : game.winner === 'white' ? '1-0' : game.winner === 'black' ? '0-1' : '*', + date: new Date(game.createdAt).toISOString(), + timeControl: game.speed || 'Unknown', + opening: opening ? `${opening.eco}: ${opening.name}` : undefined, + pgn: pgn, + finalFen: chess.fen(), + url: `https://lichess.org/${game.id}` + }; +} +