feat: add admin dashboard and players pages
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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">← 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 →</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user