211 Commits

Author SHA1 Message Date
b1cd2cef8a Merge branch 'main' into develop 2025-11-17 09:08:19 +00:00
8db9d7ff51 minor tweaks to spelling and wording 2025-11-17 09:07:13 +00:00
0b1f0b72a8 Merge pull request 'feature/mobile-menu' (#11) from feature/mobile-menu into develop
Reviewed-on: #11
2025-11-14 15:41:31 +00:00
0945097abb Merge branch 'develop' into feature/mobile-menu 2025-11-14 15:25:57 +00:00
1b46ecc3d1 Merge pull request '- added nped category options for alert popups' (#10) from bugfix/NPEDconfig into develop
Reviewed-on: #10
2025-11-14 15:10:57 +00:00
dd1cd342c1 - added nped category options for alert popups
- minor fix on modal for 'DISABLED' MCC
2025-11-14 15:01:01 +00:00
0a1ac97c57 - removed console.log 2025-11-14 11:55:01 +00:00
ac53a8fd7f Merge branch 'develop' into feature/mobile-menu 2025-11-12 10:17:49 +00:00
96a880a4df Merge pull request 'develop' (#9) from develop into main
Reviewed-on: #9
2025-11-12 09:50:18 +00:00
3ceca96276 Merge branch 'main' into develop 2025-11-12 09:50:09 +00:00
e7b741af78 Merge pull request 'updated and improved ip address validation' (#8) from bugfix/ipaddress into develop
Reviewed-on: #8
2025-11-12 09:49:03 +00:00
ed271964d8 updated and improved ip address validation 2025-11-12 09:46:59 +00:00
9b996430d0 Merge pull request '- added additional modem settings and ip validation' (#6) from bugfix/modem into develop
Reviewed-on: #6
2025-11-11 16:24:02 +00:00
2ccc26ebdc - added additional modem settings and ip validation 2025-11-11 16:22:30 +00:00
b86830a3c3 updated styling for small mobile screens 2025-11-11 15:44:06 +00:00
3d1cc09a5b Merge pull request '- removed dist and updated readme' (#5) from develop into main
Reviewed-on: #5
2025-11-11 14:27:59 +00:00
f571ab80a2 Merge branch 'main' into develop 2025-11-11 14:27:50 +00:00
b2335d2d4d - removed dist and updated readme 2025-11-11 14:26:52 +00:00
a873da8ef5 Merge pull request 'develop' (#4) from develop into main
Reviewed-on: #4
2025-11-11 13:58:46 +00:00
5014c97b4d updated readme 2025-11-11 13:57:05 +00:00
e374456588 updated readme 2025-11-11 13:52:58 +00:00
ee3e111e9b Merge pull request 'Update README.md' (#3) from tobaojo-patch-1 into develop
Reviewed-on: #3
2025-11-11 13:50:02 +00:00
a8178269c2 Update README.md 2025-11-11 13:49:46 +00:00
c057ce5084 Merge pull request 'develop' (#2) from develop into main
Reviewed-on: #2
2025-11-11 12:52:29 +00:00
0c7d99f7ea minor bugfix: removed csv check for update files 2025-11-11 12:50:48 +00:00
f2e10f958d Merge pull request 'added wifi validation' (#1) from bugfix/wifi into develop
Reviewed-on: #1
2025-11-11 12:40:15 +00:00
903b856303 added wifi validation 2025-11-11 12:37:28 +00:00
be0a047d30 - commiting changes for now will revert back 2025-11-11 12:01:28 +00:00
672ff1d2f1 Merged main into develop 2025-11-11 10:49:06 +00:00
08a07b7ffb Merged in develop (pull request #38)
Develop
2025-11-11 10:46:53 +00:00
d60c546db1 Merged in bugfix/Matttesting (pull request #37)
Bugfix/Matttesting
2025-11-11 10:45:25 +00:00
f35e2f9fb5 - added store and forward
- amended sighting ammend endpoint
2025-11-11 10:43:14 +00:00
cac9a2167d - tweaked timeouts 2025-11-10 13:47:19 +00:00
feddaa1eb0 - addressed bugs flagged by BR 2025-11-10 13:44:29 +00:00
ddeedd2d72 - fixed cropped live feed and amended APIs for submission 2025-11-10 11:55:15 +00:00
a734de6261 - removed ids in api enpoints as they are in JSON objects being sent 2025-11-10 09:05:08 +00:00
d57ad1003a -address fixes and changes per feedback from Matt and Brad 2025-11-05 16:30:27 +00:00
861f2dd31d - added endpoints for dns and other 2025-11-04 17:04:19 +00:00
647fd201a3 - minor tweaks on zoom and navigation arrow 2025-11-04 16:09:24 +00:00
c127ce8a8c - more addition bugfixes 2025-11-04 15:29:48 +00:00
61894c0c42 - minor big fixes
- default settings toggled
-update camera zoom need to test
2025-11-04 13:38:06 +00:00
76643cc84c Merged main into develop 2025-11-04 12:00:04 +00:00
f6c1ea2b1c - fixed endpoint typo 2025-11-04 11:49:40 +00:00
18e4d1dcff - quick fix for bof2 2025-11-04 11:45:54 +00:00
010a9fb59d Merged in develop (pull request #36)
Develop
2025-11-04 11:41:47 +00:00
b1953dd965 Merged in bugfix/uploadsounds-2 (pull request #35)
Bugfix/uploadsounds 2
2025-11-04 11:40:16 +00:00
630261ac21 Merged develop into bugfix/uploadsounds-2 2025-11-04 11:37:59 +00:00
c948192f10 Merged in bugfix/bearertype (pull request #34)
Bugfix/bearertype
2025-11-04 11:37:05 +00:00
f47459d116 - added validation endpoint 2025-11-04 11:31:37 +00:00
705d7c7040 - removed other options 2025-11-04 11:17:04 +00:00
ca625673e9 - updated to retrieve and set BOF2 data 2025-11-04 11:05:40 +00:00
538b623ac6 - updated form to include bof2 constants
- refactored code to make more scalable and use one form
2025-11-04 10:24:06 +00:00
933c101cbc - refactored to fetch confis based on formats
- send configs based on formats
2025-11-03 15:01:13 +00:00
af1dabc8fc Merged main into bugfix/uploadsounds-2 2025-10-31 08:27:59 +00:00
a839502421 - added sound file size limit 2025-10-30 11:16:06 +00:00
39629897d4 - removed console.logs 2025-10-30 10:53:48 +00:00
cd26b3b68f Merged main into develop 2025-10-30 10:41:07 +00:00
a8abed2246 - added feature to cache sounds for cross devices - should work in theory 2025-10-29 15:04:40 +00:00
cf72a1e1d3 - fixed file size bug 2025-10-28 15:23:28 +00:00
c8eed55801 - uploaded files seems to work on desktop version 2025-10-28 13:53:11 +00:00
907555cb0d - storing changes for now 2025-10-28 09:54:29 +00:00
d6c39843c8 Merged in develop (pull request #33)
Develop
2025-10-28 08:52:54 +00:00
a64fa76ecb - saving current work before refactor 2025-10-28 08:50:55 +00:00
93dcde4459 Merge branch 'develop' into bugfix/uploadsounds-2 2025-10-27 14:28:56 +00:00
a5b07333da Merged in bugfix/minorchanges (pull request #32)
bugfix/minorChanges
2025-10-27 14:20:56 +00:00
ae0a6f9249 - added debouncing of sounds
- added new sound files
- moved soung hits to sighting list
2025-10-27 14:00:28 +00:00
350d7cf41c Merge branch 'bugfix/uploadsounds-2' of bitbucket.org:mavsystemsltd/mav-in-car-fe into bugfix/uploadsounds-2 2025-10-27 11:31:25 +00:00
78e5da45ca Merged develop into bugfix/uploadsounds-2 2025-10-27 11:29:07 +00:00
e46460f41d Merged in enhancement/sessionstats (pull request #31)
Enhancement/sessionstats
2025-10-27 11:23:29 +00:00
6c441a0a4b Merged in enhancement-refactor (pull request #30)
Enhancement refactor
2025-10-27 11:11:15 +00:00
2d5b264041 - refactored state for sessionlist, and session active and pause states 2025-10-27 11:04:53 +00:00
251a2f5e7b - refactored NPED Login & logout 2025-10-27 09:35:59 +00:00
18534ceb2c - added functionality to save sighting sessions to black board 2025-10-27 08:28:44 +00:00
9975e6a6ca Merged develop into enhancement/sessionstats 2025-10-24 11:12:51 +00:00
c83122cd52 - added session sighting component
- add new session paused state and stop adding to session when true
2025-10-24 12:10:10 +01:00
abc8007fc6 Merged in enhancement/sightingHits (pull request #29)
Enhancement/sightingHits
2025-10-24 09:56:25 +00:00
7903633809 - removed console.log 2025-10-24 10:55:05 +01:00
359f3781f2 - refactored to allow for stacking of special hits (NPED + Hotlist) 2025-10-24 10:49:04 +01:00
f264f4e808 - resolved coding error 2025-10-23 09:19:18 +01:00
0c6e4b57be - need to find better way to fetch file urls to use for sound 2025-10-22 16:12:49 +01:00
a958901bed - will pick up later 2025-10-22 12:20:15 +01:00
4519700561 - added new sound files
- new functionality to upload files
- need to get and locatate uploaded files
2025-10-22 11:51:37 +01:00
b58181e551 - started improvements on session page to include pause and save buttons 2025-10-22 08:54:42 +01:00
df6bf75184 Merged in bugfix/uploadsounds (pull request #28)
Bugfix/uploadsounds
2025-10-21 14:20:33 +00:00
c5cea81532 Merged develop into bugfix/uploadsounds 2025-10-21 14:19:14 +00:00
78905b09e0 - added framework for playing uploaded music files. need to permanently store and retreive files 2025-10-21 12:52:14 +01:00
1ffad51503 fixed feature to upload sound files 2025-10-20 16:17:37 +01:00
d16f55413c Merged main into develop 2025-10-20 10:54:41 +00:00
3598f8d069 Merged in develop (pull request #27)
Develop
2025-10-20 10:53:48 +00:00
0a3a543d6f Merged in bugfix/hostlistsound (pull request #26)
bugfix/hotlistsound
2025-10-20 10:35:44 +00:00
0867b3b743 - added sounds for all hotlist sounds 2025-10-20 11:32:45 +01:00
a152c15ec7 - removed console.log 2025-10-20 10:59:38 +01:00
617ea60f26 Merged in bugfix/AlanFeedback (pull request #25)
Bugfix/AlanFeedback
2025-10-20 09:54:13 +00:00
1b0790a841 Merged develop into bugfix/AlanFeedback 2025-10-20 09:53:46 +00:00
a54e6a79c1 - updated button to match 2025-10-20 10:50:16 +01:00
b2dd35b311 - added volume functionality for NPED notifications 2025-10-20 09:11:05 +01:00
82b84dc46e - added volume setting for sighting hits 2025-10-17 16:12:02 +01:00
34c996c990 Merged main into develop 2025-10-17 10:39:28 +00:00
bb82fad583 Merged in develop (pull request #23)
Develop
2025-10-17 10:30:02 +00:00
3eb539fd9d - start addressing Alans feedback 2025-10-17 10:17:01 +01:00
7b730a8029 Merged in enhancement/sessionpage (pull request #22)
Enhancement/sessionpage
2025-10-17 07:29:49 +00:00
c8f4ebf5a9 - improvements made to session page alert list 2025-10-15 16:11:10 +01:00
7cfebab6c1 - improved UI for sessions page 2025-10-15 15:15:04 +01:00
c6ddd04303 Merged in bugfix/minor-issues-5 (pull request #21)
Bugfix/minor issues 5
2025-10-15 12:56:03 +00:00
db925e18ac Merged develop into bugfix/minor-issues-5 2025-10-15 12:54:29 +00:00
c8b381d816 - code clean up 2025-10-15 12:43:14 +01:00
9c9b8cb6b0 - added plate patch to alert item
- added hotlist name pick up on modals
2025-10-15 12:24:28 +01:00
4da240a204 - added prettier config file
- improved sound state to remove pooling

- increased size of naviagtion arrows and fixed navigation onClick

-  decreased width of nav bar

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

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

163
README.md
View File

@@ -1,69 +1,116 @@
# React + TypeScript + Vite
# Mav Mobile UI
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
This is a React-based web application built with Vite (react and typescript).
Currently, two official plugins are available:
## Getting started
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
### Prerequisites
## Expanding the ESLint configuration
- Node.js (v18 or higher recommended)
- Yarn (v1.22+) (https://yarnpkg.com/)
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
### Installation
```js
export default tseslint.config([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
...tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
...tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
...tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```bash
git clone https://mavportal.com/TobaOjo/Mav-Mobile-UI.git
cd Mav-Mobile-UI
yarn install
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
### Running Locally
```bash
yarn dev
```
The app will be available at `http://localhost:5173`.
To run on locally on other devices
```bash
yarn dev --host
```
The app will be available at the exposed addresses to access e.g. http://1xx.xxx.x.xxx:<PORT>/Mobile
## Tech Stack
- **React** UI library
- **Vite** Build tool
- **Yarn** Package manager
## Configuration
Create a `.env` file to access the Mav Mobile box in unit 5 for or for any environment-specific settings:
```env
VITE_AGX_BOX_URL=http://1xx.xxx.xxx.xxx:<PORT>
```
## Development
### Linting & Formatting
```bash
yarn lint
yarn format
```
### Testing
(Currently not implemented consider adding Jest or Vitest)
## Deployment
To build for production:
Navigate to the Mav-Mobile-UI folder
```bash
cd Mav-Mobile-UI
```
**Delete** the local .env (Production will use its own domain)
run
```bash
yarn build
```
- Navigate to your Mav-Mobile-UI folder
- Select the Dist folder
- Compress to (ZIP)
- Log into box on Moba using Session > SSH and putting IP in Remote Host.
- Creds are mav:mav
- Drag and drop dist.zip into file explorer menu on left hand side (has to be named dist.zip exactly).
- Run command
```bash
sudo ./integrate-web-ui.sh
```
Run
```bash
sudo nano web-static/index.html
```
- add the following between the lines </body> & </html>
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default tseslint.config([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
<foot>
<script>
if (window.location.pathname !== "/Mobile") {
window.location.replace(window.location.origin + "/Mobile");
}
</script>
</foot>
```
- Run
```bash
sudo reboot
```
It should come back up all working

View File

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

View File

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

4308
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

18
public/Hotlist_Hit.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 18 KiB

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

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

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 204 KiB

497
public/NPED.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

28
public/NPED_Cat_A.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

29
public/NPED_Cat_B.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

27
public/NPED_Cat_C.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 23 KiB

View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,21 +1,30 @@
import { useGetOverviewSnapshot } from "../../hooks/useGetOverviewSnapshot";
import NavigationArrow from "../UI/NavigationArrow";
import Loading from "../UI/Loading";
import ErrorState from "../UI/ErrorState";
type SnapshotContainerProps = {
side: string;
settingsPage?: boolean;
zoomLevel?: number;
onZoomLevelChange?: (level: number) => void;
};
export const SnapshotContainer = ({
side,
settingsPage,
}: SnapshotContainerProps) => {
const { canvasRef } = useGetOverviewSnapshot(side);
export const SnapshotContainer = ({ side, settingsPage }: SnapshotContainerProps) => {
const { canvasRef, isError, isPending } = useGetOverviewSnapshot(side);
return (
<div className="relative w-full aspect-video">
<div className="flex flex-col md:flex-row">
<NavigationArrow side={side} settingsPage={settingsPage} />
<canvas ref={canvasRef} className="w-full h-full object-contain block" />
<div className="w-full bg-[#253445] rounded-md overflow-hidden md:h-[500px] lg:h-[70vh]">
{isError && <ErrorState />}
{isPending && (
<div className="absolute inset-0 grid place-items-center">
<Loading message="Camera Preview" />
</div>
)}
<canvas ref={canvasRef} className="absolute w-full h-full z-20" />
</div>
</div>
);
};

View File

@@ -1,48 +1,96 @@
import { Formik, Field, Form } from "formik";
import type {
CameraSettingErrorValues,
CameraSettingValues,
} from "../../types/types";
import { toast } from "sonner";
import type { CameraConfig, CameraSettingErrorValues, CameraSettingValues, ZoomInOptions } from "../../types/types";
import { useEffect, useMemo, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faEye, faEyeSlash } from "@fortawesome/free-regular-svg-icons";
import CardHeader from "../UI/CardHeader";
import { useCameraMode, useCameraZoom } from "../../hooks/useCameraZoom";
import { parseRTSPUrl, reverseZoomMapping, zoomMapping } from "../../utils/utils";
const CameraSettingFields = () => {
const initialValues: CameraSettingValues = {
friendlyName: "",
cameraAddress: "",
userName: "",
password: "",
setupCamera: 1,
};
type CameraSettingsProps = {
initialData: CameraConfig;
updateCameraConfig: (values: CameraSettingValues) => Promise<void> | void;
zoomLevel?: number;
onZoomLevelChange?: (level: number | undefined) => void;
updateCameraConfigError: null | Error;
};
const CameraSettingFields = ({
initialData,
updateCameraConfig,
zoomLevel,
onZoomLevelChange,
}: CameraSettingsProps) => {
const [showPwd, setShowPwd] = useState(false);
const cameraControllerSide = initialData?.id === "CameraA" ? "CameraControllerA" : "CameraControllerB";
const { mutation, query } = useCameraZoom({ camera: cameraControllerSide });
const { cameraModeQuery, cameraModeMutation } = useCameraMode({ camera: cameraControllerSide });
const zoomOptions = [1, 2, 4];
const magnification = query?.data?.propMagnification?.value;
const apiZoom = reverseZoomMapping(magnification);
const parsed = parseRTSPUrl(initialData?.propURI?.value);
const cameraMode = cameraModeQuery?.data?.propDayNightMode?.value;
useEffect(() => {
if (!query?.data) return;
onZoomLevelChange?.(apiZoom);
}, [query?.data, onZoomLevelChange, apiZoom]);
const initialValues = useMemo<CameraSettingValues>(
() => ({
friendlyName: initialData?.id ?? "",
cameraAddress: initialData?.propURI?.value ?? "",
userName: parsed?.username ?? "",
password: parsed?.password ?? "",
id: initialData?.id,
mode: cameraMode ?? "day",
zoom: apiZoom,
}),
[initialData?.id, initialData?.propURI?.value, parsed?.username, parsed?.password, cameraMode, apiZoom]
);
const validateValues = (values: CameraSettingValues) => {
const errors: CameraSettingErrorValues = {};
if (!values.friendlyName) errors.friendlyName = "Required";
if (!values.cameraAddress) errors.cameraAddress = "Required";
if (!values.userName) errors.userName = "Required";
if (!values.password) errors.password = "Required";
return errors;
};
const handleSubmit = (values: CameraSettingValues) => {
// post values to endpoint
toast("Settings Saved");
updateCameraConfig(values);
};
const handleRadioButtonChange = async (levelNumber: number) => {
if (!onZoomLevelChange || !zoomLevel) return;
const text = zoomMapping(levelNumber);
onZoomLevelChange(levelNumber);
const zoomInOptions: ZoomInOptions = {
camera: cameraControllerSide,
multiplier: levelNumber,
multiplierText: text,
};
mutation.mutate(zoomInOptions);
};
const selectedZoom = zoomLevel ?? 1;
return (
<Formik
initialValues={initialValues}
onSubmit={handleSubmit}
validate={validateValues}
validateOnChange={false}
enableReinitialize
>
{({ errors, touched, setFieldValue }) => (
<Form className="flex flex-col space-y-4 p-2">
{({ errors, touched, values, setFieldValue, isSubmitting }) => (
<Form className="flex flex-col space-y-6 p-2 overflow-x-hidden">
<div className="flex flex-col space-y-2 relative">
<label htmlFor="friendlyName">Friendly Name</label>
<label htmlFor="friendlyName">Name</label>
{touched.friendlyName && errors.friendlyName && (
<small className="absolute right-0 top-0 text-red-500">
{errors.friendlyName}
</small>
<small className="absolute right-0 top-0 text-red-500">{errors.friendlyName}</small>
)}
<Field
id="friendlyName"
@@ -53,30 +101,10 @@ const CameraSettingFields = () => {
/>
</div>
<div className="flex flex-col space-y-2 relative">
<label htmlFor="setupCamera">Setup Camera</label>
<Field
as="select"
id="setupCamera"
name="setupCamera"
className="p-2 border border-gray-400 rounded-lg text-white bg-[#253445]"
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
setFieldValue("setupCamera", parseInt(e.target.value, 10))
}
>
<option value={1}>1</option>
<option value={2}>2</option>
<option value={3}>3</option>
<option value={4}>4</option>
</Field>
</div>
<div className="flex flex-col space-y-2 relative">
<label htmlFor="cameraAddress">Camera Address</label>
{touched.cameraAddress && errors.cameraAddress && (
<small className="absolute right-0 top-0 text-red-500">
{errors.cameraAddress}
</small>
<small className="absolute right-0 top-0 text-red-500">{errors.cameraAddress}</small>
)}
<Field
id="cameraAddress"
@@ -84,16 +112,13 @@ const CameraSettingFields = () => {
type="text"
className="p-2 border border-gray-400 rounded-lg"
placeholder="RTSP://..."
autoComplete="street-address"
/>
</div>
<div className="flex flex-col space-y-2 relative">
<label htmlFor="userName">User Name</label>
{touched.userName && errors.userName && (
<small className="absolute right-0 top-0 text-red-500">
{errors.userName}
</small>
<small className="absolute right-0 top-0 text-red-500">{errors.userName}</small>
)}
<Field
id="userName"
@@ -108,26 +133,92 @@ const CameraSettingFields = () => {
<div className="flex flex-col space-y-2 relative">
<label htmlFor="password">Password</label>
{touched.password && errors.password && (
<small className="absolute right-0 top-0 text-red-500">
{errors.password}
</small>
<small className="absolute right-0 top-0 text-red-500">{errors.password}</small>
)}
<div className="flex gap-2 items-center relative mb-4">
<Field
id="password"
name="password"
type="password"
className="p-2 border border-gray-400 rounded-lg"
type={showPwd ? "text" : "password"}
className="p-2 border border-gray-400 rounded-lg w-full "
placeholder="Enter password"
autoComplete="new-password"
/>
<FontAwesomeIcon
type="button"
className="absolute right-5 end-0"
onClick={() => setShowPwd((s) => !s)}
icon={showPwd ? faEyeSlash : faEye}
/>
</div>
<button
type="submit"
className="bg-blue-800 text-white rounded-lg p-2 mx-auto"
<div className="my-3">
<CardHeader title="Zoom settings" />
<div className="mx-auto grid grid-cols-3 place-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"
>
Save settings
{zoomMapping(zoom)}
</label>
</div>
))}
</div>
</div>
<div>
<CardHeader title="Mode" />
<div
role="radiogroup"
aria-label="Camera mode"
className="mx-auto grid grid-cols-2 place-items-center gap-3"
>
{["day", "night"].map((el) => (
<div key={el} className="my-3">
<Field
type="radio"
name="mode"
value={el}
checked={values.mode === el}
id={`mode-${el}`}
className="peer hidden"
disabled={cameraModeMutation.isPending}
onChange={async () => {
setFieldValue("mode", el);
await cameraModeMutation.mutateAsync({ camera: cameraControllerSide, mode: el });
}}
/>
<label
htmlFor={`mode-${el}`}
className={`px-8 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
${cameraModeMutation.isPending ? "opacity-60 cursor-not-allowed" : "cursor-pointer"}`}
>
{el === "day" ? "Day" : "Night"}
</label>
</div>
))}
</div>
</div>
<div className="mt-3">
{
<button type="submit" className="bg-green-700 text-white rounded-lg p-2 mx-auto w-full">
{isSubmitting ? "Saving" : "Save settings"}
</button>
}
</div>
</div>
</Form>
)}
</Formik>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

@@ -0,0 +1,67 @@
import Card from "../UI/Card";
import CardHeader from "../UI/CardHeader";
import NPEDCatToggle from "../SettingForms/NPED/NPEDCatToggle";
import FormGroup from "../SettingForms/components/FormGroup";
import { Form, Formik } from "formik";
import { useIntegrationsContext } from "../../context/IntegrationsContext";
import { useCameraBlackboard } from "../../hooks/useCameraBlackboard";
import type { CategoryPopups } from "../../types/types";
import { toast } from "sonner";
const NPEDCategoryPopup = () => {
const { state, dispatch } = useIntegrationsContext();
const { mutation } = useCameraBlackboard();
const isCatAEnabled = state?.iscatEnabled?.catA;
const isCatBEnabled = state?.iscatEnabled?.catB;
const isCatCEnabled = state?.iscatEnabled?.catC;
const isCatDEnabled = state?.iscatEnabled?.catD;
const initialValues = {
catA: isCatAEnabled ?? true,
catB: isCatBEnabled ?? true,
catC: isCatCEnabled ?? true,
catD: isCatDEnabled ?? false,
};
const handleSubmit = async (values: CategoryPopups) => {
const result = await mutation.mutateAsync({
operation: "INSERT",
value: values,
path: "CategoryPopup",
});
if (result?.reason === "OK") toast.success("Pop up settings saved");
dispatch({ type: "NPEDCATENABLED", payload: values });
};
return (
<Card className="p-4">
<CardHeader title={"Alert Pop ups"} />
<p className="italic my-2">Allows alerts to pop up to user.</p>
<Formik initialValues={initialValues} onSubmit={handleSubmit}>
<Form className="flex flex-col space-y-5 px-2">
<FormGroup>
<NPEDCatToggle name={"catA"} label="NPED Category A" />
</FormGroup>
<FormGroup>
<NPEDCatToggle name={"catB"} label="NPED Category B" />
</FormGroup>
<FormGroup>
<NPEDCatToggle name={"catC"} label="NPED Category C" />
</FormGroup>
<FormGroup>
<NPEDCatToggle name={"catD"} label="NPED Category D" />
</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"
>
Save
</button>
</Form>
</Formik>
</Card>
);
};
export default NPEDCategoryPopup;

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,32 +1,34 @@
import { Field, useFormikContext } from "formik";
import FormToggle from "../components/FormToggle";
export const ValuesComponent = () => {
return null;
};
import FormGroup from "../components/FormGroup";
import type { BearerTypeFieldType, InitialValuesForm } from "../../../types/types";
const BearerTypeFields = () => {
const { values } = useFormikContext();
useFormikContext<BearerTypeFieldType & InitialValuesForm>();
return (
<div className="flex flex-col space-y-4">
<div className="flex items-center gap-3">
<div className="flex flex-col space-y-4 px-2">
<FormGroup>
<label htmlFor="format">Format</label>
<Field
as="select"
name="format"
id="format"
className="p-2 border border-gray-400 rounded-lg text-white bg-[#253445]"
className="p-2 border border-gray-400 rounded-lg text-white bg-[#253445] w-full md:w-60"
>
<option value="JSON">JSON</option>
<option value="BOF2">BOF2</option>
<option key={"JSON"} value={"JSON"}>
JSON
</option>
<option key={"BOF2"} value={"BOF2"}>
BOF2
</option>
</Field>
</div>
</FormGroup>
<FormGroup>
<div className="flex flex-col space-y-4">
<FormToggle name="enabled" label="Enabled" />
<FormToggle name="verbose" label="Verbose" />
</div>
</FormGroup>
</div>
);
};

View File

@@ -1,12 +1,54 @@
import { useFormikContext, type FormikTouched } from "formik";
import Card from "../../UI/Card";
import CardHeader from "../../UI/CardHeader";
import ChannelFields from "./ChannelFields";
import type { BearerTypeFieldType, InitialValuesForm } from "../../../types/types";
import { useCameraBackOfficeOutput } from "../../../hooks/useBackOfficeConfig";
import { useEffect, useMemo } from "react";
type ChannelCardProps = {
touched: FormikTouched<BearerTypeFieldType & InitialValuesForm>;
isSubmitting: boolean;
isBof2ConstantsLoading: boolean;
isDispatcherLoading: boolean;
};
const ChannelCard = ({ touched, isSubmitting, isBof2ConstantsLoading, isDispatcherLoading }: ChannelCardProps) => {
const { values, setFieldValue } = useFormikContext<BearerTypeFieldType & InitialValuesForm>();
const { backOfficeQuery } = useCameraBackOfficeOutput(values?.format);
const isBackOfficeQueryLoading = backOfficeQuery?.isFetching;
const mapped = useMemo(() => {
const d = backOfficeQuery?.data;
return {
backOfficeURL: d?.propBackofficeURL?.value ?? "",
username: d?.propUsername?.value ?? "",
password: d?.propPassword?.value ?? "",
connectTimeoutSeconds: Number(d?.propConnectTimeoutSeconds?.value),
readTimeoutSeconds: Number(d?.propReadTimeoutSeconds?.value),
};
}, [backOfficeQuery?.data]);
useEffect(() => {
if (!backOfficeQuery?.isSuccess) return;
for (const [key, value] of Object.entries(mapped)) {
setFieldValue(key, value);
}
}, [backOfficeQuery.isSuccess, mapped, setFieldValue]);
const ChannelCard = () => {
return (
<Card>
<CardHeader title="Channel 1 (JSON)" />
<ChannelFields />
<Card className="p-4 overflow-y-auto ">
<CardHeader title={`Channel (${values?.format})`} />
{!isBof2ConstantsLoading && !isDispatcherLoading && !isBackOfficeQueryLoading ? (
<ChannelFields
touched={touched}
isSubmitting={isSubmitting}
backOfficeData={backOfficeQuery}
format={values?.format}
/>
) : (
<>Loading...</>
)}
</Card>
);
};

View File

@@ -1,21 +1,64 @@
import { Field, useFormikContext } from "formik";
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Field, useFormikContext, type FormikTouched } from "formik";
import FormGroup from "../components/FormGroup";
import { useEffect, useState } from "react";
import { faEyeSlash, faEye } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import type { BearerTypeFieldType, InitialValuesForm } from "../../../types/types";
import { toast } from "sonner";
import type { UseQueryResult } from "@tanstack/react-query";
const ChannelFields = () => {
useFormikContext();
type ChannelFieldsProps = {
touched: FormikTouched<BearerTypeFieldType & InitialValuesForm>;
isSubmitting: boolean;
backOfficeData: UseQueryResult<any, Error>;
format?: string;
};
const ChannelFields = ({ touched, isSubmitting, format }: ChannelFieldsProps) => {
const [showPwd, setShowPwd] = useState(false);
const { submitCount, isValid, values, errors } = useFormikContext<BearerTypeFieldType & InitialValuesForm>();
const ValidationToastOnce = () => {
useEffect(() => {
if (submitCount > 0 && !isValid) {
toast.error("Check fields are filled in");
}
}, []);
return null;
};
return (
<div className="flex flex-col space-y-2">
<>
{format?.toLowerCase() !== "bof2" && format?.toLowerCase() !== "json" ? (
<>
<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">
Format coming soon
</div>
<p className="max-w-md text-slate-300">
Output configuration currently supports <span className="font-bold text-blue-400">JSON</span> or{" "}
<span className="font-bold text-emerald-400">BOF2</span>. <br /> More formats will be added in future
updates.
</p>
</div>
</>
) : (
<>
<div className="flex flex-col space-y-2 px-2">
<FormGroup>
<label htmlFor="backoffice" className="m-0">
Back Office URL
</label>
<Field
name={"backOfficeURL"}
type="text"
id="backoffice"
placeholder="https://www.backoffice.com"
className="p-1.5 border border-gray-400 rounded-lg"
className={`p-1.5 border border-gray-400 rounded-lg w-full md:w-60`}
/>
</FormGroup>
<FormGroup>
@@ -25,26 +68,39 @@ const ChannelFields = () => {
type="text"
id="username"
placeholder="Back office username"
className="p-1.5 border border-gray-400 rounded-lg"
className={`p-1.5 border border-gray-400 rounded-lg w-full md:w-60`}
/>
</FormGroup>
<FormGroup>
<label htmlFor="password">Password</label>
<div className="flex gap-2 items-center relative mb-4">
<Field
name={"password"}
type="password"
type={showPwd ? "text" : "password"}
id="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>
<label htmlFor="connectTimeoutSeconds">Connect Timeout Seconds</label>
<Field
name={"connectTimeoutSeconds"}
type="number"
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>
@@ -54,10 +110,138 @@ const ChannelFields = () => {
type="number"
id="readTimeoutSeconds"
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>
{/* Overview quality and scale */}
<FormGroup>
<label htmlFor="overviewQuality">Overview quality and scale</label>
<Field
name={"overviewQuality"}
as="select"
id="overviewQuality"
className="p-2 border border-gray-400 rounded-lg text-white bg-[#253445] w-full md:w-60"
>
<option value={"HIGH"}>High</option>
<option value={"MEDIUM"}>Medium</option>
<option value={"LOW"}>Low</option>
</Field>
</FormGroup>
{/* propOverviewImageScaleFactor cropSizeFactor */}
<FormGroup>
<label htmlFor="cropSizeFactor">Crop Size Factor</label>
<Field
name={"cropSizeFactor"}
as="select"
id="cropSizeFactor"
className="p-2 border border-gray-400 rounded-lg text-white bg-[#253445] w-full md:w-60"
>
<option value={"FULL"}>Full</option>
<option value={"3/4"}>3/4</option>
<option value={"1/2"}>1/2</option>
<option value={"1/4"}>1/4</option>
</Field>
</FormGroup>
{format?.toLowerCase() === "bof2" && (
<>
<div className="space-y-3">
<div className="border-b border-gray-500 my-3">
<h2 className="font-bold">{values.format} Constants</h2>
</div>
<FormGroup>
<label htmlFor="FFID">Feed ID / Force ID</label>
<Field
name={"FFID"}
type="text"
id="FFID"
placeholder="ABC123"
className={`p-1.5 border ${
errors.readTimeoutSeconds && touched.readTimeoutSeconds ? "border-red-500" : "border-gray-400 "
} rounded-lg w-full md:w-60`}
/>
</FormGroup>
<FormGroup>
<label htmlFor="SCID">Source ID / Camera ID</label>
<Field
name={"SCID"}
type="text"
id="SCID"
placeholder="DEF345"
className={`p-1.5 border ${
errors.readTimeoutSeconds && touched.readTimeoutSeconds ? "border-red-500" : "border-gray-400 "
} rounded-lg w-full md:w-60`}
/>
</FormGroup>
<FormGroup>
<label htmlFor="timestampSource">Timestamp Source</label>
<Field
name={"timestampSource"}
as="select"
id="timestampSource"
className="p-2 border border-gray-400 rounded-lg text-white bg-[#253445] w-full md:w-60"
>
<option value={"UTC"}>UTC</option>
<option value={"local"}>Local</option>
</Field>
</FormGroup>
<FormGroup>
<label htmlFor="GPSFormat">GPS Format</label>
<Field
name={"GPSFormat"}
as="select"
id="GPSFormat"
className="p-2 border border-gray-400 rounded-lg text-white bg-[#253445] w-full md:w-60"
>
<option value={"Minutes"}>Minutes</option>
<option value={"Decimal Degrees"}>Decimal degrees</option>
</Field>
</FormGroup>
</div>
<div className="space-y-3">
<div className="border-b border-gray-500 my-3">
<h2 className="font-bold">{values.format} Lane ID Config</h2>
</div>
<FormGroup>
<label htmlFor="LID1">Lane ID 1 (Camera A)</label>
<Field
name={"LID1"}
type="text"
id="LID1"
placeholder="10"
className={`p-1.5 border ${
errors.readTimeoutSeconds && touched.readTimeoutSeconds ? "border-red-500" : "border-gray-400 "
} rounded-lg w-full md:w-60`}
/>
</FormGroup>
<FormGroup>
<label htmlFor="LID2">Lane ID 2 (Camera B)</label>
<Field
name={"LID2"}
type="text"
id="LID2"
placeholder="20"
className={`p-1.5 border ${
errors.readTimeoutSeconds && touched.readTimeoutSeconds ? "border-red-500" : "border-gray-400 "
} rounded-lg w-full md:w-60`}
/>
</FormGroup>
</div>
</>
)}
</div>
<button
type="submit"
className="w-full md:w-1/4 text-white bg-green-700 hover:bg-green-800 font-small rounded-lg text-sm px-2 py-2.5"
>
{isSubmitting ? "Saving..." : "Save Changes"}
</button>
<ValidationToastOnce />
</>
)}
</>
);
};

View File

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

View File

@@ -0,0 +1,22 @@
import { Field } from "formik";
type NPEDToggleProps = {
name: string;
label: string;
};
const NPEDCatToggle = ({ name, label }: NPEDToggleProps) => {
return (
<label className="flex items-center gap-3 cursor-pointer select-none w-full justify-between">
<span className="text-lg">{label}</span>
<Field id={name} type="checkbox" name={name} className="sr-only peer" />
<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"
/>
</label>
);
};
export default NPEDCatToggle;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,82 +1,150 @@
import { Formik, Form } from "formik";
import { Form, Formik } from "formik";
import BearerTypeCard from "../BearerType/BearerTypeCard";
import ChannelCard from "../Channel1-JSON/ChannelCard";
import type { InitialValuesForm } from "../../../types/types";
import { useState } from "react";
import AdvancedToggle from "../../UI/AdvancedToggle";
import OverviewTextCard from "../OverviewText/OverviewTextCard";
import SightingDataCard from "../SightingData/SightingDataCard";
import { useCameraOutput, useGetDispatcherConfig } from "../../../hooks/useCameraOutput";
import type {
BearerTypeFieldType,
InitialValuesForm,
InitialValuesFormErrors,
OptionalBOF2Constants,
OptionalBOF2LaneIDs,
} from "../../../types/types";
import { useQueryClient } from "@tanstack/react-query";
import { useUpdateBackOfficeConfig } from "../../../hooks/useBackOfficeConfig";
import { useFormVaidate } from "../../../hooks/useFormValidate";
import { useSightingAmend } from "../../../hooks/useSightingAmend";
import StoreCard from "../Store/StoreCard";
const SettingForms = () => {
const [advancedToggle, setAdvancedToggle] = useState(false);
const qc = useQueryClient();
const { dispatcherQuery, dispatcherMutation, backOfficeDispatcherMutation, bof2LandMutation, laneIdQuery } =
useCameraOutput();
const { backOfficeMutation } = useUpdateBackOfficeConfig();
const { bof2ConstantsQuery } = useGetDispatcherConfig();
const { validateMutation } = useFormVaidate();
const { sightingAmendQuery, sightingAmendMutation } = useSightingAmend();
const initialValues = {
format: "JSON",
enabled: false,
verbose: false,
const format = dispatcherQuery?.data?.propFormat?.value;
const enabled = dispatcherQuery?.data?.propEnabled?.value;
const sightingQuality = sightingAmendQuery?.data?.propOverviewQuality?.value;
const cropSizeFactor = sightingAmendQuery?.data?.propOverviewImageScaleFactor?.value;
const laneID = laneIdQuery?.data?.id;
const LID1 = laneIdQuery?.data?.propLaneID1?.value;
const LID2 = laneIdQuery?.data?.propLaneID2?.value;
const FFID = bof2ConstantsQuery?.data?.propFeedIdentifier?.value;
const SCID = bof2ConstantsQuery?.data?.propSourceIdentifier?.value;
const GPSFormat = bof2ConstantsQuery?.data?.propGpsFormat?.value;
const timestampSource = bof2ConstantsQuery?.data?.propTimeZoneType?.value;
const isDispatcherLoading = dispatcherQuery?.isFetching;
const isBof2ConstantsLoading = bof2ConstantsQuery?.isFetching;
const initialValues: BearerTypeFieldType & InitialValuesForm & OptionalBOF2Constants & OptionalBOF2LaneIDs = {
format: format ?? "JSON",
enabled: enabled === "true",
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",
connectTimeoutSeconds: Number(5),
readTimeoutSeconds: Number(15),
overviewQuality: sightingQuality ?? "HIGH",
cropSizeFactor: cropSizeFactor ?? "3/4",
// Bof2 - optional constants
FFID: FFID ?? "",
SCID: SCID ?? "",
timestampSource: timestampSource ?? "",
GPSFormat: GPSFormat ?? "",
//BOF2 - optional Lane IDs
laneId: laneID ?? "",
LID1: LID1 ?? "",
LID2: LID2 ?? "",
};
const handleSubmit = (values: InitialValuesForm) => {
alert(JSON.stringify(values));
const validateValues = (values: InitialValuesForm): InitialValuesFormErrors => {
const errors: InitialValuesFormErrors = {};
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;
};
const handleSubmit = async (
values: BearerTypeFieldType & InitialValuesForm & OptionalBOF2Constants & OptionalBOF2LaneIDs
) => {
const validResponse = await validateMutation.mutateAsync(values);
const dispatcherData = {
format: values.format,
enabled: values.enabled,
};
const result = await dispatcherMutation.mutateAsync(dispatcherData);
if (result?.id) {
qc.invalidateQueries({ queryKey: ["dispatcher"] });
qc.invalidateQueries({ queryKey: ["backoffice", values.format] });
if (validResponse?.reason === "OK") {
await backOfficeMutation.mutateAsync(values);
await sightingAmendMutation.mutateAsync(values);
if (values.format.toLowerCase() === "bof2") {
const bof2ConstantsData: OptionalBOF2Constants = {
FFID: values.FFID,
SCID: values.SCID,
timestampSource: values.timestampSource,
GPSFormat: values.GPSFormat,
};
const bof2LaneData: OptionalBOF2LaneIDs = {
laneId: laneIdQuery?.data?.id,
LID1: values.LID1,
LID2: values.LID2,
};
await bof2LandMutation.mutateAsync(bof2LaneData);
await backOfficeDispatcherMutation.mutateAsync(bof2ConstantsData);
}
} else {
console.log("error");
return;
}
}
};
return (
<Formik initialValues={initialValues} onSubmit={handleSubmit}>
<Form className="flex flex-col space-y-3">
<Formik initialValues={initialValues} onSubmit={handleSubmit} validate={validateValues} enableReinitialize>
{({ isSubmitting, touched }) => (
<Form>
<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>
<BearerTypeCard />
<ChannelCard />
<StoreCard />
</div>
<AdvancedToggle
advancedToggle={advancedToggle}
onAdvancedChange={setAdvancedToggle}
<ChannelCard
touched={touched}
isSubmitting={isSubmitting}
isDispatcherLoading={isDispatcherLoading}
isBof2ConstantsLoading={isBof2ConstantsLoading}
/>
{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 = () => {
return (
<Card>
<Card className="p-4">
<CardHeader title={"Sighting Data"} />
<SightingDataFields />
</Card>

View File

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

View File

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

View File

@@ -0,0 +1,152 @@
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-full md: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,110 @@
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";
import { useFileUpload } from "../../../hooks/useFileUpload";
const SoundUpload = () => {
const { state, dispatch } = useSoundContext();
const { mutation } = useCameraBlackboard();
const { mutation: fileMutation } = useFileUpload({
queryKey: state.sightingSound ? [state.sightingSound] : undefined,
});
const initialValues: SoundUploadValue = {
name: "",
soundFile: null,
soundFileName: "",
soundUrl: "",
uploadedAt: Date.now(),
};
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,
});
await fileMutation.mutateAsync(values.soundFile);
if (result.reason !== "OK") {
toast.error("Cannot update sound settings");
}
dispatch({ type: "ADD", payload: values });
};
return (
<Formik initialValues={initialValues} onSubmit={handleSubmit} enableReinitialize>
{({ setFieldValue, errors, setFieldError }) => (
<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]);
setFieldValue("uploadedAt", Date.now());
if (e?.target?.files[0]?.size >= 1 * 1024 * 1024) {
setFieldError("soundFile", "larger than 1mb");
toast.error("File larger than 1MB");
return;
}
} 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">
<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-full md: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-5 lg:col-span-3 w-full">
<CardHeader title={"Sound upload"} />
<SoundUpload />
</Card>
);
};
export default SoundUploadCard;

View File

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

View File

@@ -0,0 +1,29 @@
import { useStoreDispatch } from "../../../hooks/useStoreDispatch";
import VehicleSessionItem from "../../UI/VehicleSessionItem";
const StoreFields = () => {
const { storeQuery } = useStoreDispatch();
const totalPending = storeQuery?.data?.totalPending;
const totalActive = storeQuery?.data?.totalActive;
const totalSent = storeQuery?.data?.totalSent;
const totalReceived = storeQuery?.data?.totalReceived;
const totalLost = storeQuery?.data?.totalLost;
if (storeQuery.isLoading) return <div className="p-4">Loading store data...</div>;
if (storeQuery.error) return <div className="p-4">Error: {storeQuery.error.message}</div>;
return (
<div className="p-4">
<ul className="text-white space-y-3">
<VehicleSessionItem sessionNumber={totalActive} textColour="text-gray-400" vehicleTag={"Total Active:"} />
<VehicleSessionItem sessionNumber={totalSent} textColour="text-blue-400" vehicleTag={"Total Sent:"} />
<VehicleSessionItem sessionNumber={totalReceived} textColour="text-green-400" vehicleTag={"Total Received:"} />
<VehicleSessionItem sessionNumber={totalPending} textColour="text-amber-400" vehicleTag={"Total Pending:"} />
<VehicleSessionItem sessionNumber={totalLost} textColour="text-red-400" vehicleTag={"Total Lost:"} />
</ul>
</div>
);
};
export default StoreFields;

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,46 +1,60 @@
export async function handleSystemSave(deviceName: string, sntpServer: string, sntpInterval: number, timeZone: string) {
const payload = { // Build JSON
import { toast } from "sonner";
import type { SystemValues } from "../../../types/types";
import { CAM_BASE } from "../../../utils/config";
const camBase = import.meta.env.MODE !== "development" ? CAM_BASE : "";
export async function handleSystemSave(values: SystemValues) {
const payload = {
// Build JSON
id: "GLOBAL--Device",
fields: [
{ property: "propDeviceName", value: deviceName },
{ property: "propSNTPServer", value: sntpServer },
{ property: "propSNTPIntervalMinutes", value: Number(sntpInterval) },
{ property: "propLocalTimeZone", value: timeZone }
]
{ property: "propDeviceName", value: values.deviceName },
{ property: "propSNTPServer", value: values.sntpServer },
{
property: "propSNTPIntervalMinutes",
value: Number(values.sntpInterval),
},
{ property: "propLocalTimeZone", value: values.timeZone },
],
};
try {
const response = await fetch("http://192.168.75.11/api/update-config", {
const response = await fetch(`${camBase}/api/update-config`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json"
Accept: "application/json",
},
body: JSON.stringify(payload)
body: JSON.stringify(payload),
});
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new Error(`HTTP ${response.status} ${response.statusText}${text ? ` - ${text}` : ""}`);
}
alert("System Settings Saved Successfully!");
} catch (err) {
if (err instanceof Error) {
toast.error(`Failed to save system settings: ${err.message}`);
console.error(err);
} else {
toast.error("An unexpected error occurred while saving.");
console.error("Unknown error:", err);
}
}
}
export async function handleSystemRecall() {
const url = "http://192.168.75.11/api/fetch-config?id=GLOBAL--Device";
const url = `${camBase}/api/fetch-config?id=GLOBAL--Device`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 7000);
const timeoutId = setTimeout(() => controller.abort(), 70000);
try {
const response = await fetch(url, {
method: "GET",
headers: { "Accept": "application/json" },
signal: controller.signal
headers: { Accept: "application/json" },
signal: controller.signal,
});
if (!response.ok) {
@@ -54,11 +68,9 @@ export async function handleSystemRecall() {
const sntpServer = data?.propSNTPServer?.value ?? null;
const timeZone = data?.propLocalTimeZone?.value ?? null;
let sntpIntervalRaw = data?.propSNTPIntervalMinutes?.value;
const sntpIntervalRaw = data?.propSNTPIntervalMinutes?.value;
let sntpInterval =
typeof sntpIntervalRaw === "number"
? sntpIntervalRaw
: Number.parseInt(String(sntpIntervalRaw).trim(), 10);
typeof sntpIntervalRaw === "number" ? sntpIntervalRaw : Number.parseInt(String(sntpIntervalRaw).trim(), 10);
if (!Number.isFinite(sntpInterval)) {
sntpInterval = 60;
@@ -66,7 +78,12 @@ export async function handleSystemRecall() {
return { deviceName, sntpServer, sntpInterval, timeZone };
} catch (err) {
console.error(err);
if (err instanceof Error) {
toast.error(`Error: ${err.message}`);
} else {
toast.error("An unexpected error occurred");
}
return null;
} finally {
clearTimeout(timeoutId);

View File

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

View File

@@ -0,0 +1,200 @@
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 { useDNSSettings, useSystemConfig } from "../../../hooks/useSystemConfig";
import { ValidateIPaddress } from "../../../utils/utils";
import { toast } from "sonner";
const SystemConfigFields = () => {
const { saveSystemSettings, systemSettingsData, saveSystemSettingsLoading } = useSystemConfig();
const { hardRebootMutation } = useReboots();
const { dnsQuery, dnsMutation } = useDNSSettings();
const dnsPrimary = dnsQuery?.data?.propNameServerPrimary?.value;
const dnsSecondary = dnsQuery?.data?.propNameServerSecondary?.value;
const initialvalues: SystemValues = {
deviceName: systemSettingsData?.deviceName ?? "",
timeZone: systemSettingsData?.timeZone ?? "",
sntpServer: systemSettingsData?.sntpServer ?? "",
sntpInterval: systemSettingsData?.sntpInterval ?? 60,
serverPrimary: dnsPrimary ?? "",
serverSecondary: dnsSecondary ?? "",
softwareUpdate: null,
};
const handleSubmit = async (values: SystemValues) => {
saveSystemSettings(values);
await dnsMutation.mutateAsync(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";
const invalidPrimary = ValidateIPaddress(values.serverPrimary);
const invalidSecondary = ValidateIPaddress(values.serverSecondary);
if (invalidPrimary || invalidSecondary) {
toast.error(invalidPrimary || invalidSecondary, {
id: "invalid-ip",
});
}
return errors;
};
// const handleSoftReboot = async () => {
// await softRebootMutation.mutate();
// };
const handleHardReboot = async () => {
await hardRebootMutation.mutate();
};
return (
<Formik
initialValues={initialvalues}
onSubmit={handleSubmit}
validate={validateValues}
enableReinitialize
validateOnChange={false}
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>
<FormGroup>
<label htmlFor="serverPrimary" className="font-medium whitespace-nowrap md:w-1/2 text-left">
Primary DNS Server
</label>
<Field
id="serverPrimary"
name="serverPrimary"
type="text"
className="p-2 border border-gray-400 rounded-lg w-full max-w-xs"
placeholder="Enter DNS primary address"
autoComplete="off"
/>
</FormGroup>
<FormGroup>
<label htmlFor="serverSecondary" className="font-medium whitespace-nowrap md:w-1/2 text-left">
Secondary DNS Server
</label>
<Field
id="serverSecondary"
name="serverSecondary"
type="text"
className="p-2 border border-gray-400 rounded-lg w-full max-w-xs"
placeholder="Enter DNS secondary address"
autoComplete="off"
/>
</FormGroup>
<button
type="submit"
className="w-full md: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-full md: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,57 @@
// 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");
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,12 @@
import Card from "../../UI/Card";
import CardHeader from "../../UI/CardHeader";
import { useState } from "react";
import FormGroup from "../components/FormGroup";
import ModemSettings from "./ModemSettings";
const ModemCard = () => {
const [apn, setApn] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [authType, setAuthType] = useState("PAP");
return (
<Card>
<Card className="p-4">
<CardHeader title={"Modem"} />
<div className="flex flex-col gap-4">
<FormGroup>
<label htmlFor="apn" className="font-medium whitespace-nowrap md:w-2/3">APN</label>
<input
id="apn"
name="apn"
type="text"
className="p-2 border border-gray-400 rounded-lg flex-1 md:w-2/3"
placeholder="Enter APN"
value={apn}
onChange={e => setApn(e.target.value)}
/>
</FormGroup>
<FormGroup>
<label htmlFor="modemUsername" className="font-medium whitespace-nowrap md:w-2/3">Username</label>
<input
id="modemUsername"
name="modemUsername"
type="text"
className="p-2 border border-gray-400 rounded-lg flex-1 md:w-2/3"
placeholder="Enter Username"
value={username}
onChange={e => setUsername(e.target.value)}
/>
</FormGroup>
<FormGroup>
<label htmlFor="modemPassword" className="font-medium whitespace-nowrap md:w-2/3">Password</label>
<input
id="modemPassword"
name="modemPassword"
type="password"
className="p-2 border border-gray-400 rounded-lg flex-1 md:w-2/3"
placeholder="Enter Password"
value={password}
onChange={e => setPassword(e.target.value)}
/>
</FormGroup>
<FormGroup>
<label htmlFor="authType" className="font-medium whitespace-nowrap md:w-2/3">Authentication Type</label>
<select
id="authType"
name="authType"
className="p-2 border border-gray-400 rounded-lg text-white bg-[#253445] flex-1 md:w-2/3"
value={authType}
onChange={e => setAuthType(e.target.value)}
>
<option value="PAP">PAP</option>
<option value="CHAP">CHAP</option>
<option value="None">None</option>
</select>
</FormGroup>
<button
className="bg-[#26B170] text-white px-4 py-2 rounded hover:bg-green-700 transition w-full max-w-md"
//onClick={() => handleModemSave(apn, username, password, authType)}
>
Save Modem Settings
</button>
</div>
<ModemSettings />
</Card>
);
};

View File

@@ -0,0 +1,193 @@
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";
import { ValidateIPaddress } from "../../../utils/utils";
import { toast, Toaster } from "sonner";
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;
const serverPrimary = modemQuery?.data?.propNameServerPrimary?.value;
const serverSecondary = modemQuery?.data?.propNameServerSecondary?.value;
useEffect(() => {
setShowSettings(mode === "AUTO");
}, [mode]);
const inititalValues = {
apn: apn ?? "",
username: username ?? "",
password: password ?? "",
authenticationType: "PAP",
serverPrimary: serverPrimary ?? "",
serverSecondary: serverSecondary ?? "",
};
const handleSubmit = async (values: ModemSettingsType) => {
const invalidPrimary = ValidateIPaddress(values.serverPrimary);
const invalidSecondary = ValidateIPaddress(values.serverSecondary);
if (invalidPrimary || invalidSecondary) {
toast.error(invalidPrimary || invalidSecondary, {
id: "invalid-ip",
});
return;
}
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",
},
{
property: "propNameServerPrimary",
value: values.serverPrimary,
},
{
property: "propNameServerSecondary",
value: values.serverSecondary,
},
],
};
const response = await modemMutation.mutateAsync(modemConfig);
if (!response?.id) {
toast.success("Modem settings updated successfully", {
id: "modemSettings",
});
}
};
return (
<>
<ModemToggle showSettings={showSettings} onShowSettings={setShowSettings} />
<Formik initialValues={inititalValues} onSubmit={handleSubmit} enableReinitialize>
{({ isSubmitting }) => (
<Form className="flex flex-col space-y-5 px-2">
{!showSettings && (
<>
<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="serverPrimary" className="font-medium whitespace-nowrap md:w-2/3">
Name server primary
</label>
<Field
placeholder="Enter Server primary"
name="serverPrimary"
id="serverPrimary"
type="text"
className="p-1.5 border border-gray-400 rounded-lg"
/>
</FormGroup>
<FormGroup>
<label htmlFor="serverSecondary" className="font-medium whitespace-nowrap md:w-2/3">
Name server secondary
</label>
<Field
placeholder="Enter Server secondary"
name="serverSecondary"
id="serverSecondary"
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">
Authentication Type
</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-full md: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>
<Toaster />
</>
);
};
export default ModemSettings;

View File

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

View File

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

View File

@@ -0,0 +1,116 @@
import { Field, Form, Formik } from "formik";
import FormGroup from "../components/FormGroup";
import type { WifiSettingValues } from "../../../types/types";
import { useWifiAndModem } from "../../../hooks/useCameraWifiandModem";
import { useState } from "react";
import { faEyeSlash, faEye } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { toast, Toaster } from "sonner";
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 validatePassword = (password: string) => {
if (password.length < 8) {
toast.error("Password must be at least 8 characters long", { id: "password" });
return "Password must be at least 8 characters long";
}
};
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"
validate={validatePassword}
/>
<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>
<Toaster />
</>
);
};
export default WiFiSettingsForm;

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,19 +1,13 @@
import { useCallback, useRef, useState } from "react";
import { BLANK_IMG } from "../../utils/utils";
import SightingWidgetDetails from "../SightingsWidget/SightingWidgetDetails";
import { useOverviewOverlay } from "../../hooks/useOverviewOverlay";
import { useSightingFeedContext } from "../../context/SightingFeedContext";
import { useHiDPICanvas } from "../../hooks/useHiDPICanvas";
import NavigationArrow from "../UI/NavigationArrow";
import { useSwipeable } from "react-swipeable";
import { useNavigate } from "react-router";
import NumberPlate from "../PlateStack/NumberPlate";
import Loading from "../UI/Loading";
const SightingOverview = () => {
const navigate = useNavigate();
const handlers = useSwipeable({
onSwipedRight: () => navigate("/front-camera-settings"),
trackMouse: true,
});
const [overlayMode, setOverlayMode] = useState<0 | 1 | 2>(0);
const imgRef = useRef<HTMLImageElement | null>(null);
@@ -23,19 +17,43 @@ const SightingOverview = () => {
setOverlayMode((m) => ((m + 1) % 3) as 0 | 1 | 2);
}, []);
const { effectiveSelected, side, mostRecent, noSighting, isPending } =
useSightingFeedContext();
const { side, mostRecent, isError, isLoading } = useSightingFeedContext();
useOverviewOverlay(mostRecent, overlayMode, imgRef, canvasRef);
const { sync } = useHiDPICanvas(imgRef, canvasRef);
if (noSighting || isPending) return <p>loading</p>;
if (isLoading)
return (
<div className="mt-2 grid gap-3">
<div className="inline-block w-[90%] mx-auto" {...handlers}>
<div className="h-150 flex items-center justify-center">
<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
ref={imgRef}
onLoad={() => {
@@ -44,7 +62,7 @@ const SightingOverview = () => {
}}
src={mostRecent?.overviewUrl || BLANK_IMG}
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}
style={{
display: mostRecent?.overviewUrl ? "block" : "none",
@@ -52,23 +70,10 @@ const SightingOverview = () => {
/>
<canvas
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>
<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,25 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import type { SightingWidgetType } from "../../types/types";
import { BLANK_IMG, capitalize, formatAge } from "../../utils/utils";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { HitKind, QueuedHit, ReducedSightingType, SightingType } from "../../types/types";
import { BLANK_IMG } from "../../utils/utils";
import NumberPlate from "../PlateStack/NumberPlate";
import Card from "../UI/Card";
import CardHeader from "../UI/CardHeader";
import clsx from "clsx";
import { useSightingFeedContext } from "../../context/SightingFeedContext";
import SightingModal from "../SightingModal/SightingModal";
import { useAlertHitContext } from "../../context/AlertHitContext";
import HotListImg from "/Hotlist_Hit.svg";
import NPED_CAT_A from "/NPED_Cat_A.svg";
import NPED_CAT_B from "/NPED_Cat_B.svg";
import NPED_CAT_C from "/NPED_Cat_C.svg";
import popup from "../../assets/sounds/ui/popup_open.mp3";
import notification from "../../assets/sounds/ui/notification.mp3";
import { useSound } from "react-sounds";
import { useIntegrationsContext } from "../../context/IntegrationsContext";
import { useSoundContext } from "../../context/SoundContext";
import Loading from "../UI/Loading";
import { checkIsHotListHit, getNPEDCategory } from "../../utils/utils";
import { useCachedSoundSrc } from "../../hooks/usecachedSoundSrc";
function useNow(tickMs = 1000) {
const [, setNow] = useState(() => Date.now());
@@ -13,107 +27,209 @@ function useNow(tickMs = 1000) {
const id = setInterval(() => setNow(Date.now()), tickMs);
return () => clearInterval(id);
}, [tickMs]);
return undefined;
return null;
}
export type SightingHistoryProps = {
baseUrl: string;
entries?: number; // number of rows to show
pollMs?: number; // poll frequency
type SightingHistoryProps = {
baseUrl?: string;
entries?: number;
pollMs?: number;
autoSelectLatest?: boolean;
title: string;
className?: string;
};
type SightingHistoryWidgetProps = React.HTMLAttributes<HTMLDivElement>;
export default function SightingHistoryWidget({
className,
}: SightingHistoryWidgetProps) {
export default function SightingHistoryWidget({ className, title }: SightingHistoryProps) {
const [modalQueue, setModalQueue] = useState<QueuedHit[]>([]);
useNow(1000);
const { sightings, selectedRef, setSelectedRef } = useSightingFeedContext();
const { state } = useSoundContext();
const { src: soundSrcHotlist } = useCachedSoundSrc(state?.hotlistSound, state?.soundOptions, notification);
const { src: soundSrcNped } = useCachedSoundSrc(state?.NPEDsound, state?.soundOptions, popup);
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, state: alertState } = useAlertHitContext();
const { state: integrationState, dispatch: integrationDispatch } = useIntegrationsContext();
const sessionStarted = integrationState.sessionStarted;
const sessionPaused = integrationState.sessionPaused;
const processedRefs = useRef<Set<number | string>>(new Set());
const hasAutoOpenedRef = useRef(false);
const npedRef = useRef(false);
const isCatAEnabled = integrationState?.iscatEnabled?.catA;
const isCatBEnabled = integrationState?.iscatEnabled?.catB;
const isCatCEnabled = integrationState?.iscatEnabled?.catC;
const isCatDEnabled = integrationState?.iscatEnabled?.catD;
const enqueue = useCallback((sighting: SightingType, kind: HitKind) => {
const id = sighting.vrm ?? sighting.ref;
if (processedRefs.current.has(id)) return;
const inList = alertState?.alertList?.find((sighting) => sighting.vrm === id);
if (inList) {
return;
}
processedRefs.current.add(id);
setModalQueue((q) => [...q, { id, sighting, kind }]);
}, []);
const reduceObject = (obj: SightingType): ReducedSightingType => {
return {
vrm: obj.vrm,
metadata: obj?.metadata,
};
};
useEffect(() => {
if (sessionStarted) {
if (!mostRecent) return;
if (sessionPaused) return;
const reducedMostRecent = reduceObject(mostRecent);
integrationDispatch({ type: "ADD", payload: reducedMostRecent });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mostRecent, sessionStarted]);
const onRowClick = useCallback(
(ref: number) => {
setSelectedRef(ref);
(sighting: SightingType) => {
if (!sighting) return;
setSightingModalOpen(true);
setSelectedSighting(sighting);
},
[setSelectedRef]
[setSelectedSighting, setSightingModalOpen]
);
const rows = useMemo(
() => sightings?.filter(Boolean) as SightingWidgetType[],
[sightings]
);
const rows = useMemo(() => sightings?.filter(Boolean) as SightingType[], [sightings]);
useEffect(() => {
if (!rows?.length) return;
for (const sighting of rows) {
const id = sighting.vrm;
if (processedRefs.current.has(id)) continue;
const isHotlistHit = checkIsHotListHit(sighting);
const npedcategory = sighting?.metadata?.npedJSON?.["NPED CATEGORY"];
const isNPED =
(npedcategory === "A" && isCatAEnabled) ||
(npedcategory === "B" && isCatBEnabled) ||
(npedcategory === "C" && isCatCEnabled) ||
(npedcategory === "D" && isCatDEnabled);
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";
const isNPEDHitD = cat === "D";
return isNPEDHitA || isNPEDHitB || isNPEDHitC || isNPEDHitD;
});
const firstHot = rows?.find((r) => {
const isHotListHit = checkIsHotListHit(r);
return isHotListHit;
});
if (firstNPED) {
enqueue(firstNPED, "NPED");
npedRef.current = true;
}
if (firstHot) {
enqueue(firstHot, "HOTLIST");
hasAutoOpenedRef.current = true;
}
}, [enqueue, hotlistsound, npedSound, rows, setSelectedSighting, setSightingModalOpen]);
useEffect(() => {
if (!isSightingModalOpen && modalQueue.length > 0) {
const next = modalQueue[0];
if (next.kind === "NPED") npedSound();
else hotlistsound();
setSelectedSighting(next.sighting);
setSightingModalOpen(true);
}
}, [isSightingModalOpen, npedSound, hotlistsound, setSelectedSighting, setSightingModalOpen, modalQueue]);
const handleClose = () => {
setSightingModalOpen(false);
setModalQueue((q) => q.slice(1));
};
return (
<Card className={clsx("overflow-y-auto h-100", className)}>
<CardHeader title="Front Camera Sightings" />
<>
<Card className={clsx("overflow-y-auto min-h-[40vh] md:min-h-[60vh] max-h-[80vh] lg:w-[40%] p-4", className)}>
<CardHeader title={title} />
<div className="flex flex-col gap-3 ">
{isLoading && (
<div className="my-50 h-[50%]">
<Loading message="Loading Sightings" />
</div>
)}
{/* Rows */}
<div className="flex flex-col">
{rows?.map((obj, idx) => {
console.log(obj);
const isNPEDHit = obj?.metadata?.npedJSON?.status_code === 201;
const isSelected = obj?.ref === selectedRef;
{rows?.map((obj) => {
const cat = getNPEDCategory(obj);
const isNPEDHitA = cat === "A";
const isNPEDHitB = cat === "B";
const isNPEDHitC = cat === "C";
const motionAway = (obj?.motion ?? "").toUpperCase() === "AWAY";
const primaryIsColour = obj?.srcCam === 1;
const secondaryMissing = (obj?.vrmSecondary ?? "") === "";
const isHotListHit = checkIsHotListHit(obj);
return (
<div
key={idx}
className={`border border-neutral-700 rounded-md mb-2 p-2 cursor-pointer ${
isSelected ? "ring-2 ring-blue-400" : ""
}`}
onClick={() => onRowClick(obj.ref)}
key={obj.ref}
className={`border border-gray-700 rounded-md mb-2 p-2 cursor-pointer `}
onClick={() => onRowClick(obj)}
>
{/* Info bar */}
<div className="flex items-center gap-3 text-xs bg-neutral-900 px-2 py-1 rounded">
<div className="min-w-14">
CH: {obj ? obj.charHeight : "—"}
</div>
<div className="min-w-14">
Seen: {obj ? obj.seenCount : "—"}
</div>
<div className="min-w-20">
{obj ? capitalize(obj.motion) : "—"}
</div>
<div className="min-w-14 opacity-80">
{obj ? formatAge(obj.timeStampMillis) : "—"}
</div>
</div>
{/* Patch row */}
<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 className={`flex items-center gap-3 mt-2 justify-between `}>
<div className={`border p-1 `}>
<img src={obj?.plateUrlColour || BLANK_IMG} height={48} width={200} alt="colour patch" />
</div>
{isHotListHit && (
<img src={HotListImg} alt="hotlistHit" className="h-20 object-contain rounded-md" />
)}
{isNPEDHitA && <img src={NPED_CAT_A} alt="hotlistHit" className="h-20 object-contain rounded-md" />}
{isNPEDHitB && <img src={NPED_CAT_B} alt="hotlistHit" className="h-20 object-contain rounded-md" />}
{isNPEDHitC && <img src={NPED_CAT_C} alt="hotlistHit" className="h-20 object-contain rounded-md" />}
<NumberPlate motion={motionAway} vrm={obj?.vrm} />
</div>
</div>
@@ -122,5 +238,7 @@ export default function SightingHistoryWidget({
</div>
</div>
</Card>
<SightingModal isSightingModalOpen={isSightingModalOpen} handleClose={handleClose} sighting={selectedSighting} />
</>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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