feat: Enhance plugin store and experiment design infrastructure

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

View File

@@ -0,0 +1,88 @@
import { z } from "zod";
import { NextResponse } from "next/server";
import { getPlugin } from "~/lib/plugin-store/service";
import { db } from "~/server/db";
import { installedPlugins } from "~/server/db/schema/store";
import { eq } from "drizzle-orm";
// POST /api/plugins/install - Install a plugin
export async function POST(req: Request) {
try {
const body = await req.json();
const schema = z.object({
robotId: z.string(),
repositoryId: z.string(),
});
const { robotId, repositoryId } = schema.parse(body);
// Get plugin details
const plugin = await getPlugin(robotId);
if (!plugin) {
return NextResponse.json(
{ error: "Plugin not found" },
{ status: 404 }
);
}
// Check if already installed
const existing = await db.query.installedPlugins.findFirst({
where: eq(installedPlugins.robotId, robotId),
});
if (existing) {
return NextResponse.json(
{ error: "Plugin already installed" },
{ status: 400 }
);
}
// Install plugin
const installed = await db.insert(installedPlugins).values({
robotId,
repositoryId,
name: plugin.name,
version: plugin.version,
enabled: true,
config: {},
}).returning();
return NextResponse.json(installed[0]);
} catch (error) {
console.error("Failed to install plugin:", error);
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: "Invalid request body", details: error.errors },
{ status: 400 }
);
}
return NextResponse.json(
{ error: "Failed to install plugin" },
{ status: 500 }
);
}
}
// DELETE /api/plugins/install - Uninstall a plugin
export async function DELETE(req: Request) {
try {
const url = new URL(req.url);
const robotId = url.searchParams.get("robotId");
if (!robotId) {
return NextResponse.json(
{ error: "Robot ID is required" },
{ status: 400 }
);
}
await db.delete(installedPlugins).where(eq(installedPlugins.robotId, robotId));
return NextResponse.json({ success: true });
} catch (error) {
console.error("Failed to uninstall plugin:", error);
return NextResponse.json(
{ error: "Failed to uninstall plugin" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,41 @@
"use client";
import { ExperimentDesigner } from "~/components/experiments/experiment-designer";
import { PageContent } from "~/components/layout/page-content";
import { PageHeader } from "~/components/layout/page-header";
import { Button } from "~/components/ui/button";
import { PanelLeft } from "lucide-react";
import { useSidebar } from "~/components/ui/sidebar";
import { cn } from "~/lib/utils";
export default function ExperimentDesignerPage() {
const { state, setOpen } = useSidebar();
return (
<div className="flex h-[calc(100vh-4rem)] flex-col">
<PageHeader className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold">Experiment Designer</h1>
<p className="text-muted-foreground">
Design your experiment workflow by dragging and connecting actions
</p>
</div>
<Button
variant="outline"
size="icon"
onClick={() => setOpen(state === "expanded" ? false : true)}
>
<PanelLeft className="h-4 w-4" />
</Button>
</PageHeader>
<PageContent className={cn(
"flex-1 overflow-hidden p-0",
// Adjust margin when app sidebar is collapsed
state === "collapsed" && "ml-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]",
state === "expanded" && "ml-[calc(var(--sidebar-width))]"
)}>
<ExperimentDesigner />
</PageContent>
</div>
);
}

View File

@@ -9,6 +9,7 @@ import { AppSidebar } from "~/components/navigation/app-sidebar"
import { Header } from "~/components/navigation/header"
import { SidebarProvider } from "~/components/ui/sidebar"
import { StudyProvider } from "~/components/providers/study-provider"
import { PluginStoreProvider } from "~/components/providers/plugin-store-provider"
import { PageTransition } from "~/components/layout/page-transition"
export default function DashboardLayout({
@@ -34,8 +35,8 @@ export default function DashboardLayout({
}, [status, router])
useEffect(() => {
// Only redirect if we've loaded studies and user has none
if (!isLoadingStudies && studies && studies.length === 0) {
// Only redirect if we've loaded studies and user has none, and we're not already on onboarding
if (!isLoadingStudies && studies && studies.length === 0 && !window.location.pathname.includes("/onboarding")) {
router.replace("/onboarding")
}
}, [studies, isLoadingStudies, router])
@@ -57,19 +58,47 @@ export default function DashboardLayout({
return (
<SidebarProvider>
<StudyProvider>
<div className="flex h-full min-h-screen w-full">
<AppSidebar />
<div className="flex w-0 flex-1 flex-col">
<Header />
<main className="flex-1 overflow-auto p-4">
<PageTransition>
{children}
</PageTransition>
</main>
<PluginStoreProvider>
<StudyProvider>
<div className="flex h-full min-h-screen w-full bg-muted/40 dark:bg-background relative">
{/* Background Elements */}
<div className="pointer-events-none fixed inset-0 z-0">
{/* Base Gradient */}
<div className="absolute inset-0 bg-gradient-to-b from-background via-primary/10 to-background" />
{/* Gradient Orb */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="absolute h-[1200px] w-[1200px] left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
<div className="absolute inset-0 rounded-full bg-gradient-to-r from-primary/30 via-secondary/30 to-background opacity-60 blur-3xl dark:opacity-40 animate-gradient" />
</div>
</div>
{/* Dotted Pattern */}
<div
className="absolute inset-0 opacity-[0.35] dark:opacity-[0.15]"
style={{
backgroundImage: `
radial-gradient(circle at 1px 1px, hsl(var(--primary)/0.5) 1px, transparent 0)
`,
backgroundSize: '32px 32px',
maskImage: 'linear-gradient(to bottom, transparent, black 10%, black 90%, transparent)',
}}
/>
</div>
{/* Content */}
<AppSidebar className="z-20" />
<div className="flex w-0 flex-1 flex-col z-10">
<Header/>
<main className="flex-1 overflow-auto p-4">
<PageTransition>
{children}
</PageTransition>
</main>
</div>
</div>
</div>
</StudyProvider>
</StudyProvider>
</PluginStoreProvider>
</SidebarProvider>
)
}

View File

@@ -0,0 +1,32 @@
import { api } from "~/trpc/server";
import { PluginBrowser } from "~/components/store/plugin-browser";
import { PageHeader } from "~/components/layout/page-header";
import { PageContent } from "~/components/layout/page-content";
import { AddRepositoryDialog } from "~/components/store/add-repository-dialog";
import { getCaller } from "~/trpc/server";
export default async function StorePage() {
// Fetch both plugins and repositories using tRPC
const caller = await getCaller();
const [plugins, repositories] = await Promise.all([
caller.pluginStore.getPlugins(),
caller.pluginStore.getRepositories(),
]);
return (
<>
<PageHeader
title="Robot Store"
description="Browse and manage robot plugins"
>
<AddRepositoryDialog />
</PageHeader>
<PageContent>
<PluginBrowser
repositories={repositories}
initialPlugins={plugins}
/>
</PageContent>
</>
);
}

View File

@@ -1,4 +1,91 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 200 30% 97%;
--foreground: 200 50% 20%;
--card: 0 0% 100%;
--card-foreground: 200 50% 20%;
--popover: 0 0% 100%;
--popover-foreground: 200 50% 20%;
--primary: 200 85% 45%;
--primary-foreground: 0 0% 100%;
--secondary: 200 30% 96%;
--secondary-foreground: 200 50% 20%;
--muted: 200 30% 96%;
--muted-foreground: 200 30% 40%;
--accent: 200 85% 45%;
--accent-foreground: 0 0% 100%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 100%;
--border: 200 30% 90%;
--input: 200 30% 90%;
--ring: 200 85% 45%;
--radius: 0.5rem;
--chart-1: 200 85% 45%;
--chart-2: 142 71% 45%;
--chart-3: 217 91% 60%;
--chart-4: 47 95% 57%;
--chart-5: 0 84% 60%;
/* Sidebar specific colors */
--sidebar-background: 0 0% 100%;
--sidebar-foreground: 200 50% 20%;
--sidebar-muted: 200 30% 40%;
--sidebar-muted-foreground: 200 30% 40%;
--sidebar-accent: 200 30% 96%;
--sidebar-accent-foreground: 200 50% 20%;
--sidebar-border: 200 30% 90%;
--sidebar-ring: 200 85% 45%;
--sidebar-hover: 200 40% 96%;
--sidebar-active: var(--primary);
--sidebar-active-foreground: var(--primary-foreground);
}
@media (prefers-color-scheme: dark) {
:root {
--background: 200 30% 8%;
--foreground: 200 20% 96%;
--card: 200 25% 15%;
--card-foreground: 200 15% 85%;
--popover: 200 50% 8%;
--popover-foreground: 200 20% 96%;
--primary: 200 70% 40%;
--primary-foreground: 0 0% 100%;
--secondary: 200 30% 20%;
--secondary-foreground: 200 20% 96%;
--muted: 200 30% 20%;
--muted-foreground: 200 30% 65%;
--accent: 200 70% 40%;
--accent-foreground: 0 0% 100%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 100%;
--border: 200 30% 20%;
--input: 200 30% 20%;
--ring: 200 70% 40%;
--chart-1: 200 70% 40%;
--chart-2: 142 71% 45%;
--chart-3: 217 91% 60%;
--chart-4: 47 95% 57%;
--chart-5: 0 84% 60%;
/* Sidebar specific colors - dark mode */
--sidebar-background: 200 30% 12%;
--sidebar-foreground: 200 20% 96%;
--sidebar-muted: 200 30% 65%;
--sidebar-muted-foreground: 200 30% 65%;
--sidebar-accent: 200 30% 20%;
--sidebar-accent-foreground: 200 20% 96%;
--sidebar-border: 200 30% 20%;
--sidebar-ring: 200 70% 40%;
--sidebar-hover: 200 25% 20%;
--sidebar-active: var(--primary);
--sidebar-active-foreground: var(--primary-foreground);
}
}
.auth-gradient {
@apply relative bg-background;
}
@@ -18,4 +105,159 @@
.auth-input {
@apply h-10 bg-background/50 backdrop-blur supports-[backdrop-filter]:bg-background/30;
}
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
font-feature-settings:
"rlig" 1,
"calt" 1;
}
}
/* Sidebar and Header shared styles */
[data-sidebar="sidebar"],
[data-nav="header"] {
@apply relative isolate rounded-lg border-[hsl(var(--sidebar-border))] bg-transparent;
}
[data-sidebar="sidebar"]::before,
[data-nav="header"]::before {
@apply absolute inset-0 -z-10 rounded-lg backdrop-blur-2xl content-[''];
}
/* Light mode adjustments */
:root [data-sidebar="sidebar"]::before,
:root [data-nav="header"]::before {
@apply bg-white/50;
}
/* Dark mode adjustments */
@media (prefers-color-scheme: dark) {
:root [data-sidebar="sidebar"]::before,
:root [data-nav="header"]::before {
@apply bg-black/30;
}
}
[data-sidebar="sidebar"] {
@apply border-r p-2 text-[hsl(var(--sidebar-foreground))];
}
/* Fix collapsed sidebar spacing */
[data-sidebar="sidebar"][data-collapsible="icon"] {
@apply p-2;
}
[data-sidebar="sidebar"][data-collapsible="icon"] [data-sidebar="menu"] {
@apply p-0;
}
/* Fix study selector and user bar in collapsed mode */
[data-sidebar="sidebar"][data-collapsible="icon"] [data-sidebar="header"],
[data-sidebar="sidebar"][data-collapsible="icon"] [data-sidebar="footer"] {
@apply p-0;
}
[data-sidebar="sidebar"][data-collapsible="icon"]
[data-sidebar="header"]
[data-sidebar="menu-button"],
[data-sidebar="sidebar"][data-collapsible="icon"]
[data-sidebar="footer"]
[data-sidebar="menu-button"] {
@apply !h-8 !w-8 !p-0;
}
[data-sidebar="sidebar"][data-collapsible="icon"]
[data-sidebar="header"]
[data-sidebar="menu-button"]
> div,
[data-sidebar="sidebar"][data-collapsible="icon"]
[data-sidebar="footer"]
[data-sidebar="menu-button"]
> div {
@apply flex !h-8 !w-8 items-center justify-center !p-0;
}
[data-sidebar="sidebar"][data-collapsible="icon"]
[data-sidebar="header"]
[data-sidebar="menu-button"]
[role="img"],
[data-sidebar="sidebar"][data-collapsible="icon"]
[data-sidebar="footer"]
[data-sidebar="menu-button"]
[role="img"] {
@apply !h-8 !w-8;
}
/* Regular menu button styles */
[data-sidebar="menu-button"] {
@apply mt-2 rounded-lg px-2 py-2.5 text-[hsl(var(--sidebar-foreground))] transition-all duration-200 first:mt-0 hover:bg-[hsl(var(--sidebar-hover))] hover:text-[hsl(var(--sidebar-active))];
}
[data-sidebar="menu-button"][data-active="true"] {
@apply bg-[hsl(var(--sidebar-active))]/10 font-medium text-[hsl(var(--sidebar-active))] ring-1 ring-inset ring-[hsl(var(--sidebar-active))]/20 hover:bg-[hsl(var(--sidebar-active))]/15 hover:ring-[hsl(var(--sidebar-active))]/30;
}
[data-sidebar="group-label"] {
@apply text-[hsl(var(--sidebar-muted))];
}
[data-sidebar="menu-action"],
[data-sidebar="group-action"] {
@apply rounded-lg px-2 py-2.5 text-[hsl(var(--sidebar-muted))] transition-all duration-200 hover:bg-[hsl(var(--sidebar-hover))] hover:text-[hsl(var(--sidebar-active))];
}
/* Card elevation utilities */
.card-level-1 {
@apply bg-[hsl(var(--card))] shadow-sm transition-shadow duration-200 hover:shadow;
}
.card-level-2 {
@apply bg-[hsl(var(--card))] shadow transition-shadow duration-200 hover:shadow-md;
}
.card-level-3 {
@apply bg-[hsl(var(--card))] shadow-md transition-shadow duration-200 hover:shadow-lg;
}
/* Gradient Animations */
@keyframes gradient-move {
0% {
transform: scale(1) rotate(0deg);
}
25% {
transform: scale(1.05) rotate(90deg);
}
50% {
transform: scale(0.95) rotate(180deg);
}
75% {
transform: scale(1.05) rotate(270deg);
}
100% {
transform: scale(1) rotate(360deg);
}
}
.animate-gradient {
animation: gradient-move 30s ease-in-out infinite;
transform-origin: center;
will-change: transform;
}
@layer utilities {
.step {
counter-increment: step;
}
.step:before {
@apply absolute inline-flex h-9 w-9 items-center justify-center rounded-full bg-muted text-center -indent-px font-mono text-base font-medium;
@apply ml-[-50px] mt-[-4px];
content: counter(step);
}
}

View File

@@ -1,4 +1,4 @@
import "~/styles/globals.css";
import "./globals.css";
import { GeistSans } from 'geist/font/sans';
import { headers } from "next/headers";

View File

@@ -1,12 +1,14 @@
import { getServerAuthSession } from "~/server/auth";
import { Button } from "~/components/ui/button";
import Link from "next/link";
import { BotIcon, ArrowRight, Sparkles, Brain, Microscope } from "lucide-react";
import { Logo } from "~/components/logo";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { HeroSection } from "~/components/home/hero-section";
import { FeaturesSection } from "~/components/home/features-section";
import { CTASection } from "~/components/home/cta-section";
export default async function Home() {
const session = await getServerAuthSession();
const isLoggedIn = !!session;
return (
<div className="min-h-screen bg-background relative">
@@ -41,131 +43,28 @@ export default async function Home() {
</div>
</nav>
{/* Hero Section */}
<section className="container mx-auto px-4 py-24">
<div className="grid lg:grid-cols-2 gap-12 items-center">
<div className="space-y-6">
<div 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>
</div>
<h1 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
</h1>
<p className="text-xl text-muted-foreground">
A comprehensive platform for designing, executing, and analyzing Wizard-of-Oz experiments in human-robot interaction studies.
</p>
<div className="flex flex-col sm:flex-row gap-4 pt-4">
{!session ? (
<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>
</div>
</div>
<div 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>
</div>
{/* Content Sections */}
<div className="relative">
<HeroSection isLoggedIn={isLoggedIn} />
{/* Dotted pattern for content sections */}
<div className="relative">
<div
className="absolute inset-0 pointer-events-none opacity-30"
style={{
backgroundImage: `
radial-gradient(circle at 1px 1px, hsl(var(--muted-foreground)) 1px, transparent 0),
linear-gradient(to bottom, transparent, hsl(var(--background)))
`,
backgroundSize: '32px 32px, 100% 100%',
maskImage: 'linear-gradient(to bottom, transparent, black 10%, black 90%, transparent)',
}}
/>
<FeaturesSection />
<CTASection isLoggedIn={isLoggedIn} />
</div>
</section>
{/* Features Section */}
<section className="container mx-auto px-4 py-24 space-y-12">
<div 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>
</div>
<div className="grid md:grid-cols-3 gap-8">
<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">
<Sparkles className="size-6 text-primary" />
</div>
<CardTitle>Visual Experiment Design</CardTitle>
<CardDescription>
Create and configure experiments using an intuitive drag-and-drop interface without extensive coding.
</CardDescription>
</CardHeader>
</Card>
<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">
<Brain className="size-6 text-primary" />
</div>
<CardTitle>Real-time Control</CardTitle>
<CardDescription>
Execute experiments with synchronized views for wizards and observers, enabling seamless collaboration.
</CardDescription>
</CardHeader>
</Card>
<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">
<Microscope className="size-6 text-primary" />
</div>
<CardTitle>Comprehensive Analysis</CardTitle>
<CardDescription>
Record, playback, and analyze experimental data with built-in annotation and export tools.
</CardDescription>
</CardHeader>
</Card>
</div>
</section>
{/* CTA Section */}
<section className="container mx-auto px-4 py-24">
<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">
<BotIcon className="size-12 mb-4" />
<h2 className="text-3xl font-bold tracking-tight">
Ready to Transform Your Research?
</h2>
<p className="text-primary-foreground/90 max-w-[600px]">
Join the growing community of researchers using HRIStudio to advance human-robot interaction studies.
</p>
{!session ? (
<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>
)}
</CardContent>
</Card>
</section>
</div>
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,48 +0,0 @@
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
export const env = createEnv({
/**
* Specify your server-side environment variables schema here. This way you can ensure the app
* isn't built with invalid env vars.
*/
server: {
AUTH_SECRET:
process.env.NODE_ENV === "production"
? z.string()
: z.string().optional(),
DATABASE_URL: z.string().url(),
NODE_ENV: z
.enum(["development", "test", "production"])
.default("development"),
},
/**
* Specify your client-side environment variables schema here. This way you can ensure the app
* isn't built with invalid env vars. To expose them to the client, prefix them with
* `NEXT_PUBLIC_`.
*/
client: {
// NEXT_PUBLIC_CLIENTVAR: z.string(),
},
/**
* You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g.
* middlewares) or client-side so we need to destruct manually.
*/
runtimeEnv: {
AUTH_SECRET: process.env.AUTH_SECRET,
DATABASE_URL: process.env.DATABASE_URL,
NODE_ENV: process.env.NODE_ENV,
},
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially
* useful for Docker builds.
*/
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
/**
* Makes it so that empty strings are treated as undefined. `SOME_VAR: z.string()` and
* `SOME_VAR=''` will throw an error.
*/
emptyStringAsUndefined: true,
});

View File

@@ -1,16 +1,50 @@
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
export const env = createEnv({
server: {
// Node environment
NODE_ENV: z.enum(["development", "test", "production"]),
// Database configuration
DATABASE_URL: z.string().url(),
STORAGE_TYPE: z.enum(["s3", "minio", "local"]).default("minio"),
// ... other server-side env vars
// Authentication
NEXTAUTH_SECRET: z.string().min(1),
NEXTAUTH_URL: z.string().url(),
// Email configuration
SMTP_HOST: z.string(),
SMTP_PORT: z.string().transform(Number),
SMTP_USER: z.string(),
SMTP_PASS: z.string(),
EMAIL_FROM_NAME: z.string(),
EMAIL_FROM_ADDRESS: z.string().email(),
},
client: {
NEXT_PUBLIC_APP_URL: z.string().url(),
// ... client-side env vars
// Add client-side env vars here if needed
},
runtimeEnv: {
// Node environment
NODE_ENV: process.env.NODE_ENV,
// Database configuration
DATABASE_URL: process.env.DATABASE_URL,
STORAGE_TYPE: process.env.STORAGE_TYPE,
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL
}
})
// Authentication
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
NEXTAUTH_URL: process.env.NEXTAUTH_URL,
// Email configuration
SMTP_HOST: process.env.SMTP_HOST,
SMTP_PORT: process.env.SMTP_PORT,
SMTP_USER: process.env.SMTP_USER,
SMTP_PASS: process.env.SMTP_PASS,
EMAIL_FROM_NAME: process.env.EMAIL_FROM_NAME,
EMAIL_FROM_ADDRESS: process.env.EMAIL_FROM_ADDRESS,
},
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
});

View File

@@ -1,35 +0,0 @@
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
export const env = createEnv({
server: {
NODE_ENV: z.enum(["development", "test", "production"]),
DATABASE_URL: z.string().url(),
NEXTAUTH_SECRET: z.string().min(1),
NEXTAUTH_URL: z.string().url(),
// Email configuration
SMTP_HOST: z.string(),
SMTP_PORT: z.string().transform(Number),
SMTP_USER: z.string(),
SMTP_PASS: z.string(),
EMAIL_FROM_NAME: z.string(),
EMAIL_FROM_ADDRESS: z.string().email(),
},
client: {
// Add client-side env vars here
},
runtimeEnv: {
NODE_ENV: process.env.NODE_ENV,
DATABASE_URL: process.env.DATABASE_URL,
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
NEXTAUTH_URL: process.env.NEXTAUTH_URL,
// Email configuration
SMTP_HOST: process.env.SMTP_HOST,
SMTP_PORT: process.env.SMTP_PORT,
SMTP_USER: process.env.SMTP_USER,
SMTP_PASS: process.env.SMTP_PASS,
EMAIL_FROM_NAME: process.env.EMAIL_FROM_NAME,
EMAIL_FROM_ADDRESS: process.env.EMAIL_FROM_ADDRESS,
},
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
});

View File

@@ -0,0 +1,159 @@
"use client";
import { type ReactNode } from "react";
import { type RobotPlugin } from "~/lib/plugin-store/types";
import {
Move,
MessageSquare,
Clock,
KeyboardIcon,
Pointer,
Video,
GitBranch,
Repeat,
Navigation,
type LucideIcon,
} from "lucide-react";
// Map of action types to their icons
const ACTION_ICONS: Record<string, LucideIcon> = {
move: Move,
speak: MessageSquare,
wait: Clock,
input: KeyboardIcon,
gesture: Pointer,
record: Video,
condition: GitBranch,
loop: Repeat,
navigation: Navigation,
};
export interface ActionConfig {
type: string;
title: string;
description: string;
icon: ReactNode;
defaultParameters: Record<string, any>;
pluginId?: string;
ros2Config?: {
messageType: string;
topic?: string;
service?: string;
action?: string;
payloadMapping: {
type: "direct" | "transform";
transformFn?: string;
};
qos?: {
reliability: "reliable" | "best_effort";
durability: "volatile" | "transient_local";
history: "keep_last" | "keep_all";
depth?: number;
};
};
}
export function getActionIcon(iconName: string): ReactNode {
const Icon = ACTION_ICONS[iconName.toLowerCase()] ?? Move;
return <Icon className="h-4 w-4" />;
}
export function getDefaultParameters(parameters: {
type: "object";
properties: Record<string, {
type: string;
title: string;
description?: string;
default?: any;
minimum?: number;
maximum?: number;
enum?: string[];
unit?: string;
}>;
required: string[];
}): Record<string, any> {
const defaults: Record<string, any> = {};
for (const [key, prop] of Object.entries(parameters.properties)) {
defaults[key] = prop.default ?? (
prop.type === "number" ? 0 :
prop.type === "string" ? "" :
prop.type === "boolean" ? false :
prop.type === "array" ? [] :
prop.type === "object" ? {} :
null
);
}
return defaults;
}
export function getPluginActions(plugins: RobotPlugin[]): ActionConfig[] {
return plugins.flatMap(plugin =>
plugin.actions.map(action => ({
type: `${plugin.robotId}:${action.type}`,
title: action.title,
description: action.description,
icon: getActionIcon(action.icon ?? action.type),
defaultParameters: getDefaultParameters(action.parameters),
pluginId: plugin.robotId,
ros2Config: action.ros2,
}))
);
}
// Built-in actions that are always available
export const BUILT_IN_ACTIONS: ActionConfig[] = [
{
type: "wait",
title: "Wait",
description: "Pause for a specified duration",
icon: <Clock className="h-4 w-4" />,
defaultParameters: {
duration: 1000,
showCountdown: true,
},
},
{
type: "input",
title: "User Input",
description: "Wait for participant response",
icon: <KeyboardIcon className="h-4 w-4" />,
defaultParameters: {
type: "button",
prompt: "Please respond",
timeout: null,
},
},
{
type: "record",
title: "Record",
description: "Start or stop recording",
icon: <Video className="h-4 w-4" />,
defaultParameters: {
type: "start",
streams: ["video"],
},
},
{
type: "condition",
title: "Condition",
description: "Branch based on a condition",
icon: <GitBranch className="h-4 w-4" />,
defaultParameters: {
condition: "",
trueActions: [],
falseActions: [],
},
},
{
type: "loop",
title: "Loop",
description: "Repeat a sequence of actions",
icon: <Repeat className="h-4 w-4" />,
defaultParameters: {
count: 1,
actions: [],
},
},
];

View File

@@ -0,0 +1,156 @@
{
"robotId": "turtlebot3-burger",
"name": "TurtleBot3 Burger",
"description": "A compact, affordable, programmable, ROS2-based mobile robot for education and research",
"platform": "ROS2",
"version": "2.0.0",
"manufacturer": {
"name": "ROBOTIS",
"website": "https://www.robotis.com/",
"support": "https://emanual.robotis.com/docs/en/platform/turtlebot3/overview/"
},
"documentation": {
"mainUrl": "https://emanual.robotis.com/docs/en/platform/turtlebot3/overview/",
"apiReference": "https://emanual.robotis.com/docs/en/platform/turtlebot3/ros2_manipulation/",
"wikiUrl": "https://wiki.ros.org/turtlebot3",
"videoUrl": "https://www.youtube.com/watch?v=rVM994ZhsEM"
},
"assets": {
"thumbnailUrl": "/robots/turtlebot3-burger-thumb.png",
"images": {
"main": "/robots/turtlebot3-burger-main.png",
"angles": {
"front": "/robots/turtlebot3-burger-front.png",
"side": "/robots/turtlebot3-burger-side.png",
"top": "/robots/turtlebot3-burger-top.png"
},
"dimensions": "/robots/turtlebot3-burger-dimensions.png"
},
"model": {
"format": "URDF",
"url": "https://raw.githubusercontent.com/ROBOTIS-GIT/turtlebot3/master/turtlebot3_description/urdf/turtlebot3_burger.urdf"
}
},
"specs": {
"dimensions": {
"length": 0.138,
"width": 0.178,
"height": 0.192,
"weight": 1.0
},
"capabilities": [
"differential_drive",
"lidar",
"imu",
"odometry"
],
"maxSpeed": 0.22,
"batteryLife": 2.5
},
"ros2Config": {
"namespace": "turtlebot3",
"nodePrefix": "hri_studio",
"defaultTopics": {
"cmd_vel": "/cmd_vel",
"odom": "/odom",
"scan": "/scan",
"imu": "/imu",
"joint_states": "/joint_states"
}
},
"actions": [
{
"actionId": "move-velocity",
"type": "move",
"title": "Set Velocity",
"description": "Control the robot's linear and angular velocity",
"icon": "navigation",
"parameters": {
"type": "object",
"properties": {
"linear": {
"type": "number",
"title": "Linear Velocity",
"description": "Forward/backward velocity",
"default": 0,
"minimum": -0.22,
"maximum": 0.22,
"unit": "m/s"
},
"angular": {
"type": "number",
"title": "Angular Velocity",
"description": "Rotational velocity",
"default": 0,
"minimum": -2.84,
"maximum": 2.84,
"unit": "rad/s"
}
},
"required": ["linear", "angular"]
},
"ros2": {
"messageType": "geometry_msgs/msg/Twist",
"topic": "/cmd_vel",
"payloadMapping": {
"type": "transform",
"transformFn": "transformToTwist"
},
"qos": {
"reliability": "reliable",
"durability": "volatile",
"history": "keep_last",
"depth": 1
}
}
},
{
"actionId": "move-to-pose",
"type": "move",
"title": "Move to Position",
"description": "Navigate to a specific position on the map",
"icon": "target",
"parameters": {
"type": "object",
"properties": {
"x": {
"type": "number",
"title": "X Position",
"description": "X coordinate in meters",
"default": 0,
"unit": "m"
},
"y": {
"type": "number",
"title": "Y Position",
"description": "Y coordinate in meters",
"default": 0,
"unit": "m"
},
"theta": {
"type": "number",
"title": "Orientation",
"description": "Final orientation",
"default": 0,
"unit": "rad"
}
},
"required": ["x", "y", "theta"]
},
"ros2": {
"messageType": "geometry_msgs/msg/PoseStamped",
"action": "/navigate_to_pose",
"payloadMapping": {
"type": "transform",
"transformFn": "transformToPoseStamped"
}
}
}
]
}

View File

@@ -0,0 +1,153 @@
import { db } from "~/server/db";
import { pluginRepositories } from "~/server/db/schema";
import {
type RobotPlugin,
type RepositoryMetadata,
repositoryMetadataSchema,
} from "./types";
import { PluginStore } from "./store";
import { eq } from "drizzle-orm";
// Singleton instance
let store: PluginStore | null = null;
export async function getPluginStore() {
if (!store) {
store = new PluginStore();
try {
await store.initialize();
} catch (error) {
console.error("Failed to initialize plugin store:", error);
throw error;
}
}
return store;
}
export async function getPlugins(): Promise<RobotPlugin[]> {
const store = await getPluginStore();
return store.getAllPlugins();
}
export async function getRepositories(): Promise<RepositoryMetadata[]> {
const store = await getPluginStore();
return store.getAllRepositories();
}
export async function addRepository(url: string): Promise<RepositoryMetadata> {
// Clean URL and ensure it ends with a trailing slash
const cleanUrl = url.trim().replace(/\/?$/, "/");
try {
// Determine if this is a Git URL or repository URL
const isGitUrl = cleanUrl.includes("github.com/");
const repoUrl = isGitUrl
? cleanUrl
.replace("github.com/", "")
.split("/")
.slice(0, 2)
.join("/")
.replace(/\/$/, "")
: cleanUrl.replace(/\/$/, "");
// Construct URLs
const gitUrl = isGitUrl
? cleanUrl
: `https://github.com/${repoUrl.replace("https://", "").replace(".github.io/", "/")}`;
const repositoryUrl = isGitUrl
? `https://${repoUrl.replace(/^[^/]+\//, "").replace(/\/$/, "")}.github.io/${repoUrl.split("/").pop()}`
: cleanUrl;
// Fetch repository metadata
const metadataUrl = `${repositoryUrl}/repository.json`;
console.log("Loading repository metadata from:", metadataUrl);
const response = await fetch(metadataUrl);
if (!response.ok) {
throw new Error(
`Failed to fetch repository metadata (${response.status}): ${response.statusText}\n` +
"Make sure the URL points to a valid plugin repository containing repository.json",
);
}
const text = await response.text();
if (!text) {
throw new Error("Empty response from repository");
}
console.log("Repository metadata content:", text);
const metadata = JSON.parse(text);
// Validate metadata
const validatedMetadata = await repositoryMetadataSchema.parseAsync({
...metadata,
urls: {
git: gitUrl,
repository: repositoryUrl,
},
enabled: true,
lastSyncedAt: new Date(),
});
// Check if repository already exists
const existing = await db.query.pluginRepositories.findFirst({
where: eq(pluginRepositories.id, validatedMetadata.id),
});
if (existing) {
throw new Error(`Repository ${validatedMetadata.id} already exists`);
}
// Add to database
const [stored] = await db
.insert(pluginRepositories)
.values({
id: validatedMetadata.id,
urls: {
git: gitUrl,
repository: repositoryUrl,
},
trust: validatedMetadata.trust,
enabled: true,
lastSyncedAt: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
})
.returning();
// Clear the store instance to force a fresh load
store = null;
return validatedMetadata;
} catch (error) {
console.error("Failed to add repository:", error);
if (error instanceof Error) {
throw error;
}
throw new Error("Failed to add repository");
}
}
export async function removeRepository(id: string): Promise<void> {
if (!id) {
throw new Error("Repository ID is required");
}
try {
// Remove from database first
await db.delete(pluginRepositories).where(eq(pluginRepositories.id, id));
// Clear the store instance to force a fresh load
store = null;
} catch (error) {
console.error("Failed to remove repository:", error);
throw error;
}
}
export async function getPlugin(
robotId: string,
): Promise<RobotPlugin | undefined> {
const store = await getPluginStore();
return store.getPlugin(robotId);
}

View File

@@ -0,0 +1,468 @@
import { z } from "zod";
import { type RobotPlugin, type RepositoryMetadata, type StoredRepositoryMetadata, robotPluginSchema, repositoryMetadataSchema, storedRepositoryMetadataSchema } from "./types";
import { db } from "~/server/db";
import { pluginRepositories } from "~/server/db/schema/store";
import { eq } from "drizzle-orm";
export class PluginLoadError extends Error {
constructor(
message: string,
public robotId?: string,
public cause?: unknown
) {
super(message);
this.name = "PluginLoadError";
}
}
export class PluginStore {
private plugins: Map<string, RobotPlugin> = new Map();
private repositories: Map<string, RepositoryMetadata> = new Map();
private transformFunctions: Map<string, Function> = new Map();
private pluginToRepo: Map<string, string> = new Map(); // Maps plugin IDs to repository IDs
private lastRefresh: Map<string, number> = new Map(); // Cache timestamps
private readonly CACHE_TTL = 5 * 60 * 1000; // 5 minutes
constructor() {
// Register built-in transform functions
this.registerTransformFunction("transformToTwist", this.transformToTwist);
this.registerTransformFunction("transformToPoseStamped", this.transformToPoseStamped);
}
private getRepositoryFileUrl(baseUrl: string, filePath: string): string {
try {
// Clean URLs and join them
const cleanBaseUrl = baseUrl.replace(/\/$/, '');
const cleanFilePath = filePath.replace(/^\//, '');
return `${cleanBaseUrl}/${cleanFilePath}`;
} catch (error) {
console.error('Failed to construct repository file URL:', error);
throw error;
}
}
async initialize() {
try {
// Load repositories from database
const dbRepositories = await db.query.pluginRepositories.findMany();
for (const repository of dbRepositories) {
if (!repository.enabled) continue;
// Convert database model to repository metadata
const storedMetadata: StoredRepositoryMetadata = {
id: repository.id,
url: repository.url,
trust: repository.trust as "official" | "verified" | "community",
enabled: repository.enabled,
lastSyncedAt: repository.lastSyncedAt ?? undefined,
};
try {
// Fetch full metadata from repository
const metadata = await this.refreshRepositoryMetadata(storedMetadata);
// Add to in-memory cache
this.repositories.set(repository.id, metadata);
// Always load plugins on initialization
await this.loadRepositoryPlugins(metadata);
this.lastRefresh.set(repository.id, Date.now());
// Update last synced timestamp
await db.update(pluginRepositories)
.set({
lastSyncedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(pluginRepositories.id, repository.id));
} catch (error) {
console.warn(`Failed to refresh repository metadata for ${repository.id}:`, error);
// Continue with next repository if refresh fails
}
}
} catch (error) {
console.error("Failed to initialize plugin store:", error);
throw new PluginLoadError(
"Failed to initialize plugin store",
undefined,
error
);
}
}
private shouldRefreshCache(repositoryId: string): boolean {
const lastRefreshTime = this.lastRefresh.get(repositoryId);
if (!lastRefreshTime) return true;
return Date.now() - lastRefreshTime > this.CACHE_TTL;
}
private async refreshRepositoryMetadata(repository: StoredRepositoryMetadata): Promise<RepositoryMetadata> {
try {
const repoUrl = this.getRepositoryFileUrl(repository.url, "repository.json");
const response = await fetch(repoUrl);
if (!response.ok) {
throw new Error(`Failed to fetch repository metadata: ${response.statusText}`);
}
const text = await response.text();
if (!text) {
throw new Error("Empty response from repository");
}
const data = JSON.parse(text);
const metadata = await repositoryMetadataSchema.parseAsync({
...data,
id: repository.id,
enabled: repository.enabled,
lastSyncedAt: repository.lastSyncedAt,
});
// Transform asset URLs to absolute URLs
if (metadata.assets) {
metadata.assets = {
icon: metadata.assets.icon ? this.getRepositoryFileUrl(repository.url, metadata.assets.icon) : undefined,
logo: metadata.assets.logo ? this.getRepositoryFileUrl(repository.url, metadata.assets.logo) : undefined,
banner: metadata.assets.banner ? this.getRepositoryFileUrl(repository.url, metadata.assets.banner) : undefined,
};
}
// Initialize stats with default values
metadata.stats = {
downloads: 0,
stars: 0,
plugins: 0,
...metadata.stats,
};
// Update in-memory cache
this.repositories.set(repository.id, metadata);
this.lastRefresh.set(repository.id, Date.now());
return metadata;
} catch (error) {
console.error(`Failed to refresh repository metadata for ${repository.id}:`, error);
throw error;
}
}
async loadRepository(url: string): Promise<RepositoryMetadata> {
try {
// Fetch repository metadata
const repoUrl = this.getRepositoryFileUrl(url, "repository.json");
console.log("Loading repository metadata from:", repoUrl);
const response = await fetch(repoUrl);
if (!response.ok) {
throw new Error(`Failed to fetch repository metadata: ${response.statusText}`);
}
const text = await response.text();
console.log("Repository metadata content:", text);
if (!text) {
throw new Error("Empty response from repository");
}
const data = JSON.parse(text);
console.log("Parsed repository metadata:", data);
const metadata = await repositoryMetadataSchema.parseAsync({
...data,
enabled: true,
lastSyncedAt: new Date(),
});
// Transform asset URLs to absolute URLs
if (metadata.assets) {
metadata.assets = {
icon: metadata.assets.icon ? this.getRepositoryFileUrl(url, metadata.assets.icon) : undefined,
logo: metadata.assets.logo ? this.getRepositoryFileUrl(url, metadata.assets.logo) : undefined,
banner: metadata.assets.banner ? this.getRepositoryFileUrl(url, metadata.assets.banner) : undefined,
};
}
// Initialize stats with default values
metadata.stats = {
downloads: 0,
stars: 0,
plugins: 0,
...metadata.stats,
};
// Check if repository already exists
const existing = await db.query.pluginRepositories.findFirst({
where: eq(pluginRepositories.id, metadata.id),
});
if (existing) {
throw new Error(`Repository ${metadata.id} already exists`);
}
// Add to database - only store essential fields
const storedMetadata: StoredRepositoryMetadata = {
id: metadata.id,
url: metadata.url,
trust: metadata.trust,
enabled: true,
lastSyncedAt: new Date(),
};
await db.insert(pluginRepositories).values({
...storedMetadata,
createdAt: new Date(),
updatedAt: new Date(),
});
// Add to in-memory cache
this.repositories.set(metadata.id, metadata);
this.lastRefresh.set(metadata.id, Date.now());
// Load plugins
await this.loadRepositoryPlugins(metadata);
return metadata;
} catch (error) {
console.error("Failed to load repository:", error);
throw new PluginLoadError(
`Failed to load repository from ${url}`,
undefined,
error
);
}
}
private transformAssetUrls(plugin: RobotPlugin, baseUrl: string): RobotPlugin {
const transformUrl = (url: string) => {
if (url.startsWith('http')) return url;
return this.getRepositoryFileUrl(baseUrl, url);
};
return {
...plugin,
assets: {
...plugin.assets,
thumbnailUrl: transformUrl(plugin.assets.thumbnailUrl),
images: {
...plugin.assets.images,
main: transformUrl(plugin.assets.images.main),
angles: plugin.assets.images.angles ? {
front: plugin.assets.images.angles.front ? transformUrl(plugin.assets.images.angles.front) : undefined,
side: plugin.assets.images.angles.side ? transformUrl(plugin.assets.images.angles.side) : undefined,
top: plugin.assets.images.angles.top ? transformUrl(plugin.assets.images.angles.top) : undefined,
} : undefined,
dimensions: plugin.assets.images.dimensions ? transformUrl(plugin.assets.images.dimensions) : undefined,
},
model: plugin.assets.model ? {
...plugin.assets.model,
url: transformUrl(plugin.assets.model.url),
} : undefined,
},
};
}
private async loadRepositoryPlugins(repository: RepositoryMetadata) {
try {
// Load plugins index
const indexUrl = this.getRepositoryFileUrl(repository.url, "plugins/index.json");
console.log("Loading plugins index from:", indexUrl);
const indexResponse = await fetch(indexUrl);
if (!indexResponse.ok) {
throw new Error(`Failed to fetch plugins index (${indexResponse.status})`);
}
const indexText = await indexResponse.text();
console.log("Plugins index content:", indexText);
if (!indexText || indexText.trim() === "") {
throw new Error("Empty response from plugins index");
}
const pluginFiles = JSON.parse(indexText) as string[];
console.log("Found plugin files:", pluginFiles);
// Update plugin count in repository stats
if (repository.stats) {
repository.stats.plugins = pluginFiles.length;
// Update in-memory cache only
this.repositories.set(repository.id, repository);
}
// Load each plugin file
for (const pluginFile of pluginFiles) {
try {
const pluginUrl = this.getRepositoryFileUrl(repository.url, `plugins/${pluginFile}`);
console.log("Loading plugin from:", pluginUrl);
const pluginResponse = await fetch(pluginUrl);
if (!pluginResponse.ok) {
console.error(`Failed to load plugin file ${pluginFile}: ${pluginResponse.statusText}`);
continue;
}
const pluginText = await pluginResponse.text();
if (!pluginText || pluginText.trim() === "") {
console.error(`Empty response from plugin file ${pluginFile}`);
continue;
}
const pluginData = JSON.parse(pluginText);
const plugin = await robotPluginSchema.parseAsync(pluginData);
// Transform relative asset URLs to absolute URLs
const transformedPlugin = this.transformAssetUrls(plugin, repository.url);
// Store the plugin and its repository mapping
this.plugins.set(transformedPlugin.robotId, transformedPlugin);
this.pluginToRepo.set(transformedPlugin.robotId, repository.id);
console.log(`Successfully loaded plugin: ${transformedPlugin.name} (${transformedPlugin.robotId})`);
} catch (error) {
console.error(`Failed to load plugin ${pluginFile}:`, error);
// Continue with next plugin if one fails
continue;
}
}
} catch (error) {
console.error(`Failed to load plugins for repository ${repository.id}:`, error);
throw error;
}
}
async removeRepository(id: string): Promise<void> {
const repository = this.repositories.get(id);
if (!repository) return;
if (repository.official) {
throw new Error("Cannot remove official repository");
}
// Remove from database
await db.delete(pluginRepositories).where(eq(pluginRepositories.id, id));
// Remove plugins associated with this repository
for (const [pluginId, repoId] of this.pluginToRepo.entries()) {
if (repoId === id) {
this.plugins.delete(pluginId);
this.pluginToRepo.delete(pluginId);
}
}
// Remove from cache
this.repositories.delete(id);
this.lastRefresh.delete(id);
}
async loadPluginFromUrl(url: string): Promise<RobotPlugin> {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch plugin: ${response.statusText}`);
}
const text = await response.text();
if (!text) {
throw new Error("Empty response from plugin URL");
}
return this.loadPluginFromJson(text);
} catch (error) {
throw new PluginLoadError(
`Failed to load plugin from URL: ${url}`,
undefined,
error
);
}
}
async loadPluginFromJson(jsonString: string): Promise<RobotPlugin> {
try {
const data = JSON.parse(jsonString);
const plugin = await robotPluginSchema.parseAsync(data);
this.plugins.set(plugin.robotId, plugin);
return plugin;
} catch (error) {
if (error instanceof z.ZodError) {
throw new PluginLoadError(
`Invalid plugin format: ${error.errors.map(e => e.message).join(", ")}`,
undefined,
error
);
}
throw new PluginLoadError(
"Failed to load plugin",
undefined,
error
);
}
}
getPlugin(robotId: string): RobotPlugin | undefined {
return this.plugins.get(robotId);
}
getAllPlugins(): RobotPlugin[] {
return Array.from(this.plugins.values());
}
getRepository(id: string): RepositoryMetadata | undefined {
return this.repositories.get(id);
}
getAllRepositories(): RepositoryMetadata[] {
return Array.from(this.repositories.values());
}
registerTransformFunction(name: string, fn: Function): void {
this.transformFunctions.set(name, fn);
}
getTransformFunction(name: string): Function | undefined {
return this.transformFunctions.get(name);
}
private async validatePlugin(data: unknown): Promise<RobotPlugin> {
return robotPluginSchema.parseAsync(data);
}
private transformToTwist(params: { linear: number; angular: number }) {
return {
linear: {
x: params.linear,
y: 0.0,
z: 0.0
},
angular: {
x: 0.0,
y: 0.0,
z: params.angular
}
};
}
private transformToPoseStamped(params: { x: number; y: number; theta: number }) {
return {
header: {
stamp: {
sec: Math.floor(Date.now() / 1000),
nanosec: (Date.now() % 1000) * 1000000
},
frame_id: "map"
},
pose: {
position: {
x: params.x,
y: params.y,
z: 0.0
},
orientation: {
x: 0.0,
y: 0.0,
z: Math.sin(params.theta / 2),
w: Math.cos(params.theta / 2)
}
}
};
}
}

View File

@@ -0,0 +1,221 @@
import { z } from "zod";
// Version compatibility schema
export const versionCompatibilitySchema = z.object({
hristudio: z.object({
min: z.string(),
max: z.string().optional(),
recommended: z.string().optional(),
}),
ros2: z
.object({
distributions: z.array(z.string()),
recommended: z.string().optional(),
})
.optional(),
});
// Repository metadata schema
export const storedRepositoryMetadataSchema = z.object({
id: z.string(),
urls: z.object({
git: z.string().url().optional(),
repository: z.string().url(),
}),
trust: z.enum(["official", "verified", "community"]).default("community"),
enabled: z.boolean().default(true),
lastSyncedAt: z.date().optional(),
});
export const repositoryMetadataSchema = storedRepositoryMetadataSchema.extend({
// These fields are fetched from the repository.json but not stored in the database
name: z.string(),
description: z.string().optional(),
official: z.boolean().default(false),
author: z.object({
name: z.string(),
email: z.string().email().optional(),
url: z.string().url().optional(),
organization: z.string().optional(),
}),
maintainers: z
.array(
z.object({
name: z.string(),
email: z.string().email().optional(),
url: z.string().url().optional(),
}),
)
.optional(),
homepage: z.string().url().optional(),
license: z.string(),
defaultBranch: z.string().default("main"),
lastUpdated: z.string().datetime(),
compatibility: z.object({
hristudio: z.object({
min: z.string(),
max: z.string().optional(),
recommended: z.string().optional(),
}),
ros2: z
.object({
distributions: z.array(z.string()),
recommended: z.string().optional(),
})
.optional(),
}),
tags: z.array(z.string()).default([]),
stats: z
.object({
plugins: z.number().default(0),
})
.optional(),
assets: z
.object({
icon: z.string().optional(),
logo: z.string().optional(),
banner: z.string().optional(),
})
.optional(),
});
export type StoredRepositoryMetadata = z.infer<
typeof storedRepositoryMetadataSchema
>;
export type RepositoryMetadata = z.infer<typeof repositoryMetadataSchema>;
// Core types for the plugin store
export type ActionType =
| "move" // Robot movement
| "speak" // Robot speech
| "wait" // Wait for a duration
| "input" // Wait for user input
| "gesture" // Robot gesture
| "record" // Start/stop recording
| "condition" // Conditional branching
| "loop"; // Repeat actions
// Zod schema for parameter properties
export const parameterPropertySchema = z.object({
type: z.string(),
title: z.string(),
description: z.string().optional(),
default: z.any().optional(),
minimum: z.number().optional(),
maximum: z.number().optional(),
enum: z.array(z.string()).optional(),
unit: z.string().optional(),
});
// Zod schema for ROS2 QoS settings
export const qosSchema = z.object({
reliability: z.enum(["reliable", "best_effort"]),
durability: z.enum(["volatile", "transient_local"]),
history: z.enum(["keep_last", "keep_all"]),
depth: z.number().optional(),
});
// Zod schema for action definition
export const actionDefinitionSchema = z.object({
actionId: z.string(),
type: z.enum([
"move",
"speak",
"wait",
"input",
"gesture",
"record",
"condition",
"loop",
]),
title: z.string(),
description: z.string(),
icon: z.string().optional(),
parameters: z.object({
type: z.literal("object"),
properties: z.record(parameterPropertySchema),
required: z.array(z.string()),
}),
ros2: z.object({
messageType: z.string(),
topic: z.string().optional(),
service: z.string().optional(),
action: z.string().optional(),
payloadMapping: z.object({
type: z.enum(["direct", "transform"]),
map: z.record(z.string()).optional(),
transformFn: z.string().optional(),
}),
qos: qosSchema.optional(),
}),
});
// Zod schema for the entire robot plugin
export const robotPluginSchema = z.object({
robotId: z.string(),
name: z.string(),
description: z.string().optional(),
platform: z.string(),
version: z.string(),
manufacturer: z.object({
name: z.string(),
website: z.string().url().optional(),
support: z.string().url().optional(),
}),
documentation: z.object({
mainUrl: z.string().url(),
apiReference: z.string().url().optional(),
wikiUrl: z.string().url().optional(),
videoUrl: z.string().url().optional(),
}),
assets: z.object({
thumbnailUrl: z.string(),
logo: z.string().optional(),
images: z.object({
main: z.string(),
angles: z
.object({
front: z.string().optional(),
side: z.string().optional(),
top: z.string().optional(),
})
.optional(),
dimensions: z.string().optional(),
}),
model: z
.object({
format: z.enum(["URDF", "glTF", "other"]),
url: z.string().url(),
})
.optional(),
}),
specs: z.object({
dimensions: z.object({
length: z.number(),
width: z.number(),
height: z.number(),
weight: z.number(),
}),
capabilities: z.array(z.string()),
maxSpeed: z.number(),
batteryLife: z.number(),
}),
actions: z.array(actionDefinitionSchema),
ros2Config: z.object({
namespace: z.string(),
nodePrefix: z.string(),
defaultTopics: z.record(z.string()),
}),
});
// TypeScript types inferred from the Zod schemas
export type ParameterProperty = z.infer<typeof parameterPropertySchema>;
export type QoSSettings = z.infer<typeof qosSchema>;
export type ActionDefinition = z.infer<typeof actionDefinitionSchema>;
export type RobotPlugin = z.infer<typeof robotPluginSchema>;

View File

@@ -2,6 +2,7 @@ import { createTRPCRouter } from "~/server/api/trpc";
import { studyRouter } from "./routers/study";
import { participantRouter } from "./routers/participant";
import { experimentRouter } from "./routers/experiment";
import { pluginStoreRouter } from "./routers/plugin-store";
/**
* This is the primary router for your server.
@@ -12,6 +13,7 @@ export const appRouter = createTRPCRouter({
study: studyRouter,
participant: participantRouter,
experiment: experimentRouter,
pluginStore: pluginStoreRouter,
});
// export type definition of API

View File

@@ -0,0 +1,178 @@
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import {
addRepository,
getPlugins,
getRepositories,
removeRepository,
} from "~/lib/plugin-store/service";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { installedPlugins } from "~/server/db/schema/store";
export const pluginStoreRouter = createTRPCRouter({
// Get all repositories
getRepositories: protectedProcedure.query(async () => {
try {
return await getRepositories();
} catch (error) {
console.error("Failed to get repositories:", error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to get repositories",
});
}
}),
// Get all available plugins
getPlugins: protectedProcedure.query(async () => {
try {
return await getPlugins();
} catch (error) {
console.error("Failed to get plugins:", error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to get plugins",
});
}
}),
// Add a new repository
addRepository: protectedProcedure
.input(
z.object({
url: z.string().url(),
}),
)
.mutation(async ({ input }) => {
try {
return await addRepository(input.url);
} catch (error) {
console.error("Failed to add repository:", error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message:
error instanceof Error ? error.message : "Failed to add repository",
});
}
}),
// Remove a repository
removeRepository: protectedProcedure
.input(
z.object({
id: z.string(),
}),
)
.mutation(async ({ input }) => {
try {
await removeRepository(input.id);
return { success: true };
} catch (error) {
console.error("Failed to remove repository:", error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message:
error instanceof Error
? error.message
: "Failed to remove repository",
});
}
}),
// Install a plugin
installPlugin: protectedProcedure
.input(
z.object({
robotId: z.string(),
repositoryId: z.string(),
}),
)
.mutation(async ({ ctx, input }) => {
try {
// Get plugin details
const plugin = await getPlugins().then((plugins) =>
plugins.find((p) => p.robotId === input.robotId),
);
if (!plugin) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Plugin not found",
});
}
// Check if already installed
const existing = await ctx.db.query.installedPlugins.findFirst({
where: eq(installedPlugins.robotId, input.robotId),
});
if (existing) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Plugin already installed",
});
}
// Install plugin
const [installed] = await ctx.db
.insert(installedPlugins)
.values({
robotId: input.robotId,
repositoryId: input.repositoryId,
enabled: true,
config: {},
})
.returning();
return installed;
} catch (error) {
console.error("Failed to install plugin:", error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message:
error instanceof Error ? error.message : "Failed to install plugin",
});
}
}),
// Uninstall a plugin
uninstallPlugin: protectedProcedure
.input(
z.object({
robotId: z.string(),
}),
)
.mutation(async ({ ctx, input }) => {
try {
await ctx.db
.delete(installedPlugins)
.where(eq(installedPlugins.robotId, input.robotId));
return { success: true };
} catch (error) {
console.error("Failed to uninstall plugin:", error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message:
error instanceof Error
? error.message
: "Failed to uninstall plugin",
});
}
}),
// Get installed plugins
getInstalledPlugins: protectedProcedure.query(async ({ ctx }) => {
try {
return await ctx.db.query.installedPlugins.findMany({
orderBy: (installedPlugins, { asc }) => [asc(installedPlugins.robotId)],
});
} catch (error) {
console.error("Failed to get installed plugins:", error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to get installed plugins",
});
}
}),
});

View File

@@ -73,6 +73,12 @@ export const authOptions: NextAuthOptions = {
if (user) {
token.id = user.id;
token.email = user.email;
token.firstName = user.firstName;
token.lastName = user.lastName;
token.name =
user.firstName && user.lastName
? `${user.firstName} ${user.lastName}`
: null;
}
return token;
},
@@ -80,6 +86,9 @@ export const authOptions: NextAuthOptions = {
if (token) {
session.user.id = token.id as string;
session.user.email = token.email as string;
session.user.firstName = token.firstName as string | null;
session.user.lastName = token.lastName as string | null;
session.user.name = token.name as string | null;
}
return session;
},
@@ -97,4 +106,4 @@ export const getServerAuthSession = () => getServerSession(authOptions);
export const handlers = { GET: getServerSession, POST: getServerSession };
// Auth for client components
export const auth = () => getServerSession(authOptions);
export const auth = () => getServerSession(authOptions);

View File

@@ -1,7 +1,7 @@
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import { env } from "~/env";
import { env } from "~/env.mjs";
import * as schema from "./schema";
/**

View File

@@ -1,4 +1,6 @@
// Re-export all schema definitions from individual schema files
export * from "./schema/auth";
export * from "./schema/studies";
export * from "./schema/permissions";
export * from "./schema/permissions";
export * from "./schema/experiments";
export * from "./schema/store";

View File

@@ -2,13 +2,12 @@ import { relations } from "drizzle-orm";
import {
integer,
pgEnum,
pgTable,
text,
timestamp,
varchar,
serial
varchar
} from "drizzle-orm/pg-core";
import { participants } from "../schema";
import { createTable } from "../utils";
import { users } from "./auth";
import { studies } from "./studies";
@@ -40,7 +39,7 @@ export const trialStatusEnum = pgEnum("trial_status", [
]);
// Tables
export const experiments = pgTable("experiments", {
export const experiments = createTable("experiments", {
id: integer("id").primaryKey().notNull().generatedAlwaysAsIdentity(),
studyId: integer("study_id")
.notNull()
@@ -59,7 +58,7 @@ export const experiments = pgTable("experiments", {
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
export const steps = pgTable("steps", {
export const steps = createTable("steps", {
id: integer("id").primaryKey().notNull().generatedAlwaysAsIdentity(),
experimentId: integer("experiment_id")
.notNull()
@@ -74,7 +73,7 @@ export const steps = pgTable("steps", {
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
export const actions = pgTable("actions", {
export const actions = createTable("actions", {
id: integer("id").primaryKey().notNull().generatedAlwaysAsIdentity(),
stepId: integer("step_id")
.notNull()
@@ -88,7 +87,7 @@ export const actions = pgTable("actions", {
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
export const trials = pgTable("trials", {
export const trials = createTable("trials", {
id: integer("id").primaryKey().notNull().generatedAlwaysAsIdentity(),
experimentId: integer("experiment_id")
.notNull()
@@ -110,7 +109,7 @@ export const trials = pgTable("trials", {
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
export const trialEvents = pgTable("trial_events", {
export const trialEvents = createTable("trial_events", {
id: integer("id").primaryKey().notNull().generatedAlwaysAsIdentity(),
trialId: integer("trial_id")
.notNull()

View File

@@ -1,4 +0,0 @@
export * from "./auth";
export * from "./studies";
export * from "./permissions";
export * from "./experiments";

View File

@@ -56,11 +56,12 @@ export const userRoles = createTable(
.notNull()
.references(() => roles.id, { onDelete: "cascade" }),
studyId: integer("study_id")
.notNull()
.references(() => studies.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at").defaultNow().notNull(),
},
(table) => ({
pk: primaryKey({ columns: [table.userId, table.roleId, table.studyId ?? ""] }),
pk: primaryKey({ columns: [table.userId, table.roleId, table.studyId] }),
})
);

View File

@@ -0,0 +1,32 @@
import { jsonb, text, timestamp, boolean } from "drizzle-orm/pg-core";
import { createId } from "@paralleldrive/cuid2";
import { createTable } from "../utils";
export const pluginRepositories = createTable("plugin_repositories", {
id: text("id")
.primaryKey()
.$defaultFn(() => createId()),
urls: jsonb("urls").notNull().$type<{ git: string; repository: string }>(),
trust: text("trust", { enum: ["official", "verified", "community"] })
.notNull()
.default("community"),
enabled: boolean("enabled").notNull().default(true),
lastSyncedAt: timestamp("last_synced_at"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
export const installedPlugins = createTable("installed_plugins", {
id: text("id")
.primaryKey()
.$defaultFn(() => createId()),
robotId: text("robot_id").notNull(),
repositoryId: text("repository_id")
.notNull()
.references(() => pluginRepositories.id, { onDelete: "cascade" }),
enabled: boolean("enabled").notNull().default(true),
config: jsonb("config").notNull().default({}),
lastSyncedAt: timestamp("last_synced_at"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});

View File

@@ -1,9 +1,8 @@
import { relations } from "drizzle-orm";
import { integer, pgEnum, text, timestamp, varchar, serial, jsonb } from "drizzle-orm/pg-core";
import { integer, pgEnum, text, timestamp, varchar } from "drizzle-orm/pg-core";
import { ROLES } from "~/lib/permissions/constants";
import { createTable } from "../utils";
import { users } from "./auth";
import { type Step } from "~/lib/experiments/types";
// Create enum from role values
export const studyRoleEnum = pgEnum("study_role", [
@@ -35,12 +34,6 @@ export const activityTypeEnum = pgEnum("activity_type", [
"participant_added",
"participant_updated",
"participant_removed",
"experiment_created",
"experiment_updated",
"experiment_deleted",
"trial_started",
"trial_completed",
"trial_cancelled",
"invitation_sent",
"invitation_accepted",
"invitation_declined",
@@ -61,33 +54,13 @@ export const invitationStatusEnum = pgEnum("invitation_status", [
"revoked",
]);
export const studyActivityTypeEnum = pgEnum("study_activity_type", [
"member_added",
"member_role_changed",
"study_updated",
"participant_added",
"participant_updated",
"invitation_sent",
"invitation_accepted",
"invitation_declined",
"invitation_expired",
"invitation_revoked",
]);
// Create enum for experiment status
export const experimentStatusEnum = pgEnum("experiment_status", [
"draft",
"active",
"archived",
]);
export const studies = createTable("study", {
id: integer("id").primaryKey().notNull().generatedAlwaysAsIdentity(),
title: varchar("title", { length: 256 }).notNull(),
description: text("description"),
createdById: varchar("created_by", { length: 255 }).notNull().references(() => users.id),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }),
createdById: varchar("created_by", { length: 255 }).references(() => users.id),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
export const studyMembers = createTable("study_member", {
@@ -95,7 +68,7 @@ export const studyMembers = createTable("study_member", {
studyId: integer("study_id").notNull().references(() => studies.id, { onDelete: "cascade" }),
userId: varchar("user_id", { length: 255 }).notNull().references(() => users.id, { onDelete: "cascade" }),
role: studyRoleEnum("role").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
});
export const studyMetadata = createTable("study_metadata", {
@@ -103,8 +76,8 @@ export const studyMetadata = createTable("study_metadata", {
studyId: integer("study_id").notNull().references(() => studies.id, { onDelete: "cascade" }),
key: varchar("key", { length: 256 }).notNull(),
value: text("value"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
export const studyActivities = createTable("study_activity", {
@@ -113,22 +86,20 @@ export const studyActivities = createTable("study_activity", {
userId: varchar("user_id", { length: 255 }).notNull().references(() => users.id),
type: activityTypeEnum("type").notNull(),
description: text("description").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
});
export const participants = createTable("participant", {
id: integer("id").primaryKey().notNull().generatedAlwaysAsIdentity(),
studyId: integer("study_id").notNull().references(() => studies.id, { onDelete: "cascade" }),
// Identifiable information - only visible to roles with VIEW_PARTICIPANT_NAMES permission
identifier: varchar("identifier", { length: 256 }),
email: varchar("email", { length: 256 }),
firstName: varchar("first_name", { length: 256 }),
lastName: varchar("last_name", { length: 256 }),
// Non-identifiable information - visible to all study members
notes: text("notes"),
status: participantStatusEnum("status").notNull().default("active"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
export const studyInvitations = createTable("study_invitation", {
@@ -138,32 +109,21 @@ export const studyInvitations = createTable("study_invitation", {
role: studyRoleEnum("role").notNull(),
token: varchar("token", { length: 255 }).notNull().unique(),
status: invitationStatusEnum("status").notNull().default("pending"),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }),
expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
createdById: varchar("created_by", { length: 255 }).notNull().references(() => users.id),
});
export const experiments = createTable("experiment", {
id: integer("id").primaryKey().notNull().generatedAlwaysAsIdentity(),
studyId: integer("study_id").notNull().references(() => studies.id, { onDelete: "cascade" }),
title: varchar("title", { length: 256 }).notNull(),
description: text("description"),
version: integer("version").notNull().default(1),
status: experimentStatusEnum("status").notNull().default("draft"),
steps: jsonb("steps").$type<Step[]>().default([]),
createdById: varchar("created_by", { length: 255 }).notNull().references(() => users.id),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }),
});
// Relations
export const studiesRelations = relations(studies, ({ one, many }) => ({
creator: one(users, { fields: [studies.createdById], references: [users.id] }),
creator: one(users, {
fields: [studies.createdById],
references: [users.id],
}),
members: many(studyMembers),
participants: many(participants),
invitations: many(studyInvitations),
experiments: many(experiments),
}));
export const studyMembersRelations = relations(studyMembers, ({ one }) => ({
@@ -178,9 +138,4 @@ export const participantsRelations = relations(participants, ({ one }) => ({
export const studyInvitationsRelations = relations(studyInvitations, ({ one }) => ({
study: one(studies, { fields: [studyInvitations.studyId], references: [studies.id] }),
creator: one(users, { fields: [studyInvitations.createdById], references: [users.id] }),
}));
export const experimentsRelations = relations(experiments, ({ one }) => ({
study: one(studies, { fields: [experiments.studyId], references: [studies.id] }),
creator: one(users, { fields: [experiments.createdById], references: [users.id] }),
}));

29
src/server/db/seed.ts Normal file
View File

@@ -0,0 +1,29 @@
import { db } from "./index";
import { pluginRepositories } from "./schema/store";
async function seed() {
console.log("🌱 Seeding database...");
// Seed official repository with minimal info
// The store will load the full metadata from GitHub Pages when initialized
await db.insert(pluginRepositories).values({
id: "hristudio-official",
url: "https://soconnor0919.github.io/robot-plugins",
trust: "official",
enabled: true,
lastSyncedAt: new Date(),
}).onConflictDoUpdate({
target: pluginRepositories.id,
set: {
url: "https://soconnor0919.github.io/robot-plugins",
lastSyncedAt: new Date(),
}
});
console.log("✅ Database seeded!");
}
seed().catch((error) => {
console.error("Failed to seed database:", error);
process.exit(1);
});

View File

@@ -1,305 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
font-family: Arial, Helvetica, sans-serif;
}
@layer base {
:root {
/* Base colors */
--background: 0 0% 100%;
--foreground: 200 50% 20%;
/* Primary colors */
--primary: 200 85% 45%;
--primary-foreground: 0 0% 100%;
/* Card colors and elevation */
--card-level-1: 200 30% 98%;
--card-level-2: 200 30% 96%;
--card-level-3: 200 30% 94%;
--card: 0 0% 100%;
--card-foreground: 200 50% 20%;
/* Button and interactive states */
--secondary: 200 30% 96%;
--secondary-foreground: 200 50% 20%;
--muted: 200 30% 96%;
--muted-foreground: 200 30% 40%;
--accent: 200 30% 96%;
--accent-foreground: 200 50% 20%;
--destructive: 0 84% 60%;
--destructive-foreground: 0 0% 100%;
/* Border and ring */
--border: 200 30% 90%;
--input: 200 30% 90%;
--ring: 200 85% 45%;
/* Radius */
--radius: 0.5rem;
/* More subtle primary blue to match sidebar */
--primary: 200 85% 45%;
--primary-foreground: 0 0% 100%;
/* Slightly tinted card backgrounds */
--card: 0 0% 100%;
--card-foreground: 200 30% 25%;
/* Popover styling */
--popover: 0 0% 100%;
--popover-foreground: 200 50% 20%;
/* Softer secondary colors */
--secondary: 200 30% 96%;
--secondary-foreground: 200 50% 20%;
/* Muted tones with slight blue tint */
--muted: 200 30% 96%;
--muted-foreground: 200 30% 40%;
/* Accent colors with more pop */
--accent: 200 85% 45%;
--accent-foreground: 0 0% 100%;
/* Brighter destructive red */
--destructive: 0 84% 60%;
--destructive-foreground: 0 0% 100%;
/* Subtle borders and inputs */
--border: 200 30% 90%;
--input: 200 30% 90%;
--ring: 200 85% 45%;
/* Card elevation levels with blue tint */
--card-level-1: 200 30% 98%;
--card-level-2: 200 30% 96%;
--card-level-3: 200 30% 94%;
/* Sidebar specific colors */
--sidebar-background: 0 0% 100%;
--sidebar-foreground: 200 50% 20%;
--sidebar-muted: 200 30% 40%;
--sidebar-hover: 200 40% 95%;
--sidebar-border: 200 30% 92%;
--sidebar-separator: 200 30% 92%;
--sidebar-active: var(--primary);
--sidebar-active-foreground: var(--primary-foreground);
/* Sidebar gradient colors - more subtle blue */
--sidebar-gradient-from: 200 40% 85%;
--sidebar-gradient-to: 200 35% 80%;
/* Sidebar text colors */
--sidebar-text: 200 30% 25%;
--sidebar-text-muted: 200 25% 45%;
--sidebar-text-hover: 200 30% 25%;
/* Gradient */
--gradient-start: 200 40% 85%;
--gradient-end: 200 35% 80%;
/* Sidebar and Header Gradients - matching subtle blue */
--sidebar-gradient-from: 200 40% 85%;
--sidebar-gradient-to: 200 35% 80%;
/* Sidebar Colors - subtle blue */
--sidebar-accent: 200 30% 95%;
--sidebar-accent-foreground: 200 40% 45%;
--sidebar-primary: 200 40% 45%;
--sidebar-primary-foreground: 0 0% 100%;
/* Card styling to match sidebar aesthetic */
--card-border: 200 30% 90%;
--card-hover: 200 40% 98%;
/* Hover states - more subtle */
--hover-background: 200 40% 98%;
--hover-foreground: 200 30% 25%;
--hover-border: 200 30% 85%;
}
@media (prefers-color-scheme: dark) {
:root {
--background: 200 50% 10%;
--foreground: 200 20% 96%;
/* Card colors - dark */
--card-level-1: 200 25% 15%;
--card-level-2: 200 25% 18%;
--card-level-3: 200 25% 20%;
--card: 200 25% 15%;
--card-foreground: 200 20% 96%;
/* Button and interactive states - dark */
--secondary: 200 30% 20%;
--secondary-foreground: 200 20% 96%;
--muted: 200 30% 20%;
--muted-foreground: 200 30% 65%;
--accent: 200 30% 20%;
--accent-foreground: 200 20% 96%;
/* Border and ring - dark */
--border: 200 30% 20%;
--input: 200 30% 20%;
/* Darker theme with blue undertones */
--background: 200 50% 10%;
--foreground: 200 20% 96%;
/* Card and surface colors */
--card: 200 25% 15%;
--card-foreground: 200 15% 85%;
/* Popover styling */
--popover: 200 50% 8%;
--popover-foreground: 200 20% 96%;
/* Vibrant primary in dark mode */
--primary: 200 85% 45%;
--primary-foreground: 0 0% 100%;
/* Secondary colors */
--secondary: 200 30% 20%;
--secondary-foreground: 200 20% 96%;
/* Muted colors with better visibility */
--muted: 200 30% 20%;
--muted-foreground: 200 30% 65%;
/* Accent colors */
--accent: 200 85% 45%;
--accent-foreground: 0 0% 100%;
/* Destructive red */
--destructive: 0 84% 60%;
--destructive-foreground: 0 0% 100%;
/* Border and input colors */
--border: 200 30% 20%;
--input: 200 30% 20%;
--ring: 200 85% 45%;
/* Card elevation levels */
--card-level-1: 200 25% 18%;
--card-level-2: 200 25% 20%;
--card-level-3: 200 25% 22%;
/* Sidebar specific colors */
--sidebar-background: 200 50% 8%;
--sidebar-foreground: 200 20% 96%;
--sidebar-muted: 200 30% 65%;
--sidebar-hover: 200 25% 20%;
--sidebar-border: 200 30% 20%;
--sidebar-separator: 200 30% 20%;
--sidebar-active: 200 85% 60%;
--sidebar-active-foreground: 200 50% 10%;
/* Sidebar gradient colors - more subtle dark blue */
--sidebar-gradient-from: 200 25% 30%;
--sidebar-gradient-to: 200 20% 25%;
/* Sidebar text colors for dark mode */
--sidebar-text: 0 0% 100%;
--sidebar-text-muted: 200 15% 85%;
--sidebar-text-hover: 0 0% 100%;
/* Gradient */
--gradient-start: 200 25% 30%;
--gradient-end: 200 20% 25%;
/* Card styling for dark mode */
--card-border: 200 20% 25%;
--card-hover: 200 25% 20%;
/* Hover states for dark mode */
--hover-background: 200 25% 20%;
--hover-foreground: 200 15% 85%;
--hover-border: 200 20% 30%;
}
}
/* Add these utility classes */
.card-level-1 {
background-color: hsl(var(--card-level-1));
}
.card-level-2 {
background-color: hsl(var(--card-level-2));
}
.card-level-3 {
background-color: hsl(var(--card-level-3));
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
/* Sidebar specific styles */
[data-sidebar="sidebar"] {
@apply bg-gradient-to-b from-[hsl(var(--sidebar-gradient-from))] to-[hsl(var(--sidebar-gradient-to))];
}
.sidebar-separator {
@apply my-3 border-t border-[hsl(var(--sidebar-text-muted))]/10;
}
.sidebar-dropdown-content {
@apply bg-[hsl(var(--sidebar-gradient-from))] border-[hsl(var(--sidebar-text))]/10;
}
/* Sidebar text styles */
[data-sidebar="sidebar"] {
@apply text-[hsl(var(--sidebar-text))];
}
[data-sidebar="menu-button"] {
@apply text-[hsl(var(--sidebar-text))] hover:bg-[hsl(var(--sidebar-hover))]/20 transition-colors duration-200;
}
[data-sidebar="menu-button"][data-active="true"] {
@apply bg-[hsl(var(--sidebar-hover))]/30 text-[hsl(var(--sidebar-text))] font-medium;
}
[data-sidebar="group-label"] {
@apply text-[hsl(var(--sidebar-text-muted))];
}
[data-sidebar="menu-action"],
[data-sidebar="group-action"] {
@apply text-[hsl(var(--sidebar-text-muted))] hover:text-[hsl(var(--sidebar-text))] hover:bg-[hsl(var(--sidebar-hover))]/20 transition-colors duration-200;
}
/* Card elevation utilities */
.card-level-1 {
@apply shadow-[0_4px_6px_-1px_rgba(0,0,0,0.05),0_2px_4px_-1px_rgba(0,0,0,0.05)] hover:shadow-[0_6px_8px_-1px_rgba(0,0,0,0.05),0_4px_6px_-1px_rgba(0,0,0,0.05)];
}
.card-level-2 {
@apply shadow-[0_8px_10px_-2px_rgba(0,0,0,0.05),0_4px_6px_-2px_rgba(0,0,0,0.05)] hover:shadow-[0_10px_12px_-2px_rgba(0,0,0,0.05),0_6px_8px_-2px_rgba(0,0,0,0.05)];
}
.card-level-3 {
@apply shadow-[0_12px_14px_-3px_rgba(0,0,0,0.05),0_6px_8px_-3px_rgba(0,0,0,0.05)] hover:shadow-[0_14px_16px_-3px_rgba(0,0,0,0.05),0_8px_10px_-3px_rgba(0,0,0,0.05)];
}
/* Card styling */
.card {
@apply bg-card text-card-foreground border border-[hsl(var(--card-border))] rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200;
@apply hover:bg-[hsl(var(--card-hover))];
}
/* Add floating effect to cards */
.card-floating {
@apply transform hover:-translate-y-0.5 transition-all duration-200;
}

View File

@@ -41,4 +41,4 @@ const createContext = cache(async () => {
const getQueryClient = cache(() => createQueryClient(defaultQueryClientOptions));
const getCaller = cache(async () => appRouter.createCaller(await createContext()));
export { api };
export { api, getCaller };