diff --git a/src/components/LinearProgressBar.tsx b/src/components/LinearProgressBar.tsx
index 211ffa82..84049f36 100644
--- a/src/components/LinearProgressBar.tsx
+++ b/src/components/LinearProgressBar.tsx
@@ -7,11 +7,17 @@ import {
linearProgressClasses,
} from "@mui/material";
-const LinearProgressBar = (
- props: LinearProgressProps & { value: number; label: string }
-) => {
- if (props.value === 0) return null;
+/**
+ * A styled linear progress bar with optional label and percentage display.
+ */
+interface LinearProgressBarProps extends LinearProgressProps {
+ value: number;
+ label: string;
+}
+function LinearProgressBar({ value, label, ...rest }: LinearProgressBarProps) {
+ // Allow rendering if label === "" (OpeningProgress case), otherwise hide if value === 0
+ if (value === 0 && label !== "") return null;
return (
-
- {props.label}
+
+ {label}
({
borderRadius: "5px",
height: "5px",
@@ -45,7 +55,7 @@ const LinearProgressBar = (
{`${Math.round(
- props.value
+ value
)}%`}
diff --git a/src/components/OpeningControls.tsx b/src/components/OpeningControls.tsx
new file mode 100644
index 00000000..9e2f9c34
--- /dev/null
+++ b/src/components/OpeningControls.tsx
@@ -0,0 +1,49 @@
+
+import { Button, Stack } from "@mui/material";
+import { memo } from "react";
+
+/**
+ * Control buttons for skipping or resetting the opening variation.
+ */
+export interface OpeningControlsProps {
+ moveIdx: number;
+ selectedVariationMovesLength: number;
+ allDone: boolean;
+ onSkip: () => void;
+ onReset: () => void;
+ disabled?: boolean;
+}
+
+function OpeningControls({
+ moveIdx,
+ selectedVariationMovesLength,
+ allDone,
+ onSkip,
+ onReset,
+ disabled = false,
+}: OpeningControlsProps) {
+ return (
+
+
+
+
+ );
+}
+
+export default memo(OpeningControls);
diff --git a/src/components/OpeningProgress.tsx b/src/components/OpeningProgress.tsx
new file mode 100644
index 00000000..c09eb578
--- /dev/null
+++ b/src/components/OpeningProgress.tsx
@@ -0,0 +1,58 @@
+import { useEffect, useState, memo } from "react";
+import LinearProgressBar from "./LinearProgressBar";
+import { Box } from "@mui/material";
+import { useTheme } from "@mui/material/styles";
+
+// Props:
+// - total: total number of variations
+// - currentVariationIndex: index of the current variation (optional, for display)
+
+/**
+ * Progress bar for opening training, showing completed variations out of total.
+ */
+export interface OpeningProgressProps {
+ total: number;
+ // List of completed variation indexes
+ completed: number[];
+}
+
+function OpeningProgress({ total, completed }: OpeningProgressProps) {
+ const [progress, setProgress] = useState(completed);
+ const theme = useTheme();
+
+ useEffect(() => {
+ setProgress(completed);
+ }, [completed]);
+
+ // Calculate percentage
+ const percent = total > 0 ? (progress.length / total) * 100 : 0;
+ const label = `${progress.length} / ${total}`;
+
+ return (
+
+
+ {label}
+
+
+
+
+
+ );
+};
+
+export default memo(OpeningProgress);
diff --git a/src/components/VariationHeader.tsx b/src/components/VariationHeader.tsx
new file mode 100644
index 00000000..d8216844
--- /dev/null
+++ b/src/components/VariationHeader.tsx
@@ -0,0 +1,44 @@
+
+import { Typography, Stack, Button } from "@mui/material";
+import { memo } from "react";
+
+/**
+ * Header for the opening variation panel.
+ */
+export interface VariationHeaderProps {
+ variationName?: string;
+ trainingMode: boolean;
+ onSetTrainingMode: (training: boolean) => void;
+ variationComplete: boolean;
+}
+
+const VariationHeader: React.FC = ({
+ variationName,
+ trainingMode,
+ onSetTrainingMode,
+ variationComplete,
+}) => (
+ <>
+
+ {variationName}
+
+
+
+
+
+
+ {variationComplete ? (
+ Variation complete! Next variation loading…
+ ) : trainingMode ? (
+ Play the correct move to continue.
+ ) : (
+ Play the move indicated by the arrow to continue.
+ )}
+ >
+);
+
+export default memo(VariationHeader);
diff --git a/src/components/board/index.tsx b/src/components/board/index.tsx
index d02404b9..1ae28752 100644
--- a/src/components/board/index.tsx
+++ b/src/components/board/index.tsx
@@ -22,6 +22,12 @@ import PlayerHeader from "./playerHeader";
import { boardHueAtom, pieceSetAtom } from "./states";
import tinycolor from "tinycolor2";
+export interface TrainingFeedback {
+ square: string; // ex: 'e4'
+ icon: string; // chemin de l'icône
+ alt: string; // texte alternatif
+}
+
export interface Props {
id: string;
canPlay?: Color | boolean;
@@ -34,6 +40,9 @@ export interface Props {
showBestMoveArrow?: boolean;
showPlayerMoveIconAtom?: PrimitiveAtom;
showEvaluationBar?: boolean;
+ trainingFeedback?: TrainingFeedback;
+ bestMoveUci?: string;
+ hidePlayerHeaders?: boolean;
}
export default function Board({
@@ -48,6 +57,9 @@ export default function Board({
showBestMoveArrow = false,
showPlayerMoveIconAtom,
showEvaluationBar = false,
+ trainingFeedback,
+ bestMoveUci,
+ hidePlayerHeaders = false,
}: Props) {
const boardRef = useRef(null);
const game = useAtomValue(gameAtom);
@@ -208,9 +220,19 @@ export default function Board({
);
const customArrows: Arrow[] = useMemo(() => {
+ if (bestMoveUci && showBestMoveArrow) {
+ // Priorité à la flèche d'ouverture
+ return [[
+ bestMoveUci.slice(0, 2),
+ bestMoveUci.slice(2, 4),
+ tinycolor(CLASSIFICATION_COLORS[MoveClassification.Best])
+ .spin(-boardHue)
+ .toHexString(),
+ ] as Arrow];
+ }
+ // Fallback moteur
const bestMove = position?.lastEval?.bestMove;
const moveClassification = position?.eval?.moveClassification;
-
if (
bestMove &&
showBestMoveArrow &&
@@ -226,12 +248,10 @@ export default function Board({
.spin(-boardHue)
.toHexString(),
] as Arrow;
-
return [bestMoveArrow];
}
-
return [];
- }, [position, showBestMoveArrow, boardHue]);
+ }, [bestMoveUci, position, showBestMoveArrow, boardHue]);
const SquareRenderer: CustomSquareRenderer = useMemo(() => {
return getSquareRenderer({
@@ -239,12 +259,14 @@ export default function Board({
clickedSquaresAtom,
playableSquaresAtom,
showPlayerMoveIconAtom,
+ trainingFeedback, // nouvelle prop transmise
});
}, [
currentPositionAtom,
clickedSquaresAtom,
playableSquaresAtom,
showPlayerMoveIconAtom,
+ trainingFeedback,
]);
const customPieces = useMemo(
@@ -306,11 +328,14 @@ export default function Board({
paddingLeft={showEvaluationBar ? 2 : 0}
size="grow"
>
-
+ {/* Enlève l'affichage des PlayerHeader si hidePlayerHeaders est true */}
+ {!hidePlayerHeaders && (
+
+ )}
-
+ {!hidePlayerHeaders && (
+
+ )}
);
diff --git a/src/components/board/squareRenderer.tsx b/src/components/board/squareRenderer.tsx
index 67cb7673..02cae926 100644
--- a/src/components/board/squareRenderer.tsx
+++ b/src/components/board/squareRenderer.tsx
@@ -15,6 +15,11 @@ export interface Props {
clickedSquaresAtom: PrimitiveAtom;
playableSquaresAtom: PrimitiveAtom;
showPlayerMoveIconAtom?: PrimitiveAtom;
+ trainingFeedback?: {
+ square: string;
+ icon: string;
+ alt: string;
+ };
}
export function getSquareRenderer({
@@ -22,6 +27,7 @@ export function getSquareRenderer({
clickedSquaresAtom,
playableSquaresAtom,
showPlayerMoveIconAtom = atom(false),
+ trainingFeedback,
}: Props) {
const squareRenderer = forwardRef(
(props, ref) => {
@@ -64,6 +70,25 @@ export function getSquareRenderer({
{children}
{highlightSquareStyle && }
{playableSquareStyle && }
+ {/* Affichage de l’icône de feedback training si demandé et sur la bonne case */}
+ {trainingFeedback && trainingFeedback.square === square && (
+
+ )}
+ {/* Aucun affichage de message texte d'erreur ici, seulement l'icône */}
{moveClassification && showPlayerMoveIcon && square === toSquare && (
void;
+ setLastMistakeVisible: (mistake: Mistake | null) => void;
+ undoMove: () => void;
+}
+
+/**
+ * Custom hook that checks if the user's move matches the expected move in the opening sequence,
+ * and handles mistakes (visual feedback + undo).
+ */
+export function useMistakeHandler({
+ selectedVariation,
+ game,
+ moveIdx,
+ isUserTurn,
+ setMoveIdx,
+ setLastMistakeVisible,
+ undoMove,
+}: UseMistakeHandlerParams) {
+ return useCallback(() => {
+ if (!selectedVariation || !game) return;
+ if (moveIdx >= selectedVariation.moves.length) return;
+ if (!isUserTurn) return;
+ const history = game.history({ verbose: true });
+ if (history.length !== moveIdx + 1) return;
+ const last = history[history.length - 1];
+ const expectedMove = new Chess();
+ for (let i = 0; i < moveIdx; i++) {
+ expectedMove.move(selectedVariation.moves[i]);
+ }
+ const expected = expectedMove.move(selectedVariation.moves[moveIdx]);
+ if (!expected || last.from !== expected.from || last.to !== expected.to) {
+ const mistakeType = (last.captured || last.san.includes("#")) ? "Blunder" : "Mistake";
+ setTimeout(() => {
+ setLastMistakeVisible({ from: last.from, to: last.to, type: mistakeType });
+ setTimeout(() => {
+ undoMove();
+ setLastMistakeVisible(null);
+ }, 1300);
+ }, 200);
+ } else {
+ setMoveIdx(moveIdx + 1);
+ }
+ }, [selectedVariation, game, moveIdx, isUserTurn, setMoveIdx, setLastMistakeVisible, undoMove]);
+}
+
diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx
index f863fb7d..04c7e02b 100644
--- a/src/pages/_app.tsx
+++ b/src/pages/_app.tsx
@@ -5,6 +5,7 @@ import "@fontsource/roboto/700.css";
import { AppProps } from "next/app";
import Layout from "@/sections/layout";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import "./global-fix.css";
const queryClient = new QueryClient();
diff --git a/src/pages/choose-opening.tsx b/src/pages/choose-opening.tsx
new file mode 100644
index 00000000..7b2d2289
--- /dev/null
+++ b/src/pages/choose-opening.tsx
@@ -0,0 +1,118 @@
+import React from "react";
+import { useRouter } from "next/router";
+import { PageTitle } from "@/components/pageTitle";
+import { Box, Typography, Paper, Stack } from "@mui/material";
+
+// List of available openings (evolutive)
+interface Opening {
+ key: string;
+ name: string;
+ description?: string;
+ available: boolean;
+}
+
+const openings: Opening[] = [
+ {
+ key: "italian",
+ name: "Italian Game",
+ description: "A classic and popular opening for beginners and intermediate players.",
+ available: true,
+ },
+ {
+ key: "caro-kann",
+ name: "Caro-Kann",
+ description: "Coming soon ! A solid defense for strategic players.",
+ available: false,
+ },
+ {
+ key: "england-gambit",
+ name: "England Gambit",
+ description: "Coming soon ! An aggressive gambit to surprise your opponent.",
+ available: false,
+ },
+];
+
+export default function ChooseOpeningPage() {
+ const router = useRouter();
+
+ const handleChoose = (openingKey: string) => {
+ router.push(`/opening-trainer?opening=${openingKey}`);
+ };
+
+ return (
+ <>
+
+
+
+ Choose an opening
+
+
+ {openings.map((opening) => (
+
+ theme.palette.mode === "dark"
+ ? "#232323"
+ : "#fafbfc",
+ transition: "box-shadow 0.2s, border-color 0.2s, background 0.2s",
+ cursor: opening.available ? "pointer" : "not-allowed",
+ opacity: opening.available ? 1 : 0.6,
+ '&:hover': opening.available
+ ? {
+ boxShadow: 6,
+ borderColor: "primary.main",
+ background: (theme) =>
+ theme.palette.mode === "dark"
+ ? "#232323"
+ : "#f0f7fa",
+ }
+ : {},
+ }}
+ onClick={opening.available ? () => handleChoose(opening.key) : undefined}
+ >
+
+ {opening.name}
+
+ {opening.description && (
+
+ {opening.description}
+
+ )}
+
+ ))}
+
+
+ >
+ );
+}
diff --git a/src/pages/global-fix.css b/src/pages/global-fix.css
new file mode 100644
index 00000000..082d75b4
--- /dev/null
+++ b/src/pages/global-fix.css
@@ -0,0 +1,8 @@
+html, body {
+ width: 100vw;
+ min-height: 100vh;
+ margin: 0;
+ padding: 0;
+ overflow-x: hidden;
+ box-sizing: border-box;
+}
diff --git a/src/pages/opening-trainer.tsx b/src/pages/opening-trainer.tsx
new file mode 100644
index 00000000..9ca87625
--- /dev/null
+++ b/src/pages/opening-trainer.tsx
@@ -0,0 +1,451 @@
+import { italianGameVariations } from "../data/openings to learn/italian";
+import { Box } from "@mui/material";
+import { useState, useMemo, useEffect, useCallback } from "react";
+import Board from "../components/board";
+import { Chess } from "chess.js";
+import { atom, useAtom } from "jotai";
+import { useChessActions } from "../hooks/useChessActions";
+import { Color } from "../types/enums";
+import { CurrentPosition } from "../types/eval";
+import OpeningProgress from "../components/OpeningProgress";
+import { Grid2 as Grid } from "@mui/material";
+import { useScreenSize } from "../hooks/useScreenSize";
+import EvaluationBar from "../components/board/evaluationBar";
+import { useEngine } from "../hooks/useEngine";
+import { EngineName } from "../types/enums";
+import OpeningControls from "../components/OpeningControls";
+import VariationHeader from "../components/VariationHeader";
+import { useMistakeHandler } from "../hooks/useMistakeHandler";
+
+// Returns the learning color for the variation (default is white, but can be extended)
+function getLearningColor(): Color {
+ // Always returns white for now
+ return Color.White;
+}
+
+interface Mistake {
+ from: string;
+ to: string;
+ type: string;
+}
+
+export default function OpeningPage() {
+ const [currentVariantIdx, setCurrentVariantIdx] = useState(0);
+ const [moveIdx, setMoveIdx] = useState(0);
+ const [trainingMode, setTrainingMode] = useState(false);
+ const [lastMistakeVisible, setLastMistakeVisible] = useState(null);
+ // Atom Jotai for game state
+ const [gameAtomInstance] = useState(() => atom(new Chess()));
+ const [game, setGame] = useAtom(gameAtomInstance);
+ const { undoMove } = useChessActions(gameAtomInstance);
+
+ // List of variations to learn (all)
+ const variations = italianGameVariations;
+ const selectedVariation = variations[currentVariantIdx] || null;
+
+ // Learning color (fixed for the variation)
+ const learningColor = useMemo(() => {
+ if (!selectedVariation) return Color.White;
+ return getLearningColor(); // No argument needed
+ }, [selectedVariation]);
+
+ // Indicates if it's the user's turn to play
+ const isUserTurn = useMemo(() => {
+ if (!selectedVariation) return false;
+ // moveIdx % 2 === 0 => white, 1 => black (if the sequence starts with white)
+ const colorToPlay = moveIdx % 2 === 0 ? Color.White : Color.Black;
+ return colorToPlay === learningColor;
+ }, [moveIdx, learningColor, selectedVariation]);
+
+ // Generate the expected move in UCI format for the arrow (only if it's the user's turn to play)
+ const bestMoveUci = useMemo(() => {
+ if (
+ selectedVariation &&
+ game &&
+ moveIdx < selectedVariation.moves.length
+ ) {
+ const chess = new Chess(game.fen());
+ const san = selectedVariation.moves[moveIdx];
+ const moves = chess.moves({ verbose: true });
+ const moveObj = moves.find((m) => m.san === san);
+ if (moveObj) {
+ return moveObj.from + moveObj.to + (moveObj.promotion ? moveObj.promotion : "");
+ }
+ }
+ return undefined;
+ }, [selectedVariation, game, moveIdx, isUserTurn]);
+
+ // Writable atom for currentPosition (read/write)
+ // Instead of a local atom, use the engine evaluation mechanism already present in the project
+ // (see useEngine, useGameData, or similar hooks if available)
+ // Here, we use a simple effect to update the evaluation after each move using the engine
+ const [currentPositionAtom, setCurrentPositionAtom] = useState(() => atom({
+ lastEval: { lines: [] },
+ eval: { moveClassification: undefined, lines: [] },
+ }));
+
+ // Engine integration for real-time evaluation
+ const engine = useEngine(EngineName.Stockfish17Lite);
+
+ useEffect(() => {
+ if (!game || !engine || !engine.getIsReady()) return;
+ let cancelled = false;
+ const fen = game.fen();
+ // Delay the analysis to prioritize move animation
+ const timeout = setTimeout(() => {
+ if (cancelled) return;
+ engine.evaluatePositionWithUpdate({
+ fen,
+ depth: 14,
+ multiPv: 2,
+ setPartialEval: (evalResult) => {
+ if (!cancelled) {
+ setCurrentPositionAtom(atom({
+ lastEval: evalResult,
+ eval: evalResult,
+ }));
+ }
+ },
+ });
+ }, 200); // 200ms allows time for move animation
+ return () => {
+ cancelled = true;
+ clearTimeout(timeout);
+ engine.stopAllCurrentJobs();
+ };
+ }, [game, moveIdx, engine]);
+
+ // Reset on each variation or progression
+ useEffect(() => {
+ if (!selectedVariation) return;
+ try {
+ const chess = new Chess();
+ for (let i = 0; i < moveIdx; i++) {
+ const move = selectedVariation.moves[i];
+ const result = chess.move(move);
+ if (!result) break; // Stop if invalid move
+ }
+ setGame(chess);
+ } catch (e) {
+ // Error handling: avoid crash
+ setGame(new Chess());
+ }
+ }, [selectedVariation, moveIdx, setGame]);
+
+ // Validate user move: if wrong move, undo and annotate
+ useEffect(() => {
+ if (!selectedVariation || !game) return;
+ if (moveIdx >= selectedVariation.moves.length) return;
+ if (!isUserTurn) return; // Only validate user moves
+ let mistakeTimeout: NodeJS.Timeout | null = null;
+ let undoTimeout: NodeJS.Timeout | null = null;
+ try {
+ const history = game.history({ verbose: true });
+ if (history.length !== moveIdx + 1) return;
+ const last = history[history.length - 1];
+ const expectedMove = new Chess();
+ for (let i = 0; i < moveIdx; i++) expectedMove.move(selectedVariation.moves[i]);
+ const expected = expectedMove.move(selectedVariation.moves[moveIdx]);
+ if (!expected || last.from !== expected.from || last.to !== expected.to) {
+ // Wrong move: wait 200ms before showing error icon, then undo after 1.5s
+ let mistakeType = "Mistake";
+ if (last.captured || last.san.includes("#")) mistakeType = "Blunder";
+ // Do NOT set lastMistakeVisible immediately
+ mistakeTimeout = setTimeout(() => {
+ setLastMistakeVisible({ from: last.from, to: last.to, type: mistakeType });
+ }, 200);
+ undoTimeout = setTimeout(() => {
+ setLastMistakeVisible(null);
+ undoMove();
+ }, 1500);
+ } else {
+ setLastMistakeVisible(null);
+ setMoveIdx((idx) => idx + 1);
+ }
+ } catch (e) {
+ setLastMistakeVisible(null);
+ }
+ return () => {
+ if (mistakeTimeout) clearTimeout(mistakeTimeout);
+ if (undoTimeout) clearTimeout(undoTimeout);
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [game.history().length, trainingMode, selectedVariation, isUserTurn]);
+
+ // Automatically advance opponent moves after a correct user move
+ useEffect(() => {
+ if (!selectedVariation) return;
+ if (moveIdx >= selectedVariation.moves.length) return;
+ // If it's not the user's turn, automatically advance opponent moves
+ if (!isUserTurn) {
+ // Play all opponent moves until the next user move or end of sequence
+ let nextIdx = moveIdx;
+ let colorToPlay = nextIdx % 2 === 0 ? Color.White : Color.Black;
+ while (nextIdx < selectedVariation.moves.length && colorToPlay !== learningColor) {
+ nextIdx++;
+ colorToPlay = nextIdx % 2 === 0 ? Color.White : Color.Black;
+ }
+ if (nextIdx !== moveIdx) {
+ // Delay increased to 500ms to allow time for user move animation
+ setTimeout(() => setMoveIdx(nextIdx), 500);
+ }
+ }
+ }, [moveIdx, isUserTurn, selectedVariation, learningColor]);
+
+ // Automatically chain variations
+ useEffect(() => {
+ if (!selectedVariation) return;
+ if (moveIdx >= selectedVariation.moves.length) {
+ // Success: move to the next variation after a short delay
+ if (currentVariantIdx < variations.length - 1) {
+ setTimeout(() => {
+ setCurrentVariantIdx((idx) => idx + 1);
+ setMoveIdx(0);
+ }, 800);
+ }
+ }
+ }, [moveIdx, selectedVariation, currentVariantIdx, variations.length]);
+
+ // If all variations are completed
+ const allDone = currentVariantIdx >= variations.length;
+
+ // Progress management (persisted by mode)
+ const openingKey = "italian";
+ const progressMode = trainingMode ? "training" : "learning";
+ const progressStorageKey = `${openingKey}-progress-${progressMode}`;
+ const [completedVariations, setCompletedVariations] = useState(() => {
+ if (typeof window === "undefined") return [];
+ try {
+ const raw = localStorage.getItem(progressStorageKey);
+ return raw ? JSON.parse(raw) : [];
+ } catch {
+ return [];
+ }
+ });
+
+ // Mark a variation as completed
+ useEffect(() => {
+ if (!selectedVariation) return;
+ if (moveIdx >= selectedVariation.moves.length) {
+ if (!completedVariations.includes(currentVariantIdx)) {
+ const updated = [...completedVariations, currentVariantIdx];
+ setCompletedVariations(updated);
+ localStorage.setItem(progressStorageKey, JSON.stringify(updated));
+ }
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [moveIdx, selectedVariation, currentVariantIdx, progressMode]);
+
+ // When loading, if there is a completed variation, jump to the first incomplete one
+ useEffect(() => {
+ if (completedVariations.length > 0 && completedVariations.length < variations.length) {
+ // Find the first incomplete variation
+ const firstIncomplete = variations.findIndex((_, idx) => !completedVariations.includes(idx));
+ if (firstIncomplete !== -1 && currentVariantIdx !== firstIncomplete) {
+ setCurrentVariantIdx(firstIncomplete);
+ setMoveIdx(0);
+ setLastMistakeVisible(null);
+ setGame(new Chess());
+ }
+ } else if (completedVariations.length === variations.length && currentVariantIdx !== variations.length) {
+ // All done, move to the end
+ setCurrentVariantIdx(variations.length - 1); // Correction: do not exceed max index
+ setMoveIdx(0);
+ setLastMistakeVisible(null);
+ setGame(new Chess());
+ }
+ }, [completedVariations, variations.length, currentVariantIdx, setGame]);
+
+ // Reset progress
+ const handleResetProgress = useCallback(() => {
+ localStorage.removeItem(progressStorageKey);
+ setCompletedVariations([]);
+ setCurrentVariantIdx(0);
+ setMoveIdx(0);
+ setLastMistakeVisible(null);
+ setGame(new Chess());
+ }, [setCompletedVariations, setCurrentVariantIdx, setMoveIdx, setLastMistakeVisible, setGame]);
+
+ // Determine the target square of the last move played (for overlay)
+ const lastMoveSquare = useMemo(() => {
+ if (!game) return null;
+ const history = game.history({ verbose: true });
+ if (history.length === 0) return null;
+ const last = history[history.length - 1];
+ return last.to;
+ }, [game]);
+
+ // Determine the type of icon to display (success/error)
+ const trainingFeedback = useMemo(() => {
+ if (!lastMoveSquare) return undefined;
+ // Show red cross icon if the last move was incorrectly played by the human
+ if (lastMistakeVisible && lastMistakeVisible.to === lastMoveSquare) {
+ return { square: lastMoveSquare, icon: "/icons/mistake.png", alt: "Incorrect move" };
+ }
+ // Show nothing if the move is correct
+ return undefined;
+ }, [lastMistakeVisible, lastMoveSquare]);
+
+ const screenSize = useScreenSize();
+ // Responsive constants
+ const evalBarWidth = 32; // px
+ const evalBarGap = 8; // px
+
+ // Dynamic board size calculation
+ const boardSize = useMemo(() => {
+ const { width, height } = screenSize;
+ let maxBoardWidth = width - 300 - evalBarWidth;
+ if (typeof window !== "undefined" && window.innerWidth < 900) {
+ maxBoardWidth = width - evalBarWidth - 24;
+ return Math.max(180, Math.min(maxBoardWidth, height - 150));
+ }
+ return Math.max(240, Math.min(maxBoardWidth, height * 0.83));
+ }, [screenSize]);
+
+ // Handler for skip variation
+ const handleSkipVariation = useCallback(() => {
+ let newCompleted = completedVariations;
+ if (!completedVariations.includes(currentVariantIdx)) {
+ newCompleted = [...completedVariations, currentVariantIdx];
+ setCompletedVariations(newCompleted);
+ localStorage.setItem(progressStorageKey, JSON.stringify(newCompleted));
+ }
+ if (currentVariantIdx < variations.length - 1) {
+ setCurrentVariantIdx(idx => idx + 1);
+ setMoveIdx(0);
+ setLastMistakeVisible(null);
+ setGame(new Chess());
+ } else {
+ setMoveIdx(0);
+ setLastMistakeVisible(null);
+ setGame(new Chess());
+ }
+ }, [completedVariations, currentVariantIdx, setCompletedVariations, setCurrentVariantIdx, setMoveIdx, setLastMistakeVisible, setGame, variations.length]);
+
+ // Use mistake handler hook
+ useMistakeHandler({
+ selectedVariation,
+ game,
+ moveIdx,
+ isUserTurn,
+ setMoveIdx,
+ setLastMistakeVisible,
+ undoMove,
+ });
+
+ return (
+
+ {/* Left area: evaluation bar + board */}
+
+ {selectedVariation && !allDone && game && (
+
+ {/* Evaluation bar on the left, vertically centered */}
+
+
+
+ {/* Chessboard */}
+
+
+
+
+ )}
+
+ {/* Right area: progress panel, buttons, text */}
+
+ {/* Centered container for title and buttons */}
+
+ = (selectedVariation?.moves.length || 0)}
+ />
+
+ {/* Progress bar at the bottom right, always visible */}
+
+
+
+
+
+
+ );
+}
diff --git a/src/public/openings/italian-game.png b/src/public/openings/italian-game.png
new file mode 100644
index 00000000..c3313f2e
--- /dev/null
+++ b/src/public/openings/italian-game.png
@@ -0,0 +1 @@
+// Illustration d'ouverture Italienne pour la page Opening
diff --git a/src/sections/layout/NavMenu.tsx b/src/sections/layout/NavMenu.tsx
index 337b2299..8d08eca4 100644
--- a/src/sections/layout/NavMenu.tsx
+++ b/src/sections/layout/NavMenu.tsx
@@ -1,4 +1,4 @@
-import NavLink from "@/components/NavLink";
+import NavLink from "../../components/NavLink";
import { Icon } from "@iconify/react";
import {
Box,
@@ -19,6 +19,11 @@ const MenuOptions = [
icon: "streamline:database",
href: "/database",
},
+ {
+ text: "Opening Trainer",
+ icon: "mdi:book-open-variant",
+ href: "/choose-opening", // Redirige désormais vers la page de choix d'ouverture
+ },
];
interface Props {
diff --git a/src/sections/play/board.tsx b/src/sections/play/board.tsx
index 209a5438..7c98db92 100644
--- a/src/sections/play/board.tsx
+++ b/src/sections/play/board.tsx
@@ -79,6 +79,7 @@ export default function BoardContainer() {
blackPlayer={black}
boardOrientation={playerColor}
currentPositionAtom={gameDataAtom}
+ hidePlayerHeaders={true}
/>
);
}