feat(dashboard): implement bot settings page with partial updates and serialization fixes

This commit is contained in:
syntaxbullet
2026-01-08 22:35:46 +01:00
parent d46434de18
commit c6fd23b5fa
5 changed files with 740 additions and 13 deletions

View File

@@ -1,11 +1,480 @@
import { useState, useEffect } from "react";
import { Loader2, Save, RefreshCw, Smartphone, Coins, Trophy, Shield, Users, Terminal, MessageSquare } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent } from "@/components/ui/card";
// Types matching the backend response
interface RoleOption { id: string; name: string; color: string; }
interface ChannelOption { id: string; name: string; type: number; }
interface SettingsMeta {
roles: RoleOption[];
channels: ChannelOption[];
commands: string[];
}
import { type GameConfigType } from "@shared/lib/config";
// Recursive partial type for nested updates
type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};
export function Settings() {
// We use a DeepPartial type for the local form state to allow for safe updates
const [config, setConfig] = useState<DeepPartial<GameConfigType> | null>(null);
const [meta, setMeta] = useState<SettingsMeta>({ roles: [], channels: [], commands: [] });
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [activeTab, setActiveTab] = useState("general");
useEffect(() => {
fetchData();
}, []);
const fetchData = async () => {
setLoading(true);
try {
const [configRes, metaRes] = await Promise.all([
fetch("/api/settings"),
fetch("/api/settings/meta")
]);
if (configRes.ok && metaRes.ok) {
const configData = await configRes.json();
const metaData = await metaRes.json();
setConfig(configData);
setMeta(metaData);
}
} catch (error) {
console.error("Failed to fetch settings:", error);
} finally {
setLoading(false);
}
};
const handleSave = async () => {
setSaving(true);
try {
const response = await fetch("/api/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(config)
});
if (!response.ok) throw new Error("Failed to save");
// Reload to satisfy any server-side transformations
await fetchData();
} catch (error) {
console.error("Failed to save settings:", error);
} finally {
setSaving(false);
}
};
// Helper to update nested state
const updateConfig = (path: string, value: any) => {
if (!path) return;
setConfig((prev: any) => {
const newConfig = { ...prev };
const parts = path.split('.');
let current = newConfig;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i];
if (part === undefined) return prev;
// Clone nested objects to ensure immutability
if (!current[part]) current[part] = {};
current[part] = { ...current[part] };
current = current[part];
}
const lastPart = parts[parts.length - 1];
if (lastPart !== undefined) {
current[lastPart] = value;
}
return newConfig;
});
};
if (loading || !config) {
return (
<div className="flex items-center justify-center h-[50vh]">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}
const tabs = [
{ id: "general", label: "General", icon: Smartphone },
{ id: "economy", label: "Economy", icon: Coins },
{ id: "leveling", label: "Leveling", icon: Trophy },
{ id: "moderation", label: "Moderation", icon: Shield },
{ id: "roles", label: "Roles", icon: Users },
{ id: "system", label: "System", icon: Terminal },
];
return (
<div>
<h2 className="text-3xl font-bold tracking-tight">Settings</h2>
<p className="text-muted-foreground">Manage bot configuration.</p>
<div className="mt-6 rounded-xl border border-dashed p-8 text-center text-muted-foreground">
Settings panel coming soon...
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-3xl font-bold tracking-tight text-white drop-shadow-md">Settings</h2>
<p className="text-white/60">Manage global bot configuration</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={fetchData} className="glass border-white/10 hover:bg-white/10">
<RefreshCw className="h-4 w-4 mr-2" />
Reset
</Button>
<Button onClick={handleSave} disabled={saving} className="bg-primary hover:bg-primary/80 text-primary-foreground shadow-[0_0_15px_rgba(var(--primary),0.3)]">
{saving ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Save className="h-4 w-4 mr-2" />}
Save Changes
</Button>
</div>
</div>
{/* Tabs Navigation */}
<div className="flex items-center gap-2 overflow-x-auto pb-2">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-all border ${activeTab === tab.id
? "bg-primary/20 border-primary/50 text-white shadow-[0_0_10px_rgba(var(--primary),0.2)]"
: "bg-white/5 border-white/5 text-white/50 hover:bg-white/10 hover:text-white"
}`}
>
<tab.icon className="h-4 w-4" />
<span className="font-medium">{tab.label}</span>
</button>
))}
</div>
<Card className="glass border-white/10">
<CardContent className="p-6">
{activeTab === "general" && (
<div className="space-y-6">
<SectionTitle icon={MessageSquare} title="Channels" description="Default channels for bot interactions" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<SelectField
label="Welcome Channel"
value={config?.welcomeChannelId}
options={meta.channels}
onChange={(v) => updateConfig("welcomeChannelId", v)}
/>
<SelectField
label="Feedback Channel"
value={config?.feedbackChannelId}
options={meta.channels}
onChange={(v) => updateConfig("feedbackChannelId", v)}
/>
</div>
<SectionTitle icon={Terminal} title="Terminal" description="Dedicated channel for CLI-like interactions" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<SelectField
label="Terminal Channel"
value={config?.terminal?.channelId}
options={meta.channels}
onChange={(v) => updateConfig("terminal.channelId", v)}
/>
<InputField
label="Terminal Message ID"
value={config?.terminal?.messageId}
onChange={(v) => updateConfig("terminal.messageId", v)}
placeholder="ID of the pinned message"
/>
</div>
</div>
)}
{activeTab === "economy" && (
<div className="space-y-6">
<SectionTitle icon={Coins} title="Daily Rewards" description="Configure daily currency claims" />
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<InputField
label="Base Amount"
type="number"
value={config?.economy?.daily?.amount}
onChange={(v) => updateConfig("economy.daily.amount", v)}
/>
<InputField
label="Streak Bonus"
type="number"
value={config?.economy?.daily?.streakBonus}
onChange={(v) => updateConfig("economy.daily.streakBonus", v)}
/>
<InputField
label="Weekly Bonus"
type="number"
value={config?.economy?.daily?.weeklyBonus}
onChange={(v) => updateConfig("economy.daily.weeklyBonus", v)}
/>
</div>
<SectionTitle icon={RefreshCw} title="Transfers" description="Rules for player-to-player transfers" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<InputField
label="Minimum Amount"
type="number"
value={config?.economy?.transfers?.minAmount}
onChange={(v) => updateConfig("economy.transfers.minAmount", v)}
/>
<div className="flex items-center justify-between p-4 rounded-lg bg-white/5 border border-white/5">
<span className="text-sm font-medium">Allow Self Transfer</span>
<input
type="checkbox"
checked={config?.economy?.transfers?.allowSelfTransfer ?? false}
onChange={(e) => updateConfig("economy.transfers.allowSelfTransfer", e.target.checked)}
className="h-5 w-5 rounded border-white/10 bg-white/5"
/>
</div>
</div>
<SectionTitle icon={Trophy} title="Lootdrops" description="Random event configuration" />
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<InputField
label="Spawn Chance"
type="number"
value={config?.lootdrop?.spawnChance}
onChange={(v) => updateConfig("lootdrop.spawnChance", Number(v))}
/>
<InputField
label="Cooldown (ms)"
type="number"
value={config?.lootdrop?.cooldownMs}
onChange={(v) => updateConfig("lootdrop.cooldownMs", Number(v))}
/>
<InputField
label="Min Messages"
type="number"
value={config?.lootdrop?.minMessages}
onChange={(v) => updateConfig("lootdrop.minMessages", Number(v))}
/>
<InputField
label="Reward Min"
type="number"
value={config?.lootdrop?.reward?.min}
onChange={(v) => updateConfig("lootdrop.reward.min", Number(v))}
/>
<InputField
label="Reward Max"
type="number"
value={config?.lootdrop?.reward?.max}
onChange={(v) => updateConfig("lootdrop.reward.max", Number(v))}
/>
</div>
</div>
)}
{activeTab === "leveling" && (
<div className="space-y-6">
<SectionTitle icon={Trophy} title="XP Formula" description="Calculate level requirements" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<InputField
label="Base XP"
type="number"
value={config?.leveling?.base}
onChange={(v) => updateConfig("leveling.base", Number(v))}
/>
<InputField
label="Exponent"
type="number"
value={config?.leveling?.exponent}
onChange={(v) => updateConfig("leveling.exponent", Number(v))}
/>
</div>
<SectionTitle icon={MessageSquare} title="Chat XP" description="XP gained from text messages" />
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<InputField
label="Min XP"
type="number"
value={config?.leveling?.chat?.minXp}
onChange={(v) => updateConfig("leveling.chat.minXp", Number(v))}
/>
<InputField
label="Max XP"
type="number"
value={config?.leveling?.chat?.maxXp}
onChange={(v) => updateConfig("leveling.chat.maxXp", Number(v))}
/>
<InputField
label="Cooldown (ms)"
type="number"
value={config?.leveling?.chat?.cooldownMs}
onChange={(v) => updateConfig("leveling.chat.cooldownMs", Number(v))}
/>
</div>
</div>
)}
{activeTab === "roles" && (
<div className="space-y-6">
<SectionTitle icon={Users} title="System Roles" description="Map discord roles to bot functions" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<SelectField
label="Student Role"
value={config?.studentRole}
options={meta.roles}
onChange={(v) => updateConfig("studentRole", v)}
/>
<SelectField
label="Visitor Role"
value={config?.visitorRole}
options={meta.roles}
onChange={(v) => updateConfig("visitorRole", v)}
/>
</div>
<SectionTitle icon={Terminal} title="Color Roles" description="Roles that players can buy/equip for username colors" />
{/* Multi-select for color roles is complex, simpler impl for now */}
<div className="p-4 rounded-lg bg-white/5 border border-white/5 space-y-2">
<p className="text-sm text-white/60 mb-2">Available Color Roles</p>
<div className="flex flex-wrap gap-2">
{(config?.colorRoles || []).map((roleId: string | undefined) => {
if (!roleId) return null;
const role = meta.roles.find(r => r.id === roleId);
return (
<span key={roleId} className="px-2 py-1 rounded bg-white/10 text-xs flex items-center gap-1">
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: role?.color || '#999' }} />
{role?.name || roleId}
<button
onClick={() => updateConfig("colorRoles", (config?.colorRoles || []).filter((id: string | undefined) => id !== roleId))}
className="hover:text-red-400 ml-1"
>
×
</button>
</span>
);
})}
</div>
<div className="mt-2">
<select
className="w-full bg-black/20 border border-white/10 rounded-md p-2 text-sm text-white mt-2"
onChange={(e) => {
if (e.target.value && !(config?.colorRoles || []).includes(e.target.value)) {
updateConfig("colorRoles", [...(config?.colorRoles || []), e.target.value]);
}
e.target.value = "";
}}
>
<option value="">+ Add Color Role</option>
{meta.roles.map(r => (
<option key={r.id} value={r.id} style={{ color: r.color }}>{r.name}</option>
))}
</select>
</div>
</div>
</div>
)}
{activeTab === "moderation" && (
<div className="space-y-6">
<SectionTitle icon={Shield} title="Pruning" description="Batch message deletion config" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<InputField
label="Max Prune Amount"
type="number"
value={config?.moderation?.prune?.maxAmount}
onChange={(v) => updateConfig("moderation.prune.maxAmount", Number(v))}
/>
<InputField
label="Confirmation Threshold"
type="number"
value={config?.moderation?.prune?.confirmThreshold}
onChange={(v) => updateConfig("moderation.prune.confirmThreshold", Number(v))}
/>
</div>
</div>
)}
{activeTab === "system" && (
<div className="space-y-6">
<SectionTitle icon={Terminal} title="Commands" description="Enable or disable specific bot commands" />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{meta.commands.map(cmd => (
<div key={cmd} className="flex items-center justify-between p-3 rounded-lg bg-white/5 border border-white/5">
<div className="flex flex-col">
<span className="text-sm font-medium">/{cmd}</span>
<span className="text-[10px] text-white/40">
{config?.commands?.[cmd] === false ? "Disabled" : "Enabled"}
</span>
</div>
<input
type="checkbox"
checked={config?.commands?.[cmd] !== false}
onChange={(e) => updateConfig(`commands.${cmd}`, e.target.checked)}
className="h-4 w-4 rounded border-white/10 bg-white/5 accent-primary"
/>
</div>
))}
{meta.commands.length === 0 && (
<div className="col-span-full text-center py-8 text-white/30">
No commands found in metadata.
</div>
)}
</div>
</div>
)}
</CardContent>
</Card>
</div>
);
}
// Sub-components for cleaner code
function SectionTitle({ icon: Icon, title, description }: { icon: any, title: string, description: string }) {
return (
<div className="flex items-start gap-3 border-b border-white/5 pb-2 mb-4">
<div className="p-2 rounded-lg bg-primary/10 text-primary">
<Icon className="h-5 w-5" />
</div>
<div>
<h3 className="text-lg font-bold text-white leading-none">{title}</h3>
<p className="text-sm text-white/40 mt-1">{description}</p>
</div>
</div>
);
}
function InputField({ label, value, onChange, type = "text", placeholder }: { label: string, value: any, onChange: (val: string) => void, type?: string, placeholder?: string }) {
return (
<div className="space-y-1.5">
<label className="text-xs font-medium text-white/60 ml-1">{label}</label>
<Input
type={type}
value={value ?? ""}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="bg-black/20 border-white/10 text-white focus:border-primary/50"
/>
</div>
);
}
function SelectField({ label, value, options, onChange }: { label: string, value: string | undefined, options: any[], onChange: (val: string) => void }) {
return (
<div className="space-y-1.5">
<label className="text-xs font-medium text-white/60 ml-1">{label}</label>
<div className="relative">
<select
value={value || ""}
onChange={(e) => onChange(e.target.value)}
className="flex h-10 w-full rounded-md border border-white/10 bg-black/20 px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 text-white appearance-none"
>
<option value="" className="bg-zinc-900 text-white/50">Select...</option>
{options.map((opt) => (
<option key={opt.id} value={opt.id} className="bg-zinc-900">
{opt.name}
</option>
))}
</select>
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-white/50">
<svg className="h-4 w-4 fill-current" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M9.293 12.95l.707.707L15.657 8l-1.414-1.414L10 10.828 5.757 6.586 4.343 8z" /></svg>
</div>
</div>
</div>
);