diff options
| author | Arne Rief <riearn@proton.me> | 2025-12-19 20:03:03 +0100 |
|---|---|---|
| committer | Arne Rief <riearn@proton.me> | 2025-12-19 20:03:03 +0100 |
| commit | 655ec610fcce8dd7748f10772d520bdff4f7c78e (patch) | |
| tree | 35b79f30d2cb5aea88cf76ce27f480da93cefd32 /frontend/src | |
Basic setup & login
Diffstat (limited to 'frontend/src')
| -rw-r--r-- | frontend/src/App.tsx | 23 | ||||
| -rw-r--r-- | frontend/src/assets/map-blurred.png | bin | 0 -> 258720 bytes | |||
| -rw-r--r-- | frontend/src/assets/robot-outline.svg | 31 | ||||
| -rw-r--r-- | frontend/src/components/Logo.tsx | 12 | ||||
| -rw-r--r-- | frontend/src/components/ProtectedRoute.tsx | 18 | ||||
| -rw-r--r-- | frontend/src/config.ts | 4 | ||||
| -rw-r--r-- | frontend/src/main.tsx | 10 | ||||
| -rw-r--r-- | frontend/src/pages/Dashboard.tsx | 5 | ||||
| -rw-r--r-- | frontend/src/pages/Login.tsx | 148 | ||||
| -rw-r--r-- | frontend/src/styles/button.css | 77 | ||||
| -rw-r--r-- | frontend/src/styles/index.css | 94 | ||||
| -rw-r--r-- | frontend/src/styles/login.css | 62 | ||||
| -rw-r--r-- | frontend/src/types/login.ts | 21 |
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 Binary files differnew file mode 100644 index 0000000..a9f6b1d --- /dev/null +++ b/frontend/src/assets/map-blurred.png 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; +}; |
