diff --git a/apps/client/src/components/admin/manage/cad-settings/live-map-tab.tsx b/apps/client/src/components/admin/manage/cad-settings/live-map-tab.tsx index 27009cc35..c7bfaf60f 100644 --- a/apps/client/src/components/admin/manage/cad-settings/live-map-tab.tsx +++ b/apps/client/src/components/admin/manage/cad-settings/live-map-tab.tsx @@ -345,22 +345,24 @@ function ManageURLPopover(props: ManageURLPopoverProps) { -

{props.url ? t("editURL") : t("addURL")}

- -
- setName(value)} /> - setUrl(value)} - /> - - -
+
+

{props.url ? t("editURL") : t("addURL")}

+ +
+ setName(value)} /> + setUrl(value)} + /> + + +
+
diff --git a/apps/client/src/components/dispatch/map/calls/active-map-calls.tsx b/apps/client/src/components/dispatch/map/calls/active-map-calls.tsx index 153ab9eca..56044686f 100644 --- a/apps/client/src/components/dispatch/map/calls/active-map-calls.tsx +++ b/apps/client/src/components/dispatch/map/calls/active-map-calls.tsx @@ -1,27 +1,28 @@ import * as React from "react"; -import { createPortal } from "react-dom"; import { useTranslations } from "next-intl"; -import type { Full911Call } from "state/dispatch/dispatch-state"; import type { Call911 } from "@snailycad/types"; import { Manage911CallModal } from "components/dispatch/active-calls/modals/manage-911-call-modal"; import { useListener } from "@casperiv/use-socket.io"; import { SocketEvents } from "@snailycad/config"; -import { usePortal } from "@casperiv/useful"; import { CallItem } from "./call-item"; import { useCall911State } from "state/dispatch/call-911-state"; import { Accordion } from "@snailycad/ui"; +import { useDispatchMapState } from "state/mapState"; +import { useMarkerChange } from "./use-marker-change"; -export interface MapCallProps { - hasMarker(callId: string): boolean; - setMarker(call: Full911Call, type: "remove" | "set"): void; - openItems: string[]; - setOpenItems: React.Dispatch>; -} - -export function ActiveMapCalls({ hasMarker, setOpenItems, openItems, setMarker }: MapCallProps) { +export function ActiveMapCalls() { const t = useTranslations("Calls"); const calls911State = useCall911State(); - const portalRef = usePortal("ActiveMapCalls"); + const { handleMarkerChange } = useMarkerChange(); + const { openCalls, setOpenCalls } = useDispatchMapState((state) => ({ + openCalls: state.openCalls, + setOpenCalls: state.setOpenCalls, + })); + const callsWithPosition = React.useMemo(() => { + return calls911State.calls.filter( + (v) => v.gtaMapPosition || (v.position?.lat && v.position.lng), + ); + }, [calls911State.calls]); useListener( SocketEvents.Create911Call, @@ -57,31 +58,28 @@ export function ActiveMapCalls({ hasMarker, setOpenItems, openItems, setMarker } ); return ( - portalRef && - createPortal( -
-

{t("active911Calls")}

- {calls911State.calls.length <= 0 ? ( -

{t("no911Calls")}

- ) : ( - - {calls911State.calls.map((call) => { - return ( - - ); - })} - - )} +
+ {calls911State.calls.length <= 0 ? ( +

{t("no911Calls")}

+ ) : ( + + {calls911State.calls.map((call) => { + return ( + callsWithPosition.some((c) => c.id === callId)} + setMarker={handleMarkerChange} + key={call.id} + call={call} + /> + ); + })} + + )} - calls911State.setCurrentlySelectedCall(null)} - call={calls911State.currentlySelectedCall} - /> -
, - portalRef, - ) + calls911State.setCurrentlySelectedCall(null)} + call={calls911State.currentlySelectedCall} + /> +
); } diff --git a/apps/client/src/components/dispatch/map/calls/call-item.tsx b/apps/client/src/components/dispatch/map/calls/call-item.tsx index f145e558b..62b90dd42 100644 --- a/apps/client/src/components/dispatch/map/calls/call-item.tsx +++ b/apps/client/src/components/dispatch/map/calls/call-item.tsx @@ -11,13 +11,14 @@ import { } from "@snailycad/ui"; import { useModal } from "state/modalState"; import type { Full911Call } from "state/dispatch/dispatch-state"; -import type { MapCallProps } from "./active-map-calls"; import { useTranslations } from "next-intl"; import { isUnitCombined } from "@snailycad/utils"; import { useCall911State } from "state/dispatch/call-911-state"; import { CallDescription } from "components/dispatch/active-calls/CallDescription"; -interface CallItemProps extends Omit { +interface CallItemProps { + hasMarker(callId: string): boolean; + setMarker(call: Full911Call, type: "remove" | "set"): void; call: Full911Call; } diff --git a/apps/client/src/components/dispatch/map/calls/render-active-map-calls.tsx b/apps/client/src/components/dispatch/map/calls/render-active-map-calls.tsx index d28803d3c..e54815498 100644 --- a/apps/client/src/components/dispatch/map/calls/render-active-map-calls.tsx +++ b/apps/client/src/components/dispatch/map/calls/render-active-map-calls.tsx @@ -1,16 +1,13 @@ import * as React from "react"; -import { icon as leafletIcon, type LeafletEvent } from "leaflet"; -import useFetch from "lib/useFetch"; +import { icon as leafletIcon } from "leaflet"; import { Marker, Popup, Tooltip, useMap } from "react-leaflet"; -import type { Full911Call } from "state/dispatch/dispatch-state"; -import { ActiveMapCalls } from "./active-map-calls"; import { convertToMap } from "lib/map/utils"; import { Button } from "@snailycad/ui"; import { useTranslations } from "next-intl"; -import type { Put911CallByIdData } from "@snailycad/types/api"; import { useCall911State } from "state/dispatch/call-911-state"; import { CallDescription } from "components/dispatch/active-calls/CallDescription"; import { MapItem, useDispatchMapState } from "state/mapState"; +import { useMarkerChange } from "./use-marker-change"; const CALL_ICON_SIZE = 30; @@ -23,145 +20,79 @@ const CALL_ICON = leafletIcon({ }); export function RenderActiveCalls() { - const [openItems, setOpenItems] = React.useState([]); + const hiddenItems = useDispatchMapState((state) => state.hiddenItems); + const { openCalls, setOpenCalls } = useDispatchMapState((state) => ({ + setOpenCalls: state.setOpenCalls, + openCalls: state.openCalls, + })); const map = useMap(); const t = useTranslations("Calls"); - const { execute } = useFetch(); + const { handleMarkerChange, handleMoveEnd } = useMarkerChange(); - const hiddenItems = useDispatchMapState((state) => state.hiddenItems); - const { setCalls, calls } = useCall911State((state) => ({ - setCalls: state.setCalls, - calls: state.calls, - })); + const calls = useCall911State((state) => state.calls); const callsWithPosition = React.useMemo(() => { return calls.filter((v) => v.gtaMapPosition || (v.position?.lat && v.position.lng)); }, [calls]); - function handleCallStateUpdate(callId: string, data: Full911Call) { - const prevIdx = calls.findIndex((v) => v.id === callId); - if (prevIdx !== -1) { - calls[prevIdx] = data; - } - - setCalls(calls); - } - - async function handleMoveEnd(e: LeafletEvent, call: Full911Call) { - const latLng = e.target._latlng; - const data = { - ...call, - gtaMapPosition: null, - gtaMapPositionId: null, - position: { id: call.positionId ?? "", ...latLng }, - }; - - handleCallStateUpdate(call.id, data); - - const { json } = await execute({ - path: `/911-calls/${call.id}`, - method: "PUT", - data: { - gtaMapPosition: null, - gtaMapPositionId: null, - position: data.position, - }, - }); - - handleCallStateUpdate(call.id, { ...data, ...json }); - } - - async function handleMarkerChange(call: Full911Call, type: "remove" | "set") { - const index = calls.findIndex((v) => v.id === call.id); - const coords = convertToMap(150 * index, 0, map); - - const callData = - type === "set" - ? { ...call, position: { ...coords, id: "null" } } - : { ...call, position: null }; - - handleCallStateUpdate(call.id, callData); - - const { json } = await execute({ - path: `/911-calls/${call.id}`, - method: "PUT", - data: { - position: callData.position, - }, - }); - - handleCallStateUpdate(call.id, { ...callData, ...json }); - } - function handleToggle(callId: string) { - setOpenItems((p) => { - if (p.includes(callId)) { - return p.filter((v) => v !== callId); - } + const newOpenCalls = openCalls.includes(callId) + ? openCalls.filter((v) => v !== callId) + : [...openCalls, callId]; - return [...p, callId]; - }); + setOpenCalls(newOpenCalls); } return ( - <> - {!hiddenItems[MapItem.CALLS] && - callsWithPosition.map((call) => { - const callGtaPosition = - call.gtaMapPosition && call.gtaMapPositionId - ? convertToMap(call.gtaMapPosition.x, call.gtaMapPosition.y, map) - : null; - const callPosition = call.position as { lat: number; lng: number }; - const position = callGtaPosition ?? callPosition; - - return ( - handleMoveEnd(e, call), - }} - draggable - key={call.id} - position={position} - icon={CALL_ICON} - > - {t("dragToMoveCallBlip")} - - -

- {t("location")}: {call.location} -

-

- {t("caller")}: {call.name} -

-
- {t("description")}: -
- -
- - -
-
-
- ); - })} - - callsWithPosition.some((v) => v.id === callId)} - setMarker={handleMarkerChange} - /> - + !hiddenItems[MapItem.CALLS] && + callsWithPosition.map((call) => { + const callGtaPosition = + call.gtaMapPosition && call.gtaMapPositionId + ? convertToMap(call.gtaMapPosition.x, call.gtaMapPosition.y, map) + : null; + const callPosition = call.position as { lat: number; lng: number }; + const position = callGtaPosition ?? callPosition; + + return ( + handleMoveEnd(e, call), + }} + draggable + key={call.id} + position={position} + icon={CALL_ICON} + > + {t("dragToMoveCallBlip")} + + +

+ {t("location")}: {call.location} +

+

+ {t("caller")}: {call.name} +

+
+ {t("description")}: +
+ +
+ + +
+
+
+ ); + }) ); } diff --git a/apps/client/src/components/dispatch/map/calls/use-marker-change.ts b/apps/client/src/components/dispatch/map/calls/use-marker-change.ts new file mode 100644 index 000000000..822b5f108 --- /dev/null +++ b/apps/client/src/components/dispatch/map/calls/use-marker-change.ts @@ -0,0 +1,87 @@ +import type { Put911CallByIdData } from "@snailycad/types/api"; +import type { LeafletEvent } from "leaflet"; +import { convertToMap } from "lib/map/utils"; +import useFetch from "lib/useFetch"; +import * as React from "react"; +import type { Full911Call } from "state/dispatch/active-dispatcher-state"; +import { useCall911State } from "state/dispatch/call-911-state"; +import { useMapStore } from "state/mapState"; + +export function useMarkerChange() { + const map = useMapStore((state) => state.map); + const { execute } = useFetch(); + const { calls, setCalls } = useCall911State((state) => ({ + calls: state.calls, + setCalls: state.setCalls, + })); + + const handleCallStateUpdate = React.useCallback( + (callId: string, data: Full911Call) => { + const prevIdx = calls.findIndex((v) => v.id === callId); + if (prevIdx !== -1) { + calls[prevIdx] = data; + } + + setCalls(calls); + }, + [calls], // eslint-disable-line react-hooks/exhaustive-deps + ); + + const handleMoveEnd = React.useCallback( + async (e: LeafletEvent, call: Full911Call) => { + const latLng = e.target._latlng; + const data = { + ...call, + gtaMapPosition: null, + gtaMapPositionId: null, + position: { id: call.positionId ?? "", ...latLng }, + }; + + handleCallStateUpdate(call.id, data); + + const { json } = await execute({ + path: `/911-calls/${call.id}`, + method: "PUT", + data: { + gtaMapPosition: null, + gtaMapPositionId: null, + position: data.position, + }, + }); + + handleCallStateUpdate(call.id, { ...data, ...json }); + }, + [handleCallStateUpdate], // eslint-disable-line react-hooks/exhaustive-deps + ); + + const handleMarkerChange = React.useCallback( + async (call: Full911Call, type: "remove" | "set") => { + if (!map) return; + const index = calls.findIndex((v) => v.id === call.id); + const coords = convertToMap(150 * index, 0, map); + + const callData = + type === "set" + ? { ...call, position: { ...coords, id: "null" } } + : { ...call, position: null }; + + handleCallStateUpdate(call.id, callData); + + const { json } = await execute({ + path: `/911-calls/${call.id}`, + method: "PUT", + data: { + position: callData.position, + }, + }); + + handleCallStateUpdate(call.id, { ...callData, ...json }); + }, + [handleCallStateUpdate, map, calls], // eslint-disable-line react-hooks/exhaustive-deps + ); + + return { + handleMarkerChange, + handleMoveEnd, + }; +} diff --git a/apps/client/src/components/dispatch/map/map.tsx b/apps/client/src/components/dispatch/map/map.tsx index 570dd4ffa..f00f17e2b 100644 --- a/apps/client/src/components/dispatch/map/map.tsx +++ b/apps/client/src/components/dispatch/map/map.tsx @@ -9,51 +9,62 @@ import { RenderMapPlayers } from "./units/render-map-players"; import { SelectMapServerModal } from "./modals/select-map-server-modal"; import { RenderMapSmartSigns } from "./smart-signs/render-map-smart-signs"; import { RenderMapSmartMotorwaySigns } from "./smart-motorway-signs/render-map-smart-motorway-signs"; +import { MapSidebar } from "./sidebar/map-sidebar"; +import { useMapStore } from "state/mapState"; const TILES_URL = "/tiles/minimap_sea_{y}_{x}.webp" as const; export function Map() { - const [map, setMap] = React.useState(); - const bounds = React.useMemo(() => (map ? getMapBounds(map) : undefined), [map]); + const mapStore = useMapStore(); + + const bounds = React.useMemo( + () => (mapStore.map ? getMapBounds(mapStore.map) : undefined), + [mapStore.map], + ); React.useEffect(() => { if (bounds) { - map?.setMaxBounds(bounds); - map?.fitBounds(bounds); - map?.setZoom(-2); - map?.getBounds(); + mapStore.map?.setMaxBounds(bounds); + mapStore.map?.fitBounds(bounds); + mapStore.map?.setZoom(-2); + mapStore.map?.getBounds(); } }, [bounds]); // eslint-disable-line react-hooks/exhaustive-deps return ( - { - map && setMap(map); - }} - style={{ zIndex: 1, height: "calc(100vh - 3.5rem)", width: "100%" }} - crs={CRS.Simple} - center={[0, 0]} - zoom={-2} - bounds={bounds} - zoomControl={false} - > - - - - - - - - - - - + <> + + + { + map && mapStore.setMap(map); + }} + style={{ zIndex: 1, height: "calc(100vh - 3.5rem)", width: "100%" }} + crs={CRS.Simple} + center={[0, 0]} + zoom={-2} + bounds={bounds} + zoomControl={false} + > + + + + + + + + + + + + ); } diff --git a/apps/client/src/components/dispatch/map/sidebar/map-sidebar.tsx b/apps/client/src/components/dispatch/map/sidebar/map-sidebar.tsx new file mode 100644 index 000000000..74d50fa3f --- /dev/null +++ b/apps/client/src/components/dispatch/map/sidebar/map-sidebar.tsx @@ -0,0 +1,29 @@ +import { TabList, TabsContent } from "@snailycad/ui"; +import { useTranslations } from "use-intl"; +import { ActiveMapCalls } from "../calls/active-map-calls"; +import { ActiveMapUnits } from "../units/active-map-units"; + +export function MapSidebar() { + const t = useTranslations(); + + const tabs = [ + { value: "active-calls", name: t("Calls.active911Calls") }, + { + value: "active-units", + name: t("Leo.activeUnits"), + }, + ]; + + return ( + + ); +} diff --git a/apps/client/src/components/dispatch/map/units/active-map-units.tsx b/apps/client/src/components/dispatch/map/units/active-map-units.tsx index 369e54fd0..6a39e19d5 100644 --- a/apps/client/src/components/dispatch/map/units/active-map-units.tsx +++ b/apps/client/src/components/dispatch/map/units/active-map-units.tsx @@ -2,28 +2,23 @@ import * as React from "react"; import type { CombinedEmsFdUnit, CombinedLeoUnit, EmsFdDeputy, Officer } from "@snailycad/types"; import { useActiveDeputies } from "hooks/realtime/useActiveDeputies"; import { useActiveOfficers } from "hooks/realtime/useActiveOfficers"; -import type { MapPlayer, PlayerDataEventPayload } from "types/map"; -import { createPortal } from "react-dom"; -import { usePortal } from "@casperiv/useful"; import { useTranslations } from "next-intl"; import { UnitItem } from "./unit-item"; import { ManageUnitModal } from "components/dispatch/active-units/modals/manage-unit-modal"; import { useMapPlayersStore } from "hooks/realtime/use-map-players"; import { createMapUnitsFromActiveUnits } from "lib/map/create-map-units-from-active-units.ts"; import { Accordion } from "@snailycad/ui"; +import { useDispatchMapState } from "state/mapState"; -interface Props { - players: (MapPlayer | PlayerDataEventPayload)[]; - openItems: string[]; - setOpenItems: React.Dispatch>; -} - -export function ActiveMapUnits({ openItems, setOpenItems }: Props) { +export function ActiveMapUnits() { const [tempUnit, setTempUnit] = React.useState< null | Officer | EmsFdDeputy | CombinedEmsFdUnit | CombinedLeoUnit >(null); const players = useMapPlayersStore((state) => state.players); - const portalRef = usePortal("ActiveMapCalls"); + const { openUnits, setOpenUnits } = useDispatchMapState((state) => ({ + openUnits: state.openUnits, + setOpenUnits: state.setOpenUnits, + })); const t = useTranslations("Leo"); const { activeOfficers } = useActiveOfficers(); @@ -35,32 +30,24 @@ export function ActiveMapUnits({ openItems, setOpenItems }: Props) { }); return ( - portalRef && - createPortal( -
-

{t("activeUnits")}

- {units.length <= 0 ? ( -

{t("noActiveUnits")}

- ) : ( - - {units.map((player, idx) => { - return ( - - ); - })} - - )} +
+ {units.length <= 0 ? ( +

{t("noActiveUnits")}

+ ) : ( + + {units.map((player, idx) => { + return ( + + ); + })} + + )} - {tempUnit ? setTempUnit(null)} unit={tempUnit} /> : null} -
, - portalRef, - ) + {tempUnit ? setTempUnit(null)} unit={tempUnit} /> : null} +
); } diff --git a/apps/client/src/components/dispatch/map/units/render-map-players.tsx b/apps/client/src/components/dispatch/map/units/render-map-players.tsx index 2f82e8c80..0d1cb9122 100644 --- a/apps/client/src/components/dispatch/map/units/render-map-players.tsx +++ b/apps/client/src/components/dispatch/map/units/render-map-players.tsx @@ -1,35 +1,25 @@ import * as React from "react"; -import { ActiveMapUnits } from "./active-map-units"; import { PlayerMarker } from "./player-marker"; import { useMapPlayers } from "hooks/realtime/use-map-players"; +import { useDispatchMapState } from "state/mapState"; export function RenderMapPlayers() { - const [openItems, setOpenItems] = React.useState([]); + const { openUnits, setOpenUnits } = useDispatchMapState((state) => ({ + openUnits: state.openUnits, + setOpenUnits: state.setOpenUnits, + })); const { players } = useMapPlayers(); function handleToggle(name: string) { - setOpenItems((p) => { - if (p.includes(name)) { - return p.filter((v) => v !== name); - } - - return [...p, name]; - }); + const newOpenUnits = openUnits.includes(name) + ? openUnits.filter((v) => v !== name) + : [...openUnits, name]; + setOpenUnits(newOpenUnits); } const playerValues = React.useMemo(() => [...players.values()], [players]); - return ( - <> - {playerValues.map((player, idx) => ( - - ))} - - - - ); + return playerValues.map((player, idx) => ( + + )); } diff --git a/apps/client/src/pages/dispatch/map.tsx b/apps/client/src/pages/dispatch/map.tsx index 29e96039c..b61d2d67d 100644 --- a/apps/client/src/pages/dispatch/map.tsx +++ b/apps/client/src/pages/dispatch/map.tsx @@ -65,7 +65,7 @@ export default function MapPage(props: Props) { diff --git a/apps/client/src/state/mapState.ts b/apps/client/src/state/mapState.ts index c0b13089e..88c6aa40a 100644 --- a/apps/client/src/state/mapState.ts +++ b/apps/client/src/state/mapState.ts @@ -25,6 +25,12 @@ interface DispatchMapState { currentMapServerURL: string | null; setCurrentMapServerURL(url: string): void; + + openUnits: string[]; + setOpenUnits(units: string[]): void; + + openCalls: string[]; + setOpenCalls(calls: string[]): void; } interface SocketStore { @@ -35,9 +41,24 @@ interface SocketStore { setStatus(status: ConnectionStatus): void; } +interface MapStore { + map: L.Map | null; + setMap(map: L.Map): void; +} + export const useDispatchMapState = createWithEqualityFn()( persist( (set) => ({ + openCalls: [], + setOpenCalls(calls) { + set({ openCalls: calls }); + }, + + openUnits: [], + setOpenUnits(units) { + set({ openUnits: units }); + }, + smartSigns: [], setSmartSigns(signs) { set({ smartSigns: signs }); @@ -89,3 +110,13 @@ export const useSocketStore = createWithEqualityFn()( }), shallow, ); + +export const useMapStore = createWithEqualityFn()( + (set) => ({ + map: null, + setMap(map) { + set({ map }); + }, + }), + shallow, +);