From 655ec610fcce8dd7748f10772d520bdff4f7c78e Mon Sep 17 00:00:00 2001 From: Arne Rief Date: Fri, 19 Dec 2025 20:03:03 +0100 Subject: Basic setup & login --- frontend/src/App.tsx | 23 +++++ frontend/src/assets/map-blurred.png | Bin 0 -> 258720 bytes frontend/src/assets/robot-outline.svg | 31 ++++++ frontend/src/components/Logo.tsx | 12 +++ frontend/src/components/ProtectedRoute.tsx | 18 ++++ frontend/src/config.ts | 4 + frontend/src/main.tsx | 10 ++ frontend/src/pages/Dashboard.tsx | 5 + frontend/src/pages/Login.tsx | 148 +++++++++++++++++++++++++++++ frontend/src/styles/button.css | 77 +++++++++++++++ frontend/src/styles/index.css | 94 ++++++++++++++++++ frontend/src/styles/login.css | 62 ++++++++++++ frontend/src/types/login.ts | 21 ++++ 13 files changed, 505 insertions(+) create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/assets/map-blurred.png create mode 100644 frontend/src/assets/robot-outline.svg create mode 100644 frontend/src/components/Logo.tsx create mode 100644 frontend/src/components/ProtectedRoute.tsx create mode 100644 frontend/src/config.ts create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/Dashboard.tsx create mode 100644 frontend/src/pages/Login.tsx create mode 100644 frontend/src/styles/button.css create mode 100644 frontend/src/styles/index.css create mode 100644 frontend/src/styles/login.css create mode 100644 frontend/src/types/login.ts (limited to 'frontend/src') 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 ( + + } /> + + + + } + /> + } /> + + ); +} + +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 Binary files /dev/null and b/frontend/src/assets/map-blurred.png 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 @@ + + + + + + + + + + + + + + + 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 ( +
+ Robot Tracker Logo +

Robot Tracker

+
+ ); +} + +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 ; + } + + 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( + + + +); 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

Placeholder

; +} + +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(""); + const [formData, setFormData] = useState(EMPTY_FORM_DATA); + const [isLoading, setIsLoading] = useState(false); + + function handleUserInput(event: ChangeEvent) { + if (errorMessage) { + setErrorMessage(""); + } + + setFormData((oldFormData) => ({ + ...oldFormData, + [event.target.name]: event.target.value, + })); + } + + async function handleLogin(event: FormEvent) { + 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 ( +
+
+ +

🤖 Please log in to use the app 🤖

+ + {/* "noValidate" to enable manual errorMessage in handleLogin; alternatively omit "required" */} +
+
+ + +
+ +
+ + +
+ + {errorMessage && ( +
{errorMessage}
+ )} + + +
+
+
+ ); +} + +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; +}; -- cgit v1.2.3