Files
hristudio/src/components/store/robot-list.tsx
Sean O'Connor ab08c1b724 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
2025-02-28 11:10:56 -05:00

439 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}