Files
Server/AdminResources.cs
Bandwidth 53a10f9196 okmegalul
2026-04-26 19:52:51 +02:00

2895 lines
100 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
namespace GeoSus.Server;
/// <summary>
/// Embedded resources pro Admin Panel
/// HTML, CSS a JavaScript jako stringy pro snadné servování bez externích souborů
/// </summary>
public static class AdminResources
{
public static string Html => @"<!DOCTYPE html>
<html lang=""cs"">
<head>
<meta charset=""UTF-8"">
<meta name=""viewport"" content=""width=device-width, initial-scale=1.0"">
<title>GeoSus Admin Panel</title>
<link rel=""stylesheet"" href=""/admin/style.css"">
<link rel=""stylesheet"" href=""https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"" />
<script src=""https://unpkg.com/leaflet@1.9.4/dist/leaflet.js""></script>
</head>
<body>
<!-- Login Screen -->
<div id=""login-screen"" class=""screen active"">
<div class=""login-container"">
<div class=""login-logo"">
<div class=""logo-icon"">🌍</div>
<h1>GeoSus</h1>
<p>Admin Panel</p>
</div>
<form id=""login-form"">
<div class=""input-group"">
<input type=""password"" id=""password"" placeholder=""Admin heslo"" autocomplete=""current-password"">
<span class=""input-icon"">🔐</span>
</div>
<button type=""submit"" class=""btn btn-primary btn-full"">
<span>Přihlásit se</span>
<span class=""btn-arrow"">→</span>
</button>
<div id=""login-error"" class=""error-message""></div>
</form>
</div>
<div class=""login-bg""></div>
</div>
<!-- Main Dashboard -->
<div id=""dashboard"" class=""screen"">
<!-- Sidebar -->
<nav class=""sidebar"">
<div class=""sidebar-header"">
<div class=""logo"">🌍</div>
<span class=""logo-text"">GeoSus</span>
</div>
<div class=""nav-section"">
<div class=""nav-label"">Přehled</div>
<a href=""#"" class=""nav-item active"" data-view=""overview"">
<span class=""nav-icon"">📊</span>
<span>Dashboard</span>
</a>
<a href=""#"" class=""nav-item"" data-view=""lobbies"">
<span class=""nav-icon"">🎮</span>
<span>Lobby</span>
<span class=""nav-badge"" id=""lobby-count"">0</span>
</a>
<a href=""#"" class=""nav-item"" data-view=""players"">
<span class=""nav-icon"">👥</span>
<span>Hráči</span>
<span class=""nav-badge"" id=""player-count"">0</span>
</a>
</div>
<div class=""nav-section"">
<div class=""nav-label"">Nastavení</div>
<a href=""#"" class=""nav-item"" data-view=""config"">
<span class=""nav-icon"">⚙️</span>
<span>Konfigurace</span>
</a>
<a href=""#"" class=""nav-item"" data-view=""broadcast"">
<span class=""nav-icon"">📢</span>
<span>Broadcast</span>
</a>
<a href=""#"" class=""nav-item"" data-view=""archive"">
<span class=""nav-icon"">📁</span>
<span>Archiv</span>
</a>
</div>
<div class=""sidebar-footer"">
<div class=""server-status"">
<span class=""status-dot online""></span>
<span id=""server-uptime"">00:00:00</span>
</div>
<button class=""btn-logout"" id=""logout-btn"">
<span>🚪</span>
<span>Odhlásit</span>
</button>
</div>
</nav>
<!-- Main Content -->
<main class=""main-content"">
<!-- Overview View -->
<div id=""view-overview"" class=""view active"">
<div class=""view-header"">
<h1>Dashboard</h1>
<div class=""header-actions"">
<span class=""last-update"">Aktualizováno: <span id=""last-update-time"">--:--:--</span></span>
</div>
</div>
<div class=""stats-grid"">
<div class=""stat-card"">
<div class=""stat-icon"">🎮</div>
<div class=""stat-content"">
<div class=""stat-value"" id=""stat-lobbies"">0</div>
<div class=""stat-label"">Aktivní lobby</div>
</div>
</div>
<div class=""stat-card"">
<div class=""stat-icon"">👥</div>
<div class=""stat-content"">
<div class=""stat-value"" id=""stat-players"">0</div>
<div class=""stat-label"">Hráčů online</div>
</div>
</div>
<div class=""stat-card"">
<div class=""stat-icon"">⏱️</div>
<div class=""stat-content"">
<div class=""stat-value"" id=""stat-uptime"">0:00:00</div>
<div class=""stat-label"">Uptime</div>
</div>
</div>
<div class=""stat-card"">
<div class=""stat-icon"">💾</div>
<div class=""stat-content"">
<div class=""stat-value"" id=""stat-memory"">0 MB</div>
<div class=""stat-label"">Paměť</div>
</div>
</div>
</div>
<div class=""dashboard-grid"">
<div class=""card"">
<div class=""card-header"">
<h3>Aktivní Lobby</h3>
<button class=""btn btn-sm"" onclick=""refreshLobbies()"">🔄</button>
</div>
<div class=""card-body"">
<div id=""lobby-list"" class=""lobby-list"">
<div class=""empty-state"">Žádné aktivní lobby</div>
</div>
</div>
</div>
<div class=""card"">
<div class=""card-header"">
<h3>Rychlé akce</h3>
</div>
<div class=""card-body"">
<div class=""quick-actions"">
<button class=""btn btn-action"" onclick=""showBroadcastModal()"">
<span class=""action-icon"">📢</span>
<span>Broadcast zpráva</span>
</button>
<button class=""btn btn-action"" onclick=""showView('config')"">
<span class=""action-icon"">⚙️</span>
<span>Upravit konfiguraci</span>
</button>
<button class=""btn btn-action"" onclick=""showView('players')"">
<span class=""action-icon"">👥</span>
<span>Spravovat hráče</span>
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Lobbies View -->
<div id=""view-lobbies"" class=""view"">
<div class=""view-header"">
<h1>Správa Lobby</h1>
</div>
<div class=""lobbies-container"">
<div class=""lobbies-list"" id=""lobbies-full-list"">
<!-- Dynamicky generováno -->
</div>
</div>
</div>
<!-- Spectate View -->
<div id=""view-spectate"" class=""view"">
<div class=""view-header"">
<button class=""btn btn-back"" onclick=""showView('lobbies')"">← Zpět</button>
<h1>Spectate: <span id=""spectate-lobby-code"">---</span></h1>
<div class=""spectate-phase"" id=""spectate-phase"">---</div>
</div>
<div class=""spectate-container"">
<div class=""spectate-map-container"">
<div id=""spectate-map"" class=""spectate-map""></div>
<div class=""map-overlay"">
<div class=""map-legend"">
<div class=""legend-item""><span class=""legend-dot crew""></span> Crew</div>
<div class=""legend-item""><span class=""legend-dot impostor""></span> Impostor</div>
<div class=""legend-item""><span class=""legend-dot dead""></span> Dead</div>
<div class=""legend-item""><span class=""legend-dot task""></span> Task</div>
<div class=""legend-item""><span class=""legend-dot body""></span> Body</div>
<div class=""legend-item""><span class=""legend-dot repair""></span> Repair</div>
</div>
</div>
</div>
<div class=""spectate-sidebar"">
<div class=""spectate-panel"">
<h3>👥 Hráči</h3>
<div id=""spectate-players"" class=""player-list""></div>
</div>
<div class=""spectate-panel"" id=""spectate-votes-panel"" style=""display:none"">
<h3>🗳️ Hlasování</h3>
<div id=""spectate-votes"" class=""votes-display""></div>
</div>
<div class=""spectate-panel"" id=""spectate-sabotage-panel"" style=""display:none"">
<h3>⚠️ Sabotáž</h3>
<div id=""spectate-sabotage"" class=""sabotage-info""></div>
</div>
<div class=""spectate-panel"">
<h3>📋 Tasks</h3>
<div class=""task-progress"">
<div class=""progress-bar"">
<div class=""progress-fill"" id=""task-progress-fill""></div>
</div>
<span id=""task-progress-text"">0/0</span>
</div>
</div>
</div>
</div>
</div>
<!-- Players View -->
<div id=""view-players"" class=""view"">
<div class=""view-header"">
<h1>Správa hráčů</h1>
<div class=""header-actions"">
<input type=""text"" id=""player-search"" placeholder=""Hledat hráče..."" class=""search-input"">
</div>
</div>
<div class=""players-table-container"">
<table class=""data-table"">
<thead>
<tr>
<th>Jméno</th>
<th>ID</th>
<th>Lobby</th>
<th>Role</th>
<th>Stav</th>
<th>Cheat Score</th>
<th>Akce</th>
</tr>
</thead>
<tbody id=""players-table-body"">
</tbody>
</table>
</div>
</div>
<!-- Config View -->
<div id=""view-config"" class=""view"">
<div class=""view-header"">
<h1>Konfigurace serveru</h1>
</div>
<div class=""config-container"">
<form id=""config-form"" class=""config-form"">
<div class=""config-section"">
<h3>🎯 Gameplay (vzdálenosti & cooldowny)</h3>
<div class=""config-grid"">
<div class=""config-item"">
<label>Kill Distance (m)</label>
<input type=""number"" name=""killDistanceM"" step=""0.1"">
</div>
<div class=""config-item"">
<label>Kill Cooldown (ms)</label>
<input type=""number"" name=""killCooldownMs"" step=""1000"">
</div>
<div class=""config-item"">
<label>Report Distance (m)</label>
<input type=""number"" name=""reportDistanceM"" step=""0.1"">
</div>
<div class=""config-item"">
<label>Task Distance (m)</label>
<input type=""number"" name=""taskStartDistanceM"" step=""0.1"">
</div>
<div class=""config-item"">
<label>Task Leave Debounce (ms)</label>
<input type=""number"" name=""taskLeaveDebounceMs"" step=""500"">
</div>
<div class=""config-item"">
<label>Task Progress Keepalive (ms)</label>
<input type=""number"" name=""taskProgressKeepaliveMs"" step=""500"">
</div>
<div class=""config-item"">
<label>Max Speed (m/s)</label>
<input type=""number"" name=""maxSpeedMps"" step=""0.1"">
</div>
<div class=""config-item"">
<label>Teleport Threshold (m)</label>
<input type=""number"" name=""teleportThresholdMeters"" step=""1"">
</div>
</div>
</div>
<div class=""config-section"">
<h3>🆕 Per-Lobby výchozí hodnoty</h3>
<div class=""config-grid"">
<div class=""config-item"">
<label>Default Impostor Count</label>
<input type=""number"" name=""defaultImpostorCount"" step=""1"" min=""1"">
</div>
<div class=""config-item"">
<label>Default Task Count</label>
<input type=""number"" name=""defaultTaskCount"" step=""1"" min=""1"">
</div>
<div class=""config-item"">
<label>Max Players Per Lobby</label>
<input type=""number"" name=""maxPlayersPerLobby"" step=""1"" min=""2"">
</div>
<div class=""config-item"">
<label>Default Tie Policy</label>
<select name=""defaultTiePolicy"">
<option value=""NoEject"">NoEject</option>
<option value=""Random"">Random</option>
<option value=""EjectLowestId"">EjectLowestId</option>
</select>
</div>
</div>
</div>
<div class=""config-section"">
<h3>📏 Distance Check Slack (P13b)</h3>
<p class=""config-help"">Server validates actions at <code>distance + min(distance × percent / 100, maxM)</code> so a player at the visible edge of a button radius can press it reliably despite 1Hz position broadcasts and GPS jitter.</p>
<div class=""config-grid"">
<div class=""config-item"">
<label>Slack Percent (%)</label>
<input type=""number"" name=""distanceCheckSlackPercent"" step=""1"" min=""0"" max=""100"">
</div>
<div class=""config-item"">
<label>Slack Max (m)</label>
<input type=""number"" name=""distanceCheckSlackMaxM"" step=""0.5"" min=""0"">
</div>
</div>
</div>
<div class=""config-section"">
<h3>🗳️ Meeting</h3>
<div class=""config-grid"">
<div class=""config-item"">
<label>Meeting Arrival Radius (m)</label>
<input type=""number"" name=""meetingArrivalRadiusM"" step=""1"">
</div>
<div class=""config-item"">
<label>Emergency Call Radius (m)</label>
<input type=""number"" name=""emergencyMeetingCallRadiusM"" step=""1"">
</div>
<div class=""config-item"">
<label>Arrival Base (ms)</label>
<input type=""number"" name=""arrivalBaseMs"" step=""1000"">
</div>
<div class=""config-item"">
<label>Arrival Safety Margin (ms)</label>
<input type=""number"" name=""arrivalSafetyMarginMs"" step=""100"">
</div>
<div class=""config-item"">
<label>Allowed Late (ms)</label>
<input type=""number"" name=""allowedLateMs"" step=""500"">
</div>
<div class=""config-item"">
<label>Diskuze (ms)</label>
<input type=""number"" name=""discussionPhaseMs"" step=""1000"">
</div>
<div class=""config-item"">
<label>Hlasování (ms)</label>
<input type=""number"" name=""votingPhaseMs"" step=""1000"">
</div>
<div class=""config-item"">
<label>Emergency Cooldown (ms)</label>
<input type=""number"" name=""emergencyMeetingCooldownMs"" step=""1000"">
</div>
<div class=""config-item"">
<label>Max Emergency / Hráč</label>
<input type=""number"" name=""maxEmergencyMeetingsPerPlayer"" step=""1"" min=""0"">
</div>
</div>
</div>
<div class=""config-section"">
<h3>⚠️ Sabotáž</h3>
<div class=""config-grid"">
<div class=""config-item"">
<label>Sabotage Cooldown (ms)</label>
<input type=""number"" name=""sabotageCooldownMs"" step=""1000"">
</div>
<div class=""config-item"">
<label>Comms Blackout Duration (ms)</label>
<input type=""number"" name=""commsBlackoutDurationMs"" step=""1000"">
</div>
<div class=""config-item"">
<label>Meltdown Deadline (ms)</label>
<input type=""number"" name=""criticalMeltdownDeadlineMs"" step=""1000"">
</div>
<div class=""config-item"">
<label>Repair Station Distance (m)</label>
<input type=""number"" name=""repairStationDistanceM"" step=""0.5"">
</div>
<div class=""config-item"">
<label>Repair Station Hold (ms)</label>
<input type=""number"" name=""repairStationHoldMs"" step=""500"">
</div>
<div class=""config-item"">
<label>Simultaneous Repair Window (ms)</label>
<input type=""number"" name=""simultaneousRepairWindowMs"" step=""500"">
</div>
</div>
</div>
<div class=""config-section"">
<h3>🛡️ Anti-Cheat & Lobby</h3>
<div class=""config-grid"">
<div class=""config-item"">
<label>Movement Validation Window (s)</label>
<input type=""number"" name=""movementValidationWindowSec"" step=""0.5"">
</div>
<div class=""config-item"">
<label>Cheat Score - Warn</label>
<input type=""number"" name=""cheatScoreWarnThreshold"" step=""1"">
</div>
<div class=""config-item"">
<label>Cheat Score - Restrict</label>
<input type=""number"" name=""cheatScoreRestrictThreshold"" step=""1"">
</div>
<div class=""config-item"">
<label>Cheat Score - Kick</label>
<input type=""number"" name=""cheatScoreKickThreshold"" step=""1"">
</div>
<div class=""config-item"">
<label>Idle Lobby TTL (ms)</label>
<input type=""number"" name=""idleLobbyTtlMs"" step=""60000"">
</div>
<div class=""config-item"">
<label>Host Timeout (ms)</label>
<input type=""number"" name=""hostTimeoutMs"" step=""1000"">
</div>
<div class=""config-item"">
<label>Reconnect Window (ms)</label>
<input type=""number"" name=""reconnectWindowMs"" step=""1000"">
</div>
<div class=""config-item"">
<label>Join Rate Limit / minutu</label>
<input type=""number"" name=""joinRateLimitPerMinute"" step=""1"">
</div>
</div>
</div>
<div class=""config-actions"">
<button type=""submit"" class=""btn btn-primary"">💾 Uložit změny</button>
<button type=""button"" class=""btn"" onclick=""loadConfig()"">🔄 Obnovit</button>
</div>
</form>
</div>
</div>
<!-- Broadcast View -->
<div id=""view-broadcast"" class=""view"">
<div class=""view-header"">
<h1>Broadcast zpráva</h1>
</div>
<div class=""broadcast-container"">
<form id=""broadcast-form"" class=""broadcast-form"">
<div class=""form-group"">
<label>Cíl</label>
<select id=""broadcast-target"">
<option value="""">Všechny lobby</option>
</select>
</div>
<div class=""form-group"">
<label>Zpráva</label>
<textarea id=""broadcast-message"" rows=""4"" placeholder=""Napište zprávu pro hráče...""></textarea>
</div>
<button type=""submit"" class=""btn btn-primary"">📢 Odeslat</button>
</form>
</div>
</div>
<!-- Archive View (P13d) -->
<div id=""view-archive"" class=""view"">
<div class=""view-header"">
<h1>Archiv her</h1>
<div class=""header-actions"">
<button class=""btn"" onclick=""loadArchiveList()"">🔄 Obnovit</button>
</div>
</div>
<div id=""archive-list-container"" class=""archive-container"">
<div id=""archive-list"" class=""archive-list"">
<div class=""empty-state"">Načítám archivované hry...</div>
</div>
</div>
<div id=""archive-detail-container"" class=""archive-detail"" style=""display: none;"">
<div class=""detail-header"">
<button class=""btn"" onclick=""closeArchiveDetail()"">← Zpět na seznam</button>
<button class=""btn btn-primary"" onclick=""window.print()"">🖨️ Vytisknout report</button>
</div>
<div class=""print-page"">
<h2 id=""archive-detail-title"">Archiv hry</h2>
<div id=""archive-detail-summary"" class=""archive-summary""></div>
<div class=""archive-detail-grid"">
<div class=""card"">
<div class=""card-header""><h3>📍 Trasy hráčů</h3></div>
<div class=""card-body"">
<div id=""archive-map"" style=""height: 500px; border-radius: 8px;""></div>
</div>
</div>
<div class=""card"">
<div class=""card-header""><h3>📋 Časová osa</h3></div>
<div class=""card-body"">
<div id=""archive-timeline"" class=""archive-timeline""></div>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
<!-- Modals -->
<div id=""modal-overlay"" class=""modal-overlay"" onclick=""closeModal()""></div>
<div id=""kick-modal"" class=""modal"">
<div class=""modal-header"">
<h3>Kick hráče</h3>
<button class=""modal-close"" onclick=""closeModal()"">×</button>
</div>
<div class=""modal-body"">
<p>Opravdu chcete vyhodit hráče <strong id=""kick-player-name""></strong>?</p>
<div class=""form-group"">
<label>Důvod (volitelné)</label>
<input type=""text"" id=""kick-reason"" placeholder=""Důvod kicku..."">
</div>
</div>
<div class=""modal-footer"">
<button class=""btn"" onclick=""closeModal()"">Zrušit</button>
<button class=""btn btn-danger"" onclick=""confirmKick()"">Kick</button>
</div>
</div>
<script src=""/admin/app.js""></script>
</body>
</html>";
public static string Css => @"/* ═══════════════════════════════════════════════════════════════════════════
GeoSus Admin Panel - Styles
Modern, Dark Theme with Accent Colors
═══════════════════════════════════════════════════════════════════════════ */
:root {
--bg-primary: #0f0f1a;
--bg-secondary: #1a1a2e;
--bg-card: #16213e;
--bg-hover: #1f2b4a;
--text-primary: #ffffff;
--text-secondary: #a0a0b0;
--text-muted: #6b6b7b;
--accent-primary: #00d4ff;
--accent-secondary: #7c3aed;
--accent-gradient: linear-gradient(135deg, #00d4ff 0%, #7c3aed 100%);
--success: #10b981;
--warning: #f59e0b;
--danger: #ef4444;
--info: #3b82f6;
--crew-color: #3b82f6;
--impostor-color: #ef4444;
--dead-color: #6b7280;
--task-color: #10b981;
--body-color: #f59e0b;
--repair-color: #8b5cf6;
--border-color: rgba(255, 255, 255, 0.1);
--shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 10px 25px -5px rgba(0, 0, 0, 0.4);
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 16px;
--radius-full: 9999px;
--transition: all 0.2s ease;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
overflow: hidden;
height: 100vh;
}
/* ═══════════════════════════════════════════════════════════════════════════
LOGIN SCREEN
═══════════════════════════════════════════════════════════════════════════ */
.screen {
display: none;
height: 100vh;
}
.screen.active {
display: flex;
}
#login-screen {
justify-content: center;
align-items: center;
position: relative;
overflow: hidden;
}
.login-bg {
position: absolute;
inset: 0;
background:
radial-gradient(ellipse at 20% 50%, rgba(0, 212, 255, 0.15) 0%, transparent 50%),
radial-gradient(ellipse at 80% 50%, rgba(124, 58, 237, 0.15) 0%, transparent 50%),
var(--bg-primary);
z-index: 0;
}
.login-container {
position: relative;
z-index: 1;
background: var(--bg-secondary);
padding: 3rem;
border-radius: var(--radius-lg);
border: 1px solid var(--border-color);
box-shadow: var(--shadow-lg);
width: 100%;
max-width: 400px;
animation: slideUp 0.5s ease;
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.login-logo {
text-align: center;
margin-bottom: 2rem;
}
.logo-icon {
font-size: 4rem;
margin-bottom: 0.5rem;
animation: float 3s ease-in-out infinite;
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
.login-logo h1 {
font-size: 2rem;
background: var(--accent-gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.login-logo p {
color: var(--text-secondary);
font-size: 0.9rem;
}
.input-group {
position: relative;
margin-bottom: 1.5rem;
}
.input-group input {
width: 100%;
padding: 1rem 1rem 1rem 3rem;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
color: var(--text-primary);
font-size: 1rem;
transition: var(--transition);
}
.input-group input:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px rgba(0, 212, 255, 0.2);
}
.input-icon {
position: absolute;
left: 1rem;
top: 50%;
transform: translateY(-50%);
font-size: 1.2rem;
}
.error-message {
color: var(--danger);
font-size: 0.85rem;
margin-top: 1rem;
text-align: center;
min-height: 1.5rem;
}
/* ═══════════════════════════════════════════════════════════════════════════
BUTTONS
═══════════════════════════════════════════════════════════════════════════ */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
border: none;
border-radius: var(--radius-md);
font-size: 0.95rem;
font-weight: 500;
cursor: pointer;
transition: var(--transition);
background: var(--bg-card);
color: var(--text-primary);
border: 1px solid var(--border-color);
}
.btn:hover {
background: var(--bg-hover);
transform: translateY(-1px);
}
.btn-primary {
background: var(--accent-gradient);
border: none;
color: white;
}
.btn-primary:hover {
opacity: 0.9;
box-shadow: 0 4px 15px rgba(0, 212, 255, 0.4);
}
.btn-danger {
background: var(--danger);
border: none;
color: white;
}
.btn-danger:hover {
background: #dc2626;
}
.btn-full {
width: 100%;
}
.btn-sm {
padding: 0.4rem 0.8rem;
font-size: 0.85rem;
}
.btn-arrow {
transition: transform 0.2s;
}
.btn:hover .btn-arrow {
transform: translateX(4px);
}
.btn-action {
flex-direction: column;
padding: 1.5rem;
gap: 0.75rem;
flex: 1;
}
.action-icon {
font-size: 2rem;
}
.btn-back {
padding: 0.5rem 1rem;
}
/* ═══════════════════════════════════════════════════════════════════════════
DASHBOARD LAYOUT
═══════════════════════════════════════════════════════════════════════════ */
#dashboard {
display: flex;
}
.sidebar {
width: 260px;
background: var(--bg-secondary);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
height: 100vh;
}
.sidebar-header {
padding: 1.5rem;
display: flex;
align-items: center;
gap: 0.75rem;
border-bottom: 1px solid var(--border-color);
}
.sidebar-header .logo {
font-size: 2rem;
}
.logo-text {
font-size: 1.5rem;
font-weight: 700;
background: var(--accent-gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.nav-section {
padding: 1rem 0;
}
.nav-label {
padding: 0.5rem 1.5rem;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-muted);
}
.nav-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1.5rem;
color: var(--text-secondary);
text-decoration: none;
transition: var(--transition);
position: relative;
}
.nav-item:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.nav-item.active {
background: rgba(0, 212, 255, 0.1);
color: var(--accent-primary);
}
.nav-item.active::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: var(--accent-gradient);
border-radius: 0 3px 3px 0;
}
.nav-icon {
font-size: 1.2rem;
}
.nav-badge {
margin-left: auto;
background: var(--accent-primary);
color: var(--bg-primary);
padding: 0.15rem 0.5rem;
border-radius: var(--radius-full);
font-size: 0.75rem;
font-weight: 600;
}
.sidebar-footer {
margin-top: auto;
padding: 1rem 1.5rem;
border-top: 1px solid var(--border-color);
}
.server-status {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
font-size: 0.9rem;
color: var(--text-secondary);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-muted);
}
.status-dot.online {
background: var(--success);
box-shadow: 0 0 8px var(--success);
}
.btn-logout {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.75rem;
background: transparent;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
color: var(--text-secondary);
cursor: pointer;
transition: var(--transition);
}
.btn-logout:hover {
background: rgba(239, 68, 68, 0.1);
border-color: var(--danger);
color: var(--danger);
}
/* ═══════════════════════════════════════════════════════════════════════════
MAIN CONTENT
═══════════════════════════════════════════════════════════════════════════ */
.main-content {
flex: 1;
overflow-y: auto;
height: 100vh;
background: var(--bg-primary);
}
.view {
display: none;
padding: 2rem;
animation: fadeIn 0.3s ease;
}
.view.active {
display: block;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.view-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 2rem;
}
.view-header h1 {
font-size: 1.75rem;
font-weight: 600;
}
.header-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: 1rem;
}
.last-update {
font-size: 0.85rem;
color: var(--text-muted);
}
/* ═══════════════════════════════════════════════════════════════════════════
STATS CARDS
═══════════════════════════════════════════════════════════════════════════ */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.stat-card {
background: var(--bg-card);
border-radius: var(--radius-lg);
padding: 1.5rem;
display: flex;
align-items: center;
gap: 1rem;
border: 1px solid var(--border-color);
transition: var(--transition);
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
border-color: var(--accent-primary);
}
.stat-icon {
font-size: 2.5rem;
opacity: 0.8;
}
.stat-value {
font-size: 2rem;
font-weight: 700;
background: var(--accent-gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.stat-label {
font-size: 0.9rem;
color: var(--text-secondary);
}
/* ═══════════════════════════════════════════════════════════════════════════
CARDS
═══════════════════════════════════════════════════════════════════════════ */
.card {
background: var(--bg-card);
border-radius: var(--radius-lg);
border: 1px solid var(--border-color);
overflow: hidden;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border-color);
}
.card-header h3 {
font-size: 1rem;
font-weight: 600;
}
.card-body {
padding: 1.5rem;
}
.dashboard-grid {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 1.5rem;
}
@media (max-width: 1200px) {
.dashboard-grid {
grid-template-columns: 1fr;
}
}
.quick-actions {
display: flex;
gap: 1rem;
}
.empty-state {
text-align: center;
padding: 2rem;
color: var(--text-muted);
}
/* ═══════════════════════════════════════════════════════════════════════════
LOBBY LIST
═══════════════════════════════════════════════════════════════════════════ */
.lobby-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-height: 400px;
overflow-y: auto;
}
.lobby-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background: var(--bg-secondary);
border-radius: var(--radius-md);
cursor: pointer;
transition: var(--transition);
}
.lobby-item:hover {
background: var(--bg-hover);
transform: translateX(4px);
}
.lobby-code {
font-family: monospace;
font-size: 1.1rem;
font-weight: 600;
color: var(--accent-primary);
background: rgba(0, 212, 255, 0.1);
padding: 0.25rem 0.75rem;
border-radius: var(--radius-sm);
}
.lobby-info {
flex: 1;
}
.lobby-phase {
font-size: 0.85rem;
color: var(--text-secondary);
}
.lobby-players {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
}
.btn-spectate {
padding: 0.5rem 1rem;
background: var(--accent-gradient);
border: none;
color: white;
}
/* ═══════════════════════════════════════════════════════════════════════════
SPECTATE VIEW
═══════════════════════════════════════════════════════════════════════════ */
#view-spectate {
padding: 0;
height: 100vh;
display: none;
flex-direction: column;
}
#view-spectate.active {
display: flex;
}
#view-spectate .view-header {
padding: 1rem 2rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
margin-bottom: 0;
}
.spectate-phase {
margin-left: auto;
padding: 0.5rem 1rem;
background: var(--bg-card);
border-radius: var(--radius-full);
font-weight: 600;
text-transform: uppercase;
font-size: 0.85rem;
}
.spectate-phase.lobby { color: var(--info); }
.spectate-phase.playing { color: var(--success); }
.spectate-phase.discussion { color: var(--warning); }
.spectate-phase.voting { color: var(--danger); }
.spectate-container {
flex: 1;
display: flex;
overflow: hidden;
}
.spectate-map-container {
flex: 1;
position: relative;
}
.spectate-map {
width: 100%;
height: 100%;
background: var(--bg-primary);
}
.map-overlay {
position: absolute;
bottom: 1rem;
left: 1rem;
z-index: 1000;
}
.map-legend {
background: rgba(15, 15, 26, 0.9);
padding: 0.75rem 1rem;
border-radius: var(--radius-md);
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.legend-item {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8rem;
color: var(--text-secondary);
}
.legend-dot {
width: 12px;
height: 12px;
border-radius: 50%;
}
.legend-dot.crew { background: var(--crew-color); }
.legend-dot.impostor { background: var(--impostor-color); }
.legend-dot.dead { background: var(--dead-color); }
.legend-dot.task { background: var(--task-color); }
.legend-dot.body { background: var(--body-color); }
.legend-dot.repair { background: var(--repair-color); }
.spectate-sidebar {
width: 320px;
background: var(--bg-secondary);
border-left: 1px solid var(--border-color);
overflow-y: auto;
display: flex;
flex-direction: column;
}
.spectate-panel {
padding: 1rem;
border-bottom: 1px solid var(--border-color);
}
.spectate-panel h3 {
font-size: 0.9rem;
margin-bottom: 0.75rem;
color: var(--text-secondary);
}
.player-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.player-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0.75rem;
background: var(--bg-card);
border-radius: var(--radius-sm);
font-size: 0.9rem;
}
.player-role {
width: 8px;
height: 8px;
border-radius: 50%;
}
.player-role.crew { background: var(--crew-color); }
.player-role.impostor { background: var(--impostor-color); }
.player-item.dead {
opacity: 0.5;
text-decoration: line-through;
}
.player-name {
flex: 1;
}
.player-state {
font-size: 0.75rem;
color: var(--text-muted);
text-transform: uppercase;
}
/* Votes Display */
.votes-display {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.vote-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
background: var(--bg-card);
border-radius: var(--radius-sm);
}
.vote-target {
font-weight: 600;
flex: 1;
}
.vote-count {
background: var(--accent-primary);
color: var(--bg-primary);
padding: 0.15rem 0.5rem;
border-radius: var(--radius-full);
font-size: 0.8rem;
font-weight: 600;
}
/* Task Progress */
.task-progress {
display: flex;
align-items: center;
gap: 1rem;
}
.progress-bar {
flex: 1;
height: 8px;
background: var(--bg-primary);
border-radius: var(--radius-full);
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--accent-gradient);
transition: width 0.3s ease;
width: 0%;
}
/* Sabotage Info */
.sabotage-info {
background: rgba(239, 68, 68, 0.1);
border: 1px solid var(--danger);
border-radius: var(--radius-md);
padding: 0.75rem;
}
.sabotage-type {
font-weight: 600;
color: var(--danger);
margin-bottom: 0.5rem;
}
.sabotage-timer {
font-size: 1.5rem;
font-weight: 700;
color: var(--danger);
font-family: monospace;
}
/* ═══════════════════════════════════════════════════════════════════════════
PLAYERS TABLE
═══════════════════════════════════════════════════════════════════════════ */
.search-input {
padding: 0.5rem 1rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
color: var(--text-primary);
font-size: 0.9rem;
width: 250px;
}
.search-input:focus {
outline: none;
border-color: var(--accent-primary);
}
.players-table-container {
background: var(--bg-card);
border-radius: var(--radius-lg);
border: 1px solid var(--border-color);
overflow: hidden;
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th,
.data-table td {
padding: 1rem;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
.data-table th {
background: var(--bg-secondary);
font-weight: 600;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
}
.data-table tr:hover {
background: var(--bg-hover);
}
.data-table .role-badge {
padding: 0.25rem 0.75rem;
border-radius: var(--radius-full);
font-size: 0.8rem;
font-weight: 600;
}
.role-badge.crew {
background: rgba(59, 130, 246, 0.2);
color: var(--crew-color);
}
.role-badge.impostor {
background: rgba(239, 68, 68, 0.2);
color: var(--impostor-color);
}
.cheat-score {
font-weight: 600;
}
.cheat-score.low { color: var(--success); }
.cheat-score.medium { color: var(--warning); }
.cheat-score.high { color: var(--danger); }
/* ═══════════════════════════════════════════════════════════════════════════
CONFIG FORM
═══════════════════════════════════════════════════════════════════════════ */
.config-container {
max-width: 800px;
}
.config-form {
background: var(--bg-card);
border-radius: var(--radius-lg);
border: 1px solid var(--border-color);
overflow: hidden;
}
.config-section {
padding: 1.5rem;
border-bottom: 1px solid var(--border-color);
}
.config-section:last-of-type {
border-bottom: none;
}
.config-section h3 {
margin-bottom: 1rem;
font-size: 1rem;
}
.config-help {
margin: -0.5rem 0 1rem 0;
font-size: 0.8rem;
color: var(--text-muted, #888);
line-height: 1.4;
}
.config-help code {
background: var(--bg-elev, rgba(255,255,255,0.06));
padding: 0.05rem 0.3rem;
border-radius: 3px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.75rem;
}
.config-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.config-item label {
display: block;
margin-bottom: 0.5rem;
font-size: 0.85rem;
color: var(--text-secondary);
}
.config-item input {
width: 100%;
padding: 0.75rem;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
color: var(--text-primary);
font-size: 1rem;
}
.config-item input:focus {
outline: none;
border-color: var(--accent-primary);
}
.config-actions {
padding: 1.5rem;
background: var(--bg-secondary);
display: flex;
gap: 1rem;
}
/* ═══════════════════════════════════════════════════════════════════════════
BROADCAST FORM
═══════════════════════════════════════════════════════════════════════════ */
.broadcast-container {
max-width: 600px;
}
.broadcast-form {
background: var(--bg-card);
border-radius: var(--radius-lg);
border: 1px solid var(--border-color);
padding: 1.5rem;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.form-group select,
.form-group textarea {
width: 100%;
padding: 0.75rem;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
color: var(--text-primary);
font-size: 1rem;
font-family: inherit;
}
.form-group textarea {
resize: vertical;
min-height: 100px;
}
.form-group select:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--accent-primary);
}
/* ═══════════════════════════════════════════════════════════════════════════
MODAL
═══════════════════════════════════════════════════════════════════════════ */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
z-index: 1000;
opacity: 0;
visibility: hidden;
transition: var(--transition);
}
.modal-overlay.active {
opacity: 1;
visibility: visible;
}
.modal {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(0.9);
background: var(--bg-card);
border-radius: var(--radius-lg);
border: 1px solid var(--border-color);
z-index: 1001;
width: 90%;
max-width: 400px;
opacity: 0;
visibility: hidden;
transition: var(--transition);
}
.modal.active {
opacity: 1;
visibility: visible;
transform: translate(-50%, -50%) scale(1);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border-color);
}
.modal-close {
background: none;
border: none;
font-size: 1.5rem;
color: var(--text-secondary);
cursor: pointer;
}
.modal-body {
padding: 1.5rem;
}
.modal-footer {
padding: 1rem 1.5rem;
background: var(--bg-secondary);
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
/* ═══════════════════════════════════════════════════════════════════════════
LEAFLET MAP CUSTOMIZATION
═══════════════════════════════════════════════════════════════════════════ */
.leaflet-container {
background: var(--bg-primary) !important;
}
.player-marker {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 50%;
border: 3px solid white;
font-size: 14px;
font-weight: bold;
color: white;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
transition: transform 0.3s ease;
}
.player-marker.crew { background: var(--crew-color); }
.player-marker.impostor { background: var(--impostor-color); }
.player-marker.dead {
background: var(--dead-color);
opacity: 0.6;
}
.task-marker {
width: 20px;
height: 20px;
background: var(--task-color);
border-radius: 50%;
border: 2px solid white;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.task-marker.completed {
background: var(--text-muted);
}
.body-marker {
width: 24px;
height: 24px;
background: var(--body-color);
border-radius: 50%;
border: 2px solid white;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
}
.repair-marker {
width: 24px;
height: 24px;
background: var(--repair-color);
border-radius: 4px;
border: 2px solid white;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
}
.repair-marker.repaired {
background: var(--success);
}
/* Custom popup */
.leaflet-popup-content-wrapper {
background: var(--bg-card) !important;
border: 1px solid var(--border-color) !important;
color: var(--text-primary) !important;
border-radius: var(--radius-md) !important;
}
.leaflet-popup-tip {
background: var(--bg-card) !important;
}
/* ═══════════════════════════════════════════════════════════════════════════
RESPONSIVE
═══════════════════════════════════════════════════════════════════════════ */
@media (max-width: 1024px) {
.sidebar {
width: 80px;
}
.logo-text,
.nav-item span:not(.nav-icon):not(.nav-badge),
.nav-label,
.btn-logout span:last-child {
display: none;
}
.nav-item {
justify-content: center;
padding: 1rem;
}
.nav-badge {
position: absolute;
top: 0.5rem;
right: 0.5rem;
margin: 0;
}
.spectate-sidebar {
width: 280px;
}
}
@media (max-width: 768px) {
.spectate-container {
flex-direction: column;
}
.spectate-sidebar {
width: 100%;
height: 200px;
flex-direction: row;
overflow-x: auto;
}
.spectate-panel {
min-width: 200px;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.quick-actions {
flex-direction: column;
}
}
/* ═══════════════════════════════════════════════════════════════════════════
SCROLLBAR
═══════════════════════════════════════════════════════════════════════════ */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-primary);
}
::-webkit-scrollbar-thumb {
background: var(--bg-hover);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
/* ═══════════════════════════════════════════════════════════════════════════
ANIMATIONS
═══════════════════════════════════════════════════════════════════════════ */
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.loading {
animation: pulse 1.5s infinite;
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-5px); }
75% { transform: translateX(5px); }
}
.shake {
animation: shake 0.3s ease;
}
/* ═══════════════════════════════════════════════════════════════════════════
ARCHIVE VIEW (P13d)
═══════════════════════════════════════════════════════════════════════════ */
.archive-container {
background: var(--bg-card);
border-radius: var(--radius-md);
padding: 16px;
box-shadow: var(--shadow);
}
.archive-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 12px;
}
.archive-card {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
padding: 16px;
cursor: pointer;
transition: all 0.2s;
}
.archive-card:hover {
background: var(--bg-hover);
border-color: var(--accent-primary);
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.archive-card-title {
font-weight: 600;
color: var(--text-primary);
margin-bottom: 6px;
font-family: monospace;
font-size: 13px;
word-break: break-all;
}
.archive-card-meta {
color: var(--text-secondary);
font-size: 12px;
display: flex;
justify-content: space-between;
gap: 8px;
}
.archive-detail {
background: var(--bg-card);
border-radius: var(--radius-md);
padding: 16px;
box-shadow: var(--shadow);
}
.detail-header {
display: flex;
gap: 12px;
margin-bottom: 16px;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
}
.archive-summary {
background: var(--bg-secondary);
padding: 12px;
border-radius: var(--radius-sm);
margin-bottom: 16px;
color: var(--text-secondary);
font-size: 13px;
}
.archive-detail-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.archive-timeline {
max-height: 500px;
overflow-y: auto;
font-size: 12px;
}
.timeline-event {
padding: 8px 12px;
border-left: 3px solid var(--accent-primary);
margin-bottom: 6px;
background: var(--bg-secondary);
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
}
.timeline-event-type {
font-weight: 600;
color: var(--accent-primary);
margin-bottom: 4px;
}
.timeline-event-meta {
color: var(--text-muted);
font-size: 11px;
font-family: monospace;
}
.timeline-event-payload {
color: var(--text-secondary);
font-size: 11px;
margin-top: 4px;
white-space: pre-wrap;
font-family: monospace;
max-height: 120px;
overflow-y: auto;
}
@media (max-width: 1100px) {
.archive-detail-grid { grid-template-columns: 1fr; }
}
/* ═══════════════════════════════════════════════════════════════════════════
PRINT (P13d)
═══════════════════════════════════════════════════════════════════════════ */
@media print {
/* Reset for paper - dark theme is unprintable. */
body, html { background: white !important; color: black !important; }
/* Hide everything except the per-game detail report. */
.sidebar, .modal-overlay, .modal,
#login-screen, #view-overview, #view-lobbies, #view-spectate,
#view-players, #view-config, #view-broadcast,
#archive-list-container, .detail-header, .header-actions,
.sidebar-footer, .nav-section { display: none !important; }
.main-content { margin-left: 0 !important; padding: 0 !important; }
.view-header h1 { color: black !important; }
.archive-detail, .archive-summary, .card, .card-header, .card-body {
background: white !important;
color: black !important;
border: 1px solid #ccc !important;
box-shadow: none !important;
page-break-inside: avoid;
}
.archive-detail-grid { grid-template-columns: 1fr !important; gap: 12px !important; }
.timeline-event {
background: #f5f5f5 !important;
color: black !important;
border-left-color: #555 !important;
page-break-inside: avoid;
}
.timeline-event-type { color: #333 !important; }
.timeline-event-meta,
.timeline-event-payload { color: #555 !important; }
.archive-timeline { max-height: none !important; overflow: visible !important; }
/* Force the map to a printable size and let it overflow into multiple pages
gracefully (browsers can't print live tiles, but a static screenshot of the
canvas will be embedded by most modern browsers). */
#archive-map { height: 400px !important; page-break-inside: avoid; }
}";
public static string JavaScript => @"/* ═══════════════════════════════════════════════════════════════════════════
GeoSus Admin Panel - JavaScript
Real-time dashboard with WebSocket support
═══════════════════════════════════════════════════════════════════════════ */
// State
let statsWs = null;
let spectateWs = null;
let spectateMap = null;
let playerMarkers = {};
let taskMarkers = {};
let bodyMarkers = {};
let repairMarkers = {};
let buildingLayers = [];
let currentLobbyId = null;
let kickPlayerId = null;
// ═══════════════════════════════════════════════════════════════════════════
// INITIALIZATION
// ═══════════════════════════════════════════════════════════════════════════
document.addEventListener('DOMContentLoaded', () => {
// Login form
document.getElementById('login-form').addEventListener('submit', handleLogin);
// Navigation
document.querySelectorAll('.nav-item').forEach(item => {
item.addEventListener('click', (e) => {
e.preventDefault();
const view = item.dataset.view;
if (view) showView(view);
});
});
// Logout
document.getElementById('logout-btn').addEventListener('click', handleLogout);
// Config form
document.getElementById('config-form').addEventListener('submit', handleConfigSave);
// Broadcast form
document.getElementById('broadcast-form').addEventListener('submit', handleBroadcast);
// Player search
document.getElementById('player-search').addEventListener('input', debounce(loadPlayers, 300));
// Check if already logged in
checkAuth();
});
// ═══════════════════════════════════════════════════════════════════════════
// AUTHENTICATION
// ═══════════════════════════════════════════════════════════════════════════
async function checkAuth() {
try {
const response = await fetch('/admin/api/status');
if (response.ok) {
showDashboard();
}
} catch (e) {
// Not logged in
}
}
async function handleLogin(e) {
e.preventDefault();
const password = document.getElementById('password').value;
const errorEl = document.getElementById('login-error');
try {
const response = await fetch('/admin/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
});
if (response.ok) {
showDashboard();
} else {
errorEl.textContent = 'Nesprávné heslo';
document.getElementById('password').classList.add('shake');
setTimeout(() => document.getElementById('password').classList.remove('shake'), 300);
}
} catch (e) {
errorEl.textContent = 'Chyba připojení k serveru';
}
}
async function handleLogout() {
await fetch('/admin/api/logout');
if (statsWs) statsWs.close();
if (spectateWs) spectateWs.close();
document.getElementById('dashboard').classList.remove('active');
document.getElementById('login-screen').classList.add('active');
document.getElementById('password').value = '';
}
function showDashboard() {
document.getElementById('login-screen').classList.remove('active');
document.getElementById('dashboard').classList.add('active');
// Connect WebSocket for stats
connectStatsWebSocket();
// Load initial data
refreshLobbies();
loadConfig();
}
// ═══════════════════════════════════════════════════════════════════════════
// WEBSOCKET - SERVER STATS
// ═══════════════════════════════════════════════════════════════════════════
function connectStatsWebSocket() {
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
statsWs = new WebSocket(`${protocol}//${location.host}/admin/ws/stats`);
statsWs.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'stats') {
updateStats(data);
}
};
statsWs.onclose = () => {
setTimeout(connectStatsWebSocket, 3000);
};
}
function updateStats(data) {
document.getElementById('stat-lobbies').textContent = data.lobbies;
document.getElementById('stat-players').textContent = data.players;
document.getElementById('stat-memory').textContent = data.memoryMb + ' MB';
const uptime = formatUptime(data.uptime);
document.getElementById('stat-uptime').textContent = uptime;
document.getElementById('server-uptime').textContent = uptime;
document.getElementById('lobby-count').textContent = data.lobbies;
document.getElementById('player-count').textContent = data.players;
document.getElementById('last-update-time').textContent = new Date().toLocaleTimeString();
}
function formatUptime(seconds) {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}
// ═══════════════════════════════════════════════════════════════════════════
// VIEWS
// ═══════════════════════════════════════════════════════════════════════════
function showView(viewName) {
// If leaving spectate view, cleanup WebSocket
if (viewName !== 'spectate' && currentLobbyId) {
currentLobbyId = null; // MUST be set before close to prevent reconnect
if (spectateWs) {
spectateWs.close();
spectateWs = null;
}
}
// Update nav
document.querySelectorAll('.nav-item').forEach(item => {
item.classList.toggle('active', item.dataset.view === viewName);
});
// Update views
document.querySelectorAll('.view').forEach(view => {
view.classList.toggle('active', view.id === `view-${viewName}`);
});
// Load data for specific views
if (viewName === 'lobbies') refreshLobbies();
if (viewName === 'players') loadPlayers();
if (viewName === 'config') loadConfig();
if (viewName === 'broadcast') loadBroadcastTargets();
if (viewName === 'archive') loadArchiveList();
}
// ═══════════════════════════════════════════════════════════════════════════
// LOBBIES
// ═══════════════════════════════════════════════════════════════════════════
async function refreshLobbies() {
try {
const response = await fetch('/admin/api/lobbies');
const data = await response.json();
renderLobbies(data.lobbies);
} catch (e) {
console.error('Failed to load lobbies:', e);
}
}
function renderLobbies(lobbies) {
// Dashboard list
const listEl = document.getElementById('lobby-list');
// Full list
const fullListEl = document.getElementById('lobbies-full-list');
if (lobbies.length === 0) {
listEl.innerHTML = '<div class=""empty-state"">Žádné aktivní lobby</div>';
fullListEl.innerHTML = '<div class=""empty-state"">Žádné aktivní lobby</div>';
return;
}
const renderItem = (lobby) => `
<div class=""lobby-item"" onclick=""spectate('${lobby.id}')"">
<span class=""lobby-code"">${lobby.joinCode}</span>
<div class=""lobby-info"">
<div class=""lobby-phase"">${translatePhase(lobby.phase)}</div>
</div>
<div class=""lobby-players"">
<span>👥</span>
<span>${lobby.playerCount}</span>
</div>
<button class=""btn btn-sm btn-spectate"" onclick=""event.stopPropagation(); spectate('${lobby.id}')"">
👁️ Spectate
</button>
</div>
`;
listEl.innerHTML = lobbies.slice(0, 5).map(renderItem).join('');
fullListEl.innerHTML = lobbies.map(renderItem).join('');
}
function translatePhase(phase) {
const translations = {
'Lobby': '🏠 Lobby',
'Playing': '🎮 Hra',
'Discussion': '💬 Diskuze',
'Voting': '🗳️ Hlasování',
'Ended': '🏁 Konec'
};
return translations[phase] || phase;
}
// ═══════════════════════════════════════════════════════════════════════════
// SPECTATE
// ═══════════════════════════════════════════════════════════════════════════
function spectate(lobbyId) {
currentLobbyId = lobbyId;
showView('spectate');
// Initialize map if needed
if (!spectateMap) {
spectateMap = L.map('spectate-map', {
zoomControl: true,
attributionControl: false
}).setView([50.0, 14.0], 15);
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
maxZoom: 19
}).addTo(spectateMap);
}
// Connect WebSocket
if (spectateWs) spectateWs.close();
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
spectateWs = new WebSocket(`${protocol}//${location.host}/admin/ws/spectate/${lobbyId}`);
spectateWs.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'state') {
updateSpectateView(data.lobby);
}
};
spectateWs.onerror = (err) => {
console.error('Spectate WebSocket error:', err);
};
spectateWs.onclose = () => {
// Only reconnect if we're still viewing this specific lobby
// currentLobbyId is set to null when user clicks Back
if (currentLobbyId === lobbyId && document.getElementById('view-spectate').classList.contains('active')) {
console.log('Spectate WebSocket closed, reconnecting in 3s...');
setTimeout(() => {
// Double-check we still want to reconnect
if (currentLobbyId === lobbyId) {
spectate(lobbyId);
}
}, 3000);
}
};
}
function updateSpectateView(lobby) {
// Update header
document.getElementById('spectate-lobby-code').textContent = lobby.joinCode;
const phaseEl = document.getElementById('spectate-phase');
phaseEl.textContent = translatePhase(lobby.phase);
phaseEl.className = 'spectate-phase ' + lobby.phase.toLowerCase();
// Update map center if needed
if (lobby.settings && lobby.settings.center) {
const center = [lobby.settings.center.lat, lobby.settings.center.lng];
if (spectateMap.getZoom() < 10) {
spectateMap.setView(center, 16);
}
// Draw play area circle
if (!spectateMap.playAreaCircle) {
spectateMap.playAreaCircle = L.circle(center, {
radius: lobby.settings.radius,
color: '#00d4ff',
fillColor: '#00d4ff',
fillOpacity: 0.05,
weight: 2,
dashArray: '5, 10'
}).addTo(spectateMap);
}
}
// Update players
updatePlayersOnMap(lobby.players);
updatePlayersList(lobby.players);
// Update tasks
updateTasksOnMap(lobby.tasks);
updateTaskProgress(lobby.tasks, lobby.players);
// Update bodies
updateBodiesOnMap(lobby.bodies);
// Update sabotage
updateSabotage(lobby.sabotage);
// Update votes
updateVotes(lobby.votes, lobby.players, lobby.phase);
// Render buildings from map data
if (lobby.mapData && buildingLayers.length === 0) {
renderBuildings(lobby.mapData);
}
}
function updatePlayersOnMap(players) {
const existingIds = new Set();
players.forEach(player => {
existingIds.add(player.id);
const roleClass = player.role.toLowerCase();
const isAlive = player.state === 'Alive';
if (playerMarkers[player.id]) {
// Update position
playerMarkers[player.id].setLatLng([player.lat, player.lng]);
} else {
// Create marker
const icon = L.divIcon({
className: 'player-marker-container',
html: `<div class=""player-marker ${roleClass} ${isAlive ? '' : 'dead'}"">${player.name.charAt(0).toUpperCase()}</div>`,
iconSize: [32, 32],
iconAnchor: [16, 16]
});
playerMarkers[player.id] = L.marker([player.lat, player.lng], { icon })
.bindPopup(`<b>${player.name}</b><br>Role: ${player.role}<br>State: ${player.state}`)
.addTo(spectateMap);
}
});
// Remove old markers
Object.keys(playerMarkers).forEach(id => {
if (!existingIds.has(id)) {
spectateMap.removeLayer(playerMarkers[id]);
delete playerMarkers[id];
}
});
}
function updatePlayersList(players) {
const listEl = document.getElementById('spectate-players');
listEl.innerHTML = players.map(p => `
<div class=""player-item ${p.state !== 'Alive' ? 'dead' : ''}"">
<span class=""player-role ${p.role.toLowerCase()}""></span>
<span class=""player-name"">${p.name}</span>
<span class=""player-state"">${p.state}</span>
</div>
`).join('');
}
function updateTasksOnMap(tasks) {
if (!tasks) return;
tasks.forEach(task => {
const isComplete = task.completedBy && task.completedBy.length > 0;
if (!taskMarkers[task.id]) {
const icon = L.divIcon({
className: 'task-marker-container',
html: `<div class=""task-marker ${isComplete ? 'completed' : ''}""></div>`,
iconSize: [20, 20],
iconAnchor: [10, 10]
});
taskMarkers[task.id] = L.marker([task.lat, task.lng], { icon })
.bindPopup(`<b>${task.name}</b>`)
.addTo(spectateMap);
}
});
}
function updateTaskProgress(tasks, players) {
if (!tasks) return;
const crewPlayers = players.filter(p => p.role === 'Crew');
const totalRequired = tasks.length * crewPlayers.length;
const completed = tasks.reduce((sum, t) => sum + (t.completedBy ? t.completedBy.length : 0), 0);
const percent = totalRequired > 0 ? (completed / totalRequired) * 100 : 0;
document.getElementById('task-progress-fill').style.width = percent + '%';
document.getElementById('task-progress-text').textContent = `${completed}/${totalRequired}`;
}
function updateBodiesOnMap(bodies) {
if (!bodies) return;
const existingIds = new Set();
bodies.forEach(body => {
existingIds.add(body.victimId);
if (!bodyMarkers[body.victimId]) {
const icon = L.divIcon({
className: 'body-marker-container',
html: '<div class=""body-marker"">💀</div>',
iconSize: [24, 24],
iconAnchor: [12, 12]
});
bodyMarkers[body.victimId] = L.marker([body.lat, body.lng], { icon })
.addTo(spectateMap);
}
});
// Remove reported bodies
Object.keys(bodyMarkers).forEach(id => {
if (!existingIds.has(id)) {
spectateMap.removeLayer(bodyMarkers[id]);
delete bodyMarkers[id];
}
});
}
function updateSabotage(sabotage) {
const panel = document.getElementById('spectate-sabotage-panel');
const info = document.getElementById('spectate-sabotage');
if (!sabotage) {
panel.style.display = 'none';
// Clear repair markers
Object.values(repairMarkers).forEach(m => spectateMap.removeLayer(m));
repairMarkers = {};
return;
}
panel.style.display = 'block';
let timerHtml = '';
if (sabotage.deadline) {
const remaining = Math.max(0, Math.floor((new Date(sabotage.deadline) - new Date()) / 1000));
timerHtml = `<div class=""sabotage-timer"">${remaining}s</div>`;
}
info.innerHTML = `
<div class=""sabotage-type"">⚠️ ${sabotage.type}</div>
${timerHtml}
`;
// Show repair stations
if (sabotage.repairStations) {
sabotage.repairStations.forEach(rs => {
if (!repairMarkers[rs.id]) {
const icon = L.divIcon({
className: 'repair-marker-container',
html: `<div class=""repair-marker ${rs.isRepaired ? 'repaired' : ''}"">🔧</div>`,
iconSize: [24, 24],
iconAnchor: [12, 12]
});
repairMarkers[rs.id] = L.marker([rs.lat, rs.lng], { icon }).addTo(spectateMap);
}
});
}
}
function updateVotes(votes, players, phase) {
const panel = document.getElementById('spectate-votes-panel');
const display = document.getElementById('spectate-votes');
if (phase !== 'Voting' || !votes) {
panel.style.display = 'none';
return;
}
panel.style.display = 'block';
// Count votes
const voteCounts = {};
Object.values(votes).forEach(targetId => {
voteCounts[targetId] = (voteCounts[targetId] || 0) + 1;
});
// Get player names
const playerNames = {};
players.forEach(p => playerNames[p.id] = p.name);
display.innerHTML = Object.entries(voteCounts)
.sort((a, b) => b[1] - a[1])
.map(([targetId, count]) => `
<div class=""vote-row"">
<span class=""vote-target"">${targetId === 'skip' ? '⏭️ Skip' : playerNames[targetId] || targetId}</span>
<span class=""vote-count"">${count}</span>
</div>
`).join('');
}
function renderBuildings(mapData) {
if (!mapData || !mapData.elements) return;
mapData.elements
.filter(el => el.type === 'way' && el.tags && (el.tags.building || el.tags.amenity))
.forEach(way => {
if (way.geometry) {
const coords = way.geometry.map(p => [p.lat, p.lon]);
const polygon = L.polygon(coords, {
color: '#00d4ff',
weight: 1,
fillColor: '#1a1a2e',
fillOpacity: 0.7
}).addTo(spectateMap);
buildingLayers.push(polygon);
}
});
}
// ═══════════════════════════════════════════════════════════════════════════
// PLAYERS MANAGEMENT
// ═══════════════════════════════════════════════════════════════════════════
async function loadPlayers() {
const search = document.getElementById('player-search').value;
try {
const response = await fetch(`/admin/api/players?search=${encodeURIComponent(search)}`);
const data = await response.json();
renderPlayersTable(data.players);
} catch (e) {
console.error('Failed to load players:', e);
}
}
function renderPlayersTable(players) {
const tbody = document.getElementById('players-table-body');
if (players.length === 0) {
tbody.innerHTML = '<tr><td colspan=""7"" class=""empty-state"">Žádní hráči online</td></tr>';
return;
}
tbody.innerHTML = players.map(p => `
<tr>
<td><strong>${p.displayName}</strong></td>
<td><code>${p.playerId.substring(0, 8)}</code></td>
<td><span class=""lobby-code"">${p.joinCode}</span></td>
<td><span class=""role-badge ${p.role.toLowerCase()}"">${p.role}</span></td>
<td>${p.state}</td>
<td><span class=""cheat-score ${getCheatScoreClass(p.cheatScore)}"">${p.cheatScore}</span></td>
<td>
<button class=""btn btn-sm btn-danger"" onclick=""showKickModal('${p.playerId}', '${p.displayName}')"">Kick</button>
</td>
</tr>
`).join('');
}
function getCheatScoreClass(score) {
if (score < 10) return 'low';
if (score < 25) return 'medium';
return 'high';
}
function showKickModal(playerId, playerName) {
kickPlayerId = playerId;
document.getElementById('kick-player-name').textContent = playerName;
document.getElementById('kick-reason').value = '';
document.getElementById('modal-overlay').classList.add('active');
document.getElementById('kick-modal').classList.add('active');
}
function closeModal() {
document.getElementById('modal-overlay').classList.remove('active');
document.querySelectorAll('.modal').forEach(m => m.classList.remove('active'));
}
async function confirmKick() {
const reason = document.getElementById('kick-reason').value || 'Kicked by admin';
try {
await fetch('/admin/api/kick', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ playerId: kickPlayerId, reason })
});
closeModal();
loadPlayers();
} catch (e) {
alert('Nepodařilo se vyhodit hráče');
}
}
// ═══════════════════════════════════════════════════════════════════════════
// CONFIG
// ═══════════════════════════════════════════════════════════════════════════
async function loadConfig() {
try {
const response = await fetch('/admin/api/config');
const config = await response.json();
const form = document.getElementById('config-form');
Object.entries(config).forEach(([key, value]) => {
const input = form.querySelector(`[name=""${key}""]`);
if (input) input.value = value;
});
} catch (e) {
console.error('Failed to load config:', e);
}
}
async function handleConfigSave(e) {
e.preventDefault();
const form = e.target;
const formData = new FormData(form);
const config = {};
formData.forEach((value, key) => {
config[key] = parseFloat(value) || parseInt(value) || value;
});
try {
await fetch('/admin/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
alert('Konfigurace uložena!');
} catch (e) {
alert('Nepodařilo se uložit konfiguraci');
}
}
// ═══════════════════════════════════════════════════════════════════════════
// BROADCAST
// ═══════════════════════════════════════════════════════════════════════════
async function loadBroadcastTargets() {
try {
const response = await fetch('/admin/api/lobbies');
const data = await response.json();
const select = document.getElementById('broadcast-target');
select.innerHTML = '<option value="""">Všechny lobby</option>' +
data.lobbies.map(l => `<option value=""${l.id}"">${l.joinCode} (${l.playerCount} hráčů)</option>`).join('');
} catch (e) {
console.error('Failed to load broadcast targets:', e);
}
}
async function handleBroadcast(e) {
e.preventDefault();
const lobbyId = document.getElementById('broadcast-target').value;
const message = document.getElementById('broadcast-message').value;
if (!message.trim()) {
alert('Zadejte zprávu');
return;
}
try {
await fetch('/admin/api/broadcast', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ lobbyId: lobbyId || null, message })
});
document.getElementById('broadcast-message').value = '';
alert('Zpráva odeslána!');
} catch (e) {
alert('Nepodařilo se odeslat zprávu');
}
}
function showBroadcastModal() {
showView('broadcast');
}
// ═══════════════════════════════════════════════════════════════════════════
// UTILITIES
// ═══════════════════════════════════════════════════════════════════════════
function debounce(func, wait) {
let timeout;
return function(...args) {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this, args), wait);
};
}
function escapeHtml(s) {
if (s == null) return '';
return String(s).replace(/[&<>""]/g, c => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '""': '&quot;'
})[c]);
}
// ═══════════════════════════════════════════════════════════════════════════
// ARCHIVE (P13d)
// ═══════════════════════════════════════════════════════════════════════════
let archiveMap = null;
let currentArchiveId = null;
async function loadArchiveList() {
const container = document.getElementById('archive-list');
if (!container) return;
container.innerHTML = '<div class=""empty-state loading"">Načítám...</div>';
try {
const response = await fetch('/admin/api/archive');
const data = await response.json();
if (!data.archives || data.archives.length === 0) {
container.innerHTML = '<div class=""empty-state"">Žádné archivované hry</div>';
return;
}
// Render via data attributes + delegated click handler so we don't
// have to embed user-controlled values into an inline onclick string
// (which is a quoting nightmare given the verbatim C# wrapping).
container.innerHTML = data.archives.map(a => {
const ts = a.timestamp ? new Date(a.timestamp).toLocaleString() : '?';
const sizeKb = a.sizeBytes ? Math.round(a.sizeBytes / 1024) + ' KB' : '?';
const lobbyDisplay = escapeHtml(a.lobbyId || '');
return '<div class=""archive-card"" data-id=""' + escapeHtml(a.id) + '"" data-lobby=""' + lobbyDisplay + '"" data-ts=""' + escapeHtml(ts) + '"">' +
'<div class=""archive-card-title"">' + lobbyDisplay + '</div>' +
'<div class=""archive-card-meta""><span>' + escapeHtml(ts) + '</span><span>' + a.eventCount + ' eventů, ' + sizeKb + '</span></div>' +
'</div>';
}).join('');
container.querySelectorAll('.archive-card').forEach(card => {
card.addEventListener('click', () => {
openArchiveDetail(card.dataset.id, card.dataset.lobby, card.dataset.ts);
});
});
} catch (e) {
container.innerHTML = '<div class=""empty-state"">Chyba načítání archivu</div>';
console.error('loadArchiveList:', e);
}
}
async function openArchiveDetail(id, lobbyId, ts) {
currentArchiveId = id;
document.getElementById('archive-list-container').style.display = 'none';
document.getElementById('archive-detail-container').style.display = 'block';
document.getElementById('archive-detail-title').textContent = 'Archiv: ' + lobbyId;
document.getElementById('archive-detail-summary').innerHTML =
'<strong>Lobby ID:</strong> ' + escapeHtml(lobbyId) +
' &nbsp;|&nbsp; <strong>Archivováno:</strong> ' + escapeHtml(ts);
// Lazy-init the Leaflet map. Subsequent opens reuse the same instance and
// just clear out the previously-drawn polylines/markers.
if (!archiveMap) {
archiveMap = L.map('archive-map').setView([0, 0], 2);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap',
maxZoom: 19
}).addTo(archiveMap);
} else {
archiveMap.eachLayer(l => {
if (l instanceof L.Polyline || l instanceof L.CircleMarker || l instanceof L.Marker) {
archiveMap.removeLayer(l);
}
});
}
// Force layout recalculation after the container becomes visible.
setTimeout(() => archiveMap && archiveMap.invalidateSize(), 100);
try {
const [eventsRes, positionsRes] = await Promise.all([
fetch('/admin/api/archive/' + encodeURIComponent(currentArchiveId) + '/events'),
fetch('/admin/api/archive/' + encodeURIComponent(currentArchiveId) + '/positions')
]);
const events = await eventsRes.json();
const positions = await positionsRes.json();
renderArchiveTimeline(events);
renderArchivePolylines(positions);
} catch (e) {
console.error('openArchiveDetail:', e);
}
}
function closeArchiveDetail() {
document.getElementById('archive-list-container').style.display = 'block';
document.getElementById('archive-detail-container').style.display = 'none';
currentArchiveId = null;
}
function renderArchiveTimeline(events) {
const container = document.getElementById('archive-timeline');
if (!container) return;
if (!events || !Array.isArray(events) || events.length === 0) {
container.innerHTML = '<div class=""empty-state"">Žádné eventy</div>';
return;
}
container.innerHTML = events.map(e => {
const ts = e.timestamp ? new Date(e.timestamp).toLocaleTimeString() : '?';
const payload = e.payload ? escapeHtml(JSON.stringify(e.payload, null, 2)) : '';
const actor = e.actor ? ' (actor: ' + escapeHtml(String(e.actor).substring(0, 8)) + ')' : '';
const type = escapeHtml(e.eventType || '?');
return '<div class=""timeline-event"">' +
'<div class=""timeline-event-type"">' + type + '</div>' +
'<div class=""timeline-event-meta"">[' + ts + ']' + actor + ' #' + (e.eventId || '?') + '</div>' +
(payload ? '<div class=""timeline-event-payload"">' + payload + '</div>' : '') +
'</div>';
}).join('');
}
function renderArchivePolylines(data) {
if (!data || !data.polylines || data.polylines.length === 0) return;
const colors = ['#00d4ff', '#7c3aed', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#3b82f6', '#ec4899'];
let bounds = null;
data.polylines.forEach((player, idx) => {
if (!player.points || player.points.length === 0) return;
const latlngs = player.points.map(p => [p.lat, p.lon]);
const color = colors[idx % colors.length];
const label = player.displayName || player.playerId || '?';
const line = L.polyline(latlngs, { color: color, weight: 3, opacity: 0.8 })
.bindTooltip(label)
.addTo(archiveMap);
L.circleMarker(latlngs[0], { radius: 6, color: color, fillColor: color, fillOpacity: 1, weight: 1 })
.bindTooltip(label + ' (start)')
.addTo(archiveMap);
if (latlngs.length > 1) {
L.circleMarker(latlngs[latlngs.length - 1], { radius: 6, color: color, fillColor: '#fff', fillOpacity: 1, weight: 2 })
.bindTooltip(label + ' (end)')
.addTo(archiveMap);
}
if (!bounds) bounds = line.getBounds();
else bounds.extend(line.getBounds());
});
if (bounds && bounds.isValid()) archiveMap.fitBounds(bounds.pad(0.15));
}";
}