diff --git a/AdminPanel.cs b/AdminPanel.cs index 5b6e75b..e6a2b79 100644 --- a/AdminPanel.cs +++ b/AdminPanel.cs @@ -150,7 +150,22 @@ public class AdminPanel await HandleStatsWebSocketAsync(context, sessionId); return; } - + + // P13d: archive endpoints with archive id in the URL path + // Layout: /admin/api/archive//events and /admin/api/archive//positions + if (path.StartsWith("/admin/api/archive/") && path.EndsWith("/events")) + { + var archiveId = path["/admin/api/archive/".Length..^"/events".Length]; + await HandleArchiveEventsAsync(archiveId, response); + return; + } + if (path.StartsWith("/admin/api/archive/") && path.EndsWith("/positions")) + { + var archiveId = path["/admin/api/archive/".Length..^"/positions".Length]; + await HandleArchivePositionsAsync(archiveId, response); + return; + } + // REST API endpointy switch (path) { @@ -194,6 +209,11 @@ public class AdminPanel await HandleLobbyUpdateSettingsAsync(request, response); break; + // ── P13d: archive listing endpoint ─────────────────────── + case "/admin/api/archive": + await HandleListArchivesAsync(response); + break; + case "/admin/api/logout": _sessions.TryRemove(sessionId, out _); response.SetCookie(new Cookie("geosus_admin_session", "", "/admin") { Expired = true }); @@ -330,44 +350,114 @@ public class AdminPanel await WriteJsonAsync(response, new { lobbies }); } + /// + /// P13b: full ServerConfig round-trip. The previous hand-rolled selective + /// serialization only exposed ~10 fields; the admin panel now needs the + /// entire appsettings.json shape (~50 fields). We serialize through the + /// same JsonOptions the file loader uses so camelCase / enum-as-string + /// stays consistent. The StatsApiKey is masked in the GET response so + /// the secret doesn't leak into a browser dump. + /// private async Task HandleGetConfigAsync(HttpListenerResponse response) { - // Vrátíme pouze bezpečné konfigurace (ne hesla apod.) - var config = new + // Round-trip through JSON to get a clean copy + apply the server's + // own naming policy / enum converters. Mask StatsApiKey on the way out. + var json = JsonSerializer.Serialize(_config, JsonOptions.Indented); + var node = JsonDocument.Parse(json).RootElement; + + // Re-emit with the api-key masked. + using var ms = new MemoryStream(); + using (var writer = new Utf8JsonWriter(ms, new JsonWriterOptions { Indented = true })) { - tcpPort = _config.TcpPort, - httpPort = _config.HttpPort, - maxPlayersPerLobby = _config.MaxPlayersPerLobby, - killDistanceM = _config.KillDistanceM, - killCooldownMs = _config.KillCooldownMs, - discussionPhaseMs = _config.DiscussionPhaseMs, - votingPhaseMs = _config.VotingPhaseMs, - taskStartDistanceM = _config.TaskStartDistanceM, - sabotageCooldownMs = _config.SabotageCooldownMs, - criticalMeltdownDeadlineMs = _config.CriticalMeltdownDeadlineMs, - maxSpeedMps = _config.MaxSpeedMps, - teleportThresholdMeters = _config.TeleportThresholdMeters - }; - - await WriteJsonAsync(response, config); + writer.WriteStartObject(); + foreach (var prop in node.EnumerateObject()) + { + if (prop.NameEquals("statsApiKey")) + { + var raw = prop.Value.ValueKind == JsonValueKind.String ? prop.Value.GetString() : null; + if (string.IsNullOrEmpty(raw)) writer.WriteNull("statsApiKey"); + else writer.WriteString("statsApiKey", "********"); + continue; + } + prop.WriteTo(writer); + } + writer.WriteEndObject(); + } + + response.ContentType = "application/json; charset=utf-8"; + var bytes = ms.ToArray(); + response.ContentLength64 = bytes.Length; + await response.OutputStream.WriteAsync(bytes); } - + + /// + /// P13b: accept arbitrary subset of ServerConfig fields. Only updates + /// fields the request actually carries; ignores unknown fields. Persists + /// the file at appsettings.json on every successful update so changes + /// survive restart. Apply rule: changes affect ONLY new lobbies created + /// after this point - existing lobbies keep their snapshot of the values + /// taken at their creation time. (See LobbySettings.FromDefaults.) + /// private async Task HandleSetConfigAsync(HttpListenerRequest request, HttpListenerResponse response) { using var reader = new StreamReader(request.InputStream); var body = await reader.ReadToEndAsync(); var data = JsonSerializer.Deserialize(body); - - // Aplikuj změny - if (data.TryGetProperty("killDistanceM", out var kd)) _config.KillDistanceM = kd.GetDouble(); - if (data.TryGetProperty("killCooldownMs", out var kc)) _config.KillCooldownMs = kc.GetInt32(); - if (data.TryGetProperty("discussionPhaseMs", out var dp)) _config.DiscussionPhaseMs = dp.GetInt32(); - if (data.TryGetProperty("votingPhaseMs", out var vp)) _config.VotingPhaseMs = vp.GetInt32(); - if (data.TryGetProperty("maxSpeedMps", out var ms)) _config.MaxSpeedMps = ms.GetDouble(); - - _logger.LogInformation("Config updated via admin panel"); - - await WriteJsonAsync(response, new { success = true }); + + // Reflection-based update: walk every public settable property on + // ServerConfig, look for a matching camelCased field in the JSON, + // try to coerce. Skip the masked key sentinel so the user can leave + // ******** in the form without nuking their real key. + var props = typeof(ServerConfig).GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance); + int applied = 0; + foreach (var p in props) + { + if (!p.CanWrite) continue; + var camel = char.ToLowerInvariant(p.Name[0]) + p.Name[1..]; + if (!data.TryGetProperty(camel, out var el)) continue; + + try + { + if (p.Name == nameof(ServerConfig.StatsApiKey) && + el.ValueKind == JsonValueKind.String && el.GetString() == "********") + { + continue; // masked sentinel - leave existing + } + + object? val = (Type.GetTypeCode(p.PropertyType)) switch + { + TypeCode.Int32 => el.ValueKind == JsonValueKind.Number ? el.GetInt32() : int.TryParse(el.GetString(), out var i) ? i : (object?)null, + TypeCode.Int64 => el.ValueKind == JsonValueKind.Number ? el.GetInt64() : long.TryParse(el.GetString(), out var l) ? l : (object?)null, + TypeCode.Double => el.ValueKind == JsonValueKind.Number ? el.GetDouble() : double.TryParse(el.GetString(), out var d) ? d : (object?)null, + TypeCode.Boolean => el.ValueKind == JsonValueKind.True ? true : el.ValueKind == JsonValueKind.False ? false : (object?)null, + TypeCode.String => el.ValueKind == JsonValueKind.String ? el.GetString() : el.ValueKind == JsonValueKind.Null ? null : (object?)null, + _ => p.PropertyType.IsEnum && el.ValueKind == JsonValueKind.String && Enum.TryParse(p.PropertyType, el.GetString(), true, out var ev) ? ev : null + }; + if (val != null || p.PropertyType == typeof(string)) + { + p.SetValue(_config, val); + applied++; + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Admin SetConfig: failed to set {Property}", p.Name); + } + } + + // Persist to disk so the change survives restart. + try + { + _config.Save("appsettings.json"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to persist admin config update to appsettings.json"); + } + + _logger.LogInformation("Config updated via admin panel: {Count} field(s)", applied); + + await WriteJsonAsync(response, new { success = true, fieldsApplied = applied }); } private async Task HandlePlayersAsync(HttpListenerRequest request, HttpListenerResponse response) @@ -552,7 +642,285 @@ public class AdminPanel await WriteJsonAsync(response, new { success = true }); } - + + #region ═══════════════════════════════════════════════════════════════════ + // P13d: ARCHIVE + // ════════════════════════════════════════════════════════════════════════ + #endregion + + /// + /// P13d: list every archived lobby on disk. Persistence.ArchiveLobby + /// drops them at /archive/_/. We parse + /// the directory name back into (lobbyId, timestamp) for the UI, count + /// the WAL events, and report on-disk size so the operator can spot + /// abnormally-large or empty runs at a glance. + /// + private async Task HandleListArchivesAsync(HttpListenerResponse response) + { + var archiveDir = Path.Combine(_config.DataPath, "archive"); + if (!Directory.Exists(archiveDir)) + { + await WriteJsonAsync(response, new { archives = Array.Empty() }); + return; + } + + var archives = Directory.GetDirectories(archiveDir) + .Select(d => new DirectoryInfo(d)) + .OrderByDescending(d => d.CreationTimeUtc) + .Select(d => + { + var name = d.Name; + var lastUnderscore = name.LastIndexOf('_'); + string lobbyId = name; + string timestampRaw = ""; + if (lastUnderscore > 0) + { + lobbyId = name[..lastUnderscore]; + timestampRaw = name[(lastUnderscore + 1)..]; + } + + string isoTimestamp = ""; + if (DateTime.TryParseExact(timestampRaw, "yyyyMMddHHmmss", + System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal, + out var parsedTs)) + { + isoTimestamp = parsedTs.ToString("o"); + } + else + { + // Fallback: directory creation time. + isoTimestamp = d.CreationTimeUtc.ToString("o"); + } + + int eventCount = 0; + long sizeBytes = 0; + try + { + foreach (var w in Directory.GetFiles(d.FullName, "wal_*.ndjson")) + { + var fi = new FileInfo(w); + sizeBytes += fi.Length; + eventCount += File.ReadLines(w).Count(l => !string.IsNullOrWhiteSpace(l)); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to stat archive {Dir}", d.FullName); + } + + return new + { + id = name, + lobbyId, + timestamp = isoTimestamp, + eventCount, + sizeBytes + }; + }) + .ToList(); + + await WriteJsonAsync(response, new { archives }); + } + + /// + /// P13d: stream a single archive's WAL events back to the admin panel as + /// a JSON array. The on-disk format is NDJSON (one already-serialized + /// GameEvent per line); we glue the lines with commas inside [] rather + /// than re-parsing each one - the admin panel doesn't need the parsed + /// tree on the server side, and zero-copy keeps memory usage bounded + /// even for hour-long lobbies. + /// + private async Task HandleArchiveEventsAsync(string archiveId, HttpListenerResponse response) + { + if (!IsSafeArchiveId(archiveId)) + { + response.StatusCode = 400; + await WriteJsonAsync(response, new { error = "Invalid archive id" }); + return; + } + + var archivePath = Path.Combine(_config.DataPath, "archive", archiveId); + if (!Directory.Exists(archivePath)) + { + response.StatusCode = 404; + await WriteJsonAsync(response, new { error = "Archive not found" }); + return; + } + + var walFiles = Directory.GetFiles(archivePath, "wal_*.ndjson") + .OrderBy(f => f) + .ToList(); + + // Buffer the whole array in memory so we can set Content-Length and + // avoid chunked transfer headaches. Typical lobby archives are well + // under 10MB; if we ever need to stream, swap in chunked-encoding here. + var sb = new StringBuilder(); + sb.Append('['); + bool first = true; + foreach (var w in walFiles) + { + string[] lines; + try + { + lines = await File.ReadAllLinesAsync(w); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to read archive WAL {Path}", w); + continue; + } + foreach (var line in lines) + { + if (string.IsNullOrWhiteSpace(line)) continue; + if (!first) sb.Append(','); + sb.Append(line); + first = false; + } + } + sb.Append(']'); + + response.ContentType = "application/json; charset=utf-8"; + var bytes = Encoding.UTF8.GetBytes(sb.ToString()); + response.ContentLength64 = bytes.Length; + await response.OutputStream.WriteAsync(bytes); + } + + /// + /// P13d: extract per-player polylines from an archive's WAL. We don't + /// persist UpdatePosition (high-rate client telemetry), so we reconstruct + /// movement traces from the events that DO carry coordinates: kill/body/ + /// task-start/meeting-call/etc. Each event with a `location` or `position` + /// payload is attributed to the actor (preferred) or the victim/clientUuid + /// inside the payload, then stitched into a per-player time-ordered list + /// the frontend renders as a Leaflet polyline. + /// + private async Task HandleArchivePositionsAsync(string archiveId, HttpListenerResponse response) + { + if (!IsSafeArchiveId(archiveId)) + { + response.StatusCode = 400; + await WriteJsonAsync(response, new { error = "Invalid archive id" }); + return; + } + + var archivePath = Path.Combine(_config.DataPath, "archive", archiveId); + if (!Directory.Exists(archivePath)) + { + response.StatusCode = 404; + await WriteJsonAsync(response, new { error = "Archive not found" }); + return; + } + + var walFiles = Directory.GetFiles(archivePath, "wal_*.ndjson") + .OrderBy(f => f) + .ToList(); + + var polylines = new Dictionary>(); + var playerNames = new Dictionary(); + + foreach (var w in walFiles) + { + string[] lines; + try + { + lines = await File.ReadAllLinesAsync(w); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to read archive WAL {Path}", w); + continue; + } + + foreach (var line in lines) + { + if (string.IsNullOrWhiteSpace(line)) continue; + + JsonDocument? doc = null; + try { doc = JsonDocument.Parse(line); } + catch { continue; } + using (doc) + { + var root = doc.RootElement; + string eventType = root.TryGetProperty("eventType", out var et) ? (et.GetString() ?? "") : ""; + string actor = root.TryGetProperty("actor", out var a) ? (a.GetString() ?? "") : ""; + string ts = root.TryGetProperty("timestamp", out var t) ? (t.GetString() ?? "") : ""; + + if (!root.TryGetProperty("payload", out var payload) || payload.ValueKind != JsonValueKind.Object) + continue; + + // Capture display names from PlayerJoined for legend labels. + if (eventType == "PlayerJoined") + { + string cid = payload.TryGetProperty("clientUuid", out var c) ? (c.GetString() ?? "") : ""; + string dn = payload.TryGetProperty("displayName", out var d) ? (d.GetString() ?? "") : ""; + if (!string.IsNullOrEmpty(cid) && !string.IsNullOrEmpty(dn)) + playerNames[cid] = dn; + continue; + } + + // Find a (lat,lon) pair in either `location` or `position`. + double? lat = null, lon = null; + if (payload.TryGetProperty("location", out var loc) && loc.ValueKind == JsonValueKind.Object) + { + if (loc.TryGetProperty("lat", out var la) && la.ValueKind == JsonValueKind.Number) lat = la.GetDouble(); + if (loc.TryGetProperty("lon", out var lo) && lo.ValueKind == JsonValueKind.Number) lon = lo.GetDouble(); + } + else if (payload.TryGetProperty("position", out var pos) && pos.ValueKind == JsonValueKind.Object) + { + if (pos.TryGetProperty("lat", out var la) && la.ValueKind == JsonValueKind.Number) lat = la.GetDouble(); + if (pos.TryGetProperty("lon", out var lo) && lo.ValueKind == JsonValueKind.Number) lon = lo.GetDouble(); + } + + if (!lat.HasValue || !lon.HasValue) continue; + + // Attribute the position to a specific player. PlayerKilled + // gives us the victim's last position (more useful than the + // killer's for trace reconstruction); everything else falls + // through to actor / payload.clientUuid. + string? pid = null; + if (eventType == "PlayerKilled") + pid = payload.TryGetProperty("victimId", out var v) ? v.GetString() : null; + else if (!string.IsNullOrEmpty(actor)) + pid = actor; + else if (payload.TryGetProperty("clientUuid", out var c2)) + pid = c2.GetString(); + + if (string.IsNullOrEmpty(pid)) continue; + + if (!polylines.TryGetValue(pid, out var list)) + { + list = new List(); + polylines[pid] = list; + } + list.Add(new { lat = lat.Value, lon = lon.Value, ts, evt = eventType }); + } + } + } + + var result = polylines.Select(kvp => new + { + playerId = kvp.Key, + displayName = playerNames.TryGetValue(kvp.Key, out var n) ? n : kvp.Key, + points = kvp.Value + }).ToList(); + + await WriteJsonAsync(response, new { polylines = result }); + } + + /// + /// Reject any archive id containing a path-traversal token. We also bar + /// raw separators because the on-disk layout is one flat directory per + /// archive - no nesting expected. + /// + private static bool IsSafeArchiveId(string archiveId) + { + if (string.IsNullOrEmpty(archiveId)) return false; + if (archiveId.Contains("..")) return false; + if (archiveId.Contains('/') || archiveId.Contains('\\')) return false; + return true; + } + #region ═══════════════════════════════════════════════════════════════════ // WEBSOCKET - SPECTATE // ════════════════════════════════════════════════════════════════════════ diff --git a/AdminResources.cs b/AdminResources.cs index 040bcc7..3ae50ca 100644 --- a/AdminResources.cs +++ b/AdminResources.cs @@ -77,8 +77,12 @@ public static class AdminResources 📢 Broadcast + + 📁 + Archiv + - +
@@ -334,7 +338,7 @@ public static class AdminResources

Broadcast zpráva

- +
@@ -351,6 +355,48 @@ public static class AdminResources
+ + +
+
+

Archiv her

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

Archiv hry

+
+ +
+
+

📍 Trasy hráčů

+
+
+
+
+
+

📋 Časová osa

+
+
+
+
+
+
+
+
@@ -1648,6 +1694,172 @@ body { .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 => @"/* ═══════════════════════════════════════════════════════════════════════════ @@ -1832,6 +2044,7 @@ function showView(viewName) { if (viewName === 'players') loadPlayers(); if (viewName === 'config') loadConfig(); if (viewName === 'broadcast') loadBroadcastTargets(); + if (viewName === 'archive') loadArchiveList(); } // ═══════════════════════════════════════════════════════════════════════════ @@ -2370,5 +2583,158 @@ function debounce(func, wait) { 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)); }"; }