added frontend + securing beta server invites

This commit is contained in:
2026-01-02 22:50:02 +05:30
parent cb12b8ef75
commit 9b17a99456
52 changed files with 5409 additions and 5 deletions

View 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;

View 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>
</>
);
}

View 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
View 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
View 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"
>
&ldquo;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.&rdquo;
</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;

View 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>
</>
);
}

File diff suppressed because it is too large Load Diff

View 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>
)
}

View 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
View 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>
);
}

View 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>
}

View 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 }