namespace GeoSus.Server; /// /// Embedded resources pro Admin Panel /// HTML, CSS a JavaScript jako stringy pro snadné servování bez externích souborů /// public static class AdminResources { public static string Html => @" GeoSus Admin Panel
🌍

GeoSus

Admin Panel

🔐

Dashboard

Aktualizováno: --:--:--
🎮
0
Aktivní lobby
👥
0
Hráčů online
⏱️
0:00:00
Uptime
💾
0 MB
Paměť

Aktivní Lobby

Žádné aktivní lobby

Rychlé akce

Správa Lobby

Spectate: ---

---
Crew
Impostor
Dead
Task
Body
Repair

👥 Hráči

🗳️ Hlasování

⚠️ Sabotáž

📋 Tasks

0/0

Správa hráčů

Jméno ID Lobby Role Stav Cheat Score Akce

Konfigurace serveru

🎯 Gameplay

🗳️ Meeting

⚠️ Sabotáž

Broadcast zpráva

Archiv her

Načítám archivované hry...

Archiv hry

📍 Trasy hráčů

📋 Časová osa

Kick hráče

Opravdu chcete vyhodit hráče ?

"; 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-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 = '
Žádné aktivní lobby
'; fullListEl.innerHTML = '
Žádné aktivní lobby
'; return; } const renderItem = (lobby) => `
${lobby.joinCode}
${translatePhase(lobby.phase)}
👥 ${lobby.playerCount}
`; 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: `
${player.name.charAt(0).toUpperCase()}
`, iconSize: [32, 32], iconAnchor: [16, 16] }); playerMarkers[player.id] = L.marker([player.lat, player.lng], { icon }) .bindPopup(`${player.name}
Role: ${player.role}
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 => `
${p.name} ${p.state}
`).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: `
`, iconSize: [20, 20], iconAnchor: [10, 10] }); taskMarkers[task.id] = L.marker([task.lat, task.lng], { icon }) .bindPopup(`${task.name}`) .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: '
💀
', 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 = `
${remaining}s
`; } info.innerHTML = `
⚠️ ${sabotage.type}
${timerHtml} `; // Show repair stations if (sabotage.repairStations) { sabotage.repairStations.forEach(rs => { if (!repairMarkers[rs.id]) { const icon = L.divIcon({ className: 'repair-marker-container', html: `
🔧
`, 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]) => `
${targetId === 'skip' ? '⏭️ Skip' : playerNames[targetId] || targetId} ${count}
`).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 = 'Žádní hráči online'; return; } tbody.innerHTML = players.map(p => ` ${p.displayName} ${p.playerId.substring(0, 8)} ${p.joinCode} ${p.role} ${p.state} ${p.cheatScore} `).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 = '' + data.lobbies.map(l => ``).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 => ({ '&': '&', '<': '<', '>': '>', '""': '"' })[c]); } // ═══════════════════════════════════════════════════════════════════════════ // ARCHIVE (P13d) // ═══════════════════════════════════════════════════════════════════════════ let archiveMap = null; let currentArchiveId = null; async function loadArchiveList() { const container = document.getElementById('archive-list'); if (!container) return; container.innerHTML = '
Načítám...
'; try { const response = await fetch('/admin/api/archive'); const data = await response.json(); if (!data.archives || data.archives.length === 0) { container.innerHTML = '
Žádné archivované hry
'; 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 '
' + '
' + lobbyDisplay + '
' + '
' + escapeHtml(ts) + '' + a.eventCount + ' eventů, ' + sizeKb + '
' + '
'; }).join(''); container.querySelectorAll('.archive-card').forEach(card => { card.addEventListener('click', () => { openArchiveDetail(card.dataset.id, card.dataset.lobby, card.dataset.ts); }); }); } catch (e) { container.innerHTML = '
Chyba načítání archivu
'; 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 = 'Lobby ID: ' + escapeHtml(lobbyId) + '  |  Archivováno: ' + 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 = '
Žádné eventy
'; 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 '
' + '
' + type + '
' + '
[' + ts + ']' + actor + ' #' + (e.eventId || '?') + '
' + (payload ? '
' + payload + '
' : '') + '
'; }).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)); }"; }