feat: Add GroupsPanel component for managing turtle groups and commands
This commit is contained in:
327
client/src/components/GroupsPanel.jsx
Normal file
327
client/src/components/GroupsPanel.jsx
Normal file
@@ -0,0 +1,327 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import './GroupsPanel.css';
|
||||
|
||||
const GroupsPanel = ({ turtles, apiUrl, wsUrl }) => {
|
||||
const [groups, setGroups] = useState([]);
|
||||
const [groupName, setGroupName] = useState('');
|
||||
const [groupColor, setGroupColor] = useState('#3b82f6');
|
||||
const [selectedGroup, setSelectedGroup] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [message, setMessage] = useState(null);
|
||||
|
||||
const colorPresets = [
|
||||
{ name: 'Blue', value: '#3b82f6' },
|
||||
{ name: 'Green', value: '#10b981' },
|
||||
{ name: 'Red', value: '#ef4444' },
|
||||
{ name: 'Yellow', value: '#f59e0b' },
|
||||
{ name: 'Purple', value: '#8b5cf6' },
|
||||
{ name: 'Pink', value: '#ec4899' },
|
||||
{ name: 'Cyan', value: '#06b6d4' },
|
||||
{ name: 'Orange', value: '#f97316' },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
loadGroups();
|
||||
|
||||
// Refresh groups every 10 seconds
|
||||
const interval = setInterval(loadGroups, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const loadGroups = async () => {
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/api/groups`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setGroups(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load groups:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const createGroup = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!groupName.trim()) {
|
||||
showMessage('Please enter a group name', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/api/groups`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
groupName: groupName.trim(),
|
||||
color: groupColor
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showMessage('Group created successfully!', 'success');
|
||||
setGroupName('');
|
||||
loadGroups();
|
||||
} else {
|
||||
showMessage('Failed to create group', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage('Error creating group', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteGroup = async (groupId) => {
|
||||
if (!confirm('Are you sure you want to delete this group?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/api/groups/${groupId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showMessage('Group deleted', 'success');
|
||||
if (selectedGroup?.groupId === groupId) {
|
||||
setSelectedGroup(null);
|
||||
}
|
||||
loadGroups();
|
||||
} else {
|
||||
showMessage('Failed to delete group', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage('Error deleting group', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const addTurtleToGroup = async (groupId, turtleId) => {
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/api/groups/${groupId}/members`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ turtleId })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showMessage('Turtle added to group', 'success');
|
||||
loadGroups();
|
||||
} else {
|
||||
showMessage('Failed to add turtle', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage('Error adding turtle', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const removeTurtleFromGroup = async (groupId, turtleId) => {
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/api/groups/${groupId}/members/${turtleId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showMessage('Turtle removed from group', 'success');
|
||||
loadGroups();
|
||||
} else {
|
||||
showMessage('Failed to remove turtle', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage('Error removing turtle', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const sendGroupCommand = async (groupId, command) => {
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/api/groups/${groupId}/command`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
showMessage(`Command sent to ${data.sentTo} turtles`, 'success');
|
||||
} else {
|
||||
showMessage('Failed to send command', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage('Error sending command', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const showMessage = (text, type) => {
|
||||
setMessage({ text, type });
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
};
|
||||
|
||||
const getTurtleById = (turtleId) => {
|
||||
return turtles.find(t => t.turtleID === turtleId);
|
||||
};
|
||||
|
||||
const getAvailableTurtles = (groupMembers) => {
|
||||
const memberIds = groupMembers.map(m => m.turtleId);
|
||||
return turtles.filter(t => !memberIds.includes(t.turtleID));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="groups-panel">
|
||||
<div className="groups-header">
|
||||
<h2>👥 Turtle Groups</h2>
|
||||
<div className="group-count">{groups.length} teams</div>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`message ${message.type}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Group Form */}
|
||||
<div className="create-group-section">
|
||||
<h3>Create New Group</h3>
|
||||
<form onSubmit={createGroup} className="create-group-form">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Group name..."
|
||||
value={groupName}
|
||||
onChange={(e) => setGroupName(e.target.value)}
|
||||
maxLength={50}
|
||||
disabled={loading}
|
||||
/>
|
||||
<div className="color-picker">
|
||||
{colorPresets.map(preset => (
|
||||
<button
|
||||
key={preset.value}
|
||||
type="button"
|
||||
className={`color-option ${groupColor === preset.value ? 'active' : ''}`}
|
||||
style={{ backgroundColor: preset.value }}
|
||||
onClick={() => setGroupColor(preset.value)}
|
||||
title={preset.name}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<button type="submit" disabled={loading || !groupName.trim()}>
|
||||
{loading ? 'Creating...' : '➕ Create Group'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Groups List */}
|
||||
<div className="groups-list">
|
||||
{groups.length === 0 && (
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon">👥</div>
|
||||
<div className="empty-title">No Groups Yet</div>
|
||||
<div className="empty-text">Create a group to organize your turtles into teams</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groups.map(group => (
|
||||
<div key={group.groupId} className="group-card">
|
||||
<div className="group-header" style={{ borderLeftColor: group.color }}>
|
||||
<div className="group-title">
|
||||
<div
|
||||
className="group-color-indicator"
|
||||
style={{ backgroundColor: group.color }}
|
||||
/>
|
||||
<h3>{group.groupName}</h3>
|
||||
<span className="member-count">{group.memberCount} members</span>
|
||||
</div>
|
||||
<button
|
||||
className="delete-group-btn"
|
||||
onClick={() => deleteGroup(group.groupId)}
|
||||
title="Delete group"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Group Members */}
|
||||
<div className="group-members">
|
||||
<h4>Members:</h4>
|
||||
{group.members && group.members.length > 0 ? (
|
||||
<div className="members-list">
|
||||
{group.members.map(member => {
|
||||
const turtle = getTurtleById(member.turtleId);
|
||||
return (
|
||||
<div key={member.turtleId} className="member-item">
|
||||
<div className="member-info">
|
||||
<span className="member-icon">🐢</span>
|
||||
<span className="member-name">
|
||||
{turtle?.name || `Turtle ${member.turtleId}`}
|
||||
</span>
|
||||
{turtle && (
|
||||
<span className="member-status" title={turtle.mode}>
|
||||
{turtle.mode === 'exploring' && '🔍'}
|
||||
{turtle.mode === 'mining' && '⛏️'}
|
||||
{turtle.mode === 'returning' && '🏠'}
|
||||
{turtle.mode === 'idle' && '💤'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="remove-member-btn"
|
||||
onClick={() => removeTurtleFromGroup(group.groupId, member.turtleId)}
|
||||
title="Remove from group"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="no-members">No members yet</div>
|
||||
)}
|
||||
|
||||
{/* Add Member Dropdown */}
|
||||
{getAvailableTurtles(group.members || []).length > 0 && (
|
||||
<div className="add-member-section">
|
||||
<select
|
||||
onChange={(e) => {
|
||||
if (e.target.value) {
|
||||
addTurtleToGroup(group.groupId, parseInt(e.target.value));
|
||||
e.target.value = '';
|
||||
}
|
||||
}}
|
||||
defaultValue=""
|
||||
>
|
||||
<option value="">➕ Add turtle...</option>
|
||||
{getAvailableTurtles(group.members || []).map(turtle => (
|
||||
<option key={turtle.turtleID} value={turtle.turtleID}>
|
||||
🐢 {turtle.name || `Turtle ${turtle.turtleID}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Group Commands */}
|
||||
{group.members && group.members.length > 0 && (
|
||||
<div className="group-commands">
|
||||
<h4>Group Commands:</h4>
|
||||
<div className="command-buttons">
|
||||
<button onClick={() => sendGroupCommand(group.groupId, 'explore')}>
|
||||
🔍 Explore
|
||||
</button>
|
||||
<button onClick={() => sendGroupCommand(group.groupId, 'mine')}>
|
||||
⛏️ Mine
|
||||
</button>
|
||||
<button onClick={() => sendGroupCommand(group.groupId, 'returnHome')}>
|
||||
🏠 Return Home
|
||||
</button>
|
||||
<button onClick={() => sendGroupCommand(group.groupId, 'stop')}>
|
||||
⏹️ Stop
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupsPanel;
|
||||
Reference in New Issue
Block a user