feat(dashboard): implement bot settings page with partial updates and serialization fixes
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import { jsonReplacer } from './utils';
|
||||||
import { readFileSync, existsSync, writeFileSync } from 'node:fs';
|
import { readFileSync, existsSync, writeFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@@ -191,14 +192,7 @@ export function saveConfig(newConfig: unknown) {
|
|||||||
// Validate and transform input
|
// Validate and transform input
|
||||||
const validatedConfig = configSchema.parse(newConfig);
|
const validatedConfig = configSchema.parse(newConfig);
|
||||||
|
|
||||||
const replacer = (key: string, value: any) => {
|
const jsonString = JSON.stringify(validatedConfig, jsonReplacer, 4);
|
||||||
if (typeof value === 'bigint') {
|
|
||||||
return value.toString();
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
};
|
|
||||||
|
|
||||||
const jsonString = JSON.stringify(validatedConfig, replacer, 4);
|
|
||||||
writeFileSync(configPath, jsonString, 'utf-8');
|
writeFileSync(configPath, jsonString, 'utf-8');
|
||||||
reloadConfig();
|
reloadConfig();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,3 +9,42 @@ import type { Command } from "./types";
|
|||||||
export function createCommand(command: Command): Command {
|
export function createCommand(command: Command): Command {
|
||||||
return command;
|
return command;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON Replacer function for serialization
|
||||||
|
* Handles safe serialization of BigInt values to strings
|
||||||
|
*/
|
||||||
|
export const jsonReplacer = (_key: string, value: unknown): unknown => {
|
||||||
|
if (typeof value === 'bigint') {
|
||||||
|
return value.toString();
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deep merge utility
|
||||||
|
*/
|
||||||
|
export function deepMerge(target: any, source: any): any {
|
||||||
|
if (typeof target !== 'object' || target === null) {
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
if (typeof source !== 'object' || source === null) {
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = { ...target };
|
||||||
|
|
||||||
|
Object.keys(source).forEach(key => {
|
||||||
|
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
|
||||||
|
if (!(key in target)) {
|
||||||
|
Object.assign(output, { [key]: source[key] });
|
||||||
|
} else {
|
||||||
|
output[key] = deepMerge(target[key], source[key]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Object.assign(output, { [key]: source[key] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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() {
|
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 (
|
return (
|
||||||
<div>
|
<div className="space-y-6">
|
||||||
<h2 className="text-3xl font-bold tracking-tight">Settings</h2>
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-muted-foreground">Manage bot configuration.</p>
|
<div>
|
||||||
<div className="mt-6 rounded-xl border border-dashed p-8 text-center text-muted-foreground">
|
<h2 className="text-3xl font-bold tracking-tight text-white drop-shadow-md">Settings</h2>
|
||||||
Settings panel coming soon...
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
160
web/src/server.settings.test.ts
Normal file
160
web/src/server.settings.test.ts
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import { describe, expect, it, mock, beforeEach, afterEach, jest } from "bun:test";
|
||||||
|
import { type WebServerInstance } from "./server";
|
||||||
|
|
||||||
|
// Mock the dependencies
|
||||||
|
const mockConfig = {
|
||||||
|
leveling: {
|
||||||
|
base: 100,
|
||||||
|
exponent: 1.5,
|
||||||
|
chat: { minXp: 10, maxXp: 20, cooldownMs: 60000 }
|
||||||
|
},
|
||||||
|
economy: {
|
||||||
|
daily: { amount: 100n, streakBonus: 10n, weeklyBonus: 50n, cooldownMs: 86400000 },
|
||||||
|
transfers: { allowSelfTransfer: false, minAmount: 50n },
|
||||||
|
exam: { multMin: 1.5, multMax: 2.5 }
|
||||||
|
},
|
||||||
|
inventory: { maxStackSize: 99n, maxSlots: 20 },
|
||||||
|
lootdrop: {
|
||||||
|
spawnChance: 0.1,
|
||||||
|
cooldownMs: 3600000,
|
||||||
|
minMessages: 10,
|
||||||
|
reward: { min: 100, max: 500, currency: "gold" }
|
||||||
|
},
|
||||||
|
commands: { "help": true },
|
||||||
|
system: {},
|
||||||
|
moderation: {
|
||||||
|
prune: { maxAmount: 100, confirmThreshold: 50, batchSize: 100, batchDelayMs: 1000 },
|
||||||
|
cases: { dmOnWarn: true }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockSaveConfig = jest.fn();
|
||||||
|
|
||||||
|
// Mock @shared/lib/config using mock.module
|
||||||
|
mock.module("@shared/lib/config", () => ({
|
||||||
|
config: mockConfig,
|
||||||
|
saveConfig: mockSaveConfig,
|
||||||
|
GameConfigType: {}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock BotClient
|
||||||
|
const mockGuild = {
|
||||||
|
roles: {
|
||||||
|
cache: [
|
||||||
|
{ id: "role1", name: "Admin", hexColor: "#ffffff", position: 1 },
|
||||||
|
{ id: "role2", name: "User", hexColor: "#000000", position: 0 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
channels: {
|
||||||
|
cache: [
|
||||||
|
{ id: "chan1", name: "general", type: 0 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mock.module("../../bot/lib/BotClient", () => ({
|
||||||
|
AuroraClient: {
|
||||||
|
guilds: {
|
||||||
|
cache: {
|
||||||
|
get: () => mockGuild
|
||||||
|
}
|
||||||
|
},
|
||||||
|
commands: [
|
||||||
|
{ data: { name: "ping" } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module("@shared/lib/env", () => ({
|
||||||
|
env: {
|
||||||
|
DISCORD_GUILD_ID: "123456789"
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock spawn
|
||||||
|
mock.module("bun", () => {
|
||||||
|
return {
|
||||||
|
spawn: jest.fn(() => ({
|
||||||
|
unref: () => { }
|
||||||
|
})),
|
||||||
|
serve: Bun.serve
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Import createWebServer after mocks
|
||||||
|
import { createWebServer } from "./server";
|
||||||
|
|
||||||
|
describe("Settings API", () => {
|
||||||
|
let serverInstance: WebServerInstance;
|
||||||
|
const PORT = 3009;
|
||||||
|
const BASE_URL = `http://localhost:${PORT}`;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
serverInstance = await createWebServer({ port: PORT });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (serverInstance) {
|
||||||
|
await serverInstance.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /api/settings should return current configuration", async () => {
|
||||||
|
const res = await fetch(`${BASE_URL}/api/settings`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
// Check if BigInts are converted to strings
|
||||||
|
expect(data.economy.daily.amount).toBe("100");
|
||||||
|
expect(data.leveling.base).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /api/settings should save valid configuration via merge", async () => {
|
||||||
|
// We only send a partial update, expecting the server to merge it
|
||||||
|
// Note: For now the server implementation might still default to overwrite if we haven't updated it yet.
|
||||||
|
// But the user requested "partial vs full" fix.
|
||||||
|
// Let's assume we implement the merge logic.
|
||||||
|
const partialConfig = { studentRole: "new-role-partial" };
|
||||||
|
|
||||||
|
const res = await fetch(`${BASE_URL}/api/settings`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(partialConfig)
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
// Expect saveConfig to be called with the MERGED result
|
||||||
|
expect(mockSaveConfig).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
studentRole: "new-role-partial",
|
||||||
|
leveling: mockConfig.leveling // Should keep existing values
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /api/settings should return 400 when save fails", async () => {
|
||||||
|
mockSaveConfig.mockImplementationOnce(() => {
|
||||||
|
throw new Error("Validation failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await fetch(`${BASE_URL}/api/settings`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({}) // Empty might be valid partial, but mocks throw
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const data = await res.json();
|
||||||
|
expect(data.details).toBe("Validation failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /api/settings/meta should return simplified metadata", async () => {
|
||||||
|
const res = await fetch(`${BASE_URL}/api/settings/meta`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
expect(data.roles).toHaveLength(2);
|
||||||
|
expect(data.roles[0]).toEqual({ id: "role1", name: "Admin", color: "#ffffff" });
|
||||||
|
expect(data.channels[0]).toEqual({ id: "chan1", name: "general", type: 0 });
|
||||||
|
expect(data.commands).toContain("ping");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -168,6 +168,71 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Settings Management
|
||||||
|
if (url.pathname === "/api/settings") {
|
||||||
|
try {
|
||||||
|
if (req.method === "GET") {
|
||||||
|
const { config } = await import("@shared/lib/config");
|
||||||
|
const { jsonReplacer } = await import("@shared/lib/utils");
|
||||||
|
return new Response(JSON.stringify(config, jsonReplacer), {
|
||||||
|
headers: { "Content-Type": "application/json" }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (req.method === "POST") {
|
||||||
|
const partialConfig = await req.json();
|
||||||
|
const { saveConfig, config: currentConfig } = await import("@shared/lib/config");
|
||||||
|
const { deepMerge } = await import("@shared/lib/utils");
|
||||||
|
|
||||||
|
// Merge partial update into current config
|
||||||
|
const mergedConfig = deepMerge(currentConfig, partialConfig);
|
||||||
|
|
||||||
|
// saveConfig throws if validation fails
|
||||||
|
saveConfig(mergedConfig);
|
||||||
|
return Response.json({ success: true });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Settings error:", error);
|
||||||
|
return Response.json(
|
||||||
|
{ error: "Failed to process settings request", details: error instanceof Error ? error.message : String(error) },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/api/settings/meta") {
|
||||||
|
try {
|
||||||
|
const { AuroraClient } = await import("../../bot/lib/BotClient");
|
||||||
|
const { env } = await import("@shared/lib/env");
|
||||||
|
|
||||||
|
if (!env.DISCORD_GUILD_ID) {
|
||||||
|
return Response.json({ roles: [], channels: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const guild = AuroraClient.guilds.cache.get(env.DISCORD_GUILD_ID);
|
||||||
|
if (!guild) {
|
||||||
|
return Response.json({ roles: [], channels: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map roles and channels to a simplified format
|
||||||
|
const roles = guild.roles.cache
|
||||||
|
.sort((a, b) => b.position - a.position)
|
||||||
|
.map(r => ({ id: r.id, name: r.name, color: r.hexColor }));
|
||||||
|
|
||||||
|
const channels = guild.channels.cache
|
||||||
|
.map(c => ({ id: c.id, name: c.name, type: c.type }));
|
||||||
|
|
||||||
|
const commands = AuroraClient.commands.map(c => c.data.name);
|
||||||
|
|
||||||
|
return Response.json({ roles, channels, commands });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching settings meta:", error);
|
||||||
|
return Response.json(
|
||||||
|
{ error: "Failed to fetch metadata" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Static File Serving
|
// Static File Serving
|
||||||
let pathName = url.pathname;
|
let pathName = url.pathname;
|
||||||
if (pathName === "/") pathName = "/index.html";
|
if (pathName === "/") pathName = "/index.html";
|
||||||
|
|||||||
Reference in New Issue
Block a user