47 Commits

Author SHA1 Message Date
5ba35bb471 remove unused mock data files and update docker-compose paths 2025-10-25 22:44:43 +02:00
47b5590394 removed landingpage from admin panel 2025-10-25 22:43:16 +02:00
47fec60b5b added landingpage and fixed routing 2025-10-25 22:41:43 +02:00
e9319b49ec enhanced Header component with mobile menu and password change dialog; updated HomePage layout 2025-10-25 22:31:54 +02:00
b98e38b38b enhanced MyLoansPage with confirmation dialog for loan deletion; improved table layout and added code formatting for loan codes 2025-10-25 22:11:44 +02:00
a24a3033d3 updated scheme 2025-10-25 21:53:08 +02:00
d94d68aa33 added password input component and integrated password change functionality in Header; updated LoginPage for localized labels 2025-10-25 21:49:28 +02:00
cc0dcaf664 added MyLoansPage component and integrated loan deletion functionality; updated routing in App and added Header component 2025-10-25 21:27:08 +02:00
7a79bf4436 added loan creation functionality and improved error handling in HomePage and Fetcher 2025-10-25 20:19:11 +02:00
4b00dd6554 added borrowable items fetching and date input functionality to HomePage 2025-10-25 20:01:06 +02:00
ba34a97328 added sf pro 2025-10-25 19:07:26 +02:00
a013ad0bb8 enhanced greeting 2025-10-25 16:47:32 +02:00
d7240584f9 added logout function and enhanced greeting 2025-10-25 16:44:30 +02:00
a0bdf5539c added greeting with context logic 2025-10-25 15:59:52 +02:00
770025f8fc added changelog mock (not yet functionaning) 2025-10-25 00:23:56 +02:00
960e91c38a feat: Implement authentication flow with token verification and protected routes 2025-10-24 20:45:37 +02:00
b99f52f09a feat: Initialize FrontendV2 project with React, Vite, and Tailwind CSS
- Add package.json with dependencies and scripts for development and build
- Include Vite logo and React logo SVGs in public/assets
- Set up Tailwind CSS in App.css and index.css
- Create main App component with routing for Home and Login pages
- Implement LoginPage with authentication logic and error handling
- Add HomePage component as a landing page
- Create MyAlert component for displaying alerts using Chakra UI
- Implement color mode toggle functionality with Chakra UI
- Set up global state management using Jotai for authentication
- Create ProtectedRoutes component to guard routes based on authentication
- Add utility components for Toaster and Tooltip using Chakra UI
- Configure Tailwind CSS and TypeScript settings for the project
- Implement AddLoan component for selecting loan periods and fetching available items
2025-10-24 20:21:32 +02:00
86af1a5edf implemented jotai atoms 2025-10-22 21:44:05 +02:00
49d4d13afc improved design of email 2025-10-05 15:55:05 +02:00
45fa095eaf fixed mail view issues 2025-10-05 15:49:38 +02:00
23be7e12c7 removed some logging stuff 2025-10-04 19:34:27 +02:00
6ea1ff799c added .env link function 2025-10-04 00:26:02 +02:00
5131266242 changed itemform placeholder for better understanding 2025-10-04 00:08:19 +02:00
bb17bc735c added landingpage to email 2025-10-04 00:07:32 +02:00
af7d15c97a add nodemailer integration for loan email notifications and implement loan info retrieval 2025-10-02 22:32:26 +02:00
04453fd885 improved design of the error message 2025-09-30 13:06:49 +02:00
bf36a6605f improved error handling for adding an item 2025-09-30 13:03:00 +02:00
8f9696991f improved error handling 2025-09-30 12:59:38 +02:00
9cad1e8b6b fixed minor display bugs 2025-09-30 12:53:58 +02:00
880029a0cf changed docs 2025-09-29 10:53:50 +02:00
32abe60d98 fixed api route for setting the return and take date 2025-09-29 10:44:51 +02:00
ea965971f1 Refactor Dashboard and Login components: add URL synchronization in Dashboard, update Login form submission handling 2025-09-28 16:07:39 +02:00
eff1f61422 Refactor API routes: remove API key requirement for allLoans and allItems endpoints, update comments for clarity 2025-09-27 23:29:19 +02:00
a4c0323100 changed docs 2025-09-27 23:29:08 +02:00
fc755edadf fixed scheme 2025-09-27 23:06:21 +02:00
0fca896cc2 Refactor API key validation: streamline error handling and enforce API key presence in routes 2025-09-27 22:54:15 +02:00
f83f321876 NOT WORKING - Implement API key management features: add API key creation and deletion, update API routes, and refactor related components. - NOT WORKING 2025-09-27 17:33:59 +02:00
b9d637665c changed data scheme 2025-09-27 16:40:36 +02:00
378720b235 created APIKeyTable 2025-09-27 16:37:57 +02:00
49f4ba8483 added itemview to landingpage 2025-09-22 13:22:27 +02:00
ea5d31384a added a bit documentation to the api file 2025-09-21 10:45:31 +02:00
7f5f464841 changed design for the landingpage 2025-09-21 10:45:16 +02:00
ab93c9959d fullfilled landingpage 2025-09-21 00:48:28 +02:00
679ef7dcbd feat: implement Landingpage component and update Layout to conditionally render it 2025-09-19 12:24:17 +02:00
c3572a3d70 fix: adjust icon placement and styling for safe state indicator 2025-09-16 13:49:44 +02:00
4fc60a08d9 refactor: update button for safe state with improved styling and text display 2025-09-16 13:48:56 +02:00
5159877d8d added changeSafeState function 2025-09-16 13:00:15 +02:00
76 changed files with 10040 additions and 780 deletions

View File

@@ -1,4 +1,4 @@
# Backend API docs # Backend API docs (apiV2)
If you want to cooperate with me, or build something new with my backend API, feel free to reach out! If you want to cooperate with me, or build something new with my backend API, feel free to reach out!
@@ -6,49 +6,51 @@ On this page you will learn how my API works.
## General information ## General information
When you look at my backend folder and file structure, you can see that I have two files called `API`. The first file called `api.js` is for my web frontend, because this file works together with my JWT token service. When you look at my backend folder and file structure, you can see that I have two files called `API`. The first file called `api.js` which is for my web frontend, because this file works together with my JWT token service.
**\*But I have built a second API. You can see the second API file in the same directory, the file is called `apiV2.js`.** But I have built a second API. You can see the second API file in the same directory, the file is called `apiV2.js`.
This is the file that you can use to build an API. But first you have to get an API Key. You can get the API key from my admin dashboard. When you don't have any access to my admin dashboard, please contact your administrator or me.
But first you have to get the Admin API key, stored in an `.env` file on my server. ---
## Base URL
- Frontend: `https://insta.the1s.de`
- Backend: `https://backend.insta.the1s.de`
- Base path for this API: `https://backend.insta.the1s.de/apiV2`
You can see the status of this and all my other services at `https://status.the1s.de`.
_I have also build a [fallback page](https://git.the1s.de/theis.gaedigk/fallback-page). When only the application is down, you will see a friendly message and a link to the status page. (Only if the server is not down)_
--- ---
## Authentication ## Authentication
All endpoints require the Admin API key (`ADMIN_ID`) as a URL parameter. All endpoints require an API key as a path parameter named `:key`.
Example: `/apiV2/items/{ADMIN_ID}` Example: `/apiV2/items/:key`
If the key is missing or invalid, the API responds with `401 Unauthorized`.
--- ---
## URL ## Endpoints
- The frontend is currently running on `https://insta.the1s.de`. ### 1) Get all items
- The backend is currently running on `https://backend.insta.the1s.de`. GET `/apiV2/items/:key`
You can see the status of this and all my other services at `https://status.the1s.de`. Returns a list of all items wrapped in a `data` object.
--- Example request:
## Current endpoints
### 1. Get All Items
**GET** `/apiV2/items/:key`
Returns a list of all items and their details.
#### Example Request
``` ```
GET https://backend.insta.the1s.de/apiV2/items/your_admin_key GET https://backend.insta.the1s.de/apiV2/items/12345
``` ```
#### Example Response Example response:
``` ```
{ {
@@ -59,206 +61,66 @@ GET https://backend.insta.the1s.de/apiV2/items/your_admin_key
"can_borrow_role": 4, "can_borrow_role": 4,
"inSafe": 1, "inSafe": 1,
"entry_created_at": "2025-08-19T22:02:16.000Z" "entry_created_at": "2025-08-19T22:02:16.000Z"
},
{
"id": 2,
"item_name": "DJI 2er Mikro 1",
"can_borrow_role": 4,
"inSafe": 1,
"entry_created_at": "2025-08-19T22:02:16.000Z"
},
{
"id": 3,
"item_name": "DJI 2er Mikro 2",
"can_borrow_role": 4,
"inSafe": 1,
"entry_created_at": "2025-08-19T22:02:16.000Z"
},
{
"id": 4,
"item_name": "Rode Richt Mikrofon",
"can_borrow_role": 2,
"inSafe": 1,
"entry_created_at": "2025-08-19T22:02:16.000Z"
},
{
"id": 5,
"item_name": "Kamera Stativ",
"can_borrow_role": 1,
"inSafe": 1,
"entry_created_at": "2025-08-19T22:02:16.000Z"
},
{
"id": 6,
"item_name": "SONY Kamera - inkl. Akkus und Objektiv",
"can_borrow_role": 1,
"inSafe": 1,
"entry_created_at": "2025-08-19T22:02:16.000Z"
},
{
"id": 7,
"item_name": "MacBook inkl. Adapter",
"can_borrow_role": 2,
"inSafe": 1,
"entry_created_at": "2025-08-19T22:02:16.000Z"
},
{
"id": 8,
"item_name": "SD Karten",
"can_borrow_role": 3,
"inSafe": 1,
"entry_created_at": "2025-08-19T22:02:16.000Z"
},
{
"id": 9,
"item_name": "Kameragimbal",
"can_borrow_role": 1,
"inSafe": 1,
"entry_created_at": "2025-08-19T22:02:16.000Z"
},
{
"id": 10,
"item_name": "ATEM MINI PRO",
"can_borrow_role": 1,
"inSafe": 1,
"entry_created_at": "2025-08-19T22:02:16.000Z"
},
{
"id": 11,
"item_name": "Handygimbal",
"can_borrow_role": 4,
"inSafe": 1,
"entry_created_at": "2025-08-19T22:02:16.000Z"
},
{
"id": 12,
"item_name": "Kameralfter",
"can_borrow_role": 1,
"inSafe": 1,
"entry_created_at": "2025-08-19T22:02:16.000Z"
},
{
"id": 13,
"item_name": "Kleine Kamera 1 - inkl. Objektiv",
"can_borrow_role": 2,
"inSafe": 1,
"entry_created_at": "2025-08-19T22:02:16.000Z"
},
{
"id": 14,
"item_name": "Kleine Kamera 2 - inkl. Objektiv",
"can_borrow_role": 2,
"inSafe": 1,
"entry_created_at": "2025-08-19T22:02:16.000Z"
} }
] ]
} }
``` ```
Each item has the following properties: Fields:
- `id`: The unique identifier for the item. - `id`: Unique identifier
- `item_name`: The name of the item. - `item_name`: Item name
- `can_borrow_role`: The role ID that is allowed to borrow the item. - `can_borrow_role`: Role allowed to borrow
- `inSafe`: Indicates whether the item is currently in the locker (1) or not (0). This variable/state can change over time. - `inSafe`: 1 if in locker, 0 otherwise
- `entry_created_at`: Creation timestamp
_You also get an http 200 status code._ Status: 200 on success, 500 on failure.
--- ---
### 2. Change Item Safe State ### 2) Change item safe state
**POST** `/apiV2/controlInSafe/:key/:itemId/:state` POST `/apiV2/controlInSafe/:key/:itemId/:state`
Updates the `inSafe` state of an item (whether it is in the locker). Updates `inSafe` (locker) state of an item.
- `state` must be `"1"` (in safe) or `"0"` (not in safe). - `state` must be `"1"` (in safe) or `"0"` (not in safe)
#### Example Request Example request:
``` ```
POST https://backend.insta.the1s.de/apiV2/controlInSafe/your_admin_key/item_id/new_item_state POST https://backend.insta.the1s.de/apiV2/controlInSafe/12345/123/1
``` ```
#### Example Response Example response (shape depends on database service):
``` ```
{} { "data": { /* update result */ } }
``` ```
_An empty object means, that the operation was successful and no further information is returned._ Status:
_You also get an http 200 status code._ - 200 on success
- 400 if `state` is invalid
- 500 on failure
**You can get the item id on the admin panel, from your system administrator.**
--- ---
### 3. Set Return Date ### 3) Get loan by code
**POST** `/apiV2/setReturnDate/:key/:loan_code` GET `/apiV2/getLoanByCode/:key/:loan_code`
Sets the `returned_date` of a loan to the current server time. Retrieves the details of a specific loan.
- `loan_code`: The unique code of the loan. Example request:
#### Example Request
``` ```
POST https://backend.insta.the1s.de/apiV2/setReturnDate/your_admin_key/your_loan_code GET https://backend.insta.the1s.de/apiV2/getLoanByCode/12345/123456
``` ```
#### Example Response Example response:
```
{}
```
_An empty object means, that the operation was successful and no further information is returned._
_You also get an http 200 status code._
---
### 4. Set Take Date
**POST** `/apiV2/setTakeDate/:key/:loan_code`
Sets the `take_date` of a loan to the current server time.
- `loan_code`: The unique code of the loan.
#### Example Request
```
POST https://backend.insta.the1s.de/apiV2/setTakeDate/your_admin_key/your_loan_code
```
#### Example Response
```
{}
```
_An empty object means, that the operation was successful and no further information is returned._
_You also get an http 2xx status code._
---
### 5. Get whole loan by loan code
**POST** `/getLoanByCode/:key/:loan_code`
Retrieves the details of a specific loan by its unique code.
- `loan_code`: The unique code of the loan.
#### Example Request
```
GET https://backend.insta.the1s.de/getLoanByCode/your_admin_key/your_loan_code
```
#### Example Response
``` ```
{ {
@@ -271,36 +133,78 @@ GET https://backend.insta.the1s.de/getLoanByCode/your_admin_key/your_loan_code
"take_date": null, "take_date": null,
"returned_date": null, "returned_date": null,
"created_at": "2025-08-20T11:23:40.000Z", "created_at": "2025-08-20T11:23:40.000Z",
"loaned_items_id": [ "loaned_items_id": [8, 9],
8, "loaned_items_name": ["SD Karten", "Kameragimbal"]
9
],
"loaned_items_name": [
"SD Karten",
"Kameragimbal"
]
} }
} }
``` ```
_You also get an http 200 status code._ Status:
If the loan id does not exist, you will receive a 404 status code and an error message. - 200 on success
- 404 if not found
```
{
"message": "Loan not found"
}
```
--- ---
## Error Handling ### 4) Set return date (now) by loan code
- `403 Forbidden`: Invalid or missing API key. POST `/apiV2/setReturnDate/:key/:loan_code`
- `400 Bad Request`: Invalid parameters (e.g., wrong state value).
- `500 Internal Server Error`: Database or server error. 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:
```
POST https://backend.insta.the1s.de/apiV2/setReturnDate/12345/123456
```
Example response:
```
{ "data": { /* update result */ } }
```
Status: 200 on success, 500 on failure.
--- ---
If you have questions or want to collaborate, please reach out to me! ### 5) Set take date (now) by loan code
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:
```
POST https://backend.insta.the1s.de/apiV2/setTakeDate/12345/123456
```
Example response:
```
{ "data": { /* update result */ } }
```
Status: 200 on success, 500 on failure.
---
## Error handling
- 401 Unauthorized: Missing or invalid API key
- 400 Bad Request: Invalid parameters (e.g., wrong state value)
- 404 Not Found: Loan not found
- 500 Internal Server Error: Database or server error
---
If you have questions or want to collaborate, please reach out!

24
FrontendV2/.gitignore vendored Normal file
View 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
View 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
View 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...
},
},
])
```

View 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
View 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>

6045
FrontendV2/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

50
FrontendV2/package.json Normal file
View File

@@ -0,0 +1,50 @@
{
"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",
"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-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"
}
}

View 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
View 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
View 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;

View 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

View 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)."
]
}
]
}
]
}

View 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>
);
}

View File

@@ -0,0 +1,372 @@
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";
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();
// 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">
Willkommen zurück, {username}!
</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>
);
};

View 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;

View 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}
/>
)
},
)

View 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" }
}
}

View 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>
)
}

View 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>
)
}

View 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
View 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);
}

13
FrontendV2/src/main.tsx Normal file
View File

@@ -0,0 +1,13 @@
import { Provider } from "@/components/ui/provider";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App.tsx";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<Provider>
<App />
</Provider>
</StrictMode>
);

View 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>
);
};

View File

@@ -0,0 +1,245 @@
import React, { useEffect, useState } from "react";
import {
Spinner,
Text,
VStack,
Table,
Heading,
HStack,
Card,
SimpleGrid,
Button,
} from "@chakra-ui/react";
import { Lock, LockOpen } from "lucide-react";
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;
username: string;
start_date: string;
end_date: string;
returned_date: string | null;
take_date: string | null;
loaned_items_name: string[] | string;
};
type Device = {
id: number;
item_name: string;
can_borrow_role: string;
inSafe: number;
entry_created_at: string;
};
const Landingpage: React.FC = () => {
const [isLoading, setIsLoading] = useState(false);
const [loans, setLoans] = useState<Loan[]>([]);
const [devices, setDevices] = useState<Device[]>([]);
const [isError, setIsError] = useState(false);
const [errorStatus, setErrorStatus] = useState<"error" | "success">("error");
const [errorMessage, setErrorMessage] = useState("");
const [errorDsc, setErrorDsc] = useState("");
const setError = (
status: "error" | "success",
message: string,
description: string
) => {
setIsError(false);
setErrorStatus(status);
setErrorMessage(message);
setErrorDsc(description);
setIsError(true);
};
useEffect(() => {
const fetchData = async () => {
setIsLoading(true);
try {
const loanRes = await fetch(`${API_BASE}/apiV2/allLoans`);
const loanData = await loanRes.json();
if (Array.isArray(loanData)) {
setLoans(loanData);
} else {
setError(
"error",
"Fehler beim Laden",
"Unerwartetes Datenformat erhalten. (Ausleihen)"
);
}
const deviceRes = await fetch(`${API_BASE}/apiV2/allItems`);
const deviceData = await deviceRes.json();
if (Array.isArray(deviceData)) {
setDevices(deviceData);
} else {
setError(
"error",
"Fehler beim Laden",
"Unerwartetes Datenformat erhalten. (Geräte)"
);
}
} catch (e) {
setError(
"error",
"Fehler beim Laden",
"Die Ausleihen konnten nicht geladen werden."
);
} finally {
setIsLoading(false);
}
};
fetchData();
}, []);
return (
<>
<Heading as="h1" size="lg" mb={2}>
Matthias-Claudius-Schule Technik
</Heading>
<Heading as="h2" size="md" mb={4}>
Alle Ausleihen
</Heading>
{isError && (
<MyAlert
status={errorStatus}
description={errorDsc}
title={errorMessage}
/>
)}
{isLoading && (
<VStack colorPalette="teal">
<Spinner color="colorPalette.600" />
<Text color="colorPalette.600">Loading...</Text>
</VStack>
)}
{!isLoading && (
<Table.Root size="sm" striped>
<Table.Header>
<Table.Row>
<Table.ColumnHeader>
<strong>#</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Benutzername</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Startdatum</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Enddatum</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Ausgeliehene Artikel</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Rückgabedatum</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Ausleihdatum</strong>
</Table.ColumnHeader>
</Table.Row>
</Table.Header>
<Table.Body>
{loans.map((loan) => (
<Table.Row key={loan.id}>
<Table.Cell>{loan.id}</Table.Cell>
<Table.Cell>{loan.username}</Table.Cell>
<Table.Cell>{formatDateTime(loan.start_date)}</Table.Cell>
<Table.Cell>{formatDateTime(loan.end_date)}</Table.Cell>
<Table.Cell>
{Array.isArray(loan.loaned_items_name)
? loan.loaned_items_name.join(", ")
: loan.loaned_items_name}
</Table.Cell>
<Table.Cell>{formatDateTime(loan.returned_date)}</Table.Cell>
<Table.Cell>{formatDateTime(loan.take_date)}</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
)}
{!isLoading && loans.length === 0 && !isError && (
<Text color="gray.500" mt={2}>
Keine Ausleihen vorhanden.
</Text>
)}
<Heading as="h2" size="md" mb={4}>
Alle Geräte
</Heading>
{/* Responsive Grid mit gleich hohen Karten */}
<SimpleGrid minChildWidth="200px" gap={2} alignItems="stretch">
{devices.map((device) => (
<Card.Root
key={device.id}
size="sm"
bg={device.inSafe ? "green" : "red"}
h="full"
minH="100px"
>
<Card.Header>
{device.inSafe ? <LockOpen size={16} /> : <Lock size={16} />}
<Heading size="md">{device.item_name}</Heading>
</Card.Header>
<Card.Body color="fg.muted">
<Text>Ausleihrolle: {device.can_borrow_role}</Text>
</Card.Body>
</Card.Root>
))}
</SimpleGrid>
<HStack mt={3} gap={3} align="center" role="group" aria-label="Legende">
<Text fontWeight="medium" color="fg.muted">
Legende:
</Text>
<Button
size="sm"
variant="subtle"
colorPalette="green"
pointerEvents="none"
cursor="default"
borderRadius="full"
>
<HStack gap={2}>
<LockOpen size={16} />
<Text>Im Schließfach</Text>
</HStack>
</Button>
<Button
size="sm"
variant="subtle"
colorPalette="red"
pointerEvents="none"
cursor="default"
borderRadius="full"
>
<HStack gap={2}>
<Lock size={16} />
<Text>Nicht im Schließfach</Text>
</HStack>
</Button>
</HStack>
</>
);
};
export default Landingpage;

View 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>
);
};

View 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>
</>
);
};

View 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[]>([]);

View 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;
}

View 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.

View 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,
};
};

View 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 }} />
);
};

View File

@@ -0,0 +1,11 @@
module.exports = {
content: [
"./index.html",
"./src/**/*.{js,jsx,ts,tsx}",
// add other paths if needed
],
theme: {
extend: {},
},
plugins: [],
};

View 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
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View 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
View 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,
},
},
});

View File

@@ -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" })
);
}

View File

@@ -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"
}
]

View File

@@ -12,6 +12,7 @@
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@tailwindcss/vite": "^4.1.11", "@tailwindcss/vite": "^4.1.11",
"@tanstack/react-query": "^5.85.5", "@tanstack/react-query": "^5.85.5",
"jotai": "^2.15.0",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"lucide-react": "^0.539.0", "lucide-react": "^0.539.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
@@ -4420,6 +4421,35 @@
"jiti": "lib/jiti-cli.mjs" "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": { "node_modules/js-cookie": {
"version": "3.0.5", "version": "3.0.5",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
@@ -5645,13 +5675,13 @@
} }
}, },
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.14", "version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"fdir": "^6.4.4", "fdir": "^6.5.0",
"picomatch": "^4.0.2" "picomatch": "^4.0.3"
}, },
"engines": { "engines": {
"node": ">=12.0.0" "node": ">=12.0.0"
@@ -5849,9 +5879,9 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "7.1.3", "version": "7.1.11",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.3.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.11.tgz",
"integrity": "sha512-OOUi5zjkDxYrKhTV3V7iKsoS37VUM7v40+HuwEmcrsf11Cdx9y3DIr2Px6liIcZFwt3XSRpQvFpL3WVy7ApkGw==", "integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
@@ -5859,7 +5889,7 @@
"picomatch": "^4.0.3", "picomatch": "^4.0.3",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"rollup": "^4.43.0", "rollup": "^4.43.0",
"tinyglobby": "^0.2.14" "tinyglobby": "^0.2.15"
}, },
"bin": { "bin": {
"vite": "bin/vite.js" "vite": "bin/vite.js"

View File

@@ -14,6 +14,7 @@
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@tailwindcss/vite": "^4.1.11", "@tailwindcss/vite": "^4.1.11",
"@tanstack/react-query": "^5.85.5", "@tanstack/react-query": "^5.85.5",
"jotai": "^2.15.0",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"lucide-react": "^0.539.0", "lucide-react": "^0.539.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",

View File

@@ -4,9 +4,7 @@ import Layout from "./Layout/Layout";
function App() { function App() {
return ( return (
<> <>
<Layout> <Layout />
<p></p>
</Layout>
</> </>
); );
} }

View File

@@ -1,10 +1,12 @@
import React from "react"; import React from "react";
import { useState } from "react"; import { useState } from "react";
import { useEffect } from "react";
import { Box, Heading, Text, Flex, Button } from "@chakra-ui/react"; import { Box, Heading, Text, Flex, Button } from "@chakra-ui/react";
import Sidebar from "./Sidebar"; import Sidebar from "./Sidebar";
import UserTable from "../components/UserTable"; import UserTable from "../components/UserTable";
import ItemTable from "../components/ItemTable"; import ItemTable from "../components/ItemTable";
import LoanTable from "../components/LoanTable"; import LoanTable from "../components/LoanTable";
import APIKeyTable from "@/components/APIKeyTable";
import { MoveLeft } from "lucide-react"; import { MoveLeft } from "lucide-react";
type DashboardProps = { type DashboardProps = {
@@ -16,6 +18,24 @@ const Dashboard: React.FC<DashboardProps> = ({ onLogout }) => {
const [activeView, setActiveView] = useState(""); 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 ( return (
<Flex h="100vh"> <Flex h="100vh">
<Sidebar <Sidebar
@@ -23,6 +43,7 @@ const Dashboard: React.FC<DashboardProps> = ({ onLogout }) => {
viewGegenstaende={() => setActiveView("Gegenstände")} viewGegenstaende={() => setActiveView("Gegenstände")}
viewSchliessfaecher={() => setActiveView("Schließfächer")} viewSchliessfaecher={() => setActiveView("Schließfächer")}
viewUser={() => setActiveView("User")} viewUser={() => setActiveView("User")}
viewAPI={() => setActiveView("API")}
/> />
<Box flex="1" display="flex" flexDirection="column"> <Box flex="1" display="flex" flexDirection="column">
<Flex <Flex
@@ -66,6 +87,7 @@ const Dashboard: React.FC<DashboardProps> = ({ onLogout }) => {
{activeView === "User" && <UserTable />} {activeView === "User" && <UserTable />}
{activeView === "Ausleihen" && <LoanTable />} {activeView === "Ausleihen" && <LoanTable />}
{activeView === "Gegenstände" && <ItemTable />} {activeView === "Gegenstände" && <ItemTable />}
{activeView === "API" && <APIKeyTable />}
</Box> </Box>
</Box> </Box>
</Flex> </Flex>

View File

@@ -3,18 +3,28 @@ import { useEffect } from "react";
import Dashboard from "./Dashboard"; import Dashboard from "./Dashboard";
import Login from "./Login"; import Login from "./Login";
import Cookies from "js-cookie"; import Cookies from "js-cookie";
import Landingpage from "@/components/API/Landingpage";
type LayoutProps = { const API_BASE =
children: React.ReactNode; (import.meta as any).env?.VITE_BACKEND_URL ||
}; import.meta.env.VITE_BACKEND_URL ||
"http://localhost:8002";
const Layout: React.FC<LayoutProps> = ({ children }) => { const Layout: React.FC = () => {
const [isLoggedIn, setIsLoggedIn] = useState(false); const [isLoggedIn, setIsLoggedIn] = useState(false);
const [showAPI, setShowAPI] = useState(false);
useEffect(() => { useEffect(() => {
const path = window.location.pathname.replace(/\/+$/, ""); // remove trailing slash
if (path === "/api") {
setShowAPI(true);
console.log("signal");
return;
}
if (Cookies.get("token")) { if (Cookies.get("token")) {
const verifyToken = async () => { const verifyToken = async () => {
const response = await fetch("http://localhost:8002/api/verifyToken", { const response = await fetch(`${API_BASE}/api/verifyToken`, {
method: "GET", method: "GET",
headers: { headers: {
Authorization: `Bearer ${Cookies.get("token")}`, Authorization: `Bearer ${Cookies.get("token")}`,
@@ -34,11 +44,19 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
const handleLogout = () => { const handleLogout = () => {
Cookies.remove("token"); Cookies.remove("token");
window.location.pathname = "/";
setIsLoggedIn(false); setIsLoggedIn(false);
}; };
if (showAPI) {
return (
<main>
<Landingpage />
</main>
);
}
return ( return (
<>
<main> <main>
{isLoggedIn ? ( {isLoggedIn ? (
<Dashboard onLogout={() => handleLogout()} /> <Dashboard onLogout={() => handleLogout()} />
@@ -46,8 +64,6 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
<Login onSuccess={() => setIsLoggedIn(true)} /> <Login onSuccess={() => setIsLoggedIn(true)} />
)} )}
</main> </main>
{children}
</>
); );
}; };

View File

@@ -24,6 +24,7 @@ const Login: React.FC<{ onSuccess: () => void }> = ({ onSuccess }) => {
return ( return (
<div className="min-h-screen flex items-center justify-center p-4"> <div className="min-h-screen flex items-center justify-center p-4">
<form onSubmit={(e) => e.preventDefault()}>
<Card.Root maxW="sm"> <Card.Root maxW="sm">
<Card.Header> <Card.Header>
<Card.Title>Login</Card.Title> <Card.Title>Login</Card.Title>
@@ -54,11 +55,12 @@ const Login: React.FC<{ onSuccess: () => void }> = ({ onSuccess }) => {
{isError && ( {isError && (
<MyAlert status="error" title={errorMsg} description={errorDsc} /> <MyAlert status="error" title={errorMsg} description={errorDsc} />
)} )}
<Button onClick={() => handleLogin()} variant="solid"> <Button type="submit" onClick={() => handleLogin()} variant="solid">
Login Login
</Button> </Button>
</Card.Footer> </Card.Footer>
</Card.Root> </Card.Root>
</form>
</div> </div>
); );
}; };

View File

@@ -6,12 +6,14 @@ type SidebarProps = {
viewGegenstaende: () => void; viewGegenstaende: () => void;
viewSchliessfaecher: () => void; viewSchliessfaecher: () => void;
viewUser: () => void; viewUser: () => void;
viewAPI: () => void;
}; };
const Sidebar: React.FC<SidebarProps> = ({ const Sidebar: React.FC<SidebarProps> = ({
viewAusleihen, viewAusleihen,
viewGegenstaende, viewGegenstaende,
viewUser, viewUser,
viewAPI,
}) => { }) => {
return ( return (
<Box <Box
@@ -58,6 +60,15 @@ const Sidebar: React.FC<SidebarProps> = ({
> >
Gegenstände Gegenstände
</Link> </Link>
<Link
px={3}
py={2}
rounded="md"
_hover={{ bg: "gray.700", textDecoration: "none" }}
onClick={viewAPI}
>
API Keys
</Link>
</VStack> </VStack>
<Box mt="auto" pt={8} fontSize="xs" color="gray.500"> <Box mt="auto" pt={8} fontSize="xs" color="gray.500">

View File

@@ -0,0 +1,3 @@
import { atom } from "jotai";
export const testAtom = atom<number>(0);

View 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.

View File

@@ -0,0 +1,208 @@
import React from "react";
import {
Table,
Spinner,
Text,
VStack,
Button,
HStack,
IconButton,
Heading,
} from "@chakra-ui/react";
import { Tooltip } from "@/components/ui/tooltip";
import MyAlert from "./myChakra/MyAlert";
import { Trash2, RefreshCcwDot, CirclePlus } from "lucide-react";
import Cookies from "js-cookie";
import { useState, useEffect } from "react";
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;
user: string;
entry_created_at: string;
};
const APIKeyTable: React.FC = () => {
const [items, setItems] = useState<Items[]>([]);
const [errorStatus, setErrorStatus] = useState<"error" | "success">("error");
const [errorMessage, setErrorMessage] = useState("");
const [errorDsc, setErrorDsc] = useState("");
const [isError, setIsError] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [reload, setReload] = useState(false);
const [addAPIForm, setAddAPIForm] = useState(false);
const setError = (
status: "error" | "success",
message: string,
description: string
) => {
setIsError(false);
setErrorStatus(status);
setErrorMessage(message);
setErrorDsc(description);
setIsError(true);
};
useEffect(() => {
const fetchData = async () => {
setIsLoading(true);
try {
const response = await fetch(`${API_BASE}/api/apiKeys`, {
method: "GET",
headers: {
Authorization: `Bearer ${Cookies.get("token")}`,
},
});
const data = await response.json();
return data;
} catch (error) {
setError("error", "Failed to fetch items", "There is an error");
} finally {
setIsLoading(false);
}
};
fetchData().then((data) => {
if (Array.isArray(data)) {
setItems(data);
}
});
}, [reload]);
return (
<>
{/* Action toolbar */}
<HStack
mb={4}
gap={3}
justify="flex-start"
align="center"
flexWrap="wrap"
>
<Tooltip content="API Keys neu laden" openDelay={300}>
<IconButton
aria-label="Refresh API Keys"
size="sm"
variant="outline"
rounded="md"
shadow="sm"
_hover={{ shadow: "md", transform: "translateY(-2px)" }}
_active={{ transform: "translateY(0)" }}
onClick={() => setReload(!reload)}
>
<RefreshCcwDot size={18} />
</IconButton>
</Tooltip>
<Tooltip content="Neuen API Key hinzufügen" openDelay={300}>
<Button
size="sm"
colorPalette="teal"
variant="solid"
rounded="md"
fontWeight="semibold"
shadow="sm"
_hover={{ shadow: "md", bg: "colorPalette.600" }}
_active={{ bg: "colorPalette.700" }}
onClick={() => {
setAddAPIForm(true);
}}
>
<CirclePlus size={18} style={{ marginRight: 6 }} />
Neuen API Key hinzufügen
</Button>
</Tooltip>
</HStack>
{/* End action toolbar */}
<Heading marginBottom={4} size="md">
Gegenstände
</Heading>
{isError && (
<MyAlert
status={errorStatus}
description={errorDsc}
title={errorMessage}
/>
)}
{isLoading && (
<VStack colorPalette="teal">
<Spinner color="colorPalette.600" />
<Text color="colorPalette.600">Loading...</Text>
</VStack>
)}
{addAPIForm && (
<AddAPIKey
onClose={() => {
setAddAPIForm(false);
setReload(!reload);
}}
alert={setError}
/>
)}
<Table.Root size="sm" striped>
<Table.Header>
<Table.Row>
<Table.ColumnHeader>
<strong>#</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>API Key</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Benutzer</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Eintrag erstellt am</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Aktionen</strong>
</Table.ColumnHeader>
</Table.Row>
</Table.Header>
<Table.Body>
{items.map((apiKey) => (
<Table.Row key={apiKey.id}>
<Table.Cell>{apiKey.id}</Table.Cell>
<Table.Cell>{apiKey.apiKey}</Table.Cell>
<Table.Cell>{apiKey.user}</Table.Cell>
<Table.Cell>{formatDateTime(apiKey.entry_created_at)}</Table.Cell>
<Table.Cell>
<Button
onClick={() =>
deleteAPKey(apiKey.id).then((response) => {
if (response.success) {
setItems(items.filter((i) => i.id !== apiKey.id));
setError(
"success",
"Gegenstand gelöscht",
"Der Gegenstand wurde erfolgreich gelöscht."
);
}
})
}
colorPalette="red"
size="sm"
ml={2}
>
<Trash2 />
</Button>
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
</>
);
};
export default APIKeyTable;

View File

@@ -0,0 +1,81 @@
import React from "react";
import { Button, Card, Field, Input, Stack } from "@chakra-ui/react";
import { createAPIentry } from "@/utils/userActions";
type AddAPIKeyProps = {
onClose: () => void;
alert: (
status: "success" | "error",
message: string,
description: string
) => void;
};
const AddAPIKey: React.FC<AddAPIKeyProps> = ({ onClose, alert }) => {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<Card.Root maxW="sm">
<Card.Header>
<Card.Title>Neuen API Key erstellen</Card.Title>
<Card.Description>
Füllen Sie das folgende Formular aus, um einen API Key zu erstellen.
</Card.Description>
</Card.Header>
<Card.Body>
<Stack gap="4" w="full">
<Field.Root>
<Field.Label>API key</Field.Label>
<Input type="number" id="apiKey" />
</Field.Root>
<Field.Root>
<Field.Label>Benutzer</Field.Label>
<Input id="user" type="text" />
</Field.Root>
</Stack>
</Card.Body>
<Card.Footer justifyContent="flex-end">
<Button variant="outline" onClick={onClose}>
Abbrechen
</Button>
<Button
variant="solid"
onClick={async () => {
const apiKey =
(
document.getElementById("apiKey") as HTMLInputElement
)?.value.trim() || "";
const user =
(
document.getElementById("user") as HTMLInputElement
)?.value.trim() || "";
if (!apiKey || !user) return;
const res = await createAPIentry(apiKey, user);
if (res.success) {
alert(
"success",
"API Key erstellt",
"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();
}
}}
>
Erstellen
</Button>
</Card.Footer>
</Card.Root>
</div>
);
};
export default AddAPIKey;

View File

@@ -33,7 +33,7 @@ const AddItemForm: React.FC<AddItemFormProps> = ({ onClose, alert }) => {
<Input <Input
id="can_borrow_role" id="can_borrow_role"
type="number" type="number"
placeholder="Zahl (z.B. 2)" placeholder="Zahl (1 - 4)"
/> />
</Field.Root> </Field.Root>
</Stack> </Stack>
@@ -68,8 +68,10 @@ const AddItemForm: React.FC<AddItemFormProps> = ({ onClose, alert }) => {
alert( alert(
"error", "error",
"Fehler", "Fehler",
"Der Gegenstand konnte nicht erstellt werden." res.message ||
"Der Gegenstand konnte nicht erstellt werden. (frontend bug)"
); );
onClose();
} }
}} }}
> >

View File

@@ -55,7 +55,9 @@ const ChangePWform: React.FC<ChangePWformProps> = ({
</Field.Root> </Field.Root>
</Stack> </Stack>
</Card.Body> </Card.Body>
<Card.Footer justifyContent="flex-end" gap="2"> <Card.Footer gap="2">
<Stack w="full" gap="3">
<Stack direction="row" justify="flex-end" gap="2">
<Button variant="outline" onClick={onClose}> <Button variant="outline" onClick={onClose}>
Abbrechen Abbrechen
</Button> </Button>
@@ -64,7 +66,9 @@ const ChangePWform: React.FC<ChangePWformProps> = ({
onClick={async () => { onClick={async () => {
const newPassword = const newPassword =
( (
document.getElementById("new_password") as HTMLInputElement document.getElementById(
"new_password"
) as HTMLInputElement
)?.value.trim() || ""; )?.value.trim() || "";
const confirmNewPassword = const confirmNewPassword =
( (
@@ -98,6 +102,8 @@ const ChangePWform: React.FC<ChangePWformProps> = ({
> >
Ändern Ändern
</Button> </Button>
</Stack>
{showSubAlert && ( {showSubAlert && (
<Alert.Root status="error"> <Alert.Root status="error">
<Alert.Indicator /> <Alert.Indicator />
@@ -106,6 +112,7 @@ const ChangePWform: React.FC<ChangePWformProps> = ({
</Alert.Content> </Alert.Content>
</Alert.Root> </Alert.Root>
)} )}
</Stack>
</Card.Footer> </Card.Footer>
</Card.Root> </Card.Root>
</div> </div>

View File

@@ -9,7 +9,6 @@ import {
IconButton, IconButton,
Heading, Heading,
Icon, Icon,
Tag,
Input, Input,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { Tooltip } from "@/components/ui/tooltip"; import { Tooltip } from "@/components/ui/tooltip";
@@ -24,10 +23,19 @@ import {
} from "lucide-react"; } from "lucide-react";
import Cookies from "js-cookie"; import Cookies from "js-cookie";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { deleteItem, handleEditItems } from "@/utils/userActions"; import {
deleteItem,
handleEditItems,
changeSafeState,
} from "@/utils/userActions";
import AddItemForm from "./AddItemForm"; import AddItemForm from "./AddItemForm";
import { formatDateTime } from "@/utils/userFuncs"; 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 = { type Items = {
id: number; id: number;
item_name: string; item_name: string;
@@ -74,7 +82,7 @@ const ItemTable: React.FC = () => {
const fetchData = async () => { const fetchData = async () => {
setIsLoading(true); setIsLoading(true);
try { try {
const response = await fetch("http://localhost:8002/api/allItems", { const response = await fetch(`${API_BASE}/api/allItems`, {
method: "GET", method: "GET",
headers: { headers: {
Authorization: `Bearer ${Cookies.get("token")}`, Authorization: `Bearer ${Cookies.get("token")}`,
@@ -211,55 +219,39 @@ const ItemTable: React.FC = () => {
/> />
</Table.Cell> </Table.Cell>
<Table.Cell> <Table.Cell>
{item.inSafe ? ( <Button
<Tag.Root onClick={() =>
size="md" changeSafeState(item.id).then(() => setReload(!reload))
bg="green.500" }
color="white" size="xs"
px={4}
py={1.5}
rounded="full" rounded="full"
display="inline-flex" px={3}
alignItems="center" py={1}
gap={2} gap={2}
shadow="sm" variant="ghost"
_hover={{ shadow: "md" }} color={item.inSafe ? "green.600" : "red.600"}
borderWidth="1px"
borderColor={item.inSafe ? "green.300" : "red.300"}
_hover={{
bg: item.inSafe ? "green.50" : "red.50",
borderColor: item.inSafe ? "green.400" : "red.400",
transform: "translateY(-1px)",
shadow: "sm",
}}
_active={{ transform: "translateY(0)" }}
aria-label={
item.inSafe ? "Mark as not in safe" : "Mark as in safe"
}
> >
<Icon as={CheckCircle2} boxSize={4} /> <Icon
<Text as={item.inSafe ? CheckCircle2 : XCircle}
as="span" boxSize={3.5}
fontSize="xs" mr={2}
letterSpacing="wide" />
textTransform="uppercase" <Text as="span" fontSize="xs" fontWeight="semibold">
> {item.inSafe ? "Yes" : "No"}
Yes
</Text> </Text>
</Tag.Root> </Button>
) : (
<Tag.Root
size="md"
bg="red.500"
color="white"
px={4}
py={1.5}
rounded="full"
display="inline-flex"
alignItems="center"
gap={2}
shadow="sm"
_hover={{ shadow: "md" }}
>
<Icon as={XCircle} boxSize={4} />
<Text
as="span"
fontSize="xs"
letterSpacing="wide"
textTransform="uppercase"
>
No
</Text>
</Tag.Root>
)}
</Table.Cell> </Table.Cell>
<Table.Cell>{formatDateTime(item.entry_created_at)}</Table.Cell> <Table.Cell>{formatDateTime(item.entry_created_at)}</Table.Cell>
<Table.Cell> <Table.Cell>

View File

@@ -18,6 +18,11 @@ import { formatDateTime } from "@/utils/userFuncs";
import { Trash2, RefreshCcwDot } from "lucide-react"; import { Trash2, RefreshCcwDot } from "lucide-react";
import { deleteLoan } from "@/utils/userActions"; 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 LoanTable: React.FC = () => {
const [items, setItems] = useState<Loan[]>([]); const [items, setItems] = useState<Loan[]>([]);
const [errorStatus, setErrorStatus] = useState<"error" | "success">("error"); const [errorStatus, setErrorStatus] = useState<"error" | "success">("error");
@@ -55,7 +60,7 @@ const LoanTable: React.FC = () => {
const fetchData = async () => { const fetchData = async () => {
setIsLoading(true); setIsLoading(true);
try { try {
const response = await fetch("http://localhost:8002/api/allLoans", { const response = await fetch(`${API_BASE}/api/allLoans`, {
method: "GET", method: "GET",
headers: { headers: {
Authorization: `Bearer ${Cookies.get("token")}`, Authorization: `Bearer ${Cookies.get("token")}`,

View File

@@ -1,7 +1,12 @@
import Cookies from "js-cookie"; 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 () => { export const fetchUserData = async () => {
const response = await fetch("http://localhost:8002/api/allUsers", { const response = await fetch(`${API_BASE}/api/allUsers`, {
headers: { headers: {
Authorization: `Bearer ${Cookies.get("token")}`, Authorization: `Bearer ${Cookies.get("token")}`,
}, },

View File

@@ -1,5 +1,10 @@
import Cookies from "js-cookie"; 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 LoginSuccess = { success: true };
export type LoginFailure = { export type LoginFailure = {
success: false; success: false;
@@ -13,7 +18,7 @@ export const loginFunc = async (
password: string password: string
): Promise<LoginResult> => { ): Promise<LoginResult> => {
try { try {
const response = await fetch("http://localhost:8002/api/loginAdmin", { const response = await fetch(`${API_BASE}/api/loginAdmin`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }), body: JSON.stringify({ username, password }),

View File

@@ -1,9 +1,14 @@
import Cookies from "js-cookie"; 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) => { export const handleDelete = async (userId: number) => {
try { try {
const response = await fetch( const response = await fetch(
`http://localhost:8002/api/deleteUser/${userId}`, `${API_BASE}/api/deleteUser/${userId}`,
{ {
method: "DELETE", method: "DELETE",
headers: { headers: {
@@ -28,7 +33,7 @@ export const handleEdit = async (
) => { ) => {
try { try {
const response = await fetch( const response = await fetch(
`http://localhost:8002/api/editUser/${userId}`, `${API_BASE}/api/editUser/${userId}`,
{ {
method: "POST", method: "POST",
headers: { headers: {
@@ -54,7 +59,7 @@ export const createUser = async (
password: string password: string
) => { ) => {
try { try {
const response = await fetch(`http://localhost:8002/api/createUser`, { const response = await fetch(`${API_BASE}/api/createUser`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -74,7 +79,7 @@ export const createUser = async (
export const changePW = async (newPassword: string, username: string) => { export const changePW = async (newPassword: string, username: string) => {
try { try {
const response = await fetch(`http://localhost:8002/api/changePWadmin`, { const response = await fetch(`${API_BASE}/api/changePWadmin`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -95,7 +100,7 @@ export const changePW = async (newPassword: string, username: string) => {
export const deleteLoan = async (loanId: number) => { export const deleteLoan = async (loanId: number) => {
try { try {
const response = await fetch( const response = await fetch(
`http://localhost:8002/api/deleteLoan/${loanId}`, `${API_BASE}/api/deleteLoan/${loanId}`,
{ {
method: "DELETE", method: "DELETE",
headers: { headers: {
@@ -116,7 +121,7 @@ export const deleteLoan = async (loanId: number) => {
export const deleteItem = async (itemId: number) => { export const deleteItem = async (itemId: number) => {
try { try {
const response = await fetch( const response = await fetch(
`http://localhost:8002/api/deleteItem/${itemId}`, `${API_BASE}/api/deleteItem/${itemId}`,
{ {
method: "DELETE", method: "DELETE",
headers: { headers: {
@@ -139,7 +144,7 @@ export const createItem = async (
can_borrow_role: number can_borrow_role: number
) => { ) => {
try { try {
const response = await fetch(`http://localhost:8002/api/createItem`, { const response = await fetch(`${API_BASE}/api/createItem`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -148,7 +153,11 @@ export const createItem = async (
body: JSON.stringify({ item_name, can_borrow_role }), body: JSON.stringify({ item_name, can_borrow_role }),
}); });
if (!response.ok) { 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 }; return { success: true };
} catch (error) { } catch (error) {
@@ -163,7 +172,7 @@ export const handleEditItems = async (
can_borrow_role: string can_borrow_role: string
) => { ) => {
try { try {
const response = await fetch("http://localhost:8002/api/updateItemByID", { const response = await fetch(`${API_BASE}/api/updateItemByID`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -180,3 +189,69 @@ export const handleEditItems = async (
return { success: false }; return { success: false };
} }
}; };
export const changeSafeState = async (itemId: number) => {
try {
const response = await fetch(
`${API_BASE}/api/changeSafeState/${itemId}`,
{
method: "PUT",
headers: {
Authorization: `Bearer ${Cookies.get("token")}`,
},
}
);
if (!response.ok) {
throw new Error("Failed to change safe state");
}
return { success: true };
} catch (error) {
console.error("Error changing safe state:", error);
return { success: false };
}
};
export const createAPIentry = async (apiKey: string, user: string) => {
try {
const response = await fetch(`${API_BASE}/api/createAPIentry`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${Cookies.get("token")}`,
},
body: JSON.stringify({ apiKey, user }),
});
if (!response.ok) {
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) {
console.error("Error creating API entry:", error);
return { success: false };
}
};
export const deleteAPKey = async (apiKeyId: number) => {
try {
const response = await fetch(
`${API_BASE}/api/deleteAPKey/${apiKeyId}`,
{
method: "DELETE",
headers: {
Authorization: `Bearer ${Cookies.get("token")}`,
},
}
);
if (!response.ok) {
throw new Error("Failed to delete API key");
}
return { success: true };
} catch (error) {
console.error("Error deleting API key:", error);
return { success: false };
}
};

View File

@@ -29,7 +29,8 @@
"@/*": ["./src/*"] "@/*": ["./src/*"]
}, },
"forceConsistentCasingInFileNames": true "forceConsistentCasingInFileNames": true,
"ignoreDeprecations": "6.0"
}, },
"include": ["src"] "include": ["src"]
} }

View File

@@ -14,7 +14,8 @@
"ejs": "^3.1.10", "ejs": "^3.1.10",
"express": "^5.1.0", "express": "^5.1.0",
"jose": "^6.0.12", "jose": "^6.0.12",
"mysql2": "^3.14.3" "mysql2": "^3.14.3",
"nodemailer": "^7.0.6"
} }
}, },
"node_modules/accepts": { "node_modules/accepts": {
@@ -713,6 +714,15 @@
"node": ">= 0.6" "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": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",

View File

@@ -16,6 +16,7 @@
"ejs": "^3.1.10", "ejs": "^3.1.10",
"express": "^5.1.0", "express": "^5.1.0",
"jose": "^6.0.12", "jose": "^6.0.12",
"mysql2": "^3.14.3" "mysql2": "^3.14.3",
"nodemailer": "^7.0.6"
} }
} }

View File

@@ -22,9 +22,195 @@ import {
changeUserPasswordFRONTEND, changeUserPasswordFRONTEND,
changeInSafeStateV2, changeInSafeStateV2,
updateItemByID, updateItemByID,
getAllApiKeys,
createAPIentry,
deleteAPKey,
getLoanInfoWithID,
SETdeleteLoanFromDatabase,
} from "../services/database.js"; } from "../services/database.js";
import { authenticate, generateToken } from "../services/tokenService.js"; import { authenticate, generateToken } from "../services/tokenService.js";
const router = express.Router(); 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) => { router.post("/login", async (req, res) => {
const result = await loginFunc(req.body.username, req.body.password); const result = await loginFunc(req.body.username, req.body.password);
@@ -40,7 +226,6 @@ router.post("/login", async (req, res) => {
}); });
router.get("/items", authenticate, async (req, res) => { router.get("/items", authenticate, async (req, res) => {
console.log(req);
const result = await getItemsFromDatabase(req.user.role); const result = await getItemsFromDatabase(req.user.role);
if (result.success) { if (result.success) {
res.status(200).json(result.data); res.status(200).json(result.data);
@@ -77,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) => { router.post("/borrowableItems", authenticate, async (req, res) => {
const { startDate, endDate } = req.body || {}; const { startDate, endDate } = req.body || {};
if (!startDate || !endDate) { if (!startDate || !endDate) {
@@ -155,6 +350,15 @@ router.post("/createLoan", authenticate, async (req, res) => {
); );
if (result.success) { 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({ return res.status(201).json({
message: "Loan created successfully", message: "Loan created successfully",
loanId: result.data.id, loanId: result.data.id,
@@ -239,7 +443,7 @@ router.delete("/deleteUser/:id", authenticate, async (req, res) => {
}); });
router.get("/verifyToken", 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) => { router.post("/editUser/:id", authenticate, async (req, res) => {
@@ -319,9 +523,9 @@ router.post("/updateItemByID", authenticate, async (req, res) => {
return res.status(500).json({ message: "Failed to update item" }); return res.status(500).json({ message: "Failed to update item" });
}); });
router.post("/setSafeState", authenticate, async (req, res) => { router.put("/changeSafeState/:itemId", authenticate, async (req, res) => {
const { itemId, state } = req.body || {}; const itemId = req.params.itemId;
const result = await changeInSafeStateV2(itemId, state); const result = await changeInSafeStateV2(itemId);
if (result.success) { if (result.success) {
return res return res
.status(200) .status(200)
@@ -330,4 +534,66 @@ router.post("/setSafeState", authenticate, async (req, res) => {
return res.status(500).json({ message: "Failed to update item safe state" }); return res.status(500).json({ message: "Failed to update item safe state" });
}); });
router.get("/apiKeys", authenticate, async (req, res) => {
const result = await getAllApiKeys();
if (result.success) {
return res.status(200).json(result.data);
}
return res.status(500).json({ message: "Failed to fetch API keys" });
});
router.delete("/deleteAPKey/:id", authenticate, async (req, res) => {
const apiKeyId = req.params.id;
const result = await deleteAPKey(apiKeyId);
if (result.success) {
return res.status(200).json({ message: "API key deleted successfully" });
}
return res.status(500).json({ message: "Failed to delete API key" });
});
router.post("/createAPIentry", authenticate, async (req, res) => {
const apiKey = req.body.apiKey;
const user = req.body.user;
if (!apiKey || !user) {
return res.status(400).json({ message: "API key and user are required" });
}
// Ensure apiKey is a number
const apiKeyNum = Number(apiKey);
if (!Number.isFinite(apiKeyNum)) {
return res.status(400).json({ message: "API key must be a number" });
}
const result = await createAPIentry(apiKeyNum, user);
if (result.success) {
return res.status(201).json({ message: "API key created successfully" });
}
if (result.code === "DUPLICATE") {
return res.status(409).json({ message: "API key already exists" });
}
return res.status(500).json({ message: "Failed to create API key" });
});
router.get("/apiKeys/validate/:key", async (req, res) => {
try {
const rawKey = req.params.key;
const result = await getAllApiKeys();
if (!result.success || !Array.isArray(result.data)) {
return res.status(500).json({ valid: false });
}
const isValid = result.data.some((entry) => {
const val = String(
entry?.key ?? entry?.apiKey ?? entry?.api_key ?? entry
);
return val === String(rawKey);
});
return res.status(200).json({ valid: isValid });
} catch (err) {
console.error("validate api key error:", err);
return res.status(500).json({ valid: false });
}
});
export default router; export default router;

View File

@@ -3,33 +3,68 @@ import dotenv from "dotenv";
import { import {
getItemsFromDatabaseV2, getItemsFromDatabaseV2,
changeInSafeStateV2, changeInSafeStateV2,
setReturnDateV2,
setTakeDateV2, setTakeDateV2,
setReturnDateV2,
getLoanByCodeV2, getLoanByCodeV2,
getAllLoansV2,
getAPIkey,
} from "../services/database.js"; } from "../services/database.js";
dotenv.config(); dotenv.config();
const router = express.Router(); const router = express.Router();
async function validateAPIKey(apiKey) {
try {
if (!apiKey) return false;
const result = await getAPIkey();
if (!result?.success || !Array.isArray(result.data)) return false;
return result.data.some((row) => String(row.apiKey) === String(apiKey));
} catch (err) {
console.error("validateAPIKey error:", err);
return false;
}
}
// Add a guard that returns Access Denied instead of hanging
const apiKeyGuard = async (req, res, next) => {
try {
const key = req.params.key;
if (!key) {
return res
.status(401)
.json({ message: "Access denied: missing API key" });
}
const ok = await validateAPIKey(key);
if (!ok) {
return res
.status(401)
.json({ message: "Access denied: invalid API key" });
}
next();
} catch (e) {
console.error("apiKeyGuard error:", e);
res.status(500).json({ message: "Internal server error" });
}
};
// Route for API to get ALL items from the database // Route for API to get ALL items from the database
router.get("/items/:key", async (req, res) => { router.get("/items/:key", apiKeyGuard, async (req, res) => {
if (req.params.key === process.env.ADMIN_ID) {
const result = await getItemsFromDatabaseV2(); const result = await getItemsFromDatabaseV2();
if (result.success) { if (result.success) {
res.status(200).json({ data: result.data }); res.status(200).json({ data: result.data });
} else { } else {
res.status(500).json({ message: "Failed to fetch items" }); res.status(500).json({ message: "Failed to fetch items" });
} }
} else {
res.status(403).json({ message: "Access denied" });
}
}); });
// Route for API to control the position of an item // Route for API to control the position of an item
router.post("/controlInSafe/:key/:itemId/:state", async (req, res) => { router.post(
if (req.params.key === process.env.ADMIN_ID) { "/controlInSafe/:key/:itemId/:state",
apiKeyGuard,
async (req, res) => {
const itemId = req.params.itemId; const itemId = req.params.itemId;
const state = req.params.state; const state = req.params.state;
if (state === "1" || state === "0") { if (state === "1" || state === "0") {
const result = await changeInSafeStateV2(itemId, state); const result = await changeInSafeStateV2(itemId, state);
if (result.success) { if (result.success) {
@@ -40,53 +75,58 @@ router.post("/controlInSafe/:key/:itemId/:state", async (req, res) => {
} else { } else {
res.status(400).json({ message: "Invalid state value" }); res.status(400).json({ message: "Invalid state value" });
} }
} else {
res.status(403).json({ message: "Access denied" });
} }
}); );
router.get("/getLoanByCode/:key/:loan_code", async (req, res) => { // Route for API to get a loan by its code
if (req.params.key === process.env.ADMIN_ID) { router.get("/getLoanByCode/:key/:loan_code", apiKeyGuard, async (req, res) => {
const loan_code = req.params.loan_code; const loan_code = req.params.loan_code;
const result = await getLoanByCodeV2(loan_code); const result = await getLoanByCodeV2(loan_code);
if (result.success) { if (result.success) {
res.status(200).json({ data: result.data }); res.status(200).json({ data: result.data });
} else { } else {
res.status(404).json({ message: "Loan not found" }); res.status(404).json({ message: "Loan not found" });
} }
}
}); });
// Route for API to set the return date // Route for API to set the return date by the loan code
router.post("/setReturnDate/:key/:loan_code", async (req, res) => { router.post("/setReturnDate/:key/:loan_code", apiKeyGuard, async (req, res) => {
if (req.params.key === process.env.ADMIN_ID) {
const loanCode = req.params.loan_code; const loanCode = req.params.loan_code;
const result = await setReturnDateV2(loanCode); const result = await setReturnDateV2(loanCode);
if (result.success) { if (result.success) {
res.status(200).json({ data: result.data }); res.status(200).json({ data: result.data });
} else { } else {
res.status(500).json({ message: "Failed to set return date" }); res.status(500).json({ message: "Failed to set return date" });
} }
} else {
res.status(403).json({ message: "Access denied" });
}
}); });
// Route for API to set the take away date // Route for API to set the take away date by the loan code
router.post("/setTakeDate/:key/:loan_code", async (req, res) => { router.post("/setTakeDate/:key/:loan_code", apiKeyGuard, async (req, res) => {
if (req.params.key === process.env.ADMIN_ID) {
const loanCode = req.params.loan_code; const loanCode = req.params.loan_code;
const result = await setTakeDateV2(loanCode); const result = await setTakeDateV2(loanCode);
if (result.success) { if (result.success) {
res.status(200).json({ data: result.data }); res.status(200).json({ data: result.data });
} else { } else {
res.status(500).json({ message: "Failed to set take date" }); res.status(500).json({ message: "Failed to set take date" });
} }
});
// Route for API to get ALL loans from the database without sensitive info (only for landingpage)
router.get("/allLoans", async (req, res) => {
const result = await getAllLoansV2();
if (result.success) {
return res.status(200).json(result.data);
}
return res.status(500).json({ message: "Failed to fetch loans" });
});
// Route for API to get ALL items from the database (only for landingpage)
router.get("/allItems", async (req, res) => {
const result = await getItemsFromDatabaseV2();
if (result.success) {
res.status(200).json(result.data);
} else { } else {
res.status(403).json({ message: "Access denied" }); res.status(500).json({ message: "Failed to fetch items" });
} }
}); });

View File

@@ -34,6 +34,7 @@ CREATE TABLE `loans` (
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP, `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`loaned_items_id` json NOT NULL DEFAULT ('[]'), `loaned_items_id` json NOT NULL DEFAULT ('[]'),
`loaned_items_name` json NOT NULL DEFAULT ('[]'), `loaned_items_name` json NOT NULL DEFAULT ('[]'),
`deleted` bool NOT NULL DEFAULT false,
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
UNIQUE KEY `loan_code` (`loan_code`) UNIQUE KEY `loan_code` (`loan_code`)
); );
@@ -58,6 +59,14 @@ CREATE TABLE `lockers` (
UNIQUE KEY `locker_number` (`locker_number`) UNIQUE KEY `locker_number` (`locker_number`)
); );
CREATE TABLE `apiKeys` (
`id` int NOT NULL AUTO_INCREMENT,
`apiKey` int NOT NULL UNIQUE,
`user` VARCHAR(255) NOT NULL,
`entry_created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
);
INSERT INTO `items` (`item_name`, `can_borrow_role`, `inSafe`) VALUES INSERT INTO `items` (`item_name`, `can_borrow_role`, `inSafe`) VALUES
('DJI 1er Mikro', 4, 1), ('DJI 1er Mikro', 4, 1),
('DJI 2er Mikro 1', 4, 1), ('DJI 2er Mikro 1', 4, 1),

View File

@@ -39,10 +39,10 @@ export const getLoanByCodeV2 = async (loan_code) => {
return { success: false }; return { success: false };
}; };
export const changeInSafeStateV2 = async (itemId, state) => { export const changeInSafeStateV2 = async (itemId) => {
const [result] = await pool.query( const [result] = await pool.query(
"UPDATE items SET inSafe = ? WHERE id = ?", "UPDATE items SET inSafe = NOT inSafe WHERE id = ?",
[state, itemId] [itemId]
); );
if (result.affectedRows > 0) { if (result.affectedRows > 0) {
return { success: true }; return { success: true };
@@ -51,22 +51,56 @@ export const changeInSafeStateV2 = async (itemId, state) => {
}; };
export const setReturnDateV2 = async (loanCode) => { 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( const [result] = await pool.query(
"UPDATE loans SET returned_date = NOW() WHERE loan_code = ?", "UPDATE loans SET returned_date = NOW() WHERE loan_code = ?",
[loanCode] [loanCode]
); );
if (result.affectedRows > 0) {
if (result.affectedRows > 0 && setItemStates.affectedRows > 0) {
return { success: true }; return { success: true };
} }
return { success: false }; return { success: false };
}; };
export const setTakeDateV2 = async (loanCode) => { 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( const [result] = await pool.query(
"UPDATE loans SET take_date = NOW() WHERE loan_code = ?", "UPDATE loans SET take_date = NOW() WHERE loan_code = ?",
[loanCode] [loanCode]
); );
if (result.affectedRows > 0) {
if (result.affectedRows > 0 && setItemStates.affectedRows > 0) {
return { success: true }; return { success: true };
} }
return { success: false }; return { success: false };
@@ -92,9 +126,10 @@ export const getLoansFromDatabase = async () => {
}; };
export const getUserLoansFromDatabase = async (username) => { export const getUserLoansFromDatabase = async (username) => {
const [result] = await pool.query("SELECT * FROM loans WHERE username = ?;", [ const [result] = await pool.query(
username, "SELECT * FROM loans WHERE username = ? AND deleted = 0;",
]); [username]
);
if (result.length > 0) { if (result.length > 0) {
return { success: true, data: result }; return { success: true, data: result };
} else if (result.length == 0) { } 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 ( export const getBorrowableItemsFromDatabase = async (
startDate, startDate,
endDate, endDate,
@@ -148,6 +195,16 @@ export const getBorrowableItemsFromDatabase = async (
return { success: false }; 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 ( export const createLoanInDatabase = async (
username, username,
startDate, startDate,
@@ -447,3 +504,46 @@ export const updateItemByID = async (itemId, item_name, can_borrow_role) => {
if (result.affectedRows > 0) return { success: true }; if (result.affectedRows > 0) return { success: true };
return { success: false }; return { success: false };
}; };
export const getAllLoansV2 = async () => {
const [rows] = await pool.query(
"SELECT id, username, start_date, end_date, loaned_items_name, returned_date, take_date FROM loans"
);
if (rows.length > 0) {
return { success: true, data: rows };
}
return { success: false };
};
export const getAllApiKeys = async () => {
const [rows] = await pool.query("SELECT * FROM apiKeys");
if (rows.length > 0) {
return { success: true, data: rows };
}
return { success: false };
};
export const createAPIentry = async (apiKey, user) => {
const [result] = await pool.query(
"INSERT INTO apiKeys (apiKey, user) VALUES (?, ?)",
[apiKey, user]
);
if (result.affectedRows > 0) return { success: true };
return { success: false };
};
export const deleteAPKey = async (apiKeyId) => {
const [result] = await pool.query("DELETE FROM apiKeys WHERE id = ?", [
apiKeyId,
]);
if (result.affectedRows > 0) return { success: true };
return { success: false };
};
export const getAPIkey = async () => {
const [rows] = await pool.query("SELECT apiKey FROM apiKeys");
if (rows.length > 0) {
return { success: true, data: rows };
}
return { success: false };
};

View File

@@ -9,7 +9,6 @@ export async function generateToken(payload) {
.setIssuedAt() .setIssuedAt()
.setExpirationTime("2h") // Token valid for 2 hours .setExpirationTime("2h") // Token valid for 2 hours
.sign(secret); .sign(secret);
console.log("Generated token: ", newToken);
return newToken; return newToken;
} }

View File

@@ -1,13 +1,13 @@
services: services:
# borrow_system-frontend: # borrow_system-frontend:
# container_name: borrow_system-frontend # container_name: borrow_system-frontend
# build: ./frontend # build: ./FrontendV2
# ports: # ports:
# - "8001:8001" # - "8001:8001"
# environment: # environment:
# - CHOKIDAR_USEPOLLING=true # - CHOKIDAR_USEPOLLING=true
# volumes: # volumes:
# - ./frontend:/app # - ./FrontendV2:/app
# - /app/node_modules # - /app/node_modules
# restart: unless-stopped # restart: unless-stopped

View File

@@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"@tailwindcss/vite": "^4.1.11", "@tailwindcss/vite": "^4.1.11",
"@tanstack/react-query": "^5.85.5", "@tanstack/react-query": "^5.85.5",
"jotai": "^2.15.0",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"lucide-react": "^0.539.0", "lucide-react": "^0.539.0",
"primeicons": "^7.0.0", "primeicons": "^7.0.0",
@@ -3164,6 +3165,35 @@
"jiti": "lib/jiti-cli.mjs" "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": { "node_modules/js-cookie": {
"version": "3.0.5", "version": "3.0.5",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
@@ -4292,13 +4322,13 @@
} }
}, },
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.14", "version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"fdir": "^6.4.4", "fdir": "^6.5.0",
"picomatch": "^4.0.2" "picomatch": "^4.0.3"
}, },
"engines": { "engines": {
"node": ">=12.0.0" "node": ">=12.0.0"
@@ -4469,17 +4499,17 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "7.1.2", "version": "7.1.12",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.2.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.12.tgz",
"integrity": "sha512-J0SQBPlQiEXAF7tajiH+rUooJPo0l8KQgyg4/aMunNtrOa7bwuZJsJbDWzeljqQpgftxuq5yNJxQ91O9ts29UQ==", "integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.4.6", "fdir": "^6.5.0",
"picomatch": "^4.0.3", "picomatch": "^4.0.3",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"rollup": "^4.43.0", "rollup": "^4.43.0",
"tinyglobby": "^0.2.14" "tinyglobby": "^0.2.15"
}, },
"bin": { "bin": {
"vite": "bin/vite.js" "vite": "bin/vite.js"

View File

@@ -12,6 +12,7 @@
"dependencies": { "dependencies": {
"@tailwindcss/vite": "^4.1.11", "@tailwindcss/vite": "^4.1.11",
"@tanstack/react-query": "^5.85.5", "@tanstack/react-query": "^5.85.5",
"jotai": "^2.15.0",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"lucide-react": "^0.539.0", "lucide-react": "^0.539.0",
"primeicons": "^7.0.0", "primeicons": "^7.0.0",

View File

@@ -1,9 +1,7 @@
import "./App.css"; import "./App.css";
import Layout from "./layout/Layout"; import Layout from "./layout/Layout";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Form1 from "./components/Form1"; import { AddLoan } from "./components/AddLoan";
import Form2 from "./components/Form2";
import Form4 from "./components/Form4";
import LoginForm from "./components/LoginForm"; import LoginForm from "./components/LoginForm";
import Cookies from "js-cookie"; import Cookies from "js-cookie";
import { import {
@@ -46,13 +44,7 @@ function App() {
return isLoggedIn ? ( return isLoggedIn ? (
<Layout onLogout={handleLogout}> <Layout onLogout={handleLogout}>
<div className="space-y-6"> <AddLoan />
<Form1 />
<div className="h-px bg-slate-200" />
<Form2 />
<div className="h-px bg-slate-200" />
<Form4 />
</div>
</Layout> </Layout>
) : ( ) : (
<LoginForm onLogin={() => setIsLoggedIn(true)} /> <LoginForm onLogin={() => setIsLoggedIn(true)} />

View 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);

View 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.

View 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>
);
};

View File

@@ -19,6 +19,11 @@ type Loan = {
loaned_items_name: string[]; 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) => { const formatDate = (iso: string | null) => {
if (!iso) return "-"; if (!iso) return "-";
const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/); 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[]> { async function fetchUserLoans(): Promise<Loan[]> {
const res = await fetch("http://localhost:8002/api/userLoans", { const res = await fetch(`${API_BASE}/api/userLoans`, {
method: "GET", method: "GET",
headers: { Authorization: `Bearer ${Cookies.get("token") || ""}` }, headers: { Authorization: `Bearer ${Cookies.get("token") || ""}` },
}); });

View File

@@ -1,11 +1,18 @@
import Cookies from "js-cookie"; import Cookies from "js-cookie";
import { myToast } from "./toastify"; 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 // Event name used to notify the app when the list of items has been updated
export const ALL_ITEMS_UPDATED_EVENT = "allItemsUpdated"; export const ALL_ITEMS_UPDATED_EVENT = "allItemsUpdated";
export const BORROWABLE_ITEMS_UPDATED_EVENT = "borrowableItemsUpdated"; export const BORROWABLE_ITEMS_UPDATED_EVENT = "borrowableItemsUpdated";
export const AUTH_LOGOUT_EVENT = "authLogout"; 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; let sendError = false;
function logout() { function logout() {
@@ -25,7 +32,7 @@ export const fetchAllData = async (token: string | undefined) => {
if (!token) return; if (!token) return;
// First we fetch all items that are potentially available for borrowing // First we fetch all items that are potentially available for borrowing
try { try {
const response = await fetch("http://localhost:8002/api/items", { const response = await fetch(`${API_BASE}/api/items`, {
method: "GET", method: "GET",
headers: { headers: {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
@@ -57,7 +64,7 @@ export const fetchAllData = async (token: string | undefined) => {
// get all loans // get all loans
try { try {
const response = await fetch("http://localhost:8002/api/loans", { const response = await fetch(`${API_BASE}/api/loans`, {
method: "GET", method: "GET",
headers: { headers: {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
@@ -89,7 +96,7 @@ export const fetchAllData = async (token: string | undefined) => {
// get user loans // get user loans
try { try {
const response = await fetch("http://localhost:8002/api/userLoans", { const response = await fetch(`${API_BASE}/api/userLoans`, {
method: "GET", method: "GET",
headers: { headers: {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
@@ -122,7 +129,7 @@ export const fetchAllData = async (token: string | undefined) => {
export const loginUser = async (username: string, password: string) => { export const loginUser = async (username: string, password: string) => {
try { try {
const response = await fetch("http://localhost:8002/api/login", { const response = await fetch(`${API_BASE}/api/login`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -158,7 +165,7 @@ export const getBorrowableItems = async () => {
} }
try { try {
const response = await fetch("http://localhost:8002/api/borrowableItems", { const response = await fetch(`${API_BASE}/api/borrowableItems`, {
method: "POST", method: "POST",
headers: { headers: {
Authorization: `Bearer ${Cookies.get("token") || ""}`, Authorization: `Bearer ${Cookies.get("token") || ""}`,
@@ -185,6 +192,7 @@ export const getBorrowableItems = async () => {
const data = await response.json(); const data = await response.json();
localStorage.setItem("borrowableItems", JSON.stringify(data)); localStorage.setItem("borrowableItems", JSON.stringify(data));
window.dispatchEvent(new Event(BORROWABLE_ITEMS_UPDATED_EVENT)); // notify same-tab listeners window.dispatchEvent(new Event(BORROWABLE_ITEMS_UPDATED_EVENT)); // notify same-tab listeners
console.log("Borrowable items fetched successfully"); console.log("Borrowable items fetched successfully");
} catch (error) { } catch (error) {

View File

@@ -2,10 +2,15 @@ import { myToast } from "./toastify";
import Cookies from "js-cookie"; import Cookies from "js-cookie";
import { queryClient } from "./queryClient"; 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> => { export const handleDeleteLoan = async (loanID: number): Promise<boolean> => {
try { try {
const response = await fetch( const response = await fetch(
`http://localhost:8002/api/deleteLoan/${loanID}`, `${API_BASE}/api/deleteLoan/${loanID}`,
{ {
method: "DELETE", method: "DELETE",
headers: { headers: {
@@ -75,7 +80,7 @@ export const rmFromRemove = (itemID: number) => {
export const createLoan = async (startDate: string, endDate: string) => { export const createLoan = async (startDate: string, endDate: string) => {
const items = removeArr; const items = removeArr;
const response = await fetch("http://localhost:8002/api/createLoan", { const response = await fetch(`${API_BASE}/api/createLoan`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -103,7 +108,7 @@ export const createLoan = async (startDate: string, endDate: string) => {
export const onReturn = async (loanID: number) => { export const onReturn = async (loanID: number) => {
const response = await fetch( const response = await fetch(
`http://localhost:8002/api/returnLoan/${loanID}`, `${API_BASE}/api/returnLoan/${loanID}`,
{ {
method: "POST", method: "POST",
headers: { headers: {
@@ -122,7 +127,7 @@ export const onReturn = async (loanID: number) => {
}; };
export const onTake = 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", method: "POST",
headers: { headers: {
Authorization: `Bearer ${Cookies.get("token") || ""}`, Authorization: `Bearer ${Cookies.get("token") || ""}`,
@@ -139,7 +144,7 @@ export const onTake = async (loanID: number) => {
}; };
export const changePW = async (oldPassword: string, newPassword: string) => { 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", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",

Binary file not shown.