Release v2.0.1 #99
@@ -83,6 +83,7 @@ router.patch("/:id", async (req, res) => {
|
|||||||
data: {
|
data: {
|
||||||
stationId: updatedConnectedAircraft.stationId,
|
stationId: updatedConnectedAircraft.stationId,
|
||||||
aircraftId: updatedConnectedAircraft.id,
|
aircraftId: updatedConnectedAircraft.id,
|
||||||
|
userId: updatedConnectedAircraft.userId,
|
||||||
},
|
},
|
||||||
} as NotificationPayload);
|
} as NotificationPayload);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,15 @@ import { Server, Socket } from "socket.io";
|
|||||||
|
|
||||||
export const handleConnectDispatch =
|
export const handleConnectDispatch =
|
||||||
(socket: Socket, io: Server) =>
|
(socket: Socket, io: Server) =>
|
||||||
async ({ logoffTime, selectedZone }: { logoffTime: string; selectedZone: string }) => {
|
async ({
|
||||||
|
logoffTime,
|
||||||
|
selectedZone,
|
||||||
|
ghostMode,
|
||||||
|
}: {
|
||||||
|
logoffTime: string;
|
||||||
|
selectedZone: string;
|
||||||
|
ghostMode: boolean;
|
||||||
|
}) => {
|
||||||
try {
|
try {
|
||||||
const user: User = socket.data.user; // User ID aus dem JWT-Token
|
const user: User = socket.data.user; // User ID aus dem JWT-Token
|
||||||
|
|
||||||
@@ -53,6 +61,7 @@ export const handleConnectDispatch =
|
|||||||
userId: user.id,
|
userId: user.id,
|
||||||
zone: selectedZone,
|
zone: selectedZone,
|
||||||
loginTime: new Date().toISOString(),
|
loginTime: new Date().toISOString(),
|
||||||
|
ghostMode,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,19 +2,29 @@ import { Connection } from "./_components/Connection";
|
|||||||
import { Audio } from "../../../../_components/Audio/Audio";
|
import { Audio } from "../../../../_components/Audio/Audio";
|
||||||
import { ExitIcon, ExternalLinkIcon } from "@radix-ui/react-icons";
|
import { ExitIcon, ExternalLinkIcon } from "@radix-ui/react-icons";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Settings } from "_components/navbar/Settings";
|
import { Settings } from "./_components/Settings";
|
||||||
import AdminPanel from "_components/navbar/AdminPanel";
|
import AdminPanel from "_components/navbar/AdminPanel";
|
||||||
import { getServerSession } from "api/auth/[...nextauth]/auth";
|
import { getServerSession } from "api/auth/[...nextauth]/auth";
|
||||||
import { WarningAlert } from "_components/navbar/PageAlert";
|
import { WarningAlert } from "_components/navbar/PageAlert";
|
||||||
import { Radar } from "lucide-react";
|
import { Radar } from "lucide-react";
|
||||||
|
import { ChangelogWrapper } from "_components/navbar/ChangelogWrapper";
|
||||||
|
import { prisma } from "@repo/db";
|
||||||
|
|
||||||
export default async function Navbar() {
|
export default async function Navbar() {
|
||||||
const session = await getServerSession();
|
const session = await getServerSession();
|
||||||
|
const latestChangelog = await prisma.changelog.findFirst({
|
||||||
|
orderBy: {
|
||||||
|
createdAt: "desc",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
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">
|
<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 />}
|
{session?.user.permissions.includes("ADMIN_KICK") && <AdminPanel />}
|
||||||
</div>
|
</div>
|
||||||
<WarningAlert />
|
<WarningAlert />
|
||||||
@@ -38,12 +48,12 @@ export default async function Navbar() {
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
<button className="btn btn-ghost">
|
<button className="btn btn-ghost">
|
||||||
<ExternalLinkIcon className="w-4 h-4" /> HUB
|
<ExternalLinkIcon className="h-4 w-4" /> HUB
|
||||||
</button>
|
</button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href={"/logout"}>
|
<Link href={"/logout"}>
|
||||||
<button className="btn btn-ghost">
|
<button className="btn btn-ghost">
|
||||||
<ExitIcon className="w-4 h-4" />
|
<ExitIcon className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useMutation } from "@tanstack/react-query";
|
|||||||
import { Prisma } from "@repo/db";
|
import { Prisma } from "@repo/db";
|
||||||
import { changeDispatcherAPI } from "_querys/dispatcher";
|
import { changeDispatcherAPI } from "_querys/dispatcher";
|
||||||
import { Button, getNextDateWithTime } from "@repo/shared-components";
|
import { Button, getNextDateWithTime } from "@repo/shared-components";
|
||||||
|
import { Ghost } from "lucide-react";
|
||||||
|
|
||||||
export const ConnectionBtn = () => {
|
export const ConnectionBtn = () => {
|
||||||
const modalRef = useRef<HTMLDialogElement>(null);
|
const modalRef = useRef<HTMLDialogElement>(null);
|
||||||
@@ -14,6 +15,7 @@ export const ConnectionBtn = () => {
|
|||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
logoffTime: "",
|
logoffTime: "",
|
||||||
selectedZone: "LST_01",
|
selectedZone: "LST_01",
|
||||||
|
ghostMode: false,
|
||||||
});
|
});
|
||||||
const changeDispatcherMutation = useMutation({
|
const changeDispatcherMutation = useMutation({
|
||||||
mutationFn: ({ id, data }: { id: number; data: Prisma.ConnectedDispatcherUpdateInput }) =>
|
mutationFn: ({ id, data }: { id: number; data: Prisma.ConnectedDispatcherUpdateInput }) =>
|
||||||
@@ -45,7 +47,7 @@ export const ConnectionBtn = () => {
|
|||||||
modalRef.current?.showModal();
|
modalRef.current?.showModal();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Verbunden
|
Verbunden {connection.ghostMode && <Ghost />}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
@@ -89,6 +91,21 @@ export const ConnectionBtn = () => {
|
|||||||
<p className="fieldset-label">Du kannst diese Zeit später noch anpassen.</p>
|
<p className="fieldset-label">Du kannst diese Zeit später noch anpassen.</p>
|
||||||
)}
|
)}
|
||||||
</fieldset>
|
</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">
|
<div className="modal-action flex w-full justify-between">
|
||||||
<form method="dialog" className="flex w-full justify-between">
|
<form method="dialog" className="flex w-full justify-between">
|
||||||
<button className="btn btn-soft">Zurück</button>
|
<button className="btn btn-soft">Zurück</button>
|
||||||
@@ -138,6 +155,7 @@ export const ConnectionBtn = () => {
|
|||||||
form.logoffTime && logoffHours !== undefined && logoffMinutes !== undefined
|
form.logoffTime && logoffHours !== undefined && logoffMinutes !== undefined
|
||||||
? getNextDateWithTime(logoffHours, logoffMinutes).toISOString()
|
? getNextDateWithTime(logoffHours, logoffMinutes).toISOString()
|
||||||
: "",
|
: "",
|
||||||
|
form.ghostMode,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
className="btn btn-soft btn-info"
|
className="btn btn-soft btn-info"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -13,7 +13,7 @@ export const useSounds = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
newMissionSound.current = new Audio("/sounds/Melder3.wav");
|
newMissionSound.current = new Audio("/sounds/DME-new-mission.wav");
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -2,15 +2,25 @@ import { Connection } from "./_components/Connection";
|
|||||||
import { Audio } from "_components/Audio/Audio";
|
import { Audio } from "_components/Audio/Audio";
|
||||||
import { ExitIcon, ExternalLinkIcon } from "@radix-ui/react-icons";
|
import { ExitIcon, ExternalLinkIcon } from "@radix-ui/react-icons";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Settings } from "_components/navbar/Settings";
|
import { Settings } from "./_components/Settings";
|
||||||
import { WarningAlert } from "_components/navbar/PageAlert";
|
import { WarningAlert } from "_components/navbar/PageAlert";
|
||||||
import { Radar } from "lucide-react";
|
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 (
|
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">
|
<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>
|
</div>
|
||||||
<WarningAlert />
|
<WarningAlert />
|
||||||
<div className="flex items-center gap-5">
|
<div className="flex items-center gap-5">
|
||||||
@@ -33,12 +43,12 @@ export default function Navbar() {
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
<button className="btn btn-ghost">
|
<button className="btn btn-ghost">
|
||||||
<ExternalLinkIcon className="w-4 h-4" /> HUB
|
<ExternalLinkIcon className="h-4 w-4" /> HUB
|
||||||
</button>
|
</button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href={"/logout"}>
|
<Link href={"/logout"}>
|
||||||
<button className="btn btn-ghost">
|
<button className="btn btn-ghost">
|
||||||
<ExitIcon className="w-4 h-4" />
|
<ExitIcon className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export const SettingsBtn = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
testSoundRef.current = new Audio("/sounds/Melder3.wav");
|
testSoundRef.current = new Audio("/sounds/DME-new-mission.wav");
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -33,20 +33,20 @@ const PilotPage = () => {
|
|||||||
<div className="ease relative flex h-screen w-full flex-1 overflow-hidden transition-all duration-500">
|
<div className="ease relative flex h-screen w-full flex-1 overflow-hidden transition-all duration-500">
|
||||||
{/* <MapToastCard2 /> */}
|
{/* <MapToastCard2 /> */}
|
||||||
<div className="relative flex h-full w-full flex-1">
|
<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 />
|
<Chat />
|
||||||
<Report />
|
<Report />
|
||||||
<BugReport />
|
<BugReport />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex h-full w-2/3">
|
<div className="flex h-full w-2/3">
|
||||||
<div className="relative flex h-full flex-1">
|
<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">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<SettingsBoard />
|
<SettingsBoard />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Map />
|
<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" && (
|
{!simulatorConnected && status === "connected" && (
|
||||||
<SimConnectionAlert lastUpdated={ownAircraft?.lastHeartbeat} />
|
<SimConnectionAlert lastUpdated={ownAircraft?.lastHeartbeat} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -139,10 +139,10 @@ export const SmartPopup = (
|
|||||||
<Popup {...props} className={cn("relative", wrapperClassName)}>
|
<Popup {...props} className={cn("relative", wrapperClassName)}>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"pointer-events-auto bg-base-100 relative",
|
"bg-base-100 pointer-events-auto relative",
|
||||||
anchor.includes("right") && "-translate-x-full",
|
anchor.includes("right") && "-translate-x-full",
|
||||||
anchor.includes("bottom") && "-translate-y-full",
|
anchor.includes("bottom") && "-translate-y-full",
|
||||||
!showContent && "opacity-0 pointer-events-none",
|
!showContent && "pointer-events-none opacity-0",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -150,7 +150,7 @@ export const SmartPopup = (
|
|||||||
data-id={id}
|
data-id={id}
|
||||||
id={`popup-domain-${id}`}
|
id={`popup-domain-${id}`}
|
||||||
className={cn(
|
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("left") && "-translate-x-1/2",
|
||||||
anchor.includes("top") && "-translate-y-1/2",
|
anchor.includes("top") && "-translate-y-1/2",
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -15,10 +15,19 @@ export const HPGnotificationToast = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
toast.dismiss(t.id);
|
toast.dismiss(t.id);
|
||||||
|
|
||||||
|
if (mapStore.userSettings.settingsAutoCloseMapPopup) {
|
||||||
|
mapStore.setOpenMissionMarker({
|
||||||
|
open: [{ id: event.data.mission.id, tab: "home" }],
|
||||||
|
close: mapStore.openMissionMarker?.map((m) => m.id) || [],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
mapStore.setOpenMissionMarker({
|
mapStore.setOpenMissionMarker({
|
||||||
open: [{ id: event.data.mission.id, tab: "home" }],
|
open: [{ id: event.data.mission.id, tab: "home" }],
|
||||||
close: [],
|
close: [],
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
mapStore.setMap({
|
mapStore.setMap({
|
||||||
center: [event.data.mission.addressLat, event.data.mission.addressLng],
|
center: [event.data.mission.addressLat, event.data.mission.addressLng],
|
||||||
zoom: 14,
|
zoom: 14,
|
||||||
@@ -29,7 +38,7 @@ export const HPGnotificationToast = ({
|
|||||||
return (
|
return (
|
||||||
<BaseNotification icon={<Cross />} className="flex flex-row">
|
<BaseNotification icon={<Cross />} className="flex flex-row">
|
||||||
<div className="flex-1">
|
<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>
|
<p>{event.message}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-11">
|
<div className="ml-11">
|
||||||
@@ -43,7 +52,7 @@ export const HPGnotificationToast = ({
|
|||||||
return (
|
return (
|
||||||
<BaseNotification icon={<Check />} className="flex flex-row">
|
<BaseNotification icon={<Check />} className="flex flex-row">
|
||||||
<div className="flex-1">
|
<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>
|
<p className="text-sm">{event.message}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-11">
|
<div className="ml-11">
|
||||||
|
|||||||
@@ -65,6 +65,17 @@ export const MissionAutoCloseToast = ({
|
|||||||
lng: mission.addressLng,
|
lng: mission.addressLng,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
if (mapStore.userSettings.settingsAutoCloseMapPopup) {
|
||||||
|
mapStore.setOpenMissionMarker({
|
||||||
|
open: [
|
||||||
|
{
|
||||||
|
id: mission.id,
|
||||||
|
tab: "home",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
close: mapStore.openMissionMarker?.map((m) => m.id) || [],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
mapStore.setOpenMissionMarker({
|
mapStore.setOpenMissionMarker({
|
||||||
open: [
|
open: [
|
||||||
{
|
{
|
||||||
@@ -74,6 +85,7 @@ export const MissionAutoCloseToast = ({
|
|||||||
],
|
],
|
||||||
close: [],
|
close: [],
|
||||||
});
|
});
|
||||||
|
}
|
||||||
toast.dismiss(t.id);
|
toast.dismiss(t.id);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { BaseNotification } from "_components/customToasts/BaseNotification";
|
import { BaseNotification } from "_components/customToasts/BaseNotification";
|
||||||
import { FMS_STATUS_COLORS } from "_helpers/fmsStatusColors";
|
import { FMS_STATUS_COLORS } from "_helpers/fmsStatusColors";
|
||||||
import { editConnectedAircraftAPI, getConnectedAircraftsAPI } from "_querys/aircrafts";
|
import { editConnectedAircraftAPI, getConnectedAircraftsAPI } from "_querys/aircrafts";
|
||||||
|
import { getLivekitRooms } from "_querys/livekit";
|
||||||
import { getStationsAPI } from "_querys/stations";
|
import { getStationsAPI } from "_querys/stations";
|
||||||
|
import { useAudioStore } from "_store/audioStore";
|
||||||
import { useMapStore } from "_store/mapStore";
|
import { useMapStore } from "_store/mapStore";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { useEffect, useRef, useState } from "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 status5Sounds = useRef<HTMLAudioElement | null>(null);
|
||||||
const status9Sounds = 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(() => {
|
useEffect(() => {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
status0Sounds.current = new Audio("/sounds/status-0.mp3");
|
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 [aircraftDataAcurate, setAircraftDataAccurate] = useState(false);
|
||||||
const mapStore = useMapStore((s) => s);
|
//const mapStore = useMapStore((s) => s);
|
||||||
|
const { setOpenAircraftMarker, setMap } = useMapStore((store) => store);
|
||||||
|
|
||||||
const { data: connectedAircrafts } = useQuery({
|
const { data: connectedAircrafts } = useQuery({
|
||||||
queryKey: ["aircrafts"],
|
queryKey: ["aircrafts"],
|
||||||
@@ -83,6 +103,11 @@ export const StatusToast = ({ event, t }: { event: StationStatus; t: Toast }) =>
|
|||||||
default:
|
default:
|
||||||
soundRef = null;
|
soundRef = null;
|
||||||
}
|
}
|
||||||
|
if (audioRoom !== livekitUser?.roomName) {
|
||||||
|
toast.remove(t.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (soundRef?.current) {
|
if (soundRef?.current) {
|
||||||
soundRef.current.currentTime = 0;
|
soundRef.current.currentTime = 0;
|
||||||
soundRef.current.volume = 0.7;
|
soundRef.current.volume = 0.7;
|
||||||
@@ -94,22 +119,23 @@ export const StatusToast = ({ event, t }: { event: StationStatus; t: Toast }) =>
|
|||||||
soundRef.current.currentTime = 0;
|
soundRef.current.currentTime = 0;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [event.status]);
|
}, [event.status, livekitUser?.roomName, audioRoom, t.id]);
|
||||||
|
|
||||||
if (!connectedAircraft || !station) return null;
|
if (!connectedAircraft || !station) return null;
|
||||||
return (
|
return (
|
||||||
<BaseNotification>
|
<BaseNotification>
|
||||||
<div className="flex flex-row gap-14 items-center">
|
<div className="flex flex-row items-center gap-14">
|
||||||
<p>
|
<p>
|
||||||
<span
|
<span
|
||||||
className="underline mr-1 cursor-pointer font-bold"
|
className="mr-1 cursor-pointer font-bold underline"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!connectedAircraft.posLat || !connectedAircraft.posLng) return;
|
if (!connectedAircraft.posLat || !connectedAircraft.posLng) return;
|
||||||
mapStore.setOpenAircraftMarker({
|
|
||||||
|
setOpenAircraftMarker({
|
||||||
open: [{ id: connectedAircraft.id, tab: "fms" }],
|
open: [{ id: connectedAircraft.id, tab: "fms" }],
|
||||||
close: [],
|
close: [],
|
||||||
});
|
});
|
||||||
mapStore.setMap({
|
setMap({
|
||||||
center: [connectedAircraft.posLat, connectedAircraft.posLng],
|
center: [connectedAircraft.posLat, connectedAircraft.posLng],
|
||||||
zoom: 14,
|
zoom: 14,
|
||||||
});
|
});
|
||||||
@@ -119,12 +145,12 @@ export const StatusToast = ({ event, t }: { event: StationStatus; t: Toast }) =>
|
|||||||
</span>
|
</span>
|
||||||
sendet Status {event.status}
|
sendet Status {event.status}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex items-center gap-2">
|
||||||
{QUICK_RESPONSE[String(event.status)]?.map((status) => (
|
{QUICK_RESPONSE[String(event.status)]?.map((status) => (
|
||||||
<button
|
<button
|
||||||
key={status}
|
key={status}
|
||||||
className={
|
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={{
|
style={{
|
||||||
backgroundColor: FMS_STATUS_COLORS[status],
|
backgroundColor: FMS_STATUS_COLORS[status],
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { ChatBubbleIcon, PaperPlaneIcon } from "@radix-ui/react-icons";
|
import { ChatBubbleIcon, PaperPlaneIcon } from "@radix-ui/react-icons";
|
||||||
import { useLeftMenuStore } from "_store/leftMenuStore";
|
import { useLeftMenuStore } from "_store/leftMenuStore";
|
||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
import { Fragment, useEffect, useState } from "react";
|
import { Fragment, useEffect, useState, useRef } from "react";
|
||||||
import { cn } from "@repo/shared-components";
|
import { cn } from "@repo/shared-components";
|
||||||
import { asPublicUser } from "@repo/db";
|
import { asPublicUser } from "@repo/db";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
@@ -30,6 +30,8 @@ export const Chat = () => {
|
|||||||
const [message, setMessage] = useState<string>("");
|
const [message, setMessage] = useState<string>("");
|
||||||
const dispatcherConnected = useDispatchConnectionStore((state) => state.status === "connected");
|
const dispatcherConnected = useDispatchConnectionStore((state) => state.status === "connected");
|
||||||
const pilotConnected = usePilotConnectionStore((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({
|
const { data: dispatcher } = useQuery({
|
||||||
queryKey: ["dispatcher"],
|
queryKey: ["dispatcher"],
|
||||||
@@ -61,16 +63,27 @@ export const Chat = () => {
|
|||||||
}
|
}
|
||||||
}, [btnActive, setChatOpen]);
|
}, [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 (
|
return (
|
||||||
<div className={cn("dropdown dropdown-right dropdown-center", chatOpen && "dropdown-open")}>
|
<div className={cn("dropdown dropdown-right dropdown-center", chatOpen && "dropdown-open")}>
|
||||||
|
<audio ref={audioRef} src="/sounds/newChat.mp3" preload="auto" />
|
||||||
<div className="indicator">
|
<div className="indicator">
|
||||||
{Object.values(chats).some((c) => c.notification) && (
|
|
||||||
<span className="indicator-item status status-info animate-ping"></span>
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
className={cn(
|
className={cn(
|
||||||
"btn btn-soft btn-sm cursor-default",
|
"btn btn-soft btn-sm cursor-default",
|
||||||
btnActive && "btn-primary cursor-pointer",
|
btnActive && "btn-primary cursor-pointer",
|
||||||
|
someChat && "border-warning animate-pulse",
|
||||||
)}
|
)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!btnActive) return;
|
if (!btnActive) return;
|
||||||
@@ -81,23 +94,23 @@ export const Chat = () => {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ChatBubbleIcon className="w-4 h-4" />
|
<ChatBubbleIcon className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{chatOpen && (
|
{chatOpen && (
|
||||||
<div
|
<div
|
||||||
tabIndex={0}
|
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">
|
<div className="card-body relative">
|
||||||
<button
|
<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)}
|
onClick={() => setChatOpen(false)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<span className="text-xl leading-none">×</span>
|
<span className="text-xl leading-none">×</span>
|
||||||
</button>
|
</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
|
<ChatBubbleIcon /> Chat
|
||||||
</h2>
|
</h2>
|
||||||
<div className="join">
|
<div className="join">
|
||||||
@@ -164,7 +177,7 @@ export const Chat = () => {
|
|||||||
{chat.name}
|
{chat.name}
|
||||||
{chat.notification && <span className="indicator-item status status-info" />}
|
{chat.notification && <span className="indicator-item status status-info" />}
|
||||||
</a>
|
</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... */}
|
{/* So macht man kein overflow handeling, weiß ich. Aber es funktioniert... */}
|
||||||
{chat.messages.map((chatMessage) => {
|
{chat.messages.map((chatMessage) => {
|
||||||
const isSender = chatMessage.senderId === session.data?.user.id;
|
const isSender = chatMessage.senderId === session.data?.user.id;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { getConnectedAircraftsAPI } from "_querys/aircrafts";
|
|||||||
import { FMS_STATUS_COLORS, FMS_STATUS_TEXT_COLORS } from "_helpers/fmsStatusColors";
|
import { FMS_STATUS_COLORS, FMS_STATUS_TEXT_COLORS } from "_helpers/fmsStatusColors";
|
||||||
import { useMapStore } from "_store/mapStore";
|
import { useMapStore } from "_store/mapStore";
|
||||||
import { useDispatchConnectionStore } from "_store/dispatch/connectionStore";
|
import { useDispatchConnectionStore } from "_store/dispatch/connectionStore";
|
||||||
|
import { findLeitstelleForPosition } from "_helpers/findLeitstelleinPoint";
|
||||||
|
|
||||||
export const SituationBoard = () => {
|
export const SituationBoard = () => {
|
||||||
const { setSituationTabOpen, situationTabOpen } = useLeftMenuStore();
|
const { setSituationTabOpen, situationTabOpen } = useLeftMenuStore();
|
||||||
@@ -53,7 +54,14 @@ export const SituationBoard = () => {
|
|||||||
queryKey: ["aircrafts"],
|
queryKey: ["aircrafts"],
|
||||||
queryFn: () => getConnectedAircraftsAPI(),
|
queryFn: () => getConnectedAircraftsAPI(),
|
||||||
});
|
});
|
||||||
const { setOpenAircraftMarker, setOpenMissionMarker, setMap } = useMapStore((state) => state);
|
const {
|
||||||
|
setOpenAircraftMarker,
|
||||||
|
setOpenMissionMarker,
|
||||||
|
setMap,
|
||||||
|
userSettings,
|
||||||
|
openAircraftMarker,
|
||||||
|
openMissionMarker,
|
||||||
|
} = useMapStore((state) => state);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("dropdown dropdown-top", situationTabOpen && "dropdown-open")}>
|
<div className={cn("dropdown dropdown-top", situationTabOpen && "dropdown-open")}>
|
||||||
@@ -64,17 +72,17 @@ export const SituationBoard = () => {
|
|||||||
setSituationTabOpen(!situationTabOpen);
|
setSituationTabOpen(!situationTabOpen);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ListCollapse className="w-4 h-4" />
|
<ListCollapse className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{situationTabOpen && (
|
{situationTabOpen && (
|
||||||
<div
|
<div
|
||||||
tabIndex={0}
|
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="card-body flex flex-row gap-4">
|
||||||
<div className="flex-1">
|
<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{" "}
|
<ListCollapse /> Einsatzliste{" "}
|
||||||
</h2>
|
</h2>
|
||||||
<div>
|
<div>
|
||||||
@@ -90,8 +98,8 @@ export const SituationBoard = () => {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto overflow-y-auto max-h-[170px] select-none">
|
<div className="max-h-[170px] select-none overflow-x-auto overflow-y-auto">
|
||||||
<table className="table table-xs">
|
<table className="table-xs table">
|
||||||
{/* head */}
|
{/* head */}
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -111,6 +119,17 @@ export const SituationBoard = () => {
|
|||||||
mission.state === "draft" && "missionListItem",
|
mission.state === "draft" && "missionListItem",
|
||||||
)}
|
)}
|
||||||
onDoubleClick={() => {
|
onDoubleClick={() => {
|
||||||
|
if (userSettings.settingsAutoCloseMapPopup) {
|
||||||
|
setOpenMissionMarker({
|
||||||
|
open: [
|
||||||
|
{
|
||||||
|
id: mission.id,
|
||||||
|
tab: "home",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
close: openMissionMarker?.map((m) => m.id) || [],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
setOpenMissionMarker({
|
setOpenMissionMarker({
|
||||||
open: [
|
open: [
|
||||||
{
|
{
|
||||||
@@ -120,6 +139,7 @@ export const SituationBoard = () => {
|
|||||||
],
|
],
|
||||||
close: [],
|
close: [],
|
||||||
});
|
});
|
||||||
|
}
|
||||||
setMap({
|
setMap({
|
||||||
center: {
|
center: {
|
||||||
lat: mission.addressLat,
|
lat: mission.addressLat,
|
||||||
@@ -145,13 +165,13 @@ export const SituationBoard = () => {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-px bg-gray-400 mx-2" />
|
<div className="mx-2 w-px bg-gray-400" />
|
||||||
<div className="flex-1">
|
<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
|
<Plane /> Stationen
|
||||||
</h2>
|
</h2>
|
||||||
<div className="overflow-x-auto overflow-y-auto max-h-[200px] select-none">
|
<div className="max-h-[200px] select-none overflow-x-auto overflow-y-auto">
|
||||||
<table className="table table-xs">
|
<table className="table-xs table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>BOS Name</th>
|
<th>BOS Name</th>
|
||||||
@@ -160,41 +180,59 @@ export const SituationBoard = () => {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{connectedAircrafts?.map((station) => (
|
{connectedAircrafts?.map((aircraft) => (
|
||||||
<tr
|
<tr
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
key={station.id}
|
key={aircraft.id}
|
||||||
onDoubleClick={() => {
|
onDoubleClick={() => {
|
||||||
|
if (userSettings.settingsAutoCloseMapPopup) {
|
||||||
setOpenAircraftMarker({
|
setOpenAircraftMarker({
|
||||||
open: [
|
open: [
|
||||||
{
|
{
|
||||||
id: station.id,
|
id: aircraft.id,
|
||||||
|
tab: "home",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
close: openAircraftMarker?.map((m) => m.id) || [],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setOpenAircraftMarker({
|
||||||
|
open: [
|
||||||
|
{
|
||||||
|
id: aircraft.id,
|
||||||
tab: "home",
|
tab: "home",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
close: [],
|
close: [],
|
||||||
});
|
});
|
||||||
if (station.posLat === null || station.posLng === null) return;
|
}
|
||||||
|
if (aircraft.posLat === null || aircraft.posLng === null) return;
|
||||||
setMap({
|
setMap({
|
||||||
center: {
|
center: {
|
||||||
lat: station.posLat,
|
lat: aircraft.posLat,
|
||||||
lng: station.posLng,
|
lng: aircraft.posLng,
|
||||||
},
|
},
|
||||||
zoom: 14,
|
zoom: 14,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<td>{station.Station.bosCallsignShort}</td>
|
<td>{aircraft.Station.bosCallsignShort}</td>
|
||||||
<td
|
<td
|
||||||
className="text-center font-lg font-semibold"
|
className="font-lg text-center font-semibold"
|
||||||
style={{
|
style={{
|
||||||
color: FMS_STATUS_TEXT_COLORS[station.fmsStatus],
|
color: FMS_STATUS_TEXT_COLORS[aircraft.fmsStatus],
|
||||||
backgroundColor: FMS_STATUS_COLORS[station.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>
|
||||||
<td className="whitespace-nowrap">{station.Station.bosRadioArea}</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { getConnectedAircraftPositionLogAPI, getConnectedAircraftsAPI } from "_q
|
|||||||
import { getMissionsAPI } from "_querys/missions";
|
import { getMissionsAPI } from "_querys/missions";
|
||||||
import { FMS_STATUS_COLORS, FMS_STATUS_TEXT_COLORS } from "_helpers/fmsStatusColors";
|
import { FMS_STATUS_COLORS, FMS_STATUS_TEXT_COLORS } from "_helpers/fmsStatusColors";
|
||||||
import { usePilotConnectionStore } from "_store/pilot/connectionStore";
|
import { usePilotConnectionStore } from "_store/pilot/connectionStore";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
|
||||||
const AircraftPopupContent = ({
|
const AircraftPopupContent = ({
|
||||||
aircraft,
|
aircraft,
|
||||||
@@ -37,7 +38,7 @@ const AircraftPopupContent = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const { data: missions } = useQuery({
|
const { data: missions } = useQuery({
|
||||||
queryKey: ["missions", "missions-map"],
|
queryKey: ["missions", "missions-aircraft-marker", aircraft.id],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
getMissionsAPI({
|
getMissionsAPI({
|
||||||
state: "running",
|
state: "running",
|
||||||
@@ -72,7 +73,7 @@ const AircraftPopupContent = ({
|
|||||||
}
|
}
|
||||||
}, [currentTab, aircraft, mission]);
|
}, [currentTab, aircraft, mission]);
|
||||||
|
|
||||||
const { setOpenAircraftMarker, setMap } = useMapStore((state) => state);
|
const { setOpenAircraftMarker, setMap, openAircraftMarker } = useMapStore((state) => state);
|
||||||
const { anchor } = useSmartPopup();
|
const { anchor } = useSmartPopup();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -159,7 +160,7 @@ const AircraftPopupContent = ({
|
|||||||
{aircraft.fmsStatus}
|
{aircraft.fmsStatus}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="min-w-[130px] cursor-pointer px-2"
|
className="cursor-pointer px-2"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: `${FMS_STATUS_COLORS[aircraft.fmsStatus]}`,
|
backgroundColor: `${FMS_STATUS_COLORS[aircraft.fmsStatus]}`,
|
||||||
borderBottom:
|
borderBottom:
|
||||||
@@ -179,10 +180,11 @@ const AircraftPopupContent = ({
|
|||||||
{aircraft.Station.bosUse === "DUAL_USE" && "(dual use)"}
|
{aircraft.Station.bosUse === "DUAL_USE" && "(dual use)"}
|
||||||
{aircraft.Station.bosUse === "PRIMARY" && "(primär)"}
|
{aircraft.Station.bosUse === "PRIMARY" && "(primär)"}
|
||||||
{aircraft.Station.bosUse === "SECONDARY" && "(sekundär)"}
|
{aircraft.Station.bosUse === "SECONDARY" && "(sekundär)"}
|
||||||
|
{aircraft.Station.bosUse === "SPECIAL_OPS" && "(Special OPS)"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="w-150 cursor-pointer px-2"
|
className="flex-1 cursor-pointer overflow-x-hidden px-2"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: `${FMS_STATUS_COLORS[aircraft.fmsStatus]}`,
|
backgroundColor: `${FMS_STATUS_COLORS[aircraft.fmsStatus]}`,
|
||||||
borderBottom:
|
borderBottom:
|
||||||
@@ -195,9 +197,10 @@ const AircraftPopupContent = ({
|
|||||||
>
|
>
|
||||||
<span className="text-base font-medium text-white">Einsatz</span>
|
<span className="text-base font-medium text-white">Einsatz</span>
|
||||||
<br />
|
<br />
|
||||||
<span className="text-sm font-medium text-white">
|
{!mission?.publicId && <span className="text-sm text-gray-400">Kein Einsatz</span>}
|
||||||
{mission?.publicId || "kein Einsatz"}
|
{mission?.publicId && (
|
||||||
</span>
|
<span className="text-sm font-medium text-white">{mission.publicId}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="flex cursor-pointer items-center justify-center px-4"
|
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 markerRef = useRef<LMarker>(null);
|
||||||
const popupRef = useRef<LPopup>(null);
|
const popupRef = useRef<LPopup>(null);
|
||||||
|
|
||||||
const { openAircraftMarker, setOpenAircraftMarker } = useMapStore((store) => store);
|
const { openAircraftMarker, setOpenAircraftMarker, userSettings } = useMapStore((store) => store);
|
||||||
const { data: positionLog } = useQuery({
|
const { data: positionLog } = useQuery({
|
||||||
queryKey: ["positionlog", aircraft.id],
|
queryKey: ["positionlog", aircraft.id],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
@@ -263,14 +266,16 @@ const AircraftMarker = ({ aircraft }: { aircraft: ConnectedAircraft & { Station:
|
|||||||
return () => {
|
return () => {
|
||||||
marker?.off("click", handleClick);
|
marker?.off("click", handleClick);
|
||||||
};
|
};
|
||||||
}, [aircraft.id, openAircraftMarker, setOpenAircraftMarker]);
|
}, [aircraft.id, openAircraftMarker, setOpenAircraftMarker, userSettings]);
|
||||||
|
|
||||||
const [anchor, setAnchor] = useState<"topleft" | "topright" | "bottomleft" | "bottomright">(
|
const [anchor, setAnchor] = useState<"topleft" | "topright" | "bottomleft" | "bottomright">(
|
||||||
"topleft",
|
"topleft",
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleConflict = useCallback(() => {
|
const handleConflict = useCallback(() => {
|
||||||
const newAnchor = calculateAnchor(`aircraft-${aircraft.id}`, "marker");
|
const newAnchor = calculateAnchor(`aircraft-${aircraft.id}`, "marker", {
|
||||||
|
ignoreCluster: true,
|
||||||
|
});
|
||||||
setAnchor(newAnchor);
|
setAnchor(newAnchor);
|
||||||
}, [aircraft.id]);
|
}, [aircraft.id]);
|
||||||
|
|
||||||
@@ -372,7 +377,7 @@ const AircraftMarker = ({ aircraft }: { aircraft: ConnectedAircraft & { Station:
|
|||||||
closeOnClick={false}
|
closeOnClick={false}
|
||||||
autoPan={false}
|
autoPan={false}
|
||||||
wrapperClassName="relative"
|
wrapperClassName="relative"
|
||||||
className="w-[502px]"
|
className="w-[512px]"
|
||||||
>
|
>
|
||||||
<div style={{ height: "auto", maxHeight: "90vh", overflowY: "auto" }}>
|
<div style={{ height: "auto", maxHeight: "90vh", overflowY: "auto" }}>
|
||||||
<AircraftPopupContent aircraft={aircraft} />
|
<AircraftPopupContent aircraft={aircraft} />
|
||||||
|
|||||||
@@ -20,9 +20,12 @@ export const ContextMenu = () => {
|
|||||||
setSearchPopup,
|
setSearchPopup,
|
||||||
toggleSearchElementSelection,
|
toggleSearchElementSelection,
|
||||||
} = useMapStore();
|
} = useMapStore();
|
||||||
const { missionFormValues, setMissionFormValues, setOpen, isOpen } = usePannelStore(
|
const {
|
||||||
(state) => state,
|
missionFormValues,
|
||||||
);
|
setMissionFormValues,
|
||||||
|
setOpen,
|
||||||
|
isOpen: isPannelOpen,
|
||||||
|
} = usePannelStore((state) => state);
|
||||||
const [showRulerOptions, setShowRulerOptions] = useState(false);
|
const [showRulerOptions, setShowRulerOptions] = useState(false);
|
||||||
const [rulerHover, setRulerHover] = useState(false);
|
const [rulerHover, setRulerHover] = useState(false);
|
||||||
const [rulerOptionsHover, setRulerOptionsHover] = useState(false);
|
const [rulerOptionsHover, setRulerOptionsHover] = useState(false);
|
||||||
@@ -53,7 +56,8 @@ export const ContextMenu = () => {
|
|||||||
|
|
||||||
if (!contextMenu || !dispatcherConnected) return null;
|
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 addOSMobjects = async (ignorePreviosSelected?: boolean) => {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
@@ -101,13 +105,13 @@ export const ContextMenu = () => {
|
|||||||
autoPan={false}
|
autoPan={false}
|
||||||
>
|
>
|
||||||
<div
|
<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" }}
|
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 */}
|
{/* Top Button */}
|
||||||
<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}
|
data-tip={missionBtnText}
|
||||||
style={{ transform: "translateX(-50%)" }}
|
style={{ transform: "translateX(-50%)" }}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
@@ -131,17 +135,18 @@ export const ContextMenu = () => {
|
|||||||
if (closestObject) {
|
if (closestObject) {
|
||||||
toggleSearchElementSelection(closestObject.wayID, true);
|
toggleSearchElementSelection(closestObject.wayID, true);
|
||||||
}
|
}
|
||||||
|
if (isPannelOpen) {
|
||||||
map.setView([contextMenu.lat, contextMenu.lng], 18, {
|
map.setView([contextMenu.lat, contextMenu.lng], 18, {
|
||||||
animate: true,
|
animate: true,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MapPinned size={20} />
|
<MapPinned size={20} />
|
||||||
</button>
|
</button>
|
||||||
{/* Left Button */}
|
{/* Left Button */}
|
||||||
<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%)" }}
|
style={{ transform: "translateY(-50%)" }}
|
||||||
onMouseEnter={() => setRulerHover(true)}
|
onMouseEnter={() => setRulerHover(true)}
|
||||||
onMouseLeave={() => setRulerHover(false)}
|
onMouseLeave={() => setRulerHover(false)}
|
||||||
@@ -151,7 +156,7 @@ export const ContextMenu = () => {
|
|||||||
</button>
|
</button>
|
||||||
{/* Bottom Button */}
|
{/* Bottom Button */}
|
||||||
<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"
|
data-tip="Koordinaten kopieren"
|
||||||
style={{ transform: "translateX(-50%)" }}
|
style={{ transform: "translateX(-50%)" }}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
@@ -164,7 +169,7 @@ export const ContextMenu = () => {
|
|||||||
</button>
|
</button>
|
||||||
{/* Right Button (original Search button) */}
|
{/* Right Button (original Search button) */}
|
||||||
<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"
|
data-tip="Gebäude suchen"
|
||||||
style={{ transform: "translateY(-50%)" }}
|
style={{ transform: "translateY(-50%)" }}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
@@ -176,7 +181,7 @@ export const ContextMenu = () => {
|
|||||||
{/* Ruler Options - shown when Ruler button is hovered or options are hovered */}
|
{/* Ruler Options - shown when Ruler button is hovered or options are hovered */}
|
||||||
{showRulerOptions && (
|
{showRulerOptions && (
|
||||||
<div
|
<div
|
||||||
className="absolute flex flex-col items-center pointer-events-auto"
|
className="pointer-events-auto absolute flex flex-col items-center"
|
||||||
style={{
|
style={{
|
||||||
left: "-100px", // position to the right of the left button
|
left: "-100px", // position to the right of the left button
|
||||||
top: "50%",
|
top: "50%",
|
||||||
@@ -200,7 +205,7 @@ export const ContextMenu = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<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="Strecke Messen"
|
data-tip="Strecke Messen"
|
||||||
style={{
|
style={{
|
||||||
transform: "translateX(100%)",
|
transform: "translateX(100%)",
|
||||||
@@ -212,7 +217,7 @@ export const ContextMenu = () => {
|
|||||||
<RulerDimensionLine size={20} />
|
<RulerDimensionLine size={20} />
|
||||||
</button>
|
</button>
|
||||||
<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"
|
data-tip="Radius Messen"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
/* ... */
|
/* ... */
|
||||||
@@ -221,7 +226,7 @@ export const ContextMenu = () => {
|
|||||||
<Radius size={20} />
|
<Radius size={20} />
|
||||||
</button>
|
</button>
|
||||||
<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"
|
data-tip="Fläche Messen"
|
||||||
style={{
|
style={{
|
||||||
transform: "translateX(100%)",
|
transform: "translateX(100%)",
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ const Map = () => {
|
|||||||
return (
|
return (
|
||||||
<MapContainer
|
<MapContainer
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className="flex-1 bg-base-200"
|
className="bg-base-200 z-10 flex-1"
|
||||||
center={map.center}
|
center={map.center}
|
||||||
zoom={map.zoom}
|
zoom={map.zoom}
|
||||||
fadeAnimation={false}
|
fadeAnimation={false}
|
||||||
|
|||||||
@@ -209,7 +209,15 @@ const MissionPopupContent = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const MissionMarker = ({ mission }: { mission: Mission }) => {
|
const MissionMarker = ({
|
||||||
|
mission,
|
||||||
|
options,
|
||||||
|
}: {
|
||||||
|
mission: Mission;
|
||||||
|
options: {
|
||||||
|
hideDetailedKeyword?: boolean;
|
||||||
|
};
|
||||||
|
}) => {
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
const [hideMarker, setHideMarker] = useState(false);
|
const [hideMarker, setHideMarker] = useState(false);
|
||||||
const { editingMissionId, missionFormValues } = usePannelStore((state) => state);
|
const { editingMissionId, missionFormValues } = usePannelStore((state) => state);
|
||||||
@@ -222,7 +230,7 @@ const MissionMarker = ({ mission }: { mission: Mission }) => {
|
|||||||
refetchInterval: 10000,
|
refetchInterval: 10000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { openMissionMarker, setOpenMissionMarker } = useMapStore((store) => store);
|
const { openMissionMarker, setOpenMissionMarker, userSettings } = useMapStore((store) => store);
|
||||||
|
|
||||||
const needsAction =
|
const needsAction =
|
||||||
HPGValidationRequired(mission.missionStationIds, aircrafts, mission.hpgMissionString) &&
|
HPGValidationRequired(mission.missionStationIds, aircrafts, mission.hpgMissionString) &&
|
||||||
@@ -245,7 +253,7 @@ const MissionMarker = ({ mission }: { mission: Mission }) => {
|
|||||||
tab: "home",
|
tab: "home",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
close: [],
|
close: openMissionMarker?.map((m) => m.id) || [],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -254,14 +262,16 @@ const MissionMarker = ({ mission }: { mission: Mission }) => {
|
|||||||
return () => {
|
return () => {
|
||||||
markerCopy?.off("click", handleClick);
|
markerCopy?.off("click", handleClick);
|
||||||
};
|
};
|
||||||
}, [mission.id, openMissionMarker, setOpenMissionMarker]);
|
}, [mission.id, openMissionMarker, setOpenMissionMarker, userSettings]);
|
||||||
|
|
||||||
const [anchor, setAnchor] = useState<"topleft" | "topright" | "bottomleft" | "bottomright">(
|
const [anchor, setAnchor] = useState<"topleft" | "topright" | "bottomleft" | "bottomright">(
|
||||||
"topleft",
|
"topleft",
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleConflict = useCallback(() => {
|
const handleConflict = useCallback(() => {
|
||||||
const newAnchor = calculateAnchor(`mission-${mission.id.toString()}`, "marker");
|
const newAnchor = calculateAnchor(`mission-${mission.id.toString()}`, "marker", {
|
||||||
|
ignoreCluster: true,
|
||||||
|
});
|
||||||
setAnchor(newAnchor);
|
setAnchor(newAnchor);
|
||||||
}, [mission.id]);
|
}, [mission.id]);
|
||||||
|
|
||||||
@@ -318,7 +328,7 @@ const MissionMarker = ({ mission }: { mission: Mission }) => {
|
|||||||
"
|
"
|
||||||
></div>
|
></div>
|
||||||
<span class="text-white text-[15px] text-nowrap">
|
<span class="text-white text-[15px] text-nowrap">
|
||||||
${mission.missionKeywordAbbreviation} ${mission.missionKeywordName}
|
${mission.missionKeywordAbbreviation} ${options.hideDetailedKeyword ? "" : mission.missionKeywordName}
|
||||||
</span>
|
</span>
|
||||||
<div
|
<div
|
||||||
data-anchor-lat="${mission.addressLat}"
|
data-anchor-lat="${mission.addressLat}"
|
||||||
@@ -396,6 +406,11 @@ export const MissionLayer = () => {
|
|||||||
selectedStation,
|
selectedStation,
|
||||||
} = usePilotConnectionStore((state) => state);
|
} = usePilotConnectionStore((state) => state);
|
||||||
|
|
||||||
|
const { data: aircrafts = [] } = useQuery({
|
||||||
|
queryKey: ["aircrafts"],
|
||||||
|
queryFn: () => getConnectedAircraftsAPI(),
|
||||||
|
refetchInterval: 10_000,
|
||||||
|
});
|
||||||
const { data: missions = [] } = useQuery({
|
const { data: missions = [] } = useQuery({
|
||||||
queryKey: ["missions"],
|
queryKey: ["missions"],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
@@ -426,7 +441,15 @@ export const MissionLayer = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{filteredMissions.map((mission) => {
|
{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,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
})}
|
})}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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(() => {
|
const lstName = useMemo(() => {
|
||||||
if (!aircraft.posLng || !aircraft.posLat) return station.bosRadioArea;
|
if (!aircraft.posLng || !aircraft.posLat) return station.bosRadioArea;
|
||||||
|
|||||||
@@ -21,7 +21,13 @@ const PopupContent = ({
|
|||||||
missions: Mission[];
|
missions: Mission[];
|
||||||
}) => {
|
}) => {
|
||||||
const { anchor } = useSmartPopup();
|
const { anchor } = useSmartPopup();
|
||||||
const { setOpenAircraftMarker, setOpenMissionMarker } = useMapStore((state) => state);
|
const {
|
||||||
|
setOpenAircraftMarker,
|
||||||
|
setOpenMissionMarker,
|
||||||
|
openAircraftMarker,
|
||||||
|
openMissionMarker,
|
||||||
|
userSettings,
|
||||||
|
} = useMapStore((state) => state);
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
|
|
||||||
let borderColor = "";
|
let borderColor = "";
|
||||||
@@ -42,10 +48,10 @@ const PopupContent = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="relative flex flex-col text-white min-w-[200px]">
|
<div className="relative flex min-w-fit flex-col text-white">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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("left") ? "-left-[2px]" : "-right-[2px]",
|
||||||
anchor.includes("top") ? "-top-[2px]" : "-bottom-[2px]",
|
anchor.includes("top") ? "-top-[2px]" : "-bottom-[2px]",
|
||||||
)}
|
)}
|
||||||
@@ -68,7 +74,7 @@ const PopupContent = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={mission.id}
|
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={{
|
style={{
|
||||||
backgroundColor: markerColor,
|
backgroundColor: markerColor,
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
@@ -77,6 +83,17 @@ const PopupContent = ({
|
|||||||
<span
|
<span
|
||||||
className="mx-2 my-0.5 flex-1 cursor-pointer"
|
className="mx-2 my-0.5 flex-1 cursor-pointer"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (userSettings.settingsAutoCloseMapPopup) {
|
||||||
|
setOpenMissionMarker({
|
||||||
|
open: [
|
||||||
|
{
|
||||||
|
id: mission.id,
|
||||||
|
tab: "home",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
close: openMissionMarker?.map((m) => m.id) || [],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
setOpenMissionMarker({
|
setOpenMissionMarker({
|
||||||
open: [
|
open: [
|
||||||
{
|
{
|
||||||
@@ -86,6 +103,7 @@ const PopupContent = ({
|
|||||||
],
|
],
|
||||||
close: [],
|
close: [],
|
||||||
});
|
});
|
||||||
|
}
|
||||||
map.setView([mission.addressLat, mission.addressLng], 12, {
|
map.setView([mission.addressLat, mission.addressLng], 12, {
|
||||||
animate: true,
|
animate: true,
|
||||||
});
|
});
|
||||||
@@ -99,34 +117,50 @@ const PopupContent = ({
|
|||||||
{aircrafts.map((aircraft) => (
|
{aircrafts.map((aircraft) => (
|
||||||
<div
|
<div
|
||||||
key={aircraft.id}
|
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={{
|
style={{
|
||||||
backgroundColor: FMS_STATUS_COLORS[aircraft.fmsStatus],
|
backgroundColor: FMS_STATUS_COLORS[aircraft.fmsStatus],
|
||||||
}}
|
}}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (userSettings.settingsAutoCloseMapPopup) {
|
||||||
setOpenAircraftMarker({
|
setOpenAircraftMarker({
|
||||||
open: [
|
open: [
|
||||||
{
|
{
|
||||||
id: aircraft.id,
|
id: aircraft.id,
|
||||||
tab: "aircraft",
|
tab: "home",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
close: openAircraftMarker?.map((m) => m.id) || [],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setOpenAircraftMarker({
|
||||||
|
open: [
|
||||||
|
{
|
||||||
|
id: aircraft.id,
|
||||||
|
tab: "home",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
close: [],
|
close: [],
|
||||||
});
|
});
|
||||||
|
}
|
||||||
map.setView([aircraft.posLat!, aircraft.posLng!], 12, {
|
map.setView([aircraft.posLat!, aircraft.posLng!], 12, {
|
||||||
animate: true,
|
animate: true,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="mx-2 my-0.5 text-gt font-bold"
|
className="text-gt my-0.5 font-bold"
|
||||||
style={{
|
style={{
|
||||||
color: FMS_STATUS_TEXT_COLORS[aircraft.fmsStatus],
|
color: FMS_STATUS_TEXT_COLORS[aircraft.fmsStatus],
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{aircraft.fmsStatus}
|
{aircraft.fmsStatus}
|
||||||
</span>
|
</span>
|
||||||
<span>{aircraft.Station.bosCallsign}</span>
|
<span>
|
||||||
|
{aircraft.Station.bosCallsign.length > 15
|
||||||
|
? aircraft.Station.locationStateShort
|
||||||
|
: aircraft.Station.bosCallsign}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -212,7 +246,7 @@ export const MarkerCluster = () => {
|
|||||||
const lng = aircraft.posLng!;
|
const lng = aircraft.posLng!;
|
||||||
|
|
||||||
const existingClusterIndex = newCluster.findIndex(
|
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];
|
const existingCluster = newCluster[existingClusterIndex];
|
||||||
if (existingCluster) {
|
if (existingCluster) {
|
||||||
@@ -299,7 +333,7 @@ export const MarkerCluster = () => {
|
|||||||
position={[c.lat, c.lng]}
|
position={[c.lat, c.lng]}
|
||||||
autoPan={false}
|
autoPan={false}
|
||||||
autoClose={false}
|
autoClose={false}
|
||||||
className="w-[202px]"
|
className="min-w-fit"
|
||||||
>
|
>
|
||||||
<PopupContent aircrafts={c.aircrafts} missions={c.missions} />
|
<PopupContent aircrafts={c.aircrafts} missions={c.missions} />
|
||||||
</SmartPopup>
|
</SmartPopup>
|
||||||
|
|||||||
@@ -92,6 +92,11 @@ export default function AdminPanel() {
|
|||||||
|
|
||||||
const modalRef = useRef<HTMLDialogElement>(null);
|
const modalRef = useRef<HTMLDialogElement>(null);
|
||||||
|
|
||||||
|
console.debug("piloten von API", {
|
||||||
|
anzahl: pilots?.length,
|
||||||
|
pilots,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
@@ -108,11 +113,11 @@ export default function AdminPanel() {
|
|||||||
<form method="dialog">
|
<form method="dialog">
|
||||||
<button className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
|
<button className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
|
||||||
</form>
|
</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
|
<Shield size={22} /> Admin Panel
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex gap-2 mt-4 w-full">
|
<div className="mt-4 flex w-full gap-2">
|
||||||
<div className="card bg-base-300 shadow-md w-full h-96 overflow-y-auto">
|
<div className="card bg-base-300 h-96 w-full overflow-y-auto shadow-md">
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<div className="card-title flex items-center gap-2">
|
<div className="card-title flex items-center gap-2">
|
||||||
<UserCheck size={20} /> Verbundene Clients
|
<UserCheck size={20} /> Verbundene Clients
|
||||||
|
|||||||
34
apps/dispatch/app/_components/navbar/ChangelogWrapper.tsx
Normal file
34
apps/dispatch/app/_components/navbar/ChangelogWrapper.tsx
Normal 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");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -203,7 +203,7 @@ export const useAudioStore = create<TalkState>((set, get) => ({
|
|||||||
set({ state: "connected", room, message: null });
|
set({ state: "connected", room, message: null });
|
||||||
})
|
})
|
||||||
.on(RoomEvent.Disconnected, () => {
|
.on(RoomEvent.Disconnected, () => {
|
||||||
set({ state: "disconnected" });
|
set({ state: "disconnected", speakingParticipants: [], transmitBlocked: false });
|
||||||
|
|
||||||
handleDisconnect();
|
handleDisconnect();
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,7 +11,13 @@ interface ConnectionStore {
|
|||||||
message: string;
|
message: string;
|
||||||
selectedZone: string;
|
selectedZone: string;
|
||||||
logoffTime: 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;
|
disconnect: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,11 +29,12 @@ export const useDispatchConnectionStore = create<ConnectionStore>((set) => ({
|
|||||||
message: "",
|
message: "",
|
||||||
selectedZone: "LST_01",
|
selectedZone: "LST_01",
|
||||||
logoffTime: "",
|
logoffTime: "",
|
||||||
connect: async (uid, selectedZone, logoffTime) =>
|
ghostMode: false,
|
||||||
|
connect: async (uid, selectedZone, logoffTime, ghostMode) =>
|
||||||
new Promise((resolve) => {
|
new Promise((resolve) => {
|
||||||
set({ status: "connecting", message: "" });
|
set({ status: "connecting", message: "" });
|
||||||
dispatchSocket.auth = { uid };
|
dispatchSocket.auth = { uid };
|
||||||
set({ selectedZone, logoffTime });
|
set({ selectedZone, logoffTime, ghostMode });
|
||||||
dispatchSocket.connect();
|
dispatchSocket.connect();
|
||||||
|
|
||||||
dispatchSocket.once("connect", () => {
|
dispatchSocket.once("connect", () => {
|
||||||
@@ -40,11 +47,12 @@ export const useDispatchConnectionStore = create<ConnectionStore>((set) => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
dispatchSocket.on("connect", () => {
|
dispatchSocket.on("connect", () => {
|
||||||
const { logoffTime, selectedZone } = useDispatchConnectionStore.getState();
|
const { logoffTime, selectedZone, ghostMode } = useDispatchConnectionStore.getState();
|
||||||
useAudioStore.getState().connect("LST_01", selectedZone || "Leitstelle");
|
useAudioStore.getState().connect("LST_01", selectedZone || "Leitstelle");
|
||||||
dispatchSocket.emit("connect-dispatch", {
|
dispatchSocket.emit("connect-dispatch", {
|
||||||
logoffTime,
|
logoffTime,
|
||||||
selectedZone,
|
selectedZone,
|
||||||
|
ghostMode,
|
||||||
});
|
});
|
||||||
useDispatchConnectionStore.setState({ status: "connected", message: "" });
|
useDispatchConnectionStore.setState({ status: "connected", message: "" });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -39,12 +39,21 @@ export interface MapStore {
|
|||||||
[aircraftId: string]: "home" | "fms" | "aircraft" | "mission" | "chat";
|
[aircraftId: string]: "home" | "fms" | "aircraft" | "mission" | "chat";
|
||||||
};
|
};
|
||||||
setAircraftTab: (aircraftId: number, tab: MapStore["aircraftTabs"][number]) => void;
|
setAircraftTab: (aircraftId: number, tab: MapStore["aircraftTabs"][number]) => void;
|
||||||
|
userSettings: {
|
||||||
|
settingsAutoCloseMapPopup: boolean;
|
||||||
|
};
|
||||||
|
setUserSettings: (settings: Partial<MapStore["userSettings"]>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useMapStore = create<MapStore>((set, get) => ({
|
export const useMapStore = create<MapStore>((set, get) => ({
|
||||||
openMissionMarker: [],
|
openMissionMarker: [],
|
||||||
setOpenMissionMarker: ({ open, close }) => {
|
setOpenMissionMarker: ({ open, close }) => {
|
||||||
const oldMarkers = get().openMissionMarker.filter(
|
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),
|
(m) => !close.includes(m.id) && !open.find((o) => o.id === m.id),
|
||||||
);
|
);
|
||||||
set(() => ({
|
set(() => ({
|
||||||
@@ -53,7 +62,12 @@ export const useMapStore = create<MapStore>((set, get) => ({
|
|||||||
},
|
},
|
||||||
openAircraftMarker: [],
|
openAircraftMarker: [],
|
||||||
setOpenAircraftMarker: ({ open, close }) => {
|
setOpenAircraftMarker: ({ open, close }) => {
|
||||||
const oldMarkers = get().openAircraftMarker.filter(
|
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),
|
(m) => !close.includes(m.id) && !open.find((o) => o.id === m.id),
|
||||||
);
|
);
|
||||||
set(() => ({
|
set(() => ({
|
||||||
@@ -102,4 +116,14 @@ export const useMapStore = create<MapStore>((set, get) => ({
|
|||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
missionTabs: {},
|
missionTabs: {},
|
||||||
|
userSettings: {
|
||||||
|
settingsAutoCloseMapPopup: false,
|
||||||
|
},
|
||||||
|
setUserSettings: (settings) =>
|
||||||
|
set((state) => ({
|
||||||
|
userSettings: {
|
||||||
|
...state.userSettings,
|
||||||
|
...settings,
|
||||||
|
},
|
||||||
|
})),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export async function GET(request: Request): Promise<NextResponse> {
|
|||||||
const connectedDispatcher = await prisma.connectedDispatcher.findMany({
|
const connectedDispatcher = await prisma.connectedDispatcher.findMany({
|
||||||
where: {
|
where: {
|
||||||
logoutTime: null,
|
logoutTime: null,
|
||||||
|
ghostMode: false, // Ensure we only get non-ghost mode connections
|
||||||
...filter, // Ensure filter is parsed correctly
|
...filter, // Ensure filter is parsed correctly
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export const PUT = async (req: Request) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// TODO: Position Runden
|
||||||
if (activeAircraft.posLat === position.lat && activeAircraft.posLng === position.lng) {
|
if (activeAircraft.posLat === position.lat && activeAircraft.posLng === position.lng) {
|
||||||
return Response.json({ message: "Position has not changed" }, { status: 200 });
|
return Response.json({ message: "Position has not changed" }, { status: 200 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,14 +23,14 @@ export const ConnectedDispatcher = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-w-120">
|
<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" />
|
<input type="checkbox" />
|
||||||
{/* <div className="collapse-title font-semibold">Kein Disponent Online</div> */}
|
{/* <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>
|
<span>
|
||||||
{connections} {connections == 1 ? "Verbundenes Mitglied" : "Verbundene Mitglieder"}
|
{connections} {connections == 1 ? "Verbundenes Mitglied" : "Verbundene Mitglieder"}
|
||||||
</span>
|
</span>
|
||||||
<div className="gap-2 flex items-center">
|
<div className="flex items-center gap-2">
|
||||||
<div
|
<div
|
||||||
className={`badge badge-outline ${
|
className={`badge badge-outline ${
|
||||||
(dispatcher?.length || 0) > 0 ? "badge-success" : "badge-error"
|
(dispatcher?.length || 0) > 0 ? "badge-success" : "badge-error"
|
||||||
@@ -65,7 +65,7 @@ export const ConnectedDispatcher = () => {
|
|||||||
className="tooltip tooltip-right"
|
className="tooltip tooltip-right"
|
||||||
data-tip={`vorraussichtliche Abmeldung in ${formatDistance(new Date(), new Date(d.esimatedLogoutTime), { locale: de })}`}
|
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([], {
|
{new Date(d.esimatedLogoutTime).toLocaleTimeString([], {
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
@@ -76,7 +76,7 @@ export const ConnectedDispatcher = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div>{asPublicUser(d.publicUser).fullName}</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>
|
||||||
<div>
|
<div>
|
||||||
{(() => {
|
{(() => {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "pnpm@10.13.1",
|
"packageManager": "pnpm@10.13.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack -p 3001",
|
"dev": "next dev -p 3001",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint --max-warnings 0",
|
"lint": "next lint --max-warnings 0",
|
||||||
|
|||||||
Binary file not shown.
BIN
apps/dispatch/public/sounds/connection_stoped_sepura_old.mp3
Normal file
BIN
apps/dispatch/public/sounds/connection_stoped_sepura_old.mp3
Normal file
Binary file not shown.
BIN
apps/dispatch/public/sounds/newChat.mp3
Normal file
BIN
apps/dispatch/public/sounds/newChat.mp3
Normal file
Binary file not shown.
26
apps/hub/app/(app)/_components/ChangelogWrapper.tsx
Normal file
26
apps/hub/app/(app)/_components/ChangelogWrapper.tsx
Normal 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");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -2,10 +2,25 @@ import Image from "next/image";
|
|||||||
import { DiscordLogoIcon, InstagramLogoIcon, ReaderIcon } from "@radix-ui/react-icons";
|
import { DiscordLogoIcon, InstagramLogoIcon, ReaderIcon } from "@radix-ui/react-icons";
|
||||||
import YoutubeSvg from "./youtube_wider.svg";
|
import YoutubeSvg from "./youtube_wider.svg";
|
||||||
import FacebookSvg from "./facebook.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 (
|
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 */}
|
{/* Left: Impressum & Datenschutz */}
|
||||||
<div className="flex gap-4 text-sm">
|
<div className="flex gap-4 text-sm">
|
||||||
<a href="https://virtualairrescue.com/impressum/" className="hover:text-primary">
|
<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">
|
<a href="https://virtualairrescue.com/datenschutz/" className="hover:text-primary">
|
||||||
Datenschutzerklärung
|
Datenschutzerklärung
|
||||||
</a>
|
</a>
|
||||||
|
<ChangelogWrapper latestChangelog={latestChangelog} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Center: Copyright */}
|
{/* Center: Copyright */}
|
||||||
@@ -28,7 +44,7 @@ export const Footer = () => {
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="hover:text-primary"
|
className="hover:text-primary"
|
||||||
>
|
>
|
||||||
<DiscordLogoIcon className="w-5 h-5" />
|
<DiscordLogoIcon className="h-5 w-5" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -39,7 +55,7 @@ export const Footer = () => {
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="hover:text-primary text-white"
|
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>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -50,7 +66,7 @@ export const Footer = () => {
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="hover:text-primary text-white"
|
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>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -61,12 +77,12 @@ export const Footer = () => {
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="hover:text-primary"
|
className="hover:text-primary"
|
||||||
>
|
>
|
||||||
<InstagramLogoIcon className="w-5 h-5" />
|
<InstagramLogoIcon className="h-5 w-5" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div className="tooltip tooltip-top" data-tip="Knowledgebase">
|
<div className="tooltip tooltip-top" data-tip="Knowledgebase">
|
||||||
<a href="https://docs.virtualairrescue.com/" className="hover:text-primary">
|
<a href="https://docs.virtualairrescue.com/" className="hover:text-primary">
|
||||||
<ReaderIcon className="w-5 h-5" />
|
<ReaderIcon className="h-5 w-5" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
13
apps/hub/app/(app)/admin/changelog/[id]/page.tsx
Normal file
13
apps/hub/app/(app)/admin/changelog/[id]/page.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
156
apps/hub/app/(app)/admin/changelog/_components/Form.tsx
Normal file
156
apps/hub/app/(app)/admin/changelog/_components/Form.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
27
apps/hub/app/(app)/admin/changelog/action.ts
Normal file
27
apps/hub/app/(app)/admin/changelog/action.ts
Normal 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 } });
|
||||||
|
};
|
||||||
19
apps/hub/app/(app)/admin/changelog/layout.tsx
Normal file
19
apps/hub/app/(app)/admin/changelog/layout.tsx
Normal 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;
|
||||||
5
apps/hub/app/(app)/admin/changelog/new/page.tsx
Normal file
5
apps/hub/app/(app)/admin/changelog/new/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { ChangelogForm } from "../_components/Form";
|
||||||
|
|
||||||
|
export default () => {
|
||||||
|
return <ChangelogForm />;
|
||||||
|
};
|
||||||
49
apps/hub/app/(app)/admin/changelog/page.tsx
Normal file
49
apps/hub/app/(app)/admin/changelog/page.tsx
Normal 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>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -253,7 +253,7 @@ export const Form = ({ event }: { event?: Event }) => {
|
|||||||
<PaginatedTable
|
<PaginatedTable
|
||||||
leftOfSearch={
|
leftOfSearch={
|
||||||
<h2 className="card-title">
|
<h2 className="card-title">
|
||||||
<Calendar className="h-5 w-5" /> Teilnehmer
|
<UserIcon className="h-5 w-5" /> Teilnehmer
|
||||||
</h2>
|
</h2>
|
||||||
}
|
}
|
||||||
searchFields={["User.firstname", "User.lastname", "User.publicId"]}
|
searchFields={["User.firstname", "User.lastname", "User.publicId"]}
|
||||||
|
|||||||
@@ -73,7 +73,10 @@ export const ProfileForm: React.FC<ProfileFormProps> = ({ user }: ProfileFormPro
|
|||||||
className="card-body"
|
className="card-body"
|
||||||
onSubmit={form.handleSubmit(async (values) => {
|
onSubmit={form.handleSubmit(async (values) => {
|
||||||
if (!values.id) return;
|
if (!values.id) return;
|
||||||
await editUser(values.id, values);
|
await editUser(values.id, {
|
||||||
|
...values,
|
||||||
|
email: values.email.toLowerCase(),
|
||||||
|
});
|
||||||
form.reset(values);
|
form.reset(values);
|
||||||
toast.success("Deine Änderungen wurden gespeichert!", {
|
toast.success("Deine Änderungen wurden gespeichert!", {
|
||||||
style: {
|
style: {
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ export default async function RootLayout({
|
|||||||
>
|
>
|
||||||
<div className="hero-overlay bg-opacity-30"></div>
|
<div className="hero-overlay bg-opacity-30"></div>
|
||||||
{/* Card */}
|
{/* Card */}
|
||||||
<div className="hero-content text-neutral-content text-center w-full max-w-full h-full m-10">
|
<div className="hero-content text-neutral-content m-10 h-full w-full max-w-full text-center">
|
||||||
<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="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 */}
|
{/* Top Navbar */}
|
||||||
<HorizontalNav />
|
<HorizontalNav />
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ export default async function RootLayout({
|
|||||||
<VerticalNav />
|
<VerticalNav />
|
||||||
|
|
||||||
{/* Scrollbarer Content-Bereich */}
|
{/* 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 />
|
<Penalty />
|
||||||
{!session?.user.emailVerified && (
|
{!session?.user.emailVerified && (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
@@ -50,6 +50,7 @@ export default async function RootLayout({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!session.user.pathSelected && <FirstPath />}
|
{!session.user.pathSelected && <FirstPath />}
|
||||||
|
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -91,7 +91,10 @@ export const ProfileForm = ({
|
|||||||
className="card-body"
|
className="card-body"
|
||||||
onSubmit={form.handleSubmit(async (values) => {
|
onSubmit={form.handleSubmit(async (values) => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
await updateUser(values);
|
await updateUser({
|
||||||
|
...values,
|
||||||
|
email: values.email.toLowerCase(),
|
||||||
|
});
|
||||||
if (discordAccount) {
|
if (discordAccount) {
|
||||||
await setStandardName({
|
await setStandardName({
|
||||||
memberId: discordAccount.discordId,
|
memberId: discordAccount.discordId,
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export const Login = () => {
|
|||||||
try {
|
try {
|
||||||
const data = await signIn("credentials", {
|
const data = await signIn("credentials", {
|
||||||
redirect: false,
|
redirect: false,
|
||||||
email: form.getValues("email"),
|
email: form.getValues("email").toLowerCase(),
|
||||||
password: form.getValues("password"),
|
password: form.getValues("password"),
|
||||||
});
|
});
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -62,12 +62,12 @@ export const Login = () => {
|
|||||||
Registrierung
|
Registrierung
|
||||||
</Link>
|
</Link>
|
||||||
</span>
|
</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 warst bereits Nutzer der V1? <br />
|
||||||
Melde dich mit deinen alten Zugangsdaten an.
|
Melde dich mit deinen alten Zugangsdaten an.
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 mb-2">
|
<div className="mb-2 mt-5">
|
||||||
<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
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 16 16"
|
viewBox="0 0 16 16"
|
||||||
@@ -84,7 +84,7 @@ export const Login = () => {
|
|||||||
? form.formState.errors.email.message
|
? form.formState.errors.email.message
|
||||||
: ""}
|
: ""}
|
||||||
</p>
|
</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
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 16 16"
|
viewBox="0 0 16 16"
|
||||||
@@ -105,7 +105,7 @@ export const Login = () => {
|
|||||||
className="grow"
|
className="grow"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<span className="text-sm font-medium flex justify-end">
|
<span className="flex justify-end text-sm font-medium">
|
||||||
<Link href="/passwort-reset" className="link link-accent link-hover">
|
<Link href="/passwort-reset" className="link link-accent link-hover">
|
||||||
Passwort vergessen?
|
Passwort vergessen?
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export const PasswortReset = () => {
|
|||||||
onSubmit={form.handleSubmit(async () => {
|
onSubmit={form.handleSubmit(async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const { error } = await resetPassword(form.getValues().email);
|
const { error } = await resetPassword(form.getValues().email.toLowerCase());
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -56,7 +56,7 @@ export const PasswortReset = () => {
|
|||||||
<Toaster position="top-center" reverseOrder={false} />
|
<Toaster position="top-center" reverseOrder={false} />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-3xl font-bold">Passwort zurücksetzen</h1>
|
<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
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 16 16"
|
viewBox="0 0 16 16"
|
||||||
@@ -73,7 +73,7 @@ export const PasswortReset = () => {
|
|||||||
? form.formState.errors.email.message
|
? form.formState.errors.email.message
|
||||||
: ""}
|
: ""}
|
||||||
</p>
|
</p>
|
||||||
<span className="text-sm font-medium flex justify-end">
|
<span className="flex justify-end text-sm font-medium">
|
||||||
<Link href="/login" className="link link-accent link-hover">
|
<Link href="/login" className="link link-accent link-hover">
|
||||||
zum Login
|
zum Login
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export const resetPassword = async (email: string) => {
|
|||||||
email,
|
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 (!user) {
|
||||||
if (oldUser) {
|
if (oldUser) {
|
||||||
user = await createNewUserFromOld(oldUser);
|
user = await createNewUserFromOld(oldUser);
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export const Register = () => {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const values = form.getValues();
|
const values = form.getValues();
|
||||||
const user = await register({
|
const user = await register({
|
||||||
email: form.getValues("email"),
|
email: form.getValues("email").toLowerCase(),
|
||||||
password: form.getValues("password"),
|
password: form.getValues("password"),
|
||||||
firstname: form.getValues("firstname"),
|
firstname: form.getValues("firstname"),
|
||||||
lastname: form.getValues("lastname"),
|
lastname: form.getValues("lastname"),
|
||||||
@@ -116,13 +116,13 @@ export const Register = () => {
|
|||||||
Login
|
Login
|
||||||
</Link>
|
</Link>
|
||||||
</span>
|
</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 warst bereits Nutzer der V1? <br />
|
||||||
Du musst keinen neuen Account erstellen, sondern kannst dich mit deinen alten Zugangsdaten
|
Du musst keinen neuen Account erstellen, sondern kannst dich mit deinen alten Zugangsdaten
|
||||||
anmelden.
|
anmelden.
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 mb-2">
|
<div className="mb-2 mt-5">
|
||||||
<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
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 16 16"
|
viewBox="0 0 16 16"
|
||||||
@@ -143,7 +143,7 @@ export const Register = () => {
|
|||||||
? form.formState.errors.firstname.message
|
? form.formState.errors.firstname.message
|
||||||
: ""}
|
: ""}
|
||||||
</p>
|
</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
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 16 16"
|
viewBox="0 0 16 16"
|
||||||
@@ -165,7 +165,7 @@ export const Register = () => {
|
|||||||
: ""}
|
: ""}
|
||||||
</p>
|
</p>
|
||||||
<div className="divider">Account</div>
|
<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
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 16 16"
|
viewBox="0 0 16 16"
|
||||||
@@ -183,7 +183,7 @@ export const Register = () => {
|
|||||||
? form.formState.errors.email.message
|
? form.formState.errors.email.message
|
||||||
: ""}
|
: ""}
|
||||||
</p>
|
</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
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 16 16"
|
viewBox="0 0 16 16"
|
||||||
@@ -209,7 +209,7 @@ export const Register = () => {
|
|||||||
? form.formState.errors.password.message
|
? form.formState.errors.password.message
|
||||||
: ""}
|
: ""}
|
||||||
</p>
|
</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
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 16 16"
|
viewBox="0 0 16 16"
|
||||||
|
|||||||
@@ -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) {
|
if (existingUser) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export const VerticalNav = async () => {
|
|||||||
return p.startsWith("ADMIN");
|
return p.startsWith("ADMIN");
|
||||||
});
|
});
|
||||||
return (
|
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>
|
<li>
|
||||||
<Link href="/">
|
<Link href="/">
|
||||||
<HomeIcon /> Dashboard
|
<HomeIcon /> Dashboard
|
||||||
@@ -99,6 +99,11 @@ export const VerticalNav = async () => {
|
|||||||
<Link href="/admin/penalty">Audit-Log</Link>
|
<Link href="/admin/penalty">Audit-Log</Link>
|
||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
|
{session.user.permissions.includes("ADMIN_CHANGELOG") && (
|
||||||
|
<li>
|
||||||
|
<Link href="/admin/changelog">Changelog</Link>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
</details>
|
</details>
|
||||||
</li>
|
</li>
|
||||||
@@ -112,7 +117,7 @@ export const HorizontalNav = async () => {
|
|||||||
if (!session?.user) return <Error statusCode={401} title="Benutzer nicht authentifiziert!" />;
|
if (!session?.user) return <Error statusCode={401} title="Benutzer nicht authentifiziert!" />;
|
||||||
|
|
||||||
return (
|
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">
|
<div className="flex items-center">
|
||||||
<Link href="/" className="flex items-center">
|
<Link href="/" className="flex items-center">
|
||||||
<Image
|
<Image
|
||||||
@@ -123,12 +128,12 @@ export const HorizontalNav = async () => {
|
|||||||
className="ml-2 mr-3"
|
className="ml-2 mr-3"
|
||||||
priority
|
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>
|
</Link>
|
||||||
<WarningAlert />
|
<WarningAlert />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center ml-auto">
|
<div className="ml-auto flex items-center">
|
||||||
<ul className="flex space-x-2 px-1 items-center">
|
<ul className="flex items-center space-x-2 px-1">
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
href={process.env.NEXT_PUBLIC_DISPATCH_URL + "/tracker"}
|
href={process.env.NEXT_PUBLIC_DISPATCH_URL + "/tracker"}
|
||||||
|
|||||||
@@ -18,9 +18,16 @@ export const options: AuthOptions = {
|
|||||||
try {
|
try {
|
||||||
if (!credentials) throw new Error("No credentials provided");
|
if (!credentials) throw new Error("No credentials provided");
|
||||||
const user = await prisma.user.findFirst({
|
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 (!user && v1User) {
|
||||||
if (bcrypt.compareSync(credentials.password, v1User.password)) {
|
if (bcrypt.compareSync(credentials.password, v1User.password)) {
|
||||||
const newUser = await createNewUserFromOld(v1User);
|
const newUser = await createNewUserFromOld(v1User);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "pnpm@10.13.1",
|
"packageManager": "pnpm@10.13.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack -p 3000",
|
"dev": "next dev -p 3000",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
|
|||||||
BIN
apps/hub/public/changelogImages/Frog.jpg
Normal file
BIN
apps/hub/public/changelogImages/Frog.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 447 KiB |
@@ -31,6 +31,29 @@ export interface OldUser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const createNewUserFromOld = async (oldUser: 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({
|
const newUser = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
email: oldUser.email,
|
email: oldUser.email,
|
||||||
@@ -38,7 +61,7 @@ export const createNewUserFromOld = async (oldUser: OldUser) => {
|
|||||||
migratedFromV1: true,
|
migratedFromV1: true,
|
||||||
firstname: oldUser.firstname,
|
firstname: oldUser.firstname,
|
||||||
lastname: oldUser.lastname,
|
lastname: oldUser.lastname,
|
||||||
publicId: oldUser.publicId,
|
publicId: varPublicId,
|
||||||
badges: [
|
badges: [
|
||||||
...oldUser.badges
|
...oldUser.badges
|
||||||
.map((badge) => {
|
.map((badge) => {
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export interface StationStatus {
|
|||||||
data?: {
|
data?: {
|
||||||
stationId: number;
|
stationId: number;
|
||||||
aircraftId: number;
|
aircraftId: number;
|
||||||
|
userId?: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
7
packages/database/prisma/schema/changelog.prisma
Normal file
7
packages/database/prisma/schema/changelog.prisma
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
model Changelog {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
title String
|
||||||
|
previewImage String?
|
||||||
|
text String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ model ConnectedDispatcher {
|
|||||||
zone String @default("LST_1")
|
zone String @default("LST_1")
|
||||||
esimatedLogoutTime DateTime?
|
esimatedLogoutTime DateTime?
|
||||||
logoutTime DateTime?
|
logoutTime DateTime?
|
||||||
|
ghostMode Boolean @default(false)
|
||||||
|
|
||||||
// relations:
|
// relations:
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "ConnectedDispatcher" ADD COLUMN "ghostMode" BOOLEAN NOT NULL DEFAULT false;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Changelog" ALTER COLUMN "previewImage" SET DATA TYPE TEXT;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Changelog" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "users" ADD COLUMN "changelogAck" BOOLEAN NOT NULL DEFAULT false;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "PERMISSION" ADD VALUE 'ADMIN_CHANGELOG';
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "BosUse" ADD VALUE 'SPECIAL_OPS';
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "users" ADD COLUMN "settings_auto_close_map_popup" BOOLEAN NOT NULL DEFAULT false;
|
||||||
@@ -2,6 +2,7 @@ enum BosUse {
|
|||||||
PRIMARY
|
PRIMARY
|
||||||
SECONDARY
|
SECONDARY
|
||||||
DUAL_USE
|
DUAL_USE
|
||||||
|
SPECIAL_OPS
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Country {
|
enum Country {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ enum PERMISSION {
|
|||||||
ADMIN_MESSAGE
|
ADMIN_MESSAGE
|
||||||
ADMIN_KICK
|
ADMIN_KICK
|
||||||
ADMIN_HELIPORT
|
ADMIN_HELIPORT
|
||||||
|
ADMIN_CHANGELOG
|
||||||
AUDIO
|
AUDIO
|
||||||
PILOT
|
PILOT
|
||||||
DISPO
|
DISPO
|
||||||
@@ -33,6 +34,7 @@ model User {
|
|||||||
password String
|
password String
|
||||||
vatsimCid String? @map(name: "vatsim_cid")
|
vatsimCid String? @map(name: "vatsim_cid")
|
||||||
moodleId Int? @map(name: "moodle_id")
|
moodleId Int? @map(name: "moodle_id")
|
||||||
|
changelogAck Boolean @default(false)
|
||||||
|
|
||||||
// Settings:
|
// Settings:
|
||||||
pathSelected Boolean @default(false)
|
pathSelected Boolean @default(false)
|
||||||
@@ -43,6 +45,7 @@ model User {
|
|||||||
settingsDmeVolume Float? @map(name: "settings_dme_volume")
|
settingsDmeVolume Float? @map(name: "settings_dme_volume")
|
||||||
settingsRadioVolume Float? @map(name: "settings_funk_volume")
|
settingsRadioVolume Float? @map(name: "settings_funk_volume")
|
||||||
settingsHideLastname Boolean @default(false) @map(name: "settings_hide_lastname")
|
settingsHideLastname Boolean @default(false) @map(name: "settings_hide_lastname")
|
||||||
|
settingsAutoCloseMapPopup Boolean @default(false) @map(name: "settings_auto_close_map_popup")
|
||||||
|
|
||||||
// email Verification:
|
// email Verification:
|
||||||
emailVerificationToken String? @map(name: "email_verification_token")
|
emailVerificationToken String? @map(name: "email_verification_token")
|
||||||
|
|||||||
102
packages/shared-components/components/Changelog.tsx
Normal file
102
packages/shared-components/components/Changelog.tsx
Normal 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();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -2,3 +2,4 @@ export * from "./Badge";
|
|||||||
export * from "./PenaltyDropdown";
|
export * from "./PenaltyDropdown";
|
||||||
export * from "./Maintenance";
|
export * from "./Maintenance";
|
||||||
export * from "./Button";
|
export * from "./Button";
|
||||||
|
export * from "./Changelog";
|
||||||
|
|||||||
@@ -10,8 +10,10 @@
|
|||||||
"@repo/db": "workspace:*",
|
"@repo/db": "workspace:*",
|
||||||
"@repo/typescript-config": "workspace:*",
|
"@repo/typescript-config": "workspace:*",
|
||||||
"@types/node": "^22.15.29",
|
"@types/node": "^22.15.29",
|
||||||
|
"@uiw/react-md-editor": "^4.0.8",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"lucide-react": "^0.525.0",
|
||||||
"tailwind-merge": "^3.3.1"
|
"tailwind-merge": "^3.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@@ -618,12 +618,18 @@ importers:
|
|||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22.15.29
|
specifier: ^22.15.29
|
||||||
version: 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:
|
clsx:
|
||||||
specifier: ^2.1.1
|
specifier: ^2.1.1
|
||||||
version: 2.1.1
|
version: 2.1.1
|
||||||
date-fns:
|
date-fns:
|
||||||
specifier: ^4.1.0
|
specifier: ^4.1.0
|
||||||
version: 4.1.0
|
version: 4.1.0
|
||||||
|
lucide-react:
|
||||||
|
specifier: ^0.525.0
|
||||||
|
version: 0.525.0(react@19.1.0)
|
||||||
tailwind-merge:
|
tailwind-merge:
|
||||||
specifier: ^3.3.1
|
specifier: ^3.3.1
|
||||||
version: 3.3.1
|
version: 3.3.1
|
||||||
|
|||||||
Reference in New Issue
Block a user