From f0bfaecb0b31f1c6863f150188451d81b998021a Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Sat, 14 Feb 2026 12:45:23 +0100 Subject: [PATCH] feat: add settings page with guild config, game settings, and command toggles Implements the full admin settings page covering all game settings (leveling, economy, inventory, lootdrops, trivia, moderation, commands) and guild settings (roles, channels, welcome message, moderation, feature overrides). Includes role/channel pickers, trivia category multi-select, and a feature override flag editor. Co-Authored-By: Claude Opus 4.6 --- api/src/routes/settings.routes.ts | 2 +- panel/src/App.tsx | 3 + panel/src/lib/useSettings.ts | 192 ++++ panel/src/pages/Settings.tsx | 1411 +++++++++++++++++++++++++++++ 4 files changed, 1607 insertions(+), 1 deletion(-) create mode 100644 panel/src/lib/useSettings.ts create mode 100644 panel/src/pages/Settings.tsx diff --git a/api/src/routes/settings.routes.ts b/api/src/routes/settings.routes.ts index eb055ce..1e22c67 100644 --- a/api/src/routes/settings.routes.ts +++ b/api/src/routes/settings.routes.ts @@ -139,7 +139,7 @@ async function handler(ctx: RouteContext): Promise { return a.name.localeCompare(b.name); }); - return jsonResponse({ roles, channels, commands }); + return jsonResponse({ guildId: env.DISCORD_GUILD_ID, roles, channels, commands }); }, "fetch settings meta"); } diff --git a/panel/src/App.tsx b/panel/src/App.tsx index 376fa7c..09b301c 100644 --- a/panel/src/App.tsx +++ b/panel/src/App.tsx @@ -3,6 +3,7 @@ import { useAuth } from "./lib/useAuth"; import { Loader2 } from "lucide-react"; import Layout, { type Page } from "./components/Layout"; import Dashboard from "./pages/Dashboard"; +import Settings from "./pages/Settings"; import PlaceholderPage from "./pages/PlaceholderPage"; const placeholders: Record = { @@ -74,6 +75,8 @@ export default function App() { {page === "dashboard" ? ( + ) : page === "settings" ? ( + ) : ( )} diff --git a/panel/src/lib/useSettings.ts b/panel/src/lib/useSettings.ts new file mode 100644 index 0000000..34be1fc --- /dev/null +++ b/panel/src/lib/useSettings.ts @@ -0,0 +1,192 @@ +import { useCallback, useEffect, useState } from "react"; +import { get, post, put } from "./api"; + +export interface LevelingConfig { + base: number; + exponent: number; + chat: { + cooldownMs: number; + minXp: number; + maxXp: number; + }; +} + +export interface EconomyConfig { + daily: { + amount: string; + streakBonus: string; + weeklyBonus: string; + cooldownMs: number; + }; + transfers: { + allowSelfTransfer: boolean; + minAmount: string; + }; + exam: { + multMin: number; + multMax: number; + }; +} + +export interface InventoryConfig { + maxStackSize: string; + maxSlots: number; +} + +export interface LootdropConfig { + activityWindowMs: number; + minMessages: number; + spawnChance: number; + cooldownMs: number; + reward: { + min: number; + max: number; + currency: string; + }; +} + +export interface TriviaConfig { + entryFee: string; + rewardMultiplier: number; + timeoutSeconds: number; + cooldownMs: number; + categories: number[]; + difficulty: string; +} + +export interface ModerationConfig { + prune: { + maxAmount: number; + confirmThreshold: number; + batchSize: number; + batchDelayMs: number; + }; +} + +export interface GameSettings { + leveling: LevelingConfig; + economy: EconomyConfig; + inventory: InventoryConfig; + lootdrop: LootdropConfig; + trivia: TriviaConfig; + moderation: ModerationConfig; + commands: Record; + system: Record; +} + +export interface GuildSettings { + guildId: string; + configured: boolean; + studentRoleId?: string; + visitorRoleId?: string; + colorRoleIds?: string[]; + welcomeChannelId?: string; + welcomeMessage?: string; + feedbackChannelId?: string; + terminalChannelId?: string; + terminalMessageId?: string; + moderationLogChannelId?: string; + moderationDmOnWarn?: boolean; + moderationAutoTimeoutThreshold?: number; + featureOverrides?: Record; +} + +export interface SettingsMeta { + guildId?: string; + roles: Array<{ id: string; name: string; color: string }>; + channels: Array<{ id: string; name: string; type: number }>; + commands: Array<{ name: string; category: string }>; +} + +export function useSettings() { + const [settings, setSettings] = useState(null); + const [guildSettings, setGuildSettings] = useState(null); + const [meta, setMeta] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + const fetchSettings = useCallback(async () => { + try { + setLoading(true); + setError(null); + const [settingsData, metaData] = await Promise.all([ + get("/api/settings"), + get("/api/settings/meta"), + ]); + setSettings(settingsData); + setMeta(metaData); + + // Fetch guild settings if we have a guild ID + if (metaData.guildId) { + const gs = await get( + `/api/guilds/${metaData.guildId}/settings` + ); + setGuildSettings(gs); + } + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to load settings"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchSettings(); + }, [fetchSettings]); + + const saveSettings = useCallback( + async (partial: Record) => { + try { + setSaving(true); + setError(null); + await post("/api/settings", partial); + const updated = await get("/api/settings"); + setSettings(updated); + return true; + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to save settings"); + return false; + } finally { + setSaving(false); + } + }, + [] + ); + + const saveGuildSettings = useCallback( + async (data: Partial) => { + if (!meta?.guildId) return false; + try { + setSaving(true); + setError(null); + await put(`/api/guilds/${meta.guildId}/settings`, data); + const updated = await get( + `/api/guilds/${meta.guildId}/settings` + ); + setGuildSettings(updated); + return true; + } catch (e) { + setError( + e instanceof Error ? e.message : "Failed to save guild settings" + ); + return false; + } finally { + setSaving(false); + } + }, + [meta?.guildId] + ); + + return { + settings, + guildSettings, + meta, + loading, + saving, + error, + saveSettings, + saveGuildSettings, + refetch: fetchSettings, + }; +} diff --git a/panel/src/pages/Settings.tsx b/panel/src/pages/Settings.tsx new file mode 100644 index 0000000..e1e36ca --- /dev/null +++ b/panel/src/pages/Settings.tsx @@ -0,0 +1,1411 @@ +import { useCallback, useEffect, useState } from "react"; +import { + Loader2, + AlertTriangle, + Save, + Check, + TrendingUp, + Coins, + Package, + Gift, + Brain, + Shield, + Terminal, + RotateCcw, + Server, + X, +} from "lucide-react"; +import { cn } from "../lib/utils"; +import { + useSettings, + type GameSettings, + type GuildSettings, + type SettingsMeta, +} from "../lib/useSettings"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +type SettingsSection = + | "guild" + | "leveling" + | "economy" + | "inventory" + | "lootdrop" + | "trivia" + | "moderation" + | "commands"; + +const sections: { + key: SettingsSection; + label: string; + icon: React.ComponentType<{ className?: string }>; +}[] = [ + { key: "guild", label: "Guild", icon: Server }, + { key: "leveling", label: "Leveling", icon: TrendingUp }, + { key: "economy", label: "Economy", icon: Coins }, + { key: "inventory", label: "Inventory", icon: Package }, + { key: "lootdrop", label: "Lootdrops", icon: Gift }, + { key: "trivia", label: "Trivia", icon: Brain }, + { key: "moderation", label: "Moderation", icon: Shield }, + { key: "commands", label: "Commands", icon: Terminal }, +]; + +function formatMs(ms: number): string { + if (ms >= 86_400_000) return `${ms / 86_400_000}h`; + if (ms >= 60_000) return `${ms / 60_000} min`; + return `${ms / 1_000}s`; +} + +// --------------------------------------------------------------------------- +// Reusable field components +// --------------------------------------------------------------------------- + +function Field({ + label, + hint, + children, +}: { + label: string; + hint?: string; + children: React.ReactNode; +}) { + return ( +
+ + {children} + {hint &&

{hint}

} +
+ ); +} + +function NumberInput({ + value, + onChange, + min, + max, + step, + className, +}: { + value: number; + onChange: (v: number) => void; + min?: number; + max?: number; + step?: number; + className?: string; +}) { + return ( + onChange(Number(e.target.value))} + min={min} + max={max} + step={step} + className={cn( + "w-full bg-input border border-border rounded-md px-3 py-2 text-sm font-mono text-foreground", + "focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30", + "transition-colors", + className + )} + /> + ); +} + +function StringInput({ + value, + onChange, + placeholder, + className, +}: { + value: string; + onChange: (v: string) => void; + placeholder?: string; + className?: string; +}) { + return ( + onChange(e.target.value)} + placeholder={placeholder} + className={cn( + "w-full bg-input border border-border rounded-md px-3 py-2 text-sm font-mono text-foreground", + "focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30", + "transition-colors", + className + )} + /> + ); +} + +function TextArea({ + value, + onChange, + placeholder, + rows = 3, +}: { + value: string; + onChange: (v: string) => void; + placeholder?: string; + rows?: number; +}) { + return ( +