Merged develop into main

This commit is contained in:
2025-10-07 13:36:47 +00:00
60 changed files with 1958 additions and 780 deletions

16
.env
View File

@@ -1,21 +1,9 @@
VITE_BASEURL=http://192.168.75.11/ VITE_BASEURL=http://192.168.75.11/
VITE_CAM_BASE=http://100.72.72.70:8080 VITE_CAM_BASE=http://100.118.196.113:8080
VITE_FOLKESTONE_BASE=http://100.116.253.81 VITE_FOLKESTONE_BASE=http://100.116.253.81
VITE_TESTURL=http://100.82.205.44/SightingListRear/sightingSummary?mostRecentRef=-1 VITE_TESTURL=http://100.82.205.44/SightingListRear/sightingSummary?mostRecentRef=-1
VITE_OUTSIDE_BASEURL=http://100.82.205.44 VITE_OUTSIDE_BASEURL=http://100.82.205.44
VITE_FOLKESTONE_URL=http://100.116.253.81/mergedHistory/sightingSummary?mostRecentRef= VITE_FOLKESTONE_URL=http://100.116.253.81/mergedHistory/sightingSummary?mostRecentRef=
VITE_MAV_URL=http://192.168.75.11/SightingListFront/sightingSummary?mostRecentRef= VITE_MAV_URL=http://192.168.75.11/SightingListFront/sightingSummary?mostRecentRef=
VITE_AGX_BOX_FRONT_URL=http://192.168.0.90:8080/SightingListFront/sightingSummary?mostRecentRef= VITE_AGX_BOX_URL=http://100.118.196.113:8080
VITE_AGX_BOX_REAR_URL=http://192.168.0.90:8080/SightingListRear/sightingSummary?mostRecentRef=
VITE_AGX=http://100.72.72.70:8080/SightingListRear/sightingSummary?mostRecentRef=
VITE_AGX_FRONT=http://100.72.72.70:8080/SightingListFront/sightingSummary?mostRecentRef=
VITE_AGX_FRONT_BASE=http://100.72.72.70:8080/
VITE_LOCAL=http://10.42.0.1:8080/SightingListRear/sightingSummary?mostRecentRef=
VITE_LOCAL_FRONT=http://10.42.0.1:8080/SightingListFront/sightingSummary?mostRecentRef=
VITE_LOCAL_BASE=http://10.42.0.1:8080/
VITE_LOCAL_BASE_NEW=http://100.113.222.39

View File

@@ -8,9 +8,11 @@ import Session from "./pages/Session";
import { NPEDUserProvider } from "./context/providers/NPEDUserContextProvider"; import { NPEDUserProvider } from "./context/providers/NPEDUserContextProvider";
import { AlertHitProvider } from "./context/providers/AlertHitProvider"; import { AlertHitProvider } from "./context/providers/AlertHitProvider";
import { SoundProvider } from "react-sounds"; import { SoundProvider } from "react-sounds";
import SoundContextProvider from "./context/providers/SoundContextProvider";
function App() { function App() {
return ( return (
<SoundContextProvider>
<SoundProvider initialEnabled={true}> <SoundProvider initialEnabled={true}>
<NPEDUserProvider> <NPEDUserProvider>
<AlertHitProvider> <AlertHitProvider>
@@ -27,6 +29,7 @@ function App() {
</AlertHitProvider> </AlertHitProvider>
</NPEDUserProvider> </NPEDUserProvider>
</SoundProvider> </SoundProvider>
</SoundContextProvider>
); );
} }

Binary file not shown.

View File

@@ -1,46 +1,59 @@
import { useGetOverviewSnapshot } from "../../hooks/useGetOverviewSnapshot"; import { useGetOverviewSnapshot } from "../../hooks/useGetOverviewSnapshot";
import type { ZoomInOptions } from "../../types/types";
import NavigationArrow from "../UI/NavigationArrow"; import NavigationArrow from "../UI/NavigationArrow";
import { useCameraZoom } from "../../hooks/useCameraZoom";
import { useEffect } from "react";
import Loading from "../UI/Loading";
import ErrorState from "../UI/ErrorState";
type SnapshotContainerProps = { type SnapshotContainerProps = {
side: string; side: string;
settingsPage?: boolean; settingsPage?: boolean;
zoomLevel?: number;
onZoomLevelChange?: (level: number) => void;
}; };
export const SnapshotContainer = ({ export const SnapshotContainer = ({
side, side,
settingsPage, settingsPage,
zoomLevel,
onZoomLevelChange,
}: SnapshotContainerProps) => { }: SnapshotContainerProps) => {
const { canvasRef, isError, isPending } = useGetOverviewSnapshot(); const { canvasRef, isError, isPending } = useGetOverviewSnapshot(side);
const cameraControllerSide =
side === "CameraA" ? "CameraControllerA" : "CameraControllerB";
const { mutation } = useCameraZoom({ camera: cameraControllerSide });
if (isError) return <p className="h-100">An error occurred</p>; const handleZoomClick = () => {
if (isPending) return <p className="h-100">Loading...</p>; const baseLevel = zoomLevel ?? 1;
const newLevel = baseLevel >= 8 ? 1 : baseLevel * 2;
const handleZoomClick = (event: React.MouseEvent<HTMLCanvasElement>) => { if (onZoomLevelChange) onZoomLevelChange(newLevel);
const bounds = canvasRef.current?.getBoundingClientRect();
if (!bounds) return; if (!zoomLevel) return;
const left = bounds.left;
const top = bounds.top;
const x = event.pageX;
const y = event.pageY;
const cw = canvasRef.current?.clientWidth;
const ch = canvasRef.current?.clientHeight;
if (!cw || !ch) return;
const px = x / cw;
const py = y / ch;
console.log({
left,
top,
x,
y,
px,
py,
});
}; };
useEffect(() => {
if (zoomLevel) {
const zoomInOptions: ZoomInOptions = {
camera: cameraControllerSide,
multiplier: zoomLevel,
};
mutation.mutate(zoomInOptions);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [zoomLevel]);
return ( return (
<div className="flex flex-col md:flex-row"> <div className="flex flex-col md:flex-row">
<NavigationArrow side={side} settingsPage={settingsPage} /> <NavigationArrow side={side} settingsPage={settingsPage} />
<div className="w-full"> <div className="w-full">
{isError && <ErrorState />}
{isPending && (
<div className="my-50 h-[50%]">
<Loading message="Camera Preview" />
</div>
)}
<canvas <canvas
onClick={handleZoomClick} onClick={handleZoomClick}
ref={canvasRef} ref={canvasRef}

View File

@@ -3,40 +3,80 @@ import type {
CameraConfig, CameraConfig,
CameraSettingErrorValues, CameraSettingErrorValues,
CameraSettingValues, CameraSettingValues,
ZoomInOptions,
} from "../../types/types"; } from "../../types/types";
import { 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";
import CardHeader from "../UI/CardHeader";
import { useCameraZoom } from "../../hooks/useCameraZoom";
import { parseRTSPUrl } from "../../utils/utils";
type CameraSettingsProps = { type CameraSettingsProps = {
initialData: CameraConfig; initialData: CameraConfig;
updateCameraConfig: (values: CameraSettingValues) => Promise<void> | void; updateCameraConfig: (values: CameraSettingValues) => Promise<void> | void;
zoomLevel?: number;
onZoomLevelChange?: (level: number) => void;
updateCameraConfigError: null | Error;
}; };
const CameraSettingFields = ({ const CameraSettingFields = ({
initialData, initialData,
updateCameraConfig, updateCameraConfig,
zoomLevel,
onZoomLevelChange,
updateCameraConfigError,
}: CameraSettingsProps) => { }: CameraSettingsProps) => {
const [showPwd, setShowPwd] = useState(false); const [showPwd, setShowPwd] = useState(false);
const cameraControllerSide =
initialData?.id === "CameraA" ? "CameraControllerA" : "CameraControllerB";
const { mutation, query } = useCameraZoom({ camera: cameraControllerSide });
const zoomOptions = [1, 2, 4, 8];
const parsed = parseRTSPUrl(initialData?.propURI?.value);
useEffect(() => {
if (!query?.data) return;
const apiZoom = getZoomLevel(query.data);
onZoomLevelChange?.(apiZoom);
}, [query?.data, onZoomLevelChange]);
const getZoomLevel = (levelstring: string | undefined) => {
switch (levelstring) {
case "1x":
return 1;
case "2x":
return 2;
case "4x":
return 4;
case "8x":
return 8;
default:
return 1;
}
};
const initialValues = useMemo<CameraSettingValues>( const initialValues = useMemo<CameraSettingValues>(
() => ({ () => ({
friendlyName: initialData?.id ?? "", friendlyName: initialData?.id ?? "",
cameraAddress: initialData?.propURI?.value ?? "", cameraAddress: initialData?.propURI?.value ?? "",
userName: "", userName: parsed?.username ?? "",
password: "", password: parsed?.password ?? "",
id: initialData?.id, id: initialData?.id,
zoom: zoomLevel,
}), }),
[initialData?.id, initialData?.propURI?.value] // eslint-disable-next-line react-hooks/exhaustive-deps
[initialData?.id, initialData?.propURI?.value, zoomLevel]
); );
const validateValues = (values: CameraSettingValues) => { const validateValues = (values: CameraSettingValues) => {
const errors: CameraSettingErrorValues = {}; const errors: CameraSettingErrorValues = {};
if (!values.friendlyName) errors.friendlyName = "Required"; if (!values.friendlyName) errors.friendlyName = "Required";
if (!values.cameraAddress) errors.cameraAddress = "Required"; if (!values.cameraAddress) errors.cameraAddress = "Required";
if (!values.userName) errors.userName = "Required";
if (!values.password) errors.password = "Required";
return errors; return errors;
}; };
@@ -44,6 +84,18 @@ const CameraSettingFields = ({
updateCameraConfig(values); updateCameraConfig(values);
}; };
const handleRadioButtonChange = async (levelNumber: number) => {
if (!onZoomLevelChange || !zoomLevel) return;
onZoomLevelChange(levelNumber);
const zoomInOptions: ZoomInOptions = {
camera: cameraControllerSide,
multiplier: levelNumber,
};
mutation.mutate(zoomInOptions);
};
const selectedZoom = zoomLevel ?? 1;
return ( return (
<Formik <Formik
initialValues={initialValues} initialValues={initialValues}
@@ -52,7 +104,7 @@ const CameraSettingFields = ({
validateOnChange={false} validateOnChange={false}
enableReinitialize enableReinitialize
> >
{({ errors, touched }) => ( {({ errors, touched, isSubmitting }) => (
<Form className="flex flex-col space-y-6 p-2"> <Form className="flex flex-col space-y-6 p-2">
<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>
@@ -111,7 +163,7 @@ const CameraSettingFields = ({
{errors.password} {errors.password}
</small> </small>
)} )}
<div className="flex gap-2 items-center relative"> <div className="flex gap-2 items-center relative mb-4">
<Field <Field
id="password" id="password"
name="password" name="password"
@@ -127,13 +179,46 @@ const CameraSettingFields = ({
icon={showPwd ? faEyeSlash : faEye} icon={showPwd ? faEyeSlash : faEye}
/> />
</div> </div>
<div className="my-3">
<CardHeader title="Zoom settings" />
<div className="mx-auto grid grid-cols-4 items-center">
{zoomOptions.map((zoom) => (
<div key={zoom} className="my-3">
<Field
type="radio"
name="zoom"
value={zoom.toString()}
checked={selectedZoom === zoom}
className="hidden peer"
id={`zoom${zoom}`}
onChange={() => handleRadioButtonChange(zoom)}
/>
<label
htmlFor={`zoom${zoom}`}
className="px-6 py-2 rounded-md border border-gray-300
peer-checked:border-2 peer-checked:border-blue-900
peer-checked:text-blue-600 peer-checked:bg-gray-100"
>
x{zoom}
</label>
</div>
))}
</div>
</div>
<div className="mt-3">
{updateCameraConfigError ? (
<button className="bg-red-500 text-white rounded-lg p-2 mx-auto h-[100%] w-full">
Retry
</button>
) : (
<button <button
type="submit" type="submit"
className="bg-[#26B170] text-white rounded-lg p-2 mx-auto h-[100%] w-full" className="bg-[#26B170] text-white rounded-lg p-2 mx-auto h-[100%] w-full"
> >
Save settings {isSubmitting ? "Saving" : "Save settings"}
</button> </button>
)}
</div>
</div> </div>
</Form> </Form>
)} )}

View File

@@ -4,26 +4,34 @@ import CardHeader from "../UI/CardHeader";
import CameraSettingFields from "./CameraSettingFields"; import CameraSettingFields from "./CameraSettingFields";
import { faWrench } from "@fortawesome/free-solid-svg-icons"; import { faWrench } from "@fortawesome/free-solid-svg-icons";
const CameraSettings = ({ title, side }: { title: string; side: string }) => { const CameraSettings = ({
const { title,
data, side,
isError, zoomLevel,
isPending, onZoomLevelChange,
updateCameraConfig, }: {
updateCameraConfigError, title: string;
} = useFetchCameraConfig(side); side: string;
zoomLevel?: number;
onZoomLevelChange?: (level: number) => void;
}) => {
const { data, updateCameraConfig, updateCameraConfigError } =
useFetchCameraConfig(side);
console.log(updateCameraConfigError); console.log(updateCameraConfigError);
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">
{isPending && <>Loading camera config</>}
{isError && <>Error fetching camera config</>}
<div className="relative flex flex-col space-y-3"> <div className="relative flex flex-col space-y-3">
<CardHeader title={title} icon={faWrench} /> <CardHeader title={title} icon={faWrench} />
{
<CameraSettingFields <CameraSettingFields
initialData={data} initialData={data}
updateCameraConfig={updateCameraConfig} updateCameraConfig={updateCameraConfig}
zoomLevel={zoomLevel}
onZoomLevelChange={onZoomLevelChange}
updateCameraConfigError={updateCameraConfigError}
/> />
}
</div> </div>
</Card> </Card>
); );

View File

@@ -2,14 +2,14 @@ import clsx from "clsx";
import Card from "../UI/Card"; import Card from "../UI/Card";
import { useSwipeable } from "react-swipeable"; import { useSwipeable } from "react-swipeable";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { useOverviewVideo } from "../../hooks/useOverviewVideo";
import SightingOverview from "../SightingOverview/SightingOverview"; import SightingOverview from "../SightingOverview/SightingOverview";
const FrontCameraOverviewCard = () => { const FrontCameraOverviewCard = () => {
useOverviewVideo();
const navigate = useNavigate(); const navigate = useNavigate();
const handlers = useSwipeable({ const handlers = useSwipeable({
onSwipedRight: () => navigate("/camera-settings"), onSwipedRight: () => navigate("/camera-settings"),
onSwipedLeft: () => navigate("/rear-camera-settings"),
trackMouse: true, trackMouse: true,
}); });

View File

@@ -1,20 +1,32 @@
import clsx from "clsx"; import clsx from "clsx";
import { SnapshotContainer } from "../CameraOverview/SnapshotContainer"; import { SnapshotContainer } from "../CameraOverview/SnapshotContainer";
import Card from "../UI/Card"; import Card from "../UI/Card";
import { useNavigate } from "react-router"; import { useNavigate, useLocation } from "react-router";
import { useSwipeable } from "react-swipeable"; import { useSwipeable } from "react-swipeable";
const OverviewVideoContainer = ({ const OverviewVideoContainer = ({
side, side,
settingsPage, settingsPage,
zoomLevel,
onZoomLevelChange,
}: { }: {
title: string; title: string;
side: string; side: string;
settingsPage?: boolean; settingsPage?: boolean;
zoomLevel?: number;
onZoomLevelChange?: (level: number) => void;
}) => { }) => {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const handlers = useSwipeable({ const handlers = useSwipeable({
onSwipedLeft: () => navigate("/"), onSwipedLeft: () => {
if (location.pathname === "/rear-camera-settings") return;
navigate("/");
},
onSwipedRight: () => {
if (location.pathname === "/camera-settings") return;
navigate("/");
},
trackMouse: true, trackMouse: true,
}); });
return ( return (
@@ -24,7 +36,12 @@ const OverviewVideoContainer = ({
)} )}
> >
<div className="w-full" {...handlers}> <div className="w-full" {...handlers}>
<SnapshotContainer side={side} settingsPage={settingsPage} /> <SnapshotContainer
side={side}
settingsPage={settingsPage}
zoomLevel={zoomLevel}
onZoomLevelChange={onZoomLevelChange}
/>
</div> </div>
</Card> </Card>
); );

View File

@@ -15,7 +15,7 @@ type AlertItemProps = {
const AlertItem = ({ item }: AlertItemProps) => { const AlertItem = ({ item }: AlertItemProps) => {
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const { dispatch } = useAlertHitContext(); const { dispatch, isError } = useAlertHitContext();
// const {d} = useCameraBlackboard(); // const {d} = useCameraBlackboard();
const motionAway = (item?.motion ?? "").toUpperCase() === "AWAY"; const motionAway = (item?.motion ?? "").toUpperCase() === "AWAY";
@@ -24,6 +24,7 @@ const AlertItem = ({ item }: AlertItemProps) => {
const isNPEDHitB = item?.metadata?.npedJSON?.["NPED CATEGORY"] === "B"; const isNPEDHitB = item?.metadata?.npedJSON?.["NPED CATEGORY"] === "B";
const isNPEDHitC = item?.metadata?.npedJSON?.["NPED CATEGORY"] === "C"; const isNPEDHitC = item?.metadata?.npedJSON?.["NPED CATEGORY"] === "C";
console.log(isError);
const handleClick = () => { const handleClick = () => {
setIsModalOpen(true); setIsModalOpen(true);
}; };

View File

@@ -1,16 +1,43 @@
import { Field, useFormikContext } from "formik"; import { Field, Form, Formik } from "formik";
import FormToggle from "../components/FormToggle"; import FormToggle from "../components/FormToggle";
import { useCameraOutput } from "../../../hooks/useCameraOutput";
import { cleanArray } from "../../../utils/utils";
import FormGroup from "../components/FormGroup";
import type { BearerTypeFieldType } from "../../../types/types";
export const ValuesComponent = () => { export const ValuesComponent = () => {
return null; return null;
}; };
const BearerTypeFields = () => { const BearerTypeFields = () => {
useFormikContext(); const { dispatcherQuery, dispatcherMutation } = useCameraOutput();
const format = dispatcherQuery?.data?.propFormat?.value;
const rawOptions = dispatcherQuery?.data?.propFormat?.accepted;
const enabled = dispatcherQuery?.data?.propEnabled?.value;
const verbose = dispatcherQuery?.data?.propVerbose?.value;
const options = cleanArray(rawOptions);
const initialValues: BearerTypeFieldType = {
format: format ?? "JSON",
enabled: enabled === "true",
verbose: verbose === "true",
};
const handleSubmit = async (values: BearerTypeFieldType) => {
await dispatcherMutation.mutateAsync(values);
};
return ( return (
<Formik
initialValues={initialValues}
onSubmit={handleSubmit}
enableReinitialize
>
{({ isSubmitting }) => (
<Form>
<div className="flex flex-col space-y-4 px-2"> <div className="flex flex-col space-y-4 px-2">
<div className="flex items-center gap-3 justify-between"> <FormGroup>
<label htmlFor="format">Format</label> <label htmlFor="format">Format</label>
<Field <Field
as="select" as="select"
@@ -18,15 +45,31 @@ const BearerTypeFields = () => {
id="format" id="format"
className="p-2 border border-gray-400 rounded-lg text-white bg-[#253445] w-full md:w-60" className="p-2 border border-gray-400 rounded-lg text-white bg-[#253445] w-full md:w-60"
> >
<option value="JSON">JSON</option> {options?.map((option: string) => (
<option value="BOF2">BOF2</option> <option key={option} value={option}>
{option}
</option>
))}
</Field> </Field>
</div> </FormGroup>
<FormGroup>
<div className="flex flex-col space-y-4"> <div className="flex flex-col space-y-4">
<FormToggle name="enabled" label="Enabled" /> <FormToggle name="enabled" label="Enabled" />
<FormToggle name="verbose" label="Verbose" /> <FormToggle name="verbose" label="Verbose" />
</div> </div>
</FormGroup>
<button
type="submit"
className="bg-[#26B170] text-white px-4 py-2 rounded hover:bg-green-700 transition w-full md:w-[50%]"
>
{isSubmitting || dispatcherMutation.isPending
? "Saving..."
: "Save Changes"}
</button>
</div> </div>
</Form>
)}
</Formik>
); );
}; };

View File

@@ -1,24 +1,107 @@
import { Field, useFormikContext } from "formik"; import { Field, Form, Formik, useFormikContext } from "formik";
import FormGroup from "../components/FormGroup"; import FormGroup from "../components/FormGroup";
import { useState } from "react"; import { useEffect, useState } from "react";
import { faEyeSlash, faEye } from "@fortawesome/free-solid-svg-icons"; import { faEyeSlash, faEye } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useCameraOutput } from "../../../hooks/useCameraOutput";
import type {
InitialValuesForm,
InitialValuesFormErrors,
} from "../../../types/types";
import { toast } from "sonner";
const ChannelFields = () => { const ChannelFields = () => {
useFormikContext();
const [showPwd, setShowPwd] = useState(false); const [showPwd, setShowPwd] = useState(false);
const { backOfficeQuery, backOfficeMutation } = useCameraOutput();
const backOfficeURL = backOfficeQuery?.data?.propBackofficeURL?.value;
const username = backOfficeQuery?.data?.propUsername?.value;
const password = backOfficeQuery?.data?.propPassword?.value;
const connectTimeoutSeconds =
backOfficeQuery?.data?.propConnectTimeoutSeconds?.value;
const readTimeoutSeconds =
backOfficeQuery?.data?.propReadTimeoutSeconds?.value;
const initialValues: InitialValuesForm = {
backOfficeURL: backOfficeURL ?? "",
username: username ?? "",
password: password ?? "",
connectTimeoutSeconds: Number(connectTimeoutSeconds),
readTimeoutSeconds: Number(readTimeoutSeconds),
};
const handleSubmit = async (values: InitialValuesForm) => {
await backOfficeMutation.mutateAsync(values);
};
const ValidationToastOnce = () => {
const { submitCount, isValid } = useFormikContext();
useEffect(() => {
if (submitCount > 0 && !isValid) {
toast.error("Check fields are filled in");
}
}, [submitCount, isValid]);
return null;
};
const validateValues = (
values: InitialValuesForm
): InitialValuesFormErrors => {
const errors: InitialValuesFormErrors = {};
const url = values.backOfficeURL?.trim();
const username = values.username?.trim();
const password = values.password?.trim();
if (!url) {
errors.backOfficeURL = "Required";
}
if (!username) errors.username = "Required";
if (!password) errors.password = "Required";
const read = Number(values.readTimeoutSeconds);
if (!Number.isFinite(read)) {
errors.readTimeoutSeconds = "Must be a number";
} else if (read < 0) {
errors.readTimeoutSeconds = "Must be ≥ 0";
}
const connect = Number(values.connectTimeoutSeconds);
if (!Number.isFinite(connect)) {
errors.connectTimeoutSeconds = "Must be a number";
} else if (connect < 0) {
errors.connectTimeoutSeconds = "Must be ≥ 0";
}
return errors;
};
return ( return (
<Formik
initialValues={initialValues}
onSubmit={handleSubmit}
enableReinitialize
validate={validateValues}
>
{({ errors, touched, isSubmitting }) => (
<Form>
<div className="flex flex-col space-y-2 px-2"> <div className="flex flex-col space-y-2 px-2">
<FormGroup> <FormGroup>
<label htmlFor="backoffice" className="m-0"> <label htmlFor="backoffice" className="m-0">
Back Office URL Back Office URL
</label> </label>
<Field <Field
name={"backOfficeURL"} name={"backOfficeURL"}
type="text" type="text"
id="backoffice" id="backoffice"
placeholder="https://www.backoffice.com" placeholder="https://www.backoffice.com"
className="p-1.5 border border-gray-400 rounded-lg w-full md:w-60" className={`p-1.5 border ${
errors.backOfficeURL && touched.backOfficeURL
? "border-red-500"
: "border-gray-400 "
} rounded-lg w-full md:w-60`}
/> />
</FormGroup> </FormGroup>
<FormGroup> <FormGroup>
@@ -28,7 +111,11 @@ const ChannelFields = () => {
type="text" type="text"
id="username" id="username"
placeholder="Back office username" placeholder="Back office username"
className="p-1.5 border border-gray-400 rounded-lg w-full md:w-60" className={`p-1.5 border ${
errors.username && touched.username
? "border-red-500"
: "border-gray-400 "
} rounded-lg w-full md:w-60`}
/> />
</FormGroup> </FormGroup>
<FormGroup> <FormGroup>
@@ -38,7 +125,11 @@ const ChannelFields = () => {
type={showPwd ? "text" : "password"} type={showPwd ? "text" : "password"}
id="password" id="password"
placeholder="Back office password" placeholder="Back office password"
className="p-1.5 border border-gray-400 rounded-lg w-full md:w-60" className={`p-1.5 border ${
errors.password && touched.password
? "border-red-500"
: "border-gray-400 "
} rounded-lg w-full md:w-60`}
/> />
<FontAwesomeIcon <FontAwesomeIcon
type="button" type="button"
@@ -48,12 +139,18 @@ const ChannelFields = () => {
/> />
</FormGroup> </FormGroup>
<FormGroup> <FormGroup>
<label htmlFor="connectTimeoutSeconds">Connect Timeout Seconds</label> <label htmlFor="connectTimeoutSeconds">
Connect Timeout Seconds
</label>
<Field <Field
name={"connectTimeoutSeconds"} name={"connectTimeoutSeconds"}
type="number" type="number"
id="connectTimeoutSeconds" id="connectTimeoutSeconds"
className="p-1.5 border border-gray-400 rounded-lg w-full md:w-60" className={`p-1.5 border ${
errors.connectTimeoutSeconds && touched.connectTimeoutSeconds
? "border-red-500"
: "border-gray-400 "
} rounded-lg w-full md:w-60`}
/> />
</FormGroup> </FormGroup>
<FormGroup> <FormGroup>
@@ -63,10 +160,26 @@ const ChannelFields = () => {
type="number" type="number"
id="readTimeoutSeconds" id="readTimeoutSeconds"
placeholder="https://example.com" placeholder="https://example.com"
className="p-1.5 border border-gray-400 rounded-lg w-full md:w-60" className={`p-1.5 border ${
errors.readTimeoutSeconds && touched.readTimeoutSeconds
? "border-red-500"
: "border-gray-400 "
} rounded-lg w-full md:w-60`}
/> />
</FormGroup> </FormGroup>
</div> </div>
<button
type="submit"
className="bg-[#26B170] text-white px-4 py-2 rounded hover:bg-green-700 transition w-full md:w-[50%]"
>
{isSubmitting || backOfficeMutation.isPending
? "Saving..."
: "Save Changes"}
</button>
<ValidationToastOnce />
</Form>
)}
</Formik>
); );
}; };

View File

@@ -27,12 +27,11 @@ const NPEDFields = () => {
rearId: "NPED", rearId: "NPED",
}; };
const handleSubmit = (values: NPEDFieldType) => { const handleSubmit = async (values: NPEDFieldType) => {
const valuesToSend = { const valuesToSend = {
...values, ...values,
}; };
signIn(valuesToSend); await signIn(valuesToSend);
toast.success("Signed into NPED Successfully");
}; };
const validateValues = (values: NPEDFieldType) => { const validateValues = (values: NPEDFieldType) => {
@@ -55,7 +54,7 @@ const NPEDFields = () => {
validate={validateValues} validate={validateValues}
enableReinitialize enableReinitialize
> >
{({ errors, touched }) => ( {({ errors, touched, isSubmitting }) => (
<Form className="flex flex-col space-y-5 px-2"> <Form className="flex flex-col space-y-5 px-2">
<FormGroup> <FormGroup>
<label htmlFor="username">Username</label> <label htmlFor="username">Username</label>
@@ -113,7 +112,7 @@ const NPEDFields = () => {
type="submit" type="submit"
className="w-1/4 text-white bg-green-700 hover:bg-green-800 font-small rounded-lg text-sm px-2 py-2.5 hover:cursor-pointer" className="w-1/4 text-white bg-green-700 hover:bg-green-800 font-small rounded-lg text-sm px-2 py-2.5 hover:cursor-pointer"
> >
Login {isSubmitting ? "Logging in..." : "Login"}
</button> </button>
) : ( ) : (
<button <button

View File

@@ -1,10 +1,8 @@
import { Field, useFormikContext } from "formik"; import { Field } from "formik";
import FormGroup from "../components/FormGroup"; import FormGroup from "../components/FormGroup";
import FormToggle from "../components/FormToggle"; import FormToggle from "../components/FormToggle";
const OverviewTextFields = () => { const OverviewTextFields = () => {
useFormikContext();
return ( return (
<div className="flex flex-col space-y-2 px-2"> <div className="flex flex-col space-y-2 px-2">
<FormGroup> <FormGroup>

View File

@@ -1,83 +1,12 @@
import { Formik, Form } from "formik";
import BearerTypeCard from "../BearerType/BearerTypeCard"; import BearerTypeCard from "../BearerType/BearerTypeCard";
import ChannelCard from "../Channel1-JSON/ChannelCard"; import ChannelCard from "../Channel1-JSON/ChannelCard";
import type { InitialValuesForm } from "../../../types/types";
import { useState } from "react";
import AdvancedToggle from "../../UI/AdvancedToggle";
import OverviewTextCard from "../OverviewText/OverviewTextCard";
import SightingDataCard from "../SightingData/SightingDataCard";
const SettingForms = () => { const SettingForms = () => {
const [advancedToggle, setAdvancedToggle] = useState(false);
const initialValues = {
format: "JSON",
enabled: false,
verbose: false,
backOfficeURL: "",
username: "",
password: "",
connectTimeoutSeconds: 0,
readTimeoutSeconds: 0,
overviewQuality: "high",
overviewImageScaleFactor: "full",
overviewType: "Plate Overview",
invertMotion: false,
maxPlateValueLength: 0,
vrmToTransit: "plain VRM ASCII (default)",
staticReadAction: "Use Lane Direction",
noRegionAction: "send",
countryCodeType: "IBAN 2 Character code (default)",
filterMinConfidence: 0,
filterMaxConfidence: 100,
overviewQualityOverride: 0,
sightingDataEnabled: false,
sighthingDataVerbose: false,
includeVRM: false,
includeMotion: false,
includeTimestamp: false,
timestampFormat: "UTC",
includeCameraName: false,
customFieldA: "",
customFieldB: "",
customFieldC: "",
customFieldD: "",
overlayPosition: "Top",
};
const handleSubmit = (values: InitialValuesForm) => {
alert(JSON.stringify(values));
};
return ( return (
<Formik initialValues={initialValues} onSubmit={handleSubmit}>
<Form className="flex flex-col space-y-3">
<div className="mx-auto grid grid-cols-1 sm:grid-cols-1 lg:grid-cols-2 gap-2 px-2 sm:px-4 lg:px-0 w-full"> <div className="mx-auto grid grid-cols-1 sm:grid-cols-1 lg:grid-cols-2 gap-2 px-2 sm:px-4 lg:px-0 w-full">
<BearerTypeCard /> <BearerTypeCard />
<ChannelCard /> <ChannelCard />
</div> </div>
<AdvancedToggle
advancedToggle={advancedToggle}
onAdvancedChange={setAdvancedToggle}
/>
{advancedToggle && (
<>
<div className="md:col-span-2">
<SightingDataCard />
</div>
<div className="md:col-span-2">
<OverviewTextCard />
</div>
</>
)}
<button
type="submit"
className="bg-[#26B170] text-white px-4 py-2 rounded hover:bg-green-700 transition w-full md:w-[50%]"
>
Save Changes
</button>
</Form>
</Formik>
); );
}; };

View File

@@ -1,10 +1,8 @@
import { Field, useFormikContext } from "formik"; import { Field } from "formik";
import FormGroup from "../components/FormGroup"; import FormGroup from "../components/FormGroup";
import FormToggle from "../components/FormToggle"; import FormToggle from "../components/FormToggle";
const SightingDataFields = () => { const SightingDataFields = () => {
useFormikContext();
return ( return (
<div className="flex flex-col space-y-2 px-2"> <div className="flex flex-col space-y-2 px-2">
<FormGroup> <FormGroup>

View File

@@ -0,0 +1,14 @@
import Card from "../../UI/Card";
import CardHeader from "../../UI/CardHeader";
import SoundSettingsFields from "./SoundSettingsFields";
const SoundSettingsCard = () => {
return (
<Card className="p-4">
<CardHeader title={"Sound Settings"} />
<SoundSettingsFields />
</Card>
);
};
export default SoundSettingsCard;

View File

@@ -0,0 +1,117 @@
import { Field, FieldArray, Form, Formik } from "formik";
import FormGroup from "../components/FormGroup";
import type { FormValues, Hotlist } from "../../../types/types";
import { useSoundContext } from "../../../context/SoundContext";
import { toast } from "sonner";
const SoundSettingsFields = () => {
const { state, dispatch } = useSoundContext();
const hotlists: Hotlist[] = [
{ name: "hotlist0", sound: "" },
{ name: "hotlist1", sound: "" },
{ name: "hotlist2", sound: "" },
];
const soundOptions = state?.soundOptions?.map((soundOption) => ({
value: soundOption?.name,
label: soundOption?.name,
}));
const initialValues: FormValues = {
sightingSound: state.sightingSound ?? "switch",
NPEDsound: state.NPEDsound ?? "popup",
hotlists,
};
const handleSubmit = (values: FormValues) => {
dispatch({ type: "UPDATE", payload: values });
toast.success("Sound settings updated");
};
return (
<Formik initialValues={initialValues} onSubmit={handleSubmit}>
{({ values }) => (
<Form className="flex flex-col space-y-3">
<FormGroup>
<label htmlFor="sightingSound">Sighting Sound</label>
<Field
as="select"
name="sightingSound"
className="p-2 border border-gray-400 rounded-lg text-white bg-[#253445] w-full md:w-60"
>
{soundOptions?.map(({ value, label }) => {
return (
<option key={value} value={value}>
{label}
</option>
);
})}
</Field>
</FormGroup>
<FormGroup>
<label htmlFor="NPEDsound">NPED notification Sound</label>
<Field
as="select"
name="NPEDsound"
className="p-2 border border-gray-400 rounded-lg text-white bg-[#253445] w-full md:w-60"
>
{soundOptions?.map(({ value, label }) => (
<option key={value} value={value}>
{label}
</option>
))}
</Field>
</FormGroup>
<div>
<h3 className="text-lg font-semibold mb-2">Hotlist Sounds</h3>
<FormGroup>
<FieldArray
name="hotlists"
render={() => (
<div className="w-full m-2">
{values.hotlists.length > 0 ? (
values.hotlists.map((hotlist, index) => (
<div
key={hotlist.name}
className="flex items-center m-2 w-full justify-between"
>
<label
htmlFor={`hotlists.${index}.sound`}
className="w-32 shrink-0"
>
{hotlist.name}
</label>
<Field
as="select"
name={`hotlists.${index}.sound`}
id={`hotlists.${index}.sound`}
className="p-2 border border-gray-400 rounded-lg text-white bg-[#253445] w-full md:w-60"
>
{soundOptions?.map(({ value, label }) => (
<option key={value} value={value}>
{label}
</option>
))}
</Field>
</div>
))
) : (
<p>No hotlists yet, Add one</p>
)}
</div>
)}
/>
</FormGroup>
</div>
<button
type="submit"
className="w-1/4 text-white bg-green-700 hover:bg-green-800 font-small rounded-lg text-sm px-2 py-2.5"
>
Save Settings
</button>
</Form>
)}
</Formik>
);
};
export default SoundSettingsFields;

View File

@@ -0,0 +1,68 @@
import { Form, Formik } from "formik";
import FormGroup from "../components/FormGroup";
import type { SoundUploadValue } from "../../../types/types";
import { useSoundContext } from "../../../context/SoundContext";
import { toast } from "sonner";
const SoundUpload = () => {
const { dispatch } = useSoundContext();
const initialValues: SoundUploadValue = {
name: "",
soundFile: null,
};
const handleSubmit = (values: SoundUploadValue) => {
if (!values.soundFile) {
toast.warning("Please select an audio file");
} else {
dispatch({ type: "ADD", payload: values });
toast.success("Sound file upload successfully");
}
};
return (
<Formik
initialValues={initialValues}
onSubmit={handleSubmit}
enableReinitialize
>
{({ setFieldValue, errors, setFieldError }) => (
<Form>
<FormGroup>
<label htmlFor="soundFile">Sound File</label>
<input
type="file"
name="soundFile"
id="sightingSoundinput"
accept="audio/mpeg"
onChange={(e) => {
if (
e.target?.files &&
e.target?.files[0]?.type === "audio/mpeg"
) {
setFieldValue("name", e.target.files[0].name);
setFieldValue("soundFile", e.target.files[0]);
} else {
setFieldError("soundFile", "Not an mp3 file");
toast.error("Not an mp3 file");
}
}}
/>
</FormGroup>
{errors.soundFile && (
<p className="text-red-500 text-sm mt-1">Not an mp3 file</p>
)}
<button
type="submit"
className="w-1/4 text-white bg-green-700 hover:bg-green-800 font-small rounded-lg text-sm px-2 py-2.5"
disabled={errors.soundFile ? true : false}
>
Upload
</button>
</Form>
)}
</Formik>
);
};
export default SoundUpload;

View File

@@ -0,0 +1,14 @@
import Card from "../../UI/Card";
import CardHeader from "../../UI/CardHeader";
import SoundUpload from "./SoundUpload";
const SoundUploadCard = () => {
return (
<Card className="p-4">
<CardHeader title={"Sound upload"} />
<SoundUpload />
</Card>
);
};
export default SoundUploadCard;

View File

@@ -1,15 +0,0 @@
export async function handleSoftReboot() {
const response = await fetch(
`http://192.168.75.11/api/restart-flexiai`
);
if (!response.ok) throw new Error("Failed to Software Reboot");
else alert("Software reboot triggered!");
}
export async function handleHardReboot() {
const response = await fetch(
`http://192.168.75.11/api/restart-hardware`
);
if (!response.ok) throw new Error("Failed to Hardware Reboot");
else alert("Hardware reboot triggered!");
}

View File

@@ -1,3 +1,4 @@
import { toast } from "sonner";
import type { SystemValues } from "../../../types/types"; import type { SystemValues } from "../../../types/types";
import { CAM_BASE } from "../../../utils/config"; import { CAM_BASE } from "../../../utils/config";
@@ -35,7 +36,13 @@ export async function handleSystemSave(values: SystemValues) {
); );
} }
} catch (err) { } catch (err) {
if (err instanceof Error) {
toast.error(`Failed to save system settings: ${err.message}`);
console.error(err); console.error(err);
} else {
toast.error("An unexpected error occurred while saving.");
console.error("Unknown error:", err);
}
} }
} }
@@ -79,7 +86,12 @@ export async function handleSystemRecall() {
return { deviceName, sntpServer, sntpInterval, timeZone }; return { deviceName, sntpServer, sntpInterval, timeZone };
} catch (err) { } catch (err) {
console.error(err); if (err instanceof Error) {
toast.error(`Error: ${err.message}`);
} else {
toast.error("An unexpected error occurred");
}
return null; return null;
} finally { } finally {
clearTimeout(timeoutId); clearTimeout(timeoutId);

View File

@@ -1,13 +1,16 @@
import { Formik, Field, Form } from "formik"; import { Formik, Field, Form } from "formik";
import FormGroup from "../components/FormGroup"; import FormGroup from "../components/FormGroup";
import { handleSoftReboot, handleHardReboot } from "./Reboots"; import { useReboots } from "../../../hooks/useReboots";
import { timezones } from "./timezones"; import { timezones } from "./timezones";
import SystemFileUpload from "./SystemFileUpload"; import SystemFileUpload from "./SystemFileUpload";
import type { SystemValues, SystemValuesErrors } from "../../../types/types"; import type { SystemValues, SystemValuesErrors } from "../../../types/types";
import { useSystemConfig } from "../../../hooks/useSystemConfig"; import { useSystemConfig } from "../../../hooks/useSystemConfig";
const SystemConfigFields = () => { const SystemConfigFields = () => {
const { saveSystemSettings, systemSettingsData } = useSystemConfig(); const { saveSystemSettings, systemSettingsData, saveSystemSettingsLoading } =
useSystemConfig();
const { softRebootMutation, hardRebootMutation } = useReboots();
const initialvalues: SystemValues = { const initialvalues: SystemValues = {
deviceName: systemSettingsData?.deviceName ?? "", deviceName: systemSettingsData?.deviceName ?? "",
timeZone: systemSettingsData?.timeZone ?? "", timeZone: systemSettingsData?.timeZone ?? "",
@@ -17,6 +20,7 @@ const SystemConfigFields = () => {
}; };
const handleSubmit = (values: SystemValues) => saveSystemSettings(values); const handleSubmit = (values: SystemValues) => saveSystemSettings(values);
const validateValues = (values: SystemValues) => { const validateValues = (values: SystemValues) => {
const errors: SystemValuesErrors = {}; const errors: SystemValuesErrors = {};
const interval = Number(values.sntpInterval); const interval = Number(values.sntpInterval);
@@ -28,6 +32,14 @@ const SystemConfigFields = () => {
return errors; return errors;
}; };
const handleSoftReboot = async () => {
await softRebootMutation.mutate();
};
const handleHardReboot = async () => {
await hardRebootMutation.mutate();
};
return ( return (
<Formik <Formik
initialValues={initialvalues} initialValues={initialvalues}
@@ -37,7 +49,7 @@ const SystemConfigFields = () => {
validateOnChange validateOnChange
validateOnBlur validateOnBlur
> >
{({ values, errors, touched }) => ( {({ values, errors, touched, isSubmitting }) => (
<Form className="flex flex-col space-y-5 px-2"> <Form className="flex flex-col space-y-5 px-2">
<FormGroup> <FormGroup>
<label <label
@@ -131,8 +143,9 @@ const SystemConfigFields = () => {
<button <button
type="submit" type="submit"
className="bg-[#26B170] text-white px-4 py-2 rounded hover:bg-green-700 transition w-full md:w-[50%]" className="bg-[#26B170] text-white px-4 py-2 rounded hover:bg-green-700 transition w-full md:w-[50%]"
disabled={isSubmitting}
> >
Save System Settings {saveSystemSettingsLoading ? "Saving..." : "Save System Settings"}
</button> </button>
<SystemFileUpload <SystemFileUpload
name={"softwareUpdate"} name={"softwareUpdate"}
@@ -147,14 +160,18 @@ const SystemConfigFields = () => {
className="bg-red-600 text-white px-4 py-2 rounded hover:bg-red-700 transition w-full md:w-[50%]" className="bg-red-600 text-white px-4 py-2 rounded hover:bg-red-700 transition w-full md:w-[50%]"
onClick={handleSoftReboot} onClick={handleSoftReboot}
> >
Software Reboot {softRebootMutation.isPending || isSubmitting
? "Rebooting..."
: "Software Reboot"}
</button> </button>
<button <button
type="button" type="button"
className="bg-red-600 text-white px-4 py-2 rounded hover:bg-red-700 transition w-full md:w-[50%]" className="bg-red-600 text-white px-4 py-2 rounded hover:bg-red-700 transition w-full md:w-[50%]"
onClick={handleHardReboot} onClick={handleHardReboot}
> >
Hardware Reboot {hardRebootMutation.isPending || isSubmitting
? "Rebooting"
: "Hardware Reboot"}
</button> </button>
</Form> </Form>
)} )}

View File

@@ -1,96 +1,13 @@
import Card from "../../UI/Card"; import Card from "../../UI/Card";
import CardHeader from "../../UI/CardHeader"; import CardHeader from "../../UI/CardHeader";
import { useState } from "react"; import ModemSettings from "./ModemSettings";
import FormGroup from "../components/FormGroup";
const ModemCard = () => { const ModemCard = () => {
const [apn, setApn] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [authType, setAuthType] = useState("PAP");
return ( return (
// TODO: Add switch for Auto vs Manual settings
<Card className="p-4"> <Card className="p-4">
<CardHeader title={"Modem"} /> <CardHeader title={"Modem"} />
<div className="flex flex-col gap-4 px-2">
<FormGroup> <ModemSettings />
<label
htmlFor="apn"
className="font-medium whitespace-nowrap md:w-2/3"
>
APN
</label>
<input
id="apn"
name="apn"
type="text"
className="p-2 border border-gray-400 rounded-lg flex-1 md:w-2/3"
placeholder="Enter APN"
value={apn}
onChange={(e) => setApn(e.target.value)}
/>
</FormGroup>
<FormGroup>
<label
htmlFor="modemUsername"
className="font-medium whitespace-nowrap md:w-2/3"
>
Username
</label>
<input
id="modemUsername"
name="modemUsername"
type="text"
className="p-2 border border-gray-400 rounded-lg flex-1 md:w-2/3"
placeholder="Enter Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</FormGroup>
<FormGroup>
<label
htmlFor="modemPassword"
className="font-medium whitespace-nowrap md:w-2/3"
>
Password
</label>
<input
id="modemPassword"
name="modemPassword"
type="password"
className="p-2 border border-gray-400 rounded-lg flex-1 md:w-2/3"
placeholder="Enter Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</FormGroup>
<FormGroup>
<label
htmlFor="authType"
className="font-medium whitespace-nowrap md:w-2/3"
>
Authentication Type
</label>
<select
id="authType"
name="authType"
className="p-2 border border-gray-400 rounded-lg text-white bg-[#253445] flex-1 md:w-2/3"
value={authType}
onChange={(e) => setAuthType(e.target.value)}
>
<option value="PAP">PAP</option>
<option value="CHAP">CHAP</option>
<option value="None">None</option>
</select>
</FormGroup>
<button
className="bg-[#26B170] text-white px-4 py-2 rounded hover:bg-green-700 transition w-full md:w-[50%]"
//onClick={() => handleModemSave(apn, username, password, authType)}
>
Save Modem Settings
</button>
</div>
</Card> </Card>
); );
}; };

View File

@@ -0,0 +1,146 @@
import { Formik, Form, Field } from "formik";
import FormGroup from "../components/FormGroup";
import type { ModemSettingsType } from "../../../types/types";
import { useWifiAndModem } from "../../../hooks/useCameraWifiandModem";
import { useEffect, useState } from "react";
import ModemToggle from "./ModemToggle";
const ModemSettings = () => {
const [showSettings, setShowSettings] = useState(false);
const { modemQuery, modemMutation } = useWifiAndModem();
const apn = modemQuery?.data?.propAPN?.value;
const username = modemQuery?.data?.propUsername.value;
const password = modemQuery?.data?.propPassword?.value;
const mode = modemQuery?.data?.propMode?.value;
useEffect(() => {
setShowSettings(mode === "AUTO");
}, [mode]);
const inititalValues = {
apn: apn ?? "",
username: username ?? "",
password: password ?? "",
authenticationType: "PAP",
};
const handleSubmit = async (values: ModemSettingsType) => {
const modemConfig = {
id: "ModemAndWifiManager-modem",
fields: [
{
property: "propAPN",
value: values.apn,
},
{
property: "propPassword",
value: values.password,
},
{
property: "propUsername",
value: values.username,
},
{
property: "propMode",
value: showSettings ? "AUTO" : "MANUAL",
},
],
};
await modemMutation.mutateAsync(modemConfig);
};
return (
<>
<ModemToggle
showSettings={showSettings}
onShowSettings={setShowSettings}
/>
{!showSettings && (
<Formik
initialValues={inititalValues}
onSubmit={handleSubmit}
enableReinitialize
>
{({ isSubmitting }) => (
<Form className="flex flex-col space-y-5 px-2">
<FormGroup>
<label
htmlFor="apn"
className="font-medium whitespace-nowrap md:w-2/3"
>
APN
</label>
<Field
placeholder="Enter APN"
name="apn"
id="apn"
type="text"
className="p-1.5 border border-gray-400 rounded-lg"
/>
</FormGroup>
<FormGroup>
<label
htmlFor="username"
className="font-medium whitespace-nowrap md:w-2/3"
>
Username
</label>
<Field
placeholder="Enter Username"
name="username"
id="username"
type="text"
className="p-1.5 border border-gray-400 rounded-lg"
/>
</FormGroup>
<FormGroup>
<label
htmlFor="password"
className="font-medium whitespace-nowrap md:w-2/3"
>
Password
</label>
<Field
placeholder="Enter Password"
name="password"
id="password"
type="text"
className="p-1.5 border border-gray-400 rounded-lg"
/>
</FormGroup>
<FormGroup>
<label
htmlFor="password"
className="font-medium whitespace-nowrap md:w-2/3"
>
Password
</label>
<Field
name="authenticationType"
as="select"
className="p-2 border border-gray-400 rounded-lg text-white bg-[#253445] flex-1 w-2/3"
>
<option value="PAP">PAP</option>
<option value="CHAP">CHAP</option>
<option value="none">None</option>
</Field>
</FormGroup>
<button
type="submit"
className="bg-[#26B170] text-white px-4 py-2 rounded hover:bg-green-700 transition w-full md:w-[50%]"
>
{isSubmitting || modemMutation.isPending
? "Saving..."
: "Save Modem settings"}
</button>
</Form>
)}
</Formik>
)}
</>
);
};
export default ModemSettings;

View File

@@ -0,0 +1,30 @@
type ModemToggleProps = {
showSettings: boolean;
onShowSettings: (showSettings: boolean) => void;
};
const ModemToggle = ({ showSettings, onShowSettings }: ModemToggleProps) => {
return (
<div className=" text-xl items-center m-2">
<label className="flex flex-row space-x-2 items-center w-[70%] md:w-[50%]">
<span>Automatically set</span>
<input
name="advancedSettings"
type="checkbox"
checked={showSettings}
onChange={(e) => onShowSettings(e.target.checked)}
id="advancedSettings"
className="sr-only peer"
value=""
/>
<div
className="relative w-10 h-5 rounded-full bg-gray-300 transition peer-checked:bg-blue-500 after:content-['']
after:absolute after:top-0.5 after:left-0.5 after:w-4 after:h-4 after:rounded-full after:bg-white after:shadow after:transition
after:duration-300 peer-checked:after:translate-x-5"
></div>
</label>
</div>
);
};
export default ModemToggle;

View File

@@ -1,78 +1,12 @@
import Card from "../../UI/Card"; import Card from "../../UI/Card";
import CardHeader from "../../UI/CardHeader"; import CardHeader from "../../UI/CardHeader";
import { useState } from "react"; import WiFiSettingsForm from "./WiFiSettingsForm";
import FormGroup from "../components/FormGroup";
const WiFiCard = () => { const WiFiCard = () => {
const [ssid, setSsid] = useState("");
const [password, setPassword] = useState("");
const [encryption, setEncryption] = useState("WPA2");
return ( return (
<Card className="p-4"> <Card className="p-4">
<CardHeader title={"WiFi"} /> <CardHeader title={"WiFi"} />
<div className="flex flex-col gap-4 px-2"> <WiFiSettingsForm />
<FormGroup>
<label
htmlFor="ssid"
className="font-medium whitespace-nowrap md:w-2/3"
>
SSID
</label>
<input
id="ssid"
name="ssid"
type="text"
className="p-2 border border-gray-400 rounded-lg flex-1 md:w-2/3"
placeholder="Enter SSID"
value={ssid}
onChange={(e) => setSsid(e.target.value)}
/>
</FormGroup>
<FormGroup>
<label
htmlFor="password"
className="font-medium whitespace-nowrap md:w-2/3"
>
Password
</label>
<input
id="password"
name="password"
type="password"
className="p-2 border border-gray-400 rounded-lg flex-1 md:w-2/3"
placeholder="Enter Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</FormGroup>
<FormGroup>
<label
htmlFor="encryption"
className="font-medium whitespace-nowrap md:w-2/3"
>
WPA/Encryption Type
</label>
<select
id="encryption"
name="encryption"
className="p-2 border border-gray-400 rounded-lg text-white bg-[#253445] flex-1 md:w-2/3"
value={encryption}
onChange={(e) => setEncryption(e.target.value)}
>
<option value="WPA2">WPA2</option>
<option value="WPA3">WPA3</option>
<option value="WEP">WEP</option>
<option value="None">None</option>
</select>
</FormGroup>
<button
className="bg-[#26B170] text-white px-4 py-2 rounded hover:bg-green-700 transition w-full md:w-[50%]"
//onClick={() => handleWiFiSave(ssid, password, encryption)}
>
Save WiFi Settings
</button>
</div>
</Card> </Card>
); );
}; };

View File

@@ -0,0 +1,116 @@
import { Field, Form, Formik } from "formik";
import FormGroup from "../components/FormGroup";
import type { WifiSettingValues } from "../../../types/types";
import { useWifiAndModem } from "../../../hooks/useCameraWifiandModem";
import { useState } from "react";
import { faEyeSlash, faEye } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
const WiFiSettingsForm = () => {
const [showPwd, setShowPwd] = useState(false);
const { wifiQuery, wifiMutation } = useWifiAndModem();
const wifiSSID = wifiQuery?.data?.propSSID?.value;
const wifiPassword = wifiQuery?.data?.propPassword?.value;
const initialValues = {
ssid: wifiSSID ?? "",
password: wifiPassword ?? "",
encryption: "WPA2",
};
const handleSubmit = async (values: WifiSettingValues) => {
const wifiConfig = {
id: "ModemAndWifiManager-wifi",
fields: [
{
property: "propSSID",
value: values.ssid,
},
{
property: "propPassword",
value: values.password,
},
],
};
await wifiMutation.mutateAsync(wifiConfig);
};
return (
<Formik
initialValues={initialValues}
onSubmit={handleSubmit}
enableReinitialize
>
{({ isSubmitting }) => (
<Form className="flex flex-col space-y-5 px-2">
<FormGroup>
<label
htmlFor="ssid"
className="font-medium whitespace-nowrap md:w-2/3"
>
SSID
</label>
<Field
id="ssid"
name="ssid"
type="text"
className="p-1.5 border border-gray-400 rounded-lg"
placeholder="Enter SSID"
/>
</FormGroup>
<FormGroup>
<label
htmlFor="password"
className="font-medium whitespace-nowrap md:w-2/3"
>
Password
</label>
<Field
id="password"
name="password"
type={showPwd ? "text" : "password"}
className="p-1.5 border border-gray-400 rounded-lg"
placeholder="Enter Password"
/>
<FontAwesomeIcon
type="button"
className="absolute right-5 end-0"
onClick={() => setShowPwd((s) => !s)}
icon={showPwd ? faEyeSlash : faEye}
/>
</FormGroup>
<FormGroup>
<label
htmlFor="encryption"
className="font-medium whitespace-nowrap md:w-2/3"
>
WPA/Encryption Type
</label>
<Field
id="encryption"
name="encryption"
className="p-2 border border-gray-400 rounded-lg text-white bg-[#253445] flex-1 w-2/3"
as="select"
>
<option value="WPA2">WPA2</option>
<option value="WPA3">WPA3</option>
<option value="WEP">WEP</option>
<option value="None">None</option>
</Field>
</FormGroup>
<button
type="submit"
className="bg-[#26B170] text-white px-4 py-2 rounded hover:bg-green-700 transition w-full md:w-[50%]"
>
{isSubmitting || wifiMutation.isPending
? "Saving..."
: " Save WiFi settings"}
</button>
</Form>
)}
</Formik>
);
};
export default WiFiSettingsForm;

View File

@@ -1,15 +0,0 @@
import { useLatestSighting } from "../../hooks/useLatestSighting";
const SightingCanvas = () => {
const { canvasRef } = useLatestSighting();
return (
<div className="w-70 flex flex-col">
<canvas
ref={canvasRef}
className="items-center w-full h-10 object-contain block"
/>
</div>
);
};
export default SightingCanvas;

View File

@@ -5,6 +5,7 @@ import { useSightingFeedContext } from "../../context/SightingFeedContext";
import { useHiDPICanvas } from "../../hooks/useHiDPICanvas"; import { useHiDPICanvas } from "../../hooks/useHiDPICanvas";
import NavigationArrow from "../UI/NavigationArrow"; import NavigationArrow from "../UI/NavigationArrow";
import NumberPlate from "../PlateStack/NumberPlate"; import NumberPlate from "../PlateStack/NumberPlate";
import Loading from "../UI/Loading";
const SightingOverview = () => { const SightingOverview = () => {
const [overlayMode, setOverlayMode] = useState<0 | 1 | 2>(0); const [overlayMode, setOverlayMode] = useState<0 | 1 | 2>(0);
@@ -22,14 +23,31 @@ const SightingOverview = () => {
const { sync } = useHiDPICanvas(imgRef, canvasRef); const { sync } = useHiDPICanvas(imgRef, canvasRef);
if (isLoading) return <p className="h-100">Loading</p>; if (isLoading)
return (
<div className="h-150 flex items-center justify-center">
<NavigationArrow side={side} />
<Loading message="Loading" />
</div>
);
if (isError) if (isError) return;
return <p className="h-100">An error occurred, Cannot display footage</p>; <div className="h-100 flex items-center justify-center text-red-500 text-lg">
An error occurred. Cannot display footage.
</div>;
if (!mostRecent)
return (
<div className="h-150 flex items-center justify-center text-3xl animate-pulse">
<NavigationArrow side={side} />
<Loading message="No Recent Sightings" />
</div>
);
return ( return (
<div className="flex flex-col md:flex-row"> <div className="flex flex-col md:flex-row">
<NavigationArrow side={side} /> <NavigationArrow side={side} />
<div className="w-full"> <div className="w-full">
{mostRecent && ( {mostRecent && (
<div className="absolute inset-0 z-50 px-1 pt-2"> <div className="absolute inset-0 z-50 px-1 pt-2">

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { SightingType } from "../../types/types"; import type { SightingType } from "../../types/types";
import { BLANK_IMG } from "../../utils/utils"; import { BLANK_IMG, getSoundFileURL } from "../../utils/utils";
import NumberPlate from "../PlateStack/NumberPlate"; import NumberPlate from "../PlateStack/NumberPlate";
import Card from "../UI/Card"; import Card from "../UI/Card";
import CardHeader from "../UI/CardHeader"; import CardHeader from "../UI/CardHeader";
@@ -15,6 +15,8 @@ import NPED_CAT_C from "/NPED_Cat_C.svg";
import popup from "../../assets/sounds/ui/popup_open.mp3"; import popup from "../../assets/sounds/ui/popup_open.mp3";
import { useSound } from "react-sounds"; import { useSound } from "react-sounds";
import { useNPEDContext } from "../../context/NPEDUserContext"; import { useNPEDContext } from "../../context/NPEDUserContext";
import { useSoundContext } from "../../context/SoundContext";
import Loading from "../UI/Loading";
function useNow(tickMs = 1000) { function useNow(tickMs = 1000) {
const [, setNow] = useState(() => Date.now()); const [, setNow] = useState(() => Date.now());
@@ -39,7 +41,13 @@ export default function SightingHistoryWidget({
title, title,
}: SightingHistoryProps) { }: SightingHistoryProps) {
useNow(1000); useNow(1000);
const { play } = useSound(popup); const { state } = useSoundContext();
const soundSrc = useMemo(() => {
return getSoundFileURL(state.NPEDsound) ?? popup;
}, [state.NPEDsound]);
const { play } = useSound(soundSrc);
const { const {
sightings, sightings,
setSelectedSighting, setSelectedSighting,
@@ -47,6 +55,7 @@ export default function SightingHistoryWidget({
isSightingModalOpen, isSightingModalOpen,
selectedSighting, selectedSighting,
mostRecent, mostRecent,
isLoading,
} = useSightingFeedContext(); } = useSightingFeedContext();
const { dispatch } = useAlertHitContext(); const { dispatch } = useAlertHitContext();
@@ -57,6 +66,7 @@ export default function SightingHistoryWidget({
if (!mostRecent) return; if (!mostRecent) return;
setSessionList([...sessionList, mostRecent]); setSessionList([...sessionList, mostRecent]);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mostRecent, sessionStarted, setSessionList]); }, [mostRecent, sessionStarted, setSessionList]);
const hasAutoOpenedRef = useRef(false); const hasAutoOpenedRef = useRef(false);
@@ -120,6 +130,11 @@ export default function SightingHistoryWidget({
> >
<CardHeader title={title} /> <CardHeader title={title} />
<div className="flex flex-col gap-3 "> <div className="flex flex-col gap-3 ">
{isLoading && (
<div className="my-50 h-[50%]">
<Loading message="Loading Sightings" />
</div>
)}
{/* Rows */} {/* Rows */}
<div className="flex flex-col"> <div className="flex flex-col">
{rows?.map((obj) => { {rows?.map((obj) => {

View File

@@ -17,7 +17,6 @@ const CardHeader = ({
img, img,
sighting, sighting,
}: CameraOverviewHeaderProps) => { }: CameraOverviewHeaderProps) => {
// console.log(sighting?.debug.toLowerCase());
return ( return (
<div <div
className={clsx( className={clsx(

View File

@@ -0,0 +1,162 @@
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faTriangleExclamation,
faRotateRight,
faChevronDown,
faChevronUp,
faClipboard,
} from "@fortawesome/free-solid-svg-icons";
import { useState, type FC } from "react";
type Variant = "inline" | "card" | "banner";
export type ErrorStateProps = {
/** Main heading shown to the user */
title?: string;
/** Friendly message for the user */
message?: string;
/** Raw error to help devs (object, string, whatever) */
error?: unknown;
/** Called when user clicks Retry */
onRetry?: () => Promise<void> | void;
/** Show a Retry button */
showRetry?: boolean;
/** Optional custom icon */
icon?: IconDefinition;
/** Visual style */
variant?: Variant;
/** Additional actions (e.g. “Report”) */
actions?: React.ReactNode;
/** Test id for testing */
"data-testid"?: string;
/** ClassName passthrough */
className?: string;
};
function formatError(err: unknown) {
if (!err) return "";
if (typeof err === "string") return err;
if (err instanceof Error) return err.stack || err.message;
try {
return JSON.stringify(err, null, 2);
} catch {
return String(err);
}
}
const baseStyles = "w-full text-left flex items-start gap-3 rounded-md border";
const variants: Record<Variant, string> = {
inline: "p-3 border-red-200 bg-red-50 text-red-800",
card: "p-4 border-red-200 bg-red-50 text-red-800 shadow-sm",
banner: "p-3 border-red-200 bg-red-50 text-red-800 rounded-none border-x-0",
};
export const ErrorState: FC<ErrorStateProps> = ({
title = "Something went wrong",
message = "Please try again or contact support if the problem persists.",
error,
onRetry,
showRetry = !!onRetry,
icon = faTriangleExclamation,
variant = "inline",
actions,
className = "",
...rest
}) => {
const [expanded, setExpanded] = useState(false);
const [retrying, setRetrying] = useState(false);
const details = formatError(error);
async function handleRetry() {
if (!onRetry) return;
try {
setRetrying(true);
await onRetry();
} finally {
setRetrying(false);
}
}
function copyDetails() {
if (!details) return;
navigator.clipboard?.writeText(details).catch(() => {});
}
return (
<div
role="alert"
aria-live="assertive"
className={`${baseStyles} ${variants[variant]} ${className}`}
{...rest}
>
<div className="mt-0.5">
<FontAwesomeIcon icon={icon} className="h-5 w-5" />
</div>
<div className="flex-1 space-y-1">
<h3 className="font-medium">{title}</h3>
{message && <p className="text-sm opacity-90">{message}</p>}
{/* Controls */}
<div className="flex flex-wrap items-center gap-2 pt-1">
{showRetry && (
<button
type="button"
onClick={handleRetry}
disabled={retrying}
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-red-600 text-white text-sm hover:bg-red-700 disabled:opacity-60"
>
<FontAwesomeIcon icon={faRotateRight} className="h-4 w-4" />
{retrying ? "Retrying…" : "Retry"}
</button>
)}
{details && (
<>
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-md border text-sm hover:bg-white/50"
aria-expanded={expanded}
aria-controls="error-details"
>
<FontAwesomeIcon
icon={expanded ? faChevronUp : faChevronDown}
className="h-4 w-4"
/>
{expanded ? "Hide details" : "Show details"}
</button>
<button
type="button"
onClick={copyDetails}
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-md border text-sm hover:bg-white/50"
aria-label="Copy error details"
>
<FontAwesomeIcon icon={faClipboard} className="h-4 w-4" />
Copy details
</button>
</>
)}
{actions}
</div>
{/* Dev details (collapsible) */}
{expanded && details && (
<pre
id="error-details"
className="mt-2 max-h-64 overflow-auto text-xs leading-relaxed bg-white/60 text-red-900 border rounded p-3"
>
{details}
</pre>
)}
</div>
</div>
);
};
export default ErrorState;

View File

@@ -3,56 +3,25 @@ import Logo from "/MAV.svg";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { import {
faGear, faGear,
faHome,
faListCheck, faListCheck,
faMaximize, faMaximize,
faMinimize, faMinimize,
faRotate, faRotate,
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import type { VersionFieldType } from "../../types/types"; import { useState } from "react";
import { useEffect, useState } from "react";
import SoundBtn from "./SoundBtn"; import SoundBtn from "./SoundBtn";
import { useNPEDContext } from "../../context/NPEDUserContext"; import { useNPEDContext } from "../../context/NPEDUserContext";
async function fetchVersions(
signal?: AbortSignal
): Promise<VersionFieldType | undefined> {
try {
const res = await fetch("http://192.168.75.11/api/versions", { signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
} catch (error) {
console.log(error);
return undefined;
}
}
const pad = (n: number) => String(n).padStart(2, "0");
const normalizeToMs = (ts: number) => (ts < 1e12 ? ts * 1000 : ts); // seconds → ms if needed
function formatFromMs(ms: number, tz: "local" | "utc" = "local") {
const d = new Date(ms);
const h = tz === "utc" ? d.getUTCHours() : d.getHours();
const m = tz === "utc" ? d.getUTCMinutes() : d.getMinutes();
const s = tz === "utc" ? d.getUTCSeconds() : d.getSeconds();
const day = tz === "utc" ? d.getUTCDate() : d.getDate();
const month = (tz === "utc" ? d.getUTCMonth() : d.getMonth()) + 1;
const year = tz === "utc" ? d.getUTCFullYear() : d.getFullYear();
return `${pad(h)}:${pad(m)}:${pad(s)} ${pad(day)}-${pad(month)}-${year}`;
}
export default function Header() { export default function Header() {
const [offsetMs, setOffsetMs] = useState<number | null>(null);
const [nowMs, setNowMs] = useState<number>(Date.now());
const [isFullscreen, setIsFullscreen] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false);
const { sessionStarted } = useNPEDContext(); const { sessionStarted } = useNPEDContext();
const toggleFullscreen = () => { const toggleFullscreen = () => {
if (!document.fullscreenElement) { if (!document.fullscreenElement) {
// Enter fullscreen on the entire app
document.documentElement.requestFullscreen(); document.documentElement.requestFullscreen();
setIsFullscreen(true); setIsFullscreen(true);
} else { } else {
// Exit fullscreen
document.exitFullscreen(); document.exitFullscreen();
setIsFullscreen(false); setIsFullscreen(false);
} }
@@ -62,38 +31,8 @@ export default function Header() {
window.location.reload(); window.location.reload();
}; };
useEffect(() => {
const ac = new AbortController();
fetchVersions(ac.signal)
.then((data) => {
if (!data) throw new Error("No data");
const serverMs = normalizeToMs(data?.timeStamp);
setOffsetMs(serverMs - Date.now());
})
.catch((err) => {
console.log(err);
});
return () => ac.abort("failed");
}, []);
useEffect(() => {
let timer: number;
const schedule = () => {
const now = Date.now();
setNowMs(now);
const delay = 1000 - (now % 1000);
timer = window.setTimeout(schedule, delay);
};
schedule();
return () => clearTimeout(timer);
}, []);
const serverNowMs = offsetMs == null ? nowMs : nowMs + offsetMs;
const localStr = formatFromMs(serverNowMs, "local");
const utcStr = formatFromMs(serverNowMs, "utc");
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"> <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="w-30"> <div className="w-30">
<Link to={"/"}> <Link to={"/"}>
<img src={Logo} alt="Logo" width={150} height={150} /> <img src={Logo} alt="Logo" width={150} height={150} />
@@ -103,11 +42,11 @@ export default function Header() {
{sessionStarted && ( {sessionStarted && (
<div className="text-green-400 font-bold">Session Active</div> <div className="text-green-400 font-bold">Session Active</div>
)} )}
<div className="flex flex-col leading-tight text-white tabular-nums text-xl text-right mx-auto md:mx-10 space-y-1 my-2">
<h2>Local: {localStr}</h2>
<h2>UTC: {utcStr}</h2>
</div>
<div className="flex flex-row space-x-8"> <div className="flex flex-row space-x-8">
<Link to={"/"}>
<FontAwesomeIcon className="text-white" icon={faHome} size="2x" />
</Link>
<div onClick={toggleFullscreen} className="flex flex-col"> <div onClick={toggleFullscreen} className="flex flex-col">
{isFullscreen ? ( {isFullscreen ? (
<FontAwesomeIcon icon={faMinimize} size="2x" /> <FontAwesomeIcon icon={faMinimize} size="2x" />

View File

@@ -0,0 +1,14 @@
type LoadingProps = {
message?: string;
};
const Loading = ({ message }: LoadingProps) => {
return (
<div className="flex flex-col items-center justify-center py-6">
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-gray-500 mb-2"></div>
{message && <p className="text-lg text-gray-500">{message}</p>}
</div>
);
};
export default Loading;

View File

@@ -26,7 +26,7 @@ const NavigationArrow = ({ side, settingsPage }: NavigationArrowProps) => {
if (settingsPage) { if (settingsPage) {
return ( return (
<> <>
{side === "CameraFront" ? ( {side === "CameraA" ? (
<FontAwesomeIcon <FontAwesomeIcon
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"
@@ -44,19 +44,17 @@ const NavigationArrow = ({ side, settingsPage }: NavigationArrowProps) => {
} }
return ( return (
<> <>
{side === "Front" ? (
<FontAwesomeIcon <FontAwesomeIcon
icon={faArrowLeft} icon={faArrowLeft}
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)}
/> />
) : (
<FontAwesomeIcon <FontAwesomeIcon
icon={faArrowRight} icon={faArrowRight}
className="absolute top-[50%] right-[2%] backdrop-blur-md hover:cursor-pointer animate-bounce z-30" className="absolute top-[50%] right-[2%] backdrop-blur-md hover:cursor-pointer animate-bounce z-30"
onClick={() => navigationDest(side)} onClick={() => navigationDest(side)}
/> />
)}
</> </>
); );
}; };

View File

@@ -0,0 +1,18 @@
import { createContext, useContext, type Dispatch } from "react";
import type { SoundAction, SoundState } from "../types/types";
type SoundContextType = {
state: SoundState;
dispatch: Dispatch<SoundAction>;
};
export const SoundContext = createContext<SoundContextType | undefined>(
undefined
);
export const useSoundContext = () => {
const ctx = useContext(SoundContext);
if (!ctx)
throw new Error("useSoundContext must be used within <SoundContext>");
return ctx;
};

View File

@@ -17,10 +17,6 @@ export const AlertHitProvider = ({ children }: AlertHitProviderTypeProps) => {
query?.data?.alertHistory?.forEach((element: SightingType) => { query?.data?.alertHistory?.forEach((element: SightingType) => {
dispatch({ type: "ADD", payload: element }); dispatch({ type: "ADD", payload: element });
}); });
} else if (query.error) {
console.error("Error fetching alert hits:", query.error);
} else {
console.log("Loading alert hits...");
} }
}, [query.data, query.error, query.isLoading]); }, [query.data, query.error, query.isLoading]);

View File

@@ -0,0 +1,17 @@
import { useMemo, useReducer, type ReactNode } from "react";
import { SoundContext } from "../SoundContext";
import { initialState, reducer } from "../reducers/SoundContextReducer";
type SoundContextProviderProps = {
children: ReactNode;
};
const SoundContextProvider = ({ children }: SoundContextProviderProps) => {
const [state, dispatch] = useReducer(reducer, initialState);
const value = useMemo(() => ({ state, dispatch }), [state, dispatch]);
return (
<SoundContext.Provider value={value}>{children}</SoundContext.Provider>
);
};
export default SoundContextProvider;

View File

@@ -0,0 +1,38 @@
import type { SoundAction, SoundState } from "../../types/types";
export const initialState: SoundState = {
sightingSound: "switch",
NPEDsound: "popup",
hotlists: [],
soundOptions: [
{ name: "switch (Default)", soundFile: null },
{ name: "popup", soundFile: null },
{ name: "notification", soundFile: null },
],
};
export function reducer(state: SoundState, action: SoundAction): SoundState {
switch (action.type) {
case "UPDATE": {
return {
...state,
sightingSound: action.payload.sightingSound,
NPEDsound: action.payload.NPEDsound,
hotlists: action.payload.hotlists.map((hotlist) => ({
name: hotlist.name,
sound: hotlist.sound,
})),
};
}
case "ADD": {
return {
...state,
soundOptions: [...(state.soundOptions ?? []), action.payload],
};
}
default:
return state;
}
}

View File

@@ -1,9 +1,13 @@
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { CAM_BASE } from "../utils/config"; import { CAM_BASE } from "../utils/config";
import type { CameraBlackBoardOptions } from "../types/types"; import type { CameraBlackBoardOptions } from "../types/types";
import { useEffect } from "react";
import { toast } from "sonner";
const getBlackboardData = async () => { const getAllBlackboardData = async () => {
const response = await fetch(`${CAM_BASE}/api/blackboard`); const response = await fetch(`${CAM_BASE}/api/blackboard`, {
signal: AbortSignal.timeout(500),
});
if (!response.ok) { if (!response.ok) {
throw new Error("Failed to fetch blackboard data"); throw new Error("Failed to fetch blackboard data");
} }
@@ -25,7 +29,7 @@ const viewBlackboardData = async (options: CameraBlackBoardOptions) => {
export const useCameraBlackboard = () => { export const useCameraBlackboard = () => {
const query = useQuery({ const query = useQuery({
queryKey: ["cameraBlackboard"], queryKey: ["cameraBlackboard"],
queryFn: getBlackboardData, queryFn: getAllBlackboardData,
}); });
const mutation = useMutation({ const mutation = useMutation({
@@ -36,7 +40,17 @@ export const useCameraBlackboard = () => {
path: options?.path, path: options?.path,
value: options?.value, value: options?.value,
}), }),
onError: (error) => {
toast.error(`cannot get data: ${error.message}`, {
id: "viewBlackboardData",
}); });
},
});
useEffect(() => {
if (query.isError)
toast.error(query.error.message, { id: "viewBlackboardData" });
}, [query?.error?.message, query.isError]);
return { query, mutation }; return { query, mutation };
}; };

View File

@@ -3,12 +3,13 @@ import { toast } from "sonner";
import { CAM_BASE } from "../utils/config"; import { CAM_BASE } from "../utils/config";
const base_url = `${CAM_BASE}/api`; const base_url = `${CAM_BASE}/api`;
console.log(base_url);
const fetchCameraSideConfig = async ({ queryKey }: { queryKey: string[] }) => { const fetchCameraSideConfig = async ({ queryKey }: { queryKey: string[] }) => {
const [, cameraSide] = queryKey; const [, cameraSide] = queryKey;
const fetchUrl = `${base_url}/fetch-config?id=${cameraSide}`; const fetchUrl = `${base_url}/fetch-config?id=${cameraSide}`;
const response = await fetch(fetchUrl); const response = await fetch(fetchUrl, {
signal: AbortSignal.timeout(500),
});
if (!response.ok) throw new Error("cannot react cameraSide "); if (!response.ok) throw new Error("cannot react cameraSide ");
return response.json(); return response.json();
}; };
@@ -20,10 +21,10 @@ const updateCamerasideConfig = async (data: {
const updateUrl = `${base_url}/update-config?id=${data.id}`; const updateUrl = `${base_url}/update-config?id=${data.id}`;
const updateConfigPayload = { const updateConfigPayload = {
id: data.id, id: data.friendlyName,
fields: [ fields: [
{ {
property: "propLEDDriverControlURI", property: "id",
value: data.friendlyName, value: data.friendlyName,
}, },
], ],
@@ -54,5 +55,6 @@ export const useFetchCameraConfig = (cameraSide: string) => {
isError: fetchedConfigQuery.isError, isError: fetchedConfigQuery.isError,
updateCameraConfig: updateConfigMutation.mutate, updateCameraConfig: updateConfigMutation.mutate,
updateCameraConfigError: updateConfigMutation.error, updateCameraConfigError: updateConfigMutation.error,
updateCameraConfigLoading: updateConfigMutation.isPending,
}; };
}; };

View File

@@ -0,0 +1,128 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { CAM_BASE } from "../utils/config";
import { useEffect } from "react";
import { toast } from "sonner";
import type { BearerTypeFieldType, InitialValuesForm } from "../types/types";
const getDispatcherConfig = async () => {
const response = await fetch(`${CAM_BASE}/api/fetch-config?id=Dispatcher`);
if (!response.ok) throw new Error("Cannot get dispatcher configuration");
return response.json();
};
const updateDispatcherConfig = async (data: BearerTypeFieldType) => {
const updateConfigPayload = {
id: "Dispatcher",
fields: [
{
property: "propEnabled",
value: data.enabled,
},
// Todo: figure out how to add verbose conditionally
{
property: "propFormat",
value: data.format,
},
],
};
const response = await fetch(`${CAM_BASE}/api/update-config?id=Dispatcher`, {
method: "POST",
body: JSON.stringify(updateConfigPayload),
});
if (!response.ok) throw new Error("Cannot update dispatcher configuration");
return response.json();
};
const getBackOfficeConfig = async () => {
const response = await fetch(
`${CAM_BASE}/api/fetch-config?id=Dispatcher-json`
);
if (!response.ok) throw new Error("Cannot get Back Office configuration");
return response.json();
};
const updateBackOfficeConfig = async (data: InitialValuesForm) => {
const updateConfigPayload = {
id: "Dispatcher-json",
fields: [
{
property: "propBackofficeURL",
value: data.backOfficeURL,
},
{
property: "propConnectTimeoutSeconds",
value: data.connectTimeoutSeconds,
},
{
property: "propPassword",
value: data.password,
},
{
property: "propReadTimeoutSeconds",
value: data.readTimeoutSeconds,
},
{
property: "propUsername",
value: data.username,
},
],
};
const response = await fetch(
`${CAM_BASE}/api/update-config?id=Dispatcher-json`,
{
method: "POST",
body: JSON.stringify(updateConfigPayload),
}
);
if (!response.ok) throw new Error("Cannot update Back Office configuration");
return response.json();
};
export const useCameraOutput = () => {
const dispatcherQuery = useQuery({
queryKey: ["dispatcher"],
queryFn: getDispatcherConfig,
});
const backOfficeQuery = useQuery({
queryKey: ["backoffice"],
queryFn: getBackOfficeConfig,
});
const dispatcherMutation = useMutation({
mutationFn: updateDispatcherConfig,
mutationKey: ["dispatcherUpdate"],
onError: (error) => toast.error(error.message),
onSuccess: (data) => {
if (data) {
toast.success("Settings successfully updated");
}
},
});
const backOfficeMutation = useMutation({
mutationKey: ["backOfficeUpdate"],
mutationFn: updateBackOfficeConfig,
onError: (error) => toast.error(error.message),
onSuccess: (data) => {
if (data) {
toast.success("Settings successfully updated");
}
},
});
useEffect(() => {
if (dispatcherQuery.isError) toast.error(dispatcherQuery.error.message);
}, [dispatcherQuery?.error?.message, dispatcherQuery.isError]);
useEffect(() => {
if (backOfficeQuery.isError) toast.error(backOfficeQuery.error.message);
}, [backOfficeQuery?.error?.message, backOfficeQuery.isError]);
return {
dispatcherQuery,
dispatcherMutation,
backOfficeQuery,
backOfficeMutation,
};
};

View File

@@ -0,0 +1,117 @@
import { useQuery, useMutation } from "@tanstack/react-query";
import { CAM_BASE } from "../utils/config";
import type { ModemConfig, WifiConfig } from "../types/types";
import { useEffect } from "react";
import { toast } from "sonner";
const getWiFiSettings = async () => {
const response = await fetch(
`${CAM_BASE}/api/fetch-config?id=ModemAndWifiManager-wifi`,
{
signal: AbortSignal.timeout(500),
}
);
if (!response.ok) {
throw new Error("Cannot fetch Wifi settings");
}
return response.json();
};
const updateWifiSettings = async (wifiConfig: WifiConfig) => {
const response = await fetch(
`${CAM_BASE}/api/update-config?id=ModemAndWifiManager-wifi`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(wifiConfig),
}
);
if (!response.ok) {
throw new Error("Cannot update wifi settings");
}
return response.json();
};
const getModemSettings = async () => {
const response = await fetch(
`${CAM_BASE}/api/fetch-config?id=ModemAndWifiManager-modem`,
{
signal: AbortSignal.timeout(500),
}
);
if (!response.ok) {
throw new Error("Cannot fetch modem settings");
}
return response.json();
};
const updateModemSettings = async (modemConfig: ModemConfig) => {
const response = await fetch(
`${CAM_BASE}/api/update-config?id=ModemAndWifiManager-modem`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(modemConfig),
}
);
if (!response.ok) {
throw new Error("cannot update modem settings");
}
return response.json();
};
export const useWifiAndModem = () => {
const wifiQuery = useQuery({
queryKey: ["getWifiSettings"],
queryFn: getWiFiSettings,
});
const wifiMutation = useMutation({
mutationKey: ["updateWifiSettings"],
mutationFn: (wifiConfig: WifiConfig) => updateWifiSettings(wifiConfig),
onError: (error) => {
toast.error("Failed to update WiFi settings", { id: "wiFiSettings" });
console.error(error);
},
onSuccess: () => {
toast.success("WiFi settings updated successfully", {
id: "wiFiSettings",
});
},
});
const modemQuery = useQuery({
queryKey: ["getModemSettings"],
queryFn: getModemSettings,
});
const modemMutation = useMutation({
mutationKey: ["updateModemSettings"],
mutationFn: (modemConfig: ModemConfig) => updateModemSettings(modemConfig),
onError: (error) => {
toast.error("Failed to update Modem settings", { id: "modemSettings" });
console.error(error);
},
onSuccess: () => {
toast.success("Modem settings updated successfully", {
id: "modemSettings",
});
},
});
useEffect(() => {
if (wifiQuery.isError)
toast.error("Cannot get WiFi settings", { id: "wiFiSettings" });
}, [wifiQuery?.error?.message, wifiQuery.isError]);
useEffect(() => {
if (modemQuery.isError)
toast.error("Cannot get Modem settings", { id: "modemSettings" });
}, [modemQuery?.error?.message, modemQuery.isError]);
return {
wifiQuery,
wifiMutation,
modemQuery,
modemMutation,
};
};

View File

@@ -0,0 +1,59 @@
import {
useMutation,
useQuery,
type QueryFunctionContext,
} from "@tanstack/react-query";
import { CAM_BASE } from "../utils/config";
import type { zoomConfig, ZoomInOptions } from "../types/types";
import { toast } from "sonner";
import { useEffect } from "react";
async function zoomIn(options: ZoomInOptions) {
const response = await fetch(
`${CAM_BASE}/Ip${options.camera}-command?magnification=${options.multiplier}x`,
{
signal: AbortSignal.timeout(500),
}
);
if (!response.ok) {
throw new Error("Cannot reach camera zoom endpoint");
}
return response.json();
}
async function fetchZoomInConfig({
queryKey,
}: QueryFunctionContext<[string, zoomConfig]>) {
const [, { camera }] = queryKey;
const response = await fetch(`${CAM_BASE}/Ip${camera}-inspect`, {
signal: AbortSignal.timeout(500),
});
if (!response.ok) {
throw new Error("Cannot get camera zoom settings");
}
return response.text();
}
//change to string
export const useCameraZoom = (options: zoomConfig) => {
const mutation = useMutation({
mutationKey: ["zoomIn"],
mutationFn: (options: ZoomInOptions) => zoomIn(options),
onError: (err) => {
toast.error(`Failed to update zoom settings: ${err.message}`, {
id: "zoom",
});
},
});
const query = useQuery({
queryKey: ["fetchZoomInConfig", options],
queryFn: fetchZoomInConfig,
});
useEffect(() => {
if (query.isError) toast.error(query.error.message, { id: "hardReboot" });
}, [query?.error?.message, query.isError]);
return { mutation, query };
};

View File

@@ -3,9 +3,11 @@ import { useQuery } from "@tanstack/react-query";
import { CAM_BASE } from "../utils/config"; import { CAM_BASE } from "../utils/config";
const apiUrl = CAM_BASE; const apiUrl = CAM_BASE;
// const fetch_url = `http://100.82.205.44/Colour-preview`;
async function fetchSnapshot() { async function fetchSnapshot(cameraSide: string) {
const response = await fetch(`${apiUrl}/CameraA-preview`); const response = await fetch(`${apiUrl}/${cameraSide}-preview`, {
signal: AbortSignal.timeout(500),
});
if (!response.ok) { if (!response.ok) {
throw new Error("Cannot reach endpoint"); throw new Error("Cannot reach endpoint");
} }
@@ -13,7 +15,7 @@ async function fetchSnapshot() {
return await response.blob(); return await response.blob();
} }
export function useGetOverviewSnapshot() { export function useGetOverviewSnapshot(side: string) {
const latestUrlRef = useRef<string | null>(null); const latestUrlRef = useRef<string | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null); const canvasRef = useRef<HTMLCanvasElement | null>(null);
const imageRef = useRef<HTMLImageElement | null>(null); const imageRef = useRef<HTMLImageElement | null>(null);
@@ -38,7 +40,7 @@ export function useGetOverviewSnapshot() {
isPending, isPending,
} = useQuery({ } = useQuery({
queryKey: ["overviewSnapshot"], queryKey: ["overviewSnapshot"],
queryFn: () => fetchSnapshot(), queryFn: () => fetchSnapshot(side),
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
refetchInterval: 250, refetchInterval: 250,
}); });
@@ -75,9 +77,5 @@ export function useGetOverviewSnapshot() {
}; };
}, [drawImage]); }, [drawImage]);
if (isError) { return { canvasRef, isError, error, isPending };
console.error("Snapshot error:", error);
}
return { canvasRef, isError, isPending };
} }

View File

@@ -1,60 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { useCallback, useEffect, useRef } from "react";
async function fetchSighting() {
const response = await fetch(
// `http://100.82.205.44/api`
`http://192.168.75.11/mergedHistory/sightingSummary?mostRecentRef=-1`
);
if (!response.ok) throw new Error("Failed to fetch sighting");
return response.json();
}
export function useLatestSighting() {
const latestUrlRef = useRef<string | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const sightingImageRef = useRef<HTMLImageElement | null>(null);
const drawImage = useCallback(() => {
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
const img2 = sightingImageRef.current;
if (!img2 || !canvas) return;
canvas.width = canvas.clientWidth;
canvas.height = canvas.clientHeight;
ctx?.drawImage(img2, 0, 0, 200, 50);
}, [sightingImageRef]);
const { data } = useQuery({
queryKey: ["latestSighting"],
queryFn: fetchSighting,
refetchInterval: 100,
});
useEffect(() => {
const img = new Image();
sightingImageRef.current = img;
img.onload = () => {
drawImage();
};
img.src = data?.plateUrlColour;
if (latestUrlRef.current) {
URL.revokeObjectURL(latestUrlRef.current);
}
latestUrlRef.current = img.src;
return () => {
if (latestUrlRef.current) {
URL.revokeObjectURL(latestUrlRef.current);
latestUrlRef.current = null;
}
};
}, [data?.plateUrlColour, drawImage]);
return { data, sightingImageRef, canvasRef };
}

View File

@@ -3,10 +3,13 @@ import type { NPEDFieldType } from "../types/types";
import { useNPEDContext } from "../context/NPEDUserContext"; import { useNPEDContext } from "../context/NPEDUserContext";
import { useEffect } from "react"; import { useEffect } from "react";
import { CAM_BASE } from "../utils/config"; import { CAM_BASE } from "../utils/config";
import { toast } from "sonner";
async function fetchNPEDDetails() { async function fetchNPEDDetails() {
const fetchUrl = `${CAM_BASE}/api/fetch-config?id=NPED`; const fetchUrl = `${CAM_BASE}/api/fetch-config?id=NPED`;
const response = await fetch(fetchUrl); const response = await fetch(fetchUrl, {
signal: AbortSignal.timeout(500),
});
if (!response.ok) throw new Error("Cannot reach fetch-config endpoint"); if (!response.ok) throw new Error("Cannot reach fetch-config endpoint");
return response.json(); return response.json();
@@ -14,45 +17,37 @@ async function fetchNPEDDetails() {
async function signIn(loginDetails: NPEDFieldType) { async function signIn(loginDetails: NPEDFieldType) {
const { frontId, rearId, username, password, clientId } = loginDetails; const { frontId, rearId, username, password, clientId } = loginDetails;
const NPEDLoginURLFront = `${CAM_BASE}/api/update-config?id=${frontId}`; const NPEDLoginURLFront = `${CAM_BASE}/api/update-config?id=${frontId}`;
const NPEDLoginURLRear = `${CAM_BASE}/api/update-config?id=${rearId}`; const NPEDLoginURLRear = `${CAM_BASE}/api/update-config?id=${rearId}`;
const frontCameraPayload = {
id: frontId, const payload = (id: string) => ({
id,
fields: [ fields: [
{ property: "propEnabled", value: true }, { property: "propEnabled", value: true },
{ property: "propUsername", value: username }, { property: "propUsername", value: username },
{ property: "propPassword", value: password }, { property: "propPassword", value: password },
{ property: "propClientID", value: clientId }, { property: "propClientID", value: clientId },
], ],
};
const rearCameraPayload = {
id: rearId,
fields: [
{ property: "propEnabled", value: true },
{ property: "propUsername", value: username },
{ property: "propPassword", value: password },
{ property: "propClientID", value: clientId },
],
};
const frontCameraResponse = await fetch(NPEDLoginURLFront, {
method: "POST",
body: JSON.stringify(frontCameraPayload),
}); });
const rearCameraResponse = await fetch(NPEDLoginURLRear, { const [frontRes, rearRes] = await Promise.all([
fetch(NPEDLoginURLFront, {
method: "POST", method: "POST",
body: JSON.stringify(rearCameraPayload), body: JSON.stringify(payload(frontId)),
}); }),
fetch(NPEDLoginURLRear, {
method: "POST",
body: JSON.stringify(payload(rearId)),
}),
]);
if (!frontCameraResponse.ok) throw new Error("cannot reach NPED endpoint"); if (!frontRes.ok || !rearRes.ok)
if (!rearCameraResponse.ok) throw new Error("cannot reach NPED endpoint"); throw new Error("Cannot reach NPED endpoint");
const data = await frontCameraResponse.json();
console.log(data);
return { return {
frontResponse: frontCameraResponse.json(), frontResponse: frontRes.json(),
rearResponse: rearCameraResponse.json(), rearResponse: rearRes.json(),
}; };
} }
@@ -83,12 +78,34 @@ export const useNPEDAuth = () => {
const signInMutation = useMutation({ const signInMutation = useMutation({
mutationKey: ["NPEDSignin"], mutationKey: ["NPEDSignin"],
mutationFn: signIn, mutationFn: signIn,
onSuccess: async (data) => setUser(await data.frontResponse), onMutate: () => {
toast.loading("Signing in...");
},
onSuccess: async (data) => {
toast.dismiss();
toast.success("Signed in successfully!");
setUser(await data.frontResponse);
},
onError: (error) => {
toast.dismiss();
if (error.message.includes("timed out")) {
toast.error("Connection timed out. Please check your network.");
} else {
toast.error(`Sign-in failed: ${error.message}`);
}
},
}); });
const signOutMutation = useMutation({ const signOutMutation = useMutation({
mutationKey: ["auth", "NPEDSignOut"], mutationKey: ["auth", "NPEDSignOut"],
mutationFn: signOut, mutationFn: signOut,
onSuccess: () => {
toast.success("Signed out successfully");
setUser(null);
},
onError: (error) => {
toast.error(`Sign-out failed: ${error.message}`);
},
}); });
const fetchdataQuery = useQuery({ const fetchdataQuery = useQuery({
@@ -104,6 +121,10 @@ export const useNPEDAuth = () => {
} }
}, [fetchdataQuery.data, fetchdataQuery.isSuccess, setUser]); }, [fetchdataQuery.data, fetchdataQuery.isSuccess, setUser]);
useEffect(() => {
if (fetchdataQuery.isError) toast.error(fetchdataQuery.error.message);
}, [fetchdataQuery?.error?.message, fetchdataQuery.isError]);
return { return {
signIn: signInMutation.mutate, signIn: signInMutation.mutate,
signInAsync: signInMutation.mutateAsync, signInAsync: signInMutation.mutateAsync,
@@ -111,6 +132,8 @@ export const useNPEDAuth = () => {
isError: signInMutation.isError, isError: signInMutation.isError,
error: signInMutation.error, error: signInMutation.error,
data: signInMutation.data, data: signInMutation.data,
fetchdataQueryError: fetchdataQuery.error,
fetchdataQueryLoading: fetchdataQuery.isLoading,
user, user,
setUser, setUser,
signOut: signOutMutation.mutate, signOut: signOutMutation.mutate,

View File

@@ -1,36 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { useRef } from "react";
import { CAM_BASE } from "../utils/config";
async function fetchOverviewImage(cameraSide: string) {
const response = await fetch(`${CAM_BASE}/${cameraSide}-preview`);
if (!response.ok) throw new Error("could not fetch overview image");
return response.blob();
}
export function useOverviewVideo() {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const { isPending, isError, data } = useQuery({
queryKey: ["overviewVideo"],
queryFn: () => fetchOverviewImage("CameraFront"),
// refetchInterval: () =>
// typeof document !== "undefined" && document.visibilityState === "hidden"
// ? SLOW_MS
// : FAST_MS,
// refetchIntervalInBackground: false,
});
if (isPending) return;
if (isError) return;
const img = new Image();
const imgUrl = URL.createObjectURL(data);
img.src = imgUrl;
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
ctx?.drawImage(img, 0, 0);
}

35
src/hooks/useReboots.ts Normal file
View File

@@ -0,0 +1,35 @@
import { CAM_BASE } from "../utils/config";
import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
async function handleSoftReboot() {
const response = await fetch(`${CAM_BASE}/api/restart-flexiai`);
if (!response.ok) throw new Error("Failed to Software Reboot");
return "Software reboot triggered!";
}
async function handleHardReboot() {
const response = await fetch(`${CAM_BASE}/api/restart-hardware`);
if (!response.ok) throw new Error("Failed to Hardware Reboot");
return "Hardware reboot triggered!";
}
export const useReboots = () => {
const softRebootMutation = useMutation({
mutationKey: ["softReboot"],
mutationFn: handleSoftReboot,
onSuccess: () => toast.success("Software reboot triggered!"),
onError: (error) => toast.error(error.message),
});
const hardRebootMutation = useMutation({
mutationKey: ["hardReboot"],
mutationFn: handleHardReboot,
onSuccess: () => toast.success("Harware reboot triggered!"),
onError: (error) => toast.error(error.message),
});
return { softRebootMutation, hardRebootMutation };
};

View File

@@ -1,19 +1,24 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { 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 { 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, url: string | undefined,
ref: number ref: number
): Promise<SightingType> { ): Promise<SightingType> {
const res = await fetch(`${url}${ref}`); const res = await fetch(`${url}${ref}`, {
signal: AbortSignal.timeout(5000),
});
if (!res.ok) throw new Error(String(res.status)); if (!res.ok) throw new Error(String(res.status));
return res.json(); return res.json();
} }
export function useSightingFeed(url: string | undefined) { export function useSightingFeed(url: string | undefined) {
const { state } = 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);
@@ -23,8 +28,22 @@ export function useSightingFeed(url: string | undefined) {
const [selectedSighting, setSelectedSighting] = useState<SightingType | null>( const [selectedSighting, setSelectedSighting] = useState<SightingType | null>(
null null
); );
const first = useRef(true);
useSoundOnChange(switchSound, latestRef, { const trigger = useMemo(() => {
if (latestRef == null) return null;
if (first.current) {
first.current = false;
return Symbol("skip");
}
return latestRef;
}, [latestRef]);
const soundSrc = useMemo(() => {
return getSoundFileURL(state.sightingSound) ?? switchSound;
}, [state.sightingSound]);
//use latestref instead of trigger to revert back
useSoundOnChange(soundSrc, trigger, {
volume: 1, volume: 1,
}); });

View File

@@ -1,64 +0,0 @@
// useBeep.ts
import { useEffect, useRef } from "react";
import { useSoundEnabled } from "react-sounds"; // so it respects your SoundBtn toggle
/**
* Plays a sound whenever `latestRef` changes.
*
* @param src Path to the sound file
* @param latestRef The primitive value to watch (e.g. sighting.ref)
* @param opts volume: 0..1, enabledOverride: force enable/disable, minGapMs: throttle interval
*/
export function useBeep(
src: string,
latestRef: number | null,
opts?: { volume?: number; enabledOverride?: boolean; minGapMs?: number }
) {
const audioRef = useRef<HTMLAudioElement>(undefined);
const prevRef = useRef<number | null>(null);
const lastPlay = useRef(0);
const [enabled] = useSoundEnabled();
const minGap = opts?.minGapMs ?? 250; // dont play more than 4 times/sec
// Create the audio element once
useEffect(() => {
const a = new Audio(src);
a.preload = "auto";
if (opts?.volume !== undefined) a.volume = opts.volume;
audioRef.current = a;
return () => {
a.pause();
};
}, [src, opts?.volume]);
// Watch for ref changes
useEffect(() => {
if (latestRef == null) return;
const canPlay =
(opts?.enabledOverride ?? enabled) &&
document.visibilityState === "visible";
if (!canPlay) {
prevRef.current = latestRef; // consume the change
return;
}
if (prevRef.current !== null && latestRef !== prevRef.current) {
const now = Date.now();
if (now - lastPlay.current >= minGap) {
const a = audioRef.current;
if (a) {
try {
a.currentTime = 0; // restart from beginning
void a.play(); // fire and forget
lastPlay.current = now;
} catch (err) {
console.warn("Audio play failed:", err);
}
}
}
}
prevRef.current = latestRef;
}, [latestRef, enabled, opts?.enabledOverride, minGap]);
}

View File

@@ -5,28 +5,49 @@ import {
handleSystemSave, handleSystemSave,
handleSystemRecall, handleSystemRecall,
} from "../components/SettingForms/System/SettingSaveRecall"; } from "../components/SettingForms/System/SettingSaveRecall";
import { useEffect } from "react";
export const useSystemConfig = () => { export const useSystemConfig = () => {
const uploadSettingsMutation = useMutation({ const uploadSettingsMutation = useMutation({
mutationKey: ["uploadSettings"], mutationKey: ["uploadSettings"],
mutationFn: sendBlobFileUpload, mutationFn: sendBlobFileUpload,
onError: (error) => toast.error(error.message), onError: (error) =>
onSuccess: (test) => toast(test), toast.error(error.message, {
id: "uploadSettings",
}),
onSuccess: (test) =>
toast(test, {
id: "uploadSettings",
}),
}); });
const saveSystemSettings = useMutation({ const saveSystemSettings = useMutation({
mutationKey: ["systemSaveSettings"], mutationKey: ["systemSaveSettings"],
mutationFn: handleSystemSave, mutationFn: handleSystemSave,
onSuccess: () => toast("System Settings Saved Successfully!"), onError: (error) =>
toast.error(error.message, {
id: "systemSettings",
}),
}); });
const getSystemSettings = useQuery({ const getSystemSettings = useQuery({
queryKey: ["getSystemSettings"], queryKey: ["getSystemSettings"],
queryFn: handleSystemRecall, queryFn: handleSystemRecall,
}); });
useEffect(() => {
if (getSystemSettings.isError)
toast.error(getSystemSettings.error.message, {
id: "systemSettings",
});
}, [getSystemSettings?.error?.message, getSystemSettings.isError]);
return { return {
uploadSettings: uploadSettingsMutation.mutate, uploadSettings: uploadSettingsMutation.mutate,
saveSystemSettings: saveSystemSettings.mutate, saveSystemSettings: saveSystemSettings.mutate,
systemSettingsData: getSystemSettings.data, systemSettingsData: getSystemSettings.data,
systemSettingsError: getSystemSettings.error,
saveSystemSettingsError: saveSystemSettings.isError,
saveSystemSettingsLoading: saveSystemSettings.isPending,
}; };
}; };

View File

@@ -1,12 +1,16 @@
import FrontCameraOverviewCard from "../components/FrontCameraOverview/FrontCameraOverviewCard"; import FrontCameraOverviewCard from "../components/FrontCameraOverview/FrontCameraOverviewCard";
import SightingHistoryWidget from "../components/SightingsWidget/SightingWidget"; import SightingHistoryWidget from "../components/SightingsWidget/SightingWidget";
import { SightingFeedProvider } from "../context/providers/SightingFeedProvider"; import { SightingFeedProvider } from "../context/providers/SightingFeedProvider";
import { CAM_BASE } from "../utils/config"; import { CAM_BASE, OUTSIDE_CAM_BASE } from "../utils/config";
const Dashboard = () => { const Dashboard = () => {
const mode = import.meta.env.MODE;
const base_url = `${CAM_BASE}/SightingList/sightingSummary?mostRecentRef=`; const base_url = `${CAM_BASE}/SightingList/sightingSummary?mostRecentRef=`;
// const outside_url = `http://100.82.205.44/mergedHistory/sightingSummary?mostRecentRef=`;
console.log(mode);
console.log(OUTSIDE_CAM_BASE);
return ( return (
<SightingFeedProvider url={base_url} side="Front"> <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">
<FrontCameraOverviewCard /> <FrontCameraOverviewCard />
<SightingHistoryWidget title="Sightings" /> <SightingHistoryWidget title="Sightings" />

View File

@@ -1,16 +1,25 @@
import { useState } from "react";
import CameraSettings from "../components/CameraSettings/CameraSettings"; import CameraSettings from "../components/CameraSettings/CameraSettings";
import OverviewVideoContainer from "../components/FrontCameraSettings/OverviewVideoContainer"; import OverviewVideoContainer from "../components/FrontCameraSettings/OverviewVideoContainer";
import { Toaster } from "sonner"; import { Toaster } from "sonner";
const FrontCamera = () => { const FrontCamera = () => {
const [zoomLevel, setZoomLevel] = useState<number>(1);
return ( return (
<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">
<OverviewVideoContainer <OverviewVideoContainer
title={"Front Camera"} title={"Camera A"}
side="CameraRear" side="CameraA"
settingsPage={true} settingsPage={true}
zoomLevel={zoomLevel}
onZoomLevelChange={setZoomLevel}
/>
<CameraSettings
title="Camera A Settings"
side="CameraA"
zoomLevel={zoomLevel}
onZoomLevelChange={setZoomLevel}
/> />
<CameraSettings title="Front Camera Settings" side="CameraFront" />
<Toaster /> <Toaster />
</div> </div>
); );

View File

@@ -1,27 +1,25 @@
import { useNavigate } from "react-router";
import { useSwipeable } from "react-swipeable";
import OverviewVideoContainer from "../components/FrontCameraSettings/OverviewVideoContainer"; import OverviewVideoContainer from "../components/FrontCameraSettings/OverviewVideoContainer";
import CameraSettings from "../components/CameraSettings/CameraSettings"; import CameraSettings from "../components/CameraSettings/CameraSettings";
import { Toaster } from "sonner"; import { Toaster } from "sonner";
import { useState } from "react";
const RearCamera = () => { const RearCamera = () => {
const navigate = useNavigate(); const [zoomLevel, setZoomLevel] = useState<number>(1);
const handlers = useSwipeable({
onSwipedRight: () => navigate("/"),
trackMouse: true,
});
return ( return (
<div className="mx-auto grid grid-cols-1 sm:grid-cols-1 lg:grid-cols-2 gap-4 px-2 sm:px-4 lg:px-0 w-full order-first"> <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">
<CameraSettings title="Rear Camera Settings" side={"CameraRear"} /> <CameraSettings
title="Camera B Settings"
<div {...handlers}> side={"CameraB"}
<OverviewVideoContainer zoomLevel={zoomLevel}
title={"Rear Camera"} onZoomLevelChange={setZoomLevel}
side={"CameraRear"} />
settingsPage={true} <OverviewVideoContainer
title={"Camera B"}
side={"CameraB"}
settingsPage={true}
zoomLevel={zoomLevel}
onZoomLevelChange={setZoomLevel}
/> />
</div>
<Toaster /> <Toaster />
</div> </div>
); );

View File

@@ -1,3 +1,4 @@
import { Toaster } from "sonner";
import HistoryList from "../components/HistoryList/HistoryList.tsx"; import HistoryList from "../components/HistoryList/HistoryList.tsx";
import HitSearchCard from "../components/SessionForm/HitSearchCard.tsx"; import HitSearchCard from "../components/SessionForm/HitSearchCard.tsx";
import SessionCard from "../components/SessionForm/SessionCard.tsx"; import SessionCard from "../components/SessionForm/SessionCard.tsx";
@@ -15,6 +16,7 @@ const Session = () => {
<div className="col-span-2"> <div className="col-span-2">
<HistoryList /> <HistoryList />
</div> </div>
<Toaster />
</div> </div>
</SightingFeedProvider> </SightingFeedProvider>
); );

View File

@@ -8,6 +8,8 @@ import ModemCard from "../components/SettingForms/WiFi&Modem/ModemCard";
import SystemCard from "../components/SettingForms/System/SystemCard"; import SystemCard from "../components/SettingForms/System/SystemCard";
import { Toaster } from "sonner"; import { Toaster } from "sonner";
import { useNPEDAuth } from "../hooks/useNPEDAuth"; import { useNPEDAuth } from "../hooks/useNPEDAuth";
import SoundSettingsCard from "../components/SettingForms/Sound/SoundSettingsCard";
import SoundUploadCard from "../components/SettingForms/Sound/SoundUploadCard";
const SystemSettings = () => { const SystemSettings = () => {
useNPEDAuth(); useNPEDAuth();
@@ -20,6 +22,7 @@ const SystemSettings = () => {
<Tab>Output</Tab> <Tab>Output</Tab>
<Tab>Integrations</Tab> <Tab>Integrations</Tab>
<Tab>WiFi and Modem</Tab> <Tab>WiFi and Modem</Tab>
<Tab>Sound</Tab>
</TabList> </TabList>
<TabPanel> <TabPanel>
<div className="flex flex-col space-y-3"> <div className="flex flex-col space-y-3">
@@ -43,6 +46,12 @@ const SystemSettings = () => {
<ModemCard /> <ModemCard />
</div> </div>
</TabPanel> </TabPanel>
<TabPanel>
<div className="mx-auto grid grid-cols-1 sm:grid-cols-1 lg:grid-cols-2 gap-4 px-2 sm:px-4 lg:px-0 w-full">
<SoundSettingsCard />
<SoundUploadCard />
</div>
</TabPanel>
</Tabs> </Tabs>
<Toaster /> <Toaster />
</div> </div>

View File

@@ -49,9 +49,6 @@ export type BearerTypeFieldType = {
}; };
export type InitialValuesForm = { export type InitialValuesForm = {
format: string;
enabled: boolean;
verbose: boolean;
backOfficeURL: string; backOfficeURL: string;
username: string; username: string;
password: string; password: string;
@@ -59,6 +56,14 @@ export type InitialValuesForm = {
readTimeoutSeconds: number; readTimeoutSeconds: number;
}; };
export type InitialValuesFormErrors = {
backOfficeURL?: string;
username?: string;
password?: string;
connectTimeoutSeconds?: string;
readTimeoutSeconds?: string;
};
export type NPEDFieldType = { export type NPEDFieldType = {
frontId: string; frontId: string;
username: string | undefined; username: string | undefined;
@@ -251,3 +256,93 @@ export type CameraBlackBoardOptions = {
export type CameraBlackboardResponse = { export type CameraBlackboardResponse = {
data: object; data: object;
}; };
export type ZoomLevel = {
left: number;
top: number;
x: number;
y: number;
px: number;
py: number;
level?: number;
};
export type SoundValue = string;
export type Hotlist = {
name: string;
sound: SoundValue;
};
export type FormValues = {
sightingSound: SoundValue;
NPEDsound: SoundValue;
hotlists: Hotlist[];
};
export type SoundUploadValue = {
name: string;
soundFile: File | null;
};
export type SoundState = {
sightingSound: SoundValue;
NPEDsound: SoundValue;
hotlists: Hotlist[];
soundOptions?: SoundUploadValue[];
};
type UpdateAction = {
type: "UPDATE";
payload: {
sightingSound: SoundValue;
NPEDsound: SoundValue;
hotlists: Hotlist[];
};
};
type AddAction = {
type: "ADD";
payload: SoundUploadValue;
};
export type SoundAction = UpdateAction | AddAction;
export type WifiSettingValues = {
ssid: string;
password: string;
encryption: string;
};
export type ModemConfigPayload = {
property: string;
value: string;
};
export type WifiConfigPayload = {
property: string;
value: string;
};
export type WifiConfig = {
id: string;
fields: WifiConfigPayload[];
};
export type ModemConfig = {
id: string;
fields: ModemConfigPayload[];
};
export type ZoomInOptions = {
camera: string;
multiplier: number;
};
export type zoomConfig = {
camera: string;
};
export type ModemSettingsType = {
apn: string;
username: string;
password: string;
authenticationType: string;
};

View File

@@ -1,4 +1,4 @@
const rawCamBase = import.meta.env.VITE_CAM_BASE; const rawCamBase = import.meta.env.VITE_AGX_BOX_URL;
export const CAM_BASE = export const CAM_BASE =
rawCamBase && rawCamBase.trim().length > 0 rawCamBase && rawCamBase.trim().length > 0
? rawCamBase ? rawCamBase

View File

@@ -1,3 +1,16 @@
import switchSound from "../assets/sounds/ui/switch.mp3";
import popup from "../assets/sounds/ui/popup_open.mp3";
import notification from "../assets/sounds/ui/notification.mp3";
export function getSoundFileURL(name: string) {
const sounds: Record<string, string> = {
switch: switchSound,
popup: popup,
notification: notification,
};
return sounds[name] ?? null;
}
const randomChars = () => { const randomChars = () => {
const uppercaseAsciiStart = 65; const uppercaseAsciiStart = 65;
const letterIndex = Math.floor(Math.random() * 26); const letterIndex = Math.floor(Math.random() * 26);
@@ -6,6 +19,34 @@ const randomChars = () => {
return letter; return letter;
}; };
export function cleanArray(str: string) {
const toArr = str?.split(",");
const cleaned = toArr?.map((el: string) => {
const test = el.replace(/[^0-9a-z]/gi, "");
return test;
});
return cleaned;
}
export function parseRTSPUrl(url: string) {
const regex = /rtsp:\/\/([^:]+):([^@]+)@([^:/]+):?(\d+)?(\/.*)?/;
const match = url?.match(regex);
if (!match) {
return null;
}
return {
username: match[1],
password: match[2],
ip: match[3],
port: match[4] || "554", // default RTSP port
path: match[5] || "/",
};
}
const generateNumberPlate = () => { const generateNumberPlate = () => {
const numberPlateLetters = new Array(4); const numberPlateLetters = new Array(4);
const characters: string[] = []; const characters: string[] = [];