dev, Dispatch + Pilot Settings

This commit is contained in:
PxlLoewe
2025-07-24 14:53:40 -07:00
parent 3c5b86eb41
commit 08c4cfe082
9 changed files with 268 additions and 31 deletions

View File

@@ -2,7 +2,7 @@ import { Connection } from "./_components/Connection";
import { Audio } from "../../../../_components/Audio/Audio"; import { Audio } from "../../../../_components/Audio/Audio";
import { ExitIcon, ExternalLinkIcon } from "@radix-ui/react-icons"; import { ExitIcon, ExternalLinkIcon } from "@radix-ui/react-icons";
import Link from "next/link"; import Link from "next/link";
import { Settings } from "_components/navbar/Settings"; import { Settings } from "./_components/Settings";
import AdminPanel from "_components/navbar/AdminPanel"; import AdminPanel from "_components/navbar/AdminPanel";
import { getServerSession } from "api/auth/[...nextauth]/auth"; import { getServerSession } from "api/auth/[...nextauth]/auth";
import { WarningAlert } from "_components/navbar/PageAlert"; import { WarningAlert } from "_components/navbar/PageAlert";
@@ -12,9 +12,9 @@ export default async function Navbar() {
const session = await getServerSession(); const session = await getServerSession();
return ( return (
<div className="navbar bg-base-100 shadow-sm flex gap-5 justify-between"> <div className="navbar bg-base-100 flex justify-between gap-5 shadow-sm">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<p className="normal-case text-xl font-semibold">VAR Leitstelle V2</p> <p className="text-xl font-semibold normal-case">VAR Leitstelle V2</p>
{session?.user.permissions.includes("ADMIN_KICK") && <AdminPanel />} {session?.user.permissions.includes("ADMIN_KICK") && <AdminPanel />}
</div> </div>
<WarningAlert /> <WarningAlert />
@@ -38,12 +38,12 @@ export default async function Navbar() {
rel="noopener noreferrer" rel="noopener noreferrer"
> >
<button className="btn btn-ghost"> <button className="btn btn-ghost">
<ExternalLinkIcon className="w-4 h-4" /> HUB <ExternalLinkIcon className="h-4 w-4" /> HUB
</button> </button>
</Link> </Link>
<Link href={"/logout"}> <Link href={"/logout"}>
<button className="btn btn-ghost"> <button className="btn btn-ghost">
<ExitIcon className="w-4 h-4" /> <ExitIcon className="h-4 w-4" />
</button> </button>
</Link> </Link>
</div> </div>

View File

@@ -0,0 +1,241 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { GearIcon } from "@radix-ui/react-icons";
import { SettingsIcon, Volume2 } from "lucide-react";
import MicVolumeBar from "_components/MicVolumeIndication";
import { useMutation, useQuery } from "@tanstack/react-query";
import { editUserAPI, getUserAPI } from "_querys/user";
import { useSession } from "next-auth/react";
import { useAudioStore } from "_store/audioStore";
import toast from "react-hot-toast";
export const SettingsBtn = () => {
const session = useSession();
const [inputDevices, setInputDevices] = useState<MediaDeviceInfo[]>([]);
const { data: user } = useQuery({
queryKey: ["user", session.data?.user.id],
queryFn: () => getUserAPI(session.data!.user.id),
});
const testSoundRef = useRef<HTMLAudioElement | null>(null);
const editUserMutation = useMutation({
mutationFn: editUserAPI,
});
useEffect(() => {
if (typeof window !== "undefined") {
testSoundRef.current = new Audio("/sounds/DME-new-mission.wav");
}
}, []);
const modalRef = useRef<HTMLDialogElement>(null);
const [showIndication, setShowIndication] = useState<boolean>(false);
const [settings, setSettings] = useState({
micDeviceId: user?.settingsMicDevice || null,
micVolume: user?.settingsMicVolume || 1,
radioVolume: user?.settingsRadioVolume || 0.8,
autoCloseMapPopup: user?.settingsAutoCloseMapPopup || false,
});
const { setSettings: setAudioSettings } = useAudioStore((state) => state);
useEffect(() => {
if (user) {
setAudioSettings({
micDeviceId: user.settingsMicDevice,
micVolume: user.settingsMicVolume || 1,
radioVolume: user.settingsRadioVolume || 0.8,
dmeVolume: user.settingsDmeVolume || 0.8,
});
setSettings({
micDeviceId: user.settingsMicDevice,
micVolume: user.settingsMicVolume || 1,
radioVolume: user.settingsRadioVolume || 0.8,
autoCloseMapPopup: user.settingsAutoCloseMapPopup || false,
});
}
}, [user, setSettings, setAudioSettings]);
const setSettingsPartial = (newSettings: Partial<typeof settings>) => {
setSettings((prev) => ({
...prev,
...newSettings,
}));
};
useEffect(() => {
const setDevices = async () => {
if (typeof navigator !== "undefined" && navigator.mediaDevices?.enumerateDevices) {
const stream = await navigator.mediaDevices.getUserMedia({ video: false, audio: true });
const devices = await navigator.mediaDevices.enumerateDevices();
setInputDevices(devices.filter((d) => d.kind === "audioinput"));
stream.getTracks().forEach((track) => track.stop());
}
};
setDevices();
}, []);
return (
<div>
<button
className="btn btn-ghost"
onSubmit={() => false}
onClick={() => {
modalRef.current?.showModal();
}}
>
<GearIcon className="h-5 w-5" />
</button>
<dialog ref={modalRef} className="modal">
<div className="modal-box">
<h3 className="mb-5 flex items-center gap-2 text-lg font-bold">
<SettingsIcon size={20} /> Einstellungen
</h3>
<div className="flex flex-col items-center justify-center">
<fieldset className="fieldset mb-2 w-full">
<label className="floating-label w-full text-base">
<span>Eingabegerät</span>
<select
className="input w-full"
value={settings.micDeviceId ? settings.micDeviceId : ""}
onChange={(e) => {
setSettingsPartial({ micDeviceId: e.target.value });
setShowIndication(true);
}}
>
<option key={0} value={0} disabled>
Bitte wähle ein Eingabegerät...
</option>
{inputDevices.map((device, index) => (
<option key={index} value={device.deviceId}>
{device.label}
</option>
))}
</select>
</label>
</fieldset>
<p className="mb-2 flex w-full items-center justify-start gap-2 text-base">
<Volume2 size={20} /> Eingabelautstärke
</p>
<div className="w-full">
<input
type="range"
min={0}
max={3}
step={0.01}
onChange={(e) => {
const value = parseFloat(e.target.value);
setSettingsPartial({ micVolume: value });
setShowIndication(true);
}}
value={settings.micVolume}
className="range range-xs range-accent w-full"
/>
<div className="mt-2 flex justify-between px-2.5 text-xs">
<span>0%</span>
<span>25%</span>
<span>50%</span>
<span>75%</span>
<span>100%</span>
</div>
</div>
{showIndication && (
<MicVolumeBar
deviceId={settings.micDeviceId ? settings.micDeviceId : ""}
volumeInput={settings.micVolume}
/>
)}
<div className="divider w-full" />
</div>
<p className="mb-2 flex items-center gap-2 text-base">
<Volume2 size={20} /> Funk Lautstärke
</p>
<div className="mb-2 w-full">
<input
type="range"
min={0}
max={1}
step={0.01}
onChange={(e) => {
const value = parseFloat(e.target.value);
setSettingsPartial({ radioVolume: value });
}}
value={settings.radioVolume}
className="range range-xs range-primary w-full"
/>
<div className="mt-2 flex justify-between px-2.5 text-xs">
<span>0%</span>
<span>25%</span>
<span>50%</span>
<span>75%</span>
<span>100%</span>
</div>
</div>
<div className="flex w-full justify-center">
<div className="divider w-full">
<div>
<legend className="fieldset-legend">Login options</legend>
<label className="label">
<input type="checkbox" defaultChecked className="toggle" />
Remember me
</label>
</div>
</div>
</div>
<div className="modal-action flex justify-between">
<button
className="btn btn-soft"
type="submit"
onSubmit={() => false}
onClick={() => {
modalRef.current?.close();
testSoundRef.current?.pause();
}}
>
Schließen
</button>
<button
className="btn btn-soft btn-success"
type="submit"
onSubmit={() => false}
onClick={async () => {
testSoundRef.current?.pause();
await editUserMutation.mutateAsync({
id: session.data!.user.id,
user: {
settingsMicDevice: settings.micDeviceId,
settingsMicVolume: settings.micVolume,
settingsRadioVolume: settings.radioVolume,
},
});
setAudioSettings({
micDeviceId: settings.micDeviceId,
micVolume: settings.micVolume,
radioVolume: settings.radioVolume,
});
modalRef.current?.close();
toast.success("Einstellungen gespeichert");
}}
>
Speichern
</button>
</div>
</div>
</dialog>
</div>
);
};
export const Settings = () => {
return (
<div>
<SettingsBtn />
</div>
);
};

View File

@@ -2,15 +2,15 @@ import { Connection } from "./_components/Connection";
import { Audio } from "_components/Audio/Audio"; import { Audio } from "_components/Audio/Audio";
import { ExitIcon, ExternalLinkIcon } from "@radix-ui/react-icons"; import { ExitIcon, ExternalLinkIcon } from "@radix-ui/react-icons";
import Link from "next/link"; import Link from "next/link";
import { Settings } from "_components/navbar/Settings"; import { Settings } from "./_components/Settings";
import { WarningAlert } from "_components/navbar/PageAlert"; import { WarningAlert } from "_components/navbar/PageAlert";
import { Radar } from "lucide-react"; import { Radar } from "lucide-react";
export default function Navbar() { export default function Navbar() {
return ( return (
<div className="navbar bg-base-100 shadow-sm flex gap-5 justify-between"> <div className="navbar bg-base-100 flex justify-between gap-5 shadow-sm">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<p className="normal-case text-xl font-semibold">VAR Operations Center</p> <p className="text-xl font-semibold normal-case">VAR Operations Center</p>
</div> </div>
<WarningAlert /> <WarningAlert />
<div className="flex items-center gap-5"> <div className="flex items-center gap-5">
@@ -33,12 +33,12 @@ export default function Navbar() {
rel="noopener noreferrer" rel="noopener noreferrer"
> >
<button className="btn btn-ghost"> <button className="btn btn-ghost">
<ExternalLinkIcon className="w-4 h-4" /> HUB <ExternalLinkIcon className="h-4 w-4" /> HUB
</button> </button>
</Link> </Link>
<Link href={"/logout"}> <Link href={"/logout"}>
<button className="btn btn-ghost"> <button className="btn btn-ghost">
<ExitIcon className="w-4 h-4" /> <ExitIcon className="h-4 w-4" />
</button> </button>
</Link> </Link>
</div> </div>

View File

@@ -216,7 +216,7 @@ export const MarkerCluster = () => {
const lng = aircraft.posLng!; const lng = aircraft.posLng!;
const existingClusterIndex = newCluster.findIndex( const existingClusterIndex = newCluster.findIndex(
(c) => Math.abs(c.lat - lat) < 1 && Math.abs(c.lng - lng) < 1, (c) => Math.abs(c.lat - lat) < 1.55 && Math.abs(c.lng - lng) < 1,
); );
const existingCluster = newCluster[existingClusterIndex]; const existingCluster = newCluster[existingClusterIndex];
if (existingCluster) { if (existingCluster) {

View File

@@ -5,13 +5,14 @@ import { Button } from "@repo/shared-components";
import MDEditor from "@uiw/react-md-editor"; import MDEditor from "@uiw/react-md-editor";
import { RefreshCw } from "lucide-react"; import { RefreshCw } from "lucide-react";
import { updateChangelogAck } from "./ChangelogActions"; import { updateChangelogAck } from "./ChangelogActions";
import { Changelog } from "@repo/db";
export const ChangelogModal = ({ export const ChangelogModal = ({
latestChangelog, latestChangelog,
isOpen, isOpen,
onClose, onClose,
}: { }: {
latestChangelog: { title: string; text: string; previewImage: string } | null; latestChangelog: Changelog | null;
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
}) => { }) => {
@@ -79,11 +80,7 @@ export const ChangelogModal = ({
); );
}; };
export const ChangelogBtn = ({ export const ChangelogBtn = ({ latestChangelog }: { latestChangelog: Changelog | null }) => {
latestChangelog,
}: {
latestChangelog: { title: string; text: string; previewImage: string } | null;
}) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
if (!latestChangelog) return null; if (!latestChangelog) return null;
@@ -109,7 +106,7 @@ export const ChangelogBtn = ({
export const OpenChangelogOnPageload = ({ export const OpenChangelogOnPageload = ({
latestChangelog, latestChangelog,
}: { }: {
latestChangelog: { title: string; text: string; previewImage: string } | null; latestChangelog: Changelog | null;
}) => { }) => {
const [isOpen, setIsOpen] = useState(true); const [isOpen, setIsOpen] = useState(true);

View File

@@ -12,11 +12,7 @@ export async function getLatestChangelog() {
}); });
if (latestChangelog.length > 0 && latestChangelog[0]) { if (latestChangelog.length > 0 && latestChangelog[0]) {
return { return latestChangelog[0];
title: latestChangelog[0].title,
text: latestChangelog[0].text,
previewImage: latestChangelog[0].previewImage || "",
};
} }
return null; return null;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "settings_auto_close_map_popup" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -37,14 +37,15 @@ model User {
changelogAck Boolean @default(false) changelogAck Boolean @default(false)
// Settings: // Settings:
pathSelected Boolean @default(false) pathSelected Boolean @default(false)
migratedFromV1 Boolean @default(false) migratedFromV1 Boolean @default(false)
settingsNtfyRoom String? @map(name: "settings_ntfy_room") settingsNtfyRoom String? @map(name: "settings_ntfy_room")
settingsMicDevice String? @map(name: "settings_mic_device") settingsMicDevice String? @map(name: "settings_mic_device")
settingsMicVolume Float? @map(name: "settings_mic_volume") settingsMicVolume Float? @map(name: "settings_mic_volume")
settingsDmeVolume Float? @map(name: "settings_dme_volume") settingsDmeVolume Float? @map(name: "settings_dme_volume")
settingsRadioVolume Float? @map(name: "settings_funk_volume") settingsRadioVolume Float? @map(name: "settings_funk_volume")
settingsHideLastname Boolean @default(false) @map(name: "settings_hide_lastname") settingsHideLastname Boolean @default(false) @map(name: "settings_hide_lastname")
settingsAutoCloseMapPopup Boolean @default(false) @map(name: "settings_auto_close_map_popup")
// email Verification: // email Verification:
emailVerificationToken String? @map(name: "email_verification_token") emailVerificationToken String? @map(name: "email_verification_token")