feat: Implement digital signatures for participant consent and introduce study forms management.

This commit is contained in:
2026-03-02 10:51:20 -05:00
parent 61af467cc8
commit 0051946bde
172 changed files with 12612 additions and 9461 deletions

View File

@@ -4,7 +4,9 @@ import { Badge } from "~/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Separator } from "~/components/ui/separator";
import {
getAvailableRoles, getRoleColor, getRolePermissions
getAvailableRoles,
getRoleColor,
getRolePermissions,
} from "~/lib/auth-client";
export function RoleManagement() {

View File

@@ -1,321 +1,341 @@
"use client";
import {
type ColumnDef,
type ColumnFiltersState,
type SortingState,
type VisibilityState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
type ColumnDef,
type ColumnFiltersState,
type SortingState,
type VisibilityState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "~/components/ui/table";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { useState } from "react";
import {
ArrowUpDown,
MoreHorizontal,
Calendar,
Clock,
Activity,
Eye,
Video
ArrowUpDown,
MoreHorizontal,
Calendar,
Clock,
Activity,
Eye,
Video,
} from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import { Badge } from "~/components/ui/badge";
import Link from "next/link";
import { formatDistanceToNow } from "date-fns";
export type AnalyticsTrial = {
id: string;
sessionNumber: number;
status: string;
createdAt: Date;
startedAt: Date | null;
completedAt: Date | null;
duration: number | null;
eventCount: number;
mediaCount: number;
experimentId: string;
participant: {
participantCode: string;
};
experiment: {
name: string;
studyId: string;
};
id: string;
sessionNumber: number;
status: string;
createdAt: Date;
startedAt: Date | null;
completedAt: Date | null;
duration: number | null;
eventCount: number;
mediaCount: number;
experimentId: string;
participant: {
participantCode: string;
};
experiment: {
name: string;
studyId: string;
};
};
export const columns: ColumnDef<AnalyticsTrial>[] = [
{
accessorKey: "sessionNumber",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Session
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => <div className="font-mono text-center">#{row.getValue("sessionNumber")}</div>,
{
accessorKey: "sessionNumber",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Session
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
{
accessorKey: "participant.participantCode",
id: "participantCode",
header: "Participant",
cell: ({ row }) => (
<div className="font-medium">{row.original.participant?.participantCode ?? "Unknown"}</div>
),
cell: ({ row }) => (
<div className="text-center font-mono">
#{row.getValue("sessionNumber")}
</div>
),
},
{
accessorKey: "participant.participantCode",
id: "participantCode",
header: "Participant",
cell: ({ row }) => (
<div className="font-medium">
{row.original.participant?.participantCode ?? "Unknown"}
</div>
),
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => {
const status = row.getValue("status") as string;
return (
<Badge
variant="outline"
className={`capitalize ${
status === "completed"
? "border-green-500/20 bg-green-500/10 text-green-500"
: status === "in_progress"
? "border-blue-500/20 bg-blue-500/10 text-blue-500"
: "border-slate-500/20 bg-slate-500/10 text-slate-500"
}`}
>
{status.replace("_", " ")}
</Badge>
);
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => {
const status = row.getValue("status") as string;
return (
<Badge
variant="outline"
className={`capitalize ${status === "completed"
? "bg-green-500/10 text-green-500 border-green-500/20"
: 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"
}`}
>
{status.replace("_", " ")}
</Badge>
);
},
},
{
accessorKey: "createdAt",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Date
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
{
accessorKey: "createdAt",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Date
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const date = new Date(row.getValue("createdAt"));
return (
<div className="flex flex-col">
<span className="text-sm">{date.toLocaleDateString()}</span>
<span className="text-xs text-muted-foreground">{formatDistanceToNow(date, { addSuffix: true })}</span>
</div>
)
},
cell: ({ row }) => {
const date = new Date(row.getValue("createdAt"));
return (
<div className="flex flex-col">
<span className="text-sm">{date.toLocaleDateString()}</span>
<span className="text-muted-foreground text-xs">
{formatDistanceToNow(date, { addSuffix: true })}
</span>
</div>
);
},
{
accessorKey: "duration",
header: "Duration",
cell: ({ row }) => {
const duration = row.getValue("duration") as number | null;
if (!duration) return <span className="text-muted-foreground">-</span>;
const m = Math.floor(duration / 60);
const s = Math.floor(duration % 60);
return <div className="font-mono">{`${m}m ${s}s`}</div>;
},
},
{
accessorKey: "duration",
header: "Duration",
cell: ({ row }) => {
const duration = row.getValue("duration") as number | null;
if (!duration) return <span className="text-muted-foreground">-</span>;
const m = Math.floor(duration / 60);
const s = Math.floor(duration % 60);
return <div className="font-mono">{`${m}m ${s}s`}</div>;
},
{
accessorKey: "eventCount",
header: "Events",
cell: ({ row }) => {
return (
<div className="flex items-center gap-1">
<Activity className="h-3 w-3 text-muted-foreground" />
<span>{row.getValue("eventCount")}</span>
</div>
)
},
},
{
accessorKey: "eventCount",
header: "Events",
cell: ({ row }) => {
return (
<div className="flex items-center gap-1">
<Activity className="text-muted-foreground h-3 w-3" />
<span>{row.getValue("eventCount")}</span>
</div>
);
},
{
accessorKey: "mediaCount",
header: "Media",
cell: ({ row }) => {
const count = row.getValue("mediaCount") as number;
if (count === 0) return <span className="text-muted-foreground">-</span>;
return (
<div className="flex items-center gap-1">
<Video className="h-3 w-3 text-muted-foreground" />
<span>{count}</span>
</div>
)
},
},
{
accessorKey: "mediaCount",
header: "Media",
cell: ({ row }) => {
const count = row.getValue("mediaCount") as number;
if (count === 0) return <span className="text-muted-foreground">-</span>;
return (
<div className="flex items-center gap-1">
<Video className="text-muted-foreground h-3 w-3" />
<span>{count}</span>
</div>
);
},
{
id: "actions",
cell: ({ row }) => {
const trial = row.original;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem asChild>
<Link href={`/studies/${trial.experiment?.studyId}/trials/${trial.id}/analysis`}>
<Eye className="mr-2 h-4 w-4" />
View Analysis
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/experiments/${trial.experimentId}/trials/${trial.id}`}>
View Trial Details
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
},
},
{
id: "actions",
cell: ({ row }) => {
const trial = row.original;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem asChild>
<Link
href={`/studies/${trial.experiment?.studyId}/trials/${trial.id}/analysis`}
>
<Eye className="mr-2 h-4 w-4" />
View Analysis
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link
href={`/experiments/${trial.experimentId}/trials/${trial.id}`}
>
View Trial Details
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
},
},
];
interface StudyAnalyticsDataTableProps {
data: AnalyticsTrial[];
data: AnalyticsTrial[];
}
export function StudyAnalyticsDataTable({ data }: StudyAnalyticsDataTableProps) {
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const [rowSelection, setRowSelection] = useState({});
export function StudyAnalyticsDataTable({
data,
}: StudyAnalyticsDataTableProps) {
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const [rowSelection, setRowSelection] = useState({});
const table = useReactTable({
data,
columns,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
},
});
const table = useReactTable({
data,
columns,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
},
});
return (
<div className="w-full" id="tour-analytics-table">
<div className="flex items-center py-4">
<Input
placeholder="Filter participants..."
value={(table.getColumn("participantCode")?.getFilterValue() as string) ?? ""}
onChange={(event) =>
table.getColumn("participantCode")?.setFilterValue(event.target.value)
}
className="max-w-sm"
id="tour-analytics-filter"
/>
</div>
<div className="rounded-md border bg-card">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-end space-x-2 py-4">
<div className="flex-1 text-sm text-muted-foreground">
{table.getFilteredSelectedRowModel().rows.length} of{" "}
{table.getFilteredRowModel().rows.length} row(s) selected.
</div>
<div className="space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
</div>
return (
<div className="w-full" id="tour-analytics-table">
<div className="flex items-center py-4">
<Input
placeholder="Filter participants..."
value={
(table.getColumn("participantCode")?.getFilterValue() as string) ??
""
}
onChange={(event) =>
table
.getColumn("participantCode")
?.setFilterValue(event.target.value)
}
className="max-w-sm"
id="tour-analytics-filter"
/>
</div>
<div className="bg-card rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-end space-x-2 py-4">
<div className="text-muted-foreground flex-1 text-sm">
{table.getFilteredSelectedRowModel().rows.length} of{" "}
{table.getFilteredRowModel().rows.length} row(s) selected.
</div>
);
<div className="space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
</div>
</div>
);
}

View File

@@ -21,6 +21,7 @@ import {
User,
UserCheck,
Users,
FileText,
} from "lucide-react";
import { useSidebar } from "~/components/ui/sidebar";
@@ -96,6 +97,11 @@ const studyWorkItems = [
url: "/experiments",
icon: FlaskConical,
},
{
title: "Forms",
url: "/forms",
icon: FileText,
},
{
title: "Analytics",
url: "/analytics",
@@ -143,10 +149,15 @@ export function AppSidebar({
const isAdmin = userRole === "administrator";
const { state: sidebarState } = useSidebar();
const isCollapsed = sidebarState === "collapsed";
const { selectedStudyId, userStudies, selectStudy, refreshStudyData, isLoadingUserStudies } =
useStudyManagement();
const {
selectedStudyId,
userStudies,
selectStudy,
refreshStudyData,
isLoadingUserStudies,
} = useStudyManagement();
const { startTour } = useTour();
const { startTour, isTourActive } = useTour();
// Reference to track if we've already attempted auto-selection to avoid fighting with manual clearing
const hasAutoSelected = useRef(false);
@@ -170,12 +181,7 @@ export function AppSidebar({
hasAutoSelected.current = true;
}
}
}, [
isLoadingUserStudies,
selectedStudyId,
userStudies,
selectStudy,
]);
}, [isLoadingUserStudies, selectedStudyId, userStudies, selectStudy]);
// Debug API call
const { data: debugData } = api.dashboard.debug.useQuery(undefined, {
@@ -309,6 +315,17 @@ export function AppSidebar({
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
{isTourActive && !isCollapsed && (
<div className="mt-1 px-3 pb-2">
<div className="bg-primary/10 text-primary border-primary/20 animate-in fade-in slide-in-from-top-2 flex items-center gap-2 rounded-md border px-2.5 py-1.5 text-xs font-medium shadow-sm">
<span className="relative flex h-2 w-2">
<span className="bg-primary absolute inline-flex h-full w-full animate-ping rounded-full opacity-75"></span>
<span className="bg-primary relative inline-flex h-2 w-2 rounded-full"></span>
</span>
Tutorial Active
</div>
</div>
)}
</SidebarHeader>
<SidebarContent>
@@ -324,7 +341,10 @@ export function AppSidebar({
<TooltipTrigger asChild>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton className="w-full" id="tour-sidebar-study-selector">
<SidebarMenuButton
className="w-full"
id="tour-sidebar-study-selector"
>
<Building className="h-4 w-4 flex-shrink-0" />
<span className="truncate">
{selectedStudy?.name ?? "Select Study"}
@@ -373,7 +393,10 @@ export function AppSidebar({
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton className="w-full" id="tour-sidebar-study-selector">
<SidebarMenuButton
className="w-full"
id="tour-sidebar-study-selector"
>
<Building className="h-4 w-4 flex-shrink-0" />
<span className="truncate">
{selectedStudy?.name ?? "Select Study"}
@@ -576,22 +599,23 @@ export function AppSidebar({
{helpItems.map((item) => {
const isActive = pathname.startsWith(item.url);
const menuButton = item.action === "tour" ? (
<SidebarMenuButton
onClick={() => startTour("full_platform")}
isActive={false}
>
<item.icon className="h-4 w-4" />
<span>{item.title}</span>
</SidebarMenuButton>
) : (
<SidebarMenuButton asChild isActive={isActive}>
<Link href={item.url}>
const menuButton =
item.action === "tour" ? (
<SidebarMenuButton
onClick={() => startTour("full_platform")}
isActive={false}
>
<item.icon className="h-4 w-4" />
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
);
</SidebarMenuButton>
) : (
<SidebarMenuButton asChild isActive={isActive}>
<Link href={item.url}>
<item.icon className="h-4 w-4" />
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
);
return (
<SidebarMenuItem key={item.title}>

View File

@@ -87,33 +87,33 @@ export function ExperimentForm({ mode, experimentId }: ExperimentFormProps) {
{ label: "Studies", href: "/studies" },
...(selectedStudyId
? [
{
label: experiment?.study?.name ?? "Study",
href: `/studies/${selectedStudyId}`,
},
{ label: "Experiments", href: "/experiments" },
...(mode === "edit" && experiment
? [
{
label: experiment.name,
href: `/studies/${selectedStudyId}/experiments/${experiment.id}`,
},
{ label: "Edit" },
]
: [{ label: "New Experiment" }]),
]
{
label: experiment?.study?.name ?? "Study",
href: `/studies/${selectedStudyId}`,
},
{ label: "Experiments", href: "/experiments" },
...(mode === "edit" && experiment
? [
{
label: experiment.name,
href: `/studies/${selectedStudyId}/experiments/${experiment.id}`,
},
{ label: "Edit" },
]
: [{ label: "New Experiment" }]),
]
: [
{ label: "Experiments", href: "/experiments" },
...(mode === "edit" && experiment
? [
{
label: experiment.name,
href: `/studies/${experiment.studyId}/experiments/${experiment.id}`,
},
{ label: "Edit" },
]
: [{ label: "New Experiment" }]),
]),
{ label: "Experiments", href: "/experiments" },
...(mode === "edit" && experiment
? [
{
label: experiment.name,
href: `/studies/${experiment.studyId}/experiments/${experiment.id}`,
},
{ label: "Edit" },
]
: [{ label: "New Experiment" }]),
]),
];
useBreadcrumbsEffect(breadcrumbs);
@@ -153,14 +153,18 @@ export function ExperimentForm({ mode, experimentId }: ExperimentFormProps) {
...data,
estimatedDuration: data.estimatedDuration ?? undefined,
});
router.push(`/studies/${data.studyId}/experiments/${newExperiment.id}/designer`);
router.push(
`/studies/${data.studyId}/experiments/${newExperiment.id}/designer`,
);
} else {
const updatedExperiment = await updateExperimentMutation.mutateAsync({
id: experimentId!,
...data,
estimatedDuration: data.estimatedDuration ?? undefined,
});
router.push(`/studies/${experiment?.studyId ?? data.studyId}/experiments/${updatedExperiment.id}`);
router.push(
`/studies/${experiment?.studyId ?? data.studyId}/experiments/${updatedExperiment.id}`,
);
}
} catch (error) {
setError(

View File

@@ -1,7 +1,17 @@
"use client";
import { formatDistanceToNow } from "date-fns";
import { Calendar, FlaskConical, Plus, Settings, Users } from "lucide-react";
import {
Calendar,
FlaskConical,
Plus,
Settings,
Users,
FileEdit,
TestTube,
CheckCircle2,
Trash2,
} from "lucide-react";
import Link from "next/link";
import { Badge } from "~/components/ui/badge";
@@ -45,22 +55,22 @@ const statusConfig = {
draft: {
label: "Draft",
className: "bg-gray-100 text-gray-800 hover:bg-gray-200",
icon: "📝",
icon: FileEdit,
},
testing: {
label: "Testing",
className: "bg-yellow-100 text-yellow-800 hover:bg-yellow-200",
icon: "🧪",
icon: TestTube,
},
ready: {
label: "Ready",
className: "bg-green-100 text-green-800 hover:bg-green-200",
icon: "✅",
icon: CheckCircle2,
},
deprecated: {
label: "Deprecated",
className: "bg-red-100 text-red-800 hover:bg-red-200",
icon: "🗑️",
icon: Trash2,
},
};
@@ -98,7 +108,7 @@ function ExperimentCard({ experiment }: ExperimentCardProps) {
</div>
</div>
<Badge className={statusInfo.className} variant="secondary">
<span className="mr-1">{statusInfo.icon}</span>
<statusInfo.icon className="mr-1 h-3.5 w-3.5" />
{statusInfo.label}
</Badge>
</div>
@@ -158,10 +168,16 @@ function ExperimentCard({ experiment }: ExperimentCardProps) {
{/* Actions */}
<div className="flex gap-2 pt-2">
<Button asChild size="sm" className="flex-1">
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}`}>View Details</Link>
<Link
href={`/studies/${experiment.studyId}/experiments/${experiment.id}`}
>
View Details
</Link>
</Button>
<Button asChild size="sm" variant="outline" className="flex-1">
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/designer`}>
<Link
href={`/studies/${experiment.studyId}/experiments/${experiment.id}/designer`}
>
<Settings className="mr-1 h-3 w-3" />
Design
</Link>

View File

@@ -1,7 +1,13 @@
"use client";
import { type ColumnDef } from "@tanstack/react-table";
import { ArrowUpDown, MoreHorizontal, Edit, LayoutTemplate, Trash2 } from "lucide-react";
import {
ArrowUpDown,
MoreHorizontal,
Edit,
LayoutTemplate,
Trash2,
} from "lucide-react";
import * as React from "react";
import { formatDistanceToNow } from "date-fns";
@@ -261,10 +267,12 @@ function ExperimentActions({ experiment }: { experiment: Experiment }) {
variant="ghost"
size="icon"
asChild
className="h-8 w-8 text-muted-foreground hover:text-primary"
className="text-muted-foreground hover:text-primary h-8 w-8"
title="Open Designer"
>
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/designer`}>
<Link
href={`/studies/${experiment.studyId}/experiments/${experiment.id}/designer`}
>
<LayoutTemplate className="h-4 w-4" />
<span className="sr-only">Design</span>
</Link>
@@ -278,7 +286,7 @@ function ExperimentActions({ experiment }: { experiment: Experiment }) {
deleteMutation.mutate({ id: experiment.id });
}
}}
className="h-8 w-8 text-muted-foreground hover:text-destructive"
className="text-muted-foreground hover:text-destructive h-8 w-8"
title="Delete Experiment"
>
<Trash2 className="h-4 w-4" />

View File

@@ -1,7 +1,10 @@
"use client";
import { useState, useEffect } from "react";
import type { ActionDefinition, ExperimentAction } from "~/lib/experiment-designer/types";
import type {
ActionDefinition,
ExperimentAction,
} from "~/lib/experiment-designer/types";
import corePluginDef from "~/plugins/definitions/hristudio-core.json";
import wozPluginDef from "~/plugins/definitions/hristudio-woz.json";
@@ -56,7 +59,9 @@ export class ActionRegistry {
this.registerPluginDefinition(corePluginDef);
this.registerPluginDefinition(wozPluginDef);
console.log(`[ActionRegistry] Loaded system plugins: ${this.SYSTEM_PLUGIN_IDS.join(", ")}`);
console.log(
`[ActionRegistry] Loaded system plugins: ${this.SYSTEM_PLUGIN_IDS.join(", ")}`,
);
this.coreActionsLoaded = true;
this.notifyListeners();
@@ -64,10 +69,7 @@ export class ActionRegistry {
/* ---------------- Plugin Actions ---------------- */
loadPluginActions(
studyId: string,
studyPlugins: any[],
): void {
loadPluginActions(studyId: string, studyPlugins: any[]): void {
if (this.pluginActionsLoaded && this.loadedStudyId === studyId) return;
if (this.loadedStudyId !== studyId) {
@@ -78,7 +80,7 @@ export class ActionRegistry {
(studyPlugins ?? []).forEach((plugin) => {
this.registerPluginDefinition(plugin);
totalActionsLoaded += (plugin.actionDefinitions?.length || 0);
totalActionsLoaded += plugin.actionDefinitions?.length || 0;
});
console.log(
@@ -114,41 +116,41 @@ export class ActionRegistry {
// Default category based on plugin type or explicit category
let category = categoryMap[rawCategory];
if (!category) {
if (plugin.id === 'hristudio-woz') category = 'wizard';
else if (plugin.id === 'hristudio-core') category = 'control';
else category = 'robot';
if (plugin.id === "hristudio-woz") category = "wizard";
else if (plugin.id === "hristudio-core") category = "control";
else category = "robot";
}
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,
},
}
: action.rest
? {
transport: "rest" as const,
transport: "ros2" as const,
timeoutMs: action.timeout,
retryable: action.retryable,
rest: {
method: action.rest.method,
path: action.rest.path,
headers: action.rest.headers,
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: "internal" as const,
timeoutMs: action.timeout,
retryable: action.retryable,
};
transport: "internal" as const,
timeoutMs: action.timeout,
retryable: action.retryable,
};
// Extract semantic ID from metadata if available, otherwise fall back to database IDs
// Priority: metadata.robotId > metadata.id (for system plugins) > robotId > id
@@ -184,7 +186,7 @@ export class ActionRegistry {
},
execution,
parameterSchemaRaw: action.parameterSchema ?? undefined,
nestable: action.nestable
nestable: action.nestable,
};
// Prevent overwriting if it already exists (first-come-first-served, usually core first)
@@ -193,7 +195,9 @@ export class ActionRegistry {
}
// Register aliases
const aliases = Array.isArray(action.aliases) ? action.aliases : undefined;
const aliases = Array.isArray(action.aliases)
? action.aliases
: undefined;
if (aliases) {
for (const alias of aliases) {
if (typeof alias === "string" && alias.trim()) {
@@ -224,7 +228,8 @@ export class ActionRegistry {
if (!schema?.properties) return [];
return Object.entries(schema.properties).map(([key, paramDef]) => {
let type: "text" | "number" | "select" | "boolean" | "json" | "array" = "text";
let type: "text" | "number" | "select" | "boolean" | "json" | "array" =
"text";
if (paramDef.type === "number") {
type = "number";
@@ -259,7 +264,10 @@ export class ActionRegistry {
// Robust Reset: Remove valid plugin actions, BUT protect system plugins.
const idsToDelete: string[] = [];
this.actions.forEach((action, id) => {
if (action.source.kind === "plugin" && !this.SYSTEM_PLUGIN_IDS.includes(action.source.pluginId || "")) {
if (
action.source.kind === "plugin" &&
!this.SYSTEM_PLUGIN_IDS.includes(action.source.pluginId || "")
) {
idsToDelete.push(id);
}
});

View File

@@ -18,7 +18,7 @@ import {
PanelRightOpen,
Maximize2,
Minimize2,
Settings
Settings,
} from "lucide-react";
import { cn } from "~/lib/utils";
@@ -134,31 +134,43 @@ interface RawExperiment {
/* -------------------------------------------------------------------------- */
function adaptExistingDesign(exp: RawExperiment): ExperimentDesign | undefined {
console.log('[adaptExistingDesign] Entry - exp.steps:', exp.steps);
console.log("[adaptExistingDesign] Entry - exp.steps:", exp.steps);
// 1. Prefer database steps (Source of Truth) if valid, to ensure we have the latest
// plugin provenance data (which might be missing from stale visualDesign snapshots).
// 1. Prefer database steps (Source of Truth) if valid.
if (Array.isArray(exp.steps) && exp.steps.length > 0) {
console.log('[adaptExistingDesign] Has steps array, length:', exp.steps.length);
console.log(
"[adaptExistingDesign] Has steps array, length:",
exp.steps.length,
);
try {
// Check if steps are already converted (have trigger property) to avoid double-conversion data loss
const firstStep = exp.steps[0] as any;
let dbSteps: ExperimentStep[];
if (firstStep && typeof firstStep === 'object' && 'trigger' in firstStep) {
if (
firstStep &&
typeof firstStep === "object" &&
"trigger" in firstStep
) {
// Already converted by server
dbSteps = exp.steps as ExperimentStep[];
} else {
// Raw DB steps, need conversion
console.log('[adaptExistingDesign] Taking raw DB conversion path');
console.log("[adaptExistingDesign] Taking raw DB conversion path");
dbSteps = convertDatabaseToSteps(exp.steps);
// DEBUG: Check children after conversion
dbSteps.forEach((step) => {
step.actions.forEach((action) => {
if (["sequence", "parallel", "loop", "branch"].includes(action.type)) {
console.log(`[adaptExistingDesign] Post-conversion ${action.type} (${action.name}) children:`, action.children);
if (
["sequence", "parallel", "loop", "branch"].includes(action.type)
) {
console.log(
`[adaptExistingDesign] Post-conversion ${action.type} (${action.name}) children:`,
action.children,
);
}
});
});
@@ -173,7 +185,10 @@ function adaptExistingDesign(exp: RawExperiment): ExperimentDesign | undefined {
lastSaved: new Date(),
};
} catch (err) {
console.warn('[DesignerRoot] Failed to convert/hydrate steps, falling back to visualDesign:', err);
console.warn(
"[DesignerRoot] Failed to convert/hydrate steps, falling back to visualDesign:",
err,
);
}
}
@@ -250,7 +265,7 @@ export function DesignerRoot({
refetchOnWindowFocus: true,
staleTime: 0,
gcTime: 0, // Garbage collect immediately
}
},
);
const updateExperiment = api.experiments.update.useMutation({
@@ -381,18 +396,23 @@ export function DesignerRoot({
} | null>(null);
const [activeSortableItem, setActiveSortableItem] = useState<{
type: 'step' | 'action';
type: "step" | "action";
data: any;
} | null>(null);
/* ----------------------------- Initialization ---------------------------- */
useEffect(() => {
console.log('[DesignerRoot] useEffect triggered', { initialized, loadingExperiment, hasExperiment: !!experiment, hasInitialDesign: !!initialDesign });
console.log("[DesignerRoot] useEffect triggered", {
initialized,
loadingExperiment,
hasExperiment: !!experiment,
hasInitialDesign: !!initialDesign,
});
if (initialized) return;
if (loadingExperiment && !initialDesign) return;
console.log('[DesignerRoot] Proceeding with initialization');
console.log("[DesignerRoot] Proceeding with initialization");
const adapted =
initialDesign ??
@@ -486,7 +506,6 @@ export function DesignerRoot({
return () => clearTimeout(timeoutId);
}, [steps, initialized, recomputeHash]);
/* ----------------------------- Derived State ----------------------------- */
const hasUnsavedChanges =
!!currentDesignHash && lastPersistedHash !== currentDesignHash;
@@ -539,20 +558,30 @@ export function DesignerRoot({
// Debug: Improved structured logging for validation results
console.group("🧪 Experiment Validation Results");
if (result.valid) {
console.log(`%c✓ VALID (0 errors, ${result.warningCount} warnings, ${result.infoCount} hints)`, "color: green; font-weight: bold; font-size: 12px;");
console.log(
`%c✓ VALID (0 errors, ${result.warningCount} warnings, ${result.infoCount} hints)`,
"color: green; font-weight: bold; font-size: 12px;",
);
} else {
console.log(`%c✗ INVALID (${result.errorCount} errors, ${result.warningCount} warnings)`, "color: red; font-weight: bold; font-size: 12px;");
console.log(
`%c✗ INVALID (${result.errorCount} errors, ${result.warningCount} warnings)`,
"color: red; font-weight: bold; font-size: 12px;",
);
}
if (result.issues.length > 0) {
console.table(
result.issues.map(i => ({
result.issues.map((i) => ({
Severity: i.severity.toUpperCase(),
Category: i.category,
Message: i.message,
Suggest: i.suggestion,
Location: i.actionId ? `Action ${i.actionId}` : (i.stepId ? `Step ${i.stepId}` : 'Global')
}))
Location: i.actionId
? `Action ${i.actionId}`
: i.stepId
? `Step ${i.stepId}`
: "Global",
})),
);
} else {
console.log("No issues found. Design is perfectly compliant.");
@@ -583,7 +612,8 @@ export function DesignerRoot({
}
} catch (err) {
toast.error(
`Validation error: ${err instanceof Error ? err.message : "Unknown error"
`Validation error: ${
err instanceof Error ? err.message : "Unknown error"
}`,
);
} finally {
@@ -610,7 +640,7 @@ export function DesignerRoot({
const persist = useCallback(async () => {
if (!initialized) return;
console.log('[DesignerRoot] 💾 SAVE initiated', {
console.log("[DesignerRoot] 💾 SAVE initiated", {
stepsCount: steps.length,
actionsCount: steps.reduce((sum, s) => sum + s.actions.length, 0),
currentHash: currentDesignHash?.slice(0, 16),
@@ -625,7 +655,7 @@ export function DesignerRoot({
lastSaved: new Date().toISOString(),
};
console.log('[DesignerRoot] 💾 Sending to server...', {
console.log("[DesignerRoot] 💾 Sending to server...", {
experimentId,
stepsCount: steps.length,
version: designMeta.version,
@@ -639,7 +669,7 @@ export function DesignerRoot({
compileExecution: autoCompile,
});
console.log('[DesignerRoot] 💾 Server save successful');
console.log("[DesignerRoot] 💾 Server save successful");
// NOTE: We do NOT refetch here because it would reset the local steps state
// to the server state, which would cause the hash to match the persisted hash,
@@ -649,7 +679,7 @@ export function DesignerRoot({
// Recompute hash and update persisted hash
const hashResult = await recomputeHash();
if (hashResult?.designHash) {
console.log('[DesignerRoot] 💾 Updated persisted hash:', {
console.log("[DesignerRoot] 💾 Updated persisted hash:", {
newPersistedHash: hashResult.designHash.slice(0, 16),
fullHash: hashResult.designHash,
});
@@ -662,7 +692,7 @@ export function DesignerRoot({
// Auto-validate after save to clear "Modified" (drift) status
void validateDesign();
console.log('[DesignerRoot] 💾 SAVE complete');
console.log("[DesignerRoot] 💾 SAVE complete");
onPersist?.({
id: experimentId,
@@ -673,7 +703,7 @@ export function DesignerRoot({
lastSaved: new Date(),
});
} catch (error) {
console.error('[DesignerRoot] 💾 SAVE failed:', error);
console.error("[DesignerRoot] 💾 SAVE failed:", error);
// Error already handled by mutation onError
} finally {
setIsSaving(false);
@@ -729,7 +759,8 @@ export function DesignerRoot({
toast.success("Exported design bundle");
} catch (err) {
toast.error(
`Export failed: ${err instanceof Error ? err.message : "Unknown error"
`Export failed: ${
err instanceof Error ? err.message : "Unknown error"
}`,
);
} finally {
@@ -801,10 +832,7 @@ export function DesignerRoot({
console.log("[DesignerRoot] DragStart", { activeId, activeData });
if (
activeId.startsWith("action-") &&
activeData?.action
) {
if (activeId.startsWith("action-") && activeData?.action) {
const a = activeData.action as {
id: string;
name: string;
@@ -822,14 +850,17 @@ export function DesignerRoot({
} else if (activeId.startsWith("s-step-")) {
console.log("[DesignerRoot] Setting active sortable STEP", activeData);
setActiveSortableItem({
type: 'step',
data: activeData
type: "step",
data: activeData,
});
} else if (activeId.startsWith("s-act-")) {
console.log("[DesignerRoot] Setting active sortable ACTION", activeData);
console.log(
"[DesignerRoot] Setting active sortable ACTION",
activeData,
);
setActiveSortableItem({
type: 'action',
data: activeData
type: "action",
data: activeData,
});
}
},
@@ -856,8 +887,6 @@ export function DesignerRoot({
return;
}
const overId = over.id.toString();
const activeDef = active.data.current?.action;
@@ -892,10 +921,10 @@ export function DesignerRoot({
// 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);
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.
// 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;
@@ -907,7 +936,6 @@ export function DesignerRoot({
: 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;
@@ -969,13 +997,19 @@ export function DesignerRoot({
if (activeId.startsWith("s-step-")) {
const overId = over.id.toString();
// Allow reordering over both sortable steps (s-step-) and drop zones (step-)
if (!overId.startsWith("s-step-") && !overId.startsWith("step-")) return;
if (!overId.startsWith("s-step-") && !overId.startsWith("step-"))
return;
// Strip prefixes to get raw IDs
const rawActiveId = activeId.replace(/^s-step-/, "");
const rawOverId = overId.replace(/^s-step-/, "").replace(/^step-/, "");
console.log("[DesignerRoot] DragEnd - Step Sort", { activeId, overId, rawActiveId, rawOverId });
console.log("[DesignerRoot] DragEnd - Step Sort", {
activeId,
overId,
rawActiveId,
rawOverId,
});
const oldIndex = steps.findIndex((s) => s.id === rawActiveId);
const newIndex = steps.findIndex((s) => s.id === rawOverId);
@@ -1020,7 +1054,10 @@ export function DesignerRoot({
if (!targetStep) return;
// 2. Instantiate Action
if (active.id.toString().startsWith("action-") && active.data.current?.action) {
if (
active.id.toString().startsWith("action-") &&
active.data.current?.action
) {
const actionDef = active.data.current.action as {
id: string; // type
type: string;
@@ -1044,13 +1081,13 @@ export function DesignerRoot({
const execution: ExperimentAction["execution"] =
actionDef.execution &&
(actionDef.execution.transport === "internal" ||
actionDef.execution.transport === "rest" ||
actionDef.execution.transport === "ros2")
(actionDef.execution.transport === "internal" ||
actionDef.execution.transport === "rest" ||
actionDef.execution.transport === "ros2")
? {
transport: actionDef.execution.transport,
retryable: actionDef.execution.retryable ?? false,
}
transport: actionDef.execution.transport,
retryable: actionDef.execution.retryable ?? false,
}
: undefined;
const newId = `action-${Date.now().toString(36)}-${Math.random().toString(36).substr(2, 9)}`;
@@ -1061,12 +1098,14 @@ export function DesignerRoot({
category: actionDef.category as any,
description: "",
parameters: defaultParams,
source: actionDef.source ? {
kind: actionDef.source.kind as any,
pluginId: actionDef.source.pluginId,
pluginVersion: actionDef.source.pluginVersion,
baseActionId: actionDef.id
} : { kind: "core" },
source: actionDef.source
? {
kind: actionDef.source.kind as any,
pluginId: actionDef.source.pluginId,
pluginVersion: actionDef.source.pluginVersion,
baseActionId: actionDef.id,
}
: { kind: "core" },
execution,
children: [],
};
@@ -1080,13 +1119,25 @@ export function DesignerRoot({
void recomputeHash();
}
},
[steps, upsertAction, selectAction, recomputeHash, toggleLibraryScrollLock, reorderStep],
[
steps,
upsertAction,
selectAction,
recomputeHash,
toggleLibraryScrollLock,
reorderStep,
],
);
// validation status badges removed (unused)
/* ------------------------------- Panels ---------------------------------- */
const leftPanel = useMemo(
() => (
<div id="tour-designer-blocks" ref={libraryRootRef} data-library-root className="h-full">
<div
id="tour-designer-blocks"
ref={libraryRootRef}
data-library-root
className="h-full"
>
<ActionLibraryPanel />
</div>
),
@@ -1167,10 +1218,10 @@ export function DesignerRoot({
);
return (
<div className="relative flex h-[calc(100vh-5rem)] w-full flex-col overflow-hidden bg-background">
<div className="bg-background relative flex h-[calc(100vh-5rem)] w-full flex-col overflow-hidden">
{/* Subtle Background Gradients */}
<div className="absolute top-0 left-1/2 -z-10 h-[400px] w-[800px] -translate-x-1/2 rounded-full bg-primary/10 blur-3xl opacity-20 dark:opacity-10" />
<div className="absolute bottom-0 right-0 -z-10 h-[250px] w-[250px] rounded-full bg-violet-500/5 blur-3xl" />
<div className="bg-primary/10 absolute top-0 left-1/2 -z-10 h-[400px] w-[800px] -translate-x-1/2 rounded-full opacity-20 blur-3xl dark:opacity-10" />
<div className="absolute right-0 bottom-0 -z-10 h-[250px] w-[250px] rounded-full bg-violet-500/5 blur-3xl" />
<PageHeader
title={designMeta.name}
description={designMeta.description || "No description"}
@@ -1181,7 +1232,7 @@ export function DesignerRoot({
{/* Main Grid Container - 2-4-2 Split */}
{/* Main Grid Container - 2-4-2 Split */}
<div className="flex-1 min-h-0 w-full px-2 overflow-hidden">
<div className="min-h-0 w-full flex-1 overflow-hidden px-2">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
@@ -1190,14 +1241,16 @@ export function DesignerRoot({
onDragEnd={handleDragEnd}
onDragCancel={() => toggleLibraryScrollLock(false)}
>
<div className="grid grid-cols-8 gap-4 h-full w-full transition-all duration-300 ease-in-out">
<div className="grid h-full w-full grid-cols-8 gap-4 transition-all duration-300 ease-in-out">
{/* Left Panel (Library) */}
{!leftCollapsed && (
<div className={cn(
"flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm",
rightCollapsed ? "col-span-3" : "col-span-2"
)}>
<div className="flex items-center justify-between border-b px-3 py-2 bg-muted/30">
<div
className={cn(
"bg-background flex flex-col overflow-hidden rounded-lg border shadow-sm",
rightCollapsed ? "col-span-3" : "col-span-2",
)}
>
<div className="bg-muted/30 flex items-center justify-between border-b px-3 py-2">
<span className="text-sm font-medium">Action Library</span>
<Button
variant="ghost"
@@ -1208,26 +1261,31 @@ export function DesignerRoot({
<PanelLeftClose className="h-4 w-4" />
</Button>
</div>
<div className="flex-1 overflow-hidden min-h-0 bg-muted/10">
<div className="bg-muted/10 min-h-0 flex-1 overflow-hidden">
{leftPanel}
</div>
</div>
)}
{/* Center Panel (Workspace) */}
<div className={cn(
"flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm",
leftCollapsed && rightCollapsed ? "col-span-8" :
leftCollapsed ? "col-span-6" :
rightCollapsed ? "col-span-5" :
"col-span-4"
)}>
<div className="flex items-center justify-between border-b px-3 py-2 bg-muted/30">
<div
className={cn(
"bg-background flex flex-col overflow-hidden rounded-lg border shadow-sm",
leftCollapsed && rightCollapsed
? "col-span-8"
: leftCollapsed
? "col-span-6"
: rightCollapsed
? "col-span-5"
: "col-span-4",
)}
>
<div className="bg-muted/30 flex items-center justify-between border-b px-3 py-2">
{leftCollapsed && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 mr-2"
className="mr-2 h-6 w-6"
onClick={() => setLeftCollapsed(false)}
title="Open Library"
>
@@ -1237,14 +1295,19 @@ export function DesignerRoot({
<span className="text-sm font-medium">Flow Workspace</span>
{rightCollapsed && (
<div className="flex items-center">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => startTour('designer')}>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => startTour("designer")}
>
<HelpCircle className="h-4 w-4" />
</Button>
{rightCollapsed && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 ml-2"
className="ml-2 h-6 w-6"
onClick={() => setRightCollapsed(false)}
title="Open Inspector"
>
@@ -1254,7 +1317,7 @@ export function DesignerRoot({
</div>
)}
</div>
<div className="flex-1 overflow-hidden min-h-0 relative">
<div className="relative min-h-0 flex-1 overflow-hidden">
{centerPanel}
</div>
<div className="border-t">
@@ -1273,11 +1336,13 @@ export function DesignerRoot({
{/* Right Panel (Inspector) */}
{!rightCollapsed && (
<div className={cn(
"flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm",
leftCollapsed ? "col-span-2" : "col-span-2"
)}>
<div className="flex items-center justify-between border-b px-3 py-2 bg-muted/30">
<div
className={cn(
"bg-background flex flex-col overflow-hidden rounded-lg border shadow-sm",
leftCollapsed ? "col-span-2" : "col-span-2",
)}
>
<div className="bg-muted/30 flex items-center justify-between border-b px-3 py-2">
<span className="text-sm font-medium">Inspector</span>
<Button
variant="ghost"
@@ -1288,7 +1353,7 @@ export function DesignerRoot({
<PanelRightClose className="h-4 w-4" />
</Button>
</div>
<div className="flex-1 overflow-hidden min-h-0 bg-muted/10">
<div className="bg-muted/10 min-h-0 flex-1 overflow-hidden">
{rightPanel}
</div>
</div>
@@ -1298,35 +1363,38 @@ export function DesignerRoot({
<DragOverlay dropAnimation={null}>
{dragOverlayAction ? (
// Library Item Drag
<div className="bg-background pointer-events-none flex items-center gap-2 rounded border px-3 py-2 text-xs font-medium shadow-lg select-none ring-2 ring-blue-500/20">
<div className="bg-background pointer-events-none flex items-center gap-2 rounded border px-3 py-2 text-xs font-medium shadow-lg ring-2 ring-blue-500/20 select-none">
<div
className={cn(
"flex h-4 w-4 items-center justify-center rounded text-white",
dragOverlayAction.category === "robot" && "bg-emerald-600",
dragOverlayAction.category === "control" && "bg-amber-500",
dragOverlayAction.category === "observation" &&
"bg-purple-600",
"bg-purple-600",
)}
/>
{dragOverlayAction.name}
</div>
) : activeSortableItem?.type === 'action' ? (
) : activeSortableItem?.type === "action" ? (
// Existing Action Sort
<div className="w-[300px] opacity-90 pointer-events-none">
<div className="pointer-events-none w-[300px] opacity-90">
<SortableActionChip
stepId={activeSortableItem.data.stepId}
action={activeSortableItem.data.action}
parentId={activeSortableItem.data.parentId}
selectedActionId={selectedActionId}
onSelectAction={() => { }}
onDeleteAction={() => { }}
onSelectAction={() => {}}
onDeleteAction={() => {}}
dragHandle={true}
/>
</div>
) : activeSortableItem?.type === 'step' ? (
) : activeSortableItem?.type === "step" ? (
// Existing Step Sort
<div className="w-[400px] pointer-events-none opacity-90">
<StepCardPreview step={activeSortableItem.data.step} dragHandle />
<div className="pointer-events-none w-[400px] opacity-90">
<StepCardPreview
step={activeSortableItem.data.step}
dragHandle
/>
</div>
) : null}
</DragOverlay>

View File

@@ -173,8 +173,8 @@ export function PropertiesPanelBase({
let def = registry.getAction(selectedAction.type);
// Fallback: If action not found in registry, try without plugin prefix
if (!def && selectedAction.type.includes('.')) {
const baseType = selectedAction.type.split('.').pop();
if (!def && selectedAction.type.includes(".")) {
const baseType = selectedAction.type.split(".").pop();
if (baseType) {
def = registry.getAction(baseType);
}
@@ -187,9 +187,9 @@ export function PropertiesPanelBase({
type: selectedAction.type,
name: selectedAction.name,
description: `Action type: ${selectedAction.type}`,
category: selectedAction.category || 'control',
icon: 'Zap',
color: '#6366f1',
category: selectedAction.category || "control",
icon: "Zap",
color: "#6366f1",
parameters: [],
source: selectedAction.source,
};
@@ -225,12 +225,15 @@ export function PropertiesPanelBase({
const ResolvedIcon: React.ComponentType<{ className?: string }> =
def?.icon && iconComponents[def.icon]
? (iconComponents[def.icon] as React.ComponentType<{
className?: string;
}>)
className?: string;
}>)
: Zap;
return (
<div className={cn("w-full min-w-0 space-y-3 px-3", className)} id="tour-designer-properties">
<div
className={cn("w-full min-w-0 space-y-3 px-3", className)}
id="tour-designer-properties"
>
{/* Header / Metadata */}
<div className="border-b pb-3">
<div className="mb-2 flex items-center gap-2">
@@ -305,17 +308,23 @@ export function PropertiesPanelBase({
{/* Branching Configuration (Special Case) */}
{selectedAction.type === "branch" ? (
<div className="space-y-3">
<div className="text-muted-foreground text-[10px] tracking-wide uppercase flex justify-between items-center">
<div className="text-muted-foreground flex items-center justify-between text-[10px] tracking-wide uppercase">
<span>Branch Options</span>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0"
onClick={() => {
const currentOptions = ((containingStep.trigger.conditions as any).options as any[]) || [];
const currentOptions =
((containingStep.trigger.conditions as any)
.options as any[]) || [];
const newOptions = [
...currentOptions,
{ label: "New Option", nextStepId: design.steps[containingStep.order + 1]?.id, variant: "default" }
{
label: "New Option",
nextStepId: design.steps[containingStep.order + 1]?.id,
variant: "default",
},
];
// Sync to Step Trigger (Source of Truth)
@@ -324,16 +333,16 @@ export function PropertiesPanelBase({
...containingStep.trigger,
conditions: {
...containingStep.trigger.conditions,
options: newOptions
}
}
options: newOptions,
},
},
});
// Sync to Action Params (for consistency)
onActionUpdate(containingStep.id, selectedAction.id, {
parameters: {
...selectedAction.parameters,
options: newOptions
}
options: newOptions,
},
});
}}
>
@@ -342,26 +351,43 @@ export function PropertiesPanelBase({
</div>
<div className="space-y-3">
{(((containingStep.trigger.conditions as any).options as any[]) || []).map((opt: any, idx: number) => (
<div key={idx} className="space-y-2 p-2 rounded border bg-muted/50">
{(
((containingStep.trigger.conditions as any).options as any[]) ||
[]
).map((opt: any, idx: number) => (
<div
key={idx}
className="bg-muted/50 space-y-2 rounded border p-2"
>
<div className="grid grid-cols-5 gap-2">
<div className="col-span-3">
<Label className="text-[10px]">Label</Label>
<Input
value={opt.label}
onChange={(e) => {
const currentOptions = ((containingStep.trigger.conditions as any).options as any[]) || [];
const currentOptions =
((containingStep.trigger.conditions as any)
.options as any[]) || [];
const newOpts = [...currentOptions];
newOpts[idx] = { ...newOpts[idx], label: e.target.value };
newOpts[idx] = {
...newOpts[idx],
label: e.target.value,
};
onStepUpdate(containingStep.id, {
trigger: {
...containingStep.trigger,
conditions: { ...containingStep.trigger.conditions, options: newOpts }
}
conditions: {
...containingStep.trigger.conditions,
options: newOpts,
},
},
});
onActionUpdate(containingStep.id, selectedAction.id, {
parameters: { ...selectedAction.parameters, options: newOpts }
parameters: {
...selectedAction.parameters,
options: newOpts,
},
});
}}
className="h-7 text-xs"
@@ -370,34 +396,53 @@ export function PropertiesPanelBase({
<div className="col-span-2">
<Label className="text-[10px]">Target Step</Label>
{design.steps.length <= 1 ? (
<div className="h-7 flex items-center text-[10px] text-muted-foreground border rounded px-2 bg-muted/50 truncate" title="Add more steps to link">
<div
className="text-muted-foreground bg-muted/50 flex h-7 items-center truncate rounded border px-2 text-[10px]"
title="Add more steps to link"
>
No linkable steps
</div>
) : (
<Select
value={opt.nextStepId ?? ""}
onValueChange={(val) => {
const currentOptions = ((containingStep.trigger.conditions as any).options as any[]) || [];
const currentOptions =
((containingStep.trigger.conditions as any)
.options as any[]) || [];
const newOpts = [...currentOptions];
newOpts[idx] = { ...newOpts[idx], nextStepId: val };
onStepUpdate(containingStep.id, {
trigger: {
...containingStep.trigger,
conditions: { ...containingStep.trigger.conditions, options: newOpts }
}
});
onActionUpdate(containingStep.id, selectedAction.id, {
parameters: { ...selectedAction.parameters, options: newOpts }
conditions: {
...containingStep.trigger.conditions,
options: newOpts,
},
},
});
onActionUpdate(
containingStep.id,
selectedAction.id,
{
parameters: {
...selectedAction.parameters,
options: newOpts,
},
},
);
}}
>
<SelectTrigger className="h-7 text-xs w-full">
<SelectTrigger className="h-7 w-full text-xs">
<SelectValue placeholder="Select..." />
</SelectTrigger>
<SelectContent className="min-w-[180px]">
{design.steps.map((s) => (
<SelectItem key={s.id} value={s.id} disabled={s.id === containingStep.id}>
<SelectItem
key={s.id}
value={s.id}
disabled={s.id === containingStep.id}
>
{s.order + 1}. {s.name}
</SelectItem>
))}
@@ -410,18 +455,26 @@ export function PropertiesPanelBase({
<Select
value={opt.variant || "default"}
onValueChange={(val) => {
const currentOptions = ((containingStep.trigger.conditions as any).options as any[]) || [];
const currentOptions =
((containingStep.trigger.conditions as any)
.options as any[]) || [];
const newOpts = [...currentOptions];
newOpts[idx] = { ...newOpts[idx], variant: val };
onStepUpdate(containingStep.id, {
trigger: {
...containingStep.trigger,
conditions: { ...containingStep.trigger.conditions, options: newOpts }
}
conditions: {
...containingStep.trigger.conditions,
options: newOpts,
},
},
});
onActionUpdate(containingStep.id, selectedAction.id, {
parameters: { ...selectedAction.parameters, options: newOpts }
parameters: {
...selectedAction.parameters,
options: newOpts,
},
});
}}
>
@@ -430,7 +483,9 @@ export function PropertiesPanelBase({
</SelectTrigger>
<SelectContent>
<SelectItem value="default">Default (Next)</SelectItem>
<SelectItem value="destructive">Destructive (Red)</SelectItem>
<SelectItem value="destructive">
Destructive (Red)
</SelectItem>
<SelectItem value="outline">Outline</SelectItem>
</SelectContent>
</Select>
@@ -438,20 +493,28 @@ export function PropertiesPanelBase({
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-muted-foreground hover:text-red-500"
className="text-muted-foreground h-6 w-6 p-0 hover:text-red-500"
onClick={() => {
const currentOptions = ((containingStep.trigger.conditions as any).options as any[]) || [];
const currentOptions =
((containingStep.trigger.conditions as any)
.options as any[]) || [];
const newOpts = [...currentOptions];
newOpts.splice(idx, 1);
onStepUpdate(containingStep.id, {
trigger: {
...containingStep.trigger,
conditions: { ...containingStep.trigger.conditions, options: newOpts }
}
conditions: {
...containingStep.trigger.conditions,
options: newOpts,
},
},
});
onActionUpdate(containingStep.id, selectedAction.id, {
parameters: { ...selectedAction.parameters, options: newOpts }
parameters: {
...selectedAction.parameters,
options: newOpts,
},
});
}}
>
@@ -460,9 +523,12 @@ export function PropertiesPanelBase({
</div>
</div>
))}
{(!(((containingStep.trigger.conditions as any).options as any[])?.length)) && (
<div className="text-center py-4 border border-dashed rounded text-xs text-muted-foreground">
No options defined.<br />Click + to add a branch.
{!((containingStep.trigger.conditions as any).options as any[])
?.length && (
<div className="text-muted-foreground rounded border border-dashed py-4 text-center text-xs">
No options defined.
<br />
Click + to add a branch.
</div>
)}
</div>
@@ -478,7 +544,7 @@ export function PropertiesPanelBase({
{/* Iterations */}
<div>
<Label className="text-xs">Iterations</Label>
<div className="flex items-center gap-2 mt-1">
<div className="mt-1 flex items-center gap-2">
<Slider
min={1}
max={20}
@@ -493,44 +559,42 @@ export function PropertiesPanelBase({
});
}}
/>
<span className="text-xs font-mono w-8 text-right">
<span className="w-8 text-right font-mono text-xs">
{Number(selectedAction.parameters.iterations || 1)}
</span>
</div>
</div>
</div>
</div>
) : (
/* Standard Parameters */
def?.parameters.length ? (
) : /* Standard Parameters */
def?.parameters.length ? (
<div className="space-y-3">
<div className="text-muted-foreground text-[10px] tracking-wide uppercase">
Parameters
</div>
<div className="space-y-3">
<div className="text-muted-foreground text-[10px] tracking-wide uppercase">
Parameters
</div>
<div className="space-y-3">
{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]: val,
},
});
}}
onCommit={() => { }}
/>
))}
</div>
{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]: val,
},
});
}}
onCommit={() => {}}
/>
))}
</div>
) : (
<div className="text-muted-foreground text-xs">
No parameters for this action.
</div>
)
</div>
) : (
<div className="text-muted-foreground text-xs">
No parameters for this action.
</div>
)}
</div>
);
@@ -539,7 +603,10 @@ export function PropertiesPanelBase({
/* --------------------------- Step Properties View --------------------------- */
if (selectedStep) {
return (
<div className={cn("w-full min-w-0 space-y-3 px-3", className)} id="tour-designer-properties">
<div
className={cn("w-full min-w-0 space-y-3 px-3", className)}
id="tour-designer-properties"
>
<div className="border-b pb-2">
<h3 className="flex items-center gap-2 text-sm font-medium">
<div
@@ -625,7 +692,8 @@ export function PropertiesPanelBase({
</SelectContent>
</Select>
<p className="text-muted-foreground mt-1 text-[10px]">
Steps always execute sequentially. Use control flow actions for parallel/conditional logic.
Steps always execute sequentially. Use control flow actions
for parallel/conditional logic.
</p>
</div>
<div>
@@ -697,7 +765,7 @@ const ParameterEditor = React.memo(function ParameterEditor({
param,
value: rawValue,
onUpdate,
onCommit
onCommit,
}: ParameterEditorProps) {
// Local state for immediate feedback
const [localValue, setLocalValue] = useState<unknown>(rawValue);
@@ -708,19 +776,22 @@ const ParameterEditor = React.memo(function ParameterEditor({
setLocalValue(rawValue);
}, [rawValue]);
const handleUpdate = useCallback((newVal: unknown, immediate = false) => {
setLocalValue(newVal);
const handleUpdate = useCallback(
(newVal: unknown, immediate = false) => {
setLocalValue(newVal);
if (debounceRef.current) clearTimeout(debounceRef.current);
if (debounceRef.current) clearTimeout(debounceRef.current);
if (immediate) {
onUpdate(newVal);
} else {
debounceRef.current = setTimeout(() => {
if (immediate) {
onUpdate(newVal);
}, 300);
}
}, [onUpdate]);
} else {
debounceRef.current = setTimeout(() => {
onUpdate(newVal);
}, 300);
}
},
[onUpdate],
);
const handleCommit = useCallback(() => {
if (localValue !== rawValue) {
@@ -772,13 +843,22 @@ const ParameterEditor = React.memo(function ParameterEditor({
</div>
);
} else if (param.type === "number") {
const numericVal = typeof localValue === "number" ? localValue : (param.min ?? 0);
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 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)));
const step =
param.step ??
(range <= 5
? 0.1
: range <= 50
? 0.5
: Math.max(1, Math.round(range / 100)));
control = (
<div className="mt-1">
@@ -792,7 +872,9 @@ const ParameterEditor = React.memo(function ParameterEditor({
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()}
{step < 1
? Number(numericVal).toFixed(2)
: Number(numericVal).toString()}
</span>
</div>
<div className="text-muted-foreground mt-1 flex justify-between text-[10px]">

View File

@@ -2,52 +2,52 @@
import { SettingsTab } from "./tabs/SettingsTab";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
interface SettingsModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
experiment: {
id: string;
name: string;
description: string | null;
status: string;
studyId: string;
createdAt: Date;
updatedAt: Date;
study: {
id: string;
name: string;
};
};
designStats?: {
stepCount: number;
actionCount: number;
open: boolean;
onOpenChange: (open: boolean) => void;
experiment: {
id: string;
name: string;
description: string | null;
status: string;
studyId: string;
createdAt: Date;
updatedAt: Date;
study: {
id: string;
name: string;
};
};
designStats?: {
stepCount: number;
actionCount: number;
};
}
export function SettingsModal({
open,
onOpenChange,
experiment,
designStats,
open,
onOpenChange,
experiment,
designStats,
}: SettingsModalProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[90vh] p-0">
<DialogHeader className="sr-only">
<DialogTitle>Experiment Settings</DialogTitle>
<DialogDescription>
Configure experiment metadata and status
</DialogDescription>
</DialogHeader>
<SettingsTab experiment={experiment} designStats={designStats} />
</DialogContent>
</Dialog>
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] max-w-4xl p-0">
<DialogHeader className="sr-only">
<DialogTitle>Experiment Settings</DialogTitle>
<DialogDescription>
Configure experiment metadata and status
</DialogDescription>
</DialogHeader>
<SettingsTab experiment={experiment} designStats={designStats} />
</DialogContent>
</Dialog>
);
}

View File

@@ -106,8 +106,6 @@ function flattenIssues(issuesMap: Record<string, ValidationIssue[]>) {
return flattened;
}
/* -------------------------------------------------------------------------- */
/* Issue Item Component */
/* -------------------------------------------------------------------------- */
@@ -145,7 +143,7 @@ function IssueItem({
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<p className="text-[12px] leading-snug break-words whitespace-normal text-foreground">
<p className="text-foreground text-[12px] leading-snug break-words whitespace-normal">
{issue.message}
</p>
@@ -248,8 +246,6 @@ export function ValidationPanel({
console.log("[ValidationPanel] issues", issues, { flatIssues, counts });
}, [issues, flatIssues, counts]);
return (
<div
className={cn(
@@ -289,7 +285,7 @@ export function ValidationPanel({
className={cn(
"h-7 justify-start gap-1 text-[11px]",
severityFilter === "error" &&
"bg-red-600 text-white hover:opacity-90",
"bg-red-600 text-white hover:opacity-90",
)}
onClick={() => setSeverityFilter("error")}
aria-pressed={severityFilter === "error"}
@@ -305,7 +301,7 @@ export function ValidationPanel({
className={cn(
"h-7 justify-start gap-1 text-[11px]",
severityFilter === "warning" &&
"bg-amber-500 text-white hover:opacity-90",
"bg-amber-500 text-white hover:opacity-90",
)}
onClick={() => setSeverityFilter("warning")}
aria-pressed={severityFilter === "warning"}
@@ -321,7 +317,7 @@ export function ValidationPanel({
className={cn(
"h-7 justify-start gap-1 text-[11px]",
severityFilter === "info" &&
"bg-blue-600 text-white hover:opacity-90",
"bg-blue-600 text-white hover:opacity-90",
)}
onClick={() => setSeverityFilter("info")}
aria-pressed={severityFilter === "info"}

View File

@@ -5,16 +5,16 @@ import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useDroppable } from "@dnd-kit/core";
import {
ChevronRight,
Trash2,
Clock,
GitBranch,
Repeat,
Layers,
List,
AlertCircle,
Play,
HelpCircle
ChevronRight,
Trash2,
Clock,
GitBranch,
Repeat,
Layers,
List,
AlertCircle,
Play,
HelpCircle,
} from "lucide-react";
import { cn } from "~/lib/utils";
import { type ExperimentAction } from "~/lib/experiment-designer/types";
@@ -24,480 +24,530 @@ import { Badge } from "~/components/ui/badge";
import { useDesignerStore } from "../state/store";
export interface ActionChipProps {
stepId: string;
action: ExperimentAction;
parentId: string | null;
selectedActionId: string | null | undefined;
onSelectAction: (stepId: string, actionId: string | undefined) => void;
onDeleteAction: (stepId: string, actionId: string) => void;
onReorderAction?: (stepId: string, actionId: string, direction: 'up' | 'down') => void;
dragHandle?: boolean;
isFirst?: boolean;
isLast?: boolean;
stepId: string;
action: ExperimentAction;
parentId: string | null;
selectedActionId: string | null | undefined;
onSelectAction: (stepId: string, actionId: string | undefined) => void;
onDeleteAction: (stepId: string, actionId: string) => void;
onReorderAction?: (
stepId: string,
actionId: string,
direction: "up" | "down",
) => void;
dragHandle?: boolean;
isFirst?: boolean;
isLast?: boolean;
}
export interface ActionChipVisualsProps {
action: ExperimentAction;
isSelected?: boolean;
isDragging?: boolean;
isOverNested?: boolean;
onSelect?: (e: React.MouseEvent) => void;
onDelete?: (e: React.MouseEvent) => void;
onReorder?: (direction: 'up' | 'down') => void;
dragHandleProps?: React.HTMLAttributes<HTMLElement>;
children?: React.ReactNode;
isFirst?: boolean;
isLast?: boolean;
validationStatus?: "error" | "warning" | "info";
action: ExperimentAction;
isSelected?: boolean;
isDragging?: boolean;
isOverNested?: boolean;
onSelect?: (e: React.MouseEvent) => void;
onDelete?: (e: React.MouseEvent) => void;
onReorder?: (direction: "up" | "down") => void;
dragHandleProps?: React.HTMLAttributes<HTMLElement>;
children?: React.ReactNode;
isFirst?: boolean;
isLast?: boolean;
validationStatus?: "error" | "warning" | "info";
}
/**
* Helper to determine visual style based on action type/category
*/
function getActionVisualStyle(action: ExperimentAction) {
const def = actionRegistry.getAction(action.type);
const category = def?.category || "other";
const def = actionRegistry.getAction(action.type);
const category = def?.category || "other";
// Specific Control Types
if (action.type === "hristudio-core.wait" || action.type === "wait") {
return {
variant: "wait",
icon: Clock,
bg: "bg-amber-500/10 hover:bg-amber-500/20",
border: "border-amber-200 dark:border-amber-800",
text: "text-amber-700 dark:text-amber-400",
accent: "bg-amber-500",
};
}
if (action.type === "hristudio-core.branch" || action.type === "branch") {
return {
variant: "branch",
icon: GitBranch,
bg: "bg-orange-500/10 hover:bg-orange-500/20",
border: "border-orange-200 dark:border-orange-800",
text: "text-orange-700 dark:text-orange-400",
accent: "bg-orange-500",
};
}
if (action.type === "hristudio-core.loop" || action.type === "loop") {
return {
variant: "loop",
icon: Repeat,
bg: "bg-purple-500/10 hover:bg-purple-500/20",
border: "border-purple-200 dark:border-purple-800",
text: "text-purple-700 dark:text-purple-400",
accent: "bg-purple-500",
};
}
if (action.type === "hristudio-core.parallel" || action.type === "parallel") {
return {
variant: "parallel",
icon: Layers,
bg: "bg-emerald-500/10 hover:bg-emerald-500/20",
border: "border-emerald-200 dark:border-emerald-800",
text: "text-emerald-700 dark:text-emerald-400",
accent: "bg-emerald-500",
};
}
// General Categories
if (category === "wizard") {
return {
variant: "wizard",
icon: HelpCircle,
bg: "bg-indigo-500/5 hover:bg-indigo-500/10",
border: "border-indigo-200 dark:border-indigo-800",
text: "text-indigo-700 dark:text-indigo-300",
accent: "bg-indigo-500",
};
}
if ((category as string) === "robot" || (category as string) === "movement" || (category as string) === "speech") {
return {
variant: "robot",
icon: Play, // Or specific robot icon if available
bg: "bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700",
border: "border-slate-200 dark:border-slate-700",
text: "text-slate-700 dark:text-slate-300",
accent: "bg-slate-500",
}
}
// Default
// Specific Control Types
if (action.type === "hristudio-core.wait" || action.type === "wait") {
return {
variant: "default",
icon: undefined,
bg: "bg-muted/40 hover:bg-accent/40",
border: "border-border",
text: "text-foreground",
accent: "bg-muted-foreground",
variant: "wait",
icon: Clock,
bg: "bg-amber-500/10 hover:bg-amber-500/20",
border: "border-amber-200 dark:border-amber-800",
text: "text-amber-700 dark:text-amber-400",
accent: "bg-amber-500",
};
}
if (action.type === "hristudio-core.branch" || action.type === "branch") {
return {
variant: "branch",
icon: GitBranch,
bg: "bg-orange-500/10 hover:bg-orange-500/20",
border: "border-orange-200 dark:border-orange-800",
text: "text-orange-700 dark:text-orange-400",
accent: "bg-orange-500",
};
}
if (action.type === "hristudio-core.loop" || action.type === "loop") {
return {
variant: "loop",
icon: Repeat,
bg: "bg-purple-500/10 hover:bg-purple-500/20",
border: "border-purple-200 dark:border-purple-800",
text: "text-purple-700 dark:text-purple-400",
accent: "bg-purple-500",
};
}
if (action.type === "hristudio-core.parallel" || action.type === "parallel") {
return {
variant: "parallel",
icon: Layers,
bg: "bg-emerald-500/10 hover:bg-emerald-500/20",
border: "border-emerald-200 dark:border-emerald-800",
text: "text-emerald-700 dark:text-emerald-400",
accent: "bg-emerald-500",
};
}
// General Categories
if (category === "wizard") {
return {
variant: "wizard",
icon: HelpCircle,
bg: "bg-indigo-500/5 hover:bg-indigo-500/10",
border: "border-indigo-200 dark:border-indigo-800",
text: "text-indigo-700 dark:text-indigo-300",
accent: "bg-indigo-500",
};
}
if (
(category as string) === "robot" ||
(category as string) === "movement" ||
(category as string) === "speech"
) {
return {
variant: "robot",
icon: Play, // Or specific robot icon if available
bg: "bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700",
border: "border-slate-200 dark:border-slate-700",
text: "text-slate-700 dark:text-slate-300",
accent: "bg-slate-500",
};
}
// Default
return {
variant: "default",
icon: undefined,
bg: "bg-muted/40 hover:bg-accent/40",
border: "border-border",
text: "text-foreground",
accent: "bg-muted-foreground",
};
}
export function ActionChipVisuals({
action,
isSelected,
isDragging,
isOverNested,
onSelect,
onDelete,
onReorder,
dragHandleProps,
children,
isFirst,
isLast,
validationStatus,
action,
isSelected,
isDragging,
isOverNested,
onSelect,
onDelete,
onReorder,
dragHandleProps,
children,
isFirst,
isLast,
validationStatus,
}: ActionChipVisualsProps) {
const def = actionRegistry.getAction(action.type);
const style = getActionVisualStyle(action);
const Icon = style.icon;
const def = actionRegistry.getAction(action.type);
const style = getActionVisualStyle(action);
const Icon = style.icon;
return (
return (
<div
className={cn(
"group relative flex w-full flex-col items-start gap-1 rounded border px-3 py-2 text-[11px] transition-all duration-200",
style.bg,
style.border,
isSelected && "ring-primary border-primary bg-accent/50 ring-2",
isDragging && "scale-95 opacity-70 shadow-lg",
isOverNested &&
!isDragging &&
"bg-blue-50/50 ring-2 ring-blue-400 ring-offset-1 dark:bg-blue-900/20",
)}
onClick={onSelect}
role="button"
aria-pressed={isSelected}
tabIndex={0}
>
{/* Accent Bar logic for control flow */}
{style.variant !== "default" && style.variant !== "robot" && (
<div
className={cn(
"absolute top-0 bottom-0 left-0 w-1 rounded-l",
style.accent,
)}
/>
)}
<div
className={cn(
"flex w-full items-center gap-2",
style.variant !== "default" && style.variant !== "robot" && "pl-2",
)}
>
<div className="flex min-w-0 flex-1 items-center gap-2">
{Icon && (
<Icon className={cn("h-3.5 w-3.5 flex-shrink-0", style.text)} />
)}
<span
className={cn(
"group relative flex w-full flex-col items-start gap-1 rounded border px-3 py-2 text-[11px] transition-all duration-200",
style.bg,
style.border,
isSelected && "ring-2 ring-primary border-primary bg-accent/50",
isDragging && "opacity-70 shadow-lg scale-95",
isOverNested && !isDragging && "ring-2 ring-blue-400 ring-offset-1 bg-blue-50/50 dark:bg-blue-900/20"
"truncate leading-snug font-medium break-words",
style.text,
)}
onClick={onSelect}
role="button"
aria-pressed={isSelected}
tabIndex={0}
>
{action.name}
</span>
{/* Inline Info for Control Actions */}
{style.variant === "wait" && !!action.parameters.duration && (
<span className="bg-background/50 text-muted-foreground ml-1 rounded px-1.5 py-0.5 font-mono text-[10px]">
{String(action.parameters.duration ?? "")}s
</span>
)}
{style.variant === "loop" && (
<span className="bg-background/50 text-muted-foreground ml-1 rounded px-1.5 py-0.5 font-mono text-[10px]">
{String(action.parameters.iterations || 1)}x
</span>
)}
{style.variant === "loop" &&
action.parameters.requireApproval !== false && (
<span
className="ml-1 flex items-center gap-0.5 rounded bg-purple-500/20 px-1.5 py-0.5 font-mono text-[10px] text-purple-700 dark:text-purple-300"
title="Requires Wizard Approval"
>
<HelpCircle className="h-2 w-2" />
Ask
</span>
)}
{validationStatus === "error" && (
<div
className="h-2 w-2 flex-shrink-0 rounded-full bg-red-500 ring-1 ring-red-600"
aria-label="Error"
/>
)}
{validationStatus === "warning" && (
<div
className="h-2 w-2 flex-shrink-0 rounded-full bg-amber-500 ring-1 ring-amber-600"
aria-label="Warning"
/>
)}
</div>
<div className="bg-background/50 border-border/50 mr-1 flex items-center gap-0.5 rounded-md border px-0.5 opacity-0 shadow-sm transition-opacity group-hover:opacity-100">
<Button
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground pointer-events-auto z-20 h-5 w-5 p-0 text-[10px]"
onClick={(e) => {
e.stopPropagation();
onReorder?.("up");
}}
disabled={isFirst}
aria-label="Move action up"
>
<ChevronRight className="h-3 w-3 -rotate-90" />
</Button>
<Button
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground pointer-events-auto z-20 h-5 w-5 p-0 text-[10px]"
onClick={(e) => {
e.stopPropagation();
onReorder?.("down");
}}
disabled={isLast}
aria-label="Move action down"
>
<ChevronRight className="h-3 w-3 rotate-90" />
</Button>
</div>
<button
type="button"
onClick={onDelete}
className="text-muted-foreground hover:text-destructive rounded p-0.5 opacity-0 transition-opacity group-hover:opacity-100"
aria-label="Delete action"
>
{/* Accent Bar logic for control flow */}
{style.variant !== "default" && style.variant !== "robot" && (
<div className={cn("absolute left-0 top-0 bottom-0 w-1 rounded-l", style.accent)} />
)}
<Trash2 className="h-3 w-3" />
</button>
</div>
<div className={cn("flex w-full items-center gap-2", style.variant !== "default" && style.variant !== "robot" && "pl-2")}>
<div className="flex items-center gap-2 flex-1 min-w-0">
{Icon && <Icon className={cn("h-3.5 w-3.5 flex-shrink-0", style.text)} />}
<span className={cn("leading-snug font-medium break-words truncate", style.text)}>
{action.name}
</span>
{/* Description / Subtext */}
{def?.description && (
<div
className={cn(
"text-muted-foreground mt-0.5 line-clamp-2 w-full pl-2 text-[10px] leading-snug",
style.variant !== "default" && style.variant !== "robot" && "pl-4",
)}
>
{def.description}
</div>
)}
{/* Inline Info for Control Actions */}
{style.variant === "wait" && !!action.parameters.duration && (
<span className="ml-1 text-[10px] bg-background/50 px-1.5 py-0.5 rounded font-mono text-muted-foreground">
{String(action.parameters.duration ?? "")}s
</span>
)}
{style.variant === "loop" && (
<span className="ml-1 text-[10px] bg-background/50 px-1.5 py-0.5 rounded font-mono text-muted-foreground">
{String(action.parameters.iterations || 1)}x
</span>
)}
{style.variant === "loop" && action.parameters.requireApproval !== false && (
<span className="ml-1 text-[10px] bg-purple-500/20 px-1.5 py-0.5 rounded font-mono text-purple-700 dark:text-purple-300 flex items-center gap-0.5" title="Requires Wizard Approval">
<HelpCircle className="h-2 w-2" />
Ask
</span>
)}
{/* Tags for parameters (hide for specialized control blocks that show inline) */}
{def?.parameters?.length &&
(style.variant === "default" || style.variant === "robot") ? (
<div className="flex flex-wrap gap-1 pt-1">
{def.parameters.slice(0, 3).map((p) => (
<span
key={p.id}
className="bg-background/80 text-muted-foreground ring-border max-w-[80px] truncate rounded px-1 py-0.5 text-[9px] font-medium ring-1"
>
{p.name}
</span>
))}
{def.parameters.length > 3 && (
<span className="text-muted-foreground text-[9px]">
+{def.parameters.length - 3}
</span>
)}
</div>
) : null}
{validationStatus === "error" && (
<div className="h-2 w-2 rounded-full bg-red-500 ring-1 ring-red-600 flex-shrink-0" aria-label="Error" />
)}
{validationStatus === "warning" && (
<div className="h-2 w-2 rounded-full bg-amber-500 ring-1 ring-amber-600 flex-shrink-0" aria-label="Warning" />
)}
</div>
<div className="flex items-center gap-0.5 mr-1 bg-background/50 rounded-md border border-border/50 shadow-sm px-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 text-[10px] text-muted-foreground hover:text-foreground z-20 pointer-events-auto"
onClick={(e) => {
e.stopPropagation();
onReorder?.('up');
}}
disabled={isFirst}
aria-label="Move action up"
>
<ChevronRight className="h-3 w-3 -rotate-90" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 text-[10px] text-muted-foreground hover:text-foreground z-20 pointer-events-auto"
onClick={(e) => {
e.stopPropagation();
onReorder?.('down');
}}
disabled={isLast}
aria-label="Move action down"
>
<ChevronRight className="h-3 w-3 rotate-90" />
</Button>
</div>
<button
type="button"
onClick={onDelete}
className="text-muted-foreground hover:text-destructive rounded p-0.5 opacity-0 transition-opacity group-hover:opacity-100"
aria-label="Delete action"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
{/* Description / Subtext */}
{
def?.description && (
<div className={cn("text-muted-foreground line-clamp-2 w-full text-[10px] leading-snug pl-2 mt-0.5", style.variant !== "default" && style.variant !== "robot" && "pl-4")}>
{def.description}
</div>
)
}
{/* Tags for parameters (hide for specialized control blocks that show inline) */}
{
def?.parameters?.length && (style.variant === 'default' || style.variant === 'robot') ? (
<div className="flex flex-wrap gap-1 pt-1">
{def.parameters.slice(0, 3).map((p) => (
<span
key={p.id}
className="bg-background/80 text-muted-foreground ring-border rounded px-1 py-0.5 text-[9px] font-medium ring-1 truncate max-w-[80px]"
>
{p.name}
</span>
))}
{def.parameters.length > 3 && (
<span className="text-[9px] text-muted-foreground">+{def.parameters.length - 3}</span>
)}
</div>
) : null
}
{children}
</div >
);
{children}
</div>
);
}
export function SortableActionChip({
stepId,
action,
parentId,
selectedActionId,
onSelectAction,
onDeleteAction,
onReorderAction,
dragHandle,
isFirst,
isLast,
stepId,
action,
parentId,
selectedActionId,
onSelectAction,
onDeleteAction,
onReorderAction,
dragHandle,
isFirst,
isLast,
}: ActionChipProps) {
const isSelected = selectedActionId === action.id;
const isSelected = selectedActionId === action.id;
const insertionProjection = useDesignerStore((s) => s.insertionProjection);
const steps = useDesignerStore((s) => s.steps);
const currentStep = steps.find((s) => s.id === stepId);
const insertionProjection = useDesignerStore((s) => s.insertionProjection);
const steps = useDesignerStore((s) => s.steps);
const currentStep = steps.find((s) => s.id === stepId);
// Branch Options Visualization
const branchOptions = useMemo(() => {
if (!action.type.includes("branch") || !currentStep) return null;
// Branch Options Visualization
const branchOptions = useMemo(() => {
if (!action.type.includes("branch") || !currentStep) return null;
const options = (currentStep.trigger as any)?.conditions?.options;
if (!options?.length && !(currentStep.trigger as any)?.conditions?.nextStepId) {
return (
<div className="mt-2 text-muted-foreground/60 italic text-center py-2 text-[10px] bg-background/50 rounded border border-dashed">
No branches configured. Add options in properties.
</div>
);
}
// Combine explicit options and unconditional nextStepId
// The original FlowWorkspace logic iterated options. logic there:
// (step.trigger.conditions as any).options.map...
return (
<div className="mt-2 space-y-1 w-full">
{options?.map((opt: any, idx: number) => {
// Resolve ID to name for display
let targetName = "Unlinked";
let targetIndex = -1;
if (opt.nextStepId) {
const target = steps.find(s => s.id === opt.nextStepId);
if (target) {
targetName = target.name;
targetIndex = target.order;
}
} else if (typeof opt.nextStepIndex === 'number') {
targetIndex = opt.nextStepIndex;
targetName = `Step #${targetIndex + 1}`;
}
return (
<div key={idx} className="flex items-center justify-between rounded bg-background/50 shadow-sm border p-1.5 text-[10px]">
<div className="flex items-center gap-2 min-w-0">
<Badge variant="outline" className={cn(
"text-[9px] uppercase font-bold tracking-wider px-1 py-0 min-w-[60px] justify-center bg-background",
opt.variant === "destructive"
? "border-red-500/30 text-red-600 dark:text-red-400"
: "border-slate-500/30 text-foreground"
)}>
{opt.label}
</Badge>
<ChevronRight className="h-3 w-3 text-muted-foreground/50 flex-shrink-0" />
</div>
<div className="flex items-center gap-1.5 text-right min-w-0 max-w-[60%] justify-end">
<span className="font-medium truncate text-foreground/80" title={targetName}>
{targetName}
</span>
{targetIndex !== -1 && (
<Badge variant="secondary" className="px-1 py-0 h-3.5 text-[9px] min-w-[18px] justify-center tabular-nums bg-slate-100 dark:bg-slate-800">
#{targetIndex + 1}
</Badge>
)}
</div>
</div>
);
})}
{/* Visual indicator for unconditional jump if present and no options matched (though usually logic handles this) */}
{/* For now keeping parity with FlowWorkspace which only showed options */}
</div>
);
}, [action.type, currentStep, steps]);
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";
// Compute validation status
const issues = useDesignerStore((s) => s.validationIssues[action.id]);
const validationStatus = useMemo(() => {
if (!issues?.length) return undefined;
if (issues.some((i) => i.severity === "error")) return "error";
if (issues.some((i) => i.severity === "warning")) return "warning";
return "info";
}, [issues]);
/* ------------------------------------------------------------------------ */
/* Sortable (Local) DnD Monitoring */
/* ------------------------------------------------------------------------ */
// useSortable disabled per user request to remove action drag-and-drop
// const { ... } = useSortable(...)
// Use local dragging state or passed prop
const isDragging = dragHandle || false;
/* ------------------------------------------------------------------------ */
/* Nested Droppable (for control flow containers) */
/* ------------------------------------------------------------------------ */
const def = actionRegistry.getAction(action.type);
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) {
return (
<div
className={cn(
"relative flex w-full flex-col items-start gap-1 rounded border border-dashed px-3 py-2 text-[11px]",
"bg-blue-50/50 dark:bg-blue-900/20 border-blue-400 opacity-70"
)}
>
<div className="flex w-full items-center gap-2">
<span className="font-medium text-blue-700 italic">
{action.name}
</span>
</div>
</div >
);
const options = (currentStep.trigger as any)?.conditions?.options;
if (
!options?.length &&
!(currentStep.trigger as any)?.conditions?.nextStepId
) {
return (
<div className="text-muted-foreground/60 bg-background/50 mt-2 rounded border border-dashed py-2 text-center text-[10px] italic">
No branches configured. Add options in properties.
</div>
);
}
return (
<ActionChipVisuals
action={action}
isSelected={isSelected}
isDragging={isDragging}
isOverNested={isOverNested && !isDragging}
onSelect={(e) => {
e.stopPropagation();
onSelectAction(stepId, action.id);
}}
onDelete={(e) => {
e.stopPropagation();
onDeleteAction(stepId, action.id);
}}
onReorder={(direction) => onReorderAction?.(stepId, action.id, direction)}
isFirst={isFirst}
isLast={isLast}
validationStatus={validationStatus}
>
{/* Branch Options Visualization */}
{branchOptions}
// Combine explicit options and unconditional nextStepId
// The original FlowWorkspace logic iterated options. logic there:
// (step.trigger.conditions as any).options.map...
{/* Nested Children Rendering (e.g. for Loops/Parallel) */}
{shouldRenderChildren && (
<div
ref={setNestedNodeRef}
className={cn(
"mt-2 w-full space-y-2 rounded border border-dashed p-1.5 transition-colors",
isOverNested
? "bg-blue-100/50 dark:bg-blue-900/20 border-blue-400"
: "bg-muted/20 dark:bg-muted/10 border-border/50"
)}
return (
<div className="mt-2 w-full space-y-1">
{options?.map((opt: any, idx: number) => {
// Resolve ID to name for display
let targetName = "Unlinked";
let targetIndex = -1;
if (opt.nextStepId) {
const target = steps.find((s) => s.id === opt.nextStepId);
if (target) {
targetName = target.name;
targetIndex = target.order;
}
} else if (typeof opt.nextStepIndex === "number") {
targetIndex = opt.nextStepIndex;
targetName = `Step #${targetIndex + 1}`;
}
return (
<div
key={idx}
className="bg-background/50 flex items-center justify-between rounded border p-1.5 text-[10px] shadow-sm"
>
<div className="flex min-w-0 items-center gap-2">
<Badge
variant="outline"
className={cn(
"bg-background min-w-[60px] justify-center px-1 py-0 text-[9px] font-bold tracking-wider uppercase",
opt.variant === "destructive"
? "border-red-500/30 text-red-600 dark:text-red-400"
: "text-foreground border-slate-500/30",
)}
>
{displayChildren?.length === 0 ? (
<div className="py-2 text-center text-[10px] text-muted-foreground/60 italic">
Empty container
</div>
) : (
displayChildren?.map((child, idx) => (
<SortableActionChip
key={child.id}
stepId={stepId}
action={child}
parentId={action.id}
selectedActionId={selectedActionId}
onSelectAction={onSelectAction}
onDeleteAction={onDeleteAction}
onReorderAction={onReorderAction}
isFirst={idx === 0}
isLast={idx === (displayChildren?.length || 0) - 1}
/>
))
)}
</div>
)}
</ActionChipVisuals>
{opt.label}
</Badge>
<ChevronRight className="text-muted-foreground/50 h-3 w-3 flex-shrink-0" />
</div>
<div className="flex max-w-[60%] min-w-0 items-center justify-end gap-1.5 text-right">
<span
className="text-foreground/80 truncate font-medium"
title={targetName}
>
{targetName}
</span>
{targetIndex !== -1 && (
<Badge
variant="secondary"
className="h-3.5 min-w-[18px] justify-center bg-slate-100 px-1 py-0 text-[9px] tabular-nums dark:bg-slate-800"
>
#{targetIndex + 1}
</Badge>
)}
</div>
</div>
);
})}
{/* Visual indicator for unconditional jump if present and no options matched (though usually logic handles this) */}
{/* For now keeping parity with FlowWorkspace which only showed options */}
</div>
);
}, [action.type, currentStep, steps]);
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";
// Compute validation status
const issues = useDesignerStore((s) => s.validationIssues[action.id]);
const validationStatus = useMemo(() => {
if (!issues?.length) return undefined;
if (issues.some((i) => i.severity === "error")) return "error";
if (issues.some((i) => i.severity === "warning")) return "warning";
return "info";
}, [issues]);
/* ------------------------------------------------------------------------ */
/* Sortable (Local) DnD Monitoring */
/* ------------------------------------------------------------------------ */
// useSortable disabled per user request to remove action drag-and-drop
// const { ... } = useSortable(...)
// Use local dragging state or passed prop
const isDragging = dragHandle || false;
/* ------------------------------------------------------------------------ */
/* Nested Droppable (for control flow containers) */
/* ------------------------------------------------------------------------ */
const def = actionRegistry.getAction(action.type);
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) {
return (
<div
className={cn(
"relative flex w-full flex-col items-start gap-1 rounded border border-dashed px-3 py-2 text-[11px]",
"border-blue-400 bg-blue-50/50 opacity-70 dark:bg-blue-900/20",
)}
>
<div className="flex w-full items-center gap-2">
<span className="font-medium text-blue-700 italic">
{action.name}
</span>
</div>
</div>
);
}
return (
<ActionChipVisuals
action={action}
isSelected={isSelected}
isDragging={isDragging}
isOverNested={isOverNested && !isDragging}
onSelect={(e) => {
e.stopPropagation();
onSelectAction(stepId, action.id);
}}
onDelete={(e) => {
e.stopPropagation();
onDeleteAction(stepId, action.id);
}}
onReorder={(direction) => onReorderAction?.(stepId, action.id, direction)}
isFirst={isFirst}
isLast={isLast}
validationStatus={validationStatus}
>
{/* Branch Options Visualization */}
{branchOptions}
{/* Nested Children Rendering (e.g. for Loops/Parallel) */}
{shouldRenderChildren && (
<div
ref={setNestedNodeRef}
className={cn(
"mt-2 w-full space-y-2 rounded border border-dashed p-1.5 transition-colors",
isOverNested
? "border-blue-400 bg-blue-100/50 dark:bg-blue-900/20"
: "bg-muted/20 dark:bg-muted/10 border-border/50",
)}
>
{displayChildren?.length === 0 ? (
<div className="text-muted-foreground/60 py-2 text-center text-[10px] italic">
Empty container
</div>
) : (
displayChildren?.map((child, idx) => (
<SortableActionChip
key={child.id}
stepId={stepId}
action={child}
parentId={action.id}
selectedActionId={selectedActionId}
onSelectAction={onSelectAction}
onDeleteAction={onDeleteAction}
onReorderAction={onReorderAction}
isFirst={idx === 0}
isLast={idx === (displayChildren?.length || 0) - 1}
/>
))
)}
</div>
)}
</ActionChipVisuals>
);
}

View File

@@ -97,8 +97,12 @@ interface StepRowProps {
onDeleteAction: (stepId: string, actionId: string) => void;
setRenamingStepId: (id: string | null) => void;
registerMeasureRef: (stepId: string, el: HTMLDivElement | null) => void;
onReorderStep: (stepId: string, direction: 'up' | 'down') => void;
onReorderAction?: (stepId: string, actionId: string, direction: 'up' | 'down') => void;
onReorderStep: (stepId: string, direction: "up" | "down") => void;
onReorderAction?: (
stepId: string,
actionId: string,
direction: "up" | "down",
) => void;
isChild?: boolean;
}
@@ -157,12 +161,12 @@ function StepRow({
ref={(el) => registerMeasureRef(step.id, el)}
className={cn(
"relative px-3 py-4 transition-all duration-300",
isChild && "ml-8 pl-0"
isChild && "ml-8 pl-0",
)}
data-step-id={step.id}
>
{isChild && (
<div className="absolute left-[-24px] top-8 text-muted-foreground/40">
<div className="text-muted-foreground/40 absolute top-8 left-[-24px]">
<CornerDownRight className="h-5 w-5" />
</div>
)}
@@ -172,7 +176,7 @@ function StepRow({
"mb-2 rounded-lg border shadow-sm transition-colors",
selectedStepId === step.id
? "border-border bg-accent/30"
: "hover:bg-accent/30"
: "hover:bg-accent/30",
)}
>
<div
@@ -220,7 +224,7 @@ function StepRow({
onRenameStep(
step,
(e.target as HTMLInputElement).value.trim() ||
step.name,
step.name,
);
setRenamingStepId(null);
} else if (e.key === "Escape") {
@@ -268,10 +272,10 @@ function StepRow({
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-[11px] text-muted-foreground hover:text-foreground"
className="text-muted-foreground hover:text-foreground h-7 w-7 p-0 text-[11px]"
onClick={(e) => {
e.stopPropagation();
onReorderStep(step.id, 'up');
onReorderStep(step.id, "up");
}}
disabled={item.index === 0}
aria-label="Move step up"
@@ -281,58 +285,69 @@ function StepRow({
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-[11px] text-muted-foreground hover:text-foreground"
className="text-muted-foreground hover:text-foreground h-7 w-7 p-0 text-[11px]"
onClick={(e) => {
e.stopPropagation();
onReorderStep(step.id, 'down');
onReorderStep(step.id, "down");
}}
disabled={item.index === totalSteps - 1}
aria-label="Move step down"
>
<ChevronRight className="h-4 w-4 rotate-90" />
</Button>
</div>
</div>
{/* Conditional Branching Visualization */}
{/* Loop Visualization */}
{step.type === "loop" && (
<div className="mx-3 my-3 rounded-md border text-xs" style={{
backgroundColor: 'var(--validation-info-bg, #f0f9ff)',
borderColor: 'var(--validation-info-border, #bae6fd)',
}}>
<div className="flex items-center gap-2 border-b px-3 py-2 font-medium" style={{
borderColor: 'var(--validation-info-border, #bae6fd)',
color: 'var(--validation-info-text, #0369a1)'
}}>
<div
className="mx-3 my-3 rounded-md border text-xs"
style={{
backgroundColor: "var(--validation-info-bg, #f0f9ff)",
borderColor: "var(--validation-info-border, #bae6fd)",
}}
>
<div
className="flex items-center gap-2 border-b px-3 py-2 font-medium"
style={{
borderColor: "var(--validation-info-border, #bae6fd)",
color: "var(--validation-info-text, #0369a1)",
}}
>
<Repeat className="h-3.5 w-3.5" />
<span>Loop Logic</span>
</div>
<div className="p-2 space-y-2">
<div className="space-y-2 p-2">
<div className="flex items-center gap-2 text-[11px]">
<span className="text-muted-foreground">Repeat:</span>
<Badge variant="outline" className="font-mono">
{(step.trigger.conditions as any).loop?.iterations || 1} times
{(step.trigger.conditions as any).loop?.iterations || 1}{" "}
times
</Badge>
</div>
<div className="flex items-center gap-2 text-[11px]">
<span className="text-muted-foreground">Approval:</span>
<Badge variant={(step.trigger.conditions as any).loop?.requireApproval !== false ? "default" : "secondary"}>
{(step.trigger.conditions as any).loop?.requireApproval !== false ? "Required" : "Auto-proceed"}
<Badge
variant={
(step.trigger.conditions as any).loop?.requireApproval !==
false
? "default"
: "secondary"
}
>
{(step.trigger.conditions as any).loop?.requireApproval !==
false
? "Required"
: "Auto-proceed"}
</Badge>
</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">
@@ -342,7 +357,7 @@ function StepRow({
>
<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">
<div className="text-muted-foreground flex h-12 items-center justify-center rounded border border-dashed text-xs">
Drop actions here
</div>
) : (
@@ -367,7 +382,7 @@ function StepRow({
)}
</div>
</div>
</div >
</div>
);
}
@@ -375,15 +390,21 @@ function StepRow({
/* Step Card Preview (for DragOverlay) */
/* -------------------------------------------------------------------------- */
export function StepCardPreview({ step, dragHandle }: { step: ExperimentStep; dragHandle?: boolean }) {
export function StepCardPreview({
step,
dragHandle,
}: {
step: ExperimentStep;
dragHandle?: boolean;
}) {
return (
<div
className={cn(
"rounded-lg border bg-background shadow-xl ring-2 ring-blue-500/20",
dragHandle && "cursor-grabbing"
"bg-background rounded-lg border shadow-xl ring-2 ring-blue-500/20",
dragHandle && "cursor-grabbing",
)}
>
<div className="flex items-center justify-between gap-2 border-b px-2 py-1.5 p-3">
<div className="flex items-center justify-between gap-2 border-b p-3 px-2 py-1.5">
<div className="flex items-center gap-2">
<div className="text-muted-foreground rounded p-1">
<ChevronRight className="h-4 w-4" />
@@ -401,13 +422,13 @@ export function StepCardPreview({ step, dragHandle }: { step: ExperimentStep; dr
{step.actions.length} actions
</span>
</div>
<div className="flex items-center gap-1 text-muted-foreground">
<div className="text-muted-foreground flex items-center gap-1">
<GripVertical className="h-4 w-4" />
</div>
</div>
{/* Preview optional: show empty body hint or just the header? Header is usually enough for sorting. */}
<div className="bg-muted/10 p-2 h-12 flex items-center justify-center border-t border-dashed">
<span className="text-[10px] text-muted-foreground">
<div className="bg-muted/10 flex h-12 items-center justify-center border-t border-dashed p-2">
<span className="text-muted-foreground text-[10px]">
{step.actions.length} actions hidden while dragging
</span>
</div>
@@ -423,8 +444,6 @@ function generateStepId(): string {
return `step-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}
function sortableStepId(stepId: string) {
return `s-step-${stepId}`;
}
@@ -447,7 +466,7 @@ function StepDroppableArea({ stepId }: { stepId: string }) {
const { isOver, setNodeRef } = useDroppable({
id: `step-${stepId}`,
disabled: isStepDragging
disabled: isStepDragging,
});
if (isStepDragging) return null;
@@ -459,14 +478,12 @@ function StepDroppableArea({ stepId }: { stepId: string }) {
className={cn(
"pointer-events-none absolute inset-0 rounded-md transition-colors",
isOver &&
"bg-blue-50/40 ring-2 ring-blue-400/60 ring-offset-0 dark:bg-blue-950/20",
"bg-blue-50/40 ring-2 ring-blue-400/60 ring-offset-0 dark:bg-blue-950/20",
)}
/>
);
}
/* -------------------------------------------------------------------------- */
/* FlowWorkspace Component */
/* -------------------------------------------------------------------------- */
@@ -520,7 +537,10 @@ export function FlowWorkspace({
const childStepIds = useMemo(() => {
const children = new Set<string>();
for (const step of steps) {
if (step.type === 'conditional' && (step.trigger.conditions as any)?.options) {
if (
step.type === "conditional" &&
(step.trigger.conditions as any)?.options
) {
for (const opt of (step.trigger.conditions as any).options) {
if (opt.nextStepId) {
children.add(opt.nextStepId);
@@ -695,26 +715,33 @@ export function FlowWorkspace({
);
const handleReorderStep = useCallback(
(stepId: string, direction: 'up' | 'down') => {
console.log('handleReorderStep', stepId, direction);
(stepId: string, direction: "up" | "down") => {
console.log("handleReorderStep", stepId, direction);
const currentIndex = steps.findIndex((s) => s.id === stepId);
console.log('currentIndex', currentIndex, 'total', steps.length);
console.log("currentIndex", currentIndex, "total", steps.length);
if (currentIndex === -1) return;
const newIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
console.log('newIndex', newIndex);
const newIndex = direction === "up" ? currentIndex - 1 : currentIndex + 1;
console.log("newIndex", newIndex);
if (newIndex < 0 || newIndex >= steps.length) return;
reorderStep(currentIndex, newIndex);
},
[steps, reorderStep]
[steps, reorderStep],
);
const handleReorderAction = useCallback(
(stepId: string, actionId: string, direction: 'up' | 'down') => {
const step = steps.find(s => s.id === stepId);
(stepId: string, actionId: string, direction: "up" | "down") => {
const step = steps.find((s) => s.id === stepId);
if (!step) return;
const findInTree = (list: ExperimentAction[], pId: string | null): { list: ExperimentAction[], parentId: string | null, index: number } | null => {
const idx = list.findIndex(a => a.id === actionId);
const findInTree = (
list: ExperimentAction[],
pId: string | null,
): {
list: ExperimentAction[];
parentId: string | null;
index: number;
} | null => {
const idx = list.findIndex((a) => a.id === actionId);
if (idx !== -1) return { list, parentId: pId, index: idx };
for (const a of list) {
@@ -730,16 +757,15 @@ export function FlowWorkspace({
if (!context) return;
const { parentId, index, list } = context;
const newIndex = direction === 'up' ? index - 1 : index + 1;
const newIndex = direction === "up" ? index - 1 : index + 1;
if (newIndex < 0 || newIndex >= list.length) return;
moveAction(stepId, actionId, parentId, newIndex);
},
[steps, moveAction]
[steps, moveAction],
);
/* ------------------------------------------------------------------------ */
/* Sortable (Local) DnD Monitoring */
/* ------------------------------------------------------------------------ */
@@ -768,9 +794,11 @@ export function FlowWorkspace({
const overData = over.data.current;
if (
activeData && overData &&
activeData &&
overData &&
activeData.stepId === overData.stepId &&
activeData.type === 'action' && overData.type === 'action'
activeData.type === "action" &&
overData.type === "action"
) {
const stepId = activeData.stepId as string;
// Fix: SortableActionChip puts 'id' directly on data, not inside 'action' property
@@ -809,8 +837,8 @@ export function FlowWorkspace({
if (
activeData &&
overData &&
activeData.type === 'action' &&
overData.type === 'action'
activeData.type === "action" &&
overData.type === "action"
) {
// Fix: Access 'id' directly from data payload
const activeActionId = activeData.id;
@@ -825,12 +853,17 @@ export function FlowWorkspace({
if (activeParentId !== overParentId || activeStepId !== overStepId) {
// Determine new index
// verification of safe move handled by store
moveAction(overStepId, activeActionId, overParentId, overData.sortable.index);
moveAction(
overStepId,
activeActionId,
overParentId,
overData.sortable.index,
);
}
}
}
},
[moveAction]
[moveAction],
);
useDndMonitor({
@@ -960,4 +993,3 @@ export function FlowWorkspace({
// Wrap in React.memo to prevent unnecessary re-renders causing flashing
export default React.memo(FlowWorkspace);

View File

@@ -82,7 +82,7 @@ export function BottomStatusBar({
);
default:
return (
<div className="flex items-center gap-1.5 text-muted-foreground">
<div className="text-muted-foreground flex items-center gap-1.5">
<Hash className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Unvalidated</span>
</div>
@@ -102,7 +102,7 @@ export function BottomStatusBar({
const savingIndicator =
pendingSave || saving ? (
<div className="flex items-center gap-1.5 text-muted-foreground animate-pulse">
<div className="text-muted-foreground flex animate-pulse items-center gap-1.5">
<RefreshCw className="h-3 w-3 animate-spin" />
<span>Saving...</span>
</div>
@@ -117,7 +117,7 @@ export function BottomStatusBar({
)}
>
{/* Status Indicators */}
<div className="flex items-center gap-3 min-w-0">
<div className="flex min-w-0 items-center gap-3">
{validationBadge}
{unsavedBadge}
{savingIndicator}

View File

@@ -64,29 +64,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 transition-[width,opacity] duration-300 ease-in-out", panelCls, panelClassName)}
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 transition-[width,opacity] duration-300 ease-in-out",
panelCls,
panelClassName,
)}
>
<div
className={cn(
"h-full min-h-0 w-full overflow-x-hidden overflow-y-auto",
contentClassName,
)}
>
<div
className={cn(
"h-full min-h-0 w-full overflow-x-hidden overflow-y-auto",
contentClassName,
)}
>
{children}
</div>
</section>
);
{children}
</div>
</section>
);
export function PanelsContainer({
left,
@@ -178,7 +179,7 @@ export function PanelsContainer({
minRightPct,
maxRightPct,
leftCollapsed,
rightCollapsed
rightCollapsed,
],
);
@@ -206,7 +207,16 @@ export function PanelsContainer({
setRightPct(nextRight);
}
},
[hasLeft, hasRight, minLeftPct, maxLeftPct, minRightPct, maxRightPct, leftCollapsed, rightCollapsed],
[
hasLeft,
hasRight,
minLeftPct,
maxLeftPct,
minRightPct,
maxRightPct,
leftCollapsed,
rightCollapsed,
],
);
const endDrag = React.useCallback(() => {
@@ -270,10 +280,10 @@ export function PanelsContainer({
// We use FR units instead of % to let the browser handle exact pixel fitting without rounding errors causing overflow
const styleVars: React.CSSProperties & Record<string, string> = hasCenter
? {
"--col-left": `${hasLeft ? l : 0}fr`,
"--col-center": `${c}fr`,
"--col-right": `${hasRight ? r : 0}fr`,
}
"--col-left": `${hasLeft ? l : 0}fr`,
"--col-center": `${c}fr`,
"--col-right": `${hasRight ? r : 0}fr`,
}
: {};
// Explicit grid template depending on which side panels exist
@@ -299,19 +309,17 @@ export function PanelsContainer({
const centerDividers =
showDividers && hasCenter
? cn({
"border-l": hasLeft,
"border-r": hasRight,
})
"border-l": hasLeft,
"border-r": hasRight,
})
: undefined;
return (
<>
{/* Mobile Layout (Flex + Sheets) */}
<div className={cn("flex flex-col h-full w-full md:hidden", className)}>
<div className={cn("flex h-full w-full flex-col md:hidden", className)}>
{/* Mobile Header/Toolbar for access to panels */}
<div className="flex items-center justify-between border-b px-4 py-2 bg-background">
<div className="bg-background flex items-center justify-between border-b px-4 py-2">
<div className="flex items-center gap-2">
{hasLeft && (
<Sheet>
@@ -321,9 +329,7 @@ export function PanelsContainer({
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-[85vw] p-0 sm:max-w-md">
<div className="h-full overflow-hidden">
{left}
</div>
<div className="h-full overflow-hidden">{left}</div>
</SheetContent>
</Sheet>
)}
@@ -338,16 +344,14 @@ export function PanelsContainer({
</Button>
</SheetTrigger>
<SheetContent side="right" className="w-[85vw] p-0 sm:max-w-md">
<div className="h-full overflow-hidden">
{right}
</div>
<div className="h-full overflow-hidden">{right}</div>
</SheetContent>
</Sheet>
)}
</div>
{/* Main Content (Center) */}
<div className="flex-1 min-h-0 min-w-0 overflow-hidden relative">
<div className="relative min-h-0 min-w-0 flex-1 overflow-hidden">
{center}
</div>
</div>
@@ -357,15 +361,31 @@ export function PanelsContainer({
ref={rootRef}
aria-label={ariaLabel}
className={cn(
"relative hidden md:grid h-full min-h-0 w-full max-w-full overflow-hidden select-none",
"relative hidden h-full min-h-0 w-full max-w-full overflow-hidden select-none md:grid",
// 2-3-2 ratio for left-center-right panels when all visible
hasLeft && hasRight && !leftCollapsed && !rightCollapsed && "grid-cols-[2fr_3fr_2fr]",
hasLeft &&
hasRight &&
!leftCollapsed &&
!rightCollapsed &&
"grid-cols-[2fr_3fr_2fr]",
// Left collapsed: center + right (3:2 ratio)
hasLeft && hasRight && leftCollapsed && !rightCollapsed && "grid-cols-[3fr_2fr]",
hasLeft &&
hasRight &&
leftCollapsed &&
!rightCollapsed &&
"grid-cols-[3fr_2fr]",
// Right collapsed: left + center (2:3 ratio)
hasLeft && hasRight && !leftCollapsed && rightCollapsed && "grid-cols-[2fr_3fr]",
hasLeft &&
hasRight &&
!leftCollapsed &&
rightCollapsed &&
"grid-cols-[2fr_3fr]",
// Both collapsed: center only
hasLeft && hasRight && leftCollapsed && rightCollapsed && "grid-cols-1",
hasLeft &&
hasRight &&
leftCollapsed &&
rightCollapsed &&
"grid-cols-1",
// Only left and center
hasLeft && !hasRight && !leftCollapsed && "grid-cols-[2fr_3fr]",
hasLeft && !hasRight && leftCollapsed && "grid-cols-1",
@@ -409,7 +429,7 @@ export function PanelsContainer({
{hasLeft && !leftCollapsed && (
<button
type="button"
className="absolute top-0 bottom-0 w-1.5 -ml-0.75 z-50 cursor-col-resize hover:bg-blue-400/50 transition-colors focus:outline-none"
className="absolute top-0 bottom-0 z-50 -ml-0.75 w-1.5 cursor-col-resize transition-colors hover:bg-blue-400/50 focus:outline-none"
style={{ left: "var(--col-left)" }}
onPointerDown={startDrag("left")}
onKeyDown={onKeyResize("left")}
@@ -419,7 +439,7 @@ export function PanelsContainer({
{hasRight && !rightCollapsed && (
<button
type="button"
className="absolute top-0 bottom-0 w-1.5 -mr-0.75 z-50 cursor-col-resize hover:bg-blue-400/50 transition-colors focus:outline-none"
className="absolute top-0 bottom-0 z-50 -mr-0.75 w-1.5 cursor-col-resize transition-colors hover:bg-blue-400/50 focus:outline-none"
style={{ right: "var(--col-right)" }}
onPointerDown={startDrag("right")}
onKeyDown={onKeyResize("right")}

View File

@@ -89,8 +89,8 @@ function DraggableAction({
const style: React.CSSProperties = transform
? {
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
}
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
}
: {};
const IconComponent = iconMap[action.icon] ?? Sparkles;
@@ -174,7 +174,10 @@ export interface ActionLibraryPanelProps {
onCollapse?: (collapsed: boolean) => void;
}
export function ActionLibraryPanel({ collapsed, onCollapse }: ActionLibraryPanelProps = {}) {
export function ActionLibraryPanel({
collapsed,
onCollapse,
}: ActionLibraryPanelProps = {}) {
const registry = useActionRegistry();
const [search, setSearch] = useState("");
@@ -299,8 +302,6 @@ export function ActionLibraryPanel({ collapsed, onCollapse }: ActionLibraryPanel
setShowOnlyFavorites(false);
}, [categories]);
const filtered = useMemo(() => {
const activeCats = selectedCategories;
const q = search.trim().toLowerCase();
@@ -339,7 +340,10 @@ export function ActionLibraryPanel({ collapsed, onCollapse }: ActionLibraryPanel
).length;
return (
<div className="flex h-full flex-col overflow-hidden" id="tour-designer-blocks">
<div
className="flex h-full flex-col overflow-hidden"
id="tour-designer-blocks"
>
<div className="bg-background/60 flex-shrink-0 border-b p-2">
<div className="relative mb-2">
<Search className="text-muted-foreground absolute top-1/2 left-2 h-3.5 w-3.5 -translate-y-1/2" />
@@ -493,4 +497,3 @@ export function ActionLibraryPanel({ collapsed, onCollapse }: ActionLibraryPanel
// Wrap in React.memo to prevent unnecessary re-renders causing flashing in categories
export default React.memo(ActionLibraryPanel);

View File

@@ -155,9 +155,12 @@ function projectActionForDesign(
pluginVersion: action.source.pluginVersion,
baseActionId: action.source.baseActionId,
},
execution: action.execution ? projectExecutionDescriptor(action.execution) : null,
execution: action.execution
? projectExecutionDescriptor(action.execution)
: null,
parameterKeysOrValues: parameterProjection,
children: action.children?.map(c => projectActionForDesign(c, options)) ?? [],
children:
action.children?.map((c) => projectActionForDesign(c, options)) ?? [],
};
if (options.includeActionNames) {
@@ -176,16 +179,16 @@ function projectExecutionDescriptor(
timeoutMs: exec.timeoutMs ?? null,
ros2: exec.ros2
? {
topic: exec.ros2.topic ?? null,
service: exec.ros2.service ?? null,
action: exec.ros2.action ?? null,
}
topic: exec.ros2.topic ?? null,
service: exec.ros2.service ?? null,
action: exec.ros2.action ?? null,
}
: null,
rest: exec.rest
? {
method: exec.rest.method,
path: exec.rest.path,
}
method: exec.rest.method,
path: exec.rest.path,
}
: null,
};
}
@@ -244,12 +247,14 @@ export async function computeActionSignature(
baseActionId: def.baseActionId ?? null,
execution: def.execution
? {
transport: def.execution.transport,
retryable: def.execution.retryable ?? false,
timeoutMs: def.execution.timeoutMs ?? null,
}
transport: def.execution.transport,
retryable: def.execution.retryable ?? false,
timeoutMs: def.execution.timeoutMs ?? null,
}
: null,
schema: def.parameterSchemaRaw
? canonicalize(def.parameterSchemaRaw)
: null,
schema: def.parameterSchemaRaw ? canonicalize(def.parameterSchemaRaw) : null,
};
return hashObject(projection);
}
@@ -271,29 +276,33 @@ export async function computeDesignHash(
const sortedSteps = steps.slice().sort((a, b) => a.order - b.order);
// 2. Map hierarchically (Merkle style)
const stepHashes = await Promise.all(sortedSteps.map(async (s) => {
// Action hashes
const actionHashes = await Promise.all(s.actions.map(a => hashObject(projectActionForDesign(a, options))));
const stepHashes = await Promise.all(
sortedSteps.map(async (s) => {
// Action hashes
const actionHashes = await Promise.all(
s.actions.map((a) => hashObject(projectActionForDesign(a, options))),
);
// Step hash
const pStep = {
id: s.id,
type: s.type,
order: s.order,
trigger: {
type: s.trigger.type,
conditions: canonicalize(s.trigger.conditions),
},
actions: actionHashes,
...(options.includeStepNames ? { name: s.name } : {}),
};
return hashObject(pStep);
}));
// Step hash
const pStep = {
id: s.id,
type: s.type,
order: s.order,
trigger: {
type: s.trigger.type,
conditions: canonicalize(s.trigger.conditions),
},
actions: actionHashes,
...(options.includeStepNames ? { name: s.name } : {}),
};
return hashObject(pStep);
}),
);
// 3. Aggregate design hash
return hashObject({
steps: stepHashes,
count: steps.length
count: steps.length,
});
}

View File

@@ -93,7 +93,7 @@ export interface DesignerState {
parentId: string | null;
index: number;
action: ExperimentAction;
} | null
} | null,
) => void;
/* ------------------------------ Mutators --------------------------------- */
@@ -109,10 +109,20 @@ export interface DesignerState {
reorderStep: (from: number, to: number) => void;
// Actions
upsertAction: (stepId: string, action: ExperimentAction, parentId?: string | null, index?: number) => 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;
moveAction: (
stepId: string,
actionId: string,
newParentId: string | null,
newIndex: number,
) => void;
// Dirty
markDirty: (id: string) => void;
@@ -173,8 +183,7 @@ function cloneSteps(steps: ExperimentStep[]): ExperimentStep[] {
}
function reindexSteps(steps: ExperimentStep[]): ExperimentStep[] {
return steps
.map((s, idx) => ({ ...s, order: idx }));
return steps.map((s, idx) => ({ ...s, order: idx }));
}
function reindexActions(actions: ExperimentAction[]): ExperimentAction[] {
@@ -257,298 +266,331 @@ function insertActionIntoTree(
export const createDesignerStore = (props: {
initialSteps?: ExperimentStep[];
}) => create<DesignerState>((set, get) => ({
steps: props.initialSteps ? reindexSteps(cloneSteps(props.initialSteps)) : [],
dirtyEntities: new Set<string>(),
validationIssues: {},
actionSignatureIndex: new Map(),
actionSignatureDrift: new Set(),
pendingSave: false,
versionStrategy: "auto_minor" as VersionStrategy,
autoSaveEnabled: true,
busyHashing: false,
busyValidating: false,
insertionProjection: null,
}) =>
create<DesignerState>((set, get) => ({
steps: props.initialSteps
? reindexSteps(cloneSteps(props.initialSteps))
: [],
dirtyEntities: new Set<string>(),
validationIssues: {},
actionSignatureIndex: new Map(),
actionSignatureDrift: new Set(),
pendingSave: false,
versionStrategy: "auto_minor" as VersionStrategy,
autoSaveEnabled: true,
busyHashing: false,
busyValidating: false,
insertionProjection: null,
/* ------------------------------ Selection -------------------------------- */
selectStep: (id) =>
set({
selectedStepId: id,
selectedActionId: id ? get().selectedActionId : undefined,
}),
selectAction: (stepId, actionId) =>
set({
selectedStepId: stepId,
selectedActionId: actionId,
}),
/* ------------------------------ Selection -------------------------------- */
selectStep: (id) =>
set({
selectedStepId: id,
selectedActionId: id ? get().selectedActionId : undefined,
}),
selectAction: (stepId, actionId) =>
set({
selectedStepId: stepId,
selectedActionId: actionId,
}),
/* -------------------------------- Steps ---------------------------------- */
setSteps: (steps) =>
set(() => ({
steps: reindexSteps(cloneSteps(steps)),
dirtyEntities: new Set<string>(), // assume authoritative load
})),
/* -------------------------------- Steps ---------------------------------- */
setSteps: (steps) =>
set(() => ({
steps: reindexSteps(cloneSteps(steps)),
dirtyEntities: new Set<string>(), // assume authoritative load
})),
upsertStep: (step) =>
set((state) => {
const idx = state.steps.findIndex((s) => s.id === step.id);
let steps: ExperimentStep[];
if (idx >= 0) {
steps = [...state.steps];
steps[idx] = { ...step };
} else {
steps = [...state.steps, { ...step, order: state.steps.length }];
}
return {
steps: reindexSteps(steps),
dirtyEntities: new Set([...state.dirtyEntities, step.id]),
};
}),
upsertStep: (step) =>
set((state) => {
const idx = state.steps.findIndex((s) => s.id === step.id);
let steps: ExperimentStep[];
if (idx >= 0) {
steps = [...state.steps];
steps[idx] = { ...step };
} else {
steps = [...state.steps, { ...step, order: state.steps.length }];
}
return {
steps: reindexSteps(steps),
dirtyEntities: new Set([...state.dirtyEntities, step.id]),
};
}),
removeStep: (stepId) =>
set((state) => {
const steps = state.steps.filter((s) => s.id !== stepId);
const dirty = new Set(state.dirtyEntities);
dirty.add(stepId);
return {
steps: reindexSteps(steps),
dirtyEntities: dirty,
selectedStepId:
state.selectedStepId === stepId ? undefined : state.selectedStepId,
selectedActionId: undefined,
};
}),
removeStep: (stepId) =>
set((state) => {
const steps = state.steps.filter((s) => s.id !== stepId);
const dirty = new Set(state.dirtyEntities);
dirty.add(stepId);
return {
steps: reindexSteps(steps),
dirtyEntities: dirty,
selectedStepId:
state.selectedStepId === stepId ? undefined : state.selectedStepId,
selectedActionId: undefined,
};
}),
reorderStep: (from: number, to: number) =>
set((state: DesignerState) => {
if (
from < 0 ||
to < 0 ||
from >= state.steps.length ||
to >= state.steps.length ||
from === to
) {
return state;
}
const stepsDraft = [...state.steps];
const [moved] = stepsDraft.splice(from, 1);
if (!moved) return state;
stepsDraft.splice(to, 0, moved);
const reindexed = reindexSteps(stepsDraft);
return {
steps: reindexed,
dirtyEntities: new Set<string>([
...state.dirtyEntities,
...reindexed.map((s) => s.id),
]),
};
}),
reorderStep: (from: number, to: number) =>
set((state: DesignerState) => {
if (
from < 0 ||
to < 0 ||
from >= state.steps.length ||
to >= state.steps.length ||
from === to
) {
return state;
}
const stepsDraft = [...state.steps];
const [moved] = stepsDraft.splice(from, 1);
if (!moved) return state;
stepsDraft.splice(to, 0, moved);
const reindexed = reindexSteps(stepsDraft);
return {
steps: reindexed,
dirtyEntities: new Set<string>([
...state.dirtyEntities,
...reindexed.map((s) => s.id),
]),
};
}),
/* ------------------------------- Actions --------------------------------- */
upsertAction: (stepId: string, action: ExperimentAction, parentId: string | null = null, index?: number) =>
set((state: DesignerState) => {
const stepsDraft: ExperimentStep[] = state.steps.map((s) => {
if (s.id !== stepId) return s;
/* ------------------------------- Actions --------------------------------- */
upsertAction: (
stepId: string,
action: ExperimentAction,
parentId: string | null = null,
index?: number,
) =>
set((state: DesignerState) => {
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;
// 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)
actions: insertActionIntoTree(
s.actions,
action,
parentId,
insertIndex,
),
};
}
// 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)
steps: stepsDraft,
dirtyEntities: new Set<string>([
...state.dirtyEntities,
action.id,
stepId,
]),
};
});
return {
steps: stepsDraft,
dirtyEntities: new Set<string>([
...state.dirtyEntities,
action.id,
stepId,
]),
};
}),
}),
removeAction: (stepId: string, actionId: string) =>
set((state: DesignerState) => {
const stepsDraft: ExperimentStep[] = state.steps.map((s) =>
s.id === stepId
? {
...s,
actions: removeActionFromTree(s.actions, actionId),
}
: s,
);
const dirty = new Set<string>(state.dirtyEntities);
dirty.add(actionId);
dirty.add(stepId);
return {
steps: stepsDraft,
dirtyEntities: dirty,
selectedActionId:
state.selectedActionId === actionId
? undefined
: state.selectedActionId,
};
}),
removeAction: (stepId: string, actionId: string) =>
set((state: DesignerState) => {
const stepsDraft: ExperimentStep[] = state.steps.map((s) =>
s.id === stepId
? {
...s,
actions: removeActionFromTree(s.actions, actionId),
}
: s,
);
const dirty = new Set<string>(state.dirtyEntities);
dirty.add(actionId);
dirty.add(stepId);
return {
steps: stepsDraft,
dirtyEntities: dirty,
selectedActionId:
state.selectedActionId === actionId
? undefined
: state.selectedActionId,
};
}),
moveAction: (stepId: string, actionId: string, newParentId: string | null, newIndex: number) =>
set((state: DesignerState) => {
const stepsDraft = state.steps.map((s) => {
if (s.id !== stepId) return s;
moveAction: (
stepId: string,
actionId: string,
newParentId: string | null,
newIndex: number,
) =>
set((state: DesignerState) => {
const stepsDraft = state.steps.map((s) => {
if (s.id !== stepId) return s;
const actionToMove = findActionById(s.actions, actionId);
if (!actionToMove) return s;
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, actionId]),
};
}),
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,
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)
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 }),
setInsertionProjection: (projection) =>
set({ insertionProjection: projection }),
/* -------------------------------- Dirty ---------------------------------- */
markDirty: (id: string) =>
set((state: DesignerState) => ({
dirtyEntities: state.dirtyEntities.has(id)
? state.dirtyEntities
: new Set<string>([...state.dirtyEntities, id]),
})),
clearDirty: (id: string) =>
set((state: DesignerState) => {
if (!state.dirtyEntities.has(id)) return state;
const next = new Set(state.dirtyEntities);
next.delete(id);
return { dirtyEntities: next };
}),
clearAllDirty: () => set({ dirtyEntities: new Set<string>() }),
/* -------------------------------- Dirty ---------------------------------- */
markDirty: (id: string) =>
set((state: DesignerState) => ({
dirtyEntities: state.dirtyEntities.has(id)
? state.dirtyEntities
: new Set<string>([...state.dirtyEntities, id]),
})),
clearDirty: (id: string) =>
set((state: DesignerState) => {
if (!state.dirtyEntities.has(id)) return state;
const next = new Set(state.dirtyEntities);
next.delete(id);
return { dirtyEntities: next };
}),
clearAllDirty: () => set({ dirtyEntities: new Set<string>() }),
/* ------------------------------- Hashing --------------------------------- */
recomputeHash: async (options?: { forceFull?: boolean }) => {
const { steps, incremental } = get();
if (steps.length === 0) {
set({ currentDesignHash: undefined });
return null;
}
set({ busyHashing: true });
try {
const result = await computeIncrementalDesignHash(
steps,
options?.forceFull ? undefined : incremental,
);
set({
currentDesignHash: result.designHash,
incremental: {
actionHashes: result.actionHashes,
stepHashes: result.stepHashes,
},
});
return result;
} finally {
set({ busyHashing: false });
}
},
setPersistedHash: (hash: string) => set({ lastPersistedHash: hash }),
setValidatedHash: (hash: string) => set({ lastValidatedHash: hash }),
/* ----------------------------- Validation -------------------------------- */
setValidationIssues: (entityId: string, issues: ValidationIssue[]) =>
set((state: DesignerState) => ({
validationIssues: {
...state.validationIssues,
[entityId]: issues,
},
})),
clearValidationIssues: (entityId: string) =>
set((state: DesignerState) => {
if (!state.validationIssues[entityId]) return state;
const next = { ...state.validationIssues };
delete next[entityId];
return { validationIssues: next };
}),
clearAllValidationIssues: () => set({ validationIssues: {} }),
/* ------------------------- Action Signature Drift ------------------------ */
setActionSignature: (actionId: string, signature: string) =>
set((state: DesignerState) => {
const index = new Map(state.actionSignatureIndex);
index.set(actionId, signature);
return { actionSignatureIndex: index };
}),
detectActionSignatureDrift: (
action: ExperimentAction,
latestSignature: string,
) =>
set((state: DesignerState) => {
const current = state.actionSignatureIndex.get(action.id);
if (!current) {
const idx = new Map(state.actionSignatureIndex);
idx.set(action.id, latestSignature);
return { actionSignatureIndex: idx };
/* ------------------------------- Hashing --------------------------------- */
recomputeHash: async (options?: { forceFull?: boolean }) => {
const { steps, incremental } = get();
if (steps.length === 0) {
set({ currentDesignHash: undefined });
return null;
}
if (current === latestSignature) return {};
const drift = new Set(state.actionSignatureDrift);
drift.add(action.id);
return { actionSignatureDrift: drift };
}),
clearActionSignatureDrift: (actionId: string) =>
set((state: DesignerState) => {
if (!state.actionSignatureDrift.has(actionId)) return state;
const next = new Set(state.actionSignatureDrift);
next.delete(actionId);
return { actionSignatureDrift: next };
}),
set({ busyHashing: true });
try {
const result = await computeIncrementalDesignHash(
steps,
options?.forceFull ? undefined : incremental,
);
set({
currentDesignHash: result.designHash,
incremental: {
actionHashes: result.actionHashes,
stepHashes: result.stepHashes,
},
});
return result;
} finally {
set({ busyHashing: false });
}
},
/* ------------------------------- Save Flow -------------------------------- */
setPendingSave: (pending: boolean) => set({ pendingSave: pending }),
recordConflict: (serverHash: string, localHash: string) =>
set({
conflict: { serverHash, localHash, at: new Date() },
pendingSave: false,
}),
clearConflict: () => set({ conflict: undefined }),
setVersionStrategy: (strategy: VersionStrategy) =>
set({ versionStrategy: strategy }),
setAutoSaveEnabled: (enabled: boolean) => set({ autoSaveEnabled: enabled }),
setPersistedHash: (hash: string) => set({ lastPersistedHash: hash }),
setValidatedHash: (hash: string) => set({ lastValidatedHash: hash }),
/* ------------------------------ Server Sync ------------------------------ */
applyServerSync: (payload: {
steps: ExperimentStep[];
persistedHash?: string;
validatedHash?: string;
}) =>
set((state: DesignerState) => {
const syncedSteps = reindexSteps(cloneSteps(payload.steps));
const dirty = new Set<string>();
return {
steps: syncedSteps,
lastPersistedHash: payload.persistedHash ?? state.lastPersistedHash,
lastValidatedHash: payload.validatedHash ?? state.lastValidatedHash,
dirtyEntities: dirty,
conflict: undefined,
};
}),
}));
/* ----------------------------- Validation -------------------------------- */
setValidationIssues: (entityId: string, issues: ValidationIssue[]) =>
set((state: DesignerState) => ({
validationIssues: {
...state.validationIssues,
[entityId]: issues,
},
})),
clearValidationIssues: (entityId: string) =>
set((state: DesignerState) => {
if (!state.validationIssues[entityId]) return state;
const next = { ...state.validationIssues };
delete next[entityId];
return { validationIssues: next };
}),
clearAllValidationIssues: () => set({ validationIssues: {} }),
/* ------------------------- Action Signature Drift ------------------------ */
setActionSignature: (actionId: string, signature: string) =>
set((state: DesignerState) => {
const index = new Map(state.actionSignatureIndex);
index.set(actionId, signature);
return { actionSignatureIndex: index };
}),
detectActionSignatureDrift: (
action: ExperimentAction,
latestSignature: string,
) =>
set((state: DesignerState) => {
const current = state.actionSignatureIndex.get(action.id);
if (!current) {
const idx = new Map(state.actionSignatureIndex);
idx.set(action.id, latestSignature);
return { actionSignatureIndex: idx };
}
if (current === latestSignature) return {};
const drift = new Set(state.actionSignatureDrift);
drift.add(action.id);
return { actionSignatureDrift: drift };
}),
clearActionSignatureDrift: (actionId: string) =>
set((state: DesignerState) => {
if (!state.actionSignatureDrift.has(actionId)) return state;
const next = new Set(state.actionSignatureDrift);
next.delete(actionId);
return { actionSignatureDrift: next };
}),
/* ------------------------------- Save Flow -------------------------------- */
setPendingSave: (pending: boolean) => set({ pendingSave: pending }),
recordConflict: (serverHash: string, localHash: string) =>
set({
conflict: { serverHash, localHash, at: new Date() },
pendingSave: false,
}),
clearConflict: () => set({ conflict: undefined }),
setVersionStrategy: (strategy: VersionStrategy) =>
set({ versionStrategy: strategy }),
setAutoSaveEnabled: (enabled: boolean) => set({ autoSaveEnabled: enabled }),
/* ------------------------------ Server Sync ------------------------------ */
applyServerSync: (payload: {
steps: ExperimentStep[];
persistedHash?: string;
validatedHash?: string;
}) =>
set((state: DesignerState) => {
const syncedSteps = reindexSteps(cloneSteps(payload.steps));
const dirty = new Set<string>();
return {
steps: syncedSteps,
lastPersistedHash: payload.persistedHash ?? state.lastPersistedHash,
lastValidatedHash: payload.validatedHash ?? state.lastValidatedHash,
dirtyEntities: dirty,
conflict: undefined,
};
}),
}));
export const useDesignerStore = createDesignerStore({});

View File

@@ -51,10 +51,7 @@ export interface ValidationResult {
// Steps should ALWAYS execute sequentially
// Parallel/conditional/loop execution happens at the ACTION level, not step level
const VALID_STEP_TYPES: StepType[] = [
"sequential",
"conditional",
];
const VALID_STEP_TYPES: StepType[] = ["sequential", "conditional"];
const VALID_TRIGGER_TYPES: TriggerType[] = [
"trial_start",
"participant_action",

View File

@@ -5,24 +5,30 @@ import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { Button } from "~/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "~/components/ui/form";
import { Input } from "~/components/ui/input";
import { Textarea } from "~/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { Badge } from "~/components/ui/badge";
import { toast } from "sonner";
import { api } from "~/trpc/react";
@@ -31,264 +37,307 @@ import { Save, ExternalLink } from "lucide-react";
import Link from "next/link";
const formSchema = z.object({
name: z.string().min(2, {
message: "Name must be at least 2 characters.",
}),
description: z.string().optional(),
status: z.enum(experimentStatusEnum.enumValues),
name: z.string().min(2, {
message: "Name must be at least 2 characters.",
}),
description: z.string().optional(),
status: z.enum(experimentStatusEnum.enumValues),
});
interface SettingsTabProps {
experiment: {
id: string;
name: string;
description: string | null;
status: string;
studyId: string;
createdAt: Date;
updatedAt: Date;
study: {
id: string;
name: string;
};
};
designStats?: {
stepCount: number;
actionCount: number;
experiment: {
id: string;
name: string;
description: string | null;
status: string;
studyId: string;
createdAt: Date;
updatedAt: Date;
study: {
id: string;
name: string;
};
};
designStats?: {
stepCount: number;
actionCount: number;
};
}
export function SettingsTab({ experiment, designStats }: SettingsTabProps) {
const utils = api.useUtils();
const updateExperiment = api.experiments.update.useMutation({
onSuccess: async () => {
toast.success("Experiment settings saved successfully");
// Invalidate experiments list to refresh data
await utils.experiments.list.invalidate();
},
onError: (error) => {
toast.error(`Error saving settings: ${error.message}`);
},
const utils = api.useUtils();
const updateExperiment = api.experiments.update.useMutation({
onSuccess: async () => {
toast.success("Experiment settings saved successfully");
// Invalidate experiments list to refresh data
await utils.experiments.list.invalidate();
},
onError: (error) => {
toast.error(`Error saving settings: ${error.message}`);
},
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
name: experiment.name,
description: experiment.description ?? "",
status: experiment.status as z.infer<typeof formSchema>["status"],
},
});
function onSubmit(values: z.infer<typeof formSchema>) {
updateExperiment.mutate({
id: experiment.id,
name: values.name,
description: values.description,
status: values.status,
});
}
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
name: experiment.name,
description: experiment.description ?? "",
status: experiment.status as z.infer<typeof formSchema>["status"],
},
});
const isDirty = form.formState.isDirty;
function onSubmit(values: z.infer<typeof formSchema>) {
updateExperiment.mutate({
id: experiment.id,
name: values.name,
description: values.description,
status: values.status,
});
}
const isDirty = form.formState.isDirty;
return (
<div className="h-full overflow-y-auto p-6">
<div className="space-y-6">
{/* Header */}
<div>
<h2 className="text-2xl font-bold tracking-tight">Experiment Settings</h2>
<p className="text-muted-foreground mt-1">
Configure experiment metadata and status
</p>
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Left Column: Basic Information (Spans 2) */}
<div className="md:col-span-2 space-y-6">
<Card className="h-full">
<CardHeader>
<CardTitle>Basic Information</CardTitle>
<CardDescription>
The name and description help identify this experiment
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Experiment name" {...field} />
</FormControl>
<FormDescription>
A clear, descriptive name for your experiment
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="Describe your experiment goals, methodology, and expected outcomes..."
className="resize-none min-h-[300px]"
{...field}
/>
</FormControl>
<FormDescription>
Detailed description of the experiment purpose and design
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
</div>
{/* Right Column: Status & Metadata (Spans 1) */}
<div className="space-y-6">
{/* Status Card */}
<Card>
<CardHeader>
<CardTitle>Status</CardTitle>
<CardDescription>
Track lifecycle stage
</CardDescription>
</CardHeader>
<CardContent>
<FormField
control={form.control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>Current Status</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a status" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="draft">
<div className="flex items-center gap-2">
<Badge variant="secondary">Draft</Badge>
<span className="text-xs text-muted-foreground">WIP</span>
</div>
</SelectItem>
<SelectItem value="testing">
<div className="flex items-center gap-2">
<Badge variant="outline">Testing</Badge>
<span className="text-xs text-muted-foreground">Validation</span>
</div>
</SelectItem>
<SelectItem value="ready">
<div className="flex items-center gap-2">
<Badge variant="default" className="bg-green-500">Ready</Badge>
<span className="text-xs text-muted-foreground">Live</span>
</div>
</SelectItem>
<SelectItem value="deprecated">
<div className="flex items-center gap-2">
<Badge variant="destructive">Deprecated</Badge>
<span className="text-xs text-muted-foreground">Retired</span>
</div>
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
{/* Metadata Card */}
<Card>
<CardHeader>
<CardTitle>Metadata</CardTitle>
<CardDescription>
Read-only information
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-3">
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">Study</p>
<Link
href={`/studies/${experiment.study.id}`}
className="text-sm hover:underline flex items-center gap-1 text-primary truncate"
>
{experiment.study.name}
<ExternalLink className="h-3 w-3 flex-shrink-0" />
</Link>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">Experiment ID</p>
<p className="text-xs font-mono bg-muted p-1 rounded select-all">{experiment.id.split('-')[0]}...</p>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">Created</p>
<p className="text-xs">{new Date(experiment.createdAt).toLocaleDateString()}</p>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">Updated</p>
<p className="text-xs">{new Date(experiment.updatedAt).toLocaleDateString()}</p>
</div>
</div>
</div>
{designStats && (
<div className="pt-4 border-t">
<p className="text-xs font-medium text-muted-foreground mb-2">Statistics</p>
<div className="flex flex-wrap gap-2">
<div className="flex items-center gap-1.5 bg-muted/50 px-2 py-1 rounded text-xs">
<span className="font-semibold">{designStats.stepCount}</span>
<span className="text-muted-foreground">Steps</span>
</div>
<div className="flex items-center gap-1.5 bg-muted/50 px-2 py-1 rounded text-xs">
<span className="font-semibold">{designStats.actionCount}</span>
<span className="text-muted-foreground">Actions</span>
</div>
</div>
</div>
)}
</CardContent>
</Card>
</div>
</div>
{/* Save Button */}
<div className="flex justify-end pt-4 border-t">
<Button
type="submit"
disabled={updateExperiment.isPending || !isDirty}
className="min-w-[120px]"
>
{updateExperiment.isPending ? (
"Saving..."
) : (
<>
<Save className="h-4 w-4 mr-2" />
Save Changes
</>
)}
</Button>
</div>
</form>
</Form>
</div>
return (
<div className="h-full overflow-y-auto p-6">
<div className="space-y-6">
{/* Header */}
<div>
<h2 className="text-2xl font-bold tracking-tight">
Experiment Settings
</h2>
<p className="text-muted-foreground mt-1">
Configure experiment metadata and status
</p>
</div>
);
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
{/* Left Column: Basic Information (Spans 2) */}
<div className="space-y-6 md:col-span-2">
<Card className="h-full">
<CardHeader>
<CardTitle>Basic Information</CardTitle>
<CardDescription>
The name and description help identify this experiment
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Experiment name" {...field} />
</FormControl>
<FormDescription>
A clear, descriptive name for your experiment
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="Describe your experiment goals, methodology, and expected outcomes..."
className="min-h-[300px] resize-none"
{...field}
/>
</FormControl>
<FormDescription>
Detailed description of the experiment purpose and
design
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
</div>
{/* Right Column: Status & Metadata (Spans 1) */}
<div className="space-y-6">
{/* Status Card */}
<Card>
<CardHeader>
<CardTitle>Status</CardTitle>
<CardDescription>Track lifecycle stage</CardDescription>
</CardHeader>
<CardContent>
<FormField
control={form.control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>Current Status</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a status" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="draft">
<div className="flex items-center gap-2">
<Badge variant="secondary">Draft</Badge>
<span className="text-muted-foreground text-xs">
WIP
</span>
</div>
</SelectItem>
<SelectItem value="testing">
<div className="flex items-center gap-2">
<Badge variant="outline">Testing</Badge>
<span className="text-muted-foreground text-xs">
Validation
</span>
</div>
</SelectItem>
<SelectItem value="ready">
<div className="flex items-center gap-2">
<Badge
variant="default"
className="bg-green-500"
>
Ready
</Badge>
<span className="text-muted-foreground text-xs">
Live
</span>
</div>
</SelectItem>
<SelectItem value="deprecated">
<div className="flex items-center gap-2">
<Badge variant="destructive">
Deprecated
</Badge>
<span className="text-muted-foreground text-xs">
Retired
</span>
</div>
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
{/* Metadata Card */}
<Card>
<CardHeader>
<CardTitle>Metadata</CardTitle>
<CardDescription>Read-only information</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-3">
<div>
<p className="text-muted-foreground mb-1 text-xs font-medium">
Study
</p>
<Link
href={`/studies/${experiment.study.id}`}
className="text-primary flex items-center gap-1 truncate text-sm hover:underline"
>
{experiment.study.name}
<ExternalLink className="h-3 w-3 flex-shrink-0" />
</Link>
</div>
<div>
<p className="text-muted-foreground mb-1 text-xs font-medium">
Experiment ID
</p>
<p className="bg-muted rounded p-1 font-mono text-xs select-all">
{experiment.id.split("-")[0]}...
</p>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<p className="text-muted-foreground mb-1 text-xs font-medium">
Created
</p>
<p className="text-xs">
{new Date(
experiment.createdAt,
).toLocaleDateString()}
</p>
</div>
<div>
<p className="text-muted-foreground mb-1 text-xs font-medium">
Updated
</p>
<p className="text-xs">
{new Date(
experiment.updatedAt,
).toLocaleDateString()}
</p>
</div>
</div>
</div>
{designStats && (
<div className="border-t pt-4">
<p className="text-muted-foreground mb-2 text-xs font-medium">
Statistics
</p>
<div className="flex flex-wrap gap-2">
<div className="bg-muted/50 flex items-center gap-1.5 rounded px-2 py-1 text-xs">
<span className="font-semibold">
{designStats.stepCount}
</span>
<span className="text-muted-foreground">Steps</span>
</div>
<div className="bg-muted/50 flex items-center gap-1.5 rounded px-2 py-1 text-xs">
<span className="font-semibold">
{designStats.actionCount}
</span>
<span className="text-muted-foreground">
Actions
</span>
</div>
</div>
</div>
)}
</CardContent>
</Card>
</div>
</div>
{/* Save Button */}
<div className="flex justify-end border-t pt-4">
<Button
type="submit"
disabled={updateExperiment.isPending || !isDirty}
className="min-w-[120px]"
>
{updateExperiment.isPending ? (
"Saving..."
) : (
<>
<Save className="mr-2 h-4 w-4" />
Save Changes
</>
)}
</Button>
</div>
</form>
</Form>
</div>
</div>
);
}

View File

@@ -105,10 +105,12 @@ function ExperimentActionsCell({ experiment }: { experiment: Experiment }) {
variant="ghost"
size="icon"
asChild
className="h-8 w-8 text-muted-foreground hover:text-primary"
className="text-muted-foreground hover:text-primary h-8 w-8"
title="Open Designer"
>
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/designer`}>
<Link
href={`/studies/${experiment.studyId}/experiments/${experiment.id}/designer`}
>
<LayoutTemplate className="h-4 w-4" />
<span className="sr-only">Design</span>
</Link>
@@ -119,7 +121,7 @@ function ExperimentActionsCell({ experiment }: { experiment: Experiment }) {
variant="ghost"
size="icon"
onClick={handleDelete}
className="h-8 w-8 text-muted-foreground hover:text-destructive"
className="text-muted-foreground hover:text-destructive h-8 w-8"
title="Delete Experiment"
>
<Trash2 className="h-4 w-4" />

View File

@@ -1,371 +1,423 @@
"use client";
import React, { createContext, useContext, useEffect, useRef } from "react";
import React, {
createContext,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { driver, type Driver } from "driver.js";
import "driver.js/dist/driver.css";
import { useTheme } from "next-themes";
import { usePathname } from "next/navigation";
import { usePathname, useRouter } from "next/navigation";
import Cookies from "js-cookie";
type TourType = "dashboard" | "study_creation" | "participant_creation" | "designer" | "wizard" | "analytics" | "full_platform";
type TourType =
| "dashboard"
| "study_creation"
| "participant_creation"
| "designer"
| "wizard"
| "analytics"
| "full_platform";
interface TourContextType {
startTour: (tour: TourType) => void;
startTour: (tour: TourType) => void;
isTourActive: boolean;
}
const TourContext = createContext<TourContextType | undefined>(undefined);
export function useTour() {
const context = useContext(TourContext);
if (!context) {
throw new Error("useTour must be used within a TourProvider");
}
return context;
const context = useContext(TourContext);
if (!context) {
throw new Error("useTour must be used within a TourProvider");
}
return context;
}
export function TourProvider({ children }: { children: React.ReactNode }) {
const driverObj = useRef<Driver | null>(null);
const { theme } = useTheme();
const pathname = usePathname();
const driverObj = useRef<Driver | null>(null);
const [isTourActive, setIsTourActive] = useState(false);
const { theme } = useTheme();
const pathname = usePathname();
const router = useRouter();
// --- Multi-page Tour Logic ---
useEffect(() => {
// Check if we are in "Full Platform" mode (Local Storage OR Cookie)
const localMode = localStorage.getItem("hristudio_tour_mode");
const cookieMode = Cookies.get("hristudio_tour_mode");
// --- Multi-page Tour Logic ---
useEffect(() => {
// Check if we are in "Full Platform" mode (Local Storage OR Cookie)
const localMode = localStorage.getItem("hristudio_tour_mode");
const cookieMode = Cookies.get("hristudio_tour_mode");
const tourMode = localMode === "full" || cookieMode === "full" ? "full" : null;
const tourMode =
localMode === "full" || cookieMode === "full" ? "full" : null;
if (tourMode === "full") {
// Re-sync local storage if missing but cookie present
if (localMode !== "full") localStorage.setItem("hristudio_tour_mode", "full");
if (tourMode === "full") {
// Re-sync local storage if missing but cookie present
if (localMode !== "full")
localStorage.setItem("hristudio_tour_mode", "full");
// Small delay to ensure DOM is ready
const timer = setTimeout(() => {
if (pathname === "/dashboard") {
runTourSegment("dashboard");
} else if (pathname.includes("/studies/new")) {
runTourSegment("study_creation");
} else if (pathname.includes("/participants/new")) {
runTourSegment("participant_creation");
} else if (pathname.includes("/designer")) {
runTourSegment("designer");
} else if (pathname.includes("/wizard")) {
runTourSegment("wizard");
}
}, 500); // Reduced delay for snappier feel, but still safe for render
return () => clearTimeout(timer);
// Small delay to ensure DOM is ready
const timer = setTimeout(() => {
if (pathname === "/dashboard") {
runTourSegment("dashboard");
} else if (pathname.includes("/studies/new")) {
runTourSegment("study_creation");
} else if (pathname.includes("/participants/new")) {
runTourSegment("participant_creation");
} else if (pathname.includes("/designer")) {
runTourSegment("designer");
} else if (pathname.includes("/wizard")) {
runTourSegment("wizard");
}
}, [pathname]);
}, 500); // Reduced delay for snappier feel, but still safe for render
return () => clearTimeout(timer);
}
}, [pathname]);
useEffect(() => {
// Listen for custom tour triggers (from components without context access)
const handleTourTrigger = (e: Event) => {
const detail = (e as CustomEvent).detail as TourType;
if (detail) {
startTour(detail);
}
};
document.addEventListener('hristudio-start-tour', handleTourTrigger);
return () => document.removeEventListener('hristudio-start-tour', handleTourTrigger);
}, []);
const runTourSegment = (segment: "dashboard" | "study_creation" | "participant_creation" | "designer" | "wizard" | "analytics") => {
const isDark = theme === "dark";
// We add a specific class to handle dark/light overrides reliably
const themeClass = isDark ? "driverjs-theme-dark" : "driverjs-theme-light";
let steps: any[] = [];
if (segment === "dashboard") {
steps = [
{
element: "#dashboard-header",
popover: {
title: "Overview",
description: "Welcome to HRIStudio. This dashboard gives you a high-level view of your research activities, active studies, and data collection progress.",
side: "bottom",
align: "start",
},
},
{
element: "#tour-sidebar-overview",
popover: {
title: "Navigation: Overview",
description: "Quickly return to this main dashboard from anywhere in the application.",
side: "right",
},
},
{
element: "#tour-sidebar-studies",
popover: {
title: "Navigation: Studies",
description: "Manage all your research studies, IRBs, and team permissions in one place.",
side: "right",
},
},
{
element: "#tour-sidebar-study-selector",
popover: {
title: "Active Study Selector",
description: "Switch between different studies here. Selecting a study unlocks study-specific tools like the Experiment Designer and Data Analytics.",
side: "right",
},
},
{
element: "#tour-new-study",
popover: {
title: "Create a New Study",
description: "Ready to start? Click here to initialize a new research project and define your protocol.",
side: "right",
},
},
];
} else if (segment === "study_creation") {
steps = [
{
element: "#tour-study-name",
popover: {
title: "Naming Your Study",
description: "Choose a concise, descriptive name. This will properly namespace your data, logs, and robot configurations.",
side: "right",
}
},
{
element: "#tour-study-description",
popover: {
title: "Research Protocol",
description: "Add a short description of your methodology or research questions. This helps team members understand the context.",
side: "right",
}
},
{
element: "#tour-study-submit",
popover: {
title: "Initialize Project",
description: "Create the study to access the full suite of tools: Experiment Designer, Wizard Interface, and Analytics.",
side: "top",
}
}
];
} else if (segment === "participant_creation") {
steps = [
{
element: "#tour-participant-code",
popover: {
title: "Participant ID",
description: "Assign a unique code (e.g., P001) to identify this participant while maintaining anonymity.",
side: "right",
}
},
{
element: "#tour-participant-name",
popover: {
title: "Name (Optional)",
description: "You store their name for internal reference; analytics will use the ID.",
side: "right",
}
},
{
element: "#tour-participant-study-container",
popover: {
title: "Study Association",
description: "Link this participant to a specific research study to enable data collection.",
side: "right",
}
},
{
element: "#tour-participant-consent",
popover: {
title: "Informed Consent",
description: "Mandatory check to confirm you have obtained necessary ethical approvals and consent.",
side: "top",
}
},
{
element: "#tour-participant-submit",
popover: {
title: "Register",
description: "Create the participant record to begin scheduling trials.",
side: "top",
}
}
];
} else if (segment === "designer") {
steps = [
{
element: "#tour-designer-blocks",
popover: {
title: "Action Library",
description: "Drag and drop robot behaviors (Speech, Gestures, Movement) onto the canvas. Includes both core actions and those from installed plugins.",
side: "right",
},
},
{
element: "#tour-designer-canvas",
popover: {
title: "Visual Flow Canvas",
description: "Design your experiment logic here. Connect blocks to create sequences, branches, and loops for the robot to execute.",
side: "top",
},
},
{
element: "#tour-designer-properties",
popover: {
title: "Properties Panel",
description: "Select any block to configure its parameters—like speech text, speed, volume, or timeout durations.",
side: "left",
},
},
];
} else if (segment === "wizard") {
steps = [
{
element: "#tour-wizard-controls",
popover: {
title: "Wizard Dashboard",
description: "The command center for running trials. Manually trigger robot actions or override autonomous behaviors in real-time.",
side: "right",
},
},
{
element: "#tour-wizard-timeline",
popover: {
title: "Live Timeline",
description: "See exactly what the robot is doing, what's coming next, and a history of all events in the current session.",
side: "top",
},
},
{
element: "#tour-wizard-robot-status",
popover: {
title: "System Health",
description: "Monitor critical telemetry: battery levels, joint temperatures, and network latency to ensure safety.",
side: "left",
},
},
];
}
else if (segment === "analytics") {
steps = [
{
element: "#tour-analytics-table",
popover: {
title: "Study Analytics",
description: "View aggregate data across all participant sessions. Sort and filter to identify trends or specific trials.",
side: "bottom",
},
},
{
element: "#tour-analytics-filter",
popover: {
title: "Filter Data",
description: "Quickly find participants by ID or name using this search bar.",
side: "bottom",
},
},
{
element: "#tour-trial-metrics",
popover: {
title: "Trial Metrics",
description: "High-level KPIs for the selected trial: Duration, Robot Actions, and Intervention counts.",
side: "bottom",
},
},
{
element: "#tour-trial-timeline",
popover: {
title: "Video & Timeline",
description: "Watch the trial recording synced with the event timeline. Click any event to jump to that moment in the video.",
side: "right",
},
},
{
element: "#tour-trial-events",
popover: {
title: "Event Log",
description: "A detailed, searchable log of every system event, robot action, and wizard interaction.",
side: "left",
},
},
];
}
driverObj.current = driver({
showProgress: true,
animate: true,
allowClose: true,
steps: steps.map((step) => ({
...step,
popover: {
...step.popover,
popoverClass: `driver-popover-override ${themeClass}`,
},
})),
onDestroyed: () => {
// Persistence handled by localStorage state
}
});
driverObj.current.drive();
useEffect(() => {
// Listen for custom tour triggers (from components without context access)
const handleTourTrigger = (e: Event) => {
const detail = (e as CustomEvent).detail as TourType;
if (detail) {
startTour(detail);
}
};
const startTour = (tour: TourType) => {
if (tour === "full_platform") {
localStorage.setItem("hristudio_tour_mode", "full");
Cookies.set("hristudio_tour_mode", "full", { expires: 7 }); // 7 days persistence
document.addEventListener("hristudio-start-tour", handleTourTrigger);
return () =>
document.removeEventListener("hristudio-start-tour", handleTourTrigger);
}, []);
// Trigger current page immediately
if (pathname === "/dashboard") runTourSegment("dashboard");
else if (pathname.includes("/studies/new")) runTourSegment("study_creation");
else if (pathname.includes("/participants/new")) runTourSegment("participant_creation");
else if (pathname.includes("/designer")) runTourSegment("designer");
else if (pathname.includes("/wizard")) runTourSegment("wizard");
else if (pathname.includes("/analysis")) runTourSegment("analytics");
else runTourSegment("dashboard"); // Fallback
} else {
localStorage.setItem("hristudio_tour_mode", "manual");
Cookies.remove("hristudio_tour_mode");
const runTourSegment = (
segment:
| "dashboard"
| "study_creation"
| "participant_creation"
| "designer"
| "wizard"
| "analytics",
) => {
const isDark = theme === "dark";
// We add a specific class to handle dark/light overrides reliably
const themeClass = isDark ? "driverjs-theme-dark" : "driverjs-theme-light";
if (tour === "dashboard") runTourSegment("dashboard");
if (tour === "study_creation") runTourSegment("study_creation");
if (tour === "participant_creation") runTourSegment("participant_creation");
if (tour === "designer") runTourSegment("designer");
if (tour === "wizard") runTourSegment("wizard");
if (tour === "analytics") runTourSegment("analytics");
}
};
let steps: any[] = [];
return (
<TourContext.Provider value={{ startTour }}>
{children}
<style jsx global>{`
if (segment === "dashboard") {
steps = [
{
element: "#dashboard-header",
popover: {
title: "Overview",
description:
"Welcome to HRIStudio. This dashboard gives you a high-level view of your research activities, active studies, and data collection progress.",
side: "bottom",
align: "start",
},
},
{
element: "#tour-sidebar-overview",
popover: {
title: "Navigation: Overview",
description:
"Quickly return to this main dashboard from anywhere in the application.",
side: "right",
},
},
{
element: "#tour-sidebar-studies",
popover: {
title: "Navigation: Studies",
description:
"Manage all your research studies, IRBs, and team permissions in one place.",
side: "right",
},
},
{
element: "#tour-sidebar-study-selector",
popover: {
title: "Active Study Selector",
description:
"Switch between different studies here. Selecting a study unlocks study-specific tools like the Experiment Designer and Data Analytics.",
side: "right",
},
},
{
element: "#tour-new-study",
popover: {
title: "Create a New Study",
description:
"Ready to start? Click here to initialize a new research project and define your protocol.",
side: "right",
},
},
];
} else if (segment === "study_creation") {
steps = [
{
element: "#tour-study-name",
popover: {
title: "Naming Your Study",
description:
"Choose a concise, descriptive name. This will properly namespace your data, logs, and robot configurations.",
side: "right",
},
},
{
element: "#tour-study-description",
popover: {
title: "Research Protocol",
description:
"Add a short description of your methodology or research questions. This helps team members understand the context.",
side: "right",
},
},
{
element: "#tour-study-submit",
popover: {
title: "Initialize Project",
description:
"Create the study to access the full suite of tools: Experiment Designer, Wizard Interface, and Analytics.",
side: "top",
},
},
];
} else if (segment === "participant_creation") {
steps = [
{
element: "#tour-participant-code",
popover: {
title: "Participant ID",
description:
"Assign a unique code (e.g., P001) to identify this participant while maintaining anonymity.",
side: "right",
},
},
{
element: "#tour-participant-name",
popover: {
title: "Name (Optional)",
description:
"You store their name for internal reference; analytics will use the ID.",
side: "right",
},
},
{
element: "#tour-participant-study-container",
popover: {
title: "Study Association",
description:
"Link this participant to a specific research study to enable data collection.",
side: "right",
},
},
{
element: "#tour-participant-consent",
popover: {
title: "Informed Consent",
description:
"Mandatory check to confirm you have obtained necessary ethical approvals and consent.",
side: "top",
},
},
{
element: "#tour-participant-submit",
popover: {
title: "Register",
description:
"Create the participant record to begin scheduling trials.",
side: "top",
},
},
];
} else if (segment === "designer") {
steps = [
{
element: "#tour-designer-blocks",
popover: {
title: "Action Library",
description:
"Drag and drop robot behaviors (Speech, Gestures, Movement) onto the canvas. Includes both core actions and those from installed plugins.",
side: "right",
},
},
{
element: "#tour-designer-canvas",
popover: {
title: "Visual Flow Canvas",
description:
"Design your experiment logic here. Connect blocks to create sequences, branches, and loops for the robot to execute.",
side: "top",
},
},
{
element: "#tour-designer-properties",
popover: {
title: "Properties Panel",
description:
"Select any block to configure its parameters—like speech text, speed, volume, or timeout durations.",
side: "left",
},
},
];
} else if (segment === "wizard") {
steps = [
{
element: "#tour-wizard-controls",
popover: {
title: "Wizard Dashboard",
description:
"The command center for running trials. Manually trigger robot actions or override autonomous behaviors in real-time.",
side: "right",
},
},
{
element: "#tour-wizard-timeline",
popover: {
title: "Live Timeline",
description:
"See exactly what the robot is doing, what's coming next, and a history of all events in the current session.",
side: "top",
},
},
{
element: "#tour-wizard-robot-status",
popover: {
title: "System Health",
description:
"Monitor critical telemetry: battery levels, joint temperatures, and network latency to ensure safety.",
side: "left",
},
},
];
} else if (segment === "analytics") {
steps = [
{
element: "#tour-analytics-table",
popover: {
title: "Study Analytics",
description:
"View aggregate data across all participant sessions. Sort and filter to identify trends or specific trials.",
side: "bottom",
},
},
{
element: "#tour-analytics-filter",
popover: {
title: "Filter Data",
description:
"Quickly find participants by ID or name using this search bar.",
side: "bottom",
},
},
{
element: "#tour-trial-metrics",
popover: {
title: "Trial Metrics",
description:
"High-level KPIs for the selected trial: Duration, Robot Actions, and Intervention counts.",
side: "bottom",
},
},
{
element: "#tour-trial-timeline",
popover: {
title: "Video & Timeline",
description:
"Watch the trial recording synced with the event timeline. Click any event to jump to that moment in the video.",
side: "right",
},
},
{
element: "#tour-trial-events",
popover: {
title: "Event Log",
description:
"A detailed, searchable log of every system event, robot action, and wizard interaction.",
side: "left",
},
},
];
}
driverObj.current = driver({
showProgress: true,
animate: true,
allowClose: true,
steps: steps.map((step) => ({
...step,
popover: {
...step.popover,
popoverClass: `driver-popover-override ${themeClass}`,
},
})),
onDestroyed: () => {
// Persistence handled by localStorage state
setIsTourActive(false);
},
});
driverObj.current.drive();
setIsTourActive(true);
};
const startTour = (tour: TourType) => {
if (tour === "full_platform") {
localStorage.setItem("hristudio_tour_mode", "full");
Cookies.set("hristudio_tour_mode", "full", { expires: 7 }); // 7 days persistence
if (pathname !== "/dashboard") {
router.push("/dashboard");
return;
}
// We are already on dashboard, trigger it immediately
runTourSegment("dashboard");
} else {
localStorage.setItem("hristudio_tour_mode", "manual");
Cookies.remove("hristudio_tour_mode");
if (tour === "dashboard") runTourSegment("dashboard");
if (tour === "study_creation") runTourSegment("study_creation");
if (tour === "participant_creation")
runTourSegment("participant_creation");
if (tour === "designer") runTourSegment("designer");
if (tour === "wizard") runTourSegment("wizard");
if (tour === "analytics") runTourSegment("analytics");
}
};
return (
<TourContext.Provider value={{ startTour, isTourActive }}>
{children}
<style jsx global>{`
/*
SHADCN/UI THEMING OVERRIDES
CRITICAL: The global variables in globals.css use OKLCH/HSL values directly or with units.
DO NOT wrap variables in hsl() if they are already defined as colors.
Use direct assignment.
*/
.driver-popover-override {
padding: 1.25rem !important;
border-radius: var(--radius) !important;
box-shadow: var(--shadow-xl) !important;
max-width: 420px !important;
/* Background & Text - Match Card Aesthetic */
background-color: var(--card) !important;
color: var(--card-foreground) !important;
border: 1px solid var(--border) !important;
/* Typography */
font-family: var(--font-sans) !important;
.driver-popover-override {
padding: 1.25rem !important;
border-radius: var(--radius) !important;
box-shadow: var(--shadow-xl) !important;
max-width: 420px !important;
/* Background & Text - Match Card Aesthetic */
background-color: var(--card) !important;
color: var(--card-foreground) !important;
border: 1px solid var(--border) !important;
/* Typography */
font-family: var(--font-sans) !important;
}
/* Arrow Styling - Critical for transparent/card matching */
.driver-popover-override .driver-popover-arrow {
border-width: 8px !important;
border-width: 8px !important;
}
/*
@@ -373,93 +425,105 @@ export function TourProvider({ children }: { children: React.ReactNode }) {
Using CSS variables requires a bit of trickery because border-color expects distinct values.
We'll target the side classes driver.js adds.
*/
.driver-popover-override.driverjs-theme-dark .driver-popover-arrow-side-left.driver-popover-arrow {
border-left-color: var(--card) !important;
.driver-popover-override.driverjs-theme-dark
.driver-popover-arrow-side-left.driver-popover-arrow {
border-left-color: var(--card) !important;
}
.driver-popover-override.driverjs-theme-dark .driver-popover-arrow-side-right.driver-popover-arrow {
border-right-color: var(--card) !important;
.driver-popover-override.driverjs-theme-dark
.driver-popover-arrow-side-right.driver-popover-arrow {
border-right-color: var(--card) !important;
}
.driver-popover-override.driverjs-theme-dark .driver-popover-arrow-side-top.driver-popover-arrow {
border-top-color: var(--card) !important;
.driver-popover-override.driverjs-theme-dark
.driver-popover-arrow-side-top.driver-popover-arrow {
border-top-color: var(--card) !important;
}
.driver-popover-override.driverjs-theme-dark .driver-popover-arrow-side-bottom.driver-popover-arrow {
border-bottom-color: var(--card) !important;
.driver-popover-override.driverjs-theme-dark
.driver-popover-arrow-side-bottom.driver-popover-arrow {
border-bottom-color: var(--card) !important;
}
/* Light mode fallbacks (using border color for definition, though card bg is usually sufficient) */
.driver-popover-override.driverjs-theme-light .driver-popover-arrow-side-left.driver-popover-arrow {
border-left-color: var(--card) !important;
.driver-popover-override.driverjs-theme-light
.driver-popover-arrow-side-left.driver-popover-arrow {
border-left-color: var(--card) !important;
}
.driver-popover-override.driverjs-theme-light .driver-popover-arrow-side-right.driver-popover-arrow {
border-right-color: var(--card) !important;
.driver-popover-override.driverjs-theme-light
.driver-popover-arrow-side-right.driver-popover-arrow {
border-right-color: var(--card) !important;
}
.driver-popover-override.driverjs-theme-light .driver-popover-arrow-side-top.driver-popover-arrow {
border-top-color: var(--card) !important;
.driver-popover-override.driverjs-theme-light
.driver-popover-arrow-side-top.driver-popover-arrow {
border-top-color: var(--card) !important;
}
.driver-popover-override.driverjs-theme-light .driver-popover-arrow-side-bottom.driver-popover-arrow {
border-bottom-color: var(--card) !important;
.driver-popover-override.driverjs-theme-light
.driver-popover-arrow-side-bottom.driver-popover-arrow {
border-bottom-color: var(--card) !important;
}
/* Title Styling */
.driver-popover-override .driver-popover-title {
color: var(--foreground) !important;
font-size: 1.125rem !important; /* 18px */
font-weight: 600 !important;
margin-bottom: 0.5rem !important;
letter-spacing: -0.015em !important;
font-family: var(--font-sans) !important;
color: var(--foreground) !important;
font-size: 1.125rem !important; /* 18px */
font-weight: 600 !important;
margin-bottom: 0.5rem !important;
letter-spacing: -0.015em !important;
font-family: var(--font-sans) !important;
}
/* Description Styling */
.driver-popover-override .driver-popover-description {
color: var(--muted-foreground) !important;
font-size: 0.875rem !important; /* 14px */
line-height: 1.6 !important;
font-family: var(--font-sans) !important;
color: var(--muted-foreground) !important;
font-size: 0.875rem !important; /* 14px */
line-height: 1.6 !important;
font-family: var(--font-sans) !important;
}
/* Buttons */
.driver-popover-override .driver-popover-footer button {
background-color: var(--primary) !important;
color: var(--primary-foreground) !important;
border-radius: calc(var(--radius) - 2px) !important;
padding: 0.5rem 1rem !important;
font-size: 0.875rem !important;
font-weight: 500 !important;
border: none !important;
text-shadow: none !important;
transition-all: 0.2s !important;
font-family: var(--font-sans) !important;
background-color: var(--primary) !important;
color: var(--primary-foreground) !important;
border-radius: calc(var(--radius) - 2px) !important;
padding: 0.5rem 1rem !important;
font-size: 0.875rem !important;
font-weight: 500 !important;
border: none !important;
text-shadow: none !important;
transition-all: 0.2s !important;
font-family: var(--font-sans) !important;
}
.driver-popover-override .driver-popover-footer button:hover {
opacity: 0.9 !important;
transform: translateY(-1px);
opacity: 0.9 !important;
transform: translateY(-1px);
}
/* Navigation Buttons (Previous/Next) specifically */
.driver-popover-override .driver-popover-footer .driver-popover-prev-btn {
background-color: transparent !important;
color: var(--muted-foreground) !important;
border: 1px solid var(--border) !important;
.driver-popover-override
.driver-popover-footer
.driver-popover-prev-btn {
background-color: transparent !important;
color: var(--muted-foreground) !important;
border: 1px solid var(--border) !important;
}
.driver-popover-override .driver-popover-footer .driver-popover-prev-btn:hover {
background-color: var(--accent) !important;
color: var(--accent-foreground) !important;
.driver-popover-override
.driver-popover-footer
.driver-popover-prev-btn:hover {
background-color: var(--accent) !important;
color: var(--accent-foreground) !important;
}
/* Close Button */
.driver-popover-override .driver-popover-close-btn {
color: var(--muted-foreground) !important;
opacity: 0.7 !important;
transition: opacity 0.2s !important;
color: var(--muted-foreground) !important;
opacity: 0.7 !important;
transition: opacity 0.2s !important;
}
.driver-popover-override .driver-popover-close-btn:hover {
color: var(--foreground) !important;
opacity: 1 !important;
color: var(--foreground) !important;
opacity: 1 !important;
}
`}</style>
</TourContext.Provider>
);
</TourContext.Provider>
);
}

View File

@@ -1,7 +1,14 @@
"use client";
import { useState } from "react";
import { Upload, X, FileText, CheckCircle, AlertCircle, Loader2 } from "lucide-react";
import {
Upload,
X,
FileText,
CheckCircle,
AlertCircle,
Loader2,
} from "lucide-react";
import { Button } from "~/components/ui/button";
import { Progress } from "~/components/ui/progress";
import { api } from "~/trpc/react";
@@ -9,186 +16,215 @@ import { toast } from "sonner";
import { cn } from "~/lib/utils";
interface ConsentUploadFormProps {
studyId: string;
participantId: string;
consentFormId: string;
onSuccess: () => void;
onCancel: () => void;
studyId: string;
participantId: string;
consentFormId: string;
onSuccess: () => void;
onCancel: () => void;
}
export function ConsentUploadForm({
studyId,
participantId,
consentFormId,
onSuccess,
onCancel,
studyId,
participantId,
consentFormId,
onSuccess,
onCancel,
}: ConsentUploadFormProps) {
const [file, setFile] = useState<File | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [file, setFile] = useState<File | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
// Mutations
const getUploadUrlMutation = api.participants.getConsentUploadUrl.useMutation();
const recordConsentMutation = api.participants.recordConsent.useMutation();
// Mutations
const getUploadUrlMutation =
api.participants.getConsentUploadUrl.useMutation();
const recordConsentMutation = api.participants.recordConsent.useMutation();
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
const selectedFile = e.target.files[0];
// Validate size (10MB)
if (selectedFile.size > 10 * 1024 * 1024) {
toast.error("File too large", {
description: "Maximum file size is 10MB",
});
return;
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
const selectedFile = e.target.files[0];
// Validate size (10MB)
if (selectedFile.size > 10 * 1024 * 1024) {
toast.error("File too large", {
description: "Maximum file size is 10MB",
});
return;
}
// Validate type
const allowedTypes = [
"application/pdf",
"image/png",
"image/jpeg",
"image/jpg",
];
if (!allowedTypes.includes(selectedFile.type)) {
toast.error("Invalid file type", {
description: "Please upload a PDF, PNG, or JPG file",
});
return;
}
setFile(selectedFile);
}
};
const handleUpload = async () => {
if (!file) return;
try {
setIsUploading(true);
setUploadProgress(0);
// 1. Get Presigned URL
const { url, key } = await getUploadUrlMutation.mutateAsync({
studyId,
participantId,
filename: file.name,
contentType: file.type,
size: file.size,
});
// 2. Upload to MinIO using XMLHttpRequest for progress
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("PUT", url, true);
xhr.setRequestHeader("Content-Type", file.type);
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
const percentCompleted = Math.round(
(event.loaded * 100) / event.total,
);
setUploadProgress(percentCompleted);
}
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve();
} else {
reject(new Error(`Upload failed with status ${xhr.status}`));
}
};
xhr.onerror = () => reject(new Error("Network error during upload"));
xhr.send(file);
});
// 3. Record Consent in DB
await recordConsentMutation.mutateAsync({
participantId,
consentFormId,
storagePath: key,
});
toast.success("Consent Recorded", {
description:
"The consent form has been uploaded and recorded successfully.",
});
onSuccess();
} catch (error) {
console.error("Upload failed:", error);
toast.error("Upload Failed", {
description:
error instanceof Error
? error.message
: "An unexpected error occurred",
});
setIsUploading(false);
}
};
return (
<div className="space-y-4">
{!file ? (
<div className="bg-muted/5 hover:bg-muted/10 flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-6 transition-colors">
<Upload className="text-muted-foreground mb-4 h-8 w-8" />
<h3 className="mb-1 text-sm font-semibold">Upload Signed Consent</h3>
<p className="text-muted-foreground mb-4 text-center text-xs">
Drag and drop or click to select
<br />
PDF, PNG, JPG up to 10MB
</p>
<input
type="file"
id="consent-file-upload"
className="hidden"
accept=".pdf,.png,.jpg,.jpeg"
onChange={handleFileChange}
/>
<Button
variant="secondary"
size="sm"
onClick={() =>
document.getElementById("consent-file-upload")?.click()
}
// Validate type
const allowedTypes = ["application/pdf", "image/png", "image/jpeg", "image/jpg"];
if (!allowedTypes.includes(selectedFile.type)) {
toast.error("Invalid file type", {
description: "Please upload a PDF, PNG, or JPG file",
});
return;
}
setFile(selectedFile);
}
};
const handleUpload = async () => {
if (!file) return;
try {
setIsUploading(true);
setUploadProgress(0);
// 1. Get Presigned URL
const { url, key } = await getUploadUrlMutation.mutateAsync({
studyId,
participantId,
filename: file.name,
contentType: file.type,
size: file.size,
});
// 2. Upload to MinIO using XMLHttpRequest for progress
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("PUT", url, true);
xhr.setRequestHeader("Content-Type", file.type);
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
const percentCompleted = Math.round(
(event.loaded * 100) / event.total
);
setUploadProgress(percentCompleted);
}
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve();
} else {
reject(new Error(`Upload failed with status ${xhr.status}`));
}
};
xhr.onerror = () => reject(new Error("Network error during upload"));
xhr.send(file);
});
// 3. Record Consent in DB
await recordConsentMutation.mutateAsync({
participantId,
consentFormId,
storagePath: key,
});
toast.success("Consent Recorded", {
description: "The consent form has been uploaded and recorded successfully.",
});
onSuccess();
} catch (error) {
console.error("Upload failed:", error);
toast.error("Upload Failed", {
description: error instanceof Error ? error.message : "An unexpected error occurred",
});
setIsUploading(false);
}
};
return (
<div className="space-y-4">
{!file ? (
<div className="flex flex-col items-center justify-center border-2 border-dashed rounded-lg p-6 bg-muted/5 hover:bg-muted/10 transition-colors">
<Upload className="h-8 w-8 text-muted-foreground mb-4" />
<h3 className="font-semibold text-sm mb-1">Upload Signed Consent</h3>
<p className="text-xs text-muted-foreground mb-4 text-center">
Drag and drop or click to select<br />
PDF, PNG, JPG up to 10MB
</p>
<input
type="file"
id="consent-file-upload"
className="hidden"
accept=".pdf,.png,.jpg,.jpeg"
onChange={handleFileChange}
/>
<Button variant="secondary" size="sm" onClick={() => document.getElementById("consent-file-upload")?.click()}>
Select File
</Button>
</div>
) : (
<div className="border rounded-lg p-4 bg-muted/5">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className="h-10 w-10 bg-primary/10 rounded flex items-center justify-center">
<FileText className="h-5 w-5 text-primary" />
</div>
<div>
<p className="text-sm font-medium line-clamp-1 break-all">{file.name}</p>
<p className="text-xs text-muted-foreground">
{(file.size / 1024 / 1024).toFixed(2)} MB
</p>
</div>
</div>
{!isUploading && (
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setFile(null)}>
<X className="h-4 w-4" />
</Button>
)}
</div>
{isUploading && (
<div className="space-y-2 mb-4">
<div className="flex justify-between text-xs text-muted-foreground">
<span>Uploading...</span>
<span>{uploadProgress}%</span>
</div>
<Progress value={uploadProgress} className="h-2" />
</div>
)}
<div className="flex gap-2 justify-end">
<Button variant="outline" size="sm" onClick={onCancel} disabled={isUploading}>
Cancel
</Button>
<Button size="sm" onClick={handleUpload} disabled={isUploading}>
{isUploading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Uploading
</>
) : (
<>
<Upload className="mr-2 h-4 w-4" />
Upload & Record
</>
)}
</Button>
</div>
</div>
)}
>
Select File
</Button>
</div>
);
) : (
<div className="bg-muted/5 rounded-lg border p-4">
<div className="mb-4 flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="bg-primary/10 flex h-10 w-10 items-center justify-center rounded">
<FileText className="text-primary h-5 w-5" />
</div>
<div>
<p className="line-clamp-1 text-sm font-medium break-all">
{file.name}
</p>
<p className="text-muted-foreground text-xs">
{(file.size / 1024 / 1024).toFixed(2)} MB
</p>
</div>
</div>
{!isUploading && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setFile(null)}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
{isUploading && (
<div className="mb-4 space-y-2">
<div className="text-muted-foreground flex justify-between text-xs">
<span>Uploading...</span>
<span>{uploadProgress}%</span>
</div>
<Progress value={uploadProgress} className="h-2" />
</div>
)}
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={onCancel}
disabled={isUploading}
>
Cancel
</Button>
<Button size="sm" onClick={handleUpload} disabled={isUploading}>
{isUploading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Uploading
</>
) : (
<>
<Upload className="mr-2 h-4 w-4" />
Upload & Record
</>
)}
</Button>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,235 @@
"use client";
import { useRef, useState } from "react";
import SignatureCanvas from "react-signature-canvas";
import { Button } from "~/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
import { PenBox, Eraser, Loader2, CheckCircle } from "lucide-react";
import { api } from "~/trpc/react";
import { toast } from "sonner";
import { generatePdfBlobFromHtml } from "~/lib/pdf-generator";
import { Editor, EditorContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { Markdown } from "tiptap-markdown";
import { Table } from "@tiptap/extension-table";
import TableRow from "@tiptap/extension-table-row";
import TableCell from "@tiptap/extension-table-cell";
import TableHeader from "@tiptap/extension-table-header";
import { ScrollArea } from "~/components/ui/scroll-area";
interface DigitalSignatureModalProps {
studyId: string;
participantId: string;
participantName?: string | null;
participantCode: string;
activeForm: { id: string; content: string; version: number };
onSuccess: () => void;
}
export function DigitalSignatureModal({
studyId,
participantId,
participantName,
participantCode,
activeForm,
onSuccess,
}: DigitalSignatureModalProps) {
const [isOpen, setIsOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const sigCanvas = useRef<any>(null);
// Mutations
const getUploadUrlMutation = api.participants.getConsentUploadUrl.useMutation();
const recordConsentMutation = api.participants.recordConsent.useMutation();
// Create a preview version of the text
let previewMd = activeForm.content;
previewMd = previewMd.replace(/{{PARTICIPANT_NAME}}/g, participantName ?? "_________________");
previewMd = previewMd.replace(/{{PARTICIPANT_CODE}}/g, participantCode);
const today = new Date().toLocaleDateString();
previewMd = previewMd.replace(/{{DATE}}/g, today);
previewMd = previewMd.replace(/{{SIGNATURE_IMAGE}}/g, "_[Signature Here]_");
const previewEditor = useEditor({
extensions: [StarterKit, Table, TableRow, TableHeader, TableCell, Markdown],
content: previewMd,
editable: false,
immediatelyRender: false,
});
const handleClear = () => {
sigCanvas.current?.clear();
};
const handleSubmit = async () => {
if (sigCanvas.current?.isEmpty()) {
toast.error("Signature required", { description: "Please sign the document before submitting." });
return;
}
try {
setIsSubmitting(true);
toast.loading("Generating Signed Document...", { id: "sig-upload" });
// 1. Get Signature Image Data URL
const signatureDataUrl = sigCanvas.current.getTrimmedCanvas().toDataURL("image/png");
// 2. Prepare final Markdown and HTML
let finalMd = activeForm.content;
finalMd = finalMd.replace(/{{PARTICIPANT_NAME}}/g, participantName ?? "_________________");
finalMd = finalMd.replace(/{{PARTICIPANT_CODE}}/g, participantCode);
finalMd = finalMd.replace(/{{DATE}}/g, today);
finalMd = finalMd.replace(/{{SIGNATURE_IMAGE}}/g, `<img src="${signatureDataUrl}" style="height: 60px; max-width: 250px;" />`);
const headlessEditor = new Editor({
extensions: [StarterKit, Table, TableRow, TableHeader, TableCell, Markdown],
content: finalMd,
});
const htmlContent = headlessEditor.getHTML();
headlessEditor.destroy();
// 3. Generate PDF Blob
const filename = `Signed_Consent_${participantCode}_v${activeForm.version}.pdf`;
const pdfBlob = await generatePdfBlobFromHtml(htmlContent, { filename });
const file = new File([pdfBlob], filename, { type: "application/pdf" });
// 4. Get Presigned URL
toast.loading("Uploading Document...", { id: "sig-upload" });
const { url, key } = await getUploadUrlMutation.mutateAsync({
studyId,
participantId,
filename: file.name,
contentType: file.type,
size: file.size,
});
// 5. Upload to MinIO
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("PUT", url, true);
xhr.setRequestHeader("Content-Type", file.type);
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) resolve();
else reject(new Error(`Upload failed with status ${xhr.status}`));
};
xhr.onerror = () => reject(new Error("Network error during upload"));
xhr.send(file);
});
// 6. Record Consent in DB
toast.loading("Finalizing Consent...", { id: "sig-upload" });
await recordConsentMutation.mutateAsync({
participantId,
consentFormId: activeForm.id,
storagePath: key,
});
toast.success("Consent Successfully Recorded!", { id: "sig-upload" });
setIsOpen(false);
onSuccess();
} catch (error) {
console.error(error);
toast.error("Failed to submit digital signature", {
id: "sig-upload",
description: error instanceof Error ? error.message : "Unknown error",
});
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="default" size="sm" className="bg-primary/90 hover:bg-primary">
<PenBox className="mr-2 h-4 w-4" />
Sign Digitally
</Button>
</DialogTrigger>
<DialogContent className="max-w-4xl h-[90vh] flex flex-col p-6">
<DialogHeader>
<DialogTitle>Digital Consent Signature</DialogTitle>
<DialogDescription>
Please review the document below and provide your digital signature to consent to this study.
</DialogDescription>
</DialogHeader>
<div className="flex-1 min-h-0 grid grid-cols-1 md:grid-cols-2 gap-6 mt-4">
{/* Document Preview (Left) */}
<div className="flex flex-col border rounded-md overflow-hidden bg-muted/20">
<div className="bg-muted px-4 py-2 border-b text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Document Preview
</div>
<ScrollArea className="flex-1 w-full bg-white p-6 shadow-inner">
<div className="prose prose-sm max-w-none text-black">
<EditorContent editor={previewEditor} />
</div>
</ScrollArea>
</div>
{/* Signature Panel (Right) */}
<div className="flex flex-col space-y-4">
<div className="border rounded-md overflow-hidden bg-white shadow-sm flex flex-col">
<div className="bg-muted px-4 py-2 border-b text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Digital Signature Pad
</div>
<div className="p-4 bg-muted/10 relative">
<div className="absolute top-4 right-4">
<Button variant="ghost" size="sm" onClick={handleClear} disabled={isSubmitting}>
<Eraser className="h-4 w-4 mr-2" />
Clear
</Button>
</div>
<div className="border-2 border-dashed border-input rounded-md bg-white mt-10" style={{ height: "250px" }}>
<SignatureCanvas
ref={sigCanvas}
penColor="black"
canvasProps={{ className: "w-full h-full cursor-crosshair rounded-md" }}
/>
</div>
<p className="text-center text-xs text-muted-foreground mt-2">
Draw your signature using your mouse or touch screen inside the box above.
</p>
</div>
</div>
<div className="flex-1" />
{/* Submission Actions */}
<div className="flex flex-col space-y-3 p-4 bg-primary/5 rounded-lg border border-primary/20">
<h4 className="flex items-center text-sm font-semibold text-primary">
<CheckCircle className="h-4 w-4 mr-2" />
Agreement
</h4>
<p className="text-xs text-muted-foreground leading-relaxed">
By clicking "Submit Signed Document", you confirm that you have read and understood the information provided in the document preview, and you voluntarily agree to participate in this study.
</p>
<Button
className="w-full mt-2"
size="lg"
onClick={handleSubmit}
disabled={isSubmitting}
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Processing...
</>
) : (
"Submit Signed Document"
)}
</Button>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -4,158 +4,235 @@ import { useState } from "react";
import { api } from "~/trpc/react";
import { Button } from "~/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
import { ConsentUploadForm } from "./ConsentUploadForm";
import { FileText, Download, CheckCircle, AlertCircle, Upload } from "lucide-react";
import {
FileText,
Download,
CheckCircle,
AlertCircle,
Upload,
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "~/components/ui/badge";
import { cn } from "~/lib/utils";
import { downloadPdfFromHtml } from "~/lib/pdf-generator";
import { Editor } from "@tiptap/core";
import StarterKit from "@tiptap/starter-kit";
import { Markdown } from "tiptap-markdown";
import { Table } from "@tiptap/extension-table";
import TableRow from "@tiptap/extension-table-row";
import TableCell from "@tiptap/extension-table-cell";
import TableHeader from "@tiptap/extension-table-header";
import { DigitalSignatureModal } from "./DigitalSignatureModal";
interface ParticipantConsentManagerProps {
studyId: string;
participantId: string;
consentGiven: boolean;
consentDate: Date | null;
existingConsent: {
id: string;
storagePath: string | null;
signedAt: Date;
consentForm: {
title: string;
version: number;
};
} | null;
studyId: string;
participantId: string;
consentGiven: boolean;
consentDate: Date | null;
existingConsent: {
id: string;
storagePath: string | null;
signedAt: Date;
consentForm: {
title: string;
version: number;
};
} | null;
}
export function ParticipantConsentManager({
studyId,
participantId,
consentGiven,
consentDate,
existingConsent,
participantName,
participantCode,
}: ParticipantConsentManagerProps & { participantName?: string | null; participantCode: string }) {
const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils();
// Fetch active consent forms to know which form to sign/upload against
const { data: consentForms } = api.participants.getConsentForms.useQuery({
studyId,
participantId,
consentGiven,
consentDate,
existingConsent,
}: ParticipantConsentManagerProps) {
const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils();
});
const activeForm = consentForms?.find((f) => f.active) ?? consentForms?.[0];
// Fetch active consent forms to know which form to sign/upload against
const { data: consentForms } = api.participants.getConsentForms.useQuery({ studyId });
const activeForm = consentForms?.find((f) => f.active) ?? consentForms?.[0];
// Helper to get download URL
const { refetch: fetchDownloadUrl } = api.files.getDownloadUrl.useQuery(
{ storagePath: existingConsent?.storagePath ?? "" },
{ enabled: false },
);
// Helper to get download URL
const { refetch: fetchDownloadUrl } = api.files.getDownloadUrl.useQuery(
{ storagePath: existingConsent?.storagePath ?? "" },
{ enabled: false }
);
const handleDownload = async () => {
if (!existingConsent?.storagePath) return;
try {
const result = await fetchDownloadUrl();
if (result.data?.url) {
window.open(result.data.url, "_blank");
} else {
toast.error("Error", { description: "Could not retrieve document" });
}
} catch (error) {
toast.error("Error", { description: "Failed to get download URL" });
}
};
const handleDownload = async () => {
if (!existingConsent?.storagePath) return;
try {
const result = await fetchDownloadUrl();
if (result.data?.url) {
window.open(result.data.url, "_blank");
} else {
toast.error("Error", { description: "Could not retrieve document" });
}
} catch (error) {
toast.error("Error", { description: "Failed to get download URL" });
}
};
const handleSuccess = () => {
setIsOpen(false);
utils.participants.get.invalidate({ id: participantId });
toast.success("Success", { description: "Consent recorded successfully" });
};
const handleSuccess = () => {
setIsOpen(false);
utils.participants.get.invalidate({ id: participantId });
toast.success("Success", { description: "Consent recorded successfully" });
};
const handleDownloadUnsigned = async () => {
if (!activeForm) return;
try {
toast.loading("Generating custom document...", { id: "pdf-gen" });
return (
<div className="rounded-lg border bg-card text-card-foreground shadow-sm">
<div className="p-6 flex flex-row items-center justify-between space-y-0 pb-2">
<div className="flex flex-col space-y-1.5">
<h3 className="font-semibold leading-none tracking-tight flex items-center gap-2">
<FileText className="h-5 w-5" />
Consent Status
</h3>
<p className="text-sm text-muted-foreground">
Manage participant consent and forms.
</p>
</div>
<Badge variant={consentGiven ? "default" : "destructive"}>
{consentGiven ? "Consent Given" : "Not Recorded"}
</Badge>
</div>
<div className="p-6 pt-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
{consentGiven ? (
<>
<div className="flex items-center gap-2 text-sm font-medium">
<CheckCircle className="h-4 w-4 text-green-600" />
Signed on {consentDate ? new Date(consentDate).toLocaleDateString() : "Unknown date"}
</div>
{existingConsent && (
<p className="text-xs text-muted-foreground">
Form: {existingConsent.consentForm.title} (v{existingConsent.consentForm.version})
</p>
)}
</>
) : (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<AlertCircle className="h-4 w-4" />
No consent recorded for this participant.
</div>
)}
</div>
<div className="flex gap-2">
{consentGiven && existingConsent?.storagePath && (
<Button variant="outline" size="sm" onClick={handleDownload}>
<Download className="mr-2 h-4 w-4" />
Download PDF
</Button>
)}
// Substitute placeholders in markdown
let customMd = activeForm.content;
customMd = customMd.replace(/{{PARTICIPANT_NAME}}/g, participantName ?? "_________________");
customMd = customMd.replace(/{{PARTICIPANT_CODE}}/g, participantCode);
customMd = customMd.replace(/{{DATE}}/g, "_________________");
customMd = customMd.replace(/{{SIGNATURE_IMAGE}}/g, ""); // Blank ready for physical signature
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button size="sm" variant={consentGiven ? "secondary" : "default"}>
<Upload className="mr-2 h-4 w-4" />
{consentGiven ? "Update Consent" : "Record Consent"}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Upload Signed Consent Form</DialogTitle>
<DialogDescription>
Upload the signed PDF or image of the consent form for this participant.
{activeForm && (
<span className="block mt-1 font-medium text-foreground">
Active Form: {activeForm.title} (v{activeForm.version})
</span>
)}
</DialogDescription>
</DialogHeader>
{activeForm ? (
<ConsentUploadForm
studyId={studyId}
participantId={participantId}
consentFormId={activeForm.id}
onSuccess={handleSuccess}
onCancel={() => setIsOpen(false)}
/>
) : (
<div className="py-4 text-center text-muted-foreground">
No active consent form found for this study. Please create one first.
</div>
)}
</DialogContent>
</Dialog>
</div>
</div>
</div>
// Use headless Tiptap to parse MD to HTML via same extensions
const editor = new Editor({
extensions: [StarterKit, Table, TableRow, TableHeader, TableCell, Markdown],
content: customMd,
});
const htmlContent = editor.getHTML();
editor.destroy();
await downloadPdfFromHtml(htmlContent, {
filename: `Consent_${participantCode}_${activeForm.version}.pdf`,
});
toast.success("Document Downloaded", { id: "pdf-gen" });
} catch (e) {
toast.error("Error generating customized PDF", { id: "pdf-gen" });
console.error(e);
}
};
return (
<div className="bg-card text-card-foreground rounded-lg border shadow-sm">
<div className="flex flex-row items-center justify-between space-y-0 p-6 pb-2">
<div className="flex flex-col space-y-1.5">
<h3 className="flex items-center gap-2 leading-none font-semibold tracking-tight">
<FileText className="h-5 w-5" />
Consent Status
</h3>
<p className="text-muted-foreground text-sm">
Manage participant consent and forms.
</p>
</div>
);
<Badge variant={consentGiven ? "default" : "destructive"}>
{consentGiven ? "Consent Given" : "Not Recorded"}
</Badge>
</div>
<div className="p-6 pt-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
{consentGiven ? (
<>
<div className="flex items-center gap-2 text-sm font-medium">
<CheckCircle className="h-4 w-4 text-green-600" />
Signed on{" "}
{consentDate
? new Date(consentDate).toLocaleDateString()
: "Unknown date"}
</div>
{existingConsent && (
<p className="text-muted-foreground text-xs">
Form: {existingConsent.consentForm.title} (v
{existingConsent.consentForm.version})
</p>
)}
</>
) : (
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<AlertCircle className="h-4 w-4" />
No consent recorded for this participant.
</div>
)}
</div>
<div className="flex gap-2">
{consentGiven && existingConsent?.storagePath && (
<Button variant="outline" size="sm" onClick={handleDownload}>
<Download className="mr-2 h-4 w-4" />
Download PDF
</Button>
)}
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button
size="sm"
variant={consentGiven ? "secondary" : "default"}
>
<Upload className="mr-2 h-4 w-4" />
{consentGiven ? "Update Consent" : "Upload Consent"}
</Button>
</DialogTrigger>
{!consentGiven && activeForm && (
<>
<DigitalSignatureModal
studyId={studyId}
participantId={participantId}
participantName={participantName}
participantCode={participantCode}
activeForm={activeForm}
onSuccess={handleSuccess}
/>
<Button variant="outline" size="sm" onClick={handleDownloadUnsigned}>
<Download className="mr-2 h-4 w-4" />
Print Empty Form
</Button>
</>
)}
<DialogContent>
<DialogHeader>
<DialogTitle>Upload Signed Consent Form</DialogTitle>
<DialogDescription>
Upload the signed PDF or image of the consent form for this
participant.
{activeForm && (
<span className="text-foreground mt-1 block font-medium">
Active Form: {activeForm.title} (v{activeForm.version})
</span>
)}
</DialogDescription>
</DialogHeader>
{activeForm ? (
<ConsentUploadForm
studyId={studyId}
participantId={participantId}
consentFormId={activeForm.id}
onSuccess={handleSuccess}
onCancel={() => setIsOpen(false)}
/>
) : (
<div className="text-muted-foreground py-4 text-center">
No active consent form found for this study. Please create
one first.
</div>
)}
</DialogContent>
</Dialog>
</div>
</div>
</div>
</div>
);
}

View File

@@ -25,6 +25,7 @@ import {
SelectValue,
} from "~/components/ui/select";
import { useStudyContext } from "~/lib/study-context";
import { cn } from "~/lib/utils";
import { api } from "~/trpc/react";
import { useTour } from "~/components/onboarding/TourProvider";
import { Button } from "~/components/ui/button";
@@ -94,6 +95,7 @@ export function ParticipantForm({
defaultValues: {
consentGiven: false,
studyId: contextStudyId ?? "",
participantCode: "",
},
});
@@ -183,6 +185,20 @@ export function ParticipantForm({
}
}, [contextStudyId, mode, form]);
// Fetch next participant code
const { data: nextCode, isLoading: isNextCodeLoading } =
api.participants.getNextCode.useQuery(
{ studyId: contextStudyId! },
{ enabled: mode === "create" && !!contextStudyId },
);
// Update default value if we switch modes or remount
useEffect(() => {
if (mode === "create" && nextCode) {
form.setValue("participantCode", nextCode, { shouldValidate: true });
}
}, [mode, nextCode, form]);
const createParticipantMutation = api.participants.create.useMutation();
const updateParticipantMutation = api.participants.update.useMutation();
const deleteParticipantMutation = api.participants.delete.useMutation();
@@ -206,7 +222,9 @@ export function ParticipantForm({
email: data.email ?? undefined,
demographics,
});
router.push(`/studies/${data.studyId}/participants/${newParticipant.id}`);
router.push(
`/studies/${data.studyId}/participants/${newParticipant.id}`,
);
} else {
const updatedParticipant = await updateParticipantMutation.mutateAsync({
id: participantId!,
@@ -215,7 +233,9 @@ export function ParticipantForm({
email: data.email ?? undefined,
demographics,
});
router.push(`/studies/${contextStudyId}/participants/${updatedParticipant.id}`);
router.push(
`/studies/${contextStudyId}/participants/${updatedParticipant.id}`,
);
}
} catch (error) {
setError(
@@ -261,16 +281,18 @@ export function ParticipantForm({
title="Participant Information"
description="Basic identity and study association."
>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
<FormField>
<Label htmlFor="participantCode">Participant Code *</Label>
<Input
id="tour-participant-code"
{...form.register("participantCode")}
placeholder="e.g., P001"
className={
placeholder={isNextCodeLoading ? "Generating..." : "e.g., P001"}
readOnly={true}
className={cn(
"bg-muted text-muted-foreground",
form.formState.errors.participantCode ? "border-red-500" : ""
}
)}
/>
{form.formState.errors.participantCode && (
<p className="text-sm text-red-600">
@@ -315,44 +337,46 @@ export function ParticipantForm({
<div className="my-6" />
<FormSection
title="Demographics & Study"
description="study association and demographic details."
title={contextStudyId ? "Demographics" : "Demographics & Study"}
description={contextStudyId ? "Participant demographic details." : "Study association and demographic details."}
>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<FormField>
<Label htmlFor="studyId" id="tour-participant-study-label">Study *</Label>
<div id="tour-participant-study-container">
<Select
value={form.watch("studyId")}
onValueChange={(value) => form.setValue("studyId", value)}
disabled={studiesLoading || mode === "edit"}
>
<SelectTrigger
className={
form.formState.errors.studyId ? "border-red-500" : ""
}
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
{!contextStudyId && (
<FormField>
<Label htmlFor="studyId" id="tour-participant-study-label">
Study *
</Label>
<div id="tour-participant-study-container">
<Select
value={form.watch("studyId")}
onValueChange={(value) => form.setValue("studyId", value)}
disabled={studiesLoading || mode === "edit"}
>
<SelectValue
placeholder={
studiesLoading ? "Loading..." : "Select study"
<SelectTrigger
className={
form.formState.errors.studyId ? "border-red-500" : ""
}
/>
</SelectTrigger>
<SelectContent>
{studiesData?.studies?.map((study) => (
<SelectItem key={study.id} value={study.id}>
{study.name}
</SelectItem>
))}
</SelectContent>
</Select>
{form.formState.errors.studyId && (
<p className="text-sm text-red-600">
{form.formState.errors.studyId.message}
</p>
)}
</div>
</FormField>
>
<SelectValue
placeholder={studiesLoading ? "Loading..." : "Select study"}
/>
</SelectTrigger>
<SelectContent>
{studiesData?.studies?.map((study) => (
<SelectItem key={study.id} value={study.id}>
{study.name}
</SelectItem>
))}
</SelectContent>
</Select>
{form.formState.errors.studyId && (
<p className="text-sm text-red-600">
{form.formState.errors.studyId.message}
</p>
)}
</div>
</FormField>
)}
<FormField>
<Label htmlFor="age">Age</Label>
@@ -503,10 +527,16 @@ export function ParticipantForm({
submitButtonId="tour-participant-submit"
extraActions={
mode === "create" ? (
<Button variant="ghost" size="sm" onClick={() => startTour("participant_creation")}>
<Button
variant="ghost"
size="sm"
onClick={() => startTour("participant_creation")}
>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Help</span>
<div className="flex h-5 w-5 items-center justify-center rounded-full border text-xs text-muted-foreground">?</div>
<div className="text-muted-foreground flex h-5 w-5 items-center justify-center rounded-full border text-xs">
?
</div>
</div>
</Button>
) : undefined

View File

@@ -109,7 +109,7 @@ export const columns: ColumnDef<Participant>[] = [
return (
<TooltipProvider>
<div>
<div className="truncate font-medium max-w-[200px]">
<div className="max-w-[200px] truncate font-medium">
<Tooltip>
<TooltipTrigger asChild>
<span>{String(name) || "No name provided"}</span>
@@ -120,7 +120,7 @@ export const columns: ColumnDef<Participant>[] = [
</Tooltip>
</div>
{email && (
<div className="text-muted-foreground truncate text-sm max-w-[200px]">
<div className="text-muted-foreground max-w-[200px] truncate text-sm">
<Tooltip>
<TooltipTrigger asChild>
<span>{email}</span>
@@ -214,18 +214,20 @@ export const columns: ColumnDef<Participant>[] = [
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem asChild>
<Link href={`/studies/${studyId}/participants/${participant.id}/edit`}>
<Link
href={`/studies/${studyId}/participants/${participant.id}/edit`}
>
<Edit className="mr-2 h-4 w-4" />
Edit participant
</Link >
</DropdownMenuItem >
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-red-600">
<Trash2 className="mr-2 h-4 w-4" />
Remove
</DropdownMenuItem>
</DropdownMenuContent >
</DropdownMenu >
</DropdownMenuContent>
</DropdownMenu>
);
},
},

View File

@@ -32,7 +32,7 @@ import {
SelectValue,
} from "~/components/ui/select";
import { Mail, Plus, UserPlus } from "lucide-react";
import { Mail, Plus, UserPlus, Microscope, Wand2, Eye } from "lucide-react";
import { Badge } from "~/components/ui/badge";
import { useStudyManagement } from "~/hooks/useStudyManagement";
@@ -54,17 +54,17 @@ const roleDescriptions = {
researcher: {
label: "Researcher",
description: "Can manage experiments, view all data, and invite members",
icon: "🔬",
icon: Microscope,
},
wizard: {
label: "Wizard",
description: "Can control trials and execute experiments",
icon: "🎭",
icon: Wand2,
},
observer: {
label: "Observer",
description: "Read-only access to view trials and data",
icon: "👁️",
icon: Eye,
},
};
@@ -167,7 +167,10 @@ export function InviteMemberDialog({
([value, config]) => (
<SelectItem key={value} value={value}>
<div className="flex items-center space-x-2">
<span>{config.icon}</span>
{(() => {
const Icon = config.icon;
return <Icon className="h-4 w-4" />;
})()}
<span>{config.label}</span>
</div>
</SelectItem>
@@ -180,8 +183,18 @@ export function InviteMemberDialog({
<div className="mt-2 rounded-lg bg-slate-50 p-3">
<div className="mb-1 flex items-center space-x-2">
<Badge variant="secondary" className="text-xs">
{roleDescriptions[field.value].icon}{" "}
{roleDescriptions[field.value].label}
{(() => {
const Icon =
roleDescriptions[
field.value as keyof typeof roleDescriptions
].icon;
return <Icon className="mr-1 h-3.5 w-3.5" />;
})()}
{
roleDescriptions[
field.value as keyof typeof roleDescriptions
].label
}
</Badge>
</div>
<p className="text-xs text-slate-600">

View File

@@ -5,7 +5,14 @@ import { ArrowUpDown, MoreHorizontal } from "lucide-react";
import * as React from "react";
import { formatDistanceToNow } from "date-fns";
import { AlertCircle, Filter } from "lucide-react";
import {
AlertCircle,
Filter,
Activity,
FileEdit,
CheckCircle2,
Archive,
} from "lucide-react";
import Link from "next/link";
import { Alert, AlertDescription } from "~/components/ui/alert";
import { Badge } from "~/components/ui/badge";
@@ -14,12 +21,12 @@ import { Card, CardContent } from "~/components/ui/card";
import { Checkbox } from "~/components/ui/checkbox";
import { DataTable } from "~/components/ui/data-table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import { api } from "~/trpc/react";
@@ -69,22 +76,22 @@ const statusConfig = {
draft: {
label: "Draft",
className: "bg-gray-100 text-gray-800",
icon: "📝",
icon: FileEdit,
},
active: {
label: "Active",
className: "bg-green-100 text-green-800",
icon: "🟢",
icon: Activity,
},
completed: {
label: "Completed",
className: "bg-blue-100 text-blue-800",
icon: "✅",
icon: CheckCircle2,
},
archived: {
label: "Archived",
className: "bg-orange-100 text-orange-800",
icon: "📦",
icon: Archive,
},
};
@@ -172,7 +179,7 @@ export const columns: ColumnDef<Study>[] = [
const statusInfo = statusConfig[status as keyof typeof statusConfig];
return (
<Badge className={statusInfo.className}>
<span className="mr-1">{statusInfo.icon}</span>
<statusInfo.icon className="mr-1 h-3.5 w-3.5" />
{statusInfo.label}
</Badge>
);
@@ -186,7 +193,9 @@ export const columns: ColumnDef<Study>[] = [
const isOwner = row.original.isOwner;
return (
<Badge variant={isOwner ? "default" : "secondary"}>{String(userRole)}</Badge>
<Badge variant={isOwner ? "default" : "secondary"}>
{String(userRole)}
</Badge>
);
},
},
@@ -215,7 +224,9 @@ export const columns: ColumnDef<Study>[] = [
);
}
return (
<Badge className="bg-blue-100 text-blue-800">{Number(experimentCount)}</Badge>
<Badge className="bg-blue-100 text-blue-800">
{Number(experimentCount)}
</Badge>
);
},
},
@@ -257,7 +268,9 @@ export const columns: ColumnDef<Study>[] = [
return (
<div className="max-w-[120px]">
<div className="text-sm">
{formatDistanceToNow(new Date(date as string | number | Date), { addSuffix: true })}
{formatDistanceToNow(new Date(date as string | number | Date), {
addSuffix: true,
})}
</div>
<div className="text-muted-foreground truncate text-xs">
by {createdBy}

View File

@@ -1,15 +1,16 @@
"use client";
import { formatDistanceToNow } from "date-fns";
import { CheckCircle2, Activity, FileEdit, Archive } from "lucide-react";
import Link from "next/link";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { Separator } from "~/components/ui/separator";
@@ -45,22 +46,22 @@ const statusConfig = {
draft: {
label: "Draft",
className: "bg-gray-100 text-gray-800 hover:bg-gray-200",
icon: "📝",
icon: FileEdit,
},
active: {
label: "Active",
className: "bg-green-100 text-green-800 hover:bg-green-200",
icon: "🟢",
icon: Activity,
},
completed: {
label: "Completed",
className: "bg-blue-100 text-blue-800 hover:bg-blue-200",
icon: "✅",
icon: CheckCircle2,
},
archived: {
label: "Archived",
className: "bg-orange-100 text-orange-800 hover:bg-orange-200",
icon: "📦",
icon: Archive,
},
};
@@ -84,7 +85,7 @@ export function StudyCard({ study, userRole, isOwner }: StudyCardProps) {
</CardDescription>
</div>
<Badge className={statusInfo.className} variant="secondary">
<span className="mr-1">{statusInfo.icon}</span>
<statusInfo.icon className="mr-1 h-3.5 w-3.5" />
{statusInfo.label}
</Badge>
</div>

View File

@@ -31,10 +31,7 @@ import { Button } from "../ui/button";
const studySchema = z.object({
name: z.string().min(1, "Study name is required").max(255, "Name too long"),
description: z
.string()
.min(10, "Description must be at least 10 characters")
.max(1000, "Description too long"),
description: z.string().max(1000, "Description too long").optional(),
institution: z
.string()
.min(1, "Institution is required")
@@ -115,7 +112,7 @@ export function StudyForm({ mode, studyId }: StudyFormProps) {
institution: data.institution,
irbProtocol: data.irbProtocolNumber ?? undefined,
});
router.push(`/studies/${newStudy.id}`);
router.push(`/studies/${newStudy.id}/participants/new`);
} else {
const updatedStudy = await updateStudyMutation.mutateAsync({
id: studyId!,
@@ -171,7 +168,7 @@ export function StudyForm({ mode, studyId }: StudyFormProps) {
title="Study Details"
description="Basic information and status of your research study."
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<FormField>
<Label htmlFor="tour-study-name">Study Name *</Label>
<Input
@@ -202,7 +199,9 @@ export function StudyForm({ mode, studyId }: StudyFormProps) {
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="draft">Draft - Study in preparation</SelectItem>
<SelectItem value="draft">
Draft - Study in preparation
</SelectItem>
<SelectItem value="active">
Active - Currently recruiting/running
</SelectItem>
@@ -218,7 +217,7 @@ export function StudyForm({ mode, studyId }: StudyFormProps) {
<div className="md:col-span-2">
<FormField>
<Label htmlFor="tour-study-description">Description *</Label>
<Label htmlFor="tour-study-description">Description</Label>
<Textarea
id="tour-study-description"
{...form.register("description")}
@@ -244,7 +243,7 @@ export function StudyForm({ mode, studyId }: StudyFormProps) {
title="Configuration"
description="Institutional details and ethics approval."
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<FormField>
<Label htmlFor="institution">Institution *</Label>
<Input
@@ -349,10 +348,16 @@ export function StudyForm({ mode, studyId }: StudyFormProps) {
sidebar={mode === "create" ? sidebar : undefined}
submitButtonId="tour-study-submit"
extraActions={
<Button variant="ghost" size="sm" onClick={() => startTour("study_creation")}>
<Button
variant="ghost"
size="sm"
onClick={() => startTour("study_creation")}
>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Help</span>
<div className="flex h-5 w-5 items-center justify-center rounded-full border text-xs text-muted-foreground">?</div>
<div className="text-muted-foreground flex h-5 w-5 items-center justify-center rounded-full border text-xs">
?
</div>
</div>
</Button>
}

View File

@@ -4,10 +4,10 @@ import { Monitor, Moon, Sun } from "lucide-react";
import { Button } from "~/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import { useTheme } from "./theme-provider";
@@ -18,8 +18,8 @@ export function ThemeToggle() {
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<Sun className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>

View File

@@ -85,7 +85,9 @@ function DateTimePicker({
return (
<div className="flex items-end gap-2">
<div className="grid gap-1.5">
<Label htmlFor="date-picker" className="text-xs">Date</Label>
<Label htmlFor="date-picker" className="text-xs">
Date
</Label>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
@@ -93,7 +95,7 @@ function DateTimePicker({
id="date-picker"
className={cn(
"w-[240px] justify-start text-left font-normal",
!value && "text-muted-foreground"
!value && "text-muted-foreground",
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
@@ -112,7 +114,9 @@ function DateTimePicker({
</div>
<div className="grid gap-1.5">
<Label htmlFor="time-picker" className="text-xs">Time</Label>
<Label htmlFor="time-picker" className="text-xs">
Time
</Label>
<div className="relative">
<Input
id="time-picker"
@@ -122,7 +126,7 @@ function DateTimePicker({
disabled={!value}
className="w-[120px]"
/>
<Clock className="absolute right-3 top-2.5 h-4 w-4 text-muted-foreground pointer-events-none" />
<Clock className="text-muted-foreground pointer-events-none absolute top-2.5 right-3 h-4 w-4" />
</div>
</div>
</div>
@@ -197,8 +201,8 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
{ participantId: selectedParticipantId },
{
enabled: !!selectedParticipantId && mode === "create",
refetchOnWindowFocus: false
}
refetchOnWindowFocus: false,
},
);
useEffect(() => {
@@ -213,33 +217,33 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
{ label: "Studies", href: "/studies" },
...(contextStudyId
? [
{
label: "Study",
href: `/studies/${contextStudyId}`,
},
{ label: "Trials", href: `/studies/${contextStudyId}/trials` },
...(mode === "edit" && trial
? [
{
label: `Trial ${trial.sessionNumber || trial.id.slice(-8)}`,
href: `/studies/${contextStudyId}/trials/${trial.id}`,
},
{ label: "Edit" },
]
: [{ label: "New Trial" }]),
]
{
label: "Study",
href: `/studies/${contextStudyId}`,
},
{ label: "Trials", href: `/studies/${contextStudyId}/trials` },
...(mode === "edit" && trial
? [
{
label: `Trial ${trial.sessionNumber || trial.id.slice(-8)}`,
href: `/studies/${contextStudyId}/trials/${trial.id}`,
},
{ label: "Edit" },
]
: [{ label: "New Trial" }]),
]
: [
{ label: "Trials", href: `/studies/${contextStudyId}/trials` },
...(mode === "edit" && trial
? [
{
label: `Trial ${trial.sessionNumber || trial.id.slice(-8)}`,
href: `/studies/${contextStudyId}/trials/${trial.id}`,
},
{ label: "Edit" },
]
: [{ label: "New Trial" }]),
]),
{ label: "Trials", href: `/studies/${contextStudyId}/trials` },
...(mode === "edit" && trial
? [
{
label: `Trial ${trial.sessionNumber || trial.id.slice(-8)}`,
href: `/studies/${contextStudyId}/trials/${trial.id}`,
},
{ label: "Edit" },
]
: [{ label: "New Trial" }]),
]),
];
useBreadcrumbsEffect(breadcrumbs);
@@ -250,7 +254,9 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
form.reset({
experimentId: trial.experimentId,
participantId: trial?.participantId ?? "",
scheduledAt: trial.scheduledAt ? new Date(trial.scheduledAt) : undefined,
scheduledAt: trial.scheduledAt
? new Date(trial.scheduledAt)
: undefined,
wizardId: trial.wizardId ?? undefined,
notes: trial.notes ?? "",
sessionNumber: trial.sessionNumber ?? 1,
@@ -334,9 +340,9 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
submitText={mode === "create" ? "Schedule Trial" : "Save Changes"}
layout="full-width"
>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
{/* Left Column: Main Info (Spans 2) */}
<div className="md:col-span-2 space-y-6">
<div className="space-y-6 md:col-span-2">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<FormField>
<Label htmlFor="experimentId">Experiment *</Label>

View File

@@ -1,7 +1,16 @@
"use client";
import { type ColumnDef } from "@tanstack/react-table";
import { ArrowUpDown, ChevronDown, MoreHorizontal, Play, Gamepad2, LineChart, Ban, Printer } from "lucide-react";
import {
ArrowUpDown,
ChevronDown,
MoreHorizontal,
Play,
Gamepad2,
LineChart,
Ban,
Printer,
} from "lucide-react";
import * as React from "react";
import { format, formatDistanceToNow } from "date-fns";
@@ -125,10 +134,7 @@ export const columns: ColumnDef<Trial>[] = [
return (
<div className="font-mono text-sm">
<Link
href={href}
className="hover:underline"
>
<Link href={href} className="hover:underline">
#{Number(sessionNumber)}
</Link>
</div>
@@ -240,12 +246,7 @@ export const columns: ColumnDef<Trial>[] = [
);
}
return (
<Badge className={statusInfo.className}>
{statusInfo.label}
</Badge>
);
return <Badge className={statusInfo.className}>{statusInfo.label}</Badge>;
},
},
{
@@ -363,7 +364,7 @@ function ActionsCell({ row }: { row: { original: Trial } }) {
}
return (
<div className="flex items-center gap-2 justify-end">
<div className="flex items-center justify-end gap-2">
{trial.status === "scheduled" && (
<Button size="sm" asChild>
<Link href={`/studies/${trial.studyId}/trials/${trial.id}/wizard`}>
@@ -383,14 +384,18 @@ function ActionsCell({ row }: { row: { original: Trial } }) {
{trial.status === "completed" && (
<>
<Button size="sm" variant="outline" asChild>
<Link href={`/studies/${trial.studyId}/trials/${trial.id}/analysis`}>
<Link
href={`/studies/${trial.studyId}/trials/${trial.id}/analysis`}
>
<LineChart className="mr-1.5 h-3.5 w-3.5" />
View
</Link>
</Button>
<Button size="sm" variant="outline" asChild>
{/* We link to the analysis page with a query param to trigger print/export */}
<Link href={`/studies/${trial.studyId}/trials/${trial.id}/analysis?export=true`}>
<Link
href={`/studies/${trial.studyId}/trials/${trial.id}/analysis?export=true`}
>
<Printer className="mr-1.5 h-3.5 w-3.5" />
Export
</Link>
@@ -398,7 +403,11 @@ function ActionsCell({ row }: { row: { original: Trial } }) {
</>
)}
{(trial.status === "scheduled" || trial.status === "failed") && (
<Button size="sm" variant="ghost" className="h-8 w-8 p-0 text-muted-foreground hover:text-red-600">
<Button
size="sm"
variant="ghost"
className="text-muted-foreground h-8 w-8 p-0 hover:text-red-600"
>
<Ban className="h-4 w-4" />
<span className="sr-only">Cancel</span>
</Button>

View File

@@ -3,149 +3,199 @@
import { type ColumnDef } from "@tanstack/react-table";
import { Badge } from "~/components/ui/badge";
import { cn } from "~/lib/utils";
import { CheckCircle, AlertTriangle, Info, Bot, User, Flag, MessageSquare, Activity } from "lucide-react";
import {
CheckCircle,
AlertTriangle,
Info,
Bot,
User,
Flag,
MessageSquare,
Activity,
} from "lucide-react";
// Define the shape of our data (matching schema)
export interface TrialEvent {
id: string;
trialId: string;
eventType: string;
timestamp: Date | string;
data: any;
createdBy: string | null;
id: string;
trialId: string;
eventType: string;
timestamp: Date | string;
data: any;
createdBy: string | null;
}
// Helper to format timestamp relative to start
function formatRelativeTime(timestamp: Date | string, startTime?: Date) {
if (!startTime) return "--:--";
const date = new Date(timestamp);
const diff = date.getTime() - startTime.getTime();
if (diff < 0) return "0:00";
if (!startTime) return "--:--";
const date = new Date(timestamp);
const diff = date.getTime() - startTime.getTime();
if (diff < 0) return "0:00";
const totalSeconds = Math.floor(diff / 1000);
const m = Math.floor(totalSeconds / 60);
const s = Math.floor(totalSeconds % 60);
// Optional: extended formatting for longer durations
const h = Math.floor(m / 60);
const totalSeconds = Math.floor(diff / 1000);
const m = Math.floor(totalSeconds / 60);
const s = Math.floor(totalSeconds % 60);
// Optional: extended formatting for longer durations
const h = Math.floor(m / 60);
if (h > 0) {
return `${h}:${(m % 60).toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
}
return `${m}:${s.toString().padStart(2, "0")}`;
if (h > 0) {
return `${h}:${(m % 60).toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
}
return `${m}:${s.toString().padStart(2, "0")}`;
}
export const eventsColumns = (startTime?: Date): ColumnDef<TrialEvent>[] => [
{
id: "timestamp",
header: "Time",
accessorKey: "timestamp",
size: 90,
meta: {
style: { width: '90px', minWidth: '90px' }
},
cell: ({ row }) => {
const date = new Date(row.original.timestamp);
return (
<div className="flex flex-col py-0.5">
<span className="font-mono font-medium text-xs">
{formatRelativeTime(row.original.timestamp, startTime)}
</span>
<span className="text-[10px] text-muted-foreground hidden group-hover:block">
{date.toLocaleTimeString()}
</span>
</div>
);
},
{
id: "timestamp",
header: "Time",
accessorKey: "timestamp",
size: 90,
meta: {
style: { width: "90px", minWidth: "90px" },
},
{
accessorKey: "eventType",
header: "Event Type",
size: 160,
meta: {
style: { width: '160px', minWidth: '160px' }
},
cell: ({ row }) => {
const type = row.getValue("eventType") as string;
const isError = type.includes("error");
const isIntervention = type.includes("intervention");
const isRobot = type.includes("robot");
const isStep = type.includes("step");
const isObservation = type.includes("annotation") || type.includes("note");
const isJump = type.includes("jump"); // intervention_step_jump
const isActionComplete = type.includes("marked_complete");
let Icon = Activity;
if (isError) Icon = AlertTriangle;
else if (isIntervention || isJump) Icon = User; // Jumps are interventions
else if (isRobot) Icon = Bot;
else if (isStep) Icon = Flag;
else if (isObservation) Icon = MessageSquare;
else if (type.includes("completed") || isActionComplete) Icon = CheckCircle;
return (
<div className="flex items-center py-0.5">
<Badge variant="outline" className={cn(
"capitalize font-medium flex w-fit items-center gap-1.5 px-2 py-0.5 text-[10px]",
isError && "border-red-200 bg-red-50 text-red-700 dark:border-red-900/50 dark:bg-red-900/20 dark:text-red-400",
(isIntervention || isJump) && "border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-900/50 dark:bg-orange-900/20 dark:text-orange-400",
isRobot && "border-purple-200 bg-purple-50 text-purple-700 dark:border-purple-900/50 dark:bg-purple-900/20 dark:text-purple-400",
isStep && "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/50 dark:bg-blue-900/20 dark:text-blue-400",
isObservation && "border-yellow-200 bg-yellow-50 text-yellow-700 dark:border-yellow-900/50 dark:bg-yellow-900/20 dark:text-yellow-400",
isActionComplete && "border-green-200 bg-green-50 text-green-700 dark:border-green-900/50 dark:bg-green-900/20 dark:text-green-400"
)}>
<Icon className="h-3 w-3" />
{type.replace(/_/g, " ")}
</Badge>
</div>
);
},
filterFn: (row, id, value) => {
return value.includes(row.getValue(id));
},
cell: ({ row }) => {
const date = new Date(row.original.timestamp);
return (
<div className="flex flex-col py-0.5">
<span className="font-mono text-xs font-medium">
{formatRelativeTime(row.original.timestamp, startTime)}
</span>
<span className="text-muted-foreground hidden text-[10px] group-hover:block">
{date.toLocaleTimeString()}
</span>
</div>
);
},
{
accessorKey: "data",
header: "Details",
cell: ({ row }) => {
const data = row.original.data;
const type = row.getValue("eventType") as string;
// Wrapper for density and alignment
const Wrapper = ({ children }: { children: React.ReactNode }) => (
<div className="py-0.5 min-w-[300px] whitespace-normal break-words text-xs leading-normal">
{children}
</div>
);
if (!data || Object.keys(data).length === 0) return <Wrapper><span className="text-muted-foreground">-</span></Wrapper>;
// Smart Formatting
if (type.includes("jump")) {
return (
<Wrapper>
Jumped to step <strong>{data.stepName || (data.toIndex !== undefined ? data.toIndex + 1 : "?")}</strong>
<span className="text-muted-foreground ml-1">(Manual)</span>
</Wrapper>
);
}
if (type.includes("skipped")) {
return <Wrapper><span className="text-orange-600 dark:text-orange-400">Skipped: {data.actionId}</span></Wrapper>;
}
if (type.includes("marked_complete")) {
return <Wrapper><span className="text-green-600 dark:text-green-400">Manually marked complete</span></Wrapper>;
}
if (type.includes("annotation") || type.includes("note")) {
return <Wrapper><span className="italic text-foreground/80">{data.description || data.note || data.message || "No content"}</span></Wrapper>;
}
return (
<Wrapper>
<code className="font-mono text-muted-foreground bg-muted/50 px-1.5 py-0.5 rounded border inline-block max-w-full truncate align-middle">
{JSON.stringify(data).replace(/[{""}]/g, " ").trim()}
</code>
</Wrapper>
);
},
},
{
accessorKey: "eventType",
header: "Event Type",
size: 160,
meta: {
style: { width: "160px", minWidth: "160px" },
},
cell: ({ row }) => {
const type = row.getValue("eventType") as string;
const isError = type.includes("error");
const isIntervention = type.includes("intervention");
const isRobot = type.includes("robot");
const isStep = type.includes("step");
const isObservation =
type.includes("annotation") || type.includes("note");
const isJump = type.includes("jump"); // intervention_step_jump
const isActionComplete = type.includes("marked_complete");
let Icon = Activity;
if (isError) Icon = AlertTriangle;
else if (isIntervention || isJump)
Icon = User; // Jumps are interventions
else if (isRobot) Icon = Bot;
else if (isStep) Icon = Flag;
else if (isObservation) Icon = MessageSquare;
else if (type.includes("completed") || isActionComplete)
Icon = CheckCircle;
return (
<div className="flex items-center py-0.5">
<Badge
variant="outline"
className={cn(
"flex w-fit items-center gap-1.5 px-2 py-0.5 text-[10px] font-medium capitalize",
isError &&
"border-red-200 bg-red-50 text-red-700 dark:border-red-900/50 dark:bg-red-900/20 dark:text-red-400",
(isIntervention || isJump) &&
"border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-900/50 dark:bg-orange-900/20 dark:text-orange-400",
isRobot &&
"border-purple-200 bg-purple-50 text-purple-700 dark:border-purple-900/50 dark:bg-purple-900/20 dark:text-purple-400",
isStep &&
"border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/50 dark:bg-blue-900/20 dark:text-blue-400",
isObservation &&
"border-yellow-200 bg-yellow-50 text-yellow-700 dark:border-yellow-900/50 dark:bg-yellow-900/20 dark:text-yellow-400",
isActionComplete &&
"border-green-200 bg-green-50 text-green-700 dark:border-green-900/50 dark:bg-green-900/20 dark:text-green-400",
)}
>
<Icon className="h-3 w-3" />
{type.replace(/_/g, " ")}
</Badge>
</div>
);
},
filterFn: (row, id, value) => {
return value.includes(row.getValue(id));
},
},
{
accessorKey: "data",
header: "Details",
cell: ({ row }) => {
const data = row.original.data;
const type = row.getValue("eventType") as string;
// Wrapper for density and alignment
const Wrapper = ({ children }: { children: React.ReactNode }) => (
<div className="min-w-[300px] py-0.5 text-xs leading-normal break-words whitespace-normal">
{children}
</div>
);
if (!data || Object.keys(data).length === 0)
return (
<Wrapper>
<span className="text-muted-foreground">-</span>
</Wrapper>
);
// Smart Formatting
if (type.includes("jump")) {
return (
<Wrapper>
Jumped to step{" "}
<strong>
{data.stepName ||
(data.toIndex !== undefined ? data.toIndex + 1 : "?")}
</strong>
<span className="text-muted-foreground ml-1">(Manual)</span>
</Wrapper>
);
}
if (type.includes("skipped")) {
return (
<Wrapper>
<span className="text-orange-600 dark:text-orange-400">
Skipped: {data.actionId}
</span>
</Wrapper>
);
}
if (type.includes("marked_complete")) {
return (
<Wrapper>
<span className="text-green-600 dark:text-green-400">
Manually marked complete
</span>
</Wrapper>
);
}
if (type.includes("annotation") || type.includes("note")) {
return (
<Wrapper>
<span className="text-foreground/80 italic">
{data.description || data.note || data.message || "No content"}
</span>
</Wrapper>
);
}
return (
<Wrapper>
<code className="text-muted-foreground bg-muted/50 inline-block max-w-full truncate rounded border px-1.5 py-0.5 align-middle font-mono">
{JSON.stringify(data)
.replace(/[{""}]/g, " ")
.trim()}
</code>
</Wrapper>
);
},
},
];

View File

@@ -2,295 +2,405 @@
import * as React from "react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "~/components/ui/table";
import { Badge } from "~/components/ui/badge";
import { Input } from "~/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { usePlayback } from "../playback/PlaybackContext";
import { cn } from "~/lib/utils";
import {
CheckCircle,
AlertTriangle,
Bot,
User,
Flag,
MessageSquare,
Activity,
Video
CheckCircle,
AlertTriangle,
Bot,
User,
Flag,
MessageSquare,
Activity,
Video,
} from "lucide-react";
import { type TrialEvent } from "./events-columns";
interface EventsDataTableProps {
data: TrialEvent[];
startTime?: Date;
data: TrialEvent[];
startTime?: Date;
}
// Helper to format timestamp relative to start
function formatRelativeTime(timestamp: Date | string, startTime?: Date) {
if (!startTime) return "--:--";
const date = new Date(timestamp);
const diff = date.getTime() - startTime.getTime();
if (diff < 0) return "0:00";
if (!startTime) return "--:--";
const date = new Date(timestamp);
const diff = date.getTime() - startTime.getTime();
if (diff < 0) return "0:00";
const totalSeconds = Math.floor(diff / 1000);
const m = Math.floor(totalSeconds / 60);
const s = Math.floor(totalSeconds % 60);
const totalSeconds = Math.floor(diff / 1000);
const m = Math.floor(totalSeconds / 60);
const s = Math.floor(totalSeconds % 60);
// Optional: extended formatting for longer durations
const h = Math.floor(m / 60);
// Optional: extended formatting for longer durations
const h = Math.floor(m / 60);
if (h > 0) {
return `${h}:${(m % 60).toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
}
return `${m}:${s.toString().padStart(2, "0")}`;
if (h > 0) {
return `${h}:${(m % 60).toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
}
return `${m}:${s.toString().padStart(2, "0")}`;
}
export function EventsDataTable({ data, startTime }: EventsDataTableProps) {
const { seekTo, events, currentEventIndex } = usePlayback();
const [eventTypeFilter, setEventTypeFilter] = React.useState<string>("all");
const [globalFilter, setGlobalFilter] = React.useState<string>("");
const { seekTo, events, currentEventIndex } = usePlayback();
const [eventTypeFilter, setEventTypeFilter] = React.useState<string>("all");
const [globalFilter, setGlobalFilter] = React.useState<string>("");
// Enhanced filtering logic
const filteredData = React.useMemo(() => {
return data.filter(event => {
// Type filter
if (eventTypeFilter !== "all" && !event.eventType.includes(eventTypeFilter)) {
return false;
}
// Enhanced filtering logic
const filteredData = React.useMemo(() => {
return data.filter((event) => {
// Type filter
if (
eventTypeFilter !== "all" &&
!event.eventType.includes(eventTypeFilter)
) {
return false;
}
// Global text search (checks type and data)
if (globalFilter) {
const searchLower = globalFilter.toLowerCase();
const typeMatch = event.eventType.toLowerCase().includes(searchLower);
// Safe JSON stringify check
const dataString = event.data ? JSON.stringify(event.data).toLowerCase() : "";
const dataMatch = dataString.includes(searchLower);
// Global text search (checks type and data)
if (globalFilter) {
const searchLower = globalFilter.toLowerCase();
const typeMatch = event.eventType.toLowerCase().includes(searchLower);
// Safe JSON stringify check
const dataString = event.data
? JSON.stringify(event.data).toLowerCase()
: "";
const dataMatch = dataString.includes(searchLower);
return typeMatch || dataMatch;
}
return typeMatch || dataMatch;
}
return true;
});
}, [data, eventTypeFilter, globalFilter]);
return true;
});
}, [data, eventTypeFilter, globalFilter]);
// Active Event Logic & Auto-scroll
// Match filtered events with global playback "active event" via ID
const activeEventId = React.useMemo(() => {
if (currentEventIndex >= 0 && currentEventIndex < events.length) {
// We need to match the type of ID used in data/events
// Assuming events from context are TrialEvent compatible
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const evt = events[currentEventIndex] as any;
return evt?.id;
}
return null;
}, [events, currentEventIndex]);
// Active Event Logic & Auto-scroll
// Match filtered events with global playback "active event" via ID
const activeEventId = React.useMemo(() => {
if (currentEventIndex >= 0 && currentEventIndex < events.length) {
// We need to match the type of ID used in data/events
// Assuming events from context are TrialEvent compatible
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const evt = events[currentEventIndex] as any;
return evt?.id;
}
return null;
}, [events, currentEventIndex]);
const rowRefs = React.useRef<{ [key: string]: HTMLTableRowElement | null }>({});
const rowRefs = React.useRef<{ [key: string]: HTMLTableRowElement | null }>(
{},
);
React.useEffect(() => {
if (activeEventId && rowRefs.current[activeEventId]) {
rowRefs.current[activeEventId]?.scrollIntoView({
behavior: "smooth",
block: "center",
});
}
}, [activeEventId]);
React.useEffect(() => {
if (activeEventId && rowRefs.current[activeEventId]) {
rowRefs.current[activeEventId]?.scrollIntoView({
behavior: "smooth",
block: "center",
});
}
}, [activeEventId]);
const handleRowClick = (event: TrialEvent) => {
if (!startTime) return;
const timeMs = new Date(event.timestamp).getTime();
const seekSeconds = (timeMs - startTime.getTime()) / 1000;
seekTo(Math.max(0, seekSeconds));
};
const handleRowClick = (event: TrialEvent) => {
if (!startTime) return;
const timeMs = new Date(event.timestamp).getTime();
const seekSeconds = (timeMs - startTime.getTime()) / 1000;
seekTo(Math.max(0, seekSeconds));
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex flex-1 items-center space-x-2">
<Input
id="tour-analytics-filter"
placeholder="Search event data..."
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
className="h-8 w-[150px] lg:w-[250px]"
/>
<Select value={eventTypeFilter} onValueChange={setEventTypeFilter}>
<SelectTrigger className="h-8 w-[160px]">
<SelectValue placeholder="All Events" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Events</SelectItem>
<SelectItem value="action_executed">Actions</SelectItem>
<SelectItem value="action_skipped">Skipped Actions</SelectItem>
<SelectItem value="intervention">Interventions</SelectItem>
<SelectItem value="robot">Robot Actions</SelectItem>
<SelectItem value="step">Step Changes</SelectItem>
<SelectItem value="error">Errors</SelectItem>
<SelectItem value="annotation">Notes</SelectItem>
</SelectContent>
</Select>
</div>
<div className="text-xs text-muted-foreground mr-2">
{filteredData.length} events
</div>
</div>
<div id="tour-analytics-table" className="rounded-md border bg-background">
<div>
<Table className="w-full">
<TableHeader className="sticky top-0 bg-background z-10 shadow-sm">
<TableRow className="bg-muted/50 hover:bg-muted/50">
<TableHead className="w-[100px]">Time</TableHead>
<TableHead className="w-[180px]">Event Type</TableHead>
<TableHead className="w-auto">Details</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredData.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="h-24 text-center">
No results.
</TableCell>
</TableRow>
) : (
filteredData.map((event, index) => {
const type = event.eventType;
const data = event.data;
// Type Logic
const isError = type.includes("error");
const isIntervention = type.includes("intervention");
const isRobot = type.includes("robot");
const isStep = type.includes("step");
const isObservation = type.includes("annotation") || type.includes("note");
const isJump = type.includes("jump");
const isActionComplete = type.includes("marked_complete");
const isCamera = type.includes("camera");
let Icon = Activity;
if (isError) Icon = AlertTriangle;
else if (isIntervention || isJump) Icon = User;
else if (isRobot) Icon = Bot;
else if (isStep) Icon = Flag;
else if (isObservation) Icon = MessageSquare;
else if (isCamera) Icon = Video;
else if (type.includes("completed") || isActionComplete) Icon = CheckCircle;
// Details Logic
let detailsContent;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const d = data as any; // Cast for easier access
if (type.includes("jump")) {
detailsContent = (
<>Jumped to step <strong>{d?.stepName || (d?.toIndex !== undefined ? d.toIndex + 1 : "?")}</strong> <span className="text-muted-foreground ml-1">(Manual)</span></>
);
} else if (type.includes("skipped")) {
detailsContent = <span className="text-orange-600 dark:text-orange-400">Skipped: {d?.actionId}</span>;
} else if (type.includes("marked_complete")) {
detailsContent = <span className="text-green-600 dark:text-green-400">Manually marked complete</span>;
} else if (type.includes("annotation") || type.includes("note")) {
detailsContent = <span className="italic text-foreground/80">{d?.description || d?.note || d?.message || "No content"}</span>;
} else if (type.includes("step")) {
detailsContent = <span>Step: <strong>{d?.stepName || d?.name || (d?.index !== undefined ? `Index ${d.index}` : "")}</strong></span>;
} else if (type.includes("action_executed")) {
const name = d?.actionName || d?.actionId;
const meta = d?.actionType ? `(${d.actionType})` : d?.type ? `(${d.type})` : "";
detailsContent = <span>Executed: <strong>{name}</strong> <span className="text-muted-foreground text-[10px] ml-1">{meta}</span></span>;
} else if (type.includes("robot") || type.includes("say") || type.includes("speech")) {
const text = d?.text || d?.message || d?.data?.text;
detailsContent = (
<span>
Robot: <strong>{d?.command || d?.type || "Action"}</strong>
{text && <span className="text-muted-foreground ml-1">"{text}"</span>}
</span>
);
} else if (type.includes("intervention")) {
detailsContent = <span className="text-orange-600 dark:text-orange-400">Intervention: {d?.type || "Manual Action"}</span>;
} else if (type === "trial_started") {
detailsContent = <span className="text-green-600 font-medium">Trial Started</span>;
} else if (type === "trial_completed") {
detailsContent = <span className="text-blue-600 font-medium">Trial Completed</span>;
} else if (type === "trial_paused") {
detailsContent = <span className="text-yellow-600 font-medium">Trial Paused</span>;
} else if (isCamera) {
detailsContent = <span className="font-medium text-teal-600 dark:text-teal-400">{type === "camera_started" ? "Recording Started" : type === "camera_stopped" ? "Recording Stopped" : "Camera Event"}</span>;
} else {
// Default
if (d && Object.keys(d).length > 0) {
detailsContent = (
<code className="font-mono text-muted-foreground bg-muted/50 px-1 py-0.5 rounded border inline-block max-w-full truncate align-middle text-[10px]">
{JSON.stringify(d).replace(/[{"}]/g, " ").trim()}
</code>
);
} else {
detailsContent = <span className="text-muted-foreground text-xs">-</span>;
}
}
const isActive = activeEventId === event.id;
return (
<TableRow
key={event.id || index}
ref={(el) => {
if (event.id) rowRefs.current[event.id] = el;
}}
className={cn(
"cursor-pointer h-auto border-l-2 border-transparent transition-colors",
isActive
? "bg-muted border-l-primary"
: "hover:bg-muted/50"
)}
onClick={() => handleRowClick(event)}
>
<TableCell className="py-1 align-top w-[100px]">
<div className="flex flex-col">
<span className="font-mono font-medium text-xs">
{formatRelativeTime(event.timestamp, startTime)}
</span>
<span className="text-[10px] text-muted-foreground hidden group-hover:block">
{new Date(event.timestamp).toLocaleTimeString()}
</span>
</div>
</TableCell>
<TableCell className="py-1 align-top w-[180px]">
<div className="flex items-center">
<Badge variant="outline" className={cn(
"capitalize font-medium flex w-fit items-center gap-1.5 px-2 py-0.5 text-[10px]",
isError && "border-red-200 bg-red-50 text-red-700 dark:border-red-900/50 dark:bg-red-900/20 dark:text-red-400",
(isIntervention || isJump) && "border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-900/50 dark:bg-orange-900/20 dark:text-orange-400",
isRobot && "border-purple-200 bg-purple-50 text-purple-700 dark:border-purple-900/50 dark:bg-purple-900/20 dark:text-purple-400",
isCamera && "border-teal-200 bg-teal-50 text-teal-700 dark:border-teal-900/50 dark:bg-teal-900/20 dark:text-teal-400",
isStep && "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/50 dark:bg-blue-900/20 dark:text-blue-400",
isObservation && "border-yellow-200 bg-yellow-50 text-yellow-700 dark:border-yellow-900/50 dark:bg-yellow-900/20 dark:text-yellow-400",
isActionComplete && "border-green-200 bg-green-50 text-green-700 dark:border-green-900/50 dark:bg-green-900/20 dark:text-green-400"
)}>
<Icon className="h-3 w-3" />
{type.replace(/_/g, " ")}
</Badge>
</div>
</TableCell>
<TableCell className="py-1 align-top w-auto">
<div className="text-xs break-words whitespace-normal leading-normal min-w-0">
{detailsContent}
</div>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
</div>
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex flex-1 items-center space-x-2">
<Input
id="tour-analytics-filter"
placeholder="Search event data..."
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
className="h-8 w-[150px] lg:w-[250px]"
/>
<Select value={eventTypeFilter} onValueChange={setEventTypeFilter}>
<SelectTrigger className="h-8 w-[160px]">
<SelectValue placeholder="All Events" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Events</SelectItem>
<SelectItem value="action_executed">Actions</SelectItem>
<SelectItem value="action_skipped">Skipped Actions</SelectItem>
<SelectItem value="intervention">Interventions</SelectItem>
<SelectItem value="robot">Robot Actions</SelectItem>
<SelectItem value="step">Step Changes</SelectItem>
<SelectItem value="error">Errors</SelectItem>
<SelectItem value="annotation">Notes</SelectItem>
</SelectContent>
</Select>
</div>
);
<div className="text-muted-foreground mr-2 text-xs">
{filteredData.length} events
</div>
</div>
<div
id="tour-analytics-table"
className="bg-background rounded-md border"
>
<div>
<Table className="w-full">
<TableHeader className="bg-background sticky top-0 z-10 shadow-sm">
<TableRow className="bg-muted/50 hover:bg-muted/50">
<TableHead className="w-[100px]">Time</TableHead>
<TableHead className="w-[180px]">Event Type</TableHead>
<TableHead className="w-auto">Details</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredData.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="h-24 text-center">
No results.
</TableCell>
</TableRow>
) : (
filteredData.map((event, index) => {
const type = event.eventType;
const data = event.data;
// Type Logic
const isError = type.includes("error");
const isIntervention = type.includes("intervention");
const isRobot = type.includes("robot");
const isStep = type.includes("step");
const isObservation =
type.includes("annotation") || type.includes("note");
const isJump = type.includes("jump");
const isActionComplete = type.includes("marked_complete");
const isCamera = type.includes("camera");
let Icon = Activity;
if (isError) Icon = AlertTriangle;
else if (isIntervention || isJump) Icon = User;
else if (isRobot) Icon = Bot;
else if (isStep) Icon = Flag;
else if (isObservation) Icon = MessageSquare;
else if (isCamera) Icon = Video;
else if (type.includes("completed") || isActionComplete)
Icon = CheckCircle;
// Details Logic
let detailsContent;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const d = data as any; // Cast for easier access
if (type.includes("jump")) {
detailsContent = (
<>
Jumped to step{" "}
<strong>
{d?.stepName ||
(d?.toIndex !== undefined ? d.toIndex + 1 : "?")}
</strong>{" "}
<span className="text-muted-foreground ml-1">
(Manual)
</span>
</>
);
} else if (type.includes("skipped")) {
detailsContent = (
<span className="text-orange-600 dark:text-orange-400">
Skipped: {d?.actionId}
</span>
);
} else if (type.includes("marked_complete")) {
detailsContent = (
<span className="text-green-600 dark:text-green-400">
Manually marked complete
</span>
);
} else if (
type.includes("annotation") ||
type.includes("note")
) {
detailsContent = (
<span className="text-foreground/80 italic">
{d?.description ||
d?.note ||
d?.message ||
"No content"}
</span>
);
} else if (type.includes("step")) {
detailsContent = (
<span>
Step:{" "}
<strong>
{d?.stepName ||
d?.name ||
(d?.index !== undefined ? `Index ${d.index}` : "")}
</strong>
</span>
);
} else if (type.includes("action_executed")) {
const name = d?.actionName || d?.actionId;
const meta = d?.actionType
? `(${d.actionType})`
: d?.type
? `(${d.type})`
: "";
detailsContent = (
<span>
Executed: <strong>{name}</strong>{" "}
<span className="text-muted-foreground ml-1 text-[10px]">
{meta}
</span>
</span>
);
} else if (
type.includes("robot") ||
type.includes("say") ||
type.includes("speech")
) {
const text = d?.text || d?.message || d?.data?.text;
detailsContent = (
<span>
Robot:{" "}
<strong>{d?.command || d?.type || "Action"}</strong>
{text && (
<span className="text-muted-foreground ml-1">
"{text}"
</span>
)}
</span>
);
} else if (type.includes("intervention")) {
detailsContent = (
<span className="text-orange-600 dark:text-orange-400">
Intervention: {d?.type || "Manual Action"}
</span>
);
} else if (type === "trial_started") {
detailsContent = (
<span className="font-medium text-green-600">
Trial Started
</span>
);
} else if (type === "trial_completed") {
detailsContent = (
<span className="font-medium text-blue-600">
Trial Completed
</span>
);
} else if (type === "trial_paused") {
detailsContent = (
<span className="font-medium text-yellow-600">
Trial Paused
</span>
);
} else if (isCamera) {
detailsContent = (
<span className="font-medium text-teal-600 dark:text-teal-400">
{type === "camera_started"
? "Recording Started"
: type === "camera_stopped"
? "Recording Stopped"
: "Camera Event"}
</span>
);
} else {
// Default
if (d && Object.keys(d).length > 0) {
detailsContent = (
<code className="text-muted-foreground bg-muted/50 inline-block max-w-full truncate rounded border px-1 py-0.5 align-middle font-mono text-[10px]">
{JSON.stringify(d).replace(/[{"}]/g, " ").trim()}
</code>
);
} else {
detailsContent = (
<span className="text-muted-foreground text-xs">-</span>
);
}
}
const isActive = activeEventId === event.id;
return (
<TableRow
key={event.id || index}
ref={(el) => {
if (event.id) rowRefs.current[event.id] = el;
}}
className={cn(
"h-auto cursor-pointer border-l-2 border-transparent transition-colors",
isActive
? "bg-muted border-l-primary"
: "hover:bg-muted/50",
)}
onClick={() => handleRowClick(event)}
>
<TableCell className="w-[100px] py-1 align-top">
<div className="flex flex-col">
<span className="font-mono text-xs font-medium">
{formatRelativeTime(event.timestamp, startTime)}
</span>
<span className="text-muted-foreground hidden text-[10px] group-hover:block">
{new Date(event.timestamp).toLocaleTimeString()}
</span>
</div>
</TableCell>
<TableCell className="w-[180px] py-1 align-top">
<div className="flex items-center">
<Badge
variant="outline"
className={cn(
"flex w-fit items-center gap-1.5 px-2 py-0.5 text-[10px] font-medium capitalize",
isError &&
"border-red-200 bg-red-50 text-red-700 dark:border-red-900/50 dark:bg-red-900/20 dark:text-red-400",
(isIntervention || isJump) &&
"border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-900/50 dark:bg-orange-900/20 dark:text-orange-400",
isRobot &&
"border-purple-200 bg-purple-50 text-purple-700 dark:border-purple-900/50 dark:bg-purple-900/20 dark:text-purple-400",
isCamera &&
"border-teal-200 bg-teal-50 text-teal-700 dark:border-teal-900/50 dark:bg-teal-900/20 dark:text-teal-400",
isStep &&
"border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/50 dark:bg-blue-900/20 dark:text-blue-400",
isObservation &&
"border-yellow-200 bg-yellow-50 text-yellow-700 dark:border-yellow-900/50 dark:bg-yellow-900/20 dark:text-yellow-400",
isActionComplete &&
"border-green-200 bg-green-50 text-green-700 dark:border-green-900/50 dark:bg-green-900/20 dark:text-green-400",
)}
>
<Icon className="h-3 w-3" />
{type.replace(/_/g, " ")}
</Badge>
</div>
</TableCell>
<TableCell className="w-auto py-1 align-top">
<div className="min-w-0 text-xs leading-normal break-words whitespace-normal">
{detailsContent}
</div>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
</div>
</div>
);
}

View File

@@ -4,198 +4,239 @@ 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
AlertTriangle,
CheckCircle,
Flag,
MessageSquare,
Zap,
Circle,
Bot,
User,
Activity,
} from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
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")}`;
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();
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();
return 0;
}, [contextStartTime]);
const effectiveDuration = useMemo(() => {
if (duration > 0) return duration * 1000;
return 60000; // 1 min default
}, [duration]);
// 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
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") || type.includes("jump")) return <User className="h-4 w-4" />;
if (type.includes("robot") || type.includes("action")) return <Bot className="h-4 w-4" />;
if (type.includes("completed")) return <CheckCircle className="h-4 w-4" />;
if (type.includes("start")) return <Flag className="h-4 w-4" />;
if (type.includes("note") || type.includes("annotation")) return <MessageSquare className="h-4 w-4" />;
if (type.includes("error")) return <AlertTriangle className="h-4 w-4" />;
return <Activity className="h-4 w-4" />;
};
const getEventColor = (type: string) => {
if (type.includes("intervention") || type.includes("wizard") || type.includes("jump")) return "bg-orange-100 text-orange-600 border-orange-200";
if (type.includes("robot") || type.includes("action")) return "bg-purple-100 text-purple-600 border-purple-200";
if (type.includes("completed")) return "bg-green-100 text-green-600 border-green-200";
if (type.includes("start")) return "bg-blue-100 text-blue-600 border-blue-200";
if (type.includes("note") || type.includes("annotation")) return "bg-yellow-100 text-yellow-600 border-yellow-200";
if (type.includes("error")) return "bg-red-100 text-red-600 border-red-200";
return "bg-slate-100 text-slate-600 border-slate-200";
};
return (
<div className="w-full h-28 flex flex-col justify-center px-8 select-none">
<TooltipProvider delayDuration={0}>
{/* Main Interactive Area */}
<div
ref={containerRef}
className="relative w-full h-16 flex items-center cursor-pointer group"
onClick={handleSeek}
>
{/* The Timeline Line (Horizontal) */}
<div className="absolute left-0 right-0 h-0.5 top-1/2 -mt-px bg-border group-hover:bg-border/80 transition-colors" />
{/* Progress Fill */}
<div
className="absolute left-0 h-0.5 bg-primary/30 pointer-events-none"
style={{ width: `${currentProgress}%`, top: '50%', marginTop: '-1px' }}
/>
{/* Playhead (Scanner) */}
<div
className="absolute h-16 w-px bg-red-500 z-30 pointer-events-none transition-all duration-75"
style={{ left: `${currentProgress}%`, top: '50%', transform: 'translateY(-50%)' }}
>
{/* Knob */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-3 h-3 bg-red-500 rounded-full shadow border border-white" />
</div>
{/* Events (Avatars/Dots) */}
{sortedEvents.map((event, i) => {
const pct = getPercentage(new Date(event.timestamp).getTime());
// Smart Formatting Logic
const details = (() => {
const { eventType, data } = event;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const d = data as any;
if (eventType.includes("jump")) return `Jumped to step ${d?.stepName || d?.toIndex + 1 || "?"} (Manual)`;
if (eventType.includes("skipped")) return `Skipped: ${d?.actionId}`;
if (eventType.includes("marked_complete")) return "Manually marked complete";
if (eventType.includes("annotation") || eventType.includes("note")) return d?.description || d?.note || d?.message || "Note";
if (!d || Object.keys(d).length === 0) return null;
return JSON.stringify(d).slice(0, 100).replace(/[{""}]/g, " ").trim();
})();
return (
<Tooltip key={i}>
<TooltipTrigger asChild>
<div
className="absolute z-20 top-1/2 left-0 transform -translate-x-1/2 -translate-y-1/2 flex flex-col items-center group/event cursor-pointer p-2"
style={{ left: `${pct}%` }}
onClick={(e) => {
e.stopPropagation();
// startTime is in ms, timestamp is Date string or obj
const timeMs = new Date(event.timestamp).getTime();
const seekSeconds = (timeMs - startTime) / 1000;
seekTo(Math.max(0, seekSeconds));
}}
>
<div className={cn(
"flex h-7 w-7 items-center justify-center rounded-full border shadow-sm transition-transform hover:scale-125 hover:z-50 bg-background relative z-20",
getEventColor(event.eventType)
)}>
{getEventIcon(event.eventType)}
</div>
</div>
</TooltipTrigger>
<TooltipContent side="top">
<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>
{!!details && (
<div className="bg-muted/50 p-1.5 rounded text-[10px] max-w-[220px] break-words whitespace-normal border">
{details}
</div>
)}
</TooltipContent>
</Tooltip>
);
})}
{/* Ticks (Below) */}
{ticks.map((tick, i) => (
<div
key={i}
className="absolute top-10 text-[10px] font-mono text-muted-foreground transform -translate-x-1/2 pointer-events-none flex flex-col items-center"
style={{ left: `${tick.pct}%` }}
>
{/* Tick Mark */}
<div className="w-px h-2 bg-border mb-1" />
{tick.label}
</div>
))}
</div>
</TooltipProvider>
</div>
// 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();
return 0;
}, [contextStartTime]);
const effectiveDuration = useMemo(() => {
if (duration > 0) return duration * 1000;
return 60000; // 1 min default
}, [duration]);
// 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
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") ||
type.includes("jump")
)
return <User className="h-4 w-4" />;
if (type.includes("robot") || type.includes("action"))
return <Bot className="h-4 w-4" />;
if (type.includes("completed")) return <CheckCircle className="h-4 w-4" />;
if (type.includes("start")) return <Flag className="h-4 w-4" />;
if (type.includes("note") || type.includes("annotation"))
return <MessageSquare className="h-4 w-4" />;
if (type.includes("error")) return <AlertTriangle className="h-4 w-4" />;
return <Activity className="h-4 w-4" />;
};
const getEventColor = (type: string) => {
if (
type.includes("intervention") ||
type.includes("wizard") ||
type.includes("jump")
)
return "bg-orange-100 text-orange-600 border-orange-200";
if (type.includes("robot") || type.includes("action"))
return "bg-purple-100 text-purple-600 border-purple-200";
if (type.includes("completed"))
return "bg-green-100 text-green-600 border-green-200";
if (type.includes("start"))
return "bg-blue-100 text-blue-600 border-blue-200";
if (type.includes("note") || type.includes("annotation"))
return "bg-yellow-100 text-yellow-600 border-yellow-200";
if (type.includes("error")) return "bg-red-100 text-red-600 border-red-200";
return "bg-slate-100 text-slate-600 border-slate-200";
};
return (
<div className="flex h-28 w-full flex-col justify-center px-8 select-none">
<TooltipProvider delayDuration={0}>
{/* Main Interactive Area */}
<div
ref={containerRef}
className="group relative flex h-16 w-full cursor-pointer items-center"
onClick={handleSeek}
>
{/* The Timeline Line (Horizontal) */}
<div className="bg-border group-hover:bg-border/80 absolute top-1/2 right-0 left-0 -mt-px h-0.5 transition-colors" />
{/* Progress Fill */}
<div
className="bg-primary/30 pointer-events-none absolute left-0 h-0.5"
style={{
width: `${currentProgress}%`,
top: "50%",
marginTop: "-1px",
}}
/>
{/* Playhead (Scanner) */}
<div
className="pointer-events-none absolute z-30 h-16 w-px bg-red-500 transition-all duration-75"
style={{
left: `${currentProgress}%`,
top: "50%",
transform: "translateY(-50%)",
}}
>
{/* Knob */}
<div className="absolute top-1/2 left-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full border border-white bg-red-500 shadow" />
</div>
{/* Events (Avatars/Dots) */}
{sortedEvents.map((event, i) => {
const pct = getPercentage(new Date(event.timestamp).getTime());
// Smart Formatting Logic
const details = (() => {
const { eventType, data } = event;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const d = data as any;
if (eventType.includes("jump"))
return `Jumped to step ${d?.stepName || d?.toIndex + 1 || "?"} (Manual)`;
if (eventType.includes("skipped"))
return `Skipped: ${d?.actionId}`;
if (eventType.includes("marked_complete"))
return "Manually marked complete";
if (
eventType.includes("annotation") ||
eventType.includes("note")
)
return d?.description || d?.note || d?.message || "Note";
if (!d || Object.keys(d).length === 0) return null;
return JSON.stringify(d)
.slice(0, 100)
.replace(/[{""}]/g, " ")
.trim();
})();
return (
<Tooltip key={i}>
<TooltipTrigger asChild>
<div
className="group/event absolute top-1/2 left-0 z-20 flex -translate-x-1/2 -translate-y-1/2 transform cursor-pointer flex-col items-center p-2"
style={{ left: `${pct}%` }}
onClick={(e) => {
e.stopPropagation();
// startTime is in ms, timestamp is Date string or obj
const timeMs = new Date(event.timestamp).getTime();
const seekSeconds = (timeMs - startTime) / 1000;
seekTo(Math.max(0, seekSeconds));
}}
>
<div
className={cn(
"bg-background relative z-20 flex h-7 w-7 items-center justify-center rounded-full border shadow-sm transition-transform hover:z-50 hover:scale-125",
getEventColor(event.eventType),
)}
>
{getEventIcon(event.eventType)}
</div>
</div>
</TooltipTrigger>
<TooltipContent side="top">
<div className="mb-0.5 text-xs font-semibold tracking-wider uppercase">
{event.eventType.replace(/_/g, " ")}
</div>
<div className="mb-1 font-mono text-[10px] opacity-70">
{new Date(event.timestamp).toLocaleTimeString()}
</div>
{!!details && (
<div className="bg-muted/50 max-w-[220px] rounded border p-1.5 text-[10px] break-words whitespace-normal">
{details}
</div>
)}
</TooltipContent>
</Tooltip>
);
})}
{/* Ticks (Below) */}
{ticks.map((tick, i) => (
<div
key={i}
className="text-muted-foreground pointer-events-none absolute top-10 flex -translate-x-1/2 transform flex-col items-center font-mono text-[10px]"
style={{ left: `${tick.pct}%` }}
>
{/* Tick Mark */}
<div className="bg-border mb-1 h-2 w-px" />
{tick.label}
</div>
))}
</div>
</TooltipProvider>
</div>
);
}

View File

@@ -1,130 +1,144 @@
"use client";
import React, { createContext, useContext, useEffect, useRef, useState } from "react";
import React, {
createContext,
useContext,
useEffect,
useRef,
useState,
} from "react";
interface TrialEvent {
eventType: string;
timestamp: Date;
data?: unknown;
eventType: string;
timestamp: Date;
data?: unknown;
}
interface PlaybackContextType {
// State
currentTime: number;
duration: number;
isPlaying: boolean;
playbackRate: number;
startTime?: Date;
endTime?: Date;
// State
currentTime: number;
duration: number;
isPlaying: boolean;
playbackRate: number;
startTime?: Date;
endTime?: 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
// 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
// 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;
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;
endTime?: Date;
children: React.ReactNode;
events?: TrialEvent[];
startTime?: Date;
endTime?: Date;
}
export function PlaybackProvider({ children, events = [], startTime, endTime }: PlaybackProviderProps) {
const trialDuration = React.useMemo(() => {
if (startTime && endTime) return (new Date(endTime).getTime() - new Date(startTime).getTime()) / 1000;
return 0;
}, [startTime, endTime]);
export function PlaybackProvider({
children,
events = [],
startTime,
endTime,
}: PlaybackProviderProps) {
const trialDuration = React.useMemo(() => {
if (startTime && endTime)
return (
(new Date(endTime).getTime() - new Date(startTime).getTime()) / 1000
);
return 0;
}, [startTime, endTime]);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(trialDuration);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(trialDuration);
useEffect(() => {
if (trialDuration > 0 && duration === 0) {
setDuration(trialDuration);
}
}, [trialDuration, duration]);
const [isPlaying, setIsPlaying] = useState(false);
const [playbackRate, setPlaybackRate] = useState(1);
useEffect(() => {
if (trialDuration > 0 && duration === 0) {
setDuration(trialDuration);
}
}, [trialDuration, duration]);
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;
// 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;
// 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;
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]);
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);
// 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 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,
startTime,
endTime,
};
const value: PlaybackContextType = {
currentTime,
duration,
isPlaying,
playbackRate,
play,
pause,
togglePlay,
seekTo,
setPlaybackRate,
setDuration,
setCurrentTime,
events,
currentEventIndex,
startTime,
endTime,
};
return (
<PlaybackContext.Provider value={value}>
{children}
</PlaybackContext.Provider>
);
return (
<PlaybackContext.Provider value={value}>
{children}
</PlaybackContext.Provider>
);
}

View File

@@ -8,146 +8,158 @@ import { Slider } from "~/components/ui/slider";
import { Button } from "~/components/ui/button";
interface PlaybackPlayerProps {
src: string;
src: string;
}
export function PlaybackPlayer({ src }: PlaybackPlayerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const {
currentTime,
isPlaying,
playbackRate,
setCurrentTime,
setDuration,
togglePlay,
play,
pause
} = usePlayback();
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);
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;
// 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]);
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 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;
// 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]);
if (Math.abs(video.currentTime - currentTime) > 0.5) {
video.currentTime = currentTime;
}
}, [currentTime]);
const handleTimeUpdate = () => {
if (videoRef.current) {
setCurrentTime(videoRef.current.currentTime);
}
};
const handleTimeUpdate = () => {
if (videoRef.current) {
setCurrentTime(videoRef.current.currentTime);
}
};
const handleLoadedMetadata = () => {
if (videoRef.current) {
setIsBuffering(false);
}
};
const handleLoadedMetadata = () => {
if (videoRef.current) {
setIsBuffering(false);
}
};
const handleWaiting = () => setIsBuffering(true);
const handlePlaying = () => setIsBuffering(false);
const handleEnded = () => pause();
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}
muted={muted}
className="w-full h-full object-contain"
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata}
onWaiting={handleWaiting}
onPlaying={handlePlaying}
onEnded={handleEnded}
/>
return (
<div className="group relative overflow-hidden rounded-lg border bg-black shadow-sm">
<AspectRatio ratio={16 / 9}>
<video
ref={videoRef}
src={src}
muted={muted}
className="h-full w-full object-contain"
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata}
onWaiting={handleWaiting}
onPlaying={handlePlaying}
onEnded={handleEnded}
/>
{/* 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>
{/* 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 ?? 0;
setCurrentTime(val ?? 0);
}
}}
className="cursor-pointer"
/>
</div>
<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 ?? 0;
setCurrentTime(val ?? 0);
}
}}
className="cursor-pointer"
/>
</div>
<div className="text-xs font-mono text-white/90">
{formatTime(currentTime)} / {formatTime(videoRef.current?.duration || 0)}
</div>
<div className="font-mono text-xs 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>
<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="pointer-events-none absolute inset-0 flex items-center justify-center bg-black/20">
<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")}`;
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${s.toString().padStart(2, "0")}`;
}

View File

@@ -4,200 +4,229 @@ import React, { useState } from "react";
import { Badge } from "~/components/ui/badge";
import { Card, CardContent } from "~/components/ui/card";
import { ScrollArea } from "~/components/ui/scroll-area";
import { Flag, CheckCircle, Bot, User, MessageSquare, AlertTriangle, Activity } from "lucide-react";
import {
Flag,
CheckCircle,
Bot,
User,
MessageSquare,
AlertTriangle,
Activity,
} from "lucide-react";
interface TimelineEvent {
type: string;
timestamp: Date;
message?: string;
data?: unknown;
type: string;
timestamp: Date;
message?: string;
data?: unknown;
}
interface HorizontalTimelineProps {
events: TimelineEvent[];
startTime?: Date;
endTime?: Date;
events: TimelineEvent[];
startTime?: Date;
endTime?: Date;
}
export function HorizontalTimeline({ events, startTime, endTime }: HorizontalTimelineProps) {
const [selectedEvent, setSelectedEvent] = useState<TimelineEvent | null>(null);
if (events.length === 0) {
return (
<div className="text-center text-sm text-muted-foreground py-8">
No events recorded yet
</div>
);
}
// Calculate time range
const timestamps = events.map(e => e.timestamp.getTime());
const minTime = startTime?.getTime() ?? Math.min(...timestamps);
const maxTime = endTime?.getTime() ?? Math.max(...timestamps);
const duration = maxTime - minTime;
// Generate time markers (every 10 seconds or appropriate interval)
const getTimeMarkers = () => {
const markers: Date[] = [];
const interval = duration > 300000 ? 60000 : duration > 60000 ? 30000 : 10000; // 1min, 30s, or 10s intervals
for (let time = minTime; time <= maxTime; time += interval) {
markers.push(new Date(time));
}
if (markers[markers.length - 1]?.getTime() !== maxTime) {
markers.push(new Date(maxTime));
}
return markers;
};
const timeMarkers = getTimeMarkers();
// Get position percentage for a timestamp
const getPosition = (timestamp: Date) => {
if (duration === 0) return 50;
return ((timestamp.getTime() - minTime) / duration) * 100;
};
// Get color and icon for event type
const getEventStyle = (eventType: string) => {
if (eventType.includes("start") || eventType === "trial_started") {
return { color: "bg-blue-500", Icon: Flag };
} else if (eventType.includes("complete") || eventType === "trial_completed") {
return { color: "bg-green-500", Icon: CheckCircle };
} else if (eventType.includes("robot") || eventType.includes("action")) {
return { color: "bg-purple-500", Icon: Bot };
} else if (eventType.includes("wizard") || eventType.includes("intervention")) {
return { color: "bg-orange-500", Icon: User };
} else if (eventType.includes("note") || eventType.includes("annotation")) {
return { color: "bg-yellow-500", Icon: MessageSquare };
} else if (eventType.includes("error") || eventType.includes("issue")) {
return { color: "bg-red-500", Icon: AlertTriangle };
}
return { color: "bg-gray-500", Icon: Activity };
};
export function HorizontalTimeline({
events,
startTime,
endTime,
}: HorizontalTimelineProps) {
const [selectedEvent, setSelectedEvent] = useState<TimelineEvent | null>(
null,
);
if (events.length === 0) {
return (
<div className="space-y-4">
{/* Timeline visualization */}
<div className="relative">
<ScrollArea className="w-full">
<div className="min-w-[800px] px-4 py-8">
{/* Time markers */}
<div className="relative h-20 mb-8">
{/* Main horizontal line */}
<div className="absolute top-1/2 left-0 right-0 h-0.5 bg-border" style={{ transform: 'translateY(-50%)' }} />
<div className="text-muted-foreground py-8 text-center text-sm">
No events recorded yet
</div>
);
}
{/* Time labels */}
{timeMarkers.map((marker, i) => {
const pos = getPosition(marker);
return (
<div
key={i}
className="absolute"
style={{ left: `${pos}%`, top: '50%', transform: 'translate(-50%, -50%)' }}
>
<div className="flex flex-col items-center">
<div className="text-[10px] font-mono text-muted-foreground mb-2">
{marker.toLocaleTimeString([], {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})}
</div>
<div className="w-px h-4 bg-border" />
</div>
</div>
);
})}
</div>
// Calculate time range
const timestamps = events.map((e) => e.timestamp.getTime());
const minTime = startTime?.getTime() ?? Math.min(...timestamps);
const maxTime = endTime?.getTime() ?? Math.max(...timestamps);
const duration = maxTime - minTime;
{/* Event markers */}
<div className="relative h-40">
{/* Timeline line for events */}
<div className="absolute top-20 left-0 right-0 h-0.5 bg-border" />
// Generate time markers (every 10 seconds or appropriate interval)
const getTimeMarkers = () => {
const markers: Date[] = [];
const interval =
duration > 300000 ? 60000 : duration > 60000 ? 30000 : 10000; // 1min, 30s, or 10s intervals
{events.map((event, i) => {
const pos = getPosition(event.timestamp);
const { color, Icon } = getEventStyle(event.type);
const isSelected = selectedEvent === event;
for (let time = minTime; time <= maxTime; time += interval) {
markers.push(new Date(time));
}
if (markers[markers.length - 1]?.getTime() !== maxTime) {
markers.push(new Date(maxTime));
}
return markers;
};
return (
<div
key={i}
className="absolute"
style={{
left: `${pos}%`,
top: '50%',
transform: 'translate(-50%, -50%)'
}}
>
{/* Clickable marker group */}
<button
onClick={() => setSelectedEvent(isSelected ? null : event)}
className="flex flex-col items-center gap-1 cursor-pointer group"
title={event.message || event.type}
>
{/* Vertical dash */}
<div className={`
w-1 h-20 ${color} rounded-full
group-hover:w-1.5 transition-all
${isSelected ? 'w-1.5 ring-2 ring-offset-2 ring-offset-background ring-primary' : ''}
`} />
const timeMarkers = getTimeMarkers();
{/* Icon indicator */}
<div className={`
p-1.5 rounded-full ${color} bg-opacity-20
group-hover:bg-opacity-30 transition-all
${isSelected ? 'ring-2 ring-primary bg-opacity-40' : ''}
`}>
<Icon className={`h-3.5 w-3.5 ${color.replace('bg-', 'text-')}`} />
</div>
</button>
</div>
);
})}
</div>
// Get position percentage for a timestamp
const getPosition = (timestamp: Date) => {
if (duration === 0) return 50;
return ((timestamp.getTime() - minTime) / duration) * 100;
};
// Get color and icon for event type
const getEventStyle = (eventType: string) => {
if (eventType.includes("start") || eventType === "trial_started") {
return { color: "bg-blue-500", Icon: Flag };
} else if (
eventType.includes("complete") ||
eventType === "trial_completed"
) {
return { color: "bg-green-500", Icon: CheckCircle };
} else if (eventType.includes("robot") || eventType.includes("action")) {
return { color: "bg-purple-500", Icon: Bot };
} else if (
eventType.includes("wizard") ||
eventType.includes("intervention")
) {
return { color: "bg-orange-500", Icon: User };
} else if (eventType.includes("note") || eventType.includes("annotation")) {
return { color: "bg-yellow-500", Icon: MessageSquare };
} else if (eventType.includes("error") || eventType.includes("issue")) {
return { color: "bg-red-500", Icon: AlertTriangle };
}
return { color: "bg-gray-500", Icon: Activity };
};
return (
<div className="space-y-4">
{/* Timeline visualization */}
<div className="relative">
<ScrollArea className="w-full">
<div className="min-w-[800px] px-4 py-8">
{/* Time markers */}
<div className="relative mb-8 h-20">
{/* Main horizontal line */}
<div
className="bg-border absolute top-1/2 right-0 left-0 h-0.5"
style={{ transform: "translateY(-50%)" }}
/>
{/* Time labels */}
{timeMarkers.map((marker, i) => {
const pos = getPosition(marker);
return (
<div
key={i}
className="absolute"
style={{
left: `${pos}%`,
top: "50%",
transform: "translate(-50%, -50%)",
}}
>
<div className="flex flex-col items-center">
<div className="text-muted-foreground mb-2 font-mono text-[10px]">
{marker.toLocaleTimeString([], {
hour12: false,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
})}
</div>
<div className="bg-border h-4 w-px" />
</div>
</ScrollArea>
</div>
);
})}
</div>
{/* Selected event details */}
{selectedEvent && (
<Card>
<CardContent className="pt-4">
<div className="space-y-2">
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs">
{selectedEvent.type.replace(/_/g, " ")}
</Badge>
<span className="text-xs font-mono text-muted-foreground">
{selectedEvent.timestamp.toLocaleTimeString([], {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
fractionalSecondDigits: 3
})}
</span>
</div>
{selectedEvent.message && (
<p className="text-sm">{selectedEvent.message}</p>
)}
{selectedEvent.data !== undefined && selectedEvent.data !== null && (
<details className="text-xs">
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">
Event data
</summary>
<pre className="mt-2 p-2 bg-muted rounded text-[10px] overflow-auto">
{JSON.stringify(selectedEvent.data, null, 2)}
</pre>
</details>
)}
</div>
</CardContent>
</Card>
)}
</div>
);
{/* Event markers */}
<div className="relative h-40">
{/* Timeline line for events */}
<div className="bg-border absolute top-20 right-0 left-0 h-0.5" />
{events.map((event, i) => {
const pos = getPosition(event.timestamp);
const { color, Icon } = getEventStyle(event.type);
const isSelected = selectedEvent === event;
return (
<div
key={i}
className="absolute"
style={{
left: `${pos}%`,
top: "50%",
transform: "translate(-50%, -50%)",
}}
>
{/* Clickable marker group */}
<button
onClick={() =>
setSelectedEvent(isSelected ? null : event)
}
className="group flex cursor-pointer flex-col items-center gap-1"
title={event.message || event.type}
>
{/* Vertical dash */}
<div
className={`h-20 w-1 ${color} rounded-full transition-all group-hover:w-1.5 ${isSelected ? "ring-offset-background ring-primary w-1.5 ring-2 ring-offset-2" : ""} `}
/>
{/* Icon indicator */}
<div
className={`rounded-full p-1.5 ${color} bg-opacity-20 group-hover:bg-opacity-30 transition-all ${isSelected ? "ring-primary bg-opacity-40 ring-2" : ""} `}
>
<Icon
className={`h-3.5 w-3.5 ${color.replace("bg-", "text-")}`}
/>
</div>
</button>
</div>
);
})}
</div>
</div>
</ScrollArea>
</div>
{/* Selected event details */}
{selectedEvent && (
<Card>
<CardContent className="pt-4">
<div className="space-y-2">
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs">
{selectedEvent.type.replace(/_/g, " ")}
</Badge>
<span className="text-muted-foreground font-mono text-xs">
{selectedEvent.timestamp.toLocaleTimeString([], {
hour12: false,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
fractionalSecondDigits: 3,
})}
</span>
</div>
{selectedEvent.message && (
<p className="text-sm">{selectedEvent.message}</p>
)}
{selectedEvent.data !== undefined &&
selectedEvent.data !== null && (
<details className="text-xs">
<summary className="text-muted-foreground hover:text-foreground cursor-pointer">
Event data
</summary>
<pre className="bg-muted mt-2 overflow-auto rounded p-2 text-[10px]">
{JSON.stringify(selectedEvent.data, null, 2)}
</pre>
</details>
)}
</div>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -1,4 +1,3 @@
import { PageHeader } from "~/components/ui/page-header";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
@@ -6,7 +5,21 @@ import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import Link from "next/link";
import { LineChart, BarChart, Printer, Clock, Database, FileText, AlertTriangle, CheckCircle, VideoOff, Info, Bot, Activity, ArrowLeft } from "lucide-react";
import {
LineChart,
BarChart,
Printer,
Clock,
Database,
FileText,
AlertTriangle,
CheckCircle,
VideoOff,
Info,
Bot,
Activity,
ArrowLeft,
} from "lucide-react";
import { useEffect } from "react";
import { PlaybackProvider } from "../playback/PlaybackContext";
import { PlaybackPlayer } from "../playback/PlaybackPlayer";
@@ -15,336 +28,420 @@ import { api } from "~/trpc/react";
import { ScrollArea } from "~/components/ui/scroll-area";
import { cn } from "~/lib/utils";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "~/components/ui/resizable";
import { EventsDataTable } from "../analysis/events-data-table";
interface TrialAnalysisViewProps {
trial: {
id: string;
status: string;
startedAt: Date | null;
completedAt: Date | null;
duration: number | null;
experiment: { name: string; studyId: string };
participant: { participantCode: string };
eventCount?: number;
mediaCount?: number;
media?: { url: string; mediaType: string; format?: string; contentType?: string }[];
};
backHref: string;
trial: {
id: string;
status: string;
startedAt: Date | null;
completedAt: Date | null;
duration: number | null;
experiment: { name: string; studyId: string };
participant: { participantCode: string };
eventCount?: number;
mediaCount?: number;
media?: {
url: string;
mediaType: string;
format?: string;
contentType?: string;
}[];
};
backHref: string;
}
export function TrialAnalysisView({ trial, backHref }: TrialAnalysisViewProps) {
// Fetch events for timeline
const { data: events = [] } = api.trials.getEvents.useQuery({
trialId: trial.id,
limit: 1000
}, {
refetchInterval: 5000
});
// Fetch events for timeline
const { data: events = [] } = api.trials.getEvents.useQuery(
{
trialId: trial.id,
limit: 1000,
},
{
refetchInterval: 5000,
},
);
// Auto-print effect
useEffect(() => {
const searchParams = new URLSearchParams(window.location.search);
if (searchParams.get('export') === 'true') {
// Small delay to ensure rendering
setTimeout(() => {
window.print();
}, 1000);
}
}, []);
// Auto-print effect
useEffect(() => {
const searchParams = new URLSearchParams(window.location.search);
if (searchParams.get("export") === "true") {
// Small delay to ensure rendering
setTimeout(() => {
window.print();
}, 1000);
}
}, []);
const videoMedia = trial.media?.find(m => m.mediaType === "video" || (m as any).contentType?.startsWith("video/"));
const videoUrl = videoMedia?.url;
const videoMedia = trial.media?.find(
(m) =>
m.mediaType === "video" || (m as any).contentType?.startsWith("video/"),
);
const videoUrl = videoMedia?.url;
// Metrics
const interventionCount = events.filter(e => e.eventType.includes("intervention")).length;
const errorCount = events.filter(e => e.eventType.includes("error")).length;
const robotActionCount = events.filter(e => e.eventType.includes("robot_action")).length;
// Metrics
const interventionCount = events.filter((e) =>
e.eventType.includes("intervention"),
).length;
const errorCount = events.filter((e) => e.eventType.includes("error")).length;
const robotActionCount = events.filter((e) =>
e.eventType.includes("robot_action"),
).length;
return (
<PlaybackProvider events={events} startTime={trial.startedAt ?? undefined}>
<div id="trial-analysis-content" className="flex h-full flex-col gap-2 p-3 text-sm">
{/* Header Context */}
<PageHeader
title={trial.experiment.name}
description={`Session ${trial.id.slice(0, 8)}${trial.startedAt?.toLocaleDateString() ?? 'Unknown Date'} ${trial.startedAt?.toLocaleTimeString() ?? ''}`}
badges={[
{
label: trial.status.toUpperCase(),
variant: trial.status === 'completed' ? 'default' : 'secondary',
className: trial.status === 'completed' ? 'bg-green-500 hover:bg-green-600' : ''
}
]}
actions={
<div className="flex items-center gap-2">
<style jsx global>{`
@media print {
@page {
size: auto;
margin: 15mm;
}
body {
background: white;
color: black;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
/* Hide everything by default */
body * {
visibility: hidden;
}
/* Show only our content */
#trial-analysis-content, #trial-analysis-content * {
visibility: visible;
}
#trial-analysis-content {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: auto;
overflow: visible;
padding: 0;
margin: 0;
background: white;
}
/* Hide specific non-printable elements */
#tour-trial-video,
button,
.no-print,
[role="dialog"],
header,
nav {
display: none !important;
}
return (
<PlaybackProvider events={events} startTime={trial.startedAt ?? undefined}>
<div
id="trial-analysis-content"
className="flex h-full flex-col gap-2 p-3 text-sm"
>
{/* Header Context */}
<PageHeader
title={trial.experiment.name}
description={`Session ${trial.id.slice(0, 8)}${trial.startedAt?.toLocaleDateString() ?? "Unknown Date"} ${trial.startedAt?.toLocaleTimeString() ?? ""}`}
badges={[
{
label: trial.status.toUpperCase(),
variant: trial.status === "completed" ? "default" : "secondary",
className:
trial.status === "completed"
? "bg-green-500 hover:bg-green-600"
: "",
},
]}
actions={
<div className="flex items-center gap-2">
<style jsx global>{`
@media print {
@page {
size: auto;
margin: 15mm;
}
body {
background: white;
color: black;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
/* Hide everything by default */
body * {
visibility: hidden;
}
/* Show only our content */
#trial-analysis-content,
#trial-analysis-content * {
visibility: visible;
}
#trial-analysis-content {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: auto;
overflow: visible;
padding: 0;
margin: 0;
background: white;
}
/* Adjust Metrics for Print */
#tour-trial-metrics {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
margin-bottom: 2rem;
page-break-inside: avoid;
}
#tour-trial-metrics .rounded-xl {
border: 1px solid #ddd;
box-shadow: none;
}
/* Hide specific non-printable elements */
#tour-trial-video,
button,
.no-print,
[role="dialog"],
header,
nav {
display: none !important;
}
/* Expand Timeline */
.h-28 {
height: 120px !important;
page-break-inside: avoid;
border-bottom: 1px solid #eee;
margin-bottom: 1rem;
}
/* Adjust Metrics for Print */
#tour-trial-metrics {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
margin-bottom: 2rem;
page-break-inside: avoid;
}
#tour-trial-metrics .rounded-xl {
border: 1px solid #ddd;
box-shadow: none;
}
/* Remove Panel Resizing constraints */
[data-panel-group-direction="vertical"] {
flex-direction: column !important;
display: block !important;
height: auto !important;
}
[data-panel] {
flex: none !important;
height: auto !important;
overflow: visible !important;
}
[data-panel-resize-handle] {
display: none !important;
}
/* Expand Timeline */
.h-28 {
height: 120px !important;
page-break-inside: avoid;
border-bottom: 1px solid #eee;
margin-bottom: 1rem;
}
/* Table Styles: Clean & Full Width */
#tour-trial-events {
display: block !important;
border: none !important;
height: auto !important;
}
#tour-trial-events [data-radix-scroll-area-viewport] {
overflow: visible !important;
height: auto !important;
}
/* Hide "Filter" input wrapper if visible */
#tour-trial-events .border-b {
border-bottom: 2px solid #000 !important;
}
}
`}</style>
<Button
variant="outline"
size="sm"
className="gap-2"
onClick={() => window.print()}
>
<Printer className="h-4 w-4" />
Export Report
</Button>
</div>
}
/>
/* Remove Panel Resizing constraints */
[data-panel-group-direction="vertical"] {
flex-direction: column !important;
display: block !important;
height: auto !important;
}
[data-panel] {
flex: none !important;
height: auto !important;
overflow: visible !important;
}
[data-panel-resize-handle] {
display: none !important;
}
{/* Top Section: Metrics & Optional Video Grid */}
<div className="flex flex-col xl:flex-row gap-3 shrink-0">
<Card id="tour-trial-metrics" className="shadow-sm flex-1">
<CardContent className="p-0 h-full">
<div className="grid grid-cols-2 grid-rows-2 h-full divide-x divide-y">
<div className="flex flex-col p-4 md:p-6 justify-center">
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-2">
<Clock className="h-4 w-4 text-blue-500" /> Duration
</p>
<p className="text-2xl font-bold">
{trial.duration ? <span>{Math.floor(trial.duration / 60)}m {trial.duration % 60}s</span> : "--:--"}
</p>
</div>
<div className="flex flex-col p-4 md:p-6 justify-center border-t-0">
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-2">
<Bot className="h-4 w-4 text-purple-500" /> Robot Actions
</p>
<p className="text-2xl font-bold">{robotActionCount}</p>
</div>
<div className="flex flex-col p-4 md:p-6 justify-center">
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-2">
<AlertTriangle className="h-4 w-4 text-orange-500" /> Interventions
</p>
<p className="text-2xl font-bold">{interventionCount}</p>
</div>
<div className="flex flex-col p-4 md:p-6 justify-center">
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-2">
<Activity className="h-4 w-4 text-green-500" /> Completeness
</p>
<div className="flex items-center gap-2 text-2xl font-bold">
<span className={cn(
"inline-block h-3 w-3 rounded-full",
trial.status === 'completed' ? "bg-green-500" : "bg-yellow-500"
)} />
{trial.status === 'completed' ? '100%' : 'Incomplete'}
</div>
</div>
</div>
</CardContent>
</Card>
{videoUrl && (
<Card id="tour-trial-video" className="shadow-sm w-full xl:w-[500px] overflow-hidden shrink-0 bg-black/5 dark:bg-black/40 border">
<div className="aspect-video w-full h-full relative flex items-center justify-center bg-black">
<div className="absolute inset-0">
<PlaybackPlayer src={videoUrl} />
</div>
</div>
</Card>
)}
</div>
{/* Main Workspace: Vertical Layout */}
<div className="flex-1 min-h-0 rounded-xl border shadow-sm overflow-hidden bg-background flex flex-col">
{/* FIXED TIMELINE: Always visible at top */}
<div id="tour-trial-timeline" className="shrink-0 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 p-1">
<EventTimeline />
</div>
{/* BOTTOM: Events Table */}
<div className="flex-1 flex flex-col min-h-0 bg-background" id="tour-trial-events">
<Tabs defaultValue="events" className="flex flex-col h-full">
<div className="flex items-center justify-between px-3 py-2 border-b shrink-0 bg-muted/10">
<div className="flex items-center gap-2">
<TabsList className="h-8">
<TabsTrigger value="events" className="text-xs">All Events</TabsTrigger>
<TabsTrigger value="observations" className="text-xs">Observations ({events.filter(e => e.eventType.startsWith('annotation') || e.eventType === 'wizard_note').length})</TabsTrigger>
</TabsList>
</div>
<div className="flex items-center gap-2">
<Input
placeholder="Filter..."
className="h-7 w-[150px] text-xs"
disabled
style={{ display: 'none' }}
/>
<Badge variant="outline" className="text-[10px] font-normal">{events.length} Total</Badge>
</div>
</div>
<TabsContent value="events" className="flex-1 min-h-0 mt-0">
<ScrollArea className="h-full">
<div className="p-0">
<EventsDataTable
data={events.map(e => ({ ...e, timestamp: new Date(e.timestamp) }))}
startTime={trial.startedAt ?? undefined}
/>
</div>
</ScrollArea>
</TabsContent>
<TabsContent value="observations" className="flex-1 min-h-0 mt-0 bg-muted/5">
<ScrollArea className="h-full">
<div className="p-4 space-y-3 max-w-2xl mx-auto">
{events.filter(e => e.eventType.startsWith('annotation') || e.eventType === 'wizard_note').length > 0 ? (
events
.filter(e => e.eventType.startsWith('annotation') || e.eventType === 'wizard_note')
.map((e, i) => {
const data = e.data as any;
return (
<Card key={i} className="border shadow-none">
<CardHeader className="p-3 pb-0 flex flex-row items-center justify-between space-y-0">
<div className="flex items-center gap-2">
<Badge variant="outline" className="bg-yellow-50 text-yellow-700 border-yellow-200">
{data?.category || "Note"}
</Badge>
<span className="text-xs text-muted-foreground font-mono">
{trial.startedAt ? formatTime(new Date(e.timestamp).getTime() - new Date(trial.startedAt).getTime()) : '--:--'}
</span>
</div>
<span className="text-[10px] text-muted-foreground">
{new Date(e.timestamp).toLocaleTimeString()}
</span>
</CardHeader>
<CardContent className="p-3 pt-2">
<p className="text-sm">
{data?.description || data?.note || data?.message || "No content"}
</p>
{data?.tags && data.tags.length > 0 && (
<div className="flex gap-1 mt-2">
{data.tags.map((t: string, ti: number) => (
<Badge key={ti} variant="secondary" className="text-[10px] h-5 px-1.5">
{t}
</Badge>
))}
</div>
)}
</CardContent>
</Card>
);
})
) : (
<div className="text-center py-12 text-muted-foreground text-sm">
<Info className="h-8 w-8 mx-auto mb-2 opacity-20" />
No observations recorded for this session.
</div>
)}
</div>
</ScrollArea>
</TabsContent>
</Tabs>
</div>
</div>
/* Table Styles: Clean & Full Width */
#tour-trial-events {
display: block !important;
border: none !important;
height: auto !important;
}
#tour-trial-events [data-radix-scroll-area-viewport] {
overflow: visible !important;
height: auto !important;
}
/* Hide "Filter" input wrapper if visible */
#tour-trial-events .border-b {
border-bottom: 2px solid #000 !important;
}
}
`}</style>
<Button
variant="outline"
size="sm"
className="gap-2"
onClick={() => window.print()}
>
<Printer className="h-4 w-4" />
Export Report
</Button>
</div>
</PlaybackProvider>
);
}
/>
{/* Top Section: Metrics & Optional Video Grid */}
<div className="flex shrink-0 flex-col gap-3 xl:flex-row">
<Card id="tour-trial-metrics" className="flex-1 shadow-sm">
<CardContent className="h-full p-0">
<div className="grid h-full grid-cols-2 grid-rows-2 divide-x divide-y">
<div className="flex flex-col justify-center p-4 md:p-6">
<p className="text-muted-foreground mb-2 flex items-center gap-1.5 text-sm font-medium">
<Clock className="h-4 w-4 text-blue-500" /> Duration
</p>
<p className="text-2xl font-bold">
{trial.duration ? (
<span>
{Math.floor(trial.duration / 60)}m {trial.duration % 60}
s
</span>
) : (
"--:--"
)}
</p>
</div>
<div className="flex flex-col justify-center border-t-0 p-4 md:p-6">
<p className="text-muted-foreground mb-2 flex items-center gap-1.5 text-sm font-medium">
<Bot className="h-4 w-4 text-purple-500" /> Robot Actions
</p>
<p className="text-2xl font-bold">{robotActionCount}</p>
</div>
<div className="flex flex-col justify-center p-4 md:p-6">
<p className="text-muted-foreground mb-2 flex items-center gap-1.5 text-sm font-medium">
<AlertTriangle className="h-4 w-4 text-orange-500" />{" "}
Interventions
</p>
<p className="text-2xl font-bold">{interventionCount}</p>
</div>
<div className="flex flex-col justify-center p-4 md:p-6">
<p className="text-muted-foreground mb-2 flex items-center gap-1.5 text-sm font-medium">
<Activity className="h-4 w-4 text-green-500" /> Completeness
</p>
<div className="flex items-center gap-2 text-2xl font-bold">
<span
className={cn(
"inline-block h-3 w-3 rounded-full",
trial.status === "completed"
? "bg-green-500"
: "bg-yellow-500",
)}
/>
{trial.status === "completed" ? "100%" : "Incomplete"}
</div>
</div>
</div>
</CardContent>
</Card>
{videoUrl && (
<Card
id="tour-trial-video"
className="w-full shrink-0 overflow-hidden border bg-black/5 shadow-sm xl:w-[500px] dark:bg-black/40"
>
<div className="relative flex aspect-video h-full w-full items-center justify-center bg-black">
<div className="absolute inset-0">
<PlaybackPlayer src={videoUrl} />
</div>
</div>
</Card>
)}
</div>
{/* Main Workspace: Vertical Layout */}
<div className="bg-background flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border shadow-sm">
{/* FIXED TIMELINE: Always visible at top */}
<div
id="tour-trial-timeline"
className="bg-background/95 supports-[backdrop-filter]:bg-background/60 shrink-0 border-b p-1 backdrop-blur"
>
<EventTimeline />
</div>
{/* BOTTOM: Events Table */}
<div
className="bg-background flex min-h-0 flex-1 flex-col"
id="tour-trial-events"
>
<Tabs defaultValue="events" className="flex h-full flex-col">
<div className="bg-muted/10 flex shrink-0 items-center justify-between border-b px-3 py-2">
<div className="flex items-center gap-2">
<TabsList className="h-8">
<TabsTrigger value="events" className="text-xs">
All Events
</TabsTrigger>
<TabsTrigger value="observations" className="text-xs">
Observations (
{
events.filter(
(e) =>
e.eventType.startsWith("annotation") ||
e.eventType === "wizard_note",
).length
}
)
</TabsTrigger>
</TabsList>
</div>
<div className="flex items-center gap-2">
<Input
placeholder="Filter..."
className="h-7 w-[150px] text-xs"
disabled
style={{ display: "none" }}
/>
<Badge variant="outline" className="text-[10px] font-normal">
{events.length} Total
</Badge>
</div>
</div>
<TabsContent value="events" className="mt-0 min-h-0 flex-1">
<ScrollArea className="h-full">
<div className="p-0">
<EventsDataTable
data={events.map((e) => ({
...e,
timestamp: new Date(e.timestamp),
}))}
startTime={trial.startedAt ?? undefined}
/>
</div>
</ScrollArea>
</TabsContent>
<TabsContent
value="observations"
className="bg-muted/5 mt-0 min-h-0 flex-1"
>
<ScrollArea className="h-full">
<div className="mx-auto max-w-2xl space-y-3 p-4">
{events.filter(
(e) =>
e.eventType.startsWith("annotation") ||
e.eventType === "wizard_note",
).length > 0 ? (
events
.filter(
(e) =>
e.eventType.startsWith("annotation") ||
e.eventType === "wizard_note",
)
.map((e, i) => {
const data = e.data as any;
return (
<Card key={i} className="border shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-3 pb-0">
<div className="flex items-center gap-2">
<Badge
variant="outline"
className="border-yellow-200 bg-yellow-50 text-yellow-700"
>
{data?.category || "Note"}
</Badge>
<span className="text-muted-foreground font-mono text-xs">
{trial.startedAt
? formatTime(
new Date(e.timestamp).getTime() -
new Date(trial.startedAt).getTime(),
)
: "--:--"}
</span>
</div>
<span className="text-muted-foreground text-[10px]">
{new Date(e.timestamp).toLocaleTimeString()}
</span>
</CardHeader>
<CardContent className="p-3 pt-2">
<p className="text-sm">
{data?.description ||
data?.note ||
data?.message ||
"No content"}
</p>
{data?.tags && data.tags.length > 0 && (
<div className="mt-2 flex gap-1">
{data.tags.map((t: string, ti: number) => (
<Badge
key={ti}
variant="secondary"
className="h-5 px-1.5 text-[10px]"
>
{t}
</Badge>
))}
</div>
)}
</CardContent>
</Card>
);
})
) : (
<div className="text-muted-foreground py-12 text-center text-sm">
<Info className="mx-auto mb-2 h-8 w-8 opacity-20" />
No observations recorded for this session.
</div>
)}
</div>
</ScrollArea>
</TabsContent>
</Tabs>
</div>
</div>
</div>
</PlaybackProvider>
);
}
// Helper specific to this file if needed, otherwise ignore.
import { Input } from "~/components/ui/input";
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")}`;
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")}`;
}

View File

@@ -255,10 +255,10 @@ export function RobotActionsPanel({
// Look for ROS2 configuration in the action definition
const actionConfig = (actionDef as any).ros2
? {
topic: (actionDef as any).ros2.topic,
messageType: (actionDef as any).ros2.messageType,
payloadMapping: (actionDef as any).ros2.payloadMapping,
}
topic: (actionDef as any).ros2.topic,
messageType: (actionDef as any).ros2.messageType,
payloadMapping: (actionDef as any).ros2.payloadMapping,
}
: undefined;
await executeRosAction(
@@ -635,7 +635,7 @@ export function RobotActionsPanel({
<CardContent className="space-y-4">
{/* Parameters */}
{selectedAction.parameters &&
selectedAction.parameters.length > 0 ? (
selectedAction.parameters.length > 0 ? (
<div className="space-y-4">
<Label className="text-base">Parameters</Label>
{selectedAction.parameters.map((param, index) =>
@@ -662,9 +662,9 @@ export function RobotActionsPanel({
className="w-full"
>
{selectedPluginData &&
executingActions.has(
`${selectedPluginData.plugin.name}.${selectedAction.id}`,
) ? (
executingActions.has(
`${selectedPluginData.plugin.name}.${selectedAction.id}`,
) ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Executing...
@@ -962,7 +962,7 @@ export function RobotActionsPanel({
<CardContent className="space-y-4">
{/* Parameters */}
{selectedAction?.parameters &&
(selectedAction?.parameters?.length ?? 0) > 0 ? (
(selectedAction?.parameters?.length ?? 0) > 0 ? (
<div className="space-y-4">
<Label className="text-base">Parameters</Label>
{selectedAction?.parameters?.map((param, index) =>
@@ -990,10 +990,10 @@ export function RobotActionsPanel({
className="w-full"
>
{selectedPluginData &&
selectedAction &&
executingActions.has(
`${selectedPluginData?.plugin.name}.${selectedAction?.id}`,
) ? (
selectedAction &&
executingActions.has(
`${selectedPluginData?.plugin.name}.${selectedAction?.id}`,
) ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Executing...

View File

@@ -1,273 +1,315 @@
import { useState } from "react";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "~/components/ui/dialog";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Switch } from "~/components/ui/switch";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { Separator } from "~/components/ui/separator";
import { Loader2, Settings2 } from "lucide-react";
import { api } from "~/trpc/react";
import { toast } from "sonner";
interface RobotSettingsModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
studyId: string;
pluginId: string;
settingsSchema: SettingsSchema | null;
open: boolean;
onOpenChange: (open: boolean) => void;
studyId: string;
pluginId: string;
settingsSchema: SettingsSchema | null;
}
interface SettingsSchema {
type: "object";
title?: string;
description?: string;
properties: Record<string, PropertySchema>;
type: "object";
title?: string;
description?: string;
properties: Record<string, PropertySchema>;
}
interface PropertySchema {
type: "object" | "string" | "number" | "integer" | "boolean";
title?: string;
description?: string;
properties?: Record<string, PropertySchema>;
enum?: string[];
enumNames?: string[];
minimum?: number;
maximum?: number;
default?: unknown;
pattern?: string;
type: "object" | "string" | "number" | "integer" | "boolean";
title?: string;
description?: string;
properties?: Record<string, PropertySchema>;
enum?: string[];
enumNames?: string[];
minimum?: number;
maximum?: number;
default?: unknown;
pattern?: string;
}
export function RobotSettingsModal({
open,
onOpenChange,
studyId,
pluginId,
settingsSchema,
open,
onOpenChange,
studyId,
pluginId,
settingsSchema,
}: RobotSettingsModalProps) {
const [settings, setSettings] = useState<Record<string, unknown>>({});
const [isSaving, setIsSaving] = useState(false);
const [settings, setSettings] = useState<Record<string, unknown>>({});
const [isSaving, setIsSaving] = useState(false);
// Fetch current settings
const { data: currentSettings, isLoading } = api.studies.getPluginConfiguration.useQuery(
{ studyId, pluginId },
{ enabled: open }
// Fetch current settings
const { data: currentSettings, isLoading } =
api.studies.getPluginConfiguration.useQuery(
{ studyId, pluginId },
{ enabled: open },
);
// Update settings mutation
const updateSettings = api.studies.updatePluginConfiguration.useMutation({
onSuccess: () => {
toast.success("Robot settings updated successfully");
onOpenChange(false);
},
onError: (error: { message: string }) => {
toast.error(`Failed to update settings: ${error.message}`);
},
});
// Update settings mutation
const updateSettings = api.studies.updatePluginConfiguration.useMutation({
onSuccess: () => {
toast.success("Robot settings updated successfully");
onOpenChange(false);
},
onError: (error: { message: string }) => {
toast.error(`Failed to update settings: ${error.message}`);
},
});
// Initialize settings from current configuration
// eslint-disable-next-line react-hooks/exhaustive-deps
useState(() => {
if (currentSettings) {
setSettings(currentSettings as Record<string, unknown>);
}
});
// Initialize settings from current configuration
// eslint-disable-next-line react-hooks/exhaustive-deps
useState(() => {
if (currentSettings) {
setSettings(currentSettings as Record<string, unknown>);
}
});
const handleSave = async () => {
setIsSaving(true);
try {
await updateSettings.mutateAsync({
studyId,
pluginId,
configuration: settings,
});
} finally {
setIsSaving(false);
}
const handleSave = async () => {
setIsSaving(true);
try {
await updateSettings.mutateAsync({
studyId,
pluginId,
configuration: settings,
});
} finally {
setIsSaving(false);
}
};
const renderField = (
key: string,
schema: PropertySchema,
parentPath: string = "",
) => {
const fullPath = parentPath ? `${parentPath}.${key}` : key;
const value = getNestedValue(settings, fullPath);
const defaultValue = schema.default;
const updateValue = (newValue: unknown) => {
setSettings((prev) => setNestedValue({ ...prev }, fullPath, newValue));
};
const renderField = (key: string, schema: PropertySchema, parentPath: string = "") => {
const fullPath = parentPath ? `${parentPath}.${key}` : key;
const value = getNestedValue(settings, fullPath);
const defaultValue = schema.default;
const updateValue = (newValue: unknown) => {
setSettings((prev) => setNestedValue({ ...prev }, fullPath, newValue));
};
// Object type - render nested fields
if (schema.type === "object" && schema.properties) {
return (
<div key={fullPath} className="space-y-4">
<div className="space-y-1">
<h4 className="text-sm font-semibold">{schema.title || key}</h4>
{schema.description && (
<p className="text-xs text-muted-foreground">{schema.description}</p>
)}
</div>
<div className="ml-4 space-y-3">
{Object.entries(schema.properties).map(([subKey, subSchema]) =>
renderField(subKey, subSchema, fullPath)
)}
</div>
</div>
);
}
// Boolean type - render switch
if (schema.type === "boolean") {
return (
<div key={fullPath} className="flex items-center justify-between space-x-2">
<div className="space-y-0.5 flex-1">
<Label htmlFor={fullPath}>{schema.title || key}</Label>
{schema.description && (
<p className="text-xs text-muted-foreground">{schema.description}</p>
)}
</div>
<Switch
id={fullPath}
checked={(value ?? defaultValue) as boolean}
onCheckedChange={updateValue}
/>
</div>
);
}
// Enum type - render select
if (schema.enum) {
return (
<div key={fullPath} className="space-y-2">
<Label htmlFor={fullPath}>{schema.title || key}</Label>
{schema.description && (
<p className="text-xs text-muted-foreground">{schema.description}</p>
)}
<Select
value={(value ?? defaultValue) as string}
onValueChange={updateValue}
>
<SelectTrigger id={fullPath}>
<SelectValue placeholder="Select an option" />
</SelectTrigger>
<SelectContent>
{schema.enum.map((option, idx) => (
<SelectItem key={option} value={option}>
{schema.enumNames?.[idx] || option}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
// Number/Integer type - render number input
if (schema.type === "number" || schema.type === "integer") {
return (
<div key={fullPath} className="space-y-2">
<Label htmlFor={fullPath}>{schema.title || key}</Label>
{schema.description && (
<p className="text-xs text-muted-foreground">{schema.description}</p>
)}
<Input
id={fullPath}
type="number"
min={schema.minimum}
max={schema.maximum}
step={schema.type === "integer" ? 1 : 0.1}
value={(value ?? defaultValue) as number}
onChange={(e) => {
const newValue = schema.type === "integer"
? parseInt(e.target.value, 10)
: parseFloat(e.target.value);
updateValue(isNaN(newValue) ? defaultValue : newValue);
}}
/>
</div>
);
}
// String type - render text input
return (
<div key={fullPath} className="space-y-2">
<Label htmlFor={fullPath}>{schema.title || key}</Label>
{schema.description && (
<p className="text-xs text-muted-foreground">{schema.description}</p>
)}
<Input
id={fullPath}
type="text"
pattern={schema.pattern}
value={(value ?? defaultValue) as string}
onChange={(e) => updateValue(e.target.value)}
/>
</div>
);
};
if (!settingsSchema) {
return null;
// Object type - render nested fields
if (schema.type === "object" && schema.properties) {
return (
<div key={fullPath} className="space-y-4">
<div className="space-y-1">
<h4 className="text-sm font-semibold">{schema.title || key}</h4>
{schema.description && (
<p className="text-muted-foreground text-xs">
{schema.description}
</p>
)}
</div>
<div className="ml-4 space-y-3">
{Object.entries(schema.properties).map(([subKey, subSchema]) =>
renderField(subKey, subSchema, fullPath),
)}
</div>
</div>
);
}
// Boolean type - render switch
if (schema.type === "boolean") {
return (
<div
key={fullPath}
className="flex items-center justify-between space-x-2"
>
<div className="flex-1 space-y-0.5">
<Label htmlFor={fullPath}>{schema.title || key}</Label>
{schema.description && (
<p className="text-muted-foreground text-xs">
{schema.description}
</p>
)}
</div>
<Switch
id={fullPath}
checked={(value ?? defaultValue) as boolean}
onCheckedChange={updateValue}
/>
</div>
);
}
// Enum type - render select
if (schema.enum) {
return (
<div key={fullPath} className="space-y-2">
<Label htmlFor={fullPath}>{schema.title || key}</Label>
{schema.description && (
<p className="text-muted-foreground text-xs">
{schema.description}
</p>
)}
<Select
value={(value ?? defaultValue) as string}
onValueChange={updateValue}
>
<SelectTrigger id={fullPath}>
<SelectValue placeholder="Select an option" />
</SelectTrigger>
<SelectContent>
{schema.enum.map((option, idx) => (
<SelectItem key={option} value={option}>
{schema.enumNames?.[idx] || option}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
// Number/Integer type - render number input
if (schema.type === "number" || schema.type === "integer") {
return (
<div key={fullPath} className="space-y-2">
<Label htmlFor={fullPath}>{schema.title || key}</Label>
{schema.description && (
<p className="text-muted-foreground text-xs">
{schema.description}
</p>
)}
<Input
id={fullPath}
type="number"
min={schema.minimum}
max={schema.maximum}
step={schema.type === "integer" ? 1 : 0.1}
value={(value ?? defaultValue) as number}
onChange={(e) => {
const newValue =
schema.type === "integer"
? parseInt(e.target.value, 10)
: parseFloat(e.target.value);
updateValue(isNaN(newValue) ? defaultValue : newValue);
}}
/>
</div>
);
}
// String type - render text input
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Settings2 className="h-5 w-5" />
{settingsSchema.title || "Robot Settings"}
</DialogTitle>
{settingsSchema.description && (
<DialogDescription>{settingsSchema.description}</DialogDescription>
)}
</DialogHeader>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<div className="space-y-6 py-4">
{Object.entries(settingsSchema.properties).map(([key, schema], idx) => (
<div key={key}>
{renderField(key, schema)}
{idx < Object.keys(settingsSchema.properties).length - 1 && (
<Separator className="mt-6" />
)}
</div>
))}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSaving}>
Cancel
</Button>
<Button onClick={handleSave} disabled={isSaving || isLoading}>
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Save Settings
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<div key={fullPath} className="space-y-2">
<Label htmlFor={fullPath}>{schema.title || key}</Label>
{schema.description && (
<p className="text-muted-foreground text-xs">{schema.description}</p>
)}
<Input
id={fullPath}
type="text"
pattern={schema.pattern}
value={(value ?? defaultValue) as string}
onChange={(e) => updateValue(e.target.value)}
/>
</div>
);
};
if (!settingsSchema) {
return null;
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[80vh] max-w-2xl overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Settings2 className="h-5 w-5" />
{settingsSchema.title || "Robot Settings"}
</DialogTitle>
{settingsSchema.description && (
<DialogDescription>{settingsSchema.description}</DialogDescription>
)}
</DialogHeader>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
</div>
) : (
<div className="space-y-6 py-4">
{Object.entries(settingsSchema.properties).map(
([key, schema], idx) => (
<div key={key}>
{renderField(key, schema)}
{idx < Object.keys(settingsSchema.properties).length - 1 && (
<Separator className="mt-6" />
)}
</div>
),
)}
</div>
)}
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSaving}
>
Cancel
</Button>
<Button onClick={handleSave} disabled={isSaving || isLoading}>
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Save Settings
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// Helper functions for nested object access
function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
return path.split(".").reduce((current, key) => {
return current && typeof current === "object" ? (current as Record<string, unknown>)[key] : undefined;
}, obj as unknown);
return path.split(".").reduce((current, key) => {
return current && typeof current === "object"
? (current as Record<string, unknown>)[key]
: undefined;
}, obj as unknown);
}
function setNestedValue(obj: Record<string, unknown>, path: string, value: unknown): Record<string, unknown> {
const keys = path.split(".");
const lastKey = keys.pop()!;
const target = keys.reduce((current, key) => {
if (!current[key] || typeof current[key] !== "object") {
current[key] = {};
}
return current[key] as Record<string, unknown>;
}, obj);
target[lastKey] = value;
return obj;
function setNestedValue(
obj: Record<string, unknown>,
path: string,
value: unknown,
): Record<string, unknown> {
const keys = path.split(".");
const lastKey = keys.pop()!;
const target = keys.reduce((current, key) => {
if (!current[key] || typeof current[key] !== "object") {
current[key] = {};
}
return current[key] as Record<string, unknown>;
}, obj);
target[lastKey] = value;
return obj;
}

View File

@@ -10,7 +10,7 @@ import {
Play,
Target,
Users,
SkipForward
SkipForward,
} from "lucide-react";
import { Badge } from "~/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
@@ -22,10 +22,10 @@ interface TrialProgressProps {
id: string;
name: string;
type:
| "wizard_action"
| "robot_action"
| "parallel_steps"
| "conditional_branch";
| "wizard_action"
| "robot_action"
| "parallel_steps"
| "conditional_branch";
description?: string;
duration?: number;
parameters?: Record<string, unknown>;
@@ -118,7 +118,8 @@ export function TrialProgress({
return "pending";
// Default fallback if jumping around without explicitly adding to sets
if (index < currentStepIndex && !skippedSteps.has(index)) return "completed";
if (index < currentStepIndex && !skippedSteps.has(index))
return "completed";
return "upcoming";
};
@@ -211,12 +212,13 @@ export function TrialProgress({
</div>
<Progress
value={progress}
className={`h-2 ${trialStatus === "completed"
? "bg-green-100"
: trialStatus === "aborted" || trialStatus === "failed"
? "bg-red-100"
: "bg-blue-100"
}`}
className={`h-2 ${
trialStatus === "completed"
? "bg-green-100"
: trialStatus === "aborted" || trialStatus === "failed"
? "bg-red-100"
: "bg-blue-100"
}`}
/>
<div className="flex justify-between text-xs text-slate-500">
<span>Start</span>
@@ -255,47 +257,51 @@ export function TrialProgress({
{/* Connection Line */}
{index < steps.length - 1 && (
<div
className={`absolute top-12 left-6 h-6 w-0.5 ${getStepStatus(index + 1) === "completed" ||
className={`absolute top-12 left-6 h-6 w-0.5 ${
getStepStatus(index + 1) === "completed" ||
(getStepStatus(index + 1) === "active" &&
status === "completed")
? "bg-green-300"
: "bg-slate-300"
}`}
? "bg-green-300"
: "bg-slate-300"
}`}
/>
)}
{/* Step Card */}
<div
className={`flex items-start space-x-3 rounded-lg border p-3 transition-all ${status === "active"
? `${statusConfig.bgColor} ${statusConfig.borderColor} shadow-md ring-2 ring-blue-200`
: status === "completed"
? `${statusConfig.bgColor} ${statusConfig.borderColor}`
: status === "aborted"
className={`flex items-start space-x-3 rounded-lg border p-3 transition-all ${
status === "active"
? `${statusConfig.bgColor} ${statusConfig.borderColor} shadow-md ring-2 ring-blue-200`
: status === "completed"
? `${statusConfig.bgColor} ${statusConfig.borderColor}`
: "border-slate-200 bg-slate-50"
}`}
: status === "aborted"
? `${statusConfig.bgColor} ${statusConfig.borderColor}`
: "border-slate-200 bg-slate-50"
}`}
>
{/* Step Number & Status */}
<div className="flex-shrink-0 space-y-1">
<div
className={`flex h-8 w-12 items-center justify-center rounded-lg ${status === "active"
? statusConfig.bgColor
: status === "completed"
? "bg-green-100"
: status === "aborted"
? "bg-red-100"
: "bg-slate-100"
}`}
className={`flex h-8 w-12 items-center justify-center rounded-lg ${
status === "active"
? statusConfig.bgColor
: status === "completed"
? "bg-green-100"
: status === "aborted"
? "bg-red-100"
: "bg-slate-100"
}`}
>
<span
className={`text-sm font-medium ${status === "active"
? statusConfig.textColor
: status === "completed"
? "text-green-700"
: status === "aborted"
? "text-red-700"
: "text-slate-600"
}`}
className={`text-sm font-medium ${
status === "active"
? statusConfig.textColor
: status === "completed"
? "text-green-700"
: status === "aborted"
? "text-red-700"
: "text-slate-600"
}`}
>
{index + 1}
</span>
@@ -312,14 +318,15 @@ export function TrialProgress({
<div className="flex items-start justify-between">
<div className="min-w-0 flex-1">
<h5
className={`truncate font-medium ${status === "active"
? "text-slate-900"
: status === "completed"
? "text-green-900"
: status === "aborted"
? "text-red-900"
: "text-slate-700"
}`}
className={`truncate font-medium ${
status === "active"
? "text-slate-900"
: status === "completed"
? "text-green-900"
: status === "aborted"
? "text-red-900"
: "text-slate-700"
}`}
>
{step.name}
</h5>

View File

@@ -14,7 +14,7 @@ import {
ChevronDown,
ChevronUp,
Pause,
SkipForward
SkipForward,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { cn } from "~/lib/utils";
@@ -78,11 +78,7 @@ interface StepData {
id: string;
name: string;
description: string | null;
type:
| "wizard_action"
| "robot_action"
| "parallel_steps"
| "conditional";
type: "wizard_action" | "robot_action" | "parallel_steps" | "conditional";
parameters: Record<string, unknown>;
conditions?: {
nextStepId?: string;
@@ -91,7 +87,13 @@ interface StepData {
value: string;
nextStepId?: string;
nextStepIndex?: number;
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link";
variant?:
| "default"
| "destructive"
| "outline"
| "secondary"
| "ghost"
| "link";
}[];
};
order: number;
@@ -112,7 +114,9 @@ export const WizardInterface = React.memo(function WizardInterface({
const router = useRouter();
// UI State
const [executionPanelTab, setExecutionPanelTab] = useState<"current" | "timeline" | "events">("timeline");
const [executionPanelTab, setExecutionPanelTab] = useState<
"current" | "timeline" | "events"
>("timeline");
const [isExecutingAction, setIsExecutingAction] = useState(false);
const [monitoringPanelTab, setMonitoringPanelTab] = useState<
@@ -189,11 +193,14 @@ export const WizardInterface = React.memo(function WizardInterface({
toast.success(`Robot action completed: ${execution.actionId}`);
}, []);
const onActionFailed = useCallback((execution: { actionId: string; error?: string }) => {
toast.error(`Robot action failed: ${execution.actionId}`, {
description: execution.error,
});
}, []);
const onActionFailed = useCallback(
(execution: { actionId: string; error?: string }) => {
toast.error(`Robot action failed: ${execution.actionId}`, {
description: execution.error,
});
},
[],
);
// ROS WebSocket connection for robot control
const {
@@ -218,7 +225,7 @@ export const WizardInterface = React.memo(function WizardInterface({
async (enabled: boolean) => {
return setAutonomousLifeRaw(enabled);
},
[setAutonomousLifeRaw]
[setAutonomousLifeRaw],
);
// Use polling for trial status updates (no trial WebSocket server exists)
@@ -237,7 +244,7 @@ export const WizardInterface = React.memo(function WizardInterface({
{
refetchInterval: 3000,
staleTime: 1000,
}
},
);
// Update local trial state from polling only if changed
@@ -245,15 +252,18 @@ export const WizardInterface = React.memo(function WizardInterface({
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 ||
if (
pollingData.status !== trial.status ||
pollingData.startedAt?.getTime() !== trial.startedAt?.getTime() ||
pollingData.completedAt?.getTime() !== trial.completedAt?.getTime()) {
pollingData.completedAt?.getTime() !== trial.completedAt?.getTime()
) {
setTrial((prev) => {
// Double check inside setter to be safe
if (prev.status === pollingData.status &&
if (
prev.status === pollingData.status &&
prev.startedAt?.getTime() === pollingData.startedAt?.getTime() &&
prev.completedAt?.getTime() === pollingData.completedAt?.getTime()) {
prev.completedAt?.getTime() === pollingData.completedAt?.getTime()
) {
return prev;
}
return {
@@ -288,60 +298,80 @@ export const WizardInterface = React.memo(function WizardInterface({
message?: string;
}>
>(() => {
return (fetchedEvents ?? []).map(event => {
let message: string | undefined;
const eventData = event.data as any;
return (fetchedEvents ?? [])
.map((event) => {
let message: string | undefined;
const eventData = event.data as any;
// Extract or generate message based on event type
if (event.eventType.startsWith('annotation_')) {
message = eventData?.description || eventData?.label || 'Annotation added';
} else if (event.eventType.startsWith('robot_action_')) {
const actionName = event.eventType.replace('robot_action_', '').replace(/_/g, ' ');
message = `Robot action: ${actionName}`;
} else if (event.eventType === 'trial_started') {
message = 'Trial started';
} else if (event.eventType === 'trial_completed') {
message = 'Trial completed';
} else if (event.eventType === 'step_changed') {
message = `Step changed to: ${eventData?.stepName || 'next step'}`;
} else if (event.eventType.startsWith('wizard_')) {
message = eventData?.notes || eventData?.message || event.eventType.replace('wizard_', '').replace(/_/g, ' ');
} else {
// Generic fallback
message = eventData?.notes || eventData?.message || eventData?.description || event.eventType.replace(/_/g, ' ');
}
// Extract or generate message based on event type
if (event.eventType.startsWith("annotation_")) {
message =
eventData?.description || eventData?.label || "Annotation added";
} else if (event.eventType.startsWith("robot_action_")) {
const actionName = event.eventType
.replace("robot_action_", "")
.replace(/_/g, " ");
message = `Robot action: ${actionName}`;
} else if (event.eventType === "trial_started") {
message = "Trial started";
} else if (event.eventType === "trial_completed") {
message = "Trial completed";
} else if (event.eventType === "step_changed") {
message = `Step changed to: ${eventData?.stepName || "next step"}`;
} else if (event.eventType.startsWith("wizard_")) {
message =
eventData?.notes ||
eventData?.message ||
event.eventType.replace("wizard_", "").replace(/_/g, " ");
} else {
// Generic fallback
message =
eventData?.notes ||
eventData?.message ||
eventData?.description ||
event.eventType.replace(/_/g, " ");
}
return {
type: event.eventType,
timestamp: new Date(event.timestamp),
data: event.data,
message,
};
}).sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()); // Newest first
return {
type: event.eventType,
timestamp: new Date(event.timestamp),
data: event.data,
message,
};
})
.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()); // Newest first
}, [fetchedEvents]);
// Transform experiment steps to component format
const steps: StepData[] = useMemo(() =>
experimentSteps?.map((step, index) => ({
id: step.id,
name: step.name ?? `Step ${index + 1}`,
description: step.description,
type: mapStepType(step.type),
// Fix: Conditions are at root level from API
conditions: (step as any).conditions ?? (step as any).trigger?.conditions ?? undefined,
parameters: step.parameters ?? {},
order: step.order ?? index,
actions: step.actions?.filter(a => a.type !== 'branch').map((action) => ({
id: action.id,
name: action.name,
description: action.description,
type: action.type,
parameters: action.parameters ?? {},
order: action.order,
pluginId: action.pluginId,
const steps: StepData[] = useMemo(
() =>
experimentSteps?.map((step, index) => ({
id: step.id,
name: step.name ?? `Step ${index + 1}`,
description: step.description,
type: mapStepType(step.type),
// Fix: Conditions are at root level from API
conditions:
(step as any).conditions ??
(step as any).trigger?.conditions ??
undefined,
parameters: step.parameters ?? {},
order: step.order ?? index,
actions:
step.actions
?.filter((a) => a.type !== "branch")
.map((action) => ({
id: action.id,
name: action.name,
description: action.description,
type: action.type,
parameters: action.parameters ?? {},
order: action.order,
pluginId: action.pluginId,
})) ?? [],
})) ?? [],
})) ?? [], [experimentSteps]);
[experimentSteps],
);
const currentStep = steps[currentStepIndex] ?? null;
const totalSteps = steps.length;
@@ -416,7 +446,9 @@ export const WizardInterface = React.memo(function WizardInterface({
completedAt: data.completedAt,
});
toast.success("Trial completed! Redirecting to analysis...");
router.push(`/studies/${trial.experiment.studyId}/trials/${trial.id}/analysis`);
router.push(
`/studies/${trial.experiment.studyId}/trials/${trial.id}/analysis`,
);
}
},
});
@@ -472,8 +504,6 @@ export const WizardInterface = React.memo(function WizardInterface({
const result = await startTrialMutation.mutateAsync({ id: trial.id });
console.log("[WizardInterface] Trial started successfully", result);
// Update local state immediately
setTrial((prev) => ({
...prev,
@@ -506,7 +536,7 @@ export const WizardInterface = React.memo(function WizardInterface({
logEventMutation.mutate({
trialId: trial.id,
type: "trial_resumed",
data: { timestamp: new Date() }
data: { timestamp: new Date() },
});
setIsPaused(false);
toast.success("Trial resumed");
@@ -517,7 +547,7 @@ export const WizardInterface = React.memo(function WizardInterface({
const handleNextStep = (targetIndex?: number) => {
// If explicit target provided (from branching choice), use it
if (typeof targetIndex === 'number') {
if (typeof targetIndex === "number") {
// Find step by index to ensure safety
if (targetIndex >= 0 && targetIndex < steps.length) {
console.log(`[WizardInterface] Manual jump to step ${targetIndex}`);
@@ -531,8 +561,8 @@ export const WizardInterface = React.memo(function WizardInterface({
toIndex: targetIndex,
fromStepId: steps[currentStepIndex]?.id,
toStepId: steps[targetIndex]?.id,
reason: "manual_choice"
}
reason: "manual_choice",
},
});
setCompletedActionsCount(0);
@@ -546,13 +576,23 @@ export const WizardInterface = React.memo(function WizardInterface({
const currentStep = steps[currentStepIndex];
// Check if we have a stored response that dictates the next step
if (currentStep?.type === 'conditional' && currentStep.conditions?.options && lastResponse) {
const matchedOption = currentStep.conditions.options.find(opt => opt.value === lastResponse);
if (
currentStep?.type === "conditional" &&
currentStep.conditions?.options &&
lastResponse
) {
const matchedOption = currentStep.conditions.options.find(
(opt) => opt.value === lastResponse,
);
if (matchedOption && matchedOption.nextStepId) {
// Find index of the target step
const targetIndex = steps.findIndex(s => s.id === matchedOption.nextStepId);
const targetIndex = steps.findIndex(
(s) => s.id === matchedOption.nextStepId,
);
if (targetIndex !== -1) {
console.log(`[WizardInterface] Branching to step ${targetIndex} (${matchedOption.label})`);
console.log(
`[WizardInterface] Branching to step ${targetIndex} (${matchedOption.label})`,
);
logEventMutation.mutate({
trialId: trial.id,
@@ -561,8 +601,8 @@ export const WizardInterface = React.memo(function WizardInterface({
fromIndex: currentStepIndex,
toIndex: targetIndex,
condition: matchedOption.label,
value: lastResponse
}
value: lastResponse,
},
});
setCurrentStepIndex(targetIndex);
@@ -573,12 +613,17 @@ export const WizardInterface = React.memo(function WizardInterface({
}
// Check for explicit nextStepId in conditions (e.g. for end of branch)
console.log("[WizardInterface] Checking for nextStepId condition:", currentStep?.conditions);
console.log(
"[WizardInterface] Checking for nextStepId condition:",
currentStep?.conditions,
);
if (currentStep?.conditions?.nextStepId) {
const nextId = String(currentStep.conditions.nextStepId);
const targetIndex = steps.findIndex(s => s.id === nextId);
const targetIndex = steps.findIndex((s) => s.id === nextId);
if (targetIndex !== -1) {
console.log(`[WizardInterface] Condition-based jump to step ${targetIndex} (${nextId})`);
console.log(
`[WizardInterface] Condition-based jump to step ${targetIndex} (${nextId})`,
);
logEventMutation.mutate({
trialId: trial.id,
@@ -586,12 +631,12 @@ export const WizardInterface = React.memo(function WizardInterface({
data: {
fromIndex: currentStepIndex,
toIndex: targetIndex,
reason: "condition_next_step"
}
reason: "condition_next_step",
},
});
// Mark steps as skipped
setSkippedSteps(prev => {
setSkippedSteps((prev) => {
const next = new Set(prev);
for (let i = currentStepIndex + 1; i < targetIndex; i++) {
if (!completedSteps.has(i)) {
@@ -602,7 +647,7 @@ export const WizardInterface = React.memo(function WizardInterface({
});
// Mark current as complete
setCompletedSteps(prev => {
setCompletedSteps((prev) => {
const next = new Set(prev);
next.add(currentStepIndex);
return next;
@@ -612,17 +657,21 @@ export const WizardInterface = React.memo(function WizardInterface({
setCompletedActionsCount(0);
return;
} else {
console.warn(`[WizardInterface] Targeted nextStepId ${nextId} not found in steps list.`);
console.warn(
`[WizardInterface] Targeted nextStepId ${nextId} not found in steps list.`,
);
}
} else {
console.log("[WizardInterface] No nextStepId found in conditions, proceeding linearly.");
console.log(
"[WizardInterface] No nextStepId found in conditions, proceeding linearly.",
);
}
// Default: Linear progression
const nextIndex = currentStepIndex + 1;
if (nextIndex < steps.length) {
// Mark current step as complete
setCompletedSteps(prev => {
setCompletedSteps((prev) => {
const next = new Set(prev);
next.add(currentStepIndex);
return next;
@@ -638,8 +687,8 @@ export const WizardInterface = React.memo(function WizardInterface({
fromStepId: currentStep?.id,
toStepId: steps[nextIndex]?.id,
stepName: steps[nextIndex]?.name,
method: "auto"
}
method: "auto",
},
});
setCurrentStepIndex(nextIndex);
@@ -661,13 +710,13 @@ export const WizardInterface = React.memo(function WizardInterface({
fromStepId: currentStep?.id,
toStepId: steps[index]?.id,
stepName: steps[index]?.name,
method: "manual"
}
method: "manual",
},
});
// Mark current as complete if leaving it?
// Maybe better to only mark on "Next" or explicit complete.
// If I jump away, I might not be done.
// If I jump away, I might not be done.
// I'll leave 'completedSteps' update to explicit actions or completion.
setCurrentStepIndex(index);
@@ -676,7 +725,7 @@ export const WizardInterface = React.memo(function WizardInterface({
const handleCompleteTrial = async () => {
try {
// Mark final step as complete
setCompletedSteps(prev => {
setCompletedSteps((prev) => {
const next = new Set(prev);
next.add(currentStepIndex);
return next;
@@ -692,7 +741,9 @@ export const WizardInterface = React.memo(function WizardInterface({
archiveTrialMutation.mutate({ id: trial.id });
// Immediately navigate to analysis
router.push(`/studies/${trial.experiment.studyId}/trials/${trial.id}/analysis`);
router.push(
`/studies/${trial.experiment.studyId}/trials/${trial.id}/analysis`,
);
} catch (error) {
console.error("Failed to complete trial:", error);
}
@@ -701,8 +752,6 @@ export const WizardInterface = React.memo(function WizardInterface({
const handleAbortTrial = async () => {
try {
await abortTrialMutation.mutateAsync({ id: trial.id });
} catch (error) {
console.error("Failed to abort trial:", error);
}
@@ -731,8 +780,6 @@ export const WizardInterface = React.memo(function WizardInterface({
});
};
// Mutation for interventions
const addInterventionMutation = api.trials.addIntervention.useMutation({
onSuccess: () => toast.success("Intervention logged"),
@@ -753,9 +800,11 @@ export const WizardInterface = React.memo(function WizardInterface({
// If nextStepId is provided, jump immediately
if (parameters.nextStepId) {
const nextId = String(parameters.nextStepId);
const targetIndex = steps.findIndex(s => s.id === nextId);
const targetIndex = steps.findIndex((s) => s.id === nextId);
if (targetIndex !== -1) {
console.log(`[WizardInterface] Choice-based jump to step ${targetIndex} (${nextId})`);
console.log(
`[WizardInterface] Choice-based jump to step ${targetIndex} (${nextId})`,
);
handleNextStep(targetIndex);
return; // Exit after jump
}
@@ -780,7 +829,7 @@ export const WizardInterface = React.memo(function WizardInterface({
await addAnnotationMutation.mutateAsync({
trialId: trial.id,
description: String(parameters?.content || "Quick note"),
category: String(parameters?.category || "quick_note")
category: String(parameters?.category || "quick_note"),
});
} else {
// Generic action logging - now with more details
@@ -789,11 +838,17 @@ export const WizardInterface = React.memo(function WizardInterface({
let actionType = "unknown";
// Helper to search recursively
const findAction = (actions: ActionData[], id: string): ActionData | undefined => {
const findAction = (
actions: ActionData[],
id: string,
): ActionData | undefined => {
for (const action of actions) {
if (action.id === id) return action;
if (action.parameters?.children) {
const found = findAction(action.parameters.children as ActionData[], id);
const found = findAction(
action.parameters.children as ActionData[],
id,
);
if (found) return found;
}
}
@@ -821,10 +876,13 @@ export const WizardInterface = React.memo(function WizardInterface({
actionType = foundAction.type;
} else {
// Fallback for Wizard Actions (often have label/value in parameters)
if (parameters?.label && typeof parameters.label === 'string') {
if (parameters?.label && typeof parameters.label === "string") {
actionName = parameters.label;
actionType = "wizard_button";
} else if (parameters?.value && typeof parameters.value === 'string') {
} else if (
parameters?.value &&
typeof parameters.value === "string"
) {
actionName = parameters.value;
actionType = "wizard_input";
}
@@ -837,8 +895,8 @@ export const WizardInterface = React.memo(function WizardInterface({
actionId,
actionName,
actionType,
parameters
}
parameters,
},
});
}
@@ -877,7 +935,11 @@ export const WizardInterface = React.memo(function WizardInterface({
// Try direct WebSocket execution first for better performance
if (rosConnected) {
try {
const result = await executeRosAction(pluginName, actionId, parameters);
const result = await executeRosAction(
pluginName,
actionId,
parameters,
);
const duration =
result.endTime && result.startTime
@@ -962,8 +1024,8 @@ export const WizardInterface = React.memo(function WizardInterface({
type: "intervention_action_skipped",
data: {
actionId,
parameters
}
parameters,
},
});
}
@@ -979,18 +1041,19 @@ export const WizardInterface = React.memo(function WizardInterface({
[logRobotActionMutation, trial.id, logEventMutation, handleNextStep],
);
const handleLogEvent = useCallback((type: string, data?: any) => {
logEventMutation.mutate({
trialId: trial.id,
type,
data
});
}, [logEventMutation, trial.id]);
const handleLogEvent = useCallback(
(type: string, data?: any) => {
logEventMutation.mutate({
trialId: trial.id,
type,
data,
});
},
[logEventMutation, trial.id],
);
return (
<div className="flex h-[calc(100vh-5rem)] w-full flex-col overflow-hidden bg-background">
<div className="bg-background flex h-[calc(100vh-5rem)] w-full flex-col overflow-hidden">
<PageHeader
title="Trial Execution"
description={`Session ${trial.sessionNumber} • Participant ${trial.participant.participantCode}`}
@@ -998,11 +1061,7 @@ export const WizardInterface = React.memo(function WizardInterface({
actions={
<div className="flex items-center gap-2">
{trial.status === "scheduled" && (
<Button
onClick={handleStartTrial}
size="sm"
className="gap-2"
>
<Button onClick={handleStartTrial} size="sm" className="gap-2">
<Play className="h-4 w-4" />
Start Trial
</Button>
@@ -1016,7 +1075,11 @@ export const WizardInterface = React.memo(function WizardInterface({
onClick={isPaused ? handleResumeTrial : handlePauseTrial}
className="gap-2"
>
{isPaused ? <Play className="h-4 w-4" /> : <Pause className="h-4 w-4" />}
{isPaused ? (
<Play className="h-4 w-4" />
) : (
<Pause className="h-4 w-4" />
)}
{isPaused ? "Resume" : "Pause"}
</Button>
@@ -1065,11 +1128,10 @@ export const WizardInterface = React.memo(function WizardInterface({
/>
{/* Main Grid - Single Row */}
<div className="flex-1 min-h-0 flex gap-2 px-2 pb-2">
<div className="flex min-h-0 flex-1 gap-2 px-2 pb-2">
{/* Center - Execution Workspace */}
<div className="flex-1 flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm">
<div className="flex items-center border-b px-3 py-2 bg-muted/30 min-h-[45px]">
<div className="bg-background flex flex-1 flex-col overflow-hidden rounded-lg border shadow-sm">
<div className="bg-muted/30 flex min-h-[45px] items-center border-b px-3 py-2">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">Trial Execution</span>
{currentStep && (
@@ -1081,7 +1143,7 @@ export const WizardInterface = React.memo(function WizardInterface({
<div className="flex-1" />
<div className="mr-2 text-xs text-muted-foreground font-medium">
<div className="text-muted-foreground mr-2 text-xs font-medium">
Step {currentStepIndex + 1} / {steps.length}
</div>
@@ -1097,7 +1159,7 @@ export const WizardInterface = React.memo(function WizardInterface({
</Button>
)}
</div>
<div className="flex-1 overflow-auto bg-muted/10 pb-0">
<div className="bg-muted/10 flex-1 overflow-auto pb-0">
<div id="tour-wizard-timeline" className="h-full">
<WizardExecutionPanel
trial={trial}
@@ -1116,9 +1178,11 @@ export const WizardInterface = React.memo(function WizardInterface({
isExecuting={isExecutingAction}
onNextStep={handleNextStep}
completedActionsCount={completedActionsCount}
onActionCompleted={() => setCompletedActionsCount(c => c + 1)}
onActionCompleted={() => setCompletedActionsCount((c) => c + 1)}
onCompleteTrial={handleCompleteTrial}
readOnly={trial.status === 'completed' || _userRole === 'observer'}
readOnly={
trial.status === "completed" || _userRole === "observer"
}
rosConnected={rosConnected}
onLogEvent={handleLogEvent}
/>
@@ -1127,11 +1191,13 @@ export const WizardInterface = React.memo(function WizardInterface({
</div>
{/* Right Sidebar - Tools Tabs (Collapsible) */}
<div className={cn(
"flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm w-[350px] lg:w-[400px]",
rightCollapsed && "hidden"
)}>
<div className="flex items-center justify-between border-b px-3 py-2 bg-muted/30 shrink-0">
<div
className={cn(
"bg-background flex w-[350px] flex-col overflow-hidden rounded-lg border shadow-sm lg:w-[400px]",
rightCollapsed && "hidden",
)}
>
<div className="bg-muted/30 flex shrink-0 items-center justify-between border-b px-3 py-2">
<span className="text-sm font-medium">Tools</span>
<Button
variant="ghost"
@@ -1142,29 +1208,46 @@ export const WizardInterface = React.memo(function WizardInterface({
<PanelRightClose className="h-4 w-4" />
</Button>
</div>
<div className="flex-1 overflow-hidden bg-background">
<Tabs defaultValue="camera_obs" className="flex flex-col h-full w-full">
<TabsList className="w-full justify-start rounded-none border-b bg-muted/30 px-3 py-1 shrink-0 h-10">
<TabsTrigger value="camera_obs" className="text-xs flex-1">Camera & Obs</TabsTrigger>
<TabsTrigger value="robot" className="text-xs flex-1">Robot Control</TabsTrigger>
<div className="bg-background flex-1 overflow-hidden">
<Tabs
defaultValue="camera_obs"
className="flex h-full w-full flex-col"
>
<TabsList className="bg-muted/30 h-10 w-full shrink-0 justify-start rounded-none border-b px-3 py-1">
<TabsTrigger value="camera_obs" className="flex-1 text-xs">
Camera & Obs
</TabsTrigger>
<TabsTrigger value="robot" className="flex-1 text-xs">
Robot Control
</TabsTrigger>
</TabsList>
<TabsContent value="camera_obs" className="flex-1 flex-col m-0 p-0 h-full overflow-hidden min-h-0 data-[state=active]:flex">
<div className="flex-none bg-muted/30 border-b h-48 sm:h-56 relative group shrink-0">
<WebcamPanel readOnly={trial.status === 'completed'} trialId={trial.id} trialStatus={trial.status} />
<TabsContent
value="camera_obs"
className="m-0 h-full min-h-0 flex-1 flex-col overflow-hidden p-0 data-[state=active]:flex"
>
<div className="bg-muted/30 group relative h-48 flex-none shrink-0 border-b sm:h-56">
<WebcamPanel
readOnly={trial.status === "completed"}
trialId={trial.id}
trialStatus={trial.status}
/>
</div>
<div className="flex-1 overflow-auto min-h-0 bg-muted/10">
<div className="bg-muted/10 min-h-0 flex-1 overflow-auto">
<WizardObservationPane
onAddAnnotation={handleAddAnnotation}
onFlagIntervention={() => handleExecuteAction("intervene")}
isSubmitting={addAnnotationMutation.isPending}
trialEvents={trialEvents}
readOnly={trial.status === 'completed'}
readOnly={trial.status === "completed"}
/>
</div>
</TabsContent>
<TabsContent value="robot" className="flex-1 flex-col m-0 p-0 h-full overflow-hidden min-h-0 data-[state=active]:flex">
<TabsContent
value="robot"
className="m-0 h-full min-h-0 flex-1 flex-col overflow-hidden p-0 data-[state=active]:flex"
>
<WizardMonitoringPanel
rosConnected={rosConnected}
rosConnecting={rosConnecting}
@@ -1178,7 +1261,9 @@ export const WizardInterface = React.memo(function WizardInterface({
studyId={trial.experiment.studyId}
trialId={trial.id}
trialStatus={trial.status}
readOnly={trial.status === 'completed' || _userRole === 'observer'}
readOnly={
trial.status === "completed" || _userRole === "observer"
}
/>
</TabsContent>
</Tabs>

View File

@@ -2,12 +2,12 @@
import React, { useMemo } from "react";
import {
GitBranch,
Sparkles,
CheckCircle2,
Clock,
Play,
StickyNote,
GitBranch,
Sparkles,
CheckCircle2,
Clock,
Play,
StickyNote,
} from "lucide-react";
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
@@ -16,118 +16,126 @@ import { cn } from "~/lib/utils";
import { Progress } from "~/components/ui/progress";
export interface TrialStatusBarProps {
currentStepIndex: number;
totalSteps: number;
trialStatus: "scheduled" | "in_progress" | "completed" | "aborted" | "failed";
rosConnected: boolean;
eventsCount: number;
completedActionsCount: number;
totalActionsCount: number;
onAddNote?: () => void;
className?: string;
currentStepIndex: number;
totalSteps: number;
trialStatus: "scheduled" | "in_progress" | "completed" | "aborted" | "failed";
rosConnected: boolean;
eventsCount: number;
completedActionsCount: number;
totalActionsCount: number;
onAddNote?: () => void;
className?: string;
}
export function TrialStatusBar({
currentStepIndex,
totalSteps,
trialStatus,
rosConnected,
eventsCount,
completedActionsCount,
totalActionsCount,
onAddNote,
className,
currentStepIndex,
totalSteps,
trialStatus,
rosConnected,
eventsCount,
completedActionsCount,
totalActionsCount,
onAddNote,
className,
}: TrialStatusBarProps) {
const progressPercentage = useMemo(
() => (totalSteps > 0 ? ((currentStepIndex + 1) / totalSteps) * 100 : 0),
[currentStepIndex, totalSteps],
);
const progressPercentage = useMemo(
() => (totalSteps > 0 ? ((currentStepIndex + 1) / totalSteps) * 100 : 0),
[currentStepIndex, totalSteps],
);
const actionProgress = useMemo(
() =>
totalActionsCount > 0
? (completedActionsCount / totalActionsCount) * 100
: 0,
[completedActionsCount, totalActionsCount],
);
const actionProgress = useMemo(
() =>
totalActionsCount > 0
? (completedActionsCount / totalActionsCount) * 100
: 0,
[completedActionsCount, totalActionsCount],
);
return (
<div
className={cn(
"border-border/60 bg-muted/40 supports-[backdrop-filter]:bg-muted/30 backdrop-blur",
"flex h-9 w-full flex-shrink-0 items-center gap-4 border-t px-3 text-xs font-medium",
className,
)}
>
{/* Step Progress */}
<div className="flex items-center gap-2">
<span className="flex items-center gap-1.5 text-muted-foreground">
<GitBranch className="h-3.5 w-3.5 opacity-70" />
Step {currentStepIndex + 1}/{totalSteps}
</span>
<div className="w-20">
<Progress value={progressPercentage} className="h-1.5" />
</div>
<span className="text-muted-foreground/70">{Math.round(progressPercentage)}%</span>
</div>
<Separator orientation="vertical" className="h-4 opacity-50" />
{/* Action Progress */}
{totalActionsCount > 0 && (
<>
<div className="flex items-center gap-2">
<span className="flex items-center gap-1.5 text-muted-foreground">
<Sparkles className="h-3.5 w-3.5 opacity-70" />
{completedActionsCount}/{totalActionsCount} actions
</span>
<div className="w-16">
<Progress value={actionProgress} className="h-1.5" />
</div>
</div>
<Separator orientation="vertical" className="h-4 opacity-50" />
</>
)}
{/* Trial Stats */}
<div className="flex items-center gap-3 text-muted-foreground">
<span className="flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5 opacity-70" />
{eventsCount} events
</span>
{trialStatus === "in_progress" && (
<Badge variant="default" className="h-5 gap-1 bg-emerald-500 px-1.5 text-[10px] font-normal">
<Play className="h-2.5 w-2.5" />
Live
</Badge>
)}
{trialStatus === "completed" && (
<Badge variant="secondary" className="h-5 gap-1 px-1.5 text-[10px] font-normal">
<CheckCircle2 className="h-2.5 w-2.5" />
Completed
</Badge>
)}
</div>
<div className="flex-1" />
{/* Quick Actions */}
<div className="flex items-center gap-1">
{onAddNote && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={onAddNote}
title="Add Quick Note"
>
<StickyNote className="mr-1.5 h-3.5 w-3.5" />
Note
</Button>
)}
</div>
return (
<div
className={cn(
"border-border/60 bg-muted/40 supports-[backdrop-filter]:bg-muted/30 backdrop-blur",
"flex h-9 w-full flex-shrink-0 items-center gap-4 border-t px-3 text-xs font-medium",
className,
)}
>
{/* Step Progress */}
<div className="flex items-center gap-2">
<span className="text-muted-foreground flex items-center gap-1.5">
<GitBranch className="h-3.5 w-3.5 opacity-70" />
Step {currentStepIndex + 1}/{totalSteps}
</span>
<div className="w-20">
<Progress value={progressPercentage} className="h-1.5" />
</div>
);
<span className="text-muted-foreground/70">
{Math.round(progressPercentage)}%
</span>
</div>
<Separator orientation="vertical" className="h-4 opacity-50" />
{/* Action Progress */}
{totalActionsCount > 0 && (
<>
<div className="flex items-center gap-2">
<span className="text-muted-foreground flex items-center gap-1.5">
<Sparkles className="h-3.5 w-3.5 opacity-70" />
{completedActionsCount}/{totalActionsCount} actions
</span>
<div className="w-16">
<Progress value={actionProgress} className="h-1.5" />
</div>
</div>
<Separator orientation="vertical" className="h-4 opacity-50" />
</>
)}
{/* Trial Stats */}
<div className="text-muted-foreground flex items-center gap-3">
<span className="flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5 opacity-70" />
{eventsCount} events
</span>
{trialStatus === "in_progress" && (
<Badge
variant="default"
className="h-5 gap-1 bg-emerald-500 px-1.5 text-[10px] font-normal"
>
<Play className="h-2.5 w-2.5" />
Live
</Badge>
)}
{trialStatus === "completed" && (
<Badge
variant="secondary"
className="h-5 gap-1 px-1.5 text-[10px] font-normal"
>
<CheckCircle2 className="h-2.5 w-2.5" />
Completed
</Badge>
)}
</div>
<div className="flex-1" />
{/* Quick Actions */}
<div className="flex items-center gap-1">
{onAddNote && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={onAddNote}
title="Add Quick Note"
>
<StickyNote className="mr-1.5 h-3.5 w-3.5" />
Note
</Button>
)}
</div>
</div>
);
}
export default TrialStatusBar;

View File

@@ -9,295 +9,312 @@ import { AspectRatio } from "~/components/ui/aspect-ratio";
import { toast } from "sonner";
import { api } from "~/trpc/react";
export function WebcamPanel({ readOnly = false, trialId, trialStatus }: { readOnly?: boolean; trialId?: string; trialStatus?: string }) {
const [isCameraEnabled, setIsCameraEnabled] = useState(false);
const [isRecording, setIsRecording] = useState(false);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
export function WebcamPanel({
readOnly = false,
trialId,
trialStatus,
}: {
readOnly?: boolean;
trialId?: string;
trialStatus?: string;
}) {
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[]>([]);
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();
// TRPC mutation for presigned URL
const getUploadUrlMutation = api.storage.getUploadPresignedUrl.useMutation();
// Mutation to save recording metadata to DB
const saveRecordingMutation = api.storage.saveRecording.useMutation();
const logEventMutation = api.trials.logEvent.useMutation();
// Mutation to save recording metadata to DB
const saveRecordingMutation = api.storage.saveRecording.useMutation();
const logEventMutation = api.trials.logEvent.useMutation();
const [isMounted, setIsMounted] = useState(false);
const [isMounted, setIsMounted] = useState(false);
React.useEffect(() => {
setIsMounted(true);
}, []);
React.useEffect(() => {
setIsMounted(true);
}, []);
const handleEnableCamera = () => {
const handleEnableCamera = () => {
setIsCameraEnabled(true);
setError(null);
};
const handleDisableCamera = () => {
if (isRecording) {
handleStopRecording();
}
setIsCameraEnabled(false);
};
// Auto-record based on trial status
React.useEffect(() => {
if (!trialStatus || readOnly) return;
if (trialStatus === "in_progress") {
if (!isCameraEnabled) {
console.log("Auto-enabling camera for trial start");
setIsCameraEnabled(true);
setError(null);
};
} else if (!isRecording && webcamRef.current?.stream) {
handleStartRecording();
}
} else if (trialStatus === "completed" && isRecording) {
handleStopRecording();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [trialStatus, isCameraEnabled, isRecording, readOnly]);
const handleDisableCamera = () => {
if (isRecording) {
handleStopRecording();
const handleUserMedia = () => {
if (trialStatus === "in_progress" && !isRecording && !readOnly) {
console.log("Stream ready, auto-starting camera recording");
handleStartRecording();
}
};
const handleStartRecording = () => {
if (!webcamRef.current?.stream) return;
if (
mediaRecorderRef.current &&
mediaRecorderRef.current.state === "recording"
) {
console.log("Already recording, skipping start");
return;
}
setIsRecording(true);
chunksRef.current = [];
try {
const recorder = new MediaRecorder(webcamRef.current.stream, {
mimeType: "video/webm",
});
recorder.ondataavailable = (event) => {
if (event.data.size > 0) {
chunksRef.current.push(event.data);
}
setIsCameraEnabled(false);
};
};
// Auto-record based on trial status
React.useEffect(() => {
if (!trialStatus || readOnly) return;
recorder.onstop = async () => {
const blob = new Blob(chunksRef.current, { type: "video/webm" });
await handleUpload(blob);
};
if (trialStatus === "in_progress") {
if (!isCameraEnabled) {
console.log("Auto-enabling camera for trial start");
setIsCameraEnabled(true);
} else if (!isRecording && webcamRef.current?.stream) {
handleStartRecording();
}
} else if (trialStatus === "completed" && isRecording) {
handleStopRecording();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [trialStatus, isCameraEnabled, isRecording, readOnly]);
recorder.start();
mediaRecorderRef.current = recorder;
if (trialId) {
logEventMutation.mutate({
trialId,
type: "camera_started",
data: { action: "recording_started" },
});
}
toast.success("Recording started");
} catch (e) {
console.error("Failed to start recorder:", e);
toast.error("Failed to start recording");
setIsRecording(false);
}
};
const handleUserMedia = () => {
if (trialStatus === "in_progress" && !isRecording && !readOnly) {
console.log("Stream ready, auto-starting camera recording");
handleStartRecording();
}
};
const handleStopRecording = () => {
if (
mediaRecorderRef.current &&
isRecording &&
mediaRecorderRef.current.state === "recording"
) {
mediaRecorderRef.current.stop();
setIsRecording(false);
if (trialId) {
logEventMutation.mutate({
trialId,
type: "camera_stopped",
data: { action: "recording_stopped" },
});
}
}
};
const handleStartRecording = () => {
if (!webcamRef.current?.stream) return;
if (mediaRecorderRef.current && mediaRecorderRef.current.state === "recording") {
console.log("Already recording, skipping start");
return;
}
const handleUpload = async (blob: Blob) => {
setUploading(true);
const filename = `recording-${Date.now()}.webm`;
setIsRecording(true);
chunksRef.current = [];
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) {
const errorText = await response.text();
throw new Error(
`Upload failed: ${errorText} | Status: ${response.status}`,
);
}
// 3. Save metadata to DB
if (trialId) {
console.log("Attempting to link recording to trial:", trialId);
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;
if (trialId) {
logEventMutation.mutate({
trialId,
type: "camera_started",
data: { action: "recording_started" }
});
}
toast.success("Recording started");
} catch (e) {
console.error("Failed to start recorder:", e);
toast.error("Failed to start recording");
setIsRecording(false);
await saveRecordingMutation.mutateAsync({
trialId,
storagePath: filename,
mediaType: "video",
format: "webm",
fileSize: blob.size,
});
console.log("Recording successfully linked to trial:", trialId);
toast.success("Recording saved to trial log");
} catch (mutationError) {
console.error("Failed to link recording to trial:", mutationError);
toast.error("Video uploaded but failed to link to trial");
}
};
} else {
console.warn(
"No trialId provided, recording uploaded but not linked. Props:",
{ trialId },
);
toast.warning("Trial ID missing - recording not linked");
}
const handleStopRecording = () => {
if (mediaRecorderRef.current && isRecording && mediaRecorderRef.current.state === "recording") {
mediaRecorderRef.current.stop();
setIsRecording(false);
if (trialId) {
logEventMutation.mutate({
trialId,
type: "camera_stopped",
data: { action: "recording_stopped" }
});
}
}
};
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);
}
};
const handleUpload = async (blob: Blob) => {
setUploading(true);
const filename = `recording-${Date.now()}.webm`;
return (
<div className="flex h-full flex-col">
<div className="bg-muted/10 flex h-10 shrink-0 items-center justify-end border-b px-2 py-1">
{!readOnly && (
<div className="flex items-center gap-2">
{isCameraEnabled &&
(!isRecording ? (
<Button
variant="destructive"
size="sm"
className="animate-in fade-in h-7 px-2 text-xs"
onClick={handleStartRecording}
disabled={uploading}
>
<Video className="mr-1 h-3 w-3" />
Record
</Button>
) : (
<Button
variant="secondary"
size="sm"
className="h-7 border border-red-500 px-2 text-xs text-red-500 hover:bg-red-50"
onClick={handleStopRecording}
>
<StopCircle className="mr-1 h-3 w-3 animate-pulse" />
Stop Rec
</Button>
))}
try {
// 1. Get Presigned URL
const { url } = await getUploadUrlMutation.mutateAsync({
filename,
contentType: "video/webm",
});
{isCameraEnabled ? (
<Button
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground h-7 px-2 text-xs"
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>
// 2. Upload to S3
const response = await fetch(url, {
method: "PUT",
body: blob,
headers: {
"Content-Type": "video/webm",
},
});
<div className="bg-muted/50 relative flex flex-1 items-center justify-center overflow-hidden p-4">
{isCameraEnabled ? (
<div className="border-border relative w-full overflow-hidden rounded-lg border bg-black shadow-sm">
<AspectRatio ratio={16 / 9}>
<Webcam
ref={webcamRef}
audio={false}
width="100%"
height="100%"
onUserMedia={handleUserMedia}
onUserMediaError={(err) => setError(String(err))}
className="h-full w-full object-contain"
/>
</AspectRatio>
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Upload failed: ${errorText} | Status: ${response.status}`);
}
{/* Recording Overlay */}
{isRecording && (
<div className="absolute top-2 right-2 flex items-center gap-2 rounded-full bg-black/50 px-2 py-1 backdrop-blur-sm">
<div className="h-2 w-2 animate-pulse rounded-full bg-red-500" />
<span className="text-[10px] font-medium text-white">REC</span>
</div>
)}
// 3. Save metadata to DB
if (trialId) {
console.log("Attempting to link recording to trial:", trialId);
try {
await saveRecordingMutation.mutateAsync({
trialId,
storagePath: filename,
mediaType: "video",
format: "webm",
fileSize: blob.size,
});
console.log("Recording successfully linked to trial:", trialId);
toast.success("Recording saved to trial log");
} catch (mutationError) {
console.error("Failed to link recording to trial:", mutationError);
toast.error("Video uploaded but failed to link to trial");
}
} else {
console.warn("No trialId provided, recording uploaded but not linked. Props:", { trialId });
toast.warning("Trial ID missing - recording not linked");
}
{/* 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>
)}
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-end border-b px-2 py-1 bg-muted/10 h-10 shrink-0">
{!readOnly && (
<div className="flex items-center gap-2">
{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>
)}
{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-muted-foreground/50 text-center">
<div className="bg-muted mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-full">
<CameraOff className="h-6 w-6 opacity-50" />
</div>
<div className="flex-1 overflow-hidden bg-muted/50 p-4 flex items-center justify-center relative">
{isCameraEnabled ? (
<div className="w-full relative rounded-lg overflow-hidden border border-border shadow-sm bg-black">
<AspectRatio ratio={16 / 9}>
<Webcam
ref={webcamRef}
audio={false}
width="100%"
height="100%"
onUserMedia={handleUserMedia}
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-muted-foreground/50">
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-full bg-muted">
<CameraOff className="h-6 w-6 opacity-50" />
</div>
<p className="text-sm font-medium">Camera is disabled</p>
<Button
variant="secondary"
size="sm"
className="mt-4"
onClick={handleEnableCamera}
>
Enable Camera
</Button>
</div>
)}
</div>
</div >
);
<p className="text-sm font-medium">Camera is disabled</p>
<Button
variant="secondary"
size="sm"
className="mt-4"
onClick={handleEnableCamera}
>
Enable Camera
</Button>
</div>
)}
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -25,11 +25,7 @@ interface StepData {
id: string;
name: string;
description: string | null;
type:
| "wizard_action"
| "robot_action"
| "parallel_steps"
| "conditional"; // Updated to match DB enum
type: "wizard_action" | "robot_action" | "parallel_steps" | "conditional"; // Updated to match DB enum
parameters: Record<string, unknown>;
conditions?: {
options?: {
@@ -37,7 +33,13 @@ interface StepData {
value: string;
nextStepId?: string;
nextStepIndex?: number;
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link";
variant?:
| "default"
| "destructive"
| "outline"
| "secondary"
| "ghost"
| "link";
}[];
};
order: number;
@@ -109,12 +111,8 @@ export const WizardControlPanel = React.memo(function WizardControlPanel({
isStarting = false,
readOnly = false,
}: WizardControlPanelProps) {
return (
<div className="flex h-full flex-col" id="tour-wizard-controls">
<div className="min-h-0 flex-1">
<ScrollArea className="h-full">
<div className="space-y-4 p-3">
@@ -137,7 +135,7 @@ export const WizardControlPanel = React.memo(function WizardControlPanel({
<Button
variant="outline"
size="sm"
className="w-full justify-start border-yellow-200 bg-yellow-50 text-yellow-700 hover:bg-yellow-100 hover:text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-300 dark:border-yellow-700/50 dark:hover:bg-yellow-900/40"
className="w-full justify-start border-yellow-200 bg-yellow-50 text-yellow-700 hover:bg-yellow-100 hover:text-yellow-800 dark:border-yellow-700/50 dark:bg-yellow-900/20 dark:text-yellow-300 dark:hover:bg-yellow-900/40"
onClick={() => onExecuteAction("intervene")}
disabled={readOnly}
>
@@ -149,7 +147,9 @@ export const WizardControlPanel = React.memo(function WizardControlPanel({
variant="outline"
size="sm"
className="w-full justify-start"
onClick={() => onExecuteAction("note", { content: "Wizard note" })}
onClick={() =>
onExecuteAction("note", { content: "Wizard note" })
}
disabled={readOnly}
>
<User className="mr-2 h-3 w-3" />
@@ -170,16 +170,18 @@ export const WizardControlPanel = React.memo(function WizardControlPanel({
)}
</div>
) : (
<div className="text-xs text-muted-foreground p-2 text-center border border-dashed rounded-md bg-muted/20">
<div className="text-muted-foreground bg-muted/20 rounded-md border border-dashed p-2 text-center text-xs">
Controls available during trial
</div>
)}
{/* Step Navigation */}
<div className="pt-4 border-t space-y-2">
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Navigation</span>
<div className="space-y-2 border-t pt-4">
<span className="text-muted-foreground text-xs font-semibold tracking-wider uppercase">
Navigation
</span>
<select
className="w-full text-xs p-2 rounded-md border bg-background"
className="bg-background w-full rounded-md border p-2 text-xs"
value={currentStepIndex}
onChange={(e) => onNextStep(parseInt(e.target.value, 10))}
disabled={readOnly}

View File

@@ -1,6 +1,5 @@
"use client";
import React from "react";
import { WizardActionItem } from "./WizardActionItem";
import {
@@ -23,11 +22,7 @@ interface StepData {
id: string;
name: string;
description: string | null;
type:
| "wizard_action"
| "robot_action"
| "parallel_steps"
| "conditional";
type: "wizard_action" | "robot_action" | "parallel_steps" | "conditional";
parameters: Record<string, unknown>;
conditions?: {
options?: {
@@ -35,7 +30,13 @@ interface StepData {
value: string;
nextStepId?: string;
nextStepIndex?: number;
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link";
variant?:
| "default"
| "destructive"
| "outline"
| "secondary"
| "ghost"
| "link";
}[];
};
order: number;
@@ -166,7 +167,7 @@ export function WizardExecutionPanel({
if (trial.status === "scheduled") {
return (
<div className="flex h-full flex-col">
<div className="flex-1 flex items-center justify-center p-6">
<div className="flex flex-1 items-center justify-center p-6">
<div className="w-full max-w-md space-y-4 text-center">
<Clock className="text-muted-foreground mx-auto h-12 w-12 opacity-20" />
<div>
@@ -219,16 +220,17 @@ export function WizardExecutionPanel({
// Active trial state
return (
<div className="flex h-full flex-col overflow-hidden relative">
<div className="relative flex h-full flex-col overflow-hidden">
{/* Paused Overlay */}
{isPaused && (
<div className="absolute inset-0 z-50 bg-background/60 backdrop-blur-[2px] flex items-center justify-center">
<div className="bg-background border shadow-lg rounded-xl p-8 flex flex-col items-center max-w-sm text-center space-y-4">
<AlertCircle className="h-12 w-12 text-muted-foreground" />
<div className="bg-background/60 absolute inset-0 z-50 flex items-center justify-center backdrop-blur-[2px]">
<div className="bg-background flex max-w-sm flex-col items-center space-y-4 rounded-xl border p-8 text-center shadow-lg">
<AlertCircle className="text-muted-foreground h-12 w-12" />
<div>
<h2 className="text-xl font-bold tracking-tight">Trial Paused</h2>
<p className="text-sm text-muted-foreground mt-1">
The trial execution has been paused. Resume from the control bar to continue interacting.
<p className="text-muted-foreground mt-1 text-sm">
The trial execution has been paused. Resume from the control bar
to continue interacting.
</p>
</div>
</div>
@@ -236,48 +238,45 @@ export function WizardExecutionPanel({
)}
{/* Horizontal Step Progress Bar */}
<div className="flex-none border-b bg-muted/30 p-3">
<div className="bg-muted/30 flex-none border-b p-3">
<div className="flex items-center gap-2 overflow-x-auto pb-2">
{steps.map((step, idx) => {
const isCurrent = idx === currentStepIndex;
const isSkipped = skippedStepIndices.has(idx);
const isCompleted = completedStepIndices.has(idx) || (!isSkipped && idx < currentStepIndex);
const isCompleted =
completedStepIndices.has(idx) ||
(!isSkipped && idx < currentStepIndex);
const isUpcoming = idx > currentStepIndex;
return (
<div
key={step.id}
className="flex items-center gap-2 flex-shrink-0"
className="flex flex-shrink-0 items-center gap-2"
>
<button
onClick={() => onStepSelect(idx)}
disabled={readOnly}
className={`
group relative flex items-center gap-2 rounded-lg border-2 px-3 py-2 transition-all
${isCurrent
className={`group relative flex items-center gap-2 rounded-lg border-2 px-3 py-2 transition-all ${
isCurrent
? "border-primary bg-primary/10 shadow-sm"
: isCompleted
? "border-primary/30 bg-primary/5 hover:bg-primary/10"
: isSkipped
? "border-muted-foreground/30 bg-muted/20 border-dashed"
: "border-muted-foreground/20 bg-background hover:bg-muted/50"
}
${readOnly ? "cursor-default" : "cursor-pointer"}
`}
} ${readOnly ? "cursor-default" : "cursor-pointer"} `}
>
{/* Step Number/Icon */}
<div
className={`
flex h-6 w-6 items-center justify-center rounded-full text-xs font-bold
${isCompleted
className={`flex h-6 w-6 items-center justify-center rounded-full text-xs font-bold ${
isCompleted
? "bg-primary text-primary-foreground"
: isSkipped
? "bg-transparent border border-muted-foreground/40 text-muted-foreground"
? "border-muted-foreground/40 text-muted-foreground border bg-transparent"
: isCurrent
? "bg-primary text-primary-foreground ring-2 ring-primary/20"
? "bg-primary text-primary-foreground ring-primary/20 ring-2"
: "bg-muted text-muted-foreground"
}
`}
} `}
>
{isCompleted ? (
<CheckCircle className="h-3.5 w-3.5" />
@@ -288,12 +287,13 @@ export function WizardExecutionPanel({
{/* Step Name */}
<span
className={`text-xs font-medium max-w-[120px] truncate ${isCurrent
? "text-foreground"
: isCompleted
? "text-muted-foreground"
: "text-muted-foreground/60"
}`}
className={`max-w-[120px] truncate text-xs font-medium ${
isCurrent
? "text-foreground"
: isCompleted
? "text-muted-foreground"
: "text-muted-foreground/60"
}`}
title={step.name}
>
{step.name}
@@ -303,8 +303,11 @@ export function WizardExecutionPanel({
{/* Arrow Connector */}
{idx < steps.length - 1 && (
<ArrowRight
className={`h-4 w-4 flex-shrink-0 ${isCompleted ? "text-primary/40" : "text-muted-foreground/30"
}`}
className={`h-4 w-4 flex-shrink-0 ${
isCompleted
? "text-primary/40"
: "text-muted-foreground/30"
}`}
/>
)}
</div>
@@ -314,16 +317,20 @@ export function WizardExecutionPanel({
</div>
{/* Current Step Details - NO SCROLL */}
<div className="flex-1 min-h-0 overflow-hidden">
<div className="min-h-0 flex-1 overflow-hidden">
<div className="h-full overflow-y-auto">
<div className="pr-4">
{currentStep ? (
<div className="flex flex-col gap-4 p-4 max-w-5xl mx-auto w-full">
<div className="mx-auto flex w-full max-w-5xl flex-col gap-4 p-4">
{/* Header Info */}
<div className="space-y-1 pb-4 border-b">
<h2 className="text-xl font-bold tracking-tight">{currentStep.name}</h2>
<div className="space-y-1 border-b pb-4">
<h2 className="text-xl font-bold tracking-tight">
{currentStep.name}
</h2>
{currentStep.description && (
<div className="text-muted-foreground">{currentStep.description}</div>
<div className="text-muted-foreground">
{currentStep.description}
</div>
)}
</div>
@@ -333,34 +340,38 @@ export function WizardExecutionPanel({
{currentStep.actions.map((action, idx) => {
const isCompleted = idx < activeActionIndex;
const isActive: boolean = idx === activeActionIndex;
const isLast = idx === (currentStep.actions?.length || 0) - 1;
const isLast =
idx === (currentStep.actions?.length || 0) - 1;
return (
<div
key={action.id}
className="relative pl-8 pb-10 last:pb-0"
className="relative pb-10 pl-8 last:pb-0"
ref={isActive ? activeActionRef : undefined}
>
{/* Connecting Line */}
{!isLast && (
<div
className={`absolute left-[11px] top-8 bottom-0 w-[2px] ${isCompleted ? "bg-primary/20" : "bg-border/40"}`}
className={`absolute top-8 bottom-0 left-[11px] w-[2px] ${isCompleted ? "bg-primary/20" : "bg-border/40"}`}
/>
)}
{/* Marker */}
<div
className={`absolute left-0 top-1 h-6 w-6 rounded-full border-2 flex items-center justify-center z-10 bg-background transition-all duration-300 ${isCompleted
? "border-primary bg-primary text-primary-foreground"
: isActive
? "border-primary ring-4 ring-primary/10 scale-110"
: "border-muted-foreground/30 text-muted-foreground"
}`}
className={`bg-background absolute top-1 left-0 z-10 flex h-6 w-6 items-center justify-center rounded-full border-2 transition-all duration-300 ${
isCompleted
? "border-primary bg-primary text-primary-foreground"
: isActive
? "border-primary ring-primary/10 scale-110 ring-4"
: "border-muted-foreground/30 text-muted-foreground"
}`}
>
{isCompleted ? (
<CheckCircle className="h-3.5 w-3.5" />
) : (
<span className="text-[10px] font-bold">{idx + 1}</span>
<span className="text-[10px] font-bold">
{idx + 1}
</span>
)}
</div>
@@ -390,21 +401,28 @@ export function WizardExecutionPanel({
<div className="mt-6 flex justify-center pb-8">
<Button
size="lg"
onClick={currentStepIndex === steps.length - 1 ? onCompleteTrial : onNextStep}
className={`w-full max-w-sm text-white shadow-lg transition-all hover:scale-[1.02] ${currentStepIndex === steps.length - 1
? "bg-blue-600 hover:bg-blue-700"
: "bg-green-600 hover:bg-green-700"
}`}
onClick={
currentStepIndex === steps.length - 1
? onCompleteTrial
: onNextStep
}
className={`w-full max-w-sm text-white shadow-lg transition-all hover:scale-[1.02] ${
currentStepIndex === steps.length - 1
? "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"}
{currentStepIndex === steps.length - 1
? "Complete Trial"
: "Complete Step"}
<ArrowRight className="ml-2 h-5 w-5" />
</Button>
</div>
)}
</div>
) : (
<div className="flex h-full flex-col items-center justify-center text-muted-foreground space-y-3">
<div className="text-muted-foreground flex h-full flex-col items-center justify-center space-y-3">
<Loader2 className="h-8 w-8 animate-spin opacity-50" />
<div className="text-sm">Waiting for trial to start...</div>
</div>

View File

@@ -6,6 +6,14 @@ import {
Power,
PowerOff,
AlertCircle,
CheckCircle2,
RotateCcw,
RotateCw,
ArrowUp,
ArrowDown,
ArrowLeft,
ArrowRight,
Square,
} from "lucide-react";
import { Badge } from "~/components/ui/badge";
import { Separator } from "~/components/ui/separator";
@@ -64,24 +72,27 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
}: WizardMonitoringPanelProps) {
const [autonomousLife, setAutonomousLife] = React.useState(true);
const handleAutonomousLifeChange = React.useCallback(async (checked: boolean) => {
setAutonomousLife(checked); // Optimistic update
if (onSetAutonomousLife) {
try {
const result = await onSetAutonomousLife(checked);
if (result === false) {
throw new Error("Service unavailable");
const handleAutonomousLifeChange = React.useCallback(
async (checked: boolean) => {
setAutonomousLife(checked); // Optimistic update
if (onSetAutonomousLife) {
try {
const result = await onSetAutonomousLife(checked);
if (result === false) {
throw new Error("Service unavailable");
}
} catch (error) {
console.error("Failed to set autonomous life:", error);
setAutonomousLife(!checked); // Revert on failure
}
} catch (error) {
console.error("Failed to set autonomous life:", error);
setAutonomousLife(!checked); // Revert on failure
}
}
}, [onSetAutonomousLife]);
},
[onSetAutonomousLife],
);
return (
<div className="flex h-full flex-col p-2">
{/* Robot Controls - Scrollable */}
<div className="flex-1 min-h-0 bg-background rounded-lg border shadow-sm overflow-hidden flex flex-col">
<div className="bg-background flex min-h-0 flex-1 flex-col overflow-hidden rounded-lg border shadow-sm">
<ScrollArea className="flex-1">
<div className="space-y-4 p-3">
{/* Robot Status */}
@@ -92,7 +103,12 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
{rosConnected ? (
<Power className="h-3 w-3 text-green-600" />
) : (
<Badge variant="outline" className="text-gray-500 border-gray-300 text-xs text-muted-foreground w-auto px-1.5 py-0">Offline</Badge>
<Badge
variant="outline"
className="text-muted-foreground w-auto border-gray-300 px-1.5 py-0 text-xs text-gray-500"
>
Offline
</Badge>
)}
</div>
</div>
@@ -145,11 +161,16 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
disabled={rosConnecting || rosConnected || readOnly}
>
<Bot className="mr-1 h-3 w-3" />
{rosConnecting
? "Connecting..."
: rosConnected
? "Connected ✓"
: "Connect to NAO6"}
{rosConnecting ? (
"Connecting..."
) : rosConnected ? (
<div className="flex items-center gap-1.5">
<span>Connected</span>
<CheckCircle2 className="h-3 w-3" />
</div>
) : (
"Connect to NAO6"
)}
</Button>
) : (
<Button
@@ -192,7 +213,12 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
{/* Autonomous Life Toggle */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="autonomous-life" className="text-xs font-normal text-muted-foreground">Autonomous Life</Label>
<Label
htmlFor="autonomous-life"
className="text-muted-foreground text-xs font-normal"
>
Autonomous Life
</Label>
<Switch
id="tour-wizard-autonomous"
checked={!!autonomousLife}
@@ -235,7 +261,7 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
}}
disabled={readOnly}
>
Turn L
<RotateCcw className="mr-1 h-3 w-3" /> Turn L
</Button>
<Button
size="sm"
@@ -248,7 +274,7 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
}}
disabled={readOnly}
>
Forward
<ArrowUp className="mr-1 h-3 w-3" /> Forward
</Button>
<Button
size="sm"
@@ -261,7 +287,7 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
}}
disabled={readOnly}
>
Turn R
Turn R <RotateCw className="ml-1 h-3 w-3" />
</Button>
{/* Row 2: Left, Stop, Right */}
@@ -276,7 +302,7 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
}}
disabled={readOnly}
>
Left
<ArrowLeft className="mr-1 h-3 w-3" /> Left
</Button>
<Button
size="sm"
@@ -289,7 +315,7 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
}}
disabled={readOnly}
>
Stop
<Square className="mr-1 h-3 w-3 fill-current" /> Stop
</Button>
<Button
size="sm"
@@ -302,7 +328,7 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
}}
disabled={readOnly}
>
Right
Right <ArrowRight className="ml-1 h-3 w-3" />
</Button>
{/* Row 3: Empty, Back, Empty */}
@@ -318,7 +344,7 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
}}
disabled={readOnly}
>
Back
<ArrowDown className="mr-1 h-3 w-3" /> Back
</Button>
<div></div>
</div>
@@ -337,10 +363,14 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
<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"
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex-1 rounded-md border px-2 py-1 text-xs focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50"
disabled={readOnly}
onKeyDown={(e) => {
if (e.key === "Enter" && e.currentTarget.value.trim() && !readOnly) {
if (
e.key === "Enter" &&
e.currentTarget.value.trim() &&
!readOnly
) {
executeRosAction("nao6-ros2", "say_text", {
text: e.currentTarget.value.trim(),
}).catch(console.error);
@@ -353,7 +383,8 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
variant="outline"
className="text-xs"
onClick={(e) => {
const input = e.currentTarget.previousElementSibling as HTMLInputElement;
const input = e.currentTarget
.previousElementSibling as HTMLInputElement;
if (input?.value.trim()) {
executeRosAction("nao6-ros2", "say_text", {
text: input.value.trim(),

View File

@@ -1,174 +1,194 @@
"use client";
import React, { useState } from "react";
import { Send, Hash, Tag, Clock, Flag, CheckCircle, Bot, User, MessageSquare, AlertTriangle, Activity } from "lucide-react";
import {
Send,
Hash,
Tag,
Clock,
Flag,
CheckCircle,
Bot,
User,
MessageSquare,
AlertTriangle,
Activity,
} from "lucide-react";
import { Button } from "~/components/ui/button";
import { Textarea } from "~/components/ui/textarea";
import { ScrollArea } from "~/components/ui/scroll-area";
import { Badge } from "~/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
interface TrialEvent {
type: string;
timestamp: Date;
data?: unknown;
message?: string;
type: string;
timestamp: Date;
data?: unknown;
message?: string;
}
interface WizardObservationPaneProps {
onAddAnnotation: (
description: string,
category?: string,
tags?: string[],
) => Promise<void>;
onFlagIntervention?: () => Promise<void> | void;
isSubmitting?: boolean;
readOnly?: boolean;
onAddAnnotation: (
description: string,
category?: string,
tags?: string[],
) => Promise<void>;
onFlagIntervention?: () => Promise<void> | void;
isSubmitting?: boolean;
readOnly?: boolean;
}
export function WizardObservationPane({
onAddAnnotation,
onFlagIntervention,
isSubmitting = false,
trialEvents = [],
readOnly = false,
onAddAnnotation,
onFlagIntervention,
isSubmitting = false,
trialEvents = [],
readOnly = false,
}: WizardObservationPaneProps & { trialEvents?: TrialEvent[] }) {
const [note, setNote] = useState("");
const [category, setCategory] = useState("observation");
const [tags, setTags] = useState<string[]>([]);
const [currentTag, setCurrentTag] = useState("");
const [note, setNote] = useState("");
const [category, setCategory] = useState("observation");
const [tags, setTags] = useState<string[]>([]);
const [currentTag, setCurrentTag] = useState("");
const placeholders: Record<string, string> = {
observation: "Type your observation here...",
participant_behavior: "Describe the participant's behavior...",
system_issue: "Describe the system issue...",
success: "Describe the success...",
failure: "Describe the failure...",
};
const placeholders: Record<string, string> = {
observation: "Type your observation here...",
participant_behavior: "Describe the participant's behavior...",
system_issue: "Describe the system issue...",
success: "Describe the success...",
failure: "Describe the failure...",
};
const handleSubmit = async () => {
if (!note.trim()) return;
const handleSubmit = async () => {
if (!note.trim()) return;
await onAddAnnotation(note, category, tags);
setNote("");
setTags([]);
setCurrentTag("");
};
await onAddAnnotation(note, category, tags);
setNote("");
setTags([]);
setCurrentTag("");
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
handleSubmit();
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
handleSubmit();
}
};
const addTag = () => {
const trimmed = currentTag.trim();
if (trimmed && !tags.includes(trimmed)) {
setTags([...tags, trimmed]);
setCurrentTag("");
}
};
const addTag = () => {
const trimmed = currentTag.trim();
if (trimmed && !tags.includes(trimmed)) {
setTags([...tags, trimmed]);
setCurrentTag("");
}
};
return (
<div className="flex h-full flex-col bg-background">
<div className="flex-1 flex flex-col p-4 m-0 overflow-hidden">
<div className="flex flex-1 flex-col gap-2">
<Textarea
placeholder={readOnly ? "Session is read-only" : (placeholders[category] || "Type your observation here...")}
className="flex-1 resize-none font-mono text-sm"
value={note}
onChange={(e) => setNote(e.target.value)}
onKeyDown={handleKeyDown}
disabled={readOnly}
/>
return (
<div className="bg-background flex h-full flex-col">
<div className="m-0 flex flex-1 flex-col overflow-hidden p-4">
<div className="flex flex-1 flex-col gap-2">
<Textarea
placeholder={
readOnly
? "Session is read-only"
: placeholders[category] || "Type your observation here..."
}
className="flex-1 resize-none font-mono text-sm"
value={note}
onChange={(e) => setNote(e.target.value)}
onKeyDown={handleKeyDown}
disabled={readOnly}
/>
<div className="flex flex-col gap-2 shrink-0">
{/* Top Line: Category & Tags */}
<div className="flex items-center gap-2 w-full">
<Select value={category} onValueChange={setCategory} disabled={readOnly}>
<SelectTrigger className="w-[140px] h-8 text-xs shrink-0">
<SelectValue placeholder="Category" />
</SelectTrigger>
<SelectContent>
<SelectItem value="observation">Observation</SelectItem>
<SelectItem value="participant_behavior">Behavior</SelectItem>
<SelectItem value="system_issue">System Issue</SelectItem>
<SelectItem value="success">Success</SelectItem>
<SelectItem value="failure">Failure</SelectItem>
</SelectContent>
</Select>
<div className="flex shrink-0 flex-col gap-2">
{/* Top Line: Category & Tags */}
<div className="flex w-full items-center gap-2">
<Select
value={category}
onValueChange={setCategory}
disabled={readOnly}
>
<SelectTrigger className="h-8 w-[140px] shrink-0 text-xs">
<SelectValue placeholder="Category" />
</SelectTrigger>
<SelectContent>
<SelectItem value="observation">Observation</SelectItem>
<SelectItem value="participant_behavior">Behavior</SelectItem>
<SelectItem value="system_issue">System Issue</SelectItem>
<SelectItem value="success">Success</SelectItem>
<SelectItem value="failure">Failure</SelectItem>
</SelectContent>
</Select>
<div className="flex flex-1 min-w-[80px] items-center gap-2 rounded-md border px-2 h-8">
<Tag className={`h-3 w-3 shrink-0 ${readOnly ? "text-muted-foreground/50" : "text-muted-foreground"}`} />
<input
type="text"
placeholder={readOnly ? "" : "Add tags..."}
className="flex-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed w-full min-w-0"
value={currentTag}
onChange={(e) => setCurrentTag(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addTag();
}
}}
onBlur={addTag}
disabled={readOnly}
/>
</div>
</div>
{/* Bottom Line: Actions */}
<div className="flex items-center justify-end gap-2 w-full">
{onFlagIntervention && (
<Button
size="sm"
variant="outline"
onClick={() => onFlagIntervention()}
disabled={readOnly}
className="h-8 shrink-0 flex-1 sm:flex-none border-yellow-200 bg-yellow-50 text-yellow-700 hover:bg-yellow-100 hover:text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-300 dark:border-yellow-700/50 dark:hover:bg-yellow-900/40"
>
<AlertTriangle className="mr-2 h-3 w-3" />
Intervention
</Button>
)}
<Button
size="sm"
onClick={handleSubmit}
disabled={isSubmitting || !note.trim() || readOnly}
className="h-8 shrink-0 flex-1 sm:flex-none"
>
<Send className="mr-2 h-3 w-3" />
Save Note
</Button>
</div>
</div>
{tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{tags.map((tag) => (
<Badge
key={tag}
variant="secondary"
className="px-1 py-0 text-[10px] cursor-pointer hover:bg-destructive/10 hover:text-destructive"
onClick={() => setTags(tags.filter((t) => t !== tag))}
>
#{tag}
</Badge>
))}
</div>
)}
</div>
<div className="flex h-8 min-w-[80px] flex-1 items-center gap-2 rounded-md border px-2">
<Tag
className={`h-3 w-3 shrink-0 ${readOnly ? "text-muted-foreground/50" : "text-muted-foreground"}`}
/>
<input
type="text"
placeholder={readOnly ? "" : "Add tags..."}
className="placeholder:text-muted-foreground w-full min-w-0 flex-1 bg-transparent text-xs outline-none disabled:cursor-not-allowed"
value={currentTag}
onChange={(e) => setCurrentTag(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addTag();
}
}}
onBlur={addTag}
disabled={readOnly}
/>
</div>
</div>
{/* Bottom Line: Actions */}
<div className="flex w-full items-center justify-end gap-2">
{onFlagIntervention && (
<Button
size="sm"
variant="outline"
onClick={() => onFlagIntervention()}
disabled={readOnly}
className="h-8 flex-1 shrink-0 border-yellow-200 bg-yellow-50 text-yellow-700 hover:bg-yellow-100 hover:text-yellow-800 sm:flex-none dark:border-yellow-700/50 dark:bg-yellow-900/20 dark:text-yellow-300 dark:hover:bg-yellow-900/40"
>
<AlertTriangle className="mr-2 h-3 w-3" />
Intervention
</Button>
)}
<Button
size="sm"
onClick={handleSubmit}
disabled={isSubmitting || !note.trim() || readOnly}
className="h-8 flex-1 shrink-0 sm:flex-none"
>
<Send className="mr-2 h-3 w-3" />
Save Note
</Button>
</div>
</div>
{tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{tags.map((tag) => (
<Badge
key={tag}
variant="secondary"
className="hover:bg-destructive/10 hover:text-destructive cursor-pointer px-1 py-0 text-[10px]"
onClick={() => setTags(tags.filter((t) => t !== tag))}
>
#{tag}
</Badge>
))}
</div>
)}
</div>
);
</div>
</div>
);
}

View File

@@ -1,15 +1,15 @@
"use client"
"use client";
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDownIcon } from "lucide-react"
import * as React from "react";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { ChevronDownIcon } from "lucide-react";
import { cn } from "~/lib/utils"
import { cn } from "~/lib/utils";
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
}
function AccordionItem({
@@ -22,7 +22,7 @@ function AccordionItem({
className={cn("border-b last:border-b-0", className)}
{...props}
/>
)
);
}
function AccordionTrigger({
@@ -36,7 +36,7 @@ function AccordionTrigger({
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className
className,
)}
{...props}
>
@@ -44,7 +44,7 @@ function AccordionTrigger({
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
);
}
function AccordionContent({
@@ -60,7 +60,7 @@ function AccordionContent({
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
)
);
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };

View File

@@ -1,15 +1,15 @@
"use client"
"use client";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import * as React from "react";
import { buttonVariants } from "~/components/ui/button"
import { cn } from "~/lib/utils"
import { buttonVariants } from "~/components/ui/button";
import { cn } from "~/lib/utils";
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
}
function AlertDialogTrigger({
@@ -17,7 +17,7 @@ function AlertDialogTrigger({
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
);
}
function AlertDialogPortal({
@@ -25,7 +25,7 @@ function AlertDialogPortal({
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
);
}
function AlertDialogOverlay({
@@ -37,11 +37,11 @@ function AlertDialogOverlay({
data-slot="alert-dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
className,
)}
{...props}
/>
)
);
}
function AlertDialogContent({
@@ -55,12 +55,12 @@ function AlertDialogContent({
data-slot="alert-dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
className,
)}
{...props}
/>
</AlertDialogPortal>
)
);
}
function AlertDialogHeader({
@@ -73,7 +73,7 @@ function AlertDialogHeader({
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
);
}
function AlertDialogFooter({
@@ -85,11 +85,11 @@ function AlertDialogFooter({
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
className,
)}
{...props}
/>
)
);
}
function AlertDialogTitle({
@@ -102,7 +102,7 @@ function AlertDialogTitle({
className={cn("text-lg font-semibold", className)}
{...props}
/>
)
);
}
function AlertDialogDescription({
@@ -115,7 +115,7 @@ function AlertDialogDescription({
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
);
}
function AlertDialogAction({
@@ -127,7 +127,7 @@ function AlertDialogAction({
className={cn(buttonVariants(), className)}
{...props}
/>
)
);
}
function AlertDialogCancel({
@@ -139,19 +139,19 @@ function AlertDialogCancel({
className={cn(buttonVariants({ variant: "outline" }), className)}
{...props}
/>
)
);
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};

View File

@@ -1,7 +1,7 @@
import { cva, type VariantProps } from "class-variance-authority"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from "~/lib/utils"
import { cn } from "~/lib/utils";
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
@@ -16,8 +16,8 @@ const alertVariants = cva(
defaultVariants: {
variant: "default",
},
}
)
},
);
function Alert({
className,
@@ -31,7 +31,7 @@ function Alert({
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
);
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
@@ -40,11 +40,11 @@ function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
className,
)}
{...props}
/>
)
);
}
function AlertDescription({
@@ -56,11 +56,11 @@ function AlertDescription({
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
className,
)}
{...props}
/>
)
);
}
export { Alert, AlertTitle, AlertDescription }
export { Alert, AlertTitle, AlertDescription };

View File

@@ -1,11 +1,11 @@
"use client"
"use client";
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
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} />
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />;
}
export { AspectRatio }
export { AspectRatio };

View File

@@ -1,9 +1,9 @@
"use client"
"use client";
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import * as React from "react";
import { cn } from "~/lib/utils"
import { cn } from "~/lib/utils";
function Avatar({
className,
@@ -14,11 +14,11 @@ function Avatar({
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className
className,
)}
{...props}
/>
)
);
}
function AvatarImage({
@@ -31,7 +31,7 @@ function AvatarImage({
className={cn("aspect-square size-full", className)}
{...props}
/>
)
);
}
function AvatarFallback({
@@ -43,11 +43,11 @@ function AvatarFallback({
data-slot="avatar-fallback"
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className
className,
)}
{...props}
/>
)
);
}
export { Avatar, AvatarImage, AvatarFallback }
export { Avatar, AvatarImage, AvatarFallback };

View File

@@ -1,8 +1,8 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "~/lib/utils"
import { cn } from "~/lib/utils";
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
@@ -22,8 +22,8 @@ const badgeVariants = cva(
defaultVariants: {
variant: "default",
},
}
)
},
);
function Badge({
className,
@@ -32,7 +32,7 @@ function Badge({
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
const Comp = asChild ? Slot : "span";
return (
<Comp
@@ -40,7 +40,7 @@ function Badge({
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
);
}
export { Badge, badgeVariants }
export { Badge, badgeVariants };

View File

@@ -1,16 +1,16 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { ChevronRight, MoreHorizontal } from "lucide-react";
import { cn } from "~/lib/utils"
import { cn } from "~/lib/utils";
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<"nav"> & {
separator?: React.ComponentType<{ className?: string }>
separator?: React.ComponentType<{ className?: string }>;
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />)
Breadcrumb.displayName = "Breadcrumb"
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />);
Breadcrumb.displayName = "Breadcrumb";
const BreadcrumbList = React.forwardRef<
HTMLOListElement,
@@ -19,13 +19,13 @@ const BreadcrumbList = React.forwardRef<
<ol
ref={ref}
className={cn(
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
className
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
className,
)}
{...props}
/>
))
BreadcrumbList.displayName = "BreadcrumbList"
));
BreadcrumbList.displayName = "BreadcrumbList";
const BreadcrumbItem = React.forwardRef<
HTMLLIElement,
@@ -36,26 +36,26 @@ const BreadcrumbItem = React.forwardRef<
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
))
BreadcrumbItem.displayName = "BreadcrumbItem"
));
BreadcrumbItem.displayName = "BreadcrumbItem";
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<"a"> & {
asChild?: boolean
asChild?: boolean;
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a"
const Comp = asChild ? Slot : "a";
return (
<Comp
ref={ref}
className={cn("transition-colors hover:text-foreground", className)}
className={cn("hover:text-foreground transition-colors", className)}
{...props}
/>
)
})
BreadcrumbLink.displayName = "BreadcrumbLink"
);
});
BreadcrumbLink.displayName = "BreadcrumbLink";
const BreadcrumbPage = React.forwardRef<
HTMLSpanElement,
@@ -66,11 +66,11 @@ const BreadcrumbPage = React.forwardRef<
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
className={cn("text-foreground font-normal", className)}
{...props}
/>
))
BreadcrumbPage.displayName = "BreadcrumbPage"
));
BreadcrumbPage.displayName = "BreadcrumbPage";
const BreadcrumbSeparator = ({
children,
@@ -85,8 +85,8 @@ const BreadcrumbSeparator = ({
>
{children ?? <ChevronRight />}
</li>
)
BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
);
BreadcrumbSeparator.displayName = "BreadcrumbSeparator";
const BreadcrumbEllipsis = ({
className,
@@ -101,8 +101,8 @@ const BreadcrumbEllipsis = ({
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
)
BreadcrumbEllipsis.displayName = "BreadcrumbEllipsis"
);
BreadcrumbEllipsis.displayName = "BreadcrumbEllipsis";
export {
Breadcrumb,
@@ -112,4 +112,4 @@ export {
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}
};

View File

@@ -1,8 +1,8 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "~/lib/utils"
import { cn } from "~/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
@@ -32,8 +32,8 @@ const buttonVariants = cva(
variant: "default",
size: "default",
},
}
)
},
);
function Button({
className,
@@ -43,9 +43,9 @@ function Button({
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "button"
const Comp = asChild ? Slot : "button";
return (
<Comp
@@ -53,7 +53,7 @@ function Button({
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
);
}
export { Button, buttonVariants }
export { Button, buttonVariants };

View File

@@ -1,19 +1,19 @@
"use client"
"use client";
import * as React from "react"
import * as React from "react";
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react"
} from "lucide-react";
import {
DayPicker,
getDefaultClassNames,
type DayButton,
} from "react-day-picker"
} from "react-day-picker";
import { cn } from "~/lib/utils"
import { Button, buttonVariants } from "~/components/ui/button"
import { cn } from "~/lib/utils";
import { Button, buttonVariants } from "~/components/ui/button";
function Calendar({
className,
@@ -25,9 +25,9 @@ function Calendar({
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
buttonVariant?: React.ComponentProps<typeof Button>["variant"];
}) {
const defaultClassNames = getDefaultClassNames()
const defaultClassNames = getDefaultClassNames();
return (
<DayPicker
@@ -36,7 +36,7 @@ function Calendar({
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
className,
)}
captionLayout={captionLayout}
formatters={{
@@ -48,85 +48,85 @@ function Calendar({
root: cn("w-fit", defaultClassNames.root),
months: cn(
"flex gap-4 flex-col md:flex-row relative",
defaultClassNames.months
defaultClassNames.months,
),
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
nav: cn(
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
defaultClassNames.nav
defaultClassNames.nav,
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_previous
defaultClassNames.button_previous,
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_next
defaultClassNames.button_next,
),
month_caption: cn(
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
defaultClassNames.month_caption
defaultClassNames.month_caption,
),
dropdowns: cn(
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
defaultClassNames.dropdowns
defaultClassNames.dropdowns,
),
dropdown_root: cn(
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
defaultClassNames.dropdown_root
defaultClassNames.dropdown_root,
),
dropdown: cn(
"absolute bg-popover inset-0 opacity-0",
defaultClassNames.dropdown
defaultClassNames.dropdown,
),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
defaultClassNames.caption_label
defaultClassNames.caption_label,
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
defaultClassNames.weekday
defaultClassNames.weekday,
),
week: cn("flex w-full mt-2", defaultClassNames.week),
week_number_header: cn(
"select-none w-(--cell-size)",
defaultClassNames.week_number_header
defaultClassNames.week_number_header,
),
week_number: cn(
"text-[0.8rem] select-none text-muted-foreground",
defaultClassNames.week_number
defaultClassNames.week_number,
),
day: cn(
"relative w-full h-full p-0 text-center [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
props.showWeekNumber
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md"
: "[&:first-child[data-selected=true]_button]:rounded-l-md",
defaultClassNames.day
defaultClassNames.day,
),
range_start: cn(
"rounded-l-md bg-accent",
defaultClassNames.range_start
defaultClassNames.range_start,
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today
defaultClassNames.today,
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
defaultClassNames.outside,
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
defaultClassNames.disabled,
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
@@ -140,13 +140,13 @@ function Calendar({
className={cn(className)}
{...props}
/>
)
);
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
);
}
if (orientation === "right") {
@@ -155,12 +155,12 @@ function Calendar({
className={cn("size-4", className)}
{...props}
/>
)
);
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
);
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
@@ -170,13 +170,13 @@ function Calendar({
{children}
</div>
</td>
)
);
},
...components,
}}
{...props}
/>
)
);
}
function CalendarDayButton({
@@ -185,12 +185,12 @@ function CalendarDayButton({
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames()
const defaultClassNames = getDefaultClassNames();
const ref = React.useRef<HTMLButtonElement>(null)
const ref = React.useRef<HTMLButtonElement>(null);
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
if (modifiers.focused) ref.current?.focus();
}, [modifiers.focused]);
return (
<Button
@@ -210,11 +210,11 @@ function CalendarDayButton({
className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
className,
)}
{...props}
/>
)
);
}
export { Calendar, CalendarDayButton }
export { Calendar, CalendarDayButton };

View File

@@ -1,6 +1,6 @@
import * as React from "react"
import * as React from "react";
import { cn } from "~/lib/utils"
import { cn } from "~/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
@@ -8,11 +8,11 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
className,
)}
{...props}
/>
)
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
@@ -21,11 +21,11 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
className,
)}
{...props}
/>
)
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
@@ -35,7 +35,7 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
@@ -45,7 +45,7 @@ function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
);
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
@@ -54,11 +54,11 @@ function CardAction({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
className,
)}
{...props}
/>
)
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
@@ -68,7 +68,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
className={cn("px-6", className)}
{...props}
/>
)
);
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
@@ -78,15 +78,15 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
};

View File

@@ -1,10 +1,10 @@
"use client"
"use client";
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { CheckIcon } from "lucide-react";
import { cn } from "~/lib/utils"
import { cn } from "~/lib/utils";
function Checkbox({
className,
@@ -15,7 +15,7 @@ function Checkbox({
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
className,
)}
{...props}
>
@@ -26,7 +26,7 @@ function Checkbox({
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
);
}
export { Checkbox }
export { Checkbox };

View File

@@ -1,11 +1,11 @@
"use client"
"use client";
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
const Collapsible = CollapsiblePrimitive.Root
const Collapsible = CollapsiblePrimitive.Root;
const CollapsibleTrigger = CollapsiblePrimitive.Trigger
const CollapsibleTrigger = CollapsiblePrimitive.Trigger;
const CollapsibleContent = CollapsiblePrimitive.Content
const CollapsibleContent = CollapsiblePrimitive.Content;
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
export { Collapsible, CollapsibleTrigger, CollapsibleContent };

View File

@@ -13,8 +13,10 @@ import {
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
interface DataTableColumnHeaderProps<TData, TValue>
extends React.HTMLAttributes<HTMLDivElement> {
interface DataTableColumnHeaderProps<
TData,
TValue,
> extends React.HTMLAttributes<HTMLDivElement> {
column: Column<TData, TValue>;
title: string;
}
@@ -35,7 +37,7 @@ export function DataTableColumnHeader<TData, TValue>({
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8 data-[state=open]:bg-accent"
className="data-[state=open]:bg-accent -ml-3 h-8"
>
<span>{title}</span>
{column.getIsSorted() === "desc" ? (
@@ -49,16 +51,16 @@ export function DataTableColumnHeader<TData, TValue>({
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
<ArrowUp className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
<ArrowUp className="text-muted-foreground/70 mr-2 h-3.5 w-3.5" />
Asc
</DropdownMenuItem>
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
<ArrowDown className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
<ArrowDown className="text-muted-foreground/70 mr-2 h-3.5 w-3.5" />
Desc
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => column.toggleVisibility(false)}>
<EyeOff className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
<EyeOff className="text-muted-foreground/70 mr-2 h-3.5 w-3.5" />
Hide
</DropdownMenuItem>
</DropdownMenuContent>

View File

@@ -26,7 +26,7 @@ export function DataTablePagination<TData>({
}: DataTablePaginationProps<TData>) {
return (
<div className="flex items-center justify-between px-2">
<div className="flex-1 text-sm text-muted-foreground">
<div className="text-muted-foreground flex-1 text-sm">
{table.getFilteredSelectedRowModel().rows.length} of{" "}
{table.getFilteredRowModel().rows.length} row(s) selected.
</div>

View File

@@ -39,7 +39,7 @@ export function DataTableViewOptions<TData>({
.getAllColumns()
.filter(
(column) =>
typeof column.accessorFn !== "undefined" && column.getCanHide()
typeof column.accessorFn !== "undefined" && column.getCanHide(),
)
.map((column) => {
return (

View File

@@ -211,7 +211,7 @@ export function DataTable<TData, TValue>({
</DropdownMenu>
</div>
</div>
<div className="min-w-0 overflow-hidden rounded-md border shadow-sm bg-card">
<div className="bg-card min-w-0 overflow-hidden rounded-md border shadow-sm">
<div className="min-w-0 overflow-x-auto overflow-y-hidden">
<Table className="min-w-[600px]">
<TableHeader>

View File

@@ -1,16 +1,16 @@
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import * as React from "react";
import { cn } from "~/lib/utils"
import { cn } from "~/lib/utils";
const Dialog = DialogPrimitive.Root
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
@@ -19,13 +19,13 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
className,
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
@@ -36,20 +36,20 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
@@ -58,12 +58,12 @@ const DialogHeader = ({
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
className,
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({
className,
@@ -72,12 +72,12 @@ const DialogFooter = ({
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
className,
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
@@ -86,13 +86,13 @@ const DialogTitle = React.forwardRef<
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
"text-lg leading-none font-semibold tracking-tight",
className,
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
@@ -100,21 +100,21 @@ const DialogDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};

View File

@@ -1,15 +1,15 @@
"use client"
"use client";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import * as React from "react";
import { cn } from "~/lib/utils"
import { cn } from "~/lib/utils";
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({
@@ -17,7 +17,7 @@ function DropdownMenuPortal({
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
);
}
function DropdownMenuTrigger({
@@ -28,7 +28,7 @@ function DropdownMenuTrigger({
data-slot="dropdown-menu-trigger"
{...props}
/>
)
);
}
function DropdownMenuContent({
@@ -43,12 +43,12 @@ function DropdownMenuContent({
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
);
}
function DropdownMenuGroup({
@@ -56,7 +56,7 @@ function DropdownMenuGroup({
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
);
}
function DropdownMenuItem({
@@ -65,8 +65,8 @@ function DropdownMenuItem({
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<DropdownMenuPrimitive.Item
@@ -75,11 +75,11 @@ function DropdownMenuItem({
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
className,
)}
{...props}
/>
)
);
}
function DropdownMenuCheckboxItem({
@@ -93,7 +93,7 @@ function DropdownMenuCheckboxItem({
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
className,
)}
checked={checked}
{...props}
@@ -105,7 +105,7 @@ function DropdownMenuCheckboxItem({
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
);
}
function DropdownMenuRadioGroup({
@@ -116,7 +116,7 @@ function DropdownMenuRadioGroup({
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
);
}
function DropdownMenuRadioItem({
@@ -129,7 +129,7 @@ function DropdownMenuRadioItem({
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
className,
)}
{...props}
>
@@ -140,7 +140,7 @@ function DropdownMenuRadioItem({
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
);
}
function DropdownMenuLabel({
@@ -148,7 +148,7 @@ function DropdownMenuLabel({
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
@@ -156,11 +156,11 @@ function DropdownMenuLabel({
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
className,
)}
{...props}
/>
)
);
}
function DropdownMenuSeparator({
@@ -173,7 +173,7 @@ function DropdownMenuSeparator({
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
);
}
function DropdownMenuShortcut({
@@ -185,17 +185,17 @@ function DropdownMenuShortcut({
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
className,
)}
{...props}
/>
)
);
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
@@ -204,7 +204,7 @@ function DropdownMenuSubTrigger({
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
@@ -212,14 +212,14 @@ function DropdownMenuSubTrigger({
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
className
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
);
}
function DropdownMenuSubContent({
@@ -231,27 +231,27 @@ function DropdownMenuSubContent({
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
className,
)}
{...props}
/>
)
);
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
};

View File

@@ -1,6 +1,6 @@
"use client";
import { ArrowLeft, type LucideIcon } from "lucide-react";
import { ArrowLeft, Lightbulb, type LucideIcon } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { type ReactNode } from "react";
@@ -126,17 +126,21 @@ export function EntityForm<T extends FieldValues = FieldValues>({
{/* Form Layout */}
<div
className={cn(
"grid gap-8 w-full",
"grid w-full gap-8",
// If sidebar exists, use 2-column layout. If not, use full width.
sidebar && layout === "default"
? "grid-cols-1 lg:grid-cols-3"
: layout === "full-width"
? "grid-cols-1 w-full"
: "grid-cols-1 max-w-7xl mx-auto",
? "w-full grid-cols-1"
: "mx-auto max-w-7xl grid-cols-1",
)}
>
{/* Main Form */}
<div className={sidebar && layout === "default" ? "lg:col-span-2" : "col-span-1"}>
<div
className={
sidebar && layout === "default" ? "lg:col-span-2" : "col-span-1"
}
>
<Card>
<CardHeader>
<CardTitle>
@@ -329,7 +333,7 @@ interface TipsProps {
export function Tips({ tips }: TipsProps) {
return (
<SidebarCard title="💡 Tips">
<SidebarCard title="Tips" icon={Lightbulb}>
<div className="text-muted-foreground space-y-3 text-sm">
{tips.map((tip, index) => (
<p key={index}>{tip}</p>

View File

@@ -46,7 +46,6 @@ interface EntityViewSidebarProps {
children: ReactNode;
}
interface EntityViewProps {
children: ReactNode;
layout?: "default" | "full-width";
@@ -54,7 +53,6 @@ interface EntityViewProps {
// ... existing code ...
export function EntityViewHeader({
title,
subtitle,
@@ -125,11 +123,7 @@ export function EntityView({ children, layout = "default" }: EntityViewProps) {
// Simplification: Always take full width of the parent container provided by DashboardLayout
// The DashboardLayout already provides padding (p-4).
// We remove 'container mx-auto max-w-5xl' to stop it from shrinking.
return (
<div className="flex flex-col gap-6 w-full h-full">
{children}
</div>
);
return <div className="flex h-full w-full flex-col gap-6">{children}</div>;
}
// Utility component for empty states
@@ -171,12 +165,13 @@ interface InfoGridProps {
export function InfoGrid({ items, columns = 2 }: InfoGridProps) {
return (
<div
className={`grid gap-4 ${columns === 1
? "grid-cols-1"
: columns === 2
? "md:grid-cols-2"
: "md:grid-cols-2 lg:grid-cols-3"
}`}
className={`grid gap-4 ${
columns === 1
? "grid-cols-1"
: columns === 2
? "md:grid-cols-2"
: "md:grid-cols-2 lg:grid-cols-3"
}`}
>
{items.map((item, index) => (
<div

View File

@@ -5,10 +5,10 @@ import { Slot } from "@radix-ui/react-slot";
import * as React from "react";
import type { ControllerProps, FieldPath, FieldValues } from "react-hook-form";
import {
Controller,
FormProvider,
useFormContext,
useFormState
Controller,
FormProvider,
useFormContext,
useFormState,
} from "react-hook-form";
import { Label } from "~/components/ui/label";
@@ -155,12 +155,12 @@ function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
};

View File

@@ -1,6 +1,6 @@
import * as React from "react"
import * as React from "react";
import { cn } from "~/lib/utils"
import { cn } from "~/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
@@ -11,11 +11,11 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
className,
)}
{...props}
/>
)
);
}
export { Input }
export { Input };

View File

@@ -1,9 +1,9 @@
"use client"
"use client";
import * as LabelPrimitive from "@radix-ui/react-label"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label";
import * as React from "react";
import { cn } from "~/lib/utils"
import { cn } from "~/lib/utils";
function Label({
className,
@@ -14,11 +14,11 @@ function Label({
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
className,
)}
{...props}
/>
)
);
}
export { Label }
export { Label };

View File

@@ -32,8 +32,19 @@ export function Logo({
{showText && (
<div className="grid flex-1 text-left leading-none">
<div className="flex items-baseline gap-0">
<span className={cn(textSizes[iconSize], "font-extrabold tracking-tight")}>HRI</span>
<span className={cn(textSizes[iconSize], "font-normal tracking-tight")}>Studio</span>
<span
className={cn(
textSizes[iconSize],
"font-extrabold tracking-tight",
)}
>
HRI
</span>
<span
className={cn(textSizes[iconSize], "font-normal tracking-tight")}
>
Studio
</span>
</div>
</div>
)}

View File

@@ -1,31 +1,31 @@
"use client"
"use client";
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "~/lib/utils"
import { cn } from "~/lib/utils";
const Popover = PopoverPrimitive.Root
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 rounded-md border p-4 shadow-md outline-none",
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent }
export { Popover, PopoverTrigger, PopoverContent };

View File

@@ -1,10 +1,10 @@
"use client"
"use client";
import * as React from "react"
import { GripVerticalIcon } from "lucide-react"
import * as ResizablePrimitive from "react-resizable-panels"
import * as React from "react";
import { GripVerticalIcon } from "lucide-react";
import * as ResizablePrimitive from "react-resizable-panels";
import { cn } from "~/lib/utils"
import { cn } from "~/lib/utils";
function ResizablePanelGroup({
className,
@@ -15,17 +15,17 @@ function ResizablePanelGroup({
data-slot="resizable-panel-group"
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className
className,
)}
{...props}
/>
)
);
}
function ResizablePanel({
...props
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
}
function ResizableHandle({
@@ -33,14 +33,14 @@ function ResizableHandle({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean
withHandle?: boolean;
}) {
return (
<ResizablePrimitive.PanelResizeHandle
data-slot="resizable-handle"
className={cn(
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className
className,
)}
{...props}
>
@@ -50,7 +50,7 @@ function ResizableHandle({
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
)
);
}
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };

View File

@@ -1,9 +1,9 @@
"use client"
"use client";
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import * as React from "react";
import { cn } from "~/lib/utils"
import { cn } from "~/lib/utils";
function ScrollArea({
className,
@@ -25,7 +25,7 @@ function ScrollArea({
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
);
}
function ScrollBar({
@@ -43,7 +43,7 @@ function ScrollBar({
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
className,
)}
{...props}
>
@@ -52,7 +52,7 @@ function ScrollBar({
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
);
}
export { ScrollArea, ScrollBar }
export { ScrollArea, ScrollBar };

View File

@@ -1,14 +1,14 @@
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import * as React from "react";
import { cn } from "~/lib/utils"
import { cn } from "~/lib/utils";
const Select = SelectPrimitive.Root
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
@@ -17,8 +17,8 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
"border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-10 w-full items-center justify-between rounded-md border px-3 py-2 text-sm focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className,
)}
{...props}
>
@@ -27,8 +27,8 @@ const SelectTrigger = React.forwardRef<
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
@@ -38,14 +38,14 @@ const SelectScrollUpButton = React.forwardRef<
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
className,
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
@@ -55,15 +55,15 @@ const SelectScrollDownButton = React.forwardRef<
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
className,
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
));
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
@@ -73,10 +73,10 @@ const SelectContent = React.forwardRef<
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
className,
)}
position={position}
{...props}
@@ -86,7 +86,7 @@ const SelectContent = React.forwardRef<
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
)}
>
{children}
@@ -94,8 +94,8 @@ const SelectContent = React.forwardRef<
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
@@ -103,11 +103,11 @@ const SelectLabel = React.forwardRef<
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
className={cn("py-1.5 pr-2 pl-8 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
@@ -116,8 +116,8 @@ const SelectItem = React.forwardRef<
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
"focus:bg-accent focus:text-accent-foreground relative flex w-full cursor-default items-center rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
@@ -129,8 +129,8 @@ const SelectItem = React.forwardRef<
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
@@ -138,21 +138,21 @@ const SelectSeparator = React.forwardRef<
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
className={cn("bg-muted -mx-1 my-1 h-px", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};

View File

@@ -1,9 +1,9 @@
"use client"
"use client";
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "~/lib/utils"
import { cn } from "~/lib/utils";
function Separator({
className,
@@ -18,11 +18,11 @@ function Separator({
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
className,
)}
{...props}
/>
)
);
}
export { Separator }
export { Separator };

View File

@@ -1,31 +1,31 @@
"use client"
"use client";
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import * as React from "react";
import * as SheetPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react";
import { cn } from "~/lib/utils"
import { cn } from "~/lib/utils";
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
}
function SheetOverlay({
@@ -37,11 +37,11 @@ function SheetOverlay({
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
className,
)}
{...props}
/>
)
);
}
function SheetContent({
@@ -50,7 +50,7 @@ function SheetContent({
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
side?: "top" | "right" | "bottom" | "left";
}) {
return (
<SheetPortal>
@@ -67,7 +67,7 @@ function SheetContent({
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className
className,
)}
{...props}
>
@@ -78,7 +78,7 @@ function SheetContent({
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
)
);
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
@@ -88,7 +88,7 @@ function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
);
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
@@ -98,7 +98,7 @@ function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
);
}
function SheetTitle({
@@ -111,7 +111,7 @@ function SheetTitle({
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
);
}
function SheetDescription({
@@ -124,7 +124,7 @@ function SheetDescription({
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
);
}
export {
@@ -136,4 +136,4 @@ export {
SheetFooter,
SheetTitle,
SheetDescription,
}
};

View File

@@ -569,7 +569,7 @@ function SidebarMenuAction({
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className,
)}
{...props}

View File

@@ -1,4 +1,4 @@
import { cn } from "~/lib/utils"
import { cn } from "~/lib/utils";
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
@@ -7,7 +7,7 @@ function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
)
);
}
export { Skeleton }
export { Skeleton };

View File

@@ -1,9 +1,9 @@
"use client"
"use client";
import * as SwitchPrimitives from "@radix-ui/react-switch"
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch";
import * as React from "react";
import { cn } from "~/lib/utils"
import { cn } from "~/lib/utils";
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
@@ -11,19 +11,19 @@ const Switch = React.forwardRef<
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
"peer focus-visible:ring-ring focus-visible:ring-offset-background data-[state=checked]:bg-primary data-[state=unchecked]:bg-input inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
"bg-background pointer-events-none block h-5 w-5 rounded-full shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0",
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch }
export { Switch };

View File

@@ -18,7 +18,7 @@ function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b bg-secondary/30", className)}
className={cn("bg-secondary/30 [&_tr]:border-b", className)}
{...props}
/>
);

View File

@@ -1,9 +1,9 @@
"use client"
"use client";
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "~/lib/utils"
import { cn } from "~/lib/utils";
function Tabs({
className,
@@ -15,7 +15,7 @@ function Tabs({
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
);
}
function TabsList({
@@ -27,11 +27,11 @@ function TabsList({
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
className,
)}
{...props}
/>
)
);
}
function TabsTrigger({
@@ -43,11 +43,11 @@ function TabsTrigger({
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
className,
)}
{...props}
/>
)
);
}
function TabsContent({
@@ -60,7 +60,7 @@ function TabsContent({
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
);
}
export { Tabs, TabsList, TabsTrigger, TabsContent }
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@@ -1,6 +1,6 @@
import * as React from "react"
import * as React from "react";
import { cn } from "~/lib/utils"
import { cn } from "~/lib/utils";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
@@ -8,11 +8,11 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
className,
)}
{...props}
/>
)
);
}
export { Textarea }
export { Textarea };

View File

@@ -1,9 +1,9 @@
"use client"
"use client";
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "~/lib/utils"
import { cn } from "~/lib/utils";
function TooltipProvider({
delayDuration = 0,
@@ -15,7 +15,7 @@ function TooltipProvider({
delayDuration={delayDuration}
{...props}
/>
)
);
}
function Tooltip({
@@ -25,13 +25,13 @@ function Tooltip({
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
);
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
@@ -47,7 +47,7 @@ function TooltipContent({
sideOffset={sideOffset}
className={cn(
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
className,
)}
{...props}
>
@@ -55,7 +55,7 @@ function TooltipContent({
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };