This commit is contained in:
Stefan
2025-11-27 21:23:54 +01:00
parent 502f7e28ea
commit 3e8c16b9c2
4 changed files with 590 additions and 12 deletions
+66 -12
View File
@@ -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>
);
}
+59
View File
@@ -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">
+260
View File
@@ -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>
);
}
+205
View File
@@ -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}`
};
}