43 Commits

Author SHA1 Message Date
5b73b44e79 refactored apiKeys table structure and added new route files for loans and user management 2025-11-02 21:19:02 +01:00
cf4a003c51 removed writing error 2025-11-02 21:18:35 +01:00
592b60082b refactored api routes 2025-11-02 17:14:36 +01:00
a34292bda1 refactored authentication 2025-11-02 17:14:28 +01:00
2f37ae8067 added backendV2 2025-11-02 16:55:16 +01:00
f49da68e15 refactored scheme 2025-10-31 13:39:04 +01:00
9491da2950 edited scheme 2025-10-30 18:35:11 +01:00
a75ba12897 improved error handling 2025-10-30 17:32:55 +01:00
b52fe07618 refactor: update @tanstack/react-query to version 5.90.5 and restructure Footer component
feat: edited imports
2025-10-30 17:27:35 +01:00
0d3de4f705 added new data scheme 2025-10-30 13:55:16 +01:00
ef3f953ebd removed sth 2025-10-30 13:50:38 +01:00
1db4e69322 Remove unused components and files from the frontend, including Form4, Header, LoginForm, Object, Sidebar, and related utility functions. Clean up the project structure by deleting unnecessary CSS, TypeScript configuration files, and Vite configuration. This refactor aims to streamline the codebase and improve maintainability. 2025-10-27 20:40:53 +01:00
83f1c9d191 feat: add server-info endpoint and include server information in info.json 2025-10-26 21:55:59 +01:00
af513034ef chore: add logging for i18n variable to prevent unused variable tree shaking 2025-10-26 15:40:23 +01:00
3358c8f669 fixed render bug 2025-10-26 14:40:51 +01:00
d3f7a7570f feat: add Footer component and integrate it into App and LoginPage 2025-10-26 14:37:41 +01:00
c502601a2f feat: add language change functionality and update translations in Header and locale files 2025-10-26 14:22:19 +01:00
070a390da8 refactor: streamline language initialization and update Container component in HomePage and MyLoansPage 2025-10-26 14:05:54 +01:00
bcf93ee9eb added english language 2025-10-26 14:05:48 +01:00
9daff3ea5c refactor: update heading sizes in ItemTable, LoanTable, and UserTable components 2025-10-26 13:54:43 +01:00
71fea52da7 filter out deleted loans in getBorrowableItemsFromDatabase and createLoanInDatabase functions 2025-10-26 13:54:37 +01:00
a8821ceca8 refactored code 2025-10-26 13:39:09 +01:00
7e668e17d3 added german translation 2025-10-26 13:37:15 +01:00
965a4b97ee translated greeting 2025-10-26 12:53:07 +01:00
6054173b03 implemented i18n translation technology 2025-10-26 12:52:58 +01:00
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
96 changed files with 6723 additions and 2565 deletions

View File

@@ -7,6 +7,6 @@ RUN npm install
COPY . .
EXPOSE 8101
EXPOSE 8001
CMD ["npm", "run", "dev"]

View File

@@ -4,15 +4,19 @@ This template provides a minimal setup to get React working in Vite with HMR and
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/) for Fast Refresh
- [@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 tseslint.config([
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
@@ -20,11 +24,11 @@ export default tseslint.config([
// Other configs...
// Remove tseslint.configs.recommended and replace with this
...tseslint.configs.recommendedTypeChecked,
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
...tseslint.configs.strictTypeChecked,
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
...tseslint.configs.stylisticTypeChecked,
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
@@ -46,7 +50,7 @@ You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-re
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default tseslint.config([
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],

View File

@@ -3,9 +3,9 @@ import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { globalIgnores } from 'eslint/config'
import { defineConfig, globalIgnores } from 'eslint/config'
export default tseslint.config([
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],

View File

@@ -1,10 +1,10 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/shapes.svg" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Ausleihsystem</title>
<title>frontendv2</title>
</head>
<body>
<div id="root"></div>

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
{
"name": "frontend",
"name": "admin",
"private": true,
"version": "0.0.0",
"type": "module",
@@ -10,14 +10,21 @@
"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",
"@tanstack/react-query": "^5.90.5",
"i18next": "^25.6.0",
"jotai": "^2.15.0",
"js-cookie": "^3.0.5",
"lucide-react": "^0.539.0",
"next-themes": "^0.4.6",
"primeicons": "^7.0.0",
"primereact": "^10.9.6",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-i18next": "^16.2.0",
"react-icons": "^5.5.0",
"react-router-dom": "^7.8.0",
"react-toastify": "^11.0.5",
"split-lines": "^3.0.0",
@@ -39,6 +46,7 @@
"globals": "^16.3.0",
"typescript": "~5.8.3",
"typescript-eslint": "^8.39.0",
"vite": "^7.1.0"
"vite": "^7.1.0",
"vite-tsconfig-paths": "^5.1.4"
}
}

View File

Before

Width:  |  Height:  |  Size: 420 B

After

Width:  |  Height:  |  Size: 420 B

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

90
FrontendV2/src/App.tsx Normal file
View File

@@ -0,0 +1,90 @@
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";
import { changeLanguage } from "i18next";
import { Box, Flex } from "@chakra-ui/react";
import { Footer } from "./components/footer/Footer";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { API_BASE } from "@/config/api.config";
const queryClient = new QueryClient();
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();
}
// set initial language
if (!Cookies.get("language")) {
const getBrowserLanguage = () => {
const lang = navigator.languages?.[0] || navigator.language || "en";
return lang.split("-")[0].toLowerCase();
};
changeLanguage(getBrowserLanguage());
Cookies.set("language", getBrowserLanguage());
}
if (Cookies.get("language")) {
changeLanguage(Cookies.get("language") || "en");
}
}, []);
return (
<QueryClientProvider client={queryClient}>
<Flex direction="column" minH="100vh">
<Box as="main" flex="1">
<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>
</Box>
<Footer />
</Flex>
</QueryClientProvider>
);
}
export default App;

View File

Before

Width:  |  Height:  |  Size: 4.0 KiB

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,403 @@
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,
Flag,
} from "lucide-react";
import { useUserContext } from "@/states/Context";
import { useState } from "react";
import MyAlert from "./myChakra/MyAlert";
import { useTranslation } from "react-i18next";
import { API_BASE } from "@/config/api.config";
export const Header = () => {
const navigate = useNavigate();
const userData = useUserContext();
const { t } = useTranslation();
// Error handling states
const [isMsg, setIsMsg] = useState(false);
const [msgStatus, setMsgStatus] = useState<"error" | "success">("error");
const [msgTitle, setMsgTitle] = useState("");
const [msgDescription, setMsgDescription] = useState("");
const [oldPassword, setOldPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [, setTriggerLogout] = useAtom(triggerLogoutAtom);
const [, setIsLoggedIn] = useAtom(setIsLoggedInAtom);
// Dialog control
const [isPwOpen, setPwOpen] = useState(false);
const changePassword = async () => {
if (newPassword !== confirmPassword) {
setMsgTitle(t("err_pw_change"));
setMsgDescription(t("pw_mismatch"));
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(t("err_pw_change"));
setMsgDescription(t("pw_mismatch"));
setMsgStatus("error");
setIsMsg(true);
return;
}
setMsgTitle(t("pw_success"));
setMsgDescription(t("pw_success_desc"));
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);
navigate("/login", { replace: 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">{t("create-loan")}</Text>
</HStack>
}
/>
<Menu.Item
value="my-loans"
onSelect={() => navigate("/my-loans", { replace: true })}
children={
<HStack gap={3}>
<CircleUserRound size={16} />
<Text as="span">{t("my-loans")}</Text>
</HStack>
}
/>
<Menu.Item
value="change-password"
onSelect={() => setPwOpen(true)}
children={
<HStack gap={3}>
<RotateCcwKey size={16} />
<Text as="span">{t("change-password")}</Text>
</HStack>
}
/>
<Menu.Item
value="change-language"
onSelect={() => {
const currentLang = Cookies.get("language") || "en";
const newLang = currentLang === "en" ? "de" : "en";
Cookies.set("language", newLang);
window.location.reload();
}}
children={
<HStack gap={3}>
<LifeBuoy size={16} />
<Text as="span">{t("change-language")}</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">{t("help")}</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">{t("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">{t("logout")}</Text>
</HStack>
}
/>
</Menu.Content>
</Menu.Positioner>
</Menu.Root>
</Box>
<Flex
direction={{ base: "column", md: "row" }}
align={{ base: "stretch", md: "center" }}
justify="space-between"
gap={4}
>
{/* Left: Title + user info */}
<Stack gap={1}>
{/* Titelzeile ohne Mobile-Menu (wurde nach oben verlegt) */}
<Flex align="center" justify="space-between" gap={2}>
<Heading
size="2xl"
className="tracking-tight text-slate-900 dark:text-slate-100"
>
Home
</Heading>
</Flex>
<HStack gap={3} align="center" flexWrap="wrap">
<Text fontSize="md" className="text-slate-600 dark:text-slate-400">
{t("greeting")}
<strong>{username}</strong>!
</Text>
<Badge variant="subtle" px={2} py={1} borderRadius="full">
Rolle: {userData?.role ?? "—"}
</Badge>
</HStack>
</Stack>
{/* Right: Actions */}
{/* Desktop actions */}
<HStack
gap={2}
align="center"
justify="flex-end"
flexWrap="wrap"
display={{ base: "none", md: "flex" }}
>
<Button
colorScheme="teal"
onClick={() => navigate("/", { replace: true })}
>
<HStack gap={2}>
<CalendarPlus size={18} />
<Text as="span">{t("create-loan")}</Text>
</HStack>
</Button>
<Button onClick={() => navigate("/my-loans", { replace: true })}>
<HStack gap={2}>
<CircleUserRound size={18} />
<Text as="span">{t("my-loans")}</Text>
</HStack>
</Button>
<Button variant="ghost" onClick={() => setPwOpen(true)}>
<HStack gap={2}>
<RotateCcwKey size={18} />
<Text as="span">{t("change-password")}</Text>
</HStack>
</Button>
<Button
variant="ghost"
onClick={() => {
const currentLang = Cookies.get("language") || "en";
const newLang = currentLang === "en" ? "de" : "en";
Cookies.set("language", newLang);
window.location.reload();
}}
>
<HStack gap={2}>
<Flag size={18} />
<Text as="span">{t("change-language")}</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">{t("help")}</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">{t("source-code")}</Text>
</HStack>
</Button>
</a>
<Button onClick={logout} variant="outline" colorScheme="red">
<HStack gap={2}>
<LogOut size={18} />
<Text as="span">{t("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>{t("change-password")}</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={t("old-password")}
/>
<PasswordInput
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder={t("new-password")}
/>
<PasswordInput
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder={t("confirm-password")}
/>
</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">{t("cancel")}</Button>
</Dialog.ActionTrigger>
<Button type="submit" colorScheme="teal">
{t("save")}
</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,23 @@
import { Box } from "@chakra-ui/react";
import { useVersionInfoQuery } from "./versionInfo.query";
export const Footer = () => {
const { data: info } = useVersionInfoQuery();
return (
<Box
as="footer"
py={4}
textAlign="center"
position="fixed"
bottom="0"
left="0"
right="0"
>
Made with by Theis Gaedigk - Year 2019 at MCS-Bochum
<br />
Frontend-Version: {info ? info["frontend-info"].version : "N/A"} |
Backend-Version: {info ? info["backend-info"].version : "N/A"}
</Box>
);
};

View File

@@ -0,0 +1,29 @@
import { useQuery } from "@tanstack/react-query";
import { API_BASE } from "@/config/api.config";
export const useVersionInfoQuery = () =>
useQuery({
queryKey: ["versionInfo"],
queryFn: async () => {
const response = await fetch(`${API_BASE}/server-info`, {
method: "GET",
});
if (response.ok) {
const data = await response.json();
return data;
} else {
console.error(
"Failed to fetch version info (versionInfo.query.ts): ",
response.statusText
);
return {
"backend-info": {
version: "N/A",
},
"frontend-info": {
version: "N/A",
},
};
}
},
});

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

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

@@ -0,0 +1,18 @@
import { Provider } from "@/components/ui/provider";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App.tsx";
import i18n from "./utils/i18n"; // import i18n configuration DO NOT REMOVE
// Prevent unused variable tree shaking
let i18nUnused = i18n;
console.log(i18nUnused);
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";
import { useTranslation } from "react-i18next";
export interface User {
username: string;
role: number;
}
export const HomePage = () => {
const { t } = useTranslation();
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 className="px-6 sm:px-8 pt-10">
<Header />
{isMsg && (
<MyAlert
status={msgStatus}
title={msgTitle}
description={msgDescription}
/>
)}
<Stack as="main">
<Text>{t("timezone-info")}</Text>
<label htmlFor="startDate">
<Text>{t("start-date")}</Text>
</label>
<Input
id="startDate"
placeholder={t("start-date")}
type="datetime-local"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
/>
<label htmlFor="endDate">
<Text>{t("end-date")}</Text>
</label>
<Input
id="endDate"
placeholder={t("end-date")}
type="datetime-local"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
/>
<Button
onClick={async () => {
setIsLoadingA(true);
if (!startDate || !endDate) {
setMsgStatus("error");
setMsgTitle(t("missing-fields"));
setMsgDescription(t("missing-fields-desc"));
setIsMsg(true);
setIsLoadingA(false);
return;
}
await getBorrowableItems(startDate, endDate).then((response) => {
setIsLoadingA(false);
if (response && response.status === "error") {
setMsgStatus("error");
setMsgTitle(response.title || t("error"));
setMsgDescription(response.description || t("unknown-error"));
setIsMsg(true);
return;
}
setBorrowableItems(response.data);
setIsMsg(false);
console.log(borrowableItems);
});
}}
>
{t("get-borrowable-items")}
</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.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 || t("error"));
setMsgDescription(response.description || t("unknown-error"));
setIsMsg(true);
return;
}
setMsgStatus("success");
setMsgTitle(t("success"));
setMsgDescription(t("loan-success"));
setIsMsg(true);
})
}
>
{t("create-loan")}
</Button>
)}
</Stack>
</Container>
);
};

View File

@@ -11,13 +11,17 @@ import {
Button,
} from "@chakra-ui/react";
import { Lock, LockOpen } from "lucide-react";
import MyAlert from "../myChakra/MyAlert";
import { formatDateTime } from "@/utils/userFuncs";
import MyAlert from "@/components/myChakra/MyAlert";
import { useTranslation } from "react-i18next";
import { API_BASE } from "@/config/api.config";
const API_BASE =
(import.meta as any).env?.VITE_BACKEND_URL ||
import.meta.env.VITE_BACKEND_URL ||
"http://localhost:8002";
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`;
};
type Loan = {
id: number;
@@ -38,6 +42,8 @@ type Device = {
};
const Landingpage: React.FC = () => {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const [loans, setLoans] = useState<Loan[]>([]);
const [devices, setDevices] = useState<Device[]>([]);
@@ -69,8 +75,8 @@ const Landingpage: React.FC = () => {
} else {
setError(
"error",
"Fehler beim Laden",
"Unerwartetes Datenformat erhalten. (Ausleihen)"
t("error-by-loading"),
t("unexpected-date-format_loan")
);
}
@@ -81,16 +87,12 @@ const Landingpage: React.FC = () => {
} else {
setError(
"error",
"Fehler beim Laden",
"Unerwartetes Datenformat erhalten. (Geräte)"
t("error-by-loading"),
t("unexpected-date-format_device")
);
}
} catch (e) {
setError(
"error",
"Fehler beim Laden",
"Die Ausleihen konnten nicht geladen werden."
);
setError("error", t("error-by-loading"), t("error-fetching-loans"));
} finally {
setIsLoading(false);
}
@@ -105,7 +107,7 @@ const Landingpage: React.FC = () => {
</Heading>
<Heading as="h2" size="md" mb={4}>
Alle Ausleihen
{t("all-loans")}
</Heading>
{isError && (
@@ -119,7 +121,7 @@ const Landingpage: React.FC = () => {
{isLoading && (
<VStack colorPalette="teal">
<Spinner color="colorPalette.600" />
<Text color="colorPalette.600">Loading...</Text>
<Text color="colorPalette.600">{t("loading")}</Text>
</VStack>
)}
@@ -131,22 +133,22 @@ const Landingpage: React.FC = () => {
<strong>#</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Benutzername</strong>
<strong>{t("username")}</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Startdatum</strong>
<strong>{t("start-date")}</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Enddatum</strong>
<strong>{t("end-date")}</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Ausgeliehene Artikel</strong>
<strong>{t("rented-items")}</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Rückgabedatum</strong>
<strong>{t("return-date")}</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Ausleihdatum</strong>
<strong>{t("take-date")}</strong>
</Table.ColumnHeader>
</Table.Row>
</Table.Header>
@@ -172,12 +174,12 @@ const Landingpage: React.FC = () => {
{!isLoading && loans.length === 0 && !isError && (
<Text color="gray.500" mt={2}>
Keine Ausleihen vorhanden.
{t("no-loans-found")}
</Text>
)}
<Heading as="h2" size="md" mb={4}>
Alle Geräte
{t("all-devices")}
</Heading>
{/* Responsive Grid mit gleich hohen Karten */}
@@ -195,14 +197,16 @@ const Landingpage: React.FC = () => {
<Heading size="md">{device.item_name}</Heading>
</Card.Header>
<Card.Body color="fg.muted">
<Text>Ausleihrolle: {device.can_borrow_role}</Text>
<Text>
{t("rent-role")}: {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:
{t("legend")}:
</Text>
<Button
size="sm"
@@ -214,7 +218,7 @@ const Landingpage: React.FC = () => {
>
<HStack gap={2}>
<LockOpen size={16} />
<Text>Im Schließfach</Text>
<Text>{t("in-locker")}</Text>
</HStack>
</Button>
<Button
@@ -227,7 +231,7 @@ const Landingpage: React.FC = () => {
>
<HStack gap={2}>
<Lock size={16} />
<Text>Nicht im Schließfach</Text>
<Text>{t("not-in-locker")}</Text>
</HStack>
</Button>
</HStack>

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";
import { useTranslation } from "react-i18next";
import { Footer } from "@/components/footer/Footer";
import { API_BASE } from "@/config/api.config";
export const LoginPage = () => {
const { t } = useTranslation();
const [isLoggedIn, setIsLoggedIn] = useAtom(setIsLoggedInAtom);
const [triggerLogout, setTriggerLogout] = useAtom(triggerLogoutAtom);
const navigate = useNavigate();
useEffect(() => {
if (isLoggedIn) {
navigate("/", { replace: true });
window.location.reload(); // Wenn entfernt: Seite bleibt schwarz und muss manuell neu geladen werden
}
}, [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 ?? t("login-failed"),
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>{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} />
)}
<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>
<Footer />
</div>
);
};

View File

@@ -0,0 +1,240 @@
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";
import { useTranslation } from "react-i18next";
import { API_BASE } from "@/config/api.config";
export const MyLoansPage = () => {
const { t } = useTranslation();
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(t("error"));
setMsgDescription(t("error-fetching-loans"));
setIsMsg(true);
return;
}
const data = await res.json();
setLoans(data);
} catch (e) {
setMsgStatus("error");
setMsgTitle(t("error"));
setMsgDescription(t("network-error-fetching-loans"));
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(t("error"));
setMsgDescription(t("error-deleting-loan"));
setIsMsg(true);
return;
}
setLoans((prev) => prev.filter((loan) => loan.id !== loanId));
setMsgStatus("success");
setMsgTitle(t("success"));
setMsgDescription(t("loan-deletion-success"));
setIsMsg(true);
} catch (e) {
setMsgStatus("error");
setMsgTitle(t("error"));
setMsgDescription(t("network-error-deleting-loan"));
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 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">{t("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>{t("loan-code")}</Table.ColumnHeader>
<Table.ColumnHeader>{t("start-date")}</Table.ColumnHeader>
<Table.ColumnHeader>{t("end-date")}</Table.ColumnHeader>
<Table.ColumnHeader>{t("devices")}</Table.ColumnHeader>
<Table.ColumnHeader>{t("take-date")}</Table.ColumnHeader>
<Table.ColumnHeader>{t("return-date")}</Table.ColumnHeader>
<Table.ColumnHeader>{t("actions")}</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>{t("sure")}</Dialog.Title>
</Dialog.Header>
<Dialog.Body>
<Text>
{t("sure-delete-loan-0")}
<strong>
<Code>{delLoanCode}</Code>
</strong>{" "}
{t("sure-delete-loan-1")}
<br />
{t("sure-delete-loan-2")}
</Text>
</Dialog.Body>
<Dialog.Footer>
<Dialog.ActionTrigger asChild>
<Button variant="outline">{t("cancel")}</Button>
</Dialog.ActionTrigger>
<Button
colorPalette="red"
onClick={() => deleteLoan(loan.id)}
>
<strong>{t("delete")}</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,16 @@
import { atom } from "jotai";
interface Meta {
"backend-info": {
version: String;
};
"frontend-info": {
version: String;
};
}
export const testAtom = atom<number>(0);
export const setIsLoggedInAtom = atom<boolean>(false);
export const triggerLogoutAtom = atom<boolean>(false);
export const borrowAbleItemsAtom = atom<any[]>([]);
export const infoAtom = atom<Meta | undefined>(undefined);

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,78 @@
import Cookies from "js-cookie";
import { API_BASE } from "@/config/api.config";
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();
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,34 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import Cookies from "js-cookie";
import enLang from "./locales/en/en.json";
import deLang from "./locales/de/de.json";
// the translations
// (tip move them in a JSON file and import them,
// or even better, manage them separated from your code: https://react.i18next.com/guides/multiple-translation-files)
const resources = {
en: {
translation: enLang,
},
de: {
translation: deLang,
},
};
i18n
.use(initReactI18next) // passes i18n down to react-i18next
.init({
resources,
fallbackLng: "en", // use en if detected lng is not available
lng: Cookies.get("language") || "en", // language to use, more information here: https://www.i18next.com/overview/configuration-options#languages-namespaces-resources
// you can use the i18n.changeLanguage function to change the language manually: https://www.i18next.com/overview/api#changelanguage
// if you're using a language detector, do not define the lng option
interpolation: {
escapeValue: false, // react already safes from xss
},
});
export default i18n;

View File

@@ -0,0 +1,64 @@
{
"greeting": "Willkommen zurück, ",
"err_pw_change": "Passwortänderung fehlgeschlagen",
"pw_mismatch": "Bitte überprüfen Sie Ihre Eingaben",
"pw_success": "Passwort erfolgreich geändert",
"pw_success_desc": "Ihr Passwort wurde erfolgreich geändert.",
"create-loan": "Ausleihe erstellen",
"my-loans": "Meine Ausleihen",
"change-password": "Passwort ändern",
"help": "Hilfe",
"source-code": "Quellcode",
"logout": "Abmelden",
"old-password": "Altes Passwort",
"new-password": "Neues Passwort",
"confirm-password": "Neues Passwort wiederholen",
"cancel": "Abbrechen",
"save": "Speichern",
"start-date": "Startdatum",
"end-date": "Enddatum",
"missing-fields": "Fehlende Eingaben",
"missing-fields-desc": "Bitte Start- und Enddatum angeben.",
"error": "Fehler",
"unknown-error": "Unbekannter Frontend Fehler",
"get-borrowable-items": "Verfügbare Gegenstände abrufen",
"loading": "Laden...",
"item": "Gegenstand",
"success": "Erfolg",
"loan-success": "Ausleihe erfolgreich erstellt",
"error-by-loading": "Fehler beim Laden",
"unexpected-date-format_loan": "Unerwartetes Datumsformat erhalten. (Ausleihen)",
"unexpected-date-format_device": "Unerwartetes Datumsformat erhalten. (Gerät)",
"error-fetching-loans": "Die Ausleihen konnten nicht abgerufen werden.",
"all-loans": "Alle Ausleihen",
"username": "Benutzername",
"rented-items": "Ausgeliehene Gegenstände",
"return-date": "Rückgabedatum",
"take-date": "Abholdatum",
"no-loans-found": "Keine Ausleihen vorhanden.",
"all-devices": "Alle Geräte",
"rent-role": "Ausleihrolle",
"legend": "Legende",
"in-locker": "Im Schließfach",
"not-in-locker": "Nicht im Schließfach",
"login-failed": "Anmeldung fehlgeschlagen",
"login": "Anmelden",
"enter-credentials": "Bitte unten Ihre Anmeldedaten eingeben.",
"password": "Passwort",
"logout-success": "Erfolgreich abgemeldet",
"logout-success-desc": "Sie wurden erfolgreich abgemeldet.",
"network-error-fetching-loans": "Netzwerkfehler beim Laden der Ausleihen.",
"error-deleting-loan": "Die Ausleihe konnte nicht gelöscht werden.",
"loan-deletion-success": "Die Ausleihe wurde erfolgreich gelöscht.",
"network-error-deleting-loan": "Netzwerkfehler beim Löschen der Ausleihe.",
"loan-code": "Ausleihcode",
"devices": "Geräte",
"actions": "Aktionen",
"sure": "Sind Sie sicher?",
"sure-delete-loan-0": "Möchten Sie die Ausleihe mit dem ",
"sure-delete-loan-1": " Ausleihcode wirklich löschen?",
"sure-delete-loan-2": "Für den Admin bleibt sie weiterhin sichtbar.",
"delete": "Löschen",
"change-language": "Sprache ändern",
"timezone-info": "Die angezeigten Daten und Uhrzeiten werden in deutscher Zeitzone dargestellt und müssen auch so eingegeben werden."
}

View File

@@ -0,0 +1,64 @@
{
"greeting": "Welcome back, ",
"err_pw_change": "Password change failed",
"pw_mismatch": "Please check your input",
"pw_success": "Password changed successfully",
"pw_success_desc": "Your password was changed successfully.",
"create-loan": "Create loan",
"my-loans": "My loans",
"change-password": "Change password",
"help": "Help",
"source-code": "Source code",
"logout": "Log out",
"old-password": "Old password",
"new-password": "New password",
"confirm-password": "Repeat new password",
"cancel": "Cancel",
"save": "Save",
"start-date": "Start date",
"end-date": "End date",
"missing-fields": "Missing fields",
"missing-fields-desc": "Please provide start and end date.",
"error": "Error",
"unknown-error": "Unknown frontend error",
"get-borrowable-items": "Fetch available items",
"loading": "Loading...",
"item": "Item",
"success": "Success",
"loan-success": "Loan created successfully",
"error-by-loading": "Error while loading",
"unexpected-date-format_loan": "Unexpected date format received. (Loans)",
"unexpected-date-format_device": "Unexpected date format received. (Device)",
"error-fetching-loans": "The loans could not be retrieved.",
"all-loans": "All loans",
"username": "Username",
"rented-items": "Borrowed items",
"return-date": "Return date",
"take-date": "Collection date",
"no-loans-found": "No loans found.",
"all-devices": "All devices",
"rent-role": "Loan role",
"legend": "Legend",
"in-locker": "In locker",
"not-in-locker": "Not in locker",
"login-failed": "Login failed",
"login": "Log in",
"enter-credentials": "Please enter your credentials below.",
"password": "Password",
"logout-success": "Successfully logged out",
"logout-success-desc": "You have been logged out successfully.",
"network-error-fetching-loans": "Network error while loading loans.",
"error-deleting-loan": "The loan could not be deleted.",
"loan-deletion-success": "The loan was deleted successfully.",
"network-error-deleting-loan": "Network error while deleting the loan.",
"loan-code": "Loan code",
"devices": "Devices",
"actions": "Actions",
"sure": "Are you sure?",
"sure-delete-loan-0": "Do you really want to delete the loan with the ",
"sure-delete-loan-1": " loan code?",
"sure-delete-loan-2": "It will remain visible to the admin.",
"delete": "Delete",
"change-language": "Change language",
"timezone-info": "The displayed dates and times are shown in Berlin timezone and must also be entered as such."
}

View File

@@ -5,6 +5,7 @@
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
@@ -21,7 +22,12 @@
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
"noUncheckedSideEffectImports": true,
/* Path aliases */
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}

View File

@@ -4,6 +4,7 @@
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */

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

@@ -1,7 +1,73 @@
# Borrow System
**You have reached the `debian12` branch.**
![React](https://img.shields.io/badge/React-20232A?logo=react&logoColor=61DAFB)
![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?logo=typescript&logoColor=white)
![Vite](https://img.shields.io/badge/Vite-646CFF?logo=vite&logoColor=white)
![TailwindCSS](https://img.shields.io/badge/Tailwind_CSS-38B2AC?logo=tailwind-css&logoColor=white)
![Node.js](https://img.shields.io/badge/Node.js-339933?logo=node.js&logoColor=white)
![Express](https://img.shields.io/badge/Express-000000?logo=express&logoColor=white)
![MySQL](https://img.shields.io/badge/MySQL-4479A1?logo=mysql&logoColor=white)
![Docker](https://img.shields.io/badge/Docker-2496ED?logo=docker&logoColor=white)
![JWT](https://img.shields.io/badge/JWT-000000?logo=jsonwebtokens&logoColor=white)
Here you will find the source code of exactly the application that I have hosted.
A small fullstack system to log in, view available items, reserve them for a time window, and manage personal loans.
The main branch or the branch that I am developing on, is the `dev` branch.
- Frontend: React + TypeScript + Vite + Tailwind CSS
- Backend: Node.js + Express + MySQL + JWT (jose)
- Orchestration: Docker Compose (backend + MySQL)
## Contents
- Frontend: [frontend/](frontend)
- Vite/Tailwind config: [frontend/vite.config.ts](frontend/vite.config.ts), [frontend/tailwind.config.js](frontend/tailwind.config.js)
- App entry: [frontend/src/main.tsx](frontend/src/main.tsx), [frontend/src/App.tsx](frontend/src/App.tsx)
- UI: [frontend/src/layout/Layout.tsx](frontend/src/layout/Layout.tsx), [frontend/src/components](frontend/src/components)
- Data/utilities: [frontend/src/utils/fetchData.ts](frontend/src/utils/fetchData.ts), [frontend/src/utils/userHandler.ts](frontend/src/utils/userHandler.ts), [frontend/src/utils/toastify.ts](frontend/src/utils/toastify.ts)
- Backend: [backend/](backend)
- Server: [backend/server.js](backend/server.js)
- Routes: [backend/routes/api.js](backend/routes/api.js), [backend/routes/apiV2.js](backend/routes/apiV2.js)
- DB + services: [backend/services/database.js](backend/services/database.js), [backend/services/tokenService.js](backend/services/tokenService.js)
- Schema/seed: [backend/scheme.sql](backend/scheme.sql)
- Docs: [docs/](docs)
- API docs (see below): [docs/backend_API_docs/README.md](docs/backend_API_docs/README.md)
## Features (highlevel)
- Auth via JWT (login -> token cookie) using the backend route in [backend/routes/api.js](backend/routes/api.js).
- After login, the app loads items, loans, and user loans and keeps them in localStorage.
- Choose a date range to fetch borrowable items, select items, and create a loan.
- Manage personal loans list (and delete a loan).
Key frontend utilities:
- [`utils.fetchData.fetchAllData`](frontend/src/utils/fetchData.ts): loads items, loans, and user loans after login.
- [`utils.fetchData.getBorrowableItems`](frontend/src/utils/fetchData.ts): fetches borrowable items for the selected time range.
- [`utils.userHandler.createLoan`](frontend/src/utils/userHandler.ts): creates a new loan for selected items.
- [`utils.userHandler.handleDeleteLoan`](frontend/src/utils/userHandler.ts): deletes a loan and syncs local state.
- [`utils.toastify.myToast`](frontend/src/utils/toastify.ts): toast notifications.
UI flow (main screens):
- Period selection: [frontend/src/components/Form1.tsx](frontend/src/components/Form1.tsx)
- Borrowable items + selection: [frontend/src/components/Form2.tsx](frontend/src/components/Form2.tsx)
- User loans table: [frontend/src/components/Form4.tsx](frontend/src/components/Form4.tsx)
## Development
- Scripts: see [frontend/package.json](frontend/package.json) and [backend/package.json](backend/package.json)
- Frontend: `npm run dev`, `npm run build`, `npm run preview`, `npm run lint`
- Backend: `npm start`
- Linting: ESLint configured via [frontend/eslint.config.js](frontend/eslint.config.js)
- TypeScript configs: [frontend/tsconfig.app.json](frontend/tsconfig.app.json), [frontend/tsconfig.node.json](frontend/tsconfig.node.json)
## Configuration notes
- Vite/Tailwind integration via [frontend/vite.config.ts](frontend/vite.config.ts) and `@tailwindcss/vite`; CSS entry uses `@import "tailwindcss"` in [frontend/src/index.css](frontend/src/index.css).
- Toasts wired in [frontend/src/main.tsx](frontend/src/main.tsx) with `react-toastify`.
- Local state is stored in `localStorage` keys: `allItems`, `allLoans`, `userLoans`, `borrowableItems`. Crosscomponent updates are signaled via window events from [`utils.fetchData`](frontend/src/utils/fetchData.ts).
## API documentation
Refer to the dedicated API docs:
`docs/backend_API_docs/README.md`

View File

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

View File

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

View File

@@ -3,7 +3,6 @@ import { useEffect } from "react";
import Dashboard from "./Dashboard";
import Login from "./Login";
import Cookies from "js-cookie";
import Landingpage from "@/components/API/Landingpage";
const API_BASE =
(import.meta as any).env?.VITE_BACKEND_URL ||
@@ -12,16 +11,8 @@ const API_BASE =
const Layout: React.FC = () => {
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [showAPI, setShowAPI] = useState(false);
useEffect(() => {
const path = window.location.pathname.replace(/\/+$/, ""); // remove trailing slash
if (path === "/api") {
setShowAPI(true);
console.log("signal");
return;
}
if (Cookies.get("token")) {
const verifyToken = async () => {
const response = await fetch(`${API_BASE}/api/verifyToken`, {
@@ -48,14 +39,6 @@ const Layout: React.FC = () => {
setIsLoggedIn(false);
};
if (showAPI) {
return (
<main>
<Landingpage />
</main>
);
}
return (
<main>
{isLoggedIn ? (

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

@@ -149,7 +149,7 @@ const ItemTable: React.FC = () => {
</HStack>
{/* End action toolbar */}
<Heading marginBottom={4} size="md">
<Heading marginBottom={4} size="2xl">
Gegenstände
</Heading>
{isError && (

View File

@@ -54,6 +54,7 @@ const LoanTable: React.FC = () => {
returned_date: string;
created_at: string;
loaned_items_name: string[];
deleted: boolean;
};
useEffect(() => {
@@ -108,9 +109,13 @@ const LoanTable: React.FC = () => {
</HStack>
{/* End action toolbar */}
<Heading marginBottom={4} size="md">
<Heading marginBottom={4} size="2xl">
Ausleihen
</Heading>
<Text>
Die Ausleihen die rot sind, wurden gelöscht und sind nur für den Admin
sichtbar.
</Text>
{isError && (
<MyAlert
@@ -163,7 +168,7 @@ const LoanTable: React.FC = () => {
</Table.Header>
<Table.Body>
{items.map((item) => (
<Table.Row key={item.id}>
<Table.Row color={item.deleted ? "red" : "white"} key={item.id}>
<Table.Cell>{item.id}</Table.Cell>
<Table.Cell>{item.username}</Table.Cell>
<Table.Cell>

View File

@@ -144,7 +144,7 @@ const UserTable: React.FC = () => {
</HStack>
{/* End action toolbar */}
<Heading marginBottom={4} size="md">
<Heading marginBottom={4} size="2xl">
Benutzer
</Heading>
{changePWform && (

View File

@@ -8,13 +8,9 @@ export default defineConfig({
plugins: [react(), svgr(), tailwindcss(), tsconfigPaths()],
server: {
host: "0.0.0.0",
allowedHosts: ["admin.insta.the1s.de"],
port: 8103,
watch: { usePolling: true },
hmr: {
host: "admin.insta.the1s.de",
port: 8103,
protocol: "wss",
port: 8003,
watch: {
usePolling: true,
},
},
});

View File

@@ -7,6 +7,6 @@ RUN npm install
COPY . .
EXPOSE 8102
EXPOSE 8002
CMD ["npm", "start"]

8
backend/info.json Normal file
View File

@@ -0,0 +1,8 @@
{
"backend-info": {
"version": "v2.0 (dev)"
},
"frontend-info": {
"version": "v2.0 (dev)"
}
}

View File

@@ -26,6 +26,7 @@ import {
createAPIentry,
deleteAPKey,
getLoanInfoWithID,
SETdeleteLoanFromDatabase,
} from "../services/database.js";
import { authenticate, generateToken } from "../services/tokenService.js";
const router = express.Router();
@@ -261,6 +262,16 @@ router.delete("/deleteLoan/:id", authenticate, async (req, res) => {
}
});
router.delete("/SETdeleteLoan/:id", authenticate, async (req, res) => {
const loanId = req.params.id;
const result = await SETdeleteLoanFromDatabase(loanId);
if (result.success) {
res.status(200).json({ message: "Loan deleted successfully" });
} else {
res.status(500).json({ message: "Failed to delete loan" });
}
});
router.post("/borrowableItems", authenticate, async (req, res) => {
const { startDate, endDate } = req.body || {};
if (!startDate || !endDate) {
@@ -432,7 +443,7 @@ router.delete("/deleteUser/:id", authenticate, async (req, res) => {
});
router.get("/verifyToken", authenticate, async (req, res) => {
res.status(200).json({ message: "Token is valid" });
res.status(200).json({ message: "Token is valid", user: req.user });
});
router.post("/editUser/:id", authenticate, async (req, res) => {

View File

@@ -1,99 +0,0 @@
-- All necessary tables for the borrowing system
-- IMPORTANT: You need mySQL version 8.0 or newer!
CREATE TABLE `users` (
`id` int NOT NULL AUTO_INCREMENT,
`username` varchar(100) NOT NULL,
`password` varchar(255) NOT NULL,
`role` int DEFAULT NULL,
`entry_created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `username` (`username`)
);
CREATE TABLE `admins` (
`id` int NOT NULL AUTO_INCREMENT,
`username` varchar(100) NOT NULL,
`password` varchar(255) NOT NULL,
`first_name` varchar(255) NOT NULL,
`last_name` varchar(255) NOT NULL,
`entry_created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `username` (`username`)
);
CREATE TABLE `loans` (
`id` int NOT NULL AUTO_INCREMENT,
`username` varchar(100) NOT NULL,
`loan_code` int NOT NULL,
`start_date` timestamp NOT NULL,
`end_date` timestamp NOT NULL,
`take_date` timestamp NULL DEFAULT NULL,
`returned_date` timestamp NULL DEFAULT NULL,
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`loaned_items_id` json NOT NULL DEFAULT ('[]'),
`loaned_items_name` json NOT NULL DEFAULT ('[]'),
PRIMARY KEY (`id`),
UNIQUE KEY `loan_code` (`loan_code`)
);
CREATE TABLE `items` (
`id` int NOT NULL AUTO_INCREMENT,
`item_name` varchar(255) NOT NULL,
`can_borrow_role` INT NOT NULL,
`inSafe` tinyint(1) NOT NULL DEFAULT '1',
`entry_created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `item_name` (`item_name`)
);
CREATE TABLE `lockers` (
`id` int NOT NULL AUTO_INCREMENT,
`item` varchar(255) NOT NULL,
`locker_number` int NOT NULL,
`entry_created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `item` (`item`),
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
('DJI 1er Mikro', 4, 1),
('DJI 2er Mikro 1', 4, 1),
('DJI 2er Mikro 2', 4, 1),
('Rode Richt Mikrofon', 2, 1),
('Kamera Stativ', 1, 0),
('SONY Kamera - inkl. Akkus und Objektiv', 1, 1),
('MacBook inkl. Adapter', 2, 0),
('SD Karten', 3, 0),
('Kameragimbal', 1, 0),
('ATEM MINI PRO', 1, 1),
('Handygimbal', 4, 0),
('Kameralüfter', 1, 1),
('Kleine Kamera 1 - inkl. Objektiv', 2, 1),
('Kleine Kamera 2 - inkl. Objektiv', 2, 1);
INSERT INTO `lockers` (`item`, `locker_number`) VALUES
('DJI 1er Mikro', 1),
('DJI 2er Mikro 1', 2),
('DJI 2er Mikro 2', 3),
('Rode Richt Mikrofon', 4),
('Kamera Stativ', 5),
('SONY Kamera - inkl. Akkus und Objektiv', 6),
('MacBook inkl. Adapter', 7),
('SD Karten', 8),
('Kameragimbal', 9),
('ATEM MINI PRO', 10),
('Handygimbal', 11),
('Kameralüfter', 12),
('Kleine Kamera 1 - inkl. Objektiv', 13),
('Kleine Kamera 2 - inkl. Objektiv', 14);

View File

@@ -5,7 +5,8 @@ import apiRouter from "./routes/api.js";
import apiRouterV2 from "./routes/apiV2.js";
env.config();
const app = express();
const port = 8102;
const port = 8002;
import serverInfo from "./info.json" assert { type: "json" }
app.use(cors());
// Increase body size limits to support large CSV JSON payloads
@@ -20,6 +21,10 @@ app.get("/", (req, res) => {
res.render("index.ejs");
});
app.get("/server-info", async (req, res) => {
res.status(200).json(serverInfo);
});
app.listen(port, () => {
console.log(`Server is running on port: ${port}`);
});

View File

@@ -8,7 +8,6 @@ const pool = mysql
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
port: process.env.DB_PORT,
})
.promise();
@@ -127,9 +126,10 @@ export const getLoansFromDatabase = async () => {
};
export const getUserLoansFromDatabase = async (username) => {
const [result] = await pool.query("SELECT * FROM loans WHERE username = ?;", [
username,
]);
const [result] = await pool.query(
"SELECT * FROM loans WHERE username = ? AND deleted = 0;",
[username]
);
if (result.length > 0) {
return { success: true, data: result };
} else if (result.length == 0) {
@@ -150,6 +150,18 @@ export const deleteLoanFromDatabase = async (loanId) => {
}
};
export const SETdeleteLoanFromDatabase = async (loanId) => {
const [result] = await pool.query(
"UPDATE loans SET deleted = 1 WHERE id = ?;",
[loanId]
);
if (result.affectedRows > 0) {
return { success: true };
} else {
return { success: false };
}
};
export const getBorrowableItemsFromDatabase = async (
startDate,
endDate,
@@ -167,6 +179,7 @@ export const getBorrowableItemsFromDatabase = async (
FROM loans l
JOIN JSON_TABLE(l.loaned_items_id, '$[*]' COLUMNS (item_id INT PATH '$')) jt
WHERE jt.item_id = i.id
AND l.deleted = 0
AND l.start_date < ?
AND COALESCE(l.returned_date, l.end_date) > ?
);
@@ -257,6 +270,7 @@ export const createLoanInDatabase = async (
JOIN JSON_TABLE(l.loaned_items_id, '$[*]' COLUMNS (item_id INT PATH '$')) jt
ON TRUE
WHERE jt.item_id IN (?)
AND l.deleted = 0
AND l.start_date < ?
AND COALESCE(l.returned_date, l.end_date) > ?
`,
@@ -337,7 +351,6 @@ export const createLoanInDatabase = async (
};
// These functions are only temporary, and will be deleted when the full bin is set up.
export const onTake = async (loanId) => {
const [items] = await pool.query(
"SELECT loaned_items_id FROM loans WHERE id = ?",
@@ -393,6 +406,7 @@ export const onReturn = async (loanId) => {
}
return { success: false };
};
// Temporary functions end here.
export const loginAdmin = async (username, password) => {
const [result] = await pool.query(

12
backendV2/Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM node:20-alpine
WORKDIR /backendV2
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 8004
CMD ["npm", "start"]

8
backendV2/info.json Normal file
View File

@@ -0,0 +1,8 @@
{
"backend-info": {
"version": "v2.0 (dev)"
},
"frontend-info": {
"version": "v2.0 (dev)"
}
}

1105
backendV2/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
backendV2/package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "backendv2",
"version": "1.0.0",
"main": "server.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node server.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^17.2.1",
"ejs": "^3.1.10",
"express": "^5.1.0",
"jose": "^6.0.12",
"mysql2": "^3.14.3",
"nodemailer": "^7.0.6"
}
}

View File

View File

View File

BIN
backendV2/scheme.xlsx Normal file

Binary file not shown.

View File

@@ -0,0 +1,91 @@
-- MUST BE UPDATED BEFORE USE
USE borrow_system_new;
-- Optional: keep insert order predictable
SET time_zone = '+00:00';
-- Users
INSERT INTO users (username, password, first_name, last_name, role, is_admin)
VALUES
('alice', 'password123', 'Alice', 'Andersen', 1, false),
('bob', 'password123', 'Bob', 'Berg', 2, false),
('carol', 'password123', 'Carol', 'Christie', 2, false),
('dave', 'password123', 'Dave', 'Dawson', 1, false),
('eve', 'password123', 'Eve', 'Evans', 1, false),
('admin', 'password123', 'Admin', 'User', 3, true);
-- Items
INSERT INTO items (item_name, can_borrow_role, in_safe, last_borrowed_person, currently_borrowing)
VALUES
('Canon EOS 90D Camera', 1, false, 'bob', 'alice'),
('Rode NT1 Microphone', 1, true, 'dave', NULL),
('MacBook Pro 13', 2, false, 'bob', 'carol'),
('Tripod Manfrotto', 1, false, 'carol', 'alice'),
('LED Panel Aputure', 1, true, NULL, NULL),
('Zoom H6 Recorder', 1, true, 'dave', NULL),
('Wacom Intuos Tablet', 1, true, NULL, NULL),
('DJI Ronin-S Gimbal', 2, true, NULL, NULL),
('Sony A7 III Body', 2, false, 'carol', 'eve'),
('Sigma 24-70mm Lens', 2, false, 'carol', 'eve');
-- Capture item IDs for JSON arrays
SET @id_canon = (SELECT id FROM items WHERE item_name='Canon EOS 90D Camera');
SET @id_rode = (SELECT id FROM items WHERE item_name='Rode NT1 Microphone');
SET @id_mac13 = (SELECT id FROM items WHERE item_name='MacBook Pro 13');
SET @id_tripod = (SELECT id FROM items WHERE item_name='Tripod Manfrotto');
SET @id_led = (SELECT id FROM items WHERE item_name='LED Panel Aputure');
SET @id_zoom = (SELECT id FROM items WHERE item_name='Zoom H6 Recorder');
SET @id_tablet = (SELECT id FROM items WHERE item_name='Wacom Intuos Tablet');
SET @id_ronin = (SELECT id FROM items WHERE item_name='DJI Ronin-S Gimbal');
SET @id_sony = (SELECT id FROM items WHERE item_name='Sony A7 III Body');
SET @id_sigma = (SELECT id FROM items WHERE item_name='Sigma 24-70mm Lens');
-- Loans
INSERT INTO loans (
username, loan_code, start_date, end_date, take_date, returned_date, loaned_items_id, loaned_items_name, deleted
) VALUES
-- Ongoing loan: Alice has Canon + Tripod
('alice', 100001, '2025-10-01 09:00:00', '2025-10-08 17:00:00', '2025-10-01 09:15:00', NULL,
JSON_ARRAY(@id_canon, @id_tripod),
JSON_ARRAY('Canon EOS 90D Camera','Tripod Manfrotto'),
false
),
-- Ongoing loan: Carol has MacBook Pro 13
('carol', 100002, '2025-10-03 10:00:00', '2025-10-10 16:00:00', '2025-10-03 10:05:00', NULL,
JSON_ARRAY(@id_mac13),
JSON_ARRAY('MacBook Pro 13'),
false
),
-- Returned loan: Dave had Zoom + Rode
('dave', 100003, '2025-09-10 08:30:00', '2025-09-12 16:00:00', '2025-09-10 08:45:00', '2025-09-12 15:40:00',
JSON_ARRAY(@id_zoom, @id_rode),
JSON_ARRAY('Zoom H6 Recorder','Rode NT1 Microphone'),
false
),
-- Cancelled/deleted booking (never taken): Bob reserved Tablet
('bob', 100004, '2025-10-05 09:00:00', '2025-10-06 09:00:00', NULL, NULL,
JSON_ARRAY(@id_tablet),
JSON_ARRAY('Wacom Intuos Tablet'),
true
),
-- Ongoing loan, likely overdue: Eve has Sony + Sigma
('eve', 100005, '2025-10-15 11:00:00', '2025-10-20 12:00:00', '2025-10-15 11:10:00', NULL,
JSON_ARRAY(@id_sony, @id_sigma),
JSON_ARRAY('Sony A7 III Body','Sigma 24-70mm Lens'),
false
),
-- Completed single-day loan: Bob used LED panel
('bob', 100006, '2025-09-20 13:00:00', '2025-09-20 18:00:00', '2025-09-20 13:05:00', '2025-09-20 17:30:00',
JSON_ARRAY(@id_led),
JSON_ARRAY('LED Panel Aputure'),
false
);
-- API keys
INSERT INTO apiKeys (api_key, username)
VALUES
(71002123, 'alice'),
(71002124, 'bob'),
(71002125, 'carol'),
(99999999, 'admin');

60
backendV2/schemeV2.sql Normal file
View File

@@ -0,0 +1,60 @@
use borrow_system_new;
CREATE TABLE users (
id int NOT NULL AUTO_INCREMENT,
username varchar(100) NOT NULL UNIQUE,
password varchar(255) NOT NULL,
first_name varchar(255) NOT NULL,
last_name varchar(255) NOT NULL,
role int NOT NULL,
is_admin bool NOT NULL DEFAULT false,
entry_created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP,
entry_updated_at timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id)
) ENGINE=InnoDB;
CREATE TABLE loans (
id int NOT NULL AUTO_INCREMENT,
username varchar(100) NOT NULL,
loan_code int NOT NULL UNIQUE,
start_date timestamp NOT NULL,
end_date timestamp NOT NULL,
take_date timestamp NULL DEFAULT NULL,
returned_date timestamp NULL DEFAULT NULL,
created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP,
loaned_items_id json NOT NULL DEFAULT ('[]'),
loaned_items_name json NOT NULL DEFAULT ('[]'),
deleted bool NOT NULL DEFAULT false,
note varchar(500) DEFAULT NULL,
PRIMARY KEY (id),
CONSTRAINT fk_loans_username
FOREIGN KEY (username) REFERENCES users(username)
ON UPDATE CASCADE
ON DELETE RESTRICT
) ENGINE=InnoDB;
CREATE TABLE items (
id int NOT NULL AUTO_INCREMENT,
item_name varchar(255) NOT NULL UNIQUE,
can_borrow_role INT NOT NULL,
in_safe bool NOT NULL DEFAULT true,
entry_created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP,
entry_updated_at timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
last_borrowed_person varchar(255) DEFAULT NULL,
currently_borrowing varchar(255) DEFAULT NULL,
PRIMARY KEY (id)
);
CREATE TABLE apiKeys (
id int NOT NULL AUTO_INCREMENT,
api_key CHAR(15) NOT NULL UNIQUE,
username VARCHAR(100) NOT NULL,
last_used_at timestamp DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
entry_created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
CONSTRAINT chk_api_key_len CHECK (CHAR_LENGTH(api_key) = 15),
CONSTRAINT fk_apikeys_username
FOREIGN KEY (username) REFERENCES users(username)
ON UPDATE CASCADE
ON DELETE RESTRICT
) ENGINE=InnoDB;

22
backendV2/server.js Normal file
View File

@@ -0,0 +1,22 @@
import express from "express";
import cors from "cors";
import env from "dotenv";
env.config();
const app = express();
const port = 8002;
app.use(cors());
// Increase body size limits to support large CSV JSON payloads
app.use(express.urlencoded({ extended: true, limit: "10mb" }));
app.set("view engine", "ejs");
app.use(express.json({ limit: "10mb" }));
app.listen(port, () => {
console.log(`Server is running on port: ${port}`);
});
// error handling code
app.use((err, req, res, next) => {
// Log the error stack and send a generic error response
console.error(err.stack);
res.status(500).send("Something broke!");
});

View File

@@ -0,0 +1,65 @@
import { SignJWT, jwtVerify } from "jose";
import env from "dotenv";
import { getAllApiKeys } from "./database";
env.config();
const secretKey = process.env.SECRET_KEY;
if (!secretKey) {
throw new Error("Missing SECRET_KEY environment variable");
}
const secret = new TextEncoder().encode(secretKey);
export async function generateToken(payload) {
return await new SignJWT(payload)
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("2h")
.sign(secret);
}
export async function authenticate(req, res, next) {
const authHeader = req.headers["authorization"];
const apiKey = req.params.apiKey;
if (authHeader) {
const parts = authHeader.split(" ");
const scheme = parts[0];
const token = parts[1];
if (!/^Bearer$/i.test(scheme) || !token) {
return res.status(401).json({ message: "Unauthorized" });
}
try {
const payload = await verifyToken(token);
req.user = payload;
return next();
} catch {
return res.sendStatus(403); // present token invalid
}
} else if (apiKey) {
try {
await verifyAPIKey(apiKey);
return next();
} catch {
return res.sendStatus(403); // API Key invalid
}
} else {
return res.status(401).json({ message: "Unauthorized" }); // no credentials
}
}
async function verifyAPIKey(apiKey) {
const apiKeys = await getAllApiKeys();
const validKey = apiKeys.find((k) => k.key === apiKey);
if (!validKey) {
throw new Error("Invalid API Key");
}
}
async function verifyToken(token) {
const { payload } = await jwtVerify(token, secret, {
algorithms: ["HS256"],
});
return payload;
}

View File

@@ -0,0 +1,551 @@
import mysql from "mysql2";
import dotenv from "dotenv";
dotenv.config();
const pool = mysql
.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
})
.promise();
export const loginFunc = async (username, password) => {
const [result] = await pool.query(
"SELECT * FROM users WHERE username = ? AND password = ?",
[username, password]
);
if (result.length > 0) return { success: true, data: result[0] };
return { success: false };
};
export const getItemsFromDatabaseV2 = async () => {
const [rows] = await pool.query("SELECT * FROM items;");
if (rows.length > 0) {
return { success: true, data: rows };
}
return { success: false };
};
export const getLoanByCodeV2 = async (loan_code) => {
const [result] = await pool.query(
"SELECT * FROM loans WHERE loan_code = ?;",
[loan_code]
);
if (result.length > 0) {
return { success: true, data: result[0] };
}
return { success: false };
};
export const changeInSafeStateV2 = async (itemId) => {
const [result] = await pool.query(
"UPDATE items SET inSafe = NOT inSafe WHERE id = ?",
[itemId]
);
if (result.affectedRows > 0) {
return { success: true };
}
return { success: false };
};
export const setReturnDateV2 = async (loanCode) => {
const [items] = await pool.query(
"SELECT loaned_items_id FROM loans WHERE loan_code = ?",
[loanCode]
);
if (items.length === 0) return { success: false };
const itemIds = Array.isArray(items[0].loaned_items_id)
? items[0].loaned_items_id
: JSON.parse(items[0].loaned_items_id || "[]");
const [setItemStates] = await pool.query(
"UPDATE items SET inSafe = 1 WHERE id IN (?)",
[itemIds]
);
const [result] = await pool.query(
"UPDATE loans SET returned_date = NOW() WHERE loan_code = ?",
[loanCode]
);
if (result.affectedRows > 0 && setItemStates.affectedRows > 0) {
return { success: true };
}
return { success: false };
};
export const setTakeDateV2 = async (loanCode) => {
const [items] = await pool.query(
"SELECT loaned_items_id FROM loans WHERE loan_code = ?",
[loanCode]
);
if (items.length === 0) return { success: false };
const itemIds = Array.isArray(items[0].loaned_items_id)
? items[0].loaned_items_id
: JSON.parse(items[0].loaned_items_id || "[]");
const [setItemStates] = await pool.query(
"UPDATE items SET inSafe = 0 WHERE id IN (?)",
[itemIds]
);
const [result] = await pool.query(
"UPDATE loans SET take_date = NOW() WHERE loan_code = ?",
[loanCode]
);
if (result.affectedRows > 0 && setItemStates.affectedRows > 0) {
return { success: true };
}
return { success: false };
};
export const getItemsFromDatabase = async (role) => {
const sql =
role == 0
? "SELECT * FROM items;"
: "SELECT * FROM items WHERE can_borrow_role >= ?";
const params = role == 0 ? [] : [role];
const [rows] = await pool.query(sql, params);
if (rows.length > 0) {
return { success: true, data: rows };
}
return { success: false };
};
export const getLoansFromDatabase = async () => {
const [rows] = await pool.query("SELECT * FROM loans;");
return { success: true, data: rows.length > 0 ? rows : null };
};
export const getUserLoansFromDatabase = async (username) => {
const [result] = await pool.query(
"SELECT * FROM loans WHERE username = ? AND deleted = 0;",
[username]
);
if (result.length > 0) {
return { success: true, data: result };
} else if (result.length == 0) {
return { success: true, data: "No loans found for this user" };
} else {
return { success: false };
}
};
export const deleteLoanFromDatabase = async (loanId) => {
const [result] = await pool.query("DELETE FROM loans WHERE id = ?;", [
loanId,
]);
if (result.affectedRows > 0) {
return { success: true };
} else {
return { success: false };
}
};
export const SETdeleteLoanFromDatabase = async (loanId) => {
const [result] = await pool.query(
"UPDATE loans SET deleted = 1 WHERE id = ?;",
[loanId]
);
if (result.affectedRows > 0) {
return { success: true };
} else {
return { success: false };
}
};
export const getBorrowableItemsFromDatabase = async (
startDate,
endDate,
role = 0
) => {
// Overlap if: loan.start < end AND effective_end > start
// effective_end is returned_date if set, otherwise end_date
const hasRoleFilter = Number(role) > 0;
const sql = `
SELECT i.*
FROM items i
WHERE ${hasRoleFilter ? "i.can_borrow_role >= ? AND " : ""}NOT EXISTS (
SELECT 1
FROM loans l
JOIN JSON_TABLE(l.loaned_items_id, '$[*]' COLUMNS (item_id INT PATH '$')) jt
WHERE jt.item_id = i.id
AND l.deleted = 0
AND l.start_date < ?
AND COALESCE(l.returned_date, l.end_date) > ?
);
`;
const params = hasRoleFilter
? [role, endDate, startDate]
: [endDate, startDate];
const [rows] = await pool.query(sql, params);
if (rows.length > 0) {
return { success: true, data: rows };
}
return { success: false };
};
export const getLoanInfoWithID = async (loanId) => {
const [rows] = await pool.query("SELECT * FROM loans WHERE id = ?;", [
loanId,
]);
if (rows.length > 0) {
return { success: true, data: rows[0] };
}
return { success: false };
};
export const createLoanInDatabase = async (
username,
startDate,
endDate,
itemIds
) => {
if (!username)
return { success: false, code: "BAD_REQUEST", message: "Missing username" };
if (!Array.isArray(itemIds) || itemIds.length === 0)
return {
success: false,
code: "BAD_REQUEST",
message: "No items provided",
};
if (!startDate || !endDate)
return { success: false, code: "BAD_REQUEST", message: "Missing dates" };
const start = new Date(startDate);
const end = new Date(endDate);
if (
!(start instanceof Date) ||
isNaN(start.getTime()) ||
!(end instanceof Date) ||
isNaN(end.getTime()) ||
start >= end
) {
return {
success: false,
code: "BAD_REQUEST",
message: "Invalid date range",
};
}
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
// Ensure all items exist and collect names
const [itemsRows] = await conn.query(
"SELECT id, item_name FROM items WHERE id IN (?)",
[itemIds]
);
if (!itemsRows || itemsRows.length !== itemIds.length) {
await conn.rollback();
return {
success: false,
code: "BAD_REQUEST",
message: "One or more items not found",
};
}
const itemNames = itemIds
.map(
(id) => itemsRows.find((r) => Number(r.id) === Number(id))?.item_name
)
.filter(Boolean);
// Check availability (no overlap with existing loans)
const [confRows] = await conn.query(
`
SELECT COUNT(*) AS conflicts
FROM loans l
JOIN JSON_TABLE(l.loaned_items_id, '$[*]' COLUMNS (item_id INT PATH '$')) jt
ON TRUE
WHERE jt.item_id IN (?)
AND l.deleted = 0
AND l.start_date < ?
AND COALESCE(l.returned_date, l.end_date) > ?
`,
[itemIds, end, start]
);
if (confRows?.[0]?.conflicts > 0) {
await conn.rollback();
return {
success: false,
code: "CONFLICT",
message: "One or more items are not available in the selected period",
};
}
// Generate unique loan_code (retry a few times)
let loanCode = null;
for (let i = 0; i < 6; i++) {
const candidate = Math.floor(100000 + Math.random() * 899999); // 6 digits
const [exists] = await conn.query(
"SELECT 1 FROM loans WHERE loan_code = ? LIMIT 1",
[candidate]
);
if (exists.length === 0) {
loanCode = candidate;
break;
}
}
if (!loanCode) {
await conn.rollback();
return {
success: false,
code: "SERVER_ERROR",
message: "Failed to generate unique loan code",
};
}
// Insert loan
const [insertRes] = await conn.query(
`
INSERT INTO loans (username, loan_code, start_date, end_date, loaned_items_id, loaned_items_name)
VALUES (?, ?, ?, ?, CAST(? AS JSON), CAST(? AS JSON))
`,
[
username,
loanCode,
// Use DATETIME/TIMESTAMP friendly format
new Date(start).toISOString().slice(0, 19).replace("T", " "),
new Date(end).toISOString().slice(0, 19).replace("T", " "),
JSON.stringify(itemIds.map((n) => Number(n))),
JSON.stringify(itemNames),
]
);
await conn.commit();
return {
success: true,
data: {
id: insertRes.insertId,
loan_code: loanCode,
username,
start_date: start,
end_date: end,
items: itemIds,
item_names: itemNames,
},
};
} catch (err) {
await conn.rollback();
console.error("createLoanInDatabase error:", err);
return {
success: false,
code: "SERVER_ERROR",
message: "Failed to create loan",
};
} finally {
conn.release();
}
};
// These functions are only temporary, and will be deleted when the full bin is set up.
export const onTake = async (loanId) => {
const [items] = await pool.query(
"SELECT loaned_items_id FROM loans WHERE id = ?",
[loanId]
);
if (items.length === 0) return { success: false };
const itemIds = Array.isArray(items[0].loaned_items_id)
? items[0].loaned_items_id
: JSON.parse(items[0].loaned_items_id || "[]");
const [setItemStates] = await pool.query(
"UPDATE items SET inSafe = 0 WHERE id IN (?)",
[itemIds]
);
const [result] = await pool.query(
"UPDATE loans SET take_date = NOW() WHERE id = ?",
[loanId]
);
if (result.affectedRows > 0 && setItemStates.affectedRows > 0) {
return { success: true };
}
return { success: false };
};
export const onReturn = async (loanId) => {
const [items] = await pool.query(
"SELECT loaned_items_id FROM loans WHERE id = ?",
[loanId]
);
if (items.length === 0) return { success: false };
const itemIds = Array.isArray(items[0].loaned_items_id)
? items[0].loaned_items_id
: JSON.parse(items[0].loaned_items_id || "[]");
const [setItemStates] = await pool.query(
"UPDATE items SET inSafe = 1 WHERE id IN (?)",
[itemIds]
);
const [result] = await pool.query(
"UPDATE loans SET returned_date = NOW() WHERE id = ?",
[loanId]
);
if (result.affectedRows > 0 && setItemStates.affectedRows > 0) {
return { success: true };
}
return { success: false };
};
// Temporary functions end here.
export const loginAdmin = async (username, password) => {
const [result] = await pool.query(
"SELECT * FROM admins WHERE username = ? AND password = ?",
[username, password]
);
if (result.length > 0) return { success: true, data: result[0] };
return { success: false };
};
export const getAllUsers = async () => {
const [result] = await pool.query(
"SELECT id, username, role, entry_created_at FROM users"
);
if (result.length > 0) return { success: true, data: result };
return { success: false };
};
export const deleteUserID = async (userId) => {
const [result] = await pool.query("DELETE FROM users WHERE id = ?", [userId]);
if (result.affectedRows > 0) return { success: true };
return { success: false };
};
export const handleEdit = async (userId, username, role) => {
const [result] = await pool.query(
"UPDATE users SET username = ?, role = ? WHERE id = ?",
[username, role, userId]
);
if (result.affectedRows > 0) return { success: true };
return { success: false };
};
export const createUser = async (username, role, password) => {
const [result] = await pool.query(
"INSERT INTO users (username, role, password) VALUES (?, ?, ?)",
[username, role, password]
);
if (result.affectedRows > 0) return { success: true };
return { success: false };
};
export const getAllLoans = async () => {
const [result] = await pool.query("SELECT * FROM loans");
if (result.length > 0) return { success: true, data: result };
return { success: false };
};
export const getAllItems = async () => {
const [result] = await pool.query("SELECT * FROM items");
if (result.length > 0) return { success: true, data: result };
return { success: false };
};
export const deleteItemID = async (itemId) => {
const [result] = await pool.query("DELETE FROM items WHERE id = ?", [itemId]);
if (result.affectedRows > 0) return { success: true };
return { success: false };
};
export const createItem = async (item_name, can_borrow_role) => {
const [result] = await pool.query(
"INSERT INTO items (item_name, can_borrow_role) VALUES (?, ?)",
[item_name, can_borrow_role]
);
if (result.affectedRows > 0) return { success: true };
return { success: false };
};
export const changeUserPassword = async (username, newPassword) => {
const [result] = await pool.query(
"UPDATE users SET password = ? WHERE username = ?",
[newPassword, username]
);
if (result.affectedRows > 0) return { success: true };
return { success: false };
};
export const changeUserPasswordFRONTEND = async (
username,
oldPassword,
newPassword
) => {
const [result] = await pool.query(
"UPDATE users SET password = ? WHERE username = ? AND password = ?",
[newPassword, username, oldPassword]
);
if (result.affectedRows > 0) return { success: true };
return { success: false };
};
export const updateItemByID = async (itemId, item_name, can_borrow_role) => {
const [result] = await pool.query(
"UPDATE items SET item_name = ?, can_borrow_role = ? WHERE id = ?",
[item_name, can_borrow_role, itemId]
);
if (result.affectedRows > 0) return { success: true };
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 };
};

11
backendV2/views/index.ejs Normal file
View File

@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>backend</title>
</head>
<body>
backend
</body>
</html>

View File

@@ -1,45 +1,35 @@
services:
borrow_system-frontend:
container_name: borrow_system-frontend
build: ./frontend
ports:
- "8101:8101"
networks:
- proxynet
- borrow_system-internal
environment:
- CHOKIDAR_USEPOLLING=true
volumes:
- ./frontend:/app
- /app/node_modules
restart: unless-stopped
# borrow_system-frontend:
# container_name: borrow_system-frontend
# build: ./FrontendV2
# ports:
# - "8001:8001"
# environment:
# - CHOKIDAR_USEPOLLING=true
# volumes:
# - ./FrontendV2:/app
# - /app/node_modules
# restart: unless-stopped
admin-frontend:
container_name: admin-frontend
build: ./admin
networks:
- proxynet
- borrow_system-internal
ports:
- "8103:8103"
environment:
- CHOKIDAR_USEPOLLING=true
volumes:
- ./admin:/app
- /app/node_modules
restart: unless-stopped
# admin-frontend:
# container_name: admin-frontend
# build: ./admin
# ports:
# - "8003:8003"
# environment:
# - CHOKIDAR_USEPOLLING=true
# volumes:
# - ./admin:/app
# - /app/node_modules
# restart: unless-stopped
borrow_system-backend:
container_name: borrow_system-backend
build: ./backend
ports:
- "8102:8102"
networks:
- proxynet
- borrow_system-internal
- "8002:8002"
environment:
DB_HOST: mysql
DB_PORT: 3306
DB_USER: root
DB_PASSWORD: ${DB_PASSWORD}
DB_NAME: borrow_system
@@ -62,14 +52,21 @@ services:
- ./mysql-timezone.cnf:/etc/mysql/conf.d/timezone.cnf:ro
ports:
- "3309:3306"
networks:
- borrow_system-internal
mysql-new:
container_name: borrow_system-mysql-new
image: mysql:8.0
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
MYSQL_DATABASE: borrow_system_new
TZ: Europe/Berlin
volumes:
- mysql-data-new:/var/lib/mysql
- ./mysql-timezone.cnf:/etc/mysql/conf.d/timezone.cnf:ro
ports:
- "3310:3306"
volumes:
mysql-data:
networks:
proxynet:
external: true
borrow_system-internal:
external: false
mysql-data-new:

View File

@@ -1 +0,0 @@
@import "tailwindcss";

View File

@@ -1,62 +0,0 @@
import "./App.css";
import Layout from "./layout/Layout";
import { useEffect, useState } from "react";
import Form1 from "./components/Form1";
import Form2 from "./components/Form2";
import Form4 from "./components/Form4";
import LoginForm from "./components/LoginForm";
import Cookies from "js-cookie";
import {
fetchAllData,
ALL_ITEMS_UPDATED_EVENT,
AUTH_LOGOUT_EVENT,
} from "./utils/fetchData";
import { myToast } from "./utils/toastify";
function App() {
const [isLoggedIn, setIsLoggedIn] = useState(false);
useEffect(() => {
const token = Cookies.get("token");
if (token) {
setIsLoggedIn(true);
fetchAllData(token);
}
localStorage.setItem("borrowableItems", JSON.stringify([]));
}, []);
useEffect(() => {
const onAuthLogout = () => {
setIsLoggedIn(false);
};
window.addEventListener(AUTH_LOGOUT_EVENT, onAuthLogout);
return () => window.removeEventListener(AUTH_LOGOUT_EVENT, onAuthLogout);
}, []);
const handleLogout = () => {
Cookies.remove("token");
localStorage.removeItem("allItems");
localStorage.removeItem("allLoans");
localStorage.removeItem("userLoans");
localStorage.removeItem("borrowableItems");
window.dispatchEvent(new Event(ALL_ITEMS_UPDATED_EVENT));
myToast("Logged out successfully!", "success");
setIsLoggedIn(false);
};
return isLoggedIn ? (
<Layout onLogout={handleLogout}>
<div className="space-y-6">
<Form1 />
<div className="h-px bg-slate-200" />
<Form2 />
<div className="h-px bg-slate-200" />
<Form4 />
</div>
</Layout>
) : (
<LoginForm onLogin={() => setIsLoggedIn(true)} />
);
}
export default App;

View File

@@ -1,12 +0,0 @@
import React from "react";
const Footer: React.FC = () => {
return (
<footer className="fixed bottom-0 left-0 text-sm w-full bg-slate-100 text-center py-2 border-t border-slate-200 z-50">
<p>Made with by Theis Gaedigk - Jahrgang 2019</p>
<p>v1.1</p>
</footer>
);
};
export default Footer;

View File

@@ -1,65 +0,0 @@
import React from "react";
import Cookies from "js-cookie";
import { getBorrowableItems } from "../utils/fetchData";
const Form1: React.FC = () => {
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) || "";
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>
);
};
export default Form1;

View File

@@ -1,186 +0,0 @@
import React from "react";
import Cookies from "js-cookie";
import { createLoan, addToRemove, rmFromRemove } from "../utils/userHandler";
import { BORROWABLE_ITEMS_UPDATED_EVENT } from "../utils/fetchData";
interface BorrowItem {
id: number;
item_name: string;
can_borrow_role: string;
inSafe: number;
}
const LOCAL_STORAGE_KEY = "borrowableItems";
function normalizeBorrowable(data: any): BorrowItem[] {
const rawArr = Array.isArray(data)
? data
: Array.isArray(data?.items)
? data.items
: Array.isArray(data?.data)
? data.data
: [];
return rawArr
.map((raw: any) => {
const idRaw =
raw.id ?? raw.item_id ?? raw.itemId ?? raw.itemID ?? raw.itemIdPk;
const id = Number(idRaw);
const item_name = String(raw.item_name ?? raw.name ?? raw.title ?? "");
const can_borrow_role = String(
raw.can_borrow_role ?? raw.role ?? raw.requiredRole ?? ""
);
const inSafeRaw =
raw.inSafe ?? raw.in_safe ?? raw.inLocker ?? raw.isInSafe ?? raw.safe;
const inSafe =
typeof inSafeRaw === "boolean"
? Number(inSafeRaw)
: Number(isNaN(Number(inSafeRaw)) ? 0 : Number(inSafeRaw));
if (!Number.isFinite(id) || !item_name) return null;
return { id, item_name, can_borrow_role, inSafe };
})
.filter(Boolean) as BorrowItem[];
}
function useBorrowableItems() {
const [items, setItems] = React.useState<BorrowItem[]>([]);
const readFromStorage = React.useCallback(() => {
try {
const raw = localStorage.getItem(LOCAL_STORAGE_KEY) || "[]";
const parsed = JSON.parse(raw);
const arr = normalizeBorrowable(parsed);
setItems(arr);
} catch {
setItems([]);
}
}, []);
React.useEffect(() => {
readFromStorage();
const onStorage = (e: StorageEvent) => {
if (e.key === LOCAL_STORAGE_KEY) readFromStorage();
};
window.addEventListener("storage", onStorage);
const onBorrowableUpdated = () => readFromStorage();
window.addEventListener(
BORROWABLE_ITEMS_UPDATED_EVENT,
onBorrowableUpdated
);
return () => {
window.removeEventListener("storage", onStorage);
window.removeEventListener(
BORROWABLE_ITEMS_UPDATED_EVENT,
onBorrowableUpdated
);
};
}, [readFromStorage]);
return items;
}
const Form2: React.FC = () => {
const items = useBorrowableItems();
return (
<div className="space-y-4">
<h2 className="text-lg sm:text-xl font-bold text-slate-900">
2. Gegenstand auswählen
</h2>
{items.length === 0 ? (
<div className="text-slate-700 text-center bg-slate-100 border border-slate-200 rounded-xl p-4">
Keine Gegenstände verfügbar für diesen Zeitraum.
</div>
) : (
<>
{/* Mobile: card list */}
<div className="sm:hidden space-y-2">
{items.map((item) => (
<label
key={item.id}
htmlFor={`item-${item.id}`}
className="flex items-center justify-between gap-3 p-3 rounded-lg border border-slate-200 bg-white shadow-sm"
>
<div className="min-w-0">
<div className="text-sm font-medium text-slate-900 truncate">
{item.item_name}
</div>
<div className="text-xs text-slate-500">
{item.inSafe ? "Verfügbar" : "Nicht im Schließfach"}
</div>
</div>
<input
type="checkbox"
id={`item-${item.id}`}
onChange={(e) => {
if (e.target.checked) addToRemove(item.id);
else rmFromRemove(item.id);
}}
className="h-5 w-5 accent-indigo-600"
/>
</label>
))}
</div>
{/* Desktop: table */}
<div className="hidden sm:block overflow-x-auto rounded-xl border border-slate-200 shadow-sm bg-white">
<table className="min-w-full divide-y divide-slate-200">
<thead className="bg-slate-50">
<tr>
<th className="px-4 py-2 text-left text-xs font-semibold text-slate-700">
Gegenstand
</th>
<th className="px-4 py-2 text-left text-xs font-semibold text-slate-700">
<input type="checkbox" className="invisible" />
</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{items.map((item) => (
<tr key={item.id} className="hover:bg-slate-50">
<td className="px-4 py-2 text-sm font-medium text-slate-900">
{item.item_name}
</td>
<td className="px-4 py-2 text-sm text-slate-700 text-right">
<input
type="checkbox"
onChange={(e) => {
if (e.target.checked) addToRemove(item.id);
else rmFromRemove(item.id);
}}
id={`item-${item.id}`}
className="h-4 w-4 accent-indigo-600"
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
<div className="flex flex-col sm:flex-row gap-3 pt-1">
<button
onClick={() => {
createLoan(
Cookies.get("startDate") ?? "",
Cookies.get("endDate") ?? ""
);
}}
type="button"
className="w-full sm:w-44 bg-indigo-600 text-white font-bold py-2.5 px-4 rounded-lg shadow hover:bg-indigo-700 transition"
>
Ausleihen
</button>
</div>
</div>
);
};
export default Form2;

View File

@@ -1,277 +0,0 @@
import React from "react";
import { Trash, ArrowLeftRight } from "lucide-react";
import { handleDeleteLoan } from "../utils/userHandler";
import { useMutation, useQuery } from "@tanstack/react-query";
import Cookies from "js-cookie";
import { queryClient } from "../utils/queryClient";
import { onTake, onReturn } from "../utils/userHandler";
type Loan = {
id: number;
username: string;
loan_code: number;
start_date: string;
end_date: string;
take_date: string | null;
returned_date: string | null;
created_at: string;
loaned_items_id: number[];
loaned_items_name: string[];
};
const API_BASE =
(import.meta as any).env?.VITE_BACKEND_URL ||
import.meta.env.VITE_BACKEND_URL ||
"http://localhost:8002";
const formatDate = (iso: string | null) => {
if (!iso) return "-";
const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/);
if (!m) return iso;
const [, y, M, d, h, min] = m;
return `${d}.${M}.${y} ${h}:${min}`;
};
async function fetchUserLoans(): Promise<Loan[]> {
const res = await fetch(`${API_BASE}/api/userLoans`, {
method: "GET",
headers: { Authorization: `Bearer ${Cookies.get("token") || ""}` },
});
if (!res.ok) throw new Error("Failed to fetch user loans");
const data = await res.json();
if (data === "No loans found for this user") return [];
return Array.isArray(data) ? (data as Loan[]) : [];
}
const Form4: React.FC = () => {
const { data: userLoans = [], isFetching } = useQuery({
queryKey: ["userLoans"],
queryFn: fetchUserLoans,
});
const deleteMutation = useMutation({
mutationFn: (loanID: number) => handleDeleteLoan(loanID),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["userLoans"] });
},
});
const takeMutation = useMutation({
mutationFn: (loanID: number) => onTake(loanID),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["userLoans"] });
},
});
const returnMutation = useMutation({
mutationFn: (loanID: number) => onReturn(loanID),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["userLoans"] });
},
});
const onDelete = (loanID: number) => deleteMutation.mutate(loanID);
if (isFetching) {
return (
<div className="rounded-xl border border-slate-200 bg-white p-6 text-center text-slate-600 shadow-sm">
<p>Lade Ausleihen</p>
</div>
);
}
if (userLoans.length === 0) {
return (
<div className="rounded-xl border border-slate-200 bg-white p-6 text-center text-slate-600 shadow-sm">
<p>Keine Ausleihen gefunden.</p>
</div>
);
}
return (
<div className="space-y-3">
<p className="text-lg font-semibold tracking-tight text-slate-900">
Meine Ausleihen
</p>
<p className="text-sm text-slate-600">
Tippe auf das Papierkorb-Symbol, um eine Ausleihe zu löschen.
</p>
{/* Mobile: cards */}
<div className="space-y-2 sm:hidden">
{userLoans.map((loan) => (
<div
key={loan.id}
className="rounded-xl border border-slate-200 bg-white p-3 shadow-sm"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-semibold text-slate-900">
Leihcode: <span className="font-mono">{loan.loan_code}</span>
</div>
<div className="mt-1 grid grid-cols-2 gap-x-4 gap-y-1 text-xs text-slate-700">
<div>
<span className="text-slate-500">Start:</span>{" "}
{formatDate(loan.start_date)}
</div>
<div>
<span className="text-slate-500">Ende:</span>{" "}
{formatDate(loan.end_date)}
</div>
<div>
<span className="text-slate-500">Abgeholt:</span>{" "}
{loan.take_date ? (
formatDate(loan.take_date)
) : (
<button
className="inline-flex items-center rounded-md border border-blue-200 bg-blue-50 px-2 py-0.5 text-[11px] font-medium text-blue-700 hover:bg-blue-100 focus:outline-none focus:ring-2 focus:ring-blue-500/40 disabled:opacity-50"
onClick={() => takeMutation.mutate(loan.id)}
disabled={takeMutation.isPending}
>
{takeMutation.isPending ? "..." : "Abholen"}
</button>
)}
</div>
<div>
<span className="text-slate-500">Zurück:</span>{" "}
{loan.returned_date ? (
formatDate(loan.returned_date)
) : (
<button
className="inline-flex items-center rounded-md border border-emerald-200 bg-emerald-50 px-2 py-0.5 text-[11px] font-medium text-emerald-700 hover:bg-emerald-100 focus:outline-none focus:ring-2 focus:ring-emerald-500/40 disabled:opacity-50"
onClick={() => returnMutation.mutate(loan.id)}
disabled={returnMutation.isPending || !loan.take_date}
title={!loan.take_date ? "Erst abholen" : ""}
>
{returnMutation.isPending ? "..." : "Zurückgeben"}
</button>
)}
</div>
</div>
<div className="mt-2 text-xs text-slate-700">
<span className="text-slate-500">Gegenstände:</span>{" "}
{Array.isArray(loan.loaned_items_name)
? loan.loaned_items_name.join(", ")
: "-"}
</div>
</div>
<button
onClick={() => onDelete(loan.id)}
aria-label="Ausleihe löschen"
className="flex items-center justify-center rounded-md p-2 text-slate-600 hover:bg-red-50 hover:text-red-600 focus:outline-none focus:ring-2 focus:ring-red-500/30"
>
<Trash className="h-5 w-5" />
</button>
</div>
</div>
))}
</div>
{/* Desktop: table */}
<div className="hidden sm:block rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="overflow-x-auto">
<table className="table-auto min-w-full text-sm text-slate-700">
<thead className="sticky top-0 z-10 bg-slate-50">
<tr className="border-b border-slate-200">
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-600">
Leihcode
</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-600">
Start
</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-600">
Ende
</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-600">
Abgeholt
</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-600">
Zurückgegeben
</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-600">
Erstellt
</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-600">
Gegenstände
</th>
<th className="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-slate-600">
Aktionen
</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{userLoans.map((loan) => (
<tr key={loan.id} className="odd:bg-white even:bg-slate-50">
<td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums text-slate-900">
{loan.loan_code}
</td>
<td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums text-slate-900">
{formatDate(loan.start_date)}
</td>
<td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums text-slate-900">
{formatDate(loan.end_date)}
</td>
<td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums text-slate-900">
{loan.take_date ? (
formatDate(loan.take_date)
) : (
<button
className="inline-flex items-center rounded-md border border-blue-200 bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700 hover:bg-blue-100 focus:outline-none focus:ring-2 focus:ring-blue-500/40 disabled:opacity-50"
onClick={() => takeMutation.mutate(loan.id)}
disabled={takeMutation.isPending}
>
{takeMutation.isPending ? "..." : "Abholen"}
</button>
)}
</td>
<td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums text-slate-900">
{loan.returned_date ? (
formatDate(loan.returned_date)
) : (
<button
className="inline-flex items-center rounded-md border border-emerald-200 bg-emerald-50 px-2 py-1 text-xs font-medium text-emerald-700 hover:bg-emerald-100 focus:outline-none focus:ring-2 focus:ring-emerald-500/40 disabled:opacity-50"
onClick={() => returnMutation.mutate(loan.id)}
disabled={returnMutation.isPending || !loan.take_date}
title={!loan.take_date ? "Erst abholen" : ""}
>
{returnMutation.isPending ? "..." : "Zurückgeben"}
</button>
)}
</td>
<td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums text-slate-900">
{formatDate(loan.created_at)}
</td>
<td className="px-4 py-3 whitespace-nowrap">
<div className="text-slate-900">
{Array.isArray(loan.loaned_items_name)
? loan.loaned_items_name.join(", ")
: ""}
</div>
</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => onDelete(loan.id)}
aria-label="Ausleihe löschen"
className="inline-flex items-center rounded-md p-2 text-slate-600 hover:bg-red-50 hover:text-red-600 focus:outline-none focus:ring-2 focus:ring-red-500/30"
>
<Trash className="h-4 w-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Scroll hint */}
<div className="border-t border-gray-100 px-4 py-2">
<div className="flex items-center gap-2 text-xs text-gray-500">
<ArrowLeftRight className="h-4 w-4 text-gray-400" />
<span>Hinweis: Horizontal scrollen, um alle Spalten zu sehen.</span>
</div>
</div>
</div>
</div>
);
};
export default Form4;

View File

@@ -1,76 +0,0 @@
import React from "react";
import { changePW } from "../utils/userHandler";
import { myToast } from "../utils/toastify";
type HeaderProps = {
onLogout: () => void;
};
const Header: React.FC<HeaderProps> = ({ onLogout }) => {
const passwordForm = () => {
const oldPW = window.prompt("Altes Passwort");
const newPW = window.prompt("Neues Passwort");
const repeatNewPW = window.prompt("Neues Passwort wiederholen");
if (oldPW && newPW && repeatNewPW) {
if (newPW === repeatNewPW) {
changePW(oldPW, newPW);
} else {
myToast("Die neuen Passwörter stimmen nicht überein.", "error");
}
} else {
myToast("Bitte alle Felder ausfüllen.", "error");
}
};
const btn =
"inline-flex items-center h-9 px-3 rounded-md text-sm font-medium border border-slate-300 bg-white text-slate-700 hover:bg-slate-100 active:bg-slate-200 transition focus:outline-none focus:ring-2 focus:ring-slate-400/50";
return (
<header className="mb-4 sm:mb-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<h1 className="text-2xl sm:text-3xl font-extrabold text-slate-900 tracking-tight">
Gegenstand ausleihen
</h1>
<p className="text-slate-600 mt-1 text-sm sm:text-base">
Schnell und unkompliziert Equipment reservieren
</p>
</div>
<nav
aria-label="Aktionen"
className="flex flex-wrap items-center gap-2"
>
<a
href="https://git.the1s.de/Matthias-Claudius-Schule/borrow-system/src/branch/dev/Docs/HELP.md"
target="_blank"
rel="noreferrer"
className={btn}
>
Hilfe
</a>
<a
href="https://git.the1s.de/Matthias-Claudius-Schule/borrow-system"
target="_blank"
rel="noreferrer"
className={btn}
>
Source Code
</a>
<button type="button" onClick={passwordForm} className={btn}>
Passwort ändern
</button>
<button
type="button"
onClick={onLogout}
className={`${btn} border-rose-300 hover:bg-rose-50`}
>
Logout
</button>
</nav>
</div>
</header>
);
};
export default Header;

View File

@@ -1,75 +0,0 @@
import React from "react";
import Footer from "./Footer";
import { useState } from "react";
import { loginUser } from "../utils/fetchData";
import { myToast } from "../utils/toastify";
type LoginFormProps = {
onLogin: () => void;
};
const LoginForm: React.FC<LoginFormProps> = ({ onLogin }) => {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const result = await loginUser(username, password);
if (result.success) {
onLogin();
} else {
myToast("Login failed. Please check your credentials.", "error");
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-slate-100 p-4">
<div className="w-full max-w-sm bg-white rounded-2xl shadow-md p-6 sm:p-8 border border-slate-200">
<h2 className="text-2xl font-bold text-slate-900 mb-6 text-center">
Login
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor="username"
className="block text-sm font-medium text-slate-700 mb-1"
>
Username
</label>
<input
type="text"
onChange={(e) => setUsername(e.target.value)}
id="username"
className="mt-1 block w-full border border-slate-300 rounded-md shadow-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2.5 bg-white"
required
/>
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-slate-700 mb-1"
>
Password
</label>
<input
onChange={(e) => setPassword(e.target.value)}
type="password"
id="password"
className="mt-1 block w-full border border-slate-300 rounded-md shadow-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2.5 bg-white"
required
/>
</div>
<button
type="submit"
className="w-full bg-indigo-600 text-white font-bold py-2.5 px-4 rounded-md shadow hover:bg-indigo-700 transition"
>
Login
</button>
</form>
</div>
<Footer />
</div>
);
};
export default LoginForm;

View File

@@ -1,17 +0,0 @@
import React from "react";
type ObjectProps = {
title: string;
description: string;
};
const Object: React.FC<ObjectProps> = ({ title, description }) => {
return (
<div className="min-w-0">
<h3 className="text-sm font-semibold text-slate-900">{title}</h3>
<p className="text-xs text-slate-600 line-clamp-2">{description}</p>
</div>
);
};
export default Object;

View File

@@ -1,98 +0,0 @@
import React, { useEffect, useState } from "react";
import Object from "./Object";
import { MonitorSmartphone } from "lucide-react";
import { ALL_ITEMS_UPDATED_EVENT } from "../utils/fetchData";
const Sidebar: React.FC = () => {
const [items, setItems] = useState<any[]>(
JSON.parse(localStorage.getItem("allItems") || "[]")
);
useEffect(() => {
const handler = () => {
const next = JSON.parse(localStorage.getItem("allItems") || "[]");
setItems(next);
};
handler();
window.addEventListener(ALL_ITEMS_UPDATED_EVENT, handler);
return () => window.removeEventListener(ALL_ITEMS_UPDATED_EVENT, handler);
}, []);
const outCount = items.reduce((n, it) => n + (it.inSafe ? 0 : 1), 0);
const sorted = [...items].sort((a, b) => Number(a.inSafe) - Number(b.inSafe));
return (
<aside className="w-full md:w-72 md:h-full flex flex-col rounded-2xl pt-0 px-3 pb-3 sm:pt-0 sm:px-4 sm:pb-4 bg-gradient-to-b from-white to-slate-50 ring-1 ring-slate-200/70 shadow-md overflow-hidden">
<div className="sticky top-0 z-10 -mx-3 sm:-mx-4 px-3 sm:px-4 py-2.5 bg-white/85 backdrop-blur supports-[backdrop-filter]:backdrop-blur border-b border-slate-200/70 text-lg sm:text-xl font-bold mb-3 text-slate-900 tracking-tight flex items-center justify-between gap-2 rounded-t-2xl">
<span className="flex items-center gap-2 min-w-0 flex-1 truncate">
<MonitorSmartphone className="w-5 h-5 text-slate-700 shrink-0" />
<span className="truncate">Geräte</span>
</span>
{outCount > 0 && (
<span className="inline-flex items-center gap-1 whitespace-nowrap tabular-nums text-[10px] sm:text-xs px-2.5 py-1 rounded-full bg-amber-50 text-amber-700 ring-1 ring-amber-200/70 shadow-sm font-medium">
{outCount} außerhalb
</span>
)}
</div>
{/* Scroll area */}
<div className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden">
<div className="flex flex-col gap-3 md:space-y-3">
{sorted.map((item: any) => (
<div
key={item.item_name}
className={`group relative w-full bg-white rounded-xl p-3 sm:p-4 ring-1 ring-slate-200/70 duration-200 hover:shadow-md focus-within:ring-slate-300 ${
item.inSafe
? "border-l-4 border-emerald-400"
: "border-l-4 border-red-400 ring-red-200/60 bg-red-50/40"
}`}
>
<div className="flex items-start gap-3">
<span
className="relative mt-0.5 inline-flex"
aria-hidden="true"
>
{!item.inSafe && (
<span className="absolute inline-flex h-3 w-3 rounded-full bg-red-400 opacity-75 animate-ping"></span>
)}
<span
className={`inline-block w-3 h-3 rounded-full ring-2 ring-white ${
item.inSafe ? "bg-emerald-500" : "bg-red-500"
}`}
title={
item.inSafe ? "Im Schließfach" : "Nicht im Schließfach"
}
aria-label={
item.inSafe ? "Im Schließfach" : "Nicht im Schließfach"
}
/>
</span>
<Object
title={item.item_name}
description={
item.inSafe
? "Aktuell im Schließfach"
: "Aktuell nicht im Schließfach"
}
/>
</div>
</div>
))}
</div>
<div className="mt-4 pt-3 border-t border-slate-200/70 text-[10px] sm:text-xs text-slate-500 items-center gap-4 hidden md:flex">
<span className="inline-flex items-center gap-1">
<span className="inline-block w-3 h-3 bg-emerald-500 rounded-full ring-2 ring-white shadow-sm"></span>
Im Schließfach
</span>
<span className="inline-flex items-center gap-1">
<span className="inline-block w-3 h-3 bg-red-500 rounded-full ring-2 ring-white shadow-sm"></span>
Außerhalb des Schließfachs
</span>
</div>
</div>
</aside>
);
};
export default Sidebar;

View File

@@ -1,12 +0,0 @@
/* Tailwind (v4) */
@import "tailwindcss";
/* Small touch target improvements */
@layer base {
html:focus-within {
scroll-behavior: smooth;
}
:root {
color-scheme: light;
}
}

View File

@@ -1,36 +0,0 @@
import React from "react";
import "../App.css";
import Header from "../components/Header";
import Sidebar from "../components/Sidebar";
import Footer from "../components/Footer";
type LayoutProps = {
children: React.ReactNode;
onLogout: () => void;
};
const Layout: React.FC<LayoutProps> = ({ children, onLogout }) => {
return (
<div className="h-screen flex flex-col bg-slate-50 text-slate-800">
{/* Main */}
<main className="flex-1 min-h-0 overflow-hidden flex flex-col items-center px-3 sm:px-5 py-4 sm:py-8 pb-12">
<div className="w-full max-w-5xl flex flex-col gap-3 md:flex-row md:gap-6 md:items-stretch min-h-0 h-full">
<div className="hidden md:flex md:flex-col md:shrink-0 md:w-72 md:min-h-0 md:h-full">
<Sidebar />
</div>
<div className="flex-1 min-w-0 min-h-0 h-full flex flex-col overflow-hidden">
<div className="w-full">
<Header onLogout={onLogout} />
</div>
<div className="w-full bg-white shadow-md md:shadow-lg rounded-2xl p-4 sm:p-6 ring-1 ring-slate-200 flex-1 min-h-0 overflow-y-auto">
{children}
</div>
</div>
</div>
</main>
<Footer />
</div>
);
};
export default Layout;

View File

@@ -1,29 +0,0 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App.tsx";
import { ToastContainer, Flip } from "react-toastify";
import "react-toastify/dist/ReactToastify.css";
import { QueryClientProvider } from "@tanstack/react-query";
import { queryClient } from "./utils/queryClient";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
<ToastContainer
position="top-right"
autoClose={3000}
hideProgressBar={false}
newestOnTop
closeOnClick
rtl={false}
pauseOnFocusLoss
draggable
pauseOnHover
theme="colored"
transition={Flip}
/>
</QueryClientProvider>
</StrictMode>
);

View File

@@ -1,198 +0,0 @@
import Cookies from "js-cookie";
import { myToast } from "./toastify";
// Event name used to notify the app when the list of items has been updated
export const ALL_ITEMS_UPDATED_EVENT = "allItemsUpdated";
export const BORROWABLE_ITEMS_UPDATED_EVENT = "borrowableItemsUpdated";
export const AUTH_LOGOUT_EVENT = "authLogout";
const API_BASE =
(import.meta as any).env?.VITE_BACKEND_URL ||
import.meta.env.VITE_BACKEND_URL ||
"http://localhost:8002";
let sendError = false;
function logout() {
Cookies.remove("token");
Cookies.remove("startDate");
Cookies.remove("endDate");
localStorage.removeItem("allItems");
localStorage.removeItem("allLoans");
localStorage.removeItem("userLoans");
localStorage.removeItem("borrowableItems");
window.dispatchEvent(new Event(ALL_ITEMS_UPDATED_EVENT));
window.dispatchEvent(new Event(BORROWABLE_ITEMS_UPDATED_EVENT));
window.dispatchEvent(new Event(AUTH_LOGOUT_EVENT));
}
export const fetchAllData = async (token: string | undefined) => {
if (!token) return;
// First we fetch all items that are potentially available for borrowing
try {
const response = await fetch(`${API_BASE}/api/items`, {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
},
});
if (response.status === 500) {
if (!sendError) {
sendError = true;
myToast("Session expired. Please log in again.", "error");
logout();
return;
}
return;
}
if (!response.ok) {
myToast("Failed to fetch items", "error");
return;
}
const data = await response.json();
localStorage.setItem("allItems", JSON.stringify(data));
// Notify listeners (e.g., Sidebar) that items have been updated
window.dispatchEvent(new Event(ALL_ITEMS_UPDATED_EVENT));
} catch (error) {
myToast("An error occurred", "error");
}
// get all loans
try {
const response = await fetch(`${API_BASE}/api/loans`, {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
},
});
if (response.status === 500) {
if (!sendError) {
sendError = true;
myToast("Session expired. Please log in again.", "error");
logout();
return;
}
return;
}
if (!response.ok) {
myToast("Failed to fetch loans!", "error");
return;
}
const data = await response.json();
localStorage.setItem("allLoans", JSON.stringify(data));
// Notify listeners (e.g., Sidebar) that loans have been updated
window.dispatchEvent(new Event(ALL_ITEMS_UPDATED_EVENT));
} catch (error) {
myToast("An error occurred", "error");
}
// get user loans
try {
const response = await fetch(`${API_BASE}/api/userLoans`, {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
},
});
if (response.status === 500) {
if (!sendError) {
sendError = true;
myToast("Session expired. Please log in again.", "error");
logout();
return;
}
return;
}
if (!response.ok) {
myToast("Failed to fetch user loans!", "error");
return;
}
const data = await response.json();
localStorage.setItem("userLoans", JSON.stringify(data));
// Notify listeners (e.g., Sidebar) that loans have been updated
window.dispatchEvent(new Event(ALL_ITEMS_UPDATED_EVENT));
} catch (error) {
myToast("An error occurred", "error");
}
};
export const loginUser = async (username: string, password: string) => {
try {
const response = await fetch(`${API_BASE}/api/login`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ username, password }),
});
if (!response.ok) {
return { success: false } as const;
}
const data = await response.json();
if (data?.token) {
Cookies.set("token", data.token);
myToast("Login successful!", "success");
fetchAllData(Cookies.get("token"));
return { success: true, token: data.token } as const;
}
return { success: false } as const;
} catch (e) {
return { success: false } as const;
}
};
export const getBorrowableItems = async () => {
const startDate = Cookies.get("startDate");
const endDate = Cookies.get("endDate");
if (!startDate || !endDate) {
myToast("Bitte wähle einen Zeitraum aus.", "error");
return;
}
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.status === 500) {
if (!sendError) {
sendError = true;
myToast("Session expired. Please log in again.", "error");
logout();
return;
}
return;
}
if (!response.ok) {
myToast("Failed to fetch borrowable items", "error");
return;
}
const data = await response.json();
localStorage.setItem("borrowableItems", JSON.stringify(data));
window.dispatchEvent(new Event(BORROWABLE_ITEMS_UPDATED_EVENT)); // notify same-tab listeners
console.log("Borrowable items fetched successfully");
} catch (error) {
myToast("An error occurred", "error");
}
};

View File

@@ -1,11 +0,0 @@
import { QueryClient } from "@tanstack/react-query";
// Central QueryClient instance so utilities (e.g. file upload) can invalidate queries.
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: 1,
},
},
});

View File

@@ -1,18 +0,0 @@
import { toast, Flip, type ToastOptions } from "react-toastify";
export type ToastType = "success" | "error" | "info" | "warning";
export const myToast = (message: string, msgType: ToastType) => {
let config: ToastOptions = {
position: "top-right",
autoClose: 3000,
hideProgressBar: false,
closeOnClick: true,
pauseOnHover: true,
draggable: true,
progress: undefined,
theme: "colored",
transition: Flip,
};
toast[msgType](message, config);
};

View File

@@ -1,163 +0,0 @@
import { myToast } from "./toastify";
import Cookies from "js-cookie";
import { queryClient } from "./queryClient";
const API_BASE =
(import.meta as any).env?.VITE_BACKEND_URL ||
import.meta.env.VITE_BACKEND_URL ||
"http://localhost:8002";
export const handleDeleteLoan = async (loanID: number): Promise<boolean> => {
try {
const response = await fetch(
`${API_BASE}/api/deleteLoan/${loanID}`,
{
method: "DELETE",
headers: {
Authorization: `Bearer ${Cookies.get("token") || ""}`,
},
}
);
if (!response.ok) {
myToast("Fehler beim Löschen der Ausleihe", "error");
return false;
}
const raw = localStorage.getItem("userLoans");
let current: Array<{ id: number }> = [];
try {
const parsed = raw ? JSON.parse(raw) : [];
current = Array.isArray(parsed) ? parsed : [];
} catch {
current = [];
}
const updated = current.filter(
(loan) => Number(loan.id) !== Number(loanID)
);
localStorage.setItem("userLoans", JSON.stringify(updated));
myToast("Ausleihe erfolgreich gelöscht!", "success");
return true;
} catch (error) {
console.error("Error deleting loan:", error);
myToast("Fehler beim löschen der Ausleihe", "error");
return false;
}
};
// Parse existing cookie and coerce to numbers
let removeArr: number[] = (() => {
try {
const raw = Cookies.get("removeArr");
const parsed = raw ? JSON.parse(raw) : [];
return Array.isArray(parsed)
? parsed.map((v) => Number(v)).filter((n) => Number.isFinite(n))
: [];
} catch {
return [];
}
})();
const rawCookies = Cookies.withConverter({
write: (value: string) => value, // store raw JSON
});
export const addToRemove = (itemID: number) => {
if (!Number.isFinite(itemID)) return;
if (!removeArr.includes(itemID)) {
removeArr.push(itemID);
rawCookies.set("removeArr", JSON.stringify(removeArr));
}
};
export const rmFromRemove = (itemID: number) => {
removeArr = removeArr.filter((item) => item !== itemID);
rawCookies.set("removeArr", JSON.stringify(removeArr));
};
export const createLoan = async (startDate: string, endDate: string) => {
const items = removeArr;
const response = await fetch(`${API_BASE}/api/createLoan`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${Cookies.get("token") || ""}`,
},
body: JSON.stringify({ items, startDate, endDate }),
});
if (!response.ok) {
myToast("Fehler beim Erstellen der Ausleihe", "error");
return false;
}
// Clear selection on success
removeArr = [];
Cookies.set("removeArr", "[]");
myToast("Ausleihe erfolgreich erstellt!", "success");
queryClient.invalidateQueries({ queryKey: ["userLoans"] });
queryClient.invalidateQueries({ queryKey: ["allLoans"] });
queryClient.invalidateQueries({ queryKey: ["borrowableItems"] });
return true;
};
export const onReturn = async (loanID: number) => {
const response = await fetch(
`${API_BASE}/api/returnLoan/${loanID}`,
{
method: "POST",
headers: {
Authorization: `Bearer ${Cookies.get("token") || ""}`,
},
}
);
if (!response.ok) {
myToast("Fehler beim Zurückgeben der Ausleihe", "error");
return false;
}
myToast("Ausleihe erfolgreich zurückgegeben!", "success");
return true;
};
export const onTake = async (loanID: number) => {
const response = await fetch(`${API_BASE}/api/takeLoan/${loanID}`, {
method: "POST",
headers: {
Authorization: `Bearer ${Cookies.get("token") || ""}`,
},
});
if (!response.ok) {
myToast("Fehler beim Ausleihen der Ausleihe", "error");
return false;
}
myToast("Ausleihe erfolgreich ausgeliehen!", "success");
return true;
};
export const changePW = async (oldPassword: string, newPassword: string) => {
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) {
myToast("Fehler beim Ändern des Passworts", "error");
return false;
}
myToast("Passwort erfolgreich geändert!", "success");
return true;
};

View File

@@ -1 +0,0 @@
/// <reference types="vite/client" />

View File

@@ -1,17 +0,0 @@
import { defineConfig } from "vite";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [tailwindcss()],
server: {
host: "0.0.0.0",
allowedHosts: ["insta.the1s.de"],
port: 8101,
watch: { usePolling: true },
hmr: {
host: "insta.the1s.de",
port: 8101,
protocol: "wss",
},
},
});

Binary file not shown.