feat: Enhance plugin store and experiment design infrastructure

- Add plugin store system with dynamic loading of robot actions
- Implement plugin store API routes and database schema
- Update experiment designer to support plugin-based actions
- Refactor environment configuration and sidebar navigation
- Improve authentication session handling with additional user details
- Update Tailwind CSS configuration and global styles
- Remove deprecated files and consolidate project structure
This commit is contained in:
2025-02-28 11:10:56 -05:00
parent 88c305de61
commit ab08c1b724
75 changed files with 7641 additions and 3382 deletions

View File

@@ -25,17 +25,17 @@ import { cn } from "~/lib/utils"
export function StudySwitcher() {
const { status } = useSession()
// Show nothing while loading to prevent flash
if (status === "loading") {
return null
}
return <StudySwitcherContent />
}
function StudySwitcherContent() {
const { isMobile } = useSidebar()
const { isMobile, state } = useSidebar()
const router = useRouter()
const { studies, activeStudy, setActiveStudy, isLoading } = useStudy()
@@ -43,6 +43,8 @@ function StudySwitcherContent() {
router.push("/dashboard/studies/new")
}
const isCollapsed = state === "collapsed"
if (isLoading) {
return (
<SidebarMenu>
@@ -54,10 +56,12 @@ function StudySwitcherContent() {
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-accent/10">
<Notebook className="size-4 text-muted-foreground/50" />
</div>
<div className="grid flex-1 gap-1">
<div className="h-4 w-24 rounded bg-sidebar-accent/10" />
<div className="h-3 w-16 rounded bg-sidebar-accent/10" />
</div>
{!isCollapsed && (
<div className="grid flex-1 gap-1">
<div className="h-4 w-24 rounded bg-sidebar-accent/10" />
<div className="h-3 w-16 rounded bg-sidebar-accent/10" />
</div>
)}
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
@@ -76,10 +80,12 @@ function StudySwitcherContent() {
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
<Plus className="size-4" />
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">Create Study</span>
<span className="truncate text-xs">Get started</span>
</div>
{!isCollapsed && (
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">Create Study</span>
<span className="truncate text-xs">Get started</span>
</div>
)}
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
@@ -93,22 +99,29 @@ function StudySwitcherContent() {
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
className={cn(
"data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground",
isCollapsed && "justify-center p-0"
)}
>
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
<Notebook className="size-4" />
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">
{activeStudy?.title ?? "Select Study"}
</span>
<span className="truncate text-xs">{activeStudy?.role ?? ""}</span>
</div>
<ChevronsUpDown className="ml-auto size-4" />
{!isCollapsed && (
<>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">
{activeStudy?.title ?? "Select Study"}
</span>
<span className="truncate text-xs">{activeStudy?.role ?? ""}</span>
</div>
<ChevronsUpDown className="ml-auto size-4" />
</>
)}
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
className="min-w-56 rounded-lg"
align="start"
side={isMobile ? "bottom" : "right"}
sideOffset={4}

View File

@@ -30,62 +30,15 @@ import {
SelectValue,
} from "~/components/ui/select";
import { Switch } from "~/components/ui/switch";
import { AVAILABLE_ACTIONS } from "~/lib/experiments/actions";
import { type ActionType } from "~/lib/experiments/types";
// Define parameter schemas for each action type
const parameterSchemas = {
move: z.object({
position: z.object({
x: z.number(),
y: z.number(),
z: z.number(),
}),
speed: z.number().min(0).max(1),
easing: z.enum(["linear", "easeIn", "easeOut", "easeInOut"]),
}),
speak: z.object({
text: z.string().min(1),
speed: z.number().min(0.5).max(2),
pitch: z.number().min(0.5).max(2),
volume: z.number().min(0).max(1),
}),
wait: z.object({
duration: z.number().min(0),
showCountdown: z.boolean(),
}),
input: z.object({
type: z.enum(["button", "text", "number", "choice"]),
prompt: z.string().optional(),
options: z.array(z.string()).optional(),
timeout: z.number().nullable(),
}),
gesture: z.object({
name: z.string().min(1),
speed: z.number().min(0).max(1),
intensity: z.number().min(0).max(1),
}),
record: z.object({
type: z.enum(["start", "stop"]),
streams: z.array(z.enum(["video", "audio", "sensors"])),
}),
condition: z.object({
condition: z.string().min(1),
trueActions: z.array(z.any()),
falseActions: z.array(z.any()).optional(),
}),
loop: z.object({
count: z.number().min(1),
actions: z.array(z.any()),
}),
} satisfies Record<ActionType, z.ZodType<any>>;
import { type ActionConfig } from "~/lib/experiments/plugin-actions";
interface ActionConfigDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
type: ActionType;
type: string;
parameters: Record<string, any>;
onSubmit: (parameters: Record<string, any>) => void;
actionConfig: ActionConfig;
}
export function ActionConfigDialog({
@@ -94,11 +47,41 @@ export function ActionConfigDialog({
type,
parameters,
onSubmit,
actionConfig,
}: ActionConfigDialogProps) {
const actionConfig = AVAILABLE_ACTIONS.find((a) => a.type === type);
if (!actionConfig) return null;
// Create a dynamic schema based on the action's parameters
const createDynamicSchema = () => {
if (!actionConfig) return z.object({});
const schema = parameterSchemas[type];
const schemaFields: Record<string, z.ZodType<any>> = {};
for (const [key, prop] of Object.entries(actionConfig.defaultParameters)) {
switch (typeof prop) {
case "string":
schemaFields[key] = z.string();
break;
case "number":
schemaFields[key] = z.number();
break;
case "boolean":
schemaFields[key] = z.boolean();
break;
case "object":
if (Array.isArray(prop)) {
schemaFields[key] = z.array(z.any());
} else {
schemaFields[key] = z.record(z.any());
}
break;
default:
schemaFields[key] = z.any();
}
}
return z.object(schemaFields);
};
const schema = createDynamicSchema();
const form = useForm({
resolver: zodResolver(schema),
defaultValues: parameters,
@@ -109,6 +92,104 @@ export function ActionConfigDialog({
onOpenChange(false);
}
function renderField(key: string, value: any) {
const fieldType = typeof value;
switch (fieldType) {
case "string":
if (value.length > 50) {
return (
<FormField
key={key}
control={form.control}
name={key}
render={({ field }) => (
<FormItem>
<FormLabel>{key}</FormLabel>
<FormControl>
<Textarea {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
}
return (
<FormField
key={key}
control={form.control}
name={key}
render={({ field }) => (
<FormItem>
<FormLabel>{key}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
case "number":
return (
<FormField
key={key}
control={form.control}
name={key}
render={({ field }) => (
<FormItem>
<FormLabel>{key}</FormLabel>
<FormControl>
<Input
type="number"
{...field}
onChange={(e) => field.onChange(parseFloat(e.target.value))}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
case "boolean":
return (
<FormField
key={key}
control={form.control}
name={key}
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-2">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel>{key}</FormLabel>
</div>
<FormMessage />
</FormItem>
)}
/>
);
case "object":
if (Array.isArray(value)) {
// TODO: Add array field handling
return null;
}
// TODO: Add object field handling
return null;
default:
return null;
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
@@ -119,280 +200,10 @@ export function ActionConfigDialog({
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
{type === "move" && (
<>
<div className="grid gap-4 sm:grid-cols-3">
<FormField
control={form.control}
name="position.x"
render={({ field }) => (
<FormItem>
<FormLabel>X Position</FormLabel>
<FormControl>
<Input
type="number"
step="0.1"
{...field}
onChange={(e) =>
field.onChange(parseFloat(e.target.value))
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="position.y"
render={({ field }) => (
<FormItem>
<FormLabel>Y Position</FormLabel>
<FormControl>
<Input
type="number"
step="0.1"
{...field}
onChange={(e) =>
field.onChange(parseFloat(e.target.value))
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="position.z"
render={({ field }) => (
<FormItem>
<FormLabel>Z Position</FormLabel>
<FormControl>
<Input
type="number"
step="0.1"
{...field}
onChange={(e) =>
field.onChange(parseFloat(e.target.value))
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="speed"
render={({ field }) => (
<FormItem>
<FormLabel>Speed</FormLabel>
<FormControl>
<Input
type="number"
step="0.1"
min="0"
max="1"
{...field}
onChange={(e) =>
field.onChange(parseFloat(e.target.value))
}
/>
</FormControl>
<FormDescription>
Movement speed (0-1)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="easing"
render={({ field }) => (
<FormItem>
<FormLabel>Easing</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select easing type" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="linear">Linear</SelectItem>
<SelectItem value="easeIn">Ease In</SelectItem>
<SelectItem value="easeOut">Ease Out</SelectItem>
<SelectItem value="easeInOut">Ease In Out</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Movement easing function
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
{Object.entries(actionConfig.defaultParameters).map(([key, value]) =>
renderField(key, value)
)}
{type === "speak" && (
<>
<FormField
control={form.control}
name="text"
render={({ field }) => (
<FormItem>
<FormLabel>Text</FormLabel>
<FormControl>
<Textarea
placeholder="Enter text to speak"
className="resize-none"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid gap-4 sm:grid-cols-3">
<FormField
control={form.control}
name="speed"
render={({ field }) => (
<FormItem>
<FormLabel>Speed</FormLabel>
<FormControl>
<Input
type="number"
step="0.1"
min="0.5"
max="2"
{...field}
onChange={(e) =>
field.onChange(parseFloat(e.target.value))
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="pitch"
render={({ field }) => (
<FormItem>
<FormLabel>Pitch</FormLabel>
<FormControl>
<Input
type="number"
step="0.1"
min="0.5"
max="2"
{...field}
onChange={(e) =>
field.onChange(parseFloat(e.target.value))
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="volume"
render={({ field }) => (
<FormItem>
<FormLabel>Volume</FormLabel>
<FormControl>
<Input
type="number"
step="0.1"
min="0"
max="1"
{...field}
onChange={(e) =>
field.onChange(parseFloat(e.target.value))
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</>
)}
{type === "wait" && (
<>
<FormField
control={form.control}
name="duration"
render={({ field }) => (
<FormItem>
<FormLabel>Duration (ms)</FormLabel>
<FormControl>
<Input
type="number"
min="0"
step="100"
{...field}
onChange={(e) =>
field.onChange(parseFloat(e.target.value))
}
/>
</FormControl>
<FormDescription>
Wait duration in milliseconds
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="showCountdown"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">
Show Countdown
</FormLabel>
<FormDescription>
Display a countdown timer during the wait
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</>
)}
{/* Add more action type configurations here */}
<div className="flex justify-end gap-4">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button type="submit">Save Changes</Button>
</div>
<Button type="submit">Save Changes</Button>
</form>
</Form>
</DialogContent>

View File

@@ -17,7 +17,7 @@ import ReactFlow, {
} from "reactflow";
import { motion, AnimatePresence } from "framer-motion";
import { type Step } from "~/lib/experiments/types";
import { AVAILABLE_ACTIONS } from "~/lib/experiments/actions";
import { BUILT_IN_ACTIONS, getPluginActions, type ActionConfig } from "~/lib/experiments/plugin-actions";
import { Card } from "~/components/ui/card";
import { ScrollArea } from "~/components/ui/scroll-area";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
@@ -27,6 +27,7 @@ import { ActionItem } from "./action-item";
import { cn } from "~/lib/utils";
import { Button } from "~/components/ui/button";
import { ChevronLeft, ChevronRight, Undo, Redo, ZoomIn, ZoomOut } from "lucide-react";
import { api } from "~/trpc/react";
import "reactflow/dist/style.css";
const nodeTypes = {
@@ -51,35 +52,50 @@ export function ExperimentDesigner({
readOnly = false,
}: ExperimentDesignerProps) {
const [sidebarOpen, setSidebarOpen] = useState(true);
const [selectedNode, setSelectedNode] = useState<Node | null>(null);
const reactFlowWrapper = useRef<HTMLDivElement>(null);
const [reactFlowInstance, setReactFlowInstance] = useState<any>(null);
const [selectedNode, setSelectedNode] = useState<Node | null>(null);
// History management for undo/redo
// Get available plugins
const { data: plugins } = api.pluginStore.getPlugins.useQuery();
const { data: installedPlugins } = api.pluginStore.getInstalledPlugins.useQuery();
const installedPluginIds = installedPlugins?.map(p => p.robotId) ?? [];
// Get available actions from installed plugins
const installedPluginActions = plugins
? getPluginActions(plugins.filter(p => installedPluginIds.includes(p.robotId)))
: [];
// Combine built-in actions with plugin actions
const availableActions = [...BUILT_IN_ACTIONS, ...installedPluginActions];
// History management
const [history, setHistory] = useState<Step[][]>([defaultSteps]);
const [historyIndex, setHistoryIndex] = useState(0);
const addToHistory = useCallback((newSteps: Step[]) => {
setHistory((h) => {
const newHistory = h.slice(0, historyIndex + 1);
setHistory(prev => {
const newHistory = prev.slice(0, historyIndex + 1);
return [...newHistory, newSteps];
});
setHistoryIndex((i) => i + 1);
setHistoryIndex(prev => prev + 1);
}, [historyIndex]);
const undo = useCallback(() => {
if (historyIndex > 0) {
setHistoryIndex((i) => i - 1);
setSteps(history[historyIndex - 1]!);
onChange?.(history[historyIndex - 1]!);
setHistoryIndex(prev => prev - 1);
const prevSteps = history[historyIndex - 1]!;
setSteps(prevSteps);
onChange?.(prevSteps);
}
}, [history, historyIndex, onChange]);
const redo = useCallback(() => {
if (historyIndex < history.length - 1) {
setHistoryIndex((i) => i + 1);
setSteps(history[historyIndex + 1]!);
onChange?.(history[historyIndex + 1]!);
setHistoryIndex(prev => prev + 1);
const nextSteps = history[historyIndex + 1]!;
setSteps(nextSteps);
onChange?.(nextSteps);
}
}, [history, historyIndex, onChange]);
@@ -99,8 +115,8 @@ export function ExperimentDesigner({
);
const actionIndex = stepIndex !== -1
? newSteps[stepIndex]!.actions.findIndex(
a => a.id === action.id
)
a => a.id === action.id
)
: -1;
if (
@@ -136,18 +152,16 @@ export function ExperimentDesigner({
const onNodesChange = useCallback(
(changes: NodeChange[]) => {
setNodes((nds) => {
const newNodes = applyNodeChanges(changes, nds);
// Update selected node
const selectedChange = changes.find((c) => c.type === "select");
if (selectedChange) {
const selected = newNodes.find((n) => n.id === selectedChange.id);
setSelectedNode(selected ?? null);
}
return newNodes;
});
setNodes((nds) => applyNodeChanges(changes, nds));
const selectedChange = changes.find(
(change) => change.type === "select"
);
if (selectedChange) {
const node = nodes.find((n) => n.id === selectedChange.id);
setSelectedNode(selectedChange.selected ? node : null);
}
},
[]
[nodes]
);
const onEdgesChange = useCallback(
@@ -159,17 +173,11 @@ export function ExperimentDesigner({
const onConnect = useCallback(
(connection: Connection) => {
const newEdge: Edge = {
id: `${connection.source}-${connection.target}`,
source: connection.source ?? "",
target: connection.target ?? "",
type: "default",
animated: true,
};
setEdges((eds) => [...eds, newEdge]);
if (!connection.source || !connection.target) return;
const sourceNode = nodes.find((n) => n.id === connection.source);
const targetNode = nodes.find((n) => n.id === connection.target);
if (sourceNode && targetNode) {
const newSteps = [...steps];
const sourceStep = newSteps.find((s) =>
@@ -178,7 +186,7 @@ export function ExperimentDesigner({
const targetStep = newSteps.find((s) =>
s.actions.some((a) => a.id === targetNode.id)
);
if (sourceStep && targetStep) {
const sourceAction = sourceStep.actions.find(
(a) => a.id === sourceNode.id
@@ -227,7 +235,7 @@ export function ExperimentDesigner({
y: event.clientY,
});
const actionConfig = AVAILABLE_ACTIONS.find((a) => a.type === type);
const actionConfig = availableActions.find((a) => a.type === type);
if (!actionConfig) return;
const newAction = {
@@ -251,8 +259,8 @@ export function ExperimentDesigner({
);
const actionIndex = stepIndex !== -1
? newSteps[stepIndex]!.actions.findIndex(
a => a.id === newAction.id
)
a => a.id === newAction.id
)
: -1;
if (
@@ -284,11 +292,24 @@ export function ExperimentDesigner({
addToHistory([...steps, newStep]);
onChange?.([...steps, newStep]);
},
[steps, onChange, reactFlowInstance, addToHistory]
[steps, onChange, reactFlowInstance, addToHistory, availableActions]
);
// Group actions by source
const groupedActions = availableActions.reduce((acc, action) => {
const source = action.pluginId ?
plugins?.find(p => p.robotId === action.pluginId)?.name ?? action.pluginId :
'Built-in Actions';
if (!acc[source]) {
acc[source] = [];
}
acc[source].push(action);
return acc;
}, {} as Record<string, ActionConfig[]>);
return (
<div className={cn("relative flex h-[calc(100vh-16rem)]", className)}>
<div className={cn("relative flex h-full", className)}>
<AnimatePresence>
{sidebarOpen && (
<motion.div
@@ -298,7 +319,7 @@ export function ExperimentDesigner({
transition={{ type: "spring", damping: 20, stiffness: 300 }}
className="absolute inset-y-0 left-0 z-30 w-80 overflow-hidden"
>
<Card className="flex h-full flex-col rounded-r-none border-r-0 shadow-2xl">
<Card className="flex h-full flex-col rounded-lg border shadow-2xl">
<Tabs defaultValue="actions" className="flex h-full flex-col">
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4">
<TabsList>
@@ -316,23 +337,32 @@ export function ExperimentDesigner({
</div>
<TabsContent value="actions" className="flex-1 p-0">
<ScrollArea className="h-full">
<div className="space-y-2 p-4">
{AVAILABLE_ACTIONS.map((action) => (
<ActionItem
key={action.type}
type={action.type}
title={action.title}
description={action.description}
icon={action.icon}
draggable
onDragStart={(event) => {
event.dataTransfer.setData(
"application/reactflow",
action.type
);
event.dataTransfer.effectAllowed = "move";
}}
/>
<div className="space-y-6 p-4">
{Object.entries(groupedActions).map(([source, actions]) => (
<div key={source} className="space-y-2">
<h3 className="px-2 text-sm font-medium text-muted-foreground">
{source}
</h3>
<div className="space-y-2">
{actions.map((action) => (
<ActionItem
key={action.pluginId ? `${action.pluginId}:${action.type}` : action.type}
type={action.type}
title={action.title}
description={action.description}
icon={action.icon}
draggable
onDragStart={(event) => {
event.dataTransfer.setData(
"application/reactflow",
action.type
);
event.dataTransfer.effectAllowed = "move";
}}
/>
))}
</div>
</div>
))}
</div>
</ScrollArea>
@@ -343,7 +373,7 @@ export function ExperimentDesigner({
{selectedNode ? (
<div className="space-y-4">
<h3 className="font-medium">
{AVAILABLE_ACTIONS.find((a) => a.type === selectedNode.data.type)?.title}
{availableActions.find((a) => a.type === selectedNode.data.type)?.title}
</h3>
<pre className="rounded-lg bg-muted p-4 text-xs">
{JSON.stringify(selectedNode.data.parameters, null, 2)}
@@ -397,24 +427,24 @@ export function ExperimentDesigner({
className="react-flow-wrapper"
>
<Background />
<Controls />
<Controls className="!left-auto !right-8" />
<MiniMap
nodeColor={(node) => {
const action = AVAILABLE_ACTIONS.find(
const action = availableActions.find(
(a) => a.type === node.data.type
);
return action ? "hsl(var(--primary) / 0.5)" : "hsl(var(--muted))"
}}
maskColor="hsl(var(--background))"
className="!bg-card/80 !border !border-border rounded-lg backdrop-blur"
className="!bottom-8 !left-auto !right-8 !bg-card/80 !border !border-border rounded-lg backdrop-blur"
style={{
backgroundColor: "hsl(var(--card))",
borderRadius: "var(--radius)",
}}
/>
<Panel position="top-center" className="flex gap-2 rounded-lg bg-background/95 px-4 py-2 shadow-md backdrop-blur supports-[backdrop-filter]:bg-background/80">
<Panel position="top-right" className="flex gap-2">
<Button
variant="ghost"
variant="outline"
size="icon"
onClick={undo}
disabled={historyIndex === 0}
@@ -422,28 +452,13 @@ export function ExperimentDesigner({
<Undo className="h-4 w-4" />
</Button>
<Button
variant="ghost"
variant="outline"
size="icon"
onClick={redo}
disabled={historyIndex === history.length - 1}
>
<Redo className="h-4 w-4" />
</Button>
<div className="mx-2 w-px bg-border" />
<Button
variant="ghost"
size="icon"
onClick={() => reactFlowInstance?.zoomIn()}
>
<ZoomIn className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => reactFlowInstance?.zoomOut()}
>
<ZoomOut className="h-4 w-4" />
</Button>
</Panel>
</ReactFlow>
</ReactFlowProvider>

View File

@@ -3,7 +3,7 @@
import { memo, useState } from "react";
import { Handle, Position, type NodeProps } from "reactflow";
import { motion } from "framer-motion";
import { AVAILABLE_ACTIONS } from "~/lib/experiments/actions";
import { BUILT_IN_ACTIONS, getPluginActions } from "~/lib/experiments/plugin-actions";
import {
Card,
CardContent,
@@ -16,6 +16,7 @@ import { Settings, ArrowDown, ArrowUp } from "lucide-react";
import { cn } from "~/lib/utils";
import { ActionConfigDialog } from "../action-config-dialog";
import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip";
import { api } from "~/trpc/react";
interface ActionNodeData {
type: string;
@@ -26,15 +27,34 @@ interface ActionNodeData {
export const ActionNode = memo(({ data, selected }: NodeProps<ActionNodeData>) => {
const [configOpen, setConfigOpen] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const actionConfig = AVAILABLE_ACTIONS.find((a) => a.type === data.type);
// Get available plugins
const { data: plugins } = api.pluginStore.getPlugins.useQuery();
const { data: installedPlugins } = api.pluginStore.getInstalledPlugins.useQuery();
const installedPluginIds = installedPlugins?.map(p => p.robotId) ?? [];
// Get available actions from installed plugins
const installedPluginActions = plugins
? getPluginActions(plugins.filter(p => installedPluginIds.includes(p.robotId)))
: [];
// Combine built-in actions with plugin actions
const availableActions = [...BUILT_IN_ACTIONS, ...installedPluginActions];
const actionConfig = availableActions.find((a) => a.type === data.type);
if (!actionConfig) return null;
return (
<>
<Handle
type="target"
position={Position.Top}
className="!bg-primary !border-primary-foreground"
/>
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{
scale: 1,
animate={{
scale: 1,
opacity: 1,
}}
transition={{ duration: 0.2 }}
@@ -48,79 +68,47 @@ export const ActionNode = memo(({ data, selected }: NodeProps<ActionNodeData>) =
isHovered && "before:from-border/80 before:to-border/30",
)}
>
<Card className="relative z-10 w-[250px] bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 border-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4">
<Card className="relative z-10 min-w-[240px] overflow-hidden">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-gradient-to-br from-primary/20 to-primary/10 text-primary">
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-gradient-to-br from-primary/20 to-primary/10">
{actionConfig.icon}
</div>
<CardTitle className="text-sm font-medium leading-none">
{actionConfig.title}
</CardTitle>
<CardTitle className="text-base">{actionConfig.title}</CardTitle>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() => setConfigOpen(true)}
>
<Settings className="h-4 w-4" />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() => setConfigOpen(true)}
>
<Settings className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Configure Action</TooltipContent>
</Tooltip>
</CardHeader>
<CardContent className="p-4 pt-0">
<CardDescription className="text-xs">
<CardContent>
<CardDescription className="line-clamp-2">
{actionConfig.description}
</CardDescription>
</CardContent>
<Tooltip>
<TooltipTrigger asChild>
<Handle
type="target"
position={Position.Top}
className={cn(
"!h-3 !w-3 !border-2 !bg-background",
"!border-border transition-colors duration-200",
"data-[connecting=true]:!border-primary data-[connecting=true]:!bg-primary",
"before:absolute before:inset-[-4px] before:rounded-full before:border-2 before:border-background",
"after:absolute after:inset-[-8px] after:rounded-full after:border-2 after:border-border/50"
)}
/>
</TooltipTrigger>
<TooltipContent side="top" className="flex items-center gap-2">
<ArrowDown className="h-3 w-3" />
Input Connection
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Handle
type="source"
position={Position.Bottom}
className={cn(
"!h-3 !w-3 !border-2 !bg-background",
"!border-border transition-colors duration-200",
"data-[connecting=true]:!border-primary data-[connecting=true]:!bg-primary",
"before:absolute before:inset-[-4px] before:rounded-full before:border-2 before:border-background",
"after:absolute after:inset-[-8px] after:rounded-full after:border-2 after:border-border/50"
)}
/>
</TooltipTrigger>
<TooltipContent side="bottom" className="flex items-center gap-2">
<ArrowUp className="h-3 w-3" />
Output Connection
</TooltipContent>
</Tooltip>
</Card>
</motion.div>
<Handle
type="source"
position={Position.Bottom}
className="!bg-primary !border-primary-foreground"
/>
<ActionConfigDialog
open={configOpen}
onOpenChange={setConfigOpen}
type={data.type as any}
type={data.type}
parameters={data.parameters}
onSubmit={data.onChange ?? (() => {})}
onSubmit={data.onChange ?? (() => { })}
actionConfig={actionConfig}
/>
</>
);

View File

@@ -0,0 +1,73 @@
"use client";
import { motion } from "framer-motion";
import { BotIcon } from "lucide-react";
import Link from "next/link";
import { Button } from "~/components/ui/button";
import { Card, CardContent } from "~/components/ui/card";
interface CTASectionProps {
isLoggedIn: boolean;
}
export function CTASection({ isLoggedIn }: CTASectionProps) {
return (
<section className="container mx-auto px-4 py-24">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
>
<Card className="relative overflow-hidden">
<div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-primary via-primary to-secondary" />
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_50%_120%,rgba(0,0,0,0)_30%,rgba(0,0,0,0.15)_100%)]" />
<CardContent className="relative p-12 flex flex-col items-center text-center space-y-6 text-primary-foreground">
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
whileInView={{ scale: 1, opacity: 1 }}
viewport={{ once: true }}
transition={{ delay: 0.2, duration: 0.5 }}
>
<BotIcon className="size-12 mb-4" />
</motion.div>
<motion.h2
initial={{ y: 20, opacity: 0 }}
whileInView={{ y: 0, opacity: 1 }}
viewport={{ once: true }}
transition={{ delay: 0.3, duration: 0.5 }}
className="text-3xl font-bold tracking-tight"
>
Ready to Transform Your Research?
</motion.h2>
<motion.p
initial={{ y: 20, opacity: 0 }}
whileInView={{ y: 0, opacity: 1 }}
viewport={{ once: true }}
transition={{ delay: 0.4, duration: 0.5 }}
className="text-primary-foreground/90 max-w-[600px]"
>
Join the growing community of researchers using HRIStudio to advance human-robot interaction studies.
</motion.p>
<motion.div
initial={{ y: 20, opacity: 0 }}
whileInView={{ y: 0, opacity: 1 }}
viewport={{ once: true }}
transition={{ delay: 0.5, duration: 0.5 }}
>
{!isLoggedIn ? (
<Button size="lg" variant="secondary" asChild className="mt-4 bg-background/20 hover:bg-background/30">
<Link href="/auth/signup">Start Your Journey</Link>
</Button>
) : (
<Button size="lg" variant="secondary" asChild className="mt-4 bg-background/20 hover:bg-background/30">
<Link href="/dashboard">Go to Dashboard</Link>
</Button>
)}
</motion.div>
</CardContent>
</Card>
</motion.div>
</section>
);
}

View File

@@ -0,0 +1,67 @@
"use client";
import { motion } from "framer-motion";
import { Sparkles, Brain, Microscope } from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
const features = [
{
icon: <Sparkles className="size-6 text-primary" />,
title: "Visual Experiment Design",
description: "Create and configure experiments using an intuitive drag-and-drop interface without extensive coding."
},
{
icon: <Brain className="size-6 text-primary" />,
title: "Real-time Control",
description: "Execute experiments with synchronized views for wizards and observers, enabling seamless collaboration."
},
{
icon: <Microscope className="size-6 text-primary" />,
title: "Comprehensive Analysis",
description: "Record, playback, and analyze experimental data with built-in annotation and export tools."
}
];
export function FeaturesSection() {
return (
<section className="container mx-auto px-4 py-24 space-y-12">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
className="text-center space-y-4"
>
<h2 className="text-3xl font-bold tracking-tight bg-gradient-to-br from-foreground to-foreground/70 bg-clip-text text-transparent inline-block">
Powerful Features for HRI Research
</h2>
<p className="text-muted-foreground max-w-[600px] mx-auto">
Everything you need to design, execute, and analyze your human-robot interaction experiments.
</p>
</motion.div>
<div className="grid md:grid-cols-3 gap-8">
{features.map((feature, index) => (
<motion.div
key={feature.title}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: index * 0.1, duration: 0.5 }}
>
<Card className="group relative overflow-hidden border bg-background/60 backdrop-blur supports-[backdrop-filter]:bg-background/60 hover:shadow-lg transition-all">
<div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-primary/10 via-primary/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
<CardHeader>
<div className="size-12 rounded-lg bg-gradient-to-br from-primary/20 to-primary/10 flex items-center justify-center mb-4">
{feature.icon}
</div>
<CardTitle>{feature.title}</CardTitle>
<CardDescription>{feature.description}</CardDescription>
</CardHeader>
</Card>
</motion.div>
))}
</div>
</section>
);
}

View File

@@ -0,0 +1,101 @@
"use client";
import { motion } from "framer-motion";
import { BotIcon, ArrowRight } from "lucide-react";
import Link from "next/link";
import { Button } from "~/components/ui/button";
interface HeroSectionProps {
isLoggedIn: boolean;
}
export function HeroSection({ isLoggedIn }: HeroSectionProps) {
return (
<section className="relative">
{/* Hero gradient background */}
<div className="absolute inset-0 bg-gradient-to-b from-background via-primary/5 to-background">
<div className="absolute inset-0"
style={{
backgroundImage: `radial-gradient(circle at 50% 50%, hsl(var(--primary)/.08) 0%, transparent 50%)`,
}}
/>
</div>
<div className="container mx-auto px-4 py-24 relative">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="grid lg:grid-cols-2 gap-12 items-center"
>
<div className="space-y-6">
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2, duration: 0.5 }}
className="inline-flex rounded-lg bg-gradient-to-br from-primary/20 via-secondary/20 to-background p-1 mb-8"
>
<span className="rounded-md bg-background/95 px-3 py-1 text-sm backdrop-blur">
Now with Visual Experiment Designer
</span>
</motion.div>
<motion.h1
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3, duration: 0.5 }}
className="text-4xl font-bold tracking-tight lg:text-6xl bg-gradient-to-br from-foreground via-foreground/90 to-foreground/70 bg-clip-text text-transparent"
>
Streamline Your HRI Research
</motion.h1>
<motion.p
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4, duration: 0.5 }}
className="text-xl text-muted-foreground"
>
A comprehensive platform for designing, executing, and analyzing Wizard-of-Oz experiments in human-robot interaction studies.
</motion.p>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5, duration: 0.5 }}
className="flex flex-col sm:flex-row gap-4 pt-4"
>
{!isLoggedIn ? (
<Button size="lg" className="w-full sm:w-auto group bg-gradient-to-r from-primary to-primary hover:from-primary/90 hover:to-primary" asChild>
<Link href="/auth/signup">
Get Started
<ArrowRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" />
</Link>
</Button>
) : (
<Button size="lg" className="w-full sm:w-auto group bg-gradient-to-r from-primary to-primary hover:from-primary/90 hover:to-primary" asChild>
<Link href="/dashboard">
Go to Dashboard
<ArrowRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" />
</Link>
</Button>
)}
<Button size="lg" variant="outline" className="w-full sm:w-auto" asChild>
<Link href="https://github.com/soconnor0919/hristudio" target="_blank">
View on GitHub
</Link>
</Button>
</motion.div>
</div>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.4, duration: 0.5 }}
className="relative aspect-square lg:aspect-video"
>
<div className="absolute inset-0 bg-gradient-to-br from-primary/30 via-secondary/20 to-background rounded-lg border shadow-xl" />
<div className="absolute inset-0 flex items-center justify-center">
<BotIcon className="h-32 w-32 text-primary/40" />
</div>
</motion.div>
</motion.div>
</div>
</section>
);
}

View File

@@ -1,14 +1,12 @@
"use client"
import {
Beaker,
Home,
Settings2,
User,
Microscope,
Users,
Plus,
FlaskConical
FlaskConical,
Bot
} from "lucide-react"
import * as React from "react"
import { useSession } from "next-auth/react"
@@ -42,16 +40,11 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
title: "Studies",
url: "/dashboard/studies",
icon: Microscope,
items: [
{
title: "All Studies",
url: "/dashboard/studies",
},
{
title: "Create Study",
url: "/dashboard/studies/new",
},
],
},
{
title: "Robot Store",
url: "/dashboard/store",
icon: Bot,
},
]
@@ -62,34 +55,11 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
title: "Participants",
url: `/dashboard/studies/${activeStudy.id}/participants`,
icon: Users,
items: [
{
title: "All Participants",
url: `/dashboard/studies/${activeStudy.id}/participants`,
},
{
title: "Add Participant",
url: `/dashboard/studies/${activeStudy.id}/participants/new`,
// Only show if user is admin
hidden: activeStudy.role !== "ADMIN",
},
],
},
{
title: "Experiments",
url: `/dashboard/studies/${activeStudy.id}/experiments`,
icon: FlaskConical,
items: [
{
title: "All Experiments",
url: `/dashboard/studies/${activeStudy.id}/experiments`,
},
{
title: "Create Experiment",
url: `/dashboard/studies/${activeStudy.id}/experiments/new`,
hidden: !["OWNER", "ADMIN", "PRINCIPAL_INVESTIGATOR"].map(r => r.toLowerCase()).includes(activeStudy.role.toLowerCase()),
},
],
},
]
: []
@@ -100,22 +70,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
title: "Settings",
url: "/dashboard/settings",
icon: Settings2,
items: [
{
title: "Account",
url: "/dashboard/account",
icon: User,
},
{
title: "Team",
url: "/dashboard/settings/team",
},
{
title: "Billing",
url: "/dashboard/settings/billing",
},
],
},
}
]
const navItems = [...baseNavItems, ...studyNavItems, ...settingsNavItems]

View File

@@ -8,16 +8,19 @@ import { Logo } from "~/components/logo"
export function Header() {
return (
<div className="sticky top-0 z-40 w-full">
<header className="mx-2 mt-2 flex h-14 items-center justify-between rounded-lg border bg-gradient-to-r from-[hsl(var(--sidebar-gradient-from))] to-[hsl(var(--sidebar-gradient-to))] px-6 shadow-sm md:ml-0">
<header
data-nav="header"
className="mx-2 mt-2 flex h-14 items-center justify-between rounded-lg border shadow-sm md:ml-0 px-6"
>
<div className="flex items-center gap-2">
<SidebarTrigger className="-ml-2 text-[hsl(var(--sidebar-text))] hover:bg-[hsl(var(--sidebar-text))]/10" />
<Separator orientation="vertical" className="h-4 bg-[hsl(var(--sidebar-text))]/10" />
<SidebarTrigger className="-ml-2 text-[hsl(var(--sidebar-foreground))] hover:bg-[hsl(var(--sidebar-hover))]/20" />
<Separator orientation="vertical" className="h-4 bg-[hsl(var(--sidebar-border))]" />
<BreadcrumbNav />
</div>
<Logo
<Logo
href="/dashboard"
className="text-[hsl(var(--sidebar-text))]"
iconClassName="text-[hsl(var(--sidebar-text-muted))]"
className="text-[hsl(var(--sidebar-foreground))]"
iconClassName="text-[hsl(var(--sidebar-muted))]"
/>
</header>
</div>

View File

@@ -1,73 +1,62 @@
"use client"
import { ChevronRight, type LucideIcon } from "lucide-react"
import { usePathname } from "next/navigation"
import { type LucideIcon } from "lucide-react"
import Link from "next/link"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "~/components/ui/collapsible"
import {
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
} from "~/components/ui/sidebar"
import { cn } from "~/lib/utils"
export function NavMain({
items,
}: {
items: {
title: string
url: string
icon?: LucideIcon
isActive?: boolean
items?: {
title: string
url: string
}[]
}[]
}) {
return (
<SidebarGroup>
<SidebarGroupLabel>Platform</SidebarGroupLabel>
<SidebarMenu>
{items.map((item) => (
<Collapsible
key={item.title}
asChild
defaultOpen={item.isActive}
className="group/collapsible"
>
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton tooltip={item.title}>
{item.icon && <item.icon />}
<span>{item.title}</span>
<ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{item.items?.map((subItem) => (
<SidebarMenuSubItem key={subItem.title}>
<SidebarMenuSubButton asChild>
<a href={subItem.url}>
<span>{subItem.title}</span>
</a>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
))}
</SidebarMenu>
</SidebarGroup>
)
interface NavItem {
title: string
url: string
icon: LucideIcon
}
export function NavMain({ items }: { items: NavItem[] }) {
const pathname = usePathname()
// Find the most specific matching route
const activeItem = items
.filter(item => {
if (item.url === "/dashboard") {
return pathname === "/dashboard"
}
return pathname.startsWith(item.url)
})
.sort((a, b) => b.url.length - a.url.length)[0]
return (
<SidebarMenu className="pt-2">
{items.map((item) => {
const isActive = item.url === activeItem?.url
return (
<SidebarMenuItem key={item.url}>
<SidebarMenuButton
asChild
isActive={isActive}
tooltip={item.title}
className={cn(
"relative flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm outline-none transition-colors",
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
"focus-visible:ring-2 focus-visible:ring-sidebar-ring",
"group-data-[collapsible=icon]:px-0 group-data-[collapsible=icon]:justify-center",
isActive && "bg-sidebar-accent font-medium text-sidebar-accent-foreground"
)}
>
<Link href={item.url} className="flex items-center gap-2 w-full group-data-[collapsible=icon]:justify-center">
<item.icon className="h-4 w-4 shrink-0" />
<span className="truncate group-data-[collapsible=icon]:hidden">{item.title}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
)
})}
</SidebarMenu>
)
}

View File

@@ -1,7 +1,7 @@
"use client"
import { ChevronsUpDown, LogOut, Settings, User } from "lucide-react"
import { useSession } from "next-auth/react"
import { useSession, signOut } from "next-auth/react"
import Link from "next/link"
import Image from "next/image"
@@ -20,9 +20,13 @@ import {
SidebarMenuItem,
} from "~/components/ui/sidebar"
import { Avatar, AvatarFallback } from "~/components/ui/avatar"
import { useSidebar } from "~/components/ui/sidebar"
import { cn } from "~/lib/utils"
export function NavUser() {
const { data: session, status } = useSession()
const { state } = useSidebar()
const isCollapsed = state === "collapsed"
if (status === "loading") {
return (
@@ -30,15 +34,20 @@ export function NavUser() {
<SidebarMenuItem>
<SidebarMenuButton
size="lg"
className="animate-pulse"
className={cn(
"animate-pulse",
isCollapsed && "justify-center p-0"
)}
>
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-accent/10">
<User className="size-4 text-muted-foreground/50" />
</div>
<div className="grid flex-1 gap-1">
<div className="h-4 w-24 rounded bg-sidebar-accent/10" />
<div className="h-3 w-16 rounded bg-sidebar-accent/10" />
</div>
{!isCollapsed && (
<div className="grid flex-1 gap-1">
<div className="h-4 w-24 rounded bg-sidebar-accent/10" />
<div className="h-3 w-16 rounded bg-sidebar-accent/10" />
</div>
)}
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
@@ -56,7 +65,10 @@ export function NavUser() {
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
className={cn(
"data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground",
isCollapsed && "justify-center p-0"
)}
>
<Avatar className="size-8 rounded-lg">
{session.user.image ? (
@@ -79,19 +91,23 @@ export function NavUser() {
</AvatarFallback>
)}
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">
{session.user.name ?? "User"}
</span>
<span className="truncate text-xs text-sidebar-muted">
{session.user.email}
</span>
</div>
<ChevronsUpDown className="ml-auto size-4" />
{!isCollapsed && (
<>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">
{session.user.name ?? "User"}
</span>
<span className="truncate text-xs text-sidebar-muted">
{session.user.email}
</span>
</div>
<ChevronsUpDown className="ml-auto size-4" />
</>
)}
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
className="min-w-56 rounded-lg"
align="end"
sideOffset={4}
>
@@ -138,11 +154,12 @@ export function NavUser() {
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="/api/auth/signout">
<LogOut className="mr-2 size-4" />
Sign out
</Link>
<DropdownMenuItem
onClick={() => signOut({ callbackUrl: "/auth/signin" })}
className="cursor-pointer"
>
<LogOut className="mr-2 size-4" />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -0,0 +1,24 @@
"use client";
import { ThemeProvider } from "next-themes";
import { StudyProvider } from "./study-provider";
import { PluginStoreProvider } from "./plugin-store-provider";
import { Toaster } from "~/components/ui/toaster";
export function Providers({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<PluginStoreProvider>
<StudyProvider>
{children}
<Toaster />
</StudyProvider>
</PluginStoreProvider>
</ThemeProvider>
);
}

View File

@@ -0,0 +1,50 @@
"use client";
import { createContext, useContext, useState } from "react";
import { type RobotPlugin } from "~/lib/plugin-store/types";
interface PluginStoreContextType {
plugins: RobotPlugin[];
selectedPlugin?: RobotPlugin;
selectPlugin: (robotId: string) => void;
setPlugins: (plugins: RobotPlugin[]) => void;
}
const PluginStoreContext = createContext<PluginStoreContextType | undefined>(undefined);
export function PluginStoreProvider({
children,
initialPlugins = [],
}: {
children: React.ReactNode;
initialPlugins?: RobotPlugin[];
}) {
const [plugins, setPlugins] = useState<RobotPlugin[]>(initialPlugins);
const [selectedPlugin, setSelectedPlugin] = useState<RobotPlugin>();
const selectPlugin = (robotId: string) => {
const plugin = plugins.find(p => p.robotId === robotId);
setSelectedPlugin(plugin);
};
return (
<PluginStoreContext.Provider
value={{
plugins,
selectedPlugin,
selectPlugin,
setPlugins,
}}
>
{children}
</PluginStoreContext.Provider>
);
}
export function usePluginStore() {
const context = useContext(PluginStoreContext);
if (!context) {
throw new Error("usePluginStore must be used within a PluginStoreProvider");
}
return context;
}

View File

@@ -0,0 +1,123 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useToast } from "~/hooks/use-toast";
import { Button } from "~/components/ui/button";
import { Plus } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
import { Input } from "~/components/ui/input";
import { api } from "~/trpc/react";
import { Alert, AlertDescription } from "~/components/ui/alert";
export function AddRepositoryDialog() {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const [url, setUrl] = useState("");
const { toast } = useToast();
const utils = api.useUtils();
const addRepository = api.pluginStore.addRepository.useMutation({
onSuccess: async () => {
toast({
title: "Success",
description: "Repository added successfully",
});
setIsOpen(false);
setUrl("");
// Invalidate and refetch all plugin store queries
await Promise.all([
utils.pluginStore.getRepositories.invalidate(),
utils.pluginStore.getPlugins.invalidate(),
utils.pluginStore.getInstalledPlugins.invalidate(),
]);
// Force refetch
await Promise.all([
utils.pluginStore.getRepositories.refetch(),
utils.pluginStore.getPlugins.refetch(),
utils.pluginStore.getInstalledPlugins.refetch(),
]);
},
onError: (error) => {
console.error("Failed to add repository:", error);
toast({
title: "Error",
description: error.message || "Failed to add repository",
variant: "destructive",
});
},
});
const handleAddRepository = async () => {
if (!url) {
toast({
title: "Error",
description: "Please enter a repository URL",
variant: "destructive",
});
return;
}
try {
setIsLoading(true);
await addRepository.mutateAsync({ url });
} finally {
setIsLoading(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button size="sm">
<Plus className="h-4 w-4 mr-2" />
Add Repository
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Plugin Repository</DialogTitle>
<DialogDescription>
Enter the URL of a plugin repository. The repository must contain a repository.json file and follow the HRIStudio plugin repository structure.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<Alert>
<AlertDescription>
Example repository URL:
<code className="ml-2 rounded bg-muted px-1.5 py-0.5">
https://soconnor0919.github.io/robot-plugins
</code>
</AlertDescription>
</Alert>
<div className="grid gap-2">
<Input
placeholder="Enter repository URL"
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button
onClick={handleAddRepository}
disabled={isLoading || addRepository.isLoading}
>
{isLoading || addRepository.isLoading ? "Adding..." : "Add Repository"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,254 @@
"use client";
import { useState } from "react";
import { type RepositoryMetadata, type RobotPlugin } from "~/lib/plugin-store/types";
import { Button } from "~/components/ui/button";
import { Bot, Search, Filter } from "lucide-react";
import { RepositorySection } from "./repository-section";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { RobotGrid } from "./robot-grid";
import { RobotDetails } from "./robot-details";
import { api } from "~/trpc/react";
import { Input } from "~/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuCheckboxItem,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import { Skeleton } from "~/components/ui/skeleton";
interface PluginBrowserProps {
repositories: RepositoryMetadata[];
initialPlugins: RobotPlugin[];
}
function RobotSkeleton() {
return (
<div className="flex gap-3 rounded-lg border p-4">
<div className="relative aspect-square h-20 shrink-0 overflow-hidden rounded-md border bg-muted">
<Skeleton className="h-full w-full" />
</div>
<div className="flex flex-1 flex-col justify-between">
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<Skeleton className="h-6 w-32" />
<Skeleton className="h-5 w-20" />
</div>
<div className="space-y-1">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
</div>
<div className="mt-2 flex items-center justify-between">
<div className="flex items-center gap-4">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-4 w-16" />
</div>
<Skeleton className="h-8 w-24" />
</div>
</div>
</div>
);
}
export function PluginBrowser({ repositories, initialPlugins }: PluginBrowserProps) {
// State
const [searchQuery, setSearchQuery] = useState("");
const [selectedRepository, setSelectedRepository] = useState<string>("all");
const [showInstalled, setShowInstalled] = useState<boolean>(true);
const [showAvailable, setShowAvailable] = useState<boolean>(true);
const [selectedRobot, setSelectedRobot] = useState<RobotPlugin | null>(
initialPlugins[0] ?? null
);
// Queries
const { data: installedPlugins, isLoading: isLoadingInstalled } = api.pluginStore.getInstalledPlugins.useQuery(undefined, {
refetchOnMount: true,
refetchOnWindowFocus: true,
});
const { data: plugins, isLoading: isLoadingPlugins } = api.pluginStore.getPlugins.useQuery(undefined, {
initialData: initialPlugins,
refetchOnMount: true,
refetchOnWindowFocus: true,
});
const installedPluginIds = installedPlugins?.map(p => p.robotId) ?? [];
// Loading state
const isLoading = isLoadingInstalled || isLoadingPlugins;
// Filter plugins
const filteredPlugins = plugins.filter(plugin => {
// Repository filter
if (selectedRepository !== "all" && plugin.repositoryId !== selectedRepository) {
return false;
}
// Installation status filter
const isInstalled = installedPluginIds.includes(plugin.robotId);
if (!showInstalled && isInstalled) return false;
if (!showAvailable && !isInstalled) return false;
// Search query filter
if (searchQuery) {
const query = searchQuery.toLowerCase();
return (
plugin.name.toLowerCase().includes(query) ||
plugin.description?.toLowerCase().includes(query) ||
plugin.platform.toLowerCase().includes(query) ||
plugin.manufacturer.name.toLowerCase().includes(query)
);
}
return true;
});
return (
<Tabs defaultValue="plugins" className="space-y-6">
<TabsList>
<TabsTrigger value="plugins">Robots</TabsTrigger>
<TabsTrigger value="repositories">Repositories</TabsTrigger>
</TabsList>
<TabsContent value="plugins">
<Card>
<CardHeader>
<CardTitle>Robot Plugins</CardTitle>
<CardDescription>
Browse and manage robot plugins from your configured repositories
</CardDescription>
<div className="mt-4 flex flex-col gap-4 md:flex-row md:items-center">
<div className="relative flex-1">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search robots..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8"
/>
</div>
<div className="flex items-center gap-2">
<Select
value={selectedRepository}
onValueChange={setSelectedRepository}
>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Select Repository" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Repositories</SelectItem>
{repositories.map((repo) => (
<SelectItem key={repo.id} value={repo.id}>
{repo.name}
</SelectItem>
))}
</SelectContent>
</Select>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<Filter className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Show</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
checked={showInstalled}
onCheckedChange={setShowInstalled}
>
Installed
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={showAvailable}
onCheckedChange={setShowAvailable}
>
Available
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</CardHeader>
<CardContent>
<div className="grid h-[calc(100vh-24rem)] grid-cols-[400px_1fr] gap-8">
{/* Left Pane - Robot List */}
<div className="overflow-y-auto rounded-lg pr-4">
{isLoading ? (
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, i) => (
<RobotSkeleton key={i} />
))}
</div>
) : (
<RobotGrid
plugins={filteredPlugins}
installedPluginIds={installedPluginIds}
selectedRobotId={selectedRobot?.robotId}
onSelectRobot={setSelectedRobot}
/>
)}
</div>
{/* Right Pane - Robot Details */}
<div className="overflow-y-auto rounded-lg border bg-card">
{isLoading ? (
<div className="p-6">
<div className="space-y-4">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-4 w-full" />
<div className="flex gap-4">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-24" />
</div>
</div>
</div>
) : selectedRobot && (
<RobotDetails
robot={selectedRobot}
isInstalled={installedPluginIds.includes(selectedRobot.robotId)}
/>
)}
</div>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="repositories">
<Card>
<CardHeader>
<CardTitle>Plugin Repositories</CardTitle>
<CardDescription>
Manage your robot plugin sources
</CardDescription>
</CardHeader>
<CardContent>
{repositories.length === 0 ? (
<div className="flex flex-col items-center justify-center min-h-[400px] text-center">
<Bot className="h-16 w-16 text-muted-foreground/50 mb-4" />
<h3 className="text-lg font-medium">No Repositories Added</h3>
<p className="text-sm text-muted-foreground">
Add a repository using the button above
</p>
</div>
) : (
<RepositorySection repositories={repositories} />
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
);
}

View File

@@ -0,0 +1,98 @@
"use client";
import { type RepositoryMetadata } from "~/lib/plugin-store/types";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "~/components/ui/card";
import { Badge } from "~/components/ui/badge";
import { Bot, Star, Download, Package, Calendar } from "lucide-react";
import { formatDistanceToNow } from "date-fns";
import Image from "next/image";
interface RepositoryCardProps {
repository: RepositoryMetadata;
onRemove?: (id: string) => void;
}
export function RepositoryCard({ repository, onRemove }: RepositoryCardProps) {
const lastUpdated = new Date(repository.lastUpdated);
return (
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<div className="relative aspect-square h-12 shrink-0 overflow-hidden rounded-md border bg-muted">
{repository.assets?.logo ? (
<Image
src={repository.assets.logo}
alt={repository.name}
fill
className="object-contain p-1.5"
/>
) : repository.assets?.icon ? (
<Image
src={repository.assets.icon}
alt={repository.name}
fill
className="object-cover"
/>
) : (
<div className="flex h-full items-center justify-center">
<Bot className="h-6 w-6 text-muted-foreground/50" />
</div>
)}
</div>
<div className="flex-1 min-w-0">
<CardTitle className="flex items-center gap-2">
<span className="truncate">{repository.name}</span>
{repository.official && (
<Badge variant="default" className="shrink-0 text-xs">Official</Badge>
)}
</CardTitle>
<CardDescription className="line-clamp-2">{repository.description}</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<div className="grid gap-4">
<div className="flex flex-wrap items-center gap-4 text-sm">
<div className="flex items-center gap-1.5">
<Star className="h-4 w-4 text-muted-foreground" />
<span>{repository.stats?.stars ?? 0}</span>
</div>
<div className="flex items-center gap-1.5">
<Download className="h-4 w-4 text-muted-foreground" />
<span>{repository.stats?.downloads ?? 0}</span>
</div>
<div className="flex items-center gap-1.5">
<Package className="h-4 w-4 text-muted-foreground" />
<span>{repository.stats?.plugins ?? 0} plugins</span>
</div>
<div className="flex items-center gap-1.5">
<Calendar className="h-4 w-4 text-muted-foreground" />
<span>Updated {formatDistanceToNow(lastUpdated, { addSuffix: true })}</span>
</div>
</div>
<div className="flex flex-wrap gap-1.5">
{repository.tags.map((tag) => (
<Badge key={tag} variant="secondary" className="text-xs">
{tag}
</Badge>
))}
</div>
</div>
</CardContent>
{onRemove && !repository.official && (
<CardFooter>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => onRemove(repository.id)}
>
Remove Repository
</Button>
</CardFooter>
)}
</Card>
);
}

View File

@@ -0,0 +1,339 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useToast } from "~/hooks/use-toast";
import { type RepositoryMetadata } from "~/lib/plugin-store/types";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { Bot, Star, Download, Package, Calendar } from "lucide-react";
import Image from "next/image";
import { cn } from "~/lib/utils";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { api } from "~/trpc/react";
import { formatDistanceToNow } from "date-fns";
interface RepositorySectionProps {
repositories: RepositoryMetadata[];
}
function RepositoryListItem({
repository,
isSelected,
onSelect,
onRemove,
}: {
repository: RepositoryMetadata;
isSelected: boolean;
onSelect: () => void;
onRemove?: (id: string) => void;
}) {
return (
<div
className={cn(
"group relative flex cursor-pointer gap-3 rounded-lg border p-4 transition-all",
isSelected
? "border-primary bg-card ring-2 ring-primary/10"
: "hover:border-primary/50 hover:bg-accent/50"
)}
onClick={onSelect}
>
<div className="relative aspect-square h-20 shrink-0 overflow-hidden rounded-md border bg-muted">
{repository.assets?.logo ? (
<Image
src={repository.assets.logo}
alt={repository.name}
fill
className="object-contain p-2"
/>
) : repository.assets?.icon ? (
<Image
src={repository.assets.icon}
alt={repository.name}
fill
className="object-cover"
/>
) : (
<div className="flex h-full items-center justify-center">
<Bot className="h-10 w-10 text-muted-foreground/50" />
</div>
)}
</div>
<div className="flex-1 space-y-2">
<div className="flex items-center justify-between gap-2">
<h3 className="line-clamp-1 font-semibold tracking-tight">{repository.name}</h3>
{repository.official && (
<Badge variant="default" className="shrink-0">Official</Badge>
)}
</div>
<p className="line-clamp-2 text-sm text-muted-foreground">
{repository.description}
</p>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<Star className="h-3 w-3" />
<span>{repository.stats?.stars ?? 0}</span>
</div>
<div className="flex items-center gap-1">
<Download className="h-3 w-3" />
<span>{repository.stats?.downloads ?? 0}</span>
</div>
<div className="flex items-center gap-1">
<Package className="h-3 w-3" />
<span>{repository.stats?.plugins ?? 0} plugins</span>
</div>
</div>
</div>
</div>
);
}
function RepositoryDetails({ repository, onRemove }: { repository: RepositoryMetadata; onRemove?: (id: string) => void }) {
return (
<div className="overflow-y-auto rounded-lg border bg-card">
<div className="border-b p-6">
<div className="mb-4 flex items-start justify-between">
<div>
<h2 className="text-2xl font-semibold">{repository.name}</h2>
<p className="mt-1 text-muted-foreground">
{repository.description}
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" asChild>
<a
href={repository.urls.repository}
target="_blank"
rel="noopener noreferrer"
>
View Repository
</a>
</Button>
{repository.urls.git && (
<Button variant="outline" asChild>
<a
href={repository.urls.git}
target="_blank"
rel="noopener noreferrer"
>
View on GitHub
</a>
</Button>
)}
{onRemove && !repository.official && (
<Button
variant="ghost"
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => onRemove(repository.id)}
>
Remove Repository
</Button>
)}
</div>
</div>
<div className="flex items-center gap-4 text-sm">
<div className="flex items-center gap-1.5">
<Package className="h-4 w-4 text-muted-foreground" />
<span>{repository.stats?.plugins ?? 0} plugins</span>
</div>
<div className="flex items-center gap-1.5">
<Calendar className="h-4 w-4 text-muted-foreground" />
<span>Updated {formatDistanceToNow(new Date(repository.lastUpdated), { addSuffix: true })}</span>
</div>
</div>
</div>
<div className="p-6">
<Tabs defaultValue="overview" className="w-full">
<TabsList className="w-full">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="plugins">Plugins</TabsTrigger>
<TabsTrigger value="compatibility">Compatibility</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-6 mt-6">
{repository.assets?.banner && (
<div className="relative h-[200px] w-full overflow-hidden rounded-lg border">
<Image
src={repository.assets.banner}
alt={repository.name}
fill
className="object-cover"
/>
</div>
)}
<div className="rounded-lg border bg-card/50 p-4 shadow-sm">
<h4 className="mb-4 font-medium">Author</h4>
<div className="grid gap-2 text-sm">
<div>
<span className="text-muted-foreground">Name: </span>
<span>{repository.author.name}</span>
</div>
{repository.author.organization && (
<div>
<span className="text-muted-foreground">Organization: </span>
<span>{repository.author.organization}</span>
</div>
)}
{repository.author.url && (
<a
href={repository.author.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
View Profile
</a>
)}
</div>
</div>
<div className="rounded-lg border bg-card/50 p-4 shadow-sm">
<h4 className="mb-4 font-medium">Tags</h4>
<div className="flex flex-wrap gap-2">
{repository.tags.map((tag) => (
<Badge key={tag} variant="secondary">
{tag}
</Badge>
))}
</div>
</div>
</TabsContent>
<TabsContent value="plugins" className="mt-6">
<div className="rounded-lg border bg-card/50 p-4 shadow-sm">
<h4 className="mb-4 font-medium">Available Plugins</h4>
<p className="text-sm text-muted-foreground">
This repository contains {repository.stats?.plugins ?? 0} robot plugins.
</p>
</div>
</TabsContent>
<TabsContent value="compatibility" className="mt-6">
<div className="rounded-lg border bg-card/50 p-4 shadow-sm">
<h4 className="mb-4 font-medium">HRIStudio Compatibility</h4>
<div className="grid gap-2 text-sm">
<div>
<span className="text-muted-foreground">Minimum Version: </span>
<code className="rounded bg-muted px-1.5 py-0.5">
{repository.compatibility.hristudio.min}
</code>
</div>
{repository.compatibility.hristudio.recommended && (
<div>
<span className="text-muted-foreground">Recommended Version: </span>
<code className="rounded bg-muted px-1.5 py-0.5">
{repository.compatibility.hristudio.recommended}
</code>
</div>
)}
</div>
</div>
{repository.compatibility.ros2 && (
<div className="mt-4 rounded-lg border bg-card/50 p-4 shadow-sm">
<h4 className="mb-4 font-medium">ROS 2 Compatibility</h4>
<div className="grid gap-2 text-sm">
<div>
<span className="text-muted-foreground">Supported Distributions: </span>
<div className="flex flex-wrap gap-2 mt-1">
{repository.compatibility.ros2.distributions.map((dist) => (
<Badge key={dist} variant="secondary">
{dist}
</Badge>
))}
</div>
</div>
{repository.compatibility.ros2.recommended && (
<div>
<span className="text-muted-foreground">Recommended Distribution: </span>
<code className="rounded bg-muted px-1.5 py-0.5">
{repository.compatibility.ros2.recommended}
</code>
</div>
)}
</div>
</div>
)}
</TabsContent>
</Tabs>
</div>
</div>
);
}
export function RepositorySection({ repositories }: RepositorySectionProps) {
const router = useRouter();
const { toast } = useToast();
const [isRemoving, setIsRemoving] = useState(false);
const [selectedRepository, setSelectedRepository] = useState<RepositoryMetadata | null>(
repositories[0] ?? null
);
const utils = api.useUtils();
const removeRepository = api.pluginStore.removeRepository.useMutation({
onSuccess: () => {
toast({
title: "Success",
description: "Repository removed successfully",
});
// Invalidate all plugin store queries
utils.pluginStore.getRepositories.invalidate();
utils.pluginStore.getPlugins.invalidate();
utils.pluginStore.getInstalledPlugins.invalidate();
},
onError: (error) => {
console.error("Failed to remove repository:", error);
toast({
title: "Error",
description: error.message || "Failed to remove repository",
variant: "destructive",
});
},
});
const handleRemoveRepository = async (id: string) => {
if (isRemoving) return;
try {
setIsRemoving(true);
await removeRepository.mutateAsync({ id });
} finally {
setIsRemoving(false);
}
};
if (!repositories.length) {
return (
<div className="flex h-[calc(100vh-24rem)] items-center justify-center">
<p className="text-muted-foreground">No repositories added</p>
</div>
);
}
return (
<div className="grid h-[calc(100vh-24rem)] grid-cols-[400px_1fr] gap-8">
{/* Left Pane - Repository List */}
<div className="overflow-y-auto rounded-lg pr-4">
<div className="space-y-3">
{repositories.map((repository) => (
<RepositoryListItem
key={repository.id}
repository={repository}
isSelected={selectedRepository?.id === repository.id}
onSelect={() => setSelectedRepository(repository)}
onRemove={handleRemoveRepository}
/>
))}
</div>
</div>
{/* Right Pane - Repository Details */}
{selectedRepository && (
<RepositoryDetails
repository={selectedRepository}
onRemove={handleRemoveRepository}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,412 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { type RobotPlugin } from "~/lib/plugin-store/types";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { Bot, Download, Info, Zap, Battery, Scale, Ruler, Trash2 } from "lucide-react";
import Image from "next/image";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { api } from "~/trpc/react";
import { useToast } from "~/hooks/use-toast";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "~/components/ui/alert-dialog";
interface RobotDetailsProps {
robot: RobotPlugin;
isInstalled: boolean;
}
function RobotHeader({ robot, isInstalled }: RobotDetailsProps) {
const { toast } = useToast();
const utils = api.useUtils();
const [isProcessing, setIsProcessing] = useState(false);
const [showUninstallDialog, setShowUninstallDialog] = useState(false);
const installPlugin = api.pluginStore.installPlugin.useMutation({
onSuccess: () => {
toast({
title: "Success",
description: `${robot.name} installed successfully`,
});
utils.pluginStore.getInstalledPlugins.invalidate();
utils.pluginStore.getPlugins.invalidate();
},
onError: (error) => {
console.error("Failed to install plugin:", error);
toast({
title: "Error",
description: error.message || "Failed to install plugin",
variant: "destructive",
});
},
});
const uninstallPlugin = api.pluginStore.uninstallPlugin.useMutation({
onSuccess: () => {
toast({
title: "Success",
description: `${robot.name} uninstalled successfully`,
});
utils.pluginStore.getInstalledPlugins.invalidate();
utils.pluginStore.getPlugins.invalidate();
},
onError: (error) => {
console.error("Failed to uninstall plugin:", error);
toast({
title: "Error",
description: error.message || "Failed to uninstall plugin",
variant: "destructive",
});
},
});
const handleInstall = async () => {
if (isProcessing) return;
try {
setIsProcessing(true);
await installPlugin.mutateAsync({
robotId: robot.robotId,
repositoryId: "hristudio-official", // TODO: Get from context
});
} finally {
setIsProcessing(false);
}
};
const handleUninstall = async () => {
if (isProcessing) return;
try {
setIsProcessing(true);
await uninstallPlugin.mutateAsync({ robotId: robot.robotId });
} finally {
setIsProcessing(false);
setShowUninstallDialog(false);
}
};
return (
<div className="border-b p-6">
<div className="mb-4 flex items-start justify-between">
<div>
<h2 className="text-2xl font-semibold">{robot.name}</h2>
<p className="mt-1 text-muted-foreground">
{robot.description}
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" asChild>
<a
href={robot.documentation.mainUrl}
target="_blank"
rel="noopener noreferrer"
>
<Info className="mr-2 h-4 w-4" />
Documentation
</a>
</Button>
{isInstalled ? (
<AlertDialog open={showUninstallDialog} onOpenChange={setShowUninstallDialog}>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
Uninstall
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Uninstall Robot</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to uninstall {robot.name}? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleUninstall}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isProcessing ? "Uninstalling..." : "Uninstall"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
) : (
<Button
onClick={handleInstall}
disabled={isProcessing}
>
<Download className="mr-2 h-4 w-4" />
{isProcessing ? "Installing..." : "Install"}
</Button>
)}
</div>
</div>
<div className="flex items-center gap-4 text-sm">
<div className="flex items-center gap-1.5">
<Zap className="h-4 w-4 text-muted-foreground" />
<span>{robot.specs.maxSpeed}m/s</span>
</div>
<div className="flex items-center gap-1.5">
<Battery className="h-4 w-4 text-muted-foreground" />
<span>{robot.specs.batteryLife}h</span>
</div>
<div className="flex items-center gap-1.5">
<Scale className="h-4 w-4 text-muted-foreground" />
<span>{robot.specs.dimensions.weight}kg</span>
</div>
</div>
</div>
);
}
function RobotImages({ robot }: { robot: RobotPlugin }) {
const [showLeftFade, setShowLeftFade] = useState(false);
const [showRightFade, setShowRightFade] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
const checkScroll = () => {
const hasLeftScroll = el.scrollLeft > 0;
const hasRightScroll = el.scrollLeft < (el.scrollWidth - el.clientWidth);
setShowLeftFade(hasLeftScroll);
setShowRightFade(hasRightScroll);
};
// Check initial scroll
checkScroll();
// Add scroll listener
el.addEventListener('scroll', checkScroll);
// Add resize listener to handle window changes
window.addEventListener('resize', checkScroll);
return () => {
el.removeEventListener('scroll', checkScroll);
window.removeEventListener('resize', checkScroll);
};
}, []);
return (
<div className="relative">
<div ref={scrollRef} className="overflow-x-auto pb-4">
<div className="flex gap-4">
{/* Main Image */}
<div className="relative h-[300px] aspect-video shrink-0 overflow-hidden rounded-lg border bg-muted">
<Image
src={robot.assets.images.main}
alt={robot.name}
fill
className="object-cover"
/>
</div>
{/* Angle Images */}
{robot.assets.images.angles && (
<div className="flex gap-4">
{Object.entries(robot.assets.images.angles).map(([angle, url]) => url && (
<div
key={angle}
className="relative h-[300px] aspect-square shrink-0 overflow-hidden rounded-lg border bg-muted"
>
<Image
src={url}
alt={`${robot.name} - ${angle} view`}
fill
className="object-cover"
/>
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/50 to-transparent p-4">
<span className="text-xs font-medium text-white capitalize">
{angle} View
</span>
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* Fade indicators */}
{showLeftFade && (
<div className="pointer-events-none absolute inset-y-0 left-0 w-8 bg-gradient-to-r from-background to-transparent" />
)}
{showRightFade && (
<div className="pointer-events-none absolute inset-y-0 right-0 w-8 bg-gradient-to-l from-background to-transparent" />
)}
</div>
);
}
function RobotSpecs({ robot }: { robot: RobotPlugin }) {
return (
<div className="space-y-6">
<div className="rounded-lg border bg-card/50 p-4 shadow-sm">
<h4 className="mb-4 font-medium">Physical Specifications</h4>
<div className="grid gap-4 md:grid-cols-2">
<div className="flex items-center gap-2">
<Ruler className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">
{robot.specs.dimensions.length}m × {robot.specs.dimensions.width}m × {robot.specs.dimensions.height}m
</span>
</div>
<div className="flex items-center gap-2">
<Scale className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">{robot.specs.dimensions.weight}kg</span>
</div>
<div className="flex items-center gap-2">
<Zap className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">{robot.specs.maxSpeed}m/s</span>
</div>
<div className="flex items-center gap-2">
<Battery className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">{robot.specs.batteryLife}h</span>
</div>
</div>
</div>
<div className="rounded-lg border bg-card/50 p-4 shadow-sm">
<h4 className="mb-4 font-medium">Capabilities</h4>
<div className="flex flex-wrap gap-2">
{robot.specs.capabilities.map((capability) => (
<Badge key={capability} variant="secondary">
{capability}
</Badge>
))}
</div>
</div>
<div className="rounded-lg border bg-card/50 p-4 shadow-sm">
<h4 className="mb-4 font-medium">ROS 2 Configuration</h4>
<div className="grid gap-3 text-sm">
<div>
<span className="text-muted-foreground">Namespace: </span>
<code className="rounded bg-muted px-1.5 py-0.5">
{robot.ros2Config.namespace}
</code>
</div>
<div>
<span className="text-muted-foreground">Node Prefix: </span>
<code className="rounded bg-muted px-1.5 py-0.5">
{robot.ros2Config.nodePrefix}
</code>
</div>
<div className="grid gap-2">
<span className="text-muted-foreground">Default Topics:</span>
<div className="grid gap-1.5 pl-4">
{Object.entries(robot.ros2Config.defaultTopics).map(([name, topic]) => (
<div key={name}>
<span className="text-muted-foreground">{name}: </span>
<code className="rounded bg-muted px-1.5 py-0.5">{topic}</code>
</div>
))}
</div>
</div>
</div>
</div>
</div>
);
}
function RobotActions({ robot }: { robot: RobotPlugin }) {
return (
<div className="space-y-4">
{robot.actions.map((action) => (
<div key={action.actionId} className="rounded-lg border bg-card/50 p-4 shadow-sm">
<div className="mb-3 flex items-center justify-between">
<h4 className="font-medium">{action.title}</h4>
<Badge variant="secondary">{action.type}</Badge>
</div>
<p className="mb-4 text-sm text-muted-foreground">
{action.description}
</p>
<div className="grid gap-2">
<h5 className="text-sm font-medium text-muted-foreground">Parameters:</h5>
<div className="grid gap-2 pl-4">
{Object.entries(action.parameters.properties).map(([name, prop]) => (
<div key={name} className="text-sm">
<span className="font-medium">{prop.title}</span>
{prop.unit && (
<span className="text-muted-foreground"> ({prop.unit})</span>
)}
{prop.description && (
<p className="mt-0.5 text-muted-foreground">{prop.description}</p>
)}
</div>
))}
</div>
</div>
</div>
))}
</div>
);
}
export function RobotDetails({ robot, isInstalled }: RobotDetailsProps) {
return (
<>
<RobotHeader robot={robot} isInstalled={isInstalled} />
<div className="p-6">
<Tabs defaultValue="overview" className="w-full">
<TabsList className="w-full">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="specs">Specifications</TabsTrigger>
<TabsTrigger value="actions">Actions</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-6 mt-6">
<RobotImages robot={robot} />
<div className="rounded-lg border bg-card/50 p-4 shadow-sm">
<h4 className="mb-4 font-medium">Documentation</h4>
<div className="grid gap-2 text-sm">
<a
href={robot.documentation.mainUrl}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
User Manual
</a>
{robot.documentation.apiReference && (
<a
href={robot.documentation.apiReference}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
API Reference
</a>
)}
</div>
</div>
</TabsContent>
<TabsContent value="specs" className="mt-6">
<RobotSpecs robot={robot} />
</TabsContent>
<TabsContent value="actions" className="mt-6">
<RobotActions robot={robot} />
</TabsContent>
</Tabs>
</div>
</>
);
}

View File

@@ -0,0 +1,257 @@
"use client";
import { useState } from "react";
import { type RobotPlugin } from "~/lib/plugin-store/types";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { Bot, Download, Info, Zap, Battery, Scale, Ruler, Check, Trash2 } from "lucide-react";
import Image from "next/image";
import { cn } from "~/lib/utils";
import { api } from "~/trpc/react";
import { useToast } from "~/hooks/use-toast";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "~/components/ui/alert-dialog";
interface RobotGridProps {
plugins: RobotPlugin[];
installedPluginIds?: string[];
selectedRobotId?: string;
onSelectRobot: (robot: RobotPlugin) => void;
}
function RobotCard({
plugin,
isInstalled,
isSelected,
onSelect,
}: {
plugin: RobotPlugin;
isInstalled: boolean;
isSelected: boolean;
onSelect: () => void;
}) {
const { toast } = useToast();
const utils = api.useUtils();
const [isProcessing, setIsProcessing] = useState(false);
const [showUninstallDialog, setShowUninstallDialog] = useState(false);
const installPlugin = api.pluginStore.installPlugin.useMutation({
onSuccess: () => {
toast({
title: "Success",
description: `${plugin.name} installed successfully`,
});
utils.pluginStore.getInstalledPlugins.invalidate();
utils.pluginStore.getPlugins.invalidate();
},
onError: (error) => {
console.error("Failed to install plugin:", error);
toast({
title: "Error",
description: error.message || "Failed to install plugin",
variant: "destructive",
});
},
});
const uninstallPlugin = api.pluginStore.uninstallPlugin.useMutation({
onSuccess: () => {
toast({
title: "Success",
description: `${plugin.name} uninstalled successfully`,
});
utils.pluginStore.getInstalledPlugins.invalidate();
utils.pluginStore.getPlugins.invalidate();
},
onError: (error) => {
console.error("Failed to uninstall plugin:", error);
toast({
title: "Error",
description: error.message || "Failed to uninstall plugin",
variant: "destructive",
});
},
});
const handleInstall = async (e: React.MouseEvent) => {
e.stopPropagation();
if (isProcessing) return;
try {
setIsProcessing(true);
await installPlugin.mutateAsync({
robotId: plugin.robotId,
repositoryId: "hristudio-official", // TODO: Get from context
});
} finally {
setIsProcessing(false);
}
};
const handleUninstall = async () => {
if (isProcessing) return;
try {
setIsProcessing(true);
await uninstallPlugin.mutateAsync({ robotId: plugin.robotId });
} finally {
setIsProcessing(false);
setShowUninstallDialog(false);
}
};
return (
<div
className={cn(
"group relative flex cursor-pointer gap-3 rounded-lg border p-4 transition-all",
isSelected
? "border-primary bg-card ring-2 ring-primary/10"
: "hover:border-primary/50 hover:bg-accent/50"
)}
onClick={onSelect}
>
<div className="relative aspect-square h-20 shrink-0 overflow-hidden rounded-md border bg-muted">
{plugin.assets.logo ? (
<Image
src={plugin.assets.logo}
alt={plugin.name}
fill
className="object-contain p-2"
/>
) : plugin.assets.thumbnailUrl ? (
<Image
src={plugin.assets.thumbnailUrl}
alt={plugin.name}
fill
className="object-cover transition-transform group-hover:scale-105"
/>
) : (
<div className="flex h-full items-center justify-center">
<Bot className="h-10 w-10 text-muted-foreground/50" />
</div>
)}
</div>
<div className="flex flex-1 flex-col justify-between">
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<h3 className="line-clamp-1 font-semibold tracking-tight">{plugin.name}</h3>
<div className="flex items-center gap-2">
<Badge variant="secondary" className="shrink-0">
{plugin.platform}
</Badge>
{isInstalled && (
<Badge variant="default" className="shrink-0 bg-primary">
Installed
</Badge>
)}
</div>
</div>
<p className="line-clamp-2 text-sm text-muted-foreground">
{plugin.description}
</p>
</div>
<div className="mt-2 flex items-center justify-between">
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<Zap className="h-3 w-3" />
<span>{plugin.specs.maxSpeed}m/s</span>
</div>
<div className="flex items-center gap-1">
<Battery className="h-3 w-3" />
<span>{plugin.specs.batteryLife}h</span>
</div>
</div>
<div className="flex items-center gap-2">
{isInstalled ? (
<AlertDialog open={showUninstallDialog} onOpenChange={setShowUninstallDialog}>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
setShowUninstallDialog(true);
}}
>
<Trash2 className="h-4 w-4" />
<span className="ml-2">Uninstall</span>
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Uninstall Robot</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to uninstall {plugin.name}? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleUninstall}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isProcessing ? "Uninstalling..." : "Uninstall"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
) : (
<Button
size="sm"
onClick={handleInstall}
disabled={isProcessing}
>
{isProcessing ? (
"Installing..."
) : (
<>
<Download className="mr-2 h-4 w-4" />
Install
</>
)}
</Button>
)}
</div>
</div>
</div>
</div>
);
}
export function RobotGrid({ plugins, installedPluginIds = [], selectedRobotId, onSelectRobot }: RobotGridProps) {
if (!plugins.length) {
return (
<div className="flex h-[400px] items-center justify-center">
<div className="text-center">
<Bot className="mx-auto h-16 w-16 text-muted-foreground/50" />
<h3 className="mt-4 text-lg font-medium">No Robots Found</h3>
<p className="mt-2 text-sm text-muted-foreground">
Try adjusting your filters or adding more repositories.
</p>
</div>
</div>
);
}
return (
<div className="space-y-4">
{plugins.map((plugin) => (
<RobotCard
key={plugin.robotId}
plugin={plugin}
isInstalled={installedPluginIds.includes(plugin.robotId)}
isSelected={plugin.robotId === selectedRobotId}
onSelect={() => onSelectRobot(plugin)}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,439 @@
"use client";
import { type RobotPlugin } from "~/lib/plugin-store/types";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { Bot, Download, Info, Zap, Battery, Scale, Ruler } from "lucide-react";
import Image from "next/image";
import { cn } from "~/lib/utils";
import { useState, useRef, useEffect } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { PageHeader } from "~/components/layout/page-header";
import { PageContent } from "~/components/layout/page-content";
import { api } from "~/trpc/react";
import { useToast } from "~/hooks/use-toast";
import { useRouter } from "next/navigation";
interface RobotListProps {
plugins: RobotPlugin[];
}
function RobotListItem({
plugin,
isSelected,
onSelect
}: {
plugin: RobotPlugin;
isSelected: boolean;
onSelect: () => void;
}) {
return (
<div
className={cn(
"group relative flex cursor-pointer gap-3 rounded-lg border p-4 transition-all",
isSelected
? "border-primary bg-card ring-2 ring-primary/10"
: "hover:border-primary/50 hover:bg-accent/50"
)}
onClick={onSelect}
>
<div className="relative aspect-square h-20 shrink-0 overflow-hidden rounded-md border bg-muted">
{plugin.assets.logo ? (
<Image
src={plugin.assets.logo}
alt={plugin.name}
fill
className="object-contain p-2"
/>
) : plugin.assets.thumbnailUrl ? (
<Image
src={plugin.assets.thumbnailUrl}
alt={plugin.name}
fill
className="object-cover transition-transform group-hover:scale-105"
/>
) : (
<div className="flex h-full items-center justify-center">
<Bot className="h-10 w-10 text-muted-foreground/50" />
</div>
)}
</div>
<div className="flex-1 space-y-2">
<div className="flex items-center justify-between gap-2">
<h3 className="line-clamp-1 font-semibold tracking-tight">{plugin.name}</h3>
<Badge variant="secondary" className="shrink-0">
{plugin.platform}
</Badge>
</div>
<p className="line-clamp-2 text-sm text-muted-foreground">
{plugin.description}
</p>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<Zap className="h-3 w-3" />
<span>{plugin.specs.maxSpeed}m/s</span>
</div>
<div className="flex items-center gap-1">
<Battery className="h-3 w-3" />
<span>{plugin.specs.batteryLife}h</span>
</div>
</div>
</div>
</div>
);
}
function RobotHeader({ robot }: { robot: RobotPlugin }) {
const router = useRouter();
const { toast } = useToast();
const [isInstalling, setIsInstalling] = useState(false);
const utils = api.useUtils();
const installPlugin = api.pluginStore.installPlugin.useMutation({
onSuccess: () => {
toast({
title: "Success",
description: `${robot.name} installed successfully`,
});
// Invalidate both queries to refresh the data
utils.pluginStore.getInstalledPlugins.invalidate();
utils.pluginStore.getPlugins.invalidate();
},
onError: (error) => {
console.error("Failed to install plugin:", error);
toast({
title: "Error",
description: error.message || "Failed to install plugin",
variant: "destructive",
});
},
});
const handleInstall = async () => {
if (isInstalling) return;
try {
setIsInstalling(true);
await installPlugin.mutateAsync({
robotId: robot.robotId,
repositoryId: "hristudio-official", // TODO: Get from context
});
} finally {
setIsInstalling(false);
}
};
return (
<div className="border-b p-6">
<div className="mb-4 flex items-start justify-between">
<div>
<h2 className="text-2xl font-semibold">{robot.name}</h2>
<p className="mt-1 text-muted-foreground">
{robot.description}
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" asChild>
<a
href={robot.documentation.mainUrl}
target="_blank"
rel="noopener noreferrer"
>
<Info className="mr-2 h-4 w-4" />
Documentation
</a>
</Button>
<Button
onClick={handleInstall}
disabled={isInstalling || installPlugin.isLoading}
>
<Download className="mr-2 h-4 w-4" />
{isInstalling || installPlugin.isLoading ? "Installing..." : "Install"}
</Button>
</div>
</div>
<div className="flex items-center gap-4 text-sm">
<div className="flex items-center gap-1.5">
<Zap className="h-4 w-4 text-muted-foreground" />
<span>{robot.specs.maxSpeed}m/s</span>
</div>
<div className="flex items-center gap-1.5">
<Battery className="h-4 w-4 text-muted-foreground" />
<span>{robot.specs.batteryLife}h</span>
</div>
<div className="flex items-center gap-1.5">
<Scale className="h-4 w-4 text-muted-foreground" />
<span>{robot.specs.dimensions.weight}kg</span>
</div>
</div>
</div>
);
}
function RobotImages({ robot }: { robot: RobotPlugin }) {
const [showLeftFade, setShowLeftFade] = useState(false);
const [showRightFade, setShowRightFade] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
const checkScroll = () => {
const hasLeftScroll = el.scrollLeft > 0;
const hasRightScroll = el.scrollLeft < (el.scrollWidth - el.clientWidth);
setShowLeftFade(hasLeftScroll);
setShowRightFade(hasRightScroll);
};
// Check initial scroll
checkScroll();
// Add scroll listener
el.addEventListener('scroll', checkScroll);
// Add resize listener to handle window changes
window.addEventListener('resize', checkScroll);
return () => {
el.removeEventListener('scroll', checkScroll);
window.removeEventListener('resize', checkScroll);
};
}, []);
return (
<div className="relative">
<div ref={scrollRef} className="overflow-x-auto pb-4">
<div className="flex gap-4">
{/* Main Image */}
<div className="relative h-[300px] aspect-video shrink-0 overflow-hidden rounded-lg border bg-muted">
<Image
src={robot.assets.images.main}
alt={robot.name}
fill
className="object-cover"
/>
</div>
{/* Angle Images */}
{robot.assets.images.angles && (
<div className="flex gap-4">
{Object.entries(robot.assets.images.angles).map(([angle, url]) => url && (
<div
key={angle}
className="relative h-[300px] aspect-square shrink-0 overflow-hidden rounded-lg border bg-muted"
>
<Image
src={url}
alt={`${robot.name} - ${angle} view`}
fill
className="object-cover"
/>
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/50 to-transparent p-4">
<span className="text-xs font-medium text-white capitalize">
{angle} View
</span>
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* Fade indicators */}
{showLeftFade && (
<div className="pointer-events-none absolute inset-y-0 left-0 w-8 bg-gradient-to-r from-background to-transparent" />
)}
{showRightFade && (
<div className="pointer-events-none absolute inset-y-0 right-0 w-8 bg-gradient-to-l from-background to-transparent" />
)}
</div>
);
}
function RobotSpecs({ robot }: { robot: RobotPlugin }) {
return (
<div className="space-y-6">
<div className="rounded-lg border bg-card/50 p-4 shadow-sm">
<h4 className="mb-4 font-medium">Physical Specifications</h4>
<div className="grid gap-4 md:grid-cols-2">
<div className="flex items-center gap-2">
<Ruler className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">
{robot.specs.dimensions.length}m × {robot.specs.dimensions.width}m × {robot.specs.dimensions.height}m
</span>
</div>
<div className="flex items-center gap-2">
<Scale className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">{robot.specs.dimensions.weight}kg</span>
</div>
<div className="flex items-center gap-2">
<Zap className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">{robot.specs.maxSpeed}m/s</span>
</div>
<div className="flex items-center gap-2">
<Battery className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">{robot.specs.batteryLife}h</span>
</div>
</div>
</div>
<div className="rounded-lg border bg-card/50 p-4 shadow-sm">
<h4 className="mb-4 font-medium">Capabilities</h4>
<div className="flex flex-wrap gap-2">
{robot.specs.capabilities.map((capability) => (
<Badge key={capability} variant="secondary">
{capability}
</Badge>
))}
</div>
</div>
<div className="rounded-lg border bg-card/50 p-4 shadow-sm">
<h4 className="mb-4 font-medium">ROS 2 Configuration</h4>
<div className="grid gap-3 text-sm">
<div>
<span className="text-muted-foreground">Namespace: </span>
<code className="rounded bg-muted px-1.5 py-0.5">
{robot.ros2Config.namespace}
</code>
</div>
<div>
<span className="text-muted-foreground">Node Prefix: </span>
<code className="rounded bg-muted px-1.5 py-0.5">
{robot.ros2Config.nodePrefix}
</code>
</div>
<div className="grid gap-2">
<span className="text-muted-foreground">Default Topics:</span>
<div className="grid gap-1.5 pl-4">
{Object.entries(robot.ros2Config.defaultTopics).map(([name, topic]) => (
<div key={name}>
<span className="text-muted-foreground">{name}: </span>
<code className="rounded bg-muted px-1.5 py-0.5">{topic}</code>
</div>
))}
</div>
</div>
</div>
</div>
</div>
);
}
function RobotActions({ robot }: { robot: RobotPlugin }) {
return (
<div className="space-y-4">
{robot.actions.map((action) => (
<div key={action.actionId} className="rounded-lg border bg-card/50 p-4 shadow-sm">
<div className="mb-3 flex items-center justify-between">
<h4 className="font-medium">{action.title}</h4>
<Badge variant="secondary">{action.type}</Badge>
</div>
<p className="mb-4 text-sm text-muted-foreground">
{action.description}
</p>
<div className="grid gap-2">
<h5 className="text-sm font-medium text-muted-foreground">Parameters:</h5>
<div className="grid gap-2 pl-4">
{Object.entries(action.parameters.properties).map(([name, prop]) => (
<div key={name} className="text-sm">
<span className="font-medium">{prop.title}</span>
{prop.unit && (
<span className="text-muted-foreground"> ({prop.unit})</span>
)}
{prop.description && (
<p className="mt-0.5 text-muted-foreground">{prop.description}</p>
)}
</div>
))}
</div>
</div>
</div>
))}
</div>
);
}
export function RobotList({ plugins }: RobotListProps) {
const [selectedRobot, setSelectedRobot] = useState<RobotPlugin | null>(plugins[0] ?? null);
if (!plugins.length) {
return (
<div className="flex h-[calc(100vh-24rem)] items-center justify-center">
<p className="text-muted-foreground">No robots available</p>
</div>
);
}
return (
<div className="grid h-[calc(100vh-24rem)] grid-cols-[400px_1fr] gap-8">
{/* Left Pane - Robot List */}
<div className="overflow-y-auto rounded-lg pr-4">
<div className="space-y-3">
{plugins.map((plugin) => (
<RobotListItem
key={plugin.robotId}
plugin={plugin}
isSelected={selectedRobot?.robotId === plugin.robotId}
onSelect={() => setSelectedRobot(plugin)}
/>
))}
</div>
</div>
{/* Right Pane - Robot Details */}
{selectedRobot && (
<div className="overflow-y-auto rounded-lg border bg-card">
<RobotHeader robot={selectedRobot} />
<div className="p-6">
<Tabs defaultValue="overview" className="w-full">
<TabsList className="w-full">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="specs">Specifications</TabsTrigger>
<TabsTrigger value="actions">Actions</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-6 mt-6">
<RobotImages robot={selectedRobot} />
<div className="rounded-lg border bg-card/50 p-4 shadow-sm">
<h4 className="mb-4 font-medium">Documentation</h4>
<div className="grid gap-2 text-sm">
<a
href={selectedRobot.documentation.mainUrl}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
User Manual
</a>
{selectedRobot.documentation.apiReference && (
<a
href={selectedRobot.documentation.apiReference}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
API Reference
</a>
)}
</div>
</div>
</TabsContent>
<TabsContent value="specs" className="mt-6">
<RobotSpecs robot={selectedRobot} />
</TabsContent>
<TabsContent value="actions" className="mt-6">
<RobotActions robot={selectedRobot} />
</TabsContent>
</Tabs>
</div>
</div>
)}
</div>
);
}

View File

@@ -9,7 +9,7 @@ const Card = React.forwardRef<
<div
ref={ref}
className={cn(
"rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--card-level-1))] shadow-sm",
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
@@ -30,22 +30,25 @@ const CardHeader = React.forwardRef<
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<div
<h3
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
@@ -73,4 +76,4 @@ const CardFooter = React.forwardRef<
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -358,7 +358,7 @@ const SidebarHeader = React.forwardRef<
<div
ref={ref}
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
@@ -373,7 +373,7 @@ const SidebarFooter = React.forwardRef<
<div
ref={ref}
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)