diff --git a/apps/dispatch-server/routes/aircraft.ts b/apps/dispatch-server/routes/aircraft.ts index ba5c8f7d..c3ff5d44 100644 --- a/apps/dispatch-server/routes/aircraft.ts +++ b/apps/dispatch-server/routes/aircraft.ts @@ -83,6 +83,7 @@ router.patch("/:id", async (req, res) => { data: { stationId: updatedConnectedAircraft.stationId, aircraftId: updatedConnectedAircraft.id, + userId: updatedConnectedAircraft.userId, }, } as NotificationPayload); } diff --git a/apps/dispatch-server/socket-events/connect-dispatch.ts b/apps/dispatch-server/socket-events/connect-dispatch.ts index 06e485fe..192fd25f 100644 --- a/apps/dispatch-server/socket-events/connect-dispatch.ts +++ b/apps/dispatch-server/socket-events/connect-dispatch.ts @@ -6,7 +6,15 @@ import { Server, Socket } from "socket.io"; export const handleConnectDispatch = (socket: Socket, io: Server) => - async ({ logoffTime, selectedZone }: { logoffTime: string; selectedZone: string }) => { + async ({ + logoffTime, + selectedZone, + ghostMode, + }: { + logoffTime: string; + selectedZone: string; + ghostMode: boolean; + }) => { try { const user: User = socket.data.user; // User ID aus dem JWT-Token @@ -53,6 +61,7 @@ export const handleConnectDispatch = userId: user.id, zone: selectedZone, loginTime: new Date().toISOString(), + ghostMode, }, }); diff --git a/apps/dispatch/app/(app)/dispatch/_components/navbar/Navbar.tsx b/apps/dispatch/app/(app)/dispatch/_components/navbar/Navbar.tsx index 4be4b3c4..d1bd729e 100644 --- a/apps/dispatch/app/(app)/dispatch/_components/navbar/Navbar.tsx +++ b/apps/dispatch/app/(app)/dispatch/_components/navbar/Navbar.tsx @@ -2,19 +2,29 @@ import { Connection } from "./_components/Connection"; import { Audio } from "../../../../_components/Audio/Audio"; import { ExitIcon, ExternalLinkIcon } from "@radix-ui/react-icons"; import Link from "next/link"; -import { Settings } from "_components/navbar/Settings"; +import { Settings } from "./_components/Settings"; import AdminPanel from "_components/navbar/AdminPanel"; import { getServerSession } from "api/auth/[...nextauth]/auth"; import { WarningAlert } from "_components/navbar/PageAlert"; import { Radar } from "lucide-react"; +import { ChangelogWrapper } from "_components/navbar/ChangelogWrapper"; +import { prisma } from "@repo/db"; export default async function Navbar() { const session = await getServerSession(); + const latestChangelog = await prisma.changelog.findFirst({ + orderBy: { + createdAt: "desc", + }, + }); return ( -
+
-

VAR Leitstelle V2

+
+

VAR Leitstelle

+ +
{session?.user.permissions.includes("ADMIN_KICK") && }
@@ -38,12 +48,12 @@ export default async function Navbar() { rel="noopener noreferrer" >
diff --git a/apps/dispatch/app/(app)/dispatch/_components/navbar/_components/Connection.tsx b/apps/dispatch/app/(app)/dispatch/_components/navbar/_components/Connection.tsx index c7fe631c..f1450f19 100644 --- a/apps/dispatch/app/(app)/dispatch/_components/navbar/_components/Connection.tsx +++ b/apps/dispatch/app/(app)/dispatch/_components/navbar/_components/Connection.tsx @@ -7,6 +7,7 @@ import { useMutation } from "@tanstack/react-query"; import { Prisma } from "@repo/db"; import { changeDispatcherAPI } from "_querys/dispatcher"; import { Button, getNextDateWithTime } from "@repo/shared-components"; +import { Ghost } from "lucide-react"; export const ConnectionBtn = () => { const modalRef = useRef(null); @@ -14,6 +15,7 @@ export const ConnectionBtn = () => { const [form, setForm] = useState({ logoffTime: "", selectedZone: "LST_01", + ghostMode: false, }); const changeDispatcherMutation = useMutation({ mutationFn: ({ id, data }: { id: number; data: Prisma.ConnectedDispatcherUpdateInput }) => @@ -45,7 +47,7 @@ export const ConnectionBtn = () => { modalRef.current?.showModal(); }} > - Verbunden + Verbunden {connection.ghostMode && } ) : ( @@ -138,6 +155,7 @@ export const ConnectionBtn = () => { form.logoffTime && logoffHours !== undefined && logoffMinutes !== undefined ? getNextDateWithTime(logoffHours, logoffMinutes).toISOString() : "", + form.ghostMode, ); }} className="btn btn-soft btn-info" diff --git a/apps/dispatch/app/(app)/dispatch/_components/navbar/_components/Settings.tsx b/apps/dispatch/app/(app)/dispatch/_components/navbar/_components/Settings.tsx new file mode 100644 index 00000000..c83288b8 --- /dev/null +++ b/apps/dispatch/app/(app)/dispatch/_components/navbar/_components/Settings.tsx @@ -0,0 +1,255 @@ +"use client"; +import { useEffect, useRef, useState } from "react"; +import { GearIcon } from "@radix-ui/react-icons"; +import { SettingsIcon, Volume2 } from "lucide-react"; +import MicVolumeBar from "_components/MicVolumeIndication"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { editUserAPI, getUserAPI } from "_querys/user"; +import { useSession } from "next-auth/react"; +import { useAudioStore } from "_store/audioStore"; +import toast from "react-hot-toast"; +import { useMapStore } from "_store/mapStore"; +import { set } from "date-fns"; + +export const SettingsBtn = () => { + const session = useSession(); + + const [inputDevices, setInputDevices] = useState([]); + const { data: user } = useQuery({ + queryKey: ["user", session.data?.user.id], + queryFn: () => getUserAPI(session.data!.user.id), + }); + const testSoundRef = useRef(null); + + const editUserMutation = useMutation({ + mutationFn: editUserAPI, + }); + + useEffect(() => { + if (typeof window !== "undefined") { + testSoundRef.current = new Audio("/sounds/DME-new-mission.wav"); + } + }, []); + + const modalRef = useRef(null); + + const [showIndication, setShowIndication] = useState(false); + + const [settings, setSettings] = useState({ + micDeviceId: user?.settingsMicDevice || null, + micVolume: user?.settingsMicVolume || 1, + radioVolume: user?.settingsRadioVolume || 0.8, + autoCloseMapPopup: user?.settingsAutoCloseMapPopup || false, + }); + + const { setSettings: setAudioSettings } = useAudioStore((state) => state); + const { setUserSettings: setUserSettings } = useMapStore((state) => state); + + useEffect(() => { + if (user) { + setAudioSettings({ + micDeviceId: user.settingsMicDevice, + micVolume: user.settingsMicVolume || 1, + radioVolume: user.settingsRadioVolume || 0.8, + dmeVolume: user.settingsDmeVolume || 0.8, + }); + setSettings({ + micDeviceId: user.settingsMicDevice, + micVolume: user.settingsMicVolume || 1, + radioVolume: user.settingsRadioVolume || 0.8, + autoCloseMapPopup: user.settingsAutoCloseMapPopup || false, + }); + setUserSettings({ + settingsAutoCloseMapPopup: user.settingsAutoCloseMapPopup || false, + }); + } + }, [user, setSettings, setAudioSettings, setUserSettings]); + + const setSettingsPartial = (newSettings: Partial) => { + setSettings((prev) => ({ + ...prev, + ...newSettings, + })); + }; + + useEffect(() => { + const setDevices = async () => { + if (typeof navigator !== "undefined" && navigator.mediaDevices?.enumerateDevices) { + const stream = await navigator.mediaDevices.getUserMedia({ video: false, audio: true }); + const devices = await navigator.mediaDevices.enumerateDevices(); + setInputDevices(devices.filter((d) => d.kind === "audioinput")); + stream.getTracks().forEach((track) => track.stop()); + } + }; + + setDevices(); + }, []); + + return ( +
+ + + +
+

+ Einstellungen +

+
+
+ +
+

+ Eingabelautstärke +

+
+ { + const value = parseFloat(e.target.value); + setSettingsPartial({ micVolume: value }); + setShowIndication(true); + }} + value={settings.micVolume} + className="range range-xs range-accent w-full" + /> +
+ 0% + 25% + 50% + 75% + 100% +
+
+ {showIndication && ( + + )} +
+
+

+ Funk Lautstärke +

+
+ { + const value = parseFloat(e.target.value); + setSettingsPartial({ radioVolume: value }); + }} + value={settings.radioVolume} + className="range range-xs range-primary w-full" + /> +
+ 0% + 25% + 50% + 75% + 100% +
+
+ +
+
Disponenten Einstellungen
+
+ +
+ { + setSettingsPartial({ autoCloseMapPopup: e.target.checked }); + }} + /> + Popups automatisch schließen +
+ +
+ + +
+
+
+
+ ); +}; +export const Settings = () => { + return ( +
+ +
+ ); +}; diff --git a/apps/dispatch/app/(app)/pilot/_components/dme/useSounds.ts b/apps/dispatch/app/(app)/pilot/_components/dme/useSounds.ts index f1c2c164..641b9feb 100644 --- a/apps/dispatch/app/(app)/pilot/_components/dme/useSounds.ts +++ b/apps/dispatch/app/(app)/pilot/_components/dme/useSounds.ts @@ -13,7 +13,7 @@ export const useSounds = () => { useEffect(() => { if (typeof window !== "undefined") { - newMissionSound.current = new Audio("/sounds/Melder3.wav"); + newMissionSound.current = new Audio("/sounds/DME-new-mission.wav"); } }, []); diff --git a/apps/dispatch/app/(app)/pilot/_components/navbar/Navbar.tsx b/apps/dispatch/app/(app)/pilot/_components/navbar/Navbar.tsx index ca38fe35..39013bc1 100644 --- a/apps/dispatch/app/(app)/pilot/_components/navbar/Navbar.tsx +++ b/apps/dispatch/app/(app)/pilot/_components/navbar/Navbar.tsx @@ -2,15 +2,25 @@ import { Connection } from "./_components/Connection"; import { Audio } from "_components/Audio/Audio"; import { ExitIcon, ExternalLinkIcon } from "@radix-ui/react-icons"; import Link from "next/link"; -import { Settings } from "_components/navbar/Settings"; +import { Settings } from "./_components/Settings"; import { WarningAlert } from "_components/navbar/PageAlert"; import { Radar } from "lucide-react"; +import { prisma } from "@repo/db"; +import { ChangelogWrapper } from "_components/navbar/ChangelogWrapper"; -export default function Navbar() { +export default async function Navbar() { + const latestChangelog = await prisma.changelog.findFirst({ + orderBy: { + createdAt: "desc", + }, + }); return ( -
+
-

VAR Operations Center

+
+

VAR Operations Center

+ +
@@ -33,12 +43,12 @@ export default function Navbar() { rel="noopener noreferrer" >
diff --git a/apps/dispatch/app/_components/navbar/Settings.tsx b/apps/dispatch/app/(app)/pilot/_components/navbar/_components/Settings.tsx similarity index 99% rename from apps/dispatch/app/_components/navbar/Settings.tsx rename to apps/dispatch/app/(app)/pilot/_components/navbar/_components/Settings.tsx index 15f42bbb..9d1f23d8 100644 --- a/apps/dispatch/app/_components/navbar/Settings.tsx +++ b/apps/dispatch/app/(app)/pilot/_components/navbar/_components/Settings.tsx @@ -26,7 +26,7 @@ export const SettingsBtn = () => { useEffect(() => { if (typeof window !== "undefined") { - testSoundRef.current = new Audio("/sounds/Melder3.wav"); + testSoundRef.current = new Audio("/sounds/DME-new-mission.wav"); } }, []); diff --git a/apps/dispatch/app/(app)/pilot/page.tsx b/apps/dispatch/app/(app)/pilot/page.tsx index af023c97..f68a358b 100644 --- a/apps/dispatch/app/(app)/pilot/page.tsx +++ b/apps/dispatch/app/(app)/pilot/page.tsx @@ -33,20 +33,20 @@ const PilotPage = () => {
{/* */}
-
+
-
+
-
+
{!simulatorConnected && status === "connected" && ( )} diff --git a/apps/dispatch/app/_components/SmartPopup.tsx b/apps/dispatch/app/_components/SmartPopup.tsx index 25075986..44c57fca 100644 --- a/apps/dispatch/app/_components/SmartPopup.tsx +++ b/apps/dispatch/app/_components/SmartPopup.tsx @@ -139,10 +139,10 @@ export const SmartPopup = (
@@ -150,7 +150,7 @@ export const SmartPopup = ( data-id={id} id={`popup-domain-${id}`} className={cn( - "map-collision absolute w-[200%] h-[200%] top-0 left-0 transform pointer-events-none", + "map-collision pointer-events-none absolute left-0 top-0 h-[200%] w-[200%] transform", anchor.includes("left") && "-translate-x-1/2", anchor.includes("top") && "-translate-y-1/2", )} diff --git a/apps/dispatch/app/_components/customToasts/HPGnotification.tsx b/apps/dispatch/app/_components/customToasts/HPGnotification.tsx index ccf5593d..52152e65 100644 --- a/apps/dispatch/app/_components/customToasts/HPGnotification.tsx +++ b/apps/dispatch/app/_components/customToasts/HPGnotification.tsx @@ -15,10 +15,19 @@ export const HPGnotificationToast = ({ }) => { const handleClick = () => { toast.dismiss(t.id); - mapStore.setOpenMissionMarker({ - open: [{ id: event.data.mission.id, tab: "home" }], - close: [], - }); + + if (mapStore.userSettings.settingsAutoCloseMapPopup) { + mapStore.setOpenMissionMarker({ + open: [{ id: event.data.mission.id, tab: "home" }], + close: mapStore.openMissionMarker?.map((m) => m.id) || [], + }); + } else { + mapStore.setOpenMissionMarker({ + open: [{ id: event.data.mission.id, tab: "home" }], + close: [], + }); + } + mapStore.setMap({ center: [event.data.mission.addressLat, event.data.mission.addressLng], zoom: 14, @@ -29,7 +38,7 @@ export const HPGnotificationToast = ({ return ( } className="flex flex-row">
-

HPG validierung fehlgeschlagen

+

HPG validierung fehlgeschlagen

{event.message}

@@ -43,7 +52,7 @@ export const HPGnotificationToast = ({ return ( } className="flex flex-row">
-

HPG validierung erfolgreich

+

HPG validierung erfolgreich

{event.message}

diff --git a/apps/dispatch/app/_components/customToasts/MissionAutoClose.tsx b/apps/dispatch/app/_components/customToasts/MissionAutoClose.tsx index af1ef5e9..bc9efc43 100644 --- a/apps/dispatch/app/_components/customToasts/MissionAutoClose.tsx +++ b/apps/dispatch/app/_components/customToasts/MissionAutoClose.tsx @@ -65,15 +65,27 @@ export const MissionAutoCloseToast = ({ lng: mission.addressLng, }, }); - mapStore.setOpenMissionMarker({ - open: [ - { - id: mission.id, - tab: "home", - }, - ], - close: [], - }); + if (mapStore.userSettings.settingsAutoCloseMapPopup) { + mapStore.setOpenMissionMarker({ + open: [ + { + id: mission.id, + tab: "home", + }, + ], + close: mapStore.openMissionMarker?.map((m) => m.id) || [], + }); + } else { + mapStore.setOpenMissionMarker({ + open: [ + { + id: mission.id, + tab: "home", + }, + ], + close: [], + }); + } toast.dismiss(t.id); }} > diff --git a/apps/dispatch/app/_components/customToasts/StationStatusToast.tsx b/apps/dispatch/app/_components/customToasts/StationStatusToast.tsx index 689cbbde..ce66b81a 100644 --- a/apps/dispatch/app/_components/customToasts/StationStatusToast.tsx +++ b/apps/dispatch/app/_components/customToasts/StationStatusToast.tsx @@ -3,7 +3,9 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { BaseNotification } from "_components/customToasts/BaseNotification"; import { FMS_STATUS_COLORS } from "_helpers/fmsStatusColors"; import { editConnectedAircraftAPI, getConnectedAircraftsAPI } from "_querys/aircrafts"; +import { getLivekitRooms } from "_querys/livekit"; import { getStationsAPI } from "_querys/stations"; +import { useAudioStore } from "_store/audioStore"; import { useMapStore } from "_store/mapStore"; import { X } from "lucide-react"; import { useEffect, useRef, useState } from "react"; @@ -20,6 +22,23 @@ export const StatusToast = ({ event, t }: { event: StationStatus; t: Toast }) => const status5Sounds = useRef(null); const status9Sounds = useRef(null); + const { data: livekitRooms } = useQuery({ + queryKey: ["livekit-rooms"], + queryFn: () => getLivekitRooms(), + refetchInterval: 10000, + }); + const audioRoom = useAudioStore((s) => s.room?.name); + + const participants = + livekitRooms?.flatMap((room) => + room.participants.map((p) => ({ + ...p, + roomName: room.room.name, + })), + ) || []; + + const livekitUser = participants.find((p) => p.attributes.userId === event.data?.userId); + useEffect(() => { if (typeof window !== "undefined") { status0Sounds.current = new Audio("/sounds/status-0.mp3"); @@ -28,7 +47,8 @@ export const StatusToast = ({ event, t }: { event: StationStatus; t: Toast }) => } }, []); const [aircraftDataAcurate, setAircraftDataAccurate] = useState(false); - const mapStore = useMapStore((s) => s); + //const mapStore = useMapStore((s) => s); + const { setOpenAircraftMarker, setMap } = useMapStore((store) => store); const { data: connectedAircrafts } = useQuery({ queryKey: ["aircrafts"], @@ -83,6 +103,11 @@ export const StatusToast = ({ event, t }: { event: StationStatus; t: Toast }) => default: soundRef = null; } + if (audioRoom !== livekitUser?.roomName) { + toast.remove(t.id); + return; + } + if (soundRef?.current) { soundRef.current.currentTime = 0; soundRef.current.volume = 0.7; @@ -94,22 +119,23 @@ export const StatusToast = ({ event, t }: { event: StationStatus; t: Toast }) => soundRef.current.currentTime = 0; } }; - }, [event.status]); + }, [event.status, livekitUser?.roomName, audioRoom, t.id]); if (!connectedAircraft || !station) return null; return ( -
+

{ if (!connectedAircraft.posLat || !connectedAircraft.posLng) return; - mapStore.setOpenAircraftMarker({ + + setOpenAircraftMarker({ open: [{ id: connectedAircraft.id, tab: "fms" }], close: [], }); - mapStore.setMap({ + setMap({ center: [connectedAircraft.posLat, connectedAircraft.posLng], zoom: 14, }); @@ -119,12 +145,12 @@ export const StatusToast = ({ event, t }: { event: StationStatus; t: Toast }) => sendet Status {event.status}

-
+
{QUICK_RESPONSE[String(event.status)]?.map((status) => (
{chatOpen && (
-

+

Chat

@@ -164,7 +177,7 @@ export const Chat = () => { {chat.name} {chat.notification && } -
+
{/* So macht man kein overflow handeling, weiß ich. Aber es funktioniert... */} {chat.messages.map((chatMessage) => { const isSender = chatMessage.senderId === session.data?.user.id; diff --git a/apps/dispatch/app/_components/left/SituationBoard.tsx b/apps/dispatch/app/_components/left/SituationBoard.tsx index 874234b3..0f596e9e 100644 --- a/apps/dispatch/app/_components/left/SituationBoard.tsx +++ b/apps/dispatch/app/_components/left/SituationBoard.tsx @@ -9,6 +9,7 @@ import { getConnectedAircraftsAPI } from "_querys/aircrafts"; import { FMS_STATUS_COLORS, FMS_STATUS_TEXT_COLORS } from "_helpers/fmsStatusColors"; import { useMapStore } from "_store/mapStore"; import { useDispatchConnectionStore } from "_store/dispatch/connectionStore"; +import { findLeitstelleForPosition } from "_helpers/findLeitstelleinPoint"; export const SituationBoard = () => { const { setSituationTabOpen, situationTabOpen } = useLeftMenuStore(); @@ -53,7 +54,14 @@ export const SituationBoard = () => { queryKey: ["aircrafts"], queryFn: () => getConnectedAircraftsAPI(), }); - const { setOpenAircraftMarker, setOpenMissionMarker, setMap } = useMapStore((state) => state); + const { + setOpenAircraftMarker, + setOpenMissionMarker, + setMap, + userSettings, + openAircraftMarker, + openMissionMarker, + } = useMapStore((state) => state); return (
@@ -64,17 +72,17 @@ export const SituationBoard = () => { setSituationTabOpen(!situationTabOpen); }} > - +
{situationTabOpen && (
-

+

Einsatzliste{" "}

@@ -90,8 +98,8 @@ export const SituationBoard = () => {
-
- +
+
{/* head */} @@ -111,15 +119,27 @@ export const SituationBoard = () => { mission.state === "draft" && "missionListItem", )} onDoubleClick={() => { - setOpenMissionMarker({ - open: [ - { - id: mission.id, - tab: "home", - }, - ], - close: [], - }); + if (userSettings.settingsAutoCloseMapPopup) { + setOpenMissionMarker({ + open: [ + { + id: mission.id, + tab: "home", + }, + ], + close: openMissionMarker?.map((m) => m.id) || [], + }); + } else { + setOpenMissionMarker({ + open: [ + { + id: mission.id, + tab: "home", + }, + ], + close: [], + }); + } setMap({ center: { lat: mission.addressLat, @@ -145,13 +165,13 @@ export const SituationBoard = () => {
-
+
-

+

Stationen

-
- +
+
@@ -160,41 +180,59 @@ export const SituationBoard = () => { - {connectedAircrafts?.map((station) => ( + {connectedAircrafts?.map((aircraft) => ( { - setOpenAircraftMarker({ - open: [ - { - id: station.id, - tab: "home", - }, - ], - close: [], - }); - if (station.posLat === null || station.posLng === null) return; + if (userSettings.settingsAutoCloseMapPopup) { + setOpenAircraftMarker({ + open: [ + { + id: aircraft.id, + tab: "home", + }, + ], + close: openAircraftMarker?.map((m) => m.id) || [], + }); + } else { + setOpenAircraftMarker({ + open: [ + { + id: aircraft.id, + tab: "home", + }, + ], + close: [], + }); + } + if (aircraft.posLat === null || aircraft.posLng === null) return; setMap({ center: { - lat: station.posLat, - lng: station.posLng, + lat: aircraft.posLat, + lng: aircraft.posLng, }, zoom: 14, }); }} > - + + - ))} diff --git a/apps/dispatch/app/_components/map/AircraftMarker.tsx b/apps/dispatch/app/_components/map/AircraftMarker.tsx index 5fc9edf6..371565de 100644 --- a/apps/dispatch/app/_components/map/AircraftMarker.tsx +++ b/apps/dispatch/app/_components/map/AircraftMarker.tsx @@ -17,6 +17,7 @@ import { getConnectedAircraftPositionLogAPI, getConnectedAircraftsAPI } from "_q import { getMissionsAPI } from "_querys/missions"; import { FMS_STATUS_COLORS, FMS_STATUS_TEXT_COLORS } from "_helpers/fmsStatusColors"; import { usePilotConnectionStore } from "_store/pilot/connectionStore"; +import { useSession } from "next-auth/react"; const AircraftPopupContent = ({ aircraft, @@ -37,7 +38,7 @@ const AircraftPopupContent = ({ ); const { data: missions } = useQuery({ - queryKey: ["missions", "missions-map"], + queryKey: ["missions", "missions-aircraft-marker", aircraft.id], queryFn: () => getMissionsAPI({ state: "running", @@ -72,7 +73,7 @@ const AircraftPopupContent = ({ } }, [currentTab, aircraft, mission]); - const { setOpenAircraftMarker, setMap } = useMapStore((state) => state); + const { setOpenAircraftMarker, setMap, openAircraftMarker } = useMapStore((state) => state); const { anchor } = useSmartPopup(); return ( <> @@ -159,7 +160,7 @@ const AircraftPopupContent = ({ {aircraft.fmsStatus}
Einsatz
- - {mission?.publicId || "kein Einsatz"} - + {!mission?.publicId && Kein Einsatz} + {mission?.publicId && ( + {mission.publicId} + )}
(null); const popupRef = useRef(null); - const { openAircraftMarker, setOpenAircraftMarker } = useMapStore((store) => store); + const { openAircraftMarker, setOpenAircraftMarker, userSettings } = useMapStore((store) => store); const { data: positionLog } = useQuery({ queryKey: ["positionlog", aircraft.id], queryFn: () => @@ -263,14 +266,16 @@ const AircraftMarker = ({ aircraft }: { aircraft: ConnectedAircraft & { Station: return () => { marker?.off("click", handleClick); }; - }, [aircraft.id, openAircraftMarker, setOpenAircraftMarker]); + }, [aircraft.id, openAircraftMarker, setOpenAircraftMarker, userSettings]); const [anchor, setAnchor] = useState<"topleft" | "topright" | "bottomleft" | "bottomright">( "topleft", ); const handleConflict = useCallback(() => { - const newAnchor = calculateAnchor(`aircraft-${aircraft.id}`, "marker"); + const newAnchor = calculateAnchor(`aircraft-${aircraft.id}`, "marker", { + ignoreCluster: true, + }); setAnchor(newAnchor); }, [aircraft.id]); @@ -372,7 +377,7 @@ const AircraftMarker = ({ aircraft }: { aircraft: ConnectedAircraft & { Station: closeOnClick={false} autoPan={false} wrapperClassName="relative" - className="w-[502px]" + className="w-[512px]" >
diff --git a/apps/dispatch/app/_components/map/ContextMenu.tsx b/apps/dispatch/app/_components/map/ContextMenu.tsx index fa76c88f..962286cc 100644 --- a/apps/dispatch/app/_components/map/ContextMenu.tsx +++ b/apps/dispatch/app/_components/map/ContextMenu.tsx @@ -20,9 +20,12 @@ export const ContextMenu = () => { setSearchPopup, toggleSearchElementSelection, } = useMapStore(); - const { missionFormValues, setMissionFormValues, setOpen, isOpen } = usePannelStore( - (state) => state, - ); + const { + missionFormValues, + setMissionFormValues, + setOpen, + isOpen: isPannelOpen, + } = usePannelStore((state) => state); const [showRulerOptions, setShowRulerOptions] = useState(false); const [rulerHover, setRulerHover] = useState(false); const [rulerOptionsHover, setRulerOptionsHover] = useState(false); @@ -53,7 +56,8 @@ export const ContextMenu = () => { if (!contextMenu || !dispatcherConnected) return null; - const missionBtnText = missionFormValues && isOpen ? "Position übernehmen" : "Einsatz erstellen"; + const missionBtnText = + missionFormValues && isPannelOpen ? "Position übernehmen" : "Einsatz erstellen"; const addOSMobjects = async (ignorePreviosSelected?: boolean) => { const res = await fetch( @@ -101,13 +105,13 @@ export const ContextMenu = () => { autoPan={false} >
-
+
{/* Top Button */} {/* Left Button */} {/* Bottom Button */} {/* Right Button (original Search button) */}
- ${mission.missionKeywordAbbreviation} ${mission.missionKeywordName} + ${mission.missionKeywordAbbreviation} ${options.hideDetailedKeyword ? "" : mission.missionKeywordName}
{ selectedStation, } = usePilotConnectionStore((state) => state); + const { data: aircrafts = [] } = useQuery({ + queryKey: ["aircrafts"], + queryFn: () => getConnectedAircraftsAPI(), + refetchInterval: 10_000, + }); const { data: missions = [] } = useQuery({ queryKey: ["missions"], queryFn: () => @@ -426,7 +441,15 @@ export const MissionLayer = () => { return ( <> {filteredMissions.map((mission) => { - return ; + return ( + 10, + }} + /> + ); })} ); diff --git a/apps/dispatch/app/_components/map/_components/AircraftMarkerTabs.tsx b/apps/dispatch/app/_components/map/_components/AircraftMarkerTabs.tsx index 42c1fdf5..8a6a8ad9 100644 --- a/apps/dispatch/app/_components/map/_components/AircraftMarkerTabs.tsx +++ b/apps/dispatch/app/_components/map/_components/AircraftMarkerTabs.tsx @@ -245,7 +245,7 @@ const StationTab = ({ aircraft }: { aircraft: ConnectedAircraft & { Station: Sta })), ) || []; - const livekitUser = participants.find((p) => (p.attributes.userId = aircraft.userId)); + const livekitUser = participants.find((p) => p.attributes.userId === aircraft.userId); const lstName = useMemo(() => { if (!aircraft.posLng || !aircraft.posLat) return station.bosRadioArea; diff --git a/apps/dispatch/app/_components/map/_components/MarkerCluster.tsx b/apps/dispatch/app/_components/map/_components/MarkerCluster.tsx index a8a342a1..8dbcd078 100644 --- a/apps/dispatch/app/_components/map/_components/MarkerCluster.tsx +++ b/apps/dispatch/app/_components/map/_components/MarkerCluster.tsx @@ -21,7 +21,13 @@ const PopupContent = ({ missions: Mission[]; }) => { const { anchor } = useSmartPopup(); - const { setOpenAircraftMarker, setOpenMissionMarker } = useMapStore((state) => state); + const { + setOpenAircraftMarker, + setOpenMissionMarker, + openAircraftMarker, + openMissionMarker, + userSettings, + } = useMapStore((state) => state); const map = useMap(); let borderColor = ""; @@ -42,10 +48,10 @@ const PopupContent = ({ return ( <> -
+
{ - setOpenMissionMarker({ - open: [ - { - id: mission.id, - tab: "home", - }, - ], - close: [], - }); + if (userSettings.settingsAutoCloseMapPopup) { + setOpenMissionMarker({ + open: [ + { + id: mission.id, + tab: "home", + }, + ], + close: openMissionMarker?.map((m) => m.id) || [], + }); + } else { + setOpenMissionMarker({ + open: [ + { + id: mission.id, + tab: "home", + }, + ], + close: [], + }); + } map.setView([mission.addressLat, mission.addressLng], 12, { animate: true, }); @@ -99,34 +117,50 @@ const PopupContent = ({ {aircrafts.map((aircraft) => (
{ - setOpenAircraftMarker({ - open: [ - { - id: aircraft.id, - tab: "aircraft", - }, - ], - close: [], - }); + if (userSettings.settingsAutoCloseMapPopup) { + setOpenAircraftMarker({ + open: [ + { + id: aircraft.id, + tab: "home", + }, + ], + close: openAircraftMarker?.map((m) => m.id) || [], + }); + } else { + setOpenAircraftMarker({ + open: [ + { + id: aircraft.id, + tab: "home", + }, + ], + close: [], + }); + } map.setView([aircraft.posLat!, aircraft.posLng!], 12, { animate: true, }); }} > {aircraft.fmsStatus} - {aircraft.Station.bosCallsign} + + {aircraft.Station.bosCallsign.length > 15 + ? aircraft.Station.locationStateShort + : aircraft.Station.bosCallsign} +
))}
@@ -212,7 +246,7 @@ export const MarkerCluster = () => { const lng = aircraft.posLng!; const existingClusterIndex = newCluster.findIndex( - (c) => Math.abs(c.lat - lat) < 1 && Math.abs(c.lng - lng) < 1, + (c) => Math.abs(c.lat - lat) < 1.55 && Math.abs(c.lng - lng) < 1, ); const existingCluster = newCluster[existingClusterIndex]; if (existingCluster) { @@ -299,7 +333,7 @@ export const MarkerCluster = () => { position={[c.lat, c.lng]} autoPan={false} autoClose={false} - className="w-[202px]" + className="min-w-fit" > diff --git a/apps/dispatch/app/_components/map/openCloseMarker.ts b/apps/dispatch/app/_components/map/openCloseMarker.ts new file mode 100644 index 00000000..e69de29b diff --git a/apps/dispatch/app/_components/navbar/AdminPanel.tsx b/apps/dispatch/app/_components/navbar/AdminPanel.tsx index 3d1bbb49..1f4d35e4 100644 --- a/apps/dispatch/app/_components/navbar/AdminPanel.tsx +++ b/apps/dispatch/app/_components/navbar/AdminPanel.tsx @@ -92,6 +92,11 @@ export default function AdminPanel() { const modalRef = useRef(null); + console.debug("piloten von API", { + anzahl: pilots?.length, + pilots, + }); + return (
-

+

Admin Panel

-
-
+
+
Verbundene Clients diff --git a/apps/dispatch/app/_components/navbar/ChangelogWrapper.tsx b/apps/dispatch/app/_components/navbar/ChangelogWrapper.tsx new file mode 100644 index 00000000..7fabc27f --- /dev/null +++ b/apps/dispatch/app/_components/navbar/ChangelogWrapper.tsx @@ -0,0 +1,34 @@ +"use client"; +import { Changelog } from "@repo/db"; +import { ChangelogModalBtn } from "@repo/shared-components"; +import { useMutation } from "@tanstack/react-query"; +import { editUserAPI } from "_querys/user"; +import { useSession } from "next-auth/react"; +import toast from "react-hot-toast"; + +export const ChangelogWrapper = ({ latestChangelog }: { latestChangelog: Changelog | null }) => { + const { data: session } = useSession(); + const editUserMutation = useMutation({ + mutationFn: editUserAPI, + }); + + const autoOpen = !session?.user.changelogAck && !!latestChangelog; + + if (!latestChangelog) return null; + if (!session) return null; + + return ( + { + await editUserMutation.mutateAsync({ id: session?.user.id, user: { changelogAck: true } }); + if (!session?.user.changelogAck) { + toast.success("Changelog als gelesen markiert"); + } + }} + /> + ); +}; diff --git a/apps/dispatch/app/_store/audioStore.ts b/apps/dispatch/app/_store/audioStore.ts index 5da5cdcb..0fb63b4d 100644 --- a/apps/dispatch/app/_store/audioStore.ts +++ b/apps/dispatch/app/_store/audioStore.ts @@ -203,7 +203,7 @@ export const useAudioStore = create((set, get) => ({ set({ state: "connected", room, message: null }); }) .on(RoomEvent.Disconnected, () => { - set({ state: "disconnected" }); + set({ state: "disconnected", speakingParticipants: [], transmitBlocked: false }); handleDisconnect(); }) diff --git a/apps/dispatch/app/_store/dispatch/connectionStore.ts b/apps/dispatch/app/_store/dispatch/connectionStore.ts index bfa64533..c7a646aa 100644 --- a/apps/dispatch/app/_store/dispatch/connectionStore.ts +++ b/apps/dispatch/app/_store/dispatch/connectionStore.ts @@ -11,7 +11,13 @@ interface ConnectionStore { message: string; selectedZone: string; logoffTime: string; - connect: (uid: string, selectedZone: string, logoffTime: string) => Promise; + ghostMode: boolean; + connect: ( + uid: string, + selectedZone: string, + logoffTime: string, + ghostMode: boolean, + ) => Promise; disconnect: () => void; } @@ -23,11 +29,12 @@ export const useDispatchConnectionStore = create((set) => ({ message: "", selectedZone: "LST_01", logoffTime: "", - connect: async (uid, selectedZone, logoffTime) => + ghostMode: false, + connect: async (uid, selectedZone, logoffTime, ghostMode) => new Promise((resolve) => { set({ status: "connecting", message: "" }); dispatchSocket.auth = { uid }; - set({ selectedZone, logoffTime }); + set({ selectedZone, logoffTime, ghostMode }); dispatchSocket.connect(); dispatchSocket.once("connect", () => { @@ -40,11 +47,12 @@ export const useDispatchConnectionStore = create((set) => ({ })); dispatchSocket.on("connect", () => { - const { logoffTime, selectedZone } = useDispatchConnectionStore.getState(); + const { logoffTime, selectedZone, ghostMode } = useDispatchConnectionStore.getState(); useAudioStore.getState().connect("LST_01", selectedZone || "Leitstelle"); dispatchSocket.emit("connect-dispatch", { logoffTime, selectedZone, + ghostMode, }); useDispatchConnectionStore.setState({ status: "connected", message: "" }); }); diff --git a/apps/dispatch/app/_store/mapStore.ts b/apps/dispatch/app/_store/mapStore.ts index b6f06493..d48bbdbb 100644 --- a/apps/dispatch/app/_store/mapStore.ts +++ b/apps/dispatch/app/_store/mapStore.ts @@ -39,23 +39,37 @@ export interface MapStore { [aircraftId: string]: "home" | "fms" | "aircraft" | "mission" | "chat"; }; setAircraftTab: (aircraftId: number, tab: MapStore["aircraftTabs"][number]) => void; + userSettings: { + settingsAutoCloseMapPopup: boolean; + }; + setUserSettings: (settings: Partial) => void; } export const useMapStore = create((set, get) => ({ openMissionMarker: [], setOpenMissionMarker: ({ open, close }) => { - const oldMarkers = get().openMissionMarker.filter( - (m) => !close.includes(m.id) && !open.find((o) => o.id === m.id), - ); + const { settingsAutoCloseMapPopup } = get().userSettings; + + const oldMarkers = + settingsAutoCloseMapPopup && open.length > 0 + ? [] // If auto-close is enabled and opening a new popup, close all others + : get().openMissionMarker.filter( + (m) => !close.includes(m.id) && !open.find((o) => o.id === m.id), + ); set(() => ({ openMissionMarker: [...oldMarkers, ...open], })); }, openAircraftMarker: [], setOpenAircraftMarker: ({ open, close }) => { - const oldMarkers = get().openAircraftMarker.filter( - (m) => !close.includes(m.id) && !open.find((o) => o.id === m.id), - ); + const { settingsAutoCloseMapPopup } = get().userSettings; + + const oldMarkers = + settingsAutoCloseMapPopup && open.length > 0 + ? [] // If auto-close is enabled and opening a new popup, close all others + : get().openAircraftMarker.filter( + (m) => !close.includes(m.id) && !open.find((o) => o.id === m.id), + ); set(() => ({ openAircraftMarker: [...oldMarkers, ...open], })); @@ -102,4 +116,14 @@ export const useMapStore = create((set, get) => ({ }, })), missionTabs: {}, + userSettings: { + settingsAutoCloseMapPopup: false, + }, + setUserSettings: (settings) => + set((state) => ({ + userSettings: { + ...state.userSettings, + ...settings, + }, + })), })); diff --git a/apps/dispatch/app/api/dispatcher/route.ts b/apps/dispatch/app/api/dispatcher/route.ts index 68db178c..63761deb 100644 --- a/apps/dispatch/app/api/dispatcher/route.ts +++ b/apps/dispatch/app/api/dispatcher/route.ts @@ -10,6 +10,7 @@ export async function GET(request: Request): Promise { const connectedDispatcher = await prisma.connectedDispatcher.findMany({ where: { logoutTime: null, + ghostMode: false, // Ensure we only get non-ghost mode connections ...filter, // Ensure filter is parsed correctly }, include: { diff --git a/apps/dispatch/app/api/position-log/route.ts b/apps/dispatch/app/api/position-log/route.ts index 3b50dbcc..87891c70 100644 --- a/apps/dispatch/app/api/position-log/route.ts +++ b/apps/dispatch/app/api/position-log/route.ts @@ -64,6 +64,7 @@ export const PUT = async (req: Request) => { }, }); + // TODO: Position Runden if (activeAircraft.posLat === position.lat && activeAircraft.posLng === position.lng) { return Response.json({ message: "Position has not changed" }, { status: 200 }); } diff --git a/apps/dispatch/app/tracker/_components/ConnectedDispatcher.tsx b/apps/dispatch/app/tracker/_components/ConnectedDispatcher.tsx index d294faf2..423c6f2d 100644 --- a/apps/dispatch/app/tracker/_components/ConnectedDispatcher.tsx +++ b/apps/dispatch/app/tracker/_components/ConnectedDispatcher.tsx @@ -23,14 +23,14 @@ export const ConnectedDispatcher = () => { return (
-
+
{/*
Kein Disponent Online
*/} -
+
{connections} {connections == 1 ? "Verbundenes Mitglied" : "Verbundene Mitglieder"} -
+
0 ? "badge-success" : "badge-error" @@ -65,7 +65,7 @@ export const ConnectedDispatcher = () => { className="tooltip tooltip-right" data-tip={`vorraussichtliche Abmeldung in ${formatDistance(new Date(), new Date(d.esimatedLogoutTime), { locale: de })}`} > -

+

{new Date(d.esimatedLogoutTime).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", @@ -76,7 +76,7 @@ export const ConnectedDispatcher = () => {

{asPublicUser(d.publicUser).fullName}
-
{d.zone}
+
{d.zone}
{(() => { diff --git a/apps/dispatch/package.json b/apps/dispatch/package.json index cf2750f1..e195c34c 100644 --- a/apps/dispatch/package.json +++ b/apps/dispatch/package.json @@ -5,7 +5,7 @@ "private": true, "packageManager": "pnpm@10.13.1", "scripts": { - "dev": "next dev --turbopack -p 3001", + "dev": "next dev -p 3001", "build": "next build", "start": "next start", "lint": "next lint --max-warnings 0", diff --git a/apps/dispatch/public/sounds/Melder3.wav b/apps/dispatch/public/sounds/DME-new-mission.wav similarity index 100% rename from apps/dispatch/public/sounds/Melder3.wav rename to apps/dispatch/public/sounds/DME-new-mission.wav diff --git a/apps/dispatch/public/sounds/connection_stoped_sepura.mp3 b/apps/dispatch/public/sounds/connection_stoped_sepura.mp3 index a31c9302..522f543a 100644 Binary files a/apps/dispatch/public/sounds/connection_stoped_sepura.mp3 and b/apps/dispatch/public/sounds/connection_stoped_sepura.mp3 differ diff --git a/apps/dispatch/public/sounds/connection_stoped_sepura_old.mp3 b/apps/dispatch/public/sounds/connection_stoped_sepura_old.mp3 new file mode 100644 index 00000000..a31c9302 Binary files /dev/null and b/apps/dispatch/public/sounds/connection_stoped_sepura_old.mp3 differ diff --git a/apps/dispatch/public/sounds/newChat.mp3 b/apps/dispatch/public/sounds/newChat.mp3 new file mode 100644 index 00000000..4f61b6ec Binary files /dev/null and b/apps/dispatch/public/sounds/newChat.mp3 differ diff --git a/apps/hub/app/(app)/_components/ChangelogWrapper.tsx b/apps/hub/app/(app)/_components/ChangelogWrapper.tsx new file mode 100644 index 00000000..8b788772 --- /dev/null +++ b/apps/hub/app/(app)/_components/ChangelogWrapper.tsx @@ -0,0 +1,26 @@ +"use client"; +import { updateUser } from "(app)/settings/actions"; +import { Changelog } from "@repo/db"; +import { ChangelogModalBtn } from "@repo/shared-components"; +import { useSession } from "next-auth/react"; +import toast from "react-hot-toast"; + +export const ChangelogWrapper = ({ latestChangelog }: { latestChangelog: Changelog | null }) => { + const { data: session } = useSession(); + const autoOpen = !session?.user.changelogAck && !!latestChangelog; + + if (!latestChangelog) return null; + + return ( + { + await updateUser({ changelogAck: true }); + if (!session?.user.changelogAck) { + toast.success("Changelog als gelesen markiert"); + } + }} + /> + ); +}; diff --git a/apps/hub/app/(app)/_components/Footer.tsx b/apps/hub/app/(app)/_components/Footer.tsx index 72c56ff6..1c2a03db 100644 --- a/apps/hub/app/(app)/_components/Footer.tsx +++ b/apps/hub/app/(app)/_components/Footer.tsx @@ -2,10 +2,25 @@ import Image from "next/image"; import { DiscordLogoIcon, InstagramLogoIcon, ReaderIcon } from "@radix-ui/react-icons"; import YoutubeSvg from "./youtube_wider.svg"; import FacebookSvg from "./facebook.svg"; +import { ChangelogModalBtn } from "@repo/shared-components"; +import { getServerSession } from "api/auth/[...nextauth]/auth"; +import { updateUser } from "(app)/settings/actions"; +import toast from "react-hot-toast"; +import { ChangelogWrapper } from "(app)/_components/ChangelogWrapper"; +import { prisma } from "@repo/db"; + +export const Footer = async () => { + const session = await getServerSession(); + const latestChangelog = await prisma.changelog.findFirst({ + orderBy: { + createdAt: "desc", + }, + }); + + const autoOpen = !session?.user.changelogAck && !!latestChangelog; -export const Footer = () => { return ( -
@@ -39,7 +55,7 @@ export const Footer = () => { rel="noopener noreferrer" className="hover:text-primary text-white" > - +
@@ -50,7 +66,7 @@ export const Footer = () => { rel="noopener noreferrer" className="hover:text-primary text-white" > - +
@@ -61,12 +77,12 @@ export const Footer = () => { rel="noopener noreferrer" className="hover:text-primary" > - +
diff --git a/apps/hub/app/(app)/admin/changelog/[id]/page.tsx b/apps/hub/app/(app)/admin/changelog/[id]/page.tsx new file mode 100644 index 00000000..37af8b29 --- /dev/null +++ b/apps/hub/app/(app)/admin/changelog/[id]/page.tsx @@ -0,0 +1,13 @@ +import { prisma } from "@repo/db"; +import { ChangelogForm } from "../_components/Form"; + +export default async function Page({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const changelog = await prisma.changelog.findUnique({ + where: { + id: parseInt(id), + }, + }); + if (!changelog) return
Changelog not found
; + return ; +} diff --git a/apps/hub/app/(app)/admin/changelog/_components/Form.tsx b/apps/hub/app/(app)/admin/changelog/_components/Form.tsx new file mode 100644 index 00000000..de9abaf2 --- /dev/null +++ b/apps/hub/app/(app)/admin/changelog/_components/Form.tsx @@ -0,0 +1,156 @@ +"use client"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { ChangelogOptionalDefaultsSchema } from "@repo/db/zod"; +import { useForm } from "react-hook-form"; +import { Changelog } from "@repo/db"; +import { FileText } from "lucide-react"; +import { Input } from "../../../../_components/ui/Input"; +import { useEffect, useState } from "react"; +import { deleteChangelog, upsertChangelog } from "../action"; +import { Button } from "../../../../_components/ui/Button"; +import { redirect } from "next/navigation"; +import dynamic from "next/dynamic"; +import toast from "react-hot-toast"; + +const MarkdownEditor = dynamic(() => import("@uiw/react-md-editor"), { ssr: false }); + +export const ChangelogForm = ({ changelog }: { changelog?: Changelog }) => { + const form = useForm({ + resolver: zodResolver(ChangelogOptionalDefaultsSchema), + defaultValues: { + id: changelog?.id || undefined, + title: changelog?.title || "", + text: changelog?.text || "", + previewImage: changelog?.previewImage || "", // Changed to accept a URL as a string + }, + }); + const [markdownText, setMarkdownText] = useState(changelog?.text || ""); + const [imageError, setImageError] = useState(false); + const [showImage, setShowImage] = useState(false); + + const isValidImageUrl = (url: string) => { + if (!url) return false; + try { + const sanitizedUrl = url.trim(); // Remove leading/trailing spaces + const parsedUrl = new URL(sanitizedUrl, window.location.origin); + return parsedUrl.protocol === "http:" || parsedUrl.protocol === "https:"; + } catch (error) { + console.error("Invalid URL provided:", url, error); + return false; + } + }; + + const previewImage = form.watch("previewImage") || ""; + + useEffect(() => { + if (!isValidImageUrl(previewImage)) { + setImageError(true); + setShowImage(false); // Ensure image is hidden if URL is invalid + } else { + setImageError(false); // Reset error when previewImage changes and is valid + } + }, [previewImage]); + + return ( + <> +
{ + await upsertChangelog( + { + ...values, + text: markdownText, + }, + changelog?.id, + ); + toast.success("Daten gespeichert"); + if (!changelog) redirect(`/admin/changelog`); + })} + className="grid grid-cols-6 gap-3" + > +
+
+

+ Allgemeines +

+ + setShowImage(false)} + /> + + {(() => { + if (showImage && isValidImageUrl(previewImage) && !imageError) { + return ( + Preview { + setImageError(true); + console.error("Failed to load image at URL:", previewImage); + }} + /> + ); + } else if (imageError && showImage) { + return

Bild konnte nicht geladen werden

; + } + })()} +
+
+ +
+
+

Beschreibung

+ setMarkdownText(value || "")} + className="min-h-96 w-full" // Increased height to make the editor bigger + /> +
+
+ +
+
+
+ + {changelog && ( + + )} +
+
+
+ + + ); +}; diff --git a/apps/hub/app/(app)/admin/changelog/action.ts b/apps/hub/app/(app)/admin/changelog/action.ts new file mode 100644 index 00000000..4012f94f --- /dev/null +++ b/apps/hub/app/(app)/admin/changelog/action.ts @@ -0,0 +1,27 @@ +"use server"; + +import { prisma, Prisma, Changelog } from "@repo/db"; +export const upsertChangelog = async ( + changelog: Prisma.ChangelogCreateInput, + id?: Changelog["id"], +) => { + const newChangelog = id + ? await prisma.changelog.update({ + where: { id: id }, + data: changelog, + }) + : await prisma.$transaction(async (prisma) => { + const createdChangelog = await prisma.changelog.create({ data: changelog }); + + await prisma.user.updateMany({ + data: { changelogAck: false }, + }); + + return createdChangelog; + }); + return newChangelog; +}; + +export const deleteChangelog = async (id: Changelog["id"]) => { + await prisma.changelog.delete({ where: { id: id } }); +}; diff --git a/apps/hub/app/(app)/admin/changelog/layout.tsx b/apps/hub/app/(app)/admin/changelog/layout.tsx new file mode 100644 index 00000000..fc526d50 --- /dev/null +++ b/apps/hub/app/(app)/admin/changelog/layout.tsx @@ -0,0 +1,19 @@ +import { Error } from "_components/Error"; +import { getServerSession } from "api/auth/[...nextauth]/auth"; + +const AdminKeywordLayout = async ({ children }: { children: React.ReactNode }) => { + const session = await getServerSession(); + + if (!session) return ; + + const user = session.user; + + if (!user?.permissions.includes("ADMIN_CHANGELOG")) + return ; + + return <>{children}; +}; + +AdminKeywordLayout.displayName = "AdminKeywordLayout"; + +export default AdminKeywordLayout; diff --git a/apps/hub/app/(app)/admin/changelog/new/page.tsx b/apps/hub/app/(app)/admin/changelog/new/page.tsx new file mode 100644 index 00000000..48079d4f --- /dev/null +++ b/apps/hub/app/(app)/admin/changelog/new/page.tsx @@ -0,0 +1,5 @@ +import { ChangelogForm } from "../_components/Form"; + +export default () => { + return ; +}; diff --git a/apps/hub/app/(app)/admin/changelog/page.tsx b/apps/hub/app/(app)/admin/changelog/page.tsx new file mode 100644 index 00000000..b768e357 --- /dev/null +++ b/apps/hub/app/(app)/admin/changelog/page.tsx @@ -0,0 +1,49 @@ +"use client"; +import { DatabaseBackupIcon } from "lucide-react"; +import { PaginatedTable } from "../../../_components/PaginatedTable"; +import Link from "next/link"; +import { ColumnDef } from "@tanstack/react-table"; +import { Keyword } from "@repo/db"; + +export default () => { + return ( + <> + ( +
+ + + +
+ ), + }, + ] as ColumnDef[] + } + leftOfSearch={ + + Changelogs + + } + rightOfSearch={ +

+ + + +

+ } + /> + + ); +}; diff --git a/apps/hub/app/(app)/admin/event/_components/Form.tsx b/apps/hub/app/(app)/admin/event/_components/Form.tsx index f765118c..aba58bb9 100644 --- a/apps/hub/app/(app)/admin/event/_components/Form.tsx +++ b/apps/hub/app/(app)/admin/event/_components/Form.tsx @@ -253,7 +253,7 @@ export const Form = ({ event }: { event?: Event }) => { - Teilnehmer + Teilnehmer } searchFields={["User.firstname", "User.lastname", "User.publicId"]} diff --git a/apps/hub/app/(app)/admin/user/[id]/_components/forms.tsx b/apps/hub/app/(app)/admin/user/[id]/_components/forms.tsx index a3677f7c..209aaf8f 100644 --- a/apps/hub/app/(app)/admin/user/[id]/_components/forms.tsx +++ b/apps/hub/app/(app)/admin/user/[id]/_components/forms.tsx @@ -73,7 +73,10 @@ export const ProfileForm: React.FC = ({ user }: ProfileFormPro className="card-body" onSubmit={form.handleSubmit(async (values) => { if (!values.id) return; - await editUser(values.id, values); + await editUser(values.id, { + ...values, + email: values.email.toLowerCase(), + }); form.reset(values); toast.success("Deine Änderungen wurden gespeichert!", { style: { diff --git a/apps/hub/app/(app)/layout.tsx b/apps/hub/app/(app)/layout.tsx index 0383adc7..e38a592a 100644 --- a/apps/hub/app/(app)/layout.tsx +++ b/apps/hub/app/(app)/layout.tsx @@ -31,8 +31,8 @@ export default async function RootLayout({ >
{/* Card */} -
-
+
+
{/* Top Navbar */} @@ -42,7 +42,7 @@ export default async function RootLayout({ {/* Scrollbarer Content-Bereich */} -
+
{!session?.user.emailVerified && (
@@ -50,6 +50,7 @@ export default async function RootLayout({
)} {!session.user.pathSelected && } + {children}
diff --git a/apps/hub/app/(app)/settings/_components/forms.tsx b/apps/hub/app/(app)/settings/_components/forms.tsx index 789705ff..d378b84b 100644 --- a/apps/hub/app/(app)/settings/_components/forms.tsx +++ b/apps/hub/app/(app)/settings/_components/forms.tsx @@ -91,7 +91,10 @@ export const ProfileForm = ({ className="card-body" onSubmit={form.handleSubmit(async (values) => { setIsLoading(true); - await updateUser(values); + await updateUser({ + ...values, + email: values.email.toLowerCase(), + }); if (discordAccount) { await setStandardName({ memberId: discordAccount.discordId, diff --git a/apps/hub/app/(auth)/login/_components/Login.tsx b/apps/hub/app/(auth)/login/_components/Login.tsx index 7497b004..df02a2c2 100644 --- a/apps/hub/app/(auth)/login/_components/Login.tsx +++ b/apps/hub/app/(auth)/login/_components/Login.tsx @@ -33,7 +33,7 @@ export const Login = () => { try { const data = await signIn("credentials", { redirect: false, - email: form.getValues("email"), + email: form.getValues("email").toLowerCase(), password: form.getValues("password"), }); setIsLoading(false); @@ -62,12 +62,12 @@ export const Login = () => { Registrierung -
+
Du warst bereits Nutzer der V1?
Melde dich mit deinen alten Zugangsdaten an.
-
-
BOS Name
{station.Station.bosCallsignShort}{aircraft.Station.bosCallsignShort} - {station.fmsStatus} + {aircraft.fmsStatus} + + {aircraft.posLng || !aircraft.posLat ? ( + <>{findLeitstelleForPosition(aircraft.posLng!, aircraft.posLat!)} + ) : ( + aircraft.Station.bosRadioArea + )} {station.Station.bosRadioArea}