mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-02-04 23:46:32 -05:00
Break work
This commit is contained in:
@@ -1,25 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import {
|
||||
Activity,
|
||||
BarChart3,
|
||||
Calendar,
|
||||
Download,
|
||||
Search,
|
||||
Filter,
|
||||
TrendingDown,
|
||||
TrendingUp,
|
||||
PlayCircle,
|
||||
Calendar,
|
||||
Clock,
|
||||
ChevronRight,
|
||||
User,
|
||||
LayoutGrid
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import { PageHeader } from "~/components/ui/page-header";
|
||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||
import { useStudyContext } from "~/lib/study-context";
|
||||
import { useSelectedStudyDetails } from "~/hooks/useSelectedStudyDetails";
|
||||
import { api } from "~/trpc/react";
|
||||
import { TrialAnalysisView } from "~/components/trials/views/TrialAnalysisView";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -27,283 +27,180 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { PageHeader } from "~/components/ui/page-header";
|
||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||
import { useStudyContext } from "~/lib/study-context";
|
||||
import { useSelectedStudyDetails } from "~/hooks/useSelectedStudyDetails";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
|
||||
// Mock chart component - replace with actual charting library
|
||||
function MockChart({ title, data }: { title: string; data: number[] }) {
|
||||
const maxValue = Math.max(...data);
|
||||
// -- Sub-Components --
|
||||
|
||||
function AnalyticsContent({
|
||||
selectedTrialId,
|
||||
setSelectedTrialId,
|
||||
trialsList,
|
||||
isLoadingList
|
||||
}: {
|
||||
selectedTrialId: string | null;
|
||||
setSelectedTrialId: (id: string | null) => void;
|
||||
trialsList: any[];
|
||||
isLoadingList: boolean;
|
||||
}) {
|
||||
|
||||
// Fetch full details of selected trial
|
||||
const {
|
||||
data: selectedTrial,
|
||||
isLoading: isLoadingTrial,
|
||||
error: trialError
|
||||
} = api.trials.get.useQuery(
|
||||
{ id: selectedTrialId! },
|
||||
{ enabled: !!selectedTrialId }
|
||||
);
|
||||
|
||||
// Transform trial data
|
||||
const trialData = selectedTrial ? {
|
||||
...selectedTrial,
|
||||
startedAt: selectedTrial.startedAt ? new Date(selectedTrial.startedAt) : null,
|
||||
completedAt: selectedTrial.completedAt ? new Date(selectedTrial.completedAt) : null,
|
||||
eventCount: (selectedTrial as any).eventCount,
|
||||
mediaCount: (selectedTrial as any).mediaCount,
|
||||
} : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium">{title}</h4>
|
||||
<div className="flex h-32 items-end space-x-1">
|
||||
{data.map((value, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="bg-primary min-h-[4px] flex-1 rounded-t"
|
||||
style={{ height: `${(value / maxValue) * 100}%` }}
|
||||
<div className="h-[calc(100vh-140px)] flex flex-col">
|
||||
{selectedTrialId ? (
|
||||
isLoadingTrial ? (
|
||||
<div className="flex-1 flex items-center justify-center bg-background/50 rounded-lg border border-dashed">
|
||||
<div className="flex flex-col items-center gap-2 animate-pulse">
|
||||
<div className="h-8 w-8 rounded-full border-2 border-primary border-t-transparent animate-spin" />
|
||||
<span className="text-muted-foreground text-sm">Loading trial data...</span>
|
||||
</div>
|
||||
</div>
|
||||
) : trialError ? (
|
||||
<div className="flex-1 flex items-center justify-center p-8 bg-background/50 rounded-lg border border-dashed text-destructive">
|
||||
<div className="max-w-md text-center">
|
||||
<h3 className="font-semibold mb-2">Error Loading Trial</h3>
|
||||
<p className="text-sm opacity-80">{trialError.message}</p>
|
||||
<Button variant="outline" className="mt-4" onClick={() => setSelectedTrialId(null)}>
|
||||
Return to Overview
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : trialData ? (
|
||||
<TrialAnalysisView trial={trialData} />
|
||||
) : null
|
||||
) : (
|
||||
<div className="flex-1 bg-background/50 rounded-lg border shadow-sm overflow-hidden">
|
||||
<StudyOverviewPlaceholder
|
||||
trials={trialsList ?? []}
|
||||
onSelect={(id) => setSelectedTrialId(id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AnalyticsOverview() {
|
||||
const metrics = [
|
||||
{
|
||||
title: "Total Trials This Month",
|
||||
value: "142",
|
||||
change: "+12%",
|
||||
trend: "up",
|
||||
description: "vs last month",
|
||||
icon: Activity,
|
||||
},
|
||||
{
|
||||
title: "Avg Trial Duration",
|
||||
value: "24.5m",
|
||||
change: "-3%",
|
||||
trend: "down",
|
||||
description: "vs last month",
|
||||
icon: Calendar,
|
||||
},
|
||||
{
|
||||
title: "Completion Rate",
|
||||
value: "94.2%",
|
||||
change: "+2.1%",
|
||||
trend: "up",
|
||||
description: "vs last month",
|
||||
icon: TrendingUp,
|
||||
},
|
||||
{
|
||||
title: "Participant Retention",
|
||||
value: "87.3%",
|
||||
change: "+5.4%",
|
||||
trend: "up",
|
||||
description: "vs last month",
|
||||
icon: BarChart3,
|
||||
},
|
||||
];
|
||||
function StudyOverviewPlaceholder({ trials, onSelect }: { trials: any[], onSelect: (id: string) => void }) {
|
||||
const recentTrials = [...trials].sort((a, b) =>
|
||||
new Date(b.startedAt || b.createdAt).getTime() - new Date(a.startedAt || a.createdAt).getTime()
|
||||
).slice(0, 5);
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{metrics.map((metric) => (
|
||||
<Card key={metric.title}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
{metric.title}
|
||||
</CardTitle>
|
||||
<metric.icon className="text-muted-foreground h-4 w-4" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{metric.value}</div>
|
||||
<div className="text-muted-foreground flex items-center space-x-2 text-xs">
|
||||
<span
|
||||
className={`flex items-center ${
|
||||
metric.trend === "up" ? "text-green-600" : "text-red-600"
|
||||
}`}
|
||||
>
|
||||
{metric.trend === "up" ? (
|
||||
<TrendingUp className="mr-1 h-3 w-3" />
|
||||
) : (
|
||||
<TrendingDown className="mr-1 h-3 w-3" />
|
||||
)}
|
||||
{metric.change}
|
||||
</span>
|
||||
<span>{metric.description}</span>
|
||||
<div className="h-full p-8 grid place-items-center bg-muted/5">
|
||||
<div className="max-w-3xl w-full grid gap-8 md:grid-cols-2">
|
||||
{/* Left: Illustration / Prompt */}
|
||||
<div className="flex flex-col justify-center space-y-4">
|
||||
<div className="bg-primary/10 w-16 h-16 rounded-2xl flex items-center justify-center mb-2">
|
||||
<BarChart3 className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-tight">Analytics & Playback</h2>
|
||||
<CardDescription className="text-base mt-2">
|
||||
Select a session from the top right to review video recordings, event logs, and metrics.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex gap-4 pt-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<PlayCircle className="h-4 w-4" />
|
||||
Feature-rich playback
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChartsSection() {
|
||||
const trialData = [12, 19, 15, 27, 32, 28, 35, 42, 38, 41, 37, 44];
|
||||
const participantData = [8, 12, 10, 15, 18, 16, 20, 24, 22, 26, 23, 28];
|
||||
const completionData = [85, 88, 92, 89, 94, 91, 95, 92, 96, 94, 97, 94];
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Trial Volume</CardTitle>
|
||||
<CardDescription>Monthly trial execution trends</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<MockChart title="Trials per Month" data={trialData} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Participant Enrollment</CardTitle>
|
||||
<CardDescription>New participants over time</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<MockChart title="New Participants" data={participantData} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Completion Rates</CardTitle>
|
||||
<CardDescription>Trial completion percentage</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<MockChart title="Completion %" data={completionData} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RecentInsights() {
|
||||
const insights = [
|
||||
{
|
||||
title: "Peak Performance Hours",
|
||||
description:
|
||||
"Participants show 23% better performance during 10-11 AM trials",
|
||||
type: "trend",
|
||||
severity: "info",
|
||||
},
|
||||
{
|
||||
title: "Attention Span Decline",
|
||||
description:
|
||||
"Average attention span has decreased by 8% over the last month",
|
||||
type: "alert",
|
||||
severity: "warning",
|
||||
},
|
||||
{
|
||||
title: "High Completion Rate",
|
||||
description: "Memory retention study achieved 98% completion rate",
|
||||
type: "success",
|
||||
severity: "success",
|
||||
},
|
||||
{
|
||||
title: "Equipment Utilization",
|
||||
description: "Robot interaction trials are at 85% capacity utilization",
|
||||
type: "info",
|
||||
severity: "info",
|
||||
},
|
||||
];
|
||||
|
||||
const getSeverityColor = (severity: string) => {
|
||||
switch (severity) {
|
||||
case "success":
|
||||
return "bg-green-50 text-green-700 border-green-200";
|
||||
case "warning":
|
||||
return "bg-yellow-50 text-yellow-700 border-yellow-200";
|
||||
case "info":
|
||||
return "bg-blue-50 text-blue-700 border-blue-200";
|
||||
default:
|
||||
return "bg-gray-50 text-gray-700 border-gray-200";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Insights</CardTitle>
|
||||
<CardDescription>
|
||||
AI-generated insights from your research data
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{insights.map((insight, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`rounded-lg border p-4 ${getSeverityColor(insight.severity)}`}
|
||||
>
|
||||
<h4 className="mb-1 font-medium">{insight.title}</h4>
|
||||
<p className="text-sm">{insight.description}</p>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Clock className="h-4 w-4" />
|
||||
Synchronized timeline
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function AnalyticsContent({ studyId: _studyId }: { studyId: string }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header with time range controls */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Select defaultValue="30d">
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue placeholder="Time range" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="7d">Last 7 days</SelectItem>
|
||||
<SelectItem value="30d">Last 30 days</SelectItem>
|
||||
<SelectItem value="90d">Last 90 days</SelectItem>
|
||||
<SelectItem value="1y">Last year</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" size="sm">
|
||||
<Filter className="mr-2 h-4 w-4" />
|
||||
Filter
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Export
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overview Metrics */}
|
||||
<AnalyticsOverview />
|
||||
|
||||
{/* Charts */}
|
||||
<ChartsSection />
|
||||
|
||||
{/* Insights */}
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<div className="lg:col-span-2">
|
||||
<RecentInsights />
|
||||
</div>
|
||||
{/* Right: Recent Sessions */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Quick Actions</CardTitle>
|
||||
<CardDescription>Generate custom reports</CardDescription>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Recent Sessions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<Button variant="outline" className="w-full justify-start">
|
||||
<BarChart3 className="mr-2 h-4 w-4" />
|
||||
Trial Performance Report
|
||||
</Button>
|
||||
<Button variant="outline" className="w-full justify-start">
|
||||
<Activity className="mr-2 h-4 w-4" />
|
||||
Participant Engagement
|
||||
</Button>
|
||||
<Button variant="outline" className="w-full justify-start">
|
||||
<TrendingUp className="mr-2 h-4 w-4" />
|
||||
Trend Analysis
|
||||
</Button>
|
||||
<Button variant="outline" className="w-full justify-start">
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Custom Export
|
||||
</Button>
|
||||
<CardContent className="p-0">
|
||||
<ScrollArea className="h-[240px]">
|
||||
<div className="px-4 pb-4 space-y-1">
|
||||
{recentTrials.map(trial => (
|
||||
<button
|
||||
key={trial.id}
|
||||
onClick={() => onSelect(trial.id)}
|
||||
className="w-full flex items-center gap-3 p-3 rounded-md hover:bg-accent transition-colors text-left group"
|
||||
>
|
||||
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center text-xs font-mono font-medium text-primary">
|
||||
{trial.sessionNumber}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm truncate">
|
||||
{trial.participant?.participantCode ?? "Unknown"}
|
||||
</span>
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded-full border capitalize ${trial.status === 'completed' ? 'bg-green-500/10 text-green-500 border-green-500/20' :
|
||||
trial.status === 'in_progress' ? 'bg-blue-500/10 text-blue-500 border-blue-500/20' :
|
||||
'bg-slate-500/10 text-slate-500 border-slate-500/20'
|
||||
}`}>
|
||||
{trial.status.replace('_', ' ')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-2 mt-0.5">
|
||||
<Calendar className="h-3 w-3" />
|
||||
{new Date(trial.createdAt).toLocaleDateString()}
|
||||
<span className="text-muted-foreground top-[1px] relative text-[10px]">•</span>
|
||||
{formatDistanceToNow(new Date(trial.createdAt), { addSuffix: true })}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground/30 group-hover:text-primary transition-colors" />
|
||||
</button>
|
||||
))}
|
||||
{recentTrials.length === 0 && (
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||
No sessions found.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
// -- Main Page --
|
||||
|
||||
export default function StudyAnalyticsPage() {
|
||||
const params = useParams();
|
||||
const studyId: string = typeof params.id === "string" ? params.id : "";
|
||||
const { setSelectedStudyId, selectedStudyId } = useStudyContext();
|
||||
const { study } = useSelectedStudyDetails();
|
||||
|
||||
// State lifted up
|
||||
const [selectedTrialId, setSelectedTrialId] = useState<string | null>(null);
|
||||
|
||||
// Fetch list of trials for the selector
|
||||
const { data: trialsList, isLoading: isLoadingList } = api.trials.list.useQuery(
|
||||
{ studyId, limit: 100 },
|
||||
{ enabled: !!studyId }
|
||||
);
|
||||
|
||||
// Set breadcrumbs
|
||||
useBreadcrumbsEffect([
|
||||
{ label: "Dashboard", href: "/dashboard" },
|
||||
@@ -320,16 +217,53 @@ export default function StudyAnalyticsPage() {
|
||||
}, [studyId, selectedStudyId, setSelectedStudyId]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Analytics"
|
||||
description="Insights and data analysis for this study"
|
||||
icon={BarChart3}
|
||||
/>
|
||||
<div className="h-[calc(100vh-64px)] flex flex-col p-6 gap-6">
|
||||
<div className="flex-none">
|
||||
<PageHeader
|
||||
title="Analytics"
|
||||
description="Analyze trial data and replay sessions"
|
||||
icon={BarChart3}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Session Selector in Header */}
|
||||
<div className="w-[300px]">
|
||||
<Select
|
||||
value={selectedTrialId ?? "overview"}
|
||||
onValueChange={(val) => setSelectedTrialId(val === "overview" ? null : val)}
|
||||
>
|
||||
<SelectTrigger className="w-full h-9 text-xs">
|
||||
<LayoutGrid className="h-3.5 w-3.5 mr-2 text-muted-foreground" />
|
||||
<SelectValue placeholder="Select Session" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-[400px]" align="end">
|
||||
<SelectItem value="overview" className="border-b mb-1 pb-1 font-medium text-xs">
|
||||
Show Study Overview
|
||||
</SelectItem>
|
||||
{trialsList?.map((trial) => (
|
||||
<SelectItem key={trial.id} value={trial.id} className="text-xs">
|
||||
<span className="font-mono mr-2 text-muted-foreground">#{trial.sessionNumber}</span>
|
||||
{trial.participant?.participantCode ?? "Unknown"} <span className="text-muted-foreground ml-1">({new Date(trial.createdAt).toLocaleDateString()})</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Suspense fallback={<div>Loading analytics...</div>}>
|
||||
<AnalyticsContent studyId={studyId} />
|
||||
</Suspense>
|
||||
<div className="flex-1 min-h-0 bg-transparent">
|
||||
<Suspense fallback={<div>Loading analytics...</div>}>
|
||||
<AnalyticsContent
|
||||
selectedTrialId={selectedTrialId}
|
||||
setSelectedTrialId={setSelectedTrialId}
|
||||
trialsList={trialsList ?? []}
|
||||
isLoadingList={isLoadingList}
|
||||
studyId={studyId}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useParams } from "next/navigation";
|
||||
import { Suspense, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { Play, Zap, ArrowLeft, User, FlaskConical } from "lucide-react";
|
||||
import { Play, Zap, ArrowLeft, User, FlaskConical, LineChart } from "lucide-react";
|
||||
import { PageHeader } from "~/components/ui/page-header";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
@@ -150,10 +150,18 @@ function TrialDetailContent() {
|
||||
)}
|
||||
{(trial.status === "in_progress" ||
|
||||
trial.status === "scheduled") && (
|
||||
<Button asChild>
|
||||
<Link href={`/studies/${studyId}/trials/${trialId}/wizard`}>
|
||||
<Zap className="mr-2 h-4 w-4" />
|
||||
Wizard Interface
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{trial.status === "completed" && (
|
||||
<Button asChild>
|
||||
<Link href={`/studies/${studyId}/trials/${trialId}/wizard`}>
|
||||
<Zap className="mr-2 h-4 w-4" />
|
||||
Wizard Interface
|
||||
<Link href={`/studies/${studyId}/trials/${trialId}/analysis`}>
|
||||
<LineChart className="mr-2 h-4 w-4" />
|
||||
View Analysis
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import "~/styles/globals.css";
|
||||
|
||||
import { type Metadata } from "next";
|
||||
import { Geist } from "next/font/google";
|
||||
import { Inter } from "next/font/google";
|
||||
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
import { TRPCReactProvider } from "~/trpc/react";
|
||||
@@ -13,16 +13,16 @@ export const metadata: Metadata = {
|
||||
icons: [{ rel: "icon", url: "/favicon.ico" }],
|
||||
};
|
||||
|
||||
const geist = Geist({
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-geist-sans",
|
||||
variable: "--font-sans",
|
||||
});
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{ children: React.ReactNode }>) {
|
||||
return (
|
||||
<html lang="en" className={`${geist.variable}`}>
|
||||
<html lang="en" className={`${inter.variable}`}>
|
||||
<body>
|
||||
<SessionProvider>
|
||||
<TRPCReactProvider>{children}</TRPCReactProvider>
|
||||
|
||||
@@ -78,6 +78,7 @@ export class ActionRegistry {
|
||||
parameters?: CoreBlockParam[];
|
||||
timeoutMs?: number;
|
||||
retryable?: boolean;
|
||||
nestable?: boolean;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -139,6 +140,7 @@ export class ActionRegistry {
|
||||
parameterSchemaRaw: {
|
||||
parameters: block.parameters ?? [],
|
||||
},
|
||||
nestable: block.nestable,
|
||||
};
|
||||
|
||||
this.actions.set(actionDef.id, actionDef);
|
||||
@@ -180,31 +182,33 @@ export class ActionRegistry {
|
||||
private loadFallbackActions(): void {
|
||||
const fallbackActions: ActionDefinition[] = [
|
||||
{
|
||||
id: "wizard_speak",
|
||||
type: "wizard_speak",
|
||||
id: "wizard_say",
|
||||
type: "wizard_say",
|
||||
name: "Wizard Says",
|
||||
description: "Wizard speaks to participant",
|
||||
category: "wizard",
|
||||
icon: "MessageSquare",
|
||||
color: "#3b82f6",
|
||||
color: "#a855f7",
|
||||
parameters: [
|
||||
{
|
||||
id: "text",
|
||||
name: "Text to say",
|
||||
id: "message",
|
||||
name: "Message",
|
||||
type: "text",
|
||||
placeholder: "Hello, participant!",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
source: { kind: "core", baseActionId: "wizard_speak" },
|
||||
execution: { transport: "internal", timeoutMs: 30000 },
|
||||
parameterSchemaRaw: {
|
||||
type: "object",
|
||||
properties: {
|
||||
text: { type: "string" },
|
||||
{
|
||||
id: "tone",
|
||||
name: "Tone",
|
||||
type: "select",
|
||||
options: ["neutral", "friendly", "encouraging"],
|
||||
value: "neutral",
|
||||
},
|
||||
required: ["text"],
|
||||
},
|
||||
],
|
||||
source: { kind: "core", baseActionId: "wizard_say" },
|
||||
execution: { transport: "internal", timeoutMs: 30000 },
|
||||
parameterSchemaRaw: {},
|
||||
nestable: false,
|
||||
},
|
||||
{
|
||||
id: "wait",
|
||||
@@ -366,34 +370,34 @@ export class ActionRegistry {
|
||||
|
||||
const execution = action.ros2
|
||||
? {
|
||||
transport: "ros2" as const,
|
||||
timeoutMs: action.timeout,
|
||||
retryable: action.retryable,
|
||||
ros2: {
|
||||
topic: action.ros2.topic,
|
||||
messageType: action.ros2.messageType,
|
||||
service: action.ros2.service,
|
||||
action: action.ros2.action,
|
||||
qos: action.ros2.qos,
|
||||
payloadMapping: action.ros2.payloadMapping,
|
||||
},
|
||||
}
|
||||
transport: "ros2" as const,
|
||||
timeoutMs: action.timeout,
|
||||
retryable: action.retryable,
|
||||
ros2: {
|
||||
topic: action.ros2.topic,
|
||||
messageType: action.ros2.messageType,
|
||||
service: action.ros2.service,
|
||||
action: action.ros2.action,
|
||||
qos: action.ros2.qos,
|
||||
payloadMapping: action.ros2.payloadMapping,
|
||||
},
|
||||
}
|
||||
: action.rest
|
||||
? {
|
||||
transport: "rest" as const,
|
||||
timeoutMs: action.timeout,
|
||||
retryable: action.retryable,
|
||||
rest: {
|
||||
method: action.rest.method,
|
||||
path: action.rest.path,
|
||||
headers: action.rest.headers,
|
||||
},
|
||||
}
|
||||
transport: "rest" as const,
|
||||
timeoutMs: action.timeout,
|
||||
retryable: action.retryable,
|
||||
rest: {
|
||||
method: action.rest.method,
|
||||
path: action.rest.path,
|
||||
headers: action.rest.headers,
|
||||
},
|
||||
}
|
||||
: {
|
||||
transport: "internal" as const,
|
||||
timeoutMs: action.timeout,
|
||||
retryable: action.retryable,
|
||||
};
|
||||
transport: "internal" as const,
|
||||
timeoutMs: action.timeout,
|
||||
retryable: action.retryable,
|
||||
};
|
||||
|
||||
const actionDef: ActionDefinition = {
|
||||
id: `${plugin.robotId ?? plugin.id}.${action.id}`,
|
||||
|
||||
@@ -26,8 +26,10 @@ import {
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
KeyboardSensor,
|
||||
closestCorners,
|
||||
type DragEndEvent,
|
||||
type DragStartEvent,
|
||||
type DragOverEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import { BottomStatusBar } from "./layout/BottomStatusBar";
|
||||
import { ActionLibraryPanel } from "./panels/ActionLibraryPanel";
|
||||
@@ -599,11 +601,8 @@ export function DesignerRoot({
|
||||
// Serialize steps for stable comparison
|
||||
const stepsHash = useMemo(() => JSON.stringify(steps), [steps]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialized) return;
|
||||
void recomputeHash();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [stepsHash, initialized]);
|
||||
// Intentionally removed redundant recomputeHash useEffect that was causing excessive refreshes
|
||||
// The debounced useEffect (lines 352-372) handles this correctly.
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedStepId || selectedActionId) {
|
||||
@@ -628,18 +627,10 @@ export function DesignerRoot({
|
||||
) {
|
||||
e.preventDefault();
|
||||
void persist();
|
||||
} else if (e.key === "v" && !e.metaKey && !e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
void validateDesign();
|
||||
} else if (e.key === "e" && !e.metaKey && !e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
void handleExport();
|
||||
} else if (e.key === "n" && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
createNewStep();
|
||||
}
|
||||
// 'v' (validate), 'e' (export), 'Shift+N' (new step) shortcuts removed to prevent accidents
|
||||
},
|
||||
[hasUnsavedChanges, persist, validateDesign, handleExport, createNewStep],
|
||||
[hasUnsavedChanges, persist],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -687,43 +678,163 @@ export function DesignerRoot({
|
||||
[toggleLibraryScrollLock],
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
async (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
console.debug("[DesignerRoot] dragEnd", {
|
||||
active: active?.id,
|
||||
over: over?.id ?? null,
|
||||
});
|
||||
// Clear overlay immediately
|
||||
toggleLibraryScrollLock(false);
|
||||
setDragOverlayAction(null);
|
||||
if (!over) {
|
||||
console.debug("[DesignerRoot] dragEnd: no drop target (ignored)");
|
||||
const handleDragOver = useCallback((event: DragOverEvent) => {
|
||||
const { active, over } = event;
|
||||
const store = useDesignerStore.getState();
|
||||
|
||||
// Only handle Library -> Flow projection
|
||||
if (!active.id.toString().startsWith("action-")) {
|
||||
if (store.insertionProjection) {
|
||||
store.setInsertionProjection(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!over) {
|
||||
if (store.insertionProjection) {
|
||||
store.setInsertionProjection(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const overId = over.id.toString();
|
||||
const activeDef = active.data.current?.action;
|
||||
|
||||
if (!activeDef) return;
|
||||
|
||||
let stepId: string | null = null;
|
||||
let parentId: string | null = null;
|
||||
let index = 0;
|
||||
|
||||
// Detect target based on over id
|
||||
if (overId.startsWith("s-act-")) {
|
||||
const data = over.data.current;
|
||||
if (data && data.stepId) {
|
||||
stepId = data.stepId;
|
||||
parentId = data.parentId ?? null; // Use parentId from the action we are hovering over
|
||||
// Use sortable index (insertion point provided by dnd-kit sortable strategy)
|
||||
index = data.sortable?.index ?? 0;
|
||||
}
|
||||
} else if (overId.startsWith("container-")) {
|
||||
// Dropping into a container (e.g. Loop)
|
||||
const data = over.data.current;
|
||||
if (data && data.stepId) {
|
||||
stepId = data.stepId;
|
||||
parentId = data.parentId ?? overId.slice("container-".length);
|
||||
// If dropping into container, appending is a safe default if specific index logic is missing
|
||||
// But actually we can find length if we want. For now, 0 or append logic?
|
||||
// If container is empty, index 0 is correct.
|
||||
// If not empty, we are hitting the container *background*, so append?
|
||||
// The projection logic will insert at 'index'. If index is past length, it appends.
|
||||
// Let's set a large index to ensure append, or look up length.
|
||||
// Lookup requires finding the action in store. Expensive?
|
||||
// Let's assume index 0 for now (prepend) or implement lookup.
|
||||
// Better: lookup action -> children length.
|
||||
const actionId = parentId;
|
||||
const step = store.steps.find(s => s.id === stepId);
|
||||
// Find action recursive? Store has `findActionById` helper but it is not exported/accessible easily here?
|
||||
// Actually, `store.steps` is available.
|
||||
// We can implement a quick BFS/DFS or just assume 0.
|
||||
// If dragging over the container *background* (empty space), append is usually expected.
|
||||
// Let's try 9999?
|
||||
index = 9999;
|
||||
}
|
||||
} else if (overId.startsWith("s-step-") || overId.startsWith("step-")) {
|
||||
// Container drop (Step)
|
||||
stepId = overId.startsWith("s-step-")
|
||||
? overId.slice("s-step-".length)
|
||||
: overId.slice("step-".length);
|
||||
const step = store.steps.find((s) => s.id === stepId);
|
||||
index = step ? step.actions.length : 0;
|
||||
|
||||
} else if (overId === "projection-placeholder") {
|
||||
// Hovering over our own projection placeholder -> keep current state
|
||||
return;
|
||||
}
|
||||
|
||||
if (stepId) {
|
||||
const current = store.insertionProjection;
|
||||
// Optimization: avoid redundant updates if projection matches
|
||||
if (
|
||||
current &&
|
||||
current.stepId === stepId &&
|
||||
current.parentId === parentId &&
|
||||
current.index === index
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Expect dragged action (library) onto a step droppable
|
||||
const activeId = active.id.toString();
|
||||
const overId = over.id.toString();
|
||||
store.setInsertionProjection({
|
||||
stepId,
|
||||
parentId,
|
||||
index,
|
||||
action: {
|
||||
id: "projection-placeholder",
|
||||
type: activeDef.type,
|
||||
name: activeDef.name,
|
||||
category: activeDef.category,
|
||||
description: "Drop here",
|
||||
source: activeDef.source || { kind: "library" },
|
||||
parameters: {},
|
||||
execution: activeDef.execution,
|
||||
} as any,
|
||||
});
|
||||
} else {
|
||||
if (store.insertionProjection) store.setInsertionProjection(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (activeId.startsWith("action-") && active.data.current?.action) {
|
||||
// Resolve stepId from possible over ids: step-<id>, s-step-<id>, or s-act-<actionId>
|
||||
let stepId: string | null = null;
|
||||
const handleDragEnd = useCallback(
|
||||
async (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
// Clear overlay immediately
|
||||
toggleLibraryScrollLock(false);
|
||||
setDragOverlayAction(null);
|
||||
|
||||
// Capture and clear projection
|
||||
const store = useDesignerStore.getState();
|
||||
const projection = store.insertionProjection;
|
||||
store.setInsertionProjection(null);
|
||||
|
||||
if (!over) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Determine Target (Step, Parent, Index)
|
||||
let stepId: string | null = null;
|
||||
let parentId: string | null = null;
|
||||
let index: number | undefined = undefined;
|
||||
|
||||
if (projection) {
|
||||
stepId = projection.stepId;
|
||||
parentId = projection.parentId;
|
||||
index = projection.index;
|
||||
} else {
|
||||
// Fallback: resolution from overId (if projection failed or raced)
|
||||
const overId = over.id.toString();
|
||||
if (overId.startsWith("step-")) {
|
||||
stepId = overId.slice("step-".length);
|
||||
} else if (overId.startsWith("s-step-")) {
|
||||
stepId = overId.slice("s-step-".length);
|
||||
} else if (overId.startsWith("s-act-")) {
|
||||
// This might fail if s-act-projection, but that should have covered by projection check above
|
||||
const actionId = overId.slice("s-act-".length);
|
||||
const parent = steps.find((s) =>
|
||||
s.actions.some((a) => a.id === actionId),
|
||||
);
|
||||
stepId = parent?.id ?? null;
|
||||
}
|
||||
if (!stepId) return;
|
||||
}
|
||||
|
||||
if (!stepId) return;
|
||||
const targetStep = steps.find((s) => s.id === stepId);
|
||||
if (!targetStep) return;
|
||||
|
||||
// 2. Instantiate Action
|
||||
if (active.id.toString().startsWith("action-") && active.data.current?.action) {
|
||||
const actionDef = active.data.current.action as {
|
||||
id: string;
|
||||
id: string; // type
|
||||
type: string;
|
||||
name: string;
|
||||
category: string;
|
||||
@@ -733,14 +844,13 @@ export function DesignerRoot({
|
||||
parameters: Array<{ id: string; name: string }>;
|
||||
};
|
||||
|
||||
const targetStep = steps.find((s) => s.id === stepId);
|
||||
if (!targetStep) return;
|
||||
|
||||
const fullDef = actionRegistry.getAction(actionDef.type);
|
||||
const defaultParams: Record<string, unknown> = {};
|
||||
if (fullDef?.parameters) {
|
||||
for (const param of fullDef.parameters) {
|
||||
// @ts-expect-error - 'default' property access
|
||||
if (param.default !== undefined) {
|
||||
// @ts-expect-error - 'default' property access
|
||||
defaultParams[param.id] = param.default;
|
||||
}
|
||||
}
|
||||
@@ -755,39 +865,61 @@ export function DesignerRoot({
|
||||
transport: actionDef.execution.transport,
|
||||
retryable: actionDef.execution.retryable ?? false,
|
||||
}
|
||||
: {
|
||||
transport: "internal",
|
||||
retryable: false,
|
||||
};
|
||||
: undefined;
|
||||
|
||||
const newAction: ExperimentAction = {
|
||||
id: `action-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
type: actionDef.type,
|
||||
id: crypto.randomUUID(),
|
||||
type: actionDef.type, // this is the 'type' key
|
||||
name: actionDef.name,
|
||||
category: actionDef.category as ExperimentAction["category"],
|
||||
category: actionDef.category as any,
|
||||
description: "",
|
||||
parameters: defaultParams,
|
||||
source: actionDef.source as ExperimentAction["source"],
|
||||
source: actionDef.source ? {
|
||||
kind: actionDef.source.kind as any,
|
||||
pluginId: actionDef.source.pluginId,
|
||||
pluginVersion: actionDef.source.pluginVersion,
|
||||
baseActionId: actionDef.id
|
||||
} : { kind: "core" },
|
||||
execution,
|
||||
children: [],
|
||||
};
|
||||
|
||||
upsertAction(stepId, newAction);
|
||||
// Select the newly added action and open properties
|
||||
selectStep(stepId);
|
||||
// 3. Commit
|
||||
upsertAction(stepId, newAction, parentId, index);
|
||||
|
||||
// Auto-select
|
||||
selectAction(stepId, newAction.id);
|
||||
setInspectorTab("properties");
|
||||
await recomputeHash();
|
||||
toast.success(`Added ${actionDef.name} to ${targetStep.name}`);
|
||||
|
||||
void recomputeHash();
|
||||
}
|
||||
},
|
||||
[
|
||||
steps,
|
||||
upsertAction,
|
||||
recomputeHash,
|
||||
selectStep,
|
||||
selectAction,
|
||||
toggleLibraryScrollLock,
|
||||
],
|
||||
[steps, upsertAction, selectAction, recomputeHash, toggleLibraryScrollLock],
|
||||
);
|
||||
// validation status badges removed (unused)
|
||||
/* ------------------------------- Panels ---------------------------------- */
|
||||
const leftPanel = useMemo(
|
||||
() => (
|
||||
<div ref={libraryRootRef} data-library-root className="h-full">
|
||||
<ActionLibraryPanel />
|
||||
</div>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const centerPanel = useMemo(() => <FlowWorkspace />, []);
|
||||
|
||||
const rightPanel = useMemo(
|
||||
() => (
|
||||
<div className="h-full">
|
||||
<InspectorPanel
|
||||
activeTab={inspectorTab}
|
||||
onTabChange={setInspectorTab}
|
||||
studyPlugins={studyPlugins}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
[inspectorTab, studyPlugins],
|
||||
);
|
||||
|
||||
/* ------------------------------- Render ---------------------------------- */
|
||||
if (loadingExperiment && !initialized) {
|
||||
@@ -852,33 +984,33 @@ export function DesignerRoot({
|
||||
<div className="flex h-[calc(100vh-12rem)] w-full max-w-full flex-col overflow-hidden rounded-md border">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={pointerWithin}
|
||||
collisionDetection={closestCorners}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={() => toggleLibraryScrollLock(false)}
|
||||
>
|
||||
<PanelsContainer
|
||||
showDividers
|
||||
className="min-h-0 flex-1"
|
||||
left={
|
||||
<div ref={libraryRootRef} data-library-root className="h-full">
|
||||
<ActionLibraryPanel />
|
||||
</div>
|
||||
}
|
||||
center={<FlowWorkspace />}
|
||||
right={
|
||||
<div className="h-full">
|
||||
<InspectorPanel
|
||||
activeTab={inspectorTab}
|
||||
onTabChange={setInspectorTab}
|
||||
studyPlugins={studyPlugins}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
left={leftPanel}
|
||||
center={centerPanel}
|
||||
right={rightPanel}
|
||||
/>
|
||||
<DragOverlay>
|
||||
{dragOverlayAction ? (
|
||||
<div className="bg-background pointer-events-none rounded border px-2 py-1 text-xs shadow-lg select-none">
|
||||
<div className="bg-background flex items-center gap-2 rounded border px-3 py-2 text-xs font-medium shadow-lg select-none">
|
||||
<span
|
||||
className={cn(
|
||||
"h-2.5 w-2.5 rounded-full",
|
||||
{
|
||||
wizard: "bg-blue-500",
|
||||
robot: "bg-emerald-600",
|
||||
control: "bg-amber-500",
|
||||
observation: "bg-purple-600",
|
||||
}[dragOverlayAction.category] || "bg-slate-400",
|
||||
)}
|
||||
/>
|
||||
{dragOverlayAction.name}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -282,205 +282,22 @@ export function PropertiesPanelBase({
|
||||
Parameters
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{def.parameters.map((param) => {
|
||||
const rawValue = selectedAction.parameters[param.id];
|
||||
const commonLabel = (
|
||||
<Label className="flex items-center gap-2 text-xs">
|
||||
{param.name}
|
||||
<span className="text-muted-foreground font-normal">
|
||||
{param.type === "number" &&
|
||||
(param.min !== undefined || param.max !== undefined) &&
|
||||
typeof rawValue === "number" &&
|
||||
`( ${rawValue} )`}
|
||||
</span>
|
||||
</Label>
|
||||
);
|
||||
|
||||
/* ---- Handlers ---- */
|
||||
const updateParamValue = (value: unknown) => {
|
||||
setLocalParams((prev) => ({ ...prev, [param.id]: value }));
|
||||
debouncedParamUpdate(
|
||||
containingStep.id,
|
||||
selectedAction.id,
|
||||
param.id,
|
||||
value,
|
||||
);
|
||||
};
|
||||
|
||||
const updateParamValueImmediate = (value: unknown) => {
|
||||
setLocalParams((prev) => ({ ...prev, [param.id]: value }));
|
||||
onActionUpdate(containingStep.id, selectedAction.id, {
|
||||
parameters: {
|
||||
...selectedAction.parameters,
|
||||
[param.id]: value,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const updateParamLocal = (value: unknown) => {
|
||||
setLocalParams((prev) => ({ ...prev, [param.id]: value }));
|
||||
};
|
||||
|
||||
const commitParamValue = () => {
|
||||
if (localParams[param.id] !== rawValue) {
|
||||
{def.parameters.map((param) => (
|
||||
<ParameterEditor
|
||||
key={param.id}
|
||||
param={param}
|
||||
value={selectedAction.parameters[param.id]}
|
||||
onUpdate={(val) => {
|
||||
onActionUpdate(containingStep.id, selectedAction.id, {
|
||||
parameters: {
|
||||
...selectedAction.parameters,
|
||||
[param.id]: localParams[param.id],
|
||||
[param.id]: val,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/* ---- Control Rendering ---- */
|
||||
let control: React.ReactNode = null;
|
||||
|
||||
if (param.type === "text") {
|
||||
const localValue = localParams[param.id] ?? rawValue ?? "";
|
||||
control = (
|
||||
<Input
|
||||
value={localValue as string}
|
||||
placeholder={param.placeholder}
|
||||
onChange={(e) => updateParamValue(e.target.value)}
|
||||
onBlur={() => {
|
||||
if (localParams[param.id] !== rawValue) {
|
||||
onActionUpdate(containingStep.id, selectedAction.id, {
|
||||
parameters: {
|
||||
...selectedAction.parameters,
|
||||
[param.id]: localParams[param.id],
|
||||
},
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="mt-1 h-7 w-full text-xs"
|
||||
/>
|
||||
);
|
||||
} else if (param.type === "select") {
|
||||
const localValue = localParams[param.id] ?? rawValue ?? "";
|
||||
control = (
|
||||
<Select
|
||||
value={localValue as string}
|
||||
onValueChange={(val) => updateParamValueImmediate(val)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-7 w-full text-xs">
|
||||
<SelectValue placeholder="Select…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{param.options?.map((opt) => (
|
||||
<SelectItem key={opt} value={opt}>
|
||||
{opt}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
} else if (param.type === "boolean") {
|
||||
const localValue = localParams[param.id] ?? rawValue ?? false;
|
||||
control = (
|
||||
<div className="mt-1 flex h-7 items-center">
|
||||
<Switch
|
||||
checked={Boolean(localValue)}
|
||||
onCheckedChange={(val) =>
|
||||
updateParamValueImmediate(val)
|
||||
}
|
||||
aria-label={param.name}
|
||||
/>
|
||||
<span className="text-muted-foreground ml-2 text-[11px]">
|
||||
{Boolean(localValue) ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
} else if (param.type === "number") {
|
||||
const localValue = localParams[param.id] ?? rawValue;
|
||||
const numericVal =
|
||||
typeof localValue === "number"
|
||||
? localValue
|
||||
: typeof param.value === "number"
|
||||
? param.value
|
||||
: (param.min ?? 0);
|
||||
|
||||
if (param.min !== undefined || param.max !== undefined) {
|
||||
const min = param.min ?? 0;
|
||||
const max =
|
||||
param.max ??
|
||||
Math.max(
|
||||
min + 1,
|
||||
Number.isFinite(numericVal) ? numericVal : min + 1,
|
||||
);
|
||||
// Step heuristic
|
||||
const range = max - min;
|
||||
const step =
|
||||
param.step ??
|
||||
(range <= 5
|
||||
? 0.1
|
||||
: range <= 50
|
||||
? 0.5
|
||||
: Math.max(1, Math.round(range / 100)));
|
||||
control = (
|
||||
<div className="mt-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Slider
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={[Number(numericVal)]}
|
||||
onValueChange={(vals: number[]) =>
|
||||
updateParamLocal(vals[0])
|
||||
}
|
||||
onPointerUp={commitParamValue}
|
||||
/>
|
||||
<span className="text-muted-foreground min-w-[2.5rem] text-right text-[10px] tabular-nums">
|
||||
{step < 1
|
||||
? Number(numericVal).toFixed(2)
|
||||
: Number(numericVal).toString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground mt-1 flex justify-between text-[10px]">
|
||||
<span>{min}</span>
|
||||
<span>{max}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
control = (
|
||||
<Input
|
||||
type="number"
|
||||
value={numericVal}
|
||||
onChange={(e) =>
|
||||
updateParamValue(parseFloat(e.target.value) || 0)
|
||||
}
|
||||
onBlur={() => {
|
||||
if (localParams[param.id] !== rawValue) {
|
||||
onActionUpdate(
|
||||
containingStep.id,
|
||||
selectedAction.id,
|
||||
{
|
||||
parameters: {
|
||||
...selectedAction.parameters,
|
||||
[param.id]: localParams[param.id],
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}}
|
||||
className="mt-1 h-7 w-full text-xs"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={param.id} className="space-y-1">
|
||||
{commonLabel}
|
||||
{param.description && (
|
||||
<div className="text-muted-foreground text-[10px]">
|
||||
{param.description}
|
||||
</div>
|
||||
)}
|
||||
{control}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
}}
|
||||
onCommit={() => { }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -635,3 +452,156 @@ export function PropertiesPanelBase({
|
||||
}
|
||||
|
||||
export const PropertiesPanel = React.memo(PropertiesPanelBase);
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Isolated Parameter Editor (Optimized) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
interface ParameterEditorProps {
|
||||
param: any;
|
||||
value: unknown;
|
||||
onUpdate: (value: unknown) => void;
|
||||
onCommit: () => void;
|
||||
}
|
||||
|
||||
const ParameterEditor = React.memo(function ParameterEditor({
|
||||
param,
|
||||
value: rawValue,
|
||||
onUpdate,
|
||||
onCommit
|
||||
}: ParameterEditorProps) {
|
||||
// Local state for immediate feedback
|
||||
const [localValue, setLocalValue] = useState<unknown>(rawValue);
|
||||
const debounceRef = useRef<NodeJS.Timeout | undefined>();
|
||||
|
||||
// Sync from prop if it changes externally
|
||||
useEffect(() => {
|
||||
setLocalValue(rawValue);
|
||||
}, [rawValue]);
|
||||
|
||||
const handleUpdate = useCallback((newVal: unknown, immediate = false) => {
|
||||
setLocalValue(newVal);
|
||||
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
|
||||
if (immediate) {
|
||||
onUpdate(newVal);
|
||||
} else {
|
||||
debounceRef.current = setTimeout(() => {
|
||||
onUpdate(newVal);
|
||||
}, 300);
|
||||
}
|
||||
}, [onUpdate]);
|
||||
|
||||
const handleCommit = useCallback(() => {
|
||||
if (localValue !== rawValue) {
|
||||
onUpdate(localValue);
|
||||
}
|
||||
}, [localValue, rawValue, onUpdate]);
|
||||
|
||||
let control: React.ReactNode = null;
|
||||
|
||||
if (param.type === "text") {
|
||||
control = (
|
||||
<Input
|
||||
value={(localValue as string) ?? ""}
|
||||
placeholder={param.placeholder}
|
||||
onChange={(e) => handleUpdate(e.target.value)}
|
||||
onBlur={handleCommit}
|
||||
className="mt-1 h-7 w-full text-xs"
|
||||
/>
|
||||
);
|
||||
} else if (param.type === "select") {
|
||||
control = (
|
||||
<Select
|
||||
value={(localValue as string) ?? ""}
|
||||
onValueChange={(val) => handleUpdate(val, true)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-7 w-full text-xs">
|
||||
<SelectValue placeholder="Select…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{param.options?.map((opt: string) => (
|
||||
<SelectItem key={opt} value={opt}>
|
||||
{opt}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
} else if (param.type === "boolean") {
|
||||
control = (
|
||||
<div className="mt-1 flex h-7 items-center">
|
||||
<Switch
|
||||
checked={Boolean(localValue)}
|
||||
onCheckedChange={(val) => handleUpdate(val, true)}
|
||||
aria-label={param.name}
|
||||
/>
|
||||
<span className="text-muted-foreground ml-2 text-[11px]">
|
||||
{Boolean(localValue) ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
} else if (param.type === "number") {
|
||||
const numericVal = typeof localValue === "number" ? localValue : (param.min ?? 0);
|
||||
|
||||
if (param.min !== undefined || param.max !== undefined) {
|
||||
const min = param.min ?? 0;
|
||||
const max = param.max ?? Math.max(min + 1, Number.isFinite(numericVal) ? numericVal : min + 1);
|
||||
const range = max - min;
|
||||
const step = param.step ?? (range <= 5 ? 0.1 : range <= 50 ? 0.5 : Math.max(1, Math.round(range / 100)));
|
||||
|
||||
control = (
|
||||
<div className="mt-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Slider
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={[Number(numericVal)]}
|
||||
onValueChange={(vals) => setLocalValue(vals[0])} // Update only local visual
|
||||
onPointerUp={() => handleUpdate(localValue)} // Commit on release
|
||||
/>
|
||||
<span className="text-muted-foreground min-w-[2.5rem] text-right text-[10px] tabular-nums">
|
||||
{step < 1 ? Number(numericVal).toFixed(2) : Number(numericVal).toString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground mt-1 flex justify-between text-[10px]">
|
||||
<span>{min}</span>
|
||||
<span>{max}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
control = (
|
||||
<Input
|
||||
type="number"
|
||||
value={numericVal}
|
||||
onChange={(e) => handleUpdate(parseFloat(e.target.value) || 0)}
|
||||
onBlur={handleCommit}
|
||||
className="mt-1 h-7 w-full text-xs"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label className="flex items-center gap-2 text-xs">
|
||||
{param.name}
|
||||
<span className="text-muted-foreground font-normal">
|
||||
{param.type === "number" &&
|
||||
(param.min !== undefined || param.max !== undefined) &&
|
||||
typeof rawValue === "number" &&
|
||||
`( ${rawValue} )`}
|
||||
</span>
|
||||
</Label>
|
||||
{param.description && (
|
||||
<div className="text-muted-foreground text-[10px]">
|
||||
{param.description}
|
||||
</div>
|
||||
)}
|
||||
{control}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
useDndMonitor,
|
||||
type DragEndEvent,
|
||||
type DragStartEvent,
|
||||
type DragOverEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
useSortable,
|
||||
@@ -68,7 +69,7 @@ interface FlowWorkspaceProps {
|
||||
onActionCreate?: (stepId: string, action: ExperimentAction) => void;
|
||||
}
|
||||
|
||||
interface VirtualItem {
|
||||
export interface VirtualItem {
|
||||
index: number;
|
||||
top: number;
|
||||
height: number;
|
||||
@@ -77,6 +78,232 @@ interface VirtualItem {
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
interface StepRowProps {
|
||||
item: VirtualItem;
|
||||
selectedStepId: string | null | undefined;
|
||||
selectedActionId: string | null | undefined;
|
||||
renamingStepId: string | null;
|
||||
onSelectStep: (id: string | undefined) => void;
|
||||
onSelectAction: (stepId: string, actionId: string | undefined) => void;
|
||||
onToggleExpanded: (step: ExperimentStep) => void;
|
||||
onRenameStep: (step: ExperimentStep, name: string) => void;
|
||||
onDeleteStep: (step: ExperimentStep) => void;
|
||||
onDeleteAction: (stepId: string, actionId: string) => void;
|
||||
setRenamingStepId: (id: string | null) => void;
|
||||
registerMeasureRef: (stepId: string, el: HTMLDivElement | null) => void;
|
||||
}
|
||||
|
||||
const StepRow = React.memo(function StepRow({
|
||||
item,
|
||||
selectedStepId,
|
||||
selectedActionId,
|
||||
renamingStepId,
|
||||
onSelectStep,
|
||||
onSelectAction,
|
||||
onToggleExpanded,
|
||||
onRenameStep,
|
||||
onDeleteStep,
|
||||
onDeleteAction,
|
||||
setRenamingStepId,
|
||||
registerMeasureRef,
|
||||
}: StepRowProps) {
|
||||
const step = item.step;
|
||||
const insertionProjection = useDesignerStore((s) => s.insertionProjection);
|
||||
|
||||
const displayActions = useMemo(() => {
|
||||
if (
|
||||
insertionProjection?.stepId === step.id &&
|
||||
insertionProjection.parentId === null
|
||||
) {
|
||||
const copy = [...step.actions];
|
||||
// Insert placeholder action
|
||||
// Ensure specific ID doesn't crash keys if collision (collision unlikely for library items)
|
||||
// Actually, standard array key is action.id.
|
||||
copy.splice(insertionProjection.index, 0, insertionProjection.action);
|
||||
return copy;
|
||||
}
|
||||
return step.actions;
|
||||
}, [step.actions, step.id, insertionProjection]);
|
||||
|
||||
const {
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
attributes,
|
||||
listeners,
|
||||
isDragging,
|
||||
} = useSortable({
|
||||
id: sortableStepId(step.id),
|
||||
data: {
|
||||
type: "step",
|
||||
step: step,
|
||||
},
|
||||
});
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
top: item.top,
|
||||
left: 0,
|
||||
right: 0,
|
||||
width: "100%",
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
zIndex: isDragging ? 25 : undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} data-step-id={step.id}>
|
||||
<div
|
||||
ref={(el) => registerMeasureRef(step.id, el)}
|
||||
className="relative px-3 py-4"
|
||||
data-step-id={step.id}
|
||||
>
|
||||
<StepDroppableArea stepId={step.id} />
|
||||
<div
|
||||
className={cn(
|
||||
"mb-2 rounded border shadow-sm transition-colors",
|
||||
selectedStepId === step.id
|
||||
? "border-border bg-accent/30"
|
||||
: "hover:bg-accent/30",
|
||||
isDragging && "opacity-80 ring-1 ring-blue-300",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-between gap-2 border-b px-2 py-1.5"
|
||||
onClick={(e) => {
|
||||
const tag = (e.target as HTMLElement).tagName.toLowerCase();
|
||||
if (tag === "input" || tag === "textarea" || tag === "button")
|
||||
return;
|
||||
onSelectStep(step.id);
|
||||
onSelectAction(step.id, undefined);
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleExpanded(step);
|
||||
}}
|
||||
className="text-muted-foreground hover:bg-accent/60 hover:text-foreground rounded p-1"
|
||||
aria-label={step.expanded ? "Collapse step" : "Expand step"}
|
||||
>
|
||||
{step.expanded ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-5 px-1.5 text-[10px] font-normal"
|
||||
>
|
||||
{step.order + 1}
|
||||
</Badge>
|
||||
{renamingStepId === step.id ? (
|
||||
<Input
|
||||
autoFocus
|
||||
defaultValue={step.name}
|
||||
className="h-7 w-40 text-xs"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
onRenameStep(
|
||||
step,
|
||||
(e.target as HTMLInputElement).value.trim() ||
|
||||
step.name,
|
||||
);
|
||||
setRenamingStepId(null);
|
||||
} else if (e.key === "Escape") {
|
||||
setRenamingStepId(null);
|
||||
}
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
onRenameStep(step, e.target.value.trim() || step.name);
|
||||
setRenamingStepId(null);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-sm font-medium">{step.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground p-1 opacity-0 group-hover:opacity-100"
|
||||
aria-label="Rename step"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setRenamingStepId(step.id);
|
||||
}}
|
||||
>
|
||||
<Edit3 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<span className="text-muted-foreground hidden text-[11px] md:inline">
|
||||
{step.actions.length} actions
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-[11px] text-red-500 hover:text-red-600"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteStep(step);
|
||||
}}
|
||||
aria-label="Delete step"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<div
|
||||
className="text-muted-foreground cursor-grab p-1"
|
||||
aria-label="Drag step"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action List (Collapsible/Virtual content) */}
|
||||
{step.expanded && (
|
||||
<div className="bg-background/40 min-h-[3rem] space-y-2 p-2 pb-8">
|
||||
<SortableContext
|
||||
items={displayActions.map((a) => sortableActionId(a.id))}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
{displayActions.length === 0 ? (
|
||||
<div className="flex h-12 items-center justify-center rounded border border-dashed text-xs text-muted-foreground">
|
||||
Drop actions here
|
||||
</div>
|
||||
) : (
|
||||
displayActions.map((action) => (
|
||||
<SortableActionChip
|
||||
key={action.id}
|
||||
stepId={step.id}
|
||||
action={action}
|
||||
parentId={null}
|
||||
selectedActionId={selectedActionId}
|
||||
onSelectAction={onSelectAction}
|
||||
onDeleteAction={onDeleteAction}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Utility */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
@@ -122,37 +349,125 @@ function StepDroppableArea({ stepId }: { stepId: string }) {
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
interface ActionChipProps {
|
||||
stepId: string;
|
||||
action: ExperimentAction;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
onDelete: () => void;
|
||||
parentId: string | null;
|
||||
selectedActionId: string | null | undefined;
|
||||
onSelectAction: (stepId: string, actionId: string | undefined) => void;
|
||||
onDeleteAction: (stepId: string, actionId: string) => void;
|
||||
dragHandle?: boolean;
|
||||
}
|
||||
|
||||
function SortableActionChip({
|
||||
stepId,
|
||||
action,
|
||||
isSelected,
|
||||
onSelect,
|
||||
onDelete,
|
||||
parentId,
|
||||
selectedActionId,
|
||||
onSelectAction,
|
||||
onDeleteAction,
|
||||
dragHandle,
|
||||
}: ActionChipProps) {
|
||||
const def = actionRegistry.getAction(action.type);
|
||||
const isSelected = selectedActionId === action.id;
|
||||
|
||||
const insertionProjection = useDesignerStore((s) => s.insertionProjection);
|
||||
const displayChildren = useMemo(() => {
|
||||
if (
|
||||
insertionProjection?.stepId === stepId &&
|
||||
insertionProjection.parentId === action.id
|
||||
) {
|
||||
const copy = [...(action.children || [])];
|
||||
copy.splice(insertionProjection.index, 0, insertionProjection.action);
|
||||
return copy;
|
||||
}
|
||||
return action.children;
|
||||
}, [action.children, action.id, stepId, insertionProjection]);
|
||||
|
||||
/* ------------------------------------------------------------------------ */
|
||||
/* Main Sortable Logic */
|
||||
/* ------------------------------------------------------------------------ */
|
||||
const isPlaceholder = action.id === "projection-placeholder";
|
||||
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
isDragging: isSortableDragging,
|
||||
} = useSortable({
|
||||
id: sortableActionId(action.id),
|
||||
disabled: isPlaceholder, // Disable sortable for placeholder
|
||||
data: {
|
||||
type: "action",
|
||||
stepId,
|
||||
parentId,
|
||||
id: action.id,
|
||||
},
|
||||
});
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
// Use local dragging state or passed prop
|
||||
const isDragging = isSortableDragging || dragHandle;
|
||||
|
||||
const style = {
|
||||
transform: CSS.Translate.toString(transform),
|
||||
transition,
|
||||
zIndex: isDragging ? 30 : undefined,
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------------ */
|
||||
/* Nested Droppable (for control flow containers) */
|
||||
/* ------------------------------------------------------------------------ */
|
||||
const nestedDroppableId = `container-${action.id}`;
|
||||
const {
|
||||
isOver: isOverNested,
|
||||
setNodeRef: setNestedNodeRef
|
||||
} = useDroppable({
|
||||
id: nestedDroppableId,
|
||||
disabled: !def?.nestable || isPlaceholder, // Disable droppable for placeholder
|
||||
data: {
|
||||
type: "container",
|
||||
stepId,
|
||||
parentId: action.id,
|
||||
action // Pass full action for projection logic
|
||||
}
|
||||
});
|
||||
|
||||
const shouldRenderChildren = def?.nestable;
|
||||
|
||||
if (isPlaceholder) {
|
||||
const { setNodeRef: setPlaceholderRef } = useDroppable({
|
||||
id: "projection-placeholder",
|
||||
data: { type: "placeholder" }
|
||||
});
|
||||
|
||||
// Render simplified placeholder without hooks refs
|
||||
// We still render the content matching the action type for visual fidelity
|
||||
return (
|
||||
<div
|
||||
ref={setPlaceholderRef}
|
||||
className="group relative flex w-full flex-col items-start gap-1 rounded border-2 border-dashed border-blue-300 bg-blue-50/50 px-3 py-2 text-[11px] opacity-70"
|
||||
>
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<span className={cn(
|
||||
"h-2.5 w-2.5 rounded-full",
|
||||
def ? {
|
||||
wizard: "bg-blue-500",
|
||||
robot: "bg-emerald-500",
|
||||
control: "bg-amber-500",
|
||||
observation: "bg-purple-500",
|
||||
}[def.category] : "bg-gray-400"
|
||||
)} />
|
||||
<span className="font-medium text-foreground">{def?.name ?? action.name}</span>
|
||||
</div>
|
||||
{def?.description && (
|
||||
<div className="text-muted-foreground line-clamp-3 w-full text-[10px] leading-snug">
|
||||
{def.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
@@ -162,8 +477,13 @@ function SortableActionChip({
|
||||
"bg-muted/40 hover:bg-accent/40 cursor-pointer",
|
||||
isSelected && "border-border bg-accent/30",
|
||||
isDragging && "opacity-70 shadow-lg",
|
||||
// Visual feedback for nested drop
|
||||
isOverNested && !isDragging && "ring-2 ring-blue-400 ring-offset-1 bg-blue-50/50"
|
||||
)}
|
||||
onClick={onSelect}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelectAction(stepId, action.id);
|
||||
}}
|
||||
{...attributes}
|
||||
role="button"
|
||||
aria-pressed={isSelected}
|
||||
@@ -197,7 +517,7 @@ function SortableActionChip({
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
onDeleteAction(stepId, action.id);
|
||||
}}
|
||||
className="text-muted-foreground hover:text-foreground rounded p-0.5 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
aria-label="Delete action"
|
||||
@@ -221,12 +541,45 @@ function SortableActionChip({
|
||||
</span>
|
||||
))}
|
||||
{def.parameters.length > 4 && (
|
||||
<span className="text-muted-foreground text-[9px]">
|
||||
+{def.parameters.length - 4} more
|
||||
</span>
|
||||
<span className="text-[9px] text-muted-foreground">+{def.parameters.length - 4}</span>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Nested Actions Container */}
|
||||
{shouldRenderChildren && (
|
||||
<div
|
||||
ref={setNestedNodeRef}
|
||||
className={cn(
|
||||
"mt-2 w-full flex flex-col gap-2 pl-4 border-l-2 border-border/40 transition-all min-h-[0.5rem] pb-4",
|
||||
)}
|
||||
>
|
||||
<SortableContext
|
||||
items={(displayChildren ?? action.children ?? [])
|
||||
.filter(c => c.id !== "projection-placeholder")
|
||||
.map(c => sortableActionId(c.id))}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{(displayChildren || action.children || []).map((child) => (
|
||||
<SortableActionChip
|
||||
key={child.id}
|
||||
stepId={stepId}
|
||||
action={child}
|
||||
parentId={action.id}
|
||||
selectedActionId={selectedActionId}
|
||||
onSelectAction={onSelectAction}
|
||||
onDeleteAction={onDeleteAction}
|
||||
/>
|
||||
))}
|
||||
{(!displayChildren?.length && !action.children?.length) && (
|
||||
<div className="text-[10px] text-muted-foreground/60 italic py-1">
|
||||
Drag actions here
|
||||
</div>
|
||||
)}
|
||||
</SortableContext>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -254,7 +607,7 @@ export function FlowWorkspace({
|
||||
|
||||
const removeAction = useDesignerStore((s) => s.removeAction);
|
||||
const reorderStep = useDesignerStore((s) => s.reorderStep);
|
||||
const reorderAction = useDesignerStore((s) => s.reorderAction);
|
||||
const moveAction = useDesignerStore((s) => s.moveAction);
|
||||
const recomputeHash = useDesignerStore((s) => s.recomputeHash);
|
||||
|
||||
/* Local state */
|
||||
@@ -382,7 +735,10 @@ export function FlowWorkspace({
|
||||
description: "",
|
||||
type: "sequential",
|
||||
order: steps.length,
|
||||
trigger: { type: "trial_start", conditions: {} },
|
||||
trigger:
|
||||
steps.length === 0
|
||||
? { type: "trial_start", conditions: {} }
|
||||
: { type: "previous_step", conditions: {} },
|
||||
actions: [],
|
||||
expanded: true,
|
||||
};
|
||||
@@ -472,34 +828,77 @@ export function FlowWorkspace({
|
||||
}
|
||||
}
|
||||
}
|
||||
// Action reorder (within same parent only)
|
||||
// Action reorder (supports nesting)
|
||||
if (activeId.startsWith("s-act-") && overId.startsWith("s-act-")) {
|
||||
const fromActionId = parseSortableAction(activeId);
|
||||
const toActionId = parseSortableAction(overId);
|
||||
if (fromActionId && toActionId && fromActionId !== toActionId) {
|
||||
const fromParent = actionParentMap.get(fromActionId);
|
||||
const toParent = actionParentMap.get(toActionId);
|
||||
if (fromParent && toParent && fromParent === toParent) {
|
||||
const step = steps.find((s) => s.id === fromParent);
|
||||
if (step) {
|
||||
const fromIdx = step.actions.findIndex(
|
||||
(a) => a.id === fromActionId,
|
||||
);
|
||||
const toIdx = step.actions.findIndex((a) => a.id === toActionId);
|
||||
if (fromIdx >= 0 && toIdx >= 0) {
|
||||
reorderAction(step.id, fromIdx, toIdx);
|
||||
void recomputeHash();
|
||||
}
|
||||
}
|
||||
const activeData = active.data.current;
|
||||
const overData = over.data.current;
|
||||
|
||||
if (
|
||||
activeData && overData &&
|
||||
activeData.stepId === overData.stepId &&
|
||||
activeData.type === 'action' && overData.type === 'action'
|
||||
) {
|
||||
const stepId = activeData.stepId as string;
|
||||
const activeActionId = activeData.action.id;
|
||||
const overActionId = overData.action.id;
|
||||
|
||||
if (activeActionId !== overActionId) {
|
||||
const newParentId = overData.parentId as string | null;
|
||||
const newIndex = overData.sortable.index; // index within that parent's list
|
||||
|
||||
moveAction(stepId, activeActionId, newParentId, newIndex);
|
||||
void recomputeHash();
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[steps, reorderStep, reorderAction, actionParentMap, recomputeHash],
|
||||
[steps, reorderStep, moveAction, recomputeHash],
|
||||
);
|
||||
|
||||
/* ------------------------------------------------------------------------ */
|
||||
/* Drag Over (Live Sorting) */
|
||||
/* ------------------------------------------------------------------------ */
|
||||
const handleLocalDragOver = useCallback(
|
||||
(event: DragOverEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over) return;
|
||||
|
||||
const activeId = active.id.toString();
|
||||
const overId = over.id.toString();
|
||||
|
||||
// Only handle action reordering
|
||||
if (activeId.startsWith("s-act-") && overId.startsWith("s-act-")) {
|
||||
const activeData = active.data.current;
|
||||
const overData = over.data.current;
|
||||
|
||||
if (
|
||||
activeData &&
|
||||
overData &&
|
||||
activeData.type === 'action' &&
|
||||
overData.type === 'action'
|
||||
) {
|
||||
const activeActionId = activeData.action.id;
|
||||
const overActionId = overData.action.id;
|
||||
const activeStepId = activeData.stepId;
|
||||
const overStepId = overData.stepId;
|
||||
const activeParentId = activeData.parentId;
|
||||
const overParentId = overData.parentId;
|
||||
|
||||
// If moving between different lists (parents/steps), move immediately to visualize snap
|
||||
if (activeParentId !== overParentId || activeStepId !== overStepId) {
|
||||
// Determine new index
|
||||
// verification of safe move handled by store
|
||||
moveAction(overStepId, activeActionId, overParentId, overData.sortable.index);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[moveAction]
|
||||
);
|
||||
|
||||
useDndMonitor({
|
||||
onDragStart: handleLocalDragStart,
|
||||
onDragOver: handleLocalDragOver,
|
||||
onDragEnd: handleLocalDragEnd,
|
||||
onDragCancel: () => {
|
||||
// no-op
|
||||
@@ -509,204 +908,22 @@ export function FlowWorkspace({
|
||||
/* ------------------------------------------------------------------------ */
|
||||
/* Step Row (Sortable + Virtualized) */
|
||||
/* ------------------------------------------------------------------------ */
|
||||
function StepRow({ item }: { item: VirtualItem }) {
|
||||
const step = item.step;
|
||||
const {
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
attributes,
|
||||
listeners,
|
||||
isDragging,
|
||||
} = useSortable({
|
||||
id: sortableStepId(step.id),
|
||||
});
|
||||
// StepRow moved outside of component to prevent re-mounting on every render (flashing fix)
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
top: item.top,
|
||||
left: 0,
|
||||
right: 0,
|
||||
width: "100%",
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
zIndex: isDragging ? 25 : undefined,
|
||||
};
|
||||
|
||||
const setMeasureRef = (el: HTMLDivElement | null) => {
|
||||
const prev = measureRefs.current.get(step.id) ?? null;
|
||||
const registerMeasureRef = useCallback(
|
||||
(stepId: string, el: HTMLDivElement | null) => {
|
||||
const prev = measureRefs.current.get(stepId) ?? null;
|
||||
if (prev && prev !== el) {
|
||||
roRef.current?.unobserve(prev);
|
||||
measureRefs.current.delete(step.id);
|
||||
measureRefs.current.delete(stepId);
|
||||
}
|
||||
if (el) {
|
||||
measureRefs.current.set(step.id, el);
|
||||
measureRefs.current.set(stepId, el);
|
||||
roRef.current?.observe(el);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} data-step-id={step.id}>
|
||||
<div
|
||||
ref={setMeasureRef}
|
||||
className="relative px-3 py-4"
|
||||
data-step-id={step.id}
|
||||
>
|
||||
<StepDroppableArea stepId={step.id} />
|
||||
<div
|
||||
className={cn(
|
||||
"mb-2 rounded border shadow-sm transition-colors",
|
||||
selectedStepId === step.id
|
||||
? "border-border bg-accent/30"
|
||||
: "hover:bg-accent/30",
|
||||
isDragging && "opacity-80 ring-1 ring-blue-300",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-between gap-2 border-b px-2 py-1.5"
|
||||
onClick={(e) => {
|
||||
// Avoid selecting step when interacting with controls or inputs
|
||||
const tag = (e.target as HTMLElement).tagName.toLowerCase();
|
||||
if (tag === "input" || tag === "textarea" || tag === "button")
|
||||
return;
|
||||
selectStep(step.id);
|
||||
selectAction(step.id, undefined);
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleExpanded(step);
|
||||
}}
|
||||
className="text-muted-foreground hover:bg-accent/60 hover:text-foreground rounded p-1"
|
||||
aria-label={step.expanded ? "Collapse step" : "Expand step"}
|
||||
>
|
||||
{step.expanded ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-5 px-1.5 text-[10px] font-normal"
|
||||
>
|
||||
{step.order + 1}
|
||||
</Badge>
|
||||
{renamingStepId === step.id ? (
|
||||
<Input
|
||||
autoFocus
|
||||
defaultValue={step.name}
|
||||
className="h-7 w-40 text-xs"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
renameStep(
|
||||
step,
|
||||
(e.target as HTMLInputElement).value.trim() ||
|
||||
step.name,
|
||||
);
|
||||
setRenamingStepId(null);
|
||||
void recomputeHash();
|
||||
} else if (e.key === "Escape") {
|
||||
setRenamingStepId(null);
|
||||
}
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
renameStep(step, e.target.value.trim() || step.name);
|
||||
setRenamingStepId(null);
|
||||
void recomputeHash();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-sm font-medium">{step.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground p-1 opacity-0 group-hover:opacity-100"
|
||||
aria-label="Rename step"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setRenamingStepId(step.id);
|
||||
}}
|
||||
>
|
||||
<Edit3 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<span className="text-muted-foreground hidden text-[11px] md:inline">
|
||||
{step.actions.length} actions
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-[11px] text-red-500 hover:text-red-600"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteStep(step);
|
||||
}}
|
||||
aria-label="Delete step"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<div
|
||||
className="text-muted-foreground cursor-grab p-1"
|
||||
aria-label="Drag step"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{step.expanded && (
|
||||
<div className="space-y-2 px-3 py-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{step.actions.length > 0 && (
|
||||
<SortableContext
|
||||
items={step.actions.map((a) => sortableActionId(a.id))}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
{step.actions.map((action) => (
|
||||
<SortableActionChip
|
||||
key={action.id}
|
||||
action={action}
|
||||
isSelected={
|
||||
selectedStepId === step.id &&
|
||||
selectedActionId === action.id
|
||||
}
|
||||
onSelect={() => {
|
||||
selectStep(step.id);
|
||||
selectAction(step.id, action.id);
|
||||
}}
|
||||
onDelete={() => deleteAction(step.id, action.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
)}
|
||||
</div>
|
||||
{/* Persistent centered bottom drop hint */}
|
||||
<div className="mt-3 flex w-full items-center justify-center">
|
||||
<div className="text-muted-foreground border-muted-foreground/30 rounded border border-dashed px-2 py-1 text-[11px]">
|
||||
Drop actions here
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
/* ------------------------------------------------------------------------ */
|
||||
/* Render */
|
||||
@@ -767,7 +984,27 @@ export function FlowWorkspace({
|
||||
>
|
||||
<div style={{ height: totalHeight, position: "relative" }}>
|
||||
{virtualItems.map(
|
||||
(vi) => vi.visible && <StepRow key={vi.key} item={vi} />,
|
||||
(vi) =>
|
||||
vi.visible && (
|
||||
<StepRow
|
||||
key={vi.key}
|
||||
item={vi}
|
||||
selectedStepId={selectedStepId}
|
||||
selectedActionId={selectedActionId}
|
||||
renamingStepId={renamingStepId}
|
||||
onSelectStep={selectStep}
|
||||
onSelectAction={selectAction}
|
||||
onToggleExpanded={toggleExpanded}
|
||||
onRenameStep={(step, name) => {
|
||||
renameStep(step, name);
|
||||
void recomputeHash();
|
||||
}}
|
||||
onDeleteStep={deleteStep}
|
||||
onDeleteAction={deleteAction}
|
||||
setRenamingStepId={setRenamingStepId}
|
||||
registerMeasureRef={registerMeasureRef}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</SortableContext>
|
||||
|
||||
@@ -53,6 +53,30 @@ export interface PanelsContainerProps {
|
||||
* - Resize handles are absolutely positioned over the grid at the left and right boundaries.
|
||||
* - Fractions are clamped with configurable min/max so panels remain usable at all sizes.
|
||||
*/
|
||||
const Panel: React.FC<React.PropsWithChildren<{
|
||||
className?: string;
|
||||
panelClassName?: string;
|
||||
contentClassName?: string;
|
||||
}>> = ({
|
||||
className: panelCls,
|
||||
panelClassName,
|
||||
contentClassName,
|
||||
children,
|
||||
}) => (
|
||||
<section
|
||||
className={cn("min-w-0 overflow-hidden", panelCls, panelClassName)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full min-h-0 w-full overflow-x-hidden overflow-y-auto",
|
||||
contentClassName,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
export function PanelsContainer({
|
||||
left,
|
||||
center,
|
||||
@@ -209,10 +233,10 @@ export function PanelsContainer({
|
||||
// CSS variables for the grid fractions
|
||||
const styleVars: React.CSSProperties & Record<string, string> = hasCenter
|
||||
? {
|
||||
"--col-left": `${(hasLeft ? l : 0) * 100}%`,
|
||||
"--col-center": `${c * 100}%`,
|
||||
"--col-right": `${(hasRight ? r : 0) * 100}%`,
|
||||
}
|
||||
"--col-left": `${(hasLeft ? l : 0) * 100}%`,
|
||||
"--col-center": `${c * 100}%`,
|
||||
"--col-right": `${(hasRight ? r : 0) * 100}%`,
|
||||
}
|
||||
: {};
|
||||
|
||||
// Explicit grid template depending on which side panels exist
|
||||
@@ -229,28 +253,12 @@ export function PanelsContainer({
|
||||
const centerDividers =
|
||||
showDividers && hasCenter
|
||||
? cn({
|
||||
"border-l": hasLeft,
|
||||
"border-r": hasRight,
|
||||
})
|
||||
"border-l": hasLeft,
|
||||
"border-r": hasRight,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const Panel: React.FC<React.PropsWithChildren<{ className?: string }>> = ({
|
||||
className: panelCls,
|
||||
children,
|
||||
}) => (
|
||||
<section
|
||||
className={cn("min-w-0 overflow-hidden", panelCls, panelClassName)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full min-h-0 w-full overflow-x-hidden overflow-y-auto",
|
||||
contentClassName,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -263,11 +271,33 @@ export function PanelsContainer({
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{hasLeft && <Panel>{left}</Panel>}
|
||||
{hasLeft && (
|
||||
<Panel
|
||||
panelClassName={panelClassName}
|
||||
contentClassName={contentClassName}
|
||||
>
|
||||
{left}
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{hasCenter && <Panel className={centerDividers}>{center}</Panel>}
|
||||
{hasCenter && (
|
||||
<Panel
|
||||
className={centerDividers}
|
||||
panelClassName={panelClassName}
|
||||
contentClassName={contentClassName}
|
||||
>
|
||||
{center}
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{hasRight && <Panel>{right}</Panel>}
|
||||
{hasRight && (
|
||||
<Panel
|
||||
panelClassName={panelClassName}
|
||||
contentClassName={contentClassName}
|
||||
>
|
||||
{right}
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{/* Resize handles (only render where applicable) */}
|
||||
{hasCenter && hasLeft && (
|
||||
|
||||
@@ -174,7 +174,7 @@ export function ActionLibraryPanel() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedCategories, setSelectedCategories] = useState<
|
||||
Set<ActionCategory>
|
||||
>(new Set<ActionCategory>(["wizard"]));
|
||||
>(new Set<ActionCategory>(["wizard", "robot", "control", "observation"]));
|
||||
const [favorites, setFavorites] = useState<FavoritesState>({
|
||||
favorites: new Set<string>(),
|
||||
});
|
||||
@@ -293,9 +293,7 @@ export function ActionLibraryPanel() {
|
||||
setShowOnlyFavorites(false);
|
||||
}, [categories]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedCategories(new Set(categories.map((c) => c.key)));
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const activeCats = selectedCategories;
|
||||
|
||||
@@ -155,8 +155,9 @@ function projectActionForDesign(
|
||||
pluginVersion: action.source.pluginVersion,
|
||||
baseActionId: action.source.baseActionId,
|
||||
},
|
||||
execution: projectExecutionDescriptor(action.execution),
|
||||
execution: action.execution ? projectExecutionDescriptor(action.execution) : null,
|
||||
parameterKeysOrValues: parameterProjection,
|
||||
children: action.children?.map(c => projectActionForDesign(c, options)) ?? [],
|
||||
};
|
||||
|
||||
if (options.includeActionNames) {
|
||||
|
||||
@@ -79,6 +79,23 @@ export interface DesignerState {
|
||||
busyHashing: boolean;
|
||||
busyValidating: boolean;
|
||||
|
||||
/* ---------------------- DnD Projection (Transient) ----------------------- */
|
||||
insertionProjection: {
|
||||
stepId: string;
|
||||
parentId: string | null;
|
||||
index: number;
|
||||
action: ExperimentAction;
|
||||
} | null;
|
||||
|
||||
setInsertionProjection: (
|
||||
projection: {
|
||||
stepId: string;
|
||||
parentId: string | null;
|
||||
index: number;
|
||||
action: ExperimentAction;
|
||||
} | null
|
||||
) => void;
|
||||
|
||||
/* ------------------------------ Mutators --------------------------------- */
|
||||
|
||||
// Selection
|
||||
@@ -92,9 +109,10 @@ export interface DesignerState {
|
||||
reorderStep: (from: number, to: number) => void;
|
||||
|
||||
// Actions
|
||||
upsertAction: (stepId: string, action: ExperimentAction) => void;
|
||||
upsertAction: (stepId: string, action: ExperimentAction, parentId?: string | null, index?: number) => void;
|
||||
removeAction: (stepId: string, actionId: string) => void;
|
||||
reorderAction: (stepId: string, from: number, to: number) => void;
|
||||
moveAction: (stepId: string, actionId: string, newParentId: string | null, newIndex: number) => void;
|
||||
|
||||
// Dirty
|
||||
markDirty: (id: string) => void;
|
||||
@@ -159,17 +177,73 @@ function reindexActions(actions: ExperimentAction[]): ExperimentAction[] {
|
||||
return actions.map((a) => ({ ...a }));
|
||||
}
|
||||
|
||||
function updateActionList(
|
||||
existing: ExperimentAction[],
|
||||
function findActionById(
|
||||
list: ExperimentAction[],
|
||||
id: string,
|
||||
): ExperimentAction | null {
|
||||
for (const action of list) {
|
||||
if (action.id === id) return action;
|
||||
if (action.children) {
|
||||
const found = findActionById(action.children, id);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function updateActionInTree(
|
||||
list: ExperimentAction[],
|
||||
action: ExperimentAction,
|
||||
): ExperimentAction[] {
|
||||
const idx = existing.findIndex((a) => a.id === action.id);
|
||||
if (idx >= 0) {
|
||||
const copy = [...existing];
|
||||
copy[idx] = { ...action };
|
||||
return list.map((a) => {
|
||||
if (a.id === action.id) return { ...action };
|
||||
if (a.children) {
|
||||
return { ...a, children: updateActionInTree(a.children, action) };
|
||||
}
|
||||
return a;
|
||||
});
|
||||
}
|
||||
|
||||
// Immutable removal
|
||||
function removeActionFromTree(
|
||||
list: ExperimentAction[],
|
||||
id: string,
|
||||
): ExperimentAction[] {
|
||||
return list
|
||||
.filter((a) => a.id !== id)
|
||||
.map((a) => ({
|
||||
...a,
|
||||
children: a.children ? removeActionFromTree(a.children, id) : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
// Immutable insertion
|
||||
function insertActionIntoTree(
|
||||
list: ExperimentAction[],
|
||||
action: ExperimentAction,
|
||||
parentId: string | null,
|
||||
index: number,
|
||||
): ExperimentAction[] {
|
||||
if (!parentId) {
|
||||
// Insert at root level
|
||||
const copy = [...list];
|
||||
copy.splice(index, 0, action);
|
||||
return copy;
|
||||
}
|
||||
return [...existing, { ...action }];
|
||||
return list.map((a) => {
|
||||
if (a.id === parentId) {
|
||||
const children = a.children ? [...a.children] : [];
|
||||
children.splice(index, 0, action);
|
||||
return { ...a, children };
|
||||
}
|
||||
if (a.children) {
|
||||
return {
|
||||
...a,
|
||||
children: insertActionIntoTree(a.children, action, parentId, index),
|
||||
};
|
||||
}
|
||||
return a;
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
@@ -187,6 +261,7 @@ export const useDesignerStore = create<DesignerState>((set, get) => ({
|
||||
autoSaveEnabled: true,
|
||||
busyHashing: false,
|
||||
busyValidating: false,
|
||||
insertionProjection: null,
|
||||
|
||||
/* ------------------------------ Selection -------------------------------- */
|
||||
selectStep: (id) =>
|
||||
@@ -263,16 +338,31 @@ export const useDesignerStore = create<DesignerState>((set, get) => ({
|
||||
}),
|
||||
|
||||
/* ------------------------------- Actions --------------------------------- */
|
||||
upsertAction: (stepId: string, action: ExperimentAction) =>
|
||||
upsertAction: (stepId: string, action: ExperimentAction, parentId: string | null = null, index?: number) =>
|
||||
set((state: DesignerState) => {
|
||||
const stepsDraft: ExperimentStep[] = state.steps.map((s) =>
|
||||
s.id === stepId
|
||||
? {
|
||||
...s,
|
||||
actions: reindexActions(updateActionList(s.actions, action)),
|
||||
}
|
||||
: s,
|
||||
);
|
||||
const stepsDraft: ExperimentStep[] = state.steps.map((s) => {
|
||||
if (s.id !== stepId) return s;
|
||||
|
||||
// Check if exists (update)
|
||||
const exists = findActionById(s.actions, action.id);
|
||||
if (exists) {
|
||||
// If updating, we don't (currently) support moving via upsert.
|
||||
// Use moveAction for moving.
|
||||
return {
|
||||
...s,
|
||||
actions: updateActionInTree(s.actions, action)
|
||||
};
|
||||
}
|
||||
|
||||
// Add new
|
||||
// If index is provided, use it. Otherwise append.
|
||||
const insertIndex = index ?? s.actions.length;
|
||||
|
||||
return {
|
||||
...s,
|
||||
actions: insertActionIntoTree(s.actions, action, parentId, insertIndex)
|
||||
};
|
||||
});
|
||||
return {
|
||||
steps: stepsDraft,
|
||||
dirtyEntities: new Set<string>([
|
||||
@@ -288,11 +378,9 @@ export const useDesignerStore = create<DesignerState>((set, get) => ({
|
||||
const stepsDraft: ExperimentStep[] = state.steps.map((s) =>
|
||||
s.id === stepId
|
||||
? {
|
||||
...s,
|
||||
actions: reindexActions(
|
||||
s.actions.filter((a) => a.id !== actionId),
|
||||
),
|
||||
}
|
||||
...s,
|
||||
actions: removeActionFromTree(s.actions, actionId),
|
||||
}
|
||||
: s,
|
||||
);
|
||||
const dirty = new Set<string>(state.dirtyEntities);
|
||||
@@ -308,31 +396,29 @@ export const useDesignerStore = create<DesignerState>((set, get) => ({
|
||||
};
|
||||
}),
|
||||
|
||||
reorderAction: (stepId: string, from: number, to: number) =>
|
||||
moveAction: (stepId: string, actionId: string, newParentId: string | null, newIndex: number) =>
|
||||
set((state: DesignerState) => {
|
||||
const stepsDraft: ExperimentStep[] = state.steps.map((s) => {
|
||||
const stepsDraft = state.steps.map((s) => {
|
||||
if (s.id !== stepId) return s;
|
||||
if (
|
||||
from < 0 ||
|
||||
to < 0 ||
|
||||
from >= s.actions.length ||
|
||||
to >= s.actions.length ||
|
||||
from === to
|
||||
) {
|
||||
return s;
|
||||
}
|
||||
const actionsDraft = [...s.actions];
|
||||
const [moved] = actionsDraft.splice(from, 1);
|
||||
if (!moved) return s;
|
||||
actionsDraft.splice(to, 0, moved);
|
||||
return { ...s, actions: reindexActions(actionsDraft) };
|
||||
|
||||
const actionToMove = findActionById(s.actions, actionId);
|
||||
if (!actionToMove) return s;
|
||||
|
||||
const pruned = removeActionFromTree(s.actions, actionId);
|
||||
const inserted = insertActionIntoTree(pruned, actionToMove, newParentId, newIndex);
|
||||
return { ...s, actions: inserted };
|
||||
});
|
||||
return {
|
||||
steps: stepsDraft,
|
||||
dirtyEntities: new Set<string>([...state.dirtyEntities, stepId]),
|
||||
dirtyEntities: new Set<string>([...state.dirtyEntities, stepId, actionId]),
|
||||
};
|
||||
}),
|
||||
|
||||
reorderAction: (stepId: string, from: number, to: number) =>
|
||||
get().moveAction(stepId, get().steps.find(s => s.id === stepId)?.actions[from]?.id!, null, to), // Legacy compat support (only works for root level reorder)
|
||||
|
||||
setInsertionProjection: (projection) => set({ insertionProjection: projection }),
|
||||
|
||||
/* -------------------------------- Dirty ---------------------------------- */
|
||||
markDirty: (id: string) =>
|
||||
set((state: DesignerState) => ({
|
||||
|
||||
@@ -643,13 +643,13 @@ export function validateExecution(
|
||||
if (trialStartSteps.length > 1) {
|
||||
trialStartSteps.slice(1).forEach((step) => {
|
||||
issues.push({
|
||||
severity: "warning",
|
||||
severity: "info",
|
||||
message:
|
||||
"Multiple steps will start simultaneously. Ensure parallel execution is intended.",
|
||||
"This step will start immediately at trial start. For sequential flow, use 'Previous Step' trigger.",
|
||||
category: "execution",
|
||||
field: "trigger.type",
|
||||
stepId: step.id,
|
||||
suggestion: "Consider using sequential triggers for subsequent steps",
|
||||
suggestion: "Change trigger to 'Previous Step' if this step should follow the previous one",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
11
src/components/ui/aspect-ratio.tsx
Normal file
11
src/components/ui/aspect-ratio.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
"use client"
|
||||
|
||||
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
|
||||
|
||||
function AspectRatio({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
|
||||
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />
|
||||
}
|
||||
|
||||
export { AspectRatio }
|
||||
@@ -64,6 +64,7 @@ export interface CompiledExecutionAction {
|
||||
parameterSchemaRaw?: unknown;
|
||||
timeout?: number;
|
||||
retryable?: boolean;
|
||||
children?: CompiledExecutionAction[];
|
||||
}
|
||||
|
||||
/* ---------- Compile Entry Point ---------- */
|
||||
@@ -136,11 +137,12 @@ function compileAction(
|
||||
robotId: action.source.robotId,
|
||||
baseActionId: action.source.baseActionId,
|
||||
},
|
||||
execution: action.execution,
|
||||
execution: action.execution!, // Assumes validation passed
|
||||
parameters: action.parameters,
|
||||
parameterSchemaRaw: action.parameterSchemaRaw,
|
||||
timeout: action.execution.timeoutMs,
|
||||
retryable: action.execution.retryable,
|
||||
timeout: action.execution?.timeoutMs,
|
||||
retryable: action.execution?.retryable,
|
||||
children: action.children?.map((child, i) => compileAction(child, i)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -149,17 +151,24 @@ function compileAction(
|
||||
export function collectPluginDependencies(design: ExperimentDesign): string[] {
|
||||
const set = new Set<string>();
|
||||
for (const step of design.steps) {
|
||||
for (const action of step.actions) {
|
||||
if (action.source.kind === "plugin" && action.source.pluginId) {
|
||||
const versionPart = action.source.pluginVersion
|
||||
? `@${action.source.pluginVersion}`
|
||||
: "";
|
||||
set.add(`${action.source.pluginId}${versionPart}`);
|
||||
}
|
||||
}
|
||||
collectDependenciesFromActions(step.actions, set);
|
||||
}
|
||||
return Array.from(set).sort();
|
||||
}
|
||||
// Helper to recursively collect from actions list directly would be cleaner
|
||||
function collectDependenciesFromActions(actions: ExperimentAction[], set: Set<string>) {
|
||||
for (const action of actions) {
|
||||
if (action.source.kind === "plugin" && action.source.pluginId) {
|
||||
const versionPart = action.source.pluginVersion
|
||||
? `@${action.source.pluginVersion}`
|
||||
: "";
|
||||
set.add(`${action.source.pluginId}${versionPart}`);
|
||||
}
|
||||
if (action.children) {
|
||||
collectDependenciesFromActions(action.children, set);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Integrity Hash Generation ---------- */
|
||||
|
||||
@@ -199,6 +208,12 @@ function buildStructuralSignature(
|
||||
timeout: a.timeout,
|
||||
retryable: a.retryable ?? false,
|
||||
parameterKeys: summarizeParametersForHash(a.parameters),
|
||||
children: a.children?.map(c => ({
|
||||
id: c.id,
|
||||
// Recurse structural signature for children
|
||||
type: c.type,
|
||||
parameterKeys: summarizeParametersForHash(c.parameters),
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
pluginDependencies,
|
||||
|
||||
@@ -53,15 +53,18 @@ export interface ActionDefinition {
|
||||
};
|
||||
execution?: ExecutionDescriptor;
|
||||
parameterSchemaRaw?: unknown; // snapshot of original schema for validation/audit
|
||||
nestable?: boolean; // If true, this action can contain child actions
|
||||
}
|
||||
|
||||
export interface ExperimentAction {
|
||||
id: string;
|
||||
type: ActionType;
|
||||
type: string; // e.g. "wizard_speak", "robot_move"
|
||||
name: string;
|
||||
description?: string; // Optional description
|
||||
parameters: Record<string, unknown>;
|
||||
duration?: number;
|
||||
duration?: number; // Estimated duration in seconds
|
||||
category: ActionCategory;
|
||||
// Provenance (where did this come from?)
|
||||
source: {
|
||||
kind: "core" | "plugin";
|
||||
pluginId?: string;
|
||||
@@ -69,8 +72,14 @@ export interface ExperimentAction {
|
||||
robotId?: string | null;
|
||||
baseActionId?: string;
|
||||
};
|
||||
execution: ExecutionDescriptor;
|
||||
// Execution (how do we run this?)
|
||||
execution?: ExecutionDescriptor;
|
||||
|
||||
// Snapshot of parameter schema at the time of addition (for drift detection)
|
||||
parameterSchemaRaw?: unknown;
|
||||
|
||||
// Nested actions (control flow)
|
||||
children?: ExperimentAction[];
|
||||
}
|
||||
|
||||
export interface StepTrigger {
|
||||
|
||||
@@ -90,17 +90,26 @@ const executionDescriptorSchema = z
|
||||
// Action parameter snapshot is a free-form structure retained for audit
|
||||
const parameterSchemaRawSchema = z.unknown().optional();
|
||||
|
||||
// Action schema (loose input → normalized internal)
|
||||
const visualActionInputSchema = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
type: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
category: actionCategoryEnum.optional(),
|
||||
parameters: z.record(z.string(), z.unknown()).default({}),
|
||||
source: actionSourceSchema.optional(),
|
||||
execution: executionDescriptorSchema.optional(),
|
||||
parameterSchemaRaw: parameterSchemaRawSchema,
|
||||
// Base action schema (without recursion)
|
||||
const baseActionSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
type: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
category: actionCategoryEnum.optional(),
|
||||
parameters: z.record(z.string(), z.unknown()).default({}),
|
||||
source: actionSourceSchema.optional(),
|
||||
execution: executionDescriptorSchema.optional(),
|
||||
parameterSchemaRaw: parameterSchemaRawSchema,
|
||||
});
|
||||
|
||||
type VisualActionInput = z.infer<typeof baseActionSchema> & {
|
||||
children?: VisualActionInput[];
|
||||
};
|
||||
|
||||
const visualActionInputSchema: z.ZodType<VisualActionInput> = baseActionSchema
|
||||
.extend({
|
||||
children: z.lazy(() => z.array(visualActionInputSchema)).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -144,8 +153,7 @@ export function parseVisualDesignSteps(raw: unknown): {
|
||||
issues.push(
|
||||
...zodErr.issues.map(
|
||||
(issue) =>
|
||||
`steps${
|
||||
issue.path.length ? "." + issue.path.join(".") : ""
|
||||
`steps${issue.path.length ? "." + issue.path.join(".") : ""
|
||||
}: ${issue.message} (code=${issue.code})`,
|
||||
),
|
||||
);
|
||||
@@ -155,69 +163,73 @@ export function parseVisualDesignSteps(raw: unknown): {
|
||||
// Normalize to internal ExperimentStep[] shape
|
||||
const inputSteps = parsed.data;
|
||||
|
||||
const normalized: ExperimentStep[] = inputSteps.map((s, idx) => {
|
||||
const actions: ExperimentAction[] = s.actions.map((a) => {
|
||||
// Default provenance
|
||||
const source: {
|
||||
kind: "core" | "plugin";
|
||||
pluginId?: string;
|
||||
pluginVersion?: string;
|
||||
robotId?: string | null;
|
||||
baseActionId?: string;
|
||||
} = a.source
|
||||
const normalizeAction = (a: VisualActionInput): ExperimentAction => {
|
||||
// Default provenance
|
||||
const source: {
|
||||
kind: "core" | "plugin";
|
||||
pluginId?: string;
|
||||
pluginVersion?: string;
|
||||
robotId?: string | null;
|
||||
baseActionId?: string;
|
||||
} = a.source
|
||||
? {
|
||||
kind: a.source.kind,
|
||||
pluginId: a.source.pluginId,
|
||||
pluginVersion: a.source.pluginVersion,
|
||||
robotId: a.source.robotId ?? null,
|
||||
baseActionId: a.source.baseActionId,
|
||||
}
|
||||
kind: a.source.kind,
|
||||
pluginId: a.source.pluginId,
|
||||
pluginVersion: a.source.pluginVersion,
|
||||
robotId: a.source.robotId ?? null,
|
||||
baseActionId: a.source.baseActionId,
|
||||
}
|
||||
: { kind: "core" };
|
||||
|
||||
// Default execution
|
||||
const execution: ExecutionDescriptor = a.execution
|
||||
? {
|
||||
transport: a.execution.transport,
|
||||
timeoutMs: a.execution.timeoutMs,
|
||||
retryable: a.execution.retryable,
|
||||
ros2: a.execution.ros2,
|
||||
rest: a.execution.rest
|
||||
? {
|
||||
method: a.execution.rest.method,
|
||||
path: a.execution.rest.path,
|
||||
headers: a.execution.rest.headers
|
||||
? Object.fromEntries(
|
||||
Object.entries(a.execution.rest.headers).filter(
|
||||
(kv): kv is [string, string] =>
|
||||
typeof kv[1] === "string",
|
||||
),
|
||||
)
|
||||
: undefined,
|
||||
}
|
||||
// Default execution
|
||||
const execution: ExecutionDescriptor = a.execution
|
||||
? {
|
||||
transport: a.execution.transport,
|
||||
timeoutMs: a.execution.timeoutMs,
|
||||
retryable: a.execution.retryable,
|
||||
ros2: a.execution.ros2,
|
||||
rest: a.execution.rest
|
||||
? {
|
||||
method: a.execution.rest.method,
|
||||
path: a.execution.rest.path,
|
||||
headers: a.execution.rest.headers
|
||||
? Object.fromEntries(
|
||||
Object.entries(a.execution.rest.headers).filter(
|
||||
(kv): kv is [string, string] =>
|
||||
typeof kv[1] === "string",
|
||||
),
|
||||
)
|
||||
: undefined,
|
||||
}
|
||||
: { transport: "internal" };
|
||||
: undefined,
|
||||
}
|
||||
: { transport: "internal" };
|
||||
|
||||
return {
|
||||
id: a.id,
|
||||
type: a.type, // dynamic (pluginId.actionId)
|
||||
name: a.name,
|
||||
parameters: a.parameters ?? {},
|
||||
duration: undefined,
|
||||
category: (a.category ?? "wizard") as ActionCategory,
|
||||
source: {
|
||||
kind: source.kind,
|
||||
pluginId: source.kind === "plugin" ? source.pluginId : undefined,
|
||||
pluginVersion:
|
||||
source.kind === "plugin" ? source.pluginVersion : undefined,
|
||||
robotId: source.kind === "plugin" ? (source.robotId ?? null) : null,
|
||||
baseActionId:
|
||||
source.kind === "plugin" ? source.baseActionId : undefined,
|
||||
},
|
||||
execution,
|
||||
parameterSchemaRaw: a.parameterSchemaRaw,
|
||||
};
|
||||
});
|
||||
return {
|
||||
id: a.id,
|
||||
type: a.type,
|
||||
name: a.name,
|
||||
description: a.description,
|
||||
parameters: a.parameters ?? {},
|
||||
duration: undefined,
|
||||
category: (a.category ?? "wizard") as ActionCategory,
|
||||
source: {
|
||||
kind: source.kind,
|
||||
pluginId: source.kind === "plugin" ? source.pluginId : undefined,
|
||||
pluginVersion:
|
||||
source.kind === "plugin" ? source.pluginVersion : undefined,
|
||||
robotId: source.kind === "plugin" ? (source.robotId ?? null) : null,
|
||||
baseActionId:
|
||||
source.kind === "plugin" ? source.baseActionId : undefined,
|
||||
},
|
||||
execution,
|
||||
parameterSchemaRaw: a.parameterSchemaRaw,
|
||||
children: a.children?.map(normalizeAction) ?? [],
|
||||
};
|
||||
};
|
||||
|
||||
const normalized: ExperimentStep[] = inputSteps.map((s, idx) => {
|
||||
const actions: ExperimentAction[] = s.actions.map(normalizeAction);
|
||||
|
||||
// Construct step
|
||||
return {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { robotsRouter } from "~/server/api/routers/robots";
|
||||
import { studiesRouter } from "~/server/api/routers/studies";
|
||||
import { trialsRouter } from "~/server/api/routers/trials";
|
||||
import { usersRouter } from "~/server/api/routers/users";
|
||||
import { storageRouter } from "~/server/api/routers/storage";
|
||||
import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
|
||||
|
||||
/**
|
||||
@@ -32,6 +33,7 @@ export const appRouter = createTRPCRouter({
|
||||
collaboration: collaborationRouter,
|
||||
admin: adminRouter,
|
||||
dashboard: dashboardRouter,
|
||||
storage: storageRouter,
|
||||
});
|
||||
|
||||
// export type definition of API
|
||||
|
||||
71
src/server/api/routers/storage.ts
Normal file
71
src/server/api/routers/storage.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
|
||||
import { z } from "zod";
|
||||
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
||||
import { s3Client } from "~/server/storage";
|
||||
import { PutObjectCommand } from "@aws-sdk/client-s3";
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
import { env } from "~/env";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { db } from "~/server/db";
|
||||
import { mediaCaptures } from "~/server/db/schema";
|
||||
|
||||
export const storageRouter = createTRPCRouter({
|
||||
getUploadPresignedUrl: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
filename: z.string(),
|
||||
contentType: z.string(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
const bucket = env.MINIO_BUCKET_NAME ?? "hristudio-data";
|
||||
const key = input.filename;
|
||||
|
||||
try {
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
ContentType: input.contentType,
|
||||
});
|
||||
|
||||
const url = await getSignedUrl(s3Client, command, { expiresIn: 3600 });
|
||||
|
||||
return {
|
||||
url,
|
||||
key,
|
||||
bucket,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error generating presigned URL:", error);
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to generate upload URL",
|
||||
});
|
||||
}
|
||||
}),
|
||||
saveRecording: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
trialId: z.string(),
|
||||
storagePath: z.string(),
|
||||
fileSize: z.number().optional(),
|
||||
format: z.string().optional(),
|
||||
mediaType: z.enum(["video", "audio", "image"]).default("video"),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { db } = ctx;
|
||||
|
||||
await db.insert(mediaCaptures).values({
|
||||
trialId: input.trialId,
|
||||
mediaType: input.mediaType,
|
||||
storagePath: input.storagePath,
|
||||
fileSize: input.fileSize,
|
||||
format: input.format,
|
||||
startTimestamp: new Date(), // Approximate
|
||||
// metadata: { uploadedBy: ctx.session.user.id }
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
@@ -30,6 +30,10 @@ import {
|
||||
TrialExecutionEngine,
|
||||
type ActionDefinition,
|
||||
} from "~/server/services/trial-execution";
|
||||
import { s3Client } from "~/server/storage";
|
||||
import { GetObjectCommand } from "@aws-sdk/client-s3";
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
import { env } from "~/env";
|
||||
|
||||
// Helper function to check if user has access to trial
|
||||
async function checkTrialAccess(
|
||||
@@ -270,15 +274,34 @@ export const trialsRouter = createTRPCRouter({
|
||||
.from(trialEvents)
|
||||
.where(eq(trialEvents.trialId, input.id));
|
||||
|
||||
const mediaCount = await db
|
||||
.select({ count: count() })
|
||||
const media = await db
|
||||
.select()
|
||||
.from(mediaCaptures)
|
||||
.where(eq(mediaCaptures.trialId, input.id));
|
||||
.where(eq(mediaCaptures.trialId, input.id))
|
||||
.orderBy(desc(mediaCaptures.createdAt)); // Get latest first
|
||||
|
||||
return {
|
||||
...trial[0],
|
||||
eventCount: eventCount[0]?.count ?? 0,
|
||||
mediaCount: mediaCount[0]?.count ?? 0,
|
||||
mediaCount: media.length,
|
||||
media: await Promise.all(media.map(async (m) => {
|
||||
let url = "";
|
||||
try {
|
||||
// Generate Presigned GET URL
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: env.MINIO_BUCKET_NAME ?? "hristudio-data",
|
||||
Key: m.storagePath,
|
||||
});
|
||||
url = await getSignedUrl(s3Client, command, { expiresIn: 3600 });
|
||||
} catch (e) {
|
||||
console.error("Failed to sign URL for media", m.id, e);
|
||||
}
|
||||
return {
|
||||
...m,
|
||||
url, // Add the signed URL to the response
|
||||
contentType: m.format === 'webm' ? 'video/webm' : 'application/octet-stream', // Infer or store content type
|
||||
};
|
||||
})),
|
||||
};
|
||||
}),
|
||||
|
||||
|
||||
20
src/server/storage.ts
Normal file
20
src/server/storage.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { S3Client } from "@aws-sdk/client-s3";
|
||||
import { env } from "~/env";
|
||||
|
||||
const globalForS3 = globalThis as unknown as {
|
||||
s3Client: S3Client | undefined;
|
||||
};
|
||||
|
||||
export const s3Client =
|
||||
globalForS3.s3Client ??
|
||||
new S3Client({
|
||||
region: env.MINIO_REGION ?? "us-east-1",
|
||||
endpoint: env.MINIO_ENDPOINT,
|
||||
credentials: {
|
||||
accessKeyId: env.MINIO_ACCESS_KEY ?? "minioadmin",
|
||||
secretAccessKey: env.MINIO_SECRET_KEY ?? "minioadmin",
|
||||
},
|
||||
forcePathStyle: true, // Needed for MinIO
|
||||
});
|
||||
|
||||
if (env.NODE_ENV !== "production") globalForS3.s3Client = s3Client;
|
||||
@@ -4,8 +4,7 @@
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme {
|
||||
--font-sans:
|
||||
var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif,
|
||||
--font-sans: var(--font-sans), ui-sans-serif, system-ui, sans-serif,
|
||||
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
}
|
||||
|
||||
@@ -45,9 +44,7 @@
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--font-sans: Geist Mono, monospace;
|
||||
--font-mono: Geist Mono, monospace;
|
||||
--font-serif: Geist Mono, monospace;
|
||||
|
||||
--radius: 0rem;
|
||||
--tracking-tighter: calc(var(--tracking-normal) - 0.05em);
|
||||
--tracking-tight: calc(var(--tracking-normal) - 0.025em);
|
||||
@@ -108,9 +105,7 @@
|
||||
--sidebar-border: oklch(0.85 0.03 245);
|
||||
--sidebar-ring: oklch(0.6 0.05 240);
|
||||
--destructive-foreground: oklch(0.9702 0 0);
|
||||
--font-sans: Geist Mono, monospace;
|
||||
--font-serif: Geist Mono, monospace;
|
||||
--font-mono: Geist Mono, monospace;
|
||||
|
||||
--shadow-color: hsl(0 0% 0%);
|
||||
--shadow-opacity: 0;
|
||||
--shadow-blur: 0px;
|
||||
@@ -171,6 +166,43 @@
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
.dark {
|
||||
--background: oklch(0.12 0.008 250);
|
||||
--foreground: oklch(0.95 0.005 250);
|
||||
--card: oklch(0.18 0.008 250);
|
||||
--card-foreground: oklch(0.95 0.005 250);
|
||||
--popover: oklch(0.2 0.01 250);
|
||||
--popover-foreground: oklch(0.95 0.005 250);
|
||||
--primary: oklch(0.65 0.1 240);
|
||||
--primary-foreground: oklch(0.08 0.02 250);
|
||||
--secondary: oklch(0.25 0.015 245);
|
||||
--secondary-foreground: oklch(0.92 0.008 250);
|
||||
--muted: oklch(0.22 0.01 250);
|
||||
--muted-foreground: oklch(0.65 0.02 245);
|
||||
--accent: oklch(0.35 0.025 245);
|
||||
--accent-foreground: oklch(0.92 0.008 250);
|
||||
--destructive: oklch(0.7022 0.1892 22.2279);
|
||||
--border: oklch(0.3 0.015 250);
|
||||
--input: oklch(0.28 0.015 250);
|
||||
--ring: oklch(0.65 0.1 240);
|
||||
--chart-1: oklch(0.65 0.1 240);
|
||||
--chart-2: oklch(0.7 0.12 200);
|
||||
--chart-3: oklch(0.75 0.15 160);
|
||||
--chart-4: oklch(0.8 0.12 120);
|
||||
--chart-5: oklch(0.7 0.18 80);
|
||||
--sidebar: oklch(0.14 0.025 250);
|
||||
--sidebar-foreground: oklch(0.88 0.02 250);
|
||||
--sidebar-primary: oklch(0.8 0.06 240);
|
||||
--sidebar-primary-foreground: oklch(0.12 0.025 250);
|
||||
--sidebar-accent: oklch(0.22 0.04 245);
|
||||
--sidebar-accent-foreground: oklch(0.88 0.02 250);
|
||||
--sidebar-border: oklch(0.32 0.035 250);
|
||||
--sidebar-ring: oklch(0.55 0.08 240);
|
||||
--destructive-foreground: oklch(0.95 0.01 250);
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
|
||||
Reference in New Issue
Block a user