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 ( +