Private
Public Access
1
0
Files
EscapeFromTeacher/cmd/client/web/admin.html
Sebastian Unterschütz aff505773a
All checks were successful
Dynamic Branch Deploy / build-and-deploy (push) Successful in 7m3s
fix game
2026-03-22 10:44:58 +01:00

436 lines
18 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Admin Panel Escape From Teacher</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<style>
body { background: #0f1117; color: #e0e0e0; }
.navbar { background: #1a1d27 !important; border-bottom: 2px solid #ffc107; }
.navbar-brand { color: #ffc107 !important; font-weight: 700; letter-spacing: 1px; }
.card { background: #1a1d27; border: 1px solid #2a2d3a; }
.card-header { background: #22263a; border-bottom: 1px solid #2a2d3a; }
.table { color: #d0d0d0; }
.table thead th { background: #22263a; color: #ffc107; border-color: #2a2d3a; }
.table tbody td { border-color: #2a2d3a; vertical-align: middle; }
.table tbody tr:hover { background: rgba(255,193,7,0.05); }
.badge-valid { background: #198754; }
.badge-invalid { background: #dc3545; }
.badge-running { background: #0d6efd; }
.badge-lobby { background: #6c757d; }
.badge-countdown { background: #fd7e14; }
.badge-gameover { background: #dc3545; }
.tab-btn { background: #22263a; color: #aaa; border: 1px solid #2a2d3a; }
.tab-btn.active { background: #ffc107; color: #111; border-color: #ffc107; font-weight: 600; }
.tab-btn:hover:not(.active) { background: #2a2d3a; color: #fff; }
.player-row { font-size: 0.85rem; background: #181b28; }
.difficulty-bar { height: 6px; background: #2a2d3a; border-radius: 3px; overflow: hidden; }
.difficulty-fill { height: 100%; border-radius: 3px; transition: width 0.3s; }
.stat-card { background: #22263a; border-radius: 8px; padding: 14px 18px; border: 1px solid #2a2d3a; }
.stat-card .stat-value { font-size: 2rem; font-weight: 700; color: #ffc107; }
.stat-card .stat-label { font-size: 0.78rem; color: #888; text-transform: uppercase; letter-spacing: 1px; }
.spinner-inline { width: 16px; height: 16px; border-width: 2px; }
.alert-toast { position: fixed; bottom: 24px; right: 24px; z-index: 9999; min-width: 260px; }
[x-cloak] { display: none !important; }
</style>
</head>
<body x-data="adminApp()" x-init="init()" x-cloak>
<!-- Navbar -->
<nav class="navbar navbar-expand-lg">
<div class="container-fluid px-4">
<span class="navbar-brand">🎮 EFT Admin Panel</span>
<div class="d-flex align-items-center gap-3">
<span class="text-muted small" x-text="'Letztes Update: ' + lastUpdate"></span>
<div class="spinner-border spinner-inline text-warning" x-show="loading" role="status"></div>
</div>
</div>
</nav>
<div class="container-fluid px-4 py-4">
<!-- Stat-Karten -->
<div class="row g-3 mb-4">
<div class="col-6 col-md-3">
<div class="stat-card">
<div class="stat-value" x-text="rooms.length"></div>
<div class="stat-label">Aktive Räume</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="stat-card">
<div class="stat-value" x-text="totalPlayers"></div>
<div class="stat-label">Spieler Online</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="stat-card">
<div class="stat-value" x-text="leaderboard.length"></div>
<div class="stat-label">Leaderboard-Einträge</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="stat-card">
<div class="stat-value" x-text="invalidCount"></div>
<div class="stat-label">Ungültige Einträge</div>
</div>
</div>
</div>
<!-- Tabs -->
<div class="d-flex gap-2 mb-3">
<button class="btn tab-btn px-4" :class="{ active: tab === 'rooms' }" @click="tab = 'rooms'">
🏠 Laufende Räume
</button>
<button class="btn tab-btn px-4" :class="{ active: tab === 'leaderboard' }" @click="tab = 'leaderboard'">
🏆 Leaderboard
</button>
</div>
<!-- ===== TAB: RÄUME ===== -->
<div x-show="tab === 'rooms'">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center py-2 px-3">
<span class="fw-semibold">Laufende Räume</span>
<button class="btn btn-sm btn-outline-warning" @click="loadRooms()">⟳ Aktualisieren</button>
</div>
<div class="card-body p-0">
<template x-if="rooms.length === 0">
<p class="text-muted text-center py-4 mb-0">Keine aktiven Räume</p>
</template>
<div class="accordion" id="roomsAccordion">
<template x-for="(room, i) in rooms" :key="room.id">
<div class="accordion-item bg-transparent border-0 border-bottom" style="border-color: #2a2d3a !important">
<h2 class="accordion-header">
<button class="accordion-button collapsed py-2 px-3"
style="background:#1a1d27; color:#ddd; box-shadow:none;"
type="button"
@click="room._open = !room._open">
<div class="d-flex align-items-center gap-3 w-100">
<!-- Status Badge -->
<span class="badge"
:class="{
'badge-running': room.status === 'RUNNING',
'badge-lobby': room.status === 'LOBBY',
'badge-countdown': room.status === 'COUNTDOWN',
'badge-gameover': room.status === 'GAMEOVER'
}"
x-text="room.status"></span>
<!-- Raum ID -->
<span class="fw-semibold font-monospace small" x-text="room.id"></span>
<!-- Spieler -->
<span class="text-muted small">
👤 <span x-text="room.alive_count"></span>/<span x-text="room.player_count"></span> am Leben
</span>
<!-- Zeit -->
<span class="text-muted small ms-auto me-3" x-show="room.status === 'RUNNING'">
<span x-text="formatTime(room.elapsed_seconds)"></span>
</span>
</div>
</button>
</h2>
<div x-show="room._open" x-transition>
<div class="p-3">
<!-- Raum-Stats -->
<div class="row g-2 mb-3">
<div class="col-6 col-md-3">
<div class="small text-muted">Scroll X</div>
<div class="fw-semibold" x-text="Math.round(room.scroll_x) + ' px'"></div>
</div>
<div class="col-6 col-md-3">
<div class="small text-muted">Geschwindigkeit</div>
<div class="fw-semibold" x-text="room.current_speed.toFixed(1) + ' px/tick'"></div>
</div>
<div class="col-6 col-md-3">
<div class="small text-muted">Schwierigkeit</div>
<div class="difficulty-bar mt-1">
<div class="difficulty-fill"
:style="'width:' + (room.difficulty_factor * 100) + '%; background:' + diffColor(room.difficulty_factor)"></div>
</div>
<div class="small mt-1" x-text="(room.difficulty_factor * 100).toFixed(0) + '%'"></div>
</div>
<div class="col-6 col-md-3">
<div class="small text-muted">Host</div>
<div class="fw-semibold font-monospace small" x-text="room.host_id || '—'"></div>
</div>
</div>
<!-- Spieler-Tabelle -->
<table class="table table-sm mb-0">
<thead>
<tr>
<th>Name</th>
<th>Score</th>
<th>Status</th>
<th>Powerups</th>
</tr>
</thead>
<tbody>
<template x-for="p in room.players" :key="p.name">
<tr class="player-row">
<td x-text="p.name"></td>
<td class="text-warning fw-semibold" x-text="p.score"></td>
<td>
<span class="badge" :class="p.is_alive ? 'bg-success' : 'bg-secondary'"
x-text="p.is_spectator ? 'Zuschauer' : (p.is_alive ? 'Am Leben' : 'Tot')"></span>
</td>
<td>
<span x-show="p.has_double_jump" class="badge bg-info me-1">2x Sprung</span>
<span x-show="p.has_godmode" class="badge bg-warning text-dark">Godmode</span>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
</template>
</div>
</div>
</div>
</div>
<!-- ===== TAB: LEADERBOARD ===== -->
<div x-show="tab === 'leaderboard'">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center py-2 px-3">
<div class="d-flex align-items-center gap-3">
<span class="fw-semibold">Leaderboard-Einträge</span>
<!-- Filter -->
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" id="filterInvalid" x-model="showOnlyInvalid">
<label class="form-check-label small" for="filterInvalid">Nur ungültige</label>
</div>
</div>
<div class="d-flex gap-2">
<input class="form-control form-control-sm bg-dark text-light border-secondary"
style="max-width:200px"
placeholder="🔍 Suchen..." x-model="search">
<button class="btn btn-sm btn-outline-warning" @click="loadLeaderboard()">⟳ Aktualisieren</button>
</div>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table mb-0">
<thead>
<tr>
<th>#</th>
<th>Name</th>
<th>Score</th>
<th>Player Code</th>
<th>Proof Code</th>
<th>Zeitstempel</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
<template x-for="(e, i) in filteredLeaderboard" :key="e.key">
<tr :class="!e.valid ? 'table-danger bg-opacity-25' : ''">
<td class="text-muted small" x-text="i + 1"></td>
<td class="fw-semibold" x-text="e.player_name"></td>
<td class="text-warning fw-bold" x-text="e.score"></td>
<td class="font-monospace small text-muted" x-text="e.player_code?.substring(0,12) + '...'"></td>
<td class="font-monospace small" x-text="e.proof_code"></td>
<td class="small text-muted" x-text="formatTs(e.timestamp)"></td>
<td>
<span class="badge"
:class="e.valid ? 'badge-valid' : 'badge-invalid'"
x-text="e.valid ? '✓ Gültig' : '✗ Ungültig'"></span>
</td>
<td>
<button class="btn btn-sm btn-outline-danger"
@click="confirmDelete(e)"
title="Eintrag löschen">
🗑
</button>
</td>
</tr>
</template>
<template x-if="filteredLeaderboard.length === 0">
<tr>
<td colspan="8" class="text-center text-muted py-4">Keine Einträge gefunden</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content bg-dark text-light border border-secondary">
<div class="modal-header border-secondary">
<h5 class="modal-title text-warning">Eintrag löschen?</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body" x-show="deleteTarget">
<p>Soll dieser Eintrag wirklich dauerhaft gelöscht werden?</p>
<table class="table table-sm">
<tr>
<td class="text-muted">Name</td>
<td class="fw-semibold" x-text="deleteTarget?.player_name"></td>
</tr>
<tr>
<td class="text-muted">Score</td>
<td class="text-warning fw-bold" x-text="deleteTarget?.score"></td>
</tr>
<tr>
<td class="text-muted">Status</td>
<td>
<span class="badge" :class="deleteTarget?.valid ? 'badge-valid' : 'badge-invalid'"
x-text="deleteTarget?.valid ? '✓ Gültig' : '✗ Ungültig'"></span>
</td>
</tr>
</table>
</div>
<div class="modal-footer border-secondary">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
<button type="button" class="btn btn-danger" @click="deleteEntry()">Löschen</button>
</div>
</div>
</div>
</div>
<!-- Toast Notification -->
<div class="alert-toast" x-show="toast.show" x-transition>
<div class="alert mb-0 shadow"
:class="toast.type === 'success' ? 'alert-success' : 'alert-danger'"
x-text="toast.message">
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
function adminApp() {
return {
tab: 'rooms',
rooms: [],
leaderboard: [],
loading: false,
lastUpdate: '—',
search: '',
showOnlyInvalid: false,
deleteTarget: null,
deleteModal: null,
toast: { show: false, message: '', type: 'success' },
autoRefreshInterval: null,
init() {
this.deleteModal = new bootstrap.Modal(document.getElementById('deleteModal'))
this.loadAll()
// Auto-Refresh alle 5 Sekunden
this.autoRefreshInterval = setInterval(() => this.loadAll(), 5000)
},
async loadAll() {
await Promise.all([this.loadRooms(), this.loadLeaderboard()])
this.lastUpdate = new Date().toLocaleTimeString('de-DE')
},
async loadRooms() {
this.loading = true
try {
const r = await fetch('/admin/api/rooms')
if (!r.ok) throw new Error('Fehler ' + r.status)
const data = await r.json()
// _open-State erhalten
const openMap = {}
this.rooms.forEach(r => { if (r._open) openMap[r.id] = true })
this.rooms = (data || []).map(r => ({ ...r, _open: !!openMap[r.id] }))
} catch(e) {
this.showToast('Räume konnten nicht geladen werden: ' + e.message, 'error')
} finally {
this.loading = false
}
},
async loadLeaderboard() {
try {
const r = await fetch('/admin/api/leaderboard')
if (!r.ok) throw new Error('Fehler ' + r.status)
this.leaderboard = await r.json() || []
} catch(e) {
this.showToast('Leaderboard konnte nicht geladen werden: ' + e.message, 'error')
}
},
get filteredLeaderboard() {
return this.leaderboard.filter(e => {
if (this.showOnlyInvalid && e.valid) return false
if (this.search) {
const q = this.search.toLowerCase()
return e.player_name?.toLowerCase().includes(q) ||
e.player_code?.toLowerCase().includes(q) ||
String(e.score).includes(q)
}
return true
})
},
get totalPlayers() {
return this.rooms.reduce((s, r) => s + r.player_count, 0)
},
get invalidCount() {
return this.leaderboard.filter(e => !e.valid).length
},
confirmDelete(entry) {
this.deleteTarget = entry
this.deleteModal.show()
},
async deleteEntry() {
if (!this.deleteTarget) return
const key = this.deleteTarget.key
try {
const r = await fetch('/admin/api/leaderboard/' + encodeURIComponent(key), { method: 'DELETE' })
if (!r.ok) throw new Error('Fehler ' + r.status)
this.leaderboard = this.leaderboard.filter(e => e.key !== key)
this.showToast('Eintrag gelöscht: ' + this.deleteTarget.player_name, 'success')
} catch(e) {
this.showToast('Löschen fehlgeschlagen: ' + e.message, 'error')
} finally {
this.deleteModal.hide()
this.deleteTarget = null
}
},
showToast(message, type = 'success') {
this.toast = { show: true, message, type }
setTimeout(() => { this.toast.show = false }, 3500)
},
diffColor(f) {
if (f < 0.33) return '#198754'
if (f < 0.66) return '#fd7e14'
return '#dc3545'
},
formatTime(s) {
const m = Math.floor(s / 60)
const sec = s % 60
return (m > 0 ? m + 'm ' : '') + sec + 's'
},
formatTs(ts) {
if (!ts) return '—'
return new Date(ts * 1000).toLocaleString('de-DE', {
day: '2-digit', month: '2-digit', year: '2-digit',
hour: '2-digit', minute: '2-digit'
})
}
}
}
</script>
</body>
</html>