feat: add admin dashboard and players pages

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
achmad
2026-05-29 17:01:03 +07:00
parent 155e837041
commit 2239f590ab
6 changed files with 239 additions and 0 deletions
+35
View File
@@ -0,0 +1,35 @@
'use client';
import { useEffect, useState } from 'react';
type Stats = { players: number; games: number; activeBps: number; questsCompleted: number };
export default function DashboardPage() {
const [stats, setStats] = useState<Stats | null>(null);
useEffect(() => {
fetch('/api/admin/stats').then(r => r.json()).then(setStats).catch(() => {});
}, []);
if (!stats) return <div className="text-gray-400">Loading...</div>;
const cards = [
{ label: 'Players', value: stats.players, color: 'text-blue-400' },
{ label: 'Games Played', value: stats.games, color: 'text-green-400' },
{ label: 'Active Battle Passes', value: stats.activeBps, color: 'text-amber-400' },
{ label: 'Quests Completed', value: stats.questsCompleted, color: 'text-purple-400' },
];
return (
<div>
<h1 className="text-2xl font-bold mb-6">Dashboard</h1>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{cards.map(c => (
<div key={c.label} className="bg-gray-800 rounded-lg p-5">
<div className="text-sm text-gray-400 mb-1">{c.label}</div>
<div className={`text-3xl font-bold ${c.color}`}>{c.value}</div>
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,90 @@
'use client';
import { useEffect, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
export default function PlayerDetailPage() {
const { steamId } = useParams();
const router = useRouter();
const [data, setData] = useState<any>(null);
const [form, setForm] = useState<any>({});
const [msg, setMsg] = useState('');
useEffect(() => {
fetch(`/api/admin/players/${steamId}`).then(r => r.json()).then(d => {
setData(d);
setForm(d.player || {});
}).catch(() => router.push('/admin/players'));
}, [steamId, router]);
const save = async () => {
const res = await fetch(`/api/admin/players/${steamId}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(form),
});
const result = await res.json();
setMsg(result.success ? 'Saved!' : 'Error: ' + (result.error || ''));
};
if (!data) return <div className="text-gray-400">Loading...</div>;
return (
<div>
<a href="/admin/players" className="text-amber-400 mb-4 block">&larr; Back to Players</a>
<h1 className="text-2xl font-bold mb-4">Player: {data.player?.player_name}</h1>
<p className="font-mono text-sm text-gray-400 mb-4">Steam ID: {steamId}</p>
{msg && <p className="mb-4 text-sm" style={{ color: msg.startsWith('Saved') ? '#4ade80' : '#f87171' }}>{msg}</p>}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-gray-800 rounded-lg p-4">
<h2 className="text-lg font-semibold mb-3">Profile</h2>
<div className="grid grid-cols-2 gap-3">
{['player_name', 'profile_level', 'free_currency', 'donate_currency', 'dust_currency'].map(f => (
<div key={f}>
<label className="text-xs text-gray-400 block mb-1">{f}</label>
<input type={f === 'player_name' ? 'text' : 'number'}
value={(form as any)[f] ?? ''}
onChange={e => setForm({ ...form, [f]: e.target.value })}
className="w-full px-2 py-1 bg-gray-700 rounded text-white text-sm" />
</div>
))}
</div>
<button onClick={save} className="mt-4 bg-amber-500 hover:bg-amber-600 text-black px-4 py-2 rounded font-semibold">Save</button>
</div>
<div className="bg-gray-800 rounded-lg p-4">
<h2 className="text-lg font-semibold mb-3">Battle Pass</h2>
{data.battlePass && (
<div className="text-sm space-y-1">
<p>Level: {data.battlePass.level}</p>
<p>XP: {data.battlePass.experience}</p>
<p>Premium: {data.battlePass.has_premium ? 'Yes' : 'No'}</p>
<a href={`/admin/battlepass/${steamId}`} className="text-amber-400 block mt-2">Edit BP &rarr;</a>
</div>
)}
</div>
<div className="bg-gray-800 rounded-lg p-4">
<h2 className="text-lg font-semibold mb-3">Recent Purchases ({data.purchases?.length})</h2>
<div className="max-h-48 overflow-y-auto text-sm space-y-1">
{data.purchases?.map((p: any, i: number) => (
<div key={i} className="text-gray-300">{p.item_id} <span className="text-gray-500">({p.item_category})</span></div>
))}
{!data.purchases?.length && <p className="text-gray-500">None</p>}
</div>
</div>
<div className="bg-gray-800 rounded-lg p-4">
<h2 className="text-lg font-semibold mb-3">Recent Matches ({data.matches?.length})</h2>
<div className="max-h-48 overflow-y-auto text-sm space-y-1">
{data.matches?.map((m: any, i: number) => (
<div key={i} className="text-gray-300">
{m.hero} <span className={m.result === 'win' ? 'text-green-400' : 'text-red-400'}>{m.result}</span>
<span className="text-gray-500"> ({m.difficulty})</span>
</div>
))}
{!data.matches?.length && <p className="text-gray-500">None</p>}
</div>
</div>
</div>
</div>
);
}
+51
View File
@@ -0,0 +1,51 @@
'use client';
import { useEffect, useState } from 'react';
export default function PlayersListPage() {
const [players, setPlayers] = useState<any[]>([]);
const [search, setSearch] = useState('');
useEffect(() => {
fetch('/api/admin/players').then(r => r.json()).then(setPlayers).catch(() => {});
}, []);
const filtered = players.filter(p =>
p.steam_id?.includes(search) || p.player_name?.toLowerCase().includes(search.toLowerCase())
);
return (
<div>
<h1 className="text-2xl font-bold mb-4">Players</h1>
<input type="text" placeholder="Search by steam_id or name..." value={search}
onChange={e => setSearch(e.target.value)}
className="w-full max-w-md px-3 py-2 bg-gray-800 rounded mb-4 text-white" />
<div className="bg-gray-800 rounded-lg overflow-hidden">
<table className="w-full text-left">
<thead>
<tr className="border-b border-gray-700 text-sm text-gray-400">
<th className="p-3">Steam ID</th>
<th className="p-3">Name</th>
<th className="p-3">Level</th>
<th className="p-3">Free</th>
<th className="p-3">Donate</th>
<th className="p-3">Dust</th>
</tr>
</thead>
<tbody>
{filtered.map(p => (
<tr key={p.steam_id} className="border-b border-gray-700 hover:bg-gray-750 cursor-pointer"
onClick={() => window.location.href = `/admin/players/${p.steam_id}`}>
<td className="p-3 font-mono text-sm">{p.steam_id}</td>
<td className="p-3">{p.player_name}</td>
<td className="p-3">{p.profile_level}</td>
<td className="p-3">{p.free_currency}</td>
<td className="p-3">{p.donate_currency}</td>
<td className="p-3">{p.dust_currency}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from 'next/server';
import { getDb } from '@/lib/db';
export async function GET(request: NextRequest, { params }: { params: { steamId: string } }) {
const db = getDb();
const player = db.prepare('SELECT * FROM players WHERE steam_id = ?').get(params.steamId);
if (!player) return NextResponse.json({ error: 'Not found' }, { status: 404 });
const bp = db.prepare('SELECT * FROM battle_passes WHERE steam_id = ?').get(params.steamId);
const purchases = db.prepare('SELECT * FROM purchases WHERE steam_id = ? ORDER BY created_at DESC LIMIT 50').all(params.steamId);
const matches = db.prepare('SELECT * FROM game_history WHERE steam_id = ? ORDER BY created_at DESC LIMIT 20').all(params.steamId);
const effects = db.prepare('SELECT effects FROM active_effects WHERE steam_id = ?').get(params.steamId) as any;
return NextResponse.json({
player, battlePass: bp, purchases, matches,
activeEffects: effects ? JSON.parse(effects.effects) : {},
});
}
export async function PUT(request: NextRequest, { params }: { params: { steamId: string } }) {
const body = await request.json();
const db = getDb();
const fields: string[] = [];
const values: any[] = [];
for (const key of ['player_name', 'profile_level', 'free_currency', 'donate_currency', 'dust_currency']) {
if (body[key] !== undefined) {
fields.push(`${key} = ?`);
values.push(body[key]);
}
}
if (body.sounds_wheel !== undefined) {
fields.push('sounds_wheel = ?');
values.push(JSON.stringify(body.sounds_wheel));
}
if (fields.length === 0) return NextResponse.json({ error: 'No fields to update' }, { status: 400 });
fields.push("updated_at = datetime('now')");
values.push(params.steamId);
db.prepare(`UPDATE players SET ${fields.join(', ')} WHERE steam_id = ?`).run(...values);
return NextResponse.json({ success: true });
}
@@ -0,0 +1,8 @@
import { NextResponse } from 'next/server';
import { getDb } from '@/lib/db';
export async function GET() {
const db = getDb();
const players = db.prepare('SELECT * FROM players ORDER BY updated_at DESC LIMIT 100').all();
return NextResponse.json(players);
}
+12
View File
@@ -0,0 +1,12 @@
import { NextResponse } from 'next/server';
import { getDb } from '@/lib/db';
export async function GET() {
const db = getDb();
const players = (db.prepare('SELECT COUNT(*) as c FROM players').get() as any).c;
const games = (db.prepare('SELECT COUNT(*) as c FROM game_history').get() as any).c;
const activeBps = (db.prepare('SELECT COUNT(*) as c FROM battle_passes').get() as any).c;
const questsCompleted = (db.prepare('SELECT COUNT(*) as c FROM battle_pass_quests WHERE completed = 1').get() as any).c;
return NextResponse.json({ players, games, activeBps, questsCompleted });
}