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:
parent
69fbb015ab
commit
572f82a954
5 changed files with 193 additions and 0 deletions
|
|
@ -9,6 +9,7 @@ import MPBar from '@/components/MPBar.vue'
|
||||||
import ObstaclePanel from '@/components/ObstaclePanel.vue'
|
import ObstaclePanel from '@/components/ObstaclePanel.vue'
|
||||||
import PauseOverlay from '@/components/PauseOverlay.vue'
|
import PauseOverlay from '@/components/PauseOverlay.vue'
|
||||||
import PiPCanvas from '@/components/PiPCanvas.vue'
|
import PiPCanvas from '@/components/PiPCanvas.vue'
|
||||||
|
import PlayerStats from '@/components/PlayerStats.vue'
|
||||||
import ResearchTree from '@/components/ResearchTree.vue'
|
import ResearchTree from '@/components/ResearchTree.vue'
|
||||||
import StartScreen from '@/components/StartScreen.vue'
|
import StartScreen from '@/components/StartScreen.vue'
|
||||||
import TowerPanel from '@/components/TowerPanel.vue'
|
import TowerPanel from '@/components/TowerPanel.vue'
|
||||||
|
|
@ -86,6 +87,7 @@ const badgeColor = computed(() => {
|
||||||
|
|
||||||
<AuthModal />
|
<AuthModal />
|
||||||
<ResearchTree />
|
<ResearchTree />
|
||||||
|
<PlayerStats />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
||||||
171
src/components/PlayerStats.vue
Normal file
171
src/components/PlayerStats.vue
Normal 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>
|
||||||
|
|
@ -5,13 +5,20 @@ import { store } from '@/game/store'
|
||||||
|
|
||||||
const user = computed(() => store.auth.user)
|
const user = computed(() => store.auth.user)
|
||||||
const loggedIn = computed(() => !!user.value && user.value.id !== 'guest')
|
const loggedIn = computed(() => !!user.value && user.value.id !== 'guest')
|
||||||
|
const isAdmin = computed(() => Boolean(user.value?.admin))
|
||||||
|
|
||||||
function openResearch(): void {
|
function openResearch(): void {
|
||||||
store.auth.showResearch = true
|
store.auth.showResearch = true
|
||||||
}
|
}
|
||||||
|
function openStats(): void {
|
||||||
|
store.auth.showStats = true
|
||||||
|
}
|
||||||
function openAuth(): void {
|
function openAuth(): void {
|
||||||
store.auth.showAuth = true
|
store.auth.showAuth = true
|
||||||
}
|
}
|
||||||
|
function openAdmin(): void {
|
||||||
|
window.location.href = '/admin'
|
||||||
|
}
|
||||||
async function doLogout(): Promise<void> {
|
async function doLogout(): Promise<void> {
|
||||||
await logout()
|
await logout()
|
||||||
}
|
}
|
||||||
|
|
@ -24,11 +31,14 @@ async function doLogout(): Promise<void> {
|
||||||
</div>
|
</div>
|
||||||
<template v-if="loggedIn">
|
<template v-if="loggedIn">
|
||||||
<span class="name" :title="user?.username">👤 {{ user?.displayName }}</span>
|
<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 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>
|
<button class="chip" @click="doLogout">Abmelden</button>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<span class="name">👤 Gast</span>
|
<span class="name">👤 Gast</span>
|
||||||
|
<button class="chip stats" @click="openStats">📊 Statistik</button>
|
||||||
<button class="chip research" @click="openResearch">🧪 Forschung</button>
|
<button class="chip research" @click="openResearch">🧪 Forschung</button>
|
||||||
<button class="chip login" @click="openAuth">Anmelden</button>
|
<button class="chip login" @click="openAuth">Anmelden</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -75,6 +85,13 @@ async function doLogout(): Promise<void> {
|
||||||
.chip.research {
|
.chip.research {
|
||||||
border-color: #3d6f9e;
|
border-color: #3d6f9e;
|
||||||
}
|
}
|
||||||
|
.chip.stats {
|
||||||
|
border-color: #6e5a9e;
|
||||||
|
}
|
||||||
|
.chip.admin {
|
||||||
|
border-color: #9e503d;
|
||||||
|
color: #ffb0a0;
|
||||||
|
}
|
||||||
.chip.login {
|
.chip.login {
|
||||||
border-color: #3f8f37;
|
border-color: #3f8f37;
|
||||||
color: #b8e6b0;
|
color: #b8e6b0;
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ export const store = reactive({
|
||||||
oidcLabel: 'Mit Single Sign-On anmelden',
|
oidcLabel: 'Mit Single Sign-On anmelden',
|
||||||
showAuth: false,
|
showAuth: false,
|
||||||
showResearch: false,
|
showResearch: false,
|
||||||
|
showStats: false,
|
||||||
authStatus: '',
|
authStatus: '',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -298,4 +298,6 @@ export interface UserMetaProfile {
|
||||||
crystals: number
|
crystals: number
|
||||||
upgrades: Partial<Record<MetaUpgradeId, number>>
|
upgrades: Partial<Record<MetaUpgradeId, number>>
|
||||||
stats: UserStats
|
stats: UserStats
|
||||||
|
/** server-side flag: username is listed in ADMIN_USERS */
|
||||||
|
admin?: boolean
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue