1 Commits

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

View File

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

View File

@@ -1,7 +1,6 @@
import { DISCORD_ROLES, MissionLog, NotificationPayload, prisma } from "@repo/db";
import { MissionLog, NotificationPayload, prisma } from "@repo/db";
import { io } from "index";
import cron from "node-cron";
import { changeMemberRoles } from "routes/member";
const removeMission = async (id: number, reason: string) => {
const log: MissionLog = {
@@ -35,6 +34,7 @@ const removeMission = async (id: number, reason: string) => {
console.log(`Mission ${updatedMission.id} closed due to inactivity.`);
};
const removeClosedMissions = async () => {
const oldMissions = await prisma.mission.findMany({
where: {
@@ -140,57 +140,6 @@ const removeConnectedAircrafts = async () => {
}
});
};
const removePermissionsForBannedUsers = async () => {
const activePenalties = await prisma.penalty.findMany({
where: {
OR: [
{
type: "BAN",
suspended: false,
},
{
type: "TIME_BAN",
suspended: false,
until: {
gt: new Date().toISOString(),
},
},
],
},
include: {
User: {
include: {
DiscordAccount: true,
FormerDiscordAccounts: true,
},
},
},
});
for (const penalty of activePenalties) {
const user = penalty.User;
if (user.DiscordAccount) {
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",
);
}
}
};
cron.schedule("*/5 * * * *", async () => {
await removePermissionsForBannedUsers();
});
cron.schedule("*/1 * * * *", async () => {
try {

View File

@@ -32,25 +32,6 @@ router.post("/set-standard-name", async (req, res) => {
},
});
const activePenaltys = await prisma.penalty.findMany({
where: {
userId: user.id,
OR: [
{
type: "BAN",
suspended: false,
},
{
type: "TIME_BAN",
suspended: false,
until: {
gt: new Date().toISOString(),
},
},
],
},
});
participant.forEach(async (p) => {
if (!p.Event.discordRoleId) return;
if (eventCompleted(p.Event, p)) {
@@ -67,12 +48,8 @@ router.post("/set-standard-name", async (req, res) => {
const isPilot = user.permissions.includes("PILOT");
const isDispatcher = user.permissions.includes("DISPO");
if (activePenaltys.length > 0) {
await changeMemberRoles(memberId, [DISCORD_ROLES.PILOT, DISCORD_ROLES.DISPATCHER], "remove");
} else {
await changeMemberRoles(memberId, [DISCORD_ROLES.PILOT], isPilot ? "add" : "remove");
await changeMemberRoles(memberId, [DISCORD_ROLES.DISPATCHER], isDispatcher ? "add" : "remove");
}
await changeMemberRoles(memberId, [DISCORD_ROLES.PILOT], isPilot ? "add" : "remove");
await changeMemberRoles(memberId, [DISCORD_ROLES.DISPATCHER], isDispatcher ? "add" : "remove");
});
export default router;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -23,7 +23,7 @@ const Map = dynamic(() => import("_components/map/Map"), {
});
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
const { data: aircrafts } = useQuery({

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@ import { Marker, Polyline, useMap } from "react-leaflet";
import { DivIcon, Marker as LMarker, Popup as LPopup } from "leaflet";
import { useMapStore } from "_store/mapStore";
import { Fragment, useCallback, useEffect, useRef, useState, useMemo } from "react";
import { checkSimulatorConnected, cn } from "@repo/shared-components";
import { cn } from "@repo/shared-components";
import { ChevronsRightLeft, House, MessageSquareText, Minimize2 } from "lucide-react";
import { SmartPopup, calculateAnchor, useSmartPopup } from "_components/SmartPopup";
import FMSStatusHistory, {
@@ -396,27 +396,11 @@ const AircraftMarker = ({ aircraft }: { aircraft: ConnectedAircraft & { Station:
};
export const AircraftLayer = () => {
const [aircrafts, setAircrafts] = useState<(ConnectedAircraft & { Station: Station })[]>([]);
useEffect(() => {
const fetchAircrafts = async () => {
try {
const res = await fetch("/api/aircrafts");
if (!res.ok) {
throw new Error("Failed to fetch aircrafts");
}
const data: (ConnectedAircraft & { Station: Station })[] = await res.json();
setAircrafts(data.filter((a) => checkSimulatorConnected(a)));
} catch (error) {
console.error("Failed to fetch aircrafts:", error);
}
};
fetchAircrafts();
const interval = setInterval(fetchAircrafts, 10_000);
return () => clearInterval(interval);
}, []);
const { data: aircrafts } = useQuery({
queryKey: ["aircrafts"],
queryFn: () => getConnectedAircraftsAPI(),
refetchInterval: 10_000,
});
const { setMap } = useMapStore((state) => state);
const map = useMap();
const {
@@ -450,10 +434,8 @@ export const AircraftLayer = () => {
}
}, [pilotConnectionStatus, followOwnAircraft, ownAircraft, setMap, map]);
console.debug("Hubschrauber auf Karte:", {
total: aircrafts?.length,
displayed: filteredAircrafts.length,
});
console.debug("Hubschrauber auf Karte:", filteredAircrafts.length, filteredAircrafts);
console.debug("Daten vom Server:", aircrafts?.length, aircrafts);
return (
<>

View File

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

View File

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

View File

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

View File

@@ -296,12 +296,6 @@ const StationTab = ({ aircraft }: { aircraft: ConnectedAircraft & { Station: Sta
{aircraft.posH145active ? "H145 Aktiv" : "H145 Inaktiv"}
</span>
</span>
<span className="flex items-center gap-2">
<Lollipop size={16} />{" "}
<span className={cn(aircraft.posXplanePluginActive && "text-green-500")}>
{aircraft.posXplanePluginActive ? "X-Plane Plugin Aktiv" : "X-Plane Plugin Inaktiv"}
</span>
</span>
</div>
</div>
);

View File

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

View File

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

View File

@@ -1,7 +1,6 @@
import { create } from "zustand";
import { dispatchSocket } from "../../(app)/dispatch/socket";
import { ConnectedAircraft, Mission, MissionSdsLog, Station, User } from "@repo/db";
import { showToast } from "../../_components/customToasts/HPGnotValidated";
import { pilotSocket } from "(app)/pilot/socket";
import { useDmeStore } from "_store/pilot/dmeStore";
import { useMrtStore } from "_store/pilot/MrtStore";
@@ -133,12 +132,6 @@ pilotSocket.on("mission-alert", (data: Mission & { Stations: Station[] }) => {
useDmeStore.getState().setPage({
page: "new-mission",
});
if (
data.hpgValidationState === "NOT_VALIDATED" &&
usePilotConnectionStore.getState().connectedAircraft?.posH145active
) {
showToast();
}
});
pilotSocket.on("sds-message", (sdsMessage: MissionSdsLog) => {

View File

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

View File

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

View File

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

View File

@@ -44,7 +44,7 @@
"livekit-client": "^2.15.3",
"livekit-server-sdk": "^2.13.1",
"lucide-react": "^0.525.0",
"next": "^15.4.8",
"next": "^15.4.2",
"next-auth": "^4.24.11",
"npm": "^11.4.2",
"postcss": "^8.5.6",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 349 KiB

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
"use client";
import { Mission, MissionAlertLog, MissionLog, Prisma, Station } from "@repo/db";
import { Mission, MissionAlertLog, MissionLog, Station } from "@repo/db";
import { ColumnDef } from "@tanstack/react-table";
import { PaginatedTable } from "_components/PaginatedTable";
import { ArrowRight, NotebookText } from "lucide-react";
@@ -12,22 +12,20 @@ export const RecentFlights = () => {
<div className="card-body">
<h2 className="card-title justify-between">
<span className="card-title">
<NotebookText className="h-4 w-4" /> Logbook
<NotebookText className="w-4 h-4" /> Logbook
</span>
<Link className="badge badge-sm badge-info badge-outline" href="/logbook">
Zum vollständigen Logbook <ArrowRight className="h-4 w-4" />
Zum vollständigen Logbook <ArrowRight className="w-4 h-4" />
</Link>
</h2>
<PaginatedTable
prismaModel={"missionOnStationUsers"}
getFilter={() =>
({
User: { id: session.data?.user.id },
Mission: {
state: { in: ["finished"] },
},
}) as Prisma.MissionOnStationUsersWhereInput
}
filter={{
userId: session.data?.user?.id ?? "",
Mission: {
state: "finished",
},
}}
include={{
Station: true,
User: true,

View File

@@ -23,7 +23,6 @@ export const ChangelogForm = ({ changelog }: { changelog?: Changelog }) => {
title: changelog?.title || "",
text: changelog?.text || "",
previewImage: changelog?.previewImage || "", // Changed to accept a URL as a string
showOnChangelogPage: changelog?.showOnChangelogPage || true,
},
});
const [skipUserUpdate, setSkipUserUpdate] = useState(false);
@@ -85,7 +84,6 @@ export const ChangelogForm = ({ changelog }: { changelog?: Changelog }) => {
placeholder="Titel (vX.X.X)"
className="input-sm"
/>
<Input
form={form}
label="Bild-URL"
@@ -148,16 +146,6 @@ export const ChangelogForm = ({ changelog }: { changelog?: Changelog }) => {
</span>
</label>
)}
<label className="label mx-6 mt-6 w-full cursor-pointer">
<input
type="checkbox"
className={cn("toggle")}
{...form.register("showOnChangelogPage", {})}
/>
<span className={cn("label-text w-full text-left")}>
Auf der Changelog-Seite anzeigen
</span>
</label>
<div className="card-body">
<div className="flex w-full gap-4">
<Button

View File

@@ -1,42 +1,24 @@
"use client";
import { Check, Cross, DatabaseBackupIcon } from "lucide-react";
import { DatabaseBackupIcon } from "lucide-react";
import { PaginatedTable } from "../../../_components/PaginatedTable";
import Link from "next/link";
import { ColumnDef } from "@tanstack/react-table";
import { Changelog, Keyword, Prisma } from "@repo/db";
import { Keyword } from "@repo/db";
export default () => {
return (
<>
<PaginatedTable
stickyHeaders
initialOrderBy={[{ id: "createdAt", desc: true }]}
initialOrderBy={[{ id: "title", desc: true }]}
prismaModel="changelog"
showSearch
getFilter={(search) =>
({
OR: [
{ title: { contains: search, mode: "insensitive" } },
{ text: { contains: search, mode: "insensitive" } },
],
}) as Prisma.ChangelogWhereInput
}
searchFields={["title"]}
columns={
[
{
header: "Title",
accessorKey: "title",
},
{
header: "Auf Changelog Seite anzeigen",
accessorKey: "showOnChangelogPage",
cell: ({ row }) => (row.original.showOnChangelogPage ? <Check /> : <Cross />),
},
{
header: "Erstellt am",
accessorKey: "createdAt",
cell: ({ row }) => new Date(row.original.createdAt).toLocaleDateString(),
},
{
header: "Aktionen",
cell: ({ row }) => (
@@ -47,7 +29,7 @@ export default () => {
</div>
),
},
] as ColumnDef<Changelog>[]
] as ColumnDef<Keyword>[]
}
leftOfSearch={
<span className="flex items-center gap-2">

View File

@@ -1,4 +1,4 @@
import { Event, Participant, Prisma } from "@repo/db";
import { Event, Participant } from "@repo/db";
import { EventAppointmentOptionalDefaults, InputJsonValueType } from "@repo/db/zod";
import { ColumnDef } from "@tanstack/react-table";
import { useSession } from "next-auth/react";
@@ -64,7 +64,7 @@ export const AppointmentModal = ({
</div>
<div>
<PaginatedTable
supressQuery={appointmentForm.watch("id") === undefined}
hide={appointmentForm.watch("id") === undefined}
ref={participantTableRef}
columns={
[
@@ -167,11 +167,9 @@ export const AppointmentModal = ({
] as ColumnDef<Participant>[]
}
prismaModel={"participant"}
getFilter={() =>
({
eventAppointmentId: appointmentForm.watch("id")!,
}) as Prisma.ParticipantWhereInput
}
filter={{
eventAppointmentId: appointmentForm.watch("id"),
}}
include={{ User: true }}
leftOfPagination={
<div className="flex gap-2">

View File

@@ -1,6 +1,6 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { BADGES, Event, EVENT_TYPE, Participant, PERMISSION, Prisma, User } from "@repo/db";
import { BADGES, Event, EVENT_TYPE, Participant, PERMISSION, User } from "@repo/db";
import {
EventAppointmentOptionalDefaults,
EventAppointmentOptionalDefaultsSchema,
@@ -159,11 +159,9 @@ export const Form = ({ event }: { event?: Event }) => {
<PaginatedTable
ref={appointmentsTableRef}
prismaModel={"eventAppointment"}
getFilter={() =>
({
eventId: event?.id,
}) as Prisma.EventAppointmentWhereInput
}
filter={{
eventId: event?.id,
}}
include={{
Presenter: true,
Participants: true,
@@ -252,107 +250,92 @@ export const Form = ({ event }: { event?: Event }) => {
{!form.watch("hasPresenceEvents") ? (
<div className="card bg-base-200 col-span-6 shadow-xl">
<div className="card-body">
{
<PaginatedTable
leftOfSearch={
<h2 className="card-title">
<UserIcon className="h-5 w-5" /> Teilnehmer
</h2>
}
ref={appointmentsTableRef}
prismaModel={"participant"}
showSearch
getFilter={(searchTerm) =>
({
OR: [
{
User: {
OR: [
{ firstname: { contains: searchTerm, mode: "insensitive" } },
{ lastname: { contains: searchTerm, mode: "insensitive" } },
{ publicId: { contains: searchTerm, mode: "insensitive" } },
],
},
},
],
}) as Prisma.ParticipantWhereInput
}
include={{
User: true,
}}
supressQuery={!event}
columns={
[
{
header: "Vorname",
accessorKey: "User.firstname",
cell: ({ row }) => {
return (
<Link
className="hover:underline"
href={`/admin/user/${row.original.User.id}`}
<PaginatedTable
leftOfSearch={
<h2 className="card-title">
<UserIcon className="h-5 w-5" /> Teilnehmer
</h2>
}
searchFields={["User.firstname", "User.lastname", "User.publicId"]}
ref={appointmentsTableRef}
prismaModel={"participant"}
filter={{
eventId: event?.id,
}}
include={{
User: true,
}}
columns={
[
{
header: "Vorname",
accessorKey: "User.firstname",
cell: ({ row }) => {
return (
<Link
className="hover:underline"
href={`/admin/user/${row.original.User.id}`}
>
{row.original.User.firstname}
</Link>
);
},
},
{
header: "Nachname",
accessorKey: "User.lastname",
cell: ({ row }) => {
return (
<Link
className="hover:underline"
href={`/admin/user/${row.original.User.id}`}
>
{row.original.User.lastname}
</Link>
);
},
},
{
header: "VAR-Nummer",
accessorKey: "User.publicId",
cell: ({ row }) => {
return (
<Link
className="hover:underline"
href={`/admin/user/${row.original.User.id}`}
>
{row.original.User.publicId}
</Link>
);
},
},
{
header: "Moodle Kurs abgeschlossen",
accessorKey: "finisherMoodleCurseCompleted",
},
{
header: "Aktionen",
cell: ({ row }) => {
return (
<div className="flex gap-2">
<button
onSubmit={() => false}
type="button"
onClick={() => {
participantForm.reset(row.original);
participantModal.current?.showModal();
}}
className="btn btn-sm btn-outline"
>
{row.original.User.firstname}
</Link>
);
},
Bearbeiten
</button>
</div>
);
},
{
header: "Nachname",
accessorKey: "User.lastname",
cell: ({ row }) => {
return (
<Link
className="hover:underline"
href={`/admin/user/${row.original.User.id}`}
>
{row.original.User.lastname}
</Link>
);
},
},
{
header: "VAR-Nummer",
accessorKey: "User.publicId",
cell: ({ row }) => {
return (
<Link
className="hover:underline"
href={`/admin/user/${row.original.User.id}`}
>
{row.original.User.publicId}
</Link>
);
},
},
{
header: "Moodle Kurs abgeschlossen",
accessorKey: "finisherMoodleCurseCompleted",
},
{
header: "Aktionen",
cell: ({ row }) => {
return (
<div className="flex gap-2">
<button
onSubmit={() => false}
type="button"
onClick={() => {
participantForm.reset(row.original);
participantModal.current?.showModal();
}}
className="btn btn-sm btn-outline"
>
Bearbeiten
</button>
</div>
);
},
},
] as ColumnDef<Participant & { User: User }>[]
}
/>
}
},
] as ColumnDef<Participant & { User: User }>[]
}
/>
</div>
</div>
) : null}

View File

@@ -3,7 +3,7 @@ import { DatabaseBackupIcon } from "lucide-react";
import { PaginatedTable } from "../../../_components/PaginatedTable";
import Link from "next/link";
import { ColumnDef } from "@tanstack/react-table";
import { Heliport, Prisma } from "@repo/db";
import { Heliport } from "@repo/db";
const page = () => {
return (
@@ -11,17 +11,7 @@ const page = () => {
<PaginatedTable
stickyHeaders
prismaModel="heliport"
getFilter={(searchTerm) =>
({
OR: [
{ siteName: { contains: searchTerm, mode: "insensitive" } },
{ info: { contains: searchTerm, mode: "insensitive" } },
{ hospital: { contains: searchTerm, mode: "insensitive" } },
{ designator: { contains: searchTerm, mode: "insensitive" } },
],
}) as Prisma.HeliportWhereInput
}
showSearch
searchFields={["siteName", "info", "hospital", "designator"]}
columns={
[
{

View File

@@ -3,7 +3,7 @@ import { DatabaseBackupIcon } from "lucide-react";
import { PaginatedTable } from "../../../_components/PaginatedTable";
import Link from "next/link";
import { ColumnDef } from "@tanstack/react-table";
import { Keyword, Prisma } from "@repo/db";
import { Keyword } from "@repo/db";
export default () => {
return (
@@ -12,16 +12,7 @@ export default () => {
stickyHeaders
initialOrderBy={[{ id: "category", desc: true }]}
prismaModel="keyword"
showSearch
getFilter={(searchTerm) =>
({
OR: [
{ name: { contains: searchTerm, mode: "insensitive" } },
{ abreviation: { contains: searchTerm, mode: "insensitive" } },
{ category: { contains: searchTerm, mode: "insensitive" } },
],
}) as Prisma.KeywordWhereInput
}
searchFields={["name", "abreviation", "description"]}
columns={
[
{
@@ -50,11 +41,11 @@ export default () => {
}
leftOfSearch={
<span className="flex items-center gap-2">
<DatabaseBackupIcon className="h-5 w-5" /> Stichwörter
<DatabaseBackupIcon className="w-5 h-5" /> Stichwörter
</span>
}
rightOfSearch={
<p className="flex items-center justify-between gap-2 text-left text-2xl font-semibold">
<p className="text-2xl font-semibold text-left flex items-center gap-2 justify-between">
<Link href={"/admin/keyword/new"}>
<button className="btn btn-sm btn-outline btn-primary">Erstellen</button>
</Link>

View File

@@ -2,7 +2,7 @@
import { penaltyColumns as penaltyColumns } from "(app)/admin/penalty/columns";
import { editReport } from "(app)/admin/report/actions";
import { zodResolver } from "@hookform/resolvers/zod";
import { Report as IReport, Prisma, User } from "@repo/db";
import { Report as IReport, User } from "@repo/db";
import { ReportSchema, Report as IReportZod } from "@repo/db/zod";
import { PaginatedTable } from "_components/PaginatedTable";
import { Button } from "_components/ui/Button";
@@ -149,11 +149,9 @@ export const ReportPenalties = ({
CreatedUser: true,
Report: true,
}}
getFilter={() =>
({
reportId: report.id,
}) as Prisma.PenaltyWhereInput
}
filter={{
reportId: report.id,
}}
columns={penaltyColumns}
/>
</div>

View File

@@ -2,7 +2,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { StationOptionalDefaultsSchema } from "@repo/db/zod";
import { useForm } from "react-hook-form";
import { BosUse, ConnectedAircraft, Country, Prisma, Station, User } from "@repo/db";
import { BosUse, ConnectedAircraft, Country, Station, User } from "@repo/db";
import { FileText, LocateIcon, PlaneIcon, UserIcon } from "lucide-react";
import { Input } from "../../../../_components/ui/Input";
import { deleteStation, upsertStation } from "../action";
@@ -198,17 +198,10 @@ export const StationForm = ({ station }: { station?: Station }) => {
Verbundene Piloten
</div>
}
getFilter={(searchField) =>
({
stationId: station?.id,
OR: [
{ User: { firstname: { contains: searchField, mode: "insensitive" } } },
{ User: { lastname: { contains: searchField, mode: "insensitive" } } },
{ User: { publicId: { contains: searchField, mode: "insensitive" } } },
],
}) as Prisma.ConnectedAircraftWhereInput
}
showSearch
filter={{
stationId: station?.id,
}}
searchFields={["User.firstname", "User.lastname", "User.publicId"]}
prismaModel={"connectedAircraft"}
include={{ Station: true, User: true }}
columns={

View File

@@ -3,22 +3,14 @@ import { DatabaseBackupIcon } from "lucide-react";
import { PaginatedTable } from "../../../_components/PaginatedTable";
import Link from "next/link";
import { ColumnDef } from "@tanstack/react-table";
import { Prisma, Station } from "@repo/db";
import { Station } from "@repo/db";
const page = () => {
return (
<>
<PaginatedTable
prismaModel="station"
showSearch
getFilter={(searchField) =>
({
OR: [
{ bosCallsign: { contains: searchField, mode: "insensitive" } },
{ operator: { contains: searchField, mode: "insensitive" } },
],
}) as Prisma.StationWhereInput
}
searchFields={["bosCallsign", "operator"]}
stickyHeaders
columns={
[
@@ -52,11 +44,11 @@ const page = () => {
}
leftOfSearch={
<span className="flex items-center gap-2">
<DatabaseBackupIcon className="h-5 w-5" /> Stationen
<DatabaseBackupIcon className="w-5 h-5" /> Stationen
</span>
}
rightOfSearch={
<p className="flex items-center justify-between gap-2 text-left text-2xl font-semibold">
<p className="text-2xl font-semibold text-left flex items-center gap-2 justify-between">
<Link href={"/admin/station/new"}>
<button className="btn btn-sm btn-outline btn-primary">Erstellen</button>
</Link>

View File

@@ -6,10 +6,8 @@ import {
ConnectedAircraft,
ConnectedDispatcher,
DiscordAccount,
FormerDiscordAccount,
Penalty,
PERMISSION,
Prisma,
Station,
User,
} from "@repo/db";
@@ -61,7 +59,6 @@ import { penaltyColumns } from "(app)/admin/penalty/columns";
import { addPenalty, editPenaltys } from "(app)/admin/penalty/actions";
import { reportColumns } from "(app)/admin/report/columns";
import { sendMailByTemplate } from "../../../../../../helper/mail";
import Image from "next/image";
interface ProfileFormProps {
user: User;
@@ -79,21 +76,6 @@ export const ProfileForm: React.FC<ProfileFormProps> = ({ user }: ProfileFormPro
className="card-body"
onSubmit={form.handleSubmit(async (values) => {
if (!values.id) return;
if (values.id === session.data?.user.id && values.permissions !== user.permissions) {
toast.error("Du kannst deine eigenen Berechtigungen nicht ändern.");
return;
}
if (values.permissions?.some((perm) => !session.data?.user.permissions.includes(perm))) {
toast.error("Du kannst Berechtigungen nicht hinzufügen, die du selbst nicht besitzt.");
return;
}
const removedPermissions =
user.permissions?.filter((perm) => !values.permissions?.includes(perm)) || [];
if (removedPermissions.some((perm) => !session.data?.user.permissions.includes(perm))) {
toast.error("Du kannst Berechtigungen nicht entfernen, die du selbst nicht besitzt.");
return;
}
await editUser(values.id, {
...values,
email: values.email.toLowerCase(),
@@ -284,18 +266,10 @@ export const ConnectionHistory: React.FC<{ user: User }> = ({ user }: { user: Us
</h2>
<PaginatedTable
ref={dispoTableRef}
getFilter={() =>
({
userId: user.id,
}) as Prisma.ConnectedDispatcherWhereInput
}
filter={{
userId: user.id,
}}
prismaModel={"connectedDispatcher"}
initialOrderBy={[
{
id: "loginTime",
desc: true,
},
]}
columns={
[
{
@@ -354,19 +328,11 @@ export const ConnectionHistory: React.FC<{ user: User }> = ({ user }: { user: Us
</h2>
<PaginatedTable
ref={pilotTableRef}
getFilter={() =>
({
userId: user.id,
}) as Prisma.ConnectedAircraftWhereInput
}
filter={{
userId: user.id,
}}
prismaModel={"connectedAircraft"}
include={{ Station: true }}
initialOrderBy={[
{
id: "loginTime",
desc: true,
},
]}
columns={
[
{
@@ -512,7 +478,9 @@ export const UserPenalties = ({ user }: { user: User }) => {
CreatedUser: true,
Report: true,
}}
getFilter={() => ({ userId: user.id }) as Prisma.PenaltyWhereInput}
filter={{
userId: user.id,
}}
columns={penaltyColumns}
/>
</div>
@@ -534,17 +502,9 @@ export const UserReports = ({ user }: { user: User }) => {
</div>
<PaginatedTable
prismaModel="report"
getFilter={() =>
({
reportedUserId: user.id,
}) as Prisma.ReportWhereInput
}
initialOrderBy={[
{
id: "timestamp",
desc: true,
},
]}
filter={{
reportedUserId: user.id,
}}
include={{
Sender: true,
Reported: true,
@@ -557,7 +517,7 @@ export const UserReports = ({ user }: { user: User }) => {
interface AdminFormProps {
discordAccount?: DiscordAccount;
user: User & { CanonicalUser?: User | null; Duplicates?: User[] | null };
user: User;
dispoTime: {
hours: number;
minutes: number;
@@ -568,7 +528,6 @@ interface AdminFormProps {
minutes: number;
lastLogin?: Date;
};
formerDiscordAccounts: (FormerDiscordAccount & { DiscordAccount: DiscordAccount | null })[];
reports: {
total: number;
open: number;
@@ -588,7 +547,6 @@ export const AdminForm = ({
pilotTime,
reports,
discordAccount,
formerDiscordAccounts,
openBans,
openTimebans,
}: AdminFormProps) => {
@@ -681,57 +639,7 @@ export const AdminForm = ({
</div>
)}
</div>
{session?.user.permissions.includes("ADMIN_USER_ADVANCED") && (
<div className="mt-2 space-y-1">
<Link href={`/admin/user/${user.id}/duplicate`}>
<Button className="btn-sm btn-outline w-full">
<LockKeyhole className="h-4 w-4" /> Duplikat markieren & sperren
</Button>
</Link>
</div>
)}
</div>
{(user.CanonicalUser || (user.Duplicates && user.Duplicates.length > 0)) && (
<div role="alert" className="alert alert-error alert-outline flex flex-col">
<div className="flex items-center gap-2">
<TriangleAlert />
<div>
{user.CanonicalUser && (
<div>
<h3 className="text-lg font-semibold">Als Duplikat markiert</h3>
<p>
Dieser Account wurde als Duplikat von{" "}
<Link
href={`/admin/user/${user.CanonicalUser.id}`}
className="link link-hover font-semibold"
>
{user.CanonicalUser.firstname} {user.CanonicalUser.lastname} (
{user.CanonicalUser.publicId})
</Link>{" "}
markiert.
</p>
</div>
)}
{user.Duplicates && user.Duplicates.length > 0 && (
<div className="mt-2">
<h3 className="text-lg font-semibold">Duplikate erkannt</h3>
<p>Folgende Accounts wurden als Duplikate dieses Accounts markiert:</p>
<ul className="ml-4 mt-1 list-inside list-disc">
{user.Duplicates.map((duplicate) => (
<li key={duplicate.id}>
<Link href={`/admin/user/${duplicate.id}`} className="link link-hover">
{duplicate.firstname} {duplicate.lastname} ({duplicate.publicId})
</Link>
</li>
))}
</ul>
</div>
)}
</div>
</div>
<p className="text-sm text-gray-400">{user.duplicateReason || "Keine Grund angegeben"}</p>
</div>
)}
{(!!openBans.length || !!openTimebans.length) && (
<div role="alert" className="alert alert-warning alert-outline flex flex-col">
<div className="flex items-center gap-2">
@@ -759,65 +667,6 @@ export const AdminForm = ({
</p>
</div>
)}
<h2 className="card-title mt-2">
<DiscordLogoIcon className="h-5 w-5" /> Frühere Discord Accounts
</h2>
<div className="overflow-x-auto">
<table className="table-sm table">
<thead>
<tr>
<th>Avatar</th>
<th>Benutzername</th>
<th>Discord ID</th>
<th>getrennt am</th>
</tr>
</thead>
<tbody>
{discordAccount && (
<tr>
<td>
<Image
src={`https://cdn.discordapp.com/avatars/${discordAccount.discordId}/${discordAccount.avatar}.png`}
alt="Discord Avatar"
width={40}
height={40}
className="h-10 w-10 rounded-full"
/>
</td>
<td>{discordAccount.username}</td>
<td>{discordAccount.discordId}</td>
<td>N/A (Aktuell verbunden)</td>
</tr>
)}
{formerDiscordAccounts.map((account) => (
<tr key={account.discordId}>
<td>
{account.DiscordAccount && (
<Image
src={`https://cdn.discordapp.com/avatars/${account.DiscordAccount.discordId}/${account.DiscordAccount.avatar}.png`}
alt="Discord Avatar"
width={40}
height={40}
className="h-10 w-10 rounded-full"
/>
)}
</td>
<td>{account.DiscordAccount?.username || "Unbekannt"}</td>
<td>{account.DiscordAccount?.discordId || "Unbekannt"}</td>
<td>{new Date(account.removedAt).toLocaleDateString()}</td>
</tr>
))}
{!discordAccount && formerDiscordAccounts.length === 0 && (
<tr>
<td colSpan={3} className="text-center text-gray-400">
Keine Discord Accounts verknüpft
</td>
</tr>
)}
</tbody>
</table>
</div>
<h2 className="card-title">
<ChartBarBigIcon className="h-5 w-5" /> Aktivität
</h2>

View File

@@ -1,109 +0,0 @@
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useQuery } from "@tanstack/react-query";
import { getUser, markDuplicate } from "(app)/admin/user/action";
import { Button } from "@repo/shared-components";
import { Select } from "_components/ui/Select";
import toast from "react-hot-toast";
import { TriangleAlert } from "lucide-react";
import { useState } from "react";
const DuplicateSchema = z.object({
canonicalUserId: z.string().min(1, "Bitte Nutzer auswählen"),
reason: z.string().max(500).optional(),
});
export const DuplicateForm = ({ duplicateUserId }: { duplicateUserId: string }) => {
const form = useForm<z.infer<typeof DuplicateSchema>>({
resolver: zodResolver(DuplicateSchema),
defaultValues: { canonicalUserId: "", reason: "" },
});
const [search, setSearch] = useState("");
const { data: users } = useQuery({
queryKey: ["duplicate-search"],
queryFn: async () =>
getUser({
OR: [
{ firstname: { contains: search, mode: "insensitive" } },
{ lastname: { contains: search, mode: "insensitive" } },
{ publicId: { contains: search, mode: "insensitive" } },
{ email: { contains: search, mode: "insensitive" } },
],
}),
enabled: search.length > 0,
refetchOnWindowFocus: false,
});
return (
<form
className="flex flex-wrap gap-3"
onSubmit={form.handleSubmit(async (values) => {
try {
// find selected canonical user by id to obtain publicId
const canonical = (users || []).find((u) => u.id === values.canonicalUserId);
if (!canonical) {
toast.error("Bitte wähle einen Original-Account aus.");
return;
}
await markDuplicate({
duplicateUserId,
canonicalPublicId: canonical.publicId,
reason: values.reason,
});
toast.success("Duplikat verknüpft und Nutzer gesperrt.");
} catch (e: unknown) {
const message =
typeof e === "object" && e && "message" in e
? (e as { message?: string }).message || "Fehler beim Verknüpfen"
: "Fehler beim Verknüpfen";
toast.error(message);
}
})}
>
<div className="card bg-base-200 flex-1 basis-[800px] shadow-xl">
<div className="card-body">
<h2 className="card-title">
<TriangleAlert /> Duplikat markieren & sperren
</h2>
<Select
form={form}
name="canonicalUserId"
label="Original-Nutzer suchen & auswählen"
onInputChange={(v) => setSearch(String(v))}
options={
users?.map((u) => ({
label: `${u.firstname} ${u.lastname} (${u.publicId})`,
value: u.id,
})) || [{ label: "Kein Nutzer gefunden", value: "", disabled: true }]
}
/>
<label className="floating-label w-full">
<span className="flex items-center gap-2 text-lg">Grund (optional)</span>
<input
{...form.register("reason")}
type="text"
className="input input-bordered w-full"
placeholder="Begründung/Audit-Hinweis"
/>
</label>
</div>
</div>
<div className="card bg-base-200 flex-1 basis-[800px] shadow-xl">
<div className="card-body">
<div className="flex w-full gap-4">
<Button
isLoading={form.formState.isSubmitting}
type="submit"
className="btn btn-primary flex-1"
>
Als Duplikat verknüpfen & sperren
</Button>
</div>
</div>
</div>
</form>
);
};

View File

@@ -1,37 +0,0 @@
import { prisma } from "@repo/db";
import { DuplicateForm } from "./_components/DuplicateForm";
import { PersonIcon } from "@radix-ui/react-icons";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const user = await prisma.user.findUnique({
where: { id },
select: { id: true, firstname: true, lastname: true, publicId: true },
});
if (!user) {
return (
<div className="card bg-base-200 shadow-xl">
<div className="card-body">Nutzer nicht gefunden</div>
</div>
);
}
return (
<>
<div className="my-3">
<div className="text-left">
<Link href={`/admin/user/${user.id}`} className="link-hover l-0 text-gray-500">
<ArrowLeft className="mb-1 mr-1 inline h-4 w-4" />
Zurück zum Nutzer
</Link>
</div>
<p className="text-left text-2xl font-semibold">
<PersonIcon className="mr-2 inline h-5 w-5" /> Duplikat für {user.firstname}{" "}
{user.lastname} #{user.publicId}
</p>
</div>
<DuplicateForm duplicateUserId={user.id} />
</>
);
}

View File

@@ -12,35 +12,12 @@ import { getUserPenaltys } from "@repo/shared-components";
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
let user = await prisma.user.findUnique({
const user = await prisma.user.findUnique({
where: {
id: id,
},
include: {
DiscordAccount: true,
CanonicalUser: true,
Duplicates: true,
},
});
if (!user) {
user = await prisma.user.findFirst({
where: {
publicId: id,
},
include: {
DiscordAccount: true,
CanonicalUser: true,
Duplicates: true,
},
});
}
const formerDiscordAccounts = await prisma.formerDiscordAccount.findMany({
where: {
userId: user?.id,
},
include: {
DiscordAccount: true,
discordAccounts: true,
},
});
if (!user) return <Error statusCode={404} title="User not found" />;
@@ -142,12 +119,11 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
</div>
<div className="card bg-base-200 col-span-6 mb-4 shadow-xl xl:col-span-3">
<AdminForm
formerDiscordAccounts={formerDiscordAccounts}
user={user}
dispoTime={dispoTime}
pilotTime={pilotTime}
reports={reports}
discordAccount={user.DiscordAccount ?? undefined}
discordAccount={user.discordAccounts[0]}
openBans={openBans}
openTimebans={openTimeban}
/>

View File

@@ -2,7 +2,6 @@
import { prisma, Prisma } from "@repo/db";
import bcrypt from "bcryptjs";
import { sendMailByTemplate } from "../../../../helper/mail";
import { getServerSession } from "api/auth/[...nextauth]/auth";
export const getUser = async (where: Prisma.UserWhereInput) => {
return await prisma.user.findMany({
@@ -83,52 +82,3 @@ export const sendVerificationLink = async (userId: string) => {
code,
});
};
export const markDuplicate = async (params: {
duplicateUserId: string;
canonicalPublicId: string;
reason?: string;
}) => {
// Then in your function:
const session = await getServerSession();
if (!session?.user) throw new Error("Nicht authentifiziert");
const canonical = await prisma.user.findUnique({
where: { publicId: params.canonicalPublicId },
select: { id: true },
});
if (!canonical) throw new Error("Original-Account (canonical) nicht gefunden");
if (canonical.id === params.duplicateUserId)
throw new Error("Duplikat und Original dürfen nicht identisch sein");
const updated = await prisma.user.update({
where: { id: params.duplicateUserId },
data: {
canonicalUserId: canonical.id,
isBanned: true,
duplicateDetectedAt: new Date(),
duplicateReason: params.reason ?? undefined,
},
});
await prisma.penalty.create({
data: {
userId: params.duplicateUserId,
type: "BAN",
reason: `Account als Duplikat von #${params.canonicalPublicId} markiert.`,
createdUserId: session.user.id,
},
});
return updated;
};
export const clearDuplicateLink = async (duplicateUserId: string) => {
const updated = await prisma.user.update({
where: { id: duplicateUserId },
data: {
canonicalUserId: null,
duplicateDetectedAt: null,
duplicateReason: null,
},
});
return updated;
};

View File

@@ -3,34 +3,17 @@ import { User2 } from "lucide-react";
import { PaginatedTable } from "../../../_components/PaginatedTable";
import Link from "next/link";
import { ColumnDef } from "@tanstack/react-table";
import { DiscordAccount, Penalty, Prisma, User } from "@repo/db";
import { User } from "@repo/db";
import { useSession } from "next-auth/react";
const AdminUserPage = () => {
const { data: session } = useSession();
return (
<>
<PaginatedTable
stickyHeaders
prismaModel="user"
showSearch
getFilter={(searchTerm) => {
return {
OR: [
{ firstname: { contains: searchTerm, mode: "insensitive" } },
{ lastname: { contains: searchTerm, mode: "insensitive" } },
{ email: { contains: searchTerm, mode: "insensitive" } },
{ publicId: { contains: searchTerm, mode: "insensitive" } },
{ DiscordAccount: { username: { contains: searchTerm, mode: "insensitive" } } },
],
} as Prisma.UserWhereInput;
}}
include={{
DiscordAccount: true,
ReceivedReports: true,
Penaltys: true,
}}
searchFields={["publicId", "firstname", "lastname", "email"]}
initialOrderBy={[
{
id: "publicId",
@@ -54,15 +37,6 @@ const AdminUserPage = () => {
{
header: "Berechtigungen",
cell(props) {
const activePenaltys = props.row.original.Penaltys.filter(
(penalty) =>
!penalty.suspended &&
(penalty.type === "BAN" ||
(penalty.type === "TIME_BAN" && penalty!.until! > new Date())),
);
if (activePenaltys.length > 0) {
return <span className="font-bold text-red-600">AKTIVE STRAFE</span>;
}
if (props.row.original.permissions.length === 0) {
return <span className="text-gray-700">Keine</span>;
} else if (props.row.original.permissions.includes("ADMIN_USER_ADVANCED")) {
@@ -77,28 +51,6 @@ const AdminUserPage = () => {
);
},
},
{
header: "Strafen / Reports",
cell(props) {
const penaltyCount = props.row.original.Penaltys.length;
const reportCount = props.row.original.ReceivedReports.length;
return (
<span className="w-full text-center">
{penaltyCount} / {reportCount}
</span>
);
},
},
{
header: "Discord",
cell(props) {
const discord = props.row.original.DiscordAccount;
if (!discord) {
return <span className="text-gray-700">Nicht verbunden</span>;
}
return <span>{discord.username}</span>;
},
},
...(session?.user.permissions.includes("ADMIN_USER_ADVANCED")
? [
{
@@ -117,13 +69,7 @@ const AdminUserPage = () => {
</div>
),
},
] as ColumnDef<
User & {
DiscordAccount: DiscordAccount;
ReceivedReports: Report[];
Penaltys: Penalty[];
}
>[]
] as ColumnDef<User>[]
} // Define the columns for the user table
leftOfSearch={
<p className="flex items-center gap-2 text-left text-2xl font-semibold">

View File

@@ -1,68 +0,0 @@
"use client";
import MDEditor from "@uiw/react-md-editor";
import Image from "next/image";
export type TimelineEntry = {
id: number;
title: string;
text: string;
previewImage?: string | null;
createdAt: string;
};
const formatReleaseDate = (value: string) =>
new Intl.DateTimeFormat("en-GB", {
day: "2-digit",
month: "short",
year: "numeric",
}).format(new Date(value));
export const ChangelogTimeline = ({ entries }: { entries: TimelineEntry[] }) => {
if (!entries.length)
return <p className="text-base-content/70">Es sind noch keine Changelog-Einträge vorhanden.</p>;
return (
<div className="relative mt-6 pl-6">
<div className="bg-base-300 absolute bottom-0 left-2 top-0 w-px" aria-hidden />
<div className="space-y-8">
{entries.map((entry, idx) => (
<article key={entry.id ?? `${entry.title}-${idx}`} className="relative pl-4">
<div className="bg-primary ring-base-100 absolute -left-[9px] top-3 h-4 w-4 rounded-full ring-4" />
<div className="bg-base-200/80 rounded-xl p-5 shadow">
<div className="flex flex-col gap-1 text-left md:flex-row md:justify-between">
<div>
<h3 className="text-lg font-semibold leading-tight">{entry.title}</h3>
<p className="text-base-content/60 text-sm">
Release Date: {formatReleaseDate(entry.createdAt)}
</p>
</div>
{entry.previewImage && (
<div className="absolute right-5 top-5 md:pl-4">
<Image
src={entry.previewImage}
width={300}
height={300}
alt={`${entry.title} preview`}
className="mt-3 max-w-[300px] rounded-lg object-cover md:mt-0"
/>
</div>
)}
</div>
<div className="text-base-content/80 text-left" data-color-mode="dark">
<MDEditor.Markdown
source={entry.text}
style={{
backgroundColor: "transparent",
fontSize: "0.95rem",
}}
/>
</div>
</div>
</article>
))}
</div>
</div>
);
};

View File

@@ -1,26 +0,0 @@
import { prisma } from "@repo/db";
import { ChangelogTimeline } from "./_components/Timeline";
import { ActivityLogIcon } from "@radix-ui/react-icons";
export default async function Page() {
const changelog = await prisma.changelog.findMany({
where: { showOnChangelogPage: true },
orderBy: { createdAt: "desc" },
});
const entries = changelog.map((entry) => ({
...entry,
createdAt: entry.createdAt.toISOString(),
}));
return (
<>
<div className="w-full px-4">
<p className="flex items-center gap-2 text-left text-2xl font-semibold">
<ActivityLogIcon className="h-5 w-5" /> Changelog
</p>
</div>
<ChangelogTimeline entries={entries} />
</>
);
}

View File

@@ -27,10 +27,6 @@ const page = async () => {
},
},
},
orderBy: {
id: "desc",
},
});
const appointments = await prisma.eventAppointment.findMany({
where: {

View File

@@ -24,15 +24,16 @@ export default async function RootLayout({
return (
<div
className="hero min-h-screen"
className="min-h-screen"
style={{
backgroundImage: "url('/bg.png')",
backgroundSize: "cover",
}}
>
<div className="hero-overlay bg-opacity-30"></div>
<div className="absolute h-screen w-screen bg-opacity-30"></div>
{/* Card */}
<div className="hero-content text-neutral-content m-10 h-full w-full max-w-full text-center">
<div className="card bg-base-100 ml-24 mr-24 flex h-full max-h-[calc(100vh-13rem)] min-h-full w-full flex-col p-4 shadow-2xl">
<div className="text-neutral-content flex h-screen w-full max-w-full items-center justify-center text-center">
<div className="bg-base-100 mx-5 flex max-h-full max-w-[1600px] flex-col rounded-xl p-4 shadow-2xl xl:mx-12">
{/* Top Navbar */}
<HorizontalNav />

View File

@@ -1,5 +1,5 @@
"use client";
import { Mission, MissionAlertLog, MissionLog, Prisma, Station } from "@repo/db";
import { Mission, MissionAlertLog, MissionLog, Station } from "@repo/db";
import { ColumnDef } from "@tanstack/react-table";
import { Error } from "_components/Error";
import { PaginatedTable } from "_components/PaginatedTable";
@@ -14,21 +14,19 @@ const Page = () => {
return (
<div className="grid grid-cols-6 gap-4">
<div className="col-span-full">
<p className="flex items-center gap-2 text-left text-2xl font-semibold">
<NotebookText className="h-5 w-5" /> Einsatzhistorie
<p className="text-2xl font-semibold text-left flex items-center gap-2">
<NotebookText className="w-5 h-5" /> Einsatzhistorie
</p>
</div>
<div className="card bg-base-200 col-span-6 mb-4 shadow-xl">
<div className="card bg-base-200 shadow-xl mb-4 col-span-6">
<PaginatedTable
prismaModel={"missionOnStationUsers"}
getFilter={() =>
({
userId: session.data?.user?.id ?? "",
Mission: {
state: "finished",
},
}) as Prisma.MissionOnStationUsersWhereInput
}
filter={{
userId: session.data?.user?.id ?? "",
Mission: {
state: "finished",
},
}}
include={{
Station: true,
User: true,

View File

@@ -19,10 +19,10 @@ export default async function Home({
<RecentFlights />
</div>
<div className="card bg-base-200 col-span-6 mb-4 shadow-xl xl:col-span-3">
<Bookings />
<Badges />
</div>
<div className="card bg-base-200 col-span-6 mb-4 shadow-xl xl:col-span-3">
<Badges />
<Bookings />
</div>
</div>
<Events />

View File

@@ -1,6 +1,6 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { DiscordAccount, Penalty, Report, User } from "@repo/db";
import { DiscordAccount, Penalty, User } from "@repo/db";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
@@ -31,7 +31,7 @@ export const ProfileForm = ({
}: {
user: User;
penaltys: Penalty[];
discordAccount: DiscordAccount | null;
discordAccount?: DiscordAccount;
}): React.JSX.Element => {
const canEdit = penaltys.length === 0 && !user.isBanned;
@@ -215,11 +215,9 @@ export const ProfileForm = ({
export const SocialForm = ({
discordAccount,
user,
penaltys,
}: {
discordAccount: DiscordAccount | null;
discordAccount?: DiscordAccount;
user: User;
penaltys: Penalty[];
}): React.JSX.Element | null => {
const [isLoading, setIsLoading] = useState(false);
const [vatsimLoading, setVatsimLoading] = useState(false);
@@ -237,7 +235,6 @@ export const SocialForm = ({
},
resolver: zodResolver(schema),
});
const canUnlinkDiscord = !user.isBanned && penaltys.length === 0;
if (!user) return null;
return (
@@ -265,7 +262,7 @@ export const SocialForm = ({
</h2>
<div>
<div>
{discordAccount && canUnlinkDiscord ? (
{discordAccount ? (
<Button
className="btn-success btn-block btn-outline hover:btn-error group transition-all duration-0"
isLoading={isLoading}
@@ -329,14 +326,7 @@ export const SocialForm = ({
);
};
export const DeleteForm = ({
user,
penaltys,
}: {
user: User;
penaltys: Penalty[];
reports: Report[];
}) => {
export const DeleteForm = ({ user, penaltys }: { user: User; penaltys: Penalty[] }) => {
const router = useRouter();
const userCanDelete = penaltys.length === 0 && !user.isBanned;
return (
@@ -346,11 +336,10 @@ export const DeleteForm = ({
</h2>
{!userCanDelete && (
<div className="text-left">
<h2 className="text-warning text-lg">Du kannst dein Konto nicht löschen!</h2>
<h2 className="text-warning text-lg">Du kannst dein Konto zurzeit nicht löschen!</h2>
<p className="text-sm text-gray-400">
Da du Strafen hast oder hattest, kannst du deinen Account nicht schen. Um unsere
Community zu schützen kannst du deinen Account nicht löschen. Bitte erstelle ein
Support-Ticket, wenn du Fragen dazu hast.
Scheinbar hast du aktuell zurzeit aktive Strafen. Um unsere Community zu schützen kannst
du einen Account erst schen wenn deine Strafe nicht mehr aktiv ist
</p>
</div>
)}

View File

@@ -4,19 +4,9 @@ import { getServerSession } from "../../api/auth/[...nextauth]/auth";
import bcrypt from "bcryptjs";
export const unlinkDiscord = async (userId: string) => {
const discordAccount = await prisma.discordAccount.update({
await prisma.discordAccount.deleteMany({
where: {
userId: userId,
},
data: {
userId: null,
},
});
await prisma.formerDiscordAccount.create({
data: {
userId,
discordId: discordAccount.discordId,
},
});
};

View File

@@ -3,7 +3,6 @@ import { getServerSession } from "../../api/auth/[...nextauth]/auth";
import { ProfileForm, SocialForm, PasswordForm, DeleteForm } from "./_components/forms";
import { GearIcon } from "@radix-ui/react-icons";
import { Error } from "_components/Error";
import { getUserPenaltys } from "@repo/shared-components";
export default async function Page() {
const session = await getServerSession();
@@ -14,52 +13,43 @@ export default async function Page() {
id: session.user.id,
},
include: {
DiscordAccount: true,
discordAccounts: true,
Penaltys: true,
},
});
const userPenaltys = await prisma.penalty.findMany({
where: {
userId: session.user.id,
until: {
gte: new Date(),
},
type: {
in: ["TIME_BAN", "BAN"],
},
suspended: false,
},
});
const activePenaltys = await getUserPenaltys(session.user.id);
const userReports = await prisma.report.findMany({
where: {
reportedUserId: session.user.id,
},
});
if (!user) return <Error statusCode={401} title="Dein Account wurde nicht gefunden" />;
const discordAccount = user?.DiscordAccount;
const discordAccount = user?.discordAccounts[0];
return (
<div className="grid grid-cols-6 gap-4">
<div className="col-span-full">
<p className="flex items-center gap-2 text-left text-2xl font-semibold">
<GearIcon className="h-5 w-5" /> Einstellungen
<p className="text-2xl font-semibold text-left flex items-center gap-2">
<GearIcon className="w-5 h-5" /> Einstellungen
</p>
</div>
<div className="card bg-base-200 col-span-6 mb-4 shadow-xl xl:col-span-3">
<ProfileForm
user={user}
discordAccount={discordAccount}
penaltys={[...activePenaltys.openBans, ...activePenaltys.openTimeban]}
/>
<div className="card bg-base-200 shadow-xl mb-4 col-span-6 xl:col-span-3">
<ProfileForm user={user} penaltys={userPenaltys} discordAccount={discordAccount} />
</div>
<div className="card bg-base-200 col-span-6 mb-4 shadow-xl xl:col-span-3">
<SocialForm
user={user}
discordAccount={discordAccount}
penaltys={[...activePenaltys.openBans, ...activePenaltys.openTimeban]}
/>
<div className="card bg-base-200 shadow-xl mb-4 col-span-6 xl:col-span-3">
<SocialForm discordAccount={discordAccount} user={user} />
</div>
<div className="card bg-base-200 col-span-6 mb-4 shadow-xl xl:col-span-3">
<div className="card bg-base-200 shadow-xl mb-4 col-span-6 xl:col-span-3">
<PasswordForm />
</div>
<div className="card bg-base-200 col-span-6 mb-4 shadow-xl xl:col-span-3">
<DeleteForm user={user} reports={userReports} penaltys={userPenaltys} />
<div className="card bg-base-200 shadow-xl mb-4 col-span-6 xl:col-span-3">
<DeleteForm user={user} penaltys={userPenaltys} />
</div>
</div>
);

View File

@@ -1,16 +1,16 @@
"use client";
import { signOut } from "next-auth/react";
import { useEffect } from "react";
'use client';
import { signOut } from 'next-auth/react';
import { useEffect } from 'react';
export default () => {
useEffect(() => {
signOut({
callbackUrl: "/login",
});
}, []);
return (
<div className="card-body">
<h1 className="text-5xl">ausloggen...</h1>
</div>
);
useEffect(() => {
signOut({
callbackUrl: '/login',
});
}, []);
return (
<div className="card-body">
<h1 className="text-5xl">logging out...</h1>
</div>
);
};

View File

@@ -8,7 +8,6 @@ import { deleteBookingAPI, getBookingsAPI } from "(app)/_querys/bookings";
import { Button } from "@repo/shared-components";
import { formatTimeRange } from "../../helper/timerange";
import toast from "react-hot-toast";
import Link from "next/link";
interface BookingTimelineModalProps {
isOpen: boolean;
@@ -299,17 +298,7 @@ export const BookingTimelineModal = ({
? "LST"
: booking.Station.bosCallsignShort || booking.Station.bosCallsign}
</span>
{currentUser?.permissions.includes("ADMIN_USER") ? (
<Link
href={`/admin/user/${booking.User.publicId}`}
className="link link-hover text-xs opacity-70"
>
{booking.User.fullName}
</Link>
) : (
<span className="text-sm font-medium">{booking.User.fullName}</span>
)}
<span className="text-sm font-medium">{booking.User.fullName}</span>
</div>
<div className="flex items-center gap-2">
<div className="text-right">

View File

@@ -6,15 +6,13 @@ import {
RocketIcon,
ReaderIcon,
DownloadIcon,
UpdateIcon,
ActivityLogIcon,
} from "@radix-ui/react-icons";
import Link from "next/link";
import { WarningAlert } from "./ui/PageAlert";
import { getServerSession } from "api/auth/[...nextauth]/auth";
import { Error } from "./Error";
import Image from "next/image";
import { Loader, Plane, Radar, Workflow } from "lucide-react";
import { Plane, Radar, Workflow } from "lucide-react";
import { BookingButton } from "./BookingButton";
export const VerticalNav = async () => {
@@ -24,101 +22,93 @@ export const VerticalNav = async () => {
return p.startsWith("ADMIN");
});
return (
<ul className="menu bg-base-300 flex w-64 flex-nowrap justify-between rounded-lg p-3 font-semibold shadow-md">
<div className="border-none">
<li>
<Link href="/">
<HomeIcon /> Dashboard
</Link>
</li>
<li>
<Link href="/events">
<RocketIcon />
Events & Kurse
</Link>
</li>
<li>
<Link href="/logbook">
<ReaderIcon />
Einsatzhistorie
</Link>
</li>
<li>
<Link href="/settings">
<GearIcon />
Einstellungen
</Link>
</li>
<li>
<Link href="/resources">
<DownloadIcon />
Downloads / Links
</Link>
</li>
{viewAdminMenu && (
<li>
<details open>
<summary>
<LockClosedIcon />
Admin
</summary>
<ul>
{session.user.permissions.includes("ADMIN_USER") && (
<li>
<Link href="/admin/user">Benutzer</Link>
</li>
)}
{session.user.permissions.includes("ADMIN_STATION") && (
<li>
<Link href="/admin/station">Stationen</Link>
</li>
)}
{session.user.permissions.includes("ADMIN_KEYWORD") && (
<li>
<Link href="/admin/keyword">Stichworte</Link>
</li>
)}
{session.user.permissions.includes("ADMIN_HELIPORT") && (
<li>
<Link href="/admin/heliport">Heliports</Link>
</li>
)}
{session.user.permissions.includes("ADMIN_EVENT") && (
<li>
<Link href="/admin/event">Events</Link>
</li>
)}
{session.user.permissions.includes("ADMIN_MESSAGE") && (
<li>
<Link href="/admin/config">Config</Link>
</li>
)}
{session.user.permissions.includes("ADMIN_USER") && (
<li>
<Link href="/admin/report">Reports</Link>
</li>
)}
{session.user.permissions.includes("ADMIN_USER") && (
<li>
<Link href="/admin/penalty">Audit-Log</Link>
</li>
)}
{session.user.permissions.includes("ADMIN_CHANGELOG") && (
<li>
<Link href="/admin/changelog">Changelog</Link>
</li>
)}
</ul>
</details>
</li>
)}
</div>
<ul className="menu bg-base-300 w-64 flex-nowrap rounded-lg p-3 font-semibold shadow-md">
<li>
<Link href="/changelog">
<ActivityLogIcon />
Changelog
<Link href="/">
<HomeIcon /> Dashboard
</Link>
</li>
<li>
<Link href="/events">
<RocketIcon />
Events & Kurse
</Link>
</li>
<li>
<Link href="/logbook">
<ReaderIcon />
Einsatzhistorie
</Link>
</li>
<li>
<Link href="/settings">
<GearIcon />
Einstellungen
</Link>
</li>
<li>
<Link href="/resources">
<DownloadIcon />
Downloads / Links
</Link>
</li>
{viewAdminMenu && (
<li>
<details open>
<summary>
<LockClosedIcon />
Admin
</summary>
<ul>
{session.user.permissions.includes("ADMIN_USER") && (
<li>
<Link href="/admin/user">Benutzer</Link>
</li>
)}
{session.user.permissions.includes("ADMIN_STATION") && (
<li>
<Link href="/admin/station">Stationen</Link>
</li>
)}
{session.user.permissions.includes("ADMIN_KEYWORD") && (
<li>
<Link href="/admin/keyword">Stichworte</Link>
</li>
)}
{session.user.permissions.includes("ADMIN_HELIPORT") && (
<li>
<Link href="/admin/heliport">Heliports</Link>
</li>
)}
{session.user.permissions.includes("ADMIN_EVENT") && (
<li>
<Link href="/admin/event">Events</Link>
</li>
)}
{session.user.permissions.includes("ADMIN_MESSAGE") && (
<li>
<Link href="/admin/config">Config</Link>
</li>
)}
{session.user.permissions.includes("ADMIN_USER") && (
<li>
<Link href="/admin/report">Reports</Link>
</li>
)}
{session.user.permissions.includes("ADMIN_USER") && (
<li>
<Link href="/admin/penalty">Audit-Log</Link>
</li>
)}
{session.user.permissions.includes("ADMIN_CHANGELOG") && (
<li>
<Link href="/admin/changelog">Changelog</Link>
</li>
)}
</ul>
</details>
</li>
)}
</ul>
);
};

View File

@@ -13,7 +13,6 @@ import { AxiosError } from "axios";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { DateInput } from "_components/ui/DateInput";
import { Select } from "_components/ui/Select";
interface NewBookingFormData {
type: "STATION" | "LST_01" | "LST_02" | "LST_03" | "LST_04";
@@ -86,9 +85,6 @@ export const NewBookingModal = ({
}
});
const form = useForm<NewBookingFormData>({
resolver: zodResolver(newBookingSchema),
});
const {
register,
handleSubmit,
@@ -96,7 +92,10 @@ export const NewBookingModal = ({
setValue,
reset,
formState: { errors },
} = form;
} = useForm<NewBookingFormData>({
resolver: zodResolver(newBookingSchema),
});
const selectedType = watch("type");
const hasDISPOPermission = userPermissions.includes("DISPO");
@@ -169,17 +168,20 @@ export const NewBookingModal = ({
{isLoadingStations ? (
<div className="skeleton h-12 w-full"></div>
) : (
<Select
label="Station"
name="stationId"
form={form}
options={stations?.map((s) => {
return {
value: s.id,
label: `${s.bosCallsign} - ${s.locationState} (${s.aircraft})`,
};
<select
{...register("stationId", {
required:
selectedType === "STATION" ? "Bitte wählen Sie eine Station aus" : false,
})}
/>
className="select select-bordered w-full"
>
<option value="">Station auswählen...</option>
{stations?.map((station) => (
<option key={station.id} value={station.id}>
{station.bosCallsignShort} - {station.locationState} ({station.aircraft})
</option>
))}
</select>
)}
{errors.stationId && (
<label className="label">

View File

@@ -9,27 +9,26 @@ export interface PaginatedTableRef {
refresh: () => void;
}
interface PaginatedTableProps<TData, TWhere extends object>
extends Omit<SortableTableProps<TData>, "data"> {
interface PaginatedTableProps<TData> extends Omit<SortableTableProps<TData>, "data"> {
prismaModel: keyof PrismaClient;
stickyHeaders?: boolean;
filter?: Record<string, unknown>;
initialRowsPerPage?: number;
showSearch?: boolean;
getFilter?: (searchTerm: string) => TWhere;
searchFields?: string[];
include?: Record<string, boolean>;
strictQuery?: boolean;
leftOfSearch?: React.ReactNode;
rightOfSearch?: React.ReactNode;
leftOfPagination?: React.ReactNode;
supressQuery?: boolean;
hide?: boolean;
ref?: Ref<PaginatedTableRef>;
}
export function PaginatedTable<TData, TWhere extends object>({
export function PaginatedTable<TData>({
prismaModel,
initialRowsPerPage = 30,
getFilter,
showSearch = false,
searchFields = [],
filter,
include,
ref,
strictQuery = false,
@@ -37,9 +36,9 @@ export function PaginatedTable<TData, TWhere extends object>({
leftOfSearch,
rightOfSearch,
leftOfPagination,
supressQuery,
hide,
...restProps
}: PaginatedTableProps<TData, TWhere>) {
}: PaginatedTableProps<TData>) {
const [data, setData] = useState<TData[]>([]);
const [rowsPerPage, setRowsPerPage] = useState(initialRowsPerPage);
const [page, setPage] = useState(0);
@@ -59,19 +58,17 @@ export function PaginatedTable<TData, TWhere extends object>({
const [loading, setLoading] = useState(false);
const refreshTableData = useCallback(async () => {
if (supressQuery) {
setLoading(false);
return;
}
setLoading(true);
getData({
model: prismaModel,
limit: rowsPerPage,
offset: page * rowsPerPage,
where: getFilter ? getFilter(searchTerm) : undefined,
getData(
prismaModel,
rowsPerPage,
page * rowsPerPage,
searchTerm,
searchFields,
filter,
include,
orderBy,
select: strictQuery
strictQuery
? restProps.columns
.filter(
(col): col is { accessorKey: string } =>
@@ -83,7 +80,7 @@ export function PaginatedTable<TData, TWhere extends object>({
return acc;
}, {})
: undefined,
})
)
.then((result) => {
if (result) {
setData(result.data);
@@ -94,12 +91,12 @@ export function PaginatedTable<TData, TWhere extends object>({
setLoading(false);
});
}, [
supressQuery,
prismaModel,
rowsPerPage,
page,
searchTerm,
getFilter,
searchFields,
filter,
include,
orderBy,
strictQuery,
@@ -114,33 +111,31 @@ export function PaginatedTable<TData, TWhere extends object>({
// useEffect to show loading spinner
useEffect(() => {
if (supressQuery) return;
setLoading(true);
}, [searchTerm, page, rowsPerPage, orderBy, getFilter, setLoading, supressQuery]);
}, [searchTerm, page, rowsPerPage, orderBy, filter, setLoading]);
useDebounce(
() => {
refreshTableData();
},
500,
[searchTerm, page, rowsPerPage, orderBy, getFilter],
[searchTerm, page, rowsPerPage, orderBy, filter],
);
return (
<div className="m-4 space-y-4">
{(rightOfSearch || leftOfSearch || showSearch) && (
<div className="space-y-4 m-4">
{(rightOfSearch || leftOfSearch || searchFields.length > 0) && (
<div
className={cn(
"sticky z-20 flex items-center gap-2 py-2",
stickyHeaders && "bg-base-100/80 sticky top-0 border-b backdrop-blur",
"flex items-center gap-2 sticky py-2 z-20",
stickyHeaders && "sticky top-0 bg-base-100/80 backdrop-blur border-b",
)}
>
<div className="flex flex-1 gap-2">
<div className="flex-1 flex gap-2">
<div>{leftOfSearch}</div>
<div>{loading && <span className="loading loading-dots loading-md" />}</div>
</div>
{showSearch && (
{searchFields.length > 0 && (
<input
type="text"
placeholder="Suchen..."
@@ -155,14 +150,22 @@ export function PaginatedTable<TData, TWhere extends object>({
<div className="flex justify-center">{rightOfSearch}</div>
</div>
)}
<SortableTable data={data} prismaModel={prismaModel} setOrderBy={setOrderBy} {...restProps} />
<div className="items-between flex">
{!hide && (
<SortableTable
data={data}
prismaModel={prismaModel}
setOrderBy={setOrderBy}
{...restProps}
/>
)}
<div className="flex items-between">
{leftOfPagination}
<>
<RowsPerPage rowsPerPage={rowsPerPage} setRowsPerPage={setRowsPerPage} />
<Pagination totalPages={Math.ceil(total / rowsPerPage)} page={page} setPage={setPage} />
</>
{!hide && (
<>
<RowsPerPage rowsPerPage={rowsPerPage} setRowsPerPage={setRowsPerPage} />
<Pagination totalPages={Math.ceil(total / rowsPerPage)} page={page} setPage={setPage} />
</>
)}
</div>
</div>
);

View File

@@ -49,13 +49,13 @@ export default function SortableTable<TData>({
return (
<div className="overflow-x-auto">
<table className="table-zebra table w-full">
<table className="table table-zebra w-full">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th key={header.id} onClick={header.column.getToggleSortingHandler()}>
<div className="flex cursor-pointer items-center gap-1">
<div className="flex items-center gap-1 cursor-pointer">
{flexRender(header.column.columnDef.header, header.getContext())}
{header.column.getIsSorted() === "asc" && <ChevronUp size={16} />}
{header.column.getIsSorted() === "desc" && <ChevronDown size={16} />}
@@ -75,7 +75,7 @@ export default function SortableTable<TData>({
))}
{table.getRowModel().rows.length === 0 && (
<tr>
<td colSpan={columns.length} className="text-center text-sm font-bold text-gray-500">
<td colSpan={columns.length} className="text-center font-bold text-sm text-gray-500">
Keine Daten gefunden
</td>
</tr>
@@ -104,8 +104,6 @@ export const RowsPerPage = ({
<option value={50}>50</option>
<option value={100}>100</option>
<option value={300}>300</option>
<option value={1000}>1000</option>
<option value={5000}>5000</option>
</select>
);
};

View File

@@ -2,30 +2,56 @@
"use server";
import { prisma, PrismaClient } from "@repo/db";
export async function getData<Twhere>({
model,
limit,
offset,
where,
include,
orderBy,
select,
}: {
model: keyof PrismaClient;
limit: number;
offset: number;
where: Twhere;
include?: Record<string, boolean>;
orderBy?: Record<string, "asc" | "desc">;
select?: Record<string, any>;
}) {
if (!model || !(prisma as any)[model]) {
export async function getData(
model: keyof PrismaClient,
limit: number,
offset: number,
searchTerm: string,
searchFields: string[],
filter?: Record<string, any>,
include?: Record<string, boolean>,
orderBy?: Record<string, "asc" | "desc">,
select?: Record<string, any>,
) {
if (!model || !prisma[model]) {
return { data: [], total: 0 };
}
const delegate = (prisma as any)[model];
const formattedId = searchTerm.match(/^VAR(\d+)$/)?.[1];
const data = await delegate.findMany({
const where = searchTerm
? {
OR: [
formattedId ? { id: formattedId } : undefined,
...searchFields.map((field) => {
if (field.includes(".")) {
const parts: string[] = field.split(".");
// Helper function to build nested object
const buildNestedFilter = (parts: string[], index = 0): any => {
if (index === parts.length - 1) {
// Reached the last part - add the contains filter
return { [parts[index] as string]: { contains: searchTerm } };
}
// For intermediate levels, nest the next level
return { [parts[index] as string]: buildNestedFilter(parts, index + 1) };
};
return buildNestedFilter(parts);
}
return { [field]: { contains: searchTerm } };
}),
].filter(Boolean),
...filter,
}
: { ...filter };
if (!prisma[model]) {
return { data: [], total: 0 };
}
const data = await (prisma[model] as any).findMany({
where,
orderBy,
take: limit,
@@ -34,7 +60,7 @@ export async function getData<Twhere>({
select,
});
const total = await delegate.count({ where });
const total = await (prisma[model] as any).count({ where });
return { data, total };
}

View File

@@ -31,7 +31,6 @@ const customStyles: StylesConfig<OptionType, false> = {
backgroundColor: state.isSelected ? "hsl(var(--p))" : "hsl(var(--b1))",
color: "var(--color-primary-content)",
"&:hover": { backgroundColor: "var(--color-base-200)" }, // DaisyUI secondary color
cursor: "pointer",
}),
multiValueLabel: (provided) => ({
...provided,
@@ -50,11 +49,6 @@ const customStyles: StylesConfig<OptionType, false> = {
backgroundColor: "var(--color-base-100)",
borderRadius: "0.5rem",
}),
input: (provided) => ({
...provided,
color: "var(--color-primary-content)",
cursor: "text",
}),
};
const SelectCom = <T extends FieldValues>({
@@ -67,7 +61,7 @@ const SelectCom = <T extends FieldValues>({
}: SelectProps<T>) => {
return (
<div>
<span className="label-text flex items-center gap-2 text-lg">{label}</span>
<span className="label-text text-lg flex items-center gap-2">{label}</span>
<SelectTemplate
onChange={(newValue: any) => {
if (Array.isArray(newValue)) {

View File

@@ -3,8 +3,6 @@ import { NextRequest, NextResponse } from "next/server";
import { DiscordAccount, prisma } from "@repo/db";
import { getServerSession } from "../auth/[...nextauth]/auth";
import { setStandardName } from "../../../helper/discord";
import { getUserPenaltys } from "@repo/shared-components";
import { markDuplicate } from "(app)/admin/user/action";
export const GET = async (req: NextRequest) => {
const session = await getServerSession();
@@ -79,29 +77,6 @@ export const GET = async (req: NextRequest) => {
userId: user.id,
});
}
const formerDiscordAccount = await prisma.formerDiscordAccount.findMany({
where: {
discordId: discordUser.id,
userId: {
not: session.user.id,
},
User: {
canonicalUserId: null,
},
},
include: { User: true },
});
// Account is suspicious to multiaccounting
if (formerDiscordAccount.length > 0) {
formerDiscordAccount.forEach(async (account) => {
await markDuplicate({
duplicateUserId: session.user.id,
canonicalPublicId: account.User!.publicId,
reason: "Multiaccounting Verdacht, gleicher Discord Account wie ein anderer Nutzer.",
});
});
}
return NextResponse.redirect(`${process.env.NEXT_PUBLIC_HUB_URL}/settings`);
} catch (error) {

View File

@@ -1,5 +1,5 @@
"use client";
import { Prisma, User } from "@repo/db";
import { User } from "@repo/db";
import { ColumnDef } from "@tanstack/react-table";
import { PaginatedTable } from "_components/PaginatedTable";
@@ -7,24 +7,13 @@ export default function () {
return (
<PaginatedTable
strictQuery
showSearch
searchFields={["firstname", "lastname", "vatsimCid"]}
prismaModel={"user"}
getFilter={(searchTerm) =>
({
AND: [
{
vatsimCid: {
not: "",
},
OR: [
{ firstname: { contains: searchTerm, mode: "insensitive" } },
{ lastname: { contains: searchTerm, mode: "insensitive" } },
{ vatsimCid: { contains: searchTerm, mode: "insensitive" } },
],
},
],
}) as Prisma.UserWhereInput
}
filter={{
vatsimCid: {
gt: 1,
},
}}
leftOfSearch={<h1 className="text-2xl font-bold">Vatsim-Nutzer</h1>}
columns={
[

View File

@@ -1,10 +1,6 @@
/** @type {import('next').NextConfig} */
/* const removeImports = require("next-remove-imports")(); */
/* const nextConfig = removeImports({}); */
const nextConfig = {
images: {
domains: ["cdn.discordapp.com", "nextcloud.virtualairrescue.com"],
},
};
const nextConfig = {};
export default nextConfig;

View File

@@ -37,7 +37,7 @@
"lodash": "^4.17.21",
"lucide-react": "^0.525.0",
"moment": "^2.30.1",
"next": "^15.4.8",
"next": "^15.4.2",
"next-auth": "^4.24.11",
"next-remove-imports": "^1.0.12",
"npm": "^11.4.2",

View File

@@ -21,18 +21,6 @@ services:
command:
- "--config.file=/etc/prometheus/prometheus.yml"
victoriametrics:
image: victoriametrics/victoria-metrics:latest
container_name: victoria-metrics
restart: unless-stopped
ports:
- "8428:8428" # VM Web UI + API + Prometheus-compatible read endpoint
volumes:
- victoria-metrics-data:/storage
command:
- "-storageDataPath=/storage"
- "-retentionPeriod=24" # 24 Monate retention
redis:
container_name: redis
image: redis/redis-stack:latest
@@ -59,6 +47,5 @@ services:
volumes:
postgres-data:
victoria-metrics-data:
redis_data:
driver: local

View File

@@ -138,21 +138,6 @@ services:
- /sys:/sys:ro
networks:
- core_network
victoriametrics:
image: victoriametrics/victoria-metrics:latest
container_name: victoria-metrics
restart: unless-stopped
ports:
- "8428:8428" # VM Web UI + API + Prometheus-compatible read endpoint
volumes:
- victoria-metrics-data:/storage
command:
- "-storageDataPath=/storage"
- "-retentionPeriod=24" # 24 Monate retention
networks:
- core_network
- traefik
prometheus:
restart: unless-stopped
image: prom/prometheus:latest
@@ -250,4 +235,3 @@ volumes:
driver: local
portainer_data:
prometheus_data:
victoria-metrics-data:

View File

@@ -132,20 +132,6 @@ services:
networks:
- core_network
restart: unless-stopped
victoriametrics:
image: victoriametrics/victoria-metrics:latest
container_name: victoria-metrics
restart: unless-stopped
ports:
- "8428:8428" # VM Web UI + API + Prometheus-compatible read endpoint
volumes:
- victoria-metrics-data:/storage
command:
- "-storageDataPath=/storage"
- "-retentionPeriod=24" # 24 Monate retention
networks:
- core_network
prometheus:
image: prom/prometheus:latest
container_name: prometheus
@@ -242,4 +228,3 @@ volumes:
driver: local
portainer_data:
prometheus_data:
victoria-metrics-data:

View File

@@ -8,10 +8,10 @@
"schema": "./prisma/schema/"
},
"scripts": {
"generate": "npx prisma@6.12.0 generate && npx prisma@6.12.0 generate zod",
"migrate": "npx prisma@6.12.0 migrate dev",
"deploy": "npx prisma@6.12.0 migrate deploy",
"dev": "npx prisma@6.12.0 studio --browser none"
"generate": "npx prisma generate && npx prisma generate zod",
"migrate": "npx prisma migrate dev",
"deploy": "npx prisma migrate deploy",
"dev": "npx prisma studio --browser none"
},
"exports": {
".": "./index.ts",

View File

@@ -1,7 +0,0 @@
export interface XplaneObject {
objectName: string;
lat: number;
lon: number;
heading: number;
alt: number;
}

View File

@@ -50,20 +50,9 @@ export type MissionAutoClose = {
};
};
export type MissionClosed = {
type: "mission-closed";
status: "closed";
message: string;
data: {
missionId: number;
publicMissionId: string;
};
};
export type NotificationPayload =
| ValidationFailed
| ValidationSuccess
| AdminMessage
| StationStatus
| MissionAutoClose
| MissionClosed;
| MissionAutoClose;

View File

@@ -3,4 +3,3 @@ export * from "./MissionVehicleLog";
export * from "./User";
export * from "./OSMway";
export * from "./SocketEvents";
export * from "./MissionXplaneObjects";

View File

@@ -1,8 +1,7 @@
model Changelog {
id Int @id @default(autoincrement())
title String
previewImage String?
text String
createdAt DateTime @default(now())
showOnChangelogPage Boolean @default(true)
id Int @id @default(autoincrement())
title String
previewImage String?
text String
createdAt DateTime @default(now())
}

View File

@@ -1,22 +1,21 @@
model ConnectedAircraft {
id Int @id @default(autoincrement())
userId String
publicUser Json
lastHeartbeat DateTime @default(now())
fmsStatus String @default("6")
id Int @id @default(autoincrement())
userId String
publicUser Json
lastHeartbeat DateTime @default(now())
fmsStatus String @default("6")
// position:
posLat Float?
posLng Float?
posAlt Int?
posSpeed Int?
posHeading Int?
simulator String?
posH145active Boolean @default(false)
posXplanePluginActive Boolean @default(false)
stationId Int
loginTime DateTime @default(now())
esimatedLogoutTime DateTime?
logoutTime DateTime?
posLat Float?
posLng Float?
posAlt Int?
posSpeed Int?
posHeading Int?
simulator String?
posH145active Boolean @default(false)
stationId Int
loginTime DateTime @default(now())
esimatedLogoutTime DateTime?
logoutTime DateTime?
// relations:
User User @relation(fields: [userId], references: [id], onDelete: Cascade)

View File

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

View File

@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "Mission" ADD COLUMN "xPlaneObjects" JSONB[] DEFAULT ARRAY[]::JSONB[];

View File

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

View File

@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "users" ALTER COLUMN "settings_use_hpg_as_dispatcher" SET DEFAULT true;

View File

@@ -1,7 +0,0 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "canonical_user_id" TEXT,
ADD COLUMN "duplicate_detected_at" TIMESTAMP(3),
ADD COLUMN "duplicate_reason" TEXT;
-- AddForeignKey
ALTER TABLE "users" ADD CONSTRAINT "users_canonical_user_id_fkey" FOREIGN KEY ("canonical_user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -1,33 +0,0 @@
/*
Warnings:
- You are about to drop the column `user_id` on the `discord_accounts` table. All the data in the column will be lost.
*/
-- DropForeignKey
ALTER TABLE "discord_accounts" DROP CONSTRAINT "discord_accounts_user_id_fkey";
-- AlterTable
ALTER TABLE "discord_accounts" RENAME COLUMN "user_id" TO "userId";
-- CreateTable
CREATE TABLE "former_discord_accounts" (
"id" SERIAL NOT NULL,
"discord_id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"removed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "former_discord_accounts_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "former_discord_accounts_discord_id_key" ON "former_discord_accounts"("discord_id");
-- AddForeignKey
ALTER TABLE "former_discord_accounts" ADD CONSTRAINT "former_discord_accounts_discord_id_fkey" FOREIGN KEY ("discord_id") REFERENCES "discord_accounts"("discord_id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "former_discord_accounts" ADD CONSTRAINT "former_discord_accounts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "discord_accounts" ADD CONSTRAINT "discord_accounts_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -1,35 +0,0 @@
/*
Warnings:
- You are about to drop the `former_discord_accounts` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropForeignKey
ALTER TABLE "former_discord_accounts" DROP CONSTRAINT "former_discord_accounts_discord_id_fkey";
-- DropForeignKey
ALTER TABLE "former_discord_accounts" DROP CONSTRAINT "former_discord_accounts_user_id_fkey";
-- AlterTable
ALTER TABLE "discord_accounts" ALTER COLUMN "userId" DROP NOT NULL;
-- DropTable
DROP TABLE "former_discord_accounts";
-- CreateTable
CREATE TABLE "FormerDiscordAccount" (
"discord_id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"removed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "FormerDiscordAccount_pkey" PRIMARY KEY ("discord_id","user_id")
);
-- CreateIndex
CREATE UNIQUE INDEX "FormerDiscordAccount_discord_id_key" ON "FormerDiscordAccount"("discord_id");
-- AddForeignKey
ALTER TABLE "FormerDiscordAccount" ADD CONSTRAINT "FormerDiscordAccount_discord_id_fkey" FOREIGN KEY ("discord_id") REFERENCES "discord_accounts"("discord_id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FormerDiscordAccount" ADD CONSTRAINT "FormerDiscordAccount_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -1,8 +0,0 @@
/*
Warnings:
- A unique constraint covering the columns `[userId]` on the table `discord_accounts` will be added. If there are existing duplicate values, this will fail.
*/
-- CreateIndex
CREATE UNIQUE INDEX "discord_accounts_userId_key" ON "discord_accounts"("userId");

View File

@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "Changelog" ADD COLUMN "showOnChangelogPage" BOOLEAN NOT NULL DEFAULT true;

View File

@@ -19,7 +19,6 @@ model Mission {
missionStationIds Int[] @default([])
missionStationUserIds String[] @default([])
missionLog Json[] @default([])
xPlaneObjects Json[] @default([])
hpgMissionString String?
hpgSelectedMissionString String?
hpgAmbulanceState HpgState? @default(NOT_REQUESTED)

View File

@@ -38,16 +38,15 @@ model User {
changelogAck Boolean @default(false)
// Settings:
pathSelected Boolean @default(false)
migratedFromV1 Boolean @default(false)
settingsNtfyRoom String? @map(name: "settings_ntfy_room")
settingsMicDevice String? @map(name: "settings_mic_device")
settingsMicVolume Float? @map(name: "settings_mic_volume")
settingsDmeVolume Float? @map(name: "settings_dme_volume")
settingsRadioVolume Float? @map(name: "settings_funk_volume")
settingsHideLastname Boolean @default(false) @map(name: "settings_hide_lastname")
settingsAutoCloseMapPopup Boolean @default(false) @map(name: "settings_auto_close_map_popup")
settingsUseHPGAsDispatcher Boolean @default(true) @map(name: "settings_use_hpg_as_dispatcher")
pathSelected Boolean @default(false)
migratedFromV1 Boolean @default(false)
settingsNtfyRoom String? @map(name: "settings_ntfy_room")
settingsMicDevice String? @map(name: "settings_mic_device")
settingsMicVolume Float? @map(name: "settings_mic_volume")
settingsDmeVolume Float? @map(name: "settings_dme_volume")
settingsRadioVolume Float? @map(name: "settings_funk_volume")
settingsHideLastname Boolean @default(false) @map(name: "settings_hide_lastname")
settingsAutoCloseMapPopup Boolean @default(false) @map(name: "settings_auto_close_map_popup")
// email Verification:
emailVerificationToken String? @map(name: "email_verification_token")
@@ -60,14 +59,9 @@ model User {
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @default(now()) @map(name: "updated_at")
isBanned Boolean @default(false) @map(name: "is_banned")
// Duplicate handling:
canonicalUserId String? @map(name: "canonical_user_id")
CanonicalUser User? @relation("CanonicalUser", fields: [canonicalUserId], references: [id])
Duplicates User[] @relation("CanonicalUser")
duplicateDetectedAt DateTime? @map(name: "duplicate_detected_at")
duplicateReason String? @map(name: "duplicate_reason")
// relations:
oauthTokens OAuthToken[]
discordAccounts DiscordAccount[]
participants Participant[]
EventAppointmentUser EventAppointment[] @relation("EventAppointmentUser")
EventAppointment EventAppointment[]
@@ -85,26 +79,13 @@ model User {
CreatedPenalties Penalty[] @relation("CreatedPenalties")
Bookings Booking[]
DiscordAccount DiscordAccount?
FormerDiscordAccounts FormerDiscordAccount[]
@@map(name: "users")
}
model FormerDiscordAccount {
discordId String @unique @map(name: "discord_id")
userId String @map(name: "user_id")
removedAt DateTime @default(now()) @map(name: "removed_at")
DiscordAccount DiscordAccount? @relation(fields: [discordId], references: [discordId], onDelete: SetNull)
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@id([discordId, userId])
}
model DiscordAccount {
id Int @id @default(autoincrement())
discordId String @unique @map(name: "discord_id")
userId String @map(name: "user_id")
email String @map(name: "email")
username String @map(name: "username")
avatar String? @map(name: "avatar")
@@ -116,10 +97,8 @@ model DiscordAccount {
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @default(now()) @map(name: "updated_at")
// Related User
userId String? @unique
User User? @relation(fields: [userId], references: [id], onDelete: SetNull)
formerDiscordAccount FormerDiscordAccount?
// relations:
user User @relation(fields: [userId], references: [id], onDelete: Cascade) // Beziehung zu User
@@map(name: "discord_accounts")
}

View File

@@ -2,7 +2,10 @@ global:
scrape_interval: 40s
remote_write:
- url: "http://victoriametrics:8428/api/v1/write"
- url: https://prometheus-prod-36-prod-us-west-0.grafana.net/api/prom/push
basic_auth:
username: 2527367
password: glc_eyJvIjoiMTMzOTM4MiIsIm4iOiJzdGFjay0xMzAxNTY2LWFsbG95LWxvY2FsLWRldiIsImsiOiI1YkM0SkFvODU3NjJCaTFlQnkwY0xySjEiLCJtIjp7InIiOiJwcm9kLXVzLXdlc3QtMCJ9fQ==
scrape_configs:
- job_name: core-server

View File

@@ -1,9 +1,6 @@
global:
scrape_interval: 40s
remote_write:
- url: "http://victoriametrics:8428/api/v1/write"
scrape_configs:
- job_name: core-server
static_configs:
@@ -23,29 +20,10 @@ scrape_configs:
- job_name: "traefik"
static_configs:
- targets: ["traefik:8080"] # Traefik dashboard endpoint
- job_name: node_exporter
scrape_interval: 15s
static_configs:
- targets: ["node_exporter:9100"]
- job_name: blackbox
metrics_path: /probe
params:
module: [http_2xx]
scrape_interval: 60s
static_configs:
- targets:
- https://status.virtualairrescue.com
- https://virtualairrescue.com
- https://ops.virtualairrescue.com
- https://nextcloud.virtualairrescue.com
- https://moodle.virtualairrescue.com
relabel_configs:
- source_labels: [__address__]
target_label: __param_target
- source_labels: [__param_target]
target_label: instance
- target_label: __address__
replacement: blackbox_exporter:9115
# - job_name: "Node Exporter"
# static_configs:
# - targets:
# [
# "var01.virtualairrescue.com:9100/metrics",
# "var01.virtualairrescue.com:9100/probe?target=https://virtualairrescue.com&module=http_2xx",
# ]

430
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,28 +1,11 @@
packages:
- apps/*
- packages/*
onlyBuiltDependencies:
- '@prisma/client'
- '@prisma/engines'
- '@tailwindcss/oxide'
- "@prisma/client"
- "@prisma/engines"
- "@tailwindcss/oxide"
- esbuild
- prisma
- sharp
- unrs-resolver
overrides:
'@eslint/plugin-kit@<0.3.4': '>=0.3.4'
axios@>=1.0.0 <1.12.0: '>=1.12.0'
body-parser@>=2.2.0 <2.2.1: '>=2.2.1'
form-data@>=4.0.0 <4.0.4: '>=4.0.4'
js-yaml@>=4.0.0 <4.1.1: '>=4.1.1'
jws@<3.2.3: '>=3.2.3'
mdast-util-to-hast@>=13.0.0 <13.2.1: '>=13.2.1'
next-auth@<4.24.12: '>=4.24.12'
next@>=15.0.0 <=15.4.4: '>=15.4.5'
next@>=15.0.0-canary.0 <15.4.7: '>=15.4.7'
next@>=15.4.0-canary.0 <15.4.8: '>=15.4.8'
nodemailer@<7.0.7: '>=7.0.7'
nodemailer@<=7.0.10: '>=7.0.11'
playwright@<1.55.1: '>=1.55.1'