feat: Implement connected user API and integrate chat and report components

- Added API routes for fetching connected users, keywords, missions, and stations.
- Created a new QueryProvider component for managing query states and socket events.
- Introduced connection stores for dispatch and pilot, managing socket connections and states.
- Updated Prisma schema for connected aircraft model.
- Enhanced UI with toast notifications for status updates and chat interactions.
- Implemented query functions for fetching connected users and keywords with error handling.
This commit is contained in:
PxlLoewe
2025-05-07 00:43:45 -07:00
parent 152b3d4689
commit 50f42e99d3
49 changed files with 1040 additions and 701 deletions

View File

@@ -9,11 +9,12 @@ import { handleConnectDispatch } from "socket-events/connect-dispatch";
import router from "routes/router"; import router from "routes/router";
import cors from "cors"; import cors from "cors";
import { handleSendMessage } from "socket-events/send-message"; import { handleSendMessage } from "socket-events/send-message";
import { handleConnectPilot } from "socket-events/connect-pilot";
const app = express(); const app = express();
const server = createServer(app); const server = createServer(app);
const io = new Server(server, { export const io = new Server(server, {
adapter: createAdapter(pubClient, subClient), adapter: createAdapter(pubClient, subClient),
cors: {}, cors: {},
}); });
@@ -21,6 +22,7 @@ io.use(jwtMiddleware);
io.on("connection", (socket) => { io.on("connection", (socket) => {
socket.on("connect-dispatch", handleConnectDispatch(socket, io)); socket.on("connect-dispatch", handleConnectDispatch(socket, io));
socket.on("connect-pilot", handleConnectPilot(socket, io));
socket.on("send-message", handleSendMessage(socket, io)); socket.on("send-message", handleSendMessage(socket, io));
}); });

View File

@@ -1,5 +1,6 @@
import { prisma } from "@repo/db"; import { prisma } from "@repo/db";
import { Router } from "express"; import { Router } from "express";
import { io } from "../index";
const router = Router(); const router = Router();
@@ -41,6 +42,7 @@ router.put("/", async (req, res) => {
const newMission = await prisma.mission.create({ const newMission = await prisma.mission.create({
data: req.body, data: req.body,
}); });
io.to("missions").emit("new-mission", newMission);
res.status(201).json(newMission); res.status(201).json(newMission);
} catch (error) { } catch (error) {
res.status(500).json({ error: "Failed to create mission" }); res.status(500).json({ error: "Failed to create mission" });
@@ -55,6 +57,7 @@ router.patch("/:id", async (req, res) => {
where: { id: Number(id) }, where: { id: Number(id) },
data: req.body, data: req.body,
}); });
io.to("missions").emit("update-mission", updatedMission);
res.json(updatedMission); res.json(updatedMission);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
@@ -69,6 +72,7 @@ router.delete("/:id", async (req, res) => {
await prisma.mission.delete({ await prisma.mission.delete({
where: { id: Number(id) }, where: { id: Number(id) },
}); });
io.to("missions").emit("delete-mission", id);
res.status(204).send(); res.status(204).send();
} catch (error) { } catch (error) {
console.error(error); console.error(error);

View File

@@ -1,5 +1,4 @@
import { getPublicUser, prisma, User } from "@repo/db"; import { getPublicUser, prisma, User } from "@repo/db";
import { pubClient } from "modules/redis";
import { Server, Socket } from "socket.io"; import { Server, Socket } from "socket.io";
export const handleConnectDispatch = export const handleConnectDispatch =
@@ -80,9 +79,10 @@ export const handleConnectDispatch =
socket.join("dispatchers"); // Dem Dispatcher-Raum beitreten socket.join("dispatchers"); // Dem Dispatcher-Raum beitreten
socket.join(`user:${user.id}`); // Dem User-Raum beitreten socket.join(`user:${user.id}`); // Dem User-Raum beitreten
socket.join("missions");
io.to("dispatchers").emit("dispatcher-update"); io.to("dispatchers").emit("dispatchers-update");
io.to("pilots").emit("dispatcher-update"); io.to("pilots").emit("dispatchers-update");
socket.on("disconnect", async () => { socket.on("disconnect", async () => {
console.log("Disconnected from dispatch server"); console.log("Disconnected from dispatch server");
@@ -94,6 +94,8 @@ export const handleConnectDispatch =
logoutTime: new Date().toISOString(), logoutTime: new Date().toISOString(),
}, },
}); });
io.to("dispatchers").emit("dispatchers-update");
io.to("pilots").emit("dispatchers-update");
}); });
socket.on("reconnect", async () => { socket.on("reconnect", async () => {
console.log("Reconnected to dispatch server"); console.log("Reconnected to dispatch server");

View File

@@ -1,25 +1,42 @@
import { getPublicUser, prisma } from "@repo/db"; import { getPublicUser, prisma } from "@repo/db";
import { Server, Socket } from "socket.io"; import { Server, Socket } from "socket.io";
export const handleConnectDispatch = export const handleConnectPilot =
(socket: Socket, io: Server) => (socket: Socket, io: Server) =>
async ({ async ({
logoffTime, logoffTime,
stationId, stationId,
}: { }: {
logoffTime: string; logoffTime: string;
stationId: number; stationId: string;
}) => { }) => {
try { try {
const user = socket.data.user; // User ID aus dem JWT-Token
const userId = socket.data.user.id; // User ID aus dem JWT-Token const userId = socket.data.user.id; // User ID aus dem JWT-Token
console.log("User connected to dispatch server");
const user = await prisma.user.findUnique({ if (!user) return Error("User not found");
const existingConnection = await prisma.connectedAircraft.findFirst({
where: { where: {
id: userId, userId: user.id,
logoutTime: null,
}, },
}); });
if (!user) return Error("User not found"); if (existingConnection) {
await io
.to(`user:${user.id}`)
.emit("force-disconnect", "double-connection");
await prisma.connectedAircraft.updateMany({
where: {
userId: user.id,
logoutTime: null,
},
data: {
logoutTime: new Date().toISOString(),
},
});
}
let parsedLogoffDate = null; let parsedLogoffDate = null;
if (logoffTime.length > 0) { if (logoffTime.length > 0) {
@@ -44,7 +61,7 @@ export const handleConnectDispatch =
lastHeartbeat: new Date().toISOString(), lastHeartbeat: new Date().toISOString(),
userId: userId, userId: userId,
loginTime: new Date().toISOString(), loginTime: new Date().toISOString(),
stationId: stationId, stationId: parseInt(stationId),
/* user: { connect: { id: userId } }, // Ensure the user relationship is set /* user: { connect: { id: userId } }, // Ensure the user relationship is set
station: { connect: { id: stationId } }, // Ensure the station relationship is set */ station: { connect: { id: stationId } }, // Ensure the station relationship is set */
}, },
@@ -54,8 +71,8 @@ export const handleConnectDispatch =
socket.join(`user:${userId}`); // Join the user-specific room socket.join(`user:${userId}`); // Join the user-specific room
socket.join(`station:${stationId}`); // Join the station-specific room socket.join(`station:${stationId}`); // Join the station-specific room
io.to("dispatchers").emit("dispatcher-update"); io.to("dispatchers").emit("pilots-update");
io.to("pilots").emit("dispatcher-update"); io.to("pilots").emit("pilots-update");
// Add a listener for station-specific events // Add a listener for station-specific events
socket.on(`station:${stationId}:event`, async (data) => { socket.on(`station:${stationId}:event`, async (data) => {
@@ -66,7 +83,7 @@ export const handleConnectDispatch =
socket.on("disconnect", async () => { socket.on("disconnect", async () => {
console.log("Disconnected from dispatch server"); console.log("Disconnected from dispatch server");
await prisma.connectedDispatcher.update({ await prisma.connectedAircraft.update({
where: { where: {
id: connectedAircraftEntry.id, id: connectedAircraftEntry.id,
}, },
@@ -74,19 +91,8 @@ export const handleConnectDispatch =
logoutTime: new Date().toISOString(), logoutTime: new Date().toISOString(),
}, },
}); });
}); io.to("dispatchers").emit("pilots-update");
io.to("pilots").emit("pilots-update");
socket.on("reconnect", async () => {
console.log("Reconnected to dispatch server");
await prisma.connectedDispatcher.update({
where: {
id: connectedAircraftEntry.id,
},
data: {
lastHeartbeat: new Date().toISOString(),
logoutTime: null,
},
});
}); });
} catch (error) { } catch (error) {
console.error("Error connecting to dispatch server:", error); console.error("Error connecting to dispatch server:", error);

View File

@@ -0,0 +1,47 @@
// components/TanstackProvider.tsx
"use client";
import { toast } from "react-hot-toast";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactNode, useEffect, useState } from "react";
import { dispatchSocket } from "dispatch/socket";
export function QueryProvider({ children }: { children: ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
mutations: {
onError: (error) => {
toast.error("An error occurred: " + (error as Error).message, {
position: "top-right",
});
},
},
},
}),
);
useEffect(() => {
const invalidateMission = () => {
queryClient.invalidateQueries({
queryKey: ["missions"],
});
};
const invalidateConnectedUsers = () => {
queryClient.invalidateQueries({
queryKey: ["connected-users"],
});
};
dispatchSocket.on("update-mission", invalidateMission);
dispatchSocket.on("delete-mission", invalidateMission);
dispatchSocket.on("new-mission", invalidateMission);
dispatchSocket.on("dispatchers-update", invalidateConnectedUsers);
dispatchSocket.on("pilots-update", invalidateConnectedUsers);
}, [queryClient]);
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}

View File

@@ -0,0 +1,117 @@
import { useState } from "react";
import { toast } from "react-hot-toast";
interface ToastCard {
id: number;
title: string;
content: string;
}
const MapToastCard2 = () => {
const [cards, setCards] = useState<ToastCard[]>([]);
const [openCardId, setOpenCardId] = useState<number | null>(null);
const addCard = () => {
const newCard: ToastCard = {
id: Date.now(),
title: `Einsatz #${cards.length + 1}`,
content: `Inhalt von Einsatz #${cards.length + 1}.`,
};
setCards([...cards, newCard]);
// DEBUG
/* toast("😖 Christoph 31 sendet Status 4", {
duration: 10000,
}); */
// DEBUG
const toastId = toast.custom(
<div
style={{
display: "flex",
alignItems: "center",
lineHeight: 1.3,
willChange: "transform",
boxShadow:
"0 3px 10px rgba(0, 0, 0, 0.1), 0 3px 3px rgba(0, 0, 0, 0.05)",
maxWidth: "350px",
pointerEvents: "auto",
padding: "8px 10px",
borderRadius: "8px",
background: "var(--color-base-100)",
color: "var(--color-base-content)",
}}
>
<div
className="toastText flex items-center"
style={{
display: "flex",
justifyContent: "center",
margin: "4px 10px",
color: "inherit",
flex: "1 1 auto",
whiteSpace: "pre-line",
}}
>
😖 Christoph 31 sendet Status 5{" "}
<button
className="btn btn-sm btn-soft btn-accent ml-2"
onClick={() => toast.remove(toastId)}
>
U
</button>
</div>
</div>,
{
duration: 999999999,
},
);
// DEBUG
};
const removeCard = (id: number) => {
setCards(cards.filter((card) => card.id !== id));
};
const toggleCard = (id: number) => {
setOpenCardId(openCardId === id ? null : id);
};
return (
<div className="absolute top-4 right-4 z-[1000] flex flex-col space-y-4">
{/* DEBUG */}
<button
onClick={addCard}
className="mb-4 p-2 bg-blue-500 text-white rounded self-end"
>
Debug Einsatz
</button>
{/* DEBUG */}
{cards.map((card) => (
<div
key={card.id}
className="collapse collapse-arrow bg-base-100 border-base-300 border w-120 relative"
>
<input
type="checkbox"
className="absolute top-0 left-0 opacity-0"
checked={openCardId === card.id}
onChange={() => toggleCard(card.id)}
/>
<div className="collapse-title font-semibold flex justify-between items-center">
<span>{card.title}</span>
<button
className="btn btn-sm btn-circle btn-ghost z-10 absolute top-3.5 right-8"
onClick={(e) => {
removeCard(card.id);
}}
>
</button>
</div>
<div className="collapse-content text-sm">{card.content}</div>
</div>
))}
</div>
);
};
export default MapToastCard2;

View File

@@ -2,10 +2,11 @@
import { ChatBubbleIcon, PaperPlaneIcon } from "@radix-ui/react-icons"; import { ChatBubbleIcon, PaperPlaneIcon } from "@radix-ui/react-icons";
import { useLeftMenuStore } from "_store/leftMenuStore"; import { useLeftMenuStore } from "_store/leftMenuStore";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { Fragment, useEffect, useRef, useState } from "react"; import { Fragment, useEffect, useState } from "react";
import { cn } from "helpers/cn"; import { cn } from "helpers/cn";
import { getConenctedUsers } from "helpers/axios"; import { asPublicUser } from "@repo/db";
import { asPublicUser, ConnectedAircraft, ConnectedDispatcher } from "@repo/db"; import { useQuery } from "@tanstack/react-query";
import { getConnectedUserAPI } from "querys/connected-user";
export const Chat = () => { export const Chat = () => {
const { const {
@@ -22,49 +23,24 @@ export const Chat = () => {
} = useLeftMenuStore(); } = useLeftMenuStore();
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const session = useSession(); const session = useSession();
const [addTabValue, setAddTabValue] = useState<string>(""); const [addTabValue, setAddTabValue] = useState<string>("default");
const [message, setMessage] = useState<string>(""); const [message, setMessage] = useState<string>("");
const [connectedUser, setConnectedUser] = useState<
(ConnectedAircraft | ConnectedDispatcher)[] | null const { data: connectedUser } = useQuery({
>(null); queryKey: ["connected-users"],
const timeout = useRef<NodeJS.Timeout | null>(null); queryFn: async () => {
const user = await getConnectedUserAPI();
return user.filter((u) => u.userId !== session.data?.user.id);
},
refetchInterval: 10000,
refetchOnWindowFocus: true,
});
useEffect(() => { useEffect(() => {
if (!session.data?.user.id) return; if (!session.data?.user.id) return;
setOwnId(session.data.user.id); setOwnId(session.data.user.id);
}, [session, setOwnId]); }, [session, setOwnId]);
useEffect(() => {
const fetchConnectedUser = async () => {
const data = await getConenctedUsers();
if (data) {
const filteredConnectedUser = data.filter((user) => {
return (
user.userId !== session.data?.user.id &&
!Object.keys(chats).includes(user.userId)
);
return true;
});
setConnectedUser(filteredConnectedUser);
}
if (!addTabValue && data[0]) setAddTabValue(data[0].userId);
};
timeout.current = setInterval(() => {
fetchConnectedUser();
}, 1000);
fetchConnectedUser();
return () => {
if (timeout.current) {
clearInterval(timeout.current);
timeout.current = null;
console.log("cleared");
}
};
}, [addTabValue, chats, session.data?.user.id]);
return ( return (
<div className={cn("dropdown dropdown-right", chatOpen && "dropdown-open")}> <div className={cn("dropdown dropdown-right", chatOpen && "dropdown-open")}>
<div className="indicator"> <div className="indicator">
@@ -100,8 +76,16 @@ export const Chat = () => {
onChange={(e) => setAddTabValue(e.target.value)} onChange={(e) => setAddTabValue(e.target.value)}
> >
{!connectedUser?.length && ( {!connectedUser?.length && (
<option disabled={true}>Keine Chatpartner gefunden</option> <option disabled value="default">
Keine Chatpartner gefunden
</option>
)} )}
{connectedUser?.length && (
<option disabled value="default">
Chatpartner auswählen
</option>
)}
{connectedUser?.map((user) => ( {connectedUser?.map((user) => (
<option key={user.userId} value={user.userId}> <option key={user.userId} value={user.userId}>
{asPublicUser(user.publicUser).fullName} {asPublicUser(user.publicUser).fullName}

View File

@@ -1,53 +1,36 @@
"use client"; "use client";
import { ExclamationTriangleIcon, PaperPlaneIcon } from "@radix-ui/react-icons"; import { ExclamationTriangleIcon, PaperPlaneIcon } from "@radix-ui/react-icons";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useState } from "react";
import { cn } from "helpers/cn"; import { cn } from "helpers/cn";
import { getConenctedUsers, serverApi } from "helpers/axios"; import { serverApi } from "helpers/axios";
import { useLeftMenuStore } from "_store/leftMenuStore"; import { useLeftMenuStore } from "_store/leftMenuStore";
import { asPublicUser, ConnectedAircraft, ConnectedDispatcher } from "@repo/db"; import { asPublicUser } from "@repo/db";
import { useQuery } from "@tanstack/react-query";
import { getConnectedUserAPI } from "querys/connected-user";
export const Report = () => { export const Report = () => {
const { setChatOpen, setReportTabOpen, reportTabOpen, setOwnId } = const { setChatOpen, setReportTabOpen, reportTabOpen, setOwnId } =
useLeftMenuStore(); useLeftMenuStore();
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const session = useSession(); const session = useSession();
const [selectedPlayer, setSelectedPlayer] = useState<string>(""); const [selectedPlayer, setSelectedPlayer] = useState<string>("default");
const [message, setMessage] = useState<string>(""); const [message, setMessage] = useState<string>("");
const [connectedUser, setConnectedUser] = useState<
(ConnectedAircraft | ConnectedDispatcher)[] | null
>(null);
const timeout = useRef<NodeJS.Timeout | null>(null);
useEffect(() => { useEffect(() => {
if (!session.data?.user.id) return; if (!session.data?.user.id) return;
setOwnId(session.data.user.id); setOwnId(session.data.user.id);
}, [session, setOwnId]); }, [session, setOwnId]);
useEffect(() => { const { data: connectedUser } = useQuery({
const fetchConnectedUser = async () => { queryKey: ["connected-users"],
const data = await getConenctedUsers(); queryFn: async () => {
if (data) { const user = await getConnectedUserAPI();
const filteredConnectedUser = data.filter( return user.filter((u) => u.userId !== session.data?.user.id);
(user) => user.userId !== session.data?.user.id, },
); refetchInterval: 10000,
setConnectedUser(filteredConnectedUser); refetchOnWindowFocus: true,
} });
if (!selectedPlayer && data[0]) setSelectedPlayer(data[0].userId);
};
timeout.current = setInterval(() => {
fetchConnectedUser();
}, 1000);
fetchConnectedUser();
return () => {
if (timeout.current) {
clearInterval(timeout.current);
timeout.current = null;
}
};
}, [selectedPlayer, session.data?.user.id]);
return ( return (
<div <div
@@ -83,7 +66,14 @@ export const Report = () => {
onChange={(e) => setSelectedPlayer(e.target.value)} onChange={(e) => setSelectedPlayer(e.target.value)}
> >
{!connectedUser?.length && ( {!connectedUser?.length && (
<option disabled={true}>Keine Spieler gefunden</option> <option disabled value="default">
Keine Chatpartner gefunden
</option>
)}
{connectedUser?.length && (
<option disabled value="default">
Chatpartner auswählen
</option>
)} )}
{connectedUser?.map((user) => ( {connectedUser?.map((user) => (
<option key={user.userId} value={user.userId}> <option key={user.userId} value={user.userId}>

View File

@@ -1,5 +1,5 @@
import { create } from "zustand"; import { create } from "zustand";
import { socket } from "../dispatch/socket"; import { dispatchSocket } from "../../dispatch/socket";
interface ConnectionStore { interface ConnectionStore {
status: "connected" | "disconnected" | "connecting" | "error"; status: "connected" | "disconnected" | "connecting" | "error";
@@ -20,11 +20,11 @@ export const useDispatchConnectionStore = create<ConnectionStore>((set) => ({
connect: async (uid, selectedZone, logoffTime) => connect: async (uid, selectedZone, logoffTime) =>
new Promise((resolve) => { new Promise((resolve) => {
set({ status: "connecting", message: "" }); set({ status: "connecting", message: "" });
socket.auth = { uid }; dispatchSocket.auth = { uid };
set({ selectedZone }); set({ selectedZone });
socket.connect(); dispatchSocket.connect();
socket.once("connect", () => { dispatchSocket.once("connect", () => {
socket.emit("connect-dispatch", { dispatchSocket.emit("connect-dispatch", {
logoffTime, logoffTime,
selectedZone, selectedZone,
}); });
@@ -32,26 +32,26 @@ export const useDispatchConnectionStore = create<ConnectionStore>((set) => ({
}); });
}), }),
disconnect: () => { disconnect: () => {
socket.disconnect(); dispatchSocket.disconnect();
}, },
})); }));
socket.on("connect", () => { dispatchSocket.on("connect", () => {
useDispatchConnectionStore.setState({ status: "connected", message: "" }); useDispatchConnectionStore.setState({ status: "connected", message: "" });
}); });
socket.on("connect_error", (err) => { dispatchSocket.on("connect_error", (err) => {
useDispatchConnectionStore.setState({ useDispatchConnectionStore.setState({
status: "error", status: "error",
message: err.message, message: err.message,
}); });
}); });
socket.on("disconnect", () => { dispatchSocket.on("disconnect", () => {
useDispatchConnectionStore.setState({ status: "disconnected", message: "" }); useDispatchConnectionStore.setState({ status: "disconnected", message: "" });
}); });
socket.on("force-disconnect", (reason: string) => { dispatchSocket.on("force-disconnect", (reason: string) => {
console.log("force-disconnect", reason); console.log("force-disconnect", reason);
useDispatchConnectionStore.setState({ useDispatchConnectionStore.setState({
status: "disconnected", status: "disconnected",

View File

@@ -1,6 +1,7 @@
import { create } from "zustand"; import { create } from "zustand";
import { ChatMessage } from "@repo/db"; import { ChatMessage } from "@repo/db";
import { socket } from "dispatch/socket"; import { dispatchSocket } from "dispatch/socket";
import { pilotSocket } from "pilot/socket";
interface ChatStore { interface ChatStore {
reportTabOpen: boolean; reportTabOpen: boolean;
@@ -33,7 +34,7 @@ export const useLeftMenuStore = create<ChatStore>((set, get) => ({
chats: {}, chats: {},
sendMessage: (userId: string, message: string) => { sendMessage: (userId: string, message: string) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
socket.emit( dispatchSocket.emit(
"send-message", "send-message",
{ userId, message }, { userId, message },
({ error }: { error?: string }) => { ({ error }: { error?: string }) => {
@@ -96,7 +97,16 @@ export const useLeftMenuStore = create<ChatStore>((set, get) => ({
}, },
})); }));
socket.on( dispatchSocket.on(
"chat-message",
({ userId, message }: { userId: string; message: ChatMessage }) => {
const store = useLeftMenuStore.getState();
console.log("chat-message", userId, message);
// Update the chat store with the new message
store.addMessage(userId, message);
},
);
pilotSocket.on(
"chat-message", "chat-message",
({ userId, message }: { userId: string; message: ChatMessage }) => { ({ userId, message }: { userId: string; message: ChatMessage }) => {
const store = useLeftMenuStore.getState(); const store = useLeftMenuStore.getState();

View File

@@ -1,73 +0,0 @@
import { Mission, Prisma } from "@repo/db";
import { MissionOptionalDefaults } from "@repo/db/zod";
import { serverApi } from "helpers/axios";
import { create } from "zustand";
import { toast } from "react-hot-toast";
import axios from "axios";
interface MissionStore {
missions: Mission[];
setMissions: (missions: Mission[]) => void;
getMissions: () => Promise<undefined>;
createMission: (mission: MissionOptionalDefaults) => Promise<Mission>;
deleteMission: (id: number) => Promise<void>;
editMission: (id: number, mission: Partial<Mission>) => Promise<void>;
}
export const useMissionsStore = create<MissionStore>((set) => ({
missions: [],
setMissions: (missions) => set({ missions }),
createMission: async (mission) => {
const { data } = await serverApi.put<Mission>("/mission", mission);
set((state) => ({ missions: [...state.missions, data] }));
return data;
},
editMission: async (id, mission) => {
const { data, status } = await serverApi.patch<Mission>(
`/mission/${id}`,
mission,
);
if (status.toString().startsWith("2") && data) {
set((state) => ({
missions: state.missions.map((m) => (m.id === id ? data : m)),
}));
toast.success("Mission updated successfully");
} else {
toast.error("Failed to update mission");
}
},
deleteMission: async (id) => {
serverApi
.delete(`/mission/${id}`)
.then((res) => {
if (res.status.toString().startsWith("2")) {
set((state) => ({
missions: state.missions.filter((mission) => mission.id !== id),
}));
toast.success("Mission deleted successfully");
} else {
toast.error("Failed to delete mission");
}
})
.catch((err) => {
toast.error("Failed to delete mission");
});
},
getMissions: async () => {
const { data } = await serverApi.post<Mission[]>("/mission", {
filter: {
OR: [{ state: "draft" }, { state: "running" }],
} as Prisma.MissionWhereInput,
});
set({ missions: data });
return undefined;
},
}));
useMissionsStore
.getState()
.getMissions()
.then(() => {
console.log("Missions loaded");
});

View File

@@ -0,0 +1,63 @@
import { create } from "zustand";
import { dispatchSocket } from "../../dispatch/socket";
import { Station } from "@repo/db";
import { pilotSocket } from "pilot/socket";
interface ConnectionStore {
status: "connected" | "disconnected" | "connecting" | "error";
message: string;
selectedStation: Station | null;
connect: (
uid: string,
stationId: string,
logoffTime: string,
station: Station,
) => Promise<void>;
disconnect: () => void;
}
export const useDispatchConnectionStore = create<ConnectionStore>((set) => ({
status: "disconnected",
message: "",
selectedStation: null,
connect: async (uid, stationId, logoffTime, station) =>
new Promise((resolve) => {
set({ status: "connecting", message: "", selectedStation: station });
dispatchSocket.auth = { uid };
dispatchSocket.connect();
dispatchSocket.once("connect", () => {
dispatchSocket.emit("connect-pilot", {
logoffTime,
stationId,
});
resolve();
});
}),
disconnect: () => {
dispatchSocket.disconnect();
},
}));
dispatchSocket.on("connect", () => {
pilotSocket.disconnect();
useDispatchConnectionStore.setState({ status: "connected", message: "" });
});
dispatchSocket.on("connect_error", (err) => {
useDispatchConnectionStore.setState({
status: "error",
message: err.message,
});
});
dispatchSocket.on("disconnect", () => {
useDispatchConnectionStore.setState({ status: "disconnected", message: "" });
});
dispatchSocket.on("force-disconnect", (reason: string) => {
console.log("force-disconnect", reason);
useDispatchConnectionStore.setState({
status: "disconnected",
message: reason,
});
});

View File

@@ -1,7 +1,7 @@
import { create } from "zustand"; import { create } from "zustand";
import { socket } from "../dispatch/socket"; import { socket } from "../dispatch/socket";
export const stationStore = create((set) => { export const useStationStore = create((set) => {
return { return {
stations: [], stations: [],
setStations: (stations: any) => set({ stations }), setStations: (stations: any) => set({ stations }),

View File

@@ -0,0 +1,28 @@
import { NextResponse } from "next/server";
import { prisma } from "@repo/db";
export async function GET(): Promise<NextResponse> {
try {
const connectedDispatcher = await prisma.connectedDispatcher.findMany({
where: {
logoutTime: null,
},
});
const connectedAircraft = await prisma.connectedAircraft.findMany({
where: {
logoutTime: null,
},
});
return NextResponse.json([...connectedDispatcher, ...connectedAircraft], {
status: 200,
});
} catch (error) {
console.error(error);
return NextResponse.json(
{ error: "Failed to fetch connected User" },
{ status: 500 },
);
}
}

View File

@@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@repo/db";
export async function GET(req: NextRequest): Promise<NextResponse> {
const { searchParams } = new URL(req.url);
const id = searchParams.get("id");
const filter = searchParams.get("filter");
try {
const data = await prisma.keyword.findMany({
where: {
id: id ? Number(id) : undefined,
...(filter ? JSON.parse(filter) : {}),
},
});
return NextResponse.json(data, { status: 200 });
} catch (error) {
console.error(error);
return NextResponse.json(
{ error: "Failed to fetch keyword" },
{ status: 500 },
);
}
}

View File

@@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@repo/db";
export async function GET(req: NextRequest): Promise<NextResponse> {
const { searchParams } = new URL(req.url);
const id = searchParams.get("id");
const filter = searchParams.get("filter");
try {
const data = await prisma.mission.findMany({
where: {
id: id ? Number(id) : undefined,
...(filter ? JSON.parse(filter) : {}),
},
});
return NextResponse.json(data, { status: 200 });
} catch (error) {
console.error(error);
return NextResponse.json(
{ error: "Failed to fetch mission" },
{ status: 500 },
);
}
}

View File

@@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@repo/db";
export async function GET(req: NextRequest): Promise<NextResponse> {
const { searchParams } = new URL(req.url);
const id = searchParams.get("id");
const filter = searchParams.get("filter");
try {
const data = await prisma.station.findMany({
where: {
id: id ? Number(id) : undefined,
...(filter ? JSON.parse(filter) : {}),
},
});
return NextResponse.json(data, { status: 200 });
} catch (error) {
console.error(error);
return NextResponse.json(
{ error: "Failed to fetch station" },
{ status: 500 },
);
}
}

View File

@@ -1,4 +1,3 @@
import { useMissionsStore } from "_store/missionsStore";
import { Marker, useMap } from "react-leaflet"; import { Marker, useMap } from "react-leaflet";
import { DivIcon, Marker as LMarker, Popup as LPopup } from "leaflet"; import { DivIcon, Marker as LMarker, Popup as LPopup } from "leaflet";
import { useMapStore } from "_store/mapStore"; import { useMapStore } from "_store/mapStore";
@@ -31,6 +30,8 @@ import Einsatzdetails, {
Patientdetails, Patientdetails,
Rettungsmittel, Rettungsmittel,
} from "./_components/MissionMarkerTabs"; } from "./_components/MissionMarkerTabs";
import { useQuery } from "@tanstack/react-query";
import { getMissionsAPI } from "querys/missions";
export const MISSION_STATUS_COLORS: Record<MissionState | "attention", string> = export const MISSION_STATUS_COLORS: Record<MissionState | "attention", string> =
{ {
@@ -367,7 +368,13 @@ const MissionMarker = ({ mission }: { mission: Mission }) => {
}; };
export const MissionLayer = () => { export const MissionLayer = () => {
const missions = useMissionsStore((state) => state.missions); const { data: missions = [] } = useQuery({
queryKey: ["missions"],
queryFn: () =>
getMissionsAPI({
OR: [{ state: "draft" }, { state: "running" }],
}),
});
// IDEA: Add Marker to Map Layer / LayerGroup // IDEA: Add Marker to Map Layer / LayerGroup
return ( return (

View File

@@ -1,8 +1,8 @@
import { Mission } from "@repo/db"; import { Mission } from "@repo/db";
import { useQuery } from "@tanstack/react-query";
import { SmartPopup, useSmartPopup } from "_components/SmartPopup"; import { SmartPopup, useSmartPopup } from "_components/SmartPopup";
import { Aircraft, useAircraftsStore } from "_store/aircraftsStore"; import { Aircraft, useAircraftsStore } from "_store/aircraftsStore";
import { useMapStore } from "_store/mapStore"; import { useMapStore } from "_store/mapStore";
import { useMissionsStore } from "_store/missionsStore";
import { import {
FMS_STATUS_COLORS, FMS_STATUS_COLORS,
FMS_STATUS_TEXT_COLORS, FMS_STATUS_TEXT_COLORS,
@@ -12,6 +12,7 @@ import {
MISSION_STATUS_TEXT_COLORS, MISSION_STATUS_TEXT_COLORS,
} from "dispatch/_components/map/MissionMarkers"; } from "dispatch/_components/map/MissionMarkers";
import { cn } from "helpers/cn"; import { cn } from "helpers/cn";
import { getMissionsAPI } from "querys/missions";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useMap } from "react-leaflet"; import { useMap } from "react-leaflet";
@@ -141,7 +142,13 @@ const PopupContent = ({
export const MarkerCluster = () => { export const MarkerCluster = () => {
const map = useMap(); const map = useMap();
const aircrafts = useAircraftsStore((state) => state.aircrafts); const aircrafts = useAircraftsStore((state) => state.aircrafts);
const missions = useMissionsStore((state) => state.missions); const { data: missions } = useQuery({
queryKey: ["missions"],
queryFn: () =>
getMissionsAPI({
OR: [{ state: "draft" }, { state: "running" }],
}),
});
const [cluster, setCluster] = useState< const [cluster, setCluster] = useState<
{ {
aircrafts: Aircraft[]; aircrafts: Aircraft[];
@@ -185,7 +192,7 @@ export const MarkerCluster = () => {
]; ];
} }
}); });
missions.forEach((mission) => { missions?.forEach((mission) => {
const lat = mission.addressLat; const lat = mission.addressLat;
const lng = mission.addressLng; const lng = mission.addressLng;
const existingClusterIndex = newCluster.findIndex( const existingClusterIndex = newCluster.findIndex(

View File

@@ -24,13 +24,24 @@ import {
Mission, Mission,
MissionLog, MissionLog,
MissionMessageLog, MissionMessageLog,
Prisma,
} from "@repo/db"; } from "@repo/db";
import { useMissionsStore } from "_store/missionsStore";
import { usePannelStore } from "_store/pannelStore"; import { usePannelStore } from "_store/pannelStore";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { deleteMissionAPI, editMissionAPI } from "querys/missions";
const Einsatzdetails = ({ mission }: { mission: Mission }) => { const Einsatzdetails = ({ mission }: { mission: Mission }) => {
const { deleteMission } = useMissionsStore((state) => state); const queryClient = useQueryClient();
const deleteMissionMutation = useMutation({
mutationKey: ["missions"],
mutationFn: deleteMissionAPI,
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["missions"],
});
},
});
const { setMissionFormValues, setOpen } = usePannelStore((state) => state); const { setMissionFormValues, setOpen } = usePannelStore((state) => state);
return ( return (
<div className="p-4 text-base-content"> <div className="p-4 text-base-content">
@@ -93,7 +104,7 @@ const Einsatzdetails = ({ mission }: { mission: Mission }) => {
<button <button
className="btn btn-sm btn-error btn-outline" className="btn btn-sm btn-error btn-outline"
onClick={() => { onClick={() => {
deleteMission(mission.id); deleteMissionMutation.mutate(mission.id);
}} }}
> >
<Trash size={18} /> <Trash size={18} />
@@ -180,8 +191,22 @@ const Rettungsmittel = ({ mission }: { mission: Mission }) => {
const FMSStatusHistory = ({ mission }: { mission: Mission }) => { const FMSStatusHistory = ({ mission }: { mission: Mission }) => {
const session = useSession(); const session = useSession();
const [isAddingNote, setIsAddingNote] = useState(false); const [isAddingNote, setIsAddingNote] = useState(false);
const { editMission } = useMissionsStore((state) => state);
const [note, setNote] = useState(""); const [note, setNote] = useState("");
const queryClient = useQueryClient();
const editMissionMutation = useMutation({
mutationFn: ({
id,
mission,
}: {
id: number;
mission: Partial<Prisma.MissionUpdateInput>;
}) => editMissionAPI(id, mission),
mutationKey: ["missions"],
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["missions"] });
},
});
if (!session.data?.user) return null; if (!session.data?.user) return null;
return ( return (
@@ -220,10 +245,15 @@ const FMSStatusHistory = ({ mission }: { mission: Mission }) => {
}, },
} as MissionMessageLog, } as MissionMessageLog,
]; ];
editMission(mission.id, { editMissionMutation
.mutateAsync({
id: mission.id,
mission: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
missionLog: newMissionLog as any, missionLog: newMissionLog as any,
}).then(() => { },
})
.then(() => {
setIsAddingNote(false); setIsAddingNote(false);
setNote(""); setNote("");
}); });

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useDispatchConnectionStore } from "_store/connectionStore"; import { useDispatchConnectionStore } from "_store/dispatch/connectionStore";
import { import {
Disc, Disc,
Mic, Mic,

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { useDispatchConnectionStore } from "../../../../_store/connectionStore"; import { useDispatchConnectionStore } from "../../../../_store/dispatch/connectionStore";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
export const ConnectionBtn = () => { export const ConnectionBtn = () => {

View File

@@ -1,11 +0,0 @@
"use server";
export interface Dispatcher {
userId: string;
lastSeen: string;
loginTime: string;
logoffTime: string;
selectedZone: string;
name: string;
socketId: string;
}

View File

@@ -1,11 +1,10 @@
"use client"; "use client";
import React, { useEffect, useState } from "react"; import React, { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { BellRing, BookmarkPlus } from "lucide-react"; import { BellRing, BookmarkPlus } from "lucide-react";
import { Select } from "_components/Select"; import { Select } from "_components/Select";
import { Keyword, KEYWORD_CATEGORY, missionType, Station } from "@repo/db"; import { KEYWORD_CATEGORY, missionType, Prisma } from "@repo/db";
import { getKeywords, getStations } from "dispatch/_components/pannel/action";
import { import {
MissionOptionalDefaults, MissionOptionalDefaults,
MissionOptionalDefaultsSchema, MissionOptionalDefaultsSchema,
@@ -13,13 +12,52 @@ import {
import { usePannelStore } from "_store/pannelStore"; import { usePannelStore } from "_store/pannelStore";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { useMissionsStore } from "_store/missionsStore"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { createMissionAPI, editMissionAPI } from "querys/missions";
import { getKeywordsAPI } from "querys/keywords";
import { getStationsAPI } from "querys/stations";
export const MissionForm = () => { export const MissionForm = () => {
const { isEditingMission, editingMissionId, setEditingMission } = const { isEditingMission, editingMissionId, setEditingMission } =
usePannelStore(); usePannelStore();
const createMission = useMissionsStore((state) => state.createMission); const queryClient = useQueryClient();
const { deleteMission } = useMissionsStore((state) => state);
const { data: keywords } = useQuery({
queryKey: ["keywords"],
queryFn: () => getKeywordsAPI(),
});
const { data: stations } = useQuery({
queryKey: ["stations"],
queryFn: () => getStationsAPI(),
});
const createMissionMutation = useMutation({
mutationFn: createMissionAPI,
mutationKey: ["missions"],
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["missions"],
});
},
});
const editMissionMutation = useMutation({
mutationFn: ({
id,
mission,
}: {
id: number;
mission: Partial<Prisma.MissionUpdateInput>;
}) => editMissionAPI(id, mission),
mutationKey: ["missions"],
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["missions"],
});
},
});
const session = useSession(); const session = useSession();
const defaultFormValues = React.useMemo( const defaultFormValues = React.useMemo(
() => () =>
@@ -85,18 +123,6 @@ export const MissionForm = () => {
} }
}, [missionFormValues, form, defaultFormValues]); }, [missionFormValues, form, defaultFormValues]);
const [stations, setStations] = useState<Station[]>([]);
const [keywords, setKeywords] = useState<Keyword[]>([]);
useEffect(() => {
getKeywords().then((data) => {
setKeywords(data);
});
getStations().then((data) => {
setStations(data);
});
}, []);
console.log(form.formState.errors); console.log(form.formState.errors);
return ( return (
<form className="space-y-4"> <form className="space-y-4">
@@ -165,7 +191,7 @@ export const MissionForm = () => {
placeholder="Wähle ein oder mehrere Rettungsmittel aus" placeholder="Wähle ein oder mehrere Rettungsmittel aus"
isMulti isMulti
form={form} form={form}
options={stations.map((s) => ({ options={stations?.map((s) => ({
label: s.bosCallsign, label: s.bosCallsign,
value: s.id.toString(), value: s.id.toString(),
}))} }))}
@@ -217,7 +243,7 @@ export const MissionForm = () => {
{...form.register("missionKeywordAbbreviation")} {...form.register("missionKeywordAbbreviation")}
className="select select-primary select-bordered w-full mb-4" className="select select-primary select-bordered w-full mb-4"
onChange={(e) => { onChange={(e) => {
const keyword = keywords.find( const keyword = keywords?.find(
(k) => k.abreviation === e.target.value, (k) => k.abreviation === e.target.value,
); );
form.setValue("missionKeywordName", keyword?.name || null); form.setValue("missionKeywordName", keyword?.name || null);
@@ -232,7 +258,8 @@ export const MissionForm = () => {
<option disabled value={""}> <option disabled value={""}>
Einsatzstichwort auswählen... Einsatzstichwort auswählen...
</option> </option>
{keywords {keywords &&
keywords
.filter( .filter(
(k) => k.category === form.watch("missionKeywordCategory"), (k) => k.category === form.watch("missionKeywordCategory"),
) )
@@ -251,7 +278,8 @@ export const MissionForm = () => {
<option disabled value=""> <option disabled value="">
Einsatz Szenerie auswählen... Einsatz Szenerie auswählen...
</option> </option>
{keywords {keywords &&
keywords
.find((k) => k.name === form.watch("missionKeywordName")) .find((k) => k.name === form.watch("missionKeywordName"))
?.hpgMissionTypes?.map((missionString) => { ?.hpgMissionTypes?.map((missionString) => {
const [name] = missionString.split(":"); const [name] = missionString.split(":");
@@ -277,8 +305,6 @@ export const MissionForm = () => {
/> />
)} )}
</div> </div>
{/* Patienteninformationen Section */}
<div className="form-control"> <div className="form-control">
<h2 className="text-lg font-bold mb-2">Patienteninformationen</h2> <h2 className="text-lg font-bold mb-2">Patienteninformationen</h2>
<textarea <textarea
@@ -287,22 +313,24 @@ export const MissionForm = () => {
className="textarea textarea-primary textarea-bordered w-full" className="textarea textarea-primary textarea-bordered w-full"
/> />
</div> </div>
<p className="text-sm text-error"> <p className="text-sm text-error">
Du musst noch ein Gebäude auswählen, um den Einsatz zu erstellen. Du musst noch ein Gebäude auswählen, um den Einsatz zu erstellen.
</p> </p>
<div className="form-control min-h-[140px] max-w-[320px]"> <div className="form-control min-h-[140px]">
<div className="flex gap-2"> <div className="flex gap-2">
{isEditingMission && editingMissionId ? ( {isEditingMission && editingMissionId ? (
<button <button
type="button" type="button"
className="btn btn-primary btn-block" className="btn btn-primary flex-1"
onClick={form.handleSubmit( onClick={form.handleSubmit(
async (mission: MissionOptionalDefaults) => { async (mission: MissionOptionalDefaults) => {
try { try {
deleteMission(Number(editingMissionId)); const newMission = await editMissionMutation.mutateAsync({
const newMission = await createMission(mission); id: Number(editingMissionId),
mission:
mission as unknown as Partial<Prisma.MissionUpdateInput>,
});
toast.success( toast.success(
`Einsatz ${newMission.id} erfolgreich aktualisiert`, `Einsatz ${newMission.id} erfolgreich aktualisiert`,
); );
@@ -327,7 +355,10 @@ export const MissionForm = () => {
onClick={form.handleSubmit( onClick={form.handleSubmit(
async (mission: MissionOptionalDefaults) => { async (mission: MissionOptionalDefaults) => {
try { try {
const newMission = await createMission(mission); const newMission =
await createMissionMutation.mutateAsync(
mission as unknown as Prisma.MissionCreateInput,
);
toast.success(`Einsatz ${newMission.id} erstellt`); toast.success(`Einsatz ${newMission.id} erstellt`);
// TODO: Einsatz alarmieren // TODO: Einsatz alarmieren
setOpen(false); setOpen(false);
@@ -343,11 +374,15 @@ export const MissionForm = () => {
</button> </button>
<button <button
type="submit" type="submit"
className="btn btn-primary btn-block" className="btn btn-primary flex-1"
onClick={form.handleSubmit( onClick={form.handleSubmit(
async (mission: MissionOptionalDefaults) => { async (mission: MissionOptionalDefaults) => {
try { try {
const newMission = await createMission(mission); const newMission =
await createMissionMutation.mutateAsync(
mission as unknown as Prisma.MissionCreateInput,
);
toast.success(`Einsatz ${newMission.id} erstellt`); toast.success(`Einsatz ${newMission.id} erstellt`);
form.reset(); form.reset();
setOpen(false); setOpen(false);

View File

@@ -2,10 +2,41 @@ import { usePannelStore } from "_store/pannelStore";
import { cn } from "helpers/cn"; import { cn } from "helpers/cn";
import { MissionForm } from "./MissionForm"; import { MissionForm } from "./MissionForm";
import { Rss, Trash2Icon } from "lucide-react"; import { Rss, Trash2Icon } from "lucide-react";
import { useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import { getMissionsAPI } from "querys/missions";
export const Pannel = () => { export const Pannel = () => {
const { setOpen, setMissionFormValues } = usePannelStore(); const { setOpen, setMissionFormValues } = usePannelStore();
const { isEditingMission, setEditingMission } = usePannelStore(); const { isEditingMission, setEditingMission, missionFormValues } =
usePannelStore();
const missions = useQuery({
queryKey: ["missions"],
queryFn: () =>
getMissionsAPI({
OR: [{ state: "draft" }, { state: "running" }],
}),
});
useEffect(() => {
if (isEditingMission && missionFormValues) {
const mission = missions.data?.find(
(mission) => mission.id === missionFormValues.id,
);
if (!mission) {
setEditingMission(false, null);
setMissionFormValues({});
setOpen(false);
}
}
}, [
isEditingMission,
missions,
setMissionFormValues,
setEditingMission,
setOpen,
missionFormValues,
]);
return ( return (
<div className={cn("flex-1 max-w-[600px] z-9999999")}> <div className={cn("flex-1 max-w-[600px] z-9999999")}>

View File

@@ -1,15 +0,0 @@
"use server";
import { prisma } from "@repo/db";
export const getKeywords = async () => {
const keywords = prisma.keyword.findMany();
return keywords;
};
export const getStations = async () => {
const stations = await prisma.station.findMany();
console.log(stations);
return stations;
};

View File

@@ -4,8 +4,8 @@ import { Pannel } from "dispatch/_components/pannel/Pannel";
import { usePannelStore } from "_store/pannelStore"; import { usePannelStore } from "_store/pannelStore";
import { cn } from "helpers/cn"; import { cn } from "helpers/cn";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { Chat } from "./_components/left/Chat"; import { Chat } from "../_components/left/Chat";
import { Report } from "./_components/left/Report"; import { Report } from "../_components/left/Report";
const Map = dynamic(() => import("./_components/map/Map"), { ssr: false }); const Map = dynamic(() => import("./_components/map/Map"), { ssr: false });
const DispatchPage = () => { const DispatchPage = () => {

View File

@@ -1,5 +1,6 @@
import { pilotSocket } from "pilot/socket";
import { io } from "socket.io-client"; import { io } from "socket.io-client";
export const socket = io(process.env.NEXT_PUBLIC_DISPATCH_SERVER_URL, { export const dispatchSocket = io(process.env.NEXT_PUBLIC_DISPATCH_SERVER_URL, {
autoConnect: false, autoConnect: false,
}); });

View File

@@ -4,6 +4,8 @@ import "./globals.css";
import { NextAuthSessionProvider } from "./_components/AuthSessionProvider"; import { NextAuthSessionProvider } from "./_components/AuthSessionProvider";
import { getServerSession } from "./api/auth/[...nextauth]/auth"; import { getServerSession } from "./api/auth/[...nextauth]/auth";
import { Toaster } from "react-hot-toast"; import { Toaster } from "react-hot-toast";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { QueryProvider } from "_components/QueryProvider";
const geistSans = localFont({ const geistSans = localFont({
src: "./fonts/GeistVF.woff", src: "./fonts/GeistVF.woff",
@@ -45,9 +47,11 @@ export default async function RootLayout({
position="top-left" position="top-left"
reverseOrder={false} reverseOrder={false}
/> />
<QueryProvider>
<NextAuthSessionProvider session={session}> <NextAuthSessionProvider session={session}>
{children} {children}
</NextAuthSessionProvider> </NextAuthSessionProvider>
</QueryProvider>
</body> </body>
</html> </html>
); );

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useConnectionStore } from "_store/connectionStore"; import { useDispatchConnectionStore } from "_store/pilot/connectionStore";
import { import {
Disc, Disc,
Mic, Mic,
@@ -20,7 +20,7 @@ import { ConnectionQuality } from "livekit-client";
import { ROOMS } from "_data/livekitRooms"; import { ROOMS } from "_data/livekitRooms";
export const Audio = () => { export const Audio = () => {
const connection = useConnectionStore(); const connection = useDispatchConnectionStore();
const { const {
isTalking, isTalking,
toggleTalking, toggleTalking,
@@ -36,7 +36,7 @@ export const Audio = () => {
useEffect(() => { useEffect(() => {
const joinRoom = async () => { const joinRoom = async () => {
if (!connection.isConnected) return; if (connection.status != "connected") return;
if (state === "connected") return; if (state === "connected") return;
connect(selectedRoom); connect(selectedRoom);
}; };
@@ -46,7 +46,8 @@ export const Audio = () => {
return () => { return () => {
disconnect(); disconnect();
}; };
}, [connection.isConnected]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [connection.status]);
return ( return (
<> <>

View File

@@ -1,226 +0,0 @@
"use client";
import { ChatBubbleIcon, PaperPlaneIcon } from "@radix-ui/react-icons";
import { useLeftMenuStore } from "_store/leftMenuStore";
import { useSession } from "next-auth/react";
import { Fragment, useEffect, useRef, useState } from "react";
import { cn } from "helpers/cn";
import { getConenctedUsers } from "helpers/axios";
import {
asPublicUser,
ConnectedAircraft,
ConnectedDispatcher,
PublicUser,
} from "@repo/db";
export const Chat = () => {
const {
chatOpen,
setChatOpen,
sendMessage,
addChat,
chats,
setOwnId,
selectedChat,
setSelectedChat,
setChatNotification,
} = useLeftMenuStore();
const [sending, setSending] = useState(false);
const session = useSession();
const [addTabValue, setAddTabValue] = useState<string>("");
const [message, setMessage] = useState<string>("");
const [connectedUser, setConnectedUser] = useState<
(ConnectedAircraft | ConnectedDispatcher)[] | null
>(null);
const timeout = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
if (!session.data?.user.id) return;
setOwnId(session.data.user.id);
}, [session, setOwnId]);
useEffect(() => {
const fetchConnectedUser = async () => {
const data = await getConenctedUsers();
if (data) {
const filteredConnectedUser = data.filter((user) => {
return (
user.userId !== session.data?.user.id &&
!Object.keys(chats).includes(user.userId)
);
});
setConnectedUser(filteredConnectedUser);
}
if (!addTabValue && data[0]) setAddTabValue(data[0].userId);
};
timeout.current = setInterval(() => {
fetchConnectedUser();
}, 1000000);
fetchConnectedUser();
return () => {
if (timeout.current) {
clearInterval(timeout.current);
timeout.current = null;
console.log("cleared");
}
};
}, [addTabValue, chats, session]);
return (
<div
className={cn("dropdown dropdown-center", chatOpen && "dropdown-open")}
>
<div className="indicator">
{Object.values(chats).some((c) => c.notification) && (
<span className="indicator-item status status-info"></span>
)}
<button
className="btn btn-soft btn-sm btn-primary"
onClick={() => {
console.log("clicked");
setChatOpen(!chatOpen);
if (selectedChat) {
setChatNotification(selectedChat, false);
}
}}
>
<ChatBubbleIcon className="w-4 h-4" />
</button>
</div>
{chatOpen && (
<div
tabIndex={0}
className="dropdown-content card bg-base-200 w-150 shadow-md z-[1100]"
>
<div className="card-body">
<div className="join">
<select
className="select select-sm w-full"
value={addTabValue}
onChange={(e) => setAddTabValue(e.target.value)}
>
{!connectedUser?.length && (
<option disabled={true}>Keine Chatpartner gefunden</option>
)}
{connectedUser?.map((user) => (
<option key={user.userId} value={user.userId}>
{(user.publicUser as unknown as PublicUser).firstname}{" "}
{(user.publicUser as unknown as PublicUser).lastname}
</option>
))}
</select>
<button
className="btn btn-sm btn-soft btn-primary join-item"
onClick={() => {
const user = connectedUser?.find(
(user) => user.userId === addTabValue,
);
if (!user) return;
addChat(addTabValue, asPublicUser(user.publicUser).fullName);
setSelectedChat(addTabValue);
}}
>
<span className="text-xl">+</span>
</button>
</div>
<div className="tabs tabs-lift">
{Object.keys(chats).map((userId) => {
const chat = chats[userId];
if (!chat) return null;
return (
<Fragment key={userId}>
<input
type="radio"
name="my_tabs_3"
className="tab"
aria-label={`<${chat.name}>`}
checked={selectedChat === userId}
onClick={() => {
setChatNotification(userId, false);
}}
onChange={(e) => {
if (e.target.checked) {
// Handle tab change
setSelectedChat(userId);
}
}}
/>
<div className="tab-content bg-base-100 border-base-300 p-6">
{chat.messages.map((chatMessage) => {
const isSender =
chatMessage.senderId === session.data?.user.id;
return (
<div
key={chatMessage.id}
className={`chat ${isSender ? "chat-end" : "chat-start"}`}
>
<p className="chat-footer opacity-50">
{new Date(
chatMessage.timestamp,
).toLocaleTimeString()}
</p>
<div className="chat-bubble">
{chatMessage.text}
</div>
</div>
);
})}
{!chat.messages.length && (
<p className="text-xs opacity-50">
Noch keine Nachrichten
</p>
)}
</div>
</Fragment>
);
})}
</div>
<div className="join">
<div className="w-full">
<label className="input join-item w-full">
<input
type="text"
required
className="w-full"
onChange={(e) => {
setMessage(e.target.value);
}}
value={message}
/>
</label>
</div>
<button
className="btn btn-soft join-item"
onClick={(e) => {
e.preventDefault();
if (message.length < 1) return;
if (!selectedChat) return;
setSending(true);
sendMessage(selectedChat, message)
.then(() => {
setMessage("");
setSending(false);
})
.catch(() => {
setSending(false);
});
return false;
}}
disabled={sending}
role="button"
onSubmit={() => false}
>
{sending ? (
<span className="loading loading-spinner loading-sm"></span>
) : (
<PaperPlaneIcon />
)}
</button>
</div>
</div>
</div>
)}
</div>
);
};

View File

@@ -1,28 +1,57 @@
"use client"; "use client";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { useDispatchConnectionStore } from "../../../_store/connectionStore"; import { useDispatchConnectionStore } from "../../../_store/pilot/connectionStore";
import { useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { getStationsAPI } from "querys/stations";
export const ConnectionBtn = () => { export const ConnectionBtn = () => {
const modalRef = useRef<HTMLDialogElement>(null); const modalRef = useRef<HTMLDialogElement>(null);
const connection = useDispatchConnectionStore((state) => state); const connection = useDispatchConnectionStore((state) => state);
const [form, setForm] = useState({ const [form, setForm] = useState<{
logoffTime: "", logoffTime: string | null;
selectedZone: "LST_01", selectedStationId: number | null;
}>({
logoffTime: null,
selectedStationId: null,
}); });
const { data: stations } = useQuery({
queryKey: ["stations"],
queryFn: () => getStationsAPI(),
});
useEffect(() => {
/* getStations().then((data) => {
setStations(data);
if (data[0]) {
setForm({
...form,
selectedStationId: data[0].id,
});
}
}); */
}, [connection.status, form]);
const session = useSession(); const session = useSession();
const uid = session.data?.user?.id; const uid = session.data?.user?.id;
if (!uid) return null; if (!uid) return null;
return ( return (
<> <div className="rounded-box bg-base-200 flex justify-center items-center gap-2 p-1">
{!connection.isConnected ? ( {connection.message.length > 0 && (
<span className="mx-2 text-error">{connection.message}</span>
)}
{connection.status === "disconnected" && (
<button <button
className="btn btn-soft btn-info" className="btn btn-sm btn-soft btn-info "
onClick={() => modalRef.current?.showModal()} onClick={() => modalRef.current?.showModal()}
> >
Verbinden Verbinden
</button> </button>
) : ( )}
{connection.status == "connected" && (
<button <button
className="btn btn-soft btn-success" className="btn btn-soft btn-success"
onClick={() => modalRef.current?.showModal()} onClick={() => modalRef.current?.showModal()}
@@ -33,17 +62,38 @@ export const ConnectionBtn = () => {
<dialog ref={modalRef} className="modal"> <dialog ref={modalRef} className="modal">
<div className="modal-box flex flex-col items-center justify-center"> <div className="modal-box flex flex-col items-center justify-center">
{connection.isConnected ? ( {connection.status == "connected" ? (
<h3 className="text-lg font-bold mb-5"> <h3 className="text-lg font-bold mb-5">
Verbunden als{" "} Verbunden als{" "}
<span className="text-info"> <span className="text-info">
&lt;{connection.selectedZone}&gt; &lt;{connection.selectedStation?.bosCallsign}&gt;
</span> </span>
</h3> </h3>
) : ( ) : (
<h3 className="text-lg font-bold mb-5">Als Disponent anmelden</h3> <h3 className="text-lg font-bold mb-5">Als Disponent anmelden</h3>
)} )}
<fieldset className="fieldset w-full"> <fieldset className="fieldset w-full">
<label className="floating-label w-full text-base">
<span>Station</span>
<select
onChange={(e) =>
setForm({
...form,
selectedStationId: parseInt(e.target.value),
})
}
value={form.selectedStationId ?? ""}
className="input w-full"
>
{stations?.map((station) => (
<option key={station.id} value={station.id}>
{station.bosCallsign}
</option>
))}
</select>
</label>
</fieldset>
<fieldset className="fieldset w-full mt-2">
<label className="floating-label w-full text-base"> <label className="floating-label w-full text-base">
<span>Logoff Zeit (UTC+1)</span> <span>Logoff Zeit (UTC+1)</span>
<input <input
@@ -53,12 +103,12 @@ export const ConnectionBtn = () => {
logoffTime: e.target.value, logoffTime: e.target.value,
}) })
} }
value={form.logoffTime} value={form.logoffTime ?? ""}
type="time" type="time"
className="input w-full" className="input w-full"
/> />
</label> </label>
{!connection.isConnected && ( {connection.status == "disconnected" && (
<p className="fieldset-label"> <p className="fieldset-label">
Du kannst diese Zeit später noch anpassen. Du kannst diese Zeit später noch anpassen.
</p> </p>
@@ -67,7 +117,7 @@ export const ConnectionBtn = () => {
<div className="modal-action flex justify-between w-full"> <div className="modal-action flex justify-between w-full">
<form method="dialog" className="w-full flex justify-between"> <form method="dialog" className="w-full flex justify-between">
<button className="btn btn-soft">Abbrechen</button> <button className="btn btn-soft">Abbrechen</button>
{connection.isConnected ? ( {connection.status == "connected" ? (
<button <button
className="btn btn-soft btn-error" className="btn btn-soft btn-error"
type="submit" type="submit"
@@ -83,7 +133,16 @@ export const ConnectionBtn = () => {
type="submit" type="submit"
onSubmit={() => false} onSubmit={() => false}
onClick={() => { onClick={() => {
connection.connect(uid, form.selectedZone, form.logoffTime); connection.connect(
uid,
form.selectedStationId?.toString() || "",
form.logoffTime || "",
stations?.find(
(station) =>
station.id ===
parseInt(form.selectedStationId?.toString() || ""),
)!,
);
}} }}
className="btn btn-soft btn-info" className="btn btn-soft btn-info"
> >
@@ -94,7 +153,7 @@ export const ConnectionBtn = () => {
</div> </div>
</div> </div>
</dialog> </dialog>
</> </div>
); );
}; };

View File

@@ -5,7 +5,7 @@ import { ThemeSwap } from "./ThemeSwap";
import { Audio } from "./Audio"; import { Audio } from "./Audio";
import { useState } from "react"; import { useState } from "react";
import { ExitIcon, ExternalLinkIcon } from "@radix-ui/react-icons"; import { ExitIcon, ExternalLinkIcon } from "@radix-ui/react-icons";
import { Chat } from "./Chat"; import Link from "next/link";
export default function Navbar() { export default function Navbar() {
const [isDark, setIsDark] = useState(false); const [isDark, setIsDark] = useState(false);
@@ -24,11 +24,6 @@ export default function Navbar() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<a className="btn btn-ghost text-xl">VAR Leitstelle V2</a> <a className="btn btn-ghost text-xl">VAR Leitstelle V2</a>
</div> </div>
<div className="bg-base-200 rounded-box flex items-center gap-2 p-1">
<div className="flex items-center gap-2">
<Chat />
</div>
</div>
<div className="flex items-center gap-5"> <div className="flex items-center gap-5">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -39,12 +34,20 @@ export default function Navbar() {
</div> </div>
<ThemeSwap isDark={isDark} toggleTheme={toggleTheme} /> <ThemeSwap isDark={isDark} toggleTheme={toggleTheme} />
<div className="flex items-center"> <div className="flex items-center">
<Link
href={process.env.NEXT_PUBLIC_HUB_URL || "#!"}
target="_blank"
rel="noopener noreferrer"
>
<button className="btn btn-ghost"> <button className="btn btn-ghost">
<ExternalLinkIcon className="w-4 h-4" /> HUB <ExternalLinkIcon className="w-4 h-4" /> HUB
</button> </button>
</Link>
<Link href={"/logout"}>
<button className="btn btn-ghost"> <button className="btn btn-ghost">
<ExitIcon className="w-4 h-4" /> <ExitIcon className="w-4 h-4" />
</button> </button>
</Link>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,9 +0,0 @@
export interface Dispatcher {
userId: string;
lastSeen: string;
loginTime: string;
logoffTime: string;
selectedZone: string;
name: string;
socketId: string;
}

View File

@@ -1,5 +1,5 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Navbar from "../dispatch/_components/navbar/Navbar"; import Navbar from "./_components/navbar/Navbar";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getServerSession } from "../api/auth/[...nextauth]/auth"; import { getServerSession } from "../api/auth/[...nextauth]/auth";

View File

@@ -0,0 +1,37 @@
"use client";
import { Pannel } from "dispatch/_components/pannel/Pannel";
import { usePannelStore } from "_store/pannelStore";
import { cn } from "helpers/cn";
import dynamic from "next/dynamic";
import { Chat } from "../_components/left/Chat";
import { Report } from "../_components/left/Report";
const DispatchPage = () => {
const { isOpen } = usePannelStore();
return (
<div className="relative flex-1 flex transition-all duration-500 ease w-full">
{/* <MapToastCard2 /> */}
<div className="flex flex-1 relative">
<div className="absolute left-0 top-1/2 transform -translate-y-1/2 pl-4 z-999999">
<Chat />
<div className="mt-2">
<Report />
</div>
</div>
</div>
<div
className={cn(
"absolute right-0 w-[500px] z-999 transition-transform",
isOpen ? "translate-x-0" : "translate-x-full",
)}
>
<Pannel />
</div>
</div>
);
};
DispatchPage.displayName = "DispatchPage";
export default DispatchPage;

View File

@@ -0,0 +1,6 @@
import { io } from "socket.io-client";
import { dispatchSocket } from "dispatch/socket";
export const pilotSocket = io(process.env.NEXT_PUBLIC_DISPATCH_SERVER_URL, {
autoConnect: false,
});

View File

@@ -0,0 +1,19 @@
import { ConnectedAircraft, ConnectedDispatcher, Prisma } from "@repo/db";
import axios from "axios";
export const getConnectedUserAPI = async (
filter?: Prisma.KeywordWhereInput,
) => {
const res = await axios.get<(ConnectedAircraft | ConnectedDispatcher)[]>(
"/api/connected-user",
{
params: {
filter: JSON.stringify(filter),
},
},
);
if (res.status !== 200) {
throw new Error("Failed to fetch Connected User");
}
return res.data;
};

View File

@@ -0,0 +1,14 @@
import { Keyword, Prisma } from "@repo/db";
import axios from "axios";
export const getKeywordsAPI = async (filter?: Prisma.KeywordWhereInput) => {
const res = await axios.get<Keyword[]>("/api/keywords", {
params: {
filter: JSON.stringify(filter),
},
});
if (res.status !== 200) {
throw new Error("Failed to fetch keywords");
}
return res.data;
};

View File

@@ -0,0 +1,32 @@
import { Mission, Prisma } from "@repo/db";
import axios from "axios";
import { serverApi } from "helpers/axios";
export const getMissionsAPI = async (filter?: Prisma.MissionWhereInput) => {
const res = await axios.get<Mission[]>("/api/missions", {
params: {
filter: JSON.stringify(filter),
},
});
if (res.status !== 200) {
throw new Error("Failed to fetch stations");
}
return res.data;
};
export const createMissionAPI = async (mission: Prisma.MissionCreateInput) => {
const response = await serverApi.put<Mission>("/mission", mission);
return response.data;
};
export const editMissionAPI = async (
id: number,
mission: Prisma.MissionUpdateInput,
) => {
const respone = await serverApi.patch<Mission>(`/mission/${id}`, mission);
return respone.data;
};
export const deleteMissionAPI = async (id: number) => {
await serverApi.delete(`/mission/${id}`);
};

View File

@@ -0,0 +1,14 @@
import { Prisma, Station } from "@repo/db";
import axios from "axios";
export const getStationsAPI = async (filter?: Prisma.StationWhereInput) => {
const res = await axios.get<Station[]>("/api/stations", {
params: {
filter: JSON.stringify(filter),
},
});
if (res.status !== 200) {
throw new Error("Failed to fetch stations");
}
return res.data;
};

View File

@@ -17,7 +17,7 @@
"@repo/db": "*", "@repo/db": "*",
"@repo/ui": "*", "@repo/ui": "*",
"@tailwindcss/postcss": "^4.0.14", "@tailwindcss/postcss": "^4.0.14",
"@tanstack/react-query": "^5.69.0", "@tanstack/react-query": "^5.75.4",
"axios": "^1.9.0", "axios": "^1.9.0",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"livekit-client": "^2.9.7", "livekit-client": "^2.9.7",

View File

@@ -13,6 +13,7 @@ import {
LightningBoltIcon, LightningBoltIcon,
LockOpen1Icon, LockOpen1Icon,
HobbyKnifeIcon, HobbyKnifeIcon,
ExclamationTriangleIcon,
} from "@radix-ui/react-icons"; } from "@radix-ui/react-icons";
import { Button } from "../../../../../_components/ui/Button"; import { Button } from "../../../../../_components/ui/Button";
import { Select } from "../../../../../_components/ui/Select"; import { Select } from "../../../../../_components/ui/Select";
@@ -22,6 +23,7 @@ import { PaginatedTable, PaginatedTableRef } from "_components/PaginatedTable";
import { min } from "date-fns"; import { min } from "date-fns";
import { cn } from "../../../../../../helper/cn"; import { cn } from "../../../../../../helper/cn";
import { ChartBarBigIcon, PlaneIcon } from "lucide-react"; import { ChartBarBigIcon, PlaneIcon } from "lucide-react";
import Link from "next/link";
interface ProfileFormProps { interface ProfileFormProps {
user: User; user: User;
@@ -150,6 +152,13 @@ export const ConnectionHistory: React.FC<{ user: User }> = ({
<div className="flex-1"> <div className="flex-1">
<h2 className="card-title"> <h2 className="card-title">
<MixerHorizontalIcon className="w-5 h-5" /> Dispo-Verbindungs Historie <MixerHorizontalIcon className="w-5 h-5" /> Dispo-Verbindungs Historie
<button
onClick={() => {
dispoTableRef.current?.refresh();
}}
>
refresh
</button>
</h2> </h2>
<PaginatedTable <PaginatedTable
ref={dispoTableRef} ref={dispoTableRef}
@@ -222,7 +231,22 @@ export const ConnectionHistory: React.FC<{ user: User }> = ({
userId: user.id, userId: user.id,
}} }}
prismaModel={"connectedAircraft"} prismaModel={"connectedAircraft"}
include={{ Station: true }}
columns={[ columns={[
{
accessorKey: "Station.bosCallsign",
header: "Station",
cell: ({ row }) => {
return (
<Link
className="link link-hover"
href={`/admin/station/${(row.original as any).id}`}
>
{(row.original as any).Station.bosCallsign}
</Link>
);
},
},
{ {
accessorKey: "loginTime", accessorKey: "loginTime",
header: "Login", header: "Login",
@@ -392,6 +416,9 @@ export const AdminForm = ({ user, dispoTime, pilotTime }: AdminFormProps) => {
</div> </div>
</div> </div>
</div> </div>
<h2 className="card-title">
<ExclamationTriangleIcon className="w-5 h-5" /> Reports
</h2>
</div> </div>
); );
}; };

View File

@@ -47,6 +47,7 @@ const Page = async ({ params }: { params: { id: string } }) => {
select: { select: {
loginTime: true, loginTime: true,
logoutTime: true, logoutTime: true,
Station: true,
}, },
}); });

View File

@@ -37,10 +37,10 @@ export const resetPassword = async (id: string) => {
return { password }; return { password };
}; };
export const deleteDispoHistory = async (id: string) => { export const deleteDispoHistory = async (id: number) => {
return await prisma.connectedDispatcher.deleteMany({ return await prisma.connectedDispatcher.delete({
where: { where: {
userId: id, id: id,
}, },
}); });
}; };

Binary file not shown.

297
package-lock.json generated
View File

@@ -27,7 +27,7 @@
"@repo/db": "*", "@repo/db": "*",
"@repo/ui": "*", "@repo/ui": "*",
"@tailwindcss/postcss": "^4.0.14", "@tailwindcss/postcss": "^4.0.14",
"@tanstack/react-query": "^5.69.0", "@tanstack/react-query": "^5.75.4",
"axios": "^1.9.0", "axios": "^1.9.0",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"livekit-client": "^2.9.7", "livekit-client": "^2.9.7",
@@ -533,9 +533,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@emnapi/runtime": { "node_modules/@emnapi/runtime": {
"version": "1.3.1", "version": "1.4.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.3.1.tgz", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.3.tgz",
"integrity": "sha512-kEBmG8KyqtxJZv+ygbEim+KCGtIq1fC22Ms3S4ziXmYKm8uyoLX0MHONVKwp+9opg390VaKRNt4a7A9NwmpNhw==", "integrity": "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==",
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@@ -1366,9 +1366,9 @@
} }
}, },
"node_modules/@img/sharp-darwin-arm64": { "node_modules/@img/sharp-darwin-arm64": {
"version": "0.33.5", "version": "0.34.1",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.1.tgz",
"integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", "integrity": "sha512-pn44xgBtgpEbZsu+lWf2KNb6OAf70X68k+yk69Ic2Xz11zHR/w24/U49XT7AeRwJ0Px+mhALhU5LPci1Aymk7A==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1384,13 +1384,13 @@
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.0.4" "@img/sharp-libvips-darwin-arm64": "1.1.0"
} }
}, },
"node_modules/@img/sharp-darwin-x64": { "node_modules/@img/sharp-darwin-x64": {
"version": "0.33.5", "version": "0.34.1",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.1.tgz",
"integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", "integrity": "sha512-VfuYgG2r8BpYiOUN+BfYeFo69nP/MIwAtSJ7/Zpxc5QF3KS22z8Pvg3FkrSFJBPNQ7mmcUcYQFBmEQp7eu1F8Q==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1406,13 +1406,13 @@
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.0.4" "@img/sharp-libvips-darwin-x64": "1.1.0"
} }
}, },
"node_modules/@img/sharp-libvips-darwin-arm64": { "node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.0.4", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.1.0.tgz",
"integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", "integrity": "sha512-HZ/JUmPwrJSoM4DIQPv/BfNh9yrOA8tlBbqbLz4JZ5uew2+o22Ik+tHQJcih7QJuSa0zo5coHTfD5J8inqj9DA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1426,9 +1426,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-darwin-x64": { "node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.0.4", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.1.0.tgz",
"integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", "integrity": "sha512-Xzc2ToEmHN+hfvsl9wja0RlnXEgpKNmftriQp6XzY/RaSfwD9th+MSh0WQKzUreLKKINb3afirxW7A0fz2YWuQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1442,9 +1442,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-linux-arm": { "node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.0.5", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.1.0.tgz",
"integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", "integrity": "sha512-s8BAd0lwUIvYCJyRdFqvsj+BJIpDBSxs6ivrOPm/R7piTs5UIwY5OjXrP2bqXC9/moGsyRa37eYWYCOGVXxVrA==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -1458,9 +1458,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-linux-arm64": { "node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.0.4", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.1.0.tgz",
"integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", "integrity": "sha512-IVfGJa7gjChDET1dK9SekxFFdflarnUB8PwW8aGwEoF3oAsSDuNUTYS+SKDOyOJxQyDC1aPFMuRYLoDInyV9Ew==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1473,10 +1473,26 @@
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
} }
}, },
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.1.0.tgz",
"integrity": "sha512-tiXxFZFbhnkWE2LA8oQj7KYR+bWBkiV2nilRldT7bqoEZ4HiDOcePr9wVDAZPi/Id5fT1oY9iGnDq20cwUz8lQ==",
"cpu": [
"ppc64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": { "node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.0.4", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.1.0.tgz",
"integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", "integrity": "sha512-xukSwvhguw7COyzvmjydRb3x/09+21HykyapcZchiCUkTThEQEOMtBj9UhkaBRLuBrgLFzQ2wbxdeCCJW/jgJA==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@@ -1490,9 +1506,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-linux-x64": { "node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.0.4", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.1.0.tgz",
"integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", "integrity": "sha512-yRj2+reB8iMg9W5sULM3S74jVS7zqSzHG3Ol/twnAAkAhnGQnpjj6e4ayUz7V+FpKypwgs82xbRdYtchTTUB+Q==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1506,9 +1522,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-linuxmusl-arm64": { "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.0.4", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.1.0.tgz",
"integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", "integrity": "sha512-jYZdG+whg0MDK+q2COKbYidaqW/WTz0cc1E+tMAusiDygrM4ypmSCjOJPmFTvHHJ8j/6cAGyeDWZOsK06tP33w==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1522,9 +1538,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-linuxmusl-x64": { "node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.0.4", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.1.0.tgz",
"integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", "integrity": "sha512-wK7SBdwrAiycjXdkPnGCPLjYb9lD4l6Ze2gSdAGVZrEL05AOUJESWU2lhlC+Ffn5/G+VKuSm6zzbQSzFX/P65A==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1538,9 +1554,9 @@
} }
}, },
"node_modules/@img/sharp-linux-arm": { "node_modules/@img/sharp-linux-arm": {
"version": "0.33.5", "version": "0.34.1",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.1.tgz",
"integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", "integrity": "sha512-anKiszvACti2sGy9CirTlNyk7BjjZPiML1jt2ZkTdcvpLU1YH6CXwRAZCA2UmRXnhiIftXQ7+Oh62Ji25W72jA==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -1556,13 +1572,13 @@
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.0.5" "@img/sharp-libvips-linux-arm": "1.1.0"
} }
}, },
"node_modules/@img/sharp-linux-arm64": { "node_modules/@img/sharp-linux-arm64": {
"version": "0.33.5", "version": "0.34.1",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.1.tgz",
"integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", "integrity": "sha512-kX2c+vbvaXC6vly1RDf/IWNXxrlxLNpBVWkdpRq5Ka7OOKj6nr66etKy2IENf6FtOgklkg9ZdGpEu9kwdlcwOQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1578,13 +1594,13 @@
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.0.4" "@img/sharp-libvips-linux-arm64": "1.1.0"
} }
}, },
"node_modules/@img/sharp-linux-s390x": { "node_modules/@img/sharp-linux-s390x": {
"version": "0.33.5", "version": "0.34.1",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.1.tgz",
"integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", "integrity": "sha512-7s0KX2tI9mZI2buRipKIw2X1ufdTeaRgwmRabt5bi9chYfhur+/C1OXg3TKg/eag1W+6CCWLVmSauV1owmRPxA==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@@ -1600,13 +1616,13 @@
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.0.4" "@img/sharp-libvips-linux-s390x": "1.1.0"
} }
}, },
"node_modules/@img/sharp-linux-x64": { "node_modules/@img/sharp-linux-x64": {
"version": "0.33.5", "version": "0.34.1",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.1.tgz",
"integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", "integrity": "sha512-wExv7SH9nmoBW3Wr2gvQopX1k8q2g5V5Iag8Zk6AVENsjwd+3adjwxtp3Dcu2QhOXr8W9NusBU6XcQUohBZ5MA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1622,13 +1638,13 @@
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.0.4" "@img/sharp-libvips-linux-x64": "1.1.0"
} }
}, },
"node_modules/@img/sharp-linuxmusl-arm64": { "node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.33.5", "version": "0.34.1",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.1.tgz",
"integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", "integrity": "sha512-DfvyxzHxw4WGdPiTF0SOHnm11Xv4aQexvqhRDAoD00MzHekAj9a/jADXeXYCDFH/DzYruwHbXU7uz+H+nWmSOQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1644,13 +1660,13 @@
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.0.4" "@img/sharp-libvips-linuxmusl-arm64": "1.1.0"
} }
}, },
"node_modules/@img/sharp-linuxmusl-x64": { "node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.33.5", "version": "0.34.1",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.1.tgz",
"integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", "integrity": "sha512-pax/kTR407vNb9qaSIiWVnQplPcGU8LRIJpDT5o8PdAx5aAA7AS3X9PS8Isw1/WfqgQorPotjrZL3Pqh6C5EBg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1666,20 +1682,20 @@
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.0.4" "@img/sharp-libvips-linuxmusl-x64": "1.1.0"
} }
}, },
"node_modules/@img/sharp-wasm32": { "node_modules/@img/sharp-wasm32": {
"version": "0.33.5", "version": "0.34.1",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.1.tgz",
"integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", "integrity": "sha512-YDybQnYrLQfEpzGOQe7OKcyLUCML4YOXl428gOOzBgN6Gw0rv8dpsJ7PqTHxBnXnwXr8S1mYFSLSa727tpz0xg==",
"cpu": [ "cpu": [
"wasm32" "wasm32"
], ],
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"@emnapi/runtime": "^1.2.0" "@emnapi/runtime": "^1.4.0"
}, },
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
@@ -1689,9 +1705,9 @@
} }
}, },
"node_modules/@img/sharp-win32-ia32": { "node_modules/@img/sharp-win32-ia32": {
"version": "0.33.5", "version": "0.34.1",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.1.tgz",
"integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", "integrity": "sha512-WKf/NAZITnonBf3U1LfdjoMgNO5JYRSlhovhRhMxXVdvWYveM4kM3L8m35onYIdh75cOMCo1BexgVQcCDzyoWw==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -1708,9 +1724,9 @@
} }
}, },
"node_modules/@img/sharp-win32-x64": { "node_modules/@img/sharp-win32-x64": {
"version": "0.33.5", "version": "0.34.1",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.1.tgz",
"integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", "integrity": "sha512-hw1iIAHpNE8q3uMIRCgGOeDoz9KtFNarFLQclLxr/LK1VBkj8nby18RjFvr6aP7USRYAjTZW6yisnBWMX571Tw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1898,9 +1914,9 @@
} }
}, },
"node_modules/@next/env": { "node_modules/@next/env": {
"version": "15.2.2", "version": "15.3.1",
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.2.2.tgz", "resolved": "https://registry.npmjs.org/@next/env/-/env-15.3.1.tgz",
"integrity": "sha512-yWgopCfA9XDR8ZH3taB5nRKtKJ1Q5fYsTOuYkzIIoS8TJ0UAUKAGF73JnGszbjk2ufAQDj6mDdgsJAFx5CLtYQ==", "integrity": "sha512-cwK27QdzrMblHSn9DZRV+DQscHXRuJv6MydlJRpFSqJWZrTYMLzKDeyueJNN9MGd8NNiUKzDQADAf+dMLXX7YQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@next/eslint-plugin-next": { "node_modules/@next/eslint-plugin-next": {
@@ -1914,9 +1930,9 @@
} }
}, },
"node_modules/@next/swc-darwin-arm64": { "node_modules/@next/swc-darwin-arm64": {
"version": "15.2.2", "version": "15.3.1",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.2.2.tgz", "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.3.1.tgz",
"integrity": "sha512-HNBRnz+bkZ+KfyOExpUxTMR0Ow8nkkcE6IlsdEa9W/rI7gefud19+Sn1xYKwB9pdCdxIP1lPru/ZfjfA+iT8pw==", "integrity": "sha512-hjDw4f4/nla+6wysBL07z52Gs55Gttp5Bsk5/8AncQLJoisvTBP0pRIBK/B16/KqQyH+uN4Ww8KkcAqJODYH3w==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1930,9 +1946,9 @@
} }
}, },
"node_modules/@next/swc-darwin-x64": { "node_modules/@next/swc-darwin-x64": {
"version": "15.2.2", "version": "15.3.1",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.2.2.tgz", "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.3.1.tgz",
"integrity": "sha512-mJOUwp7al63tDpLpEFpKwwg5jwvtL1lhRW2fI1Aog0nYCPAhxbJsaZKdoVyPZCy8MYf/iQVNDuk/+i29iLCzIA==", "integrity": "sha512-q+aw+cJ2ooVYdCEqZVk+T4Ni10jF6Fo5DfpEV51OupMaV5XL6pf3GCzrk6kSSZBsMKZtVC1Zm/xaNBFpA6bJ2g==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1946,9 +1962,9 @@
} }
}, },
"node_modules/@next/swc-linux-arm64-gnu": { "node_modules/@next/swc-linux-arm64-gnu": {
"version": "15.2.2", "version": "15.3.1",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.2.2.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.3.1.tgz",
"integrity": "sha512-5ZZ0Zwy3SgMr7MfWtRE7cQWVssfOvxYfD9O7XHM7KM4nrf5EOeqwq67ZXDgo86LVmffgsu5tPO57EeFKRnrfSQ==", "integrity": "sha512-wBQ+jGUI3N0QZyWmmvRHjXjTWFy8o+zPFLSOyAyGFI94oJi+kK/LIZFJXeykvgXUk1NLDAEFDZw/NVINhdk9FQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1962,9 +1978,9 @@
} }
}, },
"node_modules/@next/swc-linux-arm64-musl": { "node_modules/@next/swc-linux-arm64-musl": {
"version": "15.2.2", "version": "15.3.1",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.2.2.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.3.1.tgz",
"integrity": "sha512-cgKWBuFMLlJ4TWcFHl1KOaVVUAF8vy4qEvX5KsNd0Yj5mhu989QFCq1WjuaEbv/tO1ZpsQI6h/0YR8bLwEi+nA==", "integrity": "sha512-IIxXEXRti/AulO9lWRHiCpUUR8AR/ZYLPALgiIg/9ENzMzLn3l0NSxVdva7R/VDcuSEBo0eGVCe3evSIHNz0Hg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1978,9 +1994,9 @@
} }
}, },
"node_modules/@next/swc-linux-x64-gnu": { "node_modules/@next/swc-linux-x64-gnu": {
"version": "15.2.2", "version": "15.3.1",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.2.2.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.3.1.tgz",
"integrity": "sha512-c3kWSOSsVL8rcNBBfOq1+/j2PKs2nsMwJUV4icUxRgGBwUOfppeh7YhN5s79enBQFU+8xRgVatFkhHU1QW7yUA==", "integrity": "sha512-bfI4AMhySJbyXQIKH5rmLJ5/BP7bPwuxauTvVEiJ/ADoddaA9fgyNNCcsbu9SlqfHDoZmfI6g2EjzLwbsVTr5A==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1994,9 +2010,9 @@
} }
}, },
"node_modules/@next/swc-linux-x64-musl": { "node_modules/@next/swc-linux-x64-musl": {
"version": "15.2.2", "version": "15.3.1",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.2.2.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.3.1.tgz",
"integrity": "sha512-PXTW9PLTxdNlVYgPJ0equojcq1kNu5NtwcNjRjHAB+/sdoKZ+X8FBu70fdJFadkxFIGekQTyRvPMFF+SOJaQjw==", "integrity": "sha512-FeAbR7FYMWR+Z+M5iSGytVryKHiAsc0x3Nc3J+FD5NVbD5Mqz7fTSy8CYliXinn7T26nDMbpExRUI/4ekTvoiA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2010,9 +2026,9 @@
} }
}, },
"node_modules/@next/swc-win32-arm64-msvc": { "node_modules/@next/swc-win32-arm64-msvc": {
"version": "15.2.2", "version": "15.3.1",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.2.2.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.3.1.tgz",
"integrity": "sha512-nG644Es5llSGEcTaXhnGWR/aThM/hIaz0jx4MDg4gWC8GfTCp8eDBWZ77CVuv2ha/uL9Ce+nPTfYkSLG67/sHg==", "integrity": "sha512-yP7FueWjphQEPpJQ2oKmshk/ppOt+0/bB8JC8svPUZNy0Pi3KbPx2Llkzv1p8CoQa+D2wknINlJpHf3vtChVBw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2026,9 +2042,9 @@
} }
}, },
"node_modules/@next/swc-win32-x64-msvc": { "node_modules/@next/swc-win32-x64-msvc": {
"version": "15.2.2", "version": "15.3.1",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.2.2.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.3.1.tgz",
"integrity": "sha512-52nWy65S/R6/kejz3jpvHAjZDPKIbEQu4x9jDBzmB9jJfuOy5rspjKu4u77+fI4M/WzLXrrQd57hlFGzz1ubcQ==", "integrity": "sha512-3PMvF2zRJAifcRNni9uMk/gulWfWS+qVI/pagd+4yLF5bcXPZPPH2xlYRYOsUjmCJOXSTAC2PjRzbhsRzR2fDQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2910,9 +2926,9 @@
} }
}, },
"node_modules/@tanstack/query-core": { "node_modules/@tanstack/query-core": {
"version": "5.69.0", "version": "5.75.4",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.69.0.tgz", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.75.4.tgz",
"integrity": "sha512-Kn410jq6vs1P8Nm+ZsRj9H+U3C0kjuEkYLxbiCyn3MDEiYor1j2DGVULqAz62SLZtUZ/e9Xt6xMXiJ3NJ65WyQ==", "integrity": "sha512-pcqOUgWG9oGlzkfRQQMMsEFmtQu0wq81A414CtELZGq+ztVwSTAaoB3AZRAXQJs88LmNMk2YpUKuQbrvzNDyRg==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"type": "github", "type": "github",
@@ -2920,12 +2936,12 @@
} }
}, },
"node_modules/@tanstack/react-query": { "node_modules/@tanstack/react-query": {
"version": "5.69.0", "version": "5.75.4",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.69.0.tgz", "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.75.4.tgz",
"integrity": "sha512-Ift3IUNQqTcaFa1AiIQ7WCb/PPy8aexZdq9pZWLXhfLcLxH0+PZqJ2xFImxCpdDZrFRZhLJrh76geevS5xjRhA==", "integrity": "sha512-Vf65pzYRkf8fk9SP1ncIZjvaXszBhtsvpf+h45Y/9kOywOrVZfBGUpCdffdsVzbmBzmz6TCFes9bM0d3pRrIsA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@tanstack/query-core": "5.69.0" "@tanstack/query-core": "5.75.4"
}, },
"funding": { "funding": {
"type": "github", "type": "github",
@@ -10965,12 +10981,12 @@
} }
}, },
"node_modules/next": { "node_modules/next": {
"version": "15.2.2", "version": "15.3.1",
"resolved": "https://registry.npmjs.org/next/-/next-15.2.2.tgz", "resolved": "https://registry.npmjs.org/next/-/next-15.3.1.tgz",
"integrity": "sha512-dgp8Kcx5XZRjMw2KNwBtUzhngRaURPioxoNIVl5BOyJbhi9CUgEtKDO7fx5wh8Z8vOVX1nYZ9meawJoRrlASYA==", "integrity": "sha512-8+dDV0xNLOgHlyBxP1GwHGVaNXsmp+2NhZEYrXr24GWLHtt27YrBPbPuHvzlhi7kZNYjeJNR93IF5zfFu5UL0g==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@next/env": "15.2.2", "@next/env": "15.3.1",
"@swc/counter": "0.1.3", "@swc/counter": "0.1.3",
"@swc/helpers": "0.5.15", "@swc/helpers": "0.5.15",
"busboy": "1.6.0", "busboy": "1.6.0",
@@ -10985,15 +11001,15 @@
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0" "node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@next/swc-darwin-arm64": "15.2.2", "@next/swc-darwin-arm64": "15.3.1",
"@next/swc-darwin-x64": "15.2.2", "@next/swc-darwin-x64": "15.3.1",
"@next/swc-linux-arm64-gnu": "15.2.2", "@next/swc-linux-arm64-gnu": "15.3.1",
"@next/swc-linux-arm64-musl": "15.2.2", "@next/swc-linux-arm64-musl": "15.3.1",
"@next/swc-linux-x64-gnu": "15.2.2", "@next/swc-linux-x64-gnu": "15.3.1",
"@next/swc-linux-x64-musl": "15.2.2", "@next/swc-linux-x64-musl": "15.3.1",
"@next/swc-win32-arm64-msvc": "15.2.2", "@next/swc-win32-arm64-msvc": "15.3.1",
"@next/swc-win32-x64-msvc": "15.2.2", "@next/swc-win32-x64-msvc": "15.3.1",
"sharp": "^0.33.5" "sharp": "^0.34.1"
}, },
"peerDependencies": { "peerDependencies": {
"@opentelemetry/api": "^1.1.0", "@opentelemetry/api": "^1.1.0",
@@ -15868,16 +15884,16 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/sharp": { "node_modules/sharp": {
"version": "0.33.5", "version": "0.34.1",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.1.tgz",
"integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", "integrity": "sha512-1j0w61+eVxu7DawFJtnfYcvSv6qPFvfTaqzTQ2BLknVhHTwGS8sc63ZBF4rzkWMBVKybo4S5OBtDdZahh2A1xg==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"color": "^4.2.3", "color": "^4.2.3",
"detect-libc": "^2.0.3", "detect-libc": "^2.0.3",
"semver": "^7.6.3" "semver": "^7.7.1"
}, },
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
@@ -15886,25 +15902,26 @@
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-arm64": "0.34.1",
"@img/sharp-darwin-x64": "0.33.5", "@img/sharp-darwin-x64": "0.34.1",
"@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-arm64": "1.1.0",
"@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.1.0",
"@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm": "1.1.0",
"@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-arm64": "1.1.0",
"@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-ppc64": "1.1.0",
"@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.1.0",
"@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linux-x64": "1.1.0",
"@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.1.0",
"@img/sharp-linux-arm": "0.33.5", "@img/sharp-libvips-linuxmusl-x64": "1.1.0",
"@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-arm": "0.34.1",
"@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-arm64": "0.34.1",
"@img/sharp-linux-x64": "0.33.5", "@img/sharp-linux-s390x": "0.34.1",
"@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linux-x64": "0.34.1",
"@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.34.1",
"@img/sharp-wasm32": "0.33.5", "@img/sharp-linuxmusl-x64": "0.34.1",
"@img/sharp-win32-ia32": "0.33.5", "@img/sharp-wasm32": "0.34.1",
"@img/sharp-win32-x64": "0.33.5" "@img/sharp-win32-ia32": "0.34.1",
"@img/sharp-win32-x64": "0.34.1"
} }
}, },
"node_modules/shebang-command": { "node_modules/shebang-command": {

View File

@@ -10,6 +10,6 @@ model ConnectedAircraft {
positionLogIds Int[] @default([]) positionLogIds Int[] @default([])
// relations: // relations:
user User @relation(fields: [userId], references: [id]) User User @relation(fields: [userId], references: [id])
station Station @relation(fields: [stationId], references: [id]) Station Station @relation(fields: [stationId], references: [id])
} }