- 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
510 lines
26 KiB
TypeScript
510 lines
26 KiB
TypeScript
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>
|
||
);
|
||
}
|