forked from syntaxbullet/aurorabot
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>
134 lines
5.0 KiB
TypeScript
134 lines
5.0 KiB
TypeScript
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>
|
|
);
|
|
}
|