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