1292 lines
72 KiB
TypeScript
1292 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 [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);
|
|
}, []);
|
|
|
|
// Add bridge
|
|
const handleAddBridge = async () => {
|
|
if (!newBridgeIn || !newBridgeOut) return;
|
|
if (newBridgeIn === newBridgeOut) {
|
|
setError("Recruit Role and Initial Role cannot be the same");
|
|
return;
|
|
}
|
|
|
|
const bridge = {
|
|
in_role_id: String(newBridgeIn),
|
|
out_role_id: String(newBridgeOut)
|
|
};
|
|
|
|
try {
|
|
const response = await fetch(`/api/guilds/${guildId}/level_bridger`, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(bridge),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json().catch(() => ({}));
|
|
throw new Error(errorData.error || "Failed to add bridge");
|
|
}
|
|
|
|
setBridges([...bridges, bridge]);
|
|
setNewBridgeIn(null);
|
|
setNewBridgeOut(null);
|
|
setError(null);
|
|
setSuccess("Bridge added successfully");
|
|
setTimeout(() => setSuccess(null), 3000);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Failed to add bridge");
|
|
}
|
|
};
|
|
|
|
// Delete bridge
|
|
const handleDeleteBridge = async (index: number) => {
|
|
const bridge = bridges[index];
|
|
const inRoleId = String(bridge.in_role_id);
|
|
|
|
try {
|
|
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) {
|
|
throw new Error("Failed to delete bridge");
|
|
}
|
|
|
|
const newBridges = [...bridges];
|
|
newBridges.splice(index, 1);
|
|
setBridges(newBridges);
|
|
setSuccess("Bridge deleted successfully");
|
|
setTimeout(() => setSuccess(null), 3000);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Failed to delete bridge");
|
|
}
|
|
};
|
|
|
|
// 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 : []);
|
|
|
|
|
|
// 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);
|
|
|
|
// 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);
|
|
|
|
// Update tracking state
|
|
setOriginalTrackNames(new Set(tracks.map(t => t.name)));
|
|
setDeletedTracks([]);
|
|
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 'Recruit Role' is obtained, it is removed and the 'Initial Role' (It should match the Initial Role in the Level Tracks) is assigned. You cannot obtain another 'Initial Role' 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={() => handleDeleteBridge(index)}
|
|
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={handleAddBridge}
|
|
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>
|
|
);
|
|
}
|