131 Commits

Author SHA1 Message Date
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
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
125 changed files with 5581 additions and 5927 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"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <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" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MAV | In Car System</title> <title>MAV Mobile</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root" class="min-h-screen flex flex-col"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>

4308
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,8 @@
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview" "preview": "vite preview",
"type-check": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.0.0", "@fortawesome/fontawesome-svg-core": "^7.0.0",
@@ -20,12 +21,16 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"country-flag-icons": "^1.5.19", "country-flag-icons": "^1.5.19",
"formik": "^2.4.6", "formik": "^2.4.6",
"howler": "^2.2.4",
"rc-slider": "^11.1.9",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"react-modal": "^3.16.3", "react-modal": "^3.16.3",
"react-router": "^7.8.0", "react-router": "^7.8.0",
"react-sounds": "^1.0.25",
"react-swipeable": "^7.0.2", "react-swipeable": "^7.0.2",
"react-tabs": "^6.1.0", "react-tabs": "^6.1.0",
"react-use": "^17.6.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwindcss": "^4.1.11" "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 Container from "./components/UI/Container";
import Dashboard from "./pages/Dashboard"; import Dashboard from "./pages/Dashboard";
import { Route, Routes } from "react-router"; import { Navigate, Route, Routes } from "react-router";
import FrontCamera from "./pages/FrontCamera"; import FrontCamera from "./pages/FrontCamera";
import RearCamera from "./pages/RearCamera"; import RearCamera from "./pages/RearCamera";
import SystemSettings from "./pages/SystemSettings"; import SystemSettings from "./pages/SystemSettings";
import Session from "./pages/Session"; import Session from "./pages/Session";
import { NPEDUserProvider } from "./context/providers/NPEDUserContextProvider"; import { NPEDUserProvider } from "./context/providers/NPEDUserContextProvider";
import { AlertHitProvider } from "./context/providers/AlertHitProvider";
import { SoundProvider } from "react-sounds";
import SoundContextProvider from "./context/providers/SoundContextProvider";
function App() { function App() {
return ( return (
<SoundContextProvider>
<SoundProvider initialEnabled={true}>
<NPEDUserProvider> <NPEDUserProvider>
<AlertHitProvider>
<Routes> <Routes>
<Route path="/" element={<Container />}> <Route path="/" element={<Container />}>
<Route index element={<Dashboard />} /> <Route index element={<Dashboard />} />
<Route path="front-camera-settings" element={<FrontCamera />} /> <Route path="camera-settings" element={<FrontCamera />} />
<Route path="rear-camera-settings" element={<RearCamera />} /> <Route path="rear-camera-settings" element={<RearCamera />} />
<Route path="system-settings" element={<SystemSettings />} /> <Route path="system-settings" element={<SystemSettings />} />
<Route path="session-settings" element={<Session />} /> <Route path="session-settings" element={<Session />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route> </Route>
</Routes> </Routes>
</AlertHitProvider>
</NPEDUserProvider> </NPEDUserProvider>
</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 { useGetOverviewSnapshot } from "../../hooks/useGetOverviewSnapshot";
import type { ZoomInOptions } from "../../types/types";
import NavigationArrow from "../UI/NavigationArrow"; import NavigationArrow from "../UI/NavigationArrow";
import { useCameraZoom } from "../../hooks/useCameraZoom";
import { useEffect } from "react";
import Loading from "../UI/Loading";
import ErrorState from "../UI/ErrorState";
type SnapshotContainerProps = { type SnapshotContainerProps = {
side: string; side: string;
settingsPage?: boolean; settingsPage?: boolean;
zoomLevel?: number;
onZoomLevelChange?: (level: number) => void;
}; };
export const SnapshotContainer = ({ export const SnapshotContainer = ({
side, side,
settingsPage, settingsPage,
zoomLevel,
onZoomLevelChange,
}: SnapshotContainerProps) => { }: SnapshotContainerProps) => {
const { canvasRef } = 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 ( return (
<div className="relative w-full aspect-video"> <div className="flex flex-col md:flex-row">
<NavigationArrow side={side} settingsPage={settingsPage} /> <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> </div>
); );
}; };

View File

@@ -1,48 +1,109 @@
import { Formik, Field, Form } from "formik"; import { Formik, Field, Form } from "formik";
import type { import type { CameraConfig, CameraSettingErrorValues, CameraSettingValues, ZoomInOptions } from "../../types/types";
CameraSettingErrorValues, import { useEffect, useMemo, useState } from "react";
CameraSettingValues, import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
} from "../../types/types"; import { faEye, faEyeSlash } from "@fortawesome/free-regular-svg-icons";
import { toast } from "sonner"; import CardHeader from "../UI/CardHeader";
import { useCameraZoom } from "../../hooks/useCameraZoom";
import { parseRTSPUrl } from "../../utils/utils";
const CameraSettingFields = () => { type CameraSettingsProps = {
const initialValues: CameraSettingValues = { initialData: CameraConfig;
friendlyName: "", updateCameraConfig: (values: CameraSettingValues) => Promise<void> | void;
cameraAddress: "", zoomLevel?: number;
userName: "", onZoomLevelChange?: (level: number) => void;
password: "", updateCameraConfigError: null | Error;
setupCamera: 1,
}; };
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 validateValues = (values: CameraSettingValues) => {
const errors: CameraSettingErrorValues = {}; const errors: CameraSettingErrorValues = {};
if (!values.friendlyName) errors.friendlyName = "Required"; if (!values.friendlyName) errors.friendlyName = "Required";
if (!values.cameraAddress) errors.cameraAddress = "Required"; if (!values.cameraAddress) errors.cameraAddress = "Required";
if (!values.userName) errors.userName = "Required";
if (!values.password) errors.password = "Required";
return errors; return errors;
}; };
const handleSubmit = (values: CameraSettingValues) => { const handleSubmit = (values: CameraSettingValues) => {
// post values to endpoint updateCameraConfig(values);
toast("Settings Saved");
}; };
const handleRadioButtonChange = async (levelNumber: number) => {
if (!onZoomLevelChange || !zoomLevel) return;
onZoomLevelChange(levelNumber);
const zoomInOptions: ZoomInOptions = {
camera: cameraControllerSide,
multiplier: levelNumber,
};
mutation.mutate(zoomInOptions);
};
const selectedZoom = zoomLevel ?? 1;
return ( return (
<Formik <Formik
initialValues={initialValues} initialValues={initialValues}
onSubmit={handleSubmit} onSubmit={handleSubmit}
validate={validateValues} validate={validateValues}
validateOnChange={false} validateOnChange={false}
enableReinitialize
> >
{({ errors, touched, setFieldValue }) => ( {({ errors, touched }) => (
<Form className="flex flex-col space-y-4 p-2"> <Form className="flex flex-col space-y-6 p-2">
<div className="flex flex-col space-y-2 relative"> <div className="flex flex-col space-y-2 relative">
<label htmlFor="friendlyName">Friendly Name</label> <label htmlFor="friendlyName">Name</label>
{touched.friendlyName && errors.friendlyName && ( {touched.friendlyName && errors.friendlyName && (
<small className="absolute right-0 top-0 text-red-500"> <small className="absolute right-0 top-0 text-red-500">{errors.friendlyName}</small>
{errors.friendlyName}
</small>
)} )}
<Field <Field
id="friendlyName" id="friendlyName"
@@ -50,33 +111,14 @@ const CameraSettingFields = () => {
type="text" type="text"
className="p-2 border border-gray-400 rounded-lg" className="p-2 border border-gray-400 rounded-lg"
placeholder="Enter camera name" placeholder="Enter camera name"
disabled
/> />
</div> </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"> <div className="flex flex-col space-y-2 relative">
<label htmlFor="cameraAddress">Camera Address</label> <label htmlFor="cameraAddress">Camera Address</label>
{touched.cameraAddress && errors.cameraAddress && ( {touched.cameraAddress && errors.cameraAddress && (
<small className="absolute right-0 top-0 text-red-500"> <small className="absolute right-0 top-0 text-red-500">{errors.cameraAddress}</small>
{errors.cameraAddress}
</small>
)} )}
<Field <Field
id="cameraAddress" id="cameraAddress"
@@ -84,16 +126,14 @@ const CameraSettingFields = () => {
type="text" type="text"
className="p-2 border border-gray-400 rounded-lg" className="p-2 border border-gray-400 rounded-lg"
placeholder="RTSP://..." placeholder="RTSP://..."
autoComplete="street-address" disabled
/> />
</div> </div>
<div className="flex flex-col space-y-2 relative"> <div className="flex flex-col space-y-2 relative">
<label htmlFor="userName">User Name</label> <label htmlFor="userName">User Name</label>
{touched.userName && errors.userName && ( {touched.userName && errors.userName && (
<small className="absolute right-0 top-0 text-red-500"> <small className="absolute right-0 top-0 text-red-500">{errors.userName}</small>
{errors.userName}
</small>
)} )}
<Field <Field
id="userName" id="userName"
@@ -102,32 +142,74 @@ const CameraSettingFields = () => {
className="p-2 border border-gray-400 rounded-lg" className="p-2 border border-gray-400 rounded-lg"
placeholder="Enter user name" placeholder="Enter user name"
autoComplete="username" autoComplete="username"
disabled
/> />
</div> </div>
<div className="flex flex-col space-y-2 relative"> <div className="flex flex-col space-y-2 relative">
<label htmlFor="password">Password</label> <label htmlFor="password">Password</label>
{touched.password && errors.password && ( {touched.password && errors.password && (
<small className="absolute right-0 top-0 text-red-500"> <small className="absolute right-0 top-0 text-red-500">{errors.password}</small>
{errors.password}
</small>
)} )}
<div className="flex gap-2 items-center relative mb-4">
<Field <Field
id="password" id="password"
name="password" name="password"
type="password" type={showPwd ? "text" : "password"}
className="p-2 border border-gray-400 rounded-lg" className="p-2 border border-gray-400 rounded-lg w-full "
placeholder="Enter password" placeholder="Enter password"
autoComplete="new-password" disabled
/>
<FontAwesomeIcon
type="button"
className="absolute right-5 end-0"
onClick={() => setShowPwd((s) => !s)}
icon={showPwd ? faEyeSlash : faEye}
/> />
</div> </div>
<div className="my-3">
<CardHeader title="Zoom settings" />
<div className="mx-auto grid grid-cols-4 items-center">
{zoomOptions.map((zoom) => (
<div key={zoom} className="my-3">
<Field
type="radio"
name="zoom"
value={zoom.toString()}
checked={selectedZoom === zoom}
className="hidden peer"
id={`zoom${zoom}`}
onChange={() => handleRadioButtonChange(zoom)}
/>
<label
htmlFor={`zoom${zoom}`}
className="px-6 py-2 rounded-md border border-gray-300
peer-checked:border-2 peer-checked:border-blue-900
peer-checked:text-blue-600 peer-checked:bg-gray-100"
>
x{zoom}
</label>
</div>
))}
</div>
</div>
<div className="mt-3">
{updateCameraConfigError ? (
<button className="bg-red-500 text-white rounded-lg p-2 mx-auto h-[100%] w-full" disabled>
Retry
</button>
) : (
<button <button
type="submit" type="submit"
className="bg-blue-800 text-white rounded-lg p-2 mx-auto" className="bg-blue-700 text-white rounded-lg p-2 mx-auto h-[100%] w-full"
disabled
> >
Save settings {/* {isSubmitting ? "Saving" : "Save settings"} bg-[#26B170] */}
{"Disabled: Coming soon"}
</button> </button>
)}
</div>
</div>
</Form> </Form>
)} )}
</Formik> </Formik>

View File

@@ -1,14 +1,36 @@
import { useFetchCameraConfig } from "../../hooks/useCameraConfig";
import Card from "../UI/Card"; import Card from "../UI/Card";
import CardHeader from "../UI/CardHeader"; import CardHeader from "../UI/CardHeader";
import CameraSettingFields from "./CameraSettingFields"; import CameraSettingFields from "./CameraSettingFields";
import { faWrench } from "@fortawesome/free-solid-svg-icons"; import { faWrench } from "@fortawesome/free-solid-svg-icons";
const CameraSettings = ({ title }: { 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 ( return (
<Card> <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 h-full"> <div className="relative flex flex-col space-y-3">
<CardHeader title={title} icon={faWrench} /> <CardHeader title={title} icon={faWrench} />
<CameraSettingFields />
{
<CameraSettingFields
initialData={data}
updateCameraConfig={updateCameraConfig}
zoomLevel={zoomLevel}
onZoomLevelChange={onZoomLevelChange}
updateCameraConfigError={updateCameraConfigError}
/>
}
</div> </div>
</Card> </Card>
); );

View File

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

View File

@@ -1,23 +1,43 @@
import clsx from "clsx"; import clsx from "clsx";
import { SnapshotContainer } from "../CameraOverview/SnapshotContainer"; import { SnapshotContainer } from "../CameraOverview/SnapshotContainer";
import { faCamera } from "@fortawesome/free-regular-svg-icons";
import Card from "../UI/Card"; import Card from "../UI/Card";
import CardHeader from "../UI/CardHeader"; import { useNavigate, useLocation } from "react-router";
import { useSwipeable } from "react-swipeable";
const OverviewVideoContainer = ({ const OverviewVideoContainer = ({
title,
side, side,
settingsPage, settingsPage,
zoomLevel,
onZoomLevelChange,
}: { }: {
title: string; title: string;
side: string; side: string;
settingsPage?: boolean; settingsPage?: boolean;
zoomLevel?: number;
onZoomLevelChange?: (level: number) => void;
}) => { }) => {
const navigate = useNavigate();
const 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 ( return (
<Card className={clsx("min-h-[40vh] md:min-h-[60vh] h-auto")}> <Card className={clsx("relative min-h-[40vh] md:min-h-[60vh] max-h-[80vh] lg:w-[70%] overflow-y-hidden")}>
<div className="relative flex flex-col space-y-3 h-full"> <div className="w-full" {...handlers}>
<CardHeader title={title} icon={faCamera} /> <SnapshotContainer
<SnapshotContainer side={side} settingsPage={settingsPage} /> side={side}
settingsPage={settingsPage}
zoomLevel={zoomLevel}
onZoomLevelChange={onZoomLevelChange}
/>
</div> </div>
</Card> </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 = { type NumberPlateProps = {
vrm?: string | undefined; vrm?: string | undefined;
motion?: boolean; 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 ( return (
<div <div
className={`relative w-[8rem] border-4 border-black rounded-lg text-nowrap className={`relative ${options.plateWidth} ${
text-black px-3 options.borderWidth
${motion ? "bg-yellow-400" : "bg-white"} } border-black rounded-xl text-nowrap
`} text-black px-6 py-2
${motion ? "bg-yellow-400" : "bg-white"}`}
> >
<div className=""> <div>
<div className="absolute inset-y-0 left-0 bg-blue-600 w-4 flex flex-col"> <div className="absolute inset-y-0 left-0 bg-blue-600 w-8 flex flex-col">
<GB /> <GB />
</div> </div>
<p className=" pl-2 font-extrabold text-right"> <p className={`pl-4 font-extrabold ${options.textSize} text-right`}>
{vrm && formatNumberPlate(vrm)} {vrm && formatNumberPlate(vrm)}
</p> </p>
</div> </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 CardHeader from "../UI/CardHeader";
import { faCamera } from "@fortawesome/free-regular-svg-icons"; import { faCamera } from "@fortawesome/free-regular-svg-icons";
import SightingOverview from "../SightingOverview/SightingOverview"; import SightingOverview from "../SightingOverview/SightingOverview";
import { useSightingFeedContext } from "../../context/SightingFeedContext";
type CardProps = React.HTMLAttributes<HTMLDivElement>; type CardProps = React.HTMLAttributes<HTMLDivElement>;
@@ -13,21 +14,14 @@ const RearCameraOverviewCard = ({ className }: CardProps) => {
const navigate = useNavigate(); const navigate = useNavigate();
const handlers = useSwipeable({ const handlers = useSwipeable({
onSwipedLeft: () => navigate("/rear-camera-settings"), onSwipedLeft: () => navigate("/rear-camera-settings"),
onSwipedDown: () => navigate("/system-settings"),
trackMouse: true, trackMouse: true,
}); });
const { mostRecent } = useSightingFeedContext();
return ( return (
<Card <Card className={clsx("relative min-h-[40vh] md:min-h-[60vh] h-auto", className)}>
className={clsx(
"relative min-h-[40vh] md:min-h-[60vh] h-auto",
className
)}
>
<div className="flex flex-col space-y-3 h-full" {...handlers}> <div className="flex flex-col space-y-3 h-full" {...handlers}>
<CardHeader title="Rear Overview" icon={faCamera} /> <CardHeader title="Rear Overview" icon={faCamera} sighting={mostRecent} />
<SightingOverview /> <SightingOverview />
{/* <SnapshotContainer side="TargetDetectionRear" /> */}
</div> </div>
</Card> </Card>
); );

View File

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

View File

@@ -1,24 +1,81 @@
import Card from "../UI/Card"; import Card from "../UI/Card";
import CardHeader from "../UI/CardHeader"; import CardHeader from "../UI/CardHeader";
import { useNPEDContext } from "../../context/NPEDUserContext";
import type { ReducedSightingType } from "../../types/types";
import { toast } from "sonner";
const SessionCard = () => { const SessionCard = () => {
const { sessionStarted, setSessionStarted, sessionList } = useNPEDContext();
const handleStartClick = () => {
setSessionStarted(!sessionStarted);
toast(`${sessionStarted ? "Vehicle tracking session Ended" : "Vehicle tracking session Started"}`);
};
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) => {
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);
return acc;
},
{
npedCatA: [],
npedCatB: [],
npedCatC: [],
npedCatD: [],
notTaxed: [],
notMOT: [],
}
);
return ( return (
<Card> <Card className="p-4 col-span-3">
<CardHeader title={"Session"} /> <CardHeader title="Session" />
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4 px-3">
<button <button
className="bg-[#26B170] text-white px-4 py-2 rounded hover:bg-green-700 transition w-full max-w-md" className={`${sessionStarted ? "bg-red-600" : "bg-[#26B170]"} text-white px-4 py-2 rounded ${
//onClick={() => handleModemSave(apn, username, password, authType)} sessionStarted ? "hover:bg-red-700" : "hover:bg-green-700"
} transition w-full`}
onClick={handleStartClick}
> >
Start Session {sessionStarted ? "End Session" : "Start Session"}
</button> </button>
<h2 className="text-white mb-2">Number of cars: </h2>
<h2 className="text-white mb-2">Cars without Tax: </h2> <ul className="text-white space-y-2">
<h2 className="text-white mb-2">Cars without MOT: </h2> <li className="rounded-xl border border-slate-800 bg-slate-800/60 p-3 shadow-sm flex flex-row justify-between">
<h2 className="text-white mb-2">Cars with NPED Cat A: </h2> <p>Number of Vehicles:</p>
<h2 className="text-white mb-2">Cars with NPED Cat B: </h2> <span className="font-bold text-green-600 text-xl">{dedupedSightings.length}</span>
<h2 className="text-white mb-2">Cars with NPED Cat C: </h2> </li>
<li className="rounded-xl border border-slate-800 bg-slate-800/60 p-3 shadow-sm flex flex-row justify-between">
<p>Vehicles without Tax:</p>
<span className="font-bold text-amber-600 text-xl">{vehicles.notTaxed.length}</span>
</li>
<li className="rounded-xl border border-slate-800 bg-slate-800/60 p-3 shadow-sm flex flex-row justify-between">
<p>Vehicles without MOT:</p>{" "}
<span className="font-bold text-red-500 text-xl">{vehicles.notMOT.length}</span>
</li>
<li className="rounded-xl border border-slate-800 bg-slate-800/60 p-3 shadow-sm flex flex-row justify-between">
<p>Vehicles with NPED Cat A:</p>
<span className="font-bold text-gray-300 text-xl">{vehicles.npedCatA.length}</span>
</li>
<li className="rounded-xl border border-slate-800 bg-slate-800/60 p-3 shadow-sm flex flex-row justify-between">
<p>Vehicles with NPED Cat B:</p>{" "}
<span className="font-bold text-gray-300text-xl">{vehicles.npedCatB.length}</span>
</li>
<li className="rounded-xl border border-slate-800 bg-slate-800/60 p-3 shadow-sm flex flex-row justify-between">
Vehicles with NPED Cat C:{" "}
<span className="font-bold text-gray-300 text-xl">{vehicles.npedCatC.length}</span>
</li>
</ul>
</div> </div>
</Card> </Card>
); );

View File

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

View File

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

View File

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

View File

@@ -1,21 +1,93 @@
import { Field, useFormikContext } from "formik"; import { Field, Form, Formik, useFormikContext } from "formik";
import FormGroup from "../components/FormGroup"; 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 = () => { 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 ( return (
<div className="flex flex-col space-y-2"> <Formik initialValues={initialValues} onSubmit={handleSubmit} enableReinitialize validate={validateValues}>
{({ errors, touched, isSubmitting }) => (
<Form>
<div className="flex flex-col space-y-2 px-2">
<FormGroup> <FormGroup>
<label htmlFor="backoffice" className="m-0"> <label htmlFor="backoffice" className="m-0">
Back Office URL Back Office URL
</label> </label>
<Field <Field
name={"backOfficeURL"} name={"backOfficeURL"}
type="text" type="text"
id="backoffice" id="backoffice"
placeholder="https://www.backoffice.com" placeholder="https://www.backoffice.com"
className="p-1.5 border border-gray-400 rounded-lg" className={`p-1.5 border ${
errors.backOfficeURL && touched.backOfficeURL ? "border-red-500" : "border-gray-400 "
} rounded-lg w-full md:w-60`}
/> />
</FormGroup> </FormGroup>
<FormGroup> <FormGroup>
@@ -25,26 +97,41 @@ const ChannelFields = () => {
type="text" type="text"
id="username" id="username"
placeholder="Back office username" placeholder="Back office username"
className="p-1.5 border border-gray-400 rounded-lg" className={`p-1.5 border ${
errors.username && touched.username ? "border-red-500" : "border-gray-400 "
} rounded-lg w-full md:w-60`}
/> />
</FormGroup> </FormGroup>
<FormGroup> <FormGroup>
<label htmlFor="password">Password</label> <label htmlFor="password">Password</label>
<div className="flex gap-2 items-center relative mb-4">
<Field <Field
name={"password"} name={"password"}
type="password" type={showPwd ? "text" : "password"}
id="password" id="password"
placeholder="Back office password" placeholder="Back office password"
className="p-1.5 border border-gray-400 rounded-lg" 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>
<FormGroup> <FormGroup>
<label htmlFor="connectTimeoutSeconds">Connect Timeout Seconds</label> <label htmlFor="connectTimeoutSeconds">Connect Timeout Seconds</label>
<Field <Field
name={"connectTimeoutSeconds"} name={"connectTimeoutSeconds"}
type="number" type="number"
id="connectTimeoutSeconds" id="connectTimeoutSeconds"
className="p-1.5 border border-gray-400 rounded-lg" className={`p-1.5 border ${
errors.connectTimeoutSeconds && touched.connectTimeoutSeconds ? "border-red-500" : "border-gray-400 "
} rounded-lg w-full md:w-60`}
/> />
</FormGroup> </FormGroup>
<FormGroup> <FormGroup>
@@ -54,10 +141,22 @@ const ChannelFields = () => {
type="number" type="number"
id="readTimeoutSeconds" id="readTimeoutSeconds"
placeholder="https://example.com" placeholder="https://example.com"
className="p-1.5 border border-gray-400 rounded-lg" className={`p-1.5 border ${
errors.readTimeoutSeconds && touched.readTimeoutSeconds ? "border-red-500" : "border-gray-400 "
} rounded-lg w-full md:w-60`}
/> />
</FormGroup> </FormGroup>
</div> </div>
<button
type="submit"
className="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 Card from "../../UI/Card";
import CardHeader from "../../UI/CardHeader"; import CardHeader from "../../UI/CardHeader";
import NPEDFields from "./NPEDFields"; import NPEDFields from "./NPEDFields";
import NPEDIcon from "/NPED.svg";
const NPEDCard = () => { const NPEDCard = () => {
return ( return (
<Card> <Card className="p-4">
<CardHeader title={"NPED Config"} img={"/NPED.jpg"} /> <CardHeader title={"NPED Config"} img={NPEDIcon} />
<NPEDFields /> <NPEDFields />
</Card> </Card>
); );

View File

@@ -3,15 +3,19 @@ import FormGroup from "../components/FormGroup";
import type { NPEDErrorValues, NPEDFieldType } from "../../../types/types"; import type { NPEDErrorValues, NPEDFieldType } from "../../../types/types";
import { useNPEDAuth } from "../../../hooks/useNPEDAuth"; import { useNPEDAuth } from "../../../hooks/useNPEDAuth";
import { toast } from "sonner"; import { toast } from "sonner";
import { faEyeSlash, faEye } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useState } from "react";
const NPEDFields = () => { const NPEDFields = () => {
const [showPwd, setShowPwd] = useState(false);
const { signIn, user, signOut } = useNPEDAuth(); const { signIn, user, signOut } = useNPEDAuth();
const initialValues = user const initialValues = user
? { ? {
username: user.propUsername.value, username: user?.propUsername?.value,
password: "", password: user?.propPassword?.value,
clientId: user.propClientID.value, clientId: user?.propClientID?.value,
frontId: "NPED", frontId: "NPED",
rearId: "NPED", rearId: "NPED",
} }
@@ -23,12 +27,11 @@ const NPEDFields = () => {
rearId: "NPED", rearId: "NPED",
}; };
const handleSubmit = (values: NPEDFieldType) => { const handleSubmit = async (values: NPEDFieldType) => {
const valuesToSend = { const valuesToSend = {
...values, ...values,
}; };
signIn(valuesToSend); await signIn(valuesToSend);
toast.success("Signed in successfully");
}; };
const validateValues = (values: NPEDFieldType) => { const validateValues = (values: NPEDFieldType) => {
@@ -41,6 +44,7 @@ const NPEDFields = () => {
const handleLogoutClick = () => { const handleLogoutClick = () => {
signOut(); signOut();
toast.warning("logged out of NPED");
}; };
return ( return (
@@ -48,9 +52,10 @@ const NPEDFields = () => {
initialValues={initialValues} initialValues={initialValues}
onSubmit={handleSubmit} onSubmit={handleSubmit}
validate={validateValues} validate={validateValues}
enableReinitialize
> >
{({ errors, touched }) => ( {({ errors, touched, isSubmitting }) => (
<Form className="flex flex-col space-y-5"> <Form className="flex flex-col space-y-5 px-2">
<FormGroup> <FormGroup>
<label htmlFor="username">Username</label> <label htmlFor="username">Username</label>
{touched.username && errors.username && ( {touched.username && errors.username && (
@@ -68,18 +73,26 @@ const NPEDFields = () => {
</FormGroup> </FormGroup>
<FormGroup> <FormGroup>
<label htmlFor="password">Password</label> <label htmlFor="password">Password</label>
<div className="flex gap-2 items-center relative mb-4">
<Field
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 && ( {touched.password && errors.password && (
<small className="absolute right-0 -top-5 text-red-500"> <small className="absolute right-0 -top-5 text-red-500">
{errors.password} {errors.password}
</small> </small>
)} )}
<Field <FontAwesomeIcon
name="password" type="button"
type="password" className="absolute right-5 end-0"
id="password" onClick={() => setShowPwd((s) => !s)}
placeholder="NPED Password" icon={showPwd ? faEyeSlash : faEye}
className="p-1.5 border border-gray-400 rounded-lg"
/> />
</div>
</FormGroup> </FormGroup>
<FormGroup> <FormGroup>
<label htmlFor="clientId">Client ID</label> <label htmlFor="clientId">Client ID</label>
@@ -101,7 +114,7 @@ const NPEDFields = () => {
type="submit" type="submit"
className="w-1/4 text-white bg-green-700 hover:bg-green-800 font-small rounded-lg text-sm px-2 py-2.5 hover:cursor-pointer" className="w-1/4 text-white bg-green-700 hover:bg-green-800 font-small rounded-lg text-sm px-2 py-2.5 hover:cursor-pointer"
> >
Login {isSubmitting ? "Logging in..." : "Login"}
</button> </button>
) : ( ) : (
<button <button

View File

@@ -1,44 +1,58 @@
import { Form, Formik } from "formik"; import { Form, Formik } from "formik";
import type { HotlistUploadType } from "../../../types/types"; import type { HotlistUploadType } from "../../../types/types";
import { useSystemConfig } from "../../../hooks/useSystemConfig";
import { CAM_BASE } from "../../../utils/config";
const NPEDHotlist = () => { const NPEDHotlist = () => {
const { uploadSettings } = useSystemConfig();
const initialValue = { const initialValue = {
file: null, 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 ( return (
<Formik initialValues={initialValue} onSubmit={handleSubmit}> <Formik initialValues={initialValue} onSubmit={handleSubmit}>
{({ setFieldValue, setErrors, errors }) => { {({ setFieldValue, setErrors, errors }) => {
return ( return (
<Form className="flex flex-col space-y-2"> <Form className="flex flex-col space-y-2 px-2">
<input <input
type="file" type="file"
name="file" name="file"
id="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) => { onChange={(e) => {
if (e.target.files) { if (e.target.files) {
if (e.target.files[0].type !== "text/csv") { if (e.target.files[0].type !== "text/csv") {
setErrors({ setErrors({
file: "This file is not a CSV, please select a different one", file: "This file is not a CSV, please select a different one",
}); });
return;
} }
setFieldValue("file", e.target.files[0]); setFieldValue("file", e.target.files[0]);
} else {
setErrors({ file: "no file" });
} }
}} }}
/> />
<button <button
type="submit" type="submit"
className="w-1/4 text-white bg-green-700 hover:bg-green-800 font-small rounded-lg text-sm px-2 py-2.5" 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 Upload
</button> </button>
<p>{errors && errors.file}</p> <p>{errors.file && errors.file}</p>
</Form> </Form>
); );
}} }}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,10 @@
import { Field, useFormikContext } from "formik"; import { Field } from "formik";
import FormGroup from "../components/FormGroup"; import FormGroup from "../components/FormGroup";
import FormToggle from "../components/FormToggle"; import FormToggle from "../components/FormToggle";
const SightingDataFields = () => { const SightingDataFields = () => {
useFormikContext();
return ( return (
<div className="flex flex-col space-y-2"> <div className="flex flex-col space-y-2 px-2">
<FormGroup> <FormGroup>
<label htmlFor="overviewQuality">Overview Quality</label> <label htmlFor="overviewQuality">Overview Quality</label>
<Field <Field
@@ -26,6 +24,7 @@ const SightingDataFields = () => {
<Field <Field
as="select" as="select"
className="p-2 border border-gray-400 rounded-lg text-white bg-[#253445]" className="p-2 border border-gray-400 rounded-lg text-white bg-[#253445]"
name="overviewImageScaleFactor"
> >
<option value="HIGH">Full</option> <option value="HIGH">Full</option>
<option value="MEDIUM">3/4</option> <option value="MEDIUM">3/4</option>
@@ -38,6 +37,7 @@ const SightingDataFields = () => {
<Field <Field
as="select" as="select"
className="p-2 border border-gray-400 rounded-lg text-white bg-[#253445]" className="p-2 border border-gray-400 rounded-lg text-white bg-[#253445]"
name="overviewType"
> >
<option value="PlainOverview">Plain Overview</option> <option value="PlainOverview">Plain Overview</option>
<option value="IncludePlatePatches">Include Plate Patches</option> <option value="IncludePlatePatches">Include Plate Patches</option>
@@ -60,6 +60,7 @@ const SightingDataFields = () => {
<Field <Field
as="select" as="select"
className="p-2 border border-gray-400 rounded-lg text-white bg-[#253445]" 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="PlainOverview">plain VRM ASCII (default)</option>
<option value="IncludePlatePatches">plain VRM ASCII (default)</option> <option value="IncludePlatePatches">plain VRM ASCII (default)</option>
@@ -70,6 +71,7 @@ const SightingDataFields = () => {
<Field <Field
as="select" as="select"
className="p-2 border border-gray-400 rounded-lg text-white bg-[#253445]" className="p-2 border border-gray-400 rounded-lg text-white bg-[#253445]"
name="staticReadAction"
> >
<option value="UseLaneDirection">Use Lane Direction</option> <option value="UseLaneDirection">Use Lane Direction</option>
<option value="IncludePlatePatches">plain VRM ASCII (default)</option> <option value="IncludePlatePatches">plain VRM ASCII (default)</option>
@@ -80,6 +82,7 @@ const SightingDataFields = () => {
<Field <Field
as="select" as="select"
className="p-2 border border-gray-400 rounded-lg text-white bg-[#253445]" className="p-2 border border-gray-400 rounded-lg text-white bg-[#253445]"
name="noRegionAction"
> >
<option value="UseLaneDirection">Send</option> <option value="UseLaneDirection">Send</option>
<option value="IncludePlatePatches">plain VRM ASCII (default)</option> <option value="IncludePlatePatches">plain VRM ASCII (default)</option>
@@ -90,6 +93,7 @@ const SightingDataFields = () => {
<Field <Field
as="select" as="select"
className="p-2 border border-gray-400 rounded-lg text-white bg-[#253445]" className="p-2 border border-gray-400 rounded-lg text-white bg-[#253445]"
name="countryCodeType"
> >
<option value="IBAN 2 Character code (default)"> <option value="IBAN 2 Character code (default)">
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,37 +1,53 @@
export async function handleSystemSave(deviceName: string, sntpServer: string, sntpInterval: number, timeZone: string) { import { toast } from "sonner";
const payload = { // Build JSON import type { SystemValues } from "../../../types/types";
import { CAM_BASE } from "../../../utils/config";
export async function handleSystemSave(values: SystemValues) {
const payload = {
// Build JSON
id: "GLOBAL--Device", id: "GLOBAL--Device",
fields: [ fields: [
{ property: "propDeviceName", value: deviceName }, { property: "propDeviceName", value: values.deviceName },
{ property: "propSNTPServer", value: sntpServer }, { property: "propSNTPServer", value: values.sntpServer },
{ property: "propSNTPIntervalMinutes", value: Number(sntpInterval) }, {
{ property: "propLocalTimeZone", value: timeZone } property: "propSNTPIntervalMinutes",
] value: Number(values.sntpInterval),
},
{ property: "propLocalTimeZone", value: values.timeZone },
],
}; };
try { try {
const response = await fetch("http://192.168.75.11/api/update-config", { const response = await fetch(`${CAM_BASE}/api/update-config`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
"Accept": "application/json" Accept: "application/json",
}, },
body: JSON.stringify(payload) body: JSON.stringify(payload),
}); });
if (!response.ok) { if (!response.ok) {
const text = await response.text().catch(() => ""); const text = await response.text().catch(() => "");
throw new Error(`HTTP ${response.status} ${response.statusText}${text ? ` - ${text}` : ""}`); throw new Error(
`HTTP ${response.status} ${response.statusText}${
text ? ` - ${text}` : ""
}`
);
} }
alert("System Settings Saved Successfully!");
} catch (err) { } catch (err) {
if (err instanceof Error) {
toast.error(`Failed to save system settings: ${err.message}`);
console.error(err); console.error(err);
} else {
toast.error("An unexpected error occurred while saving.");
console.error("Unknown error:", err);
}
} }
} }
export async function handleSystemRecall() { 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 controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 7000); const timeoutId = setTimeout(() => controller.abort(), 7000);
@@ -39,13 +55,17 @@ export async function handleSystemRecall() {
try { try {
const response = await fetch(url, { const response = await fetch(url, {
method: "GET", method: "GET",
headers: { "Accept": "application/json" }, headers: { Accept: "application/json" },
signal: controller.signal signal: controller.signal,
}); });
if (!response.ok) { if (!response.ok) {
const text = await response.text().catch(() => ""); const text = await response.text().catch(() => "");
throw new Error(`HTTP ${response.status} ${response.statusText}${text ? ` - ${text}` : ""}`); throw new Error(
`HTTP ${response.status} ${response.statusText}${
text ? ` - ${text}` : ""
}`
);
} }
const data = await response.json(); const data = await response.json();
@@ -54,7 +74,7 @@ export async function handleSystemRecall() {
const sntpServer = data?.propSNTPServer?.value ?? null; const sntpServer = data?.propSNTPServer?.value ?? null;
const timeZone = data?.propLocalTimeZone?.value ?? null; const timeZone = data?.propLocalTimeZone?.value ?? null;
let sntpIntervalRaw = data?.propSNTPIntervalMinutes?.value; const sntpIntervalRaw = data?.propSNTPIntervalMinutes?.value;
let sntpInterval = let sntpInterval =
typeof sntpIntervalRaw === "number" typeof sntpIntervalRaw === "number"
? sntpIntervalRaw ? sntpIntervalRaw
@@ -66,7 +86,12 @@ export async function handleSystemRecall() {
return { deviceName, sntpServer, sntpInterval, timeZone }; return { deviceName, sntpServer, sntpInterval, timeZone };
} catch (err) { } catch (err) {
console.error(err); if (err instanceof Error) {
toast.error(`Error: ${err.message}`);
} else {
toast.error("An unexpected error occurred");
}
return null; return null;
} finally { } finally {
clearTimeout(timeoutId); clearTimeout(timeoutId);

View File

@@ -1,217 +1,12 @@
import React from "react";
import { useEffect } from "react"
import Card from "../../UI/Card"; import Card from "../../UI/Card";
import CardHeader from "../../UI/CardHeader"; import CardHeader from "../../UI/CardHeader";
import FormGroup from "../components/FormGroup"; import SystemConfigFields from "./SystemConfigFields.tsx";
import { sendBlobFileUpload } from "./Upload";
import { handleSoftReboot, handleHardReboot } from "./Reboots.tsx";
import { handleSystemRecall, handleSystemSave } from "./SettingSaveRecall.tsx";
const SystemCard = () => { 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 ( return (
<Card className="flex flex-col items-center justify-center"> <Card className="p-4">
<CardHeader title={"System Config"} /> <CardHeader title={"System Config"} />
<div className="flex flex-col gap-4 w-full items-left max-w-md"> <SystemConfigFields />
<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> </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 Card from "../../UI/Card";
import CardHeader from "../../UI/CardHeader"; import CardHeader from "../../UI/CardHeader";
import { useState } from "react"; import ModemSettings from "./ModemSettings";
import FormGroup from "../components/FormGroup";
const ModemCard = () => { const ModemCard = () => {
const [apn, setApn] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [authType, setAuthType] = useState("PAP");
return ( return (
<Card> <Card className="p-4">
<CardHeader title={"Modem"} /> <CardHeader title={"Modem"} />
<div className="flex flex-col gap-4">
<FormGroup> <ModemSettings />
<label htmlFor="apn" className="font-medium whitespace-nowrap md:w-2/3">APN</label>
<input
id="apn"
name="apn"
type="text"
className="p-2 border border-gray-400 rounded-lg flex-1 md:w-2/3"
placeholder="Enter APN"
value={apn}
onChange={e => setApn(e.target.value)}
/>
</FormGroup>
<FormGroup>
<label htmlFor="modemUsername" className="font-medium whitespace-nowrap md:w-2/3">Username</label>
<input
id="modemUsername"
name="modemUsername"
type="text"
className="p-2 border border-gray-400 rounded-lg flex-1 md:w-2/3"
placeholder="Enter Username"
value={username}
onChange={e => setUsername(e.target.value)}
/>
</FormGroup>
<FormGroup>
<label htmlFor="modemPassword" className="font-medium whitespace-nowrap md:w-2/3">Password</label>
<input
id="modemPassword"
name="modemPassword"
type="password"
className="p-2 border border-gray-400 rounded-lg flex-1 md:w-2/3"
placeholder="Enter Password"
value={password}
onChange={e => setPassword(e.target.value)}
/>
</FormGroup>
<FormGroup>
<label htmlFor="authType" className="font-medium whitespace-nowrap md:w-2/3">Authentication Type</label>
<select
id="authType"
name="authType"
className="p-2 border border-gray-400 rounded-lg text-white bg-[#253445] flex-1 md:w-2/3"
value={authType}
onChange={e => setAuthType(e.target.value)}
>
<option value="PAP">PAP</option>
<option value="CHAP">CHAP</option>
<option value="None">None</option>
</select>
</FormGroup>
<button
className="bg-[#26B170] text-white px-4 py-2 rounded hover:bg-green-700 transition w-full max-w-md"
//onClick={() => handleModemSave(apn, username, password, authType)}
>
Save Modem Settings
</button>
</div>
</Card> </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 Card from "../../UI/Card";
import CardHeader from "../../UI/CardHeader"; import CardHeader from "../../UI/CardHeader";
import { useState } from "react"; import WiFiSettingsForm from "./WiFiSettingsForm";
import FormGroup from "../components/FormGroup";
const WiFiCard = () => { const WiFiCard = () => {
const [ssid, setSsid] = useState("");
const [password, setPassword] = useState("");
const [encryption, setEncryption] = useState("WPA2");
return ( return (
<Card className="mb-4"> <Card className="p-4">
<CardHeader title={"WiFi"} /> <CardHeader title={"WiFi"} />
<div className="flex flex-col gap-4"> <WiFiSettingsForm />
<FormGroup>
<label htmlFor="ssid" className="font-medium whitespace-nowrap md:w-2/3">SSID</label>
<input
id="ssid"
name="ssid"
type="text"
className="p-2 border border-gray-400 rounded-lg flex-1 md:w-2/3"
placeholder="Enter SSID"
value={ssid}
onChange={e => setSsid(e.target.value)}
/>
</FormGroup>
<FormGroup>
<label htmlFor="password" className="font-medium whitespace-nowrap md:w-2/3">Password</label>
<input
id="password"
name="password"
type="password"
className="p-2 border border-gray-400 rounded-lg flex-1 md:w-2/3"
placeholder="Enter Password"
value={password}
onChange={e => setPassword(e.target.value)}
/>
</FormGroup>
<FormGroup>
<label htmlFor="encryption" className="font-medium whitespace-nowrap md:w-2/3">WPA/Encryption Type</label>
<select
id="encryption"
name="encryption"
className="p-2 border border-gray-400 rounded-lg text-white bg-[#253445] flex-1 md:w-2/3"
value={encryption}
onChange={e => setEncryption(e.target.value)}
>
<option value="WPA2">WPA2</option>
<option value="WPA3">WPA3</option>
<option value="WEP">WEP</option>
<option value="None">None</option>
</select>
</FormGroup>
<button
className="bg-[#26B170] text-white px-4 py-2 rounded hover:bg-green-700 transition w-full max-w-md"
//onClick={() => handleWiFiSave(ssid, password, encryption)}
>
Save WiFi Settings
</button>
</div>
</Card> </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) => { const FormGroup = ({ children }: FormGroupProps) => {
return ( return <div className="flex flex-col md:flex-row md:items-center justify-between relative space-y-2">{children}</div>;
<div className="flex flex-col md:flex-row items-center justify-between relative">
{children}
</div>
);
}; };
export default FormGroup; export default FormGroup;

View File

@@ -2,7 +2,7 @@ import { Field } from "formik";
const FormToggle = ({ name, label }: { name: string; label?: string }) => { const FormToggle = ({ name, label }: { name: string; label?: string }) => {
return ( 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> <span className="text-sm">{label}</span>
<Field id={name} type="checkbox" name={name} className="sr-only peer" /> <Field id={name} type="checkbox" name={name} className="sr-only peer" />
<div <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) => (
<div className="items-center px-2.5 py-0.5 rounded-sm me-2 bg-amber-500 w-55 m-2">
<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 { useCallback, useRef, useState } from "react";
import { BLANK_IMG } from "../../utils/utils"; import { BLANK_IMG } from "../../utils/utils";
import SightingWidgetDetails from "../SightingsWidget/SightingWidgetDetails";
import { useOverviewOverlay } from "../../hooks/useOverviewOverlay"; import { useOverviewOverlay } from "../../hooks/useOverviewOverlay";
import { useSightingFeedContext } from "../../context/SightingFeedContext"; import { useSightingFeedContext } from "../../context/SightingFeedContext";
import { useHiDPICanvas } from "../../hooks/useHiDPICanvas"; import { useHiDPICanvas } from "../../hooks/useHiDPICanvas";
import NavigationArrow from "../UI/NavigationArrow"; import NavigationArrow from "../UI/NavigationArrow";
import { useSwipeable } from "react-swipeable"; import NumberPlate from "../PlateStack/NumberPlate";
import { useNavigate } from "react-router"; import Loading from "../UI/Loading";
const SightingOverview = () => { const SightingOverview = () => {
const navigate = useNavigate();
const handlers = useSwipeable({
onSwipedRight: () => navigate("/front-camera-settings"),
trackMouse: true,
});
const [overlayMode, setOverlayMode] = useState<0 | 1 | 2>(0); const [overlayMode, setOverlayMode] = useState<0 | 1 | 2>(0);
const imgRef = useRef<HTMLImageElement | null>(null); const imgRef = useRef<HTMLImageElement | null>(null);
@@ -23,19 +17,43 @@ const SightingOverview = () => {
setOverlayMode((m) => ((m + 1) % 3) as 0 | 1 | 2); setOverlayMode((m) => ((m + 1) % 3) as 0 | 1 | 2);
}, []); }, []);
const { effectiveSelected, side, mostRecent, noSighting, isPending } = const { side, mostRecent, isError, isLoading } = useSightingFeedContext();
useSightingFeedContext();
useOverviewOverlay(mostRecent, overlayMode, imgRef, canvasRef); useOverviewOverlay(mostRecent, overlayMode, imgRef, canvasRef);
const { sync } = useHiDPICanvas(imgRef, canvasRef); const { sync } = useHiDPICanvas(imgRef, canvasRef);
if (noSighting || isPending) return <p>loading</p>; if (isLoading)
return ( return (
<div className="mt-2 grid gap-3"> <div className="h-150 flex items-center justify-center">
<div className="inline-block w-[90%] mx-auto" {...handlers}>
<NavigationArrow side={side} /> <NavigationArrow side={side} />
<div className="relative aspect-[1280/800]"> <Loading message="Loading" />
</div>
);
if (isError) return;
<div className="h-100 flex items-center justify-center text-red-500 text-lg">
An error occurred. Cannot display footage.
</div>;
if (!mostRecent)
return (
<div className="h-150 flex items-center justify-center text-3xl animate-pulse">
<NavigationArrow side={side} />
<Loading message="No Recent Sightings" />
</div>
);
return (
<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 <img
ref={imgRef} ref={imgRef}
onLoad={() => { onLoad={() => {
@@ -44,7 +62,7 @@ const SightingOverview = () => {
}} }}
src={mostRecent?.overviewUrl || BLANK_IMG} src={mostRecent?.overviewUrl || BLANK_IMG}
alt="overview" alt="overview"
className="absolute inset-0 w-full h-full object-contain cursor-pointer z-10" className="absolute inset-0 object-contain cursor-pointer z-10 min-h-[100%] rounded-lg"
onClick={onOverviewClick} onClick={onOverviewClick}
style={{ style={{
display: mostRecent?.overviewUrl ? "block" : "none", display: mostRecent?.overviewUrl ? "block" : "none",
@@ -52,23 +70,10 @@ const SightingOverview = () => {
/> />
<canvas <canvas
ref={canvasRef} ref={canvasRef}
className="absolute inset-0 w-full h-full object-contain z-20 pointer-events-none" className="absolute inset-0 object-contain z-20 pointer-events-none "
/> />
</div> </div>
</div> </div>
<SightingWidgetDetails effectiveSelected={effectiveSelected} />
<div className="text-xs opacity-80">
Overlay:{" "}
{overlayMode === 0
? "Off"
: overlayMode === 1
? "Plate box"
: "Track + box"}{" "}
(click image to toggle)
</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 { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { SightingWidgetType } from "../../types/types"; import type { HitKind, QueuedHit, ReducedSightingType, SightingType } from "../../types/types";
import { BLANK_IMG, capitalize, formatAge } from "../../utils/utils"; import { BLANK_IMG, getSoundFileURL } from "../../utils/utils";
import NumberPlate from "../PlateStack/NumberPlate"; import NumberPlate from "../PlateStack/NumberPlate";
import Card from "../UI/Card"; import Card from "../UI/Card";
import CardHeader from "../UI/CardHeader"; import CardHeader from "../UI/CardHeader";
import clsx from "clsx"; import clsx from "clsx";
import { useSightingFeedContext } from "../../context/SightingFeedContext"; 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 { useNPEDContext } from "../../context/NPEDUserContext";
import { useSoundContext } from "../../context/SoundContext";
import Loading from "../UI/Loading";
import { checkIsHotListHit, getNPEDCategory } from "../../utils/utils";
function useNow(tickMs = 1000) { function useNow(tickMs = 1000) {
const [, setNow] = useState(() => Date.now()); const [, setNow] = useState(() => Date.now());
@@ -13,107 +26,205 @@ function useNow(tickMs = 1000) {
const id = setInterval(() => setNow(Date.now()), tickMs); const id = setInterval(() => setNow(Date.now()), tickMs);
return () => clearInterval(id); return () => clearInterval(id);
}, [tickMs]); }, [tickMs]);
return undefined; return null;
} }
export type SightingHistoryProps = { type SightingHistoryProps = {
baseUrl: string; baseUrl?: string;
entries?: number; // number of rows to show entries?: number;
pollMs?: number; // poll frequency pollMs?: number;
autoSelectLatest?: boolean; autoSelectLatest?: boolean;
title: string;
className?: string;
}; };
type SightingHistoryWidgetProps = React.HTMLAttributes<HTMLDivElement>; export default function SightingHistoryWidget({ className, title }: SightingHistoryProps) {
const [modalQueue, setModalQueue] = useState<QueuedHit[]>([]);
export default function SightingHistoryWidget({
className,
}: SightingHistoryWidgetProps) {
useNow(1000); 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 { sessionStarted, setSessionList, sessionList } = useNPEDContext();
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;
const reducedMostRecent = reduceObject(mostRecent);
setSessionList([...sessionList, reducedMostRecent]);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mostRecent, sessionStarted, setSessionList]);
const onRowClick = useCallback( const onRowClick = useCallback(
(ref: number) => { (sighting: SightingType) => {
setSelectedRef(ref); if (!sighting) return;
setSightingModalOpen(true);
setSelectedSighting(sighting);
}, },
[setSelectedRef] [setSelectedSighting, setSightingModalOpen]
); );
const rows = useMemo( const rows = useMemo(() => sightings?.filter(Boolean) as SightingType[], [sightings]);
() => sightings?.filter(Boolean) as SightingWidgetType[],
[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 ( return (
<Card className={clsx("overflow-y-auto h-100", className)}> <>
<CardHeader title="Front Camera Sightings" /> <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 "> <div className="flex flex-col gap-3 ">
{isLoading && (
<div className="my-50 h-[50%]">
<Loading message="Loading Sightings" />
</div>
)}
{/* Rows */} {/* Rows */}
<div className="flex flex-col"> <div className="flex flex-col">
{rows?.map((obj, idx) => { {rows?.map((obj) => {
console.log(obj); const cat = getNPEDCategory(obj);
const isNPEDHit = obj?.metadata?.npedJSON?.status_code === 201; const isNPEDHitA = cat === "A";
const isSelected = obj?.ref === selectedRef; const isNPEDHitB = cat === "B";
const isNPEDHitC = cat === "C";
const motionAway = (obj?.motion ?? "").toUpperCase() === "AWAY"; const motionAway = (obj?.motion ?? "").toUpperCase() === "AWAY";
const primaryIsColour = obj?.srcCam === 1; const isHotListHit = checkIsHotListHit(obj);
const secondaryMissing = (obj?.vrmSecondary ?? "") === "";
return ( return (
<div <div
key={idx} key={obj.ref}
className={`border border-neutral-700 rounded-md mb-2 p-2 cursor-pointer ${ className={`border border-gray-700 rounded-md mb-2 p-2 cursor-pointer `}
isSelected ? "ring-2 ring-blue-400" : "" onClick={() => onRowClick(obj)}
}`}
onClick={() => onRowClick(obj.ref)}
> >
{/* Info bar */} <div className={`flex items-center gap-3 mt-2 justify-between `}>
<div className="flex items-center gap-3 text-xs bg-neutral-900 px-2 py-1 rounded"> <div className={`border p-1 `}>
<div className="min-w-14"> <img src={obj?.plateUrlColour || BLANK_IMG} height={48} width={200} alt="colour patch" />
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 */}
<div
className={`flex items-center gap-3 mt-2
${isNPEDHit ? "border border-red-600" : ""}
`}
>
<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>
<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> </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} /> <NumberPlate motion={motionAway} vrm={obj?.vrm} />
</div> </div>
</div> </div>
@@ -122,5 +233,7 @@ export default function SightingHistoryWidget({
</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 = { type SightingWidgetDetailsProps = {
effectiveSelected: SightingWidgetType | null; effectiveSelected: SightingType | null;
}; };
const SightingWidgetDetails = ({ const SightingWidgetDetails = ({
effectiveSelected, effectiveSelected,
}: SightingWidgetDetailsProps) => { }: SightingWidgetDetailsProps) => {
return ( return (
<>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-sm"> <div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-sm">
{effectiveSelected?.vrm && (
<div> <div>
VRM:{" "} VRM:{" "}
<span className="opacity-90">{effectiveSelected?.vrm ?? "—"}</span> <span className="opacity-90">{effectiveSelected?.vrm ?? "—"}</span>
</div> </div>
<div> )}
Timestamp:{" "}
<span className="opacity-90">{effectiveSelected?.timeStamp ?? ""}</span> {effectiveSelected?.make !== "" && (
</div>
<div> <div>
Make:{" "} Make:{" "}
<span className="opacity-90">{effectiveSelected?.make ?? "—"}</span> <span className="opacity-90">{effectiveSelected?.make ?? "—"}</span>
</div> </div>
)}
{effectiveSelected?.model.trim() !== "" && (
<div> <div>
Model:{" "} 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"> <span className="opacity-90">
{effectiveSelected?.seenCount ?? "—"} {effectiveSelected?.model ?? "—"}
</span> </span>
</div> </div>
)}
{effectiveSelected?.color !== "" && (
<div> <div>
Colour:{" "} 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"> <span className="opacity-90">
{effectiveSelected?.charHeight ?? "—"} {effectiveSelected?.color ?? "—"}
</span> </span>
</div> </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> </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 ( return (
<div <div
className={clsx( 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 className
)} )}
> >

View File

@@ -1,29 +1,38 @@
import type { IconProp } from "@fortawesome/fontawesome-svg-core"; import type { IconProp } from "@fortawesome/fontawesome-svg-core";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import clsx from "clsx"; import clsx from "clsx";
import NumberPlate from "../PlateStack/NumberPlate";
import type { SightingType } from "../../types/types";
type CameraOverviewHeaderProps = { type CameraOverviewHeaderProps = {
title: string; title?: string;
icon?: IconProp; icon?: IconProp;
img?: string; img?: string;
sighting?: SightingType | null;
}; };
const CardHeader = ({
const CardHeader = ({ title, icon, img }: CameraOverviewHeaderProps) => { title,
icon,
img,
sighting,
}: CameraOverviewHeaderProps) => {
return ( return (
<div <div
className={clsx( 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"> <div className="flex items-center space-x-2">
{icon && <FontAwesomeIcon icon={icon} className="size-4" />} {icon && <FontAwesomeIcon icon={icon} className="size-4" />}
<h2 className="text-xl">{title}</h2> <h2 className="text-xl">{title}</h2>
</div> </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> </div>
); );
}; };
export default CardHeader; export default CardHeader;

View File

@@ -4,9 +4,11 @@ import { Outlet } from "react-router";
const Container = () => { const Container = () => {
return ( return (
<div> <div className="min-h-screen">
<Header /> <Header />
<div className="min-h-screen">
<Outlet /> <Outlet />
</div>
<Footer /> <Footer />
</div> </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,82 +1,76 @@
import * as React from "react";
import { Link } from "react-router"; import { Link } from "react-router";
import Logo from "/MAV.svg"; import Logo from "/MAV.svg";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faGear, faListCheck } from "@fortawesome/free-solid-svg-icons"; import {
import type { VersionFieldType } from "../../types/types"; faGear,
faHome,
async function fetchVersions(signal?: AbortSignal): Promise<VersionFieldType> { faListCheck,
const res = await fetch("http://192.168.75.11/api/versions", { signal }); faMaximize,
if (!res.ok) throw new Error(`HTTP ${res.status}`); faMinimize,
return res.json(); faRotate,
} } from "@fortawesome/free-solid-svg-icons";
import { useState } from "react";
const pad = (n: number) => String(n).padStart(2, "0"); import SoundBtn from "./SoundBtn";
const normalizeToMs = (ts: number) => (ts < 1e12 ? ts * 1000 : ts); // seconds → ms if needed import { useNPEDContext } from "../../context/NPEDUserContext";
function formatFromMs(ms: number, tz: "local" | "utc" = "local") {
const d = new Date(ms);
const h = tz === "utc" ? d.getUTCHours() : d.getHours();
const m = tz === "utc" ? d.getUTCMinutes() : d.getMinutes();
const s = tz === "utc" ? d.getUTCSeconds() : d.getSeconds();
const day = tz === "utc" ? d.getUTCDate() : d.getDate();
const month = (tz === "utc" ? d.getUTCMonth() : d.getMonth()) + 1;
const year = tz === "utc" ? d.getUTCFullYear() : d.getFullYear();
return `${pad(h)}:${pad(m)}:${pad(s)} ${pad(day)}-${pad(month)}-${year}`;
}
export default function Header() { export default function Header() {
const [offsetMs, setOffsetMs] = React.useState<number | null>(null); const [isFullscreen, setIsFullscreen] = useState(false);
const [nowMs, setNowMs] = React.useState<number>(Date.now()); const { sessionStarted } = useNPEDContext();
React.useEffect(() => { const toggleFullscreen = () => {
const ac = new AbortController(); if (!document.fullscreenElement) {
fetchVersions(ac.signal) document.documentElement.requestFullscreen();
.then((data) => { setIsFullscreen(true);
const serverMs = normalizeToMs(data.timeStamp); } else {
setOffsetMs(serverMs - Date.now()); document.exitFullscreen();
}) setIsFullscreen(false);
return () => ac.abort(); }
}, []);
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 serverNowMs = offsetMs == null ? nowMs : nowMs + offsetMs; const refreshBrowser = () => {
const localStr = formatFromMs(serverNowMs, "local"); window.location.reload();
const utcStr = formatFromMs(serverNowMs, "utc"); };
return ( return (
<div className="relative bg-[#253445] border-b border-gray-500 items-center mx-auto px-2 sm:px-6 lg:px-8 p-4 flex flex-row justify-between"> <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">
{/* Left: Logo */} <div className="w-28">
<div className="w-30">
<Link to={"/"}> <Link to={"/"}>
<img src={Logo} alt="Logo" width={150} height={150} /> <img src={Logo} alt="Logo" width={150} height={150} />
</Link> </Link>
</div> </div>
{/* Right: Texts stacked + icons */} <div className="flex flex-col lg:flex-row items-center space-x-24 justify-items-center">
<div className="flex items-center space-x-12"> {sessionStarted && (
<div className="flex flex-col leading-tight text-white text-sm tabular-nums"> <div className="text-green-400 font-bold">Session Active</div>
<h2>Local: {localStr}</h2> )}
<h2>UTC: {utcStr}</h2>
</div>
<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"}> <Link to={"/session-settings"}>
<FontAwesomeIcon className="text-white" icon={faListCheck} /> <FontAwesomeIcon
className="text-white"
icon={faListCheck}
size="2x"
/>
</Link> </Link>
<Link to={"/system-settings"}> <Link to={"/system-settings"}>
<FontAwesomeIcon className="text-white" icon={faGear} /> <FontAwesomeIcon className="text-white" icon={faGear} size="2x" />
</Link> </Link>
</div> </div>
</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"; import { useNavigate } from "react-router";
type NavigationArrowProps = { type NavigationArrowProps = {
side: string; side: string | undefined;
settingsPage?: boolean; settingsPage?: boolean;
}; };
const NavigationArrow = ({ side, settingsPage }: NavigationArrowProps) => { const NavigationArrow = ({ side, settingsPage }: NavigationArrowProps) => {
const navigate = useNavigate(); const navigate = useNavigate();
const navigationDest = (side: string) => { const navigationDest = (side: string | undefined) => {
if (settingsPage) { if (settingsPage) {
navigate("/"); navigate("/");
return; return;
} }
if (side === "Front") { if (side === "Front") {
navigate("/front-camera-settings"); navigate("/camera-settings");
} else if (side === "Rear") { } else if (side === "Rear") {
navigate("/Rear-Camera-settings"); navigate("/Rear-Camera-settings");
} }
@@ -26,16 +26,18 @@ const NavigationArrow = ({ side, settingsPage }: NavigationArrowProps) => {
if (settingsPage) { if (settingsPage) {
return ( return (
<> <>
{side === "CameraFront" ? ( {side === "CameraA" ? (
<FontAwesomeIcon <FontAwesomeIcon
size="2xl"
icon={faArrowRight} icon={faArrowRight}
className="absolute top-[50%] right-[2%] backdrop-blur-md hover:cursor-pointer animate-bounce" className="absolute top-[50%] right-[2%] backdrop-blur-lg hover:cursor-pointer animate-bounce z-30"
onClick={() => navigationDest(side)} onClick={() => navigationDest("Front")}
/> />
) : ( ) : (
<FontAwesomeIcon <FontAwesomeIcon
icon={faArrowLeft} icon={faArrowLeft}
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)} onClick={() => navigationDest(side)}
/> />
)} )}
@@ -44,19 +46,19 @@ const NavigationArrow = ({ side, settingsPage }: NavigationArrowProps) => {
} }
return ( return (
<> <>
{side === "Front" ? (
<FontAwesomeIcon <FontAwesomeIcon
icon={faArrowLeft} icon={faArrowLeft}
className="absolute top-[50%] left-[2%] backdrop-blur-md hover:cursor-pointer animate-bounce" size="2xl"
onClick={() => navigationDest(side)} className="absolute top-[50%] left-[2%] backdrop-blur-md hover:cursor-pointer animate-bounce z-100 "
onClick={() => navigationDest("Front")}
/> />
) : (
<FontAwesomeIcon <FontAwesomeIcon
icon={faArrowRight} icon={faArrowRight}
className="absolute top-[50%] right-[2%] backdrop-blur-md hover:cursor-pointer animate-bounce" size="2xl"
onClick={() => navigationDest(side)} className="absolute top-[50%] right-[2%] backdrop-blur-md hover:cursor-pointer animate-bounce z-100"
onClick={() => navigationDest("Rear")}
/> />
)}
</> </>
); );
}; };

View File

@@ -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,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

@@ -1,9 +1,13 @@
import { createContext, useContext, type SetStateAction } from "react"; import { createContext, useContext, type SetStateAction } from "react";
import type { NPEDCameraConfig, NPEDUser } from "../types/types"; import type { NPEDUser, ReducedSightingType } from "../types/types";
type UserContextValue = { type UserContextValue = {
user: NPEDCameraConfig | null; user: NPEDUser | null;
setUser: React.Dispatch<SetStateAction<NPEDUser | null>>; setUser: React.Dispatch<SetStateAction<NPEDUser | null>>;
sessionStarted: boolean;
setSessionStarted: React.Dispatch<SetStateAction<boolean>>;
sessionList: ReducedSightingType[];
setSessionList: React.Dispatch<SetStateAction<ReducedSightingType[]>>;
}; };
export const NPEDUserContext = createContext<UserContextValue | undefined>( export const NPEDUserContext = createContext<UserContextValue | undefined>(

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

@@ -1,5 +1,5 @@
import { useState, type ReactNode } from "react"; import { useState, type ReactNode } from "react";
import type { NPEDUser } from "../../types/types"; import type { NPEDUser, ReducedSightingType } from "../../types/types";
import { NPEDUserContext } from "../NPEDUserContext"; import { NPEDUserContext } from "../NPEDUserContext";
type NPEDUserProviderType = { type NPEDUserProviderType = {
@@ -8,9 +8,20 @@ type NPEDUserProviderType = {
export const NPEDUserProvider = ({ children }: NPEDUserProviderType) => { export const NPEDUserProvider = ({ children }: NPEDUserProviderType) => {
const [user, setUser] = useState<NPEDUser | null>(null); const [user, setUser] = useState<NPEDUser | null>(null);
const [sessionStarted, setSessionStarted] = useState(false);
const [sessionList, setSessionList] = useState<ReducedSightingType[]>([]);
return ( return (
<NPEDUserContext.Provider value={{ user, setUser }}> <NPEDUserContext.Provider
value={{
user,
setUser,
setSessionStarted,
sessionStarted,
sessionList,
setSessionList,
}}
>
{children} {children}
</NPEDUserContext.Provider> </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 { useSightingFeed } from "../../hooks/useSightingFeed";
import { SightingFeedContext } from "../SightingFeedContext"; import { SightingFeedContext } from "../SightingFeedContext";
type SightingFeedProviderProps = { type SightingFeedProviderProps = {
url: string; url?: string | undefined;
children: ReactNode; children: ReactNode;
side: string; side?: string | undefined;
}; };
export const SightingFeedProvider = ({ export const SightingFeedProvider = ({
@@ -17,23 +17,33 @@ export const SightingFeedProvider = ({
sightings, sightings,
selectedRef, selectedRef,
setSelectedRef, setSelectedRef,
effectiveSelected, data,
isLoading,
isError,
setSelectedSighting,
selectedSighting,
mostRecent, mostRecent,
isPending, sessionStarted,
noSighting,
} = useSightingFeed(url); } = useSightingFeed(url);
const [isSightingModalOpen, setSightingModalOpen] = useState(false);
return ( return (
<SightingFeedContext.Provider <SightingFeedContext.Provider
value={{ value={{
sightings, sightings,
selectedRef, selectedRef,
setSelectedRef, setSelectedRef,
effectiveSelected, setSelectedSighting,
selectedSighting,
setSightingModalOpen,
isSightingModalOpen,
mostRecent, mostRecent,
isError,
isLoading,
side, side,
isPending, data,
noSighting, sessionStarted,
}} }}
> >
{children} {children}

View File

@@ -0,0 +1,67 @@
import { useEffect, useMemo, useReducer, useRef, useState, type ReactNode } from "react";
import { SoundContext } from "../SoundContext";
import { initialState, reducer } from "../reducers/SoundContextReducer";
import { useCameraBlackboard } from "../../hooks/useCameraBlackboard";
type SoundContextProviderProps = {
children: ReactNode;
};
const SoundContextProvider = ({ children }: SoundContextProviderProps) => {
const audioReady = useRef(false);
const [audioArmed, setAudioArmed] = useState(false);
const audioCtxRef = useRef<AudioContext | null>(null);
const [state, dispatch] = useReducer(reducer, initialState);
const { mutation } = useCameraBlackboard();
useEffect(() => {
const fetchSound = async () => {
const result = await mutation.mutateAsync({
operation: "VIEW",
path: "soundSettings",
});
if (!result.result || typeof result.result !== "object") {
dispatch({ type: "UPDATE", payload: state });
} else {
dispatch({ type: "UPDATE", payload: result.result });
}
};
fetchSound();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const unlock = async () => {
if (!audioCtxRef.current) audioCtxRef.current = new AudioContext();
if (audioCtxRef.current.state !== "running") {
try {
await audioCtxRef.current.resume();
} catch {
/* empty */
}
}
const armed = audioCtxRef.current.state === "running";
audioReady.current = audioCtxRef.current.state === "running";
setAudioArmed(armed);
if (audioReady.current) {
window.removeEventListener("pointerdown", unlock);
window.removeEventListener("keydown", unlock);
window.removeEventListener("touchstart", unlock);
}
};
window.addEventListener("pointerdown", unlock, { once: false });
window.addEventListener("keydown", unlock, { once: false });
window.addEventListener("touchstart", unlock, { once: false });
return () => {
window.removeEventListener("pointerdown", unlock);
window.removeEventListener("keydown", unlock);
window.removeEventListener("touchstart", unlock);
};
}, []);
const value = useMemo(() => ({ state, dispatch, audioArmed }), [state, audioArmed]);
return <SoundContext.Provider value={value}>{children}</SoundContext.Provider>;
};
export default SoundContextProvider;

View File

@@ -0,0 +1,63 @@
import type { AlertPayload, AlertState } from "../../types/types";
export const initalState = {
alertList: [],
allAlerts: [],
};
export function reducer(state: AlertState, action: AlertPayload) {
switch (action.type) {
case "ADD": {
if (action.payload && "vrm" in action.payload) {
const alreadyExists = state.allAlerts.some(
(alertItem) => alertItem.vrm === action.payload.vrm
);
if (alreadyExists) {
return state;
}
return {
...state,
allAlerts: [...state.allAlerts, action.payload],
alertList: [...state.allAlerts, action.payload],
};
}
return state;
}
case "SEARCH": {
if (action.payload && typeof action.payload === "string") {
const searchTerm = action.payload.toLowerCase();
return {
...state,
alertList: state.alertList.filter((alertItem) =>
alertItem.vrm.toLowerCase().includes(searchTerm)
),
};
} else {
return {
...state,
alertList: state.allAlerts,
};
}
}
case "REMOVE": {
return {
...state,
alertList: state.alertList.filter(
(alertItem) => alertItem.vrm !== action.payload.vrm
),
};
}
case "DELETE": {
return {
...state,
alertList: action.payload,
};
}
default:
return { ...state };
}
}

View File

@@ -0,0 +1,69 @@
import type { SoundAction, SoundState } from "../../types/types";
export const initialState: SoundState = {
sightingSound: "switch",
NPEDsound: "popup",
hotlistSound: "warning",
hotlists: [{ name: "hotlistName", sound: "notification" }],
soundOptions: [
{ name: "Switch (Default)", soundFileName: "switch" },
{ name: "Popup", soundFileName: "popup" },
{ name: "Notification", soundFileName: "notification" },
{ name: "Beep", soundFileName: "beep" },
{ name: "Ding", soundFileName: "ding" },
{ name: "Shutter", soundFileName: "shutter" },
{ name: "Warning (voice)", soundFileName: "warning" },
],
sightingVolume: 1,
NPEDsoundVolume: 1,
hotlistSoundVolume: 1,
};
export function reducer(state: SoundState, action: SoundAction): SoundState {
switch (action.type) {
case "UPDATE": {
return {
...state,
sightingSound: action.payload.sightingSound,
NPEDsound: action.payload.NPEDsound,
hotlistSound: action.payload.hotlistSound,
hotlists: action.payload.hotlists?.map((hotlist) => ({
name: hotlist.name,
sound: hotlist.sound,
})),
NPEDsoundVolume: action.payload.NPEDsoundVolume,
sightingVolume: action.payload.sightingVolume,
hotlistSoundVolume: action.payload.hotlistSoundVolume,
soundOptions: action.payload.soundOptions,
};
}
case "ADD": {
return {
...state,
soundOptions: [...(state.soundOptions ?? []), action.payload],
};
}
// todo: refactor to use single state coupled with sound name. e.g : {name: <soundname>, volume: <volume>}
case "SIGHTINGVOLUME":
return {
...state,
sightingVolume: action.payload,
};
case "NPEDVOLUME":
return {
...state,
NPEDsoundVolume: action.payload,
};
case "HOTLISTVOLUME":
return {
...state,
hotlistSoundVolume: action.payload,
};
default:
return state;
}
}

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