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:
12
panel/index.html
Normal file
12
panel/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Aurora Admin Panel</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
28
panel/package.json
Normal file
28
panel/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "panel",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-router-dom": "^7.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.1.6",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"daisyui": "^5.0.43",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.10",
|
||||
"@tailwindcss/vite": "^4.1.10",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^6.3.5"
|
||||
}
|
||||
}
|
||||
53
panel/src/App.tsx
Normal file
53
panel/src/App.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Routes, Route } from "react-router-dom";
|
||||
import { useAuth } from "./lib/useAuth";
|
||||
import Layout from "./components/Layout";
|
||||
import Dashboard from "./pages/Dashboard";
|
||||
import Items from "./pages/Items";
|
||||
import Quests from "./pages/Quests";
|
||||
import Classes from "./pages/Classes";
|
||||
import Users from "./pages/Users";
|
||||
import Settings from "./pages/Settings";
|
||||
import Lootdrops from "./pages/Lootdrops";
|
||||
|
||||
export default function App() {
|
||||
const { loading, user, logout } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-base-200">
|
||||
<span className="loading loading-spinner loading-lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-base-200">
|
||||
<div className="card bg-base-100 shadow-xl p-8 text-center max-w-sm">
|
||||
<h1 className="text-2xl font-bold mb-2">Aurora Admin Panel</h1>
|
||||
<p className="text-base-content/60 mb-6">Sign in with Discord to continue.</p>
|
||||
<a href={`/auth/discord?return_to=${encodeURIComponent(window.location.origin + '/')}`} className="btn btn-primary">
|
||||
<svg className="w-5 h-5 mr-2" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20.317 4.37a19.791 19.791 0 00-4.885-1.515.074.074 0 00-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 00-5.487 0 12.64 12.64 0 00-.617-1.25.077.077 0 00-.079-.037A19.736 19.736 0 003.677 4.37a.07.07 0 00-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 00.031.057 19.9 19.9 0 005.993 3.03.078.078 0 00.084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 00-.041-.106 13.107 13.107 0 01-1.872-.892.077.077 0 01-.008-.128 10.2 10.2 0 00.372-.292.074.074 0 01.077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 01.078.01c.12.098.246.198.373.292a.077.077 0 01-.006.127 12.299 12.299 0 01-1.873.892.077.077 0 00-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 00.084.028 19.839 19.839 0 006.002-3.03.077.077 0 00.032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 00-.031-.03z" />
|
||||
</svg>
|
||||
Sign in with Discord
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<Layout user={user} onLogout={logout} />}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="items" element={<Items />} />
|
||||
<Route path="quests" element={<Quests />} />
|
||||
<Route path="classes" element={<Classes />} />
|
||||
<Route path="users" element={<Users />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
<Route path="lootdrops" element={<Lootdrops />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
73
panel/src/components/DataTable.tsx
Normal file
73
panel/src/components/DataTable.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export interface Column<T> {
|
||||
key: string;
|
||||
header: string;
|
||||
render?: (row: T) => ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface DataTableProps<T> {
|
||||
columns: Column<T>[];
|
||||
data: T[];
|
||||
keyField: string;
|
||||
loading?: boolean;
|
||||
onRowClick?: (row: T) => void;
|
||||
emptyMessage?: string;
|
||||
}
|
||||
|
||||
export default function DataTable<T extends Record<string, unknown>>({
|
||||
columns,
|
||||
data,
|
||||
keyField,
|
||||
loading,
|
||||
onRowClick,
|
||||
emptyMessage = "No data found",
|
||||
}: DataTableProps<T>) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center p-8">
|
||||
<span className="loading loading-spinner loading-lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="table table-zebra w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((col) => (
|
||||
<th key={col.key} className={col.className}>
|
||||
{col.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="text-center py-8 text-base-content/50">
|
||||
{emptyMessage}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data.map((row) => (
|
||||
<tr
|
||||
key={String(row[keyField])}
|
||||
className={onRowClick ? "cursor-pointer hover" : ""}
|
||||
onClick={() => onRowClick?.(row)}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<td key={col.key} className={col.className}>
|
||||
{col.render ? col.render(row) : String(row[col.key] ?? "")}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
92
panel/src/components/Layout.tsx
Normal file
92
panel/src/components/Layout.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { NavLink, Outlet } from "react-router-dom";
|
||||
import type { AuthUser } from "../lib/useAuth";
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ to: "/", label: "Dashboard", icon: "M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0a1 1 0 01-1-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 01-1 1" },
|
||||
{ to: "/items", label: "Items", icon: "M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" },
|
||||
{ to: "/quests", label: "Quests", icon: "M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" },
|
||||
{ to: "/classes", label: "Classes", icon: "M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" },
|
||||
{ to: "/users", label: "Users", icon: "M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" },
|
||||
{ to: "/settings", label: "Settings", icon: "M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z" },
|
||||
{ to: "/lootdrops", label: "Lootdrops", icon: "M12 8v13m0-13V6a2 2 0 112 2h-2zm0 0V5.5A2.5 2.5 0 109.5 8H12zm-7 4h14M5 12a2 2 0 110-4h14a2 2 0 110 4M5 12v7a2 2 0 002 2h10a2 2 0 002-2v-7" },
|
||||
];
|
||||
|
||||
function avatarUrl(user: AuthUser): string {
|
||||
if (user.avatar) {
|
||||
return `https://cdn.discordapp.com/avatars/${user.discordId}/${user.avatar}.png?size=64`;
|
||||
}
|
||||
const index = (BigInt(user.discordId) >> 22n) % 6n;
|
||||
return `https://cdn.discordapp.com/embed/avatars/${index}.png`;
|
||||
}
|
||||
|
||||
export default function Layout({
|
||||
user,
|
||||
onLogout,
|
||||
}: {
|
||||
user: AuthUser;
|
||||
onLogout: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-screen bg-base-200">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-64 bg-base-300 flex flex-col">
|
||||
<div className="p-4 font-bold text-xl border-b border-base-content/10">
|
||||
Aurora Panel
|
||||
</div>
|
||||
<nav className="flex-1 p-2 space-y-1">
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === "/"}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors ${
|
||||
isActive
|
||||
? "bg-primary text-primary-content"
|
||||
: "hover:bg-base-content/10"
|
||||
}`
|
||||
}
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5 shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d={item.icon} />
|
||||
</svg>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<div className="p-3 border-t border-base-content/10">
|
||||
<div className="flex items-center gap-3">
|
||||
<img
|
||||
src={avatarUrl(user)}
|
||||
className="w-8 h-8 rounded-full"
|
||||
alt=""
|
||||
/>
|
||||
<span className="text-sm font-medium flex-1 truncate">
|
||||
{user.username}
|
||||
</span>
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="btn btn-ghost btn-xs"
|
||||
title="Logout"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 overflow-auto p-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
panel/src/components/Modal.tsx
Normal file
32
panel/src/components/Modal.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface ModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
export default function Modal({ open, onClose, title, children, actions }: ModalProps) {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<dialog className="modal modal-open">
|
||||
<div className="modal-box max-w-2xl">
|
||||
<h3 className="font-bold text-lg mb-4">{title}</h3>
|
||||
<button
|
||||
className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"
|
||||
onClick={onClose}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
{children}
|
||||
{actions && <div className="modal-action">{actions}</div>}
|
||||
</div>
|
||||
<form method="dialog" className="modal-backdrop">
|
||||
<button onClick={onClose}>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
2
panel/src/index.css
Normal file
2
panel/src/index.css
Normal file
@@ -0,0 +1,2 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "daisyui";
|
||||
47
panel/src/lib/api.ts
Normal file
47
panel/src/lib/api.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
const BASE = "";
|
||||
|
||||
export interface ApiError {
|
||||
error: string;
|
||||
details?: string;
|
||||
}
|
||||
|
||||
export async function api<T = unknown>(
|
||||
path: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
},
|
||||
credentials: "same-origin",
|
||||
});
|
||||
|
||||
if (res.status === 401) {
|
||||
window.location.href = `/auth/discord?return_to=${encodeURIComponent(window.location.href)}`;
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw body as ApiError;
|
||||
}
|
||||
|
||||
if (res.status === 204 || res.headers.get("content-length") === "0") {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const get = <T = unknown>(path: string) => api<T>(path);
|
||||
|
||||
export const post = <T = unknown>(path: string, data?: unknown) =>
|
||||
api<T>(path, { method: "POST", body: data ? JSON.stringify(data) : undefined });
|
||||
|
||||
export const put = <T = unknown>(path: string, data?: unknown) =>
|
||||
api<T>(path, { method: "PUT", body: data ? JSON.stringify(data) : undefined });
|
||||
|
||||
export const del = <T = unknown>(path: string) =>
|
||||
api<T>(path, { method: "DELETE" });
|
||||
35
panel/src/lib/useAuth.ts
Normal file
35
panel/src/lib/useAuth.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export interface AuthUser {
|
||||
discordId: string;
|
||||
username: string;
|
||||
avatar: string | null;
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
loading: boolean;
|
||||
user: AuthUser | null;
|
||||
}
|
||||
|
||||
export function useAuth(): AuthState & { logout: () => Promise<void> } {
|
||||
const [state, setState] = useState<AuthState>({ loading: true, user: null });
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/auth/me", { credentials: "same-origin" })
|
||||
.then((r) => r.json())
|
||||
.then((data: { authenticated: boolean; user?: AuthUser }) => {
|
||||
setState({
|
||||
loading: false,
|
||||
user: data.authenticated ? data.user! : null,
|
||||
});
|
||||
})
|
||||
.catch(() => setState({ loading: false, user: null }));
|
||||
}, []);
|
||||
|
||||
const logout = async () => {
|
||||
await fetch("/auth/logout", { method: "POST", credentials: "same-origin" });
|
||||
setState({ loading: false, user: null });
|
||||
};
|
||||
|
||||
return { ...state, logout };
|
||||
}
|
||||
13
panel/src/main.tsx
Normal file
13
panel/src/main.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>
|
||||
);
|
||||
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>
|
||||
);
|
||||
}
|
||||
84
panel/src/pages/Dashboard.tsx
Normal file
84
panel/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { get } from "../lib/api";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
if (!stats) return <div className="alert alert-error">Failed to load stats</div>;
|
||||
|
||||
const uptimeHours = Math.floor((stats.uptime ?? 0) / 3600);
|
||||
const uptimeMins = Math.floor(((stats.uptime ?? 0) % 3600) / 60);
|
||||
|
||||
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>
|
||||
|
||||
<div className="text-sm text-base-content/50">
|
||||
Live data via WebSocket — updates every 5 seconds
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
265
panel/src/pages/Items.tsx
Normal file
265
panel/src/pages/Items.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
144
panel/src/pages/Lootdrops.tsx
Normal file
144
panel/src/pages/Lootdrops.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
170
panel/src/pages/Quests.tsx
Normal file
170
panel/src/pages/Quests.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
94
panel/src/pages/Settings.tsx
Normal file
94
panel/src/pages/Settings.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
265
panel/src/pages/Users.tsx
Normal file
265
panel/src/pages/Users.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
19
panel/tsconfig.json
Normal file
19
panel/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
21
panel/vite.config.ts
Normal file
21
panel/vite.config.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": "http://localhost:3000",
|
||||
"/auth": "http://localhost:3000",
|
||||
"/ws": {
|
||||
target: "ws://localhost:3000",
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: "dist",
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user