Files
void-sentinel/web/components/LevelingEditor.tsx

1275 lines
72 KiB
TypeScript

"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import {
Plus,
Trash2,
GripVertical,
ChevronDown,
ChevronUp,
Save,
X,
Loader2,
AlertCircle,
Check,
Search,
ArrowRight,
} from "lucide-react";
interface Role {
role_id: string;
role_name: string;
color: number;
position: number;
}
interface TrackRole {
role_name: string;
role_id: string;
level: number;
}
interface Track {
name: string;
roles: TrackRole[];
}
interface LevelBridge {
in_role_id: string;
out_role_id: string;
}
interface LevelingEditorProps {
guildId: string;
}
function intToHexColor(color: number | undefined): string {
if (color === undefined || color === null || color === 0) return "#99AAB5"; // Discord default gray
return `#${color.toString(16).padStart(6, "0")}`;
}
export default function LevelingEditor({ guildId }: LevelingEditorProps) {
const [tracks, setTracks] = useState<Track[]>([]);
const [guildRoles, setGuildRoles] = useState<Role[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [expandedTracks, setExpandedTracks] = useState<Set<string>>(new Set());
const [hasChanges, setHasChanges] = useState(false);
const [newTrackName, setNewTrackName] = useState("");
const [showAddTrack, setShowAddTrack] = useState(false);
const [draggedItem, setDraggedItem] = useState<{
trackIndex: number;
roleIndex: number;
} | null>(null);
const [openRoleDropdown, setOpenRoleDropdown] = useState<string | null>(null);
const [openInitialRoleDropdown, setOpenInitialRoleDropdown] = useState<string | null>(null);
const [roleSearchQuery, setRoleSearchQuery] = useState("");
const [initialRoleSearchQuery, setInitialRoleSearchQuery] = useState("");
const [deletedTracks, setDeletedTracks] = useState<string[]>([]);
const [originalTrackNames, setOriginalTrackNames] = useState<Set<string>>(new Set());
// Level Bridger State
const [bridges, setBridges] = useState<LevelBridge[]>([]);
const [deletedBridges, setDeletedBridges] = useState<string[]>([]); // in_role_ids
const [newBridgeIn, setNewBridgeIn] = useState<string | null>(null);
const [newBridgeOut, setNewBridgeOut] = useState<string | null>(null);
const [showAddBridge, setShowAddBridge] = useState(false);
// Bridge custom dropdown state
const [openBridgeInDropdown, setOpenBridgeInDropdown] = useState(false);
const [openBridgeOutDropdown, setOpenBridgeOutDropdown] = useState(false);
const [bridgeInSearchQuery, setBridgeInSearchQuery] = useState("");
const [bridgeOutSearchQuery, setBridgeOutSearchQuery] = useState("");
// Dropdown directions
const [roleDropdownDirection, setRoleDropdownDirection] = useState<"up" | "down">("down");
const [initialRoleDropdownDirection, setInitialRoleDropdownDirection] = useState<"up" | "down">("down");
const [bridgeInDropdownDirection, setBridgeInDropdownDirection] = useState<"up" | "down">("down");
const [bridgeOutDropdownDirection, setBridgeOutDropdownDirection] = useState<"up" | "down">("down");
const dropdownRef = useRef<HTMLDivElement>(null);
const initialDropdownRef = useRef<HTMLDivElement>(null);
const bridgeInDropdownRef = useRef<HTMLDivElement>(null);
const bridgeOutDropdownRef = useRef<HTMLDivElement>(null);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setOpenRoleDropdown(null);
setRoleSearchQuery("");
}
if (initialDropdownRef.current && !initialDropdownRef.current.contains(event.target as Node)) {
setOpenInitialRoleDropdown(null);
setInitialRoleSearchQuery("");
}
if (bridgeInDropdownRef.current && !bridgeInDropdownRef.current.contains(event.target as Node)) {
setOpenBridgeInDropdown(false);
setBridgeInSearchQuery("");
}
if (bridgeOutDropdownRef.current && !bridgeOutDropdownRef.current.contains(event.target as Node)) {
setOpenBridgeOutDropdown(false);
setBridgeOutSearchQuery("");
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
// Fetch tracks and roles
const fetchData = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [tracksRes, rolesRes, bridgeRes] = await Promise.all([
fetch(`/api/guilds/${guildId}/tracks`),
fetch(`/api/guilds/${guildId}/roles`),
fetch(`/api/guilds/${guildId}/level_bridger`),
]);
if (!tracksRes.ok) {
throw new Error("Failed to fetch tracks");
}
if (!rolesRes.ok) {
throw new Error("Failed to fetch roles");
}
// Optional: check bridgeRes but don't fail hard if it doesn't exist yet (though we just added it)
const tracksData = await tracksRes.json();
const rolesData = await rolesRes.json();
const bridgesData = await bridgeRes.json().catch(() => []); // Default to empty if fails
// Create a lookup map for guild roles to get names
const rolesMap = new Map((Array.isArray(rolesData) ? rolesData : []).map((r: Role) => [r.role_id, r]));
const transformedTracks: Track[] = [];
// Helper to transform roles and find names from guild roles
const transformRoles = (rawRoles: any[]): TrackRole[] => {
if (!Array.isArray(rawRoles)) return [];
return rawRoles.map((r) => {
// Handle role_id (new format) or id (old format)
// Note: sending role_id as u64 might result in precision loss if parsed as number
// but we convert to string here matching the rolesMap keys
const roleId = String(r.role_id || r.id);
const guildRole = rolesMap.get(roleId);
return {
role_id: roleId,
// Use name from guild roles, fall back to API name
role_name: guildRole?.role_name || r.role_name || r.name || "Unknown Role",
level: r.level
};
});
};
// Transform tracks data
if (Array.isArray(tracksData)) {
tracksData.forEach((track: any) => {
// Support new format: { track_name: "...", roles: [...] }
if (track.track_name) {
transformedTracks.push({
name: track.track_name,
roles: transformRoles(track.roles)
});
} else {
// Support legacy dictionary format in array
// [{ "TrackName": [...] }]
Object.entries(track).forEach(([name, roles]) => {
if (name === "track_name" || name === "roles") return;
transformedTracks.push({
name,
roles: transformRoles(roles as any[])
});
});
}
});
} else if (tracksData && typeof tracksData === 'object') {
// Support legacy dictionary format: { "TrackName": [...] }
Object.entries(tracksData).forEach(([name, roles]) => {
transformedTracks.push({
name,
roles: transformRoles(roles as any[])
});
});
}
setTracks(transformedTracks);
setGuildRoles(rolesData || []);
setOriginalTrackNames(new Set(transformedTracks.map(t => t.name)));
setDeletedTracks([]);
setBridges(Array.isArray(bridgesData) ? bridgesData : []);
setDeletedBridges([]);
// All tracks start collapsed
} catch (err) {
setError(err instanceof Error ? err.message : "An error occurred");
} finally {
setLoading(false);
}
}, [guildId]);
useEffect(() => {
fetchData();
}, [fetchData]);
// Save changes - delete removed tracks and update remaining
const saveChanges = async () => {
setSaving(true);
setError(null);
setSuccess(null);
try {
// First, delete any tracks that were removed
const deletePromises = deletedTracks.map(async (trackName) => {
const response = await fetch(`/api/guilds/${guildId}/tracks`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ track_name: trackName }),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || `Failed to delete track "${trackName}"`);
}
});
await Promise.all(deletePromises);
// Level Bridger Deletions
const deleteBridgePromises = deletedBridges.map(async (inRoleId) => {
const response = await fetch(`/api/guilds/${guildId}/level_bridger`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ in_role_id: inRoleId }),
});
if (!response.ok) {
console.error(`Failed to delete bridge for ${inRoleId}`);
}
});
await Promise.all(deleteBridgePromises);
// Then, update/create each track with the new format:
// { track_name: string, roles: [{ role_id: number, level: number }, ...] }
const updatePromises = tracks.map(async (track) => {
const payload = {
track_name: track.name,
roles: track.roles.map((role) => ({
role_id: role.role_id,
level: role.level
}))
};
const response = await fetch(`/api/guilds/${guildId}/tracks`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || `Failed to save track "${track.name}"`);
}
});
await Promise.all(updatePromises);
// Level Bridger Updates (PUT)
const updateBridgePromises = bridges.map(async (bridge) => {
const response = await fetch(`/api/guilds/${guildId}/level_bridger`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(bridge),
});
if (!response.ok) {
console.error(`Failed to save bridge ${bridge.in_role_id} -> ${bridge.out_role_id}`);
}
});
await Promise.all(updateBridgePromises);
// Update tracking state
setOriginalTrackNames(new Set(tracks.map(t => t.name)));
setDeletedTracks([]);
setDeletedBridges([]);
setSuccess("Changes saved successfully!");
setHasChanges(false);
setTimeout(() => setSuccess(null), 3000);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to save");
} finally {
setSaving(false);
}
};
// Toggle track expansion
const toggleTrack = (trackName: string) => {
setExpandedTracks((prev) => {
const newSet = new Set(prev);
if (newSet.has(trackName)) {
newSet.delete(trackName);
} else {
newSet.add(trackName);
}
return newSet;
});
};
// Add new track
const addTrack = () => {
if (!newTrackName.trim()) return;
if (tracks.some((t) => t.name === newTrackName.trim())) {
setError("Track with this name already exists");
return;
}
setTracks([...tracks, { name: newTrackName.trim(), roles: [] }]);
setExpandedTracks((prev) => new Set([...prev, newTrackName.trim()]));
setNewTrackName("");
setShowAddTrack(false);
setHasChanges(true);
};
// Delete track
const deleteTrack = (trackIndex: number) => {
const trackName = tracks[trackIndex].name;
const newTracks = [...tracks];
newTracks.splice(trackIndex, 1);
setTracks(newTracks);
// If this track existed on the server, mark it for deletion
if (originalTrackNames.has(trackName)) {
setDeletedTracks(prev => [...prev, trackName]);
}
setHasChanges(true);
};
// Add role to track (only non-initial roles, level 5+)
const addRoleToTrack = (trackIndex: number, roleId: string) => {
const role = guildRoles.find((r) => r.role_id === roleId);
if (!role) return;
const newTracks = [...tracks];
const existingLevels = newTracks[trackIndex].roles.filter(r => r.level > 0).map((r) => r.level);
const nextLevel = existingLevels.length === 0 ? 5 : Math.max(...existingLevels) + 5;
newTracks[trackIndex].roles.push({
role_id: role.role_id,
role_name: role.role_name,
level: nextLevel,
});
// Sort by level
newTracks[trackIndex].roles.sort((a, b) => a.level - b.level);
setTracks(newTracks);
setHasChanges(true);
};
// Set or update initial role (level 0)
const setInitialRole = (trackIndex: number, roleId: string) => {
const role = guildRoles.find((r) => r.role_id === roleId);
if (!role) return;
const newTracks = [...tracks];
// Remove existing initial role if any
newTracks[trackIndex].roles = newTracks[trackIndex].roles.filter(r => r.level !== 0);
// Add new initial role at the beginning
newTracks[trackIndex].roles.unshift({
role_id: role.role_id,
role_name: role.role_name,
level: 0,
});
setTracks(newTracks);
setHasChanges(true);
setOpenInitialRoleDropdown(null);
setInitialRoleSearchQuery("");
};
// Get initial role for a track
const getInitialRole = (track: Track) => {
return track.roles.find(r => r.level === 0);
};
// Remove role from track (can't remove initial role - level 0)
const removeRoleFromTrack = (trackIndex: number, roleIndex: number) => {
const newTracks = [...tracks];
const role = newTracks[trackIndex].roles[roleIndex];
// Prevent removing initial role
if (role.level === 0) return;
newTracks[trackIndex].roles.splice(roleIndex, 1);
setTracks(newTracks);
setHasChanges(true);
};
// Update role level
const updateRoleLevel = (
trackIndex: number,
roleIndex: number,
level: number
) => {
const newTracks = [...tracks];
newTracks[trackIndex].roles[roleIndex].level = Math.max(1, level);
// Don't sort immediately to allow typing
setTracks(newTracks);
setHasChanges(true);
};
// Sort roles by level (called on blur)
const sortTrackRoles = (trackIndex: number) => {
const newTracks = [...tracks];
newTracks[trackIndex].roles.sort((a, b) => a.level - b.level);
setTracks(newTracks);
};
// Drag and drop handlers
const handleDragStart = (trackIndex: number, roleIndex: number) => {
setDraggedItem({ trackIndex, roleIndex });
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
};
const handleDrop = (trackIndex: number, roleIndex: number) => {
if (!draggedItem) return;
if (
draggedItem.trackIndex === trackIndex &&
draggedItem.roleIndex === roleIndex
)
return;
const newTracks = [...tracks];
const sourceTrack = newTracks[draggedItem.trackIndex];
const targetTrack = newTracks[trackIndex];
// Get the dragged role
const [draggedRole] = sourceTrack.roles.splice(draggedItem.roleIndex, 1);
// If dropping in the same track, adjust the level
if (draggedItem.trackIndex === trackIndex) {
// Swap levels between roles
const targetRole = targetTrack.roles[roleIndex];
if (targetRole) {
const tempLevel = draggedRole.level;
draggedRole.level = targetRole.level;
targetRole.level = tempLevel;
}
targetTrack.roles.splice(roleIndex, 0, draggedRole);
} else {
// Moving to different track - keep existing level or adjust
targetTrack.roles.splice(roleIndex, 0, draggedRole);
}
// Re-sort both tracks
sourceTrack.roles.sort((a, b) => a.level - b.level);
targetTrack.roles.sort((a, b) => a.level - b.level);
setTracks(newTracks);
setDraggedItem(null);
setHasChanges(true);
};
// Get available roles for initial role (not used in any track as initial)
const getAvailableInitialRoles = (track: Track) => {
const currentInitialId = getInitialRole(track)?.role_id;
return guildRoles.filter((r) =>
r.role_name !== "@everyone" &&
(r.role_id === currentInitialId || !track.roles.some(tr => tr.role_id === r.role_id))
);
};
// Get available roles for regular roles (not already in the current track, exclude @everyone)
const getAvailableRoles = (track: Track) => {
const usedRoleIds = new Set(track.roles.map((r) => r.role_id));
return guildRoles.filter((r) => !usedRoleIds.has(r.role_id) && r.role_name !== "@everyone");
};
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-blue-500" />
<span className="ml-3 text-gray-400">Loading leveling data...</span>
</div>
);
}
return (
<div className="space-y-6">
{/* Header with Save Button */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h2 className="text-2xl sm:text-3xl font-bold text-white">
Level Tracks
</h2>
<p className="text-gray-400 text-sm mt-1">
Configure role rewards for leveling up
</p>
</div>
<div className="flex items-center gap-3">
{hasChanges && (
<span className="text-yellow-500 text-sm flex items-center gap-1">
<AlertCircle className="w-4 h-4" />
<span className="hidden sm:inline">Unsaved changes</span>
</span>
)}
<button
onClick={saveChanges}
disabled={!hasChanges || saving}
className={`flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-all ${hasChanges && !saving
? "bg-blue-600 hover:bg-blue-700 text-white"
: "bg-gray-700 text-gray-400 cursor-not-allowed"
}`}
>
{saving ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Save className="w-4 h-4" />
)}
<span className="hidden sm:inline">Save Changes</span>
<span className="sm:hidden">Save</span>
</button>
</div>
</div>
{/* Status Messages */}
{error && (
<div className="bg-red-500/10 border border-red-500/30 rounded-lg p-4 flex items-center gap-3">
<AlertCircle className="w-5 h-5 text-red-500 flex-shrink-0" />
<p className="text-red-400">{error}</p>
<button
onClick={() => setError(null)}
className="ml-auto text-red-400 hover:text-red-300"
>
<X className="w-4 h-4" />
</button>
</div>
)}
{success && (
<div className="bg-green-500/10 border border-green-500/30 rounded-lg p-4 flex items-center gap-3">
<Check className="w-5 h-5 text-green-500 flex-shrink-0" />
<p className="text-green-400">{success}</p>
</div>
)}
<div className="space-y-4">
{tracks.map((track, trackIndex) => {
const isExpanded = expandedTracks.has(track.name);
const availableRoles = getAvailableRoles(track);
const availableInitialRoles = getAvailableInitialRoles(track);
const initialRole = getInitialRole(track);
const initialRoleGuild = initialRole ? guildRoles.find(r => r.role_id === initialRole.role_id) : null;
const initialRoleColor = initialRoleGuild ? intToHexColor(initialRoleGuild.color) : "#99AAB5";
const nonInitialRoles = track.roles.filter(r => r.level > 0);
return (
<div
key={track.name}
className={`bg-white/5 border border-white/10 rounded-xl backdrop-blur-sm transition-all relative ${openRoleDropdown === track.name || openInitialRoleDropdown === track.name ? "z-20" : "z-0"
}`}
>
<div
className="flex flex-col gap-3 p-4"
>
<div className="flex items-center justify-between">
<div
className="flex items-center gap-3 cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => toggleTrack(track.name)}
>
{isExpanded ? (
<ChevronUp className="w-5 h-5 text-gray-400" />
) : (
<ChevronDown className="w-5 h-5 text-gray-400" />
)}
<h3 className="text-lg font-semibold text-white">
{track.name}
</h3>
<span className="text-sm text-gray-500">
({nonInitialRoles.length} roles)
</span>
</div>
<div className="flex items-center gap-2">
<div className="relative" ref={openRoleDropdown === track.name ? dropdownRef : null}>
<button
onClick={(e) => {
e.stopPropagation();
if (!initialRole) {
setError("Please set an initial role first");
return;
}
// Calculate direction
const rect = e.currentTarget.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom;
const direction = spaceBelow < 320 ? "up" : "down";
setRoleDropdownDirection(direction);
if (openRoleDropdown === track.name) {
setOpenRoleDropdown(null);
setRoleSearchQuery("");
} else {
setOpenRoleDropdown(track.name);
setRoleSearchQuery("");
}
}}
disabled={availableRoles.length === 0 || !initialRole}
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg font-medium text-sm transition-colors ${availableRoles.length > 0 && initialRole
? "bg-blue-600 hover:bg-blue-700 text-white"
: "bg-gray-700 text-gray-500 cursor-not-allowed"
}`}
>
<Plus className="w-4 h-4" />
<span className="hidden sm:inline">Add Role</span>
</button>
{/* Searchable Dropdown */}
{openRoleDropdown === track.name && availableRoles.length > 0 && (
<div
className={`absolute right-0 w-72 bg-zinc-900 border border-white/10 rounded-xl shadow-2xl z-50 overflow-hidden ${roleDropdownDirection === "up" ? "bottom-full mb-2" : "top-full mt-2"
}`}
onClick={(e) => e.stopPropagation()}
>
{/* Search Input */}
<div className="p-3 border-b border-white/10">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="Search roles..."
value={roleSearchQuery}
onChange={(e) => setRoleSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 bg-white/5 border border-white/10 rounded-lg text-white text-sm placeholder-gray-500 focus:outline-none focus:border-blue-500/50 focus:bg-white/10 transition-all"
autoFocus
/>
</div>
</div>
{/* Roles List */}
<div className="max-h-64 overflow-y-auto scrollbar-thin scrollbar-thumb-white/10 scrollbar-track-transparent">
{availableRoles
.filter((role) =>
(role.role_name || "").toLowerCase().includes(roleSearchQuery.toLowerCase())
)
.map((role) => {
const roleColor = intToHexColor(role.color);
return (
<button
key={role.role_id}
onClick={() => {
addRoleToTrack(trackIndex, role.role_id);
setOpenRoleDropdown(null);
setRoleSearchQuery("");
}}
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-white/10 transition-colors text-left group"
>
<span
className="w-3 h-3 rounded-full flex-shrink-0 ring-2 ring-white/20 group-hover:ring-white/40 transition-all"
style={{ backgroundColor: roleColor }}
/>
<span
className="text-sm truncate font-medium"
style={{ color: roleColor }}
>
{role.role_name || "Unknown Role"}
</span>
</button>
);
})}
{availableRoles.filter((role) =>
(role.role_name || "").toLowerCase().includes(roleSearchQuery.toLowerCase())
).length === 0 && (
<p className="text-gray-500 text-sm text-center py-4">
No roles found
</p>
)}
</div>
</div>
)}
</div>
{/* Delete Track Button */}
<button
onClick={(e) => {
e.stopPropagation();
if (
confirm(
`Are you sure you want to delete the "${track.name}" track?`
)
) {
deleteTrack(trackIndex);
}
}}
className="p-2 text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded-lg transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
{/* Initial Role Row */}
<div
className="flex items-center gap-3 ml-8"
onClick={(e) => e.stopPropagation()}
>
<span className="text-blue-400 text-sm font-medium">Initial Role:</span>
{/* Initial Role Dropdown */}
<div className="relative" ref={openInitialRoleDropdown === track.name ? initialDropdownRef : null}>
<button
onClick={(e) => {
e.stopPropagation();
// Calculate direction
const rect = e.currentTarget.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom;
const direction = spaceBelow < 320 ? "up" : "down";
setInitialRoleDropdownDirection(direction);
if (openInitialRoleDropdown === track.name) {
setOpenInitialRoleDropdown(null);
setInitialRoleSearchQuery("");
} else {
setOpenInitialRoleDropdown(track.name);
setInitialRoleSearchQuery("");
}
}}
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm transition-colors ${initialRole
? "bg-blue-500/20 border border-blue-500/40 hover:bg-blue-500/30"
: "bg-blue-500/10 border border-blue-500/30 border-dashed hover:bg-blue-500/20"
}`}
>
{initialRole ? (
<>
<span
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: initialRoleColor }}
/>
<span style={{ color: initialRoleColor }} className="font-medium">
{initialRole.role_name}
</span>
<ChevronDown className="w-3 h-3 text-blue-400 ml-1" />
</>
) : (
<>
<Plus className="w-4 h-4 text-blue-400" />
<span className="text-blue-400">Select Initial Role</span>
</>
)}
</button>
{/* Initial Role Searchable Dropdown */}
{openInitialRoleDropdown === track.name && (
<div
className={`absolute left-0 w-72 bg-zinc-900 border border-blue-500/30 rounded-xl shadow-2xl z-50 overflow-hidden ${initialRoleDropdownDirection === "up" ? "bottom-full mb-2" : "top-full mt-2"
}`}
onClick={(e) => e.stopPropagation()}
>
{/* Search Input */}
<div className="p-3 border-b border-blue-500/20">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-blue-400" />
<input
type="text"
placeholder="Search roles..."
value={initialRoleSearchQuery}
onChange={(e) => setInitialRoleSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 bg-blue-500/10 border border-blue-500/20 rounded-lg text-white text-sm placeholder-blue-300/50 focus:outline-none focus:border-blue-500/50 transition-all"
autoFocus
/>
</div>
</div>
{/* Roles List */}
<div className="max-h-64 overflow-y-auto">
{availableInitialRoles
.filter((role) =>
(role.role_name || "").toLowerCase().includes(initialRoleSearchQuery.toLowerCase())
)
.map((role) => {
const roleColor = intToHexColor(role.color);
const isSelected = initialRole?.role_id === role.role_id;
return (
<button
key={role.role_id}
onClick={() => setInitialRole(trackIndex, role.role_id)}
className={`w-full flex items-center gap-3 px-4 py-2.5 hover:bg-blue-500/20 transition-colors text-left group ${isSelected ? "bg-blue-500/20" : ""
}`}
>
<span
className="w-3 h-3 rounded-full flex-shrink-0 ring-2 ring-blue-500/30 group-hover:ring-blue-500/50 transition-all"
style={{ backgroundColor: roleColor }}
/>
<span
className="text-sm truncate font-medium"
style={{ color: roleColor }}
>
{role.role_name || "Unknown Role"}
</span>
{isSelected && (
<Check className="w-4 h-4 text-blue-400 ml-auto" />
)}
</button>
);
})}
{availableInitialRoles.filter((role) =>
(role.role_name || "").toLowerCase().includes(initialRoleSearchQuery.toLowerCase())
).length === 0 && (
<p className="text-blue-300/50 text-sm text-center py-4">
No roles found
</p>
)}
</div>
</div>
)}
</div>
</div>
</div>
{/* Track Content */}
{isExpanded && (
<div className="border-t border-white/10 p-4 space-y-4">
{/* Role List (non-initial roles only) */}
<div className="space-y-2">
{!initialRole ? (
<p className="text-blue-400 text-center py-4 bg-blue-500/10 rounded-lg border border-blue-500/20">
Please select an initial role above before adding level roles
</p>
) : nonInitialRoles.length === 0 ? (
<p className="text-gray-500 text-center py-4">
No level roles yet. Click "Add Role" to add roles that users can earn.
</p>
) : (
nonInitialRoles.map((role) => {
const actualRoleIndex = track.roles.findIndex(r => r.role_id === role.role_id && r.level === role.level);
const guildRole = guildRoles.find(
(r) => r.role_id === role.role_id
);
const roleColor = guildRole
? intToHexColor(guildRole.color)
: "#99AAB5";
return (
<div
key={role.role_id}
draggable
onDragStart={() => handleDragStart(trackIndex, actualRoleIndex)}
onDragOver={handleDragOver}
onDrop={() => handleDrop(trackIndex, actualRoleIndex)}
className={`flex flex-col sm:flex-row sm:items-center gap-3 p-3 rounded-lg border transition-all bg-black/20 border-white/5 hover:border-white/10 cursor-move ${draggedItem?.trackIndex === trackIndex &&
draggedItem?.roleIndex === actualRoleIndex
? "opacity-50"
: ""
}`}
>
<GripVertical className="w-4 h-4 text-gray-600 hidden sm:block flex-shrink-0" />
{/* Role Badge */}
<div
className="flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium flex-shrink-0"
style={{
backgroundColor: `${roleColor}20`,
borderColor: `${roleColor}50`,
borderWidth: "1px",
color: roleColor,
}}
>
<span
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: roleColor }}
/>
<span className="truncate max-w-[120px] sm:max-w-none">
{role.role_name}
</span>
</div>
{/* Level Input */}
<div className="flex items-center gap-2 flex-1 sm:flex-none">
<span className="text-gray-400 text-sm">
Level:
</span>
<input
type="number"
min="1"
value={role.level}
onChange={(e) =>
updateRoleLevel(
trackIndex,
actualRoleIndex,
parseInt(e.target.value) || 1
)
}
onBlur={() => sortTrackRoles(trackIndex)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.currentTarget.blur();
}
}}
onClick={(e) => e.stopPropagation()}
className="w-20 px-3 py-1.5 bg-black/30 border border-white/10 rounded-lg text-white text-center focus:outline-none focus:border-blue-500"
/>
</div>
{/* Remove Button */}
<button
onClick={(e) => {
e.stopPropagation();
removeRoleFromTrack(trackIndex, actualRoleIndex);
}}
className="p-2 text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded-lg transition-colors ml-auto"
>
<X className="w-4 h-4" />
</button>
</div>
);
})
)}
</div>
</div>
)}
</div>
);
})}
</div>
{/* Add New Track */}
{showAddTrack ? (
<div className="bg-white/5 border border-white/10 rounded-xl p-4">
<div className="flex flex-col sm:flex-row gap-3">
<input
type="text"
placeholder="Enter track name..."
value={newTrackName}
onChange={(e) => setNewTrackName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addTrack()}
className="flex-1 px-4 py-2.5 bg-black/30 border border-white/10 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-blue-500"
autoFocus
/>
<div className="flex gap-2">
<button
onClick={addTrack}
disabled={!newTrackName.trim()}
className="flex-1 sm:flex-none px-4 py-2.5 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-700 disabled:text-gray-500 text-white rounded-lg font-medium transition-colors"
>
Create
</button>
<button
onClick={() => {
setShowAddTrack(false);
setNewTrackName("");
}}
className="px-4 py-2.5 bg-gray-700 hover:bg-gray-600 text-gray-300 rounded-lg font-medium transition-colors"
>
Cancel
</button>
</div>
</div>
</div>
) : (
<button
onClick={() => setShowAddTrack(true)}
className="w-full py-4 border-2 border-dashed border-white/10 hover:border-white/20 rounded-xl text-gray-400 hover:text-white transition-all flex items-center justify-center gap-2"
>
<Plus className="w-5 h-5" />
Add New Track
</button>
)}
{/* Empty State */}
{tracks.length === 0 && !showAddTrack && (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-blue-500/10 flex items-center justify-center">
<Plus className="w-8 h-8 text-blue-500" />
</div>
<h3 className="text-xl font-semibold text-white mb-2">
No Level Tracks
</h3>
<p className="text-gray-400 mb-6">
Create your first track to start assigning roles based on levels
</p>
<button
onClick={() => setShowAddTrack(true)}
className="px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors"
>
Create First Track
</button>
</div>
)}
{/* Level Bridges Section */}
<div className="space-y-4 pt-8 border-t border-white/10">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h2 className="text-xl sm:text-2xl font-bold text-white">
Level Bridges
</h2>
<p className="text-gray-400 text-sm mt-1">
When the &apos;Recruit Role&apos; is obtained, it is removed and the &apos;Initial Role&apos; (It should match the Initial Role in the Level Tracks) is assigned. You cannot obtain another &apos;Initial Role&apos; while holding one, so switching tracks requires resetting progress via <code className="bg-black/30 border border-white/10 px-1.5 py-0.5 rounded text-blue-300 font-mono text-xs">/level reset</code>.
</p>
</div>
</div>
<div className="bg-white/5 border border-white/10 rounded-xl backdrop-blur-sm p-4 space-y-4">
{bridges.length === 0 && (
<p className="text-gray-500 text-center py-4">
No bridges configured. Add one below.
</p>
)}
<div className="space-y-2">
{bridges.map((bridge, index) => {
const inRole = guildRoles.find(r => r.role_id === bridge.in_role_id);
const outRole = guildRoles.find(r => r.role_id === bridge.out_role_id);
const inColor = inRole ? intToHexColor(inRole.color) : "#99AAB5";
const outColor = outRole ? intToHexColor(outRole.color) : "#99AAB5";
return (
<div key={`${bridge.in_role_id}-${bridge.out_role_id}`} className="flex items-center justify-between bg-black/20 border border-white/5 p-3 rounded-lg">
<div className="flex items-center gap-4 overflow-hidden">
{/* In Role */}
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-white/5 border border-white/10 min-w-0">
<span className="w-2 h-2 rounded-full flex-shrink-0" style={{ backgroundColor: inColor }}></span>
<span className="text-sm font-medium text-white truncate max-w-[150px]">
{inRole?.role_name || bridge.in_role_id}
</span>
</div>
<ArrowRight className="w-4 h-4 text-gray-500 flex-shrink-0" />
{/* Out Role */}
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-white/5 border border-white/10 min-w-0">
<span className="w-2 h-2 rounded-full flex-shrink-0" style={{ backgroundColor: outColor }}></span>
<span className="text-sm font-medium text-white truncate max-w-[150px]">
{outRole?.role_name || bridge.out_role_id}
</span>
</div>
</div>
<button
onClick={() => {
const newBridges = [...bridges];
newBridges.splice(index, 1);
setBridges(newBridges);
setDeletedBridges(prev => [...prev, (bridge as any).in_role_id || bridge.in_role_id]);
setHasChanges(true);
}}
className="p-2 text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded-lg transition-colors ml-2"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
);
})}
</div>
{/* Add Bridge UI */}
<div className="mt-4 pt-4 border-t border-white/5">
<h4 className="text-sm font-medium text-gray-400 mb-3">Add New Bridge</h4>
<div className="flex flex-col md:flex-row gap-3">
{/* In Role Dropdown */}
<div className="flex-1 relative" ref={bridgeInDropdownRef}>
<button
onClick={(e) => {
// Calculate direction
const rect = e.currentTarget.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom;
setBridgeInDropdownDirection(spaceBelow < 320 ? "up" : "down");
setOpenBridgeInDropdown(!openBridgeInDropdown);
setBridgeInSearchQuery("");
setOpenBridgeOutDropdown(false);
}}
className="w-full flex items-center justify-between px-3 py-2 bg-black/20 border border-white/10 rounded-lg text-sm text-white hover:bg-black/30 transition-colors"
>
{newBridgeIn ? (
(() => {
const role = guildRoles.find(r => r.role_id === newBridgeIn);
return (
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: intToHexColor(role?.color) }}></span>
<span className="truncate">{role?.role_name || newBridgeIn}</span>
</div>
);
})()
) : (
<span className="text-gray-500">Select Recruit Role...</span>
)}
<ChevronDown className="w-4 h-4 text-gray-500" />
</button>
{openBridgeInDropdown && (
<div className={`absolute left-0 w-full bg-zinc-900 border border-white/10 rounded-xl shadow-2xl z-50 overflow-hidden ${bridgeInDropdownDirection === "up" ? "bottom-full mb-2" : "top-full mt-2"
}`}>
<div className="p-3 border-b border-white/10">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="Search roles..."
value={bridgeInSearchQuery}
onChange={(e) => setBridgeInSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 bg-white/5 border border-white/10 rounded-lg text-white text-sm focus:outline-none focus:border-blue-500/50"
autoFocus
/>
</div>
</div>
<div className="max-h-60 overflow-y-auto">
{guildRoles
.filter(r =>
r.role_name !== "@everyone" &&
!bridges.some(b => b.in_role_id === r.role_id) &&
(r.role_name.toLowerCase().includes(bridgeInSearchQuery.toLowerCase()))
)
.sort((a, b) => a.role_name.localeCompare(b.role_name))
.map(r => (
<button
key={r.role_id}
onClick={() => {
setNewBridgeIn(r.role_id);
setOpenBridgeInDropdown(false);
setBridgeInSearchQuery("");
}}
className="w-full flex items-center gap-3 px-4 py-2 hover:bg-white/10 transition-colors text-left"
>
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: intToHexColor(r.color) }}></span>
<span className="text-sm truncate text-gray-300">{r.role_name}</span>
{newBridgeIn === r.role_id && <Check className="w-4 h-4 text-blue-500 ml-auto" />}
</button>
))}
{guildRoles.filter(r =>
r.role_name !== "@everyone" &&
!bridges.some(b => b.in_role_id === r.role_id) &&
(r.role_name.toLowerCase().includes(bridgeInSearchQuery.toLowerCase()))
).length === 0 && (
<div className="px-4 py-3 text-sm text-gray-500 text-center">No roles found</div>
)}
</div>
</div>
)}
</div>
<div className="flex items-center justify-center">
<ArrowRight className="w-4 h-4 text-gray-500" />
</div>
{/* Out Role Dropdown */}
<div className="flex-1 relative" ref={bridgeOutDropdownRef}>
<button
onClick={(e) => {
// Calculate direction
const rect = e.currentTarget.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom;
setBridgeOutDropdownDirection(spaceBelow < 320 ? "up" : "down");
setOpenBridgeOutDropdown(!openBridgeOutDropdown);
setBridgeOutSearchQuery("");
setOpenBridgeInDropdown(false);
}}
className="w-full flex items-center justify-between px-3 py-2 bg-black/20 border border-white/10 rounded-lg text-sm text-white hover:bg-black/30 transition-colors"
>
{newBridgeOut ? (
(() => {
const role = guildRoles.find(r => r.role_id === newBridgeOut);
return (
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: intToHexColor(role?.color) }}></span>
<span className="truncate">{role?.role_name || newBridgeOut}</span>
</div>
);
})()
) : (
<span className="text-gray-500">Select Initial Role...</span>
)}
<ChevronDown className="w-4 h-4 text-gray-500" />
</button>
{openBridgeOutDropdown && (
<div className={`absolute left-0 w-full bg-zinc-900 border border-white/10 rounded-xl shadow-2xl z-50 overflow-hidden ${bridgeOutDropdownDirection === "up" ? "bottom-full mb-2" : "top-full mt-2"
}`}>
<div className="p-3 border-b border-white/10">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="Search roles..."
value={bridgeOutSearchQuery}
onChange={(e) => setBridgeOutSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 bg-white/5 border border-white/10 rounded-lg text-white text-sm focus:outline-none focus:border-blue-500/50"
autoFocus
/>
</div>
</div>
<div className="max-h-60 overflow-y-auto">
{guildRoles
.filter(r =>
r.role_name !== "@everyone" &&
(r.role_name.toLowerCase().includes(bridgeOutSearchQuery.toLowerCase()))
)
.sort((a, b) => a.role_name.localeCompare(b.role_name))
.map(r => (
<button
key={r.role_id}
onClick={() => {
setNewBridgeOut(r.role_id);
setOpenBridgeOutDropdown(false);
setBridgeOutSearchQuery("");
}}
className="w-full flex items-center gap-3 px-4 py-2 hover:bg-white/10 transition-colors text-left"
>
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: intToHexColor(r.color) }}></span>
<span className="text-sm truncate text-gray-300">{r.role_name}</span>
{newBridgeOut === r.role_id && <Check className="w-4 h-4 text-blue-500 ml-auto" />}
</button>
))}
{guildRoles.filter(r =>
r.role_name !== "@everyone" &&
(r.role_name.toLowerCase().includes(bridgeOutSearchQuery.toLowerCase()))
).length === 0 && (
<div className="px-4 py-3 text-sm text-gray-500 text-center">No roles found</div>
)}
</div>
</div>
)}
</div>
<button
onClick={() => {
if (newBridgeIn && newBridgeOut) {
if (newBridgeIn === newBridgeOut) {
setError("Recruit Role and Initial Role cannot be the same");
return;
}
setBridges([...bridges, { in_role_id: newBridgeIn, out_role_id: newBridgeOut }]);
setNewBridgeIn(null);
setNewBridgeOut(null);
setHasChanges(true);
setError(null);
}
}}
disabled={!newBridgeIn || !newBridgeOut}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${newBridgeIn && newBridgeOut
? "bg-blue-600 hover:bg-blue-700 text-white"
: "bg-gray-700 text-gray-500 cursor-not-allowed"
}`}
>
<div className="flex items-center gap-2">
<Plus className="w-4 h-4" />
<span>Add Bridge</span>
</div>
</button>
</div>
</div>
</div>
</div>
</div>
);
}