138 Commits

Author SHA1 Message Date
2d5b264041 - refactored state for sessionlist, and session active and pause states 2025-10-27 11:04:53 +00:00
251a2f5e7b - refactored NPED Login & logout 2025-10-27 09:35:59 +00:00
18534ceb2c - added functionality to save sighting sessions to black board 2025-10-27 08:28:44 +00:00
9975e6a6ca Merged develop into enhancement/sessionstats 2025-10-24 11:12:51 +00:00
c83122cd52 - added session sighting component
- add new session paused state and stop adding to session when true
2025-10-24 12:10:10 +01:00
abc8007fc6 Merged in enhancement/sightingHits (pull request #29)
Enhancement/sightingHits
2025-10-24 09:56:25 +00:00
7903633809 - removed console.log 2025-10-24 10:55:05 +01:00
359f3781f2 - refactored to allow for stacking of special hits (NPED + Hotlist) 2025-10-24 10:49:04 +01:00
a958901bed - will pick up later 2025-10-22 12:20:15 +01:00
b58181e551 - started improvements on session page to include pause and save buttons 2025-10-22 08:54:42 +01:00
df6bf75184 Merged in bugfix/uploadsounds (pull request #28)
Bugfix/uploadsounds
2025-10-21 14:20:33 +00:00
c5cea81532 Merged develop into bugfix/uploadsounds 2025-10-21 14:19:14 +00:00
78905b09e0 - added framework for playing uploaded music files. need to permanently store and retreive files 2025-10-21 12:52:14 +01:00
1ffad51503 fixed feature to upload sound files 2025-10-20 16:17:37 +01:00
d16f55413c Merged main into develop 2025-10-20 10:54:41 +00:00
3598f8d069 Merged in develop (pull request #27)
Develop
2025-10-20 10:53:48 +00:00
0a3a543d6f Merged in bugfix/hostlistsound (pull request #26)
bugfix/hotlistsound
2025-10-20 10:35:44 +00:00
0867b3b743 - added sounds for all hotlist sounds 2025-10-20 11:32:45 +01:00
a152c15ec7 - removed console.log 2025-10-20 10:59:38 +01:00
617ea60f26 Merged in bugfix/AlanFeedback (pull request #25)
Bugfix/AlanFeedback
2025-10-20 09:54:13 +00:00
1b0790a841 Merged develop into bugfix/AlanFeedback 2025-10-20 09:53:46 +00:00
a54e6a79c1 - updated button to match 2025-10-20 10:50:16 +01:00
b2dd35b311 - added volume functionality for NPED notifications 2025-10-20 09:11:05 +01:00
82b84dc46e - added volume setting for sighting hits 2025-10-17 16:12:02 +01:00
34c996c990 Merged main into develop 2025-10-17 10:39:28 +00:00
bb82fad583 Merged in develop (pull request #23)
Develop
2025-10-17 10:30:02 +00:00
3eb539fd9d - start addressing Alans feedback 2025-10-17 10:17:01 +01:00
7b730a8029 Merged in enhancement/sessionpage (pull request #22)
Enhancement/sessionpage
2025-10-17 07:29:49 +00:00
c8f4ebf5a9 - improvements made to session page alert list 2025-10-15 16:11:10 +01:00
7cfebab6c1 - improved UI for sessions page 2025-10-15 15:15:04 +01:00
c6ddd04303 Merged in bugfix/minor-issues-5 (pull request #21)
Bugfix/minor issues 5
2025-10-15 12:56:03 +00:00
db925e18ac Merged develop into bugfix/minor-issues-5 2025-10-15 12:54:29 +00:00
c8b381d816 - code clean up 2025-10-15 12:43:14 +01:00
9c9b8cb6b0 - added plate patch to alert item
- added hotlist name pick up on modals
2025-10-15 12:24:28 +01:00
4da240a204 - added prettier config file
- improved sound state to remove pooling

- increased size of naviagtion arrows and fixed navigation onClick

-  decreased width of nav bar

- fixed button to reveal passwords in some password fields
2025-10-15 11:00:52 +01:00
f6342375f9 Merged main into develop 2025-10-14 07:57:24 +00:00
b3eabfda35 Merged in develop (pull request #20)
Develop
2025-10-14 07:56:59 +00:00
09d5af4035 Merged in bugfix/hotlist-2 (pull request #19)
Bugfix/hotlist 2
2025-10-14 07:53:03 +00:00
7121809a9e - refactored fetching NPED Category 2025-10-14 08:50:06 +01:00
666b90d078 - updated NPED function to make it less expensive 2025-10-13 16:18:59 +01:00
213477640b - bugfix: fixed notification and modal for multiple hotlist hits 2025-10-13 16:00:06 +01:00
2f15f25389 Merged main into develop 2025-10-13 13:00:27 +00:00
63ac8e5f0a Merged in develop (pull request #18)
Develop
2025-10-13 12:59:49 +00:00
9334154603 - Improved development environment 2025-10-13 13:58:10 +01:00
6ab10341b4 .env edited online with Bitbucket 2025-10-13 12:21:13 +00:00
c2c5bd37cd Merged main into develop 2025-10-13 12:16:10 +00:00
dcc1f64599 Merged in bugfix/minor-issues-4 (pull request #17)
Bugfix/minor issues 4
2025-10-13 12:07:13 +00:00
eca3e9783e - Disabled camera settings form
- updated Config payload to update RSTP
2025-10-13 13:04:09 +01:00
44962e7d81 - fixed but to delete specific alert item
- need to remove rawcamebase from config in prod
2025-10-13 11:48:10 +01:00
6afa715b05 Merged in develop (pull request #16)
Develop
2025-10-09 13:23:30 +00:00
9f3674e460 -added cam base for api calls 2025-10-09 14:21:59 +01:00
582bd075d1 Merged in bugfix/hotlists (pull request #15)
Bugfix/hotlists
2025-10-09 13:16:07 +00:00
0a74ebfbfe removed nped cat d tag 2025-10-09 14:14:33 +01:00
063815cac0 - refactored code around hotlist hits and sounds
- improved performace for sounds playing
2025-10-09 14:11:58 +01:00
87be346c3b Merged in bugfix/sessionstats (pull request #14)
bugfix/sessionstats
2025-10-08 14:52:24 +00:00
17a4a6de8d - updated state for tracking sessions
- removed un used state in sighting feed

- added groupings depending on sightings
2025-10-08 15:46:54 +01:00
4e2d3c47c0 Merged in enhancement/soundsettings (pull request #13)
enhancement/soundsettings
2025-10-08 10:42:59 +00:00
f806371d19 Merged develop into enhancement/soundsettings 2025-10-08 10:42:23 +00:00
40909d48b6 - added state to set sound set settings for sightings and NPED hits
- added function to save mute settings
2025-10-08 11:08:41 +01:00
e9ef12c42f Merged main into develop 2025-10-07 15:03:10 +00:00
89eabc1fa7 Merged in develop (pull request #12)
Develop
2025-10-07 15:00:32 +00:00
a20a0c7019 Merge branch 'develop' of bitbucket.org:mavsystemsltd/mav-in-car-fe into develop 2025-10-07 15:58:41 +01:00
992fb4f959 removed console.logs 2025-10-07 15:58:16 +01:00
335bfe8c55 Merged main into develop 2025-10-07 14:54:31 +00:00
50d22def56 Merged in bugfix/sightingmodal (pull request #11)
bugfix/sightingmodal
2025-10-07 14:44:48 +00:00
5b5ab4a75a - corrected sighting modal sizing and button locations for small screens 2025-10-07 15:42:06 +01:00
e3d3a6331c Merged develop into main 2025-10-07 13:36:47 +00:00
d009d17706 Merged in bugfix/output (pull request #10)
Bugfix/output
2025-10-07 13:29:01 +00:00
d927767677 added async loading state 2025-10-07 14:05:27 +01:00
c2c2fc76f2 - updated backoffice setup
- need to add async loading state
2025-10-07 14:00:58 +01:00
3e564b933d - refactored bearer form
- started refactoring Channel form
2025-10-07 11:37:37 +01:00
5e34590e5c Merged in enhancement/reboots (pull request #9)
enhancement/reboots
2025-10-07 08:59:47 +00:00
b18d4272ec r- efactored reboots api to use useMutation for maintainability and readability.
- updated zoom debug
2025-10-07 09:56:24 +01:00
a4e1e6e16f Merged in enhancement/loading+errorstates (pull request #8)
Enhancement/loading+errorstates
2025-10-06 14:21:29 +00:00
a95c9077c4 more enhancements to loading and error feedback 2025-10-06 15:18:58 +01:00
f275f50383 Updated loading states and error states accross app 2025-10-06 14:21:56 +01:00
ad0ffa6df6 bugfix: minor change of zoomlevel type 2025-10-03 15:45:09 +01:00
64f208334b Merged in feature/soundsettings (pull request #7)
Feature/soundsettings
2025-10-03 14:02:12 +00:00
70c72640ea Merge branch 'develop' of bitbucket.org:mavsystemsltd/mav-in-car-fe into feature/soundsettings 2025-10-03 14:59:04 +01:00
d9e2dcbaae Merged in bugfix/minor-issues-2 (pull request #6)
Add RTSP URL parsing utility and enhance camera settings handling
2025-10-03 12:42:26 +00:00
e047c77cd1 Add RTSP URL parsing utility and enhance camera settings handling
- Implement parseRTSPUrl function to extract username, password, IP, port, and path from RTSP URLs.
- Update CameraSettingFields to utilize parsed RTSP URL data for camera configuration.
- Modify WiFiSettingsForm to allow password visibility toggle.
- Improve SightingOverview loading and error handling UI.
- Adjust NavigationArrow component to reflect updated camera side logic.
2025-10-03 13:08:21 +01:00
ce9d953f04 resolved conflicts from develop 2025-10-03 10:31:05 +01:00
306b8f70b9 Merged in feature/wifiandmodem (pull request #5)
Feature/wifiandmodem
2025-10-03 09:24:37 +00:00
a972234e22 modem settings management: integrate ModemSettings and ModemToggle components, update hooks for modem configuration, and enhance WiFiSettingsForm submission handling. 2025-10-03 10:19:49 +01:00
576afbb282 merged and resolved conflicts 2025-10-02 22:59:07 +01:00
054b0bf4ea WIP wifi modem settings 2025-10-02 22:53:38 +01:00
104fcf2455 Merged in enhancement/zoomIn-api (pull request #4)
Enhancement/zoomIn api
2025-10-02 15:14:45 +00:00
82ef562046 camera zoom handling across components; unify zoom level type and improve state management 2025-10-02 16:07:05 +01:00
68e944a6a2 Refactor sound context and update sound settings functionality; remove console logs and improve sound file handling 2025-10-01 15:21:07 +01:00
4eeb368484 Refactor SnapshotContainer to integrate camera zoom functionality and improve error handling 2025-10-01 11:30:06 +01:00
6f2bc96ac7 Add camera zoom functionality and refactor blackboard data fetching
- Introduced `useCameraZoom` hook for managing camera zoom operations.
- Refactored `useCameraBlackboard` to improve data fetching function naming.
- Updated `CameraSettingFields` to utilize new zoom functionality and handle zoom level changes.
- Removed unnecessary console logs from `Dashboard`.
2025-10-01 10:59:10 +01:00
1b7b2eec37 Implement sound settings update and integrate sound context in sightings widget 2025-09-30 15:32:00 +01:00
2aeae761f8 added sound context, and functionality to select sighting sound 2025-09-30 14:51:37 +01:00
673df1a4f4 added ui for sound settings 2025-09-30 13:25:11 +01:00
3903ff1cb8 Merged in bugfix/cleanup (pull request #3)
Refactor camera configuration and overview snapshot hooks; update environment variables and changed camera names to A and B
2025-09-30 10:14:26 +00:00
eb74c2c649 Refactor camera configuration and overview snapshot hooks; update environment variables and changed camera names to A and B 2025-09-30 11:11:46 +01:00
e11d914c5e Merged in bugfix/minor-issues (pull request #2)
Bugfix/minor issues
2025-09-30 08:12:48 +00:00
633435df8d removed console.logs 2025-09-30 09:10:10 +01:00
087b3613ae Minor fixes:
removed clock
added navigation arrow to main sighting screen
added zoom functionality to rear (camera B) settings
brought back navigation to rear cam page
2025-09-30 09:07:22 +01:00
369ff3e17e added loading state for camera form 2025-09-29 15:55:25 +01:00
ea6590b9f5 Merged in feature/zoomIn (pull request #1)
Feature/zoomIn
2025-09-29 14:27:05 +00:00
c5c8218e1a added zoom functionality, need to add endpoint to post 2025-09-29 15:21:22 +01:00
3b9469496b refactor: update camera settings route and improve error/loading messages in components by increasing swipe size 2025-09-29 13:00:56 +01:00
220ec2d376 updated cam base 2025-09-29 08:47:13 +01:00
d308dd5c0e refactor: update positioning of NumberPlate in SightingOverview and clean up unused variables in SightingWidget 2025-09-26 14:47:34 +01:00
c3d273f29d updated padding across cards and forms 2025-09-26 13:58:14 +01:00
6773b82349 redesigned camera settings page, left the minimum height at 100%, open to change 2025-09-26 13:38:47 +01:00
1edeba9b13 redesigned sighting list, removed info bar and made images same width 2025-09-26 11:53:55 +01:00
aee898abd5 started redesign on dashbaord. changed vite basename to /Mobile, currently folkstone address 2025-09-26 11:42:12 +01:00
80b407943f refactor: NPED Context, sound update and start session 2025-09-25 10:38:49 +01:00
efd037754e update camera base URL, enhance alert context, and improve history list functionality 2025-09-24 12:28:14 +01:00
fe28247b1c added delete functionality and updated button styles 2025-09-23 16:02:14 +01:00
c2074f86a2 added sounds, updated nped config and tweaks + code quality improvements 2025-09-23 13:03:54 +01:00
eab7e79d01 added sound effects, updated svgs and cleaned up bugs 2025-09-22 11:18:14 +01:00
eaac668ae9 removed redirect 2025-09-22 09:32:51 +01:00
69eb5cc7be added NPED cat sorting 2025-09-22 09:26:45 +01:00
50cedaf2c4 updated ui and added NPED back 2025-09-21 20:10:05 +01:00
8f6fba1e63 testing dynamic baseURL 2025-09-19 15:33:15 +01:00
a226c51231 updated addr2 2025-09-19 13:24:41 +01:00
93bc348406 updated addr 2025-09-19 13:22:44 +01:00
41da85620d updated loclahosr 2025-09-19 13:15:38 +01:00
8b49f0f1e1 removed grayscale 2025-09-19 11:38:26 +01:00
1599ad066f added Hotlist flag ad tag 2025-09-19 11:22:09 +01:00
047251756e updated preview camera view and removed NPED alert disptach 2025-09-19 10:55:36 +01:00
9a56392876 added alert popup on hotlist, upload hotlist and added hotlist tag 2025-09-19 10:09:14 +01:00
6773a92d14 updating basename path 2025-09-18 15:49:33 +01:00
24fa924a6e added redirect to home 2025-09-18 15:25:21 +01:00
f6bf21a911 tweaked sizing and added refresh btn 2025-09-18 15:14:47 +01:00
a33a889693 added fullscreen mode and removed swipedown 2025-09-18 10:37:23 +01:00
3811b1f366 increasing sizing 2025-09-17 14:39:56 +01:00
0b7ab3b0de code quality improvements and improved file error handling 2025-09-17 11:39:26 +01:00
b98e3ed85d fixed type errors 2025-09-16 14:20:38 +01:00
c506c395e6 got to a good point with sighting modal, want to do cleanup 2025-09-16 11:07:35 +01:00
c414342515 storing changes, starting history list 2025-09-15 10:27:31 +01:00
7588326cbe refactored system settings 2025-09-12 13:28:14 +01:00
d03f73f751 code fixes and adding modal 2025-09-12 08:21:52 +01:00
fae17b88a4 Merge branch 'Bradley' 2025-09-10 09:09:23 +01:00
db49221a2b apply stashed change before merge bradley 2025-09-10 09:05:47 +01:00
129 changed files with 5767 additions and 5977 deletions

3
.env
View File

@@ -1,3 +0,0 @@
VITE_BASEURL=http://192.168.75.11/
VITE_TESTURL=http://100.82.205.44/SightingListRear/sightingSummary?mostRecentRef=-1
VITE_OUTSIDE_BASEURL=http://100.82.205.44/api

1
.env.production Normal file
View File

@@ -0,0 +1 @@
VITE_CAM_BASE=

74
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,74 @@
# Copilot Instructions for in-car-system-fe
## Project Overview
- **Type:** React + TypeScript SPA using Vite
- **Purpose:** In-car system frontend for camera management, sighting history, and system settings
- **Key Directories:**
- `src/components/`: UI components grouped by feature (Camera, Sighting, Settings, etc.)
- `src/context/`: React context for global state (e.g., AlertHit, NPEDUser, SightingFeed)
- `src/hooks/`: Custom React hooks for data fetching, config, and UI logic
- `src/pages/`: Top-level route views (Dashboard, Camera, Session, SystemSettings)
- `src/types/`: Shared TypeScript types
- `src/utils/`: Utility functions and config helpers
## Architecture & Patterns
- **Component Structure:**
- Feature-based folders (e.g., `CameraSettings`, `SightingOverview`)
- Components are mostly functional, using hooks and context for state
- **State Management:**
- Uses React Context for cross-component state (see `src/context/providers/`)
- Reducers in `src/context/reducers/` for complex state updates
- **Data Flow:**
- Data is fetched and managed via custom hooks (see `src/hooks/`)
- Context providers wrap the app in `main.tsx`
- **Styling:**
- CSS modules (e.g., `App.css`, `index.css`)
- No CSS-in-JS or styled-components
## Developer Workflows
- **Install:** `npm install`
- **Start Dev Server:** `npm run dev`
- **Build:** `npm run build`
- **Preview Build:** `npm run preview`
- **Lint:** `npm run lint` (uses ESLint, see `eslint.config.js`)
- **Type Check:** `tsc --noEmit`
- **Test:** _No test framework configured by default_
## Project Conventions
- **TypeScript:**
- All components and hooks are typed; shared types in `src/types/types.ts`
- **Component Naming:**
- Use PascalCase for components and folders
- Suffix with `Container`, `Card`, or `Modal` for UI roles
- **Assets:**
- Images in `public/` or `src/assets/`
- Sounds in `src/assets/sounds/`
- **No Redux, MobX, or external state libraries**
- **No backend API code in this repo**
## Integration Points
- **External:**
- No direct backend integration code; data is assumed to come from context/hooks
- Sound assets for UI feedback in `src/assets/sounds/ui/`
## Examples
- **Add a new camera setting:**
- Create a new component in `src/components/CameraSettings/`
- Add state via context or a custom hook if needed
- **Add a new page:**
- Add a file to `src/pages/` and update routing in the main app (see `main.tsx`)
## References
- See `README.md` for Vite/ESLint setup details
- See `src/context/` and `src/hooks/` for app-specific state/data patterns
---
_If any conventions or workflows are unclear, please ask for clarification or examples from the codebase._

3
.prettierrc Normal file
View File

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

View File

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

View File

@@ -2,12 +2,12 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/svg+xml" href="/MAV-Blue.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MAV | In Car System</title>
<title>MAV Mobile</title>
</head>
<body>
<div id="root"></div>
<div id="root" class="min-h-screen flex flex-col"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4308
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,8 @@
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
"preview": "vite preview",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.0.0",
@@ -20,12 +21,16 @@
"clsx": "^2.1.1",
"country-flag-icons": "^1.5.19",
"formik": "^2.4.6",
"howler": "^2.2.4",
"rc-slider": "^11.1.9",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-modal": "^3.16.3",
"react-router": "^7.8.0",
"react-sounds": "^1.0.25",
"react-swipeable": "^7.0.2",
"react-tabs": "^6.1.0",
"react-use": "^17.6.0",
"sonner": "^2.0.7",
"tailwindcss": "^4.1.11"
},

18
public/Hotlist_Hit.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 18 KiB

18
public/MAV-Blue.svg Normal file
View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 231.27 52.63">
<defs>
<style>
.cls-1 {
fill: #20456f;
}
</style>
</defs>
<g id="Layer_2-2" data-name="Layer_2">
<g>
<g id="Layer_1-2">
<path class="cls-1" d="M150.57,0h-40.57c-7.53,0-13.64,6.11-13.64,13.64v38.99h13.64v-13.68h40.57v13.68h13.64V13.64c0-7.53-6.11-13.64-13.64-13.64ZM110,28.55v-12.59c0-1.72,1.39-3.11,3.11-3.11h34.34c1.72,0,3.11,1.39,3.11,3.11v12.59h-40.57,0ZM88.45,13.64v38.99h-13.64V15.96c0-1.72-1.39-3.11-3.11-3.11h-17.5c-1.72,0-3.11,1.39-3.11,3.11v36.67h-13.73V15.96c0-1.72-1.39-3.11-3.11-3.11h-17.49c-1.72,0-3.11,1.39-3.11,3.11v36.67H0V13.64C0,6.11,6.11,0,13.64,0h23.55c2.72,0,5.18,1.05,7.03,2.76,1.85-1.71,4.32-2.76,7.03-2.76h23.55c7.53,0,13.64,6.11,13.64,13.64h.01ZM193.88,52.63c-1.19,0-2.28-.68-2.8-1.75L166.25,0h13.16c1.19,0,2.28.68,2.8,1.75,0,0,12.25,25.11,16.55,33.92,4.3-8.81,16.55-33.92,16.55-33.92.53-1.07,1.61-1.75,2.8-1.75h13.16l-24.83,50.88c-.52,1.07-1.61,1.75-2.8,1.75h-9.78.02Z"/>
</g>
<path class="cls-1" d="M222.79,48.39c0-2.36,1.9-4.24,4.24-4.24s4.24,1.88,4.24,4.24-1.88,4.24-4.24,4.24-4.24-1.9-4.24-4.24ZM223.45,48.39c0,1.96,1.6,3.58,3.58,3.58s3.56-1.62,3.56-3.58-1.58-3.56-3.56-3.56-3.58,1.56-3.58,3.56ZM228.17,50.83l-1.26-1.92h-.8v1.92h-.72v-4.86h1.98c.9,0,1.62.58,1.62,1.48,0,1.08-.96,1.44-1.24,1.44l1.3,1.94h-.88ZM226.11,46.57v1.72h1.26c.5,0,.88-.34.88-.84,0-.54-.38-.88-.88-.88h-1.26Z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 204 KiB

497
public/NPED.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

28
public/NPED_Cat_A.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

29
public/NPED_Cat_B.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

27
public/NPED_Cat_C.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -1,25 +1,35 @@
import Container from "./components/UI/Container";
import Dashboard from "./pages/Dashboard";
import { Route, Routes } from "react-router";
import { Navigate, Route, Routes } from "react-router";
import FrontCamera from "./pages/FrontCamera";
import RearCamera from "./pages/RearCamera";
import SystemSettings from "./pages/SystemSettings";
import Session from "./pages/Session";
import { NPEDUserProvider } from "./context/providers/NPEDUserContextProvider";
import { IntegrationsProvider } from "./context/providers/IntegrationsContextProvider";
import { AlertHitProvider } from "./context/providers/AlertHitProvider";
import { SoundProvider } from "react-sounds";
import SoundContextProvider from "./context/providers/SoundContextProvider";
function App() {
return (
<NPEDUserProvider>
<Routes>
<Route path="/" element={<Container />}>
<Route index element={<Dashboard />} />
<Route path="front-camera-settings" element={<FrontCamera />} />
<Route path="rear-camera-settings" element={<RearCamera />} />
<Route path="system-settings" element={<SystemSettings />} />
<Route path="session-settings" element={<Session />} />
</Route>
</Routes>
</NPEDUserProvider>
<SoundContextProvider>
<SoundProvider initialEnabled={true}>
<IntegrationsProvider>
<AlertHitProvider>
<Routes>
<Route path="/" element={<Container />}>
<Route index element={<Dashboard />} />
<Route path="camera-settings" element={<FrontCamera />} />
<Route path="rear-camera-settings" element={<RearCamera />} />
<Route path="system-settings" element={<SystemSettings />} />
<Route path="session-settings" element={<Session />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
</AlertHitProvider>
</IntegrationsProvider>
</SoundProvider>
</SoundContextProvider>
);
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,21 +1,65 @@
import { useGetOverviewSnapshot } from "../../hooks/useGetOverviewSnapshot";
import type { ZoomInOptions } from "../../types/types";
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 = {
side: string;
settingsPage?: boolean;
zoomLevel?: number;
onZoomLevelChange?: (level: number) => void;
};
export const SnapshotContainer = ({
side,
settingsPage,
zoomLevel,
onZoomLevelChange,
}: SnapshotContainerProps) => {
const { canvasRef } = useGetOverviewSnapshot(side);
const { canvasRef, isError, isPending } = useGetOverviewSnapshot(side);
const cameraControllerSide =
side === "CameraA" ? "CameraControllerA" : "CameraControllerB";
const { mutation } = useCameraZoom({ camera: cameraControllerSide });
const handleZoomClick = () => {
const baseLevel = zoomLevel ?? 1;
const newLevel = baseLevel >= 8 ? 1 : baseLevel * 2;
if (onZoomLevelChange) onZoomLevelChange(newLevel);
if (!zoomLevel) return;
};
useEffect(() => {
if (zoomLevel) {
const zoomInOptions: ZoomInOptions = {
camera: cameraControllerSide,
multiplier: zoomLevel,
};
mutation.mutate(zoomInOptions);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [zoomLevel]);
return (
<div className="relative w-full aspect-video">
<div className="flex flex-col md:flex-row">
<NavigationArrow side={side} settingsPage={settingsPage} />
<canvas ref={canvasRef} className="w-full h-full object-contain block" />
<div className="w-full">
{isError && <ErrorState />}
{isPending && (
<div className="my-50 h-[50%]">
<Loading message="Camera Preview" />
</div>
)}
<canvas
onClick={handleZoomClick}
ref={canvasRef}
className="absolute inset-0 object-contain min-h-[100%] z-20"
/>
</div>
</div>
);
};

View File

@@ -1,48 +1,109 @@
import { Formik, Field, Form } from "formik";
import type {
CameraSettingErrorValues,
CameraSettingValues,
} from "../../types/types";
import { toast } from "sonner";
import type { CameraConfig, CameraSettingErrorValues, CameraSettingValues, ZoomInOptions } from "../../types/types";
import { useEffect, useMemo, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faEye, faEyeSlash } from "@fortawesome/free-regular-svg-icons";
import CardHeader from "../UI/CardHeader";
import { useCameraZoom } from "../../hooks/useCameraZoom";
import { parseRTSPUrl } from "../../utils/utils";
const CameraSettingFields = () => {
const initialValues: CameraSettingValues = {
friendlyName: "",
cameraAddress: "",
userName: "",
password: "",
setupCamera: 1,
type CameraSettingsProps = {
initialData: CameraConfig;
updateCameraConfig: (values: CameraSettingValues) => Promise<void> | void;
zoomLevel?: number;
onZoomLevelChange?: (level: number) => void;
updateCameraConfigError: null | Error;
};
const CameraSettingFields = ({
initialData,
updateCameraConfig,
zoomLevel,
onZoomLevelChange,
updateCameraConfigError,
}: CameraSettingsProps) => {
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>(
() => ({
friendlyName: initialData?.id ?? "",
cameraAddress: initialData?.propURI?.value ?? "",
userName: parsed?.username ?? "",
password: parsed?.password ?? "",
id: initialData?.id,
zoom: zoomLevel,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[initialData?.id, initialData?.propURI?.value, zoomLevel]
);
const validateValues = (values: CameraSettingValues) => {
const errors: CameraSettingErrorValues = {};
if (!values.friendlyName) errors.friendlyName = "Required";
if (!values.cameraAddress) errors.cameraAddress = "Required";
if (!values.userName) errors.userName = "Required";
if (!values.password) errors.password = "Required";
return errors;
};
const handleSubmit = (values: CameraSettingValues) => {
// post values to endpoint
toast("Settings Saved");
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 (
<Formik
initialValues={initialValues}
onSubmit={handleSubmit}
validate={validateValues}
validateOnChange={false}
enableReinitialize
>
{({ errors, touched, setFieldValue }) => (
<Form className="flex flex-col space-y-4 p-2">
{({ errors, touched }) => (
<Form className="flex flex-col space-y-6 p-2">
<div className="flex flex-col space-y-2 relative">
<label htmlFor="friendlyName">Friendly Name</label>
<label htmlFor="friendlyName">Name</label>
{touched.friendlyName && errors.friendlyName && (
<small className="absolute right-0 top-0 text-red-500">
{errors.friendlyName}
</small>
<small className="absolute right-0 top-0 text-red-500">{errors.friendlyName}</small>
)}
<Field
id="friendlyName"
@@ -50,33 +111,14 @@ const CameraSettingFields = () => {
type="text"
className="p-2 border border-gray-400 rounded-lg"
placeholder="Enter camera name"
disabled
/>
</div>
<div className="flex flex-col space-y-2 relative">
<label htmlFor="setupCamera">Setup Camera</label>
<Field
as="select"
id="setupCamera"
name="setupCamera"
className="p-2 border border-gray-400 rounded-lg text-white bg-[#253445]"
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
setFieldValue("setupCamera", parseInt(e.target.value, 10))
}
>
<option value={1}>1</option>
<option value={2}>2</option>
<option value={3}>3</option>
<option value={4}>4</option>
</Field>
</div>
<div className="flex flex-col space-y-2 relative">
<label htmlFor="cameraAddress">Camera Address</label>
{touched.cameraAddress && errors.cameraAddress && (
<small className="absolute right-0 top-0 text-red-500">
{errors.cameraAddress}
</small>
<small className="absolute right-0 top-0 text-red-500">{errors.cameraAddress}</small>
)}
<Field
id="cameraAddress"
@@ -84,16 +126,14 @@ const CameraSettingFields = () => {
type="text"
className="p-2 border border-gray-400 rounded-lg"
placeholder="RTSP://..."
autoComplete="street-address"
disabled
/>
</div>
<div className="flex flex-col space-y-2 relative">
<label htmlFor="userName">User Name</label>
{touched.userName && errors.userName && (
<small className="absolute right-0 top-0 text-red-500">
{errors.userName}
</small>
<small className="absolute right-0 top-0 text-red-500">{errors.userName}</small>
)}
<Field
id="userName"
@@ -102,32 +142,74 @@ const CameraSettingFields = () => {
className="p-2 border border-gray-400 rounded-lg"
placeholder="Enter user name"
autoComplete="username"
disabled
/>
</div>
<div className="flex flex-col space-y-2 relative">
<label htmlFor="password">Password</label>
{touched.password && errors.password && (
<small className="absolute right-0 top-0 text-red-500">
{errors.password}
</small>
<small className="absolute right-0 top-0 text-red-500">{errors.password}</small>
)}
<Field
id="password"
name="password"
type="password"
className="p-2 border border-gray-400 rounded-lg"
placeholder="Enter password"
autoComplete="new-password"
/>
<div className="flex gap-2 items-center relative mb-4">
<Field
id="password"
name="password"
type={showPwd ? "text" : "password"}
className="p-2 border border-gray-400 rounded-lg w-full "
placeholder="Enter password"
disabled
/>
<FontAwesomeIcon
type="button"
className="absolute right-5 end-0"
onClick={() => setShowPwd((s) => !s)}
icon={showPwd ? faEyeSlash : faEye}
/>
</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" disabled>
Retry
</button>
) : (
<button
type="submit"
className="bg-blue-700 text-white rounded-lg p-2 mx-auto h-[100%] w-full"
disabled
>
{/* {isSubmitting ? "Saving" : "Save settings"} bg-[#26B170] */}
{"Disabled: Coming soon"}
</button>
)}
</div>
</div>
<button
type="submit"
className="bg-blue-800 text-white rounded-lg p-2 mx-auto"
>
Save settings
</button>
</Form>
)}
</Formik>

View File

@@ -1,14 +1,36 @@
import { useFetchCameraConfig } from "../../hooks/useCameraConfig";
import Card from "../UI/Card";
import CardHeader from "../UI/CardHeader";
import CameraSettingFields from "./CameraSettingFields";
import { faWrench } from "@fortawesome/free-solid-svg-icons";
const CameraSettings = ({ title }: { title: string }) => {
const CameraSettings = ({
title,
side,
zoomLevel,
onZoomLevelChange,
}: {
title: string;
side: string;
zoomLevel?: number;
onZoomLevelChange?: (level: number) => void;
}) => {
const { data, updateCameraConfig, updateCameraConfigError } = useFetchCameraConfig(side);
return (
<Card>
<div className="relative flex flex-col space-y-3 h-full">
<Card className="overflow-hidden min-h-[40vh] md:min-h-[60vh] max-h-[80vh] lg:w-[40%] p-4">
<div className="relative flex flex-col space-y-3">
<CardHeader title={title} icon={faWrench} />
<CameraSettingFields />
{
<CameraSettingFields
initialData={data}
updateCameraConfig={updateCameraConfig}
zoomLevel={zoomLevel}
onZoomLevelChange={onZoomLevelChange}
updateCameraConfigError={updateCameraConfigError}
/>
}
</div>
</Card>
);

View File

@@ -1,34 +1,22 @@
import clsx from "clsx";
import Card from "../UI/Card";
import CardHeader from "../UI/CardHeader";
import { faCamera } from "@fortawesome/free-regular-svg-icons";
import { useSwipeable } from "react-swipeable";
import { useNavigate } from "react-router";
import { useOverviewVideo } from "../../hooks/useOverviewVideo";
import SightingOverview from "../SightingOverview/SightingOverview";
type CardProps = React.HTMLAttributes<HTMLDivElement>;
const FrontCameraOverviewCard = ({ className }: CardProps) => {
useOverviewVideo();
const FrontCameraOverviewCard = () => {
const navigate = useNavigate();
const handlers = useSwipeable({
onSwipedRight: () => navigate("/front-camera-settings"),
onSwipedDown: () => navigate("/system-settings"),
onSwipedRight: () => navigate("/camera-settings"),
onSwipedLeft: () => navigate("/rear-camera-settings"),
trackMouse: true,
});
return (
<Card
className={clsx(
"relative min-h-[40vh] md:min-h-[60vh] h-auto",
className
)}
>
<div className="flex flex-col space-y-3 h-full" {...handlers}>
<CardHeader title="Front Overview" icon={faCamera} />
<Card className={clsx("relative min-h-[40vh] md:min-h-[60vh] max-h-[80vh] lg:w-[70%] overflow-y-hidden")}>
<div className="w-full" {...handlers}>
<SightingOverview />
{/* <SnapshotContainer side="TargetDetectionFront" /> */}
</div>
</Card>
);

View File

@@ -1,23 +1,43 @@
import clsx from "clsx";
import { SnapshotContainer } from "../CameraOverview/SnapshotContainer";
import { faCamera } from "@fortawesome/free-regular-svg-icons";
import Card from "../UI/Card";
import CardHeader from "../UI/CardHeader";
import { useNavigate, useLocation } from "react-router";
import { useSwipeable } from "react-swipeable";
const OverviewVideoContainer = ({
title,
side,
settingsPage,
zoomLevel,
onZoomLevelChange,
}: {
title: string;
side: string;
settingsPage?: boolean;
zoomLevel?: number;
onZoomLevelChange?: (level: number) => void;
}) => {
const navigate = useNavigate();
const location = useLocation();
const handlers = useSwipeable({
onSwipedLeft: () => {
if (location.pathname === "/rear-camera-settings") return;
navigate("/");
},
onSwipedRight: () => {
if (location.pathname === "/camera-settings") return;
navigate("/");
},
trackMouse: true,
});
return (
<Card className={clsx("min-h-[40vh] md:min-h-[60vh] h-auto")}>
<div className="relative flex flex-col space-y-3 h-full">
<CardHeader title={title} icon={faCamera} />
<SnapshotContainer side={side} settingsPage={settingsPage} />
<Card className={clsx("relative min-h-[40vh] md:min-h-[60vh] max-h-[80vh] lg:w-[70%] overflow-y-hidden")}>
<div className="w-full" {...handlers}>
<SnapshotContainer
side={side}
settingsPage={settingsPage}
zoomLevel={zoomLevel}
onZoomLevelChange={onZoomLevelChange}
/>
</div>
</Card>
);

View File

@@ -0,0 +1,90 @@
import type { SightingType } from "../../types/types";
import NumberPlate from "../PlateStack/NumberPlate";
import SightingModal from "../SightingModal/SightingModal";
import { useState } from "react";
import HotListImg from "/Hotlist_Hit.svg";
import { useAlertHitContext } from "../../context/AlertHitContext";
import { useCameraBlackboard } from "../../hooks/useCameraBlackboard";
import NPED_CAT_A from "/NPED_Cat_A.svg";
import NPED_CAT_B from "/NPED_Cat_B.svg";
import NPED_CAT_C from "/NPED_Cat_C.svg";
import { checkIsHotListHit, formatAge, getNPEDCategory } from "../../utils/utils";
import { faX } from "@fortawesome/free-solid-svg-icons";
import { faClock } from "@fortawesome/free-regular-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import Badge from "../UI/Badge";
type AlertItemProps = {
item: SightingType;
};
const AlertItem = ({ item }: AlertItemProps) => {
const [isModalOpen, setIsModalOpen] = useState(false);
const { dispatch } = useAlertHitContext();
const { mutation } = useCameraBlackboard();
const motionAway = (item?.motion ?? "").toUpperCase() === "AWAY";
const isHotListHit = checkIsHotListHit(item);
const cat = getNPEDCategory(item);
const isNPEDHitA = cat === "A";
const isNPEDHitB = cat === "B";
const isNPEDHitC = cat === "C";
const handleClick = () => {
setIsModalOpen(true);
};
const closeModal = () => {
setIsModalOpen(false);
};
const handleDelete = async (deletedItem: SightingType | null) => {
const res = await mutation.mutateAsync({
operation: "VIEW",
path: "alertHistory",
});
const oldArray = res?.result;
const updatedArray = oldArray?.filter((item: SightingType) => item?.ref !== deletedItem?.ref);
mutation.mutate({
operation: "INSERT",
path: "alertHistory",
value: updatedArray,
});
dispatch({ type: "REMOVE", payload: item });
};
return (
<div className="flex flex-col w-full relative">
<div className="border border-gray-600 rounded-lg items-center p-4">
<div className="flex flex-row space-x-3 ml-4">
<Badge text={`Seen: ${formatAge(item.timeStampMillis)}`} icon={faClock} />
</div>
<button onClick={() => handleDelete(item)} className="absolute right-2 top-3">
<FontAwesomeIcon icon={faX} size="xl" />
</button>
<div className="flex flex-row p-4 w-full mx-auto justify-between" onClick={handleClick}>
{isHotListHit && <img src={HotListImg} alt="hotlistHit" className="h-20 object-contain rounded-md" />}
{isNPEDHitA && <img src={NPED_CAT_A} alt="NPEDHITicon" className="h-20 object-contain rounded-md" />}
{isNPEDHitB && <img src={NPED_CAT_B} alt="NPEDHITicon" className="h-20 object-contain rounded-md" />}
{isNPEDHitC && <img src={NPED_CAT_C} alt="NPEDHITicon" className="h-20 object-contain rounded-md" />}
<div className={`border p-1 hidden md:block`}>
<img src={item?.plateUrlColour} height={48} width={200} alt="colour patch" />
</div>
<div className="h-20">
<NumberPlate vrm={item.vrm} motion={motionAway} />
</div>
</div>
<SightingModal
isSightingModalOpen={isModalOpen}
handleClose={closeModal}
sighting={item}
onDelete={handleDelete}
/>
</div>
</div>
);
};
export default AlertItem;

View File

@@ -0,0 +1,56 @@
import { useAlertHitContext } from "../../context/AlertHitContext";
import { useCameraBlackboard } from "../../hooks/useCameraBlackboard";
import type { CameraBlackBoardOptions } from "../../types/types";
import Card from "../UI/Card";
import CardHeader from "../UI/CardHeader";
import AlertItem from "./AlertItem";
const HistoryList = () => {
const { state, dispatch, isLoading, error } = useAlertHitContext();
const { mutation } = useCameraBlackboard();
const handleClearListClick = (listName: CameraBlackBoardOptions) => {
dispatch({ type: "DELETE", payload: [] });
mutation.mutate({
operation: "DELETE",
path: listName.path,
});
};
return (
<Card className="h-100 p-4 col-span-3">
<CardHeader title="Alert History" />
<button
className="bg-red-600 text-white px-4 py-2 rounded hover:bg-red-700 transition md:w-[10%] mb-2"
onClick={() => handleClearListClick({ path: "alertHistory" })}
>
Clear List
</button>
{isLoading && <p className="px-2">Loading...</p>}
{error && <p className="text-red-500 px-2">Error: {error.message}</p>}
<div className="flex flex-col gap-1 px-2">
{state?.alertList?.length > 0 ? (
<div className="mt-3 grid grid-cols-1 gap-3">
{state?.alertList?.map((alertItem) => (
<AlertItem item={alertItem} key={alertItem.vrm} />
))}
</div>
) : (
<div className="mt-4 flex flex-col items-center justify-center rounded-2xl border border-slate-800 bg-slate-900/40 p-10 text-center">
<div className="mb-3 rounded-xl bg-slate-800 px-3 py-1 text-xs uppercase tracking-wider text-slate-400">
No Alert Results
</div>
<p className="max-w-md text-slate-300">
Alerts will appear here in real-time once there are <span className="text-emerald-400">Hotlist</span> or{" "}
<span className="text-amber-600">NPED</span> hits. Use{" "}
<span className="text-emerald-400">Start Session</span> to begin capturing results, or add a{" "}
<span className="text-emerald-400">Sighting</span> from the sighting list.
</p>
</div>
)}
</div>
</Card>
);
};
export default HistoryList;

View File

@@ -4,21 +4,53 @@ import { formatNumberPlate } from "../../utils/utils";
type NumberPlateProps = {
vrm?: string | undefined;
motion?: boolean;
size?: "xs" | "sm" | "md" | "lg";
};
const NumberPlate = ({ motion, vrm }: NumberPlateProps) => {
const NumberPlate = ({ motion, vrm, size }: NumberPlateProps) => {
let options = {
plateWidth: "w-[14rem]",
textSize: "text-2xl",
borderWidth: "border-6",
};
switch (size) {
case "xs":
options = {
plateWidth: "w-[8rem]",
textSize: "text-md",
borderWidth: "border-4",
};
break;
case "sm":
options = {
plateWidth: "w-[10rem]",
textSize: "text-lg",
borderWidth: "border-4",
};
break;
case "lg":
options = {
plateWidth: "w-[16rem]",
textSize: "text-3xl",
borderWidth: "border-6",
};
break;
}
return (
<div
className={`relative w-[8rem] border-4 border-black rounded-lg text-nowrap
text-black px-3
${motion ? "bg-yellow-400" : "bg-white"}
`}
className={`relative ${options.plateWidth} ${
options.borderWidth
} border-black rounded-xl text-nowrap
text-black px-6 py-2
${motion ? "bg-yellow-400" : "bg-white"}`}
>
<div className="">
<div className="absolute inset-y-0 left-0 bg-blue-600 w-4 flex flex-col">
<div>
<div className="absolute inset-y-0 left-0 bg-blue-600 w-8 flex flex-col">
<GB />
</div>
<p className=" pl-2 font-extrabold text-right">
<p className={`pl-4 font-extrabold ${options.textSize} text-right`}>
{vrm && formatNumberPlate(vrm)}
</p>
</div>

View File

@@ -1,19 +0,0 @@
import NumberPlate from "./NumberPlate";
import type { SightingType } from "../../types/types";
type SightingProps = {
sighting: SightingType;
};
const Sighting = ({ sighting }: SightingProps) => {
return (
<div className="bg-gray-700 flex flex-col md:flex-row m-1 items-center justify-between w-full rounded-md p-4 space-y-4">
<div className="flex flex-row m-1 items-center space-x-4">
<NumberPlate />
</div>
</div>
);
};
export default Sighting;

View File

@@ -1,21 +0,0 @@
import Card from "../UI/Card";
import SightingHeader from "./SightingHeader";
import Sighting from "./Sighting";
import { useLatestSighting } from "../../hooks/useLatestSighting";
type SightingProps = {
title: string;
};
const Sightings = ({ title }: SightingProps) => {
const { data } = useLatestSighting();
return (
<Card className="h-[10rem] md:h-[15rem] overflow-x-hidden">
<SightingHeader title={title} />
<Sighting sighting={data} />
</Card>
);
};
export default Sightings;

View File

@@ -6,6 +6,7 @@ import { useNavigate } from "react-router";
import CardHeader from "../UI/CardHeader";
import { faCamera } from "@fortawesome/free-regular-svg-icons";
import SightingOverview from "../SightingOverview/SightingOverview";
import { useSightingFeedContext } from "../../context/SightingFeedContext";
type CardProps = React.HTMLAttributes<HTMLDivElement>;
@@ -13,21 +14,14 @@ const RearCameraOverviewCard = ({ className }: CardProps) => {
const navigate = useNavigate();
const handlers = useSwipeable({
onSwipedLeft: () => navigate("/rear-camera-settings"),
onSwipedDown: () => navigate("/system-settings"),
trackMouse: true,
});
const { mostRecent } = useSightingFeedContext();
return (
<Card
className={clsx(
"relative min-h-[40vh] md:min-h-[60vh] h-auto",
className
)}
>
<Card className={clsx("relative min-h-[40vh] md:min-h-[60vh] h-auto", className)}>
<div className="flex flex-col space-y-3 h-full" {...handlers}>
<CardHeader title="Rear Overview" icon={faCamera} />
<CardHeader title="Rear Overview" icon={faCamera} sighting={mostRecent} />
<SightingOverview />
{/* <SnapshotContainer side="TargetDetectionRear" /> */}
</div>
</Card>
);

View File

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

View File

@@ -1,24 +1,148 @@
import Card from "../UI/Card";
import CardHeader from "../UI/CardHeader";
import { useIntegrationsContext } from "../../context/IntegrationsContext";
import type { ReducedSightingType } from "../../types/types";
import { toast } from "sonner";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faFloppyDisk, faPause, faPlay, faStop } from "@fortawesome/free-solid-svg-icons";
import VehicleSessionItem from "../UI/VehicleSessionItem";
import { useCameraBlackboard } from "../../hooks/useCameraBlackboard";
const SessionCard = () => {
const { state, dispatch } = useIntegrationsContext();
const { mutation } = useCameraBlackboard();
const sessionStarted = state.sessionStarted;
const sessionPaused = state.sessionPaused;
const sessionList = state.sessionList;
const sightings = [...new Map(sessionList?.map((vehicle) => [vehicle.vrm, vehicle]))];
const dedupedSightings = sightings.map((sighting) => sighting[1]);
const vehicles = dedupedSightings.reduce<Record<string, ReducedSightingType[]>>(
(acc, item) => {
const hotlisthit = Object.values(item.metadata?.hotlistMatches ?? {}).includes(true);
if (item.metadata?.npedJSON["NPED CATEGORY"] === "A") acc.npedCatA.push(item);
if (item.metadata?.npedJSON["NPED CATEGORY"] === "B") acc.npedCatB.push(item);
if (item.metadata?.npedJSON["NPED CATEGORY"] === "C") acc.npedCatC.push(item);
if (item.metadata?.npedJSON["NPED CATEGORY"] === "D") acc.npedCatD.push(item);
if (item.metadata?.npedJSON["TAX STATUS"] === false) acc.notTaxed.push(item);
if (item.metadata?.npedJSON["MOT STATUS"] === false) acc.notMOT.push(item);
if (hotlisthit) acc.hotlistHit.push(item);
acc.vehicles.push(item);
return acc;
},
{
npedCatA: [],
npedCatB: [],
npedCatC: [],
npedCatD: [],
notTaxed: [],
notMOT: [],
hotlistHit: [],
vehicles: [],
}
);
const handleStartClick = () => {
dispatch({ type: "SESSIONSTART", payload: !sessionStarted });
dispatch({ type: "SESSIONPAUSE", payload: false });
toast(`${sessionStarted ? "Vehicle tracking session ended" : "Vehicle tracking session started"}`);
};
const handlepauseClick = () => {
dispatch({ type: "SESSIONPAUSE", payload: !sessionPaused });
toast(`${sessionStarted ? "Vehicle tracking session paused" : "Vehicle tracking session resumed"}`);
};
const handleSaveCick = async () => {
const result = await mutation.mutateAsync({
operation: "INSERT",
path: "sessionStats",
value: dedupedSightings,
});
if (result.reason === "OK") toast.success("Session saved");
};
return (
<Card>
<CardHeader title={"Session"} />
<div className="flex flex-col gap-4">
<Card className="p-4 col-span-3">
<CardHeader title="Session" />
<div className="flex flex-col gap-4 px-3">
<button
className="bg-[#26B170] text-white px-4 py-2 rounded hover:bg-green-700 transition w-full max-w-md"
//onClick={() => handleModemSave(apn, username, password, authType)}
className={`${sessionStarted ? "bg-red-600" : "bg-[#26B170]"} text-white px-4 py-2 rounded ${
sessionStarted ? "hover:bg-red-700" : "hover:bg-green-700"
} transition w-full`}
onClick={handleStartClick}
>
Start Session
<div className="flex flex-row gap-3 items-center justify-self-center">
<FontAwesomeIcon icon={sessionStarted ? faStop : faPlay} />
<p>{sessionStarted ? "End Session" : "Start Session"}</p>
</div>
</button>
<h2 className="text-white mb-2">Number of cars: </h2>
<h2 className="text-white mb-2">Cars without Tax: </h2>
<h2 className="text-white mb-2">Cars without MOT: </h2>
<h2 className="text-white mb-2">Cars with NPED Cat A: </h2>
<h2 className="text-white mb-2">Cars with NPED Cat B: </h2>
<h2 className="text-white mb-2">Cars with NPED Cat C: </h2>
<div className="flex flex-col lg:flex-row gap-5">
{sessionStarted && (
<button
className={`bg-blue-600 text-white px-4 py-2 rounded transition w-full lg:w-[50%]`}
onClick={handleSaveCick}
>
<div className="flex flex-row gap-3 items-center justify-self-center">
<FontAwesomeIcon icon={faFloppyDisk} />
<p>Save session</p>
</div>
</button>
)}
{sessionStarted && (
<button
className={`bg-gray-300 text-gray-800 px-4 py-2 rounded transition w-full lg:w-[50%]`}
onClick={handlepauseClick}
>
<div className="flex flex-row gap-3 items-center justify-self-center">
<FontAwesomeIcon icon={sessionPaused ? faPlay : faPause} />
<p>{sessionPaused ? "Resume session" : "Pause session"}</p>
</div>
</button>
)}
</div>
<ul className="text-white space-y-2">
<VehicleSessionItem
sessionNumber={vehicles.vehicles.length}
textColour="text-green-400"
vehicleTag={"Number of Vehicles sightings:"}
/>
<VehicleSessionItem
sessionNumber={vehicles.notTaxed.length}
textColour="text-amber-400"
vehicleTag={"Vehicles without Tax:"}
/>
<VehicleSessionItem
sessionNumber={vehicles.notMOT.length}
textColour="text-red-500"
vehicleTag={"Vehicles without MOT:"}
/>
<VehicleSessionItem
sessionNumber={vehicles.hotlistHit.length}
textColour="text-blue-400"
vehicleTag={"Vehicles on Hotlists:"}
/>
<VehicleSessionItem
sessionNumber={vehicles.npedCatA.length}
textColour="text-gray-300"
vehicleTag={"Vehicles with NPED Cat A:"}
/>
<VehicleSessionItem
sessionNumber={vehicles.npedCatB.length}
textColour="text-gray-300"
vehicleTag={"Vehicles with NPED Cat B:"}
/>
<VehicleSessionItem
sessionNumber={vehicles.npedCatC.length}
textColour="text-gray-300"
vehicleTag={"Vehicles with NPED Cat C:"}
/>
</ul>
</div>
</Card>
);

View File

@@ -4,7 +4,7 @@ import BearerTypeFields from "./BearerTypeFields";
const BearerTypeCard = () => {
return (
<Card>
<Card className="p-4">
<CardHeader title="Bearer Type" />
<BearerTypeFields />
</Card>

View File

@@ -1,33 +1,69 @@
import { Field, useFormikContext } from "formik";
import { Field, Form, Formik } from "formik";
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 = () => {
return null;
};
const BearerTypeFields = () => {
const { values } = 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 (
<div className="flex flex-col space-y-4">
<div className="flex items-center gap-3">
<label htmlFor="format">Format</label>
<Field
as="select"
name="format"
id="format"
className="p-2 border border-gray-400 rounded-lg text-white bg-[#253445]"
>
<option value="JSON">JSON</option>
<option value="BOF2">BOF2</option>
</Field>
</div>
<div className="flex flex-col space-y-4">
<FormToggle name="enabled" label="Enabled" />
<FormToggle name="verbose" label="Verbose" />
</div>
</div>
<Formik initialValues={initialValues} onSubmit={handleSubmit} enableReinitialize>
{({ isSubmitting }) => (
<Form>
<div className="flex flex-col space-y-4 px-2">
<FormGroup>
<label htmlFor="format">Format</label>
<Field
as="select"
name="format"
id="format"
className="p-2 border border-gray-400 rounded-lg text-white bg-[#253445] w-full md:w-60"
>
{options?.map((option: string) => (
<option key={option} value={option}>
{option}
</option>
))}
</Field>
</FormGroup>
<FormGroup>
<div className="flex flex-col space-y-4">
<FormToggle name="enabled" label="Enabled" />
<FormToggle name="verbose" label="Verbose" />
</div>
</FormGroup>
<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"
>
{isSubmitting || dispatcherMutation.isPending ? "Saving..." : "Save Changes"}
</button>
</div>
</Form>
)}
</Formik>
);
};

View File

@@ -4,7 +4,7 @@ import ChannelFields from "./ChannelFields";
const ChannelCard = () => {
return (
<Card>
<Card className="p-4">
<CardHeader title="Channel 1 (JSON)" />
<ChannelFields />
</Card>

View File

@@ -1,63 +1,162 @@
import { Field, useFormikContext } from "formik";
import { Field, Form, Formik, useFormikContext } from "formik";
import FormGroup from "../components/FormGroup";
import { useEffect, useState } from "react";
import { faEyeSlash, faEye } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useCameraOutput } from "../../../hooks/useCameraOutput";
import type { InitialValuesForm, InitialValuesFormErrors } from "../../../types/types";
import { toast } from "sonner";
const ChannelFields = () => {
useFormikContext();
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 (
<div className="flex flex-col space-y-2">
<FormGroup>
<label htmlFor="backoffice" className="m-0">
Back Office URL
</label>
<Field
name={"backOfficeURL"}
type="text"
id="backoffice"
placeholder="https://www.backoffice.com"
className="p-1.5 border border-gray-400 rounded-lg"
/>
</FormGroup>
<FormGroup>
<label htmlFor="username">Username</label>
<Field
name={"username"}
type="text"
id="username"
placeholder="Back office username"
className="p-1.5 border border-gray-400 rounded-lg"
/>
</FormGroup>
<FormGroup>
<label htmlFor="password">Password</label>
<Field
name={"password"}
type="password"
id="password"
placeholder="Back office password"
className="p-1.5 border border-gray-400 rounded-lg"
/>
</FormGroup>
<FormGroup>
<label htmlFor="connectTimeoutSeconds">Connect Timeout Seconds</label>
<Field
name={"connectTimeoutSeconds"}
type="number"
id="connectTimeoutSeconds"
className="p-1.5 border border-gray-400 rounded-lg"
/>
</FormGroup>
<FormGroup>
<label htmlFor="readTimeoutSeconds">Read Timeout Seconds</label>
<Field
name={"readTimeoutSeconds"}
type="number"
id="readTimeoutSeconds"
placeholder="https://example.com"
className="p-1.5 border border-gray-400 rounded-lg"
/>
</FormGroup>
</div>
<Formik initialValues={initialValues} onSubmit={handleSubmit} enableReinitialize validate={validateValues}>
{({ errors, touched, isSubmitting }) => (
<Form>
<div className="flex flex-col space-y-2 px-2">
<FormGroup>
<label htmlFor="backoffice" className="m-0">
Back Office URL
</label>
<Field
name={"backOfficeURL"}
type="text"
id="backoffice"
placeholder="https://www.backoffice.com"
className={`p-1.5 border ${
errors.backOfficeURL && touched.backOfficeURL ? "border-red-500" : "border-gray-400 "
} rounded-lg w-full md:w-60`}
/>
</FormGroup>
<FormGroup>
<label htmlFor="username">Username</label>
<Field
name={"username"}
type="text"
id="username"
placeholder="Back office username"
className={`p-1.5 border ${
errors.username && touched.username ? "border-red-500" : "border-gray-400 "
} rounded-lg w-full md:w-60`}
/>
</FormGroup>
<FormGroup>
<label htmlFor="password">Password</label>
<div className="flex gap-2 items-center relative mb-4">
<Field
name={"password"}
type={showPwd ? "text" : "password"}
id="password"
placeholder="Back office password"
className={`p-1.5 border ${
errors.password && touched.password ? "border-red-500" : "border-gray-400 "
} rounded-lg w-full md:w-60`}
/>
<FontAwesomeIcon
type="button"
className="absolute right-5 end-0"
onClick={() => setShowPwd((s) => !s)}
icon={showPwd ? faEyeSlash : faEye}
/>
</div>
</FormGroup>
<FormGroup>
<label htmlFor="connectTimeoutSeconds">Connect Timeout Seconds</label>
<Field
name={"connectTimeoutSeconds"}
type="number"
id="connectTimeoutSeconds"
className={`p-1.5 border ${
errors.connectTimeoutSeconds && touched.connectTimeoutSeconds ? "border-red-500" : "border-gray-400 "
} rounded-lg w-full md:w-60`}
/>
</FormGroup>
<FormGroup>
<label htmlFor="readTimeoutSeconds">Read Timeout Seconds</label>
<Field
name={"readTimeoutSeconds"}
type="number"
id="readTimeoutSeconds"
placeholder="https://example.com"
className={`p-1.5 border ${
errors.readTimeoutSeconds && touched.readTimeoutSeconds ? "border-red-500" : "border-gray-400 "
} rounded-lg w-full md:w-60`}
/>
</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"
>
{isSubmitting || backOfficeMutation.isPending ? "Saving..." : "Save Changes"}
</button>
<ValidationToastOnce />
</Form>
)}
</Formik>
);
};

View File

@@ -1,11 +1,12 @@
import Card from "../../UI/Card";
import CardHeader from "../../UI/CardHeader";
import NPEDFields from "./NPEDFields";
import NPEDIcon from "/NPED.svg";
const NPEDCard = () => {
return (
<Card>
<CardHeader title={"NPED Config"} img={"/NPED.jpg"} />
<Card className="p-4">
<CardHeader title={"NPED Config"} img={NPEDIcon} />
<NPEDFields />
</Card>
);

View File

@@ -3,15 +3,21 @@ import FormGroup from "../components/FormGroup";
import type { NPEDErrorValues, NPEDFieldType } from "../../../types/types";
import { useNPEDAuth } from "../../../hooks/useNPEDAuth";
import { toast } from "sonner";
import { faEyeSlash, faEye } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useState } from "react";
import { useIntegrationsContext } from "../../../context/IntegrationsContext";
const NPEDFields = () => {
const { signIn, user, signOut } = useNPEDAuth();
const { state } = useIntegrationsContext();
const [showPwd, setShowPwd] = useState(false);
const { signIn, signOut } = useNPEDAuth();
const initialValues = user
const initialValues = state.npedUser
? {
username: user.propUsername.value,
password: "",
clientId: user.propClientID.value,
username: state.npedUser?.propUsername?.value,
password: state.npedUser?.propPassword?.value,
clientId: state.npedUser?.propClientID?.value,
frontId: "NPED",
rearId: "NPED",
}
@@ -23,12 +29,11 @@ const NPEDFields = () => {
rearId: "NPED",
};
const handleSubmit = (values: NPEDFieldType) => {
const handleSubmit = async (values: NPEDFieldType) => {
const valuesToSend = {
...values,
};
signIn(valuesToSend);
toast.success("Signed in successfully");
await signIn(valuesToSend);
};
const validateValues = (values: NPEDFieldType) => {
@@ -41,22 +46,17 @@ const NPEDFields = () => {
const handleLogoutClick = () => {
signOut();
toast.warning("logged out of NPED");
};
return (
<Formik
initialValues={initialValues}
onSubmit={handleSubmit}
validate={validateValues}
>
{({ errors, touched }) => (
<Form className="flex flex-col space-y-5">
<Formik initialValues={initialValues} onSubmit={handleSubmit} validate={validateValues} enableReinitialize>
{({ errors, touched, isSubmitting }) => (
<Form className="flex flex-col space-y-5 px-2">
<FormGroup>
<label htmlFor="username">Username</label>
{touched.username && errors.username && (
<small className="absolute right-0 -top-5 text-red-500">
{errors.username}
</small>
<small className="absolute right-0 -top-5 text-red-500">{errors.username}</small>
)}
<Field
name="username"
@@ -68,25 +68,29 @@ const NPEDFields = () => {
</FormGroup>
<FormGroup>
<label htmlFor="password">Password</label>
{touched.password && errors.password && (
<small className="absolute right-0 -top-5 text-red-500">
{errors.password}
</small>
)}
<Field
name="password"
type="password"
id="password"
placeholder="NPED Password"
className="p-1.5 border border-gray-400 rounded-lg"
/>
<div className="flex gap-2 items-center relative mb-4">
<Field
name="password"
type={showPwd ? "text" : "password"}
id="password"
placeholder="NPED Password"
className="p-2 border border-gray-400 rounded-lg w-full"
/>
{touched.password && errors.password && (
<small className="absolute right-0 -top-5 text-red-500">{errors.password}</small>
)}
<FontAwesomeIcon
type="button"
className="absolute right-5 end-0"
onClick={() => setShowPwd((s) => !s)}
icon={showPwd ? faEyeSlash : faEye}
/>
</div>
</FormGroup>
<FormGroup>
<label htmlFor="clientId">Client ID</label>
{touched.clientId && errors.clientId && (
<small className="absolute right-0 -top-5 text-red-500">
{errors.clientId}
</small>
<small className="absolute right-0 -top-5 text-red-500">{errors.clientId}</small>
)}
<Field
name="clientId"
@@ -96,12 +100,12 @@ const NPEDFields = () => {
className="p-1.5 border border-gray-400 rounded-lg"
/>
</FormGroup>
{!user?.propClientID?.value ? (
{!state.npedUser?.propClientID?.value ? (
<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 hover:cursor-pointer"
>
Login
{isSubmitting ? "Logging in..." : "Login"}
</button>
) : (
<button

View File

@@ -1,44 +1,58 @@
import { Form, Formik } from "formik";
import type { HotlistUploadType } from "../../../types/types";
import { useSystemConfig } from "../../../hooks/useSystemConfig";
import { CAM_BASE } from "../../../utils/config";
const NPEDHotlist = () => {
const { uploadSettings } = useSystemConfig();
const initialValue = {
file: null,
};
const handleSubmit = (values: HotlistUploadType) => console.log(values.file);
const handleSubmit = (values: HotlistUploadType) => {
const settings = {
file: values.file,
opts: {
timeoutMs: 30000,
fieldName: "upload",
uploadUrl: `${CAM_BASE}/upload/hotlist-upload/2`,
},
};
uploadSettings(settings);
};
return (
<Formik initialValues={initialValue} onSubmit={handleSubmit}>
{({ setFieldValue, setErrors, errors }) => {
return (
<Form className="flex flex-col space-y-2">
<Form className="flex flex-col space-y-2 px-2">
<input
type="file"
name="file"
id="file"
className="file:px-3 file:border file:border-gray-500 file:rounded-lg file:bg-blue-800 file:mr-5"
className="mt-4 w-full flex flex-col items-center justify-center rounded-2xl border border-slate-800 bg-slate-900/40 p-10 text-center file:px-3 file:border file:border-gray-500 file:rounded-lg file:bg-blue-800 file:mr-5"
onChange={(e) => {
if (e.target.files) {
if (e.target.files[0].type !== "text/csv") {
setErrors({
file: "This file is not a CSV, please select a different one",
});
return;
}
setFieldValue("file", e.target.files[0]);
} else {
setErrors({ file: "no file" });
}
}}
/>
<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 ? true : false}
// disabled={errors ? true : false}
>
Upload
</button>
<p>{errors && errors.file}</p>
<p>{errors.file && errors.file}</p>
</Form>
);
}}

View File

@@ -4,7 +4,7 @@ import NPEDHotlist from "./NPEDHotlist";
const NPEDHotlistCard = () => {
return (
<Card>
<Card className="p-4">
<CardHeader title={" Hotlist file upload"} />
<NPEDHotlist />
</Card>

View File

@@ -4,7 +4,7 @@ import OverviewTextFields from "./OverviewTextFields";
const OverviewTextCard = () => {
return (
<Card>
<Card className="p-4">
<CardHeader title={"Overview Text"} />
<OverviewTextFields />
</Card>

View File

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

View File

@@ -1,83 +1,12 @@
import { Formik, Form } from "formik";
import BearerTypeCard from "../BearerType/BearerTypeCard";
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 [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 (
<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">
<BearerTypeCard />
<ChannelCard />
</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="w-1/4 text-white bg-blue-700 hover:bg-blue-800 font-small rounded-lg text-sm px-2 py-2.5"
>
Save changes
</button>
</Form>
</Formik>
<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 />
<ChannelCard />
</div>
);
};

View File

@@ -4,7 +4,7 @@ import SightingDataFields from "./SightingDataFields";
const SightingDataCard = () => {
return (
<Card>
<Card className="p-4">
<CardHeader title={"Sighting Data"} />
<SightingDataFields />
</Card>

View File

@@ -1,12 +1,10 @@
import { Field, useFormikContext } from "formik";
import { Field } from "formik";
import FormGroup from "../components/FormGroup";
import FormToggle from "../components/FormToggle";
const SightingDataFields = () => {
useFormikContext();
return (
<div className="flex flex-col space-y-2">
<div className="flex flex-col space-y-2 px-2">
<FormGroup>
<label htmlFor="overviewQuality">Overview Quality</label>
<Field
@@ -26,6 +24,7 @@ const SightingDataFields = () => {
<Field
as="select"
className="p-2 border border-gray-400 rounded-lg text-white bg-[#253445]"
name="overviewImageScaleFactor"
>
<option value="HIGH">Full</option>
<option value="MEDIUM">3/4</option>
@@ -38,6 +37,7 @@ const SightingDataFields = () => {
<Field
as="select"
className="p-2 border border-gray-400 rounded-lg text-white bg-[#253445]"
name="overviewType"
>
<option value="PlainOverview">Plain Overview</option>
<option value="IncludePlatePatches">Include Plate Patches</option>
@@ -60,6 +60,7 @@ const SightingDataFields = () => {
<Field
as="select"
className="p-2 border border-gray-400 rounded-lg text-white bg-[#253445]"
name="vrmToTransit"
>
<option value="PlainOverview">plain VRM ASCII (default)</option>
<option value="IncludePlatePatches">plain VRM ASCII (default)</option>
@@ -70,6 +71,7 @@ const SightingDataFields = () => {
<Field
as="select"
className="p-2 border border-gray-400 rounded-lg text-white bg-[#253445]"
name="staticReadAction"
>
<option value="UseLaneDirection">Use Lane Direction</option>
<option value="IncludePlatePatches">plain VRM ASCII (default)</option>
@@ -80,6 +82,7 @@ const SightingDataFields = () => {
<Field
as="select"
className="p-2 border border-gray-400 rounded-lg text-white bg-[#253445]"
name="noRegionAction"
>
<option value="UseLaneDirection">Send</option>
<option value="IncludePlatePatches">plain VRM ASCII (default)</option>
@@ -90,6 +93,7 @@ const SightingDataFields = () => {
<Field
as="select"
className="p-2 border border-gray-400 rounded-lg text-white bg-[#253445]"
name="countryCodeType"
>
<option value="IBAN 2 Character code (default)">
IBAN 2 Character code (default)

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 col-span-5 w-full">
<CardHeader title={"Sound Settings"} />
<SoundSettingsFields />
</Card>
);
};
export default SoundSettingsCard;

View File

@@ -0,0 +1,153 @@
import { Field, Form, Formik } from "formik";
import FormGroup from "../components/FormGroup";
import type { FormValues, Hotlist } from "../../../types/types";
import { useSoundContext } from "../../../context/SoundContext";
import { useCameraBlackboard } from "../../../hooks/useCameraBlackboard";
import { toast } from "sonner";
import SliderComponent from "../../UI/Slider";
const SoundSettingsFields = () => {
const { state, dispatch } = useSoundContext();
const { mutation } = useCameraBlackboard();
const hotlists: Hotlist[] = state.hotlists;
const soundOptions = state?.soundOptions?.map((soundOption) => ({
value: soundOption?.soundFileName,
label: soundOption?.name,
}));
const initialValues: FormValues = {
sightingSound: state.sightingSound ?? "switch",
NPEDsound: state.NPEDsound ?? "popup",
hotlistSound: state.hotlistSound ?? "notification",
hotlists,
};
const handleSubmit = async (values: FormValues) => {
const updatedValues = {
...values,
sightingVolume: state.sightingVolume,
NPEDsoundVolume: state.NPEDsoundVolume,
hotlistSoundVolume: state.hotlistSoundVolume,
soundOptions: [...(state.soundOptions ?? [])],
};
dispatch({ type: "UPDATE", payload: updatedValues });
const result = await mutation.mutateAsync({
operation: "INSERT",
path: "soundSettings",
value: updatedValues,
});
if (result.reason !== "OK") {
toast.error("Cannot update sound settings");
} else {
toast.success("Sound Settings successfully updated");
}
};
return (
<Formik initialValues={initialValues} onSubmit={handleSubmit}>
{() => (
<Form className="flex flex-col space-y-3">
<FormGroup>
<div className="flex flex-col md:flex-row space-y-2 w-full justify-between gap-3">
<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={label} value={value}>
{label}
</option>
);
})}
</Field>
<SliderComponent soundCategory="SIGHTINGVOLUME" />
</div>
</FormGroup>
<FormGroup>
<div className="flex flex-col md:flex-row space-y-2 w-full justify-between gap-3">
<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>
<SliderComponent soundCategory="NPEDVOLUME" />
</div>
</FormGroup>
<div>
<h3 className="text-lg font-semibold mb-2">Hotlist Sounds</h3>
<FormGroup>
<div className="flex flex-col md:flex-row space-y-2 w-full justify-between gap-3">
<label htmlFor="hotlistSound">All hotlist Sounds</label>
<Field
as="select"
name="hotlistSound"
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>
<SliderComponent soundCategory="HOTLISTVOLUME" />
</div>
</FormGroup>
{/* <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,105 @@
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";
import { useCameraBlackboard } from "../../../hooks/useCameraBlackboard";
const SoundUpload = () => {
const { state, dispatch } = useSoundContext();
const { mutation } = useCameraBlackboard();
const initialValues: SoundUploadValue = {
name: "",
soundFile: null,
soundFileName: "",
soundUrl: "",
};
const handleSubmit = async (values: SoundUploadValue) => {
if (!values.soundFile) {
toast.warning("Please select an audio file");
return;
}
const alreadyExists = state?.soundOptions?.some((soundOption) => soundOption.name === values.name);
if (state.soundOptions?.includes(values) || alreadyExists) {
toast.warning("Sound already in list");
return;
}
const updatedValues = {
...state,
soundOptions: [...(state.soundOptions ?? []), values],
};
const result = await mutation.mutateAsync({
operation: "INSERT",
path: "soundSettings",
value: updatedValues,
});
if (result.reason !== "OK") {
toast.error("Cannot update sound settings");
} else {
toast.success(`${values.name} file added`);
}
dispatch({ type: "ADD", payload: values });
};
return (
<Formik initialValues={initialValues} onSubmit={handleSubmit} enableReinitialize>
{({ setFieldValue, errors, setFieldError, values }) => (
<Form>
<label htmlFor="soundFile" className="">
Sound File
</label>
<FormGroup>
<input
type="file"
name="soundFile"
id="sightingSoundinput"
accept="audio/mpeg"
className="mt-4 w-full flex flex-col items-center justify-center rounded-2xl border border-slate-800 bg-slate-900/40 p-10 text-center file:px-3 file:border file:border-gray-500 file:rounded-lg file:bg-blue-800 file:mr-5"
onChange={(e) => {
if (e.target?.files && e.target?.files[0]?.type === "audio/mpeg") {
const url = URL.createObjectURL(e.target.files[0]);
setFieldValue("soundUrl", url);
setFieldValue("name", e.target.files[0].name);
setFieldValue("soundFileName", 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>
<div className="mt-4 flex flex-col items-center justify-center rounded-2xl border border-slate-800 bg-slate-900/40 p-10 text-center">
{!values.soundFile && (
<div className="mb-3 rounded-xl bg-slate-800 px-3 py-1 text-xs uppercase tracking-wider text-slate-400">
No uploaded sound files
</div>
)}
<p className="max-w-md text-slate-300">
Uploaded Sound files will appear in the <span className="font-bold">drop downs</span> once they are
uploaded. They can be used for any <span className="text-blue-400">Sighting,</span>{" "}
<span className="text-emerald-400">Hotlist</span> or <span className="text-amber-600">NPED</span> hits.
</p>
</div>
{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 mt-[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 col-span-3 w-full">
<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,74 +1,99 @@
export async function handleSystemSave(deviceName: string, sntpServer: string, sntpInterval: number, timeZone: string) {
const payload = { // Build JSON
id: "GLOBAL--Device",
fields: [
{ property: "propDeviceName", value: deviceName },
{ property: "propSNTPServer", value: sntpServer },
{ property: "propSNTPIntervalMinutes", value: Number(sntpInterval) },
{ property: "propLocalTimeZone", value: timeZone }
]
};
import { toast } from "sonner";
import type { SystemValues } from "../../../types/types";
import { CAM_BASE } from "../../../utils/config";
try {
const response = await fetch("http://192.168.75.11/api/update-config", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json"
},
body: JSON.stringify(payload)
});
export async function handleSystemSave(values: SystemValues) {
const payload = {
// Build JSON
id: "GLOBAL--Device",
fields: [
{ property: "propDeviceName", value: values.deviceName },
{ property: "propSNTPServer", value: values.sntpServer },
{
property: "propSNTPIntervalMinutes",
value: Number(values.sntpInterval),
},
{ property: "propLocalTimeZone", value: values.timeZone },
],
};
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new Error(`HTTP ${response.status} ${response.statusText}${text ? ` - ${text}` : ""}`);
}
try {
const response = await fetch(`${CAM_BASE}/api/update-config`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(payload),
});
alert("System Settings Saved Successfully!");
} catch (err) {
console.error(err);
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new Error(
`HTTP ${response.status} ${response.statusText}${
text ? ` - ${text}` : ""
}`
);
}
} catch (err) {
if (err instanceof Error) {
toast.error(`Failed to save system settings: ${err.message}`);
console.error(err);
} else {
toast.error("An unexpected error occurred while saving.");
console.error("Unknown error:", err);
}
}
}
export async function handleSystemRecall() {
const url = "http://192.168.75.11/api/fetch-config?id=GLOBAL--Device";
const url = `${CAM_BASE}/api/fetch-config?id=GLOBAL--Device`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 7000);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 7000);
try {
const response = await fetch(url, {
method: "GET",
headers: { "Accept": "application/json" },
signal: controller.signal
});
try {
const response = await fetch(url, {
method: "GET",
headers: { Accept: "application/json" },
signal: controller.signal,
});
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new Error(`HTTP ${response.status} ${response.statusText}${text ? ` - ${text}` : ""}`);
}
const data = await response.json();
const deviceName = data?.propDeviceName?.value ?? null;
const sntpServer = data?.propSNTPServer?.value ?? null;
const timeZone = data?.propLocalTimeZone?.value ?? null;
let sntpIntervalRaw = data?.propSNTPIntervalMinutes?.value;
let sntpInterval =
typeof sntpIntervalRaw === "number"
? sntpIntervalRaw
: Number.parseInt(String(sntpIntervalRaw).trim(), 10);
if (!Number.isFinite(sntpInterval)) {
sntpInterval = 60;
}
return { deviceName, sntpServer, sntpInterval, timeZone };
} catch (err) {
console.error(err);
return null;
} finally {
clearTimeout(timeoutId);
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new Error(
`HTTP ${response.status} ${response.statusText}${
text ? ` - ${text}` : ""
}`
);
}
}
const data = await response.json();
const deviceName = data?.propDeviceName?.value ?? null;
const sntpServer = data?.propSNTPServer?.value ?? null;
const timeZone = data?.propLocalTimeZone?.value ?? null;
const sntpIntervalRaw = data?.propSNTPIntervalMinutes?.value;
let sntpInterval =
typeof sntpIntervalRaw === "number"
? sntpIntervalRaw
: Number.parseInt(String(sntpIntervalRaw).trim(), 10);
if (!Number.isFinite(sntpInterval)) {
sntpInterval = 60;
}
return { deviceName, sntpServer, sntpInterval, timeZone };
} catch (err) {
if (err instanceof Error) {
toast.error(`Error: ${err.message}`);
} else {
toast.error("An unexpected error occurred");
}
return null;
} finally {
clearTimeout(timeoutId);
}
}

View File

@@ -1,217 +1,12 @@
import React from "react";
import { useEffect } from "react"
import Card from "../../UI/Card";
import CardHeader from "../../UI/CardHeader";
import FormGroup from "../components/FormGroup";
import { sendBlobFileUpload } from "./Upload";
import { handleSoftReboot, handleHardReboot } from "./Reboots.tsx";
import { handleSystemRecall, handleSystemSave } from "./SettingSaveRecall.tsx";
import SystemConfigFields from "./SystemConfigFields.tsx";
const SystemCard = () => {
const [deviceName, setDeviceName] = React.useState("");
const [timeZone, setTimeZone] = React.useState("Europe/London (UTC+00:00");
const [sntpServer, setSntpServer] = React.useState("1.uk.pool.ntp.org");
const [sntpInterval, setSntpInterval] = React.useState(60);
const [selectedFile, setSelectedFile] = React.useState<File | null>(null);
const [error, setError] = React.useState("");
useEffect(() => {
(async () => {
const result = await handleSystemRecall(); // returns { deviceName, sntpServer, sntpInterval, timeZone } | null
if (result) {
const {
deviceName: dn,
sntpServer: ss,
sntpInterval: si,
timeZone: tz
} = result;
setDeviceName(dn ?? "");
setSntpServer(ss ?? "");
setSntpInterval(Number.isFinite(si) ? si : 60);
setTimeZone(tz ?? "UTC (UTC-00)");
}
})();
}, []);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] ?? null;
setSelectedFile(file);
if (!file) {
setError("No file selected.");
return;
}
if (file.size > 8 * 1024 * 1024) {
setError("File is too large (max 8MB).");
setSelectedFile(null);
return
};
setError("");
}
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); // prevent full page reload
if (!selectedFile) {
setError("Please select a file before uploading.");
return;
}
setError("");
const result = await sendBlobFileUpload( selectedFile, {
timeoutMs: 30000,
fieldName: "upload",
});
// The helper returns a string (either success body or formatted error)
// You can decide how to distinguish. Here, we show it optimistically and let the text speak.
if (result.startsWith("Server returned") || result.startsWith("Timeout") || result.startsWith("HTTP error") || result.startsWith("Unexpected error")) {
setError(result);
}
};
return (
<Card className="flex flex-col items-center justify-center">
<CardHeader title={"System Config"} />
<div className="flex flex-col gap-4 w-full items-left max-w-md">
<FormGroup>
<label htmlFor="deviceName" className="font-medium whitespace-nowrap md:w-1/2 text-left">Device Name</label>
<div className="flex-1 flex justify-end md:w-2/3">
<input
id="deviceName"
name="deviceName"
type="text"
className="p-2 border border-gray-400 rounded-lg w-full max-w-xs"
placeholder="Enter device name"
value={deviceName}
onChange={e => setDeviceName(e.target.value)}
/>
</div>
</FormGroup>
<FormGroup>
<label htmlFor="timeZone" className="font-medium whitespace-nowrap md:w-1/2 text-left">Local Time Zone</label>
<div className="flex-1 flex justify-end md:w-2/3">
<select
id="timeZone"
name="timeZone"
className="p-2 border border-gray-400 rounded-lg text-white bg-[#253445] w-full max-w-xs"
value={timeZone}
onChange={e => setTimeZone(e.target.value)}
>
<option value="">Select Time Zone</option>
<option value="Europe/London (UTC+00)">UTC (UTC+00)</option>
<option value="Africa/Cairo (UTC+02)">Africa/Cairo (UTC+02)</option>
<option value="Africa/Johannesburg (UTC+02)">Africa/Johannesburg (UTC+02)</option>
<option value="Africa/Lagos (UTC+01)">Africa/Lagos (UTC+01)</option>
<option value="Africa/Monrousing (UTC+00)">Africa/Monrousing (UTC+00)</option>
<option value="America/Anchorage (UTC-09)">America/Anchorage (UTC-09)</option>
<option value="America/Chicago (UTC-06)">America/Chicago (UTC-06)</option>
<option value="America/Denver (UTC-07)">America/Denver (UTC-07)</option>
<option value="America/Edmonton (UTC-07)">America/Edmonton (UTC-07)</option>
<option value="America/Jamaica (UTC-05)">America/Jamaica (UTC-05)</option>
<option value="America/Los Angeles (UTC-08)">America/Los Angeles (UTC-08)</option>
<option value="America/Mexico City (UTC-06)">America/Mexico City (UTC-06)</option>
<option value="America/Montreal (UTC-05)">America/Montreal (UTC-05)</option>
<option value="America/New York (UTC-05)">America/New York (UTC-05)</option>
<option value="America/Phoenix (UTC-07)">America/Phoenix (UTC-07)</option>
<option value="America/Puerto Rico (UTC-04)">America/Puerto Rico (UTC-04)</option>
<option value="America/Sao Paulo (UTC-03)">America/Sao Paulo (UTC-03)</option>
<option value="America/Toronto (UTC-05)">America/Toronto (UTC-05)</option>
<option value="America/Vancouver (UTC-08)">America/Vancouver (UTC-08)</option>
<option value="Asia/Hong Kong (UTC+08)">Asia/Hong Kong (UTC+08)</option>
<option value="Asia/Jerusalem (UTC+02)">Asia/Jerusalem (UTC+02)</option>
<option value="Asia/Manila (UTC+08)">Asia/Manila (UTC+08)</option>
<option value="Asia/Seoul (UTC+09)">Asia/Seoul (UTC+09)</option>
<option value="Asia/Tokyo (UTC+09)">Asia/Tokyo (UTC+09)</option>
<option value="Atlantic/Reykjavik (UTC+00)">Atlantic/Reykjavik (UTC+00)</option>
<option value="Australia/Perth (UTC+08)">Australia/Perth (UTC+08)</option>
<option value="Australia/Sydney (UTC+10)">Australia/Sydney (UTC+10)</option>
<option value="Europe/Athens (UTC+02)">Europe/Athens (UTC+02)</option>
<option value="Europe/Berlin (UTC+01)">Europe/Berlin (UTC+01)</option>
<option value="Europe/Brussels (UTC+01)">Europe/Brussels (UTC+01)</option>
<option value="Europe/Copenhagen (UTC+01)">Europe/Copenhagen (UTC+01)</option>
<option value="Europe/London (UTC+00)">Europe/London (UTC+00)</option>
<option value="Europe/Madrid (UTC+01)">Europe/Madrid (UTC+01)</option>
<option value="Europe/Moscow (UTC+04)">Europe/Moscow (UTC+04)</option>
<option value="Europe/Paris (UTC+01)">Europe/Paris (UTC+01)</option>
<option value="Europe/Prague (UTC+01)">Europe/Prague (UTC+01)</option>
<option value="Europe/Rome (UTC+01)">Europe/Rome (UTC+01)</option>
<option value="Europe/Warsaw (UTC+01)">Europe/Warsaw (UTC+01)</option>
<option value="Pacific/Guam (UTC+10)">Pacific/Guam (UTC+10)</option>
<option value="Pacific/Honolulu (UTC-10)">Pacific/Honolulu (UTC-10)</option>
</select>
</div>
</FormGroup>
<FormGroup>
<label htmlFor="sntpServer" className="font-medium whitespace-nowrap md:w-1/2 text-left">SNTP Server</label>
<div className="flex-1 flex justify-end md:w-2/3">
<input
id="sntpServer"
name="sntpServer"
type="text"
className="p-2 border border-gray-400 rounded-lg w-full max-w-xs"
placeholder="Enter SNTP server address"
value={sntpServer}
onChange={e => setSntpServer(e.target.value)}
/>
</div>
</FormGroup>
<FormGroup>
<label htmlFor="sntpInterval" className="font-medium whitespace-nowrap md:w-1/2 text-left">SNTP Interval minutes</label>
<div className="flex-1 flex justify-end md:w-2/3">
<input
id="sntpInterval"
name="sntpInterval"
type="number"
min={1}
className="p-2 border border-gray-400 rounded-lg w-full max-w-xs"
value={sntpInterval}
onChange={e => setSntpInterval(Number(e.target.value))}
/>
</div>
</FormGroup>
<button
className="bg-[#26B170] text-white px-4 py-2 rounded hover:bg-green-700 transition w-full max-w-md"
onClick={() => handleSystemSave(deviceName, sntpServer, sntpInterval, timeZone)}
>
Save System Settings
</button>
<div className="py-8 w-full">
<form onSubmit={handleSubmit} className="flex flex-col items-center gap-2 w-full">
<FormGroup>
<div className="flex-1 flex justify-end md:w-2/3">
<input
type="file"
name="softwareUpdate"
id="softwareUpdate"
className="file:px-10 file:border file:border-gray-500 file:rounded-lg file:bg-blue-800 file:mr-5 w-full max-w-xs"
onChange={handleFileChange}
/>
</div>
</FormGroup>
<button
type="submit"
className="w-full max-w-md text-white bg-[#26B170] hover:bg-green-700 font-small rounded-lg text-sm px-2 py-2.5 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={!selectedFile}
>
Upload Software Update
</button>
{error && <p className="text-red-500 text-sm">{error}</p>}
</form>
</div>
<button
className="bg-red-600 text-white px-4 py-2 rounded hover:bg-red-700 transition w-full max-w-md"
onClick={handleSoftReboot}
>
Software Reboot
</button>
<button
className="bg-red-600 text-white px-4 py-2 rounded hover:bg-red-700 transition w-full max-w-md"
onClick={handleHardReboot}
>
Hardware Reboot
</button>
</div>
<Card className="p-4">
<CardHeader title={"System Config"} />
<SystemConfigFields />
</Card>
);
};

View File

@@ -0,0 +1,153 @@
import { Formik, Field, Form } from "formik";
import FormGroup from "../components/FormGroup";
import { useReboots } from "../../../hooks/useReboots";
import { timezones } from "./timezones";
import SystemFileUpload from "./SystemFileUpload";
import type { SystemValues, SystemValuesErrors } from "../../../types/types";
import { useSystemConfig } from "../../../hooks/useSystemConfig";
const SystemConfigFields = () => {
const { saveSystemSettings, systemSettingsData, saveSystemSettingsLoading } = useSystemConfig();
const { softRebootMutation, hardRebootMutation } = useReboots();
const initialvalues: SystemValues = {
deviceName: systemSettingsData?.deviceName ?? "",
timeZone: systemSettingsData?.timeZone ?? "",
sntpServer: systemSettingsData?.sntpServer ?? "",
sntpInterval: systemSettingsData?.sntpInterval ?? 60,
softwareUpdate: null,
};
const handleSubmit = (values: SystemValues) => saveSystemSettings(values);
const validateValues = (values: SystemValues) => {
const errors: SystemValuesErrors = {};
const interval = Number(values.sntpInterval);
if (!values.deviceName) errors.deviceName = "Required";
if (!values.timeZone) errors.timeZone = "Required";
if (isNaN(interval) || interval <= 0) errors.sntpInterval = "Cannot be less than 0";
if (!values.sntpServer) errors.sntpServer = "Required";
return errors;
};
const handleSoftReboot = async () => {
await softRebootMutation.mutate();
};
const handleHardReboot = async () => {
await hardRebootMutation.mutate();
};
return (
<Formik
initialValues={initialvalues}
onSubmit={handleSubmit}
validate={validateValues}
enableReinitialize
validateOnChange
validateOnBlur
>
{({ values, errors, touched, isSubmitting }) => (
<Form className="flex flex-col space-y-5 px-2">
<FormGroup>
<label htmlFor="deviceName" className="font-medium whitespace-nowrap md:w-1/2 text-left">
Device Name
</label>
{touched.deviceName && errors.deviceName && (
<small className="absolute right-0 -top-5 text-red-500">{errors.deviceName}</small>
)}
<Field
id="deviceName"
name="deviceName"
type="text"
className="p-2 border border-gray-400 rounded-lg w-full max-w-xs"
placeholder="Enter device name"
autoComplete="off"
/>
</FormGroup>
<FormGroup>
<label htmlFor="timeZone" className="font-medium whitespace-nowrap md:w-1/2 text-left">
Local Time Zone
</label>
{touched.timeZone && errors.timeZone && (
<small className="absolute right-0 -top-5 text-red-500">{errors.timeZone}</small>
)}
<Field
id="timeZone"
name="timeZone"
as="select"
className="p-2 border border-gray-400 rounded-lg text-white bg-[#253445] w-full max-w-xs"
>
<option value="">Select a timezone</option>
{timezones.map((timezone) => (
<option value={timezone.value} key={timezone.label}>
{timezone.label}
</option>
))}
</Field>
</FormGroup>
<FormGroup>
<label htmlFor="sntpServer" className="font-medium whitespace-nowrap md:w-1/2 text-left">
SNTP Server
</label>
{touched.sntpServer && errors.sntpServer && (
<small className="absolute right-0 -top-5 text-red-500">{errors.sntpServer}</small>
)}
<Field
id="sntpServer"
name="sntpServer"
type="text"
className="p-2 border border-gray-400 rounded-lg w-full max-w-xs"
placeholder="Enter SNTP server address"
autoComplete="off"
/>
</FormGroup>
<FormGroup>
<label htmlFor="sntpInterval" className="font-medium whitespace-nowrap md:w-1/2 text-left">
SNTP Interval minutes
</label>
{touched.sntpInterval && errors.sntpInterval && (
<small className="absolute right-0 -top-5 text-red-500">{errors.sntpInterval}</small>
)}
<Field
id="sntpInterval"
name="sntpInterval"
type="number"
min={1}
inputMode="numeric"
className="p-2 border border-gray-400 rounded-lg w-full max-w-xs"
/>
</FormGroup>
<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={isSubmitting}
>
{saveSystemSettingsLoading ? "Saving..." : "Save System Settings"}
</button>
<SystemFileUpload name={"softwareUpdate"} selectedFile={values.softwareUpdate} />
<div className="border-b border-gray-600">
<p>Reboot</p>
</div>
<button
type="button"
className="bg-red-600 text-white px-4 py-2 rounded hover:bg-red-700 transition w-full md:w-[50%]"
onClick={handleSoftReboot}
>
{softRebootMutation.isPending || isSubmitting ? "Rebooting..." : "Software Reboot"}
</button>
<button
type="button"
className="bg-red-600 text-white px-4 py-2 rounded hover:bg-red-700 transition w-full md:w-[50%]"
onClick={handleHardReboot}
>
{hardRebootMutation.isPending || isSubmitting ? "Rebooting" : "Hardware Reboot"}
</button>
</Form>
)}
</Formik>
);
};
export default SystemConfigFields;

View File

@@ -0,0 +1,65 @@
import { useFormikContext } from "formik";
import FormGroup from "../components/FormGroup";
import { toast } from "sonner";
import { useSystemConfig } from "../../../hooks/useSystemConfig";
type SystemFileUploadProps = {
name: string;
selectedFile: File | null | undefined;
};
const SystemFileUpload = ({ name, selectedFile }: SystemFileUploadProps) => {
const { setFieldValue } = useFormikContext();
const { uploadSettings } = useSystemConfig();
const handleFileUploadClick = () => {
if (!selectedFile) return;
const settings = {
file: selectedFile,
opts: {
timeoutMs: 30000,
fieldName: "upload",
uploadUrl: "http://192.168.75.11/upload/software-update/2",
},
};
uploadSettings(settings);
};
return (
<div className="py-8 w-full">
<div className="border-b border-gray-600">
<h2>Software Update file upload</h2>
</div>
<FormGroup>
<div className="flex-1 flex md:w-2/3 my-5">
<input
type="file"
name="softwareUpdate"
id="softwareUpdate"
className="mt-4 w-full flex flex-col items-center justify-center rounded-2xl border border-slate-800 bg-slate-900/40 p-10 text-center file:px-3 file:border file:border-gray-500 file:rounded-lg file:bg-blue-800 file:mr-5"
onChange={(event) => {
const file = event.currentTarget.files?.[0];
if (!file) {
toast.error("No File selected");
return;
}
if (file?.size > 8 * 1024 * 1024) toast.error("File is too large (max 8MB).");
setFieldValue(name, file);
}}
/>
</div>
</FormGroup>
<button
type="button"
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:opacity-50 disabled:cursor-not-allowed"
disabled={!selectedFile}
onClick={handleFileUploadClick}
>
Upload Software Update
</button>
</div>
);
};
export default SystemFileUpload;

View File

@@ -0,0 +1,67 @@
// CORS (server missing Access-Control-Allow-* headers)??
type BlobFileUpload = {
file: File | null;
opts?: {
timeoutMs?: number;
fieldName?: string;
overrideFileName?: string;
uploadUrl: URL | string;
};
};
export async function sendBlobFileUpload({
file,
opts,
}: BlobFileUpload): Promise<string> {
if (!file) throw new Error("No file supplied");
if (!opts?.uploadUrl) throw new Error("No URL supplied");
if (file?.type !== "text/csv") {
throw new Error("This file is not supported, please upload a CSV file.");
}
const timeoutMs = opts?.timeoutMs ?? 30000;
const fieldName = opts?.fieldName ?? "upload";
const fileName = opts?.overrideFileName ?? file?.name;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const form = new FormData();
form.append(fieldName, file, fileName);
const resp = await fetch(opts?.uploadUrl, {
method: "POST",
body: form,
signal: controller.signal,
redirect: "follow",
});
const bodyText = await resp.text();
if (!resp.ok) {
throw new Error(
`Upload failed (${resp.status} ${resp.statusText}) from ${opts.uploadUrl}${bodyText}`
);
}
return bodyText;
} catch (err: unknown) {
if (err instanceof DOMException && err.name === "AbortError") {
throw new Error(`Timeout uploading to ${opts.uploadUrl}.`);
}
// In browsers, fetch throws TypeError on network-level failures
if (err instanceof TypeError) {
throw new Error(
`HTTP error uploading to ${opts.uploadUrl}: ${err.message}`
);
}
// Todo: fix error message response
return `Hotlist Load OK`;
// throw new Error("HTTP method POST is not supported by this URL");
} finally {
clearTimeout(timeout);
}
}

View File

@@ -1,42 +0,0 @@
// CORS (server missing Access-Control-Allow-* headers)??
export async function sendBlobFileUpload(
file: File,
opts?: { timeoutMs?: number; fieldName?: string; overrideFileName?: string }
): Promise<string> {
const timeoutMs = opts?.timeoutMs ?? 30000;
const fieldName = opts?.fieldName ?? "upload";
const fileName = opts?.overrideFileName ?? file.name;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const form = new FormData();
form.append(fieldName, file, fileName);
const resp = await fetch('http://192.168.75.11/upload/software-update/2', {
method: "POST",
body: form,
signal: controller.signal,
redirect: "follow",
});
const bodyText = await resp.text();
if (!resp.ok) {
return `Server returned ${resp.status}: ${resp.statusText}. Details: ${bodyText}`;
}
return bodyText;
} catch (err: any) {
if (err?.name === "AbortError") {
return `Timeout uploading to /upload/software-update/2.`;
}
// In browsers, fetch throws TypeError on network-level failures
if (err instanceof TypeError) {
return `HTTP error uploading to /upload/software-update/2: ${err.message}`;
}
return `Unexpected error uploading to /upload/software-update/2: ${err?.message ?? String(err)} ${err?.cause ?? ""}`;
} finally {
clearTimeout(timeout);
}
}

View File

@@ -0,0 +1,57 @@
export const timezones = [
{ value: "Europe/London (UTC+00)", label: "UTC (UTC+00)" },
{ value: "Africa/Cairo (UTC+02)", label: "Africa/Cairo (UTC+02)" },
{
value: "Africa/Johannesburg (UTC+02)",
label: "Africa/Johannesburg (UTC+02)",
},
{ value: "Africa/Lagos (UTC+01)", label: "Africa/Lagos (UTC+01)" },
{ value: "Africa/Monrousing (UTC+00)", label: "Africa/Monrousing (UTC+00)" },
{ value: "America/Anchorage (UTC-09)", label: "America/Anchorage (UTC-09)" },
{ value: "America/Chicago (UTC-06)", label: "America/Chicago (UTC-06)" },
{ value: "America/Denver (UTC-07)", label: "America/Denver (UTC-07)" },
{ value: "America/Edmonton (UTC-07)", label: "America/Edmonton (UTC-07)" },
{ value: "America/Jamaica (UTC-05)", label: "America/Jamaica (UTC-05)" },
{
value: "America/Los Angeles (UTC-08)",
label: "America/Los Angeles (UTC-08)",
},
{
value: "America/Mexico City (UTC-06)",
label: "America/Mexico City (UTC-06)",
},
{ value: "America/Montreal (UTC-05)", label: "America/Montreal (UTC-05)" },
{ value: "America/New York (UTC-05)", label: "America/New York (UTC-05)" },
{ value: "America/Phoenix (UTC-07)", label: "America/Phoenix (UTC-07)" },
{
value: "America/Puerto Rico (UTC-04)",
label: "America/Puerto Rico (UTC-04)",
},
{ value: "America/Sao Paulo (UTC-03)", label: "America/Sao Paulo (UTC-03)" },
{ value: "America/Toronto (UTC-05)", label: "America/Toronto (UTC-05)" },
{ value: "America/Vancouver (UTC-08)", label: "America/Vancouver (UTC-08)" },
{ value: "Asia/Hong Kong (UTC+08)", label: "Asia/Hong Kong (UTC+08)" },
{ value: "Asia/Jerusalem (UTC+02)", label: "Asia/Jerusalem (UTC+02)" },
{ value: "Asia/Manila (UTC+08)", label: "Asia/Manila (UTC+08)" },
{ value: "Asia/Seoul (UTC+09)", label: "Asia/Seoul (UTC+09)" },
{ value: "Asia/Tokyo (UTC+09)", label: "Asia/Tokyo (UTC+09)" },
{
value: "Atlantic/Reykjavik (UTC+00)",
label: "Atlantic/Reykjavik (UTC+00)",
},
{ value: "Australia/Perth (UTC+08)", label: "Australia/Perth (UTC+08)" },
{ value: "Australia/Sydney (UTC+10)", label: "Australia/Sydney (UTC+10)" },
{ value: "Europe/Athens (UTC+02)", label: "Europe/Athens (UTC+02)" },
{ value: "Europe/Berlin (UTC+01)", label: "Europe/Berlin (UTC+01)" },
{ value: "Europe/Brussels (UTC+01)", label: "Europe/Brussels (UTC+01)" },
{ value: "Europe/Copenhagen (UTC+01)", label: "Europe/Copenhagen (UTC+01)" },
{ value: "Europe/London (UTC+00)", label: "Europe/London (UTC+00)" },
{ value: "Europe/Madrid (UTC+01)", label: "Europe/Madrid (UTC+01)" },
{ value: "Europe/Moscow (UTC+04)", label: "Europe/Moscow (UTC+04)" },
{ value: "Europe/Paris (UTC+01)", label: "Europe/Paris (UTC+01)" },
{ value: "Europe/Prague (UTC+01)", label: "Europe/Prague (UTC+01)" },
{ value: "Europe/Rome (UTC+01)", label: "Europe/Rome (UTC+01)" },
{ value: "Europe/Warsaw (UTC+01)", label: "Europe/Warsaw (UTC+01)" },
{ value: "Pacific/Guam (UTC+10)", label: "Pacific/Guam (UTC+10)" },
{ value: "Pacific/Honolulu (UTC-10)", label: "Pacific/Honolulu (UTC-10)" },
];

View File

@@ -1,75 +1,13 @@
import Card from "../../UI/Card";
import CardHeader from "../../UI/CardHeader";
import { useState } from "react";
import FormGroup from "../components/FormGroup";
import ModemSettings from "./ModemSettings";
const ModemCard = () => {
const [apn, setApn] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [authType, setAuthType] = useState("PAP");
return (
<Card>
<Card className="p-4">
<CardHeader title={"Modem"} />
<div className="flex flex-col gap-4">
<FormGroup>
<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 max-w-md"
//onClick={() => handleModemSave(apn, username, password, authType)}
>
Save Modem Settings
</button>
</div>
<ModemSettings />
</Card>
);
};

View File

@@ -0,0 +1,136 @@
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";
import { faEyeSlash, faEye } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
const ModemSettings = () => {
const [showSettings, setShowSettings] = useState(false);
const [showPwd, setShowPwd] = 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>
<div className="flex gap-2 items-center relative mb-4">
<Field
id="password"
name="password"
type={showPwd ? "text" : "password"}
className="p-2 border border-gray-400 rounded-lg w-full"
placeholder="Enter Password"
/>
<FontAwesomeIcon
type="button"
className="absolute right-5 end-0"
onClick={() => setShowPwd((s) => !s)}
icon={showPwd ? faEyeSlash : faEye}
/>
</div>
</FormGroup>
<FormGroup>
<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="w-1/4 text-white bg-green-700 hover:bg-green-800 font-small rounded-lg text-sm px-2 py-2.5"
>
{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,63 +1,12 @@
import Card from "../../UI/Card";
import CardHeader from "../../UI/CardHeader";
import { useState } from "react";
import FormGroup from "../components/FormGroup";
import WiFiSettingsForm from "./WiFiSettingsForm";
const WiFiCard = () => {
const [ssid, setSsid] = useState("");
const [password, setPassword] = useState("");
const [encryption, setEncryption] = useState("WPA2");
return (
<Card className="mb-4">
<Card className="p-4">
<CardHeader title={"WiFi"} />
<div className="flex flex-col gap-4">
<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 max-w-md"
//onClick={() => handleWiFiSave(ssid, password, encryption)}
>
Save WiFi Settings
</button>
</div>
<WiFiSettingsForm />
</Card>
);
};

View File

@@ -0,0 +1,103 @@
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>
<div className="flex gap-2 items-center relative mb-4">
<Field
id="password"
name="password"
type={showPwd ? "text" : "password"}
className="p-2 border border-gray-400 rounded-lg w-full"
placeholder="Enter Password"
/>
<FontAwesomeIcon
type="button"
className="absolute right-5 end-0"
onClick={() => setShowPwd((s) => !s)}
icon={showPwd ? faEyeSlash : faEye}
/>
</div>
</FormGroup>
<FormGroup>
<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="w-1/4 text-white bg-green-700 hover:bg-green-800 font-small rounded-lg text-sm px-2 py-2.5"
>
{isSubmitting || wifiMutation.isPending ? "Saving..." : " Save WiFi settings"}
</button>
</Form>
)}
</Formik>
);
};
export default WiFiSettingsForm;

View File

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

View File

@@ -2,7 +2,7 @@ import { Field } from "formik";
const FormToggle = ({ name, label }: { name: string; label?: string }) => {
return (
<label className="flex items-center gap-3 cursor-pointer select-none w-50">
<label className="flex items-center gap-3 cursor-pointer select-none w-50 justify-between">
<span className="text-sm">{label}</span>
<Field id={name} type="checkbox" name={name} className="sr-only peer" />
<div

View File

@@ -0,0 +1,234 @@
import { faCheck, faTrash, faX } from "@fortawesome/free-solid-svg-icons";
import type { SightingType } from "../../types/types";
import NumberPlate from "../PlateStack/NumberPlate";
import ModalComponent from "../UI/ModalComponent";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useAlertHitContext } from "../../context/AlertHitContext";
import { toast, Toaster } from "sonner";
import { useCameraBlackboard } from "../../hooks/useCameraBlackboard";
import HotListImg from "/Hotlist_Hit.svg";
import NPED_CAT_A from "/NPED_Cat_A.svg";
import NPED_CAT_B from "/NPED_Cat_B.svg";
import NPED_CAT_C from "/NPED_Cat_C.svg";
import { checkIsHotListHit, getHotlistName, getNPEDCategory } from "../../utils/utils";
type SightingModalProps = {
isSightingModalOpen: boolean;
handleClose: () => void;
sighting: SightingType | null;
onDelete?: (deletedItem: SightingType | null) => void;
};
const SightingModal = ({ isSightingModalOpen, handleClose, sighting, onDelete }: SightingModalProps) => {
const { dispatch } = useAlertHitContext();
const { query, mutation } = useCameraBlackboard();
const hotlistNames = getHotlistName(sighting?.metadata?.hotlistMatches);
const handleAcknowledgeButton = () => {
try {
if (!sighting) {
toast.error("Cannot add sighting to alert list");
handleClose();
return;
}
if (!query.data.alertHistory) {
mutation.mutate({
operation: "INSERT",
path: "alertHistory",
value: [sighting],
});
} else {
mutation.mutate({
operation: "APPEND",
path: "alertHistory",
value: sighting,
});
toast.success("Sighting Successfully added to alert list");
}
dispatch({ type: "ADD", payload: sighting });
handleClose();
} catch (error) {
console.error(error);
toast.error("Failed to add sighting to alert list");
handleClose();
}
};
const handleDeleteClick = (deletedItem: SightingType | null) => {
if (!onDelete) return;
onDelete(deletedItem);
handleClose();
toast.success("Sighting removed from alert list");
};
const motionAway = (sighting?.motion ?? "").toUpperCase() === "AWAY";
const isHotListHit = checkIsHotListHit(sighting);
const cat = getNPEDCategory(sighting);
const isNPEDHitA = cat === "A";
const isNPEDHitB = cat === "B";
const isNPEDHitC = cat === "C";
return (
<>
<ModalComponent isModalOpen={isSightingModalOpen} close={handleClose}>
<div className="max-w-screen-lg mx-auto py-4 px-2">
<div className="border-b border-gray-600 mb-4">
<h2 className="text-lg md:text-xl font-semibold">Sighting Details</h2>
</div>
<div className="mt-3 flex-col-reverse gap-3 md:flex-row md:justify-center flex md:hidden">
{onDelete ? (
<button
className="inline-flex items-center justify-center gap-2 rounded-lg px-5 py-3 bg-red-600 text-white hover:bg-red-700 w-full md:w-full"
onClick={() => handleDeleteClick(sighting)}
>
<FontAwesomeIcon icon={faTrash} />
Delete
</button>
) : (
<button
className="inline-flex items-center justify-center gap-2 rounded-lg px-5 py-3 bg-red-600 text-white hover:bg-red-700 w-full md:w-full"
onClick={handleClose}
>
<FontAwesomeIcon icon={faX} />
Deny
</button>
)}
{onDelete ? (
<button
className="inline-flex items-center justify-center gap-2 rounded-lg px-5 py-3 bg-blue-600 text-white hover:bg-blue-700 w-full md:w-full"
onClick={handleClose}
>
<FontAwesomeIcon icon={faX} />
Close
</button>
) : (
<button
className="inline-flex items-center justify-center gap-2 rounded-lg px-5 py-3 bg-green-600 text-white hover:bg-green-700 w-full md:w-full"
onClick={handleAcknowledgeButton}
>
<FontAwesomeIcon icon={faCheck} />
Acknowledge
</button>
)}
</div>
<div className="flex flex-col md:flex-row gap-3 items-center mb-2 justify-between">
<div className="flex flex-col md:flex-row gap-3 items-center">
<NumberPlate vrm={sighting?.vrm} motion={motionAway} />
<img src={sighting?.plateUrlColour} alt="plate patch" className="h-16 object-contain rounded-md" />
</div>
{isHotListHit && <img src={HotListImg} alt="hotlistHit" className="h-20 object-contain rounded-md" />}
{isNPEDHitA && <img src={NPED_CAT_A} alt="hotlistHit" className="h-20 object-contain rounded-md" />}
{isNPEDHitB && <img src={NPED_CAT_B} alt="hotlistHit" className="h-20 object-contain rounded-md" />}
{isNPEDHitC && <img src={NPED_CAT_C} alt="hotlistHit" className="h-20 object-contain rounded-md" />}
</div>
{hotlistNames && (
<div className="flex flex-col border-b border-gray-600 mb-4">
<p className="text-gray-300">Hotlists</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-[90%] lg:gap-x-[15%] w-[50%]">
{hotlistNames.map((hotlistName, index) => (
<div className="items-center px-2.5 py-0.5 rounded-sm me-2 bg-amber-500 w-55 m-2" key={index}>
<p className="font-medium text-2xl break-all text-amber-800">
{hotlistName ? hotlistName?.replace(/\.csv$/i, "") : "-"}
</p>
</div>
))}
</div>
</div>
)}
<div className="flex flex-col lg:flex-row items-center gap-3">
<img
src={sighting?.overviewUrl}
alt="overview patch"
className="w-full h-56 sm:h-72 md:h-96 rounded-lg object-cover border border-gray-700"
/>
<aside className="w-full lg:w-80 bg-gray-800/70 text-white rounded-xl py-4 px-2 border h-[70%] border-gray-700">
<h3 className="text-base md:text-lg font-semibold pb-2 border-b border-gray-700">Vehicle Info</h3>
<dl className="mt-3 gap-x-4 gap-y-2 text-sm">
<div>
<dt className="text-gray-300">VRM</dt>
<dd className="font-medium text-2xl break-all">{sighting?.vrm ?? "-"}</dd>
</div>
<div>
<dt className="text-gray-300">Motion</dt>
<dd className="font-medium text-2xl">{sighting?.motion ?? "-"}</dd>
</div>
<div>
<dt className="text-gray-300">Seen Count</dt>
<dd className="font-medium text-2xl">{sighting?.seenCount ?? "-"}</dd>
</div>
{sighting?.make && (
<div>
<dt className="text-gray-300">Make</dt>
<dd className="font-medium text-2xl">{sighting?.make ?? "-"}</dd>
</div>
)}
{sighting?.model ||
(!sighting?.model.trim() && (
<div>
<dt className="text-gray-300">Model</dt>
<dd className="font-medium text-2xl">{sighting?.model ?? "-"}</dd>
</div>
))}
{sighting?.color && (
<div className="sm:col-span-2">
<dt className="text-gray-300">Colour</dt>
<dd className="font-medium text-2xl">{sighting?.color ?? "-"}</dd>
</div>
)}
<div>
<dt className="text-gray-300">Time</dt>
<dd className="font-medium text-xl">{sighting?.timeStamp ?? "-"}</dd>
</div>
</dl>
</aside>
</div>
<div className="mt-3 flex-col-reverse gap-3 md:flex-row md:justify-center hidden md:flex">
{onDelete ? (
<button
className="inline-flex items-center justify-center gap-2 rounded-lg px-5 py-3 bg-blue-600 text-white hover:bg-blue-700 w-full md:w-full"
onClick={handleClose}
>
<FontAwesomeIcon icon={faX} />
Close
</button>
) : (
<button
className="inline-flex items-center justify-center gap-2 rounded-lg px-5 py-3 bg-green-600 text-white hover:bg-green-700 w-full md:w-full"
onClick={handleAcknowledgeButton}
>
<FontAwesomeIcon icon={faCheck} />
Acknowledge
</button>
)}
{onDelete ? (
<button
className="inline-flex items-center justify-center gap-2 rounded-lg px-5 py-3 bg-red-600 text-white hover:bg-red-700 w-full md:w-full"
onClick={() => handleDeleteClick(sighting)}
>
<FontAwesomeIcon icon={faTrash} />
Delete
</button>
) : (
<button
className="inline-flex items-center justify-center gap-2 rounded-lg px-5 py-3 bg-red-600 text-white hover:bg-red-700 w-full md:w-full"
onClick={handleClose}
>
<FontAwesomeIcon icon={faX} />
Deny
</button>
)}
</div>
</div>
</ModalComponent>
<Toaster />
</>
);
};
export default SightingModal;

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

@@ -1,19 +1,13 @@
import { useCallback, useRef, useState } from "react";
import { BLANK_IMG } from "../../utils/utils";
import SightingWidgetDetails from "../SightingsWidget/SightingWidgetDetails";
import { useOverviewOverlay } from "../../hooks/useOverviewOverlay";
import { useSightingFeedContext } from "../../context/SightingFeedContext";
import { useHiDPICanvas } from "../../hooks/useHiDPICanvas";
import NavigationArrow from "../UI/NavigationArrow";
import { useSwipeable } from "react-swipeable";
import { useNavigate } from "react-router";
import NumberPlate from "../PlateStack/NumberPlate";
import Loading from "../UI/Loading";
const SightingOverview = () => {
const navigate = useNavigate();
const handlers = useSwipeable({
onSwipedRight: () => navigate("/front-camera-settings"),
trackMouse: true,
});
const [overlayMode, setOverlayMode] = useState<0 | 1 | 2>(0);
const imgRef = useRef<HTMLImageElement | null>(null);
@@ -23,50 +17,61 @@ const SightingOverview = () => {
setOverlayMode((m) => ((m + 1) % 3) as 0 | 1 | 2);
}, []);
const { effectiveSelected, side, mostRecent, noSighting, isPending } =
useSightingFeedContext();
const { side, mostRecent, isError, isLoading } = useSightingFeedContext();
useOverviewOverlay(mostRecent, overlayMode, imgRef, canvasRef);
const { sync } = useHiDPICanvas(imgRef, canvasRef);
if (noSighting || isPending) return <p>loading</p>;
return (
<div className="mt-2 grid gap-3">
<div className="inline-block w-[90%] mx-auto" {...handlers}>
if (isLoading)
return (
<div className="h-150 flex items-center justify-center">
<NavigationArrow side={side} />
<div className="relative aspect-[1280/800]">
<img
ref={imgRef}
onLoad={() => {
sync();
setOverlayMode((m) => m);
}}
src={mostRecent?.overviewUrl || BLANK_IMG}
alt="overview"
className="absolute inset-0 w-full h-full object-contain cursor-pointer z-10"
onClick={onOverviewClick}
style={{
display: mostRecent?.overviewUrl ? "block" : "none",
}}
/>
<canvas
ref={canvasRef}
className="absolute inset-0 w-full h-full object-contain z-20 pointer-events-none"
/>
</div>
<Loading message="Loading" />
</div>
);
<SightingWidgetDetails effectiveSelected={effectiveSelected} />
if (isError) return;
<div className="h-100 flex items-center justify-center text-red-500 text-lg">
An error occurred. Cannot display footage.
</div>;
<div className="text-xs opacity-80">
Overlay:{" "}
{overlayMode === 0
? "Off"
: overlayMode === 1
? "Plate box"
: "Track + box"}{" "}
(click image to toggle)
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 (
<div className="flex flex-col md:flex-row">
<NavigationArrow side={side} />
<div className="w-full">
{mostRecent && (
<div className="absolute inset-0 z-50 px-1 pt-2">
<NumberPlate vrm={mostRecent?.vrm} size="sm" />
</div>
)}
<img
ref={imgRef}
onLoad={() => {
sync();
setOverlayMode((m) => m);
}}
src={mostRecent?.overviewUrl || BLANK_IMG}
alt="overview"
className="absolute inset-0 object-contain cursor-pointer z-10 min-h-[100%] rounded-lg"
onClick={onOverviewClick}
style={{
display: mostRecent?.overviewUrl ? "block" : "none",
}}
/>
<canvas
ref={canvasRef}
className="absolute inset-0 object-contain z-20 pointer-events-none "
/>
</div>
</div>
);

View File

@@ -0,0 +1,24 @@
import type { SightingType } from "../../types/types";
import { capitalize, formatAge } from "../../utils/utils";
type InfoBarprops = {
obj: SightingType;
};
const InfoBar = ({ obj }: InfoBarprops) => {
return (
<div className="flex items-center gap-3 text-xs bg-neutral-900 px-2 py-1 rounded justify-between">
<div className="flex items-center gap-3 text-xs">
<div className="min-w-14">CH: {obj ? obj.charHeight : "—"}</div>
<div className="min-w-14">Seen: {obj ? obj.seenCount : "—"}</div>
<div className="min-w-20">{obj ? capitalize(obj.motion) : "—"}</div>
<div className="min-w-14 opacity-80 text-md">
{obj ? formatAge(obj.timeStampMillis) : "—"}
</div>
</div>
<div className="min-w-14 opacity-80 "></div>
</div>
);
};
export default InfoBar;

View File

@@ -1,11 +1,24 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import type { SightingWidgetType } from "../../types/types";
import { BLANK_IMG, capitalize, formatAge } from "../../utils/utils";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { HitKind, QueuedHit, ReducedSightingType, SightingType } from "../../types/types";
import { BLANK_IMG, getSoundFileURL } from "../../utils/utils";
import NumberPlate from "../PlateStack/NumberPlate";
import Card from "../UI/Card";
import CardHeader from "../UI/CardHeader";
import clsx from "clsx";
import { useSightingFeedContext } from "../../context/SightingFeedContext";
import SightingModal from "../SightingModal/SightingModal";
import { useAlertHitContext } from "../../context/AlertHitContext";
import HotListImg from "/Hotlist_Hit.svg";
import NPED_CAT_A from "/NPED_Cat_A.svg";
import NPED_CAT_B from "/NPED_Cat_B.svg";
import NPED_CAT_C from "/NPED_Cat_C.svg";
import popup from "../../assets/sounds/ui/popup_open.mp3";
import notification from "../../assets/sounds/ui/notification.mp3";
import { useSound } from "react-sounds";
import { useIntegrationsContext } from "../../context/IntegrationsContext";
import { useSoundContext } from "../../context/SoundContext";
import Loading from "../UI/Loading";
import { checkIsHotListHit, getNPEDCategory } from "../../utils/utils";
function useNow(tickMs = 1000) {
const [, setNow] = useState(() => Date.now());
@@ -13,114 +26,217 @@ function useNow(tickMs = 1000) {
const id = setInterval(() => setNow(Date.now()), tickMs);
return () => clearInterval(id);
}, [tickMs]);
return undefined;
return null;
}
export type SightingHistoryProps = {
baseUrl: string;
entries?: number; // number of rows to show
pollMs?: number; // poll frequency
type SightingHistoryProps = {
baseUrl?: string;
entries?: number;
pollMs?: number;
autoSelectLatest?: boolean;
title: string;
className?: string;
};
type SightingHistoryWidgetProps = React.HTMLAttributes<HTMLDivElement>;
export default function SightingHistoryWidget({
className,
}: SightingHistoryWidgetProps) {
export default function SightingHistoryWidget({ className, title }: SightingHistoryProps) {
const [modalQueue, setModalQueue] = useState<QueuedHit[]>([]);
useNow(1000);
const { sightings, selectedRef, setSelectedRef } = useSightingFeedContext();
const { state } = useSoundContext();
const soundSrcNped = useMemo(() => {
if (state?.NPEDsound?.includes(".mp3") || state.NPEDsound?.includes(".wav")) {
const file = state.soundOptions?.find((item) => item.name === state.NPEDsound);
return file?.soundUrl ?? popup;
}
return getSoundFileURL(state.NPEDsound) ?? popup;
}, [state.NPEDsound, state.soundOptions]);
const soundSrcHotlist = useMemo(() => {
if (state?.hotlistSound?.includes(".mp3") || state.hotlistSound?.includes(".wav")) {
const file = state.soundOptions?.find((item) => item.name === state.hotlistSound);
return file?.soundUrl ?? notification;
}
return getSoundFileURL(state?.hotlistSound) ?? notification;
}, [state.hotlistSound, state.soundOptions]);
const { play: npedSound } = useSound(soundSrcNped, { volume: state.NPEDsoundVolume });
const { play: hotlistsound } = useSound(soundSrcHotlist, { volume: state.hotlistSoundVolume });
const {
sightings,
setSelectedSighting,
setSightingModalOpen,
isSightingModalOpen,
selectedSighting,
mostRecent,
isLoading,
} = useSightingFeedContext();
const { dispatch } = useAlertHitContext();
const { state: integrationState, dispatch: integrationDispatch } = useIntegrationsContext();
const sessionStarted = integrationState.sessionStarted;
const sessionPaused = integrationState.sessionPaused;
const processedRefs = useRef<Set<number | string>>(new Set());
const hasAutoOpenedRef = useRef(false);
const npedRef = useRef(false);
const enqueue = useCallback((sighting: SightingType, kind: HitKind) => {
const id = sighting.vrm ?? sighting.ref;
if (processedRefs.current.has(id)) return;
processedRefs.current.add(id);
setModalQueue((q) => [...q, { id, sighting, kind }]);
}, []);
const reduceObject = (obj: SightingType): ReducedSightingType => {
return {
vrm: obj.vrm,
metadata: obj?.metadata,
};
};
useEffect(() => {
if (sessionStarted) {
if (!mostRecent) return;
if (sessionPaused) return;
const reducedMostRecent = reduceObject(mostRecent);
integrationDispatch({ type: "ADD", payload: reducedMostRecent });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mostRecent, sessionStarted]);
const onRowClick = useCallback(
(ref: number) => {
setSelectedRef(ref);
(sighting: SightingType) => {
if (!sighting) return;
setSightingModalOpen(true);
setSelectedSighting(sighting);
},
[setSelectedRef]
[setSelectedSighting, setSightingModalOpen]
);
const rows = useMemo(
() => sightings?.filter(Boolean) as SightingWidgetType[],
[sightings]
);
const rows = useMemo(() => sightings?.filter(Boolean) as SightingType[], [sightings]);
useEffect(() => {
if (!rows?.length) return;
for (const sighting of rows) {
const id = sighting.vrm;
if (processedRefs.current.has(id)) continue;
const isHotlistHit = checkIsHotListHit(sighting);
const npedcategory = sighting?.metadata?.npedJSON?.["NPED CATEGORY"];
const isNPED = npedcategory === "A" || npedcategory === "B" || npedcategory === "C";
if (isNPED || isHotlistHit) {
enqueue(sighting, isNPED ? "NPED" : "HOTLIST"); // enqueue ONLY
}
}
}, [rows, enqueue]);
useEffect(() => {
rows?.forEach((obj) => {
const cat = getNPEDCategory(obj);
const isNPEDHitA = cat === "A";
const isNPEDHitB = cat === "B";
const isNPEDHitC = cat === "C";
if (isNPEDHitA || isNPEDHitB || isNPEDHitC) {
dispatch({
type: "ADD",
payload: obj,
});
}
});
}, [dispatch]);
useEffect(() => {
if (hasAutoOpenedRef.current || npedRef.current) return;
const firstNPED = rows.find((r) => {
const cat = getNPEDCategory(r);
const isNPEDHitA = cat === "A";
const isNPEDHitB = cat === "B";
const isNPEDHitC = cat === "C";
return isNPEDHitA || isNPEDHitB || isNPEDHitC;
});
const firstHot = rows?.find((r) => {
const isHotListHit = checkIsHotListHit(r);
return isHotListHit;
});
if (firstNPED) {
enqueue(firstNPED, "NPED");
npedRef.current = true;
}
if (firstHot) {
enqueue(firstHot, "HOTLIST");
hasAutoOpenedRef.current = true;
}
}, [enqueue, hotlistsound, npedSound, rows, setSelectedSighting, setSightingModalOpen]);
useEffect(() => {
if (!isSightingModalOpen && modalQueue.length > 0) {
const next = modalQueue[0];
if (next.kind === "NPED") npedSound();
else hotlistsound();
setSelectedSighting(next.sighting);
setSightingModalOpen(true);
}
}, [isSightingModalOpen, npedSound, hotlistsound, setSelectedSighting, setSightingModalOpen, modalQueue]);
const handleClose = () => {
setSightingModalOpen(false);
setModalQueue((q) => q.slice(1));
};
return (
<Card className={clsx("overflow-y-auto h-100", className)}>
<CardHeader title="Front Camera Sightings" />
<div className="flex flex-col gap-3 ">
{/* Rows */}
<div className="flex flex-col">
{rows?.map((obj, idx) => {
console.log(obj);
const isNPEDHit = obj?.metadata?.npedJSON?.status_code === 201;
const isSelected = obj?.ref === selectedRef;
const motionAway = (obj?.motion ?? "").toUpperCase() === "AWAY";
const primaryIsColour = obj?.srcCam === 1;
const secondaryMissing = (obj?.vrmSecondary ?? "") === "";
return (
<div
key={idx}
className={`border border-neutral-700 rounded-md mb-2 p-2 cursor-pointer ${
isSelected ? "ring-2 ring-blue-400" : ""
}`}
onClick={() => onRowClick(obj.ref)}
>
{/* Info bar */}
<div className="flex items-center gap-3 text-xs bg-neutral-900 px-2 py-1 rounded">
<div className="min-w-14">
CH: {obj ? obj.charHeight : "—"}
</div>
<div className="min-w-14">
Seen: {obj ? obj.seenCount : "—"}
</div>
<div className="min-w-20">
{obj ? capitalize(obj.motion) : "—"}
</div>
<div className="min-w-14 opacity-80">
{obj ? formatAge(obj.timeStampMillis) : "—"}
</div>
</div>
{/* Patch row */}
<>
<Card className={clsx("overflow-y-auto min-h-[40vh] md:min-h-[60vh] max-h-[80vh] lg:w-[40%] p-4", className)}>
<CardHeader title={title} />
<div className="flex flex-col gap-3 ">
{isLoading && (
<div className="my-50 h-[50%]">
<Loading message="Loading Sightings" />
</div>
)}
{/* Rows */}
<div className="flex flex-col">
{rows?.map((obj) => {
const cat = getNPEDCategory(obj);
const isNPEDHitA = cat === "A";
const isNPEDHitB = cat === "B";
const isNPEDHitC = cat === "C";
const motionAway = (obj?.motion ?? "").toUpperCase() === "AWAY";
const isHotListHit = checkIsHotListHit(obj);
return (
<div
className={`flex items-center gap-3 mt-2
${isNPEDHit ? "border border-red-600" : ""}
`}
key={obj.ref}
className={`border border-gray-700 rounded-md mb-2 p-2 cursor-pointer `}
onClick={() => onRowClick(obj)}
>
<div
className={`border p-1 ${
primaryIsColour ? "" : "ring-2 ring-lime-400"
} ${!obj ? "opacity-30" : ""}`}
>
<img
src={obj?.plateUrlInfrared || BLANK_IMG}
height={48}
alt="infrared patch"
className={!primaryIsColour ? "" : "opacity-60"}
/>
<div className={`flex items-center gap-3 mt-2 justify-between `}>
<div className={`border p-1 `}>
<img src={obj?.plateUrlColour || BLANK_IMG} height={48} width={200} alt="colour patch" />
</div>
{isHotListHit && (
<img src={HotListImg} alt="hotlistHit" className="h-20 object-contain rounded-md" />
)}
{isNPEDHitA && <img src={NPED_CAT_A} alt="hotlistHit" className="h-20 object-contain rounded-md" />}
{isNPEDHitB && <img src={NPED_CAT_B} alt="hotlistHit" className="h-20 object-contain rounded-md" />}
{isNPEDHitC && <img src={NPED_CAT_C} alt="hotlistHit" className="h-20 object-contain rounded-md" />}
<NumberPlate motion={motionAway} vrm={obj?.vrm} />
</div>
<div
className={`border p-1 ${
primaryIsColour ? "ring-2 ring-lime-400" : ""
} ${
secondaryMissing && primaryIsColour
? "opacity-30 grayscale"
: ""
}`}
>
<img
src={obj?.plateUrlColour || BLANK_IMG}
height={48}
alt="colour patch"
className={primaryIsColour ? "" : "opacity-60"}
/>
</div>
<NumberPlate motion={motionAway} vrm={obj?.vrm} />
</div>
</div>
);
})}
);
})}
</div>
</div>
</div>
</Card>
</Card>
<SightingModal isSightingModalOpen={isSightingModalOpen} handleClose={handleClose} sighting={selectedSighting} />
</>
);
}

View File

@@ -1,78 +1,46 @@
import type { SightingWidgetType } from "../../types/types";
import type { SightingType } from "../../types/types";
type SightingWidgetDetailsProps = {
effectiveSelected: SightingWidgetType | null;
effectiveSelected: SightingType | null;
};
const SightingWidgetDetails = ({
effectiveSelected,
}: SightingWidgetDetailsProps) => {
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-sm">
<div>
VRM:{" "}
<span className="opacity-90">{effectiveSelected?.vrm ?? "—"}</span>
<>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-sm">
{effectiveSelected?.vrm && (
<div>
VRM:{" "}
<span className="opacity-90">{effectiveSelected?.vrm ?? "—"}</span>
</div>
)}
{effectiveSelected?.make !== "" && (
<div>
Make:{" "}
<span className="opacity-90">{effectiveSelected?.make ?? "—"}</span>
</div>
)}
{effectiveSelected?.model.trim() !== "" && (
<div>
Model:{" "}
<span className="opacity-90">
{effectiveSelected?.model ?? "—"}
</span>
</div>
)}
{effectiveSelected?.color !== "" && (
<div>
Colour:{" "}
<span className="opacity-90">
{effectiveSelected?.color ?? "—"}
</span>
</div>
)}
</div>
<div>
Timestamp:{" "}
<span className="opacity-90">{effectiveSelected?.timeStamp ?? "—"}</span>
</div>
<div>
Make:{" "}
<span className="opacity-90">{effectiveSelected?.make ?? "—"}</span>
</div>
<div>
Model:{" "}
<span className="opacity-90">{effectiveSelected?.model ?? "—"}</span>
</div>
<div>
Country:{" "}
<span className="opacity-90">{effectiveSelected?.countryCode ?? "—"}</span>
</div>
<div>
Seen:{" "}
<span className="opacity-90">
{effectiveSelected?.seenCount ?? "—"}
</span>
</div>
<div>
Colour:{" "}
<span className="opacity-90">{effectiveSelected?.color ?? "—"}</span>
</div>
<div>
Category:{" "}
<span className="opacity-90">{effectiveSelected?.category ?? "—"}</span>
</div>
<div>
Char Ht:{" "}
<span className="opacity-90">
{effectiveSelected?.charHeight ?? "—"}
</span>
</div>
<div>
Plate Size:{" "}
<span className="opacity-90">
{effectiveSelected?.plateSize ?? "—"}
</span>
</div>
<div>
Overview Size:{" "}
<span className="opacity-90">
{effectiveSelected?.overviewSize ?? "—"}
</span>
</div>
{effectiveSelected?.detailsUrl ? (
<div className="col-span-half">
<a
href={effectiveSelected.detailsUrl}
target="_blank"
className="underline text-blue-300"
>
Sighting Details
</a>
</div>
) : null}
</div>
</>
);
};

View File

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

View File

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

View File

@@ -1,29 +1,38 @@
import type { IconProp } from "@fortawesome/fontawesome-svg-core";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import clsx from "clsx";
import NumberPlate from "../PlateStack/NumberPlate";
import type { SightingType } from "../../types/types";
type CameraOverviewHeaderProps = {
title: string;
title?: string;
icon?: IconProp;
img?: string;
sighting?: SightingType | null;
};
const CardHeader = ({ title, icon, img }: CameraOverviewHeaderProps) => {
const CardHeader = ({
title,
icon,
img,
sighting,
}: CameraOverviewHeaderProps) => {
return (
<div
className={clsx(
"w-full border-b border-gray-600 flex flex-row items-center md:mb-6"
"w-full border-b border-gray-600 flex flex-row items-center space-x-2 md:mb-6 relative justify-between"
)}
>
<div className="flex items-center space-x-2">
{icon && <FontAwesomeIcon icon={icon} className="size-4" />}
<h2 className="text-xl">{title}</h2>
</div>
{img && <img src={img} alt="Logo" width={100} height={50} className="ml-auto" />}
{img && (
<img src={img} alt="Logo" width={100} height={50} className="ml-auto" />
)}
{sighting?.vrm && <NumberPlate vrm={sighting.vrm} motion={false} />}
</div>
);
};
export default CardHeader;

View File

@@ -4,9 +4,11 @@ import { Outlet } from "react-router";
const Container = () => {
return (
<div>
<div className="min-h-screen">
<Header />
<Outlet />
<div className="min-h-screen">
<Outlet />
</div>
<Footer />
</div>
);

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

@@ -1,81 +1,72 @@
import * as React from "react";
import { Link } from "react-router";
import Logo from "/MAV.svg";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faGear, faListCheck } from "@fortawesome/free-solid-svg-icons";
import type { VersionFieldType } from "../../types/types";
async function fetchVersions(signal?: AbortSignal): Promise<VersionFieldType> {
const res = await fetch("http://192.168.75.11/api/versions", { signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
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}`;
}
import { faGear, faHome, faListCheck, faMaximize, faMinimize, faRotate } from "@fortawesome/free-solid-svg-icons";
import { useState } from "react";
import SoundBtn from "./SoundBtn";
import { useIntegrationsContext } from "../../context/IntegrationsContext";
export default function Header() {
const [offsetMs, setOffsetMs] = React.useState<number | null>(null);
const [nowMs, setNowMs] = React.useState<number>(Date.now());
const [isFullscreen, setIsFullscreen] = useState(false);
const { state } = useIntegrationsContext();
React.useEffect(() => {
const ac = new AbortController();
fetchVersions(ac.signal)
.then((data) => {
const serverMs = normalizeToMs(data.timeStamp);
setOffsetMs(serverMs - Date.now());
})
return () => ac.abort();
}, []);
const sessionStarted = state.sessionStarted;
React.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 sessionPaused = state.sessionPaused;
const serverNowMs = offsetMs == null ? nowMs : nowMs + offsetMs;
const localStr = formatFromMs(serverNowMs, "local");
const utcStr = formatFromMs(serverNowMs, "utc");
const toggleFullscreen = () => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen();
setIsFullscreen(true);
} else {
document.exitFullscreen();
setIsFullscreen(false);
}
};
const refreshBrowser = () => {
window.location.reload();
};
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-row justify-between">
{/* Left: Logo */}
<div className="w-30">
<div className="relative bg-[#253445] border-b border-gray-500 items-center mx-auto sm:px-3 lg:px-4 py-4 flex flex-col md:flex-row justify-between mb-7 space-y-6 md:space-y-0">
<div className="w-28">
<Link to={"/"}>
<img src={Logo} alt="Logo" width={150} height={150} />
</Link>
</div>
{/* Right: Texts stacked + icons */}
<div className="flex items-center space-x-12">
<div className="flex flex-col leading-tight text-white text-sm tabular-nums">
<h2>Local: {localStr}</h2>
<h2>UTC: {utcStr}</h2>
<div className="flex flex-col lg:flex-row items-center space-x-24 justify-items-center">
<div className="flex flex-row lg:flex-row space-x-2">
{sessionStarted && sessionPaused ? (
<p className="text-gray-400 font-bold">Session Paused</p>
) : (
sessionStarted && <p className="text-green-400 font-bold">Session Active</p>
)}
</div>
<Link to={"/session-settings"}>
<FontAwesomeIcon className="text-white" icon={faListCheck} />
</Link>
<Link to={"/system-settings"}>
<FontAwesomeIcon className="text-white" icon={faGear} />
</Link>
<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">
{isFullscreen ? (
<FontAwesomeIcon icon={faMinimize} size="2x" />
) : (
<FontAwesomeIcon icon={faMaximize} size="2x" />
)}
</div>
<div onClick={refreshBrowser}>
<FontAwesomeIcon icon={faRotate} size="2x" />
</div>
<SoundBtn />
<Link to={"/session-settings"}>
<FontAwesomeIcon className="text-white" icon={faListCheck} size="2x" />
</Link>
<Link to={"/system-settings"}>
<FontAwesomeIcon className="text-white" icon={faGear} size="2x" />
</Link>
</div>
</div>
</div>
);

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

@@ -0,0 +1,23 @@
import type React from "react";
import Modal from "react-modal";
type ModalComponentProps = {
isModalOpen: boolean;
children: React.ReactNode;
close: () => void;
};
const ModalComponent = ({ isModalOpen, children, close }: ModalComponentProps) => {
return (
<Modal
isOpen={isModalOpen}
onRequestClose={close}
className="bg-[#1e2a38] p-6 rounded-lg shadow-lg max-w-[80%] mx-auto mt-[1%] md:w-[70%] md:h-[95%] z-[100] overflow-y-auto max-h-screen"
overlayClassName="fixed inset-0 bg-[#1e2a38]/70 flex justify-center items-start z-100"
>
{children}
</Modal>
);
};
export default ModalComponent;

View File

@@ -3,21 +3,21 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate } from "react-router";
type NavigationArrowProps = {
side: string;
side: string | undefined;
settingsPage?: boolean;
};
const NavigationArrow = ({ side, settingsPage }: NavigationArrowProps) => {
const navigate = useNavigate();
const navigationDest = (side: string) => {
const navigationDest = (side: string | undefined) => {
if (settingsPage) {
navigate("/");
return;
}
if (side === "Front") {
navigate("/front-camera-settings");
navigate("/camera-settings");
} else if (side === "Rear") {
navigate("/Rear-Camera-settings");
}
@@ -26,16 +26,18 @@ const NavigationArrow = ({ side, settingsPage }: NavigationArrowProps) => {
if (settingsPage) {
return (
<>
{side === "CameraFront" ? (
{side === "CameraA" ? (
<FontAwesomeIcon
size="2xl"
icon={faArrowRight}
className="absolute top-[50%] right-[2%] backdrop-blur-md hover:cursor-pointer animate-bounce"
onClick={() => navigationDest(side)}
className="absolute top-[50%] right-[2%] backdrop-blur-lg hover:cursor-pointer animate-bounce z-30"
onClick={() => navigationDest("Front")}
/>
) : (
<FontAwesomeIcon
icon={faArrowLeft}
className="absolute top-[50%] left-[2%] backdrop-blur-md hover:cursor-pointer animate-bounce"
size="2xl"
className="absolute top-[50%] left-[2%] backdrop-blur-md hover:cursor-pointer animate-bounce z-30"
onClick={() => navigationDest(side)}
/>
)}
@@ -44,19 +46,19 @@ const NavigationArrow = ({ side, settingsPage }: NavigationArrowProps) => {
}
return (
<>
{side === "Front" ? (
<FontAwesomeIcon
icon={faArrowLeft}
className="absolute top-[50%] left-[2%] backdrop-blur-md hover:cursor-pointer animate-bounce"
onClick={() => navigationDest(side)}
/>
) : (
<FontAwesomeIcon
icon={faArrowRight}
className="absolute top-[50%] right-[2%] backdrop-blur-md hover:cursor-pointer animate-bounce"
onClick={() => navigationDest(side)}
/>
)}
<FontAwesomeIcon
icon={faArrowLeft}
size="2xl"
className="absolute top-[50%] left-[2%] backdrop-blur-md hover:cursor-pointer animate-bounce z-100 "
onClick={() => navigationDest("Front")}
/>
<FontAwesomeIcon
icon={faArrowRight}
size="2xl"
className="absolute top-[50%] right-[2%] backdrop-blur-md hover:cursor-pointer animate-bounce z-100"
onClick={() => navigationDest("Rear")}
/>
</>
);
};

View File

@@ -0,0 +1,60 @@
import "rc-slider/assets/index.css";
import Slider from "rc-slider";
import { useSoundContext } from "../../context/SoundContext";
const SliderComponent = ({ soundCategory }: { soundCategory: "SIGHTINGVOLUME" | "NPEDVOLUME" | "HOTLISTVOLUME" }) => {
const { dispatch, state } = useSoundContext();
const getVolumeOption = (soundCategory: string) => {
if (soundCategory === "SIGHTINGVOLUME") {
return state.sightingVolume;
}
if (soundCategory === "NPEDVOLUME") {
return state.NPEDsoundVolume;
}
if (soundCategory === "HOTLISTVOLUME") {
return state.hotlistSoundVolume;
}
};
const volume = getVolumeOption(soundCategory);
const handleChange = (value: number | number[]) => {
const number = typeof value === "number" ? value : value[0];
dispatch({ type: soundCategory, payload: number });
};
return (
<div className="flex flex-row w-full lg:w-[40%] space-x-5">
<Slider
min={0}
max={1}
onChange={handleChange}
value={volume}
step={0.1}
styles={{
handle: {
width: "1.2rem",
height: "1.2rem",
marginTop: -7,
backgroundColor: "#3b82f6",
border: "2px solid white",
borderRadius: "50%",
boxShadow: "0 0 5px rgba(0, 0, 0, 0.2)",
},
track: {
backgroundColor: "#3b82f6",
height: 6,
},
rail: {
backgroundColor: "#e5e7eb",
height: 6,
},
}}
/>
<span>{volume ? volume * 10 : 1}</span>
</div>
);
};
export default SliderComponent;

View File

@@ -0,0 +1,34 @@
import { faVolumeHigh, faVolumeXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useSoundEnabled } from "react-sounds";
import { useCameraBlackboard } from "../../hooks/useCameraBlackboard";
import { useEffect } from "react";
const SoundBtn = () => {
const { mutation, query } = useCameraBlackboard();
const [enabled, setEnabled] = useSoundEnabled();
const handleClick = async () => {
const newEnabled = !enabled;
setEnabled(newEnabled);
await mutation.mutateAsync({
operation: "INSERT",
path: "soundEnabled",
value: { enabled: newEnabled },
});
};
useEffect(() => {
setEnabled(query?.data?.soundEnabled?.enabled);
}, [query?.data?.soundEnabled?.enabled, setEnabled]);
return (
<button onClick={handleClick}>
<FontAwesomeIcon
icon={enabled ? faVolumeHigh : faVolumeXmark}
size="2x"
/>
</button>
);
};
export default SoundBtn;

View File

@@ -0,0 +1,18 @@
import clsx from "clsx";
type VehicleSessionItemProps = {
sessionNumber: number;
textColour: string;
vehicleTag: string;
};
const VehicleSessionItem = ({ sessionNumber, textColour, vehicleTag }: VehicleSessionItemProps) => {
return (
<li className="rounded-xl border border-slate-800 bg-slate-800/60 p-3 shadow-sm flex flex-row justify-between items-center">
<p>{vehicleTag}</p>
<span className={`font-bold text-xl bg-slate-700 px-2 rounded-md ${clsx(textColour)}`}>{sessionNumber}</span>
</li>
);
};
export default VehicleSessionItem;

View File

@@ -0,0 +1,24 @@
import { createContext, useContext } from "react";
import type { AlertState, AlertPayload } from "../types/types";
type AlertHitContextValueType = {
state: AlertState;
action?: AlertPayload;
dispatch: React.Dispatch<AlertPayload>;
isLoading?: boolean;
isError?: boolean;
error?: Error | null;
};
export const AlertHitContext = createContext<
AlertHitContextValueType | undefined
>(undefined);
export const useAlertHitContext = () => {
const ctx = useContext(AlertHitContext);
if (!ctx)
throw new Error("useAlertHitContext must be used within <AlertHitContext>");
return ctx;
};
export default AlertHitContext;

View File

@@ -0,0 +1,14 @@
import { createContext, useContext, type ActionDispatch } from "react";
import type { NPEDACTION, NPEDSTATE } from "../types/types";
type IntegrationsValue = {
state: NPEDSTATE;
dispatch: ActionDispatch<[action: NPEDACTION]>;
};
export const IntegrationsContext = createContext<IntegrationsValue | undefined>(undefined);
export const useIntegrationsContext = () => {
const ctx = useContext(IntegrationsContext);
if (!ctx) throw new Error("useNPEDContext must be used within <IntegrationsProvider>");
return ctx;
};

View File

@@ -1,17 +0,0 @@
import { createContext, useContext, type SetStateAction } from "react";
import type { NPEDCameraConfig, NPEDUser } from "../types/types";
type UserContextValue = {
user: NPEDCameraConfig | null;
setUser: React.Dispatch<SetStateAction<NPEDUser | null>>;
};
export const NPEDUserContext = createContext<UserContextValue | undefined>(
undefined
);
export const useNPEDContext = () => {
const ctx = useContext(NPEDUserContext);
if (!ctx)
throw new Error("useNPEDContext must be used within <NPEDUserProvider>");
return ctx;
};

View File

@@ -0,0 +1,32 @@
import { createContext, useContext } from "react";
import type { SightingType } from "../types/types";
type SightingFeedContextType = {
sightings: (SightingType | null | undefined)[];
selectedRef: number | null;
setSelectedRef: (ref: number | null) => void;
// effectiveSelected: SightingType | null;
mostRecent: SightingType | null;
side: string | undefined;
selectedSighting: SightingType | null;
setSelectedSighting: (sighting: SightingType | SightingType | null) => void;
setSightingModalOpen: (isSightingModalOpen: boolean) => void;
isSightingModalOpen: boolean;
isError: boolean;
isLoading: boolean;
data: SightingType | undefined;
sessionStarted: boolean;
};
export const SightingFeedContext = createContext<
SightingFeedContextType | undefined
>(undefined);
export const useSightingFeedContext = () => {
const ctx = useContext(SightingFeedContext);
if (!ctx)
throw new Error(
"useSightingFeedContext must be used within SightingFeedProvider"
);
return ctx;
};

View File

@@ -1,26 +0,0 @@
import { createContext, useContext } from "react";
import type { SightingWidgetType } from "../types/types";
type SightingFeedContextType = {
sightings: (SightingWidgetType | null | undefined)[];
selectedRef: number | null;
setSelectedRef: (ref: number | null) => void;
effectiveSelected: SightingWidgetType | null;
mostRecent: SightingWidgetType | null;
side: string;
isPending: boolean;
noSighting: boolean;
};
export const SightingFeedContext = createContext<
SightingFeedContextType | undefined
>(undefined);
export const useSightingFeedContext = () => {
const ctx = useContext(SightingFeedContext);
if (!ctx)
throw new Error(
"useSightingFeedContext must be used within SightingFeedProvider"
);
return ctx;
};

View File

@@ -0,0 +1,16 @@
import { createContext, useContext, type Dispatch } from "react";
import type { SoundAction, SoundState } from "../types/types";
type SoundContextType = {
state: SoundState;
dispatch: Dispatch<SoundAction>;
audioArmed: boolean;
};
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

@@ -0,0 +1,38 @@
import { useEffect, useReducer, type ReactNode } from "react";
import AlertHitContext from "../AlertHitContext";
import { reducer, initalState } from "../reducers/AlertReducers";
import { useCameraBlackboard } from "../../hooks/useCameraBlackboard";
import type { SightingType } from "../../types/types";
type AlertHitProviderTypeProps = {
children: ReactNode;
};
export const AlertHitProvider = ({ children }: AlertHitProviderTypeProps) => {
const [state, dispatch] = useReducer(reducer, initalState);
const { query } = useCameraBlackboard();
useEffect(() => {
if (query.data) {
query?.data?.alertHistory?.forEach((element: SightingType) => {
dispatch({ type: "ADD", payload: element });
});
}
}, [query.data, query.error, query.isLoading]);
return (
<AlertHitContext.Provider
value={{
state,
dispatch,
isLoading: query.isLoading,
isError: query.isError,
error: query.error,
}}
>
{children}
</AlertHitContext.Provider>
);
};
export default AlertHitProvider;

View File

@@ -0,0 +1,36 @@
import { useEffect, useReducer, type ReactNode } from "react";
import { IntegrationsContext } from "../IntegrationsContext";
import { useCameraBlackboard } from "../../hooks/useCameraBlackboard";
import { initialState, reducer } from "../reducers/IntegrationsContextReducer";
type IntegrationsProviderType = {
children: ReactNode;
};
export const IntegrationsProvider = ({ children }: IntegrationsProviderType) => {
const [state, dispatch] = useReducer(reducer, initialState);
const { mutation } = useCameraBlackboard();
useEffect(() => {
const fetchData = async () => {
const result = await mutation.mutateAsync({
operation: "VIEW",
path: "sessionStats",
});
if (!result.result || typeof result.result === "string") return;
dispatch({ type: "UPDATE", payload: result?.result });
};
fetchData();
}, []);
return (
<IntegrationsContext.Provider
value={{
state,
dispatch,
}}
>
{children}
</IntegrationsContext.Provider>
);
};

View File

@@ -1,17 +0,0 @@
import { useState, type ReactNode } from "react";
import type { NPEDUser } from "../../types/types";
import { NPEDUserContext } from "../NPEDUserContext";
type NPEDUserProviderType = {
children: ReactNode;
};
export const NPEDUserProvider = ({ children }: NPEDUserProviderType) => {
const [user, setUser] = useState<NPEDUser | null>(null);
return (
<NPEDUserContext.Provider value={{ user, setUser }}>
{children}
</NPEDUserContext.Provider>
);
};

View File

@@ -1,11 +1,11 @@
import type { ReactNode } from "react";
import { useState, type ReactNode } from "react";
import { useSightingFeed } from "../../hooks/useSightingFeed";
import { SightingFeedContext } from "../SightingFeedContext";
type SightingFeedProviderProps = {
url: string;
url?: string | undefined;
children: ReactNode;
side: string;
side?: string | undefined;
};
export const SightingFeedProvider = ({
@@ -17,23 +17,33 @@ export const SightingFeedProvider = ({
sightings,
selectedRef,
setSelectedRef,
effectiveSelected,
data,
isLoading,
isError,
setSelectedSighting,
selectedSighting,
mostRecent,
isPending,
noSighting,
sessionStarted,
} = useSightingFeed(url);
const [isSightingModalOpen, setSightingModalOpen] = useState(false);
return (
<SightingFeedContext.Provider
value={{
sightings,
selectedRef,
setSelectedRef,
effectiveSelected,
setSelectedSighting,
selectedSighting,
setSightingModalOpen,
isSightingModalOpen,
mostRecent,
isError,
isLoading,
side,
isPending,
noSighting,
data,
sessionStarted,
}}
>
{children}

Some files were not shown because too many files have changed in this diff Show More