Build Atelier 64 chess training app

This commit is contained in:
Michael committed 2026-07-20 15:18:52 +02:00
commit 3dd0aa9a09
15 files changed
+2574

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
.git
npm-debug.log
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
.npm-cache/
.env
*.log
+17
View File
@@ -0,0 +1,17 @@
FROM node:22-bookworm-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends stockfish && rm -rf /var/lib/apt/lists/*
WORKDIR /app
ENV NODE_ENV=production PORT=3000 STOCKFISH_PATH=/usr/games/stockfish
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
COPY server ./server
EXPOSE 3000
CMD ["node", "server/index.js"]
+52
View File
@@ -0,0 +1,52 @@
# Atelier 64
Studio personnel d’échecs: jouez librement les deux couleurs, recevez trois propositions Stockfish après chaque coup, puis demandez une explication en français à un serveur Ollama distant.
## Démarrage Docker
```bash
docker compose up --build
```
Ouvrir <http://localhost:8080>. Stockfish est inclus dans l’image Docker.
Dans **Réglages**, saisir adresse Ollama et modèle. Avec Ollama sur même ordinateur que Docker Desktop:
```text
http://host.docker.internal:11434
```
Ollama doit écouter sur réseau accessible au conteneur. Exemple côté machine Ollama:
```bash
OLLAMA_HOST=0.0.0.0:11434 ollama serve
ollama pull qwen3:8b
```
## Modèles conseillés
- `qwen3:8b`: choix par défaut, rapide, bon français, environ 6 Go disponibles.
- `qwen3:30b`: explications supérieures, machine distante puissante, environ 24 Go disponibles.
- Stockfish choisit toujours les coups. Ollama explique seulement les variantes calculées: qualité échiquéenne reste stable quel que soit LLM.
## Développement
Pré-requis: Node.js 22 et Stockfish installé dans le PATH.
```bash
npm install
npm run dev
```
Interface: <http://localhost:5173>. API: <http://localhost:3000>.
## Fonctions
- jeu des deux côtés par clic ou glisser-déposer;
- coups légaux signalés, promotion automatique en dame;
- meilleure flèche activable, 3 variantes, évaluation et profondeur réglable;
- annulation, rotation, nouvelle partie, feuille de partie;
- détection échec, mat et nulle;
- Ollama distant configurable et testable depuis interface;
- réglages conservés localement dans navigateur;
- mise en page responsive et commandes accessibles au clavier.
+17
View File
@@ -0,0 +1,17 @@
services:
atelier64:
build: .
container_name: atelier64
restart: unless-stopped
ports:
- "8080:3000"
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
- NODE_ENV=production
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/api/health').then(r=>{if(!r.ok)process.exit(1)})"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
+11
View File
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#111411" />
<meta name="description" content="Atelier 64, studio personnel d'analyse et d'entraînement aux échecs." />
<title>Atelier 64 — Studio d'échecs</title>
</head>
<body><div id="root"></div><script type="module" src="/src/main.jsx"></script></body>
</html>
+2008
View File
File diff suppressed because it is too large. Load diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "atelier-64",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "concurrently \"vite --host 0.0.0.0\" \"node --watch server/index.js\"",
"build": "vite build",
"start": "node server/index.js",
"test": "node --test"
},
"dependencies": {
"@vitejs/plugin-react": "latest",
"chess.js": "latest",
"express": "latest",
"lucide-react": "latest",
"vite": "latest",
"react": "latest",
"react-dom": "latest"
},
"devDependencies": {
"concurrently": "latest"
}
}
+56
View File
@@ -0,0 +1,56 @@
import { spawn } from 'node:child_process';
export function parseInfo(line) {
if (!line.startsWith('info ') || !line.includes(' pv ')) return null;
const depth = Number(line.match(/\bdepth (\d+)/)?.[1] || 0);
const multipv = Number(line.match(/\bmultipv (\d+)/)?.[1] || 1);
const scoreMatch = line.match(/\bscore (cp|mate) (-?\d+)/);
const pv = line.split(' pv ')[1]?.trim().split(/\s+/) || [];
if (!scoreMatch || !pv.length) return null;
return {
depth,
multipv,
score: { type: scoreMatch[1], value: Number(scoreMatch[2]) },
pv,
};
}
export function normalizeOllamaUrl(value) {
const url = new URL(value);
if (!['http:', 'https:'].includes(url.protocol)) throw new Error('Adresse Ollama invalide');
return url.origin + url.pathname.replace(/\/$/, '');
}
export function analyzePosition(fen, depth = 16, multiPv = 3) {
return new Promise((resolve, reject) => {
const binary = process.env.STOCKFISH_PATH || (process.platform === 'win32' ? 'stockfish' : '/usr/games/stockfish');
const engine = spawn(binary, [], { stdio: ['pipe', 'pipe', 'pipe'] });
const lines = new Map();
let buffer = '';
const timeout = setTimeout(() => finish(new Error('Analyse Stockfish expirée')), 25000);
function finish(error) {
clearTimeout(timeout);
if (!engine.killed) engine.kill();
if (error) reject(error);
else resolve([...lines.values()].sort((a, b) => a.multipv - b.multipv));
}
engine.on('error', finish);
engine.stderr.on('data', () => {});
engine.stdout.on('data', (chunk) => {
buffer += chunk.toString();
const rows = buffer.split(/\r?\n/);
buffer = rows.pop() || '';
for (const row of rows) {
const parsed = parseInfo(row);
if (parsed && parsed.depth >= depth - 1) lines.set(parsed.multipv, parsed);
if (row.startsWith('bestmove')) finish(lines.size ? null : new Error('Stockfish sans résultat'));
}
});
engine.stdin.write('uci\n');
engine.stdin.write(`setoption name MultiPV value ${multiPv}\n`);
engine.stdin.write(`position fen ${fen}\n`);
engine.stdin.write(`go depth ${depth}\n`);
});
}
+14
View File
@@ -0,0 +1,14 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { normalizeOllamaUrl, parseInfo } from './engine.js';
test('parse une ligne MultiPV Stockfish', () => {
assert.deepEqual(parseInfo('info depth 16 multipv 2 score cp -34 nodes 4 pv e2e4 e7e5'), {
depth: 16, multipv: 2, score: { type: 'cp', value: -34 }, pv: ['e2e4', 'e7e5'],
});
});
test('normalise URL Ollama', () => {
assert.equal(normalizeOllamaUrl('http://192.168.1.3:11434/'), 'http://192.168.1.3:11434');
assert.throws(() => normalizeOllamaUrl('file:///etc/passwd'));
});
+75
View File
@@ -0,0 +1,75 @@
import express from 'express';
import { Chess } from 'chess.js';
import { analyzePosition, normalizeOllamaUrl } from './engine.js';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const app = express();
const port = Number(process.env.PORT || 3000);
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
app.use(express.json({ limit: '32kb' }));
app.get('/api/health', (_req, res) => res.json({ ok: true }));
app.post('/api/analyze', async (req, res) => {
try {
const chess = new Chess(req.body.fen);
if (chess.isGameOver()) return res.json({ lines: [], gameOver: true });
const depth = Math.max(8, Math.min(22, Number(req.body.depth) || 16));
const raw = await analyzePosition(chess.fen(), depth, 3);
const lines = raw.map((line) => {
const replay = new Chess(chess.fen());
const moves = [];
for (const uci of line.pv.slice(0, 8)) {
const move = replay.move({ from: uci.slice(0, 2), to: uci.slice(2, 4), promotion: uci[4] || 'q' });
if (!move) break;
moves.push({ uci, san: move.san });
}
return { ...line, moves };
});
res.json({ lines, gameOver: false });
} catch (error) {
res.status(400).json({ error: error.message || 'Analyse impossible' });
}
});
app.post('/api/ollama/test', async (req, res) => {
try {
const base = normalizeOllamaUrl(req.body.url);
const response = await fetch(`${base}/api/tags`, { signal: AbortSignal.timeout(7000) });
if (!response.ok) throw new Error(`Ollama répond ${response.status}`);
const data = await response.json();
res.json({ ok: true, models: (data.models || []).map((m) => m.name) });
} catch (error) {
res.status(502).json({ error: error.message || 'Connexion Ollama impossible' });
}
});
app.post('/api/explain', async (req, res) => {
try {
const base = normalizeOllamaUrl(req.body.url);
const { fen, lines, model = 'qwen3:8b', lastMove } = req.body;
new Chess(fen);
const prompt = `Tu es un entraîneur d'échecs francophone précis et encourageant. Position FEN: ${fen}. Dernier coup: ${lastMove || 'début de position'}. Variantes Stockfish: ${JSON.stringify(lines)}. Explique en français le meilleur coup, son idée tactique ou stratégique, la menace, et un piège à éviter. 120 mots maximum. N'invente aucune variante hors des données. Utilise texte simple, sans markdown.`;
const response = await fetch(`${base}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model, stream: false, messages: [{ role: 'user', content: prompt }], options: { temperature: 0.25 } }),
signal: AbortSignal.timeout(90000),
});
if (!response.ok) throw new Error(`Ollama répond ${response.status}`);
const data = await response.json();
res.json({ explanation: data.message?.content?.replace(/<think>[\s\S]*?<\/think>/g, '').trim() || 'Aucune explication reçue.' });
} catch (error) {
res.status(502).json({ error: error.message || 'Explication impossible' });
}
});
app.use(express.static(path.join(root, 'dist')));
app.use((_req, res) => res.sendFile(path.join(root, 'dist', 'index.html')));
if (process.argv[1] === fileURLToPath(import.meta.url)) {
app.listen(port, () => console.log(`Atelier 64 écoute sur :${port}`));
}
export default app;
+19
View File
@@ -0,0 +1,19 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { once } from 'node:events';
import app from './index.js';
test('expose état API et frontend compilé', async (t) => {
const server = app.listen(0, '127.0.0.1');
t.after(() => server.close());
await once(server, 'listening');
const { port } = server.address();
const health = await fetch(`http://127.0.0.1:${port}/api/health`);
assert.equal(health.status, 200);
assert.deepEqual(await health.json(), { ok: true });
const page = await fetch(`http://127.0.0.1:${port}/`);
assert.equal(page.status, 200);
assert.match(await page.text(), /Atelier 64/);
});
+221
View File
@@ -0,0 +1,221 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { createRoot } from 'react-dom/client';
import { Chess } from 'chess.js';
import { Brain, ChevronRight, CircleHelp, FlipVertical2, Lightbulb, LoaderCircle, RotateCcw, Settings, Sparkles, X } from 'lucide-react';
import './styles.css';
const PIECES = { p: '♟', n: '♞', b: '♝', r: '♜', q: '♛', k: '♚', P: '♙', N: '♘', B: '♗', R: '♖', Q: '♕', K: '♔' };
const FILES = ['a','b','c','d','e','f','g','h'];
const RANKS = ['8','7','6','5','4','3','2','1'];
const START_FEN = new Chess().fen();
const defaultSettings = { ollamaUrl: 'http://host.docker.internal:11434', model: 'qwen3:8b', depth: 16 };
function scoreLabel(score) {
if (!score) return '—';
if (score.type === 'mate') return `M${Math.abs(score.value)}`;
const value = score.value / 100;
return `${value >= 0 ? '+' : ''}${value.toFixed(1)}`;
}
function ChessBoard({ game, orientation, selected, targets, bestMove, lastMove, onSquare }) {
const files = orientation === 'white' ? FILES : [...FILES].reverse();
const ranks = orientation === 'white' ? RANKS : [...RANKS].reverse();
const boardRef = useRef(null);
const [dragFrom, setDragFrom] = useState(null);
const arrow = useMemo(() => {
if (!bestMove) return null;
const index = (square) => ({ x: files.indexOf(square[0]), y: ranks.indexOf(square[1]) });
const from = index(bestMove.slice(0, 2));
const to = index(bestMove.slice(2, 4));
return { x1: (from.x + .5) * 12.5, y1: (from.y + .5) * 12.5, x2: (to.x + .5) * 12.5, y2: (to.y + .5) * 12.5 };
}, [bestMove, files.join(''), ranks.join('')]);
return <div className="board-shell">
<div className="board" ref={boardRef} aria-label="Échiquier interactif">
{ranks.flatMap((rank, row) => files.map((file, col) => {
const square = `${file}${rank}`;
const piece = game.get(square);
const code = piece ? (piece.color === 'w' ? piece.type.toUpperCase() : piece.type) : null;
const dark = (FILES.indexOf(file) + Number(rank)) % 2 === 1;
const isLast = lastMove?.includes(square);
const isBest = bestMove?.slice(0, 2) === square || bestMove?.slice(2, 4) === square;
return <button
type="button"
key={square}
className={`square ${dark ? 'dark' : 'light'} ${selected === square ? 'selected' : ''} ${isLast ? 'last' : ''} ${isBest ? 'best' : ''}`}
onClick={() => onSquare(square)}
onDragOver={(event) => event.preventDefault()}
onDrop={() => { if (dragFrom) onSquare(square, dragFrom); setDragFrom(null); }}
aria-label={`${square}${piece ? ` ${piece.type}` : ''}`}
>
{col === 0 && <span className="rank-label">{rank}</span>}
{row === 7 && <span className="file-label">{file}</span>}
{targets.includes(square) && <span className={`target ${piece ? 'capture' : ''}`} />}
{piece && <span className={`piece ${piece.color === 'w' ? 'white-piece' : 'black-piece'}`} draggable onDragStart={() => setDragFrom(square)}>{PIECES[code]}</span>}
</button>;
}))}
{arrow && <svg className="arrow" viewBox="0 0 100 100" aria-hidden="true">
<defs><marker id="head" markerWidth="4" markerHeight="4" refX="2.2" refY="2" orient="auto"><path d="M0,0 L4,2 L0,4 z" /></marker></defs>
<line {...arrow} markerEnd="url(#head)" />
</svg>}
</div>
</div>;
}
function SettingsPanel({ value, onSave, onClose }) {
const [form, setForm] = useState(value);
const [status, setStatus] = useState(null);
const [models, setModels] = useState([]);
async function testConnection() {
setStatus('loading');
try {
const response = await fetch('/api/ollama/test', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: form.ollamaUrl }) });
const data = await response.json();
if (!response.ok) throw new Error(data.error);
setModels(data.models);
setStatus('ok');
} catch (error) { setStatus(error.message); }
}
return <div className="modal-backdrop" onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
<section className="settings-panel" role="dialog" aria-modal="true" aria-label="Réglages">
<div className="panel-heading"><div><span className="eyebrow">CONNEXIONS</span><h2>Réglages du studio</h2></div><button className="icon-button" onClick={onClose}><X size={20}/></button></div>
<label>Adresse Ollama<input value={form.ollamaUrl} onChange={(e) => setForm({ ...form, ollamaUrl: e.target.value })} placeholder="http://192.168.1.10:11434" /></label>
<p className="field-note">Depuis Docker Desktop, <code>host.docker.internal</code> atteint Ollama installé sur cette machine.</p>
<label>Modèle<input list="model-list" value={form.model} onChange={(e) => setForm({ ...form, model: e.target.value })} /><datalist id="model-list">{models.map((m) => <option key={m} value={m}/>)}</datalist></label>
<div className="recommendation"><Sparkles size={17}/><span><strong>Conseil</strong> — qwen3:8b offre bon équilibre. qwen3:30b produit meilleures explications si machine distante possède ~24 Go de RAM/VRAM.</span></div>
<label>Profondeur Stockfish <span>{form.depth}</span><input type="range" min="10" max="22" value={form.depth} onChange={(e) => setForm({ ...form, depth: Number(e.target.value) })}/></label>
<div className="settings-actions"><button className="secondary" onClick={testConnection}>{status === 'loading' ? <LoaderCircle className="spin" size={16}/> : null} Tester Ollama</button><button className="primary" onClick={() => onSave(form)}>Enregistrer</button></div>
{status && status !== 'loading' && <p className={`connection-status ${status === 'ok' ? 'success' : 'error'}`}>{status === 'ok' ? `${models.length} modèle(s) disponible(s)` : status}</p>}
</section>
</div>;
}
function App() {
const [fen, setFen] = useState(START_FEN);
const [moves, setMoves] = useState([]);
const [history, setHistory] = useState([]);
const [selected, setSelected] = useState(null);
const [orientation, setOrientation] = useState('white');
const [analysis, setAnalysis] = useState([]);
const [analyzing, setAnalyzing] = useState(true);
const [error, setError] = useState('');
const [explanation, setExplanation] = useState('');
const [explaining, setExplaining] = useState(false);
const [showHint, setShowHint] = useState(true);
const [showSettings, setShowSettings] = useState(false);
const [settings, setSettings] = useState(() => {
try { return { ...defaultSettings, ...JSON.parse(localStorage.getItem('atelier64-settings')) }; } catch { return defaultSettings; }
});
const game = useMemo(() => new Chess(fen), [fen]);
const targets = selected ? game.moves({ square: selected, verbose: true }).map((m) => m.to) : [];
const bestMove = showHint ? analysis[0]?.pv?.[0] : null;
const lastMove = moves.at(-1)?.uci;
useEffect(() => {
const controller = new AbortController();
const timer = setTimeout(async () => {
setAnalyzing(true); setError(''); setExplanation('');
try {
const response = await fetch('/api/analyze', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fen, depth: settings.depth }), signal: controller.signal });
const data = await response.json();
if (!response.ok) throw new Error(data.error);
setAnalysis(data.lines || []);
} catch (err) { if (err.name !== 'AbortError') setError(err.message); }
finally { if (!controller.signal.aborted) setAnalyzing(false); }
}, 250);
return () => { clearTimeout(timer); controller.abort(); };
}, [fen, settings.depth]);
function attemptMove(from, to) {
const next = new Chess(fen);
try {
const move = next.move({ from, to, promotion: 'q' });
setHistory([...history, { fen, moves }]);
setMoves([...moves, { san: move.san, uci: `${move.from}${move.to}${move.promotion || ''}`, color: move.color }]);
setFen(next.fen()); setSelected(null); setShowHint(true);
} catch { setSelected(next.get(to)?.color === next.turn() ? to : null); }
}
function onSquare(square, draggedFrom) {
if (game.isGameOver()) return;
if (draggedFrom) return attemptMove(draggedFrom, square);
if (selected) {
if (selected === square) return setSelected(null);
if (targets.includes(square)) return attemptMove(selected, square);
}
setSelected(game.get(square)?.color === game.turn() ? square : null);
}
function undo() {
const previous = history.at(-1);
if (!previous) return;
setFen(previous.fen); setMoves(previous.moves); setHistory(history.slice(0, -1)); setSelected(null);
}
function reset() { setFen(START_FEN); setMoves([]); setHistory([]); setSelected(null); setAnalysis([]); setExplanation(''); }
async function explain() {
if (!analysis.length) return;
setExplaining(true); setError('');
try {
const response = await fetch('/api/explain', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: settings.ollamaUrl, model: settings.model, fen, lastMove: moves.at(-1)?.san, lines: analysis.map((l) => ({ score: l.score, line: l.moves?.map((m) => m.san) })) }) });
const data = await response.json();
if (!response.ok) throw new Error(data.error);
setExplanation(data.explanation);
} catch (err) { setError(err.message); }
finally { setExplaining(false); }
}
const status = game.isCheckmate() ? `Échec et mat — ${game.turn() === 'w' ? 'Noirs' : 'Blancs'} gagnent` : game.isDraw() ? 'Partie nulle' : game.inCheck() ? `Échec aux ${game.turn() === 'w' ? 'Blancs' : 'Noirs'}` : `${game.turn() === 'w' ? 'Blancs' : 'Noirs'} au trait`;
const rawScore = analysis[0]?.score;
const whiteScore = rawScore?.type === 'cp' ? (game.turn() === 'w' ? rawScore.value : -rawScore.value) : 0;
const whiteShare = Math.max(8, Math.min(92, 50 + whiteScore / 15));
return <div className="app">
<header>
<a className="brand" href="#" onClick={(e) => e.preventDefault()}><span className="brand-mark">64</span><span><strong>ATELIER</strong><small>STUDIO D’ÉCHECS</small></span></a>
<div className="header-status"><span className="pulse"/> STOCKFISH ACTIF <i/> PROFONDEUR {settings.depth}</div>
<button className="settings-button" onClick={() => setShowSettings(true)}><Settings size={17}/> Réglages</button>
</header>
<main>
<section className="game-column">
<div className="game-meta"><div><span className="eyebrow">PARTIE LIBRE · DEUX CÔTÉS</span><h1>{status}</h1></div><div className="turn-chip"><span className={game.turn() === 'w' ? 'mini-white' : 'mini-black'}/>{game.turn() === 'w' ? 'BLANCS' : 'NOIRS'}</div></div>
<div className="board-area">
<div className="eval-bar" aria-label="Évaluation"><span className="eval-number">{scoreLabel(rawScore)}</span><div className="eval-track"><div className="eval-white" style={{ height: `${whiteShare}%` }}/></div></div>
<ChessBoard game={game} orientation={orientation} selected={selected} targets={targets} bestMove={bestMove} lastMove={lastMove} onSquare={onSquare}/>
</div>
<div className="board-actions">
<button onClick={undo} disabled={!history.length}><RotateCcw size={17}/> Annuler</button>
<button onClick={() => setOrientation(orientation === 'white' ? 'black' : 'white')}><FlipVertical2 size={17}/> Retourner</button>
<button onClick={reset}>Nouvelle partie</button>
</div>
</section>
<aside className="coach-column">
<div className="coach-title"><div className="coach-orb"><Brain size={24}/></div><div><span className="eyebrow">ANALYSE EN DIRECT</span><h2>Le Cabinet</h2></div>{analyzing && <LoaderCircle className="spin analysis-spinner" size={18}/>}</div>
<section className="best-card">
<span className="card-index">01</span><span className="card-label">MEILLEUR COUP</span>
{analysis[0] ? <><div className="best-move"><strong>{analysis[0].moves?.[0]?.san || analysis[0].pv[0]}</strong><span>{scoreLabel(analysis[0].score)}</span></div><p>Suggestion Stockfish pour position actuelle.</p><button className={`hint-toggle ${showHint ? 'active' : ''}`} onClick={() => setShowHint(!showHint)}><Lightbulb size={16}/>{showHint ? 'Indice visible' : 'Afficher indice'}</button></> : <div className="empty-analysis">{analyzing ? 'Calcul de la position…' : game.isGameOver() ? 'Partie terminée.' : 'Analyse indisponible.'}</div>}
</section>
<section className="variations">
<div className="section-label"><span>VARIANTES PRINCIPALES</span><span>ÉVAL.</span></div>
{analysis.slice(0, 3).map((line, index) => <div className="variation" key={index}><span className="variation-number">{String(index + 1).padStart(2, '0')}</span><div>{line.moves?.slice(0, 6).map((m, i) => <span key={i} className={i === 0 ? 'first-san' : ''}>{m.san} </span>)}</div><b>{scoreLabel(line.score)}</b></div>)}
</section>
<section className="coach-note">
<div className="note-heading"><CircleHelp size={17}/><span>REGARD DU MAÎTRE</span></div>
{explanation ? <p>{explanation}</p> : <p>Demandez au modèle Ollama de transformer calcul moteur en conseil humain, adapté à cette position.</p>}
<button className="explain-button" onClick={explain} disabled={explaining || !analysis.length}>{explaining ? <LoaderCircle className="spin" size={17}/> : <Sparkles size={17}/>} {explanation ? 'Réexpliquer' : 'Expliquer ce coup'} <ChevronRight size={16}/></button>
</section>
{error && <div className="error-banner">{error}</div>}
<section className="moves-panel"><div className="section-label"><span>FEUILLE DE PARTIE</span><span>{Math.ceil(moves.length / 2)} COUPS</span></div><div className="move-list">{Array.from({ length: Math.ceil(moves.length / 2) }, (_, i) => <div className="move-row" key={i}><span>{i + 1}.</span><b>{moves[i * 2]?.san}</b><b>{moves[i * 2 + 1]?.san}</b></div>)}{!moves.length && <span className="moves-empty">Déplacez une pièce pour commencer.</span>}</div></section>
</aside>
</main>
<footer><span>ATELIER 64</span><span>Les coups viennent de Stockfish · Les explications viennent de {settings.model}</span></footer>
{showSettings && <SettingsPanel value={settings} onClose={() => setShowSettings(false)} onSave={(next) => { setSettings(next); localStorage.setItem('atelier64-settings', JSON.stringify(next)); setShowSettings(false); }}/>}
</div>;
}
createRoot(document.getElementById('root')).render(<App />);
+44
View File
@@ -0,0 +1,44 @@
@import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Manrope:wght@400;500;600;700&family=Playfair+Display:ital,wght@0,600;1,600&display=swap');
:root { --ink:#111411; --paper:#efe9dc; --paper-2:#e6dece; --copper:#c76d45; --sage:#819079; --cream:#f7f2e8; --muted:#817c70; --line:rgba(17,20,17,.14); font-family:'Manrope',sans-serif; color:var(--ink); background:var(--paper); }
* { box-sizing:border-box; }
body { margin:0; min-width:320px; min-height:100vh; background:var(--paper); }
button,input { font:inherit; }
button { color:inherit; }
.app { min-height:100vh; background-image:radial-gradient(circle at 75% 8%,rgba(199,109,69,.11),transparent 28%),linear-gradient(rgba(17,20,17,.025) 1px,transparent 1px); background-size:auto,100% 5px; }
header { height:76px; padding:0 4vw; border-bottom:1px solid var(--line); display:flex; align-items:center; justify-content:space-between; background:rgba(239,233,220,.88); backdrop-filter:blur(16px); position:relative; z-index:3; }
.brand { color:inherit; text-decoration:none; display:flex; align-items:center; gap:12px; }
.brand-mark { width:39px; height:39px; color:var(--cream); background:var(--ink); display:grid; place-items:center; font:600 18px 'Playfair Display',serif; transform:rotate(-3deg); }
.brand strong,.brand small { display:block; letter-spacing:.2em; }
.brand strong { font-size:14px; }.brand small { font:9px 'DM Mono',monospace; color:var(--muted); margin-top:3px; }
.header-status { font:10px 'DM Mono',monospace; letter-spacing:.12em; color:#596253; display:flex; align-items:center; gap:9px; }
.header-status i { width:1px;height:15px;background:var(--line); }.pulse { width:6px;height:6px;border-radius:50%;background:#6b8e65;box-shadow:0 0 0 4px rgba(107,142,101,.13); }
.settings-button,.board-actions button { border:0;background:transparent;display:flex;align-items:center;gap:8px;cursor:pointer;font-size:12px;font-weight:700;letter-spacing:.06em;text-transform:uppercase; }
.settings-button { border:1px solid var(--line);padding:10px 14px; }.settings-button:hover { background:var(--ink);color:var(--cream); }
main { width:min(1450px,94vw); margin:0 auto; padding:34px 0 45px; display:grid; grid-template-columns:minmax(480px,760px) minmax(360px,1fr); gap:clamp(35px,5vw,84px); }
.game-meta { display:flex;justify-content:space-between;align-items:flex-end;margin-bottom:18px;padding-left:33px; }
.eyebrow { font:10px 'DM Mono',monospace;letter-spacing:.19em;color:var(--copper); }
h1,h2 { font-family:'Playfair Display',serif;margin:4px 0 0;font-weight:600; } h1 { font-size:clamp(24px,2.1vw,34px); } h2 { font-size:27px; }
.turn-chip { font:10px 'DM Mono',monospace;letter-spacing:.12em;display:flex;align-items:center;gap:8px;padding-bottom:5px; }.mini-white,.mini-black { width:12px;height:12px;border-radius:50%;display:block;box-shadow:0 0 0 1px var(--ink); }.mini-white{background:var(--cream)}.mini-black{background:var(--ink)}
.board-area { display:flex; gap:16px; align-items:stretch; }
.eval-bar { width:18px; position:relative; display:flex;flex-direction:column;gap:8px; }.eval-number { font:9px 'DM Mono',monospace; writing-mode:vertical-rl;transform:rotate(180deg);height:34px; }.eval-track { background:var(--ink);flex:1;position:relative;overflow:hidden; }.eval-white { position:absolute;bottom:0;width:100%;background:var(--cream);transition:height .55s cubic-bezier(.2,.8,.2,1); }
.board-shell { width:min(100%,calc(100vh - 220px)); aspect-ratio:1; box-shadow:0 25px 50px rgba(55,43,27,.18),0 2px 0 rgba(255,255,255,.4) inset; border:7px solid #292a24; }
.board { display:grid;grid-template-columns:repeat(8,1fr);position:relative;width:100%;height:100%;border:1px solid #10110f; }
.square { border:0; padding:0; position:relative; display:grid;place-items:center;cursor:pointer; overflow:hidden; }.square.light{background:#d8d0bc}.square.dark{background:#67725e}.square:hover:after{content:'';position:absolute;inset:0;background:rgba(255,255,255,.11)}
.square.last:before { content:'';position:absolute;inset:0;background:rgba(214,169,67,.34); }.square.selected { box-shadow:inset 0 0 0 4px rgba(244,190,70,.78); }.square.best { box-shadow:inset 0 0 0 3px rgba(199,109,69,.65); }
.piece { font-family:'Times New Roman',serif;font-size:clamp(32px,5.8vw,74px);line-height:1;position:relative;z-index:2;cursor:grab;user-select:none;filter:drop-shadow(0 3px 1px rgba(0,0,0,.22));transition:transform .12s; }.piece:hover{transform:translateY(-2px)}.white-piece{color:#f6f0e2;text-shadow:-1px -1px 0 #191b17,1px -1px 0 #191b17,-1px 1px 0 #191b17,1px 1px 0 #191b17}.black-piece{color:#171914;text-shadow:0 1px 0 rgba(255,255,255,.17)}
.target { width:18%;height:18%;border-radius:50%;background:rgba(18,23,17,.35);position:absolute;z-index:3;pointer-events:none; }.target.capture { width:76%;height:76%;background:transparent;border:5px solid rgba(18,23,17,.35); }
.rank-label,.file-label { position:absolute;font:600 8px 'DM Mono',monospace;z-index:4;opacity:.72; }.rank-label{top:3px;left:4px}.file-label{bottom:2px;right:4px}.dark .rank-label,.dark .file-label{color:var(--cream)}
.arrow { position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:5;overflow:visible; }.arrow line { stroke:#d66f43;stroke-width:1.7;stroke-linecap:round;opacity:.85; }.arrow marker path{fill:#d66f43}
.board-actions { padding:18px 0 0 34px;display:flex;gap:22px; }.board-actions button { color:#5f5b52; }.board-actions button:hover{color:var(--copper)}.board-actions button:disabled{opacity:.3;cursor:not-allowed}.board-actions button:last-child{margin-left:auto;border-bottom:1px solid var(--ink);padding-bottom:3px;}
.coach-column { border-left:1px solid var(--line);padding-left:clamp(28px,4vw,60px);min-width:0; }
.coach-title { display:flex;align-items:center;gap:14px;margin:4px 0 23px; }.coach-orb{width:48px;height:48px;border-radius:50%;display:grid;place-items:center;background:var(--ink);color:var(--paper);box-shadow:0 0 0 7px rgba(17,20,17,.06)}.analysis-spinner{margin-left:auto;color:var(--copper)}
.best-card { background:var(--ink);color:var(--cream);padding:22px 25px 24px;position:relative;overflow:hidden; }.best-card:after{content:'♞';font:130px 'Times New Roman';position:absolute;right:-12px;bottom:-48px;color:rgba(255,255,255,.035);}.card-index{font:10px 'DM Mono';color:var(--copper);margin-right:13px}.card-label{font:10px 'DM Mono';letter-spacing:.18em;color:#a9aa9f}.best-move{display:flex;align-items:baseline;gap:13px;margin-top:19px}.best-move strong{font:italic 46px 'Playfair Display',serif}.best-move span{font:12px 'DM Mono';padding:4px 7px;background:var(--copper);}.best-card p{font-size:12px;color:#aaa99f;margin:2px 0 16px}.hint-toggle{position:relative;z-index:2;border:1px solid #45473f;background:transparent;color:#bbbcb1;padding:9px 12px;display:flex;align-items:center;gap:7px;cursor:pointer;font-size:11px}.hint-toggle.active{color:#f0bb68;border-color:#8b7148}.empty-analysis{padding:34px 0 23px;color:#96978d;font-family:'Playfair Display',serif;font-style:italic}
.variations { margin-top:26px; }.section-label{display:flex;justify-content:space-between;border-bottom:1px solid var(--ink);padding-bottom:8px;font:9px 'DM Mono';letter-spacing:.15em;color:#69665e}.variation{display:grid;grid-template-columns:30px 1fr 42px;gap:10px;align-items:start;border-bottom:1px solid var(--line);padding:14px 2px;font-size:13px;line-height:1.7}.variation-number{font:10px 'DM Mono';color:var(--copper);padding-top:4px}.variation b:last-child{text-align:right;font:500 11px 'DM Mono';padding-top:4px}.first-san{font-weight:800;color:var(--copper)}
.coach-note { background:#ded5c3;margin-top:26px;padding:20px 22px;border-left:3px solid var(--copper); }.note-heading{display:flex;align-items:center;gap:8px;font:10px 'DM Mono';letter-spacing:.15em}.coach-note p{font-family:'Playfair Display',serif;font-size:15px;line-height:1.55;margin:13px 0;color:#3b3a34}.explain-button{border:0;border-bottom:1px solid #383933;background:transparent;padding:5px 0;display:flex;align-items:center;gap:7px;text-transform:uppercase;font-size:10px;font-weight:800;letter-spacing:.09em;cursor:pointer}.explain-button svg:last-child{margin-left:4px}.explain-button:disabled{opacity:.5}
.moves-panel{margin-top:27px}.move-list{max-height:155px;overflow:auto;padding-top:6px}.move-row{display:grid;grid-template-columns:35px 1fr 1fr;padding:6px 2px;font-size:12px}.move-row span{font:10px 'DM Mono';color:#8d887d}.move-row b{font-weight:600}.moves-empty{font:italic 14px 'Playfair Display';color:#8a857a;display:block;padding:16px 0}.error-banner{font-size:11px;padding:11px 13px;margin-top:15px;background:#b94c3d;color:white}
footer { border-top:1px solid var(--line);padding:18px 4vw;display:flex;justify-content:space-between;color:#8d877b;font:9px 'DM Mono';letter-spacing:.12em; }
.modal-backdrop{position:fixed;inset:0;z-index:20;background:rgba(13,15,12,.68);backdrop-filter:blur(8px);display:grid;place-items:center;padding:20px}.settings-panel{width:min(540px,100%);background:var(--cream);box-shadow:0 30px 90px rgba(0,0,0,.35);padding:29px}.panel-heading{display:flex;justify-content:space-between;align-items:start;margin-bottom:24px}.icon-button{border:0;background:transparent;cursor:pointer}.settings-panel label{display:block;font-size:12px;font-weight:700;margin-top:18px}.settings-panel label>span{float:right;font:11px 'DM Mono';color:var(--copper)}.settings-panel input:not([type=range]){display:block;width:100%;margin-top:7px;background:transparent;border:0;border-bottom:1px solid #8e897f;padding:10px 2px;font:13px 'DM Mono';outline:none}.settings-panel input:focus{border-color:var(--copper)}.settings-panel input[type=range]{display:block;width:100%;accent-color:var(--copper);margin-top:12px}.field-note{font-size:10px;color:#777268;margin:7px 0}.recommendation{margin-top:19px;padding:13px;background:#e7dfcf;display:flex;gap:10px;align-items:flex-start;font-size:11px;line-height:1.5}.recommendation svg{flex:0 0 auto;color:var(--copper)}.settings-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:27px}.settings-actions button{padding:11px 16px;border:1px solid var(--ink);font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;cursor:pointer;display:flex;gap:7px;align-items:center}.settings-actions .primary{background:var(--ink);color:var(--cream)}.settings-actions .secondary{background:transparent}.connection-status{font-size:11px;text-align:right}.connection-status.success{color:#52704e}.connection-status.error{color:#b24b3c}.spin{animation:spin .8s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}
@media(max-width:980px){header{padding:0 22px}.header-status{display:none}main{grid-template-columns:1fr;width:min(720px,94vw)}.board-shell{width:100%}.coach-column{border-left:0;border-top:1px solid var(--line);padding:32px 0 0}.game-meta{padding-left:32px}}
@media(max-width:560px){header{height:64px}.brand small{display:none}.settings-button{font-size:0;padding:10px}.settings-button svg{width:19px;height:19px}.game-meta{padding-left:27px}.turn-chip{display:none}main{padding-top:22px}.board-area{gap:9px}.eval-bar{width:16px}.board-shell{border-width:4px}.piece{font-size:11vw}.board-actions{padding-left:25px;gap:14px}.board-actions button{font-size:0}.board-actions button svg{width:20px;height:20px}.board-actions button:last-child{font-size:10px}footer{gap:20px;line-height:1.5}.settings-panel{padding:22px}}
@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation:none!important;transition:none!important}}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: { proxy: { '/api': 'http://localhost:3000' } },
});