first commit
This commit is contained in:
7
dashboard/components/auth-provider.tsx
Normal file
7
dashboard/components/auth-provider.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
return <SessionProvider>{children}</SessionProvider>;
|
||||
}
|
||||
230
dashboard/components/dashboard/admin-content.tsx
Normal file
230
dashboard/components/dashboard/admin-content.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
220
dashboard/components/dashboard/antinuke-form.tsx
Normal file
220
dashboard/components/dashboard/antinuke-form.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
193
dashboard/components/dashboard/automod-form.tsx
Normal file
193
dashboard/components/dashboard/automod-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
204
dashboard/components/dashboard/autorole-form.tsx
Normal file
204
dashboard/components/dashboard/autorole-form.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
149
dashboard/components/dashboard/customroles-form.tsx
Normal file
149
dashboard/components/dashboard/customroles-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
91
dashboard/components/dashboard/form-elements.tsx
Normal file
91
dashboard/components/dashboard/form-elements.tsx
Normal 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>
|
||||
);
|
||||
208
dashboard/components/dashboard/j2c-form.tsx
Normal file
208
dashboard/components/dashboard/j2c-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
180
dashboard/components/dashboard/leveling-form.tsx
Normal file
180
dashboard/components/dashboard/leveling-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
184
dashboard/components/dashboard/logging-form.tsx
Normal file
184
dashboard/components/dashboard/logging-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
60
dashboard/components/dashboard/metric-card.tsx
Normal file
60
dashboard/components/dashboard/metric-card.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
39
dashboard/components/dashboard/page-header.tsx
Normal file
39
dashboard/components/dashboard/page-header.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
233
dashboard/components/dashboard/reactionroles-form.tsx
Normal file
233
dashboard/components/dashboard/reactionroles-form.tsx
Normal 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 "Add to Active Listeners".</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
90
dashboard/components/dashboard/server-card.tsx
Normal file
90
dashboard/components/dashboard/server-card.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
109
dashboard/components/dashboard/settings-form.tsx
Normal file
109
dashboard/components/dashboard/settings-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
443
dashboard/components/dashboard/tickets-form.tsx
Normal file
443
dashboard/components/dashboard/tickets-form.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
184
dashboard/components/dashboard/vanityrole-form.tsx
Normal file
184
dashboard/components/dashboard/vanityrole-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
201
dashboard/components/dashboard/verification-form.tsx
Normal file
201
dashboard/components/dashboard/verification-form.tsx
Normal 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 "Verify".</li>
|
||||
<li>• Captcha presents a unique image sequence.</li>
|
||||
<li>• Upon success, role is assigned.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
203
dashboard/components/dashboard/welcome-form.tsx
Normal file
203
dashboard/components/dashboard/welcome-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
124
dashboard/components/guild-tabs.tsx
Normal file
124
dashboard/components/guild-tabs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
dashboard/components/ui/button.tsx
Normal file
55
dashboard/components/ui/button.tsx
Normal 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 }
|
||||
78
dashboard/components/ui/card.tsx
Normal file
78
dashboard/components/ui/card.tsx
Normal 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 }
|
||||
24
dashboard/components/ui/input.tsx
Normal file
24
dashboard/components/ui/input.tsx
Normal 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 }
|
||||
19
dashboard/components/ui/label.tsx
Normal file
19
dashboard/components/ui/label.tsx
Normal 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 }
|
||||
178
dashboard/components/ui/select.tsx
Normal file
178
dashboard/components/ui/select.tsx
Normal 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 }
|
||||
25
dashboard/components/ui/sonner.tsx
Normal file
25
dashboard/components/ui/sonner.tsx
Normal 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 };
|
||||
28
dashboard/components/ui/switch.tsx
Normal file
28
dashboard/components/ui/switch.tsx
Normal 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 }
|
||||
116
dashboard/components/ui/table.tsx
Normal file
116
dashboard/components/ui/table.tsx
Normal 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,
|
||||
}
|
||||
23
dashboard/components/ui/textarea.tsx
Normal file
23
dashboard/components/ui/textarea.tsx
Normal 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 }
|
||||
Reference in New Issue
Block a user