diff options
| author | Arne Rief <riearn@proton.me> | 2025-12-22 12:28:33 +0100 |
|---|---|---|
| committer | Arne Rief <riearn@proton.me> | 2025-12-22 12:29:13 +0100 |
| commit | 3818739c5901cc3f1d4596b24cfe1b827a2eca23 (patch) | |
| tree | 18e0c755386e6598f1cfe4193866b0b62a8f368d | |
| parent | 237f8ae6c29bbf485c312b2fed4d5ab4f99a4eff (diff) | |
FE Sidebar, create & move requests, BE create controller
| -rw-r--r-- | backend/src/controllers/createRobot.ts | 61 | ||||
| -rw-r--r-- | backend/src/routes/router.ts | 4 | ||||
| -rw-r--r-- | backend/src/types/express.d.ts | 6 | ||||
| -rw-r--r-- | backend/src/types/request.ts | 5 | ||||
| -rw-r--r-- | frontend/src/components/Sidebar.tsx | 246 | ||||
| -rw-r--r-- | frontend/src/pages/Dashboard.tsx | 90 | ||||
| -rw-r--r-- | frontend/src/styles/sidebar.css | 171 | ||||
| -rw-r--r-- | frontend/src/types/robot.ts | 6 |
8 files changed, 587 insertions, 2 deletions
diff --git a/backend/src/controllers/createRobot.ts b/backend/src/controllers/createRobot.ts new file mode 100644 index 0000000..fa483af --- /dev/null +++ b/backend/src/controllers/createRobot.ts @@ -0,0 +1,61 @@ +import { Request, Response } from "express"; +import { QueryResult } from "pg"; +import db from "../database/postgres.js"; +import redisClient from "../database/redis.js"; +import { CreateRequest } from "../types/request.js"; +import { Robot } from "../types/robot.js"; + +const ROBOTS_CACHE_KEY = "allMyRobots"; + +async function createRobot(req: Request, res: Response) { + const io = req.app.get("io"); + + const { name } = req.body as CreateRequest; + + if (!name || !name.trim()) { + return res.status(400).json({ + message: "Robot name is required.", + }); + } + + try { + const createNewRobotQuery: QueryResult<Robot> = await db.query( + ` + INSERT INTO robots + (name, status, lat, lon, robot_positions) + VALUES ($1, $2, $3, $4, $5) + RETURNING + id, name, status, lat, lon, robot_positions, created_at, updated_at;`, + [name.trim(), "idle", 51.340863, 12.375919, JSON.stringify([])] + ); + + const newRobot = createNewRobotQuery.rows[0]; + + // Delete old Redis cache, get all robots again and broadcast update to the frontend + await redisClient.del(ROBOTS_CACHE_KEY); + console.log("Redis cache deleted after robot creation."); + + const allRobotsQuery: QueryResult<Robot> = await db.query( + "SELECT * FROM robots ORDER BY id;" + ); + + const allRobots = allRobotsQuery.rows; + + io.emit("robots_update", allRobots); + console.log("WebSocket update with newly created robot."); + + return res.status(201).json({ + message: "Robot successfully created.", + robot: newRobot, + }); + } catch (error) { + console.error("Error creating the robot: ", error); + return res.status(500).json({ + message: "Internal server error during robot creation.", + error, + }); + } +} + +export default createRobot; + diff --git a/backend/src/routes/router.ts b/backend/src/routes/router.ts index e67a64b..b526a9b 100644 --- a/backend/src/routes/router.ts +++ b/backend/src/routes/router.ts @@ -1,4 +1,5 @@ import { Router } from "express"; +import createRobot from "../controllers/createRobot.js"; import generateAdmin from "../controllers/generateAdmin.js"; import getRobots from "../controllers/getRobots.js"; import loginUser from "../controllers/loginUser.js"; @@ -12,5 +13,8 @@ router.get("/admin-generation", generateAdmin); router.post("/auth/login", loginUser); // Get robots from database; protected route router.get("/robots", authenticateUser, getRobots); +// Create a new robot; protected route +router.post("/robots", authenticateUser, createRobot); export default router; + diff --git a/backend/src/types/express.d.ts b/backend/src/types/express.d.ts index 7ac6ca0..266a9f9 100644 --- a/backend/src/types/express.d.ts +++ b/backend/src/types/express.d.ts @@ -1,7 +1,12 @@ import type { AuthorizedUser } from "./user.js"; +import { Server } from "socket.io"; declare global { namespace Express { + interface Application { + io: Server; + } + interface Request { user?: AuthorizedUser; } @@ -9,3 +14,4 @@ declare global { } export {}; + diff --git a/backend/src/types/request.ts b/backend/src/types/request.ts index ef80738..6524b16 100644 --- a/backend/src/types/request.ts +++ b/backend/src/types/request.ts @@ -1,4 +1,9 @@ +export type CreateRequest = { + name: string; +}; + export type LoginRequest = { email: string; password: string; }; + 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<number, boolean>; + +type Props = { + activeSimulation: boolean; + errorMessage: string; + setErrorMessage: Dispatch<SetStateAction<string>>; + token: string | null; + robots: Robot[]; + onStartAllRobots: () => Promise<void>; + onStopAllRobots: () => Promise<void>; +}; + +const API_URL = import.meta.env.VITE_API_URL; + +function Sidebar({ + activeSimulation, + errorMessage, + setErrorMessage, + token, + robots, + onStartAllRobots, + onStopAllRobots, +}: Props) { + const [expandedRobots, setExpandedRobots] = useState<ExpandedRobotsState>( + {} + ); + 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<HTMLInputElement>) { + setNewRobotName(event.target.value); + if (errorMessage) { + setErrorMessage(""); + } + } + + return ( + <div className="sidebar"> + <div className="sidebar-robots-header"> + <h2>Your Robots</h2> + <button + className="btn btn-add-robot" + onClick={handleAddClick} + disabled={isAddingRobot} + > + + Neu + </button> + </div> + + {isAddingRobot && ( + <div className="add-robot-form"> + <input + type="text" + placeholder="Robot name..." + value={newRobotName} + onChange={handleInputChange} + disabled={isSubmitting} + autoFocus + /> + <div className="add-robot-actions"> + <button + className="btn btn-start" + onClick={handleSubmit} + disabled={isSubmitting} + > + {isSubmitting ? "Creating..." : "Create"} + </button> + <button + className="btn btn-stop" + onClick={handleCancel} + disabled={isSubmitting} + > + Cancel + </button> + </div> + </div> + )} + + {errorMessage && ( + <div className="error-message">{errorMessage}</div> + )} + + <div className="simulation-actions"> + <button + className="btn btn-start" + onClick={onStartAllRobots} + disabled={activeSimulation} + > + Start simulation + </button> + + <button + className="btn btn-stop" + onClick={onStopAllRobots} + disabled={!activeSimulation} + > + Stop simulation + </button> + </div> + + <ul className="sidebar-robot-list"> + {robots.map((robot) => { + const isExpanded = expandedRobots[robot.id]; + + return ( + <li key={robot.id}> + <p className="robot-name">{robot.name}</p> + + <p> + Status:{" "} + <span + className={`robot-status-${robot.status}`} + > + {robot.status} + </span> + </p> + + <p className="robot-coordinates-label">Position:</p> + <ul className="robot-coordinates"> + <li>Lat: {robot.lat}</li> + <li>Lon: {robot.lon}</li> + </ul> + + <button + className="btn btn-robot-history-toggle" + onClick={() => toggleRobotHistory(robot.id)} + aria-expanded={isExpanded} + > + Position history + <span + className={`arrow ${ + isExpanded ? "open" : "" + }`} + > + ▾ + </span> + </button> + + <div + className={`robot-history ${ + isExpanded ? "expanded" : "" + }`} + > + <ul> + {robot.robot_positions?.length ? ( + robot.robot_positions.map( + (pos, index) => ( + <li key={index}> + {`Lat: ${pos.lat}, Lon: ${pos.lon}`} + </li> + ) + ) + ) : ( + <li className="robot-history-empty"> + No previous positions. + </li> + )} + </ul> + </div> + </li> + ); + })} + </ul> + </div> + ); +} + +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<string>(""); const [isLoading, setIsLoading] = useState<boolean>(true); + const [isSimulationActive, setIsSimulationActive] = useState(false); const [robots, setRobots] = useState<Robot[]>([]); 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() { <FadeLoader /> ) : ( <div className="dashboard-page"> - <CityMap robots={robots} /> <Header user={user} logout={handleLogout} /> + <CityMap robots={robots} /> + <Sidebar + activeSimulation={isSimulationActive} + errorMessage={errorMessage} + setErrorMessage={setErrorMessage} + token={token} + robots={robots} + onStartAllRobots={handleStartAllRobots} + onStopAllRobots={handleStopAllRobots} + /> </div> ); } 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; +}; + |
