Merged in bugfix/minor-issues-5 (pull request #21)

Bugfix/minor issues 5
This commit is contained in:
2025-10-15 12:56:03 +00:00
23 changed files with 254 additions and 344 deletions

3
.prettierrc Normal file
View File

@@ -0,0 +1,3 @@
{
"printWidth": 120
}

View File

@@ -1,19 +0,0 @@
TODO:
Hotlist upload (Question for Dion about API) and hits popping up in sighting stack.
NPED API working and catagories popping up in sighting stack. Images added to public folder.
Make the friendly name of each camera permeate throughout.
Make favicon MAV logo.
Swipe down to get to session page.
I have made an error I don't know how to fix in SightingFeedProvider.tsx
There is a bug in /front-camera-settings where the navigation arrow doesn't have a transparent background. I don't know why it is only that one and I can't find out why. Very strange.
The selected sighting in the sighting stack seems a tad buggy. Sometimes multiple get selected.
Can the selected sighting be shown in full detail. How this will look is still up for debate. Either as a pop up card as in AiQ Flexi, or in the OVerview card??
How do you know if the time has sync? Make UTC red if not sync.
Can the relative aspect ratio in SightingOverview.tsx be the ratio of image pixel size of the image to best take advantage of the space?
obscure details on dashboard to a toggle
FYI:
Session, WiFi and Modem stuff isn't implimented in the backend. Those are just placeholders for now.

View File

@@ -1,10 +1,5 @@
import { Formik, Field, Form } from "formik"; import { Formik, Field, Form } from "formik";
import type { import type { CameraConfig, CameraSettingErrorValues, CameraSettingValues, ZoomInOptions } from "../../types/types";
CameraConfig,
CameraSettingErrorValues,
CameraSettingValues,
ZoomInOptions,
} from "../../types/types";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faEye, faEyeSlash } from "@fortawesome/free-regular-svg-icons"; import { faEye, faEyeSlash } from "@fortawesome/free-regular-svg-icons";
@@ -28,8 +23,7 @@ const CameraSettingFields = ({
updateCameraConfigError, updateCameraConfigError,
}: CameraSettingsProps) => { }: CameraSettingsProps) => {
const [showPwd, setShowPwd] = useState(false); const [showPwd, setShowPwd] = useState(false);
const cameraControllerSide = const cameraControllerSide = initialData?.id === "CameraA" ? "CameraControllerA" : "CameraControllerB";
initialData?.id === "CameraA" ? "CameraControllerA" : "CameraControllerB";
const { mutation, query } = useCameraZoom({ camera: cameraControllerSide }); const { mutation, query } = useCameraZoom({ camera: cameraControllerSide });
const zoomOptions = [1, 2, 4, 8]; const zoomOptions = [1, 2, 4, 8];
@@ -109,9 +103,7 @@ const CameraSettingFields = ({
<div className="flex flex-col space-y-2 relative"> <div className="flex flex-col space-y-2 relative">
<label htmlFor="friendlyName">Name</label> <label htmlFor="friendlyName">Name</label>
{touched.friendlyName && errors.friendlyName && ( {touched.friendlyName && errors.friendlyName && (
<small className="absolute right-0 top-0 text-red-500"> <small className="absolute right-0 top-0 text-red-500">{errors.friendlyName}</small>
{errors.friendlyName}
</small>
)} )}
<Field <Field
id="friendlyName" id="friendlyName"
@@ -126,9 +118,7 @@ const CameraSettingFields = ({
<div className="flex flex-col space-y-2 relative"> <div className="flex flex-col space-y-2 relative">
<label htmlFor="cameraAddress">Camera Address</label> <label htmlFor="cameraAddress">Camera Address</label>
{touched.cameraAddress && errors.cameraAddress && ( {touched.cameraAddress && errors.cameraAddress && (
<small className="absolute right-0 top-0 text-red-500"> <small className="absolute right-0 top-0 text-red-500">{errors.cameraAddress}</small>
{errors.cameraAddress}
</small>
)} )}
<Field <Field
id="cameraAddress" id="cameraAddress"
@@ -143,9 +133,7 @@ const CameraSettingFields = ({
<div className="flex flex-col space-y-2 relative"> <div className="flex flex-col space-y-2 relative">
<label htmlFor="userName">User Name</label> <label htmlFor="userName">User Name</label>
{touched.userName && errors.userName && ( {touched.userName && errors.userName && (
<small className="absolute right-0 top-0 text-red-500"> <small className="absolute right-0 top-0 text-red-500">{errors.userName}</small>
{errors.userName}
</small>
)} )}
<Field <Field
id="userName" id="userName"
@@ -161,9 +149,7 @@ const CameraSettingFields = ({
<div className="flex flex-col space-y-2 relative"> <div className="flex flex-col space-y-2 relative">
<label htmlFor="password">Password</label> <label htmlFor="password">Password</label>
{touched.password && errors.password && ( {touched.password && errors.password && (
<small className="absolute right-0 top-0 text-red-500"> <small className="absolute right-0 top-0 text-red-500">{errors.password}</small>
{errors.password}
</small>
)} )}
<div className="flex gap-2 items-center relative mb-4"> <div className="flex gap-2 items-center relative mb-4">
<Field <Field
@@ -209,10 +195,7 @@ const CameraSettingFields = ({
</div> </div>
<div className="mt-3"> <div className="mt-3">
{updateCameraConfigError ? ( {updateCameraConfigError ? (
<button <button className="bg-red-500 text-white rounded-lg p-2 mx-auto h-[100%] w-full" disabled>
className="bg-red-500 text-white rounded-lg p-2 mx-auto h-[100%] w-full"
disabled
>
Retry Retry
</button> </button>
) : ( ) : (

View File

@@ -15,8 +15,7 @@ const CameraSettings = ({
zoomLevel?: number; zoomLevel?: number;
onZoomLevelChange?: (level: number) => void; onZoomLevelChange?: (level: number) => void;
}) => { }) => {
const { data, updateCameraConfig, updateCameraConfigError } = const { data, updateCameraConfig, updateCameraConfigError } = useFetchCameraConfig(side);
useFetchCameraConfig(side);
return ( return (
<Card className="overflow-hidden min-h-[40vh] md:min-h-[60vh] max-h-[80vh] lg:w-[40%] p-4"> <Card className="overflow-hidden min-h-[40vh] md:min-h-[60vh] max-h-[80vh] lg:w-[40%] p-4">

View File

@@ -43,9 +43,7 @@ const AlertItem = ({ item }: AlertItemProps) => {
path: "alertHistory", path: "alertHistory",
}); });
const oldArray = res?.result; const oldArray = res?.result;
const updatedArray = oldArray?.filter( const updatedArray = oldArray?.filter((item: SightingType) => item?.ref !== deletedItem?.ref);
(item: SightingType) => item?.ref !== deletedItem?.ref
);
mutation.mutate({ mutation.mutate({
operation: "INSERT", operation: "INSERT",
@@ -58,45 +56,14 @@ const AlertItem = ({ item }: AlertItemProps) => {
<div className="flex flex-col w-full"> <div className="flex flex-col w-full">
<div className="border border-gray-600 rounded-lg items-center py-1"> <div className="border border-gray-600 rounded-lg items-center py-1">
<InfoBar obj={item} /> <InfoBar obj={item} />
<div <div className="flex flex-row p-4 w-full mx-auto justify-between" onClick={handleClick}>
className=" flex flex-row p-4 w-full mx-auto justify-between" {isHotListHit && <img src={HotListImg} alt="hotlistHit" className="h-20 object-contain rounded-md" />}
onClick={handleClick} {isNPEDHitA && <img src={NPED_CAT_A} alt="NPEDHITicon" className="h-20 object-contain rounded-md" />}
> {isNPEDHitB && <img src={NPED_CAT_B} alt="NPEDHITicon" className="h-20 object-contain rounded-md" />}
{isHotListHit && ( {isNPEDHitC && <img src={NPED_CAT_C} alt="NPEDHITicon" className="h-20 object-contain rounded-md" />}
<img <div className={`border p-1 hidden md:block`}>
src={HotListImg} <img src={item?.plateUrlColour} height={48} width={200} alt="colour patch" />
alt="hotlistHit"
className="h-20 object-contain rounded-md"
/>
)}
{isNPEDHitA && (
<img
src={NPED_CAT_A}
alt="NPEDHITicon"
className="h-20 object-contain rounded-md"
/>
)}
{isNPEDHitB && (
<img
src={NPED_CAT_B}
alt="NPEDHITicon"
className="h-20 object-contain rounded-md"
/>
)}
{isNPEDHitC && (
<img
src={NPED_CAT_C}
alt="NPEDHITicon"
className="h-20 object-contain rounded-md"
/>
)}
<div className="flex flex-col">
<small>MAKE: {item.make}</small>
<small>MODEL: {item.model}</small>
<small>COLOUR: {item.color}</small>
</div> </div>
<NumberPlate vrm={item.vrm} motion={motionAway} /> <NumberPlate vrm={item.vrm} motion={motionAway} />
</div> </div>
<SightingModal <SightingModal

View File

@@ -120,6 +120,7 @@ const ChannelFields = () => {
</FormGroup> </FormGroup>
<FormGroup> <FormGroup>
<label htmlFor="password">Password</label> <label htmlFor="password">Password</label>
<div className="flex gap-2 items-center relative mb-4">
<Field <Field
name={"password"} name={"password"}
type={showPwd ? "text" : "password"} type={showPwd ? "text" : "password"}
@@ -137,7 +138,9 @@ const ChannelFields = () => {
onClick={() => setShowPwd((s) => !s)} onClick={() => setShowPwd((s) => !s)}
icon={showPwd ? faEyeSlash : faEye} icon={showPwd ? faEyeSlash : faEye}
/> />
</div>
</FormGroup> </FormGroup>
<FormGroup> <FormGroup>
<label htmlFor="connectTimeoutSeconds"> <label htmlFor="connectTimeoutSeconds">
Connect Timeout Seconds Connect Timeout Seconds

View File

@@ -73,24 +73,26 @@ const NPEDFields = () => {
</FormGroup> </FormGroup>
<FormGroup> <FormGroup>
<label htmlFor="password">Password</label> <label htmlFor="password">Password</label>
{touched.password && errors.password && ( <div className="flex gap-2 items-center relative mb-4">
<small className="absolute right-0 -top-5 text-red-500">
{errors.password}
</small>
)}
<Field <Field
name="password" name="password"
type={showPwd ? "text" : "password"} type={showPwd ? "text" : "password"}
id="password" id="password"
placeholder="NPED Password" placeholder="NPED Password"
className="p-1.5 border border-gray-400 rounded-lg" className="p-2 border border-gray-400 rounded-lg w-full"
/> />
{touched.password && errors.password && (
<small className="absolute right-0 -top-5 text-red-500">
{errors.password}
</small>
)}
<FontAwesomeIcon <FontAwesomeIcon
type="button" type="button"
className="absolute right-5 end-0" className="absolute right-5 end-0"
onClick={() => setShowPwd((s) => !s)} onClick={() => setShowPwd((s) => !s)}
icon={showPwd ? faEyeSlash : faEye} icon={showPwd ? faEyeSlash : faEye}
/> />
</div>
</FormGroup> </FormGroup>
<FormGroup> <FormGroup>
<label htmlFor="clientId">Client ID</label> <label htmlFor="clientId">Client ID</label>

View File

@@ -4,9 +4,12 @@ import type { ModemSettingsType } from "../../../types/types";
import { useWifiAndModem } from "../../../hooks/useCameraWifiandModem"; import { useWifiAndModem } from "../../../hooks/useCameraWifiandModem";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import ModemToggle from "./ModemToggle"; import ModemToggle from "./ModemToggle";
import { faEyeSlash, faEye } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
const ModemSettings = () => { const ModemSettings = () => {
const [showSettings, setShowSettings] = useState(false); const [showSettings, setShowSettings] = useState(false);
const [showPwd, setShowPwd] = useState(false);
const { modemQuery, modemMutation } = useWifiAndModem(); const { modemQuery, modemMutation } = useWifiAndModem();
const apn = modemQuery?.data?.propAPN?.value; const apn = modemQuery?.data?.propAPN?.value;
@@ -102,13 +105,21 @@ const ModemSettings = () => {
> >
Password Password
</label> </label>
<div className="flex gap-2 items-center relative mb-4">
<Field <Field
placeholder="Enter Password"
name="password"
id="password" id="password"
type="text" name="password"
className="p-1.5 border border-gray-400 rounded-lg" type={showPwd ? "text" : "password"}
className="p-2 border border-gray-400 rounded-lg w-full"
placeholder="Enter Password"
/> />
<FontAwesomeIcon
type="button"
className="absolute right-5 end-0"
onClick={() => setShowPwd((s) => !s)}
icon={showPwd ? faEyeSlash : faEye}
/>
</div>
</FormGroup> </FormGroup>
<FormGroup> <FormGroup>
<label <label

View File

@@ -66,11 +66,12 @@ const WiFiSettingsForm = () => {
> >
Password Password
</label> </label>
<div className="flex gap-2 items-center relative mb-4">
<Field <Field
id="password" id="password"
name="password" name="password"
type={showPwd ? "text" : "password"} type={showPwd ? "text" : "password"}
className="p-1.5 border border-gray-400 rounded-lg" className="p-2 border border-gray-400 rounded-lg w-full"
placeholder="Enter Password" placeholder="Enter Password"
/> />
<FontAwesomeIcon <FontAwesomeIcon
@@ -79,6 +80,7 @@ const WiFiSettingsForm = () => {
onClick={() => setShowPwd((s) => !s)} onClick={() => setShowPwd((s) => !s)}
icon={showPwd ? faEyeSlash : faEye} icon={showPwd ? faEyeSlash : faEye}
/> />
</div>
</FormGroup> </FormGroup>
<FormGroup> <FormGroup>
<label <label

View File

@@ -6,7 +6,7 @@ type FormGroupProps = {
const FormGroup = ({ children }: FormGroupProps) => { const FormGroup = ({ children }: FormGroupProps) => {
return ( return (
<div className="flex flex-col md:flex-row items-center justify-between relative"> <div className="flex flex-col md:flex-row md:items-center justify-between relative">
{children} {children}
</div> </div>
); );

View File

@@ -10,7 +10,7 @@ import HotListImg from "/Hotlist_Hit.svg";
import NPED_CAT_A from "/NPED_Cat_A.svg"; import NPED_CAT_A from "/NPED_Cat_A.svg";
import NPED_CAT_B from "/NPED_Cat_B.svg"; import NPED_CAT_B from "/NPED_Cat_B.svg";
import NPED_CAT_C from "/NPED_Cat_C.svg"; import NPED_CAT_C from "/NPED_Cat_C.svg";
import { checkIsHotListHit, getNPEDCategory } from "../../utils/utils"; import { checkIsHotListHit, getHotlistName, getNPEDCategory } from "../../utils/utils";
type SightingModalProps = { type SightingModalProps = {
isSightingModalOpen: boolean; isSightingModalOpen: boolean;
@@ -19,15 +19,12 @@ type SightingModalProps = {
onDelete?: (deletedItem: SightingType | null) => void; onDelete?: (deletedItem: SightingType | null) => void;
}; };
const SightingModal = ({ const SightingModal = ({ isSightingModalOpen, handleClose, sighting, onDelete }: SightingModalProps) => {
isSightingModalOpen,
handleClose,
sighting,
onDelete,
}: SightingModalProps) => {
const { dispatch } = useAlertHitContext(); const { dispatch } = useAlertHitContext();
const { query, mutation } = useCameraBlackboard(); const { query, mutation } = useCameraBlackboard();
const hotlistName = getHotlistName(sighting?.metadata?.hotlistMatches);
const handleAcknowledgeButton = () => { const handleAcknowledgeButton = () => {
try { try {
if (!sighting) { if (!sighting) {
@@ -78,9 +75,7 @@ const SightingModal = ({
<ModalComponent isModalOpen={isSightingModalOpen} close={handleClose}> <ModalComponent isModalOpen={isSightingModalOpen} close={handleClose}>
<div className="max-w-screen-lg mx-auto py-4 px-2"> <div className="max-w-screen-lg mx-auto py-4 px-2">
<div className="border-b border-gray-600 mb-4"> <div className="border-b border-gray-600 mb-4">
<h2 className="text-lg md:text-xl font-semibold"> <h2 className="text-lg md:text-xl font-semibold">Sighting Details</h2>
Sighting Details
</h2>
</div> </div>
<div className="mt-3 flex-col-reverse gap-3 md:flex-row md:justify-center flex md:hidden"> <div className="mt-3 flex-col-reverse gap-3 md:flex-row md:justify-center flex md:hidden">
{onDelete ? ( {onDelete ? (
@@ -121,41 +116,19 @@ const SightingModal = ({
<div className="flex flex-col md:flex-row gap-3 items-center mb-2 justify-between"> <div className="flex flex-col md:flex-row gap-3 items-center mb-2 justify-between">
<div className="flex flex-col md:flex-row gap-3 items-center"> <div className="flex flex-col md:flex-row gap-3 items-center">
<NumberPlate vrm={sighting?.vrm} motion={motionAway} /> <NumberPlate vrm={sighting?.vrm} motion={motionAway} />
<img <img src={sighting?.plateUrlColour} alt="plate patch" className="h-16 object-contain rounded-md" />
src={sighting?.plateUrlColour} {hotlistName && (
alt="plate patch" <div>
className="h-16 object-contain rounded-md" <p className="text-gray-300">Hotlist</p>
/> <p className="font-medium text-2xl break-all">{hotlistName ? hotlistName[0] : "-"}</p>
</div>
)}
</div> </div>
{isHotListHit && ( {isHotListHit && <img src={HotListImg} alt="hotlistHit" className="h-20 object-contain rounded-md" />}
<img {isNPEDHitA && <img src={NPED_CAT_A} alt="hotlistHit" className="h-20 object-contain rounded-md" />}
src={HotListImg} {isNPEDHitB && <img src={NPED_CAT_B} alt="hotlistHit" className="h-20 object-contain rounded-md" />}
alt="hotlistHit" {isNPEDHitC && <img src={NPED_CAT_C} alt="hotlistHit" className="h-20 object-contain rounded-md" />}
className="h-20 object-contain rounded-md"
/>
)}
{isNPEDHitA && (
<img
src={NPED_CAT_A}
alt="hotlistHit"
className="h-20 object-contain rounded-md"
/>
)}
{isNPEDHitB && (
<img
src={NPED_CAT_B}
alt="hotlistHit"
className="h-20 object-contain rounded-md"
/>
)}
{isNPEDHitC && (
<img
src={NPED_CAT_C}
alt="hotlistHit"
className="h-20 object-contain rounded-md"
/>
)}
</div> </div>
<div className="flex flex-col lg:flex-row items-center gap-3"> <div className="flex flex-col lg:flex-row items-center gap-3">
<img <img
@@ -164,52 +137,37 @@ const SightingModal = ({
className="w-full h-56 sm:h-72 md:h-96 rounded-lg object-cover border border-gray-700" className="w-full h-56 sm:h-72 md:h-96 rounded-lg object-cover border border-gray-700"
/> />
<aside className="w-full lg:w-80 bg-gray-800/70 text-white rounded-xl py-4 px-2 border h-[70%] border-gray-700"> <aside className="w-full lg:w-80 bg-gray-800/70 text-white rounded-xl py-4 px-2 border h-[70%] border-gray-700">
<h3 className="text-base md:text-lg font-semibold pb-2 border-b border-gray-700"> <h3 className="text-base md:text-lg font-semibold pb-2 border-b border-gray-700">Vehicle Info</h3>
Vehicle Info
</h3>
<dl className="mt-3 gap-x-4 gap-y-2 text-sm"> <dl className="mt-3 gap-x-4 gap-y-2 text-sm">
<div> <div>
<dt className="text-gray-300">VRM</dt> <dt className="text-gray-300">VRM</dt>
<dd className="font-medium text-2xl break-all"> <dd className="font-medium text-2xl break-all">{sighting?.vrm ?? "-"}</dd>
{sighting?.vrm ?? "-"}
</dd>
</div> </div>
<div> <div>
<dt className="text-gray-300">Motion</dt> <dt className="text-gray-300">Motion</dt>
<dd className="font-medium text-2xl"> <dd className="font-medium text-2xl">{sighting?.motion ?? "-"}</dd>
{sighting?.motion ?? "-"}
</dd>
</div> </div>
<div> <div>
<dt className="text-gray-300">Seen Count</dt> <dt className="text-gray-300">Seen Count</dt>
<dd className="font-medium text-2xl"> <dd className="font-medium text-2xl">{sighting?.seenCount ?? "-"}</dd>
{sighting?.seenCount ?? "-"}
</dd>
</div> </div>
<div> <div>
<dt className="text-gray-300">Make</dt> <dt className="text-gray-300">Make</dt>
<dd className="font-medium text-2xl"> <dd className="font-medium text-2xl">{sighting?.make ?? "-"}</dd>
{sighting?.make ?? "-"}
</dd>
</div> </div>
<div> <div>
<dt className="text-gray-300">Model</dt> <dt className="text-gray-300">Model</dt>
<dd className="font-medium text-2xl"> <dd className="font-medium text-2xl">{sighting?.model ?? "-"}</dd>
{sighting?.model ?? "-"}
</dd>
</div> </div>
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<dt className="text-gray-300">Colour</dt> <dt className="text-gray-300">Colour</dt>
<dd className="font-medium text-2xl"> <dd className="font-medium text-2xl">{sighting?.color ?? "-"}</dd>
{sighting?.color ?? "-"}
</dd>
</div> </div>
<div> <div>
<dt className="text-gray-300">Time</dt> <dt className="text-gray-300">Time</dt>
<dd className="font-medium text-xl"> <dd className="font-medium text-xl">{sighting?.timeStamp ?? "-"}</dd>
{sighting?.timeStamp ?? "-"}
</dd>
</div> </div>
</dl> </dl>
</aside> </aside>

View File

@@ -32,8 +32,8 @@ export default function Header() {
}; };
return ( return (
<div className="relative bg-[#253445] border-b border-gray-500 items-center mx-auto px-2 sm:px-6 lg:px-8 p-4 flex flex-col md:flex-row justify-between mb-7 space-y-6 md:space-y-0"> <div className="relative bg-[#253445] border-b border-gray-500 items-center mx-auto sm:px-3 lg:px-4 py-4 flex flex-col md:flex-row justify-between mb-7 space-y-6 md:space-y-0">
<div className="w-30"> <div className="w-28">
<Link to={"/"}> <Link to={"/"}>
<img src={Logo} alt="Logo" width={150} height={150} /> <img src={Logo} alt="Logo" width={150} height={150} />
</Link> </Link>

View File

@@ -11,13 +11,14 @@ const NavigationArrow = ({ side, settingsPage }: NavigationArrowProps) => {
const navigate = useNavigate(); const navigate = useNavigate();
const navigationDest = (side: string | undefined) => { const navigationDest = (side: string | undefined) => {
console.log(side);
if (settingsPage) { if (settingsPage) {
navigate("/"); navigate("/");
return; return;
} }
if (side === "Front") { if (side === "Front") {
navigate("/front-camera-settings"); navigate("/camera-settings");
} else if (side === "Rear") { } else if (side === "Rear") {
navigate("/Rear-Camera-settings"); navigate("/Rear-Camera-settings");
} }
@@ -28,13 +29,15 @@ const NavigationArrow = ({ side, settingsPage }: NavigationArrowProps) => {
<> <>
{side === "CameraA" ? ( {side === "CameraA" ? (
<FontAwesomeIcon <FontAwesomeIcon
size="2xl"
icon={faArrowRight} icon={faArrowRight}
className="absolute top-[50%] right-[2%] backdrop-blur-lg hover:cursor-pointer animate-bounce z-30" className="absolute top-[50%] right-[2%] backdrop-blur-lg hover:cursor-pointer animate-bounce z-30"
onClick={() => navigationDest(side)} onClick={() => navigationDest("Front")}
/> />
) : ( ) : (
<FontAwesomeIcon <FontAwesomeIcon
icon={faArrowLeft} icon={faArrowLeft}
size="2xl"
className="absolute top-[50%] left-[2%] backdrop-blur-md hover:cursor-pointer animate-bounce z-30" className="absolute top-[50%] left-[2%] backdrop-blur-md hover:cursor-pointer animate-bounce z-30"
onClick={() => navigationDest(side)} onClick={() => navigationDest(side)}
/> />
@@ -46,14 +49,16 @@ const NavigationArrow = ({ side, settingsPage }: NavigationArrowProps) => {
<> <>
<FontAwesomeIcon <FontAwesomeIcon
icon={faArrowLeft} icon={faArrowLeft}
className="absolute top-[50%] left-[2%] backdrop-blur-md hover:cursor-pointer animate-bounce z-30" size="2xl"
onClick={() => navigationDest(side)} className="absolute top-[50%] left-[2%] backdrop-blur-md hover:cursor-pointer animate-bounce z-100 "
onClick={() => navigationDest("Front")}
/> />
<FontAwesomeIcon <FontAwesomeIcon
icon={faArrowRight} icon={faArrowRight}
className="absolute top-[50%] right-[2%] backdrop-blur-md hover:cursor-pointer animate-bounce z-30" size="2xl"
onClick={() => navigationDest(side)} className="absolute top-[50%] right-[2%] backdrop-blur-md hover:cursor-pointer animate-bounce z-100"
onClick={() => navigationDest("Rear")}
/> />
</> </>
); );

View File

@@ -4,6 +4,7 @@ import type { SoundAction, SoundState } from "../types/types";
type SoundContextType = { type SoundContextType = {
state: SoundState; state: SoundState;
dispatch: Dispatch<SoundAction>; dispatch: Dispatch<SoundAction>;
audioArmed: boolean;
}; };
export const SoundContext = createContext<SoundContextType | undefined>( export const SoundContext = createContext<SoundContextType | undefined>(

View File

@@ -1,4 +1,11 @@
import { useEffect, useMemo, useReducer, type ReactNode } from "react"; import {
useEffect,
useMemo,
useReducer,
useRef,
useState,
type ReactNode,
} from "react";
import { SoundContext } from "../SoundContext"; import { SoundContext } from "../SoundContext";
import { initialState, reducer } from "../reducers/SoundContextReducer"; import { initialState, reducer } from "../reducers/SoundContextReducer";
import { useCameraBlackboard } from "../../hooks/useCameraBlackboard"; import { useCameraBlackboard } from "../../hooks/useCameraBlackboard";
@@ -8,6 +15,9 @@ type SoundContextProviderProps = {
}; };
const SoundContextProvider = ({ children }: SoundContextProviderProps) => { const SoundContextProvider = ({ children }: SoundContextProviderProps) => {
const audioReady = useRef(false);
const [audioArmed, setAudioArmed] = useState(false);
const audioCtxRef = useRef<AudioContext | null>(null);
const [state, dispatch] = useReducer(reducer, initialState); const [state, dispatch] = useReducer(reducer, initialState);
const { mutation } = useCameraBlackboard(); const { mutation } = useCameraBlackboard();
@@ -23,7 +33,40 @@ const SoundContextProvider = ({ children }: SoundContextProviderProps) => {
fetchSound(); fetchSound();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
const value = useMemo(() => ({ state, dispatch }), [state, dispatch]);
useEffect(() => {
const unlock = async () => {
if (!audioCtxRef.current) audioCtxRef.current = new AudioContext();
if (audioCtxRef.current.state !== "running") {
try {
await audioCtxRef.current.resume();
} catch {
/* empty */
}
}
const armed = audioCtxRef.current.state === "running";
audioReady.current = audioCtxRef.current.state === "running";
setAudioArmed(armed);
if (audioReady.current) {
window.removeEventListener("pointerdown", unlock);
window.removeEventListener("keydown", unlock);
window.removeEventListener("touchstart", unlock);
}
};
window.addEventListener("pointerdown", unlock, { once: false });
window.addEventListener("keydown", unlock, { once: false });
window.addEventListener("touchstart", unlock, { once: false });
return () => {
window.removeEventListener("pointerdown", unlock);
window.removeEventListener("keydown", unlock);
window.removeEventListener("touchstart", unlock);
};
}, []);
const value = useMemo(
() => ({ state, dispatch, audioArmed }),
[state, audioArmed]
);
return ( return (
<SoundContext.Provider value={value}>{children}</SoundContext.Provider> <SoundContext.Provider value={value}>{children}</SoundContext.Provider>
); );

View File

@@ -50,8 +50,7 @@ export const useCameraBlackboard = () => {
}); });
useEffect(() => { useEffect(() => {
if (query.isError) if (query.isError) toast.error(query.error.message, { id: "viewBlackboardData" });
toast.error(query.error.message, { id: "viewBlackboardData" });
}, [query?.error?.message, query.isError]); }, [query?.error?.message, query.isError]);
return { query, mutation }; return { query, mutation };

View File

@@ -14,11 +14,7 @@ const fetchCameraSideConfig = async ({ queryKey }: { queryKey: string[] }) => {
return response.json(); return response.json();
}; };
const updateCamerasideConfig = async (data: { const updateCamerasideConfig = async (data: { id: string | number; friendlyName: string; cameraAddress: string }) => {
id: string | number;
friendlyName: string;
cameraAddress: string;
}) => {
const updateUrl = `${base_url}/update-config?id=${data.id}`; const updateUrl = `${base_url}/update-config?id=${data.id}`;
const updateConfigPayload = { const updateConfigPayload = {
@@ -30,7 +26,7 @@ const updateCamerasideConfig = async (data: {
}, },
], ],
}; };
console.log(updateConfigPayload);
const response = await fetch(updateUrl, { const response = await fetch(updateUrl, {
method: "POST", method: "POST",
body: JSON.stringify(updateConfigPayload), body: JSON.stringify(updateConfigPayload),

View File

@@ -34,9 +34,7 @@ const updateDispatcherConfig = async (data: BearerTypeFieldType) => {
}; };
const getBackOfficeConfig = async () => { const getBackOfficeConfig = async () => {
const response = await fetch( const response = await fetch(`${CAM_BASE}/api/fetch-config?id=Dispatcher-json`);
`${CAM_BASE}/api/fetch-config?id=Dispatcher-json`
);
if (!response.ok) throw new Error("Cannot get Back Office configuration"); if (!response.ok) throw new Error("Cannot get Back Office configuration");
return response.json(); return response.json();
}; };
@@ -67,13 +65,10 @@ const updateBackOfficeConfig = async (data: InitialValuesForm) => {
}, },
], ],
}; };
const response = await fetch( const response = await fetch(`${CAM_BASE}/api/update-config?id=Dispatcher-json`, {
`${CAM_BASE}/api/update-config?id=Dispatcher-json`,
{
method: "POST", method: "POST",
body: JSON.stringify(updateConfigPayload), body: JSON.stringify(updateConfigPayload),
} });
);
if (!response.ok) throw new Error("Cannot update Back Office configuration"); if (!response.ok) throw new Error("Cannot update Back Office configuration");
return response.json(); return response.json();
}; };

View File

@@ -5,12 +5,9 @@ import { useEffect } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
const getWiFiSettings = async () => { const getWiFiSettings = async () => {
const response = await fetch( const response = await fetch(`${CAM_BASE}/api/fetch-config?id=ModemAndWifiManager-wifi`, {
`${CAM_BASE}/api/fetch-config?id=ModemAndWifiManager-wifi`,
{
signal: AbortSignal.timeout(500), signal: AbortSignal.timeout(500),
} });
);
if (!response.ok) { if (!response.ok) {
throw new Error("Cannot fetch Wifi settings"); throw new Error("Cannot fetch Wifi settings");
} }
@@ -18,14 +15,11 @@ const getWiFiSettings = async () => {
}; };
const updateWifiSettings = async (wifiConfig: WifiConfig) => { const updateWifiSettings = async (wifiConfig: WifiConfig) => {
const response = await fetch( const response = await fetch(`${CAM_BASE}/api/update-config?id=ModemAndWifiManager-wifi`, {
`${CAM_BASE}/api/update-config?id=ModemAndWifiManager-wifi`,
{
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(wifiConfig), body: JSON.stringify(wifiConfig),
} });
);
if (!response.ok) { if (!response.ok) {
throw new Error("Cannot update wifi settings"); throw new Error("Cannot update wifi settings");
} }
@@ -33,12 +27,9 @@ const updateWifiSettings = async (wifiConfig: WifiConfig) => {
}; };
const getModemSettings = async () => { const getModemSettings = async () => {
const response = await fetch( const response = await fetch(`${CAM_BASE}/api/fetch-config?id=ModemAndWifiManager-modem`, {
`${CAM_BASE}/api/fetch-config?id=ModemAndWifiManager-modem`,
{
signal: AbortSignal.timeout(500), signal: AbortSignal.timeout(500),
} });
);
if (!response.ok) { if (!response.ok) {
throw new Error("Cannot fetch modem settings"); throw new Error("Cannot fetch modem settings");
} }
@@ -46,14 +37,11 @@ const getModemSettings = async () => {
}; };
const updateModemSettings = async (modemConfig: ModemConfig) => { const updateModemSettings = async (modemConfig: ModemConfig) => {
const response = await fetch( const response = await fetch(`${CAM_BASE}/api/update-config?id=ModemAndWifiManager-modem`, {
`${CAM_BASE}/api/update-config?id=ModemAndWifiManager-modem`,
{
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(modemConfig), body: JSON.stringify(modemConfig),
} });
);
if (!response.ok) { if (!response.ok) {
throw new Error("cannot update modem settings"); throw new Error("cannot update modem settings");
} }
@@ -100,13 +88,11 @@ export const useWifiAndModem = () => {
}); });
useEffect(() => { useEffect(() => {
if (wifiQuery.isError) if (wifiQuery.isError) toast.error("Cannot get WiFi settings", { id: "wiFiSettings" });
toast.error("Cannot get WiFi settings", { id: "wiFiSettings" });
}, [wifiQuery?.error?.message, wifiQuery.isError]); }, [wifiQuery?.error?.message, wifiQuery.isError]);
useEffect(() => { useEffect(() => {
if (modemQuery.isError) if (modemQuery.isError) toast.error("Cannot get Modem settings", { id: "modemSettings" });
toast.error("Cannot get Modem settings", { id: "modemSettings" });
}, [modemQuery?.error?.message, modemQuery.isError]); }, [modemQuery?.error?.message, modemQuery.isError]);
return { return {
wifiQuery, wifiQuery,

View File

@@ -1,15 +1,12 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { Query, useQuery } from "@tanstack/react-query";
import type { SightingType } from "../types/types"; import type { SightingType } from "../types/types";
import { useSoundOnChange } from "react-sounds"; import { useSoundOnChange } from "react-sounds";
import { useSoundContext } from "../context/SoundContext"; import { useSoundContext } from "../context/SoundContext";
import { getSoundFileURL } from "../utils/utils"; import { getSoundFileURL } from "../utils/utils";
import switchSound from "../assets/sounds/ui/switch.mp3"; import switchSound from "../assets/sounds/ui/switch.mp3";
async function fetchSighting( async function fetchSighting(url: string | undefined, ref: number): Promise<SightingType> {
url: string | undefined,
ref: number
): Promise<SightingType> {
const res = await fetch(`${url}${ref}`, { const res = await fetch(`${url}${ref}`, {
signal: AbortSignal.timeout(5000), signal: AbortSignal.timeout(5000),
}); });
@@ -18,44 +15,40 @@ async function fetchSighting(
} }
export function useSightingFeed(url: string | undefined) { export function useSightingFeed(url: string | undefined) {
const { state } = useSoundContext(); const { state, audioArmed } = useSoundContext();
const [sightings, setSightings] = useState<SightingType[]>([]); const [sightings, setSightings] = useState<SightingType[]>([]);
const [selectedRef, setSelectedRef] = useState<number | null>(null); const [selectedRef, setSelectedRef] = useState<number | null>(null);
const [sessionStarted, setSessionStarted] = useState(false); const [sessionStarted, setSessionStarted] = useState(false);
const [selectedSighting, setSelectedSighting] = useState<SightingType | null>(null);
const mostRecent = sightings[0] ?? null; const mostRecent = sightings[0] ?? null;
const latestRef = mostRecent?.ref ?? null; const latestRef = mostRecent?.ref ?? null;
const [selectedSighting, setSelectedSighting] = useState<SightingType | null>(
null
);
const first = useRef(true); const first = useRef(true);
const lastSoundAt = useRef(0);
const COOLDOWN_MS = 1500;
const currentRef = useRef<number>(-1);
const lastValidTimestamp = useRef<number>(Date.now());
const trigger = useMemo(() => { const trigger = useMemo(() => {
if (latestRef == null) return null; if (latestRef == null || !audioArmed) return null;
if (first.current) { if (first.current) {
first.current = false; first.current = false;
return Symbol("skip"); return Symbol("skip");
} }
const now = Date.now();
if (now - lastSoundAt.current < COOLDOWN_MS) return Symbol("skip");
lastSoundAt.current = now;
return latestRef; return latestRef;
}, [latestRef]); }, [audioArmed, latestRef]);
const soundSrc = useMemo(() => { const soundSrc = useMemo(() => {
return getSoundFileURL(state?.sightingSound) ?? switchSound; return getSoundFileURL(state?.sightingSound) ?? switchSound;
}, [state.sightingSound]); }, [state.sightingSound]);
//use latestref instead of trigger to revert back function refetchInterval(query: Query<SightingType, Error, SightingType, (string | undefined)[]>) {
useSoundOnChange(soundSrc, trigger, { if (!query) return;
volume: 1, const data = query.state.data as SightingType | undefined;
});
const currentRef = useRef<number>(-1);
const lastValidTimestamp = useRef<number>(Date.now());
const query = useQuery({
queryKey: ["sighting-feed", url],
enabled: !!url,
queryFn: () => fetchSighting(url, currentRef.current),
refetchInterval: (q) => {
const data = q.state.data as SightingType | undefined;
const now = Date.now(); const now = Date.now();
if (data && data.ref !== -1) { if (data && data.ref !== -1) {
@@ -68,36 +61,31 @@ export function useSightingFeed(url: string | undefined) {
lastValidTimestamp.current = now; lastValidTimestamp.current = now;
} }
return 400; return 400;
}, }
const query = useQuery({
queryKey: ["sighting-feed", url],
enabled: !!url,
queryFn: () => fetchSighting(url, currentRef.current),
refetchInterval: (q) => refetchInterval(q),
refetchIntervalInBackground: true, refetchIntervalInBackground: true,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
retry: false, retry: false,
staleTime: 0, staleTime: 0,
}); });
//use latestref instead of trigger to revert back
useSoundOnChange(soundSrc, trigger, {
volume: 1,
initial: false,
});
useEffect(() => { useEffect(() => {
const data = query.data; const data = query.data;
if (!data) return; if (!data || data.ref === -1) return;
const now = Date.now(); const now = Date.now();
if (data.ref === -1) {
// setSightings((prev) => {
// if (prev[0]?.ref === data.ref) return prev;
// const dedupPrev = prev.filter((s) => s.ref !== data.ref);
// return [data, ...dedupPrev].slice(0, 7);
// });
return;
}
// if (Notification.permission === "granted") {
// new Notification("New Sighting!", {
// body: `Ref: ${data.ref}`,
// icon: "/MAV-blue.svg",
// });
// }
currentRef.current = data.ref; currentRef.current = data.ref;
lastValidTimestamp.current = now; lastValidTimestamp.current = now;
@@ -110,11 +98,6 @@ export function useSightingFeed(url: string | undefined) {
setSelectedRef(data.ref); setSelectedRef(data.ref);
}, [query.data]); }, [query.data]);
useEffect(() => {
if (query.error) {
// console.error("Sighting feed error:", query.error);
}
}, [query.error]);
return { return {
sightings, sightings,
selectedRef, selectedRef,

View File

@@ -5,7 +5,6 @@ import { CAM_BASE } from "../utils/config";
const Dashboard = () => { const Dashboard = () => {
const base_url = `${CAM_BASE}/SightingList/sightingSummary?mostRecentRef=`; const base_url = `${CAM_BASE}/SightingList/sightingSummary?mostRecentRef=`;
return ( return (
<SightingFeedProvider url={base_url}> <SightingFeedProvider url={base_url}>
<div className="mx-auto flex flex-col lg:flex-row gap-2 px-1 sm:px-2 lg:px-0 w-full min-h-screen"> <div className="mx-auto flex flex-col lg:flex-row gap-2 px-1 sm:px-2 lg:px-0 w-full min-h-screen">

View File

@@ -14,12 +14,7 @@ const FrontCamera = () => {
zoomLevel={zoomLevel} zoomLevel={zoomLevel}
onZoomLevelChange={setZoomLevel} onZoomLevelChange={setZoomLevel}
/> />
<CameraSettings <CameraSettings title="Camera A Settings" side="CameraA" zoomLevel={zoomLevel} onZoomLevelChange={setZoomLevel} />
title="Camera A Settings"
side="CameraA"
zoomLevel={zoomLevel}
onZoomLevelChange={setZoomLevel}
/>
<Toaster /> <Toaster />
</div> </div>
); );

View File

@@ -1,7 +1,7 @@
import switchSound from "../assets/sounds/ui/switch.mp3"; import switchSound from "../assets/sounds/ui/switch.mp3";
import popup from "../assets/sounds/ui/popup_open.mp3"; import popup from "../assets/sounds/ui/popup_open.mp3";
import notification from "../assets/sounds/ui/notification.mp3"; import notification from "../assets/sounds/ui/notification.mp3";
import type { SightingType } from "../types/types"; import type { HotlistMatches, SightingType } from "../types/types";
export function getSoundFileURL(name: string) { export function getSoundFileURL(name: string) {
const sounds: Record<string, string> = { const sounds: Record<string, string> = {
@@ -79,8 +79,7 @@ export const formatNumberPlate = (plate: string) => {
return formattedPlate; return formattedPlate;
}; };
export const BLANK_IMG = export const BLANK_IMG = "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==";
"data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==";
export function capitalize(s?: string) { export function capitalize(s?: string) {
return s ? s.charAt(0).toUpperCase() + s.slice(1) : ""; return s ? s.charAt(0).toUpperCase() + s.slice(1) : "";
@@ -120,12 +119,7 @@ export function drawRects(
rects.forEach((r) => { rects.forEach((r) => {
const [x, y, rw, rh] = r; const [x, y, rw, rh] = r;
ctx.beginPath(); ctx.beginPath();
ctx.rect( ctx.rect(Math.round(x * w), Math.round(y * h), Math.round(rw * w), Math.round(rh * h));
Math.round(x * w),
Math.round(y * h),
Math.round(rw * w),
Math.round(rh * h)
);
ctx.stroke(); ctx.stroke();
}); });
} }
@@ -133,13 +127,18 @@ export function drawRects(
export const checkIsHotListHit = (sigthing: SightingType | null) => { export const checkIsHotListHit = (sigthing: SightingType | null) => {
if (!sigthing) return; if (!sigthing) return;
if (sigthing?.metadata?.hotlistMatches) { if (sigthing?.metadata?.hotlistMatches) {
const isHotListHit = Object.values( const isHotListHit = Object.values(sigthing?.metadata?.hotlistMatches).includes(true);
sigthing?.metadata?.hotlistMatches
).includes(true);
return isHotListHit; return isHotListHit;
} }
}; };
export function getHotlistName(obj: HotlistMatches | undefined) {
if (!obj || Object.values(obj).includes(false)) return;
const keys = Object.keys(obj);
return keys;
}
export const getNPEDCategory = (r?: SightingType | null) => export const getNPEDCategory = (r?: SightingType | null) =>
r?.metadata?.npedJSON?.["NPED CATEGORY"] as "A" | "B" | "C" | undefined; r?.metadata?.npedJSON?.["NPED CATEGORY"] as "A" | "B" | "C" | undefined;