first commit

This commit is contained in:
RayExo
2026-07-12 15:16:07 +05:30
commit 2289301f26
915 changed files with 69400 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
"use client";
import { SessionProvider } from "next-auth/react";
export function AuthProvider({ children }: { children: React.ReactNode }) {
return <SessionProvider>{children}</SessionProvider>;
}

View File

@@ -0,0 +1,230 @@
"use client";
import React, { useState, useEffect } from "react";
import {
Shield, Users, Server, Activity, Database, Cpu, Globe, Lock, Settings, RefreshCw
} from "lucide-react";
import { cn } from "@/lib/utils";
import { api } from "@/lib/api";
import { AdminStats, AdminConfig } from "@/types/api";
import { toast } from "sonner";
export function AdminContent() {
const [stats, setStats] = useState<AdminStats | null>(null);
const [config, setConfig] = useState<AdminConfig | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [saving, setSaving] = useState(false);
const [notification, setNotification] = useState("");
const fetchData = async (isRefresh = false) => {
if (isRefresh) setRefreshing(true);
try {
const [statsData, configData] = await Promise.all([
api.getAdminStats(),
api.getAdminConfig()
]);
setStats(statsData);
setConfig(configData);
setNotification(configData.global_notification || "");
} catch (err) {
console.error("Failed to fetch admin data:", err);
toast.error("Failed to load real-time data");
} finally {
setLoading(false);
setRefreshing(false);
}
};
useEffect(() => {
fetchData();
const interval = setInterval(() => fetchData(true), 30000); // Auto refresh every 30s
return () => clearInterval(interval);
}, []);
const handleToggleMaintenance = async () => {
if (!config) return;
setSaving(true);
try {
const newStatus = !config.maintenance_mode;
await api.updateAdminConfig({ maintenance_mode: newStatus });
setConfig({ ...config, maintenance_mode: newStatus });
toast.success(`Maintenance mode ${newStatus ? 'enabled' : 'disabled'}`);
} catch (err) {
toast.error("Failed to update maintenance mode");
} finally {
setSaving(false);
}
};
const handleBroadcast = async () => {
setSaving(true);
try {
await api.updateAdminConfig({ global_notification: notification });
if (config) setConfig({ ...config, global_notification: notification });
toast.success("Broadcast message updated");
} catch (err) {
toast.error("Failed to update broadcast message");
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<RefreshCw className="h-10 w-10 text-red-500 animate-spin opacity-20" />
</div>
);
}
const statItems = [
{ name: "Total Users", value: stats?.total_users || "0", icon: Users, color: "text-blue-500" },
{ name: "Active Servers", value: stats?.active_servers || "0", icon: Server, color: "text-emerald-500" },
{ name: "API Latency", value: stats?.api_latency || "0ms", icon: Activity, color: "text-amber-500" },
{ name: "Database Size", value: stats?.db_size || "0 MB", icon: Database, color: "text-purple-500" },
];
return (
<div className="space-y-10 animate-in fade-in duration-500">
{/* Header */}
<div className="relative group">
<div className="absolute -inset-1 bg-gradient-to-r from-red-500 to-indigo-500 rounded-3xl blur opacity-10 group-hover:opacity-20 transition duration-1000"></div>
<div className="relative bg-[#0f172a] border border-white/10 rounded-3xl p-8 lg:p-12 flex flex-col lg:flex-row lg:items-center justify-between gap-8">
<div className="flex items-center gap-6">
<div className="h-16 w-16 rounded-2xl bg-red-500/20 flex items-center justify-center border border-red-500/30 shadow-2xl shadow-red-500/20">
<Shield className="h-8 w-8 text-red-500" />
</div>
<div>
<h1 className="text-4xl font-black text-white tracking-tight font-outfit">Admin Control Panel</h1>
<p className="text-slate-400 mt-2 font-medium">Restricted access for ZyroX administrators only.</p>
</div>
</div>
<button
onClick={() => fetchData(true)}
className="flex items-center gap-3 bg-red-500/5 px-6 py-3 rounded-2xl border border-red-500/10 hover:bg-red-500/10 transition-all active:scale-95 group/refresh"
>
<RefreshCw className={cn("h-4 w-4 text-red-500 transition-all", refreshing && "animate-spin")} />
<span className="text-xs font-black uppercase tracking-widest text-red-500">
{refreshing ? "Refreshing..." : "Real-time Mode"}
</span>
</button>
</div>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{statItems.map((stat) => (
<div key={stat.name} className="glass border border-white/5 rounded-3xl p-6 hover:border-white/10 transition-all group">
<div className="flex items-center justify-between mb-4">
<div className={cn("p-3 rounded-xl bg-white/[0.03] group-hover:scale-110 transition-transform", stat.color)}>
<stat.icon className="h-6 w-6" />
</div>
<span className="text-[10px] font-black uppercase tracking-widest text-emerald-500 bg-emerald-500/10 px-2 py-1 rounded-lg">
Live
</span>
</div>
<p className="text-slate-500 text-xs font-bold uppercase tracking-widest">{stat.name}</p>
<h3 className="text-2xl font-black text-white mt-1 font-outfit">{stat.value}</h3>
</div>
))}
</div>
{/* System Status Section */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* API Health */}
<div className="lg:col-span-2 glass border border-white/5 rounded-[2.5rem] overflow-hidden">
<div className="p-8 border-b border-white/5 flex items-center justify-between bg-white/[0.01]">
<div className="flex items-center gap-4">
<Activity className="h-5 w-5 text-red-500" />
<h3 className="text-lg font-bold text-white">System Nodes Status</h3>
</div>
<span className="text-[10px] font-black uppercase tracking-widest text-slate-500">Auto-Polling Active</span>
</div>
<div className="p-8 space-y-6">
{stats?.nodes.map((node) => {
const Icon = node.icon === "Globe" ? Globe : node.icon === "Database" ? Database : node.icon === "Cpu" ? Cpu : Lock;
const isHealthy = node.status === "Healthy";
return (
<div key={node.name} className="flex items-center justify-between p-4 bg-white/[0.02] rounded-2xl border border-white/5 group hover:bg-white/[0.04] transition-all">
<div className="flex items-center gap-4">
<div className="h-10 w-10 rounded-xl bg-slate-800 flex items-center justify-center group-hover:bg-slate-700 transition-colors">
<Icon className="h-5 w-5 text-slate-400" />
</div>
<div>
<h4 className="text-sm font-bold text-white">{node.name}</h4>
<p className="text-[10px] font-black uppercase text-slate-500 tracking-widest">Load: {node.load}</p>
</div>
</div>
<div className={cn(
"flex items-center gap-2 px-3 py-1.5 rounded-full border",
isHealthy ? "bg-emerald-500/10 border-emerald-500/20" : "bg-amber-500/10 border-amber-500/20"
)}>
<div className={cn("h-1.5 w-1.5 rounded-full", isHealthy ? "bg-emerald-500" : "bg-amber-500")} />
<span className={cn("text-[10px] font-bold uppercase", isHealthy ? "text-emerald-500" : "text-amber-500")}>
{node.status}
</span>
</div>
</div>
)
})}
</div>
</div>
{/* Global Config */}
<div className="glass border border-white/5 rounded-[2.5rem] overflow-hidden flex flex-col">
<div className="p-8 border-b border-white/5 flex items-center gap-4 bg-white/[0.01]">
<Settings className="h-5 w-5 text-indigo-500" />
<h3 className="text-lg font-bold text-white">Global Settings</h3>
</div>
<div className="p-8 flex-1 space-y-6">
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-[0.2em] text-slate-500 pl-1">Maintenance Mode</label>
<button
onClick={handleToggleMaintenance}
disabled={saving}
className={cn(
"w-full flex items-center justify-between p-4 rounded-2xl border transition-all",
config?.maintenance_mode
? "bg-red-500/10 border-red-500/30 text-red-500"
: "bg-white/[0.03] border-white/5 text-slate-300 hover:bg-white/[0.05]"
)}
>
<span className="text-sm font-medium">
{config?.maintenance_mode ? "Restricting Access" : "Standard Operations"}
</span>
<div className={cn(
"h-6 w-11 rounded-full relative transition-colors duration-300",
config?.maintenance_mode ? "bg-red-500" : "bg-slate-700"
)}>
<div className={cn(
"absolute top-1 h-4 w-4 bg-white rounded-full transition-all duration-300 shadow-sm",
config?.maintenance_mode ? "left-6" : "left-1"
)} />
</div>
</button>
</div>
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-[0.2em] text-slate-500 pl-1">Global Notification</label>
<textarea
value={notification}
onChange={(e) => setNotification(e.target.value)}
className="w-full h-32 bg-white/[0.03] border border-white/5 rounded-2xl p-4 text-xs font-medium text-slate-300 focus:outline-none focus:ring-1 focus:ring-red-500/30 transition-all placeholder:text-slate-600"
placeholder="Message to display across all dashboards..."
/>
</div>
<button
onClick={handleBroadcast}
disabled={saving}
className="w-full py-4 bg-primary rounded-2xl font-black uppercase tracking-widest text-xs shadow-xl shadow-primary/20 hover:brightness-110 active:scale-[0.98] transition-all disabled:opacity-50"
>
{saving ? "Processing..." : "Broadcast Message"}
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,220 @@
"use client";
import React, { useState } from "react";
import {
ShieldAlert,
Server,
User,
Settings,
RefreshCcw,
Save,
MessageSquare,
Plus,
Trash2,
ShieldCheck
} from "lucide-react";
import { toast } from "sonner";
import { api } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { AntiNukeConfig } from "@/types/api";
const FEATURES = [
{ id: 'anti_ban_kick', name: 'Anti Ban & Kick', desc: 'Auto bans rogue admins', icon: User },
{ id: 'anti_server_edit', name: 'Anti Server Edit', desc: 'Secures icon, name & regions', icon: Server },
{ id: 'anti_role_modifier', name: 'Anti Role Modifier', desc: 'Protects all roles & perms', icon: Settings },
{ id: 'anti_channel_nukes', name: 'Anti Channel Nukes', desc: 'Prevents channel wipes', icon: MessageSquare },
];
interface AntiNukeFormProps {
initialConfig: AntiNukeConfig;
guildId: string;
}
export function AntiNukeForm({ initialConfig, guildId }: AntiNukeFormProps) {
const [config, setConfig] = useState<AntiNukeConfig>(initialConfig);
const [saving, setSaving] = useState(false);
const [wlInput, setWlInput] = useState("");
const [whitelistedUsers, setWhitelistedUsers] = useState<string[]>(initialConfig.whitelisted_users || []);
const handleSave = async () => {
setSaving(true);
const promise = api.updateAntiNuke(guildId, config);
toast.promise(promise, {
loading: 'Saving Anti-Nuke configuration...',
success: 'Anti-Nuke settings saved successfully!',
error: 'Failed to update Anti-Nuke settings',
});
try {
await promise;
} catch (err: any) {
console.error(err);
} finally {
setSaving(false);
}
};
const handleAddWhitelist = async () => {
if (!wlInput.trim() || isNaN(Number(wlInput))) {
toast.error('Please enter a valid User ID');
return;
}
if (whitelistedUsers.includes(wlInput.trim())) {
toast.error('User is already whitelisted');
return;
}
setSaving(true);
try {
await api.updateAntiNuke(guildId, { status: config.status, add_whitelist: wlInput.trim() });
setWhitelistedUsers([...whitelistedUsers, wlInput.trim()]);
setWlInput('');
toast.success('User whitelisted successfully');
} catch (err: any) {
toast.error('Failed to whitelist user');
console.error(err);
} finally {
setSaving(false);
}
};
const handleRemoveWhitelist = async (userId: string) => {
setSaving(true);
try {
await api.updateAntiNuke(guildId, { status: config.status, remove_whitelist: userId });
setWhitelistedUsers(whitelistedUsers.filter(id => id !== userId));
toast.success('User removed from whitelist');
} catch (err: any) {
toast.error('Failed to remove user from whitelist');
console.error(err);
} finally {
setSaving(false);
}
};
return (
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
<div className="lg:col-span-3 space-y-6">
<div className="bg-[#141B2D] border border-slate-800 rounded-3xl overflow-hidden shadow-xl">
<div className="p-8 space-y-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-black uppercase text-slate-500 tracking-widest">Master Control</h3>
<Switch
checked={config.status}
onCheckedChange={(val) => setConfig({ ...config, status: val })}
/>
</div>
<div className="space-y-4">
{FEATURES.map((feature) => (
<div
key={feature.id}
className={cn(
"p-6 rounded-2xl border transition-all duration-300",
config.status ? "bg-slate-900/40 border-slate-800" : "bg-slate-900/10 border-slate-900 opacity-40 grayscale"
)}
>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-6">
<div className="flex items-center gap-4">
<div className={cn(
"h-12 w-12 rounded-xl flex items-center justify-center transition-colors",
config.status ? "bg-primary/20 text-primary" : "bg-slate-800 text-slate-500"
)}>
<feature.icon className="h-6 w-6" />
</div>
<div>
<h3 className="font-bold text-white">{feature.name}</h3>
<p className="text-xs text-slate-500 max-w-xs">{feature.desc}</p>
</div>
</div>
<div className="flex items-center gap-4">
{config.status && (
<div className="flex items-center gap-2 animate-in fade-in slide-in-from-right-2 duration-300">
<ShieldAlert className="h-4 w-4 text-emerald-500" />
<span className="text-xs font-bold text-emerald-500 uppercase">Protected</span>
</div>
)}
</div>
</div>
</div>
))}
</div>
<div className="pt-6 border-t border-slate-800">
<h4 className="text-sm font-bold text-white mb-4 flex items-center gap-2">
<ShieldCheck className="h-5 w-5 text-emerald-500" />
Whitelisted Users
</h4>
<div className="flex gap-2 mb-4">
<Input
placeholder="User ID..."
value={wlInput}
onChange={(e) => setWlInput(e.target.value)}
className="bg-slate-900/50"
/>
<Button onClick={handleAddWhitelist} disabled={saving} variant="secondary">
<Plus className="h-5 w-5" />
</Button>
</div>
<div className="space-y-2 max-h-48 overflow-y-auto pr-2 custom-scrollbar">
{whitelistedUsers.length === 0 ? (
<p className="text-xs text-slate-500 italic text-center py-4">No users whitelisted.</p>
) : (
whitelistedUsers.map(userId => (
<div key={userId} className="flex items-center justify-between p-3 rounded-xl bg-slate-900/30 border border-slate-800/50">
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-full bg-slate-800 flex items-center justify-center">
<User className="h-4 w-4 text-slate-400" />
</div>
<span className="text-sm font-mono text-slate-300">{userId}</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveWhitelist(userId)}
disabled={saving}
className="text-red-400 hover:text-red-300 hover:bg-red-400/10 h-8 w-8 p-0"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))
)}
</div>
</div>
<Button
onClick={handleSave}
disabled={saving}
className="w-full h-14 text-base font-bold gap-2"
>
{saving ? <RefreshCcw className="h-5 w-5 animate-spin" /> : <Save className="h-5 w-5" />}
Save Configuration
</Button>
</div>
</div>
</div>
<div className="space-y-6">
<div className="bg-gradient-to-br from-red-500/10 to-transparent border border-red-500/20 rounded-3xl p-6 relative overflow-hidden group">
<div className="absolute -right-4 -top-4 opacity-[0.03] group-hover:scale-110 transition-transform">
<ShieldAlert className="h-32 w-32 text-red-500" />
</div>
<h3 className="text-sm font-bold text-red-400 mb-2">Maximum Protection</h3>
<p className="text-xs text-slate-400 leading-relaxed mb-4">Anti-Nuke is fixed to instantly Ban malicious actors. Ensure that Zyrox&apos;s role is at the TOP of the role hierarchy for it to be able to ban admins.</p>
<div className="flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-red-500 animate-pulse" />
<span className="text-[10px] font-black uppercase text-red-500">Fixed Punishments</span>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,193 @@
"use client";
import React, { useState } from "react";
import {
Zap,
Type,
Link as LinkIcon,
MessageSquare,
UserMinus,
ShieldAlert,
Gavel,
RefreshCcw,
Save
} from "lucide-react";
import { toast } from "sonner";
import { api } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Select } from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { AutomodConfig } from "@/types/api";
const PUNISHMENT_OPTIONS = [
{ value: "delete", label: "Delete Message" },
{ value: "warn", label: "Warn User" },
{ value: "mute", label: "Mute User" },
{ value: "kick", label: "Kick User" },
{ value: "ban", label: "Ban User" },
];
const RULES = [
{ id: 'anti_spam', name: 'Anti Spam', desc: 'Detects and removes repetitive messages or rapid firing.', icon: Zap },
{ id: 'anti_caps', name: 'Anti Caps', desc: 'Prevents excessive use of uppercase letters.', icon: Type },
{ id: 'anti_links', name: 'Anti Links', desc: 'Blocks unauthorized external links in channels.', icon: LinkIcon },
{ id: 'anti_invites', name: 'Anti Invites', desc: 'Automatically removes Discord server invite links.', icon: MessageSquare },
{ id: 'anti_mentions', name: 'Anti Mass Mention', desc: 'Protects against mentioned spam (@everyone, @here).', icon: UserMinus },
];
interface AutomodFormProps {
initialConfig: AutomodConfig;
guildId: string;
}
export function AutomodForm({ initialConfig, guildId }: AutomodFormProps) {
const [config, setConfig] = useState<AutomodConfig>(initialConfig);
const [saving, setSaving] = useState(false);
const handleToggle = (key: string) => {
setConfig({
...config,
enabled: key === 'master' ? !config.enabled : config.enabled,
});
};
const handlePunishmentChange = (ruleId: string, value: string) => {
const newPunishments = { ...config.punishments };
newPunishments[ruleId] = value;
setConfig({ ...config, punishments: newPunishments });
};
const handleSave = async () => {
setSaving(true);
const promise = api.updateAutomod(guildId, {
enabled: config.enabled,
punishments: config.punishments,
});
toast.promise(promise, {
loading: 'Saving configuration...',
success: 'Configuration saved successfully!',
error: (err) => err.message || 'Failed to update settings',
});
try {
await promise;
} catch (err: any) {
console.error(err);
} finally {
setSaving(false);
}
};
return (
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
<div className="lg:col-span-3 space-y-6">
<div className="bg-[#141B2D] border border-slate-800 rounded-3xl overflow-hidden shadow-xl">
<div className="p-8 space-y-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-black uppercase text-slate-500 tracking-widest">Master Control</h3>
<Switch
checked={config.enabled}
onCheckedChange={() => handleToggle('master')}
/>
</div>
<div className="space-y-4">
{RULES.map((rule) => {
const isEnabled = config.enabled && config.punishments?.[rule.id] !== undefined;
return (
<div
key={rule.id}
className={cn(
"p-6 rounded-2xl border transition-all duration-300",
config.enabled ? "bg-slate-900/40 border-slate-800" : "bg-slate-900/10 border-slate-900 opacity-40 grayscale"
)}
>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-6">
<div className="flex items-center gap-4">
<div className={cn(
"h-12 w-12 rounded-xl flex items-center justify-center transition-colors",
isEnabled ? "bg-primary/20 text-primary" : "bg-slate-800 text-slate-500"
)}>
<rule.icon className="h-6 w-6" />
</div>
<div>
<h3 className="font-bold text-white">{rule.name}</h3>
<p className="text-xs text-slate-500 max-w-xs">{rule.desc}</p>
</div>
</div>
<div className="flex items-center gap-4">
{isEnabled && (
<div className="flex items-center gap-2 animate-in fade-in slide-in-from-right-2 duration-300">
<Gavel className="h-4 w-4 text-primary" />
<Select
value={config.punishments[rule.id] || "delete"}
onValueChange={(val) => handlePunishmentChange(rule.id, val)}
options={PUNISHMENT_OPTIONS}
className="w-40"
/>
</div>
)}
<div className="h-8 w-[1px] bg-slate-800 hidden sm:block" />
<Switch
disabled={!config.enabled}
checked={config.punishments?.[rule.id] !== undefined}
onCheckedChange={() => {
const newPunishments = { ...config.punishments };
if (newPunishments[rule.id]) {
delete newPunishments[rule.id];
} else {
newPunishments[rule.id] = "delete";
}
setConfig({...config, punishments: newPunishments});
}}
/>
</div>
</div>
</div>
);
})}
</div>
<Button
onClick={handleSave}
disabled={saving}
className="w-full h-14 text-base font-bold gap-2"
>
{saving ? <RefreshCcw className="h-5 w-5 animate-spin" /> : <Save className="h-5 w-5" />}
Save Moderation Rules
</Button>
</div>
</div>
</div>
<div className="space-y-6">
<div className="bg-[#141B2D] border border-slate-800 rounded-3xl p-6">
<h3 className="text-sm font-black uppercase text-slate-500 tracking-widest mb-4">Logging Level</h3>
<div className="space-y-3">
<div className="p-3 bg-slate-900/50 rounded-xl border border-white/5 flex items-center justify-between">
<span className="text-sm text-slate-400">Log Channel</span>
<span className="text-xs font-mono text-primary">#{config.logging_channel || 'None'}</span>
</div>
<p className="text-[10px] text-slate-500 italic text-center">Mod logs are automatically sent to the configured channel.</p>
</div>
</div>
<div className="bg-gradient-to-br from-primary/10 to-transparent border border-primary/20 rounded-3xl p-6 relative overflow-hidden group">
<div className="absolute -right-4 -top-4 opacity-[0.03] group-hover:scale-110 transition-transform">
<ShieldAlert className="h-32 w-32 text-white" />
</div>
<h3 className="text-sm font-bold text-white mb-2">Automod AI</h3>
<p className="text-xs text-slate-400 leading-relaxed mb-4">Our neural network analyzes message context to prevent false positives.</p>
<div className="flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-primary" />
<span className="text-[10px] font-black uppercase text-primary">V2 Active</span>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,204 @@
"use client";
import React, { useState } from "react";
import { UserPlus, Save, RefreshCcw, User, Bot, Trash2, ShieldCheck, Info } from "lucide-react";
import { toast } from "sonner";
import { api } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { AutoRoleConfig, DiscordRole } from "@/types/api";
import { cn } from "@/lib/utils";
interface AutoRoleFormProps {
initialConfig: AutoRoleConfig;
roles: DiscordRole[];
guildId: string;
}
export function AutoRoleForm({ initialConfig, roles, guildId }: AutoRoleFormProps) {
const [config, setConfig] = useState<AutoRoleConfig>(initialConfig);
const [saving, setSaving] = useState(false);
const handleSave = async () => {
setSaving(true);
// Be explicit about fields to send to match AutoRoleUpdate schema
const data = {
bots: config.bots,
humans: config.humans
};
const promise = api.updateAutoRole(guildId, data);
toast.promise(promise, {
loading: 'Saving AutoRole configuration...',
success: 'Settings saved successfully!',
error: 'Failed to update AutoRole config',
});
try {
await promise;
} catch (err: any) {
console.error(err);
} finally {
setSaving(false);
}
};
const addRole = (type: "humans" | "bots", roleId: string) => {
if (config[type].includes(roleId)) return;
if (config[type].length >= 10) {
toast.error(`You can only add up to 10 roles for ${type === "humans" ? "Members" : "Bots"}.`);
return;
}
setConfig({ ...config, [type]: [...config[type], roleId] });
};
const removeRole = (type: "humans" | "bots", roleId: string) => {
setConfig({ ...config, [type]: config[type].filter(r => r !== roleId) });
};
const formatColor = (decimal: number) => {
if (!decimal || decimal === 0) return "#94a3b8";
return `#${decimal.toString(16).padStart(6, '0')}`;
};
const renderRoleList = (type: "humans" | "bots") => {
const title = type === "humans" ? "Member Roles" : "Bot Roles";
const Icon = type === "humans" ? User : Bot;
const accentColor = type === "humans" ? "text-primary" : "text-blue-400";
const bgColor = type === "humans" ? "bg-primary/10" : "bg-blue-400/10";
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<div className={cn("p-2.5 rounded-xl", bgColor, accentColor)}>
<Icon className="h-5 w-5" />
</div>
<div>
<h4 className="font-bold text-white text-base">{title}</h4>
<p className="text-xs text-slate-400">Roles given to newly joined {type}.</p>
</div>
</div>
<Select value="" onValueChange={(val) => addRole(type, val)}>
<SelectTrigger className="w-full h-12 bg-slate-900/50 border-slate-800 hover:border-slate-700 transition-all">
<SelectValue placeholder={`Add a ${type === "humans" ? "member" : "bot"} role...`} />
</SelectTrigger>
<SelectContent className="bg-slate-900 border-slate-800 max-h-[300px]">
{roles
.filter(r => !config[type].includes(r.id))
.sort((a, b) => (b.position || 0) - (a.position || 0))
.map((r) => (
<SelectItem key={r.id} value={r.id} className="focus:bg-slate-800 group">
<div className="flex items-center gap-2">
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: formatColor(r.color) }}
/>
<span>{r.name}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<div className="grid grid-cols-1 gap-2 min-h-[100px] p-4 bg-slate-900/40 rounded-2xl border border-slate-800/50 relative overflow-hidden">
{config[type].length === 0 ? (
<div className="absolute inset-0 flex flex-col items-center justify-center opacity-20">
<ShieldCheck className="h-8 w-8 mb-2" />
<span className="text-xs font-medium">No roles selected</span>
</div>
) : (
<div className="flex flex-wrap gap-2 relative z-10">
{config[type].map((roleId) => {
const role = roles.find(r => r.id === roleId);
const color = role ? formatColor(role.color) : "#94a3b8";
return (
<div
key={roleId}
className="flex items-center gap-2 bg-slate-800/80 border border-slate-700/50 px-3 py-1.5 rounded-lg text-sm group animate-in zoom-in-95 duration-200"
>
<div
className="w-2 h-2 rounded-full shadow-[0_0_8px_rgba(0,0,0,0.5)]"
style={{ backgroundColor: color }}
/>
<span className="text-slate-200 font-medium">{role ? role.name : `Unknown (${roleId})`}</span>
<button
onClick={() => removeRole(type, roleId)}
className="ml-1 text-slate-500 hover:text-red-400 transition-colors p-0.5 rounded-md hover:bg-red-400/10"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
);
})}
</div>
)}
</div>
</div>
);
};
return (
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
<div className="lg:col-span-3 space-y-6">
<div className="bg-[#141B2D] border border-slate-800 rounded-[32px] shadow-2xl p-8 space-y-10 relative">
<div className="grid grid-cols-1 md:grid-cols-2 gap-10">
{renderRoleList("humans")}
{renderRoleList("bots")}
</div>
<div className="pt-6 border-t border-slate-800">
<Button
onClick={handleSave}
disabled={saving}
className="w-full h-14 text-base font-bold gap-3 shadow-lg shadow-primary/20 hover:shadow-primary/30 active:scale-[0.98] transition-all"
>
{saving ? <RefreshCcw className="h-5 w-5 animate-spin" /> : <Save className="h-5 w-5" />}
Save AutoRole Settings
</Button>
</div>
</div>
</div>
<div className="space-y-6">
<div className="bg-gradient-to-br from-[#141B2D] to-slate-900 border border-slate-800 rounded-3xl p-6 relative overflow-hidden group">
<div className="absolute -right-6 -top-6 opacity-[0.05] group-hover:scale-110 transition-transform duration-500">
<UserPlus className="h-40 w-40 text-primary" />
</div>
<div className="flex items-center gap-2 mb-4">
<Info className="h-4 w-4 text-primary" />
<h3 className="text-sm font-bold text-white">Guidelines</h3>
</div>
<p className="text-xs text-slate-400 leading-relaxed mb-6">
AutoRole ensures every new member is welcomed with the right sets of roles immediately upon joining.
</p>
<div className="space-y-4">
<div className="flex gap-3">
<div className="h-1.5 w-1.5 rounded-full bg-primary mt-1.5 shrink-0" />
<p className="text-[11px] text-slate-400 leading-relaxed">
<span className="text-slate-200 font-bold">Hierarchy Matter:</span> Ensure ZyroX&apos;s top role is <span className="text-primary italic">higher</span> than any role you select here.
</p>
</div>
<div className="flex gap-3">
<div className="h-1.5 w-1.5 rounded-full bg-primary mt-1.5 shrink-0" />
<p className="text-[11px] text-slate-400 leading-relaxed">
<span className="text-slate-200 font-bold">Bot Detection:</span> We automatically separate bots from human members for precise role assignment.
</p>
</div>
<div className="flex gap-3">
<div className="h-1.5 w-1.5 rounded-full bg-primary mt-1.5 shrink-0" />
<p className="text-[11px] text-slate-400 leading-relaxed">
<span className="text-slate-200 font-bold">Limits:</span> We has limits on roles. We support up to 10 roles per category for stability.
</p>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,149 @@
"use client";
import React, { useState } from "react";
import { Shield, Save, RefreshCcw, Star, Crown, Heart, Ghost, Settings } from "lucide-react";
import { toast } from "sonner";
import { api } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { cn } from "@/lib/utils";
interface CustomRolesFormProps {
initialConfig: any;
roles: any[];
guildId: string;
}
const ROLE_INPUTS = [
{ label: "Staff Role", key: "staff", icon: Shield, color: "text-blue-500", bg: "bg-blue-500/20" },
{ label: "Girl Role", key: "girl", icon: Heart, color: "text-pink-500", bg: "bg-pink-500/20" },
{ label: "VIP Role", key: "vip", icon: Crown, color: "text-yellow-500", bg: "bg-yellow-500/20" },
{ label: "Guest Role", key: "guest", icon: Ghost, color: "text-gray-400", bg: "bg-gray-400/20" },
{ label: "Friend Role", key: "frnd", icon: Star, color: "text-green-500", bg: "bg-green-500/20" },
];
export function CustomRolesForm({ initialConfig, roles, guildId }: CustomRolesFormProps) {
const [config, setConfig] = useState<any>(initialConfig);
const [saving, setSaving] = useState(false);
const filteredRoles = roles.filter(r => r.name !== "@everyone");
const handleSave = async () => {
setSaving(true);
const promise = api.updateCustomRoles(guildId, config);
toast.promise(promise, {
loading: 'Saving Custom Roles configuration...',
success: 'Custom Roles settings saved successfully!',
error: 'Failed to update Custom Roles config',
});
try {
await promise;
} catch (err: any) {
console.error(err);
} finally {
setSaving(false);
}
};
return (
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
<div className="lg:col-span-3 space-y-6">
<div className="bg-[#141B2D] border border-slate-800 rounded-3xl shadow-xl p-8 space-y-8">
<div className="flex items-center justify-between p-6 bg-slate-900/40 rounded-2xl border border-slate-800">
<div className="flex items-center gap-4">
<div className="p-3 bg-primary/20 text-primary rounded-xl">
<Settings className="w-5 h-5" />
</div>
<div>
<h3 className="text-lg font-black text-white">Required Permission Role</h3>
<p className="text-sm text-slate-400 mt-1">Users need this role to assign the custom roles below.</p>
</div>
</div>
<div className="w-64">
<Select
value={config.reqrole?.toString() || "none"}
onValueChange={(val) => setConfig({ ...config, reqrole: val === "none" ? null : parseInt(val) })}
>
<SelectTrigger className="w-full h-12 bg-slate-900 border-slate-800 font-medium">
<SelectValue placeholder="Select required role..." />
</SelectTrigger>
<SelectContent className="bg-slate-900 border-slate-800 max-h-[300px]">
<SelectItem value="none" className="text-slate-400 focus:bg-slate-800">None required / Admins only</SelectItem>
{filteredRoles.map((role) => (
<SelectItem key={role.id} value={role.id.toString()} className="focus:bg-slate-800">
{role.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{ROLE_INPUTS.map((input) => (
<div key={input.key} className="p-6 bg-slate-900/40 border border-slate-800 rounded-2xl space-y-4 hover:border-slate-700 transition-all duration-300">
<div className="flex items-center gap-3">
<div className={cn("p-3 rounded-xl", input.bg, input.color)}>
<input.icon className="w-5 h-5" />
</div>
<div>
<h4 className="font-bold text-white">{input.label}</h4>
<p className="text-xs text-slate-400 mt-1">Role assigned via .{input.key} command</p>
</div>
</div>
<Select
value={config[input.key]?.toString() || "none"}
onValueChange={(val) => setConfig({ ...config, [input.key]: val === "none" ? null : parseInt(val) })}
>
<SelectTrigger className="w-full h-12 bg-slate-900 border-slate-800 font-medium">
<SelectValue placeholder="Select a role..." />
</SelectTrigger>
<SelectContent className="bg-slate-900 border-slate-800 max-h-[300px]">
<SelectItem value="none" className="text-slate-400 focus:bg-slate-800">Not Set</SelectItem>
{filteredRoles.map((role) => (
<SelectItem key={role.id} value={role.id.toString()} className="focus:bg-slate-800">
{role.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
))}
</div>
<Button
onClick={handleSave}
disabled={saving}
className="w-full h-14 text-base font-bold gap-2"
>
{saving ? <RefreshCcw className="h-5 w-5 animate-spin" /> : <Save className="h-5 w-5" />}
Save Configuration
</Button>
</div>
</div>
<div className="space-y-6">
<div className="bg-gradient-to-br from-blue-500/10 to-transparent border border-blue-500/20 rounded-3xl p-6 relative overflow-hidden group">
<div className="absolute -right-4 -top-4 opacity-[0.03] group-hover:scale-110 transition-transform">
<Crown className="h-32 w-32 text-blue-500" />
</div>
<h3 className="text-sm font-bold text-blue-400 mb-2">Command Usage</h3>
<p className="text-xs text-slate-400 leading-relaxed mb-4">
Allows your trusted server managers to grant specific preset roles with simple prefix commands.
</p>
<ul className="text-xs text-slate-500 space-y-2">
<li> <code>.staff @user</code> - Assigns/Removes Staff role</li>
<li> <code>.girl @user</code> - Assigns/Removes Girl role</li>
<li> <code>.vip @user</code> - Assigns/Removes VIP role</li>
<li> Ensure Zyrox is placed higher than these roles in server settings!</li>
</ul>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,91 @@
import React from "react";
import { Switch } from "@/components/ui/switch";
import { Select, SelectOption } from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
// --- ToggleSwitch ---
interface ToggleSwitchProps {
label?: string;
description?: string;
checked: boolean;
onCheckedChange: (checked: boolean) => void;
disabled?: boolean;
className?: string;
}
export const ToggleSwitch = ({
label,
description,
checked,
onCheckedChange,
disabled,
className
}: ToggleSwitchProps) => (
<div className={cn("flex items-center justify-between gap-4 p-4 rounded-2xl bg-slate-900/30 border border-slate-800", className)}>
{(label || description) && (
<div className="flex flex-col">
{label && <span className="text-sm font-bold text-slate-200">{label}</span>}
{description && <span className="text-[11px] text-slate-500 font-medium italic mt-0.5">{description}</span>}
</div>
)}
<Switch checked={checked} onCheckedChange={onCheckedChange} disabled={disabled} />
</div>
);
// --- DropdownSelect ---
interface DropdownSelectProps {
label?: string;
value: string;
onValueChange: (value: string) => void;
options: SelectOption[];
placeholder?: string;
disabled?: boolean;
className?: string;
}
export const DropdownSelect = ({
label,
value,
onValueChange,
options,
placeholder,
disabled,
className
}: DropdownSelectProps) => (
<div className={cn("space-y-2", className)}>
{label && <label className="text-xs font-black uppercase text-slate-500 tracking-widest pl-1">{label}</label>}
<Select
value={value}
onValueChange={onValueChange}
options={options}
placeholder={placeholder}
disabled={disabled}
/>
</div>
);
// --- FormInput ---
interface FormInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string;
icon?: React.ElementType;
}
export const FormInput = ({ label, icon: Icon, className, ...props }: FormInputProps) => (
<div className="space-y-2 w-full">
{label && <label className="text-xs font-black uppercase text-slate-500 tracking-widest pl-1">{label}</label>}
<div className="relative group">
{Icon && (
<Icon className="absolute left-4 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500 group-focus-within:text-primary transition-colors" />
)}
<Input
className={cn(
"bg-slate-900/50 border-slate-800 rounded-xl h-12 focus:ring-primary/20",
Icon && "pl-12",
className
)}
{...props}
/>
</div>
</div>
);

View File

@@ -0,0 +1,208 @@
"use client";
import React, { useState } from "react";
import { Mic, Save, RefreshCcw, Settings2, Headset, Power } from "lucide-react";
import { toast } from "sonner";
import { api } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { cn } from "@/lib/utils";
interface J2CFormProps {
initialConfig: any;
channels: any[];
guildId: string;
}
export function J2CForm({ initialConfig, channels, guildId }: J2CFormProps) {
const [config, setConfig] = useState<any>(initialConfig);
const [isEnabled, setIsEnabled] = useState<boolean>(initialConfig.join_channel_id !== null);
const [saving, setSaving] = useState(false);
// Handle various Discord channel types:
// 0: Text, 2: Voice, 4: Category, 5: Announcement, 13: Stage
const voiceChannels = channels.filter(c => ["2", "13", 2, 13].includes(c.type));
const textChannels = channels.filter(c => ["0", "5", 0, 5].includes(c.type));
const categoryChannels = channels.filter(c => ["4", 4].includes(c.type));
const handleSave = async () => {
setSaving(true);
const payload = isEnabled
? {
join_channel_id: config.join_channel_id,
control_channel_id: config.control_channel_id,
category_id: config.category_id
}
: {
join_channel_id: null,
control_channel_id: null,
category_id: null
};
const promise = api.updateJ2C(guildId, payload);
toast.promise(promise, {
loading: 'Saving Join to Create configuration...',
success: 'Join to Create settings saved successfully!',
error: 'Failed to update Join to Create config',
});
try {
await promise;
} catch (err: any) {
console.error(err);
} finally {
setSaving(false);
}
};
return (
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
<div className="lg:col-span-3 space-y-6">
<div className="bg-[#141B2D] border border-slate-800 rounded-3xl shadow-xl p-8 space-y-8">
<div className="flex items-center justify-between p-6 bg-slate-900/40 rounded-2xl border border-slate-800">
<div className="flex items-center gap-4">
<div className={cn("p-3 rounded-xl transition-colors", isEnabled ? "bg-emerald-500/20 text-emerald-500" : "bg-red-500/20 text-red-500")}>
<Power className="w-5 h-5" />
</div>
<div>
<h3 className="text-lg font-black text-white">System Status</h3>
<p className="text-sm text-slate-400 mt-1">Enable or disable the Join to Create module.</p>
</div>
</div>
<div className="flex items-center gap-4">
<div className="px-4 py-2 rounded-full bg-slate-900 border border-slate-800 flex items-center gap-2">
<div className={cn("w-2 h-2 rounded-full", isEnabled ? 'bg-emerald-500 animate-pulse' : 'bg-red-500')} />
<span className="text-xs font-bold uppercase track-wider text-slate-300">
{isEnabled ? 'Active' : 'Inactive'}
</span>
</div>
<Switch
checked={isEnabled}
onCheckedChange={setIsEnabled}
className="scale-125 data-[state=checked]:bg-emerald-500"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className={cn("p-6 bg-slate-900/40 border border-slate-800 rounded-2xl space-y-4 transition-all duration-300", !isEnabled && "opacity-50 pointer-events-none")}>
<div className="flex items-center gap-3">
<div className="p-3 bg-primary/20 text-primary rounded-xl">
<Mic className="w-5 h-5" />
</div>
<div>
<h4 className="font-bold text-white">Join Channel</h4>
<p className="text-xs text-slate-400 mt-1">Voice channel users join to trigger creation</p>
</div>
</div>
<Select
value={config.join_channel_id ? config.join_channel_id : "none"}
onValueChange={(val) => setConfig({ ...config, join_channel_id: val === "none" ? null : val })}
>
<SelectTrigger className="w-full h-12 bg-slate-900 border-slate-800 font-medium">
<SelectValue placeholder="Select a voice channel..." />
</SelectTrigger>
<SelectContent className="bg-slate-900 border-slate-800 max-h-[300px]">
<SelectItem value="none" className="text-slate-400 focus:bg-slate-800">Not Set</SelectItem>
{voiceChannels.map((c) => (
<SelectItem key={c.id} value={c.id.toString()} className="focus:bg-slate-800">
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className={cn("p-6 bg-slate-900/40 border border-slate-800 rounded-2xl space-y-4 transition-all duration-300", !isEnabled && "opacity-50 pointer-events-none")}>
<div className="flex items-center gap-3">
<div className="p-3 bg-blue-500/20 text-blue-500 rounded-xl">
<Headset className="w-5 h-5" />
</div>
<div>
<h4 className="font-bold text-white">Control Panel Channel</h4>
<p className="text-xs text-slate-400 mt-1">Channel where users manage their private VCs</p>
</div>
</div>
<Select
value={config.control_channel_id ? config.control_channel_id : "none"}
onValueChange={(val) => setConfig({ ...config, control_channel_id: val === "none" ? null : val })}
>
<SelectTrigger className="w-full h-12 bg-slate-900 border-slate-800 font-medium">
<SelectValue placeholder="Select a text channel..." />
</SelectTrigger>
<SelectContent className="bg-slate-900 border-slate-800 max-h-[300px]">
<SelectItem value="none" className="text-slate-400 focus:bg-slate-800">Not Set</SelectItem>
{textChannels.map((c) => (
<SelectItem key={c.id} value={c.id.toString()} className="focus:bg-slate-800">
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className={cn("p-6 bg-slate-900/40 border border-slate-800 rounded-2xl space-y-4 transition-all duration-300", !isEnabled && "opacity-50 pointer-events-none")}>
<div className="flex items-center gap-3">
<div className="p-3 bg-purple-500/20 text-purple-500 rounded-xl">
<Settings2 className="w-5 h-5" />
</div>
<div>
<h4 className="font-bold text-white">Target Category</h4>
<p className="text-xs text-slate-400 mt-1">Category where temporary voice channels are created</p>
</div>
</div>
<Select
value={config.category_id ? config.category_id : "none"}
onValueChange={(val) => setConfig({ ...config, category_id: val === "none" ? null : val })}
>
<SelectTrigger className="w-full h-12 bg-slate-900 border-slate-800 font-medium">
<SelectValue placeholder="Automatic (Same as Join Channel)..." />
</SelectTrigger>
<SelectContent className="bg-slate-900 border-slate-800 max-h-[300px]">
<SelectItem value="none" className="text-slate-400 focus:bg-slate-800">Automatic (Same as Join Channel)</SelectItem>
{categoryChannels.map((c) => (
<SelectItem key={c.id} value={c.id.toString()} className="focus:bg-slate-800">
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<Button
onClick={handleSave}
disabled={saving || (isEnabled && !config.join_channel_id)}
className="w-full h-14 text-base font-bold gap-2"
>
{saving ? <RefreshCcw className="h-5 w-5 animate-spin" /> : <Save className="h-5 w-5" />}
{isEnabled && !config.join_channel_id ? 'Select a channel to save' : 'Save Configuration'}
</Button>
</div>
</div>
<div className="space-y-6">
<div className="bg-gradient-to-br from-primary/10 to-transparent border border-primary/20 rounded-3xl p-6 relative overflow-hidden group">
<div className="absolute -right-4 -top-4 opacity-[0.03] group-hover:scale-110 transition-transform">
<Headset className="h-32 w-32 text-primary" />
</div>
<h3 className="text-sm font-bold text-primary mb-2">How It Works</h3>
<p className="text-xs text-slate-400 leading-relaxed mb-4">
Join to Create instantly creates a private, temporary voice channel for any user who connects to the master Join Channel.
</p>
<ul className="text-xs text-slate-500 space-y-2">
<li> The voice channel is owned by the creator.</li>
<li> When the last person leaves, the channel is automatically deleted.</li>
<li> The Control Panel allows owners to lock, unlock, limit members, and kick users.</li>
</ul>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,180 @@
"use client";
import React, { useState } from "react";
import {
Save,
RefreshCcw,
Zap,
Clock,
Hash,
Palette,
Layout,
Info
} from "lucide-react";
import { toast } from "sonner";
import { api } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { LevelingConfig } from "@/types/api";
interface LevelingFormProps {
initialConfig: LevelingConfig;
guildId: string;
}
export function LevelingForm({ initialConfig, guildId }: LevelingFormProps) {
const [config, setConfig] = useState<LevelingConfig>(initialConfig);
const [saving, setSaving] = useState(false);
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
const promise = api.updateLeveling(guildId, {
enabled: config.enabled,
xp_per_message: config.xp_per_message,
cooldown: config.cooldown,
level_up_channel: config.level_up_channel,
embed_color: config.embed_style.color
});
toast.promise(promise, {
loading: 'Saving leveling settings...',
success: 'Leveling settings updated successfully!',
error: 'Failed to update leveling settings',
});
try {
await promise;
} catch (err: any) {
console.error(err);
} finally {
setSaving(false);
}
};
return (
<form onSubmit={handleSave} className="grid grid-cols-1 lg:grid-cols-3 gap-8 pb-20">
<div className="lg:col-span-2 space-y-6">
<div className="bg-[#141B2D] border border-slate-800 rounded-3xl overflow-hidden shadow-xl shadow-black/20">
<div className="p-8 space-y-8">
<div className="flex items-center justify-between mb-4 border-b border-slate-800 pb-4">
<span className="text-sm font-bold text-slate-300">Social Economy Status</span>
<Switch
checked={config.enabled}
onCheckedChange={() => setConfig({...config, enabled: !config.enabled})}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div className="space-y-2">
<label className="text-xs font-black uppercase text-slate-500 tracking-widest flex items-center gap-2">
<Zap className="h-3 w-3" />
XP Weight
</label>
<Input
type="number"
value={config.xp_per_message}
onChange={(e) => setConfig({...config, xp_per_message: parseInt(e.target.value) || 0})}
placeholder="20"
disabled={!config.enabled}
className="py-6 text-lg font-bold"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-black uppercase text-slate-500 tracking-widest flex items-center gap-2">
<Clock className="h-3 w-3" />
Cooldown
</label>
<div className="relative">
<Input
type="number"
value={config.cooldown}
onChange={(e) => setConfig({...config, cooldown: parseInt(e.target.value) || 0})}
placeholder="60"
disabled={!config.enabled}
className="py-6 text-lg font-bold pr-12"
/>
<span className="absolute right-4 top-1/2 -translate-y-1/2 text-xs font-bold text-slate-700">SEC</span>
</div>
</div>
</div>
<div className="space-y-2">
<label className="text-xs font-black uppercase text-slate-500 tracking-widest flex items-center gap-2">
<Hash className="h-3 w-3" />
Level Up Channel
</label>
<Input
value={config.level_up_channel || ""}
onChange={(e) => setConfig({...config, level_up_channel: e.target.value ? parseInt(e.target.value.replace(/\D/g, "")) : null})}
placeholder="Discord Channel ID"
disabled={!config.enabled}
className="py-6 font-mono"
/>
</div>
<Button
type="submit"
disabled={saving}
className="w-full h-14 text-base font-bold gap-2"
>
{saving ? <RefreshCcw className="h-5 w-5 animate-spin" /> : <Save className="h-5 w-5" />}
{saving ? "Updating System..." : "Update Leveling Engine"}
</Button>
</div>
</div>
<div className="bg-[#141B2D] border border-slate-800 rounded-3xl p-8 relative overflow-hidden group">
<div className="absolute right-0 top-0 h-full w-1 bg-primary/20" />
<h3 className="text-sm font-black uppercase text-slate-500 tracking-widest mb-6 flex items-center gap-2">
<Palette className="h-4 w-4" />
Cosmetic Defaults
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6 relative z-10">
<div className="space-y-2">
<p className="text-xs text-slate-500 font-bold px-1">Rank Card Color</p>
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-xl border border-slate-700 shadow-inner" style={{ backgroundColor: config.embed_style.color }} />
<Input
value={config.embed_style.color}
onChange={(e) => setConfig({...config, embed_style: {...config.embed_style, color: e.target.value}})}
className="font-mono h-10"
disabled={!config.enabled}
/>
</div>
</div>
<div className="flex items-center justify-between p-4 bg-slate-900/40 rounded-2xl border border-slate-800">
<div className="flex items-center gap-3">
<Layout className="h-4 w-4 text-slate-500" />
<span className="text-sm font-bold text-slate-300">Thumbnail</span>
</div>
<Switch
checked={config.embed_style.thumbnail}
onCheckedChange={(val) => setConfig({...config, embed_style: {...config.embed_style, thumbnail: val}})}
disabled={!config.enabled}
/>
</div>
</div>
</div>
</div>
<div className="space-y-6">
<div className="bg-[#141B2D] border border-slate-800 rounded-3xl p-6 relative overflow-hidden group shadow-lg">
<div className="absolute -right-4 -top-4 opacity-[0.03] group-hover:scale-110 transition-transform">
<Info className="h-32 w-32 text-white" />
</div>
<h3 className="text-xs font-black uppercase text-slate-500 tracking-widest mb-4">Leveling Logic</h3>
<div className="space-y-4 text-sm leading-relaxed text-slate-400">
<p>Members earn <span className="text-white font-bold italic">XP</span> by chatting.</p>
<div className="p-4 bg-primary/5 rounded-2xl border border-primary/10 text-[10px] font-mono">
5 * (level ^ 2) + (50 * level) + 100
</div>
</div>
</div>
</div>
</form>
);
}

View File

@@ -0,0 +1,184 @@
"use client";
import React, { useState } from "react";
import {
MessageSquare,
UserPlus,
ShieldAlert,
Mic,
Settings,
Hash,
ChevronRight,
BellRing,
Info
} from "lucide-react";
import { toast } from "sonner";
import { api } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Select } from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { LoggingConfig, DiscordChannel } from "@/types/api";
const LOG_CATEGORIES = [
{ id: "message_events", name: "Message Events", icon: MessageSquare, description: "Log message deletions, edits, and bulk removals." },
{ id: "join_leave_events", name: "Join & Leave Events", icon: UserPlus, description: "Track when members join or leave the server." },
{ id: "member_moderation", name: "Moderation Events", icon: ShieldAlert, description: "Log kicks, bans, and timeout updates." },
{ id: "voice_events", name: "Voice Events", icon: Mic, description: "Track members joining, leaving, or moving voice channels." },
{ id: "role_events", name: "Role Changes", icon: Settings, description: "Log role creation, deletion, and permission updates." },
{ id: "channel_events", name: "Channel Changes", icon: Hash, description: "Track channel creation, deletion, and settings updates." },
];
interface LoggingFormProps {
initialConfig: LoggingConfig;
channels: DiscordChannel[];
guildId: string;
}
export function LoggingForm({ initialConfig, channels, guildId }: LoggingFormProps) {
const [config, setConfig] = useState<LoggingConfig>(initialConfig);
const [saving, setSaving] = useState(false);
const handleToggle = async (categoryId: string, enabled: boolean) => {
// Optimistic update
const newLogEnabled = { ...config.log_enabled, [categoryId]: enabled };
setConfig({ ...config, log_enabled: newLogEnabled });
try {
await api.updateLogging(guildId, {
log_enabled: { [categoryId]: enabled }
});
toast.success(`${enabled ? 'Enabled' : 'Disabled'} ${categoryId.replace('_', ' ')} logging`);
} catch (err: any) {
setConfig(config);
toast.error("Failed to update logging setting.");
}
};
const handleChannelChange = async (categoryId: string, channelId: string) => {
// Optimistic update
const newLogChannels = { ...config.log_channels, [categoryId]: parseInt(channelId) };
setConfig({ ...config, log_channels: newLogChannels });
setSaving(true);
const promise = api.updateLogging(guildId, {
log_channels: { [categoryId]: parseInt(channelId) }
});
toast.promise(promise, {
loading: 'Updating log channel...',
success: 'Log channel updated successfully',
error: 'Failed to update log channel',
});
try {
await promise;
} catch (err: any) {
setConfig(config);
} finally {
setSaving(false);
}
};
const channelOptions = channels.map(c => ({
value: c.id.toString(),
label: `#${c.name}`
}));
return (
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
<div className="lg:col-span-3 space-y-4">
{LOG_CATEGORIES.map((cat) => (
<div key={cat.id} className="bg-[#141B2D] border border-slate-800 p-8 rounded-[40px] shadow-xl hover:border-primary/20 transition-all group">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-8">
<div className="flex items-start gap-5">
<div className="h-14 w-14 rounded-2xl bg-slate-800/50 flex items-center justify-center text-slate-400 group-hover:text-primary transition-colors border border-white/5 shrink-0">
<cat.icon className="h-7 w-7" />
</div>
<div className="flex flex-col">
<h3 className="text-lg font-black text-white tracking-tight">{cat.name}</h3>
<p className="text-[12px] text-slate-500 font-medium leading-relaxed max-w-sm mt-1">
{cat.description}
</p>
</div>
</div>
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 md:gap-8 lg:min-w-[320px]">
<div className="w-full sm:w-48 lg:w-56">
<p className="text-[10px] font-black uppercase text-slate-600 mb-2 tracking-widest pl-1">Destination</p>
<Select
value={config.log_channels[cat.id]?.toString() || ""}
onValueChange={(val) => handleChannelChange(cat.id, val)}
options={channelOptions}
placeholder="Select channel..."
className="bg-black/20 border-slate-800 rounded-xl"
/>
</div>
<div className="flex items-center gap-4 border-l border-slate-800/50 pl-4 md:pl-8">
<div className="flex-col items-end hidden sm:flex">
<span className={cn(
"text-[10px] font-black uppercase tracking-widest",
config.log_enabled[cat.id] ? "text-emerald-500" : "text-slate-600"
)}>
{config.log_enabled[cat.id] ? "Active" : "Silent"}
</span>
</div>
<Switch
checked={!!config.log_enabled[cat.id]}
onCheckedChange={(val) => handleToggle(cat.id, val)}
/>
</div>
</div>
</div>
</div>
))}
</div>
<div className="space-y-6">
<section className="bg-gradient-to-br from-primary/10 to-transparent border border-primary/20 rounded-[40px] p-8 relative overflow-hidden group">
<div className="absolute -right-4 -top-4 opacity-[0.03] group-hover:scale-110 transition-transform">
<BellRing className="h-32 w-32 text-white" />
</div>
<div className="flex items-center gap-2 mb-6">
<Info className="h-5 w-5 text-primary" />
<h3 className="font-bold text-white text-lg tracking-tight">Logging Engine</h3>
</div>
<div className="space-y-4 relative z-10">
<div className="p-4 bg-black/20 rounded-2xl border border-white/5 space-y-2">
<p className="text-[10px] uppercase font-bold text-slate-500">Intelligent Routing</p>
<p className="text-xs text-slate-300 leading-relaxed font-medium">Assign specific channels to different event types for better organization.</p>
</div>
<div className="p-4 bg-black/20 rounded-2xl border border-white/5 space-y-2">
<p className="text-[10px] uppercase font-bold text-slate-500">Webhooks</p>
<p className="text-xs text-slate-300 leading-relaxed font-medium">Coming soon: Export audit logs to external webhooks and elastic systems.</p>
</div>
</div>
<Button variant="secondary" className="w-full mt-8 py-6 rounded-[24px] font-black uppercase tracking-tighter text-xs">
Save Global Config
</Button>
</section>
<div className="bg-[#141B2D] border border-slate-800 rounded-[40px] p-8 shadow-xl">
<h3 className="text-xs font-black uppercase text-slate-500 tracking-[0.15em] mb-6 flex items-center gap-2">
<ShieldAlert className="h-4 w-4 text-amber-500" />
Audit Protection
</h3>
<div className="space-y-4">
<div className="flex items-center justify-between p-3 bg-slate-900/40 rounded-xl border border-slate-800 hover:border-slate-700 transition-colors">
<span className="text-xs font-bold text-slate-400">Protect Roles</span>
<span className="bg-slate-800 text-slate-300 px-2 py-1 rounded-md text-[10px] font-black">{config.ignore_roles.length}</span>
</div>
<div className="flex items-center justify-between p-3 bg-slate-900/40 rounded-xl border border-slate-800 hover:border-slate-700 transition-colors">
<span className="text-xs font-bold text-slate-400">Secure Channels</span>
<span className="bg-slate-800 text-slate-300 px-2 py-1 rounded-md text-[10px] font-black">{config.ignore_channels.length}</span>
</div>
</div>
<p className="text-[10px] text-slate-600 mt-6 leading-relaxed italic text-center">
Events from these entities are currently bypassed by the audit logger.
</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,60 @@
import React from "react";
import { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
interface MetricCardProps {
name: string;
value: string | number;
icon: LucideIcon;
description?: string;
trend?: {
value: string;
isUp: boolean;
};
className?: string;
}
export const MetricCard = ({
name,
value,
icon: Icon,
description,
trend,
className
}: MetricCardProps) => {
return (
<div className={cn(
"bg-[#141B2D] border border-slate-800 p-6 rounded-3xl relative overflow-hidden group hover:border-primary/50 transition-all shadow-xl hover:shadow-primary/5 shadow-black/20",
className
)}>
<div className="flex items-center justify-between relative z-10">
<div>
<p className="text-xs font-black uppercase text-slate-500 tracking-widest">{name}</p>
<div className="flex items-baseline gap-2 mt-1">
<p className="text-3xl font-black text-white tracking-tighter">{value}</p>
{trend && (
<span className={cn(
"text-[10px] font-black px-1.5 py-0.5 rounded-lg",
trend.isUp ? "bg-emerald-500/10 text-emerald-500" : "bg-red-500/10 text-red-500"
)}>
{trend.isUp ? "+" : ""}{trend.value}
</span>
)}
</div>
{description && (
<p className="text-[10px] text-slate-500 mt-2 italic font-medium">{description}</p>
)}
</div>
<div className="p-4 bg-slate-800/50 rounded-2xl group-hover:scale-110 group-hover:bg-primary/10 transition-all border border-white/5">
<Icon className="h-6 w-6 text-primary" />
</div>
</div>
{/* Decorative Gradient Line */}
<div className="absolute bottom-0 left-0 h-1 bg-gradient-to-r from-primary/50 to-transparent w-full opacity-0 group-hover:opacity-100 transition-opacity" />
{/* Glow Effect */}
<div className="absolute -right-8 -bottom-8 h-24 w-24 bg-primary/5 blur-3xl rounded-full opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
);
};

View File

@@ -0,0 +1,39 @@
import React from "react";
import { cn } from "@/lib/utils";
interface PageHeaderProps {
title: string;
description?: string;
children?: React.ReactNode;
icon?: React.ElementType;
className?: string;
}
export const PageHeader = ({
title,
description,
children,
icon: Icon,
className
}: PageHeaderProps) => {
return (
<div className={cn("flex flex-col md:flex-row md:items-center justify-between gap-6 mb-8", className)}>
<div>
<h1 className="text-3xl font-black text-white flex items-center gap-3 tracking-tight">
{Icon && <Icon className="h-8 w-8 text-primary shrink-0" />}
{title}
</h1>
{description && (
<p className="text-slate-400 mt-1 font-medium italic">
{description}
</p>
)}
</div>
{children && (
<div className="flex items-center gap-4">
{children}
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,233 @@
"use client";
import React, { useState } from "react";
import { MousePointer2, Save, RefreshCcw, Plus, Trash2, BellRing, Settings } from "lucide-react";
import { toast } from "sonner";
import { api } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
interface ReactionRolesFormProps {
initialConfig: any;
roles: any[];
guildId: string;
}
export function ReactionRolesForm({ initialConfig, roles, guildId }: ReactionRolesFormProps) {
const [config, setConfig] = useState<any>(initialConfig);
const [saving, setSaving] = useState(false);
const [loadingAction, setLoadingAction] = useState(false);
const [newRR, setNewRR] = useState({
message_id: "",
emoji: "",
role_id: "",
});
const filteredRoles = roles.filter(r => r.name !== "@everyone");
const toggleDM = async (val: boolean) => {
try {
await api.updateRR(guildId, { dm_enabled: val });
setConfig({ ...config, dm_enabled: val });
toast.success(`DM notifications ${val ? "enabled" : "disabled"}`);
} catch (error) {
toast.error("Failed to update DM setting");
}
};
const handleAdd = async () => {
if (!newRR.message_id || !newRR.emoji || !newRR.role_id) {
toast.error("Please fill in all fields");
return;
}
try {
setLoadingAction(true);
await api.updateRR(guildId, {
add_role: {
message_id: parseInt(newRR.message_id),
emoji: newRR.emoji,
role_id: parseInt(newRR.role_id),
},
});
// Try to optimistically add to the UI
setConfig({
...config,
roles: [...config.roles, { message_id: newRR.message_id, emoji: newRR.emoji, role_id: newRR.role_id }]
});
toast.success("Reaction role added");
setNewRR({ message_id: "", emoji: "", role_id: "" });
} catch (error) {
toast.error("Failed to add reaction role");
} finally {
setLoadingAction(false);
}
};
const handleDelete = async (messageId: number, emoji: string) => {
try {
setLoadingAction(true);
await api.updateRR(guildId, {
remove_role_message_id: messageId,
remove_role_emoji: emoji,
});
setConfig({
...config,
roles: config.roles.filter((r: any) => !(r.message_id === messageId && r.emoji === emoji))
});
toast.success("Reaction role removed");
} catch (error) {
toast.error("Failed to remove reaction role");
} finally {
setLoadingAction(false);
}
};
return (
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
<div className="lg:col-span-3 space-y-6">
<div className="bg-[#141B2D] border border-slate-800 rounded-3xl shadow-xl p-8 space-y-8">
<div className="flex items-center justify-between p-6 bg-slate-900/40 rounded-2xl border border-slate-800">
<div className="flex items-center gap-4">
<div className="p-3 bg-primary/20 text-primary rounded-xl">
<BellRing className="w-5 h-5" />
</div>
<div>
<h3 className="text-lg font-black text-white">DM Notifications</h3>
<p className="text-sm text-slate-400 mt-1">Send a direct message when a user gets/loses a role.</p>
</div>
</div>
<Switch
checked={config.dm_enabled}
onCheckedChange={toggleDM}
className="scale-125"
/>
</div>
<div className="pt-6 border-t border-slate-800">
<h4 className="text-sm font-bold text-white mb-4 flex items-center gap-2">
<Plus className="h-5 w-5 text-primary" />
Create New Reaction Role
</h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div className="space-y-2">
<label className="text-xs font-bold text-slate-400">Message ID</label>
<Input
placeholder="e.g. 1234567890"
value={newRR.message_id}
onChange={(e) => setNewRR({ ...newRR, message_id: e.target.value })}
className="bg-slate-900/50 h-10"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-slate-400">Emoji</label>
<Input
placeholder="e.g. ✅"
value={newRR.emoji}
onChange={(e) => setNewRR({ ...newRR, emoji: e.target.value })}
className="bg-slate-900/50 h-10"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-slate-400">Role to Assign</label>
<Select
value={newRR.role_id ? newRR.role_id.toString() : ""}
onValueChange={(val) => setNewRR({ ...newRR, role_id: val })}
>
<SelectTrigger className="w-full h-10 bg-slate-900/50 border-slate-800">
<SelectValue placeholder="Select a role..." />
</SelectTrigger>
<SelectContent className="bg-slate-900 border-slate-800 max-h-[250px]">
{filteredRoles.map((role) => (
<SelectItem key={role.id} value={role.id.toString()} className="focus:bg-slate-800">
{role.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<Button
onClick={handleAdd}
disabled={loadingAction}
className="w-full gap-2"
variant="secondary"
>
{loadingAction ? <RefreshCcw className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
Add to Active Listeners
</Button>
</div>
<div className="pt-6 border-t border-slate-800">
<h4 className="text-sm font-bold text-white mb-4 flex items-center gap-2">
<MousePointer2 className="h-5 w-5 text-primary" />
Active Reaction Roles
</h4>
<div className="space-y-3">
{config.roles.length === 0 ? (
<div className="text-center p-8 bg-slate-900/20 rounded-2xl border border-dashed border-slate-700">
<p className="text-sm text-slate-500 italic">No reaction roles currently configured.</p>
</div>
) : (
config.roles.map((rr: any, idx: number) => {
const roleName = roles.find(r => r.id === rr.role_id.toString())?.name || "Unknown Role";
return (
<div key={idx} className="flex items-center justify-between p-4 bg-slate-900/40 rounded-xl border border-slate-800">
<div className="flex items-center gap-6">
<div className="flex flex-col">
<span className="text-[10px] uppercase font-bold text-slate-500">Message ID</span>
<span className="text-sm font-mono text-slate-300">{rr.message_id}</span>
</div>
<div className="flex flex-col items-center">
<span className="text-[10px] uppercase font-bold text-slate-500">Emoji</span>
<span className="text-lg">{rr.emoji}</span>
</div>
<div className="flex flex-col">
<span className="text-[10px] uppercase font-bold text-slate-500">Role</span>
<span className="text-sm font-medium text-primary">{roleName}</span>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(rr.message_id, rr.emoji)}
disabled={loadingAction}
className="text-red-400 hover:text-red-300 hover:bg-red-400/10 h-8 w-8 p-0"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
);
})
)}
</div>
</div>
</div>
</div>
<div className="space-y-6">
<div className="bg-gradient-to-br from-primary/10 to-transparent border border-primary/20 rounded-3xl p-6 relative overflow-hidden group">
<div className="absolute -right-4 -top-4 opacity-[0.03] group-hover:scale-110 transition-transform">
<MousePointer2 className="h-32 w-32 text-primary" />
</div>
<h3 className="text-sm font-bold text-primary mb-2">Usage Guide</h3>
<p className="text-xs text-slate-400 leading-relaxed mb-4">
Reaction Roles allow members to self-assign their own roles with a single click.
</p>
<ul className="text-xs text-slate-500 space-y-2">
<li> The bot must have access to see the message you specify.</li>
<li> Make sure the bot role is HIGHER than the role you are attempting to assign.</li>
<li> The bot will automatically react to the message once you click &quot;Add to Active Listeners&quot;.</li>
</ul>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,90 @@
import React from "react";
import Image from "next/image";
import Link from "next/link";
import { Users, Hash, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface ServerCardProps {
id: string | number;
name: string;
iconUrl?: string | null;
memberCount: number;
isActive?: boolean;
className?: string;
}
export const ServerCard = ({
id,
name,
iconUrl,
memberCount,
isActive = true,
className
}: ServerCardProps) => {
return (
<div className={cn(
"bg-[#141B2D] border border-slate-800 rounded-[40px] group hover:border-primary/50 transition-all duration-500 overflow-hidden shadow-2xl hover:shadow-primary/10 shadow-black/40 h-full flex flex-col",
className
)}>
<div className="p-8 flex-grow">
<div className="flex items-start justify-between mb-8">
<div className="relative">
{iconUrl ? (
<Image
src={iconUrl}
alt={name}
width={80}
height={80}
className="rounded-3xl border-4 border-slate-800 shadow-2xl group-hover:scale-105 transition-transform duration-500"
/>
) : (
<div className="h-20 w-20 bg-primary/20 rounded-3xl flex items-center justify-center border-4 border-slate-800 text-primary font-black text-3xl shadow-2xl group-hover:scale-105 transition-transform duration-500">
{name.charAt(0)}
</div>
)}
{isActive && (
<div className="absolute -bottom-1 -right-1 h-5 w-5 rounded-full bg-emerald-500 border-4 border-[#141B2D] shadow-lg animate-pulse" title="Online" />
)}
</div>
<div className="flex flex-col items-end text-right">
<span className="text-[10px] uppercase font-black text-slate-500 tracking-[0.2em] mb-1 opacity-50">ID Reference</span>
<span className="text-[11px] font-mono font-bold text-slate-400 bg-black/20 px-3 py-1.5 rounded-xl border border-white/5 truncate max-w-[140px]">
{id}
</span>
</div>
</div>
<div>
<h3 className="text-2xl font-black text-white truncate group-hover:text-primary transition-colors tracking-tight">
{name}
</h3>
<div className="flex items-center gap-4 mt-6">
<div className="flex items-center gap-2 bg-slate-800/50 px-4 py-2 rounded-2xl border border-white/5 shadow-inner">
<Users className="h-4 w-4 text-primary" />
<span className="text-xs font-black text-slate-200 tabular-nums">
{memberCount.toLocaleString()}
</span>
</div>
<div className="flex items-center gap-2 bg-slate-800/50 px-4 py-2 rounded-2xl border border-white/5">
<Hash className="h-4 w-4 text-slate-500" />
<span className="text-[10px] font-black uppercase tracking-widest text-slate-400">
Managed
</span>
</div>
</div>
</div>
</div>
<div className="px-8 py-6 bg-slate-900/40 border-t border-slate-800/50 group-hover:bg-primary/5 transition-all">
<Link href={`/dashboard/guild/${id}`} className="block">
<Button className="w-full justify-between group/btn py-7 rounded-2xl border-slate-700 font-black uppercase tracking-tighter text-xs" variant="secondary">
<span>Access Dashboard</span>
<ChevronRight className="h-4 w-4 group-hover/btn:translate-x-1 transition-transform" />
</Button>
</Link>
</div>
</div>
);
};

View File

@@ -0,0 +1,109 @@
"use client";
import React, { useState } from "react";
import {
Save,
RefreshCcw,
Command,
Info
} from "lucide-react";
import { toast } from "sonner";
import { api } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
interface SettingsFormProps {
initialPrefix: string;
guildId: string;
}
export function SettingsForm({ initialPrefix, guildId }: SettingsFormProps) {
const [prefix, setPrefix] = useState(initialPrefix);
const [saving, setSaving] = useState(false);
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
if (!prefix || prefix.length > 10) {
toast.error("Prefix must be between 1 and 10 characters.");
return;
}
setSaving(true);
const promise = api.updatePrefix(guildId, prefix);
toast.promise(promise, {
loading: 'Updating prefix...',
success: 'Prefix updated successfully!',
error: (err) => err.message || 'Failed to update prefix',
});
try {
await promise;
} catch (err: any) {
console.error(err);
} finally {
setSaving(false);
}
};
return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="lg:col-span-2 space-y-6">
<div className="bg-[#141B2D] border border-slate-800 rounded-3xl overflow-hidden shadow-xl shadow-black/20">
<div className="p-8 space-y-6">
<form onSubmit={handleSave} className="space-y-6">
<div className="space-y-2">
<label className="text-sm font-black uppercase text-slate-500 tracking-widest flex items-center gap-2">
<Command className="h-4 w-4" />
Command Prefix
</label>
<div className="relative group">
<Input
value={prefix}
onChange={(e) => setPrefix(e.target.value)}
placeholder="e.g. !, ?, >>"
maxLength={10}
className="text-lg font-bold pr-20 py-7"
/>
<div className="absolute right-4 top-1/2 -translate-y-1/2 text-xs font-mono text-slate-600 group-focus-within:text-primary transition-colors">
{prefix.length}/10
</div>
</div>
<p className="text-xs text-slate-500 italic">This character triggers bot commands (e.g. {prefix || ">"}help)</p>
</div>
<Button
type="submit"
disabled={saving || !prefix}
className="w-full h-14 text-base font-bold gap-2 shadow-primary/20"
>
{saving ? <RefreshCcw className="h-5 w-5 animate-spin" /> : <Save className="h-5 w-5" />}
{saving ? "Saving Changes..." : "Save Configuration"}
</Button>
</form>
</div>
<div className="bg-slate-800/30 px-8 py-4 border-t border-slate-800 flex items-center justify-between">
<span className="text-xs text-slate-500 font-medium">Last synced: Just now</span>
<button className="text-xs text-primary hover:underline font-bold">Refresh Cache</button>
</div>
</div>
</div>
<div className="space-y-6">
<div className="bg-[#141B2D] border border-slate-800 rounded-3xl p-6 relative overflow-hidden group">
<div className="absolute -right-4 -top-4 opacity-[0.03] group-hover:scale-110 transition-transform">
<Info className="h-32 w-32 text-white" />
</div>
<h3 className="text-sm font-black uppercase text-slate-500 tracking-widest mb-4 flex items-center gap-2">
<Info className="h-4 w-4" />
Information
</h3>
<div className="space-y-4 text-sm leading-relaxed text-slate-400">
<p>The <span className="text-white font-bold italic">Prefix</span> is a unique identifier that tells the bot to process text as a command.</p>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,443 @@
"use client";
import React, { useState } from "react";
import {
Plus,
ExternalLink,
Mail,
Zap,
Tag,
X,
Save,
Trash2,
Settings2,
Edit3,
RefreshCcw,
MessageSquare,
Hash,
Shield
} from "lucide-react";
import { toast } from "sonner";
import { api } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { TicketConfig, TicketCategory, TicketEmbed } from "@/types/api";
interface TicketsFormProps {
initialConfig: TicketConfig;
guildId: string;
}
export function TicketsForm({ initialConfig, guildId }: TicketsFormProps) {
const [config, setConfig] = useState<TicketConfig>(initialConfig);
const [saving, setSaving] = useState(false);
const [editingCategory, setEditingCategory] = useState<{index: number, data: TicketCategory} | null>(null);
const [editingEmbed, setEditingEmbed] = useState<TicketEmbed | null>(null);
const [isAdding, setIsAdding] = useState(false);
async function fetchUpdatedConfig() {
try {
const data = await api.getTickets(guildId);
setConfig(data);
} catch (err) {
console.error("Failed to fetch ticket config:", err);
}
}
const handleUpdate = async (updateData: any) => {
setSaving(true);
const promise = api.updateTickets(guildId, updateData);
toast.promise(promise, {
loading: 'Updating ticket settings...',
success: 'Settings updated successfully',
error: 'Failed to update settings',
});
try {
await promise;
await fetchUpdatedConfig();
setIsAdding(false);
setEditingCategory(null);
setEditingEmbed(null);
} catch (err) {
console.error("Failed to update settings:", err);
} finally {
setSaving(false);
}
};
const handleAddCategory = () => {
setEditingCategory({
index: -1,
data: { name: "", emoji: "📩", staff_roles: [], button_style: 2, discord_category_id: "" }
});
setIsAdding(true);
};
const handleRemoveCategory = (index: number) => {
const newCategories = [...config.categories];
newCategories.splice(index, 1);
handleUpdate({ categories: newCategories });
};
const handleSaveCategory = () => {
if (!editingCategory) return;
const newCategories = [...config.categories];
if (isAdding) {
newCategories.push(editingCategory.data);
} else {
newCategories[editingCategory.index] = editingCategory.data;
}
handleUpdate({ categories: newCategories });
};
const handleSaveEmbed = () => {
if (!editingEmbed) return;
handleUpdate({
embed_title: editingEmbed.title,
embed_description: editingEmbed.description,
embed_color: editingEmbed.color,
embed_image_url: editingEmbed.image_url,
embed_thumbnail_url: editingEmbed.thumbnail_url
});
};
const handleSaveGlobal = () => {
handleUpdate({
panel_channel: config.panel_channel,
logging_channel: config.logging_channel,
closed_category: config.closed_category,
panel_type: config.panel_type,
staff_roles: config.staff_roles
});
};
return (
<>
{/* Category Editor Modal */}
{editingCategory && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-in fade-in duration-200">
<div className="bg-[#141B2D] border border-slate-800 rounded-3xl w-full max-w-lg shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200">
<div className="p-6 border-b border-slate-800 flex items-center justify-between bg-slate-900/50">
<h3 className="font-bold text-lg text-white flex items-center gap-2">
{isAdding ? <Plus className="h-5 w-5 text-primary" /> : <Edit3 className="h-5 w-5 text-primary" />}
{isAdding ? "Add Category" : "Edit Category"}
</h3>
<button onClick={() => setEditingCategory(null)} className="text-slate-500 hover:text-white transition-colors">
<X className="h-5 w-5" />
</button>
</div>
<div className="p-8 space-y-6">
<div className="space-y-2">
<label className="text-xs font-black uppercase text-slate-500 tracking-widest">Category Name</label>
<Input
value={editingCategory.data.name}
onChange={(e) => setEditingCategory({...editingCategory, data: {...editingCategory.data, name: e.target.value}})}
placeholder="e.g. Bug Reports"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-xs font-black uppercase text-slate-500 tracking-widest">Emoji Icon</label>
<Input
value={editingCategory.data.emoji || ""}
onChange={(e) => setEditingCategory({...editingCategory, data: {...editingCategory.data, emoji: e.target.value}})}
placeholder="e.g. 🐛"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-black uppercase text-slate-500 tracking-widest">Staff Roles (IDs)</label>
<Input
value={editingCategory.data.staff_roles.join(", ")}
onChange={(e) => {
const roles = e.target.value.split(",").map(id => id.trim()).filter(id => id && !isNaN(Number(id))).map(Number);
setEditingCategory({
...editingCategory,
data: { ...editingCategory.data, staff_roles: roles }
})
}}
placeholder="ID1, ID2..."
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-xs font-black uppercase text-slate-500 tracking-widest">Button Style</label>
<Select
value={String(editingCategory.data.button_style || 2)}
onValueChange={(val) => setEditingCategory({
...editingCategory,
data: { ...editingCategory.data, button_style: parseInt(val) }
})}
>
<SelectTrigger className="bg-slate-900/50 border-slate-800">
<SelectValue placeholder="Select Style" />
</SelectTrigger>
<SelectContent className="bg-slate-900 border-slate-800">
<SelectItem value="2">Blurple</SelectItem>
<SelectItem value="1">Grey</SelectItem>
<SelectItem value="3">Green</SelectItem>
<SelectItem value="4">Red</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-xs font-black uppercase text-slate-500 tracking-widest">Discord Category ID</label>
<Input
value={editingCategory.data.discord_category_id || ""}
onChange={(e) => setEditingCategory({
...editingCategory,
data: { ...editingCategory.data, discord_category_id: e.target.value }
})}
placeholder="Created tickets go here"
/>
</div>
</div>
<div className="pt-4 flex gap-3">
<Button variant="outline" className="flex-1" onClick={() => setEditingCategory(null)}>Cancel</Button>
<Button className="flex-1 gap-2" onClick={handleSaveCategory} disabled={saving || !editingCategory.data.name}>
{saving ? <RefreshCcw className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
Save Category
</Button>
</div>
</div>
</div>
</div>
)}
{/* Embed Appearance Editor Modal */}
{editingEmbed && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-in fade-in duration-200">
<div className="bg-[#141B2D] border border-slate-800 rounded-3xl w-full max-w-xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200">
<div className="p-6 border-b border-slate-800 flex items-center justify-between bg-slate-900/50">
<h3 className="font-bold text-lg text-white flex items-center gap-2">
<Edit3 className="h-5 w-5 text-primary" />
Customize Panel Appearance
</h3>
<button onClick={() => setEditingEmbed(null)} className="text-slate-500 hover:text-white transition-colors">
<X className="h-5 w-5" />
</button>
</div>
<div className="p-8 space-y-6 max-h-[70vh] overflow-y-auto custom-scrollbar">
<div className="space-y-2">
<label className="text-xs font-black uppercase text-slate-500 tracking-widest">Embed Title</label>
<Input
value={editingEmbed.title || ""}
onChange={(e) => setEditingEmbed({...editingEmbed, title: e.target.value})}
placeholder="e.g. Support Department"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-black uppercase text-slate-500 tracking-widest">Embed Description</label>
<Textarea
value={editingEmbed.description || ""}
onChange={(e) => setEditingEmbed({...editingEmbed, description: e.target.value})}
placeholder="Open a ticket below to talk to our staff..."
className="h-24"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-xs font-black uppercase text-slate-500 tracking-widest">Color (Decimal)</label>
<Input
value={editingEmbed.color || ""}
onChange={(e) => setEditingEmbed({...editingEmbed, color: e.target.value ? parseInt(e.target.value) : null})}
placeholder="e.g. 16711680 for Red"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-black uppercase text-slate-500 tracking-widest">Thumbnail URL</label>
<Input
value={editingEmbed.thumbnail_url || ""}
onChange={(e) => setEditingEmbed({...editingEmbed, thumbnail_url: e.target.value})}
placeholder="Small top-right image"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-xs font-black uppercase text-slate-500 tracking-widest">Main Image URL</label>
<Input
value={editingEmbed.image_url || ""}
onChange={(e) => setEditingEmbed({...editingEmbed, image_url: e.target.value})}
placeholder="Large bottom image"
/>
</div>
<div className="pt-4 flex gap-3">
<Button variant="outline" className="flex-1" onClick={() => setEditingEmbed(null)}>Cancel</Button>
<Button className="flex-1 gap-2" onClick={handleSaveEmbed} disabled={saving}>
{saving ? <RefreshCcw className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
Save Appearance
</Button>
</div>
</div>
</div>
</div>
)}
{/* Main Grid */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Left Column: Stats & Setup */}
<div className="lg:col-span-2 space-y-6">
<div className="bg-[#141B2D] border border-slate-800 rounded-3xl p-8 shadow-xl space-y-8">
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-xl bg-primary/10 text-primary"><Settings2 className="h-5 w-5" /></div>
<h3 className="text-xl font-bold text-white">Global Configuration</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-xs font-black uppercase text-slate-500 tracking-widest">Panel Channel ID</label>
<Input
value={config.panel_channel || ""}
onChange={(e) => setConfig({...config, panel_channel: e.target.value})}
placeholder="Where the ticket panel lives"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-black uppercase text-slate-500 tracking-widest">Logging Channel ID</label>
<Input
value={config.logging_channel || ""}
onChange={(e) => setConfig({...config, logging_channel: e.target.value})}
placeholder="Where transcripts go"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-black uppercase text-slate-500 tracking-widest">Closed Tickets Category ID</label>
<Input
value={config.closed_category || ""}
onChange={(e) => setConfig({...config, closed_category: e.target.value})}
placeholder="Archive closed tickets here"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-black uppercase text-slate-500 tracking-widest">Panel Interaction Type</label>
<Select
value={config.panel_type || "button"}
onValueChange={(val) => setConfig({...config, panel_type: val})}
>
<SelectTrigger className="bg-slate-900/50 border-slate-800">
<SelectValue placeholder="Select Type" />
</SelectTrigger>
<SelectContent className="bg-slate-900 border-slate-800">
<SelectItem value="button">Buttons</SelectItem>
<SelectItem value="dropdown">Dropdown Menu</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2 md:col-span-2">
<label className="text-xs font-black uppercase text-slate-500 tracking-widest">Global Staff Role IDs</label>
<Input
value={config.staff_roles.join(", ")}
onChange={(e) => {
const roles = e.target.value.split(",").map(id => id.trim()).filter(id => id && !isNaN(Number(id))).map(Number);
setConfig({...config, staff_roles: roles})
}}
placeholder="ID1, ID2... These roles can see all tickets"
/>
</div>
</div>
<Button onClick={handleSaveGlobal} disabled={saving} className="w-full gap-2" variant="secondary">
{saving ? <RefreshCcw className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
Save Core Settings
</Button>
</div>
{/* Categories Section */}
<section className="bg-[#141B2D] border border-slate-800 rounded-3xl overflow-hidden shadow-xl">
<div className="p-6 border-b border-slate-800 flex items-center justify-between bg-slate-900/20">
<div className="flex items-center gap-2">
<Tag className="h-5 w-5 text-primary" />
<h3 className="font-bold text-white">Ticket Categories</h3>
</div>
<Button size="sm" variant="outline" className="h-8 gap-1 text-xs border-primary/20 text-primary hover:bg-primary/10" onClick={handleAddCategory}>
<Plus className="h-3 w-3" />
Add New
</Button>
</div>
<div className="p-6">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{config.categories.map((cat, i) => (
<div key={i} className="flex items-center justify-between p-4 bg-slate-900/50 rounded-2xl border border-white/5 hover:border-primary/30 transition-all group">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-xl bg-slate-800 flex items-center justify-center text-xl shadow-inner">
{cat.emoji || '📩'}
</div>
<div className="flex flex-col">
<span className="text-sm font-bold text-slate-200 group-hover:text-white transition-colors">{cat.name}</span>
<span className="text-[10px] text-slate-500 flex items-center gap-1 font-mono">
<Shield className="h-2 w-2" />
{cat.staff_roles && cat.staff_roles.length > 0 ? `${cat.staff_roles.length} Staff Roles` : 'Global Staff'}
</span>
</div>
</div>
<div className="flex items-center gap-1 opacity-100 sm:opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={() => {
setEditingCategory({index: i, data: {...cat}});
setIsAdding(false);
}} className="p-2 hover:bg-slate-800 rounded-lg text-slate-400 hover:text-primary transition-colors">
<Settings2 className="h-4 w-4" />
</button>
<button onClick={() => handleRemoveCategory(i)} className="p-2 hover:bg-red-500/10 rounded-lg text-slate-400 hover:text-red-500 transition-colors">
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
))}
</div>
</div>
</section>
</div>
{/* Right Column: Stats & Configuration */}
<div className="space-y-6">
<div className="bg-[#141B2D] border border-slate-800 p-6 rounded-3xl group shadow-lg">
<div className="flex items-center justify-between mb-4">
<div className="p-3 bg-primary/10 rounded-2xl text-primary group-hover:scale-110 transition-transform">
<MessageSquare className="h-6 w-6" />
</div>
<div className="h-2 w-2 rounded-full bg-primary shadow-[0_0_10px_rgba(88,101,242,0.5)]" />
</div>
<p className="text-sm font-medium text-slate-500">Currently Open</p>
<h3 className="text-3xl font-black text-white mt-1">{config.open_ticket_count} Tickets</h3>
</div>
<section className="bg-gradient-to-br from-primary/10 to-transparent border border-primary/20 rounded-3xl p-6 relative overflow-hidden group">
<div className="absolute -right-4 -top-4 opacity-[0.03] group-hover:scale-110 transition-transform">
<Mail className="h-32 w-32 text-white" />
</div>
<div className="flex items-center gap-2 mb-4">
<Zap className="h-5 w-5 text-primary" />
<h3 className="font-bold text-white">Panel Appearance</h3>
</div>
<div className="space-y-3 relative z-10">
<div className="p-3 bg-black/20 rounded-xl border border-white/5">
<p className="text-[10px] uppercase font-bold text-slate-500 mb-1">Title</p>
<p className="text-sm text-slate-200 font-medium truncate">{config.embed.title || 'Support Department'}</p>
</div>
<div className="p-3 bg-black/20 rounded-xl border border-white/5">
<p className="text-[10px] uppercase font-bold text-slate-500 mb-1">Description</p>
<p className="text-xs text-slate-400 line-clamp-2 leading-relaxed">
{config.embed.description || 'Open a ticket below to talk to our staff.'}
</p>
</div>
</div>
<Button
variant="secondary"
className="w-full mt-6 py-5 text-xs font-bold uppercase tracking-wider"
onClick={() => setEditingEmbed({...config.embed})}
>
Edit Appearance
</Button>
</section>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,184 @@
"use client";
import React, { useState } from "react";
import { Link2, Trash2, Plus, RefreshCcw, Save } from "lucide-react";
import { toast } from "sonner";
import { api } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { VanityRoleSetup, DiscordChannel } from "@/types/api";
interface VanityRoleFormProps {
initialSetups: VanityRoleSetup[];
channels: DiscordChannel[];
roles: any[];
guildId: string;
}
export function VanityRoleForm({ initialSetups, channels, roles, guildId }: VanityRoleFormProps) {
const [setups, setSetups] = useState<VanityRoleSetup[]>(initialSetups);
const [saving, setSaving] = useState(false);
const textChannels = channels.filter(c => c.type === "0" || c.type === "text" || !c.type); // ensure text channels
const [newVanity, setNewVanity] = useState("");
const [newRole, setNewRole] = useState<string | null>(null);
const [newChannel, setNewChannel] = useState<string | null>(null);
const handleCreate = async () => {
if (!newVanity || !newRole || !newChannel) {
toast.error("Please fill in all fields (vanity, role, channel).");
return;
}
setSaving(true);
try {
await api.addVanityRole(guildId, {
vanity: newVanity,
role_id: newRole,
log_channel_id: newChannel
});
setSetups([...setups, { vanity: newVanity, role_id: newRole, log_channel_id: newChannel }]);
setNewVanity("");
setNewRole(null);
setNewChannel(null);
toast.success("Vanity role setup added!");
} catch (err) {
toast.error("Failed to add vanity role setup.");
} finally {
setSaving(false);
}
};
const handleDelete = async (vanity: string) => {
setSaving(true);
try {
await api.deleteVanityRole(guildId, vanity);
setSetups(setups.filter(s => s.vanity !== vanity));
toast.success("Vanity role setup deleted.");
} catch (err) {
toast.error("Failed to delete vanity role setup.");
} finally {
setSaving(false);
}
};
return (
<div className="space-y-8">
{/* List Existing Ones */}
<div className="bg-[#141B2D] border border-slate-800 rounded-3xl shadow-xl p-8">
<h3 className="text-xl font-bold text-white mb-6">Active Vanity Roles</h3>
{setups.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 text-slate-500">
<Link2 className="h-12 w-12 mb-4 opacity-50 bg-slate-800 p-2 rounded-xl" />
<p>No vanity roles configured yet.</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{setups.map((setup, idx) => {
const r = roles.find(ro => ro.id.toString() === setup.role_id?.toString());
const c = channels.find(ch => ch.id.toString() === setup.log_channel_id?.toString());
return (
<div key={idx} className="bg-slate-900/50 p-6 rounded-2xl border border-slate-800 relative group">
<div className="flex items-center justify-between mb-4">
<h4 className="font-bold text-lg text-primary">{setup.vanity}</h4>
<Button
variant="destructive"
size="icon"
onClick={() => handleDelete(setup.vanity)}
disabled={saving}
className="opacity-0 group-hover:opacity-100 transition-opacity"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2">
<span className="text-slate-400">Role:</span>
<span className="text-white font-medium">
{r ? r.name : setup.role_id}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-slate-400">Log Channel:</span>
<span className="text-white font-medium">
{c ? `#${c.name}` : setup.log_channel_id}
</span>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
{/* Add New Setup */}
<div className="bg-[#141B2D] border border-slate-800 rounded-3xl shadow-xl p-8">
<h3 className="text-xl font-bold text-white mb-6">Add New Vanity Role</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="space-y-3">
<label className="text-sm font-bold text-slate-300">Vanity Code</label>
<Input
value={newVanity}
onChange={(e) => setNewVanity(e.target.value)}
placeholder="e.g. zyx"
className="h-12 bg-slate-900 border-slate-800"
/>
</div>
<div className="space-y-3">
<label className="text-sm font-bold text-slate-300">Reward Role</label>
<Select
value={newRole || ""}
onValueChange={(val) => setNewRole(val)}
>
<SelectTrigger className="w-full h-12 bg-slate-900 border-slate-800 text-left">
<SelectValue placeholder="Select a role..." />
</SelectTrigger>
<SelectContent className="bg-slate-900 border-slate-800">
{roles.map((r) => (
<SelectItem key={r.id} value={r.id.toString()} className="focus:bg-slate-800">
{r.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-3">
<label className="text-sm font-bold text-slate-300">Log Channel</label>
<Select
value={newChannel || ""}
onValueChange={(val) => setNewChannel(val)}
>
<SelectTrigger className="w-full h-12 bg-slate-900 border-slate-800 text-left">
<SelectValue placeholder="Select log channel..." />
</SelectTrigger>
<SelectContent className="bg-slate-900 border-slate-800">
{textChannels.map((c) => (
<SelectItem key={c.id} value={c.id.toString()} className="focus:bg-slate-800">
# {c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<Button
onClick={handleCreate}
disabled={saving}
className="w-full h-14 mt-8 font-bold gap-2 text-base"
>
{saving ? <RefreshCcw className="h-5 w-5 animate-spin" /> : <Plus className="h-5 w-5" />}
Add Vanity Configuration
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,201 @@
"use client";
import React, { useState } from "react";
import { ShieldCheck, RefreshCcw, Save, Hash, Settings, User } from "lucide-react";
import { toast } from "sonner";
import { api } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { VerificationConfig, DiscordChannel } from "@/types/api";
interface VerificationFormProps {
initialConfig: VerificationConfig;
channels: DiscordChannel[];
roles: any[]; // We don't have a rigid Role interface but they have id, name.
guildId: string;
}
export function VerificationForm({ initialConfig, channels, roles, guildId }: VerificationFormProps) {
const [config, setConfig] = useState<VerificationConfig>(initialConfig);
const [saving, setSaving] = useState(false);
const textChannels = channels.filter((c) => c.type === "0");
const handleSave = async () => {
setSaving(true);
const promise = api.updateVerification(guildId, config);
toast.promise(promise, {
loading: 'Saving Verification configuration...',
success: 'Verification settings saved successfully!',
error: 'Failed to update Verification config',
});
try {
await promise;
} catch (err: any) {
console.error(err);
} finally {
setSaving(false);
}
};
return (
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
<div className="lg:col-span-3 space-y-6">
<div className="bg-[#141B2D] border border-slate-800 rounded-3xl shadow-xl p-8 space-y-8">
{/* Main Toggle */}
<div className="flex items-center justify-between p-6 bg-slate-900/40 rounded-2xl border border-slate-800">
<div>
<h3 className="text-lg font-black text-white">Verification System</h3>
<p className="text-sm text-slate-400 mt-1">Enable or disable server verification.</p>
</div>
<Switch
checked={config.enabled}
onCheckedChange={(val) => setConfig({ ...config, enabled: val })}
className="scale-125"
/>
</div>
<div className={`space-y-6 transition-all duration-300 ${!config.enabled && "opacity-50 pointer-events-none"}`}>
{/* Channel Setup */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-3">
<label className="text-sm font-bold text-slate-300 flex items-center gap-2">
<Hash className="h-4 w-4 text-slate-400" />
Verification Channel
</label>
<Select
value={config.verification_channel_id || "none"}
onValueChange={(val) => setConfig({ ...config, verification_channel_id: val === "none" ? null : val })}
>
<SelectTrigger className="w-full h-12 bg-slate-900 border-slate-800 font-medium">
<SelectValue placeholder="Select a channel..." />
</SelectTrigger>
<SelectContent className="bg-slate-900 border-slate-800">
<SelectItem value="none" className="text-slate-400 focus:bg-slate-800">Not Set</SelectItem>
{textChannels.map((c) => (
<SelectItem key={c.id} value={c.id.toString()} className="focus:bg-slate-800">
# {c.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-slate-500">The channel where verifying users will use the command/buttons.</p>
</div>
<div className="space-y-3">
<label className="text-sm font-bold text-slate-300 flex items-center gap-2">
<Hash className="h-4 w-4 text-slate-400" />
Log Channel
</label>
<Select
value={config.log_channel_id || "none"}
onValueChange={(val) => setConfig({ ...config, log_channel_id: val === "none" ? null : val })}
>
<SelectTrigger className="w-full h-12 bg-slate-900 border-slate-800 font-medium">
<SelectValue placeholder="Select log channel..." />
</SelectTrigger>
<SelectContent className="bg-slate-900 border-slate-800">
<SelectItem value="none" className="text-slate-400 focus:bg-slate-800">Not Set</SelectItem>
{textChannels.map((c) => (
<SelectItem key={c.id} value={c.id.toString()} className="focus:bg-slate-800">
# {c.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-slate-500">Channel to send verification success/fail logs.</p>
</div>
</div>
{/* Role Setup & Method */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-3">
<label className="text-sm font-bold text-slate-300 flex items-center gap-2">
<User className="h-4 w-4 text-slate-400" />
Verified Role
</label>
<Select
value={config.verified_role_id || "none"}
onValueChange={(val) => setConfig({ ...config, verified_role_id: val === "none" ? null : val })}
>
<SelectTrigger className="w-full h-12 bg-slate-900 border-slate-800 font-medium">
<SelectValue placeholder="Select verified role..." />
</SelectTrigger>
<SelectContent className="bg-slate-900 border-slate-800">
<SelectItem value="none" className="text-slate-400 focus:bg-slate-800">Not Set</SelectItem>
{roles.map((r) => (
<SelectItem key={r.id} value={r.id.toString()} className="focus:bg-slate-800">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: `#${r.color.toString(16).padStart(6, '0')}` }} />
{r.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-slate-500">The role given upon successful verification.</p>
</div>
<div className="space-y-3">
<label className="text-sm font-bold text-slate-300 flex items-center gap-2">
<Settings className="h-4 w-4 text-slate-400" />
Verification Method
</label>
<Select
value={config.verification_method || "both"}
onValueChange={(val) => setConfig({ ...config, verification_method: val })}
>
<SelectTrigger className="w-full h-12 bg-slate-900 border-slate-800 font-medium">
<SelectValue placeholder="Select method..." />
</SelectTrigger>
<SelectContent className="bg-slate-900 border-slate-800">
<SelectItem value="captcha" className="focus:bg-slate-800">CAPTCHA Only</SelectItem>
<SelectItem value="button" className="focus:bg-slate-800">Button Click Only</SelectItem>
<SelectItem value="both" className="focus:bg-slate-800">Both Choices Setup</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-slate-500">Select how users will be verified.</p>
</div>
</div>
</div>
<Button
onClick={handleSave}
disabled={saving}
className="w-full h-14 text-base font-bold gap-2"
>
{saving ? <RefreshCcw className="h-5 w-5 animate-spin" /> : <Save className="h-5 w-5" />}
Save Configuration
</Button>
</div>
</div>
<div className="space-y-6">
<div className="bg-gradient-to-br from-primary/10 to-transparent border border-primary/20 rounded-3xl p-6 relative overflow-hidden group">
<div className="absolute -right-4 -top-4 opacity-[0.03] group-hover:scale-110 transition-transform">
<ShieldCheck className="h-32 w-32 text-primary" />
</div>
<h3 className="text-sm font-bold text-primary mb-2">How It Works</h3>
<p className="text-xs text-slate-400 leading-relaxed mb-4">
Zyrox Verification ensures that no unauthorized bots or malicious users enter your server unverified.
</p>
<ul className="text-xs text-slate-500 space-y-2">
<li> The bot will create a panel in your Verification Channel.</li>
<li> Unverified members must click &quot;Verify&quot;.</li>
<li> Captcha presents a unique image sequence.</li>
<li> Upon success, role is assigned.</li>
</ul>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,203 @@
"use client";
import React, { useState } from "react";
import {
Save,
MessageSquare,
Type,
LayoutTemplate,
RefreshCcw
} from "lucide-react";
import { toast } from "sonner";
import { api } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Select } from "@/components/ui/select";
import { WelcomeConfig, DiscordChannel } from "@/types/api";
interface WelcomeFormProps {
initialConfig: WelcomeConfig;
channels: DiscordChannel[];
guildId: string;
}
export function WelcomeForm({ initialConfig, channels, guildId }: WelcomeFormProps) {
const [config, setConfig] = useState<WelcomeConfig>(initialConfig);
const [saving, setSaving] = useState(false);
const handleSave = async () => {
setSaving(true);
const promise = api.updateWelcome(guildId, config);
toast.promise(promise, {
loading: 'Saving welcome configuration...',
success: 'Welcome settings saved successfully!',
error: 'Failed to update welcome settings',
});
try {
await promise;
} catch (err: any) {
console.error(err);
} finally {
setSaving(false);
}
};
const channelOptions = channels.map(c => ({
value: c.id.toString(),
label: `#${c.name}`
}));
const typeOptions = [
{ value: "simple", label: "Simple Text Message" },
{ value: "embed", label: "Rich Embed Message" }
];
return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="lg:col-span-2 space-y-6">
<div className="bg-[#141B2D] border border-slate-800 rounded-3xl shadow-xl p-8 space-y-6">
<div className="space-y-4">
<div>
<label className="text-xs font-black uppercase text-slate-500 tracking-widest pl-1">Response Type</label>
<Select
value={config.welcome_type || "simple"}
onValueChange={(val) => setConfig({ ...config, welcome_type: val })}
options={typeOptions}
className="mt-2"
/>
</div>
<div>
<label className="text-xs font-black uppercase text-slate-500 tracking-widest pl-1">Welcome Channel</label>
<Select
value={config.channel_id || ""}
onValueChange={(val) => setConfig({ ...config, channel_id: val })}
options={channelOptions}
placeholder="Select a channel..."
className="mt-2"
/>
</div>
{config.welcome_type === "simple" && (
<div>
<label className="text-xs font-black uppercase text-slate-500 tracking-widest pl-1">Message Content</label>
<textarea
value={config.welcome_message || ""}
onChange={(e) => setConfig({ ...config, welcome_message: e.target.value })}
placeholder="Welcome {user} to {server_name}!"
className="w-full mt-2 bg-[#0f172a] border border-slate-800 rounded-xl p-4 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 text-white min-h-[120px]"
/>
</div>
)}
{config.welcome_type === "embed" && (
<div className="space-y-4 pt-4 border-t border-slate-800/50">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-xs font-black uppercase text-slate-500 tracking-widest pl-1">Embed Title</label>
<input
type="text"
value={config.embed_data?.title || ""}
onChange={(e) => setConfig({ ...config, embed_data: { ...config.embed_data, title: e.target.value }})}
className="w-full mt-2 bg-[#0f172a] border border-slate-800 rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 text-white"
placeholder="Welcome to the server!"
/>
</div>
<div>
<label className="text-xs font-black uppercase text-slate-500 tracking-widest pl-1">Embed Color (Hex)</label>
<input
type="text"
value={config.embed_data?.color || ""}
onChange={(e) => setConfig({ ...config, embed_data: { ...config.embed_data, color: e.target.value }})}
className="w-full mt-2 bg-[#0f172a] border border-slate-800 rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 text-white"
placeholder="#3498db"
/>
</div>
</div>
<div>
<label className="text-xs font-black uppercase text-slate-500 tracking-widest pl-1">Embed Description</label>
<textarea
value={config.embed_data?.description || ""}
onChange={(e) => setConfig({ ...config, embed_data: { ...config.embed_data, description: e.target.value }})}
placeholder="We're glad to have you here, {user}!"
className="w-full mt-2 bg-[#0f172a] border border-slate-800 rounded-xl p-4 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 text-white min-h-[100px]"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-xs font-black uppercase text-slate-500 tracking-widest pl-1">Thumbnail URL</label>
<input
type="text"
value={config.embed_data?.thumbnail || ""}
onChange={(e) => setConfig({ ...config, embed_data: { ...config.embed_data, thumbnail: e.target.value }})}
className="w-full mt-2 bg-[#0f172a] border border-slate-800 rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 text-white"
placeholder="{user_avatar} or https://..."
/>
</div>
<div>
<label className="text-xs font-black uppercase text-slate-500 tracking-widest pl-1">Image URL</label>
<input
type="text"
value={config.embed_data?.image || ""}
onChange={(e) => setConfig({ ...config, embed_data: { ...config.embed_data, image: e.target.value }})}
className="w-full mt-2 bg-[#0f172a] border border-slate-800 rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 text-white"
placeholder="https://..."
/>
</div>
</div>
</div>
)}
</div>
<Button
onClick={handleSave}
disabled={saving}
className="w-full h-14 text-base font-bold gap-2"
>
{saving ? <RefreshCcw className="h-5 w-5 animate-spin" /> : <Save className="h-5 w-5" />}
Save Configuration
</Button>
</div>
</div>
<div className="space-y-6">
<div className="bg-[#141B2D] border border-slate-800 rounded-3xl p-6 shadow-xl">
<h3 className="text-sm font-black uppercase text-slate-500 tracking-widest mb-4">Variables</h3>
<div className="space-y-2 text-xs text-slate-400 font-mono bg-slate-900/50 p-4 rounded-2xl border border-white/5">
<p className="flex justify-between hover:text-white transition-colors"><span>{'{user}'}</span> <span>@Username</span></p>
<p className="flex justify-between hover:text-white transition-colors"><span>{'{user_name}'}</span> <span>Username</span></p>
<p className="flex justify-between hover:text-white transition-colors"><span>{'{server_name}'}</span> <span>Server Name</span></p>
<p className="flex justify-between hover:text-white transition-colors"><span>{'{server_membercount}'}</span> <span>Total Members</span></p>
<p className="border-t border-slate-800 my-2 pt-2 flex justify-between hover:text-white transition-colors"><span>{'{user_avatar}'}</span> <span>Avatar Image</span></p>
<p className="flex justify-between hover:text-white transition-colors"><span>{'{server_icon}'}</span> <span>Server Logo</span></p>
</div>
<p className="text-[10px] text-slate-500 italic text-center mt-4">You can use these variables in both message content and embeds to personalize welcomes.</p>
</div>
<div className="bg-[#141B2D] border border-slate-800 rounded-3xl p-6 shadow-xl">
<h3 className="text-sm font-black uppercase text-slate-500 tracking-widest mb-4">Auto Setup</h3>
<Button onClick={() => setConfig({
...config,
welcome_type: "embed",
embed_data: {
...config.embed_data,
title: "Welcome to {server_name}!",
description: "Hi {user}, we're glad you joined! You are member #{server_membercount}.",
color: "2f3136",
thumbnail: "{user_avatar}"
}
})}
variant="outline"
className="w-full border-primary/50 hover:bg-primary/20 text-primary"
>
<LayoutTemplate className="w-4 h-4 mr-2" />
Apply Default Template
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,124 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
import {
ShieldCheck,
Ticket,
BarChart4,
FileText,
Settings,
Layers,
Sword,
Activity,
SmilePlus,
Rocket,
Shield,
MessageSquare,
Sparkles,
Link as LinkIcon,
Bot,
ChevronLeft,
ChevronRight,
Volume2,
Link2,
Zap,
Mic,
Mail
} from "lucide-react";
interface Tab {
name: string;
href: string;
icon: any;
}
export function GuildTabs({ guildId }: { guildId: string }) {
const pathname = usePathname();
const scrollContainerRef = React.useRef<HTMLDivElement>(null);
const scroll = (direction: "left" | "right") => {
if (scrollContainerRef.current) {
const scrollAmount = 200;
scrollContainerRef.current.scrollBy({
left: direction === "left" ? -scrollAmount : scrollAmount,
behavior: "smooth",
});
}
};
const tabs: Tab[] = [
{ name: "Overview", href: `/dashboard/guild/${guildId}`, icon: Layers },
{ name: "Anti-Nuke", href: `/dashboard/guild/${guildId}/antinuke`, icon: Sword },
{ name: "Automod", href: `/dashboard/guild/${guildId}/automod`, icon: ShieldCheck },
{ name: "Tickets", href: `/dashboard/guild/${guildId}/tickets`, icon: Ticket },
{ name: "Verification", href: `/dashboard/guild/${guildId}/verification`, icon: Shield },
{ name: "Welcome", href: `/dashboard/guild/${guildId}/welcome`, icon: SmilePlus },
{ name: "Invites", href: `/dashboard/guild/${guildId}/invites`, icon: LinkIcon },
{ name: "Auto Role", href: `/dashboard/guild/${guildId}/autorole`, icon: Bot },
{ name: "Reaction Roles", href: `/dashboard/guild/${guildId}/reactionroles`, icon: Activity },
{ name: "Join to Create", href: `/dashboard/guild/${guildId}/j2c`, icon: Mic },
{ name: "Voice Role", href: `/dashboard/guild/${guildId}/invcrole`, icon: Volume2 },
{ name: "Vanity Roles", href: `/dashboard/guild/${guildId}/vanityroles`, icon: Link2 },
{ name: "Auto React", href: `/dashboard/guild/${guildId}/autoreact`, icon: Zap },
{ name: "Custom Roles", href: `/dashboard/guild/${guildId}/customroles`, icon: Sparkles },
{ name: "Join DM", href: `/dashboard/guild/${guildId}/joindm`, icon: Mail },
{ name: "Leveling", href: `/dashboard/guild/${guildId}/leveling`, icon: BarChart4 },
{ name: "Logging", href: `/dashboard/guild/${guildId}/logging`, icon: FileText },
{ name: "Settings", href: `/dashboard/guild/${guildId}/settings`, icon: Settings },
].filter(tab => tab.href); // Filter out any undefined hrefs
return (
<div className="relative group/tabs flex items-center w-full mb-8 sticky top-[64px] z-20 transition-all duration-300">
{/* Scroll Arrows */}
<button
onClick={() => scroll("left")}
className="absolute left-[-16px] z-30 p-2 rounded-full bg-slate-800 border border-slate-700 text-white shadow-xl opacity-0 group-hover/tabs:opacity-100 transition-opacity hover:bg-primary"
>
<ChevronLeft className="h-4 w-4" />
</button>
{/* Left Fade */}
<div className="absolute left-0 top-0 bottom-0 w-16 bg-gradient-to-r from-[#0f172a] to-transparent z-10 pointer-events-none opacity-0 group-hover/tabs:opacity-100 transition-opacity" />
<div
ref={scrollContainerRef}
className="flex gap-2 p-1.5 bg-[#141B2D]/40 border border-slate-800/40 rounded-[20px] overflow-x-auto no-scrollbar w-full scroll-smooth shadow-2xl shadow-black/20"
>
{tabs.map((tab) => {
const isActive = pathname === tab.href;
return (
<Link
key={tab.name}
href={tab.href}
className="shrink-0"
>
<div className={cn(
"flex items-center gap-2.5 px-6 py-2.5 rounded-[14px] text-[11px] font-black uppercase tracking-wider transition-all duration-500 whitespace-nowrap",
"hover:bg-slate-800/60 hover:text-white",
isActive
? "bg-primary text-white shadow-lg shadow-primary/30 ring-1 ring-white/10"
: "text-slate-400 bg-slate-900/40 border border-slate-800/30 hover:border-slate-700/50"
)}>
<tab.icon className={cn("h-3.5 w-3.5", isActive ? "animate-pulse" : "opacity-50")} />
{tab.name}
</div>
</Link>
);
})}
</div>
{/* Right Fade */}
<div className="absolute right-0 top-0 bottom-0 w-16 bg-gradient-to-l from-[#0f172a] to-transparent z-10 pointer-events-none opacity-0 group-hover/tabs:opacity-100 transition-opacity" />
<button
onClick={() => scroll("right")}
className="absolute right-[-16px] z-30 p-2 rounded-full bg-slate-800 border border-slate-700 text-white shadow-xl opacity-0 group-hover/tabs:opacity-100 transition-opacity hover:bg-primary"
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
);
}

View File

@@ -0,0 +1,55 @@
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 whitespace-nowrap rounded-xl text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 active:scale-95",
{
variants: {
variant: {
default: "bg-primary text-white hover:bg-primary-hover shadow-lg shadow-primary/20",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90 shadow-lg shadow-destructive/20",
outline:
"border border-slate-700 bg-transparent hover:bg-slate-800 hover:text-white hover:border-slate-600",
secondary:
"bg-slate-800 text-slate-200 hover:bg-slate-700 hover:text-white",
ghost: "hover:bg-slate-800 hover:text-white",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-11 px-6 py-2",
sm: "h-9 rounded-lg px-3",
lg: "h-12 rounded-2xl px-8",
icon: "h-11 w-11",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@@ -0,0 +1,78 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-3xl border border-slate-800 bg-[#141B2D] text-white shadow-xl shadow-black/20",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-xl font-bold text-white tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-slate-400", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,24 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-12 w-full rounded-xl border border-slate-800 bg-slate-900/50 px-4 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-slate-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50 transition-all",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@@ -0,0 +1,19 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Label = React.forwardRef<
HTMLLabelElement,
React.LabelHTMLAttributes<HTMLLabelElement>
>(({ className, ...props }, ref) => (
<label
ref={ref}
className={cn(
"text-sm font-bold leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-slate-300",
className
)}
{...props}
/>
))
Label.displayName = "Label"
export { Label }

View File

@@ -0,0 +1,178 @@
"use client"
import * as React from "react"
import { ChevronDown, Check } from "lucide-react"
import { cn } from "@/lib/utils"
// Context for sub-components
const SelectContext = React.createContext<{
value: string
onValueChange: (value: string) => void
isOpen: boolean
setIsOpen: (open: boolean) => void
} | null>(null)
export interface SelectOption {
value: string
label: string
}
interface SelectProps {
children?: React.ReactNode
value: string
onValueChange: (value: string) => void
// Legacy props compatibility
options?: SelectOption[]
placeholder?: string
className?: string
disabled?: boolean
}
const Select = ({ children, value, onValueChange, options, placeholder, className, disabled }: SelectProps) => {
const [isOpen, setIsOpen] = React.useState(false)
const containerRef = React.useRef<HTMLDivElement>(null)
React.useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false)
}
}
document.addEventListener("mousedown", handleClickOutside)
return () => document.removeEventListener("mousedown", handleClickOutside)
}, [])
// If options are provided, use the legacy rendering
if (options) {
const selectedOption = options.find((opt) => opt.value === value)
return (
<div className={cn("relative w-full", className)} ref={containerRef}>
<button
type="button"
disabled={disabled}
onClick={() => setIsOpen(!isOpen)}
className={cn(
"flex h-10 w-full items-center justify-between rounded-xl border border-slate-800 bg-slate-900/50 px-4 py-2 text-sm transition-all focus:outline-none focus:ring-2 focus:ring-primary/50 disabled:cursor-not-allowed disabled:opacity-50",
isOpen && "ring-2 ring-primary/50 border-slate-700"
)}
>
<span className={cn("truncate", !selectedOption && "text-slate-500")}>
{selectedOption ? selectedOption.label : placeholder}
</span>
<ChevronDown className={cn("h-4 w-4 text-slate-500 transition-transform duration-200", isOpen && "rotate-180")} />
</button>
{isOpen && (
<div className="absolute top-full z-50 mt-2 w-full overflow-hidden rounded-xl border border-slate-800 bg-[#141B2D] p-1 shadow-2xl animate-in fade-in zoom-in-95 duration-200">
<div className="max-h-60 overflow-y-auto overflow-x-hidden no-scrollbar">
{options.map((option) => (
<button
key={option.value}
type="button"
onClick={() => {
onValueChange(option.value)
setIsOpen(false)
}}
className={cn(
"flex w-full items-center justify-between rounded-lg px-3 py-2 text-sm transition-colors hover:bg-slate-800",
value === option.value ? "bg-primary text-white" : "text-slate-300"
)}
>
<span className="truncate">{option.label}</span>
{value === option.value && <Check className="h-4 w-4" />}
</button>
))}
</div>
</div>
)}
</div>
)
}
// Otherwise, use the sub-component pattern
return (
<SelectContext.Provider value={{ value, onValueChange, isOpen, setIsOpen }}>
<div className={cn("relative w-full", className)} ref={containerRef}>
{children}
</div>
</SelectContext.Provider>
)
}
const SelectTrigger = React.forwardRef<
HTMLButtonElement,
React.ButtonHTMLAttributes<HTMLButtonElement>
>(({ className, children, ...props }, ref) => {
const context = React.useContext(SelectContext)
if (!context) return null
return (
<button
ref={ref}
type="button"
onClick={() => context.setIsOpen(!context.isOpen)}
className={cn(
"flex h-10 w-full items-center justify-between rounded-xl border border-slate-800 bg-slate-900/50 px-4 py-2 text-sm transition-all focus:outline-none focus:ring-2 focus:ring-primary/50 disabled:cursor-not-allowed disabled:opacity-50",
context.isOpen && "ring-2 ring-primary/50 border-slate-700",
className
)}
{...props}
>
{children}
<ChevronDown className={cn("h-4 w-4 text-slate-500 transition-transform duration-200", context.isOpen && "rotate-180")} />
</button>
)
})
SelectTrigger.displayName = "SelectTrigger"
const SelectValue = ({ placeholder, className }: { placeholder?: string, className?: string }) => {
const context = React.useContext(SelectContext)
if (!context) return null
return <span className={cn("truncate", !context.value && "text-slate-500", className)}>{context.value || placeholder}</span>
}
const SelectContent = ({ children, className }: { children: React.ReactNode, className?: string }) => {
const context = React.useContext(SelectContext)
if (!context || !context.isOpen) return null
return (
<div className={cn("absolute top-full z-50 mt-2 w-full overflow-hidden rounded-xl border border-slate-800 bg-[#141B2D] p-1 shadow-2xl animate-in fade-in zoom-in-95 duration-200", className)}>
<div className="max-h-60 overflow-y-auto overflow-x-hidden no-scrollbar">
{children}
</div>
</div>
)
}
const SelectItem = React.forwardRef<
HTMLButtonElement,
React.ButtonHTMLAttributes<HTMLButtonElement> & { value: string }
>(({ className, children, value, ...props }, ref) => {
const context = React.useContext(SelectContext)
if (!context) return null
const isSelected = context.value === value
return (
<button
ref={ref}
type="button"
onClick={() => {
context.onValueChange(value)
context.setIsOpen(false)
}}
className={cn(
"flex w-full items-center justify-between rounded-lg px-3 py-2 text-sm transition-colors hover:bg-slate-800",
isSelected ? "bg-primary text-white" : "text-slate-300",
className
)}
{...props}
>
<span className="truncate">{children}</span>
{isSelected && <Check className="h-4 w-4" />}
</button>
)
})
SelectItem.displayName = "SelectItem"
export { Select, SelectTrigger, SelectValue, SelectContent, SelectItem }

View File

@@ -0,0 +1,25 @@
import { Toaster as Sonner } from "sonner";
type ToasterProps = React.ComponentProps<typeof Sonner>;
const Toaster = ({ ...props }: ToasterProps) => {
return (
<Sonner
className="toaster group"
toastOptions={{
classNames: {
toast: "group toast group-[.toaster]:bg-[#141B2D] group-[.toaster]:text-slate-200 group-[.toaster]:border-slate-800 group-[.toaster]:shadow-2xl group-[.toaster]:rounded-2xl group-[.toaster]:p-4",
description: "group-[.toast]:text-slate-500 group-[.toast]:text-[11px] group-[.toast]:font-medium",
actionButton: "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton: "group-[.toast]:bg-slate-800 group-[.toast]:text-slate-400",
success: "group-[.toaster]:border-emerald-500/50 group-[.toaster]:bg-emerald-500/5",
error: "group-[.toaster]:border-red-500/50 group-[.toaster]:bg-red-500/5",
loading: "group-[.toaster]:border-primary/50 group-[.toaster]:bg-primary/5",
},
}}
{...props}
/>
);
};
export { Toaster };

View File

@@ -0,0 +1,28 @@
"use client";
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-[#0f172a] disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-slate-800",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-white shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

View File

@@ -0,0 +1,116 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.TableHTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b border-slate-800", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t border-slate-800 bg-slate-900/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b border-slate-800 transition-colors hover:bg-slate-800/50 data-[state=selected]:bg-slate-800",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-bold text-slate-400 [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0 text-white", className)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-slate-500", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -0,0 +1,23 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-xl border border-slate-800 bg-slate-900/50 px-3 py-2 text-sm text-white placeholder:text-slate-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Textarea.displayName = "Textarea"
export { Textarea }