added frontend + securing beta server invites
This commit is contained in:
24
web/components/Background.tsx
Normal file
24
web/components/Background.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
|
||||
const Background = () => {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[-1] overflow-hidden bg-background">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,_var(--tw-gradient-stops))] from-secondary/20 via-background to-background"></div>
|
||||
<div
|
||||
className="absolute inset-0 opacity-30"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"linear-gradient(rgba(255, 255, 255, 0.05) 1px, transparent 1px), linear-gradient(90deg, rgba(255, 255, 255, 0.05) 1px, transparent 1px)",
|
||||
backgroundSize: "40px 40px",
|
||||
}}
|
||||
></div>
|
||||
{/* Decorative glows */}
|
||||
<div className="absolute -top-40 -left-40 w-96 h-96 bg-primary/30 rounded-full blur-3xl opacity-50 animate-pulse"></div>
|
||||
<div className="absolute top-1/2 right-0 w-80 h-80 bg-accent/20 rounded-full blur-3xl opacity-30"></div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Background;
|
||||
76
web/components/DashboardSidebar.tsx
Normal file
76
web/components/DashboardSidebar.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Menu, X, Trophy, Sparkles } from "lucide-react";
|
||||
|
||||
interface DashboardSidebarProps {
|
||||
guildId: string;
|
||||
}
|
||||
|
||||
const menuItems = [
|
||||
{ id: "leaderboard", label: "Leaderboard", icon: Trophy, href: "/leaderboard" },
|
||||
{ id: "leveling", label: "Leveling", icon: Sparkles, href: "/leveling" },
|
||||
];
|
||||
|
||||
export default function DashboardSidebar({ guildId }: DashboardSidebarProps) {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const pathname = usePathname();
|
||||
|
||||
const getActiveTab = () => {
|
||||
if (pathname.includes("/leveling")) return "leveling";
|
||||
return "leaderboard";
|
||||
};
|
||||
|
||||
const activeTab = getActiveTab();
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile Menu Button */}
|
||||
<button
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
className="fixed bottom-4 right-4 z-50 lg:hidden p-3 bg-blue-600 hover:bg-blue-700 text-white rounded-full shadow-lg transition-all"
|
||||
>
|
||||
{sidebarOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
|
||||
</button>
|
||||
|
||||
{/* Mobile Sidebar Overlay */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/60 z-40 lg:hidden backdrop-blur-sm"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<div
|
||||
className={`fixed lg:relative inset-y-0 left-0 z-40 w-64 flex-shrink-0 bg-black/90 lg:bg-black/20 border-r border-white/10 overflow-y-auto backdrop-blur-md lg:backdrop-blur-sm transform transition-transform duration-300 ease-in-out ${sidebarOpen ? "translate-x-0" : "-translate-x-full lg:translate-x-0"
|
||||
}`}
|
||||
>
|
||||
<div className="p-4 space-y-2 pt-20 lg:pt-4">
|
||||
{menuItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = activeTab === item.id;
|
||||
const href = `/dashboard/${guildId}${item.href}`;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.id}
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-5 h-5" />
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
74
web/components/Features.tsx
Normal file
74
web/components/Features.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import React, { useRef } from "react";
|
||||
import gsap from "gsap";
|
||||
import { useGSAP } from "@gsap/react";
|
||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||
|
||||
gsap.registerPlugin(useGSAP, ScrollTrigger);
|
||||
|
||||
const features = [
|
||||
{
|
||||
title: "Multi-track Leveling",
|
||||
description:
|
||||
"Create distinct leveling paths for different roles or activities. Perfect for complex community structures.",
|
||||
},
|
||||
{
|
||||
title: "Coming Soon",
|
||||
description:
|
||||
"More features will be added in the future."
|
||||
},
|
||||
];
|
||||
|
||||
const Features = () => {
|
||||
const container = useRef(null);
|
||||
|
||||
useGSAP(
|
||||
() => {
|
||||
const cards = gsap.utils.toArray(".feature-card");
|
||||
|
||||
cards.forEach((card: any, index) => {
|
||||
gsap.from(card, {
|
||||
scrollTrigger: {
|
||||
trigger: card,
|
||||
start: "top 85%",
|
||||
toggleActions: "play none none reverse",
|
||||
},
|
||||
y: 50,
|
||||
opacity: 0,
|
||||
duration: 0.8,
|
||||
delay: index * 0.2,
|
||||
ease: "power3.out",
|
||||
});
|
||||
});
|
||||
},
|
||||
{ scope: container }
|
||||
);
|
||||
|
||||
return (
|
||||
<section ref={container} className="py-32 relative z-10">
|
||||
<div className="container mx-auto px-4">
|
||||
<h2 className="text-4xl font-bold text-center mb-16 bg-clip-text text-transparent bg-gradient-to-r from-white to-gray-400">
|
||||
Why Void Sentinel?
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
{features.map((feature, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="feature-card p-8 rounded-2xl bg-white/5 border border-white/10 hover:border-primary/50 transition-colors backdrop-blur-sm"
|
||||
>
|
||||
<h3 className="text-xl font-semibold mb-3 text-white">
|
||||
{feature.title}
|
||||
</h3>
|
||||
<p className="text-gray-400 leading-relaxed">
|
||||
{feature.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Features;
|
||||
13
web/components/Footer.tsx
Normal file
13
web/components/Footer.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
const Footer = () => {
|
||||
return (
|
||||
<footer className="py-12 relative z-10 border-t border-white/5 bg-black/20 backdrop-blur-md">
|
||||
<div className="container mx-auto px-4 flex flex-col md:flex-row justify-between items-center gap-6">
|
||||
<div className="text-gray-400 text-sm">
|
||||
© {new Date().getFullYear()} Void Sentinel. All rights reserved.
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
87
web/components/Hero.tsx
Normal file
87
web/components/Hero.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import React, { useRef } from "react";
|
||||
import gsap from "gsap";
|
||||
import { useGSAP } from "@gsap/react";
|
||||
import Link from "next/link";
|
||||
|
||||
gsap.registerPlugin(useGSAP);
|
||||
|
||||
interface HeroProps {
|
||||
authButton?: React.ReactNode;
|
||||
}
|
||||
|
||||
const Hero = ({ authButton }: HeroProps) => {
|
||||
const container = useRef(null);
|
||||
const titleRef = useRef(null);
|
||||
const textRef = useRef(null);
|
||||
const btnRef = useRef(null);
|
||||
|
||||
useGSAP(
|
||||
() => {
|
||||
const tl = gsap.timeline({ defaults: { ease: "power3.out" } });
|
||||
|
||||
tl.from(titleRef.current, {
|
||||
y: 50,
|
||||
opacity: 0,
|
||||
duration: 1,
|
||||
stagger: 0.2,
|
||||
})
|
||||
.from(
|
||||
textRef.current,
|
||||
{
|
||||
y: 30,
|
||||
opacity: 0,
|
||||
duration: 0.8,
|
||||
},
|
||||
"-=0.5"
|
||||
)
|
||||
.from(
|
||||
btnRef.current,
|
||||
{
|
||||
y: 20,
|
||||
opacity: 0,
|
||||
duration: 0.6,
|
||||
},
|
||||
"-=0.4"
|
||||
);
|
||||
},
|
||||
{ scope: container }
|
||||
);
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={container}
|
||||
className="min-h-screen flex flex-col items-center justify-center text-center px-4 relative"
|
||||
>
|
||||
<div className="max-w-4xl mx-auto space-y-8">
|
||||
<div className="inline-block px-4 py-1 mb-4 border border-blue-500/30 rounded-full bg-blue-500/10 backdrop-blur-sm">
|
||||
<span className="text-sm font-medium text-blue-300">Under Development - If you want pre-release access, contact <span className="font-bold">_void_x_</span> on Discord.</span>
|
||||
</div>
|
||||
<h1
|
||||
ref={titleRef}
|
||||
className="text-6xl md:text-8xl font-bold tracking-tighter bg-clip-text text-transparent bg-gradient-to-r from-white via-blue-200 to-slate-400"
|
||||
>
|
||||
VOID SENTINEL
|
||||
</h1>
|
||||
<p
|
||||
ref={textRef}
|
||||
className="text-xl md:text-2xl text-gray-400 max-w-2xl mx-auto italic"
|
||||
>
|
||||
“I am the sentinel at the edge of the void. A warden against the shadows and an echo of light for those within. I protect, I entertain, I watch.”
|
||||
</p>
|
||||
<div
|
||||
ref={btnRef}
|
||||
className="flex flex-col sm:flex-row gap-4 justify-center items-center"
|
||||
>
|
||||
{authButton}
|
||||
<Link href="/docs" className="px-8 py-2 bg-white/5 hover:bg-white/10 border border-white/10 hover:border-white/20 text-white rounded-full font-semibold transition-all hover:scale-105 flex items-center gap-2 backdrop-blur-sm">
|
||||
Documentation
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Hero;
|
||||
250
web/components/LeaderboardView.tsx
Normal file
250
web/components/LeaderboardView.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
import type { LeaderboardMember } from "@/lib/discord";
|
||||
|
||||
interface LeaderboardViewProps {
|
||||
leaderboardData: LeaderboardMember[];
|
||||
currentUserRank?: LeaderboardMember;
|
||||
currentUserId?: string;
|
||||
}
|
||||
|
||||
export default function LeaderboardView({
|
||||
leaderboardData,
|
||||
currentUserRank,
|
||||
currentUserId,
|
||||
}: LeaderboardViewProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-6 sm:mb-8">
|
||||
<h2 className="text-2xl sm:text-3xl font-bold text-white mb-4 sm:mb-6">
|
||||
Leaderboard
|
||||
</h2>
|
||||
{currentUserRank && (
|
||||
<div className="bg-white/5 border border-white/10 rounded-xl sm:rounded-2xl p-4 sm:p-6 flex flex-col md:flex-row items-center justify-between backdrop-blur-md shadow-2xl relative overflow-hidden group">
|
||||
{/* Background Glow */}
|
||||
<div className="absolute top-0 right-0 w-64 h-64 bg-blue-500/5 rounded-full blur-3xl -translate-y-1/2 translate-x-1/2 group-hover:bg-blue-500/10 transition-all duration-700"></div>
|
||||
|
||||
<div className="flex items-center gap-4 sm:gap-6 relative z-10 mb-4 md:mb-0">
|
||||
{currentUserRank.avatar ? (
|
||||
<img
|
||||
src={currentUserRank.avatar}
|
||||
alt={currentUserRank.username}
|
||||
className="w-16 h-16 sm:w-20 sm:h-20 rounded-full border-4 border-white/10 shadow-lg"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-16 h-16 sm:w-20 sm:h-20 rounded-full bg-gray-700 flex items-center justify-center text-white font-bold text-2xl sm:text-3xl border-4 border-white/10 shadow-lg">
|
||||
{currentUserRank.username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xl sm:text-2xl font-bold text-white">
|
||||
{currentUserRank.username}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex bg-black/20 rounded-xl p-3 sm:p-4 gap-4 sm:gap-8 md:gap-16 border border-white/5 relative z-10 w-full md:w-auto justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-gray-400 text-[10px] sm:text-xs uppercase font-bold tracking-wider mb-1">
|
||||
Rank
|
||||
</p>
|
||||
<p className="text-2xl sm:text-3xl font-black text-white">
|
||||
#{currentUserRank.rank}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-px bg-white/10"></div>
|
||||
<div className="text-center">
|
||||
<p className="text-gray-400 text-[10px] sm:text-xs uppercase font-bold tracking-wider mb-1">
|
||||
Level
|
||||
</p>
|
||||
<p className="text-2xl sm:text-3xl font-black text-yellow-500">
|
||||
{currentUserRank.level}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-px bg-white/10"></div>
|
||||
<div className="text-center">
|
||||
<p className="text-gray-400 text-[10px] sm:text-xs uppercase font-bold tracking-wider mb-1">
|
||||
Total XP
|
||||
</p>
|
||||
<p className="text-2xl sm:text-3xl font-black text-blue-400">
|
||||
{currentUserRank.xp.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white/5 backdrop-blur-md border border-white/10 rounded-xl sm:rounded-2xl overflow-hidden shadow-xl">
|
||||
{/* Mobile Card View */}
|
||||
<div className="block sm:hidden">
|
||||
{leaderboardData.length > 0 ? (
|
||||
<div className="divide-y divide-white/5">
|
||||
{leaderboardData.map((member) => {
|
||||
const isCurrentUser = member.user_id === currentUserId;
|
||||
let rankColor = "text-blue-400";
|
||||
if (member.rank === 1)
|
||||
rankColor = "text-yellow-400";
|
||||
if (member.rank === 2)
|
||||
rankColor = "text-gray-300";
|
||||
if (member.rank === 3)
|
||||
rankColor = "text-orange-400";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={member.user_id}
|
||||
className={`p-4 ${isCurrentUser
|
||||
? "bg-blue-600/20"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={`font-mono font-bold text-lg w-10 ${rankColor}`}
|
||||
>
|
||||
#{member.rank}
|
||||
</span>
|
||||
{member.avatar ? (
|
||||
<img
|
||||
src={member.avatar}
|
||||
alt=""
|
||||
className="w-10 h-10 rounded-full bg-gray-700 shadow-md"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-10 h-10 rounded-full bg-gray-700 flex items-center justify-center text-white font-bold">
|
||||
{member.username
|
||||
.charAt(0)
|
||||
.toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p
|
||||
className={`font-semibold truncate ${isCurrentUser
|
||||
? "text-blue-200"
|
||||
: "text-white"
|
||||
}`}
|
||||
>
|
||||
{member.username}
|
||||
</p>
|
||||
{isCurrentUser && (
|
||||
<span className="text-xs text-blue-400">
|
||||
You
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-bold text-yellow-500">
|
||||
Lv. {member.level}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 font-mono">
|
||||
{member.xp.toLocaleString()} XP
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-12 text-center text-gray-500">
|
||||
Setup leveling system to see the leaderboard
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop Table View */}
|
||||
<div className="hidden sm:block overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-white/10 text-gray-400 text-sm uppercase tracking-wider bg-white/5">
|
||||
<th className="p-4 pl-6">Rank</th>
|
||||
<th className="p-4">User</th>
|
||||
<th className="p-4 text-right">Level</th>
|
||||
<th className="p-4 pr-6 text-right">XP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-300">
|
||||
{leaderboardData.length > 0 ? (
|
||||
leaderboardData.map((member) => {
|
||||
const isCurrentUser =
|
||||
member.user_id === currentUserId;
|
||||
let rankColor = "text-blue-400";
|
||||
if (member.rank === 1)
|
||||
rankColor =
|
||||
"text-yellow-400 drop-shadow-[0_0_10px_rgba(250,204,21,0.5)]";
|
||||
if (member.rank === 2)
|
||||
rankColor =
|
||||
"text-gray-300 drop-shadow-[0_0_10px_rgba(209,213,219,0.5)]";
|
||||
if (member.rank === 3)
|
||||
rankColor =
|
||||
"text-orange-400 drop-shadow-[0_0_10px_rgba(251,146,60,0.5)]";
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={member.user_id}
|
||||
className={`border-b border-white/5 transition-all ${isCurrentUser
|
||||
? "bg-blue-600/20 hover:bg-blue-600/30"
|
||||
: "hover:bg-white/5"
|
||||
}`}
|
||||
>
|
||||
<td
|
||||
className={`p-4 pl-6 font-mono font-bold text-lg ${rankColor}`}
|
||||
>
|
||||
#{member.rank}
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
{member.avatar ? (
|
||||
<img
|
||||
src={member.avatar}
|
||||
alt=""
|
||||
className="w-10 h-10 rounded-full bg-gray-700 shadow-md"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-10 h-10 rounded-full bg-gray-700 flex items-center justify-center text-white font-bold text-lg">
|
||||
{member.username
|
||||
.charAt(0)
|
||||
.toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
<span
|
||||
className={`font-semibold text-lg ${isCurrentUser
|
||||
? "text-blue-200"
|
||||
: "text-white"
|
||||
}`}
|
||||
>
|
||||
{member.username}
|
||||
</span>
|
||||
{isCurrentUser && (
|
||||
<span className="text-xs text-blue-400 font-medium">
|
||||
You
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-4 text-right font-bold text-yellow-500 text-lg">
|
||||
{member.level}
|
||||
</td>
|
||||
<td className="p-4 pr-6 text-right text-gray-500 font-mono">
|
||||
{member.xp.toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={4}
|
||||
className="p-12 text-center text-gray-500 text-lg"
|
||||
>
|
||||
Setup leveling system to see the leaderboard
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
1274
web/components/LevelingEditor.tsx
Normal file
1274
web/components/LevelingEditor.tsx
Normal file
File diff suppressed because it is too large
Load Diff
39
web/components/LoginButton.tsx
Normal file
39
web/components/LoginButton.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
|
||||
import { signIn, signOut } from "@/auth"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import Link from "next/link"
|
||||
|
||||
export function LoginButton({ session }: { session: any }) {
|
||||
if (session) {
|
||||
return (
|
||||
<div className="flex gap-4 items-center">
|
||||
<Link href="/dashboard" className="hover:cursor-pointer px-6 py-2 bg-blue-600 hover:bg-blue-700 rounded-full font-semibold transition-all hover:scale-105">
|
||||
Dashboard
|
||||
</Link>
|
||||
<form
|
||||
action={async () => {
|
||||
"use server"
|
||||
await signOut()
|
||||
}}
|
||||
>
|
||||
<Button type="submit" size={'lg'} className="hover:cursor-pointer text-white px-6 py-2 bg-red-500/20 hover:bg-red-500/40 border border-red-500 rounded-full font-semibold transition-all hover:scale-105">
|
||||
Sign Out
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
action={async () => {
|
||||
"use server"
|
||||
await signIn("discord", { redirectTo: "/dashboard" })
|
||||
}}
|
||||
>
|
||||
<Button type="submit" className="hover:cursor-pointer px-6 py-6 text-white bg-indigo-600 hover:bg-indigo-700 rounded-full font-semibold transition-all hover:scale-105 shadow-lg shadow-indigo-500/30">
|
||||
Login with Discord
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
89
web/components/ServerSwitcher.tsx
Normal file
89
web/components/ServerSwitcher.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { ChevronDown, Check } from "lucide-react";
|
||||
import { Guild } from "@/lib/discord";
|
||||
|
||||
interface ServerSwitcherProps {
|
||||
currentGuild: Guild;
|
||||
guilds: Guild[];
|
||||
}
|
||||
|
||||
export default function ServerSwitcher({ currentGuild, guilds }: ServerSwitcherProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-white/5 transition-colors border border-transparent hover:border-white/10 group"
|
||||
>
|
||||
{currentGuild.icon ? (
|
||||
<img
|
||||
src={`https://cdn.discordapp.com/icons/${currentGuild.id}/${currentGuild.icon}.png`}
|
||||
alt={currentGuild.name}
|
||||
className="w-10 h-10 rounded-full bg-gray-800"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-10 h-10 rounded-full bg-gray-700 flex items-center justify-center text-sm font-semibold text-gray-300">
|
||||
{currentGuild.name.substring(0, 2)}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-left hidden md:block">
|
||||
<div className="font-semibold text-white flex items-center gap-2">
|
||||
{currentGuild.name}
|
||||
<ChevronDown className={`w-4 h-4 text-gray-400 transition-transform ${isOpen ? "rotate-180" : ""}`} />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute top-full left-0 mt-2 w-72 max-h-96 overflow-y-auto no-scrollbar bg-[#1e1e24] border border-white/10 rounded-xl shadow-2xl z-50 animate-in fade-in slide-in-from-top-2">
|
||||
<div className="p-2 sticky top-0 bg-[#1e1e24] border-b border-white/5">
|
||||
<p className="text-xs font-semibold text-gray-400 uppercase px-2 py-1">Switch Server</p>
|
||||
</div>
|
||||
<div className="p-2 space-y-1">
|
||||
{guilds.map((guild) => (
|
||||
<Link
|
||||
key={guild.id}
|
||||
href={`/dashboard/${guild.id}`}
|
||||
onClick={() => setIsOpen(false)}
|
||||
className={`flex items-center gap-3 w-full p-2 rounded-lg hover:bg-white/5 transition-all ${guild.id === currentGuild.id ? "bg-blue-500/10" : ""}`}
|
||||
>
|
||||
{guild.icon ? (
|
||||
<img
|
||||
src={`https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png`}
|
||||
alt={guild.name}
|
||||
className="w-8 h-8 rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full bg-gray-700 flex items-center justify-center text-xs font-bold text-gray-400">
|
||||
{guild.name.substring(0, 2)}
|
||||
</div>
|
||||
)}
|
||||
<span className={`flex-1 truncate ${guild.id === currentGuild.id ? "text-blue-300 font-medium" : "text-gray-300"}`}>
|
||||
{guild.name}
|
||||
</span>
|
||||
{guild.id === currentGuild.id && (
|
||||
<Check className="w-4 h-4 text-blue-400" />
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
121
web/components/Toast.tsx
Normal file
121
web/components/Toast.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { X, AlertCircle, CheckCircle } from "lucide-react";
|
||||
|
||||
export default function Toast() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: "success" | "error"; text: string; title: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const error = searchParams.get("error");
|
||||
const success = searchParams.get("success");
|
||||
|
||||
if (error) {
|
||||
let text = "An unexpected error occurred.";
|
||||
let title = "Error";
|
||||
|
||||
switch (error) {
|
||||
case "access_denied":
|
||||
text = "You cancelled the authorization request.";
|
||||
title = "Access Denied";
|
||||
break;
|
||||
case "invalid_request":
|
||||
text = "The request parameters were invalid.";
|
||||
title = "Invalid Request";
|
||||
break;
|
||||
case "state_mismatch":
|
||||
text = "Security check failed (State Mismatch). Please try again.";
|
||||
title = "Security Error";
|
||||
break;
|
||||
case "guild_mismatch":
|
||||
text = "The server you authorized on Discord does not match the one you selected on the dashboard.";
|
||||
title = "Server Mismatch";
|
||||
break;
|
||||
case "not_beta_server":
|
||||
text = "This server is not part of the Closed Beta program. You cannot add the bot to it yet.";
|
||||
title = "Beta Access Restricted";
|
||||
break;
|
||||
case "token_exchange_failed":
|
||||
text = "Failed to communicate with Discord. Please try again.";
|
||||
title = "Connection Failed";
|
||||
break;
|
||||
case "internal_server_error":
|
||||
text = "Something went wrong on our end.";
|
||||
title = "Server Error";
|
||||
break;
|
||||
case "config_error":
|
||||
text = "System configuration error. Please contact support.";
|
||||
title = "Configuration Error";
|
||||
break;
|
||||
default:
|
||||
text = error;
|
||||
}
|
||||
|
||||
setMessage({ type: "error", text, title });
|
||||
setIsVisible(true);
|
||||
} else if (success) {
|
||||
let text = "Operation completed successfully.";
|
||||
let title = "Success";
|
||||
|
||||
if (success === "bot_added") {
|
||||
text = "Void Sentinel has been successfully added to your server!";
|
||||
title = "Bot Added";
|
||||
}
|
||||
|
||||
setMessage({ type: "success", text, title });
|
||||
setIsVisible(true);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
const closeToast = () => {
|
||||
setIsVisible(false);
|
||||
// Optional: clear params from URL without refresh
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.delete("error");
|
||||
params.delete("success");
|
||||
params.delete("guild_id"); // Clean up other params if we want
|
||||
router.replace(`?${params.toString()}`);
|
||||
};
|
||||
|
||||
if (!isVisible || !message) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-6 right-6 z-50 animate-in slide-in-from-bottom-5 fade-in duration-300">
|
||||
<div className={`
|
||||
flex items-start gap-3 p-4 rounded-xl border shadow-2xl backdrop-blur-md max-w-md
|
||||
${message.type === 'error'
|
||||
? "bg-red-500/10 border-red-500/20 text-red-200"
|
||||
: "bg-green-500/10 border-green-500/20 text-green-200"
|
||||
}
|
||||
`}>
|
||||
<div className="mt-0.5">
|
||||
{message.type === 'error' ? (
|
||||
<AlertCircle className="w-5 h-5 text-red-400" />
|
||||
) : (
|
||||
<CheckCircle className="w-5 h-5 text-green-400" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 mr-4">
|
||||
<h4 className={`font-semibold text-sm mb-1 ${message.type === 'error' ? 'text-red-100' : 'text-green-100'}`}>
|
||||
{message.title}
|
||||
</h4>
|
||||
<p className="text-sm opacity-90 leading-relaxed">
|
||||
{message.text}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={closeToast}
|
||||
className="p-1 hover:bg-white/10 rounded-lg transition-colors -mr-1 -mt-1"
|
||||
>
|
||||
<X className="w-4 h-4 opacity-70" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
web/components/theme-provider.tsx
Normal file
11
web/components/theme-provider.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes"
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NextThemesProvider>) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
|
||||
}
|
||||
62
web/components/ui/button.tsx
Normal file
62
web/components/ui/button.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
Reference in New Issue
Block a user