feat: add admin panel with Discord OAuth and dashboard
Some checks failed
Deploy to Production / test (push) Failing after 37s
Some checks failed
Deploy to Production / test (push) Failing after 37s
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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user