Compare commits

..

12 Commits

Author SHA1 Message Date
theis.gaedigk 07503ec079 added animations to borrow-system 2026-04-19 21:32:56 +02:00
theis.gaedigk 4155982aac edited version infos 2026-04-18 14:57:51 +02:00
theis.gaedigk 59e05e5c4c changed license 2026-04-15 19:37:59 +02:00
theis.gaedigk 2facdab011 added icon 2026-03-26 23:03:19 +01:00
theis.gaedigk 04db0bd7e0 edited icon 2026-03-26 23:01:05 +01:00
theis.gaedigk 40dd34bcac changed icon 2026-03-26 22:25:46 +01:00
theis.gaedigk 939d3e89c5 added footage and updated gitignore 2026-03-17 20:35:42 +01:00
theis.gaedigk a355c42964 added license 2026-02-23 21:37:54 +01:00
theis.gaedigk c8a230979f changed version info 2026-02-22 23:45:38 +01:00
theis.gaedigk 581cd4a1fd changed link of help wiki 2026-02-22 23:41:25 +01:00
theis.gaedigk c53e6e095a updated api documentation 2026-02-22 23:33:30 +01:00
theis.gaedigk eaa325668c Implemented page header and added note column 2026-02-22 23:21:33 +01:00
28 changed files with 750 additions and 792 deletions
+6 -1
View File
@@ -116,4 +116,9 @@ ToDo.txt
# only in development branch # only in development branch
next-env.d.ts next-env.d.ts
# psd files from footage
footage/*.psd
icon/
+69 -258
View File
@@ -1,366 +1,177 @@
# Borrow System API Documentation # Borrow System API Documentation
**Frontend:** https://insta.the1s.de ## Overview
**Backend base URL:** `https://insta.the1s.de/backend/api`
--- The Borrow System API provides endpoints for managing items, loans, and door access for a borrowing/locker system. All endpoints require authentication via an 8-digit API key passed as a URL parameter.
## Authentication ## Authentication
All API endpoints require **either**: All requests must include a valid API key in the URL path as the `:key` parameter. API keys are 8-digit numeric strings.
### 1. Bearer Token (JWT)
Send an `Authorization` header:
```http
Authorization: Bearer <JWT_TOKEN>
```
- Used for user-based access.
- Token must be valid and not expired.
### 2. API Key (for devices / machine-to-machine)
Include an API key in the route as `:key` parameter:
```text
/api/.../:key/...
```
Example:
```http
GET /api/items/12345678
```
Where `12345678` is your API key.
The API key is validated server-side.
---
## Common Response Codes
- `200 OK` Request was successful.
- `401 Unauthorized` Missing or malformed credentials.
- `403 Forbidden` Credentials invalid or not allowed to access this resource.
- `404 Not Found` Resource (e.g., loan) not found.
- `500 Internal Server Error` Unexpected server error.
---
## Endpoints ## Endpoints
### 1. Get All Items The Base URL for all endpoints is: `https://insta.the1s.de/backend/api`
**GET** `/api/items/:key` ### Get All Items
Returns a list of all items. `GET /items/:key`
#### Path Parameters Returns all items in the system.
- `:key` API key (8-digit number) **Response 200:**
#### Authentication
- Either:
- Valid `Authorization: Bearer <token>`
- Or valid `:key` path parameter
#### Request Example
```http
GET /api/items/12345678 HTTP/1.1
Host: backend.insta.the1s.de
Authorization: Bearer <JWT_TOKEN>
```
#### Successful Response (200)
```json ```json
{ {
"data": [ "data": [
{ {
"id": 1, "id": 1,
"item_name": "DJI 1er Mikro", "item_name": "Laptop",
"can_borrow_role": 4, "can_borrow_role": 1,
"inSafe": 1, "in_safe": true,
"safe_nr": 3, "safe_nr": 3,
"door_key": "123", "door_key": 101,
"entry_created_at": "2025-08-19T22:02:16.000Z", "last_borrowed_person": "jdoe",
"entry_updated_at": "2025-08-19T22:02:16.000Z",
"last_borrowed_person": "alice",
"currently_borrowing": null "currently_borrowing": null
} }
] ]
} }
``` ```
#### Error Response (500) **Response 500:**
```json ```json
{ { "message": "Failed to fetch items" }
"message": "Failed to fetch items"
}
``` ```
--- ---
### 2. Toggle Item Safe State ### Change Item Safe State
Toggles `in_safe` between `0` and `1` for a given item. `POST /change-state/:key/:itemId`
**Keep in mind that when you return a loan by code, the item states are automatically updated.** Toggles the `in_safe` boolean state of an item.
**POST** `/api/change-state/:key/:itemId` **URL Parameters:**
#### Path Parameters - **key** - API key
- **itemId** - The item's ID
- `:key` API key (8-digit number) **Response 200:** Returns on successful toggle.
- `:itemId` Item ID (integer)
#### Authentication **Response 500:**
- Either Bearer token or `:key` API key.
#### Request Example
```http
POST /api/change-state/12345678/42 HTTP/1.1
Host: backend.insta.the1s.de
```
#### Successful Response (200)
```json ```json
{ { "message": "Failed to update item state" }
"data": {}
}
```
_(Implementation currently only returns `{ success: true }`, so `data` may be empty.)_
#### Error Response (500)
```json
{
"message": "Failed to update item state"
}
``` ```
--- ---
### 3. Get Loan by Code ### Get Loan by Code
Fetch loan information by `loan_code`. `GET /get-loan-by-code/:key/:loan_code`
**GET** `/api/get-loan-by-code/:key/:loan_code` Retrieves loan details by its 6-digit loan code.
#### Path Parameters **URL Parameters:**
- `:key` API key (8-digit number) - **key** - API key
- `:loan_code` Loan code (string) - **loan_code** - A 6-digit numeric loan code
#### Authentication **Response 200:**
- Either Bearer token or `:key` API key.
#### Request Example
```http
GET /api/get-loan-by-code/12345678/12345 HTTP/1.1
Host: backend.insta.the1s.de
```
#### Successful Response (200)
```json ```json
{ {
"data": { "data": {
"username": "john", "username": "jdoe",
"returned_date": null, "returned_date": null,
"take_date": "2025-01-01T10:00:00.000Z", "take_date": "2024-01-15T10:30:00.000Z",
"lockers": "[1, 2, 3]" "lockers": [1, 3]
} }
} }
``` ```
#### Error Response (404) **Response 404:**
```json ```json
{ { "message": "Loan not found" }
"message": "Loan not found"
}
``` ```
--- ---
### 4. Set Loan Return Date ### Set Take Date
Sets `returned_date = NOW()` on a loan and updates related items: `POST /set-take-date/:key/:loan_code`
- `in_safe = 1` Records when items are physically taken by setting `take_date` to the current timestamp. Updates associated items to `in_safe = false` and sets `currently_borrowing` to the loan's username.
- `currently_borrowing = NULL`
- `last_borrowed_person = username`
**POST** `/api/set-return-date/:key/:loan_code` **URL Parameters:**
#### Path Parameters - **key** - API key
- **loan_code** - A 6-digit numeric loan code
- `:key` API key (8-digit number) **Response 200:** Empty JSON object on success.
- `:loan_code` Loan code (string)
#### Authentication **Response 500:**
- Either Bearer token or `:key` API key.
#### Request Example
```http
POST /api/set-return-date/12345678/12345 HTTP/1.1
Host: backend.insta.the1s.de
```
#### Successful Response (200)
```json ```json
{ { "message": "Loan not found or already taken" }
"data": {}
}
``` ```
#### Error Response (500) > **Note:** This endpoint will fail if the loan has already been taken or does not exist.
```json
{
"message": "Failed to set return date"
}
```
--- ---
### 5. Set Loan Take Date ### Set Return Date
Sets `take_date = NOW()` on a loan and updates related items: `POST /set-return-date/:key/:loan_code`
- `in_safe = 0` Marks a loan as returned by setting `returned_date` to the current timestamp. Also updates all associated items to `in_safe = true`, clears `currently_borrowing`, and sets `last_borrowed_person`. Therefore, keep in mind that you must not call other endpoints that will change the safe state of an item after or before calling this endpoint, otherwise the state of the items will be inconsistent.
- `currently_borrowing = username`
**POST** `/api/set-take-date/:key/:loan_code` **URL Parameters:**
#### Path Parameters - **key** - API key
- **loan_code** - A 6-digit numeric loan code
- `:key` API key (8-digit number) **Response 200:** Empty JSON object on success.
- `:loan_code` Loan code (string)
#### Authentication **Response 500:**
- Either Bearer token or `:key` API key.
#### Request Example
```http
POST /api/set-take-date/12345678/LOAN-12345 HTTP/1.1
Host: backend.insta.the1s.de
```
#### Successful Response (200)
```json ```json
{ { "message": "Failed to set return date" }
"data": {}
}
``` ```
#### Error Response (500) > **Note:** This endpoint will fail if the loan has already been returned (i.e., `returned_date` is not `NULL`).
```json
{
"message": "Failed to set take date"
}
```
--- ---
### 6. Open Door by Door Key ### Open Door
Looks up an item by its `door_key`, toggles `in_safe`, and returns safe information. `GET /open-door/:key/:doorKey`
**GET** `/api/open-door/:key/:doorKey` Toggles the safe state of an item identified by its door key and returns the associated safe number.
#### Path Parameters **URL Parameters:**
- `:key` API key (8-digit number) - **key** - API key
- `:doorKey` Door key/token (string) used by hardware to identify the locker. - **doorKey** - The door key identifier assigned to an item
#### Authentication **Response 200:**
- Either Bearer token or `:key` API key.
#### Request Example
```http
GET /api/open-door/12345678/123 HTTP/1.1
Host: backend.insta.the1s.de
```
#### Successful Response (200)
```json ```json
{ {
"data": { "data": {
"safe_nr": 5, "safe_nr": 3,
"id": 42 "id": 1
} }
} }
``` ```
#### Error Response (500) **Response 500:**
```json ```json
{ { "message": "Failed to open door" }
"message": "Failed to open door"
}
``` ```
--- ## Error Handling
## Authentication Error Messages All endpoints return a `500` status code for server-side failures and a JSON body with a `message` field, except for **Get Loan by Code** which returns `404` when no matching loan is found.
### Missing credentials
Status: `401`
```json
{
"message": "Unauthorized"
}
```
### Invalid JWT
Status: `403`
```json
{
"message": "Present token invalid"
}
```
### Invalid API Key
Status: `403`
```json
{
"message": "API Key invalid"
}
```
---
## Notes
- All responses are JSON.
- Time fields like `take_date` and `returned_date` are in the format returned by MySQL (usually ISO-like strings).
- `loaned_items_id` in the database is stored as a JSON array string (e.g. `"[1,2,3]"`) and is parsed internally; clients do not interact with this field directly via current endpoints.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 428 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 416 KiB

+5 -1
View File
@@ -2,7 +2,11 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link
rel="icon"
type="image/png"
href="/icon_borrow-system-frontend_dark.png"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Ausleihsystem</title> <title>Ausleihsystem</title>
</head> </head>
+1 -1
View File
@@ -14,7 +14,7 @@ server {
} }
location /backend/ { location /backend/ {
proxy_pass http://borrow_system-backend_v2:8102/; proxy_pass http://borrow_system-backend_v2:8004/;
} }
location ~* \.(?:js|mjs|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ { location ~* \.(?:js|mjs|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
+325 -257
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "admin", "name": "admin",
"private": true, "private": true,
"version": "0.0.0", "version": "v2.1.2 (dev)",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -12,6 +12,7 @@
"dependencies": { "dependencies": {
"@chakra-ui/react": "^3.28.0", "@chakra-ui/react": "^3.28.0",
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@lottiefiles/dotlottie-react": "^0.19.0",
"@tailwindcss/vite": "^4.1.11", "@tailwindcss/vite": "^4.1.11",
"@tanstack/react-query": "^5.90.5", "@tanstack/react-query": "^5.90.5",
"i18next": "^25.6.0", "i18next": "^25.6.0",
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-shapes-icon lucide-shapes"><path d="M8.3 10a.7.7 0 0 1-.626-1.079L11.4 3a.7.7 0 0 1 1.198-.043L16.3 8.9a.7.7 0 0 1-.572 1.1Z"/><rect x="3" y="14" width="7" height="7" rx="1"/><circle cx="17.5" cy="17.5" r="3.5"/></svg>

Before

Width:  |  Height:  |  Size: 420 B

+2 -2
View File
@@ -142,7 +142,7 @@ export const Header = () => {
value="help" value="help"
onSelect={() => onSelect={() =>
window.open( window.open(
"https://git.the1s.de/Matthias-Claudius-Schule/borrow-system/wiki", "https://git.the1s.de/Matthias-Claudius-Schule/borrow-system/wiki/?action=_pages",
"_blank", "_blank",
"noopener,noreferrer", "noopener,noreferrer",
) )
@@ -279,7 +279,7 @@ export const Header = () => {
</Button> </Button>
<a <a
href="https://git.the1s.de/Matthias-Claudius-Schule/borrow-system/wiki" href="https://git.the1s.de/Matthias-Claudius-Schule/borrow-system/wiki/?action=_pages"
target="_blank" target="_blank"
> >
<Button variant="ghost"> <Button variant="ghost">
+28
View File
@@ -0,0 +1,28 @@
import { DotLottieReact } from "@lottiefiles/dotlottie-react";
export const unlockAnimation = () => {
return (
<DotLottieReact
src="https://lottie.host/f839baa1-9c64-44c4-9386-f0e4c87ab208/2Iw1m4k86d.lottie"
autoplay
/>
);
};
export const approvalAnimation = () => {
return (
<DotLottieReact
src="https://lottie.host/b7257009-9e3f-43e2-8112-a176f4696e4c/iQxxqAVOGX.lottie"
autoplay
/>
);
};
export const logoutAnimation = () => {
return (
<DotLottieReact
src="https://lottie.host/4975758c-de38-4d15-9f74-927709751d32/v8FtKpnD1y.lottie"
autoplay
/>
);
};
+9 -17
View File
@@ -1,23 +1,15 @@
"use client"; "use client"
import { ChakraProvider, defaultSystem } from "@chakra-ui/react"; import { ChakraProvider, defaultSystem } from "@chakra-ui/react"
import * as React from "react"; import {
import type { ReactNode } from "react"; ColorModeProvider,
import { ColorModeProvider as ThemeColorModeProvider } from "./color-mode"; type ColorModeProviderProps,
} from "./color-mode"
export interface ColorModeProviderProps { export function Provider(props: ColorModeProviderProps) {
children: React.ReactNode;
}
export function ColorModeProvider({ children }: ColorModeProviderProps) {
// Wrap children with the real color-mode provider
return <ThemeColorModeProvider>{children}</ThemeColorModeProvider>;
}
export function Provider({ children }: { children: ReactNode }) {
return ( return (
<ChakraProvider value={defaultSystem}> <ChakraProvider value={defaultSystem}>
<ColorModeProvider>{children}</ColorModeProvider> <ColorModeProvider {...props} />
</ChakraProvider> </ChakraProvider>
); )
} }
+157 -137
View File
@@ -18,6 +18,7 @@ import { borrowAbleItemsAtom } from "@/states/Atoms";
import { createLoan } from "@/utils/Fetcher"; import { createLoan } from "@/utils/Fetcher";
import { Header } from "@/components/Header"; import { Header } from "@/components/Header";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { approvalAnimation } from "@/components/dotLottie";
export interface User { export interface User {
username: string; username: string;
@@ -27,6 +28,8 @@ export interface User {
export const HomePage = () => { export const HomePage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const [showAnimation, setShowAnimation] = useState(false);
const [borrowableItems, setBorrowableItems] = useAtom(borrowAbleItemsAtom); const [borrowableItems, setBorrowableItems] = useAtom(borrowAbleItemsAtom);
const [startDate, setStartDate] = useState(""); const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState(""); const [endDate, setEndDate] = useState("");
@@ -46,155 +49,172 @@ export const HomePage = () => {
setSelectedItems((prevSelected) => setSelectedItems((prevSelected) =>
prevSelected.includes(itemId) prevSelected.includes(itemId)
? prevSelected.filter((id) => id !== itemId) ? prevSelected.filter((id) => id !== itemId)
: [...prevSelected, itemId] : [...prevSelected, itemId],
); );
}; };
const showApprovalAnimation = (seconds: number) => {
const milliseconds = seconds * 1000;
setShowAnimation(true);
window.setTimeout(() => {
setShowAnimation(false);
}, milliseconds);
};
return ( return (
<Container className="px-6 sm:px-8 pt-10"> <>
<Header /> {showAnimation && (
{isMsg && ( <div className="fixed inset-0 z-9999 flex items-center justify-center pointer-events-none">
<MyAlert <div>{approvalAnimation()}</div>
status={msgStatus} </div>
title={msgTitle}
description={msgDescription}
/>
)} )}
<Stack as="main"> <Container className="px-6 sm:px-8 pt-10">
<Text>{t("timezone-info")}</Text> <Header />
<label htmlFor="startDate"> {isMsg && (
<strong> <MyAlert
<Text>{t("start-date")}</Text> status={msgStatus}
</strong> title={msgTitle}
</label> description={msgDescription}
<Input />
id="startDate" )}
placeholder={t("start-date")} <Stack as="main">
type="datetime-local" <Text>{t("timezone-info")}</Text>
value={startDate} <label htmlFor="startDate">
onChange={(e) => setStartDate(e.target.value)} <strong>
/> <Text>{t("start-date")}</Text>
<label htmlFor="endDate"> </strong>
<strong> </label>
<Text>{t("end-date")}</Text> <Input
</strong> id="startDate"
</label> placeholder={t("start-date")}
<Input type="datetime-local"
id="endDate" value={startDate}
placeholder={t("end-date")} onChange={(e) => setStartDate(e.target.value)}
type="datetime-local" />
value={endDate} <label htmlFor="endDate">
onChange={(e) => setEndDate(e.target.value)} <strong>
/> <Text>{t("end-date")}</Text>
<Button </strong>
onClick={async () => { </label>
setIsLoadingA(true); <Input
if (!startDate || !endDate) { id="endDate"
setMsgStatus("error"); placeholder={t("end-date")}
setMsgTitle(t("missing-fields")); type="datetime-local"
setMsgDescription(t("missing-fields-desc")); value={endDate}
setIsMsg(true); onChange={(e) => setEndDate(e.target.value)}
setIsLoadingA(false); />
return; <Button
} onClick={async () => {
await getBorrowableItems(startDate, endDate).then((response) => { setIsLoadingA(true);
setIsLoadingA(false); if (!startDate || !endDate) {
if (response && response.status === "error") {
setMsgStatus("error"); setMsgStatus("error");
setMsgTitle(response.title || t("error")); setMsgTitle(t("missing-fields"));
setMsgDescription(response.description || t("unknown-error")); setMsgDescription(t("missing-fields-desc"));
setIsMsg(true); setIsMsg(true);
setIsLoadingA(false);
return; return;
} }
setBorrowableItems(response.data); await getBorrowableItems(startDate, endDate).then((response) => {
setIsMsg(false); setIsLoadingA(false);
}); if (response && response.status === "error") {
}} setMsgStatus("error");
> setMsgTitle(response.title || t("error"));
{t("get-borrowable-items")} setMsgDescription(response.description || t("unknown-error"));
</Button>
{isLoadingA && (
<VStack colorPalette="teal">
<Spinner color="colorPalette.600" />
<Text color="colorPalette.600">{t("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>{t("item")}</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.Row>
<Table.Cell colSpan={2}>
<InputGroup
endElement={
<Span color="fg.muted" textStyle="xs">
{note.length} / {MAX_CHARACTERS}
</Span>
}
>
<Input
placeholder={t("optional-note")}
value={note}
maxLength={MAX_CHARACTERS}
onChange={(e) => {
setNote(
e.currentTarget.value.slice(0, MAX_CHARACTERS)
);
}}
/>
</InputGroup>
</Table.Cell>
</Table.Row>
</Table.Body>
</Table.Root>
</Table.ScrollArea>
)}
{selectedItems.length >= 1 && (
<Button
onClick={() =>
createLoan(selectedItems, startDate, endDate, note).then(
(response) => {
if (response.status === "error") {
setMsgStatus("error");
setMsgTitle(response.title || t("error"));
setMsgDescription(
response.description || t("unknown-error")
);
setIsMsg(true);
return;
}
setMsgStatus("success");
setMsgTitle(t("success"));
setMsgDescription(t("loan-success"));
setIsMsg(true); setIsMsg(true);
return;
} }
) setBorrowableItems(response.data);
} setIsMsg(false);
});
}}
> >
{t("create-loan")} {t("get-borrowable-items")}
</Button> </Button>
)} {isLoadingA && (
</Stack> <VStack colorPalette="teal">
</Container> <Spinner color="colorPalette.600" />
<Text color="colorPalette.600">{t("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>{t("item")}</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.Row>
<Table.Cell colSpan={2}>
<InputGroup
endElement={
<Span color="fg.muted" textStyle="xs">
{note.length} / {MAX_CHARACTERS}
</Span>
}
>
<Input
placeholder={t("optional-note")}
value={note}
maxLength={MAX_CHARACTERS}
onChange={(e) => {
setNote(
e.currentTarget.value.slice(0, MAX_CHARACTERS),
);
}}
/>
</InputGroup>
</Table.Cell>
</Table.Row>
</Table.Body>
</Table.Root>
</Table.ScrollArea>
)}
{selectedItems.length >= 1 && (
<Button
onClick={() =>
createLoan(selectedItems, startDate, endDate, note).then(
(response) => {
if (response.status === "error") {
setMsgStatus("error");
setMsgTitle(response.title || t("error"));
setMsgDescription(
response.description || t("unknown-error"),
);
setIsMsg(true);
return;
}
showApprovalAnimation(3);
setMsgStatus("success");
setMsgTitle(t("success"));
setMsgDescription(t("loan-success"));
setIsMsg(true);
},
)
}
>
{t("create-loan")}
</Button>
)}
</Stack>
</Container>
</>
); );
}; };
+13 -14
View File
@@ -9,12 +9,13 @@ import {
Card, Card,
SimpleGrid, SimpleGrid,
Button, Button,
Container,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import MyAlert from "@/components/myChakra/MyAlert"; import MyAlert from "@/components/myChakra/MyAlert";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { API_BASE } from "@/config/api.config"; import { API_BASE } from "@/config/api.config";
import Cookies from "js-cookie"; import Cookies from "js-cookie";
import { useNavigate } from "react-router-dom"; import { Header } from "@/components/Header";
export const formatDateTime = (value: string | null | undefined) => { export const formatDateTime = (value: string | null | undefined) => {
if (!value) return "N/A"; if (!value) return "N/A";
@@ -32,6 +33,7 @@ type Loan = {
returned_date: string | null; returned_date: string | null;
take_date: string | null; take_date: string | null;
loaned_items_name: string[] | string; loaned_items_name: string[] | string;
note: string | null;
}; };
type Device = { type Device = {
@@ -46,7 +48,6 @@ type Device = {
const Landingpage: React.FC = () => { const Landingpage: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [loans, setLoans] = useState<Loan[]>([]); const [loans, setLoans] = useState<Loan[]>([]);
@@ -59,7 +60,7 @@ const Landingpage: React.FC = () => {
const setError = ( const setError = (
status: "error" | "success", status: "error" | "success",
message: string, message: string,
description: string description: string,
) => { ) => {
setIsError(false); setIsError(false);
setErrorStatus(status); setErrorStatus(status);
@@ -85,7 +86,7 @@ const Landingpage: React.FC = () => {
setError( setError(
"error", "error",
t("error-by-loading"), t("error-by-loading"),
t("unexpected-date-format_loan") t("unexpected-date-format_loan"),
); );
} }
@@ -102,7 +103,7 @@ const Landingpage: React.FC = () => {
setError( setError(
"error", "error",
t("error-by-loading"), t("error-by-loading"),
t("unexpected-date-format_device") t("unexpected-date-format_device"),
); );
} }
} catch (e) { } catch (e) {
@@ -115,14 +116,8 @@ const Landingpage: React.FC = () => {
}, []); }, []);
return ( return (
<> <Container className="px-6 sm:px-8 pt-10">
<Heading as="h1" size="lg" mb={2}> <Header />
Matthias-Claudius-Schule Technik
</Heading>
<Button onClick={() => navigate("/", { replace: true })}>
{t("back")}
</Button>
<Heading as="h2" size="md" mb={4}> <Heading as="h2" size="md" mb={4}>
{t("all-loans")} {t("all-loans")}
@@ -168,6 +163,9 @@ const Landingpage: React.FC = () => {
<Table.ColumnHeader> <Table.ColumnHeader>
<strong>{t("return-date")}</strong> <strong>{t("return-date")}</strong>
</Table.ColumnHeader> </Table.ColumnHeader>
<Table.ColumnHeader>
<strong>{t("note")}</strong>
</Table.ColumnHeader>
</Table.Row> </Table.Row>
</Table.Header> </Table.Header>
<Table.Body> <Table.Body>
@@ -184,6 +182,7 @@ const Landingpage: React.FC = () => {
</Table.Cell> </Table.Cell>
<Table.Cell>{formatDateTime(loan.take_date)}</Table.Cell> <Table.Cell>{formatDateTime(loan.take_date)}</Table.Cell>
<Table.Cell>{formatDateTime(loan.returned_date)}</Table.Cell> <Table.Cell>{formatDateTime(loan.returned_date)}</Table.Cell>
<Table.Cell>{loan.note}</Table.Cell>
</Table.Row> </Table.Row>
))} ))}
</Table.Body> </Table.Body>
@@ -260,7 +259,7 @@ const Landingpage: React.FC = () => {
</HStack> </HStack>
</Button> </Button>
</HStack> </HStack>
</> </Container>
); );
}; };
+93 -53
View File
@@ -4,26 +4,47 @@ import { Button, Card, Field, Input, Stack } from "@chakra-ui/react";
import { setIsLoggedInAtom, triggerLogoutAtom } from "@/states/Atoms"; import { setIsLoggedInAtom, triggerLogoutAtom } from "@/states/Atoms";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import Cookies from "js-cookie"; import Cookies from "js-cookie";
import { Navigate, useNavigate, useLocation } from "react-router-dom"; import { useNavigate, useLocation } from "react-router-dom";
import { PasswordInput } from "@/components/ui/password-input"; import { PasswordInput } from "@/components/ui/password-input";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { API_BASE } from "@/config/api.config"; import { API_BASE } from "@/config/api.config";
import { unlockAnimation } from "@/components/dotLottie";
import { logoutAnimation } from "@/components/dotLottie";
export const LoginPage = () => { export const LoginPage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const [isLoggedIn, setIsLoggedIn] = useAtom(setIsLoggedInAtom); const [isLoggedIn, setIsLoggedIn] = useAtom(setIsLoggedInAtom);
const [triggerLogout, setTriggerLogout] = useAtom(triggerLogoutAtom); const [triggerLogout, setTriggerLogout] = useAtom(triggerLogoutAtom);
const [showAnimation, setShowAnimation] = useState(false);
const [showLogout, setShowLogout] = useState(false);
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const from = location.state?.from?.pathname || "/"; const from = location.state?.from?.pathname || "/";
useEffect(() => { useEffect(() => {
if (isLoggedIn) { if (triggerLogout) {
navigate(from, { replace: true }); setShowLogout(true);
window.location.reload(); // if deleted, the user context is not updated in time window.setTimeout(() => {
setShowLogout(false);
}, 4500);
} }
}, [isLoggedIn, navigate, from]);
if (!isLoggedIn) return;
// Existing sessions should redirect immediately, fresh logins wait for animation.
if (!showAnimation) {
navigate(from, { replace: true });
return;
}
const timeoutId = window.setTimeout(() => {
navigate(from, { replace: true });
window.location.reload(); // keeps user context in sync after login
}, 3000);
return () => window.clearTimeout(timeoutId);
}, [isLoggedIn, showAnimation, navigate, from]);
const loginFnc = async (username: string, password: string) => { const loginFnc = async (username: string, password: string) => {
const response = await fetch(`${API_BASE}/api/users/login`, { const response = await fetch(`${API_BASE}/api/users/login`, {
@@ -42,6 +63,8 @@ export const LoginPage = () => {
}; };
} }
setShowAnimation(true);
Cookies.set("token", data.token); Cookies.set("token", data.token);
setIsLoggedIn(true); setIsLoggedIn(true);
return { success: true }; return { success: true };
@@ -62,58 +85,75 @@ export const LoginPage = () => {
return; return;
} }
setTriggerLogout(false); setTriggerLogout(false);
navigate(from, { replace: true });
}; };
if (isLoggedIn) {
return <Navigate to={from} replace />;
}
return ( return (
<div className="flex flex-1 items-center justify-center p-4"> <>
<form onSubmit={(e) => e.preventDefault()}> {showAnimation && (
<Card.Root maxW="sm"> <div className="fixed inset-0 z-9999 flex items-center justify-center pointer-events-none">
<Card.Header> <div>{unlockAnimation()}</div>
<Card.Title>{t("login")}</Card.Title> </div>
<Card.Description>{t("enter-credentials")}</Card.Description> )}
</Card.Header>
<Card.Body> {showLogout && (
<Stack gap="4" w="full"> <div className="fixed inset-0 z-9999 flex items-center justify-center pointer-events-none">
<Field.Root> <div>{logoutAnimation()}</div>
<Field.Label>{t("username")}</Field.Label> </div>
<Input )}
value={username}
onChange={(e) => setUsername(e.target.value)} <div className="flex flex-1 items-center justify-center p-4">
<form onSubmit={(e) => e.preventDefault()}>
<Card.Root maxW="sm">
<Card.Header>
<Card.Title>{t("login")}</Card.Title>
<Card.Description>{t("enter-credentials")}</Card.Description>
</Card.Header>
<Card.Body>
<Stack gap="4" w="full">
<Field.Root>
<Field.Label>{t("username")}</Field.Label>
<Input
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</Field.Root>
<Field.Root>
<Field.Label>{t("password")}</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}
/> />
</Field.Root> )}
<Field.Root> <Button
<Field.Label>{t("password")}</Field.Label> type="submit"
<PasswordInput onClick={() => handleLogin()}
value={password} variant="solid"
onChange={(e) => setPassword(e.target.value)} >
Login
</Button>
</Card.Footer>
<Card.Footer justifyContent="flex-end">
{triggerLogout && (
<MyAlert
status="success"
title={t("logout-success")}
description={t("logout-success-desc")}
/> />
</Field.Root> )}
</Stack> </Card.Footer>
</Card.Body> </Card.Root>
<Card.Footer justifyContent="flex-end"> </form>
{isError && ( </div>
<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={t("logout-success")}
description={t("logout-success-desc")}
/>
)}
</Card.Footer>
</Card.Root>
</form>
</div>
); );
}; };
+7 -14
View File
@@ -1,23 +1,16 @@
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import svgr from "vite-plugin-svgr";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
import path from "node:path"; import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({ export default defineConfig({
plugins: [tailwindcss()], plugins: [react(), svgr(), tailwindcss(), tsconfigPaths()],
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
},
},
server: { server: {
host: "0.0.0.0", host: "0.0.0.0",
allowedHosts: ["insta.the1s.de"], port: 8001,
port: 8101, watch: {
watch: { usePolling: true }, usePolling: true,
hmr: {
host: "insta.the1s.de",
port: 8101,
protocol: "wss",
}, },
}, },
}); });
+6
View File
@@ -0,0 +1,6 @@
Copyright (c) 2026 Theis Gaedigk
All rights reserved.
This source code is not to be copied, modified, or distributed in any form
without explicit written permission from the author.
+1 -1
View File
@@ -14,7 +14,7 @@ server {
} }
location /backend/ { location /backend/ {
proxy_pass http://borrow_system-backend_v2:8102/; proxy_pass http://borrow_system-backend_v2:8004/;
} }
location ~* \.(?:js|mjs|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ { location ~* \.(?:js|mjs|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "admin", "name": "admin",
"private": true, "private": true,
"version": "0.0.0", "version": "v1.3.2 (dev)",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -47,4 +47,4 @@
"vite": "^7.1.0", "vite": "^7.1.0",
"vite-tsconfig-paths": "^5.1.4" "vite-tsconfig-paths": "^5.1.4"
} }
} }
+3 -7
View File
@@ -8,13 +8,9 @@ export default defineConfig({
plugins: [react(), svgr(), tailwindcss(), tsconfigPaths()], plugins: [react(), svgr(), tailwindcss(), tsconfigPaths()],
server: { server: {
host: "0.0.0.0", host: "0.0.0.0",
allowedHosts: ["admin.insta.the1s.de"], port: 8003,
port: 8103, watch: {
watch: { usePolling: true }, usePolling: true,
hmr: {
host: "admin.insta.the1s.de",
port: 8103,
protocol: "wss",
}, },
}, },
}); });
+3 -3
View File
@@ -1,11 +1,11 @@
{ {
"backend-info": { "backend-info": {
"version": "v2.1" "version": "v2.1.1 (dev)"
}, },
"frontend-info": { "frontend-info": {
"version": "v2.1" "version": "v2.1.2 (dev)"
}, },
"admin-panel-info": { "admin-panel-info": {
"version": "v1.3.2" "version": "v1.3.2 (dev)"
} }
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "backendv2", "name": "backendv2",
"version": "1.0.0", "version": "v2.1.1 (dev)",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1", "test": "echo \"Error: no test specified\" && exit 1",
+1 -1
View File
@@ -20,7 +20,7 @@ import apiRouter from "./routes/api/api.route.js";
env.config(); env.config();
const app = express(); const app = express();
const port = 8102; const port = 8004;
app.use(cors()); app.use(cors());
// Body-Parser VOR den Routen registrieren // Body-Parser VOR den Routen registrieren
+16 -20
View File
@@ -1,23 +1,23 @@
services: services:
usr-frontend_v2: # usr-frontend_v2:
container_name: borrow_system-usr-frontend # container_name: borrow_system-usr-frontend
networks: # build: ./FrontendV2
- proxynet # ports:
build: ./FrontendV2 # - "8001:80"
restart: unless-stopped # restart: unless-stopped
admin-frontend: # admin-frontend:
container_name: borrow_system-admin-frontend # container_name: borrow_system-admin-frontend
networks: # build: ./admin
- proxynet # ports:
build: ./admin # - "8003:80"
restart: unless-stopped # restart: unless-stopped
backend_v2: backend_v2:
container_name: borrow_system-backend_v2 container_name: borrow_system-backend_v2
networks:
- proxynet
build: ./backendV2 build: ./backendV2
ports:
- "8004:8004"
environment: environment:
NODE_ENV: production NODE_ENV: production
DB_HOST: mysql_v2 DB_HOST: mysql_v2
@@ -30,8 +30,6 @@ services:
mysql_v2: mysql_v2:
container_name: borrow_system-mysql-v2 container_name: borrow_system-mysql-v2
networks:
- proxynet
image: mysql:8.0 image: mysql:8.0
restart: unless-stopped restart: unless-stopped
environment: environment:
@@ -41,11 +39,9 @@ services:
volumes: volumes:
- mysql-v2-data:/var/lib/mysql - mysql-v2-data:/var/lib/mysql
- ./mysql-timezone.cnf:/etc/mysql/conf.d/timezone.cnf:ro - ./mysql-timezone.cnf:/etc/mysql/conf.d/timezone.cnf:ro
ports:
- "3310:3306"
volumes: volumes:
mysql-data: mysql-data:
mysql-v2-data: mysql-v2-data:
networks:
proxynet:
external: true