diff --git a/web/app/dashboard/[guildId]/layout.tsx b/web/app/dashboard/[guildId]/layout.tsx index 4f26915..27d5084 100644 --- a/web/app/dashboard/[guildId]/layout.tsx +++ b/web/app/dashboard/[guildId]/layout.tsx @@ -3,6 +3,8 @@ import { redirect, notFound } from "next/navigation"; import { getUserGuilds } from "@/lib/discord"; import ServerSwitcher from "@/components/ServerSwitcher"; import DashboardSidebar from "@/components/DashboardSidebar"; +import UserProfile from "@/components/UserProfile"; + export default async function DashboardLayout({ children, @@ -35,18 +37,8 @@ export default async function DashboardLayout({ -
-
-

{session.user?.name}

-
- {session.user?.image && ( - User - )} -
+ + {/* Bot Not In Guild Content */} @@ -104,18 +96,8 @@ export default async function DashboardLayout({ -
-
-

{session.user?.name}

-
- {session.user?.image && ( - User - )} -
+ + {/* Dashboard Content with Sidebar */} @@ -123,7 +105,8 @@ export default async function DashboardLayout({ {/* Main Area */} -
+
+
{children}
diff --git a/web/app/dashboard/page.tsx b/web/app/dashboard/page.tsx index b93cfa7..075920a 100644 --- a/web/app/dashboard/page.tsx +++ b/web/app/dashboard/page.tsx @@ -3,6 +3,8 @@ import { redirect } from "next/navigation" import Link from "next/link" import { getUserGuilds } from "@/lib/discord" import Toast from "@/components/Toast" +import UserProfile from "@/components/UserProfile" + export default async function Dashboard() { const session = await auth() @@ -21,19 +23,11 @@ export default async function Dashboard() {
{/* Header / User Corner */} -
-
-

{session.user?.name}

-
- {session.user?.image && ( - User - )} +
+
+
If you would like to be participating in the beta program of void sentinel, please send a DM to _void_x_ on discord. diff --git a/web/app/docs/page.tsx b/web/app/docs/page.tsx new file mode 100644 index 0000000..bc84b58 --- /dev/null +++ b/web/app/docs/page.tsx @@ -0,0 +1,421 @@ +"use client"; + +import React, { useState } from "react"; +import Link from "next/link"; +import { + Book, + Bot, + LayoutDashboard, + MessageSquare, + Shield, + Terminal, + Zap, + Menu, + X, + ChevronRight, + Sparkles +} from "lucide-react"; +import { cn } from "@/lib/utils"; + +const sections = [ + { + id: "getting-started", + title: "Getting Started", + icon: , + items: [ + { id: "introduction", title: "Introduction" }, + { id: "inviting-bot", title: "Inviting the Bot" }, + ], + }, + { + id: "commands", + title: "Commands", + icon: , + items: [ + { id: "leveling-commands", title: "Leveling" }, + { id: "utility-commands", title: "Utility" }, + { id: "fun-commands", title: "Fun & AI" }, + ], + }, + { + id: "dashboard", + title: "Dashboard", + icon: , + items: [ + { id: "leaderboard", title: "Leaderboard" }, + { id: "leveling-system", title: "Leveling System" }, + ], + }, +]; + +export default function DocsPage() { + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + const [activeSection, setActiveSection] = useState("introduction"); + + React.useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + setActiveSection(entry.target.id); + } + }); + }, + { + rootMargin: "-100px 0px -80% 0px", + } + ); + + const ids = sections.flatMap((section) => section.items.map((item) => item.id)); + ids.forEach((id) => { + const element = document.getElementById(id); + if (element) { + observer.observe(element); + } + }); + + return () => observer.disconnect(); + }, []); + + const scrollToSection = (id: string) => { + const element = document.getElementById(id); + if (element) { + element.scrollIntoView({ behavior: "smooth" }); + setActiveSection(id); + setMobileMenuOpen(false); + } + }; + + return ( +
+ {/* Mobile Header */} +
+
+ Void Sentinel Logo + Void Sentinel +
+ +
+ +
+ {/* Sidebar Navigation */} + + + {/* Main Content */} +
+
+ {/* Getting Started */} +
+

+ Documentation +

+

+ Welcome to the official documentation for Void Sentinel. A powerful, modern Discord bot designed to elevate your server's engagement through advanced leveling, utility tools, and AI-powered interactions. +

+
+ +
+

+ Inviting the Bot +

+

+ Currently, Void Sentinel is in Beta. Access is restricted to approved servers. +

+
+
+ +
+
+

Restricted Access

+

+ To invite Void Sentinel to your server, you must request access by sending a DM to _void_x_ on Discord. Once approved, you can use the invite link provided in the dashboard. +

+
+
+
+ + {/* Commands */} +
+

+ Leveling Commands +

+
+ + + +
+
+ +
+

+ Utility Commands +

+
+ + + + + +
+
+ +
+

+ Fun & AI Commands +

+ +
+

+ Note: AI-powered features are currently experimental and restricted. +

+
+ +
+ + + + +
+
+ + {/* Dashboard Guide */} + +
+

+ Leaderboard +

+

+ View your server's leveling leaderboard through the dashboard. This list displays all members ranked by their accumulated XP and current level. +

+
+ Leaderboard Example +
+
+ +
+

+ Leveling System Setup +

+

+ The dashboard allows you to configure multi-track leveling systems. Here's how to set it up: +

+ +
+
+

+ 1 + Create Leveling Roles +

+
    +
  • Create all the level roles you intend to use in your Discord server first.
  • +
+
+ +
+

+ 2 + Add to Multi-Track Leveling System +

+
    +
  • Go to the dashboard and click on Leveling in the sidebar.
  • +
  • In the Level Tracks section, click on Add New Track.
  • +
  • Give your track a unique name (e.g., "Mage", "Warrior").
  • +
  • Select an Initial Role. This role identifies which track a user belongs to.
  • +
  • Add your level roles and assign the specific levels they unlock at.
  • +
  • Click Save Changes to apply your configuration.
  • +
+
+ Free servers can have up to 4 tracks. Premium servers support up to 10 tracks. +
+
+ +
+

+ 3 + Set Level Bridgers +

+

+ Level Bridgers help restrict users to their initial track and integrate seamlessly with Discord onboarding or reaction roles. +

+
    +
  • Select a Recruit Role (the role given by onboarding/reaction).
  • +
  • Select the corresponding Initial Role defined in your Level Track.
  • +
  • Click on Add Bridger.
  • +
+
+
+ +
+

+ + Changing Tracks +

+

+ If a user wants to switch to a different track, they must rest their progress. Use the command /level reset. + This will reset their level and XP to 0 and remove all roles associated with their current level track. +

+
+
+ +
+
+
+
+ ); +} + +function CommandCard({ command, description, args, permission, tag }: { command: string, description: string, args?: string[], permission?: string, tag?: string }) { + return ( +
+
+
+ + {command} + + {permission && ( + + {permission} + + )} +
+ {tag && ( + + {tag} + + )} +
+
+

{description}

+ {args && args.length > 0 && ( +
+

Parameters

+
    + {args.map((arg, i) => ( +
  • + {arg} +
  • + ))} +
+
+ )} +
+
+ ) +} diff --git a/web/app/favicon.ico b/web/app/favicon.ico deleted file mode 100644 index 718d6fe..0000000 Binary files a/web/app/favicon.ico and /dev/null differ diff --git a/web/components/DashboardSidebar.tsx b/web/components/DashboardSidebar.tsx index e34cab6..a2be18a 100644 --- a/web/components/DashboardSidebar.tsx +++ b/web/components/DashboardSidebar.tsx @@ -3,7 +3,8 @@ import { useState } from "react"; import Link from "next/link"; import { usePathname } from "next/navigation"; -import { Menu, X, Trophy, Sparkles } from "lucide-react"; +import { Menu, X, Trophy, Sparkles, BookMarked } from "lucide-react"; + interface DashboardSidebarProps { guildId: string; @@ -45,10 +46,14 @@ export default function DashboardSidebar({ guildId }: DashboardSidebarProps) { {/* Sidebar */}
-
+ +
+ + + {menuItems.map((item) => { const Icon = item.icon; const isActive = activeTab === item.id; @@ -60,8 +65,8 @@ export default function DashboardSidebar({ guildId }: DashboardSidebarProps) { href={href} onClick={() => setSidebarOpen(false)} className={`w-full text-left px-4 py-3 rounded-lg font-medium transition-all flex items-center gap-3 ${isActive - ? "bg-blue-600/20 text-blue-400 border border-blue-600/30" - : "text-gray-400 hover:text-white hover:bg-white/5 border border-transparent" + ? "bg-blue-600/20 text-blue-400 border border-blue-600/30" + : "text-gray-400 hover:text-white hover:bg-white/5 border border-transparent" }`} > @@ -70,7 +75,19 @@ export default function DashboardSidebar({ guildId }: DashboardSidebarProps) { ); })}
+ + {/* Docs Link at Bottom */} +
+ + + Documentation + +
+ ); } diff --git a/web/components/Footer.tsx b/web/components/Footer.tsx index 27b7859..2be7793 100644 --- a/web/components/Footer.tsx +++ b/web/components/Footer.tsx @@ -5,6 +5,11 @@ const Footer = () => {
© {new Date().getFullYear()} Void Sentinel. All rights reserved.
+
); diff --git a/web/components/LevelingEditor.tsx b/web/components/LevelingEditor.tsx index aa965d0..1a60755 100644 --- a/web/components/LevelingEditor.tsx +++ b/web/components/LevelingEditor.tsx @@ -72,7 +72,7 @@ export default function LevelingEditor({ guildId }: LevelingEditorProps) { // Level Bridger State const [bridges, setBridges] = useState([]); - const [deletedBridges, setDeletedBridges] = useState([]); // in_role_ids + const [newBridgeIn, setNewBridgeIn] = useState(null); const [newBridgeOut, setNewBridgeOut] = useState(null); const [showAddBridge, setShowAddBridge] = useState(false); @@ -118,6 +118,68 @@ export default function LevelingEditor({ guildId }: LevelingEditorProps) { return () => document.removeEventListener("mousedown", handleClickOutside); }, []); + // Add bridge + const handleAddBridge = async () => { + if (!newBridgeIn || !newBridgeOut) return; + if (newBridgeIn === newBridgeOut) { + setError("Recruit Role and Initial Role cannot be the same"); + return; + } + + const bridge = { + in_role_id: String(newBridgeIn), + out_role_id: String(newBridgeOut) + }; + + try { + const response = await fetch(`/api/guilds/${guildId}/level_bridger`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(bridge), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || "Failed to add bridge"); + } + + setBridges([...bridges, bridge]); + setNewBridgeIn(null); + setNewBridgeOut(null); + setError(null); + setSuccess("Bridge added successfully"); + setTimeout(() => setSuccess(null), 3000); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to add bridge"); + } + }; + + // Delete bridge + const handleDeleteBridge = async (index: number) => { + const bridge = bridges[index]; + const inRoleId = String(bridge.in_role_id); + + try { + const response = await fetch(`/api/guilds/${guildId}/level_bridger`, { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ in_role_id: inRoleId }), + }); + + if (!response.ok) { + throw new Error("Failed to delete bridge"); + } + + const newBridges = [...bridges]; + newBridges.splice(index, 1); + setBridges(newBridges); + setSuccess("Bridge deleted successfully"); + setTimeout(() => setSuccess(null), 3000); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to delete bridge"); + } + }; + // Fetch tracks and roles const fetchData = useCallback(async () => { setLoading(true); @@ -202,7 +264,7 @@ export default function LevelingEditor({ guildId }: LevelingEditorProps) { setDeletedTracks([]); setBridges(Array.isArray(bridgesData) ? bridgesData : []); - setDeletedBridges([]); + // All tracks start collapsed } catch (err) { @@ -239,19 +301,6 @@ export default function LevelingEditor({ guildId }: LevelingEditorProps) { await Promise.all(deletePromises); - // Level Bridger Deletions - const deleteBridgePromises = deletedBridges.map(async (inRoleId) => { - const response = await fetch(`/api/guilds/${guildId}/level_bridger`, { - method: "DELETE", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ in_role_id: inRoleId }), - }); - if (!response.ok) { - console.error(`Failed to delete bridge for ${inRoleId}`); - } - }); - await Promise.all(deleteBridgePromises); - // Then, update/create each track with the new format: // { track_name: string, roles: [{ role_id: number, level: number }, ...] } const updatePromises = tracks.map(async (track) => { @@ -277,23 +326,9 @@ export default function LevelingEditor({ guildId }: LevelingEditorProps) { await Promise.all(updatePromises); - // Level Bridger Updates (PUT) - const updateBridgePromises = bridges.map(async (bridge) => { - const response = await fetch(`/api/guilds/${guildId}/level_bridger`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(bridge), - }); - if (!response.ok) { - console.error(`Failed to save bridge ${bridge.in_role_id} -> ${bridge.out_role_id}`); - } - }); - await Promise.all(updateBridgePromises); - // Update tracking state setOriginalTrackNames(new Set(tracks.map(t => t.name))); setDeletedTracks([]); - setDeletedBridges([]); setSuccess("Changes saved successfully!"); setHasChanges(false); setTimeout(() => setSuccess(null), 3000); @@ -1054,13 +1089,7 @@ export default function LevelingEditor({ guildId }: LevelingEditorProps) {
+ + {isOpen && ( +
+
+

{user?.name || "User"}

+
+ + +
+ )} +
+ ); +} diff --git a/web/lib/discord.ts b/web/lib/discord.ts index 96b6e49..58fc224 100644 --- a/web/lib/discord.ts +++ b/web/lib/discord.ts @@ -269,9 +269,9 @@ export async function createLevelTrack( try { if (!process.env.BOT_API_URL) return { success: false, error: "Bot API URL not configured" }; - // WORKAROUND: Unquote role_id for backend (expects u64) - // We kept it as string in frontend/NextJS to preserve precision. - const bodyJson = JSON.stringify(data).replace(/"role_id":\s*"(\d+)"/g, '"role_id": $1'); + // REMOVED WORKAROUND: Backend now expects strings for u64 IDs + const bodyJson = JSON.stringify(data); + const response = await fetch(`${process.env.BOT_API_URL}/api/${guildId}/level/track`, { method: "POST", @@ -303,8 +303,9 @@ export async function updateLevelTrack( try { if (!process.env.BOT_API_URL) return { success: false, error: "Bot API URL not configured" }; - // WORKAROUND: Unquote role_id for backend (expects u64) - const bodyJson = JSON.stringify(data).replace(/"role_id":\s*"(\d+)"/g, '"role_id": $1'); + // REMOVED WORKAROUND: Backend now expects strings for u64 IDs + const bodyJson = JSON.stringify(data); + const response = await fetch(`${process.env.BOT_API_URL}/api/${guildId}/level/track`, { method: "PATCH", @@ -400,8 +401,9 @@ export async function createLevelBridge( try { if (!process.env.BOT_API_URL) return { success: false, error: "Bot API URL not configured" }; - // WORKAROUND: Unquote role_id for backend (expects u64) - const bodyJson = JSON.stringify(bridge).replace(/("in_role_id"|"out_role_id"):\s*"(\d+)"/g, '$1: $2'); + // REMOVED WORKAROUND: Backend now expects strings for u64 IDs + const bodyJson = JSON.stringify(bridge); + const response = await fetch(`${process.env.BOT_API_URL}/api/${guildId}/level_bridger`, { method: "POST", @@ -432,7 +434,8 @@ export async function updateLevelBridge( try { if (!process.env.BOT_API_URL) return { success: false, error: "Bot API URL not configured" }; - const bodyJson = JSON.stringify(bridge).replace(/("in_role_id"|"out_role_id"):\s*"(\d+)"/g, '$1: $2'); + const bodyJson = JSON.stringify(bridge); + const response = await fetch(`${process.env.BOT_API_URL}/api/${guildId}/level_bridger`, { method: "PUT", @@ -463,8 +466,8 @@ export async function deleteLevelBridge( try { if (!process.env.BOT_API_URL) return { success: false, error: "Bot API URL not configured" }; - // Even for delete, we might need to handle the u64 issue if the ID is passed in body - const bodyJson = JSON.stringify({ in_role_id: inRoleId }).replace(/("in_role_id"):\s*"(\d+)"/g, '$1: $2'); + const bodyJson = JSON.stringify({ in_role_id: inRoleId }); + const response = await fetch(`${process.env.BOT_API_URL}/api/${guildId}/level_bridger`, { method: "DELETE", diff --git a/web/public/leaderboard_example.png b/web/public/leaderboard_example.png new file mode 100644 index 0000000..87ff6ae Binary files /dev/null and b/web/public/leaderboard_example.png differ