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
Dashboard
Aktualizováno: --:--:--
Rychlé akce
📢
Broadcast zpráva
⚙️
Upravit konfiguraci
👥
Spravovat hráče
Crew
Impostor
Dead
Task
Body
Repair
Jméno
ID
Lobby
Role
Stav
Cheat Score
Akce
Načítám archivované hry...
← Zpět na seznam
🖨️ Vytisknout report
";
public static string Css => @"/* ═══════════════════════════════════════════════════════════════════════════
GeoSus Admin Panel - Styles
Modern, Dark Theme with Accent Colors
═══════════════════════════════════════════════════════════════════════════ */
:root {
--bg-primary: #0f0f1a;
--bg-secondary: #1a1a2e;
--bg-card: #16213e;
--bg-hover: #1f2b4a;
--text-primary: #ffffff;
--text-secondary: #a0a0b0;
--text-muted: #6b6b7b;
--accent-primary: #00d4ff;
--accent-secondary: #7c3aed;
--accent-gradient: linear-gradient(135deg, #00d4ff 0%, #7c3aed 100%);
--success: #10b981;
--warning: #f59e0b;
--danger: #ef4444;
--info: #3b82f6;
--crew-color: #3b82f6;
--impostor-color: #ef4444;
--dead-color: #6b7280;
--task-color: #10b981;
--body-color: #f59e0b;
--repair-color: #8b5cf6;
--border-color: rgba(255, 255, 255, 0.1);
--shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 10px 25px -5px rgba(0, 0, 0, 0.4);
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 16px;
--radius-full: 9999px;
--transition: all 0.2s ease;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
overflow: hidden;
height: 100vh;
}
/* ═══════════════════════════════════════════════════════════════════════════
LOGIN SCREEN
═══════════════════════════════════════════════════════════════════════════ */
.screen {
display: none;
height: 100vh;
}
.screen.active {
display: flex;
}
#login-screen {
justify-content: center;
align-items: center;
position: relative;
overflow: hidden;
}
.login-bg {
position: absolute;
inset: 0;
background:
radial-gradient(ellipse at 20% 50%, rgba(0, 212, 255, 0.15) 0%, transparent 50%),
radial-gradient(ellipse at 80% 50%, rgba(124, 58, 237, 0.15) 0%, transparent 50%),
var(--bg-primary);
z-index: 0;
}
.login-container {
position: relative;
z-index: 1;
background: var(--bg-secondary);
padding: 3rem;
border-radius: var(--radius-lg);
border: 1px solid var(--border-color);
box-shadow: var(--shadow-lg);
width: 100%;
max-width: 400px;
animation: slideUp 0.5s ease;
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.login-logo {
text-align: center;
margin-bottom: 2rem;
}
.logo-icon {
font-size: 4rem;
margin-bottom: 0.5rem;
animation: float 3s ease-in-out infinite;
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
.login-logo h1 {
font-size: 2rem;
background: var(--accent-gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.login-logo p {
color: var(--text-secondary);
font-size: 0.9rem;
}
.input-group {
position: relative;
margin-bottom: 1.5rem;
}
.input-group input {
width: 100%;
padding: 1rem 1rem 1rem 3rem;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
color: var(--text-primary);
font-size: 1rem;
transition: var(--transition);
}
.input-group input:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px rgba(0, 212, 255, 0.2);
}
.input-icon {
position: absolute;
left: 1rem;
top: 50%;
transform: translateY(-50%);
font-size: 1.2rem;
}
.error-message {
color: var(--danger);
font-size: 0.85rem;
margin-top: 1rem;
text-align: center;
min-height: 1.5rem;
}
/* ═══════════════════════════════════════════════════════════════════════════
BUTTONS
═══════════════════════════════════════════════════════════════════════════ */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
border: none;
border-radius: var(--radius-md);
font-size: 0.95rem;
font-weight: 500;
cursor: pointer;
transition: var(--transition);
background: var(--bg-card);
color: var(--text-primary);
border: 1px solid var(--border-color);
}
.btn:hover {
background: var(--bg-hover);
transform: translateY(-1px);
}
.btn-primary {
background: var(--accent-gradient);
border: none;
color: white;
}
.btn-primary:hover {
opacity: 0.9;
box-shadow: 0 4px 15px rgba(0, 212, 255, 0.4);
}
.btn-danger {
background: var(--danger);
border: none;
color: white;
}
.btn-danger:hover {
background: #dc2626;
}
.btn-full {
width: 100%;
}
.btn-sm {
padding: 0.4rem 0.8rem;
font-size: 0.85rem;
}
.btn-arrow {
transition: transform 0.2s;
}
.btn:hover .btn-arrow {
transform: translateX(4px);
}
.btn-action {
flex-direction: column;
padding: 1.5rem;
gap: 0.75rem;
flex: 1;
}
.action-icon {
font-size: 2rem;
}
.btn-back {
padding: 0.5rem 1rem;
}
/* ═══════════════════════════════════════════════════════════════════════════
DASHBOARD LAYOUT
═══════════════════════════════════════════════════════════════════════════ */
#dashboard {
display: flex;
}
.sidebar {
width: 260px;
background: var(--bg-secondary);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
height: 100vh;
}
.sidebar-header {
padding: 1.5rem;
display: flex;
align-items: center;
gap: 0.75rem;
border-bottom: 1px solid var(--border-color);
}
.sidebar-header .logo {
font-size: 2rem;
}
.logo-text {
font-size: 1.5rem;
font-weight: 700;
background: var(--accent-gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.nav-section {
padding: 1rem 0;
}
.nav-label {
padding: 0.5rem 1.5rem;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-muted);
}
.nav-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1.5rem;
color: var(--text-secondary);
text-decoration: none;
transition: var(--transition);
position: relative;
}
.nav-item:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.nav-item.active {
background: rgba(0, 212, 255, 0.1);
color: var(--accent-primary);
}
.nav-item.active::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: var(--accent-gradient);
border-radius: 0 3px 3px 0;
}
.nav-icon {
font-size: 1.2rem;
}
.nav-badge {
margin-left: auto;
background: var(--accent-primary);
color: var(--bg-primary);
padding: 0.15rem 0.5rem;
border-radius: var(--radius-full);
font-size: 0.75rem;
font-weight: 600;
}
.sidebar-footer {
margin-top: auto;
padding: 1rem 1.5rem;
border-top: 1px solid var(--border-color);
}
.server-status {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
font-size: 0.9rem;
color: var(--text-secondary);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-muted);
}
.status-dot.online {
background: var(--success);
box-shadow: 0 0 8px var(--success);
}
.btn-logout {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.75rem;
background: transparent;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
color: var(--text-secondary);
cursor: pointer;
transition: var(--transition);
}
.btn-logout:hover {
background: rgba(239, 68, 68, 0.1);
border-color: var(--danger);
color: var(--danger);
}
/* ═══════════════════════════════════════════════════════════════════════════
MAIN CONTENT
═══════════════════════════════════════════════════════════════════════════ */
.main-content {
flex: 1;
overflow-y: auto;
height: 100vh;
background: var(--bg-primary);
}
.view {
display: none;
padding: 2rem;
animation: fadeIn 0.3s ease;
}
.view.active {
display: block;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.view-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 2rem;
}
.view-header h1 {
font-size: 1.75rem;
font-weight: 600;
}
.header-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: 1rem;
}
.last-update {
font-size: 0.85rem;
color: var(--text-muted);
}
/* ═══════════════════════════════════════════════════════════════════════════
STATS CARDS
═══════════════════════════════════════════════════════════════════════════ */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.stat-card {
background: var(--bg-card);
border-radius: var(--radius-lg);
padding: 1.5rem;
display: flex;
align-items: center;
gap: 1rem;
border: 1px solid var(--border-color);
transition: var(--transition);
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
border-color: var(--accent-primary);
}
.stat-icon {
font-size: 2.5rem;
opacity: 0.8;
}
.stat-value {
font-size: 2rem;
font-weight: 700;
background: var(--accent-gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.stat-label {
font-size: 0.9rem;
color: var(--text-secondary);
}
/* ═══════════════════════════════════════════════════════════════════════════
CARDS
═══════════════════════════════════════════════════════════════════════════ */
.card {
background: var(--bg-card);
border-radius: var(--radius-lg);
border: 1px solid var(--border-color);
overflow: hidden;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border-color);
}
.card-header h3 {
font-size: 1rem;
font-weight: 600;
}
.card-body {
padding: 1.5rem;
}
.dashboard-grid {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 1.5rem;
}
@media (max-width: 1200px) {
.dashboard-grid {
grid-template-columns: 1fr;
}
}
.quick-actions {
display: flex;
gap: 1rem;
}
.empty-state {
text-align: center;
padding: 2rem;
color: var(--text-muted);
}
/* ═══════════════════════════════════════════════════════════════════════════
LOBBY LIST
═══════════════════════════════════════════════════════════════════════════ */
.lobby-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-height: 400px;
overflow-y: auto;
}
.lobby-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background: var(--bg-secondary);
border-radius: var(--radius-md);
cursor: pointer;
transition: var(--transition);
}
.lobby-item:hover {
background: var(--bg-hover);
transform: translateX(4px);
}
.lobby-code {
font-family: monospace;
font-size: 1.1rem;
font-weight: 600;
color: var(--accent-primary);
background: rgba(0, 212, 255, 0.1);
padding: 0.25rem 0.75rem;
border-radius: var(--radius-sm);
}
.lobby-info {
flex: 1;
}
.lobby-phase {
font-size: 0.85rem;
color: var(--text-secondary);
}
.lobby-players {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
}
.btn-spectate {
padding: 0.5rem 1rem;
background: var(--accent-gradient);
border: none;
color: white;
}
/* ═══════════════════════════════════════════════════════════════════════════
SPECTATE VIEW
═══════════════════════════════════════════════════════════════════════════ */
#view-spectate {
padding: 0;
height: 100vh;
display: none;
flex-direction: column;
}
#view-spectate.active {
display: flex;
}
#view-spectate .view-header {
padding: 1rem 2rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
margin-bottom: 0;
}
.spectate-phase {
margin-left: auto;
padding: 0.5rem 1rem;
background: var(--bg-card);
border-radius: var(--radius-full);
font-weight: 600;
text-transform: uppercase;
font-size: 0.85rem;
}
.spectate-phase.lobby { color: var(--info); }
.spectate-phase.playing { color: var(--success); }
.spectate-phase.discussion { color: var(--warning); }
.spectate-phase.voting { color: var(--danger); }
.spectate-container {
flex: 1;
display: flex;
overflow: hidden;
}
.spectate-map-container {
flex: 1;
position: relative;
}
.spectate-map {
width: 100%;
height: 100%;
background: var(--bg-primary);
}
.map-overlay {
position: absolute;
bottom: 1rem;
left: 1rem;
z-index: 1000;
}
.map-legend {
background: rgba(15, 15, 26, 0.9);
padding: 0.75rem 1rem;
border-radius: var(--radius-md);
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.legend-item {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8rem;
color: var(--text-secondary);
}
.legend-dot {
width: 12px;
height: 12px;
border-radius: 50%;
}
.legend-dot.crew { background: var(--crew-color); }
.legend-dot.impostor { background: var(--impostor-color); }
.legend-dot.dead { background: var(--dead-color); }
.legend-dot.task { background: var(--task-color); }
.legend-dot.body { background: var(--body-color); }
.legend-dot.repair { background: var(--repair-color); }
.spectate-sidebar {
width: 320px;
background: var(--bg-secondary);
border-left: 1px solid var(--border-color);
overflow-y: auto;
display: flex;
flex-direction: column;
}
.spectate-panel {
padding: 1rem;
border-bottom: 1px solid var(--border-color);
}
.spectate-panel h3 {
font-size: 0.9rem;
margin-bottom: 0.75rem;
color: var(--text-secondary);
}
.player-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.player-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0.75rem;
background: var(--bg-card);
border-radius: var(--radius-sm);
font-size: 0.9rem;
}
.player-role {
width: 8px;
height: 8px;
border-radius: 50%;
}
.player-role.crew { background: var(--crew-color); }
.player-role.impostor { background: var(--impostor-color); }
.player-item.dead {
opacity: 0.5;
text-decoration: line-through;
}
.player-name {
flex: 1;
}
.player-state {
font-size: 0.75rem;
color: var(--text-muted);
text-transform: uppercase;
}
/* Votes Display */
.votes-display {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.vote-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
background: var(--bg-card);
border-radius: var(--radius-sm);
}
.vote-target {
font-weight: 600;
flex: 1;
}
.vote-count {
background: var(--accent-primary);
color: var(--bg-primary);
padding: 0.15rem 0.5rem;
border-radius: var(--radius-full);
font-size: 0.8rem;
font-weight: 600;
}
/* Task Progress */
.task-progress {
display: flex;
align-items: center;
gap: 1rem;
}
.progress-bar {
flex: 1;
height: 8px;
background: var(--bg-primary);
border-radius: var(--radius-full);
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--accent-gradient);
transition: width 0.3s ease;
width: 0%;
}
/* Sabotage Info */
.sabotage-info {
background: rgba(239, 68, 68, 0.1);
border: 1px solid var(--danger);
border-radius: var(--radius-md);
padding: 0.75rem;
}
.sabotage-type {
font-weight: 600;
color: var(--danger);
margin-bottom: 0.5rem;
}
.sabotage-timer {
font-size: 1.5rem;
font-weight: 700;
color: var(--danger);
font-family: monospace;
}
/* ═══════════════════════════════════════════════════════════════════════════
PLAYERS TABLE
═══════════════════════════════════════════════════════════════════════════ */
.search-input {
padding: 0.5rem 1rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
color: var(--text-primary);
font-size: 0.9rem;
width: 250px;
}
.search-input:focus {
outline: none;
border-color: var(--accent-primary);
}
.players-table-container {
background: var(--bg-card);
border-radius: var(--radius-lg);
border: 1px solid var(--border-color);
overflow: hidden;
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th,
.data-table td {
padding: 1rem;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
.data-table th {
background: var(--bg-secondary);
font-weight: 600;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
}
.data-table tr:hover {
background: var(--bg-hover);
}
.data-table .role-badge {
padding: 0.25rem 0.75rem;
border-radius: var(--radius-full);
font-size: 0.8rem;
font-weight: 600;
}
.role-badge.crew {
background: rgba(59, 130, 246, 0.2);
color: var(--crew-color);
}
.role-badge.impostor {
background: rgba(239, 68, 68, 0.2);
color: var(--impostor-color);
}
.cheat-score {
font-weight: 600;
}
.cheat-score.low { color: var(--success); }
.cheat-score.medium { color: var(--warning); }
.cheat-score.high { color: var(--danger); }
/* ═══════════════════════════════════════════════════════════════════════════
CONFIG FORM
═══════════════════════════════════════════════════════════════════════════ */
.config-container {
max-width: 800px;
}
.config-form {
background: var(--bg-card);
border-radius: var(--radius-lg);
border: 1px solid var(--border-color);
overflow: hidden;
}
.config-section {
padding: 1.5rem;
border-bottom: 1px solid var(--border-color);
}
.config-section:last-of-type {
border-bottom: none;
}
.config-section h3 {
margin-bottom: 1rem;
font-size: 1rem;
}
.config-help {
margin: -0.5rem 0 1rem 0;
font-size: 0.8rem;
color: var(--text-muted, #888);
line-height: 1.4;
}
.config-help code {
background: var(--bg-elev, rgba(255,255,255,0.06));
padding: 0.05rem 0.3rem;
border-radius: 3px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.75rem;
}
.config-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.config-item label {
display: block;
margin-bottom: 0.5rem;
font-size: 0.85rem;
color: var(--text-secondary);
}
.config-item input {
width: 100%;
padding: 0.75rem;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
color: var(--text-primary);
font-size: 1rem;
}
.config-item input:focus {
outline: none;
border-color: var(--accent-primary);
}
.config-actions {
padding: 1.5rem;
background: var(--bg-secondary);
display: flex;
gap: 1rem;
}
/* ═══════════════════════════════════════════════════════════════════════════
BROADCAST FORM
═══════════════════════════════════════════════════════════════════════════ */
.broadcast-container {
max-width: 600px;
}
.broadcast-form {
background: var(--bg-card);
border-radius: var(--radius-lg);
border: 1px solid var(--border-color);
padding: 1.5rem;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.form-group select,
.form-group textarea {
width: 100%;
padding: 0.75rem;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
color: var(--text-primary);
font-size: 1rem;
font-family: inherit;
}
.form-group textarea {
resize: vertical;
min-height: 100px;
}
.form-group select:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--accent-primary);
}
/* ═══════════════════════════════════════════════════════════════════════════
MODAL
═══════════════════════════════════════════════════════════════════════════ */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
z-index: 1000;
opacity: 0;
visibility: hidden;
transition: var(--transition);
}
.modal-overlay.active {
opacity: 1;
visibility: visible;
}
.modal {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(0.9);
background: var(--bg-card);
border-radius: var(--radius-lg);
border: 1px solid var(--border-color);
z-index: 1001;
width: 90%;
max-width: 400px;
opacity: 0;
visibility: hidden;
transition: var(--transition);
}
.modal.active {
opacity: 1;
visibility: visible;
transform: translate(-50%, -50%) scale(1);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border-color);
}
.modal-close {
background: none;
border: none;
font-size: 1.5rem;
color: var(--text-secondary);
cursor: pointer;
}
.modal-body {
padding: 1.5rem;
}
.modal-footer {
padding: 1rem 1.5rem;
background: var(--bg-secondary);
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
/* ═══════════════════════════════════════════════════════════════════════════
LEAFLET MAP CUSTOMIZATION
═══════════════════════════════════════════════════════════════════════════ */
.leaflet-container {
background: var(--bg-primary) !important;
}
.player-marker {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 50%;
border: 3px solid white;
font-size: 14px;
font-weight: bold;
color: white;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
transition: transform 0.3s ease;
}
.player-marker.crew { background: var(--crew-color); }
.player-marker.impostor { background: var(--impostor-color); }
.player-marker.dead {
background: var(--dead-color);
opacity: 0.6;
}
.task-marker {
width: 20px;
height: 20px;
background: var(--task-color);
border-radius: 50%;
border: 2px solid white;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.task-marker.completed {
background: var(--text-muted);
}
.body-marker {
width: 24px;
height: 24px;
background: var(--body-color);
border-radius: 50%;
border: 2px solid white;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
}
.repair-marker {
width: 24px;
height: 24px;
background: var(--repair-color);
border-radius: 4px;
border: 2px solid white;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
}
.repair-marker.repaired {
background: var(--success);
}
/* Custom popup */
.leaflet-popup-content-wrapper {
background: var(--bg-card) !important;
border: 1px solid var(--border-color) !important;
color: var(--text-primary) !important;
border-radius: var(--radius-md) !important;
}
.leaflet-popup-tip {
background: var(--bg-card) !important;
}
/* ═══════════════════════════════════════════════════════════════════════════
RESPONSIVE
═══════════════════════════════════════════════════════════════════════════ */
@media (max-width: 1024px) {
.sidebar {
width: 80px;
}
.logo-text,
.nav-item span:not(.nav-icon):not(.nav-badge),
.nav-label,
.btn-logout span:last-child {
display: none;
}
.nav-item {
justify-content: center;
padding: 1rem;
}
.nav-badge {
position: absolute;
top: 0.5rem;
right: 0.5rem;
margin: 0;
}
.spectate-sidebar {
width: 280px;
}
}
@media (max-width: 768px) {
.spectate-container {
flex-direction: column;
}
.spectate-sidebar {
width: 100%;
height: 200px;
flex-direction: row;
overflow-x: auto;
}
.spectate-panel {
min-width: 200px;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.quick-actions {
flex-direction: column;
}
}
/* ═══════════════════════════════════════════════════════════════════════════
SCROLLBAR
═══════════════════════════════════════════════════════════════════════════ */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-primary);
}
::-webkit-scrollbar-thumb {
background: var(--bg-hover);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
/* ═══════════════════════════════════════════════════════════════════════════
ANIMATIONS
═══════════════════════════════════════════════════════════════════════════ */
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.loading {
animation: pulse 1.5s infinite;
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-5px); }
75% { transform: translateX(5px); }
}
.shake {
animation: shake 0.3s ease;
}
/* ═══════════════════════════════════════════════════════════════════════════
ARCHIVE VIEW (P13d)
═══════════════════════════════════════════════════════════════════════════ */
.archive-container {
background: var(--bg-card);
border-radius: var(--radius-md);
padding: 16px;
box-shadow: var(--shadow);
}
.archive-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 12px;
}
.archive-card {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
padding: 16px;
cursor: pointer;
transition: all 0.2s;
}
.archive-card:hover {
background: var(--bg-hover);
border-color: var(--accent-primary);
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.archive-card-title {
font-weight: 600;
color: var(--text-primary);
margin-bottom: 6px;
font-family: monospace;
font-size: 13px;
word-break: break-all;
}
.archive-card-meta {
color: var(--text-secondary);
font-size: 12px;
display: flex;
justify-content: space-between;
gap: 8px;
}
.archive-detail {
background: var(--bg-card);
border-radius: var(--radius-md);
padding: 16px;
box-shadow: var(--shadow);
}
.detail-header {
display: flex;
gap: 12px;
margin-bottom: 16px;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
}
.archive-summary {
background: var(--bg-secondary);
padding: 12px;
border-radius: var(--radius-sm);
margin-bottom: 16px;
color: var(--text-secondary);
font-size: 13px;
}
.archive-detail-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.archive-timeline {
max-height: 500px;
overflow-y: auto;
font-size: 12px;
}
.timeline-event {
padding: 8px 12px;
border-left: 3px solid var(--accent-primary);
margin-bottom: 6px;
background: var(--bg-secondary);
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
}
.timeline-event-type {
font-weight: 600;
color: var(--accent-primary);
margin-bottom: 4px;
}
.timeline-event-meta {
color: var(--text-muted);
font-size: 11px;
font-family: monospace;
}
.timeline-event-payload {
color: var(--text-secondary);
font-size: 11px;
margin-top: 4px;
white-space: pre-wrap;
font-family: monospace;
max-height: 120px;
overflow-y: auto;
}
@media (max-width: 1100px) {
.archive-detail-grid { grid-template-columns: 1fr; }
}
/* ═══════════════════════════════════════════════════════════════════════════
PRINT (P13d)
═══════════════════════════════════════════════════════════════════════════ */
@media print {
/* Reset for paper - dark theme is unprintable. */
body, html { background: white !important; color: black !important; }
/* Hide everything except the per-game detail report. */
.sidebar, .modal-overlay, .modal,
#login-screen, #view-overview, #view-lobbies, #view-spectate,
#view-players, #view-config, #view-broadcast,
#archive-list-container, .detail-header, .header-actions,
.sidebar-footer, .nav-section { display: none !important; }
.main-content { margin-left: 0 !important; padding: 0 !important; }
.view-header h1 { color: black !important; }
.archive-detail, .archive-summary, .card, .card-header, .card-body {
background: white !important;
color: black !important;
border: 1px solid #ccc !important;
box-shadow: none !important;
page-break-inside: avoid;
}
.archive-detail-grid { grid-template-columns: 1fr !important; gap: 12px !important; }
.timeline-event {
background: #f5f5f5 !important;
color: black !important;
border-left-color: #555 !important;
page-break-inside: avoid;
}
.timeline-event-type { color: #333 !important; }
.timeline-event-meta,
.timeline-event-payload { color: #555 !important; }
.archive-timeline { max-height: none !important; overflow: visible !important; }
/* Force the map to a printable size and let it overflow into multiple pages
gracefully (browsers can't print live tiles, but a static screenshot of the
canvas will be embedded by most modern browsers). */
#archive-map { height: 400px !important; page-break-inside: avoid; }
}";
public static string JavaScript => @"/* ═══════════════════════════════════════════════════════════════════════════
GeoSus Admin Panel - JavaScript
Real-time dashboard with WebSocket support
═══════════════════════════════════════════════════════════════════════════ */
// State
let statsWs = null;
let spectateWs = null;
let spectateMap = null;
let playerMarkers = {};
let taskMarkers = {};
let bodyMarkers = {};
let repairMarkers = {};
let buildingLayers = [];
let currentLobbyId = null;
let kickPlayerId = null;
// ═══════════════════════════════════════════════════════════════════════════
// INITIALIZATION
// ═══════════════════════════════════════════════════════════════════════════
document.addEventListener('DOMContentLoaded', () => {
// Login form
document.getElementById('login-form').addEventListener('submit', handleLogin);
// Navigation
document.querySelectorAll('.nav-item').forEach(item => {
item.addEventListener('click', (e) => {
e.preventDefault();
const view = item.dataset.view;
if (view) showView(view);
});
});
// Logout
document.getElementById('logout-btn').addEventListener('click', handleLogout);
// Config form
document.getElementById('config-form').addEventListener('submit', handleConfigSave);
// Broadcast form
document.getElementById('broadcast-form').addEventListener('submit', handleBroadcast);
// Player search
document.getElementById('player-search').addEventListener('input', debounce(loadPlayers, 300));
// Check if already logged in
checkAuth();
});
// ═══════════════════════════════════════════════════════════════════════════
// AUTHENTICATION
// ═══════════════════════════════════════════════════════════════════════════
async function checkAuth() {
try {
const response = await fetch('/admin/api/status');
if (response.ok) {
showDashboard();
}
} catch (e) {
// Not logged in
}
}
async function handleLogin(e) {
e.preventDefault();
const password = document.getElementById('password').value;
const errorEl = document.getElementById('login-error');
try {
const response = await fetch('/admin/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
});
if (response.ok) {
showDashboard();
} else {
errorEl.textContent = 'Nesprávné heslo';
document.getElementById('password').classList.add('shake');
setTimeout(() => document.getElementById('password').classList.remove('shake'), 300);
}
} catch (e) {
errorEl.textContent = 'Chyba připojení k serveru';
}
}
async function handleLogout() {
await fetch('/admin/api/logout');
if (statsWs) statsWs.close();
if (spectateWs) spectateWs.close();
document.getElementById('dashboard').classList.remove('active');
document.getElementById('login-screen').classList.add('active');
document.getElementById('password').value = '';
}
function showDashboard() {
document.getElementById('login-screen').classList.remove('active');
document.getElementById('dashboard').classList.add('active');
// Connect WebSocket for stats
connectStatsWebSocket();
// Load initial data
refreshLobbies();
loadConfig();
}
// ═══════════════════════════════════════════════════════════════════════════
// WEBSOCKET - SERVER STATS
// ═══════════════════════════════════════════════════════════════════════════
function connectStatsWebSocket() {
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
statsWs = new WebSocket(`${protocol}//${location.host}/admin/ws/stats`);
statsWs.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'stats') {
updateStats(data);
}
};
statsWs.onclose = () => {
setTimeout(connectStatsWebSocket, 3000);
};
}
function updateStats(data) {
document.getElementById('stat-lobbies').textContent = data.lobbies;
document.getElementById('stat-players').textContent = data.players;
document.getElementById('stat-memory').textContent = data.memoryMb + ' MB';
const uptime = formatUptime(data.uptime);
document.getElementById('stat-uptime').textContent = uptime;
document.getElementById('server-uptime').textContent = uptime;
document.getElementById('lobby-count').textContent = data.lobbies;
document.getElementById('player-count').textContent = data.players;
document.getElementById('last-update-time').textContent = new Date().toLocaleTimeString();
}
function formatUptime(seconds) {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}
// ═══════════════════════════════════════════════════════════════════════════
// VIEWS
// ═══════════════════════════════════════════════════════════════════════════
function showView(viewName) {
// If leaving spectate view, cleanup WebSocket
if (viewName !== 'spectate' && currentLobbyId) {
currentLobbyId = null; // MUST be set before close to prevent reconnect
if (spectateWs) {
spectateWs.close();
spectateWs = null;
}
}
// Update nav
document.querySelectorAll('.nav-item').forEach(item => {
item.classList.toggle('active', item.dataset.view === viewName);
});
// Update views
document.querySelectorAll('.view').forEach(view => {
view.classList.toggle('active', view.id === `view-${viewName}`);
});
// Load data for specific views
if (viewName === 'lobbies') refreshLobbies();
if (viewName === 'players') loadPlayers();
if (viewName === 'config') loadConfig();
if (viewName === 'broadcast') loadBroadcastTargets();
if (viewName === 'archive') loadArchiveList();
}
// ═══════════════════════════════════════════════════════════════════════════
// LOBBIES
// ═══════════════════════════════════════════════════════════════════════════
async function refreshLobbies() {
try {
const response = await fetch('/admin/api/lobbies');
const data = await response.json();
renderLobbies(data.lobbies);
} catch (e) {
console.error('Failed to load lobbies:', e);
}
}
function renderLobbies(lobbies) {
// Dashboard list
const listEl = document.getElementById('lobby-list');
// Full list
const fullListEl = document.getElementById('lobbies-full-list');
if (lobbies.length === 0) {
listEl.innerHTML = 'Žádné aktivní lobby
';
fullListEl.innerHTML = 'Žádné aktivní lobby
';
return;
}
const renderItem = (lobby) => `
${lobby.joinCode}
${translatePhase(lobby.phase)}
👥
${lobby.playerCount}
👁️ Spectate
`;
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}
Kick
`).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 = 'Všechny lobby ' +
data.lobbies.map(l => `${l.joinCode} (${l.playerCount} hráčů) `).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));
}";
}