Files
AuroraBot-discord/web/src/pages/Settings.tsx

482 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 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>
);
}