Maerge Commits from release to staging #139

Merged
PxlLoewe merged 25 commits from release into staging 2025-11-08 09:40:40 +00:00
74 changed files with 1279 additions and 236 deletions
Showing only changes of commit 4f22d48e83 - Show all commits

View File

@@ -83,6 +83,7 @@ router.patch("/:id", async (req, res) => {
data: {
stationId: updatedConnectedAircraft.stationId,
aircraftId: updatedConnectedAircraft.id,
userId: updatedConnectedAircraft.userId,
},
} as NotificationPayload);
}

View File

@@ -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,
},
});

View File

@@ -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 (
<div className="navbar bg-base-100 shadow-sm flex gap-5 justify-between">
<div className="navbar bg-base-100 flex justify-between gap-5 shadow-sm">
<div className="flex items-center gap-2">
<p className="normal-case text-xl font-semibold">VAR Leitstelle V2</p>
<div>
<p className="text-xl font-semibold normal-case">VAR Leitstelle</p>
<ChangelogWrapper latestChangelog={latestChangelog} />
</div>
{session?.user.permissions.includes("ADMIN_KICK") && <AdminPanel />}
</div>
<WarningAlert />
@@ -38,12 +48,12 @@ export default async function Navbar() {
rel="noopener noreferrer"
>
<button className="btn btn-ghost">
<ExternalLinkIcon className="w-4 h-4" /> HUB
<ExternalLinkIcon className="h-4 w-4" /> HUB
</button>
</Link>
<Link href={"/logout"}>
<button className="btn btn-ghost">
<ExitIcon className="w-4 h-4" />
<ExitIcon className="h-4 w-4" />
</button>
</Link>
</div>

View File

@@ -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<HTMLDialogElement>(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 && <Ghost />}
</button>
) : (
<button
@@ -89,6 +91,21 @@ export const ConnectionBtn = () => {
<p className="fieldset-label">Du kannst diese Zeit später noch anpassen.</p>
)}
</fieldset>
{session.data?.user.permissions.includes("ADMIN_KICK") &&
connection.status === "disconnected" && (
<fieldset className="fieldset bg-base-100 border-base-300 rounded-box w-full border p-4">
<legend className="fieldset-legend">Ghost-Mode</legend>
<label className="label">
<input
checked={form.ghostMode}
onChange={(e) => setForm({ ...form, ghostMode: e.target.checked })}
type="checkbox"
className="checkbox"
/>
Vesteckt deine Verbindung auf dem Tracker
</label>
</fieldset>
)}
<div className="modal-action flex w-full justify-between">
<form method="dialog" className="flex w-full justify-between">
<button className="btn btn-soft">Zurück</button>
@@ -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"

View File

@@ -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<MediaDeviceInfo[]>([]);
const { data: user } = useQuery({
queryKey: ["user", session.data?.user.id],
queryFn: () => getUserAPI(session.data!.user.id),
});
const testSoundRef = useRef<HTMLAudioElement | null>(null);
const editUserMutation = useMutation({
mutationFn: editUserAPI,
});
useEffect(() => {
if (typeof window !== "undefined") {
testSoundRef.current = new Audio("/sounds/DME-new-mission.wav");
}
}, []);
const modalRef = useRef<HTMLDialogElement>(null);
const [showIndication, setShowIndication] = useState<boolean>(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<typeof settings>) => {
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 (
<div>
<button
className="btn btn-ghost"
onSubmit={() => false}
onClick={() => {
modalRef.current?.showModal();
}}
>
<GearIcon className="h-5 w-5" />
</button>
<dialog ref={modalRef} className="modal">
<div className="modal-box">
<h3 className="mb-5 flex items-center gap-2 text-lg font-bold">
<SettingsIcon size={20} /> Einstellungen
</h3>
<div className="flex flex-col items-center justify-center">
<fieldset className="fieldset mb-2 w-full">
<label className="floating-label w-full text-base">
<span>Eingabegerät</span>
<select
className="input w-full"
value={settings.micDeviceId ? settings.micDeviceId : ""}
onChange={(e) => {
setSettingsPartial({ micDeviceId: e.target.value });
setShowIndication(true);
}}
>
<option key={0} value={0} disabled>
Bitte wähle ein Eingabegerät...
</option>
{inputDevices.map((device, index) => (
<option key={index} value={device.deviceId}>
{device.label}
</option>
))}
</select>
</label>
</fieldset>
<p className="mb-2 flex w-full items-center justify-start gap-2 text-base">
<Volume2 size={20} /> Eingabelautstärke
</p>
<div className="w-full">
<input
type="range"
min={0}
max={3}
step={0.01}
onChange={(e) => {
const value = parseFloat(e.target.value);
setSettingsPartial({ micVolume: value });
setShowIndication(true);
}}
value={settings.micVolume}
className="range range-xs range-accent w-full"
/>
<div className="mt-2 flex justify-between px-2.5 text-xs">
<span>0%</span>
<span>25%</span>
<span>50%</span>
<span>75%</span>
<span>100%</span>
</div>
</div>
{showIndication && (
<MicVolumeBar
deviceId={settings.micDeviceId ? settings.micDeviceId : ""}
volumeInput={settings.micVolume}
/>
)}
<div className="divider w-full" />
</div>
<p className="mb-2 flex items-center gap-2 text-base">
<Volume2 size={20} /> Funk Lautstärke
</p>
<div className="mb-2 w-full">
<input
type="range"
min={0}
max={1}
step={0.01}
onChange={(e) => {
const value = parseFloat(e.target.value);
setSettingsPartial({ radioVolume: value });
}}
value={settings.radioVolume}
className="range range-xs range-primary w-full"
/>
<div className="mt-2 flex justify-between px-2.5 text-xs">
<span>0%</span>
<span>25%</span>
<span>50%</span>
<span>75%</span>
<span>100%</span>
</div>
</div>
<div className="flex w-full justify-center">
<div className="divider w-full">Disponenten Einstellungen</div>
</div>
<div className="flex w-full items-center gap-2">
<input
type="checkbox"
className="toggle"
checked={settings.autoCloseMapPopup}
onChange={(e) => {
setSettingsPartial({ autoCloseMapPopup: e.target.checked });
}}
/>
Popups automatisch schließen
</div>
<div className="modal-action flex justify-between">
<button
className="btn btn-soft"
type="submit"
onSubmit={() => false}
onClick={() => {
modalRef.current?.close();
testSoundRef.current?.pause();
}}
>
Schließen
</button>
<button
className="btn btn-soft btn-success"
type="submit"
onSubmit={() => false}
onClick={async () => {
testSoundRef.current?.pause();
await editUserMutation.mutateAsync({
id: session.data!.user.id,
user: {
settingsMicDevice: settings.micDeviceId,
settingsMicVolume: settings.micVolume,
settingsRadioVolume: settings.radioVolume,
settingsAutoCloseMapPopup: settings.autoCloseMapPopup,
},
});
setAudioSettings({
micDeviceId: settings.micDeviceId,
micVolume: settings.micVolume,
radioVolume: settings.radioVolume,
});
setUserSettings({
settingsAutoCloseMapPopup: settings.autoCloseMapPopup,
});
modalRef.current?.close();
toast.success("Einstellungen gespeichert");
}}
>
Speichern
</button>
</div>
</div>
</dialog>
</div>
);
};
export const Settings = () => {
return (
<div>
<SettingsBtn />
</div>
);
};

View File

@@ -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");
}
}, []);

View File

@@ -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 (
<div className="navbar bg-base-100 shadow-sm flex gap-5 justify-between">
<div className="navbar bg-base-100 flex justify-between gap-5 shadow-sm">
<div className="flex items-center gap-2">
<p className="normal-case text-xl font-semibold">VAR Operations Center</p>
<div>
<p className="text-xl font-semibold normal-case">VAR Operations Center</p>
<ChangelogWrapper latestChangelog={latestChangelog} />
</div>
</div>
<WarningAlert />
<div className="flex items-center gap-5">
@@ -33,12 +43,12 @@ export default function Navbar() {
rel="noopener noreferrer"
>
<button className="btn btn-ghost">
<ExternalLinkIcon className="w-4 h-4" /> HUB
<ExternalLinkIcon className="h-4 w-4" /> HUB
</button>
</Link>
<Link href={"/logout"}>
<button className="btn btn-ghost">
<ExitIcon className="w-4 h-4" />
<ExitIcon className="h-4 w-4" />
</button>
</Link>
</div>

View File

@@ -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");
}
}, []);

View File

@@ -33,20 +33,20 @@ const PilotPage = () => {
<div className="ease relative flex h-screen w-full flex-1 overflow-hidden transition-all duration-500">
{/* <MapToastCard2 /> */}
<div className="relative flex h-full w-full flex-1">
<div className="z-999999 absolute left-0 top-1/2 flex -translate-y-1/2 transform flex-col space-y-2 pl-4">
<div className="absolute left-0 top-1/2 z-20 flex -translate-y-1/2 transform flex-col space-y-2 pl-4">
<Chat />
<Report />
<BugReport />
</div>
<div className="flex h-full w-2/3">
<div className="relative flex h-full flex-1">
<div className="top-19/20 z-999999 absolute left-0 -translate-y-1/2 transform pl-4">
<div className="top-19/20 absolute left-0 z-20 -translate-y-1/2 transform pl-4">
<div className="flex items-center justify-between gap-4">
<SettingsBoard />
</div>
</div>
<Map />
<div className="z-99999 absolute right-10 top-5 space-y-2">
<div className="absolute right-10 top-5 z-20 space-y-2">
{!simulatorConnected && status === "connected" && (
<SimConnectionAlert lastUpdated={ownAircraft?.lastHeartbeat} />
)}

View File

@@ -139,10 +139,10 @@ export const SmartPopup = (
<Popup {...props} className={cn("relative", wrapperClassName)}>
<div
className={cn(
"pointer-events-auto bg-base-100 relative",
"bg-base-100 pointer-events-auto relative",
anchor.includes("right") && "-translate-x-full",
anchor.includes("bottom") && "-translate-y-full",
!showContent && "opacity-0 pointer-events-none",
!showContent && "pointer-events-none opacity-0",
className,
)}
>
@@ -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",
)}

View File

@@ -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 (
<BaseNotification icon={<Cross />} className="flex flex-row">
<div className="flex-1">
<h1 className="text-red-500 font-bold">HPG validierung fehlgeschlagen</h1>
<h1 className="font-bold text-red-500">HPG validierung fehlgeschlagen</h1>
<p>{event.message}</p>
</div>
<div className="ml-11">
@@ -43,7 +52,7 @@ export const HPGnotificationToast = ({
return (
<BaseNotification icon={<Check />} className="flex flex-row">
<div className="flex-1">
<h1 className="text-green-600 font-bold">HPG validierung erfolgreich</h1>
<h1 className="font-bold text-green-600">HPG validierung erfolgreich</h1>
<p className="text-sm">{event.message}</p>
</div>
<div className="ml-11">

View File

@@ -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);
}}
>

View File

@@ -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<HTMLAudioElement | null>(null);
const status9Sounds = useRef<HTMLAudioElement | null>(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 (
<BaseNotification>
<div className="flex flex-row gap-14 items-center">
<div className="flex flex-row items-center gap-14">
<p>
<span
className="underline mr-1 cursor-pointer font-bold"
className="mr-1 cursor-pointer font-bold underline"
onClick={() => {
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 }) =>
</span>
sendet Status {event.status}
</p>
<div className="flex gap-2 items-center">
<div className="flex items-center gap-2">
{QUICK_RESPONSE[String(event.status)]?.map((status) => (
<button
key={status}
className={
"flex justify-center items-center min-w-10 min-h-10 cursor-pointer text-lg font-bold"
"flex min-h-10 min-w-10 cursor-pointer items-center justify-center text-lg font-bold"
}
style={{
backgroundColor: FMS_STATUS_COLORS[status],

View File

@@ -2,7 +2,7 @@
import { ChatBubbleIcon, PaperPlaneIcon } from "@radix-ui/react-icons";
import { useLeftMenuStore } from "_store/leftMenuStore";
import { useSession } from "next-auth/react";
import { Fragment, useEffect, useState } from "react";
import { Fragment, useEffect, useState, useRef } from "react";
import { cn } from "@repo/shared-components";
import { asPublicUser } from "@repo/db";
import { useQuery } from "@tanstack/react-query";
@@ -30,6 +30,8 @@ export const Chat = () => {
const [message, setMessage] = useState<string>("");
const dispatcherConnected = useDispatchConnectionStore((state) => state.status === "connected");
const pilotConnected = usePilotConnectionStore((state) => state.status === "connected");
const [someChat, setSomeChat] = useState(false);
const audioRef = useRef<HTMLAudioElement | null>(null);
const { data: dispatcher } = useQuery({
queryKey: ["dispatcher"],
@@ -61,16 +63,27 @@ export const Chat = () => {
}
}, [btnActive, setChatOpen]);
useEffect(() => {
if (Object.values(chats).some((c) => c.notification)) {
setSomeChat(true);
if (audioRef.current) {
audioRef.current.volume = 0.5;
audioRef.current.play().catch(() => {});
}
} else {
setSomeChat(false);
}
}, [chats]);
return (
<div className={cn("dropdown dropdown-right dropdown-center", chatOpen && "dropdown-open")}>
<audio ref={audioRef} src="/sounds/newChat.mp3" preload="auto" />
<div className="indicator">
{Object.values(chats).some((c) => c.notification) && (
<span className="indicator-item status status-info animate-ping"></span>
)}
<button
className={cn(
"btn btn-soft btn-sm cursor-default",
btnActive && "btn-primary cursor-pointer",
someChat && "border-warning animate-pulse",
)}
onClick={() => {
if (!btnActive) return;
@@ -81,23 +94,23 @@ export const Chat = () => {
}
}}
>
<ChatBubbleIcon className="w-4 h-4" />
<ChatBubbleIcon className="h-4 w-4" />
</button>
</div>
{chatOpen && (
<div
tabIndex={0}
className="dropdown-content card bg-base-200 w-150 shadow-md z-[1100] max-h-[480px] ml-2 border-1 border-primary"
className="dropdown-content card bg-base-200 w-150 border-1 border-primary z-[1100] ml-2 max-h-[480px] shadow-md"
>
<div className="card-body relative">
<button
className="absolute top-2 right-2 btn btn-xs btn-circle btn-ghost"
className="btn btn-xs btn-circle btn-ghost absolute right-2 top-2"
onClick={() => setChatOpen(false)}
type="button"
>
<span className="text-xl leading-none">&times;</span>
</button>
<h2 className="inline-flex items-center gap-2 text-lg font-bold mb-2">
<h2 className="mb-2 inline-flex items-center gap-2 text-lg font-bold">
<ChatBubbleIcon /> Chat
</h2>
<div className="join">
@@ -164,7 +177,7 @@ export const Chat = () => {
{chat.name}
{chat.notification && <span className="indicator-item status status-info" />}
</a>
<div className="tab-content bg-base-100 border-base-300 p-6 overflow-y-auto max-h-[250px]">
<div className="tab-content bg-base-100 border-base-300 max-h-[250px] overflow-y-auto p-6">
{/* So macht man kein overflow handeling, weiß ich. Aber es funktioniert... */}
{chat.messages.map((chatMessage) => {
const isSender = chatMessage.senderId === session.data?.user.id;

View File

@@ -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 (
<div className={cn("dropdown dropdown-top", situationTabOpen && "dropdown-open")}>
@@ -64,17 +72,17 @@ export const SituationBoard = () => {
setSituationTabOpen(!situationTabOpen);
}}
>
<ListCollapse className="w-4 h-4" />
<ListCollapse className="h-4 w-4" />
</button>
</div>
{situationTabOpen && (
<div
tabIndex={0}
className="dropdown-content card bg-base-200 shadow-md z-[1100] ml-2 border-1 border-info min-w-[900px] max-h-[300px]"
className="dropdown-content card bg-base-200 border-1 border-info z-[1100] ml-2 max-h-[300px] min-w-[900px] shadow-md"
>
<div className="card-body flex flex-row gap-4">
<div className="flex-1">
<h2 className="inline-flex items-center gap-2 text-lg font-bold mb-2">
<h2 className="mb-2 inline-flex items-center gap-2 text-lg font-bold">
<ListCollapse /> Einsatzliste{" "}
</h2>
<div>
@@ -90,8 +98,8 @@ export const SituationBoard = () => {
</label>
</div>
</div>
<div className="overflow-x-auto overflow-y-auto max-h-[170px] select-none">
<table className="table table-xs">
<div className="max-h-[170px] select-none overflow-x-auto overflow-y-auto">
<table className="table-xs table">
{/* head */}
<thead>
<tr>
@@ -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 = () => {
</table>
</div>
</div>
<div className="w-px bg-gray-400 mx-2" />
<div className="mx-2 w-px bg-gray-400" />
<div className="flex-1">
<h2 className="inline-flex items-center gap-2 text-lg font-bold mb-2">
<h2 className="mb-2 inline-flex items-center gap-2 text-lg font-bold">
<Plane /> Stationen
</h2>
<div className="overflow-x-auto overflow-y-auto max-h-[200px] select-none">
<table className="table table-xs">
<div className="max-h-[200px] select-none overflow-x-auto overflow-y-auto">
<table className="table-xs table">
<thead>
<tr>
<th>BOS Name</th>
@@ -160,41 +180,59 @@ export const SituationBoard = () => {
</tr>
</thead>
<tbody>
{connectedAircrafts?.map((station) => (
{connectedAircrafts?.map((aircraft) => (
<tr
className="cursor-pointer"
key={station.id}
key={aircraft.id}
onDoubleClick={() => {
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,
});
}}
>
<td>{station.Station.bosCallsignShort}</td>
<td>{aircraft.Station.bosCallsignShort}</td>
<td
className="text-center font-lg font-semibold"
className="font-lg text-center font-semibold"
style={{
color: FMS_STATUS_TEXT_COLORS[station.fmsStatus],
backgroundColor: FMS_STATUS_COLORS[station.fmsStatus],
color: FMS_STATUS_TEXT_COLORS[aircraft.fmsStatus],
backgroundColor: FMS_STATUS_COLORS[aircraft.fmsStatus],
}}
>
{station.fmsStatus}
{aircraft.fmsStatus}
</td>
<td className="whitespace-nowrap">
{aircraft.posLng || !aircraft.posLat ? (
<>{findLeitstelleForPosition(aircraft.posLng!, aircraft.posLat!)}</>
) : (
aircraft.Station.bosRadioArea
)}
</td>
<td className="whitespace-nowrap">{station.Station.bosRadioArea}</td>
</tr>
))}
</tbody>

View File

@@ -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}
</div>
<div
className="min-w-[130px] cursor-pointer px-2"
className="cursor-pointer px-2"
style={{
backgroundColor: `${FMS_STATUS_COLORS[aircraft.fmsStatus]}`,
borderBottom:
@@ -179,10 +180,11 @@ const AircraftPopupContent = ({
{aircraft.Station.bosUse === "DUAL_USE" && "(dual use)"}
{aircraft.Station.bosUse === "PRIMARY" && "(primär)"}
{aircraft.Station.bosUse === "SECONDARY" && "(sekundär)"}
{aircraft.Station.bosUse === "SPECIAL_OPS" && "(Special OPS)"}
</span>
</div>
<div
className="w-150 cursor-pointer px-2"
className="flex-1 cursor-pointer overflow-x-hidden px-2"
style={{
backgroundColor: `${FMS_STATUS_COLORS[aircraft.fmsStatus]}`,
borderBottom:
@@ -195,9 +197,10 @@ const AircraftPopupContent = ({
>
<span className="text-base font-medium text-white">Einsatz</span>
<br />
<span className="text-sm font-medium text-white">
{mission?.publicId || "kein Einsatz"}
</span>
{!mission?.publicId && <span className="text-sm text-gray-400">Kein Einsatz</span>}
{mission?.publicId && (
<span className="text-sm font-medium text-white">{mission.publicId}</span>
)}
</div>
<div
className="flex cursor-pointer items-center justify-center px-4"
@@ -227,7 +230,7 @@ const AircraftMarker = ({ aircraft }: { aircraft: ConnectedAircraft & { Station:
const markerRef = useRef<LMarker>(null);
const popupRef = useRef<LPopup>(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]"
>
<div style={{ height: "auto", maxHeight: "90vh", overflowY: "auto" }}>
<AircraftPopupContent aircraft={aircraft} />

View File

@@ -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}
>
<div
className="absolute opacity-100 pointer-events-none p-3 flex items-center justify-center"
className="pointer-events-none absolute flex items-center justify-center p-3 opacity-100"
style={{ left: "-13px", top: "-13px" }}
>
<div className="relative w-38 h-38 flex items-center justify-center -translate-x-1/2 -translate-y-1/2 pointer-events-none">
<div className="w-38 h-38 pointer-events-none relative flex -translate-x-1/2 -translate-y-1/2 items-center justify-center">
{/* Top Button */}
<button
className="btn btn-circle bg-rescuetrack w-10 h-10 absolute left-1/2 top-0 pointer-events-auto opacity-80 tooltip tooltip-top tooltip-accent"
className="btn btn-circle bg-rescuetrack tooltip tooltip-top tooltip-accent pointer-events-auto absolute left-1/2 top-0 h-10 w-10 opacity-80"
data-tip={missionBtnText}
style={{ transform: "translateX(-50%)" }}
onClick={async () => {
@@ -131,17 +135,18 @@ export const ContextMenu = () => {
if (closestObject) {
toggleSearchElementSelection(closestObject.wayID, true);
}
map.setView([contextMenu.lat, contextMenu.lng], 18, {
animate: true,
});
if (isPannelOpen) {
map.setView([contextMenu.lat, contextMenu.lng], 18, {
animate: true,
});
}
}}
>
<MapPinned size={20} />
</button>
{/* Left Button */}
<button
className="btn btn-circle bg-rescuetrack w-10 h-10 absolute top-1/2 left-0 pointer-events-auto opacity-80"
className="btn btn-circle bg-rescuetrack pointer-events-auto absolute left-0 top-1/2 h-10 w-10 opacity-80"
style={{ transform: "translateY(-50%)" }}
onMouseEnter={() => setRulerHover(true)}
onMouseLeave={() => setRulerHover(false)}
@@ -151,7 +156,7 @@ export const ContextMenu = () => {
</button>
{/* Bottom Button */}
<button
className="btn btn-circle bg-rescuetrack w-10 h-10 absolute left-1/2 bottom-0 pointer-events-auto opacity-80 tooltip tooltip-bottom tooltip-accent"
className="btn btn-circle bg-rescuetrack tooltip tooltip-bottom tooltip-accent pointer-events-auto absolute bottom-0 left-1/2 h-10 w-10 opacity-80"
data-tip="Koordinaten kopieren"
style={{ transform: "translateX(-50%)" }}
onClick={async () => {
@@ -164,7 +169,7 @@ export const ContextMenu = () => {
</button>
{/* Right Button (original Search button) */}
<button
className="btn btn-circle bg-rescuetrack w-10 h-10 absolute top-1/2 right-0 pointer-events-auto opacity-80 tooltip tooltip-right tooltip-accent"
className="btn btn-circle bg-rescuetrack tooltip tooltip-right tooltip-accent pointer-events-auto absolute right-0 top-1/2 h-10 w-10 opacity-80"
data-tip="Gebäude suchen"
style={{ transform: "translateY(-50%)" }}
onClick={async () => {
@@ -176,7 +181,7 @@ export const ContextMenu = () => {
{/* Ruler Options - shown when Ruler button is hovered or options are hovered */}
{showRulerOptions && (
<div
className="absolute flex flex-col items-center pointer-events-auto"
className="pointer-events-auto absolute flex flex-col items-center"
style={{
left: "-100px", // position to the right of the left button
top: "50%",
@@ -200,7 +205,7 @@ export const ContextMenu = () => {
}}
>
<button
className="btn btn-circle bg-rescuetrack w-10 h-10 mb-2 opacity-80 tooltip tooltip-left tooltip-accent"
className="btn btn-circle bg-rescuetrack tooltip tooltip-left tooltip-accent mb-2 h-10 w-10 opacity-80"
data-tip="Strecke Messen"
style={{
transform: "translateX(100%)",
@@ -212,7 +217,7 @@ export const ContextMenu = () => {
<RulerDimensionLine size={20} />
</button>
<button
className="btn btn-circle bg-rescuetrack w-10 h-10 mb-2 opacity-80 tooltip tooltip-left tooltip-accent"
className="btn btn-circle bg-rescuetrack tooltip tooltip-left tooltip-accent mb-2 h-10 w-10 opacity-80"
data-tip="Radius Messen"
onClick={() => {
/* ... */
@@ -221,7 +226,7 @@ export const ContextMenu = () => {
<Radius size={20} />
</button>
<button
className="btn btn-circle bg-rescuetrack w-10 h-10 opacity-80 tooltip tooltip-left tooltip-accent"
className="btn btn-circle bg-rescuetrack tooltip tooltip-left tooltip-accent h-10 w-10 opacity-80"
data-tip="Fläche Messen"
style={{
transform: "translateX(100%)",

View File

@@ -37,7 +37,7 @@ const Map = () => {
return (
<MapContainer
ref={ref}
className="flex-1 bg-base-200"
className="bg-base-200 z-10 flex-1"
center={map.center}
zoom={map.zoom}
fadeAnimation={false}

View File

@@ -209,7 +209,15 @@ const MissionPopupContent = ({
);
};
const MissionMarker = ({ mission }: { mission: Mission }) => {
const MissionMarker = ({
mission,
options,
}: {
mission: Mission;
options: {
hideDetailedKeyword?: boolean;
};
}) => {
const map = useMap();
const [hideMarker, setHideMarker] = useState(false);
const { editingMissionId, missionFormValues } = usePannelStore((state) => state);
@@ -222,7 +230,7 @@ const MissionMarker = ({ mission }: { mission: Mission }) => {
refetchInterval: 10000,
});
const { openMissionMarker, setOpenMissionMarker } = useMapStore((store) => store);
const { openMissionMarker, setOpenMissionMarker, userSettings } = useMapStore((store) => store);
const needsAction =
HPGValidationRequired(mission.missionStationIds, aircrafts, mission.hpgMissionString) &&
@@ -245,7 +253,7 @@ const MissionMarker = ({ mission }: { mission: Mission }) => {
tab: "home",
},
],
close: [],
close: openMissionMarker?.map((m) => m.id) || [],
});
}
};
@@ -254,14 +262,16 @@ const MissionMarker = ({ mission }: { mission: Mission }) => {
return () => {
markerCopy?.off("click", handleClick);
};
}, [mission.id, openMissionMarker, setOpenMissionMarker]);
}, [mission.id, openMissionMarker, setOpenMissionMarker, userSettings]);
const [anchor, setAnchor] = useState<"topleft" | "topright" | "bottomleft" | "bottomright">(
"topleft",
);
const handleConflict = useCallback(() => {
const newAnchor = calculateAnchor(`mission-${mission.id.toString()}`, "marker");
const newAnchor = calculateAnchor(`mission-${mission.id.toString()}`, "marker", {
ignoreCluster: true,
});
setAnchor(newAnchor);
}, [mission.id]);
@@ -318,7 +328,7 @@ const MissionMarker = ({ mission }: { mission: Mission }) => {
"
></div>
<span class="text-white text-[15px] text-nowrap">
${mission.missionKeywordAbbreviation} ${mission.missionKeywordName}
${mission.missionKeywordAbbreviation} ${options.hideDetailedKeyword ? "" : mission.missionKeywordName}
</span>
<div
data-anchor-lat="${mission.addressLat}"
@@ -396,6 +406,11 @@ export const MissionLayer = () => {
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 <MissionMarker key={mission.id} mission={mission as Mission} />;
return (
<MissionMarker
key={mission.id}
mission={mission as Mission}
options={{
hideDetailedKeyword: missions.length + aircrafts.length > 10,
}}
/>
);
})}
</>
);

View File

@@ -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;

View File

@@ -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 (
<>
<div className="relative flex flex-col text-white min-w-[200px]">
<div className="relative flex min-w-fit flex-col text-white">
<div
className={cn(
"absolute w-[calc(100%+2px)] h-4 z-99 pointer-events-none",
"z-99 pointer-events-none absolute h-4 w-[calc(100%+2px)]",
anchor.includes("left") ? "-left-[2px]" : "-right-[2px]",
anchor.includes("top") ? "-top-[2px]" : "-bottom-[2px]",
)}
@@ -68,7 +74,7 @@ const PopupContent = ({
return (
<div
key={mission.id}
className={cn("relative inline-flex items-center gap-2 text-nowrap w-full")}
className={cn("relative inline-flex w-full items-center gap-2 text-nowrap")}
style={{
backgroundColor: markerColor,
cursor: "pointer",
@@ -77,15 +83,27 @@ const PopupContent = ({
<span
className="mx-2 my-0.5 flex-1 cursor-pointer"
onClick={() => {
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) => (
<div
key={aircraft.id}
className="relative w-auto inline-flex items-center gap-2 text-nowrap cursor-pointer"
className="relative inline-flex w-auto cursor-pointer items-center gap-2 text-nowrap px-2"
style={{
backgroundColor: FMS_STATUS_COLORS[aircraft.fmsStatus],
}}
onClick={() => {
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,
});
}}
>
<span
className="mx-2 my-0.5 text-gt font-bold"
className="text-gt my-0.5 font-bold"
style={{
color: FMS_STATUS_TEXT_COLORS[aircraft.fmsStatus],
}}
>
{aircraft.fmsStatus}
</span>
<span>{aircraft.Station.bosCallsign}</span>
<span>
{aircraft.Station.bosCallsign.length > 15
? aircraft.Station.locationStateShort
: aircraft.Station.bosCallsign}
</span>
</div>
))}
</div>
@@ -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"
>
<PopupContent aircrafts={c.aircrafts} missions={c.missions} />
</SmartPopup>

View File

@@ -92,6 +92,11 @@ export default function AdminPanel() {
const modalRef = useRef<HTMLDialogElement>(null);
console.debug("piloten von API", {
anzahl: pilots?.length,
pilots,
});
return (
<div>
<button
@@ -108,11 +113,11 @@ export default function AdminPanel() {
<form method="dialog">
<button className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
</form>
<h3 className="font-bold text-lg flex items-center gap-2">
<h3 className="flex items-center gap-2 text-lg font-bold">
<Shield size={22} /> Admin Panel
</h3>
<div className="flex gap-2 mt-4 w-full">
<div className="card bg-base-300 shadow-md w-full h-96 overflow-y-auto">
<div className="mt-4 flex w-full gap-2">
<div className="card bg-base-300 h-96 w-full overflow-y-auto shadow-md">
<div className="card-body">
<div className="card-title flex items-center gap-2">
<UserCheck size={20} /> Verbundene Clients

View File

@@ -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 (
<ChangelogModalBtn
hideIcon
className="text-sm text-gray-500"
latestChangelog={latestChangelog}
autoOpen={autoOpen}
onClose={async () => {
await editUserMutation.mutateAsync({ id: session?.user.id, user: { changelogAck: true } });
if (!session?.user.changelogAck) {
toast.success("Changelog als gelesen markiert");
}
}}
/>
);
};

View File

@@ -203,7 +203,7 @@ export const useAudioStore = create<TalkState>((set, get) => ({
set({ state: "connected", room, message: null });
})
.on(RoomEvent.Disconnected, () => {
set({ state: "disconnected" });
set({ state: "disconnected", speakingParticipants: [], transmitBlocked: false });
handleDisconnect();
})

View File

@@ -11,7 +11,13 @@ interface ConnectionStore {
message: string;
selectedZone: string;
logoffTime: string;
connect: (uid: string, selectedZone: string, logoffTime: string) => Promise<void>;
ghostMode: boolean;
connect: (
uid: string,
selectedZone: string,
logoffTime: string,
ghostMode: boolean,
) => Promise<void>;
disconnect: () => void;
}
@@ -23,11 +29,12 @@ export const useDispatchConnectionStore = create<ConnectionStore>((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<ConnectionStore>((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: "" });
});

View File

@@ -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<MapStore["userSettings"]>) => void;
}
export const useMapStore = create<MapStore>((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<MapStore>((set, get) => ({
},
})),
missionTabs: {},
userSettings: {
settingsAutoCloseMapPopup: false,
},
setUserSettings: (settings) =>
set((state) => ({
userSettings: {
...state.userSettings,
...settings,
},
})),
}));

View File

@@ -10,6 +10,7 @@ export async function GET(request: Request): Promise<NextResponse> {
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: {

View File

@@ -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 });
}

View File

@@ -23,14 +23,14 @@ export const ConnectedDispatcher = () => {
return (
<div className="min-w-120">
<div className="collapse collapse-arrow bg-base-100 border-base-300 border">
<div className="collapse-arrow bg-base-100 border-base-300 collapse border">
<input type="checkbox" />
{/* <div className="collapse-title font-semibold">Kein Disponent Online</div> */}
<div className="collapse-title font-semibold flex items-center justify-between">
<div className="collapse-title flex items-center justify-between font-semibold">
<span>
{connections} {connections == 1 ? "Verbundenes Mitglied" : "Verbundene Mitglieder"}
</span>
<div className="gap-2 flex items-center">
<div className="flex items-center gap-2">
<div
className={`badge badge-outline ${
(dispatcher?.length || 0) > 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 })}`}
>
<p className="text-gray-500 font-thin ">
<p className="font-thin text-gray-500">
{new Date(d.esimatedLogoutTime).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
@@ -76,7 +76,7 @@ export const ConnectedDispatcher = () => {
</div>
<div>
<div>{asPublicUser(d.publicUser).fullName}</div>
<div className="text-xs uppercase font-semibold opacity-60">{d.zone}</div>
<div className="text-xs font-semibold uppercase opacity-60">{d.zone}</div>
</div>
<div>
{(() => {

View File

@@ -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",

Binary file not shown.

View File

@@ -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 (
<ChangelogModalBtn
latestChangelog={latestChangelog}
autoOpen={autoOpen}
onClose={async () => {
await updateUser({ changelogAck: true });
if (!session?.user.changelogAck) {
toast.success("Changelog als gelesen markiert");
}
}}
/>
);
};

View File

@@ -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 (
<footer className="footer flex justify-between items-center p-4 bg-base-200 mt-4 rounded-lg shadow-md">
<footer className="footer bg-base-200 mt-4 flex items-center justify-between rounded-lg p-4 shadow-md">
{/* Left: Impressum & Datenschutz */}
<div className="flex gap-4 text-sm">
<a href="https://virtualairrescue.com/impressum/" className="hover:text-primary">
@@ -14,6 +29,7 @@ export const Footer = () => {
<a href="https://virtualairrescue.com/datenschutz/" className="hover:text-primary">
Datenschutzerklärung
</a>
<ChangelogWrapper latestChangelog={latestChangelog} />
</div>
{/* Center: Copyright */}
@@ -28,7 +44,7 @@ export const Footer = () => {
rel="noopener noreferrer"
className="hover:text-primary"
>
<DiscordLogoIcon className="w-5 h-5" />
<DiscordLogoIcon className="h-5 w-5" />
</a>
</div>
@@ -39,7 +55,7 @@ export const Footer = () => {
rel="noopener noreferrer"
className="hover:text-primary text-white"
>
<Image src={YoutubeSvg} className="invert w-5 h5" alt="Youtube Icon" />
<Image src={YoutubeSvg} className="h5 w-5 invert" alt="Youtube Icon" />
</a>
</div>
@@ -50,7 +66,7 @@ export const Footer = () => {
rel="noopener noreferrer"
className="hover:text-primary text-white"
>
<Image src={FacebookSvg} className="invert w-5 h5" alt="Youtube Icon" />
<Image src={FacebookSvg} className="h5 w-5 invert" alt="Youtube Icon" />
</a>
</div>
@@ -61,12 +77,12 @@ export const Footer = () => {
rel="noopener noreferrer"
className="hover:text-primary"
>
<InstagramLogoIcon className="w-5 h-5" />
<InstagramLogoIcon className="h-5 w-5" />
</a>
</div>
<div className="tooltip tooltip-top" data-tip="Knowledgebase">
<a href="https://docs.virtualairrescue.com/" className="hover:text-primary">
<ReaderIcon className="w-5 h-5" />
<ReaderIcon className="h-5 w-5" />
</a>
</div>
</div>

View File

@@ -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 <div>Changelog not found</div>;
return <ChangelogForm changelog={changelog} />;
}

View File

@@ -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 (
<>
<form
onSubmit={form.handleSubmit(async (values) => {
await upsertChangelog(
{
...values,
text: markdownText,
},
changelog?.id,
);
toast.success("Daten gespeichert");
if (!changelog) redirect(`/admin/changelog`);
})}
className="grid grid-cols-6 gap-3"
>
<div className="card bg-base-200 col-span-6 shadow-xl">
<div className="card-body">
<h2 className="card-title">
<FileText className="h-5 w-5" /> Allgemeines
</h2>
<Input
form={form}
label="Titel"
name="title"
placeholder="Titel (vX.X.X)"
className="input-sm"
/>
<Input
form={form}
label="Bild-URL"
name="previewImage"
className="input-sm"
onChange={() => setShowImage(false)}
/>
<Button
onClick={() => setShowImage(true)}
type="button"
className="btn btn-primary btn-outline mt-2"
>
Bildvorschau anzeigen
</Button>
{(() => {
if (showImage && isValidImageUrl(previewImage) && !imageError) {
return (
<img
src={previewImage}
alt="Preview"
width={200}
height={200}
className="mt-4 max-h-48"
onError={() => {
setImageError(true);
console.error("Failed to load image at URL:", previewImage);
}}
/>
);
} else if (imageError && showImage) {
return <p className="text-error">Bild konnte nicht geladen werden</p>;
}
})()}
</div>
</div>
<div className="card bg-base-200 col-span-6 shadow-xl">
<div className="card-body">
<h2 className="card-title">Beschreibung</h2>
<MarkdownEditor
value={markdownText}
onChange={(value) => setMarkdownText(value || "")}
className="min-h-96 w-full" // Increased height to make the editor bigger
/>
</div>
</div>
<div className="card bg-base-200 col-span-6 shadow-xl">
<div className="card-body">
<div className="flex w-full gap-4">
<Button
isLoading={form.formState.isSubmitting}
type="submit"
className="btn btn-primary flex-1"
>
Speichern
</Button>
{changelog && (
<Button
onClick={async () => {
await deleteChangelog(changelog.id);
redirect("/admin/changelog");
}}
className="btn btn-error"
>
Löschen
</Button>
)}
</div>
</div>
</div>
</form>
</>
);
};

View File

@@ -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 } });
};

View File

@@ -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 <Error title="Nicht eingeloggt" statusCode={401} />;
const user = session.user;
if (!user?.permissions.includes("ADMIN_CHANGELOG"))
return <Error title="Keine Berechtigung" statusCode={403} />;
return <>{children}</>;
};
AdminKeywordLayout.displayName = "AdminKeywordLayout";
export default AdminKeywordLayout;

View File

@@ -0,0 +1,5 @@
import { ChangelogForm } from "../_components/Form";
export default () => {
return <ChangelogForm />;
};

View File

@@ -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 (
<>
<PaginatedTable
stickyHeaders
initialOrderBy={[{ id: "title", desc: true }]}
prismaModel="changelog"
searchFields={["title"]}
columns={
[
{
header: "Title",
accessorKey: "title",
},
{
header: "Aktionen",
cell: ({ row }) => (
<div className="flex items-center gap-1">
<Link href={`/admin/changelog/${row.original.id}`}>
<button className="btn btn-sm">bearbeiten</button>
</Link>
</div>
),
},
] as ColumnDef<Keyword>[]
}
leftOfSearch={
<span className="flex items-center gap-2">
<DatabaseBackupIcon className="h-5 w-5" /> Changelogs
</span>
}
rightOfSearch={
<p className="flex items-center justify-between gap-2 text-left text-2xl font-semibold">
<Link href={"/admin/changelog/new"}>
<button className="btn btn-sm btn-outline btn-primary">Erstellen</button>
</Link>
</p>
}
/>
</>
);
};

View File

@@ -253,7 +253,7 @@ export const Form = ({ event }: { event?: Event }) => {
<PaginatedTable
leftOfSearch={
<h2 className="card-title">
<Calendar className="h-5 w-5" /> Teilnehmer
<UserIcon className="h-5 w-5" /> Teilnehmer
</h2>
}
searchFields={["User.firstname", "User.lastname", "User.publicId"]}

View File

@@ -73,7 +73,10 @@ export const ProfileForm: React.FC<ProfileFormProps> = ({ 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: {

View File

@@ -31,8 +31,8 @@ export default async function RootLayout({
>
<div className="hero-overlay bg-opacity-30"></div>
{/* Card */}
<div className="hero-content text-neutral-content text-center w-full max-w-full h-full m-10">
<div className="card bg-base-100 shadow-2xl w-full min-h-full h-full max-h-[calc(100vh-13rem)] p-4 flex flex-col mr-24 ml-24">
<div className="hero-content text-neutral-content m-10 h-full w-full max-w-full text-center">
<div className="card bg-base-100 ml-24 mr-24 flex h-full max-h-[calc(100vh-13rem)] min-h-full w-full flex-col p-4 shadow-2xl">
{/* Top Navbar */}
<HorizontalNav />
@@ -42,7 +42,7 @@ export default async function RootLayout({
<VerticalNav />
{/* Scrollbarer Content-Bereich */}
<div className="flex-grow bg-base-100 px-6 rounded-lg shadow-md ml-4 overflow-auto h-full max-w-full w-full">
<div className="bg-base-100 ml-4 h-full w-full max-w-full flex-grow overflow-auto rounded-lg px-6 shadow-md">
<Penalty />
{!session?.user.emailVerified && (
<div className="mb-4">
@@ -50,6 +50,7 @@ export default async function RootLayout({
</div>
)}
{!session.user.pathSelected && <FirstPath />}
{children}
</div>
</div>

View File

@@ -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,

View File

@@ -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
</Link>
</span>
<div className="alert alert-info alert-outline text-sm font-semibold text-center justify-center">
<div className="alert alert-info alert-outline justify-center text-center text-sm font-semibold">
Du warst bereits Nutzer der V1? <br />
Melde dich mit deinen alten Zugangsdaten an.
</div>
<div className="mt-5 mb-2">
<label className="input input-bordered flex items-center gap-2 w-full">
<div className="mb-2 mt-5">
<label className="input input-bordered flex w-full items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
@@ -84,7 +84,7 @@ export const Login = () => {
? form.formState.errors.email.message
: ""}
</p>
<label className="input input-bordered flex items-center gap-2 mt-2 w-full">
<label className="input input-bordered mt-2 flex w-full items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
@@ -105,8 +105,8 @@ export const Login = () => {
className="grow"
/>
</label>
<span className="text-sm font-medium flex justify-end">
<Link href="/passwort-reset" className="link link-accent link-hover ">
<span className="flex justify-end text-sm font-medium">
<Link href="/passwort-reset" className="link link-accent link-hover">
Passwort vergessen?
</Link>
</span>

View File

@@ -29,7 +29,7 @@ export const PasswortReset = () => {
onSubmit={form.handleSubmit(async () => {
try {
setIsLoading(true);
const { error } = await resetPassword(form.getValues().email);
const { error } = await resetPassword(form.getValues().email.toLowerCase());
setIsLoading(false);
if (error) {
@@ -56,7 +56,7 @@ export const PasswortReset = () => {
<Toaster position="top-center" reverseOrder={false} />
</div>
<h1 className="text-3xl font-bold">Passwort zurücksetzen</h1>
<label className="input input-bordered flex items-center gap-2 w-full">
<label className="input input-bordered flex w-full items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
@@ -73,8 +73,8 @@ export const PasswortReset = () => {
? form.formState.errors.email.message
: ""}
</p>
<span className="text-sm font-medium flex justify-end">
<Link href="/login" className="link link-accent link-hover ">
<span className="flex justify-end text-sm font-medium">
<Link href="/login" className="link link-accent link-hover">
zum Login
</Link>
</span>

View File

@@ -13,7 +13,7 @@ export const resetPassword = async (email: string) => {
email,
},
});
const oldUser = (OLD_USER as OldUser[]).find((u) => u.email.toLowerCase() === email.toLowerCase());
const oldUser = (OLD_USER as OldUser[]).find((u) => u.email.toLowerCase() === email);
if (!user) {
if (oldUser) {
user = await createNewUserFromOld(oldUser);

View File

@@ -83,7 +83,7 @@ export const Register = () => {
setIsLoading(true);
const values = form.getValues();
const user = await register({
email: form.getValues("email"),
email: form.getValues("email").toLowerCase(),
password: form.getValues("password"),
firstname: form.getValues("firstname"),
lastname: form.getValues("lastname"),
@@ -116,13 +116,13 @@ export const Register = () => {
Login
</Link>
</span>
<div className="alert alert-info alert-outline text-sm font-semibold text-center justify-center">
<div className="alert alert-info alert-outline justify-center text-center text-sm font-semibold">
Du warst bereits Nutzer der V1? <br />
Du musst keinen neuen Account erstellen, sondern kannst dich mit deinen alten Zugangsdaten
anmelden.
</div>
<div className="mt-5 mb-2">
<label className="input input-bordered flex items-center gap-2 mt-2 w-full">
<div className="mb-2 mt-5">
<label className="input input-bordered mt-2 flex w-full items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
@@ -143,7 +143,7 @@ export const Register = () => {
? form.formState.errors.firstname.message
: ""}
</p>
<label className="input input-bordered flex items-center gap-2 mt-2 w-full">
<label className="input input-bordered mt-2 flex w-full items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
@@ -165,7 +165,7 @@ export const Register = () => {
: ""}
</p>
<div className="divider">Account</div>
<label className="input input-bordered flex items-center gap-2 w-full">
<label className="input input-bordered flex w-full items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
@@ -183,7 +183,7 @@ export const Register = () => {
? form.formState.errors.email.message
: ""}
</p>
<label className="input input-bordered flex items-center gap-2 mt-2 w-full">
<label className="input input-bordered mt-2 flex w-full items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
@@ -209,7 +209,7 @@ export const Register = () => {
? form.formState.errors.password.message
: ""}
</p>
<label className="input input-bordered flex items-center gap-2 mt-2 w-full">
<label className="input input-bordered mt-2 flex w-full items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"

View File

@@ -29,7 +29,9 @@ export const register = async ({ password, ...user }: Omit<Prisma.UserCreateInpu
},
});
const existingOldUser = (OLD_USER as OldUser[]).find((u) => u.email.toLocaleLowerCase() === user.email.toLocaleLowerCase());
const existingOldUser = (OLD_USER as OldUser[]).find(
(u) => u.email.toLocaleLowerCase() === user.email,
);
if (existingUser) {
return {

View File

@@ -21,7 +21,7 @@ export const VerticalNav = async () => {
return p.startsWith("ADMIN");
});
return (
<ul className="menu flex-nowrap w-64 bg-base-300 p-3 rounded-lg shadow-md font-semibold">
<ul className="menu bg-base-300 w-64 flex-nowrap rounded-lg p-3 font-semibold shadow-md">
<li>
<Link href="/">
<HomeIcon /> Dashboard
@@ -99,6 +99,11 @@ export const VerticalNav = async () => {
<Link href="/admin/penalty">Audit-Log</Link>
</li>
)}
{session.user.permissions.includes("ADMIN_CHANGELOG") && (
<li>
<Link href="/admin/changelog">Changelog</Link>
</li>
)}
</ul>
</details>
</li>
@@ -112,7 +117,7 @@ export const HorizontalNav = async () => {
if (!session?.user) return <Error statusCode={401} title="Benutzer nicht authentifiziert!" />;
return (
<div className="navbar bg-base-200 shadow-md rounded-lg mb-4">
<div className="navbar bg-base-200 mb-4 rounded-lg shadow-md">
<div className="flex items-center">
<Link href="/" className="flex items-center">
<Image
@@ -123,12 +128,12 @@ export const HorizontalNav = async () => {
className="ml-2 mr-3"
priority
/>
<h2 className="normal-case text-xl font-semibold">Virtual Air Rescue - HUB</h2>
<h2 className="text-xl font-semibold normal-case">Virtual Air Rescue - HUB</h2>
</Link>
<WarningAlert />
</div>
<div className="flex items-center ml-auto">
<ul className="flex space-x-2 px-1 items-center">
<div className="ml-auto flex items-center">
<ul className="flex items-center space-x-2 px-1">
<li>
<a
href={process.env.NEXT_PUBLIC_DISPATCH_URL + "/tracker"}

View File

@@ -18,9 +18,16 @@ export const options: AuthOptions = {
try {
if (!credentials) throw new Error("No credentials provided");
const user = await prisma.user.findFirst({
where: { email: credentials.email },
where: {
email: {
contains: credentials.email,
mode: "insensitive",
},
},
});
const v1User = (oldUser as OldUser[]).find((u) => u.email.toLowerCase() === credentials.email.toLowerCase());
const v1User = (oldUser as OldUser[]).find(
(u) => u.email.toLowerCase() === credentials.email,
);
if (!user && v1User) {
if (bcrypt.compareSync(credentials.password, v1User.password)) {
const newUser = await createNewUserFromOld(v1User);

View File

@@ -4,7 +4,7 @@
"private": true,
"packageManager": "pnpm@10.13.1",
"scripts": {
"dev": "next dev --turbopack -p 3000",
"dev": "next dev -p 3000",
"build": "next build",
"start": "next start",
"lint": "next lint"

Binary file not shown.

After

Width:  |  Height:  |  Size: 447 KiB

View File

@@ -31,6 +31,29 @@ export interface OldUser {
}
export const createNewUserFromOld = async (oldUser: OldUser) => {
const existingPublicId = await prisma.user.findFirst({
where: {
publicId: oldUser.publicId,
},
});
let varPublicId = oldUser.publicId;
if (existingPublicId) {
const lastUserPublicId = await prisma.user.findFirst({
select: {
publicId: true,
},
orderBy: {
publicId: "desc",
},
});
if (lastUserPublicId) {
const lastUserInt = parseInt(lastUserPublicId.publicId.replace("VAR", ""));
varPublicId = `VAR${(lastUserInt + 1).toString().padStart(4, "0")}`;
}
}
const newUser = await prisma.user.create({
data: {
email: oldUser.email,
@@ -38,7 +61,7 @@ export const createNewUserFromOld = async (oldUser: OldUser) => {
migratedFromV1: true,
firstname: oldUser.firstname,
lastname: oldUser.lastname,
publicId: oldUser.publicId,
publicId: varPublicId,
badges: [
...oldUser.badges
.map((badge) => {

View File

@@ -36,6 +36,7 @@ export interface StationStatus {
data?: {
stationId: number;
aircraftId: number;
userId?: string;
};
}

View File

@@ -0,0 +1,7 @@
model Changelog {
id Int @id @default(autoincrement())
title String
previewImage String?
text String
createdAt DateTime @default(now())
}

View File

@@ -7,6 +7,7 @@ model ConnectedDispatcher {
zone String @default("LST_1")
esimatedLogoutTime DateTime?
logoutTime DateTime?
ghostMode Boolean @default(false)
// relations:
user User @relation(fields: [userId], references: [id], onDelete: Cascade)

View File

@@ -0,0 +1,9 @@
-- CreateTable
CREATE TABLE "Changelog" (
"id" SERIAL NOT NULL,
"title" TEXT NOT NULL,
"previewImage" BYTEA,
"text" TEXT NOT NULL,
CONSTRAINT "Changelog_pkey" PRIMARY KEY ("id")
);

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "ConnectedDispatcher" ADD COLUMN "ghostMode" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Changelog" ALTER COLUMN "previewImage" SET DATA TYPE TEXT;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Changelog" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "changelogAck" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "PERMISSION" ADD VALUE 'ADMIN_CHANGELOG';

View File

@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "BosUse" ADD VALUE 'SPECIAL_OPS';

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "settings_auto_close_map_popup" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -2,6 +2,7 @@ enum BosUse {
PRIMARY
SECONDARY
DUAL_USE
SPECIAL_OPS
}
enum Country {

View File

@@ -18,6 +18,7 @@ enum PERMISSION {
ADMIN_MESSAGE
ADMIN_KICK
ADMIN_HELIPORT
ADMIN_CHANGELOG
AUDIO
PILOT
DISPO
@@ -25,24 +26,26 @@ enum PERMISSION {
}
model User {
id String @id @default(uuid())
publicId String @unique
firstname String
lastname String
email String @unique
password String
vatsimCid String? @map(name: "vatsim_cid")
moodleId Int? @map(name: "moodle_id")
id String @id @default(uuid())
publicId String @unique
firstname String
lastname String
email String @unique
password String
vatsimCid String? @map(name: "vatsim_cid")
moodleId Int? @map(name: "moodle_id")
changelogAck Boolean @default(false)
// Settings:
pathSelected Boolean @default(false)
migratedFromV1 Boolean @default(false)
settingsNtfyRoom String? @map(name: "settings_ntfy_room")
settingsMicDevice String? @map(name: "settings_mic_device")
settingsMicVolume Float? @map(name: "settings_mic_volume")
settingsDmeVolume Float? @map(name: "settings_dme_volume")
settingsRadioVolume Float? @map(name: "settings_funk_volume")
settingsHideLastname Boolean @default(false) @map(name: "settings_hide_lastname")
pathSelected Boolean @default(false)
migratedFromV1 Boolean @default(false)
settingsNtfyRoom String? @map(name: "settings_ntfy_room")
settingsMicDevice String? @map(name: "settings_mic_device")
settingsMicVolume Float? @map(name: "settings_mic_volume")
settingsDmeVolume Float? @map(name: "settings_dme_volume")
settingsRadioVolume Float? @map(name: "settings_funk_volume")
settingsHideLastname Boolean @default(false) @map(name: "settings_hide_lastname")
settingsAutoCloseMapPopup Boolean @default(false) @map(name: "settings_auto_close_map_popup")
// email Verification:
emailVerificationToken String? @map(name: "email_verification_token")

View File

@@ -0,0 +1,102 @@
"use client";
import { useState } from "react";
import { Button, cn } from "@repo/shared-components";
import MDEditor from "@uiw/react-md-editor";
import { RefreshCw } from "lucide-react";
import { Changelog } from "@repo/db";
export const ChangelogModal = ({
latestChangelog,
isOpen,
onClose,
}: {
latestChangelog: Changelog;
isOpen: boolean;
onClose: () => void;
}) => {
return (
<dialog open={isOpen} className="modal p-4">
<div className="modal-box max-h-11/12 w-11/12 max-w-2xl overflow-y-auto">
<form method="dialog">
<button
className="btn btn-sm btn-circle btn-ghost absolute right-3 top-3"
onClick={onClose}
>
</button>
</form>
<h3 className="flex items-center gap-2 text-lg font-bold">
<span className="text-primary">{latestChangelog.title}</span> ist nun Verfügbar!
</h3>
<div className="flex flex-col items-center">
{latestChangelog.previewImage && (
<img
src={latestChangelog.previewImage}
alt="Preview"
className="mt-4 h-auto w-full object-cover"
/>
)}
</div>
<div className="text-base-content/80 mb-2 mt-4 text-left">
<MDEditor.Markdown
source={latestChangelog.text}
style={{
backgroundColor: "transparent",
}}
/>
</div>
<div className="modal-action">
<Button className="btn btn-info btn-outline" onClick={onClose}>
Weiter zum HUB
</Button>
</div>
</div>
<label className="modal-backdrop" htmlFor="changelogModalToggle">
Close
</label>
</dialog>
);
};
export const ChangelogModalBtn = ({
latestChangelog,
autoOpen,
onClose,
className = "",
hideIcon = false,
}: {
latestChangelog: Changelog | null | undefined;
autoOpen: boolean;
onClose?: () => void;
className?: string;
hideIcon?: boolean;
}) => {
const [isOpen, setIsOpen] = useState(autoOpen);
if (!latestChangelog) return null;
return (
<>
<a
href="#!"
className={cn("hover:text-primary flex items-center gap-1", className)}
onClick={() => setIsOpen(true)}
>
{!hideIcon && <RefreshCw size={12} />} {latestChangelog.title}
</a>
<ChangelogModal
latestChangelog={latestChangelog}
isOpen={isOpen}
onClose={() => {
setIsOpen(false);
if (onClose) {
onClose();
}
}}
/>
</>
);
};

View File

@@ -2,3 +2,4 @@ export * from "./Badge";
export * from "./PenaltyDropdown";
export * from "./Maintenance";
export * from "./Button";
export * from "./Changelog";

View File

@@ -10,8 +10,10 @@
"@repo/db": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@types/node": "^22.15.29",
"@uiw/react-md-editor": "^4.0.8",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.525.0",
"tailwind-merge": "^3.3.1"
},
"devDependencies": {

6
pnpm-lock.yaml generated
View File

@@ -618,12 +618,18 @@ importers:
'@types/node':
specifier: ^22.15.29
version: 22.15.29
'@uiw/react-md-editor':
specifier: ^4.0.8
version: 4.0.8(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
clsx:
specifier: ^2.1.1
version: 2.1.1
date-fns:
specifier: ^4.1.0
version: 4.1.0
lucide-react:
specifier: ^0.525.0
version: 0.525.0(react@19.1.0)
tailwind-merge:
specifier: ^3.3.1
version: 3.3.1