mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-02-04 23:46:32 -05:00
Break work
This commit is contained in:
@@ -367,10 +367,8 @@ export const columns: ColumnDef<Trial>[] = [
|
||||
|
||||
function ActionsCell({ row }: { row: { original: Trial } }) {
|
||||
const trial = row.original;
|
||||
const router = React.useMemo(() => require("next/navigation").useRouter(), []); // Dynamic import to avoid hook rules in static context? No, this component is rendered in Table.
|
||||
// Actually, hooks must be at top level. This ActionsCell will be a regular component.
|
||||
// But useRouter might fail if columns is not in component tree?
|
||||
// Table cells are rendered by flexRender in React, so they are components.
|
||||
// ActionsCell is a component rendered by the table.
|
||||
|
||||
// importing useRouter is fine.
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
205
src/components/trials/playback/EventTimeline.tsx
Normal file
205
src/components/trials/playback/EventTimeline.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo, useRef, useState } from "react";
|
||||
import { usePlayback } from "./PlaybackContext";
|
||||
import { cn } from "~/lib/utils";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Flag,
|
||||
MessageSquare,
|
||||
Zap,
|
||||
Circle,
|
||||
Bot,
|
||||
User,
|
||||
Activity
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger
|
||||
} from "~/components/ui/tooltip";
|
||||
|
||||
function formatTime(seconds: number) {
|
||||
const min = Math.floor(seconds / 60);
|
||||
const sec = Math.floor(seconds % 60);
|
||||
return `${min}:${sec.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function EventTimeline() {
|
||||
const {
|
||||
duration,
|
||||
currentTime,
|
||||
events,
|
||||
seekTo,
|
||||
startTime: contextStartTime
|
||||
} = usePlayback();
|
||||
|
||||
// Determine effective time range
|
||||
const sortedEvents = useMemo(() => {
|
||||
return [...events].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
|
||||
}, [events]);
|
||||
|
||||
const startTime = useMemo(() => {
|
||||
if (contextStartTime) return new Date(contextStartTime).getTime();
|
||||
if (sortedEvents.length > 0) return new Date(sortedEvents[0]!.timestamp).getTime();
|
||||
return 0;
|
||||
}, [contextStartTime, sortedEvents]);
|
||||
|
||||
const effectiveDuration = useMemo(() => {
|
||||
if (duration > 0) return duration * 1000;
|
||||
if (sortedEvents.length === 0) return 60000; // 1 min default
|
||||
const end = new Date(sortedEvents[sortedEvents.length - 1]!.timestamp).getTime();
|
||||
return Math.max(end - startTime, 1000);
|
||||
}, [duration, sortedEvents, startTime]);
|
||||
|
||||
// Dimensions
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Helpers
|
||||
const getPercentage = (timestampMs: number) => {
|
||||
const offset = timestampMs - startTime;
|
||||
return Math.max(0, Math.min(100, (offset / effectiveDuration) * 100));
|
||||
};
|
||||
|
||||
const handleSeek = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!containerRef.current) return;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const pct = Math.max(0, Math.min(1, x / rect.width));
|
||||
seekTo(pct * (effectiveDuration / 1000));
|
||||
};
|
||||
|
||||
const currentProgress = (currentTime * 1000 / effectiveDuration) * 100;
|
||||
|
||||
// Generate ticks for "number line" look
|
||||
// We want a major tick every ~10% or meaningful time interval
|
||||
const ticks = useMemo(() => {
|
||||
const count = 10;
|
||||
return Array.from({ length: count + 1 }).map((_, i) => ({
|
||||
pct: (i / count) * 100,
|
||||
label: formatTime((effectiveDuration / 1000) * (i / count))
|
||||
}));
|
||||
}, [effectiveDuration]);
|
||||
|
||||
const getEventIcon = (type: string) => {
|
||||
if (type.includes("intervention") || type.includes("wizard")) return <User className="h-3 w-3" />;
|
||||
if (type.includes("robot") || type.includes("action")) return <Bot className="h-3 w-3" />;
|
||||
if (type.includes("completed")) return <CheckCircle className="h-3 w-3" />;
|
||||
if (type.includes("start")) return <Flag className="h-3 w-3" />;
|
||||
if (type.includes("note")) return <MessageSquare className="h-3 w-3" />;
|
||||
if (type.includes("error")) return <AlertTriangle className="h-3 w-3" />;
|
||||
return <Activity className="h-3 w-3" />;
|
||||
};
|
||||
|
||||
const getEventColor = (type: string) => {
|
||||
if (type.includes("intervention") || type.includes("wizard")) return "text-orange-500 border-orange-200 bg-orange-50";
|
||||
if (type.includes("robot") || type.includes("action")) return "text-purple-500 border-purple-200 bg-purple-50";
|
||||
if (type.includes("completed")) return "text-green-500 border-green-200 bg-green-50";
|
||||
if (type.includes("start")) return "text-blue-500 border-blue-200 bg-blue-50";
|
||||
if (type.includes("error")) return "text-red-500 border-red-200 bg-red-50";
|
||||
return "text-slate-500 border-slate-200 bg-slate-50";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col select-none py-2">
|
||||
<TooltipProvider>
|
||||
{/* Timeline Track Container */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative w-full flex-1 min-h-[80px] group cursor-crosshair border-b border-border/50"
|
||||
onClick={handleSeek}
|
||||
>
|
||||
{/* Background Grid/Ticks */}
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
{/* Major Ticks */}
|
||||
{ticks.map((tick, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute top-0 bottom-0 border-l border-border/30 flex flex-col justify-end"
|
||||
style={{ left: `${tick.pct}%` }}
|
||||
>
|
||||
<span className="text-[10px] font-mono text-muted-foreground -ml-3 mb-1 bg-background/80 px-1 rounded">
|
||||
{tick.label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Central Axis Line */}
|
||||
<div className="absolute top-1/2 left-0 right-0 h-px bg-border z-0" />
|
||||
|
||||
{/* Progress Fill (Subtle) */}
|
||||
<div
|
||||
className="absolute top-0 bottom-0 left-0 bg-primary/5 z-0 pointer-events-none"
|
||||
style={{ width: `${currentProgress}%` }}
|
||||
/>
|
||||
|
||||
{/* Playhead */}
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-px bg-red-500 z-30 pointer-events-none transition-all duration-75"
|
||||
style={{ left: `${currentProgress}%` }}
|
||||
>
|
||||
<div className="absolute -top-1 -ml-1.5 p-0.5 bg-red-500 rounded text-[8px] font-bold text-white w-3 h-3 flex items-center justify-center">
|
||||
▼
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Events "Lollipops" */}
|
||||
{sortedEvents.map((event, i) => {
|
||||
const pct = getPercentage(new Date(event.timestamp).getTime());
|
||||
const isTop = i % 2 === 0; // Stagger events top/bottom
|
||||
|
||||
return (
|
||||
<Tooltip key={i}>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className="absolute z-20 flex flex-col items-center group/event"
|
||||
style={{
|
||||
left: `${pct}%`,
|
||||
top: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
height: '100%'
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
seekTo((new Date(event.timestamp).getTime() - startTime) / 1000);
|
||||
}}
|
||||
>
|
||||
{/* The Stem */}
|
||||
<div className={cn(
|
||||
"w-px transition-all duration-200 bg-border group-hover/event:bg-primary group-hover/event:h-full",
|
||||
isTop ? "h-8 mb-auto" : "h-8 mt-auto"
|
||||
)} />
|
||||
|
||||
{/* The Node */}
|
||||
<div className={cn(
|
||||
"absolute w-6 h-6 rounded-full border shadow-sm flex items-center justify-center transition-transform hover:scale-110 cursor-pointer bg-background z-10",
|
||||
getEventColor(event.eventType),
|
||||
isTop ? "-top-2" : "-bottom-2"
|
||||
)}>
|
||||
{getEventIcon(event.eventType)}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={isTop ? "top" : "bottom"}>
|
||||
<div className="text-xs font-semibold uppercase tracking-wider mb-0.5">{event.eventType.replace(/_/g, " ")}</div>
|
||||
<div className="text-[10px] font-mono opacity-70 mb-1">
|
||||
{new Date(event.timestamp).toLocaleTimeString()}
|
||||
</div>
|
||||
{event.data && (
|
||||
<div className="bg-muted/50 p-1 rounded font-mono text-[9px] max-w-[200px] break-all">
|
||||
{JSON.stringify(event.data as object).slice(0, 100)}
|
||||
</div>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
115
src/components/trials/playback/PlaybackContext.tsx
Normal file
115
src/components/trials/playback/PlaybackContext.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import React, { createContext, useContext, useEffect, useRef, useState } from "react";
|
||||
|
||||
interface TrialEvent {
|
||||
eventType: string;
|
||||
timestamp: Date;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
interface PlaybackContextType {
|
||||
// State
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
isPlaying: boolean;
|
||||
playbackRate: number;
|
||||
startTime?: Date;
|
||||
|
||||
// Actions
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
togglePlay: () => void;
|
||||
seekTo: (time: number) => void;
|
||||
setPlaybackRate: (rate: number) => void;
|
||||
setDuration: (duration: number) => void;
|
||||
setCurrentTime: (time: number) => void; // Used by VideoPlayer to update state
|
||||
|
||||
// Data
|
||||
events: TrialEvent[];
|
||||
currentEventIndex: number; // Index of the last event that happened before currentTime
|
||||
}
|
||||
|
||||
const PlaybackContext = createContext<PlaybackContextType | null>(null);
|
||||
|
||||
export function usePlayback() {
|
||||
const context = useContext(PlaybackContext);
|
||||
if (!context) {
|
||||
throw new Error("usePlayback must be used within a PlaybackProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
interface PlaybackProviderProps {
|
||||
children: React.ReactNode;
|
||||
events?: TrialEvent[];
|
||||
startTime?: Date;
|
||||
}
|
||||
|
||||
export function PlaybackProvider({ children, events = [], startTime }: PlaybackProviderProps) {
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [playbackRate, setPlaybackRate] = useState(1);
|
||||
|
||||
// Derived state: find the latest event index based on currentTime
|
||||
const currentEventIndex = React.useMemo(() => {
|
||||
if (!startTime || events.length === 0) return -1;
|
||||
|
||||
// Find the last event that occurred before or at currentTime
|
||||
// Events are assumed to be sorted by timestamp
|
||||
// Using basic iteration for now, optimization possible for large lists
|
||||
let lastIndex = -1;
|
||||
|
||||
for (let i = 0; i < events.length; i++) {
|
||||
const eventTime = new Date(events[i]!.timestamp).getTime();
|
||||
const startStr = new Date(startTime).getTime();
|
||||
const relativeSeconds = (eventTime - startStr) / 1000;
|
||||
|
||||
if (relativeSeconds <= currentTime) {
|
||||
lastIndex = i;
|
||||
} else {
|
||||
break; // Events are sorted, so we can stop
|
||||
}
|
||||
}
|
||||
return lastIndex;
|
||||
}, [currentTime, events, startTime]);
|
||||
|
||||
// Actions
|
||||
const play = () => setIsPlaying(true);
|
||||
const pause = () => setIsPlaying(false);
|
||||
const togglePlay = () => setIsPlaying(p => !p);
|
||||
|
||||
const seekTo = (time: number) => {
|
||||
setCurrentTime(time);
|
||||
// Dispatch seek event to video player via some mechanism if needed,
|
||||
// usually VideoPlayer observes this context or we use a Ref to control it.
|
||||
// Actually, simple way: Context holds state, VideoPlayer listens to state?
|
||||
// No, VideoPlayer usually drives time.
|
||||
// Let's assume VideoPlayer updates `setCurrentTime` as it plays.
|
||||
// But if *we* seek (e.g. from timeline), we need to tell VideoPlayer to jump.
|
||||
// We might need a `seekRequest` timestamp or similar signal.
|
||||
};
|
||||
|
||||
const value: PlaybackContextType = {
|
||||
currentTime,
|
||||
duration,
|
||||
isPlaying,
|
||||
playbackRate,
|
||||
play,
|
||||
pause,
|
||||
togglePlay,
|
||||
seekTo,
|
||||
setPlaybackRate,
|
||||
setDuration,
|
||||
setCurrentTime,
|
||||
events,
|
||||
currentEventIndex,
|
||||
};
|
||||
|
||||
return (
|
||||
<PlaybackContext.Provider value={value}>
|
||||
{children}
|
||||
</PlaybackContext.Provider>
|
||||
);
|
||||
}
|
||||
154
src/components/trials/playback/PlaybackPlayer.tsx
Normal file
154
src/components/trials/playback/PlaybackPlayer.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
"use client";
|
||||
|
||||
import React, { useRef, useEffect } from "react";
|
||||
import { usePlayback } from "./PlaybackContext";
|
||||
import { AspectRatio } from "~/components/ui/aspect-ratio";
|
||||
import { Loader2, Play, Pause, Volume2, VolumeX, Maximize } from "lucide-react";
|
||||
import { Slider } from "~/components/ui/slider";
|
||||
import { Button } from "~/components/ui/button";
|
||||
|
||||
interface PlaybackPlayerProps {
|
||||
src: string;
|
||||
}
|
||||
|
||||
export function PlaybackPlayer({ src }: PlaybackPlayerProps) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const {
|
||||
currentTime,
|
||||
isPlaying,
|
||||
playbackRate,
|
||||
setCurrentTime,
|
||||
setDuration,
|
||||
togglePlay,
|
||||
play,
|
||||
pause
|
||||
} = usePlayback();
|
||||
|
||||
const [isBuffering, setIsBuffering] = React.useState(true);
|
||||
const [volume, setVolume] = React.useState(1);
|
||||
const [muted, setMuted] = React.useState(false);
|
||||
|
||||
// Sync Play/Pause state
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
if (isPlaying && video.paused) {
|
||||
video.play().catch(console.error);
|
||||
} else if (!isPlaying && !video.paused) {
|
||||
video.pause();
|
||||
}
|
||||
}, [isPlaying]);
|
||||
|
||||
// Sync Playback Rate
|
||||
useEffect(() => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.playbackRate = playbackRate;
|
||||
}
|
||||
}, [playbackRate]);
|
||||
|
||||
// Sync Seek (External seek request)
|
||||
// Note: This is tricky because normal playback also updates currentTime.
|
||||
// We need to differentiate between "time updated by video" and "time updated by user seek".
|
||||
// For now, we'll let the video drive the context time, and rely on the Parent/Context
|
||||
// to call a imperative sync if needed, or we implement a "seekRequest" signal in context.
|
||||
// simpler: If context time differs significantly from video time, we seek.
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
if (Math.abs(video.currentTime - currentTime) > 0.5) {
|
||||
video.currentTime = currentTime;
|
||||
}
|
||||
}, [currentTime]);
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
if (videoRef.current) {
|
||||
setCurrentTime(videoRef.current.currentTime);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoadedMetadata = () => {
|
||||
if (videoRef.current) {
|
||||
setDuration(videoRef.current.duration);
|
||||
setIsBuffering(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleWaiting = () => setIsBuffering(true);
|
||||
const handlePlaying = () => setIsBuffering(false);
|
||||
const handleEnded = () => pause();
|
||||
|
||||
return (
|
||||
<div className="group relative rounded-lg overflow-hidden border bg-black shadow-sm">
|
||||
<AspectRatio ratio={16 / 9}>
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={src}
|
||||
className="w-full h-full object-contain"
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
onWaiting={handleWaiting}
|
||||
onPlaying={handlePlaying}
|
||||
onEnded={handleEnded}
|
||||
onClick={togglePlay}
|
||||
/>
|
||||
|
||||
{/* Overlay Controls (Visible on Hover/Pause) */}
|
||||
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent p-4 opacity-0 transition-opacity group-hover:opacity-100 data-[paused=true]:opacity-100" data-paused={!isPlaying}>
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-white hover:bg-white/20"
|
||||
onClick={togglePlay}
|
||||
>
|
||||
{isPlaying ? <Pause className="h-6 w-6" /> : <Play className="h-6 w-6 fill-current" />}
|
||||
</Button>
|
||||
|
||||
<div className="flex-1">
|
||||
<Slider
|
||||
value={[currentTime]}
|
||||
min={0}
|
||||
max={videoRef.current?.duration || 100}
|
||||
step={0.1}
|
||||
onValueChange={([val]) => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.currentTime = val;
|
||||
setCurrentTime(val);
|
||||
}
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-xs font-mono text-white/90">
|
||||
{formatTime(currentTime)} / {formatTime(videoRef.current?.duration || 0)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-white hover:bg-white/20"
|
||||
onClick={() => setMuted(!muted)}
|
||||
>
|
||||
{muted || volume === 0 ? <VolumeX className="h-5 w-5" /> : <Volume2 className="h-5 w-5" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isBuffering && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/20 pointer-events-none">
|
||||
<Loader2 className="h-10 w-10 animate-spin text-white/80" />
|
||||
</div>
|
||||
)}
|
||||
</AspectRatio>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTime(seconds: number) {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||
}
|
||||
@@ -1,10 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||
import { LineChart, BarChart, Clock, Database, FileText } from "lucide-react";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { LineChart, BarChart, Clock, Database, FileText, AlertTriangle, CheckCircle, VideoOff, Info } from "lucide-react";
|
||||
import { PlaybackProvider } from "../playback/PlaybackContext";
|
||||
import { PlaybackPlayer } from "../playback/PlaybackPlayer";
|
||||
import { EventTimeline } from "../playback/EventTimeline";
|
||||
import { api } from "~/trpc/react";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "~/components/ui/resizable";
|
||||
|
||||
interface TrialAnalysisViewProps {
|
||||
trial: {
|
||||
@@ -17,108 +25,165 @@ interface TrialAnalysisViewProps {
|
||||
participant: { participantCode: string };
|
||||
eventCount?: number;
|
||||
mediaCount?: number;
|
||||
media?: { url: string; contentType: string }[];
|
||||
};
|
||||
}
|
||||
|
||||
export function TrialAnalysisView({ trial }: TrialAnalysisViewProps) {
|
||||
// Fetch events for timeline
|
||||
const { data: events = [] } = api.trials.getEvents.useQuery({
|
||||
trialId: trial.id,
|
||||
limit: 1000
|
||||
});
|
||||
|
||||
const videoMedia = trial.media?.find(m => m.contentType.startsWith("video/"));
|
||||
const videoUrl = videoMedia?.url;
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6 space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Status</CardTitle>
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold capitalize">{trial.status.replace("_", " ")}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{trial.completedAt
|
||||
? `Completed ${formatDistanceToNow(new Date(trial.completedAt), { addSuffix: true })}`
|
||||
: "Not completed"}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Duration</CardTitle>
|
||||
<BarChart className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{trial.duration ? `${Math.floor(trial.duration / 60)}m ${trial.duration % 60}s` : "N/A"}
|
||||
<PlaybackProvider events={events} startTime={trial.startedAt ?? undefined}>
|
||||
<div className="h-[calc(100vh-8rem)] flex flex-col bg-background rounded-lg border shadow-sm overflow-hidden">
|
||||
{/* Header Context */}
|
||||
<div className="flex items-center justify-between p-3 border-b bg-muted/20 flex-none h-14">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex flex-col">
|
||||
<h1 className="text-base font-semibold leading-none">
|
||||
{trial.experiment.name}
|
||||
</h1>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{trial.participant.participantCode} • Session {trial.id.slice(0, 4)}...
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Total execution time
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="h-8 w-px bg-border" />
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
<span>{trial.startedAt?.toLocaleDateString()} {trial.startedAt?.toLocaleTimeString()}</span>
|
||||
</div>
|
||||
{trial.duration && (
|
||||
<Badge variant="secondary" className="text-[10px] font-mono">
|
||||
{Math.floor(trial.duration / 60)}m {trial.duration % 60}s
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Events Logged</CardTitle>
|
||||
<Database className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{trial.eventCount ?? 0}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
System & user events
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Main Resizable Workspace */}
|
||||
<div className="flex-1 min-h-0">
|
||||
<ResizablePanelGroup direction="horizontal">
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Media Files</CardTitle>
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{trial.mediaCount ?? 0}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Recordings & snapshots
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* LEFT: Video & Timeline */}
|
||||
<ResizablePanel defaultSize={65} minSize={30} className="flex flex-col min-h-0">
|
||||
<ResizablePanelGroup direction="vertical">
|
||||
{/* Top: Video Player */}
|
||||
<ResizablePanel defaultSize={75} minSize={20} className="bg-black relative">
|
||||
{videoUrl ? (
|
||||
<div className="absolute inset-0">
|
||||
<PlaybackPlayer src={videoUrl} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full w-full flex flex-col items-center justify-center text-slate-500">
|
||||
<VideoOff className="h-12 w-12 mb-3 opacity-20" />
|
||||
<p className="text-sm">No recording available.</p>
|
||||
</div>
|
||||
)}
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
{/* Bottom: Timeline Track */}
|
||||
<ResizablePanel defaultSize={25} minSize={10} className="bg-background flex flex-col min-h-0">
|
||||
<div className="p-2 border-b flex-none bg-muted/10 flex items-center gap-2">
|
||||
<Info className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-[10px] uppercase font-bold text-muted-foreground tracking-wider">Timeline Track</span>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 relative">
|
||||
<div className="absolute inset-0 p-2 overflow-hidden">
|
||||
<EventTimeline />
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
{/* RIGHT: Logs & Metrics */}
|
||||
<ResizablePanel defaultSize={35} minSize={20} className="flex flex-col min-h-0 border-l bg-muted/5">
|
||||
{/* Metrics Strip */}
|
||||
<div className="grid grid-cols-2 gap-2 p-3 border-b bg-background flex-none">
|
||||
<Card className="shadow-none border-dashed bg-transparent">
|
||||
<CardContent className="p-3 py-2">
|
||||
<div className="text-[10px] uppercase text-muted-foreground font-semibold mb-0.5">Interventions</div>
|
||||
<div className="text-xl font-mono font-bold flex items-center gap-2">
|
||||
{events.filter(e => e.eventType.includes("intervention")).length}
|
||||
<AlertTriangle className="h-3.5 w-3.5 text-yellow-500" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="shadow-none border-dashed bg-transparent">
|
||||
<CardContent className="p-3 py-2">
|
||||
<div className="text-[10px] uppercase text-muted-foreground font-semibold mb-0.5">Status</div>
|
||||
<div className="text-xl font-mono font-bold flex items-center gap-2">
|
||||
{trial.status === 'completed' ? 'PASS' : 'INC'}
|
||||
<div className={`h-2 w-2 rounded-full ${trial.status === 'completed' ? 'bg-green-500' : 'bg-orange-500'}`} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Log Title */}
|
||||
<div className="p-2 px-3 border-b bg-muted/20 flex items-center justify-between flex-none">
|
||||
<span className="text-xs font-semibold flex items-center gap-2">
|
||||
<FileText className="h-3.5 w-3.5 text-primary" />
|
||||
Event Log
|
||||
</span>
|
||||
<Badge variant="outline" className="text-[10px] h-5">{events.length} Events</Badge>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Event List */}
|
||||
<div className="flex-1 min-h-0 relative bg-background/50">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="divide-y divide-border/50">
|
||||
{events.map((event, i) => (
|
||||
<div key={i} className="p-3 py-2 text-sm hover:bg-accent/50 transition-colors cursor-pointer group flex gap-3 items-start">
|
||||
<div className="font-mono text-[10px] text-muted-foreground mt-0.5 min-w-[3rem]">
|
||||
{formatTime(new Date(event.timestamp).getTime() - (trial.startedAt?.getTime() ?? 0))}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-xs text-foreground group-hover:text-primary transition-colors">
|
||||
{event.eventType.replace(/_/g, " ")}
|
||||
</span>
|
||||
</div>
|
||||
{event.data && (
|
||||
<div className="text-[10px] text-muted-foreground bg-muted p-1.5 rounded border font-mono whitespace-pre-wrap break-all opacity-80 group-hover:opacity-100">
|
||||
{JSON.stringify(event.data as object, null, 1).replace(/"/g, '').replace(/[{}]/g, '').trim()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{events.length === 0 && (
|
||||
<div className="p-8 text-center text-xs text-muted-foreground italic">
|
||||
No events found in log.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="overview" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="events">Event Log</TabsTrigger>
|
||||
<TabsTrigger value="charts">Charts</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="overview" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Analysis Overview</CardTitle>
|
||||
<CardDescription>
|
||||
Summary of trial execution for {trial.participant.participantCode} in experiment {trial.experiment.name}.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[400px] flex items-center justify-center border-2 border-dashed rounded-md m-4">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<LineChart className="h-10 w-10 mx-auto mb-2 opacity-20" />
|
||||
<p>Detailed analysis visualizations coming soon.</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="events">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Event Log</CardTitle>
|
||||
<CardDescription>
|
||||
Chronological record of all trial events.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[400px] flex items-center justify-center border-2 border-dashed rounded-md m-4">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<p>Event log view placeholder.</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</PlaybackProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTime(ms: number) {
|
||||
if (ms < 0) return "0:00";
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
const m = Math.floor(totalSeconds / 60);
|
||||
const s = Math.floor(totalSeconds % 60);
|
||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
@@ -1,366 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
Battery,
|
||||
BatteryLow,
|
||||
Bot,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
RefreshCw,
|
||||
Signal,
|
||||
SignalHigh,
|
||||
SignalLow,
|
||||
SignalMedium,
|
||||
WifiOff,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
|
||||
import { Progress } from "~/components/ui/progress";
|
||||
|
||||
interface RobotStatusProps {
|
||||
trialId: string;
|
||||
}
|
||||
|
||||
interface RobotStatus {
|
||||
id: string;
|
||||
name: string;
|
||||
connectionStatus: "connected" | "disconnected" | "connecting" | "error";
|
||||
batteryLevel?: number;
|
||||
signalStrength?: number;
|
||||
currentMode: string;
|
||||
lastHeartbeat?: Date;
|
||||
errorMessage?: string;
|
||||
capabilities: string[];
|
||||
communicationProtocol: string;
|
||||
isMoving: boolean;
|
||||
position?: {
|
||||
x: number;
|
||||
y: number;
|
||||
z?: number;
|
||||
orientation?: number;
|
||||
};
|
||||
sensors?: Record<string, string>;
|
||||
}
|
||||
|
||||
export function RobotStatus({ trialId: _trialId }: RobotStatusProps) {
|
||||
const [robotStatus, setRobotStatus] = useState<RobotStatus | null>(null);
|
||||
const [lastUpdate, setLastUpdate] = useState<Date>(new Date());
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
// Mock robot status - in real implementation, this would come from API/WebSocket
|
||||
useEffect(() => {
|
||||
// Simulate robot status updates
|
||||
const mockStatus: RobotStatus = {
|
||||
id: "robot_001",
|
||||
name: "TurtleBot3 Burger",
|
||||
connectionStatus: "connected",
|
||||
batteryLevel: 85,
|
||||
signalStrength: 75,
|
||||
currentMode: "autonomous_navigation",
|
||||
lastHeartbeat: new Date(),
|
||||
capabilities: ["navigation", "manipulation", "speech", "vision"],
|
||||
communicationProtocol: "ROS2",
|
||||
isMoving: false,
|
||||
position: {
|
||||
x: 1.2,
|
||||
y: 0.8,
|
||||
orientation: 45,
|
||||
},
|
||||
sensors: {
|
||||
lidar: "operational",
|
||||
camera: "operational",
|
||||
imu: "operational",
|
||||
odometry: "operational",
|
||||
},
|
||||
};
|
||||
|
||||
setRobotStatus(mockStatus);
|
||||
|
||||
// Simulate periodic updates
|
||||
const interval = setInterval(() => {
|
||||
setRobotStatus((prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
batteryLevel: Math.max(
|
||||
0,
|
||||
(prev.batteryLevel ?? 0) - Math.random() * 0.5,
|
||||
),
|
||||
signalStrength: Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
100,
|
||||
(prev.signalStrength ?? 0) + (Math.random() - 0.5) * 10,
|
||||
),
|
||||
),
|
||||
lastHeartbeat: new Date(),
|
||||
position: prev.position
|
||||
? {
|
||||
...prev.position,
|
||||
x: prev.position.x + (Math.random() - 0.5) * 0.1,
|
||||
y: prev.position.y + (Math.random() - 0.5) * 0.1,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
});
|
||||
setLastUpdate(new Date());
|
||||
}, 3000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const getConnectionStatusConfig = (status: string) => {
|
||||
switch (status) {
|
||||
case "connected":
|
||||
return {
|
||||
icon: CheckCircle,
|
||||
color: "text-green-600",
|
||||
bgColor: "bg-green-100",
|
||||
label: "Connected",
|
||||
};
|
||||
case "connecting":
|
||||
return {
|
||||
icon: RefreshCw,
|
||||
color: "text-blue-600",
|
||||
bgColor: "bg-blue-100",
|
||||
label: "Connecting",
|
||||
};
|
||||
case "disconnected":
|
||||
return {
|
||||
icon: WifiOff,
|
||||
color: "text-gray-600",
|
||||
bgColor: "bg-gray-100",
|
||||
label: "Disconnected",
|
||||
};
|
||||
case "error":
|
||||
return {
|
||||
icon: AlertTriangle,
|
||||
color: "text-red-600",
|
||||
bgColor: "bg-red-100",
|
||||
label: "Error",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: WifiOff,
|
||||
color: "text-gray-600",
|
||||
bgColor: "bg-gray-100",
|
||||
label: "Unknown",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const getSignalIcon = (strength: number) => {
|
||||
if (strength >= 75) return SignalHigh;
|
||||
if (strength >= 50) return SignalMedium;
|
||||
if (strength >= 25) return SignalLow;
|
||||
return Signal;
|
||||
};
|
||||
|
||||
const getBatteryIcon = (level: number) => {
|
||||
return level <= 20 ? BatteryLow : Battery;
|
||||
};
|
||||
|
||||
const handleRefreshStatus = async () => {
|
||||
setRefreshing(true);
|
||||
// Simulate API call
|
||||
setTimeout(() => {
|
||||
setRefreshing(false);
|
||||
setLastUpdate(new Date());
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
if (!robotStatus) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border p-4 text-center">
|
||||
<div className="text-slate-500">
|
||||
<Bot className="mx-auto mb-2 h-8 w-8 opacity-50" />
|
||||
<p className="text-sm">No robot connected</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const statusConfig = getConnectionStatusConfig(robotStatus.connectionStatus);
|
||||
const StatusIcon = statusConfig.icon;
|
||||
const SignalIcon = getSignalIcon(robotStatus.signalStrength ?? 0);
|
||||
const BatteryIcon = getBatteryIcon(robotStatus.batteryLevel ?? 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleRefreshStatus}
|
||||
disabled={refreshing}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-3 w-3 ${refreshing ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Main Status Card */}
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="space-y-3">
|
||||
{/* Robot Info */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-medium text-slate-900">{robotStatus.name}</div>
|
||||
<Badge
|
||||
className={`${statusConfig.bgColor} ${statusConfig.color}`}
|
||||
variant="secondary"
|
||||
>
|
||||
<StatusIcon className="mr-1 h-3 w-3" />
|
||||
{statusConfig.label}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Connection Details */}
|
||||
<div className="text-sm text-slate-600">
|
||||
Protocol: {robotStatus.communicationProtocol}
|
||||
</div>
|
||||
|
||||
{/* Status Indicators */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* Battery */}
|
||||
{robotStatus.batteryLevel !== undefined && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center space-x-1 text-xs text-slate-600">
|
||||
<BatteryIcon className="h-3 w-3" />
|
||||
<span>Battery</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Progress
|
||||
value={robotStatus.batteryLevel}
|
||||
className="h-1.5 flex-1"
|
||||
/>
|
||||
<span className="w-8 text-xs font-medium">
|
||||
{Math.round(robotStatus.batteryLevel)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Signal Strength */}
|
||||
{robotStatus.signalStrength !== undefined && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center space-x-1 text-xs text-slate-600">
|
||||
<SignalIcon className="h-3 w-3" />
|
||||
<span>Signal</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Progress
|
||||
value={robotStatus.signalStrength}
|
||||
className="h-1.5 flex-1"
|
||||
/>
|
||||
<span className="w-8 text-xs font-medium">
|
||||
{Math.round(robotStatus.signalStrength)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current Mode */}
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Activity className="h-3 w-3 text-slate-600" />
|
||||
<span className="text-sm text-slate-600">Mode:</span>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{robotStatus.currentMode
|
||||
.replace(/_/g, " ")
|
||||
.replace(/\b\w/g, (l) => l.toUpperCase())}
|
||||
</Badge>
|
||||
</div>
|
||||
{robotStatus.isMoving && (
|
||||
<div className="mt-2 flex items-center space-x-1 text-xs">
|
||||
<div className="h-1.5 w-1.5 animate-pulse rounded-full"></div>
|
||||
<span>Robot is moving</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Position Info */}
|
||||
{robotStatus.position && (
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="mb-3 text-sm font-medium text-slate-700">
|
||||
Position
|
||||
</div>
|
||||
<div>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-600">X:</span>
|
||||
<span className="font-mono">
|
||||
{robotStatus.position.x.toFixed(2)}m
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-600">Y:</span>
|
||||
<span className="font-mono">
|
||||
{robotStatus.position.y.toFixed(2)}m
|
||||
</span>
|
||||
</div>
|
||||
{robotStatus.position.orientation !== undefined && (
|
||||
<div className="col-span-2 flex justify-between">
|
||||
<span className="text-slate-600">Orientation:</span>
|
||||
<span className="font-mono">
|
||||
{Math.round(robotStatus.position.orientation)}°
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sensors Status */}
|
||||
{robotStatus.sensors && (
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="mb-3 text-sm font-medium text-slate-700">Sensors</div>
|
||||
<div>
|
||||
<div className="space-y-1">
|
||||
{Object.entries(robotStatus.sensors).map(([sensor, status]) => (
|
||||
<div
|
||||
key={sensor}
|
||||
className="flex items-center justify-between text-xs"
|
||||
>
|
||||
<span className="text-slate-600 capitalize">{sensor}:</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{status}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Alert */}
|
||||
{robotStatus.errorMessage && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription className="text-sm">
|
||||
{robotStatus.errorMessage}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Last Update */}
|
||||
<div className="flex items-center space-x-1 text-xs text-slate-500">
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>Last update: {lastUpdate.toLocaleTimeString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -195,21 +195,28 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
}
|
||||
);
|
||||
|
||||
// Update local trial state from polling
|
||||
// Update local trial state from polling only if changed
|
||||
useEffect(() => {
|
||||
if (pollingData) {
|
||||
setTrial((prev) => ({
|
||||
...prev,
|
||||
status: pollingData.status,
|
||||
startedAt: pollingData.startedAt
|
||||
? new Date(pollingData.startedAt)
|
||||
: prev.startedAt,
|
||||
completedAt: pollingData.completedAt
|
||||
? new Date(pollingData.completedAt)
|
||||
: prev.completedAt,
|
||||
}));
|
||||
if (pollingData && JSON.stringify(pollingData) !== JSON.stringify(trial)) {
|
||||
// Only update if specific fields we care about have changed to avoid
|
||||
// unnecessary re-renders that might cause UI flashing
|
||||
if (pollingData.status !== trial.status ||
|
||||
pollingData.startedAt?.getTime() !== trial.startedAt?.getTime() ||
|
||||
pollingData.completedAt?.getTime() !== trial.completedAt?.getTime()) {
|
||||
|
||||
setTrial((prev) => ({
|
||||
...prev,
|
||||
status: pollingData.status,
|
||||
startedAt: pollingData.startedAt
|
||||
? new Date(pollingData.startedAt)
|
||||
: prev.startedAt,
|
||||
completedAt: pollingData.completedAt
|
||||
? new Date(pollingData.completedAt)
|
||||
: prev.completedAt,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}, [pollingData]);
|
||||
}, [pollingData, trial]);
|
||||
|
||||
// Auto-start trial on mount if scheduled
|
||||
useEffect(() => {
|
||||
@@ -675,6 +682,7 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
onTabChange={setControlPanelTab}
|
||||
isStarting={startTrialMutation.isPending}
|
||||
onSetAutonomousLife={setAutonomousLife}
|
||||
readOnly={trial.status === 'completed' || _userRole === 'observer'}
|
||||
/>
|
||||
}
|
||||
center={
|
||||
@@ -695,6 +703,7 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
completedActionsCount={completedActionsCount}
|
||||
onActionCompleted={() => setCompletedActionsCount(c => c + 1)}
|
||||
onCompleteTrial={handleCompleteTrial}
|
||||
readOnly={trial.status === 'completed' || _userRole === 'observer'}
|
||||
/>
|
||||
}
|
||||
right={
|
||||
@@ -706,6 +715,7 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
connectRos={connectRos}
|
||||
disconnectRos={disconnectRos}
|
||||
executeRosAction={executeRosAction}
|
||||
readOnly={trial.status === 'completed' || _userRole === 'observer'}
|
||||
/>
|
||||
}
|
||||
showDividers={true}
|
||||
@@ -720,6 +730,9 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
onAddAnnotation={handleAddAnnotation}
|
||||
isSubmitting={addAnnotationMutation.isPending}
|
||||
trialEvents={trialEvents}
|
||||
// Observation pane is where observers usually work, so not readOnly for them?
|
||||
// But maybe we want 'readOnly' for completed trials.
|
||||
readOnly={trial.status === 'completed'}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
|
||||
268
src/components/trials/wizard/panels/WebcamPanel.tsx
Normal file
268
src/components/trials/wizard/panels/WebcamPanel.tsx
Normal file
@@ -0,0 +1,268 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useRef, useState } from "react";
|
||||
import Webcam from "react-webcam";
|
||||
import { Camera, CameraOff, Video, StopCircle, Loader2 } from "lucide-react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||
import { AspectRatio } from "~/components/ui/aspect-ratio";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
export function WebcamPanel({ readOnly = false }: { readOnly?: boolean }) {
|
||||
const [deviceId, setDeviceId] = useState<string | null>(null);
|
||||
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
|
||||
const [isCameraEnabled, setIsCameraEnabled] = useState(false);
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const webcamRef = useRef<Webcam>(null);
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
|
||||
// TRPC mutation for presigned URL
|
||||
const getUploadUrlMutation = api.storage.getUploadPresignedUrl.useMutation();
|
||||
|
||||
const handleDevices = useCallback(
|
||||
(mediaDevices: MediaDeviceInfo[]) => {
|
||||
setDevices(mediaDevices.filter(({ kind, deviceId }) => kind === "videoinput" && deviceId !== ""));
|
||||
},
|
||||
[setDevices],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
navigator.mediaDevices.enumerateDevices().then(handleDevices);
|
||||
}, [handleDevices]);
|
||||
|
||||
const handleEnableCamera = () => {
|
||||
setIsCameraEnabled(true);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleDisableCamera = () => {
|
||||
if (isRecording) {
|
||||
handleStopRecording();
|
||||
}
|
||||
setIsCameraEnabled(false);
|
||||
};
|
||||
|
||||
const handleStartRecording = () => {
|
||||
if (!webcamRef.current?.stream) return;
|
||||
|
||||
setIsRecording(true);
|
||||
chunksRef.current = [];
|
||||
|
||||
try {
|
||||
const recorder = new MediaRecorder(webcamRef.current.stream, {
|
||||
mimeType: "video/webm"
|
||||
});
|
||||
|
||||
recorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
chunksRef.current.push(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
recorder.onstop = async () => {
|
||||
const blob = new Blob(chunksRef.current, { type: "video/webm" });
|
||||
await handleUpload(blob);
|
||||
};
|
||||
|
||||
recorder.start();
|
||||
mediaRecorderRef.current = recorder;
|
||||
toast.success("Recording started");
|
||||
} catch (e) {
|
||||
console.error("Failed to start recorder:", e);
|
||||
toast.error("Failed to start recording");
|
||||
setIsRecording(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStopRecording = () => {
|
||||
if (mediaRecorderRef.current && isRecording) {
|
||||
mediaRecorderRef.current.stop();
|
||||
setIsRecording(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async (blob: Blob) => {
|
||||
setUploading(true);
|
||||
const filename = `recording-${Date.now()}.webm`;
|
||||
|
||||
try {
|
||||
// 1. Get Presigned URL
|
||||
const { url } = await getUploadUrlMutation.mutateAsync({
|
||||
filename,
|
||||
contentType: "video/webm",
|
||||
});
|
||||
|
||||
// 2. Upload to S3
|
||||
const response = await fetch(url, {
|
||||
method: "PUT",
|
||||
body: blob,
|
||||
headers: {
|
||||
"Content-Type": "video/webm",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Upload failed");
|
||||
}
|
||||
|
||||
toast.success("Recording uploaded successfully");
|
||||
console.log("Uploaded recording:", filename);
|
||||
} catch (e) {
|
||||
console.error("Upload error:", e);
|
||||
toast.error("Failed to upload recording");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center justify-between border-b p-3">
|
||||
<h2 className="text-sm font-semibold flex items-center gap-2">
|
||||
<Camera className="h-4 w-4" />
|
||||
Webcam Feed
|
||||
</h2>
|
||||
|
||||
{!readOnly && (
|
||||
<div className="flex items-center gap-2">
|
||||
{devices.length > 0 && (
|
||||
<Select
|
||||
value={deviceId ?? undefined}
|
||||
onValueChange={setDeviceId}
|
||||
disabled={!isCameraEnabled || isRecording}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-[130px] text-xs">
|
||||
<SelectValue placeholder="Select Camera" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{devices.map((device, key) => (
|
||||
<SelectItem key={key} value={device.deviceId} className="text-xs">
|
||||
{device.label || `Camera ${key + 1}`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{isCameraEnabled && (
|
||||
!isRecording ? (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs animate-in fade-in"
|
||||
onClick={handleStartRecording}
|
||||
disabled={uploading}
|
||||
>
|
||||
<Video className="mr-1 h-3 w-3" />
|
||||
Record
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs border-red-500 border text-red-500 hover:bg-red-50"
|
||||
onClick={handleStopRecording}
|
||||
>
|
||||
<StopCircle className="mr-1 h-3 w-3 animate-pulse" />
|
||||
Stop Rec
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
|
||||
{isCameraEnabled ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={handleDisableCamera}
|
||||
disabled={isRecording}
|
||||
>
|
||||
<CameraOff className="mr-1 h-3 w-3" />
|
||||
Off
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={handleEnableCamera}
|
||||
>
|
||||
<Camera className="mr-1 h-3 w-3" />
|
||||
Start Camera
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden bg-black p-4 flex items-center justify-center relative">
|
||||
{isCameraEnabled ? (
|
||||
<div className="w-full relative rounded-lg overflow-hidden border border-slate-800">
|
||||
<AspectRatio ratio={16 / 9}>
|
||||
<Webcam
|
||||
ref={webcamRef}
|
||||
audio={false}
|
||||
width="100%"
|
||||
height="100%"
|
||||
videoConstraints={{ deviceId: deviceId ?? undefined }}
|
||||
onUserMediaError={(err) => setError(String(err))}
|
||||
className="object-contain w-full h-full"
|
||||
/>
|
||||
</AspectRatio>
|
||||
|
||||
{/* Recording Overlay */}
|
||||
{isRecording && (
|
||||
<div className="absolute top-2 right-2 flex items-center gap-2 bg-black/50 px-2 py-1 rounded-full backdrop-blur-sm">
|
||||
<div className="w-2 h-2 rounded-full bg-red-500 animate-pulse" />
|
||||
<span className="text-[10px] font-medium text-white">REC</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Uploading Overlay */}
|
||||
{uploading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="flex flex-col items-center gap-2 text-white">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
<span className="text-xs font-medium">Uploading...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/80">
|
||||
<Alert variant="destructive" className="max-w-xs">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-slate-500">
|
||||
<CameraOff className="mx-auto mb-2 h-12 w-12 opacity-20" />
|
||||
<p className="text-sm">Camera is disabled</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-4"
|
||||
onClick={handleEnableCamera}
|
||||
>
|
||||
Enable Camera
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -98,6 +98,7 @@ interface WizardControlPanelProps {
|
||||
onTabChange: (tab: "control" | "step" | "actions" | "robot") => void;
|
||||
isStarting?: boolean;
|
||||
onSetAutonomousLife?: (enabled: boolean) => Promise<boolean | void>;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export function WizardControlPanel({
|
||||
@@ -118,6 +119,7 @@ export function WizardControlPanel({
|
||||
onTabChange,
|
||||
isStarting = false,
|
||||
onSetAutonomousLife,
|
||||
readOnly = false,
|
||||
}: WizardControlPanelProps) {
|
||||
const [autonomousLife, setAutonomousLife] = React.useState(true);
|
||||
|
||||
@@ -187,7 +189,7 @@ export function WizardControlPanel({
|
||||
}}
|
||||
className="w-full"
|
||||
size="sm"
|
||||
disabled={isStarting}
|
||||
disabled={isStarting || readOnly}
|
||||
>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
{isStarting ? "Starting..." : "Start Trial"}
|
||||
@@ -201,14 +203,14 @@ export function WizardControlPanel({
|
||||
onClick={onPauseTrial}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={false}
|
||||
disabled={readOnly}
|
||||
>
|
||||
<Pause className="mr-1 h-3 w-3" />
|
||||
Pause
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onNextStep}
|
||||
disabled={currentStepIndex >= steps.length - 1}
|
||||
disabled={(currentStepIndex >= steps.length - 1) || readOnly}
|
||||
size="sm"
|
||||
>
|
||||
<SkipForward className="mr-1 h-3 w-3" />
|
||||
@@ -223,6 +225,7 @@ export function WizardControlPanel({
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
size="sm"
|
||||
disabled={readOnly}
|
||||
>
|
||||
<CheckCircle className="mr-2 h-4 w-4" />
|
||||
Complete Trial
|
||||
@@ -233,6 +236,7 @@ export function WizardControlPanel({
|
||||
variant="destructive"
|
||||
className="w-full"
|
||||
size="sm"
|
||||
disabled={readOnly}
|
||||
>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
Abort Trial
|
||||
@@ -277,7 +281,7 @@ export function WizardControlPanel({
|
||||
id="autonomous-life"
|
||||
checked={autonomousLife}
|
||||
onCheckedChange={handleAutonomousLifeChange}
|
||||
disabled={!_isConnected}
|
||||
disabled={!_isConnected || readOnly}
|
||||
className="scale-75"
|
||||
/>
|
||||
</div>
|
||||
@@ -368,7 +372,7 @@ export function WizardControlPanel({
|
||||
console.log("[WizardControlPanel] Acknowledge clicked");
|
||||
onExecuteAction("acknowledge");
|
||||
}}
|
||||
disabled={false}
|
||||
disabled={readOnly}
|
||||
>
|
||||
<CheckCircle className="mr-2 h-3 w-3" />
|
||||
Acknowledge
|
||||
@@ -382,7 +386,7 @@ export function WizardControlPanel({
|
||||
console.log("[WizardControlPanel] Intervene clicked");
|
||||
onExecuteAction("intervene");
|
||||
}}
|
||||
disabled={false}
|
||||
disabled={readOnly}
|
||||
>
|
||||
<AlertCircle className="mr-2 h-3 w-3" />
|
||||
Intervene
|
||||
@@ -396,7 +400,7 @@ export function WizardControlPanel({
|
||||
console.log("[WizardControlPanel] Add Note clicked");
|
||||
onExecuteAction("note", { content: "Wizard note" });
|
||||
}}
|
||||
disabled={false}
|
||||
disabled={readOnly}
|
||||
>
|
||||
<User className="mr-2 h-3 w-3" />
|
||||
Add Note
|
||||
@@ -412,7 +416,7 @@ export function WizardControlPanel({
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
onClick={() => onExecuteAction("step_complete")}
|
||||
disabled={false}
|
||||
disabled={readOnly}
|
||||
>
|
||||
<CheckCircle className="mr-2 h-3 w-3" />
|
||||
Mark Complete
|
||||
@@ -441,11 +445,13 @@ export function WizardControlPanel({
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-3">
|
||||
{studyId && onExecuteRobotAction ? (
|
||||
<RobotActionsPanel
|
||||
studyId={studyId}
|
||||
trialId={trial.id}
|
||||
onExecuteAction={onExecuteRobotAction}
|
||||
/>
|
||||
<div className={readOnly ? "pointer-events-none opacity-50" : ""}>
|
||||
<RobotActionsPanel
|
||||
studyId={studyId}
|
||||
trialId={trial.id}
|
||||
onExecuteAction={onExecuteRobotAction}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
|
||||
@@ -10,18 +10,13 @@ import {
|
||||
User,
|
||||
Activity,
|
||||
Zap,
|
||||
Eye,
|
||||
List,
|
||||
Loader2,
|
||||
ArrowRight,
|
||||
AlertTriangle,
|
||||
RotateCcw,
|
||||
} from "lucide-react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||
|
||||
interface StepData {
|
||||
id: string;
|
||||
@@ -107,6 +102,7 @@ interface WizardExecutionPanelProps {
|
||||
onCompleteTrial?: () => void;
|
||||
completedActionsCount: number;
|
||||
onActionCompleted: () => void;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export function WizardExecutionPanel({
|
||||
@@ -126,47 +122,13 @@ export function WizardExecutionPanel({
|
||||
onCompleteTrial,
|
||||
completedActionsCount,
|
||||
onActionCompleted,
|
||||
readOnly = false,
|
||||
}: WizardExecutionPanelProps) {
|
||||
// Local state removed in favor of parent state to prevent reset on re-render
|
||||
// const [completedCount, setCompletedCount] = React.useState(0);
|
||||
|
||||
const activeActionIndex = completedActionsCount;
|
||||
|
||||
const getStepIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case "wizard_action":
|
||||
return User;
|
||||
case "robot_action":
|
||||
return Bot;
|
||||
case "parallel_steps":
|
||||
return Activity;
|
||||
case "conditional_branch":
|
||||
return AlertCircle;
|
||||
default:
|
||||
return Play;
|
||||
}
|
||||
};
|
||||
|
||||
const getStepStatus = (stepIndex: number) => {
|
||||
if (stepIndex < currentStepIndex) return "completed";
|
||||
if (stepIndex === currentStepIndex && trial.status === "in_progress")
|
||||
return "active";
|
||||
return "pending";
|
||||
};
|
||||
|
||||
const getStepVariant = (status: string) => {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return "default";
|
||||
case "active":
|
||||
return "secondary";
|
||||
case "pending":
|
||||
return "outline";
|
||||
default:
|
||||
return "outline";
|
||||
}
|
||||
};
|
||||
|
||||
// Pre-trial state
|
||||
if (trial.status === "scheduled") {
|
||||
return (
|
||||
@@ -252,7 +214,7 @@ export function WizardExecutionPanel({
|
||||
</div>
|
||||
|
||||
{/* Simplified Content - Sequential Focus */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="relative flex-1 overflow-hidden">
|
||||
<ScrollArea className="h-full">
|
||||
{currentStep ? (
|
||||
<div className="flex flex-col gap-6 p-6">
|
||||
@@ -281,7 +243,6 @@ export function WizardExecutionPanel({
|
||||
{currentStep.actions.map((action, idx) => {
|
||||
const isCompleted = idx < activeActionIndex;
|
||||
const isActive = idx === activeActionIndex;
|
||||
const isPending = idx > activeActionIndex;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -328,6 +289,7 @@ export function WizardExecutionPanel({
|
||||
);
|
||||
onActionCompleted();
|
||||
}}
|
||||
disabled={readOnly}
|
||||
>
|
||||
Skip
|
||||
</Button>
|
||||
@@ -348,6 +310,7 @@ export function WizardExecutionPanel({
|
||||
);
|
||||
onActionCompleted();
|
||||
}}
|
||||
disabled={readOnly || isExecuting}
|
||||
>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Execute
|
||||
@@ -364,6 +327,7 @@ export function WizardExecutionPanel({
|
||||
e.preventDefault();
|
||||
onActionCompleted();
|
||||
}}
|
||||
disabled={readOnly || isExecuting}
|
||||
>
|
||||
Mark Done
|
||||
</Button>
|
||||
@@ -394,6 +358,7 @@ export function WizardExecutionPanel({
|
||||
{ autoAdvance: false },
|
||||
);
|
||||
}}
|
||||
disabled={readOnly || isExecuting}
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -410,6 +375,7 @@ export function WizardExecutionPanel({
|
||||
category: "system_issue"
|
||||
});
|
||||
}}
|
||||
disabled={readOnly}
|
||||
>
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -432,6 +398,7 @@ export function WizardExecutionPanel({
|
||||
? "bg-blue-600 hover:bg-blue-700"
|
||||
: "bg-green-600 hover:bg-green-700"
|
||||
}`}
|
||||
disabled={readOnly || isExecuting}
|
||||
>
|
||||
{currentStepIndex === steps.length - 1 ? "Complete Trial" : "Complete Step"}
|
||||
<ArrowRight className="ml-2 h-5 w-5" />
|
||||
@@ -445,22 +412,15 @@ export function WizardExecutionPanel({
|
||||
{currentStep.type === "wizard_action" && (
|
||||
<div className="rounded-xl border border-dashed p-6 space-y-4">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Manual Controls</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-12 justify-start"
|
||||
onClick={() => onExecuteAction("acknowledge")}
|
||||
>
|
||||
<CheckCircle className="mr-2 h-4 w-4" />
|
||||
Acknowledge
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-12 justify-start"
|
||||
className="h-12 justify-start border-yellow-200 bg-yellow-50 text-yellow-700 hover:bg-yellow-100 hover:text-yellow-800"
|
||||
onClick={() => onExecuteAction("intervene")}
|
||||
disabled={readOnly}
|
||||
>
|
||||
<Zap className="mr-2 h-4 w-4" />
|
||||
Intervene
|
||||
Flag Issue / Intervention
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -472,6 +432,8 @@ export function WizardExecutionPanel({
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
{/* Scroll Hint Fade */}
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-20 bg-gradient-to-t from-background to-transparent z-10" />
|
||||
</div>
|
||||
</div >
|
||||
);
|
||||
|
||||
@@ -11,8 +11,8 @@ import { Badge } from "~/components/ui/badge";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||
import { Progress } from "~/components/ui/progress";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { WebcamPanel } from "./WebcamPanel";
|
||||
|
||||
interface WizardMonitoringPanelProps {
|
||||
rosConnected: boolean;
|
||||
@@ -33,6 +33,7 @@ interface WizardMonitoringPanelProps {
|
||||
actionId: string,
|
||||
parameters: Record<string, unknown>,
|
||||
) => Promise<unknown>;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
const WizardMonitoringPanel = function WizardMonitoringPanel({
|
||||
@@ -43,296 +44,315 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
|
||||
connectRos,
|
||||
disconnectRos,
|
||||
executeRosAction,
|
||||
readOnly = false,
|
||||
}: WizardMonitoringPanelProps) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b p-3">
|
||||
<h2 className="text-sm font-semibold">Robot Control</h2>
|
||||
<div className="flex h-full flex-col gap-2 p-2">
|
||||
{/* Camera View - Always Visible */}
|
||||
<div className="shrink-0 bg-black rounded-lg overflow-hidden border shadow-sm h-48 sm:h-56 relative group">
|
||||
<WebcamPanel readOnly={readOnly} />
|
||||
</div>
|
||||
|
||||
{/* Robot Status and Controls */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="space-y-4 p-3">
|
||||
{/* Robot Status */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium">Robot Status</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{rosConnected ? (
|
||||
<Power className="h-3 w-3 text-green-600" />
|
||||
) : (
|
||||
<PowerOff className="h-3 w-3 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Robot Controls - Scrollable */}
|
||||
<div className="flex-1 min-h-0 bg-background rounded-lg border shadow-sm overflow-hidden flex flex-col">
|
||||
<div className="px-3 py-2 border-b bg-muted/30 flex items-center gap-2">
|
||||
<Bot className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Robot Control</span>
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="space-y-4 p-3">
|
||||
{/* Robot Status */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
ROS Bridge
|
||||
</span>
|
||||
<div className="text-sm font-medium">Robot Status</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Badge
|
||||
variant={
|
||||
rosConnected
|
||||
? "default"
|
||||
: rosError
|
||||
? "destructive"
|
||||
: "outline"
|
||||
}
|
||||
className="text-xs"
|
||||
>
|
||||
{rosConnecting
|
||||
? "Connecting..."
|
||||
: rosConnected
|
||||
? "Ready"
|
||||
: rosError
|
||||
? "Failed"
|
||||
: "Offline"}
|
||||
</Badge>
|
||||
{rosConnected && (
|
||||
<span className="animate-pulse text-xs text-green-600">
|
||||
●
|
||||
</span>
|
||||
)}
|
||||
{rosConnecting && (
|
||||
<span className="animate-spin text-xs text-blue-600">
|
||||
⟳
|
||||
</span>
|
||||
{rosConnected ? (
|
||||
<Power className="h-3 w-3 text-green-600" />
|
||||
) : (
|
||||
<PowerOff className="h-3 w-3 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
ROS Bridge
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Badge
|
||||
variant={
|
||||
rosConnected
|
||||
? "default"
|
||||
: rosError
|
||||
? "destructive"
|
||||
: "outline"
|
||||
}
|
||||
className="text-xs"
|
||||
>
|
||||
{rosConnecting
|
||||
? "Connecting..."
|
||||
: rosConnected
|
||||
? "Ready"
|
||||
: rosError
|
||||
? "Failed"
|
||||
: "Offline"}
|
||||
</Badge>
|
||||
{rosConnected && (
|
||||
<span className="animate-pulse text-xs text-green-600">
|
||||
●
|
||||
</span>
|
||||
)}
|
||||
{rosConnecting && (
|
||||
<span className="animate-spin text-xs text-blue-600">
|
||||
⟳
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ROS Connection Controls */}
|
||||
<div className="pt-2">
|
||||
{!rosConnected ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full text-xs"
|
||||
onClick={() => connectRos()}
|
||||
disabled={rosConnecting || rosConnected}
|
||||
>
|
||||
<Bot className="mr-1 h-3 w-3" />
|
||||
{rosConnecting
|
||||
? "Connecting..."
|
||||
: rosConnected
|
||||
? "Connected ✓"
|
||||
: "Connect to NAO6"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full text-xs"
|
||||
onClick={() => disconnectRos()}
|
||||
>
|
||||
<PowerOff className="mr-1 h-3 w-3" />
|
||||
Disconnect
|
||||
</Button>
|
||||
{/* ROS Connection Controls */}
|
||||
<div className="pt-2">
|
||||
{!rosConnected ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full text-xs"
|
||||
onClick={() => connectRos()}
|
||||
disabled={rosConnecting || rosConnected || readOnly}
|
||||
>
|
||||
<Bot className="mr-1 h-3 w-3" />
|
||||
{rosConnecting
|
||||
? "Connecting..."
|
||||
: rosConnected
|
||||
? "Connected ✓"
|
||||
: "Connect to NAO6"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full text-xs"
|
||||
onClick={() => disconnectRos()}
|
||||
disabled={readOnly}
|
||||
>
|
||||
<PowerOff className="mr-1 h-3 w-3" />
|
||||
Disconnect
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{rosError && (
|
||||
<Alert variant="destructive" className="mt-2">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription className="text-xs">
|
||||
{rosError}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!rosConnected && !rosConnecting && (
|
||||
<div className="mt-4">
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription className="text-xs">
|
||||
Connect to ROS bridge for live robot monitoring and
|
||||
control.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{rosError && (
|
||||
<Alert variant="destructive" className="mt-2">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription className="text-xs">
|
||||
{rosError}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Separator />
|
||||
|
||||
{/* Movement Controls */}
|
||||
{rosConnected && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Movement</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{/* Row 1: Turn Left, Forward, Turn Right */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-xs"
|
||||
onClick={() => {
|
||||
executeRosAction("nao6-ros2", "turn_left", {
|
||||
speed: 0.3,
|
||||
}).catch(console.error);
|
||||
}}
|
||||
disabled={readOnly}
|
||||
>
|
||||
↺ Turn L
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-xs"
|
||||
onClick={() => {
|
||||
executeRosAction("nao6-ros2", "walk_forward", {
|
||||
speed: 0.5,
|
||||
}).catch(console.error);
|
||||
}}
|
||||
disabled={readOnly}
|
||||
>
|
||||
↑ Forward
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-xs"
|
||||
onClick={() => {
|
||||
executeRosAction("nao6-ros2", "turn_right", {
|
||||
speed: 0.3,
|
||||
}).catch(console.error);
|
||||
}}
|
||||
disabled={readOnly}
|
||||
>
|
||||
Turn R ↻
|
||||
</Button>
|
||||
|
||||
{/* Row 2: Left, Stop, Right */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-xs"
|
||||
onClick={() => {
|
||||
executeRosAction("nao6-ros2", "strafe_left", {
|
||||
speed: 0.3,
|
||||
}).catch(console.error);
|
||||
}}
|
||||
disabled={readOnly}
|
||||
>
|
||||
← Left
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
className="text-xs"
|
||||
onClick={() => {
|
||||
executeRosAction("nao6-ros2", "emergency_stop", {}).catch(
|
||||
console.error,
|
||||
);
|
||||
}}
|
||||
disabled={readOnly}
|
||||
>
|
||||
■ Stop
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-xs"
|
||||
onClick={() => {
|
||||
executeRosAction("nao6-ros2", "strafe_right", {
|
||||
speed: 0.3,
|
||||
}).catch(console.error);
|
||||
}}
|
||||
disabled={readOnly}
|
||||
>
|
||||
Right →
|
||||
</Button>
|
||||
|
||||
{/* Row 3: Empty, Back, Empty */}
|
||||
<div></div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-xs"
|
||||
onClick={() => {
|
||||
executeRosAction("nao6-ros2", "walk_backward", {
|
||||
speed: 0.3,
|
||||
}).catch(console.error);
|
||||
}}
|
||||
disabled={readOnly}
|
||||
>
|
||||
↓ Back
|
||||
</Button>
|
||||
<div></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!rosConnected && !rosConnecting && (
|
||||
<div className="mt-4">
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription className="text-xs">
|
||||
Connect to ROS bridge for live robot monitoring and
|
||||
control.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Separator />
|
||||
|
||||
{/* Quick Actions */}
|
||||
{rosConnected && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Quick Actions</div>
|
||||
|
||||
{/* TTS Input */}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Type text to speak..."
|
||||
className="flex-1 rounded-md border border-input bg-background px-2 py-1 text-xs ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
|
||||
disabled={readOnly}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && e.currentTarget.value.trim() && !readOnly) {
|
||||
executeRosAction("nao6-ros2", "say_text", {
|
||||
text: e.currentTarget.value.trim(),
|
||||
}).catch(console.error);
|
||||
e.currentTarget.value = "";
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-xs"
|
||||
onClick={(e) => {
|
||||
const input = e.currentTarget.previousElementSibling as HTMLInputElement;
|
||||
if (input?.value.trim()) {
|
||||
executeRosAction("nao6-ros2", "say_text", {
|
||||
text: input.value.trim(),
|
||||
}).catch(console.error);
|
||||
input.value = "";
|
||||
}
|
||||
}}
|
||||
disabled={readOnly}
|
||||
>
|
||||
Say
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Preset Actions */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-xs"
|
||||
onClick={() => {
|
||||
if (rosConnected) {
|
||||
executeRosAction("nao6-ros2", "say_text", {
|
||||
text: "Hello! I am NAO!",
|
||||
}).catch(console.error);
|
||||
}
|
||||
}}
|
||||
disabled={readOnly}
|
||||
>
|
||||
Say Hello
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-xs"
|
||||
onClick={() => {
|
||||
if (rosConnected) {
|
||||
executeRosAction("nao6-ros2", "say_text", {
|
||||
text: "I am ready!",
|
||||
}).catch(console.error);
|
||||
}
|
||||
}}
|
||||
disabled={readOnly}
|
||||
>
|
||||
Say Ready
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Movement Controls */}
|
||||
{rosConnected && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Movement</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{/* Row 1: Turn Left, Forward, Turn Right */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-xs"
|
||||
onClick={() => {
|
||||
executeRosAction("nao6-ros2", "turn_left", {
|
||||
speed: 0.3,
|
||||
}).catch(console.error);
|
||||
}}
|
||||
>
|
||||
↺ Turn L
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-xs"
|
||||
onClick={() => {
|
||||
executeRosAction("nao6-ros2", "walk_forward", {
|
||||
speed: 0.5,
|
||||
}).catch(console.error);
|
||||
}}
|
||||
>
|
||||
↑ Forward
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-xs"
|
||||
onClick={() => {
|
||||
executeRosAction("nao6-ros2", "turn_right", {
|
||||
speed: 0.3,
|
||||
}).catch(console.error);
|
||||
}}
|
||||
>
|
||||
Turn R ↻
|
||||
</Button>
|
||||
|
||||
{/* Row 2: Left, Stop, Right */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-xs"
|
||||
onClick={() => {
|
||||
executeRosAction("nao6-ros2", "strafe_left", {
|
||||
speed: 0.3,
|
||||
}).catch(console.error);
|
||||
}}
|
||||
>
|
||||
← Left
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
className="text-xs"
|
||||
onClick={() => {
|
||||
executeRosAction("nao6-ros2", "emergency_stop", {}).catch(
|
||||
console.error,
|
||||
);
|
||||
}}
|
||||
>
|
||||
■ Stop
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-xs"
|
||||
onClick={() => {
|
||||
executeRosAction("nao6-ros2", "strafe_right", {
|
||||
speed: 0.3,
|
||||
}).catch(console.error);
|
||||
}}
|
||||
>
|
||||
Right →
|
||||
</Button>
|
||||
|
||||
{/* Row 3: Empty, Back, Empty */}
|
||||
<div></div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-xs"
|
||||
onClick={() => {
|
||||
executeRosAction("nao6-ros2", "walk_backward", {
|
||||
speed: 0.3,
|
||||
}).catch(console.error);
|
||||
}}
|
||||
>
|
||||
↓ Back
|
||||
</Button>
|
||||
<div></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Quick Actions */}
|
||||
{rosConnected && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Quick Actions</div>
|
||||
|
||||
{/* TTS Input */}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Type text to speak..."
|
||||
className="flex-1 rounded-md border border-input bg-background px-2 py-1 text-xs ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && e.currentTarget.value.trim()) {
|
||||
executeRosAction("nao6-ros2", "say_text", {
|
||||
text: e.currentTarget.value.trim(),
|
||||
}).catch(console.error);
|
||||
e.currentTarget.value = "";
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-xs"
|
||||
onClick={(e) => {
|
||||
const input = e.currentTarget.previousElementSibling as HTMLInputElement;
|
||||
if (input?.value.trim()) {
|
||||
executeRosAction("nao6-ros2", "say_text", {
|
||||
text: input.value.trim(),
|
||||
}).catch(console.error);
|
||||
input.value = "";
|
||||
}
|
||||
}}
|
||||
>
|
||||
Say
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Preset Actions */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-xs"
|
||||
onClick={() => {
|
||||
if (rosConnected) {
|
||||
executeRosAction("nao6-ros2", "say_text", {
|
||||
text: "Hello! I am NAO!",
|
||||
}).catch(console.error);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Say Hello
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-xs"
|
||||
onClick={() => {
|
||||
if (rosConnected) {
|
||||
executeRosAction("nao6-ros2", "say_text", {
|
||||
text: "I am ready!",
|
||||
}).catch(console.error);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Say Ready
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user