Merged in develop (pull request #23)

Develop
This commit is contained in:
2025-10-17 10:30:02 +00:00
32 changed files with 370 additions and 460 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

@@ -14,11 +14,7 @@ const FrontCameraOverviewCard = () => {
}); });
return ( return (
<Card <Card className={clsx("relative min-h-[40vh] md:min-h-[60vh] max-h-[80vh] lg:w-[70%] overflow-y-hidden")}>
className={clsx(
"relative min-h-[40vh] md:min-h-[60vh] max-h-[80vh] lg:w-[70%] overflow-y-hidden"
)}
>
<div className="w-full" {...handlers}> <div className="w-full" {...handlers}>
<SightingOverview /> <SightingOverview />
</div> </div>

View File

@@ -30,11 +30,7 @@ const OverviewVideoContainer = ({
trackMouse: true, trackMouse: true,
}); });
return ( return (
<Card <Card className={clsx("relative min-h-[40vh] md:min-h-[60vh] max-h-[80vh] lg:w-[70%] overflow-y-hidden")}>
className={clsx(
"relative min-h-[40vh] md:min-h-[60vh] max-h-[80vh] lg:w-[70%] overflow-y-hidden"
)}
>
<div className="w-full" {...handlers}> <div className="w-full" {...handlers}>
<SnapshotContainer <SnapshotContainer
side={side} side={side}

View File

@@ -1,7 +1,6 @@
import type { SightingType } from "../../types/types"; import type { SightingType } from "../../types/types";
import NumberPlate from "../PlateStack/NumberPlate"; import NumberPlate from "../PlateStack/NumberPlate";
import SightingModal from "../SightingModal/SightingModal"; import SightingModal from "../SightingModal/SightingModal";
import InfoBar from "../SightingsWidget/InfoBar";
import { useState } from "react"; import { useState } from "react";
import HotListImg from "/Hotlist_Hit.svg"; import HotListImg from "/Hotlist_Hit.svg";
import { useAlertHitContext } from "../../context/AlertHitContext"; import { useAlertHitContext } from "../../context/AlertHitContext";
@@ -9,7 +8,11 @@ import { useCameraBlackboard } from "../../hooks/useCameraBlackboard";
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, formatAge, getNPEDCategory } from "../../utils/utils";
import { faX } from "@fortawesome/free-solid-svg-icons";
import { faClock } from "@fortawesome/free-regular-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import Badge from "../UI/Badge";
type AlertItemProps = { type AlertItemProps = {
item: SightingType; item: SightingType;
@@ -20,7 +23,6 @@ const AlertItem = ({ item }: AlertItemProps) => {
const { dispatch } = useAlertHitContext(); const { dispatch } = useAlertHitContext();
const { mutation } = useCameraBlackboard(); const { mutation } = useCameraBlackboard();
// const {d} = useCameraBlackboard();
const motionAway = (item?.motion ?? "").toUpperCase() === "AWAY"; const motionAway = (item?.motion ?? "").toUpperCase() === "AWAY";
const isHotListHit = checkIsHotListHit(item); const isHotListHit = checkIsHotListHit(item);
@@ -43,9 +45,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",
@@ -55,50 +55,27 @@ const AlertItem = ({ item }: AlertItemProps) => {
dispatch({ type: "REMOVE", payload: item }); dispatch({ type: "REMOVE", payload: item });
}; };
return ( return (
<div className="flex flex-col w-full"> <div className="flex flex-col w-full relative">
<div className="border border-gray-600 rounded-lg items-center py-1"> <div className="border border-gray-600 rounded-lg items-center p-4">
<InfoBar obj={item} /> <div className="flex flex-row space-x-3 ml-4">
<div <Badge text={`Seen: ${formatAge(item.timeStampMillis)}`} icon={faClock} />
className=" flex flex-row p-4 w-full mx-auto justify-between"
onClick={handleClick}
>
{isHotListHit && (
<img
src={HotListImg}
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>
<button onClick={() => handleDelete(item)} className="absolute right-2 top-3">
<FontAwesomeIcon icon={faX} size="xl" />
</button>
<div className="flex flex-row p-4 w-full mx-auto justify-between" onClick={handleClick}>
{isHotListHit && <img src={HotListImg} 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={`border p-1 hidden md:block`}>
<img src={item?.plateUrlColour} height={48} width={200} alt="colour patch" />
</div>
<div className="h-20">
<NumberPlate vrm={item.vrm} motion={motionAway} /> <NumberPlate vrm={item.vrm} motion={motionAway} />
</div> </div>
</div>
<SightingModal <SightingModal
isSightingModalOpen={isModalOpen} isSightingModalOpen={isModalOpen}
handleClose={closeModal} handleClose={closeModal}

View File

@@ -1,34 +1,14 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useAlertHitContext } from "../../context/AlertHitContext"; import { useAlertHitContext } from "../../context/AlertHitContext";
import { useCameraBlackboard } from "../../hooks/useCameraBlackboard"; import { useCameraBlackboard } from "../../hooks/useCameraBlackboard";
import type { CameraBlackBoardOptions, SightingType } from "../../types/types"; import type { CameraBlackBoardOptions } from "../../types/types";
import Card from "../UI/Card"; import Card from "../UI/Card";
import CardHeader from "../UI/CardHeader"; import CardHeader from "../UI/CardHeader";
import AlertItem from "./AlertItem"; import AlertItem from "./AlertItem";
import { faTrash } from "@fortawesome/free-solid-svg-icons";
const HistoryList = () => { const HistoryList = () => {
const { state, dispatch, isLoading, error } = useAlertHitContext(); const { state, dispatch, isLoading, error } = useAlertHitContext();
const { mutation } = useCameraBlackboard(); const { mutation } = useCameraBlackboard();
const handleDeleteClick = async (deletedItem: SightingType) => {
const res = await mutation.mutateAsync({
operation: "VIEW",
path: "alertHistory",
});
const oldArray = res?.result;
const updatedArray = oldArray?.filter(
(item: SightingType) => item?.ref !== deletedItem?.ref
);
mutation.mutate({
operation: "INSERT",
path: "alertHistory",
value: updatedArray,
});
dispatch({ type: "REMOVE", payload: deletedItem });
};
const handleClearListClick = (listName: CameraBlackBoardOptions) => { const handleClearListClick = (listName: CameraBlackBoardOptions) => {
dispatch({ type: "DELETE", payload: [] }); dispatch({ type: "DELETE", payload: [] });
mutation.mutate({ mutation.mutate({
@@ -38,7 +18,7 @@ const HistoryList = () => {
}; };
return ( return (
<Card className="h-100 p-4"> <Card className="h-100 p-4 col-span-3">
<CardHeader title="Alert History" /> <CardHeader title="Alert History" />
<button <button
className="bg-red-600 text-white px-4 py-2 rounded hover:bg-red-700 transition md:w-[10%] mb-2" className="bg-red-600 text-white px-4 py-2 rounded hover:bg-red-700 transition md:w-[10%] mb-2"
@@ -50,18 +30,23 @@ const HistoryList = () => {
{error && <p className="text-red-500 px-2">Error: {error.message}</p>} {error && <p className="text-red-500 px-2">Error: {error.message}</p>}
<div className="flex flex-col gap-1 px-2"> <div className="flex flex-col gap-1 px-2">
{state?.alertList?.length > 0 ? ( {state?.alertList?.length > 0 ? (
state?.alertList?.map((alertItem, index) => ( <div className="mt-3 grid grid-cols-1 gap-3">
<div key={index} className="flex flex-row space-x-2"> {state?.alertList?.map((alertItem) => (
<AlertItem item={alertItem} /> <AlertItem item={alertItem} key={alertItem.vrm} />
<button onClick={() => handleDeleteClick(alertItem)}> ))}
<div className="p-4">
<FontAwesomeIcon icon={faTrash} size="2x" />
</div> </div>
</button>
</div>
))
) : ( ) : (
<p>No Alert results</p> <div className="mt-4 flex flex-col items-center justify-center rounded-2xl border border-slate-800 bg-slate-900/40 p-10 text-center">
<div className="mb-3 rounded-xl bg-slate-800 px-3 py-1 text-xs uppercase tracking-wider text-slate-400">
No Alert Results
</div>
<p className="max-w-md text-slate-300">
Alerts will appear here in real-time once there are <span className="text-emerald-400">Hotlist</span> or{" "}
<span className="text-amber-600">NPED</span> hits. Use{" "}
<span className="text-emerald-400">Start Session</span> to begin capturing results, or add a{" "}
<span className="text-emerald-400">Sighting</span> from the sighting list.
</p>
</div>
)} )}
</div> </div>
</Card> </Card>

View File

@@ -18,18 +18,9 @@ const RearCameraOverviewCard = ({ className }: CardProps) => {
}); });
const { mostRecent } = useSightingFeedContext(); const { mostRecent } = useSightingFeedContext();
return ( return (
<Card <Card className={clsx("relative min-h-[40vh] md:min-h-[60vh] h-auto", className)}>
className={clsx(
"relative min-h-[40vh] md:min-h-[60vh] h-auto",
className
)}
>
<div className="flex flex-col space-y-3 h-full" {...handlers}> <div className="flex flex-col space-y-3 h-full" {...handlers}>
<CardHeader <CardHeader title="Rear Overview" icon={faCamera} sighting={mostRecent} />
title="Rear Overview"
icon={faCamera}
sighting={mostRecent}
/>
<SightingOverview /> <SightingOverview />
</div> </div>
</Card> </Card>

View File

@@ -9,38 +9,36 @@ const SessionCard = () => {
const { dispatch } = useAlertHitContext(); const { dispatch } = useAlertHitContext();
return ( return (
<Card className="p-4"> <Card className="p-4 col-span-5">
<CardHeader title={"Hit Search"} /> <CardHeader title={"Hit Search"} />
<div className="flex flex-col gap-4 px-2"> <div className="flex flex-col gap-4 px-2">
<FormGroup> <label htmlFor="VRM" className="font-medium whitespace-nowrap md:w-1/2 text-left">
<label
htmlFor="VRM"
className="font-medium whitespace-nowrap md:w-1/2 text-left"
>
VRM (Min 2 letters) VRM (Min 2 letters)
</label> </label>
<div className="flex-1 flex justify-end md:w-2/3"> <FormGroup>
<div className="flex flex-row justify-between md:w-full space-x-3">
<input <input
id="VRMSelect" id="VRMSelect"
name="VRMSelect" name="VRMSelect"
type="text" type="text"
className="p-2 border border-gray-400 rounded-lg w-full max-w-xs" className="p-2 border border-gray-400 rounded-lg w-full max-w-[70%] focus:border-emerald-400 focus:outline-none focus:ring-2 focus:ring-emerald-400/30"
placeholder="Enter VRM" placeholder="Enter VRM"
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
/> />
</div>
</FormGroup>
<button <button
className="bg-[#26B170] text-white px-4 py-2 rounded hover:bg-green-700 transition w-full mx-auto" className="bg-[#26B170] text-white px-4 py-2 rounded hover:bg-green-700 transition w-[30%] mx-3"
onClick={() => dispatch({ type: "SEARCH", payload: searchTerm })} onClick={() => dispatch({ type: "SEARCH", payload: searchTerm })}
disabled={searchTerm.trim().length < 2} disabled={searchTerm.trim().length < 2}
> >
Search Hit list Search Hit list
</button> </button>
</div>
</FormGroup>
{searchTerm && ( {searchTerm && (
<button <button
className="bg-gray-300 text-gray-900 px-4 py-2 rounded hover:bg-gray-700 transition w-full mx-auto" className="bg-gray-300 text-gray-900 px-4 py-2 rounded hover:bg-gray-700 transition w-[30%] "
onClick={() => { onClick={() => {
setSearchTerm(""); setSearchTerm("");
dispatch({ type: "SEARCH", payload: "" }); dispatch({ type: "SEARCH", payload: "" });

View File

@@ -9,37 +9,21 @@ const SessionCard = () => {
const handleStartClick = () => { const handleStartClick = () => {
setSessionStarted(!sessionStarted); setSessionStarted(!sessionStarted);
toast( toast(`${sessionStarted ? "Vehicle tracking session Ended" : "Vehicle tracking session Started"}`);
`${
sessionStarted
? "Vehicle tracking session Ended"
: "Vehicle tracking session Started"
}`
);
}; };
const sightings = [ const sightings = [...new Map(sessionList.map((vehicle) => [vehicle.vrm, vehicle]))];
...new Map(sessionList.map((vehicle) => [vehicle.vrm, vehicle])),
];
const dedupedSightings = sightings.map((sighting) => sighting[1]); const dedupedSightings = sightings.map((sighting) => sighting[1]);
const vehicles = dedupedSightings.reduce< const vehicles = dedupedSightings.reduce<Record<string, ReducedSightingType[]>>(
Record<string, ReducedSightingType[]>
>(
(acc, item) => { (acc, item) => {
if (item.metadata?.npedJSON["NPED CATEGORY"] === "A") if (item.metadata?.npedJSON["NPED CATEGORY"] === "A") acc.npedCatA.push(item);
acc.npedCatA.push(item); if (item.metadata?.npedJSON["NPED CATEGORY"] === "B") acc.npedCatB.push(item);
if (item.metadata?.npedJSON["NPED CATEGORY"] === "B") if (item.metadata?.npedJSON["NPED CATEGORY"] === "C") acc.npedCatC.push(item);
acc.npedCatB.push(item); if (item.metadata?.npedJSON["NPED CATEGORY"] === "D") acc.npedCatD.push(item);
if (item.metadata?.npedJSON["NPED CATEGORY"] === "C") if (item.metadata?.npedJSON["TAX STATUS"] === false) acc.notTaxed.push(item);
acc.npedCatC.push(item); if (item.metadata?.npedJSON["MOT STATUS"] === false) acc.notMOT.push(item);
if (item.metadata?.npedJSON["NPED CATEGORY"] === "D")
acc.npedCatD.push(item);
if (item.metadata?.npedJSON["TAX STATUS"] === false)
acc.notTaxed.push(item);
if (item.metadata?.npedJSON["MOT STATUS"] === false)
acc.notMOT.push(item);
return acc; return acc;
}, },
@@ -54,13 +38,11 @@ const SessionCard = () => {
); );
return ( return (
<Card className="p-4"> <Card className="p-4 col-span-3">
<CardHeader title="Session" /> <CardHeader title="Session" />
<div className="flex flex-col gap-4 px-2"> <div className="flex flex-col gap-4 px-3">
<button <button
className={`${ className={`${sessionStarted ? "bg-red-600" : "bg-[#26B170]"} text-white px-4 py-2 rounded ${
sessionStarted ? "bg-red-600" : "bg-[#26B170]"
} text-white px-4 py-2 rounded ${
sessionStarted ? "hover:bg-red-700" : "hover:bg-green-700" sessionStarted ? "hover:bg-red-700" : "hover:bg-green-700"
} transition w-full`} } transition w-full`}
onClick={handleStartClick} onClick={handleStartClick}
@@ -69,12 +51,30 @@ const SessionCard = () => {
</button> </button>
<ul className="text-white space-y-2"> <ul className="text-white space-y-2">
<li>Number of Vehicles: {dedupedSightings.length} </li> <li className="rounded-xl border border-slate-800 bg-slate-800/60 p-3 shadow-sm flex flex-row justify-between">
<li>Vehicles without Tax: {vehicles.notTaxed.length}</li> <p>Number of Vehicles:</p>
<li>Vehicles without MOT: {vehicles.notMOT.length}</li> <span className="font-bold text-green-600 text-xl">{dedupedSightings.length}</span>
<li>Vehicles with NPED Cat A: {vehicles.npedCatA.length}</li> </li>
<li>Vehicles with NPED Cat B: {vehicles.npedCatB.length}</li> <li className="rounded-xl border border-slate-800 bg-slate-800/60 p-3 shadow-sm flex flex-row justify-between">
<li>Vehicles with NPED Cat C: {vehicles.npedCatC.length}</li> <p>Vehicles without Tax:</p>
<span className="font-bold text-amber-600 text-xl">{vehicles.notTaxed.length}</span>
</li>
<li className="rounded-xl border border-slate-800 bg-slate-800/60 p-3 shadow-sm flex flex-row justify-between">
<p>Vehicles without MOT:</p>{" "}
<span className="font-bold text-red-500 text-xl">{vehicles.notMOT.length}</span>
</li>
<li className="rounded-xl border border-slate-800 bg-slate-800/60 p-3 shadow-sm flex flex-row justify-between">
<p>Vehicles with NPED Cat A:</p>
<span className="font-bold text-gray-300 text-xl">{vehicles.npedCatA.length}</span>
</li>
<li className="rounded-xl border border-slate-800 bg-slate-800/60 p-3 shadow-sm flex flex-row justify-between">
<p>Vehicles with NPED Cat B:</p>{" "}
<span className="font-bold text-gray-300text-xl">{vehicles.npedCatB.length}</span>
</li>
<li className="rounded-xl border border-slate-800 bg-slate-800/60 p-3 shadow-sm flex flex-row justify-between">
Vehicles with NPED Cat C:{" "}
<span className="font-bold text-gray-300 text-xl">{vehicles.npedCatC.length}</span>
</li>
</ul> </ul>
</div> </div>
</Card> </Card>

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,23 @@ 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>
/> <div className="items-center px-2.5 py-0.5 rounded-sm me-2 bg-amber-500">
<p className="font-medium text-2xl break-all text-amber-800">
{hotlistName ? hotlistName[0] : "-"}
</p>
</div>
</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 +141,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

@@ -0,0 +1,18 @@
import type { Icon, IconDefinition } from "@fortawesome/fontawesome-svg-core";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
type BadgeProps = {
icon?: Icon | IconDefinition;
text: string;
};
const Badge = ({ icon, text }: BadgeProps) => {
return (
<span className="text-md font-medium inline-flex items-center px-2.5 py-0.5 rounded-sm me-2 bg-blue-900 text-blue-200 border border-blue-500 space-x-2">
{icon && <FontAwesomeIcon icon={icon} />}
<span>{text}</span>
</span>
);
};
export default Badge;

View File

@@ -10,7 +10,7 @@ const Card = ({ children, className }: CardProps) => {
return ( return (
<div <div
className={clsx( className={clsx(
"bg-[#253445] rounded-lg mt-4 mx-2 shadow-2xl overflow-x-hidden md:row-span-1 px-2", "bg-[#253445] rounded-lg mt-4 mx-2 shadow-2xl overflow-x-hidden md:row-span-1 px-2 border border-gray-600 ",
className className
)} )}
> >

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

@@ -11,8 +11,10 @@ const Session = () => {
return ( return (
<SightingFeedProvider> <SightingFeedProvider>
<div className="mx-auto grid grid-cols-1 sm:grid-cols-1 lg:grid-cols-2 gap-2 px-1 sm:px-2 lg:px-0 w-full"> <div className="mx-auto grid grid-cols-1 sm:grid-cols-1 lg:grid-cols-2 gap-2 px-1 sm:px-2 lg:px-0 w-full">
<div className="grid grid-cols-1 lg:grid-cols-8 col-span-2 w-full">
<HitSearchCard /> <HitSearchCard />
<SessionCard /> <SessionCard />
</div>
<div className="col-span-2"> <div className="col-span-2">
<HistoryList /> <HistoryList />
</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;