Lol
This commit is contained in:
@@ -77,8 +77,12 @@ public static class AdminResources
|
||||
<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>
|
||||
@@ -334,7 +338,7 @@ public static class AdminResources
|
||||
<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"">
|
||||
@@ -351,6 +355,48 @@ public static class AdminResources
|
||||
</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>
|
||||
|
||||
@@ -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 = '<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) +
|
||||
' | <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));
|
||||
}";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user