Add interactive guided tour with driver.js
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 <noreply@anthropic.com>
This commit is contained in:
parent
7b4492c41e
commit
2d68387ed1
@ -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",
|
||||
|
||||
14
edge-ai-platform/frontend/pnpm-lock.yaml
generated
14
edge-ai-platform/frontend/pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
@ -61,7 +61,7 @@ export default function DeviceDetailClient() {
|
||||
<FlashDialog deviceId={id} />
|
||||
{selectedDevice.flashedModel && (
|
||||
<Link href={`/workspace/${id}`}>
|
||||
<Button variant="outline">{t('devices.detail.openWorkspace')}</Button>
|
||||
<Button variant="outline" data-tour-id="open-workspace-btn">{t('devices.detail.openWorkspace')}</Button>
|
||||
</Link>
|
||||
)}
|
||||
<Button variant="ghost" onClick={() => disconnectDevice(id)}>
|
||||
|
||||
@ -25,7 +25,7 @@ export default function DevicesPage() {
|
||||
<h1 className="text-2xl font-bold">{t('devices.title')}</h1>
|
||||
<p className="text-muted-foreground">{t('devices.subtitle')}</p>
|
||||
</div>
|
||||
<Button onClick={scanDevices} disabled={scanning} variant="outline">
|
||||
<Button onClick={scanDevices} disabled={scanning} variant="outline" data-tour-id="scan-devices-btn">
|
||||
<RefreshCw className={`mr-2 h-4 w-4 ${scanning ? 'animate-spin' : ''}`} />
|
||||
{scanning ? t('devices.scanning') : t('devices.scan')}
|
||||
</Button>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
@ -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({
|
||||
<RelayTokenSync />
|
||||
<ThemeSync />
|
||||
<LangSync />
|
||||
<GuidedTour />
|
||||
<Toaster richColors position="bottom-right" />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -74,7 +74,7 @@ export default function WorkspaceClient() {
|
||||
{t('inference.stopInference')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={handleStartInference} disabled={!isStreaming}>
|
||||
<Button onClick={handleStartInference} disabled={!isStreaming} data-tour-id="start-inference-btn">
|
||||
{t('inference.startInference')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@ -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 ? (
|
||||
<>
|
||||
<Link href={`/devices/${device.id}`}>
|
||||
<Button size="sm" variant="outline">
|
||||
<Button size="sm" variant="outline" {...(isFirstCard ? { 'data-tour-id': 'manage-device-btn' } : {})}>
|
||||
{t('common.manage')}
|
||||
</Button>
|
||||
</Link>
|
||||
@ -70,6 +71,7 @@ export function DeviceCard({ device }: DeviceCardProps) {
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => connectDevice(device.id)}
|
||||
{...(isFirstCard ? { 'data-tour-id': 'connect-device-btn' } : {})}
|
||||
>
|
||||
{t('common.connect')}
|
||||
</Button>
|
||||
|
||||
@ -39,8 +39,8 @@ export function DeviceList({ devices, loading }: DeviceListProps) {
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{devices.map((device) => (
|
||||
<DeviceCard key={device.id} device={device} />
|
||||
{devices.map((device, index) => (
|
||||
<DeviceCard key={device.id} device={device} isFirstCard={index === 0} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -70,7 +70,7 @@ export function FlashDialog({ deviceId }: FlashDialogProps) {
|
||||
setOpen(v);
|
||||
}}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>{t('devices.flash.flashModel')}</Button>
|
||||
<Button data-tour-id="flash-model-btn">{t('devices.flash.flashModel')}</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
|
||||
249
edge-ai-platform/frontend/src/components/guided-tour.tsx
Normal file
249
edge-ai-platform/frontend/src/components/guided-tour.tsx
Normal file
@ -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<Element | null> {
|
||||
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<ReturnType<typeof driver> | null>(null);
|
||||
const isNavigatingRef = useRef(false);
|
||||
const pollingRef = useRef<ReturnType<typeof setInterval> | 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)}<br/><br/><span class="driver-pending-hint">⏳ ${t(step.pendingDescKey)}</span>`;
|
||||
|
||||
const driverSteps: DriveStep[] = [{
|
||||
element: el ? step.elementSelector : undefined,
|
||||
popover: {
|
||||
title: `${t(step.titleKey)} <span class="driver-step-counter">(${currentStepIndex + 1} ${t('tour.of')} ${totalSteps})</span>`,
|
||||
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);
|
||||
});
|
||||
}
|
||||
@ -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() {
|
||||
<header className="flex h-14 items-center justify-between border-b bg-card px-6">
|
||||
<h1 className="text-lg font-semibold">{t('nav.platformTitle')}</h1>
|
||||
<div className="flex items-center gap-4">
|
||||
<HelpButton />
|
||||
<ConnectionStatus />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@ -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 (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleClick}
|
||||
title={t('tour.helpTooltip')}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<CircleHelp className="h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@ -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() {
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Button size="sm" onClick={() => isLast ? markComplete() : setStep(step + 1)}>
|
||||
<Button size="sm" onClick={() => {
|
||||
if (isLast) {
|
||||
markComplete();
|
||||
setTimeout(() => startTour(), 300);
|
||||
} else {
|
||||
setStep(step + 1);
|
||||
}
|
||||
}}>
|
||||
{isLast ? t('onboarding.getStarted') : t('onboarding.next')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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: '伺服器已重新連線',
|
||||
|
||||
92
edge-ai-platform/frontend/src/lib/tour-steps.ts
Normal file
92
edge-ai-platform/frontend/src/lib/tour-steps.ts
Normal file
@ -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,
|
||||
},
|
||||
];
|
||||
34
edge-ai-platform/frontend/src/stores/tour-store.ts
Normal file
34
edge-ai-platform/frontend/src/stores/tour-store.ts
Normal file
@ -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<TourState>()((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; }
|
||||
},
|
||||
}));
|
||||
Loading…
x
Reference in New Issue
Block a user