Initial commit

This commit is contained in:
2026-05-24 14:19:01 +02:00
commit 84f64ed86a
6 changed files with 4059 additions and 0 deletions

334
lara-chess/app.js Normal file
View File

@@ -0,0 +1,334 @@
/**
* 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;
/**
* Lädt die PGN-Datei und aktualisiert die Anzeige
*/
async function loadPGN() {
showLoading(true);
hideError();
try {
// Lokaler Proxy-Server (python server.py)
const response = await fetch('http://localhost:8111/pgn');
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const pgnText = await response.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 now = new Date();
document.getElementById('last-update').textContent =
`Letztes Update: ${now.toLocaleTimeString('de-DE')}`;
}
/**
* Start auto-refresh
*/
function startAutoRefresh() {
countdown = REFRESH_INTERVAL / 1000;
if (refreshTimer) clearInterval(refreshTimer);
refreshTimer = setInterval(() => {
countdown--;
document.getElementById('refresh-timer').textContent =
`Nächste Aktualisierung: ${countdown}s`;
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();

3053
lara-chess/cache/gesamt-utf8.pgn vendored Normal file

File diff suppressed because it is too large Load Diff

78
lara-chess/index.html Normal file
View File

@@ -0,0 +1,78 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🏆 Lara Kiesewetter Live Schachturnier</title>
<link rel="stylesheet" href="https://unpkg.com/@chrisoakman/chessboardjs@1.0.0/dist/chessboard-1.0.0.min.css">
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="app">
<header>
<h1>🏆 Lara Kiesewetter Live Turnier</h1>
<div id="status-bar">
<span id="last-update">Letztes Update: --</span>
<span id="refresh-timer"></span>
<button id="refresh-btn" title="Jetzt aktualisieren">🔄</button>
</div>
</header>
<div id="main-content">
<!-- Linke Spalte: Schachbrett -->
<div id="board-section">
<div id="player-black" class="player-info">
<div class="player-avatar"></div>
<div class="player-details">
<span class="player-name" id="black-name">--</span>
<span class="player-elo" id="black-elo"></span>
</div>
<div class="player-clock" id="black-clock">--:--:--</div>
</div>
<div id="board"></div>
<div id="player-white" class="player-info">
<div class="player-avatar"></div>
<div class="player-details">
<span class="player-name" id="white-name">--</span>
<span class="player-elo" id="white-elo"></span>
</div>
<div class="player-clock" id="white-clock">--:--:--</div>
</div>
</div>
<!-- Rechte Spalte: Info & Züge -->
<div id="info-section">
<div id="game-info">
<h2 id="round-info">--</h2>
<div id="result-info"></div>
</div>
<div id="moves-panel">
<h3>Züge</h3>
<div id="moves-list"></div>
</div>
<div id="all-games-panel">
<h3>Alle Partien von Lara</h3>
<div id="all-games-list"></div>
</div>
</div>
</div>
<div id="loading-overlay">
<div class="spinner"></div>
<p>Lade Turnierdaten...</p>
</div>
<div id="error-overlay" style="display:none;">
<p>⚠️ Fehler beim Laden der Daten.</p>
<p id="error-message"></p>
<button onclick="loadPGN()">Wiederholen</button>
</div>
</div>
<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>
</body>
</html>

103
lara-chess/pgn-parser.js Normal file
View File

@@ -0,0 +1,103 @@
/**
* PGN Parser - Parses PGN files and extracts game data
*/
function parsePGN(pgnText) {
const games = [];
// Split by game boundaries - each game starts with [Event
const gameBlocks = pgnText.split(/\[\s*Event\s*"/);
for (let i = 1; i < gameBlocks.length; i++) {
const game = parseGameBlock(gameBlocks[i]);
if (game) games.push(game);
}
return games;
}
function parseGameBlock(block) {
try {
const headers = {};
const headerRegex = /^\s*\[(\w+)\s+"([^"]*)"\]/g;
let match;
// Extract all headers
let tempBlock = block;
while ((match = headerRegex.exec(tempBlock)) !== null) {
headers[match[1]] = match[2];
}
// Extract moves - everything after the last header
const lastHeaderEnd = block.lastIndexOf(']"');
let movesText = lastHeaderEnd > -1 ? block.substring(lastHeaderEnd + 2).trim() : '';
// Remove comments from moves for cleaner parsing
const moves = parseMoves(movesText);
return {
event: headers.Event || '',
site: headers.Site || '',
date: headers.Date || '',
round: headers.Round || '',
white: headers.White || '',
black: headers.Black || '',
result: headers.Result || '',
termination: headers.Termination || '',
whiteElo: headers.WhiteElo || '',
blackElo: headers.BlackElo || '',
whiteClock: headers.WhiteClock || '',
blackClock: headers.BlackClock || '',
moves: moves,
isLive: headers.Termination === 'unterminated' || headers.Result === '*'
};
} catch (e) {
console.error('Error parsing game:', e);
return null;
}
}
function parseMoves(movesText) {
const moves = [];
// Remove comments in curly braces
movesText = movesText.replace(/\{[^}]*\}/g, '');
// Remove move numbers
movesText = movesText.replace(/\d+\.\s*/g, '');
// Split into individual moves
const tokens = movesText.split(/\s+/).filter(t => t.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 });
}
}
return moves;
}
function filterLaraGames(games) {
return games.filter(game =>
game.white.toLowerCase().includes('kiesewetter') ||
game.black.toLowerCase().includes('kiesewetter')
);
}
function getLiveGame(laraGames) {
// Return the game that is still in progress
return laraGames.find(game => game.isLive) || null;
}
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;
return roundB - roundA;
});
return sorted[0];
}

168
lara-chess/server.py Normal file
View File

@@ -0,0 +1,168 @@
#!/usr/bin/env python3
"""
Lokal Proxy-Server für Lara's Schachturnier
Lädt die PGN-Datei und stellt sie mit CORS-Headern bereit.
"""
import http.server
import socketserver
import urllib.request
import sys
import os
import threading
import time
from datetime import datetime
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
os.makedirs(CACHE_DIR, exist_ok=True)
def fetch_pgn():
"""Lädt die PGN-Datei von der URL als Bytes."""
try:
req = urllib.request.Request(PGN_URL, headers={"User-Agent": "Mozilla/5.0"})
with urllib.request.urlopen(req, timeout=30) as response:
return response.read()
except Exception as e:
print(f"[{datetime.now().strftime('%H:%M:%S')}] Fehler beim Laden: {e}")
return 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
class PGNHandler(http.server.BaseHTTPRequestHandler):
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"}')
elif self.path == "/status":
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(b'{"status": "ok"}')
else:
# Statische Dateien aus dem Verzeichnis
if self.path == "/":
self.path = "/index.html"
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")
with open(filepath, "rb") as f:
content = f.read()
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()
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}")
def main():
print("=" * 50)
print(" [TROPHY] 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)")
else:
print("[WARN] Initialer Ladeversuch fehlgeschlagen, wird wiederholt...")
# 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")
try:
httpd.serve_forever()
except KeyboardInterrupt:
print("\n[BYE] Server gestoppt.")
if __name__ == "__main__":
main()

323
lara-chess/style.css Normal file
View File

@@ -0,0 +1,323 @@
* {
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;
}
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;
}
header h1 {
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;
}
#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;
}
#refresh-btn:hover {
background: #ff6b6b;
transform: rotate(180deg);
}
#main-content {
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;
}
.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;
}
.player-info.active {
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;
}
.player-details {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.player-name {
font-weight: 600;
font-size: 1.1rem;
color: #fff;
}
.player-elo {
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;
}
#board {
width: 100%;
max-width: 500px;
}
/* Info Section */
#info-section {
flex: 0 0 380px;
display: flex;
flex-direction: column;
gap: 20px;
}
#game-info h2 {
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;
}
#moves-panel, #all-games-panel {
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-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;
}
#moves-list .move-number {
color: #888;
font-weight: bold;
}
#moves-list .move {
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);
}
#moves-list .move.current {
background: #e94560;
color: #fff;
}
#all-games-list {
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;
}
.game-entry:hover {
background: rgba(255, 255, 255, 0.1);
}
.game-entry.active {
border-left-color: #e94560;
background: rgba(233, 69, 96, 0.15);
}
.game-entry .game-round {
font-weight: bold;
color: #e94560;
font-size: 0.85rem;
}
.game-entry .game-players {
font-size: 0.9rem;
color: #ccc;
margin-top: 2px;
}
.game-entry .game-result {
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 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;
}
.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;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Scrollbar */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.2);
border-radius: 3px;
}
::-webkit-scrollbar-thumb {
background: #e94560;
border-radius: 3px;
}
/* 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;
}
}