feat(ui): player stats modal and admin button for ADMIN_USERS accounts

The admin dashboard exposed account stats, but players had no way to see
their own. Both gaps are closed:

Player stats modal (src/components/PlayerStats.vue):
- New "📊 Statistik" chip in the profile bar, available to logged-in
  players AND guests (guest progress lives in the same profile shape)
- Shows crystals, rounds played, wins incl. win rate, kills, total score,
  best wave, and a research progress bar (owned vs. total upgrade levels)
- Guests get a hint that their progress is local-only and transfers into
  an account on first login

Admin shortcut:
- Logged-in users whose account is listed in ADMIN_USERS (server already
  sets publicUser.admin) now get a "🛡️ Admin" chip in the profile bar
  that links to /admin with the existing session
- UserMetaProfile type gains the optional admin flag

Verified in the browser: guest sees the stats button but no admin chip;
after logging in as an ADMIN_USERS account the admin chip appears and
navigates to the dashboard (data loads, 1 account). Build clean, 54/54
tests green.
This commit is contained in:
Tronax 2026-08-17 15:18:43 +02:00
parent 69fbb015ab
commit 572f82a954
Signed by: Tronax
SSH key fingerprint: SHA256:2pKKXDZucWvaF/GzXNz0FY53EAO1YDLN80bqS+TTz/o
5 changed files with 193 additions and 0 deletions

View file

@ -9,6 +9,7 @@ import MPBar from '@/components/MPBar.vue'
import ObstaclePanel from '@/components/ObstaclePanel.vue'
import PauseOverlay from '@/components/PauseOverlay.vue'
import PiPCanvas from '@/components/PiPCanvas.vue'
import PlayerStats from '@/components/PlayerStats.vue'
import ResearchTree from '@/components/ResearchTree.vue'
import StartScreen from '@/components/StartScreen.vue'
import TowerPanel from '@/components/TowerPanel.vue'
@ -86,6 +87,7 @@ const badgeColor = computed(() => {
<AuthModal />
<ResearchTree />
<PlayerStats />
</div>
</template>

View file

@ -0,0 +1,171 @@
<script setup lang="ts">
import { computed } from 'vue'
import { META_UPGRADES } from '@/game/meta'
import { isLoggedIn } from '@/game/auth'
import { store } from '@/game/store'
const user = computed(() => store.auth.user)
const stats = computed(() => user.value?.stats)
const winRate = computed(() => {
const s = stats.value
if (!s || s.gamesPlayed === 0) return '—'
return `${Math.round((s.gamesWon / s.gamesPlayed) * 100)} %`
})
/** research progress: owned levels vs. total available levels */
const research = computed(() => {
const total = Object.values(META_UPGRADES).reduce((a, d) => a + d.maxLevel, 0)
const owned = user.value
? Object.values(user.value.upgrades).reduce((a, lvl) => a + (lvl || 0), 0)
: 0
return { owned, total, pct: total ? Math.round((owned / total) * 100) : 0 }
})
const scoreLabel = computed(() => (stats.value?.totalScore ?? 0).toLocaleString('de-DE'))
function close(): void {
store.auth.showStats = false
}
</script>
<template>
<div v-if="store.auth.showStats" class="backdrop" @click.self="close">
<div class="card">
<button class="close" @click="close"></button>
<div class="head">
<h2>📊 Meine Statistik</h2>
</div>
<p class="identity">
<template v-if="user">👤 <b>{{ user.displayName }}</b> <span class="dim">@{{ user.username }}</span></template>
<template v-else>👤 Gast</template>
</p>
<div class="grid">
<div class="kpi"><span class="v gold">💎 {{ user?.crystals ?? 0 }}</span><span class="k">Kristalle</span></div>
<div class="kpi"><span class="v">{{ stats?.gamesPlayed ?? 0 }}</span><span class="k">Runden gespielt</span></div>
<div class="kpi"><span class="v">{{ stats?.gamesWon ?? 0 }}</span><span class="k">Siege ({{ winRate }})</span></div>
<div class="kpi"><span class="v">{{ stats?.totalKills ?? 0 }}</span><span class="k">Abschüsse</span></div>
<div class="kpi"><span class="v">{{ scoreLabel }}</span><span class="k">Punkte insgesamt</span></div>
<div class="kpi"><span class="v">🌊 {{ stats?.highestWave ?? 0 }}</span><span class="k">Beste Welle</span></div>
</div>
<div class="research">
<div class="r-head">
<span>🧪 Forschung</span>
<span class="dim">{{ research.owned }} / {{ research.total }} Stufen</span>
</div>
<div class="bar"><div class="fill" :style="{ width: research.pct + '%' }" /></div>
</div>
<p v-if="!isLoggedIn()" class="hint">
Als Gast wird dein Fortschritt nur lokal im Browser gespeichert.
Mit einem Konto liegt er auf dem Server überall verfügbar.
</p>
</div>
</div>
</template>
<style scoped>
.backdrop {
position: fixed;
inset: 0;
background: rgba(8, 11, 15, 0.72);
backdrop-filter: blur(3px);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
}
.card {
position: relative;
background: var(--panel);
border: 1px solid var(--panel-border);
border-radius: 16px;
padding: 22px 26px;
width: 560px;
max-width: 94vw;
display: flex;
flex-direction: column;
gap: 14px;
}
.close {
position: absolute;
top: 10px;
right: 12px;
background: none;
border: none;
color: var(--text-dim);
font-size: 16px;
cursor: pointer;
}
.head h2 {
margin: 0;
font-size: 20px;
}
.identity {
margin: 0;
font-size: 13.5px;
}
.dim {
color: var(--text-dim);
font-size: 12px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 10px;
}
.kpi {
background: var(--panel-inset);
border: 1px solid var(--panel-border);
border-radius: 10px;
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 2px;
}
.kpi .v {
font-size: 18px;
font-weight: 800;
}
.kpi .v.gold {
color: #7fd8ff;
}
.kpi .k {
font-size: 11.5px;
color: var(--text-dim);
}
.research {
background: var(--panel-inset);
border: 1px solid var(--panel-border);
border-radius: 10px;
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.r-head {
display: flex;
justify-content: space-between;
font-size: 13px;
font-weight: 700;
}
.bar {
height: 8px;
border-radius: 6px;
background: var(--panel-border);
overflow: hidden;
}
.fill {
height: 100%;
background: linear-gradient(90deg, #f5c542, #e08a2e);
border-radius: 6px;
transition: width 0.3s ease;
}
.hint {
margin: 0;
color: var(--text-dim);
font-size: 11.5px;
text-align: center;
}
</style>

View file

@ -5,13 +5,20 @@ import { store } from '@/game/store'
const user = computed(() => store.auth.user)
const loggedIn = computed(() => !!user.value && user.value.id !== 'guest')
const isAdmin = computed(() => Boolean(user.value?.admin))
function openResearch(): void {
store.auth.showResearch = true
}
function openStats(): void {
store.auth.showStats = true
}
function openAuth(): void {
store.auth.showAuth = true
}
function openAdmin(): void {
window.location.href = '/admin'
}
async function doLogout(): Promise<void> {
await logout()
}
@ -24,11 +31,14 @@ async function doLogout(): Promise<void> {
</div>
<template v-if="loggedIn">
<span class="name" :title="user?.username">👤 {{ user?.displayName }}</span>
<button class="chip stats" @click="openStats">📊 Statistik</button>
<button class="chip research" @click="openResearch">🧪 Forschung</button>
<button v-if="isAdmin" class="chip admin" title="Admin-Dashboard öffnen" @click="openAdmin">🛡 Admin</button>
<button class="chip" @click="doLogout">Abmelden</button>
</template>
<template v-else>
<span class="name">👤 Gast</span>
<button class="chip stats" @click="openStats">📊 Statistik</button>
<button class="chip research" @click="openResearch">🧪 Forschung</button>
<button class="chip login" @click="openAuth">Anmelden</button>
</template>
@ -75,6 +85,13 @@ async function doLogout(): Promise<void> {
.chip.research {
border-color: #3d6f9e;
}
.chip.stats {
border-color: #6e5a9e;
}
.chip.admin {
border-color: #9e503d;
color: #ffb0a0;
}
.chip.login {
border-color: #3f8f37;
color: #b8e6b0;

View file

@ -21,6 +21,7 @@ export const store = reactive({
oidcLabel: 'Mit Single Sign-On anmelden',
showAuth: false,
showResearch: false,
showStats: false,
authStatus: '',
},

View file

@ -298,4 +298,6 @@ export interface UserMetaProfile {
crystals: number
upgrades: Partial<Record<MetaUpgradeId, number>>
stats: UserStats
/** server-side flag: username is listed in ADMIN_USERS */
admin?: boolean
}