From 2d68387ed13d4d98f49d86d9c457ab6c96d21098 Mon Sep 17 00:00:00 2001 From: jim800121chen Date: Tue, 10 Mar 2026 11:29:05 +0800 Subject: [PATCH] Add interactive guided tour with driver.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 6-step spotlight tour (Scan → Connect → Manage → Flash → Workspace → Inference) with step completion validation, polling auto-detection, and i18n support (en/zh-TW). Triggered from onboarding dialog or header help button. Co-Authored-By: Claude Opus 4.6 --- edge-ai-platform/frontend/package.json | 1 + edge-ai-platform/frontend/pnpm-lock.yaml | 14 +- .../app/devices/[id]/device-detail-client.tsx | 2 +- .../frontend/src/app/devices/page.tsx | 2 +- edge-ai-platform/frontend/src/app/globals.css | 98 +++++++ edge-ai-platform/frontend/src/app/layout.tsx | 2 + .../workspace/[deviceId]/workspace-client.tsx | 2 +- .../src/components/devices/device-card.tsx | 6 +- .../src/components/devices/device-list.tsx | 4 +- .../src/components/devices/flash-dialog.tsx | 2 +- .../frontend/src/components/guided-tour.tsx | 249 ++++++++++++++++++ .../frontend/src/components/layout/header.tsx | 2 + .../src/components/layout/help-button.tsx | 30 +++ .../src/components/onboarding-dialog.tsx | 11 +- edge-ai-platform/frontend/src/lib/i18n/en.ts | 26 ++ .../frontend/src/lib/i18n/types.ts | 26 ++ .../frontend/src/lib/i18n/zh-TW.ts | 26 ++ .../frontend/src/lib/tour-steps.ts | 92 +++++++ .../frontend/src/stores/tour-store.ts | 34 +++ 19 files changed, 617 insertions(+), 12 deletions(-) create mode 100644 edge-ai-platform/frontend/src/components/guided-tour.tsx create mode 100644 edge-ai-platform/frontend/src/components/layout/help-button.tsx create mode 100644 edge-ai-platform/frontend/src/lib/tour-steps.ts create mode 100644 edge-ai-platform/frontend/src/stores/tour-store.ts diff --git a/edge-ai-platform/frontend/package.json b/edge-ai-platform/frontend/package.json index 695bb18..eec0857 100644 --- a/edge-ai-platform/frontend/package.json +++ b/edge-ai-platform/frontend/package.json @@ -13,6 +13,7 @@ "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "driver.js": "^1.4.0", "lucide-react": "^0.575.0", "next": "16.1.6", "next-themes": "^0.4.6", diff --git a/edge-ai-platform/frontend/pnpm-lock.yaml b/edge-ai-platform/frontend/pnpm-lock.yaml index 91559e7..2a04adc 100644 --- a/edge-ai-platform/frontend/pnpm-lock.yaml +++ b/edge-ai-platform/frontend/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + driver.js: + specifier: ^1.4.0 + version: 1.4.0 lucide-react: specifier: ^0.575.0 version: 0.575.0(react@19.2.3) @@ -34,7 +37,7 @@ importers: version: 19.2.3(react@19.2.3) recharts: specifier: ^3.7.0 - version: 3.7.0(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react-is@16.13.1)(react@19.2.3)(redux@5.0.1) + version: 3.7.0(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react-is@17.0.2)(react@19.2.3)(redux@5.0.1) sonner: specifier: ^2.0.7 version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -2593,6 +2596,9 @@ packages: resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==} engines: {node: '>=12'} + driver.js@1.4.0: + resolution: {integrity: sha512-Gm64jm6PmcU+si21sQhBrTAM1JvUrR0QhNmjkprNLxohOBzul9+pNHXgQaT9lW84gwg9GMLB3NZGuGolsz5uew==} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -7040,6 +7046,8 @@ snapshots: dotenv@17.3.1: {} + driver.js@1.4.0: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -8595,7 +8603,7 @@ snapshots: tiny-invariant: 1.3.3 tslib: 2.8.1 - recharts@3.7.0(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react-is@16.13.1)(react@19.2.3)(redux@5.0.1): + recharts@3.7.0(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react-is@17.0.2)(react@19.2.3)(redux@5.0.1): dependencies: '@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.3)(redux@5.0.1))(react@19.2.3) clsx: 2.1.1 @@ -8605,7 +8613,7 @@ snapshots: immer: 10.2.0 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - react-is: 16.13.1 + react-is: 17.0.2 react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.3)(redux@5.0.1) reselect: 5.1.1 tiny-invariant: 1.3.3 diff --git a/edge-ai-platform/frontend/src/app/devices/[id]/device-detail-client.tsx b/edge-ai-platform/frontend/src/app/devices/[id]/device-detail-client.tsx index f83c6c6..8319aeb 100644 --- a/edge-ai-platform/frontend/src/app/devices/[id]/device-detail-client.tsx +++ b/edge-ai-platform/frontend/src/app/devices/[id]/device-detail-client.tsx @@ -61,7 +61,7 @@ export default function DeviceDetailClient() { {selectedDevice.flashedModel && ( - + )} diff --git a/edge-ai-platform/frontend/src/app/globals.css b/edge-ai-platform/frontend/src/app/globals.css index 382ca14..768470f 100644 --- a/edge-ai-platform/frontend/src/app/globals.css +++ b/edge-ai-platform/frontend/src/app/globals.css @@ -123,4 +123,102 @@ body { @apply bg-background text-foreground; } +} + +/* driver.js theme overrides — integrates with shadcn/ui CSS variables */ +.driver-popover { + background-color: var(--popover) !important; + color: var(--popover-foreground) !important; + border: 1px solid var(--border) !important; + border-radius: var(--radius) !important; + box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1) !important; +} + +.driver-popover .driver-popover-title { + font-size: 0.95rem !important; + font-weight: 600 !important; + color: var(--popover-foreground) !important; +} + +.driver-popover .driver-popover-title .driver-step-counter { + font-weight: 400 !important; + font-size: 0.8rem !important; + color: var(--muted-foreground) !important; + margin-left: 4px; +} + +.driver-popover .driver-popover-description { + font-size: 0.85rem !important; + color: var(--muted-foreground) !important; +} + +.driver-popover .driver-popover-close-btn { + color: var(--muted-foreground) !important; +} + +.driver-popover .driver-popover-close-btn:hover { + color: var(--popover-foreground) !important; +} + +.driver-popover-navigation-btns { + gap: 6px; +} + +.driver-popover .driver-popover-prev-btn { + background-color: var(--secondary) !important; + color: var(--secondary-foreground) !important; + border: 1px solid var(--border) !important; + border-radius: calc(var(--radius) - 2px) !important; + font-size: 0.8rem !important; + padding: 4px 12px !important; + text-shadow: none !important; +} + +.driver-popover .driver-popover-next-btn { + background-color: var(--primary) !important; + color: var(--primary-foreground) !important; + border: none !important; + border-radius: calc(var(--radius) - 2px) !important; + font-size: 0.8rem !important; + padding: 4px 12px !important; + text-shadow: none !important; +} + +.driver-popover .driver-popover-prev-btn:hover { + opacity: 0.9; +} + +.driver-popover .driver-popover-next-btn:hover { + opacity: 0.9; +} + +.driver-overlay { + background-color: rgb(0 0 0 / 0.5) !important; +} + +.driver-popover .driver-btn-disabled { + opacity: 0.4 !important; + cursor: not-allowed !important; + pointer-events: none !important; +} + +.driver-popover .driver-pending-hint { + color: var(--chart-1) !important; + font-size: 0.8rem !important; + font-weight: 500; +} + +.driver-popover-navigation-btns .driver-exit-tour { + font-size: 0.75rem; + color: var(--muted-foreground); + background: none; + border: none; + cursor: pointer; + padding: 4px 8px; + margin-right: auto; + transition: color 0.15s; +} + +.driver-popover-navigation-btns .driver-exit-tour:hover { + color: var(--destructive); } \ No newline at end of file diff --git a/edge-ai-platform/frontend/src/app/layout.tsx b/edge-ai-platform/frontend/src/app/layout.tsx index c8118cc..38d8e5c 100644 --- a/edge-ai-platform/frontend/src/app/layout.tsx +++ b/edge-ai-platform/frontend/src/app/layout.tsx @@ -8,6 +8,7 @@ import { ThemeSync } from "@/components/theme-sync"; import { LangSync } from "@/components/lang-sync"; import { StoreHydration } from "@/components/store-hydration"; import { RelayTokenSync } from "@/components/relay-token-sync"; +import { GuidedTour } from "@/components/guided-tour"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -47,6 +48,7 @@ export default function RootLayout({ + diff --git a/edge-ai-platform/frontend/src/app/workspace/[deviceId]/workspace-client.tsx b/edge-ai-platform/frontend/src/app/workspace/[deviceId]/workspace-client.tsx index 2d6238b..0d6e662 100644 --- a/edge-ai-platform/frontend/src/app/workspace/[deviceId]/workspace-client.tsx +++ b/edge-ai-platform/frontend/src/app/workspace/[deviceId]/workspace-client.tsx @@ -74,7 +74,7 @@ export default function WorkspaceClient() { {t('inference.stopInference')} ) : ( - )} diff --git a/edge-ai-platform/frontend/src/components/devices/device-card.tsx b/edge-ai-platform/frontend/src/components/devices/device-card.tsx index 49a2243..f423a40 100644 --- a/edge-ai-platform/frontend/src/components/devices/device-card.tsx +++ b/edge-ai-platform/frontend/src/components/devices/device-card.tsx @@ -11,9 +11,10 @@ import type { Device } from '@/types/device'; interface DeviceCardProps { device: Device; + isFirstCard?: boolean; } -export function DeviceCard({ device }: DeviceCardProps) { +export function DeviceCard({ device, isFirstCard }: DeviceCardProps) { const { t } = useTranslation(); const { connectDevice, disconnectDevice } = useDeviceStore(); const prefs = useDevicePreferencesStore((s) => s.getPreferences(device.id)); @@ -54,7 +55,7 @@ export function DeviceCard({ device }: DeviceCardProps) { {isConnected ? ( <> - @@ -70,6 +71,7 @@ export function DeviceCard({ device }: DeviceCardProps) { diff --git a/edge-ai-platform/frontend/src/components/devices/device-list.tsx b/edge-ai-platform/frontend/src/components/devices/device-list.tsx index 4b64b10..c1d7be7 100644 --- a/edge-ai-platform/frontend/src/components/devices/device-list.tsx +++ b/edge-ai-platform/frontend/src/components/devices/device-list.tsx @@ -39,8 +39,8 @@ export function DeviceList({ devices, loading }: DeviceListProps) { return (
- {devices.map((device) => ( - + {devices.map((device, index) => ( + ))}
); diff --git a/edge-ai-platform/frontend/src/components/devices/flash-dialog.tsx b/edge-ai-platform/frontend/src/components/devices/flash-dialog.tsx index 2f0ab5a..0c0f98f 100644 --- a/edge-ai-platform/frontend/src/components/devices/flash-dialog.tsx +++ b/edge-ai-platform/frontend/src/components/devices/flash-dialog.tsx @@ -70,7 +70,7 @@ export function FlashDialog({ deviceId }: FlashDialogProps) { setOpen(v); }}> - + diff --git a/edge-ai-platform/frontend/src/components/guided-tour.tsx b/edge-ai-platform/frontend/src/components/guided-tour.tsx new file mode 100644 index 0000000..bca6aad --- /dev/null +++ b/edge-ai-platform/frontend/src/components/guided-tour.tsx @@ -0,0 +1,249 @@ +'use client'; + +import { useEffect, useRef, useCallback } from 'react'; +import { usePathname, useRouter } from 'next/navigation'; +import { driver, type DriveStep, type Config } from 'driver.js'; +import 'driver.js/dist/driver.css'; +import { tourSteps } from '@/lib/tour-steps'; +import { useTourStore } from '@/stores/tour-store'; +import { useDeviceStore } from '@/stores/device-store'; +import { useTranslation } from '@/lib/i18n'; + +function waitForElement(selector: string, timeout = 3000): Promise { + return new Promise((resolve) => { + const el = document.querySelector(selector); + if (el) return resolve(el); + + const observer = new MutationObserver(() => { + const found = document.querySelector(selector); + if (found) { + observer.disconnect(); + resolve(found); + } + }); + observer.observe(document.body, { childList: true, subtree: true }); + + setTimeout(() => { + observer.disconnect(); + resolve(null); + }, timeout); + }); +} + +export function GuidedTour() { + const { t } = useTranslation(); + const pathname = usePathname(); + const router = useRouter(); + const { isActive, currentStepIndex, endTour, markTourCompleted, goToStep } = useTourStore(); + const devices = useDeviceStore((s) => s.devices); + const driverRef = useRef | null>(null); + const isNavigatingRef = useRef(false); + const pollingRef = useRef | null>(null); + + const getFirstConnectedDeviceId = useCallback(() => { + const connected = devices.find((d) => d.status === 'connected' || d.status === 'flashing' || d.status === 'inferencing'); + if (connected) return connected.id; + return devices.length > 0 ? devices[0].id : null; + }, [devices]); + + const resolvePagePath = useCallback((pageTpl: string) => { + const deviceId = getFirstConnectedDeviceId(); + if (!deviceId) return pageTpl; + return pageTpl.replace('__DEVICE_ID__', deviceId); + }, [getFirstConnectedDeviceId]); + + const cleanup = useCallback(() => { + if (driverRef.current) { + driverRef.current.destroy(); + driverRef.current = null; + } + if (pollingRef.current) { + clearInterval(pollingRef.current); + pollingRef.current = null; + } + }, []); + + // Clean up on unmount + useEffect(() => cleanup, [cleanup]); + + // Main tour effect — runs whenever step changes or becomes active + useEffect(() => { + if (!isActive) { + cleanup(); + return; + } + + const step = tourSteps[currentStepIndex]; + if (!step) { + markTourCompleted(); + endTour(); + cleanup(); + return; + } + + const targetPage = resolvePagePath(step.page); + + // If we need to navigate to a different page + if (!pathname.startsWith(targetPage)) { + isNavigatingRef.current = true; + cleanup(); + router.push(targetPage); + return; + } + + // We're on the right page — wait for the element and highlight it + const highlight = async () => { + if (isNavigatingRef.current) { + await new Promise((r) => setTimeout(r, 500)); + isNavigatingRef.current = false; + } + + const el = await waitForElement(step.elementSelector); + cleanup(); + + const totalSteps = tourSteps.length; + const isLast = currentStepIndex === totalSteps - 1; + const isFirst = currentStepIndex === 0; + const stepComplete = step.isComplete(); + + const description = stepComplete + ? t(step.descriptionKey) + : `${t(step.descriptionKey)}

⏳ ${t(step.pendingDescKey)}`; + + const driverSteps: DriveStep[] = [{ + element: el ? step.elementSelector : undefined, + popover: { + title: `${t(step.titleKey)} (${currentStepIndex + 1} ${t('tour.of')} ${totalSteps})`, + description, + side: step.side, + align: 'center', + }, + }]; + + const config: Config = { + showProgress: false, + showButtons: ['next', 'previous', 'close'], + nextBtnText: isLast ? t('tour.done') : t('tour.next'), + prevBtnText: t('tour.prev'), + doneBtnText: t('tour.done'), + steps: driverSteps, + allowClose: true, + overlayClickBehavior: 'nextStep', + stagePadding: 8, + stageRadius: 8, + onCloseClick: () => { + endTour(); + cleanup(); + }, + onNextClick: () => { + // Block advancing if step not complete + if (!step.isComplete()) return; + + if (isLast) { + markTourCompleted(); + endTour(); + cleanup(); + } else { + cleanup(); + goToStep(currentStepIndex + 1); + } + }, + onPrevClick: () => { + if (!isFirst) { + cleanup(); + goToStep(currentStepIndex - 1); + } + }, + onDestroyStarted: () => { + if (driverRef.current) { + driverRef.current.destroy(); + driverRef.current = null; + } + }, + }; + + if (isFirst) { + config.showButtons = ['next', 'close']; + } + + const d = driver(config); + driverRef.current = d; + d.drive(); + + // Style the next button as disabled if step not complete + updateNextButtonState(stepComplete); + + // Add the Exit Tour button in nav row + addExitButton(t('tour.exit')); + + // Poll for step completion — refresh the popover when condition changes + if (!stepComplete) { + pollingRef.current = setInterval(() => { + if (step.isComplete()) { + // Step completed! Refresh the highlight to update description & enable Next + if (pollingRef.current) { + clearInterval(pollingRef.current); + pollingRef.current = null; + } + // Re-trigger highlight by re-setting the same step + cleanup(); + goToStep(currentStepIndex); + } + }, 800); + } + }; + + highlight(); + }, [isActive, currentStepIndex, pathname, t, router, resolvePagePath, endTour, markTourCompleted, goToStep, cleanup]); + + // If user navigates away via sidebar during tour, end the tour + useEffect(() => { + if (!isActive) return; + const step = tourSteps[currentStepIndex]; + if (!step) return; + const targetPage = resolvePagePath(step.page); + if (!pathname.startsWith(targetPage) && !isNavigatingRef.current) { + endTour(); + cleanup(); + } + }, [pathname, isActive, currentStepIndex, resolvePagePath, endTour, cleanup]); + + return null; +} + +/** Visually disable/enable the Next button */ +function updateNextButtonState(enabled: boolean) { + requestAnimationFrame(() => { + const nextBtn = document.querySelector('.driver-popover-next-btn') as HTMLButtonElement | null; + if (!nextBtn) return; + if (enabled) { + nextBtn.classList.remove('driver-btn-disabled'); + } else { + nextBtn.classList.add('driver-btn-disabled'); + } + }); +} + +/** Inject an "Exit Tour" button into the navigation button row */ +function addExitButton(exitText: string) { + requestAnimationFrame(() => { + const nav = document.querySelector('.driver-popover-navigation-btns'); + if (!nav || nav.querySelector('.driver-exit-tour')) return; + + const exitBtn = document.createElement('button'); + exitBtn.className = 'driver-exit-tour'; + exitBtn.textContent = exitText; + + exitBtn.addEventListener('click', (e) => { + e.stopPropagation(); + useTourStore.getState().endTour(); + document.querySelectorAll('.driver-overlay, .driver-popover').forEach((el) => el.remove()); + document.querySelectorAll('[class*="driver-active"]').forEach((el) => { + el.classList.remove('driver-active-element'); + }); + }); + + // Insert at the beginning of the nav row (left side) + nav.insertBefore(exitBtn, nav.firstChild); + }); +} diff --git a/edge-ai-platform/frontend/src/components/layout/header.tsx b/edge-ai-platform/frontend/src/components/layout/header.tsx index 5f34510..20997b1 100644 --- a/edge-ai-platform/frontend/src/components/layout/header.tsx +++ b/edge-ai-platform/frontend/src/components/layout/header.tsx @@ -1,6 +1,7 @@ 'use client'; import { ConnectionStatus } from './connection-status'; +import { HelpButton } from './help-button'; import { useTranslation } from '@/lib/i18n'; export function Header() { @@ -10,6 +11,7 @@ export function Header() {

{t('nav.platformTitle')}

+
diff --git a/edge-ai-platform/frontend/src/components/layout/help-button.tsx b/edge-ai-platform/frontend/src/components/layout/help-button.tsx new file mode 100644 index 0000000..c508159 --- /dev/null +++ b/edge-ai-platform/frontend/src/components/layout/help-button.tsx @@ -0,0 +1,30 @@ +'use client'; + +import { CircleHelp } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { useTourStore } from '@/stores/tour-store'; +import { useTranslation } from '@/lib/i18n'; +import { useRouter } from 'next/navigation'; + +export function HelpButton() { + const { t } = useTranslation(); + const { startTour } = useTourStore(); + const router = useRouter(); + + const handleClick = () => { + router.push('/devices'); + setTimeout(() => startTour(), 300); + }; + + return ( + + ); +} diff --git a/edge-ai-platform/frontend/src/components/onboarding-dialog.tsx b/edge-ai-platform/frontend/src/components/onboarding-dialog.tsx index 430555b..08fc9a1 100644 --- a/edge-ai-platform/frontend/src/components/onboarding-dialog.tsx +++ b/edge-ai-platform/frontend/src/components/onboarding-dialog.tsx @@ -13,6 +13,7 @@ import { import { Button } from '@/components/ui/button'; import { useTranslation } from '@/lib/i18n'; import { useFirstVisit } from '@/hooks/use-first-visit'; +import { useTourStore } from '@/stores/tour-store'; import type { LucideIcon } from 'lucide-react'; interface Step { @@ -31,6 +32,7 @@ export function OnboardingDialog() { const { t } = useTranslation(); const [isFirstVisit, markComplete] = useFirstVisit(); const [step, setStep] = useState(0); + const { startTour } = useTourStore(); if (!isFirstVisit) return null; @@ -83,7 +85,14 @@ export function OnboardingDialog() { )} - diff --git a/edge-ai-platform/frontend/src/lib/i18n/en.ts b/edge-ai-platform/frontend/src/lib/i18n/en.ts index dadfb70..23610bc 100644 --- a/edge-ai-platform/frontend/src/lib/i18n/en.ts +++ b/edge-ai-platform/frontend/src/lib/i18n/en.ts @@ -377,6 +377,32 @@ export const en: TranslationDict = { openWorkspace: 'Open Workspace', workspace: 'Cluster Workspace', }, + tour: { + step1Title: 'Scan for Devices', + step1Desc: 'Click this button to scan for connected Kneron USB devices.', + step1Pending: 'Please click "Scan Devices" first. No devices detected yet.', + step2Title: 'Connect a Device', + step2Desc: 'Click Connect to establish a connection with the detected device.', + step2Pending: 'Please click "Connect" on a device first.', + step3Title: 'Manage Device', + step3Desc: 'Click Manage to view device details, flash models, and open the workspace.', + step3Pending: 'Please connect a device first to see the Manage button.', + step4Title: 'Flash a Model', + step4Desc: 'Select an AI model from the library and flash it to your device.', + step4Pending: 'Please flash a model to the device first.', + step5Title: 'Open Workspace', + step5Desc: 'Open the workspace to configure input sources and run inference.', + step5Pending: 'Please flash a model first — the workspace button will appear after.', + step6Title: 'Start Inference', + step6Desc: 'Select a camera or upload an image/video, then start real-time AI inference!', + step6Pending: 'Please start inference to complete the tour.', + next: 'Next', + prev: 'Previous', + done: 'Done', + exit: 'Exit Tour', + of: 'of', + helpTooltip: 'Start guided tour', + }, errors: { serverDisconnected: 'Server connection lost', serverReconnected: 'Server reconnected', diff --git a/edge-ai-platform/frontend/src/lib/i18n/types.ts b/edge-ai-platform/frontend/src/lib/i18n/types.ts index ee15513..1f3ef63 100644 --- a/edge-ai-platform/frontend/src/lib/i18n/types.ts +++ b/edge-ai-platform/frontend/src/lib/i18n/types.ts @@ -375,6 +375,32 @@ export interface TranslationDict { openWorkspace: string; workspace: string; }; + tour: { + step1Title: string; + step1Desc: string; + step1Pending: string; + step2Title: string; + step2Desc: string; + step2Pending: string; + step3Title: string; + step3Desc: string; + step3Pending: string; + step4Title: string; + step4Desc: string; + step4Pending: string; + step5Title: string; + step5Desc: string; + step5Pending: string; + step6Title: string; + step6Desc: string; + step6Pending: string; + next: string; + prev: string; + done: string; + exit: string; + of: string; + helpTooltip: string; + }; errors: { serverDisconnected: string; serverReconnected: string; diff --git a/edge-ai-platform/frontend/src/lib/i18n/zh-TW.ts b/edge-ai-platform/frontend/src/lib/i18n/zh-TW.ts index 00aefd5..f7ca383 100644 --- a/edge-ai-platform/frontend/src/lib/i18n/zh-TW.ts +++ b/edge-ai-platform/frontend/src/lib/i18n/zh-TW.ts @@ -377,6 +377,32 @@ export const zhTW: TranslationDict = { openWorkspace: '開啟工作區', workspace: '叢集工作區', }, + tour: { + step1Title: '掃描裝置', + step1Desc: '點擊此按鈕掃描已連接的 Kneron USB 裝置。', + step1Pending: '請先點擊「掃描裝置」,目前尚未偵測到任何裝置。', + step2Title: '連線裝置', + step2Desc: '點擊「連線」與偵測到的裝置建立連線。', + step2Pending: '請先點擊裝置上的「連線」按鈕。', + step3Title: '管理裝置', + step3Desc: '點擊「管理」檢視裝置詳情、燒錄模型及開啟工作區。', + step3Pending: '請先連線裝置,才會顯示「管理」按鈕。', + step4Title: '燒錄模型', + step4Desc: '從模型庫選擇 AI 模型並燒錄至你的裝置。', + step4Pending: '請先將模型燒錄至裝置。', + step5Title: '開啟工作區', + step5Desc: '開啟工作區設定輸入來源並執行推論。', + step5Pending: '請先燒錄模型,完成後會出現「開啟工作區」按鈕。', + step6Title: '開始推論', + step6Desc: '選擇攝影機或上傳圖片/影片,開始即時 AI 推論!', + step6Pending: '請開始推論以完成導覽。', + next: '下一步', + prev: '上一步', + done: '完成', + exit: '離開導覽', + of: '/', + helpTooltip: '開始導覽', + }, errors: { serverDisconnected: '伺服器連線中斷', serverReconnected: '伺服器已重新連線', diff --git a/edge-ai-platform/frontend/src/lib/tour-steps.ts b/edge-ai-platform/frontend/src/lib/tour-steps.ts new file mode 100644 index 0000000..1b3502c --- /dev/null +++ b/edge-ai-platform/frontend/src/lib/tour-steps.ts @@ -0,0 +1,92 @@ +import type { TranslationKey } from '@/lib/i18n/types'; +import { useDeviceStore } from '@/stores/device-store'; +import { useInferenceStore } from '@/stores/inference-store'; + +export interface TourStep { + id: string; + page: string; + elementSelector: string; + titleKey: TranslationKey; + descriptionKey: TranslationKey; + pendingDescKey: TranslationKey; + side: 'top' | 'bottom' | 'left' | 'right'; + /** Returns true when the step's goal has been achieved */ + isComplete: () => boolean; +} + +export const tourSteps: TourStep[] = [ + { + id: 'scan-devices', + page: '/devices', + elementSelector: '[data-tour-id="scan-devices-btn"]', + titleKey: 'tour.step1Title', + descriptionKey: 'tour.step1Desc', + pendingDescKey: 'tour.step1Pending', + side: 'bottom', + isComplete: () => useDeviceStore.getState().devices.length > 0, + }, + { + id: 'connect-device', + page: '/devices', + elementSelector: '[data-tour-id="connect-device-btn"]', + titleKey: 'tour.step2Title', + descriptionKey: 'tour.step2Desc', + pendingDescKey: 'tour.step2Pending', + side: 'bottom', + isComplete: () => { + const devices = useDeviceStore.getState().devices; + return devices.some((d) => d.status === 'connected' || d.status === 'flashing' || d.status === 'inferencing'); + }, + }, + { + id: 'manage-device', + page: '/devices', + elementSelector: '[data-tour-id="manage-device-btn"]', + titleKey: 'tour.step3Title', + descriptionKey: 'tour.step3Desc', + pendingDescKey: 'tour.step3Pending', + side: 'bottom', + // "Manage" is always available once a device is connected (previous step guarantees this) + isComplete: () => { + const devices = useDeviceStore.getState().devices; + return devices.some((d) => d.status === 'connected' || d.status === 'flashing' || d.status === 'inferencing'); + }, + }, + { + id: 'flash-model', + page: '/devices/__DEVICE_ID__', + elementSelector: '[data-tour-id="flash-model-btn"]', + titleKey: 'tour.step4Title', + descriptionKey: 'tour.step4Desc', + pendingDescKey: 'tour.step4Pending', + side: 'bottom', + isComplete: () => { + const device = useDeviceStore.getState().selectedDevice; + return !!device?.flashedModel; + }, + }, + { + id: 'open-workspace', + page: '/devices/__DEVICE_ID__', + elementSelector: '[data-tour-id="open-workspace-btn"]', + titleKey: 'tour.step5Title', + descriptionKey: 'tour.step5Desc', + pendingDescKey: 'tour.step5Pending', + side: 'bottom', + // Workspace button only shows when flashedModel exists, so same condition + isComplete: () => { + const device = useDeviceStore.getState().selectedDevice; + return !!device?.flashedModel; + }, + }, + { + id: 'start-inference', + page: '/workspace/__DEVICE_ID__', + elementSelector: '[data-tour-id="start-inference-btn"]', + titleKey: 'tour.step6Title', + descriptionKey: 'tour.step6Desc', + pendingDescKey: 'tour.step6Pending', + side: 'bottom', + isComplete: () => useInferenceStore.getState().isRunning, + }, +]; diff --git a/edge-ai-platform/frontend/src/stores/tour-store.ts b/edge-ai-platform/frontend/src/stores/tour-store.ts new file mode 100644 index 0000000..0b7dd96 --- /dev/null +++ b/edge-ai-platform/frontend/src/stores/tour-store.ts @@ -0,0 +1,34 @@ +import { create } from 'zustand'; + +interface TourState { + isActive: boolean; + currentStepIndex: number; + startTour: () => void; + endTour: () => void; + nextStep: () => void; + prevStep: () => void; + goToStep: (index: number) => void; + markTourCompleted: () => void; + hasTourCompleted: () => boolean; +} + +const TOUR_COMPLETED_KEY = 'tour-completed'; + +export const useTourStore = create()((set) => ({ + isActive: false, + currentStepIndex: 0, + + startTour: () => set({ isActive: true, currentStepIndex: 0 }), + endTour: () => set({ isActive: false, currentStepIndex: 0 }), + nextStep: () => set((s) => ({ currentStepIndex: s.currentStepIndex + 1 })), + prevStep: () => set((s) => ({ currentStepIndex: Math.max(0, s.currentStepIndex - 1) })), + goToStep: (index: number) => set({ currentStepIndex: index }), + + markTourCompleted: () => { + try { localStorage.setItem(TOUR_COMPLETED_KEY, 'true'); } catch {} + }, + + hasTourCompleted: () => { + try { return localStorage.getItem(TOUR_COMPLETED_KEY) === 'true'; } catch { return false; } + }, +}));