Files
AuroraBot-discord/web/src/pages/Settings.tsx
syntaxbullet 238d9a8803 refactor(web): enhance ui visual polish and ux
- Replace native selects with Shadcn UI Select in Settings
- Increase ActivityChart height for better visibility
- specific Economy Overview card height to fill column
- Add hover/active scale animations to sidebar items
2026-01-08 23:10:14 +01:00

510 lines
26 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 { toast } from "sonner";
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";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
// 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: { name: string; category: 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);
const toastId = toast.loading("Saving configuration...");
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();
toast.success("Settings saved successfully", { id: toastId });
} catch (error) {
console.error("Failed to save settings:", error);
toast.error("Failed to save settings", {
id: toastId,
description: "Please check your input and try again."
});
} 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>
<Switch
checked={config?.economy?.transfers?.allowSelfTransfer ?? false}
onCheckedChange={(checked) => updateConfig("economy.transfers.allowSelfTransfer", checked)}
/>
</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
value=""
onValueChange={(value) => {
if (value && !(config?.colorRoles || []).includes(value)) {
updateConfig("colorRoles", [...(config?.colorRoles || []), value]);
}
}}
>
<SelectTrigger className="w-full bg-black/20 border-white/10 text-white/50 h-9">
<SelectValue placeholder="+ Add Color Role" />
</SelectTrigger>
<SelectContent>
{meta.roles.map((r) => (
<SelectItem key={r.id} value={r.id}>
<span className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: r.color || '#999' }} />
{r.name}
</span>
</SelectItem>
))}
</SelectContent>
</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" />
{meta.commands.length === 0 ? (
<div className="text-center py-8 text-white/30">
No commands found in metadata.
</div>
) : (
Object.entries(
meta.commands.reduce((acc, cmd) => {
const cat = cmd.category || 'Uncategorized';
if (!acc[cat]) acc[cat] = [];
acc[cat].push(cmd);
return acc;
}, {} as Record<string, typeof meta.commands>)
).sort(([a], [b]) => a.localeCompare(b)).map(([category, commands]) => (
<div key={category} className="mb-6 last:mb-0">
<h4 className="text-sm font-semibold text-white/40 uppercase tracking-wider mb-3 px-1">{category}</h4>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{commands.map(cmd => (
<div key={cmd.name} className="flex items-center justify-between p-3 rounded-lg bg-white/5 border border-white/5 hover:border-white/10 transition-colors">
<div className="flex flex-col">
<span className="text-sm font-medium text-white">/{cmd.name}</span>
<span className={`text-[10px] ${config?.commands?.[cmd.name] === false ? "text-red-400" : "text-green-400"}`}>
{config?.commands?.[cmd.name] === false ? "Disabled" : "Enabled"}
</span>
</div>
<Switch
checked={config?.commands?.[cmd.name] !== false}
onCheckedChange={(checked) => updateConfig(`commands.${cmd.name}`, checked)}
/>
</div>
))}
</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>
<Select value={value || ""} onValueChange={onChange}>
<SelectTrigger className="w-full bg-black/20 border-white/10 text-white">
<SelectValue placeholder="Select..." />
</SelectTrigger>
<SelectContent>
{options.map((opt) => (
<SelectItem key={opt.id} value={opt.id}>
{opt.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}