136 Commits

Author SHA1 Message Date
PxlLoewe
2e9bb95d12 admin link 2026-02-01 12:49:05 +01:00
PxlLoewe
824d2e40a9 Fixed Wrong IP being loged 2026-02-01 11:45:18 +01:00
PxlLoewe
cc29ac3e14 List headers 2026-02-01 00:49:17 +01:00
PxlLoewe
195f1dc9c0 Fixed Account Log filter 2026-02-01 00:41:45 +01:00
PxlLoewe
4ae2e93249 Error handling Rename 2026-02-01 00:18:26 +01:00
PxlLoewe
a60cd67c44 Catch Blocks 2026-02-01 00:01:06 +01:00
PxlLoewe
829d6d8cde member, guild fetch improved 2026-01-31 23:39:35 +01:00
PxlLoewe
f0c138655e added getMember into catch block 2026-01-31 23:02:29 +01:00
PxlLoewe
ac441e908d Discord Permissions will be revoked, when under a penalty 2026-01-31 22:48:26 +01:00
PxlLoewe
d1c49a3208 Moved Dispatch NAvbar component, to remove code dupl.; Fixed timezone bug in hub 2026-01-31 22:11:46 +01:00
PxlLoewe
580dc32ad0 fix Type errors by ESM Module of Prisma 2026-01-30 19:42:34 +01:00
PxlLoewe
2d8a282cec add log for delet account 2026-01-30 19:00:01 +01:00
PxlLoewe
5607aacd16 Account deleted flag 2026-01-30 17:29:50 +01:00
PxlLoewe
8555b901a5 Fixe type errors 2026-01-30 17:14:16 +01:00
PxlLoewe
76d4355320 Merge pull request #154 from VAR-Virtual-Air-Rescue/Enhanced-Audit-log-for-user-Profiles
Enhanced audit log for user profiles
2026-01-30 17:01:50 +01:00
PxlLoewe
10af6bf71a Added Account log for registration 2026-01-30 16:56:22 +01:00
PxlLoewe
2154684223 completed Account Log 2026-01-30 16:19:00 +01:00
PxlLoewe
ea8d63ce0b Merge branch 'Enhanced-Audit-log-for-user-Profiles' of https://github.com/VAR-Virtual-Air-Rescue/var-monorepo into Enhanced-Audit-log-for-user-Profiles 2026-01-30 00:27:09 +01:00
PxlLoewe
e4aae9804b Continue Account log 2026-01-30 00:25:51 +01:00
PxlLoewe
005509598c Include Profile log in renamed penalty model -> Audit log 2026-01-29 21:54:04 +01:00
PxlLoewe
b250fa46c2 Merge pull request #153 from VAR-Virtual-Air-Rescue/event-admin-redesign
Event admin redesign
2026-01-29 21:49:05 +01:00
PxlLoewe
e4fa011d96 upgrade pnpm, Table auf Event seite 2026-01-29 21:47:49 +01:00
PxlLoewe
bdc35ea6b3 Include Profile log in renamed penalty model -> Audit log 2026-01-21 19:38:55 +01:00
PxlLoewe
9129652912 remove appointment from events 2026-01-18 01:09:39 +01:00
PxlLoewe
606379d151 Remove Event-Appointment 2026-01-18 01:01:15 +01:00
PxlLoewe
ad15f2d942 Discord Einladungslink 2026-01-17 20:52:16 +01:00
PxlLoewe
2638ad473f Dockerfile Hub 2026-01-16 00:03:33 +01:00
PxlLoewe
da93b5e60c Update Dockerfile 2026-01-16 00:03:33 +01:00
PxlLoewe
15118cac66 Revert "Revert "PR v2.0.7"" 2026-01-16 00:03:33 +01:00
PxlLoewe
c254cd0774 Revert "PR v2.0.7" 2026-01-16 00:03:33 +01:00
PxlLoewe
062e7d44c0 dev 2026-01-16 00:03:33 +01:00
PxlLoewe
b5d67e55b4 night mode nur wenn das mrt an ist 2026-01-15 22:51:10 +01:00
PxlLoewe
ea9c2c0f38 added night iamge 2026-01-15 22:37:29 +01:00
PxlLoewe
72c214a189 fixed Sound nach Verbinden auf RG 2026-01-15 22:22:36 +01:00
PxlLoewe
022d20356c Merge branch 'staging' of https://github.com/VAR-Virtual-Air-Rescue/var-monorepo into staging 2026-01-15 22:08:07 +01:00
PxlLoewe
228b0617e6 Mrt Button bug 2026-01-15 22:06:39 +01:00
PxlLoewe
3413f74fcd Merge pull request #145 from VAR-Virtual-Air-Rescue/mrt-rework
repaired nextJS dockerfiles
2026-01-15 21:38:06 +01:00
PxlLoewe
90fcaf259e repaired nextJS dockerfiles 2026-01-15 21:35:28 +01:00
PxlLoewe
bfe4d56cf7 Merge pull request #144 from VAR-Virtual-Air-Rescue/mrt-rework
Mrt rework
2026-01-15 21:14:59 +01:00
PxlLoewe
48d36af382 MRT: Rufgruppenauswahl, Herunterfahren, Hilfe 2026-01-15 21:12:15 +01:00
PxlLoewe
a65af7f011 Ealisitische Sequenz im HEader vom call-Bildschirm im MRT 2026-01-15 14:09:47 +01:00
PxlLoewe
0b30936f73 Neues MRt eingefügt. Status 059 sind nun keine FMS status mehr 2026-01-15 00:18:50 +01:00
PxlLoewe
edfaf7a228 Fehlender EventID Filter zu Teilnehmer tabelle hinzugefügt. Adatar-alternative in Nutzer übersicht 2026-01-13 13:11:54 +01:00
PxlLoewe
b1d1e7f2bf reduce image size of hub and disptach container 2026-01-13 12:35:44 +01:00
PxlLoewe
c5c3bc0775 Changelog-Seite, option zum verstecken von Einträgen auf dieser 2026-01-06 12:19:10 +01:00
PxlLoewe
dd39331c1a cron performance improved 2026-01-06 03:08:16 +01:00
PxlLoewe
0ac943c63f Discord account Linkage, penalty update 2026-01-06 03:07:09 +01:00
PxlLoewe
b16b719c74 Redesigned Search, removed Unused Admin Route 2025-12-27 15:33:00 +01:00
PxlLoewe
e9a4c50a12 fixed admin search 2025-12-26 01:25:17 +01:00
PxlLoewe
17208eded9 Added Account Dublicate fucntion, improved default sorts 2025-12-26 01:23:32 +01:00
PxlLoewe
51ef9cd90c use fetch to get Aircraft Marker 2025-12-15 21:19:39 +01:00
PxlLoewe
434154e26d Security Fixes 2025-12-15 02:55:44 +01:00
PxlLoewe
483b5eba46 Merge branch 'release' into staging 2025-12-08 19:40:07 +01:00
PxlLoewe
bc61144258 Fixed Buchungssystem 2025-12-08 19:30:08 +01:00
PxlLoewe
1e36622289 update nextJS 2025-12-08 18:48:28 +01:00
PxlLoewe
b9e871ae01 Dispo-Option, die HPG validierung nicht zu nutzen 2025-11-27 22:21:27 +01:00
PxlLoewe
6081c1e38d vm network 2025-11-08 12:03:00 +01:00
PxlLoewe
d6bfcd3061 merge prometheus 2025-11-08 11:45:08 +01:00
PxlLoewe
59357a2ae6 VM volume 2025-11-08 11:41:50 +01:00
PxlLoewe
e639ba6704 added victoriametrics 2025-11-08 11:41:50 +01:00
PxlLoewe
6a739f4871 VM volume 2025-11-08 11:31:27 +01:00
PxlLoewe
cce2c246f6 Merge branch 'staging' of https://github.com/VAR-Virtual-Air-Rescue/var-monorepo into staging 2025-11-08 11:27:44 +01:00
PxlLoewe
238fae694c added victoriametrics 2025-11-08 11:27:39 +01:00
PxlLoewe
60e60ea069 Merge pull request #139 from VAR-Virtual-Air-Rescue/release
Maerge Commits from release to staging
2025-11-08 10:40:39 +01:00
PxlLoewe
f0d133d827 rename Map-Aircraft cache key 2025-11-08 10:38:57 +01:00
PxlLoewe
cda2f272cc rename Map-Aircraft cache key 2025-11-08 09:28:23 +01:00
PxlLoewe
33c33b4de1 Doppeltes "Einsatz" bei benachrichtigungen entfernt 2025-10-28 02:35:12 +01:00
PxlLoewe
4d43e2a36d Einsatz geschlossen event wird richtig an piloten gesendet 2025-10-28 02:19:55 +01:00
PxlLoewe
da9b957fcf XPlane objecte können wegebt und per rechts-click gelöscht werden 2025-10-28 01:52:55 +01:00
PxlLoewe
5af68b8a70 Merge branch 'staging' of https://github.com/VAR-Virtual-Air-Rescue/var-monorepo into staging 2025-10-24 22:41:25 +02:00
PxlLoewe
192ad7dedd .env example anbepasst 2025-10-24 22:41:21 +02:00
PxlLoewe
4d93ceaf1c Kein Szenerie als Standart + keine Validierung für dieses Szenario 2025-10-16 18:05:15 +02:00
PxlLoewe
3d77ab3b90 mission closed socket event 2025-10-16 14:30:01 +02:00
PxlLoewe
c4e0213a5f Plazierung von X Plane Objekten 2025-10-16 14:23:50 +02:00
PxlLoewe
b5f07071a5 dev 2025-10-16 11:17:52 +02:00
PxlLoewe
1919227cd4 redis not req for local dev 2025-10-04 21:58:34 +02:00
PxlLoewe
a2c320ddbe XPlane Plugin anzeige auf tracker 2025-10-04 21:16:24 +02:00
PxlLoewe
859b8519db closes #136 2025-10-04 19:53:56 +02:00
PxlLoewe
f691eb5f7c Keine Simmulator-Verbundung kommt erst 30 sek nach Verbinden für piloten 2025-10-01 23:20:41 +02:00
PxlLoewe
cd6885c5f2 closes #102 2025-10-01 23:03:03 +02:00
PxlLoewe
eddb3317d5 closes #135 2025-10-01 22:43:30 +02:00
PxlLoewe
ada041bd4a Falsche Zeitzone bei Buchungen und HTTP status codes als Fehlermeldung behoben 2025-10-01 22:23:27 +02:00
PxlLoewe
dc4a3ab4d8 Buchungen werden in Connect Modal angezeigt (Pilot), new Booking form improvement 2025-09-28 12:19:14 +02:00
PxlLoewe
ebeb2cf93a Booking Panel auf Dashboard 2025-09-27 22:25:31 +02:00
PxlLoewe
cf199150fe futher booking stuff 2025-09-20 22:16:23 +02:00
PxlLoewe
ba027957ce Buchungssystem erste überarbeitungen 2025-09-20 00:28:53 +02:00
nocnico
a612cf9951 Add Booking System 2025-09-18 21:49:03 +02:00
PxlLoewe
13ce99da96 Merge pull request #137 from VAR-Virtual-Air-Rescue/staging
Datenschutzerklärung im Registrierungsformular
2025-09-10 23:42:33 +02:00
PxlLoewe
715cb9ef53 Datenschutzerklärung im Registrierungsformular 2025-09-10 15:58:36 +02:00
PxlLoewe
9a26920d7d Merge pull request #133 from VAR-Virtual-Air-Rescue/staging
v2.0.3
2025-07-29 16:33:11 -07:00
PxlLoewe
266ff87fd8 Penalty Nachricht im Admin form, abgelaufen für Timebans in tabelle werden Farblich dargestellt 2025-07-29 15:54:09 -07:00
PxlLoewe
99c3024d85 fixes #132 2025-07-29 15:52:34 -07:00
PxlLoewe
627060e32e Merge branch 'staging' of https://github.com/VAR-Virtual-Air-Rescue/var-monorepo into staging 2025-07-29 13:21:51 -07:00
PxlLoewe
23f7671d42 Name des Chatpartners wird nun mit rolle angezeigt 2025-07-29 13:21:47 -07:00
PxlLoewe
e134c9b2fa Huschrauber logging für alle 2025-07-29 12:37:36 -07:00
PxlLoewe
1bdccc46fe Zeitstreafen werden nun beim Verbinden überprüft 2025-07-29 12:34:55 -07:00
nocnico
d01ba24243 Fix changelog text dark on lightmode browser 2025-07-29 11:51:17 +02:00
PxlLoewe
fd50e9c4d5 logging entfernt in NewReport 2025-07-27 20:43:30 -07:00
PxlLoewe
2a859b3415 Merge pull request #128 from VAR-Virtual-Air-Rescue/staging
remove old User File from repo
2025-07-27 20:34:45 -07:00
PxlLoewe
157d2f02e1 fixed NewReport form 2025-07-27 20:23:30 -07:00
PxlLoewe
a3e143145f mit var.User.json in der gitignore kopiert git die auch nicht... 2025-07-27 19:49:57 -07:00
PxlLoewe
1b447edb11 add user migration file to gitignore 2025-07-27 17:15:28 -07:00
PxlLoewe
6b4cc0b58b remove user from repo. Local only 2025-07-27 17:15:09 -07:00
PxlLoewe
6aa6329d83 Merge pull request #127 from VAR-Virtual-Air-Rescue/staging
Bugfixes + Manuelle reports
2025-07-27 17:00:57 -07:00
PxlLoewe
f88b0bb56c Improved Station Notification 2025-07-27 16:27:58 -07:00
PxlLoewe
25f56026fc Fixed type bugs in Reports form 2025-07-27 15:02:14 -07:00
PxlLoewe
7fc8749676 staging ci timeout 2025-07-27 14:22:56 -07:00
PxlLoewe
575438e974 link zu neuem Report auf Admin-seite 2025-07-27 14:17:35 -07:00
PxlLoewe
89a0eb7135 Manuelle reports 2025-07-27 13:45:13 -07:00
PxlLoewe
2c6913eeb9 Merge pull request #126 from VAR-Virtual-Air-Rescue/staging
Prometheus rückgänging
2025-07-26 14:00:34 -07:00
PxlLoewe
453ad28538 Prometheus zückgänging 2025-07-26 13:59:55 -07:00
PxlLoewe
15f9512d8e Merge pull request #125 from VAR-Virtual-Air-Rescue/staging
vlt jetzt?
2025-07-26 13:58:22 -07:00
PxlLoewe
5a8bd1abe3 vlt jetzt? 2025-07-26 13:43:50 -07:00
PxlLoewe
daf5759778 Merge pull request #124 from VAR-Virtual-Air-Rescue/staging
prometheus config
2025-07-26 13:32:05 -07:00
PxlLoewe
1736bc79c0 prometheus config 2025-07-26 13:25:33 -07:00
PxlLoewe
de54103e6e Merge pull request #123 from VAR-Virtual-Air-Rescue/staging
Bugfux und Node Exporter für Prometheus
2025-07-26 12:34:57 -07:00
PxlLoewe
efa2ca8412 Merge branch 'staging' of https://github.com/VAR-Virtual-Air-Rescue/var-monorepo into staging 2025-07-26 12:33:44 -07:00
PxlLoewe
b9a4f5e8d3 added http service to prometheus, fixes sound Bug 2025-07-26 12:33:41 -07:00
PxlLoewe
a09671036d entferne ADMIN_HELIPORT und ADMIN_EVENT berechtigung für CGs 2025-07-26 11:24:45 -07:00
Nicolas
f0dfe91a00 Merge pull request #120 from VAR-Virtual-Air-Rescue/staging
Fix Chats können nur mit Disponenten eröffnet werden
2025-07-26 12:40:38 +02:00
nocnico
f32ed62a76 Fix Chats können nur mit Disponenten eröffnet werden 2025-07-26 12:21:23 +02:00
PxlLoewe
53de66e811 Merge pull request #119 from VAR-Virtual-Air-Rescue/staging
v2.0.2
2025-07-25 22:55:41 -07:00
PxlLoewe
5b57b31706 fixes #118 2025-07-25 22:37:46 -07:00
PxlLoewe
2abdbf168f fixes #117 2025-07-25 21:30:03 -07:00
PxlLoewe
1da23c3412 resolves #116 2025-07-25 21:02:18 -07:00
PxlLoewe
f8e9ad84b9 resolves #103 2025-07-25 20:56:21 -07:00
PxlLoewe
f534bbc902 implemented #112 2025-07-25 16:53:22 -07:00
PxlLoewe
14ea5fcf55 fixed User argument is missing on Discord-connect 2025-07-25 16:49:50 -07:00
PxlLoewe
383e15f38a Merge branch 'staging' of https://github.com/VAR-Virtual-Air-Rescue/var-monorepo into staging 2025-07-25 10:32:45 -07:00
PxlLoewe
afe32306d3 fixed #106 2025-07-25 10:32:35 -07:00
PxlLoewe
1b23aede89 fixed #105 2025-07-25 10:04:44 -07:00
PxlLoewe
54e0bc0b12 fixed #104 2025-07-25 09:53:58 -07:00
nocnico
2671571bfb maybe ghostmode fix now? 2025-07-25 17:43:05 +02:00
nocnico
b602a5836c Ghostmode fix now? 2025-07-25 17:19:58 +02:00
Nicolas
1bcb2dbff7 Merge pull request #101 from VAR-Virtual-Air-Rescue/staging
fixed #100 // fix Cluster naming, fix Marker Popup settings not used
2025-07-25 16:45:24 +02:00
nocnico
4eb46cb783 fixed #100 // fix Marker Cluster naming, fix Marker Popup settings not used 2025-07-25 16:39:21 +02:00
245 changed files with 7270 additions and 2876 deletions

View File

@@ -40,7 +40,7 @@ jobs:
username: ${{ secrets.SSH_USERNAME }}
password: ${{ secrets.SSH_PASSWORD }}
port: 22
command_timeout: 30m
command_timeout: 60m
script: |
export NVM_DIR="$HOME/.nvm"
source "$NVM_DIR/nvm.sh"

1
.gitignore vendored
View File

@@ -2,6 +2,7 @@
letsencrypt
# Dependencies
node_modules
.pnp

View File

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

View File

@@ -1,6 +1,8 @@
import { MissionLog, NotificationPayload, prisma } from "@repo/db";
import { DISCORD_ROLES, 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";
const removeMission = async (id: number, reason: string) => {
const log: MissionLog = {
@@ -34,7 +36,6 @@ 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: {
@@ -98,13 +99,25 @@ const removeClosedMissions = async () => {
if (!lastAlertTime) return;
const lastStatus1or6Log = (mission.missionLog as unknown as MissionLog[])
.filter((l) => {
return (
l.type === "station-log" && (l.data?.newFMSstatus === "1" || l.data?.newFMSstatus === "6")
);
})
.sort((a, b) => new Date(b.timeStamp).getTime() - new Date(a.timeStamp).getTime())[0];
// Case 1: Forgotten Mission, last alert more than 3 Hours ago
const now = new Date();
if (now.getTime() - lastAlertTime.getTime() > 1000 * 60 * 180)
return removeMission(mission.id, "inaktivität");
// Case 2: All stations in mission changed from status 4 to 1/6 or from status 8 to 1/6
if (allStationsInMissionChangedFromStatus4to1Or8to1)
// Case 2: All stations in mission changed from status 4 to 1/6 or from status 8 to 1/6, Status 1/6 change less more 5 minutes ago
if (
allStationsInMissionChangedFromStatus4to1Or8to1 &&
lastStatus1or6Log &&
now.getTime() - new Date(lastStatus1or6Log.timeStamp).getTime() > 1000 * 60 * 5
)
return removeMission(mission.id, "dem freimelden aller Stationen");
});
};
@@ -128,9 +141,86 @@ 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);
}
};
removePermissionsForBannedUsers();
cron.schedule("*/1 * * * *", async () => {
try {
await removePermissionsForBannedUsers();
await removeClosedMissions();
await removeConnectedAircrafts();
} catch (error) {

View File

@@ -7,22 +7,25 @@ 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;
};
router.post("/set-standard-name", async (req, res) => {
const { memberId, userId } = req.body;
export const setUserStandardNamePermissions = async ({
memberId,
userId,
}: {
memberId: string;
userId: string;
}) => {
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,
@@ -32,6 +35,25 @@ router.post("/set-standard-name", async (req, res) => {
},
});
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)) {
@@ -44,12 +66,29 @@ router.post("/set-standard-name", async (req, res) => {
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 });
}
});
export default router;

View File

@@ -9,13 +9,23 @@ if (!GUILD_ID) {
const router: Router = Router();
export const getMember = async (memberId: string) => {
const guild = client.guilds.cache.get(GUILD_ID);
let guild = client.guilds.cache.get(GUILD_ID);
if (!guild) {
guild = await client.guilds.fetch(GUILD_ID);
}
if (!guild) throw new Error("Guild not found");
try {
return guild.members.cache.get(memberId) ?? (await guild.members.fetch(memberId));
let member = guild.members.cache.get(memberId);
if (!member) {
member = await guild.members.fetch(memberId).catch((e) => undefined);
}
return member;
} catch (error) {
console.error("Error fetching member:", error);
throw new Error("Member not found");
return null;
}
};
@@ -27,6 +37,10 @@ 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" });
@@ -42,6 +56,9 @@ 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 =

View File

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

View File

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

View File

@@ -6,14 +6,17 @@ export const sendAlert = async (
id: number,
{
stationId,
desktopOnly,
}: {
stationId?: number;
desktopOnly?: boolean;
},
user: User | "HPG",
): Promise<{
connectedAircrafts: ConnectedAircraft[];
mission: Mission;
}> => {
try {
const mission = await prisma.mission.findUnique({
where: { id: id },
});
@@ -45,10 +48,13 @@ export const sendAlert = async (
});
for (const aircraft of connectedAircrafts) {
if (!desktopOnly) {
io.to(`station:${aircraft.stationId}`).emit("mission-alert", {
...mission,
Stations,
});
}
io.to(`desktop:${aircraft.userId}`).emit("mission-alert", {
missionId: mission.id,
});
@@ -123,4 +129,8 @@ export const sendAlert = async (
});
}
return { connectedAircrafts, mission };
} catch (error) {
console.error("Error sending mission alert:", error);
throw new Error("Ein Fehler ist aufgetreten. Bitte melde den Fehler als Bug");
}
};

View File

@@ -50,10 +50,7 @@ const getRthCallsigns = (mission: Mission, stations: Station[]) => {
return `🚁 RTH${callsigns.length > 1 ? "s" : ""}: ${callsigns.join(" / ")} `;
};
const getNtfyHeader = (
mission: Mission,
clientStation: Station,
): NtfyHeader => ({
const getNtfyHeader = (mission: Mission, clientStation: Station): NtfyHeader => ({
headers: {
Title: `${clientStation.bosCallsignShort} / ${mission.missionKeywordAbbreviation} / ${mission.missionKeywordCategory}`,
Tags: "pager",
@@ -76,9 +73,13 @@ export const sendNtfyMission = async (
clientStation: Station,
ntfyRoom: string,
) => {
axios.post(
try {
await axios.post(
`https://ntfy.sh/${ntfyRoom}`,
getNtfyData(mission, stations),
getNtfyHeader(mission, clientStation),
);
} catch (error) {
console.error("Error sending Ntfy mission:", error);
}
};

View File

@@ -1,13 +1,17 @@
import { createClient, RedisClientType } from "redis";
export const pubClient: RedisClientType = createClient({
url: `redis://${process.env.REDIS_HOST}:${process.env.REDIS_PORT}`,
url: `redis://${process.env.REDIS_HOST || "localhost"}:${process.env.REDIS_PORT || 6379}`,
});
export const subClient: RedisClientType = pubClient.duplicate();
Promise.all([pubClient.connect(), subClient.connect()]).then(() => {
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");
});
});
}
pubClient.on("error", (err) => console.log("Redis Client Error", err));
subClient.on("error", (err) => console.log("Redis Client Error", err));

View File

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

View File

@@ -34,7 +34,7 @@ router.patch("/:id", async (req, res) => {
},
});
if (discordAccount?.id) {
if (discordAccount?.id && !disaptcherUpdate.ghostMode) {
await renameMember(
discordAccount.discordId.toString(),
`${getPublicUser(newDispatcher.user).fullName}${newDispatcher.zone}`,

View File

@@ -87,6 +87,29 @@ 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);
@@ -113,9 +136,10 @@ router.delete("/:id", async (req, res) => {
router.post("/:id/send-alert", async (req, res) => {
const { id } = req.params;
const { stationId, vehicleName } = req.body as {
const { stationId, vehicleName, desktopOnly } = req.body as {
stationId?: number;
vehicleName?: "RTW" | "POL" | "FW";
desktopOnly?: boolean;
};
if (!req.user) {
@@ -180,7 +204,11 @@ router.post("/:id/send-alert", async (req, res) => {
return;
}
const { connectedAircrafts, mission } = await sendAlert(Number(id), { stationId }, req.user);
const { connectedAircrafts, mission } = await sendAlert(
Number(id),
{ stationId, desktopOnly },
req.user,
);
io.to("dispatchers").emit("update-mission", mission);
res.status(200).json({
@@ -189,7 +217,9 @@ router.post("/:id/send-alert", async (req, res) => {
return;
} catch (error) {
console.error(error);
res.status(500).json({ error: "Failed to send mission" });
res.status(500).json({
error: `Ein Fehler ist aufgetreten. Bitte melde den Fehler als Bug (${(error as Error).message})`,
});
return;
}
});

View File

View File

@@ -1,6 +1,6 @@
import { getPublicUser, prisma, User } from "@repo/db";
import { addRolesToMember, removeRolesFromMember, renameMember } from "modules/discord";
import { getNextDateWithTime } from "@repo/shared-components";
import { getNextDateWithTime, getUserPenaltys } from "@repo/shared-components";
import { DISCORD_ROLES } from "@repo/db";
import { Server, Socket } from "socket.io";
@@ -28,8 +28,17 @@ export const handleConnectDispatch =
return;
}
if (!user.permissions?.includes("DISPO")) {
socket.emit("error", "You do not have permission to connect to the dispatch server.");
const userPenaltys = await getUserPenaltys(user.id);
if (
userPenaltys.openTimeban.length > 0 ||
user.isBanned ||
userPenaltys.openBans.length > 0
) {
socket.emit("connect-message", {
message: "Du hast eine aktive Strafe und kannst dich deshalb nicht verbinden.",
});
socket.disconnect();
return;
}
@@ -70,7 +79,7 @@ export const handleConnectDispatch =
userId: user.id,
},
});
if (discordAccount?.id) {
if (discordAccount?.id && !ghostMode) {
await renameMember(
discordAccount.discordId.toString(),
`${getPublicUser(user).fullName}${selectedZone}`,

View File

@@ -1,8 +1,8 @@
import { getPublicUser, prisma, User } from "@repo/db";
import { addRolesToMember, removeRolesFromMember, renameMember } from "modules/discord";
import { getNextDateWithTime } from "@repo/shared-components";
import { DISCORD_ROLES } from "@repo/db";
import { Server, Socket } from "socket.io";
import { getUserPenaltys } from "@repo/shared-components";
export const handleConnectPilot =
(socket: Socket, io: Server) =>
@@ -34,6 +34,19 @@ export const handleConnectPilot =
socket.disconnect();
return;
}
const userPenaltys = await getUserPenaltys(userId);
if (
userPenaltys.openTimeban.length > 0 ||
user.isBanned ||
userPenaltys.openBans.length > 0
) {
socket.emit("connect-message", {
message: "Du hast eine aktive Strafe und kannst dich deshalb nicht verbinden.",
});
socket.disconnect();
return;
}
if (!user) return Error("User not found");
@@ -83,6 +96,8 @@ export const handleConnectPilot =
lastHeartbeat: debug ? nowPlus2h.toISOString() : undefined,
posLat: randomPos?.lat,
posLng: randomPos?.lng,
posXplanePluginActive: debug ? true : undefined,
posH145active: debug ? true : undefined,
},
});

View File

@@ -5,7 +5,7 @@ import { Server, Socket } from "socket.io";
export const handleSendMessage =
(socket: Socket, io: Server) =>
async (
{ userId, message }: { userId: string; message: string },
{ userId, message, role }: { userId: string; message: string; role: string },
cb: (err: { error?: string }) => void,
) => {
const senderId = socket.data.user.id;
@@ -24,7 +24,7 @@ export const handleSendMessage =
receiverId: userId,
senderId,
receiverName: `${receiverUser?.firstname} ${receiverUser?.lastname[0]}. - ${receiverUser?.publicId}`,
senderName: `${senderUser?.firstname} ${senderUser?.lastname[0]}. - ${senderUser?.publicId}`,
senderName: `${senderUser?.firstname} ${senderUser?.lastname[0]}. - ${role ?? senderUser?.publicId}`,
},
});

View File

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

View File

@@ -0,0 +1,32 @@
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";
export default async function Navbar({ children }: { children: React.ReactNode }) {
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>
<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

@@ -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: "LST_01",
selectedZone: "VAR_LST_RD_01",
ghostMode: false,
});
const changeDispatcherMutation = useMutation({

View File

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

@@ -3,16 +3,17 @@ import { useEffect, useRef, useState } from "react";
import { GearIcon } from "@radix-ui/react-icons";
import { SettingsIcon, Volume2 } from "lucide-react";
import MicVolumeBar from "_components/MicVolumeIndication";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { editUserAPI, getUserAPI } from "_querys/user";
import { useSession } from "next-auth/react";
import { useAudioStore } from "_store/audioStore";
import toast from "react-hot-toast";
import { useMapStore } from "_store/mapStore";
import { set } from "date-fns";
import { Button } from "@repo/shared-components";
export const SettingsBtn = () => {
const session = useSession();
const queryClient = useQueryClient();
const [inputDevices, setInputDevices] = useState<MediaDeviceInfo[]>([]);
const { data: user } = useQuery({
@@ -23,6 +24,10 @@ export const SettingsBtn = () => {
const editUserMutation = useMutation({
mutationFn: editUserAPI,
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["user", session.data?.user.id] });
},
});
useEffect(() => {
@@ -40,6 +45,7 @@ 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);
@@ -57,7 +63,8 @@ export const SettingsBtn = () => {
micDeviceId: user.settingsMicDevice,
micVolume: user.settingsMicVolume || 1,
radioVolume: user.settingsRadioVolume || 0.8,
autoCloseMapPopup: user.settingsAutoCloseMapPopup || false,
autoCloseMapPopup: user.settingsAutoCloseMapPopup,
useHPGAsDispatcher: user.settingsUseHPGAsDispatcher,
});
setUserSettings({
settingsAutoCloseMapPopup: user.settingsAutoCloseMapPopup || false,
@@ -198,6 +205,17 @@ 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 als Disponent verwenden
</div>
<div className="modal-action flex justify-between">
<button
@@ -211,7 +229,7 @@ export const SettingsBtn = () => {
>
Schließen
</button>
<button
<Button
className="btn btn-soft btn-success"
type="submit"
onSubmit={() => false}
@@ -224,6 +242,7 @@ export const SettingsBtn = () => {
settingsMicVolume: settings.micVolume,
settingsRadioVolume: settings.radioVolume,
settingsAutoCloseMapPopup: settings.autoCloseMapPopup,
settingsUseHPGAsDispatcher: settings.useHPGAsDispatcher,
},
});
setAudioSettings({
@@ -239,7 +258,7 @@ export const SettingsBtn = () => {
}}
>
Speichern
</button>
</Button>
</div>
</div>
</dialog>

View File

@@ -1,26 +0,0 @@
"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,8 +28,11 @@ 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);
@@ -44,6 +47,10 @@ 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,
@@ -81,7 +88,6 @@ export const MissionForm = () => {
},
});
const session = useSession();
const defaultFormValues = React.useMemo(
() =>
({
@@ -108,6 +114,7 @@ export const MissionForm = () => {
hpgSelectedMissionString: null,
hpg: null,
missionLog: [],
xPlaneObjects: [],
}) as MissionOptionalDefaults,
[session.data?.user.id],
);
@@ -116,13 +123,16 @@ export const MissionForm = () => {
resolver: zodResolver(MissionOptionalDefaultsSchema),
defaultValues: defaultFormValues,
});
const { missionFormValues, setOpen } = usePannelStore((state) => state);
const { missionFormValues, setOpen, setMissionFormValues } = usePannelStore((state) => state);
const validationRequired = HPGValidationRequired(
const validationRequired =
HPGValidationRequired(
form.watch("missionStationIds"),
aircrafts,
form.watch("hpgMissionString"),
);
) &&
!form.watch("hpgMissionString")?.startsWith("kein Szenario") &&
user?.settingsUseHPGAsDispatcher;
useEffect(() => {
if (session.data?.user.id) {
@@ -144,6 +154,7 @@ 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,
@@ -153,6 +164,22 @@ 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 } = {},
@@ -369,6 +396,7 @@ 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"))
@@ -415,6 +443,21 @@ 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
@@ -430,7 +473,11 @@ export const MissionForm = () => {
setSearchElements([]); // Reset search elements
setEditingMission(null);
setContextMenu(null);
toast.success(`Einsatz ${newMission.publicId} erstellt`);
if (editingMissionId) {
toast.success(`${newMission.publicId} bearbeitet`);
} else {
toast.success(`${newMission.publicId} erstellt`);
}
form.reset();
setOpen(false);
} catch (error) {
@@ -455,7 +502,11 @@ export const MissionForm = () => {
setSearchElements([]); // Reset search elements
setContextMenu(null);
toast.success(`Einsatz ${newMission.publicId} erstellt`);
if (editingMissionId) {
toast.success(`${newMission.publicId} bearbeitet`);
} else {
toast.success(`${newMission.publicId} erstellt`);
}
form.reset();
setOpen(false);
} catch (error) {

View File

@@ -1,7 +1,10 @@
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",
@@ -26,7 +29,11 @@ export default async function RootLayout({
return (
<>
<Navbar />
<Navbar>
<Audio />
<Connection />
<Settings />
</Navbar>
{children}
</>
);

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 366 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

View File

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

View File

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

View File

@@ -6,9 +6,10 @@ import { useMutation, useQuery } from "@tanstack/react-query";
import { getStationsAPI } from "_querys/stations";
import { editConnectedAircraftAPI, getConnectedAircraftsAPI } from "_querys/aircrafts";
import { Prisma } from "@repo/db";
import { Button, getNextDateWithTime } from "@repo/shared-components";
import { Button, cn, getNextDateWithTime } from "@repo/shared-components";
import { Select } from "_components/Select";
import { Radio } from "lucide-react";
import { Calendar, Radio } from "lucide-react";
import { getBookingsAPI } from "_querys/bookings";
export const ConnectionBtn = () => {
const modalRef = useRef<HTMLDialogElement>(null);
@@ -27,6 +28,19 @@ export const ConnectionBtn = () => {
queryKey: ["stations"],
queryFn: () => getStationsAPI(),
});
const { data: bookings } = useQuery({
queryKey: ["bookings"],
queryFn: () =>
getBookingsAPI({
startTime: {
lte: new Date(Date.now() + 8 * 60 * 60 * 1000),
},
endTime: {
gte: new Date(),
},
}),
});
const aircraftMutation = useMutation({
mutationFn: ({
change,
@@ -117,7 +131,9 @@ export const ConnectionBtn = () => {
(option as { component: React.ReactNode }).component
}
options={
stations?.map((station) => ({
stations?.map((station) => {
const booking = bookings?.find((b) => b.stationId == station.id);
return {
value: station.id.toString(),
label: station.bosCallsign,
component: (
@@ -126,11 +142,28 @@ export const ConnectionBtn = () => {
{connectedAircrafts?.find((a) => a.stationId == station.id) && (
<Radio className="text-warning" size={15} />
)}
{booking && (
<div
className="tooltip tooltip-right"
data-tip={`${new Date(booking.startTime).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} - ${new Date(booking.endTime).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} Uhr gebucht von ${booking.userId == session.data?.user?.id ? "dir" : booking.User.fullName}`}
>
<Calendar
className={
cn(
"text-warning",
booking?.userId === session.data?.user?.id,
) && "text-success"
}
size={15}
/>
</div>
)}
{station.bosCallsign}
</span>
</div>
),
})) ?? []
};
}) ?? []
}
/>
</div>

View File

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

@@ -3,15 +3,17 @@ 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 } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } 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({
@@ -22,6 +24,10 @@ 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(() => {
@@ -248,7 +254,7 @@ export const SettingsBtn = () => {
>
Schließen
</button>
<button
<Button
className="btn btn-soft btn-success"
type="submit"
onSubmit={() => false}
@@ -275,7 +281,7 @@ export const SettingsBtn = () => {
}}
>
Speichern
</button>
</Button>
</div>
</div>
</dialog>

View File

@@ -1,26 +0,0 @@
"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,7 +1,10 @@
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",
@@ -26,7 +29,11 @@ export default async function RootLayout({
return (
<>
<Navbar />
<Navbar>
<Audio />
<Connection />
<Settings />
</Navbar>
{children}
</>
);

View File

@@ -6,13 +6,17 @@ import { Report } from "../../_components/left/Report";
import { Dme } from "(app)/pilot/_components/dme/Dme";
import dynamic from "next/dynamic";
import { ConnectedDispatcher } from "tracker/_components/ConnectedDispatcher";
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery } from "@tanstack/react-query";
import { usePilotConnectionStore } from "_store/pilot/connectionStore";
import { getConnectedAircraftsAPI } from "_querys/aircrafts";
import { checkSimulatorConnected } from "@repo/shared-components";
import { Button, checkSimulatorConnected, useDebounce } from "@repo/shared-components";
import { SimConnectionAlert } from "(app)/pilot/_components/SimConnectionAlert";
import { SettingsBoard } from "_components/left/SettingsBoard";
import { BugReport } from "_components/left/BugReport";
import { useEffect, useState } from "react";
import { useDmeStore } from "_store/pilot/dmeStore";
import { sendMissionAPI } from "_querys/missions";
import toast from "react-hot-toast";
const Map = dynamic(() => import("_components/map/Map"), {
ssr: false,
@@ -20,12 +24,45 @@ const Map = dynamic(() => import("_components/map/Map"), {
const PilotPage = () => {
const { connectedAircraft, status } = usePilotConnectionStore((state) => state);
const { latestMission } = useDmeStore((state) => state);
// Query will be cached anyway, due to this, displayed Markers are in sync with own Aircraft connection-warning
const { data: aircrafts } = useQuery({
queryKey: ["aircrafts"],
queryFn: () => getConnectedAircraftsAPI(),
refetchInterval: 10_000,
});
const sendAlertMutation = useMutation({
mutationKey: ["missions"],
mutationFn: (params: {
id: number;
stationId?: number | undefined;
vehicleName?: "RTW" | "POL" | "FW" | undefined;
desktopOnly?: boolean | undefined;
}) => sendMissionAPI(params.id, params),
onError: (error) => {
console.error(error);
toast.error("Fehler beim Alarmieren");
},
onSuccess: (data) => {
toast.success(data.message);
},
});
const [shortlyConnected, setShortlyConnected] = useState(false);
useDebounce(
() => {
if (status === "connected") {
setShortlyConnected(false);
}
},
30_000,
[status],
);
useEffect(() => {
if (status === "connected") {
setShortlyConnected(true);
}
}, [status]);
const ownAircraft = aircrafts?.find((aircraft) => aircraft.id === connectedAircraft?.id);
const simulatorConnected = ownAircraft ? checkSimulatorConnected(ownAircraft) : false;
@@ -47,16 +84,49 @@ const PilotPage = () => {
</div>
<Map />
<div className="absolute right-10 top-5 z-20 space-y-2">
{!simulatorConnected && status === "connected" && (
{!simulatorConnected &&
status === "connected" &&
connectedAircraft &&
!shortlyConnected && (
<SimConnectionAlert lastUpdated={ownAircraft?.lastHeartbeat} />
)}
<ConnectedDispatcher />
</div>
</div>
</div>
<div className="flex h-full w-1/3">
<div className="flex h-full w-1/3 min-w-[500px]">
<div className="bg-base-300 flex h-full w-full flex-col p-4">
<h2 className="card-title mb-2">MRT & DME</h2>
<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>
<div
className="tooltip tooltip-left mb-4"
data-tip="Dadurch wird der Einsatz erneut an den Desktop-Client gesendet."
>
<Button
className="btn btn-xs btn-outline"
disabled={!latestMission}
onClick={async () => {
if (!latestMission) return;
await sendAlertMutation.mutateAsync({
id: latestMission.id,
desktopOnly: false,
});
}}
>
Erneut senden
</Button>
</div>
</div>
<div className="card bg-base-200 mb-4 shadow-xl">
<div className="card-body flex h-full w-full items-center justify-center">
<div className="max-w-150">

View File

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

View File

@@ -24,7 +24,9 @@ import { useSounds } from "_components/Audio/useSounds";
export const Audio = () => {
const {
selectedRoom,
speakingParticipants,
resetSpeakingParticipants,
isTalking,
toggleTalking,
transmitBlocked,
@@ -36,8 +38,9 @@ export const Audio = () => {
room,
message,
removeMessage,
setSelectedRoom,
} = useAudioStore();
const [selectedRoom, setSelectedRoom] = useState<string>("LST_01");
useSounds({
isReceiving: speakingParticipants.length > 0,
isTransmitting: isTalking,
@@ -46,7 +49,7 @@ export const Audio = () => {
});
const { selectedStation, status: pilotState } = usePilotConnectionStore((state) => state);
const { selectedZone, status: dispatcherState } = useDispatchConnectionStore((state) => state);
const { status: dispatcherState } = useDispatchConnectionStore((state) => state);
const session = useSession();
const [isReceivingBlick, setIsReceivingBlick] = useState(false);
const [recentSpeakers, setRecentSpeakers] = useState<typeof speakingParticipants>([]);
@@ -91,7 +94,7 @@ export const Audio = () => {
const canStopOtherSpeakers = dispatcherState === "connected";
const role =
(dispatcherState === "connected" && selectedZone) ||
(dispatcherState === "connected" && "VAR LST") ||
(pilotState == "connected" && selectedStation?.bosCallsignShort) ||
session.data?.user?.publicId;
@@ -104,7 +107,7 @@ export const Audio = () => {
data-tip="Nachricht entfernen"
>
<button
className={cn("btn btn-sm btn-ghost border-warning bg-transparent ")}
className={cn("btn btn-sm btn-ghost border-warning bg-transparent")}
onClick={() => {
removeMessage();
}}
@@ -123,9 +126,9 @@ export const Audio = () => {
>
<button
className={cn(
"btn btn-sm btn-soft bg-transparent border",
"btn btn-sm btn-soft border bg-transparent",
canStopOtherSpeakers && speakingParticipants.length > 0 && "hover:bg-error",
speakingParticipants.length > 0 && " hover:bg-errorborder",
speakingParticipants.length > 0 && "hover:bg-errorborder",
isReceivingBlick && "border-warning",
)}
onClick={() => {
@@ -133,6 +136,7 @@ export const Audio = () => {
const payload = JSON.stringify({
by: role,
});
resetSpeakingParticipants("dich");
speakingParticipants.forEach(async (p) => {
await room?.localParticipant.performRpc({
destinationIdentity: p.identity,
@@ -159,54 +163,54 @@ export const Audio = () => {
transmitBlocked && "bg-yellow-500 hover:bg-yellow-500",
state === "disconnected" && "bg-red-500 hover:bg-red-500",
state === "error" && "bg-red-500 hover:bg-red-500",
state === "connecting" && "bg-yellow-500 hover:bg-yellow-500 cursor-default",
state === "connecting" && "cursor-default bg-yellow-500 hover:bg-yellow-500",
)}
>
{state === "connected" && <Mic className="w-5 h-5" />}
{state === "disconnected" && <WifiOff className="w-5 h-5" />}
{state === "connecting" && <PlugZap className="w-5 h-5" />}
{state === "error" && <ServerCrash className="w-5 h-5" />}
{state === "connected" && <Mic className="h-5 w-5" />}
{state === "disconnected" && <WifiOff className="h-5 w-5" />}
{state === "connecting" && <PlugZap className="h-5 w-5" />}
{state === "error" && <ServerCrash className="h-5 w-5" />}
</button>
{state === "connected" && (
<details className="dropdown relative z-[1050]">
<summary className="dropdown btn btn-ghost flex items-center gap-1">
{connectionQuality === ConnectionQuality.Excellent && <Signal className="w-5 h-5" />}
{connectionQuality === ConnectionQuality.Good && <SignalMedium className="w-5 h-5" />}
{connectionQuality === ConnectionQuality.Poor && <SignalLow className="w-5 h-5" />}
{connectionQuality === ConnectionQuality.Lost && <ZapOff className="w-5 h-5" />}
{connectionQuality === ConnectionQuality.Excellent && <Signal className="h-5 w-5" />}
{connectionQuality === ConnectionQuality.Good && <SignalMedium className="h-5 w-5" />}
{connectionQuality === ConnectionQuality.Poor && <SignalLow className="h-5 w-5" />}
{connectionQuality === ConnectionQuality.Lost && <ZapOff className="h-5 w-5" />}
{connectionQuality === ConnectionQuality.Unknown && (
<ShieldQuestion className="w-5 h-5" />
<ShieldQuestion className="h-5 w-5" />
)}
<div className="badge badge-sm badge-soft badge-success">{remoteParticipants}</div>
</summary>
<ul className="menu dropdown-content bg-base-200 rounded-box z-[1050] w-52 p-2 shadow-sm">
{ROOMS.map((r) => (
<li key={r}>
<li key={r.id}>
<button
className="btn btn-sm btn-ghost text-left flex items-center justify-start gap-2 relative"
className="btn btn-sm btn-ghost relative flex items-center justify-start gap-2 text-left"
onClick={() => {
if (!role) return;
if (selectedRoom === r) return;
if (selectedRoom?.name === r.name) return;
setSelectedRoom(r);
connect(r, role);
}}
>
{room?.name === r && (
<Disc className="text-success text-sm absolute left-2" width={15} />
{room?.name === r.name && (
<Disc className="text-success absolute left-2 text-sm" width={15} />
)}
<span className="flex-1 text-center">{r}</span>
<span className="flex-1 text-center">{r.name}</span>
</button>
</li>
))}
<li>
<button
className="btn btn-sm btn-ghost text-left flex items-center justify-start gap-2 relative"
className="btn btn-sm btn-ghost relative flex items-center justify-start gap-2 text-left"
onClick={() => {
disconnect();
}}
>
<WifiOff className="text-error text-sm absolute left-2" width={15} />
<WifiOff className="text-error absolute left-2 text-sm" width={15} />
<span className="flex-1 text-center">Disconnect</span>
</button>
</li>

View File

@@ -27,7 +27,7 @@ export const useSounds = ({
useEffect(() => {
if (!window) return;
connectionStart.current = new Audio("/sounds/connection_started_sepura.mp3");
connectionEnd.current = new Audio("/sounds/connection_stoped_sepura.mp3");
connectionEnd.current = new Audio("/sounds/connection_stopped_sepura.mp3");
ownCallStarted.current = new Audio("/sounds/call_end_sepura.wav");
foreignCallStop.current = new Audio("/sounds/call_end_sepura.wav");
foreignCallBlocked.current = new Audio("/sounds/call_blocked_sepura.wav");

View File

@@ -3,7 +3,7 @@
import { toast } from "react-hot-toast";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactNode, useEffect, useState } from "react";
import { ReactNode, useEffect, useRef, useState } from "react";
import { dispatchSocket } from "(app)/dispatch/socket";
import { NotificationPayload } from "@repo/db";
import { HPGnotificationToast } from "_components/customToasts/HPGnotification";
@@ -15,6 +15,7 @@ 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(
() =>
@@ -22,7 +23,7 @@ export function QueryProvider({ children }: { children: ReactNode }) {
defaultOptions: {
mutations: {
onError: (error) => {
toast.error("An error occurred: " + (error as Error).message, {
toast.error("Ein Fehler ist aufgetreten: " + (error as Error).message, {
position: "top-right",
});
},
@@ -30,6 +31,9 @@ export function QueryProvider({ children }: { children: ReactNode }) {
},
}),
);
useEffect(() => {
notificationSound.current = new Audio("/sounds/notification.mp3");
}, []);
useEffect(() => {
const invalidateMission = () => {
queryClient.invalidateQueries({
@@ -59,8 +63,19 @@ 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} />,
{
@@ -70,23 +85,30 @@ 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

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

View File

@@ -10,9 +10,11 @@ import { getConnectedDispatcherAPI } from "_querys/dispatcher";
import { getConnectedAircraftsAPI } from "_querys/aircrafts";
import { useDispatchConnectionStore } from "_store/dispatch/connectionStore";
import { usePilotConnectionStore } from "_store/pilot/connectionStore";
import { Trash } from "lucide-react";
export const Chat = () => {
const {
removeChat,
setReportTabOpen,
chatOpen,
setChatOpen,
@@ -50,13 +52,21 @@ export const Chat = () => {
setOwnId(session.data?.user.id);
}, [session.data?.user.id, setOwnId]);
const filteredDispatcher = dispatcher?.filter((d) => d.userId !== session.data?.user.id);
const filteredDispatcher = dispatcher?.filter(
(d) => d.userId !== session.data?.user.id && !chats[d.userId],
);
const filteredAircrafts = aircrafts?.filter(
(a) => a.userId !== session.data?.user.id && dispatcherConnected,
(a) => a.userId !== session.data?.user.id && !chats[a.userId],
);
const btnActive = pilotConnected || dispatcherConnected;
useEffect(() => {
if (!filteredDispatcher?.length && !filteredAircrafts?.length) {
setAddTabValue("default");
}
}, [filteredDispatcher, filteredAircrafts]);
useEffect(() => {
if (!btnActive) {
setChatOpen(false);
@@ -146,13 +156,17 @@ export const Chat = () => {
<button
className="btn btn-sm btn-soft btn-primary join-item"
onClick={() => {
if (addTabValue === "default") return;
const aircraftUser = aircrafts?.find((a) => a.userId === addTabValue);
const dispatcherUser = dispatcher?.find((d) => d.userId === addTabValue);
const user = aircraftUser || dispatcherUser;
console.log("Adding chat for user:", addTabValue, user);
if (!user) return;
const role = "Station" in user ? user.Station.bosCallsignShort : user.zone;
console.log("Adding chat for user:", addTabValue);
addChat(addTabValue, `${asPublicUser(user.publicUser).fullName} (${role})`);
setSelectedChat(addTabValue);
setAddTabValue("default");
}}
>
<span className="text-xl">+</span>
@@ -164,7 +178,7 @@ export const Chat = () => {
if (!chat) return null;
return (
<Fragment key={userId}>
<a
<div
className={cn("indicator tab", selectedChat === userId && "tab-active")}
onClick={() => {
if (selectedChat === userId) {
@@ -176,7 +190,7 @@ export const Chat = () => {
>
{chat.name}
{chat.notification && <span className="indicator-item status status-info" />}
</a>
</div>
<div className="tab-content bg-base-100 border-base-300 max-h-[250px] overflow-y-auto p-6">
{/* So macht man kein overflow handeling, weiß ich. Aber es funktioniert... */}
{chat.messages.map((chatMessage) => {
@@ -208,6 +222,16 @@ export const Chat = () => {
)}
{selectedChat && (
<div className="join">
<button
className="join-item btn btn-error btn-outline"
onClick={(e) => {
e.stopPropagation();
removeChat(selectedChat);
}}
type="button"
>
<Trash size={16} />
</button>
<div className="w-full">
<label className="input join-item w-full">
<input

View File

@@ -17,7 +17,6 @@ import { getConnectedAircraftPositionLogAPI, getConnectedAircraftsAPI } from "_q
import { getMissionsAPI } from "_querys/missions";
import { FMS_STATUS_COLORS, FMS_STATUS_TEXT_COLORS } from "_helpers/fmsStatusColors";
import { usePilotConnectionStore } from "_store/pilot/connectionStore";
import { useSession } from "next-auth/react";
const AircraftPopupContent = ({
aircraft,
@@ -73,7 +72,7 @@ const AircraftPopupContent = ({
}
}, [currentTab, aircraft, mission]);
const { setOpenAircraftMarker, setMap, openAircraftMarker } = useMapStore((state) => state);
const { setOpenAircraftMarker, setMap } = useMapStore((state) => state);
const { anchor } = useSmartPopup();
return (
<>
@@ -398,9 +397,9 @@ const AircraftMarker = ({ aircraft }: { aircraft: ConnectedAircraft & { Station:
export const AircraftLayer = () => {
const { data: aircrafts } = useQuery({
queryKey: ["aircrafts"],
queryKey: ["connected-aircrafts", "map"],
queryFn: () => getConnectedAircraftsAPI(),
refetchInterval: 10_000,
refetchInterval: 15000,
});
const { setMap } = useMapStore((state) => state);
const map = useMap();
@@ -435,6 +434,11 @@ export const AircraftLayer = () => {
}
}, [pilotConnectionStatus, followOwnAircraft, ownAircraft, setMap, map]);
console.debug("Hubschrauber auf Karte:", {
total: aircrafts?.length,
displayed: filteredAircrafts.length,
});
return (
<>
{filteredAircrafts?.map((aircraft) => {

View File

@@ -122,7 +122,7 @@ const HeliportsLayer = () => {
};
const filterVisibleHeliports = () => {
const bounds = map.getBounds();
const bounds = map?.getBounds();
if (!heliports?.length) return;
// Filtere die Heliports, die innerhalb der Kartenansicht liegen
const visibleHeliports = heliports.filter((heliport) => {

View File

@@ -3,15 +3,22 @@ import { OSMWay } from "@repo/db";
import { useDispatchConnectionStore } from "_store/dispatch/connectionStore";
import { useMapStore } from "_store/mapStore";
import { usePannelStore } from "_store/pannelStore";
import { MapPin, MapPinned, Radius, Ruler, Search, RulerDimensionLine, Scan } from "lucide-react";
import { MapPin, MapPinned, Search, Car, Ambulance, Siren, Flame } 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,
@@ -26,15 +33,16 @@ export const ContextMenu = () => {
setOpen,
isOpen: isPannelOpen,
} = usePannelStore((state) => state);
const [showRulerOptions, setShowRulerOptions] = useState(false);
const [showObjectOptions, setShowObjectOptions] = useState(false);
const [rulerHover, setRulerHover] = useState(false);
const [rulerOptionsHover, setRulerOptionsHover] = useState(false);
const dispatcherConnected = useDispatchConnectionStore((s) => s.status) === "connected";
useEffect(() => {
setShowRulerOptions(rulerHover || rulerOptionsHover);
}, [rulerHover, rulerOptionsHover]);
const showObjectOptions = rulerHover || rulerOptionsHover;
setShowObjectOptions(showObjectOptions);
}, [isPannelOpen, rulerHover, rulerOptionsHover, setOpen]);
useEffect(() => {
const handleContextMenu = (e: any) => {
@@ -150,9 +158,12 @@ export const ContextMenu = () => {
style={{ transform: "translateY(-50%)" }}
onMouseEnter={() => setRulerHover(true)}
onMouseLeave={() => setRulerHover(false)}
disabled
disabled={
!isPannelOpen ||
!xPlaneObjectsAvailable(missionFormValues?.missionStationIds, aircrafts)
}
>
<Ruler size={20} />
<Car size={20} />
</button>
{/* Bottom Button */}
<button
@@ -178,64 +189,75 @@ export const ContextMenu = () => {
>
<Search size={20} />
</button>
{/* Ruler Options - shown when Ruler button is hovered or options are hovered */}
{showRulerOptions && (
{/* XPlane Object Options - shown when Ruler button is hovered or options are hovered */}
{showObjectOptions && (
<div
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",
}}
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"
onMouseEnter={() => setRulerOptionsHover(true)}
onMouseLeave={() => setRulerOptionsHover(false)}
>
<div
style={{
display: "flex",
flexDirection: "column",
width: "100%",
}}
>
<div className="flex w-full flex-col">
<button
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%)",
}}
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"
onClick={() => {
/* ... */
setMissionFormValues({
...missionFormValues,
xPlaneObjects: [
...(missionFormValues?.xPlaneObjects ?? []),
{
objectName: "ambulance",
alt: 0,
lat: contextMenu.lat,
lon: contextMenu.lng,
},
],
});
}}
>
<RulerDimensionLine size={20} />
<Ambulance 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="Radius Messen"
data-tip="LF platzieren"
onClick={() => {
/* ... */
console.log("Add fire engine");
setMissionFormValues({
...missionFormValues,
xPlaneObjects: [
...(missionFormValues?.xPlaneObjects ?? []),
{
objectName: "fire_engine",
alt: 0,
lat: contextMenu.lat,
lon: contextMenu.lng,
},
],
});
}}
>
<Radius size={20} />
<Flame size={20} />
</button>
<button
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%)",
}}
className="btn btn-circle bg-rescuetrack tooltip tooltip-left tooltip-accent ml-[30px] h-10 w-10 opacity-80"
data-tip="Streifenwagen platzieren"
onClick={() => {
/* ... */
console.log("Add police");
setMissionFormValues({
...missionFormValues,
xPlaneObjects: [
...(missionFormValues?.xPlaneObjects ?? []),
{
objectName: "police",
alt: 0,
lat: contextMenu.lat,
lon: contextMenu.lng,
},
],
});
}}
>
<Scan size={20} />
<Siren size={20} />
</button>
</div>
</div>

View File

@@ -1,6 +1,6 @@
"use client";
import { usePannelStore } from "_store/pannelStore";
import { Marker } from "react-leaflet";
import { Marker, useMap } from "react-leaflet";
import L from "leaflet";
import { useQuery } from "@tanstack/react-query";
import { getMissionsAPI } from "_querys/missions";
@@ -8,10 +8,14 @@ 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 } = usePannelStore((state) => state);
const { isOpen, missionFormValues, setMissionFormValues } = usePannelStore((state) => state);
const dispatcherConnectionState = useDispatchConnectionStore((state) => state.status);
const { openMissionMarker } = useMapStore((state) => state);
const { data: missions = [] } = useQuery({
queryKey: ["missions"],
queryFn: () =>
@@ -20,13 +24,28 @@ export const MapAdditionals = () => {
}),
refetchInterval: 10_000,
});
const mapStore = useMapStore((state) => state);
const { setOpenMissionMarker } = useMapStore((state) => state);
const [showDetailedAdditionals, setShowDetailedAdditionals] = useState(false);
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) =>
@@ -35,7 +54,8 @@ export const MapAdditionals = () => {
m.state === "draft" &&
m.hpgLocationLat &&
dispatcherConnectionState === "connected" &&
m.hpgLocationLng,
m.hpgLocationLng &&
openMissionMarker.find((openMission) => openMission.id === m.id),
);
return (
@@ -48,21 +68,90 @@ export const MapAdditionals = () => {
iconSize: [40, 40],
iconAnchor: [20, 35],
})}
interactive={false}
draggable={true}
eventHandlers={{
dragend: (e) => {
const marker = e.target;
const position = marker.getLatLng();
setMissionFormValues({
...missionFormValues,
addressLat: position.lat,
addressLng: position.lng,
});
},
}}
/>
)}
{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}
position={[mission.hpgLocationLat!, mission.hpgLocationLng!]}
icon={L.icon({
iconUrl: "/icons/mapMarker.png",
iconUrl: "/icons/mapMarkerAttention.png",
iconSize: [40, 40],
iconAnchor: [20, 35],
})}
eventHandlers={{
click: () =>
mapStore.setOpenMissionMarker({
setOpenMissionMarker({
open: [
{
id: mission.id,

View File

@@ -23,7 +23,7 @@ export const MISSION_STATUS_COLORS: Record<MissionState | "attention", string> =
draft: "#0092b8",
running: "#155dfc",
finished: "#155dfc",
attention: "rgb(186,105,0)",
attention: "#ba6900",
};
export const MISSION_STATUS_TEXT_COLORS: Record<MissionState, string> = {
@@ -253,7 +253,7 @@ const MissionMarker = ({
tab: "home",
},
],
close: openMissionMarker?.map((m) => m.id) || [],
close: [],
});
}
};

View File

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

View File

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

View File

@@ -158,7 +158,7 @@ const PopupContent = ({
</span>
<span>
{aircraft.Station.bosCallsign.length > 15
? aircraft.Station.locationStateShort
? aircraft.Station.bosCallsignShort
: aircraft.Station.bosCallsign}
</span>
</div>

View File

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

View File

@@ -7,6 +7,7 @@ import { getConnectedDispatcherAPI, kickDispatcherAPI } from "_querys/dispatcher
import { getLivekitRooms, kickLivekitParticipant } from "_querys/livekit";
import { ParticipantInfo } from "livekit-server-sdk";
import {
Dot,
LockKeyhole,
Plane,
RedoDot,
@@ -35,7 +36,7 @@ export default function AdminPanel() {
const { data: livekitRooms } = useQuery({
queryKey: ["livekit-rooms"],
queryFn: () => getLivekitRooms(),
refetchInterval: 10000,
refetchInterval: 5000,
});
const kickLivekitParticipantMutation = useMutation({
mutationFn: kickLivekitParticipant,
@@ -92,11 +93,6 @@ export default function AdminPanel() {
const modalRef = useRef<HTMLDialogElement>(null);
console.debug("piloten von API", {
anzahl: pilots?.length,
pilots,
});
return (
<div>
<button
@@ -149,7 +145,12 @@ export default function AdminPanel() {
{!livekitParticipant ? (
<span className="text-error">Nicht verbunden</span>
) : (
<span className="text-success">{livekitParticipant.room}</span>
<span className="text-success inline-flex items-center">
{livekitParticipant.room}{" "}
{livekitParticipant?.participant.tracks.some((t) => !t.muted) && (
<Dot className="text-warning ml-2" />
)}
</span>
)}
</td>
<td className="flex gap-2">
@@ -214,7 +215,12 @@ export default function AdminPanel() {
{!livekitParticipant ? (
<span className="text-error">Nicht verbunden</span>
) : (
<span className="text-success">{livekitParticipant.room}</span>
<span className="text-success inline-flex items-center">
{livekitParticipant.room}{" "}
{livekitParticipant?.participant.tracks.some((t) => !t.muted) && (
<Dot className="text-warning ml-2" />
)}
</span>
)}
</td>
<td className="flex gap-2">
@@ -279,8 +285,13 @@ export default function AdminPanel() {
<td>
<span className="text-error">Nicht verbunden</span>
</td>
<td>
<span className="text-success">{p.room}</span>
<td className="flex">
<span className="text-success inline-flex items-center">
{p.room}
{p.participant.tracks.some((t) => !t.muted) && (
<Dot className="text-warning ml-2" />
)}
</span>
</td>
<td className="flex gap-2">
<button

View File

@@ -1,18 +1,24 @@
"use client";
import { cn } from "@repo/shared-components";
import { ArrowLeftRight, Plane, Radar, Workflow } from "lucide-react";
import { ArrowLeftRight, ExternalLinkIcon, 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 }: { className?: string }) {
export default function ModeSwitchDropdown({
className,
btnClassName,
}: {
className?: string;
btnClassName?: string;
}) {
const path = usePathname();
const session = useSession();
return (
<div className={cn("dropdown z-999999", className)}>
<div tabIndex={0} role="button" className="btn m-1">
<div tabIndex={0} role="button" className={cn("btn", btnClassName)}>
<ArrowLeftRight size={22} /> {path.includes("pilot") && "Pilot"}
{path.includes("dispatch") && "Leitstelle"}
</div>
@@ -39,6 +45,15 @@ export default function ModeSwitchDropdown({ className }: { className?: string }
<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,3 +24,30 @@ export const fmsStatusDescription: { [key: string]: string } = {
o: "Warten, alle Abfrageplätze belegt",
u: "Verstanden",
};
export const fmsStatusDescriptionShort: { [key: string]: string } = {
NaN: "Keine D.",
"0": "Prio. Sprechen",
"1": "E.-bereit Funk",
"2": "E.-bereit Wache",
"3": "E.-übernahme",
"4": "Einsatzort",
"5": "Sprechwunsch",
"6": "Nicht e.-bereit",
"7": "Einsatzgeb.",
"8": "Bed. Verfügbar",
"9": "F-anmeldung",
E: "Einsatzabbruch",
C: "Melden Sie Einsatzübernahme",
F: "Kommen Sie über Draht",
H: "Fahren Sie Wache an",
J: "Sprechen Sie",
L: "Geben Sie Lagemeldung",
P: "Einsatz mit Polizei",
U: "Ungültige Statusfolge",
c: "Status korrigieren",
d: "Nennen Sie Transportziel",
h: "Zielklinik verständigt",
o: "Warten, alle Abfrageplätze belegt",
u: "Verstanden",
};

View File

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

View File

@@ -1,5 +1,5 @@
// Helper function for distortion curve generation
function createDistortionCurve(amount: number): Float32Array {
function createDistortionCurve(amount: number): Float32Array<ArrayBuffer> {
const k = typeof amount === "number" ? amount : 50;
const nSamples = 44100;
const curve = new Float32Array(nSamples);

View File

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

@@ -0,0 +1,36 @@
import { Booking, Prisma, PublicUser, Station } from "@repo/db";
import axios from "axios";
export const getBookingsAPI = async (filter: Prisma.BookingWhereInput) => {
const res = await axios.get<
(Booking & {
Station: Station;
User: PublicUser;
})[]
>("/api/bookings", {
params: {
filter: JSON.stringify(filter),
},
});
if (res.status !== 200) {
throw new Error("Failed to fetch stations");
}
return res.data;
};
export const createBookingAPI = async (booking: Omit<Prisma.BookingCreateInput, "User">) => {
const response = await axios.post("/api/bookings", booking);
if (response.status !== 201) {
console.error("Error creating booking:", response);
throw new Error("Failed to create booking");
}
return response.data;
};
export const deleteBookingAPI = async (bookingId: string) => {
const response = await axios.delete(`/api/bookings/${bookingId}`);
if (!response.status.toString().startsWith("2")) {
throw new Error("Failed to delete booking");
}
return bookingId;
};

View File

@@ -14,11 +14,14 @@ export const changeDispatcherAPI = async (
};
export const getConnectedDispatcherAPI = async (filter?: Prisma.ConnectedDispatcherWhereInput) => {
const res = await axios.get<ConnectedDispatcher[]>("/api/dispatcher", {
const res = await axios.get<(ConnectedDispatcher & { settingsUseHPGAsDispatcher: boolean })[]>(
"/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, Prisma } from "@repo/db";
import { Mission, MissionSdsLog, MissionSdsStatusLog, Prisma } from "@repo/db";
import axios from "axios";
import { serverApi } from "_helpers/axios";
@@ -29,6 +29,20 @@ export const editMissionAPI = async (id: number, mission: Prisma.MissionUpdateIn
const respone = await serverApi.patch<Mission>(`/mission/${id}`, mission);
return respone.data;
};
export const sendSdsStatusMessageAPI = async ({
sdsMessage,
aircraftId,
}: {
aircraftId: number;
sdsMessage: MissionSdsStatusLog;
}) => {
const respone = await serverApi.post<Mission>(`/aircrafts/${aircraftId}/send-sds-message`, {
sdsMessage,
});
return respone.data;
};
export const sendSdsMessageAPI = async ({
missionId,
sdsMessage,
@@ -55,9 +69,11 @@ export const sendMissionAPI = async (
{
stationId,
vehicleName,
desktopOnly,
}: {
stationId?: number;
vehicleName?: "RTW" | "POL" | "FW";
desktopOnly?: boolean;
},
) => {
const respone = await serverApi.post<{
@@ -65,6 +81,7 @@ export const sendMissionAPI = async (
}>(`/mission/${id}/send-alert`, {
stationId,
vehicleName,
desktopOnly,
});
return respone.data;
};

View File

@@ -21,32 +21,36 @@ 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;
connectionQuality: ConnectionQuality;
disconnect: () => void;
isTalking: boolean;
localRadioTrack: LocalTrackPublication | undefined;
message: string | null;
removeMessage: () => void;
removeSpeakingParticipant: (speakingParticipants: Participant) => void;
remoteParticipants: number;
resetSpeakingParticipants: (source: string) => void;
room: Room | null;
setSettings: (settings: Partial<TalkState["settings"]>) => void;
settings: {
micDeviceId: string | null;
micVolume: number;
radioVolume: number;
dmeVolume: number;
};
isTalking: boolean;
transmitBlocked: boolean;
removeMessage: () => void;
state: "connecting" | "connected" | "disconnected" | "error";
message: string | null;
connectionQuality: ConnectionQuality;
remoteParticipants: number;
toggleTalking: () => void;
setSettings: (settings: Partial<TalkState["settings"]>) => void;
connect: (roomName: string, role: string) => void;
disconnect: () => void;
selectedRoom?: (typeof ROOMS)[number];
setSelectedRoom: (room: (typeof ROOMS)[number]) => void;
speakingParticipants: Participant[];
addSpeakingParticipant: (participant: Participant) => void;
removeSpeakingParticipant: (speakingParticipants: Participant) => void;
room: Room | null;
localRadioTrack: LocalTrackPublication | undefined;
state: "connecting" | "connected" | "disconnected" | "error";
toggleTalking: () => void;
transmitBlocked: boolean;
};
const getToken = async (roomName: string) => {
const response = await axios.get(`/api/livekit-token?roomName=${roomName}`);
@@ -71,6 +75,19 @@ 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: [],
isTalking: false,
transmitBlocked: false,
message: `Ruf beendet durch ${source || "eine unsichtbare Macht"}`,
});
get().room?.localParticipant.setMicrophoneEnabled(false);
},
addSpeakingParticipant: (participant) => {
set((state) => {
if (!state.speakingParticipants.some((p) => p.identity === participant.identity)) {
@@ -107,11 +124,11 @@ export const useAudioStore = create<TalkState>((set, get) => ({
(oldSettings.micDeviceId !== newSettings.micDeviceId ||
oldSettings.micVolume !== newSettings.micVolume)
) {
const { room, disconnect, connect } = get();
const { room, disconnect, connect, selectedRoom } = get();
const role = room?.localParticipant.attributes.role;
if (room?.name || role) {
if (selectedRoom || role) {
disconnect();
connect(room?.name || "", role || "user");
connect(selectedRoom, role || "user");
}
}
},
@@ -150,7 +167,7 @@ export const useAudioStore = create<TalkState>((set, get) => ({
set((state) => ({ isTalking: !state.isTalking, transmitBlocked: false }));
},
connect: async (roomName, role) => {
connect: async (_room, role) => {
set({ state: "connecting" });
try {
@@ -162,13 +179,16 @@ export const useAudioStore = create<TalkState>((set, get) => ({
connectedRoom.removeAllListeners();
}
const { selectedRoom } = get();
const url = process.env.NEXT_PUBLIC_LIVEKIT_URL;
if (!url) return console.error("NEXT_PUBLIC_LIVEKIT_URL not set");
const token = await getToken(roomName);
const token = await getToken(_room?.name || selectedRoom?.name || "VAR_LST_RD_01");
if (!token) throw new Error("Fehlende Berechtigung");
const room = new Room({});
await room.prepareConnection(url, token);
const roomConnectedSound = new Audio("/sounds/403.wav");
room
// Connection events
.on(RoomEvent.Connected, async () => {
@@ -176,7 +196,8 @@ export const useAudioStore = create<TalkState>((set, get) => ({
if (dispatchState.status === "connected" && dispatchState.connectedDispatcher?.id) {
changeDispatcherAPI(dispatchState.connectedDispatcher?.id, {
zone: roomName,
zone: _room?.name || selectedRoom?.name || "VAR_LST_RD_01",
ghostMode: dispatchState.ghostMode,
});
}
@@ -197,13 +218,18 @@ export const useAudioStore = create<TalkState>((set, get) => ({
source: Track.Source.Microphone,
});
await publishedTrack.mute();
roomConnectedSound.play();
set({ localRadioTrack: publishedTrack });
set({ state: "connected", room, message: null });
set({ state: "connected", room, isTalking: false, message: null });
})
.on(RoomEvent.Disconnected, () => {
set({ state: "disconnected", speakingParticipants: [], transmitBlocked: false });
set({
state: "disconnected",
speakingParticipants: [],
transmitBlocked: false,
isTalking: false,
});
handleDisconnect();
})
@@ -222,18 +248,29 @@ export const useAudioStore = create<TalkState>((set, get) => ({
room.registerRpcMethod("force-mute", async (data: RpcInvocationData) => {
const { by } = JSON.parse(data.payload);
room.localParticipant.setMicrophoneEnabled(false);
useAudioStore.setState({
isTalking: false,
message: `Ruf beendet durch ${by || "eine unsichtbare Macht"}`,
});
return `Hello, ${data.callerIdentity}!`;
get().resetSpeakingParticipants(by);
return "OK";
});
interval = setInterval(() => {
// Filter forgotten participants
const oldSpeakingParticipants = get().speakingParticipants;
const speakingParticipants = oldSpeakingParticipants.filter((oP) => {
return Array.from(room.remoteParticipants.values()).find(
(p) => p.identity === oP.identity,
);
});
if (oldSpeakingParticipants.length !== speakingParticipants.length) {
set({
remoteParticipants: room.numParticipants === 0 ? 0 : room.numParticipants - 1, // Unreliable and delayed
speakingParticipants,
});
} else {
set({
remoteParticipants: room.numParticipants === 0 ? 0 : room.numParticipants - 1, // Unreliable and delayed
});
}
}, 500);
} catch (error: Error | unknown) {
console.error("Error occured: ", error);

View File

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

View File

@@ -2,6 +2,8 @@ import { create } from "zustand";
import { ChatMessage } from "@repo/db";
import { dispatchSocket } from "(app)/dispatch/socket";
import { pilotSocket } from "(app)/pilot/socket";
import { useDispatchConnectionStore } from "_store/dispatch/connectionStore";
import { usePilotConnectionStore } from "_store/pilot/connectionStore";
interface ChatStore {
situationTabOpen: boolean;
@@ -16,9 +18,15 @@ interface ChatStore {
setOwnId: (id: string) => void;
chats: Record<string, { name: string; notification: boolean; messages: ChatMessage[] }>;
setChatNotification: (userId: string, notification: boolean) => void;
sendMessage: (userId: string, message: string) => Promise<void>;
sendMessage: (
userId: string,
message: string,
senderName?: string,
receiverName?: string,
) => Promise<void>;
addChat: (userId: string, name: string) => void;
addMessage: (userId: string, message: ChatMessage) => void;
removeChat: (userId: string) => void;
}
export const useLeftMenuStore = create<ChatStore>((set, get) => ({
@@ -37,14 +45,24 @@ export const useLeftMenuStore = create<ChatStore>((set, get) => ({
setChatNotification(chatId, false); // Set notification to false when chat is selected
}
},
removeChat: (userId: string) => {
const { chats, setSelectedChat, selectedChat } = get();
const newChats = { ...chats };
delete newChats[userId];
set({ chats: newChats });
if (selectedChat === userId) {
setSelectedChat(null);
}
},
setOwnId: (id: string) => set({ ownId: id }),
chats: {},
sendMessage: (userId: string, message: string) => {
sendMessage: (userId, message) => {
return new Promise((resolve, reject) => {
if (dispatchSocket.connected) {
const zone = useDispatchConnectionStore.getState().selectedZone;
dispatchSocket.emit(
"send-message",
{ userId, message },
{ userId, message, role: zone },
({ error }: { error?: string }) => {
if (error) {
reject(error);
@@ -54,13 +72,19 @@ export const useLeftMenuStore = create<ChatStore>((set, get) => ({
},
);
} else if (pilotSocket.connected) {
pilotSocket.emit("send-message", { userId, message }, ({ error }: { error?: string }) => {
const bosCallsign = usePilotConnectionStore.getState().selectedStation?.bosCallsignShort;
pilotSocket.emit(
"send-message",
{ userId, message, role: bosCallsign },
({ error }: { error?: string }) => {
if (error) {
reject(error);
} else {
resolve();
}
});
},
);
}
});
},

View File

@@ -1,173 +1,92 @@
import { MissionSdsLog, Station } from "@repo/db";
import { fmsStatusDescription } from "_data/fmsStatusDescription";
import { DisplayLineProps } from "(app)/pilot/_components/mrt/Mrt";
import { create } from "zustand";
import { syncTabs } from "zustand-sync-tabs";
interface SetSdsPageParams {
page: "sds";
station: Station;
sdsMessage: MissionSdsLog;
interface SetOffPageParams {
page: "off";
}
interface SetStartupPageParams {
page: "startup";
}
interface SetHomePageParams {
page: "home";
station: Station;
fmsStatus: string;
}
interface SetSendingStatusPageParams {
page: "sending-status";
station: Station;
interface SetVoicecallPageParams {
page: "voice-call";
}
interface SetSdsReceivedPopupParams {
popup: "sds-received";
}
interface SetNewStatusPageParams {
page: "new-status";
station: Station;
interface SetGroupSelectionPopupParams {
popup: "group-selection";
}
type SetPageParams =
interface SetStatusSentPopupParams {
popup: "status-sent";
}
interface SetLoginPopupParams {
popup: "login";
}
interface SetSdsSentPopupParams {
popup: "sds-sent";
}
export type SetPageParams =
| SetHomePageParams
| SetSendingStatusPageParams
| SetSdsPageParams
| SetNewStatusPageParams;
| SetOffPageParams
| SetStartupPageParams
| SetVoicecallPageParams;
export type SetPopupParams =
| SetStatusSentPopupParams
| SetSdsSentPopupParams
| SetGroupSelectionPopupParams
| SetSdsReceivedPopupParams
| SetLoginPopupParams;
interface StringifiedData {
sdsText?: string;
sentSdsText?: string;
groupSelectionGroupId?: string;
callTextHeader?: string;
}
interface MrtStore {
page: SetPageParams["page"];
popup?: SetPopupParams["popup"];
lines: DisplayLineProps[];
stringifiedData: StringifiedData;
setStringifiedData: (data: Partial<StringifiedData>) => void;
setPage: (pageData: SetPageParams) => void;
setLines: (lines: MrtStore["lines"]) => void;
setPopup: (popupData: SetPopupParams | null) => void;
// internal
updateIntervall?: number;
nightMode: boolean;
setNightMode: (nightMode: boolean) => void;
}
export const useMrtStore = create<MrtStore>(
syncTabs(
(set) => ({
page: "home",
pageData: {
message: "",
export const useMrtStore = create<MrtStore>((set) => ({
page: "off",
nightMode: false,
stringifiedData: {
groupSelectionGroupId: "2201",
},
lines: [
{
textLeft: "VAR.#",
textSize: "2",
setNightMode: (nightMode) => set({ nightMode }),
setStringifiedData: (data) =>
set((state) => ({
stringifiedData: { ...state.stringifiedData, ...data },
})),
setPopup: (popupData) => {
set({ popup: popupData ? popupData.popup : undefined });
},
{
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",
set({ page: pageData.page });
},
{ 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,6 +1,7 @@
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";
@@ -85,7 +86,7 @@ pilotSocket.on("connect", () => {
usePilotConnectionStore.setState({ status: "connected", message: "" });
const { logoffTime, selectedStation, debug } = usePilotConnectionStore.getState();
dispatchSocket.disconnect();
useAudioStore.getState().connect("LST_01", selectedStation?.bosCallsignShort || "pilot");
useAudioStore.getState().connect(undefined, selectedStation?.bosCallsignShort || "pilot");
pilotSocket.emit("connect-pilot", {
logoffTime,
@@ -108,7 +109,7 @@ pilotSocket.on("connect-message", (data) => {
});
pilotSocket.on("disconnect", () => {
usePilotConnectionStore.setState({ status: "disconnected" });
usePilotConnectionStore.setState({ status: "disconnected", connectedAircraft: null });
useAudioStore.getState().disconnect();
});
@@ -132,14 +133,22 @@ 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().setPage({
page: "sds",
station,
sdsMessage,
useMrtStore.getState().setPopup({
popup: "sds-received",
});
useMrtStore.getState().setStringifiedData({
sdsText: sdsMessage.data.message,
});
});

View File

@@ -1,7 +1,6 @@
import { Mission, Station, User } from "@repo/db";
import { DisplayLineProps } from "(app)/pilot/_components/dme/Dme";
import { create } from "zustand";
import { syncTabs } from "zustand-sync-tabs";
interface SetHomePageParams {
page: "home";
@@ -36,7 +35,7 @@ type SetPageParams =
interface MrtStore {
page: SetPageParams["page"];
latestMission: Mission | null;
lines: DisplayLineProps[];
setPage: (pageData: SetPageParams) => void;
@@ -45,9 +44,7 @@ interface MrtStore {
let interval: NodeJS.Timeout | null = null;
export const useDmeStore = create<MrtStore>(
syncTabs(
(set) => ({
export const useDmeStore = create<MrtStore>((set) => ({
page: "home",
pageData: {
message: "",
@@ -65,6 +62,7 @@ export const useDmeStore = create<MrtStore>(
},
],
setLines: (lines) => set({ lines }),
latestMission: null,
setPage: (pageData) => {
if (interval) clearInterval(interval);
switch (pageData.page) {
@@ -122,6 +120,7 @@ export const useDmeStore = create<MrtStore>(
}
case "mission": {
set({
latestMission: pageData.mission,
page: "mission",
lines: [
{
@@ -231,9 +230,4 @@ export const useDmeStore = create<MrtStore>(
break;
}
},
}),
{
name: "dme-store", // unique name
},
),
);
}));

View File

@@ -0,0 +1,42 @@
import { getPublicUser, prisma } from "@repo/db";
import { getServerSession } from "api/auth/[...nextauth]/auth";
import { NextRequest, NextResponse } from "next/server";
export const GET = async (req: NextRequest) => {
try {
const session = await getServerSession();
if (!session?.user) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
const { searchParams } = req.nextUrl;
const filter = JSON.parse(searchParams.get("filter") || "{}");
const bookings = await prisma.booking.findMany({
where: filter,
include: {
User: true,
Station: {
select: {
id: true,
bosCallsign: true,
bosCallsignShort: true,
},
},
},
orderBy: {
startTime: "asc",
},
});
return NextResponse.json(
bookings.map((b) => ({
...b,
User: b.User ? getPublicUser(b.User) : null,
})),
);
} catch (error) {
console.error("Error fetching bookings:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
};

View File

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

View File

@@ -21,9 +21,10 @@ 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 } = (await req.json()) as {
const { position, h145, xPlanePluginActive } = (await req.json()) as {
position: PositionLog;
h145: boolean;
xPlanePluginActive: boolean;
};
if (!position) {
return Response.json({ message: "Missing id or position" });
@@ -61,6 +62,7 @@ export const PUT = async (req: Request) => {
posHeading: position.heading,
posSpeed: position.speed,
posH145active: h145,
posXplanePluginActive: xPlanePluginActive,
},
});

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

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