mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-03-24 03:37:51 -04:00
feat: enhance experiment designer action definitions, refactor trial analysis UI, and update video playback controls
This commit is contained in:
@@ -1149,7 +1149,7 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
<TabsTrigger value="robot" className="text-xs flex-1">Robot Control</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="camera_obs" className="flex-1 flex flex-col m-0 p-0 h-full overflow-hidden min-h-0">
|
||||
<TabsContent value="camera_obs" className="flex-1 flex-col m-0 p-0 h-full overflow-hidden min-h-0 data-[state=active]:flex">
|
||||
<div className="flex-none bg-muted/30 border-b h-48 sm:h-56 relative group shrink-0">
|
||||
<WebcamPanel readOnly={trial.status === 'completed'} trialId={trial.id} trialStatus={trial.status} />
|
||||
</div>
|
||||
@@ -1164,7 +1164,7 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="robot" className="flex-1 m-0 h-full overflow-hidden">
|
||||
<TabsContent value="robot" className="flex-1 flex-col m-0 p-0 h-full overflow-hidden min-h-0 data-[state=active]:flex">
|
||||
<WizardMonitoringPanel
|
||||
rosConnected={rosConnected}
|
||||
rosConnecting={rosConnecting}
|
||||
|
||||
@@ -4,21 +4,12 @@ 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, trialId, trialStatus }: { readOnly?: boolean; trialId?: string; trialStatus?: string }) {
|
||||
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);
|
||||
@@ -35,19 +26,11 @@ export function WebcamPanel({ readOnly = false, trialId, trialStatus }: { readOn
|
||||
const saveRecordingMutation = api.storage.saveRecording.useMutation();
|
||||
const logEventMutation = api.trials.logEvent.useMutation();
|
||||
|
||||
const handleDevices = useCallback(
|
||||
(mediaDevices: MediaDeviceInfo[]) => {
|
||||
setDevices(mediaDevices.filter(({ kind, deviceId }) => kind === "videoinput" && deviceId !== ""));
|
||||
},
|
||||
[setDevices],
|
||||
);
|
||||
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setIsMounted(true);
|
||||
navigator.mediaDevices.enumerateDevices().then(handleDevices);
|
||||
}, [handleDevices]);
|
||||
}, []);
|
||||
|
||||
const handleEnableCamera = () => {
|
||||
setIsCameraEnabled(true);
|
||||
@@ -87,6 +70,10 @@ export function WebcamPanel({ readOnly = false, trialId, trialStatus }: { readOn
|
||||
|
||||
const handleStartRecording = () => {
|
||||
if (!webcamRef.current?.stream) return;
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state === "recording") {
|
||||
console.log("Already recording, skipping start");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRecording(true);
|
||||
chunksRef.current = [];
|
||||
@@ -125,7 +112,7 @@ export function WebcamPanel({ readOnly = false, trialId, trialStatus }: { readOn
|
||||
};
|
||||
|
||||
const handleStopRecording = () => {
|
||||
if (mediaRecorderRef.current && isRecording) {
|
||||
if (mediaRecorderRef.current && isRecording && mediaRecorderRef.current.state === "recording") {
|
||||
mediaRecorderRef.current.stop();
|
||||
setIsRecording(false);
|
||||
if (trialId) {
|
||||
@@ -197,32 +184,10 @@ export function WebcamPanel({ readOnly = false, trialId, trialStatus }: { readOn
|
||||
|
||||
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>
|
||||
<div className="flex items-center justify-end border-b px-2 py-1 bg-muted/10 h-10 shrink-0">
|
||||
|
||||
{!readOnly && (
|
||||
<div className="flex items-center gap-2">
|
||||
{devices.length > 0 && isMounted && (
|
||||
<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 ? (
|
||||
@@ -284,7 +249,6 @@ export function WebcamPanel({ readOnly = false, trialId, trialStatus }: { readOn
|
||||
audio={false}
|
||||
width="100%"
|
||||
height="100%"
|
||||
videoConstraints={{ deviceId: deviceId ?? undefined }}
|
||||
onUserMedia={handleUserMedia}
|
||||
onUserMediaError={(err) => setError(String(err))}
|
||||
className="object-contain w-full h-full"
|
||||
@@ -334,6 +298,6 @@ export function WebcamPanel({ readOnly = false, trialId, trialStatus }: { readOn
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div >
|
||||
);
|
||||
}
|
||||
|
||||
@@ -82,10 +82,6 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
|
||||
<div className="flex h-full flex-col p-2">
|
||||
{/* 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 */}
|
||||
|
||||
@@ -47,6 +47,14 @@ export function WizardObservationPane({
|
||||
const [tags, setTags] = useState<string[]>([]);
|
||||
const [currentTag, setCurrentTag] = useState("");
|
||||
|
||||
const placeholders: Record<string, string> = {
|
||||
observation: "Type your observation here...",
|
||||
participant_behavior: "Describe the participant's behavior...",
|
||||
system_issue: "Describe the system issue...",
|
||||
success: "Describe the success...",
|
||||
failure: "Describe the failure...",
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!note.trim()) return;
|
||||
|
||||
@@ -72,10 +80,10 @@ export function WizardObservationPane({
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-background">
|
||||
<div className="flex-1 flex flex-col p-4 m-0">
|
||||
<div className="flex-1 flex flex-col p-4 m-0 overflow-hidden">
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Textarea
|
||||
placeholder={readOnly ? "Session is read-only" : "Type your observation here..."}
|
||||
placeholder={readOnly ? "Session is read-only" : (placeholders[category] || "Type your observation here...")}
|
||||
className="flex-1 resize-none font-mono text-sm"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
@@ -83,60 +91,66 @@ export function WizardObservationPane({
|
||||
disabled={readOnly}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={category} onValueChange={setCategory} disabled={readOnly}>
|
||||
<SelectTrigger className="w-[140px] h-8 text-xs">
|
||||
<SelectValue placeholder="Category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="observation">Observation</SelectItem>
|
||||
<SelectItem value="participant_behavior">Behavior</SelectItem>
|
||||
<SelectItem value="system_issue">System Issue</SelectItem>
|
||||
<SelectItem value="success">Success</SelectItem>
|
||||
<SelectItem value="failure">Failure</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex flex-col gap-2 shrink-0">
|
||||
{/* Top Line: Category & Tags */}
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<Select value={category} onValueChange={setCategory} disabled={readOnly}>
|
||||
<SelectTrigger className="w-[140px] h-8 text-xs shrink-0">
|
||||
<SelectValue placeholder="Category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="observation">Observation</SelectItem>
|
||||
<SelectItem value="participant_behavior">Behavior</SelectItem>
|
||||
<SelectItem value="system_issue">System Issue</SelectItem>
|
||||
<SelectItem value="success">Success</SelectItem>
|
||||
<SelectItem value="failure">Failure</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="flex flex-1 items-center gap-2 rounded-md border px-2 h-8">
|
||||
<Tag className={`h-3 w-3 ${readOnly ? "text-muted-foreground/50" : "text-muted-foreground"}`} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={readOnly ? "" : "Add tags..."}
|
||||
className="flex-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed"
|
||||
value={currentTag}
|
||||
onChange={(e) => setCurrentTag(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
addTag();
|
||||
}
|
||||
}}
|
||||
onBlur={addTag}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
<div className="flex flex-1 min-w-[80px] items-center gap-2 rounded-md border px-2 h-8">
|
||||
<Tag className={`h-3 w-3 shrink-0 ${readOnly ? "text-muted-foreground/50" : "text-muted-foreground"}`} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={readOnly ? "" : "Add tags..."}
|
||||
className="flex-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed w-full min-w-0"
|
||||
value={currentTag}
|
||||
onChange={(e) => setCurrentTag(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
addTag();
|
||||
}
|
||||
}}
|
||||
onBlur={addTag}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting || !note.trim() || readOnly}
|
||||
className="h-8 shrink-0"
|
||||
>
|
||||
<Send className="mr-2 h-3 w-3" />
|
||||
Add Note
|
||||
</Button>
|
||||
{onFlagIntervention && (
|
||||
{/* Bottom Line: Actions */}
|
||||
<div className="flex items-center justify-end gap-2 w-full">
|
||||
{onFlagIntervention && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onFlagIntervention()}
|
||||
disabled={readOnly}
|
||||
className="h-8 shrink-0 flex-1 sm:flex-none border-yellow-200 bg-yellow-50 text-yellow-700 hover:bg-yellow-100 hover:text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-300 dark:border-yellow-700/50 dark:hover:bg-yellow-900/40"
|
||||
>
|
||||
<AlertTriangle className="mr-2 h-3 w-3" />
|
||||
Intervention
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onFlagIntervention()}
|
||||
disabled={readOnly}
|
||||
className="h-8 shrink-0 border-yellow-200 bg-yellow-50 text-yellow-700 hover:bg-yellow-100 hover:text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-300 dark:border-yellow-700/50 dark:hover:bg-yellow-900/40"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting || !note.trim() || readOnly}
|
||||
className="h-8 shrink-0 flex-1 sm:flex-none"
|
||||
>
|
||||
<AlertTriangle className="mr-2 h-3 w-3" />
|
||||
Intervention
|
||||
<Send className="mr-2 h-3 w-3" />
|
||||
Save Note
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tags.length > 0 && (
|
||||
|
||||
Reference in New Issue
Block a user