"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([]); const [guildRoles, setGuildRoles] = useState([]); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [success, setSuccess] = useState(null); const [expandedTracks, setExpandedTracks] = useState>(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(null); const [openInitialRoleDropdown, setOpenInitialRoleDropdown] = useState(null); const [roleSearchQuery, setRoleSearchQuery] = useState(""); const [initialRoleSearchQuery, setInitialRoleSearchQuery] = useState(""); const [deletedTracks, setDeletedTracks] = useState([]); const [originalTrackNames, setOriginalTrackNames] = useState>(new Set()); // Level Bridger State const [bridges, setBridges] = useState([]); const [deletedBridges, setDeletedBridges] = useState([]); // in_role_ids const [newBridgeIn, setNewBridgeIn] = useState(null); const [newBridgeOut, setNewBridgeOut] = useState(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(null); const initialDropdownRef = useRef(null); const bridgeInDropdownRef = useRef(null); const bridgeOutDropdownRef = useRef(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 (
Loading leveling data...
); } return (
{/* Header with Save Button */}

Level Tracks

Configure role rewards for leveling up

{hasChanges && ( Unsaved changes )}
{/* Status Messages */} {error && (

{error}

)} {success && (

{success}

)}
{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 (
toggleTrack(track.name)} > {isExpanded ? ( ) : ( )}

{track.name}

({nonInitialRoles.length} roles)
{/* Searchable Dropdown */} {openRoleDropdown === track.name && availableRoles.length > 0 && (
e.stopPropagation()} > {/* Search Input */}
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 />
{/* Roles List */}
{availableRoles .filter((role) => (role.role_name || "").toLowerCase().includes(roleSearchQuery.toLowerCase()) ) .map((role) => { const roleColor = intToHexColor(role.color); return ( ); })} {availableRoles.filter((role) => (role.role_name || "").toLowerCase().includes(roleSearchQuery.toLowerCase()) ).length === 0 && (

No roles found

)}
)}
{/* Delete Track Button */}
{/* Initial Role Row */}
e.stopPropagation()} > Initial Role: {/* Initial Role Dropdown */}
{/* Initial Role Searchable Dropdown */} {openInitialRoleDropdown === track.name && (
e.stopPropagation()} > {/* Search Input */}
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 />
{/* Roles List */}
{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 ( ); })} {availableInitialRoles.filter((role) => (role.role_name || "").toLowerCase().includes(initialRoleSearchQuery.toLowerCase()) ).length === 0 && (

No roles found

)}
)}
{/* Track Content */} {isExpanded && (
{/* Role List (non-initial roles only) */}
{!initialRole ? (

★ Please select an initial role above before adding level roles

) : nonInitialRoles.length === 0 ? (

No level roles yet. Click "Add Role" to add roles that users can earn.

) : ( 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 (
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" : "" }`} > {/* Role Badge */}
{role.role_name}
{/* Level Input */}
Level: 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" />
{/* Remove Button */}
); }) )}
)}
); })}
{/* Add New Track */} {showAddTrack ? (
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 />
) : ( )} {/* Empty State */} {tracks.length === 0 && !showAddTrack && (

No Level Tracks

Create your first track to start assigning roles based on levels

)} {/* Level Bridges Section */}

Level Bridges

When the 'In Role' is obtained, it is removed and the 'Out Role' is assigned. You cannot obtain another 'Out Role' while holding one, so switching tracks requires resetting progress via /level reset.

{bridges.length === 0 && (

No bridges configured. Add one below.

)}
{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 (
{/* In Role */}
{inRole?.role_name || bridge.in_role_id}
{/* Out Role */}
{outRole?.role_name || bridge.out_role_id}
); })}
{/* Add Bridge UI */}

Add New Bridge

{/* In Role Dropdown */}
{openBridgeInDropdown && (
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 />
{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 => ( ))} {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 && (
No roles found
)}
)}
{/* Out Role Dropdown */}
{openBridgeOutDropdown && (
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 />
{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 => ( ))} {guildRoles.filter(r => r.role_name !== "@everyone" && (r.role_name.toLowerCase().includes(bridgeOutSearchQuery.toLowerCase())) ).length === 0 && (
No roles found
)}
)}
); }