feat: Enhance plugin store and experiment design infrastructure

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

21
.vscode/settings.json vendored
View File

@@ -1,2 +1,21 @@
{ {
} "editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"tailwindCSS.experimental.classRegex": [
["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
["cn\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
["tw=\"([^\"]*)\""]
],
"tailwindCSS.includeLanguages": {
"typescript": "javascript",
"typescriptreact": "javascript"
},
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true,
"css.validate": false,
"scss.validate": false,
"tailwindCSS.validate": true
}

View File

@@ -12,6 +12,7 @@
"@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@hookform/resolvers": "^3.10.0", "@hookform/resolvers": "^3.10.0",
"@paralleldrive/cuid2": "^2.2.2",
"@radix-ui/react-alert-dialog": "^1.1.6", "@radix-ui/react-alert-dialog": "^1.1.6",
"@radix-ui/react-avatar": "^1.1.2", "@radix-ui/react-avatar": "^1.1.2",
"@radix-ui/react-collapsible": "^1.1.2", "@radix-ui/react-collapsible": "^1.1.2",
@@ -328,6 +329,8 @@
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.1.7", "", { "os": "win32", "cpu": "x64" }, "sha512-dC01f1quuf97viOfW05/K8XYv2iuBgAxJZl7mbCKEjMgdQl5JjAKJ0D2qMKZCgPWDeFbFT0Q0nYWwytEW0DWTQ=="], "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.1.7", "", { "os": "win32", "cpu": "x64" }, "sha512-dC01f1quuf97viOfW05/K8XYv2iuBgAxJZl7mbCKEjMgdQl5JjAKJ0D2qMKZCgPWDeFbFT0Q0nYWwytEW0DWTQ=="],
"@noble/hashes": ["@noble/hashes@1.7.1", "", {}, "sha512-B8XBPsn4vT/KJAGqDzbwztd+6Yte3P4V7iafm24bxgDe/mlRuK6xmWPuCNrKt2vDafZ8MfJLlchDG/vYafQEjQ=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
@@ -338,6 +341,8 @@
"@panva/hkdf": ["@panva/hkdf@1.2.1", "", {}, "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw=="], "@panva/hkdf": ["@panva/hkdf@1.2.1", "", {}, "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw=="],
"@paralleldrive/cuid2": ["@paralleldrive/cuid2@2.2.2", "", { "dependencies": { "@noble/hashes": "^1.1.5" } }, "sha512-ZOBkgDwEdoYVlSeRbYYXs0S9MejQofiVYoTbKzy/6GQa39/q5tQU2IX46+shYnUkpEl3wc+J6wRlar7r2EK2xA=="],
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
"@radix-ui/number": ["@radix-ui/number@1.1.0", "", {}, "sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ=="], "@radix-ui/number": ["@radix-ui/number@1.1.0", "", {}, "sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ=="],

55
docs/DESIGN_DECISIONS.md Normal file
View File

@@ -0,0 +1,55 @@
# HRIStudio Design Decisions Documentation
## Overview
This document captures the design decisions made during the implementation of HRIStudio. It covers various areas including UI improvements, plugin store architecture, data loading and error handling, styling conformance, and overall system architecture. This serves as a reference for developers and other stakeholders to understand the rationale behind our choices.
## 1. UI Design & User Experience
- **Conditional Rendering and User Feedback:**
- Implemented dynamic messages in components (e.g., PluginBrowser, RobotList) to notify users when repositories or plugins are missing.
- Clear call-to-action prompts guide users to add repositories/plugins when necessary.
- **Consistent Styling and Themes:**
- Merged multiple globals.css files to create a cohesive, unified theme across the application.
- Updated sidebar and top bar styling, including gradients, background colors, and hover effects to ensure visual consistency in both light and dark modes.
- **Component-Based Approach:**
- Developed reusable, functional components (e.g., StudyCard, StudyForm, DeleteStudyButton) with TypeScript interfaces for enhanced maintainability and readability.
## 2. Plugin Store Architecture
- **Repository and Plugin Loading:**
- Updated the plugin loading mechanism to fetch repository metadata from `repository.json` instead of `index.json`.
- Implemented a fall-back sequence for loading individual plugin files (prioritizing `robot-plugins/plugins/{filename}` over alternative locations).
- **Error Handling:**
- Introduced a custom `PluginLoadError` to encapsulate errors in plugin loading and provide clearer error messages.
- **Caching Strategy:**
- Incorporated cache management with a TTL (5 minutes) to reduce frequent metadata fetching.
- **Transform Functions:**
- Allowed registration and retrieval of transformation functions (e.g., `transformToTwist`, `transformToPoseStamped`) that convert plugin parameters into suitable payload formats.
## 3. Data Access & ORM Integration
- **Drizzle ORM:**
- Adopted Drizzle ORM for type-safe database operations, using a custom `createTable` utility to avoid table naming conflicts.
- Defined schemas for studies, participants, permissions, and plugin repositories with clear relationships.
- **Type-Safe Data Validation:**
- Used Zod schemas along with TypeScript interfaces to validate data structures, ensuring integrity in plugin metadata and user data.
## 4. Authentication & Permissions
- **NextAuth & Auth.js Integration:**
- Configured NextAuth with a credentials provider, including bcrypt-based password verification.
- Extended session callbacks to include custom user details like name and role.
- **Role-Based Access Control:**
- Established a clear role hierarchy (Owner, Admin, Principal Investigator, Wizard, Researcher, Observer) with distinct permissions defined in a role-permission matrix.
- Enforced visibility and access controls for sensitive information based on user roles.
## 5. Next.js Architecture and Component Providers
- **Next.js 15 App Router:**
- Leveraged the new App Router to prioritize server components over client components, reducing reliance on `useEffect` and client-side state for data fetching.
- **Global Providers:**
- Wrapped the application with multiple providers (ThemeProvider, PluginStoreProvider, StudyProvider) to supply context and maintain consistent application state.
## 6. Additional Design Considerations
- **Error Logging and Debugging:**
- Enhanced error logging throughout the plugin store and repository loading processes to aid in troubleshooting.
- **Naming Conventions and File Structure:**
- Maintained a clear naming convention for directories (lowercase with dashes) and reused descriptive interface names to ensure clarity and consistency.
## Conclusion
These design decisions were made to build a robust, scalable, and user-friendly platform for managing human-robot interaction studies. By emphasizing type safety, modularization, and thorough error handling, HRIStudio is well-equipped for both current needs and future enhancements. Future work may focus on extending experimental features and integrating advanced analytics tools.

29
docs/README.md Normal file
View File

@@ -0,0 +1,29 @@
# HRIStudio Documentation
Welcome to the HRIStudio documentation. This directory contains comprehensive documentation about the design, architecture, and implementation of HRIStudio.
## Documentation Structure
- [`architecture.md`](./architecture.md) - System architecture, tech stack, and core design decisions
- [`ui-design.md`](./ui-design.md) - UI/UX guidelines, component structure, and styling decisions
- [`plugin-store.md`](./plugin-store.md) - Plugin system architecture and implementation details
- [`auth-and-permissions.md`](./auth-and-permissions.md) - Authentication and role-based access control
- [`experiment-designer.md`](./experiment-designer.md) - Experiment designer implementation and flow control
- [`data-layer.md`](./data-layer.md) - Database schema, ORM usage, and data validation
- [`development.md`](./development.md) - Development guidelines, conventions, and best practices
- [`future-roadmap.md`](./future-roadmap.md) - Planned features and future enhancements
## Quick Start
1. Read through [`architecture.md`](./architecture.md) for a high-level overview
2. Review [`development.md`](./development.md) for development setup and guidelines
3. Explore specific topics in their dedicated files
## Contributing
When adding new features or making significant changes:
1. Update relevant documentation files
2. Follow the established format and style
3. Include code examples where appropriate
4. Update this README if adding new documentation files

249
docs/architecture.md Normal file
View File

@@ -0,0 +1,249 @@
# System Architecture
## Overview
HRIStudio is built on a modern tech stack centered around Next.js 15's App Router, emphasizing server-side rendering and type safety throughout the application. This document outlines the core architectural decisions and system design.
## Tech Stack
- **Framework:** Next.js 15 (App Router)
- **Language:** TypeScript
- **Database ORM:** Drizzle
- **Authentication:** NextAuth.js (Auth.js)
- **API Layer:** tRPC
- **UI Components:**
- Shadcn UI
- Radix UI
- Tailwind CSS
- **State Management:** React Context + Hooks
- **Form Handling:** React Hook Form + Zod
## Core Architecture Components
### Next.js App Router Structure
```
src/
├── app/ # Next.js 15 App Router pages
│ ├── api/ # API routes
│ ├── auth/ # Authentication pages
│ ├── dashboard/ # Dashboard and main application
│ └── layout.tsx # Root layout
├── components/ # Shared React components
├── lib/ # Utility functions and shared logic
├── server/ # Server-side code
│ ├── api/ # tRPC routers
│ ├── auth/ # Authentication configuration
│ └── db/ # Database schemas and utilities
└── styles/ # Global styles and Tailwind config
```
### Global Providers
The application is wrapped in several context providers that manage global state:
```typescript
export function Providers({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider>
<PluginStoreProvider>
<StudyProvider>
{children}
<Toaster />
</StudyProvider>
</PluginStoreProvider>
</ThemeProvider>
);
}
```
### Server Components vs Client Components
We prioritize Server Components for better performance and SEO:
- **Server Components (Default):**
- Data fetching
- Static content rendering
- Layout components
- Database operations
- **Client Components (Marked with "use client"):**
- Interactive UI elements
- Components requiring browser APIs
- Real-time updates
- Form handling
## API Layer
### tRPC Integration
Type-safe API routes are implemented using tRPC:
```typescript
export const appRouter = createTRPCRouter({
study: studyRouter,
participant: participantRouter,
experiment: experimentRouter,
});
```
Each router provides strongly-typed procedures:
```typescript
export const studyRouter = createTRPCRouter({
create: protectedProcedure
.input(studySchema)
.mutation(async ({ ctx, input }) => {
// Implementation
}),
// Other procedures...
});
```
### Error Handling
Centralized error handling through tRPC:
```typescript
export const createTRPCContext = async (opts: CreateNextContextOptions) => {
const session = await getServerAuthSession();
return {
session,
db,
// Additional context...
};
};
```
## Database Architecture
### Drizzle ORM Integration
Custom table creation utility to prevent naming conflicts:
```typescript
export const createTable = pgTableCreator((name) => `hs_${name}`);
```
Example schema definition:
```typescript
export const studies = createTable("study", {
id: integer("id").primaryKey().notNull().generatedAlwaysAsIdentity(),
title: varchar("title", { length: 256 }).notNull(),
description: text("description"),
// Additional fields...
});
```
### Relations
Explicit relation definitions using Drizzle's relations API:
```typescript
export const studiesRelations = relations(studies, ({ one, many }) => ({
creator: one(users, {
fields: [studies.createdById],
references: [users.id],
}),
members: many(studyMembers),
// Additional relations...
}));
```
## Performance Considerations
### Server-Side Rendering
- Leveraging Next.js App Router for optimal server-side rendering
- Minimizing client-side JavaScript
- Implementing proper caching strategies
### Data Fetching
- Using React Suspense for loading states
- Implementing stale-while-revalidate patterns
- Optimizing database queries
### Caching Strategy
- Browser-level caching for static assets
- Server-side caching for API responses
- Plugin store metadata caching (5-minute TTL)
## Security
### Authentication Flow
1. User credentials validation
2. Password hashing with bcrypt
3. Session management with NextAuth
4. Role-based access control
### Data Protection
- Input validation using Zod schemas
- SQL injection prevention through Drizzle ORM
- XSS prevention through proper React escaping
- CSRF protection via Auth.js tokens
## Monitoring and Debugging
### Error Tracking
- Custom error classes for specific scenarios
- Detailed error logging
- Error boundary implementation
### Performance Monitoring
- Web Vitals tracking
- Server-side metrics collection
- Client-side performance monitoring
## Development Workflow
### Type Safety
- Strict TypeScript configuration
- Zod schema validation
- tRPC for end-to-end type safety
### Code Organization
- Feature-based directory structure
- Clear separation of concerns
- Consistent naming conventions
### Testing Strategy
- Unit tests for utility functions
- Integration tests for API routes
- End-to-end tests for critical flows
## Deployment
### Environment Configuration
- Development environment
- Staging environment
- Production environment
### CI/CD Pipeline
- Automated testing
- Type checking
- Linting
- Build verification
## Conclusion
This architecture provides a solid foundation for HRIStudio, emphasizing:
- Type safety
- Performance
- Scalability
- Maintainability
- Security
Future architectural decisions should align with these principles while considering the evolving needs of the platform.

View File

@@ -0,0 +1,365 @@
# Authentication & Permissions System
## Overview
HRIStudio implements a robust authentication and role-based access control (RBAC) system using NextAuth.js (Auth.js) and a custom permissions framework. This system ensures secure access to resources and proper isolation of sensitive data.
## Authentication
### NextAuth Configuration
```typescript
export const authConfig = {
adapter: DrizzleAdapter(db),
providers: [
CredentialsProvider({
name: "credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" }
},
async authorize(credentials) {
// Credential validation logic
}
})
],
callbacks: {
session: ({ session, user }) => ({
...session,
user: {
...session.user,
id: user.id,
name: user.firstName && user.lastName
? `${user.firstName} ${user.lastName}`
: null,
firstName: user.firstName,
lastName: user.lastName,
},
}),
},
pages: {
signIn: '/auth/signin',
},
};
```
### Session Management
```typescript
interface Session {
user: {
id: string;
email: string;
firstName: string | null;
lastName: string | null;
} & DefaultSession["user"];
}
```
## Role-Based Access Control
### Role Hierarchy
1. **Owner**
- Single owner per study
- Full control over all aspects
- Can delete study or transfer ownership
- Can manage all other roles
2. **Admin**
- Multiple admins allowed
- Can manage participants and experiments
- Cannot delete study or transfer ownership
- Can invite and manage other users (except Owner)
3. **Principal Investigator (PI)**
- Scientific oversight role
- Full access to participant data
- Can manage experiment protocols
- Cannot modify core study settings
4. **Wizard**
- Operates robots during experiments
- Can control live experiment sessions
- Limited view of participant data
- Cannot modify study design
5. **Researcher**
- Can view and analyze data
- Access to anonymized information
- Cannot modify study or participant data
- Cannot run experiment trials
6. **Observer**
- Can view live experiments
- Access to anonymized data
- Can add annotations
- Cannot modify any study aspects
### Permission Categories
```typescript
export const PERMISSIONS = {
// Study Management
CREATE_STUDY: "create_study",
DELETE_STUDY: "delete_study",
EDIT_STUDY: "edit_study",
TRANSFER_OWNERSHIP: "transfer_ownership",
// Participant Management
VIEW_PARTICIPANTS: "view_participants",
ADD_PARTICIPANT: "add_participant",
EDIT_PARTICIPANT: "edit_participant",
DELETE_PARTICIPANT: "delete_participant",
VIEW_PARTICIPANT_NAMES: "view_participant_names",
// Experiment Management
CREATE_EXPERIMENT: "create_experiment",
EDIT_EXPERIMENT: "edit_experiment",
DELETE_EXPERIMENT: "delete_experiment",
RUN_EXPERIMENT: "run_experiment",
// Data Access
EXPORT_DATA: "export_data",
VIEW_ANALYTICS: "view_analytics",
// User Management
INVITE_USERS: "invite_users",
MANAGE_ROLES: "manage_roles",
} as const;
```
### Role-Permission Matrix
```typescript
export const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
OWNER: Object.values(PERMISSIONS),
ADMIN: [
PERMISSIONS.EDIT_STUDY,
PERMISSIONS.VIEW_PARTICIPANTS,
PERMISSIONS.ADD_PARTICIPANT,
PERMISSIONS.EDIT_PARTICIPANT,
PERMISSIONS.DELETE_PARTICIPANT,
PERMISSIONS.VIEW_PARTICIPANT_NAMES,
PERMISSIONS.CREATE_EXPERIMENT,
PERMISSIONS.EDIT_EXPERIMENT,
PERMISSIONS.DELETE_EXPERIMENT,
PERMISSIONS.RUN_EXPERIMENT,
PERMISSIONS.EXPORT_DATA,
PERMISSIONS.VIEW_ANALYTICS,
PERMISSIONS.INVITE_USERS,
PERMISSIONS.MANAGE_ROLES,
],
PRINCIPAL_INVESTIGATOR: [
PERMISSIONS.VIEW_PARTICIPANTS,
PERMISSIONS.ADD_PARTICIPANT,
PERMISSIONS.EDIT_PARTICIPANT,
PERMISSIONS.VIEW_PARTICIPANT_NAMES,
PERMISSIONS.CREATE_EXPERIMENT,
PERMISSIONS.EDIT_EXPERIMENT,
PERMISSIONS.RUN_EXPERIMENT,
PERMISSIONS.EXPORT_DATA,
PERMISSIONS.VIEW_ANALYTICS,
],
// ... additional role permissions
};
```
## Implementation
### Permission Checking
```typescript
export async function checkPermissions({
studyId,
permission,
session,
}: {
studyId?: number;
permission: Permission;
session: Session | null;
}): Promise<void> {
if (!session?.user) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You must be logged in to perform this action",
});
}
// Anyone who is logged in can create a study
if (!studyId) {
if (permission === "CREATE_STUDY") {
return;
}
throw new TRPCError({
code: "BAD_REQUEST",
message: "Study ID is required for this action",
});
}
const membership = await db.query.studyMembers.findFirst({
where: and(
eq(studyMembers.studyId, studyId),
eq(studyMembers.userId, session.user.id),
),
});
if (!membership) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You do not have permission to perform this action",
});
}
const normalizedRole = membership.role.toUpperCase() as keyof typeof ROLE_PERMISSIONS;
const permittedActions = ROLE_PERMISSIONS[normalizedRole] ?? [];
if (normalizedRole === "OWNER") {
return;
}
if (!permittedActions.includes(permission)) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You do not have permission to perform this action",
});
}
}
```
### Database Schema
```typescript
export const studyMembers = createTable("study_member", {
id: integer("id").primaryKey().notNull().generatedAlwaysAsIdentity(),
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").defaultNow().notNull(),
});
```
## UI Integration
### Protected Routes
```typescript
export default function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
const { data: session, status } = useSession()
const router = useRouter()
useEffect(() => {
if (status === "unauthenticated") {
router.replace("/auth/signin")
}
}, [status, router])
if (!session) {
return null
}
return (
// Layout content
);
}
```
### Conditional Rendering
```typescript
export function ParticipantDetails({ participant, role }: Props) {
const canViewIdentifiableInfo = [
ROLES.OWNER,
ROLES.ADMIN,
ROLES.PRINCIPAL_INVESTIGATOR
].includes(role);
return (
<div>
{canViewIdentifiableInfo ? (
<div>
<h3>{participant.name}</h3>
<p>{participant.email}</p>
</div>
) : (
<div>
<h3>Participant {participant.id}</h3>
<p>[Redacted]</p>
</div>
)}
</div>
);
}
```
## Security Considerations
### Password Handling
- Passwords are hashed using bcrypt before storage
- Minimum password requirements enforced
- Rate limiting on authentication attempts
### Session Security
- CSRF protection enabled
- Secure session cookies
- Session expiration and renewal
### Data Access
- Row-level security through role checks
- Audit logging of sensitive operations
- Data encryption at rest
## Best Practices
1. **Permission Checking:**
- Always check permissions before sensitive operations
- Use the checkPermissions utility consistently
- Include proper error messages
2. **Role Assignment:**
- Validate role assignments
- Maintain role hierarchy
- Prevent privilege escalation
3. **UI Security:**
- Hide sensitive UI elements based on permissions
- Clear error messages without exposing internals
- Proper loading states during authentication
4. **Audit Trail:**
- Log authentication attempts
- Track permission changes
- Monitor sensitive data access
## Future Enhancements
1. **Advanced Authentication:**
- Multi-factor authentication
- OAuth provider integration
- SSO support
2. **Enhanced Permissions:**
- Custom role creation
- Temporary permissions
- Permission inheritance
3. **Audit System:**
- Detailed activity logging
- Security alerts
- Compliance reporting
4. **UI Improvements:**
- Role management interface
- Permission visualization
- Audit log viewer

304
docs/data-layer.md Normal file
View File

@@ -0,0 +1,304 @@
# Data Layer
## Overview
HRIStudio's data layer is built on PostgreSQL using Drizzle ORM for type-safe database operations. The system implements a comprehensive schema design that supports studies, experiments, participants, and plugin management while maintaining data integrity and proper relationships.
## Database Schema
### Core Tables
#### Studies
```typescript
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").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
export const studyMembers = createTable("study_member", {
id: integer("id").primaryKey().notNull().generatedAlwaysAsIdentity(),
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").defaultNow().notNull(),
});
```
#### Experiments
```typescript
export const experiments = createTable("experiment", {
id: integer("id").primaryKey().notNull().generatedAlwaysAsIdentity(),
studyId: integer("study_id")
.notNull()
.references(() => studies.id, { onDelete: "cascade" }),
title: varchar("title", { length: 256 }).notNull(),
description: text("description"),
version: integer("version").notNull().default(1),
status: experimentStatusEnum("status").notNull().default("draft"),
steps: jsonb("steps").$type<Step[]>().default([]),
createdById: varchar("created_by", { length: 255 })
.notNull()
.references(() => users.id),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
```
#### Participants
```typescript
export const participants = createTable("participant", {
id: integer("id").primaryKey().notNull().generatedAlwaysAsIdentity(),
studyId: integer("study_id")
.notNull()
.references(() => studies.id, { onDelete: "cascade" }),
identifier: varchar("identifier", { length: 256 }),
email: varchar("email", { length: 256 }),
firstName: varchar("first_name", { length: 256 }),
lastName: varchar("last_name", { length: 256 }),
notes: text("notes"),
status: participantStatusEnum("status").notNull().default("active"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
```
### Plugin Store Tables
```typescript
export const pluginRepositories = createTable("plugin_repository", {
id: varchar("id", { length: 255 }).primaryKey(),
name: varchar("name", { length: 255 }).notNull(),
description: text("description"),
url: varchar("url", { length: 255 }).notNull(),
official: boolean("official").default(false).notNull(),
author: jsonb("author").notNull().$type<{
name: string;
email?: string;
url?: string;
organization?: string;
}>(),
maintainers: jsonb("maintainers").$type<Array<{
name: string;
email?: string;
url?: string;
}>>(),
compatibility: jsonb("compatibility").notNull().$type<{
hristudio: {
min: string;
recommended?: string;
};
ros2?: {
distributions: string[];
recommended?: string;
};
}>(),
stats: jsonb("stats").$type<{
downloads: number;
stars: number;
plugins: number;
}>(),
addedById: varchar("added_by", { length: 255 })
.references(() => users.id),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
```
## Relations
### Study Relations
```typescript
export const studiesRelations = relations(studies, ({ one, many }) => ({
creator: one(users, {
fields: [studies.createdById],
references: [users.id],
}),
members: many(studyMembers),
participants: many(participants),
experiments: many(experiments),
}));
export const studyMembersRelations = relations(studyMembers, ({ one }) => ({
study: one(studies, {
fields: [studyMembers.studyId],
references: [studies.id],
}),
user: one(users, {
fields: [studyMembers.userId],
references: [users.id],
}),
}));
```
## Data Validation
### Zod Schemas
```typescript
const studySchema = z.object({
title: z.string().min(1, "Title is required").max(256, "Title is too long"),
description: z.string().optional(),
});
const participantSchema = z.object({
identifier: z.string().optional(),
email: z.string().email().optional(),
firstName: z.string().optional(),
lastName: z.string().optional(),
notes: z.string().optional(),
status: z.enum(["active", "inactive", "completed", "withdrawn"]),
});
const experimentSchema = z.object({
title: z.string().min(1, "Title is required").max(256, "Title is too long"),
description: z.string().optional(),
steps: z.array(stepSchema),
status: z.enum(["draft", "active", "archived"]),
});
```
## Query Building
### Type-Safe Queries
```typescript
const study = await db.query.studies.findFirst({
where: eq(studies.id, studyId),
with: {
creator: true,
members: {
with: {
user: true,
},
},
participants: true,
},
});
```
### Mutations
```typescript
const newStudy = await db.transaction(async (tx) => {
const [study] = await tx.insert(studies).values({
title,
description,
createdById: userId,
}).returning();
await tx.insert(studyMembers).values({
studyId: study.id,
userId,
role: "OWNER",
});
return study;
});
```
## Error Handling
### Database Errors
```typescript
try {
await db.insert(participants).values({
studyId,
identifier,
email,
});
} catch (error) {
if (error instanceof PostgresError) {
if (error.code === "23505") { // Unique violation
throw new TRPCError({
code: "CONFLICT",
message: "A participant with this identifier already exists",
});
}
}
throw error;
}
```
## Migrations
### Migration Structure
```typescript
import { sql } from "drizzle-orm";
import { createTable, integer, text, timestamp, varchar } from "drizzle-orm/pg-core";
export async function up(db: Database) {
await db.schema.createTable("study")
.addColumn("id", "serial", (col) => col.primaryKey())
.addColumn("title", "varchar(256)", (col) => col.notNull())
.addColumn("description", "text")
.addColumn("created_by", "varchar(255)", (col) =>
col.notNull().references("user.id"))
.addColumn("created_at", "timestamp", (col) =>
col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`))
.addColumn("updated_at", "timestamp");
}
export async function down(db: Database) {
await db.schema.dropTable("study");
}
```
## Best Practices
1. **Type Safety:**
- Use Drizzle's type inference
- Define explicit types for complex queries
- Validate input with Zod schemas
2. **Performance:**
- Use appropriate indexes
- Optimize complex queries
- Implement caching where needed
3. **Data Integrity:**
- Use transactions for related operations
- Implement proper cascading
- Validate data before insertion
4. **Security:**
- Sanitize user input
- Implement proper access control
- Use parameterized queries
## Future Enhancements
1. **Query Optimization:**
- Query caching
- Materialized views
- Query analysis tools
2. **Data Management:**
- Data archiving
- Backup strategies
- Data export tools
3. **Schema Evolution:**
- Zero-downtime migrations
- Schema versioning
- Backward compatibility
4. **Monitoring:**
- Query performance metrics
- Error tracking
- Usage analytics

376
docs/development.md Normal file
View File

@@ -0,0 +1,376 @@
# Development Guidelines
## Overview
This document outlines the development practices, coding standards, and workflow guidelines for contributing to HRIStudio. Following these guidelines ensures consistency and maintainability across the codebase.
## Development Environment
### Prerequisites
- Node.js (v18+)
- PostgreSQL (v14+)
- Docker (for local development)
- VS Code (recommended)
### Setup
1. **Clone the Repository:**
```bash
git clone https://github.com/yourusername/hristudio.git
cd hristudio
```
2. **Install Dependencies:**
```bash
npm install
```
3. **Environment Configuration:**
```bash
cp .env.example .env
# Edit .env with your local settings
```
4. **Database Setup:**
```bash
npm run docker:up # Start PostgreSQL container
npm run db:push # Apply database schema
npm run db:seed # Seed initial data
```
5. **Start Development Server:**
```bash
npm run dev
```
## Code Organization
### Directory Structure
```
src/
├── app/ # Next.js pages and layouts
├── components/ # React components
│ ├── auth/ # Authentication components
│ ├── experiments/ # Experiment-related components
│ ├── layout/ # Layout components
│ ├── navigation/ # Navigation components
│ ├── studies/ # Study-related components
│ └── ui/ # Shared UI components
├── lib/ # Utility functions and shared logic
│ ├── experiments/ # Experiment-related utilities
│ ├── permissions/ # Permission checking utilities
│ └── plugin-store/ # Plugin store implementation
├── server/ # Server-side code
│ ├── api/ # tRPC routers
│ ├── auth/ # Authentication configuration
│ └── db/ # Database schemas and utilities
└── styles/ # Global styles and Tailwind config
```
### Naming Conventions
1. **Files and Directories:**
```typescript
// Components
components/auth/sign-in-form.tsx
components/studies/study-card.tsx
// Pages
app/dashboard/studies/[id]/page.tsx
app/dashboard/experiments/new/page.tsx
```
2. **Component Names:**
```typescript
// PascalCase for component names
export function SignInForm() { ... }
export function StudyCard() { ... }
```
3. **Variables and Functions:**
```typescript
// camelCase for variables and functions
const userSession = useSession();
function handleSubmit() { ... }
```
## Coding Standards
### TypeScript
1. **Type Definitions:**
```typescript
// Use interfaces for object definitions
interface StudyProps {
id: number;
title: string;
description?: string;
createdAt: Date;
}
// Use type for unions and intersections
type Status = "draft" | "active" | "archived";
```
2. **Type Safety:**
```typescript
// Use proper type annotations
function getStudy(id: number): Promise<Study> {
return db.query.studies.findFirst({
where: eq(studies.id, id),
});
}
```
### React Components
1. **Functional Components:**
```typescript
interface ButtonProps {
variant?: "default" | "outline" | "ghost";
children: React.ReactNode;
}
export function Button({ variant = "default", children }: ButtonProps) {
return (
<button className={cn(buttonVariants({ variant }))}>
{children}
</button>
);
}
```
2. **Hooks:**
```typescript
function useStudy(studyId: number) {
const { data, isLoading } = api.study.getById.useQuery({ id: studyId });
return {
study: data,
isLoading,
};
}
```
### Styling
1. **Tailwind CSS:**
```typescript
// Use Tailwind classes
<div className="flex items-center justify-between p-4 bg-card">
<h2 className="text-lg font-semibold">Title</h2>
<Button className="hover:bg-primary/90">Action</Button>
</div>
```
2. **CSS Variables:**
```css
:root {
--primary: 217 91% 60%;
--primary-foreground: 0 0% 100%;
}
.custom-element {
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
}
```
## Testing
### Unit Tests
```typescript
describe("StudyCard", () => {
it("renders study information correctly", () => {
const study = {
id: 1,
title: "Test Study",
description: "Test Description",
};
render(<StudyCard study={study} />);
expect(screen.getByText("Test Study")).toBeInTheDocument();
expect(screen.getByText("Test Description")).toBeInTheDocument();
});
});
```
### Integration Tests
```typescript
describe("Study Creation", () => {
it("creates a new study", async () => {
const user = userEvent.setup();
render(<CreateStudyForm />);
await user.type(screen.getByLabelText("Title"), "New Study");
await user.click(screen.getByText("Create Study"));
expect(await screen.findByText("Study created successfully")).toBeInTheDocument();
});
});
```
## Error Handling
### API Errors
```typescript
try {
const result = await api.study.create.mutate(data);
toast({
title: "Success",
description: "Study created successfully",
});
} catch (error) {
toast({
title: "Error",
description: error.message,
variant: "destructive",
});
}
```
### Form Validation
```typescript
const form = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
title: "",
description: "",
},
});
function onSubmit(data: FormData) {
try {
// Form submission logic
} catch (error) {
form.setError("root", {
type: "submit",
message: "Something went wrong",
});
}
}
```
## Performance Optimization
### React Optimization
1. **Memoization:**
```typescript
const MemoizedComponent = memo(function Component({ data }: Props) {
return <div>{data}</div>;
});
```
2. **Code Splitting:**
```typescript
const DynamicComponent = dynamic(() => import("./HeavyComponent"), {
loading: () => <Skeleton />,
});
```
### Database Optimization
1. **Efficient Queries:**
```typescript
// Use select to only fetch needed fields
const study = await db.query.studies.findFirst({
select: {
id: true,
title: true,
},
where: eq(studies.id, studyId),
});
```
2. **Batch Operations:**
```typescript
await db.transaction(async (tx) => {
await Promise.all(
participants.map(p =>
tx.insert(participants).values(p)
)
);
});
```
## Git Workflow
### Branching Strategy
1. `main` - Production-ready code
2. `develop` - Development branch
3. Feature branches: `feature/feature-name`
4. Bug fixes: `fix/bug-description`
### Commit Messages
```bash
# Format
<type>(<scope>): <description>
# Examples
feat(studies): add study creation workflow
fix(auth): resolve sign-in validation issue
docs(api): update API documentation
```
## Deployment
### Environment Configuration
```bash
# Development
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/hristudio"
NEXTAUTH_URL="http://localhost:3000"
# Production
DATABASE_URL="postgresql://user:pass@production-db/hristudio"
NEXTAUTH_URL="https://hristudio.com"
```
### Build Process
```bash
# Build application
npm run build
# Type check
npm run typecheck
# Run tests
npm run test
# Start production server
npm run start
```
## Best Practices
1. **Code Quality:**
- Write self-documenting code
- Add comments for complex logic
- Follow TypeScript best practices
2. **Security:**
- Validate all inputs
- Implement proper authentication
- Use HTTPS in production
3. **Performance:**
- Optimize bundle size
- Implement caching strategies
- Monitor performance metrics
4. **Maintenance:**
- Keep dependencies updated
- Document breaking changes
- Maintain test coverage

383
docs/experiment-designer.md Normal file
View File

@@ -0,0 +1,383 @@
# Experiment Designer
## Overview
The Experiment Designer is a core feature of HRIStudio that enables researchers to create and configure robot experiments using a visual, flow-based interface. It supports drag-and-drop functionality, real-time updates, and integration with the plugin system.
## Architecture
### Core Components
```typescript
interface ExperimentDesignerProps {
className?: string;
defaultSteps?: Step[];
onChange?: (steps: Step[]) => void;
readOnly?: boolean;
}
export function ExperimentDesigner({
className,
defaultSteps = [],
onChange,
readOnly = false,
}: ExperimentDesignerProps) {
// Implementation
}
```
### Data Types
```typescript
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
export interface Action {
id: string;
type: ActionType;
parameters: Record<string, any>;
order: number;
}
export interface Step {
id: string;
title: string;
description?: string;
actions: Action[];
order: number;
}
export interface Experiment {
id: number;
studyId: number;
title: string;
description?: string;
version: number;
status: "draft" | "active" | "archived";
steps: Step[];
createdAt: Date;
updatedAt: Date;
}
```
## Visual Components
### Action Node
```typescript
interface ActionNodeData {
type: string;
parameters: Record<string, any>;
onChange?: (parameters: Record<string, any>) => void;
}
export const ActionNode = memo(({ data, selected }: NodeProps<ActionNodeData>) => {
const [configOpen, setConfigOpen] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const actionConfig = AVAILABLE_ACTIONS.find((a) => a.type === data.type);
return (
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.2 }}
className={cn(
"relative",
"before:absolute before:inset-[-2px] before:rounded-xl before:bg-gradient-to-br",
selected && "before:from-primary/50 before:to-primary/20"
)}
>
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-gradient-to-br">
{actionConfig?.icon}
</div>
<CardTitle>{actionConfig?.title}</CardTitle>
</div>
</CardHeader>
<CardContent>
<CardDescription>{actionConfig?.description}</CardDescription>
</CardContent>
</Card>
</motion.div>
);
});
```
### Flow Edge
```typescript
export function FlowEdge({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
style = {},
markerEnd,
}: EdgeProps) {
const [edgePath] = getBezierPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
});
return (
<>
<BaseEdge path={edgePath} markerEnd={markerEnd} style={style} />
<motion.path
id={id}
style={{
strokeWidth: 3,
fill: "none",
stroke: "hsl(var(--primary))",
strokeDasharray: "5,5",
opacity: 0.5,
}}
d={edgePath}
animate={{
strokeDashoffset: [0, -10],
}}
transition={{
duration: 1,
repeat: Infinity,
ease: "linear",
}}
/>
</>
);
}
```
## Action Configuration
### Available Actions
```typescript
export const AVAILABLE_ACTIONS: ActionConfig[] = [
{
type: "move",
title: "Move Robot",
description: "Move the robot to a specific position",
icon: <Move className="h-4 w-4" />,
defaultParameters: {
position: { x: 0, y: 0, z: 0 },
speed: 1,
easing: "linear",
},
},
{
type: "speak",
title: "Robot Speech",
description: "Make the robot say something",
icon: <MessageSquare className="h-4 w-4" />,
defaultParameters: {
text: "",
speed: 1,
pitch: 1,
volume: 1,
},
},
// Additional actions...
];
```
### Parameter Configuration Dialog
```typescript
interface ActionConfigDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
type: ActionType;
parameters: Record<string, any>;
onSubmit: (parameters: Record<string, any>) => void;
}
export function ActionConfigDialog({
open,
onOpenChange,
type,
parameters,
onSubmit,
}: ActionConfigDialogProps) {
const actionConfig = AVAILABLE_ACTIONS.find(a => a.type === type);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Configure {actionConfig?.title}</DialogTitle>
<DialogDescription>
{actionConfig?.description}
</DialogDescription>
</DialogHeader>
<Form>
{/* Parameter fields */}
</Form>
</DialogContent>
</Dialog>
);
}
```
## Database Schema
```typescript
export const experiments = createTable("experiment", {
id: integer("id").primaryKey().notNull().generatedAlwaysAsIdentity(),
studyId: integer("study_id")
.notNull()
.references(() => studies.id, { onDelete: "cascade" }),
title: varchar("title", { length: 256 }).notNull(),
description: text("description"),
version: integer("version").notNull().default(1),
status: experimentStatusEnum("status").notNull().default("draft"),
steps: jsonb("steps").$type<Step[]>().default([]),
createdById: varchar("created_by", { length: 255 })
.notNull()
.references(() => users.id),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
```
## Integration with Plugin System
### Action Transformation
```typescript
interface ActionTransform {
type: "direct" | "transform";
transformFn?: string;
map?: Record<string, string>;
}
function transformActionParameters(
parameters: Record<string, any>,
transform: ActionTransform
): unknown {
if (transform.type === "direct") {
return parameters;
}
const transformFn = getTransformFunction(transform.transformFn!);
return transformFn(parameters);
}
```
### Plugin Action Integration
```typescript
function getAvailableActions(plugin: RobotPlugin): ActionConfig[] {
return plugin.actions.map(action => ({
type: action.type,
title: action.title,
description: action.description,
icon: getActionIcon(action.type),
defaultParameters: getDefaultParameters(action.parameters),
transform: action.ros2?.payloadMapping,
}));
}
```
## User Interface Features
### Drag and Drop
```typescript
function onDragStart(event: DragEvent, nodeType: string) {
event.dataTransfer.setData("application/reactflow", nodeType);
event.dataTransfer.effectAllowed = "move";
}
function onDrop(event: DragEvent) {
event.preventDefault();
const type = event.dataTransfer.getData("application/reactflow");
const position = project({
x: event.clientX,
y: event.clientY,
});
const newNode = {
id: getId(),
type,
position,
data: { label: `${type} node` },
};
setNodes((nds) => nds.concat(newNode));
}
```
### Step Organization
```typescript
function reorderSteps(steps: Step[], sourceIndex: number, targetIndex: number): Step[] {
const result = Array.from(steps);
const [removed] = result.splice(sourceIndex, 1);
result.splice(targetIndex, 0, removed);
return result.map((step, index) => ({
...step,
order: index,
}));
}
```
## Best Practices
1. **Performance:**
- Use React.memo for expensive components
- Implement virtualization for large flows
- Optimize drag and drop operations
2. **User Experience:**
- Provide clear visual feedback
- Implement undo/redo functionality
- Show validation errors inline
3. **Data Management:**
- Validate experiment data
- Implement auto-save
- Version control experiments
4. **Error Handling:**
- Validate action parameters
- Handle plugin loading errors
- Provide clear error messages
## Future Enhancements
1. **Advanced Flow Control:**
- Conditional branching
- Parallel execution
- Loop constructs
2. **Visual Improvements:**
- Custom node themes
- Animation preview
- Mini-map navigation
3. **Collaboration:**
- Real-time collaboration
- Comment system
- Version history
4. **Analysis Tools:**
- Flow validation
- Performance analysis
- Debug tools

306
docs/future-roadmap.md Normal file
View File

@@ -0,0 +1,306 @@
# Future Roadmap
## Overview
This document outlines the planned features, improvements, and future direction for HRIStudio. The roadmap is organized by priority and expected timeline.
## Q2 2024
### 1. Enhanced Plugin System
- **Plugin Marketplace**
- Community plugin submissions
- Plugin ratings and reviews
- Download statistics
- Version management
- **Advanced Plugin Features**
- Real-time plugin updates
- Plugin dependency management
- Custom action visualization
- Plugin testing framework
- **Plugin Development Tools**
- Plugin scaffolding CLI
- Development documentation
- Plugin validation tools
- Local testing environment
### 2. Experiment Designer Improvements
- **Advanced Flow Control**
- Conditional branching
- Parallel execution paths
- Loop constructs
- Event-based triggers
- **Visual Enhancements**
- Custom node themes
- Animation preview
- Mini-map navigation
- Grid snapping
- **Collaboration Features**
- Real-time collaboration
- Comment system
- Version history
- Experiment templates
## Q3 2024
### 1. Data Analysis Tools
- **Analytics Dashboard**
- Experiment metrics
- Participant statistics
- Performance analytics
- Custom reports
- **Data Visualization**
- Interactive charts
- Timeline views
- Heat maps
- Export capabilities
- **Machine Learning Integration**
- Pattern recognition
- Behavior analysis
- Predictive modeling
- Anomaly detection
### 2. Real-time Monitoring
- **Live Experiment Tracking**
- Real-time status updates
- Video streaming
- Sensor data visualization
- Remote control capabilities
- **Performance Monitoring**
- System metrics
- Robot status
- Network health
- Resource usage
- **Alert System**
- Custom alert rules
- Notification preferences
- Incident reporting
- Alert history
## Q4 2024
### 1. Advanced Authentication
- **Multi-factor Authentication**
- SMS verification
- Authenticator apps
- Hardware key support
- Biometric authentication
- **Single Sign-On**
- SAML integration
- OAuth providers
- Active Directory
- Custom IdP support
- **Enhanced Security**
- Audit logging
- Session management
- IP restrictions
- Rate limiting
### 2. Collaboration Tools
- **Team Management**
- Team hierarchies
- Resource sharing
- Permission inheritance
- Team analytics
- **Communication Features**
- In-app messaging
- Discussion boards
- File sharing
- Notification system
- **Knowledge Base**
- Documentation
- Best practices
- Troubleshooting guides
- Community forums
## 2025 and Beyond
### 1. AI Integration
- **Intelligent Assistance**
- Experiment suggestions
- Optimization recommendations
- Automated analysis
- Natural language processing
- **Predictive Features**
- Resource forecasting
- Behavior prediction
- Risk assessment
- Performance optimization
- **Automated Testing**
- Test case generation
- Regression testing
- Load testing
- Security scanning
### 2. Extended Platform Support
- **Mobile Applications**
- iOS app
- Android app
- Responsive web
- Cross-platform sync
- **Additional Robot Platforms**
- ROS1 support
- Custom protocols
- Hardware abstraction
- Simulator integration
- **Cloud Integration**
- Multi-cloud support
- Edge computing
- Data replication
- Disaster recovery
## Technical Improvements
### 1. Performance Optimization
- **Frontend**
- Bundle optimization
- Code splitting
- Lazy loading
- Service workers
- **Backend**
- Query optimization
- Caching strategies
- Load balancing
- Database sharding
- **Infrastructure**
- Container orchestration
- Auto-scaling
- CDN integration
- Geographic distribution
### 2. Developer Experience
- **Development Tools**
- CLI improvements
- Debug tools
- Testing utilities
- Documentation generator
- **Code Quality**
- Automated testing
- Code coverage
- Static analysis
- Performance profiling
- **Deployment**
- CI/CD enhancements
- Environment management
- Monitoring tools
- Rollback capabilities
## Research Integration
### 1. Academic Features
- **Publication Support**
- Data export
- Citation generation
- Figure creation
- Statistical analysis
- **Study Management**
- IRB integration
- Consent management
- Protocol tracking
- Data anonymization
- **Collaboration Tools**
- Institution management
- Grant tracking
- Resource sharing
- Publication tracking
### 2. Industry Integration
- **Enterprise Features**
- SLA management
- Compliance reporting
- Asset tracking
- Cost analysis
- **Integration Options**
- API expansion
- Custom connectors
- Data pipeline
- Workflow automation
- **Support Services**
- Training programs
- Technical support
- Consulting services
- Custom development
## Timeline and Milestones
### Q2 2024
- Plugin marketplace beta release
- Advanced experiment designer features
- Initial analytics dashboard
### Q3 2024
- Real-time monitoring system
- Data analysis tools
- Machine learning integration beta
### Q4 2024
- Multi-factor authentication
- Team collaboration tools
- Knowledge base launch
### 2025
- AI assistant beta
- Mobile applications
- Extended platform support
- Research integration features
## Success Metrics
1. **User Engagement**
- Active users
- Feature adoption
- User satisfaction
- Time spent in platform
2. **Platform Growth**
- Number of studies
- Plugin ecosystem
- API usage
- Community growth
3. **Technical Performance**
- System uptime
- Response times
- Error rates
- Resource utilization
4. **Research Impact**
- Published studies
- Citations
- Collaborations
- Grant success

119
docs/plan.md Normal file
View File

@@ -0,0 +1,119 @@
# HRIStudio Development Plan
## Immediate Goal: Paper Submission (1 Month)
Focus on delivering a functional experiment designer that demonstrates the platform's capabilities for Wizard-of-Oz HRI studies.
### 1. Experiment Designer Core
- [x] Basic flow-based designer UI
- [ ] Step containers with drag-and-drop, that can contain sets of actions
- [ ] Action node system
- [ ] Action schema definition
- [ ] Visual node editor
- [ ] Connection validation
- [ ] Parameter configuration UI
### 2. Plugin System
- [x] Plugin store infrastructure
- [x] Basic plugin loading mechanism
- [ ] Action Libraries
- [ ] Wizard Actions
- [ ] Robot movement control
- [ ] Speech synthesis
- [ ] Gesture control
- [ ] TurtleBot3 Integration
- [ ] ROS2 message types
- [ ] Movement actions
- [ ] Sensor feedback
- [ ] Experiment Flow
- [ ] Timing controls
- [ ] Wait conditions
- [ ] Participant input handling
- [ ] Data recording triggers
### 3. Execution Engine
- [ ] Step execution pipeline
- [ ] Action validation
- [ ] Real-time monitoring
- [ ] Data collection
- [ ] Action logs
- [ ] Timing data
- [ ] Participant responses
## Future Extensions
### 1. Enhanced Plugin Ecosystem
- Community plugin repository
- Plugin versioning and compatibility
- Custom action development tools
### 2. Advanced Experiment Features
- Conditional branching
- Dynamic parameter adjustment
- Multi-robot coordination
- Real-time visualization
### 3. Data Analysis Tools
- Session replay
- Data export
- Analysis templates
- Visualization tools
## Technical Requirements
### Action Schema
```typescript
interface ActionDefinition {
actionId: string;
type: ActionType;
title: string;
description: string;
parameters: {
type: "object";
properties: Record<string, {
type: string;
title: string;
description?: string;
default?: any;
minimum?: number;
maximum?: number;
enum?: string[];
unit?: string;
}>;
required: string[];
};
ros2?: {
messageType: string;
topic?: string;
service?: string;
action?: string;
payloadMapping: {
type: "direct" | "transform";
map?: Record<string, string>;
transformFn?: string;
};
qos?: {
reliability: "reliable" | "best_effort";
durability: "volatile" | "transient_local";
history: "keep_last" | "keep_all";
depth?: number;
};
};
}
```
### Plugin Structure
```
plugin-name/
├── plugin.json # Plugin metadata and action definitions
├── transforms.ts # Custom transform functions
├── validators.ts # Parameter validation
└── assets/ # Icons and documentation
```
## Implementation Priority
1. Core action system and visual editor
2. Basic wizard actions (movement, speech)
3. TurtleBot3 integration
4. Flow control actions
5. Data collection
6. Analysis tools

View File

@@ -1,403 +1,371 @@
# Robot Plugin Store Architecture # Plugin Store System
The Robot Plugin Store is a central system for managing robot definitions and their associated actions. It enables contributors to add new robotics platforms and actions, which can then be used in the experiment designer and ultimately bridge to ROS2 or other robotics middleware.
## Overview ## Overview
The plugin store consists of: The Plugin Store is a core feature of HRIStudio that manages robot plugins, their repositories, and their integration into the platform. It provides a robust system for loading, validating, and utilizing robot plugins within experiments.
- A JSON-based plugin format for defining robots and their actions
- A loader system for managing and serving these plugins
- An admin interface for managing plugins
- Integration with the experiment designer
- A bridge to ROS2 for executing actions
## Plugin Schema ## Architecture
### Robot Plugin Definition ### Core Components
```typescript
export class PluginStore {
private plugins: Map<string, RobotPlugin> = new Map();
private repositories: Map<string, RepositoryMetadata> = new Map();
private transformFunctions: Map<string, Function> = new Map();
private pluginToRepo: Map<string, string> = new Map();
private lastRefresh: Map<string, number> = new Map();
private readonly CACHE_TTL = 5 * 60 * 1000; // 5 minutes
}
```
### Plugin Types
```typescript ```typescript
interface RobotPlugin { interface RobotPlugin {
// Core metadata robotId: string;
robotId: string; // Unique identifier for this robot name: string;
name: string; // Display name description: string;
description?: string; // Optional description platform: string;
platform: string; // e.g., "ROS2", "custom" version: string;
version: string; // Semver version number
// Manufacturer information
manufacturer: { manufacturer: {
name: string; // Manufacturer name name: string;
website: string; // Manufacturer website website?: string;
support?: string; // Support URL support?: string;
}; };
// Documentation
documentation: { documentation: {
mainUrl: string; // Main documentation URL mainUrl: string;
apiReference?: string; // API/ROS2 interface documentation apiReference?: string;
wikiUrl?: string; // Wiki or community documentation wikiUrl?: string;
videoUrl?: string; // Video tutorial or overview videoUrl?: string;
}; };
// Visual assets
assets: { assets: {
thumbnailUrl: string; // Small preview image thumbnailUrl: string;
images: { // Various robot images images: {
main: string; // Main robot image main: string;
angles?: { // Optional different view angles angles?: {
front?: string; front?: string;
side?: string; side?: string;
top?: string; top?: string;
}; };
dimensions?: string; // Technical drawing with dimensions dimensions?: string;
}; };
model?: { // 3D model information model?: {
format: "URDF" | "glTF" | "other"; format: string;
url: string; url: string;
}; };
}; };
// Technical specifications
specs: { specs: {
dimensions: { dimensions: {
length: number; // in meters length: number;
width: number; width: number;
height: number; height: number;
weight: number; // in kg weight: number;
}; };
capabilities: string[]; // e.g., ["differential_drive", "lidar", "camera"] capabilities: string[];
maxSpeed: number; // in m/s maxSpeed: number;
batteryLife: number; // in hours batteryLife: number;
payload?: number; // max payload in kg
}; };
// Available actions for this robot
actions: ActionDefinition[]; actions: ActionDefinition[];
}
```
## Repository Management
### Loading Repositories
```typescript
async loadRepository(url: string): Promise<RepositoryMetadata> {
// Clean URL
const cleanUrl = url.trim().replace(/\/$/, "");
// Platform-specific configuration try {
ros2Config: { // Fetch repository metadata
namespace: string; const metadataUrl = this.getRepositoryFileUrl(cleanUrl, "repository.json");
nodePrefix: string; const response = await fetch(metadataUrl);
defaultTopics: {
cmd_vel: string; if (!response.ok) {
odom: string; throw new Error(`Failed to fetch repository metadata: ${response.statusText}`);
scan: string; }
[key: string]: string;
const metadata = await response.json();
// Validate and process metadata
return metadata;
} catch (error) {
throw new PluginLoadError("Failed to load repository", undefined, error);
}
}
```
### Repository Metadata
```typescript
interface RepositoryMetadata {
id: string;
name: string;
description: string;
url: string;
official: boolean;
author: {
name: string;
email?: string;
url?: string;
organization?: string;
};
maintainers?: Array<{
name: string;
url?: string;
}>;
compatibility: {
hristudio: {
min: string;
recommended?: string;
}; };
ros2?: {
distributions: string[];
recommended?: string;
};
};
stats: {
downloads: number;
stars: number;
plugins: number;
}; };
} }
```
## Plugin Loading & Validation
### Loading Process
1. **Repository Metadata:**
```typescript
private async loadRepositoryPlugins(repository: RepositoryMetadata) {
const metadataUrl = this.getRepositoryFileUrl(
repository.url,
"repository.json"
);
// Fetch and validate metadata
}
```
2. **Individual Plugins:**
```typescript
async loadPluginFromJson(jsonString: string): Promise<RobotPlugin> {
try {
const data = JSON.parse(jsonString);
return await this.validatePlugin(data);
} catch (error) {
throw new PluginLoadError(
"Failed to parse plugin JSON",
undefined,
error
);
}
}
```
### Validation
```typescript
private async validatePlugin(data: unknown): Promise<RobotPlugin> {
try {
return robotPluginSchema.parse(data);
} catch (error) {
if (error instanceof z.ZodError) {
throw new PluginLoadError(
`Invalid plugin format: ${error.errors.map(e => e.message).join(", ")}`,
undefined,
error
);
}
throw error;
}
}
```
## Action System
### Action Types
```typescript
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
```
### Action Definition
```typescript
interface ActionDefinition { interface ActionDefinition {
actionId: string; // Unique identifier for this action actionId: string;
type: ActionType; // Type of action (move, speak, etc.) type: ActionType;
title: string; // Display name title: string;
description: string; // Description of what the action does description: string;
icon?: string; // Icon identifier for the UI
// Parameter definition (using JSON Schema)
parameters: { parameters: {
type: "object"; type: "object";
properties: Record<string, { properties: Record<string, ParameterProperty>;
type: string;
title: string;
description?: string;
default?: any;
minimum?: number;
maximum?: number;
enum?: string[];
unit?: string; // e.g., "m/s", "rad", "m"
}>;
required: string[]; required: string[];
}; };
ros2?: {
// ROS2 Integration details messageType: string;
ros2: { topic?: string;
messageType: string; // ROS message type service?: string;
topic?: string; // ROS topic to publish to action?: string;
service?: string; // ROS service to call payloadMapping: {
action?: string; // ROS action to execute
payloadMapping: { // How parameters map to ROS messages
type: "direct" | "transform"; type: "direct" | "transform";
map?: Record<string, string>; transformFn?: string;
transformFn?: string; // Name of transform function if type="transform"
};
qos?: { // Quality of Service settings
reliability: "reliable" | "best_effort";
durability: "volatile" | "transient_local";
history: "keep_last" | "keep_all";
depth?: number;
}; };
qos?: QoSSettings;
}; };
} }
``` ```
### Example Plugin JSON ### Transform Functions
```json ```typescript
{ private transformToTwist(params: { linear: number; angular: number }) {
"robotId": "turtlebot3-burger", return {
"name": "TurtleBot3 Burger", linear: {
"description": "A compact, affordable, programmable, ROS2-based mobile robot for education and research", x: params.linear,
"platform": "ROS2", y: 0.0,
"version": "2.0.0", z: 0.0
},
angular: {
x: 0.0,
y: 0.0,
z: params.angular
}
};
}
```
## Caching & Performance
### Cache Implementation
```typescript
private shouldRefreshCache(repositoryId: string): boolean {
const lastRefresh = this.lastRefresh.get(repositoryId);
if (!lastRefresh) return true;
"manufacturer": { const now = Date.now();
"name": "ROBOTIS", return now - lastRefresh > this.CACHE_TTL;
"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"
}
}
}
]
} }
``` ```
## Implementation Plan ### Error Handling
### 1. Plugin Store Module
Create a TypeScript module to manage plugins:
```typescript ```typescript
// src/lib/plugin-store/types.ts export class PluginLoadError extends Error {
export interface RobotPlugin { ... } constructor(
export interface ActionDefinition { ... } message: string,
public robotId?: string,
// src/lib/plugin-store/store.ts public cause?: unknown
export class PluginStore { ) {
private plugins: Map<string, RobotPlugin>; super(message);
this.name = "PluginLoadError";
async loadPlugins(): Promise<void>; }
async getPlugin(robotId: string): Promise<RobotPlugin>;
async getAllPlugins(): Promise<RobotPlugin[]>;
} }
``` ```
### 2. Admin Interface ## Usage Examples
Build an admin panel for managing plugins: ### Loading a Repository
- Upload/edit JSON plugin definitions
- Validate plugin schema
- Version management
- Preview plugin details
### 3. API Routes
Create API endpoints for plugin management:
```typescript ```typescript
// GET /api/plugins const store = new PluginStore();
// GET /api/plugins/:robotId await store.loadRepository("https://github.com/org/robot-plugins");
// POST /api/plugins (with auth)
// PUT /api/plugins/:robotId (with auth)
// DELETE /api/plugins/:robotId (with auth)
``` ```
### 4. Experiment Designer Integration ### Getting Plugin Information
Update the experiment designer to:
- Allow robot selection
- Load appropriate actions
- Configure ROS2 bridge settings
### 5. ROS2 Bridge Integration
Create a bridge module for executing actions:
```typescript ```typescript
// src/lib/ros2-bridge/bridge.ts const plugin = store.getPlugin("turtlebot3-burger");
export class ROS2Bridge { if (plugin) {
async executeAction( console.log(`Loaded ${plugin.name} version ${plugin.version}`);
robotId: string, console.log(`Supported actions: ${plugin.actions.length}`);
actionId: string,
parameters: Record<string, any>
): Promise<void>;
} }
``` ```
## Development Phases ### Registering Transform Functions
1. **Phase 1: Core Plugin Store** ```typescript
- Implement plugin schema and validation store.registerTransformFunction("transformToTwist", (params) => {
- Build plugin loader // Custom transformation logic
- Create basic API endpoints return transformedData;
});
```
2. **Phase 2: Admin Interface** ## Best Practices
- Build plugin management UI
- Implement plugin upload/edit
- Add version control
3. **Phase 3: Experiment Designer Integration** 1. **Error Handling:**
- Add robot selection - Always catch and properly handle plugin loading errors
- Update action library based on selection - Provide meaningful error messages
- Enhance action configuration - Include error context when possible
4. **Phase 4: ROS2 Bridge** 2. **Validation:**
- Implement ROS2 connection - Validate all plugin metadata
- Add message transformation - Verify action parameters
- Test with real robots - Check compatibility requirements
5. **Phase 5: Documentation & Testing** 3. **Performance:**
- Write contributor guidelines - Use caching appropriately
- Add comprehensive tests - Implement lazy loading where possible
- Create example plugins - Monitor memory usage
## Contributing 4. **Security:**
- Validate URLs and file paths
To add a new robot to the plugin store: - Implement proper access controls
- Sanitize plugin inputs
1. Create a new JSON file following the plugin schema
2. Test the plugin using the validation tools
3. Submit a pull request with:
- Plugin JSON
- Any custom transformation functions
- Documentation updates
- Test cases
## Future Enhancements ## Future Enhancements
- Plugin marketplace for sharing robot definitions 1. **Plugin Versioning:**
- Visual plugin builder in admin interface - Semantic versioning support
- Real-time plugin updates - Version compatibility checking
- Plugin dependency management - Update management
- Custom action visualization components
2. **Advanced Caching:**
- Persistent cache storage
- Cache invalidation strategies
- Partial cache updates
3. **Plugin Marketplace:**
- User ratings and reviews
- Download statistics
- Community contributions
4. **Enhanced Validation:**
- Runtime validation
- Performance benchmarking
- Compatibility testing
## Dynamic Plugin Loading
The plugin store in HRI Studio supports modular loading of robot actions. Not every robot action is installed by default; instead, only the necessary plugins for the desired robots are installed. This approach offers several benefits:
- Flexibility: Deploy only the robots and actions you need.
- Performance: Avoid loading unnecessary modules, leading to faster startup times and reduced memory usage.
- Extensibility: Allow companies and users to host their own plugin repositories with custom robot actions.
### Implementation Details
1. Each plugin should export a manifest adhering to the `RobotPlugin` interface, containing a unique identifier, display name, and a list of actions.
2. The system loads only the configured plugins, which can be managed via environment variables, a database table, or an admin interface.
3. Dynamic imports are used in the Next.js server environment to load robot actions on demand. For example:
```ts
async function loadPlugin(pluginUrl: string): Promise<RobotPlugin> {
const pluginModule = await import(pluginUrl);
return pluginModule.default as RobotPlugin;
}
```
This design ensures that HRI Studio remains lean and agile, seamlessly integrating new robot actions without overhead.

1
docs/root.tex Symbolic link
View File

@@ -0,0 +1 @@
/Users/soconnor/Projects/csci378/hristudio-sp2025/root.tex

346
docs/ui-design.md Normal file
View File

@@ -0,0 +1,346 @@
# UI Design & User Experience
## Design System
### Color System
Our color system is defined in CSS variables with both light and dark mode variants:
```css
:root {
/* Core colors */
--background: 0 0% 100%;
--foreground: 222 47% 11%;
/* Primary colors */
--primary: 217 91% 60%;
--primary-foreground: 0 0% 100%;
/* Card colors */
--card: 0 0% 100%;
--card-foreground: 222 47% 11%;
/* Additional semantic colors */
--muted: 210 40% 96%;
--muted-foreground: 215 16% 47%;
--accent: 210 40% 96%;
--accent-foreground: 222 47% 11%;
/* ... additional color definitions ... */
}
.dark {
--background: 222 47% 11%;
--foreground: 210 40% 98%;
/* ... dark mode variants ... */
}
```
### Typography
We use the Geist font family for its clean, modern appearance:
```typescript
import { GeistSans } from 'geist/font/sans';
<body className={cn(
"min-h-screen bg-background font-sans antialiased",
GeistSans.className
)}>
```
### Spacing System
Consistent spacing using Tailwind's scale:
- `space-1`: 0.25rem (4px)
- `space-2`: 0.5rem (8px)
- `space-4`: 1rem (16px)
- `space-6`: 1.5rem (24px)
- `space-8`: 2rem (32px)
## Component Architecture
### Base Components
All base components are built on Radix UI primitives and styled with Tailwind:
```typescript
// Example Button Component
const Button = React.forwardRef<
HTMLButtonElement,
ButtonProps
>(({ className, variant, size, ...props }, ref) => {
return (
<button
className={cn(
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors",
"focus-visible:outline-none focus-visible:ring-2",
"disabled:opacity-50 disabled:pointer-events-none",
buttonVariants({ variant, size, className })
)}
ref={ref}
{...props}
/>
);
});
```
### Layout Components
#### Page Layout
```typescript
export function PageLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex h-full min-h-screen w-full">
<AppSidebar />
<div className="flex w-0 flex-1 flex-col">
<Header />
<main className="flex-1 overflow-auto p-4">
<PageTransition>
{children}
</PageTransition>
</main>
</div>
</div>
);
}
```
#### Sidebar Navigation
The sidebar uses a floating design with dynamic content based on context:
```typescript
export function AppSidebar({ ...props }: SidebarProps) {
return (
<Sidebar
collapsible="icon"
variant="floating"
className="border-none"
{...props}
>
<SidebarHeader>
<StudySwitcher />
</SidebarHeader>
<SidebarContent>
<NavMain items={navItems} />
</SidebarContent>
<SidebarFooter>
<NavUser />
</SidebarFooter>
<SidebarRail />
</Sidebar>
);
}
```
### Form Components
Forms use React Hook Form with Zod validation:
```typescript
const form = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
title: "",
description: "",
},
});
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Additional form fields */}
</form>
</Form>
);
```
## Responsive Design
### Breakpoints
We follow Tailwind's default breakpoints:
- `sm`: 640px
- `md`: 768px
- `lg`: 1024px
- `xl`: 1280px
- `2xl`: 1536px
### Mobile-First Approach
```typescript
export function StudyCard({ study }: StudyCardProps) {
return (
<Card className="
w-full
p-4
sm:p-6
md:hover:shadow-lg
transition-all
duration-200
">
{/* Card content */}
</Card>
);
}
```
## Animation System
### Transition Utilities
Common transitions are defined in Tailwind config:
```javascript
theme: {
extend: {
transitionTimingFunction: {
'bounce-ease': 'cubic-bezier(0.68, -0.55, 0.265, 1.55)',
},
},
}
```
### Page Transitions
Using Framer Motion for smooth page transitions:
```typescript
export function PageTransition({ children }: { children: React.ReactNode }) {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
transition={{ duration: 0.2 }}
>
{children}
</motion.div>
);
}
```
## Loading States
### Skeleton Components
```typescript
export function CardSkeleton() {
return (
<div className="p-6 space-y-4">
<Skeleton className="h-7 w-[40%]" />
<Skeleton className="h-4 w-[60%]" />
<div className="pt-4">
<Skeleton className="h-4 w-[25%]" />
</div>
</div>
);
}
```
### Loading Indicators
```typescript
export function LoadingSpinner({ size = "default" }: { size?: "sm" | "default" | "lg" }) {
return (
<div
className={cn(
"animate-spin rounded-full border-2",
"border-background border-t-foreground",
{
"h-4 w-4": size === "sm",
"h-6 w-6": size === "default",
"h-8 w-8": size === "lg",
}
)}
/>
);
}
```
## Accessibility
### ARIA Labels
All interactive components include proper ARIA labels:
```typescript
export function IconButton({ label, icon: Icon, ...props }: IconButtonProps) {
return (
<Button
{...props}
aria-label={label}
className="p-2 hover:bg-muted/50 rounded-full"
>
<Icon className="h-4 w-4" />
<span className="sr-only">{label}</span>
</Button>
);
}
```
### Keyboard Navigation
Support for keyboard navigation in all interactive components:
```typescript
export function NavigationMenu() {
return (
<nav
role="navigation"
className="focus-within:outline-none"
onKeyDown={(e) => {
if (e.key === "Escape") {
// Handle escape key
}
}}
>
{/* Navigation items */}
</nav>
);
}
```
## Best Practices
1. **Component Organization:**
- One component per file
- Clear prop interfaces
- Consistent file naming
2. **Style Organization:**
- Use Tailwind utility classes
- Extract common patterns to components
- Maintain consistent spacing
3. **Performance:**
- Lazy load non-critical components
- Use React.memo for expensive renders
- Implement proper loading states
4. **Accessibility:**
- Include ARIA labels
- Support keyboard navigation
- Maintain proper contrast ratios
5. **Testing:**
- Component unit tests
- Integration tests for flows
- Visual regression testing

View File

@@ -1,12 +1,19 @@
import { type Config } from "drizzle-kit"; import { type Config } from "drizzle-kit";
import { env } from "~/env"; import { env } from "~/env.mjs";
export default { export default {
schema: "./src/server/db/schema.ts", schema: "./src/server/db/schema.ts",
out: "./drizzle",
dialect: "postgresql", dialect: "postgresql",
dbCredentials: { dbCredentials: {
url: env.DATABASE_URL, url: env.DATABASE_URL,
}, },
strict: false,
verbose: true,
migrations: {
table: "__drizzle_migrations",
schema: "public"
},
tablesFilter: ["hs_*"], tablesFilter: ["hs_*"],
} satisfies Config; } satisfies Config;

View File

@@ -1,170 +0,0 @@
CREATE TYPE "public"."activity_type" AS ENUM('study_created', 'study_updated', 'study_deleted', 'ownership_transferred', 'member_added', 'member_removed', 'member_role_changed', 'participant_added', 'participant_updated', 'participant_removed', 'experiment_created', 'experiment_updated', 'experiment_deleted', 'trial_started', 'trial_completed', 'trial_cancelled', 'invitation_sent', 'invitation_accepted', 'invitation_declined', 'invitation_expired', 'invitation_revoked', 'consent_form_added', 'consent_form_signed', 'metadata_updated', 'data_exported');--> statement-breakpoint
CREATE TYPE "public"."experiment_status" AS ENUM('draft', 'active', 'archived');--> statement-breakpoint
CREATE TYPE "public"."invitation_status" AS ENUM('pending', 'accepted', 'declined', 'expired', 'revoked');--> statement-breakpoint
CREATE TYPE "public"."participant_status" AS ENUM('active', 'inactive', 'completed', 'withdrawn');--> statement-breakpoint
CREATE TYPE "public"."study_activity_type" AS ENUM('member_added', 'member_role_changed', 'study_updated', 'participant_added', 'participant_updated', 'invitation_sent', 'invitation_accepted', 'invitation_declined', 'invitation_expired', 'invitation_revoked');--> statement-breakpoint
CREATE TYPE "public"."study_role" AS ENUM('owner', 'admin', 'principal_investigator', 'wizard', 'researcher', 'observer');--> statement-breakpoint
CREATE TABLE "hs_account" (
"userId" varchar(255) NOT NULL,
"type" varchar(255) NOT NULL,
"provider" varchar(255) NOT NULL,
"providerAccountId" varchar(255) NOT NULL,
"refresh_token" text,
"access_token" text,
"expires_at" integer,
"token_type" varchar(255),
"scope" varchar(255),
"id_token" text,
"session_state" varchar(255)
);
--> statement-breakpoint
CREATE TABLE "hs_session" (
"sessionToken" varchar(255) PRIMARY KEY NOT NULL,
"userId" varchar(255) NOT NULL,
"expires" timestamp NOT NULL
);
--> statement-breakpoint
CREATE TABLE "hs_user" (
"id" varchar(255) PRIMARY KEY NOT NULL,
"email" varchar(255) NOT NULL,
"first_name" varchar(255),
"last_name" varchar(255),
"password" varchar(255),
"emailVerified" timestamp,
"image" text
);
--> statement-breakpoint
CREATE TABLE "hs_verificationToken" (
"identifier" varchar(255) NOT NULL,
"token" varchar(255) NOT NULL,
"expires" timestamp NOT NULL
);
--> statement-breakpoint
CREATE TABLE "hs_experiment" (
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "hs_experiment_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"study_id" integer NOT NULL,
"title" varchar(256) NOT NULL,
"description" text,
"version" integer DEFAULT 1 NOT NULL,
"status" "experiment_status" DEFAULT 'draft' NOT NULL,
"steps" jsonb DEFAULT '[]'::jsonb,
"created_by" varchar(255) NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "hs_participant" (
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "hs_participant_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"study_id" integer NOT NULL,
"identifier" varchar(256),
"email" varchar(256),
"first_name" varchar(256),
"last_name" varchar(256),
"notes" text,
"status" "participant_status" DEFAULT 'active' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "hs_study" (
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "hs_study_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"title" varchar(256) NOT NULL,
"description" text,
"created_by" varchar(255) NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "hs_study_activity" (
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "hs_study_activity_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"study_id" integer NOT NULL,
"user_id" varchar(255) NOT NULL,
"type" "activity_type" NOT NULL,
"description" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "hs_study_invitation" (
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "hs_study_invitation_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"study_id" integer NOT NULL,
"email" varchar(255) NOT NULL,
"role" "study_role" NOT NULL,
"token" varchar(255) NOT NULL,
"status" "invitation_status" DEFAULT 'pending' NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone,
"created_by" varchar(255) NOT NULL,
CONSTRAINT "hs_study_invitation_token_unique" UNIQUE("token")
);
--> statement-breakpoint
CREATE TABLE "hs_study_member" (
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "hs_study_member_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"study_id" integer NOT NULL,
"user_id" varchar(255) NOT NULL,
"role" "study_role" NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "hs_study_metadata" (
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "hs_study_metadata_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"study_id" integer NOT NULL,
"key" varchar(256) NOT NULL,
"value" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "hs_permissions" (
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "hs_permissions_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"code" varchar(50) NOT NULL,
"name" varchar(100) NOT NULL,
"description" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "hs_permissions_code_unique" UNIQUE("code")
);
--> statement-breakpoint
CREATE TABLE "hs_role_permissions" (
"role_id" integer NOT NULL,
"permission_id" integer NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "hs_role_permissions_role_id_permission_id_pk" PRIMARY KEY("role_id","permission_id")
);
--> statement-breakpoint
CREATE TABLE "hs_roles" (
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "hs_roles_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"code" varchar(50) NOT NULL,
"name" varchar(100) NOT NULL,
"description" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "hs_roles_code_unique" UNIQUE("code")
);
--> statement-breakpoint
CREATE TABLE "hs_user_roles" (
"user_id" varchar(255) NOT NULL,
"role_id" integer NOT NULL,
"study_id" integer,
"created_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "hs_user_roles_user_id_role_id_study_id_pk" PRIMARY KEY("user_id","role_id","study_id")
);
--> statement-breakpoint
ALTER TABLE "hs_account" ADD CONSTRAINT "hs_account_userId_hs_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."hs_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "hs_session" ADD CONSTRAINT "hs_session_userId_hs_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."hs_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "hs_experiment" ADD CONSTRAINT "hs_experiment_study_id_hs_study_id_fk" FOREIGN KEY ("study_id") REFERENCES "public"."hs_study"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "hs_experiment" ADD CONSTRAINT "hs_experiment_created_by_hs_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."hs_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "hs_participant" ADD CONSTRAINT "hs_participant_study_id_hs_study_id_fk" FOREIGN KEY ("study_id") REFERENCES "public"."hs_study"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "hs_study" ADD CONSTRAINT "hs_study_created_by_hs_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."hs_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "hs_study_activity" ADD CONSTRAINT "hs_study_activity_study_id_hs_study_id_fk" FOREIGN KEY ("study_id") REFERENCES "public"."hs_study"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "hs_study_activity" ADD CONSTRAINT "hs_study_activity_user_id_hs_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."hs_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "hs_study_invitation" ADD CONSTRAINT "hs_study_invitation_study_id_hs_study_id_fk" FOREIGN KEY ("study_id") REFERENCES "public"."hs_study"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "hs_study_invitation" ADD CONSTRAINT "hs_study_invitation_created_by_hs_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."hs_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "hs_study_member" ADD CONSTRAINT "hs_study_member_study_id_hs_study_id_fk" FOREIGN KEY ("study_id") REFERENCES "public"."hs_study"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "hs_study_member" ADD CONSTRAINT "hs_study_member_user_id_hs_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."hs_user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "hs_study_metadata" ADD CONSTRAINT "hs_study_metadata_study_id_hs_study_id_fk" FOREIGN KEY ("study_id") REFERENCES "public"."hs_study"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "hs_role_permissions" ADD CONSTRAINT "hs_role_permissions_role_id_hs_roles_id_fk" FOREIGN KEY ("role_id") REFERENCES "public"."hs_roles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "hs_role_permissions" ADD CONSTRAINT "hs_role_permissions_permission_id_hs_permissions_id_fk" FOREIGN KEY ("permission_id") REFERENCES "public"."hs_permissions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "hs_user_roles" ADD CONSTRAINT "hs_user_roles_user_id_hs_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."hs_user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "hs_user_roles" ADD CONSTRAINT "hs_user_roles_role_id_hs_roles_id_fk" FOREIGN KEY ("role_id") REFERENCES "public"."hs_roles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "hs_user_roles" ADD CONSTRAINT "hs_user_roles_study_id_hs_study_id_fk" FOREIGN KEY ("study_id") REFERENCES "public"."hs_study"("id") ON DELETE cascade ON UPDATE no action;

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +0,0 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1739338336977,
"tag": "0000_adorable_grandmaster",
"breakpoints": true
}
]
}

View File

@@ -1,27 +0,0 @@
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful
* for Docker builds.
*/
import "./src/env.js";
/** @type {import("next").NextConfig} */
const config = {
images: {
remotePatterns: [
{
protocol: "https",
hostname: "**",
},
{
protocol: "http",
hostname: "localhost",
port: "3000",
pathname: "/api/images/**",
},
],
dangerouslyAllowSVG: true,
contentDispositionType: 'attachment',
},
};
export default config;

61
next.config.mjs Normal file
View File

@@ -0,0 +1,61 @@
import "./src/env.mjs";
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
/** @type {import('next').NextConfig} */
const config = {
// Image configuration
images: {
remotePatterns: [
{
protocol: "https",
hostname: "**",
},
{
protocol: "http",
hostname: "localhost",
port: "3000",
pathname: "/api/images/**",
},
],
dangerouslyAllowSVG: true,
contentDispositionType: 'attachment',
},
// Package configuration
transpilePackages: ["postgres"],
// Enable experimental features
experimental: {
// Enable modern webpack features
webpackBuildWorker: true,
// Turbopack configuration (when using --turbo)
turbo: {
resolveAlias: {
// Handle server-only modules in Turbo
'server-only': 'server-only',
},
},
},
// Webpack fallbacks (only used when not using Turbo)
webpack: (config, { isServer }) => {
if (!isServer) {
config.resolve.fallback = {
...config.resolve.fallback,
fs: false,
net: false,
tls: false,
crypto: false,
os: false,
path: false,
stream: false,
perf_hooks: false,
child_process: false,
};
}
return config;
},
};
export default config;

View File

@@ -9,6 +9,8 @@
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push", "db:push": "drizzle-kit push",
"db:seed": "bun src/server/db/seed.ts",
"db:reset": "docker compose down -v && docker compose up -d && bun db:push && bun db:seed",
"db:studio": "drizzle-kit studio", "db:studio": "drizzle-kit studio",
"dev": "next dev --turbo", "dev": "next dev --turbo",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache", "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
@@ -31,6 +33,7 @@
"@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@hookform/resolvers": "^3.10.0", "@hookform/resolvers": "^3.10.0",
"@paralleldrive/cuid2": "^2.2.2",
"@radix-ui/react-alert-dialog": "^1.1.6", "@radix-ui/react-alert-dialog": "^1.1.6",
"@radix-ui/react-avatar": "^1.1.2", "@radix-ui/react-avatar": "^1.1.2",
"@radix-ui/react-collapsible": "^1.1.2", "@radix-ui/react-collapsible": "^1.1.2",

View File

@@ -1,223 +0,0 @@
% Standard Paper
\documentclass[letterpaper, 10 pt, conference]{ieeeconf}
% A4 Paper
%\documentclass[a4paper, 10pt, conference]{ieeeconf}
% Only needed for \thanks command
\IEEEoverridecommandlockouts
% Needed to meet printer requirements.
\overrideIEEEmargins
%In case you encounter the following error:
%Error 1010 The PDF file may be corrupt (unable to open PDF file) OR
%Error 1000 An error occurred while parsing a contents stream. Unable to analyze the PDF file.
%This is a known problem with pdfLaTeX conversion filter. The file cannot be opened with acrobat reader
%Please use one of the alternatives below to circumvent this error by uncommenting one or the other
%\pdfobjcompresslevel=0
%\pdfminorversion=4
% See the \addtolength command later in the file to balance the column lengths
% on the last page of the document
% The following packages can be found on http:\\www.ctan.org
\usepackage{graphicx} % for pdf, bitmapped graphics files
%\usepackage{epsfig} % for postscript graphics files
%\usepackage{mathptmx} % assumes new font selection scheme installed
%\usepackage{times} % assumes new font selection scheme installed
%\usepackage{amsmath} % assumes amsmath package installed
%\usepackage{amssymb} % assumes amsmath package installed
\usepackage{url}
\usepackage{float}
\hyphenation{analysis}
\title{\LARGE \bf HRIStudio: A Framework for Wizard-of-Oz Experiments in Human-Robot Interaction Studies}
\author{Sean O'Connor and L. Felipe Perrone$^{*}$
\thanks{$^{*}$Both authors are with the Department of Computer Science at
Bucknell University in Lewisburg, PA, USA. They can be reached at {\tt\small sso005@bucknell.edu} and {\tt\small perrone@bucknell.edu}}%
}
\begin{document}
\maketitle
\thispagestyle{empty}
\pagestyle{empty}
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
\begin{abstract}
Human-robot interaction (HRI) research plays a pivotal role in shaping how robots communicate and collaborate with humans. However, conducting HRI studies, particularly those employing the Wizard-of-Oz (WoZ) technique, can be challenging. WoZ user studies can have complexities at the technical and methodological levels that may render the results irreproducible. We propose to address these challenges with HRIStudio, a novel web-based platform designed to streamline the design, execution, and analysis of WoZ experiments. HRIStudio offers an intuitive interface for experiment creation, real-time control and monitoring during experimental runs, and comprehensive data logging and playback tools for analysis and reproducibility. By lowering technical barriers, promoting collaboration, and offering methodological guidelines, HRIStudio aims to make human-centered robotics research easier, and at the same time, empower researchers to develop scientifically rigorous user studies.
\end{abstract}
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
%% TODO: Update mockup pictures with photo of subject and robot
\section{Introduction}
Human-robot interaction (HRI) is an essential field of study for understanding how robots should communicate, collaborate, and coexist with people. The development of autonomous behaviors in social robot applications, however, offers a number of challenges. The Wizard-of-Oz (WoZ) technique has emerged as a valuable experimental paradigm to address these difficulties, as it allows experimenters to simulate a robot's autonomous behaviors. With WoZ, a human operator (the \emph{``wizard''}) can operate the robot remotely, essentially simulating its autonomous behavior during user studies. This enables the rapid prototyping and continuous refinement of human-robot interactions postponing to later the full development of complex robot behaviors.
While WoZ is a powerful paradigm, it does not eliminate all experimental challenges. Researchers may face barriers related to the use of specialized tools and methodologies involved in WoZ user studies and also find difficulties in creating fully reproducible experiments. Existing solutions often rely on low-level robot operating systems, limited proprietary platforms, or require extensive custom coding, which can restrict their use to domain experts with extensive technical backgrounds.
Through a comprehensive review of current literature, we have identified a pressing need for a platform that simplifies the process of designing, executing, analyzing, and recording WoZ-based user studies. To address this gap, we are developing \emph{HRIStudio}, a novel web-based platform that enables the intuitive configuration and operation of WoZ studies for HRI research. Our contribution leverages the \emph{Robot Operating System} (ROS) to handle the complexities of interfacing with different robotics platforms. HRIStudio presents users with a high-level, user-friendly interface for experimental design, live control and monitoring during execution runs (which we call \emph{live experiment sessions}), and comprehensive post-study analysis. The system offers drag-and-drop visual programming for describing experiments without extensive coding, real-time control and observation capabilities during live experiment sessions, as well as comprehensive data logging and playback tools for analysis and enhanced reproducibility. We expect that with these features, HRIStudio will make the application of the WoZ paradigm more systematic thereby increasing the scientific rigor of this type of HRI experiment. The following sections present a brief review of the relevant literature, outline the design of HRIStudio and its experimental workflow, and offer implementation details and future directions for this work.
\section{State-of-the-Art}
The importance of the WoZ paradigm for user studies in social robotics is illustrated by the several frameworks that have been developed to support it. We describe some of the most notable as follows.
\emph{Polonius}~\cite{Lu2011}, which is based on the modular ROS platform, offers a graphical user interface for wizards to define finite state machine scripts that drive the behavior of robots during experiments. \emph{NottReal}~\cite{Porcheron2020} was designed for WoZ studies of voice user interfaces. It provides scripting capabilities and visual feedback to simulate autonomous behavior for participants. \emph{WoZ4U}~\cite{Rietz2021} presents a user-friendly GUI that makes HRI studies more accessible to non-programmers. The tight hardware focus on Aldebaran's Pepper, however, constrains the tool's applicability. \emph{OpenWoZ}~\cite{Hoffman2016} proposes a runtime-configurable framework with a multi-client architecture, enabling evaluators to modify robot behaviors during experiments. The platform allows one with programming expertise to create standard, customized robot behaviors for user studies.
In addition to the aforementioned frameworks, we considered Riek's systematic analysis of published WoZ experiments, which stresses the need for increased methodological rigor, transparency and reproducibility of WoZ studies.~\cite{Riek2012} Altogether, the literature inspired us to design HRIStudio as a platform that offers comprehensive support for WoZ studies in social robotics. Our design goals include offering a platform that is as ``robot-agnostic'' as possible and which offers its users guidance to specify and execute WoZ studies that are methodologically sound and maximally reproducible. As such, HRIStudio aims to offer an easy user interface that allows for experiments to be scripted and executed easily and which allows for the aggregation of experimental data and other assets generated in a study.
\section{Overarching Design Goals}
We have identified several guiding design principles to maximize HRIStudio's effectiveness, usefulness, and usability. Foremost, we want HRIStudio to be accessible to users with and without deep robot programming expertise so that we may lower the barrier to entry for those conducting HRI studies. The platform should provide an intuitive graphical user interface that obviates the need for describing robot behaviors in a programming language. The user should be able to focus on describing sequences of robot behaviors without getting bogged down by all the details of specific robots. To this end, we determined that the framework should offer users the means by which to describe experiments and robot behaviors, while capturing and storing all data generated including text-based logs, audio, video, IRB materials, and user consent forms.
Furthermore, we determined that the framework should also support multiple user accounts and data sharing to enable collaborations between the members of a team and the dissemination of experiments across different teams. By incorporating these design goals, HRIStudio prioritizes experiment design, collaborative workflows, methodological rigor, and scientific reproducibility.
\section{Design of the Experimental Workflow}
\subsection{Organization of a user study}
With HRIStudio, we define a hierarchical organization of elements to express WoZ user studies for HRI research. An experimenter starts by creating and configuring a \emph{study} element, which will comprise multiple instantiations of one same experimental script encapsulated in an element called \emph{experiment}, which captures the experiences of a specific human subject with the robot designated in the script.
Each \emph{experiment} comprises a sequence of one or more \emph{step} elements. Each \emph{step} models a phase of the experiment and aggregates a sequence of \emph{action} elements, which are fine-grained, specific tasks to be executed either by the wizard or by the robot. An \emph{action} targeted at the wizard provides guidance and maximizes the chances of consistent behavior. An \emph{action} targeted at the robot causes it to execute movements or verbal interactions, or causes it to wait for a human subject's input or response.
The system executes the \emph{actions} in an experimental script asynchronously and in an event-driven manner, guiding the wizard's behavior and allowing them to simulate the robot's autonomous intelligence by responding to the human subject in real time based on the human's actions and reactions. This event-driven approach allows for flexible and spontaneous reactions by the wizard, enabling a more natural and intelligent interaction with the human subject. In contrast, a time-driven script with rigid, imposed timing would show a lack of intelligence and autonomy on the part of the robot.
In order to enforce consistency across multiple runs of the \emph{experiment}, HRIStudio uses specifications encoded in the \emph{study} element to inform the wizard on how to constrain their behavior to a set of possible types of interventions. Although every experiment is potentially unique due to the unlikely perfect match of reactions between human subjects, this mechanism allows for annotating the data feed and capturing the nuances of each unique interaction.
Figure~\ref{fig:userstudy} illustrates this hierarchy of elements with a practical example. We argue that this hierarchical structure for the experimental procedure in a user study benefits methodological rigor and reproducibility while affording the researcher the ability to design complex HRI studies while guiding the wizard to follow a consistent set of instructions.
\begin{figure}[ht]
\vskip -0.4cm
\begin{center}
\includegraphics[width=0.4\paperwidth]{assets/diagrams/userstudy}
\vskip -0.5cm
\caption{A sample user study.}
\label{fig:userstudy}
\end{center}
\vskip -0.7cm
\end{figure}
\subsection{System interfaces}
HRIStudio features a user-friendly graphical interface for designing WoZ experiments. This interface provides a visual programming system that allows one to build their experiments using a drag-and-drop approach. The core of the experiment creation process offers a library of actions including common tasks and behaviors executed in the experiment such as robot movements, speech synthesis, and instructions for the wizard. One can drag and drop action components onto a canvas and arrange them into sequences that define study, experiment, steps, and action components. The interface provides configuration options that allow researchers to customize parameters in each element. This configuration system offers contextual help and documentation to guide researchers through the process while providing examples or best practices for designing studies.
\subsection{Live experiment operation}
During live experiment sessions, HRIStudio offers multiple synchronized views for experiment execution and observation, and data collection. The wizard's \emph{Execute} view gives the wizard control over the robot's actions and behaviors. Displaying the current step of the experiment along with associated actions, this interface facilitates intuitive navigation through the structural elements of the experiments and allows for the creation of annotations on a timeline. The wizard can advance through actions sequentially or manually trigger specific actions based on contextual cues or responses from the human subject. During the execution of an experiment, the interface gives the wizard manual controls to insert unscripted robot movements, speech synthesis, and other functions dynamically. These events are recorded in persistent media within the sequence of actions in the experimental script.
The observer's \emph{Execute} view supports live monitoring, note-taking, and potential interventions by additional researchers involved in the experiment. This feature ensures the option of continuous oversight without disrupting the experience of human subjects or the wizard's control. Collaboration on an experiment is made possible by allowing multiple observers to concurrently access the \emph{Execute} view.
\subsection{Data logging, playback, and annotation}
Throughout the live experiment session, the platform automatically logs various data streams, including timestamped records of all executed actions and experimental events, exposed robot sensor data, and audio and video recordings of the participant's interactions with the robot. Logged data is stored in JavaScript Object Notation (JSON) encrypted files in secure storage, enabling efficient post-experiment data analysis to ensure the privacy of human subjects.
After a live experiment session, researchers may use a \emph{Playback} view to inspect the recorded data streams and develop a holistic understanding of the experiment's progression. This interface supports features such as playback of recorded data such as audio, video, and sensor data streams, scrubbing of recorded data with the ability to mark and note significant events or observations, and export options for selected data segments or annotations.
\section{Implementation}
The realization of the proposed platform is a work in progress. So far, we have made significant advances on the design of the overall framework and of its several components while exploring underlying technologies, wireframing user views and interfaces, and establishing a development roadmap.
\subsection{Core technologies used}
We are leveraging the \emph{Next.js React} \cite{next} framework for building our framework as a web application. Next.js provides server-side rendering, improved performance, and enhanced security. By making HRIStudio a web application, we achieve independence from hardware and operating system. We are building into the framework support for API routes and integration with \emph{TypeScript Remote Procedure Call} (tRPC), which simplifies the development of APIs for interfacing with the ROS interface.
For the robot control layer, we utilize ROS as the communication and control interface. ROS offers a modular and extensible architecture, enabling seamless integration with a multitude of consumer and research robotics platforms. Thanks to the widespread adoption of ROS in the robotics community, HRIStudio will be able to support a wide range robots out-of-the-box by leveraging the efforts of the ROS community for new robot platforms.
\vspace{-0.3cm}
\subsection{High-level architecture}
We have designed our system as a full-stack web application. The frontend handles user interface components such as the experiment \emph{Design} view, the experiment \emph{Execute} view, and the \emph{Playback} view. The backend API logic manages experiment data, user authentication, and communication with a ROS interface component. In its turn, the ROS interface is implemented as a separate C++ node and translates high-level actions from the web application into low-level robot commands, sensor data, and protocols, abstracting the complexities of different robotics platforms. This modular architecture leverages the benefits of Next.js' server-side rendering, improved performance, and security, while enabling integration with various robotic platforms through ROS. Fig.~\ref{fig:systemarch} shows the structure of the application.
\begin{figure}
\begin{center}
\includegraphics[width=0.35\paperwidth]{assets/diagrams/systemarch}
\vskip -0.5cm
\caption{The high-level system architecture of HRIStudio.}
\label{fig:systemarch}
\vskip -0.8cm
\end{center}
\end{figure}
\subsection{User interface mockups}
A significant portion of our efforts have been dedicated to designing intuitive and user-friendly interface mockups for the platform's key components. We have created wireframes and prototypes for the study \emph{Dashboard}, \emph{Design} view, \emph{Execute} view, and the \emph{Playback} view.
The study \emph{Dashboard} mockups (see Figure~\ref{fig:dashboard}) display an intuitive overview of a project's status, including platform information, collaborators, completed and upcoming trials, subjects, and a list of pending issues. This will allow a researcher to quickly see what needs to be done, or easily navigate to a previous trial's data for analysis.
\begin{figure}
% \vskip -.2cm
\centering
\includegraphics[width=0.35\paperwidth]{assets/mockups/dashboard}
\vskip -0.3cm
\caption{A sample project's \emph{Dashboard} view within HRIStudio.}
\label{fig:dashboard}
\vskip -.2cm
\end{figure}
The \emph{Design} view mockups depicted in Figure~\ref{fig:design} feature a visual programming canvas where researchers can construct their experiments by dragging and dropping pre-defined action components. These components represent common tasks and behaviors, such as robot movements, speech synthesis, and instructions for the wizard. The mockups also include configuration panels for customizing the parameters of each action component.
\begin{figure}
\vskip -0.1cm
\centering
\includegraphics[width=0.35\paperwidth]{assets/mockups/design}
\vskip -0.3cm
\caption{A sample project's \emph{Design} view in HRIStudio.}
\label{fig:design}
\vskip -.3cm
\end{figure}
For the \emph{Execute} view, we have designed mockups that provide synchronized views for the wizard and observers. The wizard's view (see Figure~\ref{fig:execute}) presents an intuitive step-based interface that walks the wizard through the experiment as specified by the designer, triggering actions, and controlling the robot, while the observer view facilitates real-time monitoring and note taking.
\begin{figure}
\vskip -0.3cm
\centering
\includegraphics[width=0.35\paperwidth]{assets/mockups/execute}
\vskip -0.3cm
\caption{The wizard's \emph{Execute} view during a live experiment.}
\label{fig:execute}
% \vskip -0.9cm
\end{figure}
Fig.~\ref{fig:playback} shows \emph{Playback} mockups for synchronized playback of recorded data streams, including audio, video, and applicable sensor data. The features include visual and textual annotations, scrubbing capabilities, and data export options to support comprehensive post-experiment analysis and reproducibility.
\begin{figure}
\centering
\includegraphics[width=0.35\paperwidth]{assets/mockups/playback}
\vskip -0.3cm
\caption{The \emph{Playback} view of an experiment within a study.}
\label{fig:playback}
\vskip -0.4cm
\end{figure}
\subsection{Development roadmap}
While the UI mockups have laid a solid foundation, we anticipate challenges in transforming these designs into a fully functional platform, such as integrating the Next.js web application with the ROS interface and handling bi-directional communication between the two. We plan to leverage tRPC for real-time data exchange and robot control.
Another key challenge is developing the \emph{Design} view's visual programming environment, and encoding procedures into a shareable format. We will explore existing visual programming libraries and develop custom components for intuitive experiment construction.
Implementing robust data logging and synchronized playback of audio, video, and sensor data while ensuring efficient storage and retrieval is also crucial.
To address these challenges, our development roadmap includes:
\begin{itemize}
\item Establishing a stable Next.js codebase with tRPC integration,
\item Implementing a ROS interface node for robot communication,
\item Developing the visual experiment designer,
\item Integrating data logging for capturing experimental data streams,
\item Building playback and annotation tools with export capabilities,
\item Creating tutorials and documentation for researcher adoption.
\end{itemize}
This roadmap identifies some of the challenges ahead. We expect that this plan will fully realize HRIStudio into a functional and accessible tool for conducting WoZ experiments. We hope for this tool to become a significant aid in HRI research, empowering researchers and fostering collaboration within the community.
\bibliography{refs}
\bibliographystyle{plain}
\end{document}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,14 @@
import { getServerAuthSession } from "~/server/auth"; import { getServerAuthSession } from "~/server/auth";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import Link from "next/link"; import Link from "next/link";
import { BotIcon, ArrowRight, Sparkles, Brain, Microscope } from "lucide-react";
import { Logo } from "~/components/logo"; import { Logo } from "~/components/logo";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card"; import { HeroSection } from "~/components/home/hero-section";
import { FeaturesSection } from "~/components/home/features-section";
import { CTASection } from "~/components/home/cta-section";
export default async function Home() { export default async function Home() {
const session = await getServerAuthSession(); const session = await getServerAuthSession();
const isLoggedIn = !!session;
return ( return (
<div className="min-h-screen bg-background relative"> <div className="min-h-screen bg-background relative">
@@ -41,131 +43,28 @@ export default async function Home() {
</div> </div>
</nav> </nav>
{/* Hero Section */} {/* Content Sections */}
<section className="container mx-auto px-4 py-24"> <div className="relative">
<div className="grid lg:grid-cols-2 gap-12 items-center"> <HeroSection isLoggedIn={isLoggedIn} />
<div className="space-y-6">
<div className="inline-flex rounded-lg bg-gradient-to-br from-primary/20 via-secondary/20 to-background p-1 mb-8"> {/* Dotted pattern for content sections */}
<span className="rounded-md bg-background/95 px-3 py-1 text-sm backdrop-blur"> <div className="relative">
Now with Visual Experiment Designer <div
</span> className="absolute inset-0 pointer-events-none opacity-30"
</div> style={{
<h1 className="text-4xl font-bold tracking-tight lg:text-6xl bg-gradient-to-br from-foreground via-foreground/90 to-foreground/70 bg-clip-text text-transparent"> backgroundImage: `
Streamline Your HRI Research radial-gradient(circle at 1px 1px, hsl(var(--muted-foreground)) 1px, transparent 0),
</h1> linear-gradient(to bottom, transparent, hsl(var(--background)))
<p className="text-xl text-muted-foreground"> `,
A comprehensive platform for designing, executing, and analyzing Wizard-of-Oz experiments in human-robot interaction studies. backgroundSize: '32px 32px, 100% 100%',
</p> maskImage: 'linear-gradient(to bottom, transparent, black 10%, black 90%, transparent)',
<div className="flex flex-col sm:flex-row gap-4 pt-4"> }}
{!session ? ( />
<Button size="lg" className="w-full sm:w-auto group bg-gradient-to-r from-primary to-primary hover:from-primary/90 hover:to-primary" asChild>
<Link href="/auth/signup"> <FeaturesSection />
Get Started <CTASection isLoggedIn={isLoggedIn} />
<ArrowRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" />
</Link>
</Button>
) : (
<Button size="lg" className="w-full sm:w-auto group bg-gradient-to-r from-primary to-primary hover:from-primary/90 hover:to-primary" asChild>
<Link href="/dashboard">
Go to Dashboard
<ArrowRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" />
</Link>
</Button>
)}
<Button size="lg" variant="outline" className="w-full sm:w-auto" asChild>
<Link href="https://github.com/soconnor0919/hristudio" target="_blank">
View on GitHub
</Link>
</Button>
</div>
</div>
<div className="relative aspect-square lg:aspect-video">
<div className="absolute inset-0 bg-gradient-to-br from-primary/30 via-secondary/20 to-background rounded-lg border shadow-xl" />
<div className="absolute inset-0 flex items-center justify-center">
<BotIcon className="h-32 w-32 text-primary/40" />
</div>
</div>
</div> </div>
</section> </div>
{/* Features Section */}
<section className="container mx-auto px-4 py-24 space-y-12">
<div className="text-center space-y-4">
<h2 className="text-3xl font-bold tracking-tight bg-gradient-to-br from-foreground to-foreground/70 bg-clip-text text-transparent inline-block">
Powerful Features for HRI Research
</h2>
<p className="text-muted-foreground max-w-[600px] mx-auto">
Everything you need to design, execute, and analyze your human-robot interaction experiments.
</p>
</div>
<div className="grid md:grid-cols-3 gap-8">
<Card className="group relative overflow-hidden border bg-background/60 backdrop-blur supports-[backdrop-filter]:bg-background/60 hover:shadow-lg transition-all">
<div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-primary/10 via-primary/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
<CardHeader>
<div className="size-12 rounded-lg bg-gradient-to-br from-primary/20 to-primary/10 flex items-center justify-center mb-4">
<Sparkles className="size-6 text-primary" />
</div>
<CardTitle>Visual Experiment Design</CardTitle>
<CardDescription>
Create and configure experiments using an intuitive drag-and-drop interface without extensive coding.
</CardDescription>
</CardHeader>
</Card>
<Card className="group relative overflow-hidden border bg-background/60 backdrop-blur supports-[backdrop-filter]:bg-background/60 hover:shadow-lg transition-all">
<div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-primary/10 via-primary/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
<CardHeader>
<div className="size-12 rounded-lg bg-gradient-to-br from-primary/20 to-primary/10 flex items-center justify-center mb-4">
<Brain className="size-6 text-primary" />
</div>
<CardTitle>Real-time Control</CardTitle>
<CardDescription>
Execute experiments with synchronized views for wizards and observers, enabling seamless collaboration.
</CardDescription>
</CardHeader>
</Card>
<Card className="group relative overflow-hidden border bg-background/60 backdrop-blur supports-[backdrop-filter]:bg-background/60 hover:shadow-lg transition-all">
<div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-primary/10 via-primary/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
<CardHeader>
<div className="size-12 rounded-lg bg-gradient-to-br from-primary/20 to-primary/10 flex items-center justify-center mb-4">
<Microscope className="size-6 text-primary" />
</div>
<CardTitle>Comprehensive Analysis</CardTitle>
<CardDescription>
Record, playback, and analyze experimental data with built-in annotation and export tools.
</CardDescription>
</CardHeader>
</Card>
</div>
</section>
{/* CTA Section */}
<section className="container mx-auto px-4 py-24">
<Card className="relative overflow-hidden">
<div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-primary via-primary to-secondary" />
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_50%_120%,rgba(0,0,0,0)_30%,rgba(0,0,0,0.15)_100%)]" />
<CardContent className="relative p-12 flex flex-col items-center text-center space-y-6 text-primary-foreground">
<BotIcon className="size-12 mb-4" />
<h2 className="text-3xl font-bold tracking-tight">
Ready to Transform Your Research?
</h2>
<p className="text-primary-foreground/90 max-w-[600px]">
Join the growing community of researchers using HRIStudio to advance human-robot interaction studies.
</p>
{!session ? (
<Button size="lg" variant="secondary" asChild className="mt-4 bg-background/20 hover:bg-background/30">
<Link href="/auth/signup">Start Your Journey</Link>
</Button>
) : (
<Button size="lg" variant="secondary" asChild className="mt-4 bg-background/20 hover:bg-background/30">
<Link href="/dashboard">Go to Dashboard</Link>
</Button>
)}
</CardContent>
</Card>
</section>
</div> </div>
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -73,6 +73,12 @@ export const authOptions: NextAuthOptions = {
if (user) { if (user) {
token.id = user.id; token.id = user.id;
token.email = user.email; 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; return token;
}, },
@@ -80,6 +86,9 @@ export const authOptions: NextAuthOptions = {
if (token) { if (token) {
session.user.id = token.id as string; session.user.id = token.id as string;
session.user.email = token.email 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; return session;
}, },
@@ -97,4 +106,4 @@ export const getServerAuthSession = () => getServerSession(authOptions);
export const handlers = { GET: getServerSession, POST: getServerSession }; export const handlers = { GET: getServerSession, POST: getServerSession };
// Auth for client components // Auth for client components
export const auth = () => getServerSession(authOptions); export const auth = () => getServerSession(authOptions);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

@@ -2,74 +2,92 @@ import { type Config } from "tailwindcss";
import { fontFamily } from "tailwindcss/defaultTheme"; import { fontFamily } from "tailwindcss/defaultTheme";
export default { export default {
darkMode: ["class"], darkMode: ["class"],
content: ["./src/**/*.tsx"], content: ["./src/**/*.{js,jsx,ts,tsx}", "./src/**/*.{md,mdx}"],
theme: { theme: {
extend: { container: {
fontFamily: { center: true,
sans: [ padding: "2rem",
'var(--font-geist-sans)', screens: {
...fontFamily.sans "2xl": "1400px",
] },
}, },
borderRadius: { extend: {
lg: 'var(--radius)', fontFamily: {
md: 'calc(var(--radius) - 2px)', sans: ["var(--font-geist-sans)", ...fontFamily.sans],
sm: 'calc(var(--radius) - 4px)' },
}, borderRadius: {
colors: { lg: "var(--radius)",
background: 'hsl(var(--background))', md: "calc(var(--radius) - 2px)",
foreground: 'hsl(var(--foreground))', sm: "calc(var(--radius) - 4px)",
card: { },
DEFAULT: 'hsl(var(--card))', colors: {
foreground: 'hsl(var(--card-foreground))' background: "hsl(var(--background))",
}, foreground: "hsl(var(--foreground))",
popover: { card: {
DEFAULT: 'hsl(var(--popover))', DEFAULT: "hsl(var(--card))",
foreground: 'hsl(var(--popover-foreground))' foreground: "hsl(var(--card-foreground))",
}, },
primary: { popover: {
DEFAULT: 'hsl(var(--primary))', DEFAULT: "hsl(var(--popover))",
foreground: 'hsl(var(--primary-foreground))' foreground: "hsl(var(--popover-foreground))",
}, },
secondary: { primary: {
DEFAULT: 'hsl(var(--secondary))', DEFAULT: "hsl(var(--primary))",
foreground: 'hsl(var(--secondary-foreground))' foreground: "hsl(var(--primary-foreground))",
}, },
muted: { secondary: {
DEFAULT: 'hsl(var(--muted))', DEFAULT: "hsl(var(--secondary))",
foreground: 'hsl(var(--muted-foreground))' foreground: "hsl(var(--secondary-foreground))",
}, },
accent: { muted: {
DEFAULT: 'hsl(var(--accent))', DEFAULT: "hsl(var(--muted))",
foreground: 'hsl(var(--accent-foreground))' foreground: "hsl(var(--muted-foreground))",
}, },
destructive: { accent: {
DEFAULT: 'hsl(var(--destructive))', DEFAULT: "hsl(var(--accent))",
foreground: 'hsl(var(--destructive-foreground))' foreground: "hsl(var(--accent-foreground))",
}, },
border: 'hsl(var(--border))', destructive: {
input: 'hsl(var(--input))', DEFAULT: "hsl(var(--destructive))",
ring: 'hsl(var(--ring))', foreground: "hsl(var(--destructive-foreground))",
chart: { },
'1': 'hsl(var(--chart-1))', border: "hsl(var(--border))",
'2': 'hsl(var(--chart-2))', input: "hsl(var(--input))",
'3': 'hsl(var(--chart-3))', ring: "hsl(var(--ring))",
'4': 'hsl(var(--chart-4))', chart: {
'5': 'hsl(var(--chart-5))' "1": "hsl(var(--chart-1))",
}, "2": "hsl(var(--chart-2))",
sidebar: { "3": "hsl(var(--chart-3))",
DEFAULT: 'hsl(var(--sidebar-background))', "4": "hsl(var(--chart-4))",
foreground: 'hsl(var(--sidebar-foreground))', "5": "hsl(var(--chart-5))",
primary: 'hsl(var(--sidebar-primary))', },
'primary-foreground': 'hsl(var(--sidebar-primary-foreground))', sidebar: {
accent: 'hsl(var(--sidebar-accent))', DEFAULT: "hsl(var(--sidebar-background))",
'accent-foreground': 'hsl(var(--sidebar-accent-foreground))', foreground: "hsl(var(--sidebar-foreground))",
border: 'hsl(var(--sidebar-border))', primary: "hsl(var(--sidebar-primary))",
ring: 'hsl(var(--sidebar-ring))' "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")], plugins: [require("tailwindcss-animate")],
} satisfies Config; } satisfies Config;

View File

@@ -1 +0,0 @@
test