feat(panel): implement player dashboard with stats and inventory
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,138 @@
|
|||||||
export default function PlayerDashboard({ userId }: { userId: string }) {
|
import { useState, useEffect } from "react";
|
||||||
return <div className="text-text-tertiary">Player Dashboard — loading...</div>;
|
import { get } from "../lib/api";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
interface UserData {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
level: number;
|
||||||
|
xp: string;
|
||||||
|
balance: string;
|
||||||
|
className: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InventoryItem {
|
||||||
|
itemId: string;
|
||||||
|
name: string;
|
||||||
|
quantity: number;
|
||||||
|
rarity: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PlayerDashboard({ userId }: { userId: string }) {
|
||||||
|
const [user, setUser] = useState<UserData | null>(null);
|
||||||
|
const [inventory, setInventory] = useState<InventoryItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const [userData, invData] = await Promise.all([
|
||||||
|
get<UserData>(`/api/users/${userId}`),
|
||||||
|
get<{ items: InventoryItem[] }>(`/api/users/${userId}/inventory`).catch(() => ({ items: [] })),
|
||||||
|
]);
|
||||||
|
setUser(userData);
|
||||||
|
setInventory(invData.items ?? []);
|
||||||
|
} catch (e) {
|
||||||
|
setError("Failed to load profile");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
load();
|
||||||
|
}, [userId]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-16">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-text-tertiary" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !user) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-16 text-sm text-text-tertiary">
|
||||||
|
{error ?? "Could not load your profile."}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="font-display text-lg font-semibold mb-6">Dashboard</h1>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||||
|
<StatCard label="Level" value={String(user.level)} accent="primary" subtitle={user.className ?? undefined} />
|
||||||
|
<StatCard label="Gold" value={formatNumber(user.balance)} accent="gold" />
|
||||||
|
<StatCard label="XP" value={formatNumber(user.xp)} accent="info" />
|
||||||
|
<StatCard label="Items" value={String(inventory.length)} accent="success" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-card rounded-lg border border-border">
|
||||||
|
<div className="flex items-center gap-2 px-5 py-3 border-b border-border">
|
||||||
|
<span className="text-sm font-semibold">Inventory</span>
|
||||||
|
<span className="text-xs text-text-disabled">({inventory.length})</span>
|
||||||
|
</div>
|
||||||
|
{inventory.length === 0 ? (
|
||||||
|
<div className="px-5 py-6 text-center text-sm text-text-tertiary">No items yet</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{inventory.slice(0, 10).map((item, i) => (
|
||||||
|
<div key={i} className="flex items-center justify-between px-5 py-3 hover:bg-raised/40 transition-colors">
|
||||||
|
<div className="text-sm font-medium">{item.name}</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`text-[10px] font-semibold px-1.5 py-0.5 rounded ${rarityColor(item.rarity)}`}>
|
||||||
|
{item.rarity}
|
||||||
|
</span>
|
||||||
|
{item.quantity > 1 && (
|
||||||
|
<span className="text-xs text-text-tertiary font-mono">x{item.quantity}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{inventory.length > 10 && (
|
||||||
|
<div className="px-5 py-2 text-xs text-text-disabled text-center">
|
||||||
|
and {inventory.length - 10} more
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({ label, value, accent, subtitle }: { label: string; value: string; accent: string; subtitle?: string }) {
|
||||||
|
const borderColor: Record<string, string> = {
|
||||||
|
primary: "border-l-primary",
|
||||||
|
gold: "border-l-gold",
|
||||||
|
info: "border-l-info",
|
||||||
|
success: "border-l-success",
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className={`bg-gradient-to-br from-card to-surface border border-border rounded-lg p-5 border-l-4 ${borderColor[accent] ?? ""}`}>
|
||||||
|
<div className="text-[11px] font-semibold uppercase tracking-wider text-text-tertiary">{label}</div>
|
||||||
|
<div className="text-2xl font-bold font-display tracking-tight mt-1">{value}</div>
|
||||||
|
{subtitle && <div className="text-sm text-text-tertiary mt-0.5">{subtitle}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(val: string): string {
|
||||||
|
const n = Number(val);
|
||||||
|
if (isNaN(n)) return val;
|
||||||
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||||
|
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
||||||
|
return n.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function rarityColor(rarity: string): string {
|
||||||
|
switch (rarity?.toUpperCase()) {
|
||||||
|
case "C": return "bg-gray-500/20 text-gray-400";
|
||||||
|
case "R": return "bg-blue-500/20 text-blue-400";
|
||||||
|
case "SR": return "bg-purple-500/20 text-purple-400";
|
||||||
|
case "SSR": return "bg-amber-500/20 text-amber-400";
|
||||||
|
default: return "bg-gray-500/20 text-gray-400";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user