Merge pull request #147 from VAR-Virtual-Air-Rescue/staging

@everyone | Wir haben eine kurze Downtime überwunden und stellen euch heute v2.0.7 vor.

In der Vergangenheit haben wir viel an der Dispositionsseite gearbeitet. Das ändert sich heute. Wir aktualisieren die Bedieneinheit für Piloten auf eine neue Softwareversion, die dem Sepura SCG22 nachempfunden ist und so tatsächlich in vielen Hubschraubern verbaut ist.

### Neue Features:
- Ladescreen beim Einschalten
- Wechseln der Rufgruppe direkt im Gerät
- Status senden/empfangen
- SDS-Text bzw. senden/empfangen
- Nachtmodus ab 22:00 Uhr
- Popup bei Funkverkehr auf der Rufgruppe

Eine entsprechende Dokumentation findet ihr[ in den Docs](https://docs.virtualairrescue.com/allgemein/var-systeme/leitstelle/pilot.html).

### Weiteres:
- kleinere Bugfixes
- Performanceupgrades durch verbessertes Backup-Handling
This commit is contained in:
PxlLoewe
2026-01-15 22:59:21 +01:00
committed by GitHub
63 changed files with 1755 additions and 1015 deletions

View File

@@ -2,6 +2,7 @@ import {
AdminMessage, AdminMessage,
getPublicUser, getPublicUser,
MissionLog, MissionLog,
MissionSdsStatusLog,
NotificationPayload, NotificationPayload,
Prisma, Prisma,
prisma, prisma,
@@ -130,6 +131,44 @@ router.patch("/:id", async (req, res) => {
} }
}); });
router.post("/:id/send-sds-message", async (req, res) => {
const { id } = req.params;
const { sdsMessage } = req.body as { sdsMessage: MissionSdsStatusLog };
if (!sdsMessage.data.stationId || !id) {
res.status(400).json({ error: "Missing aircraftId or stationId" });
return;
}
await prisma.mission.updateMany({
where: {
state: "running",
missionStationIds: {
has: sdsMessage.data.stationId,
},
},
data: {
missionLog: {
push: sdsMessage as unknown as Prisma.InputJsonValue,
},
},
});
io.to(
sdsMessage.data.direction === "to-lst" ? "dispatchers" : `station:${sdsMessage.data.stationId}`,
).emit(sdsMessage.data.direction === "to-lst" ? "notification" : "sds-status", {
type: "station-status",
status: sdsMessage.data.status,
message: "SDS Status Message",
data: {
aircraftId: parseInt(id),
stationId: sdsMessage.data.stationId,
},
} as NotificationPayload);
res.sendStatus(204);
});
// Kick a connectedAircraft by ID // Kick a connectedAircraft by ID
router.delete("/:id", async (req, res) => { router.delete("/:id", async (req, res) => {
const { id } = req.params; const { id } = req.params;

View File

@@ -1,12 +1,12 @@
FROM node:22-alpine AS base FROM node:22-alpine AS base
ARG NEXT_PUBLIC_DISPATCH_URL ARG NEXT_PUBLIC_DISPATCH_URL="http://localhost:3001"
ARG NEXT_PUBLIC_DISPATCH_SERVER_URL ARG NEXT_PUBLIC_DISPATCH_SERVER_URL="http://localhost:4001"
ARG NEXT_PUBLIC_HUB_URL ARG NEXT_PUBLIC_HUB_URL="http://localhost:3002"
ARG NEXT_PUBLIC_DISPATCH_SERVICE_ID ARG NEXT_PUBLIC_DISPATCH_SERVICE_ID="1"
ARG NEXT_PUBLIC_LIVEKIT_URL ARG NEXT_PUBLIC_LIVEKIT_URL="http://localhost:7880"
ARG NEXT_PUBLIC_DISCORD_URL ARG NEXT_PUBLIC_DISCORD_URL="https://discord.com"
ARG NEXT_PUBLIC_OPENAIP_ACCESS ARG NEXT_PUBLIC_OPENAIP_ACCESS=""
ENV NEXT_PUBLIC_DISPATCH_SERVER_URL=$NEXT_PUBLIC_DISPATCH_SERVER_URL ENV NEXT_PUBLIC_DISPATCH_SERVER_URL=$NEXT_PUBLIC_DISPATCH_SERVER_URL
ENV NEXT_PUBLIC_DISPATCH_URL=$NEXT_PUBLIC_DISPATCH_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_OPENAIP_ACCESS=$NEXT_PUBLIC_OPENAIP_ACCESS
ENV NEXT_PUBLIC_DISCORD_URL=$NEXT_PUBLIC_DISCORD_URL ENV NEXT_PUBLIC_DISCORD_URL=$NEXT_PUBLIC_DISCORD_URL
FROM base AS builder
ENV PNPM_HOME="/usr/local/pnpm" ENV PNPM_HOME="/usr/local/pnpm"
ENV PATH="${PNPM_HOME}:${PATH}" ENV PATH="${PNPM_HOME}:${PATH}"
RUN corepack enable && corepack prepare pnpm@latest --activate RUN corepack enable && corepack prepare pnpm@latest --activate
RUN pnpm add -g turbo@^2.5 RUN pnpm add -g turbo@^2.5
FROM base AS builder
RUN apk update RUN apk update
RUN apk add --no-cache libc6-compat 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_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_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_DISPATCH_SERVER_URL is: $NEXT_PUBLIC_DISPATCH_SERVER_URL"
RUN echo "NEXT_PUBLIC_LIVEKIT_URL is: $NEXT_PUBLIC_LIVEKIT_URL"
COPY . . COPY . .
RUN turbo prune dispatch --docker RUN turbo prune dispatch --docker
FROM base AS installer 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 update
RUN apk add --no-cache libc6-compat RUN apk add --no-cache libc6-compat
@@ -50,19 +58,22 @@ COPY --from=builder /usr/app/out/full/ .
RUN turbo run build RUN turbo run build
FROM base AS runner FROM node:22-alpine AS runner
WORKDIR /usr/app WORKDIR /usr/app
# Don't run production as root # Don't run production as root
RUN addgroup --system --gid 1001 nodejs RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs RUN adduser --system --uid 1001 nextjs
USER nextjs
# Automatically leverage output traces to reduce image size # Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing # 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 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 connection = useDispatchConnectionStore((state) => state);
const [form, setForm] = useState({ const [form, setForm] = useState({
logoffTime: "", logoffTime: "",
selectedZone: "LST_01", selectedZone: "VAR_LST_RD_01",
ghostMode: false, ghostMode: false,
}); });
const changeDispatcherMutation = useMutation({ 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 { 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 "./mrt.css";
import Image from "next/image"; import { MrtBase } from "./Base";
import { useMrtStore } from "_store/pilot/MrtStore"; import { MrtDisplay } from "./MrtDisplay";
import { MrtButtons } from "./MrtButtons";
const MRT_BUTTON_STYLES: CSSProperties = { import { MrtPopups } from "./MrtPopups";
cursor: "pointer",
zIndex: "9999",
backgroundColor: "transparent",
border: "none",
};
const MRT_DISPLAYLINE_STYLES: CSSProperties = {
color: "white",
zIndex: 1,
};
export interface DisplayLineProps { export interface DisplayLineProps {
lineStyle?: CSSProperties; lineStyle?: CSSProperties;
@@ -27,45 +14,7 @@ export interface DisplayLineProps {
textSize: "1" | "2" | "3" | "4"; 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 = () => { export const Mrt = () => {
useSounds();
const { handleButton } = useButtons();
const { lines, page } = useMrtStore((state) => state);
return ( return (
<div <div
id="mrt-container" id="mrt-container"
@@ -78,150 +27,16 @@ export const Mrt = () => {
maxHeight: "100%", maxHeight: "100%",
maxWidth: "100%", maxWidth: "100%",
color: "white", color: "white",
gridTemplateColumns: "21.83% 4.43% 24.42% 18.08% 5.93% 1.98% 6.00% 1.69% 6.00% 9.35%", gridTemplateColumns:
gridTemplateRows: "21.58% 11.87% 3.55% 5.00% 6.84% 0.53% 3.03% 11.84% 3.55% 11.84% 20.39%", "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" && ( <MrtPopups />
<Image <MrtDisplay />
src={MrtImage} <MrtButtons />
alt="MrtImage" <MrtBase />
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,
}}
/>
)}
</div> </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 { usePilotConnectionStore } from "_store/pilot/connectionStore";
import { useMrtStore } from "_store/pilot/MrtStore"; import { useMrtStore } from "_store/pilot/MrtStore";
import { pilotSocket } from "(app)/pilot/socket"; import { pilotSocket } from "(app)/pilot/socket";
import { editConnectedAircraftAPI } from "_querys/aircrafts"; import { editConnectedAircraftAPI } from "_querys/aircrafts";
import { useEffect } from "react"; 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 = () => { export const useButtons = () => {
const station = usePilotConnectionStore((state) => state.selectedStation); const session = useSession();
const connectedAircraft = usePilotConnectionStore((state) => state.connectedAircraft); const { connect, setSelectedRoom, selectedRoom } = useAudioStore((state) => state);
const connectionStatus = usePilotConnectionStore((state) => state.status);
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({ const updateAircraftMutation = useMutation({
mutationKey: ["edit-pilot-connected-aircraft"], mutationKey: ["edit-pilot-connected-aircraft"],
mutationFn: ({ mutationFn: ({
@@ -21,56 +64,161 @@ export const useButtons = () => {
}) => editConnectedAircraftAPI(aircraftId, data), }) => editConnectedAircraftAPI(aircraftId, data),
}); });
const { setPage } = useMrtStore((state) => state); const { setPage, setPopup, page, popup, setStringifiedData, stringifiedData } = useMrtStore(
(state) => state,
);
const handleButton = const role =
(button: "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "0" | "home") => () => { (pilotState == "connected" && selectedStation?.bosCallsignShort) ||
if (connectionStatus !== "connected") return; session.data?.user?.publicId;
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 });
setTimeout(async () => { const handleHold = (button: ButtonTypes) => async () => {
await updateAircraftMutation.mutateAsync({ /* if (connectionStatus !== "connected") return; */
aircraftId: connectedAircraft.id, if (button === "end-call") {
data: { setPage({ page: "off" });
fmsStatus: button, setPopup(null);
}, }
}); if (button === "1" && page === "off") {
setPage({ setPage({ page: "startup" });
page: "home", return;
station, }
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, fmsStatus: button,
}); },
}, 1000); });
} else { setPopup({ popup: "status-sent" });
setPage({ page: "home", fmsStatus: connectedAircraft.fmsStatus || "6", station }); 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(() => { useEffect(() => {
pilotSocket.on("connect", () => { pilotSocket.on("connect", () => {
if (!station) return; const { page } = useMrtStore.getState();
setPage({ page: "home", fmsStatus: "6", station }); if (!selectedStation || page !== "off") return;
setPage({ page: "startup" });
}); });
}, [setPage, selectedStation, setPopup]);
pilotSocket.on("aircraft-update", () => { return { handleKlick, handleHold };
if (!station) return;
setPage({ page: "new-status", station });
});
}, [setPage, station]);
return { handleButton };
}; };

View File

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

View File

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

View File

@@ -23,7 +23,7 @@ const Map = dynamic(() => import("_components/map/Map"), {
}); });
const PilotPage = () => { const PilotPage = () => {
const { connectedAircraft, status, } = usePilotConnectionStore((state) => state); const { connectedAircraft, status } = usePilotConnectionStore((state) => state);
const { latestMission } = useDmeStore((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 // Query will be cached anyway, due to this, displayed Markers are in sync with own Aircraft connection-warning
const { data: aircrafts } = useQuery({ const { data: aircrafts } = useQuery({
@@ -94,10 +94,20 @@ const PilotPage = () => {
</div> </div>
</div> </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="bg-base-300 flex h-full w-full flex-col p-4">
<div className="flex justify-between"> <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 <div
className="tooltip tooltip-left mb-4" className="tooltip tooltip-left mb-4"
data-tip="Dadurch wird der Einsatz erneut an den Desktop-Client gesendet." 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 = () => { export const Audio = () => {
const { const {
selectedRoom,
speakingParticipants, speakingParticipants,
resetSpeakingParticipants, resetSpeakingParticipants,
isTalking, isTalking,
@@ -37,8 +38,8 @@ export const Audio = () => {
room, room,
message, message,
removeMessage, removeMessage,
setSelectedRoom,
} = useAudioStore(); } = useAudioStore();
const [selectedRoom, setSelectedRoom] = useState<string>("LST_01");
useSounds({ useSounds({
isReceiving: speakingParticipants.length > 0, isReceiving: speakingParticipants.length > 0,
@@ -48,7 +49,7 @@ export const Audio = () => {
}); });
const { selectedStation, status: pilotState } = usePilotConnectionStore((state) => state); const { selectedStation, status: pilotState } = usePilotConnectionStore((state) => state);
const { selectedZone, status: dispatcherState } = useDispatchConnectionStore((state) => state); const { status: dispatcherState } = useDispatchConnectionStore((state) => state);
const session = useSession(); const session = useSession();
const [isReceivingBlick, setIsReceivingBlick] = useState(false); const [isReceivingBlick, setIsReceivingBlick] = useState(false);
const [recentSpeakers, setRecentSpeakers] = useState<typeof speakingParticipants>([]); const [recentSpeakers, setRecentSpeakers] = useState<typeof speakingParticipants>([]);
@@ -93,7 +94,7 @@ export const Audio = () => {
const canStopOtherSpeakers = dispatcherState === "connected"; const canStopOtherSpeakers = dispatcherState === "connected";
const role = const role =
(dispatcherState === "connected" && selectedZone) || (dispatcherState === "connected" && "VAR LST") ||
(pilotState == "connected" && selectedStation?.bosCallsignShort) || (pilotState == "connected" && selectedStation?.bosCallsignShort) ||
session.data?.user?.publicId; session.data?.user?.publicId;
@@ -185,20 +186,20 @@ export const Audio = () => {
</summary> </summary>
<ul className="menu dropdown-content bg-base-200 rounded-box z-[1050] w-52 p-2 shadow-sm"> <ul className="menu dropdown-content bg-base-200 rounded-box z-[1050] w-52 p-2 shadow-sm">
{ROOMS.map((r) => ( {ROOMS.map((r) => (
<li key={r}> <li key={r.id}>
<button <button
className="btn btn-sm btn-ghost relative flex items-center justify-start gap-2 text-left" className="btn btn-sm btn-ghost relative flex items-center justify-start gap-2 text-left"
onClick={() => { onClick={() => {
if (!role) return; if (!role) return;
if (selectedRoom === r) return; if (selectedRoom?.name === r.name) return;
setSelectedRoom(r); setSelectedRoom(r);
connect(r, role); connect(r, role);
}} }}
> >
{room?.name === r && ( {room?.name === r.name && (
<Disc className="text-success absolute left-2 text-sm" width={15} /> <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> </button>
</li> </li>
))} ))}

View File

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

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 { 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 { getConnectedAircraftsAPI } from "_querys/aircrafts";
import { getLivekitRooms } from "_querys/livekit"; import { getLivekitRooms } from "_querys/livekit";
import { sendSdsStatusMessageAPI } from "_querys/missions";
import { getStationsAPI } from "_querys/stations"; import { getStationsAPI } from "_querys/stations";
import { useAudioStore } from "_store/audioStore"; 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 { useSession } from "next-auth/react";
import { useEffect, useRef } from "react";
import { Toast, toast } from "react-hot-toast"; import { Toast, toast } from "react-hot-toast";
export const QUICK_RESPONSE: Record<string, string[]> = { 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 status5Sounds = useRef<HTMLAudioElement | null>(null);
const status9Sounds = useRef<HTMLAudioElement | null>(null); const status9Sounds = useRef<HTMLAudioElement | null>(null);
const session = useSession();
const { data: livekitRooms } = useQuery({ const { data: livekitRooms } = useQuery({
queryKey: ["livekit-rooms"], queryKey: ["livekit-rooms"],
queryFn: () => getLivekitRooms(), queryFn: () => getLivekitRooms(),
@@ -46,7 +50,7 @@ export const StatusToast = ({ event, t }: { event: StationStatus; t: Toast }) =>
status9Sounds.current = new Audio("/sounds/status-9.mp3"); status9Sounds.current = new Audio("/sounds/status-9.mp3");
} }
}, []); }, []);
const [aircraftDataAcurate, setAircraftDataAccurate] = useState(false);
//const mapStore = useMapStore((s) => s); //const mapStore = useMapStore((s) => s);
const { setOpenAircraftMarker, setMap } = useMapStore((store) => store); 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 station = stations?.find((s) => s.id === event.data?.stationId);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const changeAircraftMutation = useMutation({ const sendSdsStatusMutation = useMutation({
mutationFn: async ({ mutationFn: async ({ sdsMessage }: { sdsMessage: MissionSdsStatusLog }) => {
id, if (!connectedAircraft?.id) throw new Error("No connected aircraft");
update, await sendSdsStatusMessageAPI({ sdsMessage, aircraftId: connectedAircraft?.id });
}: {
id: number;
update: Prisma.ConnectedAircraftUpdateInput;
}) => {
await editConnectedAircraftAPI(id, update);
queryClient.invalidateQueries({ 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(() => { useEffect(() => {
let soundRef: React.RefObject<HTMLAudioElement | null> | null = null; let soundRef: React.RefObject<HTMLAudioElement | null> | null = null;
switch (event.status) { switch (event.status) {
@@ -103,7 +94,8 @@ export const StatusToast = ({ event, t }: { event: StationStatus; t: Toast }) =>
default: default:
soundRef = null; soundRef = null;
} }
if (audioRoom !== livekitUser?.roomName) {
if (audioRoom && livekitUser?.roomName && audioRoom !== livekitUser?.roomName) {
toast.remove(t.id); toast.remove(t.id);
return; return;
} }
@@ -121,7 +113,8 @@ export const StatusToast = ({ event, t }: { event: StationStatus; t: Toast }) =>
}; };
}, [event.status, livekitUser?.roomName, audioRoom, t.id]); }, [event.status, livekitUser?.roomName, audioRoom, t.id]);
if (!connectedAircraft || !station) return null; console.log(connectedAircraft, station);
if (!connectedAircraft || !station || !session.data) return null;
return ( return (
<BaseNotification> <BaseNotification>
<div className="flex flex-row items-center gap-14"> <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"); toast.error("Keine Flugzeug-ID gefunden");
return; return;
} }
await changeAircraftMutation.mutateAsync({ await sendSdsStatusMutation.mutateAsync({
id: event.data?.aircraftId, sdsMessage: {
update: { type: "sds-status-log",
fmsStatus: status, 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); 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 { DivIcon, Marker as LMarker, Popup as LPopup } from "leaflet";
import { useMapStore } from "_store/mapStore"; import { useMapStore } from "_store/mapStore";
import { Fragment, useCallback, useEffect, useRef, useState, useMemo } from "react"; 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 { ChevronsRightLeft, House, MessageSquareText, Minimize2 } from "lucide-react";
import { SmartPopup, calculateAnchor, useSmartPopup } from "_components/SmartPopup"; import { SmartPopup, calculateAnchor, useSmartPopup } from "_components/SmartPopup";
import FMSStatusHistory, { import FMSStatusHistory, {
@@ -396,27 +396,11 @@ const AircraftMarker = ({ aircraft }: { aircraft: ConnectedAircraft & { Station:
}; };
export const AircraftLayer = () => { export const AircraftLayer = () => {
const [aircrafts, setAircrafts] = useState<(ConnectedAircraft & { Station: Station })[]>([]); const { data: aircrafts } = useQuery({
queryKey: ["connected-aircrafts", "map"],
useEffect(() => { queryFn: () => getConnectedAircraftsAPI(),
const fetchAircrafts = async () => { refetchInterval: 15000,
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 { setMap } = useMapStore((state) => state); const { setMap } = useMapStore((state) => state);
const map = useMap(); const map = useMap();
const { const {

View File

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

View File

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

View File

@@ -24,3 +24,30 @@ export const fmsStatusDescription: { [key: string]: string } = {
o: "Warten, alle Abfrageplätze belegt", o: "Warten, alle Abfrageplätze belegt",
u: "Verstanden", 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 axios from "axios";
import { serverApi } from "_helpers/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); const respone = await serverApi.patch<Mission>(`/mission/${id}`, mission);
return respone.data; 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 ({ export const sendSdsMessageAPI = async ({
missionId, missionId,
sdsMessage, sdsMessage,

View File

@@ -21,12 +21,13 @@ import { useDispatchConnectionStore } from "_store/dispatch/connectionStore";
import { changeDispatcherAPI } from "_querys/dispatcher"; import { changeDispatcherAPI } from "_querys/dispatcher";
import { getRadioStream } from "_helpers/radioEffect"; import { getRadioStream } from "_helpers/radioEffect";
import { usePilotConnectionStore } from "_store/pilot/connectionStore"; import { usePilotConnectionStore } from "_store/pilot/connectionStore";
import { ROOMS } from "_data/livekitRooms";
let interval: NodeJS.Timeout; let interval: NodeJS.Timeout;
type TalkState = { type TalkState = {
addSpeakingParticipant: (participant: Participant) => void; addSpeakingParticipant: (participant: Participant) => void;
connect: (roomName: string, role: string) => void; connect: (room: (typeof ROOMS)[number] | undefined, role: string) => void;
connectionQuality: ConnectionQuality; connectionQuality: ConnectionQuality;
disconnect: () => void; disconnect: () => void;
isTalking: boolean; isTalking: boolean;
@@ -44,6 +45,8 @@ type TalkState = {
radioVolume: number; radioVolume: number;
dmeVolume: number; dmeVolume: number;
}; };
selectedRoom?: (typeof ROOMS)[number];
setSelectedRoom: (room: (typeof ROOMS)[number]) => void;
speakingParticipants: Participant[]; speakingParticipants: Participant[];
state: "connecting" | "connected" | "disconnected" | "error"; state: "connecting" | "connected" | "disconnected" | "error";
toggleTalking: () => void; toggleTalking: () => void;
@@ -72,6 +75,10 @@ export const useAudioStore = create<TalkState>((set, get) => ({
remoteParticipants: 0, remoteParticipants: 0,
connectionQuality: ConnectionQuality.Unknown, connectionQuality: ConnectionQuality.Unknown,
room: null, room: null,
selectedRoom: ROOMS[0],
setSelectedRoom: (room) => {
set({ selectedRoom: room });
},
resetSpeakingParticipants: (source: string) => { resetSpeakingParticipants: (source: string) => {
set({ set({
speakingParticipants: [], speakingParticipants: [],
@@ -117,11 +124,11 @@ export const useAudioStore = create<TalkState>((set, get) => ({
(oldSettings.micDeviceId !== newSettings.micDeviceId || (oldSettings.micDeviceId !== newSettings.micDeviceId ||
oldSettings.micVolume !== newSettings.micVolume) oldSettings.micVolume !== newSettings.micVolume)
) { ) {
const { room, disconnect, connect } = get(); const { room, disconnect, connect, selectedRoom } = get();
const role = room?.localParticipant.attributes.role; const role = room?.localParticipant.attributes.role;
if (room?.name || role) { if (selectedRoom || role) {
disconnect(); 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 })); set((state) => ({ isTalking: !state.isTalking, transmitBlocked: false }));
}, },
connect: async (roomName, role) => { connect: async (_room, role) => {
set({ state: "connecting" }); set({ state: "connecting" });
try { try {
@@ -172,13 +179,16 @@ export const useAudioStore = create<TalkState>((set, get) => ({
connectedRoom.removeAllListeners(); connectedRoom.removeAllListeners();
} }
const { selectedRoom } = get();
const url = process.env.NEXT_PUBLIC_LIVEKIT_URL; const url = process.env.NEXT_PUBLIC_LIVEKIT_URL;
if (!url) return console.error("NEXT_PUBLIC_LIVEKIT_URL not set"); 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"); if (!token) throw new Error("Fehlende Berechtigung");
const room = new Room({}); const room = new Room({});
await room.prepareConnection(url, token); await room.prepareConnection(url, token);
const roomConnectedSound = new Audio("/sounds/403.wav");
room room
// Connection events // Connection events
.on(RoomEvent.Connected, async () => { .on(RoomEvent.Connected, async () => {
@@ -186,7 +196,7 @@ export const useAudioStore = create<TalkState>((set, get) => ({
if (dispatchState.status === "connected" && dispatchState.connectedDispatcher?.id) { if (dispatchState.status === "connected" && dispatchState.connectedDispatcher?.id) {
changeDispatcherAPI(dispatchState.connectedDispatcher?.id, { changeDispatcherAPI(dispatchState.connectedDispatcher?.id, {
zone: roomName, zone: _room?.name || selectedRoom?.name || "VAR_LST_RD_01",
ghostMode: dispatchState.ghostMode, ghostMode: dispatchState.ghostMode,
}); });
} }
@@ -208,7 +218,7 @@ export const useAudioStore = create<TalkState>((set, get) => ({
source: Track.Source.Microphone, source: Track.Source.Microphone,
}); });
await publishedTrack.mute(); await publishedTrack.mute();
roomConnectedSound.play();
set({ localRadioTrack: publishedTrack }); set({ localRadioTrack: publishedTrack });
set({ state: "connected", room, isTalking: false, message: null }); 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 }), setHideDraftMissions: (hide) => set({ hideDraftMissions: hide }),
connectedDispatcher: null, connectedDispatcher: null,
message: "", message: "",
selectedZone: "LST_01", selectedZone: "VAR_LST_RD_01",
logoffTime: "", logoffTime: "",
ghostMode: false, ghostMode: false,
connect: async (uid, selectedZone, logoffTime, ghostMode) => connect: async (uid, selectedZone, logoffTime, ghostMode) =>
@@ -48,7 +48,7 @@ export const useDispatchConnectionStore = create<ConnectionStore>((set) => ({
dispatchSocket.on("connect", () => { dispatchSocket.on("connect", () => {
const { logoffTime, selectedZone, ghostMode } = useDispatchConnectionStore.getState(); const { logoffTime, selectedZone, ghostMode } = useDispatchConnectionStore.getState();
useAudioStore.getState().connect("LST_01", selectedZone || "Leitstelle"); useAudioStore.getState().connect(undefined, selectedZone || "Leitstelle");
dispatchSocket.emit("connect-dispatch", { dispatchSocket.emit("connect-dispatch", {
logoffTime, logoffTime,
selectedZone, 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 { create } from "zustand";
import { syncTabs } from "zustand-sync-tabs";
interface SetSdsPageParams { interface SetOffPageParams {
page: "sds"; page: "off";
station: Station; }
sdsMessage: MissionSdsLog;
interface SetStartupPageParams {
page: "startup";
} }
interface SetHomePageParams { interface SetHomePageParams {
page: "home"; page: "home";
station: Station;
fmsStatus: string;
} }
interface SetSendingStatusPageParams { interface SetVoicecallPageParams {
page: "sending-status"; page: "voice-call";
station: Station; }
interface SetSdsReceivedPopupParams {
popup: "sds-received";
} }
interface SetNewStatusPageParams { interface SetGroupSelectionPopupParams {
page: "new-status"; popup: "group-selection";
station: Station;
} }
type SetPageParams = interface SetStatusSentPopupParams {
popup: "status-sent";
}
interface SetLoginPopupParams {
popup: "login";
}
interface SetSdsSentPopupParams {
popup: "sds-sent";
}
export type SetPageParams =
| SetHomePageParams | SetHomePageParams
| SetSendingStatusPageParams | SetOffPageParams
| SetSdsPageParams | SetStartupPageParams
| SetNewStatusPageParams; | SetVoicecallPageParams;
export type SetPopupParams =
| SetStatusSentPopupParams
| SetSdsSentPopupParams
| SetGroupSelectionPopupParams
| SetSdsReceivedPopupParams
| SetLoginPopupParams;
interface StringifiedData {
sdsText?: string;
sentSdsText?: string;
groupSelectionGroupId?: string;
callTextHeader?: string;
}
interface MrtStore { interface MrtStore {
page: SetPageParams["page"]; page: SetPageParams["page"];
popup?: SetPopupParams["popup"];
lines: DisplayLineProps[]; stringifiedData: StringifiedData;
setStringifiedData: (data: Partial<StringifiedData>) => void;
setPage: (pageData: SetPageParams) => 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>( export const useMrtStore = create<MrtStore>((set) => ({
syncTabs( page: "off",
(set) => ({ nightMode: false,
page: "home", stringifiedData: {
pageData: { groupSelectionGroupId: "2201",
message: "", },
}, setNightMode: (nightMode) => set({ nightMode }),
lines: [ setStringifiedData: (data) =>
{ set((state) => ({
textLeft: "VAR.#", stringifiedData: { ...state.stringifiedData, ...data },
textSize: "2", })),
}, setPopup: (popupData) => {
{ set({ popup: popupData ? popupData.popup : undefined });
textLeft: "No Data", },
textSize: "3", setPage: (pageData) => {
}, set({ page: pageData.page });
], },
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
},
),
);

View File

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

View File

@@ -1,7 +1,6 @@
import { Mission, Station, User } from "@repo/db"; import { Mission, Station, User } from "@repo/db";
import { DisplayLineProps } from "(app)/pilot/_components/dme/Dme"; import { DisplayLineProps } from "(app)/pilot/_components/dme/Dme";
import { create } from "zustand"; import { create } from "zustand";
import { syncTabs } from "zustand-sync-tabs";
interface SetHomePageParams { interface SetHomePageParams {
page: "home"; page: "home";
@@ -45,197 +44,190 @@ interface MrtStore {
let interval: NodeJS.Timeout | null = null; let interval: NodeJS.Timeout | null = null;
export const useDmeStore = create<MrtStore>( export const useDmeStore = create<MrtStore>((set) => ({
syncTabs( page: "home",
(set) => ({ pageData: {
page: "home", message: "",
pageData: { },
message: "", lines: [
},
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;
}
},
}),
{ {
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 */ 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 { @theme {
--color-rescuetrack: #46b7a3; --color-rescuetrack: #46b7a3;
--color-rescuetrack-highlight: #ff4500; --color-rescuetrack-highlight: #ff4500;

View File

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

View File

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

View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,9 +1,5 @@
FROM node:22-alpine AS base FROM node:22-alpine AS base
ENV PNPM_HOME="/usr/local/pnpm"
ENV PATH="${PNPM_HOME}:${PATH}"
ARG NEXT_PUBLIC_HUB_URL ARG NEXT_PUBLIC_HUB_URL
ARG NEXT_PUBLIC_HUB_SERVER_URL ARG NEXT_PUBLIC_HUB_SERVER_URL
ARG NEXT_PUBLIC_DISCORD_URL ARG NEXT_PUBLIC_DISCORD_URL
@@ -16,13 +12,13 @@ ENV NEXT_PUBLIC_DISCORD_URL=${NEXT_PUBLIC_DISCORD_URL}
ENV NEXT_PUBLIC_MOODLE_URL=${NEXT_PUBLIC_MOODLE_URL} ENV NEXT_PUBLIC_MOODLE_URL=${NEXT_PUBLIC_MOODLE_URL}
ENV NEXT_PUBLIC_DISPATCH_URL=${NEXT_PUBLIC_DISPATCH_URL} ENV NEXT_PUBLIC_DISPATCH_URL=${NEXT_PUBLIC_DISPATCH_URL}
RUN corepack enable && corepack prepare pnpm@latest --activate
RUN echo "NEXT_PUBLIC_DISCORD_URL=${NEXT_PUBLIC_DISCORD_URL}"
RUN pnpm add -g turbo@^2.5
FROM base AS builder 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
RUN apk update RUN apk update
RUN apk add --no-cache libc6-compat RUN apk add --no-cache libc6-compat
@@ -33,6 +29,13 @@ COPY . .
RUN turbo prune hub --docker RUN turbo prune hub --docker
FROM base AS installer 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 update
RUN apk add --no-cache libc6-compat RUN apk add --no-cache libc6-compat
@@ -44,21 +47,24 @@ RUN pnpm install
# Build the project # Build the project
COPY --from=builder /usr/app/out/full/ . COPY --from=builder /usr/app/out/full/ .
RUN turbo run build RUN turbo run build --filter=hub...
FROM base AS runner FROM node:22-alpine AS runner
WORKDIR /usr/app WORKDIR /usr/app
# Don't run production as root # Don't run production as root
RUN addgroup --system --gid 1001 nodejs RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs RUN adduser --system --uid 1001 nextjs
USER nextjs
# Automatically leverage output traces to reduce image size # Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing # 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/hub/.next/standalone ./
COPY --from=installer --chown=nextjs:nodejs /usr/app/apps/hub/.next/static ./apps/hub/.next/static
COPY --from=installer --chown=nextjs:nodejs /usr/app/apps/hub/public ./apps/hub/public
USER nextjs
# Expose the application port # Expose the application port
EXPOSE 3000 EXPOSE 3000
CMD ["pnpm", "--dir", "apps/hub", "run", "start"] CMD ["node", "apps/hub/server.js"]

View File

@@ -264,6 +264,7 @@ export const Form = ({ event }: { event?: Event }) => {
showSearch showSearch
getFilter={(searchTerm) => getFilter={(searchTerm) =>
({ ({
AND: [{ eventId: event?.id }],
OR: [ OR: [
{ {
User: { User: {

View File

@@ -776,13 +776,19 @@ export const AdminForm = ({
{discordAccount && ( {discordAccount && (
<tr> <tr>
<td> <td>
<Image {discordAccount.avatar ? (
src={`https://cdn.discordapp.com/avatars/${discordAccount.discordId}/${discordAccount.avatar}.png`} <Image
alt="Discord Avatar" src={`https://cdn.discordapp.com/avatars/${discordAccount.discordId}/${discordAccount.avatar}.png`}
width={40} alt="Discord Avatar"
height={40} width={40}
className="h-10 w-10 rounded-full" height={40}
/> className="h-10 w-10 rounded-full"
/>
) : (
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-gray-500 text-white">
N/A
</div>
)}
</td> </td>
<td>{discordAccount.username}</td> <td>{discordAccount.username}</td>
<td>{discordAccount.discordId}</td> <td>{discordAccount.discordId}</td>
@@ -792,15 +798,20 @@ export const AdminForm = ({
{formerDiscordAccounts.map((account) => ( {formerDiscordAccounts.map((account) => (
<tr key={account.discordId}> <tr key={account.discordId}>
<td> <td>
{account.DiscordAccount && ( {account.DiscordAccount &&
<Image (account.DiscordAccount.avatar ? (
src={`https://cdn.discordapp.com/avatars/${account.DiscordAccount.discordId}/${account.DiscordAccount.avatar}.png`} <Image
alt="Discord Avatar" src={`https://cdn.discordapp.com/avatars/${account.DiscordAccount.discordId}/${account.DiscordAccount.avatar}.png`}
width={40} alt="Discord Avatar"
height={40} width={40}
className="h-10 w-10 rounded-full" height={40}
/> className="h-10 w-10 rounded-full"
)} />
) : (
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-gray-500 text-white">
N/A
</div>
))}
</td> </td>
<td>{account.DiscordAccount?.username || "Unbekannt"}</td> <td>{account.DiscordAccount?.username || "Unbekannt"}</td>
<td>{account.DiscordAccount?.discordId || "Unbekannt"}</td> <td>{account.DiscordAccount?.discordId || "Unbekannt"}</td>

View File

@@ -2,6 +2,7 @@
/* const removeImports = require("next-remove-imports")(); */ /* const removeImports = require("next-remove-imports")(); */
/* const nextConfig = removeImports({}); */ /* const nextConfig = removeImports({}); */
const nextConfig = { const nextConfig = {
output: "standalone",
images: { images: {
domains: ["cdn.discordapp.com", "nextcloud.virtualairrescue.com"], domains: ["cdn.discordapp.com", "nextcloud.virtualairrescue.com"],
}, },

View File

@@ -21,7 +21,7 @@
"node": ">=18", "node": ">=18",
"pnpm": ">=10" "pnpm": ">=10"
}, },
"packageManager": "pnpm@10.13.1", "packageManager": "pnpm@10.28.0",
"workspaces": [ "workspaces": [
"apps/*", "apps/*",
"packages/*" "packages/*"

View File

@@ -1,4 +1,5 @@
import { Station } from "../../generated/client"; import { Station } from "../../generated/client";
import { StationStatus } from "./SocketEvents";
import { PublicUser } from "./User"; import { PublicUser } from "./User";
export interface MissionVehicleLog { export interface MissionVehicleLog {
@@ -37,6 +38,19 @@ export interface MissionSdsLog {
}; };
} }
export interface MissionSdsStatusLog {
type: "sds-status-log";
auto: false;
timeStamp: string;
data: {
direction: "to-lst" | "to-aircraft";
stationId: number;
station: Station;
user: PublicUser;
status: string;
};
}
export interface MissionMessageLog { export interface MissionMessageLog {
type: "message-log"; type: "message-log";
auto: false; auto: false;
@@ -90,4 +104,5 @@ export type MissionLog =
| MissionAlertLogAuto | MissionAlertLogAuto
| MissionCompletedLog | MissionCompletedLog
| MissionVehicleLog | MissionVehicleLog
| MissionReopenedLog; | MissionReopenedLog
| MissionSdsStatusLog;

521
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -23,6 +23,9 @@ overrides:
next@>=15.0.0 <=15.4.4: '>=15.4.5' next@>=15.0.0 <=15.4.4: '>=15.4.5'
next@>=15.0.0-canary.0 <15.4.7: '>=15.4.7' next@>=15.0.0-canary.0 <15.4.7: '>=15.4.7'
next@>=15.4.0-canary.0 <15.4.8: '>=15.4.8' next@>=15.4.0-canary.0 <15.4.8: '>=15.4.8'
next@>=15.4.0-canary.0 <15.4.9: '>=15.4.9'
nodemailer@<7.0.7: '>=7.0.7' nodemailer@<7.0.7: '>=7.0.7'
nodemailer@<=7.0.10: '>=7.0.11' nodemailer@<=7.0.10: '>=7.0.11'
playwright@<1.55.1: '>=1.55.1' playwright@<1.55.1: '>=1.55.1'
preact@>=10.26.5 <10.26.10: '>=10.26.10'
qs@<6.14.1: '>=6.14.1'