From 7ae5e61061b016e9883c6174a4216cae4c62af13 Mon Sep 17 00:00:00 2001 From: John Zhou Date: Wed, 17 Jun 2026 18:39:03 -0700 Subject: [PATCH 1/6] overhaul, swapped form conditional rendering of steps to a more robust auto skipping forward/backward system. looks cooler too. segmented the tour into pages based, so they can specify which tour they want to see by the button on the top bar. changed from an id system to data-tour-id so we don't have repeat ids for accessibility. styled the tour system better to be smoother, will need to move this to dwd as well. implemented auto skipping for when elements with the data-tour-id disappear/ dont exist. probably more changes, but i forgot to commit earlier. --- frontend/package-lock.json | 28 +- frontend/package.json | 2 +- frontend/src/App.tsx | 57 +- .../components/dwe/app/command-palette.tsx | 10 +- .../dwe/cameras/camera-controls.tsx | 41 +- .../components/dwe/cameras/device-card.tsx | 14 +- .../components/dwe/cameras/device-list.tsx | 10 +- .../dwe/cameras/frame-drop-indicator.tsx | 4 +- .../src/components/dwe/cameras/nickname.tsx | 11 +- .../dwe/cameras/stream/endpoint-list.tsx | 11 +- .../dwe/cameras/stream/follower-list.tsx | 12 +- .../components/dwe/cameras/stream/stream.tsx | 47 +- .../components/dwe/log-page/log-viewer.tsx | 66 +- .../src/components/dwe/network/network.tsx | 8 +- .../dwe/network/wired/wired-config.tsx | 23 +- .../dwe/network/wireless/wireless-config.tsx | 3 +- .../dwe/preferences/preferences.tsx | 22 +- .../recordings/components/recording-table.tsx | 4 +- .../components/dwe/recordings/recordings.tsx | 23 +- .../dwe/recordings/utils/recording-utils.tsx | 9 - .../components/dwe/system/system-dropdown.tsx | 24 +- .../src/components/dwe/terminal/terminal.tsx | 16 +- .../src/components/themes/mode-toggle.tsx | 4 +- frontend/src/components/tour/tour-context.ts | 55 ++ .../tour/tour-lib/tour-constants.ts | 44 + .../components/tour/tour-lib/tour-steps.tsx | 885 +++++++++++++++++ frontend/src/components/tour/tour-steps.tsx | 345 ------- frontend/src/components/tour/tour.tsx | 886 +++++++++++------- frontend/src/index.css | 7 + frontend/src/lib/tour-constants.ts | 25 - 30 files changed, 1794 insertions(+), 902 deletions(-) create mode 100644 frontend/src/components/tour/tour-context.ts create mode 100644 frontend/src/components/tour/tour-lib/tour-constants.ts create mode 100644 frontend/src/components/tour/tour-lib/tour-steps.tsx delete mode 100644 frontend/src/components/tour/tour-steps.tsx delete mode 100644 frontend/src/lib/tour-constants.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b10452cf..df257211 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -37,7 +37,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "immer": "^11.1.8", - "lucide-react": "^0.456.0", + "lucide-react": "^1.20.0", "motion": "^12.23.26", "next-themes": "^0.4.6", "openapi-fetch": "^0.13.5", @@ -114,6 +114,7 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -3468,6 +3469,7 @@ "integrity": "sha512-uKXqKN9beGoMdBfcaTY1ecwz6ctxuJAcUlwE55938g0ZJ8lRxwAZqRz2AJ4pzpt5dHdTPMB863UZ0ESiFUcP7A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3483,6 +3485,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.20.tgz", "integrity": "sha512-IPaCZN7PShZK/3t6Q87pfTkRm6oLTd4vztyoj+cbHUF1g3FfVb2tFIL79uCRKEfv16AhqDMBywP2VW3KIZUvcg==", "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -3494,6 +3497,7 @@ "integrity": "sha512-nf22//wEbKXusP6E9pfOCDwFdHAX4u172eaJI4YkDRQEZiorm6KfYnSC2SWLDMVWUOWPERmJnN0ujeAfTBLvrw==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -3540,6 +3544,7 @@ "integrity": "sha512-67kYYShjBR0jNI5vsf/c3WG4u+zDnCTHTPqVMQguffaWWFs7artgwKmfwdifl+r6XyM5LYLas/dInj2T0SgJyw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.31.0", "@typescript-eslint/types": "8.31.0", @@ -3807,7 +3812,8 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/acorn": { "version": "8.15.0", @@ -3815,6 +3821,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4049,6 +4056,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001688", "electron-to-chromium": "^1.5.73", @@ -4525,6 +4533,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5110,6 +5119,7 @@ "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz", "integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/immer" @@ -5497,12 +5507,12 @@ } }, "node_modules/lucide-react": { - "version": "0.456.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.456.0.tgz", - "integrity": "sha512-DIIGJqTT5X05sbAsQ+OhA8OtJYyD4NsEMCA/HQW/Y6ToPQ7gwbtujIoeAaup4HpHzV35SQOarKAWH8LYglB6eA==", + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.20.0.tgz", + "integrity": "sha512-jhXLeC/7m0/tjL1nzMdKk6x256zWA6AtbhTVreHOiKPoeX2d6MK4FbyIQPpVq0E6iPWBisyy1TW+pEge/uMEuQ==", "license": "ISC", "peerDependencies": { - "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "node_modules/mdast-util-from-markdown": { @@ -6554,6 +6564,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", @@ -6739,6 +6750,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -6751,6 +6763,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -7398,6 +7411,7 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", "license": "MIT", + "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -7549,6 +7563,7 @@ "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7829,6 +7844,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/frontend/package.json b/frontend/package.json index a8cd528f..15bac37d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -41,7 +41,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "immer": "^11.1.8", - "lucide-react": "^0.456.0", + "lucide-react": "^1.20.0", "motion": "^12.23.26", "next-themes": "^0.4.6", "openapi-fetch": "^0.13.5", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2263a683..1b4e9404 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,39 +12,24 @@ import { SidebarTrigger, } from "@/components/ui/sidebar"; -import { Outlet, useLocation } from "react-router-dom"; import { ThemeProvider } from "@/components/themes/theme-provider"; -import { ModeToggle } from "./components/themes/mode-toggle"; -import { CommandPalette } from "./components/dwe/app/command-palette"; -import { io, Socket } from "socket.io-client"; -import { useEffect, useRef, useState } from "react"; -import WebsocketContext from "./contexts/WebsocketContext"; +import { TourAlertDialog, TourProvider } from "@/components/tour/tour"; +import { useTour } from "@/components/tour/tour-context"; +import { TOUR_STEP_IDS } from "@/components/tour/tour-lib/tour-constants"; import { Toaster } from "@/components/ui/sonner"; -import { SystemDropdown } from "./components/dwe/system/system-dropdown"; +import { CircleHelpIcon } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { Outlet, useLocation } from "react-router-dom"; +import { io, Socket } from "socket.io-client"; import { API_CLIENT } from "./api"; -import { TourAlertDialog, TourProvider, useTour } from "@/components/tour/tour"; -import { getSteps } from "./components/tour/tour-steps"; +import { CommandPalette } from "./components/dwe/app/command-palette"; +import { SystemDropdown } from "./components/dwe/system/system-dropdown"; +import { ModeToggle } from "./components/themes/mode-toggle"; import FeaturesContext from "./contexts/FeaturesContext"; +import WebsocketContext from "./contexts/WebsocketContext"; import { useLogSocketToasts } from "./hooks/use-log-socket-toasts"; import { components } from "./schemas/dwe_os_2"; -type WelcomeTourProps = { features: components["schemas"]["FeatureSupport"] }; -function WelcomeTourManager(props: WelcomeTourProps) { - const [openTour, setOpenTour] = useState(false); - const { setSteps } = useTour(); - - useEffect(() => { - setSteps(getSteps(props.features)); - const timer = setTimeout(() => { - setOpenTour(true); - }, 100); - - return () => clearTimeout(timer); - }, [setSteps, props.features]); - - return ; -} - function AppContent() { const [features, setFeatures] = useState< components["schemas"]["FeatureSupport"] | undefined @@ -52,6 +37,8 @@ function AppContent() { const location = useLocation(); + const { startTour } = useTour(); + const getPageTitle = (pathname: string) => { switch (pathname) { case "/": @@ -87,7 +74,7 @@ function AppContent() { -
+

DWE OS

@@ -95,7 +82,7 @@ function AppContent() { - + {pageTitle} @@ -103,19 +90,26 @@ function AppContent() { -
+
+
-
+
- {features && } ); } @@ -159,6 +153,7 @@ function App() { + diff --git a/frontend/src/components/dwe/app/command-palette.tsx b/frontend/src/components/dwe/app/command-palette.tsx index 2799a83c..87328983 100644 --- a/frontend/src/components/dwe/app/command-palette.tsx +++ b/frontend/src/components/dwe/app/command-palette.tsx @@ -1,3 +1,4 @@ +import { TOUR_STEP_IDS } from "@/components/tour/tour-lib/tour-constants"; import { Command, CommandDialog, @@ -7,10 +8,9 @@ import { CommandItem, CommandList, } from "@/components/ui/command"; -import { TOUR_STEP_IDS } from "@/lib/tour-constants"; +import { Info } from "lucide-react"; import { useState } from "react"; import { useNavigate } from "react-router-dom"; -import { Info } from "lucide-react"; export function CommandPalette() { const [open, setOpen] = useState(false); @@ -22,7 +22,7 @@ export function CommandPalette() { }; return ( -
+
-
- -
+ + +
+ + - {steps[currentStep]?.content} + {currentStepData.content} -
-
- {currentStep + 1} / {steps.length} -
- {currentStep > 0 && ( - <> + + +
+
+ {/* Prev Button */} + {currentStepData.prevStepId && + !currentStepData.hidePrev && ( + )} + {/* Separator */} + {currentStepData.prevStepId && + !currentStepData.hideNext && + !currentStepData.hidePrev && ( - + )} + {/* Next Button */} + {!currentStepData.hideNext && ( + )} -
- - - - )} + + + )} ); } -export function useTour() { - const context = useContext(TourContext); - if (!context) { - throw new Error("useTour must be used within a TourProvider"); - } - return context; -} +export function TourAlertDialog() { + const { startTour, completeTour, isTourCompleted, currentStepId } = useTour(); + const dynamicSegments = useTourSteps(); + const location = useLocation(); -export function TourAlertDialog({ - isOpen, - setIsOpen, -}: { - isOpen: boolean; - setIsOpen: (isOpen: boolean) => void; -}) { - const { startTour, steps, isTourCompleted, setIsTourCompleted, currentStep } = - useTour(); - - if (isTourCompleted || steps.length === 0 || currentStep > -1) { - return null; - } - const handleSkip = async () => { - setIsOpen(false); - setIsTourCompleted(true); - }; + const activeSegment = dynamicSegments[location.pathname]; + const isOpen = + !isTourCompleted && + currentStepId === null && + !!activeSegment && + Object.keys(activeSegment.steps).length > 0; return ( @@ -511,23 +765,25 @@ export function TourAlertDialog({ Welcome to DWE OS - - Take a quick tour to learn about the key features and functionality - of DWE OS. -
-
-
- You can restart this tour anytime in{" "} - Preferences + +
+ Take a quick tour to learn about the key features and + functionality of DWE OS. +
+
+
+ You can restart this tour anytime in{" "} + Preferences +
-
- +
diff --git a/frontend/src/components/tour/tour-lib/tour-constants.ts b/frontend/src/components/tour/tour-lib/tour-constants.ts index 22c08007..09b8beb6 100644 --- a/frontend/src/components/tour/tour-lib/tour-constants.ts +++ b/frontend/src/components/tour/tour-lib/tour-constants.ts @@ -35,10 +35,10 @@ export const TOUR_STEP_IDS = { PREFS_PAGE: "prefs-page", DEFAULT_STREAM_PREFS: "default-stream-prefs", + RESET_TOUR: "reset-tour", LOGS_PAGE: "logs-page", DEBUG_LOG: "debug-log", TERMINAL: "terminal", - RESET_TOUR: "reset-tour", }; diff --git a/frontend/src/components/tour/tour-lib/tour-steps.tsx b/frontend/src/components/tour/tour-lib/tour-steps.tsx index f4619a24..f2396f00 100644 --- a/frontend/src/components/tour/tour-lib/tour-steps.tsx +++ b/frontend/src/components/tour/tour-lib/tour-steps.tsx @@ -752,10 +752,16 @@ export function useTourSteps(): Record {
Edit Connection Profile
-
- Click the settings icon to modify a specific profile. You can - switch between DHCP and Static IP, assign custom DNS servers, - and change default routing preferences. +
+ + Click the settings icon to modify a specific profile. You + can switch between DHCP and Static IP, assign custom DNS + servers, and change default routing preferences. + + + Note: Remember to click on the profile to apply your + changes. +
), @@ -771,9 +777,9 @@ export function useTourSteps(): Record {
Wireless Network
- If your device supports Wi-Fi, you will be able to scan for - networks and manage wireless connections here. Currently, it - indicates if Wi-Fi is unsupported. + Currently, wireless is unsupported. If your device supports + Wi-Fi, you will be able to scan for networks and manage + wireless connections here in future updates.
), @@ -793,7 +799,7 @@ export function useTourSteps(): Record {
Preferences
- You'll find application preferences here. Any settings and + All dweOS preferences can be found here. Any settings and configurations of the app (not devices) will be here.
@@ -818,6 +824,22 @@ export function useTourSteps(): Record {
), prevStepId: TOUR_STEP_IDS.PREFS_PAGE, + nextStepId: TOUR_STEP_IDS.RESET_TOUR, + }, + [TOUR_STEP_IDS.RESET_TOUR]: { + route: "/preferences", + position: "bottom", + content: ( +
+
Reset Tour
+ +
+ You may restart the app-wide tour guide here. Resetting{" "} + WILL REFRESH the application. +
+
+ ), + prevStepId: TOUR_STEP_IDS.DEFAULT_STREAM_PREFS, nextStepId: TOUR_STEP_IDS.LOGS_PAGE, }, }, @@ -833,14 +855,12 @@ export function useTourSteps(): Record {
Logs
- This page will display anything pertaining to the devices, it - will log occurrences like device adding, device removal, - device setting tweaks, etc. Have these on hand when contacting - support. + The Logs page displays debug logs from across the app. Have + these on hand when contacting support.
), - prevStepId: TOUR_STEP_IDS.DEFAULT_STREAM_PREFS, + prevStepId: TOUR_STEP_IDS.RESET_TOUR, nextStepId: TOUR_STEP_IDS.DEBUG_LOG, }, @@ -852,7 +872,9 @@ export function useTourSteps(): Record {
Debug Log
- You can left click into a log for a detailed view. + You can{" "} + + left-click into a log for a detailed view.
), @@ -872,7 +894,7 @@ export function useTourSteps(): Record {
Terminal
- Control your system through terminal here. + An instance of your system's terminal is running here.
), diff --git a/frontend/src/components/tour/tour.tsx b/frontend/src/components/tour/tour.tsx index 611ee0e9..bad006a0 100644 --- a/frontend/src/components/tour/tour.tsx +++ b/frontend/src/components/tour/tour.tsx @@ -242,9 +242,17 @@ export function TourProvider({ const step = stepsRef.current[currentStepId]; if (step.prevStepId && stepsRef.current[step.prevStepId]) { + if (activeSegmentPath && dynamicSegments[activeSegmentPath]) { + const isPrevStepInSegment = + !!dynamicSegments[activeSegmentPath].steps[step.prevStepId]; + + if (!isPrevStepInSegment) { + return; + } + } setCurrentStepId(step.prevStepId); } - }, [currentStepId]); + }, [currentStepId, activeSegmentPath, dynamicSegments]); const goToStepById = useCallback((id: string) => { if (stepsRef.current[id]) { @@ -570,6 +578,40 @@ export function TourProvider({ ], ); + const isFirstStep = useMemo(() => { + if (!currentStepId || !steps[currentStepId]) return true; + const step = steps[currentStepId]; + + // 1. Global check: If there is no previous step at all, we are at the start. + if (!step.prevStepId || !steps[step.prevStepId]) return true; + + // 2. Sandbox check: If we are locked to a page, verify the previous step stays on this page. + if (activeSegmentPath && dynamicSegments[activeSegmentPath]) { + const isPrevStepInSegment = + !!dynamicSegments[activeSegmentPath].steps[step.prevStepId]; + if (!isPrevStepInSegment) return true; + } + + return false; + }, [currentStepId, steps, activeSegmentPath, dynamicSegments]); + + const isLastStep = useMemo(() => { + if (!currentStepId || !steps[currentStepId]) return true; + const step = steps[currentStepId]; + + // 1. Global check: If there is no next step at all, we are done. + if (!step.nextStepId || !steps[step.nextStepId]) return true; + + // 2. Sandbox check: If we are locked to a page, verify the next step stays on this page. + if (activeSegmentPath && dynamicSegments[activeSegmentPath]) { + const isNextStepInSegment = + !!dynamicSegments[activeSegmentPath].steps[step.nextStepId]; + if (!isNextStepInSegment) return true; + } + + return false; + }, [currentStepId, steps, activeSegmentPath, dynamicSegments]); + const currentStepData = currentStepId ? steps[currentStepId] : null; return ( @@ -679,7 +721,8 @@ export function TourProvider({
{/* Prev Button */} {currentStepData.prevStepId && - !currentStepData.hidePrev && ( + !currentStepData.hidePrev && + !isFirstStep && ( )}
From dc9ab8dbb1a5a02ecc69c9a7b3afa18ebff9af13 Mon Sep 17 00:00:00 2001 From: John Zhou Date: Thu, 18 Jun 2026 13:45:27 -0700 Subject: [PATCH 3/6] componentizing tour to better compartmentalize functionality. implemented a isLoading state to the toursegments so they don't skip over tour steps on accident --- frontend/src/App.tsx | 11 +- .../components/dwe/app/command-palette.tsx | 2 +- .../dwe/cameras/camera-controls.tsx | 2 +- .../components/dwe/cameras/device-card.tsx | 2 +- .../components/dwe/cameras/device-list.tsx | 2 +- .../dwe/cameras/frame-drop-indicator.tsx | 2 +- .../src/components/dwe/cameras/nickname.tsx | 2 +- .../dwe/cameras/stream/endpoint-list.tsx | 2 +- .../dwe/cameras/stream/follower-list.tsx | 2 +- .../components/dwe/cameras/stream/stream.tsx | 2 +- .../components/dwe/log-page/log-viewer.tsx | 2 +- .../src/components/dwe/network/network.tsx | 2 +- .../dwe/network/wired/wired-config.tsx | 2 +- .../dwe/network/wireless/wireless-config.tsx | 2 +- .../dwe/preferences/preferences.tsx | 2 +- .../recordings/components/recording-table.tsx | 2 +- .../components/dwe/recordings/recordings.tsx | 2 +- .../components/dwe/system/system-dropdown.tsx | 2 +- .../src/components/dwe/terminal/terminal.tsx | 2 +- .../src/components/themes/mode-toggle.tsx | 2 +- frontend/src/components/tour/index.ts | 4 + .../tour/{tour-lib => }/tour-constants.ts | 0 frontend/src/components/tour/tour-context.ts | 9 +- .../components/tour/tour-lib/tour-steps.tsx | 907 ------------------ frontend/src/components/tour/tour-overlay.tsx | 641 +++++++++++++ .../src/components/tour/tour-provider.tsx | 212 ++++ frontend/src/components/tour/tour-steps.tsx | 894 +++++++++++++++++ frontend/src/components/tour/tour-utils.ts | 60 ++ frontend/src/components/tour/tour.tsx | 840 ---------------- 29 files changed, 1840 insertions(+), 1776 deletions(-) create mode 100644 frontend/src/components/tour/index.ts rename frontend/src/components/tour/{tour-lib => }/tour-constants.ts (100%) delete mode 100644 frontend/src/components/tour/tour-lib/tour-steps.tsx create mode 100644 frontend/src/components/tour/tour-overlay.tsx create mode 100644 frontend/src/components/tour/tour-provider.tsx create mode 100644 frontend/src/components/tour/tour-steps.tsx create mode 100644 frontend/src/components/tour/tour-utils.ts delete mode 100644 frontend/src/components/tour/tour.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1b4e9404..e1c07550 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -13,9 +13,12 @@ import { } from "@/components/ui/sidebar"; import { ThemeProvider } from "@/components/themes/theme-provider"; -import { TourAlertDialog, TourProvider } from "@/components/tour/tour"; -import { useTour } from "@/components/tour/tour-context"; -import { TOUR_STEP_IDS } from "@/components/tour/tour-lib/tour-constants"; +import { + TOUR_STEP_IDS, + TourDialog, + TourProvider, + useTour, +} from "@/components/tour"; import { Toaster } from "@/components/ui/sonner"; import { CircleHelpIcon } from "lucide-react"; import { useEffect, useRef, useState } from "react"; @@ -153,7 +156,7 @@ function App() { - + diff --git a/frontend/src/components/dwe/app/command-palette.tsx b/frontend/src/components/dwe/app/command-palette.tsx index 87328983..f38f9f5a 100644 --- a/frontend/src/components/dwe/app/command-palette.tsx +++ b/frontend/src/components/dwe/app/command-palette.tsx @@ -1,4 +1,4 @@ -import { TOUR_STEP_IDS } from "@/components/tour/tour-lib/tour-constants"; +import { TOUR_STEP_IDS } from "@/components/tour/tour-constants"; import { Command, CommandDialog, diff --git a/frontend/src/components/dwe/cameras/camera-controls.tsx b/frontend/src/components/dwe/cameras/camera-controls.tsx index a4a54c07..a1e8a7bd 100644 --- a/frontend/src/components/dwe/cameras/camera-controls.tsx +++ b/frontend/src/components/dwe/cameras/camera-controls.tsx @@ -10,7 +10,7 @@ import { MonitorCog, } from "lucide-react"; -import { TOUR_STEP_IDS } from "@/components/tour/tour-lib/tour-constants"; +import { TOUR_STEP_IDS } from "@/components/tour/tour-constants"; import { Accordion, AccordionContent, diff --git a/frontend/src/components/dwe/cameras/device-card.tsx b/frontend/src/components/dwe/cameras/device-card.tsx index cd39f9aa..c447c2f3 100644 --- a/frontend/src/components/dwe/cameras/device-card.tsx +++ b/frontend/src/components/dwe/cameras/device-card.tsx @@ -1,4 +1,4 @@ -import { TOUR_STEP_IDS } from "@/components/tour/tour-lib/tour-constants"; +import { TOUR_STEP_IDS } from "@/components/tour/tour-constants"; import { Card, CardContent, diff --git a/frontend/src/components/dwe/cameras/device-list.tsx b/frontend/src/components/dwe/cameras/device-list.tsx index 7d378df0..bd257373 100644 --- a/frontend/src/components/dwe/cameras/device-list.tsx +++ b/frontend/src/components/dwe/cameras/device-list.tsx @@ -1,4 +1,4 @@ -import { TOUR_STEP_IDS } from "@/components/tour/tour-lib/tour-constants"; +import { TOUR_STEP_IDS } from "@/components/tour/tour-constants"; import WebsocketContext from "@/contexts/WebsocketContext"; import { useDeviceStore } from "@/store/devices"; import { usePreferencesStore } from "@/store/preferences"; diff --git a/frontend/src/components/dwe/cameras/frame-drop-indicator.tsx b/frontend/src/components/dwe/cameras/frame-drop-indicator.tsx index bba9ff4e..895e8dd9 100644 --- a/frontend/src/components/dwe/cameras/frame-drop-indicator.tsx +++ b/frontend/src/components/dwe/cameras/frame-drop-indicator.tsx @@ -1,7 +1,7 @@ import { ClockArrowDown } from "lucide-react"; import { useContext, useEffect, useState } from "react"; -import { TOUR_STEP_IDS } from "@/components/tour/tour-lib/tour-constants"; +import { TOUR_STEP_IDS } from "@/components/tour/tour-constants"; import { Tooltip, TooltipContent, diff --git a/frontend/src/components/dwe/cameras/nickname.tsx b/frontend/src/components/dwe/cameras/nickname.tsx index 816d4247..a0852632 100644 --- a/frontend/src/components/dwe/cameras/nickname.tsx +++ b/frontend/src/components/dwe/cameras/nickname.tsx @@ -1,4 +1,4 @@ -import { TOUR_STEP_IDS } from "@/components/tour/tour-lib/tour-constants"; +import { TOUR_STEP_IDS } from "@/components/tour/tour-constants"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { useDeviceStore } from "@/store/devices"; diff --git a/frontend/src/components/dwe/cameras/stream/endpoint-list.tsx b/frontend/src/components/dwe/cameras/stream/endpoint-list.tsx index e4d464eb..cad2b8c5 100644 --- a/frontend/src/components/dwe/cameras/stream/endpoint-list.tsx +++ b/frontend/src/components/dwe/cameras/stream/endpoint-list.tsx @@ -11,7 +11,7 @@ import { } from "lucide-react"; import { useState } from "react"; -import { TOUR_STEP_IDS } from "@/components/tour/tour-lib/tour-constants"; +import { TOUR_STEP_IDS } from "@/components/tour/tour-constants"; import { useDeviceStore } from "@/store/devices"; import { usePreferencesStore } from "@/store/preferences"; diff --git a/frontend/src/components/dwe/cameras/stream/follower-list.tsx b/frontend/src/components/dwe/cameras/stream/follower-list.tsx index 155f6cf6..ef116df4 100644 --- a/frontend/src/components/dwe/cameras/stream/follower-list.tsx +++ b/frontend/src/components/dwe/cameras/stream/follower-list.tsx @@ -1,4 +1,4 @@ -import { TOUR_STEP_IDS } from "@/components/tour/tour-lib/tour-constants"; +import { TOUR_STEP_IDS } from "@/components/tour/tour-constants"; import { Accordion, AccordionContent, diff --git a/frontend/src/components/dwe/cameras/stream/stream.tsx b/frontend/src/components/dwe/cameras/stream/stream.tsx index 5ded7407..877b2010 100644 --- a/frontend/src/components/dwe/cameras/stream/stream.tsx +++ b/frontend/src/components/dwe/cameras/stream/stream.tsx @@ -1,7 +1,7 @@ import { Button } from "@/components/ui/button"; import { Loader2, PauseIcon, PlayIcon, RotateCcw } from "lucide-react"; -import { TOUR_STEP_IDS } from "@/components/tour/tour-lib/tour-constants"; +import { TOUR_STEP_IDS } from "@/components/tour/tour-constants"; import { Accordion, AccordionContent, diff --git a/frontend/src/components/dwe/log-page/log-viewer.tsx b/frontend/src/components/dwe/log-page/log-viewer.tsx index c86bbe9c..983eb0f7 100644 --- a/frontend/src/components/dwe/log-page/log-viewer.tsx +++ b/frontend/src/components/dwe/log-page/log-viewer.tsx @@ -1,7 +1,7 @@ "use client"; import { API_CLIENT } from "@/api"; -import { TOUR_STEP_IDS } from "@/components/tour/tour-lib/tour-constants"; +import { TOUR_STEP_IDS } from "@/components/tour/tour-constants"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; diff --git a/frontend/src/components/dwe/network/network.tsx b/frontend/src/components/dwe/network/network.tsx index 788ea3f6..9e8d3cb1 100644 --- a/frontend/src/components/dwe/network/network.tsx +++ b/frontend/src/components/dwe/network/network.tsx @@ -1,4 +1,4 @@ -import { TOUR_STEP_IDS } from "@/components/tour/tour-lib/tour-constants"; +import { TOUR_STEP_IDS } from "@/components/tour/tour-constants"; import WebsocketContext from "@/contexts/WebsocketContext"; import { useContext } from "react"; import NotConnected from "../not-connected"; diff --git a/frontend/src/components/dwe/network/wired/wired-config.tsx b/frontend/src/components/dwe/network/wired/wired-config.tsx index 3698c38c..8f587383 100644 --- a/frontend/src/components/dwe/network/wired/wired-config.tsx +++ b/frontend/src/components/dwe/network/wired/wired-config.tsx @@ -1,5 +1,5 @@ import { API_CLIENT } from "@/api"; -import { TOUR_STEP_IDS } from "@/components/tour/tour-lib/tour-constants"; +import { TOUR_STEP_IDS } from "@/components/tour/tour-constants"; import { Accordion, AccordionContent, diff --git a/frontend/src/components/dwe/network/wireless/wireless-config.tsx b/frontend/src/components/dwe/network/wireless/wireless-config.tsx index 8cfd3233..1f8dcf1e 100644 --- a/frontend/src/components/dwe/network/wireless/wireless-config.tsx +++ b/frontend/src/components/dwe/network/wireless/wireless-config.tsx @@ -1,4 +1,4 @@ -import { TOUR_STEP_IDS } from "@/components/tour/tour-lib/tour-constants"; +import { TOUR_STEP_IDS } from "@/components/tour/tour-constants"; import { Card, CardContent, diff --git a/frontend/src/components/dwe/preferences/preferences.tsx b/frontend/src/components/dwe/preferences/preferences.tsx index dd46facd..9d499979 100644 --- a/frontend/src/components/dwe/preferences/preferences.tsx +++ b/frontend/src/components/dwe/preferences/preferences.tsx @@ -1,6 +1,6 @@ import { API_CLIENT } from "@/api"; +import { TOUR_STEP_IDS } from "@/components/tour/tour-constants"; import { useTour } from "@/components/tour/tour-context"; -import { TOUR_STEP_IDS } from "@/components/tour/tour-lib/tour-constants"; import { Button } from "@/components/ui/button"; import { Card, CardHeader, CardTitle } from "@/components/ui/card"; import { Checkbox } from "@/components/ui/checkbox"; diff --git a/frontend/src/components/dwe/recordings/components/recording-table.tsx b/frontend/src/components/dwe/recordings/components/recording-table.tsx index 778fe1db..34fe570b 100644 --- a/frontend/src/components/dwe/recordings/components/recording-table.tsx +++ b/frontend/src/components/dwe/recordings/components/recording-table.tsx @@ -8,7 +8,7 @@ import { isPlayable, RecordingInfo, } from "@/components/dwe/recordings/utils/recording-utils"; -import { TOUR_STEP_IDS } from "@/components/tour/tour-lib/tour-constants"; +import { TOUR_STEP_IDS } from "@/components/tour/tour-constants"; import { Button } from "@/components/ui/button"; import { Spinner } from "@/components/ui/spinner"; import { diff --git a/frontend/src/components/dwe/recordings/recordings.tsx b/frontend/src/components/dwe/recordings/recordings.tsx index cee8ba5c..d9d5bf8c 100644 --- a/frontend/src/components/dwe/recordings/recordings.tsx +++ b/frontend/src/components/dwe/recordings/recordings.tsx @@ -5,7 +5,7 @@ import { recordingsActions, recordingsState, } from "@/components/dwe/recordings/store/recording-store"; -import { TOUR_STEP_IDS } from "@/components/tour/tour-lib/tour-constants"; +import { TOUR_STEP_IDS } from "@/components/tour/tour-constants"; import { Button } from "@/components/ui/button"; import { ButtonGroup } from "@/components/ui/button-group"; import { Input } from "@/components/ui/input"; diff --git a/frontend/src/components/dwe/system/system-dropdown.tsx b/frontend/src/components/dwe/system/system-dropdown.tsx index cfd54068..a854f0e0 100644 --- a/frontend/src/components/dwe/system/system-dropdown.tsx +++ b/frontend/src/components/dwe/system/system-dropdown.tsx @@ -1,5 +1,5 @@ import { API_CLIENT } from "@/api"; -import { TOUR_STEP_IDS } from "@/components/tour/tour-lib/tour-constants"; +import { TOUR_STEP_IDS } from "@/components/tour/tour-constants"; import { Button } from "@/components/ui/button"; import { Dialog, diff --git a/frontend/src/components/dwe/terminal/terminal.tsx b/frontend/src/components/dwe/terminal/terminal.tsx index 6b8f4cb5..530bd8f2 100644 --- a/frontend/src/components/dwe/terminal/terminal.tsx +++ b/frontend/src/components/dwe/terminal/terminal.tsx @@ -1,6 +1,6 @@ import { TTYD_TOKEN_URL, TTYD_WS } from "@/api"; import { useTheme } from "@/components/themes/theme-provider"; -import { TOUR_STEP_IDS } from "@/components/tour/tour-lib/tour-constants"; +import { TOUR_STEP_IDS } from "@/components/tour/tour-constants"; import { Card, CardContent } from "@/components/ui/card"; import FeaturesContext from "@/contexts/FeaturesContext"; import WebsocketContext from "@/contexts/WebsocketContext"; diff --git a/frontend/src/components/themes/mode-toggle.tsx b/frontend/src/components/themes/mode-toggle.tsx index c955d2f0..6b79bd06 100644 --- a/frontend/src/components/themes/mode-toggle.tsx +++ b/frontend/src/components/themes/mode-toggle.tsx @@ -3,7 +3,7 @@ import { Moon, Sun, SunMoon } from "lucide-react"; import { Button } from "@/components/ui/button"; import { useTheme } from "@/components/themes/theme-provider"; -import { TOUR_STEP_IDS } from "@/components/tour/tour-lib/tour-constants"; +import { TOUR_STEP_IDS } from "@/components/tour/tour-constants"; export function ModeToggle() { const { setTheme, theme } = useTheme(); diff --git a/frontend/src/components/tour/index.ts b/frontend/src/components/tour/index.ts new file mode 100644 index 00000000..1f593cfc --- /dev/null +++ b/frontend/src/components/tour/index.ts @@ -0,0 +1,4 @@ +export { TOUR_STEP_IDS } from "./tour-constants"; +export { useTour } from "./tour-context"; +export { TourDialog } from "./tour-overlay"; +export { TourProvider } from "./tour-provider"; diff --git a/frontend/src/components/tour/tour-lib/tour-constants.ts b/frontend/src/components/tour/tour-constants.ts similarity index 100% rename from frontend/src/components/tour/tour-lib/tour-constants.ts rename to frontend/src/components/tour/tour-constants.ts diff --git a/frontend/src/components/tour/tour-context.ts b/frontend/src/components/tour/tour-context.ts index 9cefa461..fa7749d9 100644 --- a/frontend/src/components/tour/tour-context.ts +++ b/frontend/src/components/tour/tour-context.ts @@ -2,21 +2,16 @@ import type React from "react"; import { createContext, useContext } from "react"; export interface TourStep { - id?: string; selectorId?: string; nextStepId?: string; prevStepId?: string; content: React.ReactNode; route?: string; - width?: number; - height?: number; position?: "top" | "bottom" | "left" | "right"; highlightPadding?: number; popoverWidth?: number; disableNext?: boolean; disablePrev?: boolean; - hideNext?: boolean; - hidePrev?: boolean; advanceOnClick?: boolean | string[] | string; retreatOnClick?: boolean | string[] | string; disableScroll?: boolean; @@ -30,10 +25,12 @@ export interface TourSegment { export interface TourContextType { steps: Record; - setSteps: (steps: Record) => void; currentStepId: string | null; + activeSegmentPath: string | null; isActive: boolean; isTourCompleted: boolean; + isFirstStep: boolean; + isLastStep: boolean; startTour: (isPageOnly?: boolean) => void; completeTour: () => void; diff --git a/frontend/src/components/tour/tour-lib/tour-steps.tsx b/frontend/src/components/tour/tour-lib/tour-steps.tsx deleted file mode 100644 index f2396f00..00000000 --- a/frontend/src/components/tour/tour-lib/tour-steps.tsx +++ /dev/null @@ -1,907 +0,0 @@ -import { useMemo } from "react"; - -import { TourSegment } from "@/components/tour/tour-context"; -import { TOUR_STEP_IDS } from "@/components/tour/tour-lib/tour-constants"; -import { Separator } from "@/components/ui/separator"; -import { - CirclePower, - Download, - MouseLeft, - MouseRight, - Pencil, - Plus, - RefreshCw, - Trash2, -} from "lucide-react"; - -export function useTourSteps(): Record { - return useMemo(() => { - return { - "/": { - startStepId: TOUR_STEP_IDS.POWER_SWITCH, - steps: { - [TOUR_STEP_IDS.POWER_SWITCH]: { - route: "/", - position: "bottom", - content: ( -
-
Power
- -
- This controls the system's power setting. -
    -
  • - {" "} - Restart - Reboot your system. -
  • -
  • - {" "} - Shutdown - Turn off your system. -
  • -
-
-
- ), - nextStepId: TOUR_STEP_IDS.HELP_SWITCH, - }, - - [TOUR_STEP_IDS.HELP_SWITCH]: { - route: "/", - position: "bottom", - content: ( -
-
Help
- -
- Provides resources for setup as well as quick navigation (end - tour to navigate). -
-
- ), - prevStepId: TOUR_STEP_IDS.POWER_SWITCH, - nextStepId: TOUR_STEP_IDS.TOUR_PAGE_BTN, - }, - - [TOUR_STEP_IDS.TOUR_PAGE_BTN]: { - route: "/", - position: "bottom", - content: ( -
-
Tour Page
- -
- Run a tour for the current page. -
-
- ), - prevStepId: TOUR_STEP_IDS.HELP_SWITCH, - nextStepId: TOUR_STEP_IDS.MODE_TOGGLE, - }, - - [TOUR_STEP_IDS.MODE_TOGGLE]: { - route: "/", - position: "bottom", - content: ( -
-
Dark / Light Mode
- -
- Cycles between Dark Mode, System Default, - and Light Mode. -
-
- ), - prevStepId: TOUR_STEP_IDS.TOUR_PAGE_BTN, - nextStepId: TOUR_STEP_IDS.CAMERAS_PAGE, - }, - }, - }, - "/cameras": { - startStepId: TOUR_STEP_IDS.CAMERAS_PAGE, - steps: { - [TOUR_STEP_IDS.CAMERAS_PAGE]: { - route: "/cameras", - position: "left", - disableScroll: true, - content: ( -
-
Cameras
- -
- This is where all detected cameras will automatically show up. -
-
- ), - prevStepId: TOUR_STEP_IDS.MODE_TOGGLE, - nextStepId: TOUR_STEP_IDS.CAMERA_DEVICE, - }, - [TOUR_STEP_IDS.CAMERA_DEVICE]: { - route: "/cameras", - position: "right", - disableScroll: true, - content: ( -
-
Camera Device
- -
- This is a detected camera. We will go over what each section - controls and how to set up a streaming endpoint. -
-
- ), - prevStepId: TOUR_STEP_IDS.CAMERAS_PAGE, - nextStepId: TOUR_STEP_IDS.DROPPED_FRAMES, - }, - - [TOUR_STEP_IDS.DROPPED_FRAMES]: { - route: "/cameras", - position: "right", - disableScroll: true, - highlightPadding: 8, - content: ( -
-
Dropped Frames
- -
- You can keep track of how many frames are dropped during - streaming/recording here. -
-
- ), - prevStepId: TOUR_STEP_IDS.CAMERA_DEVICE, - nextStepId: TOUR_STEP_IDS.DEVICE_NAME, - }, - - [TOUR_STEP_IDS.DEVICE_NAME]: { - route: "/cameras", - position: "right", - disableScroll: true, - highlightPadding: 8, - content: ( -
-
Device Nickname
- -
- You may give your device a name here by selecting{" "} - {" "} - Edit. -
-
- ), - prevStepId: TOUR_STEP_IDS.DROPPED_FRAMES, - nextStepId: TOUR_STEP_IDS.DEVICE_SETTINGS, - }, - - [TOUR_STEP_IDS.DEVICE_SETTINGS]: { - route: "/cameras", - position: "right", - content: ( -
-
Device Settings
- -
- This is where you'll find the specific controls to your camera - device's System, Exposure, and{" "} - Image. -
-
- ), - prevStepId: TOUR_STEP_IDS.DEVICE_NAME, - nextStepId: TOUR_STEP_IDS.SYSTEM_CONTROLS, - }, - - [TOUR_STEP_IDS.SYSTEM_CONTROLS]: { - route: "/cameras", - position: "right", - popoverWidth: 400, - content: ( -
-
System Controls
- -
    -
  • - Bitrate -
    - Determines the amount of data processed per second of - video. Higher values yield better video quality but - consume more network bandwidth. -
    -
  • -
  • - Group of Pictures -
    - Sets the interval between key-frames (I-frames) in the - video stream. A higher value improves compression - efficiency, while a lower value can reduce latency and - improve stream stability over weak connections. -
    -
  • -
  • - Variable Bitrate -
    - When enabled, the stream’s bitrate will dynamically adjust - based on the visual complexity of the scene (VBR), saving - bandwidth during static shots rather than pushing a - constant bitrate (CBR). In practice, it will use a bitrate - between 10mbps and 70mbps. -
    -
  • -
-
- ), - prevStepId: TOUR_STEP_IDS.DEVICE_SETTINGS, - nextStepId: TOUR_STEP_IDS.ADVANCED_CONTROLS, - }, - - [TOUR_STEP_IDS.ADVANCED_CONTROLS]: { - route: "/cameras", - position: "right", - popoverWidth: 400, - content: ( -
-
Advanced Controls
- -
    -
  • - JPEG Image Quality -
    - Adjusts the compression level, greatest visual clarity - results in greatest file size and vice versa. -
    -
  • -
  • - Strobe Width -
    - Defines the duration of the strobe light pulse, allowing - you to synchronize external lighting with the camera's - exposure. -
    -
  • -
  • - ISO -
    - Controls the sensor's sensitivity to light. Higher values - brighten the image but may introduce noise. -
    -
  • -
  • - Exposure Time -
    - Dictates how long the sensor is exposed to light per frame - to control brightness and motion blur. -
    -
  • -
  • - Auto Exposure -
    - When enabled, the camera automatically calculates and - adjusts its exposure settings based on the surrounding - ambient light. -
    -
  • -
-
- ), - prevStepId: TOUR_STEP_IDS.SYSTEM_CONTROLS, - nextStepId: TOUR_STEP_IDS.EXPOSURE_CONTROLS, - }, - - [TOUR_STEP_IDS.EXPOSURE_CONTROLS]: { - route: "/cameras", - position: "right", - popoverWidth: 400, - content: ( -
-
Exposure Controls
- -
    -
  • - Gain -
    - Artificially amplifies the video signal to increase - brightness in low-light scenarios. -
    -
  • -
  • - Backlight Compensation -
    - Adjusts the exposure to properly illuminate darker - subjects that are positioned against a bright background, - preventing them from appearing as silhouettes. -
    -
  • -
  • - Auto Exposure -
    - When enabled, the camera automatically calculates and - adjusts its exposure settings based on the surrounding - ambient light. -
    -
  • -
  • - Exposure Time, Absolute -
    - Allows you to manually dictate the specific duration the - sensor is exposed to light per frame. -
    -
  • -
  • - Exposure, Dynamic Framerate -
    - When toggled on, this allows the camera to automatically - lower its framerate in dark environments. This increases - the exposure time per frame, resulting in a brighter image - at the cost of video smoothness. -
    -
  • -
-
- ), - prevStepId: TOUR_STEP_IDS.ADVANCED_CONTROLS, - nextStepId: TOUR_STEP_IDS.IMAGE_PROCESSING, - }, - - [TOUR_STEP_IDS.IMAGE_PROCESSING]: { - route: "/cameras", - position: "right", - popoverWidth: 400, - content: ( -
-
Image Processing
- -
    -
  • - Brightness -
    - Adjusts the overall lightness or darkness of the video - feed. -
    -
  • -
  • - Contrast -
    - Modifies the difference between the lightest and darkest - areas of the image. -
    -
  • -
  • - Saturation -
    - Controls the intensity and vividness of the colors. -
    -
  • -
  • - Hue -
    - Shifts the overall color phase (tint) of the video. -
    -
  • -
  • - White Balance, Automatic -
    - Toggles our proprietary auto white-balance algorithm - designed to optimize color accuracy and clarity for - underwater. -
    -
  • -
  • - Gamma -
    - Adjusts the brightness of the mid-tones in the video - without severely affecting the extreme shadows or bright - highlights. -
    -
  • -
  • - White Balance Temperature -
    - Adjusts the color temperature of the camera. Lower values - produce cooler (more blue) tones, while higher values - produce warmer (more orange) tones. -
    -
  • -
  • - Sharpness -
    - Enhances edge detail to make the image appear crisper and - more defined. -
    -
  • -
  • - Power Line Frequency -
    - Prevents video flickering caused by artificial lights. You - should set this to match your local region’s electrical - grid frequency (e.g., 60 Hz for North America, 50 Hz for - Europe/Asia). -
    -
  • -
-
- ), - prevStepId: TOUR_STEP_IDS.EXPOSURE_CONTROLS, - nextStepId: TOUR_STEP_IDS.DEVICE_STREAM_CONFIG, - }, - - [TOUR_STEP_IDS.DEVICE_STREAM_CONFIG]: { - route: "/cameras", - position: "right", - disableScroll: true, - popoverWidth: 400, - - content: ( -
-
Device Stream Configuration
- -
- The device stream configurations are here. You set - customizations for: -
    -
  • - Resolution -
    - Sets the image dimensions and level of detail. -
    -
  • -
  • - Frame Rate -
    - Determines the number of frames captured per second - (FPS) for video smoothness. -
    -
  • -
  • - Format -
    - Selects the encoding standard used for the video stream. -
    -
  • -
- We will cover the Endpoints section in the next step. -
-
- ), - prevStepId: TOUR_STEP_IDS.IMAGE_PROCESSING, - nextStepId: TOUR_STEP_IDS.DEVICE_ENDPOINTS, - }, - - [TOUR_STEP_IDS.DEVICE_ENDPOINTS]: { - route: "/cameras", - position: "right", - disableScroll: true, - highlightPadding: 24, - popoverWidth: 400, - content: ( -
-
Device Endpoints
- -
- Here you specify your streaming endpoint. - - Click{" "} - {" "} - Add Endpoint to create an endpoint. - - - With an Endpoint, you can: - -
    -
  • - {" "} - Edit the IP Address or Port -
  • -
  • - {" "} - Delete the Endpoint -
  • -
-
-
- ), - prevStepId: TOUR_STEP_IDS.DEVICE_STREAM_CONFIG, - nextStepId: TOUR_STEP_IDS.DEVICE_FOLLOWERS, - }, - - [TOUR_STEP_IDS.DEVICE_FOLLOWERS]: { - route: "/cameras", - position: "top", - disableScroll: true, - popoverWidth: 400, - content: ( -
-
Followers
- -
    -
  • - Here, you can assign Followers. -
  • -
  • - If compatible cameras are detected, you can add them as{" "} - Followers by selecting them in the dropdown and - clicking Add. -
  • -
  • - Once added, the Follower's streaming and recording - will be controlled by the Leader it's assigned to. -
  • -
-
- ), - prevStepId: TOUR_STEP_IDS.DEVICE_ENDPOINTS, - nextStepId: TOUR_STEP_IDS.DEVICE_RESET, - }, - - [TOUR_STEP_IDS.DEVICE_RESET]: { - route: "/cameras", - position: "right", - disableScroll: true, - content: ( -
-
Reset Card
- -
- Reset this device's controls to default settings. -
-
- ), - prevStepId: TOUR_STEP_IDS.DEVICE_FOLLOWERS, - nextStepId: TOUR_STEP_IDS.DEVICE_STREAM, - }, - - [TOUR_STEP_IDS.DEVICE_STREAM]: { - route: "/cameras", - position: "right", - disableScroll: true, - highlightPadding: 8, - content: ( -
-
Stream
- -
- This is where you control your stream. - Please ensure your endpoints are correct. - - If this device is managed (Follower), this button - will be disabled. - -
-
- ), - prevStepId: TOUR_STEP_IDS.DEVICE_RESET, - nextStepId: TOUR_STEP_IDS.RECORDING_PAGE, - }, - }, - }, - "/recordings": { - startStepId: TOUR_STEP_IDS.RECORDING_PAGE, - steps: { - [TOUR_STEP_IDS.RECORDING_PAGE]: { - route: "/recordings", - position: "left", - content: ( -
-
Recordings
- -
- - This is where you'll find all recordings done on the system - through DWE Products. - - Drag to select multiple files. -
-
- ), - prevStepId: TOUR_STEP_IDS.DEVICE_STREAM, - nextStepId: TOUR_STEP_IDS.RECORDING_ITEM, - }, - - [TOUR_STEP_IDS.RECORDING_ITEM]: { - route: "/recordings", - position: "bottom", - content: ( -
-
Recording File
- -
- - {" "} - Right-Click a recording/selections to open an options - menu. - - - - x2 Double-Left-Click to quickly Play{" "} - supported videos. - -
-
- ), - prevStepId: TOUR_STEP_IDS.RECORDING_PAGE, - nextStepId: TOUR_STEP_IDS.RECORDING_FOOTER, - }, - - [TOUR_STEP_IDS.RECORDING_FOOTER]: { - route: "/recordings", - position: "top", - content: ( -
-
Recordings Footer
- -
- Down here you have additional details and controls. -
-
- ), - prevStepId: TOUR_STEP_IDS.RECORDING_ITEM, - nextStepId: TOUR_STEP_IDS.RECORDINGS_FUNCTIONS, - }, - - [TOUR_STEP_IDS.RECORDINGS_FUNCTIONS]: { - route: "/recordings", - position: "top", - highlightPadding: 8, - content: ( -
-
Recordings Functions
- -
- Upon selecting at least one item, the following functions - become available to you: -
    -
  • - {" "} - Download{" "} -
    - Download the selected files onto the system. -
    -
  • -
  • - {" "} - Delete -
    - Delete the selected files. -
    -
  • -
-
-
- ), - prevStepId: TOUR_STEP_IDS.RECORDING_FOOTER, - nextStepId: TOUR_STEP_IDS.STORAGE_BAR, - }, - - [TOUR_STEP_IDS.STORAGE_BAR]: { - route: "/recordings", - position: "top", - highlightPadding: 12, - content: ( -
-
Storage
- -
- You can see how storage is allocated on your system with this - widget. -
-
- ), - prevStepId: TOUR_STEP_IDS.RECORDINGS_FUNCTIONS, - nextStepId: TOUR_STEP_IDS.NETWORKING_PAGE, - }, - }, - }, - "/network": { - startStepId: TOUR_STEP_IDS.NETWORKING_PAGE, - steps: { - [TOUR_STEP_IDS.NETWORKING_PAGE]: { - route: "/network", - position: "bottom", - highlightPadding: 12, - content: ( -
-
Network Management
- -
- Welcome to the Network page. From here, you can manage both - your wired and wireless device connections and route settings. -
-
- ), - prevStepId: TOUR_STEP_IDS.STORAGE_BAR, - nextStepId: TOUR_STEP_IDS.WIRED_CONFIG, - }, - [TOUR_STEP_IDS.WIRED_CONFIG]: { - route: "/network", - position: "right", - highlightPadding: 12, - content: ( -
-
Wired Configuration
- -
- This section lists all detected wired network interfaces. You - can view their current state and manage their connection - profiles. -
-
- ), - prevStepId: TOUR_STEP_IDS.NETWORKING_PAGE, - nextStepId: TOUR_STEP_IDS.NETWORK_OPTION, - }, - [TOUR_STEP_IDS.NETWORK_OPTION]: { - route: "/network", - position: "right", - highlightPadding: 12, - content: ( -
-
Interface Status & Profiles
- -
- Click on a network interface to expand it. You'll see its - current state (e.g., Connected, Disconnected) and all - available connection profiles. -
-
- ), - prevStepId: TOUR_STEP_IDS.WIRED_CONFIG, - nextStepId: TOUR_STEP_IDS.NETWORK_OPTION_SETTINGS, - }, - [TOUR_STEP_IDS.NETWORK_OPTION_SETTINGS]: { - route: "/network", - position: "right", - content: ( -
-
Edit Connection Profile
- -
- - Click the settings icon to modify a specific profile. You - can switch between DHCP and Static IP, assign custom DNS - servers, and change default routing preferences. - - - Note: Remember to click on the profile to apply your - changes. - -
-
- ), - prevStepId: TOUR_STEP_IDS.NETWORK_OPTION, - nextStepId: TOUR_STEP_IDS.WIRELESS_NETWORK, - }, - [TOUR_STEP_IDS.WIRELESS_NETWORK]: { - route: "/network", - position: "left", - highlightPadding: 12, - content: ( -
-
Wireless Network
- -
- Currently, wireless is unsupported. If your device supports - Wi-Fi, you will be able to scan for networks and manage - wireless connections here in future updates. -
-
- ), - prevStepId: TOUR_STEP_IDS.NETWORK_OPTION_SETTINGS, - nextStepId: TOUR_STEP_IDS.PREFS_PAGE, - }, - }, - }, - "/preferences": { - startStepId: TOUR_STEP_IDS.PREFS_PAGE, - steps: { - [TOUR_STEP_IDS.PREFS_PAGE]: { - route: "/preferences", - position: "left", - content: ( -
-
Preferences
- -
- All dweOS preferences can be found here. Any settings and - configurations of the app (not devices) will be here. -
-
- ), - prevStepId: TOUR_STEP_IDS.RECORDING_FOOTER, - nextStepId: TOUR_STEP_IDS.DEFAULT_STREAM_PREFS, - }, - - [TOUR_STEP_IDS.DEFAULT_STREAM_PREFS]: { - route: "/preferences", - position: "bottom", - content: ( -
-
Default Stream Preferences
- -
- These fields determine what show up automatically when you add - an endpoint. The Default Stream Port will increment - automatically as you add new endpoints for the same Stream - Host. -
-
- ), - prevStepId: TOUR_STEP_IDS.PREFS_PAGE, - nextStepId: TOUR_STEP_IDS.RESET_TOUR, - }, - [TOUR_STEP_IDS.RESET_TOUR]: { - route: "/preferences", - position: "bottom", - content: ( -
-
Reset Tour
- -
- You may restart the app-wide tour guide here. Resetting{" "} - WILL REFRESH the application. -
-
- ), - prevStepId: TOUR_STEP_IDS.DEFAULT_STREAM_PREFS, - nextStepId: TOUR_STEP_IDS.LOGS_PAGE, - }, - }, - }, - "/log-viewer": { - startStepId: TOUR_STEP_IDS.LOGS_PAGE, - steps: { - [TOUR_STEP_IDS.LOGS_PAGE]: { - route: "/log-viewer", - position: "left", - content: ( -
-
Logs
- -
- The Logs page displays debug logs from across the app. Have - these on hand when contacting support. -
-
- ), - prevStepId: TOUR_STEP_IDS.RESET_TOUR, - nextStepId: TOUR_STEP_IDS.DEBUG_LOG, - }, - - [TOUR_STEP_IDS.DEBUG_LOG]: { - route: "/log-viewer", - position: "bottom", - content: ( -
-
Debug Log
- -
- You can{" "} - - left-click into a log for a detailed view. -
-
- ), - prevStepId: TOUR_STEP_IDS.LOGS_PAGE, - nextStepId: TOUR_STEP_IDS.TERMINAL, - }, - }, - }, - "/terminal": { - startStepId: TOUR_STEP_IDS.TERMINAL, - steps: { - [TOUR_STEP_IDS.TERMINAL]: { - route: "/terminal", - position: "left", - content: ( -
-
Terminal
- -
- An instance of your system's terminal is running here. -
-
- ), - prevStepId: TOUR_STEP_IDS.DEBUG_LOG, - }, - }, - }, - }; - }, []); -} diff --git a/frontend/src/components/tour/tour-overlay.tsx b/frontend/src/components/tour/tour-overlay.tsx new file mode 100644 index 00000000..2a066cc2 --- /dev/null +++ b/frontend/src/components/tour/tour-overlay.tsx @@ -0,0 +1,641 @@ +import { AnimatedWaves } from "@/assets/animated-waves"; +import { useTour } from "@/components/tour/tour-context"; +import { TOUR_STEPS } from "@/components/tour/tour-steps"; +import { + calculateContentPosition, + CONTENT_HEIGHT, + CONTENT_WIDTH, + getElementPosition, +} from "@/components/tour/tour-utils"; +import { + AlertDialog, + AlertDialogContent, + AlertDialogDescription, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { Spinner } from "@/components/ui/spinner"; +import { cn } from "@/lib/utils"; +import { Separator } from "@radix-ui/react-separator"; +import { X } from "lucide-react"; +import { + animate, + AnimatePresence, + motion, + useMotionTemplate, + useMotionValue, +} from "motion/react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; + +export default function TourOverlay() { + const { + currentStepId, + steps, + completeTour, + cancelTour, + nextStep, + prevStep, + isFirstStep, + isLastStep, + } = useTour(); + + const [elementPosition, setElementPosition] = useState<{ + top: number; + left: number; + width: number; + height: number; + } | null>(null); + const [isLocating, setIsLocating] = useState(false); + const [isSegmentLoading, setIsSegmentLoading] = useState(false); + + const navigate = useNavigate(); + const location = useLocation(); + const activeSegment = TOUR_STEPS[location.pathname]; + + const observerRef = useRef<{ disconnect: () => void } | null>(null); + const prevStepRef = useRef(currentStepId); + const directionRef = useRef<"forward" | "backward">("forward"); + const popoverRef = useRef(null); + + const handleNext = useCallback(() => { + directionRef.current = "forward"; + nextStep(); + }, [nextStep]); + + const handlePrev = useCallback(() => { + directionRef.current = "backward"; + prevStep(); + }, [prevStep]); + + // for the highlight box + const x = useMotionValue(0); + const y = useMotionValue(0); + const w = useMotionValue(0); + const h = useMotionValue(0); + + // for the content popover + const popoverX = useMotionValue(0); + const popoverY = useMotionValue(0); + + const transitionConfig = useMemo( + () => + ({ + type: "spring", + mass: 0.2, + stiffness: 100, + damping: 15, + }) as const, + [], + ); + + const clipPath = useMotionTemplate`polygon( + 0% 0%, 0% 100%, 100% 100%, 100% 0%, + ${x}px 0%, ${x}px ${y}px, calc(${x}px + ${w}px) ${y}px, + calc(${x}px + ${w}px) calc(${y}px + ${h}px), ${x}px calc(${y}px + ${h}px), ${x}px 0% + )`; + + // segment loaded? + useEffect(() => { + if (!activeSegment || !activeSegment.waitForSelector) { + setIsSegmentLoading(false); + return; + } + + if (document.querySelector(activeSegment.waitForSelector)) { + setIsSegmentLoading(false); + return; + } + + setIsSegmentLoading(true); + + // mutation observer watches for loaded + const observer = new MutationObserver(() => { + if (document.querySelector(activeSegment.waitForSelector!)) { + setIsSegmentLoading(false); + observer.disconnect(); + } + }); + + observer.observe(document.body, { childList: true, subtree: true }); + return () => observer.disconnect(); + }, [activeSegment, location.pathname]); + + const updatePosition = useCallback(() => { + if (!currentStepId || !steps[currentStepId]) return; + const step = steps[currentStepId]; + const targetId = step.selectorId || currentStepId; + const pos = getElementPosition(targetId, step.highlightPadding ?? 0); + + if (pos) { + setIsLocating(false); + setElementPosition((prev) => { + if ( + prev && + Math.abs(prev.top - pos.top) < 1 && + Math.abs(prev.left - pos.left) < 1 && + Math.abs(prev.width - pos.width) < 1 && + Math.abs(prev.height - pos.height) < 1 + ) { + return prev; + } + return pos; + }); + } else { + setIsLocating(true); + console.warn( + `Tour element [data-tour-id="${targetId}"] removed from DOM. Auto-skipping backward.`, + ); + if (step.prevStepId) { + prevStep(); + } else { + cancelTour(); + } + } + }, [currentStepId, steps, cancelTour, prevStep]); + + // Sync MotionValues + useEffect(() => { + if ( + (elementPosition || isLocating || isSegmentLoading) && + currentStepId && + steps[currentStepId] + ) { + const step = steps[currentStepId]; + const isStarting = prevStepRef.current === null; + + const actualHeight = popoverRef.current + ? popoverRef.current.offsetHeight + : CONTENT_HEIGHT; + + let targetX = window.innerWidth / 2; + let targetY = window.innerHeight / 2; + let targetW = 0; + let targetH = 0; + + let contentLeft = + window.innerWidth / 2 - (step.popoverWidth || CONTENT_WIDTH) / 2; + let contentTop = window.innerHeight / 2 - actualHeight / 2; + + if (!isLocating && !isSegmentLoading && elementPosition) { + targetW = elementPosition.width; + targetH = elementPosition.height; + targetX = elementPosition.left; + targetY = elementPosition.top; + + const contentPos = calculateContentPosition( + { ...elementPosition, width: targetW, height: targetH }, + step.position, + step.popoverWidth, + actualHeight, + ); + contentLeft = contentPos.left; + contentTop = contentPos.top; + } + + if (isStarting) { + x.set(targetX); + y.set(targetY); + w.set(targetW); + h.set(targetH); + popoverX.set(contentLeft); + popoverY.set(contentTop); + } else { + animate(x, targetX, transitionConfig); + animate(y, targetY, transitionConfig); + animate(w, targetW, transitionConfig); + animate(h, targetH, transitionConfig); + animate(popoverX, contentLeft, transitionConfig); + animate(popoverY, contentTop, transitionConfig); + } + prevStepRef.current = currentStepId; + } + }, [ + elementPosition, + currentStepId, + steps, + isLocating, + isSegmentLoading, + h, + w, + x, + y, + popoverX, + popoverY, + transitionConfig, + ]); + + // on DOM change + useEffect(() => { + if (isSegmentLoading) return; + + if (currentStepId && steps[currentStepId]) { + const step = steps[currentStepId]; + const targetId = step.selectorId || currentStepId; + + if (step.route && location.pathname !== step.route) { + navigate(step.route); + return; + } + + const instantElement = document.querySelector( + `[data-tour-id="${targetId}"]`, + ); + setIsLocating(!instantElement); + + const attachObserver = () => { + const element = document.querySelector( + `[data-tour-id="${targetId}"]`, + ); + if (observerRef.current) observerRef.current.disconnect(); + + if (element) { + setIsLocating(false); + element.scrollIntoView({ behavior: "smooth", block: "center" }); + updatePosition(); + + const handleShift = () => + window.requestAnimationFrame(updatePosition); + const resizeObs = new ResizeObserver(handleShift); + const mutationObs = new MutationObserver(handleShift); + + resizeObs.observe(element); + resizeObs.observe(document.body); + mutationObs.observe(document.body, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ["class", "style"], + }); + + observerRef.current = { + disconnect: () => { + resizeObs.disconnect(); + mutationObs.disconnect(); + }, + }; + } else { + // auto-skip missing elements + console.warn( + `Tour element #${targetId} not found. Skipping ${directionRef.current}.`, + ); + if (directionRef.current === "forward") { + if (step.nextStepId && steps[step.nextStepId]) handleNext(); + else completeTour(); + } else { + if (step.prevStepId && steps[step.prevStepId]) handlePrev(); + else cancelTour(); + } + } + }; + + const timer = setTimeout(attachObserver, 150); + return () => { + clearTimeout(timer); + if (observerRef.current) observerRef.current.disconnect(); + }; + } else { + setElementPosition(null); + } + }, [ + isSegmentLoading, + currentStepId, + steps, + location.pathname, + navigate, + updatePosition, + cancelTour, + completeTour, + handleNext, + handlePrev, + ]); + + // watch resize/scroll + useEffect(() => { + let ticking = false; + const handleScrollOrResize = () => { + if (!ticking) { + window.requestAnimationFrame(() => { + updatePosition(); + ticking = false; + }); + ticking = true; + } + }; + window.addEventListener("resize", handleScrollOrResize); + window.addEventListener("scroll", handleScrollOrResize, { + passive: true, + capture: true, + }); + return () => { + window.removeEventListener("resize", handleScrollOrResize); + window.removeEventListener("scroll", handleScrollOrResize, { + capture: true, + }); + }; + }, [updatePosition]); + + // observe for specific clicks to advance/retreat the tour + useEffect(() => { + if (!currentStepId || !steps[currentStepId]) return; + const step = steps[currentStepId]; + if (!step.advanceOnClick && !step.retreatOnClick) return; + const targetId = step.selectorId || currentStepId; + + const handleTargetClick = (e: MouseEvent) => { + if (!e.isTrusted) return; + const target = e.target as HTMLElement; + + // advance + if (step.advanceOnClick) { + const ids = Array.isArray(step.advanceOnClick) + ? step.advanceOnClick + : // if string, target is that id, otherwise it's the steps id + typeof step.advanceOnClick === "string" + ? [step.advanceOnClick] + : [targetId]; + + // advance if any of these ids are clicked + if (ids.some((id) => target.closest(`[data-tour-id="${id}"]`))) { + nextStep(); + return; + } + } + + // retreat + if (step.retreatOnClick) { + const ids = Array.isArray(step.retreatOnClick) + ? step.retreatOnClick + : // if string, target is that id, otherwise it's the steps id + typeof step.retreatOnClick === "string" + ? [step.retreatOnClick] + : [targetId]; + + // retreat if any of these ids are clicked + if (ids.some((id) => target.closest(`[data-tour-id="${id}"]`))) { + prevStep(); + } + } + }; + + window.addEventListener("click", handleTargetClick, true); + return () => { + window.removeEventListener("click", handleTargetClick, true); + }; + }, [currentStepId, steps, nextStep, prevStep]); + + // for blocking scrolls if needed within border box + useEffect(() => { + if (!currentStepId || !steps[currentStepId]) return; + const step = steps[currentStepId]; + if (!step.disableScroll) return; + + const preventScroll = (e: Event) => { + e.preventDefault(); + }; + + const options = { passive: false, capture: true }; + + window.addEventListener("wheel", preventScroll, options); + window.addEventListener("touchmove", preventScroll, options); + + return () => { + window.removeEventListener("wheel", preventScroll, options); + window.removeEventListener("touchmove", preventScroll, options); + }; + }, [currentStepId, steps]); + + const currentStepData = currentStepId ? steps[currentStepId] : null; + + return ( + + {currentStepId && currentStepData && (elementPosition || isLocating) && ( + <> + + + {/* Border Box */} + + currentStepData.disableInteraction && e.stopPropagation() + } + onMouseDown={(e) => + currentStepData.disableInteraction && e.stopPropagation() + } + onMouseUp={(e) => + currentStepData.disableInteraction && e.stopPropagation() + } + /> + + {/* Content Popover */} + + {isSegmentLoading ? ( + + + +
+ Waiting for page to load... +
+ +
+
+ ) : ( + <> + + + + + + + {currentStepData.content} + + + +
+
+ {currentStepData.prevStepId && !isFirstStep && ( + + )} + {currentStepData.prevStepId && !isFirstStep && ( + + )} + +
+
+ + )} +
+ + )} +
+ ); +} + +export function TourDialog() { + const { startTour, completeTour, isTourCompleted, currentStepId } = useTour(); + const location = useLocation(); + + const activeSegment = TOUR_STEPS[location.pathname]; + const isOpen = + !isTourCompleted && + currentStepId === null && + !!activeSegment && + Object.keys(activeSegment.steps).length > 0; + + return ( + + + +
+ +
+ + Welcome to DWE OS + + +
+ Take a quick tour to learn about the key features and + functionality of DWE OS. +
+
+
+ You can restart this tour anytime in{" "} + Preferences +
+
+
+
+
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/tour/tour-provider.tsx b/frontend/src/components/tour/tour-provider.tsx new file mode 100644 index 00000000..3869b8fb --- /dev/null +++ b/frontend/src/components/tour/tour-provider.tsx @@ -0,0 +1,212 @@ +import type React from "react"; +import { useCallback, useMemo, useState } from "react"; + +import { TourContext, TourStep } from "@/components/tour/tour-context"; +import TourOverlay from "@/components/tour/tour-overlay"; +import { TOUR_STEPS } from "@/components/tour/tour-steps"; +import { useLocation } from "react-router-dom"; + +interface TourProviderProps { + children: React.ReactNode; + onComplete?: () => void; + className?: string; + isTourCompleted?: boolean; + storageKey?: string; +} + +export function TourProvider({ + children, + isTourCompleted = false, + storageKey = "tourCompleted", +}: TourProviderProps) { + const [currentStepId, setCurrentStepId] = useState(null); + const [activeSegmentPath, setActiveSegmentPath] = useState( + null, + ); + const [isCompleted, setIsCompleted] = useState(() => { + if (typeof window !== "undefined") { + const stored = localStorage.getItem(storageKey); + if (stored !== null) { + return stored === "true"; + } + } + return isTourCompleted; + }); + + const location = useLocation(); + + const steps = useMemo(() => { + const flatPool: Record = {}; + Object.values(TOUR_STEPS).forEach((segment) => { + Object.assign(flatPool, segment.steps); + }); + return flatPool; + }, []); + + const saveTourState = useCallback( + (completed: boolean) => { + if (typeof window !== "undefined") { + localStorage.setItem(storageKey, String(completed)); + } + }, + [storageKey], + ); + + // ACTIONS + const startTour = useCallback( + (isPageOnly: boolean = false) => { + setIsCompleted(false); + saveTourState(false); + + const activeSegment = TOUR_STEPS[location.pathname]; + if (activeSegment) { + setActiveSegmentPath(isPageOnly ? location.pathname : null); + setCurrentStepId(activeSegment.startStepId); + } else { + setActiveSegmentPath(null); + } + }, + [saveTourState, location.pathname], + ); + + const completeTour = useCallback(() => { + setIsCompleted(true); + saveTourState(true); + setCurrentStepId(null); + }, [saveTourState]); + + const resetTour = useCallback(() => { + setIsCompleted(false); + saveTourState(false); + setCurrentStepId(null); + window.location.href = "/"; + }, [saveTourState]); + + const cancelTour = useCallback(() => { + setCurrentStepId(null); + }, []); + + const nextStep = useCallback(() => { + if (!currentStepId || !steps[currentStepId]) return; + + const step = steps[currentStepId]; + + if (step.nextStepId && steps[step.nextStepId]) { + if (activeSegmentPath && TOUR_STEPS[activeSegmentPath]) { + const isNextStepInSegment = + !!TOUR_STEPS[activeSegmentPath].steps[step.nextStepId]; + if (!isNextStepInSegment) { + completeTour(); + return; + } + } + setCurrentStepId(step.nextStepId); + } else { + completeTour(); + } + }, [steps, currentStepId, completeTour, activeSegmentPath]); + + const prevStep = useCallback(() => { + if (!currentStepId || !steps[currentStepId]) return; + + const step = steps[currentStepId]; + + if (step.prevStepId && steps[step.prevStepId]) { + if (activeSegmentPath && TOUR_STEPS[activeSegmentPath]) { + const isPrevStepInSegment = + !!TOUR_STEPS[activeSegmentPath].steps[step.prevStepId]; + + if (!isPrevStepInSegment) { + return; + } + } + setCurrentStepId(step.prevStepId); + } + }, [steps, currentStepId, activeSegmentPath]); + + const goToStepById = useCallback( + (id: string) => { + if (steps[id]) { + setCurrentStepId(id); + } else { + console.warn(`Attempted to go to non-existent step: ${id}`); + } + }, + [steps], + ); + + const isFirstStep = useMemo(() => { + if (!currentStepId || !steps[currentStepId]) return true; + const step = steps[currentStepId]; + + // step has no prev + if (!step.prevStepId || !steps[step.prevStepId]) return true; + + // check prev step stays on page + if (activeSegmentPath && TOUR_STEPS[activeSegmentPath]) { + const isPrevStepInSegment = + !!TOUR_STEPS[activeSegmentPath].steps[step.prevStepId]; + if (!isPrevStepInSegment) return true; + } + + return false; + }, [currentStepId, steps, activeSegmentPath]); + + const isLastStep = useMemo(() => { + if (!currentStepId || !steps[currentStepId]) return true; + const step = steps[currentStepId]; + + // step has no next + if (!step.nextStepId || !steps[step.nextStepId]) return true; + + // check next stays on page + if (activeSegmentPath && TOUR_STEPS[activeSegmentPath]) { + const isNextStepInSegment = + !!TOUR_STEPS[activeSegmentPath].steps[step.nextStepId]; + if (!isNextStepInSegment) return true; + } + + return false; + }, [currentStepId, steps, activeSegmentPath]); + + const contextValue = useMemo( + () => ({ + steps, + currentStepId, + activeSegmentPath, + isActive: currentStepId !== null, + isTourCompleted: isCompleted, + isFirstStep, + isLastStep, + startTour, + completeTour, + resetTour, + cancelTour, + nextStep, + prevStep, + goToStepById, + }), + [ + steps, + currentStepId, + activeSegmentPath, + isCompleted, + isFirstStep, + isLastStep, + startTour, + completeTour, + resetTour, + cancelTour, + nextStep, + prevStep, + goToStepById, + ], + ); + + return ( + + {children} + + + ); +} diff --git a/frontend/src/components/tour/tour-steps.tsx b/frontend/src/components/tour/tour-steps.tsx new file mode 100644 index 00000000..f99e39f9 --- /dev/null +++ b/frontend/src/components/tour/tour-steps.tsx @@ -0,0 +1,894 @@ +import { TOUR_STEP_IDS } from "@/components/tour/tour-constants"; +import { TourSegment } from "@/components/tour/tour-context"; +import { Separator } from "@/components/ui/separator"; +import { + CirclePower, + Download, + MouseLeft, + MouseRight, + Pencil, + Plus, + RefreshCw, + Trash2, +} from "lucide-react"; + +export const TOUR_STEPS: Record = { + "/": { + startStepId: TOUR_STEP_IDS.POWER_SWITCH, + steps: { + [TOUR_STEP_IDS.POWER_SWITCH]: { + route: "/", + position: "bottom", + content: ( +
+
Power
+ +
+ This controls the system's power setting. +
    +
  • + {" "} + Restart - Reboot your system. +
  • +
  • + {" "} + Shutdown - Turn off your system. +
  • +
+
+
+ ), + nextStepId: TOUR_STEP_IDS.HELP_SWITCH, + }, + + [TOUR_STEP_IDS.HELP_SWITCH]: { + route: "/", + position: "bottom", + content: ( +
+
Help
+ +
+ Provides resources for setup as well as quick navigation (end tour + to navigate). +
+
+ ), + prevStepId: TOUR_STEP_IDS.POWER_SWITCH, + nextStepId: TOUR_STEP_IDS.TOUR_PAGE_BTN, + }, + + [TOUR_STEP_IDS.TOUR_PAGE_BTN]: { + route: "/", + position: "bottom", + content: ( +
+
Tour Page
+ +
+ Run a tour for the current page. +
+
+ ), + prevStepId: TOUR_STEP_IDS.HELP_SWITCH, + nextStepId: TOUR_STEP_IDS.MODE_TOGGLE, + }, + + [TOUR_STEP_IDS.MODE_TOGGLE]: { + route: "/", + position: "bottom", + content: ( +
+
Dark / Light Mode
+ +
+ Cycles between Dark Mode, System Default, and{" "} + Light Mode. +
+
+ ), + prevStepId: TOUR_STEP_IDS.TOUR_PAGE_BTN, + nextStepId: TOUR_STEP_IDS.CAMERAS_PAGE, + }, + }, + }, + "/cameras": { + startStepId: TOUR_STEP_IDS.CAMERAS_PAGE, + waitForSelector: `[data-tour-id="${TOUR_STEP_IDS.CAMERA_DEVICE}"]`, + steps: { + [TOUR_STEP_IDS.CAMERAS_PAGE]: { + route: "/cameras", + position: "left", + disableScroll: true, + content: ( +
+
Cameras
+ +
+ This is where all detected cameras will automatically show up. +
+
+ ), + prevStepId: TOUR_STEP_IDS.MODE_TOGGLE, + nextStepId: TOUR_STEP_IDS.CAMERA_DEVICE, + }, + [TOUR_STEP_IDS.CAMERA_DEVICE]: { + route: "/cameras", + position: "right", + disableScroll: true, + content: ( +
+
Camera Device
+ +
+ This is a detected camera. We will go over what each section + controls and how to set up a streaming endpoint. +
+
+ ), + prevStepId: TOUR_STEP_IDS.CAMERAS_PAGE, + nextStepId: TOUR_STEP_IDS.DROPPED_FRAMES, + }, + + [TOUR_STEP_IDS.DROPPED_FRAMES]: { + route: "/cameras", + position: "right", + disableScroll: true, + highlightPadding: 8, + content: ( +
+
Dropped Frames
+ +
+ You can keep track of how many frames are dropped during + streaming/recording here. +
+
+ ), + prevStepId: TOUR_STEP_IDS.CAMERA_DEVICE, + nextStepId: TOUR_STEP_IDS.DEVICE_NAME, + }, + + [TOUR_STEP_IDS.DEVICE_NAME]: { + route: "/cameras", + position: "right", + disableScroll: true, + highlightPadding: 8, + content: ( +
+
Device Nickname
+ +
+ You may give your device a name here by selecting{" "} + {" "} + Edit. +
+
+ ), + prevStepId: TOUR_STEP_IDS.DROPPED_FRAMES, + nextStepId: TOUR_STEP_IDS.DEVICE_SETTINGS, + }, + + [TOUR_STEP_IDS.DEVICE_SETTINGS]: { + route: "/cameras", + position: "right", + content: ( +
+
Device Settings
+ +
+ This is where you'll find the specific controls to your camera + device's System, Exposure, and Image. +
+
+ ), + prevStepId: TOUR_STEP_IDS.DEVICE_NAME, + nextStepId: TOUR_STEP_IDS.SYSTEM_CONTROLS, + }, + + [TOUR_STEP_IDS.SYSTEM_CONTROLS]: { + route: "/cameras", + position: "right", + popoverWidth: 400, + content: ( +
+
System Controls
+ +
    +
  • + Bitrate +
    + Determines the amount of data processed per second of video. + Higher values yield better video quality but consume more + network bandwidth. +
    +
  • +
  • + Group of Pictures +
    + Sets the interval between key-frames (I-frames) in the video + stream. A higher value improves compression efficiency, while + a lower value can reduce latency and improve stream stability + over weak connections. +
    +
  • +
  • + Variable Bitrate +
    + When enabled, the stream’s bitrate will dynamically adjust + based on the visual complexity of the scene (VBR), saving + bandwidth during static shots rather than pushing a constant + bitrate (CBR). In practice, it will use a bitrate between + 10mbps and 70mbps. +
    +
  • +
+
+ ), + prevStepId: TOUR_STEP_IDS.DEVICE_SETTINGS, + nextStepId: TOUR_STEP_IDS.ADVANCED_CONTROLS, + }, + + [TOUR_STEP_IDS.ADVANCED_CONTROLS]: { + route: "/cameras", + position: "right", + popoverWidth: 400, + content: ( +
+
Advanced Controls
+ +
    +
  • + JPEG Image Quality +
    + Adjusts the compression level, greatest visual clarity results + in greatest file size and vice versa. +
    +
  • +
  • + Strobe Width +
    + Defines the duration of the strobe light pulse, allowing you + to synchronize external lighting with the camera's exposure. +
    +
  • +
  • + ISO +
    + Controls the sensor's sensitivity to light. Higher values + brighten the image but may introduce noise. +
    +
  • +
  • + Exposure Time +
    + Dictates how long the sensor is exposed to light per frame to + control brightness and motion blur. +
    +
  • +
  • + Auto Exposure +
    + When enabled, the camera automatically calculates and adjusts + its exposure settings based on the surrounding ambient light. +
    +
  • +
+
+ ), + prevStepId: TOUR_STEP_IDS.SYSTEM_CONTROLS, + nextStepId: TOUR_STEP_IDS.EXPOSURE_CONTROLS, + }, + + [TOUR_STEP_IDS.EXPOSURE_CONTROLS]: { + route: "/cameras", + position: "right", + popoverWidth: 400, + content: ( +
+
Exposure Controls
+ +
    +
  • + Gain +
    + Artificially amplifies the video signal to increase brightness + in low-light scenarios. +
    +
  • +
  • + Backlight Compensation +
    + Adjusts the exposure to properly illuminate darker subjects + that are positioned against a bright background, preventing + them from appearing as silhouettes. +
    +
  • +
  • + Auto Exposure +
    + When enabled, the camera automatically calculates and adjusts + its exposure settings based on the surrounding ambient light. +
    +
  • +
  • + Exposure Time, Absolute +
    + Allows you to manually dictate the specific duration the + sensor is exposed to light per frame. +
    +
  • +
  • + Exposure, Dynamic Framerate +
    + When toggled on, this allows the camera to automatically lower + its framerate in dark environments. This increases the + exposure time per frame, resulting in a brighter image at the + cost of video smoothness. +
    +
  • +
+
+ ), + prevStepId: TOUR_STEP_IDS.ADVANCED_CONTROLS, + nextStepId: TOUR_STEP_IDS.IMAGE_PROCESSING, + }, + + [TOUR_STEP_IDS.IMAGE_PROCESSING]: { + route: "/cameras", + position: "right", + popoverWidth: 400, + content: ( +
+
Image Processing
+ +
    +
  • + Brightness +
    + Adjusts the overall lightness or darkness of the video feed. +
    +
  • +
  • + Contrast +
    + Modifies the difference between the lightest and darkest areas + of the image. +
    +
  • +
  • + Saturation +
    + Controls the intensity and vividness of the colors. +
    +
  • +
  • + Hue +
    + Shifts the overall color phase (tint) of the video. +
    +
  • +
  • + White Balance, Automatic +
    + Toggles our proprietary auto white-balance algorithm designed + to optimize color accuracy and clarity for underwater. +
    +
  • +
  • + Gamma +
    + Adjusts the brightness of the mid-tones in the video without + severely affecting the extreme shadows or bright highlights. +
    +
  • +
  • + White Balance Temperature +
    + Adjusts the color temperature of the camera. Lower values + produce cooler (more blue) tones, while higher values produce + warmer (more orange) tones. +
    +
  • +
  • + Sharpness +
    + Enhances edge detail to make the image appear crisper and more + defined. +
    +
  • +
  • + Power Line Frequency +
    + Prevents video flickering caused by artificial lights. You + should set this to match your local region’s electrical grid + frequency (e.g., 60 Hz for North America, 50 Hz for + Europe/Asia). +
    +
  • +
+
+ ), + prevStepId: TOUR_STEP_IDS.EXPOSURE_CONTROLS, + nextStepId: TOUR_STEP_IDS.DEVICE_STREAM_CONFIG, + }, + + [TOUR_STEP_IDS.DEVICE_STREAM_CONFIG]: { + route: "/cameras", + position: "right", + disableScroll: true, + popoverWidth: 400, + + content: ( +
+
Device Stream Configuration
+ +
+ The device stream configurations are here. You set customizations + for: +
    +
  • + Resolution +
    + Sets the image dimensions and level of detail. +
    +
  • +
  • + Frame Rate +
    + Determines the number of frames captured per second (FPS) + for video smoothness. +
    +
  • +
  • + Format +
    + Selects the encoding standard used for the video stream. +
    +
  • +
+ We will cover the Endpoints section in the next step. +
+
+ ), + prevStepId: TOUR_STEP_IDS.IMAGE_PROCESSING, + nextStepId: TOUR_STEP_IDS.DEVICE_ENDPOINTS, + }, + + [TOUR_STEP_IDS.DEVICE_ENDPOINTS]: { + route: "/cameras", + position: "right", + disableScroll: true, + highlightPadding: 24, + popoverWidth: 400, + content: ( +
+
Device Endpoints
+ +
+ Here you specify your streaming endpoint. + + Click{" "} + {" "} + Add Endpoint to create an endpoint. + + + With an Endpoint, you can: + +
    +
  • + {" "} + Edit the IP Address or Port +
  • +
  • + {" "} + Delete the Endpoint +
  • +
+
+
+ ), + prevStepId: TOUR_STEP_IDS.DEVICE_STREAM_CONFIG, + nextStepId: TOUR_STEP_IDS.DEVICE_FOLLOWERS, + }, + + [TOUR_STEP_IDS.DEVICE_FOLLOWERS]: { + route: "/cameras", + position: "top", + disableScroll: true, + popoverWidth: 400, + content: ( +
+
Followers
+ +
    +
  • + Here, you can assign Followers. +
  • +
  • + If compatible cameras are detected, you can add them as{" "} + Followers by selecting them in the dropdown and + clicking Add. +
  • +
  • + Once added, the Follower's streaming and recording will + be controlled by the Leader it's assigned to. +
  • +
+
+ ), + prevStepId: TOUR_STEP_IDS.DEVICE_ENDPOINTS, + nextStepId: TOUR_STEP_IDS.DEVICE_RESET, + }, + + [TOUR_STEP_IDS.DEVICE_RESET]: { + route: "/cameras", + position: "right", + disableScroll: true, + content: ( +
+
Reset Card
+ +
+ Reset this device's controls to default settings. +
+
+ ), + prevStepId: TOUR_STEP_IDS.DEVICE_FOLLOWERS, + nextStepId: TOUR_STEP_IDS.DEVICE_STREAM, + }, + + [TOUR_STEP_IDS.DEVICE_STREAM]: { + route: "/cameras", + position: "right", + disableScroll: true, + highlightPadding: 8, + content: ( +
+
Stream
+ +
+ This is where you control your stream. + Please ensure your endpoints are correct. + + If this device is managed (Follower), this button will + be disabled. + +
+
+ ), + prevStepId: TOUR_STEP_IDS.DEVICE_RESET, + nextStepId: TOUR_STEP_IDS.RECORDING_PAGE, + }, + }, + }, + "/recordings": { + startStepId: TOUR_STEP_IDS.RECORDING_PAGE, + steps: { + [TOUR_STEP_IDS.RECORDING_PAGE]: { + route: "/recordings", + position: "left", + content: ( +
+
Recordings
+ +
+ + This is where you'll find all recordings done on the system + through DWE Products. + + Drag to select multiple files. +
+
+ ), + prevStepId: TOUR_STEP_IDS.DEVICE_STREAM, + nextStepId: TOUR_STEP_IDS.RECORDING_ITEM, + }, + + [TOUR_STEP_IDS.RECORDING_ITEM]: { + route: "/recordings", + position: "bottom", + content: ( +
+
Recording File
+ +
+ + {" "} + Right-Click a recording/selections to open an options + menu. + + + + x2 Double-Left-Click to quickly Play supported + videos. + +
+
+ ), + prevStepId: TOUR_STEP_IDS.RECORDING_PAGE, + nextStepId: TOUR_STEP_IDS.RECORDING_FOOTER, + }, + + [TOUR_STEP_IDS.RECORDING_FOOTER]: { + route: "/recordings", + position: "top", + content: ( +
+
Recordings Footer
+ +
+ Down here you have additional details and controls. +
+
+ ), + prevStepId: TOUR_STEP_IDS.RECORDING_ITEM, + nextStepId: TOUR_STEP_IDS.RECORDINGS_FUNCTIONS, + }, + + [TOUR_STEP_IDS.RECORDINGS_FUNCTIONS]: { + route: "/recordings", + position: "top", + highlightPadding: 8, + content: ( +
+
Recordings Functions
+ +
+ Upon selecting at least one item, the following functions become + available to you: +
    +
  • + {" "} + Download{" "} +
    + Download the selected files onto the system. +
    +
  • +
  • + {" "} + Delete +
    + Delete the selected files. +
    +
  • +
+
+
+ ), + prevStepId: TOUR_STEP_IDS.RECORDING_FOOTER, + nextStepId: TOUR_STEP_IDS.STORAGE_BAR, + }, + + [TOUR_STEP_IDS.STORAGE_BAR]: { + route: "/recordings", + position: "top", + highlightPadding: 12, + content: ( +
+
Storage
+ +
+ You can see how storage is allocated on your system with this + widget. +
+
+ ), + prevStepId: TOUR_STEP_IDS.RECORDINGS_FUNCTIONS, + nextStepId: TOUR_STEP_IDS.NETWORKING_PAGE, + }, + }, + }, + "/network": { + startStepId: TOUR_STEP_IDS.NETWORKING_PAGE, + waitForSelector: `[data-tour-id="${TOUR_STEP_IDS.NETWORK_OPTION}"]`, + steps: { + [TOUR_STEP_IDS.NETWORKING_PAGE]: { + route: "/network", + position: "bottom", + highlightPadding: 12, + content: ( +
+
Network Management
+ +
+ Welcome to the Network page. From here, you can manage both your + wired and wireless device connections and route settings. +
+
+ ), + prevStepId: TOUR_STEP_IDS.STORAGE_BAR, + nextStepId: TOUR_STEP_IDS.WIRED_CONFIG, + }, + [TOUR_STEP_IDS.WIRED_CONFIG]: { + route: "/network", + position: "right", + highlightPadding: 12, + content: ( +
+
Wired Configuration
+ +
+ This section lists all detected wired network interfaces. You can + view their current state and manage their connection profiles. +
+
+ ), + prevStepId: TOUR_STEP_IDS.NETWORKING_PAGE, + nextStepId: TOUR_STEP_IDS.NETWORK_OPTION, + }, + [TOUR_STEP_IDS.NETWORK_OPTION]: { + route: "/network", + position: "right", + highlightPadding: 12, + content: ( +
+
Interface Status & Profiles
+ +
+ Click on a network interface to expand it. You'll see its current + state (e.g., Connected, Disconnected) and all available connection + profiles. +
+
+ ), + prevStepId: TOUR_STEP_IDS.WIRED_CONFIG, + nextStepId: TOUR_STEP_IDS.NETWORK_OPTION_SETTINGS, + }, + [TOUR_STEP_IDS.NETWORK_OPTION_SETTINGS]: { + route: "/network", + position: "right", + content: ( +
+
Edit Connection Profile
+ +
+ + Click the settings icon to modify a specific profile. You can + switch between DHCP and Static IP, assign custom DNS servers, + and change default routing preferences. + + + Note: Remember to click on the profile to apply your changes. + +
+
+ ), + prevStepId: TOUR_STEP_IDS.NETWORK_OPTION, + nextStepId: TOUR_STEP_IDS.WIRELESS_NETWORK, + }, + [TOUR_STEP_IDS.WIRELESS_NETWORK]: { + route: "/network", + position: "left", + highlightPadding: 12, + content: ( +
+
Wireless Network
+ +
+ Currently, wireless is unsupported. If your device supports Wi-Fi, + you will be able to scan for networks and manage wireless + connections here in future updates. +
+
+ ), + prevStepId: TOUR_STEP_IDS.NETWORK_OPTION_SETTINGS, + nextStepId: TOUR_STEP_IDS.PREFS_PAGE, + }, + }, + }, + "/preferences": { + startStepId: TOUR_STEP_IDS.PREFS_PAGE, + steps: { + [TOUR_STEP_IDS.PREFS_PAGE]: { + route: "/preferences", + position: "left", + content: ( +
+
Preferences
+ +
+ All dweOS preferences can be found here. Any settings and + configurations of the app (not devices) will be here. +
+
+ ), + prevStepId: TOUR_STEP_IDS.WIRELESS_NETWORK, + nextStepId: TOUR_STEP_IDS.DEFAULT_STREAM_PREFS, + }, + + [TOUR_STEP_IDS.DEFAULT_STREAM_PREFS]: { + route: "/preferences", + position: "bottom", + content: ( +
+
Default Stream Preferences
+ +
+ These fields determine what show up automatically when you add an + endpoint. The Default Stream Port will increment automatically as + you add new endpoints for the same Stream Host. +
+
+ ), + prevStepId: TOUR_STEP_IDS.PREFS_PAGE, + nextStepId: TOUR_STEP_IDS.RESET_TOUR, + }, + [TOUR_STEP_IDS.RESET_TOUR]: { + route: "/preferences", + position: "bottom", + content: ( +
+
Reset Tour
+ +
+ You may restart the app-wide tour guide here. Resetting{" "} + WILL REFRESH the application. +
+
+ ), + prevStepId: TOUR_STEP_IDS.DEFAULT_STREAM_PREFS, + nextStepId: TOUR_STEP_IDS.LOGS_PAGE, + }, + }, + }, + "/log-viewer": { + startStepId: TOUR_STEP_IDS.LOGS_PAGE, + waitForSelector: `[data-tour-id="${TOUR_STEP_IDS.DEBUG_LOG}"]`, + steps: { + [TOUR_STEP_IDS.LOGS_PAGE]: { + route: "/log-viewer", + position: "left", + content: ( +
+
Logs
+ +
+ The Logs page displays debug logs from across the app. Have these + on hand when contacting support. +
+
+ ), + prevStepId: TOUR_STEP_IDS.RESET_TOUR, + nextStepId: TOUR_STEP_IDS.DEBUG_LOG, + }, + + [TOUR_STEP_IDS.DEBUG_LOG]: { + route: "/log-viewer", + position: "bottom", + content: ( +
+
Debug Log
+ +
+ You can{" "} + + left-click into a log for a detailed view. +
+
+ ), + prevStepId: TOUR_STEP_IDS.LOGS_PAGE, + nextStepId: TOUR_STEP_IDS.TERMINAL, + }, + }, + }, + "/terminal": { + startStepId: TOUR_STEP_IDS.TERMINAL, + steps: { + [TOUR_STEP_IDS.TERMINAL]: { + route: "/terminal", + position: "left", + content: ( +
+
Terminal
+ +
+ An instance of your system's terminal is running here. +
+
+ ), + prevStepId: TOUR_STEP_IDS.DEBUG_LOG, + }, + }, + }, +}; diff --git a/frontend/src/components/tour/tour-utils.ts b/frontend/src/components/tour/tour-utils.ts new file mode 100644 index 00000000..dfe6503e --- /dev/null +++ b/frontend/src/components/tour/tour-utils.ts @@ -0,0 +1,60 @@ +export const PADDING = 16; +export const CONTENT_WIDTH = 300; +export const CONTENT_HEIGHT = 200; + +export function getElementPosition(id: string, highlightPadding: number = 0) { + const element = document.querySelector(`[data-tour-id="${id}"]`); + if (!element) return null; + const rect = element.getBoundingClientRect(); + return { + top: rect.top + window.scrollY - highlightPadding, + left: rect.left + window.scrollX - highlightPadding, + width: rect.width + highlightPadding * 2, + height: rect.height + highlightPadding * 2, + }; +} + +export function calculateContentPosition( + elementPos: { top: number; left: number; width: number; height: number }, + position: "top" | "bottom" | "left" | "right" = "bottom", + popoverWidth: number = CONTENT_WIDTH, + popoverHeight: number = CONTENT_HEIGHT, +) { + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + let left = elementPos.left; + let top = elementPos.top; + + switch (position) { + case "top": + top = elementPos.top - popoverHeight - PADDING; + left = elementPos.left + elementPos.width / 2 - popoverWidth / 2; + break; + case "bottom": + top = elementPos.top + elementPos.height + PADDING; + left = elementPos.left + elementPos.width / 2 - popoverWidth / 2; + break; + case "left": + left = elementPos.left - popoverWidth - PADDING; + top = elementPos.top + elementPos.height / 2 - popoverHeight / 2; + break; + case "right": + left = elementPos.left + elementPos.width + PADDING; + top = elementPos.top + elementPos.height / 2 - popoverHeight / 2; + break; + } + + return { + top: Math.max( + PADDING, + Math.min(top, viewportHeight - popoverHeight - PADDING), + ), + left: Math.max( + PADDING, + Math.min(left, viewportWidth - popoverWidth - PADDING), + ), + width: popoverWidth, + height: popoverHeight, + }; +} diff --git a/frontend/src/components/tour/tour.tsx b/frontend/src/components/tour/tour.tsx deleted file mode 100644 index bad006a0..00000000 --- a/frontend/src/components/tour/tour.tsx +++ /dev/null @@ -1,840 +0,0 @@ -import { - animate, - AnimatePresence, - motion, - useMotionTemplate, - useMotionValue, -} from "motion/react"; -import type React from "react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; - -import { - AlertDialog, - AlertDialogContent, - AlertDialogDescription, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; -import { Button } from "@/components/ui/button"; -import { cn } from "@/lib/utils"; - -import { AnimatedWaves } from "@/assets/animated-waves"; -import { TourContext, TourStep, useTour } from "@/components/tour/tour-context"; -import { useTourSteps } from "@/components/tour/tour-lib/tour-steps"; -import { X } from "lucide-react"; -import { useLocation, useNavigate } from "react-router-dom"; -import { Separator } from "../ui/separator"; - -interface TourProviderProps { - children: React.ReactNode; - onComplete?: () => void; - className?: string; - isTourCompleted?: boolean; - storageKey?: string; -} - -const PADDING = 16; -const CONTENT_WIDTH = 300; -const CONTENT_HEIGHT = 200; - -function getElementPosition(id: string, highlightPadding: number = 0) { - const element = document.querySelector(`[data-tour-id="${id}"]`); - if (!element) return null; - const rect = element.getBoundingClientRect(); - return { - top: rect.top + window.scrollY - highlightPadding, - left: rect.left + window.scrollX - highlightPadding, - width: rect.width + highlightPadding * 2, - height: rect.height + highlightPadding * 2, - }; -} - -function calculateContentPosition( - elementPos: { top: number; left: number; width: number; height: number }, - position: "top" | "bottom" | "left" | "right" = "bottom", - popoverWidth: number = CONTENT_WIDTH, - popoverHeight: number = CONTENT_HEIGHT, -) { - const viewportWidth = window.innerWidth; - const viewportHeight = window.innerHeight; - - let left = elementPos.left; - let top = elementPos.top; - - switch (position) { - case "top": - top = elementPos.top - popoverHeight - PADDING; - left = elementPos.left + elementPos.width / 2 - popoverWidth / 2; - break; - case "bottom": - top = elementPos.top + elementPos.height + PADDING; - left = elementPos.left + elementPos.width / 2 - popoverWidth / 2; - break; - case "left": - left = elementPos.left - popoverWidth - PADDING; - top = elementPos.top + elementPos.height / 2 - popoverHeight / 2; - break; - case "right": - left = elementPos.left + elementPos.width + PADDING; - top = elementPos.top + elementPos.height / 2 - popoverHeight / 2; - break; - } - - return { - top: Math.max( - PADDING, - Math.min(top, viewportHeight - popoverHeight - PADDING), - ), - left: Math.max( - PADDING, - Math.min(left, viewportWidth - popoverWidth - PADDING), - ), - width: popoverWidth, - height: popoverHeight, - }; -} - -export function TourProvider({ - children, - className, - isTourCompleted = false, - storageKey = "tourCompleted", -}: TourProviderProps) { - const [steps, setSteps] = useState>({}); - const [currentStepId, setCurrentStepId] = useState(null); - const [activeSegmentPath, setActiveSegmentPath] = useState( - null, - ); - const [elementPosition, setElementPosition] = useState<{ - top: number; - left: number; - width: number; - height: number; - } | null>(null); - const [isLocating, setIsLocating] = useState(false); - - const navigate = useNavigate(); - const location = useLocation(); - const dynamicSegments = useTourSteps(); - - const allFlattenedSteps = useMemo(() => { - const flatPool: Record = {}; - Object.values(dynamicSegments).forEach((segment) => { - Object.assign(flatPool, segment.steps); - }); - return flatPool; - }, [dynamicSegments]); - - useEffect(() => { - setSteps(allFlattenedSteps); - }, [allFlattenedSteps]); - - const stepsRef = useRef(steps); - useEffect(() => { - stepsRef.current = steps; - }, [steps]); - - const [isCompleted, setIsCompleted] = useState(() => { - if (typeof window !== "undefined") { - const stored = localStorage.getItem(storageKey); - if (stored !== null) { - return stored === "true"; - } - } - return isTourCompleted; - }); - - const observerRef = useRef<{ - disconnect: () => void; - } | null>(null); - const prevStepRef = useRef(currentStepId); - const directionRef = useRef<"forward" | "backward">("forward"); - - // for the highlight box - const x = useMotionValue(0); - const y = useMotionValue(0); - const w = useMotionValue(0); - const h = useMotionValue(0); - - // for the content popover - const popoverX = useMotionValue(0); - const popoverY = useMotionValue(0); - - const transitionConfig = useMemo( - () => - ({ - type: "spring", - mass: 0.2, - stiffness: 100, - damping: 15, - }) as const, - [], - ); - - const saveTourState = useCallback( - (completed: boolean) => { - if (typeof window !== "undefined") { - localStorage.setItem(storageKey, String(completed)); - } - }, - [storageKey], - ); - - // ACTIONS - const startTour = useCallback( - (isPageOnly: boolean = false) => { - setIsCompleted(false); - saveTourState(false); - - const activeSegment = dynamicSegments[location.pathname]; - if (activeSegment) { - setActiveSegmentPath(isPageOnly ? location.pathname : null); - setCurrentStepId(activeSegment.startStepId); - } else { - setActiveSegmentPath(null); - } - }, - [saveTourState, dynamicSegments, location.pathname], - ); - - const completeTour = useCallback(() => { - setIsCompleted(true); - saveTourState(true); - setCurrentStepId(null); - }, [saveTourState]); - - const resetTour = useCallback(() => { - setIsCompleted(false); - saveTourState(false); - setCurrentStepId(null); - window.location.href = "/"; - }, [saveTourState]); - - const cancelTour = useCallback(() => { - setCurrentStepId(null); - }, []); - - const nextStep = useCallback(() => { - if (!currentStepId || !stepsRef.current[currentStepId]) return; - - directionRef.current = "forward"; - const step = stepsRef.current[currentStepId]; - - if (step.nextStepId && stepsRef.current[step.nextStepId]) { - if (activeSegmentPath && dynamicSegments[activeSegmentPath]) { - const isNextStepInSegment = - !!dynamicSegments[activeSegmentPath].steps[step.nextStepId]; - if (!isNextStepInSegment) { - completeTour(); - return; - } - } - setCurrentStepId(step.nextStepId); - } else { - completeTour(); - } - }, [currentStepId, completeTour, dynamicSegments, activeSegmentPath]); - - const prevStep = useCallback(() => { - if (!currentStepId || !stepsRef.current[currentStepId]) return; - - directionRef.current = "backward"; - const step = stepsRef.current[currentStepId]; - - if (step.prevStepId && stepsRef.current[step.prevStepId]) { - if (activeSegmentPath && dynamicSegments[activeSegmentPath]) { - const isPrevStepInSegment = - !!dynamicSegments[activeSegmentPath].steps[step.prevStepId]; - - if (!isPrevStepInSegment) { - return; - } - } - setCurrentStepId(step.prevStepId); - } - }, [currentStepId, activeSegmentPath, dynamicSegments]); - - const goToStepById = useCallback((id: string) => { - if (stepsRef.current[id]) { - setCurrentStepId(id); - } else { - console.warn(`Attempted to go to non-existent step: ${id}`); - } - }, []); - - const updatePosition = useCallback(() => { - if (!currentStepId || !steps[currentStepId]) return; - const step = steps[currentStepId]; - const targetId = step.selectorId || currentStepId; - const pos = getElementPosition(targetId, step.highlightPadding ?? 0); - - if (pos) { - setIsLocating(false); - setElementPosition((prev) => { - if ( - prev && - Math.abs(prev.top - pos.top) < 1 && - Math.abs(prev.left - pos.left) < 1 && - Math.abs(prev.width - pos.width) < 1 && - Math.abs(prev.height - pos.height) < 1 - ) { - return prev; - } - return pos; - }); - } else { - setIsLocating(true); - console.warn( - `Tour element [data-tour-id="${targetId}"] removed from DOM. Auto-skipping backward.`, - ); - if (step.prevStepId) { - prevStep(); - } else { - cancelTour(); - } - } - }, [currentStepId, steps, cancelTour, prevStep]); - - // Sync MotionValues - useEffect(() => { - if ( - (elementPosition || isLocating) && - currentStepId && - steps[currentStepId] - ) { - const step = steps[currentStepId]; - const isStarting = prevStepRef.current === null; - - const popoverEl = document.getElementById("tour-popover"); - const actualHeight = popoverEl ? popoverEl.offsetHeight : CONTENT_HEIGHT; - - let targetX = window.innerWidth / 2; - let targetY = window.innerHeight / 2; - let targetW = 0; - let targetH = 0; - - let contentLeft = - window.innerWidth / 2 - (step.popoverWidth || CONTENT_WIDTH) / 2; - let contentTop = window.innerHeight / 2 - actualHeight / 2; - - if (!isLocating && elementPosition) { - targetW = step.width || elementPosition.width; - targetH = step.height || elementPosition.height; - targetX = elementPosition.left; - targetY = elementPosition.top; - - const contentPos = calculateContentPosition( - { ...elementPosition, width: targetW, height: targetH }, - step.position, - step.popoverWidth, - actualHeight, - ); - contentLeft = contentPos.left; - contentTop = contentPos.top; - } - - if (isStarting) { - x.set(targetX); - y.set(targetY); - w.set(targetW); - h.set(targetH); - popoverX.set(contentLeft); - popoverY.set(contentTop); - } else { - animate(x, targetX, transitionConfig); - animate(y, targetY, transitionConfig); - animate(w, targetW, transitionConfig); - animate(h, targetH, transitionConfig); - animate(popoverX, contentLeft, transitionConfig); - animate(popoverY, contentTop, transitionConfig); - } - prevStepRef.current = currentStepId; - } - }, [ - elementPosition, - currentStepId, - steps, - isLocating, - h, - w, - x, - y, - popoverX, - popoverY, - transitionConfig, - ]); - - const clipPath = useMotionTemplate`polygon( - 0% 0%, 0% 100%, 100% 100%, 100% 0%, - ${x}px 0%, ${x}px ${y}px, calc(${x}px + ${w}px) ${y}px, - calc(${x}px + ${w}px) calc(${y}px + ${h}px), ${x}px calc(${y}px + ${h}px), ${x}px 0% - )`; - - useEffect(() => { - if (currentStepId && steps[currentStepId]) { - const step = steps[currentStepId]; - const targetId = step.selectorId || currentStepId; - - if (step.route && location.pathname !== step.route) { - navigate(step.route); - return; - } - - const instantElement = document.querySelector( - `[data-tour-id="${targetId}"]`, - ); - setIsLocating(!instantElement); - - const attachObserver = () => { - const element = document.querySelector( - `[data-tour-id="${targetId}"]`, - ); - if (observerRef.current) observerRef.current.disconnect(); - - if (element) { - setIsLocating(false); - element.scrollIntoView({ behavior: "smooth", block: "center" }); - updatePosition(); - - const handleShift = () => - window.requestAnimationFrame(updatePosition); - const resizeObs = new ResizeObserver(handleShift); - const mutationObs = new MutationObserver(handleShift); - - resizeObs.observe(element); - resizeObs.observe(document.body); - mutationObs.observe(document.body, { - childList: true, - subtree: true, - attributes: true, - attributeFilter: ["class", "style"], - }); - - observerRef.current = { - disconnect: () => { - resizeObs.disconnect(); - mutationObs.disconnect(); - }, - }; - } else { - // auto-skip missing elements - console.warn( - `Tour element #${targetId} not found. Skipping ${directionRef.current}.`, - ); - if (directionRef.current === "forward") { - if (step.nextStepId && steps[step.nextStepId]) { - setCurrentStepId(step.nextStepId); - } else { - completeTour(); - } - } else { - if (step.prevStepId && steps[step.prevStepId]) { - setCurrentStepId(step.prevStepId); - } else { - cancelTour(); - } - } - } - }; - - const timer = setTimeout(attachObserver, 150); - return () => { - clearTimeout(timer); - if (observerRef.current) observerRef.current.disconnect(); - }; - } else { - setElementPosition(null); - } - }, [ - currentStepId, - steps, - location.pathname, - navigate, - updatePosition, - cancelTour, - completeTour, - ]); - - useEffect(() => { - let ticking = false; - const handleScrollOrResize = () => { - if (!ticking) { - window.requestAnimationFrame(() => { - updatePosition(); - ticking = false; - }); - ticking = true; - } - }; - window.addEventListener("resize", handleScrollOrResize); - window.addEventListener("scroll", handleScrollOrResize, { - passive: true, - capture: true, - }); - return () => { - window.removeEventListener("resize", handleScrollOrResize); - window.removeEventListener("scroll", handleScrollOrResize, { - capture: true, - }); - }; - }, [updatePosition]); - - // observe for specific clicks to advance/retreat the tour - useEffect(() => { - if (!currentStepId || !steps[currentStepId]) return; - const step = steps[currentStepId]; - if (!step.advanceOnClick && !step.retreatOnClick) return; - const targetId = step.selectorId || currentStepId; - - const handleTargetClick = (e: MouseEvent) => { - if (!e.isTrusted) return; - const target = e.target as HTMLElement; - - // advance - if (step.advanceOnClick) { - const ids = Array.isArray(step.advanceOnClick) - ? step.advanceOnClick - : // if string, target is that id, otherwise it's the steps id - typeof step.advanceOnClick === "string" - ? [step.advanceOnClick] - : [targetId]; - - // advance if any of these ids are clicked - if (ids.some((id) => target.closest(`[data-tour-id="${id}"]`))) { - nextStep(); - return; - } - } - - // retreat - if (step.retreatOnClick) { - const ids = Array.isArray(step.retreatOnClick) - ? step.retreatOnClick - : // if string, target is that id, otherwise it's the steps id - typeof step.retreatOnClick === "string" - ? [step.retreatOnClick] - : [targetId]; - - // retreat if any of these ids are clicked - if (ids.some((id) => target.closest(`[data-tour-id="${id}"]`))) { - prevStep(); - } - } - }; - - window.addEventListener("click", handleTargetClick, true); - return () => { - window.removeEventListener("click", handleTargetClick, true); - }; - }, [currentStepId, steps, nextStep, prevStep]); - - // for blocking scrolls if needed within border box - useEffect(() => { - if (!currentStepId || !steps[currentStepId]) return; - const step = steps[currentStepId]; - if (!step.disableScroll) return; - - const preventScroll = (e: Event) => { - e.preventDefault(); - }; - - const options = { passive: false, capture: true }; - - window.addEventListener("wheel", preventScroll, options); - window.addEventListener("touchmove", preventScroll, options); - - return () => { - window.removeEventListener("wheel", preventScroll, options); - window.removeEventListener("touchmove", preventScroll, options); - }; - }, [currentStepId, steps]); - - const contextValue = useMemo( - () => ({ - steps, - setSteps, - currentStepId, - isActive: currentStepId !== null, - isTourCompleted: isCompleted, - startTour, - completeTour, - resetTour, - cancelTour, - nextStep, - prevStep, - goToStepById, - }), - [ - steps, - currentStepId, - isCompleted, - startTour, - completeTour, - resetTour, - cancelTour, - nextStep, - prevStep, - goToStepById, - ], - ); - - const isFirstStep = useMemo(() => { - if (!currentStepId || !steps[currentStepId]) return true; - const step = steps[currentStepId]; - - // 1. Global check: If there is no previous step at all, we are at the start. - if (!step.prevStepId || !steps[step.prevStepId]) return true; - - // 2. Sandbox check: If we are locked to a page, verify the previous step stays on this page. - if (activeSegmentPath && dynamicSegments[activeSegmentPath]) { - const isPrevStepInSegment = - !!dynamicSegments[activeSegmentPath].steps[step.prevStepId]; - if (!isPrevStepInSegment) return true; - } - - return false; - }, [currentStepId, steps, activeSegmentPath, dynamicSegments]); - - const isLastStep = useMemo(() => { - if (!currentStepId || !steps[currentStepId]) return true; - const step = steps[currentStepId]; - - // 1. Global check: If there is no next step at all, we are done. - if (!step.nextStepId || !steps[step.nextStepId]) return true; - - // 2. Sandbox check: If we are locked to a page, verify the next step stays on this page. - if (activeSegmentPath && dynamicSegments[activeSegmentPath]) { - const isNextStepInSegment = - !!dynamicSegments[activeSegmentPath].steps[step.nextStepId]; - if (!isNextStepInSegment) return true; - } - - return false; - }, [currentStepId, steps, activeSegmentPath, dynamicSegments]); - - const currentStepData = currentStepId ? steps[currentStepId] : null; - - return ( - - {children} - - {currentStepId && - currentStepData && - (elementPosition || isLocating) && ( - <> - - - {/* Border Box */} - - currentStepData.disableInteraction && e.stopPropagation() - } - onMouseDown={(e) => - currentStepData.disableInteraction && e.stopPropagation() - } - onMouseUp={(e) => - currentStepData.disableInteraction && e.stopPropagation() - } - /> - - {/* Content Popover */} - - - - - - - {currentStepData.content} - - - -
-
- {/* Prev Button */} - {currentStepData.prevStepId && - !currentStepData.hidePrev && - !isFirstStep && ( - - )} - {/* Separator */} - {currentStepData.prevStepId && - !currentStepData.hideNext && - !currentStepData.hidePrev && - !isFirstStep && ( - - )} - {/* Next Button */} - {!currentStepData.hideNext && ( - - )} -
-
-
- - )} -
-
- ); -} - -export function TourAlertDialog() { - const { startTour, completeTour, isTourCompleted, currentStepId } = useTour(); - const dynamicSegments = useTourSteps(); - const location = useLocation(); - - const activeSegment = dynamicSegments[location.pathname]; - const isOpen = - !isTourCompleted && - currentStepId === null && - !!activeSegment && - Object.keys(activeSegment.steps).length > 0; - - return ( - - - -
- -
- - Welcome to DWE OS - - -
- Take a quick tour to learn about the key features and - functionality of DWE OS. -
-
-
- You can restart this tour anytime in{" "} - Preferences -
-
-
-
-
- - -
-
-
- ); -} From 1a631772e80ad1f3c6bcc9579511ad2af3171edf Mon Sep 17 00:00:00 2001 From: John Zhou Date: Thu, 18 Jun 2026 14:02:30 -0700 Subject: [PATCH 4/6] stream active indicator --- .../src/components/dwe/cameras/device-card.tsx | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/dwe/cameras/device-card.tsx b/frontend/src/components/dwe/cameras/device-card.tsx index c447c2f3..9a003824 100644 --- a/frontend/src/components/dwe/cameras/device-card.tsx +++ b/frontend/src/components/dwe/cameras/device-card.tsx @@ -11,7 +11,9 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; import { useDeviceStore } from "@/store/devices"; +import { motion } from "motion/react"; import { FrameDropIndicator } from "./frame-drop-indicator"; import { CameraNickname } from "./nickname"; import { CameraStream } from "./stream/stream"; @@ -22,6 +24,9 @@ const DeviceCard = ({ bus_id }: { bus_id: string }) => { (state) => state.devices[bus_id].manufacturer, ); const string3 = useDeviceStore((state) => state.devices[bus_id].string3); + const isStreaming = useDeviceStore( + (state) => state.devices[bus_id].stream.enabled, + ); return ( {
- {deviceName} + + {deviceName} + + {deviceManufacturer} • {bus_id}
From 6ad48b5739fe5617181366a7c6868a8d22889934 Mon Sep 17 00:00:00 2001 From: John Zhou Date: Thu, 18 Jun 2026 17:04:29 -0700 Subject: [PATCH 5/6] add loading/loaded states to cameras, recordings, network, logs and attached loading states to tour to use. removed unsued tour functions, made autoskipping prev and next more robust to handle disappearing elements and loading elements --- .../components/dwe/cameras/device-list.tsx | 44 ++++- .../components/dwe/log-page/log-viewer.tsx | 166 +++++++++++------- .../dwe/network/wired/wired-config.tsx | 46 ++++- .../recordings/components/recording-table.tsx | 10 -- .../components/dwe/recordings/recordings.tsx | 50 +++++- .../dwe/recordings/store/recording-store.tsx | 3 + .../src/components/tour/tour-constants.ts | 13 ++ frontend/src/components/tour/tour-context.ts | 5 +- frontend/src/components/tour/tour-overlay.tsx | 138 ++++----------- frontend/src/components/tour/tour-steps.tsx | 39 +++- frontend/src/store/devices.ts | 22 ++- 11 files changed, 325 insertions(+), 211 deletions(-) diff --git a/frontend/src/components/dwe/cameras/device-list.tsx b/frontend/src/components/dwe/cameras/device-list.tsx index bd257373..425c7810 100644 --- a/frontend/src/components/dwe/cameras/device-list.tsx +++ b/frontend/src/components/dwe/cameras/device-list.tsx @@ -1,7 +1,9 @@ import { TOUR_STEP_IDS } from "@/components/tour/tour-constants"; +import { Spinner } from "@/components/ui/spinner"; import WebsocketContext from "@/contexts/WebsocketContext"; import { useDeviceStore } from "@/store/devices"; import { usePreferencesStore } from "@/store/preferences"; +import { CameraOff } from "lucide-react"; import { useContext, useEffect } from "react"; import { useShallow } from "zustand/shallow"; import DeviceCard from "./device-card"; @@ -16,6 +18,9 @@ const DeviceListLayout = () => { const deviceIds = useDeviceStore( useShallow((state) => Object.keys(state.devices)), ); + + const isFetchingDevices = useDeviceStore((state) => state.isFetchingDevices); + const hasFetchedDevices = useDeviceStore((state) => state.hasFetchedDevices); const resetDevices = useDeviceStore((state) => state.reset); const fetchDevices = useDeviceStore((state) => state.fetchDevices); @@ -47,13 +52,40 @@ const DeviceListLayout = () => { return (
-
- {deviceIds.map((id) => ( -
- + {!hasFetchedDevices || isFetchingDevices ? ( +
+ +

+ Scanning for devices... +

+
+ ) : deviceIds.length === 0 ? ( +
+
+ +
+
+

+ No Cameras Detected +

+

+ Please ensure your devices are properly connected and powered on. +

+ Scanning...
- ))} -
+
+ ) : ( +
+ {deviceIds.map((id) => ( +
+ +
+ ))} +
+ )}
); }; diff --git a/frontend/src/components/dwe/log-page/log-viewer.tsx b/frontend/src/components/dwe/log-page/log-viewer.tsx index 983eb0f7..a8e05e07 100644 --- a/frontend/src/components/dwe/log-page/log-viewer.tsx +++ b/frontend/src/components/dwe/log-page/log-viewer.tsx @@ -21,6 +21,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { Spinner } from "@/components/ui/spinner"; import { Table, TableBody, @@ -32,7 +33,7 @@ import { import WebsocketContext from "@/contexts/WebsocketContext"; import { getLevelColor } from "@/lib/utils"; import { components } from "@/schemas/dwe_os_2"; -import { RefreshCw, Search } from "lucide-react"; +import { FileWarning, RefreshCw, Search } from "lucide-react"; import { useContext, useEffect, useState } from "react"; import { LogDetailView } from "./log-detail-view"; @@ -45,7 +46,8 @@ export function LogViewer() { >([]); const [levelFilter, setLevelFilter] = useState("ALL"); const [searchQuery, setSearchQuery] = useState(""); - const [isLoading, setIsLoading] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [hasFetched, setHasFetched] = useState(false); const [currentPage, setCurrentPage] = useState(1); const [itemsPerPage, setItemsPerPage] = useState(10); const [selectedLog, setSelectedLog] = useState< @@ -57,7 +59,10 @@ export function LogViewer() { const logLevels = ["ALL", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]; const updateLogs = async () => { + setIsLoading(true); setLogs((await API_CLIENT.GET("/api/logs")).data!); + setIsLoading(false); + setHasFetched(true); }; useEffect(() => { @@ -209,76 +214,103 @@ export function LogViewer() {
-
- - - - Timestamp - Level - Logger - Source - Message - - - - {currentItems.length > 0 ? ( - currentItems.map((log, index) => ( - { - setSelectedLog(log); - setIsDetailOpen(true); - }} - > - - {formatTimestamp(log.timestamp)} - - - - {log.level} - - - + +

+ Fetching logs... +

+ + ) : logs.length === 0 ? ( +
+
+ +
+
+

+ No Logs Found +

+

+ There are currently no system logs to display. +

+ Scanning... +
+
+ ) : ( +
+
+ + + Timestamp + Level + Logger + Source + Message + + + + {currentItems.length > 0 ? ( + currentItems.map((log, index) => ( + { + setSelectedLog(log); + setIsDetailOpen(true); + }} > - {log.name} - - -
-
- {log.filename}:{log.lineno} + + {formatTimestamp(log.timestamp)} + + + + {log.level} + + + + {log.name} + + +
+
+ {log.filename}:{log.lineno} +
+
+ {log.function}() +
-
- {log.function}() + + +
+ {log.message}
-
-
- -
- {log.message} -
+
+ + )) + ) : ( + + + No logs found matching your filters. - )) - ) : ( - - - No logs found matching your filters. - - - )} - -
-
+ )} + + +
+ )}
diff --git a/frontend/src/components/dwe/network/wired/wired-config.tsx b/frontend/src/components/dwe/network/wired/wired-config.tsx index 8f587383..2df2895f 100644 --- a/frontend/src/components/dwe/network/wired/wired-config.tsx +++ b/frontend/src/components/dwe/network/wired/wired-config.tsx @@ -25,6 +25,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { Spinner } from "@/components/ui/spinner"; import { Table, TableBody, @@ -360,7 +361,7 @@ function ConnectionProfile({ {/* Text Content */}
-
+
{profile.id} {isActive && } @@ -468,6 +469,8 @@ function WiredDevice({ export default function WiredConfig() { const [devices, setDevices] = useState([] as WiredDeviceModel[]); + const [isFetching, setIsFetching] = useState(true); + const [hasFetched, setHasFetched] = useState(false); const { connected, socket } = useContext(WebsocketContext)!; @@ -487,6 +490,9 @@ export default function WiredConfig() { if (profileMap) setProfiles(profileMap); setDevices(devicesData); + + setIsFetching(false); + setHasFetched(true); }); }); }; @@ -511,11 +517,39 @@ export default function WiredConfig() { Wired Configuration -
- {devices.map((dev) => ( - - ))} -
+ {isFetching && !hasFetched ? ( +
+ +

+ Scanning interfaces... +

+
+ ) : devices.length === 0 ? ( +
+
+

+ No Interfaces Found +

+

+ Ensure your device has a valid physical connection. +

+ Scanning... +
+
+ ) : ( +
+ {devices.map((dev) => ( + + ))} +
+ )}
); diff --git a/frontend/src/components/dwe/recordings/components/recording-table.tsx b/frontend/src/components/dwe/recordings/components/recording-table.tsx index 34fe570b..d2a05aa6 100644 --- a/frontend/src/components/dwe/recordings/components/recording-table.tsx +++ b/frontend/src/components/dwe/recordings/components/recording-table.tsx @@ -10,7 +10,6 @@ import { } from "@/components/dwe/recordings/utils/recording-utils"; import { TOUR_STEP_IDS } from "@/components/tour/tour-constants"; import { Button } from "@/components/ui/button"; -import { Spinner } from "@/components/ui/spinner"; import { Table, TableBody, @@ -32,7 +31,6 @@ import { useSnapshot } from "valtio"; interface TableProps { recordings: readonly RecordingInfo[]; - loading: boolean; sortColumn: keyof RecordingInfo | null; sortDirection: "asc" | "desc" | null; onSort: (column: keyof RecordingInfo) => void; @@ -40,7 +38,6 @@ interface TableProps { export const RecordingTable = ({ recordings, - loading, sortColumn, sortDirection, onSort, @@ -200,13 +197,6 @@ export const RecordingTable = ({ }; }, [selectionBox]); - if (loading) - return ( -
- -
- ); - return (
{
- + {snap.loading && !snap.hasFetched ? ( +
+ +

+ Fetching recordings... +

+
+ ) : snap.recordings.length === 0 ? ( +
+
+ +
+
+

+ No Recordings Found +

+

+ There are currently no recordings saved on the system. +

+ Scanning... +
+
+ ) : ( + + )}
diff --git a/frontend/src/components/dwe/recordings/store/recording-store.tsx b/frontend/src/components/dwe/recordings/store/recording-store.tsx index 2aa6205d..8f3cdad8 100644 --- a/frontend/src/components/dwe/recordings/store/recording-store.tsx +++ b/frontend/src/components/dwe/recordings/store/recording-store.tsx @@ -24,6 +24,7 @@ interface RecordingsState { diskStats: DiskStats | null; selectedNames: string[]; loading: boolean; + hasFetched: boolean; zipJobs: ZipJob[]; isZipDrawerMinimized: boolean; isCancelAllZipModalOpen: boolean; @@ -53,6 +54,7 @@ export const recordingsState = proxy({ diskStats: null, selectedNames: [], loading: true, + hasFetched: false, zipJobs: [], isZipDrawerMinimized: false, isCancelAllZipModalOpen: false, @@ -87,6 +89,7 @@ export const recordingsActions = { console.error("Error fetching recordings:", error); } finally { recordingsState.loading = false; + recordingsState.hasFetched = true; } }, diff --git a/frontend/src/components/tour/tour-constants.ts b/frontend/src/components/tour/tour-constants.ts index 09b8beb6..d8d72de0 100644 --- a/frontend/src/components/tour/tour-constants.ts +++ b/frontend/src/components/tour/tour-constants.ts @@ -1,9 +1,11 @@ export const TOUR_STEP_IDS = { + // / POWER_SWITCH: "power-switch", HELP_SWITCH: "help-switch", TOUR_PAGE_BTN: "tour-page-btn", MODE_TOGGLE: "mode-toggle", + // /cameras CAMERAS_PAGE: "cameras-page", CAMERA_DEVICE: "camera-device", DROPPED_FRAMES: "dropped-frames", @@ -21,24 +23,35 @@ export const TOUR_STEP_IDS = { DEVICE_RESET: "device-reset", DEVICE_STREAM: "device-stream", + // /recordings RECORDING_PAGE: "recording-page", RECORDING_ITEM: "recording-item", RECORDING_FOOTER: "recording-footer", RECORDINGS_FUNCTIONS: "recordings-functions", STORAGE_BAR: "storage-bar", + // /network NETWORKING_PAGE: "networking-page", WIRED_CONFIG: "wired-config", NETWORK_OPTION: "network-option", NETWORK_OPTION_SETTINGS: "network-option-settings", WIRELESS_NETWORK: "wireless-network", + // /preferences PREFS_PAGE: "prefs-page", DEFAULT_STREAM_PREFS: "default-stream-prefs", RESET_TOUR: "reset-tour", + // /log-viewer LOGS_PAGE: "logs-page", DEBUG_LOG: "debug-log", + // /terminal TERMINAL: "terminal", + + // MISC + EMPTY_DEVICE_STATE: "empty-device-state", + EMPTY_RECORDING_STATE: "empty-recording-state", + EMPTY_NETWORK_STATE: "empty-network-state", + EMPTY_LOGS_STATE: "empty-logs-state", }; diff --git a/frontend/src/components/tour/tour-context.ts b/frontend/src/components/tour/tour-context.ts index fa7749d9..c85d96c9 100644 --- a/frontend/src/components/tour/tour-context.ts +++ b/frontend/src/components/tour/tour-context.ts @@ -10,10 +10,6 @@ export interface TourStep { position?: "top" | "bottom" | "left" | "right"; highlightPadding?: number; popoverWidth?: number; - disableNext?: boolean; - disablePrev?: boolean; - advanceOnClick?: boolean | string[] | string; - retreatOnClick?: boolean | string[] | string; disableScroll?: boolean; disableInteraction?: boolean; } @@ -21,6 +17,7 @@ export interface TourStep { export interface TourSegment { startStepId: string; steps: Record; + waitForSelector?: string | string[]; } export interface TourContextType { diff --git a/frontend/src/components/tour/tour-overlay.tsx b/frontend/src/components/tour/tour-overlay.tsx index 2a066cc2..e0397155 100644 --- a/frontend/src/components/tour/tour-overlay.tsx +++ b/frontend/src/components/tour/tour-overlay.tsx @@ -98,24 +98,32 @@ export default function TourOverlay() { // segment loaded? useEffect(() => { - if (!activeSegment || !activeSegment.waitForSelector) { - setIsSegmentLoading(false); - return; - } + const targetSelectors = activeSegment?.waitForSelector; - if (document.querySelector(activeSegment.waitForSelector)) { + // no element to search for, exit + if (!targetSelectors) { setIsSegmentLoading(false); return; } - setIsSegmentLoading(true); + const selectors = Array.isArray(targetSelectors) + ? targetSelectors + : [targetSelectors]; + + const checkIsLoading = () => + !selectors.some((selector) => { + try { + return !!document.querySelector(selector); + } catch { + console.warn("Invalid Tour Selector:", selector); + return false; + } + }); + + setIsSegmentLoading(checkIsLoading()); - // mutation observer watches for loaded const observer = new MutationObserver(() => { - if (document.querySelector(activeSegment.waitForSelector!)) { - setIsSegmentLoading(false); - observer.disconnect(); - } + setIsSegmentLoading(checkIsLoading()); }); observer.observe(document.body, { childList: true, subtree: true }); @@ -144,16 +152,8 @@ export default function TourOverlay() { }); } else { setIsLocating(true); - console.warn( - `Tour element [data-tour-id="${targetId}"] removed from DOM. Auto-skipping backward.`, - ); - if (step.prevStepId) { - prevStep(); - } else { - cancelTour(); - } } - }, [currentStepId, steps, cancelTour, prevStep]); + }, [currentStepId, steps]); // Sync MotionValues useEffect(() => { @@ -258,7 +258,18 @@ export default function TourOverlay() { const handleShift = () => window.requestAnimationFrame(updatePosition); const resizeObs = new ResizeObserver(handleShift); - const mutationObs = new MutationObserver(handleShift); + // element disappeared, prev until safe step + const mutationObs = new MutationObserver(() => { + if (!document.body.contains(element)) { + console.warn( + `Element #${targetId} destroyed. Auto-skipping backward.`, + ); + if (observerRef.current) observerRef.current.disconnect(); + handlePrev(); + } else { + handleShift(); + } + }); resizeObs.observe(element); resizeObs.observe(document.body); @@ -336,55 +347,6 @@ export default function TourOverlay() { }; }, [updatePosition]); - // observe for specific clicks to advance/retreat the tour - useEffect(() => { - if (!currentStepId || !steps[currentStepId]) return; - const step = steps[currentStepId]; - if (!step.advanceOnClick && !step.retreatOnClick) return; - const targetId = step.selectorId || currentStepId; - - const handleTargetClick = (e: MouseEvent) => { - if (!e.isTrusted) return; - const target = e.target as HTMLElement; - - // advance - if (step.advanceOnClick) { - const ids = Array.isArray(step.advanceOnClick) - ? step.advanceOnClick - : // if string, target is that id, otherwise it's the steps id - typeof step.advanceOnClick === "string" - ? [step.advanceOnClick] - : [targetId]; - - // advance if any of these ids are clicked - if (ids.some((id) => target.closest(`[data-tour-id="${id}"]`))) { - nextStep(); - return; - } - } - - // retreat - if (step.retreatOnClick) { - const ids = Array.isArray(step.retreatOnClick) - ? step.retreatOnClick - : // if string, target is that id, otherwise it's the steps id - typeof step.retreatOnClick === "string" - ? [step.retreatOnClick] - : [targetId]; - - // retreat if any of these ids are clicked - if (ids.some((id) => target.closest(`[data-tour-id="${id}"]`))) { - prevStep(); - } - } - }; - - window.addEventListener("click", handleTargetClick, true); - return () => { - window.removeEventListener("click", handleTargetClick, true); - }; - }, [currentStepId, steps, nextStep, prevStep]); - // for blocking scrolls if needed within border box useEffect(() => { if (!currentStepId || !steps[currentStepId]) return; @@ -412,6 +374,7 @@ export default function TourOverlay() { {currentStepId && currentStepData && (elementPosition || isLocating) && ( <> + {/* Overlay to block mouse events */} - {/* Border Box */} + {/* Highlight Box */} @@ -537,30 +500,7 @@ export default function TourOverlay() {
{currentStepData.prevStepId && !isFirstStep && (