jurmy24 commited on
Commit
9a9d18a
·
1 Parent(s): 0f1e910

mega big updates pre-demo

Browse files
src/App.tsx CHANGED
@@ -1,43 +1,61 @@
1
-
2
- import { Toaster } from "@/components/ui/toaster";
3
- import { Toaster as Sonner } from "@/components/ui/sonner";
4
- import { TooltipProvider } from "@/components/ui/tooltip";
 
 
5
  import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
6
- import { BrowserRouter, Routes, Route } from "react-router-dom";
7
- import Index from "./pages/Index";
8
- import NotFound from "./pages/NotFound";
9
- import Landing from "./pages/Landing";
10
- import TeleoperationPage from "./pages/Teleoperation";
11
- import Recording from "./pages/Recording";
12
- import Calibration from "./pages/Calibration";
13
- import Training from "./pages/Training";
14
- import { UrdfProvider } from "./contexts/UrdfContext";
15
- import EditDataset from "./pages/EditDataset";
 
 
 
 
 
 
 
 
16
 
17
  const queryClient = new QueryClient();
18
 
19
- const App = () => (
20
- <QueryClientProvider client={queryClient}>
21
- <TooltipProvider>
22
- <Toaster />
23
- <Sonner />
24
- <UrdfProvider>
25
- <BrowserRouter>
26
- <Routes>
27
- <Route path="/" element={<Landing />} />
28
- <Route path="/control" element={<Index />} />
29
- <Route path="/teleoperation" element={<TeleoperationPage />} />
30
- <Route path="/recording" element={<Recording />} />
31
- <Route path="/calibration" element={<Calibration />} />
32
- <Route path="/training" element={<Training />} />
33
- <Route path="/edit-dataset" element={<EditDataset />} />
34
- {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
35
- <Route path="*" element={<NotFound />} />
36
- </Routes>
37
- </BrowserRouter>
38
- </UrdfProvider>
39
- </TooltipProvider>
40
- </QueryClientProvider>
41
- );
 
 
 
 
 
 
 
 
42
 
43
  export default App;
 
1
+ import {
2
+ BrowserRouter as Router,
3
+ Routes,
4
+ Route,
5
+ BrowserRouter,
6
+ } from "react-router-dom";
7
  import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
8
+ import { ThemeProvider } from "@/contexts/ThemeContext";
9
+ import { UrdfProvider } from "@/contexts/UrdfContext";
10
+ import { DragAndDropProvider } from "@/contexts/DragAndDropContext";
11
+ import { Toaster } from "@/components/ui/toaster";
12
+ import Landing from "@/pages/Landing";
13
+ import Teleoperation from "@/pages/Teleoperation";
14
+ import DirectFollower from "@/pages/DirectFollower";
15
+ import Calibration from "@/pages/Calibration";
16
+ import Recording from "@/pages/Recording";
17
+ import Training from "@/pages/Training";
18
+ import ReplayDataset from "@/pages/ReplayDataset";
19
+ import EditDataset from "@/pages/EditDataset";
20
+ import Upload from "@/pages/Upload";
21
+ import PhoneCamera from "@/pages/PhoneCamera";
22
+ import NotFound from "@/pages/NotFound";
23
+ import "./App.css";
24
+ import { TooltipProvider } from "@radix-ui/react-tooltip";
25
+ import { ApiProvider } from "./contexts/ApiContext";
26
 
27
  const queryClient = new QueryClient();
28
 
29
+ function App() {
30
+ return (
31
+ <QueryClientProvider client={queryClient}>
32
+ <TooltipProvider>
33
+ <ThemeProvider>
34
+ <ApiProvider>
35
+ <UrdfProvider>
36
+ <DragAndDropProvider>
37
+ <BrowserRouter>
38
+ <Routes>
39
+ <Route path="/" element={<Landing />} />
40
+ <Route path="/teleoperation" element={<Teleoperation />} />
41
+ <Route path="/recording" element={<Recording />} />
42
+ <Route path="/upload" element={<Upload />} />
43
+ <Route path="/training" element={<Training />} />
44
+ <Route path="/calibration" element={<Calibration />} />
45
+ <Route path="/edit-dataset" element={<EditDataset />} />
46
+ <Route path="/replay-dataset" element={<ReplayDataset />} />
47
+ <Route path="/phone-camera" element={<PhoneCamera />} />
48
+ <Route path="*" element={<NotFound />} />
49
+ </Routes>
50
+ <Toaster />
51
+ </BrowserRouter>
52
+ </DragAndDropProvider>
53
+ </UrdfProvider>
54
+ </ApiProvider>
55
+ </ThemeProvider>
56
+ </TooltipProvider>
57
+ </QueryClientProvider>
58
+ );
59
+ }
60
 
61
  export default App;
src/components/control/DirectFollowerControlPanel.tsx ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React, { useState } from "react";
3
+ import { Button } from "@/components/ui/button";
4
+ import { Slider } from "@/components/ui/slider";
5
+
6
+ type Joint = {
7
+ name: string;
8
+ label: string;
9
+ min: number;
10
+ max: number;
11
+ step: number;
12
+ default: number;
13
+ };
14
+
15
+ const JOINTS: Joint[] = [
16
+ { name: "shoulder_pan.pos", label: "Shoulder Pan", min: -180, max: 180, step: 1, default: 0 },
17
+ { name: "shoulder_lift.pos", label: "Shoulder Lift", min: -180, max: 180, step: 1, default: 0 },
18
+ { name: "elbow_flex.pos", label: "Elbow Flex", min: -180, max: 180, step: 1, default: 0 },
19
+ { name: "wrist_flex.pos", label: "Wrist Flex", min: -180, max: 180, step: 1, default: 0 },
20
+ { name: "wrist_roll.pos", label: "Wrist Roll", min: -180, max: 180, step: 1, default: 0 },
21
+ { name: "gripper.pos", label: "Gripper", min: 0, max: 100, step: 1, default: 0 },
22
+ ];
23
+
24
+ type Props = {
25
+ onSendAction?: (values: Record<string, number>) => void;
26
+ className?: string;
27
+ };
28
+ const initialJointState = () => {
29
+ const state: Record<string, number> = {};
30
+ JOINTS.forEach(j => (state[j.name] = j.default));
31
+ return state;
32
+ };
33
+
34
+ const DirectFollowerControlPanel: React.FC<Props> = ({ onSendAction, className }) => {
35
+ const [jointValues, setJointValues] = useState<Record<string, number>>(initialJointState);
36
+
37
+ // Handler for slider changes
38
+ const handleSliderChange = (joint: Joint, value: number[]) => {
39
+ setJointValues(v => ({ ...v, [joint.name]: value[0] }));
40
+ };
41
+
42
+ // Quick action examples
43
+ const handleHome = () => {
44
+ const home: Record<string, number> = {};
45
+ JOINTS.forEach(j => (home[j.name] = 0));
46
+ setJointValues(home);
47
+ };
48
+
49
+ const handleSend = () => {
50
+ // You'd call the backend API here. For now, just call prop if present.
51
+ if (onSendAction) onSendAction(jointValues);
52
+
53
+ // TODO: Real API integration
54
+ fetch("http://localhost:8000/send-follower-command", {
55
+ method: "POST",
56
+ headers: { "Content-Type": "application/json" },
57
+ body: JSON.stringify(jointValues),
58
+ }).then(res => {
59
+ // Optionally handle response
60
+ // Could use a toast here
61
+ });
62
+ };
63
+
64
+ return (
65
+ <div className={`w-full max-w-lg mx-auto p-6 bg-gray-900 rounded-lg shadow space-y-6 ${className || ""}`}>
66
+ <h2 className="text-xl font-bold text-white mb-4 text-center">Direct Follower Control</h2>
67
+ <div className="space-y-4">
68
+ {JOINTS.map(joint => (
69
+ <div key={joint.name} className="flex flex-col">
70
+ <label className="text-gray-300 text-sm mb-1 flex justify-between">
71
+ <span>{joint.label}</span>
72
+ <span className="ml-2 font-mono text-gray-400">{jointValues[joint.name]}</span>
73
+ </label>
74
+ <Slider
75
+ min={joint.min}
76
+ max={joint.max}
77
+ step={joint.step}
78
+ value={[jointValues[joint.name]]}
79
+ onValueChange={value => handleSliderChange(joint, value)}
80
+ className="my-1"
81
+ />
82
+ <div className="flex justify-between text-xs text-gray-500 select-none">
83
+ <span>{joint.min}</span>
84
+ <span>{joint.max}</span>
85
+ </div>
86
+ </div>
87
+ ))}
88
+ </div>
89
+ <div className="flex gap-3 justify-center pt-4">
90
+ <Button variant="secondary" onClick={handleHome}>Home</Button>
91
+ <Button variant="default" className="px-8" onClick={handleSend}>
92
+ Send Action
93
+ </Button>
94
+ </div>
95
+ </div>
96
+ );
97
+ };
98
+
99
+ export default DirectFollowerControlPanel;
src/components/landing/ActionList.tsx CHANGED
@@ -1,7 +1,8 @@
1
 
2
  import React from 'react';
3
  import { Button } from "@/components/ui/button";
4
- import { ArrowRight } from "lucide-react";
 
5
  import { Action } from './types';
6
 
7
  interface ActionListProps {
@@ -14,43 +15,62 @@ const ActionList: React.FC<ActionListProps> = ({ actions, robotModel }) => {
14
  const isDisabled = !robotModel || isLeKiwi;
15
 
16
  return (
17
- <div className="pt-6">
18
- {!robotModel && (
19
- <p className="text-center text-gray-400 mb-4">
20
- Please select a robot model to continue.
21
- </p>
22
- )}
23
- {isLeKiwi && (
24
- <p className="text-center text-yellow-500 mb-4">
25
- LeKiwi model is not yet supported. Please select another model to continue.
26
- </p>
27
- )}
28
- <div className="space-y-4">
29
- {actions.map((action, index) => (
30
- <div
31
- key={index}
32
- className={`flex items-center justify-between p-4 bg-gray-800 rounded-lg border border-gray-700 transition-opacity ${
33
- isDisabled ? "opacity-50" : ""
34
- }`}
35
- >
36
- <div>
37
- <h3 className="font-semibold text-lg">{action.title}</h3>
38
- <p className="text-gray-400 text-sm">
39
- {action.description}
40
- </p>
41
- </div>
42
- <Button
43
- onClick={action.handler}
44
- size="icon"
45
- className={`${action.color} text-white`}
46
- disabled={isDisabled}
47
  >
48
- <ArrowRight className="w-5 h-5" />
49
- </Button>
50
- </div>
51
- ))}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  </div>
53
- </div>
54
  );
55
  };
56
 
 
1
 
2
  import React from 'react';
3
  import { Button } from "@/components/ui/button";
4
+ import { ArrowRight, AlertTriangle } from "lucide-react";
5
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
6
  import { Action } from './types';
7
 
8
  interface ActionListProps {
 
15
  const isDisabled = !robotModel || isLeKiwi;
16
 
17
  return (
18
+ <TooltipProvider>
19
+ <div className="pt-6">
20
+ {!robotModel && (
21
+ <p className="text-center text-gray-400 mb-4">
22
+ Please select a robot model to continue.
23
+ </p>
24
+ )}
25
+ {isLeKiwi && (
26
+ <p className="text-center text-yellow-500 mb-4">
27
+ LeKiwi model is not yet supported. Please select another model to continue.
28
+ </p>
29
+ )}
30
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
31
+ {actions.map((action, index) => (
32
+ <div
33
+ key={index}
34
+ className={`flex items-center justify-between p-4 bg-gray-800 rounded-lg border border-gray-700 transition-opacity ${
35
+ isDisabled ? "opacity-50" : ""
36
+ }`}
 
 
 
 
 
 
 
 
 
 
 
37
  >
38
+ <div className="flex items-center gap-2">
39
+ <div>
40
+ <div className="flex items-center gap-2">
41
+ <h3 className="font-semibold text-lg">{action.title}</h3>
42
+ {action.isWorkInProgress && (
43
+ <div className="flex items-center gap-1">
44
+ <Tooltip>
45
+ <TooltipTrigger>
46
+ <AlertTriangle className="w-4 h-4 text-yellow-500" />
47
+ </TooltipTrigger>
48
+ <TooltipContent>
49
+ <p>Work in progress</p>
50
+ </TooltipContent>
51
+ </Tooltip>
52
+ <span className="text-yellow-500 text-xs font-medium">Work in Progress</span>
53
+ </div>
54
+ )}
55
+ </div>
56
+ <p className="text-gray-400 text-sm">
57
+ {action.description}
58
+ </p>
59
+ </div>
60
+ </div>
61
+ <Button
62
+ onClick={action.handler}
63
+ size="icon"
64
+ className={`${action.color} text-white`}
65
+ disabled={isDisabled}
66
+ >
67
+ <ArrowRight className="w-5 h-5" />
68
+ </Button>
69
+ </div>
70
+ ))}
71
+ </div>
72
  </div>
73
+ </TooltipProvider>
74
  );
75
  };
76
 
src/components/landing/DirectFollowerModal.tsx ADDED
@@ -0,0 +1,178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React, { useState, useEffect } from "react";
3
+ import { Button } from "@/components/ui/button";
4
+ import { Input } from "@/components/ui/input";
5
+ import { Label } from "@/components/ui/label";
6
+ import {
7
+ Select,
8
+ SelectContent,
9
+ SelectItem,
10
+ SelectTrigger,
11
+ SelectValue,
12
+ } from "@/components/ui/select";
13
+ import {
14
+ Dialog,
15
+ DialogContent,
16
+ DialogHeader,
17
+ DialogTitle,
18
+ DialogDescription,
19
+ } from "@/components/ui/dialog";
20
+ import { Settings } from "lucide-react";
21
+ import PortDetectionModal from "@/components/ui/PortDetectionModal";
22
+ import PortDetectionButton from "@/components/ui/PortDetectionButton";
23
+
24
+ interface DirectFollowerModalProps {
25
+ open: boolean;
26
+ onOpenChange: (open: boolean) => void;
27
+ followerPort: string;
28
+ setFollowerPort: (value: string) => void;
29
+ followerConfig: string;
30
+ setFollowerConfig: (value: string) => void;
31
+ followerConfigs: string[];
32
+ isLoadingConfigs: boolean;
33
+ onStart: () => void;
34
+ }
35
+
36
+ const DirectFollowerModal: React.FC<DirectFollowerModalProps> = ({
37
+ open,
38
+ onOpenChange,
39
+ followerPort,
40
+ setFollowerPort,
41
+ followerConfig,
42
+ setFollowerConfig,
43
+ followerConfigs,
44
+ isLoadingConfigs,
45
+ onStart,
46
+ }) => {
47
+ const [showPortDetection, setShowPortDetection] = useState(false);
48
+
49
+ // Load saved follower port on component mount
50
+ useEffect(() => {
51
+ const loadSavedPort = async () => {
52
+ try {
53
+ const followerResponse = await fetch(
54
+ "http://localhost:8000/robot-port/follower"
55
+ );
56
+ const followerData = await followerResponse.json();
57
+ if (followerData.status === "success" && followerData.default_port) {
58
+ setFollowerPort(followerData.default_port);
59
+ }
60
+ } catch (error) {
61
+ console.error("Error loading saved follower port:", error);
62
+ }
63
+ };
64
+
65
+ if (open) {
66
+ loadSavedPort();
67
+ }
68
+ }, [open, setFollowerPort]);
69
+
70
+ const handlePortDetection = () => {
71
+ setShowPortDetection(true);
72
+ };
73
+
74
+ const handlePortDetected = (port: string) => {
75
+ setFollowerPort(port);
76
+ };
77
+
78
+ return (
79
+ <Dialog open={open} onOpenChange={onOpenChange}>
80
+ <DialogContent className="bg-gray-900 border-gray-800 text-white sm:max-w-[500px] p-8">
81
+ <DialogHeader>
82
+ <div className="flex justify-center items-center gap-4 mb-4">
83
+ <Settings className="w-8 h-8 text-blue-500" />
84
+ </div>
85
+ <DialogTitle className="text-white text-center text-2xl font-bold">
86
+ Configure Direct Follower Control
87
+ </DialogTitle>
88
+ </DialogHeader>
89
+ <div className="space-y-6 py-4">
90
+ <DialogDescription className="text-gray-400 text-base leading-relaxed text-center">
91
+ Configure the follower robot arm for direct control with 6-axis commands.
92
+ </DialogDescription>
93
+
94
+ <div className="grid grid-cols-1 gap-6">
95
+ <div className="space-y-2">
96
+ <Label
97
+ htmlFor="followerPort"
98
+ className="text-sm font-medium text-gray-300"
99
+ >
100
+ Follower Port
101
+ </Label>
102
+ <div className="flex gap-2">
103
+ <Input
104
+ id="followerPort"
105
+ value={followerPort}
106
+ onChange={(e) => setFollowerPort(e.target.value)}
107
+ placeholder="/dev/tty.usbmodem5A460816621"
108
+ className="bg-gray-800 border-gray-700 text-white flex-1"
109
+ />
110
+ <PortDetectionButton
111
+ onClick={handlePortDetection}
112
+ robotType="follower"
113
+ />
114
+ </div>
115
+ </div>
116
+
117
+ <div className="space-y-2">
118
+ <Label
119
+ htmlFor="followerConfig"
120
+ className="text-sm font-medium text-gray-300"
121
+ >
122
+ Follower Calibration Config
123
+ </Label>
124
+ <Select value={followerConfig} onValueChange={setFollowerConfig}>
125
+ <SelectTrigger className="bg-gray-800 border-gray-700 text-white">
126
+ <SelectValue
127
+ placeholder={
128
+ isLoadingConfigs
129
+ ? "Loading configs..."
130
+ : "Select follower config"
131
+ }
132
+ />
133
+ </SelectTrigger>
134
+ <SelectContent className="bg-gray-800 border-gray-700">
135
+ {followerConfigs.map((config) => (
136
+ <SelectItem
137
+ key={config}
138
+ value={config}
139
+ className="text-white hover:bg-gray-700"
140
+ >
141
+ {config}
142
+ </SelectItem>
143
+ ))}
144
+ </SelectContent>
145
+ </Select>
146
+ </div>
147
+ </div>
148
+
149
+ <div className="flex flex-col sm:flex-row gap-4 justify-center pt-4">
150
+ <Button
151
+ onClick={onStart}
152
+ className="w-full sm:w-auto bg-blue-500 hover:bg-blue-600 text-white px-10 py-6 text-lg transition-all shadow-md shadow-blue-500/30 hover:shadow-lg hover:shadow-blue-500/40"
153
+ disabled={isLoadingConfigs}
154
+ >
155
+ Start Direct Control
156
+ </Button>
157
+ <Button
158
+ onClick={() => onOpenChange(false)}
159
+ variant="outline"
160
+ className="w-full sm:w-auto border-gray-500 hover:border-gray-200 px-10 py-6 text-lg text-zinc-500 bg-zinc-900 hover:bg-zinc-800"
161
+ >
162
+ Cancel
163
+ </Button>
164
+ </div>
165
+ </div>
166
+ </DialogContent>
167
+
168
+ <PortDetectionModal
169
+ open={showPortDetection}
170
+ onOpenChange={setShowPortDetection}
171
+ robotType="follower"
172
+ onPortDetected={handlePortDetected}
173
+ />
174
+ </Dialog>
175
+ );
176
+ };
177
+
178
+ export default DirectFollowerModal;
src/components/landing/LandingHeader.tsx CHANGED
@@ -1,9 +1,68 @@
1
- import React from 'react';
2
- const LandingHeader = () => {
3
- return <div className="text-center space-y-4 max-w-lg w-full">
4
- <img src="/lovable-uploads/5e648747-34b7-4d8f-93fd-4dbd00aeeefc.png" alt="LiveLab Logo" className="mx-auto h-20 w-20" />
5
- <h1 className="text-5xl font-bold tracking-tight">LeLab</h1>
6
- <p className="text-xl text-gray-400">LeRobot but on HFSpace.</p>
7
- </div>;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  };
9
- export default LandingHeader;
 
1
+ import React from "react";
2
+ import { Button } from "@/components/ui/button";
3
+ import { Info, Globe, Wifi, WifiOff } from "lucide-react";
4
+ import { useApi } from "@/contexts/ApiContext";
5
+
6
+ interface LandingHeaderProps {
7
+ onShowInstructions: () => void;
8
+ onShowNgrokConfig: () => void;
9
+ }
10
+
11
+ const LandingHeader: React.FC<LandingHeaderProps> = ({
12
+ onShowInstructions,
13
+ onShowNgrokConfig,
14
+ }) => {
15
+ const { isNgrokEnabled } = useApi();
16
+
17
+ return (
18
+ <div className="relative w-full">
19
+ {/* Ngrok button in top right */}
20
+ <div className="absolute top-0 right-0 flex gap-2">
21
+ <Button
22
+ variant="ghost"
23
+ size="sm"
24
+ onClick={onShowNgrokConfig}
25
+ className={`transition-all duration-200 ${
26
+ isNgrokEnabled
27
+ ? "bg-green-900/30 border border-green-700 text-green-400 hover:bg-green-900/50"
28
+ : "text-gray-400 hover:text-white hover:bg-gray-800"
29
+ }`}
30
+ title={
31
+ isNgrokEnabled
32
+ ? "Ngrok enabled - Click to configure"
33
+ : "Configure Ngrok for external access"
34
+ }
35
+ >
36
+ {isNgrokEnabled ? (
37
+ <Wifi className="h-4 w-4 mr-2" />
38
+ ) : (
39
+ <Globe className="h-4 w-4 mr-2" />
40
+ )}
41
+ <span className="hidden sm:inline">
42
+ {isNgrokEnabled ? "Ngrok" : "Configure Ngrok"}
43
+ </span>
44
+ </Button>
45
+ </div>
46
+
47
+ {/* Main header content */}
48
+ <div className="text-center space-y-4 w-full pt-8">
49
+ <img
50
+ src="/lovable-uploads/5e648747-34b7-4d8f-93fd-4dbd00aeeefc.png"
51
+ alt="LiveLab Logo"
52
+ className="mx-auto h-20 w-20"
53
+ />
54
+ <h1 className="text-5xl font-bold tracking-tight">LeLab</h1>
55
+ <p className="text-xl text-gray-400">LeRobot but on HFSpace.</p>
56
+ <Button
57
+ variant="ghost"
58
+ size="icon"
59
+ onClick={onShowInstructions}
60
+ className="mx-auto"
61
+ >
62
+ <Info className="h-6 w-6 text-gray-400 hover:text-white" />
63
+ </Button>
64
+ </div>
65
+ </div>
66
+ );
67
  };
68
+ export default LandingHeader;
src/components/landing/NgrokConfigModal.tsx ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from "react";
2
+ import { Button } from "@/components/ui/button";
3
+ import { Input } from "@/components/ui/input";
4
+ import { Label } from "@/components/ui/label";
5
+ import {
6
+ Dialog,
7
+ DialogContent,
8
+ DialogHeader,
9
+ DialogTitle,
10
+ DialogDescription,
11
+ } from "@/components/ui/dialog";
12
+ import { Globe, Wifi, WifiOff, ExternalLink } from "lucide-react";
13
+ import { useApi } from "@/contexts/ApiContext";
14
+ import { useToast } from "@/hooks/use-toast";
15
+
16
+ interface NgrokConfigModalProps {
17
+ open: boolean;
18
+ onOpenChange: (open: boolean) => void;
19
+ }
20
+
21
+ const NgrokConfigModal: React.FC<NgrokConfigModalProps> = ({
22
+ open,
23
+ onOpenChange,
24
+ }) => {
25
+ const {
26
+ ngrokUrl,
27
+ isNgrokEnabled,
28
+ setNgrokUrl,
29
+ resetToLocalhost,
30
+ fetchWithHeaders,
31
+ } = useApi();
32
+ const [inputUrl, setInputUrl] = useState(ngrokUrl);
33
+ const [isTestingConnection, setIsTestingConnection] = useState(false);
34
+ const { toast } = useToast();
35
+
36
+ const handleSave = async () => {
37
+ if (!inputUrl.trim()) {
38
+ resetToLocalhost();
39
+ toast({
40
+ title: "Ngrok Disabled",
41
+ description: "Switched back to localhost mode.",
42
+ });
43
+ onOpenChange(false);
44
+ return;
45
+ }
46
+
47
+ setIsTestingConnection(true);
48
+
49
+ try {
50
+ // Clean the URL
51
+ let cleanUrl = inputUrl.trim();
52
+ if (!cleanUrl.startsWith("http")) {
53
+ cleanUrl = `https://${cleanUrl}`;
54
+ }
55
+ cleanUrl = cleanUrl.replace(/\/$/, "");
56
+
57
+ // Test the connection
58
+ const testResponse = await fetchWithHeaders(`${cleanUrl}/health`, {
59
+ method: "GET",
60
+ headers: {
61
+ Accept: "application/json",
62
+ },
63
+ });
64
+
65
+ if (testResponse.ok) {
66
+ setNgrokUrl(cleanUrl);
67
+ toast({
68
+ title: "Ngrok Configured Successfully",
69
+ description: `Connected to ${cleanUrl}. All API calls will now use this URL.`,
70
+ });
71
+ onOpenChange(false);
72
+ } else {
73
+ throw new Error(`Server responded with status ${testResponse.status}`);
74
+ }
75
+ } catch (error) {
76
+ console.error("Failed to connect to ngrok URL:", error);
77
+ toast({
78
+ title: "Connection Failed",
79
+ description: `Could not connect to ${inputUrl}. Please check the URL and ensure your ngrok tunnel is running.`,
80
+ variant: "destructive",
81
+ });
82
+ } finally {
83
+ setIsTestingConnection(false);
84
+ }
85
+ };
86
+
87
+ const handleReset = () => {
88
+ resetToLocalhost();
89
+ setInputUrl("");
90
+ toast({
91
+ title: "Reset to Localhost",
92
+ description: "All API calls will now use localhost:8000.",
93
+ });
94
+ onOpenChange(false);
95
+ };
96
+
97
+ return (
98
+ <Dialog open={open} onOpenChange={onOpenChange}>
99
+ <DialogContent className="bg-gray-900 border-gray-800 text-white w-[95vw] max-w-[600px] max-h-[90vh] overflow-y-auto p-4 sm:p-6 md:p-8">
100
+ <DialogHeader>
101
+ <div className="flex justify-center items-center mb-4">
102
+ <Globe className="w-8 h-8 text-blue-500" />
103
+ </div>
104
+ <DialogTitle className="text-white text-center text-xl sm:text-2xl font-bold">
105
+ Ngrok Configuration
106
+ </DialogTitle>
107
+ <DialogDescription className="text-gray-400 text-center text-sm sm:text-base">
108
+ Configure ngrok tunnel for external access and phone camera
109
+ features.
110
+ </DialogDescription>
111
+ </DialogHeader>
112
+
113
+ <div className="space-y-4 sm:space-y-6 py-4">
114
+ {/* Current Status */}
115
+ <div className="bg-gray-800 rounded-lg p-3 sm:p-4 border border-gray-700">
116
+ <div className="flex items-center gap-3 mb-2">
117
+ {isNgrokEnabled ? (
118
+ <Wifi className="w-4 h-4 sm:w-5 sm:h-5 text-green-500" />
119
+ ) : (
120
+ <WifiOff className="w-4 h-4 sm:w-5 sm:h-5 text-gray-500" />
121
+ )}
122
+ <span className="font-semibold text-sm sm:text-base">
123
+ Current Mode: {isNgrokEnabled ? "Ngrok" : "Localhost"}
124
+ </span>
125
+ </div>
126
+ <p className="text-xs sm:text-sm text-gray-400 break-all">
127
+ {isNgrokEnabled
128
+ ? `Using: ${ngrokUrl}`
129
+ : "Using: http://localhost:8000"}
130
+ </p>
131
+ </div>
132
+
133
+ {/* URL Input */}
134
+ <div className="space-y-2">
135
+ <Label
136
+ htmlFor="ngrokUrl"
137
+ className="text-sm font-medium text-gray-300"
138
+ >
139
+ Ngrok URL
140
+ </Label>
141
+ <Input
142
+ id="ngrokUrl"
143
+ value={inputUrl}
144
+ onChange={(e) => setInputUrl(e.target.value)}
145
+ placeholder="https://abc123.ngrok.io"
146
+ className="bg-gray-800 border-gray-700 text-white text-sm sm:text-base"
147
+ />
148
+ <p className="text-xs text-gray-500">
149
+ Enter your ngrok tunnel URL. Leave empty to use localhost.
150
+ </p>
151
+ </div>
152
+
153
+ {/* Benefits */}
154
+ <div className="bg-green-900/20 border border-green-800 rounded-lg p-3 sm:p-4">
155
+ <h3 className="font-semibold text-green-400 mb-2 text-sm sm:text-base">
156
+ Why use Ngrok?
157
+ </h3>
158
+ <ul className="text-xs sm:text-sm text-gray-300 space-y-1">
159
+ <li>• Access your robot from anywhere on the internet</li>
160
+ <li>• Use phone cameras as secondary recording angles</li>
161
+ <li>• Share your robot session with remote collaborators</li>
162
+ <li>• Test your setup from different devices</li>
163
+ </ul>
164
+ </div>
165
+
166
+ {/* Action Buttons */}
167
+ <div className="flex flex-col gap-3 sm:gap-4 pt-4">
168
+ <Button
169
+ onClick={handleSave}
170
+ disabled={isTestingConnection}
171
+ className="w-full bg-blue-500 hover:bg-blue-600 text-white px-6 sm:px-8 py-3 text-sm sm:text-lg transition-all shadow-md shadow-blue-500/30 hover:shadow-lg hover:shadow-blue-500/40"
172
+ >
173
+ {isTestingConnection ? (
174
+ <>
175
+ <div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin mr-2" />
176
+ Testing Connection...
177
+ </>
178
+ ) : (
179
+ <>
180
+ <ExternalLink className="w-4 h-4 sm:w-5 sm:h-5 mr-2" />
181
+ Save Configuration
182
+ </>
183
+ )}
184
+ </Button>
185
+
186
+ <div className="flex flex-col sm:flex-row gap-3">
187
+ {isNgrokEnabled && (
188
+ <Button
189
+ onClick={handleReset}
190
+ variant="outline"
191
+ className="w-full border-orange-500 hover:border-orange-400 text-orange-400 hover:text-orange-300 px-6 sm:px-8 py-3 text-sm sm:text-lg"
192
+ >
193
+ Reset to Localhost
194
+ </Button>
195
+ )}
196
+
197
+ <Button
198
+ onClick={() => onOpenChange(false)}
199
+ variant="outline"
200
+ className="w-full border-gray-500 hover:border-gray-200 px-6 sm:px-8 py-3 text-sm sm:text-lg text-zinc-500 bg-zinc-900 hover:bg-zinc-800"
201
+ >
202
+ Cancel
203
+ </Button>
204
+ </div>
205
+ </div>
206
+ </div>
207
+ </DialogContent>
208
+ </Dialog>
209
+ );
210
+ };
211
+
212
+ export default NgrokConfigModal;
src/components/landing/RecordingModal.tsx CHANGED
@@ -1,5 +1,4 @@
1
-
2
- import React from 'react';
3
  import { Button } from "@/components/ui/button";
4
  import { Input } from "@/components/ui/input";
5
  import { Label } from "@/components/ui/label";
@@ -17,7 +16,11 @@ import {
17
  DialogTitle,
18
  DialogDescription,
19
  } from "@/components/ui/dialog";
20
-
 
 
 
 
21
  interface RecordingModalProps {
22
  open: boolean;
23
  onOpenChange: (open: boolean) => void;
@@ -40,7 +43,6 @@ interface RecordingModalProps {
40
  isLoadingConfigs: boolean;
41
  onStart: () => void;
42
  }
43
-
44
  const RecordingModal: React.FC<RecordingModalProps> = ({
45
  open,
46
  onOpenChange,
@@ -63,159 +65,305 @@ const RecordingModal: React.FC<RecordingModalProps> = ({
63
  isLoadingConfigs,
64
  onStart,
65
  }) => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  return (
67
- <Dialog open={open} onOpenChange={onOpenChange}>
68
- <DialogContent className="bg-gray-900 border-gray-800 text-white sm:max-w-[600px] p-8 max-h-[90vh] overflow-y-auto">
69
- <DialogHeader>
70
- <div className="flex justify-center items-center gap-4 mb-4">
71
- <div className="w-8 h-8 bg-red-500 rounded-full flex items-center justify-center">
72
- <span className="text-white font-bold text-sm">REC</span>
 
 
73
  </div>
74
- </div>
75
- <DialogTitle className="text-white text-center text-2xl font-bold">
76
- Configure Recording
77
- </DialogTitle>
78
- </DialogHeader>
79
- <div className="space-y-6 py-4">
80
- <DialogDescription className="text-gray-400 text-base leading-relaxed text-center">
81
- Configure the robot arm settings and dataset parameters for
82
- recording.
83
- </DialogDescription>
84
 
85
- <div className="grid grid-cols-1 gap-6">
86
- <div className="space-y-4">
87
- <h3 className="text-lg font-semibold text-white border-b border-gray-700 pb-2">
88
- Robot Configuration
89
  </h3>
90
- <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
91
- <div className="space-y-2">
92
- <Label htmlFor="recordLeaderPort" className="text-sm font-medium text-gray-300">
93
- Leader Port
94
- </Label>
95
- <Input
96
- id="recordLeaderPort"
97
- value={leaderPort}
98
- onChange={(e) => setLeaderPort(e.target.value)}
99
- placeholder="/dev/tty.usbmodem5A460816421"
100
- className="bg-gray-800 border-gray-700 text-white"
101
- />
102
- </div>
103
- <div className="space-y-2">
104
- <Label htmlFor="recordLeaderConfig" className="text-sm font-medium text-gray-300">
105
- Leader Calibration Config
106
- </Label>
107
- <Select value={leaderConfig} onValueChange={setLeaderConfig}>
108
- <SelectTrigger className="bg-gray-800 border-gray-700 text-white">
109
- <SelectValue placeholder={isLoadingConfigs ? "Loading configs..." : "Select leader config"} />
110
- </SelectTrigger>
111
- <SelectContent className="bg-gray-800 border-gray-700">
112
- {leaderConfigs.map((config) => (
113
- <SelectItem key={config} value={config} className="text-white hover:bg-gray-700">
114
- {config}
115
- </SelectItem>
116
- ))}
117
- </SelectContent>
118
- </Select>
119
- </div>
120
- <div className="space-y-2">
121
- <Label htmlFor="recordFollowerPort" className="text-sm font-medium text-gray-300">
122
- Follower Port
123
- </Label>
124
- <Input
125
- id="recordFollowerPort"
126
- value={followerPort}
127
- onChange={(e) => setFollowerPort(e.target.value)}
128
- placeholder="/dev/tty.usbmodem5A460816621"
129
- className="bg-gray-800 border-gray-700 text-white"
130
- />
131
- </div>
132
- <div className="space-y-2">
133
- <Label htmlFor="recordFollowerConfig" className="text-sm font-medium text-gray-300">
134
- Follower Calibration Config
135
- </Label>
136
- <Select value={followerConfig} onValueChange={setFollowerConfig}>
137
- <SelectTrigger className="bg-gray-800 border-gray-700 text-white">
138
- <SelectValue placeholder={isLoadingConfigs ? "Loading configs..." : "Select follower config"} />
139
- </SelectTrigger>
140
- <SelectContent className="bg-gray-800 border-gray-700">
141
- {followerConfigs.map((config) => (
142
- <SelectItem key={config} value={config} className="text-white hover:bg-gray-700">
143
- {config}
144
- </SelectItem>
145
- ))}
146
- </SelectContent>
147
- </Select>
148
- </div>
149
- </div>
150
  </div>
151
 
152
- <div className="space-y-4">
153
- <h3 className="text-lg font-semibold text-white border-b border-gray-700 pb-2">
154
- Dataset Configuration
155
- </h3>
156
- <div className="grid grid-cols-1 gap-4">
157
- <div className="space-y-2">
158
- <Label htmlFor="datasetRepoId" className="text-sm font-medium text-gray-300">
159
- Dataset Repository ID *
160
- </Label>
161
- <Input
162
- id="datasetRepoId"
163
- value={datasetRepoId}
164
- onChange={(e) => setDatasetRepoId(e.target.value)}
165
- placeholder="username/dataset_name"
166
- className="bg-gray-800 border-gray-700 text-white"
167
- />
168
- </div>
169
- <div className="space-y-2">
170
- <Label htmlFor="singleTask" className="text-sm font-medium text-gray-300">
171
- Task Name *
172
- </Label>
173
- <Input
174
- id="singleTask"
175
- value={singleTask}
176
- onChange={(e) => setSingleTask(e.target.value)}
177
- placeholder="e.g., pick_and_place"
178
- className="bg-gray-800 border-gray-700 text-white"
179
- />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
180
  </div>
181
- <div className="space-y-2">
182
- <Label htmlFor="numEpisodes" className="text-sm font-medium text-gray-300">
183
- Number of Episodes
184
- </Label>
185
- <Input
186
- id="numEpisodes"
187
- type="number"
188
- min="1"
189
- max="100"
190
- value={numEpisodes}
191
- onChange={(e) => setNumEpisodes(parseInt(e.target.value))}
192
- className="bg-gray-800 border-gray-700 text-white"
193
- />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
  </div>
195
  </div>
196
  </div>
197
- </div>
198
 
199
- <div className="flex flex-col sm:flex-row gap-4 justify-center pt-4">
200
- <Button
201
- onClick={onStart}
202
- className="w-full sm:w-auto bg-red-500 hover:bg-red-600 text-white px-10 py-6 text-lg transition-all shadow-md shadow-red-500/30 hover:shadow-lg hover:shadow-red-500/40"
203
- disabled={isLoadingConfigs}
204
- >
205
- Start Recording
206
- </Button>
207
- <Button
208
- onClick={() => onOpenChange(false)}
209
- variant="outline"
210
- className="w-full sm:w-auto border-gray-500 hover:border-gray-200 px-10 py-6 text-lg text-zinc-500 bg-zinc-900 hover:bg-zinc-800"
211
- >
212
- Cancel
213
- </Button>
 
214
  </div>
215
- </div>
216
- </DialogContent>
217
- </Dialog>
 
 
 
 
 
 
 
 
 
 
 
 
 
218
  );
219
  };
220
-
221
  export default RecordingModal;
 
1
+ import React, { useState, useEffect } from "react";
 
2
  import { Button } from "@/components/ui/button";
3
  import { Input } from "@/components/ui/input";
4
  import { Label } from "@/components/ui/label";
 
16
  DialogTitle,
17
  DialogDescription,
18
  } from "@/components/ui/dialog";
19
+ import { QrCode } from "lucide-react";
20
+ import PortDetectionModal from "@/components/ui/PortDetectionModal";
21
+ import PortDetectionButton from "@/components/ui/PortDetectionButton";
22
+ import QrCodeModal from "@/components/recording/QrCodeModal";
23
+ import { useApi } from "@/contexts/ApiContext";
24
  interface RecordingModalProps {
25
  open: boolean;
26
  onOpenChange: (open: boolean) => void;
 
43
  isLoadingConfigs: boolean;
44
  onStart: () => void;
45
  }
 
46
  const RecordingModal: React.FC<RecordingModalProps> = ({
47
  open,
48
  onOpenChange,
 
65
  isLoadingConfigs,
66
  onStart,
67
  }) => {
68
+ const { baseUrl, fetchWithHeaders } = useApi();
69
+ const [showPortDetection, setShowPortDetection] = useState(false);
70
+ const [detectionRobotType, setDetectionRobotType] = useState<
71
+ "leader" | "follower"
72
+ >("leader");
73
+ const [showQrCodeModal, setShowQrCodeModal] = useState(false);
74
+ const [sessionId, setSessionId] = useState("");
75
+
76
+ // Load saved ports on component mount
77
+ useEffect(() => {
78
+ const loadSavedPorts = async () => {
79
+ try {
80
+ // Load leader port
81
+ const leaderResponse = await fetchWithHeaders(
82
+ `${baseUrl}/robot-port/leader`
83
+ );
84
+ const leaderData = await leaderResponse.json();
85
+ if (leaderData.status === "success" && leaderData.default_port) {
86
+ setLeaderPort(leaderData.default_port);
87
+ }
88
+
89
+ // Load follower port
90
+ const followerResponse = await fetchWithHeaders(
91
+ `${baseUrl}/robot-port/follower`
92
+ );
93
+ const followerData = await followerResponse.json();
94
+ if (followerData.status === "success" && followerData.default_port) {
95
+ setFollowerPort(followerData.default_port);
96
+ }
97
+ } catch (error) {
98
+ console.error("Error loading saved ports:", error);
99
+ }
100
+ };
101
+ if (open) {
102
+ loadSavedPorts();
103
+ }
104
+ }, [open, setLeaderPort, setFollowerPort]);
105
+ const handlePortDetection = (robotType: "leader" | "follower") => {
106
+ setDetectionRobotType(robotType);
107
+ setShowPortDetection(true);
108
+ };
109
+ const handlePortDetected = (port: string) => {
110
+ if (detectionRobotType === "leader") {
111
+ setLeaderPort(port);
112
+ } else {
113
+ setFollowerPort(port);
114
+ }
115
+ };
116
+ const handleQrCodeClick = () => {
117
+ // Generate a session ID for this recording session
118
+ const newSessionId = `recording_${Date.now()}_${Math.random()
119
+ .toString(36)
120
+ .substr(2, 9)}`;
121
+ setSessionId(newSessionId);
122
+ setShowQrCodeModal(true);
123
+ };
124
  return (
125
+ <>
126
+ <Dialog open={open} onOpenChange={onOpenChange}>
127
+ <DialogContent className="bg-gray-900 border-gray-800 text-white sm:max-w-[600px] p-8 max-h-[90vh] overflow-y-auto">
128
+ <DialogHeader>
129
+ <div className="flex justify-center items-center mb-4">
130
+ <div className="w-8 h-8 bg-red-500 rounded-full flex items-center justify-center">
131
+ <span className="text-white font-bold text-sm">REC</span>
132
+ </div>
133
  </div>
134
+ <DialogTitle className="text-white text-center text-2xl font-bold">
135
+ Configure Recording
136
+ </DialogTitle>
137
+ </DialogHeader>
138
+ <div className="space-y-6 py-4">
139
+ <DialogDescription className="text-gray-400 text-base leading-relaxed text-center">
140
+ Configure the robot arm settings and dataset parameters for
141
+ recording.
142
+ </DialogDescription>
 
143
 
144
+ <div className="border-y border-gray-700 py-6 flex flex-col items-center gap-4 bg-gray-800/50 rounded-lg">
145
+ <h3 className="text-lg font-semibold text-white">
146
+ Need an extra angle?
 
147
  </h3>
148
+ <p className="text-sm text-gray-400 -mt-2">
149
+ Add your phone as a secondary camera.
150
+ </p>
151
+ <Button
152
+ onClick={handleQrCodeClick}
153
+ title="Add Phone Camera"
154
+ className="bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 px-6 flex items-center gap-2 transition-all duration-300 shadow-lg shadow-blue-500/30 hover:shadow-xl hover:shadow-blue-500/40 transform hover:scale-105 rounded-lg"
155
+ >
156
+ <QrCode className="w-5 h-5" />
157
+ <span>Add Phone Camera</span>
158
+ </Button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
  </div>
160
 
161
+ <div className="grid grid-cols-1 gap-6">
162
+ <div className="space-y-4">
163
+ <h3 className="text-lg font-semibold text-white border-b border-gray-700 pb-2">
164
+ Robot Configuration
165
+ </h3>
166
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
167
+ <div className="space-y-2">
168
+ <Label
169
+ htmlFor="recordLeaderPort"
170
+ className="text-sm font-medium text-gray-300"
171
+ >
172
+ Leader Port
173
+ </Label>
174
+ <div className="flex gap-2">
175
+ <Input
176
+ id="recordLeaderPort"
177
+ value={leaderPort}
178
+ onChange={(e) => setLeaderPort(e.target.value)}
179
+ placeholder="/dev/tty.usbmodem5A460816421"
180
+ className="bg-gray-800 border-gray-700 text-white flex-1"
181
+ />
182
+ <PortDetectionButton
183
+ onClick={() => handlePortDetection("leader")}
184
+ robotType="leader"
185
+ />
186
+ </div>
187
+ </div>
188
+ <div className="space-y-2">
189
+ <Label
190
+ htmlFor="recordLeaderConfig"
191
+ className="text-sm font-medium text-gray-300"
192
+ >
193
+ Leader Calibration Config
194
+ </Label>
195
+ <Select
196
+ value={leaderConfig}
197
+ onValueChange={setLeaderConfig}
198
+ >
199
+ <SelectTrigger className="bg-gray-800 border-gray-700 text-white">
200
+ <SelectValue
201
+ placeholder={
202
+ isLoadingConfigs
203
+ ? "Loading configs..."
204
+ : "Select leader config"
205
+ }
206
+ />
207
+ </SelectTrigger>
208
+ <SelectContent className="bg-gray-800 border-gray-700">
209
+ {leaderConfigs.map((config) => (
210
+ <SelectItem
211
+ key={config}
212
+ value={config}
213
+ className="text-white hover:bg-gray-700"
214
+ >
215
+ {config}
216
+ </SelectItem>
217
+ ))}
218
+ </SelectContent>
219
+ </Select>
220
+ </div>
221
+ <div className="space-y-2">
222
+ <Label
223
+ htmlFor="recordFollowerPort"
224
+ className="text-sm font-medium text-gray-300"
225
+ >
226
+ Follower Port
227
+ </Label>
228
+ <div className="flex gap-2">
229
+ <Input
230
+ id="recordFollowerPort"
231
+ value={followerPort}
232
+ onChange={(e) => setFollowerPort(e.target.value)}
233
+ placeholder="/dev/tty.usbmodem5A460816621"
234
+ className="bg-gray-800 border-gray-700 text-white flex-1"
235
+ />
236
+ <PortDetectionButton
237
+ onClick={() => handlePortDetection("follower")}
238
+ robotType="follower"
239
+ />
240
+ </div>
241
+ </div>
242
+ <div className="space-y-2">
243
+ <Label
244
+ htmlFor="recordFollowerConfig"
245
+ className="text-sm font-medium text-gray-300"
246
+ >
247
+ Follower Calibration Config
248
+ </Label>
249
+ <Select
250
+ value={followerConfig}
251
+ onValueChange={setFollowerConfig}
252
+ >
253
+ <SelectTrigger className="bg-gray-800 border-gray-700 text-white">
254
+ <SelectValue
255
+ placeholder={
256
+ isLoadingConfigs
257
+ ? "Loading configs..."
258
+ : "Select follower config"
259
+ }
260
+ />
261
+ </SelectTrigger>
262
+ <SelectContent className="bg-gray-800 border-gray-700">
263
+ {followerConfigs.map((config) => (
264
+ <SelectItem
265
+ key={config}
266
+ value={config}
267
+ className="text-white hover:bg-gray-700"
268
+ >
269
+ {config}
270
+ </SelectItem>
271
+ ))}
272
+ </SelectContent>
273
+ </Select>
274
+ </div>
275
  </div>
276
+ </div>
277
+
278
+ <div className="space-y-4">
279
+ <h3 className="text-lg font-semibold text-white border-b border-gray-700 pb-2">
280
+ Dataset Configuration
281
+ </h3>
282
+ <div className="grid grid-cols-1 gap-4">
283
+ <div className="space-y-2">
284
+ <Label
285
+ htmlFor="datasetRepoId"
286
+ className="text-sm font-medium text-gray-300"
287
+ >
288
+ Dataset Repository ID *
289
+ </Label>
290
+ <Input
291
+ id="datasetRepoId"
292
+ value={datasetRepoId}
293
+ onChange={(e) => setDatasetRepoId(e.target.value)}
294
+ placeholder="username/dataset_name"
295
+ className="bg-gray-800 border-gray-700 text-white"
296
+ />
297
+ </div>
298
+ <div className="space-y-2">
299
+ <Label
300
+ htmlFor="singleTask"
301
+ className="text-sm font-medium text-gray-300"
302
+ >
303
+ Task Name *
304
+ </Label>
305
+ <Input
306
+ id="singleTask"
307
+ value={singleTask}
308
+ onChange={(e) => setSingleTask(e.target.value)}
309
+ placeholder="e.g., pick_and_place"
310
+ className="bg-gray-800 border-gray-700 text-white"
311
+ />
312
+ </div>
313
+ <div className="space-y-2">
314
+ <Label
315
+ htmlFor="numEpisodes"
316
+ className="text-sm font-medium text-gray-300"
317
+ >
318
+ Number of Episodes
319
+ </Label>
320
+ <Input
321
+ id="numEpisodes"
322
+ type="number"
323
+ min="1"
324
+ max="100"
325
+ value={numEpisodes}
326
+ onChange={(e) => setNumEpisodes(parseInt(e.target.value))}
327
+ className="bg-gray-800 border-gray-700 text-white"
328
+ />
329
+ </div>
330
  </div>
331
  </div>
332
  </div>
 
333
 
334
+ <div className="flex flex-col sm:flex-row gap-4 justify-center pt-4">
335
+ <Button
336
+ onClick={onStart}
337
+ className="w-full sm:w-auto bg-red-500 hover:bg-red-600 text-white px-10 py-6 text-lg transition-all shadow-md shadow-red-500/30 hover:shadow-lg hover:shadow-red-500/40"
338
+ disabled={isLoadingConfigs}
339
+ >
340
+ Start Recording
341
+ </Button>
342
+ <Button
343
+ onClick={() => onOpenChange(false)}
344
+ variant="outline"
345
+ className="w-full sm:w-auto border-gray-500 hover:border-gray-200 px-10 py-6 text-lg text-zinc-500 bg-zinc-900 hover:bg-zinc-800"
346
+ >
347
+ Cancel
348
+ </Button>
349
+ </div>
350
  </div>
351
+ </DialogContent>
352
+ </Dialog>
353
+
354
+ <PortDetectionModal
355
+ open={showPortDetection}
356
+ onOpenChange={setShowPortDetection}
357
+ robotType={detectionRobotType}
358
+ onPortDetected={handlePortDetected}
359
+ />
360
+
361
+ <QrCodeModal
362
+ open={showQrCodeModal}
363
+ onOpenChange={setShowQrCodeModal}
364
+ sessionId={sessionId}
365
+ />
366
+ </>
367
  );
368
  };
 
369
  export default RecordingModal;
src/components/landing/RobotModelSelector.tsx CHANGED
@@ -9,31 +9,42 @@ const RobotModelSelector: React.FC<RobotModelSelectorProps> = ({
9
  robotModel,
10
  onValueChange
11
  }) => {
12
- return <>
13
- <h2 className="text-2xl font-semibold text-center text-white">
14
  Select Robot Model
15
  </h2>
16
- <RadioGroup value={robotModel} onValueChange={onValueChange} className="space-y-2">
17
  <div>
18
  <RadioGroupItem value="SO100" id="so100" className="sr-only" />
19
- <Label htmlFor="so100" className="flex items-center space-x-4 p-4 rounded-lg bg-gray-800 border border-gray-700 cursor-pointer transition-all">
20
- <span className="w-6 h-6 rounded-full border-2 border-gray-500 flex items-center justify-center">
21
- {robotModel === "SO100" && <span className="w-3 h-3 rounded-full bg-orange-500" />}
22
  </span>
23
- <span className="text-lg flex-1">SO100/SO101
24
- </span>
25
  </Label>
26
  </div>
27
  <div>
28
- <RadioGroupItem value="LeKiwi" id="lekiwi" className="sr-only" />
29
- <Label htmlFor="lekiwi" className="flex items-center space-x-4 p-4 rounded-lg bg-gray-800 border border-gray-700 cursor-pointer transition-all">
30
- <span className="w-6 h-6 rounded-full border-2 border-gray-500 flex items-center justify-center">
31
- {robotModel === "LeKiwi" && <span className="w-3 h-3 rounded-full bg-orange-500" />}
32
  </span>
33
- <span className="text-lg flex-1">LeKiwi</span>
 
 
 
 
 
 
 
 
 
 
 
 
34
  </Label>
35
  </div>
36
  </RadioGroup>
37
- </>;
38
  };
39
  export default RobotModelSelector;
 
9
  robotModel,
10
  onValueChange
11
  }) => {
12
+ return <div className="flex items-center justify-center gap-6">
13
+ <h2 className="font-semibold text-white text-xl whitespace-nowrap">
14
  Select Robot Model
15
  </h2>
16
+ <RadioGroup value={robotModel} onValueChange={onValueChange} className="flex items-center gap-3">
17
  <div>
18
  <RadioGroupItem value="SO100" id="so100" className="sr-only" />
19
+ <Label htmlFor="so100" className="flex items-center gap-2 px-3 py-2 rounded-full bg-gray-800 border border-gray-700 cursor-pointer transition-all hover:bg-gray-750 min-w-[80px] justify-center">
20
+ <span className="w-4 h-4 rounded-full border-2 border-gray-500 flex items-center justify-center">
21
+ {robotModel === "SO100" && <span className="w-2 h-2 rounded-full bg-orange-500" />}
22
  </span>
23
+ <span className="text-sm font-medium">SO100</span>
 
24
  </Label>
25
  </div>
26
  <div>
27
+ <RadioGroupItem value="SO101" id="so101" className="sr-only" />
28
+ <Label htmlFor="so101" className="flex items-center gap-2 px-3 py-2 rounded-full bg-gray-800 border border-gray-700 cursor-pointer transition-all hover:bg-gray-750 min-w-[80px] justify-center">
29
+ <span className="w-4 h-4 rounded-full border-2 border-gray-500 flex items-center justify-center">
30
+ {robotModel === "SO101" && <span className="w-2 h-2 rounded-full bg-orange-500" />}
31
  </span>
32
+ <span className="text-sm font-medium">SO101</span>
33
+ </Label>
34
+ </div>
35
+ <div>
36
+ <RadioGroupItem value="LeKiwi" id="lekiwi" className="sr-only" disabled />
37
+ <Label htmlFor="lekiwi" className="flex items-center gap-2 rounded-full bg-gray-800 border border-gray-700 cursor-not-allowed opacity-50 transition-all min-w-[80px] justify-center px-[20px] py-[6px]">
38
+ <span className="w-4 h-4 rounded-full border-2 border-gray-500 flex items-center justify-center">
39
+ {robotModel === "LeKiwi" && <span className="w-2 h-2 rounded-full bg-orange-500" />}
40
+ </span>
41
+ <div className="flex flex-col items-center">
42
+ <span className="text-sm font-medium">LeKiwi</span>
43
+ <span className="text-xs text-gray-400">Not available</span>
44
+ </div>
45
  </Label>
46
  </div>
47
  </RadioGroup>
48
+ </div>;
49
  };
50
  export default RobotModelSelector;
src/components/landing/TeleoperationModal.tsx CHANGED
@@ -1,5 +1,4 @@
1
-
2
- import React from 'react';
3
  import { Button } from "@/components/ui/button";
4
  import { Input } from "@/components/ui/input";
5
  import { Label } from "@/components/ui/label";
@@ -18,6 +17,9 @@ import {
18
  DialogDescription,
19
  } from "@/components/ui/dialog";
20
  import { Settings } from "lucide-react";
 
 
 
21
 
22
  interface TeleoperationModalProps {
23
  open: boolean;
@@ -52,6 +54,55 @@ const TeleoperationModal: React.FC<TeleoperationModalProps> = ({
52
  isLoadingConfigs,
53
  onStart,
54
  }) => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  return (
56
  <Dialog open={open} onOpenChange={onOpenChange}>
57
  <DialogContent className="bg-gray-900 border-gray-800 text-white sm:max-w-[600px] p-8">
@@ -71,33 +122,51 @@ const TeleoperationModal: React.FC<TeleoperationModalProps> = ({
71
 
72
  <div className="grid grid-cols-1 gap-6">
73
  <div className="space-y-2">
74
- <Label htmlFor="leaderPort" className="text-sm font-medium text-gray-300">
 
 
 
75
  Leader Port
76
  </Label>
77
- <Input
78
- id="leaderPort"
79
- value={leaderPort}
80
- onChange={(e) => setLeaderPort(e.target.value)}
81
- placeholder="/dev/tty.usbmodem5A460816421"
82
- className="bg-gray-800 border-gray-700 text-white"
83
- />
 
 
 
 
 
 
84
  </div>
85
 
86
  <div className="space-y-2">
87
- <Label htmlFor="leaderConfig" className="text-sm font-medium text-gray-300">
 
 
 
88
  Leader Calibration Config
89
  </Label>
90
  <Select value={leaderConfig} onValueChange={setLeaderConfig}>
91
  <SelectTrigger className="bg-gray-800 border-gray-700 text-white">
92
  <SelectValue
93
  placeholder={
94
- isLoadingConfigs ? "Loading configs..." : "Select leader config"
 
 
95
  }
96
  />
97
  </SelectTrigger>
98
  <SelectContent className="bg-gray-800 border-gray-700">
99
  {leaderConfigs.map((config) => (
100
- <SelectItem key={config} value={config} className="text-white hover:bg-gray-700">
 
 
 
 
101
  {config}
102
  </SelectItem>
103
  ))}
@@ -106,33 +175,51 @@ const TeleoperationModal: React.FC<TeleoperationModalProps> = ({
106
  </div>
107
 
108
  <div className="space-y-2">
109
- <Label htmlFor="followerPort" className="text-sm font-medium text-gray-300">
 
 
 
110
  Follower Port
111
  </Label>
112
- <Input
113
- id="followerPort"
114
- value={followerPort}
115
- onChange={(e) => setFollowerPort(e.target.value)}
116
- placeholder="/dev/tty.usbmodem5A460816621"
117
- className="bg-gray-800 border-gray-700 text-white"
118
- />
 
 
 
 
 
 
119
  </div>
120
 
121
  <div className="space-y-2">
122
- <Label htmlFor="followerConfig" className="text-sm font-medium text-gray-300">
 
 
 
123
  Follower Calibration Config
124
  </Label>
125
  <Select value={followerConfig} onValueChange={setFollowerConfig}>
126
  <SelectTrigger className="bg-gray-800 border-gray-700 text-white">
127
  <SelectValue
128
  placeholder={
129
- isLoadingConfigs ? "Loading configs..." : "Select follower config"
 
 
130
  }
131
  />
132
  </SelectTrigger>
133
  <SelectContent className="bg-gray-800 border-gray-700">
134
  {followerConfigs.map((config) => (
135
- <SelectItem key={config} value={config} className="text-white hover:bg-gray-700">
 
 
 
 
136
  {config}
137
  </SelectItem>
138
  ))}
@@ -159,6 +246,13 @@ const TeleoperationModal: React.FC<TeleoperationModalProps> = ({
159
  </div>
160
  </div>
161
  </DialogContent>
 
 
 
 
 
 
 
162
  </Dialog>
163
  );
164
  };
 
1
+ import React, { useState, useEffect } from "react";
 
2
  import { Button } from "@/components/ui/button";
3
  import { Input } from "@/components/ui/input";
4
  import { Label } from "@/components/ui/label";
 
17
  DialogDescription,
18
  } from "@/components/ui/dialog";
19
  import { Settings } from "lucide-react";
20
+ import PortDetectionModal from "@/components/ui/PortDetectionModal";
21
+ import PortDetectionButton from "@/components/ui/PortDetectionButton";
22
+ import { useApi } from "@/contexts/ApiContext";
23
 
24
  interface TeleoperationModalProps {
25
  open: boolean;
 
54
  isLoadingConfigs,
55
  onStart,
56
  }) => {
57
+ const { baseUrl, fetchWithHeaders } = useApi();
58
+ const [showPortDetection, setShowPortDetection] = useState(false);
59
+ const [detectionRobotType, setDetectionRobotType] = useState<
60
+ "leader" | "follower"
61
+ >("leader");
62
+
63
+ // Load saved ports on component mount
64
+ useEffect(() => {
65
+ const loadSavedPorts = async () => {
66
+ try {
67
+ // Load leader port
68
+ const leaderResponse = await fetchWithHeaders(
69
+ `${baseUrl}/robot-port/leader`
70
+ );
71
+ const leaderData = await leaderResponse.json();
72
+ if (leaderData.status === "success" && leaderData.default_port) {
73
+ setLeaderPort(leaderData.default_port);
74
+ }
75
+
76
+ // Load follower port
77
+ const followerResponse = await fetchWithHeaders(
78
+ `${baseUrl}/robot-port/follower`
79
+ );
80
+ const followerData = await followerResponse.json();
81
+ if (followerData.status === "success" && followerData.default_port) {
82
+ setFollowerPort(followerData.default_port);
83
+ }
84
+ } catch (error) {
85
+ console.error("Error loading saved ports:", error);
86
+ }
87
+ };
88
+
89
+ if (open) {
90
+ loadSavedPorts();
91
+ }
92
+ }, [open, setLeaderPort, setFollowerPort]);
93
+
94
+ const handlePortDetection = (robotType: "leader" | "follower") => {
95
+ setDetectionRobotType(robotType);
96
+ setShowPortDetection(true);
97
+ };
98
+
99
+ const handlePortDetected = (port: string) => {
100
+ if (detectionRobotType === "leader") {
101
+ setLeaderPort(port);
102
+ } else {
103
+ setFollowerPort(port);
104
+ }
105
+ };
106
  return (
107
  <Dialog open={open} onOpenChange={onOpenChange}>
108
  <DialogContent className="bg-gray-900 border-gray-800 text-white sm:max-w-[600px] p-8">
 
122
 
123
  <div className="grid grid-cols-1 gap-6">
124
  <div className="space-y-2">
125
+ <Label
126
+ htmlFor="leaderPort"
127
+ className="text-sm font-medium text-gray-300"
128
+ >
129
  Leader Port
130
  </Label>
131
+ <div className="flex gap-2">
132
+ <Input
133
+ id="leaderPort"
134
+ value={leaderPort}
135
+ onChange={(e) => setLeaderPort(e.target.value)}
136
+ placeholder="/dev/tty.usbmodem5A460816421"
137
+ className="bg-gray-800 border-gray-700 text-white flex-1"
138
+ />
139
+ <PortDetectionButton
140
+ onClick={() => handlePortDetection("leader")}
141
+ robotType="leader"
142
+ />
143
+ </div>
144
  </div>
145
 
146
  <div className="space-y-2">
147
+ <Label
148
+ htmlFor="leaderConfig"
149
+ className="text-sm font-medium text-gray-300"
150
+ >
151
  Leader Calibration Config
152
  </Label>
153
  <Select value={leaderConfig} onValueChange={setLeaderConfig}>
154
  <SelectTrigger className="bg-gray-800 border-gray-700 text-white">
155
  <SelectValue
156
  placeholder={
157
+ isLoadingConfigs
158
+ ? "Loading configs..."
159
+ : "Select leader config"
160
  }
161
  />
162
  </SelectTrigger>
163
  <SelectContent className="bg-gray-800 border-gray-700">
164
  {leaderConfigs.map((config) => (
165
+ <SelectItem
166
+ key={config}
167
+ value={config}
168
+ className="text-white hover:bg-gray-700"
169
+ >
170
  {config}
171
  </SelectItem>
172
  ))}
 
175
  </div>
176
 
177
  <div className="space-y-2">
178
+ <Label
179
+ htmlFor="followerPort"
180
+ className="text-sm font-medium text-gray-300"
181
+ >
182
  Follower Port
183
  </Label>
184
+ <div className="flex gap-2">
185
+ <Input
186
+ id="followerPort"
187
+ value={followerPort}
188
+ onChange={(e) => setFollowerPort(e.target.value)}
189
+ placeholder="/dev/tty.usbmodem5A460816621"
190
+ className="bg-gray-800 border-gray-700 text-white flex-1"
191
+ />
192
+ <PortDetectionButton
193
+ onClick={() => handlePortDetection("follower")}
194
+ robotType="follower"
195
+ />
196
+ </div>
197
  </div>
198
 
199
  <div className="space-y-2">
200
+ <Label
201
+ htmlFor="followerConfig"
202
+ className="text-sm font-medium text-gray-300"
203
+ >
204
  Follower Calibration Config
205
  </Label>
206
  <Select value={followerConfig} onValueChange={setFollowerConfig}>
207
  <SelectTrigger className="bg-gray-800 border-gray-700 text-white">
208
  <SelectValue
209
  placeholder={
210
+ isLoadingConfigs
211
+ ? "Loading configs..."
212
+ : "Select follower config"
213
  }
214
  />
215
  </SelectTrigger>
216
  <SelectContent className="bg-gray-800 border-gray-700">
217
  {followerConfigs.map((config) => (
218
+ <SelectItem
219
+ key={config}
220
+ value={config}
221
+ className="text-white hover:bg-gray-700"
222
+ >
223
  {config}
224
  </SelectItem>
225
  ))}
 
246
  </div>
247
  </div>
248
  </DialogContent>
249
+
250
+ <PortDetectionModal
251
+ open={showPortDetection}
252
+ onOpenChange={setShowPortDetection}
253
+ robotType={detectionRobotType}
254
+ onPortDetected={handlePortDetected}
255
+ />
256
  </Dialog>
257
  );
258
  };
src/components/landing/UsageInstructionsModal.tsx ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+ import {
3
+ Dialog,
4
+ DialogContent,
5
+ DialogDescription,
6
+ DialogHeader,
7
+ DialogTitle,
8
+ } from "@/components/ui/dialog";
9
+ import { Terminal } from "lucide-react";
10
+ import { useApi } from "@/contexts/ApiContext";
11
+
12
+ interface UsageInstructionsModalProps {
13
+ open: boolean;
14
+ onOpenChange: (open: boolean) => void;
15
+ }
16
+
17
+ const UsageInstructionsModal: React.FC<UsageInstructionsModalProps> = ({
18
+ open,
19
+ onOpenChange,
20
+ }) => {
21
+ const { baseUrl } = useApi();
22
+ return (
23
+ <Dialog open={open} onOpenChange={onOpenChange}>
24
+ <DialogContent className="bg-gray-900 border-gray-700 text-gray-300 sm:max-w-xl">
25
+ <DialogHeader className="text-center sm:text-center">
26
+ <DialogTitle className="text-white flex items-center justify-center gap-2 text-xl">
27
+ <Terminal className="w-6 h-6" />
28
+ Running LeLab Locally
29
+ </DialogTitle>
30
+ <DialogDescription>
31
+ Instructions for setting up and running the project on your machine.
32
+ </DialogDescription>
33
+ </DialogHeader>
34
+ <div className="space-y-8 text-sm py-4">
35
+ <div className="space-y-4">
36
+ <h4 className="font-semibold text-gray-100 text-lg mb-2 border-b border-gray-700 pb-2">
37
+ 1. Installation
38
+ </h4>
39
+ <p>
40
+ Clone the repository from GitHub:{" "}
41
+ <a
42
+ href="https://github.com/nicolas-rabault/leLab"
43
+ target="_blank"
44
+ rel="noopener noreferrer"
45
+ className="text-blue-400 hover:underline"
46
+ >
47
+ nicolas-rabault/leLab
48
+ </a>
49
+ </p>
50
+ <pre className="bg-gray-800 p-3 rounded-md text-xs overflow-x-auto text-left">
51
+ <code>
52
+ git clone https://github.com/nicolas-rabault/leLab
53
+ <br />
54
+ cd leLab
55
+ </code>
56
+ </pre>
57
+ <p className="mt-2 font-medium text-gray-200">
58
+ Install dependencies (virtual environment recommended):
59
+ </p>
60
+ <pre className="bg-gray-800 p-3 rounded-md text-xs overflow-x-auto text-left">
61
+ <code>
62
+ # Create and activate virtual environment
63
+ <br />
64
+ python -m venv .venv
65
+ <br />
66
+ source .venv/bin/activate
67
+ <br />
68
+ <br />
69
+ # Install in editable mode
70
+ <br />
71
+ pip install -e .
72
+ </code>
73
+ </pre>
74
+ </div>
75
+ <div className="space-y-4">
76
+ <h4 className="font-semibold text-gray-100 text-lg mb-2 border-b border-gray-700 pb-2">
77
+ 2. Running the Application
78
+ </h4>
79
+ <p>After installation, use one of the command-line tools:</p>
80
+ <ul className="space-y-4 text-xs text-left">
81
+ <li>
82
+ <code className="bg-gray-800 p-1 rounded font-mono text-sm">
83
+ lelab
84
+ </code>
85
+ <p className="text-gray-400 mt-1">
86
+ Starts only the FastAPI backend server on{" "}
87
+ <a
88
+ href={baseUrl}
89
+ target="_blank"
90
+ rel="noopener noreferrer"
91
+ className="text-blue-400 hover:underline"
92
+ >
93
+ {baseUrl}
94
+ </a>
95
+ .
96
+ </p>
97
+ </li>
98
+ <li>
99
+ <code className="bg-gray-800 p-1 rounded font-mono text-sm">
100
+ lelab-fullstack
101
+ </code>
102
+ <p className="text-gray-400 mt-1">
103
+ Starts both FastAPI backend (port 8000) and this Vite frontend
104
+ (port 8080).
105
+ </p>
106
+ </li>
107
+ <li>
108
+ <code className="bg-gray-800 p-1 rounded font-mono text-sm">
109
+ lelab-frontend
110
+ </code>
111
+ <p className="text-gray-400 mt-1">
112
+ Starts only the frontend development server.
113
+ </p>
114
+ </li>
115
+ </ul>
116
+ </div>
117
+ </div>
118
+ </DialogContent>
119
+ </Dialog>
120
+ );
121
+ };
122
+
123
+ export default UsageInstructionsModal;
src/components/landing/types.ts CHANGED
@@ -4,4 +4,5 @@ export interface Action {
4
  description: string;
5
  handler: () => void;
6
  color: string;
 
7
  }
 
4
  description: string;
5
  handler: () => void;
6
  color: string;
7
+ isWorkInProgress?: boolean;
8
  }
src/components/recording/PhoneCameraFeed.tsx ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useRef, useState } from "react";
2
+ import { Smartphone, WifiOff } from "lucide-react";
3
+ import { useApi } from "@/contexts/ApiContext";
4
+
5
+ interface PhoneCameraFeedProps {
6
+ sessionId: string;
7
+ }
8
+
9
+ const PhoneCameraFeed: React.FC<PhoneCameraFeedProps> = ({ sessionId }) => {
10
+ const { wsBaseUrl } = useApi();
11
+ const videoRef = useRef<HTMLVideoElement>(null);
12
+ const [isConnected, setIsConnected] = useState(false);
13
+ const [error, setError] = useState<string | null>(null);
14
+ const wsRef = useRef<WebSocket | null>(null);
15
+ const mediaSourceRef = useRef<MediaSource | null>(null);
16
+
17
+ useEffect(() => {
18
+ if (!sessionId) return;
19
+
20
+ const connectWebSocket = () => {
21
+ try {
22
+ const ws = new WebSocket(`${wsBaseUrl}/ws/camera/${sessionId}`);
23
+ wsRef.current = ws;
24
+
25
+ ws.onopen = () => {
26
+ console.log("Camera feed WebSocket connected");
27
+ setIsConnected(true);
28
+ setError(null);
29
+ };
30
+
31
+ ws.onmessage = (event) => {
32
+ // Handle incoming video chunks
33
+ if (event.data instanceof Blob) {
34
+ handleVideoChunk(event.data);
35
+ } else if (event.data === "camera_connected") {
36
+ setIsConnected(true);
37
+ }
38
+ };
39
+
40
+ ws.onclose = () => {
41
+ console.log("Camera feed WebSocket disconnected");
42
+ setIsConnected(false);
43
+ };
44
+
45
+ ws.onerror = (error) => {
46
+ console.error("Camera feed WebSocket error:", error);
47
+ setError("Failed to connect to camera feed");
48
+ setIsConnected(false);
49
+ };
50
+ } catch (error) {
51
+ console.error("Failed to create WebSocket:", error);
52
+ setError("Failed to establish connection");
53
+ }
54
+ };
55
+
56
+ const handleVideoChunk = (chunk: Blob) => {
57
+ // For now, we'll just log that we received a chunk
58
+ // In a full implementation, this would use MediaSource API
59
+ console.log("Received video chunk:", chunk.size, "bytes");
60
+ };
61
+
62
+ connectWebSocket();
63
+
64
+ return () => {
65
+ if (wsRef.current) {
66
+ wsRef.current.close();
67
+ }
68
+ if (mediaSourceRef.current) {
69
+ mediaSourceRef.current.endOfStream();
70
+ }
71
+ };
72
+ }, [sessionId]);
73
+
74
+ if (error) {
75
+ return (
76
+ <div className="w-full h-32 bg-gray-800 rounded-lg flex flex-col items-center justify-center">
77
+ <WifiOff className="w-6 h-6 text-red-400 mb-2" />
78
+ <p className="text-xs text-red-400 text-center">{error}</p>
79
+ </div>
80
+ );
81
+ }
82
+
83
+ if (!isConnected) {
84
+ return (
85
+ <div className="w-full h-32 bg-gray-800 rounded-lg flex flex-col items-center justify-center">
86
+ <Smartphone className="w-6 h-6 text-gray-500 mb-2" />
87
+ <p className="text-xs text-gray-500 text-center">
88
+ Waiting for phone camera...
89
+ </p>
90
+ <div className="w-4 h-4 border-2 border-gray-600 border-t-blue-500 rounded-full animate-spin mt-2"></div>
91
+ </div>
92
+ );
93
+ }
94
+
95
+ return (
96
+ <div className="w-full h-32 bg-gray-800 rounded-lg overflow-hidden relative">
97
+ <video
98
+ ref={videoRef}
99
+ className="w-full h-full object-cover"
100
+ autoPlay
101
+ muted
102
+ playsInline
103
+ />
104
+ <div className="absolute top-2 left-2">
105
+ <div className="flex items-center gap-1 bg-black/50 px-2 py-1 rounded text-xs">
106
+ <div className="w-1.5 h-1.5 bg-green-400 rounded-full animate-pulse"></div>
107
+ <span className="text-green-400">LIVE</span>
108
+ </div>
109
+ </div>
110
+ <div className="absolute bottom-2 right-2">
111
+ <Smartphone className="w-4 h-4 text-white/70" />
112
+ </div>
113
+ </div>
114
+ );
115
+ };
116
+
117
+ export default PhoneCameraFeed;
src/components/recording/QrCodeModal.tsx ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React, { useEffect, useState } from "react";
3
+ import {
4
+ Dialog,
5
+ DialogContent,
6
+ DialogHeader,
7
+ DialogTitle,
8
+ DialogDescription,
9
+ } from "@/components/ui/dialog";
10
+ import { Button } from "@/components/ui/button";
11
+ import { Copy, Smartphone, QrCode } from "lucide-react";
12
+ import { useToast } from "@/hooks/use-toast";
13
+
14
+ interface QrCodeModalProps {
15
+ open: boolean;
16
+ onOpenChange: (open: boolean) => void;
17
+ sessionId: string;
18
+ }
19
+
20
+ const QrCodeModal: React.FC<QrCodeModalProps> = ({
21
+ open,
22
+ onOpenChange,
23
+ sessionId,
24
+ }) => {
25
+ const { toast } = useToast();
26
+ const [qrCodeUrl, setQrCodeUrl] = useState<string>("");
27
+ const [phoneUrl, setPhoneUrl] = useState<string>("");
28
+
29
+ useEffect(() => {
30
+ if (sessionId) {
31
+ // Get current host URL - in production this would be the deployed URL
32
+ const currentHost = window.location.origin;
33
+ const phonePageUrl = `${currentHost}/phone-camera?sessionId=${sessionId}`;
34
+ setPhoneUrl(phonePageUrl);
35
+
36
+ // Generate QR code using a public QR code API
37
+ const qrApiUrl = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(phonePageUrl)}`;
38
+ setQrCodeUrl(qrApiUrl);
39
+ }
40
+ }, [sessionId]);
41
+
42
+ const copyToClipboard = async () => {
43
+ try {
44
+ await navigator.clipboard.writeText(phoneUrl);
45
+ toast({
46
+ title: "URL Copied!",
47
+ description: "Phone camera URL has been copied to clipboard.",
48
+ });
49
+ } catch (error) {
50
+ toast({
51
+ title: "Copy Failed",
52
+ description: "Could not copy URL to clipboard.",
53
+ variant: "destructive",
54
+ });
55
+ }
56
+ };
57
+
58
+ return (
59
+ <Dialog open={open} onOpenChange={onOpenChange}>
60
+ <DialogContent className="bg-gray-900 border-gray-800 text-white sm:max-w-[500px] p-8">
61
+ <DialogHeader>
62
+ <div className="flex justify-center items-center gap-4 mb-4">
63
+ <div className="w-10 h-10 bg-blue-500 rounded-full flex items-center justify-center">
64
+ <Smartphone className="w-5 h-5 text-white" />
65
+ </div>
66
+ </div>
67
+ <DialogTitle className="text-white text-center text-2xl font-bold">
68
+ Add Phone Camera
69
+ </DialogTitle>
70
+ <DialogDescription className="text-gray-400 text-base leading-relaxed text-center">
71
+ Scan the QR code with your phone to add a camera feed to your recording session.
72
+ </DialogDescription>
73
+ </DialogHeader>
74
+
75
+ <div className="space-y-6 py-4">
76
+ {/* QR Code Display */}
77
+ <div className="flex justify-center">
78
+ {qrCodeUrl ? (
79
+ <div className="bg-white p-4 rounded-lg">
80
+ <img
81
+ src={qrCodeUrl}
82
+ alt="QR Code for phone camera"
83
+ className="w-48 h-48"
84
+ />
85
+ </div>
86
+ ) : (
87
+ <div className="w-48 h-48 bg-gray-800 rounded-lg flex items-center justify-center">
88
+ <QrCode className="w-12 h-12 text-gray-600" />
89
+ </div>
90
+ )}
91
+ </div>
92
+
93
+ {/* Instructions */}
94
+ <div className="space-y-4">
95
+ <h3 className="text-lg font-semibold text-white text-center">
96
+ How to connect your phone camera:
97
+ </h3>
98
+ <ol className="text-sm text-gray-400 space-y-2 list-decimal list-inside">
99
+ <li>Open your phone's camera app</li>
100
+ <li>Scan the QR code above</li>
101
+ <li>Allow camera permissions when prompted</li>
102
+ <li>Your phone camera feed will appear in the recording interface</li>
103
+ </ol>
104
+ </div>
105
+
106
+ {/* Manual URL Option */}
107
+ <div className="space-y-3">
108
+ <h4 className="text-sm font-semibold text-gray-300">
109
+ Or open this URL manually on your phone:
110
+ </h4>
111
+ <div className="flex gap-2">
112
+ <input
113
+ type="text"
114
+ value={phoneUrl}
115
+ readOnly
116
+ className="flex-1 bg-gray-800 border border-gray-700 text-white px-3 py-2 rounded text-sm"
117
+ />
118
+ <Button
119
+ onClick={copyToClipboard}
120
+ variant="outline"
121
+ size="sm"
122
+ className="border-gray-600 hover:border-blue-500 text-gray-300 hover:text-blue-400"
123
+ >
124
+ <Copy className="w-4 h-4" />
125
+ </Button>
126
+ </div>
127
+ </div>
128
+
129
+ {/* Close Button */}
130
+ <div className="flex justify-center pt-4">
131
+ <Button
132
+ onClick={() => onOpenChange(false)}
133
+ variant="outline"
134
+ className="border-gray-500 hover:border-gray-200 px-8 py-2 text-gray-300 hover:text-white"
135
+ >
136
+ Close
137
+ </Button>
138
+ </div>
139
+ </div>
140
+ </DialogContent>
141
+ </Dialog>
142
+ );
143
+ };
144
+
145
+ export default QrCodeModal;
src/components/replay/DatasetSelector.tsx ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React from 'react';
3
+ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
4
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
5
+ import { Database } from 'lucide-react';
6
+
7
+ interface Dataset {
8
+ id: string;
9
+ name: string;
10
+ }
11
+
12
+ interface DatasetSelectorProps {
13
+ datasets: Dataset[];
14
+ selectedDataset: string | null;
15
+ onSelectDataset: (id: string | null) => void;
16
+ }
17
+
18
+ const DatasetSelector: React.FC<DatasetSelectorProps> = ({ datasets, selectedDataset, onSelectDataset }) => {
19
+ return (
20
+ <Card className="bg-gray-900 border-gray-700">
21
+ <CardHeader>
22
+ <CardTitle className="flex items-center gap-3 text-white">
23
+ <Database className="w-5 h-5 text-purple-400" />
24
+ Select Dataset
25
+ </CardTitle>
26
+ </CardHeader>
27
+ <CardContent>
28
+ <Select onValueChange={(value) => onSelectDataset(value)} value={selectedDataset ?? ''}>
29
+ <SelectTrigger className="w-full bg-gray-800 border-gray-600 text-white">
30
+ <SelectValue placeholder="Choose a dataset to replay..." />
31
+ </SelectTrigger>
32
+ <SelectContent className="bg-gray-800 text-white border-gray-600">
33
+ {datasets.map(dataset => (
34
+ <SelectItem key={dataset.id} value={dataset.id} className="focus:bg-gray-700">
35
+ {dataset.name}
36
+ </SelectItem>
37
+ ))}
38
+ </SelectContent>
39
+ </Select>
40
+ </CardContent>
41
+ </Card>
42
+ );
43
+ };
44
+
45
+ export default DatasetSelector;
src/components/replay/EpisodePlayer.tsx ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React from 'react';
3
+ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
4
+ import { Film, ListVideo } from 'lucide-react';
5
+ import PlaybackControls from './PlaybackControls';
6
+ import { ScrollArea } from '@/components/ui/scroll-area';
7
+ import { cn } from '@/lib/utils';
8
+
9
+ interface Episode {
10
+ id: string;
11
+ name: string;
12
+ duration: number;
13
+ }
14
+
15
+ interface EpisodePlayerProps {
16
+ episodes: Episode[];
17
+ selectedEpisode: string | null;
18
+ onSelectEpisode: (id: string | null) => void;
19
+ }
20
+
21
+ const EpisodePlayer: React.FC<EpisodePlayerProps> = ({ episodes, selectedEpisode, onSelectEpisode }) => {
22
+ const currentEpisode = episodes.find(e => e.id === selectedEpisode);
23
+
24
+ return (
25
+ <Card className="bg-gray-900 border-gray-700 flex-1 flex flex-col">
26
+ <CardHeader>
27
+ <CardTitle className="flex items-center gap-3 text-white">
28
+ <ListVideo className="w-5 h-5 text-purple-400" />
29
+ Episodes
30
+ </CardTitle>
31
+ </CardHeader>
32
+ <CardContent className="flex-1 flex flex-col gap-4">
33
+ <ScrollArea className="h-48 pr-4 border border-gray-700 rounded-lg">
34
+ <div className="p-2 space-y-1">
35
+ {episodes.length > 0 ? (
36
+ episodes.map(episode => (
37
+ <button
38
+ key={episode.id}
39
+ onClick={() => onSelectEpisode(episode.id)}
40
+ className={cn(
41
+ "w-full text-left p-2 rounded-md transition-colors text-sm",
42
+ selectedEpisode === episode.id
43
+ ? "bg-purple-500/20 text-purple-300"
44
+ : "hover:bg-gray-800 text-gray-300"
45
+ )}
46
+ >
47
+ {episode.name} ({episode.duration}s)
48
+ </button>
49
+ ))
50
+ ) : (
51
+ <div className="text-center text-gray-500 py-16">
52
+ Select a dataset to see episodes.
53
+ </div>
54
+ )}
55
+ </div>
56
+ </ScrollArea>
57
+
58
+ {currentEpisode ? (
59
+ <div className="border-t border-gray-700 pt-4 flex flex-col gap-4">
60
+ <div className="flex items-center gap-3">
61
+ <Film className="w-5 h-5 text-gray-400" />
62
+ <h3 className="font-semibold">{currentEpisode.name}</h3>
63
+ </div>
64
+ <PlaybackControls duration={currentEpisode.duration} />
65
+ </div>
66
+ ) : (
67
+ <div className="text-center text-gray-500 pt-16 border-t border-gray-700">
68
+ Select an episode to play.
69
+ </div>
70
+ )}
71
+ </CardContent>
72
+ </Card>
73
+ );
74
+ };
75
+
76
+ export default EpisodePlayer;
src/components/replay/PlaybackControls.tsx ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React, { useState, useEffect } from 'react';
3
+ import { Button } from '@/components/ui/button';
4
+ import { Play, Pause, FastForward, Rewind, ChevronsRight, ChevronsLeft } from 'lucide-react';
5
+ import { Slider } from '@/components/ui/slider';
6
+
7
+ interface PlaybackControlsProps {
8
+ duration: number; // in seconds
9
+ }
10
+
11
+ const PlaybackControls: React.FC<PlaybackControlsProps> = ({ duration }) => {
12
+ const [isPlaying, setIsPlaying] = useState(false);
13
+ const [currentTime, setCurrentTime] = useState(0);
14
+ const [playbackSpeed, setPlaybackSpeed] = useState(1);
15
+
16
+ useEffect(() => {
17
+ // Reset time when episode changes (duration changes)
18
+ setCurrentTime(0);
19
+ setIsPlaying(false);
20
+ }, [duration]);
21
+
22
+ // This is a mock playback timer.
23
+ useEffect(() => {
24
+ let interval: NodeJS.Timeout;
25
+ if (isPlaying && currentTime < duration) {
26
+ interval = setInterval(() => {
27
+ setCurrentTime(prev => Math.min(prev + (1 * playbackSpeed), duration));
28
+ }, 1000);
29
+ }
30
+ if (currentTime >= duration) {
31
+ setIsPlaying(false);
32
+ }
33
+ return () => clearInterval(interval);
34
+ }, [isPlaying, duration, playbackSpeed, currentTime]);
35
+
36
+ const formatTime = (time: number) => {
37
+ const minutes = Math.floor(time / 60);
38
+ const seconds = Math.floor(time % 60);
39
+ return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
40
+ };
41
+
42
+ const speedOptions = [0.5, 1, 1.5, 2];
43
+
44
+ return (
45
+ <div className="space-y-3">
46
+ <div className="flex items-center gap-2">
47
+ <Slider
48
+ value={[currentTime]}
49
+ max={duration}
50
+ step={1}
51
+ onValueChange={(value) => setCurrentTime(value[0])}
52
+ className="w-full"
53
+ />
54
+ </div>
55
+ <div className="flex justify-between items-center text-xs text-gray-400">
56
+ <span>{formatTime(currentTime)}</span>
57
+ <span>{formatTime(duration)}</span>
58
+ </div>
59
+ <div className="flex items-center justify-center gap-2">
60
+ <Button variant="ghost" size="icon" className="text-gray-300 hover:text-white hover:bg-gray-700">
61
+ <ChevronsLeft />
62
+ </Button>
63
+ <Button onClick={() => setCurrentTime(t => Math.max(0, t - 5))} variant="ghost" size="icon" className="text-gray-300 hover:text-white hover:bg-gray-700">
64
+ <Rewind />
65
+ </Button>
66
+ <Button onClick={() => setIsPlaying(!isPlaying)} variant="ghost" size="icon" className="h-12 w-12 text-gray-300 hover:text-white hover:bg-gray-700">
67
+ {isPlaying ? <Pause className="h-6 w-6" /> : <Play className="h-6 w-6" />}
68
+ </Button>
69
+ <Button onClick={() => setCurrentTime(t => Math.min(duration, t + 5))} variant="ghost" size="icon" className="text-gray-300 hover:text-white hover:bg-gray-700">
70
+ <FastForward />
71
+ </Button>
72
+ <Button variant="ghost" size="icon" className="text-gray-300 hover:text-white hover:bg-gray-700">
73
+ <ChevronsRight />
74
+ </Button>
75
+ </div>
76
+ <div className="flex justify-center items-center gap-2 pt-2">
77
+ <span className="text-sm text-gray-400">Speed:</span>
78
+ {speedOptions.map(speed => (
79
+ <Button
80
+ key={speed}
81
+ variant={playbackSpeed === speed ? 'secondary' : 'ghost'}
82
+ size="sm"
83
+ onClick={() => setPlaybackSpeed(speed)}
84
+ className="text-xs"
85
+ >
86
+ {speed}x
87
+ </Button>
88
+ ))}
89
+ </div>
90
+ </div>
91
+ );
92
+ };
93
+
94
+ export default PlaybackControls;
src/components/replay/ReplayHeader.tsx ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React from 'react';
3
+ import { useNavigate } from 'react-router-dom';
4
+ import { Button } from '@/components/ui/button';
5
+ import { ArrowLeft } from 'lucide-react';
6
+ import Logo from '@/components/Logo';
7
+
8
+ const ReplayHeader = () => {
9
+ const navigate = useNavigate();
10
+
11
+ return (
12
+ <div className="flex items-center justify-between">
13
+ <div className="flex items-center gap-4 text-3xl">
14
+ <Button variant="ghost" size="icon" onClick={() => navigate("/")} className="text-slate-400 hover:bg-slate-800 hover:text-white rounded-lg">
15
+ <ArrowLeft className="w-5 h-5" />
16
+ </Button>
17
+ <Logo />
18
+ <h1 className="font-bold text-white text-2xl">
19
+ Replay Dataset
20
+ </h1>
21
+ </div>
22
+
23
+ <div className="flex items-center gap-3">
24
+ <div className={`w-3 h-3 rounded-full bg-slate-500`}></div>
25
+ <span className={`font-semibold text-gray-400`}>
26
+ Idle
27
+ </span>
28
+ </div>
29
+ </div>
30
+ );
31
+ };
32
+
33
+ export default ReplayHeader;
src/components/replay/ReplayVisualizer.tsx ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React from "react";
3
+ import { VideoOff } from "lucide-react";
4
+ import { cn } from "@/lib/utils";
5
+ import UrdfViewer from "../UrdfViewer";
6
+ import UrdfProcessorInitializer from "../UrdfProcessorInitializer";
7
+
8
+ interface ReplayVisualizerProps {
9
+ className?: string;
10
+ }
11
+
12
+ const ReplayVisualizer: React.FC<ReplayVisualizerProps> = ({
13
+ className,
14
+ }) => {
15
+ return (
16
+ <div
17
+ className={cn(
18
+ "h-full w-full space-y-4 flex flex-col",
19
+ className
20
+ )}
21
+ >
22
+ <div className="bg-gray-900 rounded-lg p-4 flex-1 flex flex-col border border-gray-700">
23
+ <div className="flex-1 bg-black rounded border border-gray-800 min-h-[50vh]">
24
+ <UrdfProcessorInitializer />
25
+ <UrdfViewer />
26
+ </div>
27
+ </div>
28
+
29
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
30
+ {[1, 2, 3, 4].map((cam) => (
31
+ <div
32
+ key={cam}
33
+ className="aspect-video bg-gray-900 rounded-lg border border-gray-800 flex flex-col items-center justify-center p-2"
34
+ >
35
+ <VideoOff className="h-8 w-8 text-gray-600 mb-2" />
36
+ <span className="text-gray-500 text-xs text-center">
37
+ Camera {cam} Feed
38
+ </span>
39
+ </div>
40
+ ))}
41
+ </div>
42
+ </div>
43
+ );
44
+ };
45
+
46
+ export default ReplayVisualizer;
src/components/test/WebSocketTest.tsx CHANGED
@@ -1,5 +1,6 @@
1
  import React, { useState, useEffect } from "react";
2
  import { Button } from "@/components/ui/button";
 
3
 
4
  interface JointData {
5
  type: "joint_update";
@@ -8,6 +9,7 @@ interface JointData {
8
  }
9
 
10
  const WebSocketTest: React.FC = () => {
 
11
  const [isConnected, setIsConnected] = useState(false);
12
  const [lastMessage, setLastMessage] = useState<JointData | null>(null);
13
  const [connectionStatus, setConnectionStatus] =
@@ -16,13 +18,13 @@ const WebSocketTest: React.FC = () => {
16
 
17
  const connect = () => {
18
  // First test server health
19
- fetch("http://localhost:8000/health")
20
  .then((response) => response.json())
21
  .then((data) => {
22
  console.log("Server health:", data);
23
 
24
  // Now try WebSocket connection
25
- const websocket = new WebSocket("ws://localhost:8000/ws/joint-data");
26
 
27
  websocket.onopen = () => {
28
  console.log("WebSocket connected");
@@ -120,7 +122,7 @@ const WebSocketTest: React.FC = () => {
120
  )}
121
 
122
  <div className="text-sm text-gray-400">
123
- <div>Expected URL: ws://localhost:8000/ws/joint-data</div>
124
  <div>Make sure your FastAPI server is running!</div>
125
  </div>
126
  </div>
 
1
  import React, { useState, useEffect } from "react";
2
  import { Button } from "@/components/ui/button";
3
+ import { useApi } from "@/contexts/ApiContext";
4
 
5
  interface JointData {
6
  type: "joint_update";
 
9
  }
10
 
11
  const WebSocketTest: React.FC = () => {
12
+ const { baseUrl, wsBaseUrl, fetchWithHeaders } = useApi();
13
  const [isConnected, setIsConnected] = useState(false);
14
  const [lastMessage, setLastMessage] = useState<JointData | null>(null);
15
  const [connectionStatus, setConnectionStatus] =
 
18
 
19
  const connect = () => {
20
  // First test server health
21
+ fetchWithHeaders(`${baseUrl}/health`)
22
  .then((response) => response.json())
23
  .then((data) => {
24
  console.log("Server health:", data);
25
 
26
  // Now try WebSocket connection
27
+ const websocket = new WebSocket(`${wsBaseUrl}/ws/joint-data`);
28
 
29
  websocket.onopen = () => {
30
  console.log("WebSocket connected");
 
122
  )}
123
 
124
  <div className="text-sm text-gray-400">
125
+ <div>Expected URL: {wsBaseUrl}/ws/joint-data</div>
126
  <div>Make sure your FastAPI server is running!</div>
127
  </div>
128
  </div>
src/components/ui/PortDetectionButton.tsx ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+ import { Button } from "@/components/ui/button";
3
+ import { Search } from "lucide-react";
4
+
5
+ interface PortDetectionButtonProps {
6
+ onClick: () => void;
7
+ robotType?: "leader" | "follower";
8
+ className?: string;
9
+ }
10
+
11
+ const PortDetectionButton: React.FC<PortDetectionButtonProps> = ({
12
+ onClick,
13
+ robotType,
14
+ className = "",
15
+ }) => {
16
+ return (
17
+ <Button
18
+ type="button"
19
+ onClick={onClick}
20
+ variant="outline"
21
+ size="sm"
22
+ className={`
23
+ h-8 px-2
24
+ border-gray-600 hover:border-blue-500
25
+ text-gray-400 hover:text-blue-400
26
+ bg-gray-800 hover:bg-gray-700
27
+ transition-all duration-200
28
+ ${className}
29
+ `}
30
+ title={`Find ${robotType || "robot"} port automatically`}
31
+ >
32
+ <Search className="w-3 h-3 mr-1" />
33
+ Find
34
+ </Button>
35
+ );
36
+ };
37
+
38
+ export default PortDetectionButton;
src/components/ui/PortDetectionModal.tsx ADDED
@@ -0,0 +1,334 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from "react";
2
+ import { Button } from "@/components/ui/button";
3
+ import {
4
+ Dialog,
5
+ DialogContent,
6
+ DialogHeader,
7
+ DialogTitle,
8
+ DialogDescription,
9
+ } from "@/components/ui/dialog";
10
+ import { Loader2, Search, CheckCircle, AlertCircle } from "lucide-react";
11
+ import { useToast } from "@/hooks/use-toast";
12
+ import { useApi } from "@/contexts/ApiContext";
13
+
14
+ interface PortDetectionModalProps {
15
+ open: boolean;
16
+ onOpenChange: (open: boolean) => void;
17
+ robotType: "leader" | "follower";
18
+ onPortDetected: (port: string) => void;
19
+ }
20
+
21
+ const PortDetectionModal: React.FC<PortDetectionModalProps> = ({
22
+ open,
23
+ onOpenChange,
24
+ robotType,
25
+ onPortDetected,
26
+ }) => {
27
+ const [step, setStep] = useState<
28
+ "instructions" | "detecting" | "success" | "error"
29
+ >("instructions");
30
+ const [detectedPort, setDetectedPort] = useState<string>("");
31
+ const [error, setError] = useState<string>("");
32
+ const [portsBeforeDisconnect, setPortsBeforeDisconnect] = useState<string[]>(
33
+ []
34
+ );
35
+ const { toast } = useToast();
36
+ const { baseUrl, fetchWithHeaders } = useApi();
37
+
38
+ const handleStartDetection = async () => {
39
+ try {
40
+ setStep("detecting");
41
+ setError("");
42
+
43
+ // Start port detection process
44
+ const response = await fetchWithHeaders(
45
+ `${baseUrl}/start-port-detection`,
46
+ {
47
+ method: "POST",
48
+ body: JSON.stringify({
49
+ robot_type: robotType,
50
+ }),
51
+ }
52
+ );
53
+
54
+ const data = await response.json();
55
+
56
+ if (data.status === "success") {
57
+ setPortsBeforeDisconnect(data.data.ports_before);
58
+ // Give user time to disconnect
59
+ setTimeout(() => {
60
+ detectPortAfterDisconnect(data.data.ports_before);
61
+ }, 3000); // 3 second delay to allow disconnection
62
+ } else {
63
+ throw new Error(data.message || "Failed to start port detection");
64
+ }
65
+ } catch (error) {
66
+ console.error("Error starting port detection:", error);
67
+ setError(
68
+ `Error starting detection: ${
69
+ error instanceof Error ? error.message : "Unknown error"
70
+ }`
71
+ );
72
+ setStep("error");
73
+ }
74
+ };
75
+
76
+ const detectPortAfterDisconnect = async (portsBefore: string[]) => {
77
+ try {
78
+ const response = await fetchWithHeaders(
79
+ `${baseUrl}/detect-port-after-disconnect`,
80
+ {
81
+ method: "POST",
82
+ body: JSON.stringify({
83
+ ports_before: portsBefore,
84
+ }),
85
+ }
86
+ );
87
+
88
+ const data = await response.json();
89
+
90
+ if (data.status === "success") {
91
+ setDetectedPort(data.port);
92
+
93
+ // Save the port for future use
94
+ await savePort(data.port);
95
+
96
+ setStep("success");
97
+
98
+ toast({
99
+ title: "Port Detected Successfully",
100
+ description: `${robotType} port detected: ${data.port}`,
101
+ });
102
+ } else {
103
+ throw new Error(data.message || "Failed to detect port");
104
+ }
105
+ } catch (error) {
106
+ console.error("Error detecting port:", error);
107
+ setError(
108
+ `Error detecting port: ${
109
+ error instanceof Error ? error.message : "Unknown error"
110
+ }`
111
+ );
112
+ setStep("error");
113
+ }
114
+ };
115
+
116
+ const savePort = async (port: string) => {
117
+ try {
118
+ await fetchWithHeaders(`${baseUrl}/save-robot-port`, {
119
+ method: "POST",
120
+ body: JSON.stringify({
121
+ robot_type: robotType,
122
+ port: port,
123
+ }),
124
+ });
125
+ } catch (error) {
126
+ console.error("Error saving port:", error);
127
+ // Don't throw here, as the main detection was successful
128
+ }
129
+ };
130
+
131
+ const handleUsePort = () => {
132
+ onPortDetected(detectedPort);
133
+ onOpenChange(false);
134
+ resetModal();
135
+ };
136
+
137
+ const handleClose = () => {
138
+ onOpenChange(false);
139
+ resetModal();
140
+ };
141
+
142
+ const resetModal = () => {
143
+ setStep("instructions");
144
+ setDetectedPort("");
145
+ setError("");
146
+ setPortsBeforeDisconnect([]);
147
+ };
148
+
149
+ const renderStepContent = () => {
150
+ switch (step) {
151
+ case "instructions":
152
+ return (
153
+ <div className="space-y-6">
154
+ <div className="text-center space-y-4">
155
+ <Search className="w-16 h-16 text-blue-500 mx-auto" />
156
+ <div className="space-y-2">
157
+ <h3 className="text-lg font-semibold text-white">
158
+ Detect {robotType === "leader" ? "Leader" : "Follower"} Port
159
+ </h3>
160
+ <p className="text-gray-400">
161
+ Follow these steps to automatically detect the robot port:
162
+ </p>
163
+ </div>
164
+ </div>
165
+
166
+ <div className="bg-gray-800 rounded-lg p-4 space-y-3">
167
+ <div className="flex items-start gap-3">
168
+ <div className="w-6 h-6 bg-blue-500 rounded-full flex items-center justify-center text-white text-sm font-bold">
169
+ 1
170
+ </div>
171
+ <p className="text-gray-300 text-sm">
172
+ Make sure your {robotType} robot arm is currently connected
173
+ </p>
174
+ </div>
175
+ <div className="flex items-start gap-3">
176
+ <div className="w-6 h-6 bg-blue-500 rounded-full flex items-center justify-center text-white text-sm font-bold">
177
+ 2
178
+ </div>
179
+ <p className="text-gray-300 text-sm">
180
+ Click "Start Detection" below
181
+ </p>
182
+ </div>
183
+ <div className="flex items-start gap-3">
184
+ <div className="w-6 h-6 bg-blue-500 rounded-full flex items-center justify-center text-white text-sm font-bold">
185
+ 3
186
+ </div>
187
+ <p className="text-gray-300 text-sm">
188
+ When prompted,{" "}
189
+ <strong>unplug the {robotType} robot arm</strong> from USB
190
+ </p>
191
+ </div>
192
+ <div className="flex items-start gap-3">
193
+ <div className="w-6 h-6 bg-blue-500 rounded-full flex items-center justify-center text-white text-sm font-bold">
194
+ 4
195
+ </div>
196
+ <p className="text-gray-300 text-sm">
197
+ The system will automatically detect which port was
198
+ disconnected
199
+ </p>
200
+ </div>
201
+ </div>
202
+
203
+ <div className="flex gap-4 justify-center">
204
+ <Button
205
+ onClick={handleStartDetection}
206
+ className="bg-blue-500 hover:bg-blue-600 text-white px-8 py-2"
207
+ >
208
+ Start Detection
209
+ </Button>
210
+ <Button
211
+ onClick={handleClose}
212
+ variant="outline"
213
+ className="border-gray-500 hover:border-gray-200 text-gray-300 hover:text-white px-8 py-2"
214
+ >
215
+ Cancel
216
+ </Button>
217
+ </div>
218
+ </div>
219
+ );
220
+
221
+ case "detecting":
222
+ return (
223
+ <div className="space-y-6 text-center">
224
+ <Loader2 className="w-16 h-16 text-blue-500 mx-auto animate-spin" />
225
+ <div className="space-y-2">
226
+ <h3 className="text-lg font-semibold text-white">
227
+ Detecting Port...
228
+ </h3>
229
+ <p className="text-gray-400">
230
+ Please <strong>unplug the {robotType} robot arm</strong> from
231
+ USB now
232
+ </p>
233
+ <p className="text-sm text-gray-500">
234
+ Detection will complete automatically in a few seconds
235
+ </p>
236
+ </div>
237
+ </div>
238
+ );
239
+
240
+ case "success":
241
+ return (
242
+ <div className="space-y-6 text-center">
243
+ <CheckCircle className="w-16 h-16 text-green-500 mx-auto" />
244
+ <div className="space-y-2">
245
+ <h3 className="text-lg font-semibold text-white">
246
+ Port Detected Successfully!
247
+ </h3>
248
+ <p className="text-gray-400">
249
+ {robotType === "leader" ? "Leader" : "Follower"} port detected:
250
+ </p>
251
+ <p className="text-xl font-mono text-green-400 bg-gray-800 px-4 py-2 rounded">
252
+ {detectedPort}
253
+ </p>
254
+ <p className="text-sm text-gray-500">
255
+ This port has been saved as the default for future use.
256
+ <br />
257
+ You can now reconnect your robot arm.
258
+ </p>
259
+ </div>
260
+
261
+ <div className="flex gap-4 justify-center">
262
+ <Button
263
+ onClick={handleUsePort}
264
+ className="bg-green-500 hover:bg-green-600 text-white px-8 py-2"
265
+ >
266
+ Use This Port
267
+ </Button>
268
+ <Button
269
+ onClick={handleClose}
270
+ variant="outline"
271
+ className="border-gray-500 hover:border-gray-200 text-gray-300 hover:text-white px-8 py-2"
272
+ >
273
+ Cancel
274
+ </Button>
275
+ </div>
276
+ </div>
277
+ );
278
+
279
+ case "error":
280
+ return (
281
+ <div className="space-y-6 text-center">
282
+ <AlertCircle className="w-16 h-16 text-red-500 mx-auto" />
283
+ <div className="space-y-2">
284
+ <h3 className="text-lg font-semibold text-white">
285
+ Detection Failed
286
+ </h3>
287
+ <p className="text-gray-400">Unable to detect the robot port.</p>
288
+ <div className="bg-red-900/20 border border-red-800 rounded-lg p-3">
289
+ <p className="text-red-400 text-sm">{error}</p>
290
+ </div>
291
+ </div>
292
+
293
+ <div className="flex gap-4 justify-center">
294
+ <Button
295
+ onClick={() => setStep("instructions")}
296
+ className="bg-blue-500 hover:bg-blue-600 text-white px-8 py-2"
297
+ >
298
+ Try Again
299
+ </Button>
300
+ <Button
301
+ onClick={handleClose}
302
+ variant="outline"
303
+ className="border-gray-500 hover:border-gray-200 text-gray-300 hover:text-white px-8 py-2"
304
+ >
305
+ Cancel
306
+ </Button>
307
+ </div>
308
+ </div>
309
+ );
310
+
311
+ default:
312
+ return null;
313
+ }
314
+ };
315
+
316
+ return (
317
+ <Dialog open={open} onOpenChange={onOpenChange}>
318
+ <DialogContent className="bg-gray-900 border-gray-800 text-white sm:max-w-[500px] p-8">
319
+ <DialogHeader>
320
+ <DialogTitle className="text-white text-center text-xl font-bold">
321
+ Port Detection
322
+ </DialogTitle>
323
+ <DialogDescription className="text-gray-400 text-center">
324
+ Automatically detect the USB port for your robot arm
325
+ </DialogDescription>
326
+ </DialogHeader>
327
+
328
+ <div className="py-4">{renderStepContent()}</div>
329
+ </DialogContent>
330
+ </Dialog>
331
+ );
332
+ };
333
+
334
+ export default PortDetectionModal;
src/contexts/ApiContext.tsx ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, {
2
+ createContext,
3
+ useContext,
4
+ useState,
5
+ useEffect,
6
+ ReactNode,
7
+ } from "react";
8
+
9
+ interface ApiContextType {
10
+ baseUrl: string;
11
+ wsBaseUrl: string;
12
+ isNgrokEnabled: boolean;
13
+ setNgrokUrl: (url: string) => void;
14
+ resetToLocalhost: () => void;
15
+ ngrokUrl: string;
16
+ getHeaders: () => Record<string, string>;
17
+ fetchWithHeaders: (url: string, options?: RequestInit) => Promise<Response>;
18
+ }
19
+
20
+ const ApiContext = createContext<ApiContextType | undefined>(undefined);
21
+
22
+ const DEFAULT_LOCALHOST = "http://localhost:8000";
23
+ const DEFAULT_WS_LOCALHOST = "ws://localhost:8000";
24
+
25
+ interface ApiProviderProps {
26
+ children: ReactNode;
27
+ }
28
+
29
+ export const ApiProvider: React.FC<ApiProviderProps> = ({ children }) => {
30
+ const [ngrokUrl, setNgrokUrlState] = useState<string>("");
31
+ const [isNgrokEnabled, setIsNgrokEnabled] = useState<boolean>(false);
32
+
33
+ // Load saved ngrok configuration on mount
34
+ useEffect(() => {
35
+ const savedNgrokUrl = localStorage.getItem("ngrok-url");
36
+ const savedNgrokEnabled = localStorage.getItem("ngrok-enabled") === "true";
37
+
38
+ if (savedNgrokUrl && savedNgrokEnabled) {
39
+ setNgrokUrlState(savedNgrokUrl);
40
+ setIsNgrokEnabled(true);
41
+ }
42
+ }, []);
43
+
44
+ const setNgrokUrl = (url: string) => {
45
+ // Clean and validate the URL
46
+ let cleanUrl = url.trim();
47
+ if (cleanUrl && !cleanUrl.startsWith("http")) {
48
+ cleanUrl = `https://${cleanUrl}`;
49
+ }
50
+
51
+ // Remove trailing slash
52
+ cleanUrl = cleanUrl.replace(/\/$/, "");
53
+
54
+ setNgrokUrlState(cleanUrl);
55
+ setIsNgrokEnabled(!!cleanUrl);
56
+
57
+ // Persist to localStorage
58
+ if (cleanUrl) {
59
+ localStorage.setItem("ngrok-url", cleanUrl);
60
+ localStorage.setItem("ngrok-enabled", "true");
61
+ } else {
62
+ localStorage.removeItem("ngrok-url");
63
+ localStorage.removeItem("ngrok-enabled");
64
+ }
65
+ };
66
+
67
+ const resetToLocalhost = () => {
68
+ setNgrokUrlState("");
69
+ setIsNgrokEnabled(false);
70
+ localStorage.removeItem("ngrok-url");
71
+ localStorage.removeItem("ngrok-enabled");
72
+ };
73
+
74
+ const baseUrl = isNgrokEnabled && ngrokUrl ? ngrokUrl : DEFAULT_LOCALHOST;
75
+ const wsBaseUrl =
76
+ isNgrokEnabled && ngrokUrl
77
+ ? ngrokUrl.replace("https://", "wss://").replace("http://", "ws://")
78
+ : DEFAULT_WS_LOCALHOST;
79
+
80
+ // Helper function to get headers with ngrok skip warning if needed
81
+ const getHeaders = (): Record<string, string> => {
82
+ const headers: Record<string, string> = {
83
+ "Content-Type": "application/json",
84
+ };
85
+
86
+ // Add ngrok skip warning header when using ngrok
87
+ if (isNgrokEnabled && ngrokUrl) {
88
+ headers["ngrok-skip-browser-warning"] = "true";
89
+ }
90
+
91
+ return headers;
92
+ };
93
+
94
+ // Enhanced fetch function that automatically includes necessary headers
95
+ const fetchWithHeaders = async (
96
+ url: string,
97
+ options: RequestInit = {}
98
+ ): Promise<Response> => {
99
+ const enhancedOptions: RequestInit = {
100
+ ...options,
101
+ headers: {
102
+ ...getHeaders(),
103
+ ...options.headers,
104
+ },
105
+ };
106
+
107
+ return fetch(url, enhancedOptions);
108
+ };
109
+
110
+ return (
111
+ <ApiContext.Provider
112
+ value={{
113
+ baseUrl,
114
+ wsBaseUrl,
115
+ isNgrokEnabled,
116
+ setNgrokUrl,
117
+ resetToLocalhost,
118
+ ngrokUrl,
119
+ getHeaders,
120
+ fetchWithHeaders,
121
+ }}
122
+ >
123
+ {children}
124
+ </ApiContext.Provider>
125
+ );
126
+ };
127
+
128
+ export const useApi = (): ApiContextType => {
129
+ const context = useContext(ApiContext);
130
+ if (context === undefined) {
131
+ throw new Error("useApi must be used within an ApiProvider");
132
+ }
133
+ return context;
134
+ };
src/hooks/useRealTimeJoints.ts CHANGED
@@ -1,5 +1,6 @@
1
  import { useEffect, useRef, useCallback } from "react";
2
  import { URDFViewerElement } from "@/lib/urdfViewerHelpers";
 
3
 
4
  interface JointData {
5
  type: "joint_update";
@@ -16,8 +17,11 @@ interface UseRealTimeJointsProps {
16
  export const useRealTimeJoints = ({
17
  viewerRef,
18
  enabled = true,
19
- websocketUrl = "ws://localhost:8000/ws/joint-data",
20
  }: UseRealTimeJointsProps) => {
 
 
 
21
  const wsRef = useRef<WebSocket | null>(null);
22
  const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
23
  const isConnectedRef = useRef<boolean>(false);
@@ -47,7 +51,7 @@ export const useRealTimeJoints = ({
47
  // First, test if the server is running
48
  const testServerConnection = async () => {
49
  try {
50
- const response = await fetch("http://localhost:8000/health");
51
  if (!response.ok) {
52
  console.error("❌ Server health check failed:", response.status);
53
  return false;
@@ -72,9 +76,9 @@ export const useRealTimeJoints = ({
72
  }
73
 
74
  try {
75
- console.log("🔗 Connecting to WebSocket:", websocketUrl);
76
 
77
- const ws = new WebSocket(websocketUrl);
78
  wsRef.current = ws;
79
 
80
  ws.onopen = () => {
 
1
  import { useEffect, useRef, useCallback } from "react";
2
  import { URDFViewerElement } from "@/lib/urdfViewerHelpers";
3
+ import { useApi } from "@/contexts/ApiContext";
4
 
5
  interface JointData {
6
  type: "joint_update";
 
17
  export const useRealTimeJoints = ({
18
  viewerRef,
19
  enabled = true,
20
+ websocketUrl,
21
  }: UseRealTimeJointsProps) => {
22
+ const { baseUrl, wsBaseUrl, fetchWithHeaders } = useApi();
23
+ const defaultWebSocketUrl = `${wsBaseUrl}/ws/joint-data`;
24
+ const finalWebSocketUrl = websocketUrl || defaultWebSocketUrl;
25
  const wsRef = useRef<WebSocket | null>(null);
26
  const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
27
  const isConnectedRef = useRef<boolean>(false);
 
51
  // First, test if the server is running
52
  const testServerConnection = async () => {
53
  try {
54
+ const response = await fetchWithHeaders(`${baseUrl}/health`);
55
  if (!response.ok) {
56
  console.error("❌ Server health check failed:", response.status);
57
  return false;
 
76
  }
77
 
78
  try {
79
+ console.log("🔗 Connecting to WebSocket:", finalWebSocketUrl);
80
 
81
+ const ws = new WebSocket(finalWebSocketUrl);
82
  wsRef.current = ws;
83
 
84
  ws.onopen = () => {
src/lib/urdfViewerHelpers.ts CHANGED
@@ -66,26 +66,8 @@ export function createUrdfViewer(
66
  directionalLight.castShadow = true;
67
  viewer.scene.add(directionalLight);
68
 
69
- // Set initial camera position for more zoomed-in view
70
- // Wait for the viewer to be fully initialized before adjusting camera
71
- setTimeout(() => {
72
- if (viewer.camera) {
73
- // Move camera closer to the robot for a more zoomed-in initial view
74
- viewer.camera.position.set(0.5, 0.3, 0.5);
75
- viewer.camera.lookAt(0, 0.2, 0); // Look at center of robot
76
-
77
- // Update controls target if available
78
- if (viewer.controls) {
79
- viewer.controls.target.set(0, 0.2, 0);
80
- viewer.controls.update();
81
- }
82
-
83
- // Trigger a redraw
84
- if (viewer.redraw) {
85
- viewer.redraw();
86
- }
87
- }
88
- }, 100);
89
 
90
  return viewer;
91
  }
@@ -172,27 +154,6 @@ export function setupModelLoading(
172
  viewer.setAttribute("urdf", loadPath);
173
  viewer.setAttribute("package", packagePath);
174
 
175
- // Handle successful loading and set initial zoom
176
- const onLoadSuccess = () => {
177
- // Set more zoomed-in camera position after model loads
178
- setTimeout(() => {
179
- if (viewer.camera && viewer.robot) {
180
- // Position camera closer for better initial view
181
- viewer.camera.position.set(0.4, 0.25, 0.4);
182
- viewer.camera.lookAt(0, 0.15, 0);
183
-
184
- if (viewer.controls) {
185
- viewer.controls.target.set(0, 0.15, 0);
186
- viewer.controls.update();
187
- }
188
-
189
- if (viewer.redraw) {
190
- viewer.redraw();
191
- }
192
- }
193
- }, 50);
194
- };
195
-
196
  // Handle error loading
197
  const onLoadError = () => {
198
  // toast.error("Failed to load model", {
@@ -216,12 +177,11 @@ export function setupModelLoading(
216
  };
217
 
218
  viewer.addEventListener("error", onLoadError);
219
- viewer.addEventListener("urdf-processed", onLoadSuccess);
220
 
221
  // Return cleanup function
222
  return () => {
223
  viewer.removeEventListener("error", onLoadError);
224
- viewer.removeEventListener("urdf-processed", onLoadSuccess);
225
  };
226
  }
227
 
 
66
  directionalLight.castShadow = true;
67
  viewer.scene.add(directionalLight);
68
 
69
+ // Camera position is no longer adjusted automatically to prevent auto-zooming.
70
+ // The user can control the view with the mouse.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
 
72
  return viewer;
73
  }
 
154
  viewer.setAttribute("urdf", loadPath);
155
  viewer.setAttribute("package", packagePath);
156
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157
  // Handle error loading
158
  const onLoadError = () => {
159
  // toast.error("Failed to load model", {
 
177
  };
178
 
179
  viewer.addEventListener("error", onLoadError);
180
+ // The 'urdf-processed' event that handled auto-zooming has been removed.
181
 
182
  // Return cleanup function
183
  return () => {
184
  viewer.removeEventListener("error", onLoadError);
 
185
  };
186
  }
187
 
src/pages/Calibration.tsx CHANGED
@@ -30,6 +30,9 @@ import {
30
  } from "lucide-react";
31
  import { useToast } from "@/hooks/use-toast";
32
  import Logo from "@/components/Logo";
 
 
 
33
 
34
  interface CalibrationStatus {
35
  calibration_active: boolean;
@@ -58,6 +61,7 @@ interface CalibrationConfig {
58
  const Calibration = () => {
59
  const navigate = useNavigate();
60
  const { toast } = useToast();
 
61
 
62
  // Ref for auto-scrolling console
63
  const consoleRef = useRef<HTMLDivElement>(null);
@@ -73,6 +77,12 @@ const Calibration = () => {
73
  []
74
  );
75
 
 
 
 
 
 
 
76
  // Calibration state
77
  const [calibrationStatus, setCalibrationStatus] = useState<CalibrationStatus>(
78
  {
@@ -91,7 +101,7 @@ const Calibration = () => {
91
  // Poll calibration status
92
  const pollStatus = async () => {
93
  try {
94
- const response = await fetch("http://localhost:8000/calibration-status");
95
  if (response.ok) {
96
  const status = await response.json();
97
  setCalibrationStatus(status);
@@ -127,11 +137,8 @@ const Calibration = () => {
127
  };
128
 
129
  try {
130
- const response = await fetch("http://localhost:8000/start-calibration", {
131
  method: "POST",
132
- headers: {
133
- "Content-Type": "application/json",
134
- },
135
  body: JSON.stringify(request),
136
  });
137
 
@@ -163,7 +170,7 @@ const Calibration = () => {
163
  // Stop calibration
164
  const handleStopCalibration = async () => {
165
  try {
166
- const response = await fetch("http://localhost:8000/stop-calibration", {
167
  method: "POST",
168
  });
169
 
@@ -215,8 +222,8 @@ const Calibration = () => {
215
 
216
  setIsLoadingConfigs(true);
217
  try {
218
- const response = await fetch(
219
- `http://localhost:8000/calibration-configs/${deviceType}`
220
  );
221
  const data = await response.json();
222
 
@@ -245,8 +252,8 @@ const Calibration = () => {
245
  if (!deviceType) return;
246
 
247
  try {
248
- const response = await fetch(
249
- `http://localhost:8000/calibration-configs/${deviceType}/${configName}`,
250
  { method: "DELETE" }
251
  );
252
  const data = await response.json();
@@ -281,11 +288,8 @@ const Calibration = () => {
281
  console.log("🔵 Enter button clicked - sending input...");
282
 
283
  try {
284
- const response = await fetch("http://localhost:8000/calibration-input", {
285
  method: "POST",
286
- headers: {
287
- "Content-Type": "application/json",
288
- },
289
  body: JSON.stringify({ input: "\n" }), // Send actual newline character
290
  });
291
 
@@ -356,6 +360,43 @@ const Calibration = () => {
356
  }
357
  }, [calibrationStatus.console_output]);
358
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
359
  // Get status color and icon
360
  const getStatusDisplay = () => {
361
  switch (calibrationStatus.status) {
@@ -455,16 +496,10 @@ const Calibration = () => {
455
  <SelectValue placeholder="Select device type" />
456
  </SelectTrigger>
457
  <SelectContent className="bg-slate-800 border-slate-700 text-white">
458
- <SelectItem
459
- value="robot"
460
- className="hover:bg-slate-700"
461
- >
462
  Robot (Follower)
463
  </SelectItem>
464
- <SelectItem
465
- value="teleop"
466
- className="hover:bg-slate-700"
467
- >
468
  Teleoperator (Leader)
469
  </SelectItem>
470
  </SelectContent>
@@ -479,13 +514,20 @@ const Calibration = () => {
479
  >
480
  Port *
481
  </Label>
482
- <Input
483
- id="port"
484
- value={port}
485
- onChange={(e) => setPort(e.target.value)}
486
- placeholder="/dev/tty.usbmodem..."
487
- className="bg-slate-700 border-slate-600 text-white rounded-md"
488
- />
 
 
 
 
 
 
 
489
  </div>
490
 
491
  {/* Config File Name */}
@@ -728,6 +770,13 @@ const Calibration = () => {
728
  </Card>
729
  </div>
730
  </div>
 
 
 
 
 
 
 
731
  </div>
732
  );
733
  };
 
30
  } from "lucide-react";
31
  import { useToast } from "@/hooks/use-toast";
32
  import Logo from "@/components/Logo";
33
+ import PortDetectionButton from "@/components/ui/PortDetectionButton";
34
+ import PortDetectionModal from "@/components/ui/PortDetectionModal";
35
+ import { useApi } from "@/contexts/ApiContext";
36
 
37
  interface CalibrationStatus {
38
  calibration_active: boolean;
 
61
  const Calibration = () => {
62
  const navigate = useNavigate();
63
  const { toast } = useToast();
64
+ const { baseUrl, fetchWithHeaders } = useApi();
65
 
66
  // Ref for auto-scrolling console
67
  const consoleRef = useRef<HTMLDivElement>(null);
 
77
  []
78
  );
79
 
80
+ // Port detection state
81
+ const [showPortDetection, setShowPortDetection] = useState(false);
82
+ const [detectionRobotType, setDetectionRobotType] = useState<
83
+ "leader" | "follower"
84
+ >("leader");
85
+
86
  // Calibration state
87
  const [calibrationStatus, setCalibrationStatus] = useState<CalibrationStatus>(
88
  {
 
101
  // Poll calibration status
102
  const pollStatus = async () => {
103
  try {
104
+ const response = await fetchWithHeaders(`${baseUrl}/calibration-status`);
105
  if (response.ok) {
106
  const status = await response.json();
107
  setCalibrationStatus(status);
 
137
  };
138
 
139
  try {
140
+ const response = await fetchWithHeaders(`${baseUrl}/start-calibration`, {
141
  method: "POST",
 
 
 
142
  body: JSON.stringify(request),
143
  });
144
 
 
170
  // Stop calibration
171
  const handleStopCalibration = async () => {
172
  try {
173
+ const response = await fetchWithHeaders(`${baseUrl}/stop-calibration`, {
174
  method: "POST",
175
  });
176
 
 
222
 
223
  setIsLoadingConfigs(true);
224
  try {
225
+ const response = await fetchWithHeaders(
226
+ `${baseUrl}/calibration-configs/${deviceType}`
227
  );
228
  const data = await response.json();
229
 
 
252
  if (!deviceType) return;
253
 
254
  try {
255
+ const response = await fetchWithHeaders(
256
+ `${baseUrl}/calibration-configs/${deviceType}/${configName}`,
257
  { method: "DELETE" }
258
  );
259
  const data = await response.json();
 
288
  console.log("🔵 Enter button clicked - sending input...");
289
 
290
  try {
291
+ const response = await fetchWithHeaders(`${baseUrl}/calibration-input`, {
292
  method: "POST",
 
 
 
293
  body: JSON.stringify({ input: "\n" }), // Send actual newline character
294
  });
295
 
 
360
  }
361
  }, [calibrationStatus.console_output]);
362
 
363
+ // Load default port when device type changes
364
+ useEffect(() => {
365
+ const loadDefaultPort = async () => {
366
+ if (!deviceType) return;
367
+
368
+ try {
369
+ const robotType = deviceType === "robot" ? "follower" : "leader";
370
+ const response = await fetchWithHeaders(
371
+ `${baseUrl}/robot-port/${robotType}`
372
+ );
373
+ const data = await response.json();
374
+ if (data.status === "success") {
375
+ // Use saved port if available, otherwise use default port
376
+ const portToUse = data.saved_port || data.default_port;
377
+ if (portToUse) {
378
+ setPort(portToUse);
379
+ }
380
+ }
381
+ } catch (error) {
382
+ console.error("Error loading default port:", error);
383
+ }
384
+ };
385
+
386
+ loadDefaultPort();
387
+ }, [deviceType]);
388
+
389
+ // Handle port detection
390
+ const handlePortDetection = () => {
391
+ const robotType = deviceType === "robot" ? "follower" : "leader";
392
+ setDetectionRobotType(robotType);
393
+ setShowPortDetection(true);
394
+ };
395
+
396
+ const handlePortDetected = (detectedPort: string) => {
397
+ setPort(detectedPort);
398
+ };
399
+
400
  // Get status color and icon
401
  const getStatusDisplay = () => {
402
  switch (calibrationStatus.status) {
 
496
  <SelectValue placeholder="Select device type" />
497
  </SelectTrigger>
498
  <SelectContent className="bg-slate-800 border-slate-700 text-white">
499
+ <SelectItem value="robot" className="hover:bg-slate-700">
 
 
 
500
  Robot (Follower)
501
  </SelectItem>
502
+ <SelectItem value="teleop" className="hover:bg-slate-700">
 
 
 
503
  Teleoperator (Leader)
504
  </SelectItem>
505
  </SelectContent>
 
514
  >
515
  Port *
516
  </Label>
517
+ <div className="flex gap-2">
518
+ <Input
519
+ id="port"
520
+ value={port}
521
+ onChange={(e) => setPort(e.target.value)}
522
+ placeholder="/dev/tty.usbmodem..."
523
+ className="bg-slate-700 border-slate-600 text-white rounded-md flex-1"
524
+ />
525
+ <PortDetectionButton
526
+ onClick={handlePortDetection}
527
+ robotType={deviceType === "robot" ? "follower" : "leader"}
528
+ className="border-slate-600 hover:border-blue-500 text-slate-400 hover:text-blue-400 bg-slate-700 hover:bg-slate-600"
529
+ />
530
+ </div>
531
  </div>
532
 
533
  {/* Config File Name */}
 
770
  </Card>
771
  </div>
772
  </div>
773
+
774
+ <PortDetectionModal
775
+ open={showPortDetection}
776
+ onOpenChange={setShowPortDetection}
777
+ robotType={detectionRobotType}
778
+ onPortDetected={handlePortDetected}
779
+ />
780
  </div>
781
  );
782
  };
src/pages/DirectFollower.tsx ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+ import { useNavigate } from "react-router-dom";
3
+ import VisualizerPanel from "@/components/control/VisualizerPanel";
4
+ import { useToast } from "@/hooks/use-toast";
5
+ import DirectFollowerControlPanel from "@/components/control/DirectFollowerControlPanel";
6
+ import UrdfViewer from "@/components/UrdfViewer";
7
+ import UrdfProcessorInitializer from "@/components/UrdfProcessorInitializer";
8
+ import Logo from "@/components/Logo";
9
+
10
+ const DirectFollowerPage = () => {
11
+ const navigate = useNavigate();
12
+ const { toast } = useToast();
13
+
14
+ const handleGoBack = async () => {
15
+ try {
16
+ // Stop the direct follower control process before navigating back
17
+ console.log("🛑 Stopping direct follower control...");
18
+ const response = await fetch("http://localhost:8000/stop-direct-follower", {
19
+ method: "POST",
20
+ });
21
+
22
+ if (response.ok) {
23
+ const result = await response.json();
24
+ console.log("✅ Direct follower control stopped:", result.message);
25
+ toast({
26
+ title: "Direct Follower Control Stopped",
27
+ description:
28
+ result.message ||
29
+ "Direct follower control has been stopped successfully.",
30
+ });
31
+ } else {
32
+ const errorText = await response.text();
33
+ console.warn(
34
+ "⚠️ Failed to stop direct follower control:",
35
+ response.status,
36
+ errorText
37
+ );
38
+ toast({
39
+ title: "Warning",
40
+ description: `Failed to stop direct follower control properly. Status: ${response.status}`,
41
+ variant: "destructive",
42
+ });
43
+ }
44
+ } catch (error) {
45
+ console.error("❌ Error stopping direct follower control:", error);
46
+ toast({
47
+ title: "Error",
48
+ description: "Failed to communicate with the robot server.",
49
+ variant: "destructive",
50
+ });
51
+ } finally {
52
+ // Navigate back regardless of the result
53
+ navigate("/");
54
+ }
55
+ };
56
+
57
+ return (
58
+ <div className="min-h-screen bg-black flex items-center justify-center p-2 sm:p-4">
59
+ <div className="w-full h-[95vh] flex flex-col lg:flex-row gap-4">
60
+ {/* Left: Visualizer */}
61
+ <div className="flex-1 flex flex-col">
62
+ <div className="bg-gray-900 rounded-lg p-4 flex-1 flex flex-col">
63
+ <div className="flex items-center gap-4 mb-4">
64
+ <button
65
+ onClick={handleGoBack}
66
+ className="text-gray-400 hover:text-white hover:bg-gray-800 p-2 rounded transition-colors"
67
+ aria-label="Go Back"
68
+ >
69
+ <svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
70
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
71
+ </svg>
72
+ </button>
73
+ {/* Only the logo, no "LiveLab" or blue L avatar */}
74
+ <Logo iconOnly />
75
+ <div className="w-px h-6 bg-gray-700" />
76
+ <h2 className="text-xl font-medium text-gray-200">Direct Follower Control</h2>
77
+ </div>
78
+ {/* Visualization area */}
79
+ <div className="flex-1 bg-black rounded border border-gray-800 min-h-[50vh]">
80
+ <div className="w-full h-full flex items-center justify-center">
81
+ <div className="w-full h-full">
82
+ {/* Urdf Viewer only */}
83
+ <VisualizerOnlyUrdf />
84
+ </div>
85
+ </div>
86
+ </div>
87
+ </div>
88
+ </div>
89
+ {/* Right: Control Panel */}
90
+ <div className="lg:w-[400px] flex-shrink-0 flex flex-col">
91
+ <DirectFollowerControlPanel />
92
+ </div>
93
+ </div>
94
+ </div>
95
+ );
96
+ };
97
+
98
+ // Helper component to render just the URDF viewer
99
+ const VisualizerOnlyUrdf: React.FC = () => {
100
+ // Important: Keep this separate from panels with cameras!
101
+ return (
102
+ <div className="w-full h-full">
103
+ {/* Use the same URDF viewer as in Teleoperation */}
104
+ <React.Suspense fallback={<div className="text-gray-400 p-12 text-center">Loading robot model...</div>}>
105
+ <UrdfVisualizerWithProcessor />
106
+ </React.Suspense>
107
+ </div>
108
+ );
109
+ };
110
+
111
+ const UrdfVisualizerWithProcessor: React.FC = () => (
112
+ <>
113
+ <UrdfProcessorInitializer />
114
+ <UrdfViewer />
115
+ </>
116
+ );
117
+
118
+ export default DirectFollowerPage;
src/pages/Landing.tsx CHANGED
@@ -1,4 +1,3 @@
1
-
2
  import React, { useState } from "react";
3
  import { useNavigate } from "react-router-dom";
4
  import { useToast } from "@/hooks/use-toast";
@@ -9,12 +8,18 @@ import ActionList from "@/components/landing/ActionList";
9
  import PermissionModal from "@/components/landing/PermissionModal";
10
  import TeleoperationModal from "@/components/landing/TeleoperationModal";
11
  import RecordingModal from "@/components/landing/RecordingModal";
 
12
  import { Action } from "@/components/landing/types";
 
 
 
13
 
14
  const Landing = () => {
15
- const [robotModel, setRobotModel] = useState("");
16
  const [showPermissionModal, setShowPermissionModal] = useState(false);
17
  const [showTeleoperationModal, setShowTeleoperationModal] = useState(false);
 
 
18
  const [leaderPort, setLeaderPort] = useState("/dev/tty.usbmodem5A460816421");
19
  const [followerPort, setFollowerPort] = useState(
20
  "/dev/tty.usbmodem5A460816621"
@@ -24,6 +29,7 @@ const Landing = () => {
24
  const [leaderConfigs, setLeaderConfigs] = useState<string[]>([]);
25
  const [followerConfigs, setFollowerConfigs] = useState<string[]>([]);
26
  const [isLoadingConfigs, setIsLoadingConfigs] = useState(false);
 
27
 
28
  // Recording state
29
  const [showRecordingModal, setShowRecordingModal] = useState(false);
@@ -39,13 +45,20 @@ const Landing = () => {
39
  const [singleTask, setSingleTask] = useState("");
40
  const [numEpisodes, setNumEpisodes] = useState(5);
41
 
 
 
 
 
 
 
 
42
  const navigate = useNavigate();
43
  const { toast } = useToast();
44
 
45
  const loadConfigs = async () => {
46
  setIsLoadingConfigs(true);
47
  try {
48
- const response = await fetch("http://localhost:8000/get-configs");
49
  const data = await response.json();
50
  setLeaderConfigs(data.leader_configs || []);
51
  setFollowerConfigs(data.follower_configs || []);
@@ -73,16 +86,16 @@ const Landing = () => {
73
  }
74
  };
75
 
76
- const handleRecordingClick = () => {
77
  if (robotModel) {
78
- setShowRecordingModal(true);
79
- loadConfigs();
80
  }
81
  };
82
 
83
- const handleEditDatasetClick = () => {
84
  if (robotModel) {
85
- navigate("/edit-dataset");
 
86
  }
87
  };
88
 
@@ -94,7 +107,14 @@ const Landing = () => {
94
 
95
  const handleReplayDatasetClick = () => {
96
  if (robotModel) {
97
- navigate("/edit-dataset");
 
 
 
 
 
 
 
98
  }
99
  };
100
 
@@ -110,11 +130,8 @@ const Landing = () => {
110
  }
111
 
112
  try {
113
- const response = await fetch("http://localhost:8000/move-arm", {
114
  method: "POST",
115
- headers: {
116
- "Content-Type": "application/json",
117
- },
118
  body: JSON.stringify({
119
  leader_port: leaderPort,
120
  follower_port: followerPort,
@@ -185,6 +202,55 @@ const Landing = () => {
185
  navigate("/recording", { state: { recordingConfig } });
186
  };
187
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
188
  const handlePermissions = async (allow: boolean) => {
189
  setShowPermissionModal(false);
190
  if (allow) {
@@ -219,49 +285,57 @@ const Landing = () => {
219
  };
220
 
221
  const actions: Action[] = [
222
- {
223
- title: "Begin Session",
224
- description: "Start a new control session.",
225
- handler: handleBeginSession,
226
- color: "bg-orange-500 hover:bg-orange-600",
227
- },
228
  {
229
  title: "Teleoperation",
230
  description: "Control the robot arm in real-time.",
231
  handler: handleTeleoperationClick,
232
  color: "bg-yellow-500 hover:bg-yellow-600",
233
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
234
  {
235
  title: "Record Dataset",
236
  description: "Record episodes for training data.",
237
  handler: handleRecordingClick,
238
  color: "bg-red-500 hover:bg-red-600",
239
  },
240
- {
241
- title: "Edit Dataset",
242
- description: "Review and modify recorded datasets.",
243
- handler: handleEditDatasetClick,
244
- color: "bg-blue-500 hover:bg-blue-600",
245
- },
246
  {
247
  title: "Training",
248
  description: "Train a model on your datasets.",
249
  handler: handleTrainingClick,
250
  color: "bg-green-500 hover:bg-green-600",
 
251
  },
252
  {
253
  title: "Replay Dataset",
254
  description: "Replay and analyze recorded datasets.",
255
  handler: handleReplayDatasetClick,
256
  color: "bg-purple-500 hover:bg-purple-600",
 
257
  },
258
  ];
259
 
260
  return (
261
  <div className="min-h-screen bg-black text-white flex flex-col items-center p-4 pt-12 sm:pt-20">
262
- <LandingHeader />
 
 
 
 
 
263
 
264
- <div className="mt-12 p-8 bg-gray-900 rounded-lg shadow-xl w-full max-w-lg space-y-6 border border-gray-700">
265
  <RobotModelSelector
266
  robotModel={robotModel}
267
  onValueChange={setRobotModel}
@@ -275,6 +349,11 @@ const Landing = () => {
275
  onPermissionsResult={handlePermissions}
276
  />
277
 
 
 
 
 
 
278
  <TeleoperationModal
279
  open={showTeleoperationModal}
280
  onOpenChange={setShowTeleoperationModal}
@@ -314,6 +393,22 @@ const Landing = () => {
314
  isLoadingConfigs={isLoadingConfigs}
315
  onStart={handleStartRecording}
316
  />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
317
  </div>
318
  );
319
  };
 
 
1
  import React, { useState } from "react";
2
  import { useNavigate } from "react-router-dom";
3
  import { useToast } from "@/hooks/use-toast";
 
8
  import PermissionModal from "@/components/landing/PermissionModal";
9
  import TeleoperationModal from "@/components/landing/TeleoperationModal";
10
  import RecordingModal from "@/components/landing/RecordingModal";
11
+ import NgrokConfigModal from "@/components/landing/NgrokConfigModal";
12
  import { Action } from "@/components/landing/types";
13
+ import UsageInstructionsModal from "@/components/landing/UsageInstructionsModal";
14
+ import DirectFollowerModal from "@/components/landing/DirectFollowerModal";
15
+ import { useApi } from "@/contexts/ApiContext";
16
 
17
  const Landing = () => {
18
+ const [robotModel, setRobotModel] = useState("SO101");
19
  const [showPermissionModal, setShowPermissionModal] = useState(false);
20
  const [showTeleoperationModal, setShowTeleoperationModal] = useState(false);
21
+ const [showUsageModal, setShowUsageModal] = useState(false);
22
+ const [showNgrokModal, setShowNgrokModal] = useState(false);
23
  const [leaderPort, setLeaderPort] = useState("/dev/tty.usbmodem5A460816421");
24
  const [followerPort, setFollowerPort] = useState(
25
  "/dev/tty.usbmodem5A460816621"
 
29
  const [leaderConfigs, setLeaderConfigs] = useState<string[]>([]);
30
  const [followerConfigs, setFollowerConfigs] = useState<string[]>([]);
31
  const [isLoadingConfigs, setIsLoadingConfigs] = useState(false);
32
+ const { baseUrl, fetchWithHeaders } = useApi();
33
 
34
  // Recording state
35
  const [showRecordingModal, setShowRecordingModal] = useState(false);
 
45
  const [singleTask, setSingleTask] = useState("");
46
  const [numEpisodes, setNumEpisodes] = useState(5);
47
 
48
+ // Direct follower control state
49
+ const [showDirectFollowerModal, setShowDirectFollowerModal] = useState(false);
50
+ const [directFollowerPort, setDirectFollowerPort] = useState(
51
+ "/dev/tty.usbmodem5A460816621"
52
+ );
53
+ const [directFollowerConfig, setDirectFollowerConfig] = useState("");
54
+
55
  const navigate = useNavigate();
56
  const { toast } = useToast();
57
 
58
  const loadConfigs = async () => {
59
  setIsLoadingConfigs(true);
60
  try {
61
+ const response = await fetchWithHeaders(`${baseUrl}/get-configs`);
62
  const data = await response.json();
63
  setLeaderConfigs(data.leader_configs || []);
64
  setFollowerConfigs(data.follower_configs || []);
 
86
  }
87
  };
88
 
89
+ const handleCalibrationClick = () => {
90
  if (robotModel) {
91
+ navigate("/calibration");
 
92
  }
93
  };
94
 
95
+ const handleRecordingClick = () => {
96
  if (robotModel) {
97
+ setShowRecordingModal(true);
98
+ loadConfigs();
99
  }
100
  };
101
 
 
107
 
108
  const handleReplayDatasetClick = () => {
109
  if (robotModel) {
110
+ navigate("/replay-dataset");
111
+ }
112
+ };
113
+
114
+ const handleDirectFollowerClick = () => {
115
+ if (robotModel) {
116
+ setShowDirectFollowerModal(true);
117
+ loadConfigs();
118
  }
119
  };
120
 
 
130
  }
131
 
132
  try {
133
+ const response = await fetchWithHeaders(`${baseUrl}/move-arm`, {
134
  method: "POST",
 
 
 
135
  body: JSON.stringify({
136
  leader_port: leaderPort,
137
  follower_port: followerPort,
 
202
  navigate("/recording", { state: { recordingConfig } });
203
  };
204
 
205
+ const handleStartDirectFollower = async () => {
206
+ if (!directFollowerConfig) {
207
+ toast({
208
+ title: "Missing Configuration",
209
+ description: "Please select a calibration config for the follower.",
210
+ variant: "destructive",
211
+ });
212
+ return;
213
+ }
214
+
215
+ try {
216
+ const response = await fetch("http://localhost:8000/direct-follower", {
217
+ method: "POST",
218
+ headers: {
219
+ "Content-Type": "application/json",
220
+ },
221
+ body: JSON.stringify({
222
+ follower_port: directFollowerPort,
223
+ follower_config: directFollowerConfig,
224
+ }),
225
+ });
226
+
227
+ const data = await response.json();
228
+
229
+ if (response.ok) {
230
+ toast({
231
+ title: "Direct Follower Control Started",
232
+ description:
233
+ data.message || "Successfully started direct follower control.",
234
+ });
235
+ setShowDirectFollowerModal(false);
236
+ navigate("/direct-follower");
237
+ } else {
238
+ toast({
239
+ title: "Error Starting Direct Follower Control",
240
+ description:
241
+ data.message || "Failed to start direct follower control.",
242
+ variant: "destructive",
243
+ });
244
+ }
245
+ } catch (error) {
246
+ toast({
247
+ title: "Connection Error",
248
+ description: "Could not connect to the backend server.",
249
+ variant: "destructive",
250
+ });
251
+ }
252
+ };
253
+
254
  const handlePermissions = async (allow: boolean) => {
255
  setShowPermissionModal(false);
256
  if (allow) {
 
285
  };
286
 
287
  const actions: Action[] = [
 
 
 
 
 
 
288
  {
289
  title: "Teleoperation",
290
  description: "Control the robot arm in real-time.",
291
  handler: handleTeleoperationClick,
292
  color: "bg-yellow-500 hover:bg-yellow-600",
293
  },
294
+ {
295
+ title: "Direct Follower Control",
296
+ description: "Train a model on your datasets.",
297
+ handler: handleDirectFollowerClick,
298
+ color: "bg-blue-500 hover:bg-blue-600",
299
+ },
300
+ {
301
+ title: "Calibration",
302
+ description: "Calibrate robot arm positions.",
303
+ handler: handleCalibrationClick,
304
+ color: "bg-indigo-500 hover:bg-indigo-600",
305
+ isWorkInProgress: true,
306
+ },
307
  {
308
  title: "Record Dataset",
309
  description: "Record episodes for training data.",
310
  handler: handleRecordingClick,
311
  color: "bg-red-500 hover:bg-red-600",
312
  },
 
 
 
 
 
 
313
  {
314
  title: "Training",
315
  description: "Train a model on your datasets.",
316
  handler: handleTrainingClick,
317
  color: "bg-green-500 hover:bg-green-600",
318
+ isWorkInProgress: true,
319
  },
320
  {
321
  title: "Replay Dataset",
322
  description: "Replay and analyze recorded datasets.",
323
  handler: handleReplayDatasetClick,
324
  color: "bg-purple-500 hover:bg-purple-600",
325
+ isWorkInProgress: true,
326
  },
327
  ];
328
 
329
  return (
330
  <div className="min-h-screen bg-black text-white flex flex-col items-center p-4 pt-12 sm:pt-20">
331
+ <div className="w-full max-w-7xl mx-auto px-4 mb-12">
332
+ <LandingHeader
333
+ onShowInstructions={() => setShowUsageModal(true)}
334
+ onShowNgrokConfig={() => setShowNgrokModal(true)}
335
+ />
336
+ </div>
337
 
338
+ <div className="p-8 bg-gray-900 rounded-lg shadow-xl w-full max-w-4xl space-y-6 border border-gray-700">
339
  <RobotModelSelector
340
  robotModel={robotModel}
341
  onValueChange={setRobotModel}
 
349
  onPermissionsResult={handlePermissions}
350
  />
351
 
352
+ <UsageInstructionsModal
353
+ open={showUsageModal}
354
+ onOpenChange={setShowUsageModal}
355
+ />
356
+
357
  <TeleoperationModal
358
  open={showTeleoperationModal}
359
  onOpenChange={setShowTeleoperationModal}
 
393
  isLoadingConfigs={isLoadingConfigs}
394
  onStart={handleStartRecording}
395
  />
396
+
397
+ <DirectFollowerModal
398
+ open={showDirectFollowerModal}
399
+ onOpenChange={setShowDirectFollowerModal}
400
+ followerPort={directFollowerPort}
401
+ setFollowerPort={setDirectFollowerPort}
402
+ followerConfig={directFollowerConfig}
403
+ setFollowerConfig={setDirectFollowerConfig}
404
+ followerConfigs={followerConfigs}
405
+ isLoadingConfigs={isLoadingConfigs}
406
+ onStart={handleStartDirectFollower}
407
+ />
408
+ <NgrokConfigModal
409
+ open={showNgrokModal}
410
+ onOpenChange={setShowNgrokModal}
411
+ />
412
  </div>
413
  );
414
  };
src/pages/PhoneCamera.tsx ADDED
@@ -0,0 +1,223 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useRef, useState } from "react";
2
+ import { useSearchParams } from "react-router-dom";
3
+ import { Camera, WifiOff, Smartphone } from "lucide-react";
4
+ import { Button } from "@/components/ui/button";
5
+ import { useApi } from "@/contexts/ApiContext";
6
+
7
+ const PhoneCamera = () => {
8
+ const [searchParams] = useSearchParams();
9
+ const sessionId = searchParams.get("sessionId");
10
+ const { wsBaseUrl } = useApi();
11
+ const videoRef = useRef<HTMLVideoElement>(null);
12
+ const canvasRef = useRef<HTMLCanvasElement>(null);
13
+ const wsRef = useRef<WebSocket | null>(null);
14
+ const streamRef = useRef<MediaStream | null>(null);
15
+ const [isConnected, setIsConnected] = useState(false);
16
+ const [isStreaming, setIsStreaming] = useState(false);
17
+ const [error, setError] = useState<string | null>(null);
18
+
19
+ useEffect(() => {
20
+ if (!sessionId) {
21
+ setError("No session ID provided");
22
+ return;
23
+ }
24
+
25
+ connectWebSocket();
26
+ return () => {
27
+ cleanup();
28
+ };
29
+ }, [sessionId]);
30
+
31
+ const connectWebSocket = () => {
32
+ try {
33
+ const ws = new WebSocket(`${wsBaseUrl}/ws/camera/${sessionId}`);
34
+ wsRef.current = ws;
35
+
36
+ ws.onopen = () => {
37
+ console.log("Phone camera WebSocket connected");
38
+ setIsConnected(true);
39
+ setError(null);
40
+ // Notify the recording page that a camera is connected
41
+ ws.send("camera_connected");
42
+ };
43
+
44
+ ws.onclose = () => {
45
+ console.log("Phone camera WebSocket disconnected");
46
+ setIsConnected(false);
47
+ };
48
+
49
+ ws.onerror = (error) => {
50
+ console.error("Phone camera WebSocket error:", error);
51
+ setError("Failed to connect to recording session");
52
+ setIsConnected(false);
53
+ };
54
+ } catch (error) {
55
+ console.error("Failed to create WebSocket:", error);
56
+ setError("Failed to establish connection");
57
+ }
58
+ };
59
+
60
+ const startCamera = async () => {
61
+ try {
62
+ const stream = await navigator.mediaDevices.getUserMedia({
63
+ video: {
64
+ facingMode: "environment", // Use back camera
65
+ width: { ideal: 640 },
66
+ height: { ideal: 480 },
67
+ },
68
+ audio: false,
69
+ });
70
+
71
+ streamRef.current = stream;
72
+ if (videoRef.current) {
73
+ videoRef.current.srcObject = stream;
74
+ }
75
+
76
+ setIsStreaming(true);
77
+ startVideoStreaming();
78
+ } catch (error) {
79
+ console.error("Error accessing camera:", error);
80
+ setError("Could not access camera. Please check permissions.");
81
+ }
82
+ };
83
+
84
+ const startVideoStreaming = () => {
85
+ if (!videoRef.current || !canvasRef.current || !wsRef.current) return;
86
+
87
+ const video = videoRef.current;
88
+ const canvas = canvasRef.current;
89
+ const ctx = canvas.getContext("2d");
90
+ const ws = wsRef.current;
91
+
92
+ const sendFrame = () => {
93
+ if (!ctx || !isConnected || !isStreaming) return;
94
+
95
+ // Draw video frame to canvas
96
+ canvas.width = video.videoWidth || 640;
97
+ canvas.height = video.videoHeight || 480;
98
+ ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
99
+
100
+ // Convert to blob and send via WebSocket
101
+ canvas.toBlob(
102
+ (blob) => {
103
+ if (blob && ws.readyState === WebSocket.OPEN) {
104
+ ws.send(blob);
105
+ }
106
+ },
107
+ "image/jpeg",
108
+ 0.7
109
+ );
110
+ };
111
+
112
+ // Send frames at ~10 FPS
113
+ const interval = setInterval(sendFrame, 100);
114
+
115
+ return () => clearInterval(interval);
116
+ };
117
+
118
+ const cleanup = () => {
119
+ if (streamRef.current) {
120
+ streamRef.current.getTracks().forEach((track) => track.stop());
121
+ }
122
+ if (wsRef.current) {
123
+ wsRef.current.close();
124
+ }
125
+ };
126
+
127
+ if (!sessionId) {
128
+ return (
129
+ <div className="min-h-screen bg-black text-white flex items-center justify-center p-4">
130
+ <div className="text-center">
131
+ <WifiOff className="w-16 h-16 text-red-400 mx-auto mb-4" />
132
+ <h1 className="text-2xl font-bold mb-2">Invalid Session</h1>
133
+ <p className="text-gray-400">No session ID provided in the URL.</p>
134
+ </div>
135
+ </div>
136
+ );
137
+ }
138
+
139
+ return (
140
+ <div className="min-h-screen bg-black text-white flex flex-col">
141
+ {/* Header */}
142
+ <div className="bg-gray-900 p-4 border-b border-gray-700">
143
+ <div className="flex items-center justify-center">
144
+ <h1 className="text-2xl font-bold">LeLab Camera</h1>
145
+ </div>
146
+ </div>
147
+
148
+ {/* Camera Section */}
149
+ <div className="flex-1 flex flex-col p-4">
150
+ {error ? (
151
+ <div className="flex-1 flex items-center justify-center">
152
+ <div className="text-center">
153
+ <WifiOff className="w-16 h-16 text-red-400 mx-auto mb-4" />
154
+ <h2 className="text-xl font-bold mb-2">Connection Error</h2>
155
+ <p className="text-gray-400 mb-4">{error}</p>
156
+ <Button
157
+ onClick={connectWebSocket}
158
+ className="bg-blue-500 hover:bg-blue-600"
159
+ >
160
+ Retry Connection
161
+ </Button>
162
+ </div>
163
+ </div>
164
+ ) : !isConnected ? (
165
+ <div className="flex-1 flex items-center justify-center">
166
+ <div className="text-center">
167
+ <div className="w-16 h-16 border-4 border-gray-600 border-t-blue-500 rounded-full animate-spin mx-auto mb-4"></div>
168
+ <h2 className="text-xl font-bold mb-2">Connecting...</h2>
169
+ <p className="text-gray-400">Connecting to recording session</p>
170
+ </div>
171
+ </div>
172
+ ) : !isStreaming ? (
173
+ <div className="flex-1 flex flex-col items-center justify-center">
174
+ <Camera className="w-16 h-16 text-blue-400 mb-4" />
175
+ <h2 className="text-xl font-bold mb-2">Ready to Start</h2>
176
+ <p className="text-gray-400 mb-6 text-center">
177
+ Tap the button below to start your camera and begin streaming to
178
+ the recording session.
179
+ </p>
180
+ <Button
181
+ onClick={startCamera}
182
+ className="bg-blue-500 hover:bg-blue-600 px-8 py-4 text-lg"
183
+ >
184
+ <Camera className="w-5 h-5 mr-2" />
185
+ Start Camera
186
+ </Button>
187
+ </div>
188
+ ) : (
189
+ <div className="flex-1 flex flex-col">
190
+ <div className="flex items-center justify-between mb-4">
191
+ <div className="flex items-center gap-2">
192
+ <div className="w-2 h-2 bg-red-500 rounded-full animate-pulse"></div>
193
+ <span className="text-red-400 font-semibold">STREAMING</span>
194
+ </div>
195
+ <Smartphone className="w-5 h-5 text-blue-400" />
196
+ </div>
197
+
198
+ <div className="flex-1 bg-gray-900 rounded-lg overflow-hidden">
199
+ <video
200
+ ref={videoRef}
201
+ autoPlay
202
+ playsInline
203
+ muted
204
+ className="w-full h-full object-cover"
205
+ />
206
+ </div>
207
+
208
+ <canvas ref={canvasRef} className="hidden" />
209
+
210
+ <div className="mt-4 text-center">
211
+ <p className="text-gray-400 text-sm">
212
+ Your camera is now streaming to the recording session. Keep this
213
+ page open during recording.
214
+ </p>
215
+ </div>
216
+ </div>
217
+ )}
218
+ </div>
219
+ </div>
220
+ );
221
+ };
222
+
223
+ export default PhoneCamera;
src/pages/Recording.tsx CHANGED
@@ -2,14 +2,11 @@ import React, { useState, useEffect } from "react";
2
  import { useNavigate, useLocation } from "react-router-dom";
3
  import { Button } from "@/components/ui/button";
4
  import { useToast } from "@/hooks/use-toast";
5
- import {
6
- ArrowLeft,
7
- Square,
8
- SkipForward,
9
- RotateCcw,
10
- Play,
11
- GraduationCap,
12
- } from "lucide-react";
13
 
14
  interface RecordingConfig {
15
  leader_port: string;
@@ -32,9 +29,11 @@ interface BackendStatus {
32
  current_phase: string;
33
  current_episode?: number;
34
  total_episodes?: number;
 
35
  phase_elapsed_seconds?: number;
36
  phase_time_limit_s?: number;
37
  session_elapsed_seconds?: number;
 
38
  available_controls: {
39
  stop_recording: boolean;
40
  exit_early: boolean;
@@ -46,6 +45,7 @@ const Recording = () => {
46
  const location = useLocation();
47
  const navigate = useNavigate();
48
  const { toast } = useToast();
 
49
 
50
  // Get recording config from navigation state
51
  const recordingConfig = location.state?.recordingConfig as RecordingConfig;
@@ -56,6 +56,11 @@ const Recording = () => {
56
  );
57
  const [recordingSessionStarted, setRecordingSessionStarted] = useState(false);
58
 
 
 
 
 
 
59
  // Redirect if no config provided
60
  useEffect(() => {
61
  if (!recordingConfig) {
@@ -82,19 +87,30 @@ const Recording = () => {
82
  if (recordingSessionStarted) {
83
  const pollStatus = async () => {
84
  try {
85
- const response = await fetch(
86
- "http://localhost:8000/recording-status"
87
  );
88
  if (response.ok) {
89
  const status = await response.json();
90
  setBackendStatus(status);
91
 
92
- // If backend recording stopped, session is complete
93
- if (!status.recording_active && recordingSessionStarted) {
94
- toast({
95
- title: "Recording Complete!",
96
- description: `All episodes have been recorded successfully.`,
97
- });
 
 
 
 
 
 
 
 
 
 
 
98
  }
99
  }
100
  } catch (error) {
@@ -110,7 +126,52 @@ const Recording = () => {
110
  return () => {
111
  if (statusInterval) clearInterval(statusInterval);
112
  };
113
- }, [recordingSessionStarted, toast]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
 
115
  const formatTime = (seconds: number): string => {
116
  const mins = Math.floor(seconds / 60);
@@ -122,11 +183,8 @@ const Recording = () => {
122
 
123
  const startRecordingSession = async () => {
124
  try {
125
- const response = await fetch("http://localhost:8000/start-recording", {
126
  method: "POST",
127
- headers: {
128
- "Content-Type": "application/json",
129
- },
130
  body: JSON.stringify(recordingConfig),
131
  });
132
 
@@ -161,8 +219,8 @@ const Recording = () => {
161
  if (!backendStatus?.available_controls.exit_early) return;
162
 
163
  try {
164
- const response = await fetch(
165
- "http://localhost:8000/recording-exit-early",
166
  {
167
  method: "POST",
168
  }
@@ -203,8 +261,8 @@ const Recording = () => {
203
  if (!backendStatus?.available_controls.rerecord_episode) return;
204
 
205
  try {
206
- const response = await fetch(
207
- "http://localhost:8000/recording-rerecord-episode",
208
  {
209
  method: "POST",
210
  }
@@ -235,7 +293,7 @@ const Recording = () => {
235
  // Equivalent to pressing ESC key in original record.py
236
  const handleStopRecording = async () => {
237
  try {
238
- const response = await fetch("http://localhost:8000/stop-recording", {
239
  method: "POST",
240
  });
241
 
@@ -243,7 +301,17 @@ const Recording = () => {
243
  title: "Recording Stopped",
244
  description: "Recording session has been stopped.",
245
  });
246
- navigate("/");
 
 
 
 
 
 
 
 
 
 
247
  } catch (error) {
248
  toast({
249
  title: "Error",
@@ -332,9 +400,11 @@ const Recording = () => {
332
  Back to Home
333
  </Button>
334
 
335
- <div className="flex items-center gap-3">
336
- <div className={`w-3 h-3 rounded-full ${getDotColor()}`}></div>
337
- <h1 className="text-3xl font-bold">Recording Session</h1>
 
 
338
  </div>
339
  </div>
340
 
@@ -402,155 +472,169 @@ const Recording = () => {
402
  </div>
403
 
404
  {/* Status and Controls */}
405
- <div className="bg-gray-900 rounded-lg p-6 border border-gray-700">
406
- <div className="flex items-center justify-between mb-6">
407
- <div>
408
- <h2 className="text-xl font-semibold text-white mb-2">
409
- Recording Status
410
- </h2>
411
- <div className="flex items-center gap-3">
412
- <div className={`w-2 h-2 rounded-full ${getDotColor()}`}></div>
413
- <span className={`font-semibold ${getStatusColor()}`}>
414
- {getStatusText()}
415
- </span>
 
 
 
 
 
 
416
  </div>
417
  </div>
418
- </div>
419
 
420
- {/* Recording Phase Controls */}
421
- {currentPhase === "recording" && (
422
- <div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
423
- <Button
424
- onClick={handleExitEarly}
425
- disabled={!backendStatus.available_controls.exit_early}
426
- className="bg-green-500 hover:bg-green-600 text-white font-semibold py-4 text-lg disabled:opacity-50"
427
- >
428
- <SkipForward className="w-5 h-5 mr-2" />
429
- End Episode
430
- </Button>
431
-
432
- <Button
433
- onClick={handleRerecordEpisode}
434
- disabled={!backendStatus.available_controls.rerecord_episode}
435
- className="bg-orange-500 hover:bg-orange-600 text-white font-semibold py-4 text-lg disabled:opacity-50"
436
- >
437
- <RotateCcw className="w-5 h-5 mr-2" />
438
- Re-record Episode
439
- </Button>
440
-
441
- <Button
442
- onClick={handleStopRecording}
443
- disabled={!backendStatus.available_controls.stop_recording}
444
- className="bg-red-500 hover:bg-red-600 text-white font-semibold py-4 text-lg disabled:opacity-50"
445
- >
446
- <Square className="w-5 h-5 mr-2" />
447
- Stop Recording
448
- </Button>
449
- </div>
450
- )}
451
 
452
- {/* Reset Phase Controls */}
453
- {currentPhase === "resetting" && (
454
- <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
455
- <Button
456
- onClick={handleExitEarly}
457
- disabled={!backendStatus.available_controls.exit_early}
458
- className="bg-blue-500 hover:bg-blue-600 text-white font-semibold py-6 text-xl disabled:opacity-50"
459
- >
460
- <Play className="w-6 h-6 mr-2" />
461
- Continue to Next Phase
462
- </Button>
463
-
464
- <Button
465
- onClick={handleStopRecording}
466
- disabled={!backendStatus.available_controls.stop_recording}
467
- className="bg-red-500 hover:bg-red-600 text-white font-semibold py-6 text-xl disabled:opacity-50"
468
- >
469
- <Square className="w-5 h-5 mr-2" />
470
- Stop Recording
471
- </Button>
472
- </div>
473
- )}
474
 
475
- {currentPhase === "completed" && (
476
- <div className="text-center">
477
- <p className="text-lg text-green-400 mb-6">
478
- ✅ Recording session completed successfully!
479
- </p>
480
- <p className="text-gray-400 mb-6">
481
- Dataset:{" "}
482
- <span className="text-white font-semibold">
483
- {recordingConfig.dataset_repo_id}
484
- </span>
485
- </p>
486
- <div className="flex flex-col sm:flex-row gap-4 justify-center">
487
  <Button
488
- onClick={() => navigate("/training")}
489
- className="bg-purple-500 hover:bg-purple-600 text-white font-semibold py-3 px-6 text-lg"
 
490
  >
491
- <GraduationCap className="w-5 h-5 mr-2" />
492
- Start Training
493
  </Button>
 
 
 
 
 
 
494
  <Button
495
- onClick={() => navigate("/")}
496
- variant="outline"
497
- className="bg-transparent border-gray-600 text-gray-300 hover:bg-gray-800 hover:text-white py-3 px-6 text-lg"
498
  >
499
- Return to Home
 
500
  </Button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
501
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
502
  </div>
503
  )}
 
504
 
505
- {/* Instructions */}
506
- <div className="mt-6 p-4 bg-gray-800 rounded-lg">
507
- <h3 className="font-semibold mb-2">
508
- {currentPhase === "recording"
509
- ? "Episode Recording Instructions:"
510
- : currentPhase === "resetting"
511
- ? "Environment Reset Instructions:"
512
- : "Session Instructions:"}
513
- </h3>
514
- {currentPhase === "recording" && (
515
- <ul className="text-sm text-gray-400 space-y-1">
516
- <li>
517
- • <strong>End Episode:</strong> Complete current episode and
518
- enter reset phase (Right Arrow)
519
- </li>
520
- <li>
521
- • <strong>Re-record Episode:</strong> Restart current episode
522
- after reset phase (Left Arrow)
523
- </li>
524
- <li>
525
- • <strong>Auto-end:</strong> Episode ends automatically after{" "}
526
- {formatTime(phaseTimeLimit)}
527
- </li>
528
- <li>
529
- • <strong>Stop Recording:</strong> End entire session (ESC
530
- key)
531
- </li>
532
- </ul>
533
- )}
534
- {currentPhase === "resetting" && (
535
- <ul className="text-sm text-gray-400 space-y-1">
536
- <li>
537
- • <strong>Continue to Next Phase:</strong> Skip reset phase
538
- and continue (Right Arrow)
539
- </li>
540
- <li>
541
- • <strong>Auto-continue:</strong> Automatically continues
542
- after {formatTime(phaseTimeLimit)}
543
- </li>
544
- <li>
545
- • <strong>Reset Phase:</strong> Use this time to prepare your
546
- environment for the next episode
547
- </li>
548
- <li>
549
- • <strong>Stop Recording:</strong> End entire session (ESC
550
- key)
551
- </li>
552
- </ul>
553
- )}
554
  </div>
555
  </div>
556
  </div>
 
2
  import { useNavigate, useLocation } from "react-router-dom";
3
  import { Button } from "@/components/ui/button";
4
  import { useToast } from "@/hooks/use-toast";
5
+ import { ArrowLeft, Square, SkipForward, RotateCcw, Play } from "lucide-react";
6
+ import UrdfViewer from "@/components/UrdfViewer";
7
+ import UrdfProcessorInitializer from "@/components/UrdfProcessorInitializer";
8
+ import PhoneCameraFeed from "@/components/recording/PhoneCameraFeed";
9
+ import { useApi } from "@/contexts/ApiContext";
 
 
 
10
 
11
  interface RecordingConfig {
12
  leader_port: string;
 
29
  current_phase: string;
30
  current_episode?: number;
31
  total_episodes?: number;
32
+ saved_episodes?: number;
33
  phase_elapsed_seconds?: number;
34
  phase_time_limit_s?: number;
35
  session_elapsed_seconds?: number;
36
+ session_ended?: boolean;
37
  available_controls: {
38
  stop_recording: boolean;
39
  exit_early: boolean;
 
45
  const location = useLocation();
46
  const navigate = useNavigate();
47
  const { toast } = useToast();
48
+ const { baseUrl, wsBaseUrl, fetchWithHeaders } = useApi();
49
 
50
  // Get recording config from navigation state
51
  const recordingConfig = location.state?.recordingConfig as RecordingConfig;
 
56
  );
57
  const [recordingSessionStarted, setRecordingSessionStarted] = useState(false);
58
 
59
+ // QR Code and camera states
60
+ const [showQrModal, setShowQrModal] = useState(false);
61
+ const [sessionId, setSessionId] = useState<string>("");
62
+ const [phoneCameraConnected, setPhoneCameraConnected] = useState(false);
63
+
64
  // Redirect if no config provided
65
  useEffect(() => {
66
  if (!recordingConfig) {
 
87
  if (recordingSessionStarted) {
88
  const pollStatus = async () => {
89
  try {
90
+ const response = await fetchWithHeaders(
91
+ `${baseUrl}/recording-status`
92
  );
93
  if (response.ok) {
94
  const status = await response.json();
95
  setBackendStatus(status);
96
 
97
+ // If backend recording stopped and session ended, navigate to upload
98
+ if (
99
+ !status.recording_active &&
100
+ status.session_ended &&
101
+ recordingSessionStarted
102
+ ) {
103
+ // Navigate to upload window with dataset info
104
+ const datasetInfo = {
105
+ dataset_repo_id: recordingConfig.dataset_repo_id,
106
+ single_task: recordingConfig.single_task,
107
+ num_episodes: recordingConfig.num_episodes,
108
+ saved_episodes: status.saved_episodes || 0,
109
+ session_elapsed_seconds: status.session_elapsed_seconds || 0,
110
+ };
111
+
112
+ navigate("/upload", { state: { datasetInfo } });
113
+ return; // Stop polling after navigation
114
  }
115
  }
116
  } catch (error) {
 
126
  return () => {
127
  if (statusInterval) clearInterval(statusInterval);
128
  };
129
+ }, [recordingSessionStarted, recordingConfig, navigate, toast]);
130
+
131
+ // Generate session ID when component loads
132
+ useEffect(() => {
133
+ const newSessionId = `session_${Date.now()}_${Math.random()
134
+ .toString(36)
135
+ .substr(2, 9)}`;
136
+ setSessionId(newSessionId);
137
+ }, []);
138
+
139
+ // Listen for phone camera connections
140
+ useEffect(() => {
141
+ if (!sessionId) return;
142
+
143
+ const connectToPhoneCameraWS = () => {
144
+ const ws = new WebSocket(`${wsBaseUrl}/ws/camera/${sessionId}`);
145
+
146
+ ws.onopen = () => {
147
+ console.log("Phone camera WebSocket connected");
148
+ };
149
+
150
+ ws.onmessage = (event) => {
151
+ if (event.data === "camera_connected" && !phoneCameraConnected) {
152
+ setPhoneCameraConnected(true);
153
+ toast({
154
+ title: "Phone Camera Connected!",
155
+ description: "New camera feed detected and connected successfully.",
156
+ });
157
+ }
158
+ };
159
+
160
+ ws.onclose = () => {
161
+ console.log("Phone camera WebSocket disconnected");
162
+ setPhoneCameraConnected(false);
163
+ };
164
+
165
+ ws.onerror = (error) => {
166
+ console.error("Phone camera WebSocket error:", error);
167
+ };
168
+
169
+ return ws;
170
+ };
171
+
172
+ const ws = connectToPhoneCameraWS();
173
+ return () => ws.close();
174
+ }, [sessionId, phoneCameraConnected, toast]);
175
 
176
  const formatTime = (seconds: number): string => {
177
  const mins = Math.floor(seconds / 60);
 
183
 
184
  const startRecordingSession = async () => {
185
  try {
186
+ const response = await fetchWithHeaders(`${baseUrl}/start-recording`, {
187
  method: "POST",
 
 
 
188
  body: JSON.stringify(recordingConfig),
189
  });
190
 
 
219
  if (!backendStatus?.available_controls.exit_early) return;
220
 
221
  try {
222
+ const response = await fetchWithHeaders(
223
+ `${baseUrl}/recording-exit-early`,
224
  {
225
  method: "POST",
226
  }
 
261
  if (!backendStatus?.available_controls.rerecord_episode) return;
262
 
263
  try {
264
+ const response = await fetchWithHeaders(
265
+ `${baseUrl}/recording-rerecord-episode`,
266
  {
267
  method: "POST",
268
  }
 
293
  // Equivalent to pressing ESC key in original record.py
294
  const handleStopRecording = async () => {
295
  try {
296
+ const response = await fetchWithHeaders(`${baseUrl}/stop-recording`, {
297
  method: "POST",
298
  });
299
 
 
301
  title: "Recording Stopped",
302
  description: "Recording session has been stopped.",
303
  });
304
+
305
+ // Navigate to upload window with current dataset info
306
+ const datasetInfo = {
307
+ dataset_repo_id: recordingConfig.dataset_repo_id,
308
+ single_task: recordingConfig.single_task,
309
+ num_episodes: recordingConfig.num_episodes,
310
+ saved_episodes: backendStatus?.saved_episodes || 0,
311
+ session_elapsed_seconds: backendStatus?.session_elapsed_seconds || 0,
312
+ };
313
+
314
+ navigate("/upload", { state: { datasetInfo } });
315
  } catch (error) {
316
  toast({
317
  title: "Error",
 
400
  Back to Home
401
  </Button>
402
 
403
+ <div className="flex items-center gap-6">
404
+ <div className="flex items-center gap-3">
405
+ <div className={`w-3 h-3 rounded-full ${getDotColor()}`}></div>
406
+ <h1 className="text-3xl font-bold">Recording Session</h1>
407
+ </div>
408
  </div>
409
  </div>
410
 
 
472
  </div>
473
 
474
  {/* Status and Controls */}
475
+ <div className="grid grid-cols-1 lg:grid-cols-4 gap-6 mb-8">
476
+ {/* Recording Status - takes up 3 columns */}
477
+ <div className="lg:col-span-3 bg-gray-900 rounded-lg p-6 border border-gray-700">
478
+ {/* Status header */}
479
+ <div className="flex items-center justify-between mb-6">
480
+ <div>
481
+ <h2 className="text-xl font-semibold text-white mb-2">
482
+ Recording Status
483
+ </h2>
484
+ <div className="flex items-center gap-3">
485
+ <div
486
+ className={`w-2 h-2 rounded-full ${getDotColor()}`}
487
+ ></div>
488
+ <span className={`font-semibold ${getStatusColor()}`}>
489
+ {getStatusText()}
490
+ </span>
491
+ </div>
492
  </div>
493
  </div>
 
494
 
495
+ {/* Recording Phase Controls */}
496
+ {currentPhase === "recording" && (
497
+ <div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
498
+ <Button
499
+ onClick={handleExitEarly}
500
+ disabled={!backendStatus.available_controls.exit_early}
501
+ className="bg-green-500 hover:bg-green-600 text-white font-semibold py-4 text-lg disabled:opacity-50"
502
+ >
503
+ <SkipForward className="w-5 h-5 mr-2" />
504
+ End Episode
505
+ </Button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
506
 
507
+ <Button
508
+ onClick={handleRerecordEpisode}
509
+ disabled={!backendStatus.available_controls.rerecord_episode}
510
+ className="bg-orange-500 hover:bg-orange-600 text-white font-semibold py-4 text-lg disabled:opacity-50"
511
+ >
512
+ <RotateCcw className="w-5 h-5 mr-2" />
513
+ Re-record Episode
514
+ </Button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
515
 
 
 
 
 
 
 
 
 
 
 
 
 
516
  <Button
517
+ onClick={handleStopRecording}
518
+ disabled={!backendStatus.available_controls.stop_recording}
519
+ className="bg-red-500 hover:bg-red-600 text-white font-semibold py-4 text-lg disabled:opacity-50"
520
  >
521
+ <Square className="w-5 h-5 mr-2" />
522
+ Stop Recording
523
  </Button>
524
+ </div>
525
+ )}
526
+
527
+ {/* Reset Phase Controls */}
528
+ {currentPhase === "resetting" && (
529
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
530
  <Button
531
+ onClick={handleExitEarly}
532
+ disabled={!backendStatus.available_controls.exit_early}
533
+ className="bg-blue-500 hover:bg-blue-600 text-white font-semibold py-6 text-xl disabled:opacity-50"
534
  >
535
+ <Play className="w-6 h-6 mr-2" />
536
+ Continue to Next Phase
537
  </Button>
538
+
539
+ <Button
540
+ onClick={handleStopRecording}
541
+ disabled={!backendStatus.available_controls.stop_recording}
542
+ className="bg-red-500 hover:bg-red-600 text-white font-semibold py-6 text-xl disabled:opacity-50"
543
+ >
544
+ <Square className="w-5 h-5 mr-2" />
545
+ Stop Recording
546
+ </Button>
547
+ </div>
548
+ )}
549
+
550
+ {currentPhase === "completed" && (
551
+ <div className="text-center">
552
+ <p className="text-lg text-green-400 mb-6">
553
+ ✅ Recording session completed successfully!
554
+ </p>
555
+ <p className="text-gray-400 mb-6">
556
+ Dataset:{" "}
557
+ <span className="text-white font-semibold">
558
+ {recordingConfig.dataset_repo_id}
559
+ </span>
560
+ </p>
561
+ <p className="text-gray-400 mb-6">
562
+ You will be redirected to the upload window shortly...
563
+ </p>
564
  </div>
565
+ )}
566
+
567
+ {/* Instructions */}
568
+ <div className="mt-6 p-4 bg-gray-800 rounded-lg">
569
+ <h3 className="font-semibold mb-2">
570
+ {currentPhase === "recording"
571
+ ? "Episode Recording Instructions:"
572
+ : currentPhase === "resetting"
573
+ ? "Environment Reset Instructions:"
574
+ : "Session Instructions:"}
575
+ </h3>
576
+ {currentPhase === "recording" && (
577
+ <ul className="text-sm text-gray-400 space-y-1">
578
+ <li>
579
+ • <strong>End Episode:</strong> Complete current episode and
580
+ enter reset phase (Right Arrow)
581
+ </li>
582
+ <li>
583
+ • <strong>Re-record Episode:</strong> Restart current
584
+ episode after reset phase (Left Arrow)
585
+ </li>
586
+ <li>
587
+ • <strong>Auto-end:</strong> Episode ends automatically
588
+ after {formatTime(phaseTimeLimit)}
589
+ </li>
590
+ <li>
591
+ • <strong>Stop Recording:</strong> End entire session (ESC
592
+ key)
593
+ </li>
594
+ </ul>
595
+ )}
596
+ {currentPhase === "resetting" && (
597
+ <ul className="text-sm text-gray-400 space-y-1">
598
+ <li>
599
+ • <strong>Continue to Next Phase:</strong> Skip reset phase
600
+ and continue (Right Arrow)
601
+ </li>
602
+ <li>
603
+ • <strong>Auto-continue:</strong> Automatically continues
604
+ after {formatTime(phaseTimeLimit)}
605
+ </li>
606
+ <li>
607
+ • <strong>Reset Phase:</strong> Use this time to prepare
608
+ your environment for the next episode
609
+ </li>
610
+ <li>
611
+ • <strong>Stop Recording:</strong> End entire session (ESC
612
+ key)
613
+ </li>
614
+ </ul>
615
+ )}
616
+ </div>
617
+ </div>
618
+
619
+ {/* Phone Camera Feed - takes up 1 column */}
620
+ {phoneCameraConnected && (
621
+ <div className="bg-gray-900 rounded-lg p-4 border border-gray-700">
622
+ <h3 className="text-sm font-semibold text-gray-400 mb-3">
623
+ Phone Camera
624
+ </h3>
625
+ <PhoneCameraFeed sessionId={sessionId} />
626
  </div>
627
  )}
628
+ </div>
629
 
630
+ {/* URDF Viewer Section */}
631
+ <div className="bg-gray-900 rounded-lg p-6 border border-gray-700 mb-8">
632
+ <h2 className="text-xl font-semibold text-white mb-4">
633
+ Robot Visualizer
634
+ </h2>
635
+ <div className="h-96 bg-gray-800 rounded-lg overflow-hidden">
636
+ <UrdfViewer />
637
+ <UrdfProcessorInitializer />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
638
  </div>
639
  </div>
640
  </div>
src/pages/ReplayDataset.tsx ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React from 'react';
3
+ import ReplayHeader from '@/components/replay/ReplayHeader';
4
+ import DatasetSelector from '@/components/replay/DatasetSelector';
5
+ import EpisodePlayer from '@/components/replay/EpisodePlayer';
6
+ import ReplayVisualizer from '@/components/replay/ReplayVisualizer';
7
+
8
+ // Mock data for now, this would be fetched from the backend
9
+ const mockDatasets = [
10
+ { id: 'dataset-1', name: 'Kitchen Task - Day 1' },
11
+ { id: 'dataset-2', name: 'Assembly Task - Morning' },
12
+ { id: 'dataset-3', name: 'Sorting Cans - Run 4' },
13
+ ];
14
+
15
+ const mockEpisodes = {
16
+ 'dataset-1': [
17
+ { id: 'ep-1', name: 'Episode 1', duration: 62 },
18
+ { id: 'ep-2', name: 'Episode 2', duration: 58 },
19
+ ],
20
+ 'dataset-2': [
21
+ { id: 'ep-3', name: 'Episode 1', duration: 120 },
22
+ ],
23
+ 'dataset-3': [
24
+ { id: 'ep-4', name: 'Episode 1', duration: 45 },
25
+ { id: 'ep-5', name: 'Episode 2', duration: 47 },
26
+ { id: 'ep-6', name: 'Episode 3', duration: 43 },
27
+ ],
28
+ };
29
+
30
+
31
+ const ReplayDataset = () => {
32
+ // Normally you would fetch datasets and handle loading/error states
33
+ const [selectedDataset, setSelectedDataset] = React.useState<string | null>(mockDatasets.length > 0 ? mockDatasets[0].id : null);
34
+ const [selectedEpisode, setSelectedEpisode] = React.useState<string | null>(null);
35
+
36
+ const episodes = selectedDataset ? mockEpisodes[selectedDataset] || [] : [];
37
+
38
+ React.useEffect(() => {
39
+ // When dataset changes, reset episode selection
40
+ setSelectedEpisode(null);
41
+ }, [selectedDataset]);
42
+
43
+ return (
44
+ <div className="min-h-screen bg-black text-white flex flex-col p-4 sm:p-6 lg:p-8">
45
+ <ReplayHeader />
46
+ <div className="flex-1 flex flex-col lg:flex-row gap-6 mt-6">
47
+ <div className="w-full lg:w-1/3 xl:w-1/4 flex flex-col gap-6">
48
+ <DatasetSelector
49
+ datasets={mockDatasets}
50
+ selectedDataset={selectedDataset}
51
+ onSelectDataset={setSelectedDataset}
52
+ />
53
+ <EpisodePlayer
54
+ episodes={episodes}
55
+ selectedEpisode={selectedEpisode}
56
+ onSelectEpisode={setSelectedEpisode}
57
+ />
58
+ </div>
59
+ <div className="flex-1">
60
+ <ReplayVisualizer />
61
+ </div>
62
+ </div>
63
+ </div>
64
+ );
65
+ };
66
+
67
+ export default ReplayDataset;
src/pages/Teleoperation.tsx CHANGED
@@ -2,16 +2,18 @@ import React from "react";
2
  import { useNavigate } from "react-router-dom";
3
  import VisualizerPanel from "@/components/control/VisualizerPanel";
4
  import { useToast } from "@/hooks/use-toast";
 
5
 
6
  const TeleoperationPage = () => {
7
  const navigate = useNavigate();
8
  const { toast } = useToast();
 
9
 
10
  const handleGoBack = async () => {
11
  try {
12
  // Stop the teleoperation process before navigating back
13
  console.log("🛑 Stopping teleoperation...");
14
- const response = await fetch("http://localhost:8000/stop-teleoperation", {
15
  method: "POST",
16
  });
17
 
 
2
  import { useNavigate } from "react-router-dom";
3
  import VisualizerPanel from "@/components/control/VisualizerPanel";
4
  import { useToast } from "@/hooks/use-toast";
5
+ import { useApi } from "@/contexts/ApiContext";
6
 
7
  const TeleoperationPage = () => {
8
  const navigate = useNavigate();
9
  const { toast } = useToast();
10
+ const { baseUrl, fetchWithHeaders } = useApi();
11
 
12
  const handleGoBack = async () => {
13
  try {
14
  // Stop the teleoperation process before navigating back
15
  console.log("🛑 Stopping teleoperation...");
16
+ const response = await fetchWithHeaders(`${baseUrl}/stop-teleoperation`, {
17
  method: "POST",
18
  });
19
 
src/pages/Upload.tsx ADDED
@@ -0,0 +1,443 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from "react";
2
+ import { useNavigate, useLocation } from "react-router-dom";
3
+ import { Button } from "@/components/ui/button";
4
+ import { Input } from "@/components/ui/input";
5
+ import { Label } from "@/components/ui/label";
6
+ import { useToast } from "@/hooks/use-toast";
7
+ import { Checkbox } from "@/components/ui/checkbox";
8
+ import {
9
+ ArrowLeft,
10
+ Upload as UploadIcon,
11
+ Database,
12
+ Tag,
13
+ Eye,
14
+ EyeOff,
15
+ ExternalLink,
16
+ CheckCircle,
17
+ AlertCircle,
18
+ Loader2,
19
+ } from "lucide-react";
20
+ import { useApi } from "@/contexts/ApiContext";
21
+
22
+ interface DatasetInfo {
23
+ dataset_repo_id: string;
24
+ single_task: string;
25
+ num_episodes: number;
26
+ saved_episodes?: number;
27
+ session_elapsed_seconds?: number;
28
+ fps?: number;
29
+ total_frames?: number;
30
+ robot_type?: string;
31
+ }
32
+
33
+ interface UploadConfig {
34
+ tags: string[];
35
+ private: boolean;
36
+ }
37
+
38
+ const Upload = () => {
39
+ const location = useLocation();
40
+ const navigate = useNavigate();
41
+ const { toast } = useToast();
42
+ const { baseUrl, fetchWithHeaders } = useApi();
43
+
44
+ // Get initial dataset info from navigation state
45
+ const initialDatasetInfo = location.state?.datasetInfo as DatasetInfo;
46
+
47
+ // State for actual dataset info (will be loaded from backend)
48
+ const [datasetInfo, setDatasetInfo] = useState<DatasetInfo | null>(null);
49
+ const [isLoadingDatasetInfo, setIsLoadingDatasetInfo] = useState(true);
50
+
51
+ // Upload configuration state
52
+ const [uploadConfig, setUploadConfig] = useState<UploadConfig>({
53
+ tags: ["robotics", "lerobot"],
54
+ private: false,
55
+ });
56
+
57
+ const [tagsInput, setTagsInput] = useState(uploadConfig.tags.join(", "));
58
+ const [isUploading, setIsUploading] = useState(false);
59
+ const [uploadSuccess, setUploadSuccess] = useState(false);
60
+
61
+ // Load actual dataset information from backend
62
+ React.useEffect(() => {
63
+ const loadDatasetInfo = async () => {
64
+ if (!initialDatasetInfo?.dataset_repo_id) {
65
+ toast({
66
+ title: "No Dataset Information",
67
+ description: "Please complete a recording session first.",
68
+ variant: "destructive",
69
+ });
70
+ navigate("/");
71
+ return;
72
+ }
73
+
74
+ try {
75
+ const response = await fetchWithHeaders(`${baseUrl}/dataset-info`, {
76
+ method: "POST",
77
+ body: JSON.stringify({
78
+ dataset_repo_id: initialDatasetInfo.dataset_repo_id,
79
+ }),
80
+ });
81
+
82
+ const data = await response.json();
83
+
84
+ if (response.ok && data.success) {
85
+ // Merge the loaded dataset info with any session info we have
86
+ setDatasetInfo({
87
+ ...data,
88
+ saved_episodes: data.num_episodes, // Use actual episodes from dataset
89
+ session_elapsed_seconds:
90
+ initialDatasetInfo.session_elapsed_seconds || 0,
91
+ });
92
+ } else {
93
+ // Fallback to initial dataset info if backend fails
94
+ toast({
95
+ title: "Warning",
96
+ description:
97
+ "Could not load complete dataset information. Using session data.",
98
+ variant: "destructive",
99
+ });
100
+ setDatasetInfo(initialDatasetInfo);
101
+ }
102
+ } catch (error) {
103
+ console.error("Error loading dataset info:", error);
104
+ // Fallback to initial dataset info
105
+ toast({
106
+ title: "Warning",
107
+ description: "Could not connect to backend. Using session data.",
108
+ variant: "destructive",
109
+ });
110
+ setDatasetInfo(initialDatasetInfo);
111
+ } finally {
112
+ setIsLoadingDatasetInfo(false);
113
+ }
114
+ };
115
+
116
+ loadDatasetInfo();
117
+ }, [initialDatasetInfo, navigate, toast]);
118
+
119
+ const formatDuration = (seconds: number): string => {
120
+ const hours = Math.floor(seconds / 3600);
121
+ const minutes = Math.floor((seconds % 3600) / 60);
122
+ const secs = seconds % 60;
123
+
124
+ if (hours > 0) {
125
+ return `${hours}h ${minutes}m ${secs}s`;
126
+ } else if (minutes > 0) {
127
+ return `${minutes}m ${secs}s`;
128
+ } else {
129
+ return `${secs}s`;
130
+ }
131
+ };
132
+
133
+ const handleUploadToHub = async () => {
134
+ if (!datasetInfo) return;
135
+
136
+ setIsUploading(true);
137
+ try {
138
+ // Parse tags from input
139
+ const tags = tagsInput
140
+ .split(",")
141
+ .map((tag) => tag.trim())
142
+ .filter((tag) => tag.length > 0);
143
+
144
+ const response = await fetchWithHeaders(`${baseUrl}/upload-dataset`, {
145
+ method: "POST",
146
+ body: JSON.stringify({
147
+ dataset_repo_id: datasetInfo.dataset_repo_id,
148
+ tags,
149
+ private: uploadConfig.private,
150
+ }),
151
+ });
152
+
153
+ const data = await response.json();
154
+
155
+ if (response.ok && data.success) {
156
+ setUploadSuccess(true);
157
+ toast({
158
+ title: "Upload Successful!",
159
+ description: `Dataset ${datasetInfo.dataset_repo_id} has been uploaded to HuggingFace Hub.`,
160
+ });
161
+ } else {
162
+ toast({
163
+ title: "Upload Failed",
164
+ description:
165
+ data.message || "Failed to upload dataset to HuggingFace Hub.",
166
+ variant: "destructive",
167
+ });
168
+ }
169
+ } catch (error) {
170
+ console.error("Error uploading dataset:", error);
171
+ toast({
172
+ title: "Connection Error",
173
+ description: "Could not connect to the backend server.",
174
+ variant: "destructive",
175
+ });
176
+ } finally {
177
+ setIsUploading(false);
178
+ }
179
+ };
180
+
181
+ const handleSkipUpload = () => {
182
+ toast({
183
+ title: "Upload Skipped",
184
+ description: "Dataset saved locally. You can upload it manually later.",
185
+ });
186
+ navigate("/");
187
+ };
188
+
189
+ // Show loading state while fetching dataset info
190
+ if (isLoadingDatasetInfo || !datasetInfo) {
191
+ return (
192
+ <div className="min-h-screen bg-black text-white flex items-center justify-center">
193
+ <div className="text-center">
194
+ <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto mb-4"></div>
195
+ <p className="text-lg">Loading dataset information...</p>
196
+ </div>
197
+ </div>
198
+ );
199
+ }
200
+
201
+ return (
202
+ <div className="min-h-screen bg-black text-white p-8">
203
+ <div className="max-w-4xl mx-auto">
204
+ {/* Header */}
205
+ <div className="flex items-center justify-between mb-8">
206
+ <Button
207
+ onClick={() => navigate("/")}
208
+ variant="outline"
209
+ className="border-gray-500 hover:border-gray-200 text-gray-300 hover:text-white"
210
+ >
211
+ <ArrowLeft className="w-4 h-4 mr-2" />
212
+ Back to Home
213
+ </Button>
214
+
215
+ <div className="flex items-center gap-3">
216
+ {uploadSuccess ? (
217
+ <CheckCircle className="w-8 h-8 text-green-500" />
218
+ ) : (
219
+ <Database className="w-8 h-8 text-blue-500" />
220
+ )}
221
+ <h1 className="text-3xl font-bold">
222
+ {uploadSuccess ? "Upload Complete" : "Dataset Upload"}
223
+ </h1>
224
+ </div>
225
+ </div>
226
+
227
+ {/* Success State */}
228
+ {uploadSuccess && (
229
+ <div className="bg-green-900/20 border border-green-600 rounded-lg p-6 mb-8">
230
+ <div className="flex items-center gap-3 mb-4">
231
+ <CheckCircle className="w-6 h-6 text-green-500" />
232
+ <h2 className="text-xl font-semibold text-green-400">
233
+ Successfully Uploaded!
234
+ </h2>
235
+ </div>
236
+ <p className="text-gray-300 mb-4">
237
+ Your dataset has been uploaded to HuggingFace Hub and is now
238
+ available for training and sharing.
239
+ </p>
240
+ <div className="flex flex-col sm:flex-row gap-4">
241
+ <Button
242
+ onClick={() =>
243
+ window.open(
244
+ `https://huggingface.co/datasets/${datasetInfo.dataset_repo_id}`,
245
+ "_blank"
246
+ )
247
+ }
248
+ className="bg-blue-500 hover:bg-blue-600 text-white"
249
+ >
250
+ <ExternalLink className="w-4 h-4 mr-2" />
251
+ View on HuggingFace Hub
252
+ </Button>
253
+ <Button
254
+ onClick={() =>
255
+ navigate("/training", {
256
+ state: { datasetRepoId: datasetInfo.dataset_repo_id },
257
+ })
258
+ }
259
+ className="bg-purple-500 hover:bg-purple-600 text-white"
260
+ >
261
+ Start Training
262
+ </Button>
263
+ <Button
264
+ onClick={() => navigate("/")}
265
+ variant="outline"
266
+ className="border-gray-600 text-gray-300 hover:bg-gray-800 hover:text-white"
267
+ >
268
+ Return to Home
269
+ </Button>
270
+ </div>
271
+ </div>
272
+ )}
273
+
274
+ {/* Upload Form */}
275
+ {!uploadSuccess && (
276
+ <>
277
+ {/* Dataset Summary */}
278
+ <div className="bg-gray-900 rounded-lg p-6 border border-gray-700 mb-8">
279
+ <h2 className="text-xl font-semibold text-white mb-4">
280
+ Dataset Summary
281
+ </h2>
282
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
283
+ <div className="space-y-3">
284
+ <div>
285
+ <span className="text-gray-400">Repository ID:</span>
286
+ <p className="text-white font-mono text-lg">
287
+ {datasetInfo.dataset_repo_id}
288
+ </p>
289
+ </div>
290
+ <div>
291
+ <span className="text-gray-400">Task:</span>
292
+ <p className="text-white">{datasetInfo.single_task}</p>
293
+ </div>
294
+ </div>
295
+ <div className="space-y-3">
296
+ <div>
297
+ <span className="text-gray-400">Episodes Recorded:</span>
298
+ <p className="text-white text-2xl font-bold text-green-400">
299
+ {datasetInfo.saved_episodes || datasetInfo.num_episodes}
300
+ </p>
301
+ {datasetInfo.total_frames && (
302
+ <p className="text-gray-400 text-sm">
303
+ {datasetInfo.total_frames} total frames
304
+ </p>
305
+ )}
306
+ </div>
307
+ <div>
308
+ <span className="text-gray-400">Session Duration:</span>
309
+ <p className="text-white">
310
+ {formatDuration(datasetInfo.session_elapsed_seconds || 0)}
311
+ </p>
312
+ {datasetInfo.fps && (
313
+ <p className="text-gray-400 text-sm">
314
+ {datasetInfo.fps} FPS
315
+ </p>
316
+ )}
317
+ </div>
318
+ </div>
319
+ </div>
320
+ </div>
321
+
322
+ {/* Upload Configuration */}
323
+ <div className="bg-gray-900 rounded-lg p-6 border border-gray-700 mb-8">
324
+ <h2 className="text-xl font-semibold text-white mb-6">
325
+ Upload Configuration
326
+ </h2>
327
+
328
+ <div className="space-y-6">
329
+ {/* Tags */}
330
+ <div>
331
+ <Label htmlFor="tags" className="text-gray-300 mb-2 block">
332
+ Tags (comma-separated)
333
+ </Label>
334
+ <Input
335
+ id="tags"
336
+ value={tagsInput}
337
+ onChange={(e) => setTagsInput(e.target.value)}
338
+ placeholder="robotics, lerobot, manipulation"
339
+ className="bg-gray-800 border-gray-600 text-white"
340
+ />
341
+ <p className="text-sm text-gray-500 mt-1">
342
+ Tags help others discover your dataset on HuggingFace Hub
343
+ </p>
344
+ </div>
345
+
346
+ {/* Privacy Setting */}
347
+ <div className="flex items-center space-x-3">
348
+ <Checkbox
349
+ id="private"
350
+ checked={uploadConfig.private}
351
+ onCheckedChange={(checked) =>
352
+ setUploadConfig({
353
+ ...uploadConfig,
354
+ private: checked as boolean,
355
+ })
356
+ }
357
+ />
358
+ <div className="flex items-center gap-2">
359
+ {uploadConfig.private ? (
360
+ <EyeOff className="w-4 h-4 text-gray-400" />
361
+ ) : (
362
+ <Eye className="w-4 h-4 text-gray-400" />
363
+ )}
364
+ <Label htmlFor="private" className="text-gray-300">
365
+ Make dataset private
366
+ </Label>
367
+ </div>
368
+ </div>
369
+ <p className="text-sm text-gray-500 ml-6">
370
+ {uploadConfig.private
371
+ ? "Only you will be able to access this dataset"
372
+ : "Dataset will be publicly accessible on HuggingFace Hub"}
373
+ </p>
374
+ </div>
375
+ </div>
376
+
377
+ {/* Action Buttons */}
378
+ <div className="flex flex-col sm:flex-row gap-4 justify-center">
379
+ <Button
380
+ onClick={handleUploadToHub}
381
+ disabled={isUploading}
382
+ className="bg-blue-500 hover:bg-blue-600 text-white font-semibold py-4 px-8 text-lg"
383
+ >
384
+ {isUploading ? (
385
+ <>
386
+ <Loader2 className="w-5 h-5 mr-2 animate-spin" />
387
+ Uploading to Hub...
388
+ </>
389
+ ) : (
390
+ <>
391
+ <UploadIcon className="w-5 h-5 mr-2" />
392
+ Upload to HuggingFace Hub
393
+ </>
394
+ )}
395
+ </Button>
396
+
397
+ <Button
398
+ onClick={handleSkipUpload}
399
+ disabled={isUploading}
400
+ variant="outline"
401
+ className="border-gray-600 text-gray-300 hover:bg-gray-800 hover:text-white py-4 px-8 text-lg"
402
+ >
403
+ Skip Upload
404
+ </Button>
405
+ </div>
406
+
407
+ {/* Info Box */}
408
+ <div className="mt-8 p-4 bg-blue-900/20 border border-blue-600 rounded-lg">
409
+ <div className="flex items-start gap-3">
410
+ <AlertCircle className="w-5 h-5 text-blue-400 mt-0.5" />
411
+ <div>
412
+ <h3 className="font-semibold text-blue-400 mb-2">
413
+ About HuggingFace Hub Upload
414
+ </h3>
415
+ <ul className="text-sm text-gray-300 space-y-1">
416
+ <li>
417
+ • Your dataset will be uploaded to HuggingFace Hub for
418
+ sharing and collaboration
419
+ </li>
420
+ <li>
421
+ • You need to be logged in to HuggingFace CLI on the
422
+ server
423
+ </li>
424
+ <li>
425
+ • Uploaded datasets can be used for training models and
426
+ sharing with the community
427
+ </li>
428
+ <li>
429
+ • You can always upload manually later using the
430
+ HuggingFace CLI
431
+ </li>
432
+ </ul>
433
+ </div>
434
+ </div>
435
+ </div>
436
+ </>
437
+ )}
438
+ </div>
439
+ </div>
440
+ );
441
+ };
442
+
443
+ export default Upload;
vite.config.ts CHANGED
@@ -8,40 +8,15 @@ export default defineConfig(({ mode }) => ({
8
  server: {
9
  host: "::",
10
  port: 8080,
11
- proxy: {
12
- "/start-training": "http://localhost:8000",
13
- "/stop-training": "http://localhost:8000",
14
- "/training-status": "http://localhost:8000",
15
- "/training-logs": "http://localhost:8000",
16
- "/start-recording": "http://localhost:8000",
17
- "/stop-recording": "http://localhost:8000",
18
- "/recording-status": "http://localhost:8000",
19
- "/recording-exit-early": "http://localhost:8000",
20
- "/recording-rerecord-episode": "http://localhost:8000",
21
- "/start-calibration": "http://localhost:8000",
22
- "/stop-calibration": "http://localhost:8000",
23
- "/calibration-status": "http://localhost:8000",
24
- "/calibration-input": "http://localhost:8000",
25
- "/calibration-debug": "http://localhost:8000",
26
- "/calibration-configs": "http://localhost:8000",
27
- "/move-arm": "http://localhost:8000",
28
- "/stop-teleoperation": "http://localhost:8000",
29
- "/teleoperation-status": "http://localhost:8000",
30
- "/joint-positions": "http://localhost:8000",
31
- "/get-configs": "http://localhost:8000",
32
- "/health": "http://localhost:8000",
33
- "/ws": {
34
- target: "ws://localhost:8000",
35
- ws: true,
36
- },
37
- },
38
- },
39
- preview: {
40
- allowedHosts: ["jurmy24-lelab.hf.space"],
41
  },
42
  plugins: [react(), mode === "development" && componentTagger()].filter(
43
  Boolean
44
  ),
 
 
 
45
  resolve: {
46
  alias: {
47
  "@": path.resolve(__dirname, "./src"),
 
8
  server: {
9
  host: "::",
10
  port: 8080,
11
+ // Proxy removed - the React app now handles API URLs directly through the ApiContext
12
+ // This provides full flexibility for localhost/ngrok switching at runtime
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  },
14
  plugins: [react(), mode === "development" && componentTagger()].filter(
15
  Boolean
16
  ),
17
+ preview: {
18
+ allowedHosts: ["jurmy24-lelab.hf.space"],
19
+ },
20
  resolve: {
21
  alias: {
22
  "@": path.resolve(__dirname, "./src"),