From 3818739c5901cc3f1d4596b24cfe1b827a2eca23 Mon Sep 17 00:00:00 2001 From: Arne Rief Date: Mon, 22 Dec 2025 12:28:33 +0100 Subject: FE Sidebar, create & move requests, BE create controller --- frontend/src/components/Sidebar.tsx | 246 ++++++++++++++++++++++++++++++++++++ frontend/src/pages/Dashboard.tsx | 90 ++++++++++++- frontend/src/styles/sidebar.css | 171 +++++++++++++++++++++++++ frontend/src/types/robot.ts | 6 + 4 files changed, 511 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/Sidebar.tsx create mode 100644 frontend/src/styles/sidebar.css (limited to 'frontend/src') diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx new file mode 100644 index 0000000..2e66865 --- /dev/null +++ b/frontend/src/components/Sidebar.tsx @@ -0,0 +1,246 @@ +import { + useState, + type ChangeEvent, + type Dispatch, + type SetStateAction, +} from "react"; +import "../styles/Button.css"; +import "../styles/Sidebar.css"; +import type { ErrorResponse } from "../types/error"; +import type { CreateRobotResponse, Robot } from "../types/robot"; + +type ExpandedRobotsState = Record; + +type Props = { + activeSimulation: boolean; + errorMessage: string; + setErrorMessage: Dispatch>; + token: string | null; + robots: Robot[]; + onStartAllRobots: () => Promise; + onStopAllRobots: () => Promise; +}; + +const API_URL = import.meta.env.VITE_API_URL; + +function Sidebar({ + activeSimulation, + errorMessage, + setErrorMessage, + token, + robots, + onStartAllRobots, + onStopAllRobots, +}: Props) { + const [expandedRobots, setExpandedRobots] = useState( + {} + ); + const [isAddingRobot, setIsAddingRobot] = useState(false); + const [newRobotName, setNewRobotName] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + + function toggleRobotHistory(robotId: number) { + setExpandedRobots((prev) => ({ + ...prev, + [robotId]: !prev[robotId], + })); + } + + function handleAddClick() { + setIsAddingRobot(true); + setNewRobotName(""); + setErrorMessage(""); + } + + function handleCancel() { + setIsAddingRobot(false); + setNewRobotName(""); + setErrorMessage(""); + } + + async function handleSubmit() { + if (!newRobotName.trim()) { + setErrorMessage("Please enter a name for the robot."); + return; + } + + setIsSubmitting(true); + setErrorMessage(""); + + try { + const response = await fetch(`${API_URL}/robots`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ name: newRobotName.trim() }), + }); + + if (!response.ok) { + const errorData: ErrorResponse = await response.json(); + throw new Error( + errorData.message || + `Error creating the new robot: ${response.status}` + ); + } + + const data: CreateRobotResponse = await response.json(); + console.log("Robot created successfully: ", data); + + // Reset form and close input field + setNewRobotName(""); + setIsAddingRobot(false); + } catch (error) { + console.error("Error creating the new robot:", error); + + if (error instanceof Error) { + setErrorMessage(error.message); + } else { + setErrorMessage("Error creating the new robot."); + } + } finally { + setIsSubmitting(false); + } + } + + function handleInputChange(event: ChangeEvent) { + setNewRobotName(event.target.value); + if (errorMessage) { + setErrorMessage(""); + } + } + + return ( +
+
+

Your Robots

+ +
+ + {isAddingRobot && ( +
+ +
+ + +
+
+ )} + + {errorMessage && ( +
{errorMessage}
+ )} + +
+ + + +
+ +
    + {robots.map((robot) => { + const isExpanded = expandedRobots[robot.id]; + + return ( +
  • +

    {robot.name}

    + +

    + Status:{" "} + + {robot.status} + +

    + +

    Position:

    +
      +
    • Lat: {robot.lat}
    • +
    • Lon: {robot.lon}
    • +
    + + + +
    +
      + {robot.robot_positions?.length ? ( + robot.robot_positions.map( + (pos, index) => ( +
    • + {`Lat: ${pos.lat}, Lon: ${pos.lon}`} +
    • + ) + ) + ) : ( +
    • + No previous positions. +
    • + )} +
    +
    +
  • + ); + })} +
+
+ ); +} + +export default Sidebar; + diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 6b43bf8..b8dd2c4 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -4,6 +4,7 @@ import { FadeLoader } from "react-spinners"; import { io } from "socket.io-client"; import CityMap from "../components/CityMap"; import Header from "../components/Header"; +import Sidebar from "../components/Sidebar"; import API_URL from "../config"; import "../styles/dashboard.css"; import type { ErrorResponse } from "../types/error"; @@ -11,7 +12,9 @@ import type { AuthorizedUser } from "../types/login"; import type { Robot, RobotsResponse } from "../types/robot"; function Dashboard() { + const [errorMessage, setErrorMessage] = useState(""); const [isLoading, setIsLoading] = useState(true); + const [isSimulationActive, setIsSimulationActive] = useState(false); const [robots, setRobots] = useState([]); const navigate = useNavigate(); @@ -26,6 +29,73 @@ function Dashboard() { navigate("/login", { replace: true }); } + async function handleStartAllRobots() { + setIsSimulationActive(true); + + try { + const response = await fetch(`${API_URL}/robots/move`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + const errorData: ErrorResponse = await response.json(); + throw new Error( + errorData.message || "Failed to set all robots moving." + ); + } + + console.log("All robots set moving."); + } catch (error) { + console.error("Error starting robots:", error); + + if (error instanceof Error) { + setErrorMessage(error.message); + } else { + setErrorMessage("An unexpected error occurred."); + } + + setIsSimulationActive(false); + } + } + + async function handleStopAllRobots() { + setIsSimulationActive(false); + + try { + const response = await fetch(`${API_URL}/robots/stop`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + const errorData: ErrorResponse = await response.json(); + throw new Error( + errorData.message || "Failed to set all robots idle." + ); + } + + console.log("All robots set idle."); + } catch (error) { + console.error("Error stopping robots:", error); + + if (error instanceof Error) { + setErrorMessage(error.message); + } else { + setErrorMessage("An unexpected error occurred."); + } + + setIsSimulationActive(true); + } + } + + // Request robot data from backend on component mount useEffect(() => { // Additional safety check to protect this page from unauthorized access if (!token || token === "undefined" || token === "null") { @@ -33,7 +103,6 @@ function Dashboard() { return; } - // Request robot data from backend async function fetchRobots() { try { setIsLoading(true); @@ -58,6 +127,13 @@ function Dashboard() { setRobots(data.data); } catch (error) { console.error("Failed to load the robots:", error); + + if (error instanceof Error) { + setErrorMessage(error.message); + } else { + setErrorMessage("An unexpected error occurred."); + } + } finally { setIsLoading(false); } @@ -83,10 +159,20 @@ function Dashboard() { ) : (
-
+ +
); } export default Dashboard; + diff --git a/frontend/src/styles/sidebar.css b/frontend/src/styles/sidebar.css new file mode 100644 index 0000000..f7bd0b2 --- /dev/null +++ b/frontend/src/styles/sidebar.css @@ -0,0 +1,171 @@ +.sidebar { + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(8px); + border-radius: 12px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + + display: flex; + flex-direction: column; + gap: 16px; + + position: absolute; + top: 120px; /* below header */ + right: 16px; + bottom: 16px; + + padding: 16px; + overflow: hidden; + width: 300px; + z-index: 10; + + & h2 { + margin: 0; + text-align: center; + } +} + +.sidebar-robots-header { + display: flex; + align-items: center; + justify-content: space-evenly; +} + +.sidebar-robot-list { + flex: 1; + list-style: none; + margin: 0; + padding: 0; + overflow-y: auto; + + & li { + border-bottom: 1px solid #eee; + padding: 12px 0; + line-height: 1.8rem; + } + + & li p { + font-weight: bold; + } + + & li p:not(:first-of-type) { + margin: 0; + } +} + +.add-robot-form { + border-bottom: 1px solid #333; + padding-bottom: 16px; + + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.add-robot-actions { + display: flex; + align-items: center; + justify-content: space-around; + gap: 8px; + width: 100%; + + & > button.btn { + max-width: 100px; + padding: 8px; + } +} + +.robot-name { + font-size: 1.1rem; + margin-top: 0.2rem; + margin-bottom: 0.8rem; + text-decoration: underline; +} + +.robot-status-idle { + color: #ed6c02; +} + +.robot-status-moving { + color: #4caf50; +} + +.robot-coordinates { + padding-left: 20px; + + & li { + border-bottom: none; + padding: 0; + } +} + +/* History/Previous positions log accordion */ +.robot-history { + max-height: 0; + overflow: hidden; + + transition: max-height 0.3s ease; + + &.expanded { + max-height: 200px; + } + + & ul { + list-style: inside; + padding-left: 16px; + margin: 8px 0 0; + } + + & li { + font-size: 0.9rem; + padding: 4px 0; + border-bottom: none; + } +} + +.robot-history-empty { + color: #999; + font-style: italic; +} + +.arrow { + font-size: 1.2rem; + transition: transform 0.2s ease; + + &.open { + transform: rotate(180deg); + } +} + +/* Mobile View */ +@media screen and (max-width: 768px) { + .sidebar { + left: 16px; + right: 16px; + bottom: 16px; + top: auto; + + border-radius: 16px; + height: 30vh; + width: auto; + } + + .simulation-actions { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + } + + .add-robot-actions > button, + .simulation-actions > button { + padding: 8px; + margin: 0; + font-size: 0.8rem; + } + + .add-robot-form { + gap: 8px; + } +} + diff --git a/frontend/src/types/robot.ts b/frontend/src/types/robot.ts index 40ce282..965afea 100644 --- a/frontend/src/types/robot.ts +++ b/frontend/src/types/robot.ts @@ -18,3 +18,9 @@ export type RobotsResponse = { source: "cache" | "database"; data: Robot[]; }; + +export type CreateRobotResponse = { + message: string; + robot: Robot; +}; + -- cgit v1.2.3