mirror of
https://github.com/soconnor0919/hristudio.git
synced 2025-12-11 22:54:45 -05:00
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:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
73
src/components/home/cta-section.tsx
Normal file
73
src/components/home/cta-section.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
src/components/home/features-section.tsx
Normal file
67
src/components/home/features-section.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
101
src/components/home/hero-section.tsx
Normal file
101
src/components/home/hero-section.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
24
src/components/providers/index.tsx
Normal file
24
src/components/providers/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
50
src/components/providers/plugin-store-provider.tsx
Normal file
50
src/components/providers/plugin-store-provider.tsx
Normal 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;
|
||||
}
|
||||
123
src/components/store/add-repository-dialog.tsx
Normal file
123
src/components/store/add-repository-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
254
src/components/store/plugin-browser.tsx
Normal file
254
src/components/store/plugin-browser.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
98
src/components/store/repository-card.tsx
Normal file
98
src/components/store/repository-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
339
src/components/store/repository-section.tsx
Normal file
339
src/components/store/repository-section.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
412
src/components/store/robot-details.tsx
Normal file
412
src/components/store/robot-details.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
257
src/components/store/robot-grid.tsx
Normal file
257
src/components/store/robot-grid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
439
src/components/store/robot-list.tsx
Normal file
439
src/components/store/robot-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user