Spaces:
Running
Running
mega big updates pre-demo
Browse files- src/App.tsx +55 -37
- src/components/control/DirectFollowerControlPanel.tsx +99 -0
- src/components/landing/ActionList.tsx +56 -36
- src/components/landing/DirectFollowerModal.tsx +178 -0
- src/components/landing/LandingHeader.tsx +67 -8
- src/components/landing/NgrokConfigModal.tsx +212 -0
- src/components/landing/RecordingModal.tsx +293 -145
- src/components/landing/RobotModelSelector.tsx +25 -14
- src/components/landing/TeleoperationModal.tsx +118 -24
- src/components/landing/UsageInstructionsModal.tsx +123 -0
- src/components/landing/types.ts +1 -0
- src/components/recording/PhoneCameraFeed.tsx +117 -0
- src/components/recording/QrCodeModal.tsx +145 -0
- src/components/replay/DatasetSelector.tsx +45 -0
- src/components/replay/EpisodePlayer.tsx +76 -0
- src/components/replay/PlaybackControls.tsx +94 -0
- src/components/replay/ReplayHeader.tsx +33 -0
- src/components/replay/ReplayVisualizer.tsx +46 -0
- src/components/test/WebSocketTest.tsx +5 -3
- src/components/ui/PortDetectionButton.tsx +38 -0
- src/components/ui/PortDetectionModal.tsx +334 -0
- src/contexts/ApiContext.tsx +134 -0
- src/hooks/useRealTimeJoints.ts +8 -4
- src/lib/urdfViewerHelpers.ts +3 -43
- src/pages/Calibration.tsx +78 -29
- src/pages/DirectFollower.tsx +118 -0
- src/pages/Landing.tsx +122 -27
- src/pages/PhoneCamera.tsx +223 -0
- src/pages/Recording.tsx +248 -164
- src/pages/ReplayDataset.tsx +67 -0
- src/pages/Teleoperation.tsx +3 -1
- src/pages/Upload.tsx +443 -0
- vite.config.ts +5 -30
src/App.tsx
CHANGED
@@ -1,43 +1,61 @@
|
|
1 |
-
|
2 |
-
|
3 |
-
|
4 |
-
|
|
|
|
|
5 |
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
6 |
-
import {
|
7 |
-
import
|
8 |
-
import
|
9 |
-
import
|
10 |
-
import
|
11 |
-
import
|
12 |
-
import
|
13 |
-
import
|
14 |
-
import
|
15 |
-
import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
16 |
|
17 |
const queryClient = new QueryClient();
|
18 |
|
19 |
-
|
20 |
-
|
21 |
-
<
|
22 |
-
<
|
23 |
-
|
24 |
-
|
25 |
-
|
26 |
-
|
27 |
-
|
28 |
-
|
29 |
-
|
30 |
-
|
31 |
-
|
32 |
-
|
33 |
-
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
-
|
38 |
-
|
39 |
-
|
40 |
-
|
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 |
-
<
|
18 |
-
|
19 |
-
|
20 |
-
|
21 |
-
|
22 |
-
|
23 |
-
|
24 |
-
|
25 |
-
|
26 |
-
|
27 |
-
|
28 |
-
|
29 |
-
|
30 |
-
|
31 |
-
|
32 |
-
|
33 |
-
|
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 |
-
<
|
49 |
-
|
50 |
-
|
51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
52 |
</div>
|
53 |
-
</
|
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
|
2 |
-
|
3 |
-
|
4 |
-
|
5 |
-
|
6 |
-
|
7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
-
|
68 |
-
<
|
69 |
-
<
|
70 |
-
<
|
71 |
-
<div className="
|
72 |
-
<
|
|
|
|
|
73 |
</div>
|
74 |
-
|
75 |
-
|
76 |
-
|
77 |
-
</
|
78 |
-
|
79 |
-
|
80 |
-
|
81 |
-
|
82 |
-
|
83 |
-
</DialogDescription>
|
84 |
|
85 |
-
|
86 |
-
|
87 |
-
|
88 |
-
Robot Configuration
|
89 |
</h3>
|
90 |
-
<
|
91 |
-
|
92 |
-
|
93 |
-
|
94 |
-
|
95 |
-
|
96 |
-
|
97 |
-
|
98 |
-
|
99 |
-
|
100 |
-
|
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="
|
153 |
-
<
|
154 |
-
|
155 |
-
|
156 |
-
|
157 |
-
<div className="
|
158 |
-
<
|
159 |
-
|
160 |
-
|
161 |
-
|
162 |
-
|
163 |
-
|
164 |
-
|
165 |
-
|
166 |
-
|
167 |
-
|
168 |
-
|
169 |
-
|
170 |
-
|
171 |
-
|
172 |
-
|
173 |
-
|
174 |
-
|
175 |
-
|
176 |
-
|
177 |
-
|
178 |
-
|
179 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
180 |
</div>
|
181 |
-
|
182 |
-
|
183 |
-
|
184 |
-
|
185 |
-
|
186 |
-
|
187 |
-
|
188 |
-
|
189 |
-
|
190 |
-
|
191 |
-
|
192 |
-
|
193 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
194 |
</div>
|
195 |
</div>
|
196 |
</div>
|
197 |
-
</div>
|
198 |
|
199 |
-
|
200 |
-
|
201 |
-
|
202 |
-
|
203 |
-
|
204 |
-
|
205 |
-
|
206 |
-
|
207 |
-
|
208 |
-
|
209 |
-
|
210 |
-
|
211 |
-
|
212 |
-
|
213 |
-
|
|
|
214 |
</div>
|
215 |
-
</
|
216 |
-
</
|
217 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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="
|
14 |
Select Robot Model
|
15 |
</h2>
|
16 |
-
<RadioGroup value={robotModel} onValueChange={onValueChange} className="
|
17 |
<div>
|
18 |
<RadioGroupItem value="SO100" id="so100" className="sr-only" />
|
19 |
-
<Label htmlFor="so100" className="flex items-center
|
20 |
-
<span className="w-
|
21 |
-
{robotModel === "SO100" && <span className="w-
|
22 |
</span>
|
23 |
-
<span className="text-
|
24 |
-
</span>
|
25 |
</Label>
|
26 |
</div>
|
27 |
<div>
|
28 |
-
<RadioGroupItem value="
|
29 |
-
<Label htmlFor="
|
30 |
-
<span className="w-
|
31 |
-
{robotModel === "
|
32 |
</span>
|
33 |
-
<span className="text-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
75 |
Leader Port
|
76 |
</Label>
|
77 |
-
<
|
78 |
-
|
79 |
-
|
80 |
-
|
81 |
-
|
82 |
-
|
83 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
84 |
</div>
|
85 |
|
86 |
<div className="space-y-2">
|
87 |
-
<Label
|
|
|
|
|
|
|
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
|
|
|
|
|
95 |
}
|
96 |
/>
|
97 |
</SelectTrigger>
|
98 |
<SelectContent className="bg-gray-800 border-gray-700">
|
99 |
{leaderConfigs.map((config) => (
|
100 |
-
<SelectItem
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
110 |
Follower Port
|
111 |
</Label>
|
112 |
-
<
|
113 |
-
|
114 |
-
|
115 |
-
|
116 |
-
|
117 |
-
|
118 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
119 |
</div>
|
120 |
|
121 |
<div className="space-y-2">
|
122 |
-
<Label
|
|
|
|
|
|
|
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
|
|
|
|
|
130 |
}
|
131 |
/>
|
132 |
</SelectTrigger>
|
133 |
<SelectContent className="bg-gray-800 border-gray-700">
|
134 |
{followerConfigs.map((config) => (
|
135 |
-
<SelectItem
|
|
|
|
|
|
|
|
|
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 |
-
|
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(
|
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:
|
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
|
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
|
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:",
|
76 |
|
77 |
-
const ws = new WebSocket(
|
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 |
-
//
|
70 |
-
//
|
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 |
-
|
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
|
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
|
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
|
167 |
method: "POST",
|
168 |
});
|
169 |
|
@@ -215,8 +222,8 @@ const Calibration = () => {
|
|
215 |
|
216 |
setIsLoadingConfigs(true);
|
217 |
try {
|
218 |
-
const response = await
|
219 |
-
|
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
|
249 |
-
|
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
|
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 |
-
<
|
483 |
-
|
484 |
-
|
485 |
-
|
486 |
-
|
487 |
-
|
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
|
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
|
77 |
if (robotModel) {
|
78 |
-
|
79 |
-
loadConfigs();
|
80 |
}
|
81 |
};
|
82 |
|
83 |
-
const
|
84 |
if (robotModel) {
|
85 |
-
|
|
|
86 |
}
|
87 |
};
|
88 |
|
@@ -94,7 +107,14 @@ const Landing = () => {
|
|
94 |
|
95 |
const handleReplayDatasetClick = () => {
|
96 |
if (robotModel) {
|
97 |
-
navigate("/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
98 |
}
|
99 |
};
|
100 |
|
@@ -110,11 +130,8 @@ const Landing = () => {
|
|
110 |
}
|
111 |
|
112 |
try {
|
113 |
-
const response = await
|
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 |
-
<
|
|
|
|
|
|
|
|
|
|
|
263 |
|
264 |
-
<div className="
|
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 |
-
|
7 |
-
|
8 |
-
|
9 |
-
|
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
|
86 |
-
|
87 |
);
|
88 |
if (response.ok) {
|
89 |
const status = await response.json();
|
90 |
setBackendStatus(status);
|
91 |
|
92 |
-
// If backend recording stopped
|
93 |
-
if (
|
94 |
-
|
95 |
-
|
96 |
-
|
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
|
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
|
165 |
-
|
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
|
207 |
-
|
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
|
239 |
method: "POST",
|
240 |
});
|
241 |
|
@@ -243,7 +301,17 @@ const Recording = () => {
|
|
243 |
title: "Recording Stopped",
|
244 |
description: "Recording session has been stopped.",
|
245 |
});
|
246 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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-
|
336 |
-
<div className=
|
337 |
-
|
|
|
|
|
338 |
</div>
|
339 |
</div>
|
340 |
|
@@ -402,155 +472,169 @@ const Recording = () => {
|
|
402 |
</div>
|
403 |
|
404 |
{/* Status and Controls */}
|
405 |
-
<div className="
|
406 |
-
|
407 |
-
|
408 |
-
|
409 |
-
|
410 |
-
|
411 |
-
|
412 |
-
|
413 |
-
|
414 |
-
|
415 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
416 |
</div>
|
417 |
</div>
|
418 |
-
</div>
|
419 |
|
420 |
-
|
421 |
-
|
422 |
-
|
423 |
-
|
424 |
-
|
425 |
-
|
426 |
-
|
427 |
-
|
428 |
-
|
429 |
-
|
430 |
-
|
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 |
-
|
453 |
-
|
454 |
-
|
455 |
-
|
456 |
-
|
457 |
-
|
458 |
-
|
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={
|
489 |
-
|
|
|
490 |
>
|
491 |
-
<
|
492 |
-
|
493 |
</Button>
|
|
|
|
|
|
|
|
|
|
|
|
|
494 |
<Button
|
495 |
-
onClick={
|
496 |
-
|
497 |
-
className="bg-
|
498 |
>
|
499 |
-
|
|
|
500 |
</Button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
501 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
502 |
</div>
|
503 |
)}
|
|
|
504 |
|
505 |
-
|
506 |
-
|
507 |
-
|
508 |
-
|
509 |
-
|
510 |
-
|
511 |
-
|
512 |
-
|
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
|
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 |
-
|
12 |
-
|
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"),
|