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

@@ -1,7 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { useConnectionStore } from "_store/connectionStore";
import { useDispatchConnectionStore } from "_store/pilot/connectionStore";
import {
Disc,
Mic,
@@ -20,7 +20,7 @@ import { ConnectionQuality } from "livekit-client";
import { ROOMS } from "_data/livekitRooms";
export const Audio = () => {
const connection = useConnectionStore();
const connection = useDispatchConnectionStore();
const {
isTalking,
toggleTalking,
@@ -36,7 +36,7 @@ export const Audio = () => {
useEffect(() => {
const joinRoom = async () => {
if (!connection.isConnected) return;
if (connection.status != "connected") return;
if (state === "connected") return;
connect(selectedRoom);
};
@@ -46,7 +46,8 @@ export const Audio = () => {
return () => {
disconnect();
};
}, [connection.isConnected]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [connection.status]);
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";
import { useSession } from "next-auth/react";
import { useDispatchConnectionStore } from "../../../_store/connectionStore";
import { useRef, useState } from "react";
import { useDispatchConnectionStore } from "../../../_store/pilot/connectionStore";
import { useEffect, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { getStationsAPI } from "querys/stations";
export const ConnectionBtn = () => {
const modalRef = useRef<HTMLDialogElement>(null);
const connection = useDispatchConnectionStore((state) => state);
const [form, setForm] = useState({
logoffTime: "",
selectedZone: "LST_01",
const [form, setForm] = useState<{
logoffTime: string | null;
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 uid = session.data?.user?.id;
if (!uid) return null;
return (
<>
{!connection.isConnected ? (
<div className="rounded-box bg-base-200 flex justify-center items-center gap-2 p-1">
{connection.message.length > 0 && (
<span className="mx-2 text-error">{connection.message}</span>
)}
{connection.status === "disconnected" && (
<button
className="btn btn-soft btn-info"
className="btn btn-sm btn-soft btn-info "
onClick={() => modalRef.current?.showModal()}
>
Verbinden
</button>
) : (
)}
{connection.status == "connected" && (
<button
className="btn btn-soft btn-success"
onClick={() => modalRef.current?.showModal()}
@@ -33,17 +62,38 @@ export const ConnectionBtn = () => {
<dialog ref={modalRef} className="modal">
<div className="modal-box flex flex-col items-center justify-center">
{connection.isConnected ? (
{connection.status == "connected" ? (
<h3 className="text-lg font-bold mb-5">
Verbunden als{" "}
<span className="text-info">
&lt;{connection.selectedZone}&gt;
&lt;{connection.selectedStation?.bosCallsign}&gt;
</span>
</h3>
) : (
<h3 className="text-lg font-bold mb-5">Als Disponent anmelden</h3>
)}
<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">
<span>Logoff Zeit (UTC+1)</span>
<input
@@ -53,12 +103,12 @@ export const ConnectionBtn = () => {
logoffTime: e.target.value,
})
}
value={form.logoffTime}
value={form.logoffTime ?? ""}
type="time"
className="input w-full"
/>
</label>
{!connection.isConnected && (
{connection.status == "disconnected" && (
<p className="fieldset-label">
Du kannst diese Zeit später noch anpassen.
</p>
@@ -67,7 +117,7 @@ export const ConnectionBtn = () => {
<div className="modal-action flex justify-between w-full">
<form method="dialog" className="w-full flex justify-between">
<button className="btn btn-soft">Abbrechen</button>
{connection.isConnected ? (
{connection.status == "connected" ? (
<button
className="btn btn-soft btn-error"
type="submit"
@@ -83,7 +133,16 @@ export const ConnectionBtn = () => {
type="submit"
onSubmit={() => false}
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"
>
@@ -94,7 +153,7 @@ export const ConnectionBtn = () => {
</div>
</div>
</dialog>
</>
</div>
);
};

View File

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