Compare commits
34 Commits
devALT
...
965a4b97ee
| Author | SHA1 | Date | |
|---|---|---|---|
| 965a4b97ee | |||
| 6054173b03 | |||
| 5ba35bb471 | |||
| 47b5590394 | |||
| 47fec60b5b | |||
| e9319b49ec | |||
| b98e38b38b | |||
| a24a3033d3 | |||
| d94d68aa33 | |||
| cc0dcaf664 | |||
| 7a79bf4436 | |||
| 4b00dd6554 | |||
| ba34a97328 | |||
| a013ad0bb8 | |||
| d7240584f9 | |||
| a0bdf5539c | |||
| 770025f8fc | |||
| 960e91c38a | |||
| b99f52f09a | |||
| 86af1a5edf | |||
| 49d4d13afc | |||
| 45fa095eaf | |||
| 23be7e12c7 | |||
| 6ea1ff799c | |||
| 5131266242 | |||
| bb17bc735c | |||
| af7d15c97a | |||
| 04453fd885 | |||
| bf36a6605f | |||
| 8f9696991f | |||
| 9cad1e8b6b | |||
| 880029a0cf | |||
| 32abe60d98 | |||
| ea965971f1 |
@@ -152,6 +152,10 @@ POST `/apiV2/setReturnDate/:key/:loan_code`
|
||||
|
||||
Sets the `returned_date` to the current server time.
|
||||
|
||||
**Note:** I have updated this API route, so that everytime you return or take a loan, the state of the loaned items is automatically updated.
|
||||
|
||||
**DO NOT UPDATE THE STATE MANUALLY! (only if the item was taken with an admin key)**
|
||||
|
||||
Example request:
|
||||
|
||||
```
|
||||
@@ -174,6 +178,10 @@ POST `/apiV2/setTakeDate/:key/:loan_code`
|
||||
|
||||
Sets the `take_date` to the current server time.
|
||||
|
||||
**Note:** I have updated this API route, so that everytime you return or take a loan, the state of the loaned items is automatically updated.
|
||||
|
||||
**DO NOT UPDATE THE STATE MANUALLY! (only if the item was taken with an admin key)**
|
||||
|
||||
Example request:
|
||||
|
||||
```
|
||||
|
||||
24
FrontendV2/.gitignore
vendored
Normal file
24
FrontendV2/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
12
FrontendV2/Dockerfile
Normal file
12
FrontendV2/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8001
|
||||
|
||||
CMD ["npm", "run", "dev"]
|
||||
73
FrontendV2/README.md
Normal file
73
FrontendV2/README.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
23
FrontendV2/eslint.config.js
Normal file
23
FrontendV2/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs['recommended-latest'],
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
13
FrontendV2/index.html
Normal file
13
FrontendV2/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontendv2</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
6132
FrontendV2/package-lock.json
generated
Normal file
6132
FrontendV2/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
52
FrontendV2/package.json
Normal file
52
FrontendV2/package.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "admin",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@chakra-ui/react": "^3.28.0",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@tanstack/react-query": "^5.85.5",
|
||||
"i18next": "^25.6.0",
|
||||
"jotai": "^2.15.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lucide-react": "^0.539.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"primeicons": "^7.0.0",
|
||||
"primereact": "^10.9.6",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-i18next": "^16.2.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-router-dom": "^7.8.0",
|
||||
"react-toastify": "^11.0.5",
|
||||
"split-lines": "^3.0.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tw-animate-css": "^1.3.5",
|
||||
"vite-plugin-svgr": "^4.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.32.0",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/react": "^19.1.9",
|
||||
"@types/react-dom": "^19.1.7",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"eslint": "^9.32.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.3.0",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.39.0",
|
||||
"vite": "^7.1.0",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
}
|
||||
}
|
||||
1
FrontendV2/public/vite.svg
Normal file
1
FrontendV2/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
73
FrontendV2/src/App.css
Normal file
73
FrontendV2/src/App.css
Normal file
@@ -0,0 +1,73 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Text",
|
||||
"SF Pro Display", "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell,
|
||||
"Helvetica Neue", Arial, "Apple Color Emoji", "Segoe UI Emoji",
|
||||
"Segoe UI Symbol", sans-serif;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
/* Display für größere Überschriften */
|
||||
@font-face {
|
||||
font-family: "SF Pro Display";
|
||||
src: url("/src/assets/fonts/sf-pro/SFProDisplay-Regular.woff2")
|
||||
format("woff2");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "SF Pro Display";
|
||||
src: url("/src/assets/fonts/sf-pro/SFProDisplay-Medium.woff2") format("woff2");
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "SF Pro Display";
|
||||
src: url("/src/assets/fonts/sf-pro/SFProDisplay-Bold.woff2") format("woff2");
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Text für Fließtext */
|
||||
@font-face {
|
||||
font-family: "SF Pro Text";
|
||||
src: url("/src/assets/fonts/sf-pro/SFProText-Regular.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "SF Pro Text";
|
||||
src: url("/src/assets/fonts/sf-pro/SFProText-Medium.woff2") format("woff2");
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "SF Pro Text";
|
||||
src: url("/src/assets/fonts/sf-pro/SFProText-Bold.woff2") format("woff2");
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Global anwenden mit Fallbacks */
|
||||
:root {
|
||||
--font-sans: "SF Pro Text", "SF Pro Display", -apple-system,
|
||||
BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
68
FrontendV2/src/App.tsx
Normal file
68
FrontendV2/src/App.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import "./App.css";
|
||||
import { LoginPage } from "@/pages/LoginPage";
|
||||
import { BrowserRouter, Route, Routes } from "react-router-dom";
|
||||
import { HomePage } from "@/pages/HomePage";
|
||||
import { ProtectedRoutes } from "./utils/ProtectedRoutes";
|
||||
import { useEffect, useState } from "react";
|
||||
import Cookies from "js-cookie";
|
||||
import { useAtom } from "jotai";
|
||||
import { setIsLoggedInAtom } from "@/states/Atoms";
|
||||
import { UserContext, type User } from "./states/Context";
|
||||
import { triggerLogoutAtom } from "@/states/Atoms";
|
||||
import { MyLoansPage } from "./pages/MyLoansPage";
|
||||
import Landingpage from "./pages/Landingpage";
|
||||
|
||||
const API_BASE =
|
||||
(import.meta as any).env?.VITE_BACKEND_URL ||
|
||||
import.meta.env.VITE_BACKEND_URL ||
|
||||
"http://localhost:8002";
|
||||
|
||||
function App() {
|
||||
const [user, setUser] = useState<User | undefined>(undefined);
|
||||
const [, setIsLoggedIn] = useAtom(setIsLoggedInAtom);
|
||||
const [, setTriggerLogout] = useAtom(triggerLogoutAtom);
|
||||
|
||||
useEffect(() => {
|
||||
if (Cookies.get("token")) {
|
||||
const verifyToken = async () => {
|
||||
const response = await fetch(`${API_BASE}/api/verifyToken`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||
},
|
||||
});
|
||||
if (response.ok) {
|
||||
setTriggerLogout(false);
|
||||
const data = await response.json();
|
||||
setUser({ username: data.user.username, role: data.user.role });
|
||||
setIsLoggedIn(true);
|
||||
} else {
|
||||
Cookies.remove("token");
|
||||
setIsLoggedIn(false);
|
||||
window.location.reload();
|
||||
}
|
||||
};
|
||||
verifyToken();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<UserContext.Provider value={user}>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route element={<ProtectedRoutes />}>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/my-loans" element={<MyLoansPage />} />
|
||||
<Route path="/landing" element={<Landingpage />} />
|
||||
</Route>
|
||||
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</UserContext.Provider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
1
FrontendV2/src/assets/react.svg
Normal file
1
FrontendV2/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
50
FrontendV2/src/components/Changelog.json
Normal file
50
FrontendV2/src/components/Changelog.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"title": "Changelog",
|
||||
"items": [
|
||||
{
|
||||
"version": "v2.1.0",
|
||||
"date": "2025-10-24",
|
||||
"changes": [
|
||||
{
|
||||
"type": "Hinzugefügt",
|
||||
"text": [
|
||||
"Neue Changelog-Komponente mit zentriertem Layout.",
|
||||
"Unterstützung für mehrsprachige Einträge (Englisch und Deutsch)."
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "Verbessert",
|
||||
"text": [
|
||||
"Performance-Optimierungen beim Laden der Listenansichten.",
|
||||
"Verbesserte Barrierefreiheit durch ARIA-Attribute."
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "Behoben",
|
||||
"text": [
|
||||
"Fehler bei der Datumsauswahl im Safari-Browser.",
|
||||
"Anzeigeprobleme bei hohen DPI-Einstellungen."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "v2.0.3",
|
||||
"date": "2025-10-10",
|
||||
"changes": [
|
||||
{
|
||||
"type": "Geändert",
|
||||
"text": [
|
||||
"Standard-Timeout für API-Requests auf 10s erhöht."
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "Sicherheit",
|
||||
"text": [
|
||||
"Abhängigkeiten aktualisiert (kritische CVEs behoben)."
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
263
FrontendV2/src/components/Changelog.tsx
Normal file
263
FrontendV2/src/components/Changelog.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
const STORAGE_KEY = "changelog";
|
||||
|
||||
type ChangeType =
|
||||
| "Hinzugefügt"
|
||||
| "Geändert"
|
||||
| "Behoben"
|
||||
| "Entfernt"
|
||||
| "Verbessert"
|
||||
| "Sicherheit"
|
||||
| "Veraltet"
|
||||
| string;
|
||||
|
||||
type ChangeEntry = {
|
||||
type: ChangeType;
|
||||
text: string | string[]; // aus localStorage kann es eine Liste sein
|
||||
};
|
||||
|
||||
type ChangelogItem = {
|
||||
version?: string;
|
||||
date: string;
|
||||
changes: ChangeEntry[];
|
||||
};
|
||||
|
||||
type StoredChangelog = {
|
||||
title: string;
|
||||
items: ChangelogItem[];
|
||||
};
|
||||
|
||||
const typeStyles: Record<string, string> = {
|
||||
Hinzugefügt:
|
||||
"bg-emerald-500/15 text-emerald-300 ring-1 ring-inset ring-emerald-500/30",
|
||||
Geändert: "bg-blue-500/15 text-blue-300 ring-1 ring-inset ring-blue-500/30",
|
||||
Behoben: "bg-amber-500/15 text-amber-300 ring-1 ring-inset ring-amber-500/30",
|
||||
Entfernt: "bg-rose-500/15 text-rose-300 ring-1 ring-inset ring-rose-500/30",
|
||||
Verbessert:
|
||||
"bg-indigo-500/15 text-indigo-300 ring-1 ring-inset ring-indigo-500/30",
|
||||
Sicherheit: "bg-red-500/15 text-red-300 ring-1 ring-inset ring-red-500/30",
|
||||
Veraltet: "bg-zinc-700/30 text-zinc-300 ring-1 ring-inset ring-zinc-600/40",
|
||||
};
|
||||
|
||||
export default function Changelog() {
|
||||
const [open, setOpen] = useState(true);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [data, setData] = useState<StoredChangelog | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const cardRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
const loadFromStorage = () => {
|
||||
try {
|
||||
setError(null);
|
||||
const raw =
|
||||
typeof window !== "undefined"
|
||||
? localStorage.getItem(STORAGE_KEY)
|
||||
: null;
|
||||
if (!raw) {
|
||||
setData(null);
|
||||
return;
|
||||
}
|
||||
const parsed = JSON.parse(raw) as StoredChangelog;
|
||||
if (!parsed || !Array.isArray(parsed.items)) {
|
||||
throw new Error("Ungültiges Format");
|
||||
}
|
||||
setData(parsed);
|
||||
} catch (e) {
|
||||
setError("Changelog konnte nicht aus localStorage geladen werden.");
|
||||
setData(null);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadFromStorage();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
};
|
||||
const onClickOutside = (e: MouseEvent) => {
|
||||
if (cardRef.current && !cardRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key === STORAGE_KEY) loadFromStorage();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
document.addEventListener("mousedown", onClickOutside);
|
||||
window.addEventListener("storage", onStorage);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKey);
|
||||
document.removeEventListener("mousedown", onClickOutside);
|
||||
window.removeEventListener("storage", onStorage);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const title = data?.title ?? "Changelog";
|
||||
const items = data?.items ?? [];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 bg-[radial-gradient(60%_60%_at_50%_0%,rgba(99,102,241,0.12),rgba(24,24,27,0))] flex items-center justify-center p-6">
|
||||
<div
|
||||
ref={cardRef}
|
||||
className={[
|
||||
"relative w-full max-w-6xl transition-all duration-300 ease-out",
|
||||
mounted
|
||||
? "opacity-100 translate-y-0 scale-100"
|
||||
: "opacity-0 translate-y-1 scale-[0.99]",
|
||||
].join(" ")}
|
||||
aria-live="polite"
|
||||
>
|
||||
{/* Gradient border wrapper */}
|
||||
<div className="rounded-2xl p-[1px] bg-gradient-to-b from-zinc-700/60 via-zinc-700/20 to-zinc-800/60 shadow-2xl">
|
||||
{/* Card */}
|
||||
<div className="relative rounded-[calc(theme(borderRadius.2xl)-1px)] border border-zinc-800/70 bg-zinc-900/70 supports-[backdrop-filter]:bg-zinc-900/60 backdrop-blur-xl ring-1 ring-white/10">
|
||||
{/* Accent top line */}
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-indigo-500/40 to-transparent" />
|
||||
|
||||
{/* Close button */}
|
||||
<button
|
||||
aria-label="Changelog schließen"
|
||||
onClick={() => setOpen(false)}
|
||||
className="absolute right-3 top-3 inline-flex h-9 w-9 items-center justify-center rounded-md text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500/70 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-900 transition"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.8}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M6 6l12 12M18 6L6 18" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Header */}
|
||||
<header className="px-10 pt-8 pb-6 border-b border-zinc-800/70">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="inline-flex h-9 w-9 items-center justify-center rounded-lg bg-indigo-500/15 text-indigo-300 ring-1 ring-inset ring-indigo-500/30">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.6}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M5.6 18.4l2.1-2.1M16.3 7.7l2.1-2.1" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-[30px] leading-8 font-semibold text-zinc-100 tracking-[-0.01em]">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="text-sm text-zinc-400">
|
||||
Aktuelle Änderungen und Updates
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Body */}
|
||||
<div className="relative max-h-[78vh] overflow-y-auto">
|
||||
<div className="absolute pointer-events-none inset-x-0 top-0 h-8 bg-gradient-to-b from-zinc-900/70 to-transparent" />
|
||||
<div className="absolute pointer-events-none inset-x-0 bottom-0 h-10 bg-gradient-to-t from-zinc-900/80 to-transparent" />
|
||||
|
||||
{error && (
|
||||
<div className="px-10 py-8">
|
||||
<div className="rounded-lg border border-red-900/40 bg-red-900/10 px-4 py-3 text-sm text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && items.length === 0 && (
|
||||
<div className="px-10 py-16 text-center">
|
||||
<p className="text-zinc-400">
|
||||
Kein Changelog im localStorage gefunden (Key: {STORAGE_KEY}
|
||||
).
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ul className="divide-y divide-zinc-800/70">
|
||||
{items.map((entry, idx) => (
|
||||
<li
|
||||
key={`${entry.version ?? entry.date}-${idx}`}
|
||||
className="px-10 py-8"
|
||||
>
|
||||
{/* Kopfzeile je Release */}
|
||||
<div className="flex flex-wrap items-baseline gap-x-4 gap-y-2">
|
||||
{entry.version && (
|
||||
<span className="inline-flex items-center rounded-md bg-gradient-to-b from-zinc-100 to-zinc-300 text-zinc-900 px-3 py-0.5 text-sm font-semibold shadow-sm">
|
||||
{entry.version}
|
||||
</span>
|
||||
)}
|
||||
<time
|
||||
className="text-sm text-zinc-400"
|
||||
dateTime={entry.date}
|
||||
>
|
||||
{new Date(entry.date).toLocaleDateString("de-DE", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "2-digit",
|
||||
})}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
{/* Zweispaltiges Layout: Typ links, Text rechts (mit schöner Leselänge) */}
|
||||
<dl
|
||||
role="list"
|
||||
className="mt-6 grid grid-cols-1 gap-x-8 gap-y-3 md:grid-cols-[max-content_1fr]"
|
||||
>
|
||||
{entry.changes.map((c, i) => (
|
||||
<div key={i} className="contents">
|
||||
<dt className="md:w-44 md:justify-end md:text-right">
|
||||
<span
|
||||
className={`inline-flex items-center rounded-md px-2 py-0.5 text-[11px] font-medium ${
|
||||
typeStyles[c.type] ??
|
||||
"bg-zinc-700/30 text-zinc-300 ring-1 ring-inset ring-zinc-600/40"
|
||||
}`}
|
||||
>
|
||||
{c.type}
|
||||
</span>
|
||||
</dt>
|
||||
|
||||
<dd className="max-w-[74ch] text-[15px] leading-7 text-zinc-200 tracking-[0.005em]">
|
||||
{Array.isArray(c.text) ? (
|
||||
<ul className="ml-4 list-disc marker:text-zinc-500/70 space-y-1.5">
|
||||
{c.text.map((t, k) => (
|
||||
<li key={k} className="break-words">
|
||||
{t}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="break-words">{c.text}</p>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* soft bottom glow */}
|
||||
<div className="pointer-events-none absolute inset-x-12 -bottom-4 h-8 blur-2xl bg-indigo-600/20 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
376
FrontendV2/src/components/Header.tsx
Normal file
376
FrontendV2/src/components/Header.tsx
Normal file
@@ -0,0 +1,376 @@
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Flex,
|
||||
Heading,
|
||||
Stack,
|
||||
Text,
|
||||
CloseButton,
|
||||
Dialog,
|
||||
Portal,
|
||||
HStack,
|
||||
IconButton,
|
||||
Menu,
|
||||
Box,
|
||||
} from "@chakra-ui/react";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
import Cookies from "js-cookie";
|
||||
import { useAtom } from "jotai";
|
||||
import { setIsLoggedInAtom, triggerLogoutAtom } from "@/states/Atoms";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
CircleUserRound,
|
||||
RotateCcwKey,
|
||||
Code,
|
||||
LifeBuoy,
|
||||
LogOut,
|
||||
CalendarPlus,
|
||||
MoreVertical,
|
||||
} from "lucide-react";
|
||||
import { useUserContext } from "@/states/Context";
|
||||
import { useState } from "react";
|
||||
import MyAlert from "./myChakra/MyAlert";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Trans } from "react-i18next";
|
||||
|
||||
const API_BASE =
|
||||
(import.meta as any).env?.VITE_BACKEND_URL ||
|
||||
import.meta.env.VITE_BACKEND_URL ||
|
||||
"http://localhost:8002";
|
||||
|
||||
export const Header = () => {
|
||||
const navigate = useNavigate();
|
||||
const userData = useUserContext();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Error handling states
|
||||
const [isMsg, setIsMsg] = useState(false);
|
||||
const [msgStatus, setMsgStatus] = useState<"error" | "success">("error");
|
||||
const [msgTitle, setMsgTitle] = useState("");
|
||||
const [msgDescription, setMsgDescription] = useState("");
|
||||
|
||||
const [oldPassword, setOldPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
|
||||
const [, setTriggerLogout] = useAtom(triggerLogoutAtom);
|
||||
const [, setIsLoggedIn] = useAtom(setIsLoggedInAtom);
|
||||
|
||||
// Dialog control
|
||||
const [isPwOpen, setPwOpen] = useState(false);
|
||||
|
||||
const changePassword = async () => {
|
||||
if (newPassword !== confirmPassword) {
|
||||
setMsgTitle("Passwortänderung fehlgeschlagen");
|
||||
setMsgDescription("Passwörter stimmen nicht überein");
|
||||
setMsgStatus("error");
|
||||
setIsMsg(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/api/changePassword`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||
},
|
||||
body: JSON.stringify({ oldPassword, newPassword }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
setMsgTitle("Passwortänderung fehlgeschlagen");
|
||||
setMsgDescription("Bitte überprüfen Sie Ihre Eingaben");
|
||||
setMsgStatus("error");
|
||||
setIsMsg(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setMsgTitle("Passwort erfolgreich geändert");
|
||||
setMsgDescription("Ihr Passwort wurde erfolgreich geändert");
|
||||
setMsgStatus("success");
|
||||
setIsMsg(true);
|
||||
|
||||
setOldPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
};
|
||||
|
||||
const username = userData?.username
|
||||
? userData.username[0].toUpperCase() + userData.username.slice(1)
|
||||
: "User";
|
||||
|
||||
const logout = () => {
|
||||
Cookies.remove("token");
|
||||
setIsLoggedIn(false);
|
||||
setTriggerLogout(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack
|
||||
as="header"
|
||||
gap={3}
|
||||
className="mb-6"
|
||||
position="relative"
|
||||
pr={{ base: 10, md: 0 }} // Platz für den Mobile-Button rechts
|
||||
>
|
||||
{/* Mobile: Drei-Punkte-Button, vertikal zentriert im Header */}
|
||||
<Box
|
||||
display={{ base: "block", md: "none" }}
|
||||
position="absolute"
|
||||
top="50%"
|
||||
right="0"
|
||||
transform="translateY(-50%)"
|
||||
zIndex={2}
|
||||
>
|
||||
<Menu.Root>
|
||||
<Menu.Trigger asChild>
|
||||
<IconButton
|
||||
aria-label="Aktionen"
|
||||
variant="solid"
|
||||
colorScheme="teal"
|
||||
size="md"
|
||||
borderRadius="full"
|
||||
boxShadow="md"
|
||||
>
|
||||
<MoreVertical size={20} />
|
||||
</IconButton>
|
||||
</Menu.Trigger>
|
||||
<Menu.Positioner>
|
||||
<Menu.Content>
|
||||
<Menu.Item
|
||||
value="create-loan"
|
||||
onSelect={() => navigate("/", { replace: true })}
|
||||
children={
|
||||
<HStack gap={3}>
|
||||
<CalendarPlus size={16} />
|
||||
<Text as="span">Ausleihe erstellen</Text>
|
||||
</HStack>
|
||||
}
|
||||
/>
|
||||
<Menu.Item
|
||||
value="my-loans"
|
||||
onSelect={() => navigate("/my-loans", { replace: true })}
|
||||
children={
|
||||
<HStack gap={3}>
|
||||
<CircleUserRound size={16} />
|
||||
<Text as="span">Meine Ausleihen</Text>
|
||||
</HStack>
|
||||
}
|
||||
/>
|
||||
<Menu.Item
|
||||
value="change-password"
|
||||
onSelect={() => setPwOpen(true)}
|
||||
children={
|
||||
<HStack gap={3}>
|
||||
<RotateCcwKey size={16} />
|
||||
<Text as="span">Passwort ändern</Text>
|
||||
</HStack>
|
||||
}
|
||||
/>
|
||||
<Menu.Item
|
||||
value="help"
|
||||
onSelect={() =>
|
||||
window.open(
|
||||
"https://git.the1s.de/Matthias-Claudius-Schule/borrow-system/wiki",
|
||||
"_blank",
|
||||
"noopener,noreferrer"
|
||||
)
|
||||
}
|
||||
children={
|
||||
<HStack gap={3}>
|
||||
<LifeBuoy size={16} />
|
||||
<Text as="span">Hilfe</Text>
|
||||
</HStack>
|
||||
}
|
||||
/>
|
||||
<Menu.Item
|
||||
value="source-code"
|
||||
onSelect={() =>
|
||||
window.open(
|
||||
"https://git.the1s.de/Matthias-Claudius-Schule/borrow-system",
|
||||
"_blank",
|
||||
"noopener,noreferrer"
|
||||
)
|
||||
}
|
||||
children={
|
||||
<HStack gap={3}>
|
||||
<Code size={16} />
|
||||
<Text as="span">Source Code</Text>
|
||||
</HStack>
|
||||
}
|
||||
/>
|
||||
<Menu.Separator />
|
||||
<Menu.Item
|
||||
value="logout"
|
||||
onSelect={logout}
|
||||
children={
|
||||
<HStack gap={3} color="red.500">
|
||||
<LogOut size={16} />
|
||||
<Text as="span">Logout</Text>
|
||||
</HStack>
|
||||
}
|
||||
/>
|
||||
</Menu.Content>
|
||||
</Menu.Positioner>
|
||||
</Menu.Root>
|
||||
</Box>
|
||||
|
||||
<Flex
|
||||
direction={{ base: "column", md: "row" }}
|
||||
align={{ base: "stretch", md: "center" }}
|
||||
justify="space-between"
|
||||
gap={4}
|
||||
>
|
||||
{/* Left: Title + user info */}
|
||||
<Stack gap={1}>
|
||||
{/* Titelzeile ohne Mobile-Menu (wurde nach oben verlegt) */}
|
||||
<Flex align="center" justify="space-between" gap={2}>
|
||||
<Heading
|
||||
size="2xl"
|
||||
className="tracking-tight text-slate-900 dark:text-slate-100"
|
||||
>
|
||||
Home
|
||||
</Heading>
|
||||
</Flex>
|
||||
|
||||
<HStack gap={3} align="center" flexWrap="wrap">
|
||||
<Text fontSize="md" className="text-slate-600 dark:text-slate-400">
|
||||
{t("greeting")}
|
||||
<strong>{username}</strong>!
|
||||
</Text>
|
||||
<Badge variant="subtle" px={2} py={1} borderRadius="full">
|
||||
Rolle: {userData?.role ?? "—"}
|
||||
</Badge>
|
||||
</HStack>
|
||||
</Stack>
|
||||
|
||||
{/* Right: Actions */}
|
||||
{/* Desktop actions */}
|
||||
<HStack
|
||||
gap={2}
|
||||
align="center"
|
||||
justify="flex-end"
|
||||
flexWrap="wrap"
|
||||
display={{ base: "none", md: "flex" }}
|
||||
>
|
||||
<Button
|
||||
colorScheme="teal"
|
||||
onClick={() => navigate("/", { replace: true })}
|
||||
>
|
||||
<HStack gap={2}>
|
||||
<CalendarPlus size={18} />
|
||||
<Text as="span">Ausleihe erstellen</Text>
|
||||
</HStack>
|
||||
</Button>
|
||||
|
||||
<Button onClick={() => navigate("/my-loans", { replace: true })}>
|
||||
<HStack gap={2}>
|
||||
<CircleUserRound size={18} />
|
||||
<Text as="span">Meine Ausleihen</Text>
|
||||
</HStack>
|
||||
</Button>
|
||||
|
||||
<Button variant="ghost" onClick={() => setPwOpen(true)}>
|
||||
<HStack gap={2}>
|
||||
<RotateCcwKey size={18} />
|
||||
<Text as="span">Passwort ändern</Text>
|
||||
</HStack>
|
||||
</Button>
|
||||
|
||||
<a
|
||||
href="https://git.the1s.de/Matthias-Claudius-Schule/borrow-system/wiki"
|
||||
target="_blank"
|
||||
>
|
||||
<Button variant="ghost">
|
||||
<HStack gap={2}>
|
||||
<LifeBuoy size={18} />
|
||||
<Text as="span">Hilfe</Text>
|
||||
</HStack>
|
||||
</Button>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://git.the1s.de/Matthias-Claudius-Schule/borrow-system"
|
||||
target="_blank"
|
||||
>
|
||||
<Button variant="ghost">
|
||||
<HStack gap={2}>
|
||||
<Code size={18} />
|
||||
<Text as="span">Source Code</Text>
|
||||
</HStack>
|
||||
</Button>
|
||||
</a>
|
||||
|
||||
<Button onClick={logout} variant="outline" colorScheme="red">
|
||||
<HStack gap={2}>
|
||||
<LogOut size={18} />
|
||||
<Text as="span">Logout</Text>
|
||||
</HStack>
|
||||
</Button>
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{/* Passwort-Dialog (kontrolliert) */}
|
||||
<Dialog.Root open={isPwOpen} onOpenChange={(e: any) => setPwOpen(e.open)}>
|
||||
<Portal>
|
||||
<Dialog.Backdrop />
|
||||
<Dialog.Positioner>
|
||||
<Dialog.Content maxW="md">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Passwort ändern</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
changePassword();
|
||||
}}
|
||||
>
|
||||
<Dialog.Body>
|
||||
<Stack gap={3}>
|
||||
<PasswordInput
|
||||
value={oldPassword}
|
||||
onChange={(e) => setOldPassword(e.target.value)}
|
||||
placeholder="Altes Passwort"
|
||||
/>
|
||||
<PasswordInput
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
placeholder="Neues Passwort"
|
||||
/>
|
||||
<PasswordInput
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="Neues Passwort wiederholen"
|
||||
/>
|
||||
</Stack>
|
||||
</Dialog.Body>
|
||||
<Dialog.Footer>
|
||||
<Stack w="100%" gap={3}>
|
||||
{isMsg && (
|
||||
<MyAlert
|
||||
status={msgStatus}
|
||||
title={msgTitle}
|
||||
description={msgDescription}
|
||||
/>
|
||||
)}
|
||||
<HStack justify="flex-end" gap={2}>
|
||||
<Dialog.ActionTrigger asChild>
|
||||
<Button variant="outline">Abbrechen</Button>
|
||||
</Dialog.ActionTrigger>
|
||||
<Button type="submit" colorScheme="teal">
|
||||
Speichern
|
||||
</Button>
|
||||
</HStack>
|
||||
</Stack>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
<Dialog.CloseTrigger asChild>
|
||||
<CloseButton size="sm" />
|
||||
</Dialog.CloseTrigger>
|
||||
</Dialog.Content>
|
||||
</Dialog.Positioner>
|
||||
</Portal>
|
||||
</Dialog.Root>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
22
FrontendV2/src/components/myChakra/MyAlert.tsx
Normal file
22
FrontendV2/src/components/myChakra/MyAlert.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from "react";
|
||||
import { Alert } from "@chakra-ui/react";
|
||||
|
||||
type MyAlertProps = {
|
||||
status: "error" | "success";
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
const MyAlert: React.FC<MyAlertProps> = ({ title, description, status }) => {
|
||||
return (
|
||||
<Alert.Root status={status}>
|
||||
<Alert.Indicator />
|
||||
<Alert.Content>
|
||||
<Alert.Title>{title}</Alert.Title>
|
||||
<Alert.Description>{description}</Alert.Description>
|
||||
</Alert.Content>
|
||||
</Alert.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default MyAlert;
|
||||
108
FrontendV2/src/components/ui/color-mode.tsx
Normal file
108
FrontendV2/src/components/ui/color-mode.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
"use client"
|
||||
|
||||
import type { IconButtonProps, SpanProps } from "@chakra-ui/react"
|
||||
import { ClientOnly, IconButton, Skeleton, Span } from "@chakra-ui/react"
|
||||
import { ThemeProvider, useTheme } from "next-themes"
|
||||
import type { ThemeProviderProps } from "next-themes"
|
||||
import * as React from "react"
|
||||
import { LuMoon, LuSun } from "react-icons/lu"
|
||||
|
||||
export interface ColorModeProviderProps extends ThemeProviderProps {}
|
||||
|
||||
export function ColorModeProvider(props: ColorModeProviderProps) {
|
||||
return (
|
||||
<ThemeProvider attribute="class" disableTransitionOnChange {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export type ColorMode = "light" | "dark"
|
||||
|
||||
export interface UseColorModeReturn {
|
||||
colorMode: ColorMode
|
||||
setColorMode: (colorMode: ColorMode) => void
|
||||
toggleColorMode: () => void
|
||||
}
|
||||
|
||||
export function useColorMode(): UseColorModeReturn {
|
||||
const { resolvedTheme, setTheme, forcedTheme } = useTheme()
|
||||
const colorMode = forcedTheme || resolvedTheme
|
||||
const toggleColorMode = () => {
|
||||
setTheme(resolvedTheme === "dark" ? "light" : "dark")
|
||||
}
|
||||
return {
|
||||
colorMode: colorMode as ColorMode,
|
||||
setColorMode: setTheme,
|
||||
toggleColorMode,
|
||||
}
|
||||
}
|
||||
|
||||
export function useColorModeValue<T>(light: T, dark: T) {
|
||||
const { colorMode } = useColorMode()
|
||||
return colorMode === "dark" ? dark : light
|
||||
}
|
||||
|
||||
export function ColorModeIcon() {
|
||||
const { colorMode } = useColorMode()
|
||||
return colorMode === "dark" ? <LuMoon /> : <LuSun />
|
||||
}
|
||||
|
||||
interface ColorModeButtonProps extends Omit<IconButtonProps, "aria-label"> {}
|
||||
|
||||
export const ColorModeButton = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
ColorModeButtonProps
|
||||
>(function ColorModeButton(props, ref) {
|
||||
const { toggleColorMode } = useColorMode()
|
||||
return (
|
||||
<ClientOnly fallback={<Skeleton boxSize="9" />}>
|
||||
<IconButton
|
||||
onClick={toggleColorMode}
|
||||
variant="ghost"
|
||||
aria-label="Toggle color mode"
|
||||
size="sm"
|
||||
ref={ref}
|
||||
{...props}
|
||||
css={{
|
||||
_icon: {
|
||||
width: "5",
|
||||
height: "5",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ColorModeIcon />
|
||||
</IconButton>
|
||||
</ClientOnly>
|
||||
)
|
||||
})
|
||||
|
||||
export const LightMode = React.forwardRef<HTMLSpanElement, SpanProps>(
|
||||
function LightMode(props, ref) {
|
||||
return (
|
||||
<Span
|
||||
color="fg"
|
||||
display="contents"
|
||||
className="chakra-theme light"
|
||||
colorPalette="gray"
|
||||
colorScheme="light"
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
export const DarkMode = React.forwardRef<HTMLSpanElement, SpanProps>(
|
||||
function DarkMode(props, ref) {
|
||||
return (
|
||||
<Span
|
||||
color="fg"
|
||||
display="contents"
|
||||
className="chakra-theme dark"
|
||||
colorPalette="gray"
|
||||
colorScheme="dark"
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
)
|
||||
159
FrontendV2/src/components/ui/password-input.tsx
Normal file
159
FrontendV2/src/components/ui/password-input.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
"use client"
|
||||
|
||||
import type {
|
||||
ButtonProps,
|
||||
GroupProps,
|
||||
InputProps,
|
||||
StackProps,
|
||||
} from "@chakra-ui/react"
|
||||
import {
|
||||
Box,
|
||||
HStack,
|
||||
IconButton,
|
||||
Input,
|
||||
InputGroup,
|
||||
Stack,
|
||||
mergeRefs,
|
||||
useControllableState,
|
||||
} from "@chakra-ui/react"
|
||||
import * as React from "react"
|
||||
import { LuEye, LuEyeOff } from "react-icons/lu"
|
||||
|
||||
export interface PasswordVisibilityProps {
|
||||
/**
|
||||
* The default visibility state of the password input.
|
||||
*/
|
||||
defaultVisible?: boolean
|
||||
/**
|
||||
* The controlled visibility state of the password input.
|
||||
*/
|
||||
visible?: boolean
|
||||
/**
|
||||
* Callback invoked when the visibility state changes.
|
||||
*/
|
||||
onVisibleChange?: (visible: boolean) => void
|
||||
/**
|
||||
* Custom icons for the visibility toggle button.
|
||||
*/
|
||||
visibilityIcon?: { on: React.ReactNode; off: React.ReactNode }
|
||||
}
|
||||
|
||||
export interface PasswordInputProps
|
||||
extends InputProps,
|
||||
PasswordVisibilityProps {
|
||||
rootProps?: GroupProps
|
||||
}
|
||||
|
||||
export const PasswordInput = React.forwardRef<
|
||||
HTMLInputElement,
|
||||
PasswordInputProps
|
||||
>(function PasswordInput(props, ref) {
|
||||
const {
|
||||
rootProps,
|
||||
defaultVisible,
|
||||
visible: visibleProp,
|
||||
onVisibleChange,
|
||||
visibilityIcon = { on: <LuEye />, off: <LuEyeOff /> },
|
||||
...rest
|
||||
} = props
|
||||
|
||||
const [visible, setVisible] = useControllableState({
|
||||
value: visibleProp,
|
||||
defaultValue: defaultVisible || false,
|
||||
onChange: onVisibleChange,
|
||||
})
|
||||
|
||||
const inputRef = React.useRef<HTMLInputElement>(null)
|
||||
|
||||
return (
|
||||
<InputGroup
|
||||
endElement={
|
||||
<VisibilityTrigger
|
||||
disabled={rest.disabled}
|
||||
onPointerDown={(e) => {
|
||||
if (rest.disabled) return
|
||||
if (e.button !== 0) return
|
||||
e.preventDefault()
|
||||
setVisible(!visible)
|
||||
}}
|
||||
>
|
||||
{visible ? visibilityIcon.off : visibilityIcon.on}
|
||||
</VisibilityTrigger>
|
||||
}
|
||||
{...rootProps}
|
||||
>
|
||||
<Input
|
||||
{...rest}
|
||||
ref={mergeRefs(ref, inputRef)}
|
||||
type={visible ? "text" : "password"}
|
||||
/>
|
||||
</InputGroup>
|
||||
)
|
||||
})
|
||||
|
||||
const VisibilityTrigger = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
function VisibilityTrigger(props, ref) {
|
||||
return (
|
||||
<IconButton
|
||||
tabIndex={-1}
|
||||
ref={ref}
|
||||
me="-2"
|
||||
aspectRatio="square"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
height="calc(100% - {spacing.2})"
|
||||
aria-label="Toggle password visibility"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
interface PasswordStrengthMeterProps extends StackProps {
|
||||
max?: number
|
||||
value: number
|
||||
}
|
||||
|
||||
export const PasswordStrengthMeter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
PasswordStrengthMeterProps
|
||||
>(function PasswordStrengthMeter(props, ref) {
|
||||
const { max = 4, value, ...rest } = props
|
||||
|
||||
const percent = (value / max) * 100
|
||||
const { label, colorPalette } = getColorPalette(percent)
|
||||
|
||||
return (
|
||||
<Stack align="flex-end" gap="1" ref={ref} {...rest}>
|
||||
<HStack width="full" {...rest}>
|
||||
{Array.from({ length: max }).map((_, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
height="1"
|
||||
flex="1"
|
||||
rounded="sm"
|
||||
data-selected={index < value ? "" : undefined}
|
||||
layerStyle="fill.subtle"
|
||||
colorPalette="gray"
|
||||
_selected={{
|
||||
colorPalette,
|
||||
layerStyle: "fill.solid",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</HStack>
|
||||
{label && <HStack textStyle="xs">{label}</HStack>}
|
||||
</Stack>
|
||||
)
|
||||
})
|
||||
|
||||
function getColorPalette(percent: number) {
|
||||
switch (true) {
|
||||
case percent < 33:
|
||||
return { label: "Low", colorPalette: "red" }
|
||||
case percent < 66:
|
||||
return { label: "Medium", colorPalette: "orange" }
|
||||
default:
|
||||
return { label: "High", colorPalette: "green" }
|
||||
}
|
||||
}
|
||||
15
FrontendV2/src/components/ui/provider.tsx
Normal file
15
FrontendV2/src/components/ui/provider.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
"use client"
|
||||
|
||||
import { ChakraProvider, defaultSystem } from "@chakra-ui/react"
|
||||
import {
|
||||
ColorModeProvider,
|
||||
type ColorModeProviderProps,
|
||||
} from "./color-mode"
|
||||
|
||||
export function Provider(props: ColorModeProviderProps) {
|
||||
return (
|
||||
<ChakraProvider value={defaultSystem}>
|
||||
<ColorModeProvider {...props} />
|
||||
</ChakraProvider>
|
||||
)
|
||||
}
|
||||
43
FrontendV2/src/components/ui/toaster.tsx
Normal file
43
FrontendV2/src/components/ui/toaster.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
Toaster as ChakraToaster,
|
||||
Portal,
|
||||
Spinner,
|
||||
Stack,
|
||||
Toast,
|
||||
createToaster,
|
||||
} from "@chakra-ui/react"
|
||||
|
||||
export const toaster = createToaster({
|
||||
placement: "bottom-end",
|
||||
pauseOnPageIdle: true,
|
||||
})
|
||||
|
||||
export const Toaster = () => {
|
||||
return (
|
||||
<Portal>
|
||||
<ChakraToaster toaster={toaster} insetInline={{ mdDown: "4" }}>
|
||||
{(toast) => (
|
||||
<Toast.Root width={{ md: "sm" }}>
|
||||
{toast.type === "loading" ? (
|
||||
<Spinner size="sm" color="blue.solid" />
|
||||
) : (
|
||||
<Toast.Indicator />
|
||||
)}
|
||||
<Stack gap="1" flex="1" maxWidth="100%">
|
||||
{toast.title && <Toast.Title>{toast.title}</Toast.Title>}
|
||||
{toast.description && (
|
||||
<Toast.Description>{toast.description}</Toast.Description>
|
||||
)}
|
||||
</Stack>
|
||||
{toast.action && (
|
||||
<Toast.ActionTrigger>{toast.action.label}</Toast.ActionTrigger>
|
||||
)}
|
||||
{toast.closable && <Toast.CloseTrigger />}
|
||||
</Toast.Root>
|
||||
)}
|
||||
</ChakraToaster>
|
||||
</Portal>
|
||||
)
|
||||
}
|
||||
46
FrontendV2/src/components/ui/tooltip.tsx
Normal file
46
FrontendV2/src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Tooltip as ChakraTooltip, Portal } from "@chakra-ui/react"
|
||||
import * as React from "react"
|
||||
|
||||
export interface TooltipProps extends ChakraTooltip.RootProps {
|
||||
showArrow?: boolean
|
||||
portalled?: boolean
|
||||
portalRef?: React.RefObject<HTMLElement | null>
|
||||
content: React.ReactNode
|
||||
contentProps?: ChakraTooltip.ContentProps
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export const Tooltip = React.forwardRef<HTMLDivElement, TooltipProps>(
|
||||
function Tooltip(props, ref) {
|
||||
const {
|
||||
showArrow,
|
||||
children,
|
||||
disabled,
|
||||
portalled = true,
|
||||
content,
|
||||
contentProps,
|
||||
portalRef,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
if (disabled) return children
|
||||
|
||||
return (
|
||||
<ChakraTooltip.Root {...rest}>
|
||||
<ChakraTooltip.Trigger asChild>{children}</ChakraTooltip.Trigger>
|
||||
<Portal disabled={!portalled} container={portalRef}>
|
||||
<ChakraTooltip.Positioner>
|
||||
<ChakraTooltip.Content ref={ref} {...contentProps}>
|
||||
{showArrow && (
|
||||
<ChakraTooltip.Arrow>
|
||||
<ChakraTooltip.ArrowTip />
|
||||
</ChakraTooltip.Arrow>
|
||||
)}
|
||||
{content}
|
||||
</ChakraTooltip.Content>
|
||||
</ChakraTooltip.Positioner>
|
||||
</Portal>
|
||||
</ChakraTooltip.Root>
|
||||
)
|
||||
},
|
||||
)
|
||||
70
FrontendV2/src/index.css
Normal file
70
FrontendV2/src/index.css
Normal file
@@ -0,0 +1,70 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Text",
|
||||
"SF Pro Display", "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell,
|
||||
"Helvetica Neue", Arial, "Apple Color Emoji", "Segoe UI Emoji",
|
||||
"Segoe UI Symbol", sans-serif;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "SF Pro Display";
|
||||
src: url("/src/assets/fonts/sf-pro/SFProDisplay-Regular.woff2")
|
||||
format("woff2");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "SF Pro Display";
|
||||
src: url("/src/assets/fonts/sf-pro/SFProDisplay-Medium.woff2") format("woff2");
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "SF Pro Display";
|
||||
src: url("/src/assets/fonts/sf-pro/SFProDisplay-Bold.woff2") format("woff2");
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "SF Pro Text";
|
||||
src: url("/src/assets/fonts/sf-pro/SFProText-Regular.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "SF Pro Text";
|
||||
src: url("/src/assets/fonts/sf-pro/SFProText-Medium.woff2") format("woff2");
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "SF Pro Text";
|
||||
src: url("/src/assets/fonts/sf-pro/SFProText-Bold.woff2") format("woff2");
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
:root {
|
||||
--font-sans: "SF Pro Text", "SF Pro Display", -apple-system,
|
||||
BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
14
FrontendV2/src/main.tsx
Normal file
14
FrontendV2/src/main.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Provider } from "@/components/ui/provider";
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import App from "./App.tsx";
|
||||
import i18n from "./utils/i18n"; // import i18n configuration DO NOT REMOVE
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<Provider>
|
||||
<App />
|
||||
</Provider>
|
||||
</StrictMode>
|
||||
);
|
||||
166
FrontendV2/src/pages/HomePage.tsx
Normal file
166
FrontendV2/src/pages/HomePage.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import {
|
||||
Container,
|
||||
Stack,
|
||||
Text,
|
||||
Button,
|
||||
Input,
|
||||
Spinner,
|
||||
VStack,
|
||||
Table,
|
||||
} from "@chakra-ui/react";
|
||||
import { useAtom } from "jotai";
|
||||
import { getBorrowableItems } from "@/utils/Fetcher";
|
||||
import { useState } from "react";
|
||||
import MyAlert from "@/components/myChakra/MyAlert";
|
||||
import { borrowAbleItemsAtom } from "@/states/Atoms";
|
||||
import { createLoan } from "@/utils/Fetcher";
|
||||
import { Header } from "@/components/Header";
|
||||
|
||||
export interface User {
|
||||
username: string;
|
||||
role: number;
|
||||
}
|
||||
|
||||
export const HomePage = () => {
|
||||
const [borrowableItems, setBorrowableItems] = useAtom(borrowAbleItemsAtom);
|
||||
const [startDate, setStartDate] = useState("");
|
||||
const [endDate, setEndDate] = useState("");
|
||||
const [isLoadingA, setIsLoadingA] = useState(false);
|
||||
const [selectedItems, setSelectedItems] = useState<number[]>([]);
|
||||
|
||||
// Error handling states
|
||||
const [isMsg, setIsMsg] = useState(false);
|
||||
const [msgStatus, setMsgStatus] = useState<"error" | "success">("error");
|
||||
const [msgTitle, setMsgTitle] = useState("");
|
||||
const [msgDescription, setMsgDescription] = useState("");
|
||||
|
||||
const handleCheckboxChange = (itemId: number) => {
|
||||
setSelectedItems((prevSelected) =>
|
||||
prevSelected.includes(itemId)
|
||||
? prevSelected.filter((id) => id !== itemId)
|
||||
: [...prevSelected, itemId]
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container maxW="7xl" className="px-6 sm:px-8 pt-10">
|
||||
<Header />
|
||||
{isMsg && (
|
||||
<MyAlert
|
||||
status={msgStatus}
|
||||
title={msgTitle}
|
||||
description={msgDescription}
|
||||
/>
|
||||
)}
|
||||
<Stack as="main">
|
||||
<label htmlFor="startDate">
|
||||
<Text>Startdatum</Text>
|
||||
</label>
|
||||
<Input
|
||||
id="startDate"
|
||||
placeholder="Startdatum"
|
||||
type="datetime-local"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
/>
|
||||
<label htmlFor="endDate">
|
||||
<Text>Enddatum</Text>
|
||||
</label>
|
||||
<Input
|
||||
id="endDate"
|
||||
placeholder="Enddatum"
|
||||
type="datetime-local"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
setIsLoadingA(true);
|
||||
if (!startDate || !endDate) {
|
||||
setMsgStatus("error");
|
||||
setMsgTitle("Fehlende Eingaben");
|
||||
setMsgDescription("Bitte Start- und Enddatum angeben.");
|
||||
setIsMsg(true);
|
||||
setIsLoadingA(false);
|
||||
return;
|
||||
}
|
||||
await getBorrowableItems(startDate, endDate).then((response) => {
|
||||
setIsLoadingA(false);
|
||||
if (response && response.status === "error") {
|
||||
setMsgStatus("error");
|
||||
setMsgTitle(response.title || "Fehler");
|
||||
setMsgDescription(
|
||||
response.description || "Unbekannter Frontend Fehler"
|
||||
);
|
||||
setIsMsg(true);
|
||||
return;
|
||||
}
|
||||
setBorrowableItems(response.data);
|
||||
setIsMsg(false);
|
||||
console.log(borrowableItems);
|
||||
});
|
||||
}}
|
||||
>
|
||||
Verfügbare Gegenstände anzeigen
|
||||
</Button>
|
||||
{isLoadingA && (
|
||||
<VStack colorPalette="teal">
|
||||
<Spinner color="colorPalette.600" />
|
||||
<Text color="colorPalette.600">Loading...</Text>
|
||||
</VStack>
|
||||
)}
|
||||
{borrowableItems.length > 0 && (
|
||||
<Table.ScrollArea borderWidth="1px" rounded="md">
|
||||
<Table.Root size="sm" stickyHeader>
|
||||
<Table.Header>
|
||||
<Table.Row bg="bg.subtle">
|
||||
<Table.ColumnHeader></Table.ColumnHeader>
|
||||
<Table.ColumnHeader>Gegenstand</Table.ColumnHeader>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
|
||||
<Table.Body>
|
||||
{borrowableItems.map((item) => (
|
||||
<Table.Row key={item.id}>
|
||||
<Table.Cell>
|
||||
<input
|
||||
onChange={() => handleCheckboxChange(item.id)}
|
||||
type="checkbox"
|
||||
name={item.id}
|
||||
id={item.id}
|
||||
/>
|
||||
</Table.Cell>
|
||||
<Table.Cell>{item.item_name}</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</Table.ScrollArea>
|
||||
)}
|
||||
{selectedItems.length >= 1 && (
|
||||
<Button
|
||||
onClick={() =>
|
||||
createLoan(selectedItems, startDate, endDate).then((response) => {
|
||||
if (response.status === "error") {
|
||||
setMsgStatus("error");
|
||||
setMsgTitle(response.title || "Fehler");
|
||||
setMsgDescription(
|
||||
response.description || "Unbekannter Frontend Fehler"
|
||||
);
|
||||
setIsMsg(true);
|
||||
return;
|
||||
}
|
||||
setMsgStatus("success");
|
||||
setMsgTitle("Erfolg");
|
||||
setMsgDescription("Gegenstände erfolgreich ausgeliehen.");
|
||||
setIsMsg(true);
|
||||
})
|
||||
}
|
||||
>
|
||||
Gegenstände ausleihen
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
@@ -11,8 +11,20 @@ import {
|
||||
Button,
|
||||
} from "@chakra-ui/react";
|
||||
import { Lock, LockOpen } from "lucide-react";
|
||||
import MyAlert from "../myChakra/MyAlert";
|
||||
import { formatDateTime } from "@/utils/userFuncs";
|
||||
import MyAlert from "@/components/myChakra/MyAlert";
|
||||
|
||||
export const formatDateTime = (value: string | null | undefined) => {
|
||||
if (!value) return "N/A";
|
||||
const m = value.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/);
|
||||
if (!m) return "N/A";
|
||||
const [, y, M, d, h, min] = m;
|
||||
return `${d}.${M}.${y} ${h}:${min} Uhr`;
|
||||
};
|
||||
|
||||
const API_BASE =
|
||||
(import.meta as any).env?.VITE_BACKEND_URL ||
|
||||
import.meta.env.VITE_BACKEND_URL ||
|
||||
"http://localhost:8002";
|
||||
|
||||
type Loan = {
|
||||
id: number;
|
||||
@@ -57,7 +69,7 @@ const Landingpage: React.FC = () => {
|
||||
const fetchData = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const loanRes = await fetch("http://localhost:8002/apiV2/allLoans");
|
||||
const loanRes = await fetch(`${API_BASE}/apiV2/allLoans`);
|
||||
const loanData = await loanRes.json();
|
||||
if (Array.isArray(loanData)) {
|
||||
setLoans(loanData);
|
||||
@@ -69,7 +81,7 @@ const Landingpage: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const deviceRes = await fetch("http://localhost:8002/apiV2/allItems");
|
||||
const deviceRes = await fetch(`${API_BASE}/apiV2/allItems`);
|
||||
const deviceData = await deviceRes.json();
|
||||
if (Array.isArray(deviceData)) {
|
||||
setDevices(deviceData);
|
||||
@@ -208,7 +220,7 @@ const Landingpage: React.FC = () => {
|
||||
borderRadius="full"
|
||||
>
|
||||
<HStack gap={2}>
|
||||
<Lock size={16} />
|
||||
<LockOpen size={16} />
|
||||
<Text>Im Schließfach</Text>
|
||||
</HStack>
|
||||
</Button>
|
||||
@@ -221,7 +233,7 @@ const Landingpage: React.FC = () => {
|
||||
borderRadius="full"
|
||||
>
|
||||
<HStack gap={2}>
|
||||
<LockOpen size={16} />
|
||||
<Lock size={16} />
|
||||
<Text>Nicht im Schließfach</Text>
|
||||
</HStack>
|
||||
</Button>
|
||||
119
FrontendV2/src/pages/LoginPage.tsx
Normal file
119
FrontendV2/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import MyAlert from "../components/myChakra/MyAlert";
|
||||
import { Button, Card, Field, Input, Stack } from "@chakra-ui/react";
|
||||
import { setIsLoggedInAtom, triggerLogoutAtom } from "@/states/Atoms";
|
||||
import { useAtom } from "jotai";
|
||||
import Cookies from "js-cookie";
|
||||
import { Navigate, useNavigate } from "react-router-dom";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
|
||||
const API_BASE =
|
||||
(import.meta as any).env?.VITE_BACKEND_URL ||
|
||||
import.meta.env.VITE_BACKEND_URL ||
|
||||
"http://localhost:8002";
|
||||
|
||||
export const LoginPage = () => {
|
||||
const [isLoggedIn, setIsLoggedIn] = useAtom(setIsLoggedInAtom);
|
||||
const [triggerLogout, setTriggerLogout] = useAtom(triggerLogoutAtom);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoggedIn) {
|
||||
navigate("/", { replace: true });
|
||||
}
|
||||
}, [isLoggedIn, navigate]);
|
||||
|
||||
const loginFnc = async (username: string, password: string) => {
|
||||
const response = await fetch(`${API_BASE}/api/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
message: data.message ?? "Login fehlgeschlagen",
|
||||
description: data.description ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
Cookies.set("token", data.token);
|
||||
setIsLoggedIn(true);
|
||||
return { success: true };
|
||||
};
|
||||
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [isError, setIsError] = useState(false);
|
||||
const [errorMsg, setErrorMsg] = useState("");
|
||||
const [errorDsc, setErrorDsc] = useState("");
|
||||
|
||||
const handleLogin = async () => {
|
||||
const result = await loginFnc(username, password);
|
||||
if (!result.success) {
|
||||
setErrorMsg(result.message);
|
||||
setErrorDsc(result.description);
|
||||
setIsError(true);
|
||||
return;
|
||||
}
|
||||
setTriggerLogout(false);
|
||||
navigate("/", { replace: true });
|
||||
};
|
||||
|
||||
if (isLoggedIn) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<form onSubmit={(e) => e.preventDefault()}>
|
||||
<Card.Root maxW="sm">
|
||||
<Card.Header>
|
||||
<Card.Title>Login</Card.Title>
|
||||
<Card.Description>
|
||||
Bitte unten Ihre Zugangsdaten eingeben.
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Body>
|
||||
<Stack gap="4" w="full">
|
||||
<Field.Root>
|
||||
<Field.Label>Benutzername</Field.Label>
|
||||
<Input
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
</Field.Root>
|
||||
<Field.Root>
|
||||
<Field.Label>Passwort</Field.Label>
|
||||
<PasswordInput
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</Field.Root>
|
||||
</Stack>
|
||||
</Card.Body>
|
||||
<Card.Footer justifyContent="flex-end">
|
||||
{isError && (
|
||||
<MyAlert status="error" title={errorMsg} description={errorDsc} />
|
||||
)}
|
||||
<Button type="submit" onClick={() => handleLogin()} variant="solid">
|
||||
Login
|
||||
</Button>
|
||||
</Card.Footer>
|
||||
<Card.Footer justifyContent="flex-end">
|
||||
{triggerLogout && (
|
||||
<MyAlert
|
||||
status="success"
|
||||
title={"Logout erfolgreich!"}
|
||||
description={"Sie wurden erfolgreich abgemeldet."}
|
||||
/>
|
||||
)}
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
245
FrontendV2/src/pages/MyLoansPage.tsx
Normal file
245
FrontendV2/src/pages/MyLoansPage.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import Cookies from "js-cookie";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import MyAlert from "@/components/myChakra/MyAlert";
|
||||
import {
|
||||
Container,
|
||||
VStack,
|
||||
Spinner,
|
||||
Text,
|
||||
Table,
|
||||
Button,
|
||||
CloseButton,
|
||||
Dialog,
|
||||
Portal,
|
||||
Code,
|
||||
} from "@chakra-ui/react";
|
||||
import { Header } from "@/components/Header";
|
||||
import { Trash2 } from "lucide-react";
|
||||
|
||||
const API_BASE =
|
||||
(import.meta as any).env?.VITE_BACKEND_URL ||
|
||||
import.meta.env.VITE_BACKEND_URL ||
|
||||
"http://localhost:8002";
|
||||
|
||||
export const MyLoansPage = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [loans, setLoans] = useState<any[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const [delLoanCode, setDelLoanCode] = useState<number | null>(null);
|
||||
|
||||
// Error handling states
|
||||
const [isMsg, setIsMsg] = useState(false);
|
||||
const [msgStatus, setMsgStatus] = useState<"error" | "success">("error");
|
||||
const [msgTitle, setMsgTitle] = useState("");
|
||||
const [msgDescription, setMsgDescription] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!Cookies.get("token")) {
|
||||
navigate("/login", { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchLoans = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const res = await fetch(`${API_BASE}/api/userLoans`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
setMsgStatus("error");
|
||||
setMsgTitle("Fehler");
|
||||
setMsgDescription(
|
||||
"Beim Laden der Ausleihen ist ein Fehler aufgetreten."
|
||||
);
|
||||
setIsMsg(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
setLoans(data);
|
||||
console.log("Fetched loans:", data);
|
||||
} catch (e) {
|
||||
setMsgStatus("error");
|
||||
setMsgTitle("Fehler");
|
||||
setMsgDescription("Netzwerkfehler beim Laden der Ausleihen.");
|
||||
setIsMsg(true);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchLoans();
|
||||
}, [navigate]);
|
||||
|
||||
const deleteLoan = async (loanId: number) => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/SETdeleteLoan/${loanId}`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
setMsgStatus("error");
|
||||
setMsgTitle("Fehler");
|
||||
setMsgDescription(
|
||||
"Beim Löschen der Ausleihe ist ein Fehler aufgetreten."
|
||||
);
|
||||
setIsMsg(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoans((prev) => prev.filter((loan) => loan.id !== loanId));
|
||||
setMsgStatus("success");
|
||||
setMsgTitle("Erfolg");
|
||||
setMsgDescription("Ausleihe erfolgreich gelöscht.");
|
||||
setIsMsg(true);
|
||||
} catch (e) {
|
||||
setMsgStatus("error");
|
||||
setMsgTitle("Fehler");
|
||||
setMsgDescription("Netzwerkfehler beim Löschen der Ausleihe.");
|
||||
setIsMsg(true);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (iso: string | null) => {
|
||||
if (!iso) return "-";
|
||||
const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/);
|
||||
if (!m) return iso;
|
||||
const [, y, M, d, h, min] = m;
|
||||
return `${d}.${M}.${y} ${h}:${min}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container maxW="7xl" className="px-6 sm:px-8 pt-10">
|
||||
<Header />
|
||||
{isMsg && (
|
||||
<MyAlert
|
||||
status={msgStatus}
|
||||
title={msgTitle}
|
||||
description={msgDescription}
|
||||
/>
|
||||
)}
|
||||
{isLoading && (
|
||||
<VStack colorPalette="teal">
|
||||
<Spinner color="colorPalette.600" />
|
||||
<Text color="colorPalette.600">Loading...</Text>
|
||||
</VStack>
|
||||
)}
|
||||
{loans && (
|
||||
<Table.Root
|
||||
size="sm"
|
||||
variant="outline"
|
||||
style={{ tableLayout: "fixed", width: "100%" }}
|
||||
>
|
||||
<Table.ColumnGroup>
|
||||
{/* Ausleihcode */}
|
||||
<Table.Column style={{ width: "14%" }} />
|
||||
{/* Startdatum */}
|
||||
<Table.Column style={{ width: "14%" }} />
|
||||
{/* Enddatum */}
|
||||
<Table.Column style={{ width: "14%" }} />
|
||||
{/* Geräte (flexibler) */}
|
||||
<Table.Column style={{ width: "28%" }} />
|
||||
{/* Ausleihdatum */}
|
||||
<Table.Column style={{ width: "14%" }} />
|
||||
{/* Rückgabedatum */}
|
||||
<Table.Column style={{ width: "14%" }} />
|
||||
{/* Aktionen */}
|
||||
<Table.Column style={{ width: "8%" }} />
|
||||
</Table.ColumnGroup>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.ColumnHeader>Ausleihcode</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>Startdatum</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>Enddatum</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>Geräte</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>Ausleihdatum</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>Rückgabedatum</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>Aktionen</Table.ColumnHeader>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{loans.map((loan) => (
|
||||
<Table.Row key={loan.id}>
|
||||
<Table.Cell>
|
||||
<Text title={loan.loan_code}>
|
||||
<Code variant="solid">{`${loan.loan_code}`}</Code>
|
||||
</Text>
|
||||
</Table.Cell>
|
||||
<Table.Cell>{formatDate(loan.start_date)}</Table.Cell>
|
||||
<Table.Cell>{formatDate(loan.end_date)}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Text title={loan.loaned_items_name}>
|
||||
{loan.loaned_items_name}
|
||||
</Text>
|
||||
</Table.Cell>
|
||||
<Table.Cell>{formatDate(loan.take_date)}</Table.Cell>
|
||||
<Table.Cell>{formatDate(loan.returned_date)}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Dialog.Root role="alertdialog">
|
||||
<Dialog.Trigger asChild>
|
||||
<Button
|
||||
onClick={() => setDelLoanCode(loan.loan_code)}
|
||||
aria-label="Ausleihe löschen"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</Dialog.Trigger>
|
||||
<Portal>
|
||||
<Dialog.Backdrop />
|
||||
<Dialog.Positioner>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Sicher?</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<Dialog.Body>
|
||||
<Text>
|
||||
Möchtest du die Ausleihe mit dem{" "}
|
||||
<strong><Code>{delLoanCode}</Code></strong> Code wirklich
|
||||
löschen?
|
||||
<br />
|
||||
Für den Admin bleibt sie weiterhin sichtbar.
|
||||
</Text>
|
||||
</Dialog.Body>
|
||||
<Dialog.Footer>
|
||||
<Dialog.ActionTrigger asChild>
|
||||
<Button variant="outline">Abbrechen</Button>
|
||||
</Dialog.ActionTrigger>
|
||||
<Button
|
||||
colorPalette="red"
|
||||
onClick={() => deleteLoan(loan.id)}
|
||||
>
|
||||
<strong>Löschen</strong>
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
<Dialog.CloseTrigger asChild>
|
||||
<CloseButton size="sm" />
|
||||
</Dialog.CloseTrigger>
|
||||
</Dialog.Content>
|
||||
</Dialog.Positioner>
|
||||
</Portal>
|
||||
</Dialog.Root>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
)}
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
};
|
||||
6
FrontendV2/src/states/Atoms.tsx
Normal file
6
FrontendV2/src/states/Atoms.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { atom } from "jotai";
|
||||
|
||||
export const testAtom = atom<number>(0);
|
||||
export const setIsLoggedInAtom = atom<boolean>(false);
|
||||
export const triggerLogoutAtom = atom<boolean>(false);
|
||||
export const borrowAbleItemsAtom = atom<any[]>([]);
|
||||
19
FrontendV2/src/states/Context.ts
Normal file
19
FrontendV2/src/states/Context.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { createContext } from "react";
|
||||
import { useContext } from "react";
|
||||
|
||||
export interface User {
|
||||
username: string;
|
||||
role: number;
|
||||
}
|
||||
|
||||
export const UserContext = createContext<User | undefined>(undefined);
|
||||
|
||||
export function useUserContext() {
|
||||
const user = useContext(UserContext);
|
||||
|
||||
if (user === undefined) {
|
||||
throw new Error("useUserContext must be used with a UserContext")
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
36
FrontendV2/src/states/README.md
Normal file
36
FrontendV2/src/states/README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# How to use Atoms
|
||||
Atoms are the fundamental building blocks of state management in this system. They represent individual pieces of state that can be shared and manipulated across different components.
|
||||
|
||||
You can also name it global state.
|
||||
|
||||
## Creating an Atom
|
||||
to create an atom you have to declare an atom like this:
|
||||
|
||||
```ts
|
||||
import { atom } from 'jotai';
|
||||
|
||||
export const NAME_OF_YOUR_ATOM = atom<type_of_your_atom>(initial_value);
|
||||
```
|
||||
|
||||
In this project we declare all atoms in the `states/Atoms.tsx`file. Which you can find above this README file.
|
||||
|
||||
## Using an Atom
|
||||
To use an atom in your component, you can use the `useAtom` hook provided by Jotai. Here's an example of how to use an atom in a React component:
|
||||
|
||||
```tsx
|
||||
import { useAtom } from 'jotai';
|
||||
import { NAME_OF_YOUR_ATOM } from '@/states/Atoms';
|
||||
|
||||
const MyComponent = () => {
|
||||
const [value, setValue] = useAtom(NAME_OF_YOUR_ATOM);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>Current value: {value}</p>
|
||||
<button onClick={() => setValue(newValue)}>Update Value</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
As you can see, you can use `useAtom` like `useState` but the state is global. In this example `value` is the current state of the atom, and `setValue` is a function to update the state, which is also known as the setter function.
|
||||
82
FrontendV2/src/utils/Fetcher.ts
Normal file
82
FrontendV2/src/utils/Fetcher.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import Cookies from "js-cookie";
|
||||
const API_BASE =
|
||||
(import.meta as any).env?.VITE_BACKEND_URL ||
|
||||
import.meta.env.VITE_BACKEND_URL ||
|
||||
"http://localhost:8002";
|
||||
|
||||
export const getBorrowableItems = async (
|
||||
startDate: string,
|
||||
endDate: string
|
||||
) => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/borrowableItems`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${Cookies.get("token") || ""}`,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify({ startDate, endDate }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
data: null,
|
||||
status: "error",
|
||||
title: "Server error",
|
||||
description:
|
||||
"Ein Fehler ist auf dem Server aufgetreten. Manchmal hilft es, die Seite neu zu laden.",
|
||||
};
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log(data);
|
||||
return {
|
||||
data: data,
|
||||
status: "success",
|
||||
title: null,
|
||||
description: null,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
data: null,
|
||||
status: "error",
|
||||
title: "Netzwerkfehler",
|
||||
description:
|
||||
"Es konnte keine Verbindung zum Server hergestellt werden. Bitte überprüfe deine Internetverbindung.",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const createLoan = async (
|
||||
itemIds: number[],
|
||||
startDate: string,
|
||||
endDate: string
|
||||
) => {
|
||||
const response = await fetch(`${API_BASE}/api/createLoan`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${Cookies.get("token") || ""}`,
|
||||
},
|
||||
body: JSON.stringify({ items: itemIds, startDate, endDate }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
data: null,
|
||||
status: "error",
|
||||
title: "Server error",
|
||||
description:
|
||||
"Ein Fehler ist auf dem Server aufgetreten. Manchmal hilft es, die Seite neu zu laden.",
|
||||
};
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return {
|
||||
data: data,
|
||||
status: "success",
|
||||
title: null,
|
||||
description: null,
|
||||
};
|
||||
};
|
||||
20
FrontendV2/src/utils/ProtectedRoutes.tsx
Normal file
20
FrontendV2/src/utils/ProtectedRoutes.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Navigate, Outlet, useLocation } from "react-router-dom";
|
||||
import Cookies from "js-cookie";
|
||||
import { useContext } from "react";
|
||||
import { UserContext } from "@/states/Context";
|
||||
|
||||
export const ProtectedRoutes = () => {
|
||||
const user = useContext(UserContext);
|
||||
const location = useLocation();
|
||||
const hasToken = Boolean(Cookies.get("token"));
|
||||
|
||||
if (hasToken && !user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return user ? (
|
||||
<Outlet />
|
||||
) : (
|
||||
<Navigate to="/login" replace state={{ from: location }} />
|
||||
);
|
||||
};
|
||||
33
FrontendV2/src/utils/i18n/index.ts
Normal file
33
FrontendV2/src/utils/i18n/index.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import i18n from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
|
||||
import enLang from "./locales/en/en.json";
|
||||
import deLang from "./locales/de/de.json";
|
||||
|
||||
// the translations
|
||||
// (tip move them in a JSON file and import them,
|
||||
// or even better, manage them separated from your code: https://react.i18next.com/guides/multiple-translation-files)
|
||||
const resources = {
|
||||
en: {
|
||||
translation: enLang,
|
||||
},
|
||||
de: {
|
||||
translation: deLang,
|
||||
},
|
||||
};
|
||||
|
||||
i18n
|
||||
.use(initReactI18next) // passes i18n down to react-i18next
|
||||
.init({
|
||||
resources,
|
||||
fallbackLng: "en", // use en if detected lng is not available
|
||||
lng: "de", // language to use, more information here: https://www.i18next.com/overview/configuration-options#languages-namespaces-resources
|
||||
// you can use the i18n.changeLanguage function to change the language manually: https://www.i18next.com/overview/api#changelanguage
|
||||
// if you're using a language detector, do not define the lng option
|
||||
|
||||
interpolation: {
|
||||
escapeValue: false, // react already safes from xss
|
||||
},
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
3
FrontendV2/src/utils/i18n/locales/de/de.json
Normal file
3
FrontendV2/src/utils/i18n/locales/de/de.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"greeting": "Willkommen zurück, "
|
||||
}
|
||||
3
FrontendV2/src/utils/i18n/locales/en/en.json
Normal file
3
FrontendV2/src/utils/i18n/locales/en/en.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"greeting": "Welcome back, "
|
||||
}
|
||||
11
FrontendV2/tailwind.config.js
Normal file
11
FrontendV2/tailwind.config.js
Normal file
@@ -0,0 +1,11 @@
|
||||
module.exports = {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,jsx,ts,tsx}",
|
||||
// add other paths if needed
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
33
FrontendV2/tsconfig.app.json
Normal file
33
FrontendV2/tsconfig.app.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
|
||||
/* Path aliases */
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
FrontendV2/tsconfig.json
Normal file
7
FrontendV2/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
FrontendV2/tsconfig.node.json
Normal file
26
FrontendV2/tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
16
FrontendV2/vite.config.ts
Normal file
16
FrontendV2/vite.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import svgr from "vite-plugin-svgr";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), svgr(), tailwindcss(), tsconfigPaths()],
|
||||
server: {
|
||||
host: "0.0.0.0",
|
||||
port: 8001,
|
||||
watch: {
|
||||
usePolling: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,276 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
|
||||
// Beispiel-Daten für die Übersicht in der Seitenleiste
|
||||
const allItems = [
|
||||
{ id: 1, name: "Kamera" },
|
||||
{ id: 2, name: "Mikrofon" },
|
||||
{ id: 3, name: "Licht-Set" },
|
||||
{ id: 4, name: "Stativ" },
|
||||
];
|
||||
|
||||
// Beispiel-Ausleihen, später per API dynamisch!
|
||||
const loans = [
|
||||
{
|
||||
itemId: 1,
|
||||
username: "max",
|
||||
start: "2025-01-01T08:00",
|
||||
end: "2025-01-01T18:00",
|
||||
loanCode: "123456",
|
||||
},
|
||||
{
|
||||
itemId: 3,
|
||||
username: "sara",
|
||||
start: "2025-01-02T10:00",
|
||||
end: "2025-01-02T16:00",
|
||||
loanCode: "654321",
|
||||
},
|
||||
];
|
||||
|
||||
// Dummy: Für das Beispiel sind einige Items "nicht verfügbar" bei bestimmten Zeiträumen
|
||||
function getAvailableItems(start: string, end: string) {
|
||||
if (start.startsWith("2025-01-01")) {
|
||||
return allItems.filter(
|
||||
(item) => item.name === "Kamera" || item.name === "Stativ"
|
||||
);
|
||||
}
|
||||
return allItems;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [step, setStep] = useState<1 | 2 | 3>(1);
|
||||
const [startDate, setStartDate] = useState("");
|
||||
const [endDate, setEndDate] = useState("");
|
||||
const [availableItems, setAvailableItems] = useState<typeof allItems>([]);
|
||||
const [selectedItem, setSelectedItem] = useState<number | null>(null);
|
||||
|
||||
// Dummy Code für das Design
|
||||
const loanCode = "123456";
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex bg-gradient-to-r from-blue-50 via-white to-blue-100">
|
||||
{/* Seitenleiste */}
|
||||
<aside className="w-80 min-h-screen bg-white/90 backdrop-blur border-r border-blue-100 shadow-xl flex flex-col p-8">
|
||||
<h2 className="text-2xl font-extrabold mb-6 text-blue-700 tracking-tight flex items-center gap-2">
|
||||
<svg
|
||||
className="w-7 h-7 text-blue-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16.5 7.5V4.75A2.25 2.25 0 0 0 14.25 2.5h-4.5A2.25 2.25 0 0 0 7.5 4.75V7.5m9 0h-9m9 0v11.75A2.25 2.25 0 0 1 14.25 21.5h-4.5A2.25 2.25 0 0 1 7.5 19.25V7.5m9 0h-9"
|
||||
/>
|
||||
</svg>
|
||||
Ausleih-Übersicht
|
||||
</h2>
|
||||
<ul className="space-y-5 flex-1">
|
||||
{allItems.map((item) => {
|
||||
const itemLoans = loans.filter((loan) => loan.itemId === item.id);
|
||||
return (
|
||||
<li
|
||||
key={item.id}
|
||||
className="bg-white/80 rounded-xl p-4 shadow hover:shadow-md transition"
|
||||
>
|
||||
<div className="font-semibold text-gray-900 flex items-center gap-2">
|
||||
<span
|
||||
className="inline-block w-2 h-2 rounded-full"
|
||||
style={{
|
||||
background:
|
||||
itemLoans.length === 0 ? "#34d399" : "#60a5fa",
|
||||
}}
|
||||
></span>
|
||||
{item.name}
|
||||
</div>
|
||||
{itemLoans.length === 0 ? (
|
||||
<div className="text-green-500 text-xs mt-1 font-medium">
|
||||
Verfügbar
|
||||
</div>
|
||||
) : (
|
||||
<ul className="mt-2 space-y-1">
|
||||
{itemLoans.map((loan, idx) => (
|
||||
<li
|
||||
key={idx}
|
||||
className="text-xs text-blue-800 bg-blue-100/60 p-1 rounded"
|
||||
>
|
||||
<span className="font-bold">{loan.username}</span>
|
||||
<span className="ml-2">
|
||||
{formatDateTime(loan.start)} –{" "}
|
||||
{formatDateTime(loan.end)}
|
||||
</span>
|
||||
<span className="ml-2 text-gray-400">
|
||||
({loan.loanCode})
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<div className="mt-10 text-xs text-gray-400 flex items-center gap-4">
|
||||
<span className="inline-block w-3 h-3 bg-green-400 rounded-full mr-1"></span>
|
||||
Verfügbar
|
||||
<span className="inline-block w-3 h-3 bg-blue-400 rounded-full ml-4 mr-1"></span>
|
||||
Verliehen
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Hauptbereich */}
|
||||
<main className="flex-1 flex flex-col items-center py-14 px-4">
|
||||
<header className="mb-12">
|
||||
<h1 className="text-4xl font-extrabold text-blue-800 tracking-tight drop-shadow-sm">
|
||||
Gegenstand ausleihen
|
||||
</h1>
|
||||
<p className="text-blue-400 mt-2 text-lg font-medium">
|
||||
Schnell und unkompliziert Equipment reservieren
|
||||
</p>
|
||||
</header>
|
||||
<div className="bg-white/90 shadow-2xl rounded-3xl p-10 w-full max-w-xl ring-1 ring-blue-100">
|
||||
{step === 1 && (
|
||||
<form
|
||||
className="space-y-6"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
setAvailableItems(getAvailableItems(startDate, endDate));
|
||||
setStep(2);
|
||||
}}
|
||||
>
|
||||
<h2 className="text-xl font-bold mb-2 text-blue-700">
|
||||
1. Zeitraum wählen
|
||||
</h2>
|
||||
<div>
|
||||
<label className="block font-medium mb-1 text-blue-900">
|
||||
Startdatum
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
className="w-full border border-blue-200 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-400 focus:outline-none"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block font-medium mb-1 text-blue-900">
|
||||
Enddatum
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
className="w-full border border-blue-200 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-400 focus:outline-none"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
required
|
||||
min={startDate}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full bg-gradient-to-r from-blue-600 to-blue-400 hover:from-blue-700 hover:to-blue-500 text-white font-bold py-2 px-4 rounded-xl shadow transition disabled:bg-gray-300"
|
||||
disabled={!startDate || !endDate || endDate <= startDate}
|
||||
>
|
||||
Verfügbare Gegenstände anzeigen
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div>
|
||||
<h2 className="text-xl font-bold mb-6 text-blue-700">
|
||||
2. Gegenstand auswählen
|
||||
</h2>
|
||||
{availableItems.length === 0 ? (
|
||||
<div className="text-red-600 mb-8 font-medium text-center">
|
||||
Keine Gegenstände verfügbar für diesen Zeitraum.
|
||||
</div>
|
||||
) : (
|
||||
<ul className="mb-8 space-y-3">
|
||||
{availableItems.map((item) => (
|
||||
<li
|
||||
key={item.id}
|
||||
className={`flex justify-between items-center p-4 rounded-xl shadow-sm border ${
|
||||
selectedItem === item.id
|
||||
? "bg-blue-100 border-blue-400"
|
||||
: "bg-green-50 border-green-100 hover:bg-blue-50"
|
||||
} transition`}
|
||||
>
|
||||
<span className="font-medium text-lg">{item.name}</span>
|
||||
<button
|
||||
className={`px-4 py-1 rounded-lg bg-gradient-to-r from-blue-500 to-blue-400 text-white text-sm font-semibold shadow hover:from-blue-600 hover:to-blue-500 ${
|
||||
selectedItem === item.id ? "ring-2 ring-blue-400" : ""
|
||||
}`}
|
||||
onClick={() => setSelectedItem(item.id)}
|
||||
>
|
||||
{selectedItem === item.id ? "Ausgewählt" : "Auswählen"}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<button
|
||||
className="px-5 py-2 bg-gray-100 text-gray-600 rounded-xl hover:bg-gray-200 font-semibold shadow"
|
||||
onClick={() => setStep(1)}
|
||||
>
|
||||
Zurück
|
||||
</button>
|
||||
<button
|
||||
className="px-5 py-2 bg-gradient-to-r from-blue-600 to-blue-400 text-white rounded-xl hover:from-blue-700 hover:to-blue-500 font-bold shadow transition disabled:bg-gray-300"
|
||||
disabled={selectedItem === null}
|
||||
onClick={() => setStep(3)}
|
||||
>
|
||||
Ausleihen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<div className="mt-2 p-8 bg-blue-50/80 border border-blue-200 rounded-2xl text-center shadow-lg">
|
||||
<h3 className="font-extrabold text-blue-700 mb-3 text-2xl">
|
||||
Ausleihe bestätigt!
|
||||
</h3>
|
||||
<p className="mb-2 text-lg">
|
||||
Ihr Ausleih-Code lautet:{" "}
|
||||
<span className="font-mono text-2xl text-blue-900 bg-white px-2 py-1 rounded shadow">
|
||||
{loanCode}
|
||||
</span>
|
||||
</p>
|
||||
<p className="mt-2 text-blue-600 text-sm">
|
||||
Bitte merken Sie sich diesen Code, um das Schließfach zu öffnen.
|
||||
</p>
|
||||
<button
|
||||
className="mt-8 px-6 py-2 bg-gradient-to-r from-blue-600 to-blue-400 text-white rounded-xl hover:from-blue-700 hover:to-blue-500 font-bold shadow"
|
||||
onClick={() => {
|
||||
setStep(1);
|
||||
setStartDate("");
|
||||
setEndDate("");
|
||||
setSelectedItem(null);
|
||||
}}
|
||||
>
|
||||
Neue Ausleihe
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Hilfsfunktion: Datumsformatierung (z.B. 01.01.2025 08:00)
|
||||
function formatDateTime(dt: string) {
|
||||
const d = new Date(dt);
|
||||
return (
|
||||
d.toLocaleDateString("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
}) +
|
||||
" " +
|
||||
d.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })
|
||||
);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Mock Book 1",
|
||||
"author": "Author 1",
|
||||
"description": "Description for Mock Book 1"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "Mock Book 2",
|
||||
"author": "Author 2",
|
||||
"description": "Description for Mock Book 2"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"title": "Mock Book 3",
|
||||
"author": "Author 3",
|
||||
"description": "Description for Mock Book 3"
|
||||
}
|
||||
]
|
||||
48
admin/package-lock.json
generated
48
admin/package-lock.json
generated
@@ -12,6 +12,7 @@
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@tanstack/react-query": "^5.85.5",
|
||||
"jotai": "^2.15.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lucide-react": "^0.539.0",
|
||||
"next-themes": "^0.4.6",
|
||||
@@ -4420,6 +4421,35 @@
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/jotai": {
|
||||
"version": "2.15.0",
|
||||
"resolved": "https://registry.npmjs.org/jotai/-/jotai-2.15.0.tgz",
|
||||
"integrity": "sha512-nbp/6jN2Ftxgw0VwoVnOg0m5qYM1rVcfvij+MZx99Z5IK13eGve9FJoCwGv+17JvVthTjhSmNtT5e1coJnr6aw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": ">=7.0.0",
|
||||
"@babel/template": ">=7.0.0",
|
||||
"@types/react": ">=17.0.0",
|
||||
"react": ">=17.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@babel/core": {
|
||||
"optional": true
|
||||
},
|
||||
"@babel/template": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/js-cookie": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
|
||||
@@ -5645,13 +5675,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.14",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
|
||||
"integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==",
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fdir": "^6.4.4",
|
||||
"picomatch": "^4.0.2"
|
||||
"fdir": "^6.5.0",
|
||||
"picomatch": "^4.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
@@ -5849,9 +5879,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.3.tgz",
|
||||
"integrity": "sha512-OOUi5zjkDxYrKhTV3V7iKsoS37VUM7v40+HuwEmcrsf11Cdx9y3DIr2Px6liIcZFwt3XSRpQvFpL3WVy7ApkGw==",
|
||||
"version": "7.1.11",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.11.tgz",
|
||||
"integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
@@ -5859,7 +5889,7 @@
|
||||
"picomatch": "^4.0.3",
|
||||
"postcss": "^8.5.6",
|
||||
"rollup": "^4.43.0",
|
||||
"tinyglobby": "^0.2.14"
|
||||
"tinyglobby": "^0.2.15"
|
||||
},
|
||||
"bin": {
|
||||
"vite": "bin/vite.js"
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@tanstack/react-query": "^5.85.5",
|
||||
"jotai": "^2.15.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lucide-react": "^0.539.0",
|
||||
"next-themes": "^0.4.6",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from "react";
|
||||
import { useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { Box, Heading, Text, Flex, Button } from "@chakra-ui/react";
|
||||
import Sidebar from "./Sidebar";
|
||||
import UserTable from "../components/UserTable";
|
||||
@@ -17,6 +18,24 @@ const Dashboard: React.FC<DashboardProps> = ({ onLogout }) => {
|
||||
|
||||
const [activeView, setActiveView] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const raw = window.location.pathname.slice(1);
|
||||
if (raw) {
|
||||
setActiveView(decodeURIComponent(raw));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Sync URL when activeView changes, without reloading
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
if (!activeView) return;
|
||||
const desired = `/${encodeURIComponent(activeView)}`;
|
||||
if (window.location.pathname !== desired) {
|
||||
window.history.replaceState(null, "", desired);
|
||||
}
|
||||
}, [activeView]);
|
||||
|
||||
return (
|
||||
<Flex h="100vh">
|
||||
<Sidebar
|
||||
|
||||
@@ -5,6 +5,11 @@ import Login from "./Login";
|
||||
import Cookies from "js-cookie";
|
||||
import Landingpage from "@/components/API/Landingpage";
|
||||
|
||||
const API_BASE =
|
||||
(import.meta as any).env?.VITE_BACKEND_URL ||
|
||||
import.meta.env.VITE_BACKEND_URL ||
|
||||
"http://localhost:8002";
|
||||
|
||||
const Layout: React.FC = () => {
|
||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||
const [showAPI, setShowAPI] = useState(false);
|
||||
@@ -19,7 +24,7 @@ const Layout: React.FC = () => {
|
||||
|
||||
if (Cookies.get("token")) {
|
||||
const verifyToken = async () => {
|
||||
const response = await fetch("http://localhost:8002/api/verifyToken", {
|
||||
const response = await fetch(`${API_BASE}/api/verifyToken`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||
@@ -39,6 +44,7 @@ const Layout: React.FC = () => {
|
||||
|
||||
const handleLogout = () => {
|
||||
Cookies.remove("token");
|
||||
window.location.pathname = "/";
|
||||
setIsLoggedIn(false);
|
||||
};
|
||||
|
||||
|
||||
@@ -24,41 +24,43 @@ const Login: React.FC<{ onSuccess: () => void }> = ({ onSuccess }) => {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<Card.Root maxW="sm">
|
||||
<Card.Header>
|
||||
<Card.Title>Login</Card.Title>
|
||||
<Card.Description>
|
||||
Bitte unten Ihre Admin Zugangsdaten eingeben.
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Body>
|
||||
<Stack gap="4" w="full">
|
||||
<Field.Root>
|
||||
<Field.Label>username</Field.Label>
|
||||
<Input
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
</Field.Root>
|
||||
<Field.Root>
|
||||
<Field.Label>password</Field.Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</Field.Root>
|
||||
</Stack>
|
||||
</Card.Body>
|
||||
<Card.Footer justifyContent="flex-end">
|
||||
{isError && (
|
||||
<MyAlert status="error" title={errorMsg} description={errorDsc} />
|
||||
)}
|
||||
<Button onClick={() => handleLogin()} variant="solid">
|
||||
Login
|
||||
</Button>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
<form onSubmit={(e) => e.preventDefault()}>
|
||||
<Card.Root maxW="sm">
|
||||
<Card.Header>
|
||||
<Card.Title>Login</Card.Title>
|
||||
<Card.Description>
|
||||
Bitte unten Ihre Admin Zugangsdaten eingeben.
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Body>
|
||||
<Stack gap="4" w="full">
|
||||
<Field.Root>
|
||||
<Field.Label>username</Field.Label>
|
||||
<Input
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
</Field.Root>
|
||||
<Field.Root>
|
||||
<Field.Label>password</Field.Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</Field.Root>
|
||||
</Stack>
|
||||
</Card.Body>
|
||||
<Card.Footer justifyContent="flex-end">
|
||||
{isError && (
|
||||
<MyAlert status="error" title={errorMsg} description={errorDsc} />
|
||||
)}
|
||||
<Button type="submit" onClick={() => handleLogin()} variant="solid">
|
||||
Login
|
||||
</Button>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
3
admin/src/States/Atoms.tsx
Normal file
3
admin/src/States/Atoms.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import { atom } from "jotai";
|
||||
|
||||
export const testAtom = atom<number>(0);
|
||||
36
admin/src/States/README.md
Normal file
36
admin/src/States/README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# How to use Atoms
|
||||
Atoms are the fundamental building blocks of state management in this system. They represent individual pieces of state that can be shared and manipulated across different components.
|
||||
|
||||
You can also name it global state.
|
||||
|
||||
## Creating an Atom
|
||||
to create an atom you have to declare an atom like this:
|
||||
|
||||
```ts
|
||||
import { atom } from 'jotai';
|
||||
|
||||
export const NAME_OF_YOUR_ATOM = atom<type_of_your_atom>(initial_value);
|
||||
```
|
||||
|
||||
In this project we declare all atoms in the `States/Atoms.tsx`file. Which you can find above this README file.
|
||||
|
||||
## Using an Atom
|
||||
To use an atom in your component, you can use the `useAtom` hook provided by Jotai. Here's an example of how to use an atom in a React component:
|
||||
|
||||
```tsx
|
||||
import { useAtom } from 'jotai';
|
||||
import { NAME_OF_YOUR_ATOM } from '@/States/Atoms';
|
||||
|
||||
const MyComponent = () => {
|
||||
const [value, setValue] = useAtom(NAME_OF_YOUR_ATOM);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>Current value: {value}</p>
|
||||
<button onClick={() => setValue(newValue)}>Update Value</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
As you can see, you can use `useAtom` like `useState` but the state is global. In this example `value` is the current state of the atom, and `setValue` is a function to update the state, which is also known as the setter function.
|
||||
@@ -18,6 +18,11 @@ import { deleteAPKey } from "@/utils/userActions";
|
||||
import AddAPIKey from "./AddAPIKey";
|
||||
import { formatDateTime } from "@/utils/userFuncs";
|
||||
|
||||
const API_BASE =
|
||||
(import.meta as any).env?.VITE_BACKEND_URL ||
|
||||
import.meta.env.VITE_BACKEND_URL ||
|
||||
"http://localhost:8002";
|
||||
|
||||
type Items = {
|
||||
id: number;
|
||||
apiKey: string;
|
||||
@@ -51,7 +56,7 @@ const APIKeyTable: React.FC = () => {
|
||||
const fetchData = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch("http://localhost:8002/api/apiKeys", {
|
||||
const response = await fetch(`${API_BASE}/api/apiKeys`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||
|
||||
@@ -59,6 +59,14 @@ const AddAPIKey: React.FC<AddAPIKeyProps> = ({ onClose, alert }) => {
|
||||
"Der API Key wurde erfolgreich erstellt."
|
||||
);
|
||||
onClose();
|
||||
} else {
|
||||
alert(
|
||||
"error",
|
||||
"Fehler beim Erstellen des API Keys",
|
||||
res.message ||
|
||||
"Beim Erstellen des API Keys ist ein Fehler aufgetreten. (frontend bug)"
|
||||
);
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -33,7 +33,7 @@ const AddItemForm: React.FC<AddItemFormProps> = ({ onClose, alert }) => {
|
||||
<Input
|
||||
id="can_borrow_role"
|
||||
type="number"
|
||||
placeholder="Zahl (z.B. 2)"
|
||||
placeholder="Zahl (1 - 4)"
|
||||
/>
|
||||
</Field.Root>
|
||||
</Stack>
|
||||
@@ -68,8 +68,10 @@ const AddItemForm: React.FC<AddItemFormProps> = ({ onClose, alert }) => {
|
||||
alert(
|
||||
"error",
|
||||
"Fehler",
|
||||
"Der Gegenstand konnte nicht erstellt werden."
|
||||
res.message ||
|
||||
"Der Gegenstand konnte nicht erstellt werden. (frontend bug)"
|
||||
);
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -55,57 +55,64 @@ const ChangePWform: React.FC<ChangePWformProps> = ({
|
||||
</Field.Root>
|
||||
</Stack>
|
||||
</Card.Body>
|
||||
<Card.Footer justifyContent="flex-end" gap="2">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
onClick={async () => {
|
||||
const newPassword =
|
||||
(
|
||||
document.getElementById("new_password") as HTMLInputElement
|
||||
)?.value.trim() || "";
|
||||
const confirmNewPassword =
|
||||
(
|
||||
document.getElementById(
|
||||
"confirm_new_password"
|
||||
) as HTMLInputElement
|
||||
)?.value.trim() || "";
|
||||
<Card.Footer gap="2">
|
||||
<Stack w="full" gap="3">
|
||||
<Stack direction="row" justify="flex-end" gap="2">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
onClick={async () => {
|
||||
const newPassword =
|
||||
(
|
||||
document.getElementById(
|
||||
"new_password"
|
||||
) as HTMLInputElement
|
||||
)?.value.trim() || "";
|
||||
const confirmNewPassword =
|
||||
(
|
||||
document.getElementById(
|
||||
"confirm_new_password"
|
||||
) as HTMLInputElement
|
||||
)?.value.trim() || "";
|
||||
|
||||
if (!newPassword || newPassword !== confirmNewPassword) {
|
||||
subAlert("Passwörter stimmen nicht überein!");
|
||||
return;
|
||||
}
|
||||
if (!newPassword || newPassword !== confirmNewPassword) {
|
||||
subAlert("Passwörter stimmen nicht überein!");
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await changePW(newPassword, username);
|
||||
if (res.success) {
|
||||
alert(
|
||||
"success",
|
||||
"Passwort geändert",
|
||||
"Das Passwort wurde erfolgreich geändert."
|
||||
);
|
||||
onClose();
|
||||
} else {
|
||||
alert(
|
||||
"error",
|
||||
"Fehler",
|
||||
"Das Passwort konnte nicht geändert werden."
|
||||
);
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
Ändern
|
||||
</Button>
|
||||
{showSubAlert && (
|
||||
<Alert.Root status="error">
|
||||
<Alert.Indicator />
|
||||
<Alert.Content>
|
||||
<Alert.Title>{subAlertMessage}</Alert.Title>
|
||||
</Alert.Content>
|
||||
</Alert.Root>
|
||||
)}
|
||||
const res = await changePW(newPassword, username);
|
||||
if (res.success) {
|
||||
alert(
|
||||
"success",
|
||||
"Passwort geändert",
|
||||
"Das Passwort wurde erfolgreich geändert."
|
||||
);
|
||||
onClose();
|
||||
} else {
|
||||
alert(
|
||||
"error",
|
||||
"Fehler",
|
||||
"Das Passwort konnte nicht geändert werden."
|
||||
);
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
Ändern
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{showSubAlert && (
|
||||
<Alert.Root status="error">
|
||||
<Alert.Indicator />
|
||||
<Alert.Content>
|
||||
<Alert.Title>{subAlertMessage}</Alert.Title>
|
||||
</Alert.Content>
|
||||
</Alert.Root>
|
||||
)}
|
||||
</Stack>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
</div>
|
||||
|
||||
@@ -31,6 +31,11 @@ import {
|
||||
import AddItemForm from "./AddItemForm";
|
||||
import { formatDateTime } from "@/utils/userFuncs";
|
||||
|
||||
const API_BASE =
|
||||
(import.meta as any).env?.VITE_BACKEND_URL ||
|
||||
import.meta.env.VITE_BACKEND_URL ||
|
||||
"http://localhost:8002";
|
||||
|
||||
type Items = {
|
||||
id: number;
|
||||
item_name: string;
|
||||
@@ -77,7 +82,7 @@ const ItemTable: React.FC = () => {
|
||||
const fetchData = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch("http://localhost:8002/api/allItems", {
|
||||
const response = await fetch(`${API_BASE}/api/allItems`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||
|
||||
@@ -18,6 +18,11 @@ import { formatDateTime } from "@/utils/userFuncs";
|
||||
import { Trash2, RefreshCcwDot } from "lucide-react";
|
||||
import { deleteLoan } from "@/utils/userActions";
|
||||
|
||||
const API_BASE =
|
||||
(import.meta as any).env?.VITE_BACKEND_URL ||
|
||||
import.meta.env.VITE_BACKEND_URL ||
|
||||
"http://localhost:8002";
|
||||
|
||||
const LoanTable: React.FC = () => {
|
||||
const [items, setItems] = useState<Loan[]>([]);
|
||||
const [errorStatus, setErrorStatus] = useState<"error" | "success">("error");
|
||||
@@ -55,7 +60,7 @@ const LoanTable: React.FC = () => {
|
||||
const fetchData = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch("http://localhost:8002/api/allLoans", {
|
||||
const response = await fetch(`${API_BASE}/api/allLoans`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import Cookies from "js-cookie";
|
||||
|
||||
const API_BASE =
|
||||
(import.meta as any).env?.VITE_BACKEND_URL ||
|
||||
import.meta.env.VITE_BACKEND_URL ||
|
||||
"http://localhost:8002";
|
||||
|
||||
export const fetchUserData = async () => {
|
||||
const response = await fetch("http://localhost:8002/api/allUsers", {
|
||||
const response = await fetch(`${API_BASE}/api/allUsers`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||
},
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import Cookies from "js-cookie";
|
||||
|
||||
const API_BASE =
|
||||
(import.meta as any).env?.VITE_BACKEND_URL ||
|
||||
import.meta.env.VITE_BACKEND_URL ||
|
||||
"http://localhost:8002";
|
||||
|
||||
export type LoginSuccess = { success: true };
|
||||
export type LoginFailure = {
|
||||
success: false;
|
||||
@@ -13,7 +18,7 @@ export const loginFunc = async (
|
||||
password: string
|
||||
): Promise<LoginResult> => {
|
||||
try {
|
||||
const response = await fetch("http://localhost:8002/api/loginAdmin", {
|
||||
const response = await fetch(`${API_BASE}/api/loginAdmin`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import Cookies from "js-cookie";
|
||||
|
||||
const API_BASE =
|
||||
(import.meta as any).env?.VITE_BACKEND_URL ||
|
||||
import.meta.env.VITE_BACKEND_URL ||
|
||||
"http://localhost:8002";
|
||||
|
||||
export const handleDelete = async (userId: number) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`http://localhost:8002/api/deleteUser/${userId}`,
|
||||
`${API_BASE}/api/deleteUser/${userId}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
@@ -28,7 +33,7 @@ export const handleEdit = async (
|
||||
) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`http://localhost:8002/api/editUser/${userId}`,
|
||||
`${API_BASE}/api/editUser/${userId}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -54,7 +59,7 @@ export const createUser = async (
|
||||
password: string
|
||||
) => {
|
||||
try {
|
||||
const response = await fetch(`http://localhost:8002/api/createUser`, {
|
||||
const response = await fetch(`${API_BASE}/api/createUser`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -74,7 +79,7 @@ export const createUser = async (
|
||||
|
||||
export const changePW = async (newPassword: string, username: string) => {
|
||||
try {
|
||||
const response = await fetch(`http://localhost:8002/api/changePWadmin`, {
|
||||
const response = await fetch(`${API_BASE}/api/changePWadmin`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -95,7 +100,7 @@ export const changePW = async (newPassword: string, username: string) => {
|
||||
export const deleteLoan = async (loanId: number) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`http://localhost:8002/api/deleteLoan/${loanId}`,
|
||||
`${API_BASE}/api/deleteLoan/${loanId}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
@@ -116,7 +121,7 @@ export const deleteLoan = async (loanId: number) => {
|
||||
export const deleteItem = async (itemId: number) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`http://localhost:8002/api/deleteItem/${itemId}`,
|
||||
`${API_BASE}/api/deleteItem/${itemId}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
@@ -139,7 +144,7 @@ export const createItem = async (
|
||||
can_borrow_role: number
|
||||
) => {
|
||||
try {
|
||||
const response = await fetch(`http://localhost:8002/api/createItem`, {
|
||||
const response = await fetch(`${API_BASE}/api/createItem`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -148,7 +153,11 @@ export const createItem = async (
|
||||
body: JSON.stringify({ item_name, can_borrow_role }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to create item");
|
||||
return {
|
||||
success: false,
|
||||
message:
|
||||
"Fehler beim Erstellen des Gegenstands. Der Name des Gegenstandes darf nicht mehrmals vergeben werden.",
|
||||
};
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
@@ -163,7 +172,7 @@ export const handleEditItems = async (
|
||||
can_borrow_role: string
|
||||
) => {
|
||||
try {
|
||||
const response = await fetch("http://localhost:8002/api/updateItemByID", {
|
||||
const response = await fetch(`${API_BASE}/api/updateItemByID`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -184,7 +193,7 @@ export const handleEditItems = async (
|
||||
export const changeSafeState = async (itemId: number) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`http://localhost:8002/api/changeSafeState/${itemId}`,
|
||||
`${API_BASE}/api/changeSafeState/${itemId}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
@@ -204,7 +213,7 @@ export const changeSafeState = async (itemId: number) => {
|
||||
|
||||
export const createAPIentry = async (apiKey: string, user: string) => {
|
||||
try {
|
||||
const response = await fetch(`http://localhost:8002/api/createAPIentry`, {
|
||||
const response = await fetch(`${API_BASE}/api/createAPIentry`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -213,7 +222,11 @@ export const createAPIentry = async (apiKey: string, user: string) => {
|
||||
body: JSON.stringify({ apiKey, user }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to create API entry");
|
||||
return {
|
||||
success: false,
|
||||
message:
|
||||
"Fehler beim Erstellen des API Keys. Achten Sie darauf, dass alle Felder ausgefüllt sind und der API Key nicht doppelt vergeben wird.",
|
||||
};
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
@@ -225,7 +238,7 @@ export const createAPIentry = async (apiKey: string, user: string) => {
|
||||
export const deleteAPKey = async (apiKeyId: number) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`http://localhost:8002/api/deleteAPKey/${apiKeyId}`,
|
||||
`${API_BASE}/api/deleteAPKey/${apiKeyId}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
|
||||
@@ -29,7 +29,8 @@
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
"forceConsistentCasingInFileNames": true
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"ignoreDeprecations": "6.0"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
12
backend/package-lock.json
generated
12
backend/package-lock.json
generated
@@ -14,7 +14,8 @@
|
||||
"ejs": "^3.1.10",
|
||||
"express": "^5.1.0",
|
||||
"jose": "^6.0.12",
|
||||
"mysql2": "^3.14.3"
|
||||
"mysql2": "^3.14.3",
|
||||
"nodemailer": "^7.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
@@ -713,6 +714,15 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.6.tgz",
|
||||
"integrity": "sha512-F44uVzgwo49xboqbFgBGkRaiMgtoBrBEWCVincJPK9+S9Adkzt/wXCLKbf7dxucmxfTI5gHGB+bEmdyzN6QKjw==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"ejs": "^3.1.10",
|
||||
"express": "^5.1.0",
|
||||
"jose": "^6.0.12",
|
||||
"mysql2": "^3.14.3"
|
||||
"mysql2": "^3.14.3",
|
||||
"nodemailer": "^7.0.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,9 +25,192 @@ import {
|
||||
getAllApiKeys,
|
||||
createAPIentry,
|
||||
deleteAPKey,
|
||||
getLoanInfoWithID,
|
||||
SETdeleteLoanFromDatabase,
|
||||
} from "../services/database.js";
|
||||
import { authenticate, generateToken } from "../services/tokenService.js";
|
||||
const router = express.Router();
|
||||
import nodemailer from "nodemailer";
|
||||
import dotenv from "dotenv";
|
||||
dotenv.config();
|
||||
|
||||
// Nice HTML + text templates for the loan email
|
||||
function buildLoanEmail({ user, items, startDate, endDate, createdDate }) {
|
||||
const brand = process.env.MAIL_BRAND_COLOR || "#0ea5e9";
|
||||
const itemsList =
|
||||
Array.isArray(items) && items.length
|
||||
? `<ul style="margin:4px 0 0 18px; padding:0;">${items
|
||||
.map(
|
||||
(i) =>
|
||||
`<li style="margin:2px 0; color:#111827; line-height:1.3;">${i}</li>`
|
||||
)
|
||||
.join("")}</ul>`
|
||||
: "<span style='color:#111827;'>N/A</span>";
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="color-scheme" content="light">
|
||||
<meta name="supported-color-schemes" content="light">
|
||||
<meta name="x-apple-disable-message-reformatting">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<style>
|
||||
:root { color-scheme: light; supported-color-schemes: light; }
|
||||
body { margin:0; padding:0; }
|
||||
/* Mobile stacking */
|
||||
@media (max-width:480px) {
|
||||
.outer { width:100% !important; }
|
||||
.pad-sm { padding:16px !important; }
|
||||
.w-label { width:120px !important; }
|
||||
}
|
||||
/* Dark-mode override safety */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body, table, td, p, a, h1, h2, h3 { background:#ffffff !important; color:#111827 !important; }
|
||||
.brand-header { background:${brand} !important; color:#ffffff !important; }
|
||||
a { color:${brand} !important; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body bgcolor="#ffffff" style="background:#ffffff; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; color:#111827; -webkit-text-size-adjust:100%;">
|
||||
<!-- Preheader (hidden) -->
|
||||
<div style="display:none; max-height:0; overflow:hidden; opacity:0; mso-hide:all;">
|
||||
Neue Ausleihe erstellt – Übersicht der Buchung.
|
||||
</div>
|
||||
<div role="article" aria-roledescription="email" lang="de" style="padding:24px; background:#f2f4f7;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" class="outer" style="max-width:600px; margin:0 auto; background:#ffffff; border:1px solid #e5e7eb; border-radius:14px; overflow:hidden;">
|
||||
<tr>
|
||||
<td class="brand-header" style="padding:22px 26px; background:${brand}; color:#ffffff;">
|
||||
<h1 style="margin:0; font-size:18px; line-height:1.35; font-weight:600;">Neue Ausleihe erstellt</h1>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="pad-sm" style="padding:24px 26px; color:#111827;">
|
||||
<p style="margin:0 0 14px 0; line-height:1.4;">Es wurde eine neue Ausleihe angelegt. Hier sind die Details:</p>
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="border-collapse:collapse; font-size:14px; line-height:1.3; background:#fcfcfd; border:1px solid #e5e7eb; border-radius:10px; overflow:hidden;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="w-label" style="padding:10px 14px; color:#6b7280; width:170px; border-bottom:1px solid #ececec;">Benutzer</td>
|
||||
<td style="padding:10px 14px; font-weight:600; border-bottom:1px solid #ececec; color:#111827;">${
|
||||
user || "N/A"
|
||||
}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:10px 14px; color:#6b7280; vertical-align:top; border-bottom:1px solid #ececec;">Ausgeliehene Gegenstände</td>
|
||||
<td style="padding:10px 14px; font-weight:600; border-bottom:1px solid #ececec; color:#111827;">${itemsList}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:10px 14px; color:#6b7280; border-bottom:1px solid #ececec;">Startdatum</td>
|
||||
<td style="padding:10px 14px; font-weight:600; border-bottom:1px solid #ececec; color:#111827;">${formatDateTime(
|
||||
startDate
|
||||
)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:10px 14px; color:#6b7280; border-bottom:1px solid #ececec;">Enddatum</td>
|
||||
<td style="padding:10px 14px; font-weight:600; border-bottom:1px solid #ececec; color:#111827;">${formatDateTime(
|
||||
endDate
|
||||
)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:10px 14px; color:#6b7280;">Erstellt am</td>
|
||||
<td style="padding:10px 14px; font-weight:600; color:#111827;">${formatDateTime(
|
||||
createdDate
|
||||
)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p style="margin:22px 0 0 0; font-size:14px;">
|
||||
<a href="https://admin.insta.the1s.de/api" style="display:inline-block; background:${brand}; color:#ffffff; text-decoration:none; padding:10px 16px; border-radius:6px; font-weight:600; font-size:14px;" target="_blank" rel="noopener noreferrer">
|
||||
Übersicht öffnen
|
||||
</a>
|
||||
</p>
|
||||
<p style="margin:18px 0 0 0; font-size:12px; color:#6b7280; line-height:1.4;">
|
||||
Diese E-Mail wurde automatisch vom Ausleihsystem gesendet. Bitte nicht antworten.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function buildLoanEmailText({ user, items, startDate, endDate, createdDate }) {
|
||||
const itemsText =
|
||||
Array.isArray(items) && items.length ? items.join(", ") : "N/A";
|
||||
return [
|
||||
"Neue Ausleihe erstellt",
|
||||
"",
|
||||
`Benutzer: ${user || "N/A"}`,
|
||||
`Gegenstände: ${itemsText}`,
|
||||
`Start: ${formatDateTime(startDate)}`,
|
||||
`Ende: ${formatDateTime(endDate)}`,
|
||||
`Erstellt am: ${formatDateTime(createdDate)}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function sendMailLoan(user, items, startDate, endDate, createdDate) {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: process.env.MAIL_HOST,
|
||||
port: process.env.MAIL_PORT,
|
||||
secure: true,
|
||||
auth: {
|
||||
user: process.env.MAIL_USER,
|
||||
pass: process.env.MAIL_PASSWORD,
|
||||
},
|
||||
});
|
||||
|
||||
(async () => {
|
||||
const info = await transporter.sendMail({
|
||||
from: '"Ausleihsystem" <noreply@mcs-medien.de>',
|
||||
to: process.env.MAIL_SENDEES,
|
||||
subject: "Eine neue Ausleihe wurde erstellt!",
|
||||
text: buildLoanEmailText({
|
||||
user,
|
||||
items,
|
||||
startDate,
|
||||
endDate,
|
||||
createdDate,
|
||||
}),
|
||||
html: buildLoanEmail({ user, items, startDate, endDate, createdDate }),
|
||||
});
|
||||
|
||||
console.log("Message sent:", info.messageId);
|
||||
})();
|
||||
console.log("sendMailLoan called");
|
||||
}
|
||||
|
||||
const formatDateTime = (value) => {
|
||||
if (value == null) return "N/A";
|
||||
|
||||
const toOut = (d) => {
|
||||
if (!(d instanceof Date) || isNaN(d.getTime())) return "N/A";
|
||||
const dd = String(d.getDate()).padStart(2, "0");
|
||||
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const yyyy = d.getFullYear();
|
||||
const hh = String(d.getHours()).padStart(2, "0");
|
||||
const mi = String(d.getMinutes()).padStart(2, "0");
|
||||
return `${dd}.${mm}.${yyyy} ${hh}:${mi} Uhr`;
|
||||
};
|
||||
|
||||
if (value instanceof Date) return toOut(value);
|
||||
if (typeof value === "number") return toOut(new Date(value));
|
||||
|
||||
const s = String(value).trim();
|
||||
|
||||
// Direct pattern: "YYYY-MM-DD[ T]HH:mm[:ss]"
|
||||
const m = s.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::\d{2})?/);
|
||||
if (m) {
|
||||
const [, y, M, d, h, min] = m;
|
||||
return `${d}.${M}.${y} ${h}:${min} Uhr`;
|
||||
}
|
||||
|
||||
// ISO or other parseable formats
|
||||
const dObj = new Date(s);
|
||||
if (!isNaN(dObj.getTime())) return toOut(dObj);
|
||||
|
||||
return "N/A";
|
||||
};
|
||||
|
||||
router.post("/login", async (req, res) => {
|
||||
const result = await loginFunc(req.body.username, req.body.password);
|
||||
@@ -43,7 +226,6 @@ router.post("/login", async (req, res) => {
|
||||
});
|
||||
|
||||
router.get("/items", authenticate, async (req, res) => {
|
||||
console.log(req);
|
||||
const result = await getItemsFromDatabase(req.user.role);
|
||||
if (result.success) {
|
||||
res.status(200).json(result.data);
|
||||
@@ -80,6 +262,16 @@ router.delete("/deleteLoan/:id", authenticate, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.delete("/SETdeleteLoan/:id", authenticate, async (req, res) => {
|
||||
const loanId = req.params.id;
|
||||
const result = await SETdeleteLoanFromDatabase(loanId);
|
||||
if (result.success) {
|
||||
res.status(200).json({ message: "Loan deleted successfully" });
|
||||
} else {
|
||||
res.status(500).json({ message: "Failed to delete loan" });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/borrowableItems", authenticate, async (req, res) => {
|
||||
const { startDate, endDate } = req.body || {};
|
||||
if (!startDate || !endDate) {
|
||||
@@ -158,6 +350,15 @@ router.post("/createLoan", authenticate, async (req, res) => {
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
const mailInfo = await getLoanInfoWithID(result.data.id);
|
||||
console.log(mailInfo);
|
||||
sendMailLoan(
|
||||
mailInfo.data.username,
|
||||
mailInfo.data.loaned_items_name,
|
||||
mailInfo.data.start_date,
|
||||
mailInfo.data.end_date,
|
||||
mailInfo.data.created_at
|
||||
);
|
||||
return res.status(201).json({
|
||||
message: "Loan created successfully",
|
||||
loanId: result.data.id,
|
||||
@@ -242,7 +443,7 @@ router.delete("/deleteUser/:id", authenticate, async (req, res) => {
|
||||
});
|
||||
|
||||
router.get("/verifyToken", authenticate, async (req, res) => {
|
||||
res.status(200).json({ message: "Token is valid" });
|
||||
res.status(200).json({ message: "Token is valid", user: req.user });
|
||||
});
|
||||
|
||||
router.post("/editUser/:id", authenticate, async (req, res) => {
|
||||
|
||||
@@ -3,8 +3,8 @@ import dotenv from "dotenv";
|
||||
import {
|
||||
getItemsFromDatabaseV2,
|
||||
changeInSafeStateV2,
|
||||
setReturnDateV2,
|
||||
setTakeDateV2,
|
||||
setReturnDateV2,
|
||||
getLoanByCodeV2,
|
||||
getAllLoansV2,
|
||||
getAPIkey,
|
||||
|
||||
@@ -34,6 +34,7 @@ CREATE TABLE `loans` (
|
||||
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`loaned_items_id` json NOT NULL DEFAULT ('[]'),
|
||||
`loaned_items_name` json NOT NULL DEFAULT ('[]'),
|
||||
`deleted` bool NOT NULL DEFAULT false,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `loan_code` (`loan_code`)
|
||||
);
|
||||
|
||||
@@ -51,22 +51,56 @@ export const changeInSafeStateV2 = async (itemId) => {
|
||||
};
|
||||
|
||||
export const setReturnDateV2 = async (loanCode) => {
|
||||
const [items] = await pool.query(
|
||||
"SELECT loaned_items_id FROM loans WHERE loan_code = ?",
|
||||
[loanCode]
|
||||
);
|
||||
|
||||
if (items.length === 0) return { success: false };
|
||||
|
||||
const itemIds = Array.isArray(items[0].loaned_items_id)
|
||||
? items[0].loaned_items_id
|
||||
: JSON.parse(items[0].loaned_items_id || "[]");
|
||||
|
||||
const [setItemStates] = await pool.query(
|
||||
"UPDATE items SET inSafe = 1 WHERE id IN (?)",
|
||||
[itemIds]
|
||||
);
|
||||
|
||||
const [result] = await pool.query(
|
||||
"UPDATE loans SET returned_date = NOW() WHERE loan_code = ?",
|
||||
[loanCode]
|
||||
);
|
||||
if (result.affectedRows > 0) {
|
||||
|
||||
if (result.affectedRows > 0 && setItemStates.affectedRows > 0) {
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false };
|
||||
};
|
||||
|
||||
export const setTakeDateV2 = async (loanCode) => {
|
||||
const [items] = await pool.query(
|
||||
"SELECT loaned_items_id FROM loans WHERE loan_code = ?",
|
||||
[loanCode]
|
||||
);
|
||||
|
||||
if (items.length === 0) return { success: false };
|
||||
|
||||
const itemIds = Array.isArray(items[0].loaned_items_id)
|
||||
? items[0].loaned_items_id
|
||||
: JSON.parse(items[0].loaned_items_id || "[]");
|
||||
|
||||
const [setItemStates] = await pool.query(
|
||||
"UPDATE items SET inSafe = 0 WHERE id IN (?)",
|
||||
[itemIds]
|
||||
);
|
||||
|
||||
const [result] = await pool.query(
|
||||
"UPDATE loans SET take_date = NOW() WHERE loan_code = ?",
|
||||
[loanCode]
|
||||
);
|
||||
if (result.affectedRows > 0) {
|
||||
|
||||
if (result.affectedRows > 0 && setItemStates.affectedRows > 0) {
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false };
|
||||
@@ -92,9 +126,10 @@ export const getLoansFromDatabase = async () => {
|
||||
};
|
||||
|
||||
export const getUserLoansFromDatabase = async (username) => {
|
||||
const [result] = await pool.query("SELECT * FROM loans WHERE username = ?;", [
|
||||
username,
|
||||
]);
|
||||
const [result] = await pool.query(
|
||||
"SELECT * FROM loans WHERE username = ? AND deleted = 0;",
|
||||
[username]
|
||||
);
|
||||
if (result.length > 0) {
|
||||
return { success: true, data: result };
|
||||
} else if (result.length == 0) {
|
||||
@@ -115,6 +150,18 @@ export const deleteLoanFromDatabase = async (loanId) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const SETdeleteLoanFromDatabase = async (loanId) => {
|
||||
const [result] = await pool.query(
|
||||
"UPDATE loans SET deleted = 1 WHERE id = ?;",
|
||||
[loanId]
|
||||
);
|
||||
if (result.affectedRows > 0) {
|
||||
return { success: true };
|
||||
} else {
|
||||
return { success: false };
|
||||
}
|
||||
};
|
||||
|
||||
export const getBorrowableItemsFromDatabase = async (
|
||||
startDate,
|
||||
endDate,
|
||||
@@ -148,6 +195,16 @@ export const getBorrowableItemsFromDatabase = async (
|
||||
return { success: false };
|
||||
};
|
||||
|
||||
export const getLoanInfoWithID = async (loanId) => {
|
||||
const [rows] = await pool.query("SELECT * FROM loans WHERE id = ?;", [
|
||||
loanId,
|
||||
]);
|
||||
if (rows.length > 0) {
|
||||
return { success: true, data: rows[0] };
|
||||
}
|
||||
return { success: false };
|
||||
};
|
||||
|
||||
export const createLoanInDatabase = async (
|
||||
username,
|
||||
startDate,
|
||||
|
||||
@@ -9,7 +9,6 @@ export async function generateToken(payload) {
|
||||
.setIssuedAt()
|
||||
.setExpirationTime("2h") // Token valid for 2 hours
|
||||
.sign(secret);
|
||||
console.log("Generated token: ", newToken);
|
||||
return newToken;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
services:
|
||||
# borrow_system-frontend:
|
||||
# container_name: borrow_system-frontend
|
||||
# build: ./frontend
|
||||
# build: ./FrontendV2
|
||||
# ports:
|
||||
# - "8001:8001"
|
||||
# environment:
|
||||
# - CHOKIDAR_USEPOLLING=true
|
||||
# volumes:
|
||||
# - ./frontend:/app
|
||||
# - ./FrontendV2:/app
|
||||
# - /app/node_modules
|
||||
# restart: unless-stopped
|
||||
|
||||
|
||||
50
frontend/package-lock.json
generated
50
frontend/package-lock.json
generated
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@tanstack/react-query": "^5.85.5",
|
||||
"jotai": "^2.15.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lucide-react": "^0.539.0",
|
||||
"primeicons": "^7.0.0",
|
||||
@@ -3164,6 +3165,35 @@
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/jotai": {
|
||||
"version": "2.15.0",
|
||||
"resolved": "https://registry.npmjs.org/jotai/-/jotai-2.15.0.tgz",
|
||||
"integrity": "sha512-nbp/6jN2Ftxgw0VwoVnOg0m5qYM1rVcfvij+MZx99Z5IK13eGve9FJoCwGv+17JvVthTjhSmNtT5e1coJnr6aw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": ">=7.0.0",
|
||||
"@babel/template": ">=7.0.0",
|
||||
"@types/react": ">=17.0.0",
|
||||
"react": ">=17.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@babel/core": {
|
||||
"optional": true
|
||||
},
|
||||
"@babel/template": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/js-cookie": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
|
||||
@@ -4292,13 +4322,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.14",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
|
||||
"integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==",
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fdir": "^6.4.4",
|
||||
"picomatch": "^4.0.2"
|
||||
"fdir": "^6.5.0",
|
||||
"picomatch": "^4.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
@@ -4469,17 +4499,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.2.tgz",
|
||||
"integrity": "sha512-J0SQBPlQiEXAF7tajiH+rUooJPo0l8KQgyg4/aMunNtrOa7bwuZJsJbDWzeljqQpgftxuq5yNJxQ91O9ts29UQ==",
|
||||
"version": "7.1.12",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.12.tgz",
|
||||
"integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.6",
|
||||
"fdir": "^6.5.0",
|
||||
"picomatch": "^4.0.3",
|
||||
"postcss": "^8.5.6",
|
||||
"rollup": "^4.43.0",
|
||||
"tinyglobby": "^0.2.14"
|
||||
"tinyglobby": "^0.2.15"
|
||||
},
|
||||
"bin": {
|
||||
"vite": "bin/vite.js"
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@tanstack/react-query": "^5.85.5",
|
||||
"jotai": "^2.15.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lucide-react": "^0.539.0",
|
||||
"primeicons": "^7.0.0",
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import "./App.css";
|
||||
import Layout from "./layout/Layout";
|
||||
import { useEffect, useState } from "react";
|
||||
import Form1 from "./components/Form1";
|
||||
import Form2 from "./components/Form2";
|
||||
import Form4 from "./components/Form4";
|
||||
import { AddLoan } from "./components/AddLoan";
|
||||
import LoginForm from "./components/LoginForm";
|
||||
import Cookies from "js-cookie";
|
||||
import {
|
||||
@@ -46,13 +44,7 @@ function App() {
|
||||
|
||||
return isLoggedIn ? (
|
||||
<Layout onLogout={handleLogout}>
|
||||
<div className="space-y-6">
|
||||
<Form1 />
|
||||
<div className="h-px bg-slate-200" />
|
||||
<Form2 />
|
||||
<div className="h-px bg-slate-200" />
|
||||
<Form4 />
|
||||
</div>
|
||||
<AddLoan />
|
||||
</Layout>
|
||||
) : (
|
||||
<LoginForm onLogin={() => setIsLoggedIn(true)} />
|
||||
|
||||
6
frontend/src/States/Atoms.tsx
Normal file
6
frontend/src/States/Atoms.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { atom } from "jotai";
|
||||
|
||||
// Atoms to store the start and end dates for loans
|
||||
export const startDate = atom<string | null>(null);
|
||||
export const endDate = atom<string | null>(null);
|
||||
export const getBorrowableItemsAtom = atom<string[] | boolean>(false);
|
||||
36
frontend/src/States/README.md
Normal file
36
frontend/src/States/README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# How to use Atoms
|
||||
Atoms are the fundamental building blocks of state management in this system. They represent individual pieces of state that can be shared and manipulated across different components.
|
||||
|
||||
You can also name it global state.
|
||||
|
||||
## Creating an Atom
|
||||
to create an atom you have to declare an atom like this:
|
||||
|
||||
```ts
|
||||
import { atom } from 'jotai';
|
||||
|
||||
export const NAME_OF_YOUR_ATOM = atom<type_of_your_atom>(initial_value);
|
||||
```
|
||||
|
||||
In this project we declare all atoms in the `States/Atoms.tsx`file. Which you can find above this README file.
|
||||
|
||||
## Using an Atom
|
||||
To use an atom in your component, you can use the `useAtom` hook provided by Jotai. Here's an example of how to use an atom in a React component:
|
||||
|
||||
```tsx
|
||||
import { useAtom } from 'jotai';
|
||||
import { NAME_OF_YOUR_ATOM } from '@/States/Atoms';
|
||||
|
||||
const MyComponent = () => {
|
||||
const [value, setValue] = useAtom(NAME_OF_YOUR_ATOM);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>Current value: {value}</p>
|
||||
<button onClick={() => setValue(newValue)}>Update Value</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
As you can see, you can use `useAtom` like `useState` but the state is global. In this example `value` is the current state of the atom, and `setValue` is a function to update the state, which is also known as the setter function.
|
||||
69
frontend/src/components/AddLoan.tsx
Normal file
69
frontend/src/components/AddLoan.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { getBorrowableItems } from "../utils/fetchData";
|
||||
import { useAtom } from "jotai";
|
||||
import { startDate, endDate } from "../States/Atoms";
|
||||
import Cookies from "js-cookie";
|
||||
|
||||
export const AddLoan = () => {
|
||||
const [start, setStart] = useAtom(startDate);
|
||||
const [end, setEnd] = useAtom(endDate);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg sm:text-xl font-bold text-slate-900">
|
||||
1. Zeitraum wählen
|
||||
</h2>
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const form = e.currentTarget as HTMLFormElement;
|
||||
const fd = new FormData(form);
|
||||
const start = (fd.get("startDate") as string) || "";
|
||||
const end = (fd.get("endDate") as string) || "";
|
||||
setStart(start);
|
||||
setEnd(end);
|
||||
Cookies.set("startDate", start);
|
||||
Cookies.set("endDate", end);
|
||||
getBorrowableItems();
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="startDate"
|
||||
className="block text-sm font-medium text-slate-700 mb-1"
|
||||
>
|
||||
Start
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
id="startDate"
|
||||
name="startDate"
|
||||
className="w-full border border-slate-300 rounded-lg px-3 py-2.5 focus:ring-2 focus:ring-indigo-500 focus:outline-none bg-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="endDate"
|
||||
className="block text-sm font-medium text-slate-700 mb-1"
|
||||
>
|
||||
Ende
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
id="endDate"
|
||||
name="endDate"
|
||||
className="w-full border border-slate-300 rounded-lg px-3 py-2.5 focus:ring-2 focus:ring-indigo-500 focus:outline-none bg-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full bg-indigo-600 text-white font-bold py-2.5 px-4 rounded-lg shadow hover:bg-indigo-700 transition"
|
||||
>
|
||||
Verfügbare Gegenstände anzeigen
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -19,6 +19,11 @@ type Loan = {
|
||||
loaned_items_name: string[];
|
||||
};
|
||||
|
||||
const API_BASE =
|
||||
(import.meta as any).env?.VITE_BACKEND_URL ||
|
||||
import.meta.env.VITE_BACKEND_URL ||
|
||||
"http://localhost:8002";
|
||||
|
||||
const formatDate = (iso: string | null) => {
|
||||
if (!iso) return "-";
|
||||
const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/);
|
||||
@@ -28,7 +33,7 @@ const formatDate = (iso: string | null) => {
|
||||
};
|
||||
|
||||
async function fetchUserLoans(): Promise<Loan[]> {
|
||||
const res = await fetch("http://localhost:8002/api/userLoans", {
|
||||
const res = await fetch(`${API_BASE}/api/userLoans`, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${Cookies.get("token") || ""}` },
|
||||
});
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import Cookies from "js-cookie";
|
||||
import { myToast } from "./toastify";
|
||||
import { useAtom } from "jotai";
|
||||
import { getBorrowableItemsAtom } from "../States/Atoms";
|
||||
|
||||
// Event name used to notify the app when the list of items has been updated
|
||||
export const ALL_ITEMS_UPDATED_EVENT = "allItemsUpdated";
|
||||
export const BORROWABLE_ITEMS_UPDATED_EVENT = "borrowableItemsUpdated";
|
||||
export const AUTH_LOGOUT_EVENT = "authLogout";
|
||||
|
||||
const API_BASE =
|
||||
(import.meta as any).env?.VITE_BACKEND_URL ||
|
||||
import.meta.env.VITE_BACKEND_URL ||
|
||||
"http://localhost:8002";
|
||||
|
||||
let sendError = false;
|
||||
|
||||
function logout() {
|
||||
@@ -25,7 +32,7 @@ export const fetchAllData = async (token: string | undefined) => {
|
||||
if (!token) return;
|
||||
// First we fetch all items that are potentially available for borrowing
|
||||
try {
|
||||
const response = await fetch("http://localhost:8002/api/items", {
|
||||
const response = await fetch(`${API_BASE}/api/items`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
@@ -57,7 +64,7 @@ export const fetchAllData = async (token: string | undefined) => {
|
||||
|
||||
// get all loans
|
||||
try {
|
||||
const response = await fetch("http://localhost:8002/api/loans", {
|
||||
const response = await fetch(`${API_BASE}/api/loans`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
@@ -89,7 +96,7 @@ export const fetchAllData = async (token: string | undefined) => {
|
||||
|
||||
// get user loans
|
||||
try {
|
||||
const response = await fetch("http://localhost:8002/api/userLoans", {
|
||||
const response = await fetch(`${API_BASE}/api/userLoans`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
@@ -122,7 +129,7 @@ export const fetchAllData = async (token: string | undefined) => {
|
||||
|
||||
export const loginUser = async (username: string, password: string) => {
|
||||
try {
|
||||
const response = await fetch("http://localhost:8002/api/login", {
|
||||
const response = await fetch(`${API_BASE}/api/login`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -158,7 +165,7 @@ export const getBorrowableItems = async () => {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch("http://localhost:8002/api/borrowableItems", {
|
||||
const response = await fetch(`${API_BASE}/api/borrowableItems`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${Cookies.get("token") || ""}`,
|
||||
@@ -185,6 +192,7 @@ export const getBorrowableItems = async () => {
|
||||
|
||||
const data = await response.json();
|
||||
localStorage.setItem("borrowableItems", JSON.stringify(data));
|
||||
|
||||
window.dispatchEvent(new Event(BORROWABLE_ITEMS_UPDATED_EVENT)); // notify same-tab listeners
|
||||
console.log("Borrowable items fetched successfully");
|
||||
} catch (error) {
|
||||
|
||||
@@ -2,10 +2,15 @@ import { myToast } from "./toastify";
|
||||
import Cookies from "js-cookie";
|
||||
import { queryClient } from "./queryClient";
|
||||
|
||||
const API_BASE =
|
||||
(import.meta as any).env?.VITE_BACKEND_URL ||
|
||||
import.meta.env.VITE_BACKEND_URL ||
|
||||
"http://localhost:8002";
|
||||
|
||||
export const handleDeleteLoan = async (loanID: number): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`http://localhost:8002/api/deleteLoan/${loanID}`,
|
||||
`${API_BASE}/api/deleteLoan/${loanID}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
@@ -75,7 +80,7 @@ export const rmFromRemove = (itemID: number) => {
|
||||
|
||||
export const createLoan = async (startDate: string, endDate: string) => {
|
||||
const items = removeArr;
|
||||
const response = await fetch("http://localhost:8002/api/createLoan", {
|
||||
const response = await fetch(`${API_BASE}/api/createLoan`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -103,7 +108,7 @@ export const createLoan = async (startDate: string, endDate: string) => {
|
||||
|
||||
export const onReturn = async (loanID: number) => {
|
||||
const response = await fetch(
|
||||
`http://localhost:8002/api/returnLoan/${loanID}`,
|
||||
`${API_BASE}/api/returnLoan/${loanID}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -122,7 +127,7 @@ export const onReturn = async (loanID: number) => {
|
||||
};
|
||||
|
||||
export const onTake = async (loanID: number) => {
|
||||
const response = await fetch(`http://localhost:8002/api/takeLoan/${loanID}`, {
|
||||
const response = await fetch(`${API_BASE}/api/takeLoan/${loanID}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${Cookies.get("token") || ""}`,
|
||||
@@ -139,7 +144,7 @@ export const onTake = async (loanID: number) => {
|
||||
};
|
||||
|
||||
export const changePW = async (oldPassword: string, newPassword: string) => {
|
||||
const response = await fetch("http://localhost:8002/api/changePassword", {
|
||||
const response = await fetch(`${API_BASE}/api/changePassword`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user