Compare commits

..

8 Commits

28 changed files with 971 additions and 638 deletions
+3
View File
@@ -0,0 +1,3 @@
[submodule "no-as-a-service"]
path = no-as-a-service
url = https://github.com/hotheadhacker/no-as-a-service.git
+1 -1
View File
@@ -14,7 +14,7 @@ server {
}
location /backend/ {
proxy_pass http://demo_borrow_system-backend_v2:8102/;
proxy_pass http://borrow_system-backend_v2:8004/;
}
location ~* \.(?:js|mjs|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
+325 -257
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -12,6 +12,7 @@
"dependencies": {
"@chakra-ui/react": "^3.28.0",
"@emotion/react": "^11.14.0",
"@lottiefiles/dotlottie-react": "^0.19.0",
"@tailwindcss/vite": "^4.1.11",
"@tanstack/react-query": "^5.90.5",
"i18next": "^25.6.0",
+2
View File
@@ -16,6 +16,7 @@ import { 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";
import { ContactPage } from "./pages/ContactPage";
const queryClient = new QueryClient();
@@ -80,6 +81,7 @@ function App() {
<Route path="/" element={<HomePage />} />
<Route path="/my-loans" element={<MyLoansPage />} />
<Route path="/landingpage" element={<Landingpage />} />
<Route path="/contact" element={<ContactPage />} />
</Route>
<Route path="/login" element={<LoginPage />} />
+30
View File
@@ -1,6 +1,7 @@
import {
Button,
Flex,
Image,
Heading,
Stack,
Text,
@@ -22,6 +23,7 @@ import {
MoreVertical,
Languages,
Table,
ContactRound,
} from "lucide-react";
import { useUserContext } from "@/states/Context";
import { useState } from "react";
@@ -153,6 +155,16 @@ export const Header = () => {
</HStack>
}
/>
<Menu.Item
value="contact"
onSelect={() => navigate("/contact", { replace: true })}
children={
<HStack gap={3}>
<ContactRound size={16} />
<Text as="span">{t("contact")}</Text>
</HStack>
}
/>
<Menu.Separator />
<Menu.Item
value="logout"
@@ -179,6 +191,13 @@ export const Header = () => {
<Stack gap={1}>
{/* Titelzeile ohne Mobile-Menu (wurde nach oben verlegt) */}
<Flex align="center" justify="space-between" gap={2}>
<Image
src="/icon_borrow-system-frontend_dark.png"
alt="borrow-system logo"
boxSize="10"
objectFit="contain"
flexShrink={0}
/>
<Heading
size="2xl"
className="tracking-tight text-slate-900 dark:text-slate-100"
@@ -278,6 +297,17 @@ export const Header = () => {
</HStack>
</Button>
</a>
<Button
variant={"outline"}
onClick={() => navigate("/contact", { replace: true })}
>
<HStack gap={2}>
<ContactRound size={18} />
<Text as="span">{t("contact")}</Text>
</HStack>
</Button>
<Button onClick={logout} variant="outline" colorScheme="red">
<HStack gap={2}>
<LogOut size={18} />
+78 -5
View File
@@ -36,12 +36,43 @@ export const UserDialogue = (props: UserDialogueProps) => {
const [msgTitle, setMsgTitle] = useState("");
const [msgDescription, setMsgDescription] = useState("");
const [isMsgNAAS, setIsMsgNAAS] = useState(false);
const [msgStatusNAAS, setMsgStatusNAAS] = useState<"error" | "success">(
"error",
);
const [msgTitleNAAS, setMsgTitleNAAS] = useState("");
const [msgDescriptionNAAS, setMsgDescriptionNAAS] = useState("");
const [oldPassword, setOldPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
// Dialog control
const [isPwOpen, setPwOpen] = useState(false);
const [naasDialog, setNaasDialog] = useState(false);
const [naas, setNaas] = useState("");
const openNAAS = async () => {
try {
const response = await fetch(`${API_BASE}/no`, {
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${Cookies.get("token")}`,
},
});
const data = await response.json();
setNaas(data.reason);
setNaasDialog(true);
} catch (error) {
setMsgStatusNAAS("error");
setMsgTitleNAAS(t("naas-error"));
setMsgDescriptionNAAS(t("naas-error-desc"));
setIsMsgNAAS(true);
console.log(msgStatusNAAS, msgTitleNAAS, msgDescriptionNAAS);
}
};
const changePassword = async () => {
if (newPassword !== confirmPassword) {
@@ -147,14 +178,31 @@ export const UserDialogue = (props: UserDialogueProps) => {
</Button>
</Stack>
</Card.Body>
<Card.Footer justifyContent="flex-end">
<Button variant="outline" onClick={() => props.setUserDialog(false)}>
{t("cancel")}
</Button>
<Card.Footer>
<Stack w="100%" gap={3}>
{isMsgNAAS && (
<MyAlert
status={msgStatusNAAS}
title={msgTitleNAAS}
description={msgDescriptionNAAS}
/>
)}
<HStack justify="flex-end" gap={2} wrap="wrap">
<Button
variant="outline"
onClick={() => props.setUserDialog(false)}
>
{t("cancel")}
</Button>
<Button variant="outline" onClick={() => openNAAS()}>
{t("try-naas")}
</Button>
</HStack>
</Stack>
</Card.Footer>
</Card.Root>
{/* Passwort-Dialog (kontrolliert) */}
{/* Passwort-Dialog */}
<Dialog.Root open={isPwOpen} onOpenChange={(e: any) => setPwOpen(e.open)}>
<Portal>
<Dialog.Backdrop />
@@ -215,6 +263,31 @@ export const UserDialogue = (props: UserDialogueProps) => {
</Dialog.Positioner>
</Portal>
</Dialog.Root>
<HStack wrap="wrap" gap="4">
<Dialog.Root
placement={"center"}
open={naasDialog}
motionPreset="slide-in-bottom"
>
<Portal>
<Dialog.Backdrop />
<Dialog.Positioner>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{t("naas-header")}</Dialog.Title>
</Dialog.Header>
<Dialog.Body>
<p>{naas}</p>
</Dialog.Body>
<Dialog.CloseTrigger asChild>
<CloseButton onClick={() => setNaasDialog(false)} size="sm" />
</Dialog.CloseTrigger>
</Dialog.Content>
</Dialog.Positioner>
</Portal>
</Dialog.Root>
</HStack>
</Flex>
);
};
+28
View File
@@ -0,0 +1,28 @@
import { DotLottieReact } from "@lottiefiles/dotlottie-react";
export const unlockAnimation = () => {
return (
<DotLottieReact
src="https://lottie.host/f839baa1-9c64-44c4-9386-f0e4c87ab208/2Iw1m4k86d.lottie"
autoplay
/>
);
};
export const approvalAnimation = () => {
return (
<DotLottieReact
src="https://lottie.host/b7257009-9e3f-43e2-8112-a176f4696e4c/iQxxqAVOGX.lottie"
autoplay
/>
);
};
export const logoutAnimation = () => {
return (
<DotLottieReact
src="https://lottie.host/4975758c-de38-4d15-9f74-927709751d32/v8FtKpnD1y.lottie"
autoplay
/>
);
};
+9 -17
View File
@@ -1,23 +1,15 @@
"use client";
"use client"
import { ChakraProvider, defaultSystem } from "@chakra-ui/react";
import * as React from "react";
import type { ReactNode } from "react";
import { ColorModeProvider as ThemeColorModeProvider } from "./color-mode";
import { ChakraProvider, defaultSystem } from "@chakra-ui/react"
import {
ColorModeProvider,
type ColorModeProviderProps,
} from "./color-mode"
export interface ColorModeProviderProps {
children: React.ReactNode;
}
export function ColorModeProvider({ children }: ColorModeProviderProps) {
// Wrap children with the real color-mode provider
return <ThemeColorModeProvider>{children}</ThemeColorModeProvider>;
}
export function Provider({ children }: { children: ReactNode }) {
export function Provider(props: ColorModeProviderProps) {
return (
<ChakraProvider value={defaultSystem}>
<ColorModeProvider>{children}</ColorModeProvider>
<ColorModeProvider {...props} />
</ChakraProvider>
);
)
}
+84
View File
@@ -0,0 +1,84 @@
import {
Field,
Textarea,
Button,
Alert,
Container,
Text,
} from "@chakra-ui/react";
import { useTranslation } from "react-i18next";
import { useState } from "react";
import { API_BASE } from "@/config/api.config";
import Cookies from "js-cookie";
import { Header } from "@/components/Header";
interface Alert {
type: "info" | "warning" | "success" | "error" | "neutral";
headline: string;
text: string;
}
export const ContactPage = () => {
const { t } = useTranslation();
const [message, setMessage] = useState("");
const [alert, setAlert] = useState<Alert | null>(null);
const sendMessage = async () => {
// Logic to send the message
const result = await fetch(`${API_BASE}/api/users/contact`, {
method: "POST",
headers: {
Authorization: `Bearer ${Cookies.get("token") || ""}`,
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({ message }),
});
if (result.ok) {
setAlert({
type: "success",
headline: t("contactPage_successHeadline"),
text: t("contactPage_successText"),
});
setMessage("");
} else {
setAlert({
type: "error",
headline: t("contactPage_errorHeadline"),
text: t("contactPage_errorText"),
});
}
};
return (
<Container className="px-6 sm:px-8 pt-10">
<Header />
<Field.Root invalid={message === ""}>
<Field.Label>
<Text>{t("contactPage_messageDescription")}</Text>
<Field.RequiredIndicator />
</Field.Label>
<Textarea
placeholder={t("contactPage_messagePlaceholder")}
variant="subtle"
value={message}
onChange={(e) => setMessage(e.target.value)}
/>
{message === "" && (
<Field.ErrorText>{t("contactPage_messageErrorText")}</Field.ErrorText>
)}
</Field.Root>
{alert && (
<Alert.Root status={alert.type}>
<Alert.Indicator />
<Alert.Content>
<Alert.Title>{alert.headline}</Alert.Title>
<Alert.Description>{alert.text}</Alert.Description>
</Alert.Content>
</Alert.Root>
)}
<Button onClick={sendMessage}>{t("contactPage_sendButton")}</Button>
</Container>
);
};
+157 -137
View File
@@ -18,6 +18,7 @@ import { borrowAbleItemsAtom } from "@/states/Atoms";
import { createLoan } from "@/utils/Fetcher";
import { Header } from "@/components/Header";
import { useTranslation } from "react-i18next";
import { approvalAnimation } from "@/components/dotLottie";
export interface User {
username: string;
@@ -27,6 +28,8 @@ export interface User {
export const HomePage = () => {
const { t } = useTranslation();
const [showAnimation, setShowAnimation] = useState(false);
const [borrowableItems, setBorrowableItems] = useAtom(borrowAbleItemsAtom);
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
@@ -46,155 +49,172 @@ export const HomePage = () => {
setSelectedItems((prevSelected) =>
prevSelected.includes(itemId)
? prevSelected.filter((id) => id !== itemId)
: [...prevSelected, itemId]
: [...prevSelected, itemId],
);
};
const showApprovalAnimation = (seconds: number) => {
const milliseconds = seconds * 1000;
setShowAnimation(true);
window.setTimeout(() => {
setShowAnimation(false);
}, milliseconds);
};
return (
<Container className="px-6 sm:px-8 pt-10">
<Header />
{isMsg && (
<MyAlert
status={msgStatus}
title={msgTitle}
description={msgDescription}
/>
<>
{showAnimation && (
<div className="fixed inset-0 z-9999 flex items-center justify-center pointer-events-none">
<div>{approvalAnimation()}</div>
</div>
)}
<Stack as="main">
<Text>{t("timezone-info")}</Text>
<label htmlFor="startDate">
<strong>
<Text>{t("start-date")}</Text>
</strong>
</label>
<Input
id="startDate"
placeholder={t("start-date")}
type="datetime-local"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
/>
<label htmlFor="endDate">
<strong>
<Text>{t("end-date")}</Text>
</strong>
</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") {
<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">
<strong>
<Text>{t("start-date")}</Text>
</strong>
</label>
<Input
id="startDate"
placeholder={t("start-date")}
type="datetime-local"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
/>
<label htmlFor="endDate">
<strong>
<Text>{t("end-date")}</Text>
</strong>
</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(response.title || t("error"));
setMsgDescription(response.description || t("unknown-error"));
setMsgTitle(t("missing-fields"));
setMsgDescription(t("missing-fields-desc"));
setIsMsg(true);
setIsLoadingA(false);
return;
}
setBorrowableItems(response.data);
setIsMsg(false);
});
}}
>
{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.Row>
<Table.Cell colSpan={2}>
<InputGroup
endElement={
<Span color="fg.muted" textStyle="xs">
{note.length} / {MAX_CHARACTERS}
</Span>
}
>
<Input
placeholder={t("optional-note")}
value={note}
maxLength={MAX_CHARACTERS}
onChange={(e) => {
setNote(
e.currentTarget.value.slice(0, MAX_CHARACTERS)
);
}}
/>
</InputGroup>
</Table.Cell>
</Table.Row>
</Table.Body>
</Table.Root>
</Table.ScrollArea>
)}
{selectedItems.length >= 1 && (
<Button
onClick={() =>
createLoan(selectedItems, startDate, endDate, note).then(
(response) => {
if (response.status === "error") {
setMsgStatus("error");
setMsgTitle(response.title || t("error"));
setMsgDescription(
response.description || t("unknown-error")
);
setIsMsg(true);
return;
}
setMsgStatus("success");
setMsgTitle(t("success"));
setMsgDescription(t("loan-success"));
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);
});
}}
>
{t("create-loan")}
{t("get-borrowable-items")}
</Button>
)}
</Stack>
</Container>
{isLoadingA && (
<VStack colorPalette="teal">
<Spinner color="colorPalette.600" />
<Text color="colorPalette.600">{t("loading")}</Text>
</VStack>
)}
{borrowableItems.length > 0 && (
<Table.ScrollArea borderWidth="1px" rounded="md">
<Table.Root size="sm" stickyHeader>
<Table.Header>
<Table.Row bg="bg.subtle">
<Table.ColumnHeader></Table.ColumnHeader>
<Table.ColumnHeader>{t("item")}</Table.ColumnHeader>
</Table.Row>
</Table.Header>
<Table.Body>
{borrowableItems.map((item) => (
<Table.Row key={item.id}>
<Table.Cell>
<input
onChange={() => handleCheckboxChange(item.id)}
type="checkbox"
name={item.id}
id={item.id}
/>
</Table.Cell>
<Table.Cell>{item.item_name}</Table.Cell>
</Table.Row>
))}
<Table.Row>
<Table.Cell colSpan={2}>
<InputGroup
endElement={
<Span color="fg.muted" textStyle="xs">
{note.length} / {MAX_CHARACTERS}
</Span>
}
>
<Input
placeholder={t("optional-note")}
value={note}
maxLength={MAX_CHARACTERS}
onChange={(e) => {
setNote(
e.currentTarget.value.slice(0, MAX_CHARACTERS),
);
}}
/>
</InputGroup>
</Table.Cell>
</Table.Row>
</Table.Body>
</Table.Root>
</Table.ScrollArea>
)}
{selectedItems.length >= 1 && (
<Button
onClick={() =>
createLoan(selectedItems, startDate, endDate, note).then(
(response) => {
if (response.status === "error") {
setMsgStatus("error");
setMsgTitle(response.title || t("error"));
setMsgDescription(
response.description || t("unknown-error"),
);
setIsMsg(true);
return;
}
showApprovalAnimation(3);
setMsgStatus("success");
setMsgTitle(t("success"));
setMsgDescription(t("loan-success"));
setIsMsg(true);
},
)
}
>
{t("create-loan")}
</Button>
)}
</Stack>
</Container>
</>
);
};
+93 -53
View File
@@ -4,26 +4,47 @@ import { Button, Card, Field, Input, Stack } from "@chakra-ui/react";
import { setIsLoggedInAtom, triggerLogoutAtom } from "@/states/Atoms";
import { useAtom } from "jotai";
import Cookies from "js-cookie";
import { Navigate, useNavigate, useLocation } from "react-router-dom";
import { useNavigate, useLocation } from "react-router-dom";
import { PasswordInput } from "@/components/ui/password-input";
import { useTranslation } from "react-i18next";
import { API_BASE } from "@/config/api.config";
import { unlockAnimation } from "@/components/dotLottie";
import { logoutAnimation } from "@/components/dotLottie";
export const LoginPage = () => {
const { t } = useTranslation();
const [isLoggedIn, setIsLoggedIn] = useAtom(setIsLoggedInAtom);
const [triggerLogout, setTriggerLogout] = useAtom(triggerLogoutAtom);
const [showAnimation, setShowAnimation] = useState(false);
const [showLogout, setShowLogout] = useState(false);
const navigate = useNavigate();
const location = useLocation();
const from = location.state?.from?.pathname || "/";
useEffect(() => {
if (isLoggedIn) {
navigate(from, { replace: true });
window.location.reload(); // if deleted, the user context is not updated in time
if (triggerLogout) {
setShowLogout(true);
window.setTimeout(() => {
setShowLogout(false);
}, 4500);
}
}, [isLoggedIn, navigate, from]);
if (!isLoggedIn) return;
// Existing sessions should redirect immediately, fresh logins wait for animation.
if (!showAnimation) {
navigate(from, { replace: true });
return;
}
const timeoutId = window.setTimeout(() => {
navigate(from, { replace: true });
window.location.reload(); // keeps user context in sync after login
}, 3000);
return () => window.clearTimeout(timeoutId);
}, [isLoggedIn, showAnimation, navigate, from]);
const loginFnc = async (username: string, password: string) => {
const response = await fetch(`${API_BASE}/api/users/login`, {
@@ -42,6 +63,8 @@ export const LoginPage = () => {
};
}
setShowAnimation(true);
Cookies.set("token", data.token);
setIsLoggedIn(true);
return { success: true };
@@ -62,58 +85,75 @@ export const LoginPage = () => {
return;
}
setTriggerLogout(false);
navigate(from, { replace: true });
};
if (isLoggedIn) {
return <Navigate to={from} replace />;
}
return (
<div className="flex flex-1 items-center justify-center p-4">
<form onSubmit={(e) => e.preventDefault()}>
<Card.Root maxW="sm">
<Card.Header>
<Card.Title>{t("login")}</Card.Title>
<Card.Description>{t("enter-credentials")}</Card.Description>
</Card.Header>
<Card.Body>
<Stack gap="4" w="full">
<Field.Root>
<Field.Label>{t("username")}</Field.Label>
<Input
value={username}
onChange={(e) => setUsername(e.target.value)}
<>
{showAnimation && (
<div className="fixed inset-0 z-9999 flex items-center justify-center pointer-events-none">
<div>{unlockAnimation()}</div>
</div>
)}
{showLogout && (
<div className="fixed inset-0 z-9999 flex items-center justify-center pointer-events-none">
<div>{logoutAnimation()}</div>
</div>
)}
<div className="flex flex-1 items-center justify-center p-4">
<form onSubmit={(e) => e.preventDefault()}>
<Card.Root maxW="sm">
<Card.Header>
<Card.Title>{t("login")}</Card.Title>
<Card.Description>{t("enter-credentials")}</Card.Description>
</Card.Header>
<Card.Body>
<Stack gap="4" w="full">
<Field.Root>
<Field.Label>{t("username")}</Field.Label>
<Input
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</Field.Root>
<Field.Root>
<Field.Label>{t("password")}</Field.Label>
<PasswordInput
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</Field.Root>
</Stack>
</Card.Body>
<Card.Footer justifyContent="flex-end">
{isError && (
<MyAlert
status="error"
title={errorMsg}
description={errorDsc}
/>
</Field.Root>
<Field.Root>
<Field.Label>{t("password")}</Field.Label>
<PasswordInput
value={password}
onChange={(e) => setPassword(e.target.value)}
)}
<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")}
/>
</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>
</div>
)}
</Card.Footer>
</Card.Root>
</form>
</div>
</>
);
};
+8
View File
@@ -84,6 +84,14 @@ export const MyLoansPage = () => {
});
if (!res.ok) {
if (res.status === 507) {
setMsgStatus("error");
setMsgTitle(t("error"));
setMsgDescription(t("error-deleting-loan-507"));
setIsMsg(true);
return;
}
setMsgStatus("error");
setMsgTitle(t("error"));
setMsgDescription(t("error-deleting-loan"));
+8 -2
View File
@@ -68,7 +68,7 @@
"admin-status": "Admin-Status",
"first-name": "Vorname",
"last-name": "Nachname",
"app-title": "Ausleihsystem (demo)",
"app-title": "Ausleihsystem",
"last-borrowed-person": "Zuletzt ausgeliehen von",
"currently-borrowed-by": "Derzeit ausgeliehen von",
"back": "Zurückgehen",
@@ -88,5 +88,11 @@
"take-loan-success": "Ausleihe erfolgreich abgeholt",
"return-loan-success": "Ausleihe erfolgreich zurückgegeben",
"network-error": "Netzwerkfehler. Kontaktieren Sie den Administrator.",
"contactPage_messageDescription": "Bitte geben Sie hier Ihre Nachricht ein. Der Systemadministrator (Theis Gaedigk) wird sich so schnell wie möglich bei Ihnen melden."
"contactPage_messageDescription": "Bitte geben Sie hier Ihre Nachricht ein. Der Systemadministrator (Theis Gaedigk) wird sich so schnell wie möglich bei Ihnen melden.",
"naas": "No-as-a-service",
"try-naas": "Klick mich",
"naas-error": "Fehler mit no-as-a-service",
"naas-error-desc": "Ein Fehler ist beim Kommunizieren mit no-as-a-service aufgetreten.",
"naas-header": "Eine gute Möglichkeit, nein zu sagen...",
"error-deleting-loan-507": "Die Ausleihe kann nicht gelöscht werden, da sie noch nicht zurückgegeben wurde."
}
+8 -2
View File
@@ -68,7 +68,7 @@
"admin-status": "Admin status",
"first-name": "First name",
"last-name": "Last name",
"app-title": "Borrow System (demo)",
"app-title": "Borrow System",
"last-borrowed-person": "Last borrowed by",
"currently-borrowed-by": "Currently borrowed by",
"back": "Go back",
@@ -88,5 +88,11 @@
"take-loan-success": "Loan taken successfully",
"return-loan-success": "Loan returned successfully",
"network-error": "Network error. Please contact the administrator.",
"contactPage_messageDescription": "Please enter your message here. The system administrator (Theis Gaedigk) will get back to you as soon as possible."
"contactPage_messageDescription": "Please enter your message here. The system administrator (Theis Gaedigk) will get back to you as soon as possible.",
"naas": "No-as-a-service",
"try-naas": "Click me",
"naas-error": "Error with no-as-a-service",
"naas-error-desc": "An error occurred while communicating with no-as-a-service.",
"naas-header": "A good way to say no...",
"error-deleting-loan-507": "The loan cannot be deleted because it has not been returned yet."
}
+7 -14
View File
@@ -1,23 +1,16 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import svgr from "vite-plugin-svgr";
import tailwindcss from "@tailwindcss/vite";
import path from "node:path";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
plugins: [tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
},
},
plugins: [react(), svgr(), tailwindcss(), tsconfigPaths()],
server: {
host: "0.0.0.0",
allowedHosts: ["insta.the1s.de"],
port: 8101,
watch: { usePolling: true },
hmr: {
host: "insta.the1s.de",
port: 8101,
protocol: "wss",
port: 8001,
watch: {
usePolling: true,
},
},
});
+1 -1
View File
@@ -14,7 +14,7 @@ server {
}
location /backend/ {
proxy_pass http://demo_borrow_system-backend_v2:8102/;
proxy_pass http://borrow_system-backend_v2:8004/;
}
location ~* \.(?:js|mjs|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
+3 -7
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,
},
},
});
+3 -3
View File
@@ -1,11 +1,11 @@
{
"backend-info": {
"version": "v2.1.1 (demo)"
"version": "v2.2 (dev)"
},
"frontend-info": {
"version": "v2.1.2 (demo)"
"version": "v2.2 (dev)"
},
"admin-panel-info": {
"version": "v1.3.2 (demo)"
"version": "v1.3.2 (dev)"
}
}
@@ -29,14 +29,14 @@ export const createUser = async (
};
export const deleteUserById = async (userId) => {
const [result] = await pool.query("DELETE FROM users WHERE id = ? AND secret_user = false", [userId]);
const [result] = await pool.query("DELETE FROM users WHERE id = ?", [userId]);
if (result.affectedRows > 0) return { success: true };
return { success: false };
};
export const changePassword = async (username, newPassword) => {
const [result] = await pool.query(
"UPDATE users SET password = ?, entry_updated_at = NOW() WHERE username = ? AND secret_user = false",
"UPDATE users SET password = ?, entry_updated_at = NOW() WHERE username = ?",
[newPassword, username],
);
if (result.affectedRows > 0) return { success: true };
@@ -52,7 +52,7 @@ export const editUserById = async (
is_admin,
) => {
const [result] = await pool.query(
"UPDATE users SET first_name = ?, last_name = ?, role = ?, email = ?, is_admin = ?, entry_updated_at = NOW() WHERE id = ? AND secret_user = false",
"UPDATE users SET first_name = ?, last_name = ?, role = ?, email = ?, is_admin = ?, entry_updated_at = NOW() WHERE id = ?",
[first_name, last_name, role, email, is_admin, userId],
);
if (result.affectedRows > 0) return { success: true };
@@ -61,7 +61,7 @@ export const editUserById = async (
export const getAllUsers = async () => {
const [result] = await pool.query(
"SELECT id, username, first_name, last_name, role, email, is_admin, entry_created_at, entry_updated_at FROM users WHERE secret_user = false",
"SELECT id, username, first_name, last_name, role, email, is_admin, entry_created_at, entry_updated_at FROM users",
);
if (result.length > 0) return { success: true, data: result };
return { success: false };
@@ -69,7 +69,7 @@ export const getAllUsers = async () => {
export const getUserById = async (userId) => {
const [rows] = await pool.query(
"SELECT id, username, first_name, last_name, role, email, is_admin FROM users WHERE id = ? AND secret_user = false",
"SELECT id, username, first_name, last_name, role, email, is_admin FROM users WHERE id = ?",
[userId],
);
if (rows.length === 0) {
@@ -234,6 +234,23 @@ export const getBorrowableItemsFromDatabase = async (
};
export const SETdeleteLoanFromDatabase = async (loanId) => {
const [checkIfdatesReturned] = await pool.query(
"SELECT take_date, returned_date FROM loans WHERE id = ? AND deleted = 0",
[loanId],
);
if (checkIfdatesReturned.length === 0) {
return { success: false, code: "LOAN_NOT_FOUND" };
}
const { take_date, returned_date } = checkIfdatesReturned[0];
const bothNull = take_date === null && returned_date === null;
const bothSet = take_date !== null && returned_date !== null;
if (!(bothNull || bothSet)) {
return { success: false, code: "LOAN_NOT_RETURNED" };
}
const [result] = await pool.query(
"UPDATE loans SET deleted = 1 WHERE id = ?;",
[loanId],
+10
View File
@@ -134,6 +134,16 @@ router.delete("/delete-loan/:id", authenticate, async (req, res) => {
if (result.success) {
res.status(200).json({ message: "Loan deleted successfully" });
} else {
if (result.code === "LOAN_NOT_FOUND") {
res.status(404).json({ message: "Loan not found" });
}
if (result.code === "LOAN_NOT_RETURNED") {
res.status(507).json({
message: "Cannot delete loan that has not been returned",
});
}
res.status(500).json({ message: "Failed to delete loan" });
}
});
-100
View File
@@ -1,100 +0,0 @@
USE borrow_system_new;
-- USERS
INSERT INTO users (username, password, email, first_name, last_name, role, is_admin)
VALUES
('user1', 'passwordhash1', 'user1@example.com', 'First1', 'Last1', 1, false),
('user2', 'passwordhash2', 'user2@example.com', 'First2', 'Last2', 1, false),
('user3', 'passwordhash3', 'user3@example.com', 'First3', 'Last3', 2, false),
('admin1', 'passwordhash4', 'admin1@example.com', 'Admin', 'One', 9, true),
('admin2', 'passwordhash5', 'admin2@example.com', 'Admin', 'Two', 9, true);
-- ITEMS
INSERT INTO items (item_name, can_borrow_role, in_safe, safe_nr, door_key, last_borrowed_person, currently_borrowing)
VALUES
('Item1', 1, true, 1, 101, NULL, NULL),
('Item2', 1, true, 2, 102, 'user1', 'user1'),
('Item3', 2, true, 3, 103, 'user2', NULL),
('Item4', 1, false, NULL, NULL, NULL, NULL),
('Item5', 2, false, NULL, NULL, 'user3', 'user3');
-- LOANS
INSERT INTO loans (
username,
lockers,
loan_code,
start_date,
end_date,
take_date,
returned_date,
created_at,
loaned_items_id,
loaned_items_name,
deleted,
note
)
VALUES
(
'user1',
JSON_ARRAY('Locker1', 'Locker2'),
'123456',
'2026-02-01 09:00:00',
'2026-02-10 17:00:00',
'2026-02-01 09:15:00',
NULL,
'2026-02-01 09:00:00',
JSON_ARRAY(1, 2),
JSON_ARRAY('Item1', 'Item2'),
false,
'Erste allgemeine Ausleihe'
),
(
'user2',
JSON_ARRAY('Locker3'),
'234567',
'2026-02-02 10:00:00',
'2026-02-05 16:00:00',
'2026-02-02 10:05:00',
'2026-02-05 15:30:00',
'2026-02-02 10:00:00',
JSON_ARRAY(3),
JSON_ARRAY('Item3'),
false,
'Zurückgegeben vor Enddatum'
),
(
'user3',
JSON_ARRAY(),
'345678',
'2026-02-03 08:30:00',
'2026-02-15 18:00:00',
NULL,
NULL,
'2026-02-03 08:30:00',
JSON_ARRAY(5),
JSON_ARRAY('Item5'),
false,
'Noch ausgeliehen'
),
(
'user1',
JSON_ARRAY('Locker4'),
'456789',
'2025-12-01 09:00:00',
'2025-12-03 17:00:00',
'2025-12-01 09:10:00',
'2025-12-03 16:45:00',
'2025-12-01 09:00:00',
JSON_ARRAY(1),
JSON_ARRAY('Item1'),
true,
'Alte, gelöschte Ausleihe'
);
-- API KEYS
INSERT INTO apiKeys (api_key, entry_name)
VALUES
('10000001', 'Entry1'),
('10000002', 'Entry2'),
('10000003', 'Entry3'),
('10000004', 'Entry4');
-1
View File
@@ -11,7 +11,6 @@ CREATE TABLE users (
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,
secret_user bool NOT NULL DEFAULT false,
PRIMARY KEY (id)
) ENGINE=InnoDB;
+18 -3
View File
@@ -1,6 +1,6 @@
import express from "express";
import cors from "cors";
import env from "dotenv";
import dotenv from "dotenv";
import info from "./info.json" assert { type: "json" };
import { authenticate } from "./services/authentication.js";
@@ -18,9 +18,10 @@ import userMgmtRouterADMIN from "./routes/admin/userMgmt.route.js";
// API routes
import apiRouter from "./routes/api/api.route.js";
env.config();
dotenv.config();
const app = express();
const port = 8102;
const port = 8004;
const naasURL = process.env.NAAS_URL;
app.use(cors());
// Body-Parser VOR den Routen registrieren
@@ -47,6 +48,20 @@ app.listen(port, () => {
console.log(`Server is running on port: ${port}`);
});
app.get("/no", async (req, res) => {
try {
const response = await fetch(naasURL);
if (!response.ok) {
res.status(500).send("Request to no-as-a-service went wrong.");
}
const data = await response.json();
res.json(data);
} catch (error) {
console.error("Error communicating with no-as-a-service:", error);
res.status(500).send("Error communicating with no-as-a-service.");
}
});
app.get("/verify", authenticate, async (req, res) => {
res.status(200).json({ message: "Token is valid", user: req.user });
});
+36
View File
@@ -0,0 +1,36 @@
# Changelog for upcoming version: v2.2
This update provides some new features for the design. It also contains some improvements and I have also fixed some bugs.
## New features
- The overview page now has the note column and is overall better organised
- I also addded the regular header to the page
- I have added three animations to the Borrow System
- I have added a new icon for the frontend, which is now also used in the header and the favicon. It is a dark version of the old icon, which fits better to the overall design. I have made it with Icon Composer. The old icon is still used for the admin panel, which has a light design. (Maybe I will change the admin panel design in the future...)
- When you go to your user card (over the user icon in the header) you have a new button "Click me". If you click it, you will get an message... _I am just saying: I have implemented the no-as-a-service code in to my Backend._
## Improvements
- I have the error logging for the API route wehre you can take loans improved.
- If you try to delete a loan that has not been returned yet, you will get an 507 error code.
## Fixed bugs
- Fixed bug: #13
- Fixed bug for messaging when server has an error
- Fixed footer height
---
## New version numbers
**Backend:** v2.2
**Frontend:** v2.2
**Admin panel:** v1.3.2
---
-[Theis](https://portfolio-theis.de)
+35 -30
View File
@@ -1,51 +1,56 @@
services:
demo_usr_frontend:
container_name: demo_borrow_system-usr-frontend
networks:
- proxynet
build: ./FrontendV2
restart: unless-stopped
# usr-frontend_v2:
# container_name: borrow_system-usr-frontend
# build: ./FrontendV2
# ports:
# - "8001:80"
# restart: always
demo_admin_frontend:
container_name: demo_borrow_system-admin-frontend
networks:
- proxynet
build: ./admin
restart: unless-stopped
# admin-frontend:
# container_name: borrow_system-admin-frontend
# build: ./admin
# ports:
# - "8003:80"
# restart: always
demo_backend_v2:
container_name: demo_borrow_system-backend_v2
networks:
- proxynet
backend_v2:
container_name: borrow_system-backend_v2
build: ./backendV2
ports:
- "8004:8004"
environment:
NODE_ENV: production
DB_HOST: demo_mysql_v2
DB_HOST: mysql_v2
DB_USER: root
DB_PASSWORD: ${DB_PASSWORD_V2}
DB_NAME: borrow_system_new
depends_on:
- demo_mysql_v2
restart: unless-stopped
- mysql_v2
restart: always
demo_mysql_v2:
container_name: demo_borrow_system-mysql-v2
networks:
- proxynet
mysql_v2:
container_name: borrow_system-mysql-v2
image: mysql:8.0
restart: unless-stopped
restart: always
environment:
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD_V2}
MYSQL_DATABASE: borrow_system_new
TZ: Europe/Berlin
volumes:
- demo_mysql-v2-data:/var/lib/mysql
- mysql-v2-data:/var/lib/mysql
- ./mysql-timezone.cnf:/etc/mysql/conf.d/timezone.cnf:ro
ports:
- "3310:3306"
no-as-a-service:
container_name: borrow_system-naas
ports:
- "3000:3000"
build:
context: ./no-as-a-service
dockerfile: Dockerfile
restart: always
volumes:
mysql-data:
demo_mysql-v2-data:
networks:
proxynet:
external: true
mysql-v2-data:
+1
Submodule no-as-a-service added at 764062a307