diff --git a/client/src/App.jsx b/client/src/App.jsx
index 865fa53..70d6d9e 100644
--- a/client/src/App.jsx
+++ b/client/src/App.jsx
@@ -5,13 +5,14 @@ import VoiceControl from './components/VoiceControl';
import StatsPanel from './components/StatsPanel';
import GroupsPanel from './components/GroupsPanel';
import TaskPanel from './components/TaskPanel';
+import PathRecorder from './components/PathRecorder';
import { useTurtleStore } from './store/turtleStore';
import './App.css';
function App() {
const connect = useTurtleStore((state) => state.connect);
const [view, setView] = useState('split'); // 'split', 'map', 'panel'
- const [panelTab, setPanelTab] = useState('control'); // 'control', 'voice', 'stats', 'groups', 'tasks'
+ const [panelTab, setPanelTab] = useState('control'); // 'control', 'voice', 'stats', 'groups', 'tasks', 'paths'
const turtles = useTurtleStore((state) => state.getTurtleArray());
const selectedTurtle = useTurtleStore((state) => state.getSelectedTurtle());
@@ -34,6 +35,8 @@ function App() {
return ;
case 'tasks':
return ;
+ case 'paths':
+ return ;
default:
return ;
}
@@ -106,6 +109,13 @@ function App() {
>
đ Tasks
+
{renderPanelContent()}
diff --git a/client/src/components/PathRecorder.css b/client/src/components/PathRecorder.css
new file mode 100644
index 0000000..e386c1a
--- /dev/null
+++ b/client/src/components/PathRecorder.css
@@ -0,0 +1,532 @@
+.path-recorder {
+ padding: 1.5rem;
+ background: #0f172a;
+ border-radius: 0.5rem;
+ height: 100%;
+ overflow-y: auto;
+}
+
+.recorder-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 1.5rem;
+ flex-wrap: wrap;
+ gap: 1rem;
+}
+
+.recorder-header h2 {
+ font-size: 1.5rem;
+ font-weight: 700;
+ color: #f9fafb;
+ margin: 0;
+}
+
+.selected-turtle-badge {
+ padding: 0.5rem 1rem;
+ background: #1e293b;
+ border-radius: 0.5rem;
+ color: #10b981;
+ font-weight: 600;
+ font-size: 0.875rem;
+ border: 1px solid #10b981;
+}
+
+.message {
+ padding: 0.75rem 1rem;
+ border-radius: 0.5rem;
+ margin-bottom: 1rem;
+ font-size: 0.875rem;
+ font-weight: 600;
+ animation: slideIn 0.3s;
+}
+
+.message.success {
+ background: #10b98133;
+ color: #10b981;
+ border: 1px solid #10b981;
+}
+
+.message.error {
+ background: #ef444433;
+ color: #ef4444;
+ border: 1px solid #ef4444;
+}
+
+.message.info {
+ background: #3b82f633;
+ color: #3b82f6;
+ border: 1px solid #3b82f6;
+}
+
+@keyframes slideIn {
+ from {
+ opacity: 0;
+ transform: translateY(-10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+/* Recording Section */
+.recording-section {
+ background: #1e293b;
+ border-radius: 0.5rem;
+ padding: 1.5rem;
+ margin-bottom: 1.5rem;
+}
+
+.recording-section h3 {
+ font-size: 1.125rem;
+ font-weight: 600;
+ color: #f9fafb;
+ margin: 0 0 1rem 0;
+}
+
+.record-form {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.record-form input,
+.record-form textarea {
+ padding: 0.75rem;
+ background: #0f172a;
+ border: 1px solid #334155;
+ border-radius: 0.375rem;
+ color: #e5e7eb;
+ font-size: 0.875rem;
+ font-family: inherit;
+}
+
+.record-form input:focus,
+.record-form textarea:focus {
+ outline: none;
+ border-color: #3b82f6;
+}
+
+.record-btn {
+ padding: 0.875rem 1.5rem;
+ background: #ef4444;
+ border: none;
+ border-radius: 0.5rem;
+ color: white;
+ font-weight: 700;
+ font-size: 1rem;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.record-btn:hover:not(:disabled) {
+ background: #dc2626;
+ transform: translateY(-2px);
+ box-shadow: 0 4px 12px rgba(239, 68, 68, 0.4);
+}
+
+.record-btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.recording-controls {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ align-items: center;
+}
+
+.waypoint-counter {
+ font-size: 1.5rem;
+ font-weight: 700;
+ color: #ef4444;
+ animation: pulse 2s infinite;
+}
+
+@keyframes pulse {
+ 0%, 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.6;
+ }
+}
+
+.recording-info {
+ text-align: center;
+ padding: 1rem;
+ background: #0f172a;
+ border-radius: 0.375rem;
+ width: 100%;
+}
+
+.recording-info p {
+ margin: 0.25rem 0;
+ color: #94a3b8;
+ font-size: 0.875rem;
+}
+
+.recording-info strong {
+ color: #e5e7eb;
+}
+
+.stop-btn {
+ padding: 0.875rem 2rem;
+ background: #3b82f6;
+ border: none;
+ border-radius: 0.5rem;
+ color: white;
+ font-weight: 700;
+ font-size: 1rem;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.stop-btn:hover {
+ background: #2563eb;
+ transform: translateY(-2px);
+ box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
+}
+
+/* Paths Section */
+.paths-section h3 {
+ font-size: 1.125rem;
+ font-weight: 600;
+ color: #f9fafb;
+ margin-bottom: 1rem;
+}
+
+.loading {
+ text-align: center;
+ padding: 2rem;
+ color: #94a3b8;
+ font-size: 0.875rem;
+}
+
+.paths-list {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.path-card {
+ background: #1e293b;
+ border-radius: 0.5rem;
+ padding: 1.5rem;
+ transition: all 0.2s;
+}
+
+.path-card:hover {
+ background: #334155;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
+}
+
+.path-card-header {
+ margin-bottom: 1rem;
+}
+
+.path-info h4 {
+ font-size: 1rem;
+ font-weight: 600;
+ color: #e5e7eb;
+ margin: 0 0 0.5rem 0;
+}
+
+.path-description {
+ font-size: 0.875rem;
+ color: #94a3b8;
+ margin: 0.5rem 0;
+ font-style: italic;
+}
+
+.path-meta {
+ display: flex;
+ gap: 1rem;
+ flex-wrap: wrap;
+ font-size: 0.75rem;
+ color: #64748b;
+ margin-top: 0.75rem;
+}
+
+.path-meta span {
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+}
+
+.path-actions {
+ display: flex;
+ gap: 0.5rem;
+}
+
+.path-actions button {
+ padding: 0.5rem 1rem;
+ border: none;
+ border-radius: 0.375rem;
+ font-size: 0.875rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.view-btn {
+ background: #3b82f6;
+ color: white;
+}
+
+.view-btn:hover {
+ background: #2563eb;
+ transform: translateY(-2px);
+}
+
+.play-btn {
+ background: #10b981;
+ color: white;
+}
+
+.play-btn:hover {
+ background: #059669;
+ transform: translateY(-2px);
+}
+
+.delete-btn {
+ background: #ef4444;
+ color: white;
+ padding: 0.5rem;
+ margin-left: auto;
+}
+
+.delete-btn:hover {
+ background: #dc2626;
+ transform: scale(1.1);
+}
+
+/* Empty State */
+.empty-state {
+ text-align: center;
+ padding: 4rem 2rem;
+}
+
+.empty-icon {
+ font-size: 4rem;
+ margin-bottom: 1rem;
+ opacity: 0.5;
+}
+
+.empty-title {
+ font-size: 1.25rem;
+ font-weight: 600;
+ color: #e5e7eb;
+ margin-bottom: 0.5rem;
+}
+
+.empty-text {
+ font-size: 0.875rem;
+ color: #94a3b8;
+}
+
+/* Path Details Modal */
+.path-details-modal {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.8);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+ padding: 1rem;
+ animation: fadeIn 0.3s;
+}
+
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+
+.modal-content {
+ background: #1e293b;
+ border-radius: 0.75rem;
+ max-width: 800px;
+ width: 100%;
+ max-height: 90vh;
+ overflow-y: auto;
+ animation: slideUp 0.3s;
+}
+
+@keyframes slideUp {
+ from {
+ transform: translateY(20px);
+ opacity: 0;
+ }
+ to {
+ transform: translateY(0);
+ opacity: 1;
+ }
+}
+
+.modal-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 1.5rem;
+ border-bottom: 1px solid #334155;
+}
+
+.modal-header h3 {
+ font-size: 1.25rem;
+ font-weight: 700;
+ color: #f9fafb;
+ margin: 0;
+}
+
+.close-btn {
+ padding: 0.5rem 0.75rem;
+ background: transparent;
+ border: none;
+ color: #94a3b8;
+ font-size: 1.5rem;
+ cursor: pointer;
+ transition: all 0.2s;
+ line-height: 1;
+}
+
+.close-btn:hover {
+ color: #e5e7eb;
+ transform: scale(1.1);
+}
+
+.modal-body {
+ padding: 1.5rem;
+}
+
+.modal-description {
+ color: #94a3b8;
+ font-style: italic;
+ margin-bottom: 1.5rem;
+}
+
+.waypoints-list h4,
+.path-visualization h4 {
+ font-size: 1rem;
+ font-weight: 600;
+ color: #f9fafb;
+ margin: 0 0 1rem 0;
+}
+
+.waypoints-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+ gap: 0.5rem;
+ margin-bottom: 1.5rem;
+}
+
+.waypoint-item {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.5rem;
+ background: #0f172a;
+ border-radius: 0.375rem;
+ font-size: 0.75rem;
+}
+
+.waypoint-number {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 1.5rem;
+ height: 1.5rem;
+ background: #3b82f6;
+ color: white;
+ border-radius: 50%;
+ font-weight: 700;
+ font-size: 0.7rem;
+}
+
+.waypoint-coords {
+ color: #e5e7eb;
+ font-family: 'Courier New', monospace;
+}
+
+.waypoint-action {
+ margin-left: auto;
+ color: #10b981;
+ font-size: 0.7rem;
+}
+
+.path-stats {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 1rem;
+}
+
+.stat {
+ padding: 1rem;
+ background: #0f172a;
+ border-radius: 0.375rem;
+}
+
+.stat-label {
+ display: block;
+ font-size: 0.75rem;
+ color: #94a3b8;
+ margin-bottom: 0.5rem;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.stat-value {
+ display: block;
+ font-size: 1rem;
+ font-weight: 600;
+ color: #3b82f6;
+ font-family: 'Courier New', monospace;
+}
+
+/* Mobile Responsive */
+@media (max-width: 768px) {
+ .path-recorder {
+ padding: 1rem;
+ }
+
+ .recorder-header {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+
+ .path-actions {
+ flex-wrap: wrap;
+ }
+
+ .waypoints-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .path-stats {
+ grid-template-columns: 1fr;
+ }
+
+ .modal-content {
+ max-height: 95vh;
+ }
+}
+
+@media (max-width: 480px) {
+ .recorder-header h2 {
+ font-size: 1.25rem;
+ }
+
+ .path-actions button {
+ flex: 1;
+ min-width: 80px;
+ }
+}
diff --git a/client/src/components/PathRecorder.jsx b/client/src/components/PathRecorder.jsx
new file mode 100644
index 0000000..2a5998d
--- /dev/null
+++ b/client/src/components/PathRecorder.jsx
@@ -0,0 +1,376 @@
+import { useState, useEffect } from 'react';
+import './PathRecorder.css';
+
+const PathRecorder = ({ turtles, selectedTurtle, apiUrl }) => {
+ const [paths, setPaths] = useState([]);
+ const [recording, setRecording] = useState(false);
+ const [currentPath, setCurrentPath] = useState(null);
+ const [pathName, setPathName] = useState('');
+ const [pathDescription, setPathDescription] = useState('');
+ const [selectedPath, setSelectedPath] = useState(null);
+ const [message, setMessage] = useState(null);
+ const [loading, setLoading] = useState(false);
+
+ useEffect(() => {
+ loadPaths();
+ }, []);
+
+ const loadPaths = async () => {
+ setLoading(true);
+ try {
+ const response = await fetch(`${apiUrl}/api/paths`);
+ if (response.ok) {
+ const data = await response.json();
+ setPaths(data);
+ }
+ } catch (error) {
+ console.error('Failed to load paths:', error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const startRecording = () => {
+ if (!selectedTurtle) {
+ showMessage('Please select a turtle first', 'error');
+ return;
+ }
+ if (!pathName.trim()) {
+ showMessage('Please enter a path name', 'error');
+ return;
+ }
+
+ setRecording(true);
+ setCurrentPath({
+ name: pathName.trim(),
+ description: pathDescription.trim(),
+ turtleId: selectedTurtle.turtleID,
+ waypoints: []
+ });
+ showMessage('Recording started! Move the turtle to record path.', 'success');
+ };
+
+ const stopRecording = async () => {
+ if (!currentPath || currentPath.waypoints.length === 0) {
+ showMessage('No waypoints recorded', 'error');
+ setRecording(false);
+ setCurrentPath(null);
+ return;
+ }
+
+ try {
+ // Create the path
+ const pathResponse = await fetch(`${apiUrl}/api/paths`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ name: currentPath.name,
+ turtleId: currentPath.turtleId,
+ description: currentPath.description
+ })
+ });
+
+ if (!pathResponse.ok) {
+ throw new Error('Failed to create path');
+ }
+
+ const pathData = await pathResponse.json();
+ const pathId = pathData.pathId;
+
+ // Add all waypoints
+ for (const waypoint of currentPath.waypoints) {
+ await fetch(`${apiUrl}/api/paths/${pathId}/waypoints`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(waypoint)
+ });
+ }
+
+ showMessage(`Path saved with ${currentPath.waypoints.length} waypoints!`, 'success');
+ setRecording(false);
+ setCurrentPath(null);
+ setPathName('');
+ setPathDescription('');
+ loadPaths();
+ } catch (error) {
+ showMessage('Failed to save path', 'error');
+ console.error(error);
+ }
+ };
+
+ const deletePath = async (pathId) => {
+ if (!confirm('Are you sure you want to delete this path?')) return;
+
+ try {
+ const response = await fetch(`${apiUrl}/api/paths/${pathId}`, {
+ method: 'DELETE'
+ });
+
+ if (response.ok) {
+ showMessage('Path deleted', 'success');
+ loadPaths();
+ if (selectedPath?.pathId === pathId) {
+ setSelectedPath(null);
+ }
+ } else {
+ showMessage('Failed to delete path', 'error');
+ }
+ } catch (error) {
+ showMessage('Error deleting path', 'error');
+ }
+ };
+
+ const viewPathDetails = async (path) => {
+ try {
+ const response = await fetch(`${apiUrl}/api/paths/${path.pathId}`);
+ if (response.ok) {
+ const data = await response.json();
+ setSelectedPath(data);
+ }
+ } catch (error) {
+ console.error('Failed to load path details:', error);
+ }
+ };
+
+ const playbackPath = (path) => {
+ showMessage(`Playback not yet implemented for path: ${path.name}`, 'info');
+ // TODO: Implement playback by sending commands to turtle
+ };
+
+ const showMessage = (text, type) => {
+ setMessage({ text, type });
+ setTimeout(() => setMessage(null), 3000);
+ };
+
+ // Simulate recording waypoints when turtle moves
+ useEffect(() => {
+ if (recording && selectedTurtle && currentPath) {
+ const newWaypoint = {
+ x: selectedTurtle.position?.x || 0,
+ y: selectedTurtle.position?.y || 0,
+ z: selectedTurtle.position?.z || 0,
+ action: selectedTurtle.lastAction || 'move'
+ };
+
+ // Only add if position changed
+ const lastWaypoint = currentPath.waypoints[currentPath.waypoints.length - 1];
+ if (!lastWaypoint ||
+ lastWaypoint.x !== newWaypoint.x ||
+ lastWaypoint.y !== newWaypoint.y ||
+ lastWaypoint.z !== newWaypoint.z) {
+ setCurrentPath(prev => ({
+ ...prev,
+ waypoints: [...prev.waypoints, newWaypoint]
+ }));
+ }
+ }
+ }, [selectedTurtle?.position, recording]);
+
+ const getTurtleById = (turtleId) => {
+ return turtles.find(t => t.turtleID === turtleId);
+ };
+
+ return (
+
+
+
đ¤ī¸ Path Recording
+ {selectedTurtle && (
+
+ đĸ {selectedTurtle.name || `Turtle ${selectedTurtle.turtleID}`}
+
+ )}
+
+
+ {message && (
+
+ {message.text}
+
+ )}
+
+ {/* Recording Controls */}
+
+
{recording ? 'đ´ Recording...' : 'âĒ Ready to Record'}
+
+ {!recording ? (
+
+ setPathName(e.target.value)}
+ maxLength={100}
+ />
+
+ ) : (
+
+
+ đ {currentPath?.waypoints.length || 0} waypoints recorded
+
+
+
Path: {currentPath?.name}
+
Turtle: đĸ {selectedTurtle?.name || `Turtle ${selectedTurtle?.turtleID}`}
+
+
+
+ )}
+
+
+ {/* Paths List */}
+
+
đ Saved Paths ({paths.length})
+
+ {loading &&
Loading paths...
}
+
+ {!loading && paths.length === 0 && (
+
+
đ¤ī¸
+
No Paths Recorded
+
Record a path by selecting a turtle and clicking "Start Recording"
+
+ )}
+
+
+ {paths.map(path => {
+ const turtle = getTurtleById(path.turtleId);
+ return (
+
+
+
+
{path.name}
+ {path.description && (
+
{path.description}
+ )}
+
+ đĸ {turtle?.name || `Turtle ${path.turtleId}`}
+ đ {path.waypointCount || 0} waypoints
+ đ
{new Date(path.createdAt).toLocaleDateString()}
+
+
+
+
+
+
+
+
+
+
+ );
+ })}
+
+
+
+ {/* Path Details Modal */}
+ {selectedPath && (
+
setSelectedPath(null)}>
+
e.stopPropagation()}>
+
+
đ {selectedPath.name}
+
+
+
+
+ {selectedPath.description && (
+
{selectedPath.description}
+ )}
+
+
+
Waypoints ({selectedPath.waypoints?.length || 0})
+
+ {selectedPath.waypoints?.map((waypoint, idx) => (
+
+ {idx + 1}
+
+ ({waypoint.x}, {waypoint.y}, {waypoint.z})
+
+ {waypoint.action && (
+ {waypoint.action}
+ )}
+
+ ))}
+
+
+
+
+
Path Preview
+
+
+ Total Distance:
+
+ {selectedPath.waypoints?.length > 1
+ ? Math.floor(
+ selectedPath.waypoints.reduce((total, waypoint, i) => {
+ if (i === 0) return 0;
+ const prev = selectedPath.waypoints[i - 1];
+ return total + Math.sqrt(
+ Math.pow(waypoint.x - prev.x, 2) +
+ Math.pow(waypoint.y - prev.y, 2) +
+ Math.pow(waypoint.z - prev.z, 2)
+ );
+ }, 0)
+ )
+ : 0} blocks
+
+
+
+ Start:
+
+ ({selectedPath.waypoints?.[0]?.x}, {selectedPath.waypoints?.[0]?.y}, {selectedPath.waypoints?.[0]?.z})
+
+
+
+ End:
+
+ {selectedPath.waypoints?.length > 0 && (
+ <>
+ ({selectedPath.waypoints[selectedPath.waypoints.length - 1].x}, {' '}
+ {selectedPath.waypoints[selectedPath.waypoints.length - 1].y}, {' '}
+ {selectedPath.waypoints[selectedPath.waypoints.length - 1].z})
+ >
+ )}
+
+
+
+
+
+
+
+ )}
+
+ );
+};
+
+export default PathRecorder;