analysis
This commit is contained in:
+66
-12
@@ -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<Record<number, string>>({});
|
||||
const [chatSession, setChatSession] = useState<ChatSession | null>(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}
|
||||
</button>
|
||||
|
||||
{/* Import from Online Platforms */}
|
||||
<div className="pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-2 text-center">
|
||||
Or import from online platforms
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
onClick={() => setShowImportModal(true)}
|
||||
className="py-2 px-3 bg-green-600 text-white rounded-lg hover:bg-green-700 font-medium text-sm flex items-center justify-center gap-2 shadow"
|
||||
>
|
||||
<Download size={16} />
|
||||
Chess.com
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowImportModal(true)}
|
||||
className="py-2 px-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium text-sm flex items-center justify-center gap-2 shadow"
|
||||
>
|
||||
<Download size={16} />
|
||||
Lichess
|
||||
</button>
|
||||
</div>
|
||||
</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">
|
||||
<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 className="w-full max-w-md">
|
||||
<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))}
|
||||
@@ -543,6 +588,15 @@ INSTRUCTIONS:
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Game Import Modal */}
|
||||
{showImportModal && (
|
||||
<GameImportModal
|
||||
onClose={() => setShowImportModal(false)}
|
||||
onSelectGame={handleImportGame}
|
||||
language={language}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,15 +11,21 @@ export default function SettingsPage() {
|
||||
const router = useRouter();
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [language, setLanguage] = useState<SupportedLanguage>('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() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Online Platform Usernames */}
|
||||
<div className="pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Online Platform Integration
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Save your usernames to quickly import games from Chess.com and Lichess in the Analysis page.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Chess.com Username */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Chess.com Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={chesscomUsername}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Lichess Username */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Lichess Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={lichessUsername}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-6 border-t border-gray-200 dark:border-gray-700 flex justify-end">
|
||||
|
||||
@@ -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<Platform>('chesscom');
|
||||
const [username, setUsername] = useState('');
|
||||
const [games, setGames] = useState<GameMetadata[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl max-w-4xl w-full max-h-[90vh] overflow-hidden border border-gray-200 dark:border-gray-700 animate-in fade-in zoom-in duration-200">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center bg-gray-50 dark:bg-gray-900">
|
||||
<h2 className="text-lg font-bold flex items-center gap-2 text-gray-900 dark:text-white">
|
||||
<Download className="text-purple-600" />
|
||||
Import Game from Online Platform
|
||||
</h2>
|
||||
<button onClick={onClose} className="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-6 overflow-y-auto max-h-[calc(90vh-80px)]">
|
||||
{/* Platform Selection */}
|
||||
<div className="space-y-3">
|
||||
<label className="block text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
Select Platform
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<button
|
||||
onClick={() => setPlatform('chesscom')}
|
||||
className={`py-3 px-4 rounded-lg border-2 font-semibold transition-all ${
|
||||
platform === 'chesscom'
|
||||
? 'bg-green-50 border-green-600 text-green-700 dark:bg-green-900/20 dark:text-green-400'
|
||||
: 'bg-white dark:bg-gray-700 border-gray-200 dark:border-gray-600 hover:border-green-300'
|
||||
}`}
|
||||
>
|
||||
Chess.com
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPlatform('lichess')}
|
||||
className={`py-3 px-4 rounded-lg border-2 font-semibold transition-all ${
|
||||
platform === 'lichess'
|
||||
? 'bg-blue-50 border-blue-600 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400'
|
||||
: 'bg-white dark:bg-gray-700 border-gray-200 dark:border-gray-600 hover:border-blue-300'
|
||||
}`}
|
||||
>
|
||||
Lichess
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Username Input */}
|
||||
<div className="space-y-3">
|
||||
<label className="block text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
Username
|
||||
</label>
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
onClick={handleFetchGames}
|
||||
disabled={isLoading || !username.trim()}
|
||||
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 font-semibold disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{isLoading ? <Loader2 className="animate-spin" size={18} /> : <Download size={18} />}
|
||||
Fetch Games
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg text-red-700 dark:text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{isLoading && (
|
||||
<div className="flex flex-col items-center justify-center py-12 space-y-4">
|
||||
<Loader2 className="animate-spin text-purple-600" size={48} />
|
||||
<p className="text-gray-500">Fetching games from {platform === 'chesscom' ? 'Chess.com' : 'Lichess'}...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Games Grid */}
|
||||
{!isLoading && games.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
Recent Games ({games.length})
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{games.map((game) => (
|
||||
<div
|
||||
key={game.id}
|
||||
onClick={() => 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 */}
|
||||
<div className="flex gap-4">
|
||||
{/* Mini Chessboard */}
|
||||
<div className="w-24 h-24 shrink-0">
|
||||
<Chessboard
|
||||
options={{
|
||||
position: game.finalFen,
|
||||
boardOrientation: 'white',
|
||||
allowDragging: false,
|
||||
darkSquareStyle: { backgroundColor: '#779954' },
|
||||
lightSquareStyle: { backgroundColor: '#e9edcc' },
|
||||
boardStyle: { borderRadius: '8px' }
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Game Details */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-semibold text-gray-900 dark:text-white truncate">
|
||||
{game.white} vs {game.black}
|
||||
</p>
|
||||
<p className={`text-sm font-medium ${getResultColor(game.result, game.white, game.black, username)}`}>
|
||||
{getResultText(game.result)}
|
||||
</p>
|
||||
</div>
|
||||
{game.url && (
|
||||
<a
|
||||
href={game.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-gray-400 hover:text-purple-600 dark:hover:text-purple-400 transition-colors"
|
||||
title="View on platform"
|
||||
>
|
||||
<ExternalLink size={16} />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 text-xs text-gray-600 dark:text-gray-400">
|
||||
<p>{formatDate(game.date)}</p>
|
||||
<p className="capitalize">{game.timeControl}</p>
|
||||
{game.opening && (
|
||||
<p className="truncate" title={game.opening}>
|
||||
{game.opening}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hover Effect */}
|
||||
<div className="absolute inset-0 bg-purple-500/5 rounded-xl opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<GameMetadata[]> {
|
||||
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<GameMetadata[]> {
|
||||
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}`
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user