mirror of
https://github.com/MrUnknownDE/bierkasten-casino.git
synced 2026-04-19 14:53:45 +02:00
feat: Add Admin and Game pages with stats, user management, and slot game functionality
- Implement AdminPage for managing user stats, transactions, and win chance settings. - Create GamePage for slot game mechanics, including wallet management and leaderboard display. - Update API to support new admin stats and transaction endpoints. - Integrate React Router for navigation between pages. - Enhance user experience with error handling and loading states.
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
// backend/src/routes/admin.ts
|
||||
import { Router } from "express";
|
||||
import { pool, query } from "../db";
|
||||
import { config } from "../config";
|
||||
import { getWinChance, setWinChance } from "../services/gameSettings";
|
||||
|
||||
export const adminRouter = Router();
|
||||
|
||||
@@ -55,13 +57,89 @@ async function requireAdmin(req: any, res: any, next: any) {
|
||||
}
|
||||
}
|
||||
|
||||
// NEUE ROUTE: GET /admin/users/search?q=...
|
||||
// Sucht Benutzer nach ihrem Discord-Namen (case-insensitive)
|
||||
// --- NEUE ENDPUNKTE ---
|
||||
|
||||
// GET /admin/stats -> Liefert Dashboard-Statistiken
|
||||
adminRouter.get("/stats", requireAdmin, async (req, res) => {
|
||||
try {
|
||||
// 1. "Online" User (aktiv in den letzten 5 Minuten)
|
||||
const onlineUsersRes = await pool.query(
|
||||
"SELECT COUNT(*) FROM session WHERE expire > NOW() - INTERVAL '5 minutes'"
|
||||
);
|
||||
const online_users = parseInt(onlineUsersRes.rows[0].count, 10);
|
||||
|
||||
// 2. Gesamte "Geldmenge" (Inflation)
|
||||
const supplyRes = await pool.query(
|
||||
"SELECT SUM(balance) as total_supply FROM wallets"
|
||||
);
|
||||
const total_supply = parseInt(supplyRes.rows[0].total_supply, 10);
|
||||
|
||||
// 3. Gesamtzahl der registrierten User
|
||||
const totalUsersRes = await pool.query("SELECT COUNT(*) FROM users");
|
||||
const total_users = parseInt(totalUsersRes.rows[0].count, 10);
|
||||
|
||||
res.json({
|
||||
online_users,
|
||||
total_users,
|
||||
total_supply,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("GET /admin/stats error:", err);
|
||||
res.status(500).json({ error: "Failed to fetch stats" });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /admin/user/:userId/transactions -> Holt die letzten 50 Transaktionen eines Users
|
||||
adminRouter.get("/user/:userId/transactions", requireAdmin, async (req, res) => {
|
||||
const userId = parseInt(req.params.userId, 10);
|
||||
if (!Number.isInteger(userId) || userId <= 0) {
|
||||
return res.status(400).json({ error: "Invalid userId" });
|
||||
}
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`
|
||||
SELECT id, amount, reason, created_at
|
||||
FROM wallet_transactions
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 50
|
||||
`,
|
||||
[userId]
|
||||
);
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
console.error("GET /admin/user/:userId/transactions error:", err);
|
||||
res.status(500).json({ error: "Failed to fetch transactions" });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /admin/settings/win-chance -> Holt die aktuelle Gewinnchance
|
||||
adminRouter.get("/settings/win-chance", requireAdmin, (req, res) => {
|
||||
res.json({ win_chance_modifier: getWinChance() });
|
||||
});
|
||||
|
||||
// POST /admin/settings/win-chance -> Setzt eine neue Gewinnchance
|
||||
adminRouter.post("/settings/win-chance", requireAdmin, (req, res) => {
|
||||
const { win_chance_modifier } = req.body;
|
||||
const modifier = parseFloat(win_chance_modifier);
|
||||
|
||||
if (!Number.isFinite(modifier) || modifier < 0.1 || modifier > 5.0) {
|
||||
return res.status(400).json({ error: "Invalid modifier (must be between 0.1 and 5.0)" });
|
||||
}
|
||||
|
||||
setWinChance(modifier);
|
||||
res.json({ win_chance_modifier: getWinChance() });
|
||||
});
|
||||
|
||||
|
||||
// --- ALTE ENDPUNKTE BLEIBEN ---
|
||||
|
||||
// GET /admin/users/search?q=...
|
||||
adminRouter.get("/users/search", requireAdmin, async (req, res) => {
|
||||
const searchQuery = req.query.q as string | undefined;
|
||||
|
||||
if (!searchQuery || searchQuery.trim().length < 2) {
|
||||
// Suchen erst ab 2 Zeichen, um die DB nicht zu überlasten
|
||||
return res.json([]);
|
||||
}
|
||||
|
||||
@@ -83,7 +161,7 @@ adminRouter.get("/users/search", requireAdmin, async (req, res) => {
|
||||
ORDER BY discord_name
|
||||
LIMIT 10
|
||||
`,
|
||||
[`%${searchQuery.trim()}%`] // ILIKE für case-insensitive Suche mit Wildcards
|
||||
[`%${searchQuery.trim()}%`]
|
||||
);
|
||||
|
||||
res.json(rows);
|
||||
@@ -93,7 +171,7 @@ adminRouter.get("/users/search", requireAdmin, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// GET /admin/me -> zeigt ob aktueller User Admin ist
|
||||
// GET /admin/me
|
||||
adminRouter.get("/me", requireAuth, async (req: any, res) => {
|
||||
try {
|
||||
const user = await getSessionUser(req);
|
||||
@@ -114,7 +192,6 @@ adminRouter.get("/me", requireAuth, async (req: any, res) => {
|
||||
});
|
||||
|
||||
// GET /admin/user/by-discord/:discordId
|
||||
// Sucht User + Wallet per Discord-ID
|
||||
adminRouter.get(
|
||||
"/user/by-discord/:discordId",
|
||||
requireAdmin,
|
||||
@@ -166,7 +243,6 @@ adminRouter.get(
|
||||
);
|
||||
|
||||
// POST /admin/user/:userId/adjust-balance
|
||||
// Body: { amount: number, reason?: string }
|
||||
adminRouter.post(
|
||||
"/user/:userId/adjust-balance",
|
||||
requireAdmin,
|
||||
@@ -189,10 +265,9 @@ adminRouter.post(
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
// Wallet holen/erzeugen
|
||||
const wRes = await client.query<{
|
||||
user_id: number;
|
||||
balance: number | string; // Wichtig: Kann auch ein String sein!
|
||||
balance: number | string;
|
||||
}>(
|
||||
`
|
||||
SELECT user_id, balance
|
||||
@@ -222,8 +297,6 @@ adminRouter.post(
|
||||
wallet = wRes.rows[0];
|
||||
}
|
||||
|
||||
// --- DER FIX ---
|
||||
// Wandle das Guthaben explizit in eine Zahl um, bevor gerechnet wird.
|
||||
const currentBalance = Number(wallet.balance) || 0;
|
||||
const newBalance = currentBalance + amount;
|
||||
|
||||
@@ -265,7 +338,6 @@ adminRouter.post(
|
||||
);
|
||||
|
||||
// POST /admin/user/:userId/reset-wallet
|
||||
// Body (optional): { reset_balance_to?: number }
|
||||
adminRouter.post(
|
||||
"/user/:userId/reset-wallet",
|
||||
requireAdmin,
|
||||
|
||||
13
backend/src/services/gameSettings.ts
Normal file
13
backend/src/services/gameSettings.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
// backend/src/services/gameSettings.ts
|
||||
// Wir speichern die Einstellung im Speicher des Servers.
|
||||
// Für eine echte Produktionsumgebung würde man dies in der Datenbank speichern.
|
||||
let winChanceModifier = 1.0; // 1.0 = Normal, <1.0 = schlechter, >1.0 = besser
|
||||
|
||||
export function getWinChance(): number {
|
||||
return winChanceModifier;
|
||||
}
|
||||
|
||||
export function setWinChance(modifier: number): void {
|
||||
console.log(`[GameSettings] Win chance modifier changed from ${winChanceModifier} to ${modifier}`);
|
||||
winChanceModifier = modifier;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// path: backend/src/services/slotService.ts
|
||||
// backend/src/services/slotService.ts
|
||||
import { randomInt } from "crypto";
|
||||
import { getWinChance } from "./gameSettings"; // Importieren
|
||||
|
||||
// backend/src/services/slotService.ts
|
||||
export type SymbolId =
|
||||
@@ -68,13 +69,25 @@ export interface SpinResult {
|
||||
}
|
||||
|
||||
function randomSymbol(): SymbolId {
|
||||
const totalWeight = SYMBOLS.reduce((s, sym) => s + sym.weight, 0);
|
||||
const modifier = getWinChance();
|
||||
|
||||
// Erstelle eine temporäre, modifizierte Symbol-Liste
|
||||
const modifiedSymbols = SYMBOLS.map(sym => {
|
||||
let weight = sym.weight;
|
||||
// Erhöhe die Chance auf wertvolle Symbole und das Buch
|
||||
if (["MUG", "BARREL", "BARON", "BOOK"].includes(sym.id)) {
|
||||
weight *= modifier;
|
||||
}
|
||||
return { ...sym, weight };
|
||||
});
|
||||
|
||||
const totalWeight = modifiedSymbols.reduce((s, sym) => s + sym.weight, 0);
|
||||
let r = Math.random() * totalWeight;
|
||||
for (const sym of SYMBOLS) {
|
||||
for (const sym of modifiedSymbols) {
|
||||
r -= sym.weight;
|
||||
if (r <= 0) return sym.id;
|
||||
}
|
||||
return SYMBOLS[0].id;
|
||||
return modifiedSymbols[0].id;
|
||||
}
|
||||
|
||||
// 5 Walzen x 3 Reihen
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.25.1",
|
||||
"recharts": "^2.12.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.3",
|
||||
@@ -18,4 +20,4 @@
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.3.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
1683
frontend/src/App.tsx
1683
frontend/src/App.tsx
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,6 @@
|
||||
// frontend/src/api.ts
|
||||
const API_BASE = "";
|
||||
|
||||
async function apiGet<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
credentials: "include",
|
||||
@@ -41,9 +43,6 @@ export interface MeResponse {
|
||||
discord_name: string;
|
||||
avatar_url: string | null;
|
||||
created_at: string;
|
||||
// Wallet-Daten sind jetzt hier enthalten
|
||||
balance: number | null;
|
||||
last_claim_at: string | null;
|
||||
}
|
||||
|
||||
export interface WalletResponse {
|
||||
@@ -90,6 +89,7 @@ export interface AdminMeResponse {
|
||||
discord_id: string;
|
||||
discord_name: string;
|
||||
}
|
||||
|
||||
export interface AdminUserSearchResult {
|
||||
user_id: number;
|
||||
discord_id: string;
|
||||
@@ -106,6 +106,19 @@ export interface AdminUserSummary {
|
||||
last_claim_at: string | null;
|
||||
}
|
||||
|
||||
export interface AdminStats {
|
||||
online_users: number;
|
||||
total_users: number;
|
||||
total_supply: number;
|
||||
}
|
||||
|
||||
export interface AdminTransaction {
|
||||
id: number;
|
||||
amount: number;
|
||||
reason: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// --- Auth / User ---
|
||||
|
||||
export async function getMe(): Promise<MeResponse> {
|
||||
@@ -113,7 +126,6 @@ export async function getMe(): Promise<MeResponse> {
|
||||
}
|
||||
|
||||
export function getLoginUrl(): string {
|
||||
// Die Route im Backend lautet /auth/discord
|
||||
return `${API_BASE}/auth/discord`;
|
||||
}
|
||||
|
||||
@@ -190,4 +202,22 @@ export async function adminResetWallet(
|
||||
`/admin/user/${userId}/reset-wallet`,
|
||||
{ reset_balance_to: resetBalanceTo }
|
||||
);
|
||||
}
|
||||
|
||||
export async function getAdminStats(): Promise<AdminStats> {
|
||||
return apiGet<AdminStats>("/admin/stats");
|
||||
}
|
||||
|
||||
export async function getAdminUserTransactions(userId: number): Promise<AdminTransaction[]> {
|
||||
return apiGet<AdminTransaction[]>(`/admin/user/${userId}/transactions`);
|
||||
}
|
||||
|
||||
export async function getWinChance(): Promise<{ win_chance_modifier: number }> {
|
||||
return apiGet<{ win_chance_modifier: number }>("/admin/settings/win-chance");
|
||||
}
|
||||
|
||||
export async function setWinChance(modifier: number): Promise<{ win_chance_modifier: number }> {
|
||||
return apiPost<{ win_chance_modifier: number }>("/admin/settings/win-chance", {
|
||||
win_chance_modifier: modifier,
|
||||
});
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
// frontend/src/main.tsx
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
);
|
||||
225
frontend/src/pages/AdminPage.tsx
Normal file
225
frontend/src/pages/AdminPage.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
// frontend/src/pages/AdminPage.tsx
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, BarChart, Bar } from 'recharts';
|
||||
import {
|
||||
AdminMeResponse,
|
||||
AdminStats,
|
||||
getAdminStats,
|
||||
AdminUserSearchResult,
|
||||
adminSearchUsers,
|
||||
adminFindUserByDiscord,
|
||||
AdminUserSummary,
|
||||
getAdminUserTransactions,
|
||||
AdminTransaction,
|
||||
getWinChance,
|
||||
setWinChance,
|
||||
adminAdjustBalance,
|
||||
adminResetWallet
|
||||
} from "../api";
|
||||
|
||||
interface AdminPageProps {
|
||||
adminInfo: AdminMeResponse | null;
|
||||
}
|
||||
|
||||
export const AdminPage: React.FC<AdminPageProps> = ({ adminInfo }) => {
|
||||
const [stats, setStats] = useState<AdminStats | null>(null);
|
||||
const [statsHistory, setStatsHistory] = useState<AdminStats[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searchResults, setSearchResults] = useState<AdminUserSearchResult[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
|
||||
const [selectedUser, setSelectedUser] = useState<AdminUserSummary | null>(null);
|
||||
const [transactions, setTransactions] = useState<AdminTransaction[]>([]);
|
||||
|
||||
const [adjustAmount, setAdjustAmount] = useState<number>(0);
|
||||
const [adjustReason, setAdjustReason] = useState<string>("");
|
||||
|
||||
const [winChance, setWinChance] = useState<number>(1.0);
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAllData = async () => {
|
||||
try {
|
||||
const statsData = await getAdminStats();
|
||||
setStats(statsData);
|
||||
setStatsHistory(prev => [...prev.slice(-9), statsData]); // Keep last 10 entries
|
||||
|
||||
const chanceData = await getWinChance();
|
||||
setWinChance(chanceData.win_chance_modifier);
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to load initial admin data");
|
||||
}
|
||||
};
|
||||
fetchAllData();
|
||||
const interval = setInterval(fetchAllData, 30000); // Refresh stats every 30 seconds
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchQuery.trim().length < 2) {
|
||||
setSearchResults([]);
|
||||
return;
|
||||
}
|
||||
const debounceTimer = setTimeout(async () => {
|
||||
setIsSearching(true);
|
||||
try {
|
||||
const results = await adminSearchUsers(searchQuery);
|
||||
setSearchResults(results);
|
||||
} catch (err: any) { setError(err.message || "Suche fehlgeschlagen"); }
|
||||
finally { setIsSearching(false); }
|
||||
}, 300);
|
||||
return () => clearTimeout(debounceTimer);
|
||||
}, [searchQuery]);
|
||||
|
||||
const handleSelectUser = async (user: AdminUserSearchResult) => {
|
||||
setIsBusy(true);
|
||||
setError(null);
|
||||
setSearchQuery(user.discord_name);
|
||||
setSearchResults([]);
|
||||
try {
|
||||
const [fullUserData, transactionsData] = await Promise.all([
|
||||
adminFindUserByDiscord(user.discord_id),
|
||||
getAdminUserTransactions(user.user_id)
|
||||
]);
|
||||
setSelectedUser(fullUserData);
|
||||
setTransactions(transactionsData);
|
||||
setAdjustAmount(0);
|
||||
setAdjustReason("");
|
||||
} catch (err: any) {
|
||||
setError(err.message || "User-Daten konnten nicht geladen werden");
|
||||
setSelectedUser(null);
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateWinChance = async (modifier: number) => {
|
||||
try {
|
||||
const res = await setWinChance(modifier);
|
||||
setWinChance(res.win_chance_modifier);
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Einstellung konnte nicht gespeichert werden");
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdjustBalance = async () => {
|
||||
if (!selectedUser) return;
|
||||
setIsBusy(true);
|
||||
try {
|
||||
const res = await adminAdjustBalance(selectedUser.user_id, adjustAmount, adjustReason);
|
||||
setSelectedUser(prev => prev ? { ...prev, balance: res.balance } : null);
|
||||
setAdjustAmount(0);
|
||||
setAdjustReason("");
|
||||
// Refresh transactions
|
||||
const transactionsData = await getAdminUserTransactions(selectedUser.user_id);
|
||||
setTransactions(transactionsData);
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Anpassung fehlgeschlagen");
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ color: '#f5f5f5' }}>
|
||||
{error && <div style={{ color: 'red', marginBottom: '1rem' }}>{error}</div>}
|
||||
|
||||
{/* Stats Section */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '16px', marginBottom: '24px' }}>
|
||||
<div style={{ background: '#1a1a2e', padding: '16px', borderRadius: '8px' }}>
|
||||
<h4>Online User (5min)</h4>
|
||||
<p style={{ fontSize: '2rem', margin: 0 }}>{stats?.online_users ?? '...'}</p>
|
||||
</div>
|
||||
<div style={{ background: '#1a1a2e', padding: '16px', borderRadius: '8px' }}>
|
||||
<h4>Registrierte User</h4>
|
||||
<p style={{ fontSize: '2rem', margin: 0 }}>{stats?.total_users ?? '...'}</p>
|
||||
</div>
|
||||
<div style={{ background: '#1a1a2e', padding: '16px', borderRadius: '8px' }}>
|
||||
<h4>Bierkästen im Umlauf</h4>
|
||||
<p style={{ fontSize: '2rem', margin: 0 }}>{stats?.total_supply.toLocaleString('de-DE') ?? '...'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Graph Section */}
|
||||
<div style={{ background: '#1a1a2e', padding: '16px', borderRadius: '8px', marginBottom: '24px' }}>
|
||||
<h3>Geldmengen-Verlauf (Inflation)</h3>
|
||||
<div style={{ width: '100%', height: 300 }}>
|
||||
<ResponsiveContainer>
|
||||
<BarChart data={statsHistory}>
|
||||
<XAxis dataKey="timestamp" tickFormatter={(ts) => new Date().toLocaleTimeString()} />
|
||||
<YAxis allowDecimals={false} />
|
||||
<Tooltip contentStyle={{ background: '#0b0b10', border: '1px solid #555' }} />
|
||||
<Legend />
|
||||
<Bar dataKey="total_supply" name="Bierkästen im Umlauf" fill="#8884d8" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Management Section */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: '24px' }}>
|
||||
{/* Left Column: Settings & User Search */}
|
||||
<div>
|
||||
<div style={{ background: '#1a1a2e', padding: '16px', borderRadius: '8px', marginBottom: '16px' }}>
|
||||
<h4>Globale Einstellungen</h4>
|
||||
<label>Gewinnchance-Modifikator: <strong>{winChance.toFixed(1)}x</strong></label>
|
||||
<input
|
||||
type="range"
|
||||
min="0.1"
|
||||
max="5.0"
|
||||
step="0.1"
|
||||
value={winChance}
|
||||
onChange={(e) => setWinChance(parseFloat(e.target.value))}
|
||||
onMouseUp={(e) => handleUpdateWinChance(parseFloat(e.currentTarget.value))}
|
||||
style={{ width: '100%', marginTop: '8px' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ background: '#1a1a2e', padding: '16px', borderRadius: '8px', position: 'relative' }}>
|
||||
<h4>User Management</h4>
|
||||
<input type="text" value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} placeholder="User suchen..." style={{ width: '100%', padding: '8px', background: '#0b0b10', border: '1px solid #555', color: 'white', borderRadius: '4px' }} />
|
||||
{searchResults.length > 0 && (
|
||||
<div style={{ position: 'absolute', background: '#2c2c4a', border: '1px solid #555', borderRadius: '4px', width: '100%', zIndex: 10 }}>
|
||||
{searchResults.map(u => <div key={u.user_id} onClick={() => handleSelectUser(u)} style={{ padding: '8px', cursor: 'pointer' }}>{u.discord_name}</div>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Selected User Details */}
|
||||
<div>
|
||||
{selectedUser ? (
|
||||
<div style={{ background: '#1a1a2e', padding: '16px', borderRadius: '8px' }}>
|
||||
<h4>Details für: {selectedUser.discord_name}</h4>
|
||||
<p>Guthaben: {selectedUser.balance.toLocaleString('de-DE')} Bierkästen</p>
|
||||
|
||||
{/* Adjust Balance */}
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center', marginBottom: '16px' }}>
|
||||
<input type="number" value={adjustAmount} onChange={e => setAdjustAmount(parseInt(e.target.value))} style={{ padding: '8px', background: '#0b0b10', border: '1px solid #555', color: 'white', borderRadius: '4px' }} />
|
||||
<input type="text" value={adjustReason} onChange={e => setAdjustReason(e.target.value)} placeholder="Grund" style={{ flex: 1, padding: '8px', background: '#0b0b10', border: '1px solid #555', color: 'white', borderRadius: '4px' }} />
|
||||
<button onClick={handleAdjustBalance} disabled={isBusy}>Buchen</button>
|
||||
</div>
|
||||
|
||||
{/* Transactions */}
|
||||
<h5>Letzte Transaktionen</h5>
|
||||
<div style={{ maxHeight: '300px', overflowY: 'auto' }}>
|
||||
{transactions.map(t => (
|
||||
<div key={t.id} style={{ display: 'flex', justifyContent: 'space-between', borderBottom: '1px solid #333', padding: '4px 0' }}>
|
||||
<span>{new Date(t.created_at).toLocaleString('de-DE')}</span>
|
||||
<span>{t.reason}</span>
|
||||
<span style={{ color: t.amount >= 0 ? 'lightgreen' : 'salmon' }}>{t.amount > 0 ? '+' : ''}{t.amount.toLocaleString('de-DE')}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', padding: '40px', background: '#1a1a2e', borderRadius: '8px' }}>
|
||||
<p>Bitte einen User suchen und auswählen, um Details anzuzeigen.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
400
frontend/src/pages/GamePage.tsx
Normal file
400
frontend/src/pages/GamePage.tsx
Normal file
@@ -0,0 +1,400 @@
|
||||
// frontend/src/pages/GamePage.tsx
|
||||
import React, { useEffect, useRef, useState, useMemo } from "react";
|
||||
import {
|
||||
getWallet,
|
||||
claimWallet,
|
||||
spinBookOfBier,
|
||||
getBalanceLeaderboard,
|
||||
getBigWinLeaderboard,
|
||||
MeResponse,
|
||||
WalletResponse,
|
||||
SlotSpinResponse,
|
||||
BalanceLeaderboardEntry,
|
||||
BigWinLeaderboardEntry,
|
||||
} from "../api";
|
||||
|
||||
// Helper-Funktionen und Konstanten, die aus der alten App.tsx kopiert wurden
|
||||
function formatMs(ms: number): string {
|
||||
if (ms <= 0) return "jetzt";
|
||||
const totalSec = Math.floor(ms / 1000);
|
||||
const h = Math.floor(totalSec / 3600);
|
||||
const m = Math.floor((totalSec % 3600) / 60);
|
||||
const s = totalSec % 60;
|
||||
const parts: string[] = [];
|
||||
if (h > 0) parts.push(`${h}h`);
|
||||
if (m > 0) parts.push(`${m}m`);
|
||||
if (s > 0 && h === 0) parts.push(`${s}s`);
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function renderSymbol(sym: string): string {
|
||||
switch (sym) {
|
||||
case "TEN": return "10";
|
||||
case "J": return "J";
|
||||
case "Q": return "Q";
|
||||
case "K": return "K";
|
||||
case "A": return "A";
|
||||
case "MUG": return "🍺";
|
||||
case "BARREL": return "🛢️";
|
||||
case "BARON": return "👑";
|
||||
case "BOOK": return "📖";
|
||||
default: return sym;
|
||||
}
|
||||
}
|
||||
|
||||
const ALL_SYMBOLS = ["TEN", "J", "Q", "K", "A", "MUG", "BARREL", "BARON", "BOOK"];
|
||||
const PAYLINES: [number, number][][] = [
|
||||
[[0, 1],[1, 1],[2, 1],[3, 1],[4, 1]], [[0, 0],[1, 0],[2, 0],[3, 0],[4, 0]],
|
||||
[[0, 2],[1, 2],[2, 2],[3, 2],[4, 2]], [[0, 0],[1, 1],[2, 2],[3, 1],[4, 0]],
|
||||
[[0, 2],[1, 1],[2, 0],[3, 1],[4, 2]], [[0, 0],[1, 1],[2, 2],[3, 2],[4, 2]],
|
||||
[[0, 2],[1, 1],[2, 0],[3, 0],[4, 0]], [[0, 1],[1, 0],[2, 1],[3, 2],[4, 1]],
|
||||
[[0, 1],[1, 2],[2, 1],[3, 0],[4, 1]], [[0, 0],[1, 1],[2, 0],[3, 1],[4, 0]],
|
||||
];
|
||||
|
||||
function createRandomGrid(cols = 5, rows = 3): string[][] {
|
||||
const grid: string[][] = [];
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const col: string[] = [];
|
||||
for (let r = 0; r < rows; r++) {
|
||||
col.push(ALL_SYMBOLS[Math.floor(Math.random() * ALL_SYMBOLS.length)]);
|
||||
}
|
||||
grid.push(col);
|
||||
}
|
||||
return grid;
|
||||
}
|
||||
|
||||
function advanceReels(prev: string[][] | null, reelStopped: boolean[]): string[][] {
|
||||
const cols = 5, rows = 3;
|
||||
const base = prev && prev.length === cols ? prev : createRandomGrid(cols, rows);
|
||||
const next: string[][] = [];
|
||||
for (let c = 0; c < cols; c++) {
|
||||
if (reelStopped[c]) {
|
||||
next.push([...base[c]]);
|
||||
continue;
|
||||
}
|
||||
const col = base[c] || [];
|
||||
const topNew = ALL_SYMBOLS[Math.floor(Math.random() * ALL_SYMBOLS.length)];
|
||||
const mid = col[0] ?? topNew;
|
||||
const bottom = col[1] ?? mid;
|
||||
next.push([topNew, mid, bottom].slice(0, rows));
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
const MIN_SPIN_MS = 2458;
|
||||
const REEL_STOP_STEP_MS = 180;
|
||||
|
||||
interface GamePageProps {
|
||||
me: MeResponse | null;
|
||||
}
|
||||
|
||||
export const GamePage: React.FC<GamePageProps> = ({ me }) => {
|
||||
const [wallet, setWallet] = useState<WalletResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [claiming, setClaiming] = useState(false);
|
||||
const [slotBet, setSlotBet] = useState<number>(10);
|
||||
const [slotSpinning, setSlotSpinning] = useState(false);
|
||||
const [lastSpin, setLastSpin] = useState<SlotSpinResponse | null>(null);
|
||||
const [displayGrid, setDisplayGrid] = useState<string[][] | null>(null);
|
||||
const [reelStopped, setReelStopped] = useState<boolean[]>([false, false, false, false, false]);
|
||||
const reelStoppedRef = useRef<boolean[]>([false, false, false, false, false]);
|
||||
const spinIntervalRef = useRef<number | null>(null);
|
||||
const spinStartTimeRef = useRef<number | null>(null);
|
||||
const pendingResultRef = useRef<SlotSpinResponse | null>(null);
|
||||
const spinAudioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const [balanceLb, setBalanceLb] = useState<BalanceLeaderboardEntry[] | null>(null);
|
||||
const [bigWinLb, setBigWinLb] = useState<BigWinLeaderboardEntry[] | null>(null);
|
||||
const [lbLoading, setLbLoading] = useState(false);
|
||||
const [lbError, setLbError] = useState<string | null>(null);
|
||||
|
||||
const updateReelStopped = (updater: (prev: boolean[]) => boolean[]) => {
|
||||
setReelStopped((prev) => {
|
||||
const next = updater(prev);
|
||||
reelStoppedRef.current = next;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
async function loadGameData() {
|
||||
if (!me) return;
|
||||
try {
|
||||
setError(null);
|
||||
const walletRes = await getWallet();
|
||||
setWallet(walletRes);
|
||||
if (!displayGrid) setDisplayGrid(createRandomGrid());
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Fehler beim Laden der Spieldaten");
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLeaderboard() {
|
||||
try {
|
||||
setLbLoading(true);
|
||||
setLbError(null);
|
||||
const [balance, bigwin] = await Promise.all([getBalanceLeaderboard(), getBigWinLeaderboard()]);
|
||||
setBalanceLb(balance);
|
||||
setBigWinLb(bigwin);
|
||||
} catch (err: any) {
|
||||
setLbError(err.message || "Fehler beim Laden des Leaderboards");
|
||||
} finally {
|
||||
setLbLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
spinAudioRef.current = new Audio("/sounds/spin.mp3");
|
||||
if (spinAudioRef.current) {
|
||||
spinAudioRef.current.loop = false;
|
||||
spinAudioRef.current.volume = 0.8;
|
||||
}
|
||||
return () => {
|
||||
if (spinIntervalRef.current !== null) window.clearInterval(spinIntervalRef.current);
|
||||
if (spinAudioRef.current) spinAudioRef.current.pause();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (me) {
|
||||
loadGameData();
|
||||
loadLeaderboard();
|
||||
} else {
|
||||
setWallet(null);
|
||||
setBalanceLb(null);
|
||||
setBigWinLb(null);
|
||||
}
|
||||
}, [me]);
|
||||
|
||||
const handleClaim = async () => {
|
||||
setClaiming(true);
|
||||
try {
|
||||
const res = await claimWallet();
|
||||
setWallet(res);
|
||||
loadLeaderboard();
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Claim fehlgeschlagen");
|
||||
} finally {
|
||||
setClaiming(false);
|
||||
}
|
||||
};
|
||||
|
||||
const playSpinAudio = () => {
|
||||
const audio = spinAudioRef.current;
|
||||
if (!audio) return;
|
||||
try {
|
||||
audio.pause();
|
||||
audio.currentTime = 0;
|
||||
const p = audio.play();
|
||||
if (p && typeof p.catch === "function") p.catch(() => {});
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const winningPositions = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
if (!lastSpin) return set;
|
||||
lastSpin.line_wins.forEach((lw) => {
|
||||
const line = PAYLINES[lw.lineIndex];
|
||||
for (let i = 0; i < lw.count && i < line.length; i++) {
|
||||
const [col, row] = line[i];
|
||||
set.add(`${col}-${row}`);
|
||||
}
|
||||
});
|
||||
return set;
|
||||
}, [lastSpin]);
|
||||
|
||||
const handleSpin = async () => {
|
||||
if (!wallet) return;
|
||||
if (slotBet <= 0) { setError("Einsatz muss > 0 sein"); return; }
|
||||
if (slotBet > wallet.balance) { setError("Nicht genug Bierkästen für diesen Einsatz"); return; }
|
||||
setSlotSpinning(true);
|
||||
setError(null);
|
||||
pendingResultRef.current = null;
|
||||
spinStartTimeRef.current = Date.now();
|
||||
updateReelStopped(() => [false, false, false, false, false]);
|
||||
playSpinAudio();
|
||||
if (spinIntervalRef.current !== null) window.clearInterval(spinIntervalRef.current);
|
||||
spinIntervalRef.current = window.setInterval(() => {
|
||||
setDisplayGrid((prev) => advanceReels(prev, reelStoppedRef.current));
|
||||
}, 70);
|
||||
|
||||
try {
|
||||
const res = await spinBookOfBier(slotBet);
|
||||
pendingResultRef.current = res;
|
||||
const start = spinStartTimeRef.current || Date.now();
|
||||
const elapsed = Date.now() - start;
|
||||
const baseDelay = Math.max(0, MIN_SPIN_MS - elapsed);
|
||||
for (let reelIndex = 0; reelIndex < 5; reelIndex++) {
|
||||
const delay = baseDelay + reelIndex * REEL_STOP_STEP_MS;
|
||||
window.setTimeout(() => {
|
||||
const result = pendingResultRef.current;
|
||||
if (!result) return;
|
||||
setDisplayGrid((prev) => {
|
||||
const current = prev || createRandomGrid();
|
||||
const next = current.map((col) => [...col]);
|
||||
next[reelIndex] = [...result.grid[reelIndex]];
|
||||
return next;
|
||||
});
|
||||
updateReelStopped((prev) => {
|
||||
const next = [...prev];
|
||||
next[reelIndex] = true;
|
||||
return next;
|
||||
});
|
||||
if (reelIndex === 4) {
|
||||
if (spinIntervalRef.current !== null) window.clearInterval(spinIntervalRef.current);
|
||||
spinIntervalRef.current = null;
|
||||
setLastSpin(result);
|
||||
setWallet((prev) => prev ? { ...prev, balance: result.balance_after } : null);
|
||||
setSlotSpinning(false);
|
||||
loadLeaderboard();
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (spinIntervalRef.current !== null) window.clearInterval(spinIntervalRef.current);
|
||||
if (spinAudioRef.current) spinAudioRef.current.pause();
|
||||
setSlotSpinning(false);
|
||||
setError(err.message || "Spin fehlgeschlagen");
|
||||
}
|
||||
};
|
||||
|
||||
if (!me) {
|
||||
return (
|
||||
<div style={{ textAlign: "center", padding: "40px 0" }}>
|
||||
<h2>Willkommen im Bierbaron Casino!</h2>
|
||||
<p>Bitte logge dich mit Discord ein, um zu spielen.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!wallet) {
|
||||
return <div style={{ textAlign: 'center', padding: '40px 0' }}>Lade Spieldaten...</div>;
|
||||
}
|
||||
|
||||
const gridToShow = displayGrid;
|
||||
const isBigWin = lastSpin && lastSpin.win_amount >= lastSpin.bet_amount * 20;
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && <div style={{ marginBottom: 12, padding: "8px 10px", borderRadius: 8, background: "rgba(255,0,0,0.12)", color: "#ff9d9d", fontSize: "0.85rem" }}>{error}</div>}
|
||||
|
||||
{/* Top-Karten: Wallet + Claim */}
|
||||
<div style={{ display: "flex", flexWrap: "wrap", justifyContent: "center", gap: "16px", alignItems: "stretch", marginBottom: 10 }}>
|
||||
<div style={{ flex: "1 1 260px", maxWidth: 380, padding: "16px", borderRadius: 12, background: "linear-gradient(145deg, #171725, #11111b)", border: "1px solid rgba(255,255,255,0.04)" }}>
|
||||
<h2 style={{ marginTop: 0, fontSize: "1.1rem", textAlign: "center" }}>Dein Bierkonto</h2>
|
||||
<p style={{ fontSize: "2.4rem", margin: "4px 0 8px", textAlign: "center" }}>
|
||||
{wallet.balance.toLocaleString("de-DE")}{" "}
|
||||
<span style={{ fontSize: "1.1rem", color: "#ccc" }}>Bierkästen</span>
|
||||
</p>
|
||||
<p style={{ fontSize: "0.85rem", color: "#aaa", textAlign: "center" }}>
|
||||
Letzter Claim:{" "}
|
||||
{wallet.last_claim_at ? new Date(wallet.last_claim_at).toLocaleString("de-DE") : "noch nie"}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ flex: "1 1 260px", maxWidth: 320, padding: "16px", borderRadius: 12, background: "linear-gradient(145deg, #191926, #131320)", border: "1px solid rgba(255,255,255,0.04)", display: "flex", flexDirection: "column", justifyContent: "space-between" }}>
|
||||
<div style={{ textAlign: "center" }}>
|
||||
<h3 style={{ marginTop: 0, fontSize: "1rem" }}>Stündlicher Claim</h3>
|
||||
<p style={{ fontSize: "0.9rem", color: "#ccc", marginBottom: 8 }}>Alle volle Stunde: <b>+25 Bierkästen</b>.</p>
|
||||
<p style={{ fontSize: "0.85rem", color: "#aaa" }}>Nächster Claim: <b>{formatMs(wallet.next_claim_in_ms)}</b></p>
|
||||
</div>
|
||||
<button onClick={handleClaim} disabled={claiming || wallet.next_claim_in_ms > 0} style={{ marginTop: 12, padding: "10px 14px", borderRadius: 999, border: "none", background: claiming || wallet.next_claim_in_ms > 0 ? "#444" : "linear-gradient(135deg, #ffb347, #ffcc33)", color: claiming || wallet.next_claim_in_ms > 0 ? "#999" : "#222", fontWeight: 600, cursor: claiming || wallet.next_claim_in_ms > 0 ? "default" : "pointer", fontSize: "0.95rem" }}>
|
||||
{claiming ? "Claim läuft..." : wallet.next_claim_in_ms > 0 ? "Noch nicht bereit" : "Bierkästen claimen 🍺"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Book of Bier Section */}
|
||||
<div style={{ marginTop: 10, padding: "18px 16px 20px", borderRadius: 12, background: "linear-gradient(145deg, #191926, #131320)", border: "1px solid rgba(255,255,255,0.04)", textAlign: "center" }}>
|
||||
<h2 style={{ marginTop: 0, fontSize: "1.2rem" }}>🎰 Book of Bier</h2>
|
||||
<p style={{ fontSize: "0.9rem", color: "#ccc", marginBottom: 14 }}>5 Walzen, 3 Reihen, 10 Gewinnlinien. <b>BOOK</b> ({renderSymbol("BOOK")}) ist Scatter: 3+ Bücher geben Bonus-Gewinne.</p>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 12, alignItems: "center", justifyContent: "center", marginBottom: 16 }}>
|
||||
<div style={{ fontSize: "0.9rem" }}>
|
||||
Einsatz:
|
||||
<input type="number" min={1} max={1000} value={slotBet} onChange={(e: React.ChangeEvent<HTMLInputElement>) => { const v = parseInt(e.target.value || "0", 10); setSlotBet(Number.isFinite(v) ? v : 0); }} style={{ width: 90, padding: "4px 6px", borderRadius: 6, border: "1px solid #555", background: "#090910", color: "#f5f5f5", textAlign: "center" }} /> Bierkästen
|
||||
</div>
|
||||
<button onClick={handleSpin} disabled={slotSpinning || wallet.balance <= 0} style={{ padding: "9px 18px", borderRadius: 999, border: "none", background: slotSpinning ? "#444" : "linear-gradient(135deg, #ff6b6b, #f9d976)", color: slotSpinning ? "#aaa" : "#222", fontWeight: 600, cursor: slotSpinning ? "default" : "pointer", fontSize: "1rem", transform: slotSpinning ? "scale(1.05)" : "scale(1)", boxShadow: slotSpinning ? "0 0 18px rgba(255,255,255,0.6)" : "none", transition: "transform 0.15s ease-out, box-shadow 0.15s ease-out" }}>
|
||||
{slotSpinning ? "Rollen..." : "Spin starten 🎰"}
|
||||
</button>
|
||||
<div style={{ fontSize: "0.85rem", color: "#aaa" }}>Kontostand: <b>{wallet.balance.toLocaleString("de-DE")}</b> Bierkästen</div>
|
||||
</div>
|
||||
{gridToShow ? (
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 18, alignItems: "flex-start", justifyContent: "center" }}>
|
||||
<div>
|
||||
<div style={{ fontSize: "0.95rem", marginBottom: 6, textAlign: "center" }}>Letzter Spin:</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(5, 56px)", gridTemplateRows: "repeat(3, 56px)", gap: 6, padding: 8, background: "#0a0a12", borderRadius: 10, border: "1px solid rgba(255,255,255,0.06)", transform: slotSpinning ? "translateY(2px)" : "translateY(0)", transition: "transform 0.1s linear" }}>
|
||||
{[0, 1, 2].map((row) => [0, 1, 2, 3, 4].map((col) => {
|
||||
const key = `${col}-${row}`;
|
||||
const isWinningCell = !slotSpinning && winningPositions.has(key);
|
||||
const isBookCell = !slotSpinning && lastSpin && lastSpin.grid[col][row] === "BOOK";
|
||||
return (
|
||||
<div key={key} style={{ display: "flex", alignItems: "center", justifyContent: "center", fontSize: "1.6rem", borderRadius: 8, border: isWinningCell ? "1px solid rgba(255,215,0,0.9)" : "1px solid rgba(255,255,255,0.08)", boxShadow: isWinningCell ? "0 0 18px rgba(255,215,0,0.9)" : "none", background: isWinningCell ? "radial-gradient(circle, rgba(255,215,0,0.22) 0, transparent 60%)" : isBookCell ? "radial-gradient(circle, rgba(173,216,230,0.25) 0, transparent 60%)" : "transparent", transform: isWinningCell ? "scale(1.22)" : isBookCell ? "scale(1.1)" : "scale(1)", transition: "transform 0.18s ease-out, box-shadow 0.18s ease-out, background 0.18s ease-out, border-color 0.18s ease-out" }}>
|
||||
{renderSymbol(gridToShow[col][row])}
|
||||
</div>
|
||||
);
|
||||
}))}
|
||||
</div>
|
||||
</div>
|
||||
{lastSpin && (
|
||||
<div style={{ fontSize: "0.9rem", minWidth: 230, textAlign: "left" }}>
|
||||
<p style={{ margin: "4px 0" }}>Einsatz: <b>{lastSpin.bet_amount}</b></p>
|
||||
<p style={{ margin: "4px 0" }}>Gewinn: <b style={{ color: lastSpin.win_amount > 0 ? "#7CFC00" : "#ff9d9d", fontSize: lastSpin.win_amount >= lastSpin.bet_amount * 10 ? "1.2rem" : "1rem" }}>{lastSpin.win_amount}</b></p>
|
||||
<p style={{ margin: "4px 0" }}>Bücher im Feld: <b>{lastSpin.book_count}</b></p>
|
||||
{lastSpin.line_wins.length > 0 ? (
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<div style={{ fontSize: "0.85rem", marginBottom: 4 }}>Liniengewinne:</div>
|
||||
<ul style={{ margin: 0, paddingLeft: 18, fontSize: "0.8rem" }}>
|
||||
{lastSpin.line_wins.map((lw, idx) => <li key={idx}>Linie {lw.lineIndex + 1}: {lw.count}x {lw.symbol} → {lw.win}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
) : <p style={{ fontSize: "0.8rem", color: "#999", marginTop: 6 }}>Keine Liniengewinne in diesem Spin.</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : <p style={{ fontSize: "0.85rem", color: "#aaa", marginTop: 8 }}>Noch kein Spin – leg los und teste das Buch des Biers. 🍻</p>}
|
||||
</div>
|
||||
|
||||
{/* Leaderboard Section */}
|
||||
<div style={{ marginTop: 24, padding: "16px", borderRadius: 12, background: "linear-gradient(145deg, #141424, #10101b)", border: "1px solid rgba(255,255,255,0.04)" }}>
|
||||
<h2 style={{ marginTop: 0, marginBottom: 10, fontSize: "1.1rem", textAlign: "center" }}>🏆 Bierbaron Leaderboards</h2>
|
||||
{lbLoading && <p style={{ fontSize: "0.85rem", color: "#aaa", textAlign: "center" }}>Lade Bestenlisten...</p>}
|
||||
{lbError && <p style={{ fontSize: "0.85rem", color: "#ff9d9d", textAlign: "center", marginBottom: 8 }}>{lbError}</p>}
|
||||
{!lbLoading && !lbError && (
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 16, justifyContent: "center", alignItems: "flex-start" }}>
|
||||
<div style={{ flex: "1 1 260px", maxWidth: 380, background: "rgba(0,0,0,0.35)", borderRadius: 10, padding: "10px 12px", border: "1px solid rgba(255,255,255,0.05)" }}>
|
||||
<h3 style={{ margin: 0, marginBottom: 8, fontSize: "0.95rem", textAlign: "center" }}>💰 Meiste Bierkästen (Top 20)</h3>
|
||||
{balanceLb && balanceLb.length > 0 ? (
|
||||
<ol style={{ margin: 0, paddingLeft: 18, fontSize: "0.85rem", maxHeight: 260, overflowY: "auto" }}>
|
||||
{balanceLb.map((entry) => (
|
||||
<li key={entry.user_id} style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8, padding: "2px 0" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||
{entry.avatar_url && <img src={entry.avatar_url} alt="" style={{ width: 20, height: 20, borderRadius: "50%" }} />}
|
||||
<span style={{ maxWidth: 140, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{entry.discord_name}</span>
|
||||
</div>
|
||||
<span style={{ fontVariantNumeric: "tabular-nums" }}>{entry.balance.toLocaleString("de-DE")} 🍺</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
) : <p style={{ fontSize: "0.8rem", color: "#888", textAlign: "center", marginTop: 6 }}>Noch keine Daten.</p>}
|
||||
</div>
|
||||
<div style={{ flex: "1 1 260px", maxWidth: 380, background: "rgba(0,0,0,0.35)", borderRadius: 10, padding: "10px 12px", border: "1px solid rgba(255,255,255,0.05)" }}>
|
||||
<h3 style={{ margin: 0, marginBottom: 8, fontSize: "0.95rem", textAlign: "center" }}>💥 Größter Einzelgewinn (Top 20)</h3>
|
||||
{bigWinLb && bigWinLb.length > 0 ? (
|
||||
<ol style={{ margin: 0, paddingLeft: 18, fontSize: "0.85rem", maxHeight: 260, overflowY: "auto" }}>
|
||||
{bigWinLb.map((entry) => (
|
||||
<li key={entry.user_id} style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8, padding: "2px 0" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||
{entry.avatar_url && <img src={entry.avatar_url} alt="" style={{ width: 20, height: 20, borderRadius: "50%" }} />}
|
||||
<span style={{ maxWidth: 140, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{entry.discord_name}</span>
|
||||
</div>
|
||||
<span style={{ fontVariantNumeric: "tabular-nums" }}>{entry.biggest_win.toLocaleString("de-DE")} 🍺</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
) : <p style={{ fontSize: "0.8rem", color: "#888", textAlign: "center", marginTop: 6 }}>Noch keine Gewinne geloggt.</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user