Merge branch 'dev' into debian12

This commit is contained in:
2025-08-20 13:18:01 +02:00
10 changed files with 156 additions and 128 deletions

View File

@@ -24,6 +24,16 @@ Example: `/apiV2/items/{ADMIN_ID}`
--- ---
## URL
- The frontend is currently running on `https://insta.the1s.de`.
- The backend is currently running on `https://backend.insta.the1s.de`.
You can see the status of this and all my other services at `https://status.the1s.de`.
---
## Current endpoints ## Current endpoints
### 1. Get All Items ### 1. Get All Items
@@ -81,9 +91,9 @@ POST /apiV2/controlInSafe/your_admin_key/5/0
{} {}
``` ```
*An empty object means, that the operation was successful and no further information is returned.* _An empty object means, that the operation was successful and no further information is returned._
*You also get an http 2xx status code.* _You also get an http 2xx status code._
--- ---
@@ -107,9 +117,9 @@ POST /apiV2/setReturnDate/your_admin_key/123456
{} {}
``` ```
*An empty object means, that the operation was successful and no further information is returned.* _An empty object means, that the operation was successful and no further information is returned._
*You also get an http 2xx status code.* _You also get an http 2xx status code._
--- ---
@@ -133,9 +143,9 @@ POST /apiV2/setTakeDate/your_admin_key/123456
{} {}
``` ```
*An empty object means, that the operation was successful and no further information is returned.* _An empty object means, that the operation was successful and no further information is returned._
*You also get an http 2xx status code.* _You also get an http 2xx status code._
--- ---

View File

@@ -9,7 +9,7 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@tailwindcss/vite": "^4.1.11", "@tailwindcss/vite": "^4.1.11",
"@tanstack/react-query": "^5.85.0", "@tanstack/react-query": "^5.85.5",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"lucide-react": "^0.539.0", "lucide-react": "^0.539.0",
"primeicons": "^7.0.0", "primeicons": "^7.0.0",
@@ -1836,9 +1836,9 @@
} }
}, },
"node_modules/@tanstack/query-core": { "node_modules/@tanstack/query-core": {
"version": "5.85.3", "version": "5.85.5",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.85.3.tgz", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.85.5.tgz",
"integrity": "sha512-9Ne4USX83nHmRuEYs78LW+3lFEEO2hBDHu7mrdIgAFx5Zcrs7ker3n/i8p4kf6OgKExmaDN5oR0efRD7i2J0DQ==", "integrity": "sha512-KO0WTob4JEApv69iYp1eGvfMSUkgw//IpMnq+//cORBzXf0smyRwPLrUvEe5qtAEGjwZTXrjxg+oJNP/C00t6w==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"type": "github", "type": "github",
@@ -1846,12 +1846,12 @@
} }
}, },
"node_modules/@tanstack/react-query": { "node_modules/@tanstack/react-query": {
"version": "5.85.3", "version": "5.85.5",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.85.3.tgz", "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.85.5.tgz",
"integrity": "sha512-AqU8TvNh5GVIE8I+TUU0noryBRy7gOY0XhSayVXmOPll4UkZeLWKDwi0rtWOZbwLRCbyxorfJ5DIjDqE7GXpcQ==", "integrity": "sha512-/X4EFNcnPiSs8wM2v+b6DqS5mmGeuJQvxBglmDxl6ZQb5V26ouD2SJYAcC3VjbNwqhY2zjxVD15rDA5nGbMn3A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@tanstack/query-core": "5.85.3" "@tanstack/query-core": "5.85.5"
}, },
"funding": { "funding": {
"type": "github", "type": "github",

View File

@@ -11,7 +11,7 @@
}, },
"dependencies": { "dependencies": {
"@tailwindcss/vite": "^4.1.11", "@tailwindcss/vite": "^4.1.11",
"@tanstack/react-query": "^5.85.0", "@tanstack/react-query": "^5.85.5",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"lucide-react": "^0.539.0", "lucide-react": "^0.539.0",
"primeicons": "^7.0.0", "primeicons": "^7.0.0",

View File

@@ -1,6 +1,9 @@
import React, { useEffect, useState } from "react"; import React from "react";
import { Trash, ArrowLeftRight } from "lucide-react"; import { Trash, ArrowLeftRight } from "lucide-react";
import { handleDeleteLoan } from "../utils/userHandler"; import { handleDeleteLoan } from "../utils/userHandler";
import { useMutation, useQuery } from "@tanstack/react-query";
import Cookies from "js-cookie";
import { queryClient } from "../utils/queryClient";
type Loan = { type Loan = {
id: number; id: number;
@@ -22,40 +25,39 @@ const formatDate = (iso: string | null) => {
return d.toLocaleString("de-DE", { dateStyle: "short", timeStyle: "short" }); return d.toLocaleString("de-DE", { dateStyle: "short", timeStyle: "short" });
}; };
const readUserLoansFromStorage = (): Loan[] => { async function fetchUserLoans(): Promise<Loan[]> {
const raw = localStorage.getItem("userLoans"); const res = await fetch("http://localhost:8002/api/userLoans", {
if (!raw || raw === '"No loans found for this user"') return []; method: "GET",
try { headers: { Authorization: `Bearer ${Cookies.get("token") || ""}` },
const parsed = JSON.parse(raw); });
return Array.isArray(parsed) ? (parsed as Loan[]) : []; if (!res.ok) throw new Error("Failed to fetch user loans");
} catch { const data = await res.json();
return []; if (data === "No loans found for this user") return [];
} return Array.isArray(data) ? (data as Loan[]) : [];
}; }
const Form4: React.FC = () => { const Form4: React.FC = () => {
const [userLoans, setUserLoans] = useState<Loan[]>(() => const { data: userLoans = [], isFetching } = useQuery({
readUserLoansFromStorage() queryKey: ["userLoans"],
); queryFn: fetchUserLoans,
});
useEffect(() => { const deleteMutation = useMutation({
const onStorage = (e: StorageEvent) => { mutationFn: (loanID: number) => handleDeleteLoan(loanID),
if (e.key === "userLoans") { onSuccess: () => {
setUserLoans(readUserLoansFromStorage()); queryClient.invalidateQueries({ queryKey: ["userLoans"] });
} },
}; });
window.addEventListener("storage", onStorage);
return () => window.removeEventListener("storage", onStorage);
}, []);
const onDelete = async (loanID: number) => { const onDelete = (loanID: number) => deleteMutation.mutate(loanID);
const ok = await handleDeleteLoan(loanID);
if (ok) { if (isFetching) {
setUserLoans((prev) => return (
prev.filter((l) => Number(l.id) !== Number(loanID)) <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) { if (userLoans.length === 0) {
return ( return (

View File

@@ -8,7 +8,7 @@ type ObjectProps = {
const Object: React.FC<ObjectProps> = ({ title, description }) => { const Object: React.FC<ObjectProps> = ({ title, description }) => {
return ( return (
<div className="min-w-0"> <div className="min-w-0">
<h3 className="text-sm font-semibold text-slate-900 truncate">{title}</h3> <h3 className="text-sm font-semibold text-slate-900">{title}</h3>
<p className="text-xs text-slate-600 line-clamp-2">{description}</p> <p className="text-xs text-slate-600 line-clamp-2">{description}</p>
</div> </div>
); );

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import Object from "./Object"; import Object from "./Object";
import { MonitorSmartphone } from "lucide-react";
import { ALL_ITEMS_UPDATED_EVENT } from "../utils/fetchData"; import { ALL_ITEMS_UPDATED_EVENT } from "../utils/fetchData";
const Sidebar: React.FC = () => { const Sidebar: React.FC = () => {
@@ -21,48 +22,41 @@ const Sidebar: React.FC = () => {
const sorted = [...items].sort((a, b) => Number(a.inSafe) - Number(b.inSafe)); const sorted = [...items].sort((a, b) => Number(a.inSafe) - Number(b.inSafe));
return ( return (
<aside className="w-full md:w-72 md:h-[calc(100vh-2rem)] md:sticky md:top-4 overflow-y-auto bg-white rounded-2xl p-3 sm:p-4 ring-1 ring-slate-200 shadow-sm"> <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">
<h2 className="text-lg sm:text-xl font-bold mb-3 text-slate-900 tracking-tight flex items-center justify-between"> <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"> <span className="flex items-center gap-2 min-w-0 flex-1 truncate">
<svg <MonitorSmartphone className="w-5 h-5 text-slate-700 shrink-0" />
className="w-5 h-5 text-slate-700" <span className="truncate">Geräte</span>
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
aria-hidden="true"
>
<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>
Geräte Übersicht
</span> </span>
{outCount > 0 && ( {outCount > 0 && (
<span className="text-[10px] sm:text-xs px-2 py-0.5 rounded-full bg-amber-100 text-amber-700"> <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 {outCount} außerhalb
</span> </span>
)} )}
</h2> </div>
{/* Mobile: horizontal scroll, Desktop: vertical list */} {/* Scroll area */}
<div className="flex gap-3 overflow-x-auto snap-x snap-mandatory pb-1 -mr-1 pr-2 md:block md:space-y-3 md:pr-1"> <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) => ( {sorted.map((item: any) => (
<div <div
key={item.item_name} key={item.item_name}
className={`bg-white rounded-xl p-3 sm:p-4 ring-1 ring-slate-200 hover:ring-slate-300 transition ${ 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 ? "" : "ring-red-200" item.inSafe
} shrink-0 snap-start min-w-[240px] md:min-w-0`} ? "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"> <div className="flex items-start gap-3">
<span className="relative mt-0.5 inline-flex" aria-hidden="true"> <span
className="relative mt-0.5 inline-flex"
aria-hidden="true"
>
{!item.inSafe && ( {!item.inSafe && (
<span className="absolute inline-flex h-3 w-3 rounded-full bg-red-400 opacity-75 animate-ping"></span> <span className="absolute inline-flex h-3 w-3 rounded-full bg-red-400 opacity-75 animate-ping"></span>
)} )}
<span <span
className={`inline-block w-3 h-3 rounded-full ${ className={`inline-block w-3 h-3 rounded-full ring-2 ring-white ${
item.inSafe ? "bg-emerald-500" : "bg-red-500" item.inSafe ? "bg-emerald-500" : "bg-red-500"
}`} }`}
title={ title={
@@ -84,11 +78,16 @@ const Sidebar: React.FC = () => {
))} ))}
</div> </div>
<div className="mt-3 text-[10px] sm:text-xs text-slate-500 items-center gap-4 hidden md:flex"> <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-block w-3 h-3 bg-emerald-500 rounded-full"></span> <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>
Verfügbar Verfügbar
<span className="inline-block w-3 h-3 bg-red-500 rounded-full"></span> </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 Außerhalb des Schließfachs
</span>
</div>
</div> </div>
</aside> </aside>
); );

View File

@@ -10,24 +10,20 @@ type LayoutProps = {
const Layout: React.FC<LayoutProps> = ({ children, onLogout }) => { const Layout: React.FC<LayoutProps> = ({ children, onLogout }) => {
return ( return (
<div className="min-h-screen flex bg-slate-50 text-slate-800"> <div className="h-screen overflow-hidden flex bg-slate-50 text-slate-800">
{/* Main */} {/* Main */}
<main className="flex-1 flex flex-col items-center px-3 sm:px-5 py-4 sm:py-8"> <main className="flex-1 min-h-0 overflow-hidden flex flex-col items-center px-3 sm:px-5 py-4 sm:py-8">
{/* Sidebar on mobile appears inline on top; on desktop it's a sticky column */} {/* Sidebar on mobile appears inline on top; on desktop it's a sticky column */}
<div className="w-full max-w-5xl md:flex md:gap-6"> <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="block md:hidden mb-3"> <div className="hidden md:flex md:flex-col md:shrink-0 md:w-72 md:min-h-0 md:h-full">
<Sidebar /> <Sidebar />
</div> </div>
<div className="hidden md:block md:shrink-0 md:w-72"> <div className="flex-1 min-w-0 min-h-0 h-full flex flex-col overflow-hidden">
<Sidebar />
</div>
<div className="flex-1 min-w-0">
<div className="w-full"> <div className="w-full">
<Header onLogout={onLogout} /> <Header onLogout={onLogout} />
</div> </div>
<div className="w-full bg-white shadow-md md:shadow-lg rounded-2xl p-4 sm:p-6 ring-1 ring-slate-200"> <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} {children}
</div> </div>
</div> </div>

View File

@@ -4,9 +4,12 @@ import "./index.css";
import App from "./App.tsx"; import App from "./App.tsx";
import { ToastContainer } from "react-toastify"; import { ToastContainer } from "react-toastify";
import "react-toastify/dist/ReactToastify.css"; import "react-toastify/dist/ReactToastify.css";
import { QueryClientProvider } from "@tanstack/react-query";
import { queryClient } from "./utils/queryClient";
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<QueryClientProvider client={queryClient}>
<App /> <App />
<ToastContainer <ToastContainer
position="top-right" position="top-right"
@@ -21,5 +24,6 @@ createRoot(document.getElementById("root")!).render(
theme="colored" theme="colored"
style={{ zIndex: 9999 }} style={{ zIndex: 9999 }}
/> />
</QueryClientProvider>
</StrictMode> </StrictMode>
); );

View File

@@ -0,0 +1,11 @@
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,5 +1,6 @@
import { myToast } from "./toastify"; import { myToast } from "./toastify";
import Cookies from "js-cookie"; import Cookies from "js-cookie";
import { queryClient } from "./queryClient";
export const handleDeleteLoan = async (loanID: number): Promise<boolean> => { export const handleDeleteLoan = async (loanID: number): Promise<boolean> => {
try { try {
@@ -92,5 +93,10 @@ export const createLoan = async (startDate: string, endDate: string) => {
removeArr = []; removeArr = [];
Cookies.set("removeArr", "[]"); Cookies.set("removeArr", "[]");
myToast("Ausleihe erfolgreich erstellt!", "success"); myToast("Ausleihe erfolgreich erstellt!", "success");
queryClient.invalidateQueries({ queryKey: ["userLoans"] });
queryClient.invalidateQueries({ queryKey: ["allLoans"] });
queryClient.invalidateQueries({ queryKey: ["borrowableItems"] });
return true; return true;
}; };