Compare commits

...

30 Commits

Author SHA1 Message Date
9f320e2f91 Fix: neue Züge wurden nicht angezeigt (Brett/Zugliste blieben in Startposition) 2026-05-28 15:48:13 +02:00
c7633f5c7f Fix: Startreihenfolge standings vor PGN, Tabellenplatz-Link mit Style 2026-05-28 09:00:13 +02:00
8758441f65 Split app.js into modular components (state, evaluation, ui, board, data) 2026-05-27 22:55:49 +02:00
8d971dbef9 Fix: Züge mit unnötiger SAN-Disambiguierung (Nge7) schlagen bei chess.js fehl; Auto-Advance bei neuen Zügen im Polling 2026-05-27 10:22:52 +02:00
a42fe45812 Doku aktualisiert: README aktualisiert (Features, Projektstruktur, Env-Vars), Doku-Prüf-Regel in AGENTS.md 2026-05-26 12:57:16 +02:00
6ba57c3927 Code-Review: Path-Traversal-Fix, toten Code entfernt (formatClock, data.error Check), Emoji-Literal korrigiert, Einrückung fix 2026-05-26 12:47:47 +02:00
352ed480a8 Fix: PGN-Encoding (UTF-8/Latin-1 Fallback für Umlaute) 2026-05-25 21:26:43 +02:00
b3d43e37b8 Eval-Bar: Hilfslinien (±1..±7) mit mix-blend-mode, Null-Linie 4px, Füllbalken dunkler 2026-05-25 21:21:36 +02:00
79ab97da6e Evaluation: depth 25 oder 15s Timeout (movetime 15000) 2026-05-25 21:08:36 +02:00
fbb15ed61a Fix: Content-Length für HTTP/1.1 statische Dateien (NS_ERROR_NET_EMPTY_RESPONSE) 2026-05-25 20:48:33 +02:00
0be816a4ec Evaluation: depth 15 statt 10s, HTTP/1.1 Chunked-Streaming, Abbruch-Support 2026-05-25 20:39:58 +02:00
ec1ebaa525 Eval-Bar mit Stockfish-Streaming, LESS entfernt, Move-Lock, AGENTS-Regeln 2026-05-25 20:09:09 +02:00
3b1a4ce4e4 Refactor: JS in js/, LESS in less/, clientseitige Less-Kompilierung via less.js CDN 2026-05-25 01:54:53 +02:00
f5d6d59cdd Responsive Mobile-Design: PGN/Tabelle-Reihenfolge, Board-Vergrößerung, Round-Info verschoben, kompaktere Player-Infos 2026-05-25 01:29:19 +02:00
47e7924a49 Fix all ESLint errors across project 2026-05-25 00:55:16 +02:00
6608d771a2 Screenshot + LESS-Migration: style.css → style.less, Build-Skript, Doku-Update 2026-05-25 00:43:32 +02:00
df77f52a53 PGN-Panel + Cache-Busting: alle Runden immer frisch laden, Rundenerkennung bei jedem Refresh 2026-05-24 18:03:09 +02:00
b337c92d77 Tabelle nur einmalig beim Laden der Seite 2026-05-24 17:42:25 +02:00
20a59166a8 Remove proxy - load data directly from DSJ 2026-05-24 17:37:22 +02:00
3944c7d5bb Fix caching issue - load fresh PGN for current round 2026-05-24 16:57:16 +02:00
812cd3b24f Fix: loadPGN ruft updateStandings() vor currentRound-Check auf, damit Requests tatsächlich ausgeführt werden 2026-05-24 15:49:56 +02:00
638ef0360f AGENTS.md mit Proxy-Regeln 2026-05-24 15:47:36 +02:00
12a2b5c7db Runde in PGN-URL (/pgn/{round}) und im Standings-Response 2026-05-24 15:44:03 +02:00
08ec14bd63 Runde wird automatisch aus der Tabelle ermittelt 2026-05-24 15:42:17 +02:00
82e37fb9bf Rundenbasierte PGN-URLs (aktuell Runde 2) 2026-05-24 15:39:47 +02:00
ab4486cc98 Server: If-Modified-Since, block bis Änderung; Client: sofortiger Re-Poll 2026-05-24 15:34:27 +02:00
7efa38c91a Switch to Long-Polling, add Turniertabellen-Anzeige für ODJM D 2026 2026-05-24 15:32:36 +02:00
2ad3dab7f8 feat: deployment support and production readiness
- Add deployment section to README (systemd, Docker, nginx reverse proxy)
- Add environment variable support to server.py (PORT, PGN_URL, CACHE_TTL)
- Update .gitignore for Python artifacts and lara-chess directory
- Improve board/navigation UX in app.js (userScrolledMoves tracking,
  updateClocks function, better game switching)
2026-05-24 15:12:17 +02:00
0d94cac60c PGN-Parser verbessert: robustes Tokenizing mit Kommentar- und %clk-Erkennung; app.js: manuelle Partieauswahl bleibt nach Klick erhalten 2026-05-24 14:49:07 +02:00
15b6f31f62 UI immer aus Laras Perspektive: Lara unten, Brett-Orientierung dynamisch, Züge in Gold 2026-05-24 14:41:08 +02:00
17 changed files with 1722 additions and 779 deletions

6
.gitignore vendored
View File

@@ -1 +1,7 @@
cache/
__pycache__/
*.pyc
lara-chess/
node_modules/
package-lock.json
stockfish.exe

8
AGENTS.md Normal file
View File

@@ -0,0 +1,8 @@
# Agenten-Regeln
- Nach jeder Änderung am Proxy (server.py) oder an den Requests (app.js) muss der Proxy neu gestartet werden: erst alten Prozess killen (`Get-Process -Name python | Stop-Process -Force`), dann unsichtbar starten.
- Commits und Push erfolgen nur durch expliziten Befehl des Nutzers.
- Der Server (server.py) wird immer unsichtbar gestartet: `Start-Process -NoNewWindow -FilePath "python" -ArgumentList "server.py"`
- Bei Updates (Polling/Code-Änderungen) niemals den aktuell angezeigten Zug wechseln. Der Benutzer bleibt auf dem von ihm ausgewählten Zug.
- Nach jeder Code-Änderung muss die Doku (README.md) auf Richtigkeit und Vollständigkeit geprüft und bei Bedarf aktualisiert werden (Features, Projektstruktur, Konfiguration, Umgebungsvariablen, Technik-Tabelle).

127
README.md
View File

@@ -1,27 +1,37 @@
# 🏆 Lara Kiesewetter Live Schachturnier
# Lara Kiesewetter Live Schachturnier
Live-Überwachung von Lara Kiesewetters Partien bei der **ODJM (Offene Deutsche Jugendmeisterschaft)** 2026. Die App lädt automatisch PGN-Daten von der Deutschen Schachjugend, zeigt das aktuelle Spielbrett an und listet alle Partien von Lara.
![Screenshot](screenshot.png)
## Features
- **Live-Brett** Visuelle Darstellung der aktuellen Partie mit chessboard.js
- **Stockfish-Analyse** Bewertungsbalken (Eval-Bar) mit Live-Score in Bauerneinheiten, powered by Stockfish-Engine
- **Spielerinfo** Anzeige von Namen, ELO und Schachuhr beider Spieler
- **Zugliste** Alle Züge der aktuellen Partie mit Klick-Navigation
- **Zugliste** Alle Züge der aktuellen Partie mit Klick-Navigation und Pfeiltasten-Steuerung
- **Alle Partien** Übersicht aller Runden mit Lara; Klick zum Wechseln
- **Live-Erkennung** Automatische Erkennung laufender (unterminierter) Partien
- **Auto-Refresh** Aktualisiert die Daten alle 60 Sekunden
- **Proxy-Server** Lokaler Python-Server mit PGN-Caching und CORS-Unterstützung
- **[Turniertabelle](https://www.deutsche-schachjugend.de/2026/odjm-d/tabelle/)** Tabellenplatz, Punkte, Siege/Unentschieden/Niederlagen von Lara
- **Auto-Refresh** Aktualisiert die Daten alle 15 Sekunden (nur aktuelle Runden)
- **Proxy-Server** Lokaler Python-Server mit Stockfish-Engine-Wrapper, PGN-Proxy und statischem File-Serving
## Projektstruktur
```
├── app.js # Hauptlogik (Brett, UI, Auto-Refresh)
```text
├── index.html # HTML-Grundgerüst
├── style.css # Dark-Theme-Styling
├── pgn-parser.js # PGN-Parser (Header + Züge extrahieren)
├── server.py # Lokaler Proxy-Server (Python 3)
├── cache/ # PGN-Cache (wird automatisch angelegt)
└── lara-chess/ # (optional reserviert für Erweiterungen)
├── style.css # Dark-Theme-Styling (responsive)
├── js/
│ ├── app.js # Event-Listener und Initialisierung
│ ├── state.js # Globaler Zustand (Variablen)
│ ├── board.js # Schachbrett-Rendering und Zug-Navigation
│ ├── data.js # PGN-Laden, Turniertabelle, Auto-Refresh
│ ├── evaluation.js # Stockfish-Analyse und Eval-Bar
│ ├── ui.js # UI-Rendering (Spielerinfo, Züge, PGN)
│ └── pgn-parser.js # PGN-Parser (Header + Züge extrahieren)
├── server.py # Lokaler Proxy-Server + Stockfish-Engine (Python 3)
├── cache/ # PGN- und Standings-Cache
└── stockfish.exe # Stockfish-Schachengine (Windows)
```
## Setup
@@ -29,6 +39,7 @@ Live-Überwachung von Lara Kiesewetters Partien bei der **ODJM (Offene Deutsche
### Voraussetzungen
- **Python 3.6+** für den Proxy-Server
- **Stockfish** (optional, für Analyse) auf Windows: `stockfish.exe` im Projektverzeichnis
- **Browser** (Chrome, Firefox, Edge)
### Installation & Start
@@ -40,37 +51,89 @@ Live-Überwachung von Lara Kiesewetters Partien bei der **ODJM (Offene Deutsche
Der Server läuft auf `http://localhost:8111`.
2. **Öffne die App im Browser:**
```
```text
http://localhost:8111
```
(Der Server serviert auch die statischen Dateien.)
### Ohne Proxy (direkter Dateizugriff)
Öffne `index.html` direkt im Browser. Hinweis: Der CORS-Proxy (`server.py`) muss trotzdem laufen, da der PGN-Endpunkt keine CORS-Header setzt.
## Technik
| Komponente | Technologie |
| ------------------ | --------------------------------------------------- |
| Schachbrett | [chessboard.js](https://github.com/oakmac/chessboardjs) |
| Schachlogik | [chess.js](https://github.com/jhlywa/chess.js) |
| UI | HTML, CSS, Vanilla JS |
| Proxy-Server | Python http.server (ohne externe Abhängigkeiten) |
| PGN-Quelle | deutsche-schachjugend.de (ODJM 2026) |
| Komponente | Technologie |
| ------------------ | ------------------------------------------------------------ |
| Schachbrett | [chessboard.js](https://github.com/oakmac/chessboardjs) |
| Schachlogik | [chess.js](https://github.com/jhlywa/chess.js) |
| Stockfish-Analyse | Stockfish Engine (lokal, via POST `/evaluate`) |
| UI | HTML, CSS, Vanilla JS, jQuery |
| Proxy-Server | Python http.server (subprocess, threading, NDJSON-Streaming) |
| PGN-Quelle | deutsche-schachjugend.de (ODJM 2026) |
## Konfiguration
## Deployment
Die wichtigsten Konstanten in `app.js`:
Die Anwendung kann als eigenständiger Server betrieben werden.
```js
const PGN_URL = 'https://www.deutsche-schachjugend.de/2026/odjm-d/partien/gesamt-utf8.pgn';
const REFRESH_INTERVAL = 60000; // Auto-Refresh alle 60s
const PLAYER_NAME = 'Kiesewetter, Lara';
### Produktivbetrieb (systemd Linux)
```bash
# Service-Datei erstellen
sudo tee /etc/systemd/system/lara-chess.service <<EOF
[Unit]
Description=Lara Kiesewetter Live Schachturnier
After=network.target
[Service]
Type=simple
User=www-data
WorkingDirectory=/opt/lara-chess
ExecStart=/usr/bin/python3 /opt/lara-chess/server.py
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
EOF
# Aktivieren und starten
sudo systemctl daemon-reload
sudo systemctl enable --now lara-chess
```
Im `server.py` lässt sich der Cache-TTL anpassen:
Die App läuft dann auf `http://SERVER_IP:8111`.
```python
CACHE_TTL = 30 # Sekunden
### Docker
```dockerfile
# Dockerfile
FROM python:3-slim
WORKDIR /app
COPY . .
EXPOSE 8111
CMD ["python", "server.py"]
```
```bash
docker build -t lara-chess .
docker run -d --name lara-chess -p 8111:8111 --restart unless-stopped lara-chess
```
### Reverse Proxy (nginx)
```nginx
server {
listen 80;
server_name chess.example.com;
location / {
proxy_pass http://127.0.0.1:8111;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
```
### Umgebungsvariablen (optional)
| Variable | Default | Beschreibung |
| ------------------- | ------------------------------ | ------------------------------- |
| `PORT` | `8111` | Server-Port |
| `STOCKFISH_PATH` | `stockfish.exe` / `stockfish` | Pfad zur Stockfish-Engine |
| `STOCKFISH_DEPTH` | `25` | Stockfish-Suchtiefe |

344
app.js
View File

@@ -1,344 +0,0 @@
/**
* Lara Kiesewetter Live Schachturnier
* Haupt-Application
*/
const PGN_URL = 'https://www.deutsche-schachjugend.de/2026/odjm-d/partien/gesamt-utf8.pgn';
const REFRESH_INTERVAL = 60000; // 60 Sekunden
const PLAYER_NAME = 'Kiesewetter, Lara';
let board = null;
let chess = null;
let currentGame = null;
let allLaraGames = [];
let refreshTimer = null;
let countdown = 0;
let serverLastFetch = null;
/**
* Lädt die PGN-Datei und aktualisiert die Anzeige
*/
async function loadPGN() {
showLoading(true);
hideError();
try {
const [pgnResponse, statusResponse] = await Promise.all([
fetch('http://localhost:8111/pgn'),
fetch('http://localhost:8111/status').catch(() => null)
]);
if (!pgnResponse.ok) throw new Error(`HTTP ${pgnResponse.status}`);
if (statusResponse && statusResponse.ok) {
const status = await statusResponse.json();
serverLastFetch = status.last_fetch ? status.last_fetch * 1000 : null;
}
const pgnText = await pgnResponse.text();
const allGames = parsePGN(pgnText);
allLaraGames = filterLaraGames(allGames);
if (allLaraGames.length === 0) {
showError('Keine Partien von Lara gefunden.');
return;
}
// Finde die aktuelle/live Partie
const liveGame = getLiveGame(allLaraGames);
const targetGame = liveGame || getLatestGame(allLaraGames);
currentGame = targetGame;
updateBoard();
updatePlayerInfo();
updateMovesList();
updateAllGamesList();
updateTimestamp();
showLoading(false);
} catch (error) {
console.error('Fehler beim Laden:', error);
showError(`Fehler: ${error.message}`);
showLoading(false);
}
}
/**
* Aktualisiert das Schachbrett
*/
function updateBoard() {
if (!currentGame) return;
chess = new Chess();
// Spiegele das Brett, wenn Lara Schwarz hat
const laraIsBlack = currentGame.black.toLowerCase().includes('kiesewetter');
const orientation = laraIsBlack ? 'black' : 'white';
// Führe alle Züge aus
for (const move of currentGame.moves) {
if (move.isResult) break;
try {
chess.move(move.san);
} catch (e) {
// Ignoriere ungültige Züge
}
}
if (board) board.position(chess.fen(), true);
else {
board = Chessboard('board', {
position: chess.fen(),
orientation: orientation,
pieceTheme: 'https://chessboardjs.com/img/chesspieces/wikipedia/{piece}.png',
draggable: false,
spawnMoveError: false
});
// Click auf Züge
document.getElementById('moves-list').addEventListener('click', handleMoveClick);
}
// Highlight active player
highlightActivePlayer();
}
/**
* Aktualisiert die Spielerinformationen
*/
function updatePlayerInfo() {
if (!currentGame) return;
const laraIsWhite = currentGame.white.toLowerCase().includes('kiesewetter');
if (laraIsWhite) {
document.getElementById('white-name').textContent = currentGame.white;
document.getElementById('white-elo').textContent = currentGame.whiteElo ? `(ELO: ${currentGame.whiteElo})` : '';
document.getElementById('white-clock').textContent = formatClock(currentGame.whiteClock);
document.getElementById('black-name').textContent = currentGame.black;
document.getElementById('black-elo').textContent = currentGame.blackElo ? `(ELO: ${currentGame.blackElo})` : '';
document.getElementById('black-clock').textContent = formatClock(currentGame.blackClock);
} else {
document.getElementById('white-name').textContent = currentGame.white;
document.getElementById('white-elo').textContent = currentGame.whiteElo ? `(ELO: ${currentGame.whiteElo})` : '';
document.getElementById('white-clock').textContent = formatClock(currentGame.whiteClock);
document.getElementById('black-name').textContent = currentGame.black;
document.getElementById('black-elo').textContent = currentGame.blackElo ? `(ELO: ${currentGame.blackElo})` : '';
document.getElementById('black-clock').textContent = formatClock(currentGame.blackClock);
}
// Round info
const roundInfo = `Runde ${currentGame.round} ${currentGame.event || 'Turnier'}`;
document.getElementById('round-info').textContent = roundInfo;
// Result info
const resultEl = document.getElementById('result-info');
if (currentGame.isLive) {
resultEl.innerHTML = '<span style="color: #4ade80;">● Laufen</span>';
} else {
resultEl.textContent = `Ergebnis: ${currentGame.result}`;
}
}
/**
* Highlight den Spieler, der gerade am Zug ist
*/
function highlightActivePlayer() {
if (!chess) return;
const whiteEl = document.getElementById('player-white');
const blackEl = document.getElementById('player-black');
whiteEl.classList.remove('active');
blackEl.classList.remove('active');
if (chess.turn() === 'w') {
whiteEl.classList.add('active');
} else {
blackEl.classList.add('active');
}
}
/**
* Aktualisiert die Zugliste
*/
function updateMovesList() {
if (!currentGame) return;
const movesList = document.getElementById('moves-list');
movesList.innerHTML = '';
const nonResultMoves = currentGame.moves.filter(m => !m.isResult);
for (let i = 0; i < nonResultMoves.length; i += 2) {
const moveNumber = Math.floor(i / 2) + 1;
// Move number
const numSpan = document.createElement('span');
numSpan.className = 'move-number';
numSpan.textContent = `${moveNumber}.`;
movesList.appendChild(numSpan);
// White move
const whiteMove = document.createElement('span');
whiteMove.className = 'move';
whiteMove.textContent = nonResultMoves[i].san;
whiteMove.dataset.index = i;
movesList.appendChild(whiteMove);
// Black move
if (i + 1 < nonResultMoves.length) {
const blackMove = document.createElement('span');
blackMove.className = 'move';
blackMove.textContent = nonResultMoves[i + 1].san;
blackMove.dataset.index = i + 1;
movesList.appendChild(blackMove);
}
}
// Mark last move
if (nonResultMoves.length > 0) {
const lastMove = movesList.querySelector(`[data-index="${nonResultMoves.length - 1}"]`);
if (lastMove) lastMove.classList.add('current');
}
// Scroll to bottom
movesList.scrollTop = movesList.scrollHeight;
}
/**
* Klick auf einen Zug in der Liste
*/
function handleMoveClick(e) {
if (!e.target.classList.contains('move') || !currentGame) return;
const index = parseInt(e.target.dataset.index);
const nonResultMoves = currentGame.moves.filter(m => !m.isResult);
chess = new Chess();
for (let i = 0; i <= index; i++) {
try {
chess.move(nonResultMoves[i].san);
} catch (err) {
break;
}
}
board.position(chess.fen(), true);
// Update current highlight
document.querySelectorAll('#moves-list .move').forEach(el => el.classList.remove('current'));
e.target.classList.add('current');
}
/**
* Aktualisiert die Liste aller Partien
*/
function updateAllGamesList() {
const list = document.getElementById('all-games-list');
list.innerHTML = '';
// Sort by round
const sorted = [...allLaraGames].sort((a, b) => {
return (parseInt(a.round) || 0) - (parseInt(b.round) || 0);
});
for (const game of sorted) {
const entry = document.createElement('div');
entry.className = 'game-entry';
if (currentGame && game === currentGame) entry.classList.add('active');
const laraIsWhite = game.white.toLowerCase().includes('kiesewetter');
const opponent = laraIsWhite ? game.black : game.white;
const color = laraIsWhite ? '⬜ Weiß' : '⬛ Schwarz';
entry.innerHTML = `
<div class="game-round">Runde ${game.round}</div>
<div class="game-players">Lara ${color} vs ${opponent}</div>
<div class="game-result">${game.isLive ? '● Laufen' : game.result}</div>
`;
entry.addEventListener('click', () => {
currentGame = game;
updateBoard();
updatePlayerInfo();
updateMovesList();
updateAllGamesList();
});
list.appendChild(entry);
}
}
/**
* Format clock string
*/
function formatClock(clockStr) {
if (!clockStr) return '--:--:--';
// Format is HH:MM:SS
return clockStr;
}
/**
* Update timestamp
*/
function updateTimestamp() {
const time = serverLastFetch ? new Date(serverLastFetch) : new Date();
document.getElementById('last-update').textContent =
`Letztes Update: ${time.toLocaleTimeString('de-DE')}`;
}
/**
* Start auto-refresh
*/
function startAutoRefresh() {
countdown = REFRESH_INTERVAL / 1000;
if (refreshTimer) clearInterval(refreshTimer);
refreshTimer = setInterval(() => {
countdown--;
const mins = Math.floor(countdown / 60);
const secs = countdown % 60;
document.getElementById('refresh-timer').textContent =
`Nächstes Update in: ${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
if (countdown <= 0) {
countdown = REFRESH_INTERVAL / 1000;
loadPGN();
}
}, 1000);
}
/**
* UI Helpers
*/
function showLoading(show) {
document.getElementById('loading-overlay').style.display = show ? 'flex' : 'none';
}
function showError(msg) {
document.getElementById('error-message').textContent = msg;
document.getElementById('error-overlay').style.display = 'flex';
}
function hideError() {
document.getElementById('error-overlay').style.display = 'none';
}
/**
* Manual refresh button
*/
document.getElementById('refresh-btn').addEventListener('click', () => {
countdown = REFRESH_INTERVAL / 1000;
loadPGN();
});
/**
* Init
*/
loadPGN();
startAutoRefresh();

13
eslint.config.mjs Normal file
View File

@@ -0,0 +1,13 @@
import js from "@eslint/js";
import globals from "globals";
import json from "@eslint/json";
import markdown from "@eslint/markdown";
import css from "@eslint/css";
import { defineConfig } from "eslint/config";
export default defineConfig([
{ files: ["**/*.{js,mjs,cjs}"], plugins: { js }, extends: ["js/recommended"], languageOptions: { globals: globals.browser } },
{ files: ["**/*.json"], ignores: ["package-lock.json"], plugins: { json }, language: "json/json", extends: ["json/recommended"] },
{ files: ["**/*.md"], plugins: { markdown }, language: "markdown/commonmark", extends: ["markdown/recommended"] },
{ files: ["**/*.css"], plugins: { css }, language: "css/css", extends: ["css/recommended"], rules: { "css/use-baseline": "off" } },
]);

View File

@@ -20,6 +20,7 @@
<div id="main-content">
<!-- Linke Spalte: Schachbrett -->
<h2 id="round-info-mobile" class="round-info-mobile"></h2>
<div id="board-section">
<div id="player-black" class="player-info">
<div class="player-avatar"></div>
@@ -29,7 +30,35 @@
</div>
<div class="player-clock" id="black-clock">--:--:--</div>
</div>
<div id="board"></div>
<div id="board-row">
<div id="board"></div>
<div id="eval-bar-container">
<div id="eval-bar">
<div id="eval-bar-lines">
<span class="eval-line"></span>
<span class="eval-line"></span>
<span class="eval-line"></span>
<span class="eval-line"></span>
<span class="eval-line"></span>
<span class="eval-line"></span>
<span class="eval-line"></span>
<span class="eval-line eval-line-center"></span>
<span class="eval-line"></span>
<span class="eval-line"></span>
<span class="eval-line"></span>
<span class="eval-line"></span>
<span class="eval-line"></span>
<span class="eval-line"></span>
<span class="eval-line"></span>
</div>
<div id="eval-bar-fill"></div>
<div id="eval-bar-marker"></div>
</div>
<div id="eval-info">
<span id="eval-score">0.00</span>
</div>
</div>
</div>
<div id="player-white" class="player-info">
<div class="player-avatar"></div>
<div class="player-details">
@@ -38,6 +67,13 @@
</div>
<div class="player-clock" id="white-clock">--:--:--</div>
</div>
<div id="pgn-panel" class="pgn-panel--desktop">
<div class="pgn-header">
<span>PGN</span>
<button class="copy-pgn-btn" title="In Zwischenablage kopieren">📋</button>
</div>
<pre class="pgn-text"></pre>
</div>
</div>
<!-- Rechte Spalte: Info & Züge -->
@@ -54,6 +90,19 @@
<h3>Alle Partien von Lara</h3>
<div id="all-games-list"></div>
</div>
<div id="pgn-panel-mobile" class="pgn-panel--mobile">
<div class="pgn-header">
<span>PGN</span>
<button class="copy-pgn-btn" title="In Zwischenablage kopieren">📋</button>
</div>
<pre class="pgn-text"></pre>
</div>
<div id="standings-panel">
<h3>Turniertabelle ODJM D 2026</h3>
<div id="standings-content">
<div class="standings-loading">Lade Tabellenstand...</div>
</div>
</div>
</div>
</div>
@@ -72,7 +121,12 @@
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script src="https://unpkg.com/@chrisoakman/chessboardjs@1.0.0/dist/chessboard-1.0.0.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/chess.js/0.10.3/chess.min.js"></script>
<script src="pgn-parser.js"></script>
<script src="app.js"></script>
<script src="js/pgn-parser.js"></script>
<script src="js/state.js"></script>
<script src="js/evaluation.js"></script>
<script src="js/ui.js"></script>
<script src="js/board.js"></script>
<script src="js/data.js"></script>
<script src="js/app.js"></script>
</body>
</html>

69
js/app.js Normal file
View File

@@ -0,0 +1,69 @@
/**
* Lara Kiesewetter Live Schachturnier
* Event listeners and initialization
*/
/* global $, Chess, Chessboard */
document.getElementById('refresh-btn').addEventListener('click', () => {
pollId++;
startAutoRefresh();
});
document.querySelectorAll('.copy-pgn-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const panel = btn.closest('[class*="pgn-panel"]');
const text = panel.querySelector('.pgn-text').textContent;
if (!text) return;
try {
await navigator.clipboard.writeText(text);
btn.textContent = '✅';
setTimeout(() => { btn.textContent = '📋'; }, 1500);
} catch {
const ta = document.createElement('textarea');
ta.value = text;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
}
});
});
document.addEventListener('keydown', (e) => {
if (!currentGame) return;
if (e.key === 'ArrowLeft') {
e.preventDefault();
goToMove(currentMoveIndex - 1);
} else if (e.key === 'ArrowRight') {
e.preventDefault();
goToMove(currentMoveIndex + 1);
}
});
window.addEventListener('resize', () => {
if (board) board.resize();
});
if (window.ResizeObserver) {
const ro = new ResizeObserver(() => {
if (board) board.resize();
});
ro.observe(document.getElementById('board'));
}
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
clearInterval(pollInterval);
clearInterval(updateTimer);
} else {
startAutoRefresh();
}
});
loadPGN();
startAutoRefresh();
chess = new Chess();
updateEvaluation();

97
js/board.js Normal file
View File

@@ -0,0 +1,97 @@
/**
* Lara Kiesewetter Live Schachturnier
* Board rendering and move navigation
*/
/* global $, Chess, Chessboard */
function handleMoveClick(e) {
if (!e.target.classList.contains('move') || !currentGame) return;
goToMove(parseInt(e.target.dataset.index));
}
function goToMove(index) {
if (!currentGame) return;
const nonResultMoves = currentGame.moves.filter(m => !m.isResult);
if (index < -1) index = -1;
if (index >= nonResultMoves.length) index = nonResultMoves.length - 1;
currentMoveIndex = index;
chess = new Chess();
for (let i = 0; i <= index; i++) {
const san = nonResultMoves[i].san;
let result = chess.move(san);
if (!result) {
const cleanSan = san.replace(/^([NBRQK])([a-h])?([1-8])?([a-h][1-8].*)$/, '$1$4');
result = chess.move(cleanSan);
}
if (!result) break;
}
board.position(chess.fen(), true);
highlightActivePlayer();
highlightLastMove();
updateClocks(index);
syncEvalBarHeight();
updateEvaluation();
document.querySelectorAll('#moves-list .move').forEach(el => el.classList.remove('current'));
if (index >= 0) {
const moveEl = document.querySelector(`#moves-list [data-index="${index}"]`);
if (moveEl) moveEl.classList.add('current');
}
}
function updateBoard() {
if (!currentGame) return;
const laraIsBlack = currentGame.black.toLowerCase().includes('kiesewetter');
const orientation = laraIsBlack ? 'black' : 'white';
const nonResultMoves = currentGame.moves.filter(m => !m.isResult);
if (currentMoveIndex >= nonResultMoves.length || currentMoveIndex <= -1) {
currentMoveIndex = nonResultMoves.length - 1;
} else if (previousMoveCount >= 0 && currentMoveIndex === previousMoveCount - 1 && nonResultMoves.length > previousMoveCount) {
currentMoveIndex = nonResultMoves.length - 1;
}
previousMoveCount = nonResultMoves.length;
chess = new Chess();
if (currentMoveIndex >= 0) {
for (let i = 0; i <= currentMoveIndex && i < nonResultMoves.length; i++) {
const san = nonResultMoves[i].san;
let result = chess.move(san);
if (!result) {
const cleanSan = san.replace(/^([NBRQK])([a-h])?([1-8])?([a-h][1-8].*)$/, '$1$4');
result = chess.move(cleanSan);
}
if (!result) break;
}
}
if (board) {
board.position(chess.fen(), true);
board.orientation(orientation);
board.resize();
highlightLastMove();
} else {
board = Chessboard('board', {
position: chess.fen(),
orientation: orientation,
pieceTheme: 'https://chessboardjs.com/img/chesspieces/wikipedia/{piece}.png',
draggable: false,
spawnMoveError: false
});
document.getElementById('moves-list').addEventListener('click', handleMoveClick);
}
highlightActivePlayer();
syncEvalBarHeight();
updateEvaluation();
}

176
js/data.js Normal file
View File

@@ -0,0 +1,176 @@
/**
* Lara Kiesewetter Live Schachturnier
* Data fetching, PGN loading, standings, auto-refresh
*/
/* global parsePGN, filterLaraGames, getLiveGame, getTodaysGames, getLatestGame */
async function fetchRoundPGN(round) {
const res = await fetch(`https://www.deutsche-schachjugend.de/2026/odjm-d/partien/${round}.pgn?t=${Date.now()}`);
if (!res.ok) return null;
const buf = await res.arrayBuffer();
let text = new TextDecoder('utf-8').decode(buf);
if (text.includes('\uFFFD')) {
text = new TextDecoder('iso-8859-1').decode(buf);
}
return text;
}
async function loadPGN(showOverlay = true) {
const currentPollId = pollId;
if (showOverlay) showLoading(true);
hideError();
try {
if (currentRound === 0) {
if (showOverlay) showLoading(false);
return;
}
const maxRound = currentRound + 1;
const isFirstLoad = Object.keys(roundPgns).length === 0;
const startRound = isFirstLoad ? 1 : currentRound;
for (let r = startRound; r <= maxRound; r++) {
const text = await fetchRoundPGN(r);
if (currentPollId !== pollId) return;
if (text !== null) roundPgns[r] = text;
}
const combinedPgn = Object.values(roundPgns).join('\n\n');
const allGames = parsePGN(combinedPgn);
allLaraGames = filterLaraGames(allGames);
if (allLaraGames.length === 0) {
return;
}
if (!userSelectedGame) {
const liveGame = getLiveGame(allLaraGames);
const todaysGames = getTodaysGames(allLaraGames);
currentGame = liveGame || (todaysGames.length > 0 ? getLatestGame(todaysGames) : getLatestGame(allLaraGames));
} else if (currentGame) {
const updatedGame = allLaraGames.find(g =>
g.white === currentGame.white &&
g.black === currentGame.black &&
g.round === currentGame.round
);
if (updatedGame) currentGame = updatedGame;
}
updateBoard();
updatePlayerInfo();
updateMovesList();
updateAllGamesList();
updatePGNDisplay();
updateTimestamp();
showLoading(false);
} catch (error) {
if (currentPollId !== pollId) return;
console.error('Fehler beim Laden:', error);
showLoading(false);
}
}
async function updateStandings() {
try {
const res = await fetch(`https://www.deutsche-schachjugend.de/2026/odjm-d/tabelle/?t=${Date.now()}`);
if (!res.ok) throw new Error('Fehler beim Laden');
const html = await res.text();
const roundMatch = html.match(/Tabellenstand\s+nach\s+der\s+(\d+)\.\s*Runde/);
if (roundMatch) {
currentRound = parseInt(roundMatch[1]);
}
const rows = html.matchAll(/<tr[^>]*>(.*?)<\/tr>/gs);
for (const row of rows) {
if (!row[1].includes('Lara Kiesewetter')) continue;
const cells = row[1].matchAll(/<td[^>]*>(.*?)<\/td>/gs);
const clean = [];
for (const cell of cells) {
clean.push(cell[1].replace(/<[^>]+>/g, '').trim());
}
if (clean.length >= 9) {
const data = {
rank: clean[0],
player: 'Lara Kiesewetter',
wins: clean[5],
draws: clean[6],
losses: clean[7],
points: clean[8],
round_info: roundMatch ? `nach der ${roundMatch[1]}. Runde` : '',
round: currentRound,
};
const container = document.getElementById('standings-content');
container.innerHTML = `
<div class="standings-rank">${data.rank}.</div>
<div class="standings-rank-label"><a href="https://www.deutsche-schachjugend.de/2026/odjm-d/tabelle/" target="_blank" rel="noopener">Tabellenplatz</a></div>
<div class="standings-header">${data.round_info || 'nach Runde 1'}</div>
<div class="standings-row">
<span class="standings-label">Punkte</span>
<span class="standings-value">${data.points}</span>
</div>
<div class="standings-row">
<span class="standings-label">Siege</span>
<span class="standings-value">${data.wins}</span>
</div>
<div class="standings-row">
<span class="standings-label">Unentschieden</span>
<span class="standings-value">${data.draws}</span>
</div>
<div class="standings-row">
<span class="standings-label">Niederlagen</span>
<span class="standings-value">${data.losses}</span>
</div>
`;
return;
}
}
document.getElementById('standings-content').innerHTML = '<div class="standings-loading">Daten nicht verfügbar</div>';
} catch {
document.getElementById('standings-content').innerHTML = '<div class="standings-loading">Daten nicht verfügbar</div>';
}
}
function updateTimestamp() {
const time = new Date();
document.getElementById('last-update').textContent =
`Letztes Update: ${time.toLocaleTimeString('de-DE')}`;
}
async function startAutoRefresh() {
clearInterval(pollInterval);
clearInterval(updateTimer);
clearInterval(standingsInterval);
const myId = ++pollId;
let lastUpdate = Date.now();
document.getElementById('refresh-timer').textContent = '0s';
document.getElementById('refresh-timer').style.color = '#4ade80';
await updateStandings();
loadPGN(true);
pollInterval = setInterval(() => {
if (pollId !== myId) { clearInterval(pollInterval); clearInterval(updateTimer); clearInterval(standingsInterval); return; }
loadPGN(false);
lastUpdate = Date.now();
}, 15000);
updateTimer = setInterval(() => {
if (pollId !== myId) { clearInterval(pollInterval); clearInterval(updateTimer); clearInterval(standingsInterval); return; }
const elapsed = Date.now() - lastUpdate;
const remaining = Math.max(0, 15000 - elapsed);
const s = Math.floor(remaining / 1000);
document.getElementById('refresh-timer').textContent = `${s}s`;
}, 1000);
standingsInterval = setInterval(() => {
if (pollId !== myId) { clearInterval(pollInterval); clearInterval(updateTimer); clearInterval(standingsInterval); return; }
updateStandings();
}, 1800000);
}

99
js/evaluation.js Normal file
View File

@@ -0,0 +1,99 @@
/**
* Lara Kiesewetter Live Schachturnier
* Stockfish evaluation
*/
/* global Chess */
function syncEvalBarHeight() {
const boardEl = document.getElementById('board');
const evalContainer = document.getElementById('eval-bar-container');
if (boardEl && evalContainer) {
const h = boardEl.offsetHeight;
if (h > 100) evalContainer.style.minHeight = h + 'px';
}
}
async function updateEvaluation() {
if (!chess) return;
const fen = chess.fen();
if (lastEvalFen === fen && evalAbortController) return;
lastEvalFen = fen;
if (evalAbortController) {
evalAbortController.abort();
}
evalAbortController = new AbortController();
try {
const res = await fetch('/evaluate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fen }),
signal: evalAbortController.signal,
});
if (!res.ok) throw new Error('Eval fehlgeschlagen');
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
if (!line.trim()) continue;
try {
displayEval(JSON.parse(line));
} catch {}
}
}
if (buffer.trim()) {
try { displayEval(JSON.parse(buffer)); } catch {}
}
} catch (e) {
if (e.name === 'AbortError') return;
const el = document.getElementById('eval-score');
if (el) el.textContent = '?';
}
}
function displayEval(data) {
const evalScore = document.getElementById('eval-score');
const barFill = document.getElementById('eval-bar-fill');
const barMarker = document.getElementById('eval-bar-marker');
let scoreText = '0.00';
let fillPercent = 50;
if (data.scoreMate !== null && data.scoreMate !== undefined && data.scoreMate !== 0) {
const mate = parseInt(data.scoreMate);
const whiteMate = chess && chess.turn() === 'w' ? mate : -mate;
if (whiteMate > 0) {
scoreText = '#' + whiteMate;
fillPercent = 100;
} else {
scoreText = '#' + Math.abs(whiteMate);
fillPercent = 0;
}
} else if (data.scoreCp !== null && data.scoreCp !== undefined) {
const cp = parseInt(data.scoreCp);
const whiteCp = chess && chess.turn() === 'w' ? cp : -cp;
const pawns = (whiteCp / 100).toFixed(2);
scoreText = (whiteCp > 0 ? '+' : '') + pawns;
fillPercent = 50 + whiteCp / 14;
fillPercent = Math.max(0, Math.min(100, fillPercent));
}
evalScore.textContent = scoreText;
if (barFill) barFill.style.height = fillPercent + '%';
if (barMarker) barMarker.style.top = (100 - fillPercent) + '%';
}

View File

@@ -66,21 +66,43 @@ function parseGameBlock(block) {
function parseMoves(movesText) {
const moves = [];
// Remove comments in curly braces
movesText = movesText.replace(/\{[^}]*\}/g, '');
let clockWhite = null;
let clockBlack = null;
let color = 'w';
// Remove move numbers
movesText = movesText.replace(/\d+\.\s*/g, '');
const tokenRegex = /\d+\.\s*|\{[^}]*\}|\S+/g;
const tokens = movesText.match(tokenRegex) || [];
// Split into individual moves
const tokens = movesText.split(/\s+/).filter(t => t.trim());
for (let i = 0; i < tokens.length; i++) {
let token = tokens[i].trim();
for (const token of tokens) {
if (['1-0', '0-1', '1/2-1/2', '*'].includes(token)) {
moves.push({ san: token, isResult: true });
} else if (token.length > 0) {
moves.push({ san: token, isResult: false });
moves.push({ san: token, isResult: true, whiteClock: clockWhite, blackClock: clockBlack });
continue;
}
if (/^\d+\.$/.test(token)) continue;
if (token.startsWith('{')) {
const clkMatch = token.match(/\[%clk\s+([\d:]+)\]/);
if (clkMatch && moves.length > 0) {
const lastMove = moves[moves.length - 1];
if (!lastMove.isResult && lastMove.color) {
if (lastMove.color === 'w') {
clockWhite = clkMatch[1];
} else {
clockBlack = clkMatch[1];
}
lastMove.whiteClock = clockWhite;
lastMove.blackClock = clockBlack;
}
}
continue;
}
const move = { san: token, isResult: false, whiteClock: clockWhite, blackClock: clockBlack, color };
moves.push(move);
color = color === 'w' ? 'b' : 'w';
}
return moves;
@@ -98,9 +120,17 @@ function getLiveGame(laraGames) {
return laraGames.find(game => game.isLive) || null;
}
function getTodaysGames(laraGames) {
const today = new Date();
const yyyy = today.getFullYear();
const mm = String(today.getMonth() + 1).padStart(2, '0');
const dd = String(today.getDate()).padStart(2, '0');
const todayStr = `${yyyy}.${mm}.${dd}`;
return laraGames.filter(game => game.date === todayStr);
}
function getLatestGame(laraGames) {
if (laraGames.length === 0) return null;
// Sort by round number, return the highest round
const sorted = [...laraGames].sort((a, b) => {
const roundA = parseInt(a.round) || 0;
const roundB = parseInt(b.round) || 0;
@@ -108,3 +138,9 @@ function getLatestGame(laraGames) {
});
return sorted[0];
}
window.parsePGN = parsePGN;
window.filterLaraGames = filterLaraGames;
window.getLiveGame = getLiveGame;
window.getTodaysGames = getTodaysGames;
window.getLatestGame = getLatestGame;

22
js/state.js Normal file
View File

@@ -0,0 +1,22 @@
/**
* Lara Kiesewetter Live Schachturnier
* Global state
*/
/* global Chess */
let board = null;
let chess = null;
let currentGame = null;
let allLaraGames = [];
let currentMoveIndex = -1;
let userSelectedGame = false;
let evalAbortController = null;
let lastEvalFen = null;
let currentRound = 0;
let roundPgns = {};
let pollId = 0;
let pollInterval = null;
let updateTimer = null;
let standingsInterval = null;
let previousMoveCount = -1;

255
js/ui.js Normal file
View File

@@ -0,0 +1,255 @@
/**
* Lara Kiesewetter Live Schachturnier
* UI rendering: player info, clocks, highlights, moves list, games list, PGN display
*/
/* global $, Chess */
function showLoading(show) {
document.getElementById('loading-overlay').style.display = show ? 'flex' : 'none';
}
function hideError() {
document.getElementById('error-overlay').style.display = 'none';
}
function updatePlayerInfo() {
if (!currentGame) return;
const laraIsWhite = currentGame.white.toLowerCase().includes('kiesewetter');
const laraPlayer = laraIsWhite ? currentGame.white : currentGame.black;
const laraElo = laraIsWhite ? currentGame.whiteElo : currentGame.blackElo;
const laraEmoji = laraIsWhite ? '⬜' : '⬛';
const oppPlayer = laraIsWhite ? currentGame.black : currentGame.white;
const oppElo = laraIsWhite ? currentGame.blackElo : currentGame.whiteElo;
const oppEmoji = laraIsWhite ? '⬛' : '⬜';
document.getElementById('black-name').textContent = oppPlayer;
document.getElementById('black-elo').textContent = oppElo ? `(ELO: ${oppElo})` : '';
document.querySelector('#player-black .player-avatar').textContent = oppEmoji;
document.getElementById('white-name').textContent = laraPlayer;
document.getElementById('white-elo').textContent = laraElo ? `(ELO: ${laraElo})` : '';
document.querySelector('#player-white .player-avatar').textContent = laraEmoji;
updateClocks(currentMoveIndex);
const roundInfo = `Runde ${currentGame.round} ${currentGame.event || 'Turnier'}`;
document.getElementById('round-info').textContent = roundInfo;
const mobileRound = document.getElementById('round-info-mobile');
if (mobileRound) mobileRound.textContent = roundInfo;
const resultEl = document.getElementById('result-info');
if (currentGame.isLive) {
resultEl.innerHTML = '<span style="color: #4ade80;">● Laufen</span>';
} else {
resultEl.textContent = `Ergebnis: ${currentGame.result}`;
}
}
function updateClocks(moveIndex) {
if (!currentGame) return;
const laraIsWhite = currentGame.white.toLowerCase().includes('kiesewetter');
let whiteClock, blackClock;
if (moveIndex < 0) {
whiteClock = currentGame.whiteClock;
blackClock = currentGame.blackClock;
} else {
const nonResultMoves = currentGame.moves.filter(m => !m.isResult);
if (moveIndex < nonResultMoves.length) {
whiteClock = nonResultMoves[moveIndex].whiteClock;
blackClock = nonResultMoves[moveIndex].blackClock;
}
}
const laraClock = laraIsWhite ? whiteClock : blackClock;
const oppClock = laraIsWhite ? blackClock : whiteClock;
document.getElementById('black-clock').textContent = oppClock || '--:--:--';
document.getElementById('white-clock').textContent = laraClock || '--:--:--';
}
function highlightActivePlayer() {
if (!chess || !currentGame) return;
const laraPanel = document.getElementById('player-white');
const oppPanel = document.getElementById('player-black');
laraPanel.classList.remove('active');
oppPanel.classList.remove('active');
const laraIsWhite = currentGame.white.toLowerCase().includes('kiesewetter');
const isLaraTurn = (chess.turn() === 'w' && laraIsWhite) || (chess.turn() === 'b' && !laraIsWhite);
if (isLaraTurn) {
laraPanel.classList.add('active');
} else {
oppPanel.classList.add('active');
}
}
function highlightLastMove() {
if (!board || !chess || !currentGame) return;
$('#board [data-square]').removeClass('last-move-highlight');
const nonResultMoves = currentGame.moves.filter(m => !m.isResult);
if (currentMoveIndex >= 0 && currentMoveIndex < nonResultMoves.length) {
const moves = chess.history({ verbose: true });
if (moves.length > 0) {
const lastMoveData = moves[moves.length - 1];
$(`#board [data-square="${lastMoveData.from}"]`).addClass('last-move-highlight');
$(`#board [data-square="${lastMoveData.to}"]`).addClass('last-move-highlight');
}
}
}
function generatePGN(game) {
if (!game) return '';
let pgn = '';
if (game.event) pgn += `[Event "${game.event}"]\n`;
if (game.site) pgn += `[Site "${game.site}"]\n`;
if (game.date) pgn += `[Date "${game.date}"]\n`;
if (game.round) pgn += `[Round "${game.round}"]\n`;
if (game.white) pgn += `[White "${game.white}"]\n`;
if (game.black) pgn += `[Black "${game.black}"]\n`;
if (game.result) pgn += `[Result "${game.result}"]\n`;
if (game.whiteElo) pgn += `[WhiteElo "${game.whiteElo}"]\n`;
if (game.blackElo) pgn += `[BlackElo "${game.blackElo}"]\n`;
if (game.termination) pgn += `[Termination "${game.termination}"]\n`;
pgn += '\n';
const nonResultMoves = game.moves.filter(m => !m.isResult);
for (let i = 0; i < nonResultMoves.length; i += 2) {
const moveNumber = Math.floor(i / 2) + 1;
pgn += `${moveNumber}. ${nonResultMoves[i].san}`;
if (i + 1 < nonResultMoves.length) {
pgn += ` ${nonResultMoves[i + 1].san} `;
}
}
const resultMove = game.moves.find(m => m.isResult);
if (resultMove) {
pgn += ` ${resultMove.san}`;
}
return pgn.trim();
}
function updatePGNDisplay() {
if (!currentGame) return;
const text = generatePGN(currentGame);
document.querySelectorAll('.pgn-text').forEach(el => el.textContent = text);
}
function updateMovesList() {
if (!currentGame) return;
const movesList = document.getElementById('moves-list');
movesList.innerHTML = '';
const nonResultMoves = currentGame.moves.filter(m => !m.isResult);
const laraIsWhite = currentGame.white.toLowerCase().includes('kiesewetter');
for (let i = 0; i < nonResultMoves.length; i += 2) {
const moveNumber = Math.floor(i / 2) + 1;
const numSpan = document.createElement('span');
numSpan.className = 'move-number';
numSpan.textContent = `${moveNumber}.`;
movesList.appendChild(numSpan);
if (laraIsWhite) {
const laraMove = document.createElement('span');
laraMove.className = 'move lara-move';
laraMove.textContent = nonResultMoves[i].san;
laraMove.dataset.index = i;
movesList.appendChild(laraMove);
if (i + 1 < nonResultMoves.length) {
const oppMove = document.createElement('span');
oppMove.className = 'move opp-move';
oppMove.textContent = nonResultMoves[i + 1].san;
oppMove.dataset.index = i + 1;
movesList.appendChild(oppMove);
}
} else {
const oppMove = document.createElement('span');
oppMove.className = 'move opp-move';
oppMove.textContent = nonResultMoves[i].san;
oppMove.dataset.index = i;
movesList.appendChild(oppMove);
if (i + 1 < nonResultMoves.length) {
const laraMove = document.createElement('span');
laraMove.className = 'move lara-move';
laraMove.textContent = nonResultMoves[i + 1].san;
laraMove.dataset.index = i + 1;
movesList.appendChild(laraMove);
}
}
}
if (currentMoveIndex >= 0) {
const curMove = movesList.querySelector(`[data-index="${currentMoveIndex}"]`);
if (curMove) curMove.classList.add('current');
}
}
function updateAllGamesList() {
const list = document.getElementById('all-games-list');
list.innerHTML = '';
const sorted = [...allLaraGames].sort((a, b) => {
return (parseInt(a.round) || 0) - (parseInt(b.round) || 0);
});
for (const game of sorted) {
const entry = document.createElement('div');
entry.className = 'game-entry';
if (currentGame && game === currentGame) entry.classList.add('active');
const laraIsWhite = game.white.toLowerCase().includes('kiesewetter');
const opponent = laraIsWhite ? game.black : game.white;
const color = laraIsWhite ? '⬜ Weiß' : '⬛ Schwarz';
let resultIcon = '';
if (game.isLive) {
resultIcon = '⏳';
} else if (game.result === '1/2-1/2') {
resultIcon = '🤝';
} else if ((laraIsWhite && game.result === '1-0') || (!laraIsWhite && game.result === '0-1')) {
resultIcon = '✅';
} else {
resultIcon = '❌';
}
entry.innerHTML = `
<div class="game-round">${resultIcon} Runde ${game.round}</div>
<div class="game-players">Lara ${color} vs ${opponent}</div>
<div class="game-result">${game.isLive ? '● Laufen' : game.result}</div>
`;
entry.addEventListener('click', () => {
userSelectedGame = true;
currentGame = game;
previousMoveCount = -1;
currentMoveIndex = Number.MAX_SAFE_INTEGER;
updateBoard();
updatePlayerInfo();
updateMovesList();
updateAllGamesList();
updatePGNDisplay();
});
list.appendChild(entry);
}
}

14
package.json Normal file
View File

@@ -0,0 +1,14 @@
{
"scripts": {},
"devDependencies": {
"@eslint/css": "^1.2.0",
"@eslint/js": "^10.0.1",
"@eslint/json": "^1.2.0",
"@eslint/markdown": "^8.0.2",
"eslint": "^10.4.0",
"globals": "^17.6.0"
},
"dependencies": {
"chess.js": "^0.10.3"
}
}

BIN
screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 323 KiB

430
server.py
View File

@@ -1,180 +1,336 @@
#!/usr/bin/env python3
"""
Lokal Proxy-Server für Lara's Schachturnier
Lädt die PGN-Datei und stellt sie mit CORS-Headern bereit.
Lokaler Server für Lara Schachturnier
Serviert statische Dateien und bietet Stockfish-Analyse.
"""
import http.server
import socketserver
import urllib.request
import sys
import os
import threading
import time
import subprocess
import json
from datetime import datetime
import re
import sys
import threading
import socket
import queue
PGN_URL = "https://www.deutsche-schachjugend.de/2026/odjm-d/partien/gesamt-utf8.pgn"
PORT = 8111
CACHE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "cache")
CACHE_FILE = os.path.join(CACHE_DIR, "gesamt-utf8.pgn")
CACHE_TTL = 30 # Sekunden
PORT = int(os.environ.get("PORT", 8111))
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
os.makedirs(CACHE_DIR, exist_ok=True)
STOCKFISH_PATH = os.environ.get("STOCKFISH_PATH")
if not STOCKFISH_PATH:
win_path = os.path.join(BASE_DIR, "stockfish.exe")
if os.path.exists(win_path):
STOCKFISH_PATH = win_path
else:
STOCKFISH_PATH = "stockfish"
last_fetch_time = None
STOCKFISH_DEPTH = int(os.environ.get("STOCKFISH_DEPTH", 25))
_stockfish_lock = threading.Lock()
def fetch_pgn():
"""Lädt die PGN-Datei von der URL als Bytes."""
global last_fetch_time
try:
req = urllib.request.Request(PGN_URL, headers={"User-Agent": "Mozilla/5.0"})
with urllib.request.urlopen(req, timeout=30) as response:
data = response.read()
last_fetch_time = time.time()
return data
except Exception as e:
print(f"[{datetime.now().strftime('%H:%M:%S')}] Fehler beim Laden: {e}")
return None
class StockfishEngine:
def __init__(self, path):
self.path = path
self.proc = None
self._output_queue = queue.Queue()
self._reader_thread = None
self._reader_alive = threading.Event()
self._cmd_lock = threading.Lock()
self._searching = False
def start(self):
if self.proc:
return
flags = 0
if sys.platform == "win32":
flags = subprocess.CREATE_NO_WINDOW
self.proc = subprocess.Popen(
[self.path],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
creationflags=flags,
)
self._reader_alive.set()
self._reader_thread = threading.Thread(target=self._read_output, daemon=True)
self._reader_thread.start()
self._send("uci")
self._read_until("uciok")
self._send("isready")
self._read_until("readyok")
print(f"[STOCKFISH] Engine gestartet: {self.path}")
def _read_output(self):
while self._reader_alive.is_set():
try:
line = self.proc.stdout.readline()
except Exception:
break
if not line:
break
self._output_queue.put(line)
def _send(self, cmd):
with self._cmd_lock:
self.proc.stdin.write(cmd + "\n")
self.proc.stdin.flush()
def _read_until(self, marker):
while True:
try:
line = self._output_queue.get(timeout=5.0)
except queue.Empty:
return
if line.strip() == marker:
return
def _drain_queue(self):
"""Leert die Queue von allen pending Zeilen."""
drained = 0
while True:
try:
self._output_queue.get_nowait()
drained += 1
except queue.Empty:
break
if drained > 0:
print(f"[STOCKFISH] {drained} alte Zeilen verworfen")
def _stop_and_wait(self):
"""Stoppt laufende Suche und wartet auf bestmove."""
if not self._searching:
return
self._send("stop")
while True:
try:
line = self._output_queue.get(timeout=3.0).strip()
except queue.Empty:
# Stockfish antwortet nicht auf stop (z.B. weil Suche schon beendet)
self._searching = False
return
if line.startswith("bestmove"):
self._searching = False
return
def evaluate(self, fen):
# Alte Suche abbrechen und Queue leeren
self._stop_and_wait()
self._drain_queue()
self._send(f"position fen {fen}")
self._send(f"go depth {STOCKFISH_DEPTH} movetime 15000")
self._searching = True
score_cp = None
score_mate = None
bestmove = None
pv = None
last_depth = 0
while True:
try:
line = self._output_queue.get(timeout=1.0).strip()
except queue.Empty:
continue
if not line:
continue
if "score cp" in line:
m = re.search(r"score cp (-?\d+)", line)
if m:
score_cp = int(m.group(1))
if "score mate" in line:
m = re.search(r"score mate (-?\d+)", line)
if m:
score_mate = int(m.group(1))
if " pv " in line:
pv = line.split(" pv ", 1)[1]
depth_m = re.search(r"depth (\d+)", line)
if depth_m and score_cp is not None:
new_depth = int(depth_m.group(1))
if new_depth != last_depth:
last_depth = new_depth
yield {
"scoreCp": score_cp,
"scoreMate": score_mate,
"bestMove": None,
"pv": pv,
"depth": new_depth,
}
if line.startswith("bestmove"):
parts = line.split()
bestmove = parts[1] if len(parts) > 1 else None
self._searching = False
break
yield {
"scoreCp": score_cp,
"scoreMate": score_mate,
"bestMove": bestmove,
"pv": pv,
}
def stop(self):
self._reader_alive.clear()
if self.proc:
self.proc.terminate()
self.proc = None
def get_pgn_content():
"""Gibt PGN-Inhalt als Bytes zurück, nutzt Cache wenn möglich."""
now = time.time()
# Prüfe Cache
if os.path.exists(CACHE_FILE):
age = now - os.path.getmtime(CACHE_FILE)
if age < CACHE_TTL:
with open(CACHE_FILE, "rb") as f:
return f.read()
# Cache verfallen oder nicht vorhanden -> neu laden
content = fetch_pgn()
if content:
with open(CACHE_FILE, "wb") as f:
f.write(content)
print(f"[{datetime.now().strftime('%H:%M:%S')}] PGN aktualisiert ({len(content)} Bytes)")
return content
# Fallback: alter Cache
if os.path.exists(CACHE_FILE):
print(f"[{datetime.now().strftime('%H:%M:%S')}] Verwende alten Cache")
with open(CACHE_FILE, "rb") as f:
return f.read()
return None
_engine = StockfishEngine(STOCKFISH_PATH)
class PGNHandler(http.server.BaseHTTPRequestHandler):
class Handler(http.server.BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def do_GET(self):
if self.path == "/pgn":
content = get_pgn_content()
if content:
self.send_response(200)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Cache-Control", "no-cache")
self.end_headers()
self.wfile.write(content)
else:
self.send_response(502)
self.send_header("Content-Type", "application/json")
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(b'{"error": "Konnte PGN nicht laden"}')
if self.path == "/":
self.path = "/index.html"
filepath = os.path.normpath(os.path.join(BASE_DIR, self.path.lstrip("/")))
if not filepath.startswith(BASE_DIR):
self.send_response(403)
self.end_headers()
return
if os.path.isfile(filepath):
content_types = {
".html": "text/html",
".css": "text/css",
".js": "application/javascript",
".json": "application/json",
".png": "image/png",
".jpg": "image/jpeg",
".svg": "image/svg+xml",
}
ext = os.path.splitext(filepath)[1]
content_type = content_types.get(ext, "application/octet-stream")
with open(filepath, "rb") as f:
content = f.read()
elif self.path == "/status":
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Type", f"{content_type}; charset=utf-8")
self.send_header("Content-Length", str(len(content)))
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Cache-Control", "no-cache")
self.end_headers()
status = {
"status": "ok",
"last_fetch": last_fetch_time,
"cache_ttl": CACHE_TTL,
"server_time": time.time()
}
self.wfile.write(json.dumps(status).encode())
self.wfile.write(content)
else:
# Statische Dateien aus dem Verzeichnis
if self.path == "/":
self.path = "/index.html"
self.send_response(404)
self.end_headers()
filepath = os.path.join(os.path.dirname(os.path.abspath(__file__)), self.path.lstrip("/"))
if os.path.isfile(filepath):
content_types = {
".html": "text/html",
".css": "text/css",
".js": "application/javascript",
".json": "application/json",
".png": "image/png",
".jpg": "image/jpeg",
".svg": "image/svg+xml",
}
ext = os.path.splitext(filepath)[1]
content_type = content_types.get(ext, "application/octet-stream")
def do_POST(self):
if self.path == "/evaluate":
length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(length) if length else b"{}"
with open(filepath, "rb") as f:
content = f.read()
try:
data = json.loads(body)
fen = data.get("fen", "")
except (json.JSONDecodeError, TypeError):
self._send_json({"error": "Invalid JSON"}, 400)
return
self.send_response(200)
self.send_header("Content-Type", f"{content_type}; charset=utf-8")
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(content)
else:
self.send_response(404)
self.end_headers()
if not fen:
self._send_json({"error": "Missing fen"}, 400)
return
self.send_response(200)
self.send_header("Content-Type", "application/x-ndjson; charset=utf-8")
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Cache-Control", "no-cache")
self.send_header("Transfer-Encoding", "chunked")
self.end_headers()
self.wfile.flush()
# TCP_NODELAY für sofortiges Senden
try:
self.connection.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
except Exception:
pass
def _write_chunk(chunk_data):
size = len(chunk_data)
self.wfile.write(f"{size:x}\r\n".encode())
self.wfile.write(chunk_data)
self.wfile.write(b"\r\n")
self.wfile.flush()
try:
with _stockfish_lock:
try:
_engine.start()
except FileNotFoundError:
_write_chunk(
json.dumps({"error": "Stockfish nicht gefunden"}).encode("utf-8")
)
return
for result in _engine.evaluate(fen):
try:
data = json.dumps(result).encode("utf-8") + b"\n"
_write_chunk(data)
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError, OSError):
# Client hat Verbindung getrennt (neuer Zug angeklickt / Pfeiltasten)
break
# Abschluss-Chunk
_write_chunk(b"")
except Exception as e:
print(f"[STOCKFISH] Fehler: {e}")
try:
_write_chunk(
json.dumps({"error": str(e)}).encode("utf-8")
)
except Exception:
pass
else:
self._send_json({"error": "Not found"}, 404)
def _send_json(self, obj, status=200):
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Cache-Control", "no-cache")
self.end_headers()
self.wfile.write(json.dumps(obj).encode("utf-8"))
def log_message(self, format, *args):
print(f"[{datetime.now().strftime('%H:%M:%S')}] {args[0]}")
def background_refresh():
"""Aktualisiert den Cache im Hintergrund."""
while True:
time.sleep(CACHE_TTL)
try:
content = fetch_pgn()
if content:
with open(CACHE_FILE, "wb") as f:
f.write(content)
except Exception as e:
print(f"[{datetime.now().strftime('%H:%M:%S')}] Hintergrund-Refresh Fehler: {e}")
print(f"[{self.log_date_time_string()}] {args[0]}")
def main():
print("=" * 50)
print(" [TROPHY] Lara Kiesewetter Live Schachturnier")
print(" Lara Kiesewetter - Live Schachturnier")
print("=" * 50)
print(f" Server läuft auf: http://localhost:{PORT}")
print(f" Drücke Ctrl+C zum Beenden")
print("=" * 50)
# Initialer Ladeversuch
content = fetch_pgn()
if content:
with open(CACHE_FILE, "wb") as f:
f.write(content)
print(f"[OK] PGN geladen ({len(content)} Bytes)")
print(f" Server laeuft auf: http://localhost:{PORT}")
if os.path.exists(STOCKFISH_PATH) or STOCKFISH_PATH == "stockfish":
print(f" Stockfish-Analyse aktiv (depth={STOCKFISH_DEPTH})")
else:
print("[WARN] Initialer Ladeversuch fehlgeschlagen, wird wiederholt...")
print(f" Stockfish nicht gefunden unter: {STOCKFISH_PATH}")
print(f" Druecke Ctrl+C zum Beenden")
print("=" * 50)
# Hintergrund-Refresh Thread starten
refresh_thread = threading.Thread(target=background_refresh, daemon=True)
refresh_thread.start()
# Server starten
with socketserver.TCPServer(("", PORT), PGNHandler) as httpd:
print(f"\n[SERVER] Server gestartet: http://localhost:{PORT}\n")
socketserver.ThreadingTCPServer.allow_reuse_address = True
with socketserver.ThreadingTCPServer(("", PORT), Handler) as httpd:
print(f"\n[SERVER] Bereit für Anfragen\n")
try:
httpd.serve_forever()
except KeyboardInterrupt:
print("\n[BYE] Server gestoppt.")
_engine.stop()
if __name__ == "__main__":

709
style.css
View File

@@ -1,333 +1,552 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
color: #e0e0e0;
min-height: 100vh;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
color: #e0e0e0;
min-height: 100vh;
}
header {
background: rgba(0, 0, 0, 0.4);
padding: 16px 24px;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
border-bottom: 2px solid #e94560;
background: rgba(0, 0, 0, 0.4);
padding: 16px 24px;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
border-bottom: 2px solid #e94560;
}
header h1 {
font-size: 1.5rem;
color: #fff;
text-shadow: 0 0 10px rgba(233, 69, 96, 0.5);
font-size: 1.5rem;
color: #fff;
text-shadow: 0 0 10px rgba(233, 69, 96, 0.5);
}
#status-bar {
display: flex;
align-items: center;
gap: 16px;
font-size: 0.85rem;
color: #aaa;
display: flex;
align-items: center;
gap: 16px;
font-size: 0.85rem;
color: #aaa;
}
#last-update, #refresh-timer {
font-family: 'Courier New', monospace;
font-size: 0.9rem;
color: #4ade80;
background: rgba(0, 0, 0, 0.4);
padding: 4px 10px;
border-radius: 6px;
border: 1px solid rgba(74, 222, 128, 0.2);
#last-update,
#refresh-timer {
font-family: 'Courier New', monospace;
font-size: 0.9rem;
color: #4ade80;
background: rgba(0, 0, 0, 0.4);
padding: 4px 10px;
border-radius: 6px;
border: 1px solid rgba(74, 222, 128, 0.2);
}
#refresh-btn {
background: #e94560;
border: none;
color: white;
width: 36px;
height: 36px;
border-radius: 50%;
cursor: pointer;
font-size: 1.2rem;
transition: transform 0.3s, background 0.3s;
background: #e94560;
border: none;
color: white;
width: 36px;
height: 36px;
border-radius: 50%;
cursor: pointer;
font-size: 1.2rem;
transition: transform 0.3s, background 0.3s;
}
#refresh-btn:hover {
background: #ff6b6b;
transform: rotate(180deg);
background: #ff6b6b;
transform: rotate(180deg);
}
#main-content {
display: flex;
gap: 24px;
padding: 24px;
max-width: 1400px;
margin: 0 auto;
align-items: flex-start;
display: flex;
gap: 24px;
padding: 24px;
max-width: 1400px;
margin: 0 auto;
align-items: flex-start;
}
/* Board Section */
#board-section {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.player-info {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
max-width: 500px;
padding: 12px 16px;
background: rgba(0, 0, 0, 0.3);
border-radius: 12px;
border: 2px solid transparent;
transition: border-color 0.3s, box-shadow 0.3s;
display: flex;
align-items: center;
gap: 12px;
width: 100%;
max-width: 500px;
padding: 12px 16px;
background: rgba(0, 0, 0, 0.3);
border-radius: 12px;
border: 2px solid transparent;
transition: border-color 0.3s, box-shadow 0.3s;
}
.player-info.active {
border-color: #e94560;
box-shadow: 0 0 15px rgba(233, 69, 96, 0.3);
border-color: #e94560;
box-shadow: 0 0 15px rgba(233, 69, 96, 0.3);
}
.player-avatar {
font-size: 2rem;
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.1);
border-radius: 50%;
flex-shrink: 0;
font-size: 2rem;
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.1);
border-radius: 50%;
flex-shrink: 0;
}
.player-details {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.player-name {
font-weight: 600;
font-size: 1.1rem;
color: #fff;
font-weight: 600;
font-size: 1.1rem;
color: #fff;
}
.player-elo {
font-size: 0.85rem;
color: #aaa;
font-size: 0.85rem;
color: #aaa;
}
.player-clock {
font-family: 'Courier New', monospace;
font-size: 1.3rem;
font-weight: bold;
background: rgba(0, 0, 0, 0.5);
padding: 6px 12px;
border-radius: 8px;
min-width: 100px;
text-align: center;
color: #4ade80;
font-family: 'Courier New', monospace;
font-size: 1.3rem;
font-weight: bold;
background: rgba(0, 0, 0, 0.5);
padding: 6px 12px;
border-radius: 8px;
min-width: 100px;
text-align: center;
color: #4ade80;
}
#board {
width: 100%;
max-width: 500px;
width: 100%;
max-width: 500px;
}
#board [data-square].last-move-highlight {
box-shadow: inset 0 0 3px 3px rgba(255, 200, 0, 0.7);
}
/* Board Row: Board + Eval-Bar nebeneinander */
#board-row {
display: flex;
gap: 8px;
width: 100%;
max-width: 540px;
}
#eval-bar-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
flex-shrink: 0;
width: 36px;
min-height: 350px;
}
#eval-bar {
flex: 1;
width: 16px;
background: #1a1a1a;
border-radius: 8px;
position: relative;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.15);
}
#eval-bar-fill {
position: absolute;
bottom: 0;
width: 100%;
background: linear-gradient(to top, #b0b0b0 0%, #909090 100%);
transition: height 0.3s ease;
border-radius: 0 0 7px 7px;
height: 50%;
}
#eval-bar-marker {
position: absolute;
left: -2px;
right: -2px;
height: 2px;
background: #000;
transition: top 0.3s ease;
z-index: 3;
box-shadow: 0 0 3px rgba(0, 0, 0, 0.5);
top: 50%;
}
#eval-bar-lines {
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.eval-line {
height: 1px;
background: #eee;
mix-blend-mode: difference;
}
.eval-line-center {
height: 4px;
background: #fff;
mix-blend-mode: difference;
}
#eval-info {
display: flex;
flex-direction: column;
align-items: center;
gap: 1px;
flex-shrink: 0;
}
#eval-score {
font-family: 'Courier New', monospace;
font-size: 0.75rem;
font-weight: bold;
color: #fff;
text-align: center;
white-space: nowrap;
}
#pgn-panel,
#pgn-panel-mobile {
width: 100%;
max-width: 500px;
}
#pgn-panel-mobile {
display: none;
}
.pgn-panel--desktop,
.pgn-panel--mobile {
background: rgba(0, 0, 0, 0.3);
border-radius: 12px;
}
.pgn-panel--desktop {
padding: 12px 16px;
}
.pgn-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
color: #e94560;
font-weight: bold;
font-size: 0.9rem;
}
.copy-pgn-btn {
background: rgba(255, 255, 255, 0.1);
border: none;
color: #ccc;
width: 32px;
height: 32px;
border-radius: 6px;
cursor: pointer;
font-size: 1rem;
transition: background 0.2s;
}
.copy-pgn-btn:hover {
background: rgba(255, 255, 255, 0.2);
}
.pgn-text {
font-family: 'Courier New', monospace;
font-size: 0.78rem;
line-height: 1.5;
color: #aaa;
white-space: pre-wrap;
word-break: break-all;
max-height: 160px;
overflow-y: auto;
padding: 8px;
background: rgba(0, 0, 0, 0.4);
border-radius: 6px;
-webkit-user-select: text;
user-select: text;
}
/* Info Section */
#info-section {
flex: 0 0 380px;
display: flex;
flex-direction: column;
gap: 20px;
flex: 0 0 380px;
display: flex;
flex-direction: column;
gap: 20px;
}
#game-info h2 {
font-size: 1.3rem;
color: #e94560;
margin-bottom: 8px;
font-size: 1.3rem;
color: #e94560;
margin-bottom: 8px;
}
#result-info {
font-size: 1.1rem;
padding: 8px 12px;
background: rgba(0, 0, 0, 0.3);
border-radius: 8px;
font-size: 1.1rem;
padding: 8px 12px;
background: rgba(0, 0, 0, 0.3);
border-radius: 8px;
}
#moves-panel, #all-games-panel {
background: rgba(0, 0, 0, 0.3);
border-radius: 12px;
padding: 16px;
#moves-panel,
#all-games-panel,
#standings-panel,
#pgn-panel-mobile {
background: rgba(0, 0, 0, 0.3);
border-radius: 12px;
padding: 16px;
}
#moves-panel h3, #all-games-panel h3 {
margin-bottom: 12px;
color: #e94560;
font-size: 1rem;
#moves-panel h3,
#all-games-panel h3,
#standings-panel h3,
#pgn-panel-mobile h3 {
margin-bottom: 12px;
color: #e94560;
font-size: 1rem;
}
#standings-content {
font-size: 0.9rem;
line-height: 1.6;
}
#standings-content .standings-loading {
color: #888;
font-style: italic;
}
.standings-row {
display: flex;
justify-content: space-between;
padding: 6px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.standings-row:last-child {
border-bottom: none;
}
.standings-label {
color: #aaa;
}
.standings-value {
color: #fff;
font-weight: 600;
}
.standings-rank {
font-size: 2rem;
font-weight: bold;
color: #e94560;
text-align: center;
padding: 8px 0;
}
.standings-rank-label {
font-size: 0.8rem;
color: #888;
text-align: center;
}
.standings-rank-label a {
color: #888;
text-decoration: none;
}
.standings-rank-label a:hover {
color: #ffd700;
text-decoration: underline;
}
.standings-header {
text-align: center;
margin-bottom: 8px;
color: #ffd700;
font-size: 0.85rem;
}
#moves-list {
max-height: 300px;
overflow-y: auto;
font-family: 'Courier New', monospace;
font-size: 0.95rem;
line-height: 1.8;
display: flex;
flex-wrap: wrap;
gap: 4px;
max-height: 300px;
overflow-y: auto;
font-family: 'Courier New', monospace;
font-size: 0.95rem;
line-height: 1.8;
display: flex;
flex-wrap: wrap;
gap: 4px;
}
#moves-list .move-number {
color: #888;
font-weight: bold;
color: #888;
font-weight: bold;
}
#moves-list .move {
color: #e0e0e0;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
transition: background 0.2s;
color: #e0e0e0;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
transition: background 0.2s;
}
#moves-list .move:hover {
background: rgba(233, 69, 96, 0.3);
background: rgba(233, 69, 96, 0.3);
}
#moves-list .move.current {
background: #e94560;
color: #fff;
background: #e94560;
color: #fff;
}
#moves-list .lara-move {
color: #ffd700;
font-weight: bold;
}
#moves-list .opp-move {
color: #aaa;
}
#all-games-list {
display: flex;
flex-direction: column;
gap: 8px;
display: flex;
flex-direction: column;
gap: 8px;
}
.game-entry {
padding: 10px 12px;
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
cursor: pointer;
transition: background 0.2s;
border-left: 3px solid transparent;
padding: 10px 12px;
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
cursor: pointer;
transition: background 0.2s;
border-left: 3px solid transparent;
}
.game-entry:hover {
background: rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.1);
}
.game-entry.active {
border-left-color: #e94560;
background: rgba(233, 69, 96, 0.15);
border-left-color: #e94560;
background: rgba(233, 69, 96, 0.15);
}
.game-entry .game-round {
font-weight: bold;
color: #e94560;
font-size: 0.85rem;
font-weight: bold;
color: #e94560;
font-size: 0.85rem;
}
.game-entry .game-players {
font-size: 0.9rem;
color: #ccc;
margin-top: 2px;
font-size: 0.9rem;
color: #ccc;
margin-top: 2px;
}
.game-entry .game-result {
font-size: 0.8rem;
color: #888;
margin-top: 2px;
font-size: 0.8rem;
color: #888;
margin-top: 2px;
}
/* Overlays */
#loading-overlay, #error-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(26, 26, 46, 0.95);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 1000;
gap: 16px;
#loading-overlay,
#error-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(26, 26, 46, 0.95);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 1000;
gap: 16px;
}
#loading-overlay p, #error-overlay p {
font-size: 1.2rem;
#loading-overlay p,
#error-overlay p {
font-size: 1.2rem;
}
#error-overlay button {
padding: 10px 24px;
background: #e94560;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 1rem;
margin-top: 8px;
padding: 10px 24px;
background: #e94560;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 1rem;
margin-top: 8px;
}
.spinner {
width: 50px;
height: 50px;
border: 4px solid rgba(233, 69, 96, 0.3);
border-top-color: #e94560;
border-radius: 50%;
animation: spin 0.8s linear infinite;
width: 50px;
height: 50px;
border: 4px solid rgba(233, 69, 96, 0.3);
border-top-color: #e94560;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
to {
transform: rotate(360deg);
}
}
/* Scrollbar */
::-webkit-scrollbar {
width: 6px;
width: 6px;
}
::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.2);
border-radius: 3px;
background: rgba(0, 0, 0, 0.2);
border-radius: 3px;
}
::-webkit-scrollbar-thumb {
background: #e94560;
border-radius: 3px;
background: #e94560;
border-radius: 3px;
}
.round-info-mobile {
display: none;
}
/* Responsive */
@media (max-width: 900px) {
#main-content {
flex-direction: column;
align-items: center;
}
#info-section {
flex: none;
width: 100%;
max-width: 500px;
}
header h1 {
font-size: 1.2rem;
}
#main-content {
flex-direction: column;
align-items: center;
padding: 12px;
}
#info-section {
flex: none;
width: 100%;
max-width: 500px;
}
html,
body {
overflow-x: hidden;
}
#board-section {
width: 100%;
align-items: stretch;
}
#board,
.player-info {
max-width: 100%;
width: 100%;
}
#board {
overflow: hidden;
}
#board > div {
max-width: 100%;
box-sizing: border-box;
}
.round-info-mobile {
display: block;
width: 100%;
max-width: 500px;
font-size: 1.3rem;
color: #e94560;
margin: 0 auto;
}
#round-info {
display: none;
}
#result-info {
display: none;
}
.pgn-panel--desktop {
display: none;
}
#pgn-panel-mobile {
display: block;
}
.player-info {
padding: 8px 12px;
}
.player-avatar {
width: 36px;
height: 36px;
font-size: 1.4rem;
}
.player-name {
font-size: 0.95rem;
}
.player-clock {
font-size: 1.1rem;
min-width: 80px;
padding: 4px 8px;
}
header h1 {
font-size: 1.2rem;
}
}