From 8bcee0b16c470b675be2e0ace6d8f6612b51dafa Mon Sep 17 00:00:00 2001 From: MayaTheShy Date: Thu, 19 Feb 2026 22:58:14 -0500 Subject: [PATCH] feat: Add PathRecorder component for recording and managing turtle paths with UI and functionality --- client/src/App.jsx | 12 +- client/src/components/PathRecorder.css | 532 +++++++++++++++++++++++++ client/src/components/PathRecorder.jsx | 376 +++++++++++++++++ 3 files changed, 919 insertions(+), 1 deletion(-) create mode 100644 client/src/components/PathRecorder.css create mode 100644 client/src/components/PathRecorder.jsx 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} + /> +