Revert "Revert "PR v2.0.7""

This commit is contained in:
PxlLoewe
2026-01-15 23:45:06 +01:00
parent c254cd0774
commit 15118cac66
63 changed files with 1755 additions and 1015 deletions

View File

@@ -1,12 +1,12 @@
FROM node:22-alpine AS base
ARG NEXT_PUBLIC_DISPATCH_URL
ARG NEXT_PUBLIC_DISPATCH_SERVER_URL
ARG NEXT_PUBLIC_HUB_URL
ARG NEXT_PUBLIC_DISPATCH_SERVICE_ID
ARG NEXT_PUBLIC_LIVEKIT_URL
ARG NEXT_PUBLIC_DISCORD_URL
ARG NEXT_PUBLIC_OPENAIP_ACCESS
ARG NEXT_PUBLIC_DISPATCH_URL="http://localhost:3001"
ARG NEXT_PUBLIC_DISPATCH_SERVER_URL="http://localhost:4001"
ARG NEXT_PUBLIC_HUB_URL="http://localhost:3002"
ARG NEXT_PUBLIC_DISPATCH_SERVICE_ID="1"
ARG NEXT_PUBLIC_LIVEKIT_URL="http://localhost:7880"
ARG NEXT_PUBLIC_DISCORD_URL="https://discord.com"
ARG NEXT_PUBLIC_OPENAIP_ACCESS=""
ENV NEXT_PUBLIC_DISPATCH_SERVER_URL=$NEXT_PUBLIC_DISPATCH_SERVER_URL
ENV NEXT_PUBLIC_DISPATCH_URL=$NEXT_PUBLIC_DISPATCH_URL
@@ -16,13 +16,13 @@ ENV NEXT_PUBLIC_LIVEKIT_URL=$NEXT_PUBLIC_LIVEKIT_URL
ENV NEXT_PUBLIC_OPENAIP_ACCESS=$NEXT_PUBLIC_OPENAIP_ACCESS
ENV NEXT_PUBLIC_DISCORD_URL=$NEXT_PUBLIC_DISCORD_URL
FROM base AS builder
ENV PNPM_HOME="/usr/local/pnpm"
ENV PATH="${PNPM_HOME}:${PATH}"
RUN corepack enable && corepack prepare pnpm@latest --activate
RUN pnpm add -g turbo@^2.5
FROM base AS builder
RUN apk update
RUN apk add --no-cache libc6-compat
@@ -31,12 +31,20 @@ WORKDIR /usr/app
RUN echo "NEXT_PUBLIC_HUB_URL is: $NEXT_PUBLIC_HUB_URL"
RUN echo "NEXT_PUBLIC_DISPATCH_SERVICE_ID is: $NEXT_PUBLIC_DISPATCH_SERVICE_ID"
RUN echo "NEXT_PUBLIC_DISPATCH_SERVER_URL is: $NEXT_PUBLIC_DISPATCH_SERVER_URL"
RUN echo "NEXT_PUBLIC_LIVEKIT_URL is: $NEXT_PUBLIC_LIVEKIT_URL"
COPY . .
RUN turbo prune dispatch --docker
FROM base AS installer
ENV PNPM_HOME="/usr/local/pnpm"
ENV PATH="${PNPM_HOME}:${PATH}"
RUN corepack enable && corepack prepare pnpm@latest --activate
RUN pnpm add -g turbo@^2.5
RUN apk update
RUN apk add --no-cache libc6-compat
@@ -50,19 +58,22 @@ COPY --from=builder /usr/app/out/full/ .
RUN turbo run build
FROM base AS runner
FROM node:22-alpine AS runner
WORKDIR /usr/app
# Don't run production as root
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
USER nextjs
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=installer --chown=nextjs:nodejs /usr/app/ ./
COPY --from=installer --chown=nextjs:nodejs /usr/app/apps/dispatch/.next/standalone ./
COPY --from=installer --chown=nextjs:nodejs /usr/app/apps/dispatch/.next/static ./apps/dispatch/.next/static
COPY --from=installer --chown=nextjs:nodejs /usr/app/apps/dispatch/public ./apps/dispatch/public
USER nextjs
# Expose the application port
EXPOSE 3001
EXPOSE 3000
CMD ["pnpm", "--dir", "apps/dispatch", "run", "start"]
CMD ["node", "apps/dispatch/server.js"]

View File

@@ -14,7 +14,7 @@ export const ConnectionBtn = () => {
const connection = useDispatchConnectionStore((state) => state);
const [form, setForm] = useState({
logoffTime: "",
selectedZone: "LST_01",
selectedZone: "VAR_LST_RD_01",
ghostMode: false,
});
const changeDispatcherMutation = useMutation({

View File

@@ -0,0 +1,29 @@
import { useEffect } from "react"; // ...existing code...
import { useMrtStore } from "_store/pilot/MrtStore";
import Image from "next/image";
import DAY_BASE_IMG from "./images/Base_NoScreen_Day.png";
import NIGHT_BASE_IMG from "./images/Base_NoScreen_Night.png";
export const MrtBase = () => {
const { nightMode, setNightMode, page } = useMrtStore((state) => state);
useEffect(() => {
const checkNightMode = () => {
const currentHour = new Date().getHours();
setNightMode(currentHour >= 22 || currentHour < 8);
};
checkNightMode(); // Initial check
const intervalId = setInterval(checkNightMode, 60000); // Check every minute
return () => clearInterval(intervalId); // Cleanup on unmount
}, [setNightMode]); // ...existing code...
return (
<Image
src={nightMode && page !== "off" ? NIGHT_BASE_IMG : DAY_BASE_IMG}
alt=""
className="z-30 col-span-full row-span-full"
/>
);
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 366 KiB

View File

@@ -1,22 +1,9 @@
import { CSSProperties } from "react";
import MrtImage from "./MRT.png";
import MrtMessageImage from "./MRT_MESSAGE.png";
import { useButtons } from "./useButtons";
import { useSounds } from "./useSounds";
import "./mrt.css";
import Image from "next/image";
import { useMrtStore } from "_store/pilot/MrtStore";
const MRT_BUTTON_STYLES: CSSProperties = {
cursor: "pointer",
zIndex: "9999",
backgroundColor: "transparent",
border: "none",
};
const MRT_DISPLAYLINE_STYLES: CSSProperties = {
color: "white",
zIndex: 1,
};
import { MrtBase } from "./Base";
import { MrtDisplay } from "./MrtDisplay";
import { MrtButtons } from "./MrtButtons";
import { MrtPopups } from "./MrtPopups";
export interface DisplayLineProps {
lineStyle?: CSSProperties;
@@ -27,45 +14,7 @@ export interface DisplayLineProps {
textSize: "1" | "2" | "3" | "4";
}
const DisplayLine = ({
style = {},
textLeft,
textMid,
textRight,
textSize,
lineStyle,
}: DisplayLineProps) => {
const INNER_TEXT_PARTS: CSSProperties = {
fontFamily: "Melder",
flex: "1",
flexBasis: "auto",
overflowWrap: "break-word",
...lineStyle,
};
return (
<div
className={`text-${textSize}`}
style={{
fontFamily: "Famirids",
display: "flex",
flexWrap: "wrap",
...style,
}}
>
<span style={INNER_TEXT_PARTS}>{textLeft}</span>
<span style={{ textAlign: "center", ...INNER_TEXT_PARTS }}>{textMid}</span>
<span style={{ textAlign: "end", ...INNER_TEXT_PARTS }}>{textRight}</span>
</div>
);
};
export const Mrt = () => {
useSounds();
const { handleButton } = useButtons();
const { lines, page } = useMrtStore((state) => state);
return (
<div
id="mrt-container"
@@ -78,150 +27,16 @@ export const Mrt = () => {
maxHeight: "100%",
maxWidth: "100%",
color: "white",
gridTemplateColumns: "21.83% 4.43% 24.42% 18.08% 5.93% 1.98% 6.00% 1.69% 6.00% 9.35%",
gridTemplateRows: "21.58% 11.87% 3.55% 5.00% 6.84% 0.53% 3.03% 11.84% 3.55% 11.84% 20.39%",
gridTemplateColumns:
"9.75% 4.23% 8.59% 7.30% 1.16% 7.30% 1.23% 7.16% 1.09% 7.30% 3.68% 4.23% 5.59% 6.07% 1.91% 6.07% 1.84% 6.21% 9.28%",
gridTemplateRows:
"21.55% 11.83% 3.55% 2.50% 9.46% 2.76% 0.66% 4.99% 6.83% 3.55% 1.97% 9.99% 4.20% 11.04% 5.12%",
}}
>
{page !== "sds" && (
<Image
src={MrtImage}
alt="MrtImage"
style={{
zIndex: 0,
height: "100%",
width: "100%",
gridArea: "1 / 1 / 13 / 13",
}}
/>
)}
{page === "sds" && (
<Image
src={MrtMessageImage}
alt="MrtImage-Message"
style={{
zIndex: 0,
height: "100%",
width: "100%",
gridArea: "1 / 1 / 13 / 13",
}}
/>
)}
<button
onClick={handleButton("home")}
style={{ gridArea: "2 / 4 / 3 / 5", ...MRT_BUTTON_STYLES }}
/>
<button
onClick={handleButton("1")}
style={{ gridArea: "2 / 5 / 3 / 6", ...MRT_BUTTON_STYLES }}
/>
<button
onClick={handleButton("2")}
style={{ gridArea: "2 / 7 / 3 / 7", ...MRT_BUTTON_STYLES }}
/>
<button
onClick={handleButton("3")}
style={{ gridArea: "2 / 9 / 3 / 10", ...MRT_BUTTON_STYLES }}
/>
<button
onClick={handleButton("4")}
style={{ gridArea: "4 / 5 / 6 / 6", ...MRT_BUTTON_STYLES }}
/>
<button
onClick={handleButton("5")}
style={{ gridArea: "4 / 7 / 6 / 7", ...MRT_BUTTON_STYLES }}
/>
<button
onClick={handleButton("6")}
style={{ gridArea: "4 / 9 / 6 / 10", ...MRT_BUTTON_STYLES }}
/>
<button
onClick={handleButton("7")}
style={{ gridArea: "8 / 5 / 9 / 6", ...MRT_BUTTON_STYLES }}
/>
<button
onClick={handleButton("8")}
style={{ gridArea: "8 / 7 / 9 / 7", ...MRT_BUTTON_STYLES }}
/>
<button
onClick={handleButton("9")}
style={{ gridArea: "8 / 9 / 9 / 10", ...MRT_BUTTON_STYLES }}
/>
<button
onClick={handleButton("0")}
style={{ gridArea: "10 / 7 / 11 / 8", ...MRT_BUTTON_STYLES }}
/>
{lines[0] && (
<DisplayLine
{...lines[0]}
style={
page === "sds"
? {
gridArea: "2 / 3 / 3 / 4",
marginLeft: "9px",
marginTop: "auto",
...MRT_DISPLAYLINE_STYLES,
...lines[0]?.style,
}
: {
gridArea: "4 / 3 / 5 / 4",
marginLeft: "9px",
marginTop: "auto",
...MRT_DISPLAYLINE_STYLES,
...lines[0]?.style,
}
}
/>
)}
{lines[1] && (
<DisplayLine
lineStyle={{
overflowX: "hidden",
maxHeight: "100%",
overflowY: "auto",
}}
{...lines[1]}
style={
page === "sds"
? {
gridArea: "4 / 2 / 10 / 4",
marginLeft: "3px",
...MRT_DISPLAYLINE_STYLES,
...lines[1].style,
}
: {
gridArea: "5 / 3 / 7 / 4",
marginLeft: "3px",
marginTop: "auto",
...MRT_DISPLAYLINE_STYLES,
...lines[1].style,
}
}
/>
)}
{lines[2] && (
<DisplayLine
{...lines[2]}
style={{
gridArea: "8 / 2 / 9 / 4",
...MRT_DISPLAYLINE_STYLES,
...lines[2]?.style,
}}
/>
)}
{lines[3] && (
<DisplayLine
{...lines[3]}
style={{
gridArea: "9 / 2 / 10 / 4",
marginRight: "10px",
...MRT_DISPLAYLINE_STYLES,
...lines[3]?.style,
}}
/>
)}
<MrtPopups />
<MrtDisplay />
<MrtButtons />
<MrtBase />
</div>
);
};

View File

@@ -0,0 +1,150 @@
import { CSSProperties, useRef } from "react";
import { useButtons } from "./useButtons";
const MRT_BUTTON_STYLES: CSSProperties = {
cursor: "pointer",
zIndex: "9999",
backgroundColor: "transparent",
border: "none",
};
interface MrtButtonProps {
onClick: () => void;
onHold?: () => void;
style: CSSProperties;
}
const MrtButton = ({ onClick, onHold, style }: MrtButtonProps) => {
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const handleMouseDown = () => {
if (!onHold) return;
timeoutRef.current = setTimeout(handleTimeoutExpired, 500);
};
const handleTimeoutExpired = () => {
timeoutRef.current = null;
if (onHold) {
onHold();
}
};
const handleMouseUp = () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
onClick();
}
};
return (
<button
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
onMouseLeave={() => timeoutRef.current && clearTimeout(timeoutRef.current)}
style={style}
/>
);
};
export const MrtButtons = () => {
const { handleHold, handleKlick } = useButtons();
return (
<>
{/* BELOW DISPLAY */}
<MrtButton
onClick={handleKlick("arrow-left")}
onHold={handleHold("arrow-left")}
style={{ gridArea: "14 / 4 / 15 / 5", ...MRT_BUTTON_STYLES }}
/>
<MrtButton
onClick={handleKlick("arrow-down")}
onHold={handleHold("arrow-down")}
style={{ gridArea: "14 / 6 / 15 / 7", ...MRT_BUTTON_STYLES }}
/>
<MrtButton
onClick={handleKlick("arrow-up")}
onHold={handleHold("arrow-up")}
style={{ gridArea: "14 / 8 / 15 / 9", ...MRT_BUTTON_STYLES }}
/>
<MrtButton
onClick={handleKlick("arrow-right")}
onHold={handleHold("arrow-right")}
style={{ gridArea: "14 / 10 / 15 / 11", ...MRT_BUTTON_STYLES }}
/>
<MrtButton
onClick={handleKlick("wheel-knob")}
onHold={handleHold("wheel-knob")}
style={{ gridArea: "14 / 2 / 15 / 4", ...MRT_BUTTON_STYLES }}
/>
{/* LINE SELECT KEY */}
<MrtButton
onHold={handleHold("3r")}
onClick={handleKlick("3r")}
style={{ gridArea: "9 / 12 / 11 / 13", ...MRT_BUTTON_STYLES }}
/>
<MrtButton
onHold={handleHold("3l")}
onClick={handleKlick("3l")}
style={{ gridArea: "9 / 2 / 11 / 3", ...MRT_BUTTON_STYLES }}
/>
{/* NUM PAD */}
<MrtButton
onHold={handleHold("1")}
onClick={handleKlick("1")}
style={{ gridArea: "2 / 14 / 3 / 15", ...MRT_BUTTON_STYLES }}
/>
<MrtButton
onHold={handleHold("2")}
onClick={handleKlick("2")}
style={{ gridArea: "2 / 16 / 3 / 17", ...MRT_BUTTON_STYLES }}
/>
<MrtButton
onHold={handleHold("3")}
onClick={handleKlick("3")}
style={{ gridArea: "2 / 18 / 3 / 19", ...MRT_BUTTON_STYLES }}
/>
<MrtButton
onHold={handleHold("4")}
onClick={handleKlick("4")}
style={{ gridArea: "4 / 14 / 6 / 15", ...MRT_BUTTON_STYLES }}
/>
<MrtButton
onHold={handleHold("5")}
onClick={handleKlick("5")}
style={{ gridArea: "4 / 16 / 6 / 17", ...MRT_BUTTON_STYLES }}
/>
<MrtButton
onHold={handleHold("6")}
onClick={handleKlick("6")}
style={{ gridArea: "4 / 18 / 6 / 19", ...MRT_BUTTON_STYLES }}
/>
<MrtButton
onHold={handleHold("7")}
onClick={handleKlick("7")}
style={{ gridArea: "8 / 14 / 10 / 15", ...MRT_BUTTON_STYLES }}
/>
<MrtButton
onHold={handleHold("8")}
onClick={handleKlick("8")}
style={{ gridArea: "8 / 16 / 10 / 17", ...MRT_BUTTON_STYLES }}
/>
<MrtButton
onHold={handleHold("9")}
onClick={handleKlick("9")}
style={{ gridArea: "8 / 18 / 10 / 19", ...MRT_BUTTON_STYLES }}
/>
<MrtButton
onHold={handleHold("0")}
onClick={handleKlick("0")}
style={{ gridArea: "11 / 16 / 13 / 17", ...MRT_BUTTON_STYLES }}
/>
<MrtButton
onHold={handleHold("end-call")}
onClick={handleKlick("end-call")}
style={{ gridArea: "13 / 16 / 15 / 17", ...MRT_BUTTON_STYLES }}
/>
</>
);
};

View File

@@ -0,0 +1,16 @@
.transition-image {
clip-path: inset(0 0 100% 0);
}
.transition-image.animate-reveal {
animation: revealFromTop 0.6s linear forwards;
}
@keyframes revealFromTop {
from {
clip-path: inset(0 0 100% 0);
}
to {
clip-path: inset(0 0 0 0);
}
}

View File

@@ -0,0 +1,270 @@
"use client";
import { SetPageParams, useMrtStore } from "_store/pilot/MrtStore";
import Image, { StaticImageData } from "next/image";
import PAGE_HOME from "./images/PAGE_Home.png";
import PAGE_HOME_NO_GROUP from "./images/PAGE_Home_no_group.png";
import PAGE_Call from "./images/PAGE_Call.png";
import PAGE_Off from "./images/PAGE_Off.png";
import PAGE_STARTUP from "./images/PAGE_Startup.png";
import { useEffect, useRef, useState } from "react";
import { cn, useDebounce } from "@repo/shared-components";
import "./MrtDisplay.css";
import { useSession } from "next-auth/react";
import { usePilotConnectionStore } from "_store/pilot/connectionStore";
import { fmsStatusDescriptionShort } from "_data/fmsStatusDescription";
import { useAudioStore } from "_store/audioStore";
import { ROOMS } from "_data/livekitRooms";
export const MrtDisplay = () => {
const { page, setPage, popup, setPopup, setStringifiedData, stringifiedData } = useMrtStore(
(state) => state,
);
const callEstablishedRef = useRef(false);
const session = useSession();
const { connectedAircraft, selectedStation } = usePilotConnectionStore((state) => state);
const { room, speakingParticipants, isTalking, state } = useAudioStore((state) => state);
const [pageImage, setPageImage] = useState<{
src: StaticImageData;
name: SetPageParams["page"];
}>({
src: PAGE_Off,
name: "off",
});
const [nextImage, setNextImage] = useState<
| {
src: StaticImageData;
name: SetPageParams["page"];
}
| undefined
>(undefined);
useDebounce(
() => {
if (!nextImage) return;
setPageImage(nextImage);
setNextImage(undefined);
},
1000,
[nextImage],
);
useEffect(() => {
if ((speakingParticipants.length > 0 || isTalking) && page === "home") {
setPage({
page: "voice-call",
});
}
}, [speakingParticipants, isTalking, page, setPage]);
useDebounce(
() => {
if (page === "startup") {
setPage({
page: "home",
});
}
},
6000,
[page, setPage],
);
useDebounce(
() => {
if (page === "startup") {
setPopup({
popup: "login",
});
}
},
7500,
[page, setPage],
);
useDebounce(
() => {
if (page === "voice-call" && speakingParticipants.length === 0 && !isTalking) {
setPage({
page: "home",
});
}
},
4000,
[page, setPage, speakingParticipants, isTalking],
);
useEffect(() => {
const timeouts: NodeJS.Timeout[] = [];
if (page === "voice-call") {
setStringifiedData({
callTextHeader: "Wählen",
});
timeouts.push(
setTimeout(() => {
setStringifiedData({
callTextHeader: "Anruf...",
});
}, 500),
setTimeout(() => {
setStringifiedData({
callTextHeader: "Gruppenruf",
});
}, 800),
setTimeout(() => {
callEstablishedRef.current = true;
}, 1500),
);
}
return () => {
timeouts.forEach((t) => clearTimeout(t));
};
}, [page, setStringifiedData]);
useDebounce(
() => {
if (isTalking && page === "voice-call") {
setStringifiedData({
callTextHeader: "Sprechen",
});
}
},
1500,
[page, isTalking],
);
useEffect(() => {
if (isTalking && page === "voice-call" && callEstablishedRef.current) {
console.log("SET TO SPRECHEN", stringifiedData.callTextHeader);
setStringifiedData({
callTextHeader: "Sprechen",
});
} else if (
!isTalking &&
page === "voice-call" &&
stringifiedData.callTextHeader === "Sprechen"
) {
setStringifiedData({
callTextHeader: "Gruppenruf",
});
}
}, [page, stringifiedData.callTextHeader, isTalking, setStringifiedData]);
useEffect(() => {
if (page !== "voice-call") {
callEstablishedRef.current = false;
}
switch (page) {
case "home":
if (state == "connected") {
setNextImage({ src: PAGE_HOME, name: "home" });
} else {
setNextImage({ src: PAGE_HOME_NO_GROUP, name: "home" });
}
break;
case "voice-call":
setNextImage({ src: PAGE_Call, name: "voice-call" });
break;
case "off":
setNextImage({ src: PAGE_Off, name: "off" });
break;
case "startup":
setNextImage({ src: PAGE_STARTUP, name: "startup" });
break;
}
}, [page, state]);
const DisplayText = ({ pageName }: { pageName: SetPageParams["page"] }) => {
return (
<div
className={cn("font-semibold text-[#000d60]", !!popup && "filter")}
style={{
fontFamily: "Bahnschrift",
}}
>
{pageName == "startup" && (
<p className="absolute left-[17%] top-[65%] h-[10%] w-[39%] text-center">
Bediengerät #{session.data?.user?.publicId}
</p>
)}
{pageName == "home" && (
<>
<p className="absolute left-[24%] top-[21%] h-[4%] w-[1%] text-xs">
{(room?.numParticipants || 1) - 1}
</p>
<p
className={cn(
"absolute left-[17%] top-[25%] h-[8%] w-[39%] text-center",
!connectedAircraft && "text-red-600",
)}
>
{connectedAircraft && (
<>
Status {connectedAircraft.fmsStatus} -{" "}
{fmsStatusDescriptionShort[connectedAircraft?.fmsStatus || "0"]}
</>
)}
{!connectedAircraft && <>Keine Verbindung</>}
</p>
<p className="absolute left-[22.7%] top-[37.8%] flex h-[5%] w-[34%] items-center text-xs">
{state == "connected" ? room?.name : "Keine RG gewählt"}
</p>
<p className="absolute left-[28%] top-[44.5%] h-[8%] w-[34%] text-xs">
{state == "connected" && ROOMS.find((r) => r.name === room?.name)?.id}
</p>
</>
)}
{pageName == "voice-call" && (
<div>
<p className="absolute left-[18%] top-[18.8%] flex h-[10%] w-[37%] items-center">
{stringifiedData.callTextHeader}
</p>
<p className="absolute left-[18%] top-[35%] h-[8%] w-[38%]">
{isTalking && selectedStation?.bosCallsignShort}
{speakingParticipants.length > 0 &&
speakingParticipants.map((p) => p.attributes.role).join(", ")}
</p>
<p className="absolute left-[18%] top-[53.5%] h-[8%] w-[38%]">
{room?.name || "Keine RG gefunden"}
</p>
<p className="absolute left-[18%] top-[60%] h-[8%] w-[36.7%] text-right">
{ROOMS.find((r) => r.name === room?.name)?.id}
</p>
</div>
)}
</div>
);
};
return (
<>
<Image
src={pageImage.src}
alt=""
width={1000}
height={1000}
className={cn(popup && "brightness-75 filter", "z-10 col-span-full row-span-full")}
/>
{nextImage && (
<div
className={cn(
popup && "brightness-75 filter",
"transition-image animate-reveal relative z-20 col-span-full row-span-full",
)}
>
<Image src={nextImage.src} alt="" width={1000} height={1000} className="h-full w-full" />
<DisplayText pageName={nextImage.name} />
</div>
)}
<div
className={cn(
popup && "brightness-75 filter",
"relative z-10 col-span-full row-span-full overflow-hidden",
)}
>
<DisplayText pageName={pageImage.name} />
</div>
</>
);
};

View File

@@ -0,0 +1,152 @@
import { SetPopupParams, useMrtStore } from "_store/pilot/MrtStore";
import Image, { StaticImageData } from "next/image";
import { useEffect, useState } from "react";
import IAMGE_POPUP_LOGIN from "./images/POPUP_login.png";
import GROUP_SELECTION_POPUP_LOGIN from "./images/POPUP_group_selection.png";
import IAMGE_POPUP_SDS_RECEIVED from "./images/POPUP_SDS_incomming.png";
import IAMGE_POPUP_SDS_SENT from "./images/POPUP_SDS_sent.png";
import IAMGE_POPUP_STATUS_SENT from "./images/POPUP_Status_sent.png";
import { ROOMS } from "_data/livekitRooms";
import { cn, useDebounce } from "@repo/shared-components";
import { usePilotConnectionStore } from "_store/pilot/connectionStore";
import { fmsStatusDescription, fmsStatusDescriptionShort } from "_data/fmsStatusDescription";
import { pilotSocket } from "(app)/pilot/socket";
import { StationStatus } from "@repo/db";
import { useSounds } from "./useSounds";
import { useButtons } from "./useButtons";
import { useAudioStore } from "_store/audioStore";
export const MrtPopups = () => {
const { sdsReceivedSoundRef } = useSounds();
const { handleKlick } = useButtons();
const { selectedRoom } = useAudioStore();
const { popup, page, setPopup, setStringifiedData, stringifiedData } = useMrtStore(
(state) => state,
);
const { connectedAircraft, status } = usePilotConnectionStore((state) => state);
const [popupImage, setPopupImage] = useState<StaticImageData | null>(null);
useEffect(() => {
switch (popup) {
case "status-sent":
setPopupImage(IAMGE_POPUP_STATUS_SENT);
break;
case "sds-sent":
setPopupImage(IAMGE_POPUP_SDS_SENT);
break;
case "sds-received":
setPopupImage(IAMGE_POPUP_SDS_RECEIVED);
break;
case "login":
setPopupImage(IAMGE_POPUP_LOGIN);
break;
case "group-selection":
setPopupImage(GROUP_SELECTION_POPUP_LOGIN);
break;
case undefined:
case null:
setPopupImage(null);
break;
}
}, [popup]);
useDebounce(
() => {
if (popup == "login") return;
if (popup == "sds-received") return;
if (popup == "group-selection") return;
setPopup(null);
},
3000,
[popup],
);
useDebounce(
() => {
if (popup == "group-selection") {
if (selectedRoom?.id === stringifiedData.groupSelectionGroupId) {
setPopup(null);
} else {
handleKlick("3l")();
}
}
},
5000,
[page, stringifiedData.groupSelectionGroupId, selectedRoom],
);
useEffect(() => {
if (status === "connecting" && page !== "off" && page !== "startup") {
setPopup({ popup: "login" });
}
}, [status, setPopup, page]);
useDebounce(
() => {
if (status === "connected") {
setPopup(null);
}
},
5000,
[status],
);
useEffect(() => {
pilotSocket.on("sds-status", (data: StationStatus) => {
setStringifiedData({ sdsText: data.status + " - " + fmsStatusDescriptionShort[data.status] });
setPopup({ popup: "sds-received" });
if (sdsReceivedSoundRef.current) {
sdsReceivedSoundRef.current.currentTime = 0;
sdsReceivedSoundRef.current.play();
}
});
}, [setPopup, setStringifiedData, sdsReceivedSoundRef]);
if (!popupImage || !popup) return null;
const DisplayText = ({ pageName }: { pageName: SetPopupParams["popup"] }) => {
const group = ROOMS.find((r) => r.id === stringifiedData.groupSelectionGroupId);
return (
<div
className={cn("font-semibold text-[#000d60]", !!popup && "filter")}
style={{
fontFamily: "Bahnschrift",
}}
>
{pageName == "status-sent" && (
<p className="absolute left-[17.5%] top-[44%] h-[10%] w-[39%] text-lg">
{fmsStatusDescription[connectedAircraft?.fmsStatus || "0"]}
</p>
)}
{pageName == "sds-sent" && (
<p className="absolute left-[17.5%] top-[44%] h-[10%] w-[39%] text-lg">
{fmsStatusDescription[stringifiedData.sentSdsText || "0"]}
</p>
)}
{pageName == "sds-received" && (
<p className="absolute left-[17.5%] top-[39%] h-[24%] w-[60%] whitespace-normal break-words">
{stringifiedData.sdsText}
</p>
)}
{pageName == "group-selection" && (
<>
<p className="absolute left-[24%] top-[39%] h-[5%] w-[30%]">{group?.name}</p>
<p className="absolute left-[24%] top-[50%] flex h-[9%] w-[31%] items-end justify-end">
{stringifiedData.groupSelectionGroupId}
</p>
</>
)}
</div>
);
};
return (
<>
<Image src={popupImage} alt="" className="z-30 col-span-full row-span-full" />
<div className="relative z-30 col-span-full row-span-full overflow-hidden">
<DisplayText pageName={popup} />
</div>
</>
);
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

View File

@@ -1,15 +1,58 @@
import { Prisma } from "@repo/db";
import { getPublicUser, MissionSdsStatusLog, Prisma } from "@repo/db";
import { usePilotConnectionStore } from "_store/pilot/connectionStore";
import { useMrtStore } from "_store/pilot/MrtStore";
import { pilotSocket } from "(app)/pilot/socket";
import { editConnectedAircraftAPI } from "_querys/aircrafts";
import { useEffect } from "react";
import { useMutation } from "@tanstack/react-query";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useSounds } from "./useSounds";
import { sendSdsStatusMessageAPI } from "_querys/missions";
import { useSession } from "next-auth/react";
import { ROOMS } from "_data/livekitRooms";
import { useAudioStore } from "_store/audioStore";
type ButtonTypes =
| "1"
| "2"
| "3"
| "4"
| "5"
| "6"
| "7"
| "8"
| "9"
| "0"
| "home"
| "3l"
| "3r"
| "wheel-knob"
| "arrow-up"
| "arrow-down"
| "arrow-left"
| "arrow-right"
| "end-call";
export const useButtons = () => {
const station = usePilotConnectionStore((state) => state.selectedStation);
const connectedAircraft = usePilotConnectionStore((state) => state.connectedAircraft);
const connectionStatus = usePilotConnectionStore((state) => state.status);
const session = useSession();
const { connect, setSelectedRoom, selectedRoom } = useAudioStore((state) => state);
const { longBtnPressSoundRef, statusSentSoundRef } = useSounds();
const queryClient = useQueryClient();
const {
status: pilotState,
selectedStation,
connectedAircraft,
} = usePilotConnectionStore((state) => state);
const sendSdsStatusMutation = useMutation({
mutationFn: async ({ sdsMessage }: { sdsMessage: MissionSdsStatusLog }) => {
if (!connectedAircraft?.id) throw new Error("No connected aircraft");
await sendSdsStatusMessageAPI({ sdsMessage, aircraftId: connectedAircraft?.id });
queryClient.invalidateQueries({
queryKey: ["missions"],
});
},
});
const updateAircraftMutation = useMutation({
mutationKey: ["edit-pilot-connected-aircraft"],
mutationFn: ({
@@ -21,56 +64,161 @@ export const useButtons = () => {
}) => editConnectedAircraftAPI(aircraftId, data),
});
const { setPage } = useMrtStore((state) => state);
const { setPage, setPopup, page, popup, setStringifiedData, stringifiedData } = useMrtStore(
(state) => state,
);
const handleButton =
(button: "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "0" | "home") => () => {
if (connectionStatus !== "connected") return;
if (!station) return;
if (!connectedAircraft?.id) return;
if (
button === "1" ||
button === "2" ||
button === "3" ||
button === "4" ||
button === "5" ||
button === "6" ||
button === "7" ||
button === "8" ||
button === "9" ||
button === "0"
) {
setPage({ page: "sending-status", station });
const role =
(pilotState == "connected" && selectedStation?.bosCallsignShort) ||
session.data?.user?.publicId;
setTimeout(async () => {
await updateAircraftMutation.mutateAsync({
aircraftId: connectedAircraft.id,
data: {
fmsStatus: button,
},
});
setPage({
page: "home",
station,
const handleHold = (button: ButtonTypes) => async () => {
/* if (connectionStatus !== "connected") return; */
if (button === "end-call") {
setPage({ page: "off" });
setPopup(null);
}
if (button === "1" && page === "off") {
setPage({ page: "startup" });
return;
}
if (!selectedStation) return;
if (!session.data?.user) return;
if (!connectedAircraft?.id) return;
if (
button === "1" ||
button === "2" ||
button === "3" ||
button === "4" ||
button === "6" ||
button === "7" ||
button === "8"
) {
longBtnPressSoundRef.current?.play();
const delay = Math.random() * 1500 + 500;
setTimeout(async () => {
await updateAircraftMutation.mutateAsync({
aircraftId: connectedAircraft.id,
data: {
fmsStatus: button,
});
}, 1000);
} else {
setPage({ page: "home", fmsStatus: connectedAircraft.fmsStatus || "6", station });
}
};
},
});
setPopup({ popup: "status-sent" });
statusSentSoundRef.current?.play();
}, delay);
} else if (button === "5" || button === "9" || button === "0") {
longBtnPressSoundRef.current?.play();
const delay = Math.random() * 1500 + 500;
setTimeout(async () => {
await sendSdsStatusMutation.mutateAsync({
sdsMessage: {
type: "sds-status-log",
auto: false,
timeStamp: new Date().toISOString(),
data: {
direction: "to-lst",
stationId: selectedStation.id,
station: selectedStation,
user: getPublicUser(session.data?.user),
status: button,
},
},
});
setStringifiedData({ sentSdsText: button });
statusSentSoundRef.current?.play();
setPopup({ popup: "sds-sent" });
}, delay);
}
};
const handleKlick = (button: ButtonTypes) => async () => {
console.log("Button clicked:", button);
//implement Kurzwahl when button is clicked short to dial
switch (button) {
case "0":
case "1":
case "2":
case "3":
case "4":
case "5":
case "6":
case "7":
case "8":
case "9":
//handle short press number buttons for kurzwahl
if (popup === "group-selection") {
if (stringifiedData.groupSelectionGroupId?.length === 4) {
setStringifiedData({ groupSelectionGroupId: button });
} else {
setStringifiedData({
groupSelectionGroupId: (stringifiedData.groupSelectionGroupId || "") + button,
});
}
}
if (page === "home" && !popup) {
setPopup({ popup: "group-selection" });
setStringifiedData({ groupSelectionGroupId: button });
}
break;
case "3r":
if (popup === "sds-received" || popup === "group-selection") {
setPopup(null);
} else if (page === "home") {
setPopup({ popup: "group-selection" });
setStringifiedData({ groupSelectionGroupId: selectedRoom?.id || ROOMS[0]!.id });
} else if (page === "voice-call") {
setPage({ page: "home" });
}
break;
case "wheel-knob":
setPopup(popup === "group-selection" ? null : { popup: "group-selection" });
setStringifiedData({ groupSelectionGroupId: selectedRoom?.id || ROOMS[0]!.id });
break;
case "arrow-right":
if (popup === "group-selection") {
let currentGroupIndex = ROOMS.findIndex(
(r) => r.id === stringifiedData.groupSelectionGroupId,
);
if (currentGroupIndex === ROOMS.length - 1) currentGroupIndex = -1;
const nextGroup = ROOMS[currentGroupIndex + 1];
if (nextGroup) {
setStringifiedData({ groupSelectionGroupId: nextGroup.id });
}
}
break;
case "arrow-left":
if (popup === "group-selection") {
let currentGroupIndex = ROOMS.findIndex(
(r) => r.id === stringifiedData.groupSelectionGroupId,
);
if (currentGroupIndex === 0) currentGroupIndex = ROOMS.length;
const previousGroup = ROOMS[currentGroupIndex - 1];
if (previousGroup) {
setStringifiedData({ groupSelectionGroupId: previousGroup.id });
}
}
break;
case "3l":
if (popup === "group-selection") {
const group = ROOMS.find((r) => r.id === stringifiedData.groupSelectionGroupId);
if (group && role) {
setSelectedRoom(group);
connect(group, role);
setPopup(null);
}
}
}
return false;
};
useEffect(() => {
pilotSocket.on("connect", () => {
if (!station) return;
setPage({ page: "home", fmsStatus: "6", station });
const { page } = useMrtStore.getState();
if (!selectedStation || page !== "off") return;
setPage({ page: "startup" });
});
}, [setPage, selectedStation, setPopup]);
pilotSocket.on("aircraft-update", () => {
if (!station) return;
setPage({ page: "new-status", station });
});
}, [setPage, station]);
return { handleButton };
return { handleKlick, handleHold };
};

View File

@@ -1,52 +1,39 @@
"use client";
import { usePilotConnectionStore } from "_store/pilot/connectionStore";
import { useMrtStore } from "_store/pilot/MrtStore";
import { useAudioStore } from "_store/audioStore";
import { RoomEvent } from "livekit-client";
import { useEffect, useRef } from "react";
export const useSounds = () => {
const mrtState = useMrtStore((state) => state);
const { connectedAircraft, selectedStation } = usePilotConnectionStore((state) => state);
const setPage = useMrtStore((state) => state.setPage);
const MRTstatusSoundRef = useRef<HTMLAudioElement>(null);
const MrtMessageReceivedSoundRef = useRef<HTMLAudioElement>(null);
const { room } = useAudioStore((state) => state);
const longBtnPressSoundRef = useRef<HTMLAudioElement>(null);
const statusSentSoundRef = useRef<HTMLAudioElement>(null);
const sdsReceivedSoundRef = useRef<HTMLAudioElement>(null);
useEffect(() => {
if (typeof window !== "undefined") {
MRTstatusSoundRef.current = new Audio("/sounds/MRT-status.mp3");
MrtMessageReceivedSoundRef.current = new Audio("/sounds/MRT-message-received.mp3");
MRTstatusSoundRef.current.onended = () => {
if (!selectedStation || !connectedAircraft?.fmsStatus) return;
setPage({
page: "home",
station: selectedStation,
fmsStatus: connectedAircraft?.fmsStatus,
});
};
MrtMessageReceivedSoundRef.current.onended = () => {
if (!selectedStation || !connectedAircraft?.fmsStatus) return;
if (mrtState.page === "sds") return;
setPage({
page: "home",
station: selectedStation,
fmsStatus: connectedAircraft?.fmsStatus,
});
};
longBtnPressSoundRef.current = new Audio("/sounds/1504.wav");
statusSentSoundRef.current = new Audio("/sounds/403.wav");
sdsReceivedSoundRef.current = new Audio("/sounds/775.wav");
}
}, [connectedAircraft?.fmsStatus, selectedStation, setPage, mrtState.page]);
const fmsStatus = connectedAircraft?.fmsStatus || "NaN";
}, []);
useEffect(() => {
if (!connectedAircraft) return;
if (mrtState.page === "new-status") {
if (fmsStatus === "J" || fmsStatus === "c") {
MrtMessageReceivedSoundRef.current?.play();
} else {
MRTstatusSoundRef.current?.play();
}
} else if (mrtState.page === "sds") {
MrtMessageReceivedSoundRef.current?.play();
}
}, [mrtState, fmsStatus, connectedAircraft, selectedStation]);
const handleRoomConnected = () => {
// Play a sound when connected to the room
// connectedSound.play();
statusSentSoundRef.current?.play();
console.log("Room connected - played sound");
};
room?.on(RoomEvent.Connected, handleRoomConnected);
return () => {
room?.off(RoomEvent.Connected, handleRoomConnected);
};
}, [room]);
return {
longBtnPressSoundRef,
statusSentSoundRef,
sdsReceivedSoundRef,
};
};

View File

@@ -76,7 +76,6 @@ export const ConnectionBtn = () => {
const session = useSession();
const uid = session.data?.user?.id;
if (!uid) return null;
console.log(bookings);
return (
<div className="rounded-box bg-base-200 flex items-center justify-center gap-2 p-1">
{connection.message.length > 0 && (

View File

@@ -23,7 +23,7 @@ const Map = dynamic(() => import("_components/map/Map"), {
});
const PilotPage = () => {
const { connectedAircraft, status, } = usePilotConnectionStore((state) => state);
const { connectedAircraft, status } = usePilotConnectionStore((state) => state);
const { latestMission } = useDmeStore((state) => state);
// Query will be cached anyway, due to this, displayed Markers are in sync with own Aircraft connection-warning
const { data: aircrafts } = useQuery({
@@ -94,10 +94,20 @@ const PilotPage = () => {
</div>
</div>
</div>
<div className="flex h-full w-1/3">
<div className="flex h-full w-1/3 min-w-[500px]">
<div className="bg-base-300 flex h-full w-full flex-col p-4">
<div className="flex justify-between">
<h2 className="card-title mb-2">MRT & DME</h2>
<div className="mb-2 flex items-center justify-end gap-2">
<h2 className="card-title">MRT & DME</h2>
<a
href="https://docs.virtualairrescue.com/allgemein/var-systeme/leitstelle/pilot.html"
target="_blank"
rel="noopener noreferrer"
className="link text-xs text-gray-500 hover:underline"
>
Hilfe
</a>
</div>
<div
className="tooltip tooltip-left mb-4"
data-tip="Dadurch wird der Einsatz erneut an den Desktop-Client gesendet."

View File

@@ -24,6 +24,7 @@ import { useSounds } from "_components/Audio/useSounds";
export const Audio = () => {
const {
selectedRoom,
speakingParticipants,
resetSpeakingParticipants,
isTalking,
@@ -37,8 +38,8 @@ export const Audio = () => {
room,
message,
removeMessage,
setSelectedRoom,
} = useAudioStore();
const [selectedRoom, setSelectedRoom] = useState<string>("LST_01");
useSounds({
isReceiving: speakingParticipants.length > 0,
@@ -48,7 +49,7 @@ export const Audio = () => {
});
const { selectedStation, status: pilotState } = usePilotConnectionStore((state) => state);
const { selectedZone, status: dispatcherState } = useDispatchConnectionStore((state) => state);
const { status: dispatcherState } = useDispatchConnectionStore((state) => state);
const session = useSession();
const [isReceivingBlick, setIsReceivingBlick] = useState(false);
const [recentSpeakers, setRecentSpeakers] = useState<typeof speakingParticipants>([]);
@@ -93,7 +94,7 @@ export const Audio = () => {
const canStopOtherSpeakers = dispatcherState === "connected";
const role =
(dispatcherState === "connected" && selectedZone) ||
(dispatcherState === "connected" && "VAR LST") ||
(pilotState == "connected" && selectedStation?.bosCallsignShort) ||
session.data?.user?.publicId;
@@ -185,20 +186,20 @@ export const Audio = () => {
</summary>
<ul className="menu dropdown-content bg-base-200 rounded-box z-[1050] w-52 p-2 shadow-sm">
{ROOMS.map((r) => (
<li key={r}>
<li key={r.id}>
<button
className="btn btn-sm btn-ghost relative flex items-center justify-start gap-2 text-left"
onClick={() => {
if (!role) return;
if (selectedRoom === r) return;
if (selectedRoom?.name === r.name) return;
setSelectedRoom(r);
connect(r, role);
}}
>
{room?.name === r && (
{room?.name === r.name && (
<Disc className="text-success absolute left-2 text-sm" width={15} />
)}
<span className="flex-1 text-center">{r}</span>
<span className="flex-1 text-center">{r.name}</span>
</button>
</li>
))}

View File

@@ -63,14 +63,15 @@ export function QueryProvider({ children }: { children: ReactNode }) {
};
const handleNotification = (notification: NotificationPayload) => {
console.log("Received notification:", notification);
const playNotificationSound = () => {
if (notificationSound.current) {
notificationSound.current.currentTime = 0;
notificationSound.current
.play()
.catch((e) => console.error("Notification sound error:", e));
}
}
notificationSound.current.currentTime = 0;
notificationSound.current
.play()
.catch((e) => console.error("Notification sound error:", e));
}
};
switch (notification.type) {
case "hpg-validation":
@@ -90,6 +91,7 @@ export function QueryProvider({ children }: { children: ReactNode }) {
});
break;
case "station-status":
console.log("station Status", QUICK_RESPONSE[notification.status]);
if (!QUICK_RESPONSE[notification.status]) return;
toast.custom((e) => <StatusToast event={notification} t={e} />, {
duration: 60000,
@@ -106,7 +108,7 @@ export function QueryProvider({ children }: { children: ReactNode }) {
break;
case "mission-closed":
toast("Dein aktueller Einsatz wurde geschlossen.");
break;
default:
toast("unbekanntes Notification-Event");

View File

@@ -1,14 +1,16 @@
import { Prisma, StationStatus } from "@repo/db";
import { getPublicUser, MissionSdsStatusLog, StationStatus } from "@repo/db";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { BaseNotification } from "_components/customToasts/BaseNotification";
import { FMS_STATUS_COLORS } from "_helpers/fmsStatusColors";
import { editConnectedAircraftAPI, getConnectedAircraftsAPI } from "_querys/aircrafts";
import { getConnectedAircraftsAPI } from "_querys/aircrafts";
import { getLivekitRooms } from "_querys/livekit";
import { sendSdsStatusMessageAPI } from "_querys/missions";
import { getStationsAPI } from "_querys/stations";
import { useAudioStore } from "_store/audioStore";
import { useMapStore } from "_store/mapStore";
import { X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useSession } from "next-auth/react";
import { useEffect, useRef } from "react";
import { Toast, toast } from "react-hot-toast";
export const QUICK_RESPONSE: Record<string, string[]> = {
@@ -22,6 +24,8 @@ export const StatusToast = ({ event, t }: { event: StationStatus; t: Toast }) =>
const status5Sounds = useRef<HTMLAudioElement | null>(null);
const status9Sounds = useRef<HTMLAudioElement | null>(null);
const session = useSession();
const { data: livekitRooms } = useQuery({
queryKey: ["livekit-rooms"],
queryFn: () => getLivekitRooms(),
@@ -46,7 +50,7 @@ export const StatusToast = ({ event, t }: { event: StationStatus; t: Toast }) =>
status9Sounds.current = new Audio("/sounds/status-9.mp3");
}
}, []);
const [aircraftDataAcurate, setAircraftDataAccurate] = useState(false);
//const mapStore = useMapStore((s) => s);
const { setOpenAircraftMarker, setMap } = useMapStore((store) => store);
@@ -65,29 +69,16 @@ export const StatusToast = ({ event, t }: { event: StationStatus; t: Toast }) =>
const station = stations?.find((s) => s.id === event.data?.stationId);
const queryClient = useQueryClient();
const changeAircraftMutation = useMutation({
mutationFn: async ({
id,
update,
}: {
id: number;
update: Prisma.ConnectedAircraftUpdateInput;
}) => {
await editConnectedAircraftAPI(id, update);
const sendSdsStatusMutation = useMutation({
mutationFn: async ({ sdsMessage }: { sdsMessage: MissionSdsStatusLog }) => {
if (!connectedAircraft?.id) throw new Error("No connected aircraft");
await sendSdsStatusMessageAPI({ sdsMessage, aircraftId: connectedAircraft?.id });
queryClient.invalidateQueries({
queryKey: ["aircrafts"],
queryKey: ["missions"],
});
},
});
useEffect(() => {
if (event.status !== connectedAircraft?.fmsStatus && aircraftDataAcurate) {
toast.remove(t.id);
} else if (event.status == connectedAircraft?.fmsStatus && !aircraftDataAcurate) {
setAircraftDataAccurate(true);
}
}, [aircraftDataAcurate, connectedAircraft, event.status, t.id]);
useEffect(() => {
let soundRef: React.RefObject<HTMLAudioElement | null> | null = null;
switch (event.status) {
@@ -103,7 +94,8 @@ export const StatusToast = ({ event, t }: { event: StationStatus; t: Toast }) =>
default:
soundRef = null;
}
if (audioRoom !== livekitUser?.roomName) {
if (audioRoom && livekitUser?.roomName && audioRoom !== livekitUser?.roomName) {
toast.remove(t.id);
return;
}
@@ -121,7 +113,8 @@ export const StatusToast = ({ event, t }: { event: StationStatus; t: Toast }) =>
};
}, [event.status, livekitUser?.roomName, audioRoom, t.id]);
if (!connectedAircraft || !station) return null;
console.log(connectedAircraft, station);
if (!connectedAircraft || !station || !session.data) return null;
return (
<BaseNotification>
<div className="flex flex-row items-center gap-14">
@@ -162,10 +155,18 @@ export const StatusToast = ({ event, t }: { event: StationStatus; t: Toast }) =>
toast.error("Keine Flugzeug-ID gefunden");
return;
}
await changeAircraftMutation.mutateAsync({
id: event.data?.aircraftId,
update: {
fmsStatus: status,
await sendSdsStatusMutation.mutateAsync({
sdsMessage: {
type: "sds-status-log",
auto: false,
data: {
direction: "to-aircraft",
stationId: event.data.stationId!,
station: station,
user: getPublicUser(session.data?.user),
status,
},
timeStamp: new Date().toISOString(),
},
});
toast.remove(t.id);

View File

@@ -2,7 +2,7 @@ import { Marker, Polyline, useMap } from "react-leaflet";
import { DivIcon, Marker as LMarker, Popup as LPopup } from "leaflet";
import { useMapStore } from "_store/mapStore";
import { Fragment, useCallback, useEffect, useRef, useState, useMemo } from "react";
import { checkSimulatorConnected, cn } from "@repo/shared-components";
import { cn } from "@repo/shared-components";
import { ChevronsRightLeft, House, MessageSquareText, Minimize2 } from "lucide-react";
import { SmartPopup, calculateAnchor, useSmartPopup } from "_components/SmartPopup";
import FMSStatusHistory, {
@@ -396,27 +396,11 @@ const AircraftMarker = ({ aircraft }: { aircraft: ConnectedAircraft & { Station:
};
export const AircraftLayer = () => {
const [aircrafts, setAircrafts] = useState<(ConnectedAircraft & { Station: Station })[]>([]);
useEffect(() => {
const fetchAircrafts = async () => {
try {
const res = await fetch("/api/aircrafts");
if (!res.ok) {
throw new Error("Failed to fetch aircrafts");
}
const data: (ConnectedAircraft & { Station: Station })[] = await res.json();
setAircrafts(data.filter((a) => checkSimulatorConnected(a)));
} catch (error) {
console.error("Failed to fetch aircrafts:", error);
}
};
fetchAircrafts();
const interval = setInterval(fetchAircrafts, 10_000);
return () => clearInterval(interval);
}, []);
const { data: aircrafts } = useQuery({
queryKey: ["connected-aircrafts", "map"],
queryFn: () => getConnectedAircraftsAPI(),
refetchInterval: 15000,
});
const { setMap } = useMapStore((state) => state);
const map = useMap();
const {

View File

@@ -9,6 +9,7 @@ import {
Mission,
MissionLog,
MissionSdsLog,
MissionSdsStatusLog,
MissionStationLog,
Prisma,
PublicUser,
@@ -40,7 +41,7 @@ import {
TextSearch,
} from "lucide-react";
import { useSession } from "next-auth/react";
import { sendSdsMessageAPI } from "_querys/missions";
import { sendSdsMessageAPI, sendSdsStatusMessageAPI } from "_querys/missions";
import { getLivekitRooms } from "_querys/livekit";
import { findLeitstelleForPosition } from "_helpers/findLeitstelleinPoint";
import { formatDistance } from "date-fns";
@@ -54,9 +55,13 @@ const FMSStatusHistory = ({
mission?: Mission;
}) => {
const log = ((mission?.missionLog as unknown as MissionLog[]) || [])
.filter((entry) => entry.type === "station-log" && entry.data.stationId === aircraft.Station.id)
.filter(
(entry) =>
(entry.type === "station-log" || entry.type == "sds-status-log") &&
entry.data.stationId === aircraft.Station.id,
)
.reverse()
.splice(0, 6) as MissionStationLog[];
.splice(0, 6) as (MissionStationLog | MissionSdsStatusLog)[];
const aircraftUser: PublicUser =
typeof aircraft.publicUser === "string" ? JSON.parse(aircraft.publicUser) : aircraft.publicUser;
@@ -103,10 +108,13 @@ const FMSStatusHistory = ({
<span
className="text-base font-bold"
style={{
color: FMS_STATUS_TEXT_COLORS[entry.data.newFMSstatus],
color:
FMS_STATUS_TEXT_COLORS[
entry.type === "sds-status-log" ? entry.data.status : entry.data.newFMSstatus
],
}}
>
{entry.data.newFMSstatus}
{entry.type === "sds-status-log" ? entry.data.status : entry.data.newFMSstatus}
</span>
<span className="text-base-content">
{new Date(entry.timeStamp).toLocaleTimeString([], {
@@ -126,6 +134,7 @@ const FMSStatusSelector = ({
}: {
aircraft: ConnectedAircraft & { Station: Station };
}) => {
const session = useSession();
const dispatcherConnected = useDispatchConnectionStore((s) => s.status) === "connected";
const [hoveredStatus, setHoveredStatus] = useState<string | null>(null);
const queryClient = useQueryClient();
@@ -144,6 +153,20 @@ const FMSStatusSelector = ({
},
});
const sendSdsStatusMutation = useMutation({
mutationFn: async ({ sdsMessage }: { sdsMessage: MissionSdsStatusLog }) => {
if (!aircraft?.id) throw new Error("No connected aircraft");
await sendSdsStatusMessageAPI({ sdsMessage, aircraftId: aircraft?.id });
queryClient.invalidateQueries({
queryKey: ["missions"],
});
},
});
if (!session.data?.user) {
return null;
}
return (
<div className="text-base-content mt-2 flex flex-col gap-2 p-4">
<div className="flex h-full items-center justify-center gap-2">
@@ -213,12 +236,21 @@ const FMSStatusSelector = ({
onMouseEnter={() => setHoveredStatus(status)}
onMouseLeave={() => setHoveredStatus(null)}
onClick={async () => {
await changeAircraftMutation.mutateAsync({
id: aircraft.id,
update: {
fmsStatus: status,
await sendSdsStatusMutation.mutateAsync({
sdsMessage: {
type: "sds-status-log",
auto: false,
timeStamp: new Date().toISOString(),
data: {
status: status,
direction: "to-aircraft",
stationId: aircraft.Station.id,
station: aircraft.Station,
user: getPublicUser(session.data?.user),
},
},
});
toast.success(`SDS Status ${status} gesendet`);
}}
>
{status}
@@ -378,7 +410,9 @@ const SDSTab = ({
?.slice()
.reverse()
.filter(
(entry) => entry.type === "sds-log" && entry.data.stationId === aircraft.Station.id,
(entry) =>
(entry.type === "sds-log" || entry.type == "sds-status-log") &&
entry.data.stationId === aircraft.Station.id,
) || [],
[mission?.missionLog, aircraft.Station.id],
);
@@ -471,7 +505,7 @@ const SDSTab = ({
)}
<ul className="max-h-[300px] space-y-2 overflow-x-auto overflow-y-auto">
{log.map((entry, index) => {
const sdsEntry = entry as MissionSdsLog;
const sdsEntry = entry as MissionSdsLog | MissionSdsStatusLog;
return (
<li key={index} className="flex items-center gap-2">
<span className="text-base-content">
@@ -489,7 +523,9 @@ const SDSTab = ({
{sdsEntry.data.user.firstname?.[0]?.toUpperCase() ?? "?"}
{sdsEntry.data.user.lastname?.[0]?.toUpperCase() ?? "?"}
</span>
<span className="text-base-content">{sdsEntry.data.message}</span>
<span className="text-base-content">
{sdsEntry.type == "sds-log" ? sdsEntry.data.message : sdsEntry.data.status}
</span>
</li>
);
})}

View File

@@ -726,7 +726,11 @@ const FMSStatusHistory = ({ mission }: { mission: Mission }) => {
<span className="text-base-content">{entry.data.station.bosCallsign}</span>
</li>
);
if (entry.type === "message-log" || entry.type === "sds-log")
if (
entry.type === "message-log" ||
entry.type === "sds-log" ||
entry.type === "sds-status-log"
)
return (
<li key={index} className="flex items-center gap-2">
<span className="text-base-content">
@@ -741,9 +745,10 @@ const FMSStatusHistory = ({ mission }: { mission: Mission }) => {
color: FMS_STATUS_TEXT_COLORS[6],
}}
>
{entry.data.user.firstname?.[0]?.toUpperCase() ?? "?"}
{entry.data.user.lastname?.[0]?.toUpperCase() ?? "?"}
{entry.type === "sds-log" && (
{entry.type == "sds-status-log" && entry.data.direction == "to-lst"
? entry.data.station.bosCallsignShort
: `${entry.data.user.firstname?.[0]?.toUpperCase() ?? "?"}${entry.data.user.lastname?.[0]?.toUpperCase() ?? "?"}`}
{(entry.type === "sds-log" || entry.type === "sds-status-log") && (
<>
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -760,11 +765,17 @@ const FMSStatusHistory = ({ mission }: { mission: Mission }) => {
/>
</svg>
{entry.data.station.bosCallsignShort}
{entry.type == "sds-status-log" && entry.data.direction == "to-aircraft"
? entry.data.station.bosCallsignShort
: "LST"}
</>
)}
</span>
<span className="text-base-content">{entry.data.message}</span>
<span className="text-base-content">
{entry.type === "sds-log" || entry.type === "message-log"
? entry.data.message
: entry.data.status}
</span>
</li>
);
if (

View File

@@ -24,3 +24,30 @@ export const fmsStatusDescription: { [key: string]: string } = {
o: "Warten, alle Abfrageplätze belegt",
u: "Verstanden",
};
export const fmsStatusDescriptionShort: { [key: string]: string } = {
NaN: "Keine D.",
"0": "Prio. Sprechen",
"1": "E.-bereit Funk",
"2": "E.-bereit Wache",
"3": "E.-übernahme",
"4": "Einsatzort",
"5": "Sprechwunsch",
"6": "Nicht e.-bereit",
"7": "Einsatzgeb.",
"8": "Bed. Verfügbar",
"9": "F-anmeldung",
E: "Einsatzabbruch",
C: "Melden Sie Einsatzübernahme",
F: "Kommen Sie über Draht",
H: "Fahren Sie Wache an",
J: "Sprechen Sie",
L: "Geben Sie Lagemeldung",
P: "Einsatz mit Polizei",
U: "Ungültige Statusfolge",
c: "Status korrigieren",
d: "Nennen Sie Transportziel",
h: "Zielklinik verständigt",
o: "Warten, alle Abfrageplätze belegt",
u: "Verstanden",
};

View File

@@ -1 +1,7 @@
export const ROOMS = ["LST_01", "LST_02", "LST_03", "LST_04", "LST_05"];
export const ROOMS = [
{ name: "VAR_LST_RD_01", id: "2201" },
{ name: "VAR_LST_RD_02", id: "2202" },
{ name: "VAR_LST_RD_03", id: "2203" },
{ name: "VAR_LST_RD_04", id: "2204" },
{ name: "VAR_LST_RD_05", id: "2205" },
];

View File

@@ -1,4 +1,4 @@
import { Mission, MissionSdsLog, Prisma } from "@repo/db";
import { Mission, MissionSdsLog, MissionSdsStatusLog, Prisma } from "@repo/db";
import axios from "axios";
import { serverApi } from "_helpers/axios";
@@ -29,6 +29,20 @@ export const editMissionAPI = async (id: number, mission: Prisma.MissionUpdateIn
const respone = await serverApi.patch<Mission>(`/mission/${id}`, mission);
return respone.data;
};
export const sendSdsStatusMessageAPI = async ({
sdsMessage,
aircraftId,
}: {
aircraftId: number;
sdsMessage: MissionSdsStatusLog;
}) => {
const respone = await serverApi.post<Mission>(`/aircrafts/${aircraftId}/send-sds-message`, {
sdsMessage,
});
return respone.data;
};
export const sendSdsMessageAPI = async ({
missionId,
sdsMessage,

View File

@@ -21,12 +21,13 @@ import { useDispatchConnectionStore } from "_store/dispatch/connectionStore";
import { changeDispatcherAPI } from "_querys/dispatcher";
import { getRadioStream } from "_helpers/radioEffect";
import { usePilotConnectionStore } from "_store/pilot/connectionStore";
import { ROOMS } from "_data/livekitRooms";
let interval: NodeJS.Timeout;
type TalkState = {
addSpeakingParticipant: (participant: Participant) => void;
connect: (roomName: string, role: string) => void;
connect: (room: (typeof ROOMS)[number] | undefined, role: string) => void;
connectionQuality: ConnectionQuality;
disconnect: () => void;
isTalking: boolean;
@@ -44,6 +45,8 @@ type TalkState = {
radioVolume: number;
dmeVolume: number;
};
selectedRoom?: (typeof ROOMS)[number];
setSelectedRoom: (room: (typeof ROOMS)[number]) => void;
speakingParticipants: Participant[];
state: "connecting" | "connected" | "disconnected" | "error";
toggleTalking: () => void;
@@ -72,6 +75,10 @@ export const useAudioStore = create<TalkState>((set, get) => ({
remoteParticipants: 0,
connectionQuality: ConnectionQuality.Unknown,
room: null,
selectedRoom: ROOMS[0],
setSelectedRoom: (room) => {
set({ selectedRoom: room });
},
resetSpeakingParticipants: (source: string) => {
set({
speakingParticipants: [],
@@ -117,11 +124,11 @@ export const useAudioStore = create<TalkState>((set, get) => ({
(oldSettings.micDeviceId !== newSettings.micDeviceId ||
oldSettings.micVolume !== newSettings.micVolume)
) {
const { room, disconnect, connect } = get();
const { room, disconnect, connect, selectedRoom } = get();
const role = room?.localParticipant.attributes.role;
if (room?.name || role) {
if (selectedRoom || role) {
disconnect();
connect(room?.name || "", role || "user");
connect(selectedRoom, role || "user");
}
}
},
@@ -160,7 +167,7 @@ export const useAudioStore = create<TalkState>((set, get) => ({
set((state) => ({ isTalking: !state.isTalking, transmitBlocked: false }));
},
connect: async (roomName, role) => {
connect: async (_room, role) => {
set({ state: "connecting" });
try {
@@ -172,13 +179,16 @@ export const useAudioStore = create<TalkState>((set, get) => ({
connectedRoom.removeAllListeners();
}
const { selectedRoom } = get();
const url = process.env.NEXT_PUBLIC_LIVEKIT_URL;
if (!url) return console.error("NEXT_PUBLIC_LIVEKIT_URL not set");
const token = await getToken(roomName);
const token = await getToken(_room?.name || selectedRoom?.name || "VAR_LST_RD_01");
if (!token) throw new Error("Fehlende Berechtigung");
const room = new Room({});
await room.prepareConnection(url, token);
const roomConnectedSound = new Audio("/sounds/403.wav");
room
// Connection events
.on(RoomEvent.Connected, async () => {
@@ -186,7 +196,7 @@ export const useAudioStore = create<TalkState>((set, get) => ({
if (dispatchState.status === "connected" && dispatchState.connectedDispatcher?.id) {
changeDispatcherAPI(dispatchState.connectedDispatcher?.id, {
zone: roomName,
zone: _room?.name || selectedRoom?.name || "VAR_LST_RD_01",
ghostMode: dispatchState.ghostMode,
});
}
@@ -208,7 +218,7 @@ export const useAudioStore = create<TalkState>((set, get) => ({
source: Track.Source.Microphone,
});
await publishedTrack.mute();
roomConnectedSound.play();
set({ localRadioTrack: publishedTrack });
set({ state: "connected", room, isTalking: false, message: null });

View File

@@ -27,7 +27,7 @@ export const useDispatchConnectionStore = create<ConnectionStore>((set) => ({
setHideDraftMissions: (hide) => set({ hideDraftMissions: hide }),
connectedDispatcher: null,
message: "",
selectedZone: "LST_01",
selectedZone: "VAR_LST_RD_01",
logoffTime: "",
ghostMode: false,
connect: async (uid, selectedZone, logoffTime, ghostMode) =>
@@ -48,7 +48,7 @@ export const useDispatchConnectionStore = create<ConnectionStore>((set) => ({
dispatchSocket.on("connect", () => {
const { logoffTime, selectedZone, ghostMode } = useDispatchConnectionStore.getState();
useAudioStore.getState().connect("LST_01", selectedZone || "Leitstelle");
useAudioStore.getState().connect(undefined, selectedZone || "Leitstelle");
dispatchSocket.emit("connect-dispatch", {
logoffTime,
selectedZone,

View File

@@ -1,173 +1,92 @@
import { MissionSdsLog, Station } from "@repo/db";
import { fmsStatusDescription } from "_data/fmsStatusDescription";
import { DisplayLineProps } from "(app)/pilot/_components/mrt/Mrt";
import { create } from "zustand";
import { syncTabs } from "zustand-sync-tabs";
interface SetSdsPageParams {
page: "sds";
station: Station;
sdsMessage: MissionSdsLog;
interface SetOffPageParams {
page: "off";
}
interface SetStartupPageParams {
page: "startup";
}
interface SetHomePageParams {
page: "home";
station: Station;
fmsStatus: string;
}
interface SetSendingStatusPageParams {
page: "sending-status";
station: Station;
interface SetVoicecallPageParams {
page: "voice-call";
}
interface SetSdsReceivedPopupParams {
popup: "sds-received";
}
interface SetNewStatusPageParams {
page: "new-status";
station: Station;
interface SetGroupSelectionPopupParams {
popup: "group-selection";
}
type SetPageParams =
interface SetStatusSentPopupParams {
popup: "status-sent";
}
interface SetLoginPopupParams {
popup: "login";
}
interface SetSdsSentPopupParams {
popup: "sds-sent";
}
export type SetPageParams =
| SetHomePageParams
| SetSendingStatusPageParams
| SetSdsPageParams
| SetNewStatusPageParams;
| SetOffPageParams
| SetStartupPageParams
| SetVoicecallPageParams;
export type SetPopupParams =
| SetStatusSentPopupParams
| SetSdsSentPopupParams
| SetGroupSelectionPopupParams
| SetSdsReceivedPopupParams
| SetLoginPopupParams;
interface StringifiedData {
sdsText?: string;
sentSdsText?: string;
groupSelectionGroupId?: string;
callTextHeader?: string;
}
interface MrtStore {
page: SetPageParams["page"];
popup?: SetPopupParams["popup"];
lines: DisplayLineProps[];
stringifiedData: StringifiedData;
setStringifiedData: (data: Partial<StringifiedData>) => void;
setPage: (pageData: SetPageParams) => void;
setLines: (lines: MrtStore["lines"]) => void;
setPopup: (popupData: SetPopupParams | null) => void;
// internal
updateIntervall?: number;
nightMode: boolean;
setNightMode: (nightMode: boolean) => void;
}
export const useMrtStore = create<MrtStore>(
syncTabs(
(set) => ({
page: "home",
pageData: {
message: "",
},
lines: [
{
textLeft: "VAR.#",
textSize: "2",
},
{
textLeft: "No Data",
textSize: "3",
},
],
setLines: (lines) => set({ lines }),
setPage: (pageData) => {
switch (pageData.page) {
case "home": {
const { station, fmsStatus } = pageData as SetHomePageParams;
set({
page: "home",
lines: [
{
textLeft: `${station?.bosCallsign}`,
style: { fontWeight: "bold" },
textSize: "2",
},
{ textLeft: "ILS VAR#", textSize: "3" },
{
textLeft: fmsStatus,
style: { fontWeight: "extrabold" },
textSize: "4",
},
{
textLeft: fmsStatusDescription[fmsStatus],
textSize: "1",
},
],
});
break;
}
case "sending-status": {
const { station } = pageData as SetSendingStatusPageParams;
set({
page: "sending-status",
lines: [
{
textLeft: `${station?.bosCallsign}`,
style: { fontWeight: "bold" },
textSize: "2",
},
{ textLeft: "ILS VAR#", textSize: "3" },
{
textMid: "sending...",
style: { fontWeight: "bold" },
textSize: "4",
},
{
textLeft: "Status wird gesendet...",
textSize: "1",
},
],
});
break;
}
case "new-status": {
const { station } = pageData as SetNewStatusPageParams;
set({
page: "new-status",
lines: [
{
textLeft: `${station?.bosCallsign}`,
style: { fontWeight: "bold" },
textSize: "2",
},
{ textLeft: "ILS VAR#", textSize: "3" },
{
textLeft: "empfangen",
style: { fontWeight: "bold" },
textSize: "4",
},
],
});
break;
}
case "sds": {
const { sdsMessage } = pageData as SetSdsPageParams;
const msg = sdsMessage.data.message;
set({
page: "sds",
lines: [
{
textLeft: `SDS-Nachricht`,
style: { fontWeight: "bold" },
textSize: "2",
},
{
textLeft: msg,
style: {
whiteSpace: "normal",
overflowWrap: "break-word",
wordBreak: "break-word",
display: "block",
maxWidth: "100%",
maxHeight: "100%",
overflow: "auto",
textOverflow: "ellipsis",
lineHeight: "1.2em",
},
textSize: "2",
},
],
});
break;
}
default:
set({ page: "home" });
break;
}
},
}),
{
name: "mrt-store", // unique name
},
),
);
export const useMrtStore = create<MrtStore>((set) => ({
page: "off",
nightMode: false,
stringifiedData: {
groupSelectionGroupId: "2201",
},
setNightMode: (nightMode) => set({ nightMode }),
setStringifiedData: (data) =>
set((state) => ({
stringifiedData: { ...state.stringifiedData, ...data },
})),
setPopup: (popupData) => {
set({ popup: popupData ? popupData.popup : undefined });
},
setPage: (pageData) => {
set({ page: pageData.page });
},
}));

View File

@@ -86,7 +86,7 @@ pilotSocket.on("connect", () => {
usePilotConnectionStore.setState({ status: "connected", message: "" });
const { logoffTime, selectedStation, debug } = usePilotConnectionStore.getState();
dispatchSocket.disconnect();
useAudioStore.getState().connect("LST_01", selectedStation?.bosCallsignShort || "pilot");
useAudioStore.getState().connect(undefined, selectedStation?.bosCallsignShort || "pilot");
pilotSocket.emit("connect-pilot", {
logoffTime,
@@ -109,7 +109,7 @@ pilotSocket.on("connect-message", (data) => {
});
pilotSocket.on("disconnect", () => {
usePilotConnectionStore.setState({ status: "disconnected" });
usePilotConnectionStore.setState({ status: "disconnected", connectedAircraft: null });
useAudioStore.getState().disconnect();
});
@@ -142,11 +142,13 @@ pilotSocket.on("mission-alert", (data: Mission & { Stations: Station[] }) => {
});
pilotSocket.on("sds-message", (sdsMessage: MissionSdsLog) => {
console.log("Received sds-message via socket:", sdsMessage);
const station = usePilotConnectionStore.getState().selectedStation;
if (!station) return;
useMrtStore.getState().setPage({
page: "sds",
station,
sdsMessage,
useMrtStore.getState().setPopup({
popup: "sds-received",
});
useMrtStore.getState().setStringifiedData({
sdsText: sdsMessage.data.message,
});
});

View File

@@ -1,7 +1,6 @@
import { Mission, Station, User } from "@repo/db";
import { DisplayLineProps } from "(app)/pilot/_components/dme/Dme";
import { create } from "zustand";
import { syncTabs } from "zustand-sync-tabs";
interface SetHomePageParams {
page: "home";
@@ -45,197 +44,190 @@ interface MrtStore {
let interval: NodeJS.Timeout | null = null;
export const useDmeStore = create<MrtStore>(
syncTabs(
(set) => ({
page: "home",
pageData: {
message: "",
},
lines: [
{
textLeft: "",
},
{
textMid: "VAR . DME# No Data",
textSize: "2",
},
{
textLeft: "",
},
],
setLines: (lines) => set({ lines }),
latestMission: null,
setPage: (pageData) => {
if (interval) clearInterval(interval);
switch (pageData.page) {
case "home": {
const setHomePage = () =>
set({
page: "home",
lines: [
{
textMid: pageData.station.bosCallsign
? `${pageData.station.bosCallsign}`
: "no Data",
style: { fontWeight: "bold" },
},
{ textMid: "" },
{
textMid: new Date().toLocaleDateString("de-DE", {
year: "numeric",
month: "2-digit",
day: "2-digit",
}),
},
{
textMid: new Date().toLocaleTimeString(),
style: { fontWeight: "bold" },
},
{ textMid: "" },
{
textMid: `${pageData.user.lastname} ${pageData.user.firstname}`,
},
{ textMid: "" },
],
});
setHomePage();
interval = setInterval(() => {
setHomePage();
}, 1000);
break;
}
case "new-mission": {
set({
page: "new-mission",
lines: [
{ textMid: "" },
{
textMid: "new mission received",
style: { fontWeight: "bold" },
},
{ textMid: "" },
],
});
break;
}
case "mission": {
set({
latestMission: pageData.mission,
page: "mission",
lines: [
{
textLeft: `${pageData.mission.missionKeywordAbbreviation}`,
textRight: pageData.mission.Stations.map((s) => s.bosCallsignShort).join(","),
style: { fontWeight: "bold" },
},
...(pageData.mission.type == "primär"
? [
{
textMid: `${pageData.mission.missionKeywordName}`,
style: { fontWeight: "bold" },
},
]
: []),
{ textLeft: `${pageData.mission.addressStreet}` },
{
textLeft: `${pageData.mission.addressZip} ${pageData.mission.addressCity}`,
},
{
textMid: "Weitere Standortinformationen:",
style: { fontWeight: "bold" },
},
{
textLeft: pageData.mission.addressAdditionalInfo || "keine Daten",
},
...(pageData.mission.type === "sekundär"
? [
{
textMid: "Zielort:",
style: { fontWeight: "bold" },
},
{
textLeft: pageData.mission.addressMissionDestination || "keine Daten",
},
]
: []),
...(pageData.mission.missionPatientInfo &&
pageData.mission.missionPatientInfo.length > 0
? [
{
textMid: "Patienteninfos:",
style: { fontWeight: "bold" },
},
{
textLeft: pageData.mission.missionPatientInfo,
},
]
: []),
...(pageData.mission.missionAdditionalInfo &&
pageData.mission.missionAdditionalInfo.length > 0
? [
{
textMid: "Weitere Infos:",
style: { fontWeight: "bold" },
},
{
textLeft: pageData.mission.missionAdditionalInfo,
},
]
: []),
],
});
break;
}
case "error": {
set({
page: "error",
lines: [
{ textMid: "Fehler:" },
{
textMid: pageData.error,
style: { fontWeight: "bold" },
},
{ textMid: "" },
],
});
break;
}
case "acknowledge": {
set({
page: "acknowledge",
lines: [
{ textMid: "" },
{
textMid: "Einsatz angenommen",
style: { fontWeight: "bold" },
},
{ textMid: "" },
],
});
break;
}
default:
set({
page: "error",
lines: [
{ textMid: "Fehler:" },
{
textMid: `Unbekannte Seite`,
style: { fontWeight: "bold" },
},
{ textMid: "" },
],
});
break;
}
},
}),
export const useDmeStore = create<MrtStore>((set) => ({
page: "home",
pageData: {
message: "",
},
lines: [
{
name: "dme-store", // unique name
textLeft: "",
},
),
);
{
textMid: "VAR . DME# No Data",
textSize: "2",
},
{
textLeft: "",
},
],
setLines: (lines) => set({ lines }),
latestMission: null,
setPage: (pageData) => {
if (interval) clearInterval(interval);
switch (pageData.page) {
case "home": {
const setHomePage = () =>
set({
page: "home",
lines: [
{
textMid: pageData.station.bosCallsign
? `${pageData.station.bosCallsign}`
: "no Data",
style: { fontWeight: "bold" },
},
{ textMid: "" },
{
textMid: new Date().toLocaleDateString("de-DE", {
year: "numeric",
month: "2-digit",
day: "2-digit",
}),
},
{
textMid: new Date().toLocaleTimeString(),
style: { fontWeight: "bold" },
},
{ textMid: "" },
{
textMid: `${pageData.user.lastname} ${pageData.user.firstname}`,
},
{ textMid: "" },
],
});
setHomePage();
interval = setInterval(() => {
setHomePage();
}, 1000);
break;
}
case "new-mission": {
set({
page: "new-mission",
lines: [
{ textMid: "" },
{
textMid: "new mission received",
style: { fontWeight: "bold" },
},
{ textMid: "" },
],
});
break;
}
case "mission": {
set({
latestMission: pageData.mission,
page: "mission",
lines: [
{
textLeft: `${pageData.mission.missionKeywordAbbreviation}`,
textRight: pageData.mission.Stations.map((s) => s.bosCallsignShort).join(","),
style: { fontWeight: "bold" },
},
...(pageData.mission.type == "primär"
? [
{
textMid: `${pageData.mission.missionKeywordName}`,
style: { fontWeight: "bold" },
},
]
: []),
{ textLeft: `${pageData.mission.addressStreet}` },
{
textLeft: `${pageData.mission.addressZip} ${pageData.mission.addressCity}`,
},
{
textMid: "Weitere Standortinformationen:",
style: { fontWeight: "bold" },
},
{
textLeft: pageData.mission.addressAdditionalInfo || "keine Daten",
},
...(pageData.mission.type === "sekundär"
? [
{
textMid: "Zielort:",
style: { fontWeight: "bold" },
},
{
textLeft: pageData.mission.addressMissionDestination || "keine Daten",
},
]
: []),
...(pageData.mission.missionPatientInfo &&
pageData.mission.missionPatientInfo.length > 0
? [
{
textMid: "Patienteninfos:",
style: { fontWeight: "bold" },
},
{
textLeft: pageData.mission.missionPatientInfo,
},
]
: []),
...(pageData.mission.missionAdditionalInfo &&
pageData.mission.missionAdditionalInfo.length > 0
? [
{
textMid: "Weitere Infos:",
style: { fontWeight: "bold" },
},
{
textLeft: pageData.mission.missionAdditionalInfo,
},
]
: []),
],
});
break;
}
case "error": {
set({
page: "error",
lines: [
{ textMid: "Fehler:" },
{
textMid: pageData.error,
style: { fontWeight: "bold" },
},
{ textMid: "" },
],
});
break;
}
case "acknowledge": {
set({
page: "acknowledge",
lines: [
{ textMid: "" },
{
textMid: "Einsatz angenommen",
style: { fontWeight: "bold" },
},
{ textMid: "" },
],
});
break;
}
default:
set({
page: "error",
lines: [
{ textMid: "Fehler:" },
{
textMid: `Unbekannte Seite`,
style: { fontWeight: "bold" },
},
{ textMid: "" },
],
});
break;
}
},
}));

View File

@@ -9,6 +9,11 @@
src: url("/fonts/MelderV2.ttf") format("truetype"); /* Chrome 4+, Firefox 3.5, Opera 10+, Safari 3—5 */
}
@font-face {
font-family: "Bahnschrift";
src: url("/fonts/bahnschrift.ttf") format("truetype"); /* Chrome 4+, Firefox 3.5, Opera 10+, Safari 3—5 */
}
@theme {
--color-rescuetrack: #46b7a3;
--color-rescuetrack-highlight: #ff4500;

View File

@@ -1,5 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -1,4 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {};
const nextConfig = {
output: "standalone",
};
export default nextConfig;

View File

@@ -60,7 +60,6 @@
"tailwindcss": "^4.1.11",
"typescript": "^5.8.3",
"zod": "^3.25.67",
"zustand": "^5.0.6",
"zustand-sync-tabs": "^0.2.2"
"zustand": "^5.0.6"
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.