145 Commits

Author SHA1 Message Date
PxlLoewe
d4c1c816ff Merge pull request #165 from VAR-Virtual-Air-Rescue/staging 2026-02-08 23:46:22 +01:00
PxlLoewe
1b425d82e2 cron zum aktuallisieren der Discord Avatare hinzugefügt 2026-02-08 22:22:25 +01:00
PxlLoewe
5d0a36f967 Report anzeige für auto-reports verbessert 2026-02-08 21:37:10 +01:00
PxlLoewe
b46ad25bde Imports entfernt 2026-02-08 20:17:44 +01:00
PxlLoewe
d5a4118025 Admin-Panel hinzugefügt 2026-02-08 20:12:45 +01:00
PxlLoewe
aded6d1492 HPG Warnung in Dispatch Settings, Status Notification 2026-02-08 13:03:11 +01:00
PxlLoewe
8340c2408c Fixed Acc deleted Warnung im Profil 2026-02-07 13:43:37 +01:00
PxlLoewe
31c17e2dda Merge pull request #161 from VAR-Virtual-Air-Rescue/staging
admin link
2026-02-01 12:52:46 +01:00
PxlLoewe
2e9bb95d12 admin link 2026-02-01 12:49:05 +01:00
PxlLoewe
c8eeee1452 Merge pull request #160 from VAR-Virtual-Air-Rescue/staging
Fixed Wrong IP being loged
2026-02-01 11:50:14 +01:00
PxlLoewe
824d2e40a9 Fixed Wrong IP being loged 2026-02-01 11:45:18 +01:00
PxlLoewe
3d1b83cd32 Merge pull request #159 from VAR-Virtual-Air-Rescue/staging
List headers
2026-02-01 10:26:35 +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
40b11d2501 Merge pull request #158 from VAR-Virtual-Air-Rescue/staging
Catch Blocks
2026-02-01 00:19:22 +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
9303878d8d Merge pull request #157 from VAR-Virtual-Air-Rescue/staging
fixed permacrash core-server
2026-01-31 23:42:07 +01:00
PxlLoewe
829d6d8cde member, guild fetch improved 2026-01-31 23:39:35 +01:00
PxlLoewe
25692b66be Merge pull request #156 from VAR-Virtual-Air-Rescue/staging
v2.0.8
2026-01-31 23:08:50 +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
8956204a2f Dockerfile Hub 2026-01-15 23:54:51 +01:00
PxlLoewe
a5b4696644 Update Dockerfile 2026-01-15 23:46:49 +01:00
PxlLoewe
a9ecf7e7b8 Merge branch 'release' of https://github.com/VAR-Virtual-Air-Rescue/var-monorepo into release 2026-01-15 23:46:16 +01:00
PxlLoewe
547768410f Merge pull request #149 from VAR-Virtual-Air-Rescue/revert-148-revert-147-staging
Revert "Revert "PR v2.0.7""
2026-01-15 23:45:26 +01:00
PxlLoewe
2c2eca6084 Revert "Revert "PR v2.0.7"" 2026-01-15 23:45:06 +01:00
PxlLoewe
5898dc6477 Merge branch 'release' of https://github.com/VAR-Virtual-Air-Rescue/var-monorepo into release 2026-01-15 23:44:08 +01:00
PxlLoewe
c9499e08be dev 2026-01-15 23:43:53 +01:00
PxlLoewe
0429d8b770 Merge pull request #148 from VAR-Virtual-Air-Rescue/revert-147-staging
Revert "PR v2.0.7"
2026-01-15 23:35:32 +01:00
PxlLoewe
7175f6571e Revert "PR v2.0.7" 2026-01-15 23:35:14 +01:00
PxlLoewe
614b92325e Merge pull request #147 from VAR-Virtual-Air-Rescue/staging
@everyone | Wir haben eine kurze Downtime überwunden und stellen euch heute v2.0.7 vor.

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

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

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

### Weiteres:
- kleinere Bugfixes
- Performanceupgrades durch verbessertes Backup-Handling
2026-01-15 22:59:21 +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
b1e508ef36 release v2.0.6
v2.0.6
2026-01-06 13:58:24 +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
6e8884f3fb Merge pull request #141 from VAR-Virtual-Air-Rescue/staging
v2.0.5
2025-12-27 16:23:33 +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
dde52bde39 Release v2.0.4
Release v2.0.4
2025-12-08 19:48:53 +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
239 changed files with 7125 additions and 2701 deletions

View File

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

1
.gitignore vendored
View File

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

View File

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

View File

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

View File

@@ -7,22 +7,25 @@ const router: Router = Router();
export const eventCompleted = (event: Event, participant?: Participant) => { export const eventCompleted = (event: Event, participant?: Participant) => {
if (!participant) return false; if (!participant) return false;
if (event.finisherMoodleCourseId && !participant.finisherMoodleCurseCompleted) return false; if (event.finisherMoodleCourseId && !participant.finisherMoodleCurseCompleted) return false;
if (event.hasPresenceEvents && !participant.attended) return false;
return true; return true;
}; };
router.post("/set-standard-name", async (req, res) => { export const setUserStandardNamePermissions = async ({
const { memberId, userId } = req.body; memberId,
userId,
}: {
memberId: string;
userId: string;
}) => {
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { where: {
id: userId, id: userId,
}, },
}); });
if (!user) { if (!user) {
res.status(404).json({ error: "User not found" });
return; return;
} }
const participant = await prisma.participant.findMany({ const participant = await prisma.participant.findMany({
where: { where: {
userId: user.id, 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) => { participant.forEach(async (p) => {
if (!p.Event.discordRoleId) return; if (!p.Event.discordRoleId) return;
if (eventCompleted(p.Event, p)) { if (eventCompleted(p.Event, p)) {
@@ -44,12 +66,29 @@ router.post("/set-standard-name", async (req, res) => {
const publicUser = getPublicUser(user); const publicUser = getPublicUser(user);
const member = await getMember(memberId); const member = await getMember(memberId);
if (!member) throw new Error("Member not found");
await member.setNickname(`${publicUser.fullName} - ${user.publicId}`); await member.setNickname(`${publicUser.fullName} - ${user.publicId}`);
const isPilot = user.permissions.includes("PILOT"); const isPilot = user.permissions.includes("PILOT");
const isDispatcher = user.permissions.includes("DISPO"); 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.PILOT], isPilot ? "add" : "remove");
await changeMemberRoles(memberId, [DISCORD_ROLES.DISPATCHER], isDispatcher ? "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; export default router;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,8 +6,10 @@ export const sendAlert = async (
id: number, id: number,
{ {
stationId, stationId,
desktopOnly,
}: { }: {
stationId?: number; stationId?: number;
desktopOnly?: boolean;
}, },
user: User | "HPG", user: User | "HPG",
): Promise<{ ): Promise<{
@@ -46,10 +48,13 @@ export const sendAlert = async (
}); });
for (const aircraft of connectedAircrafts) { for (const aircraft of connectedAircrafts) {
if (!desktopOnly) {
io.to(`station:${aircraft.stationId}`).emit("mission-alert", { io.to(`station:${aircraft.stationId}`).emit("mission-alert", {
...mission, ...mission,
Stations, Stations,
}); });
}
io.to(`desktop:${aircraft.userId}`).emit("mission-alert", { io.to(`desktop:${aircraft.userId}`).emit("mission-alert", {
missionId: mission.id, missionId: mission.id,
}); });

View File

@@ -1,13 +1,17 @@
import { createClient, RedisClientType } from "redis"; import { createClient, RedisClientType } from "redis";
export const pubClient: RedisClientType = createClient({ 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(); export const subClient: RedisClientType = pubClient.duplicate();
if (!process.env.REDIS_HOST || !process.env.REDIS_PORT) {
console.warn("REDIS_HOST or REDIS_PORT not set, skipping Redis connection");
} else {
Promise.all([pubClient.connect(), subClient.connect()]).then(() => { Promise.all([pubClient.connect(), subClient.connect()]).then(() => {
console.log("Redis connected"); console.log("Redis connected");
}); });
}
pubClient.on("error", (err) => console.log("Redis Client Error", err)); pubClient.on("error", (err) => console.log("Redis Client Error", err));
subClient.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, AdminMessage,
getPublicUser, getPublicUser,
MissionLog, MissionLog,
MissionSdsStatusLog,
NotificationPayload, NotificationPayload,
Prisma, Prisma,
prisma, prisma,
@@ -130,6 +131,54 @@ router.patch("/:id", async (req, res) => {
} }
}); });
router.post("/:id/send-sds-message", async (req, res) => {
const { id } = req.params;
const { sdsMessage } = req.body as { sdsMessage: MissionSdsStatusLog };
if (!sdsMessage.data.stationId || !id) {
res.status(400).json({ error: "Missing aircraftId or stationId" });
return;
}
await prisma.mission.updateMany({
where: {
state: "running",
missionStationIds: {
has: sdsMessage.data.stationId,
},
},
data: {
missionLog: {
push: sdsMessage as unknown as Prisma.InputJsonValue,
},
},
});
const user = await prisma.user.findFirst({
where: { publicId: sdsMessage.data.user.publicId, firstname: sdsMessage.data.user.firstname },
});
if (!user) {
res.status(404).json({ error: "User not found" });
return;
}
io.to(
sdsMessage.data.direction === "to-lst" ? "dispatchers" : `station:${sdsMessage.data.stationId}`,
).emit(sdsMessage.data.direction === "to-lst" ? "notification" : "sds-status", {
type: "station-status",
status: sdsMessage.data.status,
message: "SDS Status Message",
data: {
aircraftId: parseInt(id),
stationId: sdsMessage.data.stationId,
userId: user.id,
},
} as NotificationPayload);
res.sendStatus(204);
});
// Kick a connectedAircraft by ID // Kick a connectedAircraft by ID
router.delete("/:id", async (req, res) => { router.delete("/:id", async (req, res) => {
const { id } = req.params; const { id } = req.params;

View File

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

View File

View File

@@ -1,6 +1,6 @@
import { getPublicUser, prisma, User } from "@repo/db"; import { getPublicUser, prisma, User } from "@repo/db";
import { addRolesToMember, removeRolesFromMember, renameMember } from "modules/discord"; 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 { DISCORD_ROLES } from "@repo/db";
import { Server, Socket } from "socket.io"; import { Server, Socket } from "socket.io";
@@ -28,8 +28,17 @@ export const handleConnectDispatch =
return; return;
} }
if (!user.permissions?.includes("DISPO")) { const userPenaltys = await getUserPenaltys(user.id);
socket.emit("error", "You do not have permission to connect to the dispatch server.");
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; return;
} }

View File

@@ -1,8 +1,8 @@
import { getPublicUser, prisma, User } from "@repo/db"; import { getPublicUser, prisma, User } from "@repo/db";
import { addRolesToMember, removeRolesFromMember, renameMember } from "modules/discord"; import { addRolesToMember, removeRolesFromMember, renameMember } from "modules/discord";
import { getNextDateWithTime } from "@repo/shared-components";
import { DISCORD_ROLES } from "@repo/db"; import { DISCORD_ROLES } from "@repo/db";
import { Server, Socket } from "socket.io"; import { Server, Socket } from "socket.io";
import { getUserPenaltys } from "@repo/shared-components";
export const handleConnectPilot = export const handleConnectPilot =
(socket: Socket, io: Server) => (socket: Socket, io: Server) =>
@@ -34,6 +34,19 @@ export const handleConnectPilot =
socket.disconnect(); socket.disconnect();
return; 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"); if (!user) return Error("User not found");
@@ -83,6 +96,8 @@ export const handleConnectPilot =
lastHeartbeat: debug ? nowPlus2h.toISOString() : undefined, lastHeartbeat: debug ? nowPlus2h.toISOString() : undefined,
posLat: randomPos?.lat, posLat: randomPos?.lat,
posLng: randomPos?.lng, 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 = export const handleSendMessage =
(socket: Socket, io: Server) => (socket: Socket, io: Server) =>
async ( async (
{ userId, message }: { userId: string; message: string }, { userId, message, role }: { userId: string; message: string; role: string },
cb: (err: { error?: string }) => void, cb: (err: { error?: string }) => void,
) => { ) => {
const senderId = socket.data.user.id; const senderId = socket.data.user.id;
@@ -24,7 +24,7 @@ export const handleSendMessage =
receiverId: userId, receiverId: userId,
senderId, senderId,
receiverName: `${receiverUser?.firstname} ${receiverUser?.lastname[0]}. - ${receiverUser?.publicId}`, 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,12 +1,12 @@
FROM node:22-alpine AS base FROM node:22-alpine AS base
ARG NEXT_PUBLIC_DISPATCH_URL ARG NEXT_PUBLIC_DISPATCH_URL="http://localhost:3001"
ARG NEXT_PUBLIC_DISPATCH_SERVER_URL ARG NEXT_PUBLIC_DISPATCH_SERVER_URL="http://localhost:4001"
ARG NEXT_PUBLIC_HUB_URL ARG NEXT_PUBLIC_HUB_URL="http://localhost:3002"
ARG NEXT_PUBLIC_DISPATCH_SERVICE_ID ARG NEXT_PUBLIC_DISPATCH_SERVICE_ID="1"
ARG NEXT_PUBLIC_LIVEKIT_URL ARG NEXT_PUBLIC_LIVEKIT_URL="http://localhost:7880"
ARG NEXT_PUBLIC_DISCORD_URL ARG NEXT_PUBLIC_DISCORD_URL="https://discord.com"
ARG NEXT_PUBLIC_OPENAIP_ACCESS ARG NEXT_PUBLIC_OPENAIP_ACCESS=""
ENV NEXT_PUBLIC_DISPATCH_SERVER_URL=$NEXT_PUBLIC_DISPATCH_SERVER_URL ENV NEXT_PUBLIC_DISPATCH_SERVER_URL=$NEXT_PUBLIC_DISPATCH_SERVER_URL
ENV NEXT_PUBLIC_DISPATCH_URL=$NEXT_PUBLIC_DISPATCH_URL ENV NEXT_PUBLIC_DISPATCH_URL=$NEXT_PUBLIC_DISPATCH_URL
@@ -16,13 +16,13 @@ ENV NEXT_PUBLIC_LIVEKIT_URL=$NEXT_PUBLIC_LIVEKIT_URL
ENV NEXT_PUBLIC_OPENAIP_ACCESS=$NEXT_PUBLIC_OPENAIP_ACCESS ENV NEXT_PUBLIC_OPENAIP_ACCESS=$NEXT_PUBLIC_OPENAIP_ACCESS
ENV NEXT_PUBLIC_DISCORD_URL=$NEXT_PUBLIC_DISCORD_URL ENV NEXT_PUBLIC_DISCORD_URL=$NEXT_PUBLIC_DISCORD_URL
FROM base AS builder
ENV PNPM_HOME="/usr/local/pnpm" ENV PNPM_HOME="/usr/local/pnpm"
ENV PATH="${PNPM_HOME}:${PATH}" ENV PATH="${PNPM_HOME}:${PATH}"
RUN corepack enable && corepack prepare pnpm@latest --activate RUN corepack enable && corepack prepare pnpm@latest --activate
RUN pnpm add -g turbo@^2.5 RUN pnpm add -g turbo@^2.5
FROM base AS builder
RUN apk update RUN apk update
RUN apk add --no-cache libc6-compat RUN apk add --no-cache libc6-compat
@@ -31,12 +31,20 @@ WORKDIR /usr/app
RUN echo "NEXT_PUBLIC_HUB_URL is: $NEXT_PUBLIC_HUB_URL" RUN echo "NEXT_PUBLIC_HUB_URL is: $NEXT_PUBLIC_HUB_URL"
RUN echo "NEXT_PUBLIC_DISPATCH_SERVICE_ID is: $NEXT_PUBLIC_DISPATCH_SERVICE_ID" RUN echo "NEXT_PUBLIC_DISPATCH_SERVICE_ID is: $NEXT_PUBLIC_DISPATCH_SERVICE_ID"
RUN echo "NEXT_PUBLIC_DISPATCH_SERVER_URL is: $NEXT_PUBLIC_DISPATCH_SERVER_URL" RUN echo "NEXT_PUBLIC_DISPATCH_SERVER_URL is: $NEXT_PUBLIC_DISPATCH_SERVER_URL"
RUN echo "NEXT_PUBLIC_LIVEKIT_URL is: $NEXT_PUBLIC_LIVEKIT_URL"
COPY . . COPY . .
RUN turbo prune dispatch --docker RUN turbo prune dispatch --docker
FROM base AS installer FROM base AS installer
ENV PNPM_HOME="/usr/local/pnpm"
ENV PATH="${PNPM_HOME}:${PATH}"
RUN corepack enable && corepack prepare pnpm@latest --activate
RUN pnpm add -g turbo@^2.5
RUN apk update RUN apk update
RUN apk add --no-cache libc6-compat RUN apk add --no-cache libc6-compat
@@ -50,19 +58,23 @@ COPY --from=builder /usr/app/out/full/ .
RUN turbo run build RUN turbo run build
FROM base AS runner FROM node:22-alpine AS runner
WORKDIR /usr/app WORKDIR /usr/app
# Don't run production as root # Don't run production as root
RUN addgroup --system --gid 1001 nodejs RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs RUN adduser --system --uid 1001 nextjs
USER nextjs
# Automatically leverage output traces to reduce image size # Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing # https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=installer --chown=nextjs:nodejs /usr/app/ ./ COPY --from=installer --chown=nextjs:nodejs /usr/app/apps/dispatch/.next/standalone ./
COPY --from=installer --chown=nextjs:nodejs /usr/app/apps/dispatch/.next/static ./apps/dispatch/.next/static
COPY --from=installer --chown=nextjs:nodejs /usr/app/apps/dispatch/public ./apps/dispatch/public
USER nextjs
# Expose the application port # Expose the application port
EXPOSE 3001 EXPOSE 3000
ENV HOST=0.0.0.0
CMD ["pnpm", "--dir", "apps/dispatch", "run", "start"] CMD ["node", "apps/dispatch/server.js"]

View File

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

View File

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

View File

@@ -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

@@ -1,18 +1,19 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { GearIcon } from "@radix-ui/react-icons"; import { GearIcon } from "@radix-ui/react-icons";
import { SettingsIcon, Volume2 } from "lucide-react"; import { Info, SettingsIcon, Volume2 } from "lucide-react";
import MicVolumeBar from "_components/MicVolumeIndication"; 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 { editUserAPI, getUserAPI } from "_querys/user";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { useAudioStore } from "_store/audioStore"; import { useAudioStore } from "_store/audioStore";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { useMapStore } from "_store/mapStore"; import { useMapStore } from "_store/mapStore";
import { set } from "date-fns"; import { Button } from "@repo/shared-components";
export const SettingsBtn = () => { export const SettingsBtn = () => {
const session = useSession(); const session = useSession();
const queryClient = useQueryClient();
const [inputDevices, setInputDevices] = useState<MediaDeviceInfo[]>([]); const [inputDevices, setInputDevices] = useState<MediaDeviceInfo[]>([]);
const { data: user } = useQuery({ const { data: user } = useQuery({
@@ -23,6 +24,9 @@ export const SettingsBtn = () => {
const editUserMutation = useMutation({ const editUserMutation = useMutation({
mutationFn: editUserAPI, mutationFn: editUserAPI,
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["user", session.data?.user.id] });
},
}); });
useEffect(() => { useEffect(() => {
@@ -40,6 +44,7 @@ export const SettingsBtn = () => {
micVolume: user?.settingsMicVolume || 1, micVolume: user?.settingsMicVolume || 1,
radioVolume: user?.settingsRadioVolume || 0.8, radioVolume: user?.settingsRadioVolume || 0.8,
autoCloseMapPopup: user?.settingsAutoCloseMapPopup || false, autoCloseMapPopup: user?.settingsAutoCloseMapPopup || false,
useHPGAsDispatcher: user?.settingsUseHPGAsDispatcher || false,
}); });
const { setSettings: setAudioSettings } = useAudioStore((state) => state); const { setSettings: setAudioSettings } = useAudioStore((state) => state);
@@ -57,7 +62,8 @@ export const SettingsBtn = () => {
micDeviceId: user.settingsMicDevice, micDeviceId: user.settingsMicDevice,
micVolume: user.settingsMicVolume || 1, micVolume: user.settingsMicVolume || 1,
radioVolume: user.settingsRadioVolume || 0.8, radioVolume: user.settingsRadioVolume || 0.8,
autoCloseMapPopup: user.settingsAutoCloseMapPopup || false, autoCloseMapPopup: user.settingsAutoCloseMapPopup,
useHPGAsDispatcher: user.settingsUseHPGAsDispatcher,
}); });
setUserSettings({ setUserSettings({
settingsAutoCloseMapPopup: user.settingsAutoCloseMapPopup || false, settingsAutoCloseMapPopup: user.settingsAutoCloseMapPopup || false,
@@ -75,10 +81,14 @@ export const SettingsBtn = () => {
useEffect(() => { useEffect(() => {
const setDevices = async () => { const setDevices = async () => {
if (typeof navigator !== "undefined" && navigator.mediaDevices?.enumerateDevices) { if (typeof navigator !== "undefined" && navigator.mediaDevices?.enumerateDevices) {
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: false, audio: true }); const stream = await navigator.mediaDevices.getUserMedia({ video: false, audio: true });
const devices = await navigator.mediaDevices.enumerateDevices(); const devices = await navigator.mediaDevices.enumerateDevices();
setInputDevices(devices.filter((d) => d.kind === "audioinput")); setInputDevices(devices.filter((d) => d.kind === "audioinput"));
stream.getTracks().forEach((track) => track.stop()); stream.getTracks().forEach((track) => track.stop());
} catch (error) {
console.error("Error accessing media devices.", error);
}
} }
}; };
@@ -198,6 +208,28 @@ export const SettingsBtn = () => {
/> />
Popups automatisch schließen Popups automatisch schließen
</div> </div>
<div className="mt-2 flex w-full items-center gap-2">
<input
type="checkbox"
className="toggle"
checked={settings.useHPGAsDispatcher}
onChange={(e) => {
setSettingsPartial({ useHPGAsDispatcher: e.target.checked });
}}
/>
HPG Validierung verwenden{" "}
<div
className="tooltip tooltip-warning"
data-tip="Achtung! Mit der Client Version v2.0.1.0 werden Einsätze auch ohne Validierung an die
HPG gesendet. Die Validierung über die HPG kann zu Verzögerungen bei der
Einsatzübermittlung führen, insbesondere wenn das HPG script den Einsatz nicht sofort
validieren kann. Es wird empfohlen, diese Option nur zu aktivieren, wenn es Probleme
mit der Einsatzübermittlung gibt oder wenn die HPG Validierung ausdrücklich gewünscht
wird."
>
<Info className="text-error" size={16} />
</div>
</div>
<div className="modal-action flex justify-between"> <div className="modal-action flex justify-between">
<button <button
@@ -211,7 +243,7 @@ export const SettingsBtn = () => {
> >
Schließen Schließen
</button> </button>
<button <Button
className="btn btn-soft btn-success" className="btn btn-soft btn-success"
type="submit" type="submit"
onSubmit={() => false} onSubmit={() => false}
@@ -224,6 +256,7 @@ export const SettingsBtn = () => {
settingsMicVolume: settings.micVolume, settingsMicVolume: settings.micVolume,
settingsRadioVolume: settings.radioVolume, settingsRadioVolume: settings.radioVolume,
settingsAutoCloseMapPopup: settings.autoCloseMapPopup, settingsAutoCloseMapPopup: settings.autoCloseMapPopup,
settingsUseHPGAsDispatcher: settings.useHPGAsDispatcher,
}, },
}); });
setAudioSettings({ setAudioSettings({
@@ -239,7 +272,7 @@ export const SettingsBtn = () => {
}} }}
> >
Speichern Speichern
</button> </Button>
</div> </div>
</div> </div>
</dialog> </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 { AxiosError } from "axios";
import { cn } from "@repo/shared-components"; import { cn } from "@repo/shared-components";
import { StationsSelect } from "(app)/dispatch/_components/StationSelect"; import { StationsSelect } from "(app)/dispatch/_components/StationSelect";
import { getUserAPI } from "_querys/user";
export const MissionForm = () => { export const MissionForm = () => {
const session = useSession();
const { editingMissionId, setEditingMission } = usePannelStore(); const { editingMissionId, setEditingMission } = usePannelStore();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { setSearchElements, searchElements, setContextMenu } = useMapStore((s) => s); const { setSearchElements, searchElements, setContextMenu } = useMapStore((s) => s);
@@ -44,6 +47,10 @@ export const MissionForm = () => {
queryFn: () => getConnectedAircraftsAPI(), queryFn: () => getConnectedAircraftsAPI(),
refetchInterval: 10000, refetchInterval: 10000,
}); });
const { data: user } = useQuery({
queryKey: ["user", session.data?.user.id],
queryFn: () => getUserAPI(session.data!.user.id),
});
const createMissionMutation = useMutation({ const createMissionMutation = useMutation({
mutationFn: createMissionAPI, mutationFn: createMissionAPI,
@@ -81,7 +88,6 @@ export const MissionForm = () => {
}, },
}); });
const session = useSession();
const defaultFormValues = React.useMemo( const defaultFormValues = React.useMemo(
() => () =>
({ ({
@@ -108,6 +114,7 @@ export const MissionForm = () => {
hpgSelectedMissionString: null, hpgSelectedMissionString: null,
hpg: null, hpg: null,
missionLog: [], missionLog: [],
xPlaneObjects: [],
}) as MissionOptionalDefaults, }) as MissionOptionalDefaults,
[session.data?.user.id], [session.data?.user.id],
); );
@@ -116,13 +123,16 @@ export const MissionForm = () => {
resolver: zodResolver(MissionOptionalDefaultsSchema), resolver: zodResolver(MissionOptionalDefaultsSchema),
defaultValues: defaultFormValues, defaultValues: defaultFormValues,
}); });
const { missionFormValues, setOpen } = usePannelStore((state) => state); const { missionFormValues, setOpen, setMissionFormValues } = usePannelStore((state) => state);
const validationRequired = HPGValidationRequired( const validationRequired =
HPGValidationRequired(
form.watch("missionStationIds"), form.watch("missionStationIds"),
aircrafts, aircrafts,
form.watch("hpgMissionString"), form.watch("hpgMissionString"),
); ) &&
!form.watch("hpgMissionString")?.startsWith("kein Szenario") &&
user?.settingsUseHPGAsDispatcher;
useEffect(() => { useEffect(() => {
if (session.data?.user.id) { if (session.data?.user.id) {
@@ -144,6 +154,7 @@ export const MissionForm = () => {
return; return;
} }
for (const key in missionFormValues) { for (const key in missionFormValues) {
console.debug(key, missionFormValues[key as keyof MissionOptionalDefaults]);
if (key === "addressOSMways") continue; // Skip addressOSMways as it is handled separately if (key === "addressOSMways") continue; // Skip addressOSMways as it is handled separately
form.setValue( form.setValue(
key as keyof MissionOptionalDefaults, key as keyof MissionOptionalDefaults,
@@ -153,6 +164,22 @@ export const MissionForm = () => {
} }
}, [missionFormValues, form, defaultFormValues]); }, [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 ( const saveMission = async (
mission: MissionOptionalDefaults, mission: MissionOptionalDefaults,
{ alertWhenValid = false, createNewMission = false } = {}, { alertWhenValid = false, createNewMission = false } = {},
@@ -369,6 +396,7 @@ export const MissionForm = () => {
<option disabled value="please_select"> <option disabled value="please_select">
Einsatz Szenario auswählen... Einsatz Szenario auswählen...
</option> </option>
<option value={"kein Szenario:3_1_1_1-4_1"}>Kein Szenario</option>
{keywords && {keywords &&
keywords keywords
.find((k) => k.name === form.watch("missionKeywordName")) .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 In diesem Einsatz gibt es {form.watch("addressOSMways").length} Gebäude
</p> </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="form-control min-h-[140px]">
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
@@ -430,7 +473,11 @@ export const MissionForm = () => {
setSearchElements([]); // Reset search elements setSearchElements([]); // Reset search elements
setEditingMission(null); setEditingMission(null);
setContextMenu(null); setContextMenu(null);
toast.success(`Einsatz ${newMission.publicId} erstellt`); if (editingMissionId) {
toast.success(`${newMission.publicId} bearbeitet`);
} else {
toast.success(`${newMission.publicId} erstellt`);
}
form.reset(); form.reset();
setOpen(false); setOpen(false);
} catch (error) { } catch (error) {
@@ -455,7 +502,11 @@ export const MissionForm = () => {
setSearchElements([]); // Reset search elements setSearchElements([]); // Reset search elements
setContextMenu(null); setContextMenu(null);
toast.success(`Einsatz ${newMission.publicId} erstellt`); if (editingMissionId) {
toast.success(`${newMission.publicId} bearbeitet`);
} else {
toast.success(`${newMission.publicId} erstellt`);
}
form.reset(); form.reset();
setOpen(false); setOpen(false);
} catch (error) { } catch (error) {

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

View File

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

View File

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

View File

@@ -6,9 +6,10 @@ import { useMutation, useQuery } from "@tanstack/react-query";
import { getStationsAPI } from "_querys/stations"; import { getStationsAPI } from "_querys/stations";
import { editConnectedAircraftAPI, getConnectedAircraftsAPI } from "_querys/aircrafts"; import { editConnectedAircraftAPI, getConnectedAircraftsAPI } from "_querys/aircrafts";
import { Prisma } from "@repo/db"; 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 { Select } from "_components/Select";
import { Radio } from "lucide-react"; import { Calendar, Radio } from "lucide-react";
import { getBookingsAPI } from "_querys/bookings";
export const ConnectionBtn = () => { export const ConnectionBtn = () => {
const modalRef = useRef<HTMLDialogElement>(null); const modalRef = useRef<HTMLDialogElement>(null);
@@ -27,6 +28,19 @@ export const ConnectionBtn = () => {
queryKey: ["stations"], queryKey: ["stations"],
queryFn: () => getStationsAPI(), 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({ const aircraftMutation = useMutation({
mutationFn: ({ mutationFn: ({
change, change,
@@ -117,7 +131,9 @@ export const ConnectionBtn = () => {
(option as { component: React.ReactNode }).component (option as { component: React.ReactNode }).component
} }
options={ options={
stations?.map((station) => ({ stations?.map((station) => {
const booking = bookings?.find((b) => b.stationId == station.id);
return {
value: station.id.toString(), value: station.id.toString(),
label: station.bosCallsign, label: station.bosCallsign,
component: ( component: (
@@ -126,11 +142,28 @@ export const ConnectionBtn = () => {
{connectedAircrafts?.find((a) => a.stationId == station.id) && ( {connectedAircrafts?.find((a) => a.stationId == station.id) && (
<Radio className="text-warning" size={15} /> <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} {station.bosCallsign}
</span> </span>
</div> </div>
), ),
})) ?? [] };
}) ?? []
} }
/> />
</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 { GearIcon } from "@radix-ui/react-icons";
import { Bell, SettingsIcon, Volume2 } from "lucide-react"; import { Bell, SettingsIcon, Volume2 } from "lucide-react";
import MicVolumeBar from "_components/MicVolumeIndication"; 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 { editUserAPI, getUserAPI } from "_querys/user";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { useAudioStore } from "_store/audioStore"; import { useAudioStore } from "_store/audioStore";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import Link from "next/link"; import Link from "next/link";
import { Button } from "@repo/shared-components";
export const SettingsBtn = () => { export const SettingsBtn = () => {
const session = useSession(); const session = useSession();
const queryClient = useQueryClient();
const [inputDevices, setInputDevices] = useState<MediaDeviceInfo[]>([]); const [inputDevices, setInputDevices] = useState<MediaDeviceInfo[]>([]);
const { data: user } = useQuery({ const { data: user } = useQuery({
@@ -22,6 +24,10 @@ export const SettingsBtn = () => {
const editUserMutation = useMutation({ const editUserMutation = useMutation({
mutationFn: editUserAPI, mutationFn: editUserAPI,
mutationKey: ["user", session.data?.user.id],
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["user", session.data?.user.id] });
},
}); });
useEffect(() => { useEffect(() => {
@@ -248,7 +254,7 @@ export const SettingsBtn = () => {
> >
Schließen Schließen
</button> </button>
<button <Button
className="btn btn-soft btn-success" className="btn btn-soft btn-success"
type="submit" type="submit"
onSubmit={() => false} onSubmit={() => false}
@@ -275,7 +281,7 @@ export const SettingsBtn = () => {
}} }}
> >
Speichern Speichern
</button> </Button>
</div> </div>
</div> </div>
</dialog> </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 type { Metadata } from "next";
import Navbar from "./_components/navbar/Navbar";
import { getServerSession } from "api/auth/[...nextauth]/auth"; import { getServerSession } from "api/auth/[...nextauth]/auth";
import { Error } from "_components/Error"; 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 = { export const metadata: Metadata = {
title: "VAR: Pilot", title: "VAR: Pilot",
@@ -26,7 +29,11 @@ export default async function RootLayout({
return ( return (
<> <>
<Navbar /> <Navbar>
<Audio />
<Connection />
<Settings />
</Navbar>
{children} {children}
</> </>
); );

View File

@@ -6,13 +6,17 @@ import { Report } from "../../_components/left/Report";
import { Dme } from "(app)/pilot/_components/dme/Dme"; import { Dme } from "(app)/pilot/_components/dme/Dme";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { ConnectedDispatcher } from "tracker/_components/ConnectedDispatcher"; 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 { usePilotConnectionStore } from "_store/pilot/connectionStore";
import { getConnectedAircraftsAPI } from "_querys/aircrafts"; 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 { SimConnectionAlert } from "(app)/pilot/_components/SimConnectionAlert";
import { SettingsBoard } from "_components/left/SettingsBoard"; import { SettingsBoard } from "_components/left/SettingsBoard";
import { BugReport } from "_components/left/BugReport"; 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"), { const Map = dynamic(() => import("_components/map/Map"), {
ssr: false, ssr: false,
@@ -20,12 +24,45 @@ const Map = dynamic(() => import("_components/map/Map"), {
const PilotPage = () => { const PilotPage = () => {
const { connectedAircraft, status } = usePilotConnectionStore((state) => state); const { connectedAircraft, status } = usePilotConnectionStore((state) => state);
const { latestMission } = useDmeStore((state) => state);
// Query will be cached anyway, due to this, displayed Markers are in sync with own Aircraft connection-warning // Query will be cached anyway, due to this, displayed Markers are in sync with own Aircraft connection-warning
const { data: aircrafts } = useQuery({ const { data: aircrafts } = useQuery({
queryKey: ["aircrafts"], queryKey: ["aircrafts"],
queryFn: () => getConnectedAircraftsAPI(), queryFn: () => getConnectedAircraftsAPI(),
refetchInterval: 10_000, 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 ownAircraft = aircrafts?.find((aircraft) => aircraft.id === connectedAircraft?.id);
const simulatorConnected = ownAircraft ? checkSimulatorConnected(ownAircraft) : false; const simulatorConnected = ownAircraft ? checkSimulatorConnected(ownAircraft) : false;
@@ -47,16 +84,49 @@ const PilotPage = () => {
</div> </div>
<Map /> <Map />
<div className="absolute right-10 top-5 z-20 space-y-2"> <div className="absolute right-10 top-5 z-20 space-y-2">
{!simulatorConnected && status === "connected" && ( {!simulatorConnected &&
status === "connected" &&
connectedAircraft &&
!shortlyConnected && (
<SimConnectionAlert lastUpdated={ownAircraft?.lastHeartbeat} /> <SimConnectionAlert lastUpdated={ownAircraft?.lastHeartbeat} />
)} )}
<ConnectedDispatcher /> <ConnectedDispatcher />
</div> </div>
</div> </div>
</div> </div>
<div className="flex h-full w-1/3"> <div className="flex h-full w-1/3 min-w-[500px]">
<div className="bg-base-300 flex h-full w-full flex-col p-4"> <div className="bg-base-300 flex h-full w-full flex-col p-4">
<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 bg-base-200 mb-4 shadow-xl">
<div className="card-body flex h-full w-full items-center justify-center"> <div className="card-body flex h-full w-full items-center justify-center">
<div className="max-w-150"> <div className="max-w-150">

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,7 @@
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 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 { dispatchSocket } from "(app)/dispatch/socket";
import { NotificationPayload } from "@repo/db"; import { NotificationPayload } from "@repo/db";
import { HPGnotificationToast } from "_components/customToasts/HPGnotification"; import { HPGnotificationToast } from "_components/customToasts/HPGnotification";
@@ -15,6 +15,7 @@ import { MissionAutoCloseToast } from "_components/customToasts/MissionAutoClose
export function QueryProvider({ children }: { children: ReactNode }) { export function QueryProvider({ children }: { children: ReactNode }) {
const mapStore = useMapStore((s) => s); const mapStore = useMapStore((s) => s);
const notificationSound = useRef<HTMLAudioElement | null>(null);
const [queryClient] = useState( const [queryClient] = useState(
() => () =>
@@ -22,7 +23,7 @@ export function QueryProvider({ children }: { children: ReactNode }) {
defaultOptions: { defaultOptions: {
mutations: { mutations: {
onError: (error) => { onError: (error) => {
toast.error("An error occurred: " + (error as Error).message, { toast.error("Ein Fehler ist aufgetreten: " + (error as Error).message, {
position: "top-right", position: "top-right",
}); });
}, },
@@ -30,6 +31,9 @@ export function QueryProvider({ children }: { children: ReactNode }) {
}, },
}), }),
); );
useEffect(() => {
notificationSound.current = new Audio("/sounds/notification.mp3");
}, []);
useEffect(() => { useEffect(() => {
const invalidateMission = () => { const invalidateMission = () => {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
@@ -59,8 +63,19 @@ export function QueryProvider({ children }: { children: ReactNode }) {
}; };
const handleNotification = (notification: NotificationPayload) => { 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) { switch (notification.type) {
case "hpg-validation": case "hpg-validation":
playNotificationSound();
toast.custom( toast.custom(
(t) => <HPGnotificationToast event={notification} mapStore={mapStore} t={t} />, (t) => <HPGnotificationToast event={notification} mapStore={mapStore} t={t} />,
{ {
@@ -70,23 +85,30 @@ export function QueryProvider({ children }: { children: ReactNode }) {
break; break;
case "admin-message": case "admin-message":
playNotificationSound();
toast.custom((t) => <AdminMessageToast event={notification} t={t} />, { toast.custom((t) => <AdminMessageToast event={notification} t={t} />, {
duration: 999999, duration: 999999,
}); });
break; break;
case "station-status": case "station-status":
console.log("station Status", QUICK_RESPONSE[notification.status]);
if (!QUICK_RESPONSE[notification.status]) return; if (!QUICK_RESPONSE[notification.status]) return;
toast.custom((e) => <StatusToast event={notification} t={e} />, { toast.custom((e) => <StatusToast event={notification} t={e} />, {
duration: 60000, duration: 60000,
}); });
break; break;
case "mission-auto-close": case "mission-auto-close":
playNotificationSound();
toast.custom( toast.custom(
(t) => <MissionAutoCloseToast event={notification} t={t} mapStore={mapStore} />, (t) => <MissionAutoCloseToast event={notification} t={t} mapStore={mapStore} />,
{ {
duration: 60000, duration: 60000,
}, },
); );
break;
case "mission-closed":
toast("Dein aktueller Einsatz wurde geschlossen.");
break; break;
default: default:
toast("unbekanntes Notification-Event"); 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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { BaseNotification } from "_components/customToasts/BaseNotification"; import { BaseNotification } from "_components/customToasts/BaseNotification";
import { FMS_STATUS_COLORS } from "_helpers/fmsStatusColors"; import { FMS_STATUS_COLORS } from "_helpers/fmsStatusColors";
import { editConnectedAircraftAPI, getConnectedAircraftsAPI } from "_querys/aircrafts"; import { getConnectedAircraftsAPI } from "_querys/aircrafts";
import { getLivekitRooms } from "_querys/livekit"; import { getLivekitRooms } from "_querys/livekit";
import { sendSdsStatusMessageAPI } from "_querys/missions";
import { getStationsAPI } from "_querys/stations"; import { getStationsAPI } from "_querys/stations";
import { useAudioStore } from "_store/audioStore"; import { useAudioStore } from "_store/audioStore";
import { useMapStore } from "_store/mapStore"; import { useMapStore } from "_store/mapStore";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { useEffect, useRef, useState } from "react"; import { useSession } from "next-auth/react";
import { useEffect, useRef } from "react";
import { Toast, toast } from "react-hot-toast"; import { Toast, toast } from "react-hot-toast";
export const QUICK_RESPONSE: Record<string, string[]> = { export const QUICK_RESPONSE: Record<string, string[]> = {
@@ -22,10 +24,12 @@ export const StatusToast = ({ event, t }: { event: StationStatus; t: Toast }) =>
const status5Sounds = useRef<HTMLAudioElement | null>(null); const status5Sounds = useRef<HTMLAudioElement | null>(null);
const status9Sounds = useRef<HTMLAudioElement | null>(null); const status9Sounds = useRef<HTMLAudioElement | null>(null);
const session = useSession();
const { data: livekitRooms } = useQuery({ const { data: livekitRooms } = useQuery({
queryKey: ["livekit-rooms"], queryKey: ["livekit-rooms"],
queryFn: () => getLivekitRooms(), queryFn: () => getLivekitRooms(),
refetchInterval: 10000, refetchInterval: 5000,
}); });
const audioRoom = useAudioStore((s) => s.room?.name); 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"); status9Sounds.current = new Audio("/sounds/status-9.mp3");
} }
}, []); }, []);
const [aircraftDataAcurate, setAircraftDataAccurate] = useState(false);
//const mapStore = useMapStore((s) => s); //const mapStore = useMapStore((s) => s);
const { setOpenAircraftMarker, setMap } = useMapStore((store) => store); const { setOpenAircraftMarker, setMap } = useMapStore((store) => store);
@@ -65,28 +69,16 @@ export const StatusToast = ({ event, t }: { event: StationStatus; t: Toast }) =>
const station = stations?.find((s) => s.id === event.data?.stationId); const station = stations?.find((s) => s.id === event.data?.stationId);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const changeAircraftMutation = useMutation({ const sendSdsStatusMutation = useMutation({
mutationFn: async ({ mutationFn: async ({ sdsMessage }: { sdsMessage: MissionSdsStatusLog }) => {
id, if (!connectedAircraft?.id) throw new Error("No connected aircraft");
update, await sendSdsStatusMessageAPI({ sdsMessage, aircraftId: connectedAircraft?.id });
}: {
id: number;
update: Prisma.ConnectedAircraftUpdateInput;
}) => {
await editConnectedAircraftAPI(id, update);
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ["aircrafts"], queryKey: ["missions"],
}); });
}, },
}); });
console.log("Audio Room:", audioRoom, participants, livekitUser, event);
useEffect(() => {
if (event.status !== connectedAircraft?.fmsStatus && aircraftDataAcurate) {
toast.remove(t.id);
} else if (event.status == connectedAircraft?.fmsStatus && !aircraftDataAcurate) {
setAircraftDataAccurate(true);
}
}, [aircraftDataAcurate, connectedAircraft, event.status, t.id]);
useEffect(() => { useEffect(() => {
let soundRef: React.RefObject<HTMLAudioElement | null> | null = null; let soundRef: React.RefObject<HTMLAudioElement | null> | null = null;
@@ -103,7 +95,8 @@ export const StatusToast = ({ event, t }: { event: StationStatus; t: Toast }) =>
default: default:
soundRef = null; soundRef = null;
} }
if (audioRoom !== livekitUser?.roomName) {
if (audioRoom && livekitUser?.roomName && audioRoom !== livekitUser?.roomName) {
toast.remove(t.id); toast.remove(t.id);
return; return;
} }
@@ -121,7 +114,7 @@ export const StatusToast = ({ event, t }: { event: StationStatus; t: Toast }) =>
}; };
}, [event.status, livekitUser?.roomName, audioRoom, t.id]); }, [event.status, livekitUser?.roomName, audioRoom, t.id]);
if (!connectedAircraft || !station) return null; if (!connectedAircraft || !station || !session.data) return null;
return ( return (
<BaseNotification> <BaseNotification>
<div className="flex flex-row items-center gap-14"> <div className="flex flex-row items-center gap-14">
@@ -162,10 +155,18 @@ export const StatusToast = ({ event, t }: { event: StationStatus; t: Toast }) =>
toast.error("Keine Flugzeug-ID gefunden"); toast.error("Keine Flugzeug-ID gefunden");
return; return;
} }
await changeAircraftMutation.mutateAsync({ await sendSdsStatusMutation.mutateAsync({
id: event.data?.aircraftId, sdsMessage: {
update: { type: "sds-status-log",
fmsStatus: status, auto: false,
data: {
direction: "to-aircraft",
stationId: event.data.stationId!,
station: station,
user: getPublicUser(session.data?.user),
status,
},
timeStamp: new Date().toISOString(),
}, },
}); });
toast.remove(t.id); toast.remove(t.id);

View File

@@ -56,7 +56,7 @@ export const Chat = () => {
(d) => d.userId !== session.data?.user.id && !chats[d.userId], (d) => d.userId !== session.data?.user.id && !chats[d.userId],
); );
const filteredAircrafts = aircrafts?.filter( const filteredAircrafts = aircrafts?.filter(
(a) => a.userId !== session.data?.user.id && dispatcherConnected && !chats[a.userId], (a) => a.userId !== session.data?.user.id && !chats[a.userId],
); );
const btnActive = pilotConnected || dispatcherConnected; const btnActive = pilotConnected || dispatcherConnected;

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { usePannelStore } from "_store/pannelStore"; import { usePannelStore } from "_store/pannelStore";
import { Marker } from "react-leaflet"; import { Marker, useMap } from "react-leaflet";
import L from "leaflet"; import L from "leaflet";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { getMissionsAPI } from "_querys/missions"; import { getMissionsAPI } from "_querys/missions";
@@ -8,10 +8,13 @@ import { HPGValidationRequired } from "_helpers/hpgValidationRequired";
import { getConnectedAircraftsAPI } from "_querys/aircrafts"; import { getConnectedAircraftsAPI } from "_querys/aircrafts";
import { useMapStore } from "_store/mapStore"; import { useMapStore } from "_store/mapStore";
import { useDispatchConnectionStore } from "_store/dispatch/connectionStore"; import { useDispatchConnectionStore } from "_store/dispatch/connectionStore";
import { XplaneObject } from "@repo/db";
import { useEffect, useState } from "react";
export const MapAdditionals = () => { export const MapAdditionals = () => {
const { isOpen, missionFormValues } = usePannelStore((state) => state); const { isOpen, missionFormValues, setMissionFormValues } = usePannelStore((state) => state);
const dispatcherConnectionState = useDispatchConnectionStore((state) => state.status); const dispatcherConnectionState = useDispatchConnectionStore((state) => state.status);
const { openMissionMarker } = useMapStore((state) => state);
const { data: missions = [] } = useQuery({ const { data: missions = [] } = useQuery({
queryKey: ["missions"], queryKey: ["missions"],
@@ -21,13 +24,28 @@ export const MapAdditionals = () => {
}), }),
refetchInterval: 10_000, refetchInterval: 10_000,
}); });
const mapStore = useMapStore((state) => state); const { setOpenMissionMarker } = useMapStore((state) => state);
const [showDetailedAdditionals, setShowDetailedAdditionals] = useState(false);
const { data: aircrafts } = useQuery({ const { data: aircrafts } = useQuery({
queryKey: ["aircrafts"], queryKey: ["aircrafts"],
queryFn: () => getConnectedAircraftsAPI(), queryFn: () => getConnectedAircraftsAPI(),
refetchInterval: 10000, 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( const markersNeedingAttention = missions.filter(
(m) => (m) =>
@@ -37,7 +55,7 @@ export const MapAdditionals = () => {
m.hpgLocationLat && m.hpgLocationLat &&
dispatcherConnectionState === "connected" && dispatcherConnectionState === "connected" &&
m.hpgLocationLng && m.hpgLocationLng &&
mapStore.openMissionMarker.find((openMission) => openMission.id === m.id), openMissionMarker.find((openMission) => openMission.id === m.id),
); );
return ( return (
@@ -50,9 +68,78 @@ export const MapAdditionals = () => {
iconSize: [40, 40], iconSize: [40, 40],
iconAnchor: [20, 35], 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) => ( {markersNeedingAttention.map((mission) => (
<Marker <Marker
key={mission.id} key={mission.id}
@@ -64,7 +151,7 @@ export const MapAdditionals = () => {
})} })}
eventHandlers={{ eventHandlers={{
click: () => click: () =>
mapStore.setOpenMissionMarker({ setOpenMissionMarker({
open: [ open: [
{ {
id: mission.id, id: mission.id,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
// Helper function for distortion curve generation // 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 k = typeof amount === "number" ? amount : 50;
const nSamples = 44100; const nSamples = 44100;
const curve = new Float32Array(nSamples); 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) => { 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: { params: {
filter: JSON.stringify(filter), filter: JSON.stringify(filter),
}, },
}); },
);
if (res.status !== 200) { if (res.status !== 200) {
throw new Error("Failed to fetch Connected Dispatcher"); 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 axios from "axios";
import { serverApi } from "_helpers/axios"; import { serverApi } from "_helpers/axios";
@@ -29,6 +29,20 @@ export const editMissionAPI = async (id: number, mission: Prisma.MissionUpdateIn
const respone = await serverApi.patch<Mission>(`/mission/${id}`, mission); const respone = await serverApi.patch<Mission>(`/mission/${id}`, mission);
return respone.data; return respone.data;
}; };
export const sendSdsStatusMessageAPI = async ({
sdsMessage,
aircraftId,
}: {
aircraftId: number;
sdsMessage: MissionSdsStatusLog;
}) => {
const respone = await serverApi.post<Mission>(`/aircrafts/${aircraftId}/send-sds-message`, {
sdsMessage,
});
return respone.data;
};
export const sendSdsMessageAPI = async ({ export const sendSdsMessageAPI = async ({
missionId, missionId,
sdsMessage, sdsMessage,
@@ -55,9 +69,11 @@ export const sendMissionAPI = async (
{ {
stationId, stationId,
vehicleName, vehicleName,
desktopOnly,
}: { }: {
stationId?: number; stationId?: number;
vehicleName?: "RTW" | "POL" | "FW"; vehicleName?: "RTW" | "POL" | "FW";
desktopOnly?: boolean;
}, },
) => { ) => {
const respone = await serverApi.post<{ const respone = await serverApi.post<{
@@ -65,6 +81,7 @@ export const sendMissionAPI = async (
}>(`/mission/${id}/send-alert`, { }>(`/mission/${id}/send-alert`, {
stationId, stationId,
vehicleName, vehicleName,
desktopOnly,
}); });
return respone.data; return respone.data;
}; };

View File

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

View File

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

View File

@@ -2,6 +2,8 @@ import { create } from "zustand";
import { ChatMessage } from "@repo/db"; import { ChatMessage } from "@repo/db";
import { dispatchSocket } from "(app)/dispatch/socket"; import { dispatchSocket } from "(app)/dispatch/socket";
import { pilotSocket } from "(app)/pilot/socket"; import { pilotSocket } from "(app)/pilot/socket";
import { useDispatchConnectionStore } from "_store/dispatch/connectionStore";
import { usePilotConnectionStore } from "_store/pilot/connectionStore";
interface ChatStore { interface ChatStore {
situationTabOpen: boolean; situationTabOpen: boolean;
@@ -16,7 +18,12 @@ interface ChatStore {
setOwnId: (id: string) => void; setOwnId: (id: string) => void;
chats: Record<string, { name: string; notification: boolean; messages: ChatMessage[] }>; chats: Record<string, { name: string; notification: boolean; messages: ChatMessage[] }>;
setChatNotification: (userId: string, notification: boolean) => void; 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; addChat: (userId: string, name: string) => void;
addMessage: (userId: string, message: ChatMessage) => void; addMessage: (userId: string, message: ChatMessage) => void;
removeChat: (userId: string) => void; removeChat: (userId: string) => void;
@@ -49,12 +56,13 @@ export const useLeftMenuStore = create<ChatStore>((set, get) => ({
}, },
setOwnId: (id: string) => set({ ownId: id }), setOwnId: (id: string) => set({ ownId: id }),
chats: {}, chats: {},
sendMessage: (userId: string, message: string) => { sendMessage: (userId, message) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (dispatchSocket.connected) { if (dispatchSocket.connected) {
const zone = useDispatchConnectionStore.getState().selectedZone;
dispatchSocket.emit( dispatchSocket.emit(
"send-message", "send-message",
{ userId, message }, { userId, message, role: zone },
({ error }: { error?: string }) => { ({ error }: { error?: string }) => {
if (error) { if (error) {
reject(error); reject(error);
@@ -64,13 +72,19 @@ export const useLeftMenuStore = create<ChatStore>((set, get) => ({
}, },
); );
} else if (pilotSocket.connected) { } 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) { if (error) {
reject(error); reject(error);
} else { } else {
resolve(); 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 { create } from "zustand";
import { syncTabs } from "zustand-sync-tabs";
interface SetSdsPageParams { interface SetOffPageParams {
page: "sds"; page: "off";
station: Station; }
sdsMessage: MissionSdsLog;
interface SetStartupPageParams {
page: "startup";
} }
interface SetHomePageParams { interface SetHomePageParams {
page: "home"; page: "home";
station: Station;
fmsStatus: string;
} }
interface SetSendingStatusPageParams { interface SetVoicecallPageParams {
page: "sending-status"; page: "voice-call";
station: Station; }
interface SetSdsReceivedPopupParams {
popup: "sds-received";
} }
interface SetNewStatusPageParams { interface SetGroupSelectionPopupParams {
page: "new-status"; popup: "group-selection";
station: Station;
} }
type SetPageParams = interface SetStatusSentPopupParams {
popup: "status-sent";
}
interface SetLoginPopupParams {
popup: "login";
}
interface SetSdsSentPopupParams {
popup: "sds-sent";
}
export type SetPageParams =
| SetHomePageParams | SetHomePageParams
| SetSendingStatusPageParams | SetOffPageParams
| SetSdsPageParams | SetStartupPageParams
| SetNewStatusPageParams; | SetVoicecallPageParams;
export type SetPopupParams =
| SetStatusSentPopupParams
| SetSdsSentPopupParams
| SetGroupSelectionPopupParams
| SetSdsReceivedPopupParams
| SetLoginPopupParams;
interface StringifiedData {
sdsText?: string;
sentSdsText?: string;
groupSelectionGroupId?: string;
callTextHeader?: string;
}
interface MrtStore { interface MrtStore {
page: SetPageParams["page"]; page: SetPageParams["page"];
popup?: SetPopupParams["popup"];
lines: DisplayLineProps[]; stringifiedData: StringifiedData;
setStringifiedData: (data: Partial<StringifiedData>) => void;
setPage: (pageData: SetPageParams) => void; setPage: (pageData: SetPageParams) => void;
setLines: (lines: MrtStore["lines"]) => void; setPopup: (popupData: SetPopupParams | null) => void;
// internal
updateIntervall?: number;
nightMode: boolean;
setNightMode: (nightMode: boolean) => void;
} }
export const useMrtStore = create<MrtStore>( export const useMrtStore = create<MrtStore>((set) => ({
syncTabs( page: "off",
(set) => ({ nightMode: false,
page: "home", stringifiedData: {
pageData: { groupSelectionGroupId: "2201",
message: "",
}, },
lines: [ setNightMode: (nightMode) => set({ nightMode }),
{ setStringifiedData: (data) =>
textLeft: "VAR.#", set((state) => ({
textSize: "2", stringifiedData: { ...state.stringifiedData, ...data },
})),
setPopup: (popupData) => {
set({ popup: popupData ? popupData.popup : undefined });
}, },
{
textLeft: "No Data",
textSize: "3",
},
],
setLines: (lines) => set({ lines }),
setPage: (pageData) => { setPage: (pageData) => {
switch (pageData.page) { set({ page: pageData.page });
case "home": {
const { station, fmsStatus } = pageData as SetHomePageParams;
set({
page: "home",
lines: [
{
textLeft: `${station?.bosCallsign}`,
style: { fontWeight: "bold" },
textSize: "2",
}, },
{ textLeft: "ILS VAR#", textSize: "3" }, }));
{
textLeft: fmsStatus,
style: { fontWeight: "extrabold" },
textSize: "4",
},
{
textLeft: fmsStatusDescription[fmsStatus],
textSize: "1",
},
],
});
break;
}
case "sending-status": {
const { station } = pageData as SetSendingStatusPageParams;
set({
page: "sending-status",
lines: [
{
textLeft: `${station?.bosCallsign}`,
style: { fontWeight: "bold" },
textSize: "2",
},
{ textLeft: "ILS VAR#", textSize: "3" },
{
textMid: "sending...",
style: { fontWeight: "bold" },
textSize: "4",
},
{
textLeft: "Status wird gesendet...",
textSize: "1",
},
],
});
break;
}
case "new-status": {
const { station } = pageData as SetNewStatusPageParams;
set({
page: "new-status",
lines: [
{
textLeft: `${station?.bosCallsign}`,
style: { fontWeight: "bold" },
textSize: "2",
},
{ textLeft: "ILS VAR#", textSize: "3" },
{
textLeft: "empfangen",
style: { fontWeight: "bold" },
textSize: "4",
},
],
});
break;
}
case "sds": {
const { sdsMessage } = pageData as SetSdsPageParams;
const msg = sdsMessage.data.message;
set({
page: "sds",
lines: [
{
textLeft: `SDS-Nachricht`,
style: { fontWeight: "bold" },
textSize: "2",
},
{
textLeft: msg,
style: {
whiteSpace: "normal",
overflowWrap: "break-word",
wordBreak: "break-word",
display: "block",
maxWidth: "100%",
maxHeight: "100%",
overflow: "auto",
textOverflow: "ellipsis",
lineHeight: "1.2em",
},
textSize: "2",
},
],
});
break;
}
default:
set({ page: "home" });
break;
}
},
}),
{
name: "mrt-store", // unique name
},
),
);

View File

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

View File

@@ -1,7 +1,6 @@
import { Mission, Station, User } from "@repo/db"; import { Mission, Station, User } from "@repo/db";
import { DisplayLineProps } from "(app)/pilot/_components/dme/Dme"; import { DisplayLineProps } from "(app)/pilot/_components/dme/Dme";
import { create } from "zustand"; import { create } from "zustand";
import { syncTabs } from "zustand-sync-tabs";
interface SetHomePageParams { interface SetHomePageParams {
page: "home"; page: "home";
@@ -36,7 +35,7 @@ type SetPageParams =
interface MrtStore { interface MrtStore {
page: SetPageParams["page"]; page: SetPageParams["page"];
latestMission: Mission | null;
lines: DisplayLineProps[]; lines: DisplayLineProps[];
setPage: (pageData: SetPageParams) => void; setPage: (pageData: SetPageParams) => void;
@@ -45,9 +44,7 @@ interface MrtStore {
let interval: NodeJS.Timeout | null = null; let interval: NodeJS.Timeout | null = null;
export const useDmeStore = create<MrtStore>( export const useDmeStore = create<MrtStore>((set) => ({
syncTabs(
(set) => ({
page: "home", page: "home",
pageData: { pageData: {
message: "", message: "",
@@ -65,6 +62,7 @@ export const useDmeStore = create<MrtStore>(
}, },
], ],
setLines: (lines) => set({ lines }), setLines: (lines) => set({ lines }),
latestMission: null,
setPage: (pageData) => { setPage: (pageData) => {
if (interval) clearInterval(interval); if (interval) clearInterval(interval);
switch (pageData.page) { switch (pageData.page) {
@@ -122,6 +120,7 @@ export const useDmeStore = create<MrtStore>(
} }
case "mission": { case "mission": {
set({ set({
latestMission: pageData.mission,
page: "mission", page: "mission",
lines: [ lines: [
{ {
@@ -231,9 +230,4 @@ export const useDmeStore = create<MrtStore>(
break; 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, ...d,
user: undefined, user: undefined,
publicUser: getPublicUser(d.user), 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 }); if (!session && !payload) return Response.json({ message: "Unauthorized" }, { status: 401 });
const userId = session?.user.id || payload.id; const userId = session?.user.id || payload.id;
const { position, h145 } = (await req.json()) as { const { position, h145, xPlanePluginActive } = (await req.json()) as {
position: PositionLog; position: PositionLog;
h145: boolean; h145: boolean;
xPlanePluginActive: boolean;
}; };
if (!position) { if (!position) {
return Response.json({ message: "Missing id or position" }); return Response.json({ message: "Missing id or position" });
@@ -61,6 +62,7 @@ export const PUT = async (req: Request) => {
posHeading: position.heading, posHeading: position.heading,
posSpeed: position.speed, posSpeed: position.speed,
posH145active: h145, 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 */ src: url("/fonts/MelderV2.ttf") format("truetype"); /* Chrome 4+, Firefox 3.5, Opera 10+, Safari 3—5 */
} }
@font-face {
font-family: "Bahnschrift";
src: url("/fonts/bahnschrift.ttf") format("truetype"); /* Chrome 4+, Firefox 3.5, Opera 10+, Safari 3—5 */
}
@theme { @theme {
--color-rescuetrack: #46b7a3; --color-rescuetrack: #46b7a3;
--color-rescuetrack-highlight: #ff4500; --color-rescuetrack-highlight: #ff4500;

View File

@@ -78,6 +78,15 @@ export const ConnectedDispatcher = () => {
<div>{asPublicUser(d.publicUser).fullName}</div> <div>{asPublicUser(d.publicUser).fullName}</div>
<div className="text-xs font-semibold uppercase opacity-60">{d.zone}</div> <div className="text-xs font-semibold uppercase opacity-60">{d.zone}</div>
</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> <div>
{(() => { {(() => {
const badges = (d.publicUser as unknown as PublicUser).badges const badges = (d.publicUser as unknown as PublicUser).badges

View File

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

View File

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

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 349 KiB

Binary file not shown.

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