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.
+
+
+
+
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}`
+ };
+}
+