- {AVAILABLE_ACTIONS.find((a) => a.type === selectedNode.data.type)?.title}
+ {availableActions.find((a) => a.type === selectedNode.data.type)?.title}
{JSON.stringify(selectedNode.data.parameters, null, 2)}
@@ -397,24 +427,24 @@ export function ExperimentDesigner({
className="react-flow-wrapper"
>
-
+
{
- 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)",
}}
/>
-
+
-
-
-
diff --git a/src/components/experiments/nodes/action-node.tsx b/src/components/experiments/nodes/action-node.tsx
index ce2274c..d75ee3a 100644
--- a/src/components/experiments/nodes/action-node.tsx
+++ b/src/components/experiments/nodes/action-node.tsx
@@ -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) => {
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 (
<>
+
) =
isHovered && "before:from-border/80 before:to-border/30",
)}
>
-
-
+
+
-
+
{actionConfig.icon}
-
- {actionConfig.title}
-
+
{actionConfig.title}
-
+
+
+
+
+ Configure Action
+
-
-
+
+
{actionConfig.description}
-
-
-
-
-
-
-
- Input Connection
-
-
-
-
-
-
-
-
-
- Output Connection
-
-
-
+
{})}
+ onSubmit={data.onChange ?? (() => { })}
+ actionConfig={actionConfig}
/>
>
);
diff --git a/src/components/home/cta-section.tsx b/src/components/home/cta-section.tsx
new file mode 100644
index 0000000..c3193b7
--- /dev/null
+++ b/src/components/home/cta-section.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+
+
+ Ready to Transform Your Research?
+
+
+ Join the growing community of researchers using HRIStudio to advance human-robot interaction studies.
+
+
+ {!isLoggedIn ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/home/features-section.tsx b/src/components/home/features-section.tsx
new file mode 100644
index 0000000..ff0f1ed
--- /dev/null
+++ b/src/components/home/features-section.tsx
@@ -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: ,
+ title: "Visual Experiment Design",
+ description: "Create and configure experiments using an intuitive drag-and-drop interface without extensive coding."
+ },
+ {
+ icon: ,
+ title: "Real-time Control",
+ description: "Execute experiments with synchronized views for wizards and observers, enabling seamless collaboration."
+ },
+ {
+ icon: ,
+ title: "Comprehensive Analysis",
+ description: "Record, playback, and analyze experimental data with built-in annotation and export tools."
+ }
+];
+
+export function FeaturesSection() {
+ return (
+
+
+
+ Powerful Features for HRI Research
+
+
+ Everything you need to design, execute, and analyze your human-robot interaction experiments.
+
+
+
+
+ {features.map((feature, index) => (
+
+
+
+
+
+ {feature.icon}
+
+ {feature.title}
+ {feature.description}
+
+
+
+ ))}
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/home/hero-section.tsx b/src/components/home/hero-section.tsx
new file mode 100644
index 0000000..3e12075
--- /dev/null
+++ b/src/components/home/hero-section.tsx
@@ -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 (
+
+ {/* Hero gradient background */}
+
+
+
+
+
+
+
+ Now with Visual Experiment Designer
+
+
+
+ Streamline Your HRI Research
+
+
+ A comprehensive platform for designing, executing, and analyzing Wizard-of-Oz experiments in human-robot interaction studies.
+
+
+ {!isLoggedIn ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/navigation/app-sidebar.tsx b/src/components/navigation/app-sidebar.tsx
index fea298e..84cc97a 100644
--- a/src/components/navigation/app-sidebar.tsx
+++ b/src/components/navigation/app-sidebar.tsx
@@ -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) {
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) {
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) {
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]
diff --git a/src/components/navigation/header.tsx b/src/components/navigation/header.tsx
index 379fc05..32f08f4 100644
--- a/src/components/navigation/header.tsx
+++ b/src/components/navigation/header.tsx
@@ -8,16 +8,19 @@ import { Logo } from "~/components/logo"
export function Header() {
return (
diff --git a/src/components/navigation/nav-main.tsx b/src/components/navigation/nav-main.tsx
index 2f90e15..36c5acc 100644
--- a/src/components/navigation/nav-main.tsx
+++ b/src/components/navigation/nav-main.tsx
@@ -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 (
-
- Platform
-
- {items.map((item) => (
-
-
-
-
- {item.icon && }
- {item.title}
-
-
-
-
-
- {item.items?.map((subItem) => (
-
-
-
- {subItem.title}
-
-
-
- ))}
-
-
-
-
- ))}
-
-
- )
+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 (
+
+ {items.map((item) => {
+ const isActive = item.url === activeItem?.url
+
+ return (
+
+
+
+
+ {item.title}
+
+
+
+ )
+ })}
+
+ )
+}
\ No newline at end of file
diff --git a/src/components/navigation/nav-user.tsx b/src/components/navigation/nav-user.tsx
index 661fe2e..3fbcc8e 100644
--- a/src/components/navigation/nav-user.tsx
+++ b/src/components/navigation/nav-user.tsx
@@ -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() {
-
+ {!isCollapsed && (
+
+ )}
@@ -56,7 +65,10 @@ export function NavUser() {
{session.user.image ? (
@@ -79,19 +91,23 @@ export function NavUser() {
)}
-
-
- {session.user.name ?? "User"}
-
-
- {session.user.email}
-
-
-
+ {!isCollapsed && (
+ <>
+
+
+ {session.user.name ?? "User"}
+
+
+ {session.user.email}
+
+
+
+ >
+ )}
@@ -138,11 +154,12 @@ export function NavUser() {
-
-
-
- Sign out
-
+ signOut({ callbackUrl: "/auth/signin" })}
+ className="cursor-pointer"
+ >
+
+ Sign out
diff --git a/src/components/providers/index.tsx b/src/components/providers/index.tsx
new file mode 100644
index 0000000..5b05028
--- /dev/null
+++ b/src/components/providers/index.tsx
@@ -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 (
+
+
+
+ {children}
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/providers/plugin-store-provider.tsx b/src/components/providers/plugin-store-provider.tsx
new file mode 100644
index 0000000..eff1597
--- /dev/null
+++ b/src/components/providers/plugin-store-provider.tsx
@@ -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(undefined);
+
+export function PluginStoreProvider({
+ children,
+ initialPlugins = [],
+}: {
+ children: React.ReactNode;
+ initialPlugins?: RobotPlugin[];
+}) {
+ const [plugins, setPlugins] = useState(initialPlugins);
+ const [selectedPlugin, setSelectedPlugin] = useState();
+
+ const selectPlugin = (robotId: string) => {
+ const plugin = plugins.find(p => p.robotId === robotId);
+ setSelectedPlugin(plugin);
+ };
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function usePluginStore() {
+ const context = useContext(PluginStoreContext);
+ if (!context) {
+ throw new Error("usePluginStore must be used within a PluginStoreProvider");
+ }
+ return context;
+}
\ No newline at end of file
diff --git a/src/components/store/add-repository-dialog.tsx b/src/components/store/add-repository-dialog.tsx
new file mode 100644
index 0000000..eb6d547
--- /dev/null
+++ b/src/components/store/add-repository-dialog.tsx
@@ -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 (
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/store/plugin-browser.tsx b/src/components/store/plugin-browser.tsx
new file mode 100644
index 0000000..f9e9a4a
--- /dev/null
+++ b/src/components/store/plugin-browser.tsx
@@ -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 (
+
+ );
+}
+
+export function PluginBrowser({ repositories, initialPlugins }: PluginBrowserProps) {
+ // State
+ const [searchQuery, setSearchQuery] = useState("");
+ const [selectedRepository, setSelectedRepository] = useState("all");
+ const [showInstalled, setShowInstalled] = useState(true);
+ const [showAvailable, setShowAvailable] = useState(true);
+ const [selectedRobot, setSelectedRobot] = useState(
+ 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 (
+
+
+ Robots
+ Repositories
+
+
+
+
+
+ Robot Plugins
+
+ Browse and manage robot plugins from your configured repositories
+
+
+
+
+ setSearchQuery(e.target.value)}
+ className="pl-8"
+ />
+
+
+
+
+
+
+
+
+ Show
+
+
+ Installed
+
+
+ Available
+
+
+
+
+
+
+
+
+ {/* Left Pane - Robot List */}
+
+ {isLoading ? (
+
+ {Array.from({ length: 3 }).map((_, i) => (
+
+ ))}
+
+ ) : (
+
+ )}
+
+
+ {/* Right Pane - Robot Details */}
+
+ {isLoading ? (
+
+ ) : selectedRobot && (
+
+ )}
+
+
+
+
+
+
+
+
+
+ Plugin Repositories
+
+ Manage your robot plugin sources
+
+
+
+ {repositories.length === 0 ? (
+
+
+
No Repositories Added
+
+ Add a repository using the button above
+
+
+ ) : (
+
+ )}
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/store/repository-card.tsx b/src/components/store/repository-card.tsx
new file mode 100644
index 0000000..f4b1600
--- /dev/null
+++ b/src/components/store/repository-card.tsx
@@ -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 (
+
+
+
+
+ {repository.assets?.logo ? (
+
+ ) : repository.assets?.icon ? (
+
+ ) : (
+
+
+
+ )}
+
+
+
+ {repository.name}
+ {repository.official && (
+ Official
+ )}
+
+ {repository.description}
+
+
+
+
+
+
+
+
+ {repository.stats?.stars ?? 0}
+
+
+
+ {repository.stats?.downloads ?? 0}
+
+
+
+
{repository.stats?.plugins ?? 0} plugins
+
+
+
+ Updated {formatDistanceToNow(lastUpdated, { addSuffix: true })}
+
+
+
+ {repository.tags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+
+
+ {onRemove && !repository.official && (
+
+
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/store/repository-section.tsx b/src/components/store/repository-section.tsx
new file mode 100644
index 0000000..c5b4a99
--- /dev/null
+++ b/src/components/store/repository-section.tsx
@@ -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 (
+
+
+ {repository.assets?.logo ? (
+
+ ) : repository.assets?.icon ? (
+
+ ) : (
+
+
+
+ )}
+
+
+
+
{repository.name}
+ {repository.official && (
+ Official
+ )}
+
+
+ {repository.description}
+
+
+
+
+ {repository.stats?.stars ?? 0}
+
+
+
+ {repository.stats?.downloads ?? 0}
+
+
+
+
{repository.stats?.plugins ?? 0} plugins
+
+
+
+
+ );
+}
+
+function RepositoryDetails({ repository, onRemove }: { repository: RepositoryMetadata; onRemove?: (id: string) => void }) {
+ return (
+
+
+
+
+
{repository.name}
+
+ {repository.description}
+
+
+
+
+
+
+
+
{repository.stats?.plugins ?? 0} plugins
+
+
+
+ Updated {formatDistanceToNow(new Date(repository.lastUpdated), { addSuffix: true })}
+
+
+
+
+
+
+
+ Overview
+ Plugins
+ Compatibility
+
+
+
+ {repository.assets?.banner && (
+
+
+
+ )}
+
+
Author
+
+
+ Name:
+ {repository.author.name}
+
+ {repository.author.organization && (
+
+ Organization:
+ {repository.author.organization}
+
+ )}
+ {repository.author.url && (
+
+ View Profile
+
+ )}
+
+
+
+
Tags
+
+ {repository.tags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+
+
+
+
+
+
Available Plugins
+
+ This repository contains {repository.stats?.plugins ?? 0} robot plugins.
+
+
+
+
+
+
+
HRIStudio Compatibility
+
+
+ Minimum Version:
+
+ {repository.compatibility.hristudio.min}
+
+
+ {repository.compatibility.hristudio.recommended && (
+
+ Recommended Version:
+
+ {repository.compatibility.hristudio.recommended}
+
+
+ )}
+
+
+ {repository.compatibility.ros2 && (
+
+
ROS 2 Compatibility
+
+
+
Supported Distributions:
+
+ {repository.compatibility.ros2.distributions.map((dist) => (
+
+ {dist}
+
+ ))}
+
+
+ {repository.compatibility.ros2.recommended && (
+
+ Recommended Distribution:
+
+ {repository.compatibility.ros2.recommended}
+
+
+ )}
+
+
+ )}
+
+
+
+
+ );
+}
+
+export function RepositorySection({ repositories }: RepositorySectionProps) {
+ const router = useRouter();
+ const { toast } = useToast();
+ const [isRemoving, setIsRemoving] = useState(false);
+ const [selectedRepository, setSelectedRepository] = useState(
+ 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 (
+
+
No repositories added
+
+ );
+ }
+
+ return (
+
+ {/* Left Pane - Repository List */}
+
+
+ {repositories.map((repository) => (
+ setSelectedRepository(repository)}
+ onRemove={handleRemoveRepository}
+ />
+ ))}
+
+
+
+ {/* Right Pane - Repository Details */}
+ {selectedRepository && (
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/store/robot-details.tsx b/src/components/store/robot-details.tsx
new file mode 100644
index 0000000..db72210
--- /dev/null
+++ b/src/components/store/robot-details.tsx
@@ -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 (
+
+
+
+
{robot.name}
+
+ {robot.description}
+
+
+
+
+ {isInstalled ? (
+
+
+
+
+
+
+ Uninstall Robot
+
+ Are you sure you want to uninstall {robot.name}? This action cannot be undone.
+
+
+
+ Cancel
+
+ {isProcessing ? "Uninstalling..." : "Uninstall"}
+
+
+
+
+ ) : (
+
+ )}
+
+
+
+
+
+ {robot.specs.maxSpeed}m/s
+
+
+
+ {robot.specs.batteryLife}h
+
+
+
+ {robot.specs.dimensions.weight}kg
+
+
+
+ );
+}
+
+function RobotImages({ robot }: { robot: RobotPlugin }) {
+ const [showLeftFade, setShowLeftFade] = useState(false);
+ const [showRightFade, setShowRightFade] = useState(false);
+ const scrollRef = useRef(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 (
+
+
+
+ {/* Main Image */}
+
+
+
+
+ {/* Angle Images */}
+ {robot.assets.images.angles && (
+
+ {Object.entries(robot.assets.images.angles).map(([angle, url]) => url && (
+
+
+
+
+ {angle} View
+
+
+
+ ))}
+
+ )}
+
+
+
+ {/* Fade indicators */}
+ {showLeftFade && (
+
+ )}
+ {showRightFade && (
+
+ )}
+
+ );
+}
+
+function RobotSpecs({ robot }: { robot: RobotPlugin }) {
+ return (
+
+
+
Physical Specifications
+
+
+
+
+ {robot.specs.dimensions.length}m × {robot.specs.dimensions.width}m × {robot.specs.dimensions.height}m
+
+
+
+
+ {robot.specs.dimensions.weight}kg
+
+
+
+ {robot.specs.maxSpeed}m/s
+
+
+
+ {robot.specs.batteryLife}h
+
+
+
+
+
+
Capabilities
+
+ {robot.specs.capabilities.map((capability) => (
+
+ {capability}
+
+ ))}
+
+
+
+
+
ROS 2 Configuration
+
+
+ Namespace:
+
+ {robot.ros2Config.namespace}
+
+
+
+ Node Prefix:
+
+ {robot.ros2Config.nodePrefix}
+
+
+
+
Default Topics:
+
+ {Object.entries(robot.ros2Config.defaultTopics).map(([name, topic]) => (
+
+ {name}:
+ {topic}
+
+ ))}
+
+
+
+
+
+ );
+}
+
+function RobotActions({ robot }: { robot: RobotPlugin }) {
+ return (
+
+ {robot.actions.map((action) => (
+
+
+
{action.title}
+ {action.type}
+
+
+ {action.description}
+
+
+
Parameters:
+
+ {Object.entries(action.parameters.properties).map(([name, prop]) => (
+
+
{prop.title}
+ {prop.unit && (
+
({prop.unit})
+ )}
+ {prop.description && (
+
{prop.description}
+ )}
+
+ ))}
+
+
+
+ ))}
+
+ );
+}
+
+export function RobotDetails({ robot, isInstalled }: RobotDetailsProps) {
+ return (
+ <>
+
+
+
+
+
+ Overview
+ Specifications
+ Actions
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
\ No newline at end of file
diff --git a/src/components/store/robot-grid.tsx b/src/components/store/robot-grid.tsx
new file mode 100644
index 0000000..4347b0a
--- /dev/null
+++ b/src/components/store/robot-grid.tsx
@@ -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 (
+
+
+ {plugin.assets.logo ? (
+
+ ) : plugin.assets.thumbnailUrl ? (
+
+ ) : (
+
+
+
+ )}
+
+
+
+
+
{plugin.name}
+
+
+ {plugin.platform}
+
+ {isInstalled && (
+
+ Installed
+
+ )}
+
+
+
+ {plugin.description}
+
+
+
+
+
+
+ {plugin.specs.maxSpeed}m/s
+
+
+
+ {plugin.specs.batteryLife}h
+
+
+
+ {isInstalled ? (
+
+
+
+
+
+
+ Uninstall Robot
+
+ Are you sure you want to uninstall {plugin.name}? This action cannot be undone.
+
+
+
+ Cancel
+
+ {isProcessing ? "Uninstalling..." : "Uninstall"}
+
+
+
+
+ ) : (
+
+ )}
+
+
+
+
+ );
+}
+
+export function RobotGrid({ plugins, installedPluginIds = [], selectedRobotId, onSelectRobot }: RobotGridProps) {
+ if (!plugins.length) {
+ return (
+
+
+
+
No Robots Found
+
+ Try adjusting your filters or adding more repositories.
+
+
+
+ );
+ }
+
+ return (
+
+ {plugins.map((plugin) => (
+ onSelectRobot(plugin)}
+ />
+ ))}
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/store/robot-list.tsx b/src/components/store/robot-list.tsx
new file mode 100644
index 0000000..c7fb7a4
--- /dev/null
+++ b/src/components/store/robot-list.tsx
@@ -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 (
+
+
+ {plugin.assets.logo ? (
+
+ ) : plugin.assets.thumbnailUrl ? (
+
+ ) : (
+
+
+
+ )}
+
+
+
+
{plugin.name}
+
+ {plugin.platform}
+
+
+
+ {plugin.description}
+
+
+
+
+ {plugin.specs.maxSpeed}m/s
+
+
+
+ {plugin.specs.batteryLife}h
+
+
+
+
+ );
+}
+
+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 (
+
+
+
+
{robot.name}
+
+ {robot.description}
+
+
+
+
+
+
+
+
+
+
+ {robot.specs.maxSpeed}m/s
+
+
+
+ {robot.specs.batteryLife}h
+
+
+
+ {robot.specs.dimensions.weight}kg
+
+
+
+ );
+}
+
+function RobotImages({ robot }: { robot: RobotPlugin }) {
+ const [showLeftFade, setShowLeftFade] = useState(false);
+ const [showRightFade, setShowRightFade] = useState(false);
+ const scrollRef = useRef(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 (
+
+
+
+ {/* Main Image */}
+
+
+
+
+ {/* Angle Images */}
+ {robot.assets.images.angles && (
+
+ {Object.entries(robot.assets.images.angles).map(([angle, url]) => url && (
+
+
+
+
+ {angle} View
+
+
+
+ ))}
+
+ )}
+
+
+
+ {/* Fade indicators */}
+ {showLeftFade && (
+
+ )}
+ {showRightFade && (
+
+ )}
+
+ );
+}
+
+function RobotSpecs({ robot }: { robot: RobotPlugin }) {
+ return (
+
+
+
Physical Specifications
+
+
+
+
+ {robot.specs.dimensions.length}m × {robot.specs.dimensions.width}m × {robot.specs.dimensions.height}m
+
+
+
+
+ {robot.specs.dimensions.weight}kg
+
+
+
+ {robot.specs.maxSpeed}m/s
+
+
+
+ {robot.specs.batteryLife}h
+
+
+
+
+
+
Capabilities
+
+ {robot.specs.capabilities.map((capability) => (
+
+ {capability}
+
+ ))}
+
+
+
+
+
ROS 2 Configuration
+
+
+ Namespace:
+
+ {robot.ros2Config.namespace}
+
+
+
+ Node Prefix:
+
+ {robot.ros2Config.nodePrefix}
+
+
+
+
Default Topics:
+
+ {Object.entries(robot.ros2Config.defaultTopics).map(([name, topic]) => (
+
+ {name}:
+ {topic}
+
+ ))}
+
+
+
+
+
+ );
+}
+
+function RobotActions({ robot }: { robot: RobotPlugin }) {
+ return (
+
+ {robot.actions.map((action) => (
+
+
+
{action.title}
+ {action.type}
+
+
+ {action.description}
+
+
+
Parameters:
+
+ {Object.entries(action.parameters.properties).map(([name, prop]) => (
+
+
{prop.title}
+ {prop.unit && (
+
({prop.unit})
+ )}
+ {prop.description && (
+
{prop.description}
+ )}
+
+ ))}
+
+
+
+ ))}
+
+ );
+}
+
+export function RobotList({ plugins }: RobotListProps) {
+ const [selectedRobot, setSelectedRobot] = useState(plugins[0] ?? null);
+
+ if (!plugins.length) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {/* Left Pane - Robot List */}
+
+
+ {plugins.map((plugin) => (
+ setSelectedRobot(plugin)}
+ />
+ ))}
+
+
+
+ {/* Right Pane - Robot Details */}
+ {selectedRobot && (
+
+
+
+
+
+
+ Overview
+ Specifications
+ Actions
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx
index a068d22..45eeb22 100644
--- a/src/components/ui/card.tsx
+++ b/src/components/ui/card.tsx
@@ -9,7 +9,7 @@ const Card = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
>(({ className, ...props }, ref) => (
-
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
- HTMLDivElement,
- React.HTMLAttributes
+ HTMLParagraphElement,
+ React.HTMLAttributes
>(({ className, ...props }, ref) => (
-
)
@@ -373,7 +373,7 @@ const SidebarFooter = React.forwardRef<
)
diff --git a/src/env.js b/src/env.js
deleted file mode 100644
index add1b04..0000000
--- a/src/env.js
+++ /dev/null
@@ -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,
-});
diff --git a/src/env.mjs b/src/env.mjs
index b33f2fc..cd5857a 100644
--- a/src/env.mjs
+++ b/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
- }
-})
\ No newline at end of file
+
+ // 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,
+});
\ No newline at end of file
diff --git a/src/env.ts b/src/env.ts
deleted file mode 100644
index eaed58b..0000000
--- a/src/env.ts
+++ /dev/null
@@ -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,
-});
\ No newline at end of file
diff --git a/src/lib/experiments/plugin-actions.tsx b/src/lib/experiments/plugin-actions.tsx
new file mode 100644
index 0000000..cf5934d
--- /dev/null
+++ b/src/lib/experiments/plugin-actions.tsx
@@ -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 = {
+ 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;
+ 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 ;
+}
+
+export function getDefaultParameters(parameters: {
+ type: "object";
+ properties: Record;
+ required: string[];
+}): Record {
+ const defaults: Record = {};
+
+ 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: ,
+ defaultParameters: {
+ duration: 1000,
+ showCountdown: true,
+ },
+ },
+ {
+ type: "input",
+ title: "User Input",
+ description: "Wait for participant response",
+ icon: ,
+ defaultParameters: {
+ type: "button",
+ prompt: "Please respond",
+ timeout: null,
+ },
+ },
+ {
+ type: "record",
+ title: "Record",
+ description: "Start or stop recording",
+ icon: ,
+ defaultParameters: {
+ type: "start",
+ streams: ["video"],
+ },
+ },
+ {
+ type: "condition",
+ title: "Condition",
+ description: "Branch based on a condition",
+ icon: ,
+ defaultParameters: {
+ condition: "",
+ trueActions: [],
+ falseActions: [],
+ },
+ },
+ {
+ type: "loop",
+ title: "Loop",
+ description: "Repeat a sequence of actions",
+ icon: ,
+ defaultParameters: {
+ count: 1,
+ actions: [],
+ },
+ },
+];
\ No newline at end of file
diff --git a/src/lib/plugin-store/plugins/turtlebot3-burger.json b/src/lib/plugin-store/plugins/turtlebot3-burger.json
new file mode 100644
index 0000000..985903d
--- /dev/null
+++ b/src/lib/plugin-store/plugins/turtlebot3-burger.json
@@ -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"
+ }
+ }
+ }
+ ]
+}
\ No newline at end of file
diff --git a/src/lib/plugin-store/service.ts b/src/lib/plugin-store/service.ts
new file mode 100644
index 0000000..3a7fe2a
--- /dev/null
+++ b/src/lib/plugin-store/service.ts
@@ -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 {
+ const store = await getPluginStore();
+ return store.getAllPlugins();
+}
+
+export async function getRepositories(): Promise {
+ const store = await getPluginStore();
+ return store.getAllRepositories();
+}
+
+export async function addRepository(url: string): Promise {
+ // 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 {
+ 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 {
+ const store = await getPluginStore();
+ return store.getPlugin(robotId);
+}
diff --git a/src/lib/plugin-store/store.ts b/src/lib/plugin-store/store.ts
new file mode 100644
index 0000000..158b387
--- /dev/null
+++ b/src/lib/plugin-store/store.ts
@@ -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 = new Map();
+ private repositories: Map = new Map();
+ private transformFunctions: Map = new Map();
+ private pluginToRepo: Map = new Map(); // Maps plugin IDs to repository IDs
+ private lastRefresh: Map = 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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)
+ }
+ }
+ };
+ }
+}
\ No newline at end of file
diff --git a/src/lib/plugin-store/types.ts b/src/lib/plugin-store/types.ts
new file mode 100644
index 0000000..ff3950d
--- /dev/null
+++ b/src/lib/plugin-store/types.ts
@@ -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;
+
+// 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;
+export type QoSSettings = z.infer;
+export type ActionDefinition = z.infer;
+export type RobotPlugin = z.infer;
diff --git a/src/server/api/root.ts b/src/server/api/root.ts
index c012f3c..f11d28e 100644
--- a/src/server/api/root.ts
+++ b/src/server/api/root.ts
@@ -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
diff --git a/src/server/api/routers/plugin-store.ts b/src/server/api/routers/plugin-store.ts
new file mode 100644
index 0000000..aec3729
--- /dev/null
+++ b/src/server/api/routers/plugin-store.ts
@@ -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",
+ });
+ }
+ }),
+});
diff --git a/src/server/auth.ts b/src/server/auth.ts
index 01de7a0..3cb47b4 100644
--- a/src/server/auth.ts
+++ b/src/server/auth.ts
@@ -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);
\ No newline at end of file
+export const auth = () => getServerSession(authOptions);
diff --git a/src/server/db/index.ts b/src/server/db/index.ts
index e88dabf..3240ec9 100644
--- a/src/server/db/index.ts
+++ b/src/server/db/index.ts
@@ -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";
/**
diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts
index 78bf006..f798f6a 100644
--- a/src/server/db/schema.ts
+++ b/src/server/db/schema.ts
@@ -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";
\ No newline at end of file
+export * from "./schema/permissions";
+export * from "./schema/experiments";
+export * from "./schema/store";
\ No newline at end of file
diff --git a/src/server/db/schema/experiments.ts b/src/server/db/schema/experiments.ts
index 935e241..255b4c9 100644
--- a/src/server/db/schema/experiments.ts
+++ b/src/server/db/schema/experiments.ts
@@ -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()
diff --git a/src/server/db/schema/index.ts b/src/server/db/schema/index.ts
deleted file mode 100644
index 8b2207d..0000000
--- a/src/server/db/schema/index.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-export * from "./auth";
-export * from "./studies";
-export * from "./permissions";
-export * from "./experiments";
\ No newline at end of file
diff --git a/src/server/db/schema/permissions.ts b/src/server/db/schema/permissions.ts
index 0a4e191..6aeacbd 100644
--- a/src/server/db/schema/permissions.ts
+++ b/src/server/db/schema/permissions.ts
@@ -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] }),
})
);
diff --git a/src/server/db/schema/store.ts b/src/server/db/schema/store.ts
new file mode 100644
index 0000000..49647e8
--- /dev/null
+++ b/src/server/db/schema/store.ts
@@ -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(),
+});
diff --git a/src/server/db/schema/studies.ts b/src/server/db/schema/studies.ts
index b4d2b96..592e25c 100644
--- a/src/server/db/schema/studies.ts
+++ b/src/server/db/schema/studies.ts
@@ -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().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] }),
}));
\ No newline at end of file
diff --git a/src/server/db/seed.ts b/src/server/db/seed.ts
new file mode 100644
index 0000000..b5b6a00
--- /dev/null
+++ b/src/server/db/seed.ts
@@ -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);
+});
\ No newline at end of file
diff --git a/src/styles/globals.css b/src/styles/globals.css
deleted file mode 100644
index a54eba2..0000000
--- a/src/styles/globals.css
+++ /dev/null
@@ -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;
-}
\ No newline at end of file
diff --git a/src/trpc/server.ts b/src/trpc/server.ts
index 345210b..6f17260 100644
--- a/src/trpc/server.ts
+++ b/src/trpc/server.ts
@@ -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 };
diff --git a/tailwind.config.ts b/tailwind.config.ts
index d094f5b..642c592 100644
--- a/tailwind.config.ts
+++ b/tailwind.config.ts
@@ -2,74 +2,92 @@ import { type Config } from "tailwindcss";
import { fontFamily } from "tailwindcss/defaultTheme";
export default {
- darkMode: ["class"],
- content: ["./src/**/*.tsx"],
+ darkMode: ["class"],
+ content: ["./src/**/*.{js,jsx,ts,tsx}", "./src/**/*.{md,mdx}"],
theme: {
- extend: {
- fontFamily: {
- sans: [
- 'var(--font-geist-sans)',
- ...fontFamily.sans
- ]
- },
- borderRadius: {
- lg: 'var(--radius)',
- md: 'calc(var(--radius) - 2px)',
- sm: 'calc(var(--radius) - 4px)'
- },
- colors: {
- background: 'hsl(var(--background))',
- foreground: 'hsl(var(--foreground))',
- card: {
- DEFAULT: 'hsl(var(--card))',
- foreground: 'hsl(var(--card-foreground))'
- },
- popover: {
- DEFAULT: 'hsl(var(--popover))',
- foreground: 'hsl(var(--popover-foreground))'
- },
- primary: {
- DEFAULT: 'hsl(var(--primary))',
- foreground: 'hsl(var(--primary-foreground))'
- },
- secondary: {
- DEFAULT: 'hsl(var(--secondary))',
- foreground: 'hsl(var(--secondary-foreground))'
- },
- muted: {
- DEFAULT: 'hsl(var(--muted))',
- foreground: 'hsl(var(--muted-foreground))'
- },
- accent: {
- DEFAULT: 'hsl(var(--accent))',
- foreground: 'hsl(var(--accent-foreground))'
- },
- destructive: {
- DEFAULT: 'hsl(var(--destructive))',
- foreground: 'hsl(var(--destructive-foreground))'
- },
- border: 'hsl(var(--border))',
- input: 'hsl(var(--input))',
- ring: 'hsl(var(--ring))',
- chart: {
- '1': 'hsl(var(--chart-1))',
- '2': 'hsl(var(--chart-2))',
- '3': 'hsl(var(--chart-3))',
- '4': 'hsl(var(--chart-4))',
- '5': 'hsl(var(--chart-5))'
- },
- sidebar: {
- DEFAULT: 'hsl(var(--sidebar-background))',
- foreground: 'hsl(var(--sidebar-foreground))',
- primary: 'hsl(var(--sidebar-primary))',
- 'primary-foreground': 'hsl(var(--sidebar-primary-foreground))',
- accent: 'hsl(var(--sidebar-accent))',
- 'accent-foreground': 'hsl(var(--sidebar-accent-foreground))',
- border: 'hsl(var(--sidebar-border))',
- ring: 'hsl(var(--sidebar-ring))'
- }
- }
- }
+ container: {
+ center: true,
+ padding: "2rem",
+ screens: {
+ "2xl": "1400px",
+ },
+ },
+ extend: {
+ fontFamily: {
+ sans: ["var(--font-geist-sans)", ...fontFamily.sans],
+ },
+ borderRadius: {
+ lg: "var(--radius)",
+ md: "calc(var(--radius) - 2px)",
+ sm: "calc(var(--radius) - 4px)",
+ },
+ colors: {
+ background: "hsl(var(--background))",
+ foreground: "hsl(var(--foreground))",
+ card: {
+ DEFAULT: "hsl(var(--card))",
+ foreground: "hsl(var(--card-foreground))",
+ },
+ popover: {
+ DEFAULT: "hsl(var(--popover))",
+ foreground: "hsl(var(--popover-foreground))",
+ },
+ primary: {
+ DEFAULT: "hsl(var(--primary))",
+ foreground: "hsl(var(--primary-foreground))",
+ },
+ secondary: {
+ DEFAULT: "hsl(var(--secondary))",
+ foreground: "hsl(var(--secondary-foreground))",
+ },
+ muted: {
+ DEFAULT: "hsl(var(--muted))",
+ foreground: "hsl(var(--muted-foreground))",
+ },
+ accent: {
+ DEFAULT: "hsl(var(--accent))",
+ foreground: "hsl(var(--accent-foreground))",
+ },
+ destructive: {
+ DEFAULT: "hsl(var(--destructive))",
+ foreground: "hsl(var(--destructive-foreground))",
+ },
+ border: "hsl(var(--border))",
+ input: "hsl(var(--input))",
+ ring: "hsl(var(--ring))",
+ chart: {
+ "1": "hsl(var(--chart-1))",
+ "2": "hsl(var(--chart-2))",
+ "3": "hsl(var(--chart-3))",
+ "4": "hsl(var(--chart-4))",
+ "5": "hsl(var(--chart-5))",
+ },
+ sidebar: {
+ DEFAULT: "hsl(var(--sidebar-background))",
+ foreground: "hsl(var(--sidebar-foreground))",
+ primary: "hsl(var(--sidebar-primary))",
+ "primary-foreground": "hsl(var(--sidebar-primary-foreground))",
+ accent: "hsl(var(--sidebar-accent))",
+ "accent-foreground": "hsl(var(--sidebar-accent-foreground))",
+ border: "hsl(var(--sidebar-border))",
+ ring: "hsl(var(--sidebar-ring))",
+ },
+ },
+ keyframes: {
+ "accordion-down": {
+ from: { height: "0" },
+ to: { height: "var(--radix-accordion-content-height)" },
+ },
+ "accordion-up": {
+ from: { height: "var(--radix-accordion-content-height)" },
+ to: { height: "0" },
+ },
+ },
+ animation: {
+ "accordion-down": "accordion-down 0.2s ease-out",
+ "accordion-up": "accordion-up 0.2s ease-out",
+ },
+ },
},
plugins: [require("tailwindcss-animate")],
} satisfies Config;
diff --git a/test.txt b/test.txt
deleted file mode 100644
index 9daeafb..0000000
--- a/test.txt
+++ /dev/null
@@ -1 +0,0 @@
-test