mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-02-04 23:46:32 -05:00
add help mode
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
||||
BreadcrumbDisplay,
|
||||
} from "~/components/ui/breadcrumb-provider";
|
||||
import { StudyProvider } from "~/lib/study-context";
|
||||
import { TourProvider } from "~/components/onboarding/TourProvider";
|
||||
|
||||
interface DashboardLayoutProps {
|
||||
children: React.ReactNode;
|
||||
@@ -41,23 +42,25 @@ export default async function DashboardLayout({
|
||||
|
||||
return (
|
||||
<StudyProvider initialStudyId={selectedStudyCookie}>
|
||||
<BreadcrumbProvider>
|
||||
<SidebarProvider defaultOpen={defaultOpen}>
|
||||
<AppSidebar userRole={userRole} />
|
||||
<SidebarInset>
|
||||
<header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-12">
|
||||
<div className="flex items-center gap-2 px-4">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator orientation="vertical" className="mr-2 h-4" />
|
||||
<BreadcrumbDisplay />
|
||||
<TourProvider>
|
||||
<BreadcrumbProvider>
|
||||
<SidebarProvider defaultOpen={defaultOpen}>
|
||||
<AppSidebar userRole={userRole} />
|
||||
<SidebarInset>
|
||||
<header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-12">
|
||||
<div className="flex items-center gap-2 px-4">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator orientation="vertical" className="mr-2 h-4" />
|
||||
<BreadcrumbDisplay />
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-4 overflow-hidden p-4 pt-0">
|
||||
{children}
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-4 overflow-hidden p-4 pt-0">
|
||||
{children}
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
</BreadcrumbProvider>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
</BreadcrumbProvider>
|
||||
</TourProvider>
|
||||
</StudyProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Calendar,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
HelpCircle,
|
||||
LayoutDashboard,
|
||||
MoreHorizontal,
|
||||
Play,
|
||||
@@ -47,8 +48,10 @@ import {
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
import { api } from "~/trpc/react";
|
||||
import { useTour } from "~/components/onboarding/TourProvider";
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { startTour } = useTour();
|
||||
const [studyFilter, setStudyFilter] = React.useState<string | null>(null);
|
||||
|
||||
// --- Data Fetching ---
|
||||
@@ -81,7 +84,7 @@ export default function DashboardPage() {
|
||||
return (
|
||||
<div className="flex flex-col space-y-8 animate-in fade-in duration-500">
|
||||
{/* Header Section */}
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div id="dashboard-header" className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-foreground">Dashboard</h1>
|
||||
<p className="text-muted-foreground">
|
||||
@@ -90,6 +93,9 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="icon" onClick={() => startTour("dashboard")} title="Start Tour">
|
||||
<HelpCircle className="h-5 w-5" />
|
||||
</Button>
|
||||
<Select
|
||||
value={studyFilter ?? "all"}
|
||||
onValueChange={(value) =>
|
||||
@@ -109,7 +115,7 @@ export default function DashboardPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button asChild>
|
||||
<Button id="tour-new-study" asChild>
|
||||
<Link href="/studies/new">
|
||||
<Plus className="mr-2 h-4 w-4" /> New Study
|
||||
</Link>
|
||||
@@ -118,7 +124,7 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div id="tour-dashboard-stats" className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<StatsCard
|
||||
title="Total Participants"
|
||||
value={stats?.totalParticipants ?? 0}
|
||||
@@ -153,7 +159,7 @@ export default function DashboardPage() {
|
||||
<div className="col-span-4 space-y-4">
|
||||
|
||||
{/* Scheduled Trials */}
|
||||
<Card className="col-span-4 border-muted/40 shadow-sm">
|
||||
<Card id="tour-scheduled-trials" className="col-span-4 border-muted/40 shadow-sm">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -252,7 +258,7 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<Card className="border-muted/40 shadow-sm h-full">
|
||||
<Card id="tour-recent-activity" className="border-muted/40 shadow-sm h-full">
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Activity</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
@@ -276,7 +276,7 @@ export function AppSidebar({
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton className="w-full">
|
||||
<SidebarMenuButton className="w-full" id="tour-sidebar-study-selector">
|
||||
<Building className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="truncate">
|
||||
{selectedStudy?.name ?? "Select Study"}
|
||||
@@ -325,7 +325,7 @@ export function AppSidebar({
|
||||
) : (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton className="w-full">
|
||||
<SidebarMenuButton className="w-full" id="tour-sidebar-study-selector">
|
||||
<Building className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="truncate">
|
||||
{selectedStudy?.name ?? "Select Study"}
|
||||
@@ -383,7 +383,11 @@ export function AppSidebar({
|
||||
(item.url !== "/dashboard" && pathname.startsWith(item.url));
|
||||
|
||||
const menuButton = (
|
||||
<SidebarMenuButton asChild isActive={isActive}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={isActive}
|
||||
id={`tour-sidebar-${item.title.toLowerCase()}`}
|
||||
>
|
||||
<Link href={item.url}>
|
||||
<item.icon className="h-4 w-4" />
|
||||
<span>{item.title}</span>
|
||||
@@ -425,7 +429,11 @@ export function AppSidebar({
|
||||
pathname.startsWith(item.url));
|
||||
|
||||
const menuButton = (
|
||||
<SidebarMenuButton asChild isActive={isActive}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={isActive}
|
||||
id={`tour-sidebar-study-${item.title.toLowerCase()}`}
|
||||
>
|
||||
<Link href={item.url}>
|
||||
<item.icon className="h-4 w-4" />
|
||||
<span>{item.title}</span>
|
||||
|
||||
@@ -8,10 +8,11 @@ import React, {
|
||||
useState,
|
||||
} from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Play, RefreshCw } from "lucide-react";
|
||||
import { Play, RefreshCw, HelpCircle } from "lucide-react";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
import { PageHeader } from "~/components/ui/page-header";
|
||||
import { useTour } from "~/components/onboarding/TourProvider";
|
||||
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { api } from "~/trpc/react";
|
||||
@@ -151,6 +152,8 @@ export function DesignerRoot({
|
||||
autoCompile = true,
|
||||
onPersist,
|
||||
}: DesignerRootProps) {
|
||||
const { startTour } = useTour();
|
||||
|
||||
/* ----------------------------- Remote Experiment ------------------------- */
|
||||
const {
|
||||
data: experiment,
|
||||
@@ -899,18 +902,25 @@ export function DesignerRoot({
|
||||
/* ------------------------------- Panels ---------------------------------- */
|
||||
const leftPanel = useMemo(
|
||||
() => (
|
||||
<div ref={libraryRootRef} data-library-root className="h-full">
|
||||
<div id="tour-designer-blocks" ref={libraryRootRef} data-library-root className="h-full">
|
||||
<ActionLibraryPanel />
|
||||
</div>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const centerPanel = useMemo(() => <FlowWorkspace />, []);
|
||||
const centerPanel = useMemo(
|
||||
() => (
|
||||
<div id="tour-designer-canvas" className="h-full">
|
||||
<FlowWorkspace />
|
||||
</div>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const rightPanel = useMemo(
|
||||
() => (
|
||||
<div className="h-full">
|
||||
<div id="tour-designer-properties" className="h-full">
|
||||
<InspectorPanel
|
||||
activeTab={inspectorTab}
|
||||
onTabChange={setInspectorTab}
|
||||
@@ -932,6 +942,14 @@ export function DesignerRoot({
|
||||
|
||||
const actions = (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => startTour("designer")}
|
||||
title="Start Tour"
|
||||
>
|
||||
<HelpCircle className="h-5 w-5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
|
||||
@@ -203,7 +203,7 @@ export function PropertiesPanelBase({
|
||||
: Zap;
|
||||
|
||||
return (
|
||||
<div className={cn("w-full min-w-0 space-y-3 px-3", className)}>
|
||||
<div className={cn("w-full min-w-0 space-y-3 px-3", className)} id="tour-designer-properties">
|
||||
{/* Header / Metadata */}
|
||||
<div className="border-b pb-3">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
@@ -312,7 +312,7 @@ export function PropertiesPanelBase({
|
||||
/* --------------------------- Step Properties View --------------------------- */
|
||||
if (selectedStep) {
|
||||
return (
|
||||
<div className={cn("w-full min-w-0 space-y-3 px-3", className)}>
|
||||
<div className={cn("w-full min-w-0 space-y-3 px-3", className)} id="tour-designer-properties">
|
||||
<div className="border-b pb-2">
|
||||
<h3 className="flex items-center gap-2 text-sm font-medium">
|
||||
<div
|
||||
@@ -439,6 +439,7 @@ export function PropertiesPanelBase({
|
||||
"flex h-24 items-center justify-center text-center",
|
||||
className,
|
||||
)}
|
||||
id="tour-designer-properties"
|
||||
>
|
||||
<div>
|
||||
<Settings className="text-muted-foreground/50 mx-auto mb-2 h-6 w-6" />
|
||||
|
||||
@@ -955,6 +955,7 @@ export function FlowWorkspace({
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
id="tour-designer-canvas"
|
||||
className="relative h-0 min-h-0 flex-1 overflow-x-hidden overflow-y-auto"
|
||||
onScroll={onScroll}
|
||||
>
|
||||
|
||||
@@ -333,7 +333,7 @@ export function ActionLibraryPanel() {
|
||||
).length;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<div className="flex h-full flex-col overflow-hidden" id="tour-designer-blocks">
|
||||
<div className="bg-background/60 flex-shrink-0 border-b p-2">
|
||||
<div className="relative mb-2">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-2 h-3.5 w-3.5 -translate-y-1/2" />
|
||||
|
||||
359
src/components/onboarding/TourProvider.tsx
Normal file
359
src/components/onboarding/TourProvider.tsx
Normal file
@@ -0,0 +1,359 @@
|
||||
"use client";
|
||||
|
||||
import React, { createContext, useContext, useEffect, useRef } from "react";
|
||||
import { driver, type Driver } from "driver.js";
|
||||
import "driver.js/dist/driver.css";
|
||||
import { useTheme } from "next-themes";
|
||||
import { usePathname } from "next/navigation";
|
||||
import Cookies from "js-cookie";
|
||||
|
||||
type TourType = "dashboard" | "study_creation" | "designer" | "wizard" | "full_platform";
|
||||
|
||||
interface TourContextType {
|
||||
startTour: (tour: TourType) => void;
|
||||
}
|
||||
|
||||
const TourContext = createContext<TourContextType | undefined>(undefined);
|
||||
|
||||
export function useTour() {
|
||||
const context = useContext(TourContext);
|
||||
if (!context) {
|
||||
throw new Error("useTour must be used within a TourProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function TourProvider({ children }: { children: React.ReactNode }) {
|
||||
const driverObj = useRef<Driver | null>(null);
|
||||
const { theme } = useTheme();
|
||||
const pathname = usePathname();
|
||||
|
||||
// --- Multi-page Tour Logic ---
|
||||
useEffect(() => {
|
||||
// Check if we are in "Full Platform" mode (Local Storage OR Cookie)
|
||||
const localMode = localStorage.getItem("hristudio_tour_mode");
|
||||
const cookieMode = Cookies.get("hristudio_tour_mode");
|
||||
|
||||
const tourMode = localMode === "full" || cookieMode === "full" ? "full" : null;
|
||||
|
||||
if (tourMode === "full") {
|
||||
// Re-sync local storage if missing but cookie present
|
||||
if (localMode !== "full") localStorage.setItem("hristudio_tour_mode", "full");
|
||||
|
||||
// Small delay to ensure DOM is ready
|
||||
const timer = setTimeout(() => {
|
||||
if (pathname === "/dashboard") {
|
||||
runTourSegment("dashboard");
|
||||
} else if (pathname.includes("/studies/new")) {
|
||||
runTourSegment("study_creation");
|
||||
} else if (pathname.includes("/designer")) {
|
||||
runTourSegment("designer");
|
||||
} else if (pathname.includes("/wizard")) {
|
||||
runTourSegment("wizard");
|
||||
}
|
||||
}, 500); // Reduced delay for snappier feel, but still safe for render
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [pathname]);
|
||||
|
||||
const runTourSegment = (segment: "dashboard" | "study_creation" | "designer" | "wizard") => {
|
||||
const isDark = theme === "dark";
|
||||
// We add a specific class to handle dark/light overrides reliably
|
||||
const themeClass = isDark ? "driverjs-theme-dark" : "driverjs-theme-light";
|
||||
|
||||
let steps: any[] = [];
|
||||
|
||||
if (segment === "dashboard") {
|
||||
steps = [
|
||||
{
|
||||
element: "#dashboard-header",
|
||||
popover: {
|
||||
title: "Overview",
|
||||
description: "Welcome to HRIStudio. This dashboard gives you a high-level view of your research activities, active studies, and data collection progress.",
|
||||
side: "bottom",
|
||||
align: "start",
|
||||
},
|
||||
},
|
||||
{
|
||||
element: "#tour-sidebar-overview",
|
||||
popover: {
|
||||
title: "Navigation: Overview",
|
||||
description: "Quickly return to this main dashboard from anywhere in the application.",
|
||||
side: "right",
|
||||
},
|
||||
},
|
||||
{
|
||||
element: "#tour-sidebar-studies",
|
||||
popover: {
|
||||
title: "Navigation: Studies",
|
||||
description: "Manage all your research studies, IRBs, and team permissions in one place.",
|
||||
side: "right",
|
||||
},
|
||||
},
|
||||
{
|
||||
element: "#tour-sidebar-study-selector",
|
||||
popover: {
|
||||
title: "Active Study Selector",
|
||||
description: "Switch between different studies here. Selecting a study unlocks study-specific tools like the Experiment Designer and Data Analytics.",
|
||||
side: "right",
|
||||
},
|
||||
},
|
||||
{
|
||||
element: "#tour-new-study",
|
||||
popover: {
|
||||
title: "Create a New Study",
|
||||
description: "Ready to start? Click here to initialize a new research project and define your protocol.",
|
||||
side: "right",
|
||||
},
|
||||
},
|
||||
];
|
||||
} else if (segment === "study_creation") {
|
||||
steps = [
|
||||
{
|
||||
element: "#tour-study-name",
|
||||
popover: {
|
||||
title: "Naming Your Study",
|
||||
description: "Choose a concise, descriptive name. This will properly namespace your data, logs, and robot configurations.",
|
||||
side: "right",
|
||||
}
|
||||
},
|
||||
{
|
||||
element: "#tour-study-description",
|
||||
popover: {
|
||||
title: "Research Protocol",
|
||||
description: "Add a short description of your methodology or research questions. This helps team members understand the context.",
|
||||
side: "right",
|
||||
}
|
||||
},
|
||||
{
|
||||
element: "#tour-study-submit",
|
||||
popover: {
|
||||
title: "Initialize Project",
|
||||
description: "Create the study to access the full suite of tools: Experiment Designer, Wizard Interface, and Analytics.",
|
||||
side: "top",
|
||||
}
|
||||
}
|
||||
];
|
||||
} else if (segment === "designer") {
|
||||
steps = [
|
||||
{
|
||||
element: "#tour-designer-blocks",
|
||||
popover: {
|
||||
title: "Action Library",
|
||||
description: "Drag and drop robot behaviors (Speech, Gestures, Movement) onto the canvas. Includes both core actions and those from installed plugins.",
|
||||
side: "right",
|
||||
},
|
||||
},
|
||||
{
|
||||
element: "#tour-designer-canvas",
|
||||
popover: {
|
||||
title: "Visual Flow Canvas",
|
||||
description: "Design your experiment logic here. Connect blocks to create sequences, branches, and loops for the robot to execute.",
|
||||
side: "top",
|
||||
},
|
||||
},
|
||||
{
|
||||
element: "#tour-designer-properties",
|
||||
popover: {
|
||||
title: "Properties Panel",
|
||||
description: "Select any block to configure its parameters—like speech text, speed, volume, or timeout durations.",
|
||||
side: "left",
|
||||
},
|
||||
},
|
||||
];
|
||||
} else if (segment === "wizard") {
|
||||
steps = [
|
||||
{
|
||||
element: "#tour-wizard-controls",
|
||||
popover: {
|
||||
title: "Wizard Dashboard",
|
||||
description: "The command center for running trials. Manually trigger robot actions or override autonomous behaviors in real-time.",
|
||||
side: "right",
|
||||
},
|
||||
},
|
||||
{
|
||||
element: "#tour-wizard-timeline",
|
||||
popover: {
|
||||
title: "Live Timeline",
|
||||
description: "See exactly what the robot is doing, what's coming next, and a history of all events in the current session.",
|
||||
side: "top",
|
||||
},
|
||||
},
|
||||
{
|
||||
element: "#tour-wizard-robot-status",
|
||||
popover: {
|
||||
title: "System Health",
|
||||
description: "Monitor critical telemetry: battery levels, joint temperatures, and network latency to ensure safety.",
|
||||
side: "left",
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
driverObj.current = driver({
|
||||
showProgress: true,
|
||||
animate: true,
|
||||
allowClose: true,
|
||||
steps: steps.map((step) => ({
|
||||
...step,
|
||||
popover: {
|
||||
...step.popover,
|
||||
popoverClass: `driver-popover-override ${themeClass}`,
|
||||
},
|
||||
})),
|
||||
onDestroyed: () => {
|
||||
// Persistence handled by localStorage state
|
||||
}
|
||||
});
|
||||
|
||||
driverObj.current.drive();
|
||||
};
|
||||
|
||||
const startTour = (tour: TourType) => {
|
||||
if (tour === "full_platform") {
|
||||
localStorage.setItem("hristudio_tour_mode", "full");
|
||||
Cookies.set("hristudio_tour_mode", "full", { expires: 7 }); // 7 days persistence
|
||||
|
||||
// Trigger current page immediately
|
||||
if (pathname === "/dashboard") runTourSegment("dashboard");
|
||||
else if (pathname.includes("/studies/new")) runTourSegment("study_creation");
|
||||
else if (pathname.includes("/designer")) runTourSegment("designer");
|
||||
else if (pathname.includes("/wizard")) runTourSegment("wizard");
|
||||
else runTourSegment("dashboard"); // Fallback
|
||||
} else {
|
||||
localStorage.setItem("hristudio_tour_mode", "manual");
|
||||
Cookies.remove("hristudio_tour_mode");
|
||||
|
||||
if (tour === "dashboard") runTourSegment("dashboard");
|
||||
if (tour === "study_creation") runTourSegment("study_creation");
|
||||
if (tour === "designer") runTourSegment("designer");
|
||||
if (tour === "wizard") runTourSegment("wizard");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TourContext.Provider value={{ startTour }}>
|
||||
{children}
|
||||
<style jsx global>{`
|
||||
/*
|
||||
SHADCN/UI THEMING OVERRIDES
|
||||
CRITICAL: The global variables in globals.css use OKLCH/HSL values directly or with units.
|
||||
DO NOT wrap variables in hsl() if they are already defined as colors.
|
||||
Use direct assignment.
|
||||
*/
|
||||
|
||||
.driver-popover-override {
|
||||
padding: 1.25rem !important;
|
||||
border-radius: var(--radius) !important;
|
||||
box-shadow: var(--shadow-xl) !important;
|
||||
max-width: 420px !important;
|
||||
|
||||
/* Background & Text - Match Card Aesthetic */
|
||||
background-color: var(--card) !important;
|
||||
color: var(--card-foreground) !important;
|
||||
border: 1px solid var(--border) !important;
|
||||
|
||||
/* Typography */
|
||||
font-family: var(--font-sans) !important;
|
||||
}
|
||||
|
||||
/* Arrow Styling - Critical for transparent/card matching */
|
||||
.driver-popover-override .driver-popover-arrow {
|
||||
border-width: 8px !important;
|
||||
}
|
||||
|
||||
/*
|
||||
Since driver.js uses borders for arrows, we need to match the specific side.
|
||||
Using CSS variables requires a bit of trickery because border-color expects distinct values.
|
||||
We'll target the side classes driver.js adds.
|
||||
*/
|
||||
.driver-popover-override.driverjs-theme-dark .driver-popover-arrow-side-left.driver-popover-arrow {
|
||||
border-left-color: var(--card) !important;
|
||||
}
|
||||
.driver-popover-override.driverjs-theme-dark .driver-popover-arrow-side-right.driver-popover-arrow {
|
||||
border-right-color: var(--card) !important;
|
||||
}
|
||||
.driver-popover-override.driverjs-theme-dark .driver-popover-arrow-side-top.driver-popover-arrow {
|
||||
border-top-color: var(--card) !important;
|
||||
}
|
||||
.driver-popover-override.driverjs-theme-dark .driver-popover-arrow-side-bottom.driver-popover-arrow {
|
||||
border-bottom-color: var(--card) !important;
|
||||
}
|
||||
|
||||
/* Light mode fallbacks (using border color for definition, though card bg is usually sufficient) */
|
||||
.driver-popover-override.driverjs-theme-light .driver-popover-arrow-side-left.driver-popover-arrow {
|
||||
border-left-color: var(--card) !important;
|
||||
}
|
||||
.driver-popover-override.driverjs-theme-light .driver-popover-arrow-side-right.driver-popover-arrow {
|
||||
border-right-color: var(--card) !important;
|
||||
}
|
||||
.driver-popover-override.driverjs-theme-light .driver-popover-arrow-side-top.driver-popover-arrow {
|
||||
border-top-color: var(--card) !important;
|
||||
}
|
||||
.driver-popover-override.driverjs-theme-light .driver-popover-arrow-side-bottom.driver-popover-arrow {
|
||||
border-bottom-color: var(--card) !important;
|
||||
}
|
||||
|
||||
/* Title Styling */
|
||||
.driver-popover-override .driver-popover-title {
|
||||
color: var(--foreground) !important;
|
||||
font-size: 1.125rem !important; /* 18px */
|
||||
font-weight: 600 !important;
|
||||
margin-bottom: 0.5rem !important;
|
||||
letter-spacing: -0.015em !important;
|
||||
font-family: var(--font-sans) !important;
|
||||
}
|
||||
|
||||
/* Description Styling */
|
||||
.driver-popover-override .driver-popover-description {
|
||||
color: var(--muted-foreground) !important;
|
||||
font-size: 0.875rem !important; /* 14px */
|
||||
line-height: 1.6 !important;
|
||||
font-family: var(--font-sans) !important;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.driver-popover-override .driver-popover-footer button {
|
||||
background-color: var(--primary) !important;
|
||||
color: var(--primary-foreground) !important;
|
||||
border-radius: calc(var(--radius) - 2px) !important;
|
||||
padding: 0.5rem 1rem !important;
|
||||
font-size: 0.875rem !important;
|
||||
font-weight: 500 !important;
|
||||
border: none !important;
|
||||
text-shadow: none !important;
|
||||
transition-all: 0.2s !important;
|
||||
font-family: var(--font-sans) !important;
|
||||
}
|
||||
|
||||
.driver-popover-override .driver-popover-footer button:hover {
|
||||
opacity: 0.9 !important;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Navigation Buttons (Previous/Next) specifically */
|
||||
.driver-popover-override .driver-popover-footer .driver-popover-prev-btn {
|
||||
background-color: transparent !important;
|
||||
color: var(--muted-foreground) !important;
|
||||
border: 1px solid var(--border) !important;
|
||||
}
|
||||
.driver-popover-override .driver-popover-footer .driver-popover-prev-btn:hover {
|
||||
background-color: var(--accent) !important;
|
||||
color: var(--accent-foreground) !important;
|
||||
}
|
||||
|
||||
/* Close Button */
|
||||
.driver-popover-override .driver-popover-close-btn {
|
||||
color: var(--muted-foreground) !important;
|
||||
opacity: 0.7 !important;
|
||||
transition: opacity 0.2s !important;
|
||||
}
|
||||
|
||||
.driver-popover-override .driver-popover-close-btn:hover {
|
||||
color: var(--foreground) !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
`}</style>
|
||||
</TourContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { api } from "~/trpc/react";
|
||||
import { useTour } from "~/components/onboarding/TourProvider";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
const studySchema = z.object({
|
||||
name: z.string().min(1, "Study name is required").max(255, "Name too long"),
|
||||
@@ -49,6 +51,7 @@ interface StudyFormProps {
|
||||
|
||||
export function StudyForm({ mode, studyId }: StudyFormProps) {
|
||||
const router = useRouter();
|
||||
const { startTour } = useTour();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -167,9 +170,9 @@ export function StudyForm({ mode, studyId }: StudyFormProps) {
|
||||
description="Basic information about your research study."
|
||||
>
|
||||
<FormField>
|
||||
<Label htmlFor="name">Study Name *</Label>
|
||||
<Label htmlFor="tour-study-name">Study Name *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
id="tour-study-name"
|
||||
{...form.register("name")}
|
||||
placeholder="Enter study name..."
|
||||
className={form.formState.errors.name ? "border-red-500" : ""}
|
||||
@@ -182,9 +185,9 @@ export function StudyForm({ mode, studyId }: StudyFormProps) {
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<Label htmlFor="description">Description *</Label>
|
||||
<Label htmlFor="tour-study-description">Description *</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
id="tour-study-description"
|
||||
{...form.register("description")}
|
||||
placeholder="Describe the research objectives, methodology, and expected outcomes..."
|
||||
rows={4}
|
||||
@@ -322,6 +325,15 @@ export function StudyForm({ mode, studyId }: StudyFormProps) {
|
||||
onDelete={mode === "edit" ? onDelete : undefined}
|
||||
isDeleting={isDeleting}
|
||||
sidebar={sidebar}
|
||||
submitButtonId="tour-study-submit"
|
||||
extraActions={
|
||||
<Button variant="ghost" size="sm" onClick={() => startTour("study_creation")}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">Help</span>
|
||||
<div className="flex h-5 w-5 items-center justify-center rounded-full border text-xs text-muted-foreground">?</div>
|
||||
</div>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{formFields}
|
||||
</EntityForm>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { Play, CheckCircle, X, Clock, AlertCircle } from "lucide-react";
|
||||
import { Play, CheckCircle, X, Clock, AlertCircle, HelpCircle } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Progress } from "~/components/ui/progress";
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { api } from "~/trpc/react";
|
||||
import { useWizardRos } from "~/hooks/useWizardRos";
|
||||
import { toast } from "sonner";
|
||||
import { useTour } from "~/components/onboarding/TourProvider";
|
||||
|
||||
interface WizardInterfaceProps {
|
||||
trial: {
|
||||
@@ -77,6 +78,7 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
trial: initialTrial,
|
||||
userRole: _userRole,
|
||||
}: WizardInterfaceProps) {
|
||||
const { startTour } = useTour();
|
||||
const [trial, setTrial] = useState(initialTrial);
|
||||
const [currentStepIndex, setCurrentStepIndex] = useState(0);
|
||||
const [trialStartTime, setTrialStartTime] = useState<Date | null>(
|
||||
@@ -654,6 +656,13 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
>
|
||||
{rosConnected ? "ROS Connected" : "ROS Offline"}
|
||||
</Badge>
|
||||
<button
|
||||
onClick={() => startTour("wizard")}
|
||||
className="hover:bg-muted p-1 rounded-full transition-colors"
|
||||
title="Start Tour"
|
||||
>
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -664,59 +673,65 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
<ResizablePanel defaultSize={75} minSize={30}>
|
||||
<PanelsContainer
|
||||
left={
|
||||
<WizardControlPanel
|
||||
trial={trial}
|
||||
currentStep={currentStep}
|
||||
steps={steps}
|
||||
currentStepIndex={currentStepIndex}
|
||||
onStartTrial={handleStartTrial}
|
||||
onPauseTrial={handlePauseTrial}
|
||||
onNextStep={handleNextStep}
|
||||
onCompleteTrial={handleCompleteTrial}
|
||||
onAbortTrial={handleAbortTrial}
|
||||
onExecuteAction={handleExecuteAction}
|
||||
onExecuteRobotAction={handleExecuteRobotAction}
|
||||
studyId={trial.experiment.studyId}
|
||||
_isConnected={rosConnected}
|
||||
activeTab={controlPanelTab}
|
||||
onTabChange={setControlPanelTab}
|
||||
isStarting={startTrialMutation.isPending}
|
||||
onSetAutonomousLife={setAutonomousLife}
|
||||
readOnly={trial.status === 'completed' || _userRole === 'observer'}
|
||||
/>
|
||||
<div id="tour-wizard-controls" className="h-full">
|
||||
<WizardControlPanel
|
||||
trial={trial}
|
||||
currentStep={currentStep}
|
||||
steps={steps}
|
||||
currentStepIndex={currentStepIndex}
|
||||
onStartTrial={handleStartTrial}
|
||||
onPauseTrial={handlePauseTrial}
|
||||
onNextStep={handleNextStep}
|
||||
onCompleteTrial={handleCompleteTrial}
|
||||
onAbortTrial={handleAbortTrial}
|
||||
onExecuteAction={handleExecuteAction}
|
||||
onExecuteRobotAction={handleExecuteRobotAction}
|
||||
studyId={trial.experiment.studyId}
|
||||
_isConnected={rosConnected}
|
||||
activeTab={controlPanelTab}
|
||||
onTabChange={setControlPanelTab}
|
||||
isStarting={startTrialMutation.isPending}
|
||||
onSetAutonomousLife={setAutonomousLife}
|
||||
readOnly={trial.status === 'completed' || _userRole === 'observer'}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
center={
|
||||
<WizardExecutionPanel
|
||||
trial={trial}
|
||||
currentStep={currentStep}
|
||||
steps={steps}
|
||||
currentStepIndex={currentStepIndex}
|
||||
trialEvents={trialEvents}
|
||||
onStepSelect={(index: number) => setCurrentStepIndex(index)}
|
||||
onExecuteAction={handleExecuteAction}
|
||||
onExecuteRobotAction={handleExecuteRobotAction}
|
||||
activeTab={executionPanelTab}
|
||||
onTabChange={setExecutionPanelTab}
|
||||
onSkipAction={handleSkipAction}
|
||||
isExecuting={isExecutingAction}
|
||||
onNextStep={handleNextStep}
|
||||
completedActionsCount={completedActionsCount}
|
||||
onActionCompleted={() => setCompletedActionsCount(c => c + 1)}
|
||||
onCompleteTrial={handleCompleteTrial}
|
||||
readOnly={trial.status === 'completed' || _userRole === 'observer'}
|
||||
/>
|
||||
<div id="tour-wizard-timeline" className="h-full">
|
||||
<WizardExecutionPanel
|
||||
trial={trial}
|
||||
currentStep={currentStep}
|
||||
steps={steps}
|
||||
currentStepIndex={currentStepIndex}
|
||||
trialEvents={trialEvents}
|
||||
onStepSelect={(index: number) => setCurrentStepIndex(index)}
|
||||
onExecuteAction={handleExecuteAction}
|
||||
onExecuteRobotAction={handleExecuteRobotAction}
|
||||
activeTab={executionPanelTab}
|
||||
onTabChange={setExecutionPanelTab}
|
||||
onSkipAction={handleSkipAction}
|
||||
isExecuting={isExecutingAction}
|
||||
onNextStep={handleNextStep}
|
||||
completedActionsCount={completedActionsCount}
|
||||
onActionCompleted={() => setCompletedActionsCount(c => c + 1)}
|
||||
onCompleteTrial={handleCompleteTrial}
|
||||
readOnly={trial.status === 'completed' || _userRole === 'observer'}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
right={
|
||||
<WizardMonitoringPanel
|
||||
rosConnected={rosConnected}
|
||||
rosConnecting={rosConnecting}
|
||||
rosError={rosError ?? undefined}
|
||||
robotStatus={robotStatus}
|
||||
connectRos={connectRos}
|
||||
disconnectRos={disconnectRos}
|
||||
executeRosAction={executeRosAction}
|
||||
readOnly={trial.status === 'completed' || _userRole === 'observer'}
|
||||
/>
|
||||
<div id="tour-wizard-robot-status" className="h-full">
|
||||
<WizardMonitoringPanel
|
||||
rosConnected={rosConnected}
|
||||
rosConnecting={rosConnecting}
|
||||
rosError={rosError ?? undefined}
|
||||
robotStatus={robotStatus}
|
||||
connectRos={connectRos}
|
||||
disconnectRos={disconnectRos}
|
||||
executeRosAction={executeRosAction}
|
||||
readOnly={trial.status === 'completed' || _userRole === 'observer'}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
showDividers={true}
|
||||
className="h-full"
|
||||
|
||||
@@ -30,12 +30,14 @@ interface WizardObservationPaneProps {
|
||||
tags?: string[],
|
||||
) => Promise<void>;
|
||||
isSubmitting?: boolean;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export function WizardObservationPane({
|
||||
onAddAnnotation,
|
||||
isSubmitting = false,
|
||||
trialEvents = [],
|
||||
readOnly = false,
|
||||
}: WizardObservationPaneProps & { trialEvents?: TrialEvent[] }) {
|
||||
const [note, setNote] = useState("");
|
||||
const [category, setCategory] = useState("observation");
|
||||
@@ -82,15 +84,16 @@ export function WizardObservationPane({
|
||||
<TabsContent value="notes" className="flex-1 flex flex-col p-4 m-0 data-[state=inactive]:hidden">
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Textarea
|
||||
placeholder="Type your observation here..."
|
||||
placeholder={readOnly ? "Session is read-only" : "Type your observation here..."}
|
||||
className="flex-1 resize-none font-mono text-sm"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={category} onValueChange={setCategory}>
|
||||
<Select value={category} onValueChange={setCategory} disabled={readOnly}>
|
||||
<SelectTrigger className="w-[140px] h-8 text-xs">
|
||||
<SelectValue placeholder="Category" />
|
||||
</SelectTrigger>
|
||||
@@ -104,11 +107,11 @@ export function WizardObservationPane({
|
||||
</Select>
|
||||
|
||||
<div className="flex flex-1 items-center gap-2 rounded-md border px-2 h-8">
|
||||
<Tag className="h-3 w-3 text-muted-foreground" />
|
||||
<Tag className={`h-3 w-3 ${readOnly ? "text-muted-foreground/50" : "text-muted-foreground"}`} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Add tags..."
|
||||
className="flex-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground"
|
||||
placeholder={readOnly ? "" : "Add tags..."}
|
||||
className="flex-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed"
|
||||
value={currentTag}
|
||||
onChange={(e) => setCurrentTag(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
@@ -118,13 +121,14 @@ export function WizardObservationPane({
|
||||
}
|
||||
}}
|
||||
onBlur={addTag}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting || !note.trim()}
|
||||
disabled={isSubmitting || !note.trim() || readOnly}
|
||||
className="h-8"
|
||||
>
|
||||
<Send className="mr-2 h-3 w-3" />
|
||||
|
||||
@@ -52,6 +52,10 @@ interface EntityFormProps<T extends FieldValues = FieldValues> {
|
||||
|
||||
// Custom submit button text
|
||||
submitText?: string;
|
||||
submitButtonId?: string;
|
||||
|
||||
// Additional header actions
|
||||
extraActions?: ReactNode;
|
||||
|
||||
// Layout
|
||||
layout?: "default" | "full-width";
|
||||
@@ -76,6 +80,8 @@ export function EntityForm<T extends FieldValues = FieldValues>({
|
||||
isDeleting = false,
|
||||
sidebar,
|
||||
submitText,
|
||||
submitButtonId,
|
||||
extraActions,
|
||||
layout = "default",
|
||||
className,
|
||||
}: EntityFormProps<T>) {
|
||||
@@ -97,6 +103,7 @@ export function EntityForm<T extends FieldValues = FieldValues>({
|
||||
icon={Icon}
|
||||
actions={
|
||||
<div className="flex items-center space-x-2">
|
||||
{extraActions}
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={backUrl}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
@@ -161,6 +168,7 @@ export function EntityForm<T extends FieldValues = FieldValues>({
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
id={submitButtonId}
|
||||
type="submit"
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
|
||||
Reference in New Issue
Block a user