forked from syntaxbullet/aurorabot
feat: add admin panel with Discord OAuth and dashboard
Adds a React admin panel (panel/) with Discord OAuth2 login, live dashboard via WebSocket, and settings/management pages. Includes Docker build support, Vite proxy config for dev, game_settings migration, and open-redirect protection on auth callback. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
133
panel/src/pages/Classes.tsx
Normal file
133
panel/src/pages/Classes.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user