1 Commits

Author SHA1 Message Date
PxlLoewe
f48653c82c Wrapper size Dynamic, class cleanup 2025-10-04 19:56:50 +02:00
204 changed files with 2588 additions and 5120 deletions

View File

@@ -1,5 +1,7 @@
{
"recommendations": [
"EthanSK.restore-terminals",
"dbaeumer.vscode-eslint",
"VisualStudioExptTeam.vscodeintellicode"
]
}

View File

@@ -1,9 +1,6 @@
import { DISCORD_ROLES, MissionLog, NotificationPayload, prisma } from "@repo/db";
import { MissionLog, NotificationPayload, prisma } from "@repo/db";
import { io } from "index";
import cron from "node-cron";
import { setUserStandardNamePermissions } from "routes/helper";
import { changeMemberRoles } from "routes/member";
import client from "./discord";
const removeMission = async (id: number, reason: string) => {
const log: MissionLog = {
@@ -37,6 +34,7 @@ const removeMission = async (id: number, reason: string) => {
console.log(`Mission ${updatedMission.id} closed due to inactivity.`);
};
const removeClosedMissions = async () => {
const oldMissions = await prisma.mission.findMany({
where: {
@@ -122,37 +120,6 @@ const removeClosedMissions = async () => {
return removeMission(mission.id, "dem freimelden aller Stationen");
});
};
const syncDiscordImgUrls = async () => {
try {
const discordAccounts = await prisma.discordAccount.findMany({
where: {
updatedAt: {
lt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
},
User: {
isNot: null,
},
},
});
for (const account of discordAccounts) {
client.users.fetch(account.discordId).then((discordUser) => {
const nextAvatar = discordUser?.avatar ?? null;
if (typeof nextAvatar !== "string") return;
if (nextAvatar === account.avatar) return;
prisma.discordAccount.update({
where: {
id: account.id,
},
data: {
avatar: nextAvatar,
},
});
});
}
} catch (error) {}
};
const removeConnectedAircrafts = async () => {
const connectedAircrafts = await prisma.connectedAircraft.findMany({
where: {
@@ -173,92 +140,9 @@ const removeConnectedAircrafts = async () => {
}
});
};
const removePermissionsForBannedUsers = async () => {
try {
const removePermissionsPenaltys = await prisma.penalty.findMany({
where: {
removePermissionApplied: false,
User: {
DiscordAccount: { isNot: null },
},
},
include: {
User: {
include: {
DiscordAccount: true,
FormerDiscordAccounts: true,
},
},
},
});
const addPermissionsPenaltys = await prisma.penalty.findMany({
where: {
addPermissionApplied: false,
User: {
DiscordAccount: { isNot: null },
},
OR: [{ suspended: true }, { until: { lt: new Date().toISOString() } }],
},
include: {
User: {
include: {
DiscordAccount: true,
FormerDiscordAccounts: true,
},
},
},
});
for (const penalty of removePermissionsPenaltys) {
const user = penalty.User;
console.log(`Removing roles for user ${user.id} due to penalty ${penalty.id}`);
await changeMemberRoles(
user.DiscordAccount!.discordId,
[DISCORD_ROLES.PILOT, DISCORD_ROLES.DISPATCHER],
"remove",
);
for (const formerAccount of user.FormerDiscordAccounts) {
await changeMemberRoles(
formerAccount.discordId,
[DISCORD_ROLES.PILOT, DISCORD_ROLES.DISPATCHER],
"remove",
);
}
await prisma.penalty.update({
where: { id: penalty.id },
data: { removePermissionApplied: true },
});
}
for (const penalty of addPermissionsPenaltys) {
console.log(`Restoring roles for user ${penalty.userId} due to penalty ${penalty.id}`);
await setUserStandardNamePermissions({
memberId: penalty.User.DiscordAccount!.discordId,
userId: penalty.userId,
});
await prisma.penalty.update({
where: { id: penalty.id },
data: { addPermissionApplied: true },
});
}
} catch (error) {
console.error("Error removing permissions for banned users:", error);
}
};
cron.schedule("0 0 * * *", async () => {
try {
await syncDiscordImgUrls();
} catch (error) {
console.error("Error on daily cron job:", error);
}
});
cron.schedule("*/1 * * * *", async () => {
try {
await removePermissionsForBannedUsers();
await removeClosedMissions();
await removeConnectedAircrafts();
} catch (error) {

View File

@@ -7,25 +7,22 @@ const router: Router = Router();
export const eventCompleted = (event: Event, participant?: Participant) => {
if (!participant) return false;
if (event.finisherMoodleCourseId && !participant.finisherMoodleCurseCompleted) return false;
if (event.hasPresenceEvents && !participant.attended) return false;
return true;
};
export const setUserStandardNamePermissions = async ({
memberId,
userId,
}: {
memberId: string;
userId: string;
}) => {
router.post("/set-standard-name", async (req, res) => {
const { memberId, userId } = req.body;
const user = await prisma.user.findUnique({
where: {
id: userId,
},
});
if (!user) {
res.status(404).json({ error: "User not found" });
return;
}
const participant = await prisma.participant.findMany({
where: {
userId: user.id,
@@ -35,25 +32,6 @@ export const setUserStandardNamePermissions = async ({
},
});
const activePenaltys = await prisma.penalty.findMany({
where: {
userId: user.id,
OR: [
{
type: "BAN",
suspended: false,
},
{
type: "TIME_BAN",
suspended: false,
until: {
gt: new Date().toISOString(),
},
},
],
},
});
participant.forEach(async (p) => {
if (!p.Event.discordRoleId) return;
if (eventCompleted(p.Event, p)) {
@@ -66,29 +44,12 @@ export const setUserStandardNamePermissions = async ({
const publicUser = getPublicUser(user);
const member = await getMember(memberId);
if (!member) throw new Error("Member not found");
await member.setNickname(`${publicUser.fullName} - ${user.publicId}`);
const isPilot = user.permissions.includes("PILOT");
const isDispatcher = user.permissions.includes("DISPO");
if (activePenaltys.length > 0) {
await changeMemberRoles(memberId, [DISCORD_ROLES.PILOT, DISCORD_ROLES.DISPATCHER], "remove");
} else {
await changeMemberRoles(memberId, [DISCORD_ROLES.PILOT], isPilot ? "add" : "remove");
await changeMemberRoles(memberId, [DISCORD_ROLES.DISPATCHER], isDispatcher ? "add" : "remove");
}
};
router.post("/set-standard-name", async (req, res) => {
try {
const { memberId, userId } = req.body;
await setUserStandardNamePermissions({ memberId, userId });
res.status(200).json({ message: "Standard name and permissions set" });
} catch (error) {
res.status(500).json({ error: (error as unknown as Error).message });
}
await changeMemberRoles(memberId, [DISCORD_ROLES.PILOT], isPilot ? "add" : "remove");
await changeMemberRoles(memberId, [DISCORD_ROLES.DISPATCHER], isDispatcher ? "add" : "remove");
});
export default router;

View File

@@ -9,23 +9,13 @@ if (!GUILD_ID) {
const router: Router = Router();
export const getMember = async (memberId: string) => {
let guild = client.guilds.cache.get(GUILD_ID);
if (!guild) {
guild = await client.guilds.fetch(GUILD_ID);
}
const guild = client.guilds.cache.get(GUILD_ID);
if (!guild) throw new Error("Guild not found");
try {
let member = guild.members.cache.get(memberId);
if (!member) {
member = await guild.members.fetch(memberId).catch((e) => undefined);
}
return member;
return guild.members.cache.get(memberId) ?? (await guild.members.fetch(memberId));
} catch (error) {
console.error("Error fetching member:", error);
return null;
throw new Error("Member not found");
}
};
@@ -37,15 +27,11 @@ router.post("/rename", async (req: Request, res: Response) => {
}
try {
const member = await getMember(memberId);
if (!member) {
res.status(404).json({ error: "Member not found" });
return;
}
await member.setNickname(newName);
console.log(`Member ${member.id} renamed to ${newName}`);
res.status(200).json({ message: "Member renamed successfully" });
} catch (error) {
console.error("Error renaming member:", (error as Error).message);
console.error("Error renaming member:", error);
res.status(500).json({ error: "Failed to rename member" });
}
});
@@ -56,9 +42,6 @@ export const changeMemberRoles = async (
action: "add" | "remove",
) => {
const member = await getMember(memberId);
if (!member) {
throw new Error("Member not found");
}
const currentRoleIds = member.roles.cache.map((role) => role.id);
const filteredRoleIds =
@@ -84,7 +67,7 @@ const handleRoleChange = (action: "add" | "remove") => async (req: Request, res:
const result = await changeMemberRoles(memberId, roleIds, action);
res.status(200).json(result);
} catch (error) {
console.error(`Error ${action}ing roles:`, (error as Error).message);
console.error(`Error ${action}ing roles:`, error);
res.status(500).json({ error: `Failed to ${action} roles` });
}
};

View File

@@ -43,20 +43,14 @@ router.post("/admin-embed", async (req, res) => {
{ name: "angemeldet als", value: report.reportedUserRole, inline: true },
{
name: "gemeldet von",
value: report.Sender
? `${report.Sender?.firstname} ${report.Sender?.lastname} (${report.Sender?.publicId})`
: "System",
value: `${report.Sender?.firstname} ${report.Sender?.lastname} (${report.Sender?.publicId})`,
},
)
.setFooter({
text: "Bitte reagiere mit 🫡, wenn du den Report bearbeitet hast, oder mit ✅, wenn er abgeschlossen ist.",
})
.setTimestamp(new Date(report.timestamp));
if (report.reviewed) {
embed.setColor("DarkGreen");
} else {
embed.setColor("DarkRed");
}
.setTimestamp(new Date(report.timestamp))
.setColor("DarkRed");
const reportsChannel = await client.channels.fetch(process.env.DISCORD_REPORT_CHANNEL!);
if (!reportsChannel || !reportsChannel.isSendable()) {
@@ -65,9 +59,7 @@ router.post("/admin-embed", async (req, res) => {
}
const message = await reportsChannel.send({ embeds: [embed] });
message.react("🫡").catch(console.error);
if (!report.reviewed) {
message.react("✅").catch(console.error);
}
message.react("✅").catch(console.error);
res.json({
message: "Report embed sent to Discord channel",
});

View File

@@ -1,8 +1,7 @@
DISPATCH_SERVER_PORT=3002
REDIS_HOST=localhost
REDIS_PORT=6379
CORE_SERVER_URL=http://localhost:3005
CORE_SERVER_URL=http://core-server
DISPATCH_APP_TOKEN=dispatch
LIVEKIT_API_KEY=APIAnsGdtdYp2Ho
LIVEKIT_API_SECRET=tdPjVsYUx8ddC7K9NvdmVAeLRF9GeADD6Fedm1x63fWC
AUTH_HUB_SECRET=var
LIVEKIT_API_SECRET=tdPjVsYUx8ddC7K9NvdmVAeLRF9GeADD6Fedm1x63fWC

View File

@@ -18,10 +18,7 @@ const app = express();
const server = createServer(app);
export const io = new Server(server, {
adapter:
process.env.REDIS_HOST && process.env.REDIS_PORT
? createAdapter(pubClient, subClient)
: undefined,
adapter: createAdapter(pubClient, subClient),
cors: {},
});
io.use(jwtMiddleware);

View File

@@ -1,4 +1,4 @@
import axios, { AxiosError } from "axios";
import axios from "axios";
const discordAxiosClient = axios.create({
baseURL: process.env.CORE_SERVER_URL,
@@ -11,10 +11,7 @@ export const renameMember = async (memberId: string, newName: string) => {
newName,
})
.catch((error) => {
console.error(
"Error renaming member:",
(error as AxiosError<{ error: string }>).response?.data.error || error.message,
);
console.error("Error renaming member:", error);
});
};
@@ -25,10 +22,7 @@ export const addRolesToMember = async (memberId: string, roleIds: string[]) => {
roleIds,
})
.catch((error) => {
console.error(
"Error adding roles to member:",
(error as AxiosError<{ error: string }>).response?.data.error || error.message,
);
console.error("Error adding roles to member:", error);
});
};
@@ -39,10 +33,7 @@ export const removeRolesFromMember = async (memberId: string, roleIds: string[])
roleIds,
})
.catch((error) => {
console.error(
"Error removing roles from member:",
(error as AxiosError<{ error: string }>).response?.data.error || error.message,
);
console.error("Error removing roles from member:", error);
});
};
@@ -52,9 +43,6 @@ export const sendReportEmbed = async (reportId: number) => {
reportId,
})
.catch((error) => {
console.error(
"Error removing roles from member:",
(error as AxiosError<{ error: string }>).response?.data.error || error.message,
);
console.error("Error removing roles from member:", error);
});
};

View File

@@ -1,17 +1,13 @@
import { createClient, RedisClientType } from "redis";
export const pubClient: RedisClientType = createClient({
url: `redis://${process.env.REDIS_HOST || "localhost"}:${process.env.REDIS_PORT || 6379}`,
url: `redis://${process.env.REDIS_HOST}:${process.env.REDIS_PORT}`,
});
export const subClient: RedisClientType = pubClient.duplicate();
if (!process.env.REDIS_HOST || !process.env.REDIS_PORT) {
console.warn("REDIS_HOST or REDIS_PORT not set, skipping Redis connection");
} else {
Promise.all([pubClient.connect(), subClient.connect()]).then(() => {
console.log("Redis connected");
});
}
Promise.all([pubClient.connect(), subClient.connect()]).then(() => {
console.log("Redis connected");
});
pubClient.on("error", (err) => console.log("Redis Client Error", err));
subClient.on("error", (err) => console.log("Redis Client Error", err));

View File

@@ -2,7 +2,6 @@ import {
AdminMessage,
getPublicUser,
MissionLog,
MissionSdsStatusLog,
NotificationPayload,
Prisma,
prisma,
@@ -131,54 +130,6 @@ 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,
},
},
});
const user = await prisma.user.findFirst({
where: { publicId: sdsMessage.data.user.publicId, firstname: sdsMessage.data.user.firstname },
});
if (!user) {
res.status(404).json({ error: "User not found" });
return;
}
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,
userId: user.id,
},
} as NotificationPayload);
res.sendStatus(204);
});
// Kick a connectedAircraft by ID
router.delete("/:id", async (req, res) => {
const { id } = req.params;

View File

@@ -87,29 +87,6 @@ router.patch("/:id", async (req, res) => {
data: req.body,
});
io.to("dispatchers").emit("update-mission", { updatedMission });
if (req.body.state === "finished") {
const missionUsers = await prisma.missionOnStationUsers.findMany({
where: {
missionId: updatedMission.id,
},
select: {
userId: true,
},
});
console.log("Notifying users about mission closure:", missionUsers);
missionUsers?.forEach(({ userId }) => {
io.to(`user:${userId}`).emit("notification", {
type: "mission-closed",
status: "closed",
message: `Einsatz ${updatedMission.publicId} wurde beendet`,
data: {
missionId: updatedMission.id,
publicMissionId: updatedMission.publicId,
},
} as NotificationPayload);
});
}
res.json(updatedMission);
} catch (error) {
console.error(error);

View File

@@ -96,8 +96,6 @@ export const handleConnectPilot =
lastHeartbeat: debug ? nowPlus2h.toISOString() : undefined,
posLat: randomPos?.lat,
posLng: randomPos?.lng,
posXplanePluginActive: debug ? true : undefined,
posH145active: debug ? true : undefined,
},
});

View File

@@ -1,12 +1,12 @@
FROM node:22-alpine AS base
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=""
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
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,20 +31,12 @@ 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
@@ -58,23 +50,19 @@ COPY --from=builder /usr/app/out/full/ .
RUN turbo run build
FROM node:22-alpine AS runner
FROM base 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/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
COPY --from=installer --chown=nextjs:nodejs /usr/app/ ./
# Expose the application port
EXPOSE 3000
ENV HOST=0.0.0.0
EXPOSE 3001
CMD ["node", "apps/dispatch/server.js"]
CMD ["pnpm", "--dir", "apps/dispatch", "run", "start"]

View File

@@ -1,36 +0,0 @@
import { ExitIcon } from "@radix-ui/react-icons";
import Link from "next/link";
import { prisma } from "@repo/db";
import { ChangelogWrapper } from "_components/navbar/ChangelogWrapper";
import ModeSwitchDropdown from "_components/navbar/ModeSwitchDropdown";
import { getServerSession } from "api/auth/[...nextauth]/auth";
import AdminPanel from "_components/navbar/AdminPanel";
export default async function Navbar({ children }: { children: React.ReactNode }) {
const session = await getServerSession();
const latestChangelog = await prisma.changelog.findFirst({
orderBy: {
createdAt: "desc",
},
});
return (
<div className="navbar bg-base-100 flex justify-between gap-5 shadow-sm">
<div className="flex items-center gap-2">
<div>
<p className="text-xl font-semibold normal-case">VAR Operations Center</p>
<ChangelogWrapper latestChangelog={latestChangelog} />
</div>
{session?.user.permissions.includes("ADMIN_KICK") && <AdminPanel />}
</div>
<div className="flex items-center gap-2">
{children}
<ModeSwitchDropdown className="dropdown-center" btnClassName="btn-ghost" />
<Link href={"/logout"}>
<button className="btn btn-ghost">
<ExitIcon className="h-4 w-4" />
</button>
</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,63 @@
import { Connection } from "./_components/Connection";
import { Audio } from "../../../../_components/Audio/Audio";
import { ExitIcon, ExternalLinkIcon } from "@radix-ui/react-icons";
import Link from "next/link";
import { Settings } from "./_components/Settings";
import AdminPanel from "_components/navbar/AdminPanel";
import { getServerSession } from "api/auth/[...nextauth]/auth";
import { WarningAlert } from "_components/navbar/PageAlert";
import { Radar } from "lucide-react";
import { ChangelogWrapper } from "_components/navbar/ChangelogWrapper";
import { prisma } from "@repo/db";
export default async function Navbar() {
const session = await getServerSession();
const latestChangelog = await prisma.changelog.findFirst({
orderBy: {
createdAt: "desc",
},
});
return (
<div className="navbar bg-base-100 flex justify-between gap-5 shadow-sm">
<div className="flex items-center gap-2">
<div>
<p className="text-xl font-semibold normal-case">VAR Leitstelle</p>
<ChangelogWrapper latestChangelog={latestChangelog} />
</div>
{session?.user.permissions.includes("ADMIN_KICK") && <AdminPanel />}
</div>
<WarningAlert />
<div className="flex items-center gap-5">
<div className="flex items-center gap-2">
<Audio />
</div>
<div className="flex items-center">
<Connection />
</div>
<div className="flex items-center">
<Settings />
<Link href={"/tracker"} target="_blank" rel="noopener noreferrer">
<button className="btn btn-ghost">
<Radar size={19} /> Tracker
</button>
</Link>
<Link
href={process.env.NEXT_PUBLIC_HUB_URL || "#!"}
target="_blank"
rel="noopener noreferrer"
>
<button className="btn btn-ghost">
<ExternalLinkIcon className="h-4 w-4" /> HUB
</button>
</Link>
<Link href={"/logout"}>
<button className="btn btn-ghost">
<ExitIcon className="h-4 w-4" />
</button>
</Link>
</div>
</div>
</div>
);
}

View File

@@ -1,7 +1,7 @@
/* eslint-disable react-hooks/exhaustive-deps */
"use client";
import { useSession } from "next-auth/react";
import { useDispatchConnectionStore } from "../../../../_store/dispatch/connectionStore";
import { useDispatchConnectionStore } from "../../../../../_store/dispatch/connectionStore";
import { useEffect, useRef, useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { Prisma } from "@repo/db";
@@ -14,7 +14,7 @@ export const ConnectionBtn = () => {
const connection = useDispatchConnectionStore((state) => state);
const [form, setForm] = useState({
logoffTime: "",
selectedZone: "VAR_LST_RD_01",
selectedZone: "LST_01",
ghostMode: false,
});
const changeDispatcherMutation = useMutation({

View File

@@ -1,19 +1,18 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { GearIcon } from "@radix-ui/react-icons";
import { Info, SettingsIcon, Volume2 } from "lucide-react";
import { SettingsIcon, Volume2 } from "lucide-react";
import MicVolumeBar from "_components/MicVolumeIndication";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQuery } from "@tanstack/react-query";
import { editUserAPI, getUserAPI } from "_querys/user";
import { useSession } from "next-auth/react";
import { useAudioStore } from "_store/audioStore";
import toast from "react-hot-toast";
import { useMapStore } from "_store/mapStore";
import { Button } from "@repo/shared-components";
import { set } from "date-fns";
export const SettingsBtn = () => {
const session = useSession();
const queryClient = useQueryClient();
const [inputDevices, setInputDevices] = useState<MediaDeviceInfo[]>([]);
const { data: user } = useQuery({
@@ -24,9 +23,6 @@ export const SettingsBtn = () => {
const editUserMutation = useMutation({
mutationFn: editUserAPI,
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["user", session.data?.user.id] });
},
});
useEffect(() => {
@@ -44,7 +40,6 @@ export const SettingsBtn = () => {
micVolume: user?.settingsMicVolume || 1,
radioVolume: user?.settingsRadioVolume || 0.8,
autoCloseMapPopup: user?.settingsAutoCloseMapPopup || false,
useHPGAsDispatcher: user?.settingsUseHPGAsDispatcher || false,
});
const { setSettings: setAudioSettings } = useAudioStore((state) => state);
@@ -62,8 +57,7 @@ export const SettingsBtn = () => {
micDeviceId: user.settingsMicDevice,
micVolume: user.settingsMicVolume || 1,
radioVolume: user.settingsRadioVolume || 0.8,
autoCloseMapPopup: user.settingsAutoCloseMapPopup,
useHPGAsDispatcher: user.settingsUseHPGAsDispatcher,
autoCloseMapPopup: user.settingsAutoCloseMapPopup || false,
});
setUserSettings({
settingsAutoCloseMapPopup: user.settingsAutoCloseMapPopup || false,
@@ -81,14 +75,10 @@ export const SettingsBtn = () => {
useEffect(() => {
const setDevices = async () => {
if (typeof navigator !== "undefined" && navigator.mediaDevices?.enumerateDevices) {
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: false, audio: true });
const devices = await navigator.mediaDevices.enumerateDevices();
setInputDevices(devices.filter((d) => d.kind === "audioinput"));
stream.getTracks().forEach((track) => track.stop());
} catch (error) {
console.error("Error accessing media devices.", error);
}
const stream = await navigator.mediaDevices.getUserMedia({ video: false, audio: true });
const devices = await navigator.mediaDevices.enumerateDevices();
setInputDevices(devices.filter((d) => d.kind === "audioinput"));
stream.getTracks().forEach((track) => track.stop());
}
};
@@ -208,28 +198,6 @@ export const SettingsBtn = () => {
/>
Popups automatisch schließen
</div>
<div className="mt-2 flex w-full items-center gap-2">
<input
type="checkbox"
className="toggle"
checked={settings.useHPGAsDispatcher}
onChange={(e) => {
setSettingsPartial({ useHPGAsDispatcher: e.target.checked });
}}
/>
HPG Validierung verwenden{" "}
<div
className="tooltip tooltip-warning"
data-tip="Achtung! Mit der Client Version v2.0.1.0 werden Einsätze auch ohne Validierung an die
HPG gesendet. Die Validierung über die HPG kann zu Verzögerungen bei der
Einsatzübermittlung führen, insbesondere wenn das HPG script den Einsatz nicht sofort
validieren kann. Es wird empfohlen, diese Option nur zu aktivieren, wenn es Probleme
mit der Einsatzübermittlung gibt oder wenn die HPG Validierung ausdrücklich gewünscht
wird."
>
<Info className="text-error" size={16} />
</div>
</div>
<div className="modal-action flex justify-between">
<button
@@ -243,7 +211,7 @@ export const SettingsBtn = () => {
>
Schließen
</button>
<Button
<button
className="btn btn-soft btn-success"
type="submit"
onSubmit={() => false}
@@ -256,7 +224,6 @@ export const SettingsBtn = () => {
settingsMicVolume: settings.micVolume,
settingsRadioVolume: settings.radioVolume,
settingsAutoCloseMapPopup: settings.autoCloseMapPopup,
settingsUseHPGAsDispatcher: settings.useHPGAsDispatcher,
},
});
setAudioSettings({
@@ -272,7 +239,7 @@ export const SettingsBtn = () => {
}}
>
Speichern
</Button>
</button>
</div>
</div>
</dialog>

View File

@@ -0,0 +1,26 @@
"use client";
import { MoonIcon, SunIcon } from "@radix-ui/react-icons";
interface ThemeSwapProps {
isDark: boolean;
toggleTheme: () => void;
}
export const ThemeSwap: React.FC<ThemeSwapProps> = ({
isDark,
toggleTheme,
}) => {
return (
<label className="swap swap-rotate">
<input
type="checkbox"
className="theme-controller"
checked={isDark}
onChange={toggleTheme}
/>
<MoonIcon className="swap-off h-5 w-5 fill-current" />
<SunIcon className="swap-on h-5 w-5 fill-current" />
</label>
);
};

View File

@@ -28,11 +28,8 @@ import { selectRandomHPGMissionSzenery } from "_helpers/selectRandomHPGMission";
import { AxiosError } from "axios";
import { cn } from "@repo/shared-components";
import { StationsSelect } from "(app)/dispatch/_components/StationSelect";
import { getUserAPI } from "_querys/user";
export const MissionForm = () => {
const session = useSession();
const { editingMissionId, setEditingMission } = usePannelStore();
const queryClient = useQueryClient();
const { setSearchElements, searchElements, setContextMenu } = useMapStore((s) => s);
@@ -47,10 +44,6 @@ export const MissionForm = () => {
queryFn: () => getConnectedAircraftsAPI(),
refetchInterval: 10000,
});
const { data: user } = useQuery({
queryKey: ["user", session.data?.user.id],
queryFn: () => getUserAPI(session.data!.user.id),
});
const createMissionMutation = useMutation({
mutationFn: createMissionAPI,
@@ -88,6 +81,7 @@ export const MissionForm = () => {
},
});
const session = useSession();
const defaultFormValues = React.useMemo(
() =>
({
@@ -114,7 +108,6 @@ export const MissionForm = () => {
hpgSelectedMissionString: null,
hpg: null,
missionLog: [],
xPlaneObjects: [],
}) as MissionOptionalDefaults,
[session.data?.user.id],
);
@@ -123,16 +116,13 @@ export const MissionForm = () => {
resolver: zodResolver(MissionOptionalDefaultsSchema),
defaultValues: defaultFormValues,
});
const { missionFormValues, setOpen, setMissionFormValues } = usePannelStore((state) => state);
const { missionFormValues, setOpen } = usePannelStore((state) => state);
const validationRequired =
HPGValidationRequired(
form.watch("missionStationIds"),
aircrafts,
form.watch("hpgMissionString"),
) &&
!form.watch("hpgMissionString")?.startsWith("kein Szenario") &&
user?.settingsUseHPGAsDispatcher;
const validationRequired = HPGValidationRequired(
form.watch("missionStationIds"),
aircrafts,
form.watch("hpgMissionString"),
);
useEffect(() => {
if (session.data?.user.id) {
@@ -154,7 +144,6 @@ export const MissionForm = () => {
return;
}
for (const key in missionFormValues) {
console.debug(key, missionFormValues[key as keyof MissionOptionalDefaults]);
if (key === "addressOSMways") continue; // Skip addressOSMways as it is handled separately
form.setValue(
key as keyof MissionOptionalDefaults,
@@ -164,22 +153,6 @@ export const MissionForm = () => {
}
}, [missionFormValues, form, defaultFormValues]);
// Sync form state to store (avoid infinity loops by using watch)
useEffect(() => {
const subscription = form.watch((values) => {
// Only update store if values actually changed to prevent loops
const currentStoreValues = JSON.stringify(missionFormValues);
const newFormValues = JSON.stringify(values);
if (currentStoreValues !== newFormValues) {
console.debug("Updating store missionFormValues", values);
setMissionFormValues(values as MissionOptionalDefaults);
}
});
return () => subscription.unsubscribe();
}, [form, setMissionFormValues, missionFormValues]);
const saveMission = async (
mission: MissionOptionalDefaults,
{ alertWhenValid = false, createNewMission = false } = {},
@@ -396,7 +369,6 @@ export const MissionForm = () => {
<option disabled value="please_select">
Einsatz Szenario auswählen...
</option>
<option value={"kein Szenario:3_1_1_1-4_1"}>Kein Szenario</option>
{keywords &&
keywords
.find((k) => k.name === form.watch("missionKeywordName"))
@@ -443,21 +415,6 @@ export const MissionForm = () => {
In diesem Einsatz gibt es {form.watch("addressOSMways").length} Gebäude
</p>
<div className="flex items-center justify-between">
<p
className={cn("text-sm text-gray-500", form.watch("xPlaneObjects").length && "text-info")}
>
In diesem Einsatz gibt es {form.watch("xPlaneObjects").length} Objekte
</p>
<button
disabled={!(form.watch("xPlaneObjects")?.length > 0)}
className="btn btn-xs btn-error mt-2"
onClick={() => form.setValue("xPlaneObjects", [])}
>
löschen
</button>
</div>
<div className="form-control min-h-[140px]">
<div className="flex gap-2">
<button
@@ -473,11 +430,7 @@ export const MissionForm = () => {
setSearchElements([]); // Reset search elements
setEditingMission(null);
setContextMenu(null);
if (editingMissionId) {
toast.success(`${newMission.publicId} bearbeitet`);
} else {
toast.success(`${newMission.publicId} erstellt`);
}
toast.success(`Einsatz ${newMission.publicId} erstellt`);
form.reset();
setOpen(false);
} catch (error) {
@@ -502,11 +455,7 @@ export const MissionForm = () => {
setSearchElements([]); // Reset search elements
setContextMenu(null);
if (editingMissionId) {
toast.success(`${newMission.publicId} bearbeitet`);
} else {
toast.success(`${newMission.publicId} erstellt`);
}
toast.success(`Einsatz ${newMission.publicId} erstellt`);
form.reset();
setOpen(false);
} catch (error) {

View File

@@ -1,10 +1,7 @@
import type { Metadata } from "next";
import Navbar from "./_components/navbar/Navbar";
import { getServerSession } from "api/auth/[...nextauth]/auth";
import { Error } from "_components/Error";
import Navbar from "(app)/_components/Navbar";
import { Audio } from "_components/Audio/Audio";
import { Connection } from "./_components/navbar/Connection";
import { Settings } from "./_components/navbar/Settings";
export const metadata: Metadata = {
title: "VAR: Disponent",
@@ -29,11 +26,7 @@ export default async function RootLayout({
return (
<>
<Navbar>
<Audio />
<Connection />
<Settings />
</Navbar>
<Navbar />
{children}
</>
);

View File

@@ -1,29 +0,0 @@
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.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

View File

@@ -1,9 +1,22 @@
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 { MrtBase } from "./Base";
import { MrtDisplay } from "./MrtDisplay";
import { MrtButtons } from "./MrtButtons";
import { MrtPopups } from "./MrtPopups";
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,
};
export interface DisplayLineProps {
lineStyle?: CSSProperties;
@@ -14,7 +27,45 @@ 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"
@@ -27,16 +78,150 @@ export const Mrt = () => {
maxHeight: "100%",
maxWidth: "100%",
color: "white",
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%",
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%",
}}
>
<MrtPopups />
<MrtDisplay />
<MrtButtons />
<MrtBase />
{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,
}}
/>
)}
</div>
);
};

View File

@@ -1,150 +0,0 @@
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

@@ -1,16 +0,0 @@
.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

@@ -1,270 +0,0 @@
"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

@@ -1,152 +0,0 @@
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.

Before

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 MiB

View File

@@ -1,58 +1,15 @@
import { getPublicUser, MissionSdsStatusLog, Prisma } from "@repo/db";
import { 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, 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";
import { useMutation } from "@tanstack/react-query";
export const useButtons = () => {
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 station = usePilotConnectionStore((state) => state.selectedStation);
const connectedAircraft = usePilotConnectionStore((state) => state.connectedAircraft);
const connectionStatus = usePilotConnectionStore((state) => state.status);
const updateAircraftMutation = useMutation({
mutationKey: ["edit-pilot-connected-aircraft"],
mutationFn: ({
@@ -64,161 +21,56 @@ export const useButtons = () => {
}) => editConnectedAircraftAPI(aircraftId, data),
});
const { setPage, setPopup, page, popup, setStringifiedData, stringifiedData } = useMrtStore(
(state) => state,
);
const { setPage } = useMrtStore((state) => state);
const role =
(pilotState == "connected" && selectedStation?.bosCallsignShort) ||
session.data?.user?.publicId;
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 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,
},
});
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(),
setTimeout(async () => {
await updateAircraftMutation.mutateAsync({
aircraftId: connectedAircraft.id,
data: {
direction: "to-lst",
stationId: selectedStation.id,
station: selectedStation,
user: getPublicUser(session.data?.user),
status: button,
fmsStatus: 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;
};
});
setPage({
page: "home",
station,
fmsStatus: button,
});
}, 1000);
} else {
setPage({ page: "home", fmsStatus: connectedAircraft.fmsStatus || "6", station });
}
};
useEffect(() => {
pilotSocket.on("connect", () => {
const { page } = useMrtStore.getState();
if (!selectedStation || page !== "off") return;
setPage({ page: "startup" });
if (!station) return;
setPage({ page: "home", fmsStatus: "6", station });
});
}, [setPage, selectedStation, setPopup]);
return { handleKlick, handleHold };
pilotSocket.on("aircraft-update", () => {
if (!station) return;
setPage({ page: "new-status", station });
});
}, [setPage, station]);
return { handleButton };
};

View File

@@ -1,39 +1,52 @@
"use client";
import { useAudioStore } from "_store/audioStore";
import { RoomEvent } from "livekit-client";
import { usePilotConnectionStore } from "_store/pilot/connectionStore";
import { useMrtStore } from "_store/pilot/MrtStore";
import { useEffect, useRef } from "react";
export const useSounds = () => {
const { room } = useAudioStore((state) => state);
const longBtnPressSoundRef = useRef<HTMLAudioElement>(null);
const statusSentSoundRef = useRef<HTMLAudioElement>(null);
const sdsReceivedSoundRef = useRef<HTMLAudioElement>(null);
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);
useEffect(() => {
if (typeof window !== "undefined") {
longBtnPressSoundRef.current = new Audio("/sounds/1504.wav");
statusSentSoundRef.current = new Audio("/sounds/403.wav");
sdsReceivedSoundRef.current = new Audio("/sounds/775.wav");
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,
});
};
}
}, []);
}, [connectedAircraft?.fmsStatus, selectedStation, setPage, mrtState.page]);
const fmsStatus = connectedAircraft?.fmsStatus || "NaN";
useEffect(() => {
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,
};
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]);
};

View File

@@ -0,0 +1,58 @@
import { Connection } from "./_components/Connection";
import { Audio } from "_components/Audio/Audio";
import { ExitIcon, ExternalLinkIcon } from "@radix-ui/react-icons";
import Link from "next/link";
import { Settings } from "./_components/Settings";
import { WarningAlert } from "_components/navbar/PageAlert";
import { Radar } from "lucide-react";
import { prisma } from "@repo/db";
import { ChangelogWrapper } from "_components/navbar/ChangelogWrapper";
export default async function Navbar() {
const latestChangelog = await prisma.changelog.findFirst({
orderBy: {
createdAt: "desc",
},
});
return (
<div className="navbar bg-base-100 flex justify-between gap-5 shadow-sm">
<div className="flex items-center gap-2">
<div>
<p className="text-xl font-semibold normal-case">VAR Operations Center</p>
<ChangelogWrapper latestChangelog={latestChangelog} />
</div>
</div>
<WarningAlert />
<div className="flex items-center gap-5">
<div className="flex items-center gap-2">
<Audio />
</div>
<div className="flex items-center">
<Connection />
</div>
<div className="flex items-center">
<Settings />
<Link href={"/tracker"} target="_blank" rel="noopener noreferrer">
<button className="btn btn-ghost">
<Radar size={19} /> Tracker
</button>
</Link>
<Link
href={process.env.NEXT_PUBLIC_HUB_URL || "#!"}
target="_blank"
rel="noopener noreferrer"
>
<button className="btn btn-ghost">
<ExternalLinkIcon className="h-4 w-4" /> HUB
</button>
</Link>
<Link href={"/logout"}>
<button className="btn btn-ghost">
<ExitIcon className="h-4 w-4" />
</button>
</Link>
</div>
</div>
</div>
);
}

View File

@@ -76,6 +76,7 @@ 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

@@ -3,17 +3,15 @@ import { useEffect, useRef, useState } from "react";
import { GearIcon } from "@radix-ui/react-icons";
import { Bell, SettingsIcon, Volume2 } from "lucide-react";
import MicVolumeBar from "_components/MicVolumeIndication";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQuery } from "@tanstack/react-query";
import { editUserAPI, getUserAPI } from "_querys/user";
import { useSession } from "next-auth/react";
import { useAudioStore } from "_store/audioStore";
import toast from "react-hot-toast";
import Link from "next/link";
import { Button } from "@repo/shared-components";
export const SettingsBtn = () => {
const session = useSession();
const queryClient = useQueryClient();
const [inputDevices, setInputDevices] = useState<MediaDeviceInfo[]>([]);
const { data: user } = useQuery({
@@ -24,10 +22,6 @@ export const SettingsBtn = () => {
const editUserMutation = useMutation({
mutationFn: editUserAPI,
mutationKey: ["user", session.data?.user.id],
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["user", session.data?.user.id] });
},
});
useEffect(() => {
@@ -254,7 +248,7 @@ export const SettingsBtn = () => {
>
Schließen
</button>
<Button
<button
className="btn btn-soft btn-success"
type="submit"
onSubmit={() => false}
@@ -281,7 +275,7 @@ export const SettingsBtn = () => {
}}
>
Speichern
</Button>
</button>
</div>
</div>
</dialog>

View File

@@ -0,0 +1,26 @@
"use client";
import { MoonIcon, SunIcon } from "@radix-ui/react-icons";
interface ThemeSwapProps {
isDark: boolean;
toggleTheme: () => void;
}
export const ThemeSwap: React.FC<ThemeSwapProps> = ({
isDark,
toggleTheme,
}) => {
return (
<label className="swap swap-rotate">
<input
type="checkbox"
className="theme-controller"
checked={isDark}
onChange={toggleTheme}
/>
<MoonIcon className="swap-off h-5 w-5 fill-current" />
<SunIcon className="swap-on h-5 w-5 fill-current" />
</label>
);
};

View File

@@ -1,10 +1,7 @@
import type { Metadata } from "next";
import Navbar from "./_components/navbar/Navbar";
import { getServerSession } from "api/auth/[...nextauth]/auth";
import { Error } from "_components/Error";
import Navbar from "(app)/_components/Navbar";
import { Audio } from "_components/Audio/Audio";
import { Connection } from "./_components/navbar/Connection";
import { Settings } from "./_components/navbar/Settings";
export const metadata: Metadata = {
title: "VAR: Pilot",
@@ -29,11 +26,7 @@ export default async function RootLayout({
return (
<>
<Navbar>
<Audio />
<Connection />
<Settings />
</Navbar>
<Navbar />
{children}
</>
);

View File

@@ -94,20 +94,10 @@ const PilotPage = () => {
</div>
</div>
</div>
<div className="flex h-full w-1/3 min-w-[500px]">
<div className="flex h-full w-1/3">
<div className="bg-base-300 flex h-full w-full flex-col p-4">
<div className="flex justify-between">
<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>
<h2 className="card-title mb-2">MRT & DME</h2>
<div
className="tooltip tooltip-left mb-4"
data-tip="Dadurch wird der Einsatz erneut an den Desktop-Client gesendet."

View File

@@ -10,7 +10,7 @@ export default () => {
}, []);
return (
<div className="card-body">
<h1 className="text-5xl">ausloggen...</h1>
<h1 className="text-5xl">logging out...</h1>
</div>
);
};

View File

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

View File

@@ -3,7 +3,7 @@
import { toast } from "react-hot-toast";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactNode, useEffect, useRef, useState } from "react";
import { ReactNode, useEffect, useState } from "react";
import { dispatchSocket } from "(app)/dispatch/socket";
import { NotificationPayload } from "@repo/db";
import { HPGnotificationToast } from "_components/customToasts/HPGnotification";
@@ -15,7 +15,6 @@ import { MissionAutoCloseToast } from "_components/customToasts/MissionAutoClose
export function QueryProvider({ children }: { children: ReactNode }) {
const mapStore = useMapStore((s) => s);
const notificationSound = useRef<HTMLAudioElement | null>(null);
const [queryClient] = useState(
() =>
@@ -23,7 +22,7 @@ export function QueryProvider({ children }: { children: ReactNode }) {
defaultOptions: {
mutations: {
onError: (error) => {
toast.error("Ein Fehler ist aufgetreten: " + (error as Error).message, {
toast.error("An error occurred: " + (error as Error).message, {
position: "top-right",
});
},
@@ -31,9 +30,6 @@ export function QueryProvider({ children }: { children: ReactNode }) {
},
}),
);
useEffect(() => {
notificationSound.current = new Audio("/sounds/notification.mp3");
}, []);
useEffect(() => {
const invalidateMission = () => {
queryClient.invalidateQueries({
@@ -63,19 +59,8 @@ 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));
}
};
switch (notification.type) {
case "hpg-validation":
playNotificationSound();
toast.custom(
(t) => <HPGnotificationToast event={notification} mapStore={mapStore} t={t} />,
{
@@ -85,30 +70,23 @@ export function QueryProvider({ children }: { children: ReactNode }) {
break;
case "admin-message":
playNotificationSound();
toast.custom((t) => <AdminMessageToast event={notification} t={t} />, {
duration: 999999,
});
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,
});
break;
case "mission-auto-close":
playNotificationSound();
toast.custom(
(t) => <MissionAutoCloseToast event={notification} t={t} mapStore={mapStore} />,
{
duration: 60000,
},
);
break;
case "mission-closed":
toast("Dein aktueller Einsatz wurde geschlossen.");
break;
default:
toast("unbekanntes Notification-Event");

View File

@@ -1,24 +0,0 @@
import { BaseNotification } from "_components/customToasts/BaseNotification"
import { TriangleAlert } from "lucide-react"
import toast, { Toast } from "react-hot-toast"
export const HPGnotValidatedToast = ({_toast}: {_toast: Toast}) => {
return <BaseNotification icon={<TriangleAlert />} className="flex flex-row">
<div className="flex-1">
<h1 className="font-bold text-red-600">Einsatz nicht HPG-validiert</h1>
<p className="text-sm">Vergleiche die Position des Einsatzes mit der HPG-Position in Hubschrauber</p>
</div>
<div className="ml-11">
<button className="btn" onClick={() => toast.dismiss(_toast.id)}>
schließen
</button>
</div>
</BaseNotification>
}
export const showToast = () => {
toast.custom((t) => {
return (<HPGnotValidatedToast _toast={t} />);
}, {duration: 1000 * 60 * 10}); // 10 minutes
}

View File

@@ -1,16 +1,14 @@
import { getPublicUser, MissionSdsStatusLog, StationStatus } from "@repo/db";
import { Prisma, 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 { getConnectedAircraftsAPI } from "_querys/aircrafts";
import { editConnectedAircraftAPI, 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 { useSession } from "next-auth/react";
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import { Toast, toast } from "react-hot-toast";
export const QUICK_RESPONSE: Record<string, string[]> = {
@@ -24,8 +22,6 @@ 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(),
@@ -50,7 +46,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);
@@ -69,16 +65,28 @@ export const StatusToast = ({ event, t }: { event: StationStatus; t: Toast }) =>
const station = stations?.find((s) => s.id === event.data?.stationId);
const queryClient = useQueryClient();
const sendSdsStatusMutation = useMutation({
mutationFn: async ({ sdsMessage }: { sdsMessage: MissionSdsStatusLog }) => {
if (!connectedAircraft?.id) throw new Error("No connected aircraft");
await sendSdsStatusMessageAPI({ sdsMessage, aircraftId: connectedAircraft?.id });
const changeAircraftMutation = useMutation({
mutationFn: async ({
id,
update,
}: {
id: number;
update: Prisma.ConnectedAircraftUpdateInput;
}) => {
await editConnectedAircraftAPI(id, update);
queryClient.invalidateQueries({
queryKey: ["missions"],
queryKey: ["aircrafts"],
});
},
});
console.log("Audio Room:", audioRoom, participants, livekitUser, event);
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;
@@ -95,8 +103,7 @@ export const StatusToast = ({ event, t }: { event: StationStatus; t: Toast }) =>
default:
soundRef = null;
}
if (audioRoom && livekitUser?.roomName && audioRoom !== livekitUser?.roomName) {
if (audioRoom !== livekitUser?.roomName) {
toast.remove(t.id);
return;
}
@@ -114,7 +121,7 @@ export const StatusToast = ({ event, t }: { event: StationStatus; t: Toast }) =>
};
}, [event.status, livekitUser?.roomName, audioRoom, t.id]);
if (!connectedAircraft || !station || !session.data) return null;
if (!connectedAircraft || !station) return null;
return (
<BaseNotification>
<div className="flex flex-row items-center gap-14">
@@ -155,18 +162,10 @@ export const StatusToast = ({ event, t }: { event: StationStatus; t: Toast }) =>
toast.error("Keine Flugzeug-ID gefunden");
return;
}
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(),
await changeAircraftMutation.mutateAsync({
id: event.data?.aircraftId,
update: {
fmsStatus: status,
},
});
toast.remove(t.id);

View File

@@ -397,9 +397,9 @@ const AircraftMarker = ({ aircraft }: { aircraft: ConnectedAircraft & { Station:
export const AircraftLayer = () => {
const { data: aircrafts } = useQuery({
queryKey: ["connected-aircrafts", "map"],
queryKey: ["aircrafts"],
queryFn: () => getConnectedAircraftsAPI(),
refetchInterval: 15000,
refetchInterval: 10_000,
});
const { setMap } = useMapStore((state) => state);
const map = useMap();
@@ -434,10 +434,8 @@ export const AircraftLayer = () => {
}
}, [pilotConnectionStatus, followOwnAircraft, ownAircraft, setMap, map]);
console.debug("Hubschrauber auf Karte:", {
total: aircrafts?.length,
displayed: filteredAircrafts.length,
});
console.debug("Hubschrauber auf Karte:", filteredAircrafts.length, filteredAircrafts);
console.debug("Daten vom Server:", aircrafts?.length, aircrafts);
return (
<>

View File

@@ -3,22 +3,15 @@ import { OSMWay } from "@repo/db";
import { useDispatchConnectionStore } from "_store/dispatch/connectionStore";
import { useMapStore } from "_store/mapStore";
import { usePannelStore } from "_store/pannelStore";
import { MapPin, MapPinned, Search, Car, Ambulance, Siren, Flame } from "lucide-react";
import { MapPin, MapPinned, Radius, Ruler, Search, RulerDimensionLine, Scan } from "lucide-react";
import { getOsmAddress } from "_querys/osm";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { Popup, useMap } from "react-leaflet";
import { findClosestPolygon } from "_helpers/findClosestPolygon";
import { xPlaneObjectsAvailable } from "_helpers/xPlaneObjectsAvailable";
import { useQuery } from "@tanstack/react-query";
import { getConnectedAircraftsAPI } from "_querys/aircrafts";
export const ContextMenu = () => {
const map = useMap();
const { data: aircrafts } = useQuery({
queryKey: ["connectedAircrafts"],
queryFn: getConnectedAircraftsAPI,
});
const {
contextMenu,
searchElements,
@@ -33,16 +26,15 @@ export const ContextMenu = () => {
setOpen,
isOpen: isPannelOpen,
} = usePannelStore((state) => state);
const [showObjectOptions, setShowObjectOptions] = useState(false);
const [showRulerOptions, setShowRulerOptions] = useState(false);
const [rulerHover, setRulerHover] = useState(false);
const [rulerOptionsHover, setRulerOptionsHover] = useState(false);
const dispatcherConnected = useDispatchConnectionStore((s) => s.status) === "connected";
useEffect(() => {
const showObjectOptions = rulerHover || rulerOptionsHover;
setShowObjectOptions(showObjectOptions);
}, [isPannelOpen, rulerHover, rulerOptionsHover, setOpen]);
setShowRulerOptions(rulerHover || rulerOptionsHover);
}, [rulerHover, rulerOptionsHover]);
useEffect(() => {
const handleContextMenu = (e: any) => {
@@ -158,12 +150,9 @@ export const ContextMenu = () => {
style={{ transform: "translateY(-50%)" }}
onMouseEnter={() => setRulerHover(true)}
onMouseLeave={() => setRulerHover(false)}
disabled={
!isPannelOpen ||
!xPlaneObjectsAvailable(missionFormValues?.missionStationIds, aircrafts)
}
disabled
>
<Car size={20} />
<Ruler size={20} />
</button>
{/* Bottom Button */}
<button
@@ -189,75 +178,64 @@ export const ContextMenu = () => {
>
<Search size={20} />
</button>
{/* XPlane Object Options - shown when Ruler button is hovered or options are hovered */}
{showObjectOptions && (
{/* Ruler Options - shown when Ruler button is hovered or options are hovered */}
{showRulerOptions && (
<div
className="pointer-events-auto absolute -left-[100px] top-1/2 z-10 flex h-[200px] w-[120px] -translate-y-1/2 flex-col items-center justify-center py-5"
className="pointer-events-auto absolute flex flex-col items-center"
style={{
left: "-100px", // position to the right of the left button
top: "50%",
transform: "translateY(-50%)",
zIndex: 10,
width: "120px", // Make the hover area wider
height: "200px", // Make the hover area taller
padding: "20px 0", // Add vertical padding
display: "flex",
justifyContent: "center",
pointerEvents: "auto",
}}
onMouseEnter={() => setRulerOptionsHover(true)}
onMouseLeave={() => setRulerOptionsHover(false)}
>
<div className="flex w-full flex-col">
<div
style={{
display: "flex",
flexDirection: "column",
width: "100%",
}}
>
<button
className="btn btn-circle bg-rescuetrack tooltip tooltip-left tooltip-accent mb-2 ml-[30px] h-10 w-10 opacity-80"
data-tip="Rettungswagen platzieren"
className="btn btn-circle bg-rescuetrack tooltip tooltip-left tooltip-accent mb-2 h-10 w-10 opacity-80"
data-tip="Strecke Messen"
style={{
transform: "translateX(100%)",
}}
onClick={() => {
setMissionFormValues({
...missionFormValues,
xPlaneObjects: [
...(missionFormValues?.xPlaneObjects ?? []),
{
objectName: "ambulance",
alt: 0,
lat: contextMenu.lat,
lon: contextMenu.lng,
},
],
});
/* ... */
}}
>
<Ambulance size={20} />
<RulerDimensionLine size={20} />
</button>
<button
className="btn btn-circle bg-rescuetrack tooltip tooltip-left tooltip-accent mb-2 h-10 w-10 opacity-80"
data-tip="LF platzieren"
data-tip="Radius Messen"
onClick={() => {
console.log("Add fire engine");
setMissionFormValues({
...missionFormValues,
xPlaneObjects: [
...(missionFormValues?.xPlaneObjects ?? []),
{
objectName: "fire_engine",
alt: 0,
lat: contextMenu.lat,
lon: contextMenu.lng,
},
],
});
/* ... */
}}
>
<Flame size={20} />
<Radius size={20} />
</button>
<button
className="btn btn-circle bg-rescuetrack tooltip tooltip-left tooltip-accent ml-[30px] h-10 w-10 opacity-80"
data-tip="Streifenwagen platzieren"
className="btn btn-circle bg-rescuetrack tooltip tooltip-left tooltip-accent h-10 w-10 opacity-80"
data-tip="Fläche Messen"
style={{
transform: "translateX(100%)",
}}
onClick={() => {
console.log("Add police");
setMissionFormValues({
...missionFormValues,
xPlaneObjects: [
...(missionFormValues?.xPlaneObjects ?? []),
{
objectName: "police",
alt: 0,
lat: contextMenu.lat,
lon: contextMenu.lng,
},
],
});
/* ... */
}}
>
<Siren size={20} />
<Scan size={20} />
</button>
</div>
</div>

View File

@@ -1,6 +1,6 @@
"use client";
import { usePannelStore } from "_store/pannelStore";
import { Marker, useMap } from "react-leaflet";
import { Marker } from "react-leaflet";
import L from "leaflet";
import { useQuery } from "@tanstack/react-query";
import { getMissionsAPI } from "_querys/missions";
@@ -8,13 +8,10 @@ import { HPGValidationRequired } from "_helpers/hpgValidationRequired";
import { getConnectedAircraftsAPI } from "_querys/aircrafts";
import { useMapStore } from "_store/mapStore";
import { useDispatchConnectionStore } from "_store/dispatch/connectionStore";
import { XplaneObject } from "@repo/db";
import { useEffect, useState } from "react";
export const MapAdditionals = () => {
const { isOpen, missionFormValues, setMissionFormValues } = usePannelStore((state) => state);
const { isOpen, missionFormValues } = usePannelStore((state) => state);
const dispatcherConnectionState = useDispatchConnectionStore((state) => state.status);
const { openMissionMarker } = useMapStore((state) => state);
const { data: missions = [] } = useQuery({
queryKey: ["missions"],
@@ -24,28 +21,13 @@ export const MapAdditionals = () => {
}),
refetchInterval: 10_000,
});
const { setOpenMissionMarker } = useMapStore((state) => state);
const [showDetailedAdditionals, setShowDetailedAdditionals] = useState(false);
const mapStore = useMapStore((state) => state);
const { data: aircrafts } = useQuery({
queryKey: ["aircrafts"],
queryFn: () => getConnectedAircraftsAPI(),
refetchInterval: 10000,
});
const leafletMap = useMap();
useEffect(() => {
const handleZoomEnd = () => {
const currentZoom = leafletMap.getZoom();
setShowDetailedAdditionals(currentZoom > 10);
};
leafletMap.on("zoomend", handleZoomEnd);
return () => {
leafletMap.off("zoomend", handleZoomEnd);
};
}, [leafletMap]);
const markersNeedingAttention = missions.filter(
(m) =>
@@ -55,7 +37,7 @@ export const MapAdditionals = () => {
m.hpgLocationLat &&
dispatcherConnectionState === "connected" &&
m.hpgLocationLng &&
openMissionMarker.find((openMission) => openMission.id === m.id),
mapStore.openMissionMarker.find((openMission) => openMission.id === m.id),
);
return (
@@ -68,78 +50,9 @@ export const MapAdditionals = () => {
iconSize: [40, 40],
iconAnchor: [20, 35],
})}
draggable={true}
eventHandlers={{
dragend: (e) => {
const marker = e.target;
const position = marker.getLatLng();
setMissionFormValues({
...missionFormValues,
addressLat: position.lat,
addressLng: position.lng,
});
},
}}
interactive={false}
/>
)}
{showDetailedAdditionals &&
openMissionMarker.map((mission) => {
if (missionFormValues?.id === mission.id) return null;
const missionData = missions.find((m) => m.id === mission.id);
if (!missionData?.addressLat || !missionData?.addressLng) return null;
return (missionData.xPlaneObjects as unknown as XplaneObject[]).map((obj, index) => (
<Marker
key={`${mission.id}-additional-${index}`}
position={[obj.lat, obj.lon]}
icon={L.icon({
iconUrl: `/icons/${obj.objectName}.png`,
iconSize: [40, 40],
iconAnchor: [20, 35],
})}
interactive={false}
/>
));
})}
{isOpen &&
missionFormValues?.xPlaneObjects &&
(missionFormValues.xPlaneObjects as unknown as XplaneObject[]).map((obj, index) => (
<Marker
key={index}
position={[obj.lat, obj.lon]}
icon={L.icon({
iconUrl: `/icons/${obj.objectName}.png`,
iconSize: [40, 40],
iconAnchor: [20, 35],
})}
draggable={true}
eventHandlers={{
dragend: (e) => {
const marker = e.target;
const position = marker.getLatLng();
console.log("Marker dragged to:", position);
setMissionFormValues({
...missionFormValues,
xPlaneObjects: (missionFormValues.xPlaneObjects as unknown as XplaneObject[]).map(
(obj, objIndex) =>
objIndex === index ? { ...obj, lat: position.lat, lon: position.lng } : obj,
),
});
},
contextmenu: (e) => {
e.originalEvent.preventDefault();
const updatedObjects = (
missionFormValues.xPlaneObjects as unknown as XplaneObject[]
).filter((_, objIndex) => objIndex !== index);
setMissionFormValues({
...missionFormValues,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
xPlaneObjects: updatedObjects as unknown as any[],
});
},
}}
/>
))}
{markersNeedingAttention.map((mission) => (
<Marker
key={mission.id}
@@ -151,7 +64,7 @@ export const MapAdditionals = () => {
})}
eventHandlers={{
click: () =>
setOpenMissionMarker({
mapStore.setOpenMissionMarker({
open: [
{
id: mission.id,

View File

@@ -1,3 +0,0 @@
export const XPlaneObjects = () => {
return <div>XPlaneObjects</div>;
};

View File

@@ -9,7 +9,6 @@ import {
Mission,
MissionLog,
MissionSdsLog,
MissionSdsStatusLog,
MissionStationLog,
Prisma,
PublicUser,
@@ -41,7 +40,7 @@ import {
TextSearch,
} from "lucide-react";
import { useSession } from "next-auth/react";
import { sendSdsMessageAPI, sendSdsStatusMessageAPI } from "_querys/missions";
import { sendSdsMessageAPI } from "_querys/missions";
import { getLivekitRooms } from "_querys/livekit";
import { findLeitstelleForPosition } from "_helpers/findLeitstelleinPoint";
import { formatDistance } from "date-fns";
@@ -55,13 +54,9 @@ const FMSStatusHistory = ({
mission?: Mission;
}) => {
const log = ((mission?.missionLog as unknown as MissionLog[]) || [])
.filter(
(entry) =>
(entry.type === "station-log" || entry.type == "sds-status-log") &&
entry.data.stationId === aircraft.Station.id,
)
.filter((entry) => entry.type === "station-log" && entry.data.stationId === aircraft.Station.id)
.reverse()
.splice(0, 6) as (MissionStationLog | MissionSdsStatusLog)[];
.splice(0, 6) as MissionStationLog[];
const aircraftUser: PublicUser =
typeof aircraft.publicUser === "string" ? JSON.parse(aircraft.publicUser) : aircraft.publicUser;
@@ -108,13 +103,10 @@ const FMSStatusHistory = ({
<span
className="text-base font-bold"
style={{
color:
FMS_STATUS_TEXT_COLORS[
entry.type === "sds-status-log" ? entry.data.status : entry.data.newFMSstatus
],
color: FMS_STATUS_TEXT_COLORS[entry.data.newFMSstatus],
}}
>
{entry.type === "sds-status-log" ? entry.data.status : entry.data.newFMSstatus}
{entry.data.newFMSstatus}
</span>
<span className="text-base-content">
{new Date(entry.timeStamp).toLocaleTimeString([], {
@@ -134,7 +126,6 @@ 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();
@@ -153,20 +144,6 @@ 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">
@@ -236,21 +213,12 @@ const FMSStatusSelector = ({
onMouseEnter={() => setHoveredStatus(status)}
onMouseLeave={() => setHoveredStatus(null)}
onClick={async () => {
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),
},
await changeAircraftMutation.mutateAsync({
id: aircraft.id,
update: {
fmsStatus: status,
},
});
toast.success(`SDS Status ${status} gesendet`);
}}
>
{status}
@@ -328,12 +296,6 @@ const StationTab = ({ aircraft }: { aircraft: ConnectedAircraft & { Station: Sta
{aircraft.posH145active ? "H145 Aktiv" : "H145 Inaktiv"}
</span>
</span>
<span className="flex items-center gap-2">
<Lollipop size={16} />{" "}
<span className={cn(aircraft.posXplanePluginActive && "text-green-500")}>
{aircraft.posXplanePluginActive ? "X-Plane Plugin Aktiv" : "X-Plane Plugin Inaktiv"}
</span>
</span>
</div>
</div>
);
@@ -410,9 +372,7 @@ const SDSTab = ({
?.slice()
.reverse()
.filter(
(entry) =>
(entry.type === "sds-log" || entry.type == "sds-status-log") &&
entry.data.stationId === aircraft.Station.id,
(entry) => entry.type === "sds-log" && entry.data.stationId === aircraft.Station.id,
) || [],
[mission?.missionLog, aircraft.Station.id],
);
@@ -505,7 +465,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 | MissionSdsStatusLog;
const sdsEntry = entry as MissionSdsLog;
return (
<li key={index} className="flex items-center gap-2">
<span className="text-base-content">
@@ -523,9 +483,7 @@ const SDSTab = ({
{sdsEntry.data.user.firstname?.[0]?.toUpperCase() ?? "?"}
{sdsEntry.data.user.lastname?.[0]?.toUpperCase() ?? "?"}
</span>
<span className="text-base-content">
{sdsEntry.type == "sds-log" ? sdsEntry.data.message : sdsEntry.data.status}
</span>
<span className="text-base-content">{sdsEntry.data.message}</span>
</li>
);
})}

View File

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

View File

@@ -1,24 +1,18 @@
"use client";
import { cn } from "@repo/shared-components";
import { ArrowLeftRight, ExternalLinkIcon, Plane, Radar, Workflow } from "lucide-react";
import { ArrowLeftRight, Plane, Radar, Workflow } from "lucide-react";
import { useSession } from "next-auth/react";
import Link from "next/link";
import { usePathname } from "next/navigation";
export default function ModeSwitchDropdown({
className,
btnClassName,
}: {
className?: string;
btnClassName?: string;
}) {
export default function ModeSwitchDropdown({ className }: { className?: string }) {
const path = usePathname();
const session = useSession();
return (
<div className={cn("dropdown z-999999", className)}>
<div tabIndex={0} role="button" className={cn("btn", btnClassName)}>
<div tabIndex={0} role="button" className="btn m-1">
<ArrowLeftRight size={22} /> {path.includes("pilot") && "Pilot"}
{path.includes("dispatch") && "Leitstelle"}
</div>
@@ -45,15 +39,6 @@ export default function ModeSwitchDropdown({
<Radar size={22} /> Tracker
</Link>
</li>
<li>
<Link
href={process.env.NEXT_PUBLIC_HUB_URL || "#!"}
target="_blank"
rel="noopener noreferrer"
>
<ExternalLinkIcon size={22} /> HUB
</Link>
</li>
</ul>
</div>
);

View File

@@ -24,30 +24,3 @@ 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,7 +1 @@
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" },
];
export const ROOMS = ["LST_01", "LST_02", "LST_03", "LST_04", "LST_05"];

View File

@@ -1,11 +0,0 @@
import { ConnectedAircraft } from "@repo/db";
export const xPlaneObjectsAvailable = (
missionStationIds?: number[],
aircrafts?: ConnectedAircraft[],
) => {
return missionStationIds?.some((id) => {
const aircraft = aircrafts?.find((a) => a.stationId === id);
return aircraft?.posXplanePluginActive;
});
};

View File

@@ -14,14 +14,11 @@ export const changeDispatcherAPI = async (
};
export const getConnectedDispatcherAPI = async (filter?: Prisma.ConnectedDispatcherWhereInput) => {
const res = await axios.get<(ConnectedDispatcher & { settingsUseHPGAsDispatcher: boolean })[]>(
"/api/dispatcher",
{
params: {
filter: JSON.stringify(filter),
},
const res = await axios.get<ConnectedDispatcher[]>("/api/dispatcher", {
params: {
filter: JSON.stringify(filter),
},
);
});
if (res.status !== 200) {
throw new Error("Failed to fetch Connected Dispatcher");
}

View File

@@ -1,4 +1,4 @@
import { Mission, MissionSdsLog, MissionSdsStatusLog, Prisma } from "@repo/db";
import { Mission, MissionSdsLog, Prisma } from "@repo/db";
import axios from "axios";
import { serverApi } from "_helpers/axios";
@@ -29,20 +29,6 @@ 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,13 +21,12 @@ 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: (room: (typeof ROOMS)[number] | undefined, role: string) => void;
connect: (roomName: string, role: string) => void;
connectionQuality: ConnectionQuality;
disconnect: () => void;
isTalking: boolean;
@@ -45,8 +44,6 @@ 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;
@@ -75,10 +72,6 @@ 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: [],
@@ -124,11 +117,11 @@ export const useAudioStore = create<TalkState>((set, get) => ({
(oldSettings.micDeviceId !== newSettings.micDeviceId ||
oldSettings.micVolume !== newSettings.micVolume)
) {
const { room, disconnect, connect, selectedRoom } = get();
const { room, disconnect, connect } = get();
const role = room?.localParticipant.attributes.role;
if (selectedRoom || role) {
if (room?.name || role) {
disconnect();
connect(selectedRoom, role || "user");
connect(room?.name || "", role || "user");
}
}
},
@@ -167,7 +160,7 @@ export const useAudioStore = create<TalkState>((set, get) => ({
set((state) => ({ isTalking: !state.isTalking, transmitBlocked: false }));
},
connect: async (_room, role) => {
connect: async (roomName, role) => {
set({ state: "connecting" });
try {
@@ -179,16 +172,13 @@ 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(_room?.name || selectedRoom?.name || "VAR_LST_RD_01");
const token = await getToken(roomName);
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 () => {
@@ -196,28 +186,16 @@ export const useAudioStore = create<TalkState>((set, get) => ({
if (dispatchState.status === "connected" && dispatchState.connectedDispatcher?.id) {
changeDispatcherAPI(dispatchState.connectedDispatcher?.id, {
zone: _room?.name || selectedRoom?.name || "VAR_LST_RD_01",
zone: roomName,
ghostMode: dispatchState.ghostMode,
});
}
let inputStream = await navigator.mediaDevices
.getUserMedia({
audio: {
deviceId: get().settings.micDeviceId ?? undefined,
},
})
.catch((e) => {
console.error("Konnte das Audio-Gerät nicht öffnen:", e);
return null;
});
if (!inputStream) {
inputStream = await navigator.mediaDevices.getUserMedia({ audio: true }).catch((e) => {
console.error("Konnte das Audio-Gerät nicht öffnen:", e);
return null;
});
}
if (!inputStream) throw new Error("Konnte das Audio-Gerät nicht öffnen");
const inputStream = await navigator.mediaDevices.getUserMedia({
audio: {
deviceId: get().settings.micDeviceId ?? undefined,
},
});
// Funk-Effekt anwenden
const radioStream = getRadioStream(inputStream, get().settings.micVolume);
if (!radioStream) throw new Error("Konnte Funkstream nicht erzeugen");
@@ -230,7 +208,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: "VAR_LST_RD_01",
selectedZone: "LST_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(undefined, selectedZone || "Leitstelle");
useAudioStore.getState().connect("LST_01", selectedZone || "Leitstelle");
dispatchSocket.emit("connect-dispatch", {
logoffTime,
selectedZone,

View File

@@ -1,92 +1,173 @@
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 SetOffPageParams {
page: "off";
}
interface SetStartupPageParams {
page: "startup";
interface SetSdsPageParams {
page: "sds";
station: Station;
sdsMessage: MissionSdsLog;
}
interface SetHomePageParams {
page: "home";
station: Station;
fmsStatus: string;
}
interface SetVoicecallPageParams {
page: "voice-call";
}
interface SetSdsReceivedPopupParams {
popup: "sds-received";
interface SetSendingStatusPageParams {
page: "sending-status";
station: Station;
}
interface SetGroupSelectionPopupParams {
popup: "group-selection";
interface SetNewStatusPageParams {
page: "new-status";
station: Station;
}
interface SetStatusSentPopupParams {
popup: "status-sent";
}
interface SetLoginPopupParams {
popup: "login";
}
interface SetSdsSentPopupParams {
popup: "sds-sent";
}
export type SetPageParams =
type SetPageParams =
| SetHomePageParams
| SetOffPageParams
| SetStartupPageParams
| SetVoicecallPageParams;
export type SetPopupParams =
| SetStatusSentPopupParams
| SetSdsSentPopupParams
| SetGroupSelectionPopupParams
| SetSdsReceivedPopupParams
| SetLoginPopupParams;
interface StringifiedData {
sdsText?: string;
sentSdsText?: string;
groupSelectionGroupId?: string;
callTextHeader?: string;
}
| SetSendingStatusPageParams
| SetSdsPageParams
| SetNewStatusPageParams;
interface MrtStore {
page: SetPageParams["page"];
popup?: SetPopupParams["popup"];
stringifiedData: StringifiedData;
setStringifiedData: (data: Partial<StringifiedData>) => void;
lines: DisplayLineProps[];
setPage: (pageData: SetPageParams) => void;
setPopup: (popupData: SetPopupParams | null) => void;
// internal
updateIntervall?: number;
nightMode: boolean;
setNightMode: (nightMode: boolean) => void;
setLines: (lines: MrtStore["lines"]) => void;
}
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 });
},
}));
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
},
),
);

View File

@@ -1,7 +1,6 @@
import { create } from "zustand";
import { dispatchSocket } from "../../(app)/dispatch/socket";
import { ConnectedAircraft, Mission, MissionSdsLog, Station, User } from "@repo/db";
import { showToast } from "../../_components/customToasts/HPGnotValidated";
import { pilotSocket } from "(app)/pilot/socket";
import { useDmeStore } from "_store/pilot/dmeStore";
import { useMrtStore } from "_store/pilot/MrtStore";
@@ -86,7 +85,7 @@ pilotSocket.on("connect", () => {
usePilotConnectionStore.setState({ status: "connected", message: "" });
const { logoffTime, selectedStation, debug } = usePilotConnectionStore.getState();
dispatchSocket.disconnect();
useAudioStore.getState().connect(undefined, selectedStation?.bosCallsignShort || "pilot");
useAudioStore.getState().connect("LST_01", selectedStation?.bosCallsignShort || "pilot");
pilotSocket.emit("connect-pilot", {
logoffTime,
@@ -109,7 +108,7 @@ pilotSocket.on("connect-message", (data) => {
});
pilotSocket.on("disconnect", () => {
usePilotConnectionStore.setState({ status: "disconnected", connectedAircraft: null });
usePilotConnectionStore.setState({ status: "disconnected" });
useAudioStore.getState().disconnect();
});
@@ -133,22 +132,14 @@ pilotSocket.on("mission-alert", (data: Mission & { Stations: Station[] }) => {
useDmeStore.getState().setPage({
page: "new-mission",
});
if (
data.hpgValidationState === "NOT_VALIDATED" &&
usePilotConnectionStore.getState().connectedAircraft?.posH145active
) {
showToast();
}
});
pilotSocket.on("sds-message", (sdsMessage: MissionSdsLog) => {
console.log("Received sds-message via socket:", sdsMessage);
const station = usePilotConnectionStore.getState().selectedStation;
if (!station) return;
useMrtStore.getState().setPopup({
popup: "sds-received",
});
useMrtStore.getState().setStringifiedData({
sdsText: sdsMessage.data.message,
useMrtStore.getState().setPage({
page: "sds",
station,
sdsMessage,
});
});

View File

@@ -1,6 +1,7 @@
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";
@@ -44,190 +45,197 @@ interface MrtStore {
let interval: NodeJS.Timeout | null = null;
export const useDmeStore = create<MrtStore>((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();
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;
}
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" },
},
]
: []),
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;
}
},
}));
{ 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
},
),
);

View File

@@ -24,7 +24,6 @@ export async function GET(request: Request): Promise<NextResponse> {
...d,
user: undefined,
publicUser: getPublicUser(d.user),
settingsUseHPGAsDispatcher: d.user.settingsUseHPGAsDispatcher,
};
}),
{

View File

@@ -21,10 +21,9 @@ export const PUT = async (req: Request) => {
if (!session && !payload) return Response.json({ message: "Unauthorized" }, { status: 401 });
const userId = session?.user.id || payload.id;
const { position, h145, xPlanePluginActive } = (await req.json()) as {
const { position, h145 } = (await req.json()) as {
position: PositionLog;
h145: boolean;
xPlanePluginActive: boolean;
};
if (!position) {
return Response.json({ message: "Missing id or position" });
@@ -62,7 +61,6 @@ export const PUT = async (req: Request) => {
posHeading: position.heading,
posSpeed: position.speed,
posH145active: h145,
posXplanePluginActive: xPlanePluginActive,
},
});

View File

@@ -9,11 +9,6 @@
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

@@ -78,15 +78,6 @@ export const ConnectedDispatcher = () => {
<div>{asPublicUser(d.publicUser).fullName}</div>
<div className="text-xs font-semibold uppercase opacity-60">{d.zone}</div>
</div>
<div className="mr-2 flex flex-col justify-center">
{d.settingsUseHPGAsDispatcher ? (
<span className="badge badge-sm badge-success badge-outline">HPG aktiv</span>
) : (
<span className="badge badge-sm badge-info badge-outline">
HPG deaktiviert
</span>
)}
</div>
<div>
{(() => {
const badges = (d.publicUser as unknown as PublicUser).badges

View File

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

View File

@@ -44,7 +44,7 @@
"livekit-client": "^2.15.3",
"livekit-server-sdk": "^2.13.1",
"lucide-react": "^0.525.0",
"next": "^15.4.8",
"next": "^15.4.2",
"next-auth": "^4.24.11",
"npm": "^11.4.2",
"postcss": "^8.5.6",
@@ -60,6 +60,7 @@
"tailwindcss": "^4.1.11",
"typescript": "^5.8.3",
"zod": "^3.25.67",
"zustand": "^5.0.6"
"zustand": "^5.0.6",
"zustand-sync-tabs": "^0.2.2"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 349 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -84,7 +84,6 @@ const updateParticipantMoodleResults = async () => {
};
CronJob.from({ cronTime: "0 * * * *", onTick: syncMoodleIds, start: true });
CronJob.from({
cronTime: "*/1 * * * *",
onTick: async () => {

View File

@@ -36,7 +36,6 @@ export const removeRolesFromMember = async (memberId: string, roleIds: string[])
console.error("Error removing roles from member:", error);
});
};
export const setStandardName = async ({
memberId,
userId,

View File

@@ -5,7 +5,6 @@ import { renderPasswordChanged } from "./mail-templates/PasswordChanged";
import { renderVerificationCode } from "./mail-templates/ConfirmEmail";
import { renderBannNotice } from "modules/mail-templates/Bann";
import { renderTimeBanNotice } from "modules/mail-templates/TimeBann";
import Mail from "nodemailer/lib/mailer";
let transporter: nodemailer.Transporter | null = null;
@@ -24,9 +23,8 @@ const initTransporter = () => {
pass: process.env.MAIL_PASSWORD,
},
});
transporter.on("error", (err) => {
console.error("Mail error:", err);
console.error("Mail occurred:", err);
});
transporter.on("idle", () => {
console.log("Mail Idle");

View File

@@ -20,7 +20,7 @@ router.post("/handle-participant-finished", async (req, res) => {
Event: true,
User: {
include: {
DiscordAccount: true,
discordAccounts: true,
},
},
},
@@ -94,7 +94,7 @@ router.post("/handle-participant-enrolled", async (req, res) => {
Event: true,
User: {
include: {
DiscordAccount: true,
discordAccounts: true,
},
},
},

View File

@@ -1,5 +1,9 @@
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_SERVER_URL
ARG NEXT_PUBLIC_DISCORD_URL
@@ -12,13 +16,13 @@ ENV NEXT_PUBLIC_DISCORD_URL=${NEXT_PUBLIC_DISCORD_URL}
ENV NEXT_PUBLIC_MOODLE_URL=${NEXT_PUBLIC_MOODLE_URL}
ENV NEXT_PUBLIC_DISPATCH_URL=${NEXT_PUBLIC_DISPATCH_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 echo "NEXT_PUBLIC_DISCORD_URL=${NEXT_PUBLIC_DISCORD_URL}"
RUN pnpm add -g turbo@^2.5
FROM base AS builder
RUN apk update
RUN apk add --no-cache libc6-compat
@@ -29,13 +33,6 @@ COPY . .
RUN turbo prune hub --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
@@ -47,25 +44,21 @@ RUN pnpm install
# Build the project
COPY --from=builder /usr/app/out/full/ .
RUN turbo run build --filter=hub...
RUN turbo run build
FROM node:22-alpine AS runner
FROM base 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/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
COPY --from=installer --chown=nextjs:nodejs /usr/app/ ./
# Expose the application port
EXPOSE 3000
ENV HOST=0.0.0.0
CMD ["node", "apps/hub/server.js"]
CMD ["pnpm", "--dir", "apps/hub", "run", "start"]

View File

@@ -1,5 +1,6 @@
import { Calendar } from "lucide-react";
import { getServerSession } from "../../api/auth/[...nextauth]/auth";
import { Badge } from "@repo/shared-components";
import { JSX } from "react";
import { getPublicUser, prisma } from "@repo/db";
import { formatTimeRange } from "../../../helper/timerange";
@@ -8,9 +9,9 @@ export const Bookings: () => Promise<JSX.Element> = async () => {
const session = await getServerSession();
const futureBookings = await prisma.booking.findMany({
where: {
userId: session?.user.id,
startTime: {
gte: new Date(),
lte: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
},
},
orderBy: {

View File

@@ -23,6 +23,15 @@ const page = async () => {
userId: user.id,
},
},
Appointments: {
include: {
Participants: {
where: {
appointmentCancelled: false,
},
},
},
},
},
});
@@ -38,13 +47,23 @@ const page = async () => {
return (
<div>
<div className="col-span-full">
<p className="mb-2 mt-5 flex items-center gap-2 text-left text-xl font-semibold">
<RocketIcon className="h-4 w-4" /> Laufende Events & Kurse
<p className="text-xl font-semibold text-left flex items-center gap-2 mb-2 mt-5">
<RocketIcon className="w-4 h-4" /> Laufende Events & Kurse
</p>
</div>
<div className="grid grid-cols-6 gap-4">
{filteredEvents.map((event) => {
return <EventCard user={user} event={event} key={event.id} />;
return (
<EventCard
appointments={event.Appointments}
selectedAppointments={event.Appointments.filter((a) =>
a.Participants.find((p) => p.userId == user.id),
)}
user={user}
event={event}
key={event.id}
/>
);
})}
</div>
</div>

View File

@@ -20,18 +20,18 @@ const PathsOptions = ({
<div className="flex gap-6">
{/* Disponent Card */}
<div
className={`w-80 cursor-pointer rounded-lg border p-6 transition-colors ${
selected === "disponent" ? "border-info ring-info ring-2" : "border-base-300"
className={`cursor-pointer border rounded-lg p-6 w-80 transition-colors ${
selected === "disponent" ? "border-info ring-2 ring-info" : "border-base-300"
}`}
onClick={() => setSelected("disponent")}
role="button"
tabIndex={0}
aria-pressed={selected === "disponent"}
>
<h1 className="mb-2 flex items-center justify-center gap-2 text-lg font-semibold">
<h1 className="font-semibold text-lg mb-2 flex gap-2 justify-center items-center">
Disponent <Workflow />
</h1>
<div className="text-base-content/70 text-sm">
<div className="text-sm text-base-content/70">
Denkt sich realistische Einsatzszenarien aus, koordiniert deren Ablauf und ist die
zentrale Schnittstelle zwischen Piloten und bodengebundenen Rettungsmitteln. Er trägt
die Verantwortung für einen reibungslosen Ablauf und der erfolgreichen Durchführung der
@@ -43,18 +43,18 @@ const PathsOptions = ({
</div>
{/* Pilot Card */}
<div
className={`w-80 cursor-pointer rounded-lg border p-6 transition-colors ${
selected === "pilot" ? "border-info ring-info ring-2" : "border-base-300"
className={`cursor-pointer border rounded-lg p-6 w-80 transition-colors ${
selected === "pilot" ? "border-info ring-2 ring-info" : "border-base-300"
}`}
onClick={() => setSelected("pilot")}
role="button"
tabIndex={0}
aria-pressed={selected === "pilot"}
>
<h1 className="mb-2 flex items-center justify-center gap-2 text-lg font-semibold">
<h1 className="font-semibold text-lg mb-2 flex gap-2 justify-center items-center">
Pilot <Plane />
</h1>
<div className="text-base-content/70 text-sm">
<div className="text-sm text-base-content/70">
Fliegt die vom Disponenten erstellten Einsätze und transportiert die Med-Crew sicher zum
Einsatzort. Er übernimmt die navigatorische Vorbereitung, achtet auf Wetterentwicklungen
und sorgt für die Sicherheit seiner Crew im Flug.
@@ -76,7 +76,17 @@ const EventSelect = ({ pathSelected }: { pathSelected: "disponent" | "pilot" })
const user = useSession().data?.user;
if (!user) return null;
return events?.map((event) => {
return <EventCard user={user} event={event} key={event.id} />;
return (
<EventCard
appointments={event.Appointments}
selectedAppointments={event.Appointments.filter((a) =>
a.Participants.find((p) => p.userId == user.id),
)}
user={user}
event={event}
key={event.id}
/>
);
});
};
@@ -97,14 +107,14 @@ export const FirstPath = () => {
return (
<dialog ref={modalRef} className="modal">
<div className="modal-box w-11/12 max-w-5xl">
<h3 className="mb-10 flex items-center gap-2 text-lg font-bold">
<h3 className="flex items-center gap-2 text-lg font-bold mb-10">
{session?.user.migratedFromV1
? "Hallo, hier hat sich einiges geändert!"
: "Wähle deinen Einstieg!"}
</h3>
<h2 className="mb-4 text-center text-2xl font-bold">Willkommen bei Virtual Air Rescue!</h2>
<h2 className="text-2xl font-bold mb-4 text-center">Willkommen bei Virtual Air Rescue!</h2>
{session?.user.migratedFromV1 ? (
<p className="text-base-content/80 mb-8 text-center text-base">
<p className="mb-8 text-base text-base-content/80 text-center">
Dein Account wurde erfolgreich auf das neue System migriert. Herzlich Willkommen im
neuen HUB! Um die Erfahrung für alle Nutzer zu steigern haben wir uns dazu entschlossen,
dass alle Nutzer einen Test absolvieren müssen:{" "}
@@ -119,12 +129,12 @@ export const FirstPath = () => {
ausprobieren, wenn du möchtest.
</p>
)}
<div className="m-20 flex flex-col items-center justify-center">
<div className="flex flex-col items-center justify-center m-20">
{page === "path" && <PathsOptions selected={selected} setSelected={setSelected} />}
{page === "event-select" && (
<div className="flex min-w-[800px] flex-col gap-3">
<div className="flex flex-col gap-3 min-w-[800px]">
<div>
<p className="text-left text-sm text-gray-400">Wähle dein Einführungs-Event aus:</p>
<p className="text-left text-gray-400 text-sm">Wähle dein Einführungs-Event aus:</p>
</div>
<EventSelect pathSelected={selected!} />
</div>

View File

@@ -2,17 +2,23 @@ import Image from "next/image";
import { DiscordLogoIcon, InstagramLogoIcon, ReaderIcon } from "@radix-ui/react-icons";
import YoutubeSvg from "./youtube_wider.svg";
import FacebookSvg from "./facebook.svg";
import { ChangelogModalBtn } from "@repo/shared-components";
import { getServerSession } from "api/auth/[...nextauth]/auth";
import { updateUser } from "(app)/settings/actions";
import toast from "react-hot-toast";
import { ChangelogWrapper } from "(app)/_components/ChangelogWrapper";
import { prisma } from "@repo/db";
export const Footer = async () => {
const session = await getServerSession();
const latestChangelog = await prisma.changelog.findFirst({
orderBy: {
createdAt: "desc",
},
});
const autoOpen = !session?.user.changelogAck && !!latestChangelog;
return (
<footer className="footer bg-base-200 mt-4 flex items-center justify-between rounded-lg p-4 shadow-md">
{/* Left: Impressum & Datenschutz */}
@@ -33,7 +39,7 @@ export const Footer = async () => {
<div className="flex gap-4">
<div className="tooltip tooltip-top" data-tip="Discord">
<a
href="https://discord.gg/virtualairrescue"
href="https://discord.gg/yn7uXmmNnG"
target="_blank"
rel="noopener noreferrer"
className="hover:text-primary"

Some files were not shown because too many files have changed in this diff Show More