feat: add admin dashboard with sidebar navigation and stats overview

Replace placeholder panel with a full dashboard landing page showing
bot stats, leaderboards, and recent events from /api/stats. Add
sidebar navigation with placeholder pages for Users, Items, Classes,
Quests, Lootdrops, Moderation, Transactions, and Settings. Update
theme to match Aurora design guidelines.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
syntaxbullet
2026-02-14 12:23:13 +01:00
parent 04e5851387
commit 9471b6fdab
21 changed files with 1380 additions and 1361 deletions

View File

@@ -1,133 +0,0 @@
import { useState, useEffect, useCallback } from "react";
import { get, post, put, del } from "../lib/api";
import DataTable, { type Column } from "../components/DataTable";
import Modal from "../components/Modal";
interface GameClass {
id: string;
name: string;
balance: string;
roleId: string | null;
}
interface ClassesResponse {
classes: GameClass[];
}
export default function Classes() {
const [classes, setClasses] = useState<GameClass[]>([]);
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<GameClass | null>(null);
const [form, setForm] = useState<{ id?: string; name: string; balance: string; roleId: string | null }>({ name: "", balance: "0", roleId: null });
const [saving, setSaving] = useState(false);
const fetchClasses = useCallback(() => {
setLoading(true);
get<ClassesResponse>("/api/classes")
.then((data) => setClasses(data.classes))
.catch(() => {})
.finally(() => setLoading(false));
}, []);
useEffect(() => { fetchClasses(); }, [fetchClasses]);
const openCreate = () => {
setEditing(null);
setForm({ id: "", name: "", balance: "0", roleId: null });
setModalOpen(true);
};
const openEdit = (cls: GameClass) => {
setEditing(cls);
setForm({ name: cls.name, balance: cls.balance, roleId: cls.roleId });
setModalOpen(true);
};
const handleSave = async () => {
setSaving(true);
try {
if (editing) {
await put(`/api/classes/${editing.id}`, { name: form.name, balance: form.balance, roleId: form.roleId });
} else {
await post("/api/classes", { id: form.id, name: form.name, balance: form.balance, roleId: form.roleId });
}
setModalOpen(false);
fetchClasses();
} catch (e) {
alert(typeof e === "object" && e && "error" in e ? (e as { error: string }).error : "Failed to save");
} finally {
setSaving(false);
}
};
const handleDelete = async (cls: GameClass) => {
if (!confirm(`Delete "${cls.name}"?`)) return;
await del(`/api/classes/${cls.id}`);
fetchClasses();
};
const columns: Column<GameClass>[] = [
{ key: "id", header: "ID", className: "w-24" },
{ key: "name", header: "Name" },
{ key: "balance", header: "Balance", render: (r) => BigInt(r.balance).toLocaleString() },
{ key: "roleId", header: "Role ID", render: (r) => r.roleId ?? "—" },
{
key: "actions",
header: "",
className: "w-24",
render: (row) => (
<div className="flex gap-1">
<button className="btn btn-ghost btn-xs" onClick={(e) => { e.stopPropagation(); openEdit(row); }}>Edit</button>
<button className="btn btn-ghost btn-xs text-error" onClick={(e) => { e.stopPropagation(); handleDelete(row); }}>Del</button>
</div>
),
},
];
return (
<div>
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-bold">Classes</h1>
<button className="btn btn-primary btn-sm" onClick={openCreate}>+ New Class</button>
</div>
<DataTable columns={columns} data={classes as unknown as Record<string, unknown>[]} keyField="id" loading={loading} />
<Modal
open={modalOpen}
onClose={() => setModalOpen(false)}
title={editing ? `Edit: ${editing.name}` : "New Class"}
actions={
<>
<button className="btn btn-ghost" onClick={() => setModalOpen(false)}>Cancel</button>
<button className="btn btn-primary" onClick={handleSave} disabled={saving}>
{saving ? <span className="loading loading-spinner loading-sm" /> : "Save"}
</button>
</>
}
>
<div className="space-y-3">
{!editing && (
<div className="form-control">
<label className="label"><span className="label-text">ID (Discord role snowflake or unique number)</span></label>
<input className="input input-bordered input-sm" value={form.id ?? ""} onChange={(e) => setForm({ ...form, id: e.target.value })} />
</div>
)}
<div className="form-control">
<label className="label"><span className="label-text">Name</span></label>
<input className="input input-bordered input-sm" maxLength={50} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</div>
<div className="form-control">
<label className="label"><span className="label-text">Balance</span></label>
<input className="input input-bordered input-sm" value={form.balance} onChange={(e) => setForm({ ...form, balance: e.target.value })} />
</div>
<div className="form-control">
<label className="label"><span className="label-text">Role ID (Discord)</span></label>
<input className="input input-bordered input-sm" placeholder="Optional" value={form.roleId ?? ""} onChange={(e) => setForm({ ...form, roleId: e.target.value || null })} />
</div>
</div>
</Modal>
</div>
);
}

View File

@@ -1,84 +1,297 @@
import { useState, useEffect, useRef } from "react";
import { get } from "../lib/api";
import {
Users,
Coins,
TrendingUp,
Gift,
Loader2,
AlertTriangle,
CheckCircle,
XCircle,
Info,
Clock,
Wifi,
Trophy,
Crown,
Gem,
Wrench,
} from "lucide-react";
import { cn } from "../lib/utils";
import { useDashboard, type DashboardStats } from "../lib/useDashboard";
interface Stats {
bot: { name: string; avatarUrl: string | null; status: string | null };
guilds: { count: number };
users: { total: number; active: number };
economy: { totalWealth: string; avgLevel: number; topStreak: number; totalItems: number };
commands: { total: number; active: number; disabled: number };
ping: { avg: number };
uptime: number;
function formatNumber(n: number | string): string {
const num = typeof n === "string" ? parseFloat(n) : n;
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`;
if (num >= 1_000) return `${(num / 1_000).toFixed(1)}K`;
return num.toLocaleString();
}
export default function Dashboard() {
const [stats, setStats] = useState<Stats | null>(null);
const [loading, setLoading] = useState(true);
const wsRef = useRef<WebSocket | null>(null);
useEffect(() => {
get<Stats>("/api/stats")
.then(setStats)
.catch(() => {})
.finally(() => setLoading(false));
// Connect WebSocket for live updates
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
const ws = new WebSocket(`${protocol}//${location.host}/ws`);
wsRef.current = ws;
ws.onmessage = (e) => {
try {
const msg = JSON.parse(e.data);
if (msg.type === "STATS_UPDATE") setStats(msg.data);
} catch {}
};
return () => ws.close();
}, []);
if (loading) {
return (
<div className="flex justify-center p-12">
<span className="loading loading-spinner loading-lg" />
</div>
);
function formatUptime(ms: number): string {
const hours = Math.floor(ms / 3_600_000);
const minutes = Math.floor((ms % 3_600_000) / 60_000);
if (hours >= 24) {
const days = Math.floor(hours / 24);
return `${days}d ${hours % 24}h`;
}
return `${hours}h ${minutes}m`;
}
if (!stats) return <div className="alert alert-error">Failed to load stats</div>;
function timeAgo(ts: string | Date): string {
const diff = Date.now() - new Date(ts).getTime();
const mins = Math.floor(diff / 60_000);
if (mins < 1) return "just now";
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.floor(hours / 24)}d ago`;
}
const uptimeHours = Math.floor((stats.uptime ?? 0) / 3600);
const uptimeMins = Math.floor(((stats.uptime ?? 0) % 3600) / 60);
const eventIcons = {
success: CheckCircle,
error: XCircle,
warn: AlertTriangle,
info: Info,
} as const;
const eventColors = {
success: "text-success",
error: "text-destructive",
warn: "text-warning",
info: "text-info",
} as const;
function StatCard({
icon: Icon,
label,
value,
sub,
accent = "border-primary",
}: {
icon: React.ComponentType<{ className?: string }>;
label: string;
value: string;
sub?: string;
accent?: string;
}) {
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 mb-8">
<div className="stat bg-base-100 rounded-box shadow">
<div className="stat-title">Uptime</div>
<div className="stat-value text-lg">{uptimeHours}h {uptimeMins}m</div>
<div className="stat-desc">Ping: {stats.ping?.avg ?? 0}ms</div>
</div>
<div className="stat bg-base-100 rounded-box shadow">
<div className="stat-title">Guilds</div>
<div className="stat-value text-lg">{stats.guilds?.count ?? 0}</div>
</div>
<div className="stat bg-base-100 rounded-box shadow">
<div className="stat-title">Users</div>
<div className="stat-value text-lg">{stats.users?.total ?? 0}</div>
<div className="stat-desc">{stats.users?.active ?? 0} active</div>
</div>
<div className="stat bg-base-100 rounded-box shadow">
<div className="stat-title">Economy</div>
<div className="stat-value text-lg">{Number(stats.economy?.totalWealth ?? 0).toLocaleString()}g</div>
<div className="stat-desc">{stats.economy?.totalItems ?? 0} items in circulation</div>
</div>
<div
className={cn(
"bg-gradient-to-br from-card to-surface rounded-lg border border-border p-6 border-l-4",
accent
)}
>
<div className="flex items-center gap-3 mb-3">
<Icon className="w-5 h-5 text-primary" />
<span className="text-[11px] font-semibold uppercase tracking-wider text-text-tertiary">
{label}
</span>
</div>
<div className="text-3xl font-bold font-display tracking-tight">{value}</div>
{sub && <div className="text-sm text-text-tertiary mt-1">{sub}</div>}
</div>
);
}
<div className="text-sm text-base-content/50">
Live data via WebSocket updates every 5 seconds
function LeaderboardColumn({
title,
icon: Icon,
entries,
valueKey,
valuePrefix,
}: {
title: string;
icon: React.ComponentType<{ className?: string }>;
entries: Array<{ username: string; [k: string]: unknown }>;
valueKey: string;
valuePrefix?: string;
}) {
return (
<div className="bg-card rounded-lg border border-border">
<div className="flex items-center gap-2 px-5 py-4 border-b border-border">
<Icon className="w-4 h-4 text-primary" />
<h3 className="text-sm font-semibold">{title}</h3>
</div>
<div className="divide-y divide-border">
{entries.length === 0 && (
<div className="px-5 py-4 text-sm text-text-tertiary">No data</div>
)}
{entries.slice(0, 10).map((entry, i) => (
<div
key={entry.username}
className="flex items-center justify-between px-5 py-3 hover:bg-raised/40 transition-colors"
>
<div className="flex items-center gap-3">
<span
className={cn(
"w-6 text-xs font-mono font-medium text-right",
i === 0
? "text-gold"
: i === 1
? "text-text-secondary"
: i === 2
? "text-warning"
: "text-text-tertiary"
)}
>
#{i + 1}
</span>
<span className="text-sm">{entry.username}</span>
</div>
<span className="text-sm font-mono text-text-secondary">
{valuePrefix}
{formatNumber(entry[valueKey] as string | number)}
</span>
</div>
))}
</div>
</div>
);
}
export default function Dashboard() {
const { data, loading, error } = useDashboard();
if (loading) {
return (
<div className="flex items-center justify-center py-32">
<Loader2 className="w-6 h-6 animate-spin text-primary" />
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center py-32">
<div className="text-center">
<AlertTriangle className="w-8 h-8 text-warning mx-auto mb-3" />
<p className="text-sm text-text-tertiary">{error}</p>
</div>
</div>
);
}
if (!data) return null;
return <DashboardContent data={data} />;
}
function DashboardContent({ data }: { data: DashboardStats }) {
return (
<div className="space-y-8">
{/* Maintenance banner */}
{data.maintenanceMode && (
<div className="flex items-center gap-3 bg-warning/10 border border-warning/30 rounded-lg px-5 py-3">
<Wrench className="w-4 h-4 text-warning shrink-0" />
<span className="text-sm text-warning font-medium">
Maintenance mode is active
</span>
</div>
)}
{/* Stat cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
icon={Users}
label="Total Users"
value={formatNumber(data.users.total)}
sub={`${formatNumber(data.users.active)} active`}
accent="border-info"
/>
<StatCard
icon={Coins}
label="Total Wealth"
value={formatNumber(data.economy.totalWealth)}
accent="border-gold"
/>
<StatCard
icon={TrendingUp}
label="Avg Level"
value={data.economy.avgLevel.toFixed(1)}
sub={`Top streak: ${data.economy.topStreak}`}
accent="border-success"
/>
<StatCard
icon={Gift}
label="Active Lootdrops"
value={String(data.activeLootdrops?.length ?? 0)}
accent="border-primary"
/>
</div>
{/* Leaderboards */}
{data.leaderboards && (
<section>
<h2 className="font-display text-lg font-semibold mb-4">Leaderboards</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<LeaderboardColumn
title="Top Levels"
icon={Trophy}
entries={data.leaderboards.topLevels}
valueKey="level"
valuePrefix="Lv. "
/>
<LeaderboardColumn
title="Top Wealth"
icon={Crown}
entries={data.leaderboards.topWealth}
valueKey="balance"
/>
<LeaderboardColumn
title="Top Net Worth"
icon={Gem}
entries={data.leaderboards.topNetWorth}
valueKey="netWorth"
/>
</div>
</section>
)}
{/* Recent Events */}
{data.recentEvents.length > 0 && (
<section>
<h2 className="font-display text-lg font-semibold mb-4">Recent Events</h2>
<div className="bg-card rounded-lg border border-border divide-y divide-border">
{data.recentEvents.slice(0, 20).map((event, i) => {
const Icon = eventIcons[event.type];
return (
<div
key={i}
className="flex items-start gap-3 px-5 py-3 hover:bg-raised/40 transition-colors"
>
<Icon
className={cn("w-4 h-4 mt-0.5 shrink-0", eventColors[event.type])}
/>
<span className="text-sm flex-1">
{event.icon && <span className="mr-1.5">{event.icon}</span>}
{event.message}
</span>
<span className="text-xs text-text-tertiary font-mono whitespace-nowrap">
{timeAgo(event.timestamp)}
</span>
</div>
);
})}
</div>
</section>
)}
{/* Bot status footer */}
<footer className="flex flex-wrap items-center gap-x-6 gap-y-2 text-xs text-text-tertiary border-t border-border pt-6">
<span className="font-medium text-text-secondary">{data.bot.name}</span>
<span className="flex items-center gap-1.5">
<Wifi className="w-3 h-3" />
{Math.round(data.ping.avg)}ms
</span>
<span className="flex items-center gap-1.5">
<Clock className="w-3 h-3" />
{formatUptime(data.uptime)}
</span>
{data.bot.status && (
<span className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-success" />
{data.bot.status}
</span>
)}
</footer>
</div>
);
}

View File

@@ -1,265 +0,0 @@
import { useState, useEffect, useCallback } from "react";
import { get, post, put, del } from "../lib/api";
import DataTable, { type Column } from "../components/DataTable";
import Modal from "../components/Modal";
interface Item {
id: number;
name: string;
description: string | null;
type: string;
rarity: string;
price: string | null;
iconUrl: string;
imageUrl: string;
usageData: unknown;
}
interface ItemsResponse {
items: Item[];
total: number;
}
const ITEM_TYPES = ["CONSUMABLE", "EQUIPMENT", "MATERIAL", "LOOTBOX", "COLLECTIBLE", "KEY", "TOOL"];
const ITEM_RARITIES = ["C", "R", "SR", "SSR"];
const emptyForm = () => ({
name: "",
description: "",
type: "MATERIAL",
rarity: "C",
price: "",
iconUrl: "",
imageUrl: "",
usageData: null as unknown,
});
export default function Items() {
const [items, setItems] = useState<Item[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [typeFilter, setTypeFilter] = useState("");
const [rarityFilter, setRarityFilter] = useState("");
const [page, setPage] = useState(0);
const limit = 25;
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<Item | null>(null);
const [form, setForm] = useState(emptyForm());
const [saving, setSaving] = useState(false);
const fetchItems = useCallback(() => {
setLoading(true);
const params = new URLSearchParams({ limit: String(limit), offset: String(page * limit) });
if (search) params.set("search", search);
if (typeFilter) params.set("type", typeFilter);
if (rarityFilter) params.set("rarity", rarityFilter);
get<ItemsResponse>(`/api/items?${params}`)
.then((data) => {
setItems(data.items);
setTotal(data.total);
})
.catch(() => {})
.finally(() => setLoading(false));
}, [search, typeFilter, rarityFilter, page]);
useEffect(() => { fetchItems(); }, [fetchItems]);
const openCreate = () => {
setEditing(null);
setForm(emptyForm());
setModalOpen(true);
};
const openEdit = (item: Item) => {
setEditing(item);
setForm({
name: item.name,
description: item.description ?? "",
type: item.type,
rarity: item.rarity,
price: item.price ?? "",
iconUrl: item.iconUrl,
imageUrl: item.imageUrl,
usageData: item.usageData,
});
setModalOpen(true);
};
const handleSave = async () => {
setSaving(true);
try {
const payload = {
name: form.name,
description: form.description || null,
type: form.type,
rarity: form.rarity,
price: form.price || null,
iconUrl: form.iconUrl,
imageUrl: form.imageUrl,
usageData: form.usageData,
};
if (editing) {
await put(`/api/items/${editing.id}`, payload);
} else {
await post("/api/items", payload);
}
setModalOpen(false);
fetchItems();
} catch (e) {
alert(typeof e === "object" && e && "error" in e ? (e as { error: string }).error : "Failed to save");
} finally {
setSaving(false);
}
};
const handleDelete = async (item: Item) => {
if (!confirm(`Delete "${item.name}"?`)) return;
await del(`/api/items/${item.id}`);
fetchItems();
};
const columns: Column<Item>[] = [
{ key: "id", header: "ID", className: "w-16" },
{
key: "iconUrl",
header: "",
className: "w-12",
render: (row) =>
row.iconUrl ? (
<img src={row.iconUrl} className="w-8 h-8 rounded object-cover" alt="" />
) : (
<div className="w-8 h-8 bg-base-300 rounded" />
),
},
{ key: "name", header: "Name" },
{
key: "type",
header: "Type",
render: (row) => <span className="badge badge-sm badge-outline">{row.type}</span>,
},
{
key: "rarity",
header: "Rarity",
render: (row) => {
const colors: Record<string, string> = { C: "badge-ghost", R: "badge-info", SR: "badge-warning", SSR: "badge-error" };
return <span className={`badge badge-sm ${colors[row.rarity] ?? ""}`}>{row.rarity}</span>;
},
},
{ key: "price", header: "Price", render: (row) => row.price ? `${BigInt(row.price).toLocaleString()}` : "—" },
{
key: "actions",
header: "",
className: "w-24",
render: (row) => (
<div className="flex gap-1">
<button className="btn btn-ghost btn-xs" onClick={(e) => { e.stopPropagation(); openEdit(row); }}>Edit</button>
<button className="btn btn-ghost btn-xs text-error" onClick={(e) => { e.stopPropagation(); handleDelete(row); }}>Del</button>
</div>
),
},
];
const totalPages = Math.ceil(total / limit);
return (
<div>
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-bold">Items</h1>
<button className="btn btn-primary btn-sm" onClick={openCreate}>+ New Item</button>
</div>
<div className="flex gap-2 mb-4 flex-wrap">
<input
type="text"
placeholder="Search..."
className="input input-bordered input-sm w-48"
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
/>
<select className="select select-bordered select-sm" value={typeFilter} onChange={(e) => { setTypeFilter(e.target.value); setPage(0); }}>
<option value="">All Types</option>
{ITEM_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
<select className="select select-bordered select-sm" value={rarityFilter} onChange={(e) => { setRarityFilter(e.target.value); setPage(0); }}>
<option value="">All Rarities</option>
{ITEM_RARITIES.map((r) => <option key={r} value={r}>{r}</option>)}
</select>
</div>
<DataTable columns={columns} data={items as unknown as Record<string, unknown>[]} keyField="id" loading={loading} />
{totalPages > 1 && (
<div className="flex justify-center mt-4">
<div className="join">
<button className="join-item btn btn-sm" disabled={page === 0} onClick={() => setPage(page - 1)}>«</button>
<button className="join-item btn btn-sm">Page {page + 1} / {totalPages}</button>
<button className="join-item btn btn-sm" disabled={page >= totalPages - 1} onClick={() => setPage(page + 1)}>»</button>
</div>
</div>
)}
<Modal
open={modalOpen}
onClose={() => setModalOpen(false)}
title={editing ? `Edit: ${editing.name}` : "New Item"}
actions={
<>
<button className="btn btn-ghost" onClick={() => setModalOpen(false)}>Cancel</button>
<button className="btn btn-primary" onClick={handleSave} disabled={saving}>
{saving ? <span className="loading loading-spinner loading-sm" /> : "Save"}
</button>
</>
}
>
<div className="grid grid-cols-2 gap-3">
<div className="form-control col-span-2">
<label className="label"><span className="label-text">Name</span></label>
<input className="input input-bordered input-sm" maxLength={100} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</div>
<div className="form-control col-span-2">
<label className="label"><span className="label-text">Description</span></label>
<textarea className="textarea textarea-bordered textarea-sm" maxLength={500} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
</div>
<div className="form-control">
<label className="label"><span className="label-text">Type</span></label>
<select className="select select-bordered select-sm" value={form.type} onChange={(e) => setForm({ ...form, type: e.target.value })}>
{ITEM_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
</div>
<div className="form-control">
<label className="label"><span className="label-text">Rarity</span></label>
<select className="select select-bordered select-sm" value={form.rarity} onChange={(e) => setForm({ ...form, rarity: e.target.value })}>
{ITEM_RARITIES.map((r) => <option key={r} value={r}>{r}</option>)}
</select>
</div>
<div className="form-control col-span-2">
<label className="label"><span className="label-text">Price</span></label>
<input className="input input-bordered input-sm" placeholder="Leave empty for no price" value={form.price} onChange={(e) => setForm({ ...form, price: e.target.value })} />
</div>
<div className="form-control">
<label className="label"><span className="label-text">Icon URL</span></label>
<input className="input input-bordered input-sm" value={form.iconUrl} onChange={(e) => setForm({ ...form, iconUrl: e.target.value })} />
</div>
<div className="form-control">
<label className="label"><span className="label-text">Image URL</span></label>
<input className="input input-bordered input-sm" value={form.imageUrl} onChange={(e) => setForm({ ...form, imageUrl: e.target.value })} />
</div>
<div className="form-control col-span-2">
<label className="label"><span className="label-text">Usage Data (JSON)</span></label>
<textarea
className="textarea textarea-bordered textarea-sm font-mono text-xs"
rows={4}
value={form.usageData ? JSON.stringify(form.usageData, null, 2) : "{}"}
onChange={(e) => {
try { setForm({ ...form, usageData: e.target.value ? JSON.parse(e.target.value) : null }); } catch {}
}}
/>
</div>
</div>
</Modal>
</div>
);
}

View File

@@ -1,144 +0,0 @@
import { useState, useEffect, useCallback } from "react";
import { get, post, del } from "../lib/api";
import DataTable, { type Column } from "../components/DataTable";
import Modal from "../components/Modal";
interface Lootdrop {
messageId: string;
channelId: string;
rewardAmount: number;
currency: string;
claimedBy: string | null;
createdAt: string;
expiresAt: string | null;
}
interface LootdropsResponse {
lootdrops: Lootdrop[];
}
export default function Lootdrops() {
const [drops, setDrops] = useState<Lootdrop[]>([]);
const [loading, setLoading] = useState(true);
const [spawnOpen, setSpawnOpen] = useState(false);
const [spawnForm, setSpawnForm] = useState({ channelId: "", amount: "", currency: "" });
const [spawning, setSpawning] = useState(false);
const fetchDrops = useCallback(() => {
setLoading(true);
get<LootdropsResponse>("/api/lootdrops")
.then((data) => setDrops(data.lootdrops))
.catch(() => {})
.finally(() => setLoading(false));
}, []);
useEffect(() => { fetchDrops(); }, [fetchDrops]);
const handleSpawn = async () => {
if (!spawnForm.channelId) return;
setSpawning(true);
try {
const payload: Record<string, unknown> = { channelId: spawnForm.channelId };
if (spawnForm.amount) payload.amount = Number(spawnForm.amount);
if (spawnForm.currency) payload.currency = spawnForm.currency;
await post("/api/lootdrops", payload);
setSpawnOpen(false);
setSpawnForm({ channelId: "", amount: "", currency: "" });
fetchDrops();
} catch (e) {
alert(typeof e === "object" && e && "error" in e ? (e as { error: string }).error : "Failed to spawn");
} finally {
setSpawning(false);
}
};
const handleCancel = async (drop: Lootdrop) => {
if (!confirm("Cancel this lootdrop?")) return;
await del(`/api/lootdrops/${drop.messageId}`);
fetchDrops();
};
const columns: Column<Lootdrop>[] = [
{ key: "messageId", header: "Message ID" },
{ key: "channelId", header: "Channel" },
{ key: "rewardAmount", header: "Reward", render: (r) => `${r.rewardAmount} ${r.currency}` },
{
key: "claimedBy",
header: "Status",
render: (r) => r.claimedBy
? <span className="badge badge-sm badge-ghost">Claimed by {r.claimedBy}</span>
: <span className="badge badge-sm badge-success">Active</span>,
},
{ key: "createdAt", header: "Created", render: (r) => new Date(r.createdAt).toLocaleString() },
{
key: "expiresAt",
header: "Expires",
render: (r) => r.expiresAt ? new Date(r.expiresAt).toLocaleString() : "—",
},
{
key: "actions",
header: "",
className: "w-20",
render: (row) =>
!row.claimedBy ? (
<button className="btn btn-ghost btn-xs text-error" onClick={(e) => { e.stopPropagation(); handleCancel(row); }}>Cancel</button>
) : null,
},
];
return (
<div>
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-bold">Lootdrops</h1>
<button className="btn btn-primary btn-sm" onClick={() => setSpawnOpen(true)}>Spawn Lootdrop</button>
</div>
<DataTable columns={columns} data={drops as unknown as Record<string, unknown>[]} keyField="messageId" loading={loading} />
<Modal
open={spawnOpen}
onClose={() => setSpawnOpen(false)}
title="Spawn Lootdrop"
actions={
<>
<button className="btn btn-ghost" onClick={() => setSpawnOpen(false)}>Cancel</button>
<button className="btn btn-primary" onClick={handleSpawn} disabled={spawning}>
{spawning ? <span className="loading loading-spinner loading-sm" /> : "Spawn"}
</button>
</>
}
>
<div className="space-y-3">
<div className="form-control">
<label className="label"><span className="label-text">Channel ID</span></label>
<input
className="input input-bordered input-sm"
placeholder="Discord channel ID"
value={spawnForm.channelId}
onChange={(e) => setSpawnForm({ ...spawnForm, channelId: e.target.value })}
/>
</div>
<div className="form-control">
<label className="label"><span className="label-text">Amount (optional)</span></label>
<input
type="number"
className="input input-bordered input-sm"
placeholder="Random if empty"
value={spawnForm.amount}
onChange={(e) => setSpawnForm({ ...spawnForm, amount: e.target.value })}
/>
</div>
<div className="form-control">
<label className="label"><span className="label-text">Currency (optional)</span></label>
<input
className="input input-bordered input-sm"
placeholder="Default from settings"
value={spawnForm.currency}
onChange={(e) => setSpawnForm({ ...spawnForm, currency: e.target.value })}
/>
</div>
</div>
</Modal>
</div>
);
}

View File

@@ -0,0 +1,17 @@
import { Construction } from "lucide-react";
export default function PlaceholderPage({
title,
description,
}: {
title: string;
description: string;
}) {
return (
<div className="flex flex-col items-center justify-center py-32 text-center">
<Construction className="w-10 h-10 text-text-tertiary mb-4" />
<h1 className="font-display text-2xl font-bold mb-2">{title}</h1>
<p className="text-sm text-text-tertiary max-w-md">{description}</p>
</div>
);
}

View File

@@ -1,170 +0,0 @@
import { useState, useEffect, useCallback } from "react";
import { get, post, put, del } from "../lib/api";
import DataTable, { type Column } from "../components/DataTable";
import Modal from "../components/Modal";
interface Quest {
id: number;
name: string;
description: string | null;
triggerEvent: string;
requirements: { target: number };
rewards: { xp: number; balance: number };
}
interface QuestsResponse {
success: boolean;
data: Quest[];
}
export default function Quests() {
const [quests, setQuests] = useState<Quest[]>([]);
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<Quest | null>(null);
const [form, setForm] = useState({
name: "",
description: "",
triggerEvent: "",
target: 1,
xpReward: 0,
balanceReward: 0,
});
const [saving, setSaving] = useState(false);
const fetchQuests = useCallback(() => {
setLoading(true);
get<QuestsResponse>("/api/quests")
.then((data) => setQuests(data.data))
.catch(() => {})
.finally(() => setLoading(false));
}, []);
useEffect(() => { fetchQuests(); }, [fetchQuests]);
const openCreate = () => {
setEditing(null);
setForm({ name: "", description: "", triggerEvent: "", target: 1, xpReward: 0, balanceReward: 0 });
setModalOpen(true);
};
const openEdit = (quest: Quest) => {
setEditing(quest);
setForm({
name: quest.name,
description: quest.description ?? "",
triggerEvent: quest.triggerEvent,
target: quest.requirements.target,
xpReward: quest.rewards.xp,
balanceReward: quest.rewards.balance,
});
setModalOpen(true);
};
const handleSave = async () => {
setSaving(true);
try {
const payload = {
name: form.name,
description: form.description || undefined,
triggerEvent: form.triggerEvent,
target: form.target,
xpReward: form.xpReward,
balanceReward: form.balanceReward,
};
if (editing) {
await put(`/api/quests/${editing.id}`, payload);
} else {
await post("/api/quests", payload);
}
setModalOpen(false);
fetchQuests();
} catch (e) {
alert(typeof e === "object" && e && "error" in e ? (e as { error: string }).error : "Failed to save");
} finally {
setSaving(false);
}
};
const handleDelete = async (quest: Quest) => {
if (!confirm(`Delete "${quest.name}"?`)) return;
await del(`/api/quests/${quest.id}`);
fetchQuests();
};
const columns: Column<Quest>[] = [
{ key: "id", header: "ID", className: "w-16" },
{ key: "name", header: "Name" },
{ key: "triggerEvent", header: "Trigger", render: (r) => <span className="badge badge-sm badge-outline">{r.triggerEvent}</span> },
{ key: "target", header: "Target", render: (r) => String(r.requirements.target) },
{ key: "xpReward", header: "XP Reward", render: (r) => String(r.rewards.xp) },
{ key: "balanceReward", header: "Gold Reward", render: (r) => String(r.rewards.balance) },
{
key: "actions",
header: "",
className: "w-24",
render: (row) => (
<div className="flex gap-1">
<button className="btn btn-ghost btn-xs" onClick={(e) => { e.stopPropagation(); openEdit(row); }}>Edit</button>
<button className="btn btn-ghost btn-xs text-error" onClick={(e) => { e.stopPropagation(); handleDelete(row); }}>Del</button>
</div>
),
},
];
return (
<div>
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-bold">Quests</h1>
<button className="btn btn-primary btn-sm" onClick={openCreate}>+ New Quest</button>
</div>
<DataTable columns={columns} data={quests as unknown as Record<string, unknown>[]} keyField="id" loading={loading} />
<Modal
open={modalOpen}
onClose={() => setModalOpen(false)}
title={editing ? `Edit: ${editing.name}` : "New Quest"}
actions={
<>
<button className="btn btn-ghost" onClick={() => setModalOpen(false)}>Cancel</button>
<button className="btn btn-primary" onClick={handleSave} disabled={saving}>
{saving ? <span className="loading loading-spinner loading-sm" /> : "Save"}
</button>
</>
}
>
<div className="space-y-3">
<div className="form-control">
<label className="label"><span className="label-text">Name</span></label>
<input className="input input-bordered input-sm" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</div>
<div className="form-control">
<label className="label"><span className="label-text">Description</span></label>
<textarea className="textarea textarea-bordered textarea-sm" value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="form-control">
<label className="label"><span className="label-text">Trigger Event</span></label>
<input className="input input-bordered input-sm" value={form.triggerEvent} onChange={(e) => setForm({ ...form, triggerEvent: e.target.value })} />
</div>
<div className="form-control">
<label className="label"><span className="label-text">Target</span></label>
<input type="number" className="input input-bordered input-sm" min={1} value={form.target} onChange={(e) => setForm({ ...form, target: Number(e.target.value) })} />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="form-control">
<label className="label"><span className="label-text">XP Reward</span></label>
<input type="number" className="input input-bordered input-sm" min={0} value={form.xpReward} onChange={(e) => setForm({ ...form, xpReward: Number(e.target.value) })} />
</div>
<div className="form-control">
<label className="label"><span className="label-text">Balance Reward</span></label>
<input type="number" className="input input-bordered input-sm" min={0} value={form.balanceReward} onChange={(e) => setForm({ ...form, balanceReward: Number(e.target.value) })} />
</div>
</div>
</div>
</Modal>
</div>
);
}

View File

@@ -1,94 +0,0 @@
import { useState, useEffect } from "react";
import { get, post } from "../lib/api";
export default function Settings() {
const [settings, setSettings] = useState<Record<string, unknown> | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [raw, setRaw] = useState("");
const [parseError, setParseError] = useState("");
useEffect(() => {
get<Record<string, unknown>>("/api/settings")
.then((data) => {
setSettings(data);
setRaw(JSON.stringify(data, null, 2));
})
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const handleRawChange = (value: string) => {
setRaw(value);
try {
JSON.parse(value);
setParseError("");
} catch (e) {
setParseError(e instanceof Error ? e.message : "Invalid JSON");
}
};
const handleSave = async () => {
if (parseError) return;
setSaving(true);
try {
const parsed = JSON.parse(raw);
const updated = await post<Record<string, unknown>>("/api/settings", parsed);
setSettings(updated);
setRaw(JSON.stringify(updated, null, 2));
} catch (e) {
alert(typeof e === "object" && e && "error" in e ? (e as { error: string }).error : "Failed to save");
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div className="flex justify-center p-12">
<span className="loading loading-spinner loading-lg" />
</div>
);
}
return (
<div>
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-bold">Settings</h1>
<button
className="btn btn-primary btn-sm"
onClick={handleSave}
disabled={saving || !!parseError}
>
{saving ? <span className="loading loading-spinner loading-sm" /> : "Save"}
</button>
</div>
<div className="text-sm text-base-content/60 mb-2">
Edit game configuration directly. Changes are merged with existing settings.
</div>
{parseError && (
<div className="alert alert-error mb-3 py-2 text-sm">{parseError}</div>
)}
<textarea
className="textarea textarea-bordered w-full font-mono text-sm"
rows={30}
value={raw}
onChange={(e) => handleRawChange(e.target.value)}
/>
{settings && (
<div className="mt-4">
<h3 className="text-sm font-semibold mb-2">Quick Reference Top-level keys:</h3>
<div className="flex flex-wrap gap-1">
{Object.keys(settings).map((key) => (
<span key={key} className="badge badge-sm badge-outline">{key}</span>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -1,265 +0,0 @@
import { useState, useEffect, useCallback } from "react";
import { get, put, post, del } from "../lib/api";
import DataTable, { type Column } from "../components/DataTable";
import Modal from "../components/Modal";
interface User {
id: string;
classId: string | null;
username: string;
isActive: boolean;
balance: string;
xp: string;
level: number;
dailyStreak: number;
settings: unknown;
createdAt: string;
updatedAt: string;
}
interface UsersResponse {
users: User[];
total: number;
}
interface InventoryEntry {
userId: string;
itemId: number;
quantity: string;
item: { id: number; name: string; rarity: string; type: string };
}
interface InventoryResponse {
inventory: InventoryEntry[];
}
export default function Users() {
const [users, setUsers] = useState<User[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [page, setPage] = useState(0);
const limit = 25;
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [inventory, setInventory] = useState<InventoryEntry[]>([]);
const [invLoading, setInvLoading] = useState(false);
const [editForm, setEditForm] = useState<{ balance: string; level: string; xp: string; dailyStreak: string; isActive: boolean }>({ balance: "0", level: "1", xp: "0", dailyStreak: "0", isActive: true });
const [saving, setSaving] = useState(false);
const [addItemOpen, setAddItemOpen] = useState(false);
const [addItemId, setAddItemId] = useState("");
const [addItemQty, setAddItemQty] = useState("1");
const fetchUsers = useCallback(() => {
setLoading(true);
const params = new URLSearchParams({ limit: String(limit), offset: String(page * limit) });
if (search) params.set("search", search);
get<UsersResponse>(`/api/users?${params}`)
.then((data) => { setUsers(data.users); setTotal(data.total); })
.catch(() => {})
.finally(() => setLoading(false));
}, [search, page]);
useEffect(() => { fetchUsers(); }, [fetchUsers]);
const openUser = (user: User) => {
setSelectedUser(user);
setEditForm({
balance: user.balance,
level: String(user.level),
xp: user.xp,
dailyStreak: String(user.dailyStreak),
isActive: user.isActive,
});
setInvLoading(true);
get<InventoryResponse>(`/api/users/${user.id}/inventory`)
.then((data) => setInventory(data.inventory))
.catch(() => setInventory([]))
.finally(() => setInvLoading(false));
};
const handleSaveUser = async () => {
if (!selectedUser) return;
setSaving(true);
try {
await put(`/api/users/${selectedUser.id}`, {
balance: editForm.balance,
level: Number(editForm.level),
xp: editForm.xp,
dailyStreak: Number(editForm.dailyStreak),
isActive: editForm.isActive,
});
fetchUsers();
setSelectedUser(null);
} catch (e) {
alert(typeof e === "object" && e && "error" in e ? (e as { error: string }).error : "Failed");
} finally {
setSaving(false);
}
};
const handleAddItem = async () => {
if (!selectedUser || !addItemId) return;
try {
await post(`/api/users/${selectedUser.id}/inventory`, { itemId: Number(addItemId), quantity: addItemQty });
const data = await get<InventoryResponse>(`/api/users/${selectedUser.id}/inventory`);
setInventory(data.inventory);
setAddItemOpen(false);
setAddItemId("");
setAddItemQty("1");
} catch (e) {
alert(typeof e === "object" && e && "error" in e ? (e as { error: string }).error : "Failed");
}
};
const handleRemoveItem = async (itemId: number) => {
if (!selectedUser) return;
await del(`/api/users/${selectedUser.id}/inventory/${itemId}`);
const data = await get<InventoryResponse>(`/api/users/${selectedUser.id}/inventory`);
setInventory(data.inventory);
};
const columns: Column<User>[] = [
{ key: "id", header: "ID" },
{ key: "username", header: "Username" },
{ key: "level", header: "Lv" },
{ key: "xp", header: "XP", render: (r) => BigInt(r.xp).toLocaleString() },
{ key: "balance", header: "Balance", render: (r) => BigInt(r.balance).toLocaleString() },
{ key: "dailyStreak", header: "Streak" },
{
key: "isActive",
header: "Active",
render: (r) => <span className={`badge badge-sm ${r.isActive ? "badge-success" : "badge-ghost"}`}>{r.isActive ? "Yes" : "No"}</span>,
},
];
const totalPages = Math.ceil(total / limit);
return (
<div>
<h1 className="text-2xl font-bold mb-4">Users</h1>
<div className="mb-4">
<input
type="text"
placeholder="Search by username or ID..."
className="input input-bordered input-sm w-72"
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
/>
</div>
<DataTable columns={columns} data={users as unknown as Record<string, unknown>[]} keyField="id" loading={loading} onRowClick={(r) => openUser(r as unknown as User)} />
{totalPages > 1 && (
<div className="flex justify-center mt-4">
<div className="join">
<button className="join-item btn btn-sm" disabled={page === 0} onClick={() => setPage(page - 1)}>«</button>
<button className="join-item btn btn-sm">Page {page + 1} / {totalPages}</button>
<button className="join-item btn btn-sm" disabled={page >= totalPages - 1} onClick={() => setPage(page + 1)}>»</button>
</div>
</div>
)}
<Modal
open={!!selectedUser}
onClose={() => setSelectedUser(null)}
title={selectedUser ? `User: ${selectedUser.username}` : ""}
actions={
<>
<button className="btn btn-ghost" onClick={() => setSelectedUser(null)}>Close</button>
<button className="btn btn-primary" onClick={handleSaveUser} disabled={saving}>
{saving ? <span className="loading loading-spinner loading-sm" /> : "Save Changes"}
</button>
</>
}
>
{selectedUser && (
<div className="space-y-4">
<div className="text-sm text-base-content/60">
ID: {selectedUser.id} | Class: {selectedUser.classId ?? "None"} | Joined: {new Date(selectedUser.createdAt).toLocaleDateString()}
</div>
<div className="grid grid-cols-2 gap-3">
<div className="form-control">
<label className="label"><span className="label-text">Balance</span></label>
<input className="input input-bordered input-sm" value={editForm.balance} onChange={(e) => setEditForm({ ...editForm, balance: e.target.value })} />
</div>
<div className="form-control">
<label className="label"><span className="label-text">Level</span></label>
<input type="number" className="input input-bordered input-sm" min={0} value={editForm.level} onChange={(e) => setEditForm({ ...editForm, level: e.target.value })} />
</div>
<div className="form-control">
<label className="label"><span className="label-text">XP</span></label>
<input className="input input-bordered input-sm" value={editForm.xp} onChange={(e) => setEditForm({ ...editForm, xp: e.target.value })} />
</div>
<div className="form-control">
<label className="label"><span className="label-text">Daily Streak</span></label>
<input type="number" className="input input-bordered input-sm" min={0} value={editForm.dailyStreak} onChange={(e) => setEditForm({ ...editForm, dailyStreak: e.target.value })} />
</div>
</div>
<div className="form-control">
<label className="label cursor-pointer justify-start gap-2">
<input type="checkbox" className="toggle toggle-sm toggle-success" checked={editForm.isActive} onChange={(e) => setEditForm({ ...editForm, isActive: e.target.checked })} />
<span className="label-text">Active</span>
</label>
</div>
<div className="divider">Inventory</div>
<div className="flex justify-between items-center">
<span className="text-sm font-medium">Items ({inventory.length})</span>
<button className="btn btn-sm btn-outline" onClick={() => setAddItemOpen(true)}>+ Add Item</button>
</div>
{invLoading ? (
<span className="loading loading-spinner loading-sm" />
) : inventory.length === 0 ? (
<div className="text-sm text-base-content/50">No items</div>
) : (
<div className="overflow-x-auto max-h-48">
<table className="table table-xs">
<thead><tr><th>Item</th><th>Qty</th><th></th></tr></thead>
<tbody>
{inventory.map((inv) => (
<tr key={inv.itemId}>
<td>{inv.item?.name ?? `#${inv.itemId}`}</td>
<td>{BigInt(inv.quantity).toLocaleString()}</td>
<td><button className="btn btn-ghost btn-xs text-error" onClick={() => handleRemoveItem(inv.itemId)}>Remove</button></td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</Modal>
<Modal
open={addItemOpen}
onClose={() => setAddItemOpen(false)}
title="Add Item to Inventory"
actions={
<>
<button className="btn btn-ghost" onClick={() => setAddItemOpen(false)}>Cancel</button>
<button className="btn btn-primary" onClick={handleAddItem}>Add</button>
</>
}
>
<div className="grid grid-cols-2 gap-3">
<div className="form-control">
<label className="label"><span className="label-text">Item ID</span></label>
<input type="number" className="input input-bordered input-sm" value={addItemId} onChange={(e) => setAddItemId(e.target.value)} />
</div>
<div className="form-control">
<label className="label"><span className="label-text">Quantity</span></label>
<input className="input input-bordered input-sm" value={addItemQty} onChange={(e) => setAddItemQty(e.target.value)} />
</div>
</div>
</Modal>
</div>
);
}