summaryrefslogtreecommitdiff
path: root/frontend/src
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/src')
-rw-r--r--frontend/src/assets/robot-head.svg19
-rw-r--r--frontend/src/assets/user-icon.svg5
-rw-r--r--frontend/src/components/CityMap.tsx98
-rw-r--r--frontend/src/components/Header.tsx31
-rw-r--r--frontend/src/pages/Dashboard.tsx89
-rw-r--r--frontend/src/pages/Login.tsx11
-rw-r--r--frontend/src/styles/dashboard.css11
-rw-r--r--frontend/src/styles/header.css57
-rw-r--r--frontend/src/styles/index.css2
-rw-r--r--frontend/src/types/error.ts4
-rw-r--r--frontend/src/types/login.ts7
-rw-r--r--frontend/src/types/robot.ts20
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[];
+};