diff --git a/bot/lib/clientStats.ts b/bot/lib/clientStats.ts index 92cf657..3cbbb79 100644 --- a/bot/lib/clientStats.ts +++ b/bot/lib/clientStats.ts @@ -23,6 +23,7 @@ export function getClientStats(): ClientStats { bot: { name: AuroraClient.user?.username || "Aurora", avatarUrl: AuroraClient.user?.displayAvatarURL() || null, + status: AuroraClient.user?.presence.activities[0]?.state || AuroraClient.user?.presence.activities[0]?.name || null, }, guilds: AuroraClient.guilds.cache.size, ping: AuroraClient.ws.ping, diff --git a/shared/modules/dashboard/dashboard.types.ts b/shared/modules/dashboard/dashboard.types.ts index 4dc3ce4..948e0c9 100644 --- a/shared/modules/dashboard/dashboard.types.ts +++ b/shared/modules/dashboard/dashboard.types.ts @@ -13,6 +13,7 @@ export const DashboardStatsSchema = z.object({ bot: z.object({ name: z.string(), avatarUrl: z.string().nullable(), + status: z.string().nullable(), }), guilds: z.object({ count: z.number(), @@ -84,6 +85,7 @@ export const ClientStatsSchema = z.object({ bot: z.object({ name: z.string(), avatarUrl: z.string().nullable(), + status: z.string().nullable(), }), guilds: z.number(), ping: z.number(), diff --git a/web/build.ts b/web/build.ts index 5c7850f..76aaf15 100644 --- a/web/build.ts +++ b/web/build.ts @@ -135,6 +135,7 @@ const build = async () => { minify: true, target: "browser", sourcemap: "linked", + publicPath: "/", // Use absolute paths for SPA routing compatibility define: { "process.env.NODE_ENV": JSON.stringify((cliConfig as any).watch ? "development" : "production"), }, @@ -159,14 +160,86 @@ console.log(`\nāœ… Build completed in ${buildTime}ms\n`); if ((cliConfig as any).watch) { console.log("šŸ‘€ Watching for changes...\n"); - // Keep the process alive for watch mode - // Bun.build with watch:true handles the watching, - // we just need to make sure the script doesn't exit. - process.stdin.resume(); - // Also, handle manual exit + // Polling-based file watcher for Docker compatibility + // Docker volumes don't propagate filesystem events (inotify) reliably + const srcDir = path.join(process.cwd(), "src"); + const POLL_INTERVAL_MS = 1000; + let lastMtimes = new Map(); + let isRebuilding = false; + + // Collect all file mtimes in src directory + const collectMtimes = async (): Promise> => { + const mtimes = new Map(); + const glob = new Bun.Glob("**/*.{ts,tsx,js,jsx,css,html}"); + + for await (const file of glob.scan({ cwd: srcDir, absolute: true })) { + try { + const stat = await Bun.file(file).stat(); + if (stat) { + mtimes.set(file, stat.mtime.getTime()); + } + } catch { + // File may have been deleted, skip + } + } + return mtimes; + }; + + // Initial collection + lastMtimes = await collectMtimes(); + + // Polling loop + const poll = async () => { + if (isRebuilding) return; + + const currentMtimes = await collectMtimes(); + const changedFiles: string[] = []; + + // Check for new or modified files + for (const [file, mtime] of currentMtimes) { + const lastMtime = lastMtimes.get(file); + if (lastMtime === undefined || lastMtime < mtime) { + changedFiles.push(path.relative(srcDir, file)); + } + } + + // Check for deleted files + for (const file of lastMtimes.keys()) { + if (!currentMtimes.has(file)) { + changedFiles.push(path.relative(srcDir, file) + " (deleted)"); + } + } + + if (changedFiles.length > 0) { + isRebuilding = true; + console.log(`\nšŸ”„ Changes detected:`); + changedFiles.forEach(f => console.log(` • ${f}`)); + console.log(""); + + try { + const rebuildStart = performance.now(); + await build(); + const rebuildEnd = performance.now(); + console.log(`\nāœ… Rebuild completed in ${(rebuildEnd - rebuildStart).toFixed(2)}ms\n`); + } catch (err) { + console.error("āŒ Rebuild failed:", err); + } + + lastMtimes = currentMtimes; + isRebuilding = false; + } + }; + + const interval = setInterval(poll, POLL_INTERVAL_MS); + + // Handle manual exit process.on("SIGINT", () => { + clearInterval(interval); console.log("\nšŸ‘‹ Stopping build watcher..."); process.exit(0); }); + + // Keep process alive + process.stdin.resume(); } diff --git a/web/bun.lock b/web/bun.lock index 773abef..a77d98b 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -7,7 +7,9 @@ "dependencies": { "@hookform/resolvers": "^5.2.2", "@radix-ui/react-accordion": "^1.2.12", + "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.2.6", @@ -93,6 +95,8 @@ "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="], + "@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw=="], + "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="], "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="], @@ -101,6 +105,8 @@ "@radix-ui/react-label": ["@radix-ui/react-label@2.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A=="], + "@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="], + "@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="], "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="], @@ -295,6 +301,8 @@ "@radix-ui/react-label/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], + "@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], diff --git a/web/package.json b/web/package.json index 5a9e149..d90c6bb 100644 --- a/web/package.json +++ b/web/package.json @@ -11,7 +11,9 @@ "dependencies": { "@hookform/resolvers": "^5.2.2", "@radix-ui/react-accordion": "^1.2.12", + "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.2.6", diff --git a/web/src/App.tsx b/web/src/App.tsx index 3ffa537..38816d7 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,23 +1,50 @@ -import { BrowserRouter, Routes, Route } from "react-router-dom"; +import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; import "./index.css"; -import { Dashboard } from "./pages/Dashboard"; + import { DesignSystem } from "./pages/DesignSystem"; import { AdminQuests } from "./pages/AdminQuests"; +import { AdminOverview } from "./pages/admin/Overview"; + import { Home } from "./pages/Home"; import { Toaster } from "sonner"; +import { NavigationProvider } from "./contexts/navigation-context"; +import { MainLayout } from "./components/layout/main-layout"; + +import { SettingsLayout } from "./pages/settings/SettingsLayout"; +import { GeneralSettings } from "./pages/settings/General"; +import { EconomySettings } from "./pages/settings/Economy"; +import { SystemsSettings } from "./pages/settings/Systems"; +import { RolesSettings } from "./pages/settings/Roles"; export function App() { return ( - - - } /> - } /> - } /> - } /> - + + + + + + } /> + } /> + } /> + } /> + + + }> + } /> + } /> + } /> + } /> + } /> + + + } /> + + + ); } export default App; + diff --git a/web/src/components/layout/app-sidebar.tsx b/web/src/components/layout/app-sidebar.tsx new file mode 100644 index 0000000..c5ceecf --- /dev/null +++ b/web/src/components/layout/app-sidebar.tsx @@ -0,0 +1,216 @@ +import { Link } from "react-router-dom" +import { ChevronRight } from "lucide-react" +import { + Sidebar, + SidebarContent, + + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSub, + SidebarMenuSubItem, + SidebarMenuSubButton, + SidebarGroup, + SidebarGroupLabel, + SidebarGroupContent, + SidebarRail, + useSidebar, +} from "@/components/ui/sidebar" +import { useNavigation, type NavItem } from "@/contexts/navigation-context" + +import { cn } from "@/lib/utils" +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { useSocket } from "@/hooks/use-socket" + +function NavItemWithSubMenu({ item }: { item: NavItem }) { + const { state } = useSidebar() + const isCollapsed = state === "collapsed" + + // When collapsed, show a dropdown menu on hover/click + if (isCollapsed) { + return ( + + + + + + + + +
+ {item.title} +
+ {item.subItems?.map((subItem) => ( + + + {subItem.title} + + + ))} +
+
+
+ ) + } + + // When expanded, show collapsible sub-menu + return ( + + + + + + + {item.title} + + + + + + + {item.subItems?.map((subItem) => ( + + + + {subItem.title} + + + + ))} + + + + + ) +} + +function NavItemLink({ item }: { item: NavItem }) { + return ( + + + + + {item.title} + + + + ) +} + +export function AppSidebar() { + const { navItems } = useNavigation() + const { stats } = useSocket() + + return ( + + + + + + + {stats?.bot?.avatarUrl ? ( + {stats.bot.name} + ) : ( +
+ Aurora +
+ )} +
+ Aurora + + {stats?.bot?.status || "Online"} + +
+ +
+
+
+
+ + + + + Menu + + + + {navItems.map((item) => ( + item.subItems ? ( + + ) : ( + + ) + ))} + + + + + + + + +
+ ) +} + diff --git a/web/src/components/layout/main-layout.tsx b/web/src/components/layout/main-layout.tsx new file mode 100644 index 0000000..9383977 --- /dev/null +++ b/web/src/components/layout/main-layout.tsx @@ -0,0 +1,56 @@ +import { SidebarProvider, SidebarInset, SidebarTrigger } from "@/components/ui/sidebar" +import { AppSidebar } from "./app-sidebar" +import { MobileNav } from "@/components/navigation/mobile-nav" +import { useIsMobile } from "@/hooks/use-mobile" +import { Separator } from "@/components/ui/separator" +import { useNavigation } from "@/contexts/navigation-context" + +interface MainLayoutProps { + children: React.ReactNode +} + +export function MainLayout({ children }: MainLayoutProps) { + const isMobile = useIsMobile() + const { breadcrumbs, currentTitle } = useNavigation() + + return ( + + + + {/* Header with breadcrumbs */} +
+
+ + + +
+
+ + {/* Main content */} +
+ {children} +
+ + {/* Mobile bottom navigation */} + {isMobile && } +
+
+ ) +} diff --git a/web/src/components/navigation/mobile-nav.tsx b/web/src/components/navigation/mobile-nav.tsx new file mode 100644 index 0000000..c9f8d47 --- /dev/null +++ b/web/src/components/navigation/mobile-nav.tsx @@ -0,0 +1,41 @@ +import { Link } from "react-router-dom" +import { useNavigation } from "@/contexts/navigation-context" +import { cn } from "@/lib/utils" + +export function MobileNav() { + const { navItems } = useNavigation() + + return ( + + ) +} diff --git a/web/src/components/settings-drawer.tsx b/web/src/components/settings-drawer.tsx deleted file mode 100644 index 2ca0cbd..0000000 --- a/web/src/components/settings-drawer.tsx +++ /dev/null @@ -1,1122 +0,0 @@ -import React, { useEffect, useState } from "react"; -import { useForm } from "react-hook-form"; -import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger, SheetDescription, SheetFooter } from "./ui/sheet"; -import { Button } from "./ui/button"; -import { Settings, Save, Loader2, CreditCard, Terminal, MessageSquare, Shield, Users, Palette, Sparkles, AlertTriangle, Backpack } from "lucide-react"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs"; -import { ScrollArea } from "./ui/scroll-area"; -import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel } from "./ui/form"; -import { Input } from "./ui/input"; -import { Switch } from "./ui/switch"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; -import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "./ui/accordion"; -import { toast } from "sonner"; -import { Textarea } from "./ui/textarea"; -import { Badge } from "./ui/badge"; -import { z } from "zod"; -import { zodResolver } from "@hookform/resolvers/zod"; - -// Sentinel value for "none" selection -const NONE_VALUE = "__none__"; - -// Schema definition matching backend config -const bigIntStringSchema = z.coerce.string() - .refine((val) => /^\d+$/.test(val), { message: "Must be a valid integer" }); - -const formSchema = z.object({ - leveling: z.object({ - base: z.number(), - exponent: z.number(), - chat: z.object({ - cooldownMs: z.number(), - minXp: z.number(), - maxXp: z.number(), - }) - }), - economy: z.object({ - daily: z.object({ - amount: bigIntStringSchema, - streakBonus: bigIntStringSchema, - weeklyBonus: bigIntStringSchema, - cooldownMs: z.number(), - }), - transfers: z.object({ - allowSelfTransfer: z.boolean(), - minAmount: bigIntStringSchema, - }), - exam: z.object({ - multMin: z.number(), - multMax: z.number(), - }) - }), - inventory: z.object({ - maxStackSize: bigIntStringSchema, - maxSlots: z.number(), - }), - commands: z.record(z.string(), z.boolean()).optional(), - lootdrop: z.object({ - activityWindowMs: z.number(), - minMessages: z.number(), - spawnChance: z.number(), - cooldownMs: z.number(), - reward: z.object({ - min: z.number(), - max: z.number(), - currency: z.string(), - }) - }), - studentRole: z.string().optional(), - visitorRole: z.string().optional(), - colorRoles: z.array(z.string()).default([]), - welcomeChannelId: z.string().optional(), - welcomeMessage: z.string().optional(), - feedbackChannelId: z.string().optional(), - terminal: z.object({ - channelId: z.string(), - messageId: z.string() - }).optional(), - moderation: z.object({ - prune: z.object({ - maxAmount: z.number(), - confirmThreshold: z.number(), - batchSize: z.number(), - batchDelayMs: z.number(), - }), - cases: z.object({ - dmOnWarn: z.boolean(), - logChannelId: z.string().optional(), - autoTimeoutThreshold: z.number().optional() - }) - }), - trivia: z.object({ - entryFee: bigIntStringSchema, - rewardMultiplier: z.number(), - timeoutSeconds: z.number(), - cooldownMs: z.number(), - categories: z.array(z.number()).default([]), - difficulty: z.enum(['easy', 'medium', 'hard', 'random']), - }).optional(), - system: z.record(z.string(), z.any()).optional(), -}); - -type FormValues = z.infer; - -interface ConfigMeta { - roles: { id: string, name: string, color: string }[]; - channels: { id: string, name: string, type: number }[]; - commands: { name: string, category: string }[]; -} - -const toSelectValue = (v: string | undefined | null) => v || NONE_VALUE; -const fromSelectValue = (v: string) => v === NONE_VALUE ? "" : v; - -export function SettingsDrawer() { - const [open, setOpen] = useState(false); - const [meta, setMeta] = useState(null); - const [loading, setLoading] = useState(false); - - const form = useForm({ - resolver: zodResolver(formSchema) as any, - defaultValues: { - economy: { - daily: { amount: "0", streakBonus: "0", weeklyBonus: "0", cooldownMs: 0 }, - transfers: { minAmount: "0", allowSelfTransfer: false }, - exam: { multMin: 1, multMax: 1 } - }, - leveling: { base: 100, exponent: 1.5, chat: { minXp: 10, maxXp: 20, cooldownMs: 60000 } }, - inventory: { maxStackSize: "1", maxSlots: 10 }, - moderation: { - prune: { maxAmount: 100, confirmThreshold: 50, batchSize: 100, batchDelayMs: 1000 }, - cases: { dmOnWarn: true } - }, - lootdrop: { - spawnChance: 0.05, - minMessages: 10, - cooldownMs: 300000, - activityWindowMs: 600000, - reward: { min: 100, max: 500, currency: "AU" } - } - } - }); - - useEffect(() => { - if (open) { - setLoading(true); - Promise.all([ - fetch("/api/settings").then(res => res.json()), - fetch("/api/settings/meta").then(res => res.json()) - ]).then(([config, metaData]) => { - form.reset(config as any); - setMeta(metaData); - }).catch(err => { - toast.error("Failed to load settings"); - console.error(err); - }).finally(() => { - setLoading(false); - }); - } - }, [open, form]); - - const onSubmit = async (data: FormValues) => { - try { - const response = await fetch("/api/settings", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(data) - }); - - if (!response.ok) throw new Error("Failed to save"); - - toast.success("Settings saved successfully", { - description: "Bot configuration has been updated and reloaded." - }); - setOpen(false); - } catch (error) { - toast.error("Failed to save settings"); - console.error(error); - } - }; - - return ( - - - - - - - -
- -
- System Configuration -
- - Manage bot behavior, economy, and game systems. Changes apply immediately. - -
- - {loading ? ( -
- -

Loading configuration...

-
- ) : ( -
- - -
- - - - General - - - - Economy - - - - Systems - - - - Roles - - -
- - -
- - {/* GENERAL TAB */} - -
-
- - Onboarding - -
- -
- ( - - Welcome Channel - - Where to send welcome messages. - - )} - /> - - ( - - Welcome Message Template - -