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:
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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">
|
||||
<{connection.selectedZone}>
|
||||
<{connection.selectedStation?.bosCallsign}>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
export interface Dispatcher {
|
||||
userId: string;
|
||||
lastSeen: string;
|
||||
loginTime: string;
|
||||
logoffTime: string;
|
||||
selectedZone: string;
|
||||
name: string;
|
||||
socketId: string;
|
||||
}
|
||||
Reference in New Issue
Block a user