diff options
Diffstat (limited to 'frontend')
| -rw-r--r-- | frontend/src/assets/robot-head.svg | 19 | ||||
| -rw-r--r-- | frontend/src/assets/user-icon.svg | 5 | ||||
| -rw-r--r-- | frontend/src/components/CityMap.tsx | 98 | ||||
| -rw-r--r-- | frontend/src/components/Header.tsx | 31 | ||||
| -rw-r--r-- | frontend/src/pages/Dashboard.tsx | 89 | ||||
| -rw-r--r-- | frontend/src/pages/Login.tsx | 11 | ||||
| -rw-r--r-- | frontend/src/styles/dashboard.css | 11 | ||||
| -rw-r--r-- | frontend/src/styles/header.css | 57 | ||||
| -rw-r--r-- | frontend/src/styles/index.css | 2 | ||||
| -rw-r--r-- | frontend/src/types/error.ts | 4 | ||||
| -rw-r--r-- | frontend/src/types/login.ts | 7 | ||||
| -rw-r--r-- | frontend/src/types/robot.ts | 20 |
12 files changed, 339 insertions, 15 deletions
diff --git a/frontend/src/assets/robot-head.svg b/frontend/src/assets/robot-head.svg new file mode 100644 index 0000000..8dcf6c4 --- /dev/null +++ b/frontend/src/assets/robot-head.svg @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="iso-8859-1"?>
+<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg fill="#9c27b0" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
+ width="32px" height="32px" viewBox="0 0 45.342 45.342"
+ xml:space="preserve">
+<g>
+ <path d="M40.462,19.193H39.13v-1.872c0-3.021-2.476-5.458-5.496-5.458h-8.975v-4.49c1.18-0.683,1.973-1.959,1.973-3.423
+ c0-2.182-1.771-3.95-3.951-3.95c-2.183,0-3.963,1.769-3.963,3.95c0,1.464,0.785,2.74,1.965,3.423v4.49h-8.961
+ c-3.021,0-5.448,2.437-5.448,5.458v1.872H4.893c-1.701,0-3.091,1.407-3.091,3.108v6.653c0,1.7,1.39,3.095,3.091,3.095h1.381v1.887
+ c0,3.021,2.427,5.442,5.448,5.442h2.564v2.884c0,1.701,1.393,3.08,3.094,3.08h10.596c1.701,0,3.08-1.379,3.08-3.08v-2.883h2.578
+ c3.021,0,5.496-2.422,5.496-5.443V32.05h1.332c1.701,0,3.078-1.394,3.078-3.095v-6.653C43.54,20.601,42.165,19.193,40.462,19.193z
+ M10.681,21.271c0-1.999,1.621-3.618,3.619-3.618c1.998,0,3.617,1.619,3.617,3.618c0,1.999-1.619,3.618-3.617,3.618
+ C12.302,24.889,10.681,23.27,10.681,21.271z M27.606,34.473H17.75c-1.633,0-2.957-1.316-2.957-2.951
+ c0-1.633,1.324-2.949,2.957-2.949h9.857c1.633,0,2.957,1.316,2.957,2.949S29.239,34.473,27.606,34.473z M31.056,24.889
+ c-1.998,0-3.618-1.619-3.618-3.618c0-1.999,1.62-3.618,3.618-3.618c1.999,0,3.619,1.619,3.619,3.618
+ C34.675,23.27,33.055,24.889,31.056,24.889z"/>
+</g>
+</svg>
diff --git a/frontend/src/assets/user-icon.svg b/frontend/src/assets/user-icon.svg new file mode 100644 index 0000000..f2a5d45 --- /dev/null +++ b/frontend/src/assets/user-icon.svg @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
+<svg width="32" height="32px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M12 12C14.7614 12 17 9.76142 17 7C17 4.23858 14.7614 2 12 2C9.23858 2 7 4.23858 7 7C7 9.76142 9.23858 12 12 12Z" fill="#4caf50"/>
+<path d="M12.0002 14.5C6.99016 14.5 2.91016 17.86 2.91016 22C2.91016 22.28 3.13016 22.5 3.41016 22.5H20.5902C20.8702 22.5 21.0902 22.28 21.0902 22C21.0902 17.86 17.0102 14.5 12.0002 14.5Z" fill="#4caf50"/>
+</svg>
diff --git a/frontend/src/components/CityMap.tsx b/frontend/src/components/CityMap.tsx new file mode 100644 index 0000000..b61e69a --- /dev/null +++ b/frontend/src/components/CityMap.tsx @@ -0,0 +1,98 @@ +import { Feature, Map, View } from "ol"; +import { defaults } from "ol/control"; +import { Point } from "ol/geom"; +import TileLayer from "ol/layer/Tile"; +import VectorLayer from "ol/layer/Vector"; +import { fromLonLat, transformExtent } from "ol/proj"; +import { OSM } from "ol/source"; +import VectorSource from "ol/source/Vector"; +import Fill from "ol/style/Fill"; +import Icon from "ol/style/Icon"; +import Stroke from "ol/style/Stroke"; +import Style from "ol/style/Style"; +import Text from "ol/style/Text"; +import { useEffect, useRef } from "react"; +import robotMarker from "../assets/robot-head.svg"; +import "../styles/dashboard.css"; +import type { Robot } from "../types/robot"; + +type Props = { + robots: Robot[]; +}; + +function CityMap({ robots }: Props) { + const mapRef = useRef<HTMLDivElement | null>(null); + const mapInstance = useRef<Map | null>(null); + const markerLayer = useRef<VectorLayer<VectorSource> | null>(null); + + useEffect(() => { + if (mapInstance.current) return; // prevent re-init + + markerLayer.current = new VectorLayer({ + source: new VectorSource(), + }); + + const leipzigCenter = fromLonLat([12.375919, 51.340863]); + const leipzigArea = transformExtent( + // west, south, east, north; transform from WGS84 lat-long system to Web Mercator system + [12.22, 51.26, 12.54, 51.44], + "EPSG:4326", + "EPSG:3857" + ); + + mapInstance.current = new Map({ + target: mapRef.current as HTMLDivElement, + controls: defaults({ attribution: false }), + layers: [ + new TileLayer({ + source: new OSM(), + }), + markerLayer.current, + ], + view: new View({ + center: leipzigCenter, + zoom: 15, + extent: leipzigArea, + }), + }); + }, []); + + useEffect(() => { + if (!markerLayer.current) return; + + const source = markerLayer.current.getSource(); + if (!source) return; + + source.clear(); + + robots.forEach((robot) => { + const feature = new Feature({ + geometry: new Point(fromLonLat([robot?.lon, robot?.lat])), + robotId: robot?.id, + }); + + feature.setStyle( + new Style({ + image: new Icon({ + src: robotMarker, + scale: 1.2, + anchor: [0.5, 0.4], // anchor label bottom center of icon + }), + text: new Text({ + text: robot?.name, + font: "bold 14px sans-serif", + fill: new Fill({ color: "#000" }), + stroke: new Stroke({ color: "#fff", width: 3 }), // outline for better visibility + offsetY: -25, // move label above the marker + }), + }) + ); + + source.addFeature(feature); + }); + }, [robots]); + + return <div id="map" ref={mapRef}></div>; +} + +export default CityMap; diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx new file mode 100644 index 0000000..b9a51b8 --- /dev/null +++ b/frontend/src/components/Header.tsx @@ -0,0 +1,31 @@ +import "../styles/button.css"; +import "../styles/header.css"; +import Logo from "./Logo"; +import userIcon from "../assets/user-icon.svg"; +import type { AuthorizedUser } from "../types/login"; + +type Props = { + user: AuthorizedUser; + logout: () => Promise<void>; +}; + +function Header({ user, logout }: Props) { + return ( + <header className="header"> + <Logo /> + + <div className="header-user"> + <span className="header-user-info"> + <img src={userIcon} alt="User Icon" /> + <p>{user?.email}</p> + </span> + + <button className="btn btn-stop btn-logout" onClick={logout}> + Logout + </button> + </div> + </header> + ); +} + +export default Header; diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index f306c53..6b43bf8 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,5 +1,92 @@ +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { FadeLoader } from "react-spinners"; +import { io } from "socket.io-client"; +import CityMap from "../components/CityMap"; +import Header from "../components/Header"; +import API_URL from "../config"; +import "../styles/dashboard.css"; +import type { ErrorResponse } from "../types/error"; +import type { AuthorizedUser } from "../types/login"; +import type { Robot, RobotsResponse } from "../types/robot"; + function Dashboard() { - return <h1>Placeholder</h1>; + const [isLoading, setIsLoading] = useState<boolean>(true); + const [robots, setRobots] = useState<Robot[]>([]); + + const navigate = useNavigate(); + + const userString = localStorage.getItem("user"); + const user: AuthorizedUser = userString ? JSON.parse(userString) : null; + const token = localStorage.getItem("token-robot-tracker"); + + async function handleLogout() { + localStorage.removeItem("token-robot-tracker"); + localStorage.removeItem("user"); + navigate("/login", { replace: true }); + } + + useEffect(() => { + // Additional safety check to protect this page from unauthorized access + if (!token || token === "undefined" || token === "null") { + navigate("/login"); + return; + } + + // Request robot data from backend + async function fetchRobots() { + try { + setIsLoading(true); + + const response = await fetch(`${API_URL}/robots`, { + method: "GET", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + const errorData: ErrorResponse = await response.json(); + throw new Error( + errorData.message || + `Failed to load the robots: ${response.status}` + ); + } + + const data: RobotsResponse = await response.json(); + setRobots(data.data); + } catch (error) { + console.error("Failed to load the robots:", error); + } finally { + setIsLoading(false); + } + } + + fetchRobots(); + + // Establish WebSocket connection to backend + const socket = io(API_URL); + + // Listen for real-time robot updates + socket.on("robots_update", (updatedRobots) => { + setRobots(updatedRobots); + }); + + // Cleanup when component unmounts + return () => { + socket.disconnect(); + }; + }, [token, navigate]); + + return isLoading ? ( + <FadeLoader /> + ) : ( + <div className="dashboard-page"> + <CityMap robots={robots} /> + <Header user={user} logout={handleLogout} /> + </div> + ); } export default Dashboard; diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 2e99a2f..11b7c11 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -3,13 +3,10 @@ import { useNavigate } from "react-router-dom"; import { FadeLoader } from "react-spinners"; import Logo from "../components/Logo"; import API_URL from "../config"; -import "../styles/Button.css"; -import "../styles/Login.css"; -import type { - ErrorResponse, - LoginFormData, - LoginResponse, -} from "../types/login"; +import "../styles/button.css"; +import "../styles/login.css"; +import type { ErrorResponse } from "../types/error"; +import type { LoginFormData, LoginResponse } from "../types/login"; const EMPTY_FORM_DATA: LoginFormData = { email: "", diff --git a/frontend/src/styles/dashboard.css b/frontend/src/styles/dashboard.css new file mode 100644 index 0000000..c24deae --- /dev/null +++ b/frontend/src/styles/dashboard.css @@ -0,0 +1,11 @@ +.dashboard-page { + position: relative; + width: 100vw; + height: 100vh; + overflow: hidden; +} + +#map { + position: absolute; + inset: 0; /* = top,right,bottom,left: 0 */ +} diff --git a/frontend/src/styles/header.css b/frontend/src/styles/header.css new file mode 100644 index 0000000..42c429f --- /dev/null +++ b/frontend/src/styles/header.css @@ -0,0 +1,57 @@ +.header { + background: var(--card-bg); + backdrop-filter: blur(8px); + border-radius: var(--border-radius); + box-shadow: var(--box-shadow-dark); + + display: flex; + align-items: center; + justify-content: space-between; + + height: 60px; + padding: 8px 16px; + + position: absolute; + top: 16px; + left: 16px; + right: 16px; + + max-width: 960px; + margin: 0 auto; + z-index: 10; +} + +.header-user { + display: flex; + align-items: center; + justify-content: center; + gap: 20px; +} + +.header-user-info { + display: flex; + align-items: center; + justify-content: center; + gap: 18px; + + & > p { + font-weight: bold; + } +} + +/* Mobile View */ +@media screen and (max-width: 768px) { + .header { + flex-direction: column; + height: fit-content; + max-width: 360px; + } + + .header-user { + gap: var(--gap-normal); + } + + .header-user-info { + gap: var(--gap-small); + } +} diff --git a/frontend/src/styles/index.css b/frontend/src/styles/index.css index 23807a5..dcc2590 100644 --- a/frontend/src/styles/index.css +++ b/frontend/src/styles/index.css @@ -14,8 +14,8 @@ --color-stop: #ed6c02; --color-stop-dark: #e65100; /* Spacing vars */ - --gap-small: 8px; --gap-normal: 12px; + --gap-small: 8px; /* Text vars */ --text-small: 0.9rem; diff --git a/frontend/src/types/error.ts b/frontend/src/types/error.ts new file mode 100644 index 0000000..c848e34 --- /dev/null +++ b/frontend/src/types/error.ts @@ -0,0 +1,4 @@ +export type ErrorResponse = { + message: string; + error?: unknown; +}; diff --git a/frontend/src/types/login.ts b/frontend/src/types/login.ts index 73168be..f0cdd8d 100644 --- a/frontend/src/types/login.ts +++ b/frontend/src/types/login.ts @@ -1,7 +1,7 @@ export type AuthorizedUser = { id: number; email: string; - createdAt: Date; + createdAt: string; }; export type LoginFormData = { @@ -14,8 +14,3 @@ export type LoginResponse = { user: AuthorizedUser; token: string; }; - -export type ErrorResponse = { - message: string; - error?: unknown; -}; diff --git a/frontend/src/types/robot.ts b/frontend/src/types/robot.ts new file mode 100644 index 0000000..40ce282 --- /dev/null +++ b/frontend/src/types/robot.ts @@ -0,0 +1,20 @@ +export type RobotPosition = { + lat: number; + lon: number; +}; + +export type Robot = { + id: number; + name: string; + status: "idle" | "moving"; + lat: number; + lon: number; + robot_positions: RobotPosition[]; + created_at: string; + updated_at: string; +}; + +export type RobotsResponse = { + source: "cache" | "database"; + data: Robot[]; +}; |
