mirror of
https://github.com/soconnor0919/hristudio.git
synced 2025-12-11 22:54:45 -05:00
feat: Enhance plugin store and experiment design infrastructure
- Add plugin store system with dynamic loading of robot actions - Implement plugin store API routes and database schema - Update experiment designer to support plugin-based actions - Refactor environment configuration and sidebar navigation - Improve authentication session handling with additional user details - Update Tailwind CSS configuration and global styles - Remove deprecated files and consolidate project structure
This commit is contained in:
88
src/app/api/plugins/install/route.ts
Normal file
88
src/app/api/plugins/install/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
41
src/app/dashboard/experiments/[id]/designer/page.tsx
Normal file
41
src/app/dashboard/experiments/[id]/designer/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
32
src/app/dashboard/store/page.tsx
Normal file
32
src/app/dashboard/store/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import "~/styles/globals.css";
|
||||
import "./globals.css";
|
||||
|
||||
import { GeistSans } from 'geist/font/sans';
|
||||
import { headers } from "next/headers";
|
||||
|
||||
151
src/app/page.tsx
151
src/app/page.tsx
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,17 +25,17 @@ import { cn } from "~/lib/utils"
|
||||
|
||||
export function StudySwitcher() {
|
||||
const { status } = useSession()
|
||||
|
||||
|
||||
// Show nothing while loading to prevent flash
|
||||
if (status === "loading") {
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
return <StudySwitcherContent />
|
||||
}
|
||||
|
||||
function StudySwitcherContent() {
|
||||
const { isMobile } = useSidebar()
|
||||
const { isMobile, state } = useSidebar()
|
||||
const router = useRouter()
|
||||
const { studies, activeStudy, setActiveStudy, isLoading } = useStudy()
|
||||
|
||||
@@ -43,6 +43,8 @@ function StudySwitcherContent() {
|
||||
router.push("/dashboard/studies/new")
|
||||
}
|
||||
|
||||
const isCollapsed = state === "collapsed"
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<SidebarMenu>
|
||||
@@ -54,10 +56,12 @@ function StudySwitcherContent() {
|
||||
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-accent/10">
|
||||
<Notebook className="size-4 text-muted-foreground/50" />
|
||||
</div>
|
||||
<div className="grid flex-1 gap-1">
|
||||
<div className="h-4 w-24 rounded bg-sidebar-accent/10" />
|
||||
<div className="h-3 w-16 rounded bg-sidebar-accent/10" />
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<div className="grid flex-1 gap-1">
|
||||
<div className="h-4 w-24 rounded bg-sidebar-accent/10" />
|
||||
<div className="h-3 w-16 rounded bg-sidebar-accent/10" />
|
||||
</div>
|
||||
)}
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
@@ -76,10 +80,12 @@ function StudySwitcherContent() {
|
||||
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
|
||||
<Plus className="size-4" />
|
||||
</div>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">Create Study</span>
|
||||
<span className="truncate text-xs">Get started</span>
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">Create Study</span>
|
||||
<span className="truncate text-xs">Get started</span>
|
||||
</div>
|
||||
)}
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
@@ -93,22 +99,29 @@ function StudySwitcherContent() {
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
className={cn(
|
||||
"data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground",
|
||||
isCollapsed && "justify-center p-0"
|
||||
)}
|
||||
>
|
||||
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
|
||||
<Notebook className="size-4" />
|
||||
</div>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">
|
||||
{activeStudy?.title ?? "Select Study"}
|
||||
</span>
|
||||
<span className="truncate text-xs">{activeStudy?.role ?? ""}</span>
|
||||
</div>
|
||||
<ChevronsUpDown className="ml-auto size-4" />
|
||||
{!isCollapsed && (
|
||||
<>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">
|
||||
{activeStudy?.title ?? "Select Study"}
|
||||
</span>
|
||||
<span className="truncate text-xs">{activeStudy?.role ?? ""}</span>
|
||||
</div>
|
||||
<ChevronsUpDown className="ml-auto size-4" />
|
||||
</>
|
||||
)}
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
|
||||
className="min-w-56 rounded-lg"
|
||||
align="start"
|
||||
side={isMobile ? "bottom" : "right"}
|
||||
sideOffset={4}
|
||||
|
||||
@@ -30,62 +30,15 @@ import {
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { Switch } from "~/components/ui/switch";
|
||||
import { AVAILABLE_ACTIONS } from "~/lib/experiments/actions";
|
||||
import { type ActionType } from "~/lib/experiments/types";
|
||||
|
||||
// Define parameter schemas for each action type
|
||||
const parameterSchemas = {
|
||||
move: z.object({
|
||||
position: z.object({
|
||||
x: z.number(),
|
||||
y: z.number(),
|
||||
z: z.number(),
|
||||
}),
|
||||
speed: z.number().min(0).max(1),
|
||||
easing: z.enum(["linear", "easeIn", "easeOut", "easeInOut"]),
|
||||
}),
|
||||
speak: z.object({
|
||||
text: z.string().min(1),
|
||||
speed: z.number().min(0.5).max(2),
|
||||
pitch: z.number().min(0.5).max(2),
|
||||
volume: z.number().min(0).max(1),
|
||||
}),
|
||||
wait: z.object({
|
||||
duration: z.number().min(0),
|
||||
showCountdown: z.boolean(),
|
||||
}),
|
||||
input: z.object({
|
||||
type: z.enum(["button", "text", "number", "choice"]),
|
||||
prompt: z.string().optional(),
|
||||
options: z.array(z.string()).optional(),
|
||||
timeout: z.number().nullable(),
|
||||
}),
|
||||
gesture: z.object({
|
||||
name: z.string().min(1),
|
||||
speed: z.number().min(0).max(1),
|
||||
intensity: z.number().min(0).max(1),
|
||||
}),
|
||||
record: z.object({
|
||||
type: z.enum(["start", "stop"]),
|
||||
streams: z.array(z.enum(["video", "audio", "sensors"])),
|
||||
}),
|
||||
condition: z.object({
|
||||
condition: z.string().min(1),
|
||||
trueActions: z.array(z.any()),
|
||||
falseActions: z.array(z.any()).optional(),
|
||||
}),
|
||||
loop: z.object({
|
||||
count: z.number().min(1),
|
||||
actions: z.array(z.any()),
|
||||
}),
|
||||
} satisfies Record<ActionType, z.ZodType<any>>;
|
||||
import { type ActionConfig } from "~/lib/experiments/plugin-actions";
|
||||
|
||||
interface ActionConfigDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
type: ActionType;
|
||||
type: string;
|
||||
parameters: Record<string, any>;
|
||||
onSubmit: (parameters: Record<string, any>) => void;
|
||||
actionConfig: ActionConfig;
|
||||
}
|
||||
|
||||
export function ActionConfigDialog({
|
||||
@@ -94,11 +47,41 @@ export function ActionConfigDialog({
|
||||
type,
|
||||
parameters,
|
||||
onSubmit,
|
||||
actionConfig,
|
||||
}: ActionConfigDialogProps) {
|
||||
const actionConfig = AVAILABLE_ACTIONS.find((a) => a.type === type);
|
||||
if (!actionConfig) return null;
|
||||
// Create a dynamic schema based on the action's parameters
|
||||
const createDynamicSchema = () => {
|
||||
if (!actionConfig) return z.object({});
|
||||
|
||||
const schema = parameterSchemas[type];
|
||||
const schemaFields: Record<string, z.ZodType<any>> = {};
|
||||
|
||||
for (const [key, prop] of Object.entries(actionConfig.defaultParameters)) {
|
||||
switch (typeof prop) {
|
||||
case "string":
|
||||
schemaFields[key] = z.string();
|
||||
break;
|
||||
case "number":
|
||||
schemaFields[key] = z.number();
|
||||
break;
|
||||
case "boolean":
|
||||
schemaFields[key] = z.boolean();
|
||||
break;
|
||||
case "object":
|
||||
if (Array.isArray(prop)) {
|
||||
schemaFields[key] = z.array(z.any());
|
||||
} else {
|
||||
schemaFields[key] = z.record(z.any());
|
||||
}
|
||||
break;
|
||||
default:
|
||||
schemaFields[key] = z.any();
|
||||
}
|
||||
}
|
||||
|
||||
return z.object(schemaFields);
|
||||
};
|
||||
|
||||
const schema = createDynamicSchema();
|
||||
const form = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: parameters,
|
||||
@@ -109,6 +92,104 @@ export function ActionConfigDialog({
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
function renderField(key: string, value: any) {
|
||||
const fieldType = typeof value;
|
||||
|
||||
switch (fieldType) {
|
||||
case "string":
|
||||
if (value.length > 50) {
|
||||
return (
|
||||
<FormField
|
||||
key={key}
|
||||
control={form.control}
|
||||
name={key}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{key}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<FormField
|
||||
key={key}
|
||||
control={form.control}
|
||||
name={key}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{key}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
case "number":
|
||||
return (
|
||||
<FormField
|
||||
key={key}
|
||||
control={form.control}
|
||||
name={key}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{key}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(parseFloat(e.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
case "boolean":
|
||||
return (
|
||||
<FormField
|
||||
key={key}
|
||||
control={form.control}
|
||||
name={key}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center gap-2">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel>{key}</FormLabel>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
case "object":
|
||||
if (Array.isArray(value)) {
|
||||
// TODO: Add array field handling
|
||||
return null;
|
||||
}
|
||||
// TODO: Add object field handling
|
||||
return null;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
@@ -119,280 +200,10 @@ export function ActionConfigDialog({
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
|
||||
{type === "move" && (
|
||||
<>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="position.x"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>X Position</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(parseFloat(e.target.value))
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="position.y"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Y Position</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(parseFloat(e.target.value))
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="position.z"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Z Position</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(parseFloat(e.target.value))
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="speed"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Speed</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="1"
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(parseFloat(e.target.value))
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Movement speed (0-1)
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="easing"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Easing</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select easing type" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="linear">Linear</SelectItem>
|
||||
<SelectItem value="easeIn">Ease In</SelectItem>
|
||||
<SelectItem value="easeOut">Ease Out</SelectItem>
|
||||
<SelectItem value="easeInOut">Ease In Out</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Movement easing function
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
{Object.entries(actionConfig.defaultParameters).map(([key, value]) =>
|
||||
renderField(key, value)
|
||||
)}
|
||||
|
||||
{type === "speak" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="text"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Text</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Enter text to speak"
|
||||
className="resize-none"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="speed"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Speed</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0.5"
|
||||
max="2"
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(parseFloat(e.target.value))
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="pitch"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Pitch</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0.5"
|
||||
max="2"
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(parseFloat(e.target.value))
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="volume"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Volume</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="1"
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(parseFloat(e.target.value))
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === "wait" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="duration"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Duration (ms)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="100"
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(parseFloat(e.target.value))
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Wait duration in milliseconds
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="showCountdown"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">
|
||||
Show Countdown
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
Display a countdown timer during the wait
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Add more action type configurations here */}
|
||||
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit">Save Changes</Button>
|
||||
</div>
|
||||
<Button type="submit">Save Changes</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
|
||||
@@ -17,7 +17,7 @@ import ReactFlow, {
|
||||
} from "reactflow";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { type Step } from "~/lib/experiments/types";
|
||||
import { AVAILABLE_ACTIONS } from "~/lib/experiments/actions";
|
||||
import { BUILT_IN_ACTIONS, getPluginActions, type ActionConfig } from "~/lib/experiments/plugin-actions";
|
||||
import { Card } from "~/components/ui/card";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
|
||||
@@ -27,6 +27,7 @@ import { ActionItem } from "./action-item";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight, Undo, Redo, ZoomIn, ZoomOut } from "lucide-react";
|
||||
import { api } from "~/trpc/react";
|
||||
import "reactflow/dist/style.css";
|
||||
|
||||
const nodeTypes = {
|
||||
@@ -51,35 +52,50 @@ export function ExperimentDesigner({
|
||||
readOnly = false,
|
||||
}: ExperimentDesignerProps) {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
const [selectedNode, setSelectedNode] = useState<Node | null>(null);
|
||||
const reactFlowWrapper = useRef<HTMLDivElement>(null);
|
||||
const [reactFlowInstance, setReactFlowInstance] = useState<any>(null);
|
||||
const [selectedNode, setSelectedNode] = useState<Node | null>(null);
|
||||
|
||||
// History management for undo/redo
|
||||
// Get available plugins
|
||||
const { data: plugins } = api.pluginStore.getPlugins.useQuery();
|
||||
const { data: installedPlugins } = api.pluginStore.getInstalledPlugins.useQuery();
|
||||
const installedPluginIds = installedPlugins?.map(p => p.robotId) ?? [];
|
||||
|
||||
// Get available actions from installed plugins
|
||||
const installedPluginActions = plugins
|
||||
? getPluginActions(plugins.filter(p => installedPluginIds.includes(p.robotId)))
|
||||
: [];
|
||||
|
||||
// Combine built-in actions with plugin actions
|
||||
const availableActions = [...BUILT_IN_ACTIONS, ...installedPluginActions];
|
||||
|
||||
// History management
|
||||
const [history, setHistory] = useState<Step[][]>([defaultSteps]);
|
||||
const [historyIndex, setHistoryIndex] = useState(0);
|
||||
|
||||
const addToHistory = useCallback((newSteps: Step[]) => {
|
||||
setHistory((h) => {
|
||||
const newHistory = h.slice(0, historyIndex + 1);
|
||||
setHistory(prev => {
|
||||
const newHistory = prev.slice(0, historyIndex + 1);
|
||||
return [...newHistory, newSteps];
|
||||
});
|
||||
setHistoryIndex((i) => i + 1);
|
||||
setHistoryIndex(prev => prev + 1);
|
||||
}, [historyIndex]);
|
||||
|
||||
const undo = useCallback(() => {
|
||||
if (historyIndex > 0) {
|
||||
setHistoryIndex((i) => i - 1);
|
||||
setSteps(history[historyIndex - 1]!);
|
||||
onChange?.(history[historyIndex - 1]!);
|
||||
setHistoryIndex(prev => prev - 1);
|
||||
const prevSteps = history[historyIndex - 1]!;
|
||||
setSteps(prevSteps);
|
||||
onChange?.(prevSteps);
|
||||
}
|
||||
}, [history, historyIndex, onChange]);
|
||||
|
||||
const redo = useCallback(() => {
|
||||
if (historyIndex < history.length - 1) {
|
||||
setHistoryIndex((i) => i + 1);
|
||||
setSteps(history[historyIndex + 1]!);
|
||||
onChange?.(history[historyIndex + 1]!);
|
||||
setHistoryIndex(prev => prev + 1);
|
||||
const nextSteps = history[historyIndex + 1]!;
|
||||
setSteps(nextSteps);
|
||||
onChange?.(nextSteps);
|
||||
}
|
||||
}, [history, historyIndex, onChange]);
|
||||
|
||||
@@ -99,8 +115,8 @@ export function ExperimentDesigner({
|
||||
);
|
||||
const actionIndex = stepIndex !== -1
|
||||
? newSteps[stepIndex]!.actions.findIndex(
|
||||
a => a.id === action.id
|
||||
)
|
||||
a => a.id === action.id
|
||||
)
|
||||
: -1;
|
||||
|
||||
if (
|
||||
@@ -136,18 +152,16 @@ export function ExperimentDesigner({
|
||||
|
||||
const onNodesChange = useCallback(
|
||||
(changes: NodeChange[]) => {
|
||||
setNodes((nds) => {
|
||||
const newNodes = applyNodeChanges(changes, nds);
|
||||
// Update selected node
|
||||
const selectedChange = changes.find((c) => c.type === "select");
|
||||
if (selectedChange) {
|
||||
const selected = newNodes.find((n) => n.id === selectedChange.id);
|
||||
setSelectedNode(selected ?? null);
|
||||
}
|
||||
return newNodes;
|
||||
});
|
||||
setNodes((nds) => applyNodeChanges(changes, nds));
|
||||
const selectedChange = changes.find(
|
||||
(change) => change.type === "select"
|
||||
);
|
||||
if (selectedChange) {
|
||||
const node = nodes.find((n) => n.id === selectedChange.id);
|
||||
setSelectedNode(selectedChange.selected ? node : null);
|
||||
}
|
||||
},
|
||||
[]
|
||||
[nodes]
|
||||
);
|
||||
|
||||
const onEdgesChange = useCallback(
|
||||
@@ -159,17 +173,11 @@ export function ExperimentDesigner({
|
||||
|
||||
const onConnect = useCallback(
|
||||
(connection: Connection) => {
|
||||
const newEdge: Edge = {
|
||||
id: `${connection.source}-${connection.target}`,
|
||||
source: connection.source ?? "",
|
||||
target: connection.target ?? "",
|
||||
type: "default",
|
||||
animated: true,
|
||||
};
|
||||
setEdges((eds) => [...eds, newEdge]);
|
||||
if (!connection.source || !connection.target) return;
|
||||
|
||||
const sourceNode = nodes.find((n) => n.id === connection.source);
|
||||
const targetNode = nodes.find((n) => n.id === connection.target);
|
||||
|
||||
if (sourceNode && targetNode) {
|
||||
const newSteps = [...steps];
|
||||
const sourceStep = newSteps.find((s) =>
|
||||
@@ -178,7 +186,7 @@ export function ExperimentDesigner({
|
||||
const targetStep = newSteps.find((s) =>
|
||||
s.actions.some((a) => a.id === targetNode.id)
|
||||
);
|
||||
|
||||
|
||||
if (sourceStep && targetStep) {
|
||||
const sourceAction = sourceStep.actions.find(
|
||||
(a) => a.id === sourceNode.id
|
||||
@@ -227,7 +235,7 @@ export function ExperimentDesigner({
|
||||
y: event.clientY,
|
||||
});
|
||||
|
||||
const actionConfig = AVAILABLE_ACTIONS.find((a) => a.type === type);
|
||||
const actionConfig = availableActions.find((a) => a.type === type);
|
||||
if (!actionConfig) return;
|
||||
|
||||
const newAction = {
|
||||
@@ -251,8 +259,8 @@ export function ExperimentDesigner({
|
||||
);
|
||||
const actionIndex = stepIndex !== -1
|
||||
? newSteps[stepIndex]!.actions.findIndex(
|
||||
a => a.id === newAction.id
|
||||
)
|
||||
a => a.id === newAction.id
|
||||
)
|
||||
: -1;
|
||||
|
||||
if (
|
||||
@@ -284,11 +292,24 @@ export function ExperimentDesigner({
|
||||
addToHistory([...steps, newStep]);
|
||||
onChange?.([...steps, newStep]);
|
||||
},
|
||||
[steps, onChange, reactFlowInstance, addToHistory]
|
||||
[steps, onChange, reactFlowInstance, addToHistory, availableActions]
|
||||
);
|
||||
|
||||
// Group actions by source
|
||||
const groupedActions = availableActions.reduce((acc, action) => {
|
||||
const source = action.pluginId ?
|
||||
plugins?.find(p => p.robotId === action.pluginId)?.name ?? action.pluginId :
|
||||
'Built-in Actions';
|
||||
|
||||
if (!acc[source]) {
|
||||
acc[source] = [];
|
||||
}
|
||||
acc[source].push(action);
|
||||
return acc;
|
||||
}, {} as Record<string, ActionConfig[]>);
|
||||
|
||||
return (
|
||||
<div className={cn("relative flex h-[calc(100vh-16rem)]", className)}>
|
||||
<div className={cn("relative flex h-full", className)}>
|
||||
<AnimatePresence>
|
||||
{sidebarOpen && (
|
||||
<motion.div
|
||||
@@ -298,7 +319,7 @@ export function ExperimentDesigner({
|
||||
transition={{ type: "spring", damping: 20, stiffness: 300 }}
|
||||
className="absolute inset-y-0 left-0 z-30 w-80 overflow-hidden"
|
||||
>
|
||||
<Card className="flex h-full flex-col rounded-r-none border-r-0 shadow-2xl">
|
||||
<Card className="flex h-full flex-col rounded-lg border shadow-2xl">
|
||||
<Tabs defaultValue="actions" className="flex h-full flex-col">
|
||||
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4">
|
||||
<TabsList>
|
||||
@@ -316,23 +337,32 @@ export function ExperimentDesigner({
|
||||
</div>
|
||||
<TabsContent value="actions" className="flex-1 p-0">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-2 p-4">
|
||||
{AVAILABLE_ACTIONS.map((action) => (
|
||||
<ActionItem
|
||||
key={action.type}
|
||||
type={action.type}
|
||||
title={action.title}
|
||||
description={action.description}
|
||||
icon={action.icon}
|
||||
draggable
|
||||
onDragStart={(event) => {
|
||||
event.dataTransfer.setData(
|
||||
"application/reactflow",
|
||||
action.type
|
||||
);
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-6 p-4">
|
||||
{Object.entries(groupedActions).map(([source, actions]) => (
|
||||
<div key={source} className="space-y-2">
|
||||
<h3 className="px-2 text-sm font-medium text-muted-foreground">
|
||||
{source}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{actions.map((action) => (
|
||||
<ActionItem
|
||||
key={action.pluginId ? `${action.pluginId}:${action.type}` : action.type}
|
||||
type={action.type}
|
||||
title={action.title}
|
||||
description={action.description}
|
||||
icon={action.icon}
|
||||
draggable
|
||||
onDragStart={(event) => {
|
||||
event.dataTransfer.setData(
|
||||
"application/reactflow",
|
||||
action.type
|
||||
);
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
@@ -343,7 +373,7 @@ export function ExperimentDesigner({
|
||||
{selectedNode ? (
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-medium">
|
||||
{AVAILABLE_ACTIONS.find((a) => a.type === selectedNode.data.type)?.title}
|
||||
{availableActions.find((a) => a.type === selectedNode.data.type)?.title}
|
||||
</h3>
|
||||
<pre className="rounded-lg bg-muted p-4 text-xs">
|
||||
{JSON.stringify(selectedNode.data.parameters, null, 2)}
|
||||
@@ -397,24 +427,24 @@ export function ExperimentDesigner({
|
||||
className="react-flow-wrapper"
|
||||
>
|
||||
<Background />
|
||||
<Controls />
|
||||
<Controls className="!left-auto !right-8" />
|
||||
<MiniMap
|
||||
nodeColor={(node) => {
|
||||
const action = AVAILABLE_ACTIONS.find(
|
||||
const action = availableActions.find(
|
||||
(a) => a.type === node.data.type
|
||||
);
|
||||
return action ? "hsl(var(--primary) / 0.5)" : "hsl(var(--muted))"
|
||||
}}
|
||||
maskColor="hsl(var(--background))"
|
||||
className="!bg-card/80 !border !border-border rounded-lg backdrop-blur"
|
||||
className="!bottom-8 !left-auto !right-8 !bg-card/80 !border !border-border rounded-lg backdrop-blur"
|
||||
style={{
|
||||
backgroundColor: "hsl(var(--card))",
|
||||
borderRadius: "var(--radius)",
|
||||
}}
|
||||
/>
|
||||
<Panel position="top-center" className="flex gap-2 rounded-lg bg-background/95 px-4 py-2 shadow-md backdrop-blur supports-[backdrop-filter]:bg-background/80">
|
||||
<Panel position="top-right" className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={undo}
|
||||
disabled={historyIndex === 0}
|
||||
@@ -422,28 +452,13 @@ export function ExperimentDesigner({
|
||||
<Undo className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={redo}
|
||||
disabled={historyIndex === history.length - 1}
|
||||
>
|
||||
<Redo className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="mx-2 w-px bg-border" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => reactFlowInstance?.zoomIn()}
|
||||
>
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => reactFlowInstance?.zoomOut()}
|
||||
>
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</Button>
|
||||
</Panel>
|
||||
</ReactFlow>
|
||||
</ReactFlowProvider>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { memo, useState } from "react";
|
||||
import { Handle, Position, type NodeProps } from "reactflow";
|
||||
import { motion } from "framer-motion";
|
||||
import { AVAILABLE_ACTIONS } from "~/lib/experiments/actions";
|
||||
import { BUILT_IN_ACTIONS, getPluginActions } from "~/lib/experiments/plugin-actions";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -16,6 +16,7 @@ import { Settings, ArrowDown, ArrowUp } from "lucide-react";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { ActionConfigDialog } from "../action-config-dialog";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
interface ActionNodeData {
|
||||
type: string;
|
||||
@@ -26,15 +27,34 @@ interface ActionNodeData {
|
||||
export const ActionNode = memo(({ data, selected }: NodeProps<ActionNodeData>) => {
|
||||
const [configOpen, setConfigOpen] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const actionConfig = AVAILABLE_ACTIONS.find((a) => a.type === data.type);
|
||||
|
||||
// Get available plugins
|
||||
const { data: plugins } = api.pluginStore.getPlugins.useQuery();
|
||||
const { data: installedPlugins } = api.pluginStore.getInstalledPlugins.useQuery();
|
||||
const installedPluginIds = installedPlugins?.map(p => p.robotId) ?? [];
|
||||
|
||||
// Get available actions from installed plugins
|
||||
const installedPluginActions = plugins
|
||||
? getPluginActions(plugins.filter(p => installedPluginIds.includes(p.robotId)))
|
||||
: [];
|
||||
|
||||
// Combine built-in actions with plugin actions
|
||||
const availableActions = [...BUILT_IN_ACTIONS, ...installedPluginActions];
|
||||
const actionConfig = availableActions.find((a) => a.type === data.type);
|
||||
|
||||
if (!actionConfig) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
className="!bg-primary !border-primary-foreground"
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{
|
||||
scale: 1,
|
||||
animate={{
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
}}
|
||||
transition={{ duration: 0.2 }}
|
||||
@@ -48,79 +68,47 @@ export const ActionNode = memo(({ data, selected }: NodeProps<ActionNodeData>) =
|
||||
isHovered && "before:from-border/80 before:to-border/30",
|
||||
)}
|
||||
>
|
||||
<Card className="relative z-10 w-[250px] bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 border-none">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4">
|
||||
<Card className="relative z-10 min-w-[240px] overflow-hidden">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-gradient-to-br from-primary/20 to-primary/10 text-primary">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-gradient-to-br from-primary/20 to-primary/10">
|
||||
{actionConfig.icon}
|
||||
</div>
|
||||
<CardTitle className="text-sm font-medium leading-none">
|
||||
{actionConfig.title}
|
||||
</CardTitle>
|
||||
<CardTitle className="text-base">{actionConfig.title}</CardTitle>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => setConfigOpen(true)}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => setConfigOpen(true)}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Configure Action</TooltipContent>
|
||||
</Tooltip>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0">
|
||||
<CardDescription className="text-xs">
|
||||
<CardContent>
|
||||
<CardDescription className="line-clamp-2">
|
||||
{actionConfig.description}
|
||||
</CardDescription>
|
||||
</CardContent>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
className={cn(
|
||||
"!h-3 !w-3 !border-2 !bg-background",
|
||||
"!border-border transition-colors duration-200",
|
||||
"data-[connecting=true]:!border-primary data-[connecting=true]:!bg-primary",
|
||||
"before:absolute before:inset-[-4px] before:rounded-full before:border-2 before:border-background",
|
||||
"after:absolute after:inset-[-8px] after:rounded-full after:border-2 after:border-border/50"
|
||||
)}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="flex items-center gap-2">
|
||||
<ArrowDown className="h-3 w-3" />
|
||||
Input Connection
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
className={cn(
|
||||
"!h-3 !w-3 !border-2 !bg-background",
|
||||
"!border-border transition-colors duration-200",
|
||||
"data-[connecting=true]:!border-primary data-[connecting=true]:!bg-primary",
|
||||
"before:absolute before:inset-[-4px] before:rounded-full before:border-2 before:border-background",
|
||||
"after:absolute after:inset-[-8px] after:rounded-full after:border-2 after:border-border/50"
|
||||
)}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="flex items-center gap-2">
|
||||
<ArrowUp className="h-3 w-3" />
|
||||
Output Connection
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
className="!bg-primary !border-primary-foreground"
|
||||
/>
|
||||
<ActionConfigDialog
|
||||
open={configOpen}
|
||||
onOpenChange={setConfigOpen}
|
||||
type={data.type as any}
|
||||
type={data.type}
|
||||
parameters={data.parameters}
|
||||
onSubmit={data.onChange ?? (() => {})}
|
||||
onSubmit={data.onChange ?? (() => { })}
|
||||
actionConfig={actionConfig}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
73
src/components/home/cta-section.tsx
Normal file
73
src/components/home/cta-section.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { BotIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent } from "~/components/ui/card";
|
||||
|
||||
interface CTASectionProps {
|
||||
isLoggedIn: boolean;
|
||||
}
|
||||
|
||||
export function CTASection({ isLoggedIn }: CTASectionProps) {
|
||||
return (
|
||||
<section className="container mx-auto px-4 py-24">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Card className="relative overflow-hidden">
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-primary via-primary to-secondary" />
|
||||
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_50%_120%,rgba(0,0,0,0)_30%,rgba(0,0,0,0.15)_100%)]" />
|
||||
<CardContent className="relative p-12 flex flex-col items-center text-center space-y-6 text-primary-foreground">
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
whileInView={{ scale: 1, opacity: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.2, duration: 0.5 }}
|
||||
>
|
||||
<BotIcon className="size-12 mb-4" />
|
||||
</motion.div>
|
||||
<motion.h2
|
||||
initial={{ y: 20, opacity: 0 }}
|
||||
whileInView={{ y: 0, opacity: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.3, duration: 0.5 }}
|
||||
className="text-3xl font-bold tracking-tight"
|
||||
>
|
||||
Ready to Transform Your Research?
|
||||
</motion.h2>
|
||||
<motion.p
|
||||
initial={{ y: 20, opacity: 0 }}
|
||||
whileInView={{ y: 0, opacity: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.4, duration: 0.5 }}
|
||||
className="text-primary-foreground/90 max-w-[600px]"
|
||||
>
|
||||
Join the growing community of researchers using HRIStudio to advance human-robot interaction studies.
|
||||
</motion.p>
|
||||
<motion.div
|
||||
initial={{ y: 20, opacity: 0 }}
|
||||
whileInView={{ y: 0, opacity: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.5, duration: 0.5 }}
|
||||
>
|
||||
{!isLoggedIn ? (
|
||||
<Button size="lg" variant="secondary" asChild className="mt-4 bg-background/20 hover:bg-background/30">
|
||||
<Link href="/auth/signup">Start Your Journey</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="lg" variant="secondary" asChild className="mt-4 bg-background/20 hover:bg-background/30">
|
||||
<Link href="/dashboard">Go to Dashboard</Link>
|
||||
</Button>
|
||||
)}
|
||||
</motion.div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
67
src/components/home/features-section.tsx
Normal file
67
src/components/home/features-section.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { Sparkles, Brain, Microscope } from "lucide-react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: <Sparkles className="size-6 text-primary" />,
|
||||
title: "Visual Experiment Design",
|
||||
description: "Create and configure experiments using an intuitive drag-and-drop interface without extensive coding."
|
||||
},
|
||||
{
|
||||
icon: <Brain className="size-6 text-primary" />,
|
||||
title: "Real-time Control",
|
||||
description: "Execute experiments with synchronized views for wizards and observers, enabling seamless collaboration."
|
||||
},
|
||||
{
|
||||
icon: <Microscope className="size-6 text-primary" />,
|
||||
title: "Comprehensive Analysis",
|
||||
description: "Record, playback, and analyze experimental data with built-in annotation and export tools."
|
||||
}
|
||||
];
|
||||
|
||||
export function FeaturesSection() {
|
||||
return (
|
||||
<section className="container mx-auto px-4 py-24 space-y-12">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="text-center space-y-4"
|
||||
>
|
||||
<h2 className="text-3xl font-bold tracking-tight bg-gradient-to-br from-foreground to-foreground/70 bg-clip-text text-transparent inline-block">
|
||||
Powerful Features for HRI Research
|
||||
</h2>
|
||||
<p className="text-muted-foreground max-w-[600px] mx-auto">
|
||||
Everything you need to design, execute, and analyze your human-robot interaction experiments.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-8">
|
||||
{features.map((feature, index) => (
|
||||
<motion.div
|
||||
key={feature.title}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: index * 0.1, duration: 0.5 }}
|
||||
>
|
||||
<Card className="group relative overflow-hidden border bg-background/60 backdrop-blur supports-[backdrop-filter]:bg-background/60 hover:shadow-lg transition-all">
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-primary/10 via-primary/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
<CardHeader>
|
||||
<div className="size-12 rounded-lg bg-gradient-to-br from-primary/20 to-primary/10 flex items-center justify-center mb-4">
|
||||
{feature.icon}
|
||||
</div>
|
||||
<CardTitle>{feature.title}</CardTitle>
|
||||
<CardDescription>{feature.description}</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
101
src/components/home/hero-section.tsx
Normal file
101
src/components/home/hero-section.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { BotIcon, ArrowRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Button } from "~/components/ui/button";
|
||||
|
||||
interface HeroSectionProps {
|
||||
isLoggedIn: boolean;
|
||||
}
|
||||
|
||||
export function HeroSection({ isLoggedIn }: HeroSectionProps) {
|
||||
return (
|
||||
<section className="relative">
|
||||
{/* Hero gradient background */}
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-background via-primary/5 to-background">
|
||||
<div className="absolute inset-0"
|
||||
style={{
|
||||
backgroundImage: `radial-gradient(circle at 50% 50%, hsl(var(--primary)/.08) 0%, transparent 50%)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="container mx-auto px-4 py-24 relative">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="grid lg:grid-cols-2 gap-12 items-center"
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2, duration: 0.5 }}
|
||||
className="inline-flex rounded-lg bg-gradient-to-br from-primary/20 via-secondary/20 to-background p-1 mb-8"
|
||||
>
|
||||
<span className="rounded-md bg-background/95 px-3 py-1 text-sm backdrop-blur">
|
||||
Now with Visual Experiment Designer
|
||||
</span>
|
||||
</motion.div>
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3, duration: 0.5 }}
|
||||
className="text-4xl font-bold tracking-tight lg:text-6xl bg-gradient-to-br from-foreground via-foreground/90 to-foreground/70 bg-clip-text text-transparent"
|
||||
>
|
||||
Streamline Your HRI Research
|
||||
</motion.h1>
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.4, duration: 0.5 }}
|
||||
className="text-xl text-muted-foreground"
|
||||
>
|
||||
A comprehensive platform for designing, executing, and analyzing Wizard-of-Oz experiments in human-robot interaction studies.
|
||||
</motion.p>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.5, duration: 0.5 }}
|
||||
className="flex flex-col sm:flex-row gap-4 pt-4"
|
||||
>
|
||||
{!isLoggedIn ? (
|
||||
<Button size="lg" className="w-full sm:w-auto group bg-gradient-to-r from-primary to-primary hover:from-primary/90 hover:to-primary" asChild>
|
||||
<Link href="/auth/signup">
|
||||
Get Started
|
||||
<ArrowRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="lg" className="w-full sm:w-auto group bg-gradient-to-r from-primary to-primary hover:from-primary/90 hover:to-primary" asChild>
|
||||
<Link href="/dashboard">
|
||||
Go to Dashboard
|
||||
<ArrowRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
<Button size="lg" variant="outline" className="w-full sm:w-auto" asChild>
|
||||
<Link href="https://github.com/soconnor0919/hristudio" target="_blank">
|
||||
View on GitHub
|
||||
</Link>
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.4, duration: 0.5 }}
|
||||
className="relative aspect-square lg:aspect-video"
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/30 via-secondary/20 to-background rounded-lg border shadow-xl" />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<BotIcon className="h-32 w-32 text-primary/40" />
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,12 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
Beaker,
|
||||
Home,
|
||||
Settings2,
|
||||
User,
|
||||
Microscope,
|
||||
Users,
|
||||
Plus,
|
||||
FlaskConical
|
||||
FlaskConical,
|
||||
Bot
|
||||
} from "lucide-react"
|
||||
import * as React from "react"
|
||||
import { useSession } from "next-auth/react"
|
||||
@@ -42,16 +40,11 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
title: "Studies",
|
||||
url: "/dashboard/studies",
|
||||
icon: Microscope,
|
||||
items: [
|
||||
{
|
||||
title: "All Studies",
|
||||
url: "/dashboard/studies",
|
||||
},
|
||||
{
|
||||
title: "Create Study",
|
||||
url: "/dashboard/studies/new",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Robot Store",
|
||||
url: "/dashboard/store",
|
||||
icon: Bot,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -62,34 +55,11 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
title: "Participants",
|
||||
url: `/dashboard/studies/${activeStudy.id}/participants`,
|
||||
icon: Users,
|
||||
items: [
|
||||
{
|
||||
title: "All Participants",
|
||||
url: `/dashboard/studies/${activeStudy.id}/participants`,
|
||||
},
|
||||
{
|
||||
title: "Add Participant",
|
||||
url: `/dashboard/studies/${activeStudy.id}/participants/new`,
|
||||
// Only show if user is admin
|
||||
hidden: activeStudy.role !== "ADMIN",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Experiments",
|
||||
url: `/dashboard/studies/${activeStudy.id}/experiments`,
|
||||
icon: FlaskConical,
|
||||
items: [
|
||||
{
|
||||
title: "All Experiments",
|
||||
url: `/dashboard/studies/${activeStudy.id}/experiments`,
|
||||
},
|
||||
{
|
||||
title: "Create Experiment",
|
||||
url: `/dashboard/studies/${activeStudy.id}/experiments/new`,
|
||||
hidden: !["OWNER", "ADMIN", "PRINCIPAL_INVESTIGATOR"].map(r => r.toLowerCase()).includes(activeStudy.role.toLowerCase()),
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
: []
|
||||
@@ -100,22 +70,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
title: "Settings",
|
||||
url: "/dashboard/settings",
|
||||
icon: Settings2,
|
||||
items: [
|
||||
{
|
||||
title: "Account",
|
||||
url: "/dashboard/account",
|
||||
icon: User,
|
||||
},
|
||||
{
|
||||
title: "Team",
|
||||
url: "/dashboard/settings/team",
|
||||
},
|
||||
{
|
||||
title: "Billing",
|
||||
url: "/dashboard/settings/billing",
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
const navItems = [...baseNavItems, ...studyNavItems, ...settingsNavItems]
|
||||
|
||||
@@ -8,16 +8,19 @@ import { Logo } from "~/components/logo"
|
||||
export function Header() {
|
||||
return (
|
||||
<div className="sticky top-0 z-40 w-full">
|
||||
<header className="mx-2 mt-2 flex h-14 items-center justify-between rounded-lg border bg-gradient-to-r from-[hsl(var(--sidebar-gradient-from))] to-[hsl(var(--sidebar-gradient-to))] px-6 shadow-sm md:ml-0">
|
||||
<header
|
||||
data-nav="header"
|
||||
className="mx-2 mt-2 flex h-14 items-center justify-between rounded-lg border shadow-sm md:ml-0 px-6"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<SidebarTrigger className="-ml-2 text-[hsl(var(--sidebar-text))] hover:bg-[hsl(var(--sidebar-text))]/10" />
|
||||
<Separator orientation="vertical" className="h-4 bg-[hsl(var(--sidebar-text))]/10" />
|
||||
<SidebarTrigger className="-ml-2 text-[hsl(var(--sidebar-foreground))] hover:bg-[hsl(var(--sidebar-hover))]/20" />
|
||||
<Separator orientation="vertical" className="h-4 bg-[hsl(var(--sidebar-border))]" />
|
||||
<BreadcrumbNav />
|
||||
</div>
|
||||
<Logo
|
||||
<Logo
|
||||
href="/dashboard"
|
||||
className="text-[hsl(var(--sidebar-text))]"
|
||||
iconClassName="text-[hsl(var(--sidebar-text-muted))]"
|
||||
className="text-[hsl(var(--sidebar-foreground))]"
|
||||
iconClassName="text-[hsl(var(--sidebar-muted))]"
|
||||
/>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
@@ -1,73 +1,62 @@
|
||||
"use client"
|
||||
|
||||
import { ChevronRight, type LucideIcon } from "lucide-react"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { type LucideIcon } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "~/components/ui/collapsible"
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
} from "~/components/ui/sidebar"
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
export function NavMain({
|
||||
items,
|
||||
}: {
|
||||
items: {
|
||||
title: string
|
||||
url: string
|
||||
icon?: LucideIcon
|
||||
isActive?: boolean
|
||||
items?: {
|
||||
title: string
|
||||
url: string
|
||||
}[]
|
||||
}[]
|
||||
}) {
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Platform</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
{items.map((item) => (
|
||||
<Collapsible
|
||||
key={item.title}
|
||||
asChild
|
||||
defaultOpen={item.isActive}
|
||||
className="group/collapsible"
|
||||
>
|
||||
<SidebarMenuItem>
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuButton tooltip={item.title}>
|
||||
{item.icon && <item.icon />}
|
||||
<span>{item.title}</span>
|
||||
<ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
{item.items?.map((subItem) => (
|
||||
<SidebarMenuSubItem key={subItem.title}>
|
||||
<SidebarMenuSubButton asChild>
|
||||
<a href={subItem.url}>
|
||||
<span>{subItem.title}</span>
|
||||
</a>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</SidebarMenuItem>
|
||||
</Collapsible>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
)
|
||||
interface NavItem {
|
||||
title: string
|
||||
url: string
|
||||
icon: LucideIcon
|
||||
}
|
||||
|
||||
export function NavMain({ items }: { items: NavItem[] }) {
|
||||
const pathname = usePathname()
|
||||
|
||||
// Find the most specific matching route
|
||||
const activeItem = items
|
||||
.filter(item => {
|
||||
if (item.url === "/dashboard") {
|
||||
return pathname === "/dashboard"
|
||||
}
|
||||
return pathname.startsWith(item.url)
|
||||
})
|
||||
.sort((a, b) => b.url.length - a.url.length)[0]
|
||||
|
||||
return (
|
||||
<SidebarMenu className="pt-2">
|
||||
{items.map((item) => {
|
||||
const isActive = item.url === activeItem?.url
|
||||
|
||||
return (
|
||||
<SidebarMenuItem key={item.url}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={isActive}
|
||||
tooltip={item.title}
|
||||
className={cn(
|
||||
"relative flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm outline-none transition-colors",
|
||||
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
"focus-visible:ring-2 focus-visible:ring-sidebar-ring",
|
||||
"group-data-[collapsible=icon]:px-0 group-data-[collapsible=icon]:justify-center",
|
||||
isActive && "bg-sidebar-accent font-medium text-sidebar-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<Link href={item.url} className="flex items-center gap-2 w-full group-data-[collapsible=icon]:justify-center">
|
||||
<item.icon className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate group-data-[collapsible=icon]:hidden">{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
})}
|
||||
</SidebarMenu>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { ChevronsUpDown, LogOut, Settings, User } from "lucide-react"
|
||||
import { useSession } from "next-auth/react"
|
||||
import { useSession, signOut } from "next-auth/react"
|
||||
import Link from "next/link"
|
||||
import Image from "next/image"
|
||||
|
||||
@@ -20,9 +20,13 @@ import {
|
||||
SidebarMenuItem,
|
||||
} from "~/components/ui/sidebar"
|
||||
import { Avatar, AvatarFallback } from "~/components/ui/avatar"
|
||||
import { useSidebar } from "~/components/ui/sidebar"
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
export function NavUser() {
|
||||
const { data: session, status } = useSession()
|
||||
const { state } = useSidebar()
|
||||
const isCollapsed = state === "collapsed"
|
||||
|
||||
if (status === "loading") {
|
||||
return (
|
||||
@@ -30,15 +34,20 @@ export function NavUser() {
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className="animate-pulse"
|
||||
className={cn(
|
||||
"animate-pulse",
|
||||
isCollapsed && "justify-center p-0"
|
||||
)}
|
||||
>
|
||||
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-accent/10">
|
||||
<User className="size-4 text-muted-foreground/50" />
|
||||
</div>
|
||||
<div className="grid flex-1 gap-1">
|
||||
<div className="h-4 w-24 rounded bg-sidebar-accent/10" />
|
||||
<div className="h-3 w-16 rounded bg-sidebar-accent/10" />
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<div className="grid flex-1 gap-1">
|
||||
<div className="h-4 w-24 rounded bg-sidebar-accent/10" />
|
||||
<div className="h-3 w-16 rounded bg-sidebar-accent/10" />
|
||||
</div>
|
||||
)}
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
@@ -56,7 +65,10 @@ export function NavUser() {
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
className={cn(
|
||||
"data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground",
|
||||
isCollapsed && "justify-center p-0"
|
||||
)}
|
||||
>
|
||||
<Avatar className="size-8 rounded-lg">
|
||||
{session.user.image ? (
|
||||
@@ -79,19 +91,23 @@ export function NavUser() {
|
||||
</AvatarFallback>
|
||||
)}
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">
|
||||
{session.user.name ?? "User"}
|
||||
</span>
|
||||
<span className="truncate text-xs text-sidebar-muted">
|
||||
{session.user.email}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronsUpDown className="ml-auto size-4" />
|
||||
{!isCollapsed && (
|
||||
<>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">
|
||||
{session.user.name ?? "User"}
|
||||
</span>
|
||||
<span className="truncate text-xs text-sidebar-muted">
|
||||
{session.user.email}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronsUpDown className="ml-auto size-4" />
|
||||
</>
|
||||
)}
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
|
||||
className="min-w-56 rounded-lg"
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
>
|
||||
@@ -138,11 +154,12 @@ export function NavUser() {
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/api/auth/signout">
|
||||
<LogOut className="mr-2 size-4" />
|
||||
Sign out
|
||||
</Link>
|
||||
<DropdownMenuItem
|
||||
onClick={() => signOut({ callbackUrl: "/auth/signin" })}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<LogOut className="mr-2 size-4" />
|
||||
Sign out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
24
src/components/providers/index.tsx
Normal file
24
src/components/providers/index.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { StudyProvider } from "./study-provider";
|
||||
import { PluginStoreProvider } from "./plugin-store-provider";
|
||||
import { Toaster } from "~/components/ui/toaster";
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<PluginStoreProvider>
|
||||
<StudyProvider>
|
||||
{children}
|
||||
<Toaster />
|
||||
</StudyProvider>
|
||||
</PluginStoreProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
50
src/components/providers/plugin-store-provider.tsx
Normal file
50
src/components/providers/plugin-store-provider.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useState } from "react";
|
||||
import { type RobotPlugin } from "~/lib/plugin-store/types";
|
||||
|
||||
interface PluginStoreContextType {
|
||||
plugins: RobotPlugin[];
|
||||
selectedPlugin?: RobotPlugin;
|
||||
selectPlugin: (robotId: string) => void;
|
||||
setPlugins: (plugins: RobotPlugin[]) => void;
|
||||
}
|
||||
|
||||
const PluginStoreContext = createContext<PluginStoreContextType | undefined>(undefined);
|
||||
|
||||
export function PluginStoreProvider({
|
||||
children,
|
||||
initialPlugins = [],
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
initialPlugins?: RobotPlugin[];
|
||||
}) {
|
||||
const [plugins, setPlugins] = useState<RobotPlugin[]>(initialPlugins);
|
||||
const [selectedPlugin, setSelectedPlugin] = useState<RobotPlugin>();
|
||||
|
||||
const selectPlugin = (robotId: string) => {
|
||||
const plugin = plugins.find(p => p.robotId === robotId);
|
||||
setSelectedPlugin(plugin);
|
||||
};
|
||||
|
||||
return (
|
||||
<PluginStoreContext.Provider
|
||||
value={{
|
||||
plugins,
|
||||
selectedPlugin,
|
||||
selectPlugin,
|
||||
setPlugins,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</PluginStoreContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function usePluginStore() {
|
||||
const context = useContext(PluginStoreContext);
|
||||
if (!context) {
|
||||
throw new Error("usePluginStore must be used within a PluginStoreProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
123
src/components/store/add-repository-dialog.tsx
Normal file
123
src/components/store/add-repository-dialog.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useToast } from "~/hooks/use-toast";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Plus } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "~/components/ui/dialog";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { api } from "~/trpc/react";
|
||||
import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||
|
||||
export function AddRepositoryDialog() {
|
||||
const router = useRouter();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [url, setUrl] = useState("");
|
||||
const { toast } = useToast();
|
||||
const utils = api.useUtils();
|
||||
|
||||
const addRepository = api.pluginStore.addRepository.useMutation({
|
||||
onSuccess: async () => {
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Repository added successfully",
|
||||
});
|
||||
setIsOpen(false);
|
||||
setUrl("");
|
||||
|
||||
// Invalidate and refetch all plugin store queries
|
||||
await Promise.all([
|
||||
utils.pluginStore.getRepositories.invalidate(),
|
||||
utils.pluginStore.getPlugins.invalidate(),
|
||||
utils.pluginStore.getInstalledPlugins.invalidate(),
|
||||
]);
|
||||
|
||||
// Force refetch
|
||||
await Promise.all([
|
||||
utils.pluginStore.getRepositories.refetch(),
|
||||
utils.pluginStore.getPlugins.refetch(),
|
||||
utils.pluginStore.getInstalledPlugins.refetch(),
|
||||
]);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to add repository:", error);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: error.message || "Failed to add repository",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleAddRepository = async () => {
|
||||
if (!url) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Please enter a repository URL",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await addRepository.mutateAsync({ url });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Repository
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Plugin Repository</DialogTitle>
|
||||
<DialogDescription>
|
||||
Enter the URL of a plugin repository. The repository must contain a repository.json file and follow the HRIStudio plugin repository structure.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
Example repository URL:
|
||||
<code className="ml-2 rounded bg-muted px-1.5 py-0.5">
|
||||
https://soconnor0919.github.io/robot-plugins
|
||||
</code>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="grid gap-2">
|
||||
<Input
|
||||
placeholder="Enter repository URL"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={handleAddRepository}
|
||||
disabled={isLoading || addRepository.isLoading}
|
||||
>
|
||||
{isLoading || addRepository.isLoading ? "Adding..." : "Add Repository"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
254
src/components/store/plugin-browser.tsx
Normal file
254
src/components/store/plugin-browser.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { type RepositoryMetadata, type RobotPlugin } from "~/lib/plugin-store/types";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Bot, Search, Filter } from "lucide-react";
|
||||
import { RepositorySection } from "./repository-section";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||
import { RobotGrid } from "./robot-grid";
|
||||
import { RobotDetails } from "./robot-details";
|
||||
import { api } from "~/trpc/react";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import { Skeleton } from "~/components/ui/skeleton";
|
||||
|
||||
interface PluginBrowserProps {
|
||||
repositories: RepositoryMetadata[];
|
||||
initialPlugins: RobotPlugin[];
|
||||
}
|
||||
|
||||
function RobotSkeleton() {
|
||||
return (
|
||||
<div className="flex gap-3 rounded-lg border p-4">
|
||||
<div className="relative aspect-square h-20 shrink-0 overflow-hidden rounded-md border bg-muted">
|
||||
<Skeleton className="h-full w-full" />
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Skeleton className="h-6 w-32" />
|
||||
<Skeleton className="h-5 w-20" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PluginBrowser({ repositories, initialPlugins }: PluginBrowserProps) {
|
||||
// State
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedRepository, setSelectedRepository] = useState<string>("all");
|
||||
const [showInstalled, setShowInstalled] = useState<boolean>(true);
|
||||
const [showAvailable, setShowAvailable] = useState<boolean>(true);
|
||||
const [selectedRobot, setSelectedRobot] = useState<RobotPlugin | null>(
|
||||
initialPlugins[0] ?? null
|
||||
);
|
||||
|
||||
// Queries
|
||||
const { data: installedPlugins, isLoading: isLoadingInstalled } = api.pluginStore.getInstalledPlugins.useQuery(undefined, {
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
const { data: plugins, isLoading: isLoadingPlugins } = api.pluginStore.getPlugins.useQuery(undefined, {
|
||||
initialData: initialPlugins,
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
const installedPluginIds = installedPlugins?.map(p => p.robotId) ?? [];
|
||||
|
||||
// Loading state
|
||||
const isLoading = isLoadingInstalled || isLoadingPlugins;
|
||||
|
||||
// Filter plugins
|
||||
const filteredPlugins = plugins.filter(plugin => {
|
||||
// Repository filter
|
||||
if (selectedRepository !== "all" && plugin.repositoryId !== selectedRepository) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Installation status filter
|
||||
const isInstalled = installedPluginIds.includes(plugin.robotId);
|
||||
if (!showInstalled && isInstalled) return false;
|
||||
if (!showAvailable && !isInstalled) return false;
|
||||
|
||||
// Search query filter
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
return (
|
||||
plugin.name.toLowerCase().includes(query) ||
|
||||
plugin.description?.toLowerCase().includes(query) ||
|
||||
plugin.platform.toLowerCase().includes(query) ||
|
||||
plugin.manufacturer.name.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="plugins" className="space-y-6">
|
||||
<TabsList>
|
||||
<TabsTrigger value="plugins">Robots</TabsTrigger>
|
||||
<TabsTrigger value="repositories">Repositories</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="plugins">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Robot Plugins</CardTitle>
|
||||
<CardDescription>
|
||||
Browse and manage robot plugins from your configured repositories
|
||||
</CardDescription>
|
||||
<div className="mt-4 flex flex-col gap-4 md:flex-row md:items-center">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search robots..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={selectedRepository}
|
||||
onValueChange={setSelectedRepository}
|
||||
>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue placeholder="Select Repository" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Repositories</SelectItem>
|
||||
{repositories.map((repo) => (
|
||||
<SelectItem key={repo.id} value={repo.id}>
|
||||
{repo.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon">
|
||||
<Filter className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Show</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={showInstalled}
|
||||
onCheckedChange={setShowInstalled}
|
||||
>
|
||||
Installed
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={showAvailable}
|
||||
onCheckedChange={setShowAvailable}
|
||||
>
|
||||
Available
|
||||
</DropdownMenuCheckboxItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid h-[calc(100vh-24rem)] grid-cols-[400px_1fr] gap-8">
|
||||
{/* Left Pane - Robot List */}
|
||||
<div className="overflow-y-auto rounded-lg pr-4">
|
||||
{isLoading ? (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<RobotSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<RobotGrid
|
||||
plugins={filteredPlugins}
|
||||
installedPluginIds={installedPluginIds}
|
||||
selectedRobotId={selectedRobot?.robotId}
|
||||
onSelectRobot={setSelectedRobot}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Pane - Robot Details */}
|
||||
<div className="overflow-y-auto rounded-lg border bg-card">
|
||||
{isLoading ? (
|
||||
<div className="p-6">
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<div className="flex gap-4">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : selectedRobot && (
|
||||
<RobotDetails
|
||||
robot={selectedRobot}
|
||||
isInstalled={installedPluginIds.includes(selectedRobot.robotId)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="repositories">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Plugin Repositories</CardTitle>
|
||||
<CardDescription>
|
||||
Manage your robot plugin sources
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{repositories.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] text-center">
|
||||
<Bot className="h-16 w-16 text-muted-foreground/50 mb-4" />
|
||||
<h3 className="text-lg font-medium">No Repositories Added</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Add a repository using the button above
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<RepositorySection repositories={repositories} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
98
src/components/store/repository-card.tsx
Normal file
98
src/components/store/repository-card.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { type RepositoryMetadata } from "~/lib/plugin-store/types";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Bot, Star, Download, Package, Calendar } from "lucide-react";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import Image from "next/image";
|
||||
|
||||
interface RepositoryCardProps {
|
||||
repository: RepositoryMetadata;
|
||||
onRemove?: (id: string) => void;
|
||||
}
|
||||
|
||||
export function RepositoryCard({ repository, onRemove }: RepositoryCardProps) {
|
||||
const lastUpdated = new Date(repository.lastUpdated);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative aspect-square h-12 shrink-0 overflow-hidden rounded-md border bg-muted">
|
||||
{repository.assets?.logo ? (
|
||||
<Image
|
||||
src={repository.assets.logo}
|
||||
alt={repository.name}
|
||||
fill
|
||||
className="object-contain p-1.5"
|
||||
/>
|
||||
) : repository.assets?.icon ? (
|
||||
<Image
|
||||
src={repository.assets.icon}
|
||||
alt={repository.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Bot className="h-6 w-6 text-muted-foreground/50" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<span className="truncate">{repository.name}</span>
|
||||
{repository.official && (
|
||||
<Badge variant="default" className="shrink-0 text-xs">Official</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription className="line-clamp-2">{repository.description}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4">
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Star className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{repository.stats?.stars ?? 0}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Download className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{repository.stats?.downloads ?? 0}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Package className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{repository.stats?.plugins ?? 0} plugins</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||
<span>Updated {formatDistanceToNow(lastUpdated, { addSuffix: true })}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{repository.tags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="text-xs">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
{onRemove && !repository.official && (
|
||||
<CardFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => onRemove(repository.id)}
|
||||
>
|
||||
Remove Repository
|
||||
</Button>
|
||||
</CardFooter>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
339
src/components/store/repository-section.tsx
Normal file
339
src/components/store/repository-section.tsx
Normal file
@@ -0,0 +1,339 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useToast } from "~/hooks/use-toast";
|
||||
import { type RepositoryMetadata } from "~/lib/plugin-store/types";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Bot, Star, Download, Package, Calendar } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||
import { api } from "~/trpc/react";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
|
||||
interface RepositorySectionProps {
|
||||
repositories: RepositoryMetadata[];
|
||||
}
|
||||
|
||||
function RepositoryListItem({
|
||||
repository,
|
||||
isSelected,
|
||||
onSelect,
|
||||
onRemove,
|
||||
}: {
|
||||
repository: RepositoryMetadata;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
onRemove?: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative flex cursor-pointer gap-3 rounded-lg border p-4 transition-all",
|
||||
isSelected
|
||||
? "border-primary bg-card ring-2 ring-primary/10"
|
||||
: "hover:border-primary/50 hover:bg-accent/50"
|
||||
)}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<div className="relative aspect-square h-20 shrink-0 overflow-hidden rounded-md border bg-muted">
|
||||
{repository.assets?.logo ? (
|
||||
<Image
|
||||
src={repository.assets.logo}
|
||||
alt={repository.name}
|
||||
fill
|
||||
className="object-contain p-2"
|
||||
/>
|
||||
) : repository.assets?.icon ? (
|
||||
<Image
|
||||
src={repository.assets.icon}
|
||||
alt={repository.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Bot className="h-10 w-10 text-muted-foreground/50" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="line-clamp-1 font-semibold tracking-tight">{repository.name}</h3>
|
||||
{repository.official && (
|
||||
<Badge variant="default" className="shrink-0">Official</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="line-clamp-2 text-sm text-muted-foreground">
|
||||
{repository.description}
|
||||
</p>
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<Star className="h-3 w-3" />
|
||||
<span>{repository.stats?.stars ?? 0}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Download className="h-3 w-3" />
|
||||
<span>{repository.stats?.downloads ?? 0}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Package className="h-3 w-3" />
|
||||
<span>{repository.stats?.plugins ?? 0} plugins</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RepositoryDetails({ repository, onRemove }: { repository: RepositoryMetadata; onRemove?: (id: string) => void }) {
|
||||
return (
|
||||
<div className="overflow-y-auto rounded-lg border bg-card">
|
||||
<div className="border-b p-6">
|
||||
<div className="mb-4 flex items-start justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold">{repository.name}</h2>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{repository.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" asChild>
|
||||
<a
|
||||
href={repository.urls.repository}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
View Repository
|
||||
</a>
|
||||
</Button>
|
||||
{repository.urls.git && (
|
||||
<Button variant="outline" asChild>
|
||||
<a
|
||||
href={repository.urls.git}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
View on GitHub
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
{onRemove && !repository.official && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => onRemove(repository.id)}
|
||||
>
|
||||
Remove Repository
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Package className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{repository.stats?.plugins ?? 0} plugins</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||
<span>Updated {formatDistanceToNow(new Date(repository.lastUpdated), { addSuffix: true })}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<Tabs defaultValue="overview" className="w-full">
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="plugins">Plugins</TabsTrigger>
|
||||
<TabsTrigger value="compatibility">Compatibility</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="space-y-6 mt-6">
|
||||
{repository.assets?.banner && (
|
||||
<div className="relative h-[200px] w-full overflow-hidden rounded-lg border">
|
||||
<Image
|
||||
src={repository.assets.banner}
|
||||
alt={repository.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded-lg border bg-card/50 p-4 shadow-sm">
|
||||
<h4 className="mb-4 font-medium">Author</h4>
|
||||
<div className="grid gap-2 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Name: </span>
|
||||
<span>{repository.author.name}</span>
|
||||
</div>
|
||||
{repository.author.organization && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">Organization: </span>
|
||||
<span>{repository.author.organization}</span>
|
||||
</div>
|
||||
)}
|
||||
{repository.author.url && (
|
||||
<a
|
||||
href={repository.author.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
View Profile
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-card/50 p-4 shadow-sm">
|
||||
<h4 className="mb-4 font-medium">Tags</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{repository.tags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="plugins" className="mt-6">
|
||||
<div className="rounded-lg border bg-card/50 p-4 shadow-sm">
|
||||
<h4 className="mb-4 font-medium">Available Plugins</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This repository contains {repository.stats?.plugins ?? 0} robot plugins.
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="compatibility" className="mt-6">
|
||||
<div className="rounded-lg border bg-card/50 p-4 shadow-sm">
|
||||
<h4 className="mb-4 font-medium">HRIStudio Compatibility</h4>
|
||||
<div className="grid gap-2 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Minimum Version: </span>
|
||||
<code className="rounded bg-muted px-1.5 py-0.5">
|
||||
{repository.compatibility.hristudio.min}
|
||||
</code>
|
||||
</div>
|
||||
{repository.compatibility.hristudio.recommended && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">Recommended Version: </span>
|
||||
<code className="rounded bg-muted px-1.5 py-0.5">
|
||||
{repository.compatibility.hristudio.recommended}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{repository.compatibility.ros2 && (
|
||||
<div className="mt-4 rounded-lg border bg-card/50 p-4 shadow-sm">
|
||||
<h4 className="mb-4 font-medium">ROS 2 Compatibility</h4>
|
||||
<div className="grid gap-2 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Supported Distributions: </span>
|
||||
<div className="flex flex-wrap gap-2 mt-1">
|
||||
{repository.compatibility.ros2.distributions.map((dist) => (
|
||||
<Badge key={dist} variant="secondary">
|
||||
{dist}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{repository.compatibility.ros2.recommended && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">Recommended Distribution: </span>
|
||||
<code className="rounded bg-muted px-1.5 py-0.5">
|
||||
{repository.compatibility.ros2.recommended}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RepositorySection({ repositories }: RepositorySectionProps) {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const [isRemoving, setIsRemoving] = useState(false);
|
||||
const [selectedRepository, setSelectedRepository] = useState<RepositoryMetadata | null>(
|
||||
repositories[0] ?? null
|
||||
);
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const removeRepository = api.pluginStore.removeRepository.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Repository removed successfully",
|
||||
});
|
||||
// Invalidate all plugin store queries
|
||||
utils.pluginStore.getRepositories.invalidate();
|
||||
utils.pluginStore.getPlugins.invalidate();
|
||||
utils.pluginStore.getInstalledPlugins.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to remove repository:", error);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: error.message || "Failed to remove repository",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleRemoveRepository = async (id: string) => {
|
||||
if (isRemoving) return;
|
||||
|
||||
try {
|
||||
setIsRemoving(true);
|
||||
await removeRepository.mutateAsync({ id });
|
||||
} finally {
|
||||
setIsRemoving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!repositories.length) {
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-24rem)] items-center justify-center">
|
||||
<p className="text-muted-foreground">No repositories added</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid h-[calc(100vh-24rem)] grid-cols-[400px_1fr] gap-8">
|
||||
{/* Left Pane - Repository List */}
|
||||
<div className="overflow-y-auto rounded-lg pr-4">
|
||||
<div className="space-y-3">
|
||||
{repositories.map((repository) => (
|
||||
<RepositoryListItem
|
||||
key={repository.id}
|
||||
repository={repository}
|
||||
isSelected={selectedRepository?.id === repository.id}
|
||||
onSelect={() => setSelectedRepository(repository)}
|
||||
onRemove={handleRemoveRepository}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Pane - Repository Details */}
|
||||
{selectedRepository && (
|
||||
<RepositoryDetails
|
||||
repository={selectedRepository}
|
||||
onRemove={handleRemoveRepository}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
412
src/components/store/robot-details.tsx
Normal file
412
src/components/store/robot-details.tsx
Normal file
@@ -0,0 +1,412 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { type RobotPlugin } from "~/lib/plugin-store/types";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Bot, Download, Info, Zap, Battery, Scale, Ruler, Trash2 } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||
import { api } from "~/trpc/react";
|
||||
import { useToast } from "~/hooks/use-toast";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "~/components/ui/alert-dialog";
|
||||
|
||||
interface RobotDetailsProps {
|
||||
robot: RobotPlugin;
|
||||
isInstalled: boolean;
|
||||
}
|
||||
|
||||
function RobotHeader({ robot, isInstalled }: RobotDetailsProps) {
|
||||
const { toast } = useToast();
|
||||
const utils = api.useUtils();
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [showUninstallDialog, setShowUninstallDialog] = useState(false);
|
||||
|
||||
const installPlugin = api.pluginStore.installPlugin.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Success",
|
||||
description: `${robot.name} installed successfully`,
|
||||
});
|
||||
utils.pluginStore.getInstalledPlugins.invalidate();
|
||||
utils.pluginStore.getPlugins.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to install plugin:", error);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: error.message || "Failed to install plugin",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const uninstallPlugin = api.pluginStore.uninstallPlugin.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Success",
|
||||
description: `${robot.name} uninstalled successfully`,
|
||||
});
|
||||
utils.pluginStore.getInstalledPlugins.invalidate();
|
||||
utils.pluginStore.getPlugins.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to uninstall plugin:", error);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: error.message || "Failed to uninstall plugin",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleInstall = async () => {
|
||||
if (isProcessing) return;
|
||||
try {
|
||||
setIsProcessing(true);
|
||||
await installPlugin.mutateAsync({
|
||||
robotId: robot.robotId,
|
||||
repositoryId: "hristudio-official", // TODO: Get from context
|
||||
});
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUninstall = async () => {
|
||||
if (isProcessing) return;
|
||||
try {
|
||||
setIsProcessing(true);
|
||||
await uninstallPlugin.mutateAsync({ robotId: robot.robotId });
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
setShowUninstallDialog(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-b p-6">
|
||||
<div className="mb-4 flex items-start justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold">{robot.name}</h2>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{robot.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" asChild>
|
||||
<a
|
||||
href={robot.documentation.mainUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Info className="mr-2 h-4 w-4" />
|
||||
Documentation
|
||||
</a>
|
||||
</Button>
|
||||
{isInstalled ? (
|
||||
<AlertDialog open={showUninstallDialog} onOpenChange={setShowUninstallDialog}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Uninstall
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Uninstall Robot</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to uninstall {robot.name}? This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleUninstall}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{isProcessing ? "Uninstalling..." : "Uninstall"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleInstall}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{isProcessing ? "Installing..." : "Install"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Zap className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{robot.specs.maxSpeed}m/s</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Battery className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{robot.specs.batteryLife}h</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Scale className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{robot.specs.dimensions.weight}kg</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RobotImages({ robot }: { robot: RobotPlugin }) {
|
||||
const [showLeftFade, setShowLeftFade] = useState(false);
|
||||
const [showRightFade, setShowRightFade] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const checkScroll = () => {
|
||||
const hasLeftScroll = el.scrollLeft > 0;
|
||||
const hasRightScroll = el.scrollLeft < (el.scrollWidth - el.clientWidth);
|
||||
|
||||
setShowLeftFade(hasLeftScroll);
|
||||
setShowRightFade(hasRightScroll);
|
||||
};
|
||||
|
||||
// Check initial scroll
|
||||
checkScroll();
|
||||
|
||||
// Add scroll listener
|
||||
el.addEventListener('scroll', checkScroll);
|
||||
// Add resize listener to handle window changes
|
||||
window.addEventListener('resize', checkScroll);
|
||||
|
||||
return () => {
|
||||
el.removeEventListener('scroll', checkScroll);
|
||||
window.removeEventListener('resize', checkScroll);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div ref={scrollRef} className="overflow-x-auto pb-4">
|
||||
<div className="flex gap-4">
|
||||
{/* Main Image */}
|
||||
<div className="relative h-[300px] aspect-video shrink-0 overflow-hidden rounded-lg border bg-muted">
|
||||
<Image
|
||||
src={robot.assets.images.main}
|
||||
alt={robot.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Angle Images */}
|
||||
{robot.assets.images.angles && (
|
||||
<div className="flex gap-4">
|
||||
{Object.entries(robot.assets.images.angles).map(([angle, url]) => url && (
|
||||
<div
|
||||
key={angle}
|
||||
className="relative h-[300px] aspect-square shrink-0 overflow-hidden rounded-lg border bg-muted"
|
||||
>
|
||||
<Image
|
||||
src={url}
|
||||
alt={`${robot.name} - ${angle} view`}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/50 to-transparent p-4">
|
||||
<span className="text-xs font-medium text-white capitalize">
|
||||
{angle} View
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fade indicators */}
|
||||
{showLeftFade && (
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 w-8 bg-gradient-to-r from-background to-transparent" />
|
||||
)}
|
||||
{showRightFade && (
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 w-8 bg-gradient-to-l from-background to-transparent" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RobotSpecs({ robot }: { robot: RobotPlugin }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-lg border bg-card/50 p-4 shadow-sm">
|
||||
<h4 className="mb-4 font-medium">Physical Specifications</h4>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Ruler className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm">
|
||||
{robot.specs.dimensions.length}m × {robot.specs.dimensions.width}m × {robot.specs.dimensions.height}m
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Scale className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm">{robot.specs.dimensions.weight}kg</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm">{robot.specs.maxSpeed}m/s</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Battery className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm">{robot.specs.batteryLife}h</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-card/50 p-4 shadow-sm">
|
||||
<h4 className="mb-4 font-medium">Capabilities</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{robot.specs.capabilities.map((capability) => (
|
||||
<Badge key={capability} variant="secondary">
|
||||
{capability}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-card/50 p-4 shadow-sm">
|
||||
<h4 className="mb-4 font-medium">ROS 2 Configuration</h4>
|
||||
<div className="grid gap-3 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Namespace: </span>
|
||||
<code className="rounded bg-muted px-1.5 py-0.5">
|
||||
{robot.ros2Config.namespace}
|
||||
</code>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Node Prefix: </span>
|
||||
<code className="rounded bg-muted px-1.5 py-0.5">
|
||||
{robot.ros2Config.nodePrefix}
|
||||
</code>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<span className="text-muted-foreground">Default Topics:</span>
|
||||
<div className="grid gap-1.5 pl-4">
|
||||
{Object.entries(robot.ros2Config.defaultTopics).map(([name, topic]) => (
|
||||
<div key={name}>
|
||||
<span className="text-muted-foreground">{name}: </span>
|
||||
<code className="rounded bg-muted px-1.5 py-0.5">{topic}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RobotActions({ robot }: { robot: RobotPlugin }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{robot.actions.map((action) => (
|
||||
<div key={action.actionId} className="rounded-lg border bg-card/50 p-4 shadow-sm">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h4 className="font-medium">{action.title}</h4>
|
||||
<Badge variant="secondary">{action.type}</Badge>
|
||||
</div>
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
{action.description}
|
||||
</p>
|
||||
<div className="grid gap-2">
|
||||
<h5 className="text-sm font-medium text-muted-foreground">Parameters:</h5>
|
||||
<div className="grid gap-2 pl-4">
|
||||
{Object.entries(action.parameters.properties).map(([name, prop]) => (
|
||||
<div key={name} className="text-sm">
|
||||
<span className="font-medium">{prop.title}</span>
|
||||
{prop.unit && (
|
||||
<span className="text-muted-foreground"> ({prop.unit})</span>
|
||||
)}
|
||||
{prop.description && (
|
||||
<p className="mt-0.5 text-muted-foreground">{prop.description}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RobotDetails({ robot, isInstalled }: RobotDetailsProps) {
|
||||
return (
|
||||
<>
|
||||
<RobotHeader robot={robot} isInstalled={isInstalled} />
|
||||
|
||||
<div className="p-6">
|
||||
<Tabs defaultValue="overview" className="w-full">
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="specs">Specifications</TabsTrigger>
|
||||
<TabsTrigger value="actions">Actions</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="space-y-6 mt-6">
|
||||
<RobotImages robot={robot} />
|
||||
<div className="rounded-lg border bg-card/50 p-4 shadow-sm">
|
||||
<h4 className="mb-4 font-medium">Documentation</h4>
|
||||
<div className="grid gap-2 text-sm">
|
||||
<a
|
||||
href={robot.documentation.mainUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
User Manual
|
||||
</a>
|
||||
{robot.documentation.apiReference && (
|
||||
<a
|
||||
href={robot.documentation.apiReference}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
API Reference
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="specs" className="mt-6">
|
||||
<RobotSpecs robot={robot} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="actions" className="mt-6">
|
||||
<RobotActions robot={robot} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
257
src/components/store/robot-grid.tsx
Normal file
257
src/components/store/robot-grid.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { type RobotPlugin } from "~/lib/plugin-store/types";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Bot, Download, Info, Zap, Battery, Scale, Ruler, Check, Trash2 } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { api } from "~/trpc/react";
|
||||
import { useToast } from "~/hooks/use-toast";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "~/components/ui/alert-dialog";
|
||||
|
||||
interface RobotGridProps {
|
||||
plugins: RobotPlugin[];
|
||||
installedPluginIds?: string[];
|
||||
selectedRobotId?: string;
|
||||
onSelectRobot: (robot: RobotPlugin) => void;
|
||||
}
|
||||
|
||||
function RobotCard({
|
||||
plugin,
|
||||
isInstalled,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}: {
|
||||
plugin: RobotPlugin;
|
||||
isInstalled: boolean;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const utils = api.useUtils();
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [showUninstallDialog, setShowUninstallDialog] = useState(false);
|
||||
|
||||
const installPlugin = api.pluginStore.installPlugin.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Success",
|
||||
description: `${plugin.name} installed successfully`,
|
||||
});
|
||||
utils.pluginStore.getInstalledPlugins.invalidate();
|
||||
utils.pluginStore.getPlugins.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to install plugin:", error);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: error.message || "Failed to install plugin",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const uninstallPlugin = api.pluginStore.uninstallPlugin.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Success",
|
||||
description: `${plugin.name} uninstalled successfully`,
|
||||
});
|
||||
utils.pluginStore.getInstalledPlugins.invalidate();
|
||||
utils.pluginStore.getPlugins.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to uninstall plugin:", error);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: error.message || "Failed to uninstall plugin",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleInstall = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (isProcessing) return;
|
||||
try {
|
||||
setIsProcessing(true);
|
||||
await installPlugin.mutateAsync({
|
||||
robotId: plugin.robotId,
|
||||
repositoryId: "hristudio-official", // TODO: Get from context
|
||||
});
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUninstall = async () => {
|
||||
if (isProcessing) return;
|
||||
try {
|
||||
setIsProcessing(true);
|
||||
await uninstallPlugin.mutateAsync({ robotId: plugin.robotId });
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
setShowUninstallDialog(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative flex cursor-pointer gap-3 rounded-lg border p-4 transition-all",
|
||||
isSelected
|
||||
? "border-primary bg-card ring-2 ring-primary/10"
|
||||
: "hover:border-primary/50 hover:bg-accent/50"
|
||||
)}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<div className="relative aspect-square h-20 shrink-0 overflow-hidden rounded-md border bg-muted">
|
||||
{plugin.assets.logo ? (
|
||||
<Image
|
||||
src={plugin.assets.logo}
|
||||
alt={plugin.name}
|
||||
fill
|
||||
className="object-contain p-2"
|
||||
/>
|
||||
) : plugin.assets.thumbnailUrl ? (
|
||||
<Image
|
||||
src={plugin.assets.thumbnailUrl}
|
||||
alt={plugin.name}
|
||||
fill
|
||||
className="object-cover transition-transform group-hover:scale-105"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Bot className="h-10 w-10 text-muted-foreground/50" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="line-clamp-1 font-semibold tracking-tight">{plugin.name}</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="shrink-0">
|
||||
{plugin.platform}
|
||||
</Badge>
|
||||
{isInstalled && (
|
||||
<Badge variant="default" className="shrink-0 bg-primary">
|
||||
Installed
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="line-clamp-2 text-sm text-muted-foreground">
|
||||
{plugin.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<Zap className="h-3 w-3" />
|
||||
<span>{plugin.specs.maxSpeed}m/s</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Battery className="h-3 w-3" />
|
||||
<span>{plugin.specs.batteryLife}h</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isInstalled ? (
|
||||
<AlertDialog open={showUninstallDialog} onOpenChange={setShowUninstallDialog}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowUninstallDialog(true);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span className="ml-2">Uninstall</span>
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Uninstall Robot</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to uninstall {plugin.name}? This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleUninstall}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{isProcessing ? "Uninstalling..." : "Uninstall"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleInstall}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{isProcessing ? (
|
||||
"Installing..."
|
||||
) : (
|
||||
<>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Install
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RobotGrid({ plugins, installedPluginIds = [], selectedRobotId, onSelectRobot }: RobotGridProps) {
|
||||
if (!plugins.length) {
|
||||
return (
|
||||
<div className="flex h-[400px] items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Bot className="mx-auto h-16 w-16 text-muted-foreground/50" />
|
||||
<h3 className="mt-4 text-lg font-medium">No Robots Found</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Try adjusting your filters or adding more repositories.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{plugins.map((plugin) => (
|
||||
<RobotCard
|
||||
key={plugin.robotId}
|
||||
plugin={plugin}
|
||||
isInstalled={installedPluginIds.includes(plugin.robotId)}
|
||||
isSelected={plugin.robotId === selectedRobotId}
|
||||
onSelect={() => onSelectRobot(plugin)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
439
src/components/store/robot-list.tsx
Normal file
439
src/components/store/robot-list.tsx
Normal file
@@ -0,0 +1,439 @@
|
||||
"use client";
|
||||
|
||||
import { type RobotPlugin } from "~/lib/plugin-store/types";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Bot, Download, Info, Zap, Battery, Scale, Ruler } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||
import { PageHeader } from "~/components/layout/page-header";
|
||||
import { PageContent } from "~/components/layout/page-content";
|
||||
import { api } from "~/trpc/react";
|
||||
import { useToast } from "~/hooks/use-toast";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
interface RobotListProps {
|
||||
plugins: RobotPlugin[];
|
||||
}
|
||||
|
||||
function RobotListItem({
|
||||
plugin,
|
||||
isSelected,
|
||||
onSelect
|
||||
}: {
|
||||
plugin: RobotPlugin;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative flex cursor-pointer gap-3 rounded-lg border p-4 transition-all",
|
||||
isSelected
|
||||
? "border-primary bg-card ring-2 ring-primary/10"
|
||||
: "hover:border-primary/50 hover:bg-accent/50"
|
||||
)}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<div className="relative aspect-square h-20 shrink-0 overflow-hidden rounded-md border bg-muted">
|
||||
{plugin.assets.logo ? (
|
||||
<Image
|
||||
src={plugin.assets.logo}
|
||||
alt={plugin.name}
|
||||
fill
|
||||
className="object-contain p-2"
|
||||
/>
|
||||
) : plugin.assets.thumbnailUrl ? (
|
||||
<Image
|
||||
src={plugin.assets.thumbnailUrl}
|
||||
alt={plugin.name}
|
||||
fill
|
||||
className="object-cover transition-transform group-hover:scale-105"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Bot className="h-10 w-10 text-muted-foreground/50" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="line-clamp-1 font-semibold tracking-tight">{plugin.name}</h3>
|
||||
<Badge variant="secondary" className="shrink-0">
|
||||
{plugin.platform}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="line-clamp-2 text-sm text-muted-foreground">
|
||||
{plugin.description}
|
||||
</p>
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<Zap className="h-3 w-3" />
|
||||
<span>{plugin.specs.maxSpeed}m/s</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Battery className="h-3 w-3" />
|
||||
<span>{plugin.specs.batteryLife}h</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RobotHeader({ robot }: { robot: RobotPlugin }) {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const [isInstalling, setIsInstalling] = useState(false);
|
||||
|
||||
const utils = api.useUtils();
|
||||
const installPlugin = api.pluginStore.installPlugin.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Success",
|
||||
description: `${robot.name} installed successfully`,
|
||||
});
|
||||
// Invalidate both queries to refresh the data
|
||||
utils.pluginStore.getInstalledPlugins.invalidate();
|
||||
utils.pluginStore.getPlugins.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to install plugin:", error);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: error.message || "Failed to install plugin",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleInstall = async () => {
|
||||
if (isInstalling) return;
|
||||
|
||||
try {
|
||||
setIsInstalling(true);
|
||||
await installPlugin.mutateAsync({
|
||||
robotId: robot.robotId,
|
||||
repositoryId: "hristudio-official", // TODO: Get from context
|
||||
});
|
||||
} finally {
|
||||
setIsInstalling(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-b p-6">
|
||||
<div className="mb-4 flex items-start justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold">{robot.name}</h2>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{robot.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" asChild>
|
||||
<a
|
||||
href={robot.documentation.mainUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Info className="mr-2 h-4 w-4" />
|
||||
Documentation
|
||||
</a>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleInstall}
|
||||
disabled={isInstalling || installPlugin.isLoading}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{isInstalling || installPlugin.isLoading ? "Installing..." : "Install"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Zap className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{robot.specs.maxSpeed}m/s</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Battery className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{robot.specs.batteryLife}h</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Scale className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{robot.specs.dimensions.weight}kg</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RobotImages({ robot }: { robot: RobotPlugin }) {
|
||||
const [showLeftFade, setShowLeftFade] = useState(false);
|
||||
const [showRightFade, setShowRightFade] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const checkScroll = () => {
|
||||
const hasLeftScroll = el.scrollLeft > 0;
|
||||
const hasRightScroll = el.scrollLeft < (el.scrollWidth - el.clientWidth);
|
||||
|
||||
setShowLeftFade(hasLeftScroll);
|
||||
setShowRightFade(hasRightScroll);
|
||||
};
|
||||
|
||||
// Check initial scroll
|
||||
checkScroll();
|
||||
|
||||
// Add scroll listener
|
||||
el.addEventListener('scroll', checkScroll);
|
||||
// Add resize listener to handle window changes
|
||||
window.addEventListener('resize', checkScroll);
|
||||
|
||||
return () => {
|
||||
el.removeEventListener('scroll', checkScroll);
|
||||
window.removeEventListener('resize', checkScroll);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div ref={scrollRef} className="overflow-x-auto pb-4">
|
||||
<div className="flex gap-4">
|
||||
{/* Main Image */}
|
||||
<div className="relative h-[300px] aspect-video shrink-0 overflow-hidden rounded-lg border bg-muted">
|
||||
<Image
|
||||
src={robot.assets.images.main}
|
||||
alt={robot.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Angle Images */}
|
||||
{robot.assets.images.angles && (
|
||||
<div className="flex gap-4">
|
||||
{Object.entries(robot.assets.images.angles).map(([angle, url]) => url && (
|
||||
<div
|
||||
key={angle}
|
||||
className="relative h-[300px] aspect-square shrink-0 overflow-hidden rounded-lg border bg-muted"
|
||||
>
|
||||
<Image
|
||||
src={url}
|
||||
alt={`${robot.name} - ${angle} view`}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/50 to-transparent p-4">
|
||||
<span className="text-xs font-medium text-white capitalize">
|
||||
{angle} View
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fade indicators */}
|
||||
{showLeftFade && (
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 w-8 bg-gradient-to-r from-background to-transparent" />
|
||||
)}
|
||||
{showRightFade && (
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 w-8 bg-gradient-to-l from-background to-transparent" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RobotSpecs({ robot }: { robot: RobotPlugin }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-lg border bg-card/50 p-4 shadow-sm">
|
||||
<h4 className="mb-4 font-medium">Physical Specifications</h4>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Ruler className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm">
|
||||
{robot.specs.dimensions.length}m × {robot.specs.dimensions.width}m × {robot.specs.dimensions.height}m
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Scale className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm">{robot.specs.dimensions.weight}kg</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm">{robot.specs.maxSpeed}m/s</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Battery className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm">{robot.specs.batteryLife}h</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-card/50 p-4 shadow-sm">
|
||||
<h4 className="mb-4 font-medium">Capabilities</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{robot.specs.capabilities.map((capability) => (
|
||||
<Badge key={capability} variant="secondary">
|
||||
{capability}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-card/50 p-4 shadow-sm">
|
||||
<h4 className="mb-4 font-medium">ROS 2 Configuration</h4>
|
||||
<div className="grid gap-3 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Namespace: </span>
|
||||
<code className="rounded bg-muted px-1.5 py-0.5">
|
||||
{robot.ros2Config.namespace}
|
||||
</code>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Node Prefix: </span>
|
||||
<code className="rounded bg-muted px-1.5 py-0.5">
|
||||
{robot.ros2Config.nodePrefix}
|
||||
</code>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<span className="text-muted-foreground">Default Topics:</span>
|
||||
<div className="grid gap-1.5 pl-4">
|
||||
{Object.entries(robot.ros2Config.defaultTopics).map(([name, topic]) => (
|
||||
<div key={name}>
|
||||
<span className="text-muted-foreground">{name}: </span>
|
||||
<code className="rounded bg-muted px-1.5 py-0.5">{topic}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RobotActions({ robot }: { robot: RobotPlugin }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{robot.actions.map((action) => (
|
||||
<div key={action.actionId} className="rounded-lg border bg-card/50 p-4 shadow-sm">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h4 className="font-medium">{action.title}</h4>
|
||||
<Badge variant="secondary">{action.type}</Badge>
|
||||
</div>
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
{action.description}
|
||||
</p>
|
||||
<div className="grid gap-2">
|
||||
<h5 className="text-sm font-medium text-muted-foreground">Parameters:</h5>
|
||||
<div className="grid gap-2 pl-4">
|
||||
{Object.entries(action.parameters.properties).map(([name, prop]) => (
|
||||
<div key={name} className="text-sm">
|
||||
<span className="font-medium">{prop.title}</span>
|
||||
{prop.unit && (
|
||||
<span className="text-muted-foreground"> ({prop.unit})</span>
|
||||
)}
|
||||
{prop.description && (
|
||||
<p className="mt-0.5 text-muted-foreground">{prop.description}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RobotList({ plugins }: RobotListProps) {
|
||||
const [selectedRobot, setSelectedRobot] = useState<RobotPlugin | null>(plugins[0] ?? null);
|
||||
|
||||
if (!plugins.length) {
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-24rem)] items-center justify-center">
|
||||
<p className="text-muted-foreground">No robots available</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid h-[calc(100vh-24rem)] grid-cols-[400px_1fr] gap-8">
|
||||
{/* Left Pane - Robot List */}
|
||||
<div className="overflow-y-auto rounded-lg pr-4">
|
||||
<div className="space-y-3">
|
||||
{plugins.map((plugin) => (
|
||||
<RobotListItem
|
||||
key={plugin.robotId}
|
||||
plugin={plugin}
|
||||
isSelected={selectedRobot?.robotId === plugin.robotId}
|
||||
onSelect={() => setSelectedRobot(plugin)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Pane - Robot Details */}
|
||||
{selectedRobot && (
|
||||
<div className="overflow-y-auto rounded-lg border bg-card">
|
||||
<RobotHeader robot={selectedRobot} />
|
||||
|
||||
<div className="p-6">
|
||||
<Tabs defaultValue="overview" className="w-full">
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="specs">Specifications</TabsTrigger>
|
||||
<TabsTrigger value="actions">Actions</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="space-y-6 mt-6">
|
||||
<RobotImages robot={selectedRobot} />
|
||||
<div className="rounded-lg border bg-card/50 p-4 shadow-sm">
|
||||
<h4 className="mb-4 font-medium">Documentation</h4>
|
||||
<div className="grid gap-2 text-sm">
|
||||
<a
|
||||
href={selectedRobot.documentation.mainUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
User Manual
|
||||
</a>
|
||||
{selectedRobot.documentation.apiReference && (
|
||||
<a
|
||||
href={selectedRobot.documentation.apiReference}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
API Reference
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="specs" className="mt-6">
|
||||
<RobotSpecs robot={selectedRobot} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="actions" className="mt-6">
|
||||
<RobotActions robot={selectedRobot} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,7 @@ const Card = React.forwardRef<
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--card-level-1))] shadow-sm",
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -30,22 +30,25 @@ const CardHeader = React.forwardRef<
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
@@ -73,4 +76,4 @@ const CardFooter = React.forwardRef<
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
@@ -358,7 +358,7 @@ const SidebarHeader = React.forwardRef<
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="header"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -373,7 +373,7 @@ const SidebarFooter = React.forwardRef<
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="footer"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
48
src/env.js
48
src/env.js
@@ -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,
|
||||
});
|
||||
50
src/env.mjs
50
src/env.mjs
@@ -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,
|
||||
});
|
||||
35
src/env.ts
35
src/env.ts
@@ -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,
|
||||
});
|
||||
159
src/lib/experiments/plugin-actions.tsx
Normal file
159
src/lib/experiments/plugin-actions.tsx
Normal 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: [],
|
||||
},
|
||||
},
|
||||
];
|
||||
156
src/lib/plugin-store/plugins/turtlebot3-burger.json
Normal file
156
src/lib/plugin-store/plugins/turtlebot3-burger.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
153
src/lib/plugin-store/service.ts
Normal file
153
src/lib/plugin-store/service.ts
Normal 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);
|
||||
}
|
||||
468
src/lib/plugin-store/store.ts
Normal file
468
src/lib/plugin-store/store.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
221
src/lib/plugin-store/types.ts
Normal file
221
src/lib/plugin-store/types.ts
Normal 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>;
|
||||
@@ -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
|
||||
|
||||
178
src/server/api/routers/plugin-store.ts
Normal file
178
src/server/api/routers/plugin-store.ts
Normal 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",
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
/**
|
||||
|
||||
@@ -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";
|
||||
@@ -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()
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
export * from "./auth";
|
||||
export * from "./studies";
|
||||
export * from "./permissions";
|
||||
export * from "./experiments";
|
||||
@@ -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] }),
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
32
src/server/db/schema/store.ts
Normal file
32
src/server/db/schema/store.ts
Normal 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(),
|
||||
});
|
||||
@@ -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
29
src/server/db/seed.ts
Normal 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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user