summaryrefslogtreecommitdiff
path: root/frontend/src
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/src')
-rw-r--r--frontend/src/App.tsx23
-rw-r--r--frontend/src/assets/map-blurred.pngbin0 -> 258720 bytes
-rw-r--r--frontend/src/assets/robot-outline.svg31
-rw-r--r--frontend/src/components/Logo.tsx12
-rw-r--r--frontend/src/components/ProtectedRoute.tsx18
-rw-r--r--frontend/src/config.ts4
-rw-r--r--frontend/src/main.tsx10
-rw-r--r--frontend/src/pages/Dashboard.tsx5
-rw-r--r--frontend/src/pages/Login.tsx148
-rw-r--r--frontend/src/styles/button.css77
-rw-r--r--frontend/src/styles/index.css94
-rw-r--r--frontend/src/styles/login.css62
-rw-r--r--frontend/src/types/login.ts21
13 files changed, 505 insertions, 0 deletions
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
new file mode 100644
index 0000000..e2e7253
--- /dev/null
+++ b/frontend/src/App.tsx
@@ -0,0 +1,23 @@
+import { Navigate, Route, Routes } from "react-router-dom";
+import ProtectedRoute from "./components/ProtectedRoute";
+import Dashboard from "./pages/Dashboard";
+import Login from "./pages/Login";
+
+function App() {
+ return (
+ <Routes>
+ <Route path="/login" element={<Login />} />
+ <Route
+ path="/dashboard"
+ element={
+ <ProtectedRoute>
+ <Dashboard />
+ </ProtectedRoute>
+ }
+ />
+ <Route path="*" element={<Navigate to="/login" replace />} />
+ </Routes>
+ );
+}
+
+export default App;
diff --git a/frontend/src/assets/map-blurred.png b/frontend/src/assets/map-blurred.png
new file mode 100644
index 0000000..a9f6b1d
--- /dev/null
+++ b/frontend/src/assets/map-blurred.png
Binary files differ
diff --git a/frontend/src/assets/robot-outline.svg b/frontend/src/assets/robot-outline.svg
new file mode 100644
index 0000000..beca628
--- /dev/null
+++ b/frontend/src/assets/robot-outline.svg
@@ -0,0 +1,31 @@
+<?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="#2e7d32" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
+ width="48px" height="48px" viewBox="0 0 83.818 83.818"
+ xml:space="preserve">
+<g>
+ <g>
+ <path d="M23.772,51.659h7.416c0.552,0,1,0.446,1,1v7.5h20.168v-7.5c0-0.554,0.447-1,1-1h7.416v-6.5h-37V51.659z M32.272,46.743h20
+ c0.551,0,1,0.446,1,1c0,0.553-0.449,1-1,1h-20c-0.552,0-1-0.447-1-1C31.272,47.189,31.72,46.743,32.272,46.743z"/>
+ <path d="M28.022,30.659c0,2.344,1.907,4.25,4.25,4.25c2.343,0,4.25-1.906,4.25-4.25c0-2.344-1.907-4.25-4.25-4.25
+ C29.929,26.409,28.022,28.316,28.022,30.659z"/>
+ <path d="M48.022,30.659c0,2.344,1.906,4.25,4.25,4.25c2.342,0,4.25-1.906,4.25-4.25c0-2.344-1.908-4.25-4.25-4.25
+ C49.929,26.409,48.022,28.316,48.022,30.659z"/>
+ <rect x="57.022" y="57.576" width="3.75" height="9.167"/>
+ <rect x="23.772" y="57.576" width="3.75" height="9.167"/>
+ <path d="M41.909,0C18.763,0,0.001,18.764,0.001,41.91c0,23.145,18.763,41.908,41.908,41.908S83.817,65.054,83.817,41.91
+ C83.817,18.764,65.054,0,41.909,0z M42.272,15.409c11.303,0,20.5,9.196,20.5,20.5c0,0.553-0.449,1-1,1c-0.553,0-1-0.447-1-1
+ c0-10.201-8.3-18.5-18.5-18.5c-10.202,0-18.5,8.299-18.5,18.5c0,0.553-0.448,1-1,1c-0.552,0-1-0.447-1-1
+ C21.772,24.606,30.969,15.409,42.272,15.409z M58.522,30.659c0,3.446-2.805,6.25-6.25,6.25c-3.447,0-6.25-2.804-6.25-6.25
+ c0-3.446,2.803-6.25,6.25-6.25C55.718,24.409,58.522,27.213,58.522,30.659z M38.522,30.659c0,3.446-2.804,6.25-6.25,6.25
+ c-3.446,0-6.25-2.804-6.25-6.25c0-3.446,2.804-6.25,6.25-6.25C35.718,24.409,38.522,27.213,38.522,30.659z M22.772,39.909h39
+ c0.551,0,1,0.447,1,1c0,0.553-0.449,1-1,1h-39c-0.552,0-1-0.447-1-1C21.772,40.356,22.22,39.909,22.772,39.909z M29.522,67.743
+ c0,0.553-0.448,1-1,1h-5.75c-0.552,0-1-0.447-1-1V56.576c0-0.553,0.448-1,1-1h5.75c0.552,0,1,0.447,1,1V67.743z M62.772,67.743
+ c0,0.553-0.449,1-1,1h-5.75c-0.553,0-1-0.447-1-1V56.576c0-0.553,0.447-1,1-1h5.75c0.551,0,1,0.447,1,1V67.743z M73.188,30.659
+ v13.5c0,0.553-0.447,1-1,1h-9.416v7.5c0,0.553-0.449,1-1,1h-7.416v7.5c0,0.553-0.449,1-1,1H31.188c-0.552,0-1-0.447-1-1v-7.5
+ h-7.416c-0.552,0-1-0.447-1-1v-7.5h-9.75c-0.552,0-1-0.447-1-1V29.743c0-0.553,0.448-1,1-1c0.552,0,1,0.447,1,1v13.416h9.75h39
+ h9.416v-12.5c0-0.553,0.448-1,1-1C72.741,29.659,73.188,30.107,73.188,30.659z"/>
+ </g>
+</g>
+</svg>
diff --git a/frontend/src/components/Logo.tsx b/frontend/src/components/Logo.tsx
new file mode 100644
index 0000000..4488a7e
--- /dev/null
+++ b/frontend/src/components/Logo.tsx
@@ -0,0 +1,12 @@
+import logo from "../assets/robot-outline.svg";
+
+function Logo() {
+ return (
+ <div className="logo">
+ <img src={logo} alt="Robot Tracker Logo" />
+ <h1>Robot Tracker</h1>
+ </div>
+ );
+}
+
+export default Logo;
diff --git a/frontend/src/components/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute.tsx
new file mode 100644
index 0000000..a9d233c
--- /dev/null
+++ b/frontend/src/components/ProtectedRoute.tsx
@@ -0,0 +1,18 @@
+import type { ReactNode } from "react";
+import { Navigate } from "react-router-dom";
+
+type Props = {
+ children: ReactNode;
+};
+
+function ProtectedRoute({ children }: Props) {
+ const token = localStorage.getItem("token-robot-tracker");
+
+ if (!token || token === "undefined" || token === "null") {
+ return <Navigate to="/login" replace />;
+ }
+
+ return children;
+}
+
+export default ProtectedRoute;
diff --git a/frontend/src/config.ts b/frontend/src/config.ts
new file mode 100644
index 0000000..d7c3a7e
--- /dev/null
+++ b/frontend/src/config.ts
@@ -0,0 +1,4 @@
+const API_URL: string =
+ import.meta.env.VITE_API_URL ?? "https://localhost:3000";
+
+export default API_URL;
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
new file mode 100644
index 0000000..d2644ca
--- /dev/null
+++ b/frontend/src/main.tsx
@@ -0,0 +1,10 @@
+import { createRoot } from "react-dom/client";
+import { BrowserRouter } from "react-router-dom";
+import App from "./App";
+import "./styles/index.css";
+
+createRoot(document.getElementById("root")!).render(
+ <BrowserRouter>
+ <App />
+ </BrowserRouter>
+);
diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx
new file mode 100644
index 0000000..f306c53
--- /dev/null
+++ b/frontend/src/pages/Dashboard.tsx
@@ -0,0 +1,5 @@
+function Dashboard() {
+ return <h1>Placeholder</h1>;
+}
+
+export default Dashboard;
diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx
new file mode 100644
index 0000000..2e99a2f
--- /dev/null
+++ b/frontend/src/pages/Login.tsx
@@ -0,0 +1,148 @@
+import { useEffect, useState, type ChangeEvent, type FormEvent } from "react";
+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";
+
+const EMPTY_FORM_DATA: LoginFormData = {
+ email: "",
+ password: "",
+};
+
+function Login() {
+ const navigate = useNavigate();
+
+ const [errorMessage, setErrorMessage] = useState<string>("");
+ const [formData, setFormData] = useState<LoginFormData>(EMPTY_FORM_DATA);
+ const [isLoading, setIsLoading] = useState<boolean>(false);
+
+ function handleUserInput(event: ChangeEvent<HTMLInputElement>) {
+ if (errorMessage) {
+ setErrorMessage("");
+ }
+
+ setFormData((oldFormData) => ({
+ ...oldFormData,
+ [event.target.name]: event.target.value,
+ }));
+ }
+
+ async function handleLogin(event: FormEvent<HTMLFormElement>) {
+ event.preventDefault();
+ setErrorMessage("");
+
+ if (!formData.email || !formData.password) {
+ setErrorMessage("E-mail address and password are required.");
+ return;
+ }
+
+ setIsLoading(true);
+
+ try {
+ const response = await fetch(`${API_URL}/auth/login`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(formData),
+ });
+
+ if (!response.ok) {
+ const errorData: ErrorResponse = await response.json();
+ throw new Error(
+ errorData.message ||
+ `Login failed, status: ${response.status}`
+ );
+ }
+
+ const data: LoginResponse = await response.json();
+
+ localStorage.setItem("token-robot-tracker", data.token);
+ localStorage.setItem("user", JSON.stringify(data.user));
+ setFormData(EMPTY_FORM_DATA);
+
+ navigate("/dashboard", { replace: true });
+ } catch (error) {
+ console.error(error);
+
+ if (error instanceof Error) {
+ setErrorMessage(error.message);
+ } else {
+ setErrorMessage("An unexpected error occurred.");
+ }
+ } finally {
+ setIsLoading(false);
+ }
+ }
+
+ // Clear local storage on component mounting
+ useEffect(() => {
+ localStorage.removeItem("token-robot-tracker");
+ localStorage.removeItem("user");
+ }, []);
+
+ return (
+ <div className="login-page">
+ <div className="login-card">
+ <Logo />
+ <p className="subtitle">🤖 Please log in to use the app 🤖</p>
+
+ {/* "noValidate" to enable manual errorMessage in handleLogin; alternatively omit "required" */}
+ <form className="login-form" onSubmit={handleLogin} noValidate>
+ <div className="form-group">
+ <label htmlFor="email">E-Mail</label>
+ <input
+ type="email"
+ id="email"
+ name="email"
+ value={formData?.email || ""}
+ onChange={handleUserInput}
+ required
+ placeholder="Your e-mail..."
+ disabled={isLoading}
+ />
+ </div>
+
+ <div className="form-group">
+ <label htmlFor="password">Password</label>
+ <input
+ type="password"
+ id="password"
+ name="password"
+ value={formData?.password || ""}
+ onChange={handleUserInput}
+ required
+ placeholder="Your password..."
+ disabled={isLoading}
+ />
+ </div>
+
+ {errorMessage && (
+ <div className="error-message">{errorMessage}</div>
+ )}
+
+ <button
+ className="btn btn-start"
+ disabled={isLoading}
+ type="submit"
+ >
+ {isLoading ? (
+ <div className="loading-spinner-container">
+ <FadeLoader />
+ </div>
+ ) : (
+ "Login"
+ )}
+ </button>
+ </form>
+ </div>
+ </div>
+ );
+}
+
+export default Login;
diff --git a/frontend/src/styles/button.css b/frontend/src/styles/button.css
new file mode 100644
index 0000000..8942654
--- /dev/null
+++ b/frontend/src/styles/button.css
@@ -0,0 +1,77 @@
+.btn {
+ border: none;
+ border-radius: var(--border-radius-small);
+ color: #fff;
+ cursor: pointer;
+ font-size: 1rem;
+ font-weight: bold;
+ margin-top: 10px;
+ padding: 15px;
+ transition: background-color 0.3s, transform 0.1s;
+ width: 100%;
+
+ &:active {
+ transform: translateY(2px);
+ }
+
+ &:disabled {
+ background-color: #dcdfdf;
+ cursor: not-allowed;
+ }
+}
+
+.btn-add-robot {
+ background-color: var(--color-robot);
+ margin-top: 0;
+ padding: var(--gap-small);
+ width: fit-content;
+
+ &:hover {
+ background-color: var(--color-robot-dark);
+ }
+}
+
+.btn-robot-history-toggle {
+ background-color: var(--color-robot);
+ font-size: var(--text-small);
+ margin-top: var(--gap-small);
+ padding: 6px 8px;
+ width: fit-content;
+
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--gap-small);
+
+ &:hover {
+ background-color: var(--color-robot-dark);
+ }
+}
+
+.btn-start {
+ background-color: var(--color-start);
+
+ &:hover {
+ background-color: var(--color-start-dark);
+ }
+}
+
+.btn-stop {
+ background-color: var(--color-stop);
+
+ &:hover {
+ background-color: var(--color-stop-dark);
+ }
+}
+
+.btn-single-robot {
+ font-size: var(--text-small);
+ margin: 0;
+ padding: 6px 8px;
+ width: fit-content;
+}
+
+.btn-logout {
+ margin-top: 0;
+ padding: var(--gap-normal);
+}
diff --git a/frontend/src/styles/index.css b/frontend/src/styles/index.css
new file mode 100644
index 0000000..23807a5
--- /dev/null
+++ b/frontend/src/styles/index.css
@@ -0,0 +1,94 @@
+:root {
+ /* Container vars */
+ --border-radius: 12px;
+ --border-radius-small: 8px;
+ --box-shadow-dark: 0 4px 12px rgba(0, 0, 0, 0.15);
+ /* Color vars */
+ --card-bg: rgba(255, 255, 255, 0.95);
+ --color-label: #555;
+ --color-subtitle: #777;
+ --color-robot: #9c27b0;
+ --color-robot-dark: #7b1fa2;
+ --color-start: #4caf50;
+ --color-start-dark: #2e7d32;
+ --color-stop: #ed6c02;
+ --color-stop-dark: #e65100;
+ /* Spacing vars */
+ --gap-small: 8px;
+ --gap-normal: 12px;
+ /* Text vars */
+ --text-small: 0.9rem;
+
+ box-sizing: border-box;
+ font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
+ line-height: 1.5;
+ font-weight: 400;
+
+ color-scheme: light dark;
+ color: rgba(255, 255, 255, 0.87);
+ background-color: #242424;
+
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+
+ @media (prefers-color-scheme: light) {
+ :root {
+ color: #213547;
+ background-color: #fff;
+ }
+ }
+}
+
+body {
+ margin: 0;
+ min-height: 100vh;
+ min-width: 100vw;
+}
+
+h1 {
+ color: #333;
+ font-size: 1.8rem;
+ line-height: 1.1;
+}
+
+input {
+ border: 1px solid #ddd;
+ border-radius: var(--border-radius-small);
+ box-sizing: border-box;
+ font-size: 1rem;
+ padding: var(--gap-normal);
+ transition: border-color 0.3s, box-shadow 0.3s;
+ width: 100%;
+
+ &:focus {
+ border-color: var(--color-start);
+ box-shadow: 0 0 0 3px rgba(3, 106, 32, 0.243);
+ outline: none;
+ }
+
+ &:disabled {
+ background-color: #f0f3f3;
+ cursor: not-allowed;
+ }
+}
+
+.logo {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 1rem;
+}
+
+.error-message {
+ background-color: #fcebeb;
+ border: 1px solid #cc0000;
+ border-radius: var(--border-radius-small);
+ color: #cc0000;
+ font-size: var(--text-small);
+ font-weight: 500;
+ margin-bottom: 20px;
+ padding: 10px;
+ text-align: center;
+}
diff --git a/frontend/src/styles/login.css b/frontend/src/styles/login.css
new file mode 100644
index 0000000..4592016
--- /dev/null
+++ b/frontend/src/styles/login.css
@@ -0,0 +1,62 @@
+.login-page {
+ background-image: url("../src/assets/map-blurred.png");
+ background-position: center;
+ background-repeat: no-repeat;
+ background-size: cover;
+
+ display: flex;
+ justify-content: center;
+ align-items: center;
+
+ height: 100vh;
+ width: 100vw;
+}
+
+/* Login Card */
+.login-card {
+ background-color: var(--card-bg);
+ backdrop-filter: blur(8px);
+ border-radius: var(--border-radius);
+ box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
+ padding: 40px;
+ text-align: center;
+ width: 360px;
+
+ & .subtitle {
+ color: var(--color-subtitle);
+ font-size: var(--text-small);
+ font-weight: bold;
+ margin-bottom: 25px;
+ }
+
+ @media screen and (max-width: 768px) {
+ .login-card {
+ margin: 0 1rem;
+ }
+ }
+}
+
+/* Form */
+.login-form {
+ text-align: left;
+}
+
+.form-group {
+ margin-bottom: 20px;
+
+ & label {
+ color: var(--color-label);
+ display: block;
+ font-size: var(--text-small);
+ font-weight: bold;
+ margin-bottom: var(--gap-small);
+ }
+}
+
+.loading-spinner-container {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 20px;
+ transform: scale(0.5);
+}
diff --git a/frontend/src/types/login.ts b/frontend/src/types/login.ts
new file mode 100644
index 0000000..73168be
--- /dev/null
+++ b/frontend/src/types/login.ts
@@ -0,0 +1,21 @@
+export type AuthorizedUser = {
+ id: number;
+ email: string;
+ createdAt: Date;
+};
+
+export type LoginFormData = {
+ email: string;
+ password: string;
+};
+
+export type LoginResponse = {
+ message: string;
+ user: AuthorizedUser;
+ token: string;
+};
+
+export type ErrorResponse = {
+ message: string;
+ error?: unknown;
+};